Safety - Writes are serialized (Workspace.lock): two background jobs rebuilding one package (a sound and its subtitle applied side by side) lost an edit. - A store copies a bank file or package as "original" only if it matches the shipped catalog (Workspace.unoriginal_files: in-memory sounds, bank copies of voice lines, subtitles); the editor refuses to switch stores while changes are applied. A second store could otherwise resurrect a reverted sound. - Mod import in the editor asks before overwriting files changed outside the tool (it forced silently). - Revert all in the editor reverts texts too; the uninstaller counts text edits before offering to delete the store. - ackit restore-backup: the full-backup restore decisions.md described; it keeps the sounds of WADs without a full copy recorded. - build_catalog also stops when a subtitle differs from the catalog. Languages and texts - Game.language() returns languages without voices (JPN, RUS, ...) and Game.voice_archive() their INT voices; ackit text --lang JPN works. - Menu lookups parse one cached file (find & replace: minutes -> ms). - Subtitles decode cp1252 as Windows does: the UTF-8-in-cp1252 line Speech_TAPE_J_Answer_1_00 (KOR/POL/POR/RUS) is written back byte-for-byte; SUB_KOR/SUB_RUS rebuilt from the original game (only those rows changed). - Mods exported while another language is loaded keep name and line. Other fixes - Down-mix folds extra channels in at -3 dB (a 5.1 centre voice was dropped). - e2e_editor no longer overwrites the user's settings.json; kill_game waits for the game to exit before reverts. - Recorder handles Int32/UInt8 input and split frames; package block cache locked; conflict messages, CLI help examples, stale docstrings and doc references fixed; tests no longer read this PC's game language. Docs (decisions, user guide, README, subtitles.md, banks.md, catalog README, REPORT, NOTES, CLAUDE.md) updated; check_docs: 102 claims match. 148 tests; e2e_features 9/9, e2e_editor and verify PASS; game original afterwards.
272 lines
11 KiB
Python
272 lines
11 KiB
Python
"""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
|