Files
UnheardCity/docs/reverse-engineering/findings.md

14 KiB

Findings and experiment log

Moved here from NOTES.md during the 2026-10-08 cleanup (content unchanged; paths of the time are kept, see the mapping at the end). Format specs: docs/formats/. Decoder addresses: decoder.md.

Root cause of the original "silent audio / crash" problem

  • Wwise 2011.2.2 writes codebook IDs into the aoTuV 6.03 codebook library; the game (Wwise 2011.1) only has the standard library (== ww2ogg packed_codebooks.bin). Same IDs -> different books -> garbage/silence.
  • 2011.2.2 also computes dwDecodeAllocSize for a different decoder -> the game's bump allocator overflows -> NULL write crash (the user's old code-cave patch doubled the pool to hide it).
  • Approach A (chosen and verified): encode with stock libvorbis, transcode to the 2011.1 layout with the game's codebook IDs, compute the hash (FNV-1) and decode-alloc size with the reversed formula. No exe patch needed.

In-game experiments (2026-10-07; harness then scripts/harness.py, now ackit verify --ingame)

Target SFX:EDF036D6/393BC53A = intro/title music, plays ~5 s after launch without input.

# injected result
E0 baseline (nothing) PASS ncc 0.974 (detector hears original music)
E1 my encode of pink noise+sine, 6ch 32k q2 (maxpkt 1448, 41 KB/s) setup parsed OK, no crash, but SILENT (ncc 0.117)
E2 original game WEM 0D5E8A4E (6ch 48k, other size) PASS ncc 0.998 -> WAD+BKPK pack injection works in-game
E3 original game WEM 8D6D9BB9 (4ch 32k, setup d93b5944 = same as E1) PASS ncc 0.933 -> rate/channel change accepted
E4 my encode of decoded 8D6D9BB9 audio (4ch 32k q2, maxpkt 630) PASS ncc 0.959 -> encoder output plays correctly
E5 my noise probe 4ch 32k q2 (maxpkt 720), 10 s SILENT -> not channel count / packet size
E6 my encode of Joker speech 4ch 32k q2, 15 s PASS ncc 0.998
E7 E5 at -20 dB, 10 s SILENT -> not amplitude
E8 E5 low-passed 3 kHz, 10 s SILENT -> not spectrum
E9 same noise probe, 30 s PASS ncc 0.995 -> failures were duration: this music slot does not play sources <= ~10 s (music-segment timing in HIRC), not a codec issue
E10 Joker speech 30 s, mono 28 kHz q4, NEW setup (hash 04681ac8 via FNV-1, alloc 18180 via formula) PASS ncc 1.000 -> computed hash/alloc accepted by the game
E11 boot smoke test: ITA entry 800BE4F0 grown 30 KB -> 231 KB (all later entries shifted) + SFX pack change game boots and plays (PASS). Later found unsafe in principle (see 10.) and now refused
E12 ackit replace SFX EDF036D6/393BC53A custom_sound.mp3 then harness --installed PASS ncc 0.999
E13 mods-folder state (ITA line Speech_TAPE_JOKER_1_4 in place + music) PASS ncc 0.999
E14 2026-10-08, after the cleanup: ackit verify --ingame (Workspace.apply path, libsndfile encoder, 6ch 32k probe) PASS ncc 1.000, no crash, revert byte-identical (WAD + pack index)

Conclusion: approach A works end-to-end in-game. Use >= 30 s probes for the intro-music slot.

