Files
NX2navi/tests/ocarina_timing_check.py
chrg6 5f9374e1d9 Keep the fraction of a frame the ocarina recorder's clock was losing above 60 fps, in a new patch set orecord.
The ocarina manager counts the length of each note and rest in whole 60 Hz frames and adds the time manager's frames
per update to that clock at four sites (main+0x32926c8, main+0x3293410, main+0x3296b08, main+0x3296da4) through
fcvtzu, which cuts the step toward zero. Above 60 fps the step is under one frame, so the clock never moved: a
recording kept its notes without their lengths (the recorder leaves a length of 0 out), the 360 and 120 frame limits
never came, and a recording ended after a fixed 60 frames of its timer instead of following the clock; at the 40 and
45 fps limits it counted one a frame. The set replaces each fcvtzu with nine words that add the fraction carried on
the patch page's +264, store what is left after the cut (NaN and negatives as 0) and convert the sum. Steps of one
and two frames give the game's count with the fraction left at 0, so 30 and 60 fps are unchanged. The words call
nothing and take no lock (one of the functions returns through an unsaved x30), and use only v16-v18, which the three
functions never name. The set is appended so that no existing set's index moves; ocarina_timing = 0 gives the game's
words.

Not reproduced before the change (read from the code). The generator's check of the original words passed against
the 1.1.1 main, the new tests/ocarina_timing_check.py passed (.venv, Unicorn), as did frame_step_check,
text_speed_check, sprint_check, movie_timeline_check, cfg_parse_check, launcher_test and cfg_rows_check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 20:13:02 -05:00

351 lines
16 KiB
Python

