Skip to content

The display link stays at 60 Hz at a 30 fps target in fullscreen on a fixed-rate screen - #1555

Closed
PucaVaz wants to merge 1 commit into
seedds:mainfrom
PucaVaz:fullscreen-30-pacing
Closed

PucaVaz wants to merge 1 commit into
seedds:mainfrom
PucaVaz:fullscreen-30-pacing

Conversation

@PucaVaz

@PucaVaz PucaVaz commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

Fixes #1554.

At a 30 fps target, link_sync runs the display link at 30 Hz. In fullscreen on a fixed 60 Hz screen, that link skipped ticks (16.5-25.4 callbacks a second over stretches of 300 frames), and each skipped tick kept a frame on screen 66.7 ms. In a window the same link called back 30.0 times a second. Details and logs are in the issue.

Change

  • dm/gfx/nvn/nvn_mtl.m: link_sync takes the 30 Hz link only when link_30hz() allows it. That is everywhere except fullscreen on a fixed-rate screen (front_fullscreen() and not front_screen_variable_refresh(), the classification link_latency already uses). There the link stays at 60 Hz and the present interval paces the 30 fps, as the 60 Hz link already does for interval-2 frames: the acquire waits two ticks (mtl_link_wait_tick) and the callback keeps each frame on screen for its period (g_link_hold_until). A frame that misses its refresh then shows 16.7 ms later instead of 33.3.
  • Unchanged: windows, variable-refresh fullscreen, iOS (no front_fullscreen), and 60 fps targets. Entering or leaving fullscreen switches the mode at run time through link_sync, as it already does for the frame latency.
  • dm/gfx/tests/t_present_rescue.m: a new thirty() checks the link's mode and range at a 30 fps target in a window, in fixed-rate fullscreen and in variable-refresh fullscreen, and back again. The test reads the target through a renamed nvn_game_fps hook in t_present_rescue_focus.c, because hle_nvn.c, which defines the real one, is linked into the test.
  • docs/reference/frame-rate.md: a new entry, "The 30 Hz display link in fullscreen on a fixed-rate screen", with the symptom index regenerated and a line in Ocarina of Time's note.

Testing

Unit tests:

  • make -C dm/gfx/tests GAME=0400486024DEC000 check_t_present_rescue: 86 checks, 0 failed. With the old link_sync, the two fixed-rate fullscreen checks fail.
  • Graphics suite (make -C dm/gfx/tests GAME=0400486024DEC000 run, M4): 107 of the 109 tests built and ran, plus test_dmsh_pull and test_tbdr_budget. All passed except t_fuse_check: 2 of its 10 checks fail ("the later pass's logical target is kept afterwards"), the same on unmodified main. t_nvn and t_gfx_suspend do not link on main either (dm_vi_frame_queued undefined, since 51cc8bc), with or without this change.
  • python3 -m unittest dm/tools/tests/test_docs.py: OK, after dm/tools/docs_symptoms.py.
  • dm/tools/lint_c.py tidy (LLVM 22.1.1 here, against a baseline made with 23.1.4): no findings in the test files. nvn_mtl.m has three file/check pairs above the baseline, none near the change, and main's nvn_mtl.m has the same three with this LLVM.
  • scripts/regress.py impact: Breath of the Wild, Tears of the Kingdom and Pokémon Scarlet (through nvnQueuePresentTexture).

