Skip to content

NVN: account for four-byte direct indexed draws using control memory - #1609

Closed
ishtiaq156 wants to merge 12 commits into
seedds:mainfrom
ishtiaq156:fix/nvn-control-draw-ticket-w7-20261106
Closed

ishtiaq156 wants to merge 12 commits into
seedds:mainfrom
ishtiaq156:fix/nvn-control-draw-ticket-w7-20261106

Conversation

@ishtiaq156

@ishtiaq156 ishtiaq156 commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

Purpose

This is the generic, opt-in four-byte NVN command-ticket correction for BFRES model command-list budgets, separate from Edition profile #1563. It uses real command/control memory accounting, not a RESTBL increase or allocation bypass. This PR is Ready for review at 13d67c62, a normal two-parent merge of upstream eed7c429 into the existing #1609 branch. The merge preserves the new asynchronous queue scheduler and four-byte tickets together.

In the host recorder, a solitary direct indexed draw required 40 command bytes plus an eight-byte END, even where the native command stream can use a four-byte terminal command. A BFRES model then sizes guest model scratch from nvnCommandBufferGetCommandMemoryUsed, and that additional host representation cost can exceed a fixed guest resource budget.

General correction

  • With nvn_draw_ctl=on (or DM_NVN_DRAW_CTL=1), an eligible single direct indexed draw plus END leaves a real four-byte guest command ticket. Its complete canonical draw/END occupies 48 real bytes of guest-provided control memory. Both command and control GetUsed counts remain honest; unsupported recordings take the existing fallback, not an allocation bypass.
  • Ticket lookup uses the exact guest handle address plus a bounded generation registry and immutable 48-byte snapshots. Overlapping command-memory regrants retire affected tickets; stale handles fail before an eight-byte header read. With upstream deferred queues, submission pins the ticket lifetime through the entire CPU-side queue flatten, including nested OP_CALL and JUMP, and copies the canonical DRAW_IDX+END into queue-owned memory before async capture/preindex/replay/commit. The queued copy outlives any subsequent guest regrant; unrelated default-off titles are unchanged.
  • CopyCommands makes an independent canonical copy of a ticket draw. CallCommands retains OP_CALL semantics, and the existing relocation/capture format is reused. Command-buffer reuse and more than one cached handle are exercised by focused headless tests.
  • The feature is OFF by default for unrelated titles. The Edition opts in through its separate profile. No Switch 1 Wonder or other game profile was silently changed.

Measured original-RESTBL results

Model Previous guest scratch Four-byte-ticket scratch Original resource budget
NpcCommonerA 0x5424 0x4efc 0x5000
PlayerPeachElpWM 0x400c 0x3fd0 0x4000

Those original-RESTBL measurements explain the previously observed null-pool crash and demonstrate that these model requests fit without the earlier diagnostic +16 KiB resource-table override. They do not demonstrate full-course gameplay or all possible model workloads.

Validation and integration

  • New upstream queue compatibility regression: with an actual native-minimum 512-byte command grant, unmodified queued flatten misread a valid four-byte ticket as an eight-byte op header (reproduced RED, exit 134). The fixed canonical queue flatten passes 11 direct/nested/regrant assertions, the existing race, 1,024-generation reuse and defensive small-grant checks, and 101/101 capture-format checks. Two focused upstream async queue tests pass 38/38 each (descriptor lifetime; conditional rendering under a profile where render_cond=on). These are synthetic tests, not a new game capture or current-head signed preview.
  • Broad previously completed tests: 1,464/1,464 headless NVN/capture checks over the Edition, original Wonder, Breath of the Wild and Luigi Mansion 3; separate 150/150 unrelated-title default-off checks and 108/108 earlier merged ticket/capture checks. Previous ticket-lifetime tests cover overlap, regrant, CopyCommands and generation reuse.
  • Actual signed original-RESTBL Edition preview: World 1 frame 2350, five submits, 409 command segments, 33 canonical ticket expansions, 2/2 matching presented-frame SHA-256 digests across independent CPU/GPU replays. 69 of 334,308 post-frame memory blocks differ, so this is presented-image evidence only, not full-state equivalence.
  • The earlier credited Program0 candidate and fork-only full required stack passed 116/116 Mac/NSP and 11/11 documentation; older headless integration passed 399/399. Those sources predate the latest upstream asynchronous queue merge and are not claimed to test it. No Program0, Program1, SDK21, Edition or CI-baseline changes have been added to this NVN: account for four-byte direct indexed draws using control memory #1609 PR.
  • The source merge conflict in dm/gfx/nvn/hle_nvn.c is resolved; GitHub reported MERGEABLE at published 13d67c62. New-head CI is running. The independent upstream environment-registry omission for DM_NVN_BC_ALIAS is not introduced by this ticket fix; separate CI baseline work covers it. Ready for review does not imply all checks or manual acceptance gates are complete.

