"""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("