mirror of
https://github.com/jkdansereau/goldeneye-pc-port.git
synced 2026-10-23 15:52:42 +02:00
Findings-index regen (tools_pc/gen_findings_index.py) — only D194's row had actually drifted (fix direction implemented since, on PR #62, not "not implemented" as the cell said). GRAPHICS-BACKLOG.md's D233/D234 rows were also stale ("not investigated") despite both being fixed and verified since M-107/M-108 per findings.md. New findings from user QA (M-119): - D249: LOD/culling breaks on far geometry on big open levels (Streets, Egyptian) at default FOV; distinct from D222/D218. Also a QoL ask for a togglable LOD improvement, N64-accurate LOD kept selectable. - D250: FPS now drops to 20-40 intermittently on many levels, a likely direct consequence of D248 (M-118) exposing real per-frame cost that was invisible while the port silently ran at half rate. Claude-Session: https://claude.ai/code/session_016DQUKGLZxFtPy52MkKXXog
193 lines
10 KiB
Markdown
193 lines
10 KiB
Markdown
# 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)
|
||
|
||
Goal: know exactly what the default preset does before touching anything.
|
||
|
||
- [ ] Measure actual sim rate and present rate of the current default build
|
||
(speedgraph + a `video.c` counter; add a periodic log line
|
||
`sim=..Hz render=..Hz`). Confirm NTSC vs PAL build.
|
||
- [ ] Find why the default is ~30 rather than the console's 60 Hz. Candidates:
|
||
- tick-thread 4 ms chunked-sleep drift under host load;
|
||
- `__scTaskReady` double-buffer check (`sched.c`) stalling a task every
|
||
other retrace;
|
||
- `__scHandleRetrace`'s client flag read `*((s32*)client + 2)` is
|
||
**out-of-bounds of `OSScClient`** (struct is only `next`+`msgQ`; the
|
||
decomp even says "im wrong size"). For `gfxClient[0]` it happens to
|
||
read `gfxClient[1].next` (zero) — but this is layout luck, worth a
|
||
documented finding either way.
|
||
- [ ] Record a Dxxx finding with the measured rates and root cause.
|
||
- [ ] **Decision gate:** if 30 fps turns out to be a port artifact rather than
|
||
original behavior, document it and decide with data whether the default
|
||
preset keeps the current pace (test continuity) or is fixed. Default
|
||
assumption: keep current behavior as the "original" preset either way;
|
||
a fix becomes its own item.
|
||
|
||
### Phase 1 — PD-style pacing hardening (port-only)
|
||
|
||
Makes the default preset stable and drift-free; no game-code changes.
|
||
|
||
- [ ] Replace the free-running tick grid in `portTickThread` with a
|
||
wall-clock accumulator + remainder carry (PD `frametimeCalculate` math):
|
||
post ticks when real time says they're due, carry the remainder, bounded
|
||
catch-up after hitches (D155 philosophy). No drift under load, no burst
|
||
of back-to-back ticks.
|
||
- [ ] `video.c`: proper frame limiter for fractional caps (spin+sleep hybrid),
|
||
triple-buffered swap chain, and the PD safety rule (vsync off + no cap →
|
||
force a max cap so "unlocked" can't spin).
|
||
- [ ] Config: keep `Video.VSync` / `Video.FpsCap`; add whatever Phase 2 needs.
|
||
|
||
### 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.
|