Initial commit

This commit is contained in:
2026-10-09 02:48:24 +02:00
commit e05d973a8d
133 changed files with 101507 additions and 0 deletions

50
tests/conftest.py Normal file
View File

@@ -0,0 +1,50 @@
import os
import shutil
import sys
import pytest
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT)
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
@pytest.fixture(autouse=True)
def isolated_home(tmp_path, monkeypatch):
"""User data/config/cache go to a temp folder (never touch the real settings or stores)."""
monkeypatch.setenv("ACKIT_HOME", str(tmp_path / "home"))
yield
@pytest.fixture(scope="session")
def synth_template(tmp_path_factory):
import synth
d = tmp_path_factory.mktemp("synth_template")
originals = synth.make_game(str(d))
return str(d), originals
@pytest.fixture
def synth_game(synth_template, tmp_path):
"""A fresh copy of the synthetic game folder for each test -> (root, originals)."""
src, originals = synth_template
dst = str(tmp_path / "game")
shutil.copytree(src, dst)
return dst, originals
def _real_game():
from ackit.game import find_installs
try:
found = find_installs()
except Exception:
found = []
return found[0] if found else None
@pytest.fixture(scope="session")
def real_game_dir():
d = os.environ.get("ACKIT_TEST_GAME") or _real_game()
if not d:
pytest.skip("real game not installed")
return d

109
tests/synth.py Normal file
View File

@@ -0,0 +1,109 @@
"""Build a small synthetic Arkham City folder (WADs, a stream pack) for tests that must not depend on
real game data. Layout rules follow docs/formats/wad.md; WEMs come from ackit's own encoder."""
import os
import struct
import numpy as np
from ackit import encoder
SPEECH_ENTRY = 0xB49CDF48 # CHR_RadioScanner (3 variations here)
SINGLE_ENTRY = 0x3F21770E # an entry with one WEM
PACK_ID = 0xEDF036D6 # 'Music' stream pack
MEDIA = (0x003CB311, 0x393BC53A, 0x40000001)
AMB_ENTRY = 0x98A82EFF # AMB_Steel_B3
def tone(seconds, rate=24000, freq=440.0, ch=1):
t = np.arange(int(seconds * rate)) / rate
x = (0.3 * np.sin(2 * np.pi * freq * t) * (0.6 + 0.4 * np.sin(2 * np.pi * 2 * t))).astype(np.float32)
return np.repeat(x[:, None], ch, axis=1), rate
def wem(seconds, freq, rate=24000, ch=1):
x, sr = tone(seconds, rate, freq, ch)
return encoder.encode(x, sr, rate, ch)[0]
def _a(x, n):
return (x + n - 1) // n * n
def write_wad(path, payloads):
"""payloads: {hash: bytes}"""
s32 = lambda h: struct.unpack("<i", struct.pack("<I", h))[0]
hashes = sorted(payloads, key=s32)
n = len(hashes)
data_start = _a(0x1C + 12 * n, 0x1000)
pos, table, body = data_start, [], []
for h in hashes:
off = _a(pos, 0x1000)
p = payloads[h] + bytes(_a(len(payloads[h]), 0x800) - len(payloads[h]))
table.append((h, len(p), off))
body.append((off, p))
pos = off + len(p)
total = _a(pos, 0x1000)
out = bytearray(total)
struct.pack_into("<4s6I", out, 0, b"WWAD", 0x01020000, n, 0x1C, 12 * n, data_start, total - data_start)
for i, t in enumerate(table):
struct.pack_into("<III", out, 0x1C + 12 * i, *t)
for off, p in body:
out[off:off + len(p)] = p
os.makedirs(os.path.dirname(path), exist_ok=True)
open(path, "wb").write(bytes(out))
def grouped(wems, junk_gap=False):
"""Several WEMs in one entry, each at the next 0x800 boundary (zero padding)."""
out = bytearray()
for w in wems:
out += bytes(_a(len(out), 0x800) - len(out))
out += w
return bytes(out)
def pack(media):
"""-> (bnk bytes, wad payload) for {media id: wem}. One gap gets non-zero bytes, like the real
'Music' pack, to test byte-exact reverts."""
ids = sorted(media)
payload = bytearray()
table = []
for i, m in enumerate(ids):
start = _a(len(payload), 0x800)
gap = bytearray(start - len(payload))
if i == 1 and len(gap) > 4:
gap[1:4] = b"\x11\x22\x33"
payload += gap
table.append((m, len(media[m]), len(payload)))
payload += media[m]
payload += bytes(_a(len(payload), 0x800) - len(payload))
bank = b"BKHD" + struct.pack("<I", 8) + struct.pack("<II", 56, PACK_ID)
n = len(ids)
hdr = bytearray(0x800)
struct.pack_into("<4sII", hdr, 0, b"BKPK", 0x01020000, PACK_ID)
struct.pack_into("<IIIIII", hdr, 0x10, n, 0, 12 * n, 0x48, len(payload), 0)
struct.pack_into("<I", hdr, 0x2C, 0x48 + 12 * n)
struct.pack_into("<II", hdr, 0x38, len(bank), 0x800)
for i, t in enumerate(table):
struct.pack_into("<III", hdr, 0x48 + 12 * i, *t)
return bytes(hdr) + bank, bytes(payload)
def make_game(root):
"""Create the folder; returns the dict of original WEMs by target."""
cooked = os.path.join(root, "BmGame", "CookedPCConsole")
v = [wem(1.0, 300), wem(1.6, 500), wem(0.7, 700)]
single = wem(2.0, 350)
write_wad(os.path.join(cooked, "English(US)", "28CCF006_LOC_INT.wad"),
{SPEECH_ENTRY: grouped(v), SINGLE_ENTRY: single})
music = {MEDIA[0]: wem(1.5, 600, 48000, 2), MEDIA[1]: wem(3.0, 250, 48000, 2), MEDIA[2]: wem(0.8, 900, 48000, 2)}
bnk, payload = pack(music)
amb = grouped([wem(1.2, 200, 32000, 2), wem(0.9, 260, 32000, 2)])
write_wad(os.path.join(cooked, "SFX", "17705D3E.wad"), {PACK_ID: payload, AMB_ENTRY: amb})
open(os.path.join(cooked, "SFX", "%08X.bnk" % PACK_ID), "wb").write(bnk)
os.makedirs(os.path.join(root, "Binaries", "Win32"), exist_ok=True)
open(os.path.join(root, "Binaries", "Win32", "BatmanAC.exe"), "wb").write(b"MZ")
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()})
return originals

