Files
UnheardCity/ackit/texts.py
Cristian Gora 1ccaa8b398 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.
2026-10-13 21:34:13 +02:00

293 lines
13 KiB
Python

"""Subtitles and menu text: reading, states, apply and revert (shared by the CLI and the editor).
Keys (also used in mod packages):
SUB:<LANG>:<speech event>:<line> a subtitle string (docs/formats/subtitles.md)
MENU:<LANG>:<File>:<Section>:<Key>[#n] a menu/UI text (docs/formats/text.md)
Records live in the originals store (data["texts"]): {key: {"orig", "applied", "files", ...}}. The files
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. Writes hold the
workspace's lock (Workspace.lock).
"""
import os
import time
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 "")))
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 ---------------------------------------------------------------------
def menu_languages(self):
return loctext.languages(self.loc_root) if self.loc_root else []
def subtitle_languages(self):
langs = sorted(_shipped_index().get("SUB", {}).get("lines", {}))
return langs or self.menu_languages()
def subtitles(self, lang, progress=None):
"""[SubLine] of a language (original texts from the catalog; use `current` for the game's text)."""
if lang not in self._subs:
self._subs[lang] = load_subtitles(self.ws.game, lang, progress)
return self._subs[lang][0]
def subtitle_locations(self, key):
lang = key.split(":")[1]
self.subtitles(lang)
return self._subs[lang][1].get(key, [])
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.
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]
def menu(self, lang):
"""[loctext.Entry] of the game's current files (the values the game shows now)."""
if lang not in self._menu:
self._menu[lang] = loctext.load(self.loc_root, lang) if self.loc_root else []
return self._menu[lang]
def is_menu_file(self, path):
return bool(self.loc_root) and os.path.normcase(os.path.abspath(path)).startswith(
os.path.normcase(os.path.abspath(self.loc_root)) + os.sep)
# --- values and states ----------------------------------------------------------------------
def records(self):
return self.ws.store.data.setdefault("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):
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)})
self._menu_entry(key)
return [self._menu_path(key)]
def current(self, key):
"""The text in the game files now."""
if key.startswith("SUB:"):
locs = self.subtitle_locations(key)
if not locs:
raise GameError("no subtitle %s" % key)
l = locs[0]
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 catalog (subtitles only), or None."""
if not key.startswith("SUB:"):
return None
_, lang, rest = key.split(":", 2)
speech, line = rest.rsplit(":", 1)
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)
return r["orig"] if r else self.current(key)
def state(self, key, current=None):
cur = self.current(key) if current is None else current
r = self.records().get(key)
if r:
if r.get("applied") is not None and cur == r["applied"]:
return MODIFIED
if cur == r["orig"]:
return LOST if r.get("applied") is not None else ORIGINAL
return CHANGED
cat = self.catalog_text(key)
if cat is not None and cur != cat:
return FOREIGN
return ORIGINAL
def limit(self, key):
"""Maximum characters for a subtitle (None = no limit: menu text)."""
if not key.startswith("SUB:"):
return None
return min((subtitles.room(subtitles.Field(l.lang, l.line, "", l.pos, l.size, l.enc))
for l in self.subtitle_locations(key)), default=0)
def check(self, key, text):
"""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:
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)]
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:
self.ws._keep_original(p)
recs = self.records()
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 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]
paths = {p for k in keys for p in self.files(k)}
for k in keys:
recs.pop(k)
self.ws.store.save()
self._sync(paths, progress)
return keys
def revert_all(self, progress=None):
return self.revert(list(self.records()), progress)
def _sync(self, paths, progress):
self._menu.clear()
self.ws.sync_files(paths, progress)
# --- derived-file hooks (called by Workspace.sync_files) ------------------------------------
def subtitle_edits(self, rel):
out = []
for k, r in self.records().items():
if r.get("applied") is None or not k.startswith("SUB:"):
continue
for l in self.subtitle_locations(k):
if l.file == rel:
out.append((l.pos, subtitles.encode_fstring(r["applied"], l.size, l.enc)))
return out
def menu_content(self, path, orig_raw):
fname = os.path.basename(path)
lang = os.path.basename(os.path.dirname(path)).upper()
values = {}
for k, r in self.records().items():
if r.get("applied") is None or not k.startswith("MENU:"):
continue
_, lg, f, _rest = k.split(":", 3)
if lg == lang and f == fname:
values[k[len("MENU:"):]] = r["applied"]
return loctext.write(orig_raw, lang, fname, values) if values else orig_raw
def _find_dir(root, *parts):
d = root
for p in parts:
if not d or not os.path.isdir(d):
return None
d = next((os.path.join(d, x) for x in os.listdir(d) if x.lower() == p.lower()), None)
return d if d and os.path.isdir(d) else None