"""Subtitles and menu text: reading, states, apply and revert (shared by the CLI and the editor). Keys (also used in mod packages): SUB::: a subtitle string (docs/formats/subtitles.md) MENU:::
:[#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:::...).""" 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