"""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("Speech_Synth_Radio_02") 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("Speech_Synth_Radio_02") 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_restore_from_full_backup_keeps_wads_without_a_copy(synth_game, tmp_path): """Without full WAD backups, a restore puts the packages back but keeps the WAD sounds recorded (they stay modified and revertible).""" root, _ = synth_game before = files(root) ws = W.Workspace(root, store_root=str(tmp_path / "store"), archives=["INT", "SFX", "BANK"], full_backup=False) wad_s = ws.sound("CHR_RadioScanner_1") bank_s = ws.catalog.by_key["BANK:%08X" % synth.DLG_MEDIA] ws.apply([(wad_s, synth.wem(0.5, 1200), {}), (bank_s, synth.fitting_wem(ws, bank_s, 0.1, 1900), {})]) ws.texts.apply({"SUB:INT:%s:0" % synth.SPEECH_EVENT: "Restored."}) ws.restore_from_full_backup() assert ws.state(bank_s) == W.ORIGINAL and not ws.texts.records() assert ws.state(wad_s) == W.MODIFIED and list(ws.store.entries) == [wad_s.key] ws.revert_all() assert files(root) == before def test_writes_are_serialized(synth_game, tmp_path, monkeypatch): """The editor runs writes as background jobs: a sound and a subtitle written into one package at the same time both end up in it (a slow rebuild of the first must not overwrite the second).""" import threading import time root, _ = synth_game ws = W.Workspace(root, store_root=str(tmp_path / "store"), archives=["INT", "SFX", "BANK"]) s = ws.catalog.by_key["BANK:%08X" % synth.DLG_MEDIA] wem = synth.fitting_wem(ws, s, 0.1, 1900) build = W.Workspace._build def slow(self, path, orig): out = build(self, path, orig) time.sleep(0.5 if threading.current_thread().name == "sound" else 0) return out monkeypatch.setattr(W.Workspace, "_build", slow) key = "SUB:INT:%s:0" % synth.SPEECH_EVENT a = threading.Thread(name="sound", target=lambda: ws.apply([(s, wem, {})])) b = threading.Thread(target=lambda: (time.sleep(0.15), ws.texts.apply({key: "Both."}))) a.start(), b.start() a.join(), b.join() assert ws.state(s) == W.MODIFIED and ws.texts.state(key) == "modified" def test_another_store_does_not_take_modified_files_as_originals(synth_game, tmp_path): """A second originals store (Settings > Originals store changed, `--store`) must not copy a package that the first store modified as its "original": the catalog's checksums and texts show it is not original.""" from ackit.originals import sha1_16 from ackit.texts import TextConflictError root, _ = synth_game A = W.Workspace(root, store_root=str(tmp_path / "A"), archives=["INT", "SFX", "BANK"]) s = A.catalog.by_key["BANK:%08X" % synth.DLG_MEDIA] orig_sha = sha1_16(A.current_bytes(s)) sub = "SUB:INT:%s:0" % synth.SPEECH_EVENT rel = A.locations(s)[0].file A.apply([(s, synth.fitting_wem(A, s, 0.1, 1900), {})]) A.texts.apply({sub: "Edited."}) def store_b(): # the synthetic game has no shipped catalog: give B the checksums and texts a shipped one would have B = W.Workspace(root, store_root=str(tmp_path / "B"), archives=["INT", "SFX", "BANK"]) B._shipped = True B.catalog.bank_sha1 = {synth.DLG_MEDIA: orig_sha} B.texts._shipped_subs = ({}, {}) return B B = store_b() assert B.unoriginal_files([B.bank_path(rel)]) == [B.bank_path(rel)] with pytest.raises(TextConflictError): B.texts.apply({"SUB:INT:%s:1" % synth.SPEECH_EVENT: "Other."}) A.revert([s]) B = store_b() by_file = {} for l in B.texts.subtitle_locations(sub): by_file.setdefault(l.file, []).append(l) B.texts._shipped_subs = (by_file, {("INT", synth.SPEECH_EVENT, 0): "Scanner line two."}) assert B.texts.unoriginal_subtitles(rel) == [sub] # A's subtitle is still applied A.texts.revert_all() assert B.unoriginal_files([B.bank_path(rel)]) == [] def test_mod_export_of_a_change_made_in_another_language(synth_game, tmp_path): """A change made while another language was loaded keeps its name and line identity in a mod package.""" import json import zipfile root, _ = synth_game store = str(tmp_path / "store") every = W.Workspace(root, store_root=store, archives=["SFX", "BANK"]) # every language's lines s = every.catalog.by_key["BANK:%08X" % synth.GRUNT_MEDIA["ITA"]] every.apply([(s, synth.fitting_wem(every, s, 0.1, 900), {})]) ws = W.Workspace(root, store_root=store, archives=["INT", "SFX", "BANK"]) # now INT is loaded assert s.key not in ws.catalog.by_key pkg = str(tmp_path / "m.acmod") export_mod(ws, pkg, ws.modified_keys(), "m") it = json.loads(zipfile.ZipFile(pkg).read("manifest.json"))["items"][0] assert (it["name"], it["line"]) == (s.name, s.line) and s.line assert ws.revert_all() and not ws.store.entries def test_mod_from_another_language_matched_by_line(synth_game, tmp_path): """A voice line from another language is found through the game's line identity (its WAD position there can differ); a voice line from a package without line identities is never guessed by name.""" import json import zipfile root, _ = synth_game ws = ws_for(root, store_root=str(tmp_path / "store")) s = ws.catalog.by_key["INT:%08X:1" % synth.SPEECH_ENTRY] assert s.line and s.name == synth.SPEECH_EVENT # the game's line, named after its event ws.apply([(s, synth.wem(0.3, 1000), {})]) pkg = str(tmp_path / "other_lang.acmod") export_mod(ws, pkg, ws.modified_keys(), "t") ws.revert_all() def rewrite(change): with zipfile.ZipFile(pkg) as z: files = {n: z.read(n) for n in z.namelist()} man = json.loads(files["manifest.json"]) change(man["items"][0]) files["manifest.json"] = json.dumps(man).encode() with zipfile.ZipFile(pkg, "w") as z: for n, b in files.items(): z.writestr(n, b) def other_language(it): # as if made on the ITA game, at another position it["archive"], it["target"], it["name"] = "ITA", "%08X:2" % synth.SPEECH_ENTRY, "Speech_Synth_Radio_02" rewrite(other_language) _, items = match_mod(ws, pkg) assert items[0].how == "same line" and items[0].sound is s rewrite(lambda it: it.pop("line")) # an older package: names alone are no proof _, items = match_mod(ws, pkg) assert items[0].how == "missing" and "older version" in items[0].note def test_mod_export_import_roundtrip(synth_game, synth_template, tmp_path): root, _ = synth_game ws = ws_for(root) a = ws.sound("Speech_Synth_Radio_02") 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