reverseport 1.6.0 → 1.8.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.
Files changed (44) hide show
  1. package/README.md +71 -26
  2. package/THIRD_PARTY_NOTICES.md +1 -0
  3. package/bin/reverseport.mjs +13 -1
  4. package/dist/generatedPackageMetadata.js +1 -1
  5. package/licenses/README.md +1 -0
  6. package/licenses/godot-engine-MIT.txt +20 -0
  7. package/native/windows/build/manifest.json +1 -1
  8. package/package.json +1 -1
  9. package/port/cli.mjs +6 -4
  10. package/port/knowledge/apis/godot-3-to-4-renames.json +1 -0
  11. package/port/knowledge/apis/godot-3.json +1 -0
  12. package/port/knowledge/apis/godot-4.3.json +1 -0
  13. package/port/knowledge/apis/godot-4.7.json +1 -0
  14. package/port/src/agent.mjs +1 -1
  15. package/port/src/agentdisk.mjs +83 -0
  16. package/port/src/apicheck.mjs +280 -0
  17. package/port/src/archive.mjs +34 -4
  18. package/port/src/convert.mjs +3 -1
  19. package/port/src/dormant.mjs +159 -0
  20. package/port/src/formats.mjs +1 -0
  21. package/port/src/gamearchives.mjs +476 -0
  22. package/port/src/godotbin.mjs +544 -0
  23. package/port/src/il2cpp.mjs +148 -0
  24. package/port/src/jsworkspace.mjs +2 -2
  25. package/port/src/knowledge.mjs +3 -1
  26. package/port/src/physics.mjs +135 -0
  27. package/port/src/plan.mjs +94 -11
  28. package/port/src/porters.mjs +87 -5
  29. package/port/src/refs.mjs +120 -0
  30. package/port/src/rpgmaker.mjs +416 -0
  31. package/port/src/save.mjs +326 -0
  32. package/port/src/scene.mjs +37 -3
  33. package/port/src/shader.mjs +385 -0
  34. package/port/src/strings.mjs +327 -0
  35. package/port/src/tools.mjs +48 -8
  36. package/port/src/trace.mjs +302 -0
  37. package/port/src/unity-anim.mjs +229 -0
  38. package/port/src/upgrade.mjs +219 -0
  39. package/skills/build-inspector/SKILL.md +8 -0
  40. package/skills/godot-port/SKILL.md +2 -0
  41. package/skills/port-verify/SKILL.md +20 -9
  42. package/skills/reverse-engineer-anything/SKILL.md +18 -2
  43. package/skills/reverse-engineer-anything/references/porting-and-rebuilds.md +35 -4
  44. package/skills/unity-to-godot/SKILL.md +14 -2
