Full correctness check against the docs: safer writes, reverts and texts

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

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

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

Docs (decisions, user guide, README, subtitles.md, banks.md, catalog README,
REPORT, NOTES, CLAUDE.md) updated; check_docs: 102 claims match. 148 tests;
e2e_features 9/9, e2e_editor and verify PASS; game original afterwards.
This commit is contained in:
2026-10-13 21:34:13 +02:00
parent a6ef3b9458
commit 1ccaa8b398
41 changed files with 846 additions and 186 deletions

View File

@@ -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):

View File

@@ -4,10 +4,12 @@ in the .upk packages (docs/formats/banks.md, upk.md). Catalog archive `BANK`, ta
A media ID can be stored many times (the same bank copied into several packages, or several banks
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

View File

@@ -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:

View File

@@ -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="*")

View File

@@ -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"

View File

@@ -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]

View File

@@ -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()

View File

@@ -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

View File

@@ -5,31 +5,45 @@ Keys (also used in mod packages):
MENU:<LANG>:<File>:<Section>:<Key>[#n] a menu/UI text (docs/formats/text.md)
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]

View File

@@ -8,6 +8,7 @@ Patching rewrites only the blocks that contain changed bytes (stored as one LZO
lzo.compress_stored) and moves the chunks after them; every other byte of the file stays identical.
"""
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):

View File

@@ -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):

View File

@@ -104,9 +104,14 @@ def running_game_pids():
return [int(m.group(1)) for m in re.finditer(r'"BatmanAC\.exe","(\d+)"', out)]
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=()):

View File

@@ -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

View File

@@ -10,14 +10,19 @@ In-memory sounds (archive BANK) and the prefetch heads of speech lines live in l
packages (ackit/banks.py); a replacement fills each in-memory slot exactly (wem.fill). Before the first
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])