212
tests/test_app.py Normal file
View File

@@ -0,0 +1,212 @@
"""GUI smoke tests: the editor driven headless (offscreen Qt, synthetic game, synchronous jobs)."""
import hashlib
import os
import pytest
pytest.importorskip("PySide6")
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
os.environ["ACKIT_NO_AUDIO"] = "1"
from PySide6.QtTest import QTest # noqa: E402
from PySide6.QtWidgets import QApplication # noqa: E402
from ackit import audio, workspace as W # noqa: E402
from ackit.inject import inject # noqa: E402
import synth # noqa: E402
pytestmark = pytest.mark.gui
@pytest.fixture(scope="module")
def qapp():
return QApplication.instance() or QApplication([])
def files(root):
out = {}
for d, _, fs in os.walk(os.path.join(root, "BmGame")):
for f in fs:
p = os.path.join(d, f)
out[p] = hashlib.sha1(open(p, "rb").read()).hexdigest()
return out
@pytest.fixture
def win(qapp, synth_game, tmp_path):
from app.mainwindow import MainWindow
from app.workers import Runner
root, originals = synth_game
w = MainWindow(settings={"full_backup": True, "language": "INT"}, runner=Runner(sync=True), game_dir=root)
w.confirm = lambda *a: True
w.inform = lambda *a: None
w.editor.confirm = lambda *a: True
w.editor.inform = lambda title, text: (_ for _ in ()).throw(AssertionError("%s: %s" % (title, text)))
QTest.qWait(50)
assert w.ws is not None, w.banner_text.text()
yield w, root, originals
w.close()
def type_search(w, text):
w.search.setText(text)
w._filter_text()
def test_browse_search_filter(win):
w, root, _ = win
assert w.model.rowCount() == len(w.ws.catalog.sounds)
type_search(w, "radio scanner 2")
assert w.proxy.rowCount() == 1
type_search(w, "")
w.cmb_group.setCurrentText("Music")
assert w.proxy.rowCount() == 3 and all(w.proxy.sound_at(w.proxy.index(r, 0)).group == "Music"
for r in range(w.proxy.rowCount()))
w.clear_filters()
assert w.proxy.rowCount() == w.model.rowCount()
def test_replace_apply_revert_byte_identical(win, tmp_path):
w, root, originals = win
before = files(root)
type_search(w, "CHR_RadioScanner_2")
assert w.select_name("CHR_RadioScanner_2")
ed = w.editor
assert ed.sound.name == "CHR_RadioScanner_2" and ed.orig is not None
wav = str(tmp_path / "new.wav")
x, sr = synth.tone(1.0, 44100, 990)
audio.save_wav(wav, x, sr)
assert ed.load_file(wav)
ed.cb_match.setChecked(True)
ed._update_new()
assert ed.processed() is not None
ed.apply()
s = ed.sound
assert w.ws.state(s) == W.MODIFIED and s.key in w.model.modified
assert files(root) != before
w.cb_mod.setChecked(True)
assert w.proxy.rowCount() == 1
ed.revert()
assert files(root) == before
assert w.ws.state(s) == W.ORIGINAL and not w.model.modified
# undo of the original is impossible to lose: the store is empty again
assert not w.ws.store.entries
def test_record_simulated_input(win):
w, root, _ = win
w.select_name("CHR_RadioScanner_1")
ed = w.editor
x, sr = synth.tone(0.5, 48000, 500)
ed.recorder.start_simulated(x, sr, realtime=True)
assert ed.recorder.is_recording()
QTest.qWait(800)
assert ed.meter.value() > 0
ed.toggle_record() # stop -> becomes the replacement source
assert ed.src is not None and abs(len(ed.src[0]) - len(x)) <= sr // 50
ed.trim_silence()
ed.apply()
assert w.ws.state(ed.sound) == W.MODIFIED
w.revert_all()
assert not w.ws.modified_keys()
def test_notes_tags_favorites(win):
w, root, _ = win
w.select_name("CHR_RadioScanner_3")
s = w.selected_sounds()[0]
w.edit_notes([s], note="needs a new line", tags=["todo", "radio"])
assert w.userdata.tags(s.key) == ["radio", "todo"]
w.cmb_tag.setCurrentText("todo")
assert w.proxy.rowCount() == 1
w.cmb_tag.setCurrentIndex(0)
w._toggle_fav([s])
w.cb_fav.setChecked(True)
assert w.proxy.rowCount() == 1
type_search(w, "needs a new")
assert w.proxy.rowCount() == 1
def test_mod_export_import(win, tmp_path):
w, root, _ = win
w.select_name("CHR_RadioScanner_2")
x, sr = synth.tone(1.0, 24000, 1200)
w.editor.set_source(x, sr, "tone")
w.editor.apply()
key = w.editor.sound.key
pkg = str(tmp_path / "pkg.acmod")
w.export_mod_dialog(path=pkg, keys=[key], title="test")
assert os.path.exists(pkg)
w.revert_all()
assert not w.ws.modified_keys()
w.import_mod_dialog(path=pkg, confirm=False)
assert w.ws.modified_keys() == [key]
w.revert_all()
def test_changed_underneath_banner(win):
w, root, originals = win
w.select_name("CHR_RadioScanner_1")
x, sr = synth.tone(0.5, 24000, 1500)
w.editor.set_source(x, sr, "tone")
w.editor.apply()
s = w.editor.sound
# Steam "verify files" puts the original back behind our back
inject(w.ws.wad_path(s), {s.target: originals[s.target]}, {s.target: w.ws.limit(s)})
w.check_problems()
assert not w.banner.isHidden() and "undone outside" in w.banner_text.text()
w.reapply_lost()
assert w.ws.state(s) == W.MODIFIED
w.revert_all()
class HeldRunner:
"""Keeps jobs pending until release(), to see the loading state."""
def __init__(self, real):
self.real, self.held = real, []
def start(self, *args):
self.held.append(args)
def release(self):
while self.held:
self.real.start(*self.held.pop(0))
def test_spinners_while_loading(win, tmp_path):
w, root, _ = win
held = w.runner = HeldRunner(w.runner)
w.select_name("CHR_RadioScanner_1")
assert w.editor.wf_orig.busy and w.editor.wf_orig._timer.isActive()
held.release()
assert not w.editor.wf_orig.busy and not w.editor.wf_orig._timer.isActive()
wav = str(tmp_path / "a.wav")
audio.save_wav(wav, *synth.tone(0.3, 22050, 700))
w.editor.load_file(wav)
assert w.editor.wf_new.busy
held.release()
assert not w.editor.wf_new.busy and w.editor.src is not None
w._run_change("Working…", lambda job: None, "done")
assert not w.spinner.isHidden()
held.release()
assert w.spinner.isHidden()
w.runner = held.real
def test_background_toggle_and_filters_look_alike(win):
w, root, _ = win
w.sidebar.configure({"background_enabled": False})
assert w.sidebar.image is None
# every filter selector is the same kind of plain drop-down
assert not any(c.isEditable() for c in (w.cmb_group, w.cmb_cat, w.cmb_char, w.cmb_arch, w.cmb_tag))
def test_editor_panel_fits_its_contents(win):
w, root, _ = win
w.resize(1400, 900)
w.show()
QTest.qWait(100)
need = w.editor.sizeHint().height()
assert w.scroll.viewport().height() >= need - 2, (w.scroll.viewport().height(), need)
assert w.vsplit.sizes()[0] >= 160

