Version 3.2.2: in-memory replacements fill their slots exactly (padded ones played as silence)

This commit is contained in:
2026-10-11 00:10:44 +02:00
parent cac52e7597
commit b6b96307ca
16 changed files with 223 additions and 52 deletions

View File

@@ -7,7 +7,8 @@ Safety rules (docs/decisions.md):
* 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
packages (ackit/banks.py); a replacement fills each in-memory slot exactly (wem.fill). 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.
"""
@@ -17,7 +18,7 @@ from collections import defaultdict
from . import audio, banks
from .catalog import Catalog
from .encoder import encode_fit
from .encoder import EncodeError, encode_fit
from .game import Game, GameError
from .inject import inject, read_target, parse_target, pack_bnk_path, SizeLimitError
from .originals import OriginalsStore, sha256, sha1_16
@@ -25,6 +26,7 @@ from .pack import Pack
from .texts import Texts
from .paths import user_dir
from .wad import Wad
from .wem import FILL_MIN, fill, fillable, unfill
# sound states
ORIGINAL = "original" # untouched
@@ -93,7 +95,7 @@ class Workspace:
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 unfill(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):
@@ -133,11 +135,34 @@ class Workspace:
def original_audio(self, s):
return audio.decode_wem(self.original_bytes(s))
def fill_sizes(self, s):
"""Slot sizes a replacement of `s` must fill exactly: its in-memory copies, and for a WAD recording the
bank copies that hold it whole (wem.fill: the game plays a padded in-memory WEM as silence)."""
if s.archive == banks.ARCHIVE:
return sorted({l.size for l in self.locations(s) if l.kind == banks.MEMORY})
orig = self.limit(s)
return sorted({l.size for l in self.locations(s) if orig is not None and l.size >= orig})
def fits(self, s, wem):
"""True if `wem` can be written for `s`: within its size limit and able to fill its in-memory slots."""
lim = self.limit(s)
return (lim is None or len(wem) <= lim) and all(fillable(len(wem), n) for n in self.fill_sizes(s))
# encoding ----------------------------------------------------------------------------------
def prepare(self, s, x, src_rate, quality="auto", progress=None):
"""Encode PCM for sound `s` (its rate/channels, fitted to its size limit) -> (wem, info)."""
return encode_fit(x, src_rate, s.rate or src_rate, s.channels or x.shape[1], self.limit(s),
quality, progress)
"""Encode PCM for sound `s` (its rate/channels, fitted to its size limit) -> (wem, info). A sound with
in-memory slots gets a WEM that fills them exactly (`fits`): at least FILL_MIN bytes smaller, and an
even number of bytes smaller; the end is shortened by a few samples until the size allows that."""
sizes = self.fill_sizes(s)
lim = self.limit(s)
if sizes:
lim = min(([lim] if lim is not None else []) + sizes) - FILL_MIN
rate, ch = s.rate or src_rate, s.channels or x.shape[1]
for k in range(32):
wem, info = encode_fit(x[:len(x) - 16 * k] if k else x, src_rate, rate, ch, lim, quality, progress)
if self.fits(s, wem):
return wem, info
raise EncodeError("could not encode a size that fills this sound's slots exactly; shorten the audio")
# writing -----------------------------------------------------------------------------------
def _backup(self, wad, pack_hashes):
@@ -225,11 +250,14 @@ class Workspace:
locs = [l for l in self.locations(key) if l.file == rel]
if not locs:
continue
data = self.store.get_blob(e["applied"])
data = unfill(self.store.get_blob(e["applied"]))
for l in locs:
if l.kind == banks.MEMORY:
whole = l.kind == banks.MEMORY or l.size >= e["orig_size"] # a whole WEM in memory
if whole and fillable(len(data), l.size):
payload = fill(data, l.size)
elif whole: # recorded by a version before 3.2.2 (zero padding: the game plays silence)
payload = data + bytes(l.size - len(data))
else: # head of a WAD line, which is stored zero-padded to its original size
else: # prefetch head: the start of the WAD recording as it is stored there
full = data + bytes(max(0, e["orig_size"] - len(data)))
payload = (full + bytes(l.size))[:l.size]
out.append((l.offset, payload))
@@ -286,6 +314,12 @@ class Workspace:
"""changes: [(sound, wem_bytes, meta dict)]. One atomic rewrite per WAD / bank file. A recording stored
in two places of a WAD gets the same bytes in both (Workspace.copies)."""
keys = {s.key for s, _, _ in changes}
changes = [(s, unfill(data), meta) for s, data, meta in changes]
for s, data, _ in changes:
if not self.fits(s, data) and len(data) <= (self.limit(s) or len(data)):
raise GameError("%s: a %d-byte WEM cannot fill this sound's in-memory slots (%s bytes) exactly; "
"encode it for this sound (Workspace.prepare / the editor / ackit replace)"
% (s.name, len(data), ", ".join(map(str, self.fill_sizes(s)))))
changes = list(changes) + [(c, data, meta) for s, data, meta in changes for c in self.copies(s)
if c.key not in keys]
conflicts = []