Skip to content

Headless, the layer's frame history shows a frame at the vsync a display would give it: Star Fox's cinematic throttle no longer sleeps out its own frame - #2339

Draft
elkir0 wants to merge 1 commit into
seedds:mainfrom
elkir0:vi-history-vsync
Draft

elkir0 wants to merge 1 commit into
seedds:mainfrom
elkir0:vi-history-vsync

Conversation

@elkir0

@elkir0 elkir0 commented Nov 11, 2026

Copy link
Copy Markdown

Headless, a frame the layer's frame history lists is now shown at the vsync a display would show it at, not at the moment the history first saw it handed on. Star Fox's prologue cinematic goes from 27.7 to 28.7 fps, and its steady frames are back at 33.2 ms.

The problem

Star Fox's prologue cinematic (present interval 2, 30 fps by the game's choice) ran at 27.7 fps headless on a base M4. Its frames took 34.3 ms and never waited at the acquire. Both the GPU (about 11 ms a frame) and the CPU were mostly idle. A host sample during the cinematic had the game's input stage, sim stage and output stage threads asleep in SleepThread 86–99% of the time. The 11 ms of SyncWait a frame belonged to its vgpu_timing_thread, not to the frame's critical path.

Cause

The engine has a frame throttle (vthrottle; settings Throttle Slop Target MS, Throttle Predictive Frame Timing Factor, …). It sleeps out each frame on the output stage for out.desired_frame_time plus a predictive delta:

  • the sleep is at main+0xb447210 and goes through the helper main+0xba3b0f0, which calls SleepThread once;
  • the delta comes from main+0xba3cfc0.

The delta steers the "slop": the time from a frame's GPU end to the flip that showed it.

  • The GPU end comes from timestamp reports read by vgpu_timing_thread. They are moved to the CPU clock by an offset it measures again every 32 frames with nvnDeviceGetCurrentTimestampInNanoseconds.
  • The flip comes from nn::vi::ListFrameHistory. The game reads it at the start of each frame (main+0xb75d06c), right after the throttle's sleep.

Headless, hle_vi.c gave a frame with no Metal presented time the time the history first saw it handed on, and that moment was this very read. The game counted its own sleep as slack before the flip. With no fixed vsync to push back, it held its delta at about +1.3 ms a frame.

The change

  • dm/gfx/hle_vi.c. A frame with no presented time is shown at the first tick of the present pacer's grid at or after both its present and the end of its GPU work. The grid is the pacer's target, which it keeps while a frame is late by less than a period; one tick is one vsync of the present tick rate. The frame is not listed as shown before that tick has come, nor while its GPU work is still running. A Metal presented time still wins. Under DM_DETCLOCK, which has no pacer grid, the first-seen time stays.
  • dm/gfx/nvn/hle_nvn.c. The history is told of each present after the pacer has run, with the present time, the pacer's target and one tick.
  • dm/gfx/nvn/nvn_mtl.m. mtl_gpu_done(frame) returns whether all of a frame's command buffers have completed, and their latest GPU end. The frame's GPU tag is one less than its history number.
  • Tests. t_vi_history covers the new cases, and the weak stub in t_host_stubs.c takes the new arguments.
  • Docs. A new entry, A frame throttle steered by the layer's flips; the Layer frame history entry's Fix; Star Fox's note.

Measurements

Headless, on a base M4 (10-core GPU, 16 GB). One build on f75ac06e, run by its own host and by this one. Prologue cinematic from 95 to 195 s:

f75ac06e this PR
fps 27.7 28.7
frame p50 34.3 ms 33.4 ms
frames under 60 ms, mean 34.23 ms (29.2 fps) 33.18 ms (30.1 fps)
frames ≥ 60 ms 24 (6.1 s) 23 (5.6 s)

The frames that remain over 60 ms are scene cuts. On an earlier build they were first texture uploads in the submission (140–400 MB in 41–132 ms) and frames of the game's own loading, and this change does not address them.

  • On 6a6a018d, before 084f5aa5 (768x432 docked): 28.4–28.9 → 29.1–30.0 fps per 15 s window.
  • The title stays at 60 fps. Its acquire wait went from about 15 to 1.6–2 ms, because the throttle now sleeps up to the vsync, as it is meant to.
  • The tutorial's ring course (345–475 s on the tutorial-flight route): 59.3 fps (p95 17.44 ms) with f75ac06e's host. With this change on 6a6a018d: 59.5–60.0 fps per 30 s window (p95 17.38 ms), against 59.4–59.9 fps (p95 18.0–19.1 ms) without it.