Limits and review gates

The device advertises a 512-byte minimum command-memory grant (device-info item 0x15). Our 32-byte and 128-byte synthetic grants are deliberately defensive, below-contract fixtures, not proof of native-valid command-memory behavior. Exhaustion of the 31-bit ticket generation space fails closed. Sustained multi-queue GPU overlap and direct host-lent handle origin/lifetime remain unproven.

The preserved signed Edition preview uses shader chain 844bf1a5ea8f, while newer source expects incompatible 3ee52af3ca18. I have not signed a current-head preview or regenerated shaders/PSOs. Original-RESTBL New Game progression, Sample Place Continue, complete course/Bellabel, audible sound/movies and physical rumble remain unverified. This PR is ready for source review; issue #1580 stays open for relevant CI/dependency review and manual validation. The real frame-2350 presented-image capture predates the new scheduler; the synthetic queue tests are not equivalent to a fresh full-game replay.

@github-actions github-actions Bot added nvn NVN on Metal and its tests (dm/gfx/nvn, dm/gfx/tests) docs Documentation labels Nov 7, 2026
@ishtiaq156
ishtiaq156 marked this pull request as ready for review November 8, 2026 04:39
Keep the upstream asynchronous queue/conditional-state scheduler and seedds#1609 four-byte command tickets together. Queue flatten resolves direct and nested four-byte handles under the ticket lifetime read lock, copying canonical DRAW_IDX+END into queue-owned memory before deferred replay or guest regrant. Unrelated titles remain default-off. Add a native-minimum 512-byte CPU-only queue snapshot red/green, with previous ticket-race, generation, defensive small-grant and capture checks preserved.

Validation: expected RED exit134 before queue adaptation; green focused ticket/capture exit0 (including t_capture 101 checks), scheduler queue lifetime 38/38 exit0, render_cond-on continuation 38/38 exit0. No game launch, ROM, RESTBL, shader or signed app changes. The separate DM_NVN_BC_ALIAS environment-registry defect is upstream baseline, unrelated to this PR.
@FifteenPen

Copy link
Copy Markdown
Contributor

