Files
UnheardCity/tools/e2e_editor.py
Cristian Gora 1ccaa8b398 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.
2026-10-13 21:34:13 +02:00

113 lines
4.2 KiB
Python

"""Real end-to-end test of the EDITOR code path against the installed game (Windows, Steam running, WinDbg).
1. open the editor (offscreen) on the real game, select the intro music (Music_393BC53A);
2. load a 30 s probe file as replacement and press Apply (EditorPanel.apply);
3. start the game under cdb + speaker loopback: the probe must be heard, no crash, setup parsed;
4. press Revert (EditorPanel.revert) and check the WAD + pack index are byte-identical to before;
5. also apply/revert a speech line in place and check byte identity.
Uses the user's settings and originals store (an interrupted run shows up in `ackit status`); the settings file
is never written.
python tools/e2e_editor.py
"""
import hashlib
import os
import sys
import tempfile
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT)
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
os.environ["ACKIT_NO_AUDIO"] = "1"
def sha(p):
h = hashlib.sha1()
with open(p, "rb") as f:
for c in iter(lambda: f.read(1 << 22), b""):
h.update(c)
return h.hexdigest()
def main():
from PySide6.QtWidgets import QApplication
from ackit import settings as settings_mod
from app.mainwindow import MainWindow
from app.workers import Runner
app = QApplication([])
# the user's settings (same game folder and originals store as `ackit status`), never saved: the window
# stores its settings on load and on close, which would overwrite the user's file with this test's ones
w = MainWindow(settings=dict(settings_mod.load(), full_backup=True), runner=Runner(sync=True))
w.save_settings = lambda: None
w.confirm = lambda *a: True
w.editor.confirm = lambda *a: True
errors = []
w.editor.inform = lambda t, m: errors.append("%s: %s" % (t, m))
app.processEvents()
ws = w.ws
assert ws, "game not loaded"
ok = True
try:
ok = run(w, ws, errors)
finally: # never leave a change behind, whatever failed
ws.revert_all()
ws.texts.revert_all()
print("E2E EDITOR %s" % ("PASS" if ok else "FAIL"))
w.close()
return 0 if ok else 1
def run(w, ws, errors):
from ackit import audio
from ackit.inject import pack_bnk_path, parse_target
from ackit.verify import ingame
ok = True
with tempfile.TemporaryDirectory() as td:
# --- music (stream pack, resized) ---
s = ws.sound("Music_393BC53A")
wad = ws.wad_path(s)
files = [wad, pack_bnk_path(wad, parse_target(s.target)[0])]
before = {f: sha(f) for f in files}
w.search.setText("Music_393BC53A")
w._filter_text()
assert w.select_name("Music_393BC53A")
x, sr = ingame.probe_signal(30.0)
probe = os.path.join(td, "probe.wav")
audio.save_wav(probe, x, sr)
assert w.editor.load_file(probe)
w.editor.apply()
assert not errors, errors
print("applied through the editor:", ws.state(s))
rep = ingame.run(ws, s.key, mode="installed", seconds=40, log=print)
for k in ("crash", "alive_at_end", "setup_ok", "ncc_expected", "ncc_original", "result"):
print(" %-14s %s" % (k, rep[k]))
ok &= rep["result"] == "PASS"
w.editor.revert()
same = all(sha(f) == h for f, h in before.items())
print("music reverted through the editor, byte-identical:", same)
ok &= same
# --- speech line (in place) ---
t = ws.sound("Speech_TAPE_JOKER_1#4") # 4th WEM of that entry in this language's WAD
twad = ws.wad_path(t)
tb = sha(twad)
w.clear_filters()
assert w.select_name(t.name)
y, ysr = audio.decode_wem(ws.original_bytes(t))
line = os.path.join(td, "line.wav")
audio.save_wav(line, y[::-1], ysr) # reversed speech, same length
assert w.editor.load_file(line)
w.editor.apply()
assert not errors, errors
changed = sha(twad) != tb
w.editor.revert()
same2 = sha(twad) == tb
print("speech line applied (WAD changed: %s), reverted byte-identical: %s" % (changed, same2))
ok &= changed and same2
return ok
if __name__ == "__main__":
sys.exit(main())