@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,1380 @@
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.decode0300 = decode0300;
11
+ const zlib_1 = require("zlib");
12
+ const itw_1 = require("./itw");
13
+ // ─── Global constants (from Ghidra data section) ────────────────────────────
14
+ const DAT_004ed190 = 0.5; // double: center = (range + min) * 0.5
15
+ const DAT_004ed198 = 1.0 / 127; // double: LL band scale (0x3f80204081020408 = 1/127)
16
+ const DAT_004ed1a0 = 127.0; // double: LL band offset (subtract from byte)
17
+ const DAT_004ed1f0 = 32.0; // float: Q15-ish divisor (0x42000000 = 32.0)
18
+ const DAT_004ed1d0 = 16.0; // double: level_scale_factor base
19
+ const DAT_004ed1d8 = 1.0 / 16; // double: level_scale_factor multiplier
20
+ const DAT_004ed130 = 0.125; // float: bits-to-bytes (1/8)
21
+ const DAT_004ed118 = 0x80; // int: extra bits flag mask
22
+ const DAT_004ed11c = 5; // int: buffer size multiplier
23
+ // ─── Cursor: tracks read position into the payload ─────────────────────────
24
+ class Cursor {
25
+ constructor(buf, offset) {
26
+ this.buf = buf;
27
+ this.pos = offset;
28
+ }
29
+ readByte() {
30
+ if (this.pos >= this.buf.length)
31
+ throw new itw_1.ITWError("cursor overrun");
32
+ return this.buf[this.pos++];
33
+ }
34
+ readBE16() {
35
+ const v = (0, itw_1.readBE16)(this.buf, this.pos);
36
+ this.pos += 2;
37
+ return v;
38
+ }
39
+ readBE32() {
40
+ const v = (0, itw_1.readBE32From2BE16)(this.buf, this.pos);
41
+ this.pos += 4;
42
+ return v;
43
+ }
44
+ /** Return a subarray from current position onward */
45
+ remaining() { return this.buf.subarray(this.pos); }
46
+ /** zlib inflate: reads BE16 compressed length, then inflates into destSize buffer (zero-padded) */
47
+ copyStreamData(destSize) {
48
+ const compLen = this.readBE16();
49
+ const compressed = this.buf.subarray(this.pos, this.pos + compLen);
50
+ this.pos += compLen;
51
+ const inflated = (0, zlib_1.inflateSync)(compressed, { maxOutputLength: destSize });
52
+ // Zero-pad to destSize (matching C's calloc behavior)
53
+ const result = new Uint8Array(destSize);
54
+ result.set(new Uint8Array(inflated.buffer, inflated.byteOffset, inflated.byteLength));
55
+ return result;
56
+ }
57
+ }
58
+ // ─── Bitstream reader (LSB-first, matching Ghidra's read_bits @ 004bc220) ───
59
+ // The C code reads bits from the LOW bit of each byte first, and accumulates
60
+ // the multi-bit result with the first-read bit as bit 0 (LSB).
61
+ class Bitstream {
62
+ constructor(data) {
63
+ this.byteIdx = 0;
64
+ this.bitIdx = 0; // 0..7 within current byte
65
+ this.curByte = 0;
66
+ this.data = data;
67
+ // Match C: first byte is loaded on first read_bit call (when bitIdx==0)
68
+ }
69
+ /** Read a single bit (LSB-first from each byte) */
70
+ readBit() {
71
+ if (this.bitIdx === 0) {
72
+ this.curByte = this.byteIdx < this.data.length ? this.data[this.byteIdx] : 0;
73
+ }
74
+ const bit = this.curByte & 1;
75
+ this.curByte >>= 1;
76
+ this.bitIdx++;
77
+ if (this.bitIdx === 8) {
78
+ this.bitIdx = 0;
79
+ this.byteIdx++;
80
+ }
81
+ return bit;
82
+ }
83
+ /** Read n bits, LSB-first: first bit read → bit 0 of result */
84
+ readBits(n) {
85
+ let val = 0;
86
+ let mask = 1;
87
+ for (let i = 0; i < n; i++) {
88
+ if (this.readBit()) {
89
+ val |= mask;
90
+ }
91
+ mask <<= 1;
92
+ }
93
+ return val;
94
+ }
95
+ /** Return current byte-aligned position (for cursor tracking).
96
+ * Matches C's bitstream_finish: if bitIdx==0 return byteIdx, else byteIdx+1 */
97
+ get bytePos() {
98
+ return this.bitIdx === 0 ? this.byteIdx : this.byteIdx + 1;
99
+ }
100
+ }
101
+ // ─── Q15 ─────────────────────────────────────────────────────────────────
102
+ function q15ToFloat(v) {
103
+ // v is a signed 16-bit int
104
+ const s = (v << 16) >> 16; // sign extend
105
+ return s / DAT_004ed1f0;
106
+ }
107
+ // ─── Wavelet filter coefficients ────────────────────────────────────────────
108
+ // Convert IEEE754 hex to float
109
+ function hexToFloat(h) {
110
+ const buf = new ArrayBuffer(4);
111
+ new DataView(buf).setInt32(0, h);
112
+ return new DataView(buf).getFloat32(0);
113
+ }
114
+ // Filter type 0: CDF 9/7 biorthogonal
115
+ const ANALYSIS_LOW_97 = [
116
+ 0x3d5889c7, 0xbd08e1cf, 0xbdbe9b19, 0x3ec6212d,
117
+ // center is implicit (the filter is symmetric, stored as half-filter)
118
+ // Actually, from Ghidra the 8 taps plus implicit center:
119
+ // local_24..local_4 = 8 coefficients, center is param_2[3] (center offset)
120
+ ].map(hexToFloat);
121
+ // Actually, from Ghidra wavelet_init_filters, for filter type 0:
122
+ // Analysis lowpass (set on param_2): local_24 array (8 elements, 9-tap symmetric)
123
+ const FILTER0_ANALYSIS_LOW = [
124
+ hexToFloat(0x3d5889c7), // 0.052861...
125
+ hexToFloat(0xbd08e1cf), // -0.033477...
126
+ hexToFloat(0xbdbe9b19), // -0.093057...
127
+ hexToFloat(0x3ec6212d), // 0.386942...
128
+ // center coefficient is implicit at index=4, but the filter has 9 taps
129
+ // mirror is: [idx=8]=local_4, [7]=local_8, ... [1]=local_20, [0]=local_24
130
+ // local_24=0x3d5889c7, local_20=0xbd08e1cf, local_1c=0xbdbe9b19, local_18=0x3ec6212d
131
+ // [center], local_10=0x3ec6212d, local_c=0xbdbe9b19, local_8=0xbd08e1cf, local_4=0x3d5889c7
132
+ ];
133
+ // The filter_set_coeffs copies the local array into the filter's coeff array.
134
+ // For a 9-tap filter with center at index 4:
135
+ const F0_AL = [
136
+ hexToFloat(0x3d5889c7),
137
+ hexToFloat(0xbd08e1cf),
138
+ hexToFloat(0xbdbe9b19),
139
+ hexToFloat(0x3ec6212d),
140
+ NaN, // center — needs to be computed as (1 - 2*(sum_of_above))
141
+ hexToFloat(0x3ec6212d),
142
+ hexToFloat(0xbdbe9b19),
143
+ hexToFloat(0xbd08e1cf),
144
+ hexToFloat(0x3d5889c7),
145
+ ];
146
+ // Actually, rethinking: The filter has exactly 8 coefficients stored in local_24..local_4,
147
+ // The filter length is 9 (for low) and 7 (for high) based on wavelet_read_filter_type.
148
+ // But 8 values were stored. The 9th is the center which must be computed.
149
+ // Wait - let's look again. local_24 through local_4 = 8 dwords = 8 coefficients.
150
+ // But the filter length from wavelet_read_filter_type for type 0 is: low=9, high=7.
151
+ // So 8 stored + center (computed from normalization) = 9 total.
152
+ // Actually, looking more carefully: these 8 values are the full 8-tap non-center
153
+ // coefficients of the 9-tap filter. The center is derived.
154
+ // For CDF 9/7: known coefficients...
155
+ // Let me just use the known CDF 9/7 values instead:
156
+ // CDF 9/7 analysis lowpass (9 taps):
157
+ // 0.026749, -0.016864, -0.078223, 0.266864, 0.602949, 0.266864, -0.078223, -0.016864, 0.026749
158
+ // But wait, the hex values give us slightly different numbers. Let me just use the hex-derived values.
159
+ // Recomputing from hex:
160
+ // 0x3d5889c7 = 0.05286135
161
+ // 0xbd08e1cf = -0.03347732
162
+ // 0xbdbe9b19 = -0.09305732
163
+ // 0x3ec6212d = 0.38694268
164
+ // These are the 4 non-center coefficients on one side (symmetric).
165
+ // The center must make the sum = 1 (for lowpass):
166
+ // sum_sides = 2 * (0.05286135 - 0.03347732 - 0.09305732 + 0.38694268) = 2 * 0.31326939 = 0.62653878
167
+ // center = 1 - 0.62653878 = 0.37346122
168
+ // Hmm, but this doesn't match standard CDF 9/7. Let me just trust the data.
169
+ // Actually, looking at the filter structure more carefully:
170
+ // filter_alloc(NULL, length, parity) where parity affects the center index.
171
+ // For type 0: AL length=9 parity=0, AH length=7 parity=0, SL length=7 parity=-1, SH length=9 parity=1
172
+ // The filter's center index = floor(length/2) + parity_offset
173
+ // For length 9, parity 0: center = 4 (0-indexed)
174
+ // filter_set_coeffs copies from the local array into filter[4..4+center] etc.
175
+ // Actually I realize: the 8 stored values in local_24..local_4 are ALL 8 non-center taps.
176
+ // The center coefficient is stored at local_14 = 0x3f499a81.
177
+ // Wait no, local_14 is used for filter type 1 (the 7/5 case).
178
+ // Let me re-read the decompiled code more carefully. filter_set_coeffs takes the address
179
+ // of local_24 (for type 0 low) or local_5c (for type 0 high).
180
+ // For the lowpass, local_24..local_4 = 8 values.
181
+ // But length = 9. So there must be a 9th value somewhere.
182
+ // Looking at the local variables: local_14 = 0x3f499a81
183
+ // But local_14 is between the filter arrays... Actually local_24 through local_4
184
+ // are at offsets 0x24, 0x20, 0x1c, 0x18, 0x14, 0x10, 0x0c, 0x08, 0x04
185
+ // Wait - that's 9 values! local_24 = index0, local_20 = index1, ..., local_04 = index8
186
+ // But in the decompilation only 8 are assigned:
187
+ // local_24, local_20, local_1c, local_18, (missing local_14), local_10, local_c, local_8, local_4
188
+ // local_14 = 0x3f499a81 is assigned! It's the center coefficient!
189
+ // So the 9 coefficients of analysis lowpass (type 0) are:
190
+ // local_24=0x3d5889c7, local_20=0xbd08e1cf, local_1c=0xbdbe9b19, local_18=0x3ec6212d,
191
+ // local_14=0x3f499a81, local_10=0x3ec6212d, local_c=0xbdbe9b19, local_8=0xbd08e1cf, local_4=0x3d5889c7
192
+ // Wait, let's check: is local_14 used for type 0 or type 1?
193
+ // In the decompiled code:
194
+ // local_14 = 0x3f499a81; (assigned unconditionally at top of function)
195
+ // For type 0: filter_set_coeffs(&local_24, param_2) → copies starting from local_24
196
+ // For type 1: filter_set_coeffs(&local_40, param_2), filter_set_coeffs(&local_70, param_4)
197
+ //
198
+ // local_24 through local_4 means bytes at [ebp-0x24] through [ebp-0x4].
199
+ // The contiguous block from [ebp-0x24] to [ebp-0x04] is 0x24-0x04=0x20=32 bytes,
200
+ // but with 4-byte alignment: [ebp-0x24], [ebp-0x20], [ebp-0x1c], [ebp-0x18],
201
+ // [ebp-0x14], [ebp-0x10], [ebp-0x0c], [ebp-0x08], [ebp-0x04] = 9 float values!
202
+ // So local_14 IS the center coefficient of the analysis lowpass filter!
203
+ // OK but local_14 was also assigned differently in type 1 section...
204
+ // Looking at assignments: local_14 = 0x3f499a81 is set at the top (for type 0 center)
205
+ // But local_50 = 0x3f511889 is also set. Let me check what local_50 is.
206
+ // For analysis high (type 0): local_5c..local_44
207
+ // local_5c=0xbdb1a91a, local_58=0xbd609caf, local_54=0x3ee16f3a, local_50=0x3f511889
208
+ // local_4c=0x3ee16f3a, local_48=0xbd609caf, local_44=0xbdb1a91a
209
+ // That's 7 values at local_5c through local_44. Perfect for the 7-tap high filter.
210
+ // But wait: local_5c, local_58, local_54, local_50, local_4c, local_48, local_44 = 7 values.
211
+ // So: analysis lowpass = 9 taps: local_24(0x3d5889c7), local_20(0xbd08e1cf), local_1c(0xbdbe9b19),
212
+ // local_18(0x3ec6212d), local_14(0x3f499a81), local_10(0x3ec6212d), local_c(0xbdbe9b19),
213
+ // local_8(0xbd08e1cf), local_4(0x3d5889c7)
214
+ // Analysis highpass = 7 taps: local_5c(0xbdb1a91a), local_58(0xbd609caf), local_54(0x3ee16f3a),
215
+ // local_50(0x3f511889), local_4c(0x3ee16f3a), local_48(0xbd609caf), local_44(0xbdb1a91a)
216
+ // For filter type 1 (7/5 biorthogonal):
217
+ // Analysis lowpass = 7 taps: local_40..local_28
218
+ // local_40=0xbc2f8af9, local_3c=0xbd5b6db7, local_38=0x3e857c58, local_34=0x3f1b6db7,
219
+ // local_30=0x3e857c58, local_2c=0xbd5b6db7, local_28=0xbc2f8af9
220
+ // Analysis highpass = 5 taps: local_70..local_60
221
+ // local_70=0xbd4ccccd, local_6c=0x3e800000, local_68=0x3f19999a,
222
+ // local_64=0x3e800000, local_60=0xbd4ccccd
223
+ function buildFilterCoeffs(filterType) {
224
+ if (filterType === 0) {
225
+ // CDF 9/7
226
+ return {
227
+ analysisLowLen: 9,
228
+ analysisLowCoeffs: [
229
+ hexToFloat(0x3d5889c7), hexToFloat(0xbd08e1cf), hexToFloat(0xbdbe9b19),
230
+ hexToFloat(0x3ec6212d), hexToFloat(0x3f499a81), hexToFloat(0x3ec6212d),
231
+ hexToFloat(0xbdbe9b19), hexToFloat(0xbd08e1cf), hexToFloat(0x3d5889c7),
232
+ ],
233
+ analysisHighLen: 7,
234
+ analysisHighCoeffs: [
235
+ hexToFloat(0xbdb1a91a), hexToFloat(0xbd609caf), hexToFloat(0x3ee16f3a),
236
+ hexToFloat(0x3f511889), hexToFloat(0x3ee16f3a), hexToFloat(0xbd609caf),
237
+ hexToFloat(0xbdb1a91a),
238
+ ],
239
+ };
240
+ }
241
+ else if (filterType === 1) {
242
+ // 7/5 biorthogonal, scaled by sqrt(2)
243
+ const s = Math.sqrt(2.0);
244
+ return {
245
+ analysisLowLen: 7,
246
+ analysisLowCoeffs: [
247
+ hexToFloat(0xbc2f8af9) * s, hexToFloat(0xbd5b6db7) * s, hexToFloat(0x3e857c58) * s,
248
+ hexToFloat(0x3f1b6db7) * s, hexToFloat(0x3e857c58) * s, hexToFloat(0xbd5b6db7) * s,
249
+ hexToFloat(0xbc2f8af9) * s,
250
+ ],
251
+ analysisHighLen: 5,
252
+ analysisHighCoeffs: [
253
+ hexToFloat(0xbd4ccccd) * s, hexToFloat(0x3e800000) * s, hexToFloat(0x3f19999a) * s,
254
+ hexToFloat(0x3e800000) * s, hexToFloat(0xbd4ccccd) * s,
255
+ ],
256
+ };
257
+ }
258
+ else {
259
+ throw new itw_1.ITWError(`unsupported filter type ${filterType}`);
260
+ }
261
+ }
262
+ // Derive mirror filter (for synthesis from analysis):
263
+ // filter_derive_mirror(center, src, dest):
264
+ // First loop: from center down to 0, sign starts at +1 and alternates
265
+ // Second loop: from center+1 up to len-1, sign restarts at +1 and alternates
266
+ // dest[i] = src[i] * sign (NO reversal — same index for src and dest)
267
+ //
268
+ // Sign pattern for 9-tap (center=4): [+1,-1,+1,-1,+1, +1,-1,+1,-1]
269
+ // Sign pattern for 7-tap (center=3): [-1,+1,-1,+1, +1,-1,+1]
270
+ function deriveMirror(coeffs, center) {
271
+ const len = coeffs.length;
272
+ const result = new Array(len);
273
+ // First loop: center down to 0, sign alternates starting at +1
274
+ let sign = 1;
275
+ for (let i = center; i >= 0; i--) {
276
+ result[i] = coeffs[i] * sign;
277
+ sign = -sign;
278
+ }
279
+ // Second loop: center+1 up to len-1, sign starts at -1
280
+ // (Ghidra's loop restarts at center with +1, flips to -1, then writes center+1 with -1)
281
+ sign = -1;
282
+ for (let i = center + 1; i < len; i++) {
283
+ result[i] = coeffs[i] * sign;
284
+ sign = -sign;
285
+ }
286
+ return result;
287
+ }
288
+ function makeFilter(coeffs, length, parity) {
289
+ return { coeffs, length, center: Math.floor(length / 2), parity };
290
+ }
291
+ function initFilters(filterType) {
292
+ const { analysisLowCoeffs, analysisHighCoeffs, analysisLowLen, analysisHighLen } = buildFilterCoeffs(filterType);
293
+ // From Ghidra wavelet_init_filters:
294
+ // param_2 = analysis low (length = analysisLowLen, parity 0)
295
+ // param_4 = analysis high (length = analysisHighLen, parity 0)
296
+ // param_3 = synthesis low (length = analysisHighLen, parity -1) — derived from analysis high
297
+ // param_5 = synthesis high (length = analysisLowLen, parity 1) — derived from analysis low
298
+ // filter_derive_mirror(center, src, dest):
299
+ const analysisLowCenter = Math.floor(analysisLowLen / 2);
300
+ const analysisHighCenter = Math.floor(analysisHighLen / 2);
301
+ const synthHighCoeffs = deriveMirror(analysisLowCoeffs, analysisLowCenter);
302
+ const synthLowCoeffs = deriveMirror(analysisHighCoeffs, analysisHighCenter);
303
+ // Reconstruction uses: puVar6 (analysis high, parity 0) and puVar7 (synthesis high, parity 1)
304
+ // wavelet_reconstruct_all(puVar6, puVar7, pyramid, image)
305
+ // where puVar6 = filter_alloc(NULL, analysisHighLen, 0) → analysis high
306
+ // puVar7 = filter_alloc(NULL, analysisLowLen, 1) → synthesis high
307
+ const analysisHigh = makeFilter(analysisHighCoeffs, analysisHighLen, 0);
308
+ const synthHigh = makeFilter(synthHighCoeffs, analysisLowLen, 1);
309
+ const synthLow = makeFilter(synthLowCoeffs, analysisHighLen, -1);
310
+ // EXPERIMENT: try synthLow (g0) instead of analysisHigh (h1) as filter1
311
+ // Standard biorthogonal reconstruction uses g0 and g1 (both synthesis filters)
312
+ // TIS.exe appears to use h1 and g1 — but maybe the polyphase structure compensates?
313
+ return { reconstructFilter1: analysisHigh, reconstructFilter2: synthHigh };
314
+ // Alternative: return { reconstructFilter1: synthLow, reconstructFilter2: synthHigh };
315
+ }
316
+ // ─── Fischer rank coding tables ─────────────────────────────────────────────
317
+ // Three tables are used:
318
+ // 1. BASE TABLE: cumT(q, m) = number of signed q-tuples with sum(|xi|) <= m
319
+ // Computed mathematically. Used to build the diff table.
320
+ // 2. RANK TABLE: Hardcoded bit lengths from the binary. Used to determine
321
+ // how many bits to read for each codeword from the bitstream.
322
+ // 3. DIFF TABLE: exact count T(q, m) = base[q][m] - base[q][m-1].
323
+ // Passed to fischerDecode for combinatorial unranking.
324
+ const MAX_Q = 9; // rows 0..8
325
+ const MAX_M_LARGE = 201; // columns for rows 0-4
326
+ const MAX_M_SMALL = 31; // columns for rows 5-8
327
+ function binomial(n, k) {
328
+ if (k < 0 || k > n)
329
+ return 0;
330
+ if (k === 0 || k === n)
331
+ return 1;
332
+ let result = 1;
333
+ for (let i = 0; i < Math.min(k, n - k); i++) {
334
+ result = result * (n - i) / (i + 1);
335
+ }
336
+ return Math.round(result);
337
+ }
338
+ /** Number of signed integer q-tuples with sum of absolute values exactly m */
339
+ function countExact(q, m) {
340
+ if (m === 0)
341
+ return 1;
342
+ let sum = 0;
343
+ for (let j = 1; j <= Math.min(q, m); j++) {
344
+ sum += binomial(q, j) * binomial(m - 1, j - 1) * (1 << j);
345
+ }
346
+ return sum;
347
+ }
348
+ /** Cumulative count: number of signed integer q-tuples with sum of abs values <= m */
349
+ function countCumulative(q, m) {
350
+ let total = 0;
351
+ for (let k = 0; k <= m; k++) {
352
+ total += countExact(q, k);
353
+ }
354
+ return total;
355
+ }
356
+ /**
357
+ * Build the base table (9 × maxM).
358
+ * base_table[q][m] = cumT(q, m) = cumulative count of vectors.
359
+ */
360
+ function buildBaseTable() {
361
+ const table = [];
362
+ for (let q = 0; q < MAX_Q; q++) {
363
+ const maxM = q < 5 ? MAX_M_LARGE : MAX_M_SMALL;
364
+ const row = new Array(maxM).fill(0);
365
+ row[0] = 1;
366
+ if (q === 0) {
367
+ for (let m = 0; m < maxM; m++)
368
+ row[m] = 1;
369
+ }
370
+ else {
371
+ for (let m = 1; m < maxM; m++) {
372
+ row[m] = countCumulative(q, m);
373
+ }
374
+ }
375
+ table.push(row);
376
+ }
377
+ return table;
378
+ }
379
+ /**
380
+ * Build the diff table from the base table.
381
+ * diff[q][m] = base[q][m] - base[q][m-1] = T(q, m) = exact count for magnitude m.
382
+ * diff[q][0] = 1 for all q; diff[0][m>=1] = 0.
383
+ * This table is used by fischerDecode for combinatorial unranking.
384
+ */
385
+ function buildDiffTable(baseTable) {
386
+ const table = [];
387
+ for (let q = 0; q < MAX_Q; q++) {
388
+ const maxM = q < 5 ? MAX_M_LARGE : MAX_M_SMALL;
389
+ const row = new Array(maxM).fill(0);
390
+ row[0] = 1; // T(q, 0) = 1
391
+ if (q === 0) {
392
+ // Row 0: diff[0][0] = 1, diff[0][m>=1] = 0
393
+ }
394
+ else {
395
+ for (let m = 1; m < maxM; m++) {
396
+ row[m] = baseTable[q][m] - baseTable[q][m - 1];
397
+ }
398
+ }
399
+ table.push(row);
400
+ }
401
+ return table;
402
+ }
403
+ // ─── Hardcoded rank table (bit lengths for codewords) ───────────────────────
404
+ // From Ghidra fischer_build_rank_table: 603 hardcoded values.
405
+ // Indexed as: rankTable[quant][magnitude] → number of bits for codeword.
406
+ // Only quant=2 (201 entries), quant=4 (201 entries), quant=8 (31 entries) are stored.
407
+ // prettier-ignore
408
+ const RANK_TABLE_Q2 = [
409
+ 0, 2, 3, 4, 4, 5, 5, 5, 5, 6, 6, 6, 6, 6, 6, 6, 6, 7, 7, 7,
410
+ 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 8, 8, 8, 8, 8, 8, 8,
411
+ 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
412
+ 8, 8, 8, 8, 8, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9,
413
+ 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9,
414
+ 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9,
415
+ 9, 9, 9, 9, 9, 9, 9, 9, 9, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10,
416
+ 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10,
417
+ 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10,
418
+ 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10,
419
+ 0,
420
+ ];
421
+ // prettier-ignore
422
+ const RANK_TABLE_Q4 = [
423
+ 0, 3, 5, 7, 8, 9, 10, 10, 11, 11, 12, 12, 13, 13, 13, 14, 14, 14, 14, 15,
424
+ 15, 15, 15, 15, 16, 16, 16, 16, 16, 16, 17, 17, 17, 17, 17, 17, 17, 18, 18, 18,
425
+ 18, 18, 18, 18, 18, 18, 18, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 20,
426
+ 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 21, 21, 21, 21, 21, 21,
427
+ 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 22, 22, 22, 22, 22, 22, 22,
428
+ 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 23, 23, 23,
429
+ 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23,
430
+ 23, 23, 23, 23, 23, 23, 23, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24,
431
+ 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24,
432
+ 24, 24, 24, 24, 24, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25,
433
+ 0,
434
+ ];
435
+ // prettier-ignore
436
+ const RANK_TABLE_Q8 = [
437
+ 0, 4, 7, 10, 12, 14, 15, 17, 18, 19, 20, 21, 22, 22, 23, 24,
438
+ 24, 25, 26, 26, 27, 27, 27, 28, 28, 29, 29, 30, 30, 30, 0,
439
+ ];
440
+ /**
441
+ * Build the rank table structure from the hardcoded arrays.
442
+ * Maps quant → magnitude → bit length.
443
+ * For quant < 2 (quant=1): bit length is computed as ceil(log2(m*2+1)).
444
+ * For quant not in {2,4,8}: returns 0 (shouldn't occur in practice).
445
+ */
446
+ function buildRankTable() {
447
+ // We need rows for quant=0..8 to match the int_array(9, 0xC9, 1) structure.
448
+ // The Ghidra code stores into: row=2 (quant=2), row=4 (quant=4), row=8 (quant=8).
449
+ // All other rows are left at 0 (the table is zero-initialized by int_array_alloc).
450
+ const table = [];
451
+ for (let q = 0; q < MAX_Q; q++) {
452
+ const maxM = q < 5 ? MAX_M_LARGE : MAX_M_SMALL;
453
+ const row = new Array(maxM).fill(0);
454
+ if (q === 2) {
455
+ for (let m = 0; m < maxM; m++)
456
+ row[m] = RANK_TABLE_Q2[m];
457
+ }
458
+ else if (q === 4) {
459
+ for (let m = 0; m < maxM; m++)
460
+ row[m] = RANK_TABLE_Q4[m];
461
+ }
462
+ else if (q === 8) {
463
+ for (let m = 0; m < Math.min(maxM, RANK_TABLE_Q8.length); m++)
464
+ row[m] = RANK_TABLE_Q8[m];
465
+ }
466
+ table.push(row);
467
+ }
468
+ return table;
469
+ }
470
+ /**
471
+ * Look up a value from a 2D table. Returns the value at table[q][m],
472
+ * or 0 if out of bounds (matches C zero-initialized array behavior).
473
+ */
474
+ function tableLookup(table, q, m) {
475
+ if (q < 0 || q >= table.length)
476
+ return 0;
477
+ const row = table[q];
478
+ if (m < 0 || m >= row.length)
479
+ return 0;
480
+ return row[m];
481
+ }
482
+ // ─── Fischer decode ──────────────────────────────────────────────────────────
483
+ // Combinatorial unranking: given a codeword (rank), magnitude sum, and output length,
484
+ // decode the unique signed integer tuple.
485
+ // Uses the DIFF TABLE (exact counts T(q,m)) for lookups.
486
+ // T(q, m) = number of signed q-tuples with sum(|xi|) EXACTLY equal to m.
487
+ // The unranking partitions by first-position value:
488
+ // T(n-1, m) tuples where pos[0]=0 (same total magnitude distributed among n-1 remaining positions)
489
+ // T(n-1, m-a) tuples where |pos[0]|=a (for each sign), counted as 2*T(n-1, m-a)
490
+ // From Ghidra fischer_decode:
491
+ // - uVar3 = outLen (*out_table)
492
+ // - uVar5 starts at outLen, decremented each iteration
493
+ // - calc_rank_bit_length(rank_table, uVar5-1, local_10, 0) looks up diffTable[remaining_positions-1][remaining_magnitude]
494
+ // - local_10 = remaining magnitude, local_c = output index
495
+ function fischerDecode(outLen, codeword, magnitudeSum, diffTable) {
496
+ const out = new Array(outLen).fill(0);
497
+ if (magnitudeSum === 0)
498
+ return out;
499
+ let remaining = magnitudeSum; // local_10
500
+ let runningTotal = 0; // iVar4
501
+ let outIdx = 0; // local_c
502
+ let remainingPositions = outLen; // uVar5
503
+ while (outIdx < outLen) {
504
+ if (codeword === runningTotal) {
505
+ out[outIdx] = 0;
506
+ break;
507
+ }
508
+ // Count of tuples with 0 at this position: T(n-1, m) = exact count of
509
+ // (n-1)-tuples with sum of abs values exactly equal to remaining magnitude
510
+ const zeroCount = tableLookup(diffTable, remainingPositions - 1, remaining);
511
+ if (codeword < zeroCount + runningTotal) {
512
+ // Zero at this position
513
+ out[outIdx] = 0;
514
+ }
515
+ else {
516
+ // Non-zero: find the absolute value
517
+ let iVar4 = runningTotal + zeroCount;
518
+ let absVal = 1; // local_8
519
+ while (true) {
520
+ const subCount = tableLookup(diffTable, remainingPositions - 1, remaining - absVal);
521
+ if (codeword < iVar4 + subCount * 2)
522
+ break;
523
+ iVar4 += subCount * 2;
524
+ absVal++;
525
+ if (absVal > remaining + 1) {
526
+ console.error(`fischerDecode INFINITE LOOP: outLen=${outLen} cw=${codeword} magSum=${magnitudeSum} remaining=${remaining} absVal=${absVal} iVar4=${iVar4} outIdx=${outIdx}`);
527
+ throw new Error('fischerDecode infinite loop');
528
+ }
529
+ }
530
+ // Determine sign: positive or negative
531
+ const subCount = tableLookup(diffTable, remainingPositions - 1, remaining - absVal);
532
+ if (codeword >= iVar4 && codeword < subCount + iVar4) {
533
+ // Positive
534
+ out[outIdx] = absVal;
535
+ }
536
+ // Check if negative
537
+ if (subCount + iVar4 <= codeword) {
538
+ out[outIdx] = -absVal;
539
+ runningTotal = iVar4 + subCount;
540
+ }
541
+ else {
542
+ runningTotal = iVar4;
543
+ }
544
+ remaining -= absVal;
545
+ }
546
+ remainingPositions--;
547
+ outIdx++;
548
+ }
549
+ // End fixup: if remaining magnitude > 0, adjust the last position
550
+ // From Ghidra: if (0 < local_10) { last = outLen-1; val = out[last]; out[last] = remaining - abs(val); }
551
+ // Wait, the Ghidra code says:
552
+ // iVar4 = uVar3 - 1 (= outLen - 1)
553
+ // uVar3 = int_table_get(out_table, local_c) [local_c = outIdx at loop end]
554
+ // int_table_set(out_table, local_10 - abs(uVar3), iVar4)
555
+ // So: out[outLen-1] = remaining - abs(out[outIdx])
556
+ // Hmm, that means it reads from outIdx (which is the last written position + 1 or loop-end)
557
+ // and writes to outLen-1. Let me re-read...
558
+ // After the loop: local_c was incremented past the last written position.
559
+ // "uVar3 = int_table_get(out_table, local_c)" reads the NEXT position (which might be 0).
560
+ // Actually no: the loop does: outIdx++ at end of each iteration, so after the break,
561
+ // outIdx points to the position where we wrote 0 and broke. If we didn't break,
562
+ // outIdx = outLen after the loop.
563
+ // Hmm wait, looking at Ghidra more carefully:
564
+ // The "if (codeword == iVar4) { set 0 at local_c; break; }" breaks BEFORE incrementing.
565
+ // In the normal path, the loop body ends with: uVar5--; uVar2=get(out,local_c); local_c++; ...
566
+ // So when breaking on codeword==runningTotal, local_c is the position where 0 was set.
567
+ //
568
+ // The end fixup: "uVar3 = int_table_get(out_table, local_c)" — local_c is the last-written index
569
+ // (from the break) or the current outIdx. Then out[outLen-1] = remaining - abs(out[local_c]).
570
+ //
571
+ // Hmm, that's odd. Let me re-read the Ghidra code more carefully...
572
+ // Actually in the non-break path: the last line of the loop is:
573
+ // uVar2 = int_table_get(out_table, local_c); local_c++; local_10 -= abs(uVar2);
574
+ // So local_c is incremented AFTER reading. At loop end, local_c = outLen.
575
+ // But the fixup reads from local_c which is outLen — that's out of bounds!
576
+ // Unless... the fixup only triggers when remaining > 0 AND the break happened.
577
+ // In the break case, local_c is the break position (not incremented).
578
+ // Actually wait, looking at the Ghidra code structure:
579
+ // do {
580
+ // if (codeword == iVar4) { set(0, local_c); break; }
581
+ // ...process...
582
+ // uVar5--;
583
+ // uVar2 = get(out, local_c);
584
+ // local_c++;
585
+ // local_10 -= abs(uVar2);
586
+ // } while (local_c < outLen);
587
+ //
588
+ // if (remaining > 0) {
589
+ // iVar4 = outLen - 1;
590
+ // uVar3 = get(out, local_c);
591
+ // set(out, remaining - abs(uVar3), iVar4);
592
+ // }
593
+ //
594
+ // When the break fires: local_c = break position. remaining was not updated.
595
+ // The fixup then reads out[break_position] (which was just set to 0) → abs = 0
596
+ // Then sets out[outLen-1] = remaining - 0 = remaining.
597
+ // This makes sense! It dumps the leftover magnitude into the last position.
598
+ if (remaining > 0) {
599
+ const lastIdx = outLen - 1;
600
+ // C code reads out[outIdx] which may be out of bounds (outIdx == outLen after full loop).
601
+ // In C, the zero-initialized array has 0 beyond bounds. In JS, out[outLen] = undefined.
602
+ const lastVal = outIdx < outLen ? out[outIdx] : 0;
603
+ const absLast = Math.abs(lastVal);
604
+ out[lastIdx] = remaining - absLast;
605
+ }
606
+ return out;
607
+ }
608
+ function matrixCreate(w, h) {
609
+ return { data: new Float32Array(w * h), width: w, height: h };
610
+ }
611
+ function matrixGet(m, x, y) {
612
+ return m.data[y * m.width + x];
613
+ }
614
+ function matrixSet(m, x, y, v) {
615
+ m.data[y * m.width + x] = v;
616
+ }
617
+ function splitEvenOdd(n) {
618
+ // From Ghidra split_even_odd: if n is even, both halves = n/2
619
+ // If n is odd, even = (n+1)/2, odd = (n-1)/2
620
+ if ((n & 1) === 0) {
621
+ return [n / 2, n / 2];
622
+ }
623
+ else {
624
+ return [(n + 1) / 2, (n - 1) / 2];
625
+ }
626
+ }
627
+ function pyramidCreate(width, height, numLevels) {
628
+ const levels = [];
629
+ let w = width, h = height;
630
+ for (let lev = 0; lev < numLevels; lev++) {
631
+ const [ew, ow] = splitEvenOdd(w);
632
+ const [eh, oh] = splitEvenOdd(h);
633
+ // Subbands: LL(ew×eh), LH(ew×oh), HL(ow×eh), HH(ow×oh)
634
+ const ll = matrixCreate(ew, eh);
635
+ const lh = matrixCreate(ew, oh);
636
+ const hl = matrixCreate(ow, eh);
637
+ const hh = matrixCreate(ow, oh);
638
+ levels.push({ subbands: [ll, lh, hl, hh] });
639
+ // Next level operates on LL
640
+ w = ew;
641
+ h = eh;
642
+ }
643
+ return { levels, numLevels };
644
+ }
645
+ // ─── Polyphase synthesis (from Ghidra polyphase_convolve @ 004bc940) ────────
646
+ //
647
+ // The synthesis filter bank reconstructs the signal from subband samples.
648
+ // For a filter with center index `c`, parity offset `p = -filter_parity`,
649
+ // the convolution for output sample i is:
650
+ //
651
+ // out[i] = Σ_{j=-c}^{c} coeffs[c - j] * src_extended[(i + c + p + j) / 2]
652
+ // (only summing where (i + c + p + j) is even)
653
+ //
654
+ // Edge extension: src_extended[k] = src[boundary - k] for k outside [0, srcLen)
655
+ // where boundary depends on the extension type (param4, param5).
656
+ /**
657
+ * Edge-extend a source sample. Matches Ghidra's edge_extend_sample.
658
+ * @param src source array
659
+ * @param idx requested index (may be negative or >= srcLen)
660
+ * @param srcLen number of valid samples
661
+ * @param boundary reflection boundary
662
+ */
663
+ function edgeExtend(src, idx, srcLen, boundary) {
664
+ if (idx >= 0 && idx < srcLen)
665
+ return src[idx];
666
+ return src[boundary - idx];
667
+ }
668
+ /**
669
+ * 1D polyphase synthesis convolution.
670
+ * Matches Ghidra's polyphase_convolve @ 004bc940 / FUN_004bcdc0 (add variant).
671
+ *
672
+ * Three sections exactly as in the original:
673
+ * 1. Slow start (0 to center-parity): edge extension with leftBound
674
+ * 2. Fast interior: direct polyphase access (no edge extension needed)
675
+ * 3. Slow end (remainder to dstLen): edge extension with rightBound
676
+ *
677
+ * CRITICAL: The slow-start uses leftBound for ALL out-of-bounds samples,
678
+ * and slow-end uses rightBound for ALL out-of-bounds samples.
679
+ * This differs from our previous per-tap boundary selection (k < 0 ? left : right)
680
+ * which caused visual artifacts.
681
+ */
682
+ function polyphaseConvolve1D(filter, dst, dstLen, src, srcLen, param4, param5, add // false = set (overwrite), true = add (accumulate)
683
+ ) {
684
+ const c = filter.center; // filter[3] = (length-1)/2
685
+ const p = -filter.parity; // filter[5] = -parity from filter_alloc
686
+ // Edge extension boundaries (from edge_extension_setup)
687
+ const leftBound = (param4 === 1) ? 0 : -1;
688
+ const rightBound = (param5 === 1) ? srcLen * 2 - 2 : srcLen * 2 - 1;
689
+ // Section boundaries (from Ghidra):
690
+ // slowStartEnd = center + parity (= center - (-parity) = center - p... wait, iVar5 = filter[5] = -parity)
691
+ // In Ghidra: uVar8 = iVar4 - iVar5, where iVar4=center, iVar5=*(param_1+0x14)=filter[5]=-parity
692
+ // BUT iVar5 is stored as: EAX = filter[5], NEG EAX → iVar5_stored = -filter[5] = parity
693
+ // Wait — re-reading the ASM:
694
+ // MOV EAX,[EDI+0x14] → EAX = filter[5] = -parity_param
695
+ // NEG EAX → EAX = parity_param
696
+ // MOV [ESP+0x28],EAX → stored_parity = parity_param
697
+ // Then: uVar8 (slowStartEnd) = center + stored_parity... wait no:
698
+ // ADD EAX,ESI → EAX = parity_param + center (at 004bc95b)
699
+ // This is stored at [ESP+0x1c] = center + parity_param
700
+ // That's the slow-start end boundary.
701
+ //
702
+ // And fastEnd = dstLen - center - parity_param - 2
703
+ // (from: EAX = dstLen - center, SUB EAX, parity_param, SUB EAX, 2)
704
+ //
705
+ // With our variable names: p = -parity_param, so:
706
+ // slowStartEnd = center - p (= center + parity_param)
707
+ // fastEnd = dstLen - center + p - 2 (= dstLen - center - parity_param - 2)
708
+ const slowStartEnd = c - p; // = center + parity_param
709
+ const fastEnd = dstLen - c + p - 2; // = dstLen - center - parity_param - 2
710
+ // ── Section 1: Slow start (i from 0 to slowStartEnd-1) ──
711
+ // Uses leftBound for ALL out-of-bounds edge extension
712
+ for (let i = 0; i < slowStartEnd && i < dstLen; i++) {
713
+ let sum = 0;
714
+ for (let j = -c; j <= c; j++) {
715
+ const upIdx = i + p + j;
716
+ if ((upIdx & 1) === 0) {
717
+ const k = upIdx >> 1;
718
+ const sample = edgeExtend(src, k, srcLen, leftBound);
719
+ sum += sample * filter.coeffs[c - j];
720
+ }
721
+ }
722
+ if (add) {
723
+ dst[i] += sum;
724
+ }
725
+ else {
726
+ dst[i] = sum;
727
+ }
728
+ }
729
+ // ── Section 2: Fast interior (i from slowStartEnd to fastEnd-1, stepping by 2) ──
730
+ // Processes two outputs per iteration using polyphase decomposition.
731
+ // No edge extension needed — all source indices are guaranteed in-bounds.
732
+ //
733
+ // From Ghidra: pfVar10 = &coeffs[center * 2] (center of coeff array, float ptr)
734
+ // Even output (i): taps at coeffs[center], coeffs[center-2], coeffs[center-4], ...
735
+ // multiplied by src[srcIdx], src[srcIdx+1], src[srcIdx+2], ...
736
+ // Odd output (i+1): taps at coeffs[center-1], coeffs[center-3], coeffs[center-5], ...
737
+ // multiplied by src[srcIdx+1], src[srcIdx+2], src[srcIdx+3], ...
738
+ //
739
+ // The fast interior is functionally equivalent to the slow path for in-bounds samples,
740
+ // just optimized with pointer arithmetic. We use the slow-path formula here since
741
+ // it's clearer and produces identical results for in-bounds samples.
742
+ {
743
+ let i = slowStartEnd;
744
+ while (i < fastEnd) {
745
+ // Even output sample (i)
746
+ let sum0 = 0;
747
+ for (let j = -c; j <= c; j++) {
748
+ const upIdx = i + p + j;
749
+ if ((upIdx & 1) === 0) {
750
+ const k = upIdx >> 1;
751
+ // All k should be in-bounds in the fast section
752
+ sum0 += src[k] * filter.coeffs[c - j];
753
+ }
754
+ }
755
+ if (add) {
756
+ dst[i] += sum0;
757
+ }
758
+ else {
759
+ dst[i] = sum0;
760
+ }
761
+ // Odd output sample (i+1)
762
+ let sum1 = 0;
763
+ for (let j = -c; j <= c; j++) {
764
+ const upIdx = (i + 1) + p + j;
765
+ if ((upIdx & 1) === 0) {
766
+ const k = upIdx >> 1;
767
+ sum1 += src[k] * filter.coeffs[c - j];
768
+ }
769
+ }
770
+ if (add) {
771
+ dst[i + 1] += sum1;
772
+ }
773
+ else {
774
+ dst[i + 1] = sum1;
775
+ }
776
+ i += 2;
777
+ }
778
+ // ── Section 3: Slow end (remaining samples from i to dstLen-1) ──
779
+ // Uses rightBound for ALL out-of-bounds edge extension
780
+ while (i < dstLen) {
781
+ let sum = 0;
782
+ for (let j = -c; j <= c; j++) {
783
+ const upIdx = i + p + j;
784
+ if ((upIdx & 1) === 0) {
785
+ const k = upIdx >> 1;
786
+ const sample = edgeExtend(src, k, srcLen, rightBound);
787
+ sum += sample * filter.coeffs[c - j];
788
+ }
789
+ }
790
+ if (add) {
791
+ dst[i] += sum;
792
+ }
793
+ else {
794
+ dst[i] = sum;
795
+ }
796
+ i++;
797
+ }
798
+ }
799
+ }
800
+ // ─── Wavelet reconstruction ────────────────────────────────────────────────
801
+ // From Ghidra wavelet_reconstruct_level @ 004bc640:
802
+ // param_1 = filter1 (analysis high, parity 0) → used in wavelet_filter_apply (SET)
803
+ // param_2 = filter2 (synthesis high, parity 1) → used in wavelet_filter_add (ADD)
804
+ //
805
+ // Vertical pass (direction=1, iterate over columns):
806
+ // tmpEven = filter1 * LL + filter2 * LH (column-by-column)
807
+ // tmpOdd = filter1 * HL + filter2 * HH (column-by-column)
808
+ //
809
+ // Horizontal pass (direction=0, iterate over rows):
810
+ // output = filter1 * tmpEven + filter2 * tmpOdd (row-by-row)
811
+ //
812
+ // The param5/param6 arguments to wavelet_filter_apply/add control edge extension:
813
+ // height even: apply(f1, dst, src, 1, 1, 2) then add(f2, dst, src, 1, 2, 1)
814
+ // height odd: apply(f1, dst, src, 1, 1, 1) then add(f2, dst, src, 1, 2, 2)
815
+ // width even: apply(f1, dst, src, 0, 1, 2) then add(f2, dst, src, 0, 2, 1)
816
+ // width odd: apply(f1, dst, src, 0, 1, 1) then add(f2, dst, src, 0, 2, 2)
817
+ function waveletReconstructLevel(filter1, filter2, output, level) {
818
+ const outW = output.width;
819
+ const outH = output.height;
820
+ const [evenW, oddW] = splitEvenOdd(outW);
821
+ const [evenH, oddH] = splitEvenOdd(outH);
822
+ const ll = level.subbands[0]; // LL: evenW × evenH
823
+ const lh = level.subbands[1]; // LH: evenW × oddH
824
+ const hl = level.subbands[2]; // HL: oddW × evenH
825
+ const hh = level.subbands[3]; // HH: oddW × oddH
826
+ // Determine edge extension params based on parity
827
+ const hEven = (outH & 1) === 0;
828
+ const wEven = (outW & 1) === 0;
829
+ const vLowP4 = 1, vLowP5 = hEven ? 2 : 1;
830
+ const vHighP4 = 2, vHighP5 = hEven ? 1 : 2;
831
+ const hLowP4 = 1, hLowP5 = wEven ? 2 : 1;
832
+ const hHighP4 = 2, hHighP5 = wEven ? 1 : 2;
833
+ // Temporary buffers for vertical pass results
834
+ const tmpEven = matrixCreate(evenW, outH);
835
+ const tmpOdd = matrixCreate(oddW, outH);
836
+ // ── Step 1: Vertical pass (column by column) ──
837
+ // tmpEven columns: filter1 * LL_col + filter2 * LH_col
838
+ for (let x = 0; x < evenW; x++) {
839
+ const col = new Float32Array(outH);
840
+ const llCol = new Float32Array(evenH);
841
+ for (let y = 0; y < evenH; y++)
842
+ llCol[y] = matrixGet(ll, x, y);
843
+ polyphaseConvolve1D(filter1, col, outH, llCol, evenH, vLowP4, vLowP5, false);
844
+ const lhCol = new Float32Array(oddH);
845
+ for (let y = 0; y < oddH; y++)
846
+ lhCol[y] = matrixGet(lh, x, y);
847
+ polyphaseConvolve1D(filter2, col, outH, lhCol, oddH, vHighP4, vHighP5, true);
848
+ for (let y = 0; y < outH; y++)
849
+ tmpEven.data[y * evenW + x] = col[y];
850
+ }
851
+ // tmpOdd columns: filter1 * HL_col + filter2 * HH_col
852
+ for (let x = 0; x < oddW; x++) {
853
+ const col = new Float32Array(outH);
854
+ const hlCol = new Float32Array(evenH);
855
+ for (let y = 0; y < evenH; y++)
856
+ hlCol[y] = matrixGet(hl, x, y);
857
+ polyphaseConvolve1D(filter1, col, outH, hlCol, evenH, vLowP4, vLowP5, false);
858
+ const hhCol = new Float32Array(oddH);
859
+ for (let y = 0; y < oddH; y++)
860
+ hhCol[y] = matrixGet(hh, x, y);
861
+ polyphaseConvolve1D(filter2, col, outH, hhCol, oddH, vHighP4, vHighP5, true);
862
+ for (let y = 0; y < outH; y++)
863
+ tmpOdd.data[y * oddW + x] = col[y];
864
+ }
865
+ // ── Step 2: Horizontal pass (row by row) ──
866
+ // output rows: filter1 * tmpEven_row + filter2 * tmpOdd_row
867
+ for (let y = 0; y < outH; y++) {
868
+ const row = new Float32Array(outW);
869
+ const evenRow = new Float32Array(evenW);
870
+ for (let x = 0; x < evenW; x++)
871
+ evenRow[x] = tmpEven.data[y * evenW + x];
872
+ polyphaseConvolve1D(filter1, row, outW, evenRow, evenW, hLowP4, hLowP5, false);
873
+ const oddRow = new Float32Array(oddW);
874
+ for (let x = 0; x < oddW; x++)
875
+ oddRow[x] = tmpOdd.data[y * oddW + x];
876
+ polyphaseConvolve1D(filter2, row, outW, oddRow, oddW, hHighP4, hHighP5, true);
877
+ for (let x = 0; x < outW; x++)
878
+ output.data[y * outW + x] = row[x];
879
+ }
880
+ }
881
+ function waveletReconstructAll(filter1, filter2, pyramid, width, height) {
882
+ // From Ghidra wavelet_reconstruct_all @ 004bd1e0:
883
+ // Reconstruct from deepest level upward.
884
+ // At each level, the reconstruction output replaces the LL of the parent level.
885
+ // Level N-1 → output → LL of level N-2
886
+ // Level N-2 → output → LL of level N-3
887
+ // ...
888
+ // Level 0 → final output (full image size)
889
+ for (let lev = pyramid.numLevels - 1; lev >= 0; lev--) {
890
+ const level = pyramid.levels[lev];
891
+ // The output size = LL.width + HL.width × LL.height + LH.height
892
+ // which equals the original dimensions at this decomposition level.
893
+ const outW = level.subbands[0].width + level.subbands[2].width;
894
+ const outH = level.subbands[0].height + level.subbands[1].height;
895
+ const output = matrixCreate(outW, outH);
896
+ waveletReconstructLevel(filter1, filter2, output, level);
897
+ if (lev > 0) {
898
+ // Feed output as LL of parent level
899
+ pyramid.levels[lev - 1].subbands[0] = output;
900
+ }
901
+ else {
902
+ return output;
903
+ }
904
+ }
905
+ return pyramid.levels[0].subbands[0]; // unreachable
906
+ }
907
+ // ─── Band size calculation ────────────────────────────────────────────────
908
+ function calcBandSize(width, height, quant, orientation) {
909
+ // From Ghidra disassembly (missed by decompiler):
910
+ // orientation==0: ceil(width / (quant*2)) * height * 2
911
+ // orientation==1: ceil(height / (quant*2)) * width * 2
912
+ // The *2 comes from FMUL DAT_004ed128 (=2.0) — accounts for interleaved block pairs
913
+ if (orientation === 0) {
914
+ return Math.ceil(width / (quant * 2)) * height * 2;
915
+ }
916
+ else {
917
+ return Math.ceil(height / (quant * 2)) * width * 2;
918
+ }
919
+ }
920
+ // ─── calc_bit_length ─────────────────────────────────────────────────────
921
+ // FUN_004b6ae0(bandValue, quant, rankTable):
922
+ // quant >= 2: calc_rank_bit_length(rankTable, quant, bandValue, 0) → bit length from RANK table
923
+ // quant < 2: bandValue * 2 + 1
924
+ // FUN_004b6b10: ceil(log2(result)) — but only for quant < 2 (for quant >= 2, rank table already stores bits)
925
+ // calc_bit_length: ceil(bitCount * 0.125) = bytes
926
+ function getRankBitLength(bandValue, quant, rankTable) {
927
+ // This is FUN_004b6ae0: returns the total number of states (for quant < 2)
928
+ // or the bit length directly from the rank table (for quant >= 2)
929
+ if (quant >= 2) {
930
+ return tableLookup(rankTable, quant, bandValue);
931
+ }
932
+ else {
933
+ return bandValue * 2 + 1;
934
+ }
935
+ }
936
+ function calcBitLength(bandValue, quant, rankTable) {
937
+ let bitCount;
938
+ if (quant >= 2) {
939
+ // Rank table already stores bit counts
940
+ bitCount = getRankBitLength(bandValue, quant, rankTable);
941
+ if (bitCount < 0)
942
+ bitCount = 0;
943
+ }
944
+ else {
945
+ // For quant < 2: number of states = bandValue * 2 + 1
946
+ // FUN_004b6b10 computes ceil(log2(states))
947
+ const states = getRankBitLength(bandValue, quant, rankTable);
948
+ bitCount = states <= 1 ? 0 : Math.ceil(Math.log2(states));
949
+ }
950
+ return Math.ceil(bitCount * DAT_004ed130);
951
+ }
952
+ // ─── level_scale_factor ─────────────────────────────────────────────────
953
+ function levelScaleFactor(extraBits) {
954
+ return (DAT_004ed1d0 - extraBits) * DAT_004ed1d8;
955
+ }
956
+ // ─── Block copy (FUN_004b6ba0) ──────────────────────────────────────────
957
+ function blockCopy(dst, src, srcLen, startX, startY, strideX, strideY) {
958
+ let x = startX, y = startY;
959
+ for (let i = 0; i < srcLen; i++) {
960
+ if (x < dst.width && y < dst.height && x >= 0 && y >= 0) {
961
+ matrixSet(dst, x, y, src[i]);
962
+ }
963
+ x += strideX;
964
+ y += strideY;
965
+ }
966
+ }
967
+ // ─── itw_decode_band ───────────────────────────────────────────────────────
968
+ function itwDecodeBand(dst, cursor, quant, bandValue, bandScale, orientation, bandOffset, diffTable, // exact counts T(q,m) — passed to fischerDecode for unranking
969
+ rankTable, // hardcoded bit lengths — used for reading codeword bits
970
+ version) {
971
+ const bandSize = calcBandSize(dst.width, dst.height, quant, orientation);
972
+ if (bandSize === 0)
973
+ return;
974
+ const positions = new Int32Array(bandSize);
975
+ const magnitudes = new Int32Array(bandSize);
976
+ const extraBits = new Int32Array(bandSize);
977
+ if (quant < 2) {
978
+ // Simple path: direct codeword per position
979
+ const bytesPerPos = calcBitLength(bandValue, quant, rankTable);
980
+ const bufSize = DAT_004ed11c * bandSize;
981
+ const inflated = cursor.copyStreamData(bufSize);
982
+ const bs = new Bitstream(inflated);
983
+ for (let i = 0; i < bandSize; i++) {
984
+ positions[i] = bs.readBits(bytesPerPos * 8);
985
+ }
986
+ // Reconstruct coefficients (quant1 path)
987
+ const range = bandValue * 2 + 1;
988
+ const scale = (bandScale / bandValue) * bandOffset; // band_offset_scale
989
+ // Wait, looking at coeff_reconstruct_dispatch: for quant==1:
990
+ // coeff_reconstruct_quant1(dst, positions, band_value, band_scale, band_offset, band_offset_scale)
991
+ // But the args passed are: band_value=bandValue, band_scale=bandScale, band_offset=0.0 (reserved_zero in the call)
992
+ // Actually re-reading itw_decode_main:
993
+ // itw_decode_band(view, cursor, quant, quantSteps, bandScale, orientation, 0, bandOffset, ...)
994
+ // And coeff_reconstruct_dispatch(dst, pos, mag, extra, quant, bandValue, bandScale, orientation, 0.0, bandOffset, rankTable)
995
+ // coeff_reconstruct_quant1(dst, pos, bandValue, bandScale, param_9=0.0, param_10=bandOffset)
996
+ // formula: (codeword % (bandValue*2+1) - bandValue) * (bandScale / bandValue) * bandOffset + 0.0
997
+ // Wait, the args are: param_4=bandScale, param_5=param_9=0.0, param_6=param_10=bandOffset
998
+ // So: value = (codeword % range - bandValue) * (bandScale / bandValue) * bandOffset + 0.0
999
+ // Actually looking more carefully at coeff_reconstruct_quant1 signature:
1000
+ // coeff_reconstruct_quant1(dst, pos_table, band_value, band_scale, band_offset, band_offset_scale)
1001
+ // And in itw_decode_main call to coeff_reconstruct_dispatch:
1002
+ // (dst, pos, mag, extra, quant, bandValue, bandScale, orientation, 0.0, bandOffset, rankTable)
1003
+ // coeff_reconstruct_dispatch maps: param_7=bandScale, param_9=0.0, param_10=bandOffset
1004
+ // For quant1: param_4=bandScale, param_5=0.0=band_offset, param_6=bandOffset=band_offset_scale
1005
+ // So: value = (codeword % range - bandValue) * (bandScale / bandValue) * bandOffset + 0.0
1006
+ // Hmm that seems off. Let me re-check.
1007
+ // coeff_reconstruct_quant1(param_1=dst, param_2=pos_table, param_3=band_value, param_4=band_scale, param_5=band_offset, param_6=band_offset_scale)
1008
+ // formula: (codeword % (param_3*2+1) - param_3) * (param_4 / param_3) * param_6 + param_5
1009
+ // So: (codeword % range - bandValue) * (bandScale / bandValue) * bandOffset + 0.0
1010
+ const w = dst.width;
1011
+ const h = dst.height;
1012
+ let posIdx = 0;
1013
+ for (let x = 0; x < w; x++) {
1014
+ for (let y = 0; y < h; y++) {
1015
+ if (posIdx < bandSize) {
1016
+ const codeword = positions[posIdx++];
1017
+ const val = (codeword % range - bandValue) * (bandScale / bandValue) * bandOffset;
1018
+ matrixSet(dst, x, y, val);
1019
+ }
1020
+ }
1021
+ }
1022
+ }
1023
+ else {
1024
+ // Fischer path: magnitude-based coding
1025
+ // Read magnitudes
1026
+ const magInflated = cursor.copyStreamData(bandSize);
1027
+ for (let i = 0; i < bandSize; i++) {
1028
+ magnitudes[i] = magInflated[i];
1029
+ }
1030
+ // Read extra bits (version 0 only)
1031
+ if (version === 0) {
1032
+ const bsData = new Uint8Array(cursor.remaining());
1033
+ const bs = new Bitstream(bsData);
1034
+ for (let i = 0; i < bandSize; i++) {
1035
+ if ((magnitudes[i] & DAT_004ed118) !== 0) {
1036
+ extraBits[i] = bs.readBits(4);
1037
+ }
1038
+ else {
1039
+ extraBits[i] = 0;
1040
+ }
1041
+ magnitudes[i] = magnitudes[i] & (DAT_004ed118 - 1); // mask off flag bit
1042
+ }
1043
+ // Update cursor position
1044
+ cursor.pos += bs.bytePos;
1045
+ }
1046
+ else {
1047
+ // version != 0: no extra bits
1048
+ extraBits.fill(0);
1049
+ }
1050
+ // Read codewords
1051
+ const cwBufSize = DAT_004ed11c * bandSize;
1052
+ const cwInflated = cursor.copyStreamData(cwBufSize);
1053
+ const cwBs = new Bitstream(cwInflated);
1054
+ for (let i = 0; i < bandSize; i++) {
1055
+ const bits = tableLookup(rankTable, quant, magnitudes[i]);
1056
+ positions[i] = bits > 0 ? cwBs.readBits(bits) : 0;
1057
+ }
1058
+ // Reconstruct coefficients (quant2 path)
1059
+ // From Ghidra coeff_reconstruct_quant2:
1060
+ // fVar8 = (band_scale / band_value) * band_offset_scale — stored as float32
1061
+ // sf = level_scale_factor(extraBits) — stored as float32
1062
+ // ratio = fVar8 / sf — stored as float32
1063
+ // fVar9 = decoded_int * ratio + band_offset — stored as float32
1064
+ // From itw_decode_main dispatch: band_offset = 0.0, band_offset_scale = bandOffset
1065
+ // CRITICAL: All intermediate values are truncated to float32 in the original x87 code.
1066
+ const fVar8 = Math.fround(Math.fround(bandScale / bandValue) * bandOffset);
1067
+ let posIdx = 0;
1068
+ if (orientation === 1) {
1069
+ // Vertical blocks: outer by Y in steps of quant*2, inner by X
1070
+ // Each pair: block at (bx, by) stride (0,2) and block at (bx, by+1) stride (0,2)
1071
+ for (let by = 0; by < dst.height; by += quant * 2) {
1072
+ for (let bx = 0; bx < dst.width; bx++) {
1073
+ // First block: write at (bx, by), (bx, by+2), (bx, by+4), ...
1074
+ if (posIdx < bandSize) {
1075
+ const sf = Math.fround(levelScaleFactor(extraBits[posIdx]));
1076
+ const ratio = Math.fround(fVar8 / sf);
1077
+ const decoded = fischerDecode(quant, positions[posIdx], magnitudes[posIdx], diffTable);
1078
+ for (let k = 0; k < quant; k++) {
1079
+ const y = by + k * 2;
1080
+ if (y < dst.height) {
1081
+ matrixSet(dst, bx, y, Math.fround(decoded[k] * ratio));
1082
+ }
1083
+ }
1084
+ posIdx++;
1085
+ }
1086
+ // Second block: write at (bx, by+1), (bx, by+3), (bx, by+5), ...
1087
+ if (posIdx < bandSize) {
1088
+ const sf = Math.fround(levelScaleFactor(extraBits[posIdx]));
1089
+ const ratio = Math.fround(fVar8 / sf);
1090
+ const decoded = fischerDecode(quant, positions[posIdx], magnitudes[posIdx], diffTable);
1091
+ for (let k = 0; k < quant; k++) {
1092
+ const y = by + 1 + k * 2;
1093
+ if (y < dst.height) {
1094
+ matrixSet(dst, bx, y, Math.fround(decoded[k] * ratio));
1095
+ }
1096
+ }
1097
+ posIdx++;
1098
+ }
1099
+ }
1100
+ }
1101
+ }
1102
+ else {
1103
+ // Horizontal blocks (orientation == 0): outer by X in steps of quant*2, inner by Y
1104
+ for (let bx = 0; bx < dst.width; bx += quant * 2) {
1105
+ for (let by = 0; by < dst.height; by++) {
1106
+ // First block: write at (bx, by), (bx+2, by), (bx+4, by), ...
1107
+ if (posIdx < bandSize) {
1108
+ const sf = Math.fround(levelScaleFactor(extraBits[posIdx]));
1109
+ const ratio = Math.fround(fVar8 / sf);
1110
+ const decoded = fischerDecode(quant, positions[posIdx], magnitudes[posIdx], diffTable);
1111
+ for (let k = 0; k < quant; k++) {
1112
+ const x = bx + k * 2;
1113
+ if (x < dst.width) {
1114
+ matrixSet(dst, x, by, Math.fround(decoded[k] * ratio));
1115
+ }
1116
+ }
1117
+ posIdx++;
1118
+ }
1119
+ // Second block: write at (bx+1, by), (bx+3, by), (bx+5, by), ...
1120
+ if (posIdx < bandSize) {
1121
+ const sf = Math.fround(levelScaleFactor(extraBits[posIdx]));
1122
+ const ratio = Math.fround(fVar8 / sf);
1123
+ const decoded = fischerDecode(quant, positions[posIdx], magnitudes[posIdx], diffTable);
1124
+ for (let k = 0; k < quant; k++) {
1125
+ const x = bx + 1 + k * 2;
1126
+ if (x < dst.width) {
1127
+ matrixSet(dst, x, by, Math.fround(decoded[k] * ratio));
1128
+ }
1129
+ }
1130
+ posIdx++;
1131
+ }
1132
+ }
1133
+ }
1134
+ }
1135
+ }
1136
+ }
1137
+ // ─── Read LL band ──────────────────────────────────────────────────────────
1138
+ // From Ghidra read_ll_band: outer loop = width (x), inner loop = height (y)
1139
+ // The bytes are stored column-major in the stream.
1140
+ function readLLBand(cursor, matrix) {
1141
+ for (let x = 0; x < matrix.width; x++) {
1142
+ for (let y = 0; y < matrix.height; y++) {
1143
+ const byte = cursor.readByte();
1144
+ matrixSet(matrix, x, y, byte);
1145
+ }
1146
+ }
1147
+ }
1148
+ // ─── Subband view mapping ──────────────────────────────────────────────────
1149
+ // Maps the flat subband view array to pyramid subbands.
1150
+ // From itw_decode_main:
1151
+ // views[0] = level[0].subband[0] (LL at level 0)
1152
+ // views[1] = level[0].subband[1] (LH at level 0)
1153
+ // For each level i (1..numLevels-1):
1154
+ // views[2 + (i-1)*3 + 0] = level[i].subband[0] (LL)
1155
+ // views[2 + (i-1)*3 + 1] = level[i].subband[1] (LH)
1156
+ // views[2 + (i-1)*3 + 2] = level[i].subband[2] (HL)
1157
+ // views[local_a0] = level[numLevels-1].subband[3] (HH at deepest)
1158
+ // Actually wait, looking at the code again more carefully:
1159
+ // Level 0: band 0 (LL), band 1 (LH)
1160
+ // Level 1..N-1: band 0 (LL), band 1 (LH), band 2 (HL)
1161
+ // Last: HH of deepest level
1162
+ //
1163
+ // But the ORIENTATION and quant arrays are only for "detail" bands (local_a0 elements).
1164
+ // views has local_90 elements total, with the LAST one (views[local_a0]) being the LL of deepest.
1165
+ // Actually re-reading: piVar11 = piVar9 + local_a0 points to the last view slot,
1166
+ // and it's set to level[numLevels-1].subband[3] = HH of deepest level.
1167
+ // Then read_ll_band writes to this last view.
1168
+ //
1169
+ // WAIT. The code says:
1170
+ // piVar11 = (int *)pyramid_get_level(piVar8, iVar22 - 1); [deepest level]
1171
+ // piVar12 = level_get_subband(piVar11, 3); [HH of deepest]
1172
+ // piVar11 = piVar9 + local_a0; [last view slot]
1173
+ // *piVar11 = matrix_create_view(piVar12)
1174
+ // Then later: read_ll_band(cursor, *(*piVar11 + 4))
1175
+ // So the last view (index local_a0) is the HH of the deepest level,
1176
+ // and the LL band is read INTO that?? That doesn't make sense.
1177
+ // Unless... level_get_subband(level, 3) for the deepest level IS the LL band.
1178
+ // Let me re-check what subband index 3 is...
1179
+ // In the pyramid, level[numLevels-1] has subbands for the coarsest decomposition.
1180
+ // The "LL" at the very deepest level is a special case — it's the DC coefficients.
1181
+ // In a typical wavelet codec, the deepest LL is stored separately.
1182
+ // Here, subband 3 of the deepest level might be repurposed as the LL storage.
1183
+ // This is getting complicated. Let me just use a simpler mapping:
1184
+ // For the subband views that get decoded by itw_decode_band:
1185
+ // - views 0..(local_a0-1) are the detail subbands
1186
+ // - view local_a0 is the LL of the deepest level (stored as subband[3])
1187
+ // For 3 levels (local_90=9, local_a0=8):
1188
+ // views[0] = L0.LL, views[1] = L0.LH
1189
+ // views[2] = L1.LL, views[3] = L1.LH, views[4] = L1.HL
1190
+ // views[5] = L2.LL, views[6] = L2.LH, views[7] = L2.HL
1191
+ // views[8] = L2.HH (used for LL band reading)
1192
+ // For 4 levels (local_90=12, local_a0=11):
1193
+ // views[0] = L0.LL, views[1] = L0.LH
1194
+ // views[2] = L1.LL, ..., views[4] = L1.HL
1195
+ // views[5] = L2.LL, ..., views[7] = L2.HL
1196
+ // views[8] = L3.LL, ..., views[10] = L3.HL
1197
+ // views[11] = L3.HH
1198
+ // Quant steps from local_3c: [8, 8, 4, 4, 4, 2, 2, 2, 1, 1, 1]
1199
+ // For 3 levels, only first 8 are used (detail bands).
1200
+ // For 4 levels, all 11 are used.
1201
+ // ─── Main decode function ──────────────────────────────────────────────────
1202
+ function decode0300(buf, payloadOffset, width, height, opts) {
1203
+ // Read BE32 payload length, then the payload starts after it
1204
+ if (payloadOffset + 4 > buf.length)
1205
+ throw new itw_1.ITWError("missing wavelet length");
1206
+ const payloadLen = (0, itw_1.readBE32From2BE16)(buf, payloadOffset);
1207
+ const payloadStart = payloadOffset + 4;
1208
+ if (payloadStart + payloadLen > buf.length)
1209
+ throw new itw_1.ITWError("wavelet payload overruns file");
1210
+ // Build Fischer tables
1211
+ const baseTable = buildBaseTable(); // cumulative counts — used to build diff table
1212
+ const diffTable = buildDiffTable(baseTable); // exact counts T(q,m) — used by fischerDecode for unranking
1213
+ const rankTable = buildRankTable(); // hardcoded bit lengths — used for codeword reading
1214
+ // Quant step sizes per band
1215
+ const quantSteps = [8, 8, 4, 4, 4, 2, 2, 2, 1, 1, 1];
1216
+ // Read 3 header bytes from payload
1217
+ const cursor = new Cursor(buf, payloadStart);
1218
+ const version = cursor.readByte(); // DAT_00516c78
1219
+ const numLevels = cursor.readByte(); // 3 or 4
1220
+ const filterType = cursor.readByte(); // 0 or 1
1221
+ if (numLevels !== 3 && numLevels !== 4) {
1222
+ throw new itw_1.ITWError(`unsupported wavelet level count: ${numLevels}`);
1223
+ }
1224
+ const totalSubbands = numLevels === 3 ? 9 : 12; // local_90
1225
+ const detailSubbands = numLevels === 3 ? 8 : 11; // local_a0
1226
+ // Initialize wavelet filters (synthesis only needed for reconstruction)
1227
+ const { reconstructFilter1, reconstructFilter2 } = initFilters(filterType);
1228
+ // Debug: scale g1 (reconstructFilter2) coefficients by an override factor
1229
+ if (opts?.g1Scale !== undefined && opts.g1Scale !== 1.0) {
1230
+ const s = opts.g1Scale;
1231
+ reconstructFilter2.coeffs = reconstructFilter2.coeffs.map(c => c * s);
1232
+ }
1233
+ // Create wavelet pyramid
1234
+ const pyramid = pyramidCreate(width, height, numLevels);
1235
+ // Zero out level 0, band 2 in Ghidra = L0.HH in our mapping (subbands[3])
1236
+ // Ghidra subband order: [0]=HL, [1]=LH, [2]=HH, [3]=LL
1237
+ // Our subband order: [0]=LL, [1]=LH, [2]=HL, [3]=HH
1238
+ pyramid.levels[0].subbands[3].data.fill(0); // L0.HH zeroed
1239
+ // Build subband view mapping to match Ghidra's itw_decode_main:
1240
+ // Ghidra maps:
1241
+ // views[0] = level0.subband[0] = L0.HL → our subbands[2]
1242
+ // views[1] = level0.subband[1] = L0.LH → our subbands[1]
1243
+ // For levels 1+:
1244
+ // views[2+3*(i-1)+0] = Li.subband[0] = Li.HL → our subbands[2]
1245
+ // views[2+3*(i-1)+1] = Li.subband[1] = Li.LH → our subbands[1]
1246
+ // views[2+3*(i-1)+2] = Li.subband[2] = Li.HH → our subbands[3]
1247
+ // views[detailSubbands] = deepest_level.subband[3] = LL → our subbands[0]
1248
+ const views = [];
1249
+ views.push(pyramid.levels[0].subbands[2]); // L0.HL
1250
+ views.push(pyramid.levels[0].subbands[1]); // L0.LH
1251
+ for (let i = 1; i < numLevels; i++) {
1252
+ views.push(pyramid.levels[i].subbands[2]); // Li.HL
1253
+ views.push(pyramid.levels[i].subbands[1]); // Li.LH
1254
+ views.push(pyramid.levels[i].subbands[3]); // Li.HH
1255
+ }
1256
+ // The last view is the LL of deepest level — used as LL band target
1257
+ const llBandMatrix = pyramid.levels[numLevels - 1].subbands[0];
1258
+ views.push(llBandMatrix);
1259
+ // Per-frame loop (typically 1 iteration for param_1[5]=1)
1260
+ // The number of frames is stored in the image header at offset 0x14 (field [5]).
1261
+ // For our purposes, it's always 1 frame.
1262
+ const numFrames = 1;
1263
+ for (let frame = 0; frame < numFrames; frame++) {
1264
+ // Read orientation flags (1 bit per detail subband)
1265
+ const orientBsData = buf.subarray(cursor.pos);
1266
+ const orientBs = new Bitstream(orientBsData);
1267
+ const orientations = new Int32Array(detailSubbands);
1268
+ for (let i = 0; i < detailSubbands; i++) {
1269
+ orientations[i] = orientBs.readBits(1);
1270
+ }
1271
+ cursor.pos += orientBs.bytePos;
1272
+ // Read per-band parameters
1273
+ const bandQuantSteps = new Uint32Array(detailSubbands);
1274
+ const bandScales = new Float32Array(detailSubbands);
1275
+ const bandOffsets = new Float32Array(detailSubbands);
1276
+ for (let i = 0; i < detailSubbands; i++) {
1277
+ bandQuantSteps[i] = cursor.readBE16();
1278
+ const scaleQ15 = cursor.readBE16();
1279
+ bandScales[i] = q15ToFloat(scaleQ15);
1280
+ const offsetQ15 = cursor.readBE16();
1281
+ bandOffsets[i] = q15ToFloat(offsetQ15);
1282
+ }
1283
+ // Read min/max range
1284
+ const minVal = cursor.readBE16();
1285
+ const maxVal = cursor.readBE16();
1286
+ const range = maxVal - minVal;
1287
+ // Decode detail subbands
1288
+ for (let i = 0; i < detailSubbands; i++) {
1289
+ const view = views[i];
1290
+ itwDecodeBand(view, cursor, quantSteps[i], bandQuantSteps[i], bandScales[i], orientations[i], bandOffsets[i], diffTable, rankTable, version);
1291
+ }
1292
+ // Debug: zero out all detail bands to isolate LL-only reconstruction
1293
+ if (opts?.zeroDetailBands) {
1294
+ for (let i = 0; i < detailSubbands; i++) {
1295
+ views[i].data.fill(0);
1296
+ }
1297
+ }
1298
+ // Debug: only keep specific detail bands (bandMask is a bitmask, bit i = keep band i)
1299
+ if (opts?.bandMask !== undefined) {
1300
+ for (let i = 0; i < detailSubbands; i++) {
1301
+ if ((opts.bandMask & (1 << i)) === 0) {
1302
+ views[i].data.fill(0);
1303
+ }
1304
+ }
1305
+ }
1306
+ // Debug: scale all detail bands by a gain factor
1307
+ if (opts?.detailGain !== undefined && opts.detailGain !== 1.0) {
1308
+ const g = opts.detailGain;
1309
+ for (let i = 0; i < detailSubbands; i++) {
1310
+ const d = views[i].data;
1311
+ for (let j = 0; j < d.length; j++)
1312
+ d[j] *= g;
1313
+ }
1314
+ }
1315
+ // Read LL band (raw bytes)
1316
+ readLLBand(cursor, llBandMatrix);
1317
+ // Post-process LL band: scale/clamp
1318
+ // From Ghidra decompilation + ASM of itw_decode_main (0x004b7e36-0x004b7e87):
1319
+ // uVar20 = read_be_multibyte(2) → first_val (min_val)
1320
+ // uVar16 = read_be_multibyte(2) → second_val (max_val)
1321
+ // fVar3 = (float)(uVar16 - uVar20) → range
1322
+ // local_44 = (fVar3 + (float)uVar20) * 0.5 → center [stored as float32]
1323
+ // FSUBRP at 004b7e7f: Intel opcode DE E1 = ST(1) ← ST(0) - ST(1)
1324
+ // FPU state: ST(0) = first_val, ST(1) = range
1325
+ // Result: first_val - range (POSITIVE for normal images)
1326
+ // llScale_raw = (first_val - range) * 0.5 [stored as float32]
1327
+ // llScale = (double)llScale_raw * (1.0/127.0)
1328
+ // value = (pixel - 127.0) * llScale + center
1329
+ // clamp: range ≤ value ≤ first_val
1330
+ //
1331
+ // For 26.ITW: first_val=3522, range=1705
1332
+ // center = (1705+3522)*0.5 = 2613.5
1333
+ // llScale_raw = (3522-1705)*0.5 = 908.5
1334
+ // pixel 0 → (0-127)*7.1535+2613.5 = 1705 (= range, lower bound) ✓
1335
+ // pixel 254 → (254-127)*7.1535+2613.5 = 3522 (= first_val, upper bound) ✓
1336
+ const centerF32 = Math.fround((range + minVal) * DAT_004ed190);
1337
+ const llScaleRawF32 = Math.fround((minVal - range) * 0.5); // first_val - range (POSITIVE)
1338
+ const llScale = llScaleRawF32 * DAT_004ed198; // float32 * double → double
1339
+ const llCenter = centerF32; // loaded from float32 → extended to double
1340
+ const clampLo = Math.min(minVal, range);
1341
+ const clampHi = Math.max(minVal, range);
1342
+ const llData = llBandMatrix.data;
1343
+ const llCount = llBandMatrix.width * llBandMatrix.height;
1344
+ for (let i = 0; i < llCount; i++) {
1345
+ let val = (llData[i] - DAT_004ed1a0) * llScale + llCenter;
1346
+ if (val <= clampLo)
1347
+ val = clampLo;
1348
+ else if (val >= clampHi)
1349
+ val = clampHi;
1350
+ llData[i] = val;
1351
+ }
1352
+ }
1353
+ // Wavelet reconstruction
1354
+ const result = waveletReconstructAll(reconstructFilter1, reconstructFilter2, pyramid, width, height);
1355
+ // Convert float matrix to grayscale bytes
1356
+ // From disassembly of FUN_004b5b30:
1357
+ // CMP dword ptr [EDI],0x0 — compare float as integer (≤0 for negative/zero floats)
1358
+ // JLE → use 0
1359
+ // FCOM against 255.0 → clamp to min(val, 255.0)
1360
+ // CALL ftol → MSVC truncation toward zero (NOT round-to-nearest)
1361
+ // MOV byte ptr [EBX-1],AL — store as byte
1362
+ const pixels = new Uint8Array(width * height);
1363
+ for (let y = 0; y < height; y++) {
1364
+ for (let x = 0; x < width; x++) {
1365
+ let val = matrixGet(result, x, y);
1366
+ // Clamp float to [0, 255] THEN truncate (matching original ftol)
1367
+ if (val <= 0) {
1368
+ val = 0;
1369
+ }
1370
+ else if (val > 255) {
1371
+ val = 255;
1372
+ }
1373
+ pixels[y * width + x] = Math.trunc(val);
1374
+ }
1375
+ }
1376
+ if (opts?.returnFloat) {
1377
+ return { width, height, pixels, floatData: result.data };
1378
+ }
1379
+ return { width, height, pixels };
1380
+ }