223 lines
9.1 KiB
Python
223 lines
9.1 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.
|
|
"""
|
|
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
|