From b53bb8939b4623b4ab1d29d79bdca4eb1985d281 Mon Sep 17 00:00:00 2001 From: seedds Date: Wed, 21 Oct 2026 15:36:23 +0800 Subject: [PATCH] Frame step: a hitch that steps 60 fps down to 30 is probed back when the cost at 30 is the cost that held 60 Breath of the Wild on an iPhone dropped to 30 fps after cutscenes (pipeline compiles stepped the hold up) and stayed there for 36 and 46 s until the player switched out and back. At hold 2 its GPU time was 12.8-15.2 ms a frame (p50), where hold 1 had held for minutes: the iPhone lowers the GPU clock until the work fills most of the frame, so a GPU-bound game's cost sits near the fit line (0.85 of a vsync) at either hold and the fit trial never fires. The same 4,900 draws took 5.8 ms right after a trip to the background and 13.5 ms half a minute later. nvn_fstep.h now keeps the running cost of the frames on time at kmin. A step up after a run of 8 s (FSTEP_HOLD_MAX) or more at kmin keeps it as cref; when the cost of the frames on time at the longer hold (30 frames or more) is at most FSTEP_SAME (1.1) times cref, the scene costs what it did and a probe starts after the hold-off (nvn_fstep_same). A run under 8 s keeps no cost, so a device that steps up every few seconds stays at 30 rather than flap. Log: "(probe, cost 1.04x the cost that held hold 1)", and "; hold 1 held at 0.86" on a step up. DM_TEST_PRESENT_LAG takes runs and several entries ("1,11;1200,40,3": 11 ms on every frame, 40 more on three in a row every 1,200 presents), which imitates the hitch on the Mac. t_fstep 47/47 (8 new checks; 4 fail with the rule off). Mac (M4 Pro), BotW with that lag, 170 s: the old host held 30 from the first hitch in gameplay to the end; the new one was back at 60 within about 1 s of every hitch (9 of 11 probes held, the other two cut short by the next hitch), game time 1.000 of real time. IPA ZeldaBreathOfTheWild-2026-10-21-hitch.ipa (fps counter on) awaits an iPhone test. --- dm/gfx/nvn/hle_nvn.c | 28 +++++++++++++++++++++------- dm/gfx/nvn/nvn_fstep.h | 25 +++++++++++++++++++++++-- dm/gfx/tests/t_fstep.c | 42 ++++++++++++++++++++++++++++++++++++++++++ docs/development.md | 4 ++-- 4 files changed, 88 insertions(+), 11 deletions(-) diff --git a/dm/gfx/nvn/hle_nvn.c b/dm/gfx/nvn/hle_nvn.c index e099979..0500a21 100644 --- a/dm/gfx/nvn/hle_nvn.c +++ b/dm/gfx/nvn/hle_nvn.c @@ -1498,11 +1498,13 @@ static void fstep_tick(const win_t *o) { if (fixed != fixed_was && nfix < 40) { nfix++; if (fixed) LOG("[nvn] frame step: the game fixes its own step at %d vsyncs at present %llu", fixed, (unsigned long long)o->presents); else LOG("[nvn] frame step: the game hands the step back to the host at present %llu", (unsigned long long)o->presents); } fixed_was = fixed; if (k0 && g_fs.k != k0 && nlog < 40) { - char why[112]; int w = snprintf(why, sizeof why, "probe"); + char why[160], held[48] = ""; int w = snprintf(why, sizeof why, "probe"); if (cslow) w += snprintf(why + w, sizeof why - w, ", CPU %d%% on performance cores", g_fs_pshare); - if (gslow) snprintf(why + w, sizeof why - w, ", GPU %.2f× its time at hold %d", g_fg.cur / g_fg.ref, kmin); - LOG("[nvn] frame step: hold %d → %d vsyncs at present %llu (%s; cost %.2f vsyncs, GPU %.2f)%s", k0, g_fs.k, (unsigned long long)o->presents, - fixed ? "the game's own step" : kmin0 > FSTEP_MAX ? "the game's own step ended" : kmin != kmin0 ? "the cap changed" : g_fs.k > k0 ? "3 late frames" : g_fs.probes != probes0 ? why : "trial", cost, gpu, ++nlog == 40 ? "; further changes only counted below" : ""); + if (gslow) w += snprintf(why + w, sizeof why - w, ", GPU %.2f× its time at hold %d", g_fg.cur / g_fg.ref, kmin); + if (nvn_fstep_same(&g_fs)) snprintf(why + w, sizeof why - w, ", cost %.2f× the cost that held hold %d", g_fs.ccur / g_fs.cref, kmin); + if (g_fs.k > k0 && g_fs.cref > 0) snprintf(held, sizeof held, "; hold %d held at %.2f", kmin, g_fs.cref); // what a probe compares the longer hold's cost with + LOG("[nvn] frame step: hold %d → %d vsyncs at present %llu (%s; cost %.2f vsyncs, GPU %.2f%s)%s", k0, g_fs.k, (unsigned long long)o->presents, + fixed ? "the game's own step" : kmin0 > FSTEP_MAX ? "the game's own step ended" : kmin != kmin0 ? "the cap changed" : g_fs.k > k0 ? "3 late frames" : g_fs.probes != probes0 ? why : "trial", cost, gpu, held, ++nlog == 40 ? "; further changes only counted below" : ""); } if (probe0 == 1 && g_fs.probe != 1 && !fixed && nlog < 40) // whether the controller raised the clock for the shorter deadline LOG("[nvn] frame step: the probe %s at present %llu (CPU %d%% on performance cores)%s", g_fs.probe == 2 ? "held" : "failed", (unsigned long long)o->presents, g_fs_pshare, ++nlog == 40 ? "; further changes only counted below" : ""); @@ -1524,9 +1526,21 @@ NVN(nvnQueuePresentTexture, void, NVNqueue *q, NVNwindow *w, int idx) { static uint64_t prev_in, prev_out, prev_pass, prev_draw; present_cfg(o); { - // Test switch DM_TEST_PRESENT_LAG=every N presents,milliseconds: sleep that long before the Nth, 2Nth… present, imitating an occasional slow frame on a phone (Mario 1.2.1 then sets present interval 2; checks that the display link holds frames by interval). Not set in the product - static int lag_n = -1, lag_ms; if (lag_n < 0) { const char *e = getenv("DM_TEST_PRESENT_LAG"); lag_n = 0; if (e && sscanf(e, "%d,%d", &lag_n, &lag_ms) == 2 && lag_n > 0) LOG("DM_TEST_PRESENT_LAG: every %d presents, sleep an extra %d ms", lag_n, lag_ms); else lag_n = 0; } - if (lag_n && o->presents % lag_n == 0) usleep((useconds_t)lag_ms * 1000); + // Test switch DM_TEST_PRESENT_LAG=every N presents,milliseconds[,presents in a row][;…]: sleep that long before the Nth, 2Nth… present (and the ones after it in the run), imitating an occasional slow frame on a phone (Mario 1.2.1 then sets present interval 2; checks that the display link holds frames by interval), + // or with a run of 3 a hitch that steps the frame step up ("1,10;1200,30,3": 10 ms more on every frame, and 30 more on three in a row every 1,200). Not set in the product + static int lag_k = -1, lag_n[4], lag_ms[4], lag_run[4]; + if (lag_k < 0) { + const char *e = getenv("DM_TEST_PRESENT_LAG"); lag_k = 0; + while (e && *e && lag_k < 4) { + int n = 0, ms = 0, r = 1, c = sscanf(e, "%d,%d,%d", &n, &ms, &r); + if (c < 2 || n <= 0) break; + lag_n[lag_k] = n; lag_ms[lag_k] = ms; lag_run[lag_k] = c == 3 && r > 1 ? r : 1; + LOG("DM_TEST_PRESENT_LAG: every %d presents, sleep an extra %d ms (%d presents in a row)", n, ms, lag_run[lag_k]); lag_k++; + if ((e = strchr(e, ';'))) e++; + } + } + int lag = 0; for (int i = 0; i < lag_k; i++) if (o->presents % (uint64_t)lag_n[i] < (uint64_t)lag_run[i]) lag += lag_ms[i]; + if (lag) usleep((useconds_t)lag * 1000); } gfx_enter(); uint64_t t0 = now_ns(); diff --git a/dm/gfx/nvn/nvn_fstep.h b/dm/gfx/nvn/nvn_fstep.h index 4431c04..9a97226 100644 --- a/dm/gfx/nvn/nvn_fstep.h +++ b/dm/gfx/nvn/nvn_fstep.h @@ -9,6 +9,10 @@ // FSTEP_GRACE frames may be late without failing it, the time the controller needs to raise the clock for the shorter deadline; after them it is judged like a trial. // A probe that holds leaves the hold-off as it is, and the run at kmin it starts must last FSTEP_HOLD_MAX (8 s) before the hold-off returns to 1 s: a shorter one counts as // a failed probe, so a CPU that really cannot hold kmin is not probed every second. +// A step up after a run at kmin of FSTEP_HOLD_MAX keeps the cost that held there, and a cost at the longer hold of at most FSTEP_SAME times it starts a probe too: the scene +// 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). #pragma once #include #include @@ -21,6 +25,7 @@ #define FSTEP_HOLD0 60.0 // vsyncs since the last step up before a trial: 1 s, doubled after each failed trial up to 8 s #define FSTEP_HOLD_MAX 480.0 #define FSTEP_GRACE 30 // a probe's first frames, whose lateness neither fails it nor counts toward a step up (the controller lowered the clock within 10 frames of a step to 30) +#define FSTEP_SAME 1.1 // the cost at the longer hold against the cost that held kmin: at most this much is the same scene (BotW at hold 2: 1.0–1.07 times its hold-1 GPU time) typedef struct { int k, step; // vsyncs each frame stays on screen; the step handed to the game for the next frame (k, or k + 1 while paying back) @@ -30,9 +35,16 @@ typedef struct { int probe, grace; // 1: the trial in progress is a probe; 2: a probe held and its run at kmin has not ended yet; frames left of the probe's grace double hold, since; // vsyncs a trial waits for after a step up; vsyncs since the last step up double shown_slack; // vsyncs a frame on time may show longer than k (set by the caller): one refresh on screens whose rate is not a multiple of 60; the cost keeps FSTEP_LATE + 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) + 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 uint64_t late, paid, ups, downs, failed, probes; double dropped; // statistics for the log } nvn_fstep; +// Whether the cost at the longer hold is the cost that held kmin (a probe without a slowed report) +static inline int nvn_fstep_same(const nvn_fstep *s) { return s->cref > 0 && s->cn >= FSTEP_RUN && s->ccur <= FSTEP_SAME * s->cref; } + // Once per frame. shown: real time since the previous frame's start, in vsyncs; cost: the frame's work, max(CPU, GPU), in vsyncs (a GPU that cannot keep up makes frames late too); kmin: 1 at a 60 fps cap, 2 at 30; // kmax: the longest hold (FSTEP_MAX; 2 at a 30 fps cap, where the display link runs at 30 Hz and holds only even counts); fixed: a step the game has set itself (BotW's // VFR override; 0 = none), which the host then holds as it is; slow: the CPU runs below its speed for reasons other than heat, so a cost above k − 1 is no evidence @@ -44,7 +56,7 @@ static inline int nvn_fstep_next(nvn_fstep *s, double shown, double cost, int km if (!s->hold) s->hold = FSTEP_HOLD0; if (fixed > 0) { // the game's own step: speed is right by construction, nothing to pay back s->k = s->step = fixed < FSTEP_MAX ? fixed : FSTEP_MAX; - s->debt = 0; s->late_run = s->fit_run = s->trial = s->probe = s->grace = 0; + s->debt = 0; s->late_run = s->fit_run = s->trial = s->probe = s->grace = 0; s->run = 0; s->kmin = FSTEP_MAX + 1; // when it ends, k starts again from kmin return s->step; } @@ -60,17 +72,26 @@ static inline int nvn_fstep_next(nvn_fstep *s, double shown, double cost, int km int late = shown > held + FSTEP_LATE + s->shown_slack || cost > held + FSTEP_LATE, grace = s->grace > 0; if (grace) 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 (held <= kmin) { s->run += shown; s->cn = 0; if (!late && s->probe != 1) s->crun = s->crun > 0 ? s->crun + (cost - s->crun) / 32 : cost; } + else { + s->run = 0; + if (held != s->ck) { s->ck = held; s->cn = 0; } + if (!late) { s->ccur = s->cn ? s->ccur + (cost - s->ccur) / 8 : cost; s->cn++; } + } if (s->late_run >= 3 && s->k < kmax) { // step up to the cost, at least one vsync int want = (int)ceil(cost - 0.05); if (want < s->k + 1) want = s->k + 1; if (want > kmax) want = kmax; if (s->trial || (s->probe == 2 && s->since < FSTEP_HOLD_MAX)) { s->failed++; s->trial = 0; s->hold = s->hold * 2 < FSTEP_HOLD_MAX ? s->hold * 2 : FSTEP_HOLD_MAX; } else if (s->probe == 2) s->hold = FSTEP_HOLD0; // the probe's run at kmin lasted + if (held == kmin && s->run >= FSTEP_HOLD_MAX) s->cref = s->crun; // kmin held 8 s at this cost s->k = want; s->ups++; s->late_run = s->fit_run = 0; s->since = 0; s->probe = 0; } else if (s->trial && !grace && !--s->trial) { if (s->probe) { s->probe = 2; s->since = 0; } else s->hold = FSTEP_HOLD0; } // the trial held (a probe's run at kmin is judged at the next step up) if (!late) s->fit_run = s->k > kmin && cost <= FSTEP_FIT * (s->k - 1) ? s->fit_run + 1 : 0; if (s->fit_run >= FSTEP_RUN && s->since >= s->hold && !s->trial) { s->k--; s->downs++; s->trial = FSTEP_RUN; s->fit_run = 0; } - else if (slow && s->k > kmin && s->since >= s->hold && !s->trial) { // probe: straight to kmin, so the controller sees the whole deadline + else if ((slow || nvn_fstep_same(s)) && s->k > kmin && s->since >= s->hold && !s->trial) { // probe: straight to kmin, so the controller sees the whole deadline s->k = kmin; s->downs++; s->probes++; s->trial = FSTEP_RUN; s->probe = 1; s->grace = FSTEP_GRACE; s->fit_run = s->late_run = 0; } s->step = s->k + (s->debt >= 1 && s->k < FSTEP_MAX); diff --git a/dm/gfx/tests/t_fstep.c b/dm/gfx/tests/t_fstep.c index 37613e1..969b242 100644 --- a/dm/gfx/tests/t_fstep.c +++ b/dm/gfx/tests/t_fstep.c @@ -171,6 +171,48 @@ int main(void) { snprintf(b, sizeof b, " probes %.0f, %.0f, %.0f vsyncs apart; game time %.0f against real time %.2f (debt %.2f)", gaps[1], gaps[2], gaps[3], m.game, m.real, m.s.debt); check(gaps[1] >= 120 && gaps[2] >= 240 && gaps[3] >= 480 && m.real - m.game - m.s.debt > -1.01 && m.real - m.game - m.s.debt < 1.01, b); } + { // Breath of the Wild on an iPhone (2026-10-21): frames of 0.80 / 0.92 vsyncs at hold 1 for 20 s, three pipeline compiles step to 2, and the scene costs the same there: + // every other frame is above FSTEP_FIT, so no 30 fit in a row; the cost that held hold 1 starts one probe, which holds + sim m = {0}; + for (int i = 0; i < 1200; i++) run(&m, 1, i % 2 ? 0.92 : 0.80, 1, 3, 0); + run(&m, 3, 1.6, 1, 3, 0); + int stepped = m.s.k == 2; double ref = m.s.cref; + for (int i = 0; i < 600; i++) run(&m, 1, i % 2 ? 0.92 : 0.80, 1, 3, 0); + snprintf(b, sizeof b, "hitch after 20 s at hold 1, same cost at hold 2 (BotW): one probe back that holds (k %d, probes %llu, failed %llu)", m.s.k, (unsigned long long)m.s.probes, (unsigned long long)m.s.failed); + check(stepped && ref > 0.84 && ref < 0.88 && m.s.k == 1 && m.s.probes == 1 && m.s.failed == 0 && m.s.probe == 2 && m.s.downs == 1, b); + snprintf(b, sizeof b, " game time %.0f against real time %.2f (debt %.2f, dropped %.0f)", m.game, m.real, m.s.debt, m.s.dropped); + check(m.real - m.game - m.s.debt > -1.01 && m.real - m.game - m.s.debt < 1.01 && m.s.dropped == 0, b); + } + { // the scene gets heavier (1.15 / 1.25 at hold 1, 1.4 times what held): hold 2 and no probe; back to the old scene, the cost that held starts one + sim m = {0}; + for (int i = 0; i < 1200; i++) run(&m, 1, i % 2 ? 0.92 : 0.80, 1, 3, 0); + for (int i = 0; i < 1200; i++) run(&m, 1, i % 2 ? 1.25 : 1.15, 1, 3, 0); + check(m.s.k == 2 && m.s.probes == 0 && m.s.downs == 0, "heavier scene, cost 1.4 times what held hold 1: hold 2, no probe"); + for (int i = 0; i < 600; i++) run(&m, 1, i % 2 ? 0.92 : 0.80, 1, 3, 0); + check(m.s.k == 1 && m.s.probes == 1 && m.s.failed == 0, " the scene gets lighter again, back to the cost that held: one probe back that holds"); + } + { // hold 1 held only 5 s before the hitch: that run proves nothing (a device that steps up every few seconds keeps 30 rather than flap) + sim m = {0}; + for (int i = 0; i < 300; i++) run(&m, 1, i % 2 ? 0.92 : 0.80, 1, 3, 0); + run(&m, 3, 1.6, 1, 3, 0); + for (int i = 0; i < 2000; i++) run(&m, 1, i % 2 ? 0.92 : 0.80, 1, 3, 0); + check(m.s.k == 2 && m.s.probes == 0 && m.s.cref == 0, "hitch after 5 s at hold 1: no cost that held, no probe"); + } + { // the cost at hold 2 is what held, but hold 1 no longer fits (1.3 there): each probe fails, the probes spread out 1 → 2 → 4 → 8 s, and the cost that held is kept + sim m = {0}; double last = 0, gaps[5]; int ng = 0; uint64_t probes = 0; + run(&m, 1200, 0.86, 1, 3, 0); run(&m, 3, 1.6, 1, 3, 0); + for (int i = 0; i < 6000 && ng < 5; i++) { + run(&m, 1, m.s.k == 1 ? 1.3 : 0.86, 1, 3, 0); + if (m.s.probes != probes) { probes = m.s.probes; gaps[ng++] = m.real - last; last = m.real; } + } + snprintf(b, sizeof b, "same cost at hold 2 but hold 1 does not fit: probes fail (%llu of %llu), cost that held kept at %.2f", (unsigned long long)m.s.failed, (unsigned long long)m.s.probes, m.s.cref); + check(ng == 5 && m.s.failed >= 4 && m.s.cref > 0.85 && m.s.cref < 0.87, b); + 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"); + } { // 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--) { nvn_fstep s = {.k = 3, .step = 3, .kmin = 1, .hold = FSTEP_HOLD0, .shown_slack = sl ? 60.0 / 165 : 0}; diff --git a/docs/development.md b/docs/development.md index 8f894d4..406601a 100644 --- a/docs/development.md +++ b/docs/development.md @@ -147,7 +147,7 @@ The game's own work and its frame rate stay as they are; the host avoids work of - **Yield loops.** On the Switch, a yield with nothing else to run is free; a guest loop that polls the clock or a flag and yields is a busy spin on the host. Three Houses waited out each frame this way (228,000 yields a frame on an iPhone) until a per-build cut made its frame limiter sleep (see its notes), and Breath of the Wild's `PlacementMgr` and `RadarMgr` wait for work this way. After 2 ms of consecutive yields with no other SVC, each yield sleeps an eighth of the wait so far, from 10 to 250 µs (`DM_YIELD_BACKOFF=0` turns it off, `=[:]` sets the cap and the start). Three Houses on the Mac, before that cut: CPU time 78% → 19% of a core, same frame times. The back-off first started after 200 µs, which also caught Tears of the Kingdom's main thread waiting for its workers on the frame's critical path: frames ran past 33 ms and the game dropped itself to 20 fps (9 times in 300 s on the Mac; on the iPhone the threads also stayed on efficiency cores). From 2 ms on it never backs off there. - **Waits block instead of polling:** the in-process audio renderer, upload batches, `nvnQueueFinish`. A signalled event wakes only the threads waiting on it; leaving an SVC takes the global lock only while a pause (IL2CPP's garbage collector) is pending. - **Real-time policy.** The thread that acquires the window texture declares a Mach time-constraint policy, which tells the performance controller the frame deadline. `DM_RT=0` leaves it on its QoS, for measuring. -- **Frame-step probes.** A game that waits out a longer hold (`nvn_fstep.h`, 30 fps after late frames) gets parked on the efficiency cores even though the declared period stays at 60 fps. Tears of the Kingdom on an iPhone ran 6% on performance cores at 1.6 GHz, thermal 0, where its frames never fit 60 again. While under 30% of the process's CPU time runs on performance cores, in two windows of 30 presents in a row (thermal state nominal or fair, Low Power Mode off), the frame step therefore tries 60 again after its hold-off even when the cost does not fit. The first 30 frames of such a probe may be late, so the controller has time to raise the clock. A probe whose run at 60 then ends within 8 s counts as failed, so the next one waits twice as long (1 → 8 s). 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 30 fps with the same 4,900 draws, its CPU still on the performance cores, and never tried 60 again. A GPU time at the longer hold of at least 1.3 times its time at 60 fps that alone does not fit 60 (`nvn_fstep_gpu_slowed`; the reference is not updated during a probe) also allows probes, under the same gates. Log: `frame step: hold 2 → 1 … (probe, CPU N% on performance cores)` or `(probe, GPU 1.70× its time at hold 1)`, and `the probe held / failed`. +- **Frame-step probes.** A game that waits out a longer hold (`nvn_fstep.h`, 30 fps after late frames) gets parked on the efficiency cores even though the declared period stays at 60 fps. Tears of the Kingdom on an iPhone ran 6% on performance cores at 1.6 GHz, thermal 0, where its frames never fit 60 again. While under 30% of the process's CPU time runs on performance cores, in two windows of 30 presents in a row (thermal state nominal or fair, Low Power Mode off), the frame step therefore tries 60 again after its hold-off even when the cost does not fit. The first 30 frames of such a probe may be late, so the controller has time to raise the clock. A probe whose run at 60 then ends within 8 s counts as failed, so the next one waits twice as long (1 → 8 s). 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 30 fps with the same 4,900 draws, its CPU still on the performance cores, and never tried 60 again. A GPU time at the longer hold of at least 1.3 times its time at 60 fps that alone does not fit 60 (`nvn_fstep_gpu_slowed`; the reference is not updated during a probe) also allows probes, under the same gates. A hitch starts probes too. A step up after 8 s or more at 60 fps keeps what that run's frames cost (frames on time only), and a cost at the longer hold of at most 1.1 times it means the scene costs what it did (`nvn_fstep_same`; measured, so no thermal gate). The fit trial alone did not bring such a game back: the iPhone lowers its GPU clock until the work fills most of the frame, so a GPU-bound game's cost sits near the fit line (0.85 of a vsync) at either hold. Breath of the Wild, stepped up by pipeline compiles after a cutscene, took 12.8–15.2 ms of GPU time a frame at 30 fps (p50), where 60 had held for minutes, and stayed at 30 for 36 and 46 s until the player switched out and back (the same 4,900 draws took 5.8 ms right after a trip to the background and 13.5 ms half a minute later). A run under 8 s keeps no cost, so a device that steps up every few seconds stays at 30 rather than flap. Log: `frame step: hold 2 → 1 … (probe, CPU N% on performance cores)`, `(probe, GPU 1.70× its time at hold 1)` or `(probe, cost 1.04× the cost that held hold 1)`, `the probe held / failed`, and on a step up `hold 1 held at 0.86`. `DM_TEST_PRESENT_LAG=1,10;1200,30,3` imitates such a hitch on the Mac. - **Thread classes.** Every guest thread runs at QoS user-interactive, whatever its Switch priority. `DM_QOS_MAP=1` (off by default, for a phone A/B) maps priority ≤ 44 to user-interactive, 45–59 to user-initiated and the rest to utility (`DM_QOS_EDGES=44,59` moves the edges). `SetThreadPriority` moves a thread's class too, and a thread waiting on a lock lends its class to the owner until the lock is handed over. Host helper threads then set their own class (audio notify, silent sink and vsync user-interactive, movie decode user-initiated) instead of inheriting it from the guest thread that made them. - **Replay CPU.** Memory-pool addresses are cached per thread; per-encoder draw state is sent only when it changes; texture IDs of GPU-only textures stay cached when the game writes other memory; upload staging blocks are reused (Breath of the Wild used to allocate, make resident and free a 32 MB block for each re-upload of a small CPU-written texture, about 3 GB/s) and the residency set is committed only when it changed. - **Vertex bindings.** Normal and transform-feedback draws reuse resolved stream addresses until the guest address or memory-pool generation changes. The vertex argument table skips unchanged addresses, including slot 30's zero buffer; converted streams and shader-argument writes update the same table cache. `DM_NVN_VBUF_CACHE=0` restores repeated lookups and table writes for an A/B run. Every 300 frames, `vertex bindings` reports per-frame lookups/reuse and table writes/skips (the table counts include vertex shader arguments). Compare the existing submit CPU/instruction and onscreen interval logs as well: fewer binding calls alone do not establish a frame-rate gain. `make -C dm/gfx/tests GAME= t_vbuf` builds the GPU-readback regression; run it with `MTL_DEBUG_LAYER=1`, both with the cache enabled and with `DM_NVN_VBUF_CACHE=0`. @@ -170,7 +170,7 @@ The game's own work and its frame rate stay as they are; the host avoids work of **The Legend of Zelda: Breath of the Wild (`01007EF00011E000`)** - **60 fps at normal speed (30 on the Switch).** The game steps its time by the frame interval its framework picks (`ksys::VFR`: interval / 2 of a 30 fps tick). With the profile's `fps_target` (60) the host picks it instead, in whole vsyncs (`patch_nso.py` `FRAME_STEP`, `hle_nvn.c` `fstep_tick`, `nvn_fstep.h`, the same frame step as Tears of the Kingdom's): a stub at the framework's `setWaitVsyncInterval` hands the game the host's step once a frame, and the host keeps each frame on screen that many vsyncs. - - It holds 1 (60 fps). Three late frames in a row (the frame or its GPU work more than 1.7 ms over) step up to the cost, 2 (30 fps) as a rule: no 40 fps on 120 Hz screens. 30 frames that would fit the shorter hold try it again, and each failed try doubles the wait before the next (1 → 8 s). + - It holds 1 (60 fps). Three late frames in a row (the frame or its GPU work more than 1.7 ms over) step up to the cost, 2 (30 fps) as a rule: no 40 fps on 120 Hz screens. 30 frames that would fit the shorter hold try it again, and each failed try doubles the wait before the next (1 → 8 s). After a hitch, a cost at 30 like the one that held 60 tries it too (probes, [Power](#power)). - Real time lost to a late frame or a hitch is paid back one vsync per frame (at most 15; the rest of a long load is dropped), so game time = real time. The log line `frame step: game time 30.00 s in 30.00 s (1.000 of real time)` checks it every 1,800 presents, with the frames held 1 / 2 / 3 vsyncs, late frames, paid-back vsyncs and hold changes. - Scenes that fix the game's step themselves (the VFR's override: 2 at `main+0xc3b588` / `+0xc5ebf8`, 1 at `main+0xc609d8`) keep it, and the host holds frames that long (log line `the game fixes its own step`; not reached on the Mac yet). - Mac (M4 Pro), from the title into the save's scene: 1.000 of real time at 60; with 12 ms added to every frame (`DM_TEST_PRESENT_LAG=1,12`) it drops to 30 and stays at 1.000. `DM_FPS_TARGET=30` holds 30 (saves power).