Skip to content

gfx-correctness (2026-10-26, macos1 batch): command-list lifetime, block-compressed render targets, depth/MSAA gaps, golden-frame harness

Branch claude/gfx-correctness-2026-10-26-macos1 (Mac). Honest status words: verified = ran it here, tested = a test passes, partial = some of it.

What changed

Area Result
Command-list lifetime (gfx/src/api_cmd.c, obj.h, api_memory.c) tested (t_cmdlife), SMO long repro: see "SMO". A recorded list now lives as long as its command memory: a registry owns one reference to every list; a list ends when the game finalizes the handle, when another list is recorded at the same command-memory address, or when the pool holding that memory is finalized (nvnMemoryPoolFinalize -> gfx_cmdrec_pool_freed). The command buffer that recorded it, idle time and a memory budget no longer end it (they made SMO call dropped lists: exploded geometry). Idle lists (12 s) are held LZ4-packed by a background thread and unpacked on the next use; NAPPLE_REC_BUDGET_MB (default a quarter of the RAM) is now only a safety ceiling that warns when it drops lists. Needs -lcompression (added to gfx/Makefile, runtime/Makefile, tools/build_ios.sh). NAPPLE_REC_NO_PACK=1 disables packing.
ACNH window pass (api_program.c, api_memory.c) verified (screenshots, t_progoffset): see the section below; stopgap removed.
Block-compressed render targets (mtl_tex.m, mtl_replay.m) tested (t_bcrt, Metal API validation on). Rendering into a BC texture through a one-block-per-texel view (MK8D format 0x44 path) draws into a block-grid shadow per level/slice that is copied back into the compressed texture after the pass. No copy-in (compression passes overwrite every block).
Integer MSAA resolve (mtl_replay.m) tested (t_msres_int): nvnCommandBufferDownsample of an integer target copies sample 0 with a draw (Metal's resolve cannot). 2D multisample only (not arrays).
D24 / D24S8 (mtl_replay.m) tested (t_d24): CopyBufferToTexture into DEPTH24 (0x33, depth in the low 24 bits) / DEPTH24_STENCIL8 (0x35, depth in the high 24, stencil in the low 8) converts to float depth (+ stencil aspect). Reading 24-bit depth back to memory is not implemented (warns once). Bit layouts are from Maxwell conventions (nouveau), not verified with a game.
ResolveDepthBuffer Intentionally a no-op: it resolves hardware depth compression, which Metal does not have.
Submission look-ahead (mtl_replay.m, mtl_shader.m) verified (LAS, cold shader cache, 100 s): before each submission is replayed, a prediction pass walks its recordings running only the state ops, computes every draw's pipeline key and starts unknown pipelines building on the background pipeline queue (mtl_pipe_prefetch); side-effect ops (copies, clears, dispatches, UBO updates, syncs, counters) are skipped and the replay state is restored before the real pass. LAS: blocking pipeline builds on the replay thread 40 ms -> 0 (8 ms spent waiting for builds already running); 26 pipelines either way. NAPPLE_GFX_NO_LOOKAHEAD=1 disables it. The look-ahead is one submission deep (the game's own ordering); a longer horizon would need predicting at EndRecording with inherited state.
Depth copies with a size change (mtl_replay.m) verified (LAS: its depth buffer is scaled down each frame; the warning texture copy with a depth/multisampled/3D texture ... not implemented (format 260 -> 260) is gone, picture unchanged): a draw writes the sampled depth through [[depth(any)]] into the destination's depth attachment (nearest sampling; a stencil aspect is not copied).
Surface image wiring (mtl_replay.m, mtl_tex.m, mtl_shader.m; the translator side landed with shader-surface) tested (t_image: a fragment SUST.D.32 into an R32UI image bound with BindImage reads back exactly, the other texels stay cleared, Metal API validation on). Each ShaderInfo.img[i] is bound at texture index IMAGE_TEXTURE_BASE + i from the image handle the game bound (BindImage binding = c2 handle index) or a bindless handle in a uniform buffer; the view is the texture as the shader declares it (integer format of the same texel size for INT / UINT kinds, linear for sRGB, the declared dimensionality); a written image makes the Metal texture the owner (gpu_owned); the pipeline key carries per-stage img_int / img_uint masks from the bound textures' formats (compute pipelines are keyed by them too); shaders with texture atomics compile as MSL 3.1; R32UI/R32SI textures created with the IMAGE flag get MTLTextureUsageShaderAtomic. Not seen in a title yet (none of the four binds images in the routes run).
Pipelines archive (mtl_shader.m) partial (builds, make -C gfx test below): the archive file is loaded on a background thread (builds before it is ready compile and still go into the new archive) and capped by NAPPLE_GFX_ARCHIVE_MAX_MB (default 64): a save over the cap leaves the oldest quarter out of the next one. Not measured on MK8D's 59 MB file.
Vertex stream outside any pool (mtl_replay.m) untested on MK8D: a stream starting inside a pool but running past its end binds from its start; a stream outside every pool binds zeros instead of keeping the previous draw's buffer.
Metal API validation (mtl_replay.m, mtl_tex.m) verified (MTL_DEBUG_LAYER=1, 80-150 s headless per title through the harness: LAS, SMO clean; ACNH and MK8D aborted until fixed). Found and fixed in our paths: (1) the depth/stencil partial-clear pipeline had no colour attachments while the pass had one (ACNH Set Render Pipeline State Validation): it now declares the pass's attachments with write mask none, and the colour clear pipeline declares the other attachments too; (2) two colour attachments that are the same image (MK8D Attachment 3 overlaps with attachment 4): the later one is not attached; (3) a texture of another type than the shader declares (ACNH cube vs 2D): a dummy of the right type is bound, and a 2D sample of an array/cube texture without a layer range views layer 0; (4) the new BC copy-back copied 4x4 texels into a 2x2 mip (Copy From Buffer Validation): clamped to the level. Metal shader validation (MTL_SHADER_VALIDATION=1) crashes inside Metal while loading a binary archive (MTLMetalScriptBuilder initFromScript, macOS 27 / M5), so it needs NAPPLE_GFX_NO_ARCHIVE=1, and then LAS shows 2 GPU Hang watchdog errors in 110 s (no validation message); it is opt-in in the harness (--shader-validation).
Golden-frame harness verified end to end (golden.py run all ran all four routes, wrote contact sheets and report.json): final run (merged tree): LAS, ACNH and MK8D PASS (0 gfx warnings, Metal API validation clean, no crash: 8 031 / 2 323 / 5 119 frames); SMO fails only because its frames 8000/9000 are never reached (kernel stall below; 0 warnings, 0 validation messages). All frames are "new": no goldens are approved yet, the user approves them from the run's contact.html (/private/tmp/napple-golden-runs/20261026-173313/ on the Mac that ran it). Guide: testing-graphics.

