Files
UnheardCity/docs/formats/subtitles.md
Cristian Gora 1ccaa8b398 Full correctness check against the docs: safer writes, reverts and texts
Safety
- Writes are serialized (Workspace.lock): two background jobs rebuilding one
  package (a sound and its subtitle applied side by side) lost an edit.
- A store copies a bank file or package as "original" only if it matches the
  shipped catalog (Workspace.unoriginal_files: in-memory sounds, bank copies of
  voice lines, subtitles); the editor refuses to switch stores while changes
  are applied. A second store could otherwise resurrect a reverted sound.
- Mod import in the editor asks before overwriting files changed outside the
  tool (it forced silently).
- Revert all in the editor reverts texts too; the uninstaller counts text
  edits before offering to delete the store.
- ackit restore-backup: the full-backup restore decisions.md described; it
  keeps the sounds of WADs without a full copy recorded.
- build_catalog also stops when a subtitle differs from the catalog.

Languages and texts
- Game.language() returns languages without voices (JPN, RUS, ...) and
  Game.voice_archive() their INT voices; ackit text --lang JPN works.
- Menu lookups parse one cached file (find & replace: minutes -> ms).
- Subtitles decode cp1252 as Windows does: the UTF-8-in-cp1252 line
  Speech_TAPE_J_Answer_1_00 (KOR/POL/POR/RUS) is written back byte-for-byte;
  SUB_KOR/SUB_RUS rebuilt from the original game (only those rows changed).
- Mods exported while another language is loaded keep name and line.

Other fixes
- Down-mix folds extra channels in at -3 dB (a 5.1 centre voice was dropped).
- e2e_editor no longer overwrites the user's settings.json; kill_game waits
  for the game to exit before reverts.
- Recorder handles Int32/UInt8 input and split frames; package block cache
  locked; conflict messages, CLI help examples, stale docstrings and doc
  references fixed; tests no longer read this PC's game language.

Docs (decisions, user guide, README, subtitles.md, banks.md, catalog README,
REPORT, NOTES, CLAUDE.md) updated; check_docs: 102 claims match. 148 tests;
e2e_features 9/9, e2e_editor and verify PASS; game original afterwards.
2026-10-13 21:34:13 +02:00

65 lines
4.9 KiB
Markdown

# Subtitles (`RDialogueEvent` objects in the packages)
There are no subtitle files: every speech line has a dialogue object (class `RDialogueEvent`, Rocksteady) inside
the UE3 packages (`upk.md`), holding the subtitle text of all 11 languages. ackit reads them with
`ackit/subtitles.py` and writes them through `Workspace.texts` (`ackit/texts.py`).
## Inventory (buildid 3718300)
- 14,744 dialogue objects in the packages for 9,032 speech lines (a line's object is copied into every package
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,035-9,667 subtitle strings per language: 94-98 % of the subtitles have one line (JPN: all), the rest up
to 5 lines.
- Strings are stored as cp1252 (positive FString length) or UTF-16 (negative length): almost all RUS, JPN and
KOR strings and 85 % of POL are UTF-16; the other languages have 17-56 UTF-16 strings each.
4 cp1252 strings hold UTF-8 bytes instead (the same line in KOR, POL, POR, RUS: `Speech_TAPE_J_Answer_1_00`;
the KOR one with a damaged byte; the game's own data). ackit reads cp1252 as Windows maps it (every byte, also
the 5 that Python's cp1252 leaves undefined, which the RUS and KOR ones contain), so they are shown as stored
and every string is written back byte-for-byte.
## Scripted subtitles (`RSubtitle`)
41 more subtitles (radio and encryption-card lines) are `RSubtitle` objects in `Startup.upk`: the same layout,
with the per-language array named `ArraySubtitles` and a float (duration) at `0x2C` instead of a speech
reference. `RSubtitleLookup` (`Startup.upk`, `Lookups`) is an array of `{int key @0, object @4}`, key =
FNV-1 of the speech line name without `Speech_` (e.g. `CH_ZERO_CH0_ENCRYPTION_CARD2_01`), which links each
`RSubtitle` exactly to its line. ackit reads and writes both kinds the same way.
## Object layout (compact tagged properties)
The export data is a list of properties `u16 type, u16 field offset, value`, ended by `u16 0`. Types seen:
`0x10` object reference (i32 export/import index), `0x0D` FString, `0x09` struct/array (name i32+i32 = struct
or property name from the name table, i32 byte size, i32 array index, then `size` bytes), `0x04` 4-byte value,
`0x03` bool, `0x01` enum. Relevant properties:
| 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` (`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,744 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 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 (24 speech names, 24-36 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
bytes and is padded with NUL characters, keeping the length field. The game stops at the first NUL, so no
size field, export or package layout changes. Limit: the new text must fit the original string's bytes
(cp1252: one byte per character; UTF-16: two) - shown as "room" in the CLI and the editor.
- A cp1252 string can only hold cp1252 characters; UTF-16 strings can hold any character.
- Packages are rebuilt from the store's original copy + all applied sound and text edits (`docs/decisions.md`).