Edit subtitles and menu text

This commit is contained in:
2026-10-09 22:36:46 +02:00
parent 5d6c2447f7
commit ef68edbe45
40 changed files with 105877 additions and 1291 deletions

222
ackit/texts.py Normal file
View File

@@ -0,0 +1,222 @@
"""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.
"""
import os
import time
from . import loctext, subtitles
from .catalog import load_subtitles
from .game import GameError
ORIGINAL, MODIFIED, LOST, CHANGED, FOREIGN = "original", "modified", "lost", "changed", "foreign"
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._menu = {} # lang -> [Entry]
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):
from .catalog import _shipped_index
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):
"""Subtitle lines of a speech sound in its own language (or the INT line for other languages)."""
lang = sound.archive if sound.archive in self.subtitle_languages() else None
if not lang:
return []
return [s for s in self.subtitles(lang) if s.sound == sound.key]
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_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)
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]]
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)
from .banks import open_file
l = locs[0]
b = 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."""
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
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 if `text` cannot be written for `key`."""
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})
# --- writing --------------------------------------------------------------------------------
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]
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 self.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)
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