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

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