# 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](../agents/symptoms.md) lists every symptom.

## Frame step

### Frame cap checked and used from the same sample

- **Symptom:** clang reports division by zero in `fstep_tick` (`hle_nvn.c`).
- **Cause:** the present path checked `fps_step_cap()` through `fstep_on`, then `fstep_tick` sampled it again for `60 / cap`. The second sample could be zero even though the first enabled stepping.
- **Fix:** sample the cap once in `nvnQueuePresentTexture`, check it and pass the nonzero value into `fstep_tick`.
- **Found on:** clang static analysis ([issue #424](https://github.com/seedds/nxnative/issues/424)); no gameplay failure reported.

### 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::GameFrameworkNx` main 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 at `main+0x1126c04`), steps up after three overrunning frames and down one level every six frames, and sets the result through `setWaitVsyncInterval` (vtable +0x130, `main+0x11269b8` → `nvnWindowSetPresentInterval`). The game logic follows on its own: `ksys::VFR` is initialized with a constant base interval of 2 (`main+0x127e740`), and every frame `VFR::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 `setWaitVsyncInterval` hands the game the host's step, and frames stay on screen exactly that long. The method before it cuts the game's frame-interval logic, and those cuts stay, for the interval the framework keeps while the game's VFR override holds (DM_FRAME_STEP=legacy, which ran this method on its own, was removed in 79d82f7b): five rewriter cuts lower the floor from 2 to 1 (its `patches.py`, build `cd57b23f…`): 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 key `gpu_timestamps`, [GPU timestamps per submission segment](#gpu-timestamps-per-submission-segment)).
- **Found on:** [The Legend of Zelda: Breath of the Wild](../../dm/games/01007EF00011E000/README.md) (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`, the `fmov s11, #2.0` floor).

### A regress run stops when another app takes the key window

- **Symptom:** `scripts/regress.py` reports a hang (`no frame after … s`, a state dump with every game thread waiting) in some runs of Tears of the Kingdom, baseline and candidate alike; the log's last focus line before it is `oe focus state → 2`, with no `→ 1` after it.
- **Cause:** the game window resigning key is sent to the game as out of focus, and Tears of the Kingdom stops presenting while out of focus. During an unattended run another app (a build, a notification, another session's window) takes the key window now and then. Every hung TotK run in the harness's folder on 2026-11-03/04 had that focus line without a return; the runs that passed either had none or regained focus within a second.
- **Fix:** `regress.py` runs the host with `DM_FOCUS_KEEP=1` (`front_mac.m`): the game stays in focus while its window is up, whichever app is active; minimizing still counts.
- **Found on:** [The Legend of Zelda: Tears of the Kingdom](../../dm/games/0100F2C0115B6000/README.md#found-on-this-game) (the regress route, 2026-11-03 and 04).

### Benchmark captures perturb the measured frame pacing

- **Symptom:** otherwise stationary gameplay benchmarks show roughly 125–135 ms frame spikes at their verification screenshots; adaptive-cap results vary between runs.
- **Cause:** `scripts/bench.py` requested readback/PNG captures one-quarter and three-quarters through the timing window. The game frame stalls while the image is captured, and the adaptive frame-step controller can react to that stall. It is not evidence of equivalent renderer or pipeline-compilation work.
- **Fix:** request the default verification screenshot half a second after the measurement window, within the existing two-second exit tail. `--shots` remains an explicit diagnostic override; in-window captures are not uncontaminated performance evidence. Game-speed reports also parse the host's current `frame step: game time …` log format.
- **Check:** pre-refresh Mac BOTW 1.9.0 outdoor and TOTK 1.4.3 opening-cave logs put screenshot completion beside 125–135 ms frames, with shorter submit/GPU spans and no new pipelines. Subsequent runs used post-window screenshots; a TOTK launcher smoke report recovered `game_speed: 1.0` with the corrected parser. Those runs used shader chain `fda42ce035db`, not this branch's `b3ca4b7c621f`; no fresh runtime check on this branch is claimed. The six existing benchmark behavior tests are retained, not newly executed here.
- **Found on:** [BOTW](../../dm/games/01007EF00011E000/README.md#gpu-work) and [TOTK](../../dm/games/0100F2C0115B6000/README.md#frame-rate) on Mac routes.

### 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 (`NSProcessInfo` `thermalState` 2) iOS caps the GPU clock to about a third: the median of the frame's tiny passes, a clock proxy from `DM_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_capped` in `hle_nvn.c`: while the device is serious or worse, `fps_step_cap` holds 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 says `thermal state 2: the frame step holds 30 fps …` and `frame step cap 60 → 30 fps … (thermal state serious)`. `DM_THERMAL_CAP=0` turns it off. The Mac reports no thermal state, so it never applies there.
- **Found on:** [The Legend of Zelda: Breath of the Wild](../../dm/games/01007EF00011E000/README.md) (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-tsan` drives 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.

### Movies hold 30 fps under the frame step

- **Symptom:** in a game the host steps at 60 fps (`FRAME_STEP`), a prerendered movie plays at twice its speed, its sound falls behind, and the frame counter shows 60 during it. The log has `[movie]` lines with pictures 33 ms apart (`time 0 / 33000 / 67000 µs`) and no `frame step cap 60 → 30` at the movie.
- **Cause:** the game's movie player takes one picture from the decoder per game frame instead of following the pictures' times. On the Switch these games run at 30 fps, the movies' own rate. Breath of the Wild played 34 s of its movie in about 1,250 presents (21 s at 60) and 3,507 pictures in about 3,300 presents. Tears of the Kingdom, with the same `subsdk0` movie library, had rewriter stubs at its own movie start and end (`main+0x2b36100` / `+0x2b33918`) that set the state page's movie flag. Breath of the Wild had none.
- **Fix:** the host's movie decoder (`hle_movie.c`) is the general signal. `dm_movie_playing` holds while a video decoder handed out a picture in the last 0.5 s, cleared at once when the decoder is stopped or destroyed. Audio outputs do not count. `fps_step_cap` (`hle_nvn.c`) then holds 30 fps, logged as `frame step cap 60 → 30 fps at present … (movie playing)`. Each present copies the state into the frame-step state page's movie flag (`+4`), where Tears of the Kingdom's step stub reads it for its first, undecided steps. Its movie start and end stubs are gone. Only frame-step builds (a state page) are affected. A game that paces its movie by the pictures' times also drops to 30 for the movie, which is its Switch rate.
- **Found on:** [The Legend of Zelda: Breath of the Wild](../../dm/games/01007EF00011E000/README.md) 1.9.0 on the Mac ([issue #636](https://github.com/seedds/nxnative/issues/636)). It also replaces [The Legend of Zelda: Tears of the Kingdom](../../dm/games/0100F2C0115B6000/README.md)'s movie stubs.
- **Check:** `make -C dm/host/tests GAME=01007EF00011E000 run` (`t_movie_vpx`: a video picture raises the signal for 0.5 s, an audio output does not); `make -C dm/gfx/tests GAME=01007EF00011E000 check_t_thermal thermal-tsan` (movies against the thermal cap); `t_nvn` (the state page's flag is the host's copy). Open: no game run with the fix yet, in Breath of the Wild or Tears of the Kingdom (the movie at its own speed, `frame step cap 60 → 30 … (movie playing)` at its start and back to 60 after it); Tears of the Kingdom's `main` rewrites with 9 frame-step sites instead of 11.

### 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 fps` and a few frames later `frame step: hold 1 → 2 vsyncs at present … (3 late frames; …)`, with no probe after it.
- **Cause:** when the cap rose, `nvn_fstep_next` put 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 near `FSTEP_FIT` at either hold kept the fit trial off in both cases.
- **Fix:** `nvn_fstep.h` keeps `cref` and 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 populated `cref`; 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_fstep` with the 30 fps movie cap ([Movies hold 30 fps under the frame step](#movies-hold-30-fps-under-the-frame-step)) and [The Legend of Zelda: Breath of the Wild](../../dm/games/01007EF00011E000/README.md)'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](https://github.com/seedds/nxnative/pull/253); its initial fix still failed the first-movie, repeated-cap and full-grace regressions. All pass with the corrections above.

### A probe's grace lasts while its frames keep getting cheaper

- **Symptom:** on a Mac, a GPU-bound game at 60 fps falls to 30 or 20 fps when the scene gets heavier and stays there until a menu or a lighter scene, while the GPU clock sits at its floor (338–400 MHz on an M5 Pro) and the GPU is powered 60–75% of the time; the log shows probes back to hold 1 that fail after about 33 frames (`frame step: hold 2 → 1 vsyncs … (probe, GPU 1.67× its time at hold 1)` then `the probe failed`), and the host's profiler (`@gpu_mhz`) shows the clock rising during each probe and falling back after it.
- **Cause:** macOS lowers the GPU clock while a vsync-paced game meets its deadline (to the point where the frame's GPU work is about 70% of the frame), and raises it for a late game at 85–100% GPU occupancy slowly, about 100 MHz a second. A heavier scene at that clock makes three late frames, so the frame step steps up within 50 ms, long before the clock could rise; at hold 2 or 3 the GPU idles between frames, the clock falls to its floor, and a probe back to hold 1 (`FSTEP_GRACE` 30 frames, the iPhone controller's settling time) sees the clock rise 338 → 432 MHz and the GPU time fall 24.8 → 18.5 ms, then fails at 22 ms frames; hold 1 needs about 560 MHz there. The GPU's occupancy stays below 100% because the game waits for the fence of the frame it just submitted (as on the Switch; `nvnQueueFenceSync` takes the fence of the last commit and the GPU's own signal wakes the wait), so between the GPU's end and the next frame's first segment the GPU has nothing: the game's own work (about 1 ms), the translation up to the first cut (`DM_NVN_CUT` 64 draws, 0.5 ms) and the segments the GPU drains faster than the host encodes them (1–4 ms a frame).
- **Fix:** `nvn_fstep.h` renews a probe's grace while the probe's frames keep getting cheaper (`nvn_fstep.ramp`, set by `fstep_tick` from `DM_FSTEP_PROBE_RAMP`, on by default; `0` turns it off, `ab` alternates with `DM_AB`): a grace window whose second half cost at most 95% of its first (`FSTEP_RAMP`) and had a late frame starts another, up to `FSTEP_PROBE_MAX` (180) grace frames in all. A cost that does not fall, or falls and stops short of the hold, fails the probe as before; a window with no late frame ends the grace and the probe is judged as a trial. The probe's late frames are paid back as any other.
- **Check:** `make -C dm/gfx/tests GAME=010015100B514000 t_fstep && dm/gfx/tests/t_fstep`: a cost falling 0.6% a frame fails the probe without the rule and holds with it (three windows); a flat cost, a cost that stops at 1.3 and a cost that falls without reaching the hold last 33, 63 and 183 frames.
- **Found on:** [The Legend of Zelda: Breath of the Wild](../../dm/games/01007EF00011E000/README.md#the-frame-step-current) (Mac M5 Pro, fullscreen, present snapshots, the host's profiler); resolved there: in a 470 s run with the rule switched every 30 s, every probe with it held (11 in a row).
- **Note:** what shortens the serial chain itself, so the GPU's occupancy at a GPU-bound hold is higher and the clock rises faster: Metal encoding on its own thread (`DM_NVN_ENC_THREAD=1`, the profile's `enc_thread on`; the submit thread's translation 6.5 → about 4 ms a frame, measured on an M4 Pro and for Need for Speed), and a smaller first segment. Neither can start the GPU before the game's own work after its wait, and neither was measured on this symptom yet.


### A derived profile drops the frame step its build lacks

- **Symptom:** a game whose profile has a frame step (`FRAME_STEP` in its `patches.py`) runs at double speed when packed from another update; the log has no `frame step in whole vsyncs` line, and the frame counter shows 60.
- **Cause:** the NSP script packs a version the profile was not brought up on from a derived profile (`derive_profile` in `scripts/build_ipa_from_nsp.py`), which copied `fps_target 60` unchanged. The frame step stubs are keyed by the main's build ID (`patch_nso.py`), so the other build's main gets no stubs and no state page. Without the page, `fps_present_cap()` (`hle_nvn.c`) took the profile's 60 as the present cap and presented every vsync, while the game still stepped 1/30 s per frame at its own present interval 2.
- **Fix:** `derive_profile` reads the build IDs of the profile's `FRAME_STEP` table (with `ast`; the file is not run). When the table has entries and none is for the derived main, the derived `game.tsv` gets `fps_target 0` with a comment saying why, so the game keeps its own step and the launcher shows no Frame rate choice. Registered builds, `shader_only` derivations and profiles without `FRAME_STEP` keep the key as it is.
- **Found on:** [The Legend of Zelda: Tears of the Kingdom](../../dm/games/0100F2C0115B6000/README.md) 1.4.2 (v655360; the profile registers 1.4.3), reported in LNPANDY/NX2Apple#303 and traced by fredchu ([PR #794](https://github.com/seedds/nxnative/pull/794)).
- **Check:** `test_nsp_incremental` (`test_derived_main_without_frame_step_stubs_keeps_the_games_step`: stubs for another build only, stubs for the derived main too, no table, `shader_only`). fredchu measured 30 fps at real speed on 1.4.2 on an M1 Max; not packed here from a real 1.4.2 NSP, and not run on an iPhone.

### A present cap applies only with a frame step or an engine limit the host writes

- **Symptom:** a fixed-step game runs at double speed (or slow motion at 30) after `DM_FPS_TARGET` is set, in `Documents/env.txt` or the launcher, on a build its profile's frame step stubs and limiter rows do not know.
- **Cause:** without a frame-step state page, `fps_present_cap()` (`hle_nvn.c`) took `DM_FPS_TARGET` or the profile's `fps_target` as the present cap for any game, on the assumption that an engine limiter in `hle_fpslock.c` makes the game run at that rate ([Engine-native frame caps also govern presentation](#engine-native-frame-caps-also-govern-presentation)). Those limiters are build-gated (fixed addresses) or found by name, so on another build none may apply: the host then presented at the cap while the game stepped by its own present interval. [A derived profile drops the frame step its build lacks](#a-derived-profile-drops-the-frame-step-its-build-lacks) keeps the profile's key from asking for it; the environment could still ask.
- **Fix:** `dm_fps_lock_engine()` (`hle_fpslock.c`) says whether the host writes the target into an engine limiter of the running main: a row for its build, NieR's frame interval, Unreal's variables or Frostbite's simulation rate. The search runs once (`pthread_once`), from the first present or from the acquire's present cap, whichever asks first. Without a state page and without such a limiter, `fps_present_cap()` returns 0 and logs `present cap … not applied`, so the game's own present interval stays. Every registered build with a nonzero `fps_target` has a frame step or a limiter, so none changes.
- **Found on:** [The Legend of Zelda: Tears of the Kingdom](../../dm/games/0100F2C0115B6000/README.md) 1.4.2 with `DM_FPS_TARGET=60`, from reading the code after [PR #794](https://github.com/seedds/nxnative/pull/794); not reproduced in a game run.
- **Check:** `t_fpslock` (`dm_fps_lock_engine` in every case, asked before the first present in half of them: limiter rows, Unreal, Frostbite, NieR's build gate, another build's main, a main without Unreal variables); `t_nvn` (no limiter: the 60 and 30 caps are not applied and the game's interval stays; a state page needs no limiter).

### Engine-native frame caps also govern presentation

- **Symptom:** selecting 30 FPS changes the engine's interval field, but the screen and frame loop still run near 60 FPS.
- **Cause:** presentation took its cap only from the optional frame-step state page. Games with engine-native frame limiters have no such page, so a profile using display pacing continued to present every tick despite `DM_FPS_TARGET=30`.
- **Fix:** resolve a presentation cap from the launcher environment or profile when no frame-step page exists. Use it for the present interval and display-link target without enabling frame-step logic. Existing state-page, movie and game-override behavior keeps precedence; target zero retains the profile's interval semantics.
- **Check:** `t_nvn` verifies literal 33,333,333 / 16,666,666 ns periods for 30 / 60, display-link targets, disabled frame-step logic for native engines, target zero and movie/state-page precedence. All 134 NVN checks pass with Metal validation.
- **Found on:** [NieR:Automata iPhone controls](../../dm/games/010056B015FE8000/README.md#iphone-resolution-and-frame-rate-controls).

### Resolution policy is independent of the frame-rate cap

- **Symptom:** the game always renders at full resolution, or turning off dynamic resolution still allows the native controller to lower it.
- **Cause:** fixed-resolution code cuts bypassed native scale clamping and UV publication, while the frame-limit handler also overwrote scale and counters. Restoring only those instructions is insufficient when an additional mode-specific lower bound can override the selected preset.
- **Fix:** the verified build retains native scale/UV publication and makes its lower bound follow the selected preset. Apply the resolution choice independently of the FPS limiter. In dynamic mode the game owns current scale and timing counters; fixed mode selects the full-resolution minimum. Verify the build and required instructions before enabling it. Rewriter `CAPABILITIES` metadata offers the launcher control only for supported rewrites; older fixed-resolution images omit it.
- **Check:** production memory-policy tests cover 0/30/60 FPS, on/off/unset/invalid choices, older code and unmatched builds. An asset-backed fixture executes the actual native controller with only GOT addresses relocated, checking both console modes, scale reduction under load, correct UV publication, full-resolution recovery and the fixed-resolution path.
- **Found on:** [NieR:Automata iPhone controls](../../dm/games/010056B015FE8000/README.md#iphone-resolution-and-frame-rate-controls).

### 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_TOP` at the start of the frame's command buffer, `TIMESTAMP` at 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 segment` makes each report take the GPU start (`TIMESTAMP_TOP`) or end (other types) of the `sub_cut` segment ([Submissions handed to the GPU in segments](#submissions-handed-to-the-gpu-in-segments)) it was encoded in (`rep_ticks` in `nvn_mtl.m`: MTL4CommitFeedback times converted to the guest clock, written in the last segment's handler as before). `DM_NVN_GPU_TS=submission` gives the old timestamps.
- **Found on:** [The Legend of Zelda: Breath of the Wild](../../dm/games/01007EF00011E000/README.md) (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 with `DM_NVN_GPU_TS=submission` spent 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 `nvnDeviceGetTimestampInNanoseconds` saw 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.h` shares the 614.4 MHz conversions between all report writes (`gpu_ticks`, `rep_ticks`) and `nvnDeviceGetTimestampInNanoseconds`. Wide integer products preserve long uptimes. The guest clock epoch stays the same; CPU system counters stay at 19.2 MHz (31.25 MHz on the Switch 2: [next entry](#the-switch-2s-system-tick-is-3125-mhz)), and the API still returns nanoseconds.
- **Found on:** [NieR:Automata The End of YoRHa Edition](../../dm/games/010056B015FE8000/README.md), whose dynamic-resolution controller subtracts two raw report timestamps and converts the difference inline.
- **Check:** `t_timestamp` checks known vectors, long uptimes, round-trip precision and the game's exact inline arithmetic. `t_nvn` checks the API's known units and bounds a completed report between submit and fence wait.

### The Switch 2's system tick is 31.25 MHz

- **Symptom:** a Switch 2 game holds 20 or 30 fps on the Mac although its frame work fits in one vsync: its frame-rate controller raises the present interval (`the game sets present interval 1 → 3` in the log) and never steps back down.
- **Cause:** the Switch 2's SDK converts the system counter at 31.25 MHz (`nn::os::GetSystemTickFrequency` answers `0x1dcd650`; it reads `cntpct_el0` itself and converts with that constant), while the host served the counter (the `cntpct` gate and `svc GetSystemTick`) at the Switch's 19.2 MHz. Every span the game measured in ticks came out 1.63 times too short, and a nanosecond time it converted to ticks (`nn::os::ConvertToTick`) landed 1.63 times later than a counter read taken at the same moment. A controller that compares a GPU timestamp with a CPU tick stamp then saw a frame that ended long after it started.
- **Fix:** `guest_ticks` (`hle_os.c`) gives the counter on the guest clock at the rate of the game's SDK: 32 ns a tick when the `sdk` module names its spec `Ounce` (`dm_sdk_ounce`), 19.2 MHz otherwise. Both the `cntpct` gate (`hle.c`) and `svc GetSystemTick` (`hle_svc.c`) read it. NVN's 614.4 MHz report timestamps ([Raw NVN timestamp units](#raw-nvn-timestamp-units)) are unchanged.
- **Found on:** [Donkey Kong Bananza](../../dm/games/040074A01BF12000/README.md): its controller (`main+0x204c20`, called from `main+0x204a64` and `main+0x203718`) measures `ConvertToTick(nvnDeviceGetTimestampInNanoseconds(report))` minus a tick stamp taken at the frame's start (`main+0x204618`); a probe read 0x11d69767da4 against 0xaf5b673f04, a ratio of 1.6276 = 31.25 / 19.2. It set interval 3 and ran at 20 fps (50 ms frames); with the fix its first level runs at 60 fps (frame time p50 16.7 ms over 200 s, interval 1, raised only for a moment at loading hitches).

## 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.targetFrameRate` to 30. Changing NVN's present pacing does not change the engine's own limiter.
- **Fix:** `hle_fpslock.c` supports integer FPS fields alongside float FPS, milliseconds and vsync counts. Its build-gated row keeps the verified field at the profile's `fps_target` (`DM_FPS_TARGET` overrides it), preserving unset values such as 0 and -1. `present_interval display` removes the separate interval-2 present cap.
- **Found on:** [Pokémon Brilliant Diamond](../../dm/games/0100000011D90000/README.md), 1.3.0; its note records the setter, field and gameplay timing checks.
- **Check:** `t_fpslock` exercises 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 at `main+0x167a6ec`, loaded at `main+0x667904` and passed to `main+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](../../dm/games/01004AB00A260000/README.md) (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, polling `GetSystemTick` and 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's `SleepThread` and 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 in `dm_guest_sleep_ns` under `main+0x443d74`, and in `nvnWindowAcquireTexture` under 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](../../dm/games/0100346017304000/README.md), in its menus and town; its movies, paced by their own player, ran at 60.

### A target frame rate the game steps by

- **Symptom:** a Switch 2 game runs its title and menus at 60 fps and its gameplay at 30, asking for present interval 2, with the GPU mostly idle.
- **Cause:** the game's choice. Its graphics object (a global pointer) holds the display rate, a target frame rate and the longest interval; the present asks for interval min(longest, display / min(display, target)). Three stores set the target, 60 for menus and 30 for gameplay, and 59 readers step the game by 60.0 / target, so a frame advances it 2.0 at 30 and 1.0 at 60.
- **Fix:** the rewriter finds the field by the code that reads and sets it, in any main and with no build ID (`dm/tools/fps_target.py`, run by `patch_nso.py`), and names it in the fixups (`FPS_TARGET_PTR`, the global pointer; `FPS_TARGET_FIELD`, the field). A reader loads the field into a float register, converts it and divides 60.0 by it (`ldr sN, [xB, #F]`; `scvtf`; `fdiv` by a 60.0 made by `movz` + `fmov`), with `xB` the global's value (`ldr xB, [xA]`, `xA` made by `adrp` + `add` or read from the global's GOT slot); a store sets it to an immediate 30 or 60. A field with at least 8 readers and a store of 30 is the target, and exactly one must be: two give a red line and no write. These are the semantics writing it relies on, the game's step following the target, so the search names no engine. `hle_fpslock.c` writes the profile's `fps_target` there whenever it differs; the game then asks for interval 1 itself, and its step follows. At `DM_FPS_TARGET=30` the host writes 30 everywhere, menus included, where the game itself chose 60.
- **Check:** `dm/tools/tests/test_fps_target.py` (synthetic code: the global and its GOT slot, too few readers, no store of 30, another dividend, two fields); `fps_target.py <main>...` prints what it finds in each. Before giving a game `fps_target` on this field, check that its readers step the game by it: a target the game only presents by, with logic that counts frames, would run the game fast.
- **Found on:** [Fire Emblem Fortune's Weave](../../dm/games/04007EF01F8AE000/README.md) v131072 (its gxc graphics object, +0x1dc): gameplay at 60 fps, frame time p50 16.7 ms on an M4 Pro. Checked offline on 100 mains on the drives (2026-11-03), both Paper Marios on Intelligent Systems' older engine among them: the search finds this field (55 of its 59 readers have the form it matches) and, in every other main, not a single reader.

### 120 fps follows the frame step

- **Symptom:** with a game's target frame rate written to 120 and a 120 Hz display link, the game holds 120 where frames fit, but a scene whose frames take 14 ms runs in slow motion at 72 fps, and on a 60 Hz screen the whole game runs at half speed.
- **Cause:** the game's choice: it may step by its target whatever the frames really take ([A target frame rate the game steps by](#a-target-frame-rate-the-game-steps-by): 60.0 / target a frame). A target of 120 advances the game half a step per frame, so its speed is frames a second / 120. Whole-vsync frame steps cannot take 120 at all: their stubs hand the game whole 1/60 s vsyncs.
- **Fix:** `fps_max 120` in a profile lets `DM_FPS_TARGET` take 120 (the launcher's **Frame rate** offers it on the Mac only, through the option condition in `settings.tsv`). `fps_high` in `hle_nvn.c` then runs the frame step's controller ([nvn_fstep.h](../../dm/gfx/nvn/nvn_fstep.h)) with no state page, counted in ticks of 1/120 s: hold 1 is 120 fps, hold 2 is 60, and `hle_fpslock.c` writes the target from the hold (`nvn_fps_high_now`), so what the game steps by stays what the screen shows. `nvn_fstep.per_vsync` keeps the waits between trials the same real time. The cap is 60 on a screen below 120 Hz, while a movie plays and under the thermal cap. The display link follows the screen (`mtl_link_mode_set`, `DM_PRESENT_LINK` overrides), and on a screen whose rate is not a multiple of 120 it counts virtual ticks ([Virtual 60 Hz ticks](#virtual-60-hz-ticks-on-screens-that-are-not-a-multiple-of-60)): 144 ticks a second would run a game that steps by its target 1.2 times too fast. `fps_hold off` does not apply at 120, since a frame shown late without the target falling is slow motion. A vsync count such as `rhi.SyncInterval` stays 1 at 120.
- **Check:** `t_fstep` (kmin 1, kmax 2: frames that fit hold 120, late ones step to 60 and stop there, a cap of 60, the return), `t_link_ticks` (the real acquire at each hold on 120 and 60 Hz links, a game without `fps_max`), `t_fpslock` (the target and Unreal's limit following the hold, the interval staying 1, a game without `fps_max` at 60), `test_settings.py` (the option). Before adding `fps_max 120` to a profile, measure the game's speed at 120 against 60 over the same inputs (below): a game whose logic counts frames runs at double speed. Not run: a real screen at 60 or 144 Hz, or an iPhone.
- **Found on:** [Fire Emblem Fortune's Weave](../../dm/games/04007EF01F8AE000/README.md). Also on, with its speed measured: [Mario & Luigi: Brothership](../../dm/games/01006D0017F7A000/README.md), through Unreal's `t.MaxFPS` and `rhi.SyncInterval` ([found by name](#unreals-frame-limit-and-present-interval-found-by-name)), with no host change.

### Unreal's frame limit and present interval, found by name

- **Symptom:** an Unreal Engine game holds 30 fps with spare CPU and GPU time and asks for present interval 2; `present_interval display` alone does not help, as the engine's game thread sleeps out its own frame limit.
- **Cause:** two console variables the game's config sets: the frame limit `t.MaxFPS` (30), which the game thread sleeps out before stepping the game by the time that really passed, and the present interval `rhi.SyncInterval` (2; `nvn.SyncInterval` in UE 4.18's Switch RHI). Some games set the limit through a variable of their own that their settings apply to `t.MaxFPS` (SHIN MEGAMI TENSEI V: Vengeance's `Project.FrameRateLimit`), so writing `t.MaxFPS` alone loses to the next settings change.
- **Fix:** the rewriter finds the variables by their registered names in any Unreal main, with no address per build (`dm/tools/ue_cvar.py`, run by `patch_nso.py` on `main`), and names each one's `TAutoConsoleVariable` in the fixups (`UE_CVAR:<name>`). It finds the name (UTF-16, or UTF-32 in older builds) in main's read-only data and the code that loads it into `x1` (`adrp` + `add`); the first call after that, with the name still in `x1`, decides. A `bl` with `x0` an address made by `adrp` + `add` is the variable's constructor (UE 4.18), and `x0` is the object. A `blr` is the console manager's register call (UE 4.2x and 5), and the object is the address made by `adrp` + `add` that the value pointer is then stored into (`str x0, [xN, #0x10]`; UE 5 calls a base constructor in between). Registers are followed from the last call before the name, as UE 4.18 loads the object first; registers a call or a load overwrites are forgotten, and the next variable's name ends the search. Anything else (a lookup by name, a log line) is no registration, and a name registered at two objects gives none, with a red line. `hle_fpslock.c` writes the profile's `ue_fps_cvar` (default `t.MaxFPS`) to `fps_target` and the present intervals to 60 / `fps_target`, both threads' copies, whenever they differ; an Unreal main without the profile's variable is a red line with a target set.
- **Found on:** [Octopath Traveler 0](../../dm/games/01005270232F2000/README.md), [OCTOPATH TRAVELER II](../../dm/games/0100A3501946E000/README.md), [DRAGON QUEST VII Reimagined](../../dm/games/0100A9D01C446000/README.md), [DRAGON QUEST XI S](../../dm/games/01006C300E9F0000/README.md), [SHIN MEGAMI TENSEI V: Vengeance](../../dm/games/010069C01AB82000/README.md), [FINAL FANTASY RESONANCE](../../dm/games/0100606026F12000/README.md) and its [Asian release](../../dm/games/0100B8A026F14000/README.md), which had 13 rows of fixed addresses until 2026-10-30. Checked offline on 61 mains: it finds exactly those rows' objects in the six on the drives (DRAGON QUEST XI S and SHIN MEGAMI TENSEI V: Vengeance from the archive drive), including DRAGON QUEST XI S's `t.MaxFPS` constructor, which the host's earlier run-time search (`dm_ue_cvar`) passed over to the next variable's registration; nothing in a game that is not Unreal. Open: [OCTOPATH TRAVELER](../../dm/games/010057D006492000/README.md)'s inlined constructors keep a page base in `x22` from far before the name and store the value pointer at an offset from it (`str x0, [x22, #0x5e8]`), a form the search does not follow: its variables are not found (no write, no red line without a target).
- **Check:** `dm/tools/tests/test_ue_cvar.py` (synthetic code: the constructor form with the object loaded first, the register form with a base constructor between, a lookup, a double registration, UTF-32 names); `t_fpslock` (the rows from main's table, a present interval alone, a main without variables); `ue_cvar.py <main>...` prints what it finds in each.

### Frostbite's simulation rate, found by name

- **Symptom:** a Frostbite game holds 30 fps with spare CPU and GPU time. Raising only the frame limit and the present interval gives 60 frames a second with the game at half speed (the match clock at 3.6 game seconds a second instead of 7.5).
- **Cause:** Frostbite's `SimulationTimeSettings` caps the simulation's ticks a second: `ForceSimRate` (int32) and `MaxSimFps` (float), both 30 in EA SPORTS FC 27. The game's other 30s (its own frame regulator, the present interval 2 of `BaseDisplaySettings.PresentInterval`) only pace the frames.
- **Fix:** `hle_fpslock.c` finds the settings by name, so no address is tied to a build. Frostbite reflects its settings classes: a class record (its name, then at +0x40 its field list) and a 0x20-byte record per field (name, hash, offset at +0x10, type). A static constructor stores the record's address into the class's type info (`adrp` / `add` of each, then `str`), and code that reads the settings asks the settings registry for the instance with the type info in `x1` and a cache word in `x2`, which keeps the instance's address; the instance holds its type info at +8, which checks the word. With `fps_target` 30 or 60 the host writes both fields to the target each frame they differ. A game's own frame limit still needs its row (EA SPORTS FC 27's frame regulator, build-gated) and `present_interval display`. Without the row, as on a build not yet registered, the simulation at 60 with the regulator at 30 runs at 30 fps at the right speed (match clock as at 30).
- **Found on:** [EA SPORTS FC 27](../../dm/games/0100C49025D3E000/README.md): 60 fps in a match with the match clock at the 30 fps rate. Its `main` has unwind entries for 405 functions only, so the rewriter's function matching cannot carry a cut of the regulator across updates either.
- **Check:** `t_fpslock` builds a Frostbite module in miniature (strings, class record, field list, registration and lookup code, instance) and checks the writes, a repeated game write and a cache word whose instance has another type info.

## 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_wait` requests rescue. `link_sync` invalidates 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 take `g_pq_mu`; the requested `g_dm_present_link` stays 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 MacNativeGames `cf1e629` and [PR #198](https://github.com/seedds/nxnative/pull/198). That upstream title has no profile in this repository. Mac window occlusion also affected [ANIMAL WELL's image validation](../../dm/games/010020D01AD24000/README.md#files): foreground rescue and out-of-focus fallback produced incomparable timings; its note records the controlled comparison. [Xenoblade Chronicles 2's controller motion review](../../dm/games/0100E95004038000/README.md#controller-motion-review) also records stalled-link timing on both hosts and the paired diagnostic runs.
- **Check:** `make -C dm/gfx/tests GAME=010015100B514000 t_present_rescue` and `dm/gfx/tests/t_present_rescue` exercise 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_rescue` adds a paused real Metal display link on an offscreen Mac layer: rescue invalidates it, then `nextDrawable` succeeds (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.

### Long frames a test run makes itself

- **Symptom:** `[longframe]` lines of 40–180 ms with no new pipelines, a short submit and normal GPU time, which read like CPU hitches in the game: single frames of 60–100 ms, or bursts of 5–12 frames, one of 140–180 ms and the rest about 40 ms.
- **Cause:** the run, not the game. Each `shot` line of `DM_CTL` costs the frame it captures 60–100 ms, so a script that takes a shot every few seconds prints a long frame every few seconds. Each retry of a [silent display link](#present-thread-rescues-a-silent-display-link) waits out the silence for about 0.5 s, and those bursts repeat 3, 6, 12, 24, 48 and then every 60 s, with `display link: rescue ended` just before them. The link was silent in runs with no session on the Mac's display and ticked once a remote session had connected (seen across runs, not tested on purpose). Losing focus (`oe focus state → 2`) adds its own long frames and halves the rate.
- **Fix:** no code change. Shots are diagnostics only, and the retries need a silent link; a link that stops while its picture is visible, as on the iPhone in the entry above, would show the retry as a stutter of about 0.5 s every 60 s. To measure frame times, take no shots, and read a long frame's cause from the log before looking for it in the game: the shot or `rescue ended` line before it, and the focus lines. A stall that repeats on a timer is worth timing first: the doubling gaps gave the retry away.
- **Found on:** [Princess Peach: Showtime!](../../dm/games/01007A3009184000/README.md#frame-rate): of 491 long frames in one 18-minute run, 315 were link retries and 53 followed a shot; with neither, 112 s of lobby and play had none.

### 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 error` with `kIOGPUCommandBufferCallbackErrorPageFault`.
- **Cause:** `present_now` cycles three Metal 4 command allocators, each with its command buffer and UV buffer, by ticket, and before resetting one it waited for `g_pq_done` to reach the ticket that last used it. `g_pq_done` is not GPU completion: every display-link callback sets it to `ticket - 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 in `present_now` until the GPU has run the allocator's previous present. `g_pq_done` keeps its meaning for the game's swapchain acquire.
- **Found on:** no game. Found by reading `NvMetalDisplayLinkDelegate`, then reproduced in `t_present_alloc` (below).
- **Check:** `make -C dm/gfx/tests GAME=010015100B514000 check_t_present_alloc` holds 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 `display` mode 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 at `main+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 `game` mode (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_until` in `nvn_mtl.m`; the `display link up to tick` log line counts these ticks as `held for the present interval`), and the 30 fps target that drops the display link to 30 Hz (`nvn_game_fps` in `hle_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_tick` now 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.
- **Found on:** [Super Mario Bros. Wonder](../../dm/games/010015100B514000/README.md) (1.2.1, iPhone: the display-link hold); [The Legend of Zelda: Breath of the Wild](../../dm/games/01007EF00011E000/README.md) (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 with `DM_WINDOW=1` to get the display link. `DM_NVN_TSV` writes 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.

### Odd present intervals on the 30 Hz display link

- **Symptom:** Donkey Kong Bananza ran its heavy areas at 15 fps where its GPU took about 46 ms a frame: the frames came every 66.7 ms, and the presentation thread spent 85% of its time in `nvnWindowAcquireTexture` waiting for display-link ticks.
- **Cause:** after a second at interval 2 the display link drops to 30 Hz (`nvn_game_fps`). When the game then stepped to interval 3 (20 fps) the link stayed at 30 Hz, where 3 vsyncs are (3 + 1) / 2 = 2 ticks (`mtl_link_wait_tick`): 66.7 ms for the 50 the game asked for. The comment said such intervals "run at 30"; they ran at 15.
- **Fix:** only an even interval counts toward the 30 Hz link; an odd one returns to the 60 Hz link at once, as interval 1 does, and takes its own 3 ticks (`present_period` in `hle_nvn.c`). The one-second test no longer wraps: the run's start is stored with `| 1` and could be 1 ns after the next clock read, which made `now - start` huge and asked for 30 Hz at once.
- **Found on:** [Donkey Kong Bananza](../../dm/games/040074A01BF12000/README.md): the slow area on an M1, frame time p50 66.7 → 50.2 ms at interval 3 (`DM_NVN_TSV` `@frame_ms`, `@itv`, `@link_gap_ms` 33.3 → 16.7).
- **Check:** `t_nvn` (`interval 3: 60 Hz link`, `back to 2 from 3 waits a second again`).

### One frame per tick of a 120 Hz display link

- **Symptom:** with `present_interval display`, `DM_PRESENT_HZ=120` and `DM_PRESENT_LINK=2` (a 120 Hz display link) a game stays at 60 fps although its frame takes 8 ms; the display link logs about two ticks per present. With a requested 90 Hz, truncating the tick conversion instead allows 120 fps (reproduced through the real acquire with simulated link ticks).
- **Cause:** the acquire waits for the present interval's vsyncs on the display link (`mtl_link_wait_tick`), counted in 60 Hz vsyncs, so two ticks of a 120 Hz link per frame; display mode's interval is 1. Dividing the link rate by the requested rate once per acquire loses fractional ticks (120 / 90 becomes 1).
- **Fix:** in display mode above 60 Hz the acquire waits as many link ticks as one tick of the present rate takes (`link_wait` in `hle_nvn.c`, `mtl_link_wait_ticks`): one at 120 Hz. Fractional ticks carry across acquires: at 90 Hz, three frames wait four link ticks (1, 1, 2); a rate or pacing-mode change starts a new phase. Rates at or above the link's rate wait one tick.
- **Found on:** Batman: Arkham Knight (`0100ACD0163D0000`; its profile and note are in a PR of their own): 119.8 fps in the opening cutscene on an M2 Max.
- **Check:** `t_link_ticks` (also run in CPU-only CI): with a controlled clock and one tick delivered per timed wait, a 60 Hz vsync waits two ticks of a 120 Hz link and one tick of a 60 Hz one, and a wait in the link's own ticks takes one tick per frame. The real acquire checks display pacing at 60, 75, 90, 120 and 240 Hz, game intervals and frame-step caps, including the 60 and 30 Hz link modes. Exact tick counts, already elapsed ticks, spurious wakes and silent-link timeouts are checked without depending on OS scheduling: a sleeping ticker could miss the acquire's wall-clock timeout on CI. These checks do not measure a game's frame rate on a screen.

### 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.m` `snap_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.txt` overrides it (`DM_PRESENT_SNAP=1` on, `0` off); 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 ledger `DM_NVN_WAITS=1` counts 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_drain` in `mtl_drain`). Submission fences and upload completion do not cover these copies; previously suspend could return while the encoder still held a copy to submit.
- **Fix:** with snapshots on and the display link ticking, the acquire waits for the link's present-interval ticks like any present (`nvnWindowAcquireTexture` in `hle_nvn.c`); only a silent link falls back to the texture's slot time. The wait for a free ring slot alone did not pace: the link takes frames in order and drops all but the newest two, and a dropped frame frees its slot. Donkey Kong Bananza at the 30 fps cap ran 43-65 fps of game time on an M1 Pro and 45-50 fps on an iPhone 15 Pro Max, each frame still stepping 1/30 s (fast-forward), and about 110 of every 300 frames never reached the screen; after, 30.0 fps and 300 of 300 frames on screen (Mac; measured by the author of PR #1073 on their build). The game still runs up to a frame ahead of the screen: the link wait limits how often a frame starts, not how far ahead of the display it is. `t_link_ticks` checks the acquire with snapshots on: two ticks of a 60 Hz link at interval 2, one at interval 1, none while the link is stalled.
- **Check:** `make -C dm/gfx/tests GAME=01007EF00011E000 check_t_present_snap` uses 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](../../dm/games/01007EF00011E000/README.md) (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).

### A replaced snapshot ring texture stays resident until the slot's next use

- **Symptom:** none seen in a game. When the presented texture changes size or format while snapshots are on, a display command buffer still queued could sample the ring slot's old texture outside the residency set, which faults the queue (`kIOGPUCommandBufferCallbackErrorPageFault`, then every later command buffer ignored), as in [A present snapshot's copy needs the same residency commit](rendering.md#a-present-snapshots-copy-needs-the-same-residency-commit).
- **Cause:** `snap_take` replaces a slot's texture only once the slot's last present has been processed (`g_pq_done`), and it removed the old one from the snapshot residency set right away. A processed present has been committed, not run: as with [the present allocators](#a-present-command-allocator-is-reused-only-after-its-own-present-completes), the display-link callback sets `g_pq_done` before the GPU runs the present.
- **Fix:** the slot keeps its old texture (`g_snap[k].old`) and removes it from the residency set at the slot's next use. That use already waits for the replacing copy's event, and the queue signals that event only after all the work committed before it, which includes every present that read the old texture. Memory changes only when the presented size or format does: the old texture lasts one more round of the ring.
- **Found on:** no game. Found by reading `snap_take` while checking [GitHub issue #626](https://github.com/seedds/nxnative/issues/626) (whose logs show one size throughout).
- **Check:** `make -C dm/gfx/tests GAME=010015100B514000 check_t_present_snap` runs `t_present_snap resize`, with and without the encode thread. It holds the queue behind a shared event in place of queued display command buffers and presents a 16x16 texture after two 8x8 ones. The old textures stay in `g_snap_res` while the queue is held, also through a present that falls back because its slot's copy is still queued, and each leaves at its own slot's next use. 13 checks pass with Metal validation; with the old removal, 4 fail.
- **Not checked:** a game that changes its presented size with snapshots on, and the iPhone.

### 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 tick` lines 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::GetDisplayVsyncEvent` thread 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.m` acquire → `mtl_link_wait_tick`, as NVN's; `hle_sdk16.c` vsync thread → `mtl_link_wait_vsync`), with the old clocks as the fallback.
- **Found on:** [Super Mario Galaxy 2](../../dm/games/0100FD8022DAA000/README.md) (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`).

### A game window another process starts opens behind other windows

- **Symptom:** a `regress.py` run of an NVN game stays at 20 fps from the start (`frame N not reached`, a NEW-FAIL) in some runs only: `frame step: hold 1 → 2` and `2 → 3 vsyncs` in the first 15 presents, then `display link recovered: stalled 1017 ms` about once a second to the end, and no trial back to a shorter hold.
- **Cause:** the host is started by another process, so macOS does not activate it (`oe focus state → 2` in every run), and its window opens wherever it lands in the window order. Behind another window it is covered, and macOS fires a covered window's display link about once a second. The late frames of the boot step the hold up, which happens in every run, and the stall each second makes a frame late again before the 30 frames in a row that a trial needs. A run whose window happened to be on screen came back to 1 vsync with a probe.
- **Fix:** `DM_WINDOW_TOP=1` orders the window in front without activation and keeps it above other apps' windows (`front_mac.m`). `regress.py` sets it for its runs and captures. The host logs every change of the window's visibility (`front end: window covered`, `window visible`), and `regress.py` runs a route once more when its window was covered (`, window covered` in the report when the second run was covered too). A revision before this change ignores the setting, so its runs can still be covered.
- **Found on:** [Breath of the Wild](../../dm/games/01007EF00011E000/README.md) (2026-10-28 and 29: every one of its 20 fps runs had 21 to 115 stalls, the runs at 60 fps 0 to 7).

### 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](#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=0` keeps 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](../../dm/games/01007EF00011E000/README.md) (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.m` `front_screen_match`, called from `link_sync` with the link's rate (60, 30, or 120 with `DM_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), with `kCGConfigureForAppOnly`, 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=0` keeps the screen's rate.
- **Found on:** PR #79: the PR's author ran [Hades](../../dm/games/0100535012974000/README.md) 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.m` asked 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 with `DM_SCREEN_MATCH=0` nothing switched.
- **Not checked:** the link's new range after a switch has not run here (Hades windowed and fullscreen paced at 60 as before).

### macOS Game Mode holds the GPU at its lowest clock

- **Symptom:** 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`.
- **Cause:** macOS's Game Mode, not the host. A full-screen Metal app of about 100 lines, with no host code and about 6.5 M GPU cycles a frame, ran at 618 MHz and 9.5 ms for about 160 s in Game Mode, then at 338 MHz, 18 ms and 94% busy to the end of its 5 minutes. With Game Mode off it ran at 924 MHz and about 7 ms and spent no time at 338 MHz (one run each, M4 Pro on AC power; the run with Game Mode off also sat at a flat 56.3 fps from 120 s, unexplained). Why Game Mode does this is open, and no public API is known that keeps the clock up while Game Mode is on.
- **Fix:** a workaround for a platform limit: `dm/mac/pack_app.py` writes `LSSupportsGameMode` false, Apple's documented off switch, and keeps the games category. In the `gamepolicyd` log a new bundle with the key false gets no gaming session. The cost, not measured, is what else Game Mode does: doubled Bluetooth sampling for controllers and AirPods, and lower priority for background tasks.
- **Limit:** macOS remembers an app it has labelled a game. On a bundle ID already launched in Game Mode, with the key true or omitted, re-packing with the key false kept Game Mode on, also with `GCSupportsGameMode` false and after `lsregister -u` (5 launches with the key off, same version number; a version change is not checked). So a re-packed app needs the player's switch once: in full screen, Command-Esc, Game Overlay, Settings, Game Mode; macOS keeps that per game. A reporter rebuilt a known bundle with an `entertainment` category and got no Game Mode; not checked here.
- **Caution:** do not script full-screen launches in a row. After eight in about three minutes on macOS 27.2, `WindowServer` logged `timed out fence` from 12 s after the last app quit, with none in the 85 minutes before, and the Mac panicked (`userspace watchdog timeout: no successful checkins from WindowServer`). Another session's game had exited 18 s before the last app quit, so the launches are the likely trigger, not a proven one.
- **Found on:** [The Legend of Zelda: Tears of the Kingdom](../../dm/games/0100F2C0115B6000/README.md), by two reporters on M4 Pro Macs ([GitHub issue #349](https://github.com/seedds/nxnative/issues/349)).
- **Check:** `test_nsp_mac_app.py` checks the key. To see a game's state: `/usr/bin/log show --last 10m --predicate 'process == "gamepolicyd"' --style compact | grep -E "Found game|Game mode|DPS"`.
- **Not checked:** a game packed with the key false (the reporter's runs turned Game Mode off with the Game Overlay switch and with the category); other Apple chips; iOS, whose project still declares the key true (the same collapse is only suspected there, PR #314).

### A game opened from a fullscreen app stays a window

- **Symptom:** 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.
- **Cause:** the launcher lives in its own fullscreen Space, so opening the game makes macOS leave that Space. `front_run` asked for fullscreen on the next run-loop turn. A two-app harness showed the app active but its window not yet on the active Space (`isOnActiveSpace` 0), with the Space change arriving 0.3 to 0.5 s later. AppKit can refuse fullscreen during another animation or user gesture, reporting it only to the window delegate (`windowDidFailToEnterFullScreen:`, there is no notification). The original harness accepted the early request, but the reporter later reproduced the startup rollback on four of four Hades II launches from NX2Apple's console mode.
- **Fix:** `front_mac.m` `front_run` waits until the app is active and the window is on the active Space (`NSApplicationDidBecomeActiveNotification`, `NSWorkspaceActiveSpaceDidChangeNotification`, whichever comes last), and repeats a refused startup request once. The failure handler acts only on an outstanding startup attempt; successful entry or a player transition while the request is waiting retires it, so a later manual refusal cannot restart startup. `g_fullscreen` and `screen_match_apply` are unchanged.
- **Found on:** [PR #665](https://github.com/seedds/nxnative/pull/665), the NX2Apple launcher's console mode (LNPANDY/NX2Apple), tested by its reporter with [Hades II](../../dm/games/0100A00019DE0000/README.md).
- **Check:** the reporter confirmed that the deferred request entered fullscreen on its first attempt and stayed there, without a refusal. A harness of the startup callbacks checks both readiness orders, bounded refusal recovery, and cancellation before startup or a queued retry, plus later manual failures after successful startup.
- **Not checked:** the revised lifecycle handling in a real game through the launcher's console mode; native refusal recovery (the reporter's deferred request succeeded without it).

### The pointer stays on a Mac game

- **Symptom:** the mouse pointer stayed on the picture of a Mac game, in a window and in fullscreen, until it was moved to a screen corner.
- **Cause:** `front_run` never hid it. macOS hides the pointer only for an app that asks, and a game window is not one.
- **Fix:** `front_mac.m` `front_run` hides it after `DM_CURSOR_HIDE` seconds (3 by default, `0` keeps it) without movement over the game view, and shows it on the next movement, click or scroll (a local event monitor and a 0.25 s timer). It is shown whenever the window is not key or the pointer is outside the game view, so the title bar and other windows keep theirs.
- **Found on:** the NX2Apple launcher's issue #272, no game in particular.
- **Not checked:** not yet run on a game here.

### Frame latency 2 for the display link in fullscreen

- **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=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.
- **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.

### Fixed-rate display-link delay remains at latency 1

- **Symptom:** 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.
- **Cause:** **open**. The reported callback lead was 32.8 ms at both latency settings. Applying the ProMotion latency-2 workaround to fixed-rate fullscreen was an overly broad policy, but requesting latency 1 did not remove this delay in the reporter's measurements. No local fixed-rate timing reproduction or raw trace is available.
- **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`.

## 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 in `arena_alloc` autoreleases it. Reading `buf.length` also sends an Objective-C message on each allocation and while selecting or trimming arenas.
- **Fix:** both declarations of `arena_alloc` use a `__strong` buffer out-parameter, matching every caller's strong local. `NvArena` caches 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](https://github.com/seedds/nxnative/pull/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_vbuf` and `t_geometry` exercise arena-backed uniform shadows, vertex conversion and geometry scratch on the Mac with Metal validation.

### Dedicated c1 constants as slices of a bank

- **Symptom:** a game runs at about 10 fps with little GPU work: the submit thread waits for a command allocator (`mtl_sub_begin` → `mtl_wait_at`), the GPU sits idle between encoders for hundreds of ms (`DM_NVN_GPUPROF`), the Metal 4 `SubmissionQueue` thread spends nearly all its time in `iokit_user_client_trap` (almost all system time), and the `memory up to frame` line shows a residency set of 10,000 objects or more.
- **Cause:** a shader whose c1 constants must have 64 KiB of their own (every shader of a library without a `c1_full.tsv`, which a partial build never writes, and the shaders that file lists) got its own `MTLBuffer`. Each one is an allocation in the queue's residency set, and the kernel's work for every commit grows with that count: the submission queue fell behind, the game's command allocators stayed in flight and its frames waited for them.
- **Fix:** such a c1 is an exclusive, zero-filled 64 KiB slice of the 4 MiB banks the other c1s share (`c1_bank`, `c1_store` in `nvn_mtl.m`): the same memory and the same zeros past its data, 64 slices to an allocation.
- **Found on:** [Hyrule Warriors: Age of Imprisonment](../../dm/games/04003F40232DE000/README.md) with a partial Mac library of 15,344 shaders: 9,854 dedicated buffers (616 MB) of a residency set of 16,118 in its first field became 142 banks of 989. Frame p50 100 → 16.6 ms, p99 400–580 → 100–114 ms, submit wall clock 41–48 → 5.8 ms a frame. Open: the submission thread is still in the kernel about two thirds of the time, and the p99 follows the commits per frame (52–69 ms with `DM_NVN_CUT=0`). A Metal System Trace of 4 s in the field shows 2.8 s of driver time wiring system memory (56,769 wires, 84,954 unwire requests, mostly 512 KiB to 4 MiB): the game's GPU allocations total 7 GB, its own memory pool alone 4,655 MB (a constant, `main+0x19eb098`), on a 16 GB Mac with 9.7 GB wired system-wide and about 160 MB free, so the driver cannot keep the working set wired and re-wires it for each submission.
- **Check:** `make -C dm/gfx/tests GAME=<id> run` passes.

### 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_rmu` is an `os_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](../../dm/games/0100F2C0115B6000/README.md), measured upstream in MacNativeGames commit `5f5de5e` and ported in [PR #195](https://github.com/seedds/nxnative/pull/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_levels` and `t_view_swizzle` exercise production registry lookups and cached views with the new lock on the Mac.

### Hash-table lookups of 8-byte keys

- **Symptom:** in a replay profile, `_platform_memcmp` is most of the time spent in serial lookups (`tex_mtl` through `g_by_serial`).
- **Cause:** `nvn_hm_get` compared each probed key with `memcmp` and the table's run-time key length, a libc call per probe. The serial, live-serial, ghost, view-cache and compute-pipeline tables have 8-byte keys and are looked up several times per draw.
- **Fix:** for 8-byte keys `nvn_hm_get` loads the key as one word and compares words. The hash, probe order and answers are the same; puts and deletes are unchanged.
- **Found on:** MacNativeGames commit `7431eb7` (38 of 55 samples of the serial lookup were `memcmp` in a Tears of the Kingdom profile). Here, a lookup in a 3,000-entry table of 8-byte keys takes 1.5 ns instead of 5.9 ns on an M4 Pro (`clang -O2`, 60 million lookups); no game frame has been measured.
- **Check:** `t_hmap` checks 8- and 20-byte keys against a naive model through random puts, overwrites, deletes and clears.

### 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_cut` in `nvn_mtl.m`, called after `rp_flush` in `OP_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 (`post` ops, `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.
- **Fix:** the draw count starts with the submission (`sub_cut_begin` in `mtl_sub_begin`). It used to start at the submission's first `sub_cut`, which only a render target change reaches, so the draws a submission makes into the target it inherited, before its first target change, were never counted and the first segment came that many draws late. `t_reorder_chain` (case `inherited_target`, alone with `--inherited-target-only`) draws `cut_draws()` times into an inherited target and checks for one cut at the next target change.
- **Found on:** [The Legend of Zelda: Breath of the Wild](../../dm/games/01007EF00011E000/README.md): 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 in `nvnQueueSubmitCommands` for 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_TSV` rows `@gpu_beg_ms` / `@gpu_end_ms` against `@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_render` returns at once), so GPU time cannot yet be attributed to passes. Render-target traffic per frame is in `DM_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_publish` in `sub_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 with `DM_NVN_CUT=0`, 5.6 ms with 64, 5.35 ms with 16.
- **Note:** MacNativeGames moved its cut to 30 draws for the first segment and 40 after (`2fa27d7`; uncapped Tears of the Kingdom in a shrine, 10.77 → 10.60 ms a frame). At this repository's 60 fps cap the size changes nothing where the GPU finishes inside the frame: Breath of the Wild outdoors on an M4 Pro (2026-10-26, the outdoor seed of the [BotW Mac routes](../../dm/games/01007EF00011E000/README.md#mac-routes), frames 3,001–5,700, cut 64) waited on its fence 0.02 ms a frame on average (`@sync_ms`), with GPU span 10.8 ms. The default stays 64; a GPU-bound device (an iPhone) is where a smaller cut could show, through `DM_NVN_CUT`.


### The GPU idles with the next command buffer already committed

- **Symptom:** one frame in 50 or so takes 25-40 ms in a scene where the frame normally fits, with the host on time: `@sync_ms` is high, `@sub_wall_ms` is not, and the frame before has a GPU span (`@gpu_end_ms - @gpu_beg_ms`) of 25-40 ms while the summed `@gpu_ms` has not moved. Runs fall into two states, and on the same code one can have 5 long frames and the next 60. A bad run also shows 186 % CPU against 133 %, and the previous frame still running when the next starts (p50 4 ms, p90 10 ms, against about 0 in a good run).
- **Cause, as far as measured:** every command buffer reaches the kernel through one serial dispatch queue, `com.Metal4.SubmissionQueue`. It was active in 896 of 971 samples, mostly in `iokit_user_client_trap`, with about half a core of system time. The GPU stops 5-40 ms in front of a buffer the host committed earlier, and the stalls grow with the number of buffers queued behind running work: about 12 a frame gave 50-150 long frames per 2,700, 30 a frame gave over 200, and 6 gave fewer than 10. A stall leaves a backlog, the backlog queues more buffers, and so a run stays in the state it fell into. What holds the queue inside the kernel is unknown, as the trap could not be timed (interposing does not reach calls inside the shared cache).
- **Ruled out by measurement:** another process's GPU work (WindowServer and the Claude app together used 1-4 ms inside gaps of 15-40 ms); free memory (the same gap rate at 640 MB and at 140 MB free); wired-memory jumps (no more gaps near them than chance); residency set size (10 to 20,000 allocations, the same 0.06 ms from commit to start in a test program); residency commits (654 to 12 per 300 frames, gaps unchanged); a command allocator per segment; `DM_QOS_MAP=1`; the GPU clock (the compute batch's time does not change across a gap); the display's refresh (no phase lock). A buffer queued behind a running one does not stall in a test program by itself, so the load matters too.
- **Fix:** `sub_cut` in `nvn_mtl.m`, for every game. A cut exists so that the GPU does not sit idle while the host encodes ([segments](#submissions-handed-to-the-gpu-in-segments)), so it commits only when everything committed before it has reported back (`g_seg_inflight`). A GPU that is really idle still gets its cut at the next boundary; a cut for a copy still commits. `DM_NVN_CUT_BUSY=0` commits at every cut. The rule reduces the buffers without explaining the kernel's wait, so it removes the conditions for the stall, not its cause.
- **Timestamps:** with `gpu_timestamps segment` a report takes the GPU start or end of its segment, so the game's measurements depended on those commits. Fewer of them left every report the same time, and Ocarina of Time stopped lowering its resolution (GPU 18 ms in its heavy stretch). The boundaries therefore stay every `cut_draws()` draws, each with a GPU timestamp (`ts_stamp`, written at the start of the next encoder), whether or not the host commits there. Two things were tried first. A commit after each report means about 17 more a frame, and the stalls came back. Each report's own true time made the game's controller go to its lowest resolution, with frames of 13 and 52 ms in turn, so what the game reads here is not what hardware gives it. A timestamp written between encoders cost 0.6 ms of GPU a frame, so it waits for the next encoder. The encode thread cannot write them, so with it, and with no counter heap, every boundary stays a commit.
- **Check:** `make -C dm/gfx/tests GAME=<id> check_t_feedback` (`cut_boundary_case`). Ocarina of Time on its walk, settled, per 2,700 frames: over 25 ms 51-75 in the bad state and 3-6 in the rare good one before; 0-8 in all 7 runs after, the last 2 on the landed code; over 33 ms 12-58 before and 0-2 after; frame p95 18.9-21 ms to 17.8-18.9 ms; in a window p95 17.8 ms, 3 over 25 ms. The heavy stretch holds 16.65-16.8 ms by lowering its resolution, as before.
- **Found on:** [The Legend of Zelda: Ocarina of Time](../../dm/games/0400486024DEC000/README.md#found-on-this-game).

### The set of CPU-written textures hashed by serial

- **Symptom:** the submit thread's CPU samples half in `__CFBasicHashFindBucket_Linear` under `CFSetContainsValue` (from `tex_mtl_` and `tex_by_id`), a few hundred thousand to over a million instructions of translation per draw, the submission growing with the game's CPU-written textures.
- **Cause:** the set of textures the CPU writes (`tex_dirty_upload`, `mtl_tex_cpu_written`) was a CFSet with no callbacks, which hashes a serial as the number it is; serials come in order, so they filled one run of neighbouring buckets, and each lookup of a texture not in the set (nearly every one) probed to the end of the run. Released textures stayed in it.
- **Fix:** the host's own table (`nvn_hmap`, which mixes the key). `mtl_tex_release` removes live membership and the `packaged textures` log line counts live entries. Recorded commands and registered descriptors can still use a finalized texture, including one written after recording: its CPU-write classification moves to a retired serial-to-pool table and remains until that pool is finalized. Both marking and release lock `g_smu` before `g_cpu_tex_mu`, so a dirty-page poll marking an already finalized snapshot cannot put it back in the live set. Retired metadata can accumulate while a pool remains alive; earlier eviction requires tracking snapshot ownership.
- **Found on:** Batman: Arkham Knight (`0100ACD0163D0000`; profile not present here), reported in [PR #321](https://github.com/seedds/nxnative/pull/321). The author's gameplay measurements have not been independently reproduced here.
- **Check:** on an M4 Pro, three runs of one million misses with 6,442 consecutive serials and the same mutex took 1,693–1,707 ms with the callback-free CFSet and 9.6–10.0 ms with `nvn_hmap`. `t_cpu_tex` checks membership, duplicates, release, late dirty-page marking, pool cleanup and lookup cost without a GPU; `--metal` also records a texture-to-buffer copy and descriptor registration, writes before or after recording, finalizes and overwrites the API object, and verifies the pixels on replay. The PR's unconditional deletion failed four pixel checks; preserving retired classification passes them.

### Geometry boundaries submit prepared work early

- **Symptom:** several milliseconds of CPU translation and geometry preparation separate one frame's GPU end from the next frame's GPU start, even though the frame already contains complete geometry passes.
- **Cause:** segmented submission was checked only at the game's render-target changes. Geometry replay creates additional capture/compute/raster encoder boundaries inside one target binding, but their commands stayed on the CPU while the next run was prepared.
- **Fix:** after closing the previous geometry run's encoders and landing deferred updates, check the existing `sub_cut` policy before preparing the next run. Its draw threshold, deferral exclusions, upload flush, final-fence lifetime and segment timestamp handling remain in force. This adds submissions at existing encoder boundaries without adding render passes. `DM_NVN_GEOMETRY_CUT=0` disables the additional checks; `DM_NVN_CUT=0` still disables draw-count cuts overall. GPU dependency and pixel checks pass with either policy.
- **Found on:** [NieR:Automata US](../../dm/games/010056B015FE8000/README.md#library-shadow-sequence-and-geometry-ordering). In an alternating warmed workload of 1,024 geometry/query draws at 1080p, median CPU-encode-to-GPU-completion time falls from 11.605 to 7.800 ms; render passes stay at 17 and all query results agree. GPU execution is essentially unchanged (4.004 versus 4.007 ms), while CPU encoding rises from 6.971 to 7.268 ms: the gain is overlap, not reduced shader work. Gameplay improvement remains unmeasured.

### Reusing upload staging after a slot completes

- **Symptom:** CPU submission spikes during texture updates, with the residency log showing tens of MB of new private staging allocations per frame.
- **Cause:** an upload slot retained a block sized for the first texture in its batch. A small first texture left it at 4 MB, so later textures repeatedly allocated and retired 32 MB overflow blocks even when the same batch shape repeated.
- **Fix:** the slot's next primary block grows to its previous batch demand (capped at 32 MB), and it also retains one 32 MB overflow block. Reuse still follows `up_begin`'s wait for that slot's previous GPU batch. Each retained block is used at most once per batch; larger bursts and individual uploads over 32 MB keep the temporary, deferred-retirement path. Retained capacity is bounded at 64 MB per slot / 192 MB overall. `DM_NVN_UPLOAD_REUSE=0` restores the original policy. `t_upload_slots.m` checks distinct GPU-copied bytes across pending batches, an event-gated slot wait, warm allocation reuse, overflow beyond the budget and oversized allocations; both policies pass.
- **Found on:** [NieR:Automata US](../../dm/games/010056B015FE8000/README.md#upload-stability-follow-up). This removes allocation churn in the controlled workload; a gameplay comparison with the updated build is pending.

### Small array texture uploads without per-face staging

- **Symptom:** periodically rewritten textures cause CPU submission spikes even with no new staging allocations or shader compilation; a small cube-array image produces thousands of deswizzle dispatches and buffer-to-texture copies.
- **Cause:** the staged upload path encodes a deswizzle dispatch and a copy for every face and mip. Face count can dominate encoding cost even when total pixel data is small.
- **Fix:** mapped, non-sparse RGBA16F 2D/cube arrays with at least 32 faces and dimensions at most 64×64 write all faces in one dispatch per mip. An RGBA16Uint view preserves the exact stored component bits, including half-float NaN payloads, without floating-point conversion. Temporary views live until GPU completion; direct targets participate in dropped-upload recovery. Initial fills retain their original texture and usage contract. Large images, other formats, linear/compressible layouts, multisampling and unaligned sources keep the staged path. `DM_NVN_DIRECT_UPLOAD=0` restores it for all uploads. `t_direct_upload.m` compares all mip/face bits after CPU edits and spare reuse, checks fallback and initial fills, and injects a lost direct upload; Metal validation passes 26,384 checks normally and 26,388 with recovery injection.
- **Found on:** [NieR:Automata US](../../dm/games/010056B015FE8000/README.md#small-light-probe-array-uploads). Its 16×16×768, five-mip RGBA16F array needs five direct dispatches instead of 3,840 staged dispatches and 3,840 copies. In an alternating warmed benchmark on M4 Pro, CPU upload encoding plus commit falls from 3.916 to 0.0275 ms and upload GPU time from 1.802 to 0.0355 ms. Gameplay and terminal-screen improvement remain unmeasured.

### Address to memory pool lookups by binary search

- **Symptom:** the submit thread's CPU samples dominated by `mtl_addr` / `mtl_gpu_addr` (vertex buffers in `draw`, uniform buffers in `encode_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](../../dm/games/01007EF00011E000/README.md): 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.

### Main guest thread keeps inherited default QoS

- **Symptom:** gameplay has low CPU clocks, large render submission wall time compared with thread CPU time, and default QoS (`0x15`) on the main guest thread while workers report user-interactive (`0x21`).
- **Cause:** worker initialization applied the guest QoS policy, but `svc_thread_main_init` only recorded the boot pthread's inherited class. On iOS that class can be default. A time-constraint policy may hide this difference until it is disabled for measurement, and does not establish the intended underlying QoS.
- **Fix:** main-thread registration calls the same `qos_apply` used by worker threads, before recording the actual class, and logs the resulting class. With diagnostics enabled, `DM_NVN_CPUPROF=1` measures inclusive CPU/wall time in pass begin/end, pipeline lookup, argument encoding, texture lookup, draw, geometry, and submission indexing. These nested phase times overlap. The thread regression confirms `0x21`; on-device performance remains to be validated.
- **Found on:** [NieR:Automata US 1.0.3](../../dm/games/010056B015FE8000/README.md), where the slow gameplay captures show 159–160 million host translation instructions per frame, considerable scheduling delay, nominal thermal state, and ample memory headroom.

### 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=0` or a fixed `DM_RT_COMP=5` / `10` removed 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_tick` in `hle_nvn.c`): 5.6 ms here, 60.0 fps, 99th percentile 21 ms.
- **Found on:** [DARK SOULS: REMASTERED](../../dm/games/01004AB00A260000/README.md), in the Asylum cell.

### A shader that writes a whole frame through raw global memory

- **Symptom:** a movie or full-screen compute pass far slower than the same work should be, nearly all of it GPU time: `GPU p50` near the frame time with `submit+present` under 1 ms. `[RED] raw global memory: a write log ran out of slots` on older chains. With `DM_NVN_GPUPROF` a relaxed timestamp can put the time before the compute encoder's reported start, so the encoder itself looks short and the gap before it is what is long.
- **Cause:** every store through a loaded pointer searched the pool table in 12 steps, then logged its page in a hash set with compare-and-swap. Atomics from every lane contended even on a page the set already held, and a full frame holds more pages than the set's 1,024 slots.
- **Fix:** [the log became a bitmap of table entries, read before the atomic, and a tagged hint skips the search](shaders.md#global-memory-at-an-address-loaded-from-memory). Ocarina of Time's menu with its movie background, on an M6 Mac with 16 GB: frame time 42 ms → 16.67 ms, GPU 42 → 12.6 ms. The hint alone is 3.5 ms of that (16.1 ms without it).
- **Found on:** [The Legend of Zelda: Ocarina of Time](../../dm/games/0400486024DEC000/README.md); the diagnosis and this design first in [PR 1299](https://github.com/seedds/nxnative/pull/1299).

### Dynamic resolution that never moves, and draw passes that wait for each other

- **Symptom:** a game with dynamic resolution stays at its full size while the GPU runs far over budget: `GPU p50` above the frame time target, frame times locked to 2 vsyncs, and the same pass sizes frame after frame in `DM_NVN_GPUPROF`.
- **Cause:** the game decides from GPU timestamps. With the default `gpu_timestamps submission` every report of a submission holds the same time, so the game measures almost no GPU time and never lowers its resolution. Separately, with the default `DM_NVN_BARRIER=full` each draw pass waits for all earlier GPU work, about 180 full barriers a frame on Ocarina of Time, which NVN does not ask for.
- **Fix:** profile keys, the same mechanism as Breath of the Wild: [`gpu_timestamps segment`](../../dm/games/README.md) and `draw_barriers game`. On Ocarina of Time, on the bridge and in the village on an M6 Mac with 16 GB, p50 28 → about 17 ms and attachments moved per frame 300 → about 120 MB; its main pass goes from 1536x864 to about 763x429. Both are what hardware does, so each could become the default after a regression run on the games with no such key.
- **Open:** the main pass at about 763x429 is a low resolution, and the GPU at about 17 ms is still a little over budget, so the frame rate is not locked.
- **Found on:** [The Legend of Zelda: Ocarina of Time](../../dm/games/0400486024DEC000/README.md).

### A full table of running GPU writes ends passes for nothing

- **Symptom:** many passes a frame on the same attachments, with the residency set line showing `passes ended to land them for a read` in the thousands and `refused …: earlier submission running` matching it. Ocarina of Time: 10,463 per 300 frames, each ending the pass over a uniform block no GPU write touched.
- **Cause:** a uniform shadow asks a table of the pages GPU writes of running submissions touch (`gw_live_in`). It had 4,096 slots, a colliding slot meaning any page, and a game with this many writes kept all of them live, about 400 as wildcards, so a block whose page hashes onto a wildcard was refused.
- **Fix:** general: 65,536 slots (`GW_N`, about 2 MB). The refusals fell to 291 per 300 frames, and the worst frames from about 45 to about 35 ms.
- **Found on:** [The Legend of Zelda: Ocarina of Time](../../dm/games/0400486024DEC000/README.md).

### A host's own store in a fragment shader moves its depth test

- **Symptom:** a game that reads textures through a pool ([Shaders report which textures they read through a pool](memory.md#shaders-report-which-textures-they-read-through-a-pool)) renders far below its maximum dynamic resolution: Ocarina of Time's main pass at 763x429 with the GPU at 17 ms a frame.
- **Cause:** that report is an atomic store in the fragment shader (`dm_pool_mark`), and Metal tests a shader that stores to memory against depth after running it. So every covered pixel was shaded, with no hidden surface removal. It also risks wrong pictures: the late depth differs by an ulp from a prepass's, which fails an `EQUAL` pass along contours ([Early fragment tests from the control section](shaders.md#early-fragment-tests-from-the-control-section)).
- **Fix:** `dmsh_build.py` `pool_arrays`: a fragment shader whose only store is the mark gets `[[early_fragment_tests]]`. One that discards, writes depth, stencil or a sample mask, or stores anything itself keeps its test where it was; there the mark costs the shading. A chain change.
- **Check:** `python3 dm/tools/tests/test_dmsh_pool_arrays.py`. On the same route, the game's own controller then held its main pass at 1536x864, four times the pixels, at the same GPU time.
- **Found on:** [The Legend of Zelda: Ocarina of Time](../../dm/games/0400486024DEC000/README.md): 56 of the 258 fragment programs of a frame in Kokiri Forest carry the mark, 3 of them with a discard.
