List and edit in-memory sounds in soundbanks
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
162
tests/test_banks.py
Normal 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")
|
||||
@@ -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"
|
||||
|
||||
40
tests/test_catalogcheck.py
Normal file
40
tests/test_catalogcheck.py
Normal 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]
|
||||
Reference in New Issue
Block a user