Add the registered profile, Unity 2019 compute shaders, VP8 decoding, depth initialization and AudioOut stop fixes. Include build-gated frame limiting, regression coverage and Mac validation notes.
36 KiB
36 KiB
Frame rate
Frame pacing: the host's frame step, engine frame limiters, present intervals and display rates. Each entry: symptom, cause, fix, the games it was found on. The symptom index lists every symptom.
Frame step
sead's dynamic present interval, ksys::VFR and the host's frame step
- Symptom: a 30 fps game to run at 60; after the floor was lowered, slow motion on a GPU-bound device (GitHub issue #9).
- Cause: find where the present interval comes from and how the logic time step follows it before patching anything. BotW's
sead::GameFrameworkNxmain loop (main+0x112660c) runs a dynamic interval: from the last frame's time it computes how many vertical syncs were needed, clamps that to [2, 3] (the maximum from the interval controller built atmain+0x1126c04), steps up after three overrunning frames and down one level every six frames, and sets the result throughsetWaitVsyncInterval(vtable +0x130,main+0x11269b8→nvnWindowSetPresentInterval). The game logic follows on its own:ksys::VFRis initialized with a constant base interval of 2 (main+0x127e740), and every frameVFR::updateInterval(main+0x151d3d0) takes the framework's current interval, giving a frame ratio of interval / 2, so the engine's variable frame rate scales its time step by the present interval and the speed is right as long as the interval matches the frames. Then check what the controller measures: BotW's measured only the main thread's CPU time (about 3 ms a frame), so a GPU-bound device stayed at interval 1 and played in slow motion. - Fix: now the host's frame step in whole vsyncs: a stub at the framework's
setWaitVsyncIntervalhands the game the host's step, and frames stay on screen exactly that long. The method before it, whichDM_FRAME_STEP=legacystill runs, cuts the game's frame-interval logic: five rewriter cuts lower the floor from 2 to 1 (itspatches.py, buildcd57b23f…): the candidate floor (two places) and the hysteresis targets. Two more cuts make the controller always take its GPU-time path, and the host's timestamp reports had to carry real GPU times for that path to see anything (profile keygpu_timestamps, GPU timestamps per submission segment). - Found on: The Legend of Zelda: Breath of the Wild (1.9.0; its note has the interval controller and the legacy cuts).
- Check: probes confirmed the legacy result: the framework presents at interval 1, VFR reports interval 1 with ratio 0.5, and when a frame runs long the game steps back to 2 with ratio 1, so the game speed does not change.
- Note: the zeldaret/botw decompilation (
VFR.cpp) gave the semantics; 1.9.0's addresses were found by the constants (60 / interval, thefmov s11, #2.0floor).
A thermally serious iPhone holds 30 fps
- Symptom: on an iPhone, a game with the frame step at 60 fps drops a frame in most windows after a few minutes, and its GPU time per frame roughly doubles to triples (17-26 ms for the same scenes).
- Cause: at thermal state serious (
NSProcessInfothermalState2) iOS caps the GPU clock to about a third: the median of the frame's tiny passes, a clock proxy fromDM_NVN_GPUPROF, rose from about 6 to 22 µs. A frame with thousands of draws no longer fits 16.7 ms, and running the GPU flat out keeps the device hot. - Fix:
thermal_cappedinhle_nvn.c: while the device is serious or worse,fps_step_capholds 30 fps; it goes back to the cap after the device has been fair or better for 30 s (THERMAL_CALM_NS), so it does not flap. The thermal state is read once a second (dm_thermal_now). The log saysthermal state 2: the frame step holds 30 fps …andframe step cap 60 → 30 fps … (thermal state serious).DM_THERMAL_CAP=0turns it off. The Mac reports no thermal state, so it never applies there. - Found on: The Legend of Zelda: Breath of the Wild (iPhone 17 Pro Max, docked at 60 fps: serious after 226 s from a cold start, after 64 s from a warm one; at 30 fps the frames held with no drops).
- Resolved: movies interrupted thermal recovery, and acquire/present callers raced. The cap getter short-circuited thermal sampling while a movie or a 30 fps target applied. A serious interval during a movie could leave an earlier calm timer intact and release the cap too soon; cooling during a movie instead added another 30 s hold after playback. Thermal sampling now runs independently of those caps, with a mutex protecting the sample, cached result and recovery timer across acquire/present threads.
- Check:
make -C dm/gfx/tests GAME=01007EF00011E000 check_t_thermal thermal-tsandrives the production cap getter with a controlled clock and thermal source: serious/critical, recovery interrupted by heat, cooling and heating during movies or a 30 fps target, one-second sampling, disabled/inactive caps, the Mac's absent thermal state, and concurrent readers under ThreadSanitizer. No new device gameplay run or on-device recovery observation is claimed.
The frame step after a 30 fps cap ends
- Symptom: after a 30 fps cap ends (a prerendered movie, the thermal cap), a GPU-bound game stays at 30 fps in a scene that held 60 before the cap; the log shows
frame step cap 30 → 60 fpsand a few frames laterframe step: hold 1 → 2 vsyncs at present … (3 late frames; …), with no probe after it. - Cause: when the cap rose,
nvn_fstep_nextput the hold back to 1 with no grace, while the clocks were still set for 30 fps, so three late frames stepped it up again. After a movie, the cap change had dropped the cost that held hold 1 (cref), so the same-cost probe could not fire. After the thermal cap, the GPU reference (nvn_fgpu) had been learned during the cap, at the clock it left, so the same work at hold 2 did not look slowed. A cost nearFSTEP_FITat either hold kept the fit trial off in both cases. - Fix:
nvn_fstep.hkeepscrefand the GPU reference for each kmin. Entering a cap also saves the cost of an outgoing run that held for at least 8 s, even if no earlier hitch had populatedcref; a shorter run keeps the earlier proven reference. The frames after the cap rises are a counted probe with the usual 30 frames of grace, starting with the first frame at the shorter hold. The completed frame at the old cap consumes none of that grace, and a failed recovery doubles the hold-off as usual. - Found on: reading the code; modelled in
t_fstepwith The Legend of Zelda: Tears of the Kingdom's movie flag and The Legend of Zelda: Breath of the Wild's iPhone GPU times (0.86 vsyncs at hold 1, 1.5 at hold 2). Not yet seen on these paths in a device log; first seen on a Mac with a 30 fps mode in a downstream fork. - Check:
make -C dm/gfx/tests GAME=010015100B514000 t_fstep && dm/gfx/tests/t_fstep: movie and thermal recovery, the first movie without a prior hitch, repeated caps after short and sustained runs, all 30 grace frames, failed-probe backoff and game-time debt. The original recovery cases fail on main before PR #253; its initial fix still failed the first-movie, repeated-cap and full-grace regressions. All pass with the corrections above.
GPU timestamps per submission segment
- Symptom: slow motion when the GPU is the bottleneck: a game's interval controller that times the GPU never steps up (GitHub issue #9).
- Cause: a game that times its GPU work as the difference of two timestamp reports in one submission (
TIMESTAMP_TOPat the start of the frame's command buffer,TIMESTAMPat its end) read 0–6 ticks, because the host wrote all the reports of a submission with one time in its completion handler. - Fix: the profile key
gpu_timestamps segmentmakes each report take the GPU start (TIMESTAMP_TOP) or end (other types) of thesub_cutsegment (Submissions handed to the GPU in segments) it was encoded in (rep_ticksinnvn_mtl.m: MTL4CommitFeedback times converted to the guest clock, written in the last segment's handler as before).DM_NVN_GPU_TS=submissiongives the old timestamps. - Found on: The Legend of Zelda: Breath of the Wild (legacy frame-step method): BotW's frame has 5 reports in one submission (
main+0xe94584,main+0xe94664); with two more cuts the controller always takes the GPU path, and the GPU time now reads the frame's GPU span, gaps where the GPU waits for the host's translation included. Measured on the Mac with two BotW runs sharing the GPU under a heavy CPU load (load average about 75, a shader rebuild running): GPU time p50 16–23 ms; the fixed run stayed at interval 2 (3 while the GPU time passed 1.9 vsyncs, 1 for under 2% of the frames) at 0.93–0.98 of real time, while a run withDM_NVN_GPU_TS=submissionspent 10–70% of its frames at interval 1 and ran at 0.78–0.95. The same GPU time drives the game's dynamic resolution.
Raw NVN timestamp units
- Symptom: GPU time reads 32 times too small when a game converts raw NVN counter timestamps itself.
- Cause: the host wrote reports in 19.2 MHz CPU ticks, while the raw NVN timestamp field uses 614.4 MHz ticks (384 ticks per 625 ns). Games calling
nvnDeviceGetTimestampInNanosecondssaw correct time because its inverse conversion had the same wrong rate, but an inline conversion read a 16 ms span as 0.5 ms. - Fix:
nvn_timestamp.hshares the 614.4 MHz conversions between all report writes (gpu_ticks,rep_ticks) andnvnDeviceGetTimestampInNanoseconds. Wide integer products preserve long uptimes. The guest clock epoch stays the same; CPU system counters stay at 19.2 MHz, and the API still returns nanoseconds. - Found on: NieR:Automata The End of YoRHa Edition, whose dynamic-resolution controller subtracts two raw report timestamps and converts the difference inline.
- Check:
t_timestampchecks known vectors, long uptimes, round-trip precision and the game's exact inline arithmetic.t_nvnchecks the API's known units and bounds a completed report between submit and fence wait.
Engine frame limiters
Unity's integer target frame rate
- Symptom: menus reach 60 fps with
present_interval display, but entering gameplay returns to 30 fps despite spare GPU time. - Cause: the game sets Unity's
Application.targetFrameRateto 30. Changing NVN's present pacing does not change the engine's own limiter. - Fix:
hle_fpslock.csupports integer FPS fields alongside float FPS, milliseconds and vsync counts. Its build-gated row keeps the verified field at the profile'sfps_target(DM_FPS_TARGEToverrides it), preserving unset values such as 0 and -1.present_interval displayremoves the separate interval-2 present cap. - Found on: Pokémon Brilliant Diamond, 1.3.0; its note records the setter, field and gameplay timing checks.
- Check:
t_fpslockexercises the production write path, build mismatch, disabled override, repeated game writes, unset values and 30 fps fallback, plus the existing float, millisecond and two-copy vsync paths.
A render-thread limiter with a constant and a fixed time step
- Symptom: the game presents at interval 1, yet every frame takes exactly 33.3 ms of "frame work"; after only the limiter was raised, everything ran twice as fast on the iPhone.
- Cause: the cap is the render thread's own limiter,
main+0x707090(this, fps), which sleeps out 1000 / fps ms since its previous call, called once with the constant 30 (main+0x706fa4). Found from the render thread's backtrace in the thread dump (HGCommandDispatcher), not from the NVN calls. The frame update takes a fixed step, the float 1/30 atmain+0x167a6ec, loaded atmain+0x667904and passed tomain+0x6a337c. - Fix: two cuts: the limiter's constant, and a second cut that loads the 1/60 at
main+0x167a834(the community's 60 fps cheat for this build changes the same two words). Some 20 other loads of that 1/30 turn parameters counted in 30 fps frames into seconds or step single systems; they stay. - Found on: DARK SOULS: REMASTERED (30 on the Switch, 60 now).
- Check: before changing the constant, check how the game steps time, and not by eye: a 0.8 s walk that looked the same at 30 and 60 fps on the Mac was taken for a time-based game, and on the iPhone everything ran twice as fast. Probing every site that loads such a constant (
DM_PROBE, 16 per run) tells per-frame steps from one-off conversions, but which of them sets the game's speed only shows by timing the game.
A frame-end wait polled in 2 ms sleeps
- Symptom: 55 fps with every frame 18.2 ms, the main thread asleep most of the time; every 11th frame stays on screen two vsyncs.
- Cause: the game's own code. KTGL's frame end (
main+0x443e8c) presents, acquires the next texture, then waits until N / 60 s have passed since the frame's start tick, pollingGetSystemTickand sleeping 2 ms a round (main+0x4c9f24). N is the game's vsyncs per frame, a global. The start tick is read after the scene's update, so each frame lasts N / 60 s plus the time from the last poll to the next start, rounded up to the rounds: never exactly one vsync, on any hardware. The host'sSleepThreadand tick are exact; a model of the loop gives 54 fps with the host's acquire and with the Switch's double-buffered one alike. Found by sampling the host (sample <pid>: the main thread indm_guest_sleep_nsundermain+0x443d74, and innvnWindowAcquireTextureunder the present wrapper). - Fix: a build cut on the wait's threshold (its
patches.py): 0 when N = 1, where the game presents at interval 1 and the host's present pacing (the acquire waits) holds the frame one vsync; frame time p50 16.67 ms, wakeups 2,050 → 300 a second. Scenes with N = 2 still present at interval 1, so the wait stays theirs. Skipping the wait for every N, the first cut, presented 60 frames a second in them (28.8 with the wait, N forced to 2), for code that counts 60 / N frames a second. - Check: before cutting a game's frame limiter, find what sets its target and whether anything else holds those frames. A limiter that also enforces a slower mode (here N = 2) has to keep it.
- Found on: Taiko Risshiden V DX, in its menus and town; its movies, paced by their own player, ran at 60.
Present interval and display rate
Present thread rescues a silent display link
- Symptom: the app is active and the game keeps running, but the picture freezes while display-link callbacks stop; the log repeatedly drops queued frames.
- Cause: dropping frames releases the game's swapchain textures but never displays them. A display link may stop while the app is active, including an occluded Mac window.
- Fix: after 500 ms of silence while active,
mtl_present_waitrequests rescue.link_syncinvalidates the display link on its run loop before releasing the layer to the present thread, which takes its own drawables in mode 0. Retry delays are 3, 6, 12, 24, 48 and then at most 60 seconds. Rescue state changes takeg_pq_mu; the requestedg_dm_present_linkstays unchanged, and a new runtime mode ends rescue immediately. A retry resets stall timing. Inactive and background time do not count towards the 500 ms threshold, including a focus timestamp newer than the watchdog's clock sample. - Found on: d2r-mobile commit
afad65c(an iPhone picture froze for 80 seconds), carried through MacNativeGamescf1e629and PR #198. That upstream title has no profile in this repository; no local game reproduction is claimed. - Check:
make -C dm/gfx/tests GAME=010015100B514000 t_present_rescueanddm/gfx/tests/t_present_rescueexercise the production wait and retry paths with a controlled clock and a silent link double, without a GPU. They cover foreground grace, backoff saturation, runtime mode changes, and layer release only after run-loop invalidation. The test also runs in CPU graphics CI.make -C dm/gfx/tests GAME=010015100B514000 check_t_present_rescueadds a paused real Metal display link on an offscreen Mac layer: rescue invalidates it, thennextDrawablesucceeds (47 checks, zero failures with Metal validation).DM_TEST_LINK_STALL=<start seconds>,<duration seconds>pauses a real window's display link for manual verification. - Not checked: gameplay during stalled-link recovery or the iPhone; the PR's gameplay runs were headless.
A present command allocator is reused only after its own present completes
- Symptom: with the display link driving presents, a presented frame comes out black when three presents are on the GPU at once, or two after a dropped frame; on an M6 the same reuse also logs
[RED] display command buffer errorwithkIOGPUCommandBufferCallbackErrorPageFault. - Cause:
present_nowcycles three Metal 4 command allocators, each with its command buffer and UV buffer, by ticket, and before resetting one it waited forg_pq_doneto reach the ticket that last used it.g_pq_doneis not GPU completion: every display-link callback sets it toticket - 1(the previous present was committed, not run), and a present that was not submitted sets it to its own ticket. So a display-link present never waited there. With three presents in flight, or two after a dropped frame, the allocator was reset and its command buffer encoded again under a present the GPU had not run, and that present's drawable read back black. - Fix: each allocator keeps the newest ticket its own commit feedback has reported (
g_present_alloc_done), and its reuse waits for that. The display-link callback can now block inpresent_nowuntil the GPU has run the allocator's previous present.g_pq_donekeeps its meaning for the game's swapchain acquire. - Found on: no game. Found by reading
NvMetalDisplayLinkDelegate, then reproduced int_present_alloc(below). - Check:
make -C dm/gfx/tests GAME=010015100B514000 check_t_present_allocholds the Metal 4 queue behind a shared event and calls the display-link callback by hand, each present with a drawable of its own offscreen layer. It covers a dropped frame (tickets 1, 3, then 4 on allocator 1 again) and one frame per tick (5, 6, 7, then 8 on allocator 2 again). Before the fix, in both cases the reusing present returns while the earlier one is held and the earlier drawable reads back 0 in all 64 pixels (4 of 13 checks fail; on an M6 every run also logs the GPU page fault); with it the reusing present waits until the gate opens, and all 13 checks pass with Metal validation. - Not checked: a game run hitting it, the iPhone, and the not-submitted path, which the test does not exercise.
Frames of a dynamic present interval held on screen and at the acquire
- Symptom: "sometimes the speed increases when jumping" (Mario 1.2.1 on the iPhone): in
displaymode interval-2 frames were shown for 16.7 ms and the game visibly jumped ahead. "When pressing B, Link's move pace is speeding up" (Breath of the Wild on the iPhone);old frames dropped by merging. - Cause: Mario times every frame (
main+0xcfd24); after a frame that ran past one vertical sync it sets present interval 2 for the next frame and sets its own time step to the interval (a float atmain+0x36ceff0), so that frame catches up 2/60 s. A user's 2-minute iPhone session switched 123 times, mostly for one frame. On the game side, the acquire waited for one display-link tick per frame whatever the interval, so the game kept producing frames every 16.7 ms while the screen held each interval-2 frame for 33 ms and dropped the one in between. BotW sprinting flaps between interval 1 and 2 for runs of 6 or more frames, and its VFR time step at interval 2 is a whole 30 fps tick, so those runs went at double speed. - Fix: both sides, fixed:
- the screen: in
gamemode (the default since 2026-10-15) the display-link callback keeps a frame whose period is over 25 ms on screen for its whole period before taking the next one (g_link_hold_untilinnvn_mtl.m; thedisplay link up to ticklog line counts these ticks asheld for the present interval), and the 30 fps target that drops the display link to 30 Hz (nvn_game_fpsinhle_nvn.c) waits until interval 2 has held for a full second, so one-frame switches no longer flip the display-link mode; - the game:
mtl_link_wait_ticknow waits for as many ticks as the game's present interval (times 2 on the 120 Hz link, half rounded up on the 30 Hz link), as the Switch blocks the next acquire for N vertical syncs.
- the screen: in
- Found on: Super Mario Bros. Wonder (1.2.1, iPhone: the display-link hold); The Legend of Zelda: Breath of the Wild (iPhone: the acquire).
- Check:
DM_TEST_PRESENT_LAG=90,30(sleep 30 ms before every 90th present) makes Mario do this on the Mac; run it withDM_WINDOW=1to get the display link.DM_NVN_TSVwrites each frame's interval as@itv. - Note: the windowless Mac host was never affected by the acquire: there the acquire sleeps until the interval's slot.
Present snapshots: the game's swapchain texture free at once
- Symptom: frames miss the screen while the GPU stays at its lowest clock (444 MHz on an M2 Max); more work (a higher render scale) only makes the frames slower.
- Cause: a game with two swapchain textures gets one back only when the display link has taken its previous present, so each frame's CPU work starts on a tick and the GPU idles from the end of one frame to the first work of the next. macOS reads that idle time as room and keeps the clock low.
- Fix: present snapshots (
DM_PRESENT_SNAP=<n>, a ring of n host textures, 2 to 4; off by default on iOS and Mac;nvn_mtl.msnap_take): each present is copied on the game's queue, right after the frame's work, into the next ring slot; the display link shows the copy, and the game's texture is free at once. The game runs up to a frame ahead of the screen, and a frame whose work runs long is absorbed instead of shown late. Acquire waits for ring capacity and counts it as pacing (mtl_snap_acquire_wait). Present never waits for a snapshot slot: if it is still busy (several textures acquired ahead of time, or a copy not finished), the original texture is queued with its usual acquire ticket. Waiting inside present held the game's locks while the screen was stalled. - Setting: the iOS launcher's Frame buffering toggle is offered for NVN builds and remembers the choice. Its default is the profile's
present_snap(off unless a game was tested with it on: Breath of the Wild, a steady 60 fps on an iPhone 17 Pro Max where it fell to 30 without).Documents/env.txtoverrides it (DM_PRESENT_SNAP=1on,0off); restart the app after editing the file. The Mac uses the same environment variable and the same profile default. - Fix: the frame step judges lateness by what the screen shows while snapshots are on: the on-screen interval of the frame 8 presents back, against its hold (
fstep_tick,nvn_fstep.screen). Judged by its cost, a frame whose CPU time holds the game's waits on its own GPU work (which now runs a frame later) looked late at a GPU time of 0.75 vsyncs. - Note: the cost is up to a frame more latency.
- Note: with Metal encoding on its own thread (
DM_NVN_ENC_THREAD, the default on iOS) the copy is a block of the encode thread's own queue (rec_own_push), behind everything submitted before the present, so a present from another thread than the submitting one gets a snapshot too; the present waits only until that block is encoded (snap_commit_wait). The wait ledgerDM_NVN_WAITS=1counts every CPU wait for the GPU or the encode thread ([waits]every 300 frames). - Fix: suspend drains snapshot commits and their GPU event even when their display tickets were discarded (
snap_draininmtl_drain). Submission fences and upload completion do not cover these copies; previously suspend could return while the encoder still held a copy to submit. - Check:
make -C dm/gfx/tests GAME=01007EF00011E000 check_t_present_snapuses a gated encoder and GPU to check suspend, and a delayed present consumer to check nonblocking present, acquire pacing, fallback tickets and copied pixels after ring reuse. Runs with Metal validation on the Mac, with and without the encode thread; does not measure iPhone frame rate or latency. - Found on: The Legend of Zelda: Breath of the Wild (Mac; on the iPhone 17 Pro Max with the encode thread a steady 60 fps at docked resolution, present to screen about 74 ms with a ring of 2 against about 46–60 ms without).
Vulkan acquire and vsync event follow the display link
- Symptom: a steady game whose frame-rate counter wanders between 50 and 60 on the iPhone only; the
display link up to ticklines show about as many empty ticks as frames dropped by merging: frames arrive two in one tick and none in the next. - Cause: the Vulkan layer's acquire and the
vi::GetDisplayVsyncEventthread each kept a 60 Hz clock of their own, and Galaxy runs the Wii's VI retrace on that event, so its frames drifted against the screen. - Fix: while the display link presents, both follow its ticks (
hle_vk.macquire →mtl_link_wait_tick, as NVN's;hle_sdk16.cvsync thread →mtl_link_wait_vsync), with the old clocks as the fallback. - Found on: Super Mario Galaxy 2 (Vulkan).
- Note: a Mac window measures nothing here unless it is on screen: macOS fires an occluded window's display link about once a second (
display link recovered: stalled 1017 ms).
Virtual 60 Hz ticks on screens that are not a multiple of 60
- Symptom: asked for 60 Hz, the system fires the display link on every third refresh of a 165 Hz screen, 55 Hz, so games paced by it ran at 55 fps (27.5 at 30).
- Fix: on 144 or 165 Hz Mac displays
link_range(nvn_mtl.m) runs the link at the screen's rate and counts as ticks only the refreshes nearest a steady 1/60 s clock: 60 a second on average, each frame shown 12.1 or 18.2 ms at 165 Hz. A tick lies up to half a refresh off its slot, so two ticks can be one refresh nearer each other than their slots: the hold of frames over 25 ms (Frames of a dynamic present interval held on screen and at the acquire) tolerates one refresh more (in a simulation of the callback without it, 30 fps frames held a third tick: 22.5 fps at 165 Hz, 24 at 144), and the frame step lets a frame on time show one refresh longer than its hold (shown_slack,nvn_fstep.h; a cheap 50 ms frame shows 54.5 ms at 165 Hz, and counted as late it kept Breath of the Wild at 20 fps for good).DM_LINK_VTICK=0keeps the system's choice. - Note: iOS screens run at multiples of 60 and are not affected.
- Found on: PR #27 (its author measured at 165 Hz); The Legend of Zelda: Breath of the Wild (the 20 fps without
shown_slack). - Not checked: not yet run on such a screen here.
Fullscreen switches the screen to a rate the frame rate divides
- Symptom: virtual ticks keep the average, but a frame still stays up two or three refreshes in turn (13.9 / 20.8 ms at 144 Hz), and Apple's HUD showed the frame interval jumping between them.
- Fix:
front_mac.mfront_screen_match, called fromlink_syncwith the link's rate (60, 30, or 120 withDM_PRESENT_LINK=2): when the window is fullscreen on a screen whose mode is not a multiple of the link's rate, the host switches that screen to the mode of the same size with the highest rate that is (an XB273U at 2560x1440: 144 → 120 Hz for 60 fps), withkCGConfigureForAppOnly, so macOS switches it back when the host quits, crashes included (the PR's author checked: 144 Hz again after quitting); leaving fullscreen, or the screen, switches it back at once. A window keeps the screen's rate and the virtual ticks: the switch changes the whole screen for every app, and most external screens go black for a second or more. The link sets its range again whenever the screen's rate changes (link_sync; before, a move to another screen kept the first screen's range), with no virtual ticks after a switch. A screen with no such mode keeps the virtual ticks;DM_SCREEN_MATCH=0keeps the screen's rate. - Found on: PR #79: the PR's author ran Hades at 144 Hz (switched in a window then): the HUD's frame interval line steady at 16.7 ms, where it jumped before.
- Check: here (a built-in 120 Hz screen, a multiple of every link rate) a harness drove
front_mac.masked for 50 fps: no switch in the window, 120 → 50 Hz in fullscreen, 120 Hz again on leaving it, and 120 Hz after quitting in fullscreen; at 60 fps and withDM_SCREEN_MATCH=0nothing switched. - Not checked: the link's new range after a switch has not run here (Hades windowed and fullscreen paced at 60 as before).
CPU cost and pacing
Arena buffer ownership on allocation
- Symptom: autorelease work takes a measurable share of replay time, with temporary arena-buffer ownership traffic a few times per draw.
- Cause: under ARC an unqualified
id<MTLBuffer> *out-parameter is__autoreleasing, so assigning the arena buffer inarena_allocautoreleases it. Readingbuf.lengthalso sends an Objective-C message on each allocation and while selecting or trimming arenas. - Fix: both declarations of
arena_allocuse a__strongbuffer out-parameter, matching every caller's strong local.NvArenacaches its buffer length when created; allocation, reuse selection and idle trimming use that value. Buffer ownership and fence-based reuse remain the same. - Found on: MacNativeGames commit
0531f28, ported in PR #195, reports autorelease work falling from 2.63% to 1.73% of replay time; the PR does not identify the game for that measurement. It has not been repeated here. - Check:
t_ub,t_vbufandt_geometryexercise arena-backed uniform shadows, vertex conversion and geometry scratch on the Mac with Metal validation.
Registry lock priority inheritance
- Symptom: the submit thread waits on the texture / sampler registry lock while a lower-priority registration thread is preempted holding it.
- Cause: game threads register textures and samplers under
g_rmu, which the submit thread also takes for argument encoding. The previous mutex allowed those owners to delay the higher-priority submitter. - Fix:
g_rmuis anos_unfair_lock, lending a waiter's priority to its owner. All registration, lookup, cache, diagnostic and test callers use its lock API; the critical sections and lock order stay the same. - Found on: The Legend of Zelda: Tears of the Kingdom, measured upstream in MacNativeGames commit
5f5de5eand ported in PR #195: in a shrine, game threads at priority 46 delayed the submit thread at priority 97 by about 1.8 ms a frame. Waiting fell from 155 to about 0 ms/s there, with unchanged frame time. Those game measurements have not been repeated here. - Check:
t_stencil_view,t_view_levelsandt_view_swizzleexercise production registry lookups and cached views with the new lock on the Mac.
Submissions handed to the GPU in segments
- Symptom: the GPU sits idle each frame while the host translates the submission (a game that does not submit frame N+1 until frame N's GPU work is done).
- Cause: on the Switch the submit costs nothing; here it is the host's translation, and the GPU got nothing until the whole submission was encoded.
- Fix: the replay commits the command buffer encoded so far every 64 draws, between two passes (
sub_cutinnvn_mtl.m, called afterrp_flushinOP_SET_RT;DM_NVN_CUT=<draws>, 0 = off). It cuts only with no open encoder and nothing deferred, merged or hoisted, commits the pending upload batch first, and continues on the same command allocator. The fence signal, the completion work (postops,g_done) and the allocator's fence stay on the last segment: the queue runs command buffers in commit order, and every encoder starts with a queue barrier. - Found on: The Legend of Zelda: Breath of the Wild: a stack sample in the shrine had the main thread in
nvnSyncWait(the fence of the frame just submitted) for 59% of the time and the render thread innvnQueueSubmitCommandsfor 40%; the translation was about 3,600 draws and 347 passes, 7 ms, so per frame the GPU ran 13–14 ms and then sat idle 7.7 ms (DM_NVN_TSVrows@gpu_beg_ms/@gpu_end_msagainst@call_ms). GPU idle per frame went from 7.7 to 3.8 ms; most of the rest is the game's own work between its fence wait and the next submit (about 2.5 ms). The other five games' frame times were unchanged; Metroid Dread's p95 dropped from 19.6 to 16.9 ms. - Note: after this, BotW's docked frame p50 was 16.7 ms (was 20.8 ms before segmenting). Host translation is 8 ms per frame (22,000 instructions per draw), spread over Metal's encoder creation and draw calls, argument encoding, Objective-C retain / release and pool lookups. The per-pass GPU timer (
DM_NVN_PASSGPU) is switched off under Metal 4 (pg_renderreturns at once), so GPU time cannot yet be attributed to passes. Render-target traffic per frame is inDM_NVN_TSV's@rt_load_mb/@rt_store_mb. - Note: with the encode thread (
DM_NVN_ENC_THREAD) a segment's commit record is handed to it at once (rec_publishinsub_cut), not when its block of records fills. Breath of the Wild on an M2 Max (encode thread and snapshots,DM_FPS_TARGET=120): the frame's wait on its newest fence 12.5 ms withDM_NVN_CUT=0, 5.6 ms with 64, 5.35 ms with 16.
Address to memory pool lookups by binary search
- Symptom: the submit thread's CPU samples dominated by
mtl_addr/mtl_gpu_addr(vertex buffers indraw, uniform buffers inencode_args). - Cause: with many memory pools alive, the lookups scanned them all for every buffer binding of every draw.
- Fix: they try the last hit, then a binary search over pools sorted by start address (
pool_find, rebuilt when pools are added or removed). - Found on: The Legend of Zelda: Breath of the Wild: about 1,760 memory pools alive; a CPU sample of the submit thread had about 1,000 of its samples under those scans. Host submit time in the shrine went from 21 ms to 7 ms per frame (p50), the median frame from 38 ms to 21 ms.
Time-constraint computation declared below the thread's peaks
- Symptom: stalls of 30–60 ms about once a second, at 30 fps and at 60 (99th percentile 63–127 ms); the main thread waits 20–34 ms for the render thread once a second.
- Cause: the thread that acquires the window (here the render thread) runs under a time-constraint policy whose computation the host re-declares every 120 frames; it was 1.25 × the 90th percentile of that thread's CPU time per frame. This render thread needs 1.9–2.0 ms a frame with one or two frames in 120 near 4.4 ms, against 2.5 ms declared, and each frame past the declaration stalled the thread. Probes on both threads' waits showed the main thread's waits; adding probes slowed the render thread and the stalls vanished, and
DM_RT=0or a fixedDM_RT_COMP=5/10removed them too (99th percentile 18–21 ms) at the same CPU energy. - Fix: the declaration also covers 1.1 × the window's peak (
rt_comp_tickinhle_nvn.c): 5.6 ms here, 60.0 fps, 99th percentile 21 ms. - Found on: DARK SOULS: REMASTERED, in the Asylum cell.