universal-game-modder 0.1.4

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 (92) hide show
  1. package/ACCEPTABLE_USE.md +49 -0
  2. package/CHANGELOG.md +119 -0
  3. package/LICENSE +21 -0
  4. package/LICENSE-EULA.md +62 -0
  5. package/README.md +162 -0
  6. package/VERIFICATION.md +132 -0
  7. package/dist/backends/child-pool.js +91 -0
  8. package/dist/backends/mcp-child.js +102 -0
  9. package/dist/backends/mcp-child.js.map +1 -0
  10. package/dist/core/config.d.ts +10 -0
  11. package/dist/core/config.js +48 -0
  12. package/dist/core/config.js.map +1 -0
  13. package/dist/core/engine-detector.d.ts +13 -0
  14. package/dist/core/engine-detector.js +247 -0
  15. package/dist/core/engine-detector.js.map +1 -0
  16. package/dist/core/event-bus.js +18 -0
  17. package/dist/core/file-classifier.js +394 -0
  18. package/dist/core/model-db.js +517 -0
  19. package/dist/core/model-db.js.map +1 -0
  20. package/dist/core/router.d.ts +6 -0
  21. package/dist/core/router.js +143 -0
  22. package/dist/core/router.js.map +1 -0
  23. package/dist/core/session.js +114 -0
  24. package/dist/decoders/audio-export.js +57 -0
  25. package/dist/decoders/mesh-export.d.ts +20 -0
  26. package/dist/decoders/mesh-export.js +174 -0
  27. package/dist/decoders/mesh-export.js.map +1 -0
  28. package/dist/decoders/native/disassembler.js +85 -0
  29. package/dist/decoders/native/disassembler.js.map +1 -0
  30. package/dist/decoders/png-encode.d.ts +17 -0
  31. package/dist/decoders/png-encode.js +33 -0
  32. package/dist/decoders/texture-decode.js +278 -0
  33. package/dist/decoders/texture-decode.js.map +1 -0
  34. package/dist/decoders/unity/audioclip.d.ts +55 -0
  35. package/dist/decoders/unity/audioclip.js +87 -0
  36. package/dist/decoders/unity/audioclip.js.map +1 -0
  37. package/dist/decoders/unity/binary-reader.d.ts +45 -0
  38. package/dist/decoders/unity/binary-reader.js +141 -0
  39. package/dist/decoders/unity/binary-reader.js.map +1 -0
  40. package/dist/decoders/unity/mesh.js +198 -0
  41. package/dist/decoders/unity/mesh.js.map +1 -0
  42. package/dist/decoders/unity/ress-reader.d.ts +33 -0
  43. package/dist/decoders/unity/ress-reader.js +82 -0
  44. package/dist/decoders/unity/ress-reader.js.map +1 -0
  45. package/dist/decoders/unity/serialized-file.d.ts +65 -0
  46. package/dist/decoders/unity/serialized-file.js +144 -0
  47. package/dist/decoders/unity/texture2d.js +185 -0
  48. package/dist/decoders/unity/vertex-data.d.ts +54 -0
  49. package/dist/decoders/unity/vertex-data.js +174 -0
  50. package/dist/decoders/unity/vertex-data.js.map +1 -0
  51. package/dist/index.d.ts +1 -0
  52. package/dist/index.js +23 -0
  53. package/dist/server.d.ts +1 -0
  54. package/dist/server.js +66 -0
  55. package/dist/tools/decode-tools.d.ts +29 -0
  56. package/dist/tools/decode-tools.js +441 -0
  57. package/dist/tools/delegates/jar-editor.d.ts +2 -0
  58. package/dist/tools/delegates/jar-editor.js +276 -0
  59. package/dist/tools/delegates/unity-decompiler.d.ts +2 -0
  60. package/dist/tools/delegates/unity-decompiler.js +291 -0
  61. package/dist/tools/delegates/unreal-assets.d.ts +2 -0
  62. package/dist/tools/delegates/unreal-assets.js +122 -0
  63. package/dist/tools/detection-tools.d.ts +2 -0
  64. package/dist/tools/detection-tools.js +164 -0
  65. package/dist/tools/detection-tools.js.map +1 -0
  66. package/dist/tools/index.d.ts +8 -0
  67. package/dist/tools/index.js +30 -0
  68. package/dist/tools/index.js.map +1 -0
  69. package/dist/tools/native/index.d.ts +2 -0
  70. package/dist/tools/native/index.js +1201 -0
  71. package/dist/tools/native-comprehension-tools.js +298 -0
  72. package/dist/tools/native-comprehension-tools.js.map +1 -0
  73. package/dist/tools/unpack-tools.d.ts +2 -0
  74. package/dist/tools/unpack-tools.js +382 -0
  75. package/dist/tools/workflow-tools.d.ts +2 -0
  76. package/dist/tools/workflow-tools.js +434 -0
  77. package/dist/utils/logger.d.ts +11 -0
  78. package/dist/utils/logger.js +49 -0
  79. package/dist/utils/logger.js.map +1 -0
  80. package/dist/utils/path-utils.d.ts +15 -0
  81. package/dist/utils/path-utils.js +24 -0
  82. package/dist/utils/path-utils.js.map +1 -0
  83. package/dist/utils/steam-finder.d.ts +8 -0
  84. package/dist/utils/steam-finder.js +32 -0
  85. package/dist/web/web-server.d.ts +1 -0
  86. package/dist/web/web-server.js +369 -0
  87. package/licenses/THIRD-PARTY-NOTICES.md +35 -0
  88. package/llms-install.md +34 -0
  89. package/package.json +69 -0
  90. package/server.json +20 -0
  91. package/setup.ps1 +34 -0
  92. package/ugm.config.json +9 -0