ACNH window pass: fixed, stopgap removed (verified)

  • Root cause: the window helper at main+0x3802394 (called from main+0x37b8f44) and other first-frame code create programs with nvnProgramSetShaders whose code address is a bare offset into a shader-code pool (0xe00, 0xa200, 0x900 ...: the base the game adds is 0), about 30 calls per run. We rejected them (code < 4 GiB), ProgramSetShaders returned 0, and the game checks the result (tst w0,#0xff after the call at main+0x3807614) and then never records the program bind, the uniform-buffer binds or the texture bind of that pass. The window draw therefore ran with whatever the previous draw left bound (the 500x110 label), which is why all earlier notes found "no op names composite slot 1634" and presented the orphan composite as a stopgap.
  • Fix (gfx/src/api_program.c, api_memory.c, mtl_replay.m): the call succeeds; each stage gets a placeholder hash (high word PLCH) and the replay resolves it at the first draw, when the game has filled the pools: the oldest shader-code pool that has an NVN shader header (magic 0x12345678) at the offset of every stage of that call, and whose header's stage field (SPH word at +0x30, low 5 bits: 1 vertex, 2 fragment) matches the stage (a single offset matches several pools by chance and gave other programs' code: a vertex shader that wrote no position, fragment shaders that wrote varyings). With that the game records the full window pass (BindProgram, 4 UBO binds, BindTexture stage 1 slot 1634 = the composite, quad draw) and the picture is the real scene: Nook Inc. counter with Timmy's dialogue at frames 1400 and 1800 (screenshots checked), 0 skipped draws, 0 Metal compile failures, no orphan warning. mtl_frame_present_pick and its tables are removed (NAPPLE_NO_ORPHAN is gone); NAPPLE_PROGRAM_POOL_OFFSETS=0 restores the rejection for comparison. Test: t_progoffset (a shader-code pool, bare offsets, red triangle).
  • What is still approximate: the pool is found by content, not by the true GPU base (never found: the game's cached pool address for these programs is 0 and no NVN call returns it). A program whose shaders do not all have headers at their offsets in one pool stays unresolved (its draws are skipped; none seen in the 110 s ACNH runs). The two stages of one call must come from one pool.
  • Other findings of the investigation: nvnProgramSetShaders is also called with a NULL code address (offset 0, accepted the same way now); the 15 flags 0x62 shader pools are never asked for their address (NAPPLE_POOLLOG=1 logs the queries); a program object received a guest write at its stage-4 hash slot (value 3), ignored with a warning.
  • Diagnostics added (env only): NAPPLE_POOLLOG=1, NAPPLE_WINDRAWLOG=1 (window draws: pipeline, viewport, scissor), NAPPLE_DEBUG_NOCULL=1.

ACNH characters (Timmy and Tommy) missing: fixed (verified)

The counter scene drew without the two Nooklings (and the kiosk at the right). Not the window pass: the depth pre-pass / EQUAL colour pass draws them (d3e5ad285bd50714 / e3ad4a1919bc281f, 9660 indices, 6 draws each) with every input bound (bone palette in UBO slot 3 = cb6, 0x5d0 bytes; the old draw log printed only UBO slots 0-3 and hid that). The skinned vertex shader read cbr(cb6, cbsz[6], r19 + 65520u): Maxwell LDC's 16-bit immediate is signed (c[6][R19 - 0x10], as nvdisasm prints it and yuzu decodes it); the translator took it unsigned (after Ryujinx's field name), so every bone row below the index register was read 64 KB past the buffer, cbr() returned 0 and the skinned positions collapsed to a point. Fix in gfx/src/shader/maxwell_translate.c (sign-extend; - N in the MSL; a negative offset with no register reads 0), TRANSLATOR_VERSION 9 (invalidates cached MSL), t_xlate case. Frames 1400 and 1900 now show both characters with their shirts and the flag; make -C gfx test green. The translator is f64-isa's path: one hunk, listed in the integrator file as a conflict hotspot. The same bug could have hit any skinned mesh in any title (LAS and SMO used positive offsets in the shaders checked; re-booted after the fix, see the integrator file).

