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