Keep the water's ripples moving above 60 fps by carrying the step's fraction for each water object.

The water renderer runs trunc(step) times the water's substeps of its ripple simulation a frame, which is none while the step is under one frame: the rings stood still above 60 fps. A new site of the set ostep hands the cut to frame_rate_water_host, which adds the step to a remainder kept for the water object and returns the whole steps in it. The waters have a table of 64 slots of their own, as fewer slots than surfaces would have them take each other's in turn.

frame_step_check.py calls the water and day hooks as their sites do. It also gets the three platform stubs it has lacked since the day clock's hook, without which it did not link.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
chrg6
2026-11-12 15:05:53 -05:00
parent cc0b467a2c
commit 6d049c244f
6 changed files with 114 additions and 14 deletions

View File

@@ -350,10 +350,13 @@ launcher imports a zip it wrote. The second builds `fs_host.c` and `save_share.c
and runs each step as a start of the game on a profile it makes: the export, an import from a zip the launcher
exported, its backup and swap at the next start, the zips it refuses and Cancel. No game files.
A change to the step Ocarina is given each frame (`runtime/frame_rate.c` `ocarina_step`) passes
A change to the step Ocarina is given each frame (`runtime/frame_rate.c` `ocarina_step`), or to the whole numbers the
game's counters get out of it (`whole_take`, `frame_rate_day_host`, `frame_rate_water_host`), passes
`python tests\frame_step_check.py`, which builds `frame_rate.c` with the rest of the runtime stubbed and feeds it
display times: a held 60 fps limit gives exactly one frame every frame, a 240 fps limit never less than a quarter
frame, uneven frame times one steady step, and game time follows the clock. No game files.
frame, uneven frame times one steady step, and game time follows the clock. It calls the two hooks as their patch
sites do: at 144 fps each of 64 water objects gets its ripple steps and the day its seconds, and a step of one frame
gives what the game computes. No game files.
A change to the mouse camera (`runtime/mouse_direct.c`, the mouse path of `runtime/input.c`, the set `mouse_direct` of
`recomp/patches-v111.txt` or `patches-v143.txt`) passes `python tests\mouse_direct_check.py --cc <clang> --deps

View File

@@ -46,6 +46,17 @@ site ostep main 3b4eaf8 1e201820 bd410120 # fdiv s0, s1, s0 -> ldr s0, [x9, #2
# with the fraction kept for the next frame, so the day keeps the game's pace at any frame rate. x30 is the function's
# own saved one, and the call at main+0x3cdfc8 sets it again.
site ostep main 3cdfc0 1e380008 952b4be8 # fcvtzs w8, s0 -> bl page+0xf60
# The water's ripples: the water renderer (main+0x472e0, reached from main+0x47160 for a water object whose flags at
# +8 have bit 3, which that entry clears) runs its ripple simulation trunc(step) * substeps times, the step being the
# time manager's (its +0, or one of its group steps at +0x20 + 0x30 * n) and the substeps +0x4c of the water's
# parameters (object +0x1e8, then +0x150, or the parent those name), and skips the simulation when the product is
# under 1 (main+0x47618). With the step above under one frame that was every frame above 60 fps: the rings Link and
# everything else make in water stood still. The cut calls the page's +0xf50 instead (runtime/frame_rate.c
# frame_rate_water_host): the step goes into a remainder kept for the water object (x21, the function's x0) and w9
# gets the whole steps in it, so the rings spread at the game's pace at any frame rate, and a step of exactly one
# frame gives what the game computes. Every path to the cut has the step in s0 (the one that asks the parameters'
# 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: 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

View File

