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:
@@ -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/`
|
||||
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`.
|
||||
- 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`).
|
||||
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`).
|
||||
@@ -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
|
||||
checks byte-identity itself).
|
||||
- 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:
|
||||
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`
|
||||
|
||||
36
NOTES.md
36
NOTES.md
@@ -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
|
||||
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*).
|
||||
- 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).
|
||||
- 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).
|
||||
|
||||
@@ -38,13 +38,14 @@ localization files. Details in `docs/`.
|
||||
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 replace Speech_TAPE_JOKER_1_04 my_line.wav --match-loudness
|
||||
ackit revert Speech_TAPE_JOKER_1_04 # or: ackit revert --all
|
||||
ackit text find --lang INT protocol # subtitles and menu texts (keys SUB:... / MENU:...)
|
||||
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:...; any of the
|
||||
# 11 languages, e.g. --lang JPN)
|
||||
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`,
|
||||
`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
|
||||
with the maximum length); music can be any length. Subtitles must fit the original text's length (menu text:
|
||||
|
||||
14
REPORT.md
14
REPORT.md
@@ -41,8 +41,14 @@ editor Unheard City (mission 2).
|
||||
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
|
||||
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.
|
||||
- **Tests**: 135 automated tests (unit, synthetic game with packages/banks/dialogues/localization, a
|
||||
another tool or Steam are detected (whole-file SHA-1) and not overwritten without confirmation (also when
|
||||
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
|
||||
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.
|
||||
@@ -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
|
||||
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
|
||||
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
|
||||
- **Checked by a person in game** (2026-10-10), not automatically (nothing of this plays or shows on the title
|
||||
|
||||
@@ -53,21 +53,29 @@ def resample(x, rate, new_rate):
|
||||
|
||||
|
||||
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)
|
||||
cur = x.shape[1]
|
||||
if cur == channels:
|
||||
return x
|
||||
if channels == 1:
|
||||
return x.mean(axis=1, keepdims=True)
|
||||
if cur == 1:
|
||||
return np.repeat(x, channels, axis=1)
|
||||
out = np.empty((len(x), channels), dtype=np.float32)
|
||||
keep = min(cur, channels)
|
||||
out[:, :keep] = x[:, :keep]
|
||||
if channels > keep:
|
||||
out[:, keep:] = x.mean(axis=1, keepdims=True)
|
||||
return out
|
||||
if channels > cur:
|
||||
out = np.empty((len(x), channels), dtype=np.float32)
|
||||
out[:, :cur] = x
|
||||
out[:, cur:] = x.mean(axis=1, keepdims=True)
|
||||
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):
|
||||
|
||||
@@ -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
|
||||
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
|
||||
kind 'm' = an in-memory sound, 'p' = the in-memory head of a streamed WAD sound (prefetch); for 'p' rows
|
||||
`sound` is the WAD sound's key (e.g. ITA:2B14E472:3): speech lines have per-language media IDs and heads.
|
||||
Replacements are written in place into every copy (<= original size, zero-padded), so bank and package
|
||||
layouts never change.
|
||||
kind 'm' = an in-memory sound, 'p' = a bank copy of a streamed WAD recording (the in-memory head of a prefetched
|
||||
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):
|
||||
speech lines have per-language media IDs and heads.
|
||||
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 gzip
|
||||
@@ -157,7 +159,7 @@ class Scan:
|
||||
self.actions = {} # id -> target id
|
||||
self.sound_media = defaultdict(set) # sound object id -> media ids
|
||||
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
|
||||
# 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
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"""Index of every sound in the game's WAD archives, with names, categories and characters
|
||||
(sounds stored inside soundbanks are not indexed yet: docs/formats/banks.md).
|
||||
"""Index of every sound of the game, with names, categories and characters: the WAD archives (one per voice
|
||||
language, SFX) and the in-memory sounds stored inside soundbanks (archive BANK, docs/formats/banks.md).
|
||||
|
||||
Sources, in order:
|
||||
1. data/catalog/<ARCHIVE>.csv -- shipped catalog (instant) when the WAD fingerprint matches;
|
||||
2. otherwise a header scan of the WAD (a few seconds, cached in the user cache), classified with
|
||||
`classify` and labelled with data/catalog/characters.csv (speaker per sound name).
|
||||
1. data/catalog/<ARCHIVE>.csv -- shipped catalog (instant) when the game's fingerprint matches;
|
||||
2. otherwise a scan of the game (WAD headers: seconds; banks: minutes; cached in the user cache), classified
|
||||
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
|
||||
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
|
||||
@@ -517,14 +517,16 @@ class Catalog:
|
||||
def __init__(self, game, archives=None, progress=None):
|
||||
self.game = 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.locations = {} # BANK media id -> [Location]
|
||||
self.heads = {} # WAD sound key -> [Location] of its prefetch heads
|
||||
self.bank_sha1 = {} # BANK media id -> original checksum ('' if unknown), every language
|
||||
bank_sounds_ = []
|
||||
if banks.ARCHIVE in avail:
|
||||
# always loaded: speech lines may have prefetch heads that must be written with them
|
||||
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 l in ls:
|
||||
if l.kind == banks.PREFETCH_HEAD and l.sound:
|
||||
|
||||
49
ackit/cli.py
49
ackit/cli.py
@@ -1,15 +1,17 @@
|
||||
"""ackit - Batman: Arkham City audio toolkit (command line).
|
||||
|
||||
Everyday use, by sound name (see `ackit find`):
|
||||
ackit find joker tape search names, categories, characters
|
||||
ackit info Speech_TAPE_JOKER_1_4 details, size limit, modified state
|
||||
ackit extract Speech_TAPE_JOKER_1_4 orig.wav save the original (or --current)
|
||||
ackit replace Speech_TAPE_JOKER_1_4 line.wav encode + install a replacement
|
||||
ackit revert Speech_TAPE_JOKER_1_4 put the original back (--all for everything)
|
||||
ackit status what you have changed
|
||||
ackit find joker tape search names, categories, characters
|
||||
ackit info Speech_TAPE_JOKER_1_04 details, size limit, modified state
|
||||
ackit extract Speech_TAPE_JOKER_1_04 orig.wav save the original (or --current)
|
||||
ackit replace Speech_TAPE_JOKER_1_04 line.wav encode + install a replacement
|
||||
ackit revert Speech_TAPE_JOKER_1_04 put the original back (--all: every sound and text)
|
||||
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
|
||||
|
||||
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 os
|
||||
@@ -36,7 +38,7 @@ def _workspace(a, archives=None):
|
||||
st = settings.load()
|
||||
game = _game(a)
|
||||
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,
|
||||
full_backup=not getattr(a, "no_full_backup", False) and st.get("full_backup", True),
|
||||
progress=lambda n, t: None)
|
||||
@@ -73,7 +75,8 @@ def cmd_list(a):
|
||||
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())]
|
||||
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:
|
||||
from .catalog import write_csv
|
||||
write_csv(a.csv, rows)
|
||||
@@ -178,6 +181,23 @@ def cmd_revert(a):
|
||||
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):
|
||||
ws = _workspace(a)
|
||||
keys = ws.modified_keys()
|
||||
@@ -286,7 +306,7 @@ def cmd_game(a):
|
||||
from .workspace import default_store_root
|
||||
print("game folder: ", g.root)
|
||||
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("settings file: ", settings.path())
|
||||
|
||||
@@ -311,7 +331,7 @@ def cmd_verify(a):
|
||||
ok = selfcheck.run(ws, log=print)
|
||||
sys.exit(0 if ok else 1)
|
||||
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 "
|
||||
"test %s in the game" % (ws.game.language(), a.lang))
|
||||
if a.banks:
|
||||
@@ -371,9 +391,12 @@ def build_parser():
|
||||
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_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")
|
||||
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_argument("action", choices=["find", "show", "set", "revert"])
|
||||
s.add_argument("args", nargs="*")
|
||||
|
||||
@@ -123,6 +123,14 @@ class Game:
|
||||
def languages(self):
|
||||
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):
|
||||
try:
|
||||
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"))
|
||||
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):
|
||||
"""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 f in ("UserEngine.ini", "BmEngine.ini"):
|
||||
p = os.path.join(d, f)
|
||||
@@ -163,7 +179,7 @@ class Game:
|
||||
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")
|
||||
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()
|
||||
if "INT" in self.archives:
|
||||
return "INT"
|
||||
|
||||
@@ -64,6 +64,21 @@ def parse(raw, lang, fname):
|
||||
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):
|
||||
"""New file bytes with {entry key: new value} applied (keys not in the file raise TextError)."""
|
||||
text, enc = _decode(raw)
|
||||
@@ -73,8 +88,7 @@ def write(raw, lang, fname, values):
|
||||
e = by_key.get(key)
|
||||
if e is None:
|
||||
raise TextError("no text %s in %s" % (key, fname))
|
||||
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)")
|
||||
check_value(value, enc, fname)
|
||||
line = lines[e.line]
|
||||
end = line[len(line.rstrip("\r\n")):]
|
||||
head = line[:line.index("=") + 1]
|
||||
|
||||
@@ -41,7 +41,7 @@ def export_mod(ws, path, keys, name="", author="", description="", texts=()):
|
||||
e = ws.store.get(key)
|
||||
if not e or not e.get("applied"):
|
||||
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"])
|
||||
x, sr = audio.decode_wem(wem)
|
||||
buf = io.BytesIO()
|
||||
|
||||
@@ -36,12 +36,33 @@ class SubtitleError(ValueError):
|
||||
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):
|
||||
"""-> (text, end offset, enc)"""
|
||||
n = struct.unpack_from("<i", b, o)[0]
|
||||
if n >= 0:
|
||||
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])
|
||||
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")
|
||||
if enc == "a":
|
||||
try:
|
||||
raw = text.encode("cp1252")
|
||||
raw = _encode_cp1252(text)
|
||||
except UnicodeEncodeError:
|
||||
raise SubtitleError("this subtitle is stored as Western European (Windows-1252) text; '%s' cannot be "
|
||||
"written"
|
||||
@@ -72,7 +93,7 @@ def encode_fstring(text, size, enc):
|
||||
|
||||
def _cp1252(c):
|
||||
try:
|
||||
c.encode("cp1252")
|
||||
_encode_cp1252(c)
|
||||
return True
|
||||
except UnicodeEncodeError:
|
||||
return False
|
||||
|
||||
131
ackit/texts.py
131
ackit/texts.py
@@ -5,31 +5,45 @@ Keys (also used in mod packages):
|
||||
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
|
||||
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 time
|
||||
|
||||
from . import loctext, subtitles
|
||||
from .catalog import load_subtitles
|
||||
from . import banks, loctext, subtitles
|
||||
from .catalog import _shipped_index, catalog_dir, load_subtitles
|
||||
from .game import GameError
|
||||
|
||||
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):
|
||||
def __init__(self, keys):
|
||||
self.keys = keys
|
||||
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])
|
||||
+ (" ..." if len(keys) > 5 else ""))
|
||||
"tool): %s.\nRevert them or confirm to overwrite."
|
||||
% (", ".join(keys[:5]) + (" ..." if len(keys) > 5 else "")))
|
||||
|
||||
|
||||
class Texts:
|
||||
def __init__(self, ws):
|
||||
self.ws = ws
|
||||
self._subs = {} # lang -> ([SubLine], {key: [SubLoc]})
|
||||
self._sub_text = {} # lang -> {(speech, line): original text} (catalog)
|
||||
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")
|
||||
|
||||
# --- languages and lists ---------------------------------------------------------------------
|
||||
@@ -37,7 +51,6 @@ class Texts:
|
||||
return loctext.languages(self.loc_root) if self.loc_root else []
|
||||
|
||||
def subtitle_languages(self):
|
||||
from .catalog import _shipped_index
|
||||
langs = sorted(_shipped_index().get("SUB", {}).get("lines", {}))
|
||||
return langs or self.menu_languages()
|
||||
|
||||
@@ -54,8 +67,11 @@ class Texts:
|
||||
|
||||
def for_sound(self, sound, lang=None):
|
||||
"""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."""
|
||||
lang = lang or (sound.languages[0] if sound.languages else sound.archive)
|
||||
their own voices link their subtitles to the INT sounds, so e.g. JPN subtitles are found for INT lines.
|
||||
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():
|
||||
return []
|
||||
return [s for s in self.subtitles(lang) if sound.key in s.sounds]
|
||||
@@ -77,20 +93,44 @@ class Texts:
|
||||
def modified(self):
|
||||
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):
|
||||
lang = key.split(":")[1]
|
||||
raw = key[len("MENU:"):]
|
||||
for e in loctext.load(self.loc_root, lang):
|
||||
if e.key == raw:
|
||||
return e
|
||||
raise GameError("no menu text %s" % raw)
|
||||
path = self._menu_path(key)
|
||||
e = self._menu_entries(path, key.split(":")[1])[1].get(key[len("MENU:"):])
|
||||
if e is None:
|
||||
raise GameError("no menu text %s" % key[len("MENU:"):])
|
||||
return e
|
||||
|
||||
def files(self, key):
|
||||
"""Absolute paths of the files holding a text."""
|
||||
if key.startswith("SUB:"):
|
||||
return sorted({self.ws.bank_path(l.file) for l in self.subtitle_locations(key)})
|
||||
e = self._menu_entry(key)
|
||||
return [loctext.files(self.loc_root, e.lang)[e.file]]
|
||||
self._menu_entry(key)
|
||||
return [self._menu_path(key)]
|
||||
|
||||
def current(self, key):
|
||||
"""The text in the game files now."""
|
||||
@@ -98,22 +138,20 @@ class Texts:
|
||||
locs = self.subtitle_locations(key)
|
||||
if not locs:
|
||||
raise GameError("no subtitle %s" % key)
|
||||
from .banks import open_file
|
||||
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 self._menu_entry(key).value
|
||||
|
||||
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:"):
|
||||
return None
|
||||
_, lang, rest = key.split(":", 2)
|
||||
speech, line = rest.rsplit(":", 1)
|
||||
for s in self.subtitles(lang):
|
||||
if s.speech == speech and s.line == int(line):
|
||||
return s.text
|
||||
return None
|
||||
if lang not in self._sub_text:
|
||||
self._sub_text[lang] = {(s.speech, s.line): s.text for s in self.subtitles(lang)}
|
||||
return self._sub_text[lang].get((speech, int(line)))
|
||||
|
||||
def original(self, key):
|
||||
r = self.records().get(key)
|
||||
@@ -141,24 +179,54 @@ class Texts:
|
||||
for l in self.subtitle_locations(key)), default=0)
|
||||
|
||||
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:"):
|
||||
for l in self.subtitle_locations(key):
|
||||
subtitles.encode_fstring(text, l.size, l.enc)
|
||||
else:
|
||||
e = self._menu_entry(key)
|
||||
path = loctext.files(self.loc_root, e.lang)[e.file]
|
||||
loctext.write(open(path, "rb").read(), e.lang, e.file, {e.key: text})
|
||||
path = self._menu_path(key)
|
||||
self._menu_entry(key)
|
||||
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 --------------------------------------------------------------------------------
|
||||
@_locked
|
||||
def apply(self, changes, force=False, progress=None, meta=None):
|
||||
"""changes: {key: new text}. Every file holding one of them is rebuilt once."""
|
||||
for k, v in changes.items():
|
||||
self.check(k, v)
|
||||
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)}
|
||||
changed = set(self.ws.changed_files(paths))
|
||||
conflicts += [k for k in changes if k not in conflicts and set(self.files(k)) & changed]
|
||||
files = {k: self.files(k) for k in changes}
|
||||
paths = {p for ps in files.values() for p in ps}
|
||||
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:
|
||||
raise TextConflictError(conflicts)
|
||||
for p in paths:
|
||||
@@ -167,12 +235,13 @@ class Texts:
|
||||
now = time.time()
|
||||
for k, v in changes.items():
|
||||
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}
|
||||
recs[k].update(applied=v, applied_at=now, meta=meta or {})
|
||||
self.ws.store.save()
|
||||
self._sync(paths, progress)
|
||||
|
||||
@_locked
|
||||
def revert(self, keys, progress=None):
|
||||
recs = self.records()
|
||||
keys = [k for k in keys if k in recs]
|
||||
|
||||
17
ackit/upk.py
17
ackit/upk.py
@@ -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.
|
||||
"""
|
||||
import struct
|
||||
import threading
|
||||
from collections import OrderedDict, namedtuple
|
||||
|
||||
from . import lzo
|
||||
@@ -41,6 +42,7 @@ class Package:
|
||||
self.file_size = f.seek(0, 2)
|
||||
self._cache = OrderedDict()
|
||||
self._cache_n = cache_blocks
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# summary -----------------------------------------------------------------------------------
|
||||
def _parse_summary(self, b, f):
|
||||
@@ -157,9 +159,11 @@ class Package:
|
||||
|
||||
# reading -----------------------------------------------------------------------------------
|
||||
def _block(self, i, f=None):
|
||||
if i in self._cache:
|
||||
self._cache.move_to_end(i)
|
||||
return self._cache[i]
|
||||
with self._lock: # the editor reads one package from several background jobs
|
||||
data = self._cache.get(i)
|
||||
if data is not None:
|
||||
self._cache.move_to_end(i)
|
||||
return data
|
||||
bl = self.blocks[i]
|
||||
own = f is None
|
||||
f = f or open(self.path, "rb")
|
||||
@@ -169,9 +173,10 @@ class Package:
|
||||
finally:
|
||||
if own:
|
||||
f.close()
|
||||
self._cache[i] = data
|
||||
if len(self._cache) > self._cache_n:
|
||||
self._cache.popitem(last=False)
|
||||
with self._lock:
|
||||
self._cache[i] = data
|
||||
if len(self._cache) > self._cache_n:
|
||||
self._cache.popitem(last=False)
|
||||
return data
|
||||
|
||||
def _block_index(self, off):
|
||||
|
||||
@@ -399,8 +399,9 @@ def check_speakers(rep, rows, col):
|
||||
def check_menu(rep, game):
|
||||
"""Menu texts: the same keys in every language (facts; differences are the game's own)."""
|
||||
from .. import loctext
|
||||
root = os.path.join(game.root, "BmGame", "Localization")
|
||||
if not os.path.isdir(root):
|
||||
from ..texts import _find_dir
|
||||
root = _find_dir(game.root, "BmGame", "Localization") # case-insensitive (Linux)
|
||||
if not root:
|
||||
return
|
||||
keys = {}
|
||||
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_speakers(rep, rows_all, col)
|
||||
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()}
|
||||
# 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):
|
||||
|
||||
@@ -104,9 +104,14 @@ def running_game_pids():
|
||||
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():
|
||||
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=()):
|
||||
|
||||
@@ -65,7 +65,9 @@ def run(ws, sample=300, log=print):
|
||||
if n >= sample // max(1, len(ws.game.archives)):
|
||||
break
|
||||
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"]
|
||||
if bank:
|
||||
n = good = 0
|
||||
|
||||
@@ -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
|
||||
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
|
||||
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 os
|
||||
import threading
|
||||
from collections import defaultdict
|
||||
|
||||
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 .game import Game, GameError
|
||||
from .inject import inject, read_target, parse_target, pack_bnk_path, SizeLimitError
|
||||
@@ -41,25 +46,50 @@ class ConflictError(GameError):
|
||||
self.sounds = sounds
|
||||
super().__init__("These sounds were changed outside this tool (Steam update or 'verify', or "
|
||||
"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):
|
||||
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:
|
||||
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.store = OriginalsStore(store_root or default_store_root(self.game), self.game.root)
|
||||
self.catalog = Catalog(self.game, archives, progress)
|
||||
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)
|
||||
|
||||
# reading -----------------------------------------------------------------------------------
|
||||
def sound(self, 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):
|
||||
return self.game.wad(s.archive)
|
||||
|
||||
@@ -241,6 +271,55 @@ class Workspace:
|
||||
out.append(path)
|
||||
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):
|
||||
"""[(offset, bytes)] that the sound replacements applied now need in bank file `rel` (cooked-relative)."""
|
||||
out = []
|
||||
@@ -271,6 +350,7 @@ class Workspace:
|
||||
edits = self._audio_edits(rel) + self.texts.subtitle_edits(rel)
|
||||
return banks.open_file(orig).patched(edits) if edits else open(orig, "rb").read()
|
||||
|
||||
@locked
|
||||
def sync_files(self, paths, progress=None):
|
||||
"""Rebuild each derived file from its original copy and the edits applied now (atomic writes)."""
|
||||
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.sync_files(files, progress)
|
||||
|
||||
@locked
|
||||
def apply(self, changes, force=False, progress=None):
|
||||
"""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)."""
|
||||
@@ -326,7 +407,8 @@ class Workspace:
|
||||
for s, _, _ in changes:
|
||||
if self.state(s) in (CHANGED, FOREIGN):
|
||||
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
|
||||
if s not in conflicts and {self.bank_path(l.file) for l in self.locations(s)} & changed]
|
||||
if conflicts and not force:
|
||||
@@ -337,6 +419,7 @@ class Workspace:
|
||||
for a, items in by_arch.items():
|
||||
self._write(a, items, progress)
|
||||
|
||||
@locked
|
||||
def revert(self, sounds, progress=None):
|
||||
"""Put the stored originals back (sounds without a record are skipped). Returns reverted."""
|
||||
by_arch = defaultdict(list)
|
||||
@@ -354,31 +437,50 @@ class Workspace:
|
||||
self.store.gc()
|
||||
return done
|
||||
|
||||
@locked
|
||||
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]
|
||||
missing = [k for k in self.store.entries if k not in self.catalog.by_key]
|
||||
if missing:
|
||||
# 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]
|
||||
"""Revert every sound this store has changed (texts: Texts.revert_all)."""
|
||||
# records of archives not loaded in this catalog (e.g. another language) included
|
||||
sounds = [s for s in map(self.sound_for_key, list(self.store.entries)) if s]
|
||||
return self.revert(sounds, progress)
|
||||
|
||||
def restore_from_full_backup(self):
|
||||
"""Last-resort fallback: copy back every full backup taken by this store."""
|
||||
@locked
|
||||
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 = []
|
||||
for rel in self.store.data.get("full", {}):
|
||||
src = self.store.full_copy(rel)
|
||||
for rel in full:
|
||||
if rel in derived:
|
||||
continue
|
||||
dst = os.path.join(self.game.root, rel)
|
||||
self.game.check_writable(dst)
|
||||
tmp = dst + ".ackit-tmp"
|
||||
import shutil
|
||||
shutil.copy2(src, tmp)
|
||||
shutil.copy2(self.store.full_copy(rel), tmp)
|
||||
os.replace(tmp, dst)
|
||||
out.append(rel)
|
||||
for rec in self._derived().values():
|
||||
rec["written_fp"] = None
|
||||
entries = self.store.data["entries"]
|
||||
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["entries"] = {}
|
||||
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()
|
||||
return out
|
||||
return sorted(out + [rel for rel in derived if rel in full])
|
||||
|
||||
@@ -163,7 +163,8 @@ class Recorder(QObject):
|
||||
self.chunks = []
|
||||
self.rate = 48000
|
||||
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
|
||||
|
||||
def set_device(self, device):
|
||||
@@ -173,7 +174,7 @@ class Recorder(QObject):
|
||||
return self.source is not None or self._sim is not None
|
||||
|
||||
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 (
|
||||
QMediaDevices.defaultAudioInput() if audio_enabled() else None)
|
||||
if dev is None or dev.isNull():
|
||||
@@ -186,7 +187,8 @@ class Recorder(QObject):
|
||||
if not dev.isFormatSupported(fmt):
|
||||
fmt = dev.preferredFormat()
|
||||
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.io = self.source.start()
|
||||
if self.io is None:
|
||||
@@ -202,13 +204,20 @@ class Recorder(QObject):
|
||||
self._ingest(bytes(self.io.readAll()))
|
||||
|
||||
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:
|
||||
return
|
||||
if self.fmt_float:
|
||||
x = np.frombuffer(raw, dtype="<f4")
|
||||
else:
|
||||
x = np.frombuffer(raw[: len(raw) // 2 * 2], dtype="<i2").astype(np.float32) / 32768.0
|
||||
x = x[: len(x) // self.channels * self.channels].reshape(-1, self.channels)
|
||||
x = np.frombuffer(raw, dtype=self.sample)
|
||||
if self.sample == "<i2":
|
||||
x = x.astype(np.float32) / 32768.0
|
||||
elif self.sample == "<i4":
|
||||
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.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))
|
||||
@@ -216,8 +225,8 @@ class Recorder(QObject):
|
||||
# simulated input (tests / no microphone) ----------------------------------------------------
|
||||
def start_simulated(self, x, rate, realtime=True):
|
||||
"""Feed `x` as if it came from a microphone (20 ms blocks)."""
|
||||
self.chunks = []
|
||||
self.rate, self.channels, self.fmt_float = rate, x.shape[1], True
|
||||
self.chunks, self._rest = [], b""
|
||||
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)]
|
||||
if not realtime:
|
||||
for b in blocks:
|
||||
|
||||
@@ -100,7 +100,9 @@ class EditorPanel(QWidget):
|
||||
gl.setVerticalSpacing(4)
|
||||
self.wf_orig = self._wave("orig")
|
||||
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_stop = self._btn("Stop", "stop", self.stop, "Stop (Esc)")
|
||||
# playback options next to the volume: icons with tooltips
|
||||
|
||||
@@ -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
|
||||
(the language itself, or INT for languages the game has no voices for)."""
|
||||
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, voices
|
||||
return lang, game.voice_archive(lang)
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
@@ -100,7 +99,7 @@ class MainWindow(QMainWindow):
|
||||
tb.addWidget(self.lbl_voices)
|
||||
tb.addSeparator()
|
||||
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("import"), "Import mod…", self.import_mod_dialog)
|
||||
tb.addSeparator()
|
||||
@@ -528,35 +527,52 @@ class MainWindow(QMainWindow):
|
||||
"Reverted %d sound(s)." % len(sounds))
|
||||
|
||||
def revert_all(self):
|
||||
"""Every change of this store: sounds, subtitles and menu texts (every language)."""
|
||||
if not self.ws:
|
||||
return
|
||||
n = len(self.ws.store.entries)
|
||||
if not n:
|
||||
self.inform("Revert all", "All sounds are already original.")
|
||||
n, t = len(self.ws.store.entries), len(self.ws.texts.records())
|
||||
if not n and not t:
|
||||
self.inform("Revert all", "All sounds and texts are already original.")
|
||||
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
|
||||
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.spinner.show()
|
||||
|
||||
def done(_):
|
||||
self.spinner.hide()
|
||||
self.statusBar().showMessage(ok_msg, 8000)
|
||||
self._after_change()
|
||||
self._texts_changed()
|
||||
if self.editor.sound:
|
||||
self.editor.set_sound(self.editor.sound)
|
||||
self._refresh_after_change()
|
||||
self.check_problems()
|
||||
|
||||
def failed(e, tb):
|
||||
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.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 -----------------------------------------------------------------------
|
||||
def check_problems(self):
|
||||
@@ -628,12 +644,8 @@ class MainWindow(QMainWindow):
|
||||
def forget_problems(self):
|
||||
if not self.confirm("Forget", "Stop tracking these changes? The current game files are kept as they are."):
|
||||
return
|
||||
for s, _ in getattr(self, "problems", []):
|
||||
self.ws.store.forget(s.key)
|
||||
for k, _ in getattr(self, "text_problems", []):
|
||||
self.ws.texts.records().pop(k, None)
|
||||
self.ws.store.save()
|
||||
self.ws.store.gc()
|
||||
self.ws.forget([s.key for s, _ in getattr(self, "problems", [])],
|
||||
[k for k, _ in getattr(self, "text_problems", [])])
|
||||
self.banner.hide()
|
||||
self._after_change()
|
||||
|
||||
@@ -680,25 +692,22 @@ class MainWindow(QMainWindow):
|
||||
if dlg and dlg.exec() != QDialog.DialogCode.Accepted:
|
||||
return
|
||||
ws = self.ws
|
||||
prepared = []
|
||||
|
||||
def work(job):
|
||||
prepare_import(ws, path, items, progress=job.report)
|
||||
def work(job, force):
|
||||
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)}
|
||||
changes = [(im.sound, im.wem, source) for im in items if im.wem]
|
||||
if changes:
|
||||
try:
|
||||
ws.apply(changes, progress=job.report)
|
||||
except W.ConflictError:
|
||||
ws.apply(changes, force=True, progress=job.report)
|
||||
ws.apply(changes, force=force, progress=job.report)
|
||||
texts = {k: v for k, v, problem in match_texts(ws, man) if not problem}
|
||||
if texts:
|
||||
try:
|
||||
ws.texts.apply(texts, progress=job.report, meta=source)
|
||||
except TextConflictError:
|
||||
ws.texts.apply(texts, force=True, progress=job.report, meta=source)
|
||||
ws.texts.apply(texts, force=force, progress=job.report, meta=source)
|
||||
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 ---------------------------------------------------------------------------------
|
||||
def settings_dialog(self):
|
||||
@@ -706,7 +715,16 @@ class MainWindow(QMainWindow):
|
||||
if dlg.exec() != QDialog.DialogCode.Accepted:
|
||||
return
|
||||
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._apply_output_device()
|
||||
self.sidebar.configure(self.settings)
|
||||
|
||||
@@ -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.
|
||||
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`.
|
||||
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`.
|
||||
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).
|
||||
|
||||
@@ -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_18,0,"자, 네 과거를 사람들에게 알리고 싶지 않다면 남의 과거를 캐묻고 다니는 일은 그만둬.",48,w,INT:3F21770A:12
|
||||
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_02,0,생각해 봐. 심심하면 언제든 통화 할 수 있어. 우리 사인 한층 각별해지고 있지. 이젠 이메일을 주고받거나 낭만적인 저녁 식사를 하는 일만 남았어.,82,w,INT:4709E5BE:1
|
||||
Speech_TAPE_J_Answer_2_01,0,한 통의 부재중 전화가 있습니다.,18,w,INT:4709E5BD:1
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -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_18,0,"Вот тебе совет. Если не хочешь, чтобы кто-то узнал о твоем прошлом, перестань совать нос в мое.",95,w,INT:3F21770A:12
|
||||
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_02,0,"Только представь - я смогу позвонить тебе сразу, как только заскучаю! Мы так близки друг другу.",95,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.
|
@@ -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
|
||||
`~/.local/share/unheardcity`), id = hash of the game path; configurable (`--store`, editor settings).
|
||||
Before a sound is first changed, its original WEM bytes (SHA-256-named blob) and metadata are saved;
|
||||
the applied replacement is saved too. **Revert** re-injects the stored original; **Revert all** does this for
|
||||
every record. Stream-pack padding bytes are stored so pack reverts are byte-identical.
|
||||
the applied replacement is saved too. **Revert** re-injects the stored original; **Revert all** (editor toolbar,
|
||||
`ackit revert --all`) does this for every record of the store, sounds and texts, in every language. Stream-pack
|
||||
padding bytes are stored so pack reverts are byte-identical. The uninstaller counts sound and text records and
|
||||
does not offer to delete the store while any is left.
|
||||
- **One store per game at a time:** the changes are recorded only in the store that made them. The editor
|
||||
refuses to switch to another store folder while changes are applied (Revert all first). A store takes the
|
||||
full copy of a soundbank or package only from a file that matches the shipped catalog (every in-memory
|
||||
sound's checksum, every bank copy of a voice recording, every subtitle; `Workspace.unoriginal_files`), so a
|
||||
second store (`--store`) or another tool's change is a conflict, never a new "original". (Menu files and other
|
||||
game builds have no catalog checksums to check.)
|
||||
- **Full-file fallback:** before the first change to a WAD (and its pack index), one verified full copy is kept
|
||||
in the store (`full/`), default on, can be disabled. 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).
|
||||
- **Why the user data folder:** it survives app updates and reinstalls, needs no write access next to the game,
|
||||
and works the same on Linux/Proton. Steam never touches it.
|
||||
- **Safe writes:** every WAD is rebuilt into a temp file and atomically renamed over the original, pack indexes
|
||||
first. Several sounds of one WAD are written in one pass. A locked WAD (game running) gives a clear message.
|
||||
Writes are serialized (`Workspace.lock`): the editor runs them as background jobs, and two rebuilds of one
|
||||
package at once (e.g. a sound and its subtitle applied side by side) would otherwise lose one edit.
|
||||
- **Changed underneath:** each sound's state is computed from its bytes: original / modified (ours) / lost
|
||||
(ours was replaced by the original, e.g. Steam "verify") / changed (neither: update or another tool) /
|
||||
foreign (never touched by us, but not the catalog's original checksum). Writing over changed/foreign sounds
|
||||
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
|
||||
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 +
|
||||
replacements applied now", so reverting the last one restores the original bytes exactly, and an interrupted
|
||||
operation is repaired by the next one. Conflicts: the whole-file SHA-1 must be the original's or the last one
|
||||
ackit wrote.
|
||||
ackit wrote, and before the copy is taken the file must match the catalog (section 3).
|
||||
- **Prefetch heads and whole copies:** 396 voice recordings (81 lines, 78-80 recordings per language) have
|
||||
their first bytes copied into banks, and 49 more are stored whole in banks (same media ID, identical bytes);
|
||||
a replacement of such a recording rewrites those copies too, otherwise the game could play the old line.
|
||||
@@ -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
|
||||
the name of their English (INT) recording's position. The position in each language stays an alias (`NAME#i`).
|
||||
- A recording stored twice in a WAD (5 per language) is written and reverted in both places.
|
||||
- The catalog records the ORIGINAL bytes: `tools/build_catalog.py` stops before writing anything if a sound
|
||||
differs from the current catalog (a modified game), unless `--accept-changes` (a game update).
|
||||
- The catalog records the ORIGINAL bytes and texts: `tools/build_catalog.py` stops before writing anything if a
|
||||
sound or a subtitle differs from the current catalog (a modified game), unless `--accept-changes` (a game
|
||||
update).
|
||||
- Category scheme: `docs/categories.md`.
|
||||
|
||||
## 6. Repository cleanup choices
|
||||
|
||||
@@ -57,7 +57,7 @@ WAD sounds have such heads (2,101 copies); replacing one of them must rewrite th
|
||||
story lines) are
|
||||
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
|
||||
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`).
|
||||
|
||||
## Localized banks
|
||||
|
||||
@@ -14,6 +14,10 @@ the UE3 packages (`upk.md`), holding the subtitle text of all 11 languages. acki
|
||||
to 5 lines.
|
||||
- Strings are stored as cp1252 (positive FString length) or UTF-16 (negative length): almost all RUS, JPN and
|
||||
KOR strings and 85 % of POL are UTF-16; the other languages have 17-56 UTF-16 strings each.
|
||||
4 cp1252 strings hold UTF-8 bytes instead (the same line in KOR, POL, POR, RUS: `Speech_TAPE_J_Answer_1_00`;
|
||||
the KOR one with a damaged byte; the game's own data). ackit reads cp1252 as Windows maps it (every byte, also
|
||||
the 5 that Python's cp1252 leaves undefined, which the RUS and KOR ones contain), so they are shown as stored
|
||||
and every string is written back byte-for-byte.
|
||||
|
||||
## Scripted subtitles (`RSubtitle`)
|
||||
41 more subtitles (radio and encryption-card lines) are `RSubtitle` objects in `Startup.upk`: the same layout,
|
||||
|
||||
@@ -58,8 +58,9 @@ the same line and are changed and reverted together.
|
||||
|
||||
## Listen
|
||||
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
|
||||
play the original. Each column ends with its own buttons on one line. In **Listen**:
|
||||
resize them): **Listen**, **Replace** and **Subtitle**. Select a row and press **Space** to play the original
|
||||
(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).
|
||||
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),
|
||||
@@ -82,14 +83,22 @@ than the original, since the game may cut it off.
|
||||
|
||||
## Undo
|
||||
- **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,
|
||||
`~/.local/share/unheardcity` on Linux), so reverting works after updating or reinstalling the app too.
|
||||
Uninstalling keeps this folder; use **Revert all** before uninstalling if you want the game back to original.
|
||||
Before the first change to 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
|
||||
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
|
||||
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
|
||||
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.
|
||||
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
|
||||
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
|
||||
@@ -131,7 +141,8 @@ Click the **★** column to mark favorites. These are stored in your user data f
|
||||
|
||||
## Settings
|
||||

|
||||
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
|
||||
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.
|
||||
|
||||
@@ -11,8 +11,14 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
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"))
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -223,6 +223,60 @@ def test_mod_with_texts(win, tmp_path):
|
||||
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):
|
||||
w, root, _ = win
|
||||
tx = w.ws.texts
|
||||
@@ -387,3 +441,18 @@ def test_editor_panel_fits_its_contents(win):
|
||||
need = w.editor.sizeHint().height()
|
||||
assert w.scroll.viewport().height() >= need - 2, (w.scroll.viewport().height(), need)
|
||||
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
|
||||
|
||||
@@ -180,3 +180,19 @@ def test_every_name_resolves_to_its_sound(real_game_dir):
|
||||
for lang in g.languages:
|
||||
cat = Catalog(g, [lang, "SFX", "BANK"])
|
||||
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"]
|
||||
|
||||
@@ -35,6 +35,26 @@ def test_cli_workflow(synth_game, tmp_path, capsys):
|
||||
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):
|
||||
root, _ = synth_game
|
||||
with pytest.raises(SystemExit) as e:
|
||||
|
||||
@@ -59,6 +59,13 @@ def test_audio_helpers():
|
||||
assert audio.to_channels(x, 1).shape == (48000, 1)
|
||||
assert audio.to_channels(x[:, :1], 6).shape == (48000, 6)
|
||||
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.resample(x, 48000, 24000).shape[0] == 24000
|
||||
|
||||
|
||||
@@ -46,3 +46,17 @@ def test_not_a_game_folder(tmp_path):
|
||||
import pytest
|
||||
with pytest.raises(G.GameError):
|
||||
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
|
||||
|
||||
@@ -40,7 +40,7 @@ def test_stream_packs_noop_identical(real_game_dir):
|
||||
|
||||
def test_builder_reproduces_game_wems(real_game_dir):
|
||||
g = Game(real_game_dir)
|
||||
lang = g.language()
|
||||
lang = g.voice_archive(g.language())
|
||||
w = Wad(g.wad(lang))
|
||||
rnd = random.Random(5)
|
||||
n = 0
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Subtitles (dialogue objects in packages) and menu text (localization files): parse, apply, revert."""
|
||||
import hashlib
|
||||
import struct
|
||||
import os
|
||||
|
||||
import pytest
|
||||
@@ -36,6 +37,10 @@ def test_fstring_encoding():
|
||||
subtitles.encode_fstring("too long", 10, "a")
|
||||
with pytest.raises(subtitles.SubtitleError):
|
||||
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():
|
||||
@@ -51,6 +56,15 @@ def test_menu_parse_and_write():
|
||||
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 ------------------------------------------------------------
|
||||
def test_subtitle_catalog_and_link(synth_game, tmp_path):
|
||||
root, _ = synth_game
|
||||
|
||||
@@ -119,6 +119,101 @@ def test_full_backup_taken_once(synth_game):
|
||||
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):
|
||||
"""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."""
|
||||
|
||||
@@ -12,7 +12,8 @@ Outputs
|
||||
data/catalog/SUB_locations.csv.gz every stored copy of each subtitle string
|
||||
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
|
||||
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 collections
|
||||
@@ -88,9 +89,16 @@ def build_characters(game, src_root, final):
|
||||
return rows, unmatched, ambiguous, skipped
|
||||
|
||||
|
||||
def changed_since_catalog(game, out_dir, bank_snd):
|
||||
"""Keys whose original checksum in the existing catalog differs from the game files now."""
|
||||
def changed_since_catalog(game, out_dir, bank_snd, texts=None):
|
||||
"""Keys whose original in the existing catalog differs from the game files now: sounds (checksums) and
|
||||
subtitles (texts: {lang: [SubLine]} scanned now)."""
|
||||
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():
|
||||
p = os.path.join(out_dir, "%s.csv" % arch)
|
||||
if not os.path.exists(p):
|
||||
@@ -133,11 +141,12 @@ def main():
|
||||
for k, d in lines.issues[:10]:
|
||||
print(" issue:", k, d)
|
||||
|
||||
# safety: the catalog records ORIGINAL bytes. A sound that differs from the current catalog means a modified
|
||||
# game (an applied change, another tool) unless the game itself was updated -> stop before writing anything.
|
||||
changed = changed_since_catalog(game, out_dir, bank_snd)
|
||||
# safety: the catalog records ORIGINAL bytes and texts. A sound or subtitle that differs from the current
|
||||
# catalog means a modified game (an applied change, another tool) unless the game itself was updated -> stop
|
||||
# before writing anything.
|
||||
changed = changed_since_catalog(game, out_dir, bank_snd, texts)
|
||||
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 "
|
||||
"--accept-changes.")
|
||||
sys.exit(2)
|
||||
@@ -193,7 +202,7 @@ def main():
|
||||
"fingerprint": banks.game_fingerprint(game.cooked), "sounds": len(sounds), "groups": dict(g),
|
||||
"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),
|
||||
# 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),
|
||||
"unmapped": len({l.media for l in heads if not l.sound})},
|
||||
"issues": ["%s: %s" % i for i in sc.issues]}
|
||||
|
||||
@@ -240,6 +240,16 @@ def catalog_claims(cat):
|
||||
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"
|
||||
% (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:
|
||||
sublocs = sum(1 for _ in csv.DictReader(f))
|
||||
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))
|
||||
|
||||
|
||||
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():
|
||||
import subprocess
|
||||
out = subprocess.run([sys.executable, "-m", "pytest", "--collect-only", "-q", "-p", "no:cacheprovider"],
|
||||
@@ -535,6 +560,7 @@ def main():
|
||||
if not game:
|
||||
raise SystemExit("--scan needs the game")
|
||||
scan_claims(game)
|
||||
originality_claims(game)
|
||||
if a.wems:
|
||||
if not game:
|
||||
raise SystemExit("--wems needs the game")
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
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;
|
||||
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
|
||||
"""
|
||||
@@ -29,11 +31,15 @@ def sha(p):
|
||||
|
||||
def main():
|
||||
from PySide6.QtWidgets import QApplication
|
||||
from ackit import settings as settings_mod
|
||||
from app.mainwindow import MainWindow
|
||||
from app.workers import Runner
|
||||
|
||||
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.editor.confirm = lambda *a: True
|
||||
errors = []
|
||||
|
||||
@@ -14,10 +14,14 @@ $Data = @((Join-Path $env:APPDATA "UnheardCity"), (Join-Path $env:LOCALAPPDATA "
|
||||
function Say($msg) { if (-not $Quiet) { Write-Host $msg } }
|
||||
|
||||
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
|
||||
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
|
||||
}
|
||||
@@ -26,8 +30,8 @@ try {
|
||||
if (Get-Process -Name "UnheardCity" -ErrorAction SilentlyContinue) { throw "$Name is running. Close it first." }
|
||||
$mods = Modified-Count
|
||||
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 "'Revert all' BEFORE uninstalling (your originals store is kept either way)."
|
||||
Write-Host "$mods sound(s) or text(s) are still modified in the game. To restore the originals, open $Name and"
|
||||
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 (-not $Quiet -and -not $RemoveUserData -and $mods -eq 0 -and (Test-Path $Data[0])) {
|
||||
|
||||
Reference in New Issue
Block a user