From 01918dc065868c95aad2ec9995eb311e3201d932 Mon Sep 17 00:00:00 2001 From: Ishtiaq Ul Haq Syed Date: Fri, 6 Nov 2026 19:59:25 -0600 Subject: [PATCH 1/8] fix(nvn): store direct indexed draws in four-byte control tickets --- dm/games/README.md | 1 + dm/games/game.py | 3 + dm/gfx/nvn/hle_nvn.c | 222 ++++++++++++++++++++++++++++++++-- dm/gfx/nvn/nvn_capture.c | 17 +++ dm/gfx/nvn/nvn_capture.h | 5 + dm/gfx/nvn/nvn_capture.inc | 20 +++ dm/gfx/nvn/nvn_mtl.m | 24 ++-- dm/gfx/nvn/nvn_obj.h | 37 +++++- dm/gfx/tests/t_capture.c | 31 +++++ dm/gfx/tests/t_capture_live.m | 16 ++- dm/gfx/tests/t_nvn.c | 139 +++++++++++++++++++++ docs/agents/symptoms.md | 1 + docs/reference/nvn-objects.md | 12 +- 13 files changed, 500 insertions(+), 28 deletions(-) diff --git a/dm/games/README.md b/dm/games/README.md index 0a6b10d187..5e7cf7fd68 100644 --- a/dm/games/README.md +++ b/dm/games/README.md @@ -242,6 +242,7 @@ One row per profile; the notes are each profile's `README.md`. | `tex_dirty_upload` | `on` / `off` (the default), needs `tex_flush_upload`: a registered texture whose pages the CPU writes after `nvnTextureInitialize` without any NVN flush is uploaded as well (its pages are write-protected at registration, checked on first use; `nvn_ft_written` in `hle_nvn.c`). Tears of the Kingdom fills its ASTC terrain material arrays this way; without it the cave is magenta. FINAL FANTASY TACTICS - The Ivalice Chronicles loads its UI textures straight into CPU-uncached pools, which need no flush, after creating them; without both keys its screen stays black. The Great Ace Attorney Chronicles copies every texture into a CPU-cached pool after creating it and never flushes (its `main` has no cache maintenance at all): black too. The Legend of Zelda: Ocarina of Time writes its terrain tiles' height, blend and hole maps this way, and without the keys has holes in the ground | | `tex_dyn_slots` | `on` / `off` (the default): a shader whose buffer-texture handle the translator could not trace to one slot gets Ryujinx's fallback arrays (`__cb0_40000000`: 32 buffer textures and samplers indexed by the handle's texture / sampler ID; any other sampled texture indexes the whole pool without a key, [Texture handles from game memory index the pools](../../docs/reference/shaders.md#texture-handles-from-game-memory-index-the-pools)). With the key the replay puts slot indices in that shader's c2 texture and sampler tables (texture slot r → r, sampler slot r → r << 20) and binds slot k at element k, so the shader's own arithmetic picks the binding (`NVN_RB_TEXD` / `NVN_RB_SAMPD` in `nvn_mtl.m`). Right only where the handle comes from c2's tables: Octopath Traveler 0's 61 such vertex shaders read separate texture slots 0-10 and samplers 0-4 there and nothing else (FINAL FANTASY RESONANCE's 2,077 the texture table words 0x168-0x1c0 and sampler table words 0x568-0x590). Off, such shaders do not load. Not part of the shader chain version | | `gpu_timestamps` | What a timestamp report (`nvnCommandBufferReportCounter`) records: `submission` (the default) = the time the whole submission completed, the same for every report in it; `segment` = the GPU start (`TIMESTAMP_TOP`) or end (the other types) of the segment the report was encoded in (the host commits a submission in segments of 64 draws, `sub_cut` in `nvn_mtl.m`), so two reports of one submission bracket the GPU time between them, as on hardware. Breath of the Wild needs `segment`: its frame-interval controller and its dynamic resolution read the GPU time between the frame's first and last report, and with one time for both they read 0 (the game kept 60 fps in slow motion when the GPU could not keep up). `DM_NVN_GPU_TS=segment` / `submission` overrides it, also from `Documents/env.txt` | +| `nvn_draw_ctl` | `on` / `off` (the default): use real guest control memory for a standalone direct indexed draw plus END, leaving a 4-byte guest command-memory ticket. Both `GetCommandMemoryUsed` and `GetControlMemoryUsed` report actual occupied bytes. A handle-address registry preserves the draw across control-memory reuse, and retires it when overlapping guest command memory is granted again. Used only after an original-RESTBL allocation probe proves a title needs the native four-byte command footprint. `DM_NVN_DRAW_CTL=0` / `1` overrides it. See [Four-byte direct indexed-draw command tickets](../../docs/reference/nvn-objects.md#four-byte-direct-indexed-draw-command-tickets) | | `draw_barriers` | `full` (the default) / `game`: how draw passes are ordered. `full`: each waits for all earlier GPU work. `game`: by the game's own barriers (NVN asks for one before results of rendering are read), so a pass's vertex work overlaps the previous pass's fragments. Ocarina of Time's GPU time 25 → 18 ms, about 180 full barriers a frame halved. Only vertex-stage reads of the previous pass's output without a barrier change order. `DM_NVN_BARRIER` overrides it | | `vconv_range` | `on` / `off` (the default): vertex attributes the replay converts on the CPU (integer shader inputs fed float-type formats and the like, `vconv_bind` in `nvn_mtl.m`) are converted only over the vertices each draw reads (first / count, or the smallest and largest index of a direct indexed draw, kept while the index pages stay unwritten) instead of the whole bound range. Age of Calamity binds whole mesh pools: in a battle that was millions of vertices a frame. Indirect draws, per-instance streams and indices with a GPU write queued on them keep the whole range. `DM_NVN_VCONV_CHECK=1` scans the indices again at every reuse and reports differences; `DM_NVN_VCONV_RANGE=0` / `1` overrides it | | `shader_key_skip` | `+` (none by default): control-section bytes every shader key hashes as zeros, in `shader_split.py` (`key_ctl`) and the host (`hle_nvn.c` `keys_hash`), for an engine that shares one control section among shaders that differ only there. Batman: Arkham Knight: `0x770+0x78`, GLSLC's compile information (+0x778..+0x791 and +0x7d0..+0x7e7); without it the game asks for shaders the map does not have, each the control section of one record with the code area of another. It must stay clear of `+0x700..+0x75f`, which the tools read; it is a shader chain input, as every `shader_*` key | diff --git a/dm/games/game.py b/dm/games/game.py index 53e23ee897..db7de07f09 100644 --- a/dm/games/game.py +++ b/dm/games/game.py @@ -193,6 +193,8 @@ def header_one(g): sko, skl = (int(m[1], 0), int(m[2], 0)) if m else (0, 0) rcd = g.get('render_cond', 'off') # NVN conditional rendering evaluated at replay, timestamp reports with counter 0 (nvn_mtl.m OP_RENDER_COND): on / off if rcd not in ('on', 'off'): sys.exit(f'{g.id}: render_cond must be on or off: {rcd}') + ndc = g.get('nvn_draw_ctl', 'off') # opt-in NVN direct indexed-draw terminal tokens, using actual guest-provided control memory (hle_nvn.c) + if ndc not in ('on', 'off'): sys.exit(f'{g.id}: nvn_draw_ctl must be on or off: {ndc}') tfu = g.get('tex_flush_upload', 'off') # textures written by the CPU into a pool and published with nvnMemoryPoolFlushMappedRange are uploaded like packaged ones (hle_nvn.c): on / off if tfu not in ('on', 'off'): sys.exit(f'{g.id}: tex_flush_upload must be on or off: {tfu}') tdu = g.get('tex_dirty_upload', 'off') # with tex_flush_upload: a registered texture the CPU writes after init without any flush is uploaded too (dirty pages, hle_nvn.c nvn_ft_written): on / off @@ -259,6 +261,7 @@ def header_one(g): f'#define DM_GAME_TEX_FLUSH_UPLOAD {int(tfu == "on")}', f'#define DM_GAME_SHADER_KEY_SKIP_OFF {sko:#x}', f'#define DM_GAME_SHADER_KEY_SKIP_LEN {skl:#x}', f'#define DM_GAME_RENDER_COND {int(rcd == "on")}', + f'#define DM_GAME_NVN_DRAW_CTL {int(ndc == "on")}', f'#define DM_GAME_TEX_DIRTY_UPLOAD {int(tdu == "on")}', f'#define DM_GAME_TEX_DYN_SLOTS {int(tds == "on")}', f'#define DM_GAME_GPU_TS_SEGMENT {int(gts == "segment")}', diff --git a/dm/gfx/nvn/hle_nvn.c b/dm/gfx/nvn/hle_nvn.c index ba9bb0e4fe..0425f81963 100644 --- a/dm/gfx/nvn/hle_nvn.c +++ b/dm/gfx/nvn/hle_nvn.c @@ -858,12 +858,19 @@ static void cs_dump(void) { // Called once per frame (at present) int on = g_cs.on; memset(&g_cs, 0, sizeof g_cs); g_cs.on = on; g_cs.frame0 = f; pthread_mutex_unlock(&g_cs_mu); } static void put_hdr(uint8_t *p, uint32_t op, uint32_t size) { op_hdr h = {op, size}; memcpy(p, &h, sizeof h); if (op) cs_op(op, size); } +static void cmd_align8(cmd_t *o); +static void ctl_ticket_regrant_cmd(uintptr_t lo, uintptr_t hi); +static int nvn_draw_ctl_on(void); // New segment: if the recording in progress has records, write a JUMP at the end of the old segment to link it. A recording with none yet starts in the new segment // (its first record is its handle), and the bytes at the old write position are not its own: after an END that took the last bytes there is no room there at all static void cmd_new_segment(cmd_t *o, uint8_t *mem, size_t n) { uint8_t *start = align8(mem), *end = mem + n; + // An unfinished four-aligned direct draw needs canonical alignment before + // a link is written. Its staging allocation reserves room for this move. + if (o->recording == 1 && o->cur && ((uintptr_t)o->cur & 7)) cmd_align8(o); if (o->recording == 1 && o->cur) { if (cs_on() && o->end > o->cur) { pthread_mutex_lock(&g_cs_mu); g_cs.waste += (uint64_t)(o->end - o->cur); pthread_mutex_unlock(&g_cs_mu); } put_hdr(o->cur, OP_JUMP, 16); memcpy(o->cur + 8, &start, 8); o->used += 16; } // the JUMP also occupies guest memory of this recording: count it in used, so the game packing the next recording right behind by used does not overwrite it + if (nvn_draw_ctl_on()) ctl_ticket_regrant_cmd((uintptr_t)mem, (uintptr_t)end); o->cur = start; o->end = end; } // Host-supplied segment when the game's own command memory runs out: when the game's out-of-memory callback cannot allocate from its command memory pool, it calls AddCommandMemory with an empty pool (p = NULL, n = 0) @@ -919,6 +926,76 @@ static void hostseg_release(uintptr_t lo, uintptr_t hi) { for (int i = 0; i < g_nhostseg; i++) if (g_hostseg[i].busy && g_hostseg[i].org_hi > lo && g_hostseg[i].org_lo < hi) g_hostseg[i].busy = 0; pthread_mutex_unlock(&g_hostseg_mu); } +// Only *live* exact guest command addresses are indexed. A 31-bit generation +// prevents a token copied from a retired address from resolving after reuse. +// Handles may survive command-buffer finalization; only a game memory regrant +// retires them. This is independent of the distance to the control grant (>4GB +// on some titles), and has no arbitrary 4M-lifetime allocation cliff. +typedef struct { uintptr_t handle; uint8_t *snapshot; uint32_t generation; } ctl_ticket_t; +static pthread_mutex_t g_ctl_ticket_mu = PTHREAD_MUTEX_INITIALIZER; +// Readers hold this across one entire submit (capture, preindex and replay). +// A guest command-memory regrant waits for that CPU walk before retiring its +// snapshots; mutex-only lookup would allow use-after-free after unlock. +static pthread_rwlock_t g_ctl_ticket_life = PTHREAD_RWLOCK_INITIALIZER; +static void ctl_ticket_read_begin(void) { pthread_rwlock_rdlock(&g_ctl_ticket_life); } +static void ctl_ticket_read_end(void) { pthread_rwlock_unlock(&g_ctl_ticket_life); } +static ctl_ticket_t *g_ctl_tickets; +static size_t g_ctl_ticket_n, g_ctl_ticket_cap; +static uint32_t g_ctl_ticket_next = 1; +static size_t ctl_ticket_lower(uintptr_t handle) { + size_t lo = 0, hi = g_ctl_ticket_n; + while (lo < hi) { size_t mid = lo + (hi - lo) / 2; if (g_ctl_tickets[mid].handle < handle) lo = mid + 1; else hi = mid; } + return lo; +} +static void ctl_ticket_regrant_cmd(uintptr_t lo, uintptr_t hi) { + pthread_rwlock_wrlock(&g_ctl_ticket_life); + pthread_mutex_lock(&g_ctl_ticket_mu); + // A four-byte ticket can cross a grant boundary; retire it on any + // overlapping byte, including a grant beginning at handle+1/2/3. + size_t first = ctl_ticket_lower(lo >= 3 ? lo - 3 : 0), last = ctl_ticket_lower(hi); + while (first < last && g_ctl_tickets[first].handle + 4 <= lo) first++; + if (last > first) { + for (size_t i = first; i < last; i++) free(g_ctl_tickets[i].snapshot); + memmove(g_ctl_tickets + first, g_ctl_tickets + last, (g_ctl_ticket_n - last) * sizeof *g_ctl_tickets); + g_ctl_ticket_n -= last - first; + } + pthread_mutex_unlock(&g_ctl_ticket_mu); + pthread_rwlock_unlock(&g_ctl_ticket_life); +} +static uint32_t ctl_ticket_add(const uint8_t *handle, const uint8_t *target) { + pthread_mutex_lock(&g_ctl_ticket_mu); + if (g_ctl_ticket_next >= NVN_CTL_DRAW_TAG4) die("NVN control draw: 31-bit generation exhausted"); + if (g_ctl_ticket_n == g_ctl_ticket_cap) { + size_t nc = g_ctl_ticket_cap ? g_ctl_ticket_cap * 2 : 1024; + ctl_ticket_t *p = realloc(g_ctl_tickets, nc * sizeof *p); + if (!p) die("NVN control draw: cannot grow live-ticket registry"); + g_ctl_tickets = p; g_ctl_ticket_cap = nc; + } + size_t pos = ctl_ticket_lower((uintptr_t)handle); + if (pos < g_ctl_ticket_n && g_ctl_tickets[pos].handle == (uintptr_t)handle) + die("NVN control draw: duplicate live guest handle without a regrant"); + // A cached caller may retain the handle even after its NVN command buffer + // receives new control storage. Snapshot the *real* 48 bytes that were + // written and charged to the guest; replay never dereferences stale control. + uint8_t *snapshot = malloc(48); + if (!snapshot) die("NVN control draw: cannot snapshot guest control record"); + memcpy(snapshot, target, 48); + memmove(g_ctl_tickets + pos + 1, g_ctl_tickets + pos, (g_ctl_ticket_n - pos) * sizeof *g_ctl_tickets); + uint32_t id = g_ctl_ticket_next++; + g_ctl_tickets[pos] = (ctl_ticket_t){ (uintptr_t)handle, snapshot, id }; + g_ctl_ticket_n++; + pthread_mutex_unlock(&g_ctl_ticket_mu); + return id; +} +const uint8_t *nvn_ctl_draw_resolve(const void *handle, uint32_t id) { + const uint8_t *p = NULL; + pthread_mutex_lock(&g_ctl_ticket_mu); + size_t pos = ctl_ticket_lower((uintptr_t)handle); + if (pos < g_ctl_ticket_n && g_ctl_tickets[pos].handle == (uintptr_t)handle && + g_ctl_tickets[pos].generation == id) p = g_ctl_tickets[pos].snapshot; + pthread_mutex_unlock(&g_ctl_ticket_mu); + return p; +} NVN(nvnCommandBufferAddCommandMemory, void, NVNcommandBuffer *c, const NVNmemoryPool *p, ptrdiff_t off, size_t n) { const pool_t *pl = (const pool_t *)p; if (!pl || n < 64) { if (cs_on()) { pthread_mutex_lock(&g_cs_mu); g_cs.hostseg++; pthread_mutex_unlock(&g_cs_mu); } size_t sz = hostseg_size(CB(c), !pl, n); cmd_new_segment(CB(c), hostseg_take(CB(c), sz), sz); return; } @@ -930,12 +1007,43 @@ NVN(nvnCommandBufferAddCommandMemory, void, NVNcommandBuffer *c, const NVNmemory CB(c)->used = 0; // GetCommandMemoryUsed counts from the start of the memory the game last added, across recordings (see there) } NVN(nvnCommandBufferAddControlMemory, void, NVNcommandBuffer *c, void *m, size_t n) { - (void)m; - CB(c)->ctl_size = n; // The host's recordings contain no control-memory entries; the entire last block remains free. -} -// Get space for one record and keep `keep` bytes after it (OP_RESERVE, 0 for the END); if not enough, call back into the game per the NVN convention to add command memory -static uint8_t *cmd_alloc_keep(cmd_t *o, uint32_t size, uint32_t keep) { + cmd_t *o = CB(c); + o->ctl_mem = m; o->ctl_size = n; o->ctl_used = 0; +} +// The *guest* NVN/NVN2 command alignment is four, but our canonical record +// payloads need eight. A 4-byte terminal draw ticket leaves cur+4; if the +// next recording is not another standalone direct draw, reserve four actual +// padding bytes before its first canonical record. An unfinished single draw +// at cur%8==4 is still unobserved (its handle is not returned until END), so +// move those 40 bytes forward by four before a second operation or a GetUsed +// query. No four-byte padding becomes part of a replay/capture segment. +static void cmd_align8(cmd_t *o) { + if (!o->cur || !((uintptr_t)o->cur & 7)) return; + if (((uintptr_t)o->cur & 7) != 4) die("NVN control-draw: invalid command record alignment"); + // A previous terminal ticket may have ended the grant. Preserve its exact + // 4-byte charge; the next allocation will request a fresh segment. + if (o->end - o->cur < 4) { + if (o->recording == 1) die("NVN control-draw: scratch record has no reserved alignment space"); + return; + } + if (o->recording == 1) { + if (!o->begin || ((uintptr_t)o->begin & 7) != 4 || o->cur != o->begin + 40) + die("NVN control-draw: cannot realign previously published command bytes"); + memmove(o->begin + 4, o->begin, 40); + memset(o->begin, 0, 4); + o->begin += 4; + } else memset(o->cur, 0, 4); + o->cur += 4; + o->used += 4; +} +// Get space for a canonical record and keep `keep` bytes after it. For a +// transient, single direct indexed draw only, allow a 4-aligned but not +// 8-aligned *scratch* record: rec_draw_idx writes it with memcpy, and the +// terminal token either shrinks it to four bytes or cmd_align8 restores +// canonical alignment before any subsequent operation. +static uint8_t *cmd_alloc_keep_ex(cmd_t *o, uint32_t size, uint32_t keep, int direct4) { size = (size + 7) & ~7u; + if (!direct4) cmd_align8(o); for (int tries = 0; !o->cur || o->cur + size + keep > o->end; tries++) { // The game's callback cannot supply enough (newer-SDK games hand out small chunks sized for real-device commands, or disable the callback and manage memory themselves): continue recording in a host segment instead of aborting if (!o->cb || tries == 2) { @@ -952,6 +1060,7 @@ static uint8_t *cmd_alloc_keep(cmd_t *o, uint32_t size, uint32_t keep) { if (o->recording == 2) { o->begin = p; o->recording = 1; } // The recording's first record is its handle return p; } +static uint8_t *cmd_alloc_keep(cmd_t *o, uint32_t size, uint32_t keep) { return cmd_alloc_keep_ex(o, size, keep, 0); } static uint8_t *cmd_alloc(cmd_t *o, uint32_t size) { return cmd_alloc_keep(o, size, OP_RESERVE); } // A recording starts with its first command, as in NVN: no BEGIN record, so an empty recording takes only its END. Games size command memory by GetCommandMemoryUsed // (nvn_obj.h draw_rec): Breath of the Wild sums one scratch recording per mesh and per shape and render pass into each model's prerecorded commands, and with an @@ -960,16 +1069,83 @@ NVN(nvnCommandBufferBeginRecording, void, NVNcommandBuffer *c) { cmd_t *o = CB(c); if (o->recording) LOG("[RED] BeginRecording re-entered"); o->recording = 2; // 2: recording, no command yet (cmd_alloc sets begin) + o->used_queried = 0; +} +static pthread_once_t g_draw_ctl_once = PTHREAD_ONCE_INIT; +static int g_draw_ctl_enabled; +static void nvn_draw_ctl_init(void) { + const char *e = getenv("DM_NVN_DRAW_CTL"); + // One thread-safe environment read, with explicit override of the profile. + g_draw_ctl_enabled = e ? (e[0] == '1' && e[1] == 0) : DM_GAME_NVN_DRAW_CTL; +} +static int nvn_draw_ctl_on(void) { + pthread_once(&g_draw_ctl_once, nvn_draw_ctl_init); + return g_draw_ctl_enabled; } + NVN(nvnCommandBufferEndRecording, NVNcommandHandle, NVNcommandBuffer *c) { cmd_t *o = CB(c); + // Opt-in: for one plain indexed draw only, keep the full + // draw+END (48 real bytes) in the CONTROL memory the guest supplied and + // replace the original 40 command bytes with one 4-byte terminal token. + // This changes no guest-visible offset already queried during recording. + // Empty/multi-command, indirect, no-control and insufficient-control + // cases all retain the original in-place command stream. + if (nvn_draw_ctl_on() && o->recording == 1 && !o->used_queried && + o->begin && o->cur == o->begin + 40 && o->ctl_mem && o->ctl_used <= o->ctl_size && + o->ctl_size - o->ctl_used >= 48) { + op_hdr h; memcpy(&h, o->begin, sizeof h); + uint8_t *a = (uint8_t *)(((uintptr_t)o->ctl_mem + o->ctl_used + 7) & ~(uintptr_t)7); + size_t pad = (size_t)(a - (o->ctl_mem + o->ctl_used)); + if (h.op == OP_DRAW_IDX && h.size == 40 && pad + 48 <= o->ctl_size - o->ctl_used && + (uintptr_t)a > 4096) { + p_draw_idx d = {0}; + memcpy(&d, o->begin + 8, 32); // transient record can be only 4-aligned + // h.size == 40 proves the indirect/multi-draw tail is ABSENT. + // Never inspect d->ind/d->mdi_cnt here: those fields start at + // record offsets 40/48 and may contain arbitrary bytes from the + // next, not-yet-written part of the guest command pool. + if (d.instances == 1 && !d.base_instance) { + memcpy(a, o->begin, 40); + put_hdr(a + 40, OP_END, 8); + uint32_t id = ctl_ticket_add(o->begin, a); + if (id) { + uint32_t ticket = NVN_CTL_DRAW_TAG4 | id; + memcpy(o->begin, &ticket, 4); + o->cur = o->begin + 4; + o->used -= 36; + o->ctl_used += pad + 48; + o->recording = 0; + return (NVNcommandHandle)o->begin; + } + } + } + } uint8_t *p = cmd_alloc_keep(o, 8, 0); put_hdr(p, OP_END, 8); // nothing follows: the END may take the segment's last bytes o->recording = 0; return (NVNcommandHandle)o->begin; } NVN(nvnCommandBufferIsRecording, NVNboolean, const NVNcommandBuffer *c) { return ((const cmd_t *)c)->recording != 0; } NVN(nvnCommandBufferCopyCommands, void, NVNcommandBuffer *c, int n, const NVNcommandHandle *h) { - for (int i = 0; i < n; i++) { uint8_t *p = cmd_alloc(CB(c), 16); put_hdr(p, OP_CALL, 16); memcpy(p + 8, &h[i], 8); } + // Preserve the legacy path and its cost for every game that has not opted + // into four-byte direct-draw tickets. It need not dereference each handle. + if (!nvn_draw_ctl_on()) { + for (int i = 0; i < n; i++) { uint8_t *p = cmd_alloc(CB(c), 16); put_hdr(p, OP_CALL, 16); memcpy(p + 8, &h[i], 8); } + return; + } + for (int i = 0; i < n; i++) { + // CopyCommands snapshots a terminal draw into the destination stream, + // so its replay does not depend on the originating handle remaining + // valid after the source command memory is re-granted. Each physical + // 40-byte copy is charged to the destination guest command grant. + uint8_t copy[40]; + ctl_ticket_read_begin(); + const uint8_t *draw = h[i] ? nvn_ctl_draw_target((const void *)(uintptr_t)h[i]) : NULL; + if (draw) memcpy(copy, draw, sizeof copy); + ctl_ticket_read_end(); + if (draw) { uint8_t *p = cmd_alloc(CB(c), 40); memcpy(p, copy, sizeof copy); continue; } + uint8_t *p = cmd_alloc(CB(c), 16); put_hdr(p, OP_CALL, 16); memcpy(p + 8, &h[i], 8); + } } NVN(nvnCommandBufferSignalEvent, void, NVNcommandBuffer *c, const NVNevent *e, NVNeventSignalMode m, NVNeventSignalLocation loc, int flags, uint32_t v) { (void)loc; (void)flags; @@ -1014,7 +1190,20 @@ static void *rec(NVNcommandBuffer *c, uint32_t op, size_t payload) { #define RECN(c, OP, T, ARR, N) ((T *)rec((c), (OP), offsetof(T, ARR) + sizeof(((T *)0)->ARR[0]) * (size_t)(N))) // Draws carry the indirect / multi-draw fields (ind onwards) only when they use them: a plain indexed draw record is 0x28 bytes instead of 0x40 (nvn_obj.h draw_rec) static void rec_draw(NVNcommandBuffer *c, p_draw d) { size_t n = d.ind || d.mdi_cnt ? sizeof d : offsetof(p_draw, ind); memcpy(rec(c, OP_DRAW, n), &d, n); } -static void rec_draw_idx(NVNcommandBuffer *c, p_draw_idx d) { size_t n = d.ind || d.mdi_cnt ? sizeof d : offsetof(p_draw_idx, ind); memcpy(rec(c, OP_DRAW_IDX, n), &d, n); } +static void rec_draw_idx(NVNcommandBuffer *c, p_draw_idx d) { + size_t n = d.ind || d.mdi_cnt ? sizeof d : offsetof(p_draw_idx, ind); + cmd_t *o = CB(c); + // For a new, one-draw recording, stage its canonical 40-byte form at + // 4-byte alignment (only memcpy accesses). EndRecording can then reduce + // it to an actual 4-byte token. If a second op or offset query arrives, + // cmd_align8 shifts the still-unpublished record into 8-byte alignment. + if (n == offsetof(p_draw_idx, ind) && nvn_draw_ctl_on() && o->recording == 2 && + o->cur && ((uintptr_t)o->cur & 7) == 4) { + uint8_t *p = cmd_alloc_keep_ex(o, 40, OP_RESERVE + 4, 1); + put_hdr(p, OP_DRAW_IDX, 40); + memcpy(p + 8, &d, n); + } else memcpy(rec(c, OP_DRAW_IDX, n), &d, n); +} static void copy_view(view_t *dst, uint8_t *has, const NVNtextureView *v) { *has = v != NULL; if (v) *dst = *(const view_t *)v; } // Records with textures snapshot the texture objects (0x80 bytes each, after the record struct) and replay reads the snapshots: an NVN command no longer depends on the API object once recorded, and the game may finalize, free or reuse it before submitting. // Unity (Engage) does so when it changes scenes; only the object pointer used to be recorded, and replay read other data (a texture 1869367126 high, a Metal assertion abort). @@ -1346,6 +1535,7 @@ NVN(nvnQueueSubmitCommands, void, NVNqueue *q, int n, const NVNcommandHandle *h) ((queue_t *)q)->submits++; mtl_queue_activate(((const queue_t *)q)->serial); mtl_set_queue_origin_lower_left(((const queue_t *)q)->origin_ll); + if (nvn_draw_ctl_on()) ctl_ticket_read_begin(); nvn_cap_submit(n, (const uint64_t *)h); // DM_NVN_CAPTURE (nvn_capture.inc) mtl_sub_begin(); nvn_ft_poll(); @@ -1353,6 +1543,7 @@ NVN(nvnQueueSubmitCommands, void, NVNqueue *q, int n, const NVNcommandHandle *h) for (int i = 0; i < n; i++) { mtl_exec_rest((const uint64_t *)h + i + 1, n - i - 1); mtl_exec((const uint8_t *)h[i]); } mtl_exec_rest(NULL, 0); mtl_sub_commit(); + if (nvn_draw_ctl_on()) ctl_ticket_read_end(); pthread_mutex_unlock(&g_sub_mu); uint64_t dw = now_ns() - t0; atomic_fetch_add(&g_nvn_ns, dw); atomic_fetch_add(&g_sub_wall_ns, dw); @@ -2519,17 +2710,20 @@ NVN(nvnDeviceSetIntermediateShaderCache, void, NVNdevice *d, int n) { (void)d; ( NVN_NOPROTO(nvnWindowBuilderSetNumActiveTextures, void, NVNwindowBuilder *b, int n) { (void)b; ONCE_LOG("WindowBuilderSetNumActiveTextures %d (the host rotates through the SetTextures count)", n); } NVN_NOPROTO(nvnWindowSetNumActiveTextures, void, NVNwindow *w, int n) { (void)w; ONCE_LOG("WindowSetNumActiveTextures %d (the host rotates through the SetTextures count)", n); } -// Command buffer memory usage: record bytes written in this recording (accumulated across segments; the host does not use control memory, always 0); the memory callback switch is ignored. +// Command-buffer usage: real command bytes accumulated across segments. With +// nvn_draw_ctl on, control used/free also account for actual 48-byte guest- +// owned draw sidecars (not fabricated command bytes). The callback switch is ignored. // It used to be cur - begin: once a recording overflowed into a host segment, cur is in host memory (an address below the guest begin) and it returned 0. SMBW asks for this value after recording each material setup handle, // and on 0 (with control memory also 0) treats the recording as empty and marks the handle invalid (from main+0x4bb6c it clears bit 0 of the material entry +0x50); later draws no longer CALL it and inherit the previous object's vertex buffers / textures: // on the iPhone a batch of draws on odd frames of the opening course fetched vertices out of bounds, giving huge triangles and NaN (black band, black-edged glow, grey stripes). Without overflow the accumulated value equals cur - begin. // As in NVN, the count runs from the start of the command memory the game last added (AddCommandMemory), not from BeginRecording: Fire Emblem: Three Houses adds one 16 MB chunk of a ring to its main // command buffer and records every frame into it; its memory callback (main+0x9ca960) takes chunk start + used as the position the GPU has reached and frees the ring up to there when the frame's fence // signals. Counted per recording, the position stayed at one frame's size (about 18 KB), the ring never freed past it, and after the first 16 MB (about 15 s) the callback waited for ring space forever -NVN(nvnCommandBufferGetCommandMemoryUsed, size_t, const NVNcommandBuffer *c) { const cmd_t *m = (const cmd_t *)c; +NVN(nvnCommandBufferGetCommandMemoryUsed, size_t, const NVNcommandBuffer *c) { cmd_t *m = (cmd_t *)c; + if (m->recording) { cmd_align8(m); m->used_queried = 1; } return m->begin ? (size_t)m->used : 0; } -NVN(nvnCommandBufferGetControlMemoryUsed, size_t, const NVNcommandBuffer *c) { (void)c; return 0; } -NVN(nvnCommandBufferGetControlMemoryFree, size_t, const NVNcommandBuffer *c) { return ((const cmd_t *)c)->ctl_size; } +NVN(nvnCommandBufferGetControlMemoryUsed, size_t, const NVNcommandBuffer *c) { return ((const cmd_t *)c)->ctl_used; } +NVN(nvnCommandBufferGetControlMemoryFree, size_t, const NVNcommandBuffer *c) { const cmd_t *o = (const cmd_t *)c; return o->ctl_size - o->ctl_used; } NVN(nvnCommandBufferGetControlMemorySize, size_t, const NVNcommandBuffer *c) { return ((const cmd_t *)c)->ctl_size; } // Size and free bytes of the command memory the game last added (DRAGON QUEST XI S, Unreal Engine 4.18): as in NVN, size = used + free; free is what is left in the current segment NVN(nvnCommandBufferGetCommandMemoryFree, size_t, const NVNcommandBuffer *c) { const cmd_t *m = (const cmd_t *)c; return m->cur && m->end > m->cur ? (size_t)(m->end - m->cur) : 0; } @@ -2701,7 +2895,11 @@ NVN_NOPROTO(nvnProgramSetSampleShading, NVNboolean, NVNprogram *p, int mode) { ( // Singular / combined forms → plural / separate forms; state the replay layer does not have yet (conditional render, depth bounds, ...) is logged once and drawing continues (better to over-draw than to drop). #include "../tex/nvn_deswizzle.h" -NVN(nvnCommandBufferCallCommands, void, NVNcommandBuffer *c, int n, const NVNcommandHandle *h) { h_nvnCommandBufferCopyCommands(c, n, h); } // the replay layer treats CALL and COPY alike +NVN(nvnCommandBufferCallCommands, void, NVNcommandBuffer *c, int n, const NVNcommandHandle *h) { + // A call retains its reference to the source command handle. In contrast, + // CopyCommands above may make an independent copy of a control draw. + for (int i = 0; i < n; i++) { uint8_t *p = cmd_alloc(CB(c), 16); put_hdr(p, OP_CALL, 16); memcpy(p + 8, &h[i], 8); } +} // Combined texture handle (texture id | sampler id << 20) is recorded into the texture slot as is: the replay layer takes the texture from the low 20 bits and the sampler from the high bits (nvn_mtl.m NVN_RB_SAMPT) // One combined handle: recorded as the hardware lays the command out, the handle 16 bytes in, where Luigi's Mansion 3 patches it after recording (nvn_obj.h p_bind_tex1) NVN(nvnCommandBufferBindTexture, void, NVNcommandBuffer *c, NVNshaderStage st, int i, NVNtextureHandle h) { st = st_host(st); diff --git a/dm/gfx/nvn/nvn_capture.c b/dm/gfx/nvn/nvn_capture.c index dc6de22364..34e5418bd2 100644 --- a/dm/gfx/nvn/nvn_capture.c +++ b/dm/gfx/nvn/nvn_capture.c @@ -35,6 +35,23 @@ CAP_OFF(p_multi_draw, stride, 24); CAP_OFF(p_multi_draw, max, 32); CAP_OFF(p_mul _Static_assert(OP_MULTI_DRAW == 55, "keep existing capture op numbers stable"); _Static_assert(OP_COUNT_ == 56, "nvn_capture.c: a record op was added or removed: give it a case in cap_rec_fields"); +// The capture-format unit test links only this translation unit, without the +// live NVN recorder/registry. In the host the strong implementation in +// hle_nvn.c resolves four-byte tickets to guest-owned control sidecars. +__attribute__((weak)) const uint8_t *nvn_ctl_draw_resolve(const void *handle, uint32_t id) { (void)handle; (void)id; return NULL; } + +int cap_ctl_draw_expand(const void *src, uint8_t out[16]) { + const uint8_t *p = nvn_ctl_draw_target(src); + if (!p) return 0; + op_hdr draw, end, jump = {OP_JUMP, 16}; + memcpy(&draw, p, sizeof draw); + memcpy(&end, p + 40, sizeof end); + if (draw.op != OP_DRAW_IDX || draw.size != 40 || end.op != OP_END || end.size != 8) return 0; + memcpy(out, &jump, sizeof jump); + memcpy(out + 8, &p, sizeof p); + return 1; +} + #define MULTI_DRAW_LAYOUT "op 55 p_multi_draw 48 ind 0 cnt 8 ibuf 16 stride 24 max 32 prim 36 itype 40\n" // Copy strides travel after a copy's texture snapshot (p_copy_strides) instead of as OP_COPY_STRIDE records; the replay still takes OP_COPY_STRIDE from older captures #define COPY_STRIDES_LAYOUT "p_copy_strides 16 at 192\n" diff --git a/dm/gfx/nvn/nvn_capture.h b/dm/gfx/nvn/nvn_capture.h index bec00f50cb..7cb39cb3ff 100644 --- a/dm/gfx/nvn/nvn_capture.h +++ b/dm/gfx/nvn/nvn_capture.h @@ -84,6 +84,11 @@ typedef struct { } cap_ops; // Walks one record (op, payload of size - 8 bytes); 0 = an op the walk does not know (the capture fails rather than write host pointers) int cap_rec_fields(uint32_t op, uint8_t *payload, uint32_t size, const cap_ops *ops, void *ctx); +// Expand an experimental guest 8-byte terminal draw token into a canonical +// 16-byte JUMP record in the capture file (never in the guest's memory). +// The destination is the guest-owned control memory with the original draw +// record and END, which is then captured as a normal relocatable segment. +int cap_ctl_draw_expand(const void *src, uint8_t out[16]); // The pointers inside a tex_t snapshot: mem (an address) and pool (a pool object) int cap_tex_fields(tex_t *t, const cap_ops *ops, void *ctx); // Layout fingerprint of every payload struct the walk knows (text): stored in the capture, compared by the replayer diff --git a/dm/gfx/nvn/nvn_capture.inc b/dm/gfx/nvn/nvn_capture.inc index be405dbf06..589cb2905a 100644 --- a/dm/gfx/nvn/nvn_capture.inc +++ b/dm/gfx/nvn/nvn_capture.inc @@ -186,6 +186,26 @@ static uint64_t cap_seg_get(uint64_t addr) { // a segment's index in this subm } static void cap_seg_write(uint32_t idx) { // copy the records from the segment start to its END or JUMP, then encode them in the copy const uint8_t *p = (const uint8_t *)(uintptr_t)g_cap_segq[idx]; size_t n = 0; + uint8_t jump[16]; + if (cap_ctl_draw_expand(p, jump)) { + // Capture representation, not guest command memory: a canonical JUMP + // to the control-memory draw+END. The ordinary CAP_K_SEG relocation + // queues that sidecar, whose draw index-buffer address is relocated + // by the ordinary OP_DRAW_IDX walk. + uint8_t b[24]; uint32_t hd[2] = {idx, 16}; + memcpy(b, hd, 8); memcpy(b + 8, jump, 16); + g_cap_orig = p; + CAP_WHERE(b + 16, "control-draw jump at segment %u", idx); + if (!cap_rec_fields(OP_JUMP, b + 16, 16, &g_cap_enc, NULL)) cap_fail("control draw target relocation failed"); + g_cap_orig = NULL; CAP_WHERE(NULL, "-"); + cap_put(&g_cap_w, CAP_SEGM, g_cap_cur_submit, b, sizeof b, 1); + g_cap_segs++; + return; + } + if (nvn_ctl_draw_is_ticket(p)) { + cap_fail("orphan 4-byte command ticket: guest command memory was overwritten or re-granted before capture"); + return; + } for (;;) { op_hdr h; memcpy(&h, p + n, 8); if (h.size < 8 || (h.size & 7) || h.size > (64u << 20) || h.op >= OP_COUNT_) { cap_fail("a corrupt record in a submitted segment"); MLOG("[RED] DM_NVN_CAPTURE: segment %p offset %zu: op %u size %u", p, n, h.op, h.size); break; } diff --git a/dm/gfx/nvn/nvn_mtl.m b/dm/gfx/nvn/nvn_mtl.m index ddc242dbd2..7df733efcf 100644 --- a/dm/gfx/nvn/nvn_mtl.m +++ b/dm/gfx/nvn/nvn_mtl.m @@ -9210,7 +9210,7 @@ static void fan_ib_make(void) { static int fz_next(fz_cursor *c, const uint8_t **out, op_hdr *oh, const void **oa) { for (int guard = 0; guard < 1000000; guard++) { if (!c->p) return 0; - op_hdr h; memcpy(&h, c->p, sizeof h); const void *a = c->p + 8; + c->p = nvn_ctl_draw_follow(c->p); op_hdr h; memcpy(&h, c->p, sizeof h); const void *a = c->p + 8; switch (h.op) { // The live draws and their argument addresses are resolved at replay. A speculative scan cannot move work across this range. case OP_MULTI_DRAW: c->bad = 1; return 0; @@ -9282,7 +9282,7 @@ static int defer_same_q_depth(const defer_t *a, const p_set_rt *q) { static int g_ds_why; static int g_ds_scanned; static int g_ds_need; // g_ds_need: need accumulated during the scan, 1 depth 2 stencil // Diagnostics: the opcode that triggered the 'used' verdict (for a draw, records 1000 + dt*100 + dw*10 + st), and the number of ops scanned static int ds_scan(const uint8_t *p, ds_st *ds, int depth) { for (int guard = 0; guard < 1000000; guard++) { - op_hdr h; memcpy(&h, p, sizeof h); + p = nvn_ctl_draw_follow(p); op_hdr h; memcpy(&h, p, sizeof h); const void *a = p + 8; g_ds_scanned++; if (g_hoist_n && hoist_skipped(p)) { p += h.size; continue; } // Clear pass already executed early switch (h.op) { @@ -9578,7 +9578,7 @@ static void fz_probe_cov2(const p_set_rt *r2, const uint8_t *first, const int8_t static typeof(S) Ssave; memcpy(&Ssave, &S, sizeof S); typeof(R) Rsave = R; const p_draw *fdw = g_fd_dw; const p_draw_idx *fdi = g_fd_di; int fdwhy = g_fd_why; int pso_dirty = g_pso_dirty, dss_dirty = g_dss_dirty; - op_hdr fh; memcpy(&fh, first, sizeof fh); p_draw fdw_t; p_draw_idx fdi_t; + first = nvn_ctl_draw_follow(first); op_hdr fh; memcpy(&fh, first, sizeof fh); p_draw fdw_t; p_draw_idx fdi_t; g_fd_dw = fh.op == OP_DRAW ? draw_rec(first + 8, fh.size, &fdw_t) : NULL; g_fd_di = fh.op == OP_DRAW_IDX ? draw_idx_rec(first + 8, fh.size, &fdi_t) : NULL; R.rt = *r2; R.has_rt = 1; @@ -9669,7 +9669,7 @@ static int sm_rt_slice(const p_set_rt *a, const p_set_rt *b, int *slice) { static int sm_scan(const uint8_t *p, const p_set_rt *rt, int cur, int *lo, int *hi, uint64_t *mask) { int n = 0; *lo = *hi = cur; *mask = cur < 64 ? 1ull << cur : 0; for (int guard = 0; guard < 4096 && p; guard++) { - op_hdr h; memcpy(&h, p, sizeof h); const void *a = p + 8; + p = nvn_ctl_draw_follow(p); op_hdr h; memcpy(&h, p, sizeof h); const void *a = p + 8; switch (h.op) { case OP_JUMP: { const uint8_t *t; memcpy(&t, a, 8); p = t; continue; } case OP_SET_RT: { int sl; if (!sm_rt_slice(rt, a, &sl) || sl >= 64) return n; @@ -9821,7 +9821,7 @@ static int pass_ds_need(void) { static void sdx_walk(const uint8_t *p, int depth) { if (depth > 64) { g_sdx_ok = 0; return; } for (uint32_t guard = 0; guard < 4000000 && p && g_sdx_ok; guard++) { - op_hdr h; memcpy(&h, p, sizeof h); const void *a = p + 8; + p = nvn_ctl_draw_follow(p); op_hdr h; memcpy(&h, p, sizeof h); const void *a = p + 8; if (g_sdx_nop == g_sdx_opcap) { g_sdx_opcap = g_sdx_opcap ? g_sdx_opcap * 2 : 16384; g_sdx_op = realloc_or_die(g_sdx_op, g_sdx_opcap * sizeof *g_sdx_op, "the store lookahead index"); } uint32_t seq = g_sdx_nop; g_sdx_op[g_sdx_nop++] = p; switch (h.op) { @@ -9947,7 +9947,7 @@ static void hoist_try(void) { uint64_t bh[512]; int nbh = 0; const uint8_t *p = g_exec_next; for (int guard = 0; guard < 200000; guard++) { - op_hdr h; memcpy(&h, p, sizeof h); const void *a = p + 8; + p = nvn_ctl_draw_follow(p); op_hdr h; memcpy(&h, p, sizeof h); const void *a = p + 8; switch (h.op) { case OP_END: case OP_CALL: case OP_COPY_T2T: case OP_DRAW_TEX: case OP_COPY_B2T: case OP_COPY_T2B: case OP_CLEAR_TEX: case OP_DLSS: case OP_DISPATCH: case OP_RENDER_COND: return; case OP_JUMP: { const uint8_t *t; memcpy(&t, a, 8); p = t; continue; } @@ -10232,7 +10232,7 @@ static void defer_try(const p_draw *dw, const p_draw_idx *di) { uint64_t tex[6][NTEX], img[6][32]; memcpy(tex, S.tex, sizeof tex); memcpy(img, S.img, sizeof img); const prog_t *pg = S.prog; ds_st ds = S.ds; const uint8_t *p = g_exec_next, *p_end = NULL, *qop = NULL; const p_set_rt *q = NULL; p_set_rt scan_rt = R.rt; int srt_hit = -1, ci = 0, draws = 1, p_has_draw = 1; defer_collect_reads(&g_defer, pg, tex, img); // The current draw is P's first for (int guard = 0; guard < 200000; guard++) { - op_hdr h; memcpy(&h, p, sizeof h); const void *a = p + 8; + p = nvn_ctl_draw_follow(p); op_hdr h; memcpy(&h, p, sizeof h); const void *a = p + 8; if (g_hoist_n && hoist_skipped(p)) { p += h.size; continue; } switch (h.op) { case OP_END: { const uint8_t *nx = defer_cont(&ci); if (!nx) { g_defer.reason = 1; goto fail; } p = nx; continue; } @@ -10267,7 +10267,7 @@ static void defer_try(const p_draw *dw, const p_draw_idx *di) { if (!p_end) p_end = p; int shared = 0; for (int i = 0; i < g_defer.p_rt.n; i++) for (int j = 0; j < r->n; j++) if (g_defer.p_rt.color[i] && r->color[j] && sub_eq(&g_defer.p_rt, i, r, j)) shared = 1; if (shared) { // An empty render pass (targets set, then changed again without drawing or clearing) touches no attachment: it is neither taken as Q nor counted as a conflict const uint8_t *e = p + h.size; int ec = ci, empty = 0; - for (int g2 = 0; g2 < 200000; g2++) { op_hdr he; memcpy(&he, e, sizeof he); + for (int g2 = 0; g2 < 200000; g2++) { e = nvn_ctl_draw_follow(e); op_hdr he; memcpy(&he, e, sizeof he); if (he.op == OP_JUMP) { memcpy(&e, e + 8, 8); continue; } if (he.op == OP_END) { const uint8_t *nx = defer_cont(&ec); if (!nx) break; e = nx; continue; } if (he.op == OP_SET_RT) { empty = 1; break; } @@ -10283,8 +10283,8 @@ static void defer_try(const p_draw *dw, const p_draw_idx *di) { fail: if (g_defer.reason && passdump()) PD("G %llu rejected reason=%d op=%d", g_stat_pass + 1, g_defer.reason, g_defer.reason_op); memset(&g_defer, 0, sizeof g_defer); return; q_found: - p = qop; op_hdr qh; memcpy(&qh, p, sizeof qh); p += qh.size; int q_body = 0, q_clears = 0; // Binding tracking continues from the main scan's state (does not go back to S at the start of P) - for (int guard = 0; guard < 200000; guard++) { op_hdr h; memcpy(&h, p, sizeof h); const void *a = p + 8; if (h.op == OP_JUMP) { memcpy(&p, a, 8); continue; } + p = qop; p = nvn_ctl_draw_follow(p); op_hdr qh; memcpy(&qh, p, sizeof qh); p += qh.size; int q_body = 0, q_clears = 0; // Binding tracking continues from the main scan's state (does not go back to S at the start of P) + for (int guard = 0; guard < 200000; guard++) { p = nvn_ctl_draw_follow(p); op_hdr h; memcpy(&h, p, sizeof h); const void *a = p + 8; if (h.op == OP_JUMP) { memcpy(&p, a, 8); continue; } // The whole Q segment (up to the next SetRenderTargets / end of list): no draw may sample P's color attachments through a view covering P's level -- after deferral P's result is still in the tile and has not landed in main memory, so sampling would read old content if (h.op == OP_DRAW || h.op == OP_DRAW_IDX) { if (defer_prog_hits(pg, tex, img, &g_defer.p_rt, 1, 1)) { g_defer.reason = 14; goto fail; } q_body = 1; p += h.size; continue; } if (q_body && (h.op == OP_SET_RT || h.op == OP_END)) break; @@ -10510,7 +10510,7 @@ static int zskip_view_zero(int tid) { static int zskip_vis_dead(void) { if (!g_vis_counting) return 1; const uint8_t *p = g_exec_next; int ci = 0; if (!p) return 0; - for (int guard = 0; guard < 200000; guard++) { op_hdr h; memcpy(&h, p, sizeof h); const void *a = p + 8; + for (int guard = 0; guard < 200000; guard++) { p = nvn_ctl_draw_follow(p); op_hdr h; memcpy(&h, p, sizeof h); const void *a = p + 8; if (h.op == OP_JUMP) { memcpy(&p, a, 8); continue; } if (h.op == OP_END) { p = defer_cont(&ci); if (!p) return 0; continue; } if (h.op == OP_CALL) return 0; @@ -12992,7 +12992,7 @@ void mtl_exec(const uint8_t *p) { static int dump_all = -1; if (dump_all < 0) dump_all = getenv("DM_NVN_PASSDUMP_ALL") != NULL; int trace = dump_all && passdump(); // Normal mode does not check the frame range per operation; the full command position is only used for the designated diagnostic frames for (;;) { - op_hdr h; memcpy(&h, p, sizeof h); + p = nvn_ctl_draw_follow(p); op_hdr h; memcpy(&h, p, sizeof h); g_exec_cur = p; // Scan start point of the store lookahead: the operation that ends the render pass (inclusive) if (p == g_defer_pend) { for (int gi = 0; gi < 4; gi++) if (g_defer_slots[gi].active && g_defer_slots[gi].p_end_op == p) g_defer_slots[gi].in_p = 0; g_defer_pend = NULL; } sdx_seq(p); diff --git a/dm/gfx/nvn/nvn_obj.h b/dm/gfx/nvn/nvn_obj.h index 903a278bde..1c43d3facd 100644 --- a/dm/gfx/nvn/nvn_obj.h +++ b/dm/gfx/nvn/nvn_obj.h @@ -3,6 +3,8 @@ // State objects (Blend / Color / ... 4–0x18 bytes) are small structs the game copies by value; their layout is packed in place with bit-fields. #pragma once #include +#include +#include #include #include "nvn.h" #include "nvn_tex.h" @@ -79,8 +81,11 @@ typedef struct { NVNdevice *dev; uint64_t used; // Bytes written since the game last added command memory, across recordings and host segments, including segment-switch JUMPs (returned by GetCommandMemoryUsed) int32_t tex_base, samp_base; // Host-table bases of the texture / sampler pool this command buffer last bound (hle_nvn.c dpool_add; 0 = the first pool of its kind) - size_t ctl_size; // Bytes in the last control memory block supplied by the game (the host records no control commands) + size_t ctl_size; // Bytes in the last control-memory grant supplied by the game int64_t copy_row, copy_image; // SetCopyRowStride / SetCopyImageStride: state of the command buffer, written into each buffer <-> texture copy (p_copy_strides), not recorded + uint8_t *ctl_mem; // Guest-provided control storage for opt-in self-contained indexed draw+END sidecars + size_t ctl_used; // Actual bytes written in the current guest control memory grant + uint32_t used_queried; // A guest command offset was observed during recording: never rewrite its existing bytes } cmd_t; FITS(cmd_t, NVNcommandBuffer); // ---------- State objects (defaults follow the NVN / GL defaults) ---------- @@ -101,6 +106,36 @@ typedef struct { uint32_t stride, divisor; } vs_st; // ---------- Command records: {op, size (including header, aligned to 8)} + payload ---------- typedef struct { uint32_t op, size; } op_hdr; +// Four physical guest command bytes (NVN2's advertised command alignment is +// four) encode a generation for an exact command-handle address. The *actual* DRAW_IDX + END +// payload remains 48 physical bytes in guest-provided control memory and is +// counted by GetControlMemoryUsed; no guest command bytes are hidden. IDs +// are never reused while the process lives. Live handles are registered by +// their actual guest command address and retired when that address is re-granted. +// Replay uses a stable host snapshot; regranting control memory does not make +// a previously returned command handle unreadable. +// The high bit is disjoint from all valid OP_ values. The command handle +// remains the guest command-memory pointer, and the four bytes may be copied. +#define NVN_CTL_DRAW_TAG4 UINT32_C(0x80000000) +const uint8_t *nvn_ctl_draw_resolve(const void *handle, uint32_t id); +static inline int nvn_ctl_draw_is_ticket(const void *p) { + uint32_t v; memcpy(&v, p, sizeof v); return !!(v & NVN_CTL_DRAW_TAG4); +} +static inline const uint8_t *nvn_ctl_draw_target(const void *p) { + uint32_t v; memcpy(&v, p, sizeof v); + return (v & NVN_CTL_DRAW_TAG4) ? nvn_ctl_draw_resolve(p, v & ~NVN_CTL_DRAW_TAG4) : NULL; +} +static inline const uint8_t *nvn_ctl_draw_follow(const uint8_t *p) { + if (!nvn_ctl_draw_is_ticket(p)) return p; + const uint8_t *t = nvn_ctl_draw_target(p); + if (!t) { + // A tagged token occupies only four physical bytes; reading an 8-byte + // op header from it can cross the end of its guest memory grant. + fprintf(stderr, "[RED] NVN stale 4-byte command handle %p (overwritten or re-granted)\n", (const void *)p); + abort(); + } + return t; +} enum { OP_END, OP_JUMP, OP_BEGIN, OP_CALL, // OP_BEGIN is no longer recorded (hle_nvn.c BeginRecording) OP_SIGNAL_EVENT, OP_REPORT_COUNTER, OP_COPY_B2B, OP_UPDATE_UB, // CPU-visible side effects diff --git a/dm/gfx/tests/t_capture.c b/dm/gfx/tests/t_capture.c index 5a0b00a51d..3c9e88396d 100644 --- a/dm/gfx/tests/t_capture.c +++ b/dm/gfx/tests/t_capture.c @@ -12,6 +12,8 @@ static int checks, failed; // ---- the "live" side: two pools, a heap word list, objects, three segments ---- static uint8_t *pool_a, *pool_b; static uint64_t heap_handles[4] = {0x100001, 0x200002, 0x300003, 0x400004}; +static const uint8_t *test_draw_control; +const uint8_t *nvn_ctl_draw_resolve(const void *handle, uint32_t id) { (void)handle; return id == 42 ? test_draw_control : NULL; } static tex_t game_tex; static prog_t game_prog; static dpool_t game_dpool; static sync_t game_sync; static pool_t game_pool; typedef struct { uint8_t p[4096]; size_t n; } seg; static void *emit(seg *s, uint32_t op, size_t payload) { // as hle_nvn.c rec(): header, zeroed payload, 8-byte aligned @@ -104,6 +106,35 @@ int main(void) { // capture: regions (pool a, pool b; the heap words join as a blob region), encoded copies of the segments, the objects regs[0] = (rg){(uint64_t)(uintptr_t)pool_a, 65536}; regs[1] = (rg){(uint64_t)(uintptr_t)pool_b, 16384}; nregs = 2; + { + // Host 4-byte ticket is *normalized on capture* into ordinary JUMP + // plus a second segment, so old capture format/replay stays intact. + seg side __attribute__((aligned(16))) = {{0}, 0}; + p_draw_idx *dx = emit(&side, OP_DRAW_IDX, offsetof(p_draw_idx, ind)); + dx->prim = 4; dx->instances = 1; dx->count = 17; dx->ibuf = (uint64_t)(uintptr_t)pool_a + 0x4800; + emit(&side, OP_END, 0); + test_draw_control = side.p; + uint32_t token = NVN_CTL_DRAW_TAG4 | 42; + uint8_t canonical[16] = {0}; + CHECK(side.n == 48 && cap_ctl_draw_expand(&token, canonical), "4-byte sidecar ticket expands to a relocatable 16-byte JUMP"); + CHECK(!cap_ctl_draw_expand(&(uint32_t){NVN_CTL_DRAW_TAG4 | 41}, canonical), "unresolved ticket is not accepted"); + CHECK(cap_ctl_draw_expand(&token, canonical), "resolved guest control ticket still expands"); + op_hdr j; memcpy(&j, canonical, 8); + CHECK(j.op == OP_JUMP && j.size == 16, "capture gets canonical jump, not an unknown command opcode"); + seg *saved = segs_by_addr[1]; segs_by_addr[1] = &side; + CHECK(cap_rec_fields(OP_JUMP, canonical + 8, 16, &enc, NULL), "sidecar jump target captures as a segment object"); + uint64_t ref; memcpy(&ref, canonical + 8, 8); + CHECK(cap_obj_kind(ref) == CAP_K_SEG && cap_obj_index(ref) == 1, "control draw sidecar becomes a normal captured command segment"); + uint8_t clone[48]; memcpy(clone, side.p, side.n); + CHECK(cap_rec_fields(OP_DRAW_IDX, clone + 8, 40, &enc, NULL), "sidecar indexed draw gets normal relocation walk"); + uint64_t gpu; memcpy(&gpu, clone + 8 + offsetof(p_draw_idx, ibuf), 8); + CHECK(cap_is_addr(gpu) && cap_addr_region(gpu) == 0 && cap_addr_off(gpu) == 0x4800, "index buffer address relocates through the normal region scheme"); + new_seg[1] = side.p; CHECK(cap_rec_fields(OP_JUMP, canonical + 8, 16, &dec, NULL), "old replay jump field decoder accepts normalized sidecar"); + memcpy(&ref, canonical + 8, 8); + CHECK(ref == (uint64_t)(uintptr_t)side.p, "normalized capture jump round-trips to the relocated sidecar"); + segs_by_addr[1] = saved; + test_draw_control = NULL; + } seg *all[3] = {&s0, &s1, &s2}; uint8_t enc_bytes[3][4096]; for (int i = 0; i < 3; i++) { memcpy(enc_bytes[i], all[i]->p, all[i]->n); walk(all[i], enc_bytes[i], &enc, all[i]->p); } CHECK(nregs == 3, "the deferred handles outside the pools became a region of their own"); diff --git a/dm/gfx/tests/t_capture_live.m b/dm/gfx/tests/t_capture_live.m index bfbb6d0270..3ae2bf7b23 100644 --- a/dm/gfx/tests/t_capture_live.m +++ b/dm/gfx/tests/t_capture_live.m @@ -1,7 +1,7 @@ // DM_NVN_CAPTURE end to end on the GPU, without game files: two frames of synthetic records through the capture hooks, the second one captured; `make check_t_capture_live` // then replays the file with nvn_replay, which must give the same digest. The captured frame depends on everything a capture has to carry: a render target's content // from the frame before (texture readback), pool bytes the CPU wrote before the first submit and between the two submits (regions, block changes), a copy from a pool -// into the target and a buffer fill (relocated addresses), a call into a second segment, a texture object outside the records, a combined program the replay +// into the target and a buffer fill (relocated addresses), a call into a second segment, a four-byte indexed-draw ticket with a real 48-byte sidecar, a texture object outside the records, a combined program the replay // layer makes for itself (its serial must not be a captured one) and a registered texture whose memory pool was finalized. // ./t_capture_live #import @@ -63,6 +63,19 @@ int main(int argc, char **argv) { @autoreleasepool { mtl_pool_del(mem2); mtl_alias_drop_pool(&p2); p2 = (pool_t){0, 0x24924924, (uint8_t *)0x2492492492497f80ull, 0x2492492492497f80ull, NULL, 2}; // frame 2: two submits; the CPU writes the pool before each, the second copy reads what was written between them for (int i = 0; i < 16 * 16; i++) { pool[0x10000 + 4 * i] = 200; pool[0x10000 + 4 * i + 1] = (uint8_t)(3 * i); pool[0x10000 + 4 * i + 2] = 90; pool[0x10000 + 4 * i + 3] = 255; } + // Exercise the actual four-byte ticket through nested OP_CALL, capture's + // canonical JUMP relocation, preindex, recursive Metal replay and the + // standalone replayer. Zero indices mean no shader is needed here. + uint8_t token[4], sidecar[48] = {0}; + const op_hdr draw_header = {OP_DRAW_IDX, 40}, end_header = {OP_END, 8}; + const p_draw_idx draw_data = {.prim=NVN_DRAW_PRIMITIVE_TRIANGLES, + .itype=NVN_INDEX_TYPE_UNSIGNED_SHORT, .instances=1, + .ibuf=(uint64_t)(uintptr_t)(pool + 0x1000)}; + memcpy(sidecar, &draw_header, sizeof draw_header); + memcpy(sidecar + 8, &draw_data, 32); + memcpy(sidecar + 40, &end_header, sizeof end_header); + uint32_t ticket = NVN_CTL_DRAW_TAG4 | ctl_ticket_add(token, sidecar); + memcpy(token, &ticket, sizeof ticket); chain a = {{0}, 0}; // two programs bound to the vertex and fragment stages apart: the replay layer makes a combined program and gives it a serial of its own (bind_stages). The target // above keeps serial 1 in the capture, so a replay counting from 1 gave that serial to the combined program and the target resolved to it (Pokemon Scarlet) @@ -72,6 +85,7 @@ int main(int argc, char **argv) { @autoreleasepool { { p_set_rt *r = emit(&a, OP_SET_RT, sizeof *r); r->n = 1; r->color[0] = &rt; } copy_block(&a, &rt, (uint64_t)(uintptr_t)pool + 0x10000, 32, 8); { uint64_t *c = emit(&a, OP_CALL, 8); *c = (uint64_t)(uintptr_t)sub.p; } + { uint64_t *c = emit(&a, OP_CALL, 8); *c = (uint64_t)(uintptr_t)token; } emit(&a, OP_END, 0); submit(a.p); for (int i = 0; i < 16 * 16; i++) { pool[0x20000 + 4 * i] = (uint8_t)(i * 5); pool[0x20000 + 4 * i + 1] = 40; pool[0x20000 + 4 * i + 2] = (uint8_t)(255 - i); pool[0x20000 + 4 * i + 3] = 255; } diff --git a/dm/gfx/tests/t_nvn.c b/dm/gfx/tests/t_nvn.c index 3f00341d45..694c7c1105 100644 --- a/dm/gfx/tests/t_nvn.c +++ b/dm/gfx/tests/t_nvn.c @@ -11,6 +11,7 @@ module_t *g_mod; int g_nmod; // host symbols that cra void svc_thread_dump(void) {} int dm_guest_as_contains(uintptr_t address) { (void)address; return 0; } // Tests use host-thread stacks. void dm_vi_layer_crop(int c[4]) { c[0] = c[1] = c[2] = c[3] = 0; } // no vi layer crop in unit tests (hle_vi.c) +void dm_vi_frame_queued(uint64_t frame) { (void)frame; } // no VI frame history in headless tests void crash_install(void); void nvn_present_config(int hz, int display_mode); #include @@ -379,6 +380,144 @@ int main(void) { P(nvnTextureFinalize)(&tx); P(nvnCommandBufferFinalize)(&c3); } + // 5aa. Optional control-memory terminal draw token: all command bytes and + // all control bytes are counted against the *guest's* supplied grants. + { + static uint8_t memory[4096] __attribute__((aligned(4096))); + static uint8_t control[512] __attribute__((aligned(16))); + NVNmemoryPoolBuilder b; NVNmemoryPool pool; NVNcommandBuffer cb; + memset(memory, 0xa5, sizeof memory); // The unwritten indirect tail must not affect direct-draw eligibility. + P(nvnMemoryPoolBuilderSetDefaults)(&b); P(nvnMemoryPoolBuilderSetStorage)(&b, memory, sizeof memory); + P(nvnMemoryPoolInitialize)(&pool, &b); P(nvnCommandBufferInitialize)(&cb, &dev); + P(nvnCommandBufferAddControlMemory)(&cb, control, sizeof control); + P(nvnCommandBufferAddCommandMemory)(&cb, &pool, 0, sizeof memory); + setenv("DM_NVN_DRAW_CTL", "1", 1); + nvn_draw_ctl_init(); // Test toggles the startup-only environment setting. + P(nvnCommandBufferBeginRecording)(&cb); + P(nvnCommandBufferDrawElementsBaseVertex)(&cb, NVN_DRAW_PRIMITIVE_TRIANGLES, NVN_INDEX_TYPE_UNSIGNED_SHORT, 36, 0x123400, 5); + NVNcommandHandle h1 = P(nvnCommandBufferEndRecording)(&cb); + EXPECT("control draw: 4 actual guest command bytes", 4, P(nvnCommandBufferGetCommandMemoryUsed)(&cb)); + EXPECT("control draw: 48 actual guest control bytes", 48, P(nvnCommandBufferGetControlMemoryUsed)(&cb)); + EXPECT("control draw: control free reduced", sizeof control - 48, P(nvnCommandBufferGetControlMemoryFree)(&cb)); + EXPECT("control draw: handle remains guest in-pool", 1, (const uint8_t *)h1 == memory); + const uint8_t *side = nvn_ctl_draw_target((const void *)(uintptr_t)h1); + op_hdr h = {0}, tail = {0}; + if (side) { memcpy(&h, side, 8); memcpy(&tail, side + 40, 8); } + EXPECT("control draw: real control payload matches immutable replay snapshot", 1, side && side != control && !memcmp(side, control, 48) && h.op == OP_DRAW_IDX && h.size == 40 && tail.op == OP_END && tail.size == 8); + if (side) { p_draw_idx d; const p_draw_idx *di = draw_idx_rec(side + 8, h.size, &d); + EXPECT("control draw: index address/count/base retained", 1, di->ibuf == 0x123400 && di->count == 36 && di->base_vertex == 5); } + P(nvnCommandBufferBeginRecording)(&cb); + P(nvnCommandBufferDrawElementsBaseVertex)(&cb, NVN_DRAW_PRIMITIVE_TRIANGLES, NVN_INDEX_TYPE_UNSIGNED_SHORT, 3, 0x123800, 0); + NVNcommandHandle h2 = P(nvnCommandBufferEndRecording)(&cb); + EXPECT("second draw keeps first token and new 4-byte offset", 1, (const uint8_t *)h2 == memory + 4 && nvn_ctl_draw_target((const void *)(uintptr_t)h1) && nvn_ctl_draw_target((const void *)(uintptr_t)h2) && !memcmp(nvn_ctl_draw_target((const void *)(uintptr_t)h2), control + 48, 48)); + EXPECT("two independent draws count actual 8 command bytes", 8, P(nvnCommandBufferGetCommandMemoryUsed)(&cb)); + EXPECT("two independent draws count actual 96 control bytes", 96, P(nvnCommandBufferGetControlMemoryUsed)(&cb)); + P(nvnCommandBufferBeginRecording)(&cb); + P(nvnCommandBufferDrawElementsBaseVertex)(&cb, NVN_DRAW_PRIMITIVE_TRIANGLES, NVN_INDEX_TYPE_UNSIGNED_SHORT, 3, 0x123800, 0); + (void)P(nvnCommandBufferGetCommandMemoryUsed)(&cb); // The guest may have cached a command offset. + NVNcommandHandle h3 = P(nvnCommandBufferEndRecording)(&cb); + EXPECT("published offset refuses sidecar rewrite", 1, !nvn_ctl_draw_target((const void *)(uintptr_t)h3)); + EXPECT("published offset retains original 48-byte physical footprint", 56, P(nvnCommandBufferGetCommandMemoryUsed)(&cb)); + P(nvnCommandBufferAddControlMemory)(&cb, control, 16); + P(nvnCommandBufferBeginRecording)(&cb); + P(nvnCommandBufferDrawElementsBaseVertex)(&cb, NVN_DRAW_PRIMITIVE_TRIANGLES, NVN_INDEX_TYPE_UNSIGNED_SHORT, 3, 0x123800, 0); + NVNcommandHandle h4 = P(nvnCommandBufferEndRecording)(&cb); + EXPECT("no control capacity uses old draw+END", 1, !nvn_ctl_draw_target((const void *)(uintptr_t)h4)); + EXPECT("no control capacity charges no control bytes", 0, P(nvnCommandBufferGetControlMemoryUsed)(&cb)); + // With a finite guest control grant the token format cannot steal + // bytes past the end. Exhaustion falls back to in-pool DRAW+END. + P(nvnCommandBufferAddControlMemory)(&cb, control, sizeof control); + P(nvnCommandBufferAddCommandMemory)(&cb, &pool, 0, sizeof memory); + int tickets = 0; + for (int i = 0; i < 12; i++) { + P(nvnCommandBufferBeginRecording)(&cb); + P(nvnCommandBufferDrawElementsBaseVertex)(&cb, NVN_DRAW_PRIMITIVE_TRIANGLES, NVN_INDEX_TYPE_UNSIGNED_SHORT, 3, 0x125000 + i * 2, 0); + NVNcommandHandle next = P(nvnCommandBufferEndRecording)(&cb); + tickets += nvn_ctl_draw_target((const void *)(uintptr_t)next) != NULL; + } + EXPECT("512 control bytes admit ten physical 48-byte draw sidecars", 10, tickets); + EXPECT("all twelve recordings charge actual command bytes", 10 * 4 + 2 * 48, P(nvnCommandBufferGetCommandMemoryUsed)(&cb)); + EXPECT("control bytes charge only successful sidecars", 10 * 48, P(nvnCommandBufferGetControlMemoryUsed)(&cb)); + EXPECT("falling back leaves control free accounting stable", sizeof control - 10 * 48, P(nvnCommandBufferGetControlMemoryFree)(&cb)); + P(nvnCommandBufferAddControlMemory)(&cb, control, sizeof control); + P(nvnCommandBufferAddCommandMemory)(&cb, &pool, 0, sizeof memory); + P(nvnCommandBufferBeginRecording)(&cb); + P(nvnCommandBufferDrawElementsIndirect)(&cb, NVN_DRAW_PRIMITIVE_TRIANGLES, NVN_INDEX_TYPE_UNSIGNED_SHORT, 0x125000, 0x145000); + NVNcommandHandle indirect = P(nvnCommandBufferEndRecording)(&cb); + EXPECT("indirect draw does not enter control-memory token form", 1, !nvn_ctl_draw_target((const void *)(uintptr_t)indirect)); + EXPECT("indirect draw does not consume control grant", 0, P(nvnCommandBufferGetControlMemoryUsed)(&cb)); + // A queried offset at +4 cannot be silently shifted later: the host + // charges four actual alignment bytes, leaves the previous token + // intact, and refuses to convert this in-progress draw. + P(nvnCommandBufferAddCommandMemory)(&cb, &pool, 0, sizeof memory); + P(nvnCommandBufferAddControlMemory)(&cb, control, sizeof control); + P(nvnCommandBufferBeginRecording)(&cb); + P(nvnCommandBufferDrawElementsBaseVertex)(&cb, NVN_DRAW_PRIMITIVE_TRIANGLES, NVN_INDEX_TYPE_UNSIGNED_SHORT, 6, 0x126000, 0); + NVNcommandHandle first = P(nvnCommandBufferEndRecording)(&cb); + P(nvnCommandBufferBeginRecording)(&cb); + P(nvnCommandBufferDrawElementsBaseVertex)(&cb, NVN_DRAW_PRIMITIVE_TRIANGLES, NVN_INDEX_TYPE_UNSIGNED_SHORT, 7, 0x126100, 0); + EXPECT("mid-record query after a +4 token includes the physical alignment pad", 48, P(nvnCommandBufferGetCommandMemoryUsed)(&cb)); + NVNcommandHandle queried = P(nvnCommandBufferEndRecording)(&cb); + EXPECT("mid-record query keeps previous 4-byte token intact", 1, + queried == (NVNcommandHandle)(memory + 8) && nvn_ctl_draw_target((const void *)(uintptr_t)first) != NULL && + nvn_ctl_draw_target((const void *)(uintptr_t)queried) == NULL); + EXPECT("mid-record query keeps its canonical 48-byte draw+END", 56, P(nvnCommandBufferGetCommandMemoryUsed)(&cb)); + + // CopyCommands materializes a standalone draw in the destination, + // whereas CallCommands stores the original command handle. + P(nvnCommandBufferAddCommandMemory)(&cb, &pool, 0, sizeof memory); + P(nvnCommandBufferAddControlMemory)(&cb, control, sizeof control); + P(nvnCommandBufferBeginRecording)(&cb); + P(nvnCommandBufferDrawElementsBaseVertex)(&cb, NVN_DRAW_PRIMITIVE_TRIANGLES, NVN_INDEX_TYPE_UNSIGNED_SHORT, 8, 0x127000, 0); + NVNcommandHandle cached = P(nvnCommandBufferEndRecording)(&cb); + const uint8_t *snapshot = nvn_ctl_draw_target((const void *)(uintptr_t)cached); + EXPECT("4-byte cached handle resolves before memory regrant", 1, snapshot != NULL); + NVNcommandBuffer copy; + P(nvnCommandBufferInitialize)(©, &dev); + P(nvnCommandBufferAddCommandMemory)(©, &pool, 2048, 256); + P(nvnCommandBufferBeginRecording)(©); + P(nvnCommandBufferCopyCommands)(©, 1, &cached); + NVNcommandHandle copy_handle = P(nvnCommandBufferEndRecording)(©); + op_hdr copied_header; memcpy(&copied_header, (const void *)(uintptr_t)copy_handle, 8); + EXPECT("CopyCommands takes a charged 40-byte independent DRAW_IDX plus END", 1, + copied_header.op == OP_DRAW_IDX && copied_header.size == 40 && P(nvnCommandBufferGetCommandMemoryUsed)(©) == 48); + EXPECT("CopyCommands preserves draw bytes", 1, snapshot && !memcmp((const void *)(uintptr_t)copy_handle, snapshot, 40)); + P(nvnCommandBufferAddCommandMemory)(©, &pool, 2560, 256); + P(nvnCommandBufferBeginRecording)(©); + P(nvnCommandBufferCallCommands)(©, 1, &cached); + NVNcommandHandle call_handle = P(nvnCommandBufferEndRecording)(©); + op_hdr called_header; memcpy(&called_header, (const void *)(uintptr_t)call_handle, 8); + uintptr_t called_target = 0; memcpy(&called_target, (const uint8_t *)(uintptr_t)call_handle + 8, 8); + EXPECT("CallCommands retains the source handle in a 16-byte OP_CALL", 1, + called_header.op == OP_CALL && called_header.size == 16 && called_target == (uintptr_t)cached); + // The guest is free to reuse control memory independently of a cached + // command handle. Replay still sees the original immutable snapshot. + memset(control, 0xcc, 48); + P(nvnCommandBufferAddControlMemory)(&cb, control, sizeof control); + EXPECT("control regrant cannot invalidate a previously returned draw", 1, + snapshot && nvn_ctl_draw_target((const void *)(uintptr_t)cached) == snapshot && + ((const op_hdr *)snapshot)->op == OP_DRAW_IDX); + uint32_t old_token; memcpy(&old_token, (const void *)(uintptr_t)cached, 4); + uint8_t moved_token[4]; memcpy(moved_token, &old_token, 4); + EXPECT("a raw token at a different address cannot alias its source", 1, !nvn_ctl_draw_target(moved_token)); + // The +1 offset forces a partial overlap of the token, exercising + // generation-aware retirement without altering its four stored bytes. + P(nvnCommandBufferAddCommandMemory)(&cb, &pool, 1, 64); + uint32_t token_after; memcpy(&token_after, (const void *)(uintptr_t)cached, 4); + EXPECT("partial overlapping command regrant retires a cached handle", 1, + token_after == old_token && nvn_ctl_draw_target((const void *)(uintptr_t)cached) == NULL); + P(nvnCommandBufferAddCommandMemory)(&cb, &pool, 0, sizeof memory); + P(nvnCommandBufferBeginRecording)(&cb); + P(nvnCommandBufferDrawElementsBaseVertex)(&cb, NVN_DRAW_PRIMITIVE_TRIANGLES, NVN_INDEX_TYPE_UNSIGNED_SHORT, 9, 0x128000, 0); + NVNcommandHandle reborn = P(nvnCommandBufferEndRecording)(&cb); + uint32_t new_token; memcpy(&new_token, (const void *)(uintptr_t)reborn, 4); + EXPECT("reused handle address gets a fresh generation", 1, reborn == cached && new_token != old_token && nvn_ctl_draw_target((const void *)(uintptr_t)reborn) != NULL); + P(nvnCommandBufferFinalize)(©); + unsetenv("DM_NVN_DRAW_CTL"); + nvn_draw_ctl_init(); // Restore the default-off profile for remaining tests. + P(nvnCommandBufferFinalize)(&cb); P(nvnMemoryPoolFinalize)(&pool); + } + // 5a. Command memory per recording, as GetCommandMemoryUsed reports it (games size prerecorded command blocks by it: Breath of the Wild records one scratch draw per mesh): // no BEGIN record, a plain indexed draw without the indirect / multi-draw fields, and the handle is the first record; an indirect draw keeps its address { diff --git a/docs/agents/symptoms.md b/docs/agents/symptoms.md index 782bd2af5b..e47edf83c2 100644 --- a/docs/agents/symptoms.md +++ b/docs/agents/symptoms.md @@ -505,6 +505,7 @@ One line per symptom in the [subsystem references](../reference/): what you see - `nvn_replay` stops with `program #N (a key …) stage S: the translated shader's bindings or inputs differ from the ones the records were captured against`, and `regress.py replay` reports every game's capture as one the candidate cannot replay, while the shaders' reflection (`_b.json`) is the same in both chains apart from the chain field. → [A capture refused after a change to the reflection structs](../reference/nvn-objects.md#a-capture-refused-after-a-change-to-the-reflection-structs) - `texture object no longer valid`, followed by `CRASH sig=10` in `nvn_sparse_pool` through `sparse_gpu_wrote` and `attach`. In Rhythm Heaven Groove's frog stage, the supposed texture contained a texture handle followed by an `OP_DRAW_IDX` record. → [Bound render targets outlive command recordings](../reference/nvn-objects.md#bound-render-targets-outlive-command-recordings) - `[RED] NVN unimplemented function called: nvnCommandBufferGetControlMemoryFree` (or `…GetControlMemorySize`) once, after the game gives a command buffer control memory. → [Control memory capacity queries](../reference/nvn-objects.md#control-memory-capacity-queries) +- a BFRES model on the original resource table runs out at a command-recording heap boundary (`BufferInitialize: pool has no storage`, null-pool crash); the host uses 48 command bytes for each solitary indexed draw. → [Four-byte direct indexed-draw command tickets](../reference/nvn-objects.md#four-byte-direct-indexed-draw-command-tickets) - `FATAL NVN command memory: the game's pool is empty…`. → [Host-lent command memory segments](../reference/nvn-objects.md#host-lent-command-memory-segments) - frames stop after about 15 s; a game thread spins in its command-memory callback (a ring allocator at `main+0x9cc120` waiting for fences that have all signalled). → [What `GetCommandMemoryUsed` counts](../reference/nvn-objects.md#what-getcommandmemoryused-counts) - `CRASH sig=10` in `mtl_exec` after minutes of play, the fault address equal to `x19` and shaped like `0x1000000003`. → [Prerecorded lists in slots of their measured size](../reference/nvn-objects.md#prerecorded-lists-in-slots-of-their-measured-size) diff --git a/docs/reference/nvn-objects.md b/docs/reference/nvn-objects.md index c07df630a3..1bc0f9ca1d 100644 --- a/docs/reference/nvn-objects.md +++ b/docs/reference/nvn-objects.md @@ -231,10 +231,18 @@ The game may also write into an NVN object directly, without an NVN call, throug ### Control memory capacity queries - **Symptom:** `[RED] NVN unimplemented function called: nvnCommandBufferGetControlMemoryFree` (or `…GetControlMemorySize`) once, after the game gives a command buffer control memory. -- **Cause:** `AddControlMemory` was a no-op, and the size and free getters reached the unimplemented stub, which returns 0, so a game that checks the free space sees none left. The host writes its records only to command memory and uses no byte of the game's control memory, so in NVN's terms (size = used + free) the whole block stays free. -- **Fix:** `cmd_t` records the size of the last `AddControlMemory` block and returns it from both `GetControlMemoryFree` and `GetControlMemorySize`; `GetControlMemoryUsed` stays 0. `t_nvn` checks the values before any block, after one, after a recording and after a smaller block replaces it. +- **Cause:** `AddControlMemory` was a no-op, and the size and free getters reached the unimplemented stub, which returns 0, so a game that checks the free space sees none left. With the default recorder, no control-memory bytes are consumed. +- **Fix:** `cmd_t` records the size of the last `AddControlMemory` block and returns it from both `GetControlMemoryFree` and `GetControlMemorySize`; `GetControlMemoryUsed` stays 0 for the default recorder. An opt-in [direct indexed-draw token](#four-byte-direct-indexed-draw-command-tickets) instead writes and charges actual control-memory bytes. `t_nvn` checks the values before any block, after one, after a recording and after a smaller block replaces it. - **Found on:** South Park: The Fractured But Whole 1.05, whose port is [PR #662](https://github.com/seedds/nxnative/pull/662) (not yet in the games table). With the getters implemented it reached the title, menu, a save and character customization on the Mac; with them returning 0 it also progressed, but submitted commands at a different rate. +### Four-byte direct indexed-draw command tickets + +- **Symptom:** model loading fails at an allocation boundary despite a complete BFRES file, then `BufferInitialize: pool has no storage` or a null-pool crash. A model's native NVN command budget is smaller than a host recording of one 40-byte direct indexed draw followed by an 8-byte END. +- **Cause:** the game sums `GetCommandMemoryUsed` for each model scratch recording and allocates the total inside the resource heap. A host-only 48-byte recording can push a page-aligned request past the original resource budget. The caller cannot safely use a 32-bit relative pointer to a full draw stored in NVN control memory: the separate command and control grants can be more than 4 GiB apart. +- **Fix (opt-in):** `nvn_draw_ctl=on` or `DM_NVN_DRAW_CTL=1` stores the **actual** 40-byte `OP_DRAW_IDX` and 8-byte `OP_END` in guest control memory, charges that block to `GetControlMemoryUsed`, and writes a 4-byte tagged generation into guest command memory. `GetCommandMemoryUsed` counts the four bytes. A live command-handle-address registry resolves the generation to an immutable snapshot during Metal replay and NVN capture. Overlapping `AddCommandMemory` grants retire old entries; re-granting control memory does not invalidate a published handle. Submission readers pin snapshots until replay and capture finish, and stale or relocated tokens are rejected before an 8-byte record-header read. A queried in-progress offset, mixed operations, indirect draw, or insufficient control memory retains the ordinary canonical recording. No title enables this automatically. +- **Found on:** Super Mario Bros. Wonder, Nintendo Switch 2 Edition + Meetup in Bellabel Park, Program1 `040015100B514001`: on original RESTBL, `PlayerPeachElpWM.bfres` measured `x20=0x3000`, `x28=0x1008` (15×8-byte earlier tickets), failed at `x6=0x400c`; with 4-byte tickets it measured `x28=0xfcc`, `x6=0x3fd0` and rendered its WorldMap model and a course. These are runtime observations with the opt-in profile; other games retain their default recorder. +- **Check:** `t_nvn` covers 4-byte footprints, original 48-byte control storage, queried offsets, copy/call semantics, control-memory regrants, partially overlapping command-memory regrants, copied tokens at another address and handle-generation reuse. `t_capture` covers the canonical `OP_JUMP` representation and relocation. The live GPU capture/replay test exercises the unchanged general capture path; a real-game ticket-containing capture remains an additional validation gate. + ### Host-lent command memory segments - **Symptom:** `FATAL NVN command memory: the game's pool is empty…`. From e18e83a47b492a7293f1d81d0fd485d1248b9e9f Mon Sep 17 00:00:00 2001 From: Ishtiaq Ul Haq Syed Date: Sat, 7 Nov 2026 09:43:19 -0600 Subject: [PATCH 2/8] docs(nvn): synchronize control ticket symptom and env switch indexes --- docs/agents/symptoms.md | 2 +- docs/env_switches.tsv | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/agents/symptoms.md b/docs/agents/symptoms.md index e47edf83c2..0fb8a30634 100644 --- a/docs/agents/symptoms.md +++ b/docs/agents/symptoms.md @@ -505,7 +505,7 @@ One line per symptom in the [subsystem references](../reference/): what you see - `nvn_replay` stops with `program #N (a key …) stage S: the translated shader's bindings or inputs differ from the ones the records were captured against`, and `regress.py replay` reports every game's capture as one the candidate cannot replay, while the shaders' reflection (`_b.json`) is the same in both chains apart from the chain field. → [A capture refused after a change to the reflection structs](../reference/nvn-objects.md#a-capture-refused-after-a-change-to-the-reflection-structs) - `texture object no longer valid`, followed by `CRASH sig=10` in `nvn_sparse_pool` through `sparse_gpu_wrote` and `attach`. In Rhythm Heaven Groove's frog stage, the supposed texture contained a texture handle followed by an `OP_DRAW_IDX` record. → [Bound render targets outlive command recordings](../reference/nvn-objects.md#bound-render-targets-outlive-command-recordings) - `[RED] NVN unimplemented function called: nvnCommandBufferGetControlMemoryFree` (or `…GetControlMemorySize`) once, after the game gives a command buffer control memory. → [Control memory capacity queries](../reference/nvn-objects.md#control-memory-capacity-queries) -- a BFRES model on the original resource table runs out at a command-recording heap boundary (`BufferInitialize: pool has no storage`, null-pool crash); the host uses 48 command bytes for each solitary indexed draw. → [Four-byte direct indexed-draw command tickets](../reference/nvn-objects.md#four-byte-direct-indexed-draw-command-tickets) +- model loading fails at an allocation boundary despite a complete BFRES file, then `BufferInitialize: pool has no storage` or a null-pool crash. A model's native NVN command budget is smaller than a host recording of one 40-byte direct indexed draw followed by an 8-byte END. → [Four-byte direct indexed-draw command tickets](../reference/nvn-objects.md#four-byte-direct-indexed-draw-command-tickets) - `FATAL NVN command memory: the game's pool is empty…`. → [Host-lent command memory segments](../reference/nvn-objects.md#host-lent-command-memory-segments) - frames stop after about 15 s; a game thread spins in its command-memory callback (a ring allocator at `main+0x9cc120` waiting for fences that have all signalled). → [What `GetCommandMemoryUsed` counts](../reference/nvn-objects.md#what-getcommandmemoryused-counts) - `CRASH sig=10` in `mtl_exec` after minutes of play, the fault address equal to `x19` and shaped like `0x1000000003`. → [Prerecorded lists in slots of their measured size](../reference/nvn-objects.md#prerecorded-lists-in-slots-of-their-measured-size) diff --git a/docs/env_switches.tsv b/docs/env_switches.tsv index 3b1f01cf0b..2b1fc8af0c 100644 --- a/docs/env_switches.tsv +++ b/docs/env_switches.tsv @@ -132,6 +132,7 @@ DM_NVN_DEPTHOVR diagnostic `=,…`: those draws comp DM_NVN_DIRECT_UPLOAD switch 0 disables bounded direct RGBA16F array uploads and keeps staged copies; on by default for supported non-sparse textures. DM_NVN_DIRTYCHECK diagnostic `=1` fully hashes pool content on every use to find changes the dirty-page tracking missed DM_NVN_DISPATCH_OWN switch `=1` gives every compute dispatch a compute encoder of its own with full barriers, as before (by default dispatches share the copy encoder); `DM_NVN_PASSGPU` does the same +DM_NVN_DRAW_CTL switch `=1` enables four-byte terminal direct indexed-draw tickets with 48 actual guest control-memory bytes (profile default `nvn_draw_ctl`, normally off); `=0` disables them DM_NVN_DUAL_WANT path `=`: appends the key of each pixel shader a pipeline blends with its output 1 as the second source, while it has no dual-source entry point, to that file, once per key (the profile's `dual_want.tsv`) DM_NVN_ENC_LABEL diagnostic `=1` labels every render encoder `p x d` for Instruments traces DM_NVN_ENC_THREAD player `1` runs the replay's Metal encoding on a thread of its own while the submit thread goes on translating; on by default on iOS (`0` turns it off), off on macOS From bf7bd4e841a36f5dd5e97a1ed73a2282436937c0 Mon Sep 17 00:00:00 2001 From: seedds Date: Sat, 7 Nov 2026 09:49:34 -0600 Subject: [PATCH 3/8] test(nvn): force canonical recorder for legacy assertions --- dm/gfx/tests/t_nvn.c | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/dm/gfx/tests/t_nvn.c b/dm/gfx/tests/t_nvn.c index 694c7c1105..54208a3725 100644 --- a/dm/gfx/tests/t_nvn.c +++ b/dm/gfx/tests/t_nvn.c @@ -513,8 +513,10 @@ int main(void) { uint32_t new_token; memcpy(&new_token, (const void *)(uintptr_t)reborn, 4); EXPECT("reused handle address gets a fresh generation", 1, reborn == cached && new_token != old_token && nvn_ctl_draw_target((const void *)(uintptr_t)reborn) != NULL); P(nvnCommandBufferFinalize)(©); - unsetenv("DM_NVN_DRAW_CTL"); - nvn_draw_ctl_init(); // Restore the default-off profile for remaining tests. + // The following legacy recorder checks require canonical DRAW_IDX+END; + // explicitly disable tickets even when this game's profile opts in. + setenv("DM_NVN_DRAW_CTL", "0", 1); + nvn_draw_ctl_init(); P(nvnCommandBufferFinalize)(&cb); P(nvnMemoryPoolFinalize)(&pool); } From c31f1709ed2a09b818ac0586920d104e11f30609 Mon Sep 17 00:00:00 2001 From: seedds Date: Sat, 7 Nov 2026 10:46:03 -0600 Subject: [PATCH 4/8] test(nvn): guard ticket snapshot during cross-buffer regrant (cherry picked from commit 82c2fa6b14e5d1f6f45f0d298ead980f5a578425) --- dm/gfx/tests/t_nvn.c | 58 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/dm/gfx/tests/t_nvn.c b/dm/gfx/tests/t_nvn.c index d698b34fa4..a09db0102d 100644 --- a/dm/gfx/tests/t_nvn.c +++ b/dm/gfx/tests/t_nvn.c @@ -12,6 +12,7 @@ void nvn_present_config(int hz, int display_mode); #include #include #include +#include uint64_t now_ns(void) { struct timespec t; clock_gettime(CLOCK_MONOTONIC, &t); return (uint64_t)t.tv_sec * 1000000000ull + t.tv_nsec; } static int g_fail, g_n; @@ -41,6 +42,62 @@ static NVNqueue *g_sq; static NVNcommandHandle g_sh; static NVNT_nvnQueueSubmitC static void *susp_thr(void *a) { (void)a; pthread_mutex_lock(&g_smu); atomic_store(&g_sstarted, 1); pthread_cond_broadcast(&g_scv); pthread_mutex_unlock(&g_smu); g_sub(g_sq, 1, &g_sh); atomic_store(&g_sdone, 1); return NULL; } static int wait_started(void) { struct timespec dl; clock_gettime(CLOCK_REALTIME, &dl); dl.tv_sec += 10; pthread_mutex_lock(&g_smu); while (!atomic_load(&g_sstarted) && pthread_cond_timedwait(&g_scv, &g_smu, &dl) == 0) {} int ok = atomic_load(&g_sstarted); pthread_mutex_unlock(&g_smu); return ok; } +// CPU-only focused lifetime test. A different command buffer may regrant the +// guest bytes of a cached ticket while submit/capture is still reading its +// snapshot. The writer must wait until that complete CPU walk releases it. +typedef struct { NVNcommandBuffer cb; pool_t *pool; _Atomic int entered, done; } ticket_race_t; +static void *ticket_regrant_thread(void *arg) { + ticket_race_t *t = arg; + atomic_store(&t->entered, 1); + h_nvnCommandBufferAddCommandMemory(&t->cb, (NVNmemoryPool *)t->pool, 0, 128); + atomic_store(&t->done, 1); + return NULL; +} +static int ticket_lifetime_race(void) { + _Alignas(16) uint8_t command[256] = {0}, control[64] = {0}; + pool_t pool = {0}; pool.mem = command; + setenv("DM_NVN_DRAW_CTL", "1", 1); nvn_draw_ctl_init(); + NVNcommandBuffer source, copy; + h_nvnCommandBufferInitialize(&source, NULL); + h_nvnCommandBufferAddControlMemory(&source, control, sizeof control); + h_nvnCommandBufferAddCommandMemory(&source, (NVNmemoryPool *)&pool, 0, 128); + h_nvnCommandBufferBeginRecording(&source); + h_nvnCommandBufferDrawElementsBaseVertex(&source, NVN_DRAW_PRIMITIVE_TRIANGLES, NVN_INDEX_TYPE_UNSIGNED_SHORT, 12, 0x123400, 2); + NVNcommandHandle ticket = h_nvnCommandBufferEndRecording(&source); + EXPECT("race: source is a four-byte ticket", 4, h_nvnCommandBufferGetCommandMemoryUsed(&source)); + h_nvnCommandBufferInitialize(©, NULL); + h_nvnCommandBufferAddCommandMemory(©, (NVNmemoryPool *)&pool, 128, 128); + h_nvnCommandBufferBeginRecording(©); + h_nvnCommandBufferCopyCommands(©, 1, &ticket); + NVNcommandHandle copied = h_nvnCommandBufferEndRecording(©); + op_hdr copied_hdr; memcpy(&copied_hdr, (const void *)(uintptr_t)copied, sizeof copied_hdr); + EXPECT("race: CopyCommands is an independent draw", 1, copied_hdr.op == OP_DRAW_IDX && copied_hdr.size == 40); + h_nvnCommandBufferFinalize(&source); // A cached handle outlives its recorder. + ctl_ticket_read_begin(); // What submit/capture/preindex hold across their CPU walk. + const uint8_t *snapshot = nvn_ctl_draw_target((const void *)(uintptr_t)ticket); + EXPECT("race: ticket survives source finalize", 1, snapshot != NULL); + ticket_race_t writer = {.pool = &pool}; + h_nvnCommandBufferInitialize(&writer.cb, NULL); + pthread_t thread; + int started = pthread_create(&thread, NULL, ticket_regrant_thread, &writer) == 0; + EXPECT("race: concurrent command regrant starts", 1, started); + if (started) { + for (int i = 0; i < 1000000 && !atomic_load(&writer.entered); i++) sched_yield(); + EXPECT("race: writer reached overlapping regrant", 1, atomic_load(&writer.entered)); + usleep(10000); + EXPECT("race: regrant waits for active reader", 0, atomic_load(&writer.done)); + EXPECT("race: snapshot remains readable during submit", 1, snapshot && ((const op_hdr *)snapshot)->op == OP_DRAW_IDX); + } + ctl_ticket_read_end(); + if (started) pthread_join(thread, NULL); + EXPECT("race: overlapping regrant completes", 1, started && atomic_load(&writer.done)); + EXPECT("race: old ticket retired after reader finishes", 1, !nvn_ctl_draw_target((const void *)(uintptr_t)ticket)); + op_hdr after; memcpy(&after, (const void *)(uintptr_t)copied, sizeof after); + EXPECT("race: copied draw survives source regrant", 1, after.op == OP_DRAW_IDX && after.size == 40); + h_nvnCommandBufferFinalize(©); + return g_fail ? 1 : 0; +} + static size_t tex_size3(int target, int fmt, int w, int h, int depth) { // a layered or 3D texture's storage size NVNtextureBuilder tb; P(nvnTextureBuilderSetDevice)(&tb, &dev); P(nvnTextureBuilderSetDefaults)(&tb); P(nvnTextureBuilderSetTarget)(&tb, target); @@ -57,6 +114,7 @@ static size_t tex_size(int fmt, int w, int h, int flags, size_t *align) { } int main(void) { + if (getenv("DM_TEST_CTL_TICKET_RACE")) return ticket_lifetime_race(); // headless; never opens Metal or submits GPU work setenv("DM_NVN_COUNT", "1", 1); // the call-count checks below need the counting trampolines (off by default) // Engine-native limiters have no FPS state page. Their launcher cap must // still pace presents and select the display-link rate in display mode. From 63bf6572221a54291a6a8b2305d4f0920be822cb Mon Sep 17 00:00:00 2001 From: Ishtiaq Ul Haq Syed Date: Sat, 7 Nov 2026 11:05:37 -0600 Subject: [PATCH 5/8] test(nvn): keep ticket registry bounded across 1024 regrants (cherry picked from commit 9e21a6fb486d36c1601257569e7c2306a464fc49) --- dm/gfx/tests/t_nvn.c | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/dm/gfx/tests/t_nvn.c b/dm/gfx/tests/t_nvn.c index a09db0102d..39bb51d094 100644 --- a/dm/gfx/tests/t_nvn.c +++ b/dm/gfx/tests/t_nvn.c @@ -98,6 +98,43 @@ static int ticket_lifetime_race(void) { return g_fail ? 1 : 0; } +// Reusing one guest command grant must retire each previous immutable snapshot +// and never revalidate its generation. A long-lived model-cache workload may +// cycle the same 4 bytes thousands of times without growing the live registry. +static int ticket_registry_reuse(void) { + _Alignas(16) uint8_t command[128] = {0}, control[64] = {0}; + pool_t pool = {0}; pool.mem = command; + setenv("DM_NVN_DRAW_CTL", "1", 1); nvn_draw_ctl_init(); + NVNcommandBuffer cb; + h_nvnCommandBufferInitialize(&cb, NULL); + uint32_t previous_id = 0; + int healthy = 1, stale_rejected = 1, stable_size = 1, ids_advance = 1; + for (int i = 0; i < 1024; i++) { + h_nvnCommandBufferAddCommandMemory(&cb, (NVNmemoryPool *)&pool, 0, sizeof command); + h_nvnCommandBufferAddControlMemory(&cb, control, sizeof control); + if (previous_id && nvn_ctl_draw_resolve(command, previous_id)) stale_rejected = 0; + h_nvnCommandBufferBeginRecording(&cb); + h_nvnCommandBufferDrawElementsBaseVertex(&cb, NVN_DRAW_PRIMITIVE_TRIANGLES, + NVN_INDEX_TYPE_UNSIGNED_SHORT, 12, 0x123400, 2); + NVNcommandHandle ticket = h_nvnCommandBufferEndRecording(&cb); + uint32_t value; memcpy(&value, (const void *)(uintptr_t)ticket, sizeof value); + uint32_t id = value & ~NVN_CTL_DRAW_TAG4; + if (!(value & NVN_CTL_DRAW_TAG4) || !nvn_ctl_draw_resolve((void *)(uintptr_t)ticket, id)) healthy = 0; + if (previous_id && id <= previous_id) ids_advance = 0; + if (g_ctl_ticket_n != 1) stable_size = 0; + previous_id = id; + } + EXPECT("reuse: 1024 four-byte draws recorded and resolved", 1, healthy); + EXPECT("reuse: retired generation never resolves", 1, stale_rejected); + EXPECT("reuse: live ticket registry stays at one entry", 1, stable_size); + EXPECT("reuse: ticket generations increase on each regrant", 1, ids_advance); + h_nvnCommandBufferAddCommandMemory(&cb, (NVNmemoryPool *)&pool, 0, sizeof command); + EXPECT("reuse: final regrant retires ticket", 1, + !nvn_ctl_draw_resolve(command, previous_id) && g_ctl_ticket_n == 0); + h_nvnCommandBufferFinalize(&cb); + return g_fail ? 1 : 0; +} + static size_t tex_size3(int target, int fmt, int w, int h, int depth) { // a layered or 3D texture's storage size NVNtextureBuilder tb; P(nvnTextureBuilderSetDevice)(&tb, &dev); P(nvnTextureBuilderSetDefaults)(&tb); P(nvnTextureBuilderSetTarget)(&tb, target); @@ -115,6 +152,7 @@ static size_t tex_size(int fmt, int w, int h, int flags, size_t *align) { int main(void) { if (getenv("DM_TEST_CTL_TICKET_RACE")) return ticket_lifetime_race(); // headless; never opens Metal or submits GPU work + if (getenv("DM_TEST_CTL_TICKET_REUSE")) return ticket_registry_reuse(); // headless: bounded 1024-generation lifetime test setenv("DM_NVN_COUNT", "1", 1); // the call-count checks below need the counting trampolines (off by default) // Engine-native limiters have no FPS state page. Their launcher cap must // still pace presents and select the display-link rate in display mode. From 4b649f9f5899ffe43c64bfc8a5a43460c0fc68b9 Mon Sep 17 00:00:00 2001 From: Ishtiaq Ul Haq Syed Date: Sat, 7 Nov 2026 11:29:19 -0600 Subject: [PATCH 6/8] fix(nvn): retire tickets on small guest command regrants --- dm/gfx/nvn/hle_nvn.c | 12 +++++++++++- dm/gfx/tests/t_nvn.c | 31 +++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/dm/gfx/nvn/hle_nvn.c b/dm/gfx/nvn/hle_nvn.c index 455a4c225c..495588c80c 100644 --- a/dm/gfx/nvn/hle_nvn.c +++ b/dm/gfx/nvn/hle_nvn.c @@ -1068,7 +1068,17 @@ const uint8_t *nvn_ctl_draw_resolve(const void *handle, uint32_t id) { } NVN(nvnCommandBufferAddCommandMemory, void, NVNcommandBuffer *c, const NVNmemoryPool *p, ptrdiff_t off, size_t n) { const pool_t *pl = (const pool_t *)p; - if (!pl || n < 64) { if (cs_on()) { pthread_mutex_lock(&g_cs_mu); g_cs.hostseg++; pthread_mutex_unlock(&g_cs_mu); } size_t sz = hostseg_size(CB(c), !pl, n); cmd_new_segment(CB(c), hostseg_take(CB(c), sz), sz); return; } + if (!pl || n < 64) { + // The host cannot fit its records in a small guest grant, but even + // when lending host memory the guest's re-granted bytes retire any + // overlapping four-byte handle. Null/zero grants retire nothing. + if (nvn_draw_ctl_on() && pl && pl->mem && n) + ctl_ticket_regrant_cmd((uintptr_t)(pl->mem + off), (uintptr_t)(pl->mem + off + n)); + if (cs_on()) { pthread_mutex_lock(&g_cs_mu); g_cs.hostseg++; pthread_mutex_unlock(&g_cs_mu); } + size_t sz = hostseg_size(CB(c), !pl, n); + cmd_new_segment(CB(c), hostseg_take(CB(c), sz), sz); + return; + } if (!pl->mem) { LOG("[RED] AddCommandMemory: pool has no CPU storage"); return; } hostseg_release((uintptr_t)(pl->mem + off), (uintptr_t)(pl->mem + off + n)); if (cs_on()) { pthread_mutex_lock(&g_cs_mu); g_cs.grants++; g_cs.grant_bytes += n; if (!g_cs.grant_min || n < g_cs.grant_min) g_cs.grant_min = n; if (n > g_cs.grant_max) g_cs.grant_max = n; pthread_mutex_unlock(&g_cs_mu); } diff --git a/dm/gfx/tests/t_nvn.c b/dm/gfx/tests/t_nvn.c index 39bb51d094..7654da2663 100644 --- a/dm/gfx/tests/t_nvn.c +++ b/dm/gfx/tests/t_nvn.c @@ -135,6 +135,36 @@ static int ticket_registry_reuse(void) { return g_fail ? 1 : 0; } +// A small but real guest grant still supersedes overlapping command handles, +// even if the host cannot fit its canonical records and lends a host segment. +// The guest grant's size must not exempt it from ticket retirement. +static int ticket_small_guest_regrant(void) { + _Alignas(16) uint8_t command[128] = {0}, control[64] = {0}; + pool_t pool = {0}; pool.mem = command; + setenv("DM_NVN_DRAW_CTL", "1", 1); nvn_draw_ctl_init(); + NVNcommandBuffer cb; + h_nvnCommandBufferInitialize(&cb, NULL); + h_nvnCommandBufferAddControlMemory(&cb, control, sizeof control); + h_nvnCommandBufferAddCommandMemory(&cb, (NVNmemoryPool *)&pool, 0, sizeof command); + h_nvnCommandBufferBeginRecording(&cb); + h_nvnCommandBufferDrawElementsBaseVertex(&cb, NVN_DRAW_PRIMITIVE_TRIANGLES, + NVN_INDEX_TYPE_UNSIGNED_SHORT, 12, 0x123400, 2); + NVNcommandHandle ticket = h_nvnCommandBufferEndRecording(&cb); + uint32_t token; memcpy(&token, (const void *)(uintptr_t)ticket, sizeof token); + uint32_t id = token & ~NVN_CTL_DRAW_TAG4; + EXPECT("small grant: initial 4-byte ticket resolves", 1, + (token & NVN_CTL_DRAW_TAG4) && nvn_ctl_draw_resolve(command, id) && g_ctl_ticket_n == 1); + // Both grants are real, non-null and 32 bytes long; only the second + // overlaps the four-byte token at command[0..4). + h_nvnCommandBufferAddCommandMemory(&cb, (NVNmemoryPool *)&pool, 64, 32); + EXPECT("small grant: nonoverlap retains ticket", 1, nvn_ctl_draw_resolve(command, id) != NULL); + h_nvnCommandBufferAddCommandMemory(&cb, (NVNmemoryPool *)&pool, 0, 32); + EXPECT("small grant: overlap retires stale ticket", 1, + nvn_ctl_draw_resolve(command, id) == NULL && g_ctl_ticket_n == 0); + h_nvnCommandBufferFinalize(&cb); + return g_fail ? 1 : 0; +} + static size_t tex_size3(int target, int fmt, int w, int h, int depth) { // a layered or 3D texture's storage size NVNtextureBuilder tb; P(nvnTextureBuilderSetDevice)(&tb, &dev); P(nvnTextureBuilderSetDefaults)(&tb); P(nvnTextureBuilderSetTarget)(&tb, target); @@ -153,6 +183,7 @@ static size_t tex_size(int fmt, int w, int h, int flags, size_t *align) { int main(void) { if (getenv("DM_TEST_CTL_TICKET_RACE")) return ticket_lifetime_race(); // headless; never opens Metal or submits GPU work if (getenv("DM_TEST_CTL_TICKET_REUSE")) return ticket_registry_reuse(); // headless: bounded 1024-generation lifetime test + if (getenv("DM_TEST_CTL_TICKET_SMALL_GRANT")) return ticket_small_guest_regrant(); // headless: tiny non-null guest grants setenv("DM_NVN_COUNT", "1", 1); // the call-count checks below need the counting trampolines (off by default) // Engine-native limiters have no FPS state page. Their launcher cap must // still pace presents and select the display-link rate in display mode. From 0b9c69c2b2226b1d68934fb9ea4e45451d1bd521 Mon Sep 17 00:00:00 2001 From: Ishtiaq Ul Haq Syed Date: Sat, 7 Nov 2026 12:58:12 -0600 Subject: [PATCH 7/8] docs(nvn): qualify small grants and record real Wonder capture --- dm/gfx/tests/t_nvn.c | 11 ++++++----- docs/reference/nvn-objects.md | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/dm/gfx/tests/t_nvn.c b/dm/gfx/tests/t_nvn.c index 7654da2663..d1bb7a6b70 100644 --- a/dm/gfx/tests/t_nvn.c +++ b/dm/gfx/tests/t_nvn.c @@ -135,9 +135,10 @@ static int ticket_registry_reuse(void) { return g_fail ? 1 : 0; } -// A small but real guest grant still supersedes overlapping command handles, -// even if the host cannot fit its canonical records and lends a host segment. -// The guest grant's size must not exempt it from ticket retirement. +// Defensive host fallback, NOT a hardware-valid guest allocation: +// NVN_DEVICE_INFO_COMMAND_BUFFER_MIN_COMMAND_SIZE is 512 for NVN and NVN2. +// A non-null 32-byte grant still enters our host-segment fallback; its old +// overlapping ticket must not remain resolvable if those guest bytes change. static int ticket_small_guest_regrant(void) { _Alignas(16) uint8_t command[128] = {0}, control[64] = {0}; pool_t pool = {0}; pool.mem = command; @@ -154,8 +155,8 @@ static int ticket_small_guest_regrant(void) { uint32_t id = token & ~NVN_CTL_DRAW_TAG4; EXPECT("small grant: initial 4-byte ticket resolves", 1, (token & NVN_CTL_DRAW_TAG4) && nvn_ctl_draw_resolve(command, id) && g_ctl_ticket_n == 1); - // Both grants are real, non-null and 32 bytes long; only the second - // overlaps the four-byte token at command[0..4). + // Both grants are non-null but *below the advertised native minimum*; + // only the second overlaps the four-byte token at command[0..4). h_nvnCommandBufferAddCommandMemory(&cb, (NVNmemoryPool *)&pool, 64, 32); EXPECT("small grant: nonoverlap retains ticket", 1, nvn_ctl_draw_resolve(command, id) != NULL); h_nvnCommandBufferAddCommandMemory(&cb, (NVNmemoryPool *)&pool, 0, 32); diff --git a/docs/reference/nvn-objects.md b/docs/reference/nvn-objects.md index f8c22a8aa5..45ffc8807d 100644 --- a/docs/reference/nvn-objects.md +++ b/docs/reference/nvn-objects.md @@ -249,7 +249,7 @@ The game may also write into an NVN object directly, without an NVN call, throug - **Cause:** the game sums `GetCommandMemoryUsed` for each model scratch recording and allocates the total inside the resource heap. A host-only 48-byte recording can push a page-aligned request past the original resource budget. The caller cannot safely use a 32-bit relative pointer to a full draw stored in NVN control memory: the separate command and control grants can be more than 4 GiB apart. - **Fix (opt-in):** `nvn_draw_ctl=on` or `DM_NVN_DRAW_CTL=1` stores the **actual** 40-byte `OP_DRAW_IDX` and 8-byte `OP_END` in guest control memory, charges that block to `GetControlMemoryUsed`, and writes a 4-byte tagged generation into guest command memory. `GetCommandMemoryUsed` counts the four bytes. A live command-handle-address registry resolves the generation to an immutable snapshot during Metal replay and NVN capture. Overlapping `AddCommandMemory` grants retire old entries; re-granting control memory does not invalidate a published handle. Submission readers pin snapshots until replay and capture finish, and stale or relocated tokens are rejected before an 8-byte record-header read. A queried in-progress offset, mixed operations, indirect draw, or insufficient control memory retains the ordinary canonical recording. No title enables this automatically. - **Found on:** Super Mario Bros. Wonder, Nintendo Switch 2 Edition + Meetup in Bellabel Park, Program1 `040015100B514001`: on original RESTBL, `PlayerPeachElpWM.bfres` measured `x20=0x3000`, `x28=0x1008` (15×8-byte earlier tickets), failed at `x6=0x400c`; with 4-byte tickets it measured `x28=0xfcc`, `x6=0x3fd0` and rendered its WorldMap model and a course. These are runtime observations with the opt-in profile; other games retain their default recorder. -- **Check:** `t_nvn` covers 4-byte footprints, original 48-byte control storage, queried offsets, copy/call semantics, control-memory regrants, partially overlapping command-memory regrants, copied tokens at another address and handle-generation reuse. `t_capture` covers the canonical `OP_JUMP` representation and relocation. The live GPU capture/replay test exercises the unchanged general capture path; a real-game ticket-containing capture remains an additional validation gate. +- **Check:** `t_nvn` covers 4-byte footprints, original 48-byte control storage, queried offsets, copy/call semantics, control-memory regrants, partially overlapping command-memory regrants, copied tokens at another address and handle-generation reuse. `t_capture` covers the canonical `OP_JUMP` representation and relocation. Cross-buffer pinned-reader/regrant and 1,024-generation registry checks passed in BotW and Luigi default-off profiles with explicit test opt-in. A defensive **32-byte non-null regrant** test also passes, but it is **below the advertised native 512-byte command-memory minimum** (`NVN_DEVICE_INFO_COMMAND_BUFFER_MIN_COMMAND_SIZE=0x15`; both NVN/NVN2 device tables), so it does not prove a hardware-valid guest grant bug. Separately, a complete **real Wonder Edition frame-2350 NVNCAP** from the original-RESTBL signed preview had 5 submits, 409 segments, 33 canonical four-byte ticket expansions and 0 unmapped pool addresses; **2/2 independent replays matched the 3840×2160 presented-image SHA-256**. The **69/334,308 differing post-frame memory blocks** mean this does not establish full-state equivalence, long-running handle safety or multi-queue GPU overlap; original-RESTBL course completion and Bellabel Park also remain manual validation gates. The signed preview predates the small-grant defensive change and was not rebuilt. ### Host-lent command memory segments From e0921bd418265c1e0766ea90ea810830c1d5c75f Mon Sep 17 00:00:00 2001 From: Ishtiaq Ul Haq Syed Date: Sat, 7 Nov 2026 14:05:53 -0600 Subject: [PATCH 8/8] docs(nvn): register synthetic four-byte ticket test switches --- docs/env_switches.tsv | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/env_switches.tsv b/docs/env_switches.tsv index 18572b5480..4398d64c7f 100644 --- a/docs/env_switches.tsv +++ b/docs/env_switches.tsv @@ -336,6 +336,9 @@ DM_TEST_ARCHIVE_CONSTANTS test Use optional function constants in the synthetic DM_TEST_ARCHIVE_MIXED test Mix a newly compiled shader function with compacted bases in the archive restart fixture DM_TEST_BORDER test Path to the native t_border executable that test_dmsh_border.py runs a rewritten shader through on the GPU (`make check_t_border` sets it) DM_TEST_BUILD test Build folder of `dm/host/tests` whose `t_svc_memory` `test_memory_profile.py` runs (default `build`; `make asan` sets `build/asan`) +DM_TEST_CTL_TICKET_RACE test `=1` runs only the headless NVN four-byte ticket cross-buffer immutable-reader/regrant and CopyCommands lifetime test in `t_nvn`; no GPU submission or gameplay +DM_TEST_CTL_TICKET_REUSE test `=1` runs only the headless NVN four-byte ticket 1,024-generation regrant/live-registry-bounds test in `t_nvn`; synthetic grant sizes are not evidence of native-valid minimum-sized allocations +DM_TEST_CTL_TICKET_SMALL_GRANT test `=1` runs only the headless four-byte ticket overlap-retirement fallback test in `t_nvn` with a non-null 32-byte guest grant, **below** NVN/NVN2's advertised 512-byte command-memory minimum; defensive coverage, not a conforming guest grant DM_TEST_FS_FAIL diagnostic `=write:[:]` makes the nth save write fail with that errno (default ENOSPC), which refuses the save transaction's commit DM_TEST_FS_FAILAT diagnostic `=[:][,…]` makes steps of the save transaction (`mountclone`, `reclone`, `guestname`, `renameback`) fail with ENOSPC DM_TEST_GEOMETRY test Path of the native `t_geometry` executable; `test_dmsh_geometry.py` runs its native geometry emitter checks only when it is set