List and edit in-memory sounds in soundbanks
This commit is contained in:
@@ -6,15 +6,20 @@ Safety rules (docs/decisions.md):
|
||||
* each WAD is rewritten atomically; several sounds of one WAD are written in one pass;
|
||||
* if a sound in the game is neither its original nor what we applied (Steam update, "verify
|
||||
integrity", another tool), we refuse to write it unless forced, and report it.
|
||||
In-memory sounds (archive BANK) and the prefetch heads of speech lines live in loose banks and .upk
|
||||
packages (ackit/banks.py). Before the first change to such a file a verified full copy is kept, and the
|
||||
file is always rebuilt as "original copy + every replacement currently applied", so reverting the last
|
||||
change restores it byte-for-byte and any later operation repairs an interrupted one.
|
||||
"""
|
||||
import hashlib
|
||||
import os
|
||||
from collections import defaultdict
|
||||
|
||||
from . import audio
|
||||
from . import audio, banks
|
||||
from .catalog import Catalog
|
||||
from .encoder import encode_fit
|
||||
from .game import Game, GameError
|
||||
from .inject import inject, read_target, parse_target, pack_bnk_path
|
||||
from .inject import inject, read_target, parse_target, pack_bnk_path, SizeLimitError
|
||||
from .originals import OriginalsStore, sha256, sha1_16
|
||||
from .pack import Pack
|
||||
from .paths import user_dir
|
||||
@@ -54,7 +59,35 @@ class Workspace:
|
||||
def wad_path(self, s):
|
||||
return self.game.wad(s.archive)
|
||||
|
||||
def bank_path(self, rel):
|
||||
return banks.file_path(self.game.cooked, rel)
|
||||
|
||||
def locations(self, s_or_key):
|
||||
"""Bank copies written together with a sound: every copy of a BANK sound, or the prefetch heads
|
||||
of a WAD speech line (usually none)."""
|
||||
key = s_or_key if isinstance(s_or_key, str) else s_or_key.key
|
||||
archive, _, target = key.partition(":")
|
||||
if archive == banks.ARCHIVE:
|
||||
return self.catalog.locations.get(int(target, 16), [])
|
||||
return self.catalog.heads.get(key, [])
|
||||
|
||||
def files(self, s):
|
||||
"""Absolute paths of the game files that writing `s` changes."""
|
||||
out = []
|
||||
if s.archive != banks.ARCHIVE:
|
||||
out.append(self.wad_path(s))
|
||||
if s.is_pack_media:
|
||||
out.append(pack_bnk_path(self.wad_path(s), parse_target(s.target)[0]))
|
||||
out += sorted({self.bank_path(l.file) for l in self.locations(s)})
|
||||
return out
|
||||
|
||||
def current_bytes(self, s):
|
||||
if s.archive == banks.ARCHIVE:
|
||||
locs = self.locations(s)
|
||||
if not locs:
|
||||
raise GameError("no copy of %s was found in the game's banks" % s.name)
|
||||
l = locs[0]
|
||||
return banks.riff_trim(banks.open_file(self.bank_path(l.file)).read(l.offset, l.size))
|
||||
return read_target(self.wad_path(s), s.target)
|
||||
|
||||
def original_bytes(self, s):
|
||||
@@ -81,6 +114,9 @@ class Workspace:
|
||||
"""Max size in bytes for a replacement (None = any size)."""
|
||||
if s.is_pack_media:
|
||||
return None
|
||||
if s.archive == banks.ARCHIVE:
|
||||
locs = self.locations(s)
|
||||
return min(l.size for l in locs) if locs else s.size
|
||||
e = self.store.get(s.key)
|
||||
if e:
|
||||
return e["orig_size"]
|
||||
@@ -115,6 +151,11 @@ class Workspace:
|
||||
|
||||
def _write(self, archive, items, progress=None):
|
||||
"""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)
|
||||
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}
|
||||
@@ -140,13 +181,109 @@ class Workspace:
|
||||
else:
|
||||
self.store.record_applied(s.key, data, meta)
|
||||
self.store.save()
|
||||
self._sync_bank_files(heads, progress)
|
||||
|
||||
# bank files (in-memory sounds, prefetch heads) ----------------------------------------------
|
||||
def _bank_files(self):
|
||||
return self.store.data.setdefault("bank_files", {})
|
||||
|
||||
def _fingerprint(self, path):
|
||||
"""Content hash of a bank 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)
|
||||
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.save()
|
||||
return rec
|
||||
|
||||
def changed_files(self, sounds):
|
||||
"""Bank files of `sounds` changed since we last wrote them (Steam update/verify, other 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)
|
||||
return out
|
||||
|
||||
def _edits(self, rel):
|
||||
"""[(offset, bytes)] that the replacements applied now need in bank file `rel`."""
|
||||
out = []
|
||||
for key, e in self.store.entries.items():
|
||||
if not e.get("applied"):
|
||||
continue
|
||||
locs = [l for l in self.locations(key) if l.file == rel]
|
||||
if not locs:
|
||||
continue
|
||||
data = self.store.get_blob(e["applied"])
|
||||
for l in locs:
|
||||
if l.kind == banks.MEMORY:
|
||||
payload = data + bytes(l.size - len(data))
|
||||
else: # head of a WAD line, which is stored zero-padded to its original size
|
||||
full = data + bytes(max(0, e["orig_size"] - len(data)))
|
||||
payload = (full + bytes(l.size))[:l.size]
|
||||
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)
|
||||
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)
|
||||
if progress:
|
||||
progress("writing %s" % rel)
|
||||
edits = self._edits(rel)
|
||||
data = banks.open_file(orig).patched(edits) if edits else open(orig, "rb").read()
|
||||
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
|
||||
self.store.save()
|
||||
|
||||
def _write_banks(self, items, progress=None):
|
||||
for s, data, meta, is_revert in items:
|
||||
locs = self.locations(s)
|
||||
if not locs:
|
||||
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)
|
||||
for s, data, meta, is_revert in items:
|
||||
if is_revert:
|
||||
self.store.forget(s.key)
|
||||
else:
|
||||
self.store.remember_original(s.key, s.archive, s.target, self.locations(s)[0].file,
|
||||
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)
|
||||
|
||||
def apply(self, changes, force=False, progress=None):
|
||||
"""changes: [(sound, wem_bytes, meta dict)]. One atomic rewrite per WAD."""
|
||||
"""changes: [(sound, wem_bytes, meta dict)]. One atomic rewrite per WAD / bank file."""
|
||||
conflicts = []
|
||||
for s, _, _ in changes:
|
||||
if self.state(s) in (CHANGED, FOREIGN):
|
||||
conflicts.append(s)
|
||||
changed = set(self.changed_files([s for s, _, _ in changes]))
|
||||
conflicts += [s for s, _, _ in changes
|
||||
if s not in conflicts and {l.file for l in self.locations(s)} & changed]
|
||||
if conflicts and not force:
|
||||
raise ConflictError(conflicts)
|
||||
by_arch = defaultdict(list)
|
||||
@@ -191,6 +328,8 @@ class Workspace:
|
||||
shutil.copy2(src, tmp)
|
||||
os.replace(tmp, dst)
|
||||
out.append(rel)
|
||||
for rec in self._bank_files().values():
|
||||
rec["written_fp"] = None
|
||||
self.store.data["entries"] = {}
|
||||
self.store.save()
|
||||
self.store.gc()
|
||||
|
||||
Reference in New Issue
Block a user