Full correctness check against the docs: safer writes, reverts and texts
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.
This commit is contained in:
@@ -11,8 +11,14 @@ 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)."""
|
||||
"""User data/config/cache go to a temp folder (never touch the real settings or stores), and the language
|
||||
the game is set to on this machine (Documents\\WB Games\\...\\UserEngine.ini) does not leak into tests."""
|
||||
from ackit import game as G
|
||||
monkeypatch.setenv("ACKIT_HOME", str(tmp_path / "home"))
|
||||
real = G.Game.config_dirs
|
||||
mine = os.path.normcase(os.path.join(os.path.expanduser("~"), "Documents", "WB Games"))
|
||||
monkeypatch.setattr(G.Game, "config_dirs",
|
||||
lambda self: [d for d in real(self) if not os.path.normcase(d).startswith(mine)])
|
||||
yield
|
||||
|
||||
|
||||
|
||||
@@ -223,6 +223,60 @@ def test_mod_with_texts(win, tmp_path):
|
||||
tx.revert_all()
|
||||
|
||||
|
||||
def test_revert_all_reverts_sounds_and_texts(win):
|
||||
w, root, _ = win
|
||||
before = files(root)
|
||||
tx = w.ws.texts
|
||||
tx.apply({"SUB:INT:%s:0" % synth.SPEECH_EVENT: "Undo me.", "MENU:INT:FRONTEND.int:Menu:Quit": "Leave"})
|
||||
s = w.ws.sound("CHR_RadioScanner_1")
|
||||
w.ws.apply([(s, synth.wem(0.5, 1200), {})])
|
||||
w.revert_all()
|
||||
assert files(root) == before and not w.ws.store.entries and not tx.records()
|
||||
|
||||
|
||||
def test_mod_import_asks_before_overwriting(win, tmp_path):
|
||||
"""Files changed outside the editor are overwritten by a mod only when the user confirms."""
|
||||
w, root, originals = win
|
||||
s = w.ws.sound("CHR_RadioScanner_1")
|
||||
w.ws.apply([(s, synth.wem(0.5, 1200), {})])
|
||||
pkg = str(tmp_path / "m.acmod")
|
||||
w.export_mod_dialog(path=pkg, keys=[s.key], title="m")
|
||||
other = synth.wem(0.4, 150)
|
||||
inject(w.ws.wad_path(s), {s.target: other}, {s.target: w.ws.limit(s)}) # another tool writes the slot
|
||||
asked = []
|
||||
w.confirm = lambda *a: asked.append(a) or False
|
||||
w.import_mod_dialog(path=pkg, confirm=False)
|
||||
assert asked and w.ws.current_bytes(s) == other # declined: left alone
|
||||
w.confirm = lambda *a: True
|
||||
w.import_mod_dialog(path=pkg, confirm=False)
|
||||
assert w.ws.state(s) == W.MODIFIED
|
||||
w.revert_all()
|
||||
|
||||
|
||||
def test_store_cannot_change_while_changes_are_applied(win, tmp_path, monkeypatch):
|
||||
from app import mainwindow
|
||||
w, root, _ = win
|
||||
s = w.ws.sound("CHR_RadioScanner_1")
|
||||
w.ws.apply([(s, synth.wem(0.5, 1200), {})])
|
||||
store = w.ws.store.root
|
||||
|
||||
class Dlg:
|
||||
def __init__(self, settings, parent):
|
||||
pass
|
||||
|
||||
def exec(self):
|
||||
return mainwindow.QDialog.DialogCode.Accepted
|
||||
|
||||
def values(self):
|
||||
return dict(w.settings, store_root=str(tmp_path / "elsewhere"))
|
||||
monkeypatch.setattr(mainwindow, "SettingsDialog", Dlg)
|
||||
told = []
|
||||
w.inform = lambda *a: told.append(a)
|
||||
w.settings_dialog()
|
||||
assert told and w.settings.get("store_root") != str(tmp_path / "elsewhere") and w.ws.store.root == store
|
||||
w.revert_all()
|
||||
|
||||
|
||||
def test_text_undone_outside_shows_banner(win):
|
||||
w, root, _ = win
|
||||
tx = w.ws.texts
|
||||
@@ -387,3 +441,18 @@ def test_editor_panel_fits_its_contents(win):
|
||||
need = w.editor.sizeHint().height()
|
||||
assert w.scroll.viewport().height() >= need - 2, (w.scroll.viewport().height(), need)
|
||||
assert w.vsplit.sizes()[0] >= 160
|
||||
|
||||
|
||||
def test_recorder_sample_formats(qapp):
|
||||
"""Input devices may deliver 16/32-bit integer, 8-bit or float samples, and reads can split a frame."""
|
||||
import numpy as np
|
||||
from app.audioio import Recorder
|
||||
r = Recorder()
|
||||
x = np.array([[0.5, -0.25], [0.125, -1.0]], np.float32)
|
||||
for sample, raw in (("<i2", (x * 32768).astype("<i2").tobytes()), ("<i4", (x * 2147483648.0).astype("<i4").tobytes()),
|
||||
("u1", (x * 128 + 128).astype("u1").tobytes()), ("<f4", x.astype("<f4").tobytes())):
|
||||
r.chunks, r._rest, r.channels, r.sample = [], b"", 2, sample
|
||||
r._ingest(raw[:3])
|
||||
r._ingest(raw[3:])
|
||||
got, _ = r.stop()
|
||||
assert np.allclose(got, x, atol=1 / 128), sample
|
||||
|
||||
@@ -180,3 +180,19 @@ def test_every_name_resolves_to_its_sound(real_game_dir):
|
||||
for lang in g.languages:
|
||||
cat = Catalog(g, [lang, "SFX", "BANK"])
|
||||
assert all(cat.resolve(s.name) is s for s in cat.sounds), lang
|
||||
|
||||
|
||||
def test_build_catalog_stops_on_changed_subtitles(tmp_path):
|
||||
"""tools/build_catalog.py refuses to record a modified game: changed subtitles count like changed sounds."""
|
||||
import importlib.util
|
||||
from types import SimpleNamespace
|
||||
from ackit import subtitles
|
||||
spec = importlib.util.spec_from_file_location("build_catalog", os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "tools", "build_catalog.py"))
|
||||
bc = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(bc)
|
||||
row = subtitles.SubLine("INT", "Speech_X", 0, "Original.", 12, "a", "")
|
||||
subtitles.write_texts(str(tmp_path / "SUB_INT.csv"), [row])
|
||||
game = SimpleNamespace(archives={})
|
||||
assert bc.changed_since_catalog(game, str(tmp_path), [], {"INT": [row]}) == []
|
||||
assert bc.changed_since_catalog(game, str(tmp_path), [], {"INT": [row._replace(text="Edited.")]}) == \
|
||||
["SUB:INT:Speech_X:0"]
|
||||
|
||||
@@ -35,6 +35,26 @@ def test_cli_workflow(synth_game, tmp_path, capsys):
|
||||
run(capsys, "revert", "Speech_Synth_Radio_02", *g)
|
||||
|
||||
|
||||
def test_cli_text_language_without_voices(synth_game, capsys):
|
||||
"""--lang works for the languages that have only texts (INT voices are loaded for them, like the editor)."""
|
||||
root, _ = synth_game
|
||||
out = run(capsys, "text", "find", "--kind", "subs", "--game", root, "--lang", "RUS", "Строка")
|
||||
assert "SUB:RUS:%s:0" % synth.SPEECH_EVENT in out and "1 match" in out
|
||||
|
||||
|
||||
def test_cli_restore_backup(synth_game, tmp_path, capsys):
|
||||
root, _ = synth_game
|
||||
g = ["--game", root, "--lang", "INT", "--store", str(tmp_path / "store")]
|
||||
wav = str(tmp_path / "line.wav")
|
||||
audio.save_wav(wav, *synth.tone(1.0, 44100, 660))
|
||||
run(capsys, "replace", "Speech_Synth_Radio_02", wav, *g)
|
||||
out = run(capsys, "restore-backup", *g)
|
||||
assert "would copy back" in out and "--yes" in out # lists only
|
||||
assert "Speech_Synth_Radio_02" in run(capsys, "status", *g)
|
||||
assert "restored" in run(capsys, "restore-backup", "--yes", *g)
|
||||
assert "game files are original" in run(capsys, "status", *g)
|
||||
|
||||
|
||||
def test_cli_errors_are_friendly(synth_game, capsys):
|
||||
root, _ = synth_game
|
||||
with pytest.raises(SystemExit) as e:
|
||||
|
||||
@@ -59,6 +59,13 @@ def test_audio_helpers():
|
||||
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
|
||||
# down-mix keeps every channel: a voice only in the centre of 5.1 goes to both stereo sides at -3 dB (the
|
||||
# standard fold-down) and to mono at its own level
|
||||
c = np.zeros((100, 6), np.float32)
|
||||
c[:, 2] = 0.5
|
||||
assert np.allclose(audio.to_channels(c, 2), 0.5 * 0.7071) and np.allclose(audio.to_channels(c, 1), 0.5)
|
||||
same = np.repeat(x[:, :1], 2, axis=1) # identical L/R: mono = either side, no clipping
|
||||
assert np.allclose(audio.to_channels(same, 1)[:, 0], x[:, 0], atol=1e-6)
|
||||
assert audio.trim(x, sr, 0.25, 0.75).shape[0] == 24000
|
||||
assert audio.resample(x, 48000, 24000).shape[0] == 24000
|
||||
|
||||
|
||||
@@ -46,3 +46,17 @@ def test_not_a_game_folder(tmp_path):
|
||||
import pytest
|
||||
with pytest.raises(G.GameError):
|
||||
G.Game(str(tmp_path))
|
||||
|
||||
|
||||
def test_language_without_voices(synth_game, tmp_path, monkeypatch):
|
||||
"""A game set to a language with texts only (e.g. JPN) is in that language, with the INT voices."""
|
||||
root, _ = synth_game
|
||||
os.makedirs(os.path.join(root, "BmGame", "Localization", "JPN"), exist_ok=True)
|
||||
cfg = tmp_path / "cfg"
|
||||
cfg.mkdir()
|
||||
(cfg / "UserEngine.ini").write_bytes("[Engine.Engine]\r\nLanguage=Jpn\r\n".encode("utf-16"))
|
||||
monkeypatch.setattr(G.Game, "config_dirs", lambda self: [str(cfg)])
|
||||
g = G.Game(root)
|
||||
assert g.language() == "JPN" and g.voice_archive(g.language()) == "INT"
|
||||
from ackit.catalog import Catalog
|
||||
assert "INT" in Catalog(g).archives # the default catalog loads the INT voices
|
||||
|
||||
@@ -40,7 +40,7 @@ def test_stream_packs_noop_identical(real_game_dir):
|
||||
|
||||
def test_builder_reproduces_game_wems(real_game_dir):
|
||||
g = Game(real_game_dir)
|
||||
lang = g.language()
|
||||
lang = g.voice_archive(g.language())
|
||||
w = Wad(g.wad(lang))
|
||||
rnd = random.Random(5)
|
||||
n = 0
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Subtitles (dialogue objects in packages) and menu text (localization files): parse, apply, revert."""
|
||||
import hashlib
|
||||
import struct
|
||||
import os
|
||||
|
||||
import pytest
|
||||
@@ -36,6 +37,10 @@ def test_fstring_encoding():
|
||||
subtitles.encode_fstring("too long", 10, "a")
|
||||
with pytest.raises(subtitles.SubtitleError):
|
||||
subtitles.encode_fstring("→", 10, "a") # not in cp1252
|
||||
# every byte reads back as it was, also the 5 that Python's cp1252 leaves undefined (UTF-8 in two game strings)
|
||||
raw = struct.pack("<i", 7) + "Уж".encode("utf-8") + b"\x81\x8d\0"
|
||||
text = subtitles.read_fstring(raw, 0)[0]
|
||||
assert subtitles.encode_fstring(text, len(raw), "a") == raw
|
||||
|
||||
|
||||
def test_menu_parse_and_write():
|
||||
@@ -51,6 +56,15 @@ def test_menu_parse_and_write():
|
||||
loctext.write(raw, "INT", fname, {"INT:FRONTEND.int:Menu:Start": "two\nlines"})
|
||||
|
||||
|
||||
def test_menu_check_value():
|
||||
loctext.check_value("Fine", "utf-16", "F.int")
|
||||
loctext.check_value("Città", "cp1252", "F.int")
|
||||
with pytest.raises(loctext.TextError):
|
||||
loctext.check_value("two\nlines", "utf-16", "F.int")
|
||||
with pytest.raises(loctext.TextError):
|
||||
loctext.check_value("Привет", "cp1252", "F.int") # not in the file's encoding
|
||||
|
||||
|
||||
# --- subtitles through the workspace ------------------------------------------------------------
|
||||
def test_subtitle_catalog_and_link(synth_game, tmp_path):
|
||||
root, _ = synth_game
|
||||
|
||||
@@ -119,6 +119,101 @@ def test_full_backup_taken_once(synth_game):
|
||||
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."""
|
||||
|
||||
Reference in New Issue
Block a user