In game: MacBook Air M4, 24 GB, macOS 27.2, built-in 60 Hz screen. The Legend of Zelda: Ocarina of Time at DM_FPS_TARGET=30. Main does not apply that cap to this game, so the change was applied to the tree of the issue (main 75c1a8c + #1453, #1455, #1332, #1446, LeonardoBai12's frame-delta change and #1527). The patched code is identical to this PR's; only the comment differs. The same app bundle was used with only the host swapped. Handheld, MetalFX. The route is scripted: 90 s idle in Link's house, then 180 s of stick pushes outside (the walk). One run per row, alternating before and after.

run house fps house frame p50 / p95 / p99 house > 50 ms walk fps walk frame p50 / p95 / p99 walk > 50 ms link callbacks/s over the walk
fullscreen, before 29.1 33.3 / 34.5 / 67.2 ms 77 27.7 33.4 / 66.5 / 69.1 ms 388 24.0-30.0 (30 Hz link)
fullscreen, after 30.0 33.3 / 34.3 / 34.8 ms 0 29.7 33.4 / 36.2 / 38.4 ms 7 57.2-60.1 (60 Hz link)
window, before 30.0 33.3 / 34.3 / 34.6 ms 0 29.7 33.4 / 36.1 / 38.4 ms 4 29.7-30.1 (30 Hz link)
window, after 30.0 33.3 / 34.3 / 34.6 ms 0 29.7 33.4 / 35.8 / 38.4 ms 3 29.9-30.0 (30 Hz link)

An earlier fullscreen run before the change had 24.2 fps and 996 frames over 50 ms on the same walk.

  • Present intervals on the fullscreen walk, in 60 Hz vsyncs: before 2: 4,647, 3: 46, 4: 369; after 2: 5,424, 3: 7, 4: 3.
  • The log after the change shows present pacing mode 0 → 1 with display link: frame latency 1 (fullscreen, fixed refresh), and each frame held one extra tick (held for the present interval rises by 300 per 300 presents). In the window it is still 0 → 3.
  • The median time from the game's present to the target display time on the walk went from 91 ms to 76 ms in fullscreen (109 ms in the window).
  • Game speed (the frame-delta change's step): 0.998-1.0 game seconds per wall second in every run. No GPU errors, and the same four [RED] kinds in every run.

Not run: scripts/regress.py check, because I have none of the games impact names. Also not tested: variable-refresh (ProMotion) fullscreen, which this change leaves as it was; an external fixed-rate screen; another game at a 30 fps target on main; and the power cost of 60 callbacks a second instead of 30 in fixed-rate fullscreen.

… fixed-rate screen, where the 30 Hz link skipped ticks and kept frames on screen 66.7 ms: Ocarina of Time's 180 s walk at a 30 fps cap on a MacBook Air M4 had 388-996 frames over 50 ms in fullscreen, 7 with the 60 Hz link and 3-4 in a window
@PucaVaz

PucaVaz commented Nov 7, 2026

Copy link
Copy Markdown
Contributor Author

The fullscreen numbers in the description were measured on another tree; this is the confirmation on main. Setup:

The window went fullscreen:

  • front end: drawable 3840x2410;
  • display link: frame latency 1 (fullscreen, fixed refresh);
  • present pacing mode 0 → 1.

Results:

  • The display link called back 59.4–60.1 times a second over every 300 presents of the walk.
  • Walk: 29.9 fps in both runs, frame p50 / p95 / p99 33.2 / 34.3–34.4 / 35.5 ms, 6 and 7 frames over 50 ms (the walk includes two screenshots), and 2 present intervals of 4 vsyncs (66.7 ms) in each run.
  • House: 30.0 fps, 0 frames over 50 ms.
  • Before this change the same walk had 388 (and, in an earlier run, 996) frames over 50 ms and 369 intervals of 4 vsyncs. With it on the earlier tree: 7 and 3.
  • Game present to target display: p50 91.6–93.7 ms on the walk, against 110 ms in a window at the same cap. The 76 ms in the description was measured on the other tree with MetalFX.

seedds added a commit that referenced this pull request Nov 7, 2026
…ink stays at 60 Hz and the present interval paces the 30: the 30 Hz link skipped ticks there and kept frames on screen 66.7 ms (Ocarina of Time on a MacBook Air M4: 388 and 996 frames over 50 ms on a 180 s walk, 7 after; PR #1555)

Fixes #1554

Co-authored-by: Puca <65235669+PucaVaz@users.noreply.github.com>
@seedds

seedds commented Nov 7, 2026

Copy link
Copy Markdown
Owner

Landed unchanged as 490859e (with you as co-author; only the generated symptom index was regenerated against current main). check_t_present_rescue 86/0 and test_docs pass here. Thank you for the measurements on both trees. The cause of the skipped 30 Hz ticks stays open in docs/reference/frame-rate.md.

@seedds seedds closed this Nov 7, 2026
gnosticgeek pushed a commit to gnosticgeek/nxforge that referenced this pull request Nov 7, 2026
…cing (seedds#1555), device-shared memory pool storage, short NDSR records in the shader split (seedds#1298, took upstream's form of our own fix)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016qteK3qWJVi2jG3Gz4i1eY
gnosticgeek pushed a commit to gnosticgeek/nxforge that referenced this pull request Nov 7, 2026
…h the link in fullscreen; dm_device_share grows its table through a temporary

t_present_rescue (upstream seedds#1555's thirty()) expects the display link to run
in fullscreen; NXForge leaves it there unless DM_PRESENT_LATENCY is set
(latency() checks that), so four checks failed. thirty() now sets the
latency, which is where upstream's fullscreen 30 fps pacing applies on
NXForge. hle_svc.c dm_device_share assigned realloc's result straight back
(bugprone-suspicious-realloc-usage, lint-c ratchet); it dies on failure all
the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016qteK6QbhFrzr2Vop9t34k
john62036203-ops pushed a commit to john62036203-ops/NXNativeWindows that referenced this pull request Nov 7, 2026
…ink stays at 60 Hz and the present interval paces the 30: the 30 Hz link skipped ticks there and kept frames on screen 66.7 ms (Ocarina of Time on a MacBook Air M4: 388 and 996 frames over 50 ms on a 180 s walk, 7 after; PR seedds#1555)

Fixes seedds#1554

Co-authored-by: Puca <65235669+PucaVaz@users.noreply.github.com>
(cherry picked from commit 490859e)
Know-It-All-Brother pushed a commit to Know-It-All-Brother/nxnative that referenced this pull request Nov 9, 2026
…ink stays at 60 Hz and the present interval paces the 30: the 30 Hz link skipped ticks there and kept frames on screen 66.7 ms (Ocarina of Time on a MacBook Air M4: 388 and 996 frames over 50 ms on a 180 s walk, 7 after; PR seedds#1555)

Fixes seedds#1554

Co-authored-by: Puca <65235669+PucaVaz@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs Documentation game: 0400486024DEC000 A game 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.

Mac: at a 30 fps target in fullscreen on a fixed 60 Hz screen, the 30 Hz display link skips ticks and frames stay up 66.7 ms

2 participants