Version 3.1.0: voice lines linked across all languages from the game data

This commit is contained in:
2026-10-10 14:10:03 +02:00
parent ea3e44000a
commit fbde809370
56 changed files with 74728 additions and 73360 deletions

View File

@@ -3,11 +3,11 @@
| document | content |
|----------|---------|
| [user-guide.md](user-guide.md) | installing and using Unheard City (screenshots) |
| [decisions.md](decisions.md) | stack, dependencies/licenses, originals store, size rule, trade-offs |
| [decisions.md](decisions.md) | stack, dependencies/licenses, originals store, size rule, in-memory sounds, texts, language, verification |
| [categories.md](categories.md) | group/category scheme and character fields |
| [building.md](building.md) | building the editor and its installer (Windows, Linux) |
| [formats/wad.md](formats/wad.md) | WWAD archives, grouped entries, BKPK stream packs |
| [formats/banks.md](formats/banks.md) | soundbanks and the in-memory sounds not covered yet (footsteps, foley, effects) |
| [formats/banks.md](formats/banks.md) | soundbanks and the in-memory sounds (footsteps, foley, effects, localized speech); how each voice line is linked across languages |
| [formats/subtitles.md](formats/subtitles.md) | subtitles: dialogue objects in the packages, link to the speech sounds |
| [formats/text.md](formats/text.md) | menu and UI text: the localization files |
| [formats/upk.md](formats/upk.md) | UE3 packages: summary, LZO chunks, how ackit reads and patches them |

View File

@@ -50,10 +50,12 @@ list; unnamed IDs show as hex). First matching rule wins:
BANK sounds (in-memory, `docs/formats/banks.md`) are classified by the name of their **soundbank** (e.g.
`CHR_CatwomanFoleySuit`, `Speech_EMOTE_Batman`), which is more systematic than their event names.
Counts in the ITA catalog: Story missions 1993, Predator chatter 1796, Character lines 1537, Open-world chatter
1295, Side missions 808, Combat barks 801, Efforts & emotes 616, Interview tapes 514, Riddler 360, Radio & TV 226,
Gameplay & hints 199, DLC 167, Boss fights 150, Cinematics 150, Reactions 108, Interrogations 95. SFX archive:
Music 389, Ambience 225, SFX 31, Unknown 6.
Counts per voice language (identical in all 5): Story missions 1996, Predator chatter 1796, Character lines 1537,
Open-world chatter 1295, Side missions 808, Combat barks 801, Efforts & emotes 616, Interview tapes 514,
Riddler 360, Radio & TV 223, Gameplay & hints 199, DLC 167, Boss fights 150, Cinematics 150, Reactions 108,
Interrogations 95. SFX archive: Music 389, Ambience 225, SFX 31, Unknown 6. BANK (all languages): Combat barks
4360, Character foley 3051, Other speech 2590, Footsteps 1256, Ambient emitters 705, Weapons 494, Combat 412,
Objects 401, ...
## Characters
Two separate fields, because Wwise names describe a *scene*, not the speaker of each variation (a conversation
@@ -61,10 +63,14 @@ alternates speakers between `NAME_1`, `NAME_2`, ...):
- **character** - the verified speaker, only from the curated lists in `data/catalog/source/<lang>/*.txt`
(one file per character, listening-based). The lists name ITA sounds by their position in the ITA WAD
(wemscan names); positions differ between languages, so each label is moved to the game's line name of that
sound (`characters.csv`, empty `language`) and then applies to the same line in every language. Labels of
sounds no event plays stay ITA-only (`language` = ITA). Checked against the speaker field of the game's
dialogue objects: 955 / 955 labelled lines agree (spelling aside: TwoFace, Mysterious_stranger = Azrael).
(wemscan names); positions differ between languages, so each listed recording is found at its ITA position
and the label goes to its line (`characters.csv`, keyed by the line's name), i.e. to the same recording in
every language. A label applies to exactly the listed recordings (not to other versions of the same event).
Checked by `ackit verify --catalog` against the speaker field of the game's dialogue objects: 2,766 labelled
lines agree (spelling aside: Mister Freeze, Bruce Wayne, TwoFace, Mysterious_stranger = Azrael), 25 listed
as Unknown are named there, 19 differ (to listen to: `Batman_sword_raised_01-04` game: Ra's al Ghul,
`ENCRYPTION_CARD2_02` game: Hugo Strange, `thug1_heckle_01_3` game: Thug 01, `SIDE_CAT_TF_intro1_03` game:
Thug 06; the 12 Wonder City lines say "Announcement").
Display names:
Calendar -> Calendar Man, Croc -> Killer Croc, Freeze -> Mr. Freeze, Ivy -> Poison Ivy, Madhatter -> Mad
Hatter, Quinn -> Harley Quinn, Ras -> Ra's al Ghul, Strange -> Hugo Strange, Talia -> Talia al Ghul,
@@ -76,5 +82,5 @@ alternates speakers between `NAME_1`, `NAME_2`, ...):
never shown as the speaker. In the editor the character filter matches verified speakers, and optionally
inferred ones.
Coverage (every language): 3,366 sounds with a verified speaker, 5,930 more with inferred characters, 1,520 with
neither (mostly predator/efforts from unnamed thugs).
Coverage (every voice language, the same lines): 3,366 with a verified speaker, 6,263 more with inferred
characters, 1,186 with neither (mostly predator chatter and efforts of unnamed thugs).

View File

@@ -3,7 +3,7 @@
## 1. Stack: Python + Qt (PySide6), packaged with PyInstaller
- **Reuse:** the toolkit (`ackit`) is Python and proven against the real game; the editor imports it unchanged.
- **Cross-platform GUI:** Qt Widgets via PySide6 (LGPL-3.0, official Qt for Python) looks native on
Windows and Linux, has a fast model/view table for 11,000+ rows, and runs offscreen for automated GUI tests.
Windows and Linux, has a fast model/view table for 20,000+ rows, and runs offscreen for automated GUI tests.
- **Audio I/O:** Qt Multimedia (`QMediaDevices`, `QAudioSink`, `QAudioSource`) for playback, recording and
device selection. It ships inside the PySide6 wheels with its own backends (WASAPI on Windows,
PulseAudio/PipeWire on Linux), so unlike PortAudio/`sounddevice` nothing has to be installed on Linux.
@@ -42,6 +42,7 @@ loopback); it is a developer tool and not part of the editor.
every record. Stream-pack padding bytes are stored so pack reverts are byte-identical.
- **Full-file fallback:** before the first change to a WAD (and its pack index), one verified full copy is kept
in the store (`full/`), default on, can be disabled. Used by "Restore from full backup" if the store is damaged.
Soundbank files, packages and localization files always get this copy (they are rebuilt from it, 4b/4c).
- **Why the user data folder:** it survives app updates and reinstalls, needs no write access next to the game,
and works the same on Linux/Proton. Steam never touches it.
- **Safe writes:** every WAD is rebuilt into a temp file and atomically renamed over the original, pack indexes
@@ -53,7 +54,8 @@ loopback); it is a developer tool and not part of the editor.
## 4. Size rule
Speech/ambience variations are addressed by offset+size from BKPK tables inside compressed `.upk` packages,
which ackit does not rewrite. Replacements are written in place and must not exceed the original WEM's size;
which ackit leaves unchanged (it patches packages only in place). Replacements are written in place and must not
exceed the original WEM's size;
the encoder lowers quality, then sample rate, to fit, and refuses otherwise. Music stream packs have a loose
index (`SFX\*.bnk`) that ackit rewrites, so music can be any length. In-memory sounds follow the same rule
(below). Lifting the limit would mean re-laying out the uncompressed packages (export offsets, BKPK/DIDX
@@ -76,8 +78,12 @@ tables): high risk for little gain, not done.
- **Prefetch heads:** 396 speech lines (about 79 per language) have their first bytes copied into banks; a
replacement of such a line rewrites those heads too, otherwise the start would play the old line.
- **Names:** the hash list names none of these media. Names come from Wwise events whose names are in the
packages' name tables (FNV-1 of the name = event ID), following event -> action -> containers -> sounds
(6,416 sounds); the rest are numbered per bank. Categories use the bank name.
packages' name and export tables (FNV-1 of the name = event ID), following event -> action -> containers ->
sounds (6,456 sounds); the rest are numbered per bank. Categories use the bank name. A localized speech line
has the same name in every language (its line, section 5).
- **Localized speech:** speech banks hold one copy per voice language (4d); every media is labelled with the
language(s) whose banks hold it (a few recordings are shared by several languages) and the catalog shows
only the selected language's lines.
## 4c. Subtitles and menu text
- **Subtitles** are written in place into every copy of the dialogue object, NUL-padded inside the original
@@ -85,8 +91,11 @@ tables): high risk for little gain, not done.
string's length ("room"). Moving the object to the end of the package (new export offset/size and an extra
chunk) would lift the limit, but no dialogue is loaded on the title screen, so the result could not be
checked automatically in the game: not done.
- **Links** subtitle -> speech sound come from the soundbanks (event -> ... -> per-language media -> WAD), not
from names: the `_NN` suffixes do not follow the WEM order inside WAD entries.
- **Links** subtitle -> speech sound come from the soundbanks (event -> ... -> per-language media -> WAD line
or localized in-memory line), not from names: the `_NN` suffixes do not follow the WEM order inside WAD
entries. Scripted subtitles (`RSubtitle`) are linked through the game's own lookup table (FNV-1 of the line
name without `Speech_`). The subtitle objects' names also find events that no package name table lists, and
an event that plays two versions of a line links its subtitle to both.
- **Menu text** files are plain UTF-16 text: no size limit; only the edited values change.
- Both use the originals store like sounds: texts are recorded as strings (`texts` in store.json) and every
file is rebuilt from its original copy + the applied edits (sound and text edits combine in one package).
@@ -103,10 +112,18 @@ copy per voice language) is filtered the same way, so the sound list never mixes
- Shipped CSVs per archive (names, categories, characters, original size + checksum) make loading instant and
enable change detection; any other build is scanned at runtime (header reads only, cached).
- Verified speakers come only from the user's lists; name-based character inference is kept in a separate field.
- Speech sounds are named after the game's line (speech event), not after their position in the WAD entry:
the WEMs of an entry are stored in media-ID order and every language has its own media IDs, so position i
is a different line in each language (75 % of the multi-variation lines). Line names make names, speaker
labels, mod packages and subtitles agree across languages.
- Voice lines are identified by the game's own data, not by names or positions (`docs/formats/banks.md`,
*Lines*): the recordings that are sources of the same Wwise Sound objects are one line, with exactly one
recording per language (12,434 lines, all 5 languages). The catalog stores that identity (`line`), and
speaker labels, mod packages (another language: the same `line`) and subtitles follow it. Positions are
useless across languages (WEMs are stored in media-ID order; position i is another line in 75 % of the
multi-variation entries).
- A line has one name in every language: the most specific speech event that plays it (10,156 of 10,815 voice
lines), numbered `NAME#2` in the event's own order when it plays two versions; the other 659 keep the name of
their English (INT) recording's position. The position in each language stays an alias (`NAME#i`).
- A recording stored twice in a WAD (5 per language) is written and reverted in both places.
- The catalog records the ORIGINAL bytes: `tools/build_catalog.py` stops before writing anything if a sound
differs from the current catalog (a modified game), unless `--accept-changes` (a game update).
- Category scheme: `docs/categories.md`.
## 6. Repository cleanup choices
@@ -123,7 +140,9 @@ copy per voice language) is filtered the same way, so the sound list never mixes
store does not need it.
## 7. Editor UX choices
- One window: filters (left), sound table (top), editor of the selected sound (bottom). The primary flow
- One window with three tabs. *Sounds*: filters (left) and sound table on top, the editor of the selected
sound below across the full width, in three resizable columns (Listen | Replace | Subtitle) so nothing has
to scroll at usual window sizes. *Subtitles* and *Menu text*: search, filters, table, editor. The primary flow
(search -> select -> Space to listen -> Load file/Record -> Apply) needs no menus; power features (tags,
favorites, multi-select revert, mod packages, settings) are in the toolbar and context menu.
- Long work (loading, encoding, writing 0.5 GB WADs) runs in background jobs with status messages; the
@@ -143,3 +162,18 @@ copy per voice language) is filtered the same way, so the sound list never mixes
replacement file, decoded off the GUI thread), next to the Apply/Revert buttons while encoding/writing, and in
the status bar for game loading and bulk changes.
All filter selectors (including Character) are plain, non-editable drop-downs so they look and behave alike.
## 8. Verification
- `pytest`: unit tests, a synthetic game (WADs, packs, packages with banks, localized banks, dialogue and subtitle
objects, localization files), a two-language game built like the real one (per-language BKPK tables, a media
ID shared by both languages, a recording stored twice), offscreen GUI tests, and read-only real-game tests
when the game is installed.
- `ackit verify` (WEMs rebuilt byte-for-byte, encode/decode), `ackit verify --catalog` (catalog vs game files,
and every relation re-derived from the game files with independent checks; it prints them as facts).
- Real-game write round trips without launching the game: `tools/e2e_features.py [--lang all]` (every copy of
an in-memory sound, localized line, prefetch heads, subtitles incl. RSubtitle, menu text, sound + subtitle in
one package, a mod moved to another voice language; `all` = each of the 11 languages) and
`tools/e2e_editor.py` (through the editor's code path).
- In the running game: `ackit verify --ingame` (music heard), `--banks` (patched packages loaded, probe data in
memory), `--texts` (menu texts and Startup subtitles found in memory). Every tool reverts in a `finally` block
and checks that every touched file is byte-identical afterwards.

View File

@@ -7,10 +7,12 @@ Survey of the installed game (buildid 3718300), 2026-10-09.
## Inventory
| where | banks | unique in-memory WEMs | in the catalog |
|-------|-------|-----------------------|----------------|
| loose `SFX\*.bnk` (Batman/Catwoman footsteps, 32 surfaces each, + `TST_Prep_Media`) | 65 | 1,117 | 0 |
| banks embedded in `.upk` packages (in 1,432 of the 2,650 packages) | 3,340 | 15,641 (~276 MB) | 0 |
| where | banks | unique in-memory WEMs |
|-------|-------|-----------------------|
| loose `SFX\*.bnk` (Batman/Catwoman footsteps, 32 surfaces each, + `TST_Prep_Media`) | 65 | 1,117 |
| banks embedded in `.upk` packages (in 1,432 of the 2,650 packages) | 3,340 | 15,641 (~276 MB) |
All of them are in the catalog (archive `BANK`).
The two sets share 119 media IDs: 15,800 unique media IDs in 96,011 places (bank copies). 396 of them are
**prefetch heads** of streamed WAD sounds (below), so there are **15,404 in-memory sounds**. All copies of a
@@ -52,11 +54,37 @@ WAD sounds have such heads (2,101 copies); replacing one of them must rewrite th
## Localized banks
Speech banks are localized inside the (language-neutral) packages: one `AkBank` export starts with a language
list (FStrings `INT FRA ITA DEU ESN`) and then holds one complete bank per language, in that order, all with
the same bank ID; each language's copy has its own media IDs (checked on 215 banks against the WAD languages
of their streamed lines). ackit labels every in-memory media with its language (`BANK.csv` column `language`):
8,098 language-neutral sounds, ~1,460 per voice language. A catalog shows the neutral sounds plus the loaded
language's lines; names are unique per language.
list (FStrings `INT FRA ITA DEU ESN`) and then holds one block per language, in that order: the BKPK table(s)
of the speech WAD entry for that language (`wad.md`) and a complete bank, with the same bank ID in every
language. ackit gives each bank and each BKPK table the language of its position in that list (an export
whose block count differs from its language count would be reported; none does). Each in-memory media gets
the languages of the banks that hold it (`BANK.csv` column `language`): 8,098 sounds are language-neutral,
~1,460 belong to one voice language, 6 to several (a DLC intro line shared by INT/FRA/ITA/ESN while DEU has
its own); a media stored in the banks of every language is listed as `''` (the same recording everywhere).
A catalog shows the neutral sounds plus the loaded language's lines; names are unique per language.
## Lines: one recording per language
Implemented in `ackit/lines.py`, checked by `ackit verify --catalog`. A voice line exists once per language,
and the game ties the recordings together itself:
- Every WEM of a speech WAD has exactly one row in its own language's BKPK table (media ID, offset, size):
10,816 of 10,816 in each of the 5 languages, no row without a WEM. 657 media IDs are used by several
languages (cinematics, emotes, Clayface), each at another position in each language's WAD, so the tables
are kept per language and never merged.
- Every recording, streamed, prefetched or in memory, is the source of a Wwise Sound object; Sound objects,
events, actions and containers have the same IDs in every language, only the media differ. A *line* = the
recordings that are sources of the same Sound objects: 12,434 lines (WAD and in-memory), each with exactly
one recording in each of the 5 voice languages (61,510 recordings), no Sound object with two recordings in
one language. Line identity (`line` column) = the smallest Sound object ID.
- Prefetched sources count too: the 396 lines with a prefetch head (mostly the game-over taunts) are linked
only through them.
- 5 recordings per language are stored twice in that language's WAD (same media ID in two entries, byte-
identical, e.g. `Speech_STEEL_A1_CH2_Thug_behinddoormuffled` and `DIA_SteelA1Muffled`): the second place
is a copy of the line (same `line`, keeps its position name) and is written and reverted with it, because
the game plays whichever place the loaded package's table names.
- Independent checks (no BKPK involved): every WAD recording is named as a source by a Sound object of a bank
of its own language (54,055 of 54,055); every stored prefetch head equals the start of the recording its
line gives it (2,101 of 2,101); a line's recordings have similar lengths across languages (11,761 of 12,434
within a factor 1.5; the others are different edits, e.g. a 0.55 s ESN line vs 5.46 s elsewhere).
## Banks inside packages
- The packages are LZO-compressed UE3 packages (`upk.md`); ackit decompresses them itself and searches the