SMO

Final repro (2026-10-27, default save root with the player's progress, NAPPLE_INPUT_SCRIPT='30:A:0.3,45:A:0.3,60:A:0.3', headless 230 s, merged tree): exit 0, 12 803 frames, 0 stale or invalid command handle, 11 046 command lists alive at the end, screenshots at frames 4000, 8000, 9000 and 10000 show the Cascade Kingdom with Mario asleep (the parrot scene) with correct geometry (no exploded meshes). The earlier "stall at ~7 500 frames" only happened with a fresh scratch save (the intro cutscene path, thread 0x6b spinning on core 0: kernel scheduler, see integrator file); with the real save the scene loads and runs. Lifetime also verified by t_cmdlife.

MK8D

Data: in-place build of the three NSPs (3.0.5 update + Booster Pass), 150 s headless with NAPPLE_INPUT_SCRIPT A presses every 4 s: exit 0, 6 347 frames (loaded Mac, load average 5-40), warnings: only vertex stream .. not inside a memory pool (once; the address is in no pool at all, so the game binds memory the GPU could not reach on the console either: it is now bound as zeros instead of the previous draw's buffer, no corrupt geometry); no stale or invalid command handle (the documented 65 000 a minute), no TextureInitialize: storage out of range, no compressed-render-target warning in this route (the t_bcrt test covers the copy-back). Windowed (not headless) 100 s run on the merged tree, idle Mac: shown_fps 59.6-60.0 (one 35.8 sample, a pipeline-build window); the 45 fps ceiling of the notes was not reproduced.

Merged main 401484e (2026-10-26 evening)

Merged the integrated main (runtime-depth, media-services, shader-surface, support-tables, f64-isa, sim-online) into this branch; one conflict (THIRD_PARTY_NOTICES.md, both sides appended). After the merge: make -C gfx test green, translator cache v10 (f64-isa's refused double shaders retry), t_xlate rebuilds when tests/*.inc change, support tables regenerated.

Not done / next

  • MK8D: the mode-preview panel of the Grand Prix menu is a black rectangle (frame 1500 of the regression run); it is a video preview (movie decode, not gfx), noted for the media owner.
  • Pipeline look-ahead hook (prewarm the pipelines of upcoming draws): not done.
  • Harness routes were not exercised end to end for all four titles; routes are first guesses.
  • First-use stutter numbers not measured.