77
tests/test_catalog.py Normal file
View File

@@ -0,0 +1,77 @@
"""Catalog: classification rules, naming, character labels, search; the shipped CSVs."""
import csv
import os
import pytest
from ackit import classify
from ackit.catalog import Catalog, read_csv, load_characters
from ackit.game import Game
from ackit.paths import catalog_dir
@pytest.mark.parametrize("name,group,category", [
("Speech_TAPE_JOKER_1_4", "Speech", "Interview tapes"),
("Speech_CINEMATIC_CH1_CATWOMAN_SAVED_9", "Speech", "Cinematics"),
("Speech_PRED_THUG1_FULL_Nervous_112", "Speech", "Predator chatter"),
("CHR_RadioScanner_12", "Speech", "Radio & TV"),
("AMB_Steel_B3", "Ambience", "Ambience"),
("Music_393BC53A", "Music", "Music"),
("6C151539_1", "Unknown", "Unknown"),
])
def test_classify(name, group, category):
assert classify.classify(name) == (group, category)
def test_pack_media_are_music():
assert classify.classify("003CB311", pack="Music") == ("Music", "Music")
assert classify.classify("X", pack="Music_PDLC")[1] == "Music (DLC)"
def test_infer_characters():
assert classify.infer_characters("Speech_TAPE_MADHATTER_5_10") == ["Hugo Strange", "Mad Hatter"]
assert "TYGER guard" in classify.infer_characters("Speech_PRED_TYGER2_Generic_7")
assert classify.infer_characters("Speech_COMBAT_BARKS_BANE_Hit_batman_3")[:2] == ["Bane", "Batman"]
def test_synthetic_catalog_names_and_search(synth_game):
root, _ = synth_game
cat = Catalog(Game(root), ["INT", "SFX"])
names = {s.name for s in cat.sounds}
assert {"CHR_RadioScanner_1", "CHR_RadioScanner_2", "CHR_RadioScanner_3", "Music_393BC53A"} <= names
s = cat.resolve("CHR_RadioScanner#2")
assert s.target.endswith(":1") and cat.resolve("chr_radioscanner_2.wav") is s
assert cat.resolve(s.key) is s
assert [x.name for x in cat.search("radio scanner 3")] == ["CHR_RadioScanner_3"]
with pytest.raises(LookupError) as e:
cat.resolve("CHR_RadioScaner_2")
assert "did you mean" in str(e.value)
def test_characters_csv_covers_user_lists():
chars = load_characters()
assert len(chars) > 3000
src = os.path.join(catalog_dir(), "source", "ita", "Joker.txt")
first = open(src).readline().strip()[:-4]
assert "Joker" in chars[first.lower()]
def test_shipped_csvs_are_consistent():
for f in os.listdir(catalog_dir()):
if f.endswith(".csv") and f != "characters.csv":
rows = read_csv(os.path.join(catalog_dir(), f))
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)
hdr = next(csv.reader(open(os.path.join(catalog_dir(), "ITA.csv"), encoding="utf-8")))
assert hdr[:3] == ["archive", "target", "name"]
@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
s = cat.resolve("Speech_TAPE_JOKER_1_4")
assert s.category == "Interview tapes" and s.size == 44985

