build-pc.sh Windows temp-dir re-exec fixes; CI pin actions and no-cache ROM handling; original-N64 and Deck presets; D440 preset labelling; D442 All unlocked as a RAM/query-time override; D443 settings values (MSAA 16x, Restart game); D444 front-end Previous/Next paging; D447 Original-aspect pillarbox/letterbox. Outside contributions by dolent: PRs #120 and #121 (relabelled D445, D446), merged as dolent's original commits. Refs: D440 D441 D442 D443 D444 D445 D446 D447 D387 D283 #120 #121
16 KiB
Unlocked FPS Plan
Status: proposal, parked — documented while audio work (project Phase 3) is active. Do not start Phase 0 until audio is done; first action when resumed is the Phase 0 measurement pass.
M-119 note: this plan's premise below ("default preset = ~30fps") is now
stale — D248 (M-118) found and fixed a real port bug (a pointer-width
scheduler ABI bug) that had been silently halving the game to 30fps since the
port's inception; the corrected default is a genuine 60fps. Re-measure Phase 0
against the corrected baseline before resuming this plan. Also see D250
(M-119, open): the exposed real 60fps cost now drops to 20-40fps intermittently
on some levels — a performance pass may be a prerequisite for this plan, not
just a baseline re-measurement.
Related: docs/dev/findings.md §D117 (GE_DETERM), §D155 (catch-up clamp),
port/src/libultra.c (pacemaker), src/game/frametiming.c, src/sched.c.
Decisions (agreed)
- Default preset = the original pace — whatever the game currently does
(measured ~30 fps; Phase 0 pins the exact rate). All testing, level
sweeps, determinism runs (
GE_DETERM), and playtest baselines stay on the default preset. Its code path must remain byte-for-byte what it is today. - Unlocked FPS (60 / 120 / uncapped) is EXPERIMENTAL. It ships behind a clearly-labelled option and may be less polished than the default.
- Hard requirement: raising the presentation rate must never change game speed. Sim rate is invariant across presets. This is "the main speed-up issue" and it gates everything else.
- Follow the PD PC port where feasible (
pd_portcheckout): wall-clock accumulator pacing with remainder carry, vsync + framerate-limit options, safety cap when vsync is off.
Non-goals (for now)
- Motion interpolation between sim ticks (the only thing that would make 120 fps look like 120 fps). Requires per-entity prev/cur state in the DL-building game code — rule #2 territory, parked as a separate proposal.
- Changing the sim rate itself (running logic at 120 Hz with halved constants is explicitly out — game logic).
Background: why raising FPS today speeds up the game
One clock drives everything:
portTickThread(port/src/libultra.c) posts a VI-retrace message on a free-running grid (60 Hz NTSC / 50 Hz PAL,g_tickIntervalUs).src/sched.c__scMainblocks on that retrace;__scHandleRetracenotifies clients → exactly one sim tick + one render per retrace.osGetCount()is wall-clock scaled to 46.5 MHz.waitForNextFrame()(src/game/frametiming.c) converts elapsed time todeltaFrames, which becomesg_ClockTimer(src/game/lv.c:1008) driving the per-tick sim loops.
The quantization is the trap: nextFrameTime = (elapsed + Q/2) / Q (Q = one
frame quantum). The +Q/2 bias rounds every interval up to at least 1
tick, and waitForNextFrame spins until ≥1 tick is due. Consequence:
sim speed tracks the retrace rate almost 1:1 — 30 Hz retrace → 30 ticks/s,
120 Hz retrace → ~120 ticks/s (each 8.3 ms interval rounds up to a full
tick). That is exactly the observed "anything faster speeds up the game".
Two useful existing properties:
- GE's game code already handles
g_ClockTimer == 0and multi-tick deltas everywhere (for (i = 0; i < g_ClockTimer; i++), explicit== 0guards in bondhead/bondview2/propobj/explosion). The N64 used this for dropped frames. - D155 already clamps catch-up to 6 ticks after a real-time hitch.
What the PD port does (reference)
src/game/timing.cframetimeCalculate(): wall-clock accumulator with remainder carry (lostframetime60t) →diffframe60/diffframe240ticks due per rendered frame; render loop runs at display rate.- Vsync + framerate limit options (
VIDEO_MAX_FPS200/240); vsync-off with no cap forces the safety cap. - Feasibility mapping to GE:
- Pacing math (accumulator + carry) → adoptable port-only. ✓
- Render frames carrying zero sim ticks → blocked in GE by
waitForNextFrame's ≥1-tick enforcement (frameDelay, only settable underLEFTOVERDEBUG). A craftedosGetCount()cannot defeat it (the spin ++Q/2bias always yield ≥1). Needs a game-code change → parked (see "Parked" below), and note it buys little without interpolation.
Phases
Phase 0 — Baseline & ground truth (port-only)
RESOLVED (2026-09-12, commit 8f2f9e1d, D248). The ~30 fps default was
confirmed a port artifact, not original N64/GE behavior: sched.c's
__scHandleRetrace read a per-client "every retrace vs. every other
retrace" flag via a hardcoded *((s32*)client + 2) byte offset (sched.c
~line 350) that only lands on the right field when OSScClient is 8 bytes
(32-bit pointers, true on N64). On this 64-bit port the struct is 16 bytes,
so the read landed inside the gfx client's own msgQ pointer (always
non-NULL), silently collapsing the gfx client onto the audio client's 30Hz
path even though the VI-retrace pacemaker itself always ticked at a correct
60Hz. Fix (#ifdef PORT, sched.c ~line 348): index the struct
(client[1].next) instead of the hand-rolled offset, so it scales with
pointer width. Verified live via the Video.DisplayFPS overlay: rock-stable
30 FPS pre-fix, 60 FPS post-fix, same build/scene. Same bug class as
D122/D126/D132/D209 (pointer-width struct growth), just in the scheduler.
Decision-gate answer: the default preset is now the console's real 60Hz(NTSC)/50Hz(PAL) rate — decision #1 above ("default preset = the original pace") is satisfied by this fix, not by preserving the halved 30fps. All prior playtest/determinism baselines predate this fix and should be treated as pre-D248.
- Find why the default was ~30 rather than the console's 60 Hz — root cause above; §F D248.
- Record a Dxxx finding with the measured rates and root cause — D248.
- Measure actual sim rate and present rate with a dedicated periodic log
line (
sim=..Hz render=..Hz) rather than the one-offDisplayFPSoverlay reading used to verify D248. Not yet built — still open if a standing measurement (vs. a spot-check) is wanted before Phase 1/2 work lands. - Decision gate — resolved above.
Phase 1 — PD-style pacing hardening (port-only)
Makes the default preset stable and drift-free; no game-code changes.
Audit (2026-09-12, post-D248): more of this than the plan assumed already exists. Verified by reading the code directly (not just the delegate's first pass, which mis-read one of these — see below):
- Accumulator + remainder carry, tick thread.
portTickThread(port/src/libultra.c~294-313) already schedules offg_nextTickUs += g_tickIntervalUs— the scheduled time, notnow— so it doesn't drift under load. It resyncs tonow + intervalonly when a tick is already overdue (g_nextTickUs <= now), which is a bounded-catch-up behavior, not a naive reset. This already matches the PDframetimeCalculateaccumulator pattern. - Accumulator + remainder carry, present-side limiter.
sync_framerate_with_timer(port/fast3d/gfx_sdl2.cpp~403-428) uses the same shape:next = previous_time + interval(schedule-based, not wall-clock-based), a sleep-then-busy-wait hybrid to hit the deadline precisely, and only snapsprevious_timeto the real wake timetwhen the overshoot exceeds ~1ms (t - next < 10000in its 100ns units) — i.e. it already carries the remainder in the normal case and only gives it up after a real hitch. (An earlier audit pass mis-read this as "no remainder carry, resets every frame" — it does not; re-verified by readinggfx_sdl2.cpp:403-428directly.) - Triple-buffered swap chain. Not present — presentation uses a
standard SDL/GL double-buffer swap (
SDL_GL_SwapWindow,gfx_sdl_swap_buffers_begin,gfx_sdl2.cpp~436-444). Not started. - PD safety rule (vsync off + no cap → force a max cap). Not present.
The only existing floor/ceiling logic is the D186 guard in
gfx_sdl_set_target_fps/video.c(~289-292, ~464-467), which goes the other direction — it refuses caps below 30 (because pacing still runs inline on the sim thread, see Phase 2 note below) — there is no "vsync off AND cap==0" case that forces a ceiling. Not started. - Config:
Video.VSync/Video.FpsCapalready registered (video.c:106-107) and live-reapplied (video.c~340-349).
Net: the hard part of Phase 1 (drift-free accumulator scheduling) is already done, on both the sim-tick and present-side clocks. What's left is narrow: a safety-cap rule and, if wanted, triple buffering — plus whatever Phase 2 needs on top.
Phase 2 first slice, scoped (2026-09-12). The real gap for a 120fps
presentation preset isn't pacing math — it's that gfx_run()
(gfx_pc.cpp:3179-3232) composites straight into the default framebuffer
(fb 0) once per sim tick and then calls gfx_wapi->swap_buffers_begin()
once; there's no mechanism to show that same completed frame again on an
extra vsync between sim ticks. The existing GfxRenderingAPI already has
everything needed to do this without any new GL primitives or a second
thread/context:
copy_framebuffer(fb_dst, fb_src, left, top, flip_y, use_back)(gfx_opengl.cpp:1270) already blits the real window backbuffer (fb_src == 0+use_back = truereadsGL_BACK, confirmedgfx_opengl.cpp:1309-1311) into an arbitrary allocated FBO/texture, and can blit back the other way (fb_dst == 0writes straight into the real default framebuffer,gfx_opengl.cpp:1214'sfb_id == 0 -> bind 0convention).gfx_pc.cppalready keeps a couple of always-allocated internal framebuffers this same way (game_framebuffer,game_framebuffer_msaa_resolved, created atgfx_pc.cpp:3069-3070) — a new "last presented frame" capture FBO is the same pattern, one more entry.- The tick clock (
portTickThread) runs on its own pthread, independent of the render/sim thread that callsgfx_run/swap_buffers_begin— so spending extra wall-clock time insideswap_buffers_beginpresenting a duplicate frame does not touch the tick schedule at all; it only changes how much idle time the render thread burns before the next tick message is waiting for it. Sim rate stays exactly invariant by construction, per decision #3.
Smallest concrete slice: capture the just-finished frame into a new
internal FBO right before the real swap_buffers_begin() call
(gfx_pc.cpp:3230-3231, via gfx_rapi->copy_framebuffer(captureFb, 0, -1, -1, false, true)); when an experimental Video.PresentRate config knob
requests double-rate (2x — e.g. 120 from a 60Hz NTSC sim, 100 from 50Hz
PAL), have gfx_sdl_swap_buffers_begin (gfx_sdl2.cpp) do the normal swap,
then — using the same schedule-accumulator shape as
sync_framerate_with_timer — sleep to the halfway point of the tick
interval, blit the capture FBO back into fb 0
(gfx_rapi->copy_framebuffer(0, captureFb, -1, -1, false, false)), and
SDL_GL_SwapWindow again. Default (Video.PresentRate = 0) must take none
of these code paths, so the default preset stays byte-identical to today.
Restrict the first slice to exactly {0 = off, 2 = double}; don't generalize
to arbitrary/uncapped multipliers yet — that needs the vsync-off safety cap
from the Phase 1 list above first. No options-menu UI in this slice
(config-file-only); that's a fast follow once the mechanism is playtest-
verified not to tear/stutter.
Phase 2 — Experimental presentation rates (port-only)
The user-visible "60 / 120 / unlocked" feature. Game speed is invariant by construction: sim + render stay on the original tick clock; only present runs faster by showing each rendered frame more than once.
- Frame duplication at the present stage (
video.c/ fast3d SDL swap path, neargfx_pre_swap_hook): after a fresh frame is presented, re-present the last finished frame on extra vsyncs until the next sim tick's frame is ready. Target rates: 60, 120, uncapped (safety-capped à la PDVIDEO_MAX_FPS). - Input at present rate: sample SDL input continuously in
port/src/input.c; each sim tick consumes the latest state (compare against PDjoy.c). This is the real tangible win of the feature: up to ~half a frame of latency removed even without interpolation. - Options menu entries, clearly labelled experimental
(
port/src/optionsoverlay.c), default preset untouched. - Audio check:
src/audi.cuses real-timeosGetTime()and its own thread; libaudio buffer sends ride the sim/retrace clock. Verify nothing divides by an assumed frame rate; expect no changes.
Phase 3 — Verification & sign-off
GE_DETERMregression: default preset simulation path byte-identical to pre-change (existing determinism diffs keep passing).- Preset-invariance playtest: a timed in-game event (e.g. a fixed-length animation or timer) takes the same real time on default / 60 / 120 / unlocked. This is the acceptance test for decision #3.
- Hitch recovery: force a multi-second stall (debugger pause / asset load), confirm D155 clamp holds and no speed spiral on any preset.
./build-pc.sh+/linkcheckritual; new findings in §F/§H with index labels.
Timing-consumer audit (each gets a line in the finding)
| Consumer | Clock class | Action |
|---|---|---|
src/game/frametiming.c (waitForNextFrame, updateFrameCounters) |
sim-time | unchanged; stays tick-locked |
src/boss.c:403,517 (main-loop gate, MAIN_LOOP_TICK_INTERVAL) |
sim-time | unchanged; re-verify vs new pacemaker |
src/usb.c:387,405 (LibDragon flash timeouts) |
real-time | fine as-is; document |
src/speed_graph.c |
measurement | fine; source of the rate log line |
libultrare/audio/env.c, reverb.c lastCnt |
perf counters only | fine; document |
src/audi.c g_DeltaTime etc. (osGetTime) |
real-time | fine; verify in Phase 2 |
Parked (needs explicit sign-off, separate proposals)
- True decoupled sim/render (PD's zero-tick render frames). Minimal
change class: relax
waitForNextFrame's ≥1-tick enforcement so rendered frames can carrydeltaFrames = 0(GE already guardsg_ClockTimer == 0everywhere). This is a behavior-affecting game-code edit gated off by default — outside the ABI-only D3x exception, needs its own rules decision. Perceptual benefit over Phase 2 duplication is small without interpolation, so do this only as a prerequisite for #2. - Motion interpolation (prev/cur entity state, lerp at render time). The actual "120 fps looks like 120 fps" feature. Large, invasive, in DL-building game code. Separate proposal; not implied by this plan.
Open questions
- Phase 0 outcome: is the ~30 fps default a port artifact? (fix vs keep — see decision gate)
- PAL builds: default stays 50 Hz pace; experimental present rates identical option set. Confirm no PAL-specific pacing assumptions in Phase 1 math.
- Naming/caps for "unlocked": adopt PD's safety-cap value (200/240) or expose the cap as a slider?
M-87 PD-legacy-survey note (candidate 8): if this plan stalls and GE stays
fixed-tick, PD's Game.TickRateDivisor (0–10, slow-motion/perf lever) +
Game.ExtraSleep (port/src/main.c:39,167-168, applied src/game/timing.c:49)
is a cheap borrowable fallback lever — not otherwise relevant since this plan's
whole direction is decoupling sim from frame rate, the opposite of PD's
fixed-tick-with-a-divisor approach.