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

108 lines
10 KiB
Markdown

# 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`.
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 (`debugger.post_key`, WM_KEYDOWN) 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.
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 %+.
## 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`.