Add physics_steps = 2: at least a 60 fps frame's solver steps in every update on 1.1.1.

Link is carried by the Deku Tree's floating platform fully at 60 fps, about a tenth at 160 and not at all at 240,
which follows the solver steps an update (4, 2, 1 with physics_steps = 1) rather than those a second. The new set
osolverfull raises the rounded-up count to the 60 Hz frame's 4 in every update; physics_steps = 2 switches it on and
osolver off at start, and the mode is logged. 60 and 30 fps keep the game's 4 and 8. It costs more physics time above
60 fps: 576 solver steps a second at 144 fps and 960 at 240, against 288 and 240 with 1, the default, which stays
until a run confirms the carry. frame_step_check runs the lifted words of all three counts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
chrg6
2026-11-13 06:26:39 -05:00
parent b912d72b87
commit a74503cd86
7 changed files with 121 additions and 5 deletions

View File

@@ -469,7 +469,9 @@ frame, uneven frame times one steady step, and game time follows the clock. It c
sites do: at 144 fps each of 64 water objects gets its ripple steps and the day its seconds, at 120, 144 and 240 fps
the water managers and floors of the set `owater` get 60 fixed steps a second with only s0 changed, and a step of one
frame gives what the game computes. It also checks that each of those sites in `recomp/patches-v111.txt` ends in one
`bl` to its page slot. No game files.
`bl` to its page slot, and runs the physics solver step count's words of the original and the sets `osolver` and
`osolverfull` (`physics_steps` 0, 1 and 2), lifted as the generator lifts them: the game's 4 and 8 at 60 and 30 fps
for all three, rounded up by `osolver` and at least 4 every update by `osolverfull` above 60 fps. No game files.
A change to how the mixer applies volumes (`runtime/audio_mixer.c` `accumulate_ramp`, the voice and splitter paths
of `audio_mixer_render`) or to what it owes the device after a stall (`ring_pull`, `audio_mixer_owed`) passes

View File

@@ -42,7 +42,9 @@
# highfps_fix = 1
# Above 60 fps the game's physics solved contacts and friction in fewer steps a second than at 60, and in none at all
# in a frame shorter than 1/240 s, so things standing on moving platforms were left behind. 1 rounds the step count
# up instead; 0 is the game's own count.
# up instead; 0 is the game's own count. 2 also gives every frame at least the 4 steps of a 60 fps frame, which costs
# more physics time above 60 fps (576 steps a second at 144 fps, 960 at 240, against about 288 and 240 with 1) and is
# a candidate fix for Link left behind on the Deku Tree's floating platform. Read at start.
# physics_steps = 1
# The number in the window that shows a pick-up (a rupee) counts up by whole steps a frame, and above 60 fps the game
# rounded that step down to nothing: the window stayed until the area was left and its counting sound went on. 1 lets

View File

@@ -60,6 +60,26 @@ site ostep main 3cdfc0 1e380008 952b4be8 # fcvtzs w8, s0 -> bl page+0xf60
# parent keeps it across its call), and x30 is the function's own saved one.
site ostep main 475f8 1e380009 95396656 # fcvtzs w9, s0 -> bl page+0xf50
# Physics solver steps, at least a 60 fps frame's (physics_steps = 2, runtime/frame_rate.c frame_step_init switches
# this set on and osolver off): the step setup below with the count of osolver, ceil(frames per update * count - 0.25),
# raised to the count itself, the steps of one 60 Hz frame. Above 60 fps the rounded-up count still gives fewer
# solver steps an update (2 at 120 to 160 fps, 1 at 240) while their number a second stays near 240; with this set
# every update gets the 60 Hz frame's 4, each covering a quarter of the update's time, so a run at 240 fps solves 960
# steps a second, 576 at 144. Link stood on the Deku Tree's floating platform (B1, Obj_Deku_MoveFloor_A_01, drifting
# under GroundControlActor_WaveLoop) is carried by it fully at 60 fps, about a tenth at 160 and not at all at 240,
# which follows the steps an update and not those a second; whether this mends the carry needs a run. At 60 and 30
# fps the count is the game's (4 and 8). The first two sites are osolver's; at the third the cut becomes fmax s0, s0,
# s1 (s1 the count, made a float by the scvtf before the fmadd) and the round up, so w1 = max(ceil(s0), count). s0
# and s1 are not read before the call's callee (the world's main+0x3a24c20) and the code after it write them, and
# none of the words reads or writes the flags.
set osolverfull NX2NAVI_PHYSICS_STEPS_FULL 0
site osolverfull main 38aebe4 bd440d02 1e2a1002 # ldr s2, [x8, #0x40c] (0.001) -> fmov s2, #0.25
site osolverfull main 38aebf0 1f010800 1f218800 # fmadd s0, s0, s1, s2 -> fnmsub s0, s0, s1, s2
site osolverfull main 38aebf4 1e380001 1e214800 1e280001 # fcvtzs w1, s0 -> fmax s0, s0, s1; fcvtps w1, s0
site osolverfull main 38afd10 bd440d02 1e2a1002 # the same three in the second copy
site osolverfull main 38afd1c 1f010800 1f218800
site osolverfull main 38afd20 1e380001 1e214800 1e280001
# Physics solver steps: the physics system's step setup (main+0x38aeb80, and the same code in main+0x38afc10) gives the
# rigid body world its solver step count for the frame, trunc(frames per update * count + 0.001) with the count at
# system +0x244 (4, set by the constructor main+0x38ae800), and the world keeps the result's inverse beside it (world

