Files
totk-pc-recomp/tests/diff/func.py
T
CHEN Feng 34b4faee7c Run the game on x86-64-v2 CPUs without AVX2
-march=x86-64-v3 kept the port and the installer to CPUs with AVX2 and FMA. --cpu v2 compiles the generated code and the runtime for SSE4.2 instead. Without FMA3 the FP lowering calls fma/fmaf, so runtime/fma.c provides correctly rounded ones (musl, MIT) that honour MXCSR's rounding mode, FTZ and DAZ; the UCRT's software fallback rounds incorrectly by up to half an ulp. simd.h's fast paths keep their assumptions.

The installer's host check asks for x86-64-v2 and selects libtotkrt.a or libtotkrt-v2.a; both release scripts build and ship the pair. The differential tests compile fma.c as well, so a --cflags=-march=x86-64-v2 run tests it.

Tested on an i7-3770K (no AVX2, FMA or BMI) with an RTX 3060 Ti, game 1.4.3: the installer ran end to end, --selftest passed and play holds 40-60 fps at 1080p. fma and fmaf matched 152,288 exact vectors over two seeds, including ties, subnormals and every rounding mode, FTZ and DAZ combination. The v3 build is unchanged: fma.c is an empty translation unit there.
2026-11-01 07:09:13 +08:00

429 lines
19 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)))
# fma.c: a --cflags=-march=x86-64-v2 run must exercise the software fma the v2 build links, not the C runtime's.
srcs += [table, ROOT / 'tests' / 'diff' / 'harness.c', ROOT / 'runtime' / 'fma.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())