diff --git a/dm/gfx/nvn/nvn_mtl.m b/dm/gfx/nvn/nvn_mtl.m index ac449a5733..341175a4cd 100644 --- a/dm/gfx/nvn/nvn_mtl.m +++ b/dm/gfx/nvn/nvn_mtl.m @@ -11628,7 +11628,8 @@ static CAFrameRateRange link_range(int mode) { // and why a fixed-rate screen still has about 20 ms more presentation delay with the link at either latency, remain open (docs/reference/frame-rate.md) __attribute__((weak)) int front_fullscreen(void) { return 0; } // front_mac.m; graphics unit tests and iOS have none __attribute__((weak)) int front_screen_variable_refresh(void) { return 0; } // front_mac.m; graphics unit tests and iOS have none -static int link_latency(void) { return g_present_latency > 0 ? g_present_latency : front_fullscreen() && front_screen_variable_refresh() ? 2 : 1; } +// CAMetalDisplayLink accepts only 1 or 2. Compare its readback with the supported request, or an oversized override rewrites and logs every frame. +static int link_latency(void) { return g_present_latency > 0 ? MIN(g_present_latency, 2) : front_fullscreen() && front_screen_variable_refresh() ? 2 : 1; } static const char *link_latency_why(void) { return g_present_latency > 0 ? "DM_PRESENT_LATENCY" : !front_fullscreen() ? "window" : front_screen_variable_refresh() ? "fullscreen, variable refresh" : "fullscreen, fixed refresh"; } 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 diff --git a/dm/gfx/tests/t_present_rescue.m b/dm/gfx/tests/t_present_rescue.m index b39520e461..0df9fd3302 100644 --- a/dm/gfx/tests/t_present_rescue.m +++ b/dm/gfx/tests/t_present_rescue.m @@ -3,7 +3,8 @@ #import @interface SilentDisplayLink : NSObject @property CAFrameRateRange preferredFrameRateRange; -@property float preferredFrameLatency; +@property(nonatomic) float preferredFrameLatency; +@property(nonatomic) NSUInteger latencyWrites; @property (weak) id delegate; @property BOOL paused; @property BOOL invalidated; @@ -12,6 +13,11 @@ - (void)addToRunLoop:(NSRunLoop *)loop forMode:(NSRunLoopMode)mode; - (void)invalidate; @end @implementation SilentDisplayLink +@synthesize preferredFrameLatency = _preferredFrameLatency; +- (void)setPreferredFrameLatency:(float)value { + self.latencyWrites++; + _preferredFrameLatency = value < 1 ? 1 : value > 2 ? 2 : value; // CAMetalDisplayLink accepts only 1 or 2 +} - (instancetype)initWithMetalLayer:(CAMetalLayer *)layer { (void)layer; return [super init]; } - (void)addToRunLoop:(NSRunLoop *)loop forMode:(NSRunLoopMode)mode { (void)mode; [loop addPort:[NSMachPort port] forMode:NSDefaultRunLoopMode]; } - (void)invalidate { self.invalidated = YES; } @@ -110,6 +116,17 @@ static void lifecycle(void) { g_present_latency = lat; test_variable_refresh = variable; test_fullscreen = full; link_sync(); CHECK("DM_PRESENT_LATENCY wins for every screen and window mode", link.preferredFrameLatency == lat); } + for (int variable = 0; variable <= 1; variable++) for (int fullscreen = 0; fullscreen <= 1; fullscreen++) { + test_variable_refresh = variable; test_fullscreen = fullscreen; + const int requests[] = {3, INT_MAX}; + for (unsigned i = 0; i < sizeof requests / sizeof requests[0]; i++) { + g_present_latency = requests[i]; + CHECK("an oversized override requests supported latency 2", link_latency() == 2); + link_sync(); NSUInteger writes = link.latencyWrites; + for (int frame = 0; frame < 10; frame++) link_sync(); + CHECK("a clamped override does not rewrite the link every frame", link.latencyWrites == writes); + } + } g_present_latency = 0; test_fullscreen = test_variable_refresh = 0; link_sync(); g_link = nil; g_link_started = 0; } static void metal_lifecycle(void) { diff --git a/docs/agents/symptoms.md b/docs/agents/symptoms.md index 34adc2e8bf..4da4173e8e 100644 --- a/docs/agents/symptoms.md +++ b/docs/agents/symptoms.md @@ -340,6 +340,7 @@ One line per symptom in the [subsystem references](../reference/): what you see - 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) - 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) +- `DM_PRESENT_LATENCY=3` or higher prints `display link: frame latency` on every frame without increasing the actual latency. → [An unsupported display-link latency is written every frame](../reference/frame-rate.md#an-unsupported-display-link-latency-is-written-every-frame) - 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) - the submit thread waits on the texture / sampler registry lock while a lower-priority registration thread is preempted holding it. → [Registry lock priority inheritance](../reference/frame-rate.md#registry-lock-priority-inheritance) - in a replay profile, `_platform_memcmp` is most of the time spent in serial lookups (`tex_mtl` through `g_by_serial`). → [Hash-table lookups of 8-byte keys](../reference/frame-rate.md#hash-table-lookups-of-8-byte-keys) diff --git a/docs/env_switches.tsv b/docs/env_switches.tsv index 5fdc1afef1..0dd945ae7b 100644 --- a/docs/env_switches.tsv +++ b/docs/env_switches.tsv @@ -202,7 +202,7 @@ DM_PRELOAD diagnostic Set: on the Mac, runs the host's early background loading DM_PRESENT_FIFO switch `0` makes the display link take the newest queued frame each tick, as before; by default it shows frames in order, at most one tick behind 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 `=` (above 0): overrides the display link's preferred frame latency for every screen and window mode (default 2 in variable-refresh fullscreen, 1 otherwise) +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_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 diff --git a/docs/reference/frame-rate.md b/docs/reference/frame-rate.md index 768e531965..e2f9930190 100644 --- a/docs/reference/frame-rate.md +++ b/docs/reference/frame-rate.md @@ -247,7 +247,7 @@ Frame pacing: the host's frame step, engine frame limiters, present intervals an - **Symptom:** 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. - **Cause:** in fullscreen the system shows the layer directly, and at frame latency 1 the display link calls back only one 60 Hz period before the target display time. There it shows some presents one refresh late and skips the link's next tick, though the callbacks run on time (16.2–16.6 ms ahead), the present takes about 0.1 ms and the misses do not follow GPU load (the late presents had less GPU work in flight than the on-time ones). The frames that wait for the skipped tick start late, and the frame step reads three of them in a row as load. Why the system misses at 16 ms of lead is **open**. -- **Fix (a workaround):** `link_latency` (`nvn_mtl.m`) asks for frame latency 2 only while the window is fullscreen on a variable-refresh screen (`front_mac.m` `front_fullscreen` and `front_screen_variable_refresh`), and 1 in a window or on a fixed-rate screen. `screen_refresh` classifies the current screen mode by its positive `minimumRefreshInterval` being shorter than `maximumRefreshInterval`, rather than its maximum Hz or model; it updates at startup, on screen and display-parameter changes, and on fullscreen transitions. `link_sync` applies the current policy to an existing link too. Latency 2 gives the system 33 ms, for one frame more input lag in variable-refresh fullscreen only. `DM_PRESENT_LATENCY=` overrides the policy for every screen and window mode. iOS keeps latency 1. The fixed-rate policy correction ([issue #822](https://github.com/seedds/nxnative/issues/822)) narrows this workaround; it does not resolve the separate [fixed-rate display-link delay](#fixed-rate-display-link-delay-remains-at-latency-1). +- **Fix (a workaround):** `link_latency` (`nvn_mtl.m`) asks for frame latency 2 only while the window is fullscreen on a variable-refresh screen (`front_mac.m` `front_fullscreen` and `front_screen_variable_refresh`), and 1 in a window or on a fixed-rate screen. `screen_refresh` classifies the current screen mode by its positive `minimumRefreshInterval` being shorter than `maximumRefreshInterval`, rather than its maximum Hz or model; it updates at startup, on screen and display-parameter changes, and on fullscreen transitions. `link_sync` applies the current policy to an existing link too. Latency 2 gives the system 33 ms, for one frame more input lag in variable-refresh fullscreen only. `DM_PRESENT_LATENCY=1` or `2` overrides the policy for every screen and window mode; larger positive values use 2. iOS keeps latency 1. The fixed-rate policy correction ([issue #822](https://github.com/seedds/nxnative/issues/822)) narrows this workaround; it does not resolve the separate [fixed-rate display-link delay](#fixed-rate-display-link-delay-remains-at-latency-1). - **Found on:** issue #347 (and #348, Mario Kart 8 Deluxe on an M4, closed into it); [The Legend of Zelda: Tears of the Kingdom](../../dm/games/0100F2C0115B6000/README.md) on an M4 Pro: in fullscreen at latency 1, 68 to 1,640 of about 9,600 presents a refresh late per 180 s run, and in one run the 1 → 2 → 3 steps and 15 presents a second twice; at latency 2, 17 and 18 in two runs, no cheap step-ups. The reporter's M5 Max: 49 skipped ticks in 2,306 frames at latency 1, 4 in 5,328 at latency 2. - **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. @@ -260,6 +260,13 @@ Frame pacing: the host's frame step, engine frame limiters, present intervals an - **Fix:** **open**. Fixed-rate screens now request latency 1 by default, as described [above](#frame-latency-2-for-the-display-link-in-fullscreen); this only corrects the workaround's scope. The display link remains enabled, and the remaining presentation delay needs its own diagnosis. - **Found on:** [issue #822](https://github.com/seedds/nxnative/issues/822), Lollipop Chainsaw RePOP (`0100DD301A686000`, [PR #684](https://github.com/seedds/nxnative/pull/684)'s profile), macOS 27, `--platform mac-app`. +### An unsupported display-link latency is written every frame + +- **Symptom:** `DM_PRESENT_LATENCY=3` or higher prints `display link: frame latency` on every frame without increasing the actual latency. +- **Cause:** [CAMetalDisplayLink accepts only 1 or 2](https://developer.apple.com/documentation/quartzcore/cametaldisplaylink/preferredframelatency). It reads back 2 after a larger request, but `link_sync` compared that value with the unbounded override and kept setting it again. +- **Fix:** `link_latency` bounds positive overrides to 2 before creating or updating a link, so its requested value, comparison and log agree. The automatic window/fullscreen policy stays as above. The synthetic display-link test models the system's readback and counts property writes across frames. +- **Found on:** [Mario Kart 8 Deluxe](../../dm/games/0100152000022000/README.md), [issue #603, finding 2](https://github.com/seedds/nxnative/issues/603); reproduced independently with an offscreen link on macOS 26.6. This fixes the override's repeated writes, not the still-open fullscreen late-present mechanism. + ## CPU cost and pacing ### Arena buffer ownership on allocation