208 lines
9.0 KiB
Python
208 lines
9.0 KiB
Python
"""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.wem import fill, unfill
|
|
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"}
|
|
neutral = synth.FOOT + synth.FOLEY + synth.GUI + (synth.DLG_MEDIA,)
|
|
assert set(bank) == {"%08X" % m for m in neutral + (synth.GRUNT_MEDIA["INT"],)} # INT catalog: no ITA copy
|
|
grunt = bank["%08X" % synth.GRUNT_MEDIA["INT"]]
|
|
assert grunt.language == "INT" and grunt.name == synth.GRUNT_EVENT and grunt.group == "Speech"
|
|
ita = Workspace(root, store_root=str(tmp_path / "s2"), archives=["BANK"]).catalog # no language: all
|
|
assert {s.language for s in ita.sounds if s.bank == synth.GRUNT_BANK} == {"INT", "ITA"}
|
|
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():
|
|
if s.language:
|
|
continue
|
|
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.fitting_wem(ws, s, 0.2, 1500)
|
|
assert len(new) < ws.limit(s)
|
|
ws.apply([(s, new, {"source": "test"})])
|
|
assert ws.state(s) == MODIFIED and ws.current_bytes(s) == new
|
|
for l in ws.locations(s): # every copy filled exactly: no zero padding after the WEM
|
|
assert _copy(ws, l) == fill(new, l.size) and len(_copy(ws, l)) == l.size
|
|
assert unfill(_copy(ws, l)) == 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_fill_slot_exactly():
|
|
"""The game plays an in-memory WEM followed by zero padding as silence: a smaller replacement is grown to
|
|
its slot with a label list chunk (wem.fill), which the game's own WEMs use too."""
|
|
from ackit import audio
|
|
from ackit.wem import FILL_MIN, Wem, fillable
|
|
w = synth.wem(0.2, 1500)
|
|
for size in (len(w), len(w) + FILL_MIN, len(w) + 1000):
|
|
f = fill(w, size)
|
|
assert len(f) == size and 8 + int.from_bytes(f[4:8], "little") == size # RIFF covers the slot
|
|
tags = [b"fmt ", b"data"] if size == len(w) else [b"fmt ", b"LIST", b"data"]
|
|
assert unfill(f) == w and [c.tag for c in Wem.parse(f).chunks] == tags
|
|
assert (audio.decode_wem(f)[0] == audio.decode_wem(w)[0]).all()
|
|
assert not fillable(len(w), len(w) + 3) and not fillable(len(w), len(w) + FILL_MIN - 2)
|
|
with pytest.raises(ValueError):
|
|
fill(w, len(w) + 1)
|
|
f = fill(w, len(w) + 40)
|
|
a = f.index(b"adtllabl")
|
|
game_like = f[:a + 12] + (1).to_bytes(4, "little") + b"END\0" + f[a + 20:] # a real marker label: kept
|
|
assert len(game_like) == len(f) and unfill(game_like) == game_like
|
|
|
|
|
|
def test_bank_apply_refuses_unfillable_and_prepare_fits(synth_game, tmp_path):
|
|
from ackit.game import GameError
|
|
root, _ = synth_game
|
|
ws = _ws(root, tmp_path)
|
|
s = ws.catalog.by_key["BANK:%08X" % synth.FOOT[1]]
|
|
bad = next(w for w in (synth.wem(0.1 * (1 - 0.01 * k), 2000) for k in range(40)) if not ws.fits(s, w)
|
|
and len(w) <= ws.limit(s))
|
|
with pytest.raises(GameError):
|
|
ws.apply([(s, bad, {})])
|
|
assert ws.store.entries == {}
|
|
x, sr = synth.tone(0.1, 24000, 2000, 1)
|
|
wem, _ = ws.prepare(s, x, sr)
|
|
assert ws.fits(s, wem)
|
|
|
|
|
|
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.fitting_wem(ws, s, 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.fitting_wem(ws, a, 0.2, 1500), synth.fitting_wem(ws, b, 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) == 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.fitting_wem(ws, s, 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.fitting_wem(ws, s, 0.2, 1600), {})])
|
|
ws.apply([(s, synth.fitting_wem(ws, s, 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")
|