From 4acde72a6e985941e3d38c5c9e0ea3687d95be53 Mon Sep 17 00:00:00 2001 From: seedds Date: Mon, 26 Oct 2026 14:22:03 +0800 Subject: [PATCH] Recover the frame step after movie and thermal caps (#253) Keep the proven frame cost and GPU reference for each cap, and probe the shorter hold when a movie or thermal cap ends. Otherwise a GPU-bound game can fall straight back to 30 fps without a usable recovery reference. Save an uninterrupted sustained run when a cap begins, even if no earlier hitch populated cref. Keep the earlier reference through short runs and update it after another sustained run. Count cap-release probes and give them all 30 grace frames: the completed frame at the old cap consumes none. Add movie and thermal recovery regressions, including first movies, repeated caps, full grace and failed-probe backoff. Document the mechanism and link it from both Zelda game notes. Validated: 64/64 frame-step checks, including ASan/UBSan; six checks fail against the original PR. Production thermal tests and ThreadSanitizer, docs tests and diff checks pass; the BotW Mac host builds. Device clock recovery remains modelled, without a new gameplay observation. Co-authored-by: RAHUL REX <75500427+RahulRex0@users.noreply.github.com> --- dm/games/01007EF00011E000/README.md | 1 + dm/games/0100F2C0115B6000/README.md | 2 + dm/gfx/nvn/nvn_fstep.h | 34 +++++++++++--- dm/gfx/tests/t_fstep.c | 71 ++++++++++++++++++++++++++++- docs/agents/symptoms.md | 1 + docs/reference/frame-rate.md | 8 ++++ 6 files changed, 108 insertions(+), 9 deletions(-) diff --git a/dm/games/01007EF00011E000/README.md b/dm/games/01007EF00011E000/README.md index 72c6a10..6947229 100644 --- a/dm/games/01007EF00011E000/README.md +++ b/dm/games/01007EF00011E000/README.md @@ -188,6 +188,7 @@ General host changes first needed here; each applies to every game: - **Resolved: the IPA omitted pulling libraries with `pso_mtl4 on`, and explicitly including them failed under macOS Bash 3.2.** See [Pulling vertex inputs](../../../docs/reference/rendering.md#pulling-vertex-inputs). - **Upscaling in the present** (`fx_encode`, `DM_PRESENT_UPSCALE`): [The present upscales through MetalFX](../../../docs/reference/rendering.md#the-present-upscales-through-metalfx). - **Thermal cap; resolved movie recovery and acquire/present races** (`thermal_capped`, `DM_THERMAL_CAP`): [A thermally serious iPhone holds 30 fps](../../../docs/reference/frame-rate.md#a-thermally-serious-iphone-holds-30-fps). +- **Resolved in the host: frame-step recovery after a 30 fps cap**: [The frame step after a 30 fps cap ends](../../../docs/reference/frame-rate.md#the-frame-step-after-a-30-fps-cap-ends). Modelled with the measured iPhone GPU times; recovery on the device has not been observed here. - **Pipeline keys omit disabled blend factors** (`pso_key_norm`): [Pipeline keys omit unused colour state](../../../docs/reference/shaders.md#pipeline-keys-omit-unused-colour-state). - **Resolved: GPU readback temporaries retained by a game thread's outer pool** (`mtl_read_texels`): [GPU readback without an autorelease pool](../../../docs/reference/memory.md#gpu-readback-without-an-autorelease-pool). Ported from MacNativeGames; checked here with a synthetic Metal readback lifetime test, without a new gameplay memory measurement. - **Compute dispatches ahead of their pass** (`cs_hoist_try`, `DM_NVN_CS_HOIST`): [Compute dispatches between the draws of a pass run ahead of it](../../../docs/reference/rendering.md#compute-dispatches-between-the-draws-of-a-pass-run-ahead-of-it). diff --git a/dm/games/0100F2C0115B6000/README.md b/dm/games/0100F2C0115B6000/README.md index 705c834..c1f1f29 100644 --- a/dm/games/0100F2C0115B6000/README.md +++ b/dm/games/0100F2C0115B6000/README.md @@ -73,6 +73,8 @@ Game-specific findings. The general host changes they led to are listed under [F General host changes first needed here; each applies to every game: +- **Resolved in the host: frame-step recovery after a 30 fps movie cap**: [The frame step after a 30 fps cap ends](../../../docs/reference/frame-rate.md#the-frame-step-after-a-30-fps-cap-ends). Modelled with the movie flag; no new gameplay recovery observation is claimed. + - **Packaged texture block dimensions and linear storage rows:** [Packaged texture block dimensions and linear storage rows](../../../docs/reference/nvn-objects.md#packaged-texture-block-dimensions-and-linear-storage-rows). - **Resolved in the host: dirty-page writes missed during upload**, with both unprotect paths covered by regression tests; the glyph-cache symptom remains unconfirmed here: [Dirty-page writes missed during upload](../../../docs/reference/nvn-objects.md#dirty-page-writes-missed-during-upload). - **Depth16 load-clears match fragment writes:** [Depth16 load-clears match fragment writes](../../../docs/reference/rendering.md#depth16-load-clears-match-fragment-writes). diff --git a/dm/gfx/nvn/nvn_fstep.h b/dm/gfx/nvn/nvn_fstep.h index 67a110c..27bc1ba 100644 --- a/dm/gfx/nvn/nvn_fstep.h +++ b/dm/gfx/nvn/nvn_fstep.h @@ -13,6 +13,11 @@ // costs what it did, so the step up came from a hitch (Breath of the Wild on an iPhone: pipeline compiles after a cutscene). The fit trial alone does not bring such a game // back: an iPhone lowers its GPU clock until the work fills most of the frame, so a GPU-bound game's cost sits near FSTEP_FIT at either hold (BotW: GPU 12.8–15.2 ms at hold // 2, p50, where hold 1 had held for minutes; the same 4,900 draws took 5.8 ms after a trip to the background and 13.5 ms half a minute later). +// A cap that falls and rises again (Tears of the Kingdom's prerendered movies, the iOS thermal cap in hle_nvn.c) keeps what was learned at each kmin: the cost that held it +// (cref) and the GPU reference (nvn_fgpu below); and the frames after the cap rises are a probe with its grace, as the clocks are still set for the longer hold. Before, the +// rise set k to kmin with no grace, so three frames late while the clock rose stepped up again, with cref dropped at the cap change and a GPU reference taken at the 30 fps +// cap: neither the fit trial (a GPU-bound cost near FSTEP_FIT) nor a probe brought kmin back (t_fstep: the movie and thermal cap cases). A cref kept from before the cap +// starts a probe only when the cost at the longer hold is within FSTEP_SAME of it, and a failed probe doubles the hold-off as any other does. #pragma once #include #include @@ -38,9 +43,10 @@ typedef struct { int screen; // set by the caller before each call, then cleared: 0 = judge lateness by shown and cost; 1 = the screen showed a recent frame on time, 2 = late. With present // snapshots (nvn_mtl.m) the game runs ahead of the screen and a frame that runs long is absorbed, so only what the screen shows counts double run, crun, cref; // vsyncs at kmin in a row; the cost of its frames on time (running mean; not during a probe's trial, whose frames run while the clock rises); - // crun when the last run of FSTEP_HOLD_MAX or more ended in a step up (0 = none yet) + // crun when the last run of FSTEP_HOLD_MAX or more ended in a step up or a cap change (0 = none yet) double ccur; int ck, cn; // the cost of the frames on time at the longer hold ck (running mean) and their count int ckmin; // the kmin run, crun and cref belong to + double cref_at[FSTEP_MAX + 1]; // cref of each kmin, kept while another kmin is in force uint64_t late, paid, ups, downs, failed, probes; double dropped; // statistics for the log } nvn_fstep; @@ -68,15 +74,25 @@ static inline int nvn_fstep_next(nvn_fstep *s, double shown, double cost, int km if (s->debt > FSTEP_DEBT_MAX) { s->dropped += s->debt - FSTEP_DEBT_MAX; s->debt = FSTEP_DEBT_MAX; } if (s->debt < -1) s->debt = -1; // ahead (a frame shorter than its step): nothing slows the game down, so at most one vsync is remembered s->since += shown; - if (kmin < s->kmin || s->k < kmin) s->k = kmin; // the cap rose (a movie or the game's own step ended) or fell + int cap_probe = kmin < s->kmin && s->kmin <= FSTEP_MAX && s->k > kmin; + if (cap_probe) { // the cap rose: a probe at once, whose grace covers the clocks still set for the longer hold + s->k = kmin; s->probes++; s->trial = FSTEP_RUN; s->probe = 1; s->grace = FSTEP_GRACE; s->fit_run = s->late_run = 0; + } else if (kmin < s->kmin || s->k < kmin) s->k = kmin; // the game's own step ended, or the cap fell if (s->k > kmax) s->k = kmax; s->kmin = kmin; int scr = s->screen; s->screen = 0; int late = scr ? scr == 2 : shown > held + FSTEP_LATE + s->shown_slack || cost > held + FSTEP_LATE, grace = s->grace > 0; - if (grace) s->grace--; + if (grace && !cap_probe) s->grace--; // the completed frame at the old cap does not consume the new hold's grace if (late) { s->late++; s->late_run = grace ? 0 : s->late_run + 1; s->fit_run = 0; } else s->late_run = 0; // The cost of the frames on time (a hitch is not what the scene costs): at kmin, and at the longer hold - if (kmin != s->ckmin) { s->ckmin = kmin; s->run = s->crun = s->cref = 0; } + if (kmin != s->ckmin) { // another cap: its run starts over, and what held it before comes back + if (s->ckmin >= 1 && s->ckmin <= FSTEP_MAX) { + // A cap can end a sustained run before any hitch saved its cost. Short runs keep the earlier proven reference. + if (s->run >= FSTEP_HOLD_MAX && s->crun > 0) s->cref = s->crun; + s->cref_at[s->ckmin] = s->cref; + } + s->ckmin = kmin; s->run = s->crun = 0; s->cref = kmin <= FSTEP_MAX ? s->cref_at[kmin] : 0; + } // A frame the screen showed on time while its cost did not fit (absorbed by present snapshots) is not what the hold costs: the probes would take it for the scene that held kmin int fits = !scr || cost <= held; if (held <= kmin) { s->run += shown; s->cn = 0; if (!late && fits && s->probe != 1) s->crun = s->crun > 0 ? s->crun + (cost - s->crun) / 32 : cost; } @@ -107,15 +123,19 @@ static inline int nvn_fstep_next(nvn_fstep *s, double shown, double cost, int km // The GPU clock follows the hold too: Breath of the Wild on an iPhone went from 14.4 to about 25 ms of GPU time a frame at hold 2 with the same 4,900 draws, the CPU still on its // performance cores, so its cost never fitted hold 1 again. The GPU counts as slowed (another reason for nvn_fstep_next's slow) when its time at a hold above kmin is at least // FSTEP_GPU_SLOW times its time at kmin and it alone does not fit k − 1 (a GPU at 3 ms of 17, 6 times its time on a loading screen, is not what holds a frame back). -// The reference is not updated during a probe's trial (its frames run while the clock is still rising), so a failed probe leaves it as it was +// The reference is not updated during a probe's trial (its frames run while the clock is still rising), so a failed probe leaves it as it was. It is kept for each kmin: one +// learned at a 30 fps cap is the GPU time at the clock that cap left (lowered for 30 fps, and under the thermal cap for the heat), and taken as hold 1's after the cap rose it +// made the same work at hold 2 look not slowed #define FSTEP_GPU_SLOW 1.3 // GPU time at the longer hold against its time at kmin #define FSTEP_GPU_N 30 // frames at the longer hold before the comparison counts -typedef struct { double ref, cur; int n, k; } nvn_fgpu; // GPU time at kmin (running mean); at the current hold k and its frame count +typedef struct { double ref, cur; int n, k; double ref_at[FSTEP_MAX + 1]; } nvn_fgpu; // GPU time at kmin (running mean; of every kmin in ref_at); at the current hold k and its frame count static inline int nvn_fgpu_slow(const nvn_fgpu *g) { return g->n >= FSTEP_GPU_N && g->ref > 0 && g->cur >= FSTEP_GPU_SLOW * g->ref && g->cur > FSTEP_FIT * (g->k - 1); } // Once per frame, with the frame's GPU time (vsyncs; 0 = none finished yet) and the hold and probe state (nvn_fstep.probe) it ran under static inline int nvn_fstep_gpu_slowed(nvn_fgpu *g, double gpu, int k, int kmin, int probe) { + if (kmin < 1 || kmin > FSTEP_MAX) return 0; // no reference slot for it (hle_nvn.c passes 1 or 2) + g->ref = g->ref_at[kmin]; if (gpu <= 0) return nvn_fgpu_slow(g); - if (k <= kmin) { if (probe != 1) g->ref = g->ref > 0 ? g->ref + (gpu - g->ref) / 32 : gpu; g->n = 0; g->k = k; return 0; } + if (k <= kmin) { if (probe != 1) g->ref = g->ref_at[kmin] = g->ref > 0 ? g->ref + (gpu - g->ref) / 32 : gpu; g->n = 0; g->k = k; return 0; } if (k != g->k) { g->k = k; g->n = 0; } g->cur = g->n ? g->cur + (gpu - g->cur) / 8 : gpu; g->n++; return nvn_fgpu_slow(g); diff --git a/dm/gfx/tests/t_fstep.c b/dm/gfx/tests/t_fstep.c index bd1941d..be0678f 100644 --- a/dm/gfx/tests/t_fstep.c +++ b/dm/gfx/tests/t_fstep.c @@ -95,6 +95,14 @@ int main(void) { run(&m, 120, 0.5, 2, 3, 0); run(&m, 1, 0.5, 1, 3, 0); check(m.s.k == 1 && m.s.ups == 0 && m.s.late == 0, " movie ends (kmin 1): hold 1 at once"); } + { sim m = {0}; run(&m, 600, 0.86, 1, 3, 0); run(&m, 600, 1.5, 2, 2, 0); run(&m, 1, 1.5, 1, 3, 0); + check(m.s.k == 1 && m.s.grace == FSTEP_GRACE && m.s.trial == FSTEP_RUN && m.s.probes == 1, + "cap release: count a probe, with all grace frames left for hold 1"); + int frames = 0; + while (m.s.k == 1 && frames < 100) { run(&m, 1, 1.3, 1, 3, 0); frames++; } + check(frames == FSTEP_GRACE + 3 && m.s.k == 2 && m.s.failed == 1 && m.s.hold == 2 * FSTEP_HOLD0, + " recovery fails after 30 grace frames plus 3 late frames, then doubles the hold-off"); + } { sim m = {0}; run(&m, 60, 0.5, 1, 3, 0); run(&m, 120, 0.5, 1, 3, 2); check(m.s.k == 2 && m.next == 2 && m.s.debt == 0, "the game fixes its step at 2 (BotW's VFR override): hold 2, step 2"); run(&m, 2, 0.5, 1, 3, 0); @@ -225,8 +233,67 @@ int main(void) { snprintf(b, sizeof b, " probes %.0f, %.0f, %.0f, %.0f vsyncs apart; game time %.0f against real time %.2f (debt %.2f)", gaps[1], gaps[2], gaps[3], gaps[4], m.game, m.real, m.s.debt); check(gaps[1] >= 120 && gaps[2] >= 240 && gaps[3] >= 480 && gaps[4] >= 480 && gaps[4] < 600 && m.real - m.game - m.s.debt > -1.01 && m.real - m.game - m.s.debt < 1.01, b); } - { sim m = {0}; run(&m, 1200, 0.86, 1, 3, 0); run(&m, 1, 0.5, 2, 3, 0); - check(m.s.cref == 0 && m.s.run <= 2, "movie flag (kmin 2): the cost that held kmin 1 is dropped"); + for (int sustained = 0; sustained < 2; sustained++) { + // The first movie follows an uninterrupted run: no earlier hitch has saved cref. Only a run of at least 8 s proves that this cost held 60. + sim m = {0}; run(&m, sustained ? 1200 : 300, 0.86, 1, 3, 0); + run(&m, 600, 0.5, 2, 2, 0); + check(sustained ? m.s.cref_at[1] > 0.85 && m.s.cref_at[1] < 0.87 : m.s.cref_at[1] == 0, + sustained ? "first movie after 20 s at 60: save the sustained run without an earlier hitch" + : "first movie after only 5 s at 60: no proven cost to save"); + run(&m, 60, 0.86, 1, 3, 0); uint64_t probes = m.s.probes; run(&m, 3, 1.6, 1, 3, 0); + for (int i = 0; i < 1200; i++) run(&m, 1, m.s.k == 1 ? 0.86 : 0.9, 1, 3, 0); + check(sustained ? m.s.k == 1 && m.s.probes > probes : m.s.k == 2 && m.s.probes == probes, + sustained ? " hitch 1 s after the first movie: the saved run brings 60 back" + : " hitch after a short unproven run: keep 30 without a same-cost probe"); + } + { // Later movies keep the last proven cost through short runs, and replace it after another sustained run at a different cost. + sim m = {0}; run(&m, 1200, 0.86, 1, 3, 0); run(&m, 120, 0.5, 2, 2, 0); + run(&m, 120, 0.6, 1, 3, 0); run(&m, 120, 0.5, 2, 2, 0); + check(m.s.cref_at[1] > 0.85 && m.s.cref_at[1] < 0.87, "second movie after only 2 s: keep the earlier proven cost"); + run(&m, 1200, 0.6, 1, 3, 0); run(&m, 120, 0.5, 2, 2, 0); + check(m.s.cref_at[1] > 0.59 && m.s.cref_at[1] < 0.61, " another sustained run before a movie: update the proven cost"); + } + { sim m = {0}; run(&m, 1200, 0.86, 1, 3, 0); run(&m, 3, 1.6, 1, 3, 0); double held = m.s.cref; run(&m, 1, 0.5, 2, 3, 0); + check(held > 0.85 && m.s.cref == 0 && m.s.run <= 2, "movie flag (kmin 2): the cost that held kmin 1 is set aside"); + run(&m, 120, 0.5, 2, 3, 0); run(&m, 1, 0.5, 1, 3, 0); + check(m.s.cref == held, " movie ends (kmin 1): the cost that held kmin 1 comes back"); + } + { // Tears of the Kingdom's prerendered movies set a 30 cap (kmin 2). A GPU-bound game at 0.86 vsyncs at hold 1 and 0.9 at hold 2 (the clock lowered for the longer hold) + // has learned the cost that held hold 1 from a hitch; after a 10 s movie, pipeline compiles 1 s in (three frames of 1.6) step it up before 8 s at hold 1 relearn it + sim m = {0}; run(&m, 1200, 0.86, 1, 3, 0); run(&m, 3, 1.6, 1, 3, 0); + for (int i = 0; i < 1200; i++) run(&m, 1, m.s.k == 1 ? 0.86 : 0.9, 1, 3, 0); + double held = m.s.cref; int at1 = 0; + run(&m, 600, 0.5, 2, 2, 0); run(&m, 60, 0.86, 1, 3, 0); run(&m, 3, 1.6, 1, 3, 0); + for (int i = 0; i < 1200; i++) { run(&m, 1, m.s.k == 1 ? 0.86 : 0.9, 1, 3, 0); at1 += i >= 600 && m.s.k == 1; } + snprintf(b, sizeof b, " a hitch 1 s after a movie: the cost that held hold 1 starts a probe (k %d, at hold 1 %d of the last 600)", m.s.k, at1); + check(held > 0.85 && m.s.k == 1 && at1 == 600, b); + } + // The iOS thermal cap (hle_nvn.c thermal_capped): 30 fps from frame 600 for 30 s. Breath of the Wild on an iPhone, GPU-bound: 0.86 vsyncs (14.4 ms) at hold 1, 1.5 (25 ms) + // at hold 2, where the clock follows the hold. After the cap and after each run at hold 2, the first 10 frames at hold 1 take 1.5 until the clock rises again; c = 1: the + // first 60 frames after the cap (a clock slower to come back), so the probe at the cap's end fails; c = 2: hold 1 takes 1.3 for good after the cap (a heavier scene) + for (int c = 0; c < 3; c++) { + static const char *const what[] = {"thermal cap ends: the probe's grace covers the clock, hold 1 at once and it stays (k %d, %d steps up after the cap, at hold 1 %d of the last 600)", + " the clock takes 1 s: the probe fails, the GPU against hold 1's own reference starts another (k %d, %d steps up, at hold 1 %d of the last 600)", + " hold 1 no longer fits (1.3): a probe at most every 8 s, at hold 1 %d of the last 600; game time %.0f against real time %.2f (probes %llu)"}; + int rise = 600 + 1800, since1 = 0, at1 = 0, n = rise + 3600; uint64_t probes = 0, ups = 0; double last = 0, gap = 1e9; + sim m = {0}; nvn_fgpu g = {0}; + for (int i = 0; i < n; i++) { + int kmin = i >= 600 && i < rise ? 2 : 1, kmax = kmin == 2 ? 2 : 3, k = m.s.k ? m.s.k : kmin; + since1 = k == 1 ? since1 + 1 : 0; + double w = k >= 2 ? 1.5 : i < rise ? 0.86 : c == 2 ? 1.3 : since1 <= 10 || (c == 1 && i < rise + 60) ? 1.5 : 0.86; + m.slow = nvn_fstep_gpu_slowed(&g, w, k, kmin, m.s.probe); + run(&m, 1, w, kmin, kmax, 0); + if (i == rise - 1) ups = m.s.ups; + if (i >= n - 600) at1 += m.s.k == 1; + if (i > rise + 1200 && m.s.probes != probes) { if (last) gap = gap < m.real - last ? gap : m.real - last; last = m.real; } + probes = m.s.probes; + } + int up = (int)(m.s.ups - ups); + if (c < 2) { snprintf(b, sizeof b, what[c], m.s.k, up, at1); check(m.s.k == 1 && at1 == 600 && (c || !up), b); } + else { + snprintf(b, sizeof b, what[c], at1, m.game, m.real, (unsigned long long)m.s.probes); + check(at1 < 100 && gap >= FSTEP_HOLD_MAX && m.real - m.game - m.s.debt > -1.01 && m.real - m.game - m.s.debt < 1.01, b); + } } { // 165 Hz with virtual ticks (nvn_mtl.m link_range): cheap frames show on the refreshes nearest their slots, 12.1 / 18.2 ms at hold 1, 30.3 / 36.4 at 2, 48.5 / 54.5 at 3 for (int sl = 1; sl >= 0; sl--) { diff --git a/docs/agents/symptoms.md b/docs/agents/symptoms.md index 9ea37f1..2db5f23 100644 --- a/docs/agents/symptoms.md +++ b/docs/agents/symptoms.md @@ -215,6 +215,7 @@ One line per symptom in the [subsystem references](../reference/): what you see - a 30 fps game to run at 60; after the floor was lowered, slow motion on a GPU-bound device (GitHub issue #9). → [sead's dynamic present interval, `ksys::VFR` and the host's frame step](../reference/frame-rate.md#seads-dynamic-present-interval-ksysvfr-and-the-hosts-frame-step) - 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). → [A thermally serious iPhone holds 30 fps](../reference/frame-rate.md#a-thermally-serious-iphone-holds-30-fps) +- 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. → [The frame step after a 30 fps cap ends](../reference/frame-rate.md#the-frame-step-after-a-30-fps-cap-ends) - slow motion when the GPU is the bottleneck: a game's interval controller that times the GPU never steps up (GitHub issue #9). → [GPU timestamps per submission segment](../reference/frame-rate.md#gpu-timestamps-per-submission-segment) - GPU time reads 32 times too small when a game converts raw NVN counter timestamps itself. → [Raw NVN timestamp units](../reference/frame-rate.md#raw-nvn-timestamp-units) - 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. → [A render-thread limiter with a constant and a fixed time step](../reference/frame-rate.md#a-render-thread-limiter-with-a-constant-and-a-fixed-time-step) diff --git a/docs/reference/frame-rate.md b/docs/reference/frame-rate.md index fbbbf2a..d82d27c 100644 --- a/docs/reference/frame-rate.md +++ b/docs/reference/frame-rate.md @@ -22,6 +22,14 @@ Frame pacing: the host's frame step, engine frame limiters, present intervals an - **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. +### 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 Legend of Zelda: Tears of the Kingdom](../../dm/games/0100F2C0115B6000/README.md)'s movie flag 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. + ### 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).