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:
2026-10-13 21:34:13 +02:00
parent a6ef3b9458
commit 1ccaa8b398
41 changed files with 846 additions and 186 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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"]

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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."""