04 · Road to 60
The road to sixty
F-Zero X runs its logic at 60 Hz and the port never lowered that. On 10-26 both consoles race at 60: the New 3DS with headroom to spare, the Old 3DS at the edge in 3D. Most of this page is how the New 3DS got there first, from 08-14 to 09-03, on a renderer that is no longer the default. Every number is labelled with its date and build.
Where we are now · 10-26, v1.1.0
| Console · mode | Now (date, build) | Then |
|---|---|---|
| New 3DS · 1P race, 3D | 60 fps in every ~1 s window; render task 2.5–3.8 ms, ~13 ms of headroom (card 6, 10-26, ba0692e; widescreen on) | median 59.6, p10 51.4, 53% of beats at 60 (round 5, 09-03) |
| New 3DS · 1P race, 2D | no separate native-renderer figure on file (v1.0.0 notes: not yet measured); 3D, the heavier mode, holds 60 | median 57.2 with stereo off (round 2, 09-02) |
| New 3DS · menus, 3D | medians 56–61 fps (card 6; few windows, noisy) | ~20 fps in the emulator (08-21) |
| Old 3DS · 1P race, 2D | 60 fps: median frame 16.5 ms, 0 hitches (card 3, 10-02); full-field GP 57.3 fps mean with diagnostic logging (card 2, 10-02) | unsupported until 09-28; ~10 fps on first boot (09-29) |
| Old 3DS · 1P race, 3D | 59.7–59.8 fps with one submission per stereo chunk, baseline 56.4 / 59.3 (card 6, 10-26; two blocks per arm); a heavier 16-machine race 53.7 fps (10-24) | 59–60 in race windows on courses 12–16 (card 4, 10-08) |
| Old 3DS · menus, 3D | 44.6 fps, from 40.2 (card 6, 10-26) | interpreter menus with frame skipping (v1.0.0) |
| Old 3DS · multiplayer | 2D: 60 fps race windows, 2 players + 14 CPU (10-25, 3c0c7b7). 3D: locked 30 by design (10-24) | multiplayer is new in v1.1.0 |
| New 3DS · multiplayer | no separate figure on file | — |
Since v1.0.0 (10-19) both consoles run the native renderer by default ([perf] preset = auto: n60 on New 3DS, o3ds-auto on Old 3DS), with the render thread off. Sources: hwlogs/n3ds-card6, o3ds-card6, o3ds-card3, o3ds-0c2; RELEASE-NOTES.md; MULTIPLAYER.md. The renderer and the Old 3DS road are on The road to 60, all over again.
What the Old 3DS work gave back to the New 3DS
- The default route. The native renderer, per-eye stereo replay and end-of-frame pacing are the New 3DS default since v1.0.0 (
n60, render thread off). The render-thread path below is still built in behind an explicit ini. - Two audio levers (
audio_ctx_narrow,audio_simd) are on in the New 3DS preset too, though it never lacked audio headroom. - Fixes for both models: a backlight/HOME freeze, a save that could be lost on power loss (an SD rename quirk), and a log that never blocks the frame.
Where we were. Everything below is the New 3DS campaign of 08-14 → 09-03 (v0.9.0), on the libultraship-derived interpreter with a render thread on core 2. Notes marked Later say what changed afterwards.
Where the frame went: the CPU-bound verdict
New 3DS · 08-20 measurement pass (emulator), confirmed on hardware 09-01
The measurement pass of 08-20 settled the strategy. GPU drawing time was flat at 0.4 ms per frame and the loop never once waited on the PICA; CPU build time was 26 to 58 ms. Fill rate, model decimation and texture compression were struck from the plan as irrelevant to frame time (the whole race used 8 KB of texture bytes and 957 vertices per frame). The bottleneck was the software display-list interpreter and the bridge that feeds it. Every lever from then on attacked CPU time, and every profile was reported as a set of named buckets:
| Bucket | What it is |
|---|---|
br | the bridge pre-pass: converting the game's raw display list into the runtime's command form, resolving addresses |
dsp | interpreter dispatch: walking the converted list and executing every command |
vtx | vertex transform: projecting N64 vertices to clip space on the CPU |
tri | per-triangle work: tile extents, shader lookup, clip parameters, packed VBO emission |
imp | texture import: looking up or uploading textures for a draw |
drw | issuing the draw to citro3d, including the stereo target switch |
The emulator era: 08-14 → 08-21
Before the console arrived every number was an Azahar proxy, known to be pessimistic on menus and unrepresentative in absolute terms. The shape of the work still held.
| Date | Lever | Measured (emulator) |
|---|---|---|
| 08-14 | First race | 12–20 fps race, 15–20 fps menus, debug logging on |
| 08-20 | Frame pacer double-throttle fix, HLE audio producer to core 2, malloc histogram | measurement pass: CPU 26–58 ms vs GPU 0.4 ms |
| 08-21 | Loadblock discovery. Per-opcode profiler named G_LOADBLOCK at 22.34 ms per frame: each 4 KB load did 512 shared_ptr copies (a thousand atomic RMWs), a 4 KB memcpy and a live getenv | F3 22.34 → 2.21 ms; dispatch 27.6 → 8.9 ms; menu wall 35–38 → 16.7–19.9 ms. User: "blazing fast" |
| 08-21 | Flat dispatch table replacing libultraship's layered handler tables | neutral on time, enabled per-opcode profiling |
| 08-21 night | S7 interpreter memo (raw dispatch, geo-diag gate, tri-state memo), async frame-mirror copy, vblank-stall skip, upload thrash 155 → 0.5 per frame, resolutions 93 → 0 | menu 15 → 19.9 fps; race build 26–58 → 24–46 ms; title wall 34 → 21–27 ms |
| 08-21 | Texrect state fast path (batch same-state runs) | E4 texrect bucket 6–9.7 ms attacked |
Hardware, before the campaign: 08-21 → 08-28
| Date | Build | Hardware result (user + log) |
|---|---|---|
| 08-21 | First .cia and .3dsx, rspolish | 35–55 fps; median 48–50, p95 58, max 59 over 8 races; crowd dips 25–35. "wow it runs amazing" |
| 08-27 | Fleet drop: traffic grind (bridge region cache, binary-search asset lookup, pipesync no-op), sky wedge, CCMUX 11, stereo armed, shadow | "performance is much better! 3D works great, doesnt seem to affect performance much at all"; one hard crash (filelog OOM, fixed same day) |
| 08-28 | Touch menu, full-bleed display, rival detail option, triloop packed VBO, leak fix (upstream LUS bug) | median 51, p95 60, max 60; MINIMAL rival detail = +8–9 fps in crowds, floor ~40 (was 25–35) |
| 08-28 | GPU vertex transform moonshot | imperceptible on hardware; venue floor loses texture mapping (24-bit uniform precision). Shelved. |
The LOCKED-60 campaign: 09-01 → 09-03
New 3DS hardware · v0.9.0-era libultraship interpreter build · rounds 1–5
Five hardware rounds in three days, each a staged branch with a test plan, each measured from the console's own once-per-second fps beats in the log. Stereo was accidentally off in rounds 1 and 2 and on from round 3, which makes the later gains larger than they look.
| Round | What it carried | Conditions | Beats | Median | p10 | At cap |
|---|---|---|---|---|---|---|
| Round 1 | Mainline before the campaign | stereo off · trace off | 754 beats · 63 min | 51.3 | 39.2 | 9% |
| Round 2 | Bridge memos (brfast) | stereo off · trace off | 78 beats · 6.5 min | 57.2 | 46.6 | 29% |
| Round 3 | HUD atlas · triangle memo · TMEM · audio | stereo on · trace off | 237 beats · 20 min | 56.0 | 48.0 | 32% |
| Round 4 | Render thread on core 2 (pipe) | stereo on · trace off | 58 beats · 5 min | 57.6 | 44.6 | 14% |
| Round 5 | Ahead mode · bridge on main · auto rival detail | stereo on · trace off · full GP | 229 beats · 19 min | 59.6 | 51.4 | 53% |
Where the milliseconds came from
New 3DS hardware · 09-02 → 09-03 · trace on
Hardware profiles with the trace on (which itself inflates the numbers by a few milliseconds) on the same course, crowd windows (30 machines in view) and steady windows. Buckets in milliseconds per frame.
| Profile | Window | br | dsp | vtx | tri | imp | drw | Total |
|---|---|---|---|---|---|---|---|---|
| Campaign levers off control, same course | crowd | 2.96 | 8.26 | 1.12 | 5.58 | 0.78 | 1.48 | 20.2 |
| Campaign levers off control, same course | steady | 2.2 | 7.63 | 0.66 | 3.98 | 0.76 | 1.18 | 16.4 |
| Campaign levers on atlas · tri memo · TMEM · brfast | crowd | 3.07 | 7.84 | 1.18 | 4.64 | 0.78 | 1.24 | 18.8 |
| Campaign levers on atlas · tri memo · TMEM · brfast | steady | 2.17 | 7.35 | 0.65 | 3.22 | 0.72 | 0.79 | 14.9 |
| Render thread (pipe) core 2 renders, main waits | crowd | 3.12 | 4.89 | 1.01 | 4.21 | 0.68 | 1.16 | 15.1 |
| Render thread (pipe) core 2 renders, main waits | steady | 2.72 | 3.87 | 0.7 | 3.12 | 0.66 | 0.95 | 12.0 |
Every lever, and its verdict
Verdicts as of 09-03 (v0.9.0); later changes noted in the rows
| Lever | Idea | Measured | Verdict |
|---|---|---|---|
| Loadblock span store | One record per 4 KB load instead of 512 shared_ptr copies; same-content skip; getenv latch | F3 22.3 → 2.2 ms, dispatch 27.6 → 8.9 ms (emu) | mainline |
| Flat dispatch + [prof]/[profop] | Plain table dispatch; per-bucket and per-opcode timers | neutral; enabled everything after | mainline |
| S7 interpreter memos | Raw-pointer dispatch, gated geo diagnostics, tri-state memo | build 34 → 23.5 ms (emu); one 2× regression bisected out (dlcache) | mainline |
| Async frame mirror, vblank-stall skip | Menus stop waiting on a copy and on a late vblank | menu 15 → 19.9 fps; wVbl 10–16 ms → 0 (emu) | mainline |
| Texrect fast path | Batch same-state rectangle runs | E4 bucket attacked; superseded by the HUD atlas | mainline |
| Traffic grind | Bridge region cache (no svcQueryMemory per probe), binary-search asset lookup, pipesync no-op | br 19.7 → 10.4 ms, wall 55.7 → 46.7 ms (emu) | mainline |
| Prim/env value-change flush | Correctness first: flush on colour change | fixes machine tint; small cost | mainline |
| Rival detail (NATIVE/REDUCED/MINIMAL) | Bias the game's own LOD tiers for non-player machines beyond the 5 nearest | +8–9 fps in crowds on hardwareLater (10-19): the New 3DS preset now runs full rival detail; automatic scaling is an Old 3DS lever. — RELEASE-NOTES.md v1.0.0 | mainline |
| Triloop packed VBO | Single-write PICA-layout emission from the interpreter | kills the double vertex copy | mainline |
| Leak fix (uninitialised ColorCombinerKey::shader_id) | Upstream LUS bug: combiner map missed per draw | heap flat; hidden per-draw flush tax gone | mainline |
| Double-height stereo target | One 400×480 target, per-eye viewport, fewer PICA flushes | user observed 2D ≈ 3D on hardware; citro3d source says flush cost is GPU-side and the GPU idlesLater (10-26): both eyes did end up in one GPU submission, by another route (a CMDBUF_JUMP per stereo chunk): 3D menus +11% on Old 3DS, neutral on New 3DS; v1.1.0 default. — o3ds-card6-analysis.md | shelved |
| GPU vertex transform (moonshot) | Unlit vertex transform on the PICA vertex shader | works (220 k draws, zero fallback, −62% vtx emu); imperceptible on HW; 24-bit uniform precision breaks ±32000-unit floorsLater (09-29 → 10-19): a machines-only gpuxf = 1 ships on by default on both consoles; the floor stays on the CPU. — o3dsgpuxf-progress.md; gdx3ds_preset_n60.c | shelved |
| Bridge output cache | Cache converted display lists across frames, epoch-validated | census: 87% of walked commands are host-built per frame; ceiling ~1.5 msHeld (10-02): the NO-GO stands; the native fork removed the bridge from the race path instead. — native.html | NO-GO |
| brfast (bridge memos) | Per-list facts memo, address-resolve memo, placeholder/raw-copy/range-class memos | br 11.5 → 4.9 ms (emu); HW br 6.43 → 3.73 in crowds; median 56.0 → 57.2, p10 42.0 → 47.2 | mainline |
| HUD texrect atlas (trectbatch) | Pack HUD rectangles into atlas pages; batches stay open across views | crowd draws 136 → 26 (E4 −2.6 ms emu) | mainline |
| trifast | Fast packed triangle loop; found the S7 memo had never actually hit (flag bug) | tri −15..−19% (emu); HW tri 5.58 → 4.64 ms crowd | mainline |
| tmemfast | TMEM load bookkeeping | F3 −22..−35% (emu) | mainline |
| audioprime / audioprime2 | Boot audio underrun: title font sample load blocked on a 10.7 MB inflate | store audio_table uncompressed in the o2r: preload 4.3 → 1.97 s | mainline |
| dspfast (batch-break fold) | Fold prim/env changes to avoid batch splits | census: texture switches split anyway; cannot pay | default off |
| Machine texture atlas (atlas3d) | Atlas machine-part textures to cut switches | only ~21 imports per frame split a batch; 0.1–0.5 ms best case; two thirds of machine textures wrap or mirror | default off |
| Render thread, core 2 (mode 1, pipe) | Interpreter and draws on core 2, forked at osSpTaskStartGo | HW: stable, HOME clean; median 49 → 56, p10 42 → 48 (trace on); not yet overlapping (waitMain 10–15 ms) | mainline |
| Render thread mode 2 (ahead) | DP-done acknowledged when the game parks; 2-deep backpressure; true one-frame pipeline | HW round 5: median 59.6, p10 51.4, 53% at capLater (10-19 → 10-26): off by default since v1.0.0 (renderthread = 0): the native race path fits on one core, and New 3DS 3D races held 60 in every window on card 6 without it. — gdx3ds_preset_n60.c; o3ds-card6-analysis.md §4 | mainline |
| Bridge on main (bridgemain) | Bridge pre-pass runs on core 0 while core 2 renders | render-thread br 4 → 0 ms; brMain 3.6 ms; balanced cores | mainline |
| Auto rival detail (dynlod) | Raise the LOD tier when render > 15 ms, lower when < 12 ms; user setting is the floor | shipped in round 5; thresholds untuned on HWLater (10-19): kept in the Old 3DS o3ds-auto preset; not used by the New 3DS preset. — RELEASE-NOTES.md v1.0.0 | mainline |
| Render-thread-owned texture cache | Fixes the round-5 data abort: clears become requests drained on the render thread | receipt texcacheMainMut=0; awaiting HWLater (10-19): the render thread is off in the v1.0.0 presets, so this fix is off the default path. — gdx3ds_preset_n60.c | mainline |
Where this road ended
The interpreter path topped out here: a median a hair under 60 on the New 3DS, with the crowd start still the floor. For the Old 3DS it was a dead end, about 10 fps on first boot, far short of 60. That is what led to the native fork: The road to 60, all over again.