45
tests/test_cli.py Normal file
View File

@@ -0,0 +1,45 @@
"""The `ackit` command line on a synthetic game."""
import os
import pytest
from ackit import audio, cli
import synth
def run(capsys, *args):
cli.main(list(args))
return capsys.readouterr().out
def test_cli_workflow(synth_game, tmp_path, capsys):
root, originals = synth_game
g = ["--game", root, "--lang", "INT"]
out = run(capsys, "find", "radioscanner", *g)
assert "CHR_RadioScanner_2" in out
wav = str(tmp_path / "line.wav")
x, sr = synth.tone(1.0, 44100, 660)
audio.save_wav(wav, x, sr)
out = run(capsys, "replace", "CHR_RadioScanner_2", wav, *g)
assert "replaced CHR_RadioScanner_2" in out
assert "CHR_RadioScanner_2" in run(capsys, "status", *g)
assert "modified" in run(capsys, "info", "CHR_RadioScanner_2", *g)
orig = str(tmp_path / "orig.wem")
run(capsys, "extract", "CHR_RadioScanner_2", orig, *g)
assert open(orig, "rb").read() == originals["%08X:1" % synth.SPEECH_ENTRY]
mod = str(tmp_path / "m.acmod")
assert "1 sounds" in run(capsys, "export-mod", mod, *g)
assert "reverted 1" in run(capsys, "revert", "--all", *g)
assert "original" in run(capsys, "status", *g)
assert "exact" in run(capsys, "import-mod", mod, *g)
run(capsys, "revert", "CHR_RadioScanner_2", *g)
def test_cli_errors_are_friendly(synth_game, capsys):
root, _ = synth_game
with pytest.raises(SystemExit) as e:
cli.main(["info", "Nope_Nothing", "--game", root, "--lang", "INT"])
assert "no sound named" in str(e.value)
with pytest.raises(SystemExit) as e:
cli.main(["info", "x", "--game", os.path.dirname(root)])
assert "not a Batman" in str(e.value)

