Version 3.2.2: in-memory replacements fill their slots exactly (padded ones played as silence)
This commit is contained in:
@@ -8,4 +8,4 @@ Library layers (low -> high):
|
||||
inject (WAD writing), originals (store), workspace (apply/revert), modpack (share)
|
||||
tools cli (`ackit` command), verify/ (offline self-check, in-game test on Windows)
|
||||
"""
|
||||
__version__ = "3.2.1"
|
||||
__version__ = "3.2.2"
|
||||
|
||||
@@ -140,8 +140,7 @@ def prepare_import(ws, path, items, progress=None):
|
||||
continue
|
||||
orig = ws.original_bytes(im.sound)
|
||||
wem = z.read(im.item["wem"])
|
||||
lim = ws.limit(im.sound)
|
||||
if im.how == "exact" and sha1_16(orig) == im.item.get("original_sha1") and (lim is None or len(wem) <= lim):
|
||||
if im.how == "exact" and sha1_16(orig) == im.item.get("original_sha1") and ws.fits(im.sound, wem):
|
||||
im.wem, im.note = wem, "exact copy"
|
||||
else:
|
||||
x, sr = sf.read(io.BytesIO(z.read(im.item["audio"])), dtype="float32", always_2d=True)
|
||||
|
||||
@@ -200,10 +200,10 @@ def bank_probes(ws, sounds):
|
||||
secs = min(10.0, max(0.4, s.seconds or 1.0))
|
||||
while secs >= 0.1:
|
||||
wem, _ = encode(x[:int(secs * sr)], sr, PROBE_RATE, 1, PROBE_Q)
|
||||
if len(wem) <= lim:
|
||||
if ws.fits(s, wem): # within the slot, and able to fill it exactly (wem.fill)
|
||||
out.append((s, wem))
|
||||
break
|
||||
secs *= 0.7
|
||||
secs *= 0.7 if len(wem) > lim else 0.98
|
||||
return out
|
||||
|
||||
|
||||
|
||||
54
ackit/wem.py
54
ackit/wem.py
@@ -101,6 +101,60 @@ class Wem:
|
||||
return b"RIFF" + struct.pack("<I", len(body)) + body
|
||||
|
||||
|
||||
# ---- filling an in-memory slot exactly ----
|
||||
# The game ships no in-memory WEM with bytes after its RIFF inside its slot, and plays a replacement that is
|
||||
# followed by zero padding there as silence (findings 24). A smaller replacement is therefore grown to the
|
||||
# slot's exact size with a label list chunk before `data`, the form the game's own WEMs use for markers:
|
||||
# LIST{'adtl', labl{u32 cue id 0, zero text}}. Even sizes only (the game's chunks before `data` are even).
|
||||
FILL_MIN = 8 + 4 + 8 + 4 + 2
|
||||
|
||||
|
||||
def fillable(length, size):
|
||||
"""True if a WEM of `length` bytes can fill a slot of `size` bytes exactly."""
|
||||
pad = size - length
|
||||
return pad == 0 or (pad >= FILL_MIN and pad % 2 == 0)
|
||||
|
||||
|
||||
def fill(wem: bytes, size: int) -> bytes:
|
||||
"""`wem` grown to exactly `size` bytes (see above). ValueError if `fillable` is False."""
|
||||
if not fillable(len(wem), size):
|
||||
raise ValueError("a %d-byte WEM cannot fill a %d-byte slot exactly" % (len(wem), size))
|
||||
pad = size - len(wem)
|
||||
if not pad:
|
||||
return wem
|
||||
i = _data_chunk(wem)
|
||||
text = pad - 8 - 4 - 8 - 4
|
||||
chunk = (b"LIST" + struct.pack("<I", pad - 8) + b"adtl" + b"labl" + struct.pack("<II", text + 4, 0)
|
||||
+ bytes(text))
|
||||
out = wem[:i] + chunk + wem[i:]
|
||||
return out[:4] + struct.pack("<I", len(out) - 8) + out[8:]
|
||||
|
||||
|
||||
def unfill(wem: bytes) -> bytes:
|
||||
"""The WEM without a filler chunk added by `fill` (a game WEM is returned unchanged)."""
|
||||
i = _data_chunk(wem)
|
||||
if i is None:
|
||||
return wem
|
||||
for j in range(12, i, 2):
|
||||
if wem[j:j + 4] == b"LIST" and wem[j + 8:j + 16] == b"adtllabl":
|
||||
n = 8 + struct.unpack_from("<I", wem, j + 4)[0]
|
||||
if j + n == i and struct.unpack_from("<I", wem, j + 20)[0] == 0 and not wem[j + 24:i].strip(b"\0"):
|
||||
out = wem[:j] + wem[i:]
|
||||
return out[:4] + struct.pack("<I", len(out) - 8) + out[8:]
|
||||
return wem
|
||||
|
||||
|
||||
def _data_chunk(wem: bytes):
|
||||
"""Offset of the `data` chunk header (walking the chunks), or None."""
|
||||
pos = 12
|
||||
while pos + 8 <= len(wem):
|
||||
tag, ln = struct.unpack_from("<4sI", wem, pos)
|
||||
if tag == b"data":
|
||||
return pos
|
||||
pos += 8 + ln
|
||||
return None
|
||||
|
||||
|
||||
SUB_ALIGN = 0x800
|
||||
_NONZERO = re.compile(rb"[^\x00]")
|
||||
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
Reference in New Issue
Block a user