Files
NX2navi/runtime/frame_rate.c
2026-11-07 05:04:38 -05:00

293 lines
16 KiB
C

// Adapted from MacNativeGames games/<game>/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).
//
// NX2navi'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, NX2NAVI_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.
//
// NX2NAVI_TRACE=perf adds a line per 120 presents: fps, the game seconds stepped against wall seconds, and with
// NX2NAVI_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 (NX2NAVI_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(); // NX2NAVI_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, NX2NAVI_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 NX2NAVI_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("NX2NAVI_FPS_LIMIT"), *old = cfg_get("NX2NAVI_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: NX2NAVI_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: NX2NAVI_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 (NX2NAVI_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;
}
// ---- NX2NAVI_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
// NX2NAVI_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("NX2NAVI_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);
}