mirror of
https://github.com/tangerineturtle1/NX2navi.git
synced 2026-11-15 19:19:58 -04:00
293 lines
16 KiB
C
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);
|
|
}
|