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).