Files
goldeneye-pc-port/docs/dev/UNLOCKED-FPS-PLAN.md
T
James Dansereau ad3856892a v0.4.1 sync: build and CI fixes, settings presets D440-D447, outside PRs
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
2026-10-06 18:35:39 -04:00

277 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)
1. **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.
2. **Unlocked FPS (60 / 120 / uncapped) is EXPERIMENTAL.** It ships behind a
clearly-labelled option and may be less polished than the default.
3. **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.
4. **Follow the PD PC port where feasible** (`pd_port` checkout): 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` `__scMain` blocks on that retrace; `__scHandleRetrace`
notifies 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 to `deltaFrames`, which
becomes `g_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 == 0` and multi-tick deltas
everywhere (`for (i = 0; i < g_ClockTimer; i++)`, explicit `== 0` guards 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.c` `frametimeCalculate()`: wall-clock accumulator with
remainder carry (`lostframetime60t`) → `diffframe60` / `diffframe240` ticks
due **per rendered frame**; render loop runs at display rate.
- Vsync + framerate limit options (`VIDEO_MAX_FPS` 200/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
under `LEFTOVERDEBUG`). A crafted `osGetCount()` cannot defeat it (the
spin + `+Q/2` bias 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.
- [x] Find why the default was ~30 rather than the console's 60 Hz — root
cause above; §F **D248**.
- [x] 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-off `DisplayFPS`
overlay 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.
- [x] **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):
- [x] **Accumulator + remainder carry, tick thread.** `portTickThread`
(`port/src/libultra.c` ~294-313) already schedules off `g_nextTickUs +=
g_tickIntervalUs` — the *scheduled* time, not `now` — so it doesn't
drift under load. It resyncs to `now + interval` only when a tick is
already overdue (`g_nextTickUs <= now`), which is a bounded-catch-up
behavior, not a naive reset. This already matches the PD
`frametimeCalculate` accumulator pattern.
- [x] **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 snaps `previous_time` to the real wake time `t`
when the overshoot exceeds ~1ms (`t - next < 10000` in 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
reading `gfx_sdl2.cpp:403-428` directly.)
- [ ] **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.
- [x] Config: `Video.VSync` / `Video.FpsCap` already 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 = true` reads `GL_BACK`, confirmed
`gfx_opengl.cpp:1309-1311`) into an arbitrary allocated FBO/texture, and
can blit back the other way (`fb_dst == 0` writes straight into the real
default framebuffer, `gfx_opengl.cpp:1214`'s `fb_id == 0 -> bind 0`
convention).
- `gfx_pc.cpp` already keeps a couple of always-allocated internal
framebuffers this same way (`game_framebuffer`,
`game_framebuffer_msaa_resolved`, created at `gfx_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 calls `gfx_run`/`swap_buffers_begin` — so
spending extra wall-clock time *inside* `swap_buffers_begin` presenting 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, near `gfx_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 PD `VIDEO_MAX_FPS`).
- [ ] Input at present rate: sample SDL input continuously in
`port/src/input.c`; each sim tick consumes the latest state (compare
against PD `joy.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.c` uses real-time `osGetTime()` 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_DETERM` regression: 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` + `/linkcheck` ritual; 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)
1. **True decoupled sim/render (PD's zero-tick render frames).** Minimal
change class: relax `waitForNextFrame`'s ≥1-tick enforcement so rendered
frames can carry `deltaFrames = 0` (GE already guards `g_ClockTimer == 0`
everywhere). 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.
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.