List and edit in-memory sounds in soundbanks
This commit is contained in:
159
ackit/verify/catalogcheck.py
Normal file
159
ackit/verify/catalogcheck.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""Cross-check the catalog against the installed game (`ackit verify --catalog`).
|
||||
|
||||
For every archive the catalog in use (shipped CSV or the user-cache scan) is compared with a fresh scan:
|
||||
missing a sound of the game that is not in the catalog
|
||||
extra a catalog row that is not in the game
|
||||
duplicate a target or a name used twice
|
||||
size/sha1 the catalog's original size or checksum differs from the game (game modified or other build)
|
||||
category the row's group/category is not what the classification rules give for its name
|
||||
character the verified speaker differs from data/catalog/characters.csv
|
||||
language the archive code does not match the WAD's language folder
|
||||
copies (BANK) stored copies or prefetch heads differ from the location index
|
||||
Run it on unmodified game files (`ackit status`), otherwise modified sounds show up as size/sha1 issues.
|
||||
"""
|
||||
import collections
|
||||
import hashlib
|
||||
import os
|
||||
import random
|
||||
|
||||
from .. import banks, classify
|
||||
from ..catalog import (Catalog, available_archives, bank_sounds, hash_names, head_owners, is_shipped, load_characters,
|
||||
scan_archive)
|
||||
from ..inject import read_target
|
||||
from ..wad import Wad
|
||||
|
||||
FOLDER_LANG = {"ENGLISH(US)": "INT", "ITALIAN": "ITA", "FRENCH(FRANCE)": "FRA", "GERMAN": "DEU",
|
||||
"SPANISH(SPAIN)": "ESN"}
|
||||
|
||||
|
||||
class Report:
|
||||
def __init__(self):
|
||||
self.checked = collections.Counter()
|
||||
self.issues = [] # (archive, kind, key, detail)
|
||||
|
||||
def add(self, archive, kind, key, detail=""):
|
||||
self.issues.append((archive, kind, key, detail))
|
||||
|
||||
def counts(self):
|
||||
return collections.Counter((a, k) for a, k, _, _ in self.issues)
|
||||
|
||||
@property
|
||||
def ok(self):
|
||||
return not self.issues
|
||||
|
||||
|
||||
def _expected_category(s):
|
||||
if s.archive == banks.ARCHIVE:
|
||||
return classify.classify(s.name, bank=s.bank)
|
||||
pack = None
|
||||
if s.is_pack_media:
|
||||
pack = hash_names().get(int(s.target.split("/")[0], 16), s.target.split("/")[0])
|
||||
return classify.classify(s.name, pack=pack)
|
||||
|
||||
|
||||
def _check_rows(rep, archive, rows, characters):
|
||||
seen_t, seen_n = set(), {}
|
||||
for s in rows:
|
||||
rep.checked[archive] += 1
|
||||
if s.archive != archive:
|
||||
rep.add(archive, "language", s.key, "row says archive %s" % s.archive)
|
||||
if s.target in seen_t:
|
||||
rep.add(archive, "duplicate", s.key, "target")
|
||||
seen_t.add(s.target)
|
||||
if s.name.lower() in seen_n:
|
||||
rep.add(archive, "duplicate", s.key, "name %s (also %s)" % (s.name, seen_n[s.name.lower()]))
|
||||
seen_n[s.name.lower()] = s.key
|
||||
g, c = _expected_category(s)
|
||||
if (s.group, s.category) != (g, c):
|
||||
rep.add(archive, "category", s.key, "%s/%s, rules give %s/%s" % (s.group, s.category, g, c))
|
||||
want = next((characters[a.lower()] for a in s.names() if a.lower() in characters), "")
|
||||
if s.character != want:
|
||||
rep.add(archive, "character", s.key, "%r, curated list says %r" % (s.character, want))
|
||||
|
||||
|
||||
def check_wad(rep, game, archive, rows, characters):
|
||||
path = game.wad(archive)
|
||||
if archive != "SFX":
|
||||
folder = os.path.basename(os.path.dirname(path)).upper()
|
||||
if FOLDER_LANG.get(folder, archive) != archive:
|
||||
rep.add(archive, "language", archive, "WAD in folder %s" % folder)
|
||||
_check_rows(rep, archive, rows, characters)
|
||||
fresh = {s.target: s for s in scan_archive(archive, path, characters=characters)}
|
||||
cat = {s.target: s for s in rows}
|
||||
for t in sorted(set(fresh) - set(cat)):
|
||||
rep.add(archive, "missing", "%s:%s" % (archive, t), fresh[t].name)
|
||||
for t in sorted(set(cat) - set(fresh)):
|
||||
rep.add(archive, "extra", "%s:%s" % (archive, t), cat[t].name)
|
||||
wad = Wad(path)
|
||||
for t in sorted(set(cat) & set(fresh)):
|
||||
s = cat[t]
|
||||
if s.size and s.size != fresh[t].size and not s.is_pack_media:
|
||||
rep.add(archive, "size", s.key, "catalog %d, game %d" % (s.size, fresh[t].size))
|
||||
if s.sha1:
|
||||
h = hashlib.sha1(read_target(path, t, wad)).hexdigest()[:16]
|
||||
if h != s.sha1:
|
||||
rep.add(archive, "sha1", s.key, "game file differs from the catalog's original")
|
||||
|
||||
|
||||
def check_banks(rep, game, rows, locations, characters, quick=False, sample=40, progress=None):
|
||||
_check_rows(rep, banks.ARCHIVE, rows, characters)
|
||||
files = banks.bank_files(game.cooked)
|
||||
if quick:
|
||||
pk = [f for f in files if f.lower().endswith(".upk")]
|
||||
rnd = random.Random(1)
|
||||
files = [f for f in files if f.startswith("SFX/")] + sorted(rnd.sample(pk, min(sample, len(pk))))
|
||||
sc = banks.scan(game.cooked, progress, files=files)
|
||||
owners = head_owners(game, sc)
|
||||
index = collections.defaultdict(set)
|
||||
for m, ls in locations.items():
|
||||
for l in ls:
|
||||
if l.file in set(files):
|
||||
index[(l.media, l.kind)].add((l.file, l.offset, l.size, l.sound if l.kind == banks.PREFETCH_HEAD else ""))
|
||||
found = collections.defaultdict(set)
|
||||
for l in sc.locations:
|
||||
found[(l.media, l.kind)].add((l.file, l.offset, l.size, owners.get(l.media, "") if l.kind == banks.PREFETCH_HEAD else ""))
|
||||
for k in sorted(set(found) | set(index)):
|
||||
if found.get(k, set()) != index.get(k, set()):
|
||||
rep.add(banks.ARCHIVE, "copies", "%08X" % k[0], "%s: index %d, game %d"
|
||||
% ("head" if k[1] == banks.PREFETCH_HEAD else "copies", len(index.get(k, ())), len(found.get(k, ()))))
|
||||
cat = {s.target: s for s in rows}
|
||||
fresh = {s.target: s for s in bank_sounds(sc, characters)}
|
||||
for t in sorted(set(fresh) - set(cat)):
|
||||
rep.add(banks.ARCHIVE, "missing", "BANK:" + t, fresh[t].bank)
|
||||
if not quick:
|
||||
for t in sorted(set(cat) - set(fresh)):
|
||||
rep.add(banks.ARCHIVE, "extra", "BANK:" + t, cat[t].name)
|
||||
for t in sorted(set(cat) & set(fresh)):
|
||||
s, f = cat[t], fresh[t]
|
||||
if s.sha1 and f.sha1 and s.sha1 != f.sha1:
|
||||
rep.add(banks.ARCHIVE, "sha1", s.key, "game file differs from the catalog's original")
|
||||
if f.size < s.size:
|
||||
rep.add(banks.ARCHIVE, "size", s.key, "catalog %d, game %d" % (s.size, f.size))
|
||||
for m, ls in locations.items():
|
||||
for l in ls:
|
||||
if l.kind == banks.PREFETCH_HEAD and not l.sound:
|
||||
rep.add(banks.ARCHIVE, "copies", "%08X" % m, "prefetch head without a speech line")
|
||||
|
||||
|
||||
def run(game, archives=None, quick=False, log=print, progress=None):
|
||||
"""Check the given archives (default: all). Returns a Report."""
|
||||
rep = Report()
|
||||
characters = load_characters()
|
||||
archives = archives or available_archives(game)
|
||||
cat = Catalog(game, archives, progress)
|
||||
by_arch = collections.defaultdict(list)
|
||||
for s in cat.sounds:
|
||||
by_arch[s.archive].append(s)
|
||||
for a in archives:
|
||||
log("checking %s ..." % a)
|
||||
if a == banks.ARCHIVE:
|
||||
check_banks(rep, game, by_arch[a], cat.locations, characters, quick=quick, progress=progress)
|
||||
else:
|
||||
check_wad(rep, game, a, by_arch[a], characters)
|
||||
known = {n.lower() for s in cat.sounds for n in s.names()}
|
||||
# speaker names are the same in every language; only meaningful for the build the lists were made for
|
||||
if any(a in game.languages and is_shipped(game, a) for a in archives):
|
||||
for n in sorted(characters):
|
||||
if n not in known:
|
||||
rep.add("characters.csv", "extra", n, "no sound with this name")
|
||||
return rep
|
||||
@@ -61,6 +61,43 @@ def read_mem(pid, addr, n):
|
||||
k32.CloseHandle(h)
|
||||
|
||||
|
||||
class _MBI(ctypes.Structure):
|
||||
_fields_ = [("BaseAddress", ctypes.c_void_p), ("AllocationBase", ctypes.c_void_p),
|
||||
("AllocationProtect", wt.DWORD), ("RegionSize", ctypes.c_size_t), ("State", wt.DWORD),
|
||||
("Protect", wt.DWORD), ("Type", wt.DWORD)]
|
||||
|
||||
|
||||
k32.VirtualQueryEx.argtypes = [wt.HANDLE, ctypes.c_void_p, ctypes.POINTER(_MBI), ctypes.c_size_t]
|
||||
k32.VirtualQueryEx.restype = ctypes.c_size_t
|
||||
MEM_COMMIT, PAGE_NOACCESS, PAGE_GUARD = 0x1000, 0x01, 0x100
|
||||
|
||||
|
||||
def search_memory(pid, needles, limit=1 << 32):
|
||||
"""Which of `needles` (bytes) occur anywhere in the committed memory of the process -> set."""
|
||||
h = k32.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
|
||||
if not h:
|
||||
return set()
|
||||
found = set()
|
||||
try:
|
||||
addr, mbi = 0, _MBI()
|
||||
while addr < limit and len(found) < len(needles):
|
||||
if not k32.VirtualQueryEx(h, ctypes.c_void_p(addr), ctypes.byref(mbi), ctypes.sizeof(mbi)):
|
||||
break
|
||||
size = mbi.RegionSize
|
||||
if mbi.State == MEM_COMMIT and not mbi.Protect & (PAGE_NOACCESS | PAGE_GUARD):
|
||||
buf = ctypes.create_string_buffer(size)
|
||||
got = ctypes.c_size_t()
|
||||
if k32.ReadProcessMemory(h, ctypes.c_void_p(addr), buf, size, ctypes.byref(got)):
|
||||
data = buf.raw[:got.value]
|
||||
for n in needles:
|
||||
if n not in found and n in data:
|
||||
found.add(n)
|
||||
addr = (mbi.BaseAddress or 0) + size
|
||||
finally:
|
||||
k32.CloseHandle(h)
|
||||
return found
|
||||
|
||||
|
||||
u32 = ctypes.WinDLL("user32", use_last_error=True)
|
||||
WM_KEYDOWN, WM_KEYUP = 0x0100, 0x0101
|
||||
VK = dict(enter=0x0D, space=0x20, esc=0x1B, up=0x26, down=0x28, left=0x25, right=0x27, e=0x45)
|
||||
|
||||
@@ -119,3 +119,92 @@ def run(ws, key=DEFAULT_TARGET, wem=None, mode="modded", seconds=40.0, threshold
|
||||
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
|
||||
|
||||
@@ -66,11 +66,23 @@ def run(ws, sample=300, log=print):
|
||||
break
|
||||
log("%-4s layout %s, rebuilt %d/%d sample WEMs byte-identical" % (arch, "OK" if not probs else probs[:2], good, n))
|
||||
ok &= not probs and good >= n - 2 # 4 looping music tracks are known exceptions
|
||||
bank = [s for s in ws.catalog.sounds if s.archive == "BANK"]
|
||||
if bank:
|
||||
n = good = 0
|
||||
for s in random.Random(1).sample(bank, min(len(bank), sample)):
|
||||
n += 1
|
||||
try:
|
||||
good += rebuild_identical(ws.current_bytes(s))
|
||||
except Exception:
|
||||
pass
|
||||
log("BANK in-memory sounds: rebuilt %d/%d sample WEMs byte-identical" % (good, n))
|
||||
ok &= good == n
|
||||
# 3
|
||||
t = np.arange(48000 * 2) / 48000.0
|
||||
sig = (0.3 * np.sin(2 * np.pi * 330 * t) * np.sin(2 * np.pi * 1.5 * t)).astype(np.float32)[:, None]
|
||||
for s in [x for x in ws.catalog.sounds if x.group == "Speech"][:1] + \
|
||||
[x for x in ws.catalog.sounds if x.group == "Music"][:1]:
|
||||
[x for x in ws.catalog.sounds if x.group == "Music"][:1] + \
|
||||
[x for x in ws.catalog.sounds if x.archive == "BANK" and x.seconds >= 1.5][:1]:
|
||||
wem, info = ws.prepare(s, sig, 48000)
|
||||
y, sr = audio.decode_wem(wem)
|
||||
ref = audio.to_channels(audio.resample(sig, 48000, sr), y.shape[1])
|
||||
|
||||
Reference in New Issue
Block a user