From 6b6c99f49d0831b508b67a06089bfe562fdd1a1b Mon Sep 17 00:00:00 2001 From: The-Inceptions <83852285+The-Inceptions@users.noreply.github.com> Date: Mon, 9 Nov 2026 06:14:13 -0500 Subject: [PATCH 1/2] Fullscreen on a ProMotion screen holds 60 fps: at 60 fps in variable-refresh fullscreen the host runs the 120 Hz display link, a frame every other tick, where the 60 Hz link at frame latency 2 showed every present a refresh late and skipped every other tick (Ocarina of Time on an M5 Pro: 40 fps with the GPU at 10 ms of the 16.7) On a MacBook Pro's ProMotion screen, a 60 fps game in fullscreen ran at 40 fps, while the same route in a window held 60.0. The DM_NVN_TSV rows show the cause: - With the 60 Hz CAMetalDisplayLink at frame latency 2 (the variable-refresh fullscreen workaround of #347), the system showed every present one 120 Hz refresh after the link's target (@pres_at_ms - @link_target_ms = 8.33 ms on every frame). - The link then skipped its next tick after every second present (@link_gap_ms 16.7, 33.3, 16.7, 33.3 ms): 40 callbacks a second. - The host paces the game by those callbacks, so the game ran at 40 fps. - At latency 1, the misses of #347 came back and the frame step went to 4 vsyncs (15 fps). The fix, link_vrr_120 (nvn_mtl.m): at 60 fps in fullscreen on a variable-refresh screen whose rate is a multiple of 120 Hz, link_sync runs the existing 120 Hz link of DM_PRESENT_LINK=2, at frame latency 2 as before. - The acquire waits two ticks per vsync, and the vsync event follows every other tick. - A frame that misses its tick goes up on the next 120 Hz refresh instead of a whole 60 Hz period later. - A window, a fixed-rate screen, a screen of another rate, a 30 fps target and a set DM_PRESENT_LINK keep their link. - The link follows fullscreen in and out as the latency policy does, and the log says when it switches. Measured with scripts/bench.py on Ocarina of Time's kokiri-deck route: M5 Pro MacBook Pro, 20-core GPU, macOS 27.2, host= variants of main and this change, two runs each. - Fullscreen: 39.7 -> 59.2 fps, frame p99 33.5 -> 25.3 ms, frames over 1.5 vsyncs 914 -> 62. - Window: 59.9 / 59.95 fps, unchanged. - At latency 1, the 120 Hz link did a little worse (59.3 fps, 48 frames over 1.5 vsyncs against 35), so latency stays 2. Tested: - t_present_rescue --metal with the Metal debug layer: 100 checks. A new vrr120 group covers window and fullscreen; fixed-rate, 144 Hz, 240 Hz and unknown screens; DM_PRESENT_LINK; a 30 fps target; and leaving fullscreen. - make -C dm/gfx/tests GAME=0400486024DEC000 run: 148 result lines with 72,183 checks and 0 failed, plus 30 Python tests passing. Python 3.12 or newer must be first on PATH; the system Python 3.9 stops check_t_alpha on dmsh_side.py's `str | None`. - test_docs. Regression: Ocarina of Time only. It's the one game built on this Mac, and its profile has no smoke.tsv. iOS is unchanged, since front_fullscreen is 0 there. Co-Authored-By: Claude Opus 5.5 --- dm/games/0400486024DEC000/README.md | 2 ++ dm/gfx/nvn/nvn_mtl.m | 12 +++++++++++- dm/gfx/tests/t_present_rescue.m | 23 ++++++++++++++++++++++- dm/gfx/tests/t_present_rescue_focus.c | 2 ++ docs/agents/symptoms.md | 1 + docs/env_switches.tsv | 2 +- docs/reference/frame-rate.md | 10 ++++++++++ 7 files changed, 49 insertions(+), 3 deletions(-) diff --git a/dm/games/0400486024DEC000/README.md b/dm/games/0400486024DEC000/README.md index e6329de8ee..d7a7b7a9ee 100644 --- a/dm/games/0400486024DEC000/README.md +++ b/dm/games/0400486024DEC000/README.md @@ -31,6 +31,7 @@ One [NUP container](../../../docs/development.md#the-packing-script-in-detail) o Measured on an M6 Mac with 16 GB, headless, on one walk with camera turns (`walk.sh` in the work folder). +- Current on an M5 Pro MacBook Pro (16-inch, 20-core GPU, 24 GB, macOS 27.2, 2026-11-09), `kokiri-deck` from a deck autosave with the full library of chain `dedc9391c30b`, at full resolution (Dynamic resolution off), two runs a variant: in a window a locked 60 fps (59.8–60.0, frame p99 16.8–19.4 ms, GPU p50 10.2 ms). In fullscreen on its ProMotion screen it held 40 fps (39.6–40.1) at the same GPU time, with every present a 120 Hz refresh late and every other link tick skipped: [The 60 Hz link at 40 fps in fullscreen on a ProMotion screen](../../../docs/reference/frame-rate.md#the-60-hz-link-at-40-fps-in-fullscreen-on-a-promotion-screen). With the 120 Hz link there, 58.8–59.6 fps, frame p99 25.3 ms. MetalFX present upscaling to the 3456-wide fullscreen cost no measurable GPU time (59.4–59.7 fps). Resident about 10 GB, GPU allocated 11.2–11.4 GB; the prepare step took 7.3 s on a first start. - Current: the title and menu, a movie behind them, are a locked 60 fps (p50 16.67 ms, GPU span 15.6 ms). - Game speed: the game steps its time by a frame ratio, its base rate (60, or 30) / its int frame rate, whatever the frame took (`main+0x6e6cc4` on v131072), so frames longer than a vsync played in slow motion (seen by the user on a slower Mac; [issue 1375](https://github.com/seedds/nxnative/issues/1375)'s M1 Max ran the title at about 28 fps). With `fps_target 60` the ratio is the host's frame step × base / 60 ([A fixed frame step plays in slow motion on a slow Mac](../../../docs/reference/frame-rate.md#a-fixed-frame-step-plays-in-slow-motion-on-a-slow-mac)), a stub placed by its function's fingerprint ([`patches.py`](patches.py)). Mac runs on the M6 (2026-11-08, a test host on the libraries of chain `3ee493b2c3a4`): the user's play sessions held 60 fps with 2- and 3-vsync holds while areas loaded, game time 1.000 of real time by the host's count. That count is the stub's own; no run on a Mac slow enough to show the slow motion before and after. - Current: walking the village at the game's top internal resolution, 1536x864: a locked 60 fps, mean 16.67 ms over each settled window. Per 2,700 frames: 0-8 frames over 25 ms and 0-2 over 33 ms. Frame p95 17.8-18.9 ms with no window, as the game's own loop moves around its fence wait; in a window p95 17.8 ms, 3 over 25 ms and 5-16 over 18 ms per 600. GPU span p50 16.5 ms, summed GPU 15.8 ms, CPU 6 ms, about 101 passes a frame. The heavy stretch where the village opens (about 1,560 draws) holds 16.65-16.8 ms by lowering its resolution, then returns to full. The main pass used to sit at 763x429 at the same GPU time: [marked shaders tested depth late](../../../docs/reference/frame-rate.md#a-hosts-own-store-in-a-fragment-shader-moves-its-depth-test). @@ -108,6 +109,7 @@ From an empty save: at about 45 s `A` (title), `A` (New Game), then the movie. F ## Found on this game +- [The 60 Hz link at 40 fps in fullscreen on a ProMotion screen](../../../docs/reference/frame-rate.md#the-60-hz-link-at-40-fps-in-fullscreen-on-a-promotion-screen): 40 fps in fullscreen on an M5 Pro MacBook Pro with the GPU at 10 ms of the 16.7, every present one 120 Hz refresh late and every other display-link tick skipped; the 120 Hz link holds 59 fps there. - [The shader package and the shader map come from different splits](../../../docs/reference/packing.md#the-shader-package-and-the-shader-map-come-from-different-splits): resolved, a black screen with sound in a build through NX2Apple ([issue #1841](https://github.com/seedds/nxnative/issues/1841)); the present pass was translated from a terrain shader's record. Records now carry their keys, and the shader build finds them by key. - [Native mesh pipelines compiled at their first draw](../../../docs/reference/shaders.md#native-mesh-pipelines-compiled-at-their-first-draw): resolved. The foliage, shadows and enemies drawn with task and mesh shaders stalled at their first draw on a cold Metal cache (59.5 s of builds at the Kokiri Forest entry on an M5 Max, one frame of 42.9 s), as nothing recorded or kept their pipelines; `pso_manifest.tsv` now carries 123 `M` rows (Kokiri Forest, Hyrule Field, Castle Town, Hyrule Castle), and with the prepare step they are built before the first frame. Pipelines outside the list still stall once, then go into `pso_user.tsv`. - [One Pro Controller read as two players](../../../docs/reference/services.md#one-pro-controller-read-as-two-players): resolved, no music in Kokiri Forest gameplay and its cutscenes; the controller applet opened over and over and the game kept its music suspended while it had focus. diff --git a/dm/gfx/nvn/nvn_mtl.m b/dm/gfx/nvn/nvn_mtl.m index 5f669e0996..311c8a3f43 100644 --- a/dm/gfx/nvn/nvn_mtl.m +++ b/dm/gfx/nvn/nvn_mtl.m @@ -128,6 +128,7 @@ static inline uint64_t tex_slot_h(const uint64_t *tex, const uint64_t *img, unsi static int g_pq_inflight; // Under g_pq_mu: number of presents committed to the GPU whose completion callback has not arrived yet int g_dm_present_link; // Present pacing mode (runtime value of DM_PRESENT_LINK, modified directly during same-process alternating measurement): 0 = acquire the drawable itself; 1 = display link at 60 Hz; 2 = display link at 120 Hz, the game presents a frame every other display-link tick static int g_present_latency; // DM_PRESENT_LATENCY: the display link's frame latency in a window and in fullscreen alike (0 = not set: link_latency picks it) +static int g_present_link_env; // DM_PRESENT_LINK was set: its mode holds everywhere, and link_vrr_120 does not move a 60 Hz link to 120 Hz in variable-refresh fullscreen static uint64_t g_pq_done; static uint64_t g_pq_read; static id g_res; @@ -602,7 +603,7 @@ void mtl_init(void) { if (g_dev) return; const char *vbc = getenv("DM_NVN_VBUF_CACHE"); g_vbuf_cache = !(vbc && !strcmp(vbc, "0")); const char *link = getenv("DM_PRESENT_LINK"); - g_dm_present_link = link ? atoi(link) : 1; if (g_dm_present_link < 0 || g_dm_present_link > 2) g_dm_present_link = 1; // Default 1 (real-device same-process alternation: when load exceeds one display-link period, long frames 15% → 0 and frame intervals even out; no difference from 0 at a steady 60); DM_PRESENT_LINK=0 turns it off // 1 = present on every tick of a 60 Hz display link; 2 = 120 Hz display link, the game presents a frame every other display-link tick (when behind, it can present on the next 8.33 ms display-link tick) + g_dm_present_link = link ? atoi(link) : 1; if (g_dm_present_link < 0 || g_dm_present_link > 2) g_dm_present_link = 1; g_present_link_env = link != NULL; // Default 1 (real-device same-process alternation: when load exceeds one display-link period, long frames 15% → 0 and frame intervals even out; no difference from 0 at a steady 60); DM_PRESENT_LINK=0 turns it off // 1 = present on every tick of a 60 Hz display link; 2 = 120 Hz display link, the game presents a frame every other display-link tick (when behind, it can present on the next 8.33 ms display-link tick) const char *latency = getenv("DM_PRESENT_LATENCY"); if (latency && atoi(latency) > 0) g_present_latency = atoi(latency); g_res_check_on = getenv("DM_RES_CHECK") != NULL; @@ -15471,6 +15472,12 @@ static CAFrameRateRange link_range(int mode) { // frames over 50 ms; 3-4 in a window, where the same link held 30.0). Why is open (docs/reference/frame-rate.md). The 60 Hz link paces the 30 through the present interval, as it // does interval-2 frames (two ticks per acquire, mtl_link_wait_tick; each frame held for its period, g_link_hold_until): 7 frames over 50 ms on the same walk static int link_30hz(void) { return !front_fullscreen() || front_screen_variable_refresh(); } +// The 120 Hz link (mode 2, a frame every other tick) in place of the 60 Hz one in fullscreen on a variable-refresh screen whose rate is a multiple of 120 (a ProMotion +// screen), unless DM_PRESENT_LINK chose the mode. There, with the 60 Hz link at frame latency 2, the system showed every present one 120 Hz refresh after the link's +// target time (@pres_at_ms − @link_target_ms = 8.33 ms) and the link then skipped every other tick (@link_gap_ms 16.7, 33.3, 16.7, …): 40 callbacks a second, so a game +// paced by the link ran at 40 fps with its GPU at 10 ms of the 16.7 (Ocarina of Time on an M5 Pro, macOS 27.2; at latency 1 the frame step went to 4 vsyncs, 15 fps). +// The 120 Hz link held 60 fps there with presents on their targets; a window keeps the 60 Hz link, where every present lands on its target (docs/reference/frame-rate.md) +static int link_vrr_120(void) { int hz = front_screen_max_fps(); return !g_present_link_env && front_fullscreen() && front_screen_variable_refresh() && hz >= 120 && hz % 120 == 0; } static int g_link_mode = 1; // Current mode of the display link object: 1 / 2 same as g_dm_present_link, 3 = power-saving mode 30 Hz static void *link_main(void *arg) { // Display link thread: started when switching to display-link mode, exits when switching back to 0 (void)arg; @@ -15501,6 +15508,9 @@ static void link_sync(void) { // Called every frame by the game's present thre if (g_rescue_mode) want = 0; pthread_mutex_unlock(&g_pq_mu); if (want && mtl_fps_target() == 30 && link_30hz()) want = 3; // Power-saving mode: display link at 30 Hz + static int vrr_was; int vrr = want == 1 && link_vrr_120(); // variable-refresh fullscreen: the 120 Hz link paces the 60 (link_vrr_120) + if (vrr != vrr_was) { vrr_was = vrr; MLOG("display link: %s (frame %llu)", vrr ? "120 Hz in fullscreen on a variable-refresh screen, a frame every other tick (DM_PRESENT_LINK=1 keeps 60 Hz)" : "60 Hz again (window, fixed-rate screen or another frame rate)", (unsigned long long)atomic_load(&g_cur_frame)); } + if (vrr) want = 2; if (want) front_screen_match(want == 3 ? 30 : want == 2 ? 120 : 60); // The screen's rate changed (switched by front_screen_match, or another screen): the range again, virtual ticks or none. Taken only once the link object exists, so a change // while its thread starts (after link_main read the rate) is not lost diff --git a/dm/gfx/tests/t_present_rescue.m b/dm/gfx/tests/t_present_rescue.m index 2960d73503..91af88ae95 100644 --- a/dm/gfx/tests/t_present_rescue.m +++ b/dm/gfx/tests/t_present_rescue.m @@ -32,6 +32,7 @@ - (void)invalidate { self.invalidated = YES; } int test_fullscreen; int test_variable_refresh; int test_game_fps = 60; +int test_screen_hz; // the screen's maximumFramesPerSecond (front_screen_max_fps); 0 = not known, as the graphics layer's weak default uint64_t test_focus_since; void *front_metal_layer(void) { return NULL; } module_t *module_of(const void *p) { (void)p; return NULL; } @@ -143,6 +144,26 @@ static void thirty(void) { // A 30 fps target runs the link at 30 Hz, except i test_game_fps = 60; link_sync(); CHECK("60 fps in a window: the 60 Hz link", g_link_mode == 1 && link.preferredFrameRateRange.preferred == 60); g_link = nil; g_link_started = 0; } +static void vrr120(void) { // 60 fps in fullscreen on a variable-refresh screen at a multiple of 120 Hz takes the 120 Hz link (a frame every other tick); everything else keeps the 60 Hz one. + reset(); test_fullscreen = 0; test_variable_refresh = 1; test_screen_hz = 120; test_game_fps = 60; g_present_latency = 0; g_present_link_env = 0; + SilentDisplayLink *link = [SilentDisplayLink new]; g_link = link; g_link_started = 1; + link_sync(); CHECK("a window on a 120 Hz variable-refresh screen keeps the 60 Hz link", g_link_mode == 1 && link.preferredFrameRateRange.preferred == 60 && mtl_link_rate() == 60); + test_fullscreen = 1; link_sync(); + CHECK("fullscreen on a 120 Hz variable-refresh screen takes the 120 Hz link", g_link_mode == 2 && link.preferredFrameRateRange.preferred == 120 && mtl_link_rate() == 120); + CHECK("the 120 Hz link leaves the requested mode as it was", g_dm_present_link == 1); + CHECK("the 120 Hz link keeps frame latency 2", link.preferredFrameLatency == 2); + test_variable_refresh = 0; link_sync(); CHECK("fullscreen on a fixed-rate screen keeps the 60 Hz link", g_link_mode == 1 && link.preferredFrameRateRange.preferred == 60); + test_variable_refresh = 1; test_screen_hz = 144; link_sync(); CHECK("a variable-refresh screen at 144 Hz keeps the 60 Hz link", g_link_mode == 1); + test_screen_hz = 0; link_sync(); CHECK("a screen of unknown rate keeps the 60 Hz link", g_link_mode == 1); + test_screen_hz = 240; link_sync(); CHECK("a variable-refresh screen at 240 Hz takes the 120 Hz link", g_link_mode == 2); + test_screen_hz = 120; g_present_link_env = 1; link_sync(); CHECK("DM_PRESENT_LINK=1 keeps the 60 Hz link in fullscreen", g_link_mode == 1 && link.preferredFrameRateRange.preferred == 60); + g_present_link_env = 0; link_sync(); CHECK("without DM_PRESENT_LINK the 120 Hz link again", g_link_mode == 2); + test_game_fps = 30; link_sync(); CHECK("30 fps in variable-refresh fullscreen keeps the 30 Hz link", g_link_mode == 3 && link.preferredFrameRateRange.preferred == 30); + test_game_fps = 60; link_sync(); CHECK("back at 60 fps: the 120 Hz link", g_link_mode == 2); + test_fullscreen = 0; link_sync(); CHECK("leaving fullscreen: the 60 Hz link again", g_link_mode == 1 && link.preferredFrameRateRange.preferred == 60 && mtl_link_rate() == 60); + g_dm_present_link = 2; link_sync(); CHECK("DM_PRESENT_LINK=2 keeps the 120 Hz link in a window", g_link_mode == 2); + g_dm_present_link = 1; test_screen_hz = 0; test_variable_refresh = 0; link_sync(); g_link = nil; g_link_started = 0; +} static void metal_lifecycle(void) { id device = MTLCreateSystemDefaultDevice(); CHECK("Metal device available", device != nil); if (!device) return; @@ -161,7 +182,7 @@ static void metal_lifecycle(void) { int main(int argc, char **argv) { @autoreleasepool { alarm(10); setbuf(stdout, NULL); if (argc > 2 || (argc == 2 && strcmp(argv[1], "--metal"))) return 2; - thresholds(); backoff(); settings(); lifecycle(); latency(); thirty(); + thresholds(); backoff(); settings(); lifecycle(); latency(); thirty(); vrr120(); if (argc == 2) metal_lifecycle(); printf("present rescue: %d checks, %d failed\n", checks, failed); return failed != 0; } } diff --git a/dm/gfx/tests/t_present_rescue_focus.c b/dm/gfx/tests/t_present_rescue_focus.c index 8413e9a4fe..4c7b7a5734 100644 --- a/dm/gfx/tests/t_present_rescue_focus.c +++ b/dm/gfx/tests/t_present_rescue_focus.c @@ -10,3 +10,5 @@ extern int test_variable_refresh; int front_screen_variable_refresh(void) { return test_variable_refresh; } extern int test_game_fps; int test_game_fps_now(void) { return test_game_fps; } +extern int test_screen_hz; +int front_screen_max_fps(void) { return test_screen_hz; } diff --git a/docs/agents/symptoms.md b/docs/agents/symptoms.md index 2cdaf96bc9..c4b7878bce 100644 --- a/docs/agents/symptoms.md +++ b/docs/agents/symptoms.md @@ -496,6 +496,7 @@ One line per symptom in the [subsystem references](../reference/): what you see - a Mac game in fullscreen holds 60 fps for 45 s to 3 minutes, then the same work takes about twice the GPU time (Tears of the Kingdom 10.5 → 19.7 ms) and the frame step falls to 30 fps. `powermetrics` shows the GPU at its lowest clock (338 MHz on an M4 Pro) while it is 72–98% busy, with thermal pressure nominal. Probes back to 60 fail, because the clock does not rise within a probe. `gamepolicyd` logs `Game mode enabled` and `Set DPS to 2`. → [macOS Game Mode holds the GPU at its lowest clock](../reference/frame-rate.md#macos-game-mode-holds-the-gpu-at-its-lowest-clock) - a Mac game started with `DM_FULLSCREEN=1` from an app that is itself in fullscreen (the launcher's console mode) came up as a window. → [A game opened from a fullscreen app stays a window](../reference/frame-rate.md#a-game-opened-from-a-fullscreen-app-stays-a-window) - on a ProMotion built-in screen, only in fullscreen, the frame step went 1 → 2 → 3 vsyncs on frames costing 0.1–0.6 vsyncs (`3 late frames; cost 0.32 vsyncs`) and the game presented 15 frames a second; a window, or the screen set to a fixed 60 Hz, did not do it. The `DM_NVN_TSV` rows show presents shown one 120 Hz refresh late (`@pres_at_ms` − `@link_target_ms` = 8.3 ms) with a skipped tick after each (`@link_gap_ms` 33 ms), and `@link_lead_ms` about 16 ms against about 50 ms in a window. → [Frame latency 2 for the display link in fullscreen](../reference/frame-rate.md#frame-latency-2-for-the-display-link-in-fullscreen) +- in fullscreen on a MacBook Pro's ProMotion screen, a 60 fps game held 40 fps with its GPU at 10 ms a frame: frame times alternated 16.7 and 33.3 ms, and each `on-screen` line counted half its frames as held too long (`dropped 150` of 300, `discarded 0`). The same route in a window held 60.0 fps, and the title screen showed the same 40 fps in fullscreen. → [The 60 Hz link at 40 fps in fullscreen on a ProMotion screen](../reference/frame-rate.md#the-60-hz-link-at-40-fps-in-fullscreen-on-a-promotion-screen) - on a MacBook Air M2's fixed 60 Hz built-in display, the reporter measured about 50 ms from the game's present call to on-screen presentation with the display link, at both requested frame latencies 1 and 2, versus 29 ms with `DM_PRESENT_LINK=0`. After loading, all three runs held 60 fps with 0.3% of on-screen intervals outside 16.7 ± 2 ms and no frame-step changes. → [Fixed-rate display-link delay remains at latency 1](../reference/frame-rate.md#fixed-rate-display-link-delay-remains-at-latency-1) - at a 30 fps target, in fullscreen on a fixed 60 Hz screen, frames stay on screen 66.7 ms, often many in a row; in a window a late frame stays 50 ms. The `display link up to tick` lines count 300 ticks per 300 presents, but over 12-18 s instead of 10. → [The 30 Hz display link in fullscreen on a fixed-rate screen](../reference/frame-rate.md#the-30-hz-display-link-in-fullscreen-on-a-fixed-rate-screen) - autorelease work takes a measurable share of replay time, with temporary arena-buffer ownership traffic a few times per draw. → [Arena buffer ownership on allocation](../reference/frame-rate.md#arena-buffer-ownership-on-allocation) diff --git a/docs/env_switches.tsv b/docs/env_switches.tsv index 399702b374..83994e8ec0 100644 --- a/docs/env_switches.tsv +++ b/docs/env_switches.tsv @@ -246,7 +246,7 @@ DM_PRESENT_FIFO switch `0` makes the display link take the newest queued frame e DM_PRESENT_HZ switch `=`: the frequency of presentation pacing (default 60); `0` means no frame limit and is for performance profiling only DM_PRESENT_INTERVAL switch `display` presents one frame per display-link tick whatever interval the game sets; `game` follows the game's present interval (default: the profile's `present_interval`) DM_PRESENT_LATENCY switch `=1` or `2`: overrides the display link's preferred frame latency for every screen and window mode; larger positive values use 2 (default 2 in variable-refresh fullscreen, 1 otherwise) -DM_PRESENT_LINK switch Present pacing: `0` acquires the drawable itself, `1` (the default) presents on a 60 Hz display link, `2` uses a 120 Hz display link with a frame every other tick +DM_PRESENT_LINK switch Present pacing: `0` acquires the drawable itself, `1` (the default) presents on a 60 Hz display link, `2` uses a 120 Hz display link with a frame every other tick. Unset, a 60 fps game in fullscreen on a variable-refresh screen whose rate is a multiple of 120 Hz (a ProMotion screen) takes the 120 Hz link of `2`; a value set here holds in every window and screen mode DM_PRESENT_SNAP player `=`: present snapshots, a ring of n host textures (2 to 4; `1` = 2) the presents are copied to so the game's swapchain texture is free at once; `0` off (default: the profile's `present_snap`) DM_PRESENT_UPSCALE player `metalfx` scales a picture smaller than its rectangle on the screen through MetalFX's spatial scaler; `off` (the default) scales it bilinearly DM_PREV_PROGRAM switch What `oe::GetPreviousProgramIndex` answers (default -1, started from HOME); `oe::ExecuteProgram` sets it for the program it starts diff --git a/docs/reference/frame-rate.md b/docs/reference/frame-rate.md index 87aa946217..7767d77558 100644 --- a/docs/reference/frame-rate.md +++ b/docs/reference/frame-rate.md @@ -372,6 +372,16 @@ Frame pacing: the host's frame step, engine frame limiters, present intervals an - **Note:** a 120 Hz link instead (`DM_PRESENT_LINK=2`, a frame every other tick, latency 1: 16.6 ms of lead) kept the frame step at its cost in three runs, but the system still showed 102–209 of about 9,600 presents one refresh late, against 17–19 at latency 2; so a steady panel rate does not remove the misses. - **Check (fixed-rate policy correction, #822):** the full graphics suite passed, including `t_present_rescue --metal` (64 checks) with synthetic fixed/variable screen transitions and both explicit overrides; an isolated run of the production screen classifier passed fixed 60/120 Hz, variable refresh, mode-change and unknown-screen cases. The iOS compile check passed all 185 sources, C/Objective-C lint had no new findings, and the documentation tests passed. Regression risk was judged **moderate** because the policy uses shared Mac presentation state. The three selected fullscreen comparisons were Tears of the Kingdom (the original ProMotion workaround), Breath of the Wild (another affected frame-step profile), and Pokémon Scarlet (a different engine on the same presentation path); all three were named by `regress.py impact`. The user stopped the regression during shader/host preparation, before gameplay comparisons, so no game regression result is claimed. - **Not checked:** Mario Kart 8 Deluxe on the M4 with latency 2; physical fixed-rate displays; moving between screens or changing refresh mode while a game runs (only synthetic state transitions in `t_present_rescue`); the new policy's fullscreen gameplay comparison. +- **Later:** on an M5 Pro with macOS 27.2 the 60 Hz link at latency 2 still showed every present a refresh late, and the game ran at 40 fps: [The 60 Hz link at 40 fps in fullscreen on a ProMotion screen](#the-60-hz-link-at-40-fps-in-fullscreen-on-a-promotion-screen). + +### The 60 Hz link at 40 fps in fullscreen on a ProMotion screen + +- **Symptom:** in fullscreen on a MacBook Pro's ProMotion screen, a 60 fps game held 40 fps with its GPU at 10 ms a frame: frame times alternated 16.7 and 33.3 ms, and each `on-screen` line counted half its frames as held too long (`dropped 150` of 300, `discarded 0`). The same route in a window held 60.0 fps, and the title screen showed the same 40 fps in fullscreen. +- **Cause:** with the 60 Hz link at frame latency 2 ([Frame latency 2 for the display link in fullscreen](#frame-latency-2-for-the-display-link-in-fullscreen)), the system showed every present one 120 Hz refresh after the link's target (`@pres_at_ms` − `@link_target_ms` = 8.33 ms on every frame of the `DM_NVN_TSV` rows), and the link skipped its next tick after every second present: `@link_gap_ms` ran 16.7, 33.3, 16.7, 33.3 ms, 40 callbacks a second, each `display link up to tick` line with `empty ticks 0`. The host paces the game by the link's ticks, so the game ran at 40 fps. In a window the link called back 49.9 ms ahead (33.3 in fullscreen) and every present landed on its target. At frame latency 1 (`DM_PRESENT_LATENCY=1`) the misses of issue #347 came back and the frame step went to 4 vsyncs, 15 fps. The 120 Hz link (`DM_PRESENT_LINK=2`, a frame every other tick) at latency 2 showed the presents on their targets. Why the system misses a 60 Hz link's targets in variable-refresh fullscreen is **open**. +- **Fix:** `link_vrr_120` (`nvn_mtl.m`): at 60 fps in fullscreen on a variable-refresh screen whose rate is a multiple of 120 Hz, `link_sync` runs the 120 Hz link of `DM_PRESENT_LINK=2` instead of the 60 Hz one: the acquire waits two of its ticks per vsync (`mtl_link_wait_tick`), the vsync event follows every other tick, and a frame that misses its tick goes up on the next 120 Hz refresh instead of a whole 60 Hz period later. Frame latency stays 2. A window, a fixed-rate screen, a screen of another rate, a 30 fps target (the 30 Hz link) and a set `DM_PRESENT_LINK` keep their link; `DM_PRESENT_LINK=1` keeps the 60 Hz link in fullscreen. The switch follows fullscreen in and out, as the latency policy does, and logs `display link: 120 Hz in fullscreen on a variable-refresh screen`. +- **Found on:** [Ocarina of Time](../../dm/games/0400486024DEC000/README.md) (Switch 2, v131072) on an M5 Pro MacBook Pro (16-inch, 20-core GPU, ProMotion), macOS 27.2, the `kokiri-deck` route with `scripts/bench.py`, `host=` variants of `main` and this change on the same libraries and pipeline archive, two runs each: in fullscreen 39.7 → 59.2 fps (58.8–59.6), frame p50 33.19 → 16.66 ms, p99 33.53 → 25.27 ms, frames over 1.5 vsyncs 914 → 62 of about 2,680, GPU p50 10.2 / 10.0 ms; in a window 59.9 / 59.95 fps, unchanged (no `120 Hz` line). At the title screen in fullscreen before the change: 40.5 fps on the 60 Hz link at latency 2, 15 fps at latency 1, 59.3 fps with `DM_PRESENT_LINK=0` (frame p99 25.2 ms) and 59.7 with `DM_PRESENT_LINK=2` (p99 19.2 ms). On the 120 Hz link the system still shows each present one refresh after its target, harmlessly, and about 2% of its callbacks come two refreshes apart, so about 2% of frames stay up 25 ms. At frame latency 1 the 120 Hz link did a little worse (59.3 fps, frame p99 29.2 ms, 48 frames over 1.5 vsyncs, against 59.55 fps, 25.2 ms and 35 at latency 2, two runs each), so frame latency stays 2 +- **Check:** `t_present_rescue` (`vrr120`): a window keeps the 60 Hz link; fullscreen on a 120 Hz variable-refresh screen takes the 120 Hz link at latency 2 with the requested mode left at 1; a fixed-rate screen, a 144 Hz screen, a screen of unknown rate, `DM_PRESENT_LINK=1` and a 30 fps target keep theirs; 240 Hz takes it; leaving fullscreen goes back to the 60 Hz link. `make -C dm/gfx/tests GAME=0400486024DEC000 check_t_present_rescue` (with `--metal` and the Metal debug layer): 100 checks, 0 failed. +- **Not checked:** external variable-refresh screens; other ProMotion Macs and macOS versions (the M4 Pro and M5 Max of issue #347 held their frame rate at latency 2 on the 60 Hz link, so the miss may depend on the GPU or the system); input latency against the 60 Hz link; iOS, where nothing changes (`front_fullscreen` is 0 there). ### Fixed-rate display-link delay remains at latency 1 From ba5c9dd874cd2fe0d2dcd8aa497ed56296f13bc0 Mon Sep 17 00:00:00 2001 From: The-Inceptions <83852285+The-Inceptions@users.noreply.github.com> Date: Mon, 9 Nov 2026 07:04:44 -0500 Subject: [PATCH 2/2] The fixed 60 Hz screen mode in fullscreen, measured beside the 120 Hz link: 60.0 fps, frame p99 16.9 ms, on the 60 Hz link at latency 1 (Ocarina of Time, M5 Pro, the screen held at 60 Hz for one app) A note in the reference entry; switching a variable-refresh screen to its 60 Hz mode is left out of this change, as it changes the mode of the whole screen. Co-Authored-By: Claude Opus 5.5 --- docs/reference/frame-rate.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/reference/frame-rate.md b/docs/reference/frame-rate.md index 7767d77558..78d2ea1ded 100644 --- a/docs/reference/frame-rate.md +++ b/docs/reference/frame-rate.md @@ -381,6 +381,7 @@ Frame pacing: the host's frame step, engine frame limiters, present intervals an - **Fix:** `link_vrr_120` (`nvn_mtl.m`): at 60 fps in fullscreen on a variable-refresh screen whose rate is a multiple of 120 Hz, `link_sync` runs the 120 Hz link of `DM_PRESENT_LINK=2` instead of the 60 Hz one: the acquire waits two of its ticks per vsync (`mtl_link_wait_tick`), the vsync event follows every other tick, and a frame that misses its tick goes up on the next 120 Hz refresh instead of a whole 60 Hz period later. Frame latency stays 2. A window, a fixed-rate screen, a screen of another rate, a 30 fps target (the 30 Hz link) and a set `DM_PRESENT_LINK` keep their link; `DM_PRESENT_LINK=1` keeps the 60 Hz link in fullscreen. The switch follows fullscreen in and out, as the latency policy does, and logs `display link: 120 Hz in fullscreen on a variable-refresh screen`. - **Found on:** [Ocarina of Time](../../dm/games/0400486024DEC000/README.md) (Switch 2, v131072) on an M5 Pro MacBook Pro (16-inch, 20-core GPU, ProMotion), macOS 27.2, the `kokiri-deck` route with `scripts/bench.py`, `host=` variants of `main` and this change on the same libraries and pipeline archive, two runs each: in fullscreen 39.7 → 59.2 fps (58.8–59.6), frame p50 33.19 → 16.66 ms, p99 33.53 → 25.27 ms, frames over 1.5 vsyncs 914 → 62 of about 2,680, GPU p50 10.2 / 10.0 ms; in a window 59.9 / 59.95 fps, unchanged (no `120 Hz` line). At the title screen in fullscreen before the change: 40.5 fps on the 60 Hz link at latency 2, 15 fps at latency 1, 59.3 fps with `DM_PRESENT_LINK=0` (frame p99 25.2 ms) and 59.7 with `DM_PRESENT_LINK=2` (p99 19.2 ms). On the 120 Hz link the system still shows each present one refresh after its target, harmlessly, and about 2% of its callbacks come two refreshes apart, so about 2% of frames stay up 25 ms. At frame latency 1 the 120 Hz link did a little worse (59.3 fps, frame p99 29.2 ms, 48 frames over 1.5 vsyncs, against 59.55 fps, 25.2 ms and 35 at latency 2, two runs each), so frame latency stays 2 - **Check:** `t_present_rescue` (`vrr120`): a window keeps the 60 Hz link; fullscreen on a 120 Hz variable-refresh screen takes the 120 Hz link at latency 2 with the requested mode left at 1; a fixed-rate screen, a 144 Hz screen, a screen of unknown rate, `DM_PRESENT_LINK=1` and a 30 fps target keep theirs; 240 Hz takes it; leaving fullscreen goes back to the 60 Hz link. `make -C dm/gfx/tests GAME=0400486024DEC000 check_t_present_rescue` (with `--metal` and the Metal debug layer): 100 checks, 0 failed. +- **Note:** with the screen held at its fixed 60 Hz mode (`CGConfigureDisplayWithDisplayMode` for one app, as `front_screen_match` switches a 144 Hz screen), the same route in fullscreen held 60.0 fps, frame p99 16.9 ms, 2 frames over 1.5 vsyncs in two runs. That was the 60 Hz link at latency 1 ("fullscreen, fixed refresh"), as in a window. The GPU took 14.0 ms there against 10.0, at the lower clock the system chose. Switching a variable-refresh screen to its 60 Hz mode in fullscreen would remove the remaining 25 ms frames, but it changes the mode of the whole screen while the game is fullscreen; it is not done here. - **Not checked:** external variable-refresh screens; other ProMotion Macs and macOS versions (the M4 Pro and M5 Max of issue #347 held their frame rate at latency 2 on the 60 Hz link, so the miss may depend on the GPU or the system); input latency against the 60 Hz link; iOS, where nothing changes (`front_fullscreen` is 0 there). ### Fixed-rate display-link delay remains at latency 1