A second game that needs this: Drag x Drive (040041601D83A000, v196608, SDK 20.5.6, Nintendo EPD's engine), profile in #1944.

Without the four-byte tickets it crashes 5 s after boot, on its first scene: the game initializes a buffer in no memory pool, then asks that pool for its flags.

[RED] BufferInitialize: pool has no storage (pool 0x0 flags 0 offset 0 size 0x170)
CRASH sig=11 addr=0x4 pc=h_nvnMemoryPoolGetFlags+0 lr=dm_bigstack_ret+0
  • One binary, main db206a4 + this PR with nvn_draw_ctl on in the game's profile: DM_NVN_DRAW_CTL=0 gives the crash above; with the tickets on it played 27 minutes with a pair of Joy-Con 2s.
  • Main e82888e: without this PR, the same crash. With this PR merged and DM_NVN_DRAW_CTL=1: boot, the title and about 6 minutes of play at the game's 30 fps, no red line.

M4 Pro, macOS 27.2.

FifteenPen added a commit to FifteenPen/nxnative that referenced this pull request Nov 9, 2026
…on the Mac with a pair of Joy-Con 2s at the game's own 30 fps: its profile, note and table row (it needs PR seedds#1609 to boot, and PR seedds#1921, the first commit of this branch)
seedds added a commit that referenced this pull request Nov 10, 2026
…: a recording's end, its links and its calls are in control memory, as in NVN, and what games record per mesh takes compact forms the queue expands at submit, so a typical mesh draw takes 88 bytes where the driver takes 132 and the host took 112 (PR #1609)

Engines that size a model's prerecorded commands by measuring scratch recordings against a fixed resource budget ran out of it with the host's records: Super Mario Bros. Wonder's Nintendo Switch 2 Edition (NpcCommonerA 0x5424 of 0x5000) and Drag x Drive (5 s after boot), BufferInitialize with no pool, then a crash in nvnMemoryPoolGetFlags. PR #1609 shrank one-draw recordings to 4-byte tickets behind a profile key; this fixes the cause for every game instead.

- Control memory: each recording's control list is 8-byte entries in the game's control memory (pieces of records, links to more control memory); the handle is the list. Command memory holds only records: no END, no JUMP, no CALL, nothing past what GetCommandMemoryUsed reported. GetControlMemoryUsed / Free count the entries; the game's out-of-control-memory callback is called (Cuphead refills from it), and the host lends blocks when it gives none.
- CallCommands copies the called list's pieces into the caller, as the driver copies a command set's GPU FIFO entries: Donkey Kong Bananza resets its control arena twice per command arena, and while a call pointed at the called list the caller replayed overwritten entries (FATAL invalid NVN command size in about one 100 s run in six). CopyCommands copies the records.
- Compact records (nvn_obj.h OPC_*): draws, viewports, stencil values, handle binds, uniform / storage / vertex buffer binds, vertex state, small states, barrier, primitive restart, scissor, with small arguments in the header's upper 24 bits. The queue's flatten (and nvn_rec_linear) expands each into the canonical records, so replay, capture and the replay walkers see no new op.
- Sized against the driver's own commands: dm/tools/nvn_native_sizes.py finds the NVN driver in a game's sdk (names, implementations, the command buffer layout from its getters) and runs each call in Unicorn. NVN1 (Fire Emblem: Three Houses, Cuphead, Minecraft Dungeons II) and NVN2 (Ocarina of Time, Donkey Kong Bananza) agree; five calls stay above the driver's at the 8-byte header's floor (depth-stencil state, BindTexture kept for Luigi's Mansion 3's in-place patch, storage buffer, blend color, scissor). Table in docs/reference/memory.md.

Checked: t_nvn 290/290 (51 calls recorded compact and canonical expand byte for byte; calls replay after the called list's control memory is overwritten, copies after both of the source's memories are; a list linked across control blocks), the gfx suite, lint. Minecraft Dungeons II on the Mac: the same picture in Squid Coast as before (its early stall at frame 120-140 happens on the old host too). Donkey Kong Bananza on the Mac (no shader libraries for this chain): three 100 s runs without a crash, command bytes per frame 127 -> 88 KB. Not run: the Wonder Edition and Drag x Drive (not on this Mac), iOS, Ocarina of Time (its pipeline preparation outlasts a run here).

Co-authored-by: ishtiaq156 <25985759+ishtiaq156@users.noreply.github.com>
@seedds

seedds commented Nov 10, 2026

Copy link
Copy Markdown
Owner

Thank you for the diagnosis and the measurements: they pointed at the cause. Landed on main as f02d047 with a different design, credited to you as co-author. Closing this PR in favour of it.

Why not the tickets: a native indexed draw can't fit in 4 bytes, so the tickets made one kind of recording smaller than the driver's to absorb overhead from elsewhere, behind a profile key. The overhead was the host writing a recording's END (8 bytes), its chunk links and its calls into command memory. NVN keeps all three in control memory.

What landed, for every game:

  • Control lists. Each recording's END, chunk links and calls are 8-byte entries in the game's control memory, and the handle points to that list. GetControlMemoryUsed reports them. CallCommands copies the called list's chunks, as the driver copies GPU FIFO entries. CopyCommands copies the records.
  • Compact records. What games record per mesh (draws, viewports, stencil values, handle binds, uniform, storage and vertex buffer binds, vertex state, small states) takes compact forms. The queue expands them back at submit, so replay and capture are unchanged.
  • Measured against the driver itself. The new dm/tools/nvn_native_sizes.py runs the game's own NVN driver from its sdk in an emulator. NVN1 and NVN2 agree on the sizes. A typical mesh draw is now 88 bytes, against 132 for the driver and 112 for the host before. Your two models: a draw recording dropped from 48 bytes to 24 (the driver's own is 48–72), and every recording loses its 8-byte END. The full table is in docs/reference/memory.md, under "Model heaps sized by the host's command record sizes".

