"""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. """ 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, 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.language or sound.archive if lang not in self.subtitle_languages(): 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