View File

@@ -33,7 +33,7 @@ static int save_ok = 1; // the last cfg_save_to wrote (o
// Every name the runtime reads (and the patch sets in generated/), without the prefix. A file key outside this list is
// logged as unknown. A new variable goes here too, or its ini key logs a harmless "unknown key" line.
static const char *const known[] = {
"CORES", "TM_DISPATCH", "NVN2_CS_TABLE", "SKIP_SHADERS", "MOVIE_DEBAND", "MOVIE_FPS", "MOVIE_SKIP", "MOVIE_OVERLAY", "MOVIES", "NPLN", "NVN2_PROGRAMS", "NVN_TRAP", "OCARINA_REALTIME", "ONESHOT_BUDGET", "ONESHOT_WAIT_MS", "OCARINA_STEP", "PHYSICS_STEPS", "PICKUP_COUNT", "WATER_STEPS", "SOUND_STEPS", "FLOOR_PROBE", "FLOOR_SCAN", "MOVIE_PTS", "STEP_STEADY", "VI_HZ", "VI_VRR", "TIME_DUMP", "SHADER_DUMP", "SHOT_AFTER", "SHOT_DIR", "SHOT_EVERY", // Switch 2 port: movie skip (romfs.c), NVN2 proc traps (nvn_procs.c)
"CORES", "TM_DISPATCH", "NVN2_CS_TABLE", "SKIP_SHADERS", "MOVIE_DEBAND", "MOVIE_FPS", "MOVIE_SKIP", "MOVIE_OVERLAY", "MOVIES", "NPLN", "NVN2_PROGRAMS", "NVN_TRAP", "OCARINA_REALTIME", "ONESHOT_BUDGET", "ONESHOT_WAIT_MS", "OCARINA_STEP", "PHYSICS_STEPS", "PHYSICS_STEPS_FULL", "PICKUP_COUNT", "WATER_STEPS", "SOUND_STEPS", "FLOOR_PROBE", "FLOOR_SCAN", "MOVIE_PTS", "STEP_STEADY", "VI_HZ", "VI_VRR", "TIME_DUMP", "SHADER_DUMP", "SHOT_AFTER", "SHOT_DIR", "SHOT_EVERY", // Switch 2 port: movie skip (romfs.c), NVN2 proc traps (nvn_procs.c)
"AA", "AA_LOG", "AB", "AMIIBO_DIR", "ANISO", "APP_POOL", "ASPECT", "ASPECT_PATCH", "ASSUME_FOCUS", "ASTC", "ASTC_BC", "ASTC_TIME", "ASYNC_PREEMPT", "AUDIO", "AUDIO_CATCHUP", "AUDIO_CHANNELS", "AUDIO_DEVICE", "AUDIO_GAME_PRIO", "AUDIO_OFF", "AUDIO_PREFILL_MS", "AUDIO_RAMP", "AUDIO_STATS",
"BARRIER_GAME", "MESH_AHEAD", "MESH_FALLBACK", "MESH_FALLBACK_MB", "MESH_FALLBACK_WORKERS", "PROBE_MESHFB_DIR",
"BACKGROUND_WINDOW", "BIND_A", "BIND_B", "BIND_DOWN", "BIND_L", "BIND_LCLICK", "BIND_LEFT", "BIND_LSTICK_DOWN", "BIND_LSTICK_LEFT",

View File

@@ -96,6 +96,20 @@ void frame_step_init(void) {
atomic_store_explicit(ostep_page, bits, memory_order_release);
rt_log("frame rate: the game's frames per update follow the display interval (set ostep)");
}
// physics_steps (patches-v111.txt osolver and osolverfull): 0 the game's solver step count, trunc(frames per update
// * 4 + 0.001); 1 (the default) that count rounded up; 2 rounded up and at least the 4 of a 60 Hz frame, every
// update. The two sets patch the same words; this start keeps one of them on.
int solver = rt_patch_set("osolver"), solver_full = rt_patch_set("osolverfull");
if (solver_full >= 0) {
const char *e = cfg_get("NX2NAVI_PHYSICS_STEPS");
int full = (e && !strcmp(e, "2")) || rt_patch_enabled(solver_full);
rt_patch_switch(solver_full, full);
if (full && solver >= 0) rt_patch_disable(solver);
rt_log("frame rate: physics solver steps an update: %s",
full ? "rounded up and at least a 60 fps frame's 4 (physics_steps = 2)"
: solver >= 0 && rt_patch_enabled(solver) ? "rounded up (physics_steps = 1)"
: "the game's own count (physics_steps = 0)");
}
if (realtime < 0) {
rt_log("frame rate: no patch table, shipping 30 fps whole-vsync steps");
return;

View File

@@ -129,6 +129,25 @@ static uint32_t sim(uint64_t object, float seconds) {
memcpy(&quotient, &c.v[0], 4);
return cut_fcvtzs(quotient); // the game's fcvtzs after the site
}
// The physics solver step count (patches-v111.txt osolver and osolverfull, lifted by frame_step_check.py): the step
// setup's words from the literal load to the cut, run on the frames per update in s0 and the count (4) in s1 as the
// game loads them, x8 pointing at the literal 0.001 the original loads. Returns w1, the steps.
void solver_game(Cpu *c);
void solver_osolver(Cpu *c);
void solver_osolverfull(Cpu *c);
static uint32_t solver_steps(void (*fn)(Cpu *), float frames) {
static float literal[0x410 / 4];
literal[0x40c / 4] = 0.001f;
Cpu c;
memset(&c, 0, sizeof c);
uint32_t bits;
memcpy(&bits, &frames, 4);
c.v[0] = bits, c.v[1] = 4, c.x[8] = (uint64_t)(uintptr_t)literal, c.x[1] = ~0ull;
fn(&c);
CHECK(c.x[1] >> 32 == 0, "the solver count leaves x1's upper half %#llx", (unsigned long long)c.x[1]);
return (uint32_t)c.x[1];
}
int main(int argc, char **argv) {
int mean = argc > 1 && !strcmp(argv[1], "mean");
if (mean) steady = "0";
@@ -341,6 +360,25 @@ int main(int argc, char **argv) {
sound_gate_take(&g, 0, 0.0f), "NaN and 0 run as the game has them and clear what is owed");
fake_manager = 0;
}
// The solver step count: at 60 and 30 fps all three give the game's 4 and 8 (also with the steady step a little
// off them); above 60 fps the game's count falls to 1 and to 0, osolver's rounds up (2 at 144 fps, 1 at 240), and
// osolverfull's keeps every update at the 60 Hz frame's 4.
{
const struct { float frames; uint32_t game, up, full; } want[] = {
{1.0f, 4, 4, 4}, {2.0f, 8, 8, 8}, {0.98f, 3, 4, 4}, {1.02f, 4, 4, 4}, {60.0f / 144.0f, 1, 2, 4},
{60.0f / 160.0f, 1, 2, 4}, {0.25f, 1, 1, 4}, {60.0f / 300.0f, 0, 1, 4}, {1.5f, 6, 6, 6}};
for (size_t i = 0; i < sizeof want / sizeof want[0]; i++) {
uint32_t g = solver_steps(solver_game, want[i].frames), u = solver_steps(solver_osolver, want[i].frames),
f = solver_steps(solver_osolverfull, want[i].frames);
CHECK(g == want[i].game && u == want[i].up && f == want[i].full,
"%.4f frames an update: solver steps %u / %u / %u, not %u / %u / %u", want[i].frames, g, u, f,
want[i].game, want[i].up, want[i].full);
}
// steps a second at 144 and 240 fps: osolver 288 and 240, osolverfull 576 and 960
printf("frame_step_check: solver steps a second at 144 / 240 fps: physics_steps 1 %u / %u, 2 %u / %u\n",
144 * solver_steps(solver_osolver, 60.0f / 144.0f), 240 * solver_steps(solver_osolver, 0.25f),
144 * solver_steps(solver_osolverfull, 60.0f / 144.0f), 240 * solver_steps(solver_osolverfull, 0.25f));
}
printf("frame_step_check: %s\n", fails ? "FAILED" : "ok");
return fails != 0;
}

View File

@@ -11,8 +11,10 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MSYS_BIN = Path(r'C:\msys64\ucrt64\bin')
sys.path[:0] = [str(ROOT / 'recomp')]
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
PAGE = 0x4ea0000 # the patch page of 1.1.1, main+0x4ea0000
# The sites that call frame_rate.c's hosts, as recomp/patches-v111.txt has them: set, module offset, the original word,
@@ -54,17 +56,55 @@ def check_sites():
return bad
# The solver step count's words at the step setup's first copy (main+0x38aebe4..0x38aebf4; the second copy at
# main+0x38afd10 has the same words): the original's, the set osolver's and the set osolverfull's, lifted as the
# generator lifts them into one C function each, with the scvtf between them (main+0x38aebec) that every variant keeps.
SOLVER_SITES = (0x38aebe4, 0x38aebf0, 0x38aebf4)
SOLVER_COPY = (0x38afd10, 0x38afd1c, 0x38afd20)
SCVTF = 0x5e21d821 # scvtf s1, s1
def solver_functions():
table = load(ROOT / 'recomp/patches-v111.txt', [('main', 0x7000000000, PAGE)])
names = {i: t[0] for i, t in enumerate(table.sets)}
variants = {'game': [], 'osolver': [], 'osolverfull': []}
for site, copy in zip(SOLVER_SITES, SOLVER_COPY):
alts = {names[key]: (words, have) for key, words, have in table.sites['main'][site]}
copies = {names[key]: (words, have) for key, words, have in table.sites['main'][copy]}
assert set(alts) == {'osolver', 'osolverfull'} and alts == copies, (hex(site), alts, copies)
original = alts['osolver'][1]
variants['game'].append([original])
for name in ('osolver', 'osolverfull'):
variants[name].append(alts[name][0])
lift = Lifter(0, simd='c')
out = [f'// {GENERATED_MARKER}: the solver step count words lifted by tests/frame_step_check.py.',
'#include "cpu.h"', '#include "simd.h"', '']
for name, sites in variants.items():
body = []
for k, words in enumerate(sites):
for w in words:
insn = lift.lift(w, 0x7000000000 + SOLVER_SITES[k])
assert insn.kind == 'op', (name, hex(w), insn.kind)
body.append(' ' + insn.code)
if k == 0:
body.append(' ' + lift.lift(SCVTF, 0x70038aebec).code)
out.append(f'void solver_{name}(Cpu *c) {{\n' + '\n'.join(body) + '\n}\n')
return '\n'.join(out)
def main():
work = ROOT / 'build' / 'test' / 'frame_step'
work.mkdir(parents=True, exist_ok=True)
(work / 'layout.h').write_text(LAYOUT)
exe = work / ('frame_step_check.exe' if os.name == 'nt' else 'frame_step_check')
solver = work / 'solver_sites.c'
solver.write_text(solver_functions())
env = dict(os.environ)
if os.name == 'nt' and MSYS_BIN.exists():
env['PATH'] = str(MSYS_BIN) + os.pathsep + env.get('PATH', '')
subprocess.check_call([compiler(), '-O1', '-Wall', '-Wno-unused-function', '-fno-strict-aliasing',
'-I', str(ROOT / 'runtime'), '-I', str(work),
str(ROOT / 'tests' / 'frame_step_check.c'), '-o', str(exe), '-lm'], env=env)
str(ROOT / 'tests' / 'frame_step_check.c'), str(solver), '-o', str(exe), '-lm'], env=env)
rc = subprocess.run([str(exe)], env=env).returncode
return subprocess.run([str(exe), 'mean'], env=env).returncode or rc or check_sites()