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.
216 lines
19 KiB
Markdown
216 lines
19 KiB
Markdown
# Decisions
|
|
|
|
## 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 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.
|
|
- **Distribution:** PyInstaller one-folder build per platform. It bundles Python, Qt and all native libraries;
|
|
the user unzips and runs `UnheardCity(.exe)`, or on Windows double-clicks `Install.cmd` (see below). Nothing
|
|
else is installed. Alternatives considered:
|
|
Tauri/Rust or C# would mean rewriting the verified codec/injector (high risk, no benefit); a local web UI
|
|
would still need a bundled runtime and makes audio recording harder.
|
|
|
|
## 2. No external programs at runtime
|
|
The first toolkit used ffmpeg (decoding inputs and libvorbis encoding) and vgmstream (checks). Both are gone:
|
|
- **Encoding:** `libsndfile` (via the `soundfile` wheel) contains libvorbis 1.3.7. Tested: it yields exactly the
|
|
same codebook compatibility and the same game setups as ffmpeg's libvorbis (findings 12). Encoding is in-process.
|
|
- **Decoding game WEMs:** ackit's own WEM -> Ogg converter (the game's codebooks) + libsndfile.
|
|
- **Input formats:** libsndfile reads WAV, FLAC, OGG/Vorbis/Opus, MP3, AIFF and more. M4A/AAC/WMA would need
|
|
ffmpeg: an ffmpeg found on the system is used if present, otherwise the user gets a clear "convert to WAV/MP3"
|
|
message. Bundling ffmpeg (~80-100 MB, GPL/LGPL build care) for rare formats was judged not worth it.
|
|
- **Resampling:** `soxr` (LGPL-2.1).
|
|
So there are **no vendor binaries to fetch** (no `tools/fetch_vendor` script was needed): every native
|
|
dependency comes from version-pinned PyPI wheels (which embed libsndfile, libvorbis, libogg, FLAC, opus, mpg123,
|
|
Qt and Qt Multimedia's FFmpeg backend) that PyInstaller bundles, resolved relative to the app (never `PATH`).
|
|
`tools/collect_licenses.py` writes `THIRD_PARTY_LICENSES.txt` into each build from the wheels' own license files
|
|
plus official texts kept in `tools/licenses/` (sources in SOURCES.txt; no network at build time). Verified: the packaged Windows build runs
|
|
its decode/encode/inject smoke test with `PATH` reduced to `C:\Windows` (no ffmpeg, vgmstream or Python).
|
|
**Licenses:** PySide6/Qt LGPL-3.0 (dynamic linking, unmodified Qt shared libraries in the one-folder build,
|
|
source at https://code.qt.io), libsndfile LGPL-2.1, mpg123 LGPL-2.1, soxr LGPL-2.1, libvorbis/libogg/FLAC/opus
|
|
BSD, numpy BSD, Python PSF. One-folder (not one-file) builds keep the LGPL libraries replaceable.
|
|
The only Windows-only part is the in-game test harness (`ackit verify --ingame`, uses WinDbg/cdb and WASAPI
|
|
loopback); it is a developer tool and not part of the editor.
|
|
|
|
## 3. Where originals live (revert safety)
|
|
- **Originals store** per game folder: `<user data>/UnheardCity/stores/<id>/` (Windows `%APPDATA%`, Linux
|
|
`~/.local/share/unheardcity`), id = hash of the game path; configurable (`--store`, editor settings).
|
|
Before a sound is first changed, its original WEM bytes (SHA-256-named blob) and metadata are saved;
|
|
the applied replacement is saved too. **Revert** re-injects the stored original; **Revert all** (editor toolbar,
|
|
`ackit revert --all`) does this for every record of the store, sounds and texts, in every language. Stream-pack
|
|
padding bytes are stored so pack reverts are byte-identical. The uninstaller counts sound and text records and
|
|
does not offer to delete the store while any is left.
|
|
- **One store per game at a time:** the changes are recorded only in the store that made them. The editor
|
|
refuses to switch to another store folder while changes are applied (Revert all first). A store takes the
|
|
full copy of a soundbank or package only from a file that matches the shipped catalog (every in-memory
|
|
sound's checksum, every bank copy of a voice recording, every subtitle; `Workspace.unoriginal_files`), so a
|
|
second store (`--store`) or another tool's change is a conflict, never a new "original". (Menu files and other
|
|
game builds have no catalog checksums to check.)
|
|
- **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. If the store is damaged, `ackit restore-backup --yes`
|
|
copies the WADs back (their sounds are forgotten) and rebuilds the soundbanks, packages and localization files
|
|
from their copies, keeping the sounds of WADs without a full copy recorded and revertible.
|
|
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
|
|
first. Several sounds of one WAD are written in one pass. A locked WAD (game running) gives a clear message.
|
|
Writes are serialized (`Workspace.lock`): the editor runs them as background jobs, and two rebuilds of one
|
|
package at once (e.g. a sound and its subtitle applied side by side) would otherwise lose one edit.
|
|
- **Changed underneath:** each sound's state is computed from its bytes: original / modified (ours) / lost
|
|
(ours was replaced by the original, e.g. Steam "verify") / changed (neither: update or another tool) /
|
|
foreign (never touched by us, but not the catalog's original checksum). Writing over changed/foreign sounds
|
|
or texts requires explicit confirmation (editor: a question, also when installing a mod; CLI: `--force`); the
|
|
catalog is used only when the game's fingerprint matches.
|
|
|
|
## 4. Size rule
|
|
Speech/ambience variations are addressed by offset+size from BKPK tables inside compressed `.upk` packages,
|
|
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
|
|
tables): high risk for little gain, not done.
|
|
|
|
## 4b. In-memory sounds (soundbanks in loose `.bnk` files and `.upk` packages)
|
|
- **Model:** one catalog row per Wwise media ID (archive `BANK`, target = media ID), not per bank: the same
|
|
media is stored in up to dozens of bank copies (banks are cooked into every package that uses them), and the
|
|
game may load any of them. A replacement is written into **every** copy (`BANK_locations.csv.gz`).
|
|
- **In place:** each copy keeps its size; DIDX/HIRC offsets and the package layout never change, so nothing
|
|
else in the engine data has to be rewritten. Unlike WAD speech, an in-memory copy is never zero-padded:
|
|
the replacement fills its slot exactly with a label list chunk (the game plays a padded in-memory WEM as
|
|
silence; `docs/formats/banks.md`). Shrinking the declared sizes (DIDX and every Sound object's in-memory
|
|
size) would also work, but needs every HIRC source of the media in every bank copy located and rewritten.
|
|
- **Packages:** ackit reads the LZO packages itself (pure Python, ~47 MB/s, block-level access) and writes
|
|
only the changed 128 KiB blocks, stored as a single LZO literal run (valid LZO1X without matches), then
|
|
fixes the chunk table. No compressor and no external tool are needed; untouched blocks stay byte-identical.
|
|
- **Revert:** before the first change to a bank file a verified full copy goes to the store (mandatory, not
|
|
optional like the WAD copies: the file is rebuilt from it). The file content is always "original copy +
|
|
replacements applied now", so reverting the last one restores the original bytes exactly, and an interrupted
|
|
operation is repaired by the next one. Conflicts: the whole-file SHA-1 must be the original's or the last one
|
|
ackit wrote, and before the copy is taken the file must match the catalog (section 3).
|
|
- **Prefetch heads and whole copies:** 396 voice recordings (81 lines, 78-80 recordings per language) have
|
|
their first bytes copied into banks, and 49 more are stored whole in banks (same media ID, identical bytes);
|
|
a replacement of such a recording rewrites those copies too, otherwise the game could play the old line.
|
|
They are not listed as sounds of their own.
|
|
- **Names:** no list names these media. Names come from Wwise events whose names are in the
|
|
packages' name and export tables (FNV-1 of the name = event ID), following event -> action -> containers ->
|
|
sounds (13,501 of 15,355, voice lines included); the other 1,854 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
|
|
string (the game stops at the first NUL), so nothing in the package moves; the limit is the original
|
|
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 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).
|
|
- Keys (`SUB:<LANG>:<speech>:<line>`, `MENU:<LANG>:<File>:<Section>:<Key>`) are stable and used in mod packages.
|
|
|
|
## 4d. One language setting
|
|
The editor has a single *Language* list (top bar) for voices, subtitles and menu text, starting at the language
|
|
the game is set to on every launch (not remembered: the game's setting is what the player hears). The game
|
|
has voices in 5 languages and texts in 11; for the other 6 it plays the INT voices, so the editor loads the INT
|
|
voice lines with that language's texts and says so next to the list. Localized in-memory speech (one bank
|
|
copy per voice language) is filtered the same way, so the sound list never mixes languages.
|
|
|
|
## 5. Catalog
|
|
- 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).
|
|
- Names come from the game files only: a WAD entry or stream pack is named after its soundbank in the game's
|
|
`CookedBanks.txt` (entry ID = FNV-1 of the bank name; this gives exactly the names of the community hash list
|
|
the toolkit used to ship, which was removed), speech lines after their events (below).
|
|
- Verified speakers come only from the user's lists; name-based character inference is kept in a separate field.
|
|
- 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; 81.2-81.6 % of the WEMs in multi-variation entries
|
|
are at another position than in INT).
|
|
- A line has one name in every language: the most specific speech event that plays it (10,174 of the 10,811
|
|
WAD voice lines), numbered `NAME#2` in the event's own order when it plays two versions; the other 637 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 and texts: `tools/build_catalog.py` stops before writing anything if a
|
|
sound or a subtitle differs from the current catalog (a modified game), unless `--accept-changes` (a game
|
|
update).
|
|
- Category scheme: `docs/categories.md`.
|
|
|
|
## 6. Repository cleanup choices
|
|
- Windows installer: a per-user PowerShell installer shipped in the zip (`Install.cmd`) instead of Inno Setup or
|
|
MSI, so building needs no extra tool; no admin rights, Start menu shortcut, Settings > Apps uninstall entry.
|
|
The zip still runs without installing.
|
|
- One entry point: `ackit` (CLI) and `UnheardCity` (GUI). The `.bat` launchers and the `mods/` folder
|
|
flow were removed (`ackit apply <folder>` and mod packages replace them).
|
|
- The user's third-party downloads that sat in `tools/` and `repositories/` (quickbms, Ravioli, AbdAshes' guide,
|
|
vgmstream/ww2ogg/revorb sources) were moved to the git-ignored `external/` folder, since `tools/` now holds
|
|
build scripts only.
|
|
- Experiment scripts and outputs were deleted; they are preserved in git tag `pre-cleanup`.
|
|
- The old `_backup/` (2.3 GB full copies) was deleted on 2026-10-08 with the game verified original; the originals
|
|
store does not need it.
|
|
|
|
## 7. Editor UX choices
|
|
- 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). Inside each
|
|
column the controls are aligned, not packed in rows that wrap: Replace's edit fields in a grid with one
|
|
label column, and every column's actions in one full-width bar at its bottom, level across the three
|
|
columns (Listen: Original | Installed | Stop; Replace: Apply to game, twice as wide, | Revert | Save WAV;
|
|
Subtitle: Apply | Revert). Every row of buttons fills its column's width (Load file / device / Record; Trim
|
|
silence / Replacement; Show in list). A/B and Loop are icon buttons with tooltips next to the volume. The editor fits a 1000-pixel-wide window without horizontal scrolling (measured:
|
|
minimum 984 px with the window's vertical scroll bar). *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
|
|
window stays responsive. Errors are shown as plain sentences (game running/locked file, wrong folder,
|
|
unsupported audio, too long, changed outside the tool).
|
|
- No network access and no telemetry. User notes/tags/favorites live in the user data folder.
|
|
- One look: neutral dark greys (no blue tint), a single dark red accent (#b0242c, theme and icon), solid panels. Icons are vector drawings in
|
|
`app/icons.py` (no emoji, no icon fonts, no image files), so they look the same on every platform.
|
|
- **Artwork:** the app icon (the Arkham City logo bat in flat black over a red audio waveform of rounded bars, on a
|
|
white rounded tile; shown only in the window/taskbar and the toolbar corner) and the default background wallpaper come from images supplied by the user.
|
|
`tools/make_assets.py <folder>` turns `bac_logo_full.png` + `wallpaper.jpg` into `app/assets/` (app_icon.png,
|
|
app_icon.ico, wallpaper.jpg), which the build bundles. The folder is committed (public artwork and generated
|
|
content). Without the assets the app falls back to a plain waveform icon and no wallpaper.
|
|
- Background: the wallpaper sits only behind the filter panel, scaled to the panel height so its left part fills
|
|
the panel, under a light veil. Settings -> Background toggles it and sets its strength (default 10 %).
|
|
- Loading feedback stays where the work is, never modal: a spinner inside the waveform being loaded (sound or
|
|
replacement file, decoded off the GUI thread), next to the Apply/Revert buttons while encoding/writing, over
|
|
a list while it loads (the sound list at start and on a language change, the subtitles / menu text of a
|
|
language, read off the GUI thread), and in the status bar for game loading and bulk changes.
|
|
- Lists filter and sort in Python, not with Qt's QSortFilterProxyModel (`app/models.py: RowView`): the view
|
|
computes the visible rows in one pass and one sort over cached keys (~10-20 ms for 21,000 sounds; the Qt
|
|
proxy called back into Python for every comparison: up to 1.5 s per sort or widened filter). Rows a filter
|
|
shows again keep their sorted place; selection and the current row survive every change.
|
|
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, one also stored whole in a bank), 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 and whole bank copies of a line, 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).
|
|
- Documentation: `tools/check_docs.py [--scan] [--wems]` recomputes every figure the documents state (from the
|
|
catalog, the build's `index.json`, the menu files; with `--scan` from a fresh package scan and the catalog
|
|
check's facts; with `--wems` from rebuilding every WAD WEM) and fails on any difference, so figures are never
|
|
copied by hand.
|
|
- 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.
|