I couldn't run the Wonder Switch 2 Edition or Drag x Drive here. @FifteenPen, could you try Drag x Drive on main without nvn_draw_ctl? It crashed 5 s after boot without this PR. A log either way would help.

@seedds seedds closed this Nov 10, 2026
dsi-hub pushed a commit to dsi-hub/nxnative that referenced this pull request Nov 11, 2026
…: a recording's end, its links and its calls are in control memory, as in NVN, and what games record per mesh takes compact forms the queue expands at submit, so a typical mesh draw takes 88 bytes where the driver takes 132 and the host took 112 (PR seedds#1609)

Engines that size a model's prerecorded commands by measuring scratch recordings against a fixed resource budget ran out of it with the host's records: Super Mario Bros. Wonder's Nintendo Switch 2 Edition (NpcCommonerA 0x5424 of 0x5000) and Drag x Drive (5 s after boot), BufferInitialize with no pool, then a crash in nvnMemoryPoolGetFlags. PR seedds#1609 shrank one-draw recordings to 4-byte tickets behind a profile key; this fixes the cause for every game instead.

- Control memory: each recording's control list is 8-byte entries in the game's control memory (pieces of records, links to more control memory); the handle is the list. Command memory holds only records: no END, no JUMP, no CALL, nothing past what GetCommandMemoryUsed reported. GetControlMemoryUsed / Free count the entries; the game's out-of-control-memory callback is called (Cuphead refills from it), and the host lends blocks when it gives none.
- CallCommands copies the called list's pieces into the caller, as the driver copies a command set's GPU FIFO entries: Donkey Kong Bananza resets its control arena twice per command arena, and while a call pointed at the called list the caller replayed overwritten entries (FATAL invalid NVN command size in about one 100 s run in six). CopyCommands copies the records.
- Compact records (nvn_obj.h OPC_*): draws, viewports, stencil values, handle binds, uniform / storage / vertex buffer binds, vertex state, small states, barrier, primitive restart, scissor, with small arguments in the header's upper 24 bits. The queue's flatten (and nvn_rec_linear) expands each into the canonical records, so replay, capture and the replay walkers see no new op.
- Sized against the driver's own commands: dm/tools/nvn_native_sizes.py finds the NVN driver in a game's sdk (names, implementations, the command buffer layout from its getters) and runs each call in Unicorn. NVN1 (Fire Emblem: Three Houses, Cuphead, Minecraft Dungeons II) and NVN2 (Ocarina of Time, Donkey Kong Bananza) agree; five calls stay above the driver's at the 8-byte header's floor (depth-stencil state, BindTexture kept for Luigi's Mansion 3's in-place patch, storage buffer, blend color, scissor). Table in docs/reference/memory.md.

Checked: t_nvn 290/290 (51 calls recorded compact and canonical expand byte for byte; calls replay after the called list's control memory is overwritten, copies after both of the source's memories are; a list linked across control blocks), the gfx suite, lint. Minecraft Dungeons II on the Mac: the same picture in Squid Coast as before (its early stall at frame 120-140 happens on the old host too). Donkey Kong Bananza on the Mac (no shader libraries for this chain): three 100 s runs without a crash, command bytes per frame 127 -> 88 KB. Not run: the Wonder Edition and Drag x Drive (not on this Mac), iOS, Ocarina of Time (its pipeline preparation outlasts a run here).

Co-authored-by: ishtiaq156 <25985759+ishtiaq156@users.noreply.github.com>
(cherry picked from commit f02d047)
@FifteenPen

Copy link
Copy Markdown
Contributor

Thank you for the diagnosis and the measurements: they pointed at the cause. Landed on main as f02d047 with a different design, credited to you as co-author. Closing this PR in favour of it.

Why not the tickets: a native indexed draw can't fit in 4 bytes, so the tickets made one kind of recording smaller than the driver's to absorb overhead from elsewhere, behind a profile key. The overhead was the host writing a recording's END (8 bytes), its chunk links and its calls into command memory. NVN keeps all three in control memory.

What landed, for every game:

  • Control lists. Each recording's END, chunk links and calls are 8-byte entries in the game's control memory, and the handle points to that list. GetControlMemoryUsed reports them. CallCommands copies the called list's chunks, as the driver copies GPU FIFO entries. CopyCommands copies the records.
  • Compact records. What games record per mesh (draws, viewports, stencil values, handle binds, uniform, storage and vertex buffer binds, vertex state, small states) takes compact forms. The queue expands them back at submit, so replay and capture are unchanged.
  • Measured against the driver itself. The new dm/tools/nvn_native_sizes.py runs the game's own NVN driver from its sdk in an emulator. NVN1 and NVN2 agree on the sizes. A typical mesh draw is now 88 bytes, against 132 for the driver and 112 for the host before. Your two models: a draw recording dropped from 48 bytes to 24 (the driver's own is 48–72), and every recording loses its 8-byte END. The full table is in docs/reference/memory.md, under "Model heaps sized by the host's command record sizes".

I couldn't run the Wonder Switch 2 Edition or Drag x Drive here. @FifteenPen, could you try Drag x Drive on main without nvn_draw_ctl? It crashed 5 s after boot without this PR. A log either way would help.

Just saw this, apologies. I'll run Drag x Drive on main without nvn_draw_ctl and send a log when I wake up.

seedds added a commit that referenced this pull request Nov 13, 2026
Advance Wars 1+2: Re-Boot Camp aborted when the enemy used a CO power, about a minute into the reporter's save, since f02d047 (PR #1609): FATAL NVN command list ... record {op 8, size 504} ... 0 bytes into its piece of 16, in nvn_queue_submit.

Cause, in the game's own code: a Unity rendering plugin in main (render-event handler main+0x1545800) rotates three command buffers, each with its own 1 MB block of one pool. For each render event it waits with nvnSyncWait(sync[i], infinite) on the fence it recorded at the end of buffer i's previous recording, re-adds block i from its start (AddCommandMemory at main+0x15458cc), records its work or nothing plus a FenceSync, and hands the handle to Unity, which calls it into the frame's list without submitting (main+0x96bb00). A frame normally has two events (traced on the Mac at the title). The reporter's list has four: B's 16-byte fence-only recording, C's work, A's fence, then B's work. The wait before B's second use is on a fence in the frame's own list, not submitted yet. The host answers at once, and the console must too: a wait that held there would stop the game. So B's block was recorded over while the list still called its first recording, and the 16-byte piece became the head of a 504-byte SET_RT. On the console the GPU runs whatever bytes are there. Before f02d047 a call was a CALL record to the handle, and the replay ran B's later recording to its END, twice, unnoticed. Since then the flatten checked each record against its piece and died.

Fix: nvn_piece_record (hle_nvn.c), used by nq_flatten, nvn_rec_linear and CopyCommands. A piece runs what its command memory holds at submit, as the GPU runs a command set's bytes: the whole records that fit in it. The first record that does not fit, or a header that is no record, ends the piece with a [RED] line (the first 8, then every 1,000th) instead of aborting. Where the GPU would run part of such a record, the host runs none of it; here that drops the head of a SET_RT that B's own piece runs whole later in the list.

Checked: t_nvn 314 checks, 0 failed, with three new ones in 5b''' (a called recording recorded over by a longer record: the piece is skipped, the caller's own record runs, the linear copy holds only that; by a record of the same size: it runs). test_docs, and lint_c.py tidy on hle_nvn.c and t_nvn.c (nothing above the baseline). Advance Wars on the Mac with the fixed host: boot, opening movie, title and main menu, no red line. Not run: the CO power frame itself (it needs the reporter's save), regress.py (only the path that aborted changes), iOS.

New reference entry: docs/reference/nvn-objects.md, "A called recording recorded over before its caller is submitted"; symptom index and the game's note updated.

Fixes #2437
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs Documentation nvn NVN on Metal and its tests (dm/gfx/nvn, dm/gfx/tests)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants