@emdzej/itw-decoder 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,323 @@
1
+ "use strict";
2
+ /**
3
+ * Copyright (c) 2026 Michał Jaskólski
4
+ *
5
+ * This source code is licensed under the PolyForm Noncommercial License 1.0.0
6
+ * found in the LICENSE file in the root directory of this repository.
7
+ * https://polyformproject.org/licenses/noncommercial/1.0.0
8
+ */
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.decode0400 = decode0400;
11
+ /**
12
+ * ITW 0x0400 (entropy) decoder.
13
+ *
14
+ * Faithful port of the original pipeline from Ghidra decompilation:
15
+ * FUN_004b5a40 — read tables from file
16
+ * FUN_004b6340 — parse Huffman leaf table from byte array
17
+ * FUN_004b6570 — build Huffman tree by weight (float comparison!)
18
+ * FUN_004b6250 — Huffman decode: consume bytes LSB-first, emit symbols
19
+ * FUN_004b57f0 — main 0x0400 pipeline: interleave + expand
20
+ * FUN_004b5c40 — build powers-of-two table (depth=8)
21
+ * FUN_004b5d20 — expand: literal copy then RLE with powers table
22
+ */
23
+ const itw_1 = require("./itw");
24
+ // ─── Data structures ────────────────────────────────────────────────
25
+ // ByteStack mirrors FUN_004b67d0 / FUN_004b6890:
26
+ // field[0] = count, field[3] = data pointer (byte array)
27
+ class ByteStack {
28
+ constructor() {
29
+ this.data = [];
30
+ }
31
+ get count() { return this.data.length; }
32
+ push(v) { this.data.push(v & 0xff); }
33
+ at(i) {
34
+ if (i < 0 || i >= this.data.length)
35
+ throw new itw_1.ITWError(`ByteStack out of bounds: ${i} (len=${this.data.length})`);
36
+ return this.data[i];
37
+ }
38
+ }
39
+ // IntStack mirrors FUN_004b6940 / FUN_004b6a10:
40
+ // field[0] = count, field[3] = data pointer (int32 array)
41
+ class IntStack {
42
+ constructor() {
43
+ this.data = [];
44
+ }
45
+ get count() { return this.data.length; }
46
+ push(v) { this.data.push(v); }
47
+ at(i) {
48
+ if (i < 0 || i >= this.data.length)
49
+ throw new itw_1.ITWError(`IntStack out of bounds: ${i} (len=${this.data.length})`);
50
+ return this.data[i];
51
+ }
52
+ }
53
+ // ─── FUN_004b5a40: Read payload tables from buffer ──────────────────
54
+ function readTables(buf, off) {
55
+ const intArr = new IntStack(); // DAT_00580020
56
+ const tableB = new ByteStack(); // DAT_00580014
57
+ const tableC = new ByteStack(); // DAT_0058001c
58
+ let p = off;
59
+ if (p >= buf.length)
60
+ throw new itw_1.ITWError("payload missing N");
61
+ // fread(&local_1,1,1,param_1); DAT_00580018 = local_1 & 0xff;
62
+ const n = buf[p++];
63
+ // FUN_004b6a10(DAT_00580020, DAT_00580018); — push N itself
64
+ intArr.push(n);
65
+ // then push N byte values as ints
66
+ for (let i = 0; i < n; i++) {
67
+ if (p >= buf.length)
68
+ throw new itw_1.ITWError("table A overruns file");
69
+ intArr.push(buf[p++] & 0xff);
70
+ }
71
+ // len1 = FUN_004b5750(param_1) → BE32 via two BE16
72
+ if (p + 4 > buf.length)
73
+ throw new itw_1.ITWError("missing len1");
74
+ const len1 = (0, itw_1.readBE32From2BE16)(buf, p);
75
+ p += 4;
76
+ if (p + len1 > buf.length)
77
+ throw new itw_1.ITWError("payload length exceeds file size at table B");
78
+ for (let i = 0; i < len1; i++) {
79
+ tableB.push(buf[p++]);
80
+ }
81
+ // len2 = FUN_004b5750(param_1) → BE32 via two BE16
82
+ if (p + 4 > buf.length)
83
+ throw new itw_1.ITWError("missing len2");
84
+ const len2 = (0, itw_1.readBE32From2BE16)(buf, p);
85
+ p += 4;
86
+ if (p + len2 > buf.length)
87
+ throw new itw_1.ITWError("payload length exceeds file size at table C");
88
+ for (let i = 0; i < len2; i++) {
89
+ tableC.push(buf[p++]);
90
+ }
91
+ return { intArr, tableB, tableC, n };
92
+ }
93
+ // ─── FUN_004b6340: Parse Huffman leaf table from ByteStack ──────────
94
+ function parseHuffLeaves(bs) {
95
+ if (bs.count < 4)
96
+ throw new itw_1.ITWError("huff table too small");
97
+ // count assembled as LE32: b0 + b1*256 + b2*65536 + b3*16M
98
+ const count = bs.at(0) + bs.at(1) * 0x100 + bs.at(2) * 0x10000 + bs.at(3) * 0x1000000;
99
+ let p = 4;
100
+ const leaves = [];
101
+ for (let i = 0; i < count; i++) {
102
+ if (p + 8 > bs.count)
103
+ throw new itw_1.ITWError("huff leaf overrun");
104
+ // byte at p+0 = symbol (stored to node+2, but read from record[0])
105
+ const symbol = bs.at(p);
106
+ // bytes p+4..p+7 = weight assembled LE, interpreted as IEEE 754 float
107
+ const weightBits = (bs.at(p + 4) | (bs.at(p + 5) << 8) |
108
+ (bs.at(p + 6) << 16) | (bs.at(p + 7) << 24)) >>> 0;
109
+ const floatBuf = new ArrayBuffer(4);
110
+ new DataView(floatBuf).setUint32(0, weightBits, true);
111
+ const weight = new DataView(floatBuf).getFloat32(0, true);
112
+ leaves.push({
113
+ isLeaf: 1,
114
+ symbol,
115
+ id: -1,
116
+ leftId: -1,
117
+ rightId: -1,
118
+ parentId: -1,
119
+ weight,
120
+ });
121
+ p += 8;
122
+ }
123
+ if (p + 4 > bs.count)
124
+ throw new itw_1.ITWError("missing trailing huff value");
125
+ const trailingVal = bs.at(p) + bs.at(p + 1) * 0x100 + bs.at(p + 2) * 0x10000 + bs.at(p + 3) * 0x1000000;
126
+ p += 4;
127
+ return { leaves, trailingVal, bitstreamStart: p };
128
+ }
129
+ // ─── FUN_004b6570 + FUN_004b60c0: Build Huffman tree ────────────────
130
+ function buildHuffTree(leaves) {
131
+ if (leaves.length === 0)
132
+ throw new itw_1.ITWError("empty huff table");
133
+ const nodes = [];
134
+ let nextId = 0;
135
+ // Assign IDs to leaves
136
+ for (const leaf of leaves) {
137
+ leaf.id = nextId++;
138
+ nodes.push(leaf);
139
+ }
140
+ // Priority queue: indices to merge
141
+ let queue = leaves.map(l => l.id);
142
+ while (queue.length > 1) {
143
+ // Sort by weight ascending (float comparison as in original FUN_004b60c0)
144
+ queue.sort((a, b) => nodes[a].weight - nodes[b].weight);
145
+ const leftId = queue.shift();
146
+ const rightId = queue.shift();
147
+ const left = nodes[leftId];
148
+ const right = nodes[rightId];
149
+ const parentNode = {
150
+ isLeaf: 0,
151
+ symbol: 0,
152
+ id: nextId++,
153
+ leftId: left.id,
154
+ rightId: right.id,
155
+ parentId: -1,
156
+ weight: left.weight + right.weight,
157
+ };
158
+ left.parentId = parentNode.id;
159
+ right.parentId = parentNode.id;
160
+ nodes.push(parentNode);
161
+ queue.push(parentNode.id);
162
+ }
163
+ // Root
164
+ const rootId = queue[0];
165
+ nodes[rootId].parentId = -1;
166
+ return nodes;
167
+ }
168
+ // ─── FUN_004b6250: Huffman decode from ByteStack ────────────────────
169
+ // Consumes bytes from bitstreamStart..end, 8 bits per byte LSB-first.
170
+ // On leaf, emits symbol to output ByteStack.
171
+ function huffDecode(bs, bitstreamStart, nodes, rootId, maxBits) {
172
+ const out = new ByteStack();
173
+ let curId = rootId;
174
+ let bitsUsed = 0;
175
+ for (let byteIdx = bitstreamStart; byteIdx < bs.count; byteIdx++) {
176
+ let byte = bs.at(byteIdx);
177
+ let bitsInByte = 8;
178
+ while (bitsInByte > 0) {
179
+ if (bitsUsed >= maxBits)
180
+ return out;
181
+ bitsInByte--;
182
+ bitsUsed++;
183
+ const node = nodes[curId];
184
+ // bit 0 → left (+8 in original), bit 1 → right (+0xC)
185
+ if ((byte & 1) === 0) {
186
+ curId = node.leftId;
187
+ }
188
+ else {
189
+ curId = node.rightId;
190
+ }
191
+ byte = byte >> 1;
192
+ if (curId < 0 || curId >= nodes.length) {
193
+ throw new itw_1.ITWError(`huffman tree traversal error: invalid nodeId ${curId}`);
194
+ }
195
+ const child = nodes[curId];
196
+ if (child.isLeaf !== 0) {
197
+ out.push(child.symbol);
198
+ curId = rootId; // reset to root
199
+ }
200
+ }
201
+ }
202
+ return out;
203
+ }
204
+ // ─── Full Huffman pipeline for one table ────────────────────────────
205
+ function huffDecodeTable(bs) {
206
+ const { leaves, trailingVal, bitstreamStart } = parseHuffLeaves(bs);
207
+ const nodes = buildHuffTree(leaves);
208
+ const rootId = nodes.length - 1; // Root is last node added
209
+ // trailingVal = bit budget (stored at param_1[4] in original, read as LE32)
210
+ return huffDecode(bs, bitstreamStart, nodes, rootId, trailingVal);
211
+ }
212
+ // ─── FUN_004b57f0: Interleave decoded B and C into intArr ───────────
213
+ const DEPTH = 8; // DAT_004ed104 = 8
214
+ function interleave(n, decodedB, decodedC, intArr) {
215
+ let remaining = decodedB.count + decodedC.count;
216
+ let idxB = 0; // ESI — index into decodedB (puVar4) data
217
+ let idxC = 0; // EDI — index into decodedC (puVar5) data
218
+ while (remaining > 0) {
219
+ // Guard: stop if B data is exhausted (original C code has no bounds check —
220
+ // it would read garbage beyond the buffer; we stop gracefully instead)
221
+ if (idxB >= decodedB.count)
222
+ break;
223
+ const bVal = decodedB.at(idxB);
224
+ if (bVal < n + DEPTH) {
225
+ // Push C byte then B byte; consume 2 from remaining
226
+ remaining -= 2;
227
+ // Guard: if C data is exhausted, push 0 (matches original's "read past end" behavior)
228
+ if (idxC < decodedC.count) {
229
+ intArr.push(decodedC.at(idxC));
230
+ }
231
+ else {
232
+ intArr.push(0);
233
+ }
234
+ idxC++;
235
+ intArr.push(bVal);
236
+ }
237
+ else {
238
+ // Push just B byte; consume 1 from remaining
239
+ remaining -= 1;
240
+ intArr.push(bVal);
241
+ }
242
+ idxB++;
243
+ }
244
+ }
245
+ // ─── FUN_004b5d20: Expand intArr via powers table ───────────────────
246
+ function expand(intArr) {
247
+ const out = new ByteStack();
248
+ const data = intArr.data;
249
+ if (data.length === 0)
250
+ throw new itw_1.ITWError("empty intArr for expansion");
251
+ // First word = N (literal count); *param_1 = uVar2
252
+ const literalCount = data[0];
253
+ // Copy next literalCount ints into codebook (param_1[1] array)
254
+ const codebook = [];
255
+ let p = 1;
256
+ for (let i = 0; i < literalCount && p < data.length; i++, p++) {
257
+ codebook.push(data[p]);
258
+ }
259
+ // param_1[3] = depth = 8 (from FUN_004b5c40: *(param_1+0xc) = 8)
260
+ const depth = DEPTH;
261
+ // Process remaining words
262
+ while (p < data.length) {
263
+ const w = data[p];
264
+ if (w < literalCount + depth) {
265
+ // Replicate: times = 2^w, value = codebook[nextWord - depth]
266
+ let times = 1;
267
+ for (let k = 0; k < w; k++)
268
+ times *= 2;
269
+ const idx = data[p + 1];
270
+ p += 2;
271
+ // Value from codebook via: *(*(param_1[1]+0xc) + (idx - depth) * 4)
272
+ // param_1[1] is the int-stack whose data array is codebook
273
+ const cbIdx = idx - depth;
274
+ if (cbIdx < 0 || cbIdx >= codebook.length) {
275
+ throw new itw_1.ITWError(`codebook index ${cbIdx} out of range (size ${codebook.length})`);
276
+ }
277
+ const val = codebook[cbIdx];
278
+ for (let t = 0; t < times; t++) {
279
+ out.push(val);
280
+ }
281
+ }
282
+ else {
283
+ // Single value: codebook[(w - literalCount) - depth]
284
+ p += 1;
285
+ const cbIdx = (w - literalCount) - depth;
286
+ if (cbIdx < 0 || cbIdx >= codebook.length) {
287
+ throw new itw_1.ITWError(`codebook index ${cbIdx} out of range (size ${codebook.length})`);
288
+ }
289
+ out.push(codebook[cbIdx]);
290
+ }
291
+ }
292
+ return out;
293
+ }
294
+ // ─── Main entry point ───────────────────────────────────────────────
295
+ function decode0400(buf, payloadOffset, width, height) {
296
+ // Step 1: Read tables (FUN_004b5a40)
297
+ const { intArr, tableB, tableC, n } = readTables(buf, payloadOffset);
298
+ // Step 2: Huffman decode B and C (FUN_004b6250)
299
+ const decodedB = huffDecodeTable(tableB);
300
+ const decodedC = huffDecodeTable(tableC);
301
+ // Step 3: Interleave decoded B/C into intArr (FUN_004b57f0 loop)
302
+ interleave(n, decodedB, decodedC, intArr);
303
+ // Step 4: Expand via powers table (FUN_004b5c40 + FUN_004b5d20)
304
+ const expanded = expand(intArr);
305
+ // Step 5: Copy to pixel buffer (1-indexed in original: data[idx-1+iVar11])
306
+ // Note: Some files produce slightly fewer pixels than expected due to the
307
+ // original C code's lack of bounds checking in the interleave/expand pipeline.
308
+ // We tolerate a small shortfall (< 1% of total) and zero-fill the remainder.
309
+ const total = width * height;
310
+ if (expanded.count < total) {
311
+ const shortfall = total - expanded.count;
312
+ const shortfallPct = (shortfall / total) * 100;
313
+ if (shortfallPct > 1) {
314
+ throw new itw_1.ITWError(`decoded pixel buffer too small: got ${expanded.count}, need ${total} (${shortfallPct.toFixed(1)}% short)`);
315
+ }
316
+ }
317
+ const pixels = new Uint8Array(total); // zero-filled by default
318
+ const copyLen = Math.min(expanded.count, total);
319
+ for (let i = 0; i < copyLen; i++) {
320
+ pixels[i] = expanded.at(i);
321
+ }
322
+ return { width, height, pixels };
323
+ }
package/dist/index.js ADDED
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ /**
4
+ * Copyright (c) 2026 Michał Jaskólski
5
+ *
6
+ * This source code is licensed under the PolyForm Noncommercial License 1.0.0
7
+ * found in the LICENSE file in the root directory of this repository.
8
+ * https://polyformproject.org/licenses/noncommercial/1.0.0
9
+ */
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ const fs_1 = require("fs");
12
+ const path_1 = require("path");
13
+ const commander_1 = require("commander");
14
+ const itw_1 = require("./itw");
15
+ const decode0400_1 = require("./decode0400");
16
+ const decode0300_1 = require("./decode0300");
17
+ const png_1 = require("./png");
18
+ const module_1 = require("module");
19
+ // resolve package.json relative to this file at runtime (works for both ts-node and compiled dist/)
20
+ const _require = (0, module_1.createRequire)(__filename);
21
+ const { version } = _require("../package.json");
22
+ const program = new commander_1.Command();
23
+ program
24
+ .name("itw-decode")
25
+ .description("Decode BMW TIS .ITW proprietary image files to PNG")
26
+ .version(version, "-V, --version", "output the current version")
27
+ .argument("<input>", "path to the .ITW file to decode")
28
+ .option("-o, --output <file>", "output PNG path (default: <input>.png)")
29
+ .option("-d, --dir <directory>", "output directory (default: current working directory)")
30
+ .action(async (input, options) => {
31
+ const inputPath = (0, path_1.resolve)(input);
32
+ const defaultName = (0, path_1.basename)(inputPath).replace(/\.itw$/i, "") + ".png";
33
+ const outputPath = options.output
34
+ ? (0, path_1.resolve)(options.output)
35
+ : (0, path_1.join)((0, path_1.resolve)(options.dir ?? process.cwd()), defaultName);
36
+ const buf = (0, fs_1.readFileSync)(inputPath);
37
+ const { header, payloadOffset } = (0, itw_1.parseHeader)(buf);
38
+ let result;
39
+ if (header.subtype === 0x0300) {
40
+ result = (0, decode0300_1.decode0300)(buf, payloadOffset, header.width, header.height);
41
+ }
42
+ else {
43
+ result = (0, decode0400_1.decode0400)(buf, payloadOffset, header.width, header.height);
44
+ }
45
+ await (0, png_1.writePng)(outputPath, result.pixels, result.width, result.height);
46
+ console.error(`wrote ${outputPath} (${result.width}x${result.height})`);
47
+ });
48
+ program.parseAsync(process.argv).catch((err) => {
49
+ if (err instanceof itw_1.ITWError) {
50
+ console.error(`error: ${err.message}`);
51
+ }
52
+ else {
53
+ console.error(err);
54
+ }
55
+ process.exit(1);
56
+ });
package/dist/itw.js ADDED
@@ -0,0 +1,59 @@
1
+ "use strict";
2
+ /**
3
+ * Copyright (c) 2026 Michał Jaskólski
4
+ *
5
+ * This source code is licensed under the PolyForm Noncommercial License 1.0.0
6
+ * found in the LICENSE file in the root directory of this repository.
7
+ * https://polyformproject.org/licenses/noncommercial/1.0.0
8
+ */
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.ITWError = void 0;
11
+ exports.readBE16 = readBE16;
12
+ exports.readBE32From2BE16 = readBE32From2BE16;
13
+ exports.readLE32 = readLE32;
14
+ exports.parseHeader = parseHeader;
15
+ class ITWError extends Error {
16
+ constructor(message) {
17
+ super(message);
18
+ this.name = "ITWError";
19
+ }
20
+ }
21
+ exports.ITWError = ITWError;
22
+ function readBE16(buf, off) {
23
+ if (off + 2 > buf.length)
24
+ throw new ITWError(`readBE16 out of range @${off}`);
25
+ return (buf[off] << 8) | buf[off + 1];
26
+ }
27
+ function readBE32From2BE16(buf, off) {
28
+ const hi = readBE16(buf, off);
29
+ const lo = readBE16(buf, off + 2);
30
+ return (hi << 16) | lo;
31
+ }
32
+ function readLE32(buf, off) {
33
+ if (off + 4 > buf.length)
34
+ throw new ITWError(`readLE32 out of range @${off}`);
35
+ return buf[off] | (buf[off + 1] << 8) | (buf[off + 2] << 16) | (buf[off + 3] << 24);
36
+ }
37
+ function parseHeader(buf) {
38
+ // Header layout (from Ghidra FUN_004b5680 + FUN_004b5780):
39
+ // 6 x BE16 in FUN_004b5680: magic(2), magic(2), version, width, height, bpp
40
+ // 1 x BE16 in FUN_004b5780: subtype
41
+ // Total = 7 x BE16 = 14 bytes
42
+ if (buf.length < 14)
43
+ throw new ITWError("file too small for header");
44
+ const magic = String.fromCharCode(buf[0], buf[1], buf[2], buf[3]);
45
+ if (magic !== "ITW_")
46
+ throw new ITWError("bad magic");
47
+ const version = readBE16(buf, 4);
48
+ const width = readBE16(buf, 6);
49
+ const height = readBE16(buf, 8);
50
+ const bpp = readBE16(buf, 0x0a);
51
+ const subtype = readBE16(buf, 0x0c);
52
+ if (version !== 0x0100 && version !== 0x0200)
53
+ throw new ITWError(`unsupported version 0x${version.toString(16)}`);
54
+ if (subtype !== 0x0300 && subtype !== 0x0400)
55
+ throw new ITWError(`unsupported subtype 0x${subtype.toString(16)}`);
56
+ if (bpp !== 8)
57
+ throw new ITWError(`unsupported bpp ${bpp}`);
58
+ return { header: { version, width, height, bpp, subtype }, payloadOffset: 14 };
59
+ }
package/dist/png.js ADDED
@@ -0,0 +1,35 @@
1
+ "use strict";
2
+ /**
3
+ * Copyright (c) 2026 Michał Jaskólski
4
+ *
5
+ * This source code is licensed under the PolyForm Noncommercial License 1.0.0
6
+ * found in the LICENSE file in the root directory of this repository.
7
+ * https://polyformproject.org/licenses/noncommercial/1.0.0
8
+ */
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.grayToRgba = grayToRgba;
11
+ exports.writePng = writePng;
12
+ const pngjs_1 = require("pngjs");
13
+ const fs_1 = require("fs");
14
+ function grayToRgba(gray, width, height) {
15
+ const out = new Uint8Array(width * height * 4);
16
+ for (let i = 0, j = 0; i < gray.length; i++, j += 4) {
17
+ const v = gray[i];
18
+ out[j] = v;
19
+ out[j + 1] = v;
20
+ out[j + 2] = v;
21
+ out[j + 3] = 255;
22
+ }
23
+ return out;
24
+ }
25
+ async function writePng(path, gray, width, height) {
26
+ const png = new pngjs_1.PNG({ width, height, colorType: 6 });
27
+ png.data = Buffer.from(grayToRgba(gray, width, height));
28
+ await new Promise((resolve, reject) => {
29
+ png
30
+ .pack()
31
+ .pipe((0, fs_1.createWriteStream)(path))
32
+ .on("finish", () => resolve())
33
+ .on("error", reject);
34
+ });
35
+ }
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@emdzej/itw-decoder",
3
+ "version": "0.1.0",
4
+ "type": "commonjs",
5
+ "description": "Decode BMW TIS .ITW proprietary image files to PNG",
6
+ "license": "PolyForm-Noncommercial-1.0.0",
7
+ "main": "dist/index.js",
8
+ "bin": {
9
+ "itw-decode": "dist/index.js"
10
+ },
11
+ "files": [
12
+ "dist"
13
+ ],
14
+ "scripts": {
15
+ "build": "tsc",
16
+ "start": "node dist/index.js",
17
+ "dev": "ts-node src/index.ts",
18
+ "decode": "ts-node src/index.ts"
19
+ },
20
+ "dependencies": {
21
+ "commander": "^14.0.3",
22
+ "pngjs": "^7.0.0"
23
+ },
24
+ "devDependencies": {
25
+ "@types/node": "^20.11.0",
26
+ "@types/pngjs": "^6.0.4",
27
+ "ts-node": "^10.9.2",
28
+ "typescript": "^5.4.0"
29
+ }
30
+ }