View File

@@ -6,7 +6,8 @@ the UE3 packages (`upk.md`), holding the subtitle text of all 11 languages. acki
## Inventory (buildid 3718300)
- 14,743 dialogue objects in the packages for 9,016 speech lines (a line's object is copied into every package
that uses it; all copies are identical).
that uses it; all copies are identical), plus 41 scripted subtitles (below): ~9,400 subtitle strings and
168,688 stored copies.
- 11 languages: INT, FRA, ITA, DEU, ESN, ESM, POL, POR, RUS, JPN, KOR (the speech WADs exist only for INT, FRA,
ITA, DEU, ESN; the other languages play the INT voices with their own subtitles).
- ~9,400 subtitle strings per language: 94 % of the subtitles have one line, the rest up to 5 lines.
@@ -29,21 +30,25 @@ or property name from the name table, i32 byte size, i32 array index, then `size
| field | content |
|-------|---------|
| `0x10` @ `0x2C` | the speech event this line plays, e.g. export `Speech_CHURCH_B1_Ch2_POST_Explosion_BM_Cash_04` |
| `0x0D` @ `0x34` | speaker, e.g. `Batman` |
| `0x0D` @ `0x34` | speaker, e.g. `Batman` (`ackit verify --catalog` compares it with the curated speaker labels) |
| `0x09 LocalizedSubtitles` | u32 count, then per language: `0D 00 00 00` FString language code, `09 00 0C 00 Subtitles` struct, `00 00` |
| `Subtitles` | u32 line count, then per line: `0D 00 00 00` FString text, `04 00 0C 00` 4-byte value, `00 00` |
The sizes add up exactly in all 14,743 objects (checked by the parser: `SubtitleError` otherwise).
## Link to the speech sound
The speech event name is hashed (FNV-1) to the Wwise event ID, followed through the soundbanks (event ->
action -> containers -> Sound) to the streamed media, and that media ID is looked up in the per-language
BKPK tables (`banks.md`) to find the WAD entry and variation of each language. This is exact; the `_NN`
suffix of the event name does **not** match the variation order inside the WAD entry (WEMs are stored in
media-ID order). Lines kept in memory link to the localized BANK sound of their language. Linked: ~99 % of
the strings (e.g. INT 9,265 / 9,379); the rest have no event that reaches a sound (unused or placeholder
lines such as `..._TEMP_29`) and stay editable without a sound link.
Event names with a number (`Speech_X_04`) exist only as export names, not in the name table.
The speech event name is hashed (FNV-1) to the Wwise event ID and followed through the soundbanks (event ->
action -> containers -> Sound objects, the same in every language) to that language's recording of the line
(`banks.md`, *Lines*): a WAD variation or a localized in-memory sound. This is exact; the `_NN` suffix of the
event name does **not** match the variation order inside the WAD entry (WEMs are stored in media-ID order).
The names come from the packages' name and export tables (numbered names like `Speech_X_04` exist only as
export names) and from the subtitle objects themselves: some events are listed in no name table, but their
dialogue / RSubtitle object names them (Vicki Vale's radio reports, the Hush tape reveal).
An event that plays two versions of a line (105 subtitled events, e.g. Mr. Freeze's lines) links its subtitle
to both (`sound` = `key1;key2`). Linked: 103,304 of 103,658 strings in the 11 languages (INT 9,373 / 9,404),
each to the same line in every language. The rest (29 speech names, ~32 strings per language: Riddler trophy
texts, Arkham City Stories, radio reports and tapes the game does not ship audio events for) have no event in
any bank and stay editable without a sound link.
## How ackit writes them
- In place, into **every copy** (`data/catalog/SUB_locations.csv.gz`): the new text replaces the FString's

View File

@@ -22,6 +22,16 @@ All offsets in the summary (names, exports, ...) refer to the **uncompressed** p
package = the summary without the chunk table, followed by the chunks' data at their UncompressedOffset
(the first chunk starts right after the summary, at NameOffset).
## Name, import and export tables (uncompressed package, at NameOffset / ImportOffset / ExportOffset)
- Name: FString (positive length: ANSI incl. NUL; negative: UTF-16) + u64 flags.
- Import (28 bytes): ClassPackage (name index, number), ClassName (index, number), i32 OuterIndex,
ObjectName (index, number).
- Export: i32 ClassIndex, SuperIndex, OuterIndex, ObjectName (index, number), ArchetypeIndex, **one extra u32
(licensee 101)**, u64 ObjectFlags, i32 SerialSize, SerialOffset, u32 ExportFlags, i32 count + count x i32
(net objects), 16-byte guid, u32 package flags: 72 bytes when the net-object list is empty. Checked on every
export of the game. A name with number n is shown as `NAME_<n-1>` (e.g. the speech event `..._04`): such full
names exist only in the export table. `Package.tables()` returns names, imports and exports.
## Compressed chunks
Each chunk (at CompressedOffset): `u32 tag, u32 block size (0x20000), u32 compressed sum, u32 uncompressed
sum`, then one `{u32 compressed size, u32 uncompressed size}` per block, then the blocks. Every block is an

View File

@@ -26,14 +26,19 @@ Rules:
Payload kinds:
- **LOC WADs**: each entry is a group of WEMs, the variations of one line (ITA: 10816 WEMs in
2464 entries; e.g. `CHR_RadioScanner` holds 91). Each WEM starts at the next 0x800 boundary after
the previous one (zero padding); 3 entries pack two WEMs back to back. Addressed `HASH:i`.
the previous one (zero padding); 3 entries pack two WEMs back to back. Addressed `HASH:i`. The WEMs of an
entry are stored in media-ID order and the languages have different media IDs, so variation `i` is a
different line in each language (75 % of the lines). Which recording is which line comes from the banks
(`banks.md`, *Lines*), not from positions.
- **SFX WAD**: 79 grouped ambience/effect entries (260 WEMs) plus 4 **stream packs** (below).
Where the game gets the inner offsets: the entry IDs appear next to `BKPK` headers inside the
LZO-compressed UE3 packages (e.g. `BmGame.upk`, `OW.upk`, `Church_B1_*.upk`). Those embedded BKPK
tables give each WEM's offset **and size**. ackit does not rewrite `.upk` files, so these WEMs are
replaced **in place, no larger than the original** (zero-filled to the original size), and the WAD
layout never changes. The user's earlier quickbms-reimport mod did the same and was loaded by the game.
LZO-compressed UE3 packages (e.g. `BmGame.upk`, `OW.upk`, `Church_B1_*.upk`). A speech entry has one table
per language, inside a localized `AkBank` export (its k-th table belongs to the k-th language the export lists;
the SFX WAD's 79 tables are outside such exports). Those embedded BKPK tables give each WEM's offset **and
size**; in every language each WEM has exactly one row (`banks.md`, *Lines*). ackit never changes them,
so these WEMs are replaced **in place, no larger than the original** (zero-filled to the original size), and
the WAD layout never changes. The user's earlier quickbms-reimport mod did the same and was loaded by the game.
Only the 4 SFX stream packs, whose BKPK index is a loose `.bnk`, can change size.
## BKPK stream packs (`SFX\<ID>.bnk` + SFX WAD entry `<ID>`)
@@ -61,9 +66,9 @@ end rounded up to 0x800. Padding is mostly zero (a few non-zero bytes in `EDF036
original bytes where nothing moved).
Replacing a pack media (`ackit` target `EDF036D6/393BC53A`) rewrites the pack table (`.bnk`), the
WAD entry payload, and the WAD table/offsets. Verified in-game (NOTES.md, E2).
WAD entry payload, and the WAD table/offsets. Verified in-game (findings E2, E14).
## Regular soundbanks
The other `SFX\*.bnk` files and the banks embedded in `.upk` packages hold their WEMs in memory (not in a
WAD); see `banks.md`. The toolkit does not read or modify them yet.
WAD); ackit lists and writes them as the catalog archive `BANK` (`banks.md`).