Checks

  • t_vi_history on two profiles. Its seven new cases:

    • an early present;
    • a late present whose GPU end comes later still;
    • GPU work still running;
    • no GPU record;
    • the GPU frame being the frame minus one;
    • a presented time winning;
    • the guest-clock conversion.

    Three injected bugs each fail it: the frame offset, the rounding to the next vsync, and the wait for the tick.

  • make -C dm/gfx/tests GAME=0400E88025B1A000 run: 165 test programs, 0 failed.

  • make -C dm/host/tests GAME=0400E88025B1A000 run: 50, 0 failed.

  • t_nvn (298 checks) and t_fstep (78), both 0 failed.

  • test_docs passes.

  • regress.py impact upstream/main..HEAD: every NVN game through nvnQueuePresentTexture. Breath of the Wild, Tears of the Kingdom and Pokémon Scarlet are named by their usage manifests.

Not checked

  • regress.py check has not been run yet: Star Fox is the only game on the Mac this was made on. It will run on a second Mac (Mario Kart World and others), and its report will be added here. Draft until then.
  • A window. Metal's presented times take the unchanged path. Windowed runs were not possible while this Mac's display was asleep.
  • The other games that read the history: Minecraft Dungeons II, Octopath Traveler 0 and II, and Luigi's Mansion 3. They now see a frame shown only once its GPU work has ended and its vsync has come.

🤖 Generated with Claude Code

…nc a display would show it at, not when the history first saw it handed on: Star Fox's frame throttle (vthrottle) sleeps out each frame for out.desired_frame_time plus a predictive delta (main+0xb447210, one SleepThread in main+0xba3b0f0) that steers the time from a frame's GPU end to its flip, and reads the flip from nn::vi::ListFrameHistory at the start of each frame (main+0xb75d06c), right after its own sleep. With no Metal presented time (headless, or before the handler runs) hle_vi.c gave such a frame the time the history first saw it, which was that read, so the game took its own sleep for slack before the flip and held its delta at about +1.3 ms: its prologue cinematic (present interval 2) ran at 34.3 ms a frame and never waited at the acquire, the GPU and the CPU mostly idle (a host sample had its input, sim and output stage threads in SleepThread 86-99% of the time). hle_vi.c now shows such a frame at the first tick of the present pacer's grid (its target, kept while a frame is late by less than a period; one tick a vsync of the present tick rate) at or after both its present and the end of its GPU work (mtl_gpu_done in nvn_mtl.m, on the frame's GPU tag, one less than its history number), and not before that tick or while that work runs; hle_nvn.c tells the history of each present after the pacer has run, with the present time, the pacer's target and the tick, and none under DM_DETCLOCK, where the first-seen time stays; a Metal presented time still wins. Measured headless on a base M4 (10-core GPU, 16 GB), one build on f75ac06 run by its host and by this one, the cinematic from 95 to 195 s: 27.7 -> 28.7 fps, frame p50 34.3 -> 33.4 ms, frames under 60 ms 34.23 -> 33.18 ms (29.2 -> 30.1 fps); the rest are scene cuts (24 and 23 frames of 60 ms or more), first texture uploads in the submission and the game's own loading. On 6a6a018 (768x432 docked) 28.4-28.9 -> 29.1-30.0 fps per 15 s window. The title stays at 60 fps; the ring course 59.3 fps with f75ac06's host, and with the change on 6a6a018 59.5-60.0 fps per 30 s window, p95 17.38 ms (59.4-59.9, p95 18.0-19.1 ms without). t_vi_history gets the virtual vsync's cases (an early present, a late one with a later GPU end, GPU work still running, no GPU record, the frame one less, a presented time winning, the guest clock; three injected bugs each fail it), t_host_stubs.c's weak stub takes the new arguments; the gfx suite (165 programs) and the host suite (50) pass for Star Fox, as do test_docs. regress.py impact: every NVN game through nvnQueuePresentTexture. Not run: regress.py check (only Star Fox on this Mac); a window; the other games that read the history (Minecraft Dungeons II, Octopath Traveler 0 and II, Luigi's Mansion 3), which now see a frame shown only once its GPU work has ended and its vsync has come

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions github-actions Bot added host The Horizon host: SVCs, services, threads, memory (dm/host) nvn NVN on Metal and its tests (dm/gfx/nvn, dm/gfx/tests) docs Documentation game: 0400E88025B1A000 A game profile labels Nov 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs Documentation game: 0400E88025B1A000 A game profile host The Horizon host: SVCs, services, threads, memory (dm/host) nvn NVN on Metal and its tests (dm/gfx/nvn, dm/gfx/tests)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant