Keep a texture's finalization on its registered slots, so a stale slot stays stale after its image was evicted.

Whether a slot was stale was read from the released image's entry, which eviction, the released cap or a new image
at the address can destroy before the slot is looked up again; the slot then revived the texture from storage the
game had freed (the access violation in the block copy during a pool refresh). The flag now lives on the slot: a
release sets it on the slots registered with that descriptor, a registration clears it, and a finalized slot of a
CPU-written texture follows only live GPU content at its address.

From pull request #348 by TophC7, taken as it is.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
chrg6
2026-11-10 09:13:08 -05:00
parent 87bf554637
commit 25e31815fd
3 changed files with 54 additions and 26 deletions

View File

@@ -89,6 +89,9 @@ keeps per-core data without locks and relies on that, so the host models it (`ru
(`GfxShaderRes.ptr`, `vk_exec_draw.c ptr_addr`).
- Bindless textures: the texture and sampler pools are descriptor arrays kept in step with the game's pool writes
(`vk_exec_draw.c pool_sync`); slots that resolve alike share one lookup.
Finalization is retained on registered slots independently of image-cache eviction: a stale CPU-backed descriptor
cannot recreate an image from freed storage or follow newly reused CPU bytes. Registration makes it current again;
finalized descriptors may still follow live GPU-owned content.
- Shaders: Ampere (SM86) code is lowered to Maxwell (`tools/xlate/AmpereLowering.cs`) and translated to SPIR-V by the
pinned shader translator (`tools/vendor/ryujinx`, 1.3.2) with our patches (`tools/xlate/patches/`), built with .NET 10 NativeAOT as
`nx2navi_xlate.exe` and run as separate processes. Translations are cached on disk; shaders and pipelines seen in

View File

@@ -699,14 +699,21 @@ static int test_textures(pool_t *tpool, pool_t *phys) {
vk_write_texels(&g, NULL, &all, gl[0], 256, 256 * 64);
if (read_back(&g, 0, gl, 0, "physical-pool RGBA8 WriteTexels")) return 1;
// a slot of a finalized texture follows the live texture at the same storage. The slot's own storage is CPU-written
// (mirrored), so it follows only a texture holding GPU content its bytes lack (vk_texture.c slot_resolve). The live
// texture is cleared on the GPU and noted as rendered into, as a draw into it would be; the slot's image must then
// show the clear, which neither the storage bytes nor a recreated image of the slot's own descriptor hold.
// A finalized CPU-backed slot must not revive its stale bytes or follow a new, unrendered CPU texture.
vk_texture_register(5, &b, NULL);
vk_texture_release(&b);
tex_t b2 = make_tex(tpool, 0x40000, 0x25, 16, 12, 1, 4);
const VkTexImage *live = vk_texture_get(&b2);
GCHECK(live && !vk_texture_by_id(5, NULL, NULL), "stale CPU slot 5 revived or followed unrendered storage");
// Registration is the lifetime boundary that makes the descriptor current again, even after its image was evicted.
vk_texture_register(5, &b, NULL);
const VkTexImage *renewed = vk_texture_by_id(5, NULL, NULL);
GCHECK(renewed && renewed->format == VK_FORMAT_BC1_RGBA_UNORM_BLOCK && renewed->width == 64 && renewed->height == 48,
"re-registered CPU slot 5 did not resolve its own descriptor");
vk_texture_release(&b);
// Once the live texture at its storage holds GPU content the storage bytes lack, the finalized CPU slot follows it.
// The live texture is cleared on the GPU and noted as rendered into, as a draw into it would be; the slot's image
// must then show the clear, which neither the storage bytes nor a recreated image of the slot's own descriptor hold.
GCHECK(live && live->image && live->width == 16 && live->height == 12, "RGBA8 image of the texture at the slot's storage");
vk_texture_gpu_written(&b2); // the clear below is its content, not the storage
const float clear[4] = {0.0f, 1.0f, 1.0f, 1.0f};
@@ -717,10 +724,17 @@ static int test_textures(pool_t *tpool, pool_t *phys) {
VkCommandBuffer ccb = vk_upload_begin();
vkCmdClearColorImage(ccb, live->image, VK_IMAGE_LAYOUT_GENERAL, &cv, 1, &rng);
vk_upload_end();
const VkTexImage *followed = vk_texture_by_id(5, NULL, NULL);
GCHECK(followed && followed->image == live->image, "finalized CPU slot 5 does not follow the rendered texture at its storage");
GCHECK(image_is_color(followed->image, 16, 12, cleared), "the image of slot 5 does not hold the clear written into the live texture");
// GPU-owned slots do follow the live image at their storage, even when its descriptor has changed.
vk_texture_register(5, &g, NULL);
vk_texture_release(&g);
tex_t g2 = make_tex(phys, 0, NVN_FORMAT_R32UI, 32, 32, 1, 5);
live = vk_texture_get(&g2);
VkImage live_img = live ? live->image : VK_NULL_HANDLE;
const VkTexImage *slot = vk_texture_by_id(5, NULL, NULL);
GCHECK(live && slot && slot->image == live_img, "slot 5 does not follow the live texture at its storage");
GCHECK(image_is_color(slot->image, 16, 12, cleared), "the image of slot 5 does not hold the clear written into the live texture");
samp_par sp;
memset(&sp, 0, sizeof sp);
sp.minf = NVN_MIN_FILTER_LINEAR_MIPMAP_LINEAR, sp.magf = NVN_MAG_FILTER_LINEAR, sp.ws = sp.wt = sp.wr = NVN_WRAP_MODE_REPEAT;
@@ -728,14 +742,15 @@ static int test_textures(pool_t *tpool, pool_t *phys) {
vk_sampler_register(0, &sp);
VkSampler s0 = vk_sampler_by_id(0), s1 = vk_sampler_for(&sp);
GCHECK(s0 && s0 == s1 && !vk_sampler_by_id(1), "sampler slots");
g_gpu_checks += 9;
vk_texture_release(&t), vk_texture_release(&b2), vk_texture_release(&g);
g_gpu_checks += 12;
vk_texture_release(&t), vk_texture_release(&b2), vk_texture_release(&g2);
for (int i = 0; i < 4; i++) free(lin[i]), free(bl[i]);
free(gl[0]);
uint64_t images, ups, reups;
vk_texture_stats(&images, &ups, &reups);
printf("probe-vkmem: textures: RGBA8 3 levels and BC1 equal to the CPU reference after upload, re-upload after CPU "
"writes, physical-pool alias WriteTexels/ReadTexels, slot redirect, samplers (%llu images, %llu uploads, "
"writes, physical-pool alias WriteTexels/ReadTexels, stale CPU slot, CPU slot following rendered content, GPU slot "
"redirect, samplers (%llu images, %llu uploads, "
"%llu re-uploads)\n", (unsigned long long)images, (unsigned long long)ups, (unsigned long long)reups);
return 0;
}

View File

@@ -4,7 +4,7 @@
// and sampler descriptor slots, and VkSamplers.
//
// A texture that mirrors its pool memory (tex_mirrored) stops re-uploading once the GPU rendered into it (the
// "GPU-owned" rule); a slot of a finalized texture follows the live texture at its storage address.
// "GPU-owned" rule); a finalized slot follows live GPU content but never revives freed CPU-backed storage.
// Contains code Copyright (c) 2026 jhaizhou, Copyright (c) 2026 Peiikai (MIT; see THIRD_PARTY_NOTICES.md).
//
// Every image lives in VK_IMAGE_LAYOUT_GENERAL. Uploads are recorded into the upload batch of vk_memory.c; the replay
@@ -56,7 +56,6 @@ typedef struct Tex {
uint64_t retire_takes; // vk_pool_takes when it was retired
uint64_t retire_due; // vk_sync_issued once every pool set was taken since (0: not yet); destroyed after it
int uploaded, released;
uint32_t rel_serial; // reg_serial when it was released: slots registered before then are stale (slot_resolve)
uint64_t uid; // names the entry in its bucket's released set (tex_gen_flip); 0 until its first release
uint64_t rel_frame; // gfx_counters.frames when it was released (drop_stale)
uint32_t back_gap; // most presents between a release and its taking back so far (stale_after)
@@ -101,7 +100,7 @@ typedef struct {
uint32_t cgen; // agen of the slot's address when ce was resolved
uint32_t cepoch;
view_t cview;
uint8_t chas_view, used, has_view;
uint8_t chas_view, used, has_view, finalized; // finalized survives eviction of the released Tex entry
uint8_t cheld; // ce is NULL as the texture was evicted by disuse, valid while cgen is (held_out)
uint8_t cwant; // a shader sampled its stand-in (vk_pool_marks_read): slot_resolve makes it at once
uint64_t khash; // hash of the descriptor key and view, 0 for CPU-written textures (vk_texture_slot_khash_locked)
@@ -164,7 +163,7 @@ static int nowned, cowned;
static uint64_t max_owned; // largest storage size in owned[]
static uint64_t gpu_writes; // GPU writes into mirrored textures so far: Tex.gpu_seq
// Bumped whenever the entry a slot resolves to can change: an entry created or destroyed, released or taken back into
// use (the slot lookup and the follow of a finalized slot read only by_key, by_addr and the released flags).
// use, or a registered slot finalized (the follow reads by_key, by_addr and the entry and slot lifetime flags).
static uint32_t tex_gen = 1;
// Bumped with tex_gen and whenever a slot is registered: what vk_texture_by_id returns for any slot may have changed.
static uint32_t slots_gen = 1;
@@ -257,10 +256,10 @@ static void tex_gen_bump(uint64_t addr) {
// generation never stands for two states. The global generations still move, so their readers look, and find the
// tokens equal. plain: the take-back of an entry that was superseded or overwritten while released changes more than
// the flag, and is a change like any other.
// One input of slot_resolve is not in the state: the stale rule compares a slot's registration with the entry's
// release (rel_serial), which is later at every release. A slot is stale at every later release once it is at one, so
// what it resolves to in a released state stays right; the one that is not stale takes the entry back, which is the
// lookup-made change in_lookup deals with.
// One input of slot_resolve is not in the state: the stale rule reads the slot's finalized flag, which a release sets
// on the slots registered with its descriptor and a registration clears. A slot is stale at every later release once
// it is at one, so what it resolves to in a released state stays right; the one that is not stale takes the entry
// back, which is the lookup-made change in_lookup deals with.
static void tex_gen_flip(Tex *e, int plain) {
static int on = -1;
static uint64_t uids;
@@ -2329,7 +2328,6 @@ static int wb_wanted(const Tex *e) { return e->t.L.size <= wb_limit || e->run <
static void mark_released(Tex *e) { // tex_lock held; e is not released
e->released = 1;
e->rel_epoch = submit_epoch;
e->rel_serial = reg_serial;
e->rel_frame = __atomic_load_n(&gfx_counters.frames, __ATOMIC_RELAXED);
nreleased++;
rel_append(e);
@@ -2399,10 +2397,24 @@ void vk_texture_release(const tex_t *t) {
make_key(t, &k);
lock();
Tex *e = nvn_hm_get(&by_key, &k);
// Staleness belongs to the registered descriptor, not the cached image: a released Tex can be destroyed before the
// slot looks again (eviction, the released cap, start_at for a mirrored one). A registered texture may also be
// finalized before its image is created.
int changed = 0;
if (sinfo)
for (int id = bucket_head[agen_idx(k.addr)]; id >= 0; id = slot_next[id]) {
TexSlot *s = &tslots[id];
if (s->finalized || (uintptr_t)s->t.mem != k.addr) continue;
TexKey sk;
make_key(&s->t, &sk);
if (!memcmp(&sk, &k, sizeof k)) s->finalized = 1, s->ce = NULL, changed = 1;
}
if (e && !e->released) {
mark_released(e);
changed = 0; // the image's release already invalidated the address bucket
sweep_released();
}
if (changed) tex_gen_bump(k.addr);
unlock();
}
@@ -2447,7 +2459,7 @@ void vk_texture_register(int id, const tex_t *t, const view_t *v) {
// again (slot_resolve's stale rule), so it goes the full way. A slot held out of the pool (cheld) keeps that too:
// the same descriptor names the same held texture. Both only while the resolution is current (cgen): a destroyed
// entry bumped the generation and its record may be freed, so ce is not read past that.
int current = s->used && s->cgen == agen[sinfo[id] & 0xffff];
int current = s->used && !s->finalized && s->cgen == agen[sinfo[id] & 0xffff];
if (current && ((s->ce && !s->ce->released) || s->cheld) && !memcmp(&s->t, t, sizeof *t) &&
s->has_view == (v != NULL) && (!v || !memcmp(&s->v, v, sizeof *v))) {
n_same_regs++;
@@ -2457,6 +2469,7 @@ void vk_texture_register(int id, const tex_t *t, const view_t *v) {
s->t = *t;
if (!s->used && id < VK_POOL_TEX) pool_slots++;
s->used = 1;
s->finalized = 0;
s->ce = NULL;
s->cheld = 0;
slots_gen++;
@@ -2515,7 +2528,7 @@ static Tex *slot_resolve(TexSlot *s, view_t *view, int *has_view) {
make_key(&s->t, &k);
Tex *e = nvn_hm_get(&by_key, &k);
int stale = 0;
if (!e || e->released) { // a finalized slot follows the live texture at its storage
if (!e || e->released || s->finalized) { // a finalized slot follows live GPU content, not newly reused CPU bytes
// Prefer the live entry that differs from the slot descriptor only in its swizzle: transient render targets
// share storage, and the newest one at the address is usually another size. The map screen samples its
// 2250x1080 map target through an (R,G,B,1) descriptor and followed a 960x60 target at the same address.
@@ -2535,17 +2548,14 @@ static Tex *slot_resolve(TexSlot *s, view_t *view, int *has_view) {
// that 2D image, no 3D view can be made of it (vk_exec_draw.c get_view) and they read and wrote stand-ins.
// With the target kept they get the volume itself.
if (p->key.d.target != k.d.target) continue;
if (same_but_swizzle(&p->key, &k)) { e = p; break; }
if (same_but_swizzle(&p->key, &k) && (!cpu_slot || !s->finalized || alias_has_gpu_content(p))) { e = p; break; }
if (!any && (!cpu_slot || alias_has_gpu_content(p))) any = p;
}
// own is missing or released here; for a CPU slot entry_of below finds or revives it by key. A CPU slot whose
// own entry was released after the slot was registered is stale: the game finalized that texture and is
// freeing its storage, and reviving it uploaded from that storage while another thread unmapped it (an
// access violation in tex_bl_copy from the pool refresh, Kokiri Forest). It resolves to nothing; a slot
// registered again after the release is live and revives the entry.
// A finalized CPU descriptor must not recreate its image from freed storage. The slot keeps this state even
// after its released image was evicted; registering the descriptor again makes it current.
if (!e && any) e = any;
else if (!e && !cpu_slot) e = own && own->superseded ? NULL : own; // a superseded one: entry_of takes it back
else if (!e && own && (uint32_t)(sinfo[s - tslots] >> 16) <= own->rel_serial) stale = 1;
else if (!e && cpu_slot && s->finalized) stale = 1;
}
if (!e && s->t.mem && !stale) {
// evicted by disuse: the pool refresh keeps the stand-in, without spending the creation budget on it