Files
totk-pc-recomp/runtime/frame_rate.c
T
Valiantsin DzerakhandClaude Opus 5.5 06811330e7 Extend the climb controller's frame-rate factor above 60 fps, so Link climbs walls and ladders at full speed (#35, #276, #1001)
The climb velocity step (1.4.3 main+0x1f49754, 1.2.1 main+0x1918220) multiplies the requested velocity by Nintendo's
factor for a movement loss that grows with the frame rate. 1.4.3 fits it at 30 and 60 fps and caps it at 2 from about
77 fps (63% climb speed at 120, 34% at 240); 1.2.1 has no 60 fps branch (78% at 60). Below a 1/60 s step the set climb
continues the factor with a curve fitted to a replayed ladder climb, taking the patch page's frame ratio without the
time scale, so 30 and 60 fps keep the shipping factor bit for bit, slow motion included. Only the player's climb
modules reach this code. Its own setting, climb_fix under [frame rate], with a menu toggle; off without the real-time
step. Ladder replay, 1.4.3: 1.665 / 1.663 / 1.669 / 1.681 m/s at 30 / 60 / 120 / 240 (shipping 120 1.046, 240 0.563).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 18:44:18 +03:00

298 lines
16 KiB
C

// Adapted from MacNativeGames games/totk/runtime/game_patches.c @ ebe621d (MIT, Copyright (c) 2026 MacNativeGames
// contributors): runtime_frame_step_update (2338bb0: no 1/120 s floor) and the speed survey's player chain
// (world_survey.c player_actor).
// Frame rate: the frame limit and the real-time frame step, the host half of the patch sets fps60, realtime and cloth
// (build tables patches-v121.txt, patches-v143.txt).
//
// TotK's adaptive present-interval controller (main+0x8f26c8) turns the main thread's measured work into a number of
// 60 Hz ticks clamped to [minimum, maximum]; shipping data sets the minimum to 2 (30 fps), the set fps60 lowers it to
// 1. The controller still raises the interval when a frame does not fit. Shipping code then steps the game by whole
// vsyncs (VFR step main+0x7ede6c: ratio = interval * 0.5, seconds = interval / 60; global ratio at main+0x8f2394), so
// game speed is right only when frames take exactly their interval. With the set realtime both read the patch page
// instead: +0 ratio = seconds * 30, +4 seconds (1.4.3: +8 interval = seconds * 60, the producer's nominal interval
// before its half-step coefficient), the moving average of the last 5 display intervals, at most 1/15 s
// (slower frames slow the game down, as on the console) and at least 1/240 s, so a higher frame-rate cap only changes
// the pacer (frame_step_cap_ns, TOTK_FPS_LIMIT). The set cloth gives hair and cloth the same clock and needs realtime.
//
// The floor: with a shorter step (ratio below 0.125) 1.4.3 aborts within seconds of some save loads (issue #241, a
// 280 Hz monitor): Havok's hkDefaultMemoryImpl reports "Out of memory" for the buffer the physics pre-solve (profiler
// scope "TtPreSolve") resizes in main+0x1261630. Forced steps on the Depths save: 1/240 s plays, 1/244 s and shorter
// abort; the field, sky island and fight saves play at 1/280 s, the town save aborts after about 800 frames. The
// same short steps also crash a physics worker reading that buffer's contacts: an access violation in main+0x1292560
// or its caller main+0x1286870 (narrowphase contact setup, reached from the grid task main+0x12f1ea0), reading a
// Havok body's shape at body+0x78 as null or garbage (issues #29 and #92, 240 Hz monitors with fps_limit = display).
// Steps drawn from 1/252..1/230 s crash the Depths load there in seconds; 1/240..1/230 s plays. The frame limit
// stops at 240 too (LIMIT_MAX_HZ), so the floor only catches the average dipping under 1/240 s at that limit: a
// present late by less than a period keeps the pacer's phase (nvn_api_obj.c, pace), so the next display interval is
// shorter than the period. No limit makes the game run slower than the clock.
//
// The sample is the display time the pacer gives each present (nvnQueuePresentTexture), so the step follows the
// frames as shown, pacing waits included. With the step on, the pacer ignores the game's interval and holds a frame
// only to the frame limit (nvn_api_obj.c present_period): the controller's 2 or 3 vsyncs would only add latency. The
// vsync event stays at 60 Hz whatever the limit: the game does not wait for it each frame (1.4.3 shows its title at 144
// fps with the event at 60 Hz).
// One 64-bit and one 32-bit store per present; the guest reads the floats on its own threads, and each stays finite
// and in range whichever store it sees.
//
// The world clock (frame_step_world_us) sums the steps from the first present that finds the player actor away from
// the origin, where the title screen keeps it: the end of the first save load. Recorded and scripted input
// (input.c) runs on it, so a replay presses at the same game time at any frame rate and after a first load of any
// length; a later load (fast travel) still shifts what follows by its length.
//
// TOTK_TRACE=perf adds a line per 120 presents: fps, the game seconds stepped against wall seconds, and with
// TOTK_SPEED_PROBE=1 the player's position and ground speed (an independent check: Link runs at the same speed at
// any frame rate when game time follows wall time).
#include <math.h>
#include <stdatomic.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "cfg.h"
#include "gfx.h"
#include "host.h"
#include "layout.h"
#include "platform/platform.h"
#define STEP_SAMPLES 5
#define STEP_MAX_S (1.0 / 15.0) // longest step; a longer frame slows the game down
#define STEP_RESET_S 0.25 // a gap this long (loading, a debugger) restarts the average
#define STEP_FIRST_S (1.0 / 60.0)
#define STEP_MIN_S (1.0 / 240.0) // shortest step; a shorter one makes the game's physics run out of memory
static _Atomic uint64_t *step_word; // patch page +0: ratio (low float), seconds (high float)
static double step_now = STEP_FIRST_S; // the step the patch page holds: what the game steps each frame
static _Atomic uint32_t *step_interval; // patch page +8: seconds * 60 (patches-v143.txt realtime)
static uint64_t last_ns;
static double samples[STEP_SAMPLES];
static unsigned sample_count, sample_next;
static void step_store(float seconds) {
step_now = seconds;
float ratio = seconds * 30.0f, interval = seconds * 60.0f;
uint32_t lo, hi, iv;
memcpy(&lo, &ratio, 4);
memcpy(&hi, &seconds, 4);
memcpy(&iv, &interval, 4);
atomic_store_explicit(step_word, (uint64_t)lo | (uint64_t)hi << 32, memory_order_release);
atomic_store_explicit(step_interval, iv, memory_order_release);
}
static void frame_limit_live(void);
void frame_step_init(void) {
int fps60 = rt_patch_set("fps60"), realtime = rt_patch_set("realtime"), cloth = rt_patch_set("cloth");
if (realtime < 0) {
rt_log("frame rate: no patch table, shipping 30 fps whole-vsync steps");
return;
}
if (!rt_patch_enabled(realtime) && rt_patch_enabled(cloth)) {
rt_patch_disable(cloth);
rt_log("frame rate: cloth clock off, it needs the real-time step (TOTK_REALTIME=0)");
}
int climb = rt_patch_set("climb"); // reads the page's frame ratio, written only with the real-time step
if (climb >= 0 && !rt_patch_enabled(realtime) && rt_patch_enabled(climb)) {
rt_patch_disable(climb);
rt_log("frame rate: climb speed fix off, it needs the real-time step (TOTK_REALTIME=0)");
}
if (rt_patch_enabled(realtime)) {
step_word = (_Atomic uint64_t *)rt_patch_page();
step_interval = (_Atomic uint32_t *)((uint8_t *)rt_patch_page() + 8);
step_store((float)STEP_FIRST_S);
}
frame_limit_live(); // TOTK_FPS_LIMIT decides the set fps60 from the start
rt_log("frame rate: present interval %s, %s", rt_patch_enabled(fps60) ? "1..3 (above 30 fps)" : "2..3 (shipping 30 fps)",
step_word ? "real-time step (5-frame average of display intervals, at most 1/15 s)" : "whole-vsync steps");
}
int frame_step_realtime(void) { return step_word != NULL; }
// The frame limit (menu, TOTK_FPS_LIMIT, live): 30, 60, 90, 120, 144, any whole rate from 30 to 240, or display (the
// refresh rate of the window's monitor, at most 240). A higher number gives 240, the rate of the shortest step the
// game survives (STEP_MIN_S). Unset, the older TOTK_FPS60=0 still means 30. A limit of 30 switches the set
// fps60 off (the shipping minimum interval 2), every other limit switches it on; the controller reads its minimum
// interval from the patched sites each frame. With the real-time step the pacer holds frames to the limit; above 60
// only the pacer changes, as the step's floor is the limit's ceiling. Without it the game steps whole 60 Hz vsyncs and the pacer
// follows the game's interval, so the limit stops at 60.
#define LIMIT_MIN_HZ 30
#define LIMIT_MAX_HZ 240 // 1 / STEP_MIN_S
static int fps60_set = -2;
static unsigned limit_seen;
static int limit_read;
static int limit_hz = -1; // 0: display; -1 before the first read
static int limit_parse(void) {
const char *e = cfg_get("TOTK_FPS_LIMIT"), *old = cfg_get("TOTK_FPS60");
if (!e || !*e) return old && !strcmp(old, "0") ? 30 : 60;
if (!strcmp(e, "display")) return 0;
char *end;
long n = strtol(e, &end, 10);
if (!*end && n > LIMIT_MAX_HZ) {
rt_log("frame rate: TOTK_FPS_LIMIT=%s is above %d, where the game's physics runs out of memory; %d", e,
LIMIT_MAX_HZ, LIMIT_MAX_HZ);
return LIMIT_MAX_HZ;
}
if (!*end && n >= LIMIT_MIN_HZ) return (int)n;
rt_log("frame rate: TOTK_FPS_LIMIT=%s is not %d..%d or display; 60", e, LIMIT_MIN_HZ, LIMIT_MAX_HZ);
return 60;
}
static void frame_limit_live(void) {
unsigned v = cfg_version();
if (limit_read && v == limit_seen) return;
limit_read = 1;
limit_seen = v;
int hz = limit_parse();
if (hz == limit_hz) return;
limit_hz = hz;
if (fps60_set == -2) fps60_set = rt_patch_set("fps60");
if (fps60_set >= 0) rt_patch_switch(fps60_set, hz != 30);
if ((hz > 60 || hz == 0) && !step_word)
rt_log("frame rate: limits above 60 need the real-time step (TOTK_REALTIME=1); 60 fps");
else if (hz) rt_log("frame rate: limit %d fps", hz);
else if (gfx_window_refresh_hz() > 0) rt_log("frame rate: limit at the monitor's refresh rate (%.3f Hz now)", gfx_window_refresh_hz());
else rt_log("frame rate: limit at the monitor's refresh rate (60 until the window measures it)");
}
uint64_t frame_step_cap_ns(void) {
frame_limit_live();
double hz = limit_hz ? limit_hz : gfx_window_refresh_hz();
if (hz < LIMIT_MIN_HZ) hz = 60; // display rate unknown (no window yet)
if (hz > LIMIT_MAX_HZ) hz = LIMIT_MAX_HZ;
return (uint64_t)(1e9 / hz + 0.5);
}
// The frame rate the limit gives, for the performance overlay: the setting, the monitor's rate for "display", 60 at
// most without the real-time step; 0 before the first frame read the setting.
double frame_step_limit_hz(void) {
int hz = limit_hz;
if (hz < 0) return 0;
double r = hz ? hz : gfx_window_refresh_hz();
if (r < LIMIT_MIN_HZ) r = 60;
return !step_word && r > 60 ? 60 : r > LIMIT_MAX_HZ ? LIMIT_MAX_HZ : r;
}
// ---- TOTK_TRACE=perf speed lines
static uint64_t win_t0, win_frames, win_hist[4]; // win_hist: frames by present interval 1, 2, 3+ ([0] unused)
static double win_game;
static int probe = -1;
static float last_pos[3];
static uint64_t last_pos_ns;
static int read_guest(uint64_t a, void *out, size_t n) { return a && plat_mem_read(a, out, n); }
static uint64_t read_ptr(uint64_t a) {
uint64_t p = 0;
return read_guest(a, &p, 8) ? p : 0;
}
// 1.2.1: [main+0x4728538] (1.1.0: [main+0x4630a68], 1.4.3: [main+0x3add238]) +0x1e8 scene, its component 13 lists the
// resident actors; the one named "Player" has its position at +0x2b4 (MacNativeGames world_survey.c, game_version.h
// TOTK_GAME_V121/V143). The 1.1.0 slot was measured from its own dereference chain: 181 scene accesses in that build
// read it, and one of them reproduces this walk (the 14-component check, component 13, the 0x70 stride and the name
// compare) instruction for instruction.
static int player_pos(float xyz[3]) {
uint64_t module = read_ptr(MOD_MAIN_BASE + (RT_V143 ? 0x3add238 : RT_V110 ? 0x4630a68 : 0x4728538)),
scene = module ? read_ptr(module + 0x1e8) : 0;
int32_t count = 0;
if (!scene || !read_guest(scene + 0x50, &count, 4) || count < 14 || count > 256) return 0;
uint64_t comps = read_ptr(scene + 0x58), resident = comps ? read_ptr(comps + 13 * 8) : 0;
if (!resident || !read_guest(resident + 0x20, &count, 4) || count < 1 || count > 64) return 0;
uint64_t links = read_ptr(resident + 0x28);
for (int i = 0; links && i < count; i++) {
uint64_t data = read_ptr(links + (uint64_t)i * 0x70 + 8), actor = data ? read_ptr(data + 0x40) : 0;
uint64_t name = actor ? read_ptr(actor + 0x218) : 0;
char text[7];
if (name && read_guest(name, text, 7) && !memcmp(text, "Player", 7))
return read_guest(actor + 0x2b4, xyz, 12) && isfinite(xyz[0]) && isfinite(xyz[2]);
}
return 0;
}
static void speed_report(uint64_t now, double step, int interval) {
if (!win_t0) {
win_t0 = now;
return;
}
win_frames++;
win_game += step;
win_hist[interval < 1 ? 1 : interval > 3 ? 3 : interval]++;
if (win_frames < 120) return;
if (probe < 0) {
const char *e = cfg_get("TOTK_SPEED_PROBE");
probe = e && !strcmp(e, "1");
}
double wall = (double)(now - win_t0) * 1e-9;
char pos[160] = "";
float p[3];
if (probe && player_pos(p)) {
double d = last_pos_ns ? hypot(p[0] - last_pos[0], p[2] - last_pos[2]) : 0,
s = last_pos_ns ? (double)(now - last_pos_ns) * 1e-9 : 0;
snprintf(pos, sizeof pos, "; player %.1f %.1f %.1f, %.2f m in %.3f s = %.2f m/s", p[0], p[1], p[2], d, s,
s > 0 ? d / s : 0);
memcpy(last_pos, p, sizeof p);
last_pos_ns = now;
}
if (step_word)
rt_log("fps: %llu frames in %.3f s (%.1f fps), %.3f s game stepped, speed %.3f; interval 1/2/3+ %llu/%llu/%llu%s",
(unsigned long long)win_frames, wall, (double)win_frames / wall, win_game, win_game / wall,
(unsigned long long)win_hist[1], (unsigned long long)win_hist[2], (unsigned long long)win_hist[3], pos);
else
rt_log("fps: %llu frames in %.3f s (%.1f fps), whole-vsync steps; interval 1/2/3+ %llu/%llu/%llu%s",
(unsigned long long)win_frames, wall, (double)win_frames / wall, (unsigned long long)win_hist[1],
(unsigned long long)win_hist[2], (unsigned long long)win_hist[3], pos);
win_t0 = now;
win_frames = 0;
win_game = 0;
memset(win_hist, 0, sizeof win_hist);
}
static _Atomic int64_t world_us = -1;
static _Atomic uint64_t game_us; // game time stepped since the first present (frame_step_ticks)
static uint64_t presents;
int64_t frame_step_world_us(void) { return atomic_load_explicit(&world_us, memory_order_relaxed); }
uint64_t frame_step_ticks(void) { return atomic_load_explicit(&game_us, memory_order_relaxed) * 60 / 1000000; }
static void world_clock(double dt, double step) {
presents++;
int64_t w = atomic_load_explicit(&world_us, memory_order_relaxed);
if (w < 0) {
float p[3];
if (!player_pos(p) || fabsf(p[0]) + fabsf(p[2]) < 1.0f) return; // a few guest reads, until the load ends
rt_log("frame step: world clock starts at present %llu, %.2f s after the guest started (the player at %.1f %.1f %.1f)",
(unsigned long long)presents, rt_guest_start_ns ? (kern_now_ns() - rt_guest_start_ns) / 1e9 : 0.0, p[0],
p[1], p[2]);
atomic_store_explicit(&world_us, 0, memory_order_relaxed);
return;
}
if (!step_word) step = dt > STEP_MAX_S ? STEP_MAX_S : dt; // whole-vsync steps: the display time stands in
atomic_store_explicit(&world_us, w + (int64_t)(step * 1e6 + 0.5), memory_order_relaxed);
}
void frame_step_present(uint64_t display_ns, int interval) {
frame_limit_live();
double step = 0, dt = last_ns ? (double)(display_ns - last_ns) * 1e-9 : 0;
if (step_word && last_ns) {
if (dt > STEP_RESET_S || dt < 0.001) {
sample_count = sample_next = 0;
} else {
if (dt > STEP_MAX_S) dt = STEP_MAX_S;
samples[sample_next++ % STEP_SAMPLES] = dt;
if (sample_count < STEP_SAMPLES) sample_count++;
double sum = 0;
for (unsigned i = 0; i < sample_count; i++) sum += samples[i];
step = sum / sample_count;
if (step < STEP_MIN_S) step = STEP_MIN_S;
step_store((float)step);
}
}
last_ns = display_ns;
// The game clock of untimed script lines: what the game stepped this frame, the patch page's step with the
// real-time step (kept through a reset), whole vsyncs without it.
double game = step_word ? step_now : (interval > 0 ? interval : 1) / 60.0;
atomic_store_explicit(&game_us, atomic_load_explicit(&game_us, memory_order_relaxed) + (uint64_t)(game * 1e6 + 0.5),
memory_order_relaxed);
world_clock(dt, step);
if (rt_trace & RT_TRACE_PERF) speed_report(display_ns, step, interval);
}