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.
This commit is contained in:
2026-10-13 21:34:13 +02:00
parent a6ef3b9458
commit 1ccaa8b398
41 changed files with 846 additions and 186 deletions

View File

@@ -5,7 +5,8 @@ Batman: Arkham City (PC) audio toolkit + editor. Read `NOTES.md` first (status,
- Layout: `ackit/` library + CLI (`python -m ackit -h`), `app/` PySide6 editor, `tests/` pytest, `data/catalog/` - Layout: `ackit/` library + CLI (`python -m ackit -h`), `app/` PySide6 editor, `tests/` pytest, `data/catalog/`
CSVs (+ user's original lists in `data/catalog/source/`, never edit them), `docs/`, `tools/` build scripts. CSVs (+ user's original lists in `data/catalog/source/`, never edit them), `docs/`, `tools/` build scripts.
- Dev env: `.venv` (Python 3.14, `pip install -e .[app,dev,verify]`). Run tests with `.venv/Scripts/python -m pytest -q`. - Dev env: `.venv` (Python 3.14, `pip install -e .[app,dev,verify]`). Run tests with `.venv/Scripts/python -m pytest -q`.
- Writing game files goes through `ackit.workspace.Workspace` only (originals store, atomic writes, conflict checks). - Writing game files goes through `ackit.workspace.Workspace` only (originals store, atomic writes, conflict checks,
one write at a time: `Workspace.lock`, held by every apply/revert/rebuild incl. `Texts`).
Speech/ambience: in place, never larger than the original WEM; music packs: any size (`docs/formats/wad.md`). Speech/ambience: in place, never larger than the original WEM; music packs: any size (`docs/formats/wad.md`).
In-memory sounds (archive BANK, loose `.bnk` + `.upk` packages): in place into every copy; a bank file is always In-memory sounds (archive BANK, loose `.bnk` + `.upk` packages): in place into every copy; a bank file is always
rebuilt from the store's original copy + the applied replacements (`docs/formats/banks.md`, `upk.md`). rebuilt from the store's original copy + the applied replacements (`docs/formats/banks.md`, `upk.md`).
@@ -15,7 +16,7 @@ Batman: Arkham City (PC) audio toolkit + editor. Read `NOTES.md` first (status,
`ackit verify --ingame [--banks | --texts]` (Windows; needs Steam running and logged in, WinDbg; ~1.5 min; reverts and `ackit verify --ingame [--banks | --texts]` (Windows; needs Steam running and logged in, WinDbg; ~1.5 min; reverts and
checks byte-identity itself). checks byte-identity itself).
- Rebuild catalogs only from original game files: `python tools/build_catalog.py` (~5 min, scans all packages; - Rebuild catalogs only from original game files: `python tools/build_catalog.py` (~5 min, scans all packages;
stops if a sound differs from the current catalog). Voice lines are identified by the game's Wwise Sound stops if a sound or subtitle differs from the current catalog). Voice lines are identified by the game's Wwise Sound
objects (`ackit/lines.py`, column `line`; one recording per language), never by WAD position or name: objects (`ackit/lines.py`, column `line`; one recording per language), never by WAD position or name:
positions differ per language (`docs/decisions.md` 5, `docs/formats/banks.md` *Lines*). positions differ per language (`docs/decisions.md` 5, `docs/formats/banks.md` *Lines*).
- Figures in the docs are checked, not copied: after changing data, code or docs run `python tools/check_docs.py` - Figures in the docs are checked, not copied: after changing data, code or docs run `python tools/check_docs.py`

View File

@@ -126,6 +126,42 @@
action bar level with the others; A/B and Loop are icons next to the volume. Fits a 1000-px window action bar level with the others; A/B and Loop are icons next to the volume. Fits a 1000-px window
without horizontal scrolling (minimum 984 px). Docs: recordings the game shares between languages (user saw without horizontal scrolling (minimum 984 px). Docs: recordings the game shares between languages (user saw
English lines "leaking" into other languages: the game's own data, banks.md *Lines*). English lines "leaking" into other languages: the game's own data, banks.md *Lines*).
- 2026-10-13 (user: "full check of the source code for correctness, also against the documentation"): figures
all matched (`check_docs --scan`), the formats code matched the docs; fixed: editor **Revert all** reverted
sounds only (docs and uninstaller promised everything; the uninstaller also counted only sound records and
could offer to delete the store while texts were modified) -> sounds + texts, uninstaller counts texts;
concurrent background writes to one package lost an edit (reproduced) -> `Workspace.lock` serializes every
write; editor mod import forced over conflicts silently -> asks; a second originals store (Settings / `--store`)
copied a package modified by the first as its "original" (reproduced: a replaced sound survived both reverts)
-> `Workspace.unoriginal_files` (catalog checksums, heads, subtitles; 1,447 files, no false alarm, ~15 s for
all) makes it a conflict and the editor refuses a store change while changes exist; `ackit text --lang JPN`
(text-only languages) failed -> `Game.voice_archive`; menu lookups reloaded all ~37 files per call (find &
replace of "the" in INT: ~4 min frozen) -> one cached file per lookup (0.04 s for 1,193 keys);
build_catalog's safety check ignored subtitles -> also compares them; `ackit restore-backup` added (decisions
named a restore that nothing exposed; it also forgot WAD sounds without a full copy); recorder handles
Int32/UInt8 input and split frames; stale docstrings, `ackit -h` examples, conflict message, case-insensitive
Localization path in `verify --catalog`. 146 tests. During the checks an e2e_editor game launch failed
("code never decrypted") and its revert found the WAD still locked by the dying game (left Music_393BC53A
modified; reverted by hand, SFX catalog OK): `debugger.kill_game` now waits until the game has exited.
- 2026-10-13 second full check (user: "do this again to make sure"): codec (wwvorbis, codebooks, allocsize, ogg),
classify vs categories.md, verify harness, tools and widgets re-read; fixed: `tools/e2e_editor.py` saved its own
settings over the user's settings.json (lost store/device/background settings; it now uses the user's
settings and never saves); `Game.language()` ignored languages without voices (a JPN game opened the editor
and `ackit text` in INT; now the game's language, `Game.voice_archive` for its speech archive) and the tests
read this PC's UserEngine.ini (conftest isolates it); a mod exported while another language was loaded lost
name + line of that language's changes (`Workspace.sound_for_key`); down-mix dropped the extra channels (a
5.1 centre-only voice became silent stereo; now folded in at -3 dB, peak-limited); `Texts.for_sound` default
for shared in-memory lines; package block cache locked (concurrent readers); `catalog._whole_recordings` ->
`whole_recordings` in docs/comments; the user guide no longer sends app users to the CLI-only restore.
148 tests.
- 2026-10-13 third check (property checks on the real game data): every menu file rewrites byte-identically,
no ambiguous menu keys, every subtitle copy has the catalog's room; 2 of 103,658 subtitle strings could not be
written back as stored: one line (`Speech_TAPE_J_Answer_1_00`) is UTF-8 bytes in a cp1252 string in KOR, POL,
POR, RUS (the game's data; KOR's damaged), and the RUS/KOR ones contain the 5 bytes Python's cp1252 leaves
undefined (decoded as U+FFFD, lossy) -> cp1252 as Windows maps it (lossless; all 103,658 reproduce), catalog
rebuilt from the original game (`--accept-changes`: exactly those 2 rows changed), subtitles.md documents it
(claim derived by check_docs). User guide: Space plays the replacement once one is loaded (not always the
original); tooltip too. App smoke test on the real game PASS.
- DONE by the user in game: replaced voice lines, edited subtitles and replaced footsteps (after the fill fix). - DONE by the user in game: replaced voice lines, edited subtitles and replaced footsteps (after the fill fix).
- OPEN: channel order for >2 channels unverified; 19 speaker labels to check by listening; the Linux build was - OPEN: channel order for >2 channels unverified; 19 speaker labels to check by listening; the Linux build was
never built or run (no Linux here). never built or run (no Linux here).

View File

@@ -38,13 +38,14 @@ localization files. Details in `docs/`.
ackit find joker tape # search by name, category or character ackit find joker tape # search by name, category or character
ackit extract Speech_TAPE_JOKER_1_04 orig.wav # listen to the original (a line has one name in every language) ackit extract Speech_TAPE_JOKER_1_04 orig.wav # listen to the original (a line has one name in every language)
ackit replace Speech_TAPE_JOKER_1_04 my_line.wav --match-loudness ackit replace Speech_TAPE_JOKER_1_04 my_line.wav --match-loudness
ackit revert Speech_TAPE_JOKER_1_04 # or: ackit revert --all ackit revert Speech_TAPE_JOKER_1_04 # or: ackit revert --all (every sound and text)
ackit text find --lang INT protocol # subtitles and menu texts (keys SUB:... / MENU:...) ackit text find --lang INT protocol # subtitles and menu texts (keys SUB:... / MENU:...; any of the
# 11 languages, e.g. --lang JPN)
ackit text set "MENU:INT:FRONTEND.int:Buttonpress:DoubleTap" "Tap twice" ackit text set "MENU:INT:FRONTEND.int:Buttonpress:DoubleTap" "Tap twice"
``` ```
More: `ackit status`, `ackit list --character Joker`, `ackit export-mod my.acmod` / `ackit import-mod`, More: `ackit status`, `ackit list --character Joker`, `ackit export-mod my.acmod` / `ackit import-mod`,
`ackit verify` (offline self-check), `ackit verify --catalog` (catalog vs game files), `ackit game` (detected `ackit verify` (offline self-check), `ackit verify --catalog` (catalog vs game files), `ackit game` (detected
install, language, store). `ackit -h` lists all commands. The game folder is found through Steam (Windows, Linux, Flatpak, Proton); otherwise pass `--game DIR`. install, language, store), `ackit restore-backup` (last resort if the originals store is damaged). `ackit -h` lists all commands. The game folder is found through Steam (Windows, Linux, Flatpak, Proton); otherwise pass `--game DIR`.
Rules worth knowing: speech/effects replacements must fit in the original's size (the tool squeezes or refuses Rules worth knowing: speech/effects replacements must fit in the original's size (the tool squeezes or refuses
with the maximum length); music can be any length. Subtitles must fit the original text's length (menu text: with the maximum length); music can be any length. Subtitles must fit the original text's length (menu text:

View File

@@ -41,8 +41,14 @@ editor Unheard City (mission 2).
export/import with texts, changed-outside banner for sounds and texts. export/import with texts, changed-outside banner for sounds and texts.
- **Safety**: every changed file (WAD, bank, package, localization file) is rebuilt from a verified original copy - **Safety**: every changed file (WAD, bank, package, localization file) is rebuilt from a verified original copy
in the store + the edits applied now; reverting the last edit restores it byte-for-byte; files changed by in the store + the edits applied now; reverting the last edit restores it byte-for-byte; files changed by
another tool or Steam are detected (whole-file SHA-1) and not overwritten without confirmation. another tool or Steam are detected (whole-file SHA-1) and not overwritten without confirmation (also when
- **Tests**: 135 automated tests (unit, synthetic game with packages/banks/dialogues/localization, a installing a mod in the editor). The original copy is taken only from a file that matches the catalog
(another tool's or another originals store's change is a conflict; checked on all 1,447 files with sounds or
subtitles: no false alarm), and the editor does not switch stores while changes are applied. Writes are
serialized, so a sound and its subtitle applied at the same time both reach the package. **Revert all**
(editor and `ackit revert --all`) reverts sounds and texts; `ackit restore-backup` is the last resort for a
damaged store.
- **Tests**: 148 automated tests (unit, synthetic game with packages/banks/dialogues/localization, a
two-language game for the lines (incl. a recording also stored whole in a bank), the shipped catalog's cross-references, real-game read-only (every two-language game for the lines (incl. a recording also stored whole in a bank), the shipped catalog's cross-references, real-game read-only (every
name of every language's catalog resolves to its own sound), offscreen GUI incl. list order, selection and name of every language's catalog resolves to its own sound), offscreen GUI incl. list order, selection and
loading overlays) pass. `ackit verify` PASS (incl. loading overlays) pass. `ackit verify` PASS (incl.
@@ -60,7 +66,9 @@ editor Unheard City (mission 2).
- **Package**: `dist/UnheardCity-3.2.3-windows-amd64.zip` (86 MB, catalogs included). With `PATH` reduced to - **Package**: `dist/UnheardCity-3.2.3-windows-amd64.zip` (86 MB, catalogs included). With `PATH` reduced to
the Windows folders the packaged exe passes its smoke test (in the game's language - Italian on the test PC: the Windows folders the packaged exe passes its smoke test (in the game's language - Italian on the test PC:
21,015 sounds loaded, encode + inject, 9,377 subtitles and 7,020 menu texts loaded, an in-memory sound decoded from a package) and the GUI starts and 21,015 sounds loaded, encode + inject, 9,377 subtitles and 7,020 menu texts loaded, an in-memory sound decoded from a package) and the GUI starts and
quits cleanly. The installer is unchanged since mission 2 (install/upgrade/uninstall verified then). quits cleanly. The installer is unchanged since mission 2 (install/upgrade/uninstall verified then); the
uninstaller now also counts text edits before offering to delete the user data (2026-10-13; that count was
tested, the full uninstall cycle was not run again).
## Unresolved / limitations ## Unresolved / limitations
- **Checked by a person in game** (2026-10-10), not automatically (nothing of this plays or shows on the title - **Checked by a person in game** (2026-10-10), not automatically (nothing of this plays or shows on the title

View File

@@ -53,21 +53,29 @@ def resample(x, rate, new_rate):
def to_channels(x, channels): def to_channels(x, channels):
"""Up/down-mix. Mono is copied to every channel; extra channels get the average.""" """Up/down-mix. Up: mono is copied to every channel, other sources keep their channels and the new ones get
the average. Down: nothing is dropped - every channel goes into the mono sum, or the channels beyond the
kept ones (e.g. the centre and surrounds of 5.1) are added to every kept one at -3 dB (the standard
fold-down); a result louder than the source's peak is scaled back to it, so it does not clip."""
x = _as2d(x) x = _as2d(x)
cur = x.shape[1] cur = x.shape[1]
if cur == channels: if cur == channels:
return x return x
if channels == 1:
return x.mean(axis=1, keepdims=True)
if cur == 1: if cur == 1:
return np.repeat(x, channels, axis=1) return np.repeat(x, channels, axis=1)
out = np.empty((len(x), channels), dtype=np.float32) if channels > cur:
keep = min(cur, channels) out = np.empty((len(x), channels), dtype=np.float32)
out[:, :keep] = x[:, :keep] out[:, :cur] = x
if channels > keep: out[:, cur:] = x.mean(axis=1, keepdims=True)
out[:, keep:] = x.mean(axis=1, keepdims=True) return out
return out if channels == 1:
out = x.sum(axis=1, keepdims=True)
else:
out = x[:, :channels] + 0.7071 * x[:, channels:].sum(axis=1, keepdims=True)
peak_in, peak_out = float(np.max(np.abs(x), initial=0.0)), float(np.max(np.abs(out), initial=0.0))
if peak_out > peak_in > 0:
out = out * (peak_in / peak_out)
return out.astype(np.float32)
def trim(x, rate, start=None, end=None): def trim(x, rate, start=None, end=None):

View File

@@ -4,10 +4,12 @@ in the .upk packages (docs/formats/banks.md, upk.md). Catalog archive `BANK`, ta
A media ID can be stored many times (the same bank copied into several packages, or several banks A media ID can be stored many times (the same bank copied into several packages, or several banks
holding the same media). The location index (data/catalog/BANK_locations.csv.gz) lists every copy: holding the same media). The location index (data/catalog/BANK_locations.csv.gz) lists every copy:
media, file (relative to CookedPCConsole, '/'-separated), offset (in the *uncompressed* file), size, kind media, file (relative to CookedPCConsole, '/'-separated), offset (in the *uncompressed* file), size, kind
kind 'm' = an in-memory sound, 'p' = the in-memory head of a streamed WAD sound (prefetch); for 'p' rows kind 'm' = an in-memory sound, 'p' = a bank copy of a streamed WAD recording (the in-memory head of a prefetched
`sound` is the WAD sound's key (e.g. ITA:2B14E472:3): speech lines have per-language media IDs and heads. sound, or a whole copy with the same media ID); for 'p' rows `sound` is the WAD sound's key (e.g. ITA:2B14E472:3):
Replacements are written in place into every copy (<= original size, zero-padded), so bank and package speech lines have per-language media IDs and heads.
layouts never change. Replacements are written in place into every copy, no larger than the smallest; a whole in-memory WEM fills its
slot exactly (wem.fill: the game plays a zero-padded one as silence), a head gets the start of the new
recording. So bank and package layouts never change.
""" """
import csv import csv
import gzip import gzip
@@ -157,7 +159,7 @@ class Scan:
self.actions = {} # id -> target id self.actions = {} # id -> target id
self.sound_media = defaultdict(set) # sound object id -> media ids self.sound_media = defaultdict(set) # sound object id -> media ids
self.prefetch = set() # media ids whose bank copies belong to a WAD recording (prefetch self.prefetch = set() # media ids whose bank copies belong to a WAD recording (prefetch
# heads; whole recordings: catalog._whole_recordings) # heads; whole recordings: catalog.whole_recordings)
self.lang_media = defaultdict(set) # language -> media ids named by Sound objects of that language's banks self.lang_media = defaultdict(set) # language -> media ids named by Sound objects of that language's banks
# BKPK tables: WAD entry id -> {language: {media id: (offset in entry, size)}}. A table belongs to the # BKPK tables: WAD entry id -> {language: {media id: (offset in entry, size)}}. A table belongs to the
# language its localized AkBank export declares ('' = outside such an export). The same media ID can be # language its localized AkBank export declares ('' = outside such an export). The same media ID can be

View File

@@ -1,10 +1,10 @@
"""Index of every sound in the game's WAD archives, with names, categories and characters """Index of every sound of the game, with names, categories and characters: the WAD archives (one per voice
(sounds stored inside soundbanks are not indexed yet: docs/formats/banks.md). language, SFX) and the in-memory sounds stored inside soundbanks (archive BANK, docs/formats/banks.md).
Sources, in order: Sources, in order:
1. data/catalog/<ARCHIVE>.csv -- shipped catalog (instant) when the WAD fingerprint matches; 1. data/catalog/<ARCHIVE>.csv -- shipped catalog (instant) when the game's fingerprint matches;
2. otherwise a header scan of the WAD (a few seconds, cached in the user cache), classified with 2. otherwise a scan of the game (WAD headers: seconds; banks: minutes; cached in the user cache), classified
`classify` and labelled with data/catalog/characters.csv (speaker per sound name). with `classify` and labelled with data/catalog/characters.csv (speaker per sound name).
Names come from the game: a WAD entry ID is the Wwise ID (FNV-1) of a soundbank listed in the game's Names come from the game: a WAD entry ID is the Wwise ID (FNV-1) of a soundbank listed in the game's
CookedPCConsole/CookedBanks.txt (`bank_names`); an entry with one WEM is NAME CookedPCConsole/CookedBanks.txt (`bank_names`); an entry with one WEM is NAME
(alias NAME_1), entries with n variations are NAME_1..NAME_n (alias NAME#i); stream-pack media are (alias NAME_1), entries with n variations are NAME_1..NAME_n (alias NAME#i); stream-pack media are
@@ -517,14 +517,16 @@ class Catalog:
def __init__(self, game, archives=None, progress=None): def __init__(self, game, archives=None, progress=None):
self.game = game self.game = game
avail = available_archives(game) avail = available_archives(game)
self.archives = archives or [a for a in (game.language(), "SFX", banks.ARCHIVE) if a in avail] self.archives = archives or [a for a in (game.voice_archive(game.language()), "SFX", banks.ARCHIVE) if a in avail]
self.sounds = [] self.sounds = []
self.locations = {} # BANK media id -> [Location] self.locations = {} # BANK media id -> [Location]
self.heads = {} # WAD sound key -> [Location] of its prefetch heads self.heads = {} # WAD sound key -> [Location] of its prefetch heads
self.bank_sha1 = {} # BANK media id -> original checksum ('' if unknown), every language
bank_sounds_ = [] bank_sounds_ = []
if banks.ARCHIVE in avail: if banks.ARCHIVE in avail:
# always loaded: speech lines may have prefetch heads that must be written with them # always loaded: speech lines may have prefetch heads that must be written with them
bank_sounds_, self.locations = load_banks(game, progress) bank_sounds_, self.locations = load_banks(game, progress)
self.bank_sha1 = {int(s.target, 16): s.sha1 for s in bank_sounds_}
for ls in self.locations.values(): for ls in self.locations.values():
for l in ls: for l in ls:
if l.kind == banks.PREFETCH_HEAD and l.sound: if l.kind == banks.PREFETCH_HEAD and l.sound:

View File

@@ -1,15 +1,17 @@
"""ackit - Batman: Arkham City audio toolkit (command line). """ackit - Batman: Arkham City audio toolkit (command line).
Everyday use, by sound name (see `ackit find`): Everyday use, by sound name (see `ackit find`):
ackit find joker tape search names, categories, characters ackit find joker tape search names, categories, characters
ackit info Speech_TAPE_JOKER_1_4 details, size limit, modified state ackit info Speech_TAPE_JOKER_1_04 details, size limit, modified state
ackit extract Speech_TAPE_JOKER_1_4 orig.wav save the original (or --current) ackit extract Speech_TAPE_JOKER_1_04 orig.wav save the original (or --current)
ackit replace Speech_TAPE_JOKER_1_4 line.wav encode + install a replacement ackit replace Speech_TAPE_JOKER_1_04 line.wav encode + install a replacement
ackit revert Speech_TAPE_JOKER_1_4 put the original back (--all for everything) ackit revert Speech_TAPE_JOKER_1_04 put the original back (--all: every sound and text)
ackit status what you have changed ackit status what you have changed
ackit text find --lang JPN batman subtitles and menu text (any of the 11 languages)
ackit export-mod my.acmod / import-mod my.acmod share your changes ackit export-mod my.acmod / import-mod my.acmod share your changes
The game folder is detected from Steam (Windows, Linux, Proton); override with --game DIR. The game folder is detected from Steam (Windows, Linux, Proton); override with --game DIR. --lang picks the
language (default: the game's); languages without their own voices use the INT voice lines, like the game.
""" """
import argparse import argparse
import os import os
@@ -36,7 +38,7 @@ def _workspace(a, archives=None):
st = settings.load() st = settings.load()
game = _game(a) game = _game(a)
if archives is None and getattr(a, "lang", None): if archives is None and getattr(a, "lang", None):
archives = [a.lang, "SFX", "BANK"] archives = [game.voice_archive(a.lang), "SFX", "BANK"]
return Workspace(game, a.store or st.get("store_root"), archives, return Workspace(game, a.store or st.get("store_root"), archives,
full_backup=not getattr(a, "no_full_backup", False) and st.get("full_backup", True), full_backup=not getattr(a, "no_full_backup", False) and st.get("full_backup", True),
progress=lambda n, t: None) progress=lambda n, t: None)
@@ -73,7 +75,8 @@ def cmd_list(a):
c = a.character.lower() c = a.character.lower()
rows = [s for s in rows if c in s.character.lower() or (a.inferred and c in s.characters_inferred.lower())] rows = [s for s in rows if c in s.character.lower() or (a.inferred and c in s.characters_inferred.lower())]
if a.modified: if a.modified:
rows = [s for s in rows if s.key in ws.modified_keys()] mod = set(ws.modified_keys())
rows = [s for s in rows if s.key in mod]
if a.csv: if a.csv:
from .catalog import write_csv from .catalog import write_csv
write_csv(a.csv, rows) write_csv(a.csv, rows)
@@ -178,6 +181,23 @@ def cmd_revert(a):
print("reverted %d sound(s)" % len(done)) print("reverted %d sound(s)" % len(done))
def cmd_restore_backup(a):
ws = _workspace(a)
full = sorted(ws.store.data.get("full", {}))
if not full:
print("no full copies in this store (%s)" % ws.store.root)
return
if not a.yes:
print("would copy back (or rebuild) %d file(s) from the full copies in %s:" % (len(full), ws.store.root))
for rel in full:
print(" " + rel)
print("Sounds and texts in these files are forgotten; sounds of WADs without a full copy stay recorded.\n"
"Use this only if the originals store is damaged (normally: ackit revert --all). Run again with --yes.")
return
done = ws.restore_from_full_backup(progress=print)
print("restored %d file(s); %d sound(s) still recorded" % (len(done), len(ws.store.entries)))
def cmd_status(a): def cmd_status(a):
ws = _workspace(a) ws = _workspace(a)
keys = ws.modified_keys() keys = ws.modified_keys()
@@ -286,7 +306,7 @@ def cmd_game(a):
from .workspace import default_store_root from .workspace import default_store_root
print("game folder: ", g.root) print("game folder: ", g.root)
print("archives: ", ", ".join("%s=%s" % (k, g.rel(v)) for k, v in g.archives.items())) print("archives: ", ", ".join("%s=%s" % (k, g.rel(v)) for k, v in g.archives.items()))
print("speech language: ", g.language()) print("language: ", "%s (voices: %s)" % (g.language(), g.voice_archive(g.language())))
print("originals store: ", a.store or st.get("store_root") or default_store_root(g)) print("originals store: ", a.store or st.get("store_root") or default_store_root(g))
print("settings file: ", settings.path()) print("settings file: ", settings.path())
@@ -311,7 +331,7 @@ def cmd_verify(a):
ok = selfcheck.run(ws, log=print) ok = selfcheck.run(ws, log=print)
sys.exit(0 if ok else 1) sys.exit(0 if ok else 1)
from .verify import ingame from .verify import ingame
if a.lang and a.lang != ws.game.language(): if a.lang and a.lang != ws.game.language(): # voices and texts follow the game's setting
raise GameError("the game plays its own language (%s); set it in Steam (game properties > Language) to " raise GameError("the game plays its own language (%s); set it in Steam (game properties > Language) to "
"test %s in the game" % (ws.game.language(), a.lang)) "test %s in the game" % (ws.game.language(), a.lang))
if a.banks: if a.banks:
@@ -371,9 +391,12 @@ def build_parser():
s.add_argument("name"); s.add_argument("audio") s.add_argument("name"); s.add_argument("audio")
s = add("apply", cmd_apply, "replace every sound named by the files in a folder", (enc,)) s = add("apply", cmd_apply, "replace every sound named by the files in a folder", (enc,))
s.add_argument("folder") s.add_argument("folder")
s = add("revert", cmd_revert, "put original sounds back", aliases=["restore"]) s = add("revert", cmd_revert, "put original sounds back (--all: every sound and text)", aliases=["restore"])
s.add_argument("names", nargs="*"); s.add_argument("--all", action="store_true") s.add_argument("names", nargs="*"); s.add_argument("--all", action="store_true")
add("status", cmd_status, "list the sounds you modified") add("status", cmd_status, "list the sounds and texts you modified")
s = add("restore-backup", cmd_restore_backup, "last resort if the originals store is damaged: put back the "
"full file copies it keeps (lists them; --yes to do it)")
s.add_argument("--yes", action="store_true")
s = add("text", cmd_text, "subtitles and menu text: find WORDS | show KEY | set KEY TEXT | revert KEY/--all") s = add("text", cmd_text, "subtitles and menu text: find WORDS | show KEY | set KEY TEXT | revert KEY/--all")
s.add_argument("action", choices=["find", "show", "set", "revert"]) s.add_argument("action", choices=["find", "show", "set", "revert"])
s.add_argument("args", nargs="*") s.add_argument("args", nargs="*")

View File

@@ -123,6 +123,14 @@ class Game:
def languages(self): def languages(self):
return [a for a in self.archives if a != "SFX"] return [a for a in self.archives if a != "SFX"]
def voice_archive(self, lang):
"""The speech archive used for a language: its own, or INT for the languages the game has no voices for
(the game plays the INT voices with their own subtitles and menu text)."""
lang = (lang or "INT").upper()
if lang in self.languages:
return lang
return "INT" if "INT" in self.languages else self.languages[0]
def wad(self, archive): def wad(self, archive):
try: try:
return self.archives[archive.upper()] return self.archives[archive.upper()]
@@ -153,8 +161,16 @@ class Game:
dirs.append(os.path.join(pfx, docs, "WB Games", "Batman Arkham City GOTY", "BmGame", "Config")) dirs.append(os.path.join(pfx, docs, "WB Games", "Batman Arkham City GOTY", "BmGame", "Config"))
return [d for d in dirs if os.path.isdir(d)] return [d for d in dirs if os.path.isdir(d)]
def all_languages(self):
"""Every language of the game: the voice languages (speech WADs) and the text languages (folders of
BmGame/Localization; the game plays the INT voices for those without their own)."""
loc = _iglob_one(self.root, "BmGame", "Localization")
texts = [d.upper() for d in os.listdir(loc) if os.path.isdir(os.path.join(loc, d))] if loc else []
return sorted(set(self.languages) | set(texts))
def language(self): def language(self):
"""Speech language the game is set to (falls back to INT, then the first available).""" """The language the game is set to (voices or texts only, e.g. JPN; `voice_archive` gives its speech
archive); falls back to INT, then the first voice language."""
for d in self.config_dirs(): for d in self.config_dirs():
for f in ("UserEngine.ini", "BmEngine.ini"): for f in ("UserEngine.ini", "BmEngine.ini"):
p = os.path.join(d, f) p = os.path.join(d, f)
@@ -163,7 +179,7 @@ class Game:
raw = open(p, "rb").read() raw = open(p, "rb").read()
txt = raw.decode("utf-16") if raw[:2] in (b"\xff\xfe", b"\xfe\xff") else raw.decode("latin-1") txt = raw.decode("utf-16") if raw[:2] in (b"\xff\xfe", b"\xfe\xff") else raw.decode("latin-1")
m = re.search(r"^\s*Language\s*=\s*(\w+)", txt, re.M | re.I) m = re.search(r"^\s*Language\s*=\s*(\w+)", txt, re.M | re.I)
if m and m.group(1).upper() in self.archives: if m and m.group(1).upper() in self.all_languages():
return m.group(1).upper() return m.group(1).upper()
if "INT" in self.archives: if "INT" in self.archives:
return "INT" return "INT"

View File

@@ -64,6 +64,21 @@ def parse(raw, lang, fname):
return out return out
def encoding(raw):
"""Encoding of a file's bytes: 'utf-16', 'utf-8' or 'cp1252'."""
return "utf-16" if raw[:2] == b"\xff\xfe" else "utf-8" if raw[:3] == b"\xef\xbb\xbf" else "cp1252"
def check_value(value, enc, fname):
"""Raise TextError if `value` cannot be written into a file of encoding `enc` (what `write` refuses)."""
if "\r" in value or "\n" in value:
raise TextError("a menu text cannot contain line breaks (use the game's own markers, e.g. \\n)")
try:
_encode(value, enc)
except UnicodeEncodeError:
raise TextError("%s is stored as %s and cannot hold these characters" % (fname, enc)) from None
def write(raw, lang, fname, values): def write(raw, lang, fname, values):
"""New file bytes with {entry key: new value} applied (keys not in the file raise TextError).""" """New file bytes with {entry key: new value} applied (keys not in the file raise TextError)."""
text, enc = _decode(raw) text, enc = _decode(raw)
@@ -73,8 +88,7 @@ def write(raw, lang, fname, values):
e = by_key.get(key) e = by_key.get(key)
if e is None: if e is None:
raise TextError("no text %s in %s" % (key, fname)) raise TextError("no text %s in %s" % (key, fname))
if "\r" in value or "\n" in value: check_value(value, enc, fname)
raise TextError("a menu text cannot contain line breaks (use the game's own markers, e.g. \\n)")
line = lines[e.line] line = lines[e.line]
end = line[len(line.rstrip("\r\n")):] end = line[len(line.rstrip("\r\n")):]
head = line[:line.index("=") + 1] head = line[:line.index("=") + 1]

View File

@@ -41,7 +41,7 @@ def export_mod(ws, path, keys, name="", author="", description="", texts=()):
e = ws.store.get(key) e = ws.store.get(key)
if not e or not e.get("applied"): if not e or not e.get("applied"):
raise ValueError("%s is not modified" % key) raise ValueError("%s is not modified" % key)
s = ws.catalog.by_key.get(key) s = ws.sound_for_key(key) # also a change made in another language (its name and line)
wem = ws.store.get_blob(e["applied"]) wem = ws.store.get_blob(e["applied"])
x, sr = audio.decode_wem(wem) x, sr = audio.decode_wem(wem)
buf = io.BytesIO() buf = io.BytesIO()

View File

@@ -36,12 +36,33 @@ class SubtitleError(ValueError):
pass pass
# Windows-1252 as Windows itself maps it: the 5 bytes Python's cp1252 leaves undefined stand for the same code
# points (U+0081 ...). One line is UTF-8 bytes in a cp1252 FString in 4 languages (Speech_TAPE_J_Answer_1_00,
# docs/formats/subtitles.md); the RUS and KOR ones contain such bytes. Read and written back losslessly, as stored.
_CP1252_UNDEFINED = frozenset((0x81, 0x8D, 0x8F, 0x90, 0x9D))
def _decode_cp1252(raw):
try:
return raw.decode("cp1252")
except UnicodeDecodeError:
return "".join(chr(x) if x in _CP1252_UNDEFINED else bytes([x]).decode("cp1252") for x in raw)
def _encode_cp1252(text):
"""UnicodeEncodeError for a character that is not in Windows-1252."""
try:
return text.encode("cp1252")
except UnicodeEncodeError:
return b"".join(bytes([ord(c)]) if ord(c) in _CP1252_UNDEFINED else c.encode("cp1252") for c in text)
def read_fstring(b, o): def read_fstring(b, o):
"""-> (text, end offset, enc)""" """-> (text, end offset, enc)"""
n = struct.unpack_from("<i", b, o)[0] n = struct.unpack_from("<i", b, o)[0]
if n >= 0: if n >= 0:
raw = bytes(b[o + 4:o + 4 + n]) raw = bytes(b[o + 4:o + 4 + n])
return raw.split(b"\0", 1)[0].decode("cp1252", "replace"), o + 4 + n, "a" return _decode_cp1252(raw.split(b"\0", 1)[0]), o + 4 + n, "a"
raw = bytes(b[o + 4:o + 4 - 2 * n]) raw = bytes(b[o + 4:o + 4 - 2 * n])
return raw.decode("utf-16-le", "replace").split("\0", 1)[0], o + 4 - 2 * n, "w" return raw.decode("utf-16-le", "replace").split("\0", 1)[0], o + 4 - 2 * n, "w"
@@ -53,7 +74,7 @@ def encode_fstring(text, size, enc):
raise SubtitleError("the text must not contain NUL characters") raise SubtitleError("the text must not contain NUL characters")
if enc == "a": if enc == "a":
try: try:
raw = text.encode("cp1252") raw = _encode_cp1252(text)
except UnicodeEncodeError: except UnicodeEncodeError:
raise SubtitleError("this subtitle is stored as Western European (Windows-1252) text; '%s' cannot be " raise SubtitleError("this subtitle is stored as Western European (Windows-1252) text; '%s' cannot be "
"written" "written"
@@ -72,7 +93,7 @@ def encode_fstring(text, size, enc):
def _cp1252(c): def _cp1252(c):
try: try:
c.encode("cp1252") _encode_cp1252(c)
return True return True
except UnicodeEncodeError: except UnicodeEncodeError:
return False return False

View File

@@ -5,31 +5,45 @@ Keys (also used in mod packages):
MENU:<LANG>:<File>:<Section>:<Key>[#n] a menu/UI text (docs/formats/text.md) MENU:<LANG>:<File>:<Section>:<Key>[#n] a menu/UI text (docs/formats/text.md)
Records live in the originals store (data["texts"]): {key: {"orig", "applied", "files", ...}}. The files Records live in the originals store (data["texts"]): {key: {"orig", "applied", "files", ...}}. The files
holding the texts are derived files of the Workspace: each is rebuilt from its original copy plus every edit holding the texts are derived files of the Workspace: each is rebuilt from its original copy plus every edit
applied now (sounds and texts), so reverting the last edit restores it byte-for-byte. applied now (sounds and texts), so reverting the last edit restores it byte-for-byte. Writes hold the
workspace's lock (Workspace.lock).
""" """
import os import os
import time import time
from . import loctext, subtitles from . import banks, loctext, subtitles
from .catalog import load_subtitles from .catalog import _shipped_index, catalog_dir, load_subtitles
from .game import GameError from .game import GameError
ORIGINAL, MODIFIED, LOST, CHANGED, FOREIGN = "original", "modified", "lost", "changed", "foreign" ORIGINAL, MODIFIED, LOST, CHANGED, FOREIGN = "original", "modified", "lost", "changed", "foreign"
def _locked(f):
"""Run a Texts method holding the workspace's write lock."""
def run(self, *a, **kw):
with self.ws.lock:
return f(self, *a, **kw)
run.__name__, run.__doc__ = f.__name__, f.__doc__
return run
class TextConflictError(GameError): class TextConflictError(GameError):
def __init__(self, keys): def __init__(self, keys):
self.keys = keys self.keys = keys
super().__init__("These texts were changed outside this tool (Steam update or 'verify', or another mod " super().__init__("These texts were changed outside this tool (Steam update or 'verify', or another mod "
"tool): %s.\nRevert them or confirm to overwrite." % ", ".join(keys[:5]) "tool): %s.\nRevert them or confirm to overwrite."
+ (" ..." if len(keys) > 5 else "")) % (", ".join(keys[:5]) + (" ..." if len(keys) > 5 else "")))
class Texts: class Texts:
def __init__(self, ws): def __init__(self, ws):
self.ws = ws self.ws = ws
self._subs = {} # lang -> ([SubLine], {key: [SubLoc]}) self._subs = {} # lang -> ([SubLine], {key: [SubLoc]})
self._sub_text = {} # lang -> {(speech, line): original text} (catalog)
self._menu = {} # lang -> [Entry] self._menu = {} # lang -> [Entry]
self._menu_paths = {} # lang -> {file name: path}
self._menu_file = {} # path -> ((size, mtime), encoding, {entry key: Entry})
self._shipped_subs = None # (by file: [SubLoc], {(lang, speech, line): text}) of the shipped catalog
self.loc_root = _find_dir(ws.game.root, "BmGame", "Localization") self.loc_root = _find_dir(ws.game.root, "BmGame", "Localization")
# --- languages and lists --------------------------------------------------------------------- # --- languages and lists ---------------------------------------------------------------------
@@ -37,7 +51,6 @@ class Texts:
return loctext.languages(self.loc_root) if self.loc_root else [] return loctext.languages(self.loc_root) if self.loc_root else []
def subtitle_languages(self): def subtitle_languages(self):
from .catalog import _shipped_index
langs = sorted(_shipped_index().get("SUB", {}).get("lines", {})) langs = sorted(_shipped_index().get("SUB", {}).get("lines", {}))
return langs or self.menu_languages() return langs or self.menu_languages()
@@ -54,8 +67,11 @@ class Texts:
def for_sound(self, sound, lang=None): def for_sound(self, sound, lang=None):
"""Subtitle lines of a speech sound, in `lang` (default: the sound's own language). Languages without """Subtitle lines of a speech sound, in `lang` (default: the sound's own language). Languages without
their own voices link their subtitles to the INT sounds, so e.g. JPN subtitles are found for INT lines.""" their own voices link their subtitles to the INT sounds, so e.g. JPN subtitles are found for INT lines.
lang = lang or (sound.languages[0] if sound.languages else sound.archive) A sound shared by every language (an in-memory line stored once) defaults to the game's language."""
game = self.ws.game
lang = lang or (sound.languages[0] if sound.languages else
sound.archive if sound.archive in game.languages else game.language())
if lang not in self.subtitle_languages(): if lang not in self.subtitle_languages():
return [] return []
return [s for s in self.subtitles(lang) if sound.key in s.sounds] return [s for s in self.subtitles(lang) if sound.key in s.sounds]
@@ -77,20 +93,44 @@ class Texts:
def modified(self): def modified(self):
return [k for k, r in self.records().items() if r.get("applied") is not None] return [k for k, r in self.records().items() if r.get("applied") is not None]
def _menu_path(self, key):
"""Path of the file holding menu text `key` (MENU:<LANG>:<File>:...)."""
parts = key.split(":", 3)
if len(parts) < 4 or not self.loc_root:
raise GameError("no menu text %s" % key[len("MENU:"):])
lang = parts[1]
if lang not in self._menu_paths:
self._menu_paths[lang] = loctext.files(self.loc_root, lang)
path = self._menu_paths[lang].get(parts[2])
if not path:
raise GameError("no menu text %s" % key[len("MENU:"):])
return path
def _menu_entries(self, path, lang):
"""(encoding, {entry key: Entry}) of one menu file as it is now (parsed again only when it changed)."""
st = os.stat(path)
stamp = (st.st_size, st.st_mtime_ns)
hit = self._menu_file.get(path)
if not hit or hit[0] != stamp:
raw = open(path, "rb").read()
hit = (stamp, loctext.encoding(raw),
{e.key: e for e in loctext.parse(raw, lang.upper(), os.path.basename(path))})
self._menu_file[path] = hit
return hit[1], hit[2]
def _menu_entry(self, key): def _menu_entry(self, key):
lang = key.split(":")[1] path = self._menu_path(key)
raw = key[len("MENU:"):] e = self._menu_entries(path, key.split(":")[1])[1].get(key[len("MENU:"):])
for e in loctext.load(self.loc_root, lang): if e is None:
if e.key == raw: raise GameError("no menu text %s" % key[len("MENU:"):])
return e return e
raise GameError("no menu text %s" % raw)
def files(self, key): def files(self, key):
"""Absolute paths of the files holding a text.""" """Absolute paths of the files holding a text."""
if key.startswith("SUB:"): if key.startswith("SUB:"):
return sorted({self.ws.bank_path(l.file) for l in self.subtitle_locations(key)}) return sorted({self.ws.bank_path(l.file) for l in self.subtitle_locations(key)})
e = self._menu_entry(key) self._menu_entry(key)
return [loctext.files(self.loc_root, e.lang)[e.file]] return [self._menu_path(key)]
def current(self, key): def current(self, key):
"""The text in the game files now.""" """The text in the game files now."""
@@ -98,22 +138,20 @@ class Texts:
locs = self.subtitle_locations(key) locs = self.subtitle_locations(key)
if not locs: if not locs:
raise GameError("no subtitle %s" % key) raise GameError("no subtitle %s" % key)
from .banks import open_file
l = locs[0] l = locs[0]
b = open_file(self.ws.bank_path(l.file)).read(l.pos, l.size) b = banks.open_file(self.ws.bank_path(l.file)).read(l.pos, l.size)
return subtitles.read_fstring(b, 0)[0] return subtitles.read_fstring(b, 0)[0]
return self._menu_entry(key).value return self._menu_entry(key).value
def catalog_text(self, key): def catalog_text(self, key):
"""Original text according to the shipped catalog (subtitles only), or None.""" """Original text according to the catalog (subtitles only), or None."""
if not key.startswith("SUB:"): if not key.startswith("SUB:"):
return None return None
_, lang, rest = key.split(":", 2) _, lang, rest = key.split(":", 2)
speech, line = rest.rsplit(":", 1) speech, line = rest.rsplit(":", 1)
for s in self.subtitles(lang): if lang not in self._sub_text:
if s.speech == speech and s.line == int(line): self._sub_text[lang] = {(s.speech, s.line): s.text for s in self.subtitles(lang)}
return s.text return self._sub_text[lang].get((speech, int(line)))
return None
def original(self, key): def original(self, key):
r = self.records().get(key) r = self.records().get(key)
@@ -141,24 +179,54 @@ class Texts:
for l in self.subtitle_locations(key)), default=0) for l in self.subtitle_locations(key)), default=0)
def check(self, key, text): def check(self, key, text):
"""Raise SubtitleError / TextError if `text` cannot be written for `key`.""" """Raise SubtitleError / TextError (ValueError) if `text` cannot be written for `key`, GameError if
there is no such text."""
if key.startswith("SUB:"): if key.startswith("SUB:"):
for l in self.subtitle_locations(key): for l in self.subtitle_locations(key):
subtitles.encode_fstring(text, l.size, l.enc) subtitles.encode_fstring(text, l.size, l.enc)
else: else:
e = self._menu_entry(key) path = self._menu_path(key)
path = loctext.files(self.loc_root, e.lang)[e.file] self._menu_entry(key)
loctext.write(open(path, "rb").read(), e.lang, e.file, {e.key: text}) loctext.check_value(text, self._menu_entries(path, key.split(":")[1])[0], os.path.basename(path))
def unoriginal_subtitles(self, rel):
"""Subtitle strings in package `rel` (cooked-relative) that differ from the shipped catalog: [SUB key]
(none if the catalog is not the game's)."""
if self._shipped_subs is None:
self._shipped_subs = ({}, {})
shipped = _shipped_index().get("SUB", {})
loc = os.path.join(catalog_dir(), subtitles.LOCATIONS)
if shipped.get("fingerprint") == banks.game_fingerprint(self.ws.game.cooked) and os.path.exists(loc):
by_file, texts = self._shipped_subs
for ls in subtitles.read_locations(loc).values():
for l in ls:
by_file.setdefault(l.file, []).append(l)
for lang in shipped.get("lines", {}):
p = os.path.join(catalog_dir(), "SUB_%s.csv" % lang)
if os.path.exists(p):
texts.update({(lang, s.speech, s.line): s.text for s in subtitles.read_texts(p, lang)})
by_file, texts = self._shipped_subs
out = []
locs = sorted(by_file.get(rel, []), key=lambda l: l.pos)
if locs:
r = banks.open_file(self.ws.bank_path(rel))
for l in locs:
want = texts.get((l.lang, l.speech, l.line))
if want is not None and subtitles.read_fstring(r.read(l.pos, l.size), 0)[0] != want:
out.append(subtitles.key(l.lang, l.speech, l.line))
return out
# --- writing -------------------------------------------------------------------------------- # --- writing --------------------------------------------------------------------------------
@_locked
def apply(self, changes, force=False, progress=None, meta=None): def apply(self, changes, force=False, progress=None, meta=None):
"""changes: {key: new text}. Every file holding one of them is rebuilt once.""" """changes: {key: new text}. Every file holding one of them is rebuilt once."""
for k, v in changes.items(): for k, v in changes.items():
self.check(k, v) self.check(k, v)
conflicts = [k for k in changes if self.state(k) in (CHANGED, FOREIGN)] conflicts = [k for k in changes if self.state(k) in (CHANGED, FOREIGN)]
paths = {p for k in changes for p in self.files(k)} files = {k: self.files(k) for k in changes}
changed = set(self.ws.changed_files(paths)) paths = {p for ps in files.values() for p in ps}
conflicts += [k for k in changes if k not in conflicts and set(self.files(k)) & changed] changed = set(self.ws.changed_files(paths)) | set(self.ws.unoriginal_files(paths))
conflicts += [k for k in changes if k not in conflicts and set(files[k]) & changed]
if conflicts and not force: if conflicts and not force:
raise TextConflictError(conflicts) raise TextConflictError(conflicts)
for p in paths: for p in paths:
@@ -167,12 +235,13 @@ class Texts:
now = time.time() now = time.time()
for k, v in changes.items(): for k, v in changes.items():
if k not in recs: if k not in recs:
recs[k] = {"orig": self.current(k), "files": [self.ws.game.rel(p) for p in self.files(k)], recs[k] = {"orig": self.current(k), "files": [self.ws.game.rel(p) for p in files[k]],
"first_modified": now, "applied": None} "first_modified": now, "applied": None}
recs[k].update(applied=v, applied_at=now, meta=meta or {}) recs[k].update(applied=v, applied_at=now, meta=meta or {})
self.ws.store.save() self.ws.store.save()
self._sync(paths, progress) self._sync(paths, progress)
@_locked
def revert(self, keys, progress=None): def revert(self, keys, progress=None):
recs = self.records() recs = self.records()
keys = [k for k in keys if k in recs] keys = [k for k in keys if k in recs]

View File

@@ -8,6 +8,7 @@ Patching rewrites only the blocks that contain changed bytes (stored as one LZO
lzo.compress_stored) and moves the chunks after them; every other byte of the file stays identical. lzo.compress_stored) and moves the chunks after them; every other byte of the file stays identical.
""" """
import struct import struct
import threading
from collections import OrderedDict, namedtuple from collections import OrderedDict, namedtuple
from . import lzo from . import lzo
@@ -41,6 +42,7 @@ class Package:
self.file_size = f.seek(0, 2) self.file_size = f.seek(0, 2)
self._cache = OrderedDict() self._cache = OrderedDict()
self._cache_n = cache_blocks self._cache_n = cache_blocks
self._lock = threading.Lock()
# summary ----------------------------------------------------------------------------------- # summary -----------------------------------------------------------------------------------
def _parse_summary(self, b, f): def _parse_summary(self, b, f):
@@ -157,9 +159,11 @@ class Package:
# reading ----------------------------------------------------------------------------------- # reading -----------------------------------------------------------------------------------
def _block(self, i, f=None): def _block(self, i, f=None):
if i in self._cache: with self._lock: # the editor reads one package from several background jobs
self._cache.move_to_end(i) data = self._cache.get(i)
return self._cache[i] if data is not None:
self._cache.move_to_end(i)
return data
bl = self.blocks[i] bl = self.blocks[i]
own = f is None own = f is None
f = f or open(self.path, "rb") f = f or open(self.path, "rb")
@@ -169,9 +173,10 @@ class Package:
finally: finally:
if own: if own:
f.close() f.close()
self._cache[i] = data with self._lock:
if len(self._cache) > self._cache_n: self._cache[i] = data
self._cache.popitem(last=False) if len(self._cache) > self._cache_n:
self._cache.popitem(last=False)
return data return data
def _block_index(self, off): def _block_index(self, off):

View File

@@ -399,8 +399,9 @@ def check_speakers(rep, rows, col):
def check_menu(rep, game): def check_menu(rep, game):
"""Menu texts: the same keys in every language (facts; differences are the game's own).""" """Menu texts: the same keys in every language (facts; differences are the game's own)."""
from .. import loctext from .. import loctext
root = os.path.join(game.root, "BmGame", "Localization") from ..texts import _find_dir
if not os.path.isdir(root): root = _find_dir(game.root, "BmGame", "Localization") # case-insensitive (Linux)
if not root:
return return
keys = {} keys = {}
for lang in loctext.languages(root): for lang in loctext.languages(root):
@@ -443,7 +444,7 @@ def run(game, archives=None, quick=False, log=print, progress=None):
check_subtitle_lines(rep, rows_all) check_subtitle_lines(rep, rows_all)
check_speakers(rep, rows_all, col) check_speakers(rep, rows_all, col)
check_menu(rep, game) check_menu(rep, game)
characters = load_characters(lang=game.language()) characters = load_characters(lang=game.voice_archive(game.language()))
known = {n.lower() for s in cat.sounds for n in s.names()} known = {n.lower() for s in cat.sounds for n in s.names()}
# speaker names are the same in every language; only meaningful for the build the lists were made for # speaker names are the same in every language; only meaningful for the build the lists were made for
if any(a in game.languages and is_shipped(game, a) for a in archives): if any(a in game.languages and is_shipped(game, a) for a in archives):

View File

@@ -104,9 +104,14 @@ def running_game_pids():
return [int(m.group(1)) for m in re.finditer(r'"BatmanAC\.exe","(\d+)"', out)] return [int(m.group(1)) for m in re.finditer(r'"BatmanAC\.exe","(\d+)"', out)]
def kill_game(): def kill_game(timeout=20):
"""Kill the game and wait until it has exited: it keeps the WADs and packages open until then, so a revert
right after taskkill would find them locked."""
for pid in running_game_pids(): for pid in running_game_pids():
subprocess.run(["taskkill", "/F", "/PID", str(pid)], capture_output=True) subprocess.run(["taskkill", "/F", "/PID", str(pid)], capture_output=True)
t0 = time.time()
while running_game_pids() and time.time() - t0 < timeout:
time.sleep(0.25)
def launch_game(exe, extra_args=()): def launch_game(exe, extra_args=()):

View File

@@ -65,7 +65,9 @@ def run(ws, sample=300, log=print):
if n >= sample // max(1, len(ws.game.archives)): if n >= sample // max(1, len(ws.game.archives)):
break break
log("%-4s layout %s, rebuilt %d/%d sample WEMs byte-identical" % (arch, "OK" if not probs else probs[:2], good, n)) log("%-4s layout %s, rebuilt %d/%d sample WEMs byte-identical" % (arch, "OK" if not probs else probs[:2], good, n))
ok &= not probs and good >= n - 2 # 4 looping music tracks are known exceptions # known exceptions (docs/formats/wem.md): a few SFX WEMs (music seek tables and sample counts, 4-channel
# ambience with a loop chunk) and one INT speech WEM (avg-bytes/s field) are not reproduced byte-for-byte
ok &= not probs and good >= n - 2
bank = [s for s in ws.catalog.sounds if s.archive == "BANK"] bank = [s for s in ws.catalog.sounds if s.archive == "BANK"]
if bank: if bank:
n = good = 0 n = good = 0

View File

@@ -10,14 +10,19 @@ In-memory sounds (archive BANK) and the prefetch heads of speech lines live in l
packages (ackit/banks.py); a replacement fills each in-memory slot exactly (wem.fill). Before the first packages (ackit/banks.py); a replacement fills each in-memory slot exactly (wem.fill). Before the first
change to such a file a verified full copy is kept, and the change to such a file a verified full copy is kept, and the
file is always rebuilt as "original copy + every replacement currently applied", so reverting the last file is always rebuilt as "original copy + every replacement currently applied", so reverting the last
change restores it byte-for-byte and any later operation repairs an interrupted one. change restores it byte-for-byte and any later operation repairs an interrupted one. That copy is taken only
from a file that is original according to the catalog (`unoriginal_files`), else the write is a conflict.
Writes are serialized (`Workspace.lock`): the editor runs them as background jobs, and two rebuilds of one
file at the same time would lose one of the edits.
""" """
import functools
import hashlib import hashlib
import os import os
import threading
from collections import defaultdict from collections import defaultdict
from . import audio, banks from . import audio, banks
from .catalog import Catalog from .catalog import Catalog, available_archives, is_shipped, load_archive
from .encoder import EncodeError, encode_fit from .encoder import EncodeError, encode_fit
from .game import Game, GameError from .game import Game, GameError
from .inject import inject, read_target, parse_target, pack_bnk_path, SizeLimitError from .inject import inject, read_target, parse_target, pack_bnk_path, SizeLimitError
@@ -41,25 +46,50 @@ class ConflictError(GameError):
self.sounds = sounds self.sounds = sounds
super().__init__("These sounds were changed outside this tool (Steam update or 'verify', or " super().__init__("These sounds were changed outside this tool (Steam update or 'verify', or "
"another mod tool): %s.\nRevert them or confirm to overwrite." "another mod tool): %s.\nRevert them or confirm to overwrite."
% ", ".join(s.name for s in sounds[:5]) + (" ..." if len(sounds) > 5 else "")) % (", ".join(s.name for s in sounds[:5]) + (" ..." if len(sounds) > 5 else "")))
def default_store_root(game): def default_store_root(game):
return os.path.join(user_dir("data"), "stores", game.store_id) return os.path.join(user_dir("data"), "stores", game.store_id)
def locked(f):
"""Run a Workspace method holding its write lock (Texts has the same wrapper: texts._locked)."""
@functools.wraps(f)
def run(self, *a, **kw):
with self.lock:
return f(self, *a, **kw)
return run
class Workspace: class Workspace:
def __init__(self, game_dir, store_root=None, archives=None, full_backup=True, progress=None): def __init__(self, game_dir, store_root=None, archives=None, full_backup=True, progress=None):
self.game = game_dir if isinstance(game_dir, Game) else Game(game_dir) self.game = game_dir if isinstance(game_dir, Game) else Game(game_dir)
self.store = OriginalsStore(store_root or default_store_root(self.game), self.game.root) self.store = OriginalsStore(store_root or default_store_root(self.game), self.game.root)
self.catalog = Catalog(self.game, archives, progress) self.catalog = Catalog(self.game, archives, progress)
self.full_backup = full_backup self.full_backup = full_backup
self.lock = threading.RLock() # one write (apply, revert, file rebuild) at a time
self._by_file = None # cooked-relative file -> [Location] (unoriginal_files)
self._shipped = None # the bank catalog is the shipped one (has checksums)
self._wad_rows = {} # voice archive -> {key: Sound} (heads' recordings)
self._other = {} # archive not loaded here -> Catalog (sound_for_key)
self.texts = Texts(self) self.texts = Texts(self)
# reading ----------------------------------------------------------------------------------- # reading -----------------------------------------------------------------------------------
def sound(self, name_or_key): def sound(self, name_or_key):
return self.catalog.resolve(name_or_key) return self.catalog.resolve(name_or_key)
def sound_for_key(self, key):
"""The Sound of a key, also from an archive not loaded here (a change made in another language), or
None."""
if key in self.catalog.by_key:
return self.catalog.by_key[key]
archive = key.split(":")[0]
if archive not in self._other:
self._other[archive] = Catalog(self.game, [archive]) if archive in available_archives(self.game) \
else None
return self._other[archive].by_key.get(key) if self._other[archive] else None
def wad_path(self, s): def wad_path(self, s):
return self.game.wad(s.archive) return self.game.wad(s.archive)
@@ -241,6 +271,55 @@ class Workspace:
out.append(path) out.append(path)
return out return out
def unoriginal_files(self, paths):
"""Bank files and packages this store has never written that are not the game's original according to
the shipped catalog: an in-memory sound, the bank copy of a voice recording or a subtitle in them differs
(another originals store or another tool changed them). Their current content must not become this
store's "original copy". Menu files and other game builds have no catalog checksums: not checked."""
out = []
for path in sorted(set(paths)):
if self._derived().get(self.game.rel(path)) or self.texts.is_menu_file(path):
continue
rel = os.path.relpath(path, self.game.cooked).replace(os.sep, "/")
if not self._bank_copies_original(rel) or self.texts.unoriginal_subtitles(rel):
out.append(path)
return out
def _bank_copies_original(self, rel):
if self._by_file is None:
self._by_file = defaultdict(list)
for ls in self.catalog.locations.values():
for l in ls:
self._by_file[l.file].append(l)
locs = sorted(self._by_file.get(rel, []), key=lambda l: l.offset)
if self._shipped is None:
self._shipped = is_shipped(self.game, banks.ARCHIVE)
if not locs or not self._shipped:
return True
r = banks.open_file(self.bank_path(rel))
for l in locs:
cur = r.read(l.offset, l.size)
if l.kind == banks.MEMORY:
want = self.catalog.bank_sha1.get(l.media)
if want and sha1_16(cur) != want:
return False
elif l.sound: # a bank copy of a WAD recording: the start of the original
orig = self._wad_original(l.sound)
if orig is None or cur != (orig + bytes(l.size))[:l.size]:
return False
return True
def _wad_original(self, key):
"""Original bytes of a WAD recording (this store's record, or the game's when it matches the catalog)."""
if self.store.get(key):
return self.store.original(key)
archive, _, target = key.partition(":")
if archive not in self._wad_rows:
self._wad_rows[archive] = {s.key: s for s in load_archive(self.game, archive)}
s = self._wad_rows[archive].get(key)
cur = read_target(self.game.wad(archive), target)
return None if s and s.sha1 and sha1_16(cur) != s.sha1 else cur
def _audio_edits(self, rel): def _audio_edits(self, rel):
"""[(offset, bytes)] that the sound replacements applied now need in bank file `rel` (cooked-relative).""" """[(offset, bytes)] that the sound replacements applied now need in bank file `rel` (cooked-relative)."""
out = [] out = []
@@ -271,6 +350,7 @@ class Workspace:
edits = self._audio_edits(rel) + self.texts.subtitle_edits(rel) edits = self._audio_edits(rel) + self.texts.subtitle_edits(rel)
return banks.open_file(orig).patched(edits) if edits else open(orig, "rb").read() return banks.open_file(orig).patched(edits) if edits else open(orig, "rb").read()
@locked
def sync_files(self, paths, progress=None): def sync_files(self, paths, progress=None):
"""Rebuild each derived file from its original copy and the edits applied now (atomic writes).""" """Rebuild each derived file from its original copy and the edits applied now (atomic writes)."""
for path in sorted(set(paths)): for path in sorted(set(paths)):
@@ -310,6 +390,7 @@ class Workspace:
self.store.save() # the store says what the files must contain before they change self.store.save() # the store says what the files must contain before they change
self.sync_files(files, progress) self.sync_files(files, progress)
@locked
def apply(self, changes, force=False, progress=None): def apply(self, changes, force=False, progress=None):
"""changes: [(sound, wem_bytes, meta dict)]. One atomic rewrite per WAD / bank file. A recording stored """changes: [(sound, wem_bytes, meta dict)]. One atomic rewrite per WAD / bank file. A recording stored
in two places of a WAD gets the same bytes in both (Workspace.copies).""" in two places of a WAD gets the same bytes in both (Workspace.copies)."""
@@ -326,7 +407,8 @@ class Workspace:
for s, _, _ in changes: for s, _, _ in changes:
if self.state(s) in (CHANGED, FOREIGN): if self.state(s) in (CHANGED, FOREIGN):
conflicts.append(s) conflicts.append(s)
changed = set(self.changed_files([self.bank_path(l.file) for s, _, _ in changes for l in self.locations(s)])) paths = [self.bank_path(l.file) for s, _, _ in changes for l in self.locations(s)]
changed = set(self.changed_files(paths)) | set(self.unoriginal_files(paths))
conflicts += [s for s, _, _ in changes conflicts += [s for s, _, _ in changes
if s not in conflicts and {self.bank_path(l.file) for l in self.locations(s)} & changed] if s not in conflicts and {self.bank_path(l.file) for l in self.locations(s)} & changed]
if conflicts and not force: if conflicts and not force:
@@ -337,6 +419,7 @@ class Workspace:
for a, items in by_arch.items(): for a, items in by_arch.items():
self._write(a, items, progress) self._write(a, items, progress)
@locked
def revert(self, sounds, progress=None): def revert(self, sounds, progress=None):
"""Put the stored originals back (sounds without a record are skipped). Returns reverted.""" """Put the stored originals back (sounds without a record are skipped). Returns reverted."""
by_arch = defaultdict(list) by_arch = defaultdict(list)
@@ -354,31 +437,50 @@ class Workspace:
self.store.gc() self.store.gc()
return done return done
@locked
def revert_all(self, progress=None): def revert_all(self, progress=None):
sounds = [self.catalog.by_key[k] for k in list(self.store.entries) if k in self.catalog.by_key] """Revert every sound this store has changed (texts: Texts.revert_all)."""
missing = [k for k in self.store.entries if k not in self.catalog.by_key] # records of archives not loaded in this catalog (e.g. another language) included
if missing: sounds = [s for s in map(self.sound_for_key, list(self.store.entries)) if s]
# records of archives not loaded in this catalog (e.g. another language)
other = Catalog(self.game, sorted({k.split(":")[0] for k in missing}))
sounds += [other.by_key[k] for k in missing if k in other.by_key]
return self.revert(sounds, progress) return self.revert(sounds, progress)
def restore_from_full_backup(self): @locked
"""Last-resort fallback: copy back every full backup taken by this store.""" def forget(self, keys=(), text_keys=()):
"""Stop tracking changes (the game files are kept as they are)."""
for k in keys:
self.store.forget(k)
for k in text_keys:
self.texts.records().pop(k, None)
self.store.save()
self.store.gc()
@locked
def restore_from_full_backup(self, progress=None):
"""Last resort when the originals store is damaged (`ackit restore-backup`): put back the full copies.
A WAD (and its pack indexes) is copied back and its sounds are forgotten; bank files, packages and menu
files (always copied) are rebuilt from their copies with the edits of the sounds still recorded, i.e.
those of WADs without a full copy (full backups off), which stay modified and revertible. Returns the
restored files (game-relative)."""
import shutil
full = self.store.data.get("full", {})
derived = self._derived()
out = [] out = []
for rel in self.store.data.get("full", {}): for rel in full:
src = self.store.full_copy(rel) if rel in derived:
continue
dst = os.path.join(self.game.root, rel) dst = os.path.join(self.game.root, rel)
self.game.check_writable(dst) self.game.check_writable(dst)
tmp = dst + ".ackit-tmp" tmp = dst + ".ackit-tmp"
import shutil shutil.copy2(self.store.full_copy(rel), tmp)
shutil.copy2(src, tmp)
os.replace(tmp, dst) os.replace(tmp, dst)
out.append(rel) out.append(rel)
for rec in self._derived().values(): entries = self.store.data["entries"]
rec["written_fp"] = None for k, e in list(entries.items()):
if e["archive"] == banks.ARCHIVE or e.get("wad") in full:
del entries[k]
self.store.data["texts"] = {} self.store.data["texts"] = {}
self.store.data["entries"] = {}
self.store.save() self.store.save()
paths = [os.path.join(self.game.root, rel) for rel in derived if rel in full]
self.sync_files(paths, progress)
self.store.gc() self.store.gc()
return out return sorted(out + [rel for rel in derived if rel in full])

View File

@@ -163,7 +163,8 @@ class Recorder(QObject):
self.chunks = [] self.chunks = []
self.rate = 48000 self.rate = 48000
self.channels = 1 self.channels = 1
self.fmt_float = False self.sample = "<i2" # numpy type of the device's samples
self._rest = b"" # bytes of an incomplete frame, completed by the next read
self._sim = None self._sim = None
def set_device(self, device): def set_device(self, device):
@@ -173,7 +174,7 @@ class Recorder(QObject):
return self.source is not None or self._sim is not None return self.source is not None or self._sim is not None
def start(self): def start(self):
self.chunks = [] self.chunks, self._rest = [], b""
dev = self.device if (self.device is not None and not self.device.isNull()) else ( dev = self.device if (self.device is not None and not self.device.isNull()) else (
QMediaDevices.defaultAudioInput() if audio_enabled() else None) QMediaDevices.defaultAudioInput() if audio_enabled() else None)
if dev is None or dev.isNull(): if dev is None or dev.isNull():
@@ -186,7 +187,8 @@ class Recorder(QObject):
if not dev.isFormatSupported(fmt): if not dev.isFormatSupported(fmt):
fmt = dev.preferredFormat() fmt = dev.preferredFormat()
self.rate, self.channels = fmt.sampleRate(), fmt.channelCount() self.rate, self.channels = fmt.sampleRate(), fmt.channelCount()
self.fmt_float = fmt.sampleFormat() == QAudioFormat.SampleFormat.Float F = QAudioFormat.SampleFormat
self.sample = {F.Float: "<f4", F.Int32: "<i4", F.UInt8: "u1"}.get(fmt.sampleFormat(), "<i2")
self.source = QAudioSource(dev, fmt, self) self.source = QAudioSource(dev, fmt, self)
self.io = self.source.start() self.io = self.source.start()
if self.io is None: if self.io is None:
@@ -202,13 +204,20 @@ class Recorder(QObject):
self._ingest(bytes(self.io.readAll())) self._ingest(bytes(self.io.readAll()))
def _ingest(self, raw): def _ingest(self, raw):
raw = self._rest + raw
frame = np.dtype(self.sample).itemsize * self.channels
n = len(raw) // frame * frame
raw, self._rest = raw[:n], raw[n:]
if not raw: if not raw:
return return
if self.fmt_float: x = np.frombuffer(raw, dtype=self.sample)
x = np.frombuffer(raw, dtype="<f4") if self.sample == "<i2":
else: x = x.astype(np.float32) / 32768.0
x = np.frombuffer(raw[: len(raw) // 2 * 2], dtype="<i2").astype(np.float32) / 32768.0 elif self.sample == "<i4":
x = x[: len(x) // self.channels * self.channels].reshape(-1, self.channels) x = (x / 2147483648.0).astype(np.float32)
elif self.sample == "u1":
x = (x.astype(np.float32) - 128.0) / 128.0
x = x.reshape(-1, self.channels)
self.chunks.append(x) self.chunks.append(x)
self.level.emit(float(np.max(np.abs(x))) if len(x) else 0.0) self.level.emit(float(np.max(np.abs(x))) if len(x) else 0.0)
self.elapsed.emit(sum(len(c) for c in self.chunks) / float(self.rate)) self.elapsed.emit(sum(len(c) for c in self.chunks) / float(self.rate))
@@ -216,8 +225,8 @@ class Recorder(QObject):
# simulated input (tests / no microphone) ---------------------------------------------------- # simulated input (tests / no microphone) ----------------------------------------------------
def start_simulated(self, x, rate, realtime=True): def start_simulated(self, x, rate, realtime=True):
"""Feed `x` as if it came from a microphone (20 ms blocks).""" """Feed `x` as if it came from a microphone (20 ms blocks)."""
self.chunks = [] self.chunks, self._rest = [], b""
self.rate, self.channels, self.fmt_float = rate, x.shape[1], True self.rate, self.channels, self.sample = rate, x.shape[1], "<f4"
blocks = [x[i:i + rate // 50] for i in range(0, len(x), rate // 50)] blocks = [x[i:i + rate // 50] for i in range(0, len(x), rate // 50)]
if not realtime: if not realtime:
for b in blocks: for b in blocks:

View File

@@ -100,7 +100,9 @@ class EditorPanel(QWidget):
gl.setVerticalSpacing(4) gl.setVerticalSpacing(4)
self.wf_orig = self._wave("orig") self.wf_orig = self._wave("orig")
self.wf_inst = self._wave("inst") self.wf_inst = self._wave("inst")
self.b_play_orig = self._btn("Original", "play", lambda: self.play("orig"), "Play the original (Space)", icons.STEEL) self.b_play_orig = self._btn("Original", "play", lambda: self.play("orig"),
"Play the original (double-click the row; Space until a replacement is loaded)",
icons.STEEL)
self.b_play_inst = self._btn("Installed", "play", lambda: self.play("inst"), "Play what the game has now", "#8f4a4e") self.b_play_inst = self._btn("Installed", "play", lambda: self.play("inst"), "Play what the game has now", "#8f4a4e")
self.b_stop = self._btn("Stop", "stop", self.stop, "Stop (Esc)") self.b_stop = self._btn("Stop", "stop", self.stop, "Stop (Esc)")
# playback options next to the volume: icons with tooltips # playback options next to the volume: icons with tooltips

View File

@@ -39,8 +39,7 @@ def editor_languages(game, language=None):
"""(language, voices): the selected language (default: the game's) and the speech archive used for it """(language, voices): the selected language (default: the game's) and the speech archive used for it
(the language itself, or INT for languages the game has no voices for).""" (the language itself, or INT for languages the game has no voices for)."""
lang = (language or game.language() or "INT").upper() lang = (language or game.language() or "INT").upper()
voices = lang if lang in game.languages else ("INT" if "INT" in game.languages else game.languages[0]) return lang, game.voice_archive(lang)
return lang, voices
class MainWindow(QMainWindow): class MainWindow(QMainWindow):
@@ -100,7 +99,7 @@ class MainWindow(QMainWindow):
tb.addWidget(self.lbl_voices) tb.addWidget(self.lbl_voices)
tb.addSeparator() tb.addSeparator()
self.act_revert_all = tb.addAction(icons.icon("revert"), "Revert all", self.revert_all) self.act_revert_all = tb.addAction(icons.icon("revert"), "Revert all", self.revert_all)
self.act_revert_all.setToolTip("Put every original sound back") self.act_revert_all.setToolTip("Put every original sound and text back (all languages)")
tb.addAction(icons.icon("export"), "Export mod…", self.export_mod_dialog) tb.addAction(icons.icon("export"), "Export mod…", self.export_mod_dialog)
tb.addAction(icons.icon("import"), "Import mod…", self.import_mod_dialog) tb.addAction(icons.icon("import"), "Import mod…", self.import_mod_dialog)
tb.addSeparator() tb.addSeparator()
@@ -528,35 +527,52 @@ class MainWindow(QMainWindow):
"Reverted %d sound(s)." % len(sounds)) "Reverted %d sound(s)." % len(sounds))
def revert_all(self): def revert_all(self):
"""Every change of this store: sounds, subtitles and menu texts (every language)."""
if not self.ws: if not self.ws:
return return
n = len(self.ws.store.entries) n, t = len(self.ws.store.entries), len(self.ws.texts.records())
if not n: if not n and not t:
self.inform("Revert all", "All sounds are already original.") self.inform("Revert all", "All sounds and texts are already original.")
return return
if not self.confirm("Revert all", "Put the original back for all %d changed sound(s)?" % n): if not self.confirm("Revert all", "Put the original back for all %d changed sound(s) and %d changed "
"text(s)?" % (n, t)):
return return
self._run_change("Reverting everything…", lambda job: self.ws.revert_all(job.report),
"All sounds are original again.")
def _run_change(self, busy, fn, ok_msg): def work(job):
self.ws.revert_all(job.report)
self.ws.texts.revert_all(job.report)
self._run_change("Reverting everything…", work, "All sounds and texts are original again.")
def _run_change(self, busy, fn, ok_msg, ask_force=False):
"""Run a change as a background job. ask_force: fn(job, force); when files were changed outside this
tool, ask the user and run it again with force=True."""
self.statusBar().showMessage(busy) self.statusBar().showMessage(busy)
self.spinner.show() self.spinner.show()
def done(_): def done(_):
self.spinner.hide() self.spinner.hide()
self.statusBar().showMessage(ok_msg, 8000) self.statusBar().showMessage(ok_msg, 8000)
self._after_change() self._refresh_after_change()
self._texts_changed()
if self.editor.sound:
self.editor.set_sound(self.editor.sound)
self.check_problems() self.check_problems()
def failed(e, tb): def failed(e, tb):
self.spinner.hide() self.spinner.hide()
if self.ws:
self._refresh_after_change() # part of the change may have been written
if ask_force and isinstance(e, (W.ConflictError, TextConflictError)):
if self.confirm("Changed outside this tool", "%s\n\nOverwrite them?" % e):
self._run_change(busy, lambda job: fn(job, True), ok_msg)
return
self.inform("Error", str(e)) self.inform("Error", str(e))
self.runner.start(fn, done, failed, lambda m: self.statusBar().showMessage(m)) self.runner.start((lambda job: fn(job, False)) if ask_force else fn, done, failed,
lambda m: self.statusBar().showMessage(m))
def _refresh_after_change(self):
self._after_change()
self._texts_changed()
if self.editor.sound:
self.editor.set_sound(self.editor.sound)
# --- changed underneath ----------------------------------------------------------------------- # --- changed underneath -----------------------------------------------------------------------
def check_problems(self): def check_problems(self):
@@ -628,12 +644,8 @@ class MainWindow(QMainWindow):
def forget_problems(self): def forget_problems(self):
if not self.confirm("Forget", "Stop tracking these changes? The current game files are kept as they are."): if not self.confirm("Forget", "Stop tracking these changes? The current game files are kept as they are."):
return return
for s, _ in getattr(self, "problems", []): self.ws.forget([s.key for s, _ in getattr(self, "problems", [])],
self.ws.store.forget(s.key) [k for k, _ in getattr(self, "text_problems", [])])
for k, _ in getattr(self, "text_problems", []):
self.ws.texts.records().pop(k, None)
self.ws.store.save()
self.ws.store.gc()
self.banner.hide() self.banner.hide()
self._after_change() self._after_change()
@@ -680,25 +692,22 @@ class MainWindow(QMainWindow):
if dlg and dlg.exec() != QDialog.DialogCode.Accepted: if dlg and dlg.exec() != QDialog.DialogCode.Accepted:
return return
ws = self.ws ws = self.ws
prepared = []
def work(job): def work(job, force):
prepare_import(ws, path, items, progress=job.report) if not prepared: # encode once (a confirmed overwrite runs this again)
prepare_import(ws, path, items, progress=job.report)
prepared.append(True)
source = {"source": "mod:" + os.path.basename(path)} source = {"source": "mod:" + os.path.basename(path)}
changes = [(im.sound, im.wem, source) for im in items if im.wem] changes = [(im.sound, im.wem, source) for im in items if im.wem]
if changes: if changes:
try: ws.apply(changes, force=force, progress=job.report)
ws.apply(changes, progress=job.report)
except W.ConflictError:
ws.apply(changes, force=True, progress=job.report)
texts = {k: v for k, v, problem in match_texts(ws, man) if not problem} texts = {k: v for k, v, problem in match_texts(ws, man) if not problem}
if texts: if texts:
try: ws.texts.apply(texts, force=force, progress=job.report, meta=source)
ws.texts.apply(texts, progress=job.report, meta=source)
except TextConflictError:
ws.texts.apply(texts, force=True, progress=job.report, meta=source)
return len(changes) + len(texts) return len(changes) + len(texts)
self._run_change("Installing mod…", work, "Installed %s" % os.path.basename(path)) self._run_change("Installing mod…", work, "Installed %s" % os.path.basename(path), ask_force=True)
# --- settings --------------------------------------------------------------------------------- # --- settings ---------------------------------------------------------------------------------
def settings_dialog(self): def settings_dialog(self):
@@ -706,7 +715,16 @@ class MainWindow(QMainWindow):
if dlg.exec() != QDialog.DialogCode.Accepted: if dlg.exec() != QDialog.DialogCode.Accepted:
return return
old = dict(self.settings) old = dict(self.settings)
self.settings.update(dlg.values()) new = dlg.values()
if self.ws and (new.get("store_root") or None) != (old.get("store_root") or None) and (
self.ws.store.entries or self.ws.texts.records()):
# the changes are recorded in the current store only; another store would take the modified files
# as originals (and could not revert these changes)
self.inform("Settings", "The originals store keeps the originals of the changes you have applied. "
"Use Revert all before choosing another store folder; the store was not "
"changed.")
new["store_root"] = old.get("store_root")
self.settings.update(new)
self.save_settings() self.save_settings()
self._apply_output_device() self._apply_output_device()
self.sidebar.configure(self.settings) self.sidebar.configure(self.settings)

View File

@@ -68,7 +68,7 @@ scans the game itself and applies the same rules (no checksums then; the bank sc
to the lines, so a label then applies to the same recording in every language. to the lines, so a label then applies to the same recording in every language.
3. Run `python tools/build_catalog.py` - it scans every archive and all soundbanks of the installed game (all 3. Run `python tools/build_catalog.py` - it scans every archive and all soundbanks of the installed game (all
languages present; ~5 minutes) and rewrites the CSVs, the location indexes, `characters.csv` and `index.json`. languages present; ~5 minutes) and rewrites the CSVs, the location indexes, `characters.csv` and `index.json`.
It stops before writing anything if a sound differs from the current catalog (a modified game); after a game It stops before writing anything if a sound or a subtitle differs from the current catalog (a modified game); after a game
update run it with `--accept-changes`. update run it with `--accept-changes`.
4. Run `ackit verify --catalog` (no issues expected). 4. Run `ackit verify --catalog` (no issues expected).
New character labels need a display name in `ackit/classify.py: USER_LABELS` (otherwise the file name is used). New character labels need a display name in `ackit/classify.py: USER_LABELS` (otherwise the file name is used).

View File

@@ -8138,7 +8138,7 @@ Speech_TAPE_JOKER_5_16,0,아예 샤피를 미치게 만든 건 네가 아니라
Speech_TAPE_JOKER_5_17,0,고맙네.,4,w,INT:3F21770A:5 Speech_TAPE_JOKER_5_17,0,고맙네.,4,w,INT:3F21770A:5
Speech_TAPE_JOKER_5_18,0,"자, 네 과거를 사람들에게 알리고 싶지 않다면 남의 과거를 캐묻고 다니는 일은 그만둬.",48,w,INT:3F21770A:12 Speech_TAPE_JOKER_5_18,0,"자, 네 과거를 사람들에게 알리고 싶지 않다면 남의 과거를 캐묻고 다니는 일은 그만둬.",48,w,INT:3F21770A:12
Speech_TAPE_JOKER_5_19,0,의사를 하나 더 보내줘. 다른 의사의 진단도 받아보고 싶군.,33,w,INT:3F21770A:0 Speech_TAPE_JOKER_5_19,0,의사를 하나 더 보내줘. 다른 의사의 진단도 받아보고 싶군.,33,w,INT:3F21770A:0
Speech_TAPE_J_Answer_1_00,0,"한 통ì<EFBFBD>˜ ë¶€ìž,중 전화가 있습니다.",44,a, Speech_TAPE_J_Answer_1_00,0,"한 통의 ë¶€ìž,중 전화가 있습니다.",44,a,
Speech_TAPE_J_Answer_1_CHAR_JOKER_First_answer_01,0,왜 진작에 이 생각을 못 했을까? 세상에 둘도 없는 최고의 친구와 직통 회선을 연결하는 거 말야.,54,w,INT:4709E5BE:0 Speech_TAPE_J_Answer_1_CHAR_JOKER_First_answer_01,0,왜 진작에 이 생각을 못 했을까? 세상에 둘도 없는 최고의 친구와 직통 회선을 연결하는 거 말야.,54,w,INT:4709E5BE:0
Speech_TAPE_J_Answer_1_CHAR_JOKER_First_answer_02,0,생각해 봐. 심심하면 언제든 통화 할 수 있어. 우리 사인 한층 각별해지고 있지. 이젠 이메일을 주고받거나 낭만적인 저녁 식사를 하는 일만 남았어.,82,w,INT:4709E5BE:1 Speech_TAPE_J_Answer_1_CHAR_JOKER_First_answer_02,0,생각해 봐. 심심하면 언제든 통화 할 수 있어. 우리 사인 한층 각별해지고 있지. 이젠 이메일을 주고받거나 낭만적인 저녁 식사를 하는 일만 남았어.,82,w,INT:4709E5BE:1
Speech_TAPE_J_Answer_2_01,0,한 통의 부재중 전화가 있습니다.,18,w,INT:4709E5BD:1 Speech_TAPE_J_Answer_2_01,0,한 통의 부재중 전화가 있습니다.,18,w,INT:4709E5BD:1
Can't render this file because it is too large.

View File

@@ -8345,7 +8345,7 @@ Speech_TAPE_JOKER_5_16,0,"Теперь еще попробуй рассказа
Speech_TAPE_JOKER_5_17,0,Благодарю.,10,w,INT:3F21770A:5 Speech_TAPE_JOKER_5_17,0,Благодарю.,10,w,INT:3F21770A:5
Speech_TAPE_JOKER_5_18,0,"Вот тебе совет. Если не хочешь, чтобы кто-то узнал о твоем прошлом, перестань совать нос в мое.",95,w,INT:3F21770A:12 Speech_TAPE_JOKER_5_18,0,"Вот тебе совет. Если не хочешь, чтобы кто-то узнал о твоем прошлом, перестань совать нос в мое.",95,w,INT:3F21770A:12
Speech_TAPE_JOKER_5_19,0,"И не забудь прислать еще одного дока, док. Нужна консультация.",62,w,INT:3F21770A:0 Speech_TAPE_JOKER_5_19,0,"И не забудь прислать еще одного дока, док. Нужна консультация.",62,w,INT:3F21770A:0
Speech_TAPE_J_Answer_1_00,0,У ваÑ<EFBFBD> один пропущенный звонок,54,a, Speech_TAPE_J_Answer_1_00,0,У вас один пропущенный звонок,54,a,
Speech_TAPE_J_Answer_1_CHAR_JOKER_First_answer_01,0,И почему я раньше об этом не подумал? Прямая связь с моим лучшим на свете другом!,81,w,INT:4709E5BE:0 Speech_TAPE_J_Answer_1_CHAR_JOKER_First_answer_01,0,И почему я раньше об этом не подумал? Прямая связь с моим лучшим на свете другом!,81,w,INT:4709E5BE:0
Speech_TAPE_J_Answer_1_CHAR_JOKER_First_answer_02,0,"Только представь - я смогу позвонить тебе сразу, как только заскучаю! Мы так близки друг другу.",95,w,INT:4709E5BE:1 Speech_TAPE_J_Answer_1_CHAR_JOKER_First_answer_02,0,"Только представь - я смогу позвонить тебе сразу, как только заскучаю! Мы так близки друг другу.",95,w,INT:4709E5BE:1
Speech_TAPE_J_Answer_1_CHAR_JOKER_First_answer_02,1,Следующий шаг - переписка по электронной почте и романтические ужины.,69,w,INT:4709E5BE:1 Speech_TAPE_J_Answer_1_CHAR_JOKER_First_answer_02,1,Следующий шаг - переписка по электронной почте и романтические ужины.,69,w,INT:4709E5BE:1
Can't render this file because it is too large.

View File

@@ -38,19 +38,32 @@ loopback); it is a developer tool and not part of the editor.
- **Originals store** per game folder: `<user data>/UnheardCity/stores/<id>/` (Windows `%APPDATA%`, Linux - **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). `~/.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; 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 the applied replacement is saved too. **Revert** re-injects the stored original; **Revert all** (editor toolbar,
every record. Stream-pack padding bytes are stored so pack reverts are byte-identical. `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 - **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. 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). 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, - **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. 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 - **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. 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 - **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) / (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 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. 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 ## 4. Size rule
Speech/ambience variations are addressed by offset+size from BKPK tables inside compressed `.upk` packages, Speech/ambience variations are addressed by offset+size from BKPK tables inside compressed `.upk` packages,
@@ -77,7 +90,7 @@ tables): high risk for little gain, not done.
optional like the WAD copies: the file is rebuilt from it). The file content is always "original copy + 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 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 operation is repaired by the next one. Conflicts: the whole-file SHA-1 must be the original's or the last one
ackit wrote. 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 - **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); 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. a replacement of such a recording rewrites those copies too, otherwise the game could play the old line.
@@ -130,8 +143,9 @@ copy per voice language) is filtered the same way, so the sound list never mixes
WAD voice lines), numbered `NAME#2` in the event's own order when it plays two versions; the other 637 keep 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`). 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. - 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 - The catalog records the ORIGINAL bytes and texts: `tools/build_catalog.py` stops before writing anything if a
differs from the current catalog (a modified game), unless `--accept-changes` (a game update). sound or a subtitle differs from the current catalog (a modified game), unless `--accept-changes` (a game
update).
- Category scheme: `docs/categories.md`. - Category scheme: `docs/categories.md`.
## 6. Repository cleanup choices ## 6. Repository cleanup choices

View File

@@ -57,7 +57,7 @@ WAD sounds have such heads (2,101 copies); replacing one of them must rewrite th
story lines) are story lines) are
also stored **whole** in banks: an in-memory source with the WAD recording's own media ID, byte-identical to also stored **whole** in banks: an in-memory source with the WAD recording's own media ID, byte-identical to
the WAD WEM (84 copies). They are handled like heads: part of the WAD line, written and reverted with it, not the WAD WEM (84 copies). They are handled like heads: part of the WAD line, written and reverted with it, not
sounds of their own (`catalog._whole_recordings`). Heads and whole copies together: 445 recordings, 2,185 sounds of their own (`catalog.whole_recordings`). Heads and whole copies together: 445 recordings, 2,185
copies (`BANK_locations.csv.gz` kind `p`). copies (`BANK_locations.csv.gz` kind `p`).
## Localized banks ## Localized banks

View File

@@ -14,6 +14,10 @@ the UE3 packages (`upk.md`), holding the subtitle text of all 11 languages. acki
to 5 lines. to 5 lines.
- Strings are stored as cp1252 (positive FString length) or UTF-16 (negative length): almost all RUS, JPN and - 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. 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`) ## Scripted subtitles (`RSubtitle`)
41 more subtitles (radio and encryption-card lines) are `RSubtitle` objects in `Startup.upk`: the same layout, 41 more subtitles (radio and encryption-card lines) are `RSubtitle` objects in `Startup.upk`: the same layout,

View File

@@ -58,8 +58,9 @@ the same line and are changed and reverted together.
## Listen ## Listen
The editor under the list shows the selected sound in three columns side by side (drag the dividers to The editor under the list shows the selected sound in three columns side by side (drag the dividers to
resize them): **Listen**, **Replace** and **Subtitle**. Select a row and press **Space** (or double-click) to resize them): **Listen**, **Replace** and **Subtitle**. Select a row and press **Space** to play the original
play the original. Each column ends with its own buttons on one line. In **Listen**: (once a replacement is loaded, Space plays the replacement; Space again stops), or double-click it. Each column
ends with its own buttons on one line. In **Listen**:
- **Original** / **Installed** play the game's original and what is currently in the game; **Stop** (Esc). - **Original** / **Installed** play the game's original and what is currently in the game; **Stop** (Esc).
Click on a waveform to play from that point. Click on a waveform to play from that point.
- Above them: **A/B** (switch between the original and your replacement at the same position, to compare), - Above them: **A/B** (switch between the original and your replacement at the same position, to compare),
@@ -82,14 +83,22 @@ than the original, since the game may cut it off.
## Undo ## Undo
- **Revert** in the editor puts that sound back exactly. - **Revert** in the editor puts that sound back exactly.
- Right-click rows → **Revert selected**; toolbar **Revert all** puts every original back. - Right-click rows → **Revert selected**; toolbar **Revert all** puts every original back: all sounds,
subtitles and menu texts you changed, in every language.
- Originals are kept in your user data folder (`%APPDATA%\UnheardCity` on Windows, - 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. `~/.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. 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 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). (Settings), always for soundbanks, packages and text files (they are rebuilt from that copy). Only files the
game ships unchanged are copied; a file another tool (or another originals store) has changed is reported as
*changed outside this tool* first.
- The originals of your changes are only in the store that made them, so the store folder (Settings) cannot be
changed while changes are applied: use **Revert all** first.
- If Steam "Verify integrity of game files" or an update undoes your changes, a bar offers **Re-apply my - 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. changes** or **Forget them**. Do not use "verify" to undo mods; use Revert all.
- If the store folder itself is damaged, the full copies can still be put back with the command-line toolkit
that Unheard City is built on (`ackit restore-backup`, see the project README; not part of the editor
download).
## Subtitles ## Subtitles
The **Subtitles** tab lists the subtitle of every speech line in the selected language (see *Language*). The **Subtitles** tab lists the subtitle of every speech line in the selected language (see *Language*).
@@ -123,6 +132,7 @@ Click the **★** column to mark favorites. These are stored in your user data f
- **Export mod…**: choose which changed sounds and texts to include and save a `.acmod` file (the exact game - **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). 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. - **Import mod…**: shows which sounds match this game, then installs them. You can revert them like any change.
If a sound or text the mod replaces was changed outside the editor, you are asked before it is overwritten.
- Changed subtitles and menu texts are exported and imported with the sounds (listed as *Text*); a text stays - Changed subtitles and menu texts are exported and imported with the sounds (listed as *Text*); a text stays
in its language (a French subtitle edit changes the French subtitle). in its language (a French subtitle edit changes the French subtitle).
- In another language, voice lines are matched by the game's own identity of the line, so the same line is - In another language, voice lines are matched by the game's own identity of the line, so the same line is
@@ -131,7 +141,8 @@ Click the **★** column to mark favorites. These are stored in your user data f
## Settings ## Settings
![Settings](img/settings.png) ![Settings](img/settings.png)
Game folder, where originals are kept, the full-backup option and the playback device. The recording device Game folder, where originals are kept (changeable only while nothing is modified), the full-backup option and
the playback device. The recording device
is chosen next to the Record button. **Background**: show or hide the Arkham City wallpaper behind the filter is chosen next to the Record button. **Background**: show or hide the Arkham City wallpaper behind the filter
panel; **Background strength**: how visible it is (10 % by default). panel; **Background strength**: how visible it is (10 % by default).
The editor panel opens tall enough to show all its controls; drag the divider above it to change that. The editor panel opens tall enough to show all its controls; drag the divider above it to change that.

View File

@@ -11,8 +11,14 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def isolated_home(tmp_path, monkeypatch): def isolated_home(tmp_path, monkeypatch):
"""User data/config/cache go to a temp folder (never touch the real settings or stores).""" """User data/config/cache go to a temp folder (never touch the real settings or stores), and the language
the game is set to on this machine (Documents\\WB Games\\...\\UserEngine.ini) does not leak into tests."""
from ackit import game as G
monkeypatch.setenv("ACKIT_HOME", str(tmp_path / "home")) monkeypatch.setenv("ACKIT_HOME", str(tmp_path / "home"))
real = G.Game.config_dirs
mine = os.path.normcase(os.path.join(os.path.expanduser("~"), "Documents", "WB Games"))
monkeypatch.setattr(G.Game, "config_dirs",
lambda self: [d for d in real(self) if not os.path.normcase(d).startswith(mine)])
yield yield

View File

@@ -223,6 +223,60 @@ def test_mod_with_texts(win, tmp_path):
tx.revert_all() tx.revert_all()
def test_revert_all_reverts_sounds_and_texts(win):
w, root, _ = win
before = files(root)
tx = w.ws.texts
tx.apply({"SUB:INT:%s:0" % synth.SPEECH_EVENT: "Undo me.", "MENU:INT:FRONTEND.int:Menu:Quit": "Leave"})
s = w.ws.sound("CHR_RadioScanner_1")
w.ws.apply([(s, synth.wem(0.5, 1200), {})])
w.revert_all()
assert files(root) == before and not w.ws.store.entries and not tx.records()
def test_mod_import_asks_before_overwriting(win, tmp_path):
"""Files changed outside the editor are overwritten by a mod only when the user confirms."""
w, root, originals = win
s = w.ws.sound("CHR_RadioScanner_1")
w.ws.apply([(s, synth.wem(0.5, 1200), {})])
pkg = str(tmp_path / "m.acmod")
w.export_mod_dialog(path=pkg, keys=[s.key], title="m")
other = synth.wem(0.4, 150)
inject(w.ws.wad_path(s), {s.target: other}, {s.target: w.ws.limit(s)}) # another tool writes the slot
asked = []
w.confirm = lambda *a: asked.append(a) or False
w.import_mod_dialog(path=pkg, confirm=False)
assert asked and w.ws.current_bytes(s) == other # declined: left alone
w.confirm = lambda *a: True
w.import_mod_dialog(path=pkg, confirm=False)
assert w.ws.state(s) == W.MODIFIED
w.revert_all()
def test_store_cannot_change_while_changes_are_applied(win, tmp_path, monkeypatch):
from app import mainwindow
w, root, _ = win
s = w.ws.sound("CHR_RadioScanner_1")
w.ws.apply([(s, synth.wem(0.5, 1200), {})])
store = w.ws.store.root
class Dlg:
def __init__(self, settings, parent):
pass
def exec(self):
return mainwindow.QDialog.DialogCode.Accepted
def values(self):
return dict(w.settings, store_root=str(tmp_path / "elsewhere"))
monkeypatch.setattr(mainwindow, "SettingsDialog", Dlg)
told = []
w.inform = lambda *a: told.append(a)
w.settings_dialog()
assert told and w.settings.get("store_root") != str(tmp_path / "elsewhere") and w.ws.store.root == store
w.revert_all()
def test_text_undone_outside_shows_banner(win): def test_text_undone_outside_shows_banner(win):
w, root, _ = win w, root, _ = win
tx = w.ws.texts tx = w.ws.texts
@@ -387,3 +441,18 @@ def test_editor_panel_fits_its_contents(win):
need = w.editor.sizeHint().height() need = w.editor.sizeHint().height()
assert w.scroll.viewport().height() >= need - 2, (w.scroll.viewport().height(), need) assert w.scroll.viewport().height() >= need - 2, (w.scroll.viewport().height(), need)
assert w.vsplit.sizes()[0] >= 160 assert w.vsplit.sizes()[0] >= 160
def test_recorder_sample_formats(qapp):
"""Input devices may deliver 16/32-bit integer, 8-bit or float samples, and reads can split a frame."""
import numpy as np
from app.audioio import Recorder
r = Recorder()
x = np.array([[0.5, -0.25], [0.125, -1.0]], np.float32)
for sample, raw in (("<i2", (x * 32768).astype("<i2").tobytes()), ("<i4", (x * 2147483648.0).astype("<i4").tobytes()),
("u1", (x * 128 + 128).astype("u1").tobytes()), ("<f4", x.astype("<f4").tobytes())):
r.chunks, r._rest, r.channels, r.sample = [], b"", 2, sample
r._ingest(raw[:3])
r._ingest(raw[3:])
got, _ = r.stop()
assert np.allclose(got, x, atol=1 / 128), sample

View File

@@ -180,3 +180,19 @@ def test_every_name_resolves_to_its_sound(real_game_dir):
for lang in g.languages: for lang in g.languages:
cat = Catalog(g, [lang, "SFX", "BANK"]) cat = Catalog(g, [lang, "SFX", "BANK"])
assert all(cat.resolve(s.name) is s for s in cat.sounds), lang assert all(cat.resolve(s.name) is s for s in cat.sounds), lang
def test_build_catalog_stops_on_changed_subtitles(tmp_path):
"""tools/build_catalog.py refuses to record a modified game: changed subtitles count like changed sounds."""
import importlib.util
from types import SimpleNamespace
from ackit import subtitles
spec = importlib.util.spec_from_file_location("build_catalog", os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tools", "build_catalog.py"))
bc = importlib.util.module_from_spec(spec)
spec.loader.exec_module(bc)
row = subtitles.SubLine("INT", "Speech_X", 0, "Original.", 12, "a", "")
subtitles.write_texts(str(tmp_path / "SUB_INT.csv"), [row])
game = SimpleNamespace(archives={})
assert bc.changed_since_catalog(game, str(tmp_path), [], {"INT": [row]}) == []
assert bc.changed_since_catalog(game, str(tmp_path), [], {"INT": [row._replace(text="Edited.")]}) == \
["SUB:INT:Speech_X:0"]

View File

@@ -35,6 +35,26 @@ def test_cli_workflow(synth_game, tmp_path, capsys):
run(capsys, "revert", "Speech_Synth_Radio_02", *g) run(capsys, "revert", "Speech_Synth_Radio_02", *g)
def test_cli_text_language_without_voices(synth_game, capsys):
"""--lang works for the languages that have only texts (INT voices are loaded for them, like the editor)."""
root, _ = synth_game
out = run(capsys, "text", "find", "--kind", "subs", "--game", root, "--lang", "RUS", "Строка")
assert "SUB:RUS:%s:0" % synth.SPEECH_EVENT in out and "1 match" in out
def test_cli_restore_backup(synth_game, tmp_path, capsys):
root, _ = synth_game
g = ["--game", root, "--lang", "INT", "--store", str(tmp_path / "store")]
wav = str(tmp_path / "line.wav")
audio.save_wav(wav, *synth.tone(1.0, 44100, 660))
run(capsys, "replace", "Speech_Synth_Radio_02", wav, *g)
out = run(capsys, "restore-backup", *g)
assert "would copy back" in out and "--yes" in out # lists only
assert "Speech_Synth_Radio_02" in run(capsys, "status", *g)
assert "restored" in run(capsys, "restore-backup", "--yes", *g)
assert "game files are original" in run(capsys, "status", *g)
def test_cli_errors_are_friendly(synth_game, capsys): def test_cli_errors_are_friendly(synth_game, capsys):
root, _ = synth_game root, _ = synth_game
with pytest.raises(SystemExit) as e: with pytest.raises(SystemExit) as e:

View File

@@ -59,6 +59,13 @@ def test_audio_helpers():
assert audio.to_channels(x, 1).shape == (48000, 1) assert audio.to_channels(x, 1).shape == (48000, 1)
assert audio.to_channels(x[:, :1], 6).shape == (48000, 6) assert audio.to_channels(x[:, :1], 6).shape == (48000, 6)
assert abs(audio.peak_db(audio.normalize(x, -3.0)) + 3.0) < 0.01 assert abs(audio.peak_db(audio.normalize(x, -3.0)) + 3.0) < 0.01
# down-mix keeps every channel: a voice only in the centre of 5.1 goes to both stereo sides at -3 dB (the
# standard fold-down) and to mono at its own level
c = np.zeros((100, 6), np.float32)
c[:, 2] = 0.5
assert np.allclose(audio.to_channels(c, 2), 0.5 * 0.7071) and np.allclose(audio.to_channels(c, 1), 0.5)
same = np.repeat(x[:, :1], 2, axis=1) # identical L/R: mono = either side, no clipping
assert np.allclose(audio.to_channels(same, 1)[:, 0], x[:, 0], atol=1e-6)
assert audio.trim(x, sr, 0.25, 0.75).shape[0] == 24000 assert audio.trim(x, sr, 0.25, 0.75).shape[0] == 24000
assert audio.resample(x, 48000, 24000).shape[0] == 24000 assert audio.resample(x, 48000, 24000).shape[0] == 24000

View File

@@ -46,3 +46,17 @@ def test_not_a_game_folder(tmp_path):
import pytest import pytest
with pytest.raises(G.GameError): with pytest.raises(G.GameError):
G.Game(str(tmp_path)) G.Game(str(tmp_path))
def test_language_without_voices(synth_game, tmp_path, monkeypatch):
"""A game set to a language with texts only (e.g. JPN) is in that language, with the INT voices."""
root, _ = synth_game
os.makedirs(os.path.join(root, "BmGame", "Localization", "JPN"), exist_ok=True)
cfg = tmp_path / "cfg"
cfg.mkdir()
(cfg / "UserEngine.ini").write_bytes("[Engine.Engine]\r\nLanguage=Jpn\r\n".encode("utf-16"))
monkeypatch.setattr(G.Game, "config_dirs", lambda self: [str(cfg)])
g = G.Game(root)
assert g.language() == "JPN" and g.voice_archive(g.language()) == "INT"
from ackit.catalog import Catalog
assert "INT" in Catalog(g).archives # the default catalog loads the INT voices

View File

@@ -40,7 +40,7 @@ def test_stream_packs_noop_identical(real_game_dir):
def test_builder_reproduces_game_wems(real_game_dir): def test_builder_reproduces_game_wems(real_game_dir):
g = Game(real_game_dir) g = Game(real_game_dir)
lang = g.language() lang = g.voice_archive(g.language())
w = Wad(g.wad(lang)) w = Wad(g.wad(lang))
rnd = random.Random(5) rnd = random.Random(5)
n = 0 n = 0

View File

@@ -1,5 +1,6 @@
"""Subtitles (dialogue objects in packages) and menu text (localization files): parse, apply, revert.""" """Subtitles (dialogue objects in packages) and menu text (localization files): parse, apply, revert."""
import hashlib import hashlib
import struct
import os import os
import pytest import pytest
@@ -36,6 +37,10 @@ def test_fstring_encoding():
subtitles.encode_fstring("too long", 10, "a") subtitles.encode_fstring("too long", 10, "a")
with pytest.raises(subtitles.SubtitleError): with pytest.raises(subtitles.SubtitleError):
subtitles.encode_fstring("→", 10, "a") # not in cp1252 subtitles.encode_fstring("→", 10, "a") # not in cp1252
# every byte reads back as it was, also the 5 that Python's cp1252 leaves undefined (UTF-8 in two game strings)
raw = struct.pack("<i", 7) + "Уж".encode("utf-8") + b"\x81\x8d\0"
text = subtitles.read_fstring(raw, 0)[0]
assert subtitles.encode_fstring(text, len(raw), "a") == raw
def test_menu_parse_and_write(): def test_menu_parse_and_write():
@@ -51,6 +56,15 @@ def test_menu_parse_and_write():
loctext.write(raw, "INT", fname, {"INT:FRONTEND.int:Menu:Start": "two\nlines"}) loctext.write(raw, "INT", fname, {"INT:FRONTEND.int:Menu:Start": "two\nlines"})
def test_menu_check_value():
loctext.check_value("Fine", "utf-16", "F.int")
loctext.check_value("Città", "cp1252", "F.int")
with pytest.raises(loctext.TextError):
loctext.check_value("two\nlines", "utf-16", "F.int")
with pytest.raises(loctext.TextError):
loctext.check_value("Привет", "cp1252", "F.int") # not in the file's encoding
# --- subtitles through the workspace ------------------------------------------------------------ # --- subtitles through the workspace ------------------------------------------------------------
def test_subtitle_catalog_and_link(synth_game, tmp_path): def test_subtitle_catalog_and_link(synth_game, tmp_path):
root, _ = synth_game root, _ = synth_game

View File

@@ -119,6 +119,101 @@ def test_full_backup_taken_once(synth_game):
assert rel in restored and ws.state(s) == W.ORIGINAL assert rel in restored and ws.state(s) == W.ORIGINAL
def test_restore_from_full_backup_keeps_wads_without_a_copy(synth_game, tmp_path):
"""Without full WAD backups, a restore puts the packages back but keeps the WAD sounds recorded (they stay
modified and revertible)."""
root, _ = synth_game
before = files(root)
ws = W.Workspace(root, store_root=str(tmp_path / "store"), archives=["INT", "SFX", "BANK"], full_backup=False)
wad_s = ws.sound("CHR_RadioScanner_1")
bank_s = ws.catalog.by_key["BANK:%08X" % synth.DLG_MEDIA]
ws.apply([(wad_s, synth.wem(0.5, 1200), {}), (bank_s, synth.fitting_wem(ws, bank_s, 0.1, 1900), {})])
ws.texts.apply({"SUB:INT:%s:0" % synth.SPEECH_EVENT: "Restored."})
ws.restore_from_full_backup()
assert ws.state(bank_s) == W.ORIGINAL and not ws.texts.records()
assert ws.state(wad_s) == W.MODIFIED and list(ws.store.entries) == [wad_s.key]
ws.revert_all()
assert files(root) == before
def test_writes_are_serialized(synth_game, tmp_path, monkeypatch):
"""The editor runs writes as background jobs: a sound and a subtitle written into one package at the same
time both end up in it (a slow rebuild of the first must not overwrite the second)."""
import threading
import time
root, _ = synth_game
ws = W.Workspace(root, store_root=str(tmp_path / "store"), archives=["INT", "SFX", "BANK"])
s = ws.catalog.by_key["BANK:%08X" % synth.DLG_MEDIA]
wem = synth.fitting_wem(ws, s, 0.1, 1900)
build = W.Workspace._build
def slow(self, path, orig):
out = build(self, path, orig)
time.sleep(0.5 if threading.current_thread().name == "sound" else 0)
return out
monkeypatch.setattr(W.Workspace, "_build", slow)
key = "SUB:INT:%s:0" % synth.SPEECH_EVENT
a = threading.Thread(name="sound", target=lambda: ws.apply([(s, wem, {})]))
b = threading.Thread(target=lambda: (time.sleep(0.15), ws.texts.apply({key: "Both."})))
a.start(), b.start()
a.join(), b.join()
assert ws.state(s) == W.MODIFIED and ws.texts.state(key) == "modified"
def test_another_store_does_not_take_modified_files_as_originals(synth_game, tmp_path):
"""A second originals store (Settings > Originals store changed, `--store`) must not copy a package that the
first store modified as its "original": the catalog's checksums and texts show it is not original."""
from ackit.originals import sha1_16
from ackit.texts import TextConflictError
root, _ = synth_game
A = W.Workspace(root, store_root=str(tmp_path / "A"), archives=["INT", "SFX", "BANK"])
s = A.catalog.by_key["BANK:%08X" % synth.DLG_MEDIA]
orig_sha = sha1_16(A.current_bytes(s))
sub = "SUB:INT:%s:0" % synth.SPEECH_EVENT
rel = A.locations(s)[0].file
A.apply([(s, synth.fitting_wem(A, s, 0.1, 1900), {})])
A.texts.apply({sub: "Edited."})
def store_b():
# the synthetic game has no shipped catalog: give B the checksums and texts a shipped one would have
B = W.Workspace(root, store_root=str(tmp_path / "B"), archives=["INT", "SFX", "BANK"])
B._shipped = True
B.catalog.bank_sha1 = {synth.DLG_MEDIA: orig_sha}
B.texts._shipped_subs = ({}, {})
return B
B = store_b()
assert B.unoriginal_files([B.bank_path(rel)]) == [B.bank_path(rel)]
with pytest.raises(TextConflictError):
B.texts.apply({"SUB:INT:%s:1" % synth.SPEECH_EVENT: "Other."})
A.revert([s])
B = store_b()
by_file = {}
for l in B.texts.subtitle_locations(sub):
by_file.setdefault(l.file, []).append(l)
B.texts._shipped_subs = (by_file, {("INT", synth.SPEECH_EVENT, 0): "Scanner line two."})
assert B.texts.unoriginal_subtitles(rel) == [sub] # A's subtitle is still applied
A.texts.revert_all()
assert B.unoriginal_files([B.bank_path(rel)]) == []
def test_mod_export_of_a_change_made_in_another_language(synth_game, tmp_path):
"""A change made while another language was loaded keeps its name and line identity in a mod package."""
import json
import zipfile
root, _ = synth_game
store = str(tmp_path / "store")
every = W.Workspace(root, store_root=store, archives=["SFX", "BANK"]) # every language's lines
s = every.catalog.by_key["BANK:%08X" % synth.GRUNT_MEDIA["ITA"]]
every.apply([(s, synth.fitting_wem(every, s, 0.1, 900), {})])
ws = W.Workspace(root, store_root=store, archives=["INT", "SFX", "BANK"]) # now INT is loaded
assert s.key not in ws.catalog.by_key
pkg = str(tmp_path / "m.acmod")
export_mod(ws, pkg, ws.modified_keys(), "m")
it = json.loads(zipfile.ZipFile(pkg).read("manifest.json"))["items"][0]
assert (it["name"], it["line"]) == (s.name, s.line) and s.line
assert ws.revert_all() and not ws.store.entries
def test_mod_from_another_language_matched_by_line(synth_game, tmp_path): def test_mod_from_another_language_matched_by_line(synth_game, tmp_path):
"""A voice line from another language is found through the game's line identity (its WAD position there """A voice line from another language is found through the game's line identity (its WAD position there
can differ); a voice line from a package without line identities is never guessed by name.""" can differ); a voice line from a package without line identities is never guessed by name."""

View File

@@ -12,7 +12,8 @@ Outputs
data/catalog/SUB_locations.csv.gz every stored copy of each subtitle string data/catalog/SUB_locations.csv.gz every stored copy of each subtitle string
data/catalog/index.json fingerprints the CSVs were built from + counts data/catalog/index.json fingerprints the CSVs were built from + counts
Run it only on clean game files (`ackit status` must say nothing is modified). It stops before writing Run it only on clean game files (`ackit status` must say nothing is modified). It stops before writing
anything if a sound differs from the current catalog (a modified game); after a game update use --accept-changes. anything if a sound or a subtitle differs from the current catalog (a modified game); after a game update use
--accept-changes. (Menu text has no catalog.)
""" """
import argparse import argparse
import collections import collections
@@ -88,9 +89,16 @@ def build_characters(game, src_root, final):
return rows, unmatched, ambiguous, skipped return rows, unmatched, ambiguous, skipped
def changed_since_catalog(game, out_dir, bank_snd): def changed_since_catalog(game, out_dir, bank_snd, texts=None):
"""Keys whose original checksum in the existing catalog differs from the game files now.""" """Keys whose original in the existing catalog differs from the game files now: sounds (checksums) and
subtitles (texts: {lang: [SubLine]} scanned now)."""
out = [] out = []
for lang, rows in (texts or {}).items():
p = os.path.join(out_dir, "SUB_%s.csv" % lang)
if os.path.exists(p):
old = {(s.speech, s.line): s.text for s in subtitles.read_texts(p, lang)}
out += [subtitles.key(lang, s.speech, s.line) for s in rows
if (s.speech, s.line) in old and old[(s.speech, s.line)] != s.text]
for arch, path in game.archives.items(): for arch, path in game.archives.items():
p = os.path.join(out_dir, "%s.csv" % arch) p = os.path.join(out_dir, "%s.csv" % arch)
if not os.path.exists(p): if not os.path.exists(p):
@@ -133,11 +141,12 @@ def main():
for k, d in lines.issues[:10]: for k, d in lines.issues[:10]:
print(" issue:", k, d) print(" issue:", k, d)
# safety: the catalog records ORIGINAL bytes. A sound that differs from the current catalog means a modified # safety: the catalog records ORIGINAL bytes and texts. A sound or subtitle that differs from the current
# game (an applied change, another tool) unless the game itself was updated -> stop before writing anything. # catalog means a modified game (an applied change, another tool) unless the game itself was updated -> stop
changed = changed_since_catalog(game, out_dir, bank_snd) # before writing anything.
changed = changed_since_catalog(game, out_dir, bank_snd, texts)
if changed and not a.accept_changes: if changed and not a.accept_changes:
print("STOP: %d sound(s) differ from the current catalog, e.g. %s." % (len(changed), changed[:5])) print("STOP: %d sound(s) or subtitle(s) differ from the current catalog, e.g. %s." % (len(changed), changed[:5]))
print("Revert all changes (ackit status / ackit revert --all) and run again; after a game update use " print("Revert all changes (ackit status / ackit revert --all) and run again; after a game update use "
"--accept-changes.") "--accept-changes.")
sys.exit(2) sys.exit(2)
@@ -193,7 +202,7 @@ def main():
"fingerprint": banks.game_fingerprint(game.cooked), "sounds": len(sounds), "groups": dict(g), "fingerprint": banks.game_fingerprint(game.cooked), "sounds": len(sounds), "groups": dict(g),
"named_by_event": by_event, "named_by_bank": len(sounds) - by_event, "named_by_event": by_event, "named_by_bank": len(sounds) - by_event,
"copies": sum(1 for l in locs if l.kind == banks.MEMORY), "files": len(sc.files), "copies": sum(1 for l in locs if l.kind == banks.MEMORY), "files": len(sc.files),
# bank copies of WAD recordings: prefetch heads and whole in-memory copies (catalog._whole_recordings) # bank copies of WAD recordings: prefetch heads and whole in-memory copies (catalog.whole_recordings)
"prefetch_heads": {"recordings": len({l.media for l in heads}), "copies": len(heads), "prefetch_heads": {"recordings": len({l.media for l in heads}), "copies": len(heads),
"unmapped": len({l.media for l in heads if not l.sound})}, "unmapped": len({l.media for l in heads if not l.sound})},
"issues": ["%s: %s" % i for i in sc.issues]} "issues": ["%s: %s" % i for i in sc.issues]}

View File

@@ -240,6 +240,16 @@ def catalog_claims(cat):
west = [enc[L]["w"] for L in langs if L not in ("RUS", "JPN", "KOR", "POL")] west = [enc[L]["w"] for L in langs if L not in ("RUS", "JPN", "KOR", "POL")]
claim("docs/formats/subtitles.md", "%d %% of POL are UTF-16; the other languages have %s UTF-16 strings each" claim("docs/formats/subtitles.md", "%d %% of POL are UTF-16; the other languages have %s UTF-16 strings each"
% (pol, rng(west))) % (pol, rng(west)))
utf8 = [] # cp1252 strings whose bytes are UTF-8 text (the game's data)
for L, t in sorted(texts.items()):
for s in t:
if s.enc == "a":
u = subtitles._encode_cp1252(s.text).decode("utf-8", "replace")
multi = sum(1 for c in u if ord(c) > 0x7F and c != "\ufffd") # valid multi-byte characters
if multi and u.count("\ufffd") <= multi // 4: # (one corrupt byte tolerated)
utf8.append((L, s.speech))
claim("docs/formats/subtitles.md", "%d cp1252 strings hold UTF-8 bytes instead (the same line in %s: `%s`"
% (len(utf8), ", ".join(L for L, _ in utf8), "`, `".join(sorted({sp for _, sp in utf8}))))
with gzip.open(os.path.join(cat, subtitles.LOCATIONS), "rt", newline="") as f: with gzip.open(os.path.join(cat, subtitles.LOCATIONS), "rt", newline="") as f:
sublocs = sum(1 for _ in csv.DictReader(f)) sublocs = sum(1 for _ in csv.DictReader(f))
claim("REPORT.md", "Edited in place in every copy (%s copies)" % n(sublocs)) claim("REPORT.md", "Edited in place in every copy (%s copies)" % n(sublocs))
@@ -505,6 +515,21 @@ def wem_claims():
% (n(total - len(bad)), n(total), exceptions)) % (n(total - len(bad)), n(total), exceptions))
def originality_claims(game):
"""Workspace.unoriginal_files on every file with sounds or subtitles of the (original) game: no false alarm."""
import tempfile
from ackit import subtitles
from ackit.workspace import Workspace
with tempfile.TemporaryDirectory() as td:
ws = Workspace(game, store_root=td, archives=[game.voice_archive("INT"), "SFX", "BANK"])
rels = {l.file for ls in ws.catalog.locations.values() for l in ls}
rels |= {l.file for ls in subtitles.read_locations(os.path.join(catalog_dir(), subtitles.LOCATIONS)).values()
for l in ls}
bad = ws.unoriginal_files([ws.bank_path(r) for r in rels])
claim("REPORT.md", "checked on all %s files with sounds or subtitles: %s" % (
n(len(rels)), "no false alarm" if not bad else "%d false alarms" % len(bad)), "Workspace.unoriginal_files")
def test_claims(): def test_claims():
import subprocess import subprocess
out = subprocess.run([sys.executable, "-m", "pytest", "--collect-only", "-q", "-p", "no:cacheprovider"], out = subprocess.run([sys.executable, "-m", "pytest", "--collect-only", "-q", "-p", "no:cacheprovider"],
@@ -535,6 +560,7 @@ def main():
if not game: if not game:
raise SystemExit("--scan needs the game") raise SystemExit("--scan needs the game")
scan_claims(game) scan_claims(game)
originality_claims(game)
if a.wems: if a.wems:
if not game: if not game:
raise SystemExit("--wems needs the game") raise SystemExit("--wems needs the game")

View File

@@ -5,6 +5,8 @@
3. start the game under cdb + speaker loopback: the probe must be heard, no crash, setup parsed; 3. start the game under cdb + speaker loopback: the probe must be heard, no crash, setup parsed;
4. press Revert (EditorPanel.revert) and check the WAD + pack index are byte-identical to before; 4. press Revert (EditorPanel.revert) and check the WAD + pack index are byte-identical to before;
5. also apply/revert a speech line in place and check byte identity. 5. also apply/revert a speech line in place and check byte identity.
Uses the user's settings and originals store (an interrupted run shows up in `ackit status`); the settings file
is never written.
python tools/e2e_editor.py python tools/e2e_editor.py
""" """
@@ -29,11 +31,15 @@ def sha(p):
def main(): def main():
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
from ackit import settings as settings_mod
from app.mainwindow import MainWindow from app.mainwindow import MainWindow
from app.workers import Runner from app.workers import Runner
app = QApplication([]) app = QApplication([])
w = MainWindow(settings={"full_backup": True}, runner=Runner(sync=True)) # the user's settings (same game folder and originals store as `ackit status`), never saved: the window
# stores its settings on load and on close, which would overwrite the user's file with this test's ones
w = MainWindow(settings=dict(settings_mod.load(), full_backup=True), runner=Runner(sync=True))
w.save_settings = lambda: None
w.confirm = lambda *a: True w.confirm = lambda *a: True
w.editor.confirm = lambda *a: True w.editor.confirm = lambda *a: True
errors = [] errors = []

View File

@@ -14,10 +14,14 @@ $Data = @((Join-Path $env:APPDATA "UnheardCity"), (Join-Path $env:LOCALAPPDATA "
function Say($msg) { if (-not $Quiet) { Write-Host $msg } } function Say($msg) { if (-not $Quiet) { Write-Host $msg } }
function Modified-Count { function Modified-Count {
# sounds still changed in the game, according to the originals stores # sounds and texts (subtitles, menu text) still changed in the game, according to the originals stores
$n = 0 $n = 0
Get-ChildItem -Path (Join-Path $Data[0] "stores\*\store.json") -ErrorAction SilentlyContinue | ForEach-Object { Get-ChildItem -Path (Join-Path $Data[0] "stores\*\store.json") -ErrorAction SilentlyContinue | ForEach-Object {
try { $n += @((Get-Content -Raw -LiteralPath $_.FullName | ConvertFrom-Json).entries.PSObject.Properties).Count } catch {} try {
$s = Get-Content -Raw -LiteralPath $_.FullName | ConvertFrom-Json
$n += @($s.entries.PSObject.Properties).Count
if ($s.texts) { $n += @($s.texts.PSObject.Properties).Count }
} catch {}
} }
return $n return $n
} }
@@ -26,8 +30,8 @@ try {
if (Get-Process -Name "UnheardCity" -ErrorAction SilentlyContinue) { throw "$Name is running. Close it first." } if (Get-Process -Name "UnheardCity" -ErrorAction SilentlyContinue) { throw "$Name is running. Close it first." }
$mods = Modified-Count $mods = Modified-Count
if ($mods -gt 0 -and -not $Quiet) { if ($mods -gt 0 -and -not $Quiet) {
Write-Host "$mods sound(s) are still modified in the game. To restore the originals, open $Name and use" Write-Host "$mods sound(s) or text(s) are still modified in the game. To restore the originals, open $Name and"
Write-Host "'Revert all' BEFORE uninstalling (your originals store is kept either way)." Write-Host "use 'Revert all' BEFORE uninstalling (your originals store is kept either way)."
if ((Read-Host "Uninstall anyway? [y/N]") -notmatch '^(y|yes)$') { exit 1 } if ((Read-Host "Uninstall anyway? [y/N]") -notmatch '^(y|yes)$') { exit 1 }
} }
if (-not $Quiet -and -not $RemoveUserData -and $mods -eq 0 -and (Test-Path $Data[0])) { if (-not $Quiet -and -not $RemoveUserData -and $mods -eq 0 -and (Test-Path $Data[0])) {