Findings log

  1. WAD: see docs/formats/wad.md. Round trip byte-identical (ITA, INT, SFX). Entries in LOC/SFX WADs are raw RIFF WEMs (3 ITA entries contain 2 concatenated RIFFs).

  2. Codebooks: game table @ runtime 0x0158E748 = 599 ptrs (598 books) == ww2ogg standard packed_codebooks.bin byte-for-byte. Saved as ackit/data/game_codebooks.bin.

  3. User's mod.wem (2011.2.2): codebook ids 8-19, 97-107 — every one maps to a different book in std vs aoTuV lib -> wrong decode. Also 1024/1024 blocksizes, decode_alloc 16452.

  4. Original game WEMs: libvorbis (ffmpeg) at 24k mono q<=4.5 reproduces the game's 24k/9-10 setup byte-for-byte (same 35 codebook ids). Several other known setups also reproduced (see work/qsearch). 28k/44.1k not exactly reproduced (encoder version/tuning) -> handled by formulas instead.

  5. uHashCodebook = FNV-1 32 over stripped setup bytes (24/24). Codec setup cache keyed by this hash (VorbisGetOrCreateCodecSetup: bucket hash%31, compares full hash) -> distinct setups MUST have distinct hashes.

  6. dwDecodeAllocSize = 0x30 + parser pool allocations (Tremor-lowmem derived; ackit/allocsize.py) + 4*nbooks + 8. Exact 24/24.

  7. Field rules (docs/formats/wem.md): last_granule_extra = loop_end_extra = sum(packet samples) - total_frames; loop_start = 2+setup_len; loop_end = data_len - seek_size; avg_bps = (data_len-seek)*rate//frames; seek table = (dgranule,doffset) u16 pairs at first packet whose end-granule >= last+interval (8192 default).

  8. libvorbis q>=5 at 24k uses a codebook (dims=2, entries=100) absent from the game library -> encoder must pick q that fits.

  9. Equal blocksizes (9/9, 10/10): packets keep the standard Vorbis packet-type bit; unequal: "modified" packets.

  10. (2026-10-07, session 2) LOC/SFX entries are groups of variations (ITA 10816 WEMs in 2464 entries). The first splitter only saw the first WEM, so the first injector would have DROPPED variations (fixed). Inner offsets and sizes come from BKPK tables inside compressed .upk packages (entry IDs found next to 'BKPK' in BmGame.upk, OW.upk, ...), so replacements are in place and <= the original WEM size; only SFX stream packs (index in a loose .bnk) may change size. The user's earlier quickbms reimport mod followed the same rule.

  11. Front-end sounds heard at startup mostly come from banks embedded in .upk packages; the intro music is the only deterministic WAD stream (pack EDF036D6 media 393BC53A, 6ch 48 kHz, 171 s).

  12. (2026-10-08) libsndfile 1.2.2's bundled libvorbis gives exactly the same compatibility table and the same game setups as ffmpeg's libvorbis (q0..10 at 12/24/28/32/44.1/48 kHz, 1/2/6 ch), so ffmpeg is not needed. libsndfile cannot do q<0 (fit-to-size stops at q0, then lowers the rate).

  13. (2026-10-08) libsndfile's Vorbis writer crashes with a C stack overflow on a single very large write (30 s x 6 ch); encoder.vorbis_ogg writes in 4096-frame blocks.

  14. libvorbis 6-channel at 44.1/48 kHz uses dedicated 5.1 codebooks (libvorbis 1.3) the game does not have; 6 channels work at <= 32 kHz (uncoupled, like Wwise). The encoder falls back to 32 kHz automatically.

  15. Channel order for > 2 channels: the bitstream order equals the array order given to libsndfile; whether the game expects WAVEFORMATEX order (FL FR FC LFE BL BR) is unverified (identical-channel probes were used).

  16. (2026-10-09) The WADs hold only streamed audio. About 15,800 more sounds (footsteps, foley, combat, weapons, UI, grunts) are in-memory WEMs inside soundbanks: 65 loose SFX\*.bnk (1,117) and 3,340 banks embedded in the .upk packages (14,802; many banks copied into several packages). None were in the catalog then (they are now: archive BANK); the WAD side was complete. Inventory and layout: docs/formats/banks.md.

  17. (2026-10-09) Speech lines have per-language media IDs: every package carries one BKPK table per language for the same WAD entry. 396 lines (78-80 per language) have a prefetch head (first N bytes) in banks; each head equals the start of exactly one language's WEM.

  18. (2026-10-09) In-game test of in-memory sounds (ackit verify --ingame --banks): 77 probes written into the 78 sounds stored in the Frontend*/Startup packages (54 files: patched LZO packages + loose banks). The game starts and runs, all 68 probe byte patterns are found in its memory (it loaded the patched packages), revert byte-identical: PASS. Not shown: a probe being heard. Only 7 Vorbis streams start on the title screen (setups 4f708975, 6ded1c65, 92f94a3f, d93b5944, d4b2e824: shared by hundreds of sounds, music included) and none is a probe. Tried: Enter/arrow keys posted to the game window (WM_KEYDOWN/WM_KEYUP; that helper was removed later) to reach the main menu: no UI sound followed (the game probably reads input another way; global SendInput is not used on purpose); a breakpoint on the setup parser entry: no extra events. Hypotheses: no in-memory sound plays without input; the decode path is the same as for WAD sounds (same WEM rules, 300/300 in-memory WEMs rebuild byte-identically), so playback is expected to work like E4/E10.

  19. (2026-10-09) In-game text test (ackit verify --ingame --texts, ITA): 12 menu texts of FRONTEND/GFxUI prefixed with markers and 12 subtitles patched in place (21 files). All 12 markers are found as UTF-16 in the game's memory after ~45 s (the localization files are read at startup), no crash, revert byte-identical: PASS. Subtitles are not loaded on the title screen (no dialogue object in Frontend*/Startup/BmGame).

  20. (2026-10-10) Variation positions differ between languages. A WAD entry stores its WEMs in media-ID order and each language has its own media IDs, so for 7,010 of 9,366 lines (75 %) the same game line is at a different position in the ITA and INT entries (e.g. Speech_CH_ZERO_CH0_fight_intro_01 = ITA :0, INT :2). Position names (NAME_i, wemscan) therefore mean different lines per language. Speech sounds are now named after their speech event (same in every language); the curated ITA lists are mapped through it. Independent check: the speaker field of the dialogue objects agrees with the mapped labels for 955 / 955 lines. (Superseded by 22: the identity of a line now comes from the Sound objects, for every recording; measured that way, 81.2-81.6 % of the WEMs in multi-variation entries are at another position than in INT.)

  21. (2026-10-10) In-memory speech banks are localized (language list + one bank per language in one AkBank export, checked on 215 banks against the WAD languages of their streamed lines); 41 more subtitles are RSubtitle objects linked through RSubtitleLookup (key = FNV-1 of the line name without Speech_). Subtitle coverage by category is uneven in the game itself: efforts 0 %, combat barks 11 %, cinematics 10 % (cutscene lines are mostly not reached by speech events and have no dialogue object), story/predator/open world ~90 %+. (With every version of a line linked, item 22: combat barks 9 %, cinematics 23 %; REPORT.md.)

  22. (2026-10-10) One recording per language, from the game data alone (docs/formats/banks.md, Lines). The BKPK tables of a speech entry sit inside the localized AkBank export, one per declared language; they used to be merged by media ID, but 657 media IDs are used by several languages at different positions, so the last language read (ESN) overwrote the others (120-157 WEMs per language looked unreferenced). Kept per language: 10,816 / 10,816 WEMs matched in every language. Prefetch sources were left out of the Sound-object map, which hid the 396 prefetched lines (game-over taunts). Five recordings per language are stored twice (same media ID in two entries, identical bytes). Subtitle objects name events no package lists (FNV-1 of the name = event ID: Vicki Vale's radio reports, Hush tape reveal). Result: 12,434 lines with exactly one recording in each of the 5 languages, no conflict; independent checks: 54,055 / 54,055 WAD recordings are sources in a bank of their own language, 2,101 / 2,101 prefetch heads equal the start of their recording, every linked subtitle is on the same line in all 11 languages.

  23. (2026-10-10, final check) Whole WAD recordings in banks. 49 in-memory media (9-11 per language) have the media ID of a WAD recording of their bank's language and are byte-identical to it (game-over taunts, Speech_CH_ZERO_CH0_ENCRYPTION_CARD3_01, Speech_SEQUENCE_CH2_RESCUE_DOC_01, ...): the line's Sound object names that media, and some bank also stores the whole WEM in memory. They were listed as separate BANK sounds (with the line's name: the CLI called the name ambiguous), so a replacement of the line left the in-memory copies old. Now they are bank copies of the WAD recording like prefetch heads (84 copies; 445 recordings with heads or whole copies, 2,185 copies), written and reverted with the line. Also: 8 in-memory sounds per catalog had the same <Bank>_<n> name as an SFX WAD sound of the same bank (CHR_RasBossFight_1, SEQ_DemonTrials_1); they now take <Bank>_<MEDIA>, and a line's own name wins over another recording's position alias (CHR_RadioScanner#1 in FRA). Every name of every catalog resolves to its own sound.

  24. (2026-10-10, gameplay test by the user) Padded in-memory replacements play as silence. All 568 Batman footsteps were replaced; in game the original steps were heard and the replaced cape-open sound was silent. Memory scan while walking: no original footstep WEM loaded, 137 replacements loaded; debugger: the replacements' Vorbis setups are created and parsed OK (ret 0). Every untouched in-memory WEM fills its slot exactly (RIFF size = DIDX size, 500 / 500 sampled), while the replacements were zero-padded. Filled with a LIST{adtl, labl} chunk before data (55 in-memory originals carry cue+LIST chunks), every step plays the replacement. ackit now fills every in-memory copy and every whole bank copy of a WAD recording (wem.fill); WAD entries and prefetch heads keep zero padding (streamed playback is unaffected). The remaining sound after each step is Batman's gear and cape foley (CHR_BatmanFoleyBelt, ..Cape).

Old path -> new path

scripts/harness.py -> ackit verify --ingame (ackit/verify/ingame.py); scripts/validate_builder.py -> ackit/verify/selfcheck.py + tests/test_realgame.py; ackit/gamefiles.py -> ackit/game.py + ackit/originals.py; ackit/simple.py -> ackit/workspace.py; work/*.py experiment scripts -> git tag pre-cleanup.