List and edit in-memory sounds in soundbanks

This commit is contained in:
2026-10-09 20:12:10 +02:00
parent 61bbf26f3b
commit 5d6c2447f7
36 changed files with 71568 additions and 54823 deletions

View File

@@ -89,9 +89,10 @@ def pack(media):
return bytes(hdr) + bank, bytes(payload)
def bank(bank_id, media, name=None, extra_sources=()):
def bank(bank_id, media, name=None, extra_sources=(), prefetch=()):
"""A v56 soundbank holding `media` {id: wem} in memory (DIDX/DATA, 16-byte aligned) with one HIRC
Sound object per media; extra_sources: [(media id, bank id, off, size)] for sources of other banks."""
Sound object per media; extra_sources: [(media id, bank id, off, size)] for sources of other banks;
media ids in `prefetch` get a prefetch source (their DIDX data is the head of a streamed WEM)."""
data = bytearray()
didx = b""
for m in sorted(media):
@@ -104,11 +105,12 @@ def bank(bank_id, media, name=None, extra_sources=()):
objs = []
for i, m in enumerate(sorted(media)):
off = struct.unpack_from("<I", didx, 12 * i + 4)[0]
objs.append((m, bank_id, data_pos + off, len(media[m])))
objs += list(extra_sources)
objs.append((m, bank_id if m not in prefetch else 0x1234, data_pos + off, len(media[m]),
2 if m in prefetch else 0))
objs += [x + (0,) for x in extra_sources]
hirc = struct.pack("<I", len(objs))
for i, (m, b, off, size) in enumerate(objs):
body = struct.pack("<7I", 0x100 + i, 0x00040001, 0, m, b, off, size) + bytes(6)
for i, (m, b, off, size, stream) in enumerate(objs):
body = struct.pack("<7I", 0x100 + i, 0x00040001, stream, m, b, off, size) + bytes(6)
hirc += bytes([2]) + struct.pack("<I", len(body)) + body
out = head + b"DATA" + struct.pack("<I", len(data)) + bytes(data) + b"HIRC" + struct.pack("<I", len(hirc)) + hirc
if name:
@@ -172,4 +174,44 @@ def make_game(root):
originals = {"%08X:%d" % (SPEECH_ENTRY, i): w for i, w in enumerate(v)}
originals["%08X" % SINGLE_ENTRY] = single
originals.update({"%08X/%08X" % (PACK_ID, m): w for m, w in music.items()})
originals.update(make_banks(cooked, v))
return originals
# soundbanks (docs/formats/banks.md): media ids of the in-memory sounds
FOOT = (0x0A000001, 0x0A000002) # loose bank CHR_SynthFootsteps_Media
FOLEY = (0x0B000001, 0x0B000002) # bank CHR_SynthFoley, copied into both packages
GUI = (0x0C000001,) # bank GUI_Synth (package B only)
LINE_MEDIA = (0x0D000000, 0x0D000001, 0x0D000002) # INT media ids of the SPEECH_ENTRY variations
HEAD = 0x200 # prefetch head size of variation 1
def make_banks(cooked, speech_wems):
"""Loose bank + two packages (one bank in both) + a prefetch head of INT SPEECH_ENTRY variation 1."""
from ackit.bnk import fnv1
names = ["Init", "CHR_SynthFootsteps_Media", "CHR_SynthFoley", "GUI_Synth", "Speech_Synth"]
with open(os.path.join(cooked, "CookedBanks.txt"), "w", newline="\r\n") as f:
f.write("\n".join(names) + "\n")
foot = {FOOT[0]: wem(0.4, 800), FOOT[1]: wem(0.3, 900)}
foley = {FOLEY[0]: wem(0.5, 1000), FOLEY[1]: wem(0.6, 1100, 32000)}
gui = {GUI[0]: wem(0.5, 1200)}
open(os.path.join(cooked, "SFX", "%08X.bnk" % fnv1(names[1])), "wb").write(
bank(fnv1(names[1]), foot, name=names[1]))
# BKPK table of SPEECH_ENTRY for INT (media ids differ per language; offsets as in the WAD entry)
offs, pos = [], 0
for w in speech_wems:
pos = _a(pos, 0x800)
offs.append(pos)
pos += len(w)
tbl = bytearray(0x48)
struct.pack_into("<4sII", tbl, 0, b"BKPK", 0x01020000, SPEECH_ENTRY)
struct.pack_into("<IIIIII", tbl, 0x10, 3, 0, 36, 0x48, _a(pos, 0x800), 0)
tbl += b"".join(struct.pack("<III", m, len(w), o) for m, w, o in zip(LINE_MEDIA, speech_wems, offs))
speech = bank(fnv1(names[4]), {LINE_MEDIA[1]: speech_wems[1][:HEAD]}, prefetch=(LINE_MEDIA[1],))
filler = bytes(range(256)) * 9
synth_a = filler + bank(fnv1(names[2]), foley) + filler[:777] + bytes(tbl) + speech + filler
synth_b = filler[:1000] + bank(fnv1(names[3]), gui) + filler + bank(fnv1(names[2]), foley) + filler
package(os.path.join(cooked, "Synth_A.upk"), synth_a)
package(os.path.join(cooked, "Synth_B.upk"), synth_b)
out = {"%08X" % m: w for m, w in list(foot.items()) + list(foley.items()) + list(gui.items())}
return out

View File

@@ -93,6 +93,24 @@ def test_replace_apply_revert_byte_identical(win, tmp_path):
assert not w.ws.store.entries
def test_bank_sound_apply_revert_in_editor(win, tmp_path):
w, root, originals = win
before = files(root)
w.cmb_arch.setCurrentText("BANK")
assert w.proxy.rowCount() == 5
assert w.select_name("CHR_SynthFoley_1")
ed = w.editor
assert "bank CHR_SynthFoley (2 copies)" in ed.meta.text()
wav = str(tmp_path / "foley.wav")
audio.save_wav(wav, *synth.tone(0.2, 44100, 1300))
assert ed.load_file(wav)
ed._update_new()
ed.apply()
assert w.ws.state(ed.sound) == W.MODIFIED and files(root) != before
ed.revert()
assert files(root) == before and not w.ws.store.entries
def test_record_simulated_input(win):
w, root, _ = win
w.select_name("CHR_RadioScanner_1")

162
tests/test_banks.py Normal file
View File

@@ -0,0 +1,162 @@
"""In-memory sounds (archive BANK) and prefetch heads: catalog scan, apply/revert through Workspace."""
import hashlib
import os
import pytest
import synth
from ackit import banks
from ackit.game import Game
from ackit.inject import SizeLimitError
from ackit.upk import Package
from ackit.workspace import Workspace, ConflictError, MODIFIED, ORIGINAL
def _sha(p):
return hashlib.sha1(open(p, "rb").read()).hexdigest()
def _ws(root, tmp_path):
return Workspace(root, store_root=str(tmp_path / "store"), archives=["INT", "SFX", "BANK"])
def _cooked(root):
return Game(root).cooked
def _copy(ws, l):
return banks.open_file(ws.bank_path(l.file)).read(l.offset, l.size)
def test_bank_catalog(synth_game, tmp_path):
root, originals = synth_game
ws = _ws(root, tmp_path)
bank = {s.target: s for s in ws.catalog.sounds if s.archive == "BANK"}
assert set(bank) == {"%08X" % m for m in synth.FOOT + synth.FOLEY + synth.GUI}
foot = bank["%08X" % synth.FOOT[0]]
assert foot.bank == "CHR_SynthFootsteps_Media" and foot.name == "CHR_SynthFootsteps_Media_1"
assert (foot.group, foot.category) == ("SFX", "Footsteps")
assert bank["%08X" % synth.GUI[0]].category == "Interface"
foley = bank["%08X" % synth.FOLEY[1]]
assert foley.rate == 32000 and foley.size == len(originals[foley.target])
assert sorted(l.file for l in ws.locations(foley)) == ["Synth_A.upk", "Synth_B.upk"]
for s in bank.values():
assert ws.current_bytes(s) == originals[s.target]
assert all(_copy(ws, l) == originals[s.target] for l in ws.locations(s))
# the prefetch head belongs to INT variation 1 of the speech entry, not to the BANK archive
line = ws.catalog.by_key["INT:%08X:1" % synth.SPEECH_ENTRY]
heads = ws.locations(line)
assert [(l.file, l.size, l.kind) for l in heads] == [("Synth_A.upk", synth.HEAD, "p")]
assert _copy(ws, heads[0]) == originals["%08X:1" % synth.SPEECH_ENTRY][:synth.HEAD]
assert ws.locations(ws.catalog.by_key["INT:%08X:0" % synth.SPEECH_ENTRY]) == []
def test_bank_apply_every_copy_and_revert_byte_identical(synth_game, tmp_path):
root, originals = synth_game
ws = _ws(root, tmp_path)
cooked = _cooked(root)
files = [os.path.join(cooked, f) for f in ("Synth_A.upk", "Synth_B.upk")]
before = {f: _sha(f) for f in files}
s = ws.catalog.by_key["BANK:%08X" % synth.FOLEY[0]]
new = synth.wem(0.2, 1500)
assert len(new) < ws.limit(s)
ws.apply([(s, new, {"source": "test"})])
assert ws.state(s) == MODIFIED
for l in ws.locations(s):
assert _copy(ws, l) == new + bytes(l.size - len(new))
other = ws.catalog.by_key["BANK:%08X" % synth.FOLEY[1]]
assert all(_copy(ws, l) == originals[other.target] for l in ws.locations(other))
assert all(_sha(f) != before[f] for f in files)
assert Package(files[1]).size == Package(ws.store.full_copy(ws.game.rel(files[1]))).size # layout unchanged
ws.revert([s])
assert ws.state(s) == ORIGINAL and {f: _sha(f) for f in files} == before
def test_loose_bank_in_place(synth_game, tmp_path):
root, originals = synth_game
ws = _ws(root, tmp_path)
s = ws.catalog.by_key["BANK:%08X" % synth.FOOT[1]]
path = ws.files(s)[0]
before, size = _sha(path), os.path.getsize(path)
ws.apply([(s, synth.wem(0.1, 2000), {})])
assert os.path.getsize(path) == size and _sha(path) != before
ws.revert_all()
assert _sha(path) == before
def test_partial_revert_keeps_other_changes(synth_game, tmp_path):
root, originals = synth_game
ws = _ws(root, tmp_path)
path = os.path.join(_cooked(root), "Synth_B.upk")
before = _sha(path)
a = ws.catalog.by_key["BANK:%08X" % synth.FOLEY[0]]
b = ws.catalog.by_key["BANK:%08X" % synth.GUI[0]]
na, nb = synth.wem(0.2, 1500), synth.wem(0.2, 1700)
ws.apply([(a, na, {}), (b, nb, {})])
ws.revert([a])
assert ws.current_bytes(a) == originals[a.target]
assert ws.current_bytes(b).startswith(nb) and ws.state(b) == MODIFIED
ws.revert([b])
assert _sha(path) == before
def test_speech_line_with_prefetch_head(synth_game, tmp_path):
root, originals = synth_game
ws = _ws(root, tmp_path)
line = ws.catalog.by_key["INT:%08X:1" % synth.SPEECH_ENTRY]
files = ws.files(line)
assert [os.path.basename(f) for f in files] == ["28CCF006_LOC_INT.wad", "Synth_A.upk"]
before = {f: _sha(f) for f in files}
new = synth.wem(0.3, 640)
ws.apply([(line, new, {})])
head = ws.locations(line)[0]
assert _copy(ws, head) == (new + bytes(synth.HEAD))[:synth.HEAD]
assert ws.current_bytes(line).startswith(new)
ws.revert([line])
assert {f: _sha(f) for f in files} == before
def test_bank_size_limit(synth_game, tmp_path):
root, _ = synth_game
ws = _ws(root, tmp_path)
s = ws.catalog.by_key["BANK:%08X" % synth.FOOT[1]]
with pytest.raises(SizeLimitError):
ws.apply([(s, synth.wem(3.0, 440), {})])
assert ws.store.entries == {}
def test_bank_file_changed_underneath(synth_game, tmp_path):
root, originals = synth_game
ws = _ws(root, tmp_path)
s = ws.catalog.by_key["BANK:%08X" % synth.GUI[0]]
ws.apply([(s, synth.wem(0.2, 1500), {})])
path = ws.files(s)[0]
# another tool rewrites the package (different bytes elsewhere in it)
pk = Package(path)
other = pk.patched([(pk.header_size + 10, b"OTHER-TOOL")])
open(path, "wb").write(other)
with pytest.raises(ConflictError):
ws.apply([(s, synth.wem(0.2, 1600), {})])
ws.apply([(s, synth.wem(0.2, 1600), {})], force=True)
ws.revert([s])
assert _sha(path) == _sha(ws.store.full_copy(ws.game.rel(path)))
def test_event_names():
sc = banks.Scan()
sc.names = {"Play_Synth_Punch", "Stop_Synth_Punch", "Unrelated"}
sc.bank_names = {7: "CHR_SynthCombat"}
for m in (1, 2, 3):
sc.media[m] = {"size": 10, "sha1": {"x"}, "head": b"", "banks": {7}, "kinds": {"m"}}
# event -> action -> random container (children 0x50, 0x51) -> sounds -> media 1, 2; media 3 unreferenced
from ackit.bnk import fnv1
import struct
sc.objects = {0x50: 2, 0x51: 2, 0x60: 5, 0x70: 3, fnv1("Play_Synth_Punch"): 4}
sc.containers = {0x60: struct.pack("<I", 0x60) + bytes(9) + struct.pack("<3I", 2, 0x50, 0x51) + bytes(5)}
sc.actions = {0x70: 0x60}
sc.events = {fnv1("Play_Synth_Punch"): [0x70]}
sc.sound_media = {0x50: {1}, 0x51: {2}}
names = banks.media_names(sc)
assert names[1] == ("Play_Synth_Punch_1", "CHR_SynthCombat")
assert names[2] == ("Play_Synth_Punch_2", "CHR_SynthCombat")
assert names[3] == ("CHR_SynthCombat_1", "CHR_SynthCombat")

View File

@@ -4,7 +4,7 @@ import os
import pytest
from ackit import classify
from ackit import banks, classify
from ackit.catalog import Catalog, read_csv, load_characters
from ackit.game import Game
from ackit.paths import catalog_dir
@@ -63,15 +63,24 @@ def test_shipped_csvs_are_consistent():
keys = {r.key for r in rows}
assert len(keys) == len(rows), f
assert all(r.group in classify.GROUPS for r in rows)
assert all(len(r.sha1) == 16 for r in rows)
assert len({r.name.lower() for r in rows}) == len(rows), f
# no checksum only where the copies of a BANK sound differ (1C898731)
assert sum(len(r.sha1) != 16 for r in rows) <= (1 if f == "BANK.csv" else 0), f
hdr = next(csv.reader(open(os.path.join(catalog_dir(), "ITA.csv"), encoding="utf-8")))
assert hdr[:3] == ["archive", "target", "name"]
locs = banks.read_locations(os.path.join(catalog_dir(), banks.LOCATIONS))
bank = {r.target for r in read_csv(os.path.join(catalog_dir(), "BANK.csv"))}
assert {"%08X" % m for m, ls in locs.items() if ls[0].kind == "m"} == bank
assert all(l.sound for ls in locs.values() for l in ls if l.kind == "p")
@pytest.mark.game
def test_real_game_uses_shipped_catalog(real_game_dir):
g = Game(real_game_dir)
cat = Catalog(g, ["ITA", "SFX"])
assert len(cat.sounds) == 10816 + 651
cat = Catalog(g, ["ITA", "SFX", "BANK"])
assert len(cat.sounds) == 10816 + 651 + 15404
s = cat.resolve("Speech_TAPE_JOKER_1_4")
assert s.category == "Interview tapes" and s.size == 44985
assert len(cat.heads) == 396 and sum(len(v) for v in cat.heads.values()) == 2101
b = cat.resolve("Play_BatmanDeviceImpact_Wood_1")
assert b.archive == "BANK" and b.group == "SFX"

View File

@@ -0,0 +1,40 @@
"""`ackit verify --catalog`: the catalog in use against a fresh scan of the game."""
import glob
import os
from ackit.catalog import read_csv, write_csv
from ackit.game import Game
from ackit.paths import user_dir
from ackit.verify import catalogcheck
def test_clean_synthetic_catalog_passes(synth_game):
root, _ = synth_game
rep = catalogcheck.run(Game(root), log=lambda *a: None)
assert rep.ok, rep.issues
assert rep.checked["BANK"] == 5 and rep.checked["INT"] == 4
def test_corrupted_catalog_is_reported(synth_game):
root, _ = synth_game
game = Game(root)
catalogcheck.run(game, ["INT", "BANK"], log=lambda *a: None) # builds the cached scans
cache = glob.glob(os.path.join(user_dir("cache"), "scan_INT_*.csv"))[0]
rows = read_csv(cache)
rows[1].category = "Weapons" # wrong category
rows.append(rows[0]) # duplicate
del rows[2] # missing
write_csv(cache, rows)
bank = glob.glob(os.path.join(user_dir("cache"), "scan_BANK_*.csv"))[0]
brows = read_csv(bank)
brows[0].name = brows[1].name # duplicate name
write_csv(bank, brows[:-1]) # one sound missing
rep = catalogcheck.run(game, ["INT", "BANK"], log=lambda *a: None)
kinds = {(a, k) for a, k, _, _ in rep.issues}
assert {("INT", "category"), ("INT", "duplicate"), ("INT", "missing"), ("BANK", "duplicate"),
("BANK", "missing")} <= kinds
def test_real_catalog_quick(real_game_dir):
rep = catalogcheck.run(Game(real_game_dir), quick=True, log=lambda *a: None)
assert rep.ok, rep.issues[:10]