@@ -0,0 +1,476 @@
1
+ // Game-specific containers: Ren'Py RPA, GameMaker data.win (FORM), FMOD Studio banks (RIFF FEV + FSB5),
2
+ // Wwise sound banks (BKHD), Unreal .pak (v1–v11) and Flash SWF. Each reader returns the same shape openArchive
3
+ // uses: { format, entries: [{ path, size, offset, ... }], ...facts }. Entries that are stored in the clear get an
4
+ // offset/size (and optionally a prefix or zlib flag) so archiveTool can extract them; encrypted content is
5
+ // described and never decrypted (project rule).
6
+ import fs from 'node:fs/promises';
7
+ import zlib from 'node:zlib';
8
+ import path from 'node:path';
9
+ import { RPError, readAt } from './util.mjs';
10
+
11
+ // ------------------------------------------------------------------------------------------------ Ren'Py RPA
12
+ /** Minimal Python pickle reader for the shapes Ren'Py's index uses (dict of str → list of tuples of int/bytes). */
13
+ export function unpickle(buf) {
14
+ const stack = [], memo = new Map();
15
+ let marks = [];
16
+ let p = 0;
17
+ const u8 = () => buf[p++], i32 = () => { const v = buf.readInt32LE(p); p += 4; return v; }, u32 = () => { const v = buf.readUInt32LE(p); p += 4; return v; };
18
+ const bytesN = n => { const b = buf.subarray(p, p + n); p += n; return b; };
19
+ const mark = () => { marks.push(stack.length); };
20
+ const popMark = () => { const i = marks.pop(); const items = stack.splice(i); return items; };
21
+ for (let guard = 0; guard < 50_000_000; guard++) {
22
+ if (p >= buf.length) throw new RPError('BAD_FORMAT', 'pickle ended early');
23
+ const op = u8();
24
+ switch (op) {
25
+ case 0x80: p++; break; // PROTO
26
+ case 0x2e: return stack.pop(); // STOP
27
+ case 0x7d: stack.push({}); break; // EMPTY_DICT
28
+ case 0x5d: stack.push([]); break; // EMPTY_LIST
29
+ case 0x29: stack.push([]); break; // EMPTY_TUPLE
30
+ case 0x28: mark(); break; // MARK
31
+ case 0x4e: stack.push(null); break; // NONE
32
+ case 0x88: stack.push(true); break; case 0x89: stack.push(false); break;
33
+ case 0x4a: stack.push(i32()); break; // BININT
34
+ case 0x4b: stack.push(u8()); break; // BININT1
35
+ case 0x4d: { stack.push(buf.readUInt16LE(p)); p += 2; break; } // BININT2
36
+ case 0x8a: { const n = u8(); const b = bytesN(n); let v = 0n; for (let i = n - 1; i >= 0; i--) v = (v << 8n) | BigInt(b[i]); if (n && b[n - 1] & 0x80) v -= 1n << BigInt(8 * n); stack.push(Number(v)); break; } // LONG1
37
+ case 0x8b: { const n = u32(); const b = bytesN(n); let v = 0n; for (let i = n - 1; i >= 0; i--) v = (v << 8n) | BigInt(b[i]); stack.push(Number(v)); break; } // LONG4
38
+ case 0x4c: { const e = buf.indexOf(0x0a, p); stack.push(Number(buf.toString('latin1', p, e).replace(/L$/, ''))); p = e + 1; break; } // LONG
39
+ case 0x49: { const e = buf.indexOf(0x0a, p); stack.push(Number(buf.toString('latin1', p, e))); p = e + 1; break; } // INT
40
+ case 0x47: { stack.push(buf.readDoubleBE(p)); p += 8; break; } // BINFLOAT
41
+ case 0x58: { const n = u32(); stack.push(bytesN(n).toString('utf8')); break; } // BINUNICODE
42
+ case 0x8c: { const n = u8(); stack.push(bytesN(n).toString('utf8')); break; } // SHORT_BINUNICODE
43
+ case 0x8d: { const n = Number(buf.readBigUInt64LE(p)); p += 8; stack.push(bytesN(n).toString('utf8')); break; } // BINUNICODE8
44
+ case 0x54: { const n = u32(); stack.push(bytesN(n)); break; } // BINSTRING (bytes)
45
+ case 0x55: { const n = u8(); stack.push(bytesN(n)); break; } // SHORT_BINSTRING
46
+ case 0x42: { const n = u32(); stack.push(bytesN(n)); break; } // BINBYTES
47
+ case 0x43: { const n = u8(); stack.push(bytesN(n)); break; } // SHORT_BINBYTES
48
+ case 0x8e: { const n = Number(buf.readBigUInt64LE(p)); p += 8; stack.push(bytesN(n)); break; } // BINBYTES8
49
+ case 0x53: { const e = buf.indexOf(0x0a, p); const s = buf.toString('latin1', p, e); p = e + 1; stack.push(Buffer.from(s.replace(/^['"]|['"]$/g, ''), 'latin1')); break; } // STRING
50
+ case 0x56: { const e = buf.indexOf(0x0a, p); stack.push(buf.toString('latin1', p, e)); p = e + 1; break; } // UNICODE
51
+ case 0x71: memo.set(u8(), stack.at(-1)); break; // BINPUT
52
+ case 0x72: memo.set(u32(), stack.at(-1)); break; // LONG_BINPUT
53
+ case 0x94: memo.set(memo.size, stack.at(-1)); break; // MEMOIZE
54
+ case 0x70: { const e = buf.indexOf(0x0a, p); memo.set(Number(buf.toString('latin1', p, e)), stack.at(-1)); p = e + 1; break; } // PUT
55
+ case 0x68: stack.push(memo.get(u8())); break; // BINGET
56
+ case 0x6a: stack.push(memo.get(u32())); break; // LONG_BINGET
57
+ case 0x67: { const e = buf.indexOf(0x0a, p); stack.push(memo.get(Number(buf.toString('latin1', p, e)))); p = e + 1; break; } // GET
58
+ case 0x74: stack.push(popMark()); break; // TUPLE
59
+ case 0x85: stack.push([stack.pop()]); break; // TUPLE1
60
+ case 0x86: { const b = stack.pop(), a = stack.pop(); stack.push([a, b]); break; } // TUPLE2
61
+ case 0x87: { const c = stack.pop(), b = stack.pop(), a = stack.pop(); stack.push([a, b, c]); break; } // TUPLE3
62
+ case 0x6c: stack.push(popMark()); break; // LIST
63
+ case 0x61: { const v = stack.pop(); stack.at(-1).push(v); break; } // APPEND
64
+ case 0x65: { const items = popMark(); stack.at(-1).push(...items); break; } // APPENDS
65
+ case 0x64: { const items = popMark(); const d = {}; for (let i = 0; i < items.length; i += 2) d[String(items[i])] = items[i + 1]; stack.push(d); break; } // DICT
66
+ case 0x73: { const v = stack.pop(), k = stack.pop(); stack.at(-1)[String(k)] = v; break; } // SETITEM
67
+ case 0x75: { const items = popMark(); const d = stack.at(-1); for (let i = 0; i < items.length; i += 2) d[String(items[i])] = items[i + 1]; break; } // SETITEMS
68
+ case 0x30: stack.pop(); break; // POP
69
+ case 0x31: popMark(); break; // POP_MARK
70
+ case 0x32: stack.push(stack.at(-1)); break; // DUP
71
+ case 0x95: p += 8; break; // FRAME
72
+ case 0x8f: stack.push(new Set()); break; // EMPTY_SET
73
+ case 0x90: { const items = popMark(); for (const it of items) stack.at(-1).add(it); break; } // ADDITEMS
74
+ case 0x91: stack.push(new Set(popMark())); break; // FROZENSET
75
+ case 0x63: { const e1 = buf.indexOf(0x0a, p); const e2 = buf.indexOf(0x0a, e1 + 1); stack.push({ __global__: `${buf.toString('latin1', p, e1)}.${buf.toString('latin1', e1 + 1, e2)}` }); p = e2 + 1; break; } // GLOBAL
76
+ case 0x93: { const n = stack.pop(), m = stack.pop(); stack.push({ __global__: `${m}.${n}` }); break; } // STACK_GLOBAL
77
+ case 0x52: case 0x81: case 0x62: case 0x4f: { const arg = stack.pop(); const cls = stack.pop(); stack.push({ __object__: cls?.__global__ ?? cls, args: arg }); break; } // REDUCE / NEWOBJ / BUILD / OBJ (approximate)
78
+ default: throw new RPError('BAD_FORMAT', `pickle opcode 0x${op.toString(16)} not supported at ${p - 1}`);
79
+ }
80
+ }
81
+ throw new RPError('BAD_FORMAT', 'pickle too long');
82
+ }
83
+
84
+ export async function readRpa(abs, size) {
85
+ const head = await readAt(abs, 0, 64);
86
+ const line = head.toString('latin1', 0, head.indexOf(0x0a) < 0 ? 64 : head.indexOf(0x0a));
87
+ const m = line.match(/^RPA-(\d\.\d) ([0-9a-fA-F]+)(?: ([0-9a-fA-F]+))?/);
88
+ if (!m) throw new RPError('BAD_FORMAT', 'Not an RPA-2.0/3.0 archive.');
89
+ const version = m[1], indexOffset = parseInt(m[2], 16), key = m[3] ? parseInt(m[3], 16) : 0;
90
+ if (indexOffset <= 0 || indexOffset >= size) throw new RPError('BAD_FORMAT', 'RPA index offset outside the file.');
91
+ const raw = await readAt(abs, indexOffset, size - indexOffset);
92
+ let idx;
93
+ try { idx = unpickle(zlib.inflateSync(raw)); } catch (e) { throw new RPError('BAD_FORMAT', `RPA index unreadable: ${e.message}`); }
94
+ const entries = [];
95
+ for (const [name, parts] of Object.entries(idx)) {
96
+ if (!Array.isArray(parts)) continue;
97
+ let total = 0, first = null, pieces = 0;
98
+ for (const part of parts) {
99
+ if (!Array.isArray(part)) continue;
100
+ const [off, len, prefix] = part;
101
+ const offset = (Number(off) ^ key) >>> 0, length = (Number(len) ^ key) >>> 0;
102
+ const pre = prefix instanceof Uint8Array ? Buffer.from(prefix) : typeof prefix === 'string' ? Buffer.from(prefix, 'latin1') : Buffer.alloc(0);
103
+ total += length + pre.length;
104
+ if (!first) first = { offset, length, prefix: pre };
105
+ pieces++;
106
+ }
107
+ if (!first) continue;
108
+ entries.push({ path: name, size: total, offset: first.offset, ...(first.prefix.length ? { prefix: first.prefix } : {}), ...(pieces > 1 ? { multipart: pieces, note: 'only the first piece is extracted' } : {}), ...(pieces > 1 ? { size: first.length + first.prefix.length } : {}) });
109
+ }
110
+ entries.sort((a, b) => a.path.localeCompare(b.path));
111
+ const rpyc = entries.filter(e => /\.rpyc$/i.test(e.path)).length, rpy = entries.filter(e => /\.rpy$/i.test(e.path)).length;
112
+ return { format: 'renpy-rpa', rpaVersion: version, keyed: !!key, entries, renpy: { compiledScripts: rpyc, sourceScripts: rpy, note: rpyc && !rpy ? '.rpyc files are compiled Ren\'Py AST (RPYC2 slots of pickled nodes); the original .rpy source is not inside. Dialogue and menu text can still be pulled with re_strings on extracted .rpyc (string scan) or from the game\'s tl/ folder.' : rpy ? 'source .rpy scripts are inside: extract them and run re_strings/re_graph on the folder.' : 'no scripts in this archive (images/audio/fonts).' } };
113
+ }
114
+
115
+ // ------------------------------------------------------------------------------------------------ GameMaker data.win
116
+ const GM_CHUNK_NAMES = { GEN8: 'general info', OPTN: 'options', LANG: 'languages', EXTN: 'extensions', SOND: 'sounds', AGRP: 'audio groups', SPRT: 'sprites', BGND: 'backgrounds/tilesets', PATH: 'paths', SCPT: 'scripts', GLOB: 'global init', SHDR: 'shaders', FONT: 'fonts', TMLN: 'timelines', OBJT: 'objects', ROOM: 'rooms', DAFL: 'data files', TPAG: 'texture pages (sprite frames)', CODE: 'GML bytecode', VARI: 'variables', FUNC: 'functions', STRG: 'strings', TXTR: 'texture sheets', AUDO: 'audio data', EMBI: 'embedded images', TGIN: 'texture group info', ACRV: 'animation curves', SEQN: 'sequences', TAGS: 'tags', FEDS: 'filter effects', PSEM: 'particle emitters', PSYS: 'particle systems', UILR: 'UI layers' };
117
+
118
+ export async function readGameMaker(abs, size) {
119
+ const head = await readAt(abs, 0, 8);
120
+ if (head.toString('latin1', 0, 4) !== 'FORM') throw new RPError('BAD_FORMAT', 'Not a GameMaker FORM file.');
121
+ const formLen = head.readUInt32LE(4);
122
+ const chunks = [];
123
+ let p = 8;
124
+ const chunkIndex = new Map();
125
+ while (p + 8 <= Math.min(size, 8 + formLen)) {
126
+ const h = await readAt(abs, p, 8);
127
+ const name = h.toString('latin1', 0, 4), len = h.readUInt32LE(4);
128
+ if (!/^[A-Z0-9]{4}$/.test(name)) break;
129
+ chunks.push({ name, meaning: GM_CHUNK_NAMES[name] ?? '?', offset: p + 8, size: len });
130
+ chunkIndex.set(name, { offset: p + 8, size: len });
131
+ p += 8 + len;
132
+ }
133
+ const strgAt = new Map();
134
+ const readString = async ptr => { if (!ptr || ptr + 4 > size) return null; if (strgAt.has(ptr)) return strgAt.get(ptr); const b = await readAt(abs, ptr - 4, 4); const n = b.readUInt32LE(0); if (n > 4096) return null; const s = (await readAt(abs, ptr, n)).toString('utf8'); strgAt.set(ptr, s); return s; };
135
+ const listOf = async name => { const c = chunkIndex.get(name); if (!c || c.size < 4) return []; const b = await readAt(abs, c.offset, Math.min(c.size, 4 + 4 * 200000)); const n = b.readUInt32LE(0); const out = []; for (let i = 0; i < Math.min(n, 200000) && 4 + 4 * i + 4 <= b.length; i++) out.push(b.readUInt32LE(4 + 4 * i)); return out; };
136
+ const namesOf = async (chunk, nameOffsetInRecord = 0) => { const ptrs = await listOf(chunk); const names = []; for (const ptr of ptrs.slice(0, 5000)) { if (!ptr || ptr + nameOffsetInRecord + 4 > size) continue; const rec = await readAt(abs, ptr + nameOffsetInRecord, 4); const s = await readString(rec.readUInt32LE(0)); if (s) names.push(s); } return names; };
137
+ const info = {};
138
+ const gen8 = chunkIndex.get('GEN8');
139
+ if (gen8) {
140
+ const g = await readAt(abs, gen8.offset, Math.min(gen8.size, 128));
141
+ info.bytecodeVersion = g[1];
142
+ info.name = await readString(g.readUInt32LE(4));
143
+ info.displayName = await readString(g.readUInt32LE(0x2c));
144
+ info.version = `${g.readUInt32LE(0x30)}.${g.readUInt32LE(0x34)}.${g.readUInt32LE(0x38)}.${g.readUInt32LE(0x3c)}`;
145
+ info.windowSize = [g.readUInt32LE(0x40), g.readUInt32LE(0x44)];
146
+ info.gameMakerFamily = info.bytecodeVersion >= 17 ? 'GameMaker Studio 2.3+ / GameMaker 2022+' : info.bytecodeVersion >= 15 ? 'GameMaker Studio 2' : 'GameMaker: Studio 1.x';
147
+ }
148
+ const entries = [];
149
+ // Texture sheets: each TXTR record points at an embedded PNG (or QOI/BZ2 in newer runtimes).
150
+ const txtr = chunkIndex.get('TXTR');
151
+ if (txtr) {
152
+ const ptrs = await listOf('TXTR');
153
+ for (let i = 0; i < ptrs.length; i++) {
154
+ const rec = await readAt(abs, ptrs[i], 16);
155
+ let dataPtr = null;
156
+ for (const cand of [rec.readUInt32LE(8), rec.readUInt32LE(12), rec.readUInt32LE(4)]) { if (cand > 0 && cand < size) { const sig = await readAt(abs, cand, 8); if (sig[0] === 0x89 && sig.toString('latin1', 1, 4) === 'PNG') { dataPtr = cand; break; } if (sig.toString('latin1', 0, 4) === 'qoif' || sig.toString('latin1', 0, 4) === '2zoq') { dataPtr = cand; break; } } }
157
+ if (dataPtr == null) continue;
158
+ const sig = await readAt(abs, dataPtr, 4);
159
+ const isPng = sig[0] === 0x89;
160
+ let len = 0;
161
+ if (isPng) { // walk PNG chunks to IEND
162
+ let q = dataPtr + 8;
163
+ for (let guard = 0; guard < 100000 && q + 8 <= size; guard++) { const ch = await readAt(abs, q, 8); const l = ch.readUInt32BE(0), t = ch.toString('latin1', 4, 8); q += 12 + l; if (t === 'IEND') break; }
164
+ len = q - dataPtr;
165
+ } else { const next = ptrs[i + 1] ? (await readAt(abs, ptrs[i + 1], 16)).readUInt32LE(8) : null; len = (next && next > dataPtr ? next : (txtr.offset + txtr.size)) - dataPtr; }
166
+ entries.push({ path: `textures/texture_${i}.${isPng ? 'png' : 'qoi'}`, offset: dataPtr, size: len });
167
+ }
168
+ }
169
+ const audo = chunkIndex.get('AUDO');
170
+ if (audo) {
171
+ const ptrs = await listOf('AUDO');
172
+ const soundNames = await namesOf('SOND', 0);
173
+ for (let i = 0; i < ptrs.length; i++) {
174
+ const h = await readAt(abs, ptrs[i], 8);
175
+ const len = h.readUInt32LE(0);
176
+ const sig = h.toString('latin1', 4, 8);
177
+ const ext = sig === 'RIFF' ? 'wav' : sig === 'OggS' ? 'ogg' : 'bin';
178
+ entries.push({ path: `audio/${soundNames[i] ?? `audio_${i}`}.${ext}`, offset: ptrs[i] + 4, size: len });
179
+ }
180
+ }
181
+ const names = {};
182
+ for (const [chunk, key] of [['SPRT', 'sprites'], ['OBJT', 'objects'], ['ROOM', 'rooms'], ['SCPT', 'scripts'], ['SOND', 'sounds'], ['FONT', 'fonts'], ['SHDR', 'shaders'], ['BGND', 'backgrounds'], ['PATH', 'paths'], ['TMLN', 'timelines'], ['SEQN', 'sequences']]) { const list = await namesOf(chunk, 0).catch(() => []); if (list.length) names[key] = list; }
183
+ const strg = chunkIndex.get('STRG');
184
+ let stringCount = 0;
185
+ if (strg) stringCount = (await readAt(abs, strg.offset, 4)).readUInt32LE(0);
186
+ const code = chunkIndex.get('CODE');
187
+ const codeEntries = code ? (await readAt(abs, code.offset, 4)).readUInt32LE(0) : 0;
188
+ return {
189
+ format: 'gamemaker-data', entries, chunks: chunks.map(c => ({ name: c.name, meaning: c.meaning, size: c.size })), game: info, counts: { strings: stringCount, codeEntries, ...Object.fromEntries(Object.entries(names).map(([k, v]) => [k, v.length])) }, names: Object.fromEntries(Object.entries(names).map(([k, v]) => [k, v.slice(0, 300)])),
190
+ note: 'Textures and audio are stored in the clear and extract as PNG/QOI and WAV/OGG (names from SOND). GML is VM bytecode in CODE; the strings, object, script and room names above are the map for a rebuild (re_blueprint) — port behaviour from them, not from decompiled code.',
191
+ };
192
+ }
193
+
194
+ // ------------------------------------------------------------------------------------------------ FMOD / Wwise
195
+ const FSB_CODECS = { 1: 'PCM8', 2: 'PCM16', 3: 'PCM24', 4: 'PCM32', 5: 'PCMFLOAT', 6: 'GCADPCM', 7: 'IMAADPCM', 8: 'VAG', 9: 'HEVAG', 10: 'XMA', 11: 'MPEG', 12: 'CELT', 13: 'AT9', 14: 'XWMA', 15: 'VORBIS', 16: 'FADPCM', 17: 'OPUS' };
196
+ export async function readFsb5(abs, base, maxLen) {
197
+ const h = await readAt(abs, base, 64);
198
+ if (h.toString('latin1', 0, 4) !== 'FSB5') return null;
199
+ const version = h.readUInt32LE(4), numSamples = h.readUInt32LE(8), headersSize = h.readUInt32LE(12), namesSize = h.readUInt32LE(16), dataSize = h.readUInt32LE(20), mode = h.readUInt32LE(24);
200
+ const headerLen = version === 0 ? 64 : 60;
201
+ const hdr = await readAt(abs, base + headerLen, headersSize);
202
+ const names = namesSize ? await readAt(abs, base + headerLen + headersSize, namesSize) : null;
203
+ const dataBase = base + headerLen + headersSize + namesSize;
204
+ const samples = [];
205
+ let p = 0;
206
+ const RATES = { 0: 4000, 1: 8000, 2: 11000, 3: 11025, 4: 16000, 5: 22050, 6: 24000, 7: 32000, 8: 44100, 9: 48000, 10: 96000 };
207
+ for (let i = 0; i < numSamples && p + 8 <= hdr.length; i++) {
208
+ const raw = hdr.readBigUInt64LE(p); p += 8;
209
+ let next = Number(raw & 1n);
210
+ const freqIdx = Number((raw >> 1n) & 0xfn), channels = Number((raw >> 5n) & 1n) + 1, dataOffset = Number((raw >> 6n) & 0xfffffffn) * 32, sampleCount = Number(raw >> 34n);
211
+ let frequency = RATES[freqIdx] ?? 0;
212
+ while (next && p + 4 <= hdr.length) { const c = hdr.readUInt32LE(p); p += 4; next = c & 1; const chunkSize = (c >> 1) & 0xffffff, type = (c >> 25) & 0x7f; if (type === 1 && chunkSize >= 1) channels; if (type === 2 && chunkSize >= 4) frequency = hdr.readUInt32LE(p); p += chunkSize; }
213
+ samples.push({ index: i, frequency, channels, dataOffset, samples: sampleCount });
214
+ }
215
+ for (let i = 0; i < samples.length; i++) samples[i].size = (i + 1 < samples.length ? samples[i + 1].dataOffset : dataSize) - samples[i].dataOffset;
216
+ if (names) { let q = 0; for (let i = 0; i < samples.length && q + 4 <= names.length; i++) { const off = names.readUInt32LE(4 * i); const e = names.indexOf(0, off); samples[i].name = names.toString('utf8', off, e < 0 ? names.length : e); q += 4; } }
217
+ const codec = FSB_CODECS[mode] ?? `codec ${mode}`;
218
+ const entries = samples.map(s => ({ path: `samples/${s.name ?? `sample_${s.index}`}.${codec === 'VORBIS' ? 'fsbvorbis' : codec === 'MPEG' ? 'mp3' : codec.startsWith('PCM') ? 'pcm' : codec.toLowerCase()}`, offset: dataBase + s.dataOffset, size: s.size, frequency: s.frequency, channels: s.channels, samples: s.samples, codec }));
219
+ return { fsbVersion: version, codec, samples: samples.length, entries, totalDataSize: dataSize, note: codec === 'VORBIS' ? 'FSB5 Vorbis packets have no Ogg headers (FMOD keeps the setup tables keyed by CRC); extracted .fsbvorbis needs a tool with FMOD\'s header table (e.g. vgmstream) to become .ogg.' : codec.startsWith('PCM') ? 'raw PCM: wrap in a WAV header with the listed frequency/channels.' : `${codec} data as stored.` };
220
+ }
221
+
222
+ export async function readFmodBank(abs, size) {
223
+ const head = await readAt(abs, 0, 12);
224
+ if (head.toString('latin1', 0, 4) !== 'RIFF' || head.toString('latin1', 8, 12) !== 'FEV ') throw new RPError('BAD_FORMAT', 'Not an FMOD Studio bank (RIFF FEV).');
225
+ const chunks = [];
226
+ let p = 12;
227
+ let fsb = null;
228
+ const strings = [];
229
+ while (p + 8 <= size) {
230
+ const h = await readAt(abs, p, 8);
231
+ const id = h.toString('latin1', 0, 4), len = h.readUInt32LE(4);
232
+ chunks.push({ id, size: len });
233
+ if (id === 'SND ') {
234
+ // Sample data chunk: FSB5 sits at a 32-byte aligned offset inside.
235
+ const scan = await readAt(abs, p + 8, Math.min(len, 64));
236
+ const at = scan.indexOf('FSB5');
237
+ if (at >= 0) fsb = await readFsb5(abs, p + 8 + at, len);
238
+ }
239
+ if (id === 'LIST' || id === 'PROJ' || id === 'BNKI') { const b = await readAt(abs, p + 8, Math.min(len, 65536)); for (const m of b.toString('latin1').matchAll(/[\x20-\x7e]{6,}/g)) if (/^[\w:/ .-]+$/.test(m[0])) strings.push(m[0]); }
240
+ p += 8 + len + (len & 1);
241
+ }
242
+ const events = strings.filter(s => /^event:\//.test(s)), buses = strings.filter(s => /^bus:\//.test(s)), vcas = strings.filter(s => /^vca:\//.test(s));
243
+ return { format: 'fmod-bank', chunks, entries: fsb?.entries ?? [], fsb: fsb ? { version: fsb.fsbVersion, codec: fsb.codec, samples: fsb.samples, note: fsb.note } : null, events: [...new Set(events)].slice(0, 500), buses: [...new Set(buses)], vcas: [...new Set(vcas)], note: events.length ? 'Event paths come from the bank\'s string table; the authoring project (.fspro) is needed for parameters and logic. Port by recreating events in the target engine\'s audio system with the same names.' : 'No event names in this bank (strings bank missing: Master.strings.bank holds the GUID→path table). Samples are listed from the embedded FSB5.' };
244
+ }
245
+
246
+ const HIRC_TYPES = { 1: 'settings', 2: 'sound', 3: 'action', 4: 'event', 5: 'random/sequence container', 6: 'switch container', 7: 'actor-mixer', 8: 'audio bus', 9: 'blend container', 10: 'music segment', 11: 'music track', 12: 'music switch', 13: 'music playlist', 14: 'attenuation', 15: 'dialogue event', 16: 'fx share set', 17: 'fx custom', 18: 'aux bus', 19: 'lfo', 20: 'envelope', 21: 'audio device', 22: 'time mod' };
247
+ export async function readWwiseBank(abs, size) {
248
+ const head = await readAt(abs, 0, 8);
249
+ if (head.toString('latin1', 0, 4) !== 'BKHD') throw new RPError('BAD_FORMAT', 'Not a Wwise sound bank (BKHD).');
250
+ const chunks = [];
251
+ let p = 0, bankVersion = null, bankId = null, didx = [], dataOffset = null, hirc = {}, hircCount = 0;
252
+ while (p + 8 <= size) {
253
+ const h = await readAt(abs, p, 8);
254
+ const id = h.toString('latin1', 0, 4), len = h.readUInt32LE(4);
255
+ chunks.push({ id, size: len });
256
+ if (id === 'BKHD') { const b = await readAt(abs, p + 8, Math.min(len, 16)); bankVersion = b.readUInt32LE(0); bankId = b.readUInt32LE(4); }
257
+ if (id === 'DIDX') { const b = await readAt(abs, p + 8, len); for (let q = 0; q + 12 <= b.length; q += 12) didx.push({ id: b.readUInt32LE(q), offset: b.readUInt32LE(q + 4), size: b.readUInt32LE(q + 8) }); }
258
+ if (id === 'DATA') dataOffset = p + 8;
259
+ if (id === 'HIRC') { const b = await readAt(abs, p + 8, Math.min(len, 4 * 1024 * 1024)); hircCount = b.readUInt32LE(0); let q = 4; for (let i = 0; i < hircCount && q + 9 <= b.length; i++) { const type = b[q], l = b.readUInt32LE(q + 1); const name = HIRC_TYPES[type] ?? `type ${type}`; hirc[name] = (hirc[name] ?? 0) + 1; q += 5 + l; } }
260
+ p += 8 + len;
261
+ }
262
+ // SoundbanksInfo.xml next to the bank maps IDs to names when the project exported it.
263
+ const names = new Map();
264
+ for (const cand of ['SoundbanksInfo.xml', '../SoundbanksInfo.xml']) {
265
+ const f = path.resolve(path.dirname(abs), cand);
266
+ const xml = await fs.readFile(f, 'utf8').catch(() => null);
267
+ if (!xml) continue;
268
+ for (const m of xml.matchAll(/<(?:File|Event|Sound)\s+Id="(\d+)"[^>]*>\s*(?:<ShortName>([^<]*)<\/ShortName>|<Path>([^<]*)<\/Path>)?/g)) names.set(Number(m[1]), m[2] ?? m[3] ?? null);
269
+ break;
270
+ }
271
+ const entries = dataOffset != null ? didx.map(d => ({ path: `media/${names.get(d.id) ? names.get(d.id).replace(/\.wav$/i, '') : d.id}.wem`, offset: dataOffset + d.offset, size: d.size, wemId: d.id })) : [];
272
+ return { format: 'wwise-bank', bankVersion, bankId, chunks, entries, objects: hirc, objectCount: hircCount, namedIds: names.size, note: 'Embedded media are .wem (Wwise Vorbis/ADPCM/PCM/Opus): convert with vgmstream or ww2ogg+revorb. Events and sounds are hashed IDs; SoundbanksInfo.xml (when the project exported it) gives the names. Streamed media live as separate .wem files next to the bank.' };
273
+ }
274
+
275
+ // ------------------------------------------------------------------------------------------------ Unreal .pak
276
+ const PAK_MAGIC = Buffer.from([0xe1, 0x12, 0x6f, 0x5a]);
277
+ export async function readUnrealPak(abs, size) {
278
+ const tailLen = Math.min(size, 1024);
279
+ const tail = await readAt(abs, size - tailLen, tailLen);
280
+ let m = -1;
281
+ // The footer layout differs per version; the magic is 4 bytes before the version field.
282
+ for (let i = tail.length - 4; i >= 0; i--) if (tail[i] === 0xe1 && tail[i + 1] === 0x12 && tail[i + 2] === 0x6f && tail[i + 3] === 0x5a) { const v = tail.readUInt32LE(i + 4); if (v >= 1 && v <= 12 && i + 28 <= tail.length) { m = i; break; } }
283
+ if (m < 0) throw new RPError('BAD_FORMAT', 'No Unreal pak footer found.');
284
+ const version = tail.readUInt32LE(m + 4);
285
+ const indexOffset = Number(tail.readBigUInt64LE(m + 8)), indexSize = Number(tail.readBigUInt64LE(m + 16));
286
+ const encryptedIndex = version >= 4 ? tail[m - 1] === 1 : false;
287
+ const encryptionGuid = version >= 7 ? tail.subarray(m - 17, m - 1).toString('hex') : null;
288
+ const compressionMethods = [];
289
+ if (version >= 8) { const n = version >= 10 ? 5 : 4; const base = m + 8 + 8 + 8 + 20; for (let i = 0; i < n && base + 32 * (i + 1) <= tail.length; i++) { const s = tail.toString('latin1', base + 32 * i, base + 32 * (i + 1)).replace(/\0.*$/s, ''); if (s) compressionMethods.push(s); } }
290
+ const methods = version >= 8 ? ['None', ...compressionMethods] : ['None', 'Zlib', 'Gzip', 'Custom', 'Oodle'];
291
+ const base = { format: 'unreal-pak', pakVersion: version, indexOffset, indexSize, encryptedIndex, ...(encryptionGuid && !/^0+$/.test(encryptionGuid) ? { encryptionKeyGuid: encryptionGuid } : {}), compressionMethods: methods.slice(1) };
292
+ if (encryptedIndex) return { ...base, entries: [], note: 'The pak index is AES-encrypted: entries cannot be listed and nothing is decrypted here. Port from the Unreal project source (Content/, Source/).' };
293
+ if (indexOffset <= 0 || indexOffset + indexSize > size || indexSize > 512 * 1024 * 1024) throw new RPError('BAD_FORMAT', 'pak index outside the file.');
294
+ const idx = await readAt(abs, indexOffset, indexSize);
295
+ let p = 0;
296
+ const fstring = () => { const n = idx.readInt32LE(p); p += 4; if (n < 0) { const s = idx.toString('utf16le', p, p + (-n) * 2 - 2); p += (-n) * 2; return s; } const s = idx.toString('utf8', p, p + Math.max(0, n - 1)); p += n; return s; };
297
+ const mountPoint = fstring();
298
+ const count = idx.readInt32LE(p); p += 4;
299
+ const entries = [];
300
+ const readEntry = (buf, q, ver) => {
301
+ const e = {};
302
+ e.offset = Number(buf.readBigUInt64LE(q)); q += 8;
303
+ e.compressedSize = Number(buf.readBigUInt64LE(q)); q += 8;
304
+ e.size = Number(buf.readBigUInt64LE(q)); q += 8;
305
+ e.compression = ver >= 8 ? buf.readUInt32LE(q) : buf.readUInt32LE(q); q += 4;
306
+ if (ver <= 1) { q += 8; } // timestamp
307
+ q += 20; // sha1
308
+ if (ver >= 3) {
309
+ if (e.compression !== 0) { const blocks = buf.readUInt32LE(q); q += 4; e.blocks = []; for (let i = 0; i < blocks; i++) { e.blocks.push({ start: Number(buf.readBigUInt64LE(q)), end: Number(buf.readBigUInt64LE(q + 8)) }); q += 16; } }
310
+ e.encrypted = buf[q] === 1; q += 1;
311
+ e.blockSize = buf.readUInt32LE(q); q += 4;
312
+ }
313
+ return { entry: e, next: q };
314
+ };
315
+ if (version < 10) {
316
+ for (let i = 0; i < count && p < idx.length; i++) {
317
+ const name = fstring();
318
+ const { entry, next } = readEntry(idx, p, version);
319
+ p = next;
320
+ entries.push({ path: (mountPoint.replace(/^\.\.\/\.\.\/\.\.\//, '') + name).replace(/^\/+/, ''), ...entry, compressionMethod: methods[entry.compression] ?? `method ${entry.compression}` });
321
+ }
322
+ } else {
323
+ // v10/11: path hash index + full directory index with encoded entries.
324
+ p += 8; // path hash seed
325
+ const hasPathHash = idx.readInt32LE(p); p += 4;
326
+ if (hasPathHash) p += 8 + 8 + 20;
327
+ const hasFullDir = idx.readInt32LE(p); p += 4;
328
+ let fdOffset = 0, fdSize = 0;
329
+ if (hasFullDir) { fdOffset = Number(idx.readBigUInt64LE(p)); fdSize = Number(idx.readBigUInt64LE(p + 8)); p += 16 + 20; }
330
+ const encodedSize = idx.readInt32LE(p); p += 4;
331
+ const encoded = idx.subarray(p, p + encodedSize); p += encodedSize;
332
+ const decode = off => {
333
+ const flags = encoded.readUInt32LE(off); let q = off + 4;
334
+ const compression = (flags >> 23) & 0x3f, encrypted = !!(flags & (1 << 22)), blockCount = (flags >> 6) & 0xffff, blockSizeBits = flags & 0x3f;
335
+ const offset32 = !!(flags & (1 << 31)), size32 = !!(flags & (1 << 30)), csize32 = !!(flags & (1 << 29));
336
+ const offset = offset32 ? encoded.readUInt32LE(q) : Number(encoded.readBigUInt64LE(q)); q += offset32 ? 4 : 8;
337
+ const size = size32 ? encoded.readUInt32LE(q) : Number(encoded.readBigUInt64LE(q)); q += size32 ? 4 : 8;
338
+ let compressedSize = size;
339
+ if (compression !== 0) { compressedSize = csize32 ? encoded.readUInt32LE(q) : Number(encoded.readBigUInt64LE(q)); q += csize32 ? 4 : 8; }
340
+ return { offset, size, compressedSize, compression, encrypted, blocks: blockCount, blockSize: blockSizeBits === 0x3f ? compressedSize : blockSizeBits << 11 };
341
+ };
342
+ if (hasFullDir && fdOffset >= indexOffset && fdOffset + fdSize <= size) {
343
+ const fd = await readAt(abs, fdOffset, fdSize);
344
+ let q = 0;
345
+ const fstr = () => { const n = fd.readInt32LE(q); q += 4; if (n < 0) { const s = fd.toString('utf16le', q, q + (-n) * 2 - 2); q += (-n) * 2; return s; } const s = fd.toString('utf8', q, q + Math.max(0, n - 1)); q += n; return s; };
346
+ const dirs = fd.readInt32LE(q); q += 4;
347
+ for (let d = 0; d < dirs && q < fd.length; d++) {
348
+ const dir = fstr();
349
+ const files = fd.readInt32LE(q); q += 4;
350
+ for (let f = 0; f < files && q < fd.length; f++) {
351
+ const name = fstr();
352
+ const encOff = fd.readInt32LE(q); q += 4;
353
+ if (encOff < 0 || encOff + 4 > encoded.length) { entries.push({ path: `${mountPoint}${dir}${name}`.replace(/^\.\.\/\.\.\/\.\.\//, '').replace(/^\/+/, ''), size: null, note: 'entry not encoded (listed in the path hash index only)' }); continue; }
354
+ const e = decode(encOff);
355
+ entries.push({ path: `${mountPoint}${dir}${name}`.replace(/^\.\.\/\.\.\/\.\.\//, '').replace(/^\/+/, '').replace(/\/\.\//g, '/'), ...e, compressionMethod: methods[e.compression] ?? `method ${e.compression}` });
356
+ }
357
+ }
358
+ } else base.note = 'No full directory index (pak built with -nodirectoryindex): only the count is known.';
359
+ base.encodedEntries = count;
360
+ }
361
+ // Data position: each entry's data is preceded by a copy of its FPakEntry header.
362
+ for (const e of entries) {
363
+ if (e.size == null) continue;
364
+ const headerLen = 8 + 8 + 8 + 4 + 20 + (version <= 1 ? 8 : 0) + (version >= 3 ? 1 + 4 + (e.compression ? 4 + 16 * (e.blocks?.length ?? e.blocks ?? 0) : 0) : 0);
365
+ e.dataOffset = e.offset + headerLen;
366
+ if (e.encrypted) { e.encryptedData = true; }
367
+ e.extractable = !e.encrypted && (e.compression === 0 || /^(Zlib|Gzip)$/i.test(e.compressionMethod));
368
+ if (e.extractable && e.compression === 0) { e.offset = e.dataOffset; }
369
+ else if (e.extractable) { e.zlibBlocks = (Array.isArray(e.blocks) ? e.blocks : []).map(b => ({ start: e.offset + b.start - (version >= 5 ? e.offset : 0), end: e.offset + b.end - (version >= 5 ? e.offset : 0) })); if (!e.zlibBlocks.length) e.extractable = false; e.offset = e.dataOffset; }
370
+ else e.offset = e.dataOffset;
371
+ }
372
+ const byExt = {};
373
+ for (const e of entries) { const ext = path.extname(e.path).toLowerCase() || '(none)'; byExt[ext] = (byExt[ext] ?? 0) + 1; }
374
+ const uassets = entries.filter(e => /\.uasset$/i.test(e.path)).length, umaps = entries.filter(e => /\.umap$/i.test(e.path)).length;
375
+ return { ...base, mountPoint, entries, unreal: { uassets, umaps, note: `${uassets} .uasset (${umaps} maps). Cooked .uasset/.uexp hold serialized UObjects, not source: Blueprints are bytecode, C++ is compiled into the game binary. Port from the project (Content/*.uasset in the editor, Source/) when you have it; from a pak, re_binary on the executable plus asset names and this listing give the rebuild map.` }, note: entries.some(e => e.encrypted) ? 'Some entries are AES-encrypted: they are listed, never decrypted.' : entries.some(e => e.compression && !e.extractable) ? 'Oodle/LZ4-compressed entries are listed; only stored and Zlib entries extract here.' : undefined };
376
+ }
377
+
378
+ // ------------------------------------------------------------------------------------------------ Flash SWF
379
+ const SWF_TAGS = { 0: 'End', 1: 'ShowFrame', 2: 'DefineShape', 4: 'PlaceObject', 5: 'RemoveObject', 6: 'DefineBits', 7: 'DefineButton', 8: 'JPEGTables', 9: 'SetBackgroundColor', 10: 'DefineFont', 11: 'DefineText', 12: 'DoAction', 13: 'DefineFontInfo', 14: 'DefineSound', 15: 'StartSound', 17: 'DefineButtonSound', 18: 'SoundStreamHead', 19: 'SoundStreamBlock', 20: 'DefineBitsLossless', 21: 'DefineBitsJPEG2', 22: 'DefineShape2', 23: 'DefineButtonCxform', 24: 'Protect', 26: 'PlaceObject2', 28: 'RemoveObject2', 32: 'DefineShape3', 33: 'DefineText2', 34: 'DefineButton2', 35: 'DefineBitsJPEG3', 36: 'DefineBitsLossless2', 37: 'DefineEditText', 39: 'DefineSprite', 43: 'FrameLabel', 45: 'SoundStreamHead2', 46: 'DefineMorphShape', 48: 'DefineFont2', 56: 'ExportAssets', 57: 'ImportAssets', 58: 'EnableDebugger', 59: 'DoInitAction', 60: 'DefineVideoStream', 61: 'VideoFrame', 62: 'DefineFontInfo2', 64: 'EnableDebugger2', 65: 'ScriptLimits', 66: 'SetTabIndex', 69: 'FileAttributes', 70: 'PlaceObject3', 71: 'ImportAssets2', 73: 'DefineFontAlignZones', 74: 'CSMTextSettings', 75: 'DefineFont3', 76: 'SymbolClass', 77: 'Metadata', 78: 'DefineScalingGrid', 82: 'DoABC', 83: 'DefineShape4', 84: 'DefineMorphShape2', 86: 'DefineSceneAndFrameLabelData', 87: 'DefineBinaryData', 88: 'DefineFontName', 89: 'StartSound2', 90: 'DefineBitsJPEG4', 91: 'DefineFont4' };
380
+
381
+ function abcStrings(abc) {
382
+ // abcFile: minor u16, major u16, cpool: int_count + ints (u30), uint, double (u64), string_count + strings (u30 len + utf8)
383
+ let p = 4;
384
+ const u30 = () => { let r = 0, s = 0; for (let i = 0; i < 5; i++) { const b = abc[p++]; r |= (b & 0x7f) << s; s += 7; if (!(b & 0x80)) break; } return r >>> 0; };
385
+ const skipArr = (count, each) => { for (let i = 0; i < Math.max(0, count - 1); i++) each(); };
386
+ skipArr(u30(), u30); skipArr(u30(), u30); const dc = u30(); p += Math.max(0, dc - 1) * 8;
387
+ const sc = u30(); const out = [];
388
+ for (let i = 0; i < sc - 1 && p < abc.length; i++) { const n = u30(); out.push(abc.toString('utf8', p, p + n)); p += n; }
389
+ return out;
390
+ }
391
+
392
+ export async function readSwf(abs, size) {
393
+ const head = await readAt(abs, 0, 8);
394
+ const sig = head.toString('latin1', 0, 3);
395
+ if (!['FWS', 'CWS', 'ZWS'].includes(sig)) throw new RPError('BAD_FORMAT', 'Not a SWF file.');
396
+ const version = head[3], fileLength = head.readUInt32LE(4);
397
+ let body;
398
+ if (sig === 'FWS') body = await readAt(abs, 8, Math.min(size - 8, 256 * 1024 * 1024));
399
+ else if (sig === 'CWS') { try { body = zlib.inflateSync(await readAt(abs, 8, size - 8)); } catch { throw new RPError('BAD_FORMAT', 'SWF zlib body did not inflate.'); } }
400
+ else return { format: 'swf', swfVersion: version, compression: 'LZMA', fileLength, entries: [], note: 'ZWS (LZMA-compressed) SWF: Node has no LZMA decoder; decompress with 7-Zip/xz into FWS first (header: FWS + version + length, then the raw body), then run re_archive again.' };
401
+ // Header: RECT (nbits), frame rate (fixed 8.8), frame count.
402
+ let p = 0;
403
+ const nbits = body[0] >> 3;
404
+ p = Math.ceil((5 + nbits * 4) / 8);
405
+ const twips = (() => { let bit = 5; const read = () => { let v = 0; for (let i = 0; i < nbits; i++) { const b = (body[bit >> 3] >> (7 - (bit & 7))) & 1; v = (v << 1) | b; bit++; } return v; }; return [read(), read(), read(), read()]; })();
406
+ const frameRate = body.readUInt16LE(p) / 256; p += 2;
407
+ const frameCount = body.readUInt16LE(p); p += 2;
408
+ const tags = {}, entries = [], symbols = [], labels = [], classes = new Set(), strings = new Set();
409
+ let as3 = false, as2 = 0;
410
+ for (let guard = 0; guard < 1_000_000 && p + 2 <= body.length; guard++) {
411
+ const h = body.readUInt16LE(p); p += 2;
412
+ const code = h >> 6; let len = h & 0x3f;
413
+ if (len === 0x3f) { len = body.readUInt32LE(p); p += 4; }
414
+ const name = SWF_TAGS[code] ?? `tag${code}`;
415
+ tags[name] = (tags[name] ?? 0) + 1;
416
+ const data = body.subarray(p, p + len);
417
+ if (code === 0) break;
418
+ if (code === 82) { as3 = true; const flags = data.readUInt32LE(0); void flags; const e = data.indexOf(0, 4); const abcName = data.toString('utf8', 4, e); const abc = data.subarray(e + 1); try { for (const s of abcStrings(abc)) if (/^[A-Za-z_$][\w$]*(?:[.:][A-Za-z_$][\w$]*)*$/.test(s) && s.length > 2) { strings.add(s); if (/^[A-Z]/.test(s.split(/[.:]/).at(-1)) && s.length < 80) classes.add(s); } } catch { /* partial abc */ } entries.push({ path: `abc/${abcName || `script_${entries.length}`}.abc`, offset: sig === 'FWS' ? 8 + p + e + 1 : null, size: abc.length, inflated: sig !== 'FWS' }); }
419
+ if (code === 12 || code === 59) as2++;
420
+ if (code === 76 || code === 56) { let q = 0; const n = data.readUInt16LE(q); q += 2; for (let i = 0; i < n && q < data.length; i++) { const id = data.readUInt16LE(q); q += 2; const e = data.indexOf(0, q); symbols.push({ id, name: data.toString('utf8', q, e) }); q = e + 1; } }
421
+ if (code === 43) labels.push(data.toString('utf8', 0, data.indexOf(0) < 0 ? data.length : data.indexOf(0)));
422
+ if (code === 21 || code === 35 || code === 90) { const id = data.readUInt16LE(0); let off = 2; if (code === 35 || code === 90) off += 4; const img = data.subarray(off); const isPng = img[0] === 0x89, isGif = img.toString('latin1', 0, 3) === 'GIF'; entries.push({ path: `images/image_${id}.${isPng ? 'png' : isGif ? 'gif' : 'jpg'}`, offset: sig === 'FWS' ? 8 + p + off : null, size: code === 21 ? img.length : data.readUInt32LE(2), inflated: sig !== 'FWS', characterId: id }); }
423
+ if (code === 20 || code === 36) { const id = data.readUInt16LE(0); entries.push({ path: `images/lossless_${id}.zlib-bitmap`, offset: sig === 'FWS' ? 8 + p : null, size: len, inflated: sig !== 'FWS', characterId: id, note: `format ${data[2]} ${data.readUInt16LE(3)}x${data.readUInt16LE(5)}: zlib bitmap, convert by hand` }); }
424
+ if (code === 14) { const id = data.readUInt16LE(0); const fmt = data[2] >> 4; const codec = ['PCM', 'ADPCM', 'MP3', 'PCM-LE', 'Nellymoser16', 'Nellymoser8', 'Nellymoser', null, null, null, null, 'Speex'][fmt] ?? `codec ${fmt}`; entries.push({ path: `sounds/sound_${id}.${codec === 'MP3' ? 'mp3' : 'raw'}`, offset: sig === 'FWS' ? 8 + p + 7 + (codec === 'MP3' ? 2 : 0) : null, size: len - 7 - (codec === 'MP3' ? 2 : 0), inflated: sig !== 'FWS', characterId: id, codec }); }
425
+ if (code === 87) { const id = data.readUInt16LE(0); entries.push({ path: `binary/data_${id}.bin`, offset: sig === 'FWS' ? 8 + p + 6 : null, size: len - 6, inflated: sig !== 'FWS', characterId: id }); }
426
+ p += len;
427
+ }
428
+ const symbolClasses = symbols.map(s => s.name);
429
+ const documentClass = symbols.find(s => s.id === 0)?.name ?? null;
430
+ return {
431
+ format: 'swf', swfVersion: version, compression: sig === 'CWS' ? 'zlib' : 'none', fileLength, stage: { width: (twips[1] - twips[0]) / 20, height: (twips[3] - twips[2]) / 20, frameRate, frameCount }, tags, entries,
432
+ actionScript: as3 ? 'AS3 (DoABC)' : as2 ? 'AS2 (DoAction)' : 'none', documentClass, symbolClasses: symbolClasses.slice(0, 300), frameLabels: labels.slice(0, 300), classes: [...classes].slice(0, 500), identifierStrings: strings.size,
433
+ note: sig === 'CWS' ? 'Compressed SWF: entries are listed from the inflated body; to extract, save the inflated body as FWS (re_archive action:"extract" writes it) and extract from that.' : 'Images, sounds and ABC blocks extract as-is; shapes, sprites and timelines are vector/timeline data (rebuild from the symbol and class names with re_blueprint; Ruffle can run the original).',
434
+ };
435
+ }
436
+
437
+ // ------------------------------------------------------------------------------------------------ web exports
438
+ /** Unity WebGL Build/*.data (UnityWebData1.0), also gzip/brotli-compressed (.data.gz, .data.br, .unityweb). */
439
+ export async function readUnityWebData(abs) {
440
+ let buf = await fs.readFile(abs);
441
+ const layers = [];
442
+ if (buf[0] === 0x1f && buf[1] === 0x8b) { buf = zlib.gunzipSync(buf); layers.push('gzip'); }
443
+ else if (!buf.subarray(0, 16).toString('latin1').startsWith('UnityWebData')) { try { const b = zlib.brotliDecompressSync(buf); if (b.subarray(0, 16).toString('latin1').startsWith('UnityWebData')) { buf = b; layers.push('brotli'); } } catch { /* not brotli */ } }
444
+ if (!buf.subarray(0, 16).toString('latin1').startsWith('UnityWebData')) throw new RPError('BAD_FORMAT', 'Not a Unity WebGL data file (UnityWebData1.0).');
445
+ const headerEnd = buf.indexOf(0, 0) + 1;
446
+ const headerSize = buf.readUInt32LE(headerEnd);
447
+ let p = headerEnd + 4;
448
+ const entries = [];
449
+ while (p < headerSize) {
450
+ const offset = buf.readUInt32LE(p), size = buf.readUInt32LE(p + 4), nl = buf.readUInt32LE(p + 8);
451
+ const name = buf.toString('utf8', p + 12, p + 12 + nl);
452
+ p += 12 + nl;
453
+ entries.push({ path: name, offset, size, ...(layers.length ? { inMemory: true } : {}) });
454
+ }
455
+ const meta = entries.find(e => /global-metadata\.dat$/.test(e.path));
456
+ return { format: 'unity-webgl-data', compression: layers[0] ?? 'none', entries, data: layers.length ? buf : null,
457
+ unity: { il2cpp: !!meta, note: meta ? 'IL2CPP build: Il2CppData/Metadata/global-metadata.dat holds type, method and field names (re_il2cpp); code is in the .wasm (re_binary).' : 'Mono/older build or no metadata entry.' },
458
+ note: 'data.unity3d / level files / sharedassets are Unity serialized files (open them with an asset tool); Resources and StreamingAssets entries are plain files.' };
459
+ }
460
+
461
+ /** Emscripten file_packager output: the .data blob plus the loader .js that lists files with start/end offsets. */
462
+ export async function readEmscriptenData(abs) {
463
+ const dir = path.dirname(abs), base = path.basename(abs).replace(/\.data$/i, '');
464
+ const candidates = [`${base}.js`, `${base}.loader.js`, 'index.js', ...(await fs.readdir(dir).catch(() => [])).filter(f => f.endsWith('.js'))];
465
+ for (const c of [...new Set(candidates)]) {
466
+ const js = await fs.readFile(path.join(dir, c), 'utf8').catch(() => null);
467
+ if (!js || !/loadPackage|remote_package_size|"files"\s*:\s*\[/.test(js)) continue;
468
+ const m = js.match(/loadPackage\(\s*(\{[\s\S]*?"remote_package_size"\s*:\s*\d+[^}]*\})\s*\)/) ?? js.match(/(\{"files"\s*:\s*\[[\s\S]*?\][^}]*\})/);
469
+ if (!m) continue;
470
+ let meta; try { meta = JSON.parse(m[1]); } catch { continue; }
471
+ const entries = (meta.files ?? []).map(f => ({ path: String(f.filename).replace(/^\/+/, ''), offset: f.start, size: f.end - f.start, ...(f.audio ? { audio: true } : {}) }));
472
+ const engine = entries.some(e => /\.pck$/.test(e.path)) ? 'Godot (pack inside: re_archive it after extracting)' : entries.some(e => /love|main\.lua$/.test(e.path)) ? 'LÖVE' : entries.some(e => /\.rpa|renpy/.test(e.path)) ? "Ren'Py web" : entries.some(e => /\.py$/.test(e.path)) ? 'Python (Pyodide/pygbag)' : null;
473
+ return { format: 'emscripten-data', loader: c, packageSize: meta.remote_package_size ?? null, entries, ...(engine ? { engine } : {}), note: meta.package_uuid ? `package ${meta.package_uuid}` : undefined };
474
+ }
475
+ throw new RPError('BAD_FORMAT', 'Emscripten .data needs its loader .js next to it (the file that calls loadPackage with the file list).');
476
+ }