@@ -0,0 +1,441 @@
1
+ /**
2
+ * decode-tools.ts — PR-2 "Universal Asset Decoder" (texture-first slice).
3
+ *
4
+ * decode_assets(db_path | game_path): reads the PR-1 catalog (The Model), finds the Unity
5
+ * .assets container rows PR-1 catalogued as un-expanded, cracks each one open with our
6
+ * serialized-file walker, extracts every Texture2D (pulling streamed pixels from the
7
+ * sibling .resS), decodes to RGBA via the wrapped codec, writes a PNG, and writes the
8
+ * decoded asset back into The Model as its own row:
9
+ *
10
+ * asset(container_path = the .assets file, internal_path = "<TexName>#<pathID>",
11
+ * type = 'texture', format = <TextureFormat>, decoded = 1,
12
+ * decoded_path = <png>, provenance = 'verified', source_tool = 'decode_assets')
13
+ *
14
+ * This uses the container_path/internal_path seam PR-1 built for exactly this (PR-1 rows
15
+ * have internal_path = '' for standalone files; PR-2 members get a non-empty internal_path,
16
+ * so the UNIQUE (game_id, container_path, internal_path) key never collides with the
17
+ * container's own catalog row).
18
+ *
19
+ * RAILS (honored):
20
+ * - READ-ONLY on game files. The ONLY writes are: the decoded PNGs (jailed to the output
21
+ * dir, default <game>/.autopsy/decoded/, overridable for write-protected installs) and
22
+ * the .autopsy.db rows. Never a byte into the game's own assets.
23
+ * - VERIFY-AFTER-WRITE: each PNG is re-read/validated; each DB row is read back.
24
+ * - NEVER SILENTLY DROP: an unsupported format / unreadable stream becomes a decoded=0
25
+ * row with a note, and the run continues (A3-class).
26
+ * - PROVENANCE: a byte-level decoded texture is 'verified'; a failed one is 'uncertain'.
27
+ */
28
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
29
+ import { basename, dirname, join } from 'path';
30
+ import Database from 'better-sqlite3';
31
+ import { AutopsyModel } from '../core/model-db.js';
32
+ import { parseSerializedFile, ClassID } from '../decoders/unity/serialized-file.js';
33
+ import { parseTexture2D, TextureFormat } from '../decoders/unity/texture2d.js';
34
+ import { ResSReader } from '../decoders/unity/ress-reader.js';
35
+ import { decodeTexture } from '../decoders/texture-decode.js';
36
+ import { encodePng } from '../decoders/png-encode.js';
37
+ import { parseMesh } from '../decoders/unity/mesh.js';
38
+ import { exportGlb, exportObj } from '../decoders/mesh-export.js';
39
+ import { parseAudioClip } from '../decoders/unity/audioclip.js';
40
+ import { exportAudio } from '../decoders/audio-export.js';
41
+ import { PNG } from 'pngjs';
42
+ /** Sanitize a texture name into a safe filename fragment. */
43
+ function safeName(name) {
44
+ return name.replace(/[^A-Za-z0-9._-]+/g, '_').slice(0, 120) || 'texture';
45
+ }
46
+ export function getDecodeTools() {
47
+ return [
48
+ {
49
+ name: 'decode_assets',
50
+ description: 'Universal Asset Decoder. Reads a PR-1 autopsy catalog, cracks open Unity .assets containers, and decodes their members back into The Model (decoded=1, decoded_path set): Texture2D -> PNG (streamed pixels from sibling .resS), Mesh -> glTF .glb + .obj (plain/uncompressed; compressed noted+deferred), and AudioClip -> WAV (PCM) / OGG (bare Vorbis); FSB5-wrapped Vorbis + other codecs are noted+deferred. Read-only on game files; decoded files + DB rows are the only writes. Pass either db_path (the .autopsy.db) or game_path (whose default .autopsy/ DB is used). Optional out_dir overrides where decoded files land; asset_kinds restricts to textures, meshes, or audio.',
51
+ inputSchema: {
52
+ type: 'object',
53
+ properties: {
54
+ db_path: { type: 'string', description: 'Path to the <game>.autopsy.db produced by unpack_game.' },
55
+ game_path: {
56
+ type: 'string',
57
+ description: 'Game install dir; its default .autopsy/<name>.autopsy.db is used if db_path is omitted.',
58
+ },
59
+ out_dir: {
60
+ type: 'string',
61
+ description: 'Optional output dir for decoded PNGs. Defaults to <db_dir>/decoded/.',
62
+ },
63
+ limit: { type: 'number', description: 'Optional cap on textures decoded (for quick smoke runs).' },
64
+ asset_kinds: {
65
+ type: 'string',
66
+ enum: ['all', 'textures', 'meshes', 'audio'],
67
+ description: "Which asset kinds to decode. 'all' (default) does textures + meshes + audio; restrict with 'textures', 'meshes', or 'audio'.",
68
+ },
69
+ },
70
+ },
71
+ handler: async (args) => {
72
+ const dbPath = resolveDbPath(args.db_path, args.game_path);
73
+ if (!dbPath || !existsSync(dbPath)) {
74
+ return JSON.stringify({ error: `autopsy DB not found. db_path=${args.db_path ?? ''} game_path=${args.game_path ?? ''}` }, null, 2);
75
+ }
76
+ const limit = typeof args.limit === 'number' ? args.limit : Infinity;
77
+ // Output dir: jailed under the DB's dir by default (which is .autopsy/), overridable.
78
+ const outDir = args.out_dir ?? join(dirname(dbPath), 'decoded');
79
+ mkdirSync(outDir, { recursive: true });
80
+ // Read catalog: find the game_id and the .assets container rows.
81
+ const readDb = new Database(dbPath, { readonly: true });
82
+ let gameId;
83
+ let containerRows;
84
+ try {
85
+ const game = readDb.prepare('SELECT id FROM game LIMIT 1').get();
86
+ if (!game) {
87
+ readDb.close();
88
+ return JSON.stringify({ error: 'catalog has no game row — run unpack_game first.' }, null, 2);
89
+ }
90
+ gameId = game.id;
91
+ // DISTINCT: a container may now have many decoded member rows (textures/meshes)
92
+ // whose container_path is the .assets file — we want each .assets file ONCE.
93
+ // Also require internal_path='' so we select the original PR-1 container catalog
94
+ // row, never a decoded member row.
95
+ containerRows = readDb
96
+ .prepare(`SELECT DISTINCT container_path FROM asset
97
+ WHERE game_id = ? AND lower(container_path) LIKE '%.assets'
98
+ AND internal_path = ''
99
+ ORDER BY container_path`)
100
+ .all(gameId);
101
+ }
102
+ finally {
103
+ readDb.close();
104
+ }
105
+ const counts = {
106
+ containers_seen: containerRows.length,
107
+ containers_parsed: 0,
108
+ textures_found: 0,
109
+ textures_decoded: 0,
110
+ textures_failed: 0,
111
+ pngs_written: 0,
112
+ meshes_found: 0,
113
+ meshes_decoded: 0,
114
+ meshes_failed: 0,
115
+ meshes_deferred_compressed: 0,
116
+ audio_found: 0,
117
+ audio_decoded: 0,
118
+ audio_failed: 0,
119
+ audio_deferred_unsupported: 0,
120
+ rows_written: 0,
121
+ };
122
+ const failures = [];
123
+ // Which asset kinds to decode (default all). 'all' | 'textures' | 'meshes' | 'audio'.
124
+ const kindsArg = args.asset_kinds ?? 'all';
125
+ if (!['all', 'textures', 'meshes', 'audio'].includes(kindsArg)) {
126
+ return JSON.stringify({ error: `invalid asset_kinds '${kindsArg}' — expected 'all', 'textures', 'meshes', or 'audio'` }, null, 2);
127
+ }
128
+ const doTextures = kindsArg === 'all' || kindsArg === 'textures';
129
+ const doMeshes = kindsArg === 'all' || kindsArg === 'meshes';
130
+ const doAudio = kindsArg === 'all' || kindsArg === 'audio';
131
+ // Open the Model for writes (writeback rows). Same DB file; writer reopens it.
132
+ const model = new AutopsyModel(dbPath);
133
+ try {
134
+ for (const row of containerRows) {
135
+ const assetsPath = row.container_path;
136
+ if (!existsSync(assetsPath)) {
137
+ failures.push({ texture: '(container)', container: assetsPath, reason: 'container file missing on disk' });
138
+ continue;
139
+ }
140
+ let parsed;
141
+ try {
142
+ parsed = parseSerializedFile(readFileSync(assetsPath));
143
+ }
144
+ catch (e) {
145
+ failures.push({ texture: '(container)', container: assetsPath, reason: `parse failed: ${e.message}` });
146
+ continue;
147
+ }
148
+ counts.containers_parsed++;
149
+ const ress = new ResSReader(assetsPath);
150
+ const containerOutDir = join(outDir, basename(assetsPath));
151
+ const texObjs = parsed.objects.filter((o) => o.classID === ClassID.Texture2D);
152
+ try {
153
+ if (doTextures) {
154
+ for (const obj of texObjs) {
155
+ if (counts.textures_decoded >= limit)
156
+ break;
157
+ counts.textures_found++;
158
+ // 1) parse the Texture2D fields
159
+ let tex;
160
+ try {
161
+ tex = parseTexture2D(parsed.buf, parsed.header, obj);
162
+ }
163
+ catch (e) {
164
+ counts.textures_failed++;
165
+ recordFailedRow(model, gameId, assetsPath, `tex@${obj.pathID}`, e.message);
166
+ counts.rows_written++;
167
+ failures.push({ texture: `pathID ${obj.pathID}`, container: basename(assetsPath), reason: `parse: ${e.message}` });
168
+ continue;
169
+ }
170
+ const internalPath = `${tex.name}#${obj.pathID}`;
171
+ const fmtName = TextureFormat[tex.textureFormat] ?? `Unknown(${tex.textureFormat})`;
172
+ // 2) get raw pixel bytes (streamed or inline)
173
+ const raw = tex.streamData ? ress.read(tex.streamData) : tex.inlineData;
174
+ if (!raw || raw.length === 0) {
175
+ counts.textures_failed++;
176
+ recordFailedRow(model, gameId, assetsPath, internalPath, `no pixel data (stream=${!!tex.streamData})`, fmtName);
177
+ counts.rows_written++;
178
+ failures.push({ texture: tex.name, container: basename(assetsPath), reason: 'no pixel data' });
179
+ continue;
180
+ }
181
+ // 3) decode -> RGBA
182
+ const dec = await decodeTexture(raw, tex.textureFormat, tex.width, tex.height);
183
+ if (!dec.ok || !dec.rgba) {
184
+ counts.textures_failed++;
185
+ recordFailedRow(model, gameId, assetsPath, internalPath, dec.reason ?? 'decode failed', fmtName);
186
+ counts.rows_written++;
187
+ failures.push({ texture: tex.name, container: basename(assetsPath), reason: dec.reason ?? 'decode failed' });
188
+ continue;
189
+ }
190
+ // 4) encode PNG + write (jailed to outDir)
191
+ mkdirSync(containerOutDir, { recursive: true });
192
+ const pngPath = join(containerOutDir, `${safeName(tex.name)}_${obj.pathID}.png`);
193
+ const png = encodePng(dec.rgba, tex.width, tex.height);
194
+ writeFileSync(pngPath, png);
195
+ // 5) VERIFY-AFTER-WRITE: re-read the PNG and confirm dimensions.
196
+ let verified = false;
197
+ try {
198
+ const rt = PNG.sync.read(readFileSync(pngPath));
199
+ verified = rt.width === tex.width && rt.height === tex.height;
200
+ }
201
+ catch {
202
+ verified = false;
203
+ }
204
+ if (!verified) {
205
+ counts.textures_failed++;
206
+ recordFailedRow(model, gameId, assetsPath, internalPath, 'PNG verify-after-write failed', fmtName);
207
+ counts.rows_written++;
208
+ failures.push({ texture: tex.name, container: basename(assetsPath), reason: 'png verify failed' });
209
+ continue;
210
+ }
211
+ counts.pngs_written++;
212
+ counts.textures_decoded++;
213
+ // 6) writeback: decoded asset row (verified)
214
+ model.addAsset({
215
+ game_id: gameId,
216
+ container_path: assetsPath,
217
+ internal_path: internalPath,
218
+ type: 'texture',
219
+ format: fmtName,
220
+ decoded: true,
221
+ decoded_path: pngPath,
222
+ // size = byte size of the decoded artifact on disk (the PNG this row points at),
223
+ // matching the convention that asset.size is a real on-disk byte count. The source
224
+ // texture's pixel dimensions live in notes.
225
+ size: png.length,
226
+ notes: `${tex.width}x${tex.height} ${fmtName}; decoded by decode_assets`,
227
+ provenance: 'verified',
228
+ source_tool: 'decode_assets',
229
+ });
230
+ counts.rows_written++;
231
+ }
232
+ } // end doTextures
233
+ // ---- MESHES ----
234
+ if (doMeshes) {
235
+ const meshObjs = parsed.objects.filter((o) => o.classID === ClassID.Mesh);
236
+ for (const obj of meshObjs) {
237
+ counts.meshes_found++;
238
+ let mesh;
239
+ try {
240
+ mesh = parseMesh(parsed.buf, parsed.header, obj);
241
+ }
242
+ catch (e) {
243
+ counts.meshes_failed++;
244
+ recordFailedRow(model, gameId, assetsPath, `mesh@${obj.pathID}`, `parse: ${e.message}`, 'mesh', 'mesh');
245
+ counts.rows_written++;
246
+ failures.push({ texture: `mesh pathID ${obj.pathID}`, container: basename(assetsPath), reason: `parse: ${e.message}` });
247
+ continue;
248
+ }
249
+ const internalPath = `${mesh.name}#${obj.pathID}`;
250
+ // Compressed meshes (PackedBitVector) are a deferred slice — note, don't mis-decode.
251
+ if (mesh.compressed) {
252
+ counts.meshes_deferred_compressed++;
253
+ recordFailedRow(model, gameId, assetsPath, internalPath, 'compressed mesh (PackedBitVector) — deferred to a later slice', 'mesh', 'mesh');
254
+ counts.rows_written++;
255
+ continue;
256
+ }
257
+ if (!mesh.positions || mesh.vertexCount === 0) {
258
+ counts.meshes_failed++;
259
+ recordFailedRow(model, gameId, assetsPath, internalPath, 'no vertex positions (streamed vertex data not yet supported)', 'mesh', 'mesh');
260
+ counts.rows_written++;
261
+ failures.push({ texture: mesh.name, container: basename(assetsPath), reason: 'no positions' });
262
+ continue;
263
+ }
264
+ // export glb (primary) + obj (fallback), jailed to outDir
265
+ mkdirSync(containerOutDir, { recursive: true });
266
+ const glbPath = join(containerOutDir, `${safeName(mesh.name)}_${obj.pathID}.glb`);
267
+ const objPath = join(containerOutDir, `${safeName(mesh.name)}_${obj.pathID}.obj`);
268
+ let glb;
269
+ try {
270
+ glb = exportGlb(mesh);
271
+ writeFileSync(glbPath, glb);
272
+ writeFileSync(objPath, exportObj(mesh));
273
+ }
274
+ catch (e) {
275
+ counts.meshes_failed++;
276
+ recordFailedRow(model, gameId, assetsPath, internalPath, `export failed: ${e.message}`, 'mesh', 'mesh');
277
+ counts.rows_written++;
278
+ failures.push({ texture: mesh.name, container: basename(assetsPath), reason: `export: ${e.message}` });
279
+ continue;
280
+ }
281
+ // VERIFY-AFTER-WRITE: re-parse the glb header + accessor counts.
282
+ let verified = false;
283
+ try {
284
+ const g = readFileSync(glbPath);
285
+ const magic = g.readUInt32LE(0);
286
+ const jsonLen = g.readUInt32LE(12);
287
+ const gltf = JSON.parse(g.toString('utf8', 20, 20 + jsonLen));
288
+ const posAcc = gltf.accessors[gltf.meshes[0].primitives[0].attributes.POSITION];
289
+ verified = magic === 0x46546c67 && posAcc.count === mesh.vertexCount;
290
+ }
291
+ catch {
292
+ verified = false;
293
+ }
294
+ if (!verified) {
295
+ counts.meshes_failed++;
296
+ recordFailedRow(model, gameId, assetsPath, internalPath, 'glb verify-after-write failed', 'mesh', 'mesh');
297
+ counts.rows_written++;
298
+ failures.push({ texture: mesh.name, container: basename(assetsPath), reason: 'glb verify failed' });
299
+ continue;
300
+ }
301
+ counts.meshes_decoded++;
302
+ model.addAsset({
303
+ game_id: gameId,
304
+ container_path: assetsPath,
305
+ internal_path: internalPath,
306
+ type: 'mesh',
307
+ format: 'gltf',
308
+ decoded: true,
309
+ decoded_path: glbPath,
310
+ size: glb.length,
311
+ notes: `${mesh.vertexCount} verts, ${mesh.indices.length / 3} tris, attrs=[${mesh.attributesExported.join(',')}]; obj at ${objPath}; decoded by decode_assets`,
312
+ provenance: 'verified',
313
+ source_tool: 'decode_assets',
314
+ });
315
+ counts.rows_written++;
316
+ }
317
+ }
318
+ // ---- AUDIO ----
319
+ if (doAudio) {
320
+ const audioObjs = parsed.objects.filter((o) => o.classID === ClassID.AudioClip);
321
+ for (const obj of audioObjs) {
322
+ counts.audio_found++;
323
+ let clip;
324
+ try {
325
+ clip = parseAudioClip(parsed.buf, parsed.header, obj);
326
+ }
327
+ catch (e) {
328
+ counts.audio_failed++;
329
+ recordFailedRow(model, gameId, assetsPath, `audio@${obj.pathID}`, `parse: ${e.message}`, 'audio', 'audio');
330
+ counts.rows_written++;
331
+ failures.push({ texture: `audio pathID ${obj.pathID}`, container: basename(assetsPath), reason: `parse: ${e.message}` });
332
+ continue;
333
+ }
334
+ const internalPath = `${clip.name}#${obj.pathID}`;
335
+ // Pull the streamed samples from the sibling .resource via ResSReader.
336
+ const raw = clip.size > 0 ? ress.read({ offset: clip.offset, size: clip.size, path: clip.source }) : null;
337
+ if (!raw || raw.length === 0) {
338
+ counts.audio_failed++;
339
+ recordFailedRow(model, gameId, assetsPath, internalPath, `no stream bytes (source=${basename(clip.source)} size=${clip.size})`, clip.compressionName, 'audio');
340
+ counts.rows_written++;
341
+ failures.push({ texture: clip.name, container: basename(assetsPath), reason: 'no stream bytes' });
342
+ continue;
343
+ }
344
+ const exp = exportAudio(clip, raw);
345
+ if (!exp) {
346
+ // Unsupported compression (e.g. FSB5-wrapped Vorbis, ADPCM, MP3) — deferred, noted.
347
+ counts.audio_deferred_unsupported++;
348
+ recordFailedRow(model, gameId, assetsPath, internalPath, `unsupported audio format ${clip.compressionName} (FSB5/Vorbis unwrap deferred)`, clip.compressionName, 'audio');
349
+ counts.rows_written++;
350
+ continue;
351
+ }
352
+ mkdirSync(containerOutDir, { recursive: true });
353
+ const audioPath = join(containerOutDir, `${safeName(clip.name)}_${obj.pathID}.${exp.ext}`);
354
+ writeFileSync(audioPath, exp.bytes);
355
+ // VERIFY-AFTER-WRITE: re-read + check the container header (RIFF/WAVE or OggS).
356
+ let verified = false;
357
+ try {
358
+ const g = readFileSync(audioPath);
359
+ if (exp.ext === 'wav')
360
+ verified = g.toString('ascii', 0, 4) === 'RIFF' && g.toString('ascii', 8, 12) === 'WAVE';
361
+ else
362
+ verified = g.toString('ascii', 0, 4) === 'OggS';
363
+ }
364
+ catch {
365
+ verified = false;
366
+ }
367
+ if (!verified) {
368
+ counts.audio_failed++;
369
+ recordFailedRow(model, gameId, assetsPath, internalPath, 'audio verify-after-write failed', clip.compressionName, 'audio');
370
+ counts.rows_written++;
371
+ failures.push({ texture: clip.name, container: basename(assetsPath), reason: 'audio verify failed' });
372
+ continue;
373
+ }
374
+ counts.audio_decoded++;
375
+ model.addAsset({
376
+ game_id: gameId,
377
+ container_path: assetsPath,
378
+ internal_path: internalPath,
379
+ type: 'audio',
380
+ format: exp.ext,
381
+ decoded: true,
382
+ decoded_path: audioPath,
383
+ size: exp.bytes.length,
384
+ notes: `${clip.channels}ch ${clip.frequency}Hz ${clip.bitsPerSample}bit ${clip.length.toFixed(2)}s ${clip.compressionName}; decoded by decode_assets`,
385
+ provenance: 'verified',
386
+ source_tool: 'decode_assets',
387
+ });
388
+ counts.rows_written++;
389
+ }
390
+ }
391
+ }
392
+ finally {
393
+ ress.close();
394
+ }
395
+ }
396
+ }
397
+ finally {
398
+ model.close();
399
+ }
400
+ const summary = {
401
+ db_path: dbPath,
402
+ out_dir: outDir,
403
+ counts,
404
+ failures_sample: failures.slice(0, 15),
405
+ failures_total: failures.length,
406
+ };
407
+ return JSON.stringify(summary, null, 2);
408
+ },
409
+ },
410
+ ];
411
+ }
412
+ /** Write a decoded=0 asset row recording WHY an asset couldn't be decoded (never silent). */
413
+ function recordFailedRow(model, gameId, assetsPath, internalPath, reason, fmtName = 'unknown', assetType = 'texture') {
414
+ try {
415
+ model.addAsset({
416
+ game_id: gameId,
417
+ container_path: assetsPath,
418
+ internal_path: internalPath,
419
+ type: assetType,
420
+ format: fmtName,
421
+ decoded: false,
422
+ decoded_path: null,
423
+ size: 0,
424
+ notes: `decode failed: ${reason}`,
425
+ provenance: 'uncertain',
426
+ source_tool: 'decode_assets',
427
+ });
428
+ }
429
+ catch {
430
+ /* never let bookkeeping abort the run */
431
+ }
432
+ }
433
+ function resolveDbPath(dbPath, gamePath) {
434
+ if (dbPath)
435
+ return dbPath;
436
+ if (!gamePath)
437
+ return null;
438
+ const name = basename(gamePath).replace(/[^A-Za-z0-9._-]+/g, '_') || 'game';
439
+ return join(gamePath, '.autopsy', `${name}.autopsy.db`);
440
+ }
441
+ //# sourceMappingURL=decode-tools.js.map
@@ -0,0 +1,2 @@
1
+ import { ToolDefinition } from '../index.js';
2
+ export declare function getJarDelegateTools(): ToolDefinition[];