"""Automated in-game test (Windows): does a replaced sound actually play? Flow: apply the replacement through `Workspace.apply` (the same code path as the editor) -> start the game under cdb with logging breakpoints on the decoder + record the speakers (WASAPI loopback) -> check: no crash, the codec setup of the injected WEM was created and parsed OK, and the injected audio is heard -> kill the game -> revert and check the files are byte-identical again. Default target: SFX music EDF036D6/393BC53A (intro/title music), which the game starts ~5 s after launch without input. That slot ignores sources shorter than ~10 s, so probes are 30 s long. Only correlation scores of the recording are kept (the loopback may contain other apps' audio). """ import hashlib import json import os import re import time import numpy as np from .. import audio from ..originals import sha256 DEFAULT_TARGET = "SFX:EDF036D6/393BC53A" RATE = 48000 def probe_signal(seconds=30.0, rate=RATE, seed=1234): """Pink-ish noise + vibrato tone: easy to recognise in a recording.""" rng = np.random.default_rng(seed) n = int(seconds * rate) white = rng.standard_normal(n) pink = np.cumsum(white) * 0.002 pink -= np.convolve(pink, np.ones(2048) / 2048, mode="same") t = np.arange(n) / rate tone = 0.25 * np.sin(2 * np.pi * (440 + 20 * np.sin(2 * np.pi * 0.5 * t)) * t) x = (pink / (np.max(np.abs(pink)) + 1e-9) * 0.35 + tone).astype(np.float32) return x[:, None], rate def _mono48(wem): x, sr = audio.decode_wem(wem) return audio.to_channels(audio.resample(x, sr, RATE), 1)[:, 0] def _sha1_file(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 run(ws, key=DEFAULT_TARGET, wem=None, mode="modded", seconds=40.0, threshold=0.5, logdir=None, revert_after=True, log=print): """mode: 'modded' (apply `wem`, test, revert), 'baseline' (nothing), 'installed' (test the current state). Returns a report dict with report['result'] in ('PASS', 'FAIL').""" from .debugger import Run, kill_game from .loopback import LoopbackRecorder, detect_chunks from ..inject import pack_bnk_path, parse_target s = ws.catalog.resolve(key) wad = ws.wad_path(s) files = [wad] + ([pack_bnk_path(wad, parse_target(s.target)[0])] if s.is_pack_media else []) logdir = logdir or os.path.join(os.getcwd(), "logs") os.makedirs(logdir, exist_ok=True) stamp = time.strftime("%Y%m%d_%H%M%S") logp = os.path.join(logdir, "ingame_%s.log" % stamp) kill_game() report = dict(target=s.key, name=s.name, mode=mode) before = {f: _sha1_file(f) for f in files} if mode == "modded" else {} if mode == "modded": ws.apply([(s, wem, {"source": "ingame-test"})], progress=log) expect = ws.current_bytes(s) orig = ws.original_bytes(s) from ..wem import Wem report["expect_hash"] = "%08x" % Wem.parse(expect).vorb["codebook_hash"] ref_expect, ref_orig = _mono48(expect), _mono48(orig) rec = LoopbackRecorder(seconds + 10) rec.start() runner = Run(ws.game.exe, logp) try: report.update(runner.start()) while time.time() - runner.t0 < seconds and runner.alive(): time.sleep(0.5) if re.search(r"^ACKIT_CRASH", runner.log(), re.M): break report["alive_at_end"] = runner.alive() finally: runner.stop() rec.stop_flag = True rec.join(5) if mode == "modded" and revert_after: ws.revert([s], progress=log) report["reverted_byte_identical"] = all(_sha1_file(f) == h for f, h in before.items()) text = runner.log() report["crash"] = bool(re.search(r"^ACKIT_CRASH", text, re.M)) ev = [] for ln in text.splitlines(): m = re.match(r"ACKIT_SETUP hash=([0-9a-f]+)", ln) if m: ev.append([m.group(1), "cached"]) m = re.match(r"ACKIT_PARSE ret=([0-9a-f]+)", ln) if m and ev and ev[-1][1] == "cached": ev[-1][1] = m.group(1) report["setups"] = ["%s:%s" % tuple(e) for e in ev] mine = [r for h, r in ev if h == report["expect_hash"]] report["setup_ok"] = bool(mine) and all(r in ("0", "cached") for r in mine) cap = rec.audio() report["capture_error"] = rec.error report["ncc_expected"] = round(detect_chunks(cap, ref_expect)[0], 3) report["ncc_original"] = round(detect_chunks(cap, ref_orig)[0], 3) ok = (not report["crash"] and report["alive_at_end"] and report["setup_ok"] and report["ncc_expected"] >= threshold and (mode == "baseline" or sha256(expect) == sha256(orig) or report["ncc_expected"] > report["ncc_original"]) and report.get("reverted_byte_identical", True)) report["result"] = "PASS" if ok else "FAIL" json.dump(report, open(logp.replace(".log", ".json"), "w"), indent=1) return report # --- texts -------------------------------------------------------------------------------------- def run_texts(ws, seconds=45.0, menu_files=("FRONTEND", "GFXUI"), n_menu=12, n_subs=12, logdir=None, log=print): """Write markers into menu texts of the game's language (front-end files, loaded at startup) and into some subtitles, start the game, then check: no crash, the menu markers are in the game's memory (it loaded the edited localization files), and after the revert every touched file is byte-identical. Subtitles are not loaded on the title screen, so for them the check is that the patched packages do not break the game.""" import random from .debugger import Run, kill_game, search_memory tx = ws.texts lang = ws.game.language() menu = [e for e in tx.menu(lang) if e.file.split(".")[0].upper() in menu_files and len(e.value) >= 4] rnd = random.Random(3) picks = rnd.sample(menu, min(n_menu, len(menu))) changes = {"MENU:" + e.key: "ACKIT%02d %s" % (i, e.value) for i, e in enumerate(picks)} subs = [s for s in tx.subtitles(lang) if s.room >= 12] if lang in tx.subtitle_languages() else [] for i, s in enumerate(rnd.sample(subs, min(n_subs, len(subs)))): changes["SUB:%s:%s:%d" % (lang, s.speech, s.line)] = ("AKS%02d " % i + s.text)[:s.room] files = sorted({p for k in changes for p in tx.files(k)}) logdir = logdir or os.path.join(os.getcwd(), "logs") os.makedirs(logdir, exist_ok=True) logp = os.path.join(logdir, "ingame_texts_%s.log" % time.strftime("%Y%m%d_%H%M%S")) report = dict(mode="texts", lang=lang, menu_texts=len(picks), subtitles=len(changes) - len(picks), files=len(files)) kill_game() before = {f: _sha1_file(f) for f in files} tx.apply(changes, progress=log) needles = {("ACKIT%02d " % i).encode("utf-16-le") for i in range(len(picks))} runner = Run(ws.game.exe, logp) found = set() try: report.update(runner.start()) while time.time() - runner.t0 < seconds and runner.alive(): time.sleep(0.5) if re.search(r"^ACKIT_CRASH", runner.log(), re.M): break if runner.alive(): found = search_memory(report["pid"], needles) report["alive_at_end"] = runner.alive() finally: runner.stop() tx.revert(list(changes), progress=log) report["reverted_byte_identical"] = all(_sha1_file(f) == h for f, h in before.items()) report["crash"] = bool(re.search(r"^ACKIT_CRASH", runner.log(), re.M)) report["menu_markers_in_memory"] = len(found) ok = (not report["crash"] and report["alive_at_end"] and report["menu_markers_in_memory"] > 0 and report["reverted_byte_identical"]) report["result"] = "PASS" if ok else "FAIL" json.dump(report, open(logp.replace(".log", ".json"), "w"), indent=1) return report # --- in-memory sounds (soundbanks inside the packages) ------------------------------------------ PROBE_RATE, PROBE_Q = 28000, 4 # a codebook setup the game's own WEMs do not use (E10) def bank_probes(ws, sounds): """[(sound, probe WEM)]: the same probe format for every sound, shortened to fit its slot.""" from ..encoder import encode x, sr = probe_signal(10.0) out = [] for s in sounds: lim = ws.limit(s) secs = min(10.0, max(0.4, s.seconds or 1.0)) while secs >= 0.1: wem, _ = encode(x[:int(secs * sr)], sr, PROBE_RATE, 1, PROBE_Q) if len(wem) <= lim: out.append((s, wem)) break secs *= 0.7 return out def run_banks(ws, keys=None, seconds=45.0, logdir=None, log=print): """Replace in-memory sounds (default: every sound stored in the Frontend*/Startup packages) with probes, start the game, then check: no crash, the probe data is in the game's memory (the modified packages were loaded), and after the revert every touched file is byte-identical. Probes decoded (codec setup log) and heard (loopback correlation) are reported too, but no in-memory sound plays on the title screen without input, so they are not required. Returns a report dict (report['result'] PASS/FAIL).""" from .debugger import Run, kill_game, search_memory from .loopback import LoopbackRecorder, detect_chunks from ..wem import Wem if keys: sounds = [ws.catalog.resolve(k) for k in keys] else: # everything stored in the packages loaded for the title screen (rain ambience plays without input) sounds = [s for s in ws.catalog.sounds if s.archive == "BANK" and any( l.file.lower().startswith(("frontend", "startup")) for l in ws.locations(s))] probes = bank_probes(ws, sounds) files = sorted({f for s, _ in probes for f in ws.files(s)}) logdir = logdir or os.path.join(os.getcwd(), "logs") os.makedirs(logdir, exist_ok=True) logp = os.path.join(logdir, "ingame_banks_%s.log" % time.strftime("%Y%m%d_%H%M%S")) report = dict(mode="banks", sounds=len(sounds), probes=len(probes), files=len(files)) kill_game() before = {f: _sha1_file(f) for f in files} hashes = {"%08x" % Wem.parse(w).vorb["codebook_hash"] for _, w in probes} needles = set() for _, w in probes: if len(w) > 400: needles.add(w[len(w) // 2:len(w) // 2 + 32]) report.update(probe_hashes=sorted(hashes), needles=len(needles)) ws.apply([(s, w, {"source": "ingame-test"}) for s, w in probes], progress=log) from .debugger import ADDR # also log every setup parse (in-memory sources may not go through the stream setup cache) extra = 'bp %#x ".printf \\"ACKIT_PARSE2 hash=%%08x\\\\n\\", poi(poi(@esp+4)+0x9c); gc"' % ADDR["parse_setup"] runner = Run(ws.game.exe, logp, script_extra=extra) found = set() rec = LoopbackRecorder(seconds + 10) rec.start() try: report.update(runner.start()) pid = report["pid"] while time.time() - runner.t0 < seconds and runner.alive(): time.sleep(0.5) if re.search(r"^ACKIT_CRASH", runner.log(), re.M): break if runner.alive(): found = search_memory(pid, needles) report["alive_at_end"] = runner.alive() finally: runner.stop() rec.stop_flag = True rec.join(5) ws.revert([s for s, _ in probes], progress=log) report["reverted_byte_identical"] = all(_sha1_file(f) == h for f, h in before.items()) text = runner.log() report["crash"] = bool(re.search(r"^ACKIT_CRASH", text, re.M)) ref = probe_signal(0.35)[0][:, 0] report["capture_error"] = rec.error report["ncc_probe"] = round(detect_chunks(rec.audio(), ref)[0], 3) seen = re.findall(r"ACKIT_(?:SETUP|PARSE2) hash=([0-9a-f]+)", text) report["probe_streams_decoded"] = sum(1 for h in seen if h in hashes) report["setups_logged"] = len(seen) report["probes_in_memory"] = len(found) ok = (not report["crash"] and report["alive_at_end"] and report["probes_in_memory"] > 0 and report["reverted_byte_identical"]) report["result"] = "PASS" if ok else "FAIL" json.dump(report, open(logp.replace(".log", ".json"), "w"), indent=1) return report