70
tests/test_codec.py Normal file
View File

@@ -0,0 +1,70 @@
"""WEM encoding/decoding: setup transcoding, hash/alloc formulas, encode -> decode fidelity."""
import numpy as np
import pytest
from ackit import audio, encoder
from ackit.allocsize import decode_alloc, fnv1_32
from ackit.convert import library
from ackit.known_setups import db
from ackit.wem import Wem
from ackit.wwvorbis import transcode_setup, W2V, V2W
import synth
def test_codebook_library_is_the_game_one():
assert len(library().books) == 598
@pytest.mark.parametrize("entry", db())
def test_hash_and_alloc_formulas_reproduce_every_game_setup(entry):
s = bytes.fromhex(entry["setup"])
ch = int(list(entry["formats"])[0].split("/")[1][:-2])
assert fnv1_32(s) == entry["hash"]
assert decode_alloc(s, ch, library())[0] == entry["alloc"]
@pytest.mark.parametrize("rate,ch", [(24000, 1), (28000, 1), (44100, 2), (32000, 6)])
def test_encode_decode_roundtrip(rate, ch):
x, sr = synth.tone(1.5, 48000, 440, 1)
wem, info = encoder.encode(x, sr, rate, ch)
m = Wem.parse(wem)
assert m.channels == ch and m.rate == info["rate"]
# setup survives a Wwise -> Vorbis -> Wwise round trip
vs, _ = transcode_setup(m.setup_packet(), W2V, ch, library())
assert transcode_setup(vs, V2W, ch, library())[0] == m.setup_packet()
y, ysr = audio.decode_wem(wem)
ref = audio.resample(x, sr, ysr)[:, 0]
n = min(len(ref), len(y))
assert abs(len(y) - len(ref)) < 64
assert np.corrcoef(ref[:n], y[:n, 0])[0, 1] > 0.98
def test_six_channels_at_48k_fall_back_to_32k():
x, sr = synth.tone(0.5, 48000, 440, 1)
_, info = encoder.encode(x, sr, 48000, 6)
assert info["rate"] == 32000
def test_encode_fit_shrinks_or_fails():
x, sr = synth.tone(3.0, 24000, 440, 1)
full, _ = encoder.encode(x, sr, 24000, 1)
wem, info = encoder.encode_fit(x, sr, 24000, 1, limit=int(len(full) * 0.8))
assert len(wem) <= len(full) * 0.8 and "shrunk_from" in info
with pytest.raises(encoder.EncodeError):
encoder.encode_fit(x, sr, 24000, 1, limit=300)
def test_audio_helpers():
x, sr = synth.tone(1.0, 48000, 440, 2)
assert audio.to_channels(x, 1).shape == (48000, 1)
assert audio.to_channels(x[:, :1], 6).shape == (48000, 6)
assert abs(audio.peak_db(audio.normalize(x, -3.0)) + 3.0) < 0.01
assert audio.trim(x, sr, 0.25, 0.75).shape[0] == 24000
assert audio.resample(x, 48000, 24000).shape[0] == 24000
def test_long_multichannel_encode():
"""libsndfile's Vorbis writer overflowed the stack on one huge write (30 s x 6 ch); we write in blocks."""
x, sr = synth.tone(30.0, 32000, 330, 6)
wem, info = encoder.encode(x, sr, 32000, 6)
assert Wem.parse(wem).vorb["total_frames"] == 30 * 32000

64
tests/test_formats.py Normal file
View File

@@ -0,0 +1,64 @@
"""Container formats: WAD round trip, stream packs, grouped entries, target reads."""
import os
from ackit.inject import read_target, inject
from ackit.pack import Pack
from ackit.wad import Wad, write_wad
from ackit.wem import split_concatenated
import synth
def _wad(root, name):
c = os.path.join(root, "BmGame", "CookedPCConsole")
return {"INT": os.path.join(c, "English(US)", "28CCF006_LOC_INT.wad"),
"SFX": os.path.join(c, "SFX", "17705D3E.wad")}[name]
def test_wad_roundtrip_identical(synth_game, tmp_path):
root, _ = synth_game
for name in ("INT", "SFX"):
src = _wad(root, name)
w = Wad(src)
assert w.check() == []
out = str(tmp_path / ("rt_%s.wad" % name))
write_wad(w, out, {})
assert open(out, "rb").read() == open(src, "rb").read()
def test_grouped_entry_scan(synth_game):
root, originals = synth_game
w = Wad(_wad(root, "INT"))
wems, _ = split_concatenated(w.read(synth.SPEECH_ENTRY))
assert len(wems) == 3
assert all(off % 0x800 == 0 for off, _ in wems)
for i in range(3):
assert read_target(_wad(root, "INT"), "%08X:%d" % (synth.SPEECH_ENTRY, i)) == originals["%08X:%d" % (synth.SPEECH_ENTRY, i)]
def test_pack_noop_rebuild_identical(synth_game):
root, originals = synth_game
wad = _wad(root, "SFX")
bnk_path = os.path.join(os.path.dirname(wad), "%08X.bnk" % synth.PACK_ID)
bnk = open(bnk_path, "rb").read()
payload = Wad(wad).read(synth.PACK_ID)
pk = Pack(bnk)
nb, npay = pk.rebuild(payload, {})
assert nb == bnk and npay == payload
assert pk.gaps(payload) # the synthetic pack has non-zero padding, like the real 'Music' pack
for m in synth.MEDIA:
t = "%08X/%08X" % (synth.PACK_ID, m)
assert read_target(wad, t) == originals[t]
def test_pack_resize_keeps_other_media(synth_game, tmp_path):
root, originals = synth_game
wad = _wad(root, "SFX")
big = synth.wem(6.0, 330, 48000, 2)
t = "%08X/%08X" % (synth.PACK_ID, synth.MEDIA[1])
out = str(tmp_path / "17705D3E.wad")
inject(wad, {t: big}, {}, out_path=out)
assert Wad(out).check() == []
assert read_target(out, t) == big
for m in (synth.MEDIA[0], synth.MEDIA[2]):
o = "%08X/%08X" % (synth.PACK_ID, m)
assert read_target(out, o) == originals[o]

48
tests/test_game.py Normal file
View File

