From 5b0c11a4f307bd7bb5fdebfa23664a9c67f3b10a Mon Sep 17 00:00:00 2001 From: seedds Date: Mon, 26 Oct 2026 02:06:27 +0800 Subject: [PATCH] Fix NVN dirty-page upload races (#197) Take upload stamps before protection, initialize cached checks from those stamps, and wait out pages another thread is unprotecting before retrying. Publish page generations before advancing the clock again in both the fault handler and batched host pre-write path. A shared stamp_page helper closes the remaining batched publication race in the original PR. Add deterministic publication regressions for both paths and document the mechanism and the limits of the game-level evidence. The regressions fail on main, and the batched case also fails with the original PR applied. Validation: full graphics suite, Wonder Mac host build, all 133 iOS source compile checks, and documentation tests pass. No fresh gameplay or iPhone runtime validation. Co-authored-by: Aleksandr Svidchenkov --- dm/games/0100F2C0115B6000/README.md | 1 + dm/gfx/nvn/nvn_dirty.c | 15 ++++- dm/gfx/nvn/nvn_dirty.h | 2 +- dm/gfx/nvn/nvn_mtl.m | 17 +++--- dm/gfx/tests/t_dirty_race.c | 87 +++++++++++++++++++++++++++++ docs/agents/symptoms.md | 1 + docs/reference/nvn-objects.md | 8 +++ 7 files changed, 120 insertions(+), 11 deletions(-) diff --git a/dm/games/0100F2C0115B6000/README.md b/dm/games/0100F2C0115B6000/README.md index 7055d03..8a8c68e 100644 --- a/dm/games/0100F2C0115B6000/README.md +++ b/dm/games/0100F2C0115B6000/README.md @@ -71,6 +71,7 @@ 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: 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). - **Pipeline keys omit absent-target blend and mask state**, and old prewarm rows are normalized and deduplicated: [Pipeline keys omit unused colour state](../../../docs/reference/shaders.md#pipeline-keys-omit-unused-colour-state). - **Bounded upload staging and retirement on upload completion:** [Upload staging grows within one rendering submission](../../../docs/reference/memory.md#upload-staging-grows-within-one-rendering-submission). - **Resolved: registry lock priority inversion** (`g_rmu`): [Registry lock priority inheritance](../../../docs/reference/frame-rate.md#registry-lock-priority-inheritance). Ported from MacNativeGames; checked here through the Metal registry view tests, without a new gameplay contention measurement. diff --git a/dm/gfx/nvn/nvn_dirty.c b/dm/gfx/nvn/nvn_dirty.c index da0778a..d31d8cf 100644 --- a/dm/gfx/nvn/nvn_dirty.c +++ b/dm/gfx/nvn/nvn_dirty.c @@ -67,9 +67,15 @@ static range_t *find_span(uintptr_t a, size_t len) { for (int i = 0; i < n; i++) { range_t *r = rg(i); if (live_at(i) && a >= r->base && a + len <= r->end) return r; } return NULL; } +static void stamp_page(range_t *r, uintptr_t p) { + atomic_store(&r->gen[p], atomic_fetch_add(&g_now, 1) + 1); + // Publish completion after the generation: a checker may have cached "clean" using the incremented clock before the generation store. Both fault and host-write paths + // must invalidate that cached clock, as the page stays writable until the next upload protects it again. + atomic_fetch_add(&g_now, 1); +} static void unprotect_page(range_t *r, uintptr_t p) { // The caller has already changed the state from PROT to BUSY mp(r->base + (p << g_shift), g_pg, PROT_READ | PROT_WRITE); - atomic_store(&r->gen[p], atomic_fetch_add(&g_now, 1) + 1); + stamp_page(r, p); atomic_store(&r->state[p], PG_NONE); } // Unprotect one page of a range: if protected, unprotect and stamp it; if another thread is changing its protection, wait for it to finish and look again -- if the other side was setting read-only, the page is PROT after the wait and still has to be unprotected @@ -99,7 +105,7 @@ static void release_pages(range_t *r, uintptr_t p, uintptr_t q, _Atomic uint64_t if (took) { if (!open) { run = i; open = 1; } continue; } if (open) { mp(r->base + (run << g_shift), (i - run) << g_shift, PROT_READ | PROT_WRITE); - for (uintptr_t k = run; k < i; k++) { atomic_store(&r->gen[k], atomic_fetch_add(&g_now, 1) + 1); atomic_store(&r->state[k], PG_NONE); } + for (uintptr_t k = run; k < i; k++) { stamp_page(r, k); atomic_store(&r->state[k], PG_NONE); } if (count) atomic_fetch_add(count, i - run); open = 0; } @@ -175,13 +181,16 @@ void nvn_dirty_protect(const void *addr, size_t len) { if (!r) return; uintptr_t a = (uintptr_t)addr, p = (a - r->base) >> g_shift, q = (a + len - 1 - r->base) >> g_shift; // Consecutive pages are merged into one mprotect (per-page calls reach thousands per frame on the first upload of large textures; character select page startup frame 5: 4888 calls). - // Pages already PROT / BUSY break the consecutive run: do not protect again, and do not touch pages another thread is changing + // Pages already PROT / BUSY break the consecutive run: do not protect again, and do not touch pages another thread is changing. + // A BUSY page is waited out (the run so far is protected first, so none of ours is BUSY meanwhile) and looked at again: an unprotect in progress leaves the page writable + // with a generation the caller's stamp may cover, so skipped, its later writes never faulted and the texture stayed stale (MacNativeGames a166602) uintptr_t run = 0; int open = 0; for (uintptr_t i = p; i <= q + 1; i++) { uint8_t st = PG_NONE; int took = i <= q && atomic_compare_exchange_strong(&r->state[i], &st, PG_BUSY); if (took && !open) { run = i; open = 1; } if (!took && open) { mp(r->base + (run << g_shift), (i - run) << g_shift, PROT_READ); for (uintptr_t k = run; k < i; k++) atomic_store(&r->state[k], PG_PROT); open = 0; } + if (!took && i <= q && st == PG_BUSY && busy_wait(r, i)) i--; // this page again } } uint64_t nvn_dirty_now(void) { return atomic_load(&g_now); } diff --git a/dm/gfx/nvn/nvn_dirty.h b/dm/gfx/nvn/nvn_dirty.h index 57d9253..0499d49 100644 --- a/dm/gfx/nvn/nvn_dirty.h +++ b/dm/gfx/nvn/nvn_dirty.h @@ -8,7 +8,7 @@ void nvn_dirty_stat(int *live, int *peak, int *cap); // number of void nvn_dirty_remove_range(const void *mem); // Pool destruction: restore protected pages to writable and mark invalid int nvn_dirty_tracked(const void *addr, size_t len); void nvn_dirty_protect(const void *addr, size_t len); // Set the covered pages read-only -uint64_t nvn_dirty_now(void); // Current generation: taken before upload, as the stamp of that content +uint64_t nvn_dirty_now(void); // Current generation: take the content's stamp before nvn_dirty_protect and before reading it int nvn_dirty_since(const void *addr, size_t len, uint64_t stamp); // Covered pages were written after the stamp -> 1 const void *nvn_dirty_span(const void *addr, size_t len); // The tracked range holding the span (a handle to keep; NULL = untracked) int nvn_dirty_since_span(const void *span, const void *addr, size_t len, uint64_t stamp); // nvn_dirty_since through a kept handle (rechecked, scanned again only if it no longer holds the span) diff --git a/dm/gfx/nvn/nvn_mtl.m b/dm/gfx/nvn/nvn_mtl.m index b688796..3a08624 100644 --- a/dm/gfx/nvn/nvn_mtl.m +++ b/dm/gfx/nvn/nvn_mtl.m @@ -1510,7 +1510,7 @@ static void pkg_edges_take(NvPkg *pk, const tex_t *t) { // right after an uplo static int pkg_edges_clean(NvPkg *pk, const tex_t *t) { // the covered pages were written since the upload: 1 = only edge pages, and the texture's bytes there are as uploaded if (!pk->edge_n[0] && !pk->edge_n[1]) return 0; uintptr_t r[2][2], ia, ie; pkg_edge_ranges(t, r, &ia, &ie); - nvn_dirty_protect(t->mem, t->L.size); uint64_t st = nvn_dirty_now(); // protect, take the stamp, then read (as pkg_upload): a later write is seen at the next check + uint64_t st = nvn_dirty_now(); nvn_dirty_protect(t->mem, t->L.size); // take the stamp, protect, then read (as pkg_upload): a later write is seen at the next check if (ie > ia && nvn_dirty_since_span(pk->span, (const void *)ia, ie - ia, pk->stamp)) return 0; for (int i = 0; i < 2; i++) if (pk->edge_n[i] && memcmp(pk->edge[i], (const void *)r[i][0], pk->edge_n[i])) return 0; pk->stamp = st; g_pkg_edge_skips++; @@ -1604,7 +1604,10 @@ static id tex_upgrade(const tex_t *t, id base, MTLTextur return upgraded; } static id tex_upgrade_pfv(const tex_t *t, id base, MTLPixelFormat view_pf) { return tex_upgrade(t, base, MTLTextureUsagePixelFormatView, view_pf); } -// Packaged texture upload: first set read-only, then take the stamp, then read the pool memory and deswizzle into a staging buffer, and blit into a new private texture. +// Packaged texture upload: first take the stamp, then set read-only, then read the pool memory and deswizzle into a staging buffer, and blit into a new private texture. +// The stamp comes first: a write that faults while the pages are being protected gets a generation above it and leaves its page writable, and its bytes may land after +// the read; a stamp taken after the protect covered that generation, so the write and every later one to that page went unseen and the texture stayed stale +// (MacNativeGames a166602, missing letters in Tears of the Kingdom's glyph cache). The same order holds wherever a stamp is taken with a protect. // Batching: uploads within one replay submission share one upload command buffer (compute encoder deswizzle + a unified blit), flushed before the replay command buffer is committed; // the same queue executes in commit order ⇒ uploads are always finished before the first sample. Uploads outside a submission are flushed on the spot. The staging area is cut from 32 MB private blocks, held by the command buffer until completion. static _Atomic uint64_t g_upload_ns; // Cumulative time of deswizzle + upload encoding on the submit thread (timing instrumentation) @@ -1857,8 +1860,8 @@ static id pkg_upload_(const tex_t *t, uint64_t *stamp, idpool && nvn_sparse_pool(t->pool); if (stamp) { // no stamp: a one-time upload of content the CPU will not change (tex_init_fill), with no write tracking - if (sparse) nvn_sparse_protect(t->pool, (uint64_t)(uintptr_t)t->mem, t->L.size); else nvn_dirty_protect(t->mem, t->L.size); - *stamp = nvn_dirty_now(); } + *stamp = nvn_dirty_now(); + if (sparse) nvn_sparse_protect(t->pool, (uint64_t)(uintptr_t)t->mem, t->L.size); else nvn_dirty_protect(t->mem, t->L.size); } int bw, bh, bpb = nvn_fmt_bpb(t->d.format, &bw, &bh); unsigned layers = (t->d.target == 4 || t->d.target == 9) ? t->d.depth : t->d.target == 8 ? 6 : t->d.target == 3 ? t->d.height : 1; // a cube array's depth counts layer-faces (tex_desc) size_t total = 0; @@ -2118,7 +2121,7 @@ static id tex_mtl_(const tex_t *ct) { if (pk->spare && pk->spare != nt) defer_obj_release(pk->spare); // still in use, or of another shape: retired as before if (pk->sparse) sparse_view(pk, t); zb_report(t, pk->sparse ? (unsigned)pk->vbase : 0, "re-upload"); - pk->spare = old; pk->spare_fence = g_fence + 1; g_ser_gen++; pk->stamp = st; pk->vgen = pk->vseen = vg; pk->span = nvn_dirty_span(t->mem, t->L.size); pk->seen = nvn_dirty_now(); g_pkg_reuploads++; if (dirtycheck() && !pk->sparse) pk->hash = full_hash(t->mem, t->L.size); pkg_edges_take(pk, t); } + pk->spare = old; pk->spare_fence = g_fence + 1; g_ser_gen++; pk->stamp = st; pk->vgen = pk->vseen = vg; pk->span = nvn_dirty_span(t->mem, t->L.size); pk->seen = st; g_pkg_reuploads++; if (dirtycheck() && !pk->sparse) pk->hash = full_hash(t->mem, t->L.size); pkg_edges_take(pk, t); } return pk->view ? pk->view : pk->tex; } pk = [NvPkg new]; @@ -2126,7 +2129,7 @@ static id tex_mtl_(const tex_t *ct) { pk->tex = pkg_upload(t, &pk->stamp, nil); if (!pk->tex) return nil; pk->span = nvn_dirty_span(t->mem, t->L.size); - pk->seen = nvn_dirty_now(); g_pkg_uploads++; if (dirtycheck() && !pk->sparse) pk->hash = full_hash(t->mem, t->L.size); + pk->seen = pk->stamp; g_pkg_uploads++; if (dirtycheck() && !pk->sparse) pk->hash = full_hash(t->mem, t->L.size); // "nothing written since" starts at the stamp: a write that faulted during the upload moved "now" past it pkg_edges_take(pk, t); if (pk->sparse) sparse_view(pk, t); zb_report(t, pk->sparse ? (unsigned)pk->vbase : 0, "upload"); @@ -4171,7 +4174,7 @@ static int ix_range(const p_draw_idx *di, uint32_t *mn, uint32_t *mx) { const void *span = nvn_dirty_span(p, (size_t)n); if (!span) return 0; // not in a tracked memory pool if (!e) { if (g_ir.n >= 1 << 16) { nvn_hm_clear(&g_ir, free); memset(g_vc_rgn, 0, sizeof g_vc_rgn); } e = calloc(1, sizeof *e); nvn_hm_put(&g_ir, &k, e); } - nvn_dirty_protect(p, (size_t)n); e->stamp = e->seen = nvn_dirty_now(); e->span = span; e->lo = di->ibuf; e->n = n; vc_rgn_mark(e->lo, e->n); // protect, take the stamp, then read (as pkg_upload) + e->stamp = e->seen = nvn_dirty_now(); nvn_dirty_protect(p, (size_t)n); e->span = span; e->lo = di->ibuf; e->n = n; vc_rgn_mark(e->lo, e->n); // take the stamp, protect, then read (as pkg_upload) ix_minmax(p, isz, di->count, &e->mn, &e->mx); *mn = e->mn; *mx = e->mx; return 1; } diff --git a/dm/gfx/tests/t_dirty_race.c b/dm/gfx/tests/t_dirty_race.c index 6cf9e2e..a1b309d 100644 --- a/dm/gfx/tests/t_dirty_race.c +++ b/dm/gfx/tests/t_dirty_race.c @@ -5,6 +5,16 @@ // How it is staged: the mprotect in nvn_dirty.c is replaced by t_mprotect here -- before the call with which A sets read-only lands, B is let in and given 100 ms, so the order is deterministic. // Expected values do not depend on the implementation under test: after B's prep_write returns, this page must really be writable (reading 16 bytes into it gives 16; writing one byte directly does not hang and reads back as written), // and this write must be recorded as dirty (nvn_dirty_since returns 1 for a stamp taken before the page was set read-only). +#include +static void t_before_store(const volatile void *object); +// Observe the clock increment before its value reaches the page generation, at the real publication seam in both unprotect paths. +#undef atomic_store +#define atomic_store(object, desired) do { \ + __auto_type object_ = (object); \ + __auto_type desired_ = (desired); \ + t_before_store(object_); \ + __c11_atomic_store(object_, desired_, __ATOMIC_SEQ_CST); \ +} while (0) #define mprotect t_mprotect #include "../nvn/nvn_dirty.c" #undef mprotect @@ -26,12 +36,85 @@ static int g_fail; } while (0) static uint8_t *g_page; static _Atomic int g_armed, g_go, g_b_done; +static int g_publish_armed, g_observed_clean; +static uint8_t *g_publish_page; +static const volatile void *g_publish_gen; +static size_t g_publish_len; +static uint64_t g_publish_stamp, g_observed_now; +static void t_before_store(const volatile void *object) { + if (!g_publish_armed || object != g_publish_gen) return; + g_publish_armed = 0; + g_observed_now = nvn_dirty_now(); + g_observed_clean = !nvn_dirty_since(g_publish_page, g_publish_len, g_publish_stamp); +} static void *thread_b(void *arg) { (void)arg; while (!atomic_load(&g_go)) usleep(200); prep_write(g_page, 16); atomic_store(&g_b_done, 1); return NULL; } int t_mprotect(void *a, size_t n, int prot) { if (prot == PROT_READ && atomic_exchange(&g_armed, 0)) { atomic_store(&g_go, 1); usleep(100000); } // Before A's mprotect lands: let B in and wait until it runs to the end or gets stuck waiting return mprotect(a, n, prot); } +// A page another thread is unprotecting (its mprotect to writable done and the new generation stored, the state still BUSY) must be waited out by nvn_dirty_protect and +// protected again: skipped, it stayed writable with a generation the caller's stamp covers, and later writes to it were never seen (MacNativeGames a166602, missing letters +// in a glyph cache). Thread C plays the last step of that unprotect 50 ms late +static _Atomic uint8_t *g_c_state; +static void *thread_c(void *arg) { (void)arg; usleep(50000); atomic_store(g_c_state, PG_NONE); return NULL; } +static void busy_protect(size_t pg) { + uint8_t *page = mmap(NULL, pg, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANON, -1, 0); + if (page == MAP_FAILED) { perror("mmap"); g_fail++; return; } + nvn_dirty_add_range(page, pg); + range_t *r = find_span((uintptr_t)page, 1); uintptr_t i = r ? ((uintptr_t)page - r->base) >> g_shift : 0; + nvn_dirty_protect(page, pg); + uint8_t st = PG_PROT; + if (!r || !atomic_compare_exchange_strong(&r->state[i], &st, PG_BUSY)) { printf("busy page: not protected\n"); g_fail++; return; } + mprotect(page, pg, PROT_READ | PROT_WRITE); // what unprotect_page does before it stores the generation + atomic_store(&r->gen[i], atomic_fetch_add(&g_now, 1) + 1); + g_c_state = &r->state[i]; + pthread_t tc; pthread_create(&tc, NULL, thread_c, NULL); + uint64_t stamp = nvn_dirty_now(); // an uploader's stamp, then its protect + nvn_dirty_protect(page, pg); + pthread_join(tc, NULL); + EXPECT("protect waits out a page being unprotected (state PROT)", PG_PROT, atomic_load(&r->state[i])); + EXPECT("no write after the stamp yet: clean", 0, nvn_dirty_since(page, 16, stamp)); + page[0] = 7; + EXPECT("a later write to that page is seen", 1, nvn_dirty_since(page, 16, stamp)); +} +// Why the uploads take their stamp before nvn_dirty_protect: a write that faults while the pages are protected or read gets a generation above a stamp taken before and +// leaves its page writable; a stamp taken after covers it, so that write and every later one are missed (the second check is the old order, expected to miss) +static void stamp_order(size_t pg) { + uint8_t *page = mmap(NULL, pg, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANON, -1, 0); + if (page == MAP_FAILED) { perror("mmap"); g_fail++; return; } + nvn_dirty_add_range(page, pg); + uint64_t before = nvn_dirty_now(); + nvn_dirty_protect(page, pg); + page[0] = 1; // faults during the upload + uint64_t after = nvn_dirty_now(); + page[0] = 2; // after the read: no fault, the page is writable + EXPECT("stamp before protect: the writes are seen", 1, nvn_dirty_since(page, 16, before)); + EXPECT("stamp after protect (the old order): the writes are missed", 0, nvn_dirty_since(page, 16, after)); +} + +// A lookup can see the new clock before the page generation is stored and cache a clean result. Completing either unprotect path must invalidate that cached result. +static void generation_publish(size_t pg, int batch) { + size_t len = batch ? 2 * pg : pg; + uint8_t *page = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANON, -1, 0); + if (page == MAP_FAILED) { perror("mmap"); g_fail++; return; } + nvn_dirty_add_range(page, len); + g_publish_page = page + len - pg; g_publish_len = pg; g_publish_stamp = nvn_dirty_now(); + range_t *r = find_span((uintptr_t)page, len); + g_publish_gen = &r->gen[((uintptr_t)g_publish_page - r->base) >> g_shift]; + nvn_dirty_protect(page, len); + g_observed_clean = 0; g_publish_armed = 1; + if (batch) prep_write(page, len); else on_fault(page, 1, NULL); + page[0] = 3; + if (batch) page[pg] = 4; + printf("generation publication (%s):\n", batch ? "host pre-write, batched pages" : "page fault"); + EXPECT("observer ran before the generation was stored", 1, g_observed_clean); + EXPECT("publication invalidates the observer's cached clock", 1, nvn_dirty_now() != g_observed_now); + EXPECT("lookup using the cached clock detects the write", 0, + nvn_dirty_now() == g_observed_now || !nvn_dirty_since(g_publish_page, pg, g_publish_stamp)); + nvn_dirty_remove_range(page); munmap(page, len); +} + int main(void) { crash_install(); size_t pg = (size_t)getpagesize(); @@ -53,5 +136,9 @@ int main(void) { else { printf(" (read failed errno=%d; the page is read-only while its state is 'none', a direct write would spin in the page-fault handler, so this check is skipped)\n", errno); g_fail++; } EXPECT("this write is recorded as dirty (against a stamp from before set-read-only)", 1, nvn_dirty_since(g_page, 16, stamp)); close(fd); + busy_protect(pg); + stamp_order(pg); + generation_publish(pg, 0); + generation_publish(pg, 1); printf("t_dirty_race: %s\n", g_fail ? "FAILED" : "all passed"); return g_fail != 0; } diff --git a/docs/agents/symptoms.md b/docs/agents/symptoms.md index 9c0004e..892a55e 100644 --- a/docs/agents/symptoms.md +++ b/docs/agents/symptoms.md @@ -254,6 +254,7 @@ One line per symptom in the [subsystem references](../reference/): what you see - some textures are re-uploaded every frame (several MB per frame). → [Dirty-page ranges of pools that share a host page](../reference/nvn-objects.md#dirty-page-ranges-of-pools-that-share-a-host-page) - packaged textures re-uploaded at every use with unchanged content; upload staging and frame times rising. → [Writes beside a packaged texture in a shared host page](../reference/nvn-objects.md#writes-beside-a-packaged-texture-in-a-shared-host-page) - about one run in four stalled early (around the opening movie or the title), with no log output, not even the time-limit report. → [Dirty-page tracker race and early stalls](../reference/nvn-objects.md#dirty-page-tracker-race-and-early-stalls) +- a CPU-written texture stays stale after later writes; letters can be missing from a glyph cache. → [Dirty-page writes missed during upload](../reference/nvn-objects.md#dirty-page-writes-missed-during-upload) - device / subsystem setup returns quietly and the next stage never starts. → [Version and capability checks that fail quietly](../reference/nvn-objects.md#version-and-capability-checks-that-fail-quietly) - a null-pointer crash early in `nnMain`, right after NVN setup should have happened (Super Mario 3D World + Bowser's Fury, `main+0x759e08`: the device singleton `main+0x1a28ea0` is still null). → [The device's API version](../reference/nvn-objects.md#the-devices-api-version) - null read at `main+0xb8462c` right after the first two textures, called from `main+0x670eb0` (Bayonetta 3), or cleanup crashes at `main+0x9c0d1c` after shader initialization fails (ASTRAL CHAIN). → [Newest GPU code version reported by the driver](../reference/nvn-objects.md#newest-gpu-code-version-reported-by-the-driver) diff --git a/docs/reference/nvn-objects.md b/docs/reference/nvn-objects.md index 0e9c538..903ae7a 100644 --- a/docs/reference/nvn-objects.md +++ b/docs/reference/nvn-objects.md @@ -283,6 +283,14 @@ Games read this count as a position in their own command memory and size their h - **Found on:** [Fire Emblem Engage](../../dm/games/0100A6301214E000/README.md): probably fixed; since the pause, 12 runs (eight of them 200 s runs through the movie and title, four of 330 to 850 s) have not stalled once, where one in four used to. - **Note:** if a stall comes back, sample the process when its log stops growing (`sample 3`) to see where every thread waits. The crash handler writes one line with `write()` and exits if the stderr lock stays taken for 1 s, so a stall that holds that lock shows up as such. +### Dirty-page writes missed during upload + +- **Symptom:** a CPU-written texture stays stale after later writes; letters can be missing from a glyph cache. +- **Cause:** a protect skipped a page another thread was unprotecting, leaving it writable with a generation already covered by the upload stamp. Taking the stamp after protecting, or setting the cached last-check generation after the upload, could also cover a fault whose bytes arrived after the read. Separately, an unprotect advanced the global clock before publishing the page generation: a checker could cache a clean result in between and never scan again once the page became writable. Both the page-fault path and batched host pre-write path had that publication window. +- **Fix:** take the stamp before protecting and reading, and start the last-check generation at that stamp. Finish any pending protection run before waiting for a BUSY page, then retry that page. Both unprotect paths use `stamp_page` to publish the page generation and advance the global clock once more afterward, invalidating checks that ran before publication. These are general tracker and cache rules, with no game-specific gate. +- **Found on:** [Tears of the Kingdom](../../dm/games/0100F2C0115B6000/README.md), reported in MacNativeGames commit `a166602` and ported through [PR #197](https://github.com/seedds/nxnative/pull/197). The missing-letter symptom has not been reproduced in nxnative; the tracker failures are reproduced locally by the regression tests. +- **Check:** `t_dirty_race` covers the BUSY-page retry, stamp ordering, and a check between clock allocation and generation publication in both unprotect paths. The publication cases fail on main; the batched case also fails with the original PR applied. + ## NVN entry points ### Version and capability checks that fail quietly