mirror of
https://github.com/valentderah/totk-pc-recomp.git
synced 2026-11-11 01:41:02 +00:00
The generator emits a guest poll such as `ldr w9, [x19]; cmp w9, #1; b.eq` as a plain C load, and clang hoists it out of the loop: the lock wait in f_7001fa0e2c compiled to `cmp; jne; jmp .`, so a thread that found the lock held spun forever after its holder released it. A fight run hung this way in 3 of 13 launches on an RX 9070, with the UBO snapshot on and off; gdb showed the thread on that `jmp` in every sample. The gate is an opaque call on every back-edge of the 868 loops tools/spin_waits.py lists, so each pass reloads memory: the exe goes from 182 self-jumps to 148, and 144 of those are the game's own `b .` instructions. 8 of 8 fight runs then passed; tests/diff/func.py with --patches passed 5470 cases on 878 functions, and the four probes passed on 1.4.3. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
428 lines
18 KiB
Python
428 lines
18 KiB
Python
"""Differential test of whole emitted functions: recomp/gen.py's C for a guest function against Unicorn running the
|
|
same function from its entry until it returns.
|
|
|
|
Picks functions from the ExeFS modules: no branches out of the function, no SVC, BRK, system register, exclusive or
|
|
atomic instructions; with conditional branches, and loops, jump tables, SIMD and calls where the module has them.
|
|
Emits them with the generator's own Emitter, compiles them with tests/diff/harness.c, and runs each from random
|
|
states: x0-x7 point into the scratch buffer (or hold small integers), the buffer holds pointers into itself and random
|
|
data, SP sits near the buffer's top and LR is a sentinel address. Unicorn (Cortex-A57) stops when PC reaches the
|
|
sentinel. A case is skipped when Unicorn faults, writes outside the scratch buffer (module images are mapped
|
|
read-only), leaves the function, or runs longer than --steps instructions. Compares X0-X30, SP, V0-V31 and the
|
|
scratch buffer; NZCV is local to a generated function (recomp/flags.py).
|
|
|
|
Every BL and BLR goes to one stub on both sides (rt_stub in harness.c, Runner.stub here) instead of its callee. The
|
|
stub folds the guest state at the call into a digest and sets the caller-saved registers from it, so the test sees
|
|
whether the emitted code had every register in the Cpu at the call and reads them back after it. Callers of another
|
|
picked function are dropped, as the C side would run that function instead of the stub.
|
|
|
|
With --patches, the spin waits of the table (recomp/patches.py `spin`) are emitted with their gates, and functions
|
|
that hold one come first (a quarter of the picks at most); a case whose loop does not leave runs into --steps.
|
|
|
|
Usage: func.py [--exefs DIR] [--modules main,subsdk0,sdk] [--functions N] [--cases K] [--seed S] [--jobs J]
|
|
[--patches FILE]
|
|
"""
|
|
import argparse
|
|
import os
|
|
import platform
|
|
import random
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import zlib
|
|
from collections import Counter
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import unicorn as uc
|
|
from unicorn import arm64_const as A
|
|
|
|
from common import BUF, BUF_SIZE, MSYS_BIN, ROOT, Emu, compiler, diff_state, rand_fpcr, rand_v, rand_x, run_harness
|
|
import gen
|
|
import patches
|
|
from layout import layout
|
|
from module import Module
|
|
|
|
SENTINEL = 0x7210000000 # LR on entry; never executed
|
|
PTR_LO, PTR_HI = BUF + 0x8000, BUF + 0x28000
|
|
STACK_TOP = BUF + 0x3F000
|
|
MIN_INSNS, MAX_INSNS = 6, 600
|
|
|
|
|
|
FNV = 0x100000001B3
|
|
M64 = (1 << 64) - 1
|
|
|
|
|
|
def splitmix(z):
|
|
z = (z + 0x9E3779B97F4A7C15) & M64
|
|
z = ((z ^ (z >> 30)) * 0xBF58476D1CE4E5B9) & M64
|
|
z = ((z ^ (z >> 27)) * 0x94D049BB133111EB) & M64
|
|
return z ^ (z >> 31)
|
|
|
|
|
|
def call_mask(w):
|
|
return ((w & 0xFC000000) == 0x94000000) | ((w & 0xFFFFFC1F) == 0xD63F0000) # BL, BLR
|
|
|
|
|
|
def forbidden_mask(w):
|
|
"""Words a picked function must not contain."""
|
|
m = (w & 0xFF000000) == 0xD4000000 # SVC, HVC, SMC, BRK, HLT, DCPS
|
|
sysi = (w & 0xFFC00000) == 0xD5000000 # system: MRS, MSR, SYS (DC ZVA), hints, barriers
|
|
hint_or_barrier = ((w & 0xFFFFF000) == 0xD5032000) | ((w & 0xFFFFF000) == 0xD5033000)
|
|
m |= sysi & ~hint_or_barrier
|
|
m |= (w & 0x3F000000) == 0x08000000 # exclusives, load-acquire / store-release, CAS
|
|
m |= (w & 0x3F200C00) == 0x38200000 # LSE atomics
|
|
return m
|
|
|
|
|
|
def cond_mask(w):
|
|
return (((w & 0xFE000010) == 0x54000000) | ((w & 0x7E000000) == 0x34000000) |
|
|
((w & 0x7E000000) == 0x36000000))
|
|
|
|
|
|
def simd_mask(w):
|
|
return ((w & 0x0E000000) == 0x0E000000) | (((w & 0x0A000000) == 0x08000000) & (((w >> 26) & 1) == 1))
|
|
|
|
|
|
def pick_functions(name, mod, count, r, spins=None):
|
|
"""[(entry, end, tags)] of up to count functions of mod, plus the Emitter that will emit them. spins: {spin wait
|
|
back-edge: head}, emitted with gates."""
|
|
starts, _ = gen.discover(mod)
|
|
w = mod.words
|
|
ends = np.append(starts[1:], len(w))
|
|
|
|
def per_fn(mask):
|
|
cs = np.concatenate([[0], np.cumsum(mask.astype(np.int64))])
|
|
return cs[ends] - cs[starts]
|
|
|
|
size = ends - starts
|
|
ok = (per_fn(forbidden_mask(w)) == 0) & (per_fn(cond_mask(w)) > 0) & (size >= MIN_INSNS) & (size <= MAX_INSNS)
|
|
has_br = per_fn((w & 0xFFFFFC1F) == 0xD61F0000) > 0
|
|
has_call = per_fn(call_mask(w)) > 0
|
|
cand = np.flatnonzero(ok)
|
|
with_br = [int(i) for i in cand if has_br[i]]
|
|
with_call = [int(i) for i in cand if has_call[i] and not has_br[i]]
|
|
rest = [int(i) for i in cand if not has_br[i] and not has_call[i]]
|
|
spins = spins or {}
|
|
gated = np.searchsorted(mod.lo + starts * 4, np.array(sorted(spins), dtype=np.int64), side='right') - 1
|
|
with_spin = sorted({int(i) for i in gated} & set(cand.tolist()))
|
|
for pool in (with_spin, with_br, with_call, rest):
|
|
r.shuffle(pool)
|
|
emitter = gen.Emitter(mod, set((mod.lo + starts * 4).tolist()), spins=spins)
|
|
picks, tags_seen = [], Counter()
|
|
# Functions with a spin wait first, then with a br (jump tables, fallbacks), at most a quarter of the picks each;
|
|
# then functions with calls, at most half.
|
|
for pool, quota in ((with_spin, count // 4), (with_br, count // 4), (with_call, count // 2), (rest, count)):
|
|
taken = 0
|
|
for i in pool:
|
|
if len(picks) >= count or taken >= quota:
|
|
break
|
|
entry, end = mod.lo + int(starts[i]) * 4, mod.lo + int(ends[i]) * 4
|
|
if any(e == entry for e, _, _ in picks):
|
|
continue
|
|
tags = check_function(emitter, entry, end)
|
|
if tags is None:
|
|
continue
|
|
if any(spins.get(x) is not None for x in range(entry, end, 4)):
|
|
tags.add('spin_gate')
|
|
picks.append((entry, end, tags))
|
|
taken += 1
|
|
picked = {e for e, _, _ in picks}
|
|
picks = [p for p in picks if not callees(emitter, p[0], p[1]) & picked]
|
|
for _, _, tags in picks:
|
|
tags_seen.update(tags)
|
|
print(f'{name}: {len(starts)} functions, {len(cand)} candidates, picked {len(picks)}: '
|
|
f'{dict(tags_seen)}', flush=True)
|
|
return picks, emitter
|
|
|
|
|
|
def call_sites(mod, entry, end):
|
|
"""Addresses of the BL and BLR instructions of [entry, end)."""
|
|
return [a for a in range(entry, end, 4) if mod.lift(a).kind in ('bl', 'blr')]
|
|
|
|
|
|
def callees(emitter, entry, end):
|
|
"""Function starts the emitted C of [entry, end) calls directly (`f_<addr>(c)`, not through rt_dispatch)."""
|
|
mod = emitter.mod
|
|
return {mod.lift(a).target for a in call_sites(mod, entry, end)
|
|
if mod.lift(a).kind == 'bl' and mod.lift(a).target in emitter.starts}
|
|
|
|
|
|
def check_function(emitter, entry, end):
|
|
"""Tags of a function the test can run in isolation, or None."""
|
|
mod = emitter.mod
|
|
insns = [mod.lift(a) for a in range(entry, end, 4)]
|
|
if insns[-1].kind not in gen.TERMINATORS or not any(i.kind == 'ret' for i in insns):
|
|
return None
|
|
tags = set()
|
|
for k, ins in enumerate(insns):
|
|
a = entry + 4 * k
|
|
if ins.kind in ('bl', 'blr'):
|
|
tags.add('calls')
|
|
if ins.kind in ('b', 'bcond'):
|
|
if not entry <= ins.target < end:
|
|
return None
|
|
if ins.target <= a:
|
|
tags.add('loop')
|
|
if ins.kind == 'br':
|
|
jt = gen.jumptables.resolve(mod, a, entry, end)
|
|
if jt:
|
|
tags.add('jump_table')
|
|
elif gen.jumptables.is_computed(mod, a, entry):
|
|
tags.add('all_label_fallback')
|
|
else:
|
|
return None
|
|
if simd_mask(mod.words[(entry - mod.lo) >> 2:(end - mod.lo) >> 2]).any():
|
|
tags.add('simd')
|
|
return tags
|
|
|
|
|
|
def build(work, emitted, jobs, cflags):
|
|
"""Compile the emitted functions with harness.c into work/func[.exe]; tests[k] is emitted[k]."""
|
|
cc = compiler()
|
|
env = dict(os.environ)
|
|
if os.name == 'nt' and MSYS_BIN.exists():
|
|
env['PATH'] = str(MSYS_BIN) + os.pathsep + env.get('PATH', '')
|
|
flags = ['-O1', '-fno-strict-aliasing', '-Wno-return-type', '-Wno-unused-label', '-I', str(ROOT / 'runtime')]
|
|
flags += cflags.split()
|
|
groups = {}
|
|
for name, entry, end, emitter in emitted:
|
|
groups.setdefault(name, []).append((entry, end))
|
|
srcs, n = [], max(1, jobs)
|
|
for name, fns in groups.items():
|
|
emitter = next(e for m, _, _, e in emitted if m == name)
|
|
per = (len(fns) + n - 1) // n
|
|
for j in range(0, len(fns), per):
|
|
path = work / f'{name}_{j // per:03d}.c'
|
|
path.write_text(gen.chunk_text(name, j // per, emitter, fns[j:j + per]))
|
|
srcs.append(path)
|
|
table = work / 'table.c'
|
|
called = set()
|
|
for _, entry, end, emitter in emitted:
|
|
called |= callees(emitter, entry, end)
|
|
table.write_text('#include "cpu.h"\n' + ''.join(f'void {gen.fname(e)}(Cpu *c);\n' for _, e, _, _ in emitted) +
|
|
'GuestFn tests[] = {' + ','.join(gen.fname(e) for _, e, _, _ in emitted) + '};\n' +
|
|
f'int ntests = {len(emitted)};\n' + 'void rt_stub_call(Cpu *c, uint64_t target);\n' +
|
|
''.join(f'void {gen.fname(t)}(Cpu *c) {{ rt_stub_call(c, {hex(t)}ull); }}\n'
|
|
for t in sorted(called)))
|
|
srcs += [table, ROOT / 'tests' / 'diff' / 'harness.c']
|
|
objs, procs = [], []
|
|
for s in srcs:
|
|
o = work / (s.stem + '.o')
|
|
objs.append(o)
|
|
procs.append(subprocess.Popen([cc] + flags + ['-c', str(s), '-o', str(o)], env=env))
|
|
if any(p.wait() for p in procs):
|
|
raise SystemExit('compile failed')
|
|
exe = work / ('func.exe' if os.name == 'nt' else 'func')
|
|
subprocess.check_call([cc, '-fuse-ld=lld', '-o', str(exe)] + [str(o) for o in objs], env=env)
|
|
return exe
|
|
|
|
|
|
def make_buffer(r):
|
|
out = bytearray()
|
|
for _ in range(BUF_SIZE // 8):
|
|
k = r.random()
|
|
if k < 0.4:
|
|
v = r.randrange(PTR_LO, PTR_HI, 8)
|
|
elif k < 0.7:
|
|
v = r.randrange(0, 256)
|
|
else:
|
|
v = r.getrandbits(64)
|
|
out += v.to_bytes(8, 'little')
|
|
return bytes(out)
|
|
|
|
|
|
def make_case(r):
|
|
x = [rand_x(r) for _ in range(31)]
|
|
for i in range(8):
|
|
k = r.random()
|
|
if k < 0.7:
|
|
x[i] = r.randrange(PTR_LO, PTR_HI, 16)
|
|
elif k < 0.9:
|
|
x[i] = r.randrange(0, 64)
|
|
x[30] = SENTINEL
|
|
sp = STACK_TOP - r.randrange(0, 0x100, 16)
|
|
return rand_fpcr(r), x, sp, r.getrandbits(4) << 28, [rand_v(r) for _ in range(32)]
|
|
|
|
|
|
class Runner:
|
|
"""Unicorn with the module images read-only, a block hook that stops when execution leaves the function, and a
|
|
code hook on every call site of the picked functions that stops before the call, for the stub."""
|
|
|
|
MAX_CALLS = 1000
|
|
|
|
def __init__(self, images, buf0, sites):
|
|
self.emu = Emu(images)
|
|
mu = self.emu.mu
|
|
for base, data in images:
|
|
mu.mem_protect(base, (len(data) + 0xFFFF) & ~0xFFFF, uc.UC_PROT_READ | uc.UC_PROT_EXEC)
|
|
mu.mem_map(SENTINEL, 0x1000)
|
|
mu.mem_write(BUF, buf0)
|
|
self.buf0 = buf0
|
|
self.lo = self.hi = self.h = 0
|
|
self.left = False
|
|
self.at_call = None
|
|
mu.hook_add(uc.UC_HOOK_BLOCK, self._block)
|
|
# One hook per call site, all added before anything is translated, so no cached block lacks one.
|
|
for a in sorted(sites):
|
|
mu.hook_add(uc.UC_HOOK_CODE, self._call, begin=a, end=a)
|
|
|
|
def _block(self, mu, address, size, user):
|
|
if not self.lo <= address < self.hi and address != SENTINEL:
|
|
self.left = True
|
|
mu.emu_stop()
|
|
|
|
def _call(self, mu, address, size, user):
|
|
if self.lo <= address < self.hi:
|
|
self.at_call = address
|
|
mu.emu_stop()
|
|
|
|
def stub(self, a):
|
|
"""harness.c rt_stub for the call at a, which Unicorn has not executed yet. Returns the address to resume at."""
|
|
e, mu = self.emu, self.emu.mu
|
|
w = int.from_bytes(mu.mem_read(a, 4), 'little')
|
|
if (w & 0xFC000000) == 0x94000000: # BL
|
|
imm = w & 0x03FFFFFF
|
|
target = (a + ((imm - (1 << 26) if imm >> 25 else imm) << 2)) & M64
|
|
else: # BLR
|
|
target = mu.reg_read(e.xregs[(w >> 5) & 31])
|
|
mu.reg_write(A.UC_ARM64_REG_X30, a + 4)
|
|
h = ((self.h ^ target) * FNV) & M64
|
|
for val in list(mu.reg_read_batch(e.xregs)) + [mu.reg_read(A.UC_ARM64_REG_SP)]:
|
|
h = ((h ^ val) * FNV) & M64
|
|
for val in mu.reg_read_batch(e.qregs[:8]):
|
|
h = ((h ^ (val & M64)) * FNV) & M64
|
|
h = ((h ^ (val >> 64)) * FNV) & M64
|
|
self.h = h
|
|
regs = []
|
|
for i in range(19):
|
|
s = splitmix((h + i) & M64)
|
|
regs.append((e.xregs[i], PTR_LO + ((s >> 4) % ((PTR_HI - PTR_LO) // 16)) * 16 if i < 8 and s & 3 else s))
|
|
regs.append((A.UC_ARM64_REG_NZCV, splitmix((h + 19) & M64) >> 60 << 28))
|
|
for i in range(8):
|
|
regs.append((e.qregs[i], splitmix((h + 33 + 2 * i) & M64) << 64 | splitmix((h + 32 + 2 * i) & M64)))
|
|
mu.reg_write_batch(regs)
|
|
top = BUF + BUF_SIZE - 16
|
|
n = int.from_bytes(mu.mem_read(top, 8), 'little')
|
|
mu.mem_write(top, ((n + 1) & M64).to_bytes(8, 'little') + h.to_bytes(8, 'little'))
|
|
return a + 4
|
|
|
|
def run(self, entry, end, case, steps):
|
|
"""(x, sp, nzcv, pc, v, crc of the buffer) or None if the case is skipped."""
|
|
fpcr, x, sp, nzcv, v = case
|
|
e, mu = self.emu, self.emu.mu
|
|
self.lo, self.hi, self.left, self.h = entry, end, False, 0
|
|
mu.reg_write_batch(list(zip(e.xregs, x[:31])) + list(zip(e.qregs, v)) +
|
|
[(A.UC_ARM64_REG_SP, sp), (A.UC_ARM64_REG_NZCV, nzcv), (A.UC_ARM64_REG_FPCR, fpcr)])
|
|
pc, ok = entry, False
|
|
try:
|
|
for _ in range(self.MAX_CALLS):
|
|
self.at_call = None
|
|
mu.emu_start(pc, SENTINEL, count=steps)
|
|
if self.at_call is None:
|
|
ok = not self.left and mu.reg_read(A.UC_ARM64_REG_PC) == SENTINEL
|
|
break
|
|
pc = self.stub(self.at_call)
|
|
except uc.UcError:
|
|
ok = False
|
|
if not ok:
|
|
mu.mem_write(BUF, self.buf0)
|
|
return None
|
|
o = mu.reg_read_batch(e.out_regs)
|
|
crc = zlib.crc32(bytes(mu.mem_read(BUF, BUF_SIZE)))
|
|
mu.mem_write(BUF, self.buf0)
|
|
return list(o[:31]), o[31], o[32] & 0xF0000000, o[33], list(o[34:]), crc
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
ap.add_argument('--exefs', type=Path, default=ROOT / 'build' / 'extracted' / 'v121' / 'exefs')
|
|
ap.add_argument('--modules', default='main,subsdk0,sdk')
|
|
ap.add_argument('--functions', type=int, default=400, help='per module')
|
|
ap.add_argument('--cases', type=int, default=12, help='random states tried per function')
|
|
ap.add_argument('--steps', type=int, default=200000, help='Unicorn instruction cap per case')
|
|
ap.add_argument('--seed', type=int, default=1)
|
|
ap.add_argument('--jobs', type=int, default=os.cpu_count() or 4)
|
|
ap.add_argument('--show', type=int, default=20)
|
|
ap.add_argument('--patches', type=Path, help='patch table whose spin waits are emitted with their gates')
|
|
ap.add_argument('--cflags', default='-march=x86-64-v3' if platform.machine().lower() in ('amd64', 'x86_64') else '')
|
|
args = ap.parse_args()
|
|
r = random.Random(args.seed)
|
|
t0 = time.time()
|
|
want = args.modules.split(',')
|
|
|
|
work = ROOT / 'build' / 'test' / 'func'
|
|
work.mkdir(parents=True, exist_ok=True)
|
|
images, emitted, tags = [], [], []
|
|
mods = layout(args.exefs)
|
|
table = patches.load(args.patches, mods) if args.patches else patches.Table()
|
|
for name, base, _ in mods:
|
|
if name not in want:
|
|
continue
|
|
mod = Module(name, args.exefs / name, base, 'c')
|
|
img = work / f'{name}.img'
|
|
img.write_bytes(mod.image)
|
|
images.append((base, img, mod.image))
|
|
picks, emitter = pick_functions(name, mod, args.functions, r, table.spins_for(name, base))
|
|
for entry, end, t in picks:
|
|
emitted.append((name, entry, end, emitter))
|
|
tags.append(t)
|
|
print(f'picked {len(emitted)} functions ({time.time() - t0:.0f}s)', flush=True)
|
|
|
|
t1 = time.time()
|
|
exe = build(work, emitted, args.jobs, args.cflags)
|
|
print(f'compiled ({time.time() - t1:.0f}s)', flush=True)
|
|
|
|
buf0 = make_buffer(r)
|
|
sites = set()
|
|
for _, entry, end, emitter in emitted:
|
|
sites.update(call_sites(emitter.mod, entry, end))
|
|
runner = Runner([(base, data) for base, _, data in images], buf0, sites)
|
|
cases, expect, tested, skipped = [], [], set(), 0
|
|
t2 = time.time()
|
|
for k, (name, entry, end, _) in enumerate(emitted):
|
|
for _ in range(args.cases):
|
|
case = make_case(r)
|
|
want_state = runner.run(entry, end, case, args.steps)
|
|
if want_state is None:
|
|
skipped += 1
|
|
continue
|
|
fpcr, x, sp, nzcv, v = case
|
|
cases.append((k, fpcr, x, sp, nzcv, v, True))
|
|
expect.append(want_state)
|
|
tested.add(k)
|
|
print(f'unicorn: {len(cases)} cases on {len(tested)} functions, {skipped} skipped ({time.time() - t2:.0f}s)',
|
|
flush=True)
|
|
|
|
t3 = time.time()
|
|
recs, err = run_harness(exe, work, buf0, cases, [(base, img) for base, img, _ in images], stubs=True)
|
|
print(f'harness: {len(recs)} of {len(cases)} cases ({time.time() - t3:.0f}s)')
|
|
if err:
|
|
k = cases[len(recs)][0] if len(recs) < len(cases) else None
|
|
where = f', stopped in {emitted[k][0]} {emitted[k][1]:#x}' if k is not None else ''
|
|
print(f'harness failed: {err}{where}')
|
|
|
|
fails, nfail = {}, 0
|
|
for case, want_state, got in zip(cases, expect, recs):
|
|
# Generated functions keep NZCV in a local (recomp/flags.py) and leave c->nzcv as it was: flags only count
|
|
# where the function reads them, which the registers and memory show.
|
|
d = diff_state(got, want_state, check_pc=False, check_mem=True, check_nzcv=False)
|
|
if d:
|
|
nfail += 1
|
|
fails.setdefault(case[0], d[:4])
|
|
by_tag, fail_tag = Counter(), Counter()
|
|
for k in tested:
|
|
by_tag.update(tags[k] or {'plain'})
|
|
if k in fails:
|
|
fail_tag.update(tags[k] or {'plain'})
|
|
print(f'{len(tested)} functions, {len(cases)} cases: {nfail} failing cases, {len(fails)} failing functions '
|
|
f'({time.time() - t0:.0f}s total)')
|
|
print(f' tested by tag: {dict(by_tag)}; failing by tag: {dict(fail_tag)}')
|
|
for k, d in list(fails.items())[:args.show]:
|
|
name, entry, end, _ = emitted[k]
|
|
print(f' {name:8} {gen.fname(entry)} ({(end - entry) // 4} insns, {sorted(tags[k])}): {"; ".join(d)}')
|
|
return 1 if fails or err else 0
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main())
|