@@ -0,0 +1,48 @@
"""Game discovery: Steam libraries on Linux (incl. extra libraries and Proton prefix language)."""
import os
import shutil
from ackit import game as G
def test_linux_steam_and_proton_discovery(synth_game, tmp_path, monkeypatch):
root, _ = synth_game
home = tmp_path / "home"
steam = home / ".local" / "share" / "Steam"
lib2 = tmp_path / "games" / "SteamLibrary"
(steam / "steamapps").mkdir(parents=True)
common = lib2 / "steamapps" / "common"
common.mkdir(parents=True)
gdir = common / "Batman Arkham City GOTY"
shutil.copytree(root, gdir)
# a second language so the ini choice is visible
loc = gdir / "BmGame" / "CookedPCConsole"
(loc / "French(France)").mkdir()
shutil.copy(loc / "English(US)" / "28CCF006_LOC_INT.wad", loc / "French(France)" / "134795B3_LOC_FRA.wad")
(steam / "steamapps" / "libraryfolders.vdf").write_text(
'"libraryfolders"\n{\n "0" { "path" "%s" }\n "1" { "path" "%s" }\n}\n' % (steam, lib2))
cfg = (lib2 / "steamapps" / "compatdata" / "200260" / "pfx" / "drive_c" / "users" / "steamuser" / "Documents" /
"WB Games" / "Batman Arkham City GOTY" / "BmGame" / "Config")
cfg.mkdir(parents=True)
(cfg / "UserEngine.ini").write_bytes("[Engine.Engine]\r\nLanguage=Fra\r\n".encode("utf-16"))
monkeypatch.setattr(G.sys, "platform", "linux")
monkeypatch.setattr(G.os.path, "expanduser", lambda p: p.replace("~", str(home)))
found = G.find_installs()
assert [os.path.normcase(f) for f in found] == [os.path.normcase(str(gdir))]
g = G.Game(found[0])
assert set(g.archives) == {"INT", "FRA", "SFX"}
assert g.language() == "FRA"
assert g.exe and g.exe.endswith("BatmanAC.exe")
def test_case_insensitive_paths(synth_game, tmp_path):
root, _ = synth_game
assert G._iglob_one(root, "bmgame", "cookedpcconsole")
assert G.Game(root).languages == ["INT"]
def test_not_a_game_folder(tmp_path):
import pytest
with pytest.raises(G.GameError):
G.Game(str(tmp_path))

51
tests/test_realgame.py Normal file
View File

@@ -0,0 +1,51 @@
"""Read-only checks against the installed game (skipped when it is not installed)."""
import random
import pytest
from ackit.game import Game
from ackit.inject import pack_bnk_path, is_pack
from ackit.pack import Pack
from ackit.verify.selfcheck import rebuild_identical
from ackit.wad import Wad, write_wad
from ackit.wem import split_concatenated
pytestmark = pytest.mark.game
def test_wad_roundtrip_identical(real_game_dir, tmp_path):
g = Game(real_game_dir)
src = g.wad("SFX")
out = str(tmp_path / "rt.wad")
write_wad(Wad(src), out, {})
a, b = open(src, "rb"), open(out, "rb")
while True:
x, y = a.read(1 << 22), b.read(1 << 22)
assert x == y
if not x:
break
def test_stream_packs_noop_identical(real_game_dir):
g = Game(real_game_dir)
wad = g.wad("SFX")
w = Wad(wad)
for e in w.entries:
if is_pack(wad, e.hash):
bnk = open(pack_bnk_path(wad, e.hash), "rb").read()
payload = w.read(e.hash)
nb, npay = Pack(bnk).rebuild(payload, {})
assert nb == bnk and npay == payload
def test_builder_reproduces_game_wems(real_game_dir):
g = Game(real_game_dir)
lang = g.language()
w = Wad(g.wad(lang))
rnd = random.Random(5)
n = 0
for e in rnd.sample(w.entries, 80):
for _, r in split_concatenated(w.read(e.hash))[0][:3]:
assert rebuild_identical(r)
n += 1
assert n >= 80

142
tests/test_workspace.py Normal file
View File