"""Check the set orecord of recomp/patches-v111.txt: the ocarina recorder's clock carries each update's fraction of a
60 Hz frame on the patch page's +264 instead of cutting it.
1. The table: the set's switch and default, its four sites, their original word and the nine replacement words, the
page word resolved to adrp x8 of the patch page at each site, and no other set's word loading or storing +264.
2. The lifted words (recomp/lifter.py, as the generator lifts them) against the game's word and a float32 model:
steps of one and two frames give the game's count with the fraction left at 0; the steps of the 40 to 240 fps
limits count 3600 frames a minute; NaN, negative, infinite and huge steps give the game's count; only x8, v16-v18
and FPSR change.
3. Unicorn: the nine words, the adrp included, run at each site against the lifted C over random steps and fractions:
registers and the page agree bit for bit.
Requires Unicorn, numpy and a C compiler (--cc or CC). No game files. Harness files stay in build/test/ocarina_timing.
"""
import argparse
import ctypes
import os
from pathlib import Path
import random
import shutil
import struct
import subprocess
import sys
import numpy as np
import unicorn as uc
from unicorn import arm64_const as arm
ROOT = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(ROOT), str(ROOT / 'recomp')]
from lifter import Lifter # noqa: E402
from patches import load # noqa: E402
from tools.check_content import GENERATED_MARKER # noqa: E402
MSYS_BIN = Path(r'C:\msys64\ucrt64\bin')
BASE = 0x7000000000
SIZE = 0x4ea0000 # a stand-in main size: the patch page lands at main+0x4ea0000, as on 1.1.1
SITES = (0x32926c8, 0x3293410, 0x3296b08, 0x3296da4)
GAME = 0x1e390008 # fcvtzu w8, s0
WORDS = [('page', 8), 0xbd410910, 0x1e302810, 0x1e25c211, 0x1e313a11, 0x2f00e412, 0x1e326a31, 0xbd010911,
0x1e390208]
FRACTION = 264 # the page byte the words keep the fraction in
CPU_SIZE = 1024
X8, NZCV, FPCR, FPSR, V = 8 * 8, 272, 320, 328, 512
def compiler(cc):
if cc:
return cc
if os.name == 'nt' and (MSYS_BIN / 'clang.exe').exists():
return str(MSYS_BIN / 'clang.exe')
return shutil.which('clang') or 'clang'
def f32(bits):
return np.frombuffer(struct.pack('<I', bits), dtype='<f4')[0]
def bits(value):
return struct.unpack('<I', np.float32(value).tobytes())[0]
def fcvtzu(value):
"""fcvtzu w, s: toward zero, NaN and negatives to 0, saturated at 2^32 - 1."""
if np.isnan(value) or value <= 0:
return 0
if value >= np.float32(4294967296.0):
return 0xffffffff
return int(np.trunc(value))
def model(step, fraction):
"""The words in float32 with round to nearest: (w8, the new fraction's bits)."""
with np.errstate(all='ignore'):
total = np.float32(step) + np.float32(fraction)
rest = total - np.trunc(total)
if np.isnan(rest):
rest = np.float32(0.0)
elif rest < 0 or (rest == 0 and np.signbit(rest)):
rest = np.float32(0.0)
return fcvtzu(total), bits(rest)
def check_table():
table = load(ROOT / 'recomp/patches-v111.txt', [('main', BASE, SIZE)])
names = [s[0] for s in table.sets]
assert table.sets[-1] == ('orecord', 'NX2NAVI_OCARINA_TIMING', 1), table.sets[-1]
key = names.index('orecord')
mine = sorted(off for off, alts in table.sites['main'].items() if any(k == key for k, _, _ in alts))
assert mine == list(SITES), [hex(s) for s in mine]
resolved = table.for_module('main', BASE)
for site in SITES:
alts = table.sites['main'][site]
assert len(alts) == 1, (hex(site), alts)
k, words, original = alts[0]
assert k == key and original == GAME and words == WORDS, (hex(site), words, hex(original))
(_, real), = resolved[BASE + site]
adrp = real[0]
imm = ((adrp >> 29) & 3) | ((adrp >> 5) & 0x7ffff) << 2
imm -= (imm & 1 << 20) << 1
target = ((BASE + site) & ~0xfff) + (imm << 12)
assert adrp & 0x9f00001f == 0x90000008 and target == table.page, (hex(site), hex(adrp), hex(target))
# No other set's word loads or stores the fraction's bytes through an unsigned offset.
for off, alts in table.sites['main'].items():
for k, words, _ in alts:
if k == key or not any(isinstance(w, tuple) for w in words):
continue
for w in words:
if isinstance(w, tuple) or w & 0x3b000000 != 0x39000000:
continue
scale = w >> 30
if (w >> 26) & 1 and (w >> 22) & 2:
scale = 4
start = ((w >> 10) & 0xfff) << scale
overlap = start < FRACTION + 4 and start + (1 << scale) > FRACTION
assert not overlap, (names[k], hex(off), hex(w))
return table
def build(cc, work):
lift = Lifter(0, simd='c')
export = '__declspec(dllexport) ' if os.name == 'nt' else ''
# Each function sets the host's floating-point mode from c->fpcr as the runtime does on entry to guest code
# (runtime/cpu.h rt_fpcr_sync) and puts Python's back after the words.
out = [f'// {GENERATED_MARKER}: the set orecord\'s words lifted by tests/ocarina_timing_check.py.',
'#include "cpu.h"', '',
'#if defined(__x86_64__)',
'#define ENTER unsigned saved = __builtin_ia32_stmxcsr(); rt_fpcr_sync(c);',
'#define LEAVE __builtin_ia32_ldmxcsr(saved);',
'#else',
'#define ENTER',
'#define LEAVE',
'#endif', '']
for i, site in enumerate(SITES):
body = []
for j, w in enumerate(WORDS[1:], 1): # x8 is the page: the caller sets it as the adrp would
insn = lift.lift(w, BASE + site + 4 * j)
assert insn.kind == 'op', (hex(w), insn.kind)
body.append(' ' + insn.code)
out.append(f'{export}void orecord_{i}(Cpu *c) {{\n ENTER\n' + '\n'.join(body) + '\n LEAVE\n}\n')
insn = lift.lift(GAME, BASE + SITES[0])
out.append(f'{export}void game_word(Cpu *c) {{\n ENTER\n {insn.code}\n LEAVE\n}}\n')
source = work / 'orecord.c'
library = work / ('orecord.dll' if os.name == 'nt' else 'orecord.so')
source.write_text('\n'.join(out))
env = dict(os.environ)
if os.name == 'nt' and MSYS_BIN.exists():
env['PATH'] = str(MSYS_BIN) + os.pathsep + env.get('PATH', '')
subprocess.run([cc, '-shared', '-O2', '-fno-strict-aliasing', '-I', str(ROOT / 'runtime'),
*([] if os.name == 'nt' else ['-fPIC']), str(source), '-o', str(library)], check=True, env=env)
if os.name == 'nt' and MSYS_BIN.exists() and hasattr(os, 'add_dll_directory'):
os.add_dll_directory(str(MSYS_BIN))
dll = ctypes.CDLL(str(library))
fns = []
for name in [f'orecord_{i}' for i in range(len(SITES))] + ['game_word']:
fn = getattr(dll, name)
fn.argtypes = [ctypes.c_void_p]
fn.restype = None
fns.append(fn)
return fns[:-1], fns[-1]
class Harness:
"""A Cpu and a page buffer for the lifted words; x8 is set to the page before each call."""
def __init__(self, sites, game):
self.sites, self.game = sites, game
self.raw = ctypes.create_string_buffer(CPU_SIZE + 16)
self.cpu = (ctypes.addressof(self.raw) + 15) & ~15
self.page = ctypes.create_string_buffer(4096)
def set_v(self, n, value):
ctypes.memmove(self.cpu + V + 16 * n, value.to_bytes(16, 'little'), 16)
def get_v(self, n):
return int.from_bytes(ctypes.string_at(self.cpu + V + 16 * n, 16), 'little')
def x8(self):
return struct.unpack('<Q', ctypes.string_at(self.cpu + X8, 8))[0]
def fraction(self):
return struct.unpack_from('<I', self.page.raw, FRACTION)[0]
def set_fraction(self, b):
struct.pack_into('<I', self.page, FRACTION, b)
def run(self, step_bits, site=0, game=False):
"""One call; returns w8. The Cpu changes only in x8, v16-v18 and FPSR, and the page only at +264."""
self.set_v(0, step_bits)
if not game:
ctypes.memmove(self.cpu + X8, struct.pack('<Q', ctypes.addressof(self.page)), 8)
before = ctypes.string_at(self.cpu, CPU_SIZE)
page = self.page.raw
(self.game if game else self.sites[site])(self.cpu)
after = ctypes.string_at(self.cpu, CPU_SIZE)
keep = [(0, X8), (X8 + 8, FPSR), (FPSR + 8, V + 16 * 16), (V + 19 * 16, CPU_SIZE)]
for lo, hi in keep:
assert before[lo:hi] == after[lo:hi], ('Cpu bytes changed', lo, hi)
if game:
assert self.page.raw == page
else:
assert self.page.raw[:FRACTION] == page[:FRACTION] and self.page.raw[FRACTION + 4:] == page[FRACTION + 4:]
w8 = self.x8()
assert w8 >> 32 == 0, hex(w8)
return w8
def check_lifted(h):
rng = random.Random(264)
# Whole steps: the game's count, and the fraction stays +0.0.
for step, want in ((1.0, 1), (2.0, 2)):
h.set_fraction(0)
for _ in range(1000):
h.set_v(16, rng.getrandbits(128))
assert h.run(bits(step)) == want and h.run(bits(step), game=True) == want
assert h.fraction() == 0, hex(h.fraction())
# The limits' steps: 3600 frames a minute, each update the floor or the ceiling of the step.
patterns = {120: [0, 1], 240: [0, 0, 0, 1]}
for hz in (40, 45, 90, 120, 144, 165, 240):
step = bits(np.float32(60.0 / hz))
h.set_fraction(0)
lo, hi = int(np.floor(f32(step))), int(np.ceil(f32(step)))
game = h.run(step, game=True)
assert game == (1 if hz < 60 else 0), (hz, game)
seq, fraction = [], 0
for k in range(60 * hz):
w = h.run(step, site=k % len(SITES))
want, fraction = model(f32(step), f32(fraction))
assert (w, h.fraction()) == (want, fraction), (hz, k, w, want, hex(h.fraction()), hex(fraction))
assert lo <= w <= hi, (hz, k, w)
seq.append(w)
assert abs(sum(seq) - 3600) <= 1, (hz, sum(seq))
if hz in patterns:
p = patterns[hz]
assert seq == p * (len(seq) // len(p)), (hz, seq[:16])
if hz == 144: # 5 in every 12 updates; the float step is a little under 5/12, so the first 12 count 4
blocks = [sum(seq[i:i + 12]) for i in range(0, len(seq), 12)]
assert blocks[0] in (4, 5) and all(b == 5 for b in blocks[1:]), (hz, sorted(set(blocks)))
# A carried fraction: 0.75 + 0.5 gives one frame and leaves 0.25.
h.set_fraction(bits(0.75))
assert h.run(bits(0.5)) == 1 and h.fraction() == bits(0.25)
# Steps the game never gives: the game's count, a fraction in [0, 1), and the next whole step counts one.
specials = [0x7fc00000, 0xffc00000, bits(-0.5), 0x80000000, 0, 0x7f800000, 0xff800000, bits(1e10), bits(-1e10)]
for start in (0, bits(0.75)):
for s in specials:
h.set_fraction(start)
game = h.run(s, game=True)
assert h.run(s) == game == fcvtzu(f32(s)), (hex(start), hex(s), game)
f = f32(h.fraction())
assert not np.isnan(f) and 0 <= f < 1 and not np.signbit(f), (hex(start), hex(s), hex(h.fraction()))
if start == 0 and s not in (0, 0x80000000, bits(1e10)):
assert h.fraction() == 0, (hex(s), hex(h.fraction()))
assert h.run(bits(1.0)) == 1
# v0 keeps the step, its upper lanes too.
h.set_fraction(0)
v0 = (rng.getrandbits(96) << 32) | bits(0.25)
h.set_v(0, v0)
ctypes.memmove(h.cpu + X8, struct.pack('<Q', ctypes.addressof(h.page)), 8)
h.sites[0](h.cpu)
assert h.get_v(0) == v0
return 60 * (40 + 45 + 90 + 120 + 144 + 165 + 240)
def check_unicorn(h, table, count):
page = table.page
resolved = table.for_module('main', BASE)
emu = uc.Uc(uc.UC_ARCH_ARM64, uc.UC_MODE_ARM)
emu.ctl_set_cpu_model(arm.UC_CPU_ARM64_A57)
emu.reg_write(arm.UC_ARM64_REG_CPACR_EL1, 0x300000) # FP and SIMD on
code_lo, code_hi = (BASE + min(SITES)) & ~0xfff, (BASE + max(SITES) + 0x1000) & ~0xfff
emu.mem_map(code_lo, code_hi - code_lo)
emu.mem_map(page, 0x1000)
for site in SITES:
(_, words), = resolved[BASE + site]
emu.mem_write(BASE + site, struct.pack('<9I', *words))
xregs = [getattr(arm, f'UC_ARM64_REG_X{i}') for i in range(31)]
qregs = [getattr(arm, f'UC_ARM64_REG_Q{i}') for i in range(32)]
rng = random.Random(0x265fc)
specials = [0x7fc00000, 0xffc00000, bits(-0.5), 0x80000000, 0, 0x7f800000, 0xff800000, bits(1e10), 0x00000001,
0x7f7fffff, bits(1.0), bits(2.0), bits(0.5), bits(np.float32(60.0 / 144))]
cases = [(s, f) for s in specials for f in (0, bits(0.75), bits(np.nextafter(np.float32(1), np.float32(0))))]
cases += [(bits(rng.uniform(0, 4)), bits(rng.uniform(0, 1))) for _ in range(count)]
for n, (step, fraction) in enumerate(cases):
if f32(fraction) >= 1:
fraction = 0
i = n % len(SITES)
x = [rng.getrandbits(64) for _ in range(31)]
v = [rng.getrandbits(128) for _ in range(32)]
v[0] = (v[0] & ~0xffffffff) | step
nzcv = rng.randrange(16) << 28
fpcr = rng.choice([0, 0, 0, 1 << 24, 1 << 25, 3 << 24])
image = bytearray(rng.getrandbits(8) for _ in range(16)) * 256
struct.pack_into('<I', image, FRACTION, fraction)
# Unicorn: the nine words from the site's address.
emu.mem_write(page, bytes(image))
for reg, value in zip(xregs, x):
emu.reg_write(reg, value)
for reg, value in zip(qregs, v):
emu.reg_write(reg, value)
emu.reg_write(arm.UC_ARM64_REG_NZCV, nzcv)
emu.reg_write(arm.UC_ARM64_REG_FPCR, fpcr)
emu.emu_start(BASE + SITES[i], BASE + SITES[i] + 36)
# The lifted C: x8 set to the page buffer as the adrp sets it to the page.
x_c = list(x)
x_c[8] = ctypes.addressof(h.page)
cpu = struct.pack('<31Q', *x_c) + bytes(8 + 8 + 8) + struct.pack('<Q', nzcv)
ctypes.memmove(h.cpu, cpu + bytes(CPU_SIZE - len(cpu)), CPU_SIZE)
ctypes.memmove(h.cpu + FPCR, struct.pack('<Q', fpcr), 8)
for k, value in enumerate(v):
h.set_v(k, value)
ctypes.memmove(ctypes.addressof(h.page), bytes(image), len(image))
h.sites[i](h.cpu)
want_x = [emu.reg_read(reg) for reg in xregs]
have_x = list(struct.unpack('<31Q', ctypes.string_at(h.cpu, 31 * 8)))
assert have_x == want_x, (hex(step), hex(fraction), [(k, hex(a), hex(b)) for k, (a, b) in
enumerate(zip(have_x, want_x)) if a != b])
for k, reg in enumerate(qregs):
assert h.get_v(k) == emu.reg_read(reg), (hex(step), hex(fraction), hex(fpcr), k, hex(h.get_v(k)),
hex(emu.reg_read(reg)))
for k in (16, 17, 18):
assert h.get_v(k) >> 32 == 0, (k, hex(h.get_v(k)))
assert emu.reg_read(arm.UC_ARM64_REG_NZCV) & 0xf0000000 == nzcv
assert struct.unpack('<Q', ctypes.string_at(h.cpu + NZCV, 8))[0] == nzcv
mem = bytes(emu.mem_read(page, 0x1000))
assert h.page.raw == mem, (hex(step), hex(fraction))
assert mem[:FRACTION] == bytes(image[:FRACTION]) and mem[FRACTION + 4:] == bytes(image[FRACTION + 4:])
if fpcr == 0:
assert (want_x[8], struct.unpack_from('<I', mem, FRACTION)[0]) == model(f32(step), f32(fraction)), \
(hex(step), hex(fraction))
return len(cases)
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('--cc', default=os.environ.get('CC'))
parser.add_argument('--count', type=int, default=10000, help='random steps compared with Unicorn')
args = parser.parse_args()
table = check_table()
work = ROOT / 'build/test/ocarina_timing'
work.mkdir(parents=True, exist_ok=True)
sites, game = build(compiler(args.cc), work)
h = Harness(sites, game)
calls = check_lifted(h)
compared = check_unicorn(h, table, args.count)
print(f'Ocarina timing: the set orecord\'s four sites and words; {calls} lifted calls at the 40 to 240 fps steps '
f'count 3600 frames a minute; {compared} C/Unicorn comparisons agree')
if __name__ == '__main__':
main()