# 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: `/UnheardCity/stores//` (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** does this for 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 first. Several sounds of one WAD are written in one pass. A locked WAD (game running) gives a clear message. - **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 requires explicit confirmation; the catalog is used only when the WAD 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. - **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:::`, `MENU:::
:`) 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: `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 - 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 ` 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) 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 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 ` 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.