@@ -0,0 +1,142 @@
"""Apply / revert safety on a synthetic game: byte-exact reverts, size limits, conflict detection,
mod packages."""
import hashlib
import os
import pytest
from ackit import workspace as W
from ackit.inject import SizeLimitError, inject
from ackit.modpack import export_mod, import_mod, match_mod
import synth
def sha(p):
return hashlib.sha1(open(p, "rb").read()).hexdigest()
def files(root):
out = []
for d, _, fs in os.walk(os.path.join(root, "BmGame")):
out += [os.path.join(d, f) for f in fs]
return {f: sha(f) for f in sorted(out)}
def ws_for(root, **kw):
return W.Workspace(root, archives=["INT", "SFX"], **kw)
def test_apply_in_place_and_revert_byte_identical(synth_game):
root, originals = synth_game
before = files(root)
ws = ws_for(root)
s = ws.sound("CHR_RadioScanner_2")
x, sr = synth.tone(1.2, 24000, 900)
wem, info = ws.prepare(s, x, sr)
assert len(wem) <= ws.limit(s)
ws.apply([(s, wem, {"source": "test"})])
assert ws.current_bytes(s) == wem and ws.state(s) == W.MODIFIED
assert ws.original_bytes(s) == originals[s.target]
# neighbours untouched
for i in (0, 2):
t = "%08X:%d" % (synth.SPEECH_ENTRY, i)
assert ws.current_bytes(ws.catalog.by_key["INT:" + t]) == originals[t]
assert ws.revert([s]) == [s]
assert files(root) == before
assert ws.state(s) == W.ORIGINAL and not ws.store.entries
def test_pack_media_resize_and_revert_byte_identical(synth_game):
root, originals = synth_game
before = files(root)
ws = ws_for(root)
s = ws.catalog.by_key["SFX:%08X/%08X" % (synth.PACK_ID, synth.MEDIA[1])]
assert ws.limit(s) is None
x, sr = synth.tone(8.0, 48000, 200, 2)
wem, _ = ws.prepare(s, x, sr)
assert len(wem) > len(originals[s.target])
ws.apply([(s, wem, {})])
assert ws.current_bytes(s) == wem
ws.revert_all()
assert files(root) == before
def test_size_limit_enforced(synth_game):
root, _ = synth_game
ws = ws_for(root)
s = ws.sound("CHR_RadioScanner_3")
big = synth.wem(5.0, 440)
with pytest.raises(SizeLimitError):
ws.apply([(s, big, {})])
assert not ws.modified_keys()
def test_reapply_uses_original_limit(synth_game):
root, _ = synth_game
ws = ws_for(root)
s = ws.sound("CHR_RadioScanner_2")
lim = ws.limit(s)
ws.apply([(s, synth.wem(0.3, 1000), {})]) # much smaller
assert ws.limit(s) == lim # still the original's size
ws.apply([(s, synth.wem(1.0, 800), {})])
ws.revert([s])
assert not ws.store.entries
def test_conflict_when_changed_underneath(synth_game):
root, _ = synth_game
ws = ws_for(root)
s = ws.sound("CHR_RadioScanner_1")
ws.apply([(s, synth.wem(0.5, 1200), {})])
# simulate another tool / Steam writing something else into the same slot
other = synth.wem(0.4, 150)
inject(ws.wad_path(s), {s.target: other}, {s.target: ws.limit(s)})
assert ws.state(s) == W.CHANGED
with pytest.raises(W.ConflictError):
ws.apply([(s, synth.wem(0.5, 1300), {})])
ws.apply([(s, synth.wem(0.5, 1300), {})], force=True)
ws.revert([s])
assert ws.state(s) == W.ORIGINAL
def test_lost_state_after_external_restore(synth_game):
root, originals = synth_game
ws = ws_for(root)
s = ws.sound("CHR_RadioScanner_1")
ws.apply([(s, synth.wem(0.5, 1200), {})])
inject(ws.wad_path(s), {s.target: originals[s.target]}, {s.target: ws.limit(s)}) # like 'verify files'
assert ws.state(s) == W.LOST
def test_full_backup_taken_once(synth_game):
root, _ = synth_game
ws = ws_for(root)
s = ws.sound("CHR_RadioScanner_1")
ws.apply([(s, synth.wem(0.5, 1200), {})])
rel = ws.game.rel(ws.wad_path(s))
assert ws.store.full_copy(rel)
restored = ws.restore_from_full_backup()
assert rel in restored and ws.state(s) == W.ORIGINAL
def test_mod_export_import_roundtrip(synth_game, synth_template, tmp_path):
root, _ = synth_game
ws = ws_for(root)
a = ws.sound("CHR_RadioScanner_2")
b = ws.catalog.by_key["SFX:%08X/%08X" % (synth.PACK_ID, synth.MEDIA[0])]
wa, wb = synth.wem(1.0, 820), synth.wem(2.5, 410, 48000, 2)
ws.apply([(a, wa, {}), (b, wb, {})])
pkg = str(tmp_path / "test.acmod")
assert export_mod(ws, pkg, ws.modified_keys(), "t") == 2
ws.revert_all()
# import on a fresh copy of the game ("another machine")
import shutil
other = str(tmp_path / "other")
shutil.copytree(synth_template[0], other)
ws2 = ws_for(other, store_root=str(tmp_path / "store2"))
_, items = match_mod(ws2, pkg)
assert [i.how for i in items] == ["exact", "exact"]
res = import_mod(ws2, pkg)
assert all(i.note == "exact copy" for i in res)
assert ws2.current_bytes(ws2.catalog.by_key[a.key]) == wa
assert ws2.current_bytes(ws2.catalog.by_key[b.key]) == wb