Initial commit
This commit is contained in:
121
ackit/verify/ingame.py
Normal file
121
ackit/verify/ingame.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user