diff --git a/dm/games/04008E001F098000/README.md b/dm/games/04008E001F098000/README.md index 88aed24729..b975be812b 100644 --- a/dm/games/04008E001F098000/README.md +++ b/dm/games/04008E001F098000/README.md @@ -39,6 +39,7 @@ There is no game-specific patch and no rewriter cut. Everything else it needed i - In the doubles match on the M6 (window, 2026-11-12): frame time p50 16.67 / p95 17.3-17.9 ms, NVN submit and present about 3.4 ms, CPU about 92% of one core. - bymaxmarc's measurements on the M2 Max (scene loads of 110-225 ms, a 250 ms GPU frame at boot) are in [PR #2387](https://github.com/seedds/nxnative/pull/2387)'s note. +- **Two players, pool tables.** In bymaxmarc's two-player skate session (M2 Max, 2026-11-12) the texture pool table (33,024 IDs) was built whole about 3 times a frame from the game's first sparse mapping, at about frame 3,900: 14.7-15.4 ms of a 25 ms submit, with the dynamic resolution at its floor (854x240 a player). Every refusal to patch was `a sparse mapping` (213,859 of 214,245 by frame 8,000, `sparse mappings 12`): the host refused a patch over any storage change once any mapping had been made. bymaxmarc's two-player Avioneta session of 2026-11-11 built the table about 3 times a frame for 13.9 ms of a 23 ms submit, at 30 and then 24 fps, before the log split the refusals by cause; likely the same mapping, not confirmed. A table is now built whole only over a mapping made since its walk ([Pool tables patched instead of built whole](../../../docs/reference/nvn-objects.md#pool-tables-patched-instead-of-built-whole)); in a player's 11-minute session with the change on 2026-11-12, two-player skate and two-player Avioneta with no crash, the `pool tables up to frame` lines counted 0.00 tables built whole a frame (about 3 before), at 30 fps (present interval 2); two-player Avioneta held mostly 2134x600 a view, while skate's dynamic resolution stayed at its 854x240 floor a view, which is not the tables but the GPU clock at 30 fps (a separate change). - Out of focus the game pauses and its GPU queues wait on it for 6-10 s at a time (`[RED] queue 4: an event wait has held it …`); runs set `DM_FOCUS_KEEP=1`. ## Motion controls @@ -70,4 +71,5 @@ The work folder `04008E001F098000-work` beside the dump has `run/boot.sh < - [The fragment's shading-rate size reads 1 x 1](../../../docs/reference/shaders.md#the-fragments-shading-rate-size-reads-1-x-1) - [A render-target view of another class: the texture is upgraded, not drawn in its base format](../../../docs/reference/rendering.md#a-render-target-view-of-another-class-the-texture-is-upgraded-not-drawn-in-its-base-format) - [Conditional rendering evaluated at replay](../../../docs/reference/rendering.md#conditional-rendering-evaluated-at-replay) +- [Pool tables patched instead of built whole](../../../docs/reference/nvn-objects.md#pool-tables-patched-instead-of-built-whole) (a sparse mapping long before) - [Tests of one uniform word folded into switches](../../../docs/reference/shaders.md#tests-of-one-uniform-word-folded-into-switches) and [Specialized second functions in the offline pipeline archive](../../../docs/reference/shaders.md#specialized-second-functions-in-the-offline-pipeline-archive) diff --git a/dm/gfx/nvn/hle_nvn.c b/dm/gfx/nvn/hle_nvn.c index a4d838a0d1..74dce352de 100644 --- a/dm/gfx/nvn/hle_nvn.c +++ b/dm/gfx/nvn/hle_nvn.c @@ -2013,9 +2013,9 @@ static void power_line(uint64_t frame) { if (fs_ != pfs || fr != pfr || ft != pft) LOG("[nvn] same-pass feedback up to frame %llu, per frame: %.1f snapshots taken, %.1f draws reused one, %.1f read the tile (%.1f waited for their pipeline)", frame, (fs_ - pfs) / nf, (fr - pfr) / nf, (ft - pft) / nf, (fw - pfw) / nf); pfs = fs_; pfr = fr; pft = ft; pfw = fw; - void mtl_pool_stats(uint64_t s[6]); static uint64_t pps[6]; uint64_t ps[6]; mtl_pool_stats(ps); // the tables of handles traced to no slot (nvn_mtl.m pool_gpu) - if (ps[0] != pps[0] || ps[2] != pps[2] || ps[3] != pps[3]) LOG("[nvn] pool tables up to frame %llu, per frame: %.2f built whole (%.2f ms), %.2f patched, %.2f kept; of these two %.2f over memory pool changes, %.2f over a new submission", - frame, (ps[0] - pps[0]) / nf, (ps[1] - pps[1]) / 1e3 / nf, (ps[2] - pps[2]) / nf, (ps[3] - pps[3]) / nf, (ps[4] - pps[4]) / nf, (ps[5] - pps[5]) / nf); + void mtl_pool_stats(uint64_t s[8]); static uint64_t pps[8]; uint64_t ps[8]; mtl_pool_stats(ps); // the tables of handles traced to no slot (nvn_mtl.m pool_gpu) + if (ps[0] != pps[0] || ps[2] != pps[2] || ps[3] != pps[3]) LOG("[nvn] pool tables up to frame %llu, per frame: %.2f built whole (%.2f ms), %.2f patched, %.2f kept; of these two %.2f over memory pool changes, %.2f over a new submission; registrations %.1f, storage changes %.1f", + frame, (ps[0] - pps[0]) / nf, (ps[1] - pps[1]) / 1e3 / nf, (ps[2] - pps[2]) / nf, (ps[3] - pps[3]) / nf, (ps[4] - pps[4]) / nf, (ps[5] - pps[5]) / nf, (ps[6] - pps[6]) / nf, (ps[7] - pps[7]) / nf); memcpy(pps, ps, sizeof pps); void mtl_gsb_stats(uint64_t *, uint64_t *); static uint64_t pgn, pgb; uint64_t gn, gb; mtl_gsb_stats(&gn, &gb); // GPU textures' writes stored into their storage (nvn_mtl.m gsb_store) if (gn != pgn) LOG("[nvn] GPU texture storage up to frame %llu: %llu regions, %.1f MB written into their storage on the GPU (%.2f regions, %.2f MB per frame)", diff --git a/dm/gfx/nvn/nvn_mtl.m b/dm/gfx/nvn/nvn_mtl.m index 9b6a52b250..61a11115d4 100644 --- a/dm/gfx/nvn/nvn_mtl.m +++ b/dm/gfx/nvn/nvn_mtl.m @@ -6326,14 +6326,17 @@ void mtl_prog_snapshot(prog_t *copy,const prog_t *source) { // Texture ID views are valid for an epoch (view_ser), not for every serial table change: ser_sync takes the notes (own_note) and sends only the IDs registered with a noted serial // down the slow path; a change without a note starts a new epoch, which sends them all (what every g_ser_gen bump did before). A battle map in Fire Emblem: Fortune's Weave changes a // few serials a frame (the per-stage program ring in bind_stages among them), and each change made the next pool walk (pool_gpu) resolve all ~5,000 IDs again, 2 ms a frame. -// Once any sparse mapping exists (nvn_sparse_gen) every change starts a new epoch, as before: a sparse texture's page-table changes bump no generation the ID cache follows. Under g_rmu -static uint64_t g_treg_ep = 1, g_ser_synced = 1; +// A sparse mapping since the last sync (nvn_sparse_gen) starts a new epoch too: a sparse texture's page-table changes bump no generation the ID cache follows, and its view is checked +// against them only on the slow path. Only a mapping since the last sync: once any mapping existed every change started a new epoch, and Nintendo Switch Sports Resort, which maps +// a sparse pool a few times a minute in two-player skate, resolved its 17,000 texture IDs again at each of its 60 storage changes a frame. Under g_rmu +static uint64_t g_treg_ep = 1, g_ser_synced = 1, g_sparse_synced; static void ser_sync(void) { uint64_t s[SER_NOTES]; uint64_t sg; unsigned n = own_notes_take(s, &sg); - if (sg - g_ser_synced != n || nvn_sparse_gen()) g_treg_ep++; + uint64_t vg = nvn_sparse_gen(); + if (sg - g_ser_synced != n || vg != g_sparse_synced) g_treg_ep++; else for (unsigned i = 0; i < n; i++) for (int k = (int)(uintptr_t)nvn_hm_get(&g_treg_serial, &s[i]); k; k = g_treg[k - 1].next) g_treg[k - 1].view_ser = 0; - g_ser_synced = sg; + g_ser_synced = sg; g_sparse_synced = vg; } static inline uint64_t treg_ep(void) { if (g_ser_gen != g_ser_synced) ser_sync(); return g_treg_ep; } static int *g_treg_transient; static size_t g_ntreg_transient, g_treg_transient_cap; @@ -10133,7 +10136,7 @@ static void warm_start(void) { // 1.4-1.6 a frame of 9 ms, 50-55 fps for five seconds; PR #2389) enum { POOL_IDS_MAX = 8192 }; // the IDs a patch takes at most, and a list holds typedef struct { int *v, n, cap; } pool_ids; -typedef struct { __strong id buf; uint64_t gpu, reg, ser, frame, sub; int samp, dtt, dpf, base, left, left_req; pool_ids dead, trans; int over; } pool_snap; // over: its last walk found the budget full, so what it left out has its stand-in (pool_stand) +typedef struct { __strong id buf; uint64_t gpu, reg, ser, frame, sub; int samp, dtt, dpf, base, left, left_req; pool_ids dead, trans; int over; uint64_t vgen; } pool_snap; // over: its last walk found the budget full, so what it left out has its stand-in (pool_stand); vgen: the sparse mappings (nvn_sparse_gen) before its walk static pool_snap g_psnap[POOL_SNAPS]; static unsigned g_psnap_next; static uint64_t g_psnap_gen = 1; // bumped by each rebuild or patch: an argument block holding a pool address is not reused past it (encode_args_) static uint64_t g_stat_pool_builds; static double g_stat_pool_ms; @@ -10142,7 +10145,9 @@ static void warm_start(void) { // [2] resized, [3] a change without a note, [4] a new submission over more than POOL_IDS_MAX retired IDs. pchg / schg: of those patched or kept, the ones over memory pool changes // and over a new submission static uint64_t g_stat_pool_patched, g_stat_pool_kept, g_stat_pool_nopatch[5], g_stat_pool_pchg, g_stat_pool_schg; -void mtl_pool_stats(uint64_t s[6]) { s[0] = g_stat_pool_builds; s[1] = (uint64_t)(g_stat_pool_ms * 1000); s[2] = g_stat_pool_patched; s[3] = g_stat_pool_kept; s[4] = g_stat_pool_pchg; s[5] = g_stat_pool_schg; } // the power line's pool table counts (hle_nvn.c) +// [0] split by its cause: registrations past their log (REG_LOG), storage changes past theirs (SER_LOG), a sparse mapping since the table was made, more than 8192 IDs to take again. Which one decides how a game that builds its tables whole every frame can be helped +static uint64_t g_stat_pool_many[4]; +void mtl_pool_stats(uint64_t s[8]) { s[0] = g_stat_pool_builds; s[1] = (uint64_t)(g_stat_pool_ms * 1000); s[2] = g_stat_pool_patched; s[3] = g_stat_pool_kept; s[4] = g_stat_pool_pchg; s[5] = g_stat_pool_schg; s[6] = atomic_load(&g_reg_gen); s[7] = atomic_load(&g_ser_gen); } // the power line's pool table counts (hle_nvn.c) static void ids_push(pool_ids *l, int id_) { // past POOL_IDS_MAX the list is dropped (n -1) if (l->n < 0) return; if (l->n == POOL_IDS_MAX) { free(l->v); *l = (pool_ids){NULL, -1, 0}; return; } @@ -10447,7 +10452,7 @@ static uint64_t pool_entry(int samp, int dtt, int id_, uint64_t limit, int *gone } static uint64_t pool_gpu(int samp, int dtt, int dpf, int base, __unsafe_unretained id *out) { pool_seen_scan(); - uint64_t rg = atomic_load(&g_reg_gen), sg = samp ? 0 : atomic_load(&g_ser_gen); // a sampler table follows registrations alone + uint64_t rg = atomic_load(&g_reg_gen), sg = samp ? 0 : atomic_load(&g_ser_gen), vg = samp ? 0 : nvn_sparse_gen(); // a sampler table follows registrations alone pool_snap *p = NULL, *free_ = NULL; for (int i = 0; i < POOL_SNAPS && !p; i++) { pool_snap *q = &g_psnap[i]; if (!q->buf) { if (!free_) free_ = q; } @@ -10457,11 +10462,13 @@ static uint64_t pool_gpu(int samp, int dtt, int dpf, int base, __unsafe_unretain if (p && p->reg == rg && p->ser == sg && !stale && !walk_on) { *out = p->buf; return p->gpu; } // Only registrations and noted storage changes since the table was built (each generation in its log), the IDs without storage after a pool change and the transient IDs in a // new submission: take those IDs again, by the walk's rules and within what this frame may still make. The table is kept when none of them changes an entry; otherwise the old - // one is copied and patched, as draws already encoded read the old one. A sparse texture's page table follows no note (ser_sync), so then it is built whole. Ocarina of Time, + // one is copied and patched, as draws already encoded read the old one. A sparse texture's page table follows no note (ser_sync), so a storage change after a sparse mapping the + // table was walked before builds it whole (the walk sees the new epoch); with no mapping since, its entries change only by the notes, as without sparse pools. Ocarina of Time, // a seven-minute walk through Kokiri Forest: building the tables whole took about 53 s (61,000 builds) int nn = (samp ? g_nsreg : g_ntreg) - base; if (nn < 1) nn = 1; if (p && !walk_on) do { - if (rg - p->reg >= REG_LOG || sg - p->ser >= SER_LOG || (!samp && p->ser != sg && nvn_sparse_gen())) { g_stat_pool_nopatch[0]++; break; } + { int why = rg - p->reg >= REG_LOG ? 0 : sg - p->ser >= SER_LOG ? 1 : !samp && p->ser != sg && p->vgen != vg ? 2 : -1; + if (why >= 0) { g_stat_pool_nopatch[0]++; g_stat_pool_many[why]++; break; } } if (*(const uint32_t *)p->buf.contents != (uint32_t)nn) { g_stat_pool_nopatch[2]++; break; } static int *ids; static size_t idcap; size_t nid = 0; int known = 1, pools = 0; #define POOL_ID(x) do { if (nid == idcap) { idcap = idcap ? idcap * 2 : 256; ids = realloc_or_die(ids, idcap * sizeof *ids, "the pool table patch"); } ids[nid++] = (x); } while (0) @@ -10487,7 +10494,7 @@ static uint64_t pool_gpu(int samp, int dtt, int dpf, int base, __unsafe_unretain } #undef POOL_ID if (!known) { g_stat_pool_nopatch[3]++; break; } - if (nid >= POOL_IDS_MAX) { g_stat_pool_nopatch[0]++; break; } + if (nid >= POOL_IDS_MAX) { g_stat_pool_nopatch[0]++; g_stat_pool_many[3]++; break; } uint64_t *e = (uint64_t *)((uint8_t *)p->buf.contents + DMSH_POOL_HEAD), dummy = e[nn], room = samp ? UINT64_MAX : pool_room(), spent = g_walk_spent; int gone = 0, left = 0, left_req = 0; id b = nil; uint64_t *be = NULL; pool_ids nd = {0}, nt = {0}; ids_begin(0); ids_begin(1); @@ -10535,7 +10542,7 @@ static uint64_t pool_gpu(int samp, int dtt, int dpf, int base, __unsafe_unretain e[n] = dummy; pool_table_give(p->buf); free(p->dead.v); free(p->trans.v); // Retired registrations drop their views at submission end; their resource IDs cannot outlive that submission (trans, taken again by the next one) - *p = (pool_snap){b, b.gpuAddress, rg, sg, atomic_load(&g_cur_frame), nt.n ? g_sub_serial : 0, samp, dtt, dpf, base, left, left_req, nd, nt, samp || g_walk_over}; + *p = (pool_snap){b, b.gpuAddress, rg, sg, atomic_load(&g_cur_frame), nt.n ? g_sub_serial : 0, samp, dtt, dpf, base, left, left_req, nd, nt, samp || g_walk_over, vg}; g_psnap_gen++; static mach_timebase_info_data_t tb; if (!tb.denom) mach_timebase_info(&tb); double ms = (double)(mach_absolute_time() - t0) * tb.numer / tb.denom / 1e6; g_stat_pool_ms += ms; @@ -10555,11 +10562,11 @@ static uint64_t pool_gpu(int samp, int dtt, int dpf, int base, __unsafe_unretain if (g_stat_pool_builds++ < 16 || g_stat_pool_builds % 1000 == 0 || log_gone) MLOG("pool for handles traced to no slot: %s pool (texture type %d, class %d) at base %d, %d IDs of which %d hold %s and %d have no storage (memory pool finalized or gone), built in %.2f ms (%llu builds, %.0f ms in all; " "built because new %llu, registration %llu, storage %llu, retired IDs %llu, textures left out %llu; taken alone: %llu tables patched, %llu kept (%llu over memory pool changes, %llu over a new submission); " - "not: too many or sparse %llu, a pool change over too many IDs without storage %llu, a new submission over too many retired IDs %llu, resized %llu, a change without a note %llu)", samp ? "sampler" : "texture", + "not: too many or sparse %llu (registrations past the log %llu, storage changes past the log %llu, a sparse mapping %llu, IDs past 8192 %llu; registrations so far %llu, storage changes so far %llu, sparse mappings %llu), a pool change over too many IDs without storage %llu, a new submission over too many retired IDs %llu, resized %llu, a change without a note %llu)", samp ? "sampler" : "texture", samp ? -1 : dtt, samp ? -1 : dpf, base, n, real, samp ? "samplers" : "textures of the type", gone, ms, (unsigned long long)g_stat_pool_builds, g_stat_pool_ms, (unsigned long long)g_stat_pool_why[0], (unsigned long long)g_stat_pool_why[1], (unsigned long long)g_stat_pool_why[2], (unsigned long long)g_stat_pool_why[3], (unsigned long long)g_stat_pool_why[4], (unsigned long long)g_stat_pool_patched, (unsigned long long)g_stat_pool_kept, (unsigned long long)g_stat_pool_pchg, (unsigned long long)g_stat_pool_schg, - (unsigned long long)g_stat_pool_nopatch[0], (unsigned long long)g_stat_pool_nopatch[1], (unsigned long long)g_stat_pool_nopatch[4], (unsigned long long)g_stat_pool_nopatch[2], (unsigned long long)g_stat_pool_nopatch[3]); + (unsigned long long)g_stat_pool_nopatch[0], (unsigned long long)g_stat_pool_many[0], (unsigned long long)g_stat_pool_many[1], (unsigned long long)g_stat_pool_many[2], (unsigned long long)g_stat_pool_many[3], (unsigned long long)atomic_load(&g_reg_gen), (unsigned long long)atomic_load(&g_ser_gen), (unsigned long long)nvn_sparse_gen(), (unsigned long long)g_stat_pool_nopatch[1], (unsigned long long)g_stat_pool_nopatch[4], (unsigned long long)g_stat_pool_nopatch[2], (unsigned long long)g_stat_pool_nopatch[3]); *out = b; return p->gpu; } static id dummy_buf(void) { static id b; if (!b) { b = [g_dev newBufferWithLength:64 << 10 options:MTLResourceStorageModeShared]; res_add(b); } return b; } diff --git a/dm/gfx/tests/t_pool_notes.m b/dm/gfx/tests/t_pool_notes.m index 8af3b9d877..bdf0aaab25 100644 --- a/dm/gfx/tests/t_pool_notes.m +++ b/dm/gfx/tests/t_pool_notes.m @@ -218,6 +218,35 @@ int main(void) { @autoreleasepool { "no table is built and no ID went down the slow path: the takeover's note names no ID"); same_as_whole(b18, "the patched table holds what a table built whole does (a finalized twin's base taken over)"); + // More registrations than the ring of notes holds since the table was made: the table is built whole, and the log line's split of "too many" names the registrations + table(); + builds = g_stat_pool_builds; uint64_t many_reg = g_stat_pool_many[0], many_ser = g_stat_pool_many[1], many_ids = g_stat_pool_many[3]; + for (int i = 0; i <= REG_LOG; i++) mtl_reg_tex(5, (const tex_t *)(i & 1 ? &t5 : &t9), NULL); // a registration that repeats the ID's texture changes nothing, so alternate two + id b19 = table(); + CHECK(g_stat_pool_builds == builds + 1 && g_stat_pool_many[0] == many_reg + 1, "registrations past the log build the table whole, counted as such"); + CHECK(g_stat_pool_many[1] == many_ser && g_stat_pool_many[3] == many_ids, "and not as storage changes past their log or IDs past the patch's limit"); + CHECK(entry(b19, 5) != dummy_of(b19), "ID 5 still holds its texture"); + + // A sparse mapping: a sparse texture's page table follows no note, so a storage change after a mapping builds a table made before it whole and sends every ID down the slow path + // once. Only once: a table made after the last mapping is patched over noted storage changes, and no ID goes down the slow path again (Nintendo Switch Sports Resort maps a sparse + // pool a few times a minute in two-player skate; once any mapping existed, every storage change built its 33,024-entry table whole, about 3 times a frame) + uint64_t s5 = ((const tex_t *)&t5)->serial, many_sparse = g_stat_pool_many[2]; + atomic_fetch_add(&g_vp_gen, 1); + builds = g_stat_pool_builds; ep = g_treg_ep; own_note(s5); + id b20 = table(); + CHECK(g_stat_pool_builds == builds + 1 && g_stat_pool_many[2] == many_sparse + 1, "a storage change after a mapping builds a table made before it whole, counted as a sparse mapping"); + CHECK(g_treg_ep != ep && entry(b20, 5) != dummy_of(b20), "and sends every ID down the slow path, ID 5 still holding its texture"); + builds = g_stat_pool_builds; ep = g_treg_ep; patched = g_stat_pool_patched; kept = g_stat_pool_kept; own_note(s5); + id b21 = table(); + CHECK(g_stat_pool_builds == builds && g_stat_pool_many[2] == many_sparse + 1 && g_stat_pool_patched + g_stat_pool_kept == patched + kept + 1, + "with no mapping since the table was made, a noted storage change patches or keeps it"); + CHECK(g_treg_ep == ep && entry(b21, 5) == entry(b20, 5), "and no ID goes down the slow path"); + same_as_whole(b21, "the table taken alone holds what a table built whole does (a mapping long before)"); + atomic_fetch_add(&g_vp_gen, 1); + builds = g_stat_pool_builds; own_note(s5); + table(); + CHECK(g_stat_pool_builds == builds + 1 && g_stat_pool_many[2] == many_sparse + 2, "another mapping builds the table whole again at the next storage change"); + mtl_reg_tex(1, NULL, NULL); mtl_reg_tex(2, NULL, NULL); mtl_reg_tex(5, NULL, NULL); mtl_reg_tex(6, NULL, NULL); mtl_reg_tex(7, NULL, NULL); mtl_reg_tex(9, NULL, NULL); mtl_tex_release(&t2); h_nvnTextureFinalize(&t1); h_nvnTextureFinalize(&t5); h_nvnTextureFinalize(&t9); h_nvnTextureFinalize(&t10); h_nvnMemoryPoolFinalize((NVNmemoryPool *)&dpool); h_nvnMemoryPoolFinalize((NVNmemoryPool *)&epool); diff --git a/docs/reference/nvn-objects.md b/docs/reference/nvn-objects.md index c70f16b0e8..8e4ae5d50e 100644 --- a/docs/reference/nvn-objects.md +++ b/docs/reference/nvn-objects.md @@ -191,7 +191,7 @@ A game may tell NVN objects apart by their bytes, without an NVN call. A host re - **Symptom:** the submit thread spends milliseconds a frame in `pool_gpu` → `tex_by_id` → `tex_mtl` / `tex_get` (a `sample` of the process), and the `[nvn-mtl] pool for handles traced to no slot … built in X ms` line shows a rebuild of the texture pool table every frame at about 2 ms. Fire Emblem Fortune's Weave's battle map: 1.9 ms a build, three builds a frame, about 14 % of the main thread. - **Cause:** a texture ID's cached view was valid while `g_ser_gen` stood still, and every change to the serial table bumped it. A game that changes a few serials a frame (the per-stage program ring in `bind_stages` does) therefore sent every registered ID down the slow path at the next lookup, and `pool_gpu` walks all of them (about 5,000 in Fortune's Weave). Packaged (CPU-written) IDs took the slow path again on any guest write anywhere. -- **Fix:** for every game (the generation and its notes are the resource owner's, `nvn_owner.m`; the readers are `nvn_mtl.m`'s). A change to one serial's object bumps `g_ser_gen` through `own_note`, which records the serial; ID views are valid for an epoch (`treg_ep`), and `ser_sync` invalidates only the IDs registered with a noted serial (`g_treg_serial`). A bump without a note (the sharing table, upgrades, a dropped upload), more than 64 notes, or any sparse mapping (`nvn_sparse_gen`) starts a new epoch, which re-checks every ID as before. A packaged ID whose pages nobody wrote since its upload stamp keeps its view (the test `tex_mtl_` makes), and `tex_fast_rid` returns the cached resource ID of a view `tex_by_id` kept. Other users of `g_ser_gen` (argument blocks, `pool_gpu`'s own key) are unchanged. +- **Fix:** for every game (the generation and its notes are the resource owner's, `nvn_owner.m`; the readers are `nvn_mtl.m`'s). A change to one serial's object bumps `g_ser_gen` through `own_note`, which records the serial; ID views are valid for an epoch (`treg_ep`), and `ser_sync` invalidates only the IDs registered with a noted serial (`g_treg_serial`). A bump without a note (the sharing table, upgrades, a dropped upload), more than 64 notes, or a sparse mapping since the last sync (`nvn_sparse_gen`, `g_sparse_synced`) starts a new epoch, which re-checks every ID as before. A packaged ID whose pages nobody wrote since its upload stamp keeps its view (the test `tex_mtl_` makes), and `tex_fast_rid` returns the cached resource ID of a view `tex_by_id` kept. Other users of `g_ser_gen` (argument blocks, `pool_gpu`'s own key) are unchanged. - **Check:** `make -C dm/gfx/tests GAME= run` (`t_cpu_tex` among them). Fortune's Weave's battle map (975 draws a frame, MacBook Pro M1 Pro): pool build 1.9 → 1.2 ms, submit 52.5k → 42.8k instructions per draw. - **Found on:** [Fire Emblem Fortune's Weave](../../dm/games/04007EF01F8AE000/README.md). @@ -205,9 +205,10 @@ A game may tell NVN objects apart by their bytes, without an NVN call. A host re - A table keeps the IDs whose entries are views of retired registrations (`pool_snap.trans`), those the read scan writes in place (`pool_seen_scan`) among them; the next submission takes only those again, and such a table does not stand past its submission. - A takeover records a note that names no texture (`own_note(0)`): it changes no serial's texture, as an entry a live serial holds is not evicted (`serial_table_holds`) and one a finalized serial holds through `g_ghost` is only unref'd. A live sRGB / linear twin holds a view of the shared texture, which counts as holding the texture ([A view's entry did not hold its parent](#a-views-entry-did-not-hold-its-parent)). A retired ID's view goes at the submission's end, and the next submission takes those IDs again. - A patch makes textures within what the frame may still make (`pool_room`), as a walk does; a texture left out counts in the table's `left`, and the walk goes on next frame as before. - - Past `POOL_IDS_MAX` IDs on a list, a note the ring has overwritten, a bump without a note, a pool that grew, or a sparse mapping, the table is built whole as before. The log line says why tables were built whole and how many were patched or kept; the power lines gain `[nvn] pool tables up to frame …`. + - Past `POOL_IDS_MAX` IDs on a list, a note the ring has overwritten, a bump without a note, a pool that grew, or a storage change after a sparse mapping made since the table's walk (`pool_snap.vgen`), the table is built whole as before. The log line says why tables were built whole and how many were patched or kept; the power lines gain `[nvn] pool tables up to frame …`. - **Check:** `make -C dm/gfx/tests GAME= check_t_pool_notes`: a pool change, a finalized pool, a retired ID across submissions, a takeover, a registration past the budget and a read written in place each patch or keep the table, and the patched tables hold what tables built whole do. The notes and the patching were measured on the host of [PR #1387](https://github.com/seedds/nxnative/pull/1387) (MacBook Pro M5 Pro, a six-minute walk through Kokiri Forest): tables built whole about 9,600 (26 s) with only pool changes noted, 14 (0.3 s) with all three. Open: the counts on this walk, which also has the read bitmap and the budget, are not measured yet. The table patching and the first notes are [PR #1299](https://github.com/seedds/nxnative/pull/1299)'s; the unnoted takeover was found in [issue #1326](https://github.com/seedds/nxnative/issues/1326). -- **Found on:** [The Legend of Zelda: Ocarina of Time](../../dm/games/0400486024DEC000/README.md). +- **Resolved, a sparse mapping long before:** a storage change was refused a patch whenever any sparse mapping had ever been made (`nvn_sparse_gen()` not 0), and `ser_sync` started a new epoch at every sync after one, though a sparse texture's page table changes only at a mapping. Nintendo Switch Sports Resort's two-player skate (a player's session, 2026-11-12) maps a sparse pool a few times a minute (`sparse mappings 2` by frame 4,200, 12 by frame 8,000): from its first mapping, at about frame 3,900, its 33,024-entry texture table was built whole about 3 times a frame, 14.7-15.4 ms of a submit thread's 25 ms, at its 60-90 storage changes a frame; the refusal line put 213,859 of 214,245 refusals under `a sparse mapping`, none under the rings or the ID limit. The refusals are split by cause in the `pool for handles traced to no slot` line (`registrations past the log`, `storage changes past the log`, `a sparse mapping`, `IDs past 8192`, with the registration, storage-change and sparse-mapping generations so far), and the `pool tables up to frame` line gives registrations and storage changes per frame; before the split every refusal read `too many or sparse` and a build was classed by the first generation that moved, so a build after a mapping read as `storage`. Each table now keeps the sparse generation of its walk (`pool_snap.vgen`) and is built whole only when a mapping came since, and `ser_sync` starts a new epoch only for a mapping since the last sync (`g_sparse_synced`). `check_t_pool_notes` checks that a table made after the last mapping is patched over noted storage changes without sending the IDs down the slow path, and that a mapping after it still builds it whole (three of its checks fail without the change). The two-player Avioneta session of 2026-11-11 had the same pattern counted as one cause (`too many or sparse`, all `storage` builds), likely the same mapping; its log predates the split, and a sparse pool's mappings are not logged one by one (its one `MapVirtual` line is a whole-pool alias), so that is not confirmed. A second player cannot be scripted (`DM_KEYS` and `DM_CTL` drive player 1 only), so the two-player game is not run with the change. +- **Found on:** [The Legend of Zelda: Ocarina of Time](../../dm/games/0400486024DEC000/README.md); [Nintendo Switch Sports Resort](../../dm/games/04008E001F098000/README.md) (the sparse mapping). ### More finalized textures in use than their table held