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

View File

@@ -22,6 +22,7 @@ from .game import Game, GameError
from .inject import inject, read_target, parse_target, pack_bnk_path, SizeLimitError
from .originals import OriginalsStore, sha256, sha1_16
from .pack import Pack
from .texts import Texts
from .paths import user_dir
from .wad import Wad
@@ -51,6 +52,7 @@ class Workspace:
self.store = OriginalsStore(store_root or default_store_root(self.game), self.game.root)
self.catalog = Catalog(self.game, archives, progress)
self.full_backup = full_backup
self.texts = Texts(self)
# reading -----------------------------------------------------------------------------------
def sound(self, name_or_key):
@@ -153,9 +155,9 @@ class Workspace:
"""items: [(sound, new_bytes, meta, is_revert)] all in one archive."""
if archive == banks.ARCHIVE:
return self._write_banks(items, progress)
heads = sorted({l.file for s, _, _, _ in items for l in self.locations(s)})
for rel in heads:
self._keep_original_file(rel)
heads = sorted({self.bank_path(l.file) for s, _, _, _ in items for l in self.locations(s)})
for path in heads:
self._keep_original(path)
wad = self.game.wad(archive)
self.game.check_writable(wad)
packs = {parse_target(s.target)[0] for s, _, _, _ in items if s.is_pack_media}
@@ -181,43 +183,44 @@ class Workspace:
else:
self.store.record_applied(s.key, data, meta)
self.store.save()
self._sync_bank_files(heads, progress)
self.sync_files(heads, progress)
# bank files (in-memory sounds, prefetch heads) ----------------------------------------------
def _bank_files(self):
return self.store.data.setdefault("bank_files", {})
# derived files: bank files and packages (in-memory sounds, prefetch heads, subtitles) and the
# localization files (menu text). Each is rebuilt from its original copy + every edit applied now.
def _derived(self):
return self.store.data.setdefault("derived", {})
def _fingerprint(self, path):
"""Content hash of a bank file (whole file: any change by another tool is detected)."""
"""Content hash of a file (whole file: any change by another tool is detected)."""
h = hashlib.sha1()
with open(path, "rb") as f:
for c in iter(lambda: f.read(1 << 22), b""):
h.update(c)
return h.hexdigest()
def _keep_original_file(self, rel):
"""Before the first change to a bank file: verified full copy + its fingerprint."""
rec = self._bank_files().get(rel)
def _keep_original(self, path):
"""Before the first change to a derived file: verified full copy + its fingerprint."""
rel = self.game.rel(path)
rec = self._derived().get(rel)
if rec:
return rec
path = self.bank_path(rel)
self.game.check_writable(path)
self.store.full_backup(path, self.game.rel(path))
rec = self._bank_files()[rel] = {"orig_fp": self._fingerprint(path), "written_fp": None}
self.store.full_backup(path, rel)
rec = self._derived()[rel] = {"orig_fp": self._fingerprint(path), "written_fp": None}
self.store.save()
return rec
def changed_files(self, sounds):
"""Bank files of `sounds` changed since we last wrote them (Steam update/verify, other tool)."""
def changed_files(self, paths):
"""Derived files changed since we last wrote them (Steam update/verify, another tool)."""
out = []
for rel in sorted({l.file for s in sounds for l in self.locations(s)}):
rec = self._bank_files().get(rel)
if rec and self._fingerprint(self.bank_path(rel)) not in (rec["orig_fp"], rec["written_fp"]):
out.append(rel)
for path in sorted(set(paths)):
rec = self._derived().get(self.game.rel(path))
if rec and self._fingerprint(path) not in (rec["orig_fp"], rec["written_fp"]):
out.append(path)
return out
def _edits(self, rel):
"""[(offset, bytes)] that the replacements applied now need in bank file `rel`."""
def _audio_edits(self, rel):
"""[(offset, bytes)] that the sound replacements applied now need in bank file `rel` (cooked-relative)."""
out = []
for key, e in self.store.entries.items():
if not e.get("applied"):
@@ -235,24 +238,31 @@ class Workspace:
out.append((l.offset, payload))
return out
def _sync_bank_files(self, rels, progress=None):
"""Rebuild each bank file from its original copy and the replacements applied now."""
for rel in rels:
path = self.bank_path(rel)
rec = self._keep_original_file(rel)
def _build(self, path, orig):
"""Content of derived file `path` from its original copy `orig`."""
if self.texts.is_menu_file(path):
return self.texts.menu_content(path, open(orig, "rb").read())
rel = os.path.relpath(path, self.game.cooked).replace(os.sep, "/")
edits = self._audio_edits(rel) + self.texts.subtitle_edits(rel)
return banks.open_file(orig).patched(edits) if edits else open(orig, "rb").read()
def sync_files(self, paths, progress=None):
"""Rebuild each derived file from its original copy and the edits applied now (atomic writes)."""
for path in sorted(set(paths)):
rec = self._keep_original(path)
orig = self.store.full_copy(self.game.rel(path))
if orig is None:
raise GameError("the original copy of %s is missing from the store" % rel)
raise GameError("the original copy of %s is missing from the store" % self.game.rel(path))
if progress:
progress("writing %s" % rel)
edits = self._edits(rel)
data = banks.open_file(orig).patched(edits) if edits else open(orig, "rb").read()
progress("writing %s" % os.path.basename(path))
data = self._build(path, orig)
self.game.check_writable(path)
tmp = path + ".ackit-tmp"
with open(tmp, "wb") as f:
f.write(data)
os.replace(tmp, path)
rec["written_fp"] = self._fingerprint(path) if edits else None
same = data == open(orig, "rb").read()
rec["written_fp"] = None if same else self._fingerprint(path)
self.store.save()
def _write_banks(self, items, progress=None):
@@ -262,9 +272,9 @@ class Workspace:
raise GameError("no copy of %s was found in the game's banks" % s.name)
if not is_revert and len(data) > min(l.size for l in locs):
raise SizeLimitError(s.target, min(l.size for l in locs), len(data))
files = sorted({l.file for s, _, _, _ in items for l in self.locations(s)})
for rel in files:
self._keep_original_file(rel)
files = sorted({self.bank_path(l.file) for s, _, _, _ in items for l in self.locations(s)})
for path in files:
self._keep_original(path)
for s, data, meta, is_revert in items:
if is_revert:
self.store.forget(s.key)
@@ -273,7 +283,7 @@ class Workspace:
self.current_bytes(s))
self.store.record_applied(s.key, data, meta)
self.store.save() # the store says what the files must contain before they change
self._sync_bank_files(files, progress)
self.sync_files(files, progress)
def apply(self, changes, force=False, progress=None):
"""changes: [(sound, wem_bytes, meta dict)]. One atomic rewrite per WAD / bank file."""
@@ -281,9 +291,9 @@ class Workspace:
for s, _, _ in changes:
if self.state(s) in (CHANGED, FOREIGN):
conflicts.append(s)
changed = set(self.changed_files([s for s, _, _ in changes]))
changed = set(self.changed_files([self.bank_path(l.file) for s, _, _ in changes for l in self.locations(s)]))
conflicts += [s for s, _, _ in changes
if s not in conflicts and {l.file for l in self.locations(s)} & changed]
if s not in conflicts and {self.bank_path(l.file) for l in self.locations(s)} & changed]
if conflicts and not force:
raise ConflictError(conflicts)
by_arch = defaultdict(list)
@@ -328,8 +338,9 @@ class Workspace:
shutil.copy2(src, tmp)
os.replace(tmp, dst)
out.append(rel)
for rec in self._bank_files().values():
for rec in self._derived().values():
rec["written_fp"] = None
self.store.data["texts"] = {}
self.store.data["entries"] = {}
self.store.save()
self.store.gc()