View File

@@ -70,8 +70,8 @@ Conclusion: approach A works end-to-end in-game. Use >= 30 s probes for the intr
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`.
the `.upk` packages (15,641; 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.
@@ -95,12 +95,25 @@ Conclusion: approach A works end-to-end in-game. Use >= 30 s probes for the intr
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.)
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 %+.
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.
## 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`;

View File

@@ -27,10 +27,14 @@ game is set to (Steam > game properties > Language) and applies to everything yo
Changing it reloads the lists; the next start is again in the game's language.
## Names
Voice lines carry the game's own line names (e.g. `Speech_TAPE_JOKER_1_04`), the same in every language and the
same as in the Subtitles tab. Inside a WAD entry the game stores the variations of a line in a different order
in each language, so the position-based name is kept only as a second name (`Speech_TAPE_JOKER_1#4` = the 4th
WEM of that entry in this language; search finds both).
Every voice line has one name in every language, taken from the game itself (e.g. `Speech_TAPE_JOKER_1_04`, the
same as in the Subtitles tab): the line `Speech_X` in Italian is the same line `Speech_X` in English, French,
German and Spanish. Inside a WAD entry the game stores the variations of a line in a different order in each
language, so the position-based name is kept only as a second name (`Speech_TAPE_JOKER_1#4` = the 4th WEM of
that entry in this language; search finds both). When the game plays one of two versions of a line, the
second is shown as `NAME#2`. About 6 % of the lines are named by no game event; they take the name of their
English recording in every language. A few recordings are stored twice in the game files: both rows belong to
the same line and are changed and reverted together.
## Find a sound
- Type in the **search box** (Ctrl+F): words can be parts of the name, a category, a character, your own
@@ -77,8 +81,9 @@ than the original, since the game may cut it off.
- Right-click rows → **Revert selected**; toolbar **Revert all** puts every original back.
- Originals are kept in your user data folder (`%APPDATA%\UnheardCity` on Windows,
`~/.local/share/unheardcity` on Linux), so reverting works after updating or reinstalling the app too.
Uninstalling keeps this folder; use **Revert all** before uninstalling if you want the game back to original. Before the first
change to an audio archive, one full copy of it is also kept there as a fallback (Settings).
Uninstalling keeps this folder; use **Revert all** before uninstalling if you want the game back to original.
Before the first change to a game file, a full copy of it is kept there too: optional for the WAD archives
(Settings), always for soundbanks, packages and text files (they are rebuilt from that copy).
- If Steam "Verify integrity of game files" or an update undoes your changes, a bar offers **Re-apply my
changes** or **Forget them**. Do not use "verify" to undo mods; use Revert all.
@@ -90,11 +95,13 @@ The **Subtitles** tab lists the subtitle of every speech line in the selected la
shows *used / room* characters). Latin-1 subtitles accept Latin-1 characters only (accents are fine);
Russian, Polish, Japanese and Korean subtitles accept any character.
- **Go to the sound** opens the speech line in the Sounds tab. In the Sounds tab, the editor of a speech line
shows its subtitle under the audio: edit both side by side and use **Apply subtitle**, or **Show in
Subtitles** to jump back.
shows its subtitle in the **Subtitle** column next to the audio: edit both side by side, **Apply** /
**Revert** the subtitle there, or **Show in list** to jump back to the Subtitles tab.
- **Find and replace…** works on the rows the filters show (texts that would not fit are listed and skipped).
- **Revert** / **Revert all texts here** put the original text back (every copy in every game file).
- About 8 % of the subtitles belong to lines the game never plays (placeholders); they have no linked sound.
- About 30 subtitles per language have no sound in the game (texts such as the Arkham City Stories, unused
radio reports); they have no linked sound. A subtitle of a line the game plays in two versions belongs to
both. The game itself has no subtitles for efforts and grunts and for most combat barks and cutscene lines.
## Menu text
The **Menu text** tab lists every menu, HUD, bio, riddle and credits string of the selected language (about
@@ -107,10 +114,14 @@ Right-click → **Notes & tags…** (F2): free text and comma-separated tags, se
Click the **★** column to mark favorites. These are stored in your user data folder, not in the game.
## Share your changes (mods)
- **Export mod…**: choose which changed sounds to include and save a `.acmod` file (the exact game data plus
the audio, so it also works for another language whose original has a different size).
- **Export mod…**: choose which changed sounds and texts to include and save a `.acmod` file (the exact game
data plus the audio, so it also works for another language whose original has a different size).
- **Import mod…**: shows which sounds match this game, then installs them. You can revert them like any change.
- Changed subtitles and menu texts are exported and imported with the sounds (listed as *Text*).
- Changed subtitles and menu texts are exported and imported with the sounds (listed as *Text*); a text stays
in its language (an Italian subtitle edit changes the Italian subtitle).
- In another language, voice lines are matched by the game's own identity of the line, so the same line is
replaced even where its position differs. Voice lines from packages made with versions before 3.1 carry no
such identity: the import lists them instead of guessing (export the mod again with this version).
## Settings
![Settings](img/settings.png)
@@ -125,6 +136,7 @@ The editor panel opens tall enough to show all its controls; drag the divider ab
| "Cannot write … close the game" | quit Arkham City (and anything else using the file) and try again |
| "not a Batman: Arkham City folder" | pick the folder that contains `BmGame` and `Binaries` |
| "audio is too long for this sound" | shorten the audio (see the original's length) |
| "the text is … characters long but this subtitle has room for …" | shorten the subtitle (the counter shows the room) |
| "Cannot read … Supported: WAV, FLAC, …" | convert the file to WAV/MP3/FLAC, or install ffmpeg |
| "changed outside this tool" | the game files were changed by an update or another tool; revert or confirm to overwrite |
| no sound when playing | choose another playback device in Settings |