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

7.1 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 (15,641; many banks copied into several packages). None are in the catalog; the WAD side is complete. Inventory, layout and editing consequences: docs/formats/banks.md.

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.