@@ -482,9 +482,12 @@ static double ocarina_step(double dt) {
// above 60 fps, the cut loses the fraction every frame. whole_frames keeps it per counter (by its guest address) and
// hands out the whole part, so each counter moves at the game's pace at any frame rate; at a step of exactly one it
// gives what the game computes. A value that is not positive, or too large for a count, is cut as fcvtzs cuts it.
// The water's ripples keep their remainders per water object in a table of their own (frame_rate_water_host).
#define WHOLE_SLOTS 8
static struct { uint64_t at; float rem; uint32_t used; } whole_slot[WHOLE_SLOTS];
static uint32_t whole_calls;
#define WATER_SLOTS 64
typedef struct { uint64_t at; float rem; uint64_t used; } WholeSlot; // used: whole_calls at the slot's last use
static WholeSlot whole_slot[WHOLE_SLOTS], water_slot[WATER_SLOTS];
static uint64_t whole_calls;
static PlatMutex whole_lock = PLAT_MUTEX_INIT;
static uint32_t cut_fcvtzs(float f) { // fcvtzs w, s: toward zero, saturating, NaN 0
@@ -494,23 +497,26 @@ static uint32_t cut_fcvtzs(float f) { // fcvtzs w, s: toward zero, saturating,
return (uint32_t)(int32_t)f;
}
static uint32_t whole_frames(uint64_t at, float amount) {
// The whole part of amount plus the remainder the table slot[n] keeps for `at`; a new `at` takes the slot unused longest.
static uint32_t whole_take(WholeSlot *slot, int n, uint64_t at, float amount) {
if (!(amount > 0.0f) || amount >= 2147483648.0f) return cut_fcvtzs(amount);
plat_mutex_lock(&whole_lock);
int k = -1, oldest = 0;
for (int i = 0; i < WHOLE_SLOTS; i++) {
if (whole_slot[i].at == at) { k = i; break; }
if (whole_slot[i].used < whole_slot[oldest].used) oldest = i;
for (int i = 0; i < n; i++) {
if (slot[i].at == at) { k = i; break; }
if (slot[i].used < slot[oldest].used) oldest = i;
}
if (k < 0) k = oldest, whole_slot[k].at = at, whole_slot[k].rem = 0.0f;
whole_slot[k].used = ++whole_calls;
float sum = whole_slot[k].rem + amount;
if (k < 0) k = oldest, slot[k].at = at, slot[k].rem = 0.0f;
slot[k].used = ++whole_calls;
float sum = slot[k].rem + amount;
uint32_t whole = cut_fcvtzs(sum);
whole_slot[k].rem = sum - (float)whole;
slot[k].rem = sum - (float)whole;
plat_mutex_unlock(&whole_lock);
return whole;
}
static uint32_t whole_frames(uint64_t at, float amount) { return whole_take(whole_slot, WHOLE_SLOTS, at, amount); }
// The time of day (main+0x3cdf70, patches-v111.txt): s0 holds twice the step, the game seconds of this frame; w8 gets
// the whole ones, the counter being the seconds of the day at +0x3c of the object in x19.
// NX2NAVI_TRACE=perf: every 10 s, the game seconds the day was given beside those the cut alone would have given.
@@ -535,6 +541,32 @@ void frame_rate_day_host(Cpu *c) {
}
}
// The water's ripples (main+0x472e0, patches-v111.txt): s0 holds the frame's step and w9 gets the whole steps in it,
// which the renderer multiplies by the water's substeps. The remainder is kept for the water object in x21, in a table
// of the waters' own: every water surface of a place asks once a frame, and with fewer slots than surfaces each would
// take another's slot in turn, start without a remainder every frame and stand still as it did with the cut.
// NX2NAVI_TRACE=perf: every 10 s, the steps the waters were given beside those the cut alone would have given.
void frame_rate_water_host(Cpu *c) {
uint32_t bits = (uint32_t)c->v[0];
float step;
memcpy(&step, &bits, 4);
uint32_t whole = whole_take(water_slot, WATER_SLOTS, c->x[21], step);
c->x[9] = whole;
if (rt_trace & RT_TRACE_PERF) {
static uint64_t t0, asked, given, cut;
uint64_t now = plat_now_ns(), n = 0, g = 0, k = 0, t = 0;
plat_mutex_lock(&whole_lock); // the counts: nothing says one thread alone draws the waters
asked++, given += whole, cut += cut_fcvtzs(step);
if (!t0) t0 = now;
if (now - t0 >= 10000000000ull) n = asked, g = given, k = cut, t = now - t0, t0 = now, asked = given = cut = 0;
plat_mutex_unlock(&whole_lock);
if (n)
rt_log("frame rate: the water's ripples got %llu steps in %.1f s, asked %llu times (cut to whole steps "
"each time, as the game cuts them, %llu)", (unsigned long long)g, t / 1e9, (unsigned long long)n,
(unsigned long long)k);
}
}
// The 60 Hz frames the game steps at each update where the set ostep gives them (Ocarina); 1 elsewhere, the game's own
// nominal frame. The mouse camera divides by it (mouse_direct.c): the game multiplies camera input by this step.
float frame_step_frames(void) {

View File

@@ -397,6 +397,7 @@ int frame_free_running(void); // no fps60/realtime p
double frame_step_limit_hz(void); // the frame rate the limit gives (overlay); 0 unknown
double frame_step_game_hz(void); // the rate the game draws at under the limit; 0 unknown
void frame_rate_day_host(Cpu *c); // the time of day's seconds a frame (set ostep, page +0xf60)
void frame_rate_water_host(Cpu *c); // the water's ripple steps a frame (set ostep, page +0xf50)
float frame_step_frames(void); // the 60 Hz frames the game steps per update (set ostep), else 1
void frame_step_flight(float *page, float *game, uint64_t *presents); // flight.c: the step at a host present
// rtss_limit.c: RivaTuner Statistics Server takes the frame limit where it runs

View File

@@ -32,9 +32,12 @@ uint8_t rt_patch_on[RT_PATCH_COUNT > 0 ? RT_PATCH_COUNT : 1]; // read by the g
#ifdef RT_PATCH_PAGE
#include "debug_tools.h"
__attribute__((constructor)) static void freecam_register(void) { rt_register_host(RT_PATCH_PAGE + 0xf70, dbg_freecam_host); }
// The time of day's site of the set ostep (patches-v111.txt) calls the page's +0xf60 (frame_rate.c).
// The time of day's site of the set ostep (patches-v111.txt) calls the page's +0xf60 and the water renderer's site
// its +0xf50 (frame_rate.c).
__attribute__((constructor)) static void day_register(void) {
if (RT_V111) rt_register_host(RT_PATCH_PAGE + 0xf60, frame_rate_day_host);
if (!RT_V111) return;
rt_register_host(RT_PATCH_PAGE + 0xf60, frame_rate_day_host);
rt_register_host(RT_PATCH_PAGE + 0xf50, frame_rate_water_host);
}
#endif

View File

@@ -44,9 +44,12 @@ int plat_mem_readable(uint64_t a, size_t n) { (void)a, (void)n; return 0; }
void mem_kernel_access(uint64_t a, uint64_t n) { (void)a, (void)n; }
int mem_host_readable(uint64_t a, uint64_t n) { (void)a, (void)n; return 0; }
uint64_t kern_now_ns(void) { return 0; }
void plat_mutex_lock(PlatMutex *m) { (void)m; } // one thread here
void plat_mutex_unlock(PlatMutex *m) { (void)m; }
// ---- one present: the pacer asks for the period, then reports the display time. Returns the step in 60 Hz frames.
static uint64_t now_ns = 1000000000ull;
uint64_t plat_now_ns(void) { return now_ns; }
static double wall_s, game_s;
static float present(double interval_s) {
frame_step_cap_ns();
@@ -65,6 +68,25 @@ static void set_limit(const char *l) {
wall_s = game_s = 0;
}
// ---- the patch sites that cut the step to a whole number, called as the lifted code calls them: the value in s0, the
// object in its register, the whole number back in the register the cut wrote (all 64 bits of it).
static Cpu cpu;
static uint32_t water(uint64_t object, float step) { // frame_rate_water_host: x21, w9
uint32_t bits;
memcpy(&bits, &step, 4);
cpu.v[0] = bits, cpu.x[21] = object, cpu.x[9] = ~0ull;
frame_rate_water_host(&cpu);
CHECK(cpu.x[9] >> 32 == 0, "the water's steps leave x9's upper half %#llx", (unsigned long long)cpu.x[9]);
return (uint32_t)cpu.x[9];
}
static uint32_t day(uint64_t object, float secs) { // frame_rate_day_host: x19, w8
uint32_t bits;
memcpy(&bits, &secs, 4);
cpu.v[0] = bits, cpu.x[19] = object, cpu.x[8] = ~0ull;
frame_rate_day_host(&cpu);
return (uint32_t)cpu.x[8];
}
int main(int argc, char **argv) {
int mean = argc > 1 && !strcmp(argv[1], "mean");
if (mean) steady = "0";
@@ -170,6 +192,34 @@ int main(int argc, char **argv) {
caps[i].gen, game, caps[i].game_hz);
}
fg_mult = 1;
// The water's ripples at 144 fps, a step of 60/144 frame: 64 water objects asked once a frame each run 600 steps
// in 1440 frames (the cut gave none), and the day's seconds asked between them keep their own fraction.
{
const float step = 60.0f / 144.0f;
const uint64_t first = 0x7100000000ull, clock = 0x7200000000ull;
uint32_t steps[64] = {0}, secs = 0;
for (int f = 0; f < 1440; f++) {
for (int k = 0; k < 64; k++) steps[k] += water(first + 0x1000 * (uint64_t)k, step);
secs += day(clock, 2 * step);
}
for (int k = 0; k < 64; k++)
CHECK(steps[k] >= 599 && steps[k] <= 600, "water object %d of 64 at 144 fps: %u steps in 1440 frames", k, steps[k]);
CHECK(secs >= 1199 && secs <= 1200, "the day beside 64 water objects: %u seconds in 1440 frames", secs);
}
// A step of one frame, and of two, is what the game computes, also after a fraction was left; a quarter frame
// gives one step every fourth frame, and what fcvtzs cuts to nothing or saturates leaves the remainder alone.
{
const uint64_t pond = 0x7300000000ull;
for (int i = 0; i < 3; i++) CHECK(water(pond, 0.25f) == 0, "a quarter frame, call %d", i);
CHECK(water(pond, 0.0f) == 0 && water(pond, -3.5f) == 0xfffffffdu && water(pond, NAN) == 0 &&
water(pond, 3e9f) == 0x7fffffffu, "zero, a negative step, NaN and a step too large are cut as fcvtzs cuts");
CHECK(water(pond, 0.25f) == 1, "the fourth quarter frame");
CHECK(water(pond, 0.5f) == 0, "half a frame");
for (int i = 0; i < 60; i++) CHECK(water(pond, 1.0f) == 1, "one frame, call %d", i);
for (int i = 0; i < 30; i++) CHECK(water(pond, 2.0f) == 2, "two frames, call %d", i);
CHECK(water(pond, 0.5f) == 1, "the half frame kept through the whole ones");
}
printf("frame_step_check: %s\n", fails ? "FAILED" : "ok");
return fails != 0;
}