WIP: perf(macos): batch the mesh fallback's draws of a pass on MoltenVK #783

Closed
mooki wants to merge 122 commits from mooki/Longshot:mac/mesh-batch into main
Member

Type: perf · Risk: medium · Tested on: macOS 26.7.1, Apple M4 Max (MoltenVK main 5189f8de + argbuf-slots), arm64 and x86_64 under Rosetta · Needs: macOS, Apple silicon (the E-series runs and a start to play on the rebased stack); Windows and Linux, any GPU (build and start)

Why

The Mac has no mesh shaders, so the game's mesh programs are drawn through the compute fallback: a draw's task and mesh stages run as
dispatches (after an argument conversion for an indirect draw), then its triangles are drawn. Dispatches cannot run inside a render
pass, so each fallback draw ends the pass and begins it again, and on Apple's tile GPUs every such break stores and loads every
attachment. In run #31's field-dawn hold 74.8 of 76.9 fallback draws a frame broke their pass, 72.2 of them in runs one pass could
hold. This PR draws a run of fallback draws on the same targets in one pass on MoltenVK, their dispatches first, with the same results.
It is part of the macOS port's performance work (MacPerf §4 #8). See #420.

What changed

On MoltenVK the first fallback draw of a pass records its dispatches as before and opens the pass in a secondary command buffer. A
later fallback draw that may join (same targets, no draw of the pass that may write memory, its program writing none, no attachment
the pass wrote sampled, fewer members than the cap, a records set within the allowance) records its conversion and dispatches into
the recording's command buffer before that pass, into a records set of its own, and its draw into a new secondary: a segment. When the
pass ends, the recording begins its rendering with secondary contents and executes the segments, ending and beginning it again (after
LS's pass-start barrier) where a segment must be a pass of its own. Every pass end with batching is one the replay makes without it.

  • the decisions: gpu/vk/mesh_batch.h (new, CPU only: State::join, split, draw, runs; the refusals by reason).
  • the recording: gpu/vk/vk_draw.cpp mesh_batch_open (from begin_pass for a fallback draw's pass), mesh_batch_join and
    mesh_batch_member (in draw()), mesh_batch_segment_begin, mesh_batch_emit (from end_pass), desc_carry and
    draw_binds_carry (what a draw keeps bound, carried into each new command buffer), MeshBatchOwed (the pass end a held-back
    conversion owes); fallback_record, fallback_record_count (#707's count draws) and mesh_indirect_convert hold back their pass end
    for a joining member; batch_records_of makes the members' records sets, each the size of the set the draw would use (the same
    area, header and rotation: #708's provoking_fallback decides alike), within the renderer's allowance (#707's
    mesh_area::affordable). gpu/vk/vk_loader.h loads vkCmdExecuteCommands; vk_recorder.h / vk_work.h carry it; vk_replay.cpp
    prints the vk: mesh batch line with the frame statistics.
  • the switch: RX_VK_MESH_BATCH (0 off, <n> members 2-64, default 8), MoltenVK's driver only, off with the recorder thread, the
    parallel replay, dumps, probes, RX_VK_DRAW_STATS and RX_VK_BARRIER_EVERY_DRAW. The bench's B1 arm: RX_VK_BENCH_AB=mesh_batch
    (with RX_VK_MESH_BATCH=0, so A is off).
  • with #780 and #782 (Integration 39's fix B, 94fca509, tests only): a batch's pass begins from #780's
    ps.lead, a batch opens only for a fallback draw's begin and a begin is held only for a foldable clear's, so a pass is never both;
    end_pass settles a held begin, then emits the batch. test_load_clear runs the mesh batches in its fixture; test_widen8 holds
    the widening at the draw before a batch's segment and the look ahead's stop at a mesh draw. With #780 and #782's look
    ahead off by default (Integration 40), a batch's pass begins with its loads and a restart-off 8-bit draw ends a batch, as before.
  • docs: docs/development.md (RX_VK_MESH_BATCH, the bench arm), gpu/vk/VULKAN.md. Tests: tests/test_mesh_batch.py +
    tests/mesh_batch_test.cpp (new); the fixtures that cut the touched code get stand-ins; test_mesh_provoking,
    test_mesh_count_fallback, test_mesh_fallback, test_fallback_depth_hold, test_perf_lines read the new forms.

Measurements

Verdict: helps: in the field-dawn hold MeshBatch is worth −1.1 ms of GPU a frame (20.2 → 19.1 ms) and +1.1 fps (32.6 → 33.7),
1.0 ms of frame, against a run-to-run noise of 0.17 ms of frame, 0.0 ms of GPU and 0.2 fps (E7); it saves 56.9 pass ends a frame. It
is exact on the GPU where probed: in MB-01 every step drew byte for byte what the unbatched replay draws.

Baseline: main does not start on macOS. The E-series runs are the integration build I39 7a7726fe (this PR's code with
#780's and #782's, and the other integrated macOS topics), arm64, one run each: before is E4, all on but MeshBatch
(RX_VK_MESH_BATCH=0), after is E2, all three on (I39's defaults; #780 and #782's look ahead are off by default here,
as Integration 40 made them, each measured under the noise). This head's MeshBatch, LoadClear and Widen8 functions are I39's text
exactly. For scale, E3 (all three off) ran 32.3 fps, GPU 20.5 ms: all three on are 1.29 ms of frame and 1.4 fps (+4.3%) faster
(reports/2026-11-15-mac-perf-before-after.md).

Hardware: macOS 26.7.1 (25G241), Apple M4 Max (40-core GPU), MoltenVK main 5189f8de + argbuf-slots (c170a745), 16-core CPU, 64 GB
Scene: the field-dawn save, slot 00, entered by START and A×5, then 600 s held in play, windowless; the instrument set on in every run
(RX_VK_PERF_LINES=1, RX_VK_GPU_TIMELINE=1, the present trace); quiet required before each start, the other lanes paused
Method: the medians of the hold's 120-present periods from +20 s (E4 158 periods, E2 141; the bench's and the chain's periods left
out of E2); one run each, the noise from E7 (a repeat of E2: 0.17 ms of frame apart); runs mac-arm64-7a7726fe-04 (E4),
-02 (E2), -08 (E7), -03 (E3); reports reports/2026-11-15-mac-perf-E4.md, -E2.md, -E7.md, -E3.md
How to reproduce: the series' ls-run.py prepare/run commands and common switches (reports/2026-11-15-mac-perf-runs-i39.md §2-§3),
with RX_VK_MESH_BATCH=0 for the before run

field-dawn hold, 600 s Before, RX_VK_MESH_BATCH=0: integration @ 7a7726fe (E4) After: integration @ 7a7726fe (E2)
fps (median [min-max] of the periods) 32.6 [31.3-33.2] 33.7 [32.7-34.4]
GPU ms a frame 20.2 [19.7-21.1] 19.1 [18.6-19.8]
frame ms 30.67 29.67
L1 pass ends a frame 188.0 133.9
L6 fallback draws that split their pass 74.7 of 76.9 20.6
vk: mesh batch: pass ends saved a frame (off) 56.9 (17.0 batches of 73.9 fallback draws; 7 records sets, 448 MB)
E2: vk: mesh batch (RX_VK_MESH_BATCH=8, at present 2640): 17.0 batches a frame of 73.9 fallback draws (56.9 joined one, their dispatches before its pass), 0.0 segments a pass of their own: 56.9 pass ends saved a frame; refused to join: its program may write memory 3.0, the batch is full 3.8; records sets 7 (448 MB)
E4: 158 periods; fps 32.6 [31.3-33.2]; frame ms 30.6748; GPU ms 20.2 [19.7-21.1]
E2: 141 periods; fps 33.7 [32.7-34.4]; frame ms 29.6736; GPU ms 19.1 [18.6-19.8]
E7: run-to-run noise: 0.17 ms of frame (29.50 against 29.67), 0.0 ms of GPU, 0.2 fps

MB-01 (exactness on the GPU; review c7b37d4c…, LS_COMMIT 29b93740; reports/MeshBatch-files/mb01/runs-mb01-arm64/result.json,
35d1d2d5…, released once on arm64 by the parent, reports/2026-11-14-mesh-batch.md §5): each of 8 steps recorded unbatched (U)
and batched (B) in one standalone MoltenVK process with the PR's own mesh_batch.h; both colour attachments, a memory-writing draw's
buffer and the occlusion results compared. Passes U → B: a run of 6 6 → 1, the cap 10 → 3, a target change 4 → 2, indirect 6 → 2;
B equal to U in 8 of 8 steps; both controls (merged without the real split, unsealed) differ from U, as predicted
(all_as_predicted 1, batch_exact_on_moltenvk 1).

Flags and settings

One new switch and one new arm of #704's bench switch; on by default on MoltenVK only.

Flag What it is Before this PR After this PR Effect Helps / doesn't help / untested Recommendation
RX_VK_MESH_BATCH=0 or <n> the mesh fallback's draws of a pass batched in that pass; <n> members (2-64) no switch: each fallback draw breaks its pass unset: on on MoltenVK only, 8 members; 0 off; read once fewer pass breaks, up to 7 more records sets a recording thread within the allowance; the same pixels exact in MB-01; the frame time is the E-series'; never on another driver on (the default), 8 members: MoltenVK only
RX_VK_BENCH_AB=mesh_batch the bench's B arm for this change, with RX_VK_BENCH (and RX_VK_MESH_BATCH=0) no such arm A: a pass break a fallback draw; B: batched A against B on the same frame, and whether B's picture is A's the B1 run is the E-series' for measuring only

Testing

Tested: on macOS 26.7.1, Apple M4 Max:

  • At this head (94fca509, on #782 7fd975fb with #708 merged): test_mesh_batch (45 checks, 0 failures; 20,000 random
    streams, 465,859 pass ends down to 373,389; 13 of 13 mutants killed), test_load_clear and test_widen8 (82,412 checks) pass on
    arm64 and x86_64; on the base a4d99ff3 with this head's tests all three fail, both arches (mesh_batch.h not included; no
    pass_keeps; draw()'s widening not after the fallback's dispatches). Every check whose test or fixture this PR or its merged base
    changes passes: test_count_draws, test_draw_binds, test_draw_side_effects, test_draw_stream_visibility,
    test_drawid_direct, test_fallback_buffer_cleanup, test_fallback_depth_hold, test_fsr_uniform_ownership,
    test_index8_bind, test_mesh_area, test_mesh_census, test_mesh_count_fallback, test_mesh_drawid, test_mesh_fallback,
    test_mesh_provoking, test_null_descriptors, test_null_layout_push, test_perf_lines, test_provoking_first,
    test_recorder_sync, test_recording_admission, test_resolver_coverage, test_sampler_admission, test_seg_gathers,
    test_seg_xfer, test_texel_views_plan, test_tls_objects, test_vendor_fixes, test_vk_occlusion_queries (both arches where
    they build; source-only ones on arm64). vk_draw.cpp, vk_replay.cpp, the four SPIR-V back-end files and host/nvn_native.c
    #707 brings compiled whole for both arches, 0 warnings (reports/PubWave-files/PubLate5/PerfTrio/proof/,
    compile/mesh-batch-94fca509/, -host/).
  • At the topic's own head (macos/mesh-batch 29b93740 on the integration branch I38 3f872b0c): tests/test_mesh_batch.py +
    tests/mesh_batch_test.cpp (a model recorder mirroring vk_draw.cpp's decisions over a tile-GPU model; every write and read in GPU
    order, batched against unbatched): 45 checks, 0 failures, 20,000 random streams (465,859 pass ends down to 373,389), 6 of 6
    controls and 13 of 13 mutants caught; the 66 checks that touch the changed code and test_tls_objects pass; 25 files compiled whole
    for both arches; full suite 586 passed, 1 failed (test_menu_i18n, as on I38), 16 skipped (reports/2026-11-14-mesh-batch.md §4).
  • With #780 and #782 on the integration branch (Integration 39, 7a7726fe): test_mesh_batch, test_load_clear (6,000
    random sequences with 5,512 batches: each batch's begin loads every attachment, none opens inside a pass or on a held begin) and
    test_widen8, both arches, among 103 of 103 checks; full suite 588 passed, 1 failed (test_menu_i18n), 16 skipped.
  • MB-01 on the GPU (Measurements).
  • Off Apple (unifdef -U__APPLE__, the base a4d99ff3 against this head): every changed file differs, the change being gated by the
    driver (reports/PubWave-files/PubLate5/PerfTrio/offapple-mesh-batch-own.txt).
  • The wave's full suite: see the run_all box.

Known gap, being fixed: draws that depend on the provoking vertex are left out when their list finds no room in the
recording's stream (vk: a draw depending on the provoking vertex left out: no room for its list in the stream …).

  • I39's E-series runs with this change on left 16 out each; with it off (E3, E4), 0.
  • Run #36 on the integration at this stack's defaults (LoadClear and Widen8's look ahead off) left out over 11,000, 0.6–0.9
    a frame.

A fix that never leaves a draw out for lack of stream room is in progress, and this PR stays WIP until it is in the stack.

Not tested: the three together on the GPU (a held begin next to a widening, a batch after leading clears on other targets:
reasoned and CPU-tested in Integration 39, not probed); MoltenVK 1.4.2 in MB-01; any driver but MoltenVK (never on there); not
built on Windows or Linux; its test request covers it.

Must be tested:

  • macOS, Apple silicon, on the rebased macOS stack: the field-dawn hold at the defaults with RX_VK_PERF_LINES=1, expecting the
    vk: mesh batch line to count batches and pass ends saved, L6's splits to fall as in E2, and the GPU time per frame near E2's
    (by hand: the parent's next runs).
  • Windows and Linux, a GPU on the compute fallback and one without it: build this PR (head 94fca509, it carries #782, #708 and
    what they carry) and #782's head with #708 merged (a4d99ff3) the same way, start each to the title, expecting no vk: mesh batch
    line, every other line as there, and the same picture (by hand: test request T-XXXX).

Risks

What could break: on MoltenVK only: a batch's pass is recorded in secondary command buffers and executed at its end; a joining
member's dispatches run before the pass's earlier draws (never past a draw that may write memory, never reading an attachment the pass
wrote); up to 7 more records sets a recording thread, made as runs need them within the allowance (a member that cannot get one is
drawn as before). A restart-off 8-bit draw that needs a widening at the draw (#782) ends a batch.

Turning it off: RX_VK_MESH_BATCH=0, or revert the PR.

Compatibility: Nothing changes for players outside macOS. No file in the shader stamp changed; no MKWVK_API_VERSION change.

Reviewer notes

  • Review a4d99ff3..94fca509 for this PR's own work (3 commits): cherry-pick -x of macos/mesh-batch e73a3a1f and 29b93740, then
    Integration 39's fix B 7a7726fe (its change line for line). 7fd975fb..a4d99ff3 is its base:
    • 49b8ae6a merges #708 (mac/mesh-provoking 881408f9, carrying #707 merged into #703): this PR holds back #707's
      fallback_record_count pass end, sizes a member's records set as #707's growing area so #708's rotation decides alike, and its
      tests follow #708's. Conflicts resolved as Integration 36/37 has them (the includes; count_rewrite_records keeps #704's
      why(CountDraw) before its end_pass; the report declarations; both lanes' blocks in test_resolver_coverage). Its change from
      the first parent is #708's change from #703, line for line, but for merged_sites' list.
    • a4d99ff3 = Integration 36's b4732392 (-x): #707's count draws' own pass end said MeshFallback (the line this PR then holds
      back), and the two checks that follow #707's moves (test_perf_lines, test_drawid_direct).
  • The picks' conflicts, as Integration 39's merge 70e25167 resolved them: begin_pass builds the rendering info from #780's
    ps.lead, gives it to mesh_batch_open, then holds the begin for a leading clear, else records it unless a batch opened; the two
    clears keep #780's begin_pass(…, lead) with mesh_batch_cmd right after it, the depth clear's batch note before the
    fold-or-clear; end_pass settles, then emits, then ends the rendering; the RX_VK_BENCH_AB row names the three arms. Left out as
    not in this stack: #702's FanIndirect declaration and report block, #686's memory_import_refusal_report line, #705's quads paragraph
    in VULKAN.md, #696's and #697's docs rows. These functions, load_clear.h, widen8.h, mesh_batch.h and the three tests are
    Integration 39's text exactly.
  • Depends on #782 and #708, and what they carry (#780, #678, #703, #707, #704, #680, #701 …).
  • Sibling items (whichever lands second): the report declaration and block beside #702's FanIndirect ones (keep both); with #704 and
    #707 in main, a4d99ff3 is theirs (drop it if main has it).
  • Owner item: the batching is exact on any driver drawing mesh programs through the fallback; dropping the MoltenVK gate (Windows and
    Linux GPUs without mesh shaders) is a separate PR.
  • Conflicts with main: this head is on main e090cc48; main has moved since, and a delta sweep follows.

Issues

See #420

Checklist

Everyone

  • One topic.
  • The title is a Conventional Commit (type(scope): description) of the status line's type.
  • Why gives the reason plainly and links the issues that fit (the tracker searched).
  • Measurements has a verdict, or one line saying why there is nothing to measure.
  • Testing names what was tested, what wasn't, and what must still be tested, where and how. (Test requests not open yet.)
  • Rebased on current main. (On main e090cc48 through #782 and #708; main has moved since: a delta sweep follows.)
  • python -I -B tests/run_all.py passes (any failure that also fails on main is named). (At this head 94fca509 (wave P): 585 passed, 31 failed, 18 skipped against main e090cc48's 544/32/17 on this Mac; the 31 failures are main's own and nothing that passes on main fails; test_collect_wide now passes; 40 new tests pass; reports/PubWave-files/PubLate5/wave/full-P-94fca509/.)
  • No game files, dumps, build output or generated files.
  • third_party/ untouched (a new folder with a PROVENANCE.md instead).
  • The license is OK: GPL-3.0-or-later, nothing incompatible and nothing decompiled.

When it applies

  • AI assistance recorded with Assisted-by: / Co-Authored-By: trailers.
  • Switches documented (docs/development.md; the README for a menu row) and shipped at their recommended value.
  • A game-free test added for the fix, failing on main and passing here.
  • A check under tests/ that reads changed source updated in this PR, not weakened.
  • Docs updated.
**Type:** perf · **Risk:** medium · **Tested on:** macOS 26.7.1, Apple M4 Max (MoltenVK main 5189f8de + argbuf-slots), arm64 and x86_64 under Rosetta · **Needs:** macOS, Apple silicon (the E-series runs and a start to play on the rebased stack); Windows and Linux, any GPU (build and start) ## Why The Mac has no mesh shaders, so the game's mesh programs are drawn through the compute fallback: a draw's task and mesh stages run as dispatches (after an argument conversion for an indirect draw), then its triangles are drawn. Dispatches cannot run inside a render pass, so each fallback draw ends the pass and begins it again, and on Apple's tile GPUs every such break stores and loads every attachment. In run #31's field-dawn hold 74.8 of 76.9 fallback draws a frame broke their pass, 72.2 of them in runs one pass could hold. This PR draws a run of fallback draws on the same targets in one pass on MoltenVK, their dispatches first, with the same results. It is part of the macOS port's performance work (MacPerf §4 #8). See #420. ## What changed On MoltenVK the first fallback draw of a pass records its dispatches as before and opens the pass in a secondary command buffer. A later fallback draw that may join (same targets, no draw of the pass that may write memory, its program writing none, no attachment the pass wrote sampled, fewer members than the cap, a records set within the allowance) records its conversion and dispatches into the recording's command buffer before that pass, into a records set of its own, and its draw into a new secondary: a segment. When the pass ends, the recording begins its rendering with secondary contents and executes the segments, ending and beginning it again (after LS's pass-start barrier) where a segment must be a pass of its own. Every pass end with batching is one the replay makes without it. - the decisions: `gpu/vk/mesh_batch.h` (new, CPU only: `State::join`, `split`, `draw`, `runs`; the refusals by reason). - the recording: `gpu/vk/vk_draw.cpp` `mesh_batch_open` (from `begin_pass` for a fallback draw's pass), `mesh_batch_join` and `mesh_batch_member` (in `draw()`), `mesh_batch_segment_begin`, `mesh_batch_emit` (from `end_pass`), `desc_carry` and `draw_binds_carry` (what a draw keeps bound, carried into each new command buffer), `MeshBatchOwed` (the pass end a held-back conversion owes); `fallback_record`, `fallback_record_count` (#707's count draws) and `mesh_indirect_convert` hold back their pass end for a joining member; `batch_records_of` makes the members' records sets, each the size of the set the draw would use (the same area, header and rotation: #708's `provoking_fallback` decides alike), within the renderer's allowance (#707's `mesh_area::affordable`). `gpu/vk/vk_loader.h` loads `vkCmdExecuteCommands`; `vk_recorder.h` / `vk_work.h` carry it; `vk_replay.cpp` prints the `vk: mesh batch` line with the frame statistics. - the switch: `RX_VK_MESH_BATCH` (`0` off, `<n>` members 2-64, default 8), MoltenVK's driver only, off with the recorder thread, the parallel replay, dumps, probes, `RX_VK_DRAW_STATS` and `RX_VK_BARRIER_EVERY_DRAW`. The bench's B1 arm: `RX_VK_BENCH_AB=mesh_batch` (with `RX_VK_MESH_BATCH=0`, so A is off). - with #780 and #782 (Integration 39's fix B, `94fca509`, tests only): a batch's pass begins from #780's `ps.lead`, a batch opens only for a fallback draw's begin and a begin is held only for a foldable clear's, so a pass is never both; `end_pass` settles a held begin, then emits the batch. `test_load_clear` runs the mesh batches in its fixture; `test_widen8` holds the widening at the draw before a batch's segment and the look ahead's stop at a mesh draw. With #780 and #782's look ahead off by default (Integration 40), a batch's pass begins with its loads and a restart-off 8-bit draw ends a batch, as before. - docs: `docs/development.md` (`RX_VK_MESH_BATCH`, the bench arm), `gpu/vk/VULKAN.md`. Tests: `tests/test_mesh_batch.py` + `tests/mesh_batch_test.cpp` (new); the fixtures that cut the touched code get stand-ins; `test_mesh_provoking`, `test_mesh_count_fallback`, `test_mesh_fallback`, `test_fallback_depth_hold`, `test_perf_lines` read the new forms. ## Measurements **Verdict:** helps: in the field-dawn hold MeshBatch is worth **−1.1 ms of GPU a frame (20.2 → 19.1 ms) and +1.1 fps (32.6 → 33.7)**, 1.0 ms of frame, against a run-to-run noise of 0.17 ms of frame, 0.0 ms of GPU and 0.2 fps (E7); it saves 56.9 pass ends a frame. It is exact on the GPU where probed: in MB-01 every step drew byte for byte what the unbatched replay draws. Baseline: main does not start on macOS. The E-series runs are the integration build I39 `7a7726fe` (this PR's code with #780's and #782's, and the other integrated macOS topics), arm64, one run each: before is E4, all on but MeshBatch (`RX_VK_MESH_BATCH=0`), after is E2, all three on (I39's defaults; #780 and #782's look ahead are off by default here, as Integration 40 made them, each measured under the noise). This head's MeshBatch, LoadClear and Widen8 functions are I39's text exactly. For scale, E3 (all three off) ran 32.3 fps, GPU 20.5 ms: all three on are 1.29 ms of frame and 1.4 fps (+4.3%) faster (`reports/2026-11-15-mac-perf-before-after.md`). Hardware: macOS 26.7.1 (25G241), Apple M4 Max (40-core GPU), MoltenVK main 5189f8de + argbuf-slots (`c170a745`), 16-core CPU, 64 GB Scene: the field-dawn save, slot 00, entered by START and A×5, then 600 s held in play, windowless; the instrument set on in every run (`RX_VK_PERF_LINES=1`, `RX_VK_GPU_TIMELINE=1`, the present trace); quiet required before each start, the other lanes paused Method: the medians of the hold's 120-present periods from +20 s (E4 158 periods, E2 141; the bench's and the chain's periods left out of E2); one run each, the noise from E7 (a repeat of E2: 0.17 ms of frame apart); runs `mac-arm64-7a7726fe-04` (E4), `-02` (E2), `-08` (E7), `-03` (E3); reports `reports/2026-11-15-mac-perf-E4.md`, `-E2.md`, `-E7.md`, `-E3.md` How to reproduce: the series' `ls-run.py prepare`/`run` commands and common switches (`reports/2026-11-15-mac-perf-runs-i39.md` §2-§3), with `RX_VK_MESH_BATCH=0` for the before run | field-dawn hold, 600 s | Before, RX_VK_MESH_BATCH=0: integration @ 7a7726fe (E4) | After: integration @ 7a7726fe (E2) | |---|---|---| | fps (median [min-max] of the periods) | 32.6 [31.3-33.2] | 33.7 [32.7-34.4] | | GPU ms a frame | 20.2 [19.7-21.1] | 19.1 [18.6-19.8] | | frame ms | 30.67 | 29.67 | | L1 pass ends a frame | 188.0 | 133.9 | | L6 fallback draws that split their pass | 74.7 of 76.9 | 20.6 | | `vk: mesh batch`: pass ends saved a frame | (off) | 56.9 (17.0 batches of 73.9 fallback draws; 7 records sets, 448 MB) | ``` E2: vk: mesh batch (RX_VK_MESH_BATCH=8, at present 2640): 17.0 batches a frame of 73.9 fallback draws (56.9 joined one, their dispatches before its pass), 0.0 segments a pass of their own: 56.9 pass ends saved a frame; refused to join: its program may write memory 3.0, the batch is full 3.8; records sets 7 (448 MB) E4: 158 periods; fps 32.6 [31.3-33.2]; frame ms 30.6748; GPU ms 20.2 [19.7-21.1] E2: 141 periods; fps 33.7 [32.7-34.4]; frame ms 29.6736; GPU ms 19.1 [18.6-19.8] E7: run-to-run noise: 0.17 ms of frame (29.50 against 29.67), 0.0 ms of GPU, 0.2 fps ``` MB-01 (exactness on the GPU; review `c7b37d4c…`, `LS_COMMIT` `29b93740`; `reports/MeshBatch-files/mb01/runs-mb01-arm64/result.json`, `35d1d2d5…`, released once on arm64 by the parent, `reports/2026-11-14-mesh-batch.md` §5): each of 8 steps recorded unbatched (U) and batched (B) in one standalone MoltenVK process with the PR's own `mesh_batch.h`; both colour attachments, a memory-writing draw's buffer and the occlusion results compared. Passes U → B: a run of 6 6 → 1, the cap 10 → 3, a target change 4 → 2, indirect 6 → 2; B equal to U in 8 of 8 steps; both controls (merged without the real split, unsealed) differ from U, as predicted (`all_as_predicted 1`, `batch_exact_on_moltenvk 1`). ### Flags and settings One new switch and one new arm of #704's bench switch; on by default on MoltenVK only. | Flag | What it is | Before this PR | After this PR | Effect | Helps / doesn't help / untested | Recommendation | |---|---|---|---|---|---|---| | `RX_VK_MESH_BATCH=0` or `<n>` | the mesh fallback's draws of a pass batched in that pass; `<n>` members (2-64) | no switch: each fallback draw breaks its pass | unset: on on MoltenVK only, 8 members; `0` off; read once | fewer pass breaks, up to 7 more records sets a recording thread within the allowance; the same pixels | exact in MB-01; the frame time is the E-series'; never on another driver | on (the default), 8 members: MoltenVK only | | `RX_VK_BENCH_AB=mesh_batch` | the bench's B arm for this change, with `RX_VK_BENCH` (and `RX_VK_MESH_BATCH=0`) | no such arm | A: a pass break a fallback draw; B: batched | A against B on the same frame, and whether B's picture is A's | the B1 run is the E-series' | for measuring only | ## Testing **Tested:** on macOS 26.7.1, Apple M4 Max: - At this head (`94fca509`, on #782 `7fd975fb` with #708 merged): `test_mesh_batch` (45 checks, 0 failures; 20,000 random streams, 465,859 pass ends down to 373,389; 13 of 13 mutants killed), `test_load_clear` and `test_widen8` (82,412 checks) pass on arm64 and x86_64; on the base `a4d99ff3` with this head's tests all three fail, both arches (`mesh_batch.h` not included; no `pass_keeps`; draw()'s widening not after the fallback's dispatches). Every check whose test or fixture this PR or its merged base changes passes: `test_count_draws`, `test_draw_binds`, `test_draw_side_effects`, `test_draw_stream_visibility`, `test_drawid_direct`, `test_fallback_buffer_cleanup`, `test_fallback_depth_hold`, `test_fsr_uniform_ownership`, `test_index8_bind`, `test_mesh_area`, `test_mesh_census`, `test_mesh_count_fallback`, `test_mesh_drawid`, `test_mesh_fallback`, `test_mesh_provoking`, `test_null_descriptors`, `test_null_layout_push`, `test_perf_lines`, `test_provoking_first`, `test_recorder_sync`, `test_recording_admission`, `test_resolver_coverage`, `test_sampler_admission`, `test_seg_gathers`, `test_seg_xfer`, `test_texel_views_plan`, `test_tls_objects`, `test_vendor_fixes`, `test_vk_occlusion_queries` (both arches where they build; source-only ones on arm64). `vk_draw.cpp`, `vk_replay.cpp`, the four SPIR-V back-end files and `host/nvn_native.c` #707 brings compiled whole for both arches, 0 warnings (`reports/PubWave-files/PubLate5/PerfTrio/proof/`, `compile/mesh-batch-94fca509/`, `-host/`). - At the topic's own head (`macos/mesh-batch` `29b93740` on the integration branch I38 `3f872b0c`): `tests/test_mesh_batch.py` + `tests/mesh_batch_test.cpp` (a model recorder mirroring `vk_draw.cpp`'s decisions over a tile-GPU model; every write and read in GPU order, batched against unbatched): 45 checks, 0 failures, 20,000 random streams (465,859 pass ends down to 373,389), 6 of 6 controls and 13 of 13 mutants caught; the 66 checks that touch the changed code and `test_tls_objects` pass; 25 files compiled whole for both arches; full suite 586 passed, 1 failed (`test_menu_i18n`, as on I38), 16 skipped (`reports/2026-11-14-mesh-batch.md` §4). - With #780 and #782 on the integration branch (Integration 39, `7a7726fe`): `test_mesh_batch`, `test_load_clear` (6,000 random sequences with 5,512 batches: each batch's begin loads every attachment, none opens inside a pass or on a held begin) and `test_widen8`, both arches, among 103 of 103 checks; full suite 588 passed, 1 failed (`test_menu_i18n`), 16 skipped. - MB-01 on the GPU (Measurements). - Off Apple (`unifdef -U__APPLE__`, the base `a4d99ff3` against this head): every changed file differs, the change being gated by the driver (`reports/PubWave-files/PubLate5/PerfTrio/offapple-mesh-batch-own.txt`). - The wave's full suite: see the run_all box. **Known gap, being fixed:** draws that depend on the provoking vertex are left out when their list finds no room in the recording's stream (`vk: a draw depending on the provoking vertex left out: no room for its list in the stream …`). - I39's E-series runs with this change on left 16 out each; with it off (E3, E4), 0. - Run #36 on the integration at this stack's defaults (LoadClear and Widen8's look ahead off) left out over 11,000, 0.6–0.9 a frame. A fix that never leaves a draw out for lack of stream room is in progress, and this PR stays WIP until it is in the stack. **Not tested:** the three together on the GPU (a held begin next to a widening, a batch after leading clears on other targets: reasoned and CPU-tested in Integration 39, not probed); MoltenVK 1.4.2 in MB-01; any driver but MoltenVK (never on there); not built on Windows or Linux; its test request covers it. **Must be tested:** - macOS, Apple silicon, on the rebased macOS stack: the field-dawn hold at the defaults with `RX_VK_PERF_LINES=1`, expecting the `vk: mesh batch` line to count batches and pass ends saved, `L6`'s splits to fall as in E2, and the GPU time per frame near E2's (by hand: the parent's next runs). - Windows and Linux, a GPU on the compute fallback and one without it: build this PR (head `94fca509`, it carries #782, #708 and what they carry) and #782's head with #708 merged (`a4d99ff3`) the same way, start each to the title, expecting no `vk: mesh batch` line, every other line as there, and the same picture (by hand: test request T-XXXX). ## Risks **What could break:** on MoltenVK only: a batch's pass is recorded in secondary command buffers and executed at its end; a joining member's dispatches run before the pass's earlier draws (never past a draw that may write memory, never reading an attachment the pass wrote); up to 7 more records sets a recording thread, made as runs need them within the allowance (a member that cannot get one is drawn as before). A restart-off 8-bit draw that needs a widening at the draw (#782) ends a batch. **Turning it off:** `RX_VK_MESH_BATCH=0`, or revert the PR. **Compatibility:** Nothing changes for players outside macOS. No file in the shader stamp changed; no `MKWVK_API_VERSION` change. ## Reviewer notes - Review `a4d99ff3..94fca509` for this PR's own work (3 commits): `cherry-pick -x` of `macos/mesh-batch` `e73a3a1f` and `29b93740`, then Integration 39's fix B `7a7726fe` (its change line for line). `7fd975fb..a4d99ff3` is its base: - `49b8ae6a` merges #708 (`mac/mesh-provoking` `881408f9`, carrying #707 merged into #703): this PR holds back #707's `fallback_record_count` pass end, sizes a member's records set as #707's growing area so #708's rotation decides alike, and its tests follow #708's. Conflicts resolved as Integration 36/37 has them (the includes; `count_rewrite_records` keeps #704's `why(CountDraw)` before its end_pass; the report declarations; both lanes' blocks in `test_resolver_coverage`). Its change from the first parent is #708's change from #703, line for line, but for `merged_sites`' list. - `a4d99ff3` = Integration 36's `b4732392` (`-x`): #707's count draws' own pass end said `MeshFallback` (the line this PR then holds back), and the two checks that follow #707's moves (`test_perf_lines`, `test_drawid_direct`). - The picks' conflicts, as Integration 39's merge `70e25167` resolved them: `begin_pass` builds the rendering info from #780's `ps.lead`, gives it to `mesh_batch_open`, then holds the begin for a leading clear, else records it unless a batch opened; the two clears keep #780's `begin_pass(…, lead)` with `mesh_batch_cmd` right after it, the depth clear's batch note before the fold-or-clear; `end_pass` settles, then emits, then ends the rendering; the `RX_VK_BENCH_AB` row names the three arms. Left out as not in this stack: #702's FanIndirect declaration and report block, #686's `memory_import_refusal_report` line, #705's quads paragraph in `VULKAN.md`, #696's and #697's docs rows. These functions, `load_clear.h`, `widen8.h`, `mesh_batch.h` and the three tests are Integration 39's text exactly. - Depends on #782 and #708, and what they carry (#780, #678, #703, #707, #704, #680, #701 …). - Sibling items (whichever lands second): the report declaration and block beside #702's FanIndirect ones (keep both); with #704 and #707 in main, `a4d99ff3` is theirs (drop it if main has it). - Owner item: the batching is exact on any driver drawing mesh programs through the fallback; dropping the MoltenVK gate (Windows and Linux GPUs without mesh shaders) is a separate PR. - Conflicts with main: this head is on main e090cc48; main has moved since, and a delta sweep follows. ## Issues See #420 ## Checklist ### Everyone - [x] One topic. - [x] The title is a Conventional Commit (`type(scope): description`) of the status line's type. - [x] Why gives the reason plainly and links the issues that fit (the tracker searched). - [x] Measurements has a verdict, or one line saying why there is nothing to measure. - [x] Testing names what was tested, what wasn't, and what must still be tested, where and how. (Test requests not open yet.) - [ ] Rebased on current `main`. (On main e090cc48 through #782 and #708; main has moved since: a delta sweep follows.) - [x] `python -I -B tests/run_all.py` passes (any failure that also fails on `main` is named). (At this head `94fca509` (wave P): 585 passed, 31 failed, 18 skipped against main e090cc48's 544/32/17 on this Mac; the 31 failures are main's own and nothing that passes on main fails; `test_collect_wide` now passes; 40 new tests pass; `reports/PubWave-files/PubLate5/wave/full-P-94fca509/`.) - [x] No game files, dumps, build output or generated files. - [x] `third_party/` untouched (a new folder with a `PROVENANCE.md` instead). - [x] The license is OK: GPL-3.0-or-later, nothing incompatible and nothing decompiled. ### When it applies - [x] AI assistance recorded with `Assisted-by:` / `Co-Authored-By:` trailers. - [x] Switches documented (docs/development.md; the README for a menu row) and shipped at their recommended value. - [x] A game-free test added for the fix, failing on `main` and passing here. - [x] A check under `tests/` that reads changed source updated in this PR, not weakened. - [x] Docs updated.
mooki added 122 commits 2026-11-15 11:55:58 +00:00
Reserve the fixed guest window with a non-overwriting Mach mapping. Reclaim
complete owned host pages with fresh anonymous mappings and zero only the
partial-page edges, preserving adjacent 4 KiB guest pages on 16 KiB hosts.
Use the actual host granule for non-guest allocation/protection and report
macOS discard failures instead of returning success.

Add game-free source-linked arm64/Rosetta tests covering collisions, repeated
free/recommit, unaligned ranges, failure paths and resident-memory reduction.
Keep Linux and Windows paths unchanged. Build-list integration is separate.

Assisted-by: GPT-6 Astra
Co-Authored-By: GPT-6 Astra <[email protected]>
Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Keep the POSIX layer's outward 4 KiB MEM_DECOMMIT and inward 4 KiB MEM_RESET
contracts above the byte-exact Darwin helper. Check arithmetic before rounding,
and keep separate real-memory assertions for helper and wrapper ranges.

Default the Darwin regression test to the native architecture. Both CPU routes
remain explicitly selectable for acceptance without requiring Rosetta or arm64
execution from every ordinary macOS suite run.

Assisted-by: GPT-6 Astra
Co-Authored-By: GPT-6 Astra <[email protected]>
Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Main's #324 (a4c77174) made hos_commit in host/mkw_glue.c return whether
the range was committed, so its definition now starts "int hos_commit(".
The Darwin guest-memory test cut the function by its old "void" head and
stopped with a ValueError on main's source, on this branch and on main alike,
before compiling anything. Cut it by the new head.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
test(runtime): follow main's HLE call counts in the Darwin guest-memory fixture
Some checks failed
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been skipped
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been skipped
Third-party parts / The same as upstream (pull_request) Has been skipped
Website / Website on Cloudflare (pull_request) Has been skipped
PR description / Description follows the template (pull_request_target) Successful in 20s
CI / Conventional Commit title (pull_request) Successful in 12s
CI / Workflow lint (actionlint) (pull_request) Successful in 9s
Guide screenshots / Guide images and local UI source review (pull_request) Failing after 5s
Third-party parts / As their notes say (pull_request) Successful in 9s
ae36a96bb0
Main's HOS_HLE_STATS (#153) made HLE() a counting wrapper: a static
HleCount named by its first field, hos_hle_stats and hos_hle_counted. The
fixture's cut of hos_mem.c now gets those two (off: each body runs
directly) and main's -Wno-missing-field-initializers, as main's other
C fixtures pass. The guest-memory checks are unchanged.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Decode Darwin signal contexts and access faults for arm64 and x86_64,
copy supported context edits back on signal return, and prevent recursive
callback dispatch when a fault handler itself faults. Keep the non-Apple
bridge text and the x64-shaped CONTEXT layout unchanged.

Add source-linked, game-free owned-memory fault tests for both targets,
including page repair, PC redirection, prior handlers and an inaccessible
handler stack. Document the native PC/SP contract and remaining limits.

Assisted-by: GPT-6 Astra
Co-Authored-By: GPT-6 Astra <[email protected]>
Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Do not make the ordinary Apple Silicon suite depend on Rosetta. Default
to the running Python process's architecture and retain explicit repeated
--arch options for dual-route qualification. Document that command and
count only files when reporting crash-report directory changes.

Assisted-by: GPT-6 Astra
Co-Authored-By: GPT-6 Astra <[email protected]>
Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
test(runtime): classify the fault bridge's fatal _exit in main's exit inventory
Some checks failed
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been skipped
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been skipped
Third-party parts / The same as upstream (pull_request) Has been skipped
Website / Website on Cloudflare (pull_request) Has been skipped
PR description / Description follows the template (pull_request_target) Successful in 5s
CI / Conventional Commit title (pull_request) Successful in 3s
CI / Workflow lint (actionlint) (pull_request) Successful in 5s
Guide screenshots / Guide images and local UI source review (pull_request) Failing after 5s
Third-party parts / As their notes say (pull_request) Successful in 9s
1b028c8419
Main's crash records (#75) added an exhaustive inventory of host process
exits to test_crash_record.py, which scans every host/**/*.c on every
platform. darwin_fault.c's nested-fault _exit(128 + sig) is fatal, so it
is listed as one and exempt from the clean-end mark, like hos_thread.c's
ExitProcess(3). Without it the inventory fails on Windows and Linux too.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Select a local Vulkan loader or MoltenVK with RX_VK_LIBRARY and keep SDL and
renderer startup on that same library. Capture SDL's instance extensions on
the window thread, enable advertised portability extensions, and name missing
admission requirements and VkResults without changing Windows or Linux paths.

Keep the non-Apple host API at 21 for existing prebuilt renderers; append the
two macOS callbacks only in Apple's version 22. Cover pure decisions, legacy
platform expectations, ABI layout, and the SDL2/SDL3 bridge without a GPU.

Assisted-by: GPT-6 Astra
Co-Authored-By: GPT-6 Astra <[email protected]>
Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Remove the SDL source-string wiring and source-index ordering assertions while
keeping the sixteen compiled behavioral cases and surface adapter compile.
Replace the comment-sensitive API header hash with fixed baseline field names,
byte offsets, versions and sizes, retaining typed Apple callback checks and
both platforms' actual admission decisions and negative controls.

Production code is unchanged. Prior full-suite and device receipts remain
bound to 818e33b1; only the affected game-free tests were rerun.

Assisted-by: GPT-6 Astra
Co-Authored-By: GPT-6 Astra <[email protected]>
Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The fixture cuts LoadLibraryA and create_device out of main's source. Since
#34, LoadLibraryA keeps dlerror()'s text in load_error, and since #191
native_fadd_wanted also takes the device ID and driver version, so the cut
code no longer compiled against the fixture's stand-ins on this base. Give
it load_error and a dlerror stand-in, and the four-argument stand-in. No
expectation changed.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Main's custom output size (#51) took MKWVK_API_VERSION 22 on every
platform and appended take_text at offset 352. The macOS loader
callbacks now follow it: the rebased header says 23 on Apple and 22
elsewhere. The fixed contract follows: non-Apple version 22, size 360
and 45 named offsets (take_text added); Apple version 23, size 376,
vulkan_library and vulkan_extensions at 360 and 368.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
fix(macos): storage buffer tables get descriptor sets on MoltenVK
Some checks failed
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been skipped
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been skipped
Third-party parts / The same as upstream (pull_request) Has been skipped
Website / Website on Cloudflare (pull_request) Has been skipped
CI / Conventional Commit title (pull_request) Successful in 16s
CI / Workflow lint (actionlint) (pull_request) Successful in 22s
Guide screenshots / Guide images and local UI source review (pull_request) Failing after 22s
Third-party parts / As their notes say (pull_request) Successful in 33s
PR description / Description follows the template (pull_request_target) Successful in 17s
9778fd0ee8
MoltenVK 1.4.2 binds a push descriptor set's resources one by one, never
through a Metal argument buffer (MVKDescriptorSet.mm pickArgumentBufferMode
returns Off for every push layout, and the pipeline names that set to
SPIRV-Cross as discrete). The SPIR-V back end declares a storage buffer
table (count > 1: Info::global_tables, the task and mesh programs'
c0+0x880 table) as one fixed-size array per typed view, all at one binding
(DefineSsbos; any global memory access uses the U32, U32x2 and U32x4
views), and SPIRV-Cross 6c09849f aliases such arrays only inside an
argument buffer. So every pushed program with a table failed: 256
"Attempting to alias same binding with a descriptor array which is not
implemented through argument buffers" errors and as many failed compute
pipelines in the first Mac start (mac-arm64-a5f2d6f9-01).

vk_vendor.h gets a fix, set_for_tables, on by default only for
VK_DRIVER_ID_MOLTENVK, with RX_VK_SET_FOR_TABLES to force it either way.
ensure_layout's push decision moves into layout_pushes(): with the fix on,
a set that has an array of storage buffers is not pushed but allocated
from the recording's pool, the path programs over the push limit already
take, where MoltenVK's sets are Metal 3 argument buffers. Every other set
is still pushed, the bindings are the same either way, and the SPIR-V,
the translator stamp and the program cache are unchanged. The vendor
fixes' log line at device creation says whether it is on, and a MoltenVK
run with MVK_CONFIG_USE_METAL_ARGUMENT_BUFFERS=0 is warned that these
programs cannot translate.

tests/test_set_for_tables.py compiles vk_draw.cpp's layout code (kPushSize
to the end of ensure_layout) on made-up programs and devices: on MoltenVK
a set with a table (compute, task, fragment) is a set, a set without one
(texture arrays and a constant buffer of count 2 included) is pushed;
ten other drivers push as before; the switch works both ways. It fails
on the base with 7 of 79 cases. tests/test_vendor_fixes.py adds a MoltenVK
device and the switch, and checks that the fix stays out of the
translation's profile and stamp. Windows and Linux: same layouts as
before; the log line gains one field.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 0c2fea2a303a5158d12ebc87bcd28467486486ef)
fix(macos): programs reading storage buffer lengths get descriptor sets on MoltenVK
Some checks failed
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been skipped
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been skipped
Third-party parts / The same as upstream (pull_request) Has been skipped
Website / Website on Cloudflare (pull_request) Has been skipped
PR description / Description follows the template (pull_request_target) Successful in 16s
CI / Conventional Commit title (pull_request) Successful in 15s
CI / Workflow lint (actionlint) (pull_request) Successful in 22s
Guide screenshots / Guide images and local UI source review (pull_request) Failing after 19s
Third-party parts / As their notes say (pull_request) Successful in 31s
ef43dffcc8
MoltenVK 1.4.2 writes the buffer sizes SPIRV-Cross reads for OpArrayLength
in a discretely bound set only in bindDescriptorSets
(MVKCommandEncoderState.mm), never in pushDescriptorSet, and it binds every
push set discretely. So every pushed storage buffer's length read 0
whatever its range, while an ordinary set's (a Metal 3 argument buffer
with its own sizes) was exact (GPU probe,
reports/2026-11-14-null-range-probe.md). The back end measures storage
buffers in its global memory search (DefineGlobalMemoryFunctions:
native_global_bounds' candidates and every single storage buffer), so in a
pushed program no buffer ever held a global address: global stores and
atomics were dropped, the atomics returned 0, and loads the page table
does not serve read 0.

vk_vendor.h gets set_for_lengths, on by default only for
VK_DRIVER_ID_MOLTENVK, with RX_VK_SET_FOR_LENGTHS to force it either way.
The new gpu/vk/spirv_lengths.h (no Vulkan, as fallback_size_policy.h)
walks a module's words for OpArrayLength.
Each Stage gets array_length, noted beside its phis wherever the program
cache handles its SPIR-V: program_cache_save after translation (before the
program is published), a whole record read, and complete_from for a
compute program read without its code. The words are not kept anywhere a
layout could scan them later, and a light compute program's layout is made
before its code is read, so until then (array_length -1) a stage counts as
reading a length where it uses global memory: outside segmented variants,
which the cache never keeps, that is the only place the back end measures
a storage buffer. layout_pushes(p, bindings, total) then refuses a push
for any program with a length in any stage. Every other program, every
other driver, the SPIR-V, the translator stamp and the program cache are
unchanged.

The vendor fixes' log line says whether it is on, and at close one line
says how many programs it gave a set (and how many of those were decided
before their code was read).

tests/test_set_for_lengths.py builds real back end modules
(tests/set_for_lengths_ir.cpp: compute and vertex global memory with a
single storage buffer, 18 OpArrayLength each; global memory with nothing to
search; direct storage in compute, vertex and fragment stages, none), then
compiles vk_draw.cpp's layout code with their descriptors and notes:
138 layouts on MoltenVK and ten other drivers, with the switch unset, 0
and 1, stages not read yet, and the bindings equal pushed or not; plus the
renderer's scan on the same modules and on words that are not a module,
and the count kept for the log. test_vendor_fixes.py covers the new fix
on 17 devices, both ways of the switch, and its absence from the
translation's profile and stamp. The program cache fixtures
(program_cache_one_variant_test.cpp, program_cache_cleanup_test.cpp), which
compile vk_progcache.cpp's functions, include spirv_lengths.h beside
fallback_size_policy.h.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit bf5ca961c91479da13e8923387012ef6dc7fb2c7)
fix(macos): stages with more than 8 read_write textures get descriptor sets on MoltenVK
All checks were successful
PR description / Description follows the template (pull_request_target) Successful in 18s
bff8bdc326
On MoltenVK 1.4.2 a pushed set is bound outside an argument buffer
(MVKDescriptorSet.mm pickArgumentBufferMode: push layouts are Off), and
there Metal's compiler takes at most 8 read_write textures in one function:
TexelDescProbe TD5 (40 storage images and 9 storage texel buffers in one
compute stage) failed pipeline creation pushed with "number of textures
with read_write access exceeds maximum supported (8)", and worked in an
ordinary set (Metal 3 argument buffers). The device reports
maxPerStageDescriptorStorageImages 256 (MoltenVK sets
maxPerStageStorageTextureCount to its argument buffers' texture count), so
nothing in Vulkan's limits stops it.

Which descriptors count: SPIRV-Cross 6c09849f declares a storage image
(sampled 2), storage texel buffers included as texture_buffer since
MoltenVK sets texture_buffer_native, access::read_write unless the
variable is decorated NonWritable or NonReadable (spirv_msl.cpp
image_type_glsl), and it never adds those decorations itself. The eden back
end decorates none (DefineImages, DefineImageBuffers), although Info knows
is_read and is_written. So every storage image and storage texel buffer
counts, by descriptor; sampled images and uniform texel buffers do not.
The limit is per Metal function, that is per stage.

layout_pushes now refuses a push under the existing set_for_tables fix
when any stage's storage images and storage texel buffers, counted by the
bindings' descriptorCount, exceed 8. It is the same cause and remedy as a
storage buffer table (a shape a pushed set cannot take on MoltenVK, where
the pipeline fails; an ordinary set takes it), and RX_VK_SET_FOR_TABLES
covers both, so the vendor fixes' log line is unchanged. ensure_layout
records where each stage's bindings begin, so the mesh fallback's task
and mesh stages, both compute, are counted apart. The programs moved for
it are counted and logged at close (draw_layouts_report). Every other
driver, the SPIR-V, the translator stamp and the program cache are
unchanged.

tests/test_set_for_tables.py gains 55 layouts: 8 and 9 storage images,
5+3 and 5+4 images and texel buffers, 9 texel buffers, an image array of
8 and of 9, graphics stages of 8+8 (pushed) and 0+9, the fallback's task
and mesh stages of 5+5 (pushed) and 4+9, and 41 read-only textures beside
8 storage images (pushed), on MoltenVK; the 9s on the ten other drivers
(pushed); the switch both ways; and the count kept for the close. It
fails on 7b82e199. test_vendor_fixes.py follows the new call and checks
the count reaches the close's log.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 162aca1b3cce50adde9250f14b209a4e5b4c1331)
This PR needs the macOS fault-context PR (#436); its commits are kept as they are.
The one conflict, host/posix/win32_posix.c's Apple include block, keeps both
darwin_mem.h (the guest-memory PR's) and darwin_fault.h (#436's).

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A connected guest span that the views of one operation need can be larger than
one device allocation (41,747,087,360 bytes on MoltenVK 1.4.2, M4 Max). It is
then imported as disjoint, adjacent segments of at most the cap.
gpu/vk/span_segments.h (rules only, no Vulkan) chooses the cuts: multiples of
the import granularity, the fewest segments, the least cost of the views a cut
splits (gathers by their bytes, then uniform, texel and storage buffers), a
cost for moving a previous cut, and the lowest addresses on ties. It maps any
guest range onto the segments, and splits a texel view at cuts and at
maxTexelBufferElements, reporting 12-byte seams. Invalid input gets an
explicit refusal. Nothing uses it yet: vk_memory.cpp takes it later.

tests/test_span_segments.py checks it against brute force at caps of 48 KiB,
64 KiB, 96 KiB and 1 MiB with 16 KiB granularity, plus the 48 GiB example, the
512 GiB guest window and the top of the u64 range (--arch arm64 / x86_64 on
macOS).

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 11e53b66ffbdddd0cfeaa7a043361a337299d855)
Keep address-region and page frees owned until discard or in-place zeroing
finishes. Route guest mutations from each image to one executable-owned
registry, and pin complete imported backing through duplicate and overlapping
GPU memory lifetimes without querying the renderer under ownership locks.

Use real Mach query ranges and protections, preserve byte-exact 4 KiB guest
ownership and 64 KiB commit semantics, and handle all native watch subpages
with checked fault recovery and conservative dirty coverage. Replace racy
bulk dirty-byte reads with same-granule atomics.

Add game-free native arm64/Rosetta fixtures for two-image dispatch, actual
faults, aliases, failures, and deterministic free/reallocate/import races.
Carry prior RSS evidence rather than repeat unchanged experiments.

Assisted-by: GPT-6 Astra
Co-Authored-By: GPT-6 Astra <[email protected]>
Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
feat(vk): let the segment planner add segments that pay for themselves
Some checks failed
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been skipped
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been skipped
Third-party parts / The same as upstream (pull_request) Has been skipped
Website / Website on Cloudflare (pull_request) Has been skipped
PR description / Description follows the template (pull_request_target) Successful in 17s
CI / Conventional Commit title (pull_request) Successful in 16s
CI / Workflow lint (actionlint) (pull_request) Successful in 24s
Guide screenshots / Guide images and local UI source review (pull_request) Failing after 20s
Third-party parts / As their notes say (pull_request) Successful in 33s
aae09e20f7
With the fewest segments, a large gathered view can be cut on every draw
although one more segment would keep it whole: a 32 GiB index extent at
[8, 40) GiB in a 48 GiB domain at the 41,747,087,360-byte cap is split by
every two-segment cut list. plan() now weighs any number of segments, each
one beyond the fewest at Weights::segment (2^32 bytes-equivalent, above a
move), and returns the least cost, then the fewest segments, then the lowest
cuts. Every cut of that plan is a mark (the first multiple of g at or above a
demand's end, or a previous cut) or ceil(next cut - cap), so the search runs
over those candidates and is exact over [k_min, k_min + min(marks,
C0 / segment)]; a least plan over 4096 segments is refused. Plan::extra
reports the added segments.

The test brute-forces the same range of segment counts, checks a DP over
every count, and adds the 32 GiB extent (three segments, nothing gathered).

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 189d0636cb5e90e0d98de397d0b9676a5f8354c2)
Keep a cancelled-writable watch state so an already pending diagnostic fault
is acknowledged without rearming or changing permissions. Check the real
mapping before acknowledgement; intentional read-only/no-access stays closed.

Freeze affected watch pages once, preflight the complete native range against
Mach maximum permissions, and issue one protection operation. Preserve prior
watch states when either preflight or the native operation fails.

Exercise the real Win32 vectored dispatcher, signal-handler retention and the
next genuine watch fault. Cover permission failures both before preflight and
after a deterministic barrier, with real Mach maximum-protection changes and
no manufactured VM results, on native arm64 and Rosetta.

Assisted-by: GPT-6 Astra
Co-Authored-By: GPT-6 Astra <[email protected]>
Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The two commits before this one also changed Windows and Linux: the
dirty-page scan read its marks with byte atomics, the renderer's zeroed
guest memory was marked written, an empty range consumed no mark,
allocation failures in the scan and in the address-region bookkeeping
were fatal, hos_region_alloc refused a bad size or alignment,
hos_mark_written clamped its length, and VirtualQuery checked its
pointer and set last_error. Nobody has timed the scan there, so each of
these is now inside #if defined(__APPLE__), with main's code in the
#else. unifdef -U__APPLE__ of every host/ file this branch changes
equals main's byte for byte, and so do their preprocessed Linux and
Windows token streams. Issue #432 is the version for every platform.

test_collect_wide's fixture compiles the scan the platform builds: byte
atomics, and an empty range consuming nothing, on macOS; main's word
loads elsewhere.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
docs(macos): describe the guest window's one owner, import pins and native-page watches
Some checks failed
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been skipped
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been skipped
Third-party parts / The same as upstream (pull_request) Has been skipped
Website / Website on Cloudflare (pull_request) Has been skipped
PR description / Description follows the template (pull_request_target) Successful in 5s
CI / Conventional Commit title (pull_request) Successful in 3s
CI / Workflow lint (actionlint) (pull_request) Successful in 6s
Guide screenshots / Guide images and local UI source review (pull_request) Failing after 6s
Third-party parts / As their notes say (pull_request) Successful in 28s
fceb934f11
The guest-memory section still described the groundwork alone: it said
the write watch and VirtualQuery were separate work and that the
region allocator's unlock-before-discard race remained. Describe the
executable's single owner and how each image reaches it, frees that
keep their range until the bytes are gone, import pins, the real
VirtualQuery and VirtualProtect, the native-page watch and the atomic
dirty scan, that all of it is macOS only (issue #432), and the test's
two narrower modes.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Every scan down vk_memory.cpp's import table by start (pool_lookup's
covering import and merge plan, bg_drain, prefetch_chunks,
import_in_background, memory_note_gpu_write and memory_guest_imported)
stopped once an import ended more than 1 GiB below its address, on the
belief that no import is larger than that. pool_lookup's own merge escape
leaves an import above 1 GiB over the smaller ones inside it ("left
overlapping"), and the device allows imports up to max_import_size: a scan
stopped at a small import inside a large one, missed the large one, and
the same bytes were imported again, two memories over the same bytes.

import_reach.h counts the live imports by span class; its bound is above
every live span, within twice the largest. imported_add and imported_take,
now the table's only ways in and out, keep the count. A scan stops only
where no import further down can reach its address, so it also stops
sooner than before when the imports are small. The covering-import checks
in pool_lookup and import_in_background look past the last import at or
below the range for the same reason.

Tests: test_import_reach.py runs the actual scans with a 4 GiB import over
a 16 MiB chunk, left by the merge escape (imports are recorded ranges: no
payload, no Vulkan, no game). Its lookup, query, note, drain, background
and prefetch cases fail on main 5e072fc8 and pass here, and a source check
keeps every change to the table in the two helpers. test_import_windows
runs pool_lookup with the helpers; test_mirror_gpu_only names the new
publication point.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
test(vk): the readback refresh fixture runs memory_note_gpu_write with the import table's helpers
Some checks failed
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been skipped
Third-party parts / The same as upstream (pull_request) Has been skipped
Website / Website on Cloudflare (pull_request) Has been skipped
PR description / Description follows the template (pull_request_target) Successful in 4s
CI / Conventional Commit title (pull_request) Successful in 3s
CI / Workflow lint (actionlint) (pull_request) Successful in 5s
Guide screenshots / Guide images and local UI source review (pull_request) Failing after 6s
Third-party parts / As their notes say (pull_request) Successful in 9s
639ab978be
test_parallel_rb_refresh cuts memory_note_gpu_write out of vk_memory.cpp,
whose scan now stops at the live imports' reach: the fixture includes
import_reach.h, cuts live_reach, imported_add and imported_take beside it,
and puts its pool in the table through imported_add. It failed to build
at 11e9ee44 ("use of undeclared identifier 'import_reach'").

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A guest span larger than one device allocation (the cap on MoltenVK/M4 Max is 41,747,087,360 bytes) is bound as
pieces: one descriptor per allocation segment, at the descriptor's own binding (reports/2026-11-14-full-range-design.md
sections 4.1-4.4). This is the shader half; the draw side (descriptors, layout, gathers) comes later.

- runtime_info.h: SegEntry {Storage|Table|TexBuf|ImgBuf, desc, pieces} and RuntimeInfo::segmented.
- Back end: a segmented storage buffer is an array of pieces at its binding (a table count * pieces, entry k's piece j
  at k * pieces + j); its loads, stores and atomics call DontInline helpers made after main, which find the piece by
  the pieces' OpArrayLength in order, with constant indices. The global memory fallbacks sum the pieces' lengths for a
  segmented descriptor's bound end and branch to its piece (single, native candidate, table written out and in the
  loop). Texel and image buffers are arrays too, their fetches, reads, writes and atomics through helpers that pick
  the piece by OpImageQuerySize, and a texel buffer's size query sums the pieces. ImageQuery was declared for every
  module already. With RuntimeInfo::segmented empty nothing new is made: the SPIR-V is byte-identical.
- vk_shader: SegSig (entries, hash) on Program, seg_sig and seg_variant; program_segmented finds or translates the
  variant of a plain program with plain's texture questions and answers and checks its descriptors are plain's;
  program_pick, mesh_program_locked, program_find_plain, loaded_variants and portable_variant keep the plain and
  segmented variants apart; every stage's RuntimeInfo::segmented comes from Program::seg.
- vk_progcache: a segmented variant is never written to the program cache nor listed among the loaded programs.
- tests/test_seg_shader.py + tests/seg_shader_ir.cpp: synthetic IR programs through the real back end, plain and
  segmented; structure checks, spirv-val (Vulkan 1.3, uniform buffer standard layout), and with --base-tree a byte
  comparison of the plain modules against an older tree. Five source-reading checks follow the new Program field and
  the atomics' new branch.

The translator stamp changes with this (eden/, vk_shader.cpp, sizeof(Program)): every program cache is rebuilt once.
Land it in the same release as the vk_api.h change that already does so.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit b7928876dab846485f667c1c934d34d25ecef035)
fix(vk): MslArrayPtr: no pointer into a storage buffer array is passed to a function
All checks were successful
PR description / Description follows the template (pull_request_target) Successful in 5s
407a0cfc93
On MoltenVK a segmented storage program with a compare-exchange loop (an
increment, decrement or float add the device does not have) failed pipeline
creation: NullProbe revision 02's NS2, "no matching function for call",
&ssbo0_4[0u] given to a device _66 * parameter. SPIRV-Cross's MSL (6c09849f,
MoltenVK 1.4.2) passes a storage buffer pointer to a function by value, and
takes the address of the argument's expression: for an element of a buffer
array, which MSL holds as an array of pointers, that is the address of the
pointer (reports/2026-11-14-msl-arrayptr.md).

A descriptor bound as pieces now has its loops written out in the piece,
inside the segmented helper (CasLoopHere, CasStoreHere: CasLoop's loop,
attempt for attempt, its variables made first in the helper), instead of
calling the CasLoop function or write_storage_cas_loop_func with a pointer
into the pieces' array. Every other program is as before byte for byte.

Tests: test_seg_shader.py checks that no module passes a pointer into a
buffer descriptor array to a function. The two checks that cut the back
end's source follow the new loop and parameter.

Ported from macos/msl-arrayptr, where it was committed on NullShader, to the
pieces alone: here every storage access that reaches a segmented helper is
in pieces, so the loops are always written out. Left for the NullShader
change: the bound-whole branch that still calls the function with its
block, the guarded helper's cas_loop, and the tests that need NullShader or
tests/spirv_run.h (the CPU runs of the loops and of a storage buffer table,
--changed-segmented and the *.cpurun comparison).

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit bd396010887afab9520181009db1b56a4cc425e3)
feat(vk): texel codecs in the SPIR-V back end and program variants
Some checks failed
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been skipped
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been skipped
Third-party parts / The same as upstream (pull_request) Has been skipped
Website / Website on Cloudflare (pull_request) Has been skipped
CI / Conventional Commit title (pull_request) Successful in 15s
CI / Workflow lint (actionlint) (pull_request) Successful in 23s
Guide screenshots / Guide images and local UI source review (pull_request) Failing after 20s
Third-party parts / As their notes say (pull_request) Successful in 8s
PR description / Description follows the template (pull_request_target) Successful in 5s
b78e421a97
A texel buffer whose format the GPU gives no texel buffer view (MoltenVK 1.4.2 on Apple GPUs: R32G32B32_SFLOAT, the
R8G8B8 formats and the 16-bit packed ones) is read and written through an R8, R16 or R32 UINT view of the same bytes,
with the format's decode and encode in the shader (reports/2026-11-14-texel-formats-design.md section 4).

- Back end: RuntimeInfo::texel_codecs (TexelEntry: a texture or image buffer descriptor and its Shader::TexelCodec),
  honoured with Profile::support_texel_codecs only. Carrier images, per-codec decode and encode functions of integer
  operations alone (normalized components through tables of the exact floats, writes rounded to nearest even from the
  float's bits), whole-texel bounds (reads past the last texel 0, writes dropped, the size the elements over k), an
  integer format's own view read as U32 or S32 texels, and composition with the segmented piece arrays (the helpers
  take each descriptor's own image type). Typed storage accesses and atomics keep the view of their declared format:
  a codec there is refused. With no codec the SPIR-V is byte for byte as before.
- Renderer: gpu/vk/texel_codec.h, the plan shared by the variants and (next) the views; texel_codecs_on (macOS, from
  the format query, RX_VK_TEXEL_CODECS); each texel buffer's codec a variant answer (desc | Program::kTexelAnswer, so
  the program cache and the pipeline state list carry it as they are), checked by still_valid; "txcd" in the
  translator stamp when on; program cache format 2.
- Tests: tests/seg_shader_ir.cpp and tests/spirv_run.h emit every codec plain and in pieces, validate it, run its
  decode and encode on the CPU against reference conversions (every input of the 8- and 16-bit formats, random and
  boundary sets for 32-bit) and run its entry point over texel buffers in memory; the GPU probe's codec module.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 783eb1898b91d505ff22a31f337256c5047d0177)
fix(macos): a window without Vulkan extensions ends as a clean exit, as main's exit inventory asks
Some checks failed
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been skipped
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been skipped
Third-party parts / The same as upstream (pull_request) Has been skipped
Website / Website on Cloudflare (pull_request) Has been skipped
CI / Conventional Commit title (pull_request) Successful in 4s
PR description / Description follows the template (pull_request_target) Successful in 4s
CI / Workflow lint (actionlint) (pull_request) Successful in 6s
Guide screenshots / Guide images and local UI source review (pull_request) Failing after 5s
Third-party parts / As their notes say (pull_request) Successful in 8s
f270209f99
Main's #75 classifies every host process exit (test_crash_record's
inventory, read from the source on every platform): a normal exit marks
the clean end first, a fatal one is listed as fatal. The macOS
ExitProcess(1) after hos_darwin_vulkan_capture() fails is the same kind
of exit as the SDL_CreateWindow failure just above it (a message, then
the end), so it marks the clean end the same way, and the inventory
lists display_sdl.c's second ExitProcess(1). Windows and Linux compile
the same code as before (the exit is inside #if defined(__APPLE__)).

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Carries the vk-admission pull request's commits, which this one needs
(MkwVkHost's Apple callbacks, darwin_vulkan.h), on top of guest-16k.
host/posix/win32_posix.c: its Apple include block keeps darwin_mem.h,
darwin_fault.h and darwin_vulkan.h.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Append Apple23 full-backing import lifetime callbacks while preserving the
non-Apple21 layout. Keep buffer views move-only, release buffers before the
last memory owner unpins backing, and preserve completion-gated retirement.
Unpublished failures unwind; published roots retain process lifetime rather
than calling Vulkan from unsafe static teardown.

Check and unwind post-device startup allocations. Resolve the shared audio
shim through the executable-owned SDL service on Darwin without unresolved
weak imports or linker relaxation. Retain the existing loader/SDL behavior.

Add game-free owner, ABI, source-topology and strict-link consumers, including
both native arm64 and x86_64 routes. This slice does not claim to fix separate
host-import alias visibility or implement the final alias admission planner.

Assisted-by: GPT-6 Astra
Co-Authored-By: GPT-6 Astra <[email protected]>
Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
044635ea gave vk_memory.cpp's Pool an Apple-only DarwinImportView member and
import_pool an Apple route (darwin_import_pool) right after its guards. It
moved test_import_buffer_binding and test_texel_buffer_ownership onto the
Windows/Linux layout (#if defined(__APPLE__) -> #if 0: the Apple owner has
its own darwin_import_lifetime_test), but three more fixtures cut the same
code and stopped compiling on macOS. Each passes at 58c5c5aa (044635ea's
parent) and fails at 044635ea:

- test_direct_buffer_writes and test_parallel_rb_refresh: their cut of
  struct Pool named DarwinImportView. They now cut Pool's Windows/Linux
  layout, the same one they compiled before.
- nx2/test_import_windows: its cut of import_pool's guards, which ends at
  the import, took in the unterminated Apple #if. It now ends at the Apple
  route, which must stay one #if just before the import; the fixture's own
  continuation models the Windows/Linux import as before.

No assertion, case or check is removed or loosened. test_parallel_rb_refresh
and test_import_windows also need the renderer-core extractions (next).

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
044635ea added vma_startup_rollback (vk_vma.cpp, run after a failed
replay_start), which calls vmaDestroyAllocator. vk_vma.cpp includes
vk_work.h last so that RX_VK_RECORDER=defer/thread drains before its
destructions, and every such boundary call in a vk_work.h file must be in
RX_REC_BOUNDARIES with a drain-first macro. This one was not, and
test_recorder_sync caught it ("vk_vma.cpp calls ['vmaDestroyAllocator'],
which vk_work.h does not drain before"): it passes at 58c5c5aa (044635ea's
parent) and fails at 044635ea.

Add it to the list and to vk_work.h, next to vmaDestroyBuffer and
vmaDestroyImage. With the recorder off (the default) boundary() is a
relaxed load and a branch. Rollback runs before any image or buffer exists,
so nothing should be held there today, but the allocator is no longer
destroyed while a recorder could still hold packets. The test is unchanged
(sources: 42 boundaries drained, was 41).

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
tests/test_darwin_renderer.py cuts vk_memory.cpp's code into
darwin_import_lifetime_test.cpp. Two of main's changes since this
branch's first base broke that cut on the rebase, before any check ran:

- #324 reworded the comment after imports_give_back's drain of the
  evicted pools ("(longshot) Off by default on Windows ..." is now
  "(longshot) Default by OS and vendor ..."). The cut ends at that
  comment, so it now looks for the new words. The cut code is the same
  drain loop as before.
- #250 (sparse mirrors) made retire_pool erase a retired pool from
  sparse_wanted. The fixture's stand-ins gain an empty sparse_wanted,
  beside mirror_wanted, as main declares it (std::vector<Pool*>).

No assertion, case or expected output changes.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
107fda29 moves the new pool into the import table
(imported.emplace(p.lo, std::move(p))) instead of copying it.
tests/test_mirror_gpu_only.py ends its cut of pool_lookup's merge at
"imported.emplace(p.lo, p)", so it stopped with "substring not found"
(full-suite receipt full-91f9a58d: the only check that passes on main
and fails here). It now ends at the moved form. The check is the same:
the merged pool must take in the gpu_only and gpu_written marks of the
pools it absorbs before it goes into the table.

On the macOS integration branch this was hidden: renderer-core's
53b8914d rewrote the same line for its imported_add. When that pull
request rebases onto this one, its line replaces this one.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Main's custom output size (#51) took MKWVK_API_VERSION 22 everywhere, so
the macOS loader callbacks (#438) became version 23 after take_text, and
this branch's guest_import_pin and guest_import_unpin, appended after
them, become version 24. The Apple table is now 392 bytes with the four
callbacks at 360, 368, 376 and 384; the admission contract and the
renderer startup fixture pin exactly that. Windows and Linux stay at 22.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
fix(macos): vk_memory.cpp compiles on macOS: #324's evicted_pools declaration is not Apple's
Some checks failed
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been skipped
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been skipped
Third-party parts / The same as upstream (pull_request) Has been skipped
Website / Website on Cloudflare (pull_request) Has been skipped
PR description / Description follows the template (pull_request_target) Successful in 16s
CI / Conventional Commit title (pull_request) Successful in 14s
CI / Workflow lint (actionlint) (pull_request) Successful in 22s
Guide screenshots / Guide images and local UI source review (pull_request) Failing after 21s
Third-party parts / As their notes say (pull_request) Successful in 33s
f66d0a87b8
Main's #324 (a4c77174) declares `extern std::deque<…> evicted_pools;` above
memory_guest_imported, which reads it on Windows and Linux. This branch
defines evicted_pools on macOS as a process-owned reference
(`auto& evicted_pools = *new std::deque<…>;`), so after the rebase onto
6c9edffe vk_memory.cpp did not compile for macOS ("redefinition of
'evicted_pools' with a different type"). The game-free fixtures cut the
functions out and never compile the whole file, so they did not see it.

The declaration is now for Windows and Linux only: on macOS
memory_guest_imported answers through darwin_guest_imported, which comes after
the definition. Windows and Linux compile the same text as before.

First made on mac/renderer-core, where this branch is merged.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 444e05c119a261b36c7c762abf9d26194b0ee417)
# Conflicts:
#	gpu/vk/vk_memory.cpp
#	tests/test_mirror_gpu_only.py
#	tests/test_parallel_rb_refresh.py
#431 (fix/import-lookback) makes imported_add and imported_take the import
table's only ways in and out. #491 (mac/renderer-correctness) gives the
macOS Pool an import view (DarwinImportView) that owns the imported memory
and cannot be copied, defines the table on macOS as a process-owned map,
and releases imports through destroy_import. Merged, they do not build or
test on macOS:

- imported_add took a const Pool& and copied it. It now takes Pool&& and
  moves it into the table (the macOS stack's form, 53b8914d). Its callers
  already move (the merge kept #491's moves); the fixtures that call it with
  a local pool move it, or a copy where they read the pool afterwards
  (nx2/test_import_windows.py). Windows and Linux insert the same Pool at
  the same key: a move in place of a copy.
- test_import_reach.py cut the Pool and the scans with their Apple guards,
  so on macOS the fixture met DarwinImportView. It now resolves the Apple
  guards to the other side (test_vk_darwin_admission.py's select, as
  test_darwin_renderer.py does; the macOS route is that test's), cuts
  destroy_import where the source has it, and its source check does not
  count macOS's definition of the table as a change to it.
- test_darwin_renderer.py's Darwin fixture cuts live_reach, imported_add and
  imported_take (darwin_import_lifetime_test.cpp includes import_reach.h),
  and its queue-slot order check finds imported_take where the source has it.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
RendererSliceReview findings on 044635ea, forwarded by the parent.

darwin_import_commit refused a whole import when any page in its range was
committed but NOACCESS. Darwin's VirtualQuery reports the demand-paged guest
window committed with each page's own protection, so an explicit NOACCESS or
a diagnostic watch page dropped every binding in that range to zeros, where
the pre-pin path tried the import and let the driver's VkResult decide. The
CPU protection of a committed page is CPU-side state: such pages are now left
exactly as they are and the import proceeds; only an unmapped page or a failed
query refuses. Whether MoltenVK/Metal imports a NOACCESS host range is left to
its own result; a GPU arm for the parent-released probe settles it.

waveOutOpen on macOS returned MMSYSERR_ERROR without a word when the
executable provides no hos_posix_audio_open. The lookup now names the missing
service once, on stderr; nothing else changes.

Tests: darwin_import_lifetime_test.cpp imports a range with a committed
NOACCESS page (no commit, no reprotection); test_darwin_audio_service.py
counts the line: once with the provider missing, never with it present. Both
fail on an archived 73f90266.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 9e3832639a33e22dc8672e80a64354eb729c4c48)
Two host imports of the same bytes read stale data within one submission on MoltenVK. Under alias
isolation (set in memory_init on Apple only) every operation's guest buffers are admitted before it
binds the first, and no import is replaced while recorded work may still reach it:

- Seam: memory_admit / replay_admit on the replay thread (replay_mu, no parallel worker): draw()
  (mesh records, then its buffers once the pipeline is known), dispatch() (the upscale stand-in, then
  its buffers), the replay's signal, buffer copy, fill and uniform update, the textures of every
  copy and clear, and copy_b2t's buffer side once its texture is resolved.
- Resolver: one side-effect-free description of what each binding path binds, from the binders' own
  helpers (uniform_bind_size, storage_binding, texel_buffer_extent, buffer_extent, the *_bytes
  helpers), including the GPU unswizzle's source (texture_unswizzle_source), the temporal
  upscaler's actor motion (temporal_actor_demands) and the diagnostic readers' copies
  (RX_VK_DRAW_STATS, RX_VK_DUMP). tests/test_resolver_coverage.py holds every gpu_buffer_bind /
  gpu_buffer call site of the renderer to its resolver demand and its seam.
- Covering reuse: a range a live import holds needs nothing; on Apple a range inside a live import's
  backing becomes a new view on that memory. Backing extents count for alias conflicts.
- Handoff: end the pass, flush the uniform updates before this place, device-to-host barrier,
  submit, wait, then import (old memories retired and kept to completion, page-table changes
  deferred), host-to-device barrier.
- Cohorts: different operations whose union passes the cap get one memory each, with a handoff.
- Under batch admission the dry pass's view-making marks are skipped (its command buffer is then
  recorded in order); RX_VK_POOL_END_IMPORTED is ignored under alias isolation (it imported to
  answer a resolver's question).

Off alias isolation (Windows, Linux) nothing of this runs and every binding path is unchanged.

Tests: test_alias_admission, test_admission_handoff, test_resolver_coverage (new); the Apple
spare-backing reuse and backing-overlap cases in darwin_import_lifetime_test; extractions of
test_import_chunk_default, test_fallback_depth_hold, test_vulkan_copy_span(_gpu_compile),
test_draw_side_effects and test_vk_occlusion_queries follow the moved code, every assertion kept
(the last two also check the admission seam). test_import_reach.py's main() call is restored: the
53b8914d copy ran nothing.

Published on main 6c9edffe with #491 and #431 below it (mac/renderer-core):
- actor_reads binds main's index bytes (count << itype) for every index
  type, as main's actor_motion does. The macOS stack's copy bound nothing
  for an index type outside 0..2, which would change Windows and Linux too;
  such a draw does not reach actor_motion (bones_used refuses it when the
  draw is noted) unless its model key matches a draw of another index type,
  so that guard is left out of this macOS PR.
- vk_temporal.cpp: #59 (actor motion pairs copies by place) copies a pose
  read at its draw from the stage; the copy from the game's memory, its
  else branch, binds through actor_reads.
- vk_memory.cpp imports_give_back keeps #324's retire grace beside alias_ref.
- test_import_reach.py keeps #431's source check and prefetch case and takes
  the admission excerpts and --arch. Its main() call needed no restoring:
  #431's copy has it.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit ae1d7812c12f3e64941da0c34bb3541eb695f6db)
A connected guest span one operation needs that is larger than max_import_size (41,747,087,360 bytes
on MoltenVK 1.4.2, M4 Max) was left unplanned (admit_over_cap): its bindings came back empty. It is
now a segmented domain (reports/2026-11-14-full-range-design.md 2.1, 2.3, 5.3-5.5), in place of
that branch:

- memory_admit plans it as disjoint, adjacent segments where span_segments::plan cuts them (the
  operation's views and classes as demands, the domain's previous cuts kept unless moving one pays,
  priced extra segments), each an ordinary Pool of at most the cap: one import, its own page-table
  entries, mirror, write-backs and retirement. Only segments holding a demand are imported; every
  live import over their bytes is retired, after a handoff when recorded work may reach it.
- A view across a cut is never one memory: pool_lookup refuses it (counted, logged), not owed to an
  admission. gpu_span_bind (vk_internal.h SpanRef) gives its pieces, one per segment, for the
  consumers' failure branches; gpu_span_pieces and gpu_span_layout describe them binding nothing.
- Domains change only at an admission, never while parallel workers run (debug-checked with the
  design's invariants, seg_invariants); a batch past the cap is admitted as segments instead of
  being recorded in order. memory_admit loses its batch flag and AdmissionStats its splits.
- span_unused_piece: the hook the NullDesc design fills (MoltenVK has no nullDescriptor: an unused
  piece slot of a storage table cannot be null).
- import_planned retires a taken-in import only while it is still over the plan's bytes (two
  segment plans of one admission may name the same old import).

Off alias isolation nothing of this runs.

Tests: test_segmented_domains (the 48 GiB A/B case at the real cap, the priced extra segment,
injected small caps, growth with the cuts kept, a moved cut after a handoff, frozen under workers,
a refused domain reported, nothing off alias isolation; fails on the planner commit and on the
archived base); test_resolver_coverage lists gpu_span_bind's bindings and holds future callers to
a failure branch.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 9fb9b3b4a60af49e54031d9c7707cdaf71fa5117)
The renderer-core merge (f216d287) moved code three fixtures cut out of the
sources. ae1d7812 updated the extractions it listed; these three it missed
(its named set held no nx2/ tests, and the other two already failed on its
base for the reason the previous commit fixes). The code moved; what it does
did not change, so each fixture follows it and keeps every assertion and case:

- nx2/test_cbuf_table_extent: ae1d7812 moved uniform_range's `used` lines,
  unchanged, into uniform_used(), which the resolver shares, and
  uniform_range calls it. The test searched for those lines after
  uniform_range( (ValueError). It now cuts uniform_used's body, from the
  same `uint32_t used = index < Shader::Info::MAX_CBUFS` line, and
  uniform_range's own `used` up to the driver banks, and runs both as
  uniform_range does.
- tests/test_parallel_rb_refresh: ae1d7812 moved build_descriptors'
  bare-entry guess and binding range into storage_binding() (b.addr, b.size,
  b.bare, ...). The fixture now cuts storage_binding's guess and range and
  build_descriptors' binding from them, through memory_note_gpu_write, the
  lines it cut before. 53b8914d made memory_note_gpu_write's scan stop by
  import_reach.h's live_reach, so the fixture includes the header, cuts
  live_reach and imported_add, and adds its pool through imported_add.
- nx2/test_import_windows: pool_lookup and the code above it now read
  53b8914d's import_reach (live_reach, imported_add, imported_take) and
  ae1d7812's admission state and alias isolation globals. The fixture cuts
  them (DemandClass and AdmissionStats from vk_internal.h), with isolation
  off as on Windows and Linux and alias_conflict false (as
  tests/import_reach_test.cpp does). Its retire_pool spy and its two seeded
  imports use imported_take/imported_add, the table's only ways in and out.

The three print what they printed at 58c5c5aa.

Published on main 6c9edffe (mac/renderer-core): #431 below this branch
already cuts live_reach, imported_add and imported_take into
nx2/test_import_windows and test_parallel_rb_refresh (its rb_table.inc), so
those two keep #431's cuts and add only what follows ae1d7812: the alias
isolation globals, DemandClass and AdmissionStats, and alias_conflict's
stub; storage_binding's cut.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 64a84e4dbc10017f44cd16fc0fbd7440135ee498)
Two findings from the renderer-core review (reports/2026-11-14-renderer-core-review.md), both in vk_replay.cpp's
parallel-batch admission. Neither path is reachable off Apple: admit_fallback and batch_admit are set only where
memory_admission_on() is true.

P2.5: a parallel replay's batch its dry pass could not know is recorded on the replay thread in order (Mode::Worker,
admit_fallback), each operation admitted as the serial replay admits it, except NOP_UPDATE_UB, whose worker branch broke out
before op_admit. Its destination is bound at the recording's end (flush_ub_updates -> gpu_write); one that needed a growth over
reached memory was refused by the guard and written on the CPU out of GPU order. The worker branch now admits it under
admit_fallback, before the update counts as passed, so a handoff there writes only the updates before it.

P3.1: a batch admitted at once whose admission hands off called admission_handoff() with rmode Serial, which wrote every listed
uniform update into the recording that ends, including the ones the batch's own dry pass had listed (from ub0). admission_handoff
takes the end of the updates to write (ub_end, default all: unchanged for every other caller), and the batch passes ub0.

Tests: tests/test_batch_admission.py (new) runs the NOP_UPDATE_UB case, op_admit, replay_admit, admission_handoff and the batch's
handoff statement cut out of the source; on bacb7c59 both of its cases fail for those reasons. tests/test_admission_handoff.py
cuts admission_handoff out by its name only (its signature gained the parameter).

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 39542f89f4443e14b70fb1e46bdaa3e0e330f2ea)
Findings P1 and P2.2 of the renderer-core review (reports/2026-11-14-renderer-core-review.md). Both are in vk_memory.cpp's
admission planner, which runs only under alias isolation (memory_admit returns at once elsewhere: macOS only).

P1, the page-table holes. A plan retires every live import it overlaps (retire_pool zeroes the import's whole range), but two plans
did not cover what they retired:
- the cohort (plan_cohort) retired the imports it overlaps and does not need without growing over them;
- a segment (seg_plan) retired an import that reaches into a segment no demand needs.
Shaders that follow the guest page table then miss on those bytes. The fix, by the rule that no byte reachable before an admission
is left without memory after it (design 5.5):
- admit_rest: the bytes of each retired import that no plan holds are planned with the first plan that retires it, imported
  exactly, before that plan's import (one that fails leaves everything as it was, as mirrors_trim does), and published after it;
  they keep the import's marks (gpu_only, gpu_written, trims, last_value) and count in the handoff's conflict test;
- seg_plan: a segment that replaces live imports makes every segment over those imports needed, to a fixpoint;
- the SegMem span takes in every live import over it, so a segment retires only imports its domain holds whole;
- the cohort's threshold is the device's import cap (max_import_size) under the admission, not kMergeMax: a merge up to the cap is
  one import (pool_lookup keeps kMergeMax: plan_import's merge_max defaults to it);
- admit_apply: a plan that is not made keeps the imports it would retire, and no later plan of the admission retires them.

P2.2, covered demands across a domain's boundary or cut. A range a live import already holds was left out of the domain: across the
domain's edge, the segment's plan retired its import and the guard then refused its binding; across a cut, one live import bound
it whole while gpu_span_pieces reported its pieces. The span's live-import take-in covers the first. For the second, a covered
range that straddles a cut seeds its domain's span (its segments are planned, the crossing import retired with the handoff the
conflict test asks for).

Tests: tests/test_admission_coverage.py with tests/admission_coverage_test.cpp (new; the admission cut out of the source, recorded
imports and page table, injected caps), cases merge-table, cohort-table, segment-table, domain-edge, covered-straddle. Each fails on
bacb7c59 for the reviewed reason (reports/CoreFix-files/repro-base-bacb7c59) and passes here on arm64 and x86_64.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit f29396c79600467b11fbb935a529a4c2511cf533)
Findings P2.1 and P3.3 of the renderer-core review (reports/2026-11-14-renderer-core-review.md). The code is Apple-only
(#if defined(__APPLE__) in vk_memory.cpp's admission planner).

P2.1: covering_backing returns the owner's backing for a demand across the end of the owner's view, and the view plan was
[max(want_lo, b->lo), min(want_hi, b->hi)): (a) at the owner's own key, imported_add kept the owner, the new view was destroyed,
memory_admit returned Ready and the guard refused the binding at every admission; (b) at another key, the view overlapped the
owner (eviction of either zeroed page-table entries the other served; seg_invariants broke inside a domain). A failed
darwin_import_view looped the same way. Now:
- view_plan grows the view over every live view on the same backing it overlaps; those are retired when it is made (the same
  memory: alias_conflict excludes it, no handoff; retired views are kept until their work is done) and their gpu_only and
  gpu_written marks carried. A live import of another memory over it means no view: the range is planned as pool_lookup imports it.
- admit_apply makes the view first, then retires what it takes in, then inserts it, and checks the insertion.
- A view that fails is logged, its backing is remembered (weak_ptr) as one that takes no view, and its range is imported on its own
  (the binding, or the guard's debt and the next admission's handoff), never as a view on it again.
P3.3: admit_st.reused counts only a view that went into the table.

Tests: tests/darwin_import_lifetime_test.cpp (the real Apple import code, test_darwin_renderer.py, both arches) gains spare-cross
(a range across a one-chunk owner's end), spare-reuse-count, spare-overlap (a two-chunk owner) and spare-view-failure (the driver
refuses a second buffer on a memory). Each fails on bacb7c59 for the reviewed reason (reports/CoreFix-files/repro-base-bacb7c59)
and passes here; the fixture takes --case NAME to run one alone (the driver's --process-exit is unchanged).

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 0c1d2aea03e17a3a10b0c1aa6ef69461d8d405d9)
Finding P2.4 of the renderer-core review (reports/2026-11-14-renderer-core-review.md). Under alias isolation (macOS) bg_drain
dropped a background import only when it overlapped a live import or one sat at its start; it never asked alias_conflict, so an
import over bytes a memory retired, evicted or queued dead in the current handoff epoch still held (alias_ref) was published:
two memories over the same bytes in one epoch, the stale read alias isolation prevents. It could also publish a chunk across a
segmented domain's cut. Reachable only with the opt-in background imports (RX_VK_PREFETCH_CHUNKS, RX_VK_ASYNC_IMPORT,
RX_VK_BG_IMPORT).

Under alias isolation bg_drain now also drops (counted in st_prefetch_dropped) an import when alias_conflict holds over its
memory's bytes (on Apple its whole backing), or when it is over a segmented domain (seg_overlaps). The new branch runs only when
alias_isolation is set: off Apple the condition is unchanged. alias_conflict's and seg_overlaps' declarations move up beside the
alias isolation globals, and seg_overlaps' definition into SegMem's state block (above pool_lookup), so bg_drain (and every
fixture that cuts it out with them) sees both.

Tests: tests/admission_coverage_test.cpp gains bg-epoch (an import given back after work reached it, a background import of its
bytes), bg-domain (a background chunk across a cut after the segments were given back) and bg-elsewhere (off alias isolation,
published as before); tests/darwin_import_lifetime_test.cpp gains apple-bg-epoch (Apple's alias_conflict, real import code). The
first two and apple-bg-epoch fail on bacb7c59 for the reviewed reason (reports/CoreFix-files/repro-base-bacb7c59); all pass here
on arm64 and x86_64.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 755ac7e8757fdd30d4809ae1142e51232fae2a9e)
Finding N1 of the core-fix review (reports/2026-11-14-core-fix-review.md). f29396c7 let the admission merge imports up to the
device's import cap (plan_import(..., max_import_size), and the plans' merge at max_import_size). mirror_make refuses a VRAM mirror
for any pool over 1 GiB, and mirrors are on by default for a discrete, non-GCN GPU (an Intel Mac with an RDNA Radeon, which alias
isolation also covers) and with RX_VK_MIRROR=1: such a merged pool lost its mirror, every binding in it reading host memory.

The cap was never needed for correctness once admit_rest keeps a cohort's leftover bytes mapped, so the admission's threshold is
kMergeMax again (cohort plus leftovers past it). plan_import loses the merge_max parameter that only the admission used; it, its
comment and pool_lookup's calls are as on bacb7c59. No correctness reason to keep the cap was found: a cohort and its leftovers
import the same bytes a merge would, each piece no larger than the import it came from.

Off Apple: unchanged (admit_plan runs only under alias isolation; plan_import is textually bacb7c59's again).

Tests: tests/admission_coverage_test.cpp gains merge-mirror (the seam of two live 768 MiB imports at the real cap: no import the
admission makes is larger than mirror_make's limit, which the driver reads from mirror_make itself, and every byte stays mapped).
It fails on 755ac7e8 for that reason (reports/CoreFix-files/repro2-base-755ac7e8) and passes here on arm64 and x86_64;
merge-table (the same seam: every byte mapped, one handoff) passes on both.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 9ccc7c19864991be11d4437211eecf3e430b2a7d)
Finding N2 of the core-fix review (reports/2026-11-14-core-fix-review.md). f29396c7's closure in seg_plan marked every segment over
an import a needed segment retires as needed, and each such segment was then imported whole (up to the device's cap, 41.7 GB of
guest address space on an M4 Max) only to keep that import's few bytes in it mapped; and when such a forced segment's import failed
after the needed one's, the crossing import was already retired.

The closure is gone: seg_plan plans only the segments the operation's ranges need. The retired imports' bytes in segments no range
needs are leftovers, as a cohort's are: admit_rest now takes segment plans too and cuts every piece that lies in a domain planned in
this admission at that domain's cuts, so each piece lies in one segment, crosses no cut and overlaps nothing (invariant 1 holds),
and costs no more than the import it came from. They are made before the segment's own import and published after it retires the
import (admit_apply, unchanged), so a failure leaves everything as it was.

Off Apple: unreachable (seg_plan and admit_rest run only from the admission, under alias isolation).

Tests: tests/admission_coverage_test.cpp gains segment-rest (a chunk across the cut into a 100 MiB segment nobody needs, retired by
the segment the operation needs: its 4 MiB there stay one import clipped at the cut, nothing else of that segment is imported, every
byte stays mapped, no import crosses a cut). It fails on 755ac7e8 for that reason (reports/CoreFix-files/repro2-base-755ac7e8) and
passes here on arm64 and x86_64; segment-table, domain-edge and covered-straddle (the other segment cases) pass on both.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 596f0ff4063da99505e7d5df789580fb4cde89f7)
Open item 1 of the core-fix review (reports/2026-11-14-core-fix-review.md), more reachable after N1 (9ccc7c19): with mirrors on
(a discrete, non-GCN GPU such as an Intel Mac's RDNA Radeon, or RX_VK_MIRROR=1), an import an admission retires whose bytes go to
several new imports (a cohort and that import's leftovers, or two segments over an import across their cut) handed what the GPU
wrote into its mirror and nowhere else to the first of them whose mirror was made only: mirror_make ends a retired pool's wait unless
it is trimmed, so the others' mirrors were filled from guest memory without it.

admit_apply now does what mirrors_trim does for a trim's parts:
- it counts, per import the plans retire, the new pools its bytes go to (the plans over it and its leftovers);
- a retired import with more than one, whose mirror was on (inherit_pending, set by retire_pool), is marked trimmed and counted in
  trims_waiting once the plan that retires it is made, so each new pool takes its share when its mirror is made, and trims_settle
  lets it go once every live pool over its range has its own (or after kIdleEpochs, as before);
- a leftover of an import whose mirror was on wants a mirror at once (MIRROR_WANTED, in mirror_wanted), as a trim's parts do.
trims_waiting is declared ahead of the admission region (its definition, with mirrors_trim, is unchanged).

Mirrors off (Apple Silicon's default): no pool is ever MIRROR_ON, so retire_pool sets no inherit_pending, nothing is marked and no
mirror is wanted: unchanged (the counting runs, without effect). Off Apple: unreachable (admit_apply runs only under alias isolation).

Tests: tests/admission_coverage_test.cpp gains mirror-cohort (the seam's cohort retires a mirrored import: the cohort and the leftover
both inherit, the leftover wants a mirror, trims_settle, cut out of the source, lets the old one go) and mirror-segments (two needed
segments over a mirrored import across their cut); mirror_made models mirror_make's inheritance, whose two tests the driver checks
are in mirror_make verbatim. Both fail on 596f0ff4 for the reviewed reason (reports/CoreFix-files/repro3-base-596f0ff4) and pass here
on arm64 and x86_64; mirror-off (mirrors off: nothing marked, nothing wanted) passes on both. alias_admission_test.cpp,
segmented_domains_test.cpp and darwin_import_lifetime_test.cpp get a trims_waiting stub only (no assertion changed).

Published on main 6c9edffe (mac/renderer-core): darwin_import_lifetime_test's
trims_waiting stub sits beside #491's sparse_wanted stub (main's #250).

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 6aa7c2af0fb4e9d0dc57d6eeb8018aaac07ed234)
Main's #250 (sparse mirrors) makes retire_pool drop a retired pool from
sparse_wanted, the pools a binding found a sparse block with no memory in.
test_alias_admission, test_segmented_domains and test_admission_coverage cut
retire_pool out of vk_memory.cpp with the admission, so their fixtures did
not compile on main 6c9edffe ("use of undeclared identifier
'sparse_wanted'"). Each gets the stub darwin_import_lifetime_test already has
(#491). No assertion changes.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Main's #59 (actor motion pairs a model's copies by place) takes a skinned
draw's pose as the draw read it (temporal_note_skinned_draw, ub_read), through
a host-visible stage, and copies the bones and origin from the game's memory
only for a draw whose pose was not read, or with no stage. actor_reads and
temporal_actor_demands (alias isolation's resolver for the upscaler's actor
motion) still demanded the bones and origin of every skinned draw: a
superset, so admissions on macOS could import or hand off for bytes nothing
binds.

pose_read_at_draw(s, k, staged) is now the one test, actor_motion's (with its
stage) and the resolver's (with the stage actor_motion takes next): the
resolver demands a draw's bones and origin only where actor_motion copies
them, and its vertices and indices always. With no stage made yet the
resolver demands the bones and origin too, since actor_motion's making of the
stage may fail. test_resolver_coverage holds both to that test.

Off alias isolation (Windows, Linux) the resolver does not run, and
actor_motion's test is the same expression as before.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
test(vk): test_split_ub_flush follows flush_ub_updates' end parameter
Some checks failed
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been skipped
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been skipped
Third-party parts / The same as upstream (pull_request) Has been skipped
Website / Website on Cloudflare (pull_request) Has been skipped
CI / Conventional Commit title (pull_request) Successful in 8s
CI / Workflow lint (actionlint) (pull_request) Successful in 12s
Guide screenshots / Guide images and local UI source review (pull_request) Failing after 14s
Third-party parts / As their notes say (pull_request) Successful in 25s
PR description / Description follows the template (pull_request_target) Successful in 5s
61766e3eca
Main's #189 check reads flush_ub_updates by its signature. The admission (6e765c8d) gives it an end, the first update not
written, for a handoff in a dry-passed command buffer, defaulting to SIZE_MAX, so the check's anchor was not found and it
stopped with a ValueError. It now reads the new signature and still requires what it guarded: the writes start at
ub_first_of_recording and, with the default end every caller in maybe_split and finish_recording uses, reach the last
update (end = std::min(end, ub_updates.size())).

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This PR needs the texel codec PR (#P21: texel_codec::Plan, which
texel_view_pieces takes) and the renderer-core PR (#P19, the base:
SegMem, gpu_span_bind, SpanLayout). Both are kept as they are; the
merge had no conflicts.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
feat(vk): the segmented consumers' frozen contract (C0)
Some checks failed
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been skipped
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been skipped
Third-party parts / The same as upstream (pull_request) Has been skipped
Website / Website on Cloudflare (pull_request) Has been skipped
CI / Conventional Commit title (pull_request) Successful in 8s
CI / Workflow lint (actionlint) (pull_request) Successful in 12s
Guide screenshots / Guide images and local UI source review (pull_request) Failing after 11s
Third-party parts / As their notes say (pull_request) Successful in 22s
PR description / Description follows the template (pull_request_target) Successful in 6s
4c16dbc4cc
The interfaces the four segmented-consumer lanes (SegBind, SegXfer,
TexelDesc, NullShader; reports/2026-11-14-seg-consumer-plan.md section 3)
code against, frozen before they fork. Inert: no caller of anything new,
no SPIR-V, layout, push range or stream change, sizeof(Program) unchanged.

- vk_internal.h / vk_memory.cpp, SegMem's consumer interface:
  gpu_span_any() (a relaxed count of the live domains, stored in
  seg_commit; domains are kept for the run), gpu_span (gpu_buffer's
  flavour of gpu_span_bind: import buffers, no written marks, no mirror),
  SpanLayout::bind_stamp (gpu_bind_stamp(): a segment can be imported
  again with its cuts kept, so cut_gen alone never keys piece buffers),
  span_unused_piece's semantics ({null_storage, 0, 1}), and the SegArena
  API (SegArenaRef, seg_arena_take, seg_gather, seg_scatter; stub bodies
  after stream_alloc that give no room and log, SegBind's to fill).
- Review fixes in the same interface (renderer-core review P2-3, P3.2):
  every piece of gpu_span_bind carries its buffer's device address (the
  import's or the mirror's; 0 only with bufferDeviceAddress off), and its
  one-piece path stores the translated guest address, not a virtual one.
- vk_texture.h/.cpp: texel_view_pieces / texel_piece_views, stubs that
  return 1 and false (TexelDesc's to fill).
- vk_shader.h: Program::presence_words, in what was padding.
- Back end: PRESENCE_PUSH_OFFSET (88), Profile::emulate_null_descriptors,
  Bindings::presence / presence_read with the bit numbering rule.
- tests: test_resolver_coverage lists gpu_span_bind / gpu_span sites too
  (scoped to gpu/vk: gpu/render has an rx::gpu_span of its own), checks
  that gpu_span follows its own gpu_buffer, and splits SITES into
  SITES_BASE plus one block per lane. test_segmented_domains executes the
  contract (the count 0, 1, 2 -> 1 on a merge, 0; gpu_span's pieces;
  addresses with the fixture's gpu_buffer_bind returning none as the real
  one does; the bind stamp; a virtual address's guest). test_seg_shader
  accepts a base tree of this back end's generation and compares every
  module.
- docs/segmented-binding.md: the gate (every new behaviour Apple-only,
  other platforms byte-identical) and one heading per lane.

Conflict on this branch (not with main): tests/test_seg_shader.py's
docstring, against the array_pointer_calls paragraph #P20's
msl-arrayptr port added; both kept, this paragraph first.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 95fa06d4e6526601bd2e1bc88c2f8f5720855350)
The segmented binding PR needs the MoltenVK descriptor-set fixes (#P17
storage buffer tables, #P26 storage buffer lengths; #P28 read_write
textures, which it lands after): ensure_layout's layout_pushes decides
the push, and seg-bind's counts go through it. Their commits are kept as
they are; the merge had no conflicts.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The renderer's half of plan section 6 (reports/2026-11-14-seg-consumer-plan.md), on B2 08cccafb:

- vk_texture.cpp texel_buffer_view(t, v, storage, plan): the plan's view (the format's own, an alias, a typed
  access's declared format, or an R8/R16/R32 UINT carrier), whole-texel ranges at most maxTexelBufferElements of
  the view's elements, plan.zero as a cached 16-byte zero view; key (format, offset, range) with the backing as
  before, every view through one cache helper (texel_view_locked).
- texel_view_pieces / texel_piece_views (C0.4): cut at a segmented domain's cuts (span_segments::texel_pieces, in
  view elements) and, on Apple, at runs of maxTexelBufferElements; each piece view on its segment's buffer (gpu_span
  after gpu_buffer); a stale count, an unbound piece, a seam or a written seam refused loudly (the element absent).
- gpu/vk/null_dummies.h (new, pure): the stand-ins' tables by view type x class x multisample, storage format, and
  the texel zero view's format from the variant's texel answer. vk_texture.cpp null_dummies_make makes them once
  (1x1 zero images, views, four samplers, texel zero views; vkGetPhysicalDeviceImageFormatProperties first; one
  clear submit), only on Apple without nullDescriptor (make_dummies).
- vk_draw.cpp: texel_views with texel_plan / texel_plan_native, P piece views for a segmented variant's entry, the
  zero view of the declared format for an absent element; sampled and storage-image stand-ins, null samplers, the
  default sampler for a real view whose sampler failed; DescWriter presence bits (C0.8 numbering), the graphics
  words kept per thread, push_presence at offset 88.
- Non-Apple, and Apple with nullDescriptor: writes byte-identical to B2 (checked against the B2 cut).

Tests: test_texel_buffer_ownership (extended), test_texel_views_plan, test_null_dummies, test_null_descriptors,
test_null_layout_push (new), SITES_TEXEL; docs: texel-buffer-formats.md (new), texel-buffer-ownership.md,
segmented-binding.md TexelDesc.

Conflict on main: tests/test_texel_buffer_ownership.py's eviction cut
ends at imports_give_back's "(longshot) Default by OS and vendor"
comment, as main's #324 reworded it ("Off by default" before).

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 36fe0a1359b31095fcb8d40d3c2f502a105d6b1b)
A failed case printed only a CalledProcessError; it now prints the fixture's own FAILED line and the case.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit cb8dea7c94ef0b0c002c9102d6c78c48bc778b7e)
On a segmented domain (macOS), vk_draw.cpp now binds storage descriptors across a segment's cut as piece arrays in the
segmented variant program_segmented makes, gathers uniform buffers and fixed-function inputs into a per-recording arena at
the pre-pass point, and fills C0's arena stubs. On Apple, unbound storage descriptors take a zero-length range of a new
64 KiB null_storage buffer (span_unused_piece set), and unbound uniform buffers take 64 KiB of zero_buffer, which loses
its STORAGE usage. Other systems keep every write, layout and push range as before.

- ensure_layout and the descriptor counts take piece counts from Program::seg; push range 88 + 4 * presence_words
- build_descriptors: single, table (k*P + j, prefix rule), bare/wide/shrink per piece; a refused array writes nothing
- uniform_range: gather queued (SegBuild), null_ubo on Apple
- draw()/dispatch(): seg_sig_of + program_segmented after the seam, always waited for; same_desc never reuses a
  gathered or stale piece set; vertex/index/indirect/mesh-args/dispatch-args gathers; index extent demanded (O9)
- pipeline_note and the compute notes skip segmented variants
- tests: test_seg_bind (new), resolver coverage SITES_BIND, and the fixtures and anchors the change moved

The next commits add test_seg_gathers, test_seg_notes, the desc_counts, push_state and vertex_bind
extensions and the docs section. (Reworded from the topic's "wip:" commit; the content is the topic's.)

Conflicts on main 6c9edffe: #252's compute-variant path in dispatch()
(program_cache_complete_ask, on_worker, made_how; compute_optimize at
the bind) meets this commit's rename of the pre-seam program to
`plain`. #252's lines are kept here as main has them, naming `p`; the
notes take SegBind's guard and #252's optimize and bind follow it.
They follow the rename in this PR's last commit, which vk_draw.cpp
needs to compile; tests/test_program_cache_one_variant.py the same.
tests/test_set_for_tables.py (#P28's read_write cases): CASES counts
both sides' cases.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit f40b77f71a47ee130cbe0df476d5e59f00fcef03)
draw()'s pre-pass point cleared only the gathers an earlier build had queued when the draw reused its descriptors. The build's
lost flag stayed: a build or signature that was refused (seg_lost: no arena room, a piece array that could not be written, a
descriptor in more than 255 pieces) left its own draw out, and then every later draw that reused the previous set while a domain
existed was left out too, with no log line of its own. The whole SegBuild is now reset there (its gathered/pieces/stamp fields are
read only at the bind of a fresh build). Apple-only in effect: off alias isolation no build is ever refused.

tests/test_seg_gathers.py's "stale" case fails without it.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit a38cf86cc766d3254539965265c9bf3d56123b87)
Plan 4.3 items 2, 3, 6 and 7 (reports/2026-11-14-seg-consumer-plan.md):
- tests/test_seg_gathers.py + tests/seg_gathers_test.cpp: the source's own cuts (RecordingRes and the arena with
  draw_begin_recording's reset and give-back, SegBuild, the size helpers, the bound-state caches, and draw()'s mesh-args
  rewrite, pre-pass gathers, vertex streams and indirect/indexed binds; dispatch()'s grid gather and binding) over a command
  recorder and SegProbe's domain. 76 checks: the order end_pass, barrier, copies, barrier, reader; vertex sized and WHOLE
  (clipped to the domain); direct indices; the indexed-indirect extent; records across a cut; native mesh records; the
  mesh-args rewrite per piece; a queued uniform gather; the 12-byte grid; count words in place and a straddling one left out;
  aligned arena offsets; arena made, grown, reset, given back, and full (a loud skip); nothing kept in kept_vb, vertex_set or
  last_ib; no stale refusal after a reusing draw. --base compares the common path's recorded commands with B2's cut; --json
  writes seg-gathers.json for SegBindProbe. Against B2 (--source) it builds and fails.
- tests/test_seg_notes.py: pipeline_note and dispatch()'s note lines cut out: a segmented variant makes no state_record,
  PipeNote or compute note; a plain one is noted as B2's cut is (--base).
- test_desc_counts.py, test_push_state.py, test_vertex_bind.py: --source, and SegBind's source rules (piece counts with
  seg_layout_count; the push range kPushSize + 4 * presence_words and uses_push; gathered streams and indices never reach
  vertex_bind_reuse, kept_vb, vertex_set or last_ib). Each fails against B2.
- docs/segmented-binding.md: the SegBind (L1) section.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit a0eccd31253378f8beb5963da4d4a6e5f340e670)
The segmented single storage array's case (plan 4.3 item 5) had no negative: the check always cut its own tree. With --source
<B2 archive> it fails as required ("moltenvk.segmented_single want=set got=push").

Conflict with #P28 (read_write textures; not with main): the docstring
keeps #P28's paragraph with this usage line, and main() cuts from
--source's vk_draw.cpp with #P28's read_write count (as in the
macOS integration, Integration 17).

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 5824f6828e21cf810dcceaa6be425a9224b15994)
SegBind renamed draw()'s program for the bound state to `plain` (after the seam `p` is it or its segmented variant), so the
source rule "topo = pipeline_topology(p, topo);" no longer matched and the check failed in the full suite (a new failure
against B2's full run). The rule now reads `plain`; what it checks is unchanged.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 2e1eb52102ae7a889af7a0aa770f448833d36ebf)
ensure_layout, which the fixture compiles from vk_draw.cpp (kPushSize
to its end), now counts each descriptor through SegBind's
seg_layout_count and grows the push range by Program::presence_words.
test_set_for_lengths (mvk-sizes) was written after SegBind's base, so
its fixture had neither, and it no longer built after the merge of
SegBind into macos/integrated (a34be2da). It gets the same stubs
SegBind gave test_set_for_tables' fixture: presence_words = 0, and a
seg_layout_count that keeps every count (its modules are plain
variants). No assertion changed.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 071c628f14ec46d0c55b1e5ef1a2941040a362aa)
A guest range across a SegMem segment's cut is never one memory: gpu_buffer_bind
and gpu_buffer return it empty. Each SegXfer consumer's failure branch now binds
it piece by piece (gpu_span_bind, or gpu_span for the unswizzle's gpu_buffer
reader) after its operation's admission, and records the work per piece or
through the recording's SegArena (C0's declarations; SegBind fills the bodies):

- vk_replay.cpp: gpu_write as one vkCmdUpdateBuffer per piece (unaligned stays
  on the CPU); NOP_COPY_B2B as regions between the sides' pieces, one
  vkCmdCopyBuffer per buffer pair; NOP_CLEAR_BUF as one fill per piece.
- vk_xfer.cpp: B2T gathers the span into the arena and copies from it; T2B
  gathers in first (row padding keeps the guest's bytes), copies the image into
  the arena and scatters every byte back; bufferOffset a multiple of the texel
  block; stamp and texture_gpu_wrote over the span.
- vk_texture.cpp: the GPU unswizzle gathers its source into the arena in the
  upload command buffer, ahead of the dispatches, at the storage alignment.
- vk_temporal.cpp: the actor's bones and origin copied per piece, its vertices
  and indices gathered and read through the arena's device address.
- vk_draw.cpp: the frame dump, StatsQuery copies and mesh-indirect convert per
  piece; the mesh fallback's 8-byte indirect input gathered, by address.

No arena room is refused loudly (error log, counter), never silently. Every
path tests gpu_span_any() first: with no domain (all platforms but macOS under
alias isolation) every consumer records exactly what it did before, which the
new fixtures check against B2's recorded commands. The arithmetic is in the
new pure header gpu/vk/span_xfer.h.

Tests: tests/test_seg_xfer.py (+ seg_xfer_test.cpp), copy_b2t in
tests/test_vulkan_copy_span.py (+ copy_b2t_span_test.cpp), the gpu compile
fixture given SegMem's types, and SITES_XFER in test_resolver_coverage.py.
Docs: docs/segmented-binding.md § SegXfer.

Conflict on main 6c9edffe: vk_temporal.cpp actor_motion, #59 (actor
motion pairs copies by place): a pose read at its draw is copied from
the stage (placed), and only otherwise from the game's memory. The
piece-by-piece copy of a pose across a segment's cut goes in that
else branch, where the bones and origin are bound; the kept pose
takes #59's placed and place.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit db3c9bf0c7c9181001522991d2447321362b5724)
Semantic rework for main 6c9edffe, after the picks. SegBind renames the
program for the bound state to `plain` before the admission seam (after
it, `p` is that program or its segmented variant). Main's #252 added
the compute-variant path on those lines (program_cache_complete_ask,
on_worker, made_how), kept as main has it in the seg-bind pick, which
left vk_draw.cpp not compiling ("use of undeclared identifier 'p'", 7
lines). Those lines now name `plain`: the CAS and the ask, the job, the
cached pipeline, and the census and give-up after the 50 ms wait.

Behaviour is #252's: the plain program is the one read without its
code and made whole there. A segmented variant is translated, not read
from the cache, and compiled synchronously after the seam through
cached_compute, which takes #252's Intel gate itself (Windows Intel
only; segmented variants exist on macOS only). At the bind, #252's owed
optimized build and compute_optimized stay on `p`: a variant is never
built quick, so it binds its own pipeline.

The checks that read these lines follow:
test_program_cache_one_variant (its first-CAS, cached-pipeline and
complete anchors; its code_words check now looks for both names),
test_intel_compiler_gate (the ask's anchor), and test_seg_notes, whose
cut of the compute notes now ends where #252 queues the owed optimized
build (at the bind on a tree without it; its between() helper went with
the old cut). No check is weakened.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Fixture follow-up for main 6c9edffe. tests/test_seg_gathers.py cuts
draw_begin_recording into its fixture, and main's #162 (the recorder
thread's own slot resets, RX_VK_RECORDER_OWN) marks the descriptor pool
reset there with a rec::OwnSlot scope, so the fixture did not build
("use of undeclared identifier 'rec'"). It gets the stub the other
fixtures that cut that function have (draw_stream_visibility_test,
recording_admission_test): a scope that does nothing. No check changed.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Semantic rework for main 6c9edffe. Main's #59 (actor motion pairs
copies by place) takes a pose read at its draw through a stage, and
copies the bones and origin from the game's memory only otherwise;
#P19's rework (pose_read_at_draw, shared by actor_motion and
temporal_actor_demands) makes the resolver demand them only there. The
SegXfer pick puts the per-piece pose copy in that else branch.

- tests/seg_xfer_test.cpp: the fixture's State takes #59's members
  (Skin::pose and kNoPose, pose_bytes, poses_time, Pose::placed and
  place, pose_stages, pose_stage_at, poses_read, poses_copied) and the
  <cmath> and <unordered_set> actor_motion now needs; its skins keep
  kNoPose, so its cases still take the copy from the game's memory,
  across a cut as before.
- tests/test_seg_xfer.py cuts pose_read_at_draw into the fixture too
  (when the tree has it).
- tests/test_resolver_coverage.py: SITES_XFER's four actor_motion sites
  expect the resolver's loop as SITES_BASE's do since #P19's rework
  (from pose_read_at_draw's 2 or 0), and the bones' site the same
  decision in both functions.

28 of 28 SegXfer cases and 74 binding sites in 19 functions pass again;
no expectation was dropped.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
test(vk): SegXfer's gpu_write checks follow #329's ordered inline writes
Some checks failed
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been skipped
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been skipped
Third-party parts / The same as upstream (pull_request) Has been skipped
Website / Website on Cloudflare (pull_request) Has been skipped
CI / Conventional Commit title (pull_request) Successful in 13s
CI / Workflow lint (actionlint) (pull_request) Successful in 17s
Guide screenshots / Guide images and local UI source review (pull_request) Failing after 13s
Third-party parts / As their notes say (pull_request) Successful in 20s
PR description / Description follows the template (pull_request_target) Successful in 6s
c22c9a9a11
Fixture follow-up for main 6c9edffe. Main's #329 (order overlapping
inline buffer updates) puts a full barrier before each vkCmdUpdateBuffer
in gpu_write, and tests that with test_inline_write_order, which cuts
gpu_write alone.

- tests/test_inline_write_order.py: SegXfer's span path in gpu_write
  calls gpu_span_any, gpu_span_bind and gpu_write_pieces; the fixture
  stubs them as they are off macOS and with no domain (gpu_span_any()
  false), so it tests #329's order as before. Its three variants keep
  their results (the barrier and the work count still caught).
- tests/test_seg_xfer.py: replay.gpu_write.common, recorded on the
  topic's base (08cccafb) without #329's barriers, is recorded again:
  the common path at this branch equals its base's (--base), on arm64
  and x86_64. The other eight common digests are unchanged.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
MoltenVK 1.4.2 widens 8-bit indices to 16 bits on the GPU before every
indexed draw, into a temporary of twice the bound size, and a bind
without a size reaches the buffer's end (MVKCmdDraw.mm
convertUint8IndexBuffer, MVKCmdBindIndexBuffer::setContent). LS binds a
guest pool's whole VkBuffer at the indices, so a six-index draw in a
64 MB pool widened 64 MB into a 128 MB shared MTLBuffer. Each command
pool keeps every such region one command buffer held at once, resident
and purgeable, until the pool is destroyed (MVKMTLBufferAllocation.mm);
vkTrimCommandPool and vkResetCommandPool(RELEASE_RESOURCES) keep them.
With LS's 48 recording pools that is run #23's 128 MB IOAccelerator
regions (98 at the title, 385 after the A) and the GPU's wire limit.

On MoltenVK's driver alone (vendor fix index8_size, RX_VK_INDEX8_SIZE),
with VK_KHR_maintenance5 enabled for it, a direct draw's 8-bit indices
are bound with vkCmdBindIndexBuffer2KHR and their own count: MoltenVK
widens exactly the indices the draw reads, at the same point of the
command buffer. Indirect draws keep the bind to the pool's end (their
indices are read where the GPU finds them). Every other driver binds as
before. A MoltenVK-only line with the frame statistics counts the 8-bit
draws and what their pools held past the indices.

Tests: tests/test_index8_bind.py (new; fails at 78e403de), the index
bind cache keyed on size (test_draw_binds.py), the recorder's coverage
of vkCmdBindIndexBuffer2KHR and the SegBind fixture's fake.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 0fdc1cc83dc3def6918a2ae08f35afca43d4962d)
feat(vk): count polygon modes and restart-off strip draws on Apple, log-only
Some checks failed
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been skipped
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been skipped
Third-party parts / The same as upstream (pull_request) Has been skipped
Website / Website on Cloudflare (pull_request) Has been skipped
PR description / Description follows the template (pull_request_target) Successful in 14s
CI / Conventional Commit title (pull_request) Successful in 10s
CI / Workflow lint (actionlint) (pull_request) Successful in 13s
Guide screenshots / Guide images and local UI source review (pull_request) Failing after 14s
Third-party parts / As their notes say (pull_request) Successful in 18s
Website / Website on Cloudflare (pull_request_target) Has been skipped
648232f043
KosmicKrisp has no fillModeNonSolid and MoltenVK no POINT polygons, and Metal restarts an indexed strip or fan at the
all-ones index whatever Vulkan's restart enable says. Before anything is fixed, count whether the game needs it
(mode_census.h, Apple only; nothing built or drawn changes):

- the graphics pipelines built, where st_pipelines counts them, and the draws recorded, where st_draws counts them, by
  NVN's polygon mode, with the first 8 LINE and POINT pipelines (program, pipeline key, topology) and programs named;
- the indexed strip and fan draws with restart off; a direct one's indices are read whole, a page at a time, through
  memory_read_as_submitted for the index type's all-ones value (0xFF, 0xFFFF, 0xFFFFFFFF). An indirect one's range is
  the GPU's and a range the CPU cannot read is not guessed: both are counted as not scanned.

Any other draw costs restart_off_strip's compares and its polygon mode's count on the thread's own counters. Printed
with the frame statistics every 120 presents ("at present N") and at close ("at close"). The two tests that compile
build_graphics and draw()'s binds from the source get a stand-in for the counts.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 1bd30dceb348909ca03c4cf12709dccbb0900f4a)
docs(macos): RX_VK_INDEX8_SIZE in the switch table
All checks were successful
PR description / Description follows the template (pull_request_target) Successful in 10s
Website / Website on Cloudflare (pull_request_target) Has been skipped
76cdd7a703
The topic added the vendor fix's switch without a row; docs/development.md's
switch table gets one beside RX_VK_SET_FOR_LENGTHS: on by default on MoltenVK
only, what it binds, and what MoltenVK did without it.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
MoltenVK 1.4.2 reports drawIndirectCount off, and its vkCmdDrawIndirectCount and
vkCmdDrawIndexedIndirectCount are empty functions (vulkan.mm:2390-2414). draw() called
them whenever the game's draw had a count address, so on the Mac every such draw was
dropped without a word (MT-01's u8_indirect_count step drew nothing).

Where the device lacks the feature (and only there; RX_VK_COUNT_REWRITE=1 forces it on
a device with it, to compare), a count draw's records are rewritten at draw()'s
pre-pass point, after the segment gathers and outside the pass, between two full
barriers: shaders/count_draws.comp copies the game's records under the count the GPU
reads into the recording's arena and zeroes the rest up to maxDrawCount (instanceCount
0: nothing drawn), and a plain vkCmdDraw(Indexed)Indirect draws maxDrawCount records
from there. Record i keeps its draw index and its own words, the count is clamped to
maxDrawCount by construction, and records at or past the count are never read. A draw
the rewrite cannot make (records off 4-byte boundaries, no pipeline, no arena room) is
left out loudly, never given to the empty function. Devices with drawIndirectCount
record what they did before.

A vk: count draws line with the frame statistics counts them a frame (indexed, 8-bit,
tessellated, pass breaks, maxDrawCount), only where the rewrite is on; vk_main says
when the device has no drawIndirectCount.

tests/test_count_draws.py runs the shader's own record code (count_draws_record.glsl,
shared with the shader) and count_draws.h's dispatch plan on the CPU over every count
from 0 to past maxDrawCount, strides, offsets, alignments, indexed and not, split
dispatches; checks the SPIR-V header against its source and the renderer's source.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit b97c61c845eb98bdd0fae4a7f2ea117cf2434d8e)
The full suite at b97c61c8 failed two checks the change broke:
- test_mode_census holds the census call within three lines of draw_skip_report: the
  count draws' report now has its own block after the census's.
- test_seg_gathers compiles draw()'s pre-pass and indirect binds from the source: its
  fixture gets count_draws.h, the draw's topology and a count_rewrite stand-in that
  records what the rewrite is given, and a case on a device without drawIndirectCount:
  the rewrite reads SegBind's gathered records and the count in place, after the
  gathers' barrier and before the pass, and the draw reads the arena's rewritten
  records with the plain indirect call, never the driver's count draw.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit f09886c799272b781b2c3c1d4d99eed8da2719be)
MoltenVK (1.4.2, its newest release) stages vkCmdUpdateBuffer's bytes in its
command pool's temporary MTLBuffers when it encodes, and every direct draw
whose pipeline reads the draw index zeroes the first word of the temporary
MTLBuffer its draw-index word lies in, not that word (MVKCmdDraw.mm:212 and
:495; fixed upstream after the release in bc0d5bf733). An update staged at
that buffer's start lost its first word before the GPU copied it. CR-01
(reports/2026-11-14-copy-race.md) measured it: all 1,010 failing trials lost
exactly the vkCmdUpdateBuffer word, in both argument-buffer modes, only with
a direct draw encoded after the update in the same pool (none with the pass's
draw left out or recorded first), while copies from the probe's own memory
landed in every trial.

cmd_update_buffer (vk_draw.cpp) records the renderer's buffer updates: on
MoltenVK the bytes go into the recording's stream (16-aligned) and a
vkCmdCopyBuffer from there takes the update's place in the command order;
without room, outside the recording's command buffer or with no recording it
records vkCmdUpdateBuffer as before, logged and counted. Every other driver
records vkCmdUpdateBuffer exactly as before. gpu_write and gpu_write_pieces
(vk_replay.cpp: event signals, uniform updates, the stream's writes) record
through it. The page table's update (vk_memory.cpp memory_table_flush) stays:
it is in the upload command buffer, whose pool records no draw.

Tests: tests/test_update_staging.py cuts cmd_update_buffer out of the source
and runs it on a model of the stream and of MoltenVK's clobber (fails before:
cmd_update_buffer is missing), and checks that gpu/vk has no other
vkCmdUpdateBuffer. tests/seg_xfer_test.cpp's model is not MoltenVK: its
cmd_update_buffer is vkCmdUpdateBuffer, and the common path's commands are
the base's (COMMON_SHA256 unchanged). VULKAN.md says so.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit e0e1ea92ed2f6e9e6d15ce1a088125cd8b6b34d9)
MoltenVK (1.4.2, its newest release) takes a 4-byte temporary for a direct
draw's draw index (vkCmdDraw, vkCmdDrawIndexed of a pipeline reading
gl_DrawID) and zeroes the first word of the temporary's whole MTLBuffer
instead of that word (MVKCmdDraw.mm:212 and :495; fixed upstream after the
release in bc0d5bf733). The draw reads whatever its region last held, and
the word it zeroes can be another command's live bytes (CopyRace: a staged
vkCmdUpdateBuffer's first word). Its indirect draws write each record's
index into their own region (:810, :1141).

On MoltenVK, draw() now records a direct draw of a program whose stages
read the draw index (Shader::Info loads DrawID, for which the SPIR-V back
end declares BuiltIn DrawIndex) as a one-record vkCmdDraw(Indexed)Indirect
from the recording's stream (drawid_indirect, 16-aligned, cmd being the
recording's). The record holds the direct draw's own parameters word for
word (drawid_direct.h), and the direct call is made from the same words, so
both forms draw the same; one record has draw index 0, as a direct draw.
The three sites are the plain and indexed direct draws and the quads.

Kept direct, by design and counted: empty draws (MoltenVK encodes nothing),
tessellated draws (the indirect form takes MoltenVK's indirect tessellation
temporaries, about 160 MiB a draw, and is bounded at 128 Ki vertices),
triangle fans (MoltenVK already draws a fan pipeline's direct draw as an
indexed indirect draw of one record with its index written), and a first
instance without drawIndirectFirstInstance; with no room in the stream or
outside the recording's command buffer, direct, logged and counted. On
MoltenVK the streams also carry VK_BUFFER_USAGE_INDIRECT_BUFFER_BIT. A
"vk: direct draws reading the draw index" line with the frame statistics
counts them a frame, only on MoltenVK. Every other driver records the same
direct calls as before and makes its streams as before.

tests/test_drawid_direct.py runs tests/drawid_direct_test.cpp on the code
cut out of vk_draw.cpp (drawid_indirect, the report, draw()'s three sites):
the choice over every input; every site on five drivers, five programs, six
topologies and 640 draws against the call draw() made before, on a model of
the stream and of MoltenVK's draw index; 400 draws in one recording moving
through four streams; the record not made; the report's counts (970,954
checks). It checks the source too: the gates, the sites, the streams'
usage, the report's wiring, where the SPIR-V back end declares DrawIndex,
and that the renderer's other direct draws use its own shaders, none
reading gl_DrawID. 14 mutants of the change all fail it. SegBind's fixture
gets a drawid_indirect stand-in (its device is not MoltenVK);
test_mode_census follows the indexed direct call's new text. DI-01
(reports/2026-11-14-drawid-direct.md) measures it on the GPU.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 3f1ee3da110c41b77f830160f7fd6c81127b0bd9)
b97c61c8 asked the rewrite of drawIndirectCount alone, on every system: a
Windows or Linux device without the feature would have changed from the
driver's vkCmdDraw*IndirectCount (what main calls there) to the rewrite. The
motivation and every measurement (CD-01/CD-02, CR-01, runs #25/#26) are
MoltenVK's, whose two count-draw functions are empty, so the rewrite, its
device line and its report are now macOS's alone (count_rewrite_on returns
false elsewhere; vk_main.cpp's line inside __APPLE__). Taking the rewrite to
every device without the feature is a change for every platform and needs
its own PR. RX_VK_COUNT_REWRITE=1 still forces the rewrite on a Mac whose
device has the feature (KosmicKrisp, MoltenVK 1.4.3).

test_count_draws checks both gates; VULKAN.md and count_draws.h say so.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
DI-01 (reports/2026-11-14-drawid-direct.md) ran the direct and one-record
indirect forms on MoltenVK 1.4.2: the indirect form was exact in every shape
(0 stale of 3,744 draw indices), and the direct plain, indexed and quad draws
read stale words, but tessellated direct draws read draw index 0 (64 of 64,
with update tags in the pool) while their indirect form took about 135 MB of
GPU memory and 3 ms of submit a draw. So 3f1ee3da's choice to keep them
direct stands, and the error line it logged for each ("writes a direct draw's
draw index to the wrong word") was wrong for them: they are now counted in
the vk: direct draws line only, as fans are (0 of 64 stale direct in DI-01).
drawid_direct.h and VULKAN.md give DI-01's numbers; the fixture expects an
error line only for a first instance without drawIndirectFirstInstance.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 0a6956568f871c66b08c29dffc0a1b9614dfb5de)
LS keys a graphics pipeline by its topology's class and builds a class's
pipeline with the topology of the draw that first asked for it; the
topology is dynamic state. MoltenVK 1.4.2 converts a fan by the
pipeline's static topology (MVKCmdDraw.mm:183, :456, :731, :1052) and
draws a dynamic TRIANGLE_FAN as a triangle list (mvk_datatypes.mm:507),
so a fan drawn with a pipeline a list or strip made is drawn as a list,
and a list or strip drawn with one a fan made is converted as a fan.
NVN's POLYGON is drawn as a fan.

On MoltenVK only, fan_key.h's census registers every whole graphics
pipeline (build_graphics, gpl_link) by its handle and static topology
(a reused handle registered again) and compares each non-mesh draw of
the triangle class with the pipeline it binds. A "vk: fans on MoltenVK"
line with the frame statistics and at close gives the fan draws (NVN
polygons among them), the fans on a pipeline made with another triangle
topology, the other triangle draws on a pipeline made as a fan, the
pipelines drawn both ways and the pipelines built as fans and otherwise.
Nothing built or drawn changes; every other driver counts and prints
nothing.

tests/test_fan_key.py runs the census cut out of vk_draw.cpp against a
model of MoltenVK 1.4.2's decision over every pair and triple of NVN
primitives on four drivers, quick pipelines replaced by full builds,
reused handles, the per-thread answer and eight threads, and checks the
source's gates, registrations, count sites and wiring.
tests/test_fallback_pipeline_state.py, which compiles build_graphics,
stands in census_fan_made.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit b52fe890c484f148fc133e74edcc170c0b798e61)
The topic named its switch in gpu/vk/VULKAN.md only; docs/development.md's
switch table gets its row: macOS only, off by default, the rewrite on a device
with drawIndirectCount too, to compare.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
test(macos): the three recording fixtures stand in stream_usage
Some checks failed
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been skipped
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been skipped
Third-party parts / The same as upstream (pull_request) Has been skipped
Website / Website on Cloudflare (pull_request) Has been skipped
PR description / Description follows the template (pull_request_target) Successful in 13s
CI / Conventional Commit title (pull_request) Successful in 8s
CI / Workflow lint (actionlint) (pull_request) Successful in 12s
Guide screenshots / Guide images and local UI source review (pull_request) Failing after 8s
Third-party parts / As their notes say (pull_request) Successful in 13s
Website / Website on Cloudflare (pull_request_target) Has been skipped
cb60b05a5c
draw_stream_visibility, fsr_uniform_ownership and recording_admission cut
stream_insert out of vk_draw.cpp, which now makes the streams with
stream_usage() (3f1ee3da: the indirect usage added on MoltenVK only); the
full suite at 0a695656 found the three not compiling. Each fixture's device
is not MoltenVK, so its stand-in returns its own kStreamUse.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 4249329689c68e10e96e16ee99b9f20af5cbe193)
fix(macos): fans keyed apart from the other triangle topologies on MoltenVK
Some checks failed
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been skipped
Third-party parts / The same as upstream (pull_request) Has been skipped
Website / Website on Cloudflare (pull_request) Has been skipped
CI / Conventional Commit title (pull_request) Successful in 5s
PR description / Description follows the template (pull_request_target) Successful in 9s
CI / Workflow lint (actionlint) (pull_request) Successful in 9s
Guide screenshots / Guide images and local UI source review (pull_request) Failing after 8s
Third-party parts / As their notes say (pull_request) Successful in 10s
Website / Website on Cloudflare (pull_request_target) Has been skipped
29afee2a96
LS keys a graphics pipeline by its topology's class and builds a key's
pipeline with the topology of the draw that first asked for it.
MoltenVK 1.4.2 converts a fan by the pipeline's static topology
(MVKCmdDraw.mm:183, :456, :731, :1052) and draws a dynamic TRIANGLE_FAN
as a triangle list (mvk_datatypes.mm:507), so a fan drawn with a
pipeline a list or strip made was drawn as a list, and a list or strip
drawn with one a fan made was converted as a fan. NVN's POLYGON is
drawn as a fan.

On MoltenVK a fan now has a class of its own (fan_key::key_class,
vk_draw.cpp pipeline_class) in all three places that keyed by the
class: pipeline_key, graphics_pipeline's last-two cache and gpl_link's
vertex input library. Every pipeline a fan binds is then built as a
fan and no other triangle draw's is; MoltenVK draws those by the
topology set per draw. MoltenVK's other static-topology decisions
(the Metal descriptor's topology class, point sizes, line widths, cull
state) follow the class, which the key already holds.

program_cache_stamp takes the word "fank" on MoltenVK only: the notes,
predicted keys and prepare record kept under the class key (a fan's
note handed its fan pipeline to the class's lists and strips) are not
used, and the next start prepares and notes again. The pipeline cache's
files are not used on MoltenVK, and the Metal archives' descriptors
hold only the topology class, so both stay good. Every other driver's
keys and stamp are byte for byte as before.

tests/test_fan_key.py now also cuts pipeline_class, pipeline_key and
program_cache_stamp: the class over every topology and driver; the key
over every topology, four drivers, both EDS3 settings and a grid of
programs, states and targets against the key before the fix (equal but
for MoltenVK's fans); the stamp against the one before; and LS's
entries over every pair and triple of NVN primitives against a model of
MoltenVK 1.4.2: 780 draws drawn wrong with the class key, 0 with this.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 1e8d1ba3dbeefe92f66961afcbb2b83984370561)
test(vk): test_inline_write_order (main's, #329) has gpu_write's cmd_update_buffer
Some checks failed
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been skipped
Third-party parts / The same as upstream (pull_request) Has been skipped
Website / Website on Cloudflare (pull_request) Has been skipped
PR description / Description follows the template (pull_request_target) Successful in 14s
CI / Conventional Commit title (pull_request) Successful in 7s
CI / Workflow lint (actionlint) (pull_request) Successful in 20s
Guide screenshots / Guide images and local UI source review (pull_request) Failing after 11s
Third-party parts / As their notes say (pull_request) Successful in 19s
Website / Website on Cloudflare (pull_request_target) Has been skipped
1ca0ed4792
Main's test_inline_write_order compiles vk_replay.cpp's gpu_write with a
recording spy; since e0e1ea92 gpu_write records its chunks through
vk_draw.cpp's cmd_update_buffer, which the fixture lacked (it stopped
compiling; the integration branch never had this test). Its stand-in is
cmd_update_buffer on any driver but MoltenVK: vkCmdUpdateBuffer through the
same work wrapper, so the barrier and work-count negatives still fail as
they must. test_update_staging runs the MoltenVK side.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
NVN, as OpenGL, takes a primitive's flat values (and a layer or viewport written before
rasterization) from its last vertex. LS asks Vulkan for the same through
VK_EXT_provoking_vertex; MoltenVK offers provokingVertexLast only with the private Metal API it
is not built with, so on the Mac every primitive takes them from its first vertex.

On such a device, log-only: draw() counts each draw whose fragment stage reads a flat generic its
last pre-rasterization stage writes (Shader::Info::interpolation, as the SPIR-V back end decorates
Flat), or whose last stage writes the layer or viewport, by topology and kind (direct, indirect,
count, mesh fallback, mesh), with the indirect ones' records and bound index range, and every
adjacency draw of any program. draw_provoking_report prints it with the frame statistics. A device
with provokingVertexLast counts and prints nothing.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 1e6e7cdfa1c0ed6585981ec2b12ded44ab28567d)
On a device without provokingVertexLast (MoltenVK: Metal's flat values come from a primitive's
first vertex), a direct draw of triangles, strips, fans, lines or line strips whose pipeline
depends on the provoking vertex, with no geometry stage, is drawn as the list Vulkan would
assemble from it, each primitive's vertices rotated so its last vertex (NVN's) comes first
(provoking::convert). A rotation is cyclic, so triangles keep their winding; lines are reversed;
the primitives and their order are the draw's own, and primitive restart is honoured as
dynamic_state enables it. The 32-bit indices are written into the recording's stream (the game's
indices read on the CPU as submitted, as the quads' are; positions, with the first vertex as the
vertex offset, for a draw without indices), the pipeline and dynamic state are the list's, and
the draw goes through DrawIdDirect's record as the other direct draws. A list not made (outside
the recording's command buffer, unreadable indices, no stream room) is logged at error level and
the draw drawn as before. Devices with provokingVertexLast are not touched.

test_drawid_direct counts this fourth direct draw site, behind drawid_indirect as the others.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 415d6836a64bda4b6289f6f3e79197cbfbca4461)
Without a geometry stage Vulkan rasterizes an adjacency primitive as its plain line or triangle,
its adjacency vertices unused. On a device without provokingVertexLast a direct adjacency draw
(lines, line strips, triangles, triangle strips with adjacency) whose pipeline depends on the
provoking vertex and has no geometry stage is now drawn the same way as the other topologies: the
plain list, the adjacency vertices left out, each primitive's last vertex (OpenGL's table 13.2:
4k+2, k+2, 6k+4, 2k+4) first, the winding kept. Other adjacency draws are drawn as before
(MoltenVK draws those as plain lists of every vertex: counted by draw_provoking_report, a finding
of its own).

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 6f6529b24b27060c81c16b270254663ba39d966a)
The mesh fallback draws one record a corner, in its mesh program's primitive order. On a device
without provokingVertexLast, for a program that depends on the provoking vertex, its vertex draw
is now vkCmdDrawIndexedIndirect of the same header through a static index list that reads each
triangle's corners {2, 0, 1} and each line's {1, 0} (provoking::rotation): the last corner first,
the winding kept. The list covers the program's record capacity (MESH_FALLBACK_RECORD_WORDS /
fallback_record_words, the epilogue's fit), made once per corner count and larger when needed. The
header {count, 1, 0, 0} reads as the indexed record; its fifth word, the allocator, is a first
instance nothing reads (the generated vertex stage reads only its vertex index and the pipeline has
no vertex input), so it needs drawIndirectFirstInstance, else the draw is drawn as before, logged.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 1aced9b7e8b5458befea4dfc38df305913332ebc)
PV-01 (MoltenVK 1.4.2, Apple M4 Max) measured the fix's lists against the draws as they were: a rotated
triangle covers the same pixels with the same primitive, smooth and depth bits, and passes a depth-EQUAL
test against the unrotated draw's depth (q4_tris, q4_strip: every difference 0), but a reversed line covers
other pixels (q4_lines: 150 pixels of coverage, 75 depth-EQUAL failures; q4_lstrip: 15 and 57; noflat_lines,
with nothing flat: 49). A line's only rotation is its reversal, so lines are not converted.

- provoking.h: convertible() is triangles, strips, fans and the two triangle adjacency topologies;
  convert() drops its line cases (it writes nothing for any other topology); list_topology() goes (the
  list is always a TRIANGLE_LIST); rotation() is the triangles' alone.
- vk_draw.cpp: a converted draw's topology is VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST; provoking_list's
  restart rule follows dynamic_state's for the topologies that reach it; the mesh fallback's rotation list
  and its draw are for triangle programs only (a line program's vertex draw is drawn as before).
- Lines, line strips, their adjacency forms and the fallback's lines are still counted by
  draw_provoking_report and keep their first vertex's flat values, as before the fix.
- Tests: the fixture checks the convertible set, that convert writes nothing for lines, points, patches
  and quads, the triangle topologies against Vulkan's assembly, and the triangle rotation list; the source
  checks follow the new switch, restart rule and fallback condition.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit bd019caddc9c471d6fd9cd36aef21db54601db27)
provoking_list wrote the list into the recording's stream before the pipeline was chosen, ahead of the memory
admission. The admission can hand the draw to a new recording (vk_replay.cpp admission_handoff: the old one
submitted and waited, a new slot begun), which then bound an index range in the old slot's stream; that stream
is rewritten when the old slot is reused, possibly while the new recording still reads it. A draw left out after
the decision (no pipeline yet, a segmented variant lost) also counted a list it never drew.

- provoking_list decides (the same conditions; room for the list in the stream now, stream_reserve; the game's
  indices read on the CPU as submitted, held by the list in the thread's lent buffer) and writes nothing.
- provoking_write, at the draw (after the admission and the pipeline), allocates the list in the stream of the
  recording the draw is in and converts into it; the draw binds that buffer. If the stream cannot grow there
  (device memory), the draw is left out, logged at error level and counted ("left out, no room for the list in
  the stream at the draw"), since its pipeline is the list's.
- listed counts the lists drawn, at the draw.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 16216adc33ff7081bb3a0d3b687fbee947cb6519)
What the Mac's 33 ms frame is made of could not be read from a run: there was no reason for each render pass end, no attachment
traffic, no count of what MoltenVK splits, no timing of the frame's command buffers against the CPU's clock, and no way to show a
change exact on the same frame. These are MacPerf's section 5 measurements (reports/2026-11-14-mac-perf.md), log-only:

- RX_VK_PERF_LINES=1: every 120 presents L1 (render pass ends by reason, and the passes begun again on the same attachments), L2
  (bytes loaded and stored by the passes, leading whole clears), L3 (MoltenVK only: 8-bit indexed draws, fans, passes with
  occlusion queries), L4 (NVN's discards, recorded into the command buffers only with this switch as NOP_DISCARD, and what reads a
  discarded target before it is rewritten), L6 (mesh fallback draws, their splits and batchable runs); every 600 presents L5
  (readback waits by thread and kind, and the part the present poll slept through).
- RX_VK_GPU_TIMELINE=1|2: the recordings' existing timestamps put on the CPU's clock with VK_KHR_calibrated_timestamps (asked for
  only with the switch): per frame, the GPU busy and idle times, the gaps between parts, submit to start, the last part to the
  game's present, the present to the next frame's first part; per frame type the counts; at 2, a timestamp pair per pass and
  dispatch and each type's top spans. Bounded memory, made only with the switch.
- RX_VK_BENCH_AB=pass_barrier|dedupe_off|chunk:<n> with RX_VK_BENCH: the replays in blocks A, B, A, each replay's GPU time and a
  hash of the window texture after it, and whether B's picture is A's bit for bit.

Off, each hook is one load of a flag set before the first replay; nothing drawn changes on or off. The host's present-poll sleep
clock includes the sleep in progress (read only while the FPS Breakdown or these switches time sleeps). Tests:
tests/test_perf_lines.py (perf_lines.h on the CPU, the source wiring, the hooks' cost off and on); fixtures that cut the touched
code get the stand-ins.

(Picked onto the count-draw rewrite, main's newer invalidate() with RX_VK_READBACK_LATE and RX_VK_ASYNC kept: L5's wait kinds
sit in its pending branch. The topic's added count on the readback line, the waits for a whole submission whose pages were
written in place, prints on every driver by default, so it is its own PR on main, fix/readback-whole-waits, not in this commit.
Only vk_perf.h is included here: the topic's other include lines were its base's.)

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 2c20c030308c637379690a3b4c9532eb888ddd53)
Since 415d6836 three fixtures that cut draw() failed (the suite at 1aced9b7, run by name in sel-1):
- test_draw_side_effects: its decision block holds provoking_list's call and the topology switch, with no
  VkPrimitiveTopology, ProvokingList or provoking_list there. Stand-ins: no list (the fixture's device has
  provokingVertexLast), the topology unchanged, nothing read.
- test_seg_gathers: its @BINDS@ cut holds the list's draw (pv, provoking_write, game_topo). Stand-ins: no draw
  is a list.
- test_draw_binds: draw() binds an index buffer at five places now (the converted draw's list and the mesh
  fallback's rotation list, both through bind_index).

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 3044f69b5ac08c82045e7c327781480d4b70d7b1)
test(vk): test_inline_write_order (main's, #329) includes perf_lines.h for full_barrier's bench arm
Some checks failed
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been skipped
Third-party parts / The same as upstream (pull_request) Has been skipped
Website / Website on Cloudflare (pull_request) Has been skipped
CI / Conventional Commit title (pull_request) Successful in 4s
CI / Workflow lint (actionlint) (pull_request) Successful in 6s
Guide screenshots / Guide images and local UI source review (pull_request) Failing after 5s
Third-party parts / As their notes say (pull_request) Successful in 10s
PR description / Description follows the template (pull_request_target) Successful in 4s
Website / Website on Cloudflare (pull_request_target) Has been skipped
46eabf24e4
PerfInstr's full_barrier asks perf_lines::bench_b("dedupe_off") (RX_VK_BENCH_AB's B arm; false unless the bench runs it).
main's test_inline_write_order (#329) compiles full_barrier cut out of vk_texture.cpp, and did not compile at this head
("use of undeclared identifier 'perf_lines'"; found by the wave's full suite: the topic's base predates the test). It now
includes perf_lines.h, as the topic did for test_dump_readback's fixture; its three variants (the current code and two negative
controls) run as before, both compilers.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
As the topic's own 653493d4 merged macos/fan-key 1e8d1ba3: this branch sits on #P-drawid-direct 9c2bd272 and
mac/fan-key on the same head, so the provoking-vertex commits keep their picks and fan-key keeps the commits
FK-01 pinned.

Conflicts, each kept both sides, as in 653493d4:
- vk_draw.cpp: the two includes (provoking.h, fan_key.h); draw()'s two non-mesh count lines: ProvokingFirst's
  count (census_draw, provoking_note) with FanKey's census_fan_draw after it.
- vk_draw.h: the declarations of draw_provoking_report and draw_fan_census_report.
- vk_replay.cpp log_frame_stats: the provoking report's block, then the fan census's.
The merge's change from the first parent is mac/fan-key's change from 9c2bd272, line for line.

Semantic changes the merge needs (test_fan_key's count lines; a converted fan's pipeline under the fan key)
are the next commits (ed0829b5, e7f37bb3).

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
After the merge draw()'s five count lines are `st_draws++, ps.draws++, census_draw(s, p.key), provoking_note(s,
p, d, game_topo);` (ProvokingFirst), and FanKey's census_fan_draw follows the two non-mesh ones. test_fan_key
looked for the count line without provoking_note, so it found 0 sites. It now looks for the merged line, still
five of them and census_fan_draw right after the two non-mesh ones; nothing else in the check changes.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit ed0829b55441bcd4403d4d98ffc6728d02ec2da0)
With FanKey merged, a draw provoking_list decides on takes VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST before its
pipeline is chosen, so pipeline_key and graphics_pipeline's last two key it by pipeline_class(TRIANGLE_LIST),
the triangles' class: its pipeline was built by a list, strip or triangle adjacency draw, never as a fan, and
MoltenVK draws it by the topology set per draw. A fan drawn as it was (indirect, or its list not made) keys
apart (kFanClass) and binds a pipeline built as a fan. Before FanKey the class key could hand a converted fan
the pipeline an unconverted fan of the same program built (PV-01's conv_fan .g subs: 193/778 and 274/1250
pixels right).

provoking_first_test.cpp's compose() runs, on MoltenVK and NVIDIA, every ordered sequence of one to three draws
of one dependent program (fans, lists, strips converted or drawn as they were, the triangle adjacency forms
converted) with topology_class and pipeline_class cut from vk_draw.cpp and draw()'s own topology switch: every
draw as asked by MoltenVK 1.4.2's model (fan_key::moltenvk_draws), a converted draw never on a pipeline built
as a fan, a fan drawn as it was always on one; off MoltenVK the key is the class. The same sequences keyed by
the class alone draw some wrong on MoltenVK (what the key keeps apart). The source checks add that pipeline_key
and graphics_pipeline key by pipeline_class. The test fails against 3044f69b's gpu/vk (no pipeline_class) and
with each of the compose mutants (no fan key, the fan key on every driver, the converted draw keyed by the
game's topology, kFanClass equal to the triangles', pipeline_key or the last-two cache by the class).

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit e7f37bb33c429fe11dda60e56c3c0c650d3f7a60)
LS counted the game's mesh draws in one host counter shared by the three NVN
calls, printed no mesh census with the frame statistics, and nothing of what
the compute fallback (issue 640: GPUs without VK_EXT_mesh_shader, MoltenVK,
KosmicKrisp, AMD under Linux by default, Navi 21/22 task + mesh programs on
Windows) leaves out or cuts (reports/2026-11-14-mesh-gap.md section 2).

- host/nvn_native.c draw_mesh: one counter a kind (DrawMeshTasks,
  DrawMeshTasksIndirect, MultiDrawMeshTasksIndirectCount), each printed at its
  own first three calls and every 20,000th: the first mesh count draw the game
  records is named at once.
- vk_draw.cpp MeshTally, reported by draw_mesh_report with the frame
  statistics after the count draws' line (vk_replay.cpp): the game's mesh draws
  by kind, those drawn by path (native, fallback), direct task draws split into
  batches, count draws' maxDrawCount, native count draws past the ring's 4096
  converted records, direct mesh-only fallback draws past the slots, and the
  fallback draws left out, by reason (with a count, descriptors, buffers,
  pipelines compiling or refused, arena, converted arguments, a held depth
  draw). Printed where the fallback is on or a mesh draw was made.
- The census: the fallback's records header grows from 8 to 16 words; words
  8 and 9 hold the address of the drawn program's census row (host memory, a
  row a program; emit_spirv.h MESH_FALLBACK_CENSUS_WORD). The task and mesh
  epilogues add what they cut there, each only in its cut branch: mesh
  workgroups past task_cap (and the largest count asked), meshlets refused for
  a slot past MESH_FALLBACK_CAPACITY and task workgroups wholly past it,
  mesh-only workgroups past it, records past the 64 MB area (and the most a
  draw needed). draw_mesh_report sums the rows and names the programs that cut.
  The header is now reset by one cmd_update_buffer of its 16 words (on MoltenVK
  a copy from the recording's stream) in place of four fills.

Nothing drawn changes. The fallback programs' SPIR-V changes (the header's
size and the census branches), so the translator stamp moves and the program
cache is rebuilt once.

Tests: tests/test_mesh_census.py (new) checks the host counters, the census
layout and each census site's condition, the tally sites, and the report's
place; tests/test_mesh_fallback.py takes the header's update for its fills;
tests/seg_xfer_test.cpp stubs the counters and the 16-word header, and
tests/test_seg_xfer.py records the fallback common case's new commands (one
update in place of four fills); tests/test_fallback_depth_hold.py follows the
held depth draw's counted skip. The tally sits before vertex_bind_reuse, outside
the ranges tests/test_seg_gathers.py cuts from vk_draw.cpp.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit f27457c0fb836fdba0bcf8614e9f18e81e3b6009)
fix(macos): the provoking-vertex count and lists on macOS only; Windows and Linux record as before
Some checks failed
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been skipped
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been skipped
Third-party parts / The same as upstream (pull_request) Has been skipped
Website / Website on Cloudflare (pull_request) Has been skipped
PR description / Description follows the template (pull_request_target) Successful in 6s
CI / Conventional Commit title (pull_request) Successful in 4s
Guide screenshots / Guide images and local UI source review (pull_request) Failing after 6s
CI / Workflow lint (actionlint) (pull_request) Successful in 7s
Third-party parts / As their notes say (pull_request) Successful in 10s
Website / Website on Cloudflare (pull_request_target) Has been skipped
8d552a6c6a
The topic gated its count, its lists, the mesh fallback's rotation list and its report on the device
alone (dev.provoking_vertex: VK_EXT_provoking_vertex with provokingVertexLast). A Windows or Linux
device without provokingVertexLast would have gone from main's draws (the first vertex provoking, as
there today) to LS's converted lists and a new report line. The motivation and every measurement
(PV-01, PV-02) are MoltenVK 1.4.2's, which offers provokingVertexLast only with Metal's private API,
so the four gates now ask provoking_first(): the device's answer on macOS, false elsewhere. Taking the
count and the lists to every device without provokingVertexLast is a change for every platform and
needs its own PR.

test_provoking_first checks the gate's two forms and that no ProvokingFirst gate asks the device alone.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
On a GPU without VK_EXT_mesh_shader (MoltenVK, KosmicKrisp, AMD under Linux
by default, Navi 21/22 task + mesh programs on Windows, GTX 10xx, RX 5000)
task and mesh programs run as compute dispatches (issue 640), and
fallback_record left every MultiDrawMeshTasksIndirectCount out: no dispatch
takes a count, and each record needs its own draw index
(reports/2026-11-14-mesh-gap.md 3.1-3.2).

- count_draws.h and shaders/count_draws_record.glsl are generalized: a
  Layout reads copy_words words of a record and writes them, then `ones`
  words of 1. count_draws::plain(indexed) is the count draws' (every word
  copied, no ones: their records are byte for byte as before);
  count_draws::mesh() reads a record's count word and writes {count, 1, 1}
  under the count, {0, 0, 0} past it. The push block is eight words; the
  SPIR-V header is regenerated (glslang 16.6.0).
- vk_draw.cpp count_rewrite_records holds the rewrite's commands (two full
  barriers, chunks, push descriptors); count_rewrite gives it the plain
  layout as before.
- fallback_record_count: the records and count bound as draw_demands admits
  them (records across a segment's cut gathered into the arena), the
  header reset with the census row, the records rewritten into dispatch
  records, then per record i: its draw command emptied and its first vertex
  set to the allocator (i > 0; the allocator is never reset, so record i's
  draw command covers exactly the records its workgroups took), draw index
  i and its own record as the task's input pushed, the task (or mesh-only)
  dispatch indirect from dispatch record i, the mesh dispatch from the
  header, the command copied into the arena, a full barrier between steps.
  draw() then draws maxDrawCount commands with vkCmdDrawIndirect in one pass
  (one pass break a count draw), in record order, inside the occlusion query.
  Records at or past the count dispatch and draw nothing; records past
  maxDrawCount are never read. What cannot be bound or rewritten is left
  out loudly and counted. The fallback's records buffer is a transfer
  source too.
- DrawID in a fallback task or mesh stage (a GLCompute entry point, where
  Vulkan has no BuiltIn DrawIndex) reads a pushed u32 at
  MESH_DRAW_ID_PUSH_OFFSET (76: the 4 bytes after a fallback task's u32
  first, so no offset moves; PRESENCE_PUSH_OFFSET stays 88) and declares
  neither DrawIndex nor DrawParameters. Stages that do not load DrawID are
  emitted byte for byte as before. push_constants pushes 0 for single draws.
- mesh_convert_bytes makes no 4096-record ring for a count draw where every
  mesh program takes the fallback.

Every platform's fallback changes: count draws that were left out are drawn.
Cost follows maxDrawCount: per record two dispatches, three transfers and
four full barriers, then maxDrawCount draws (on MoltenVK encoded at submit).

Tests: tests/count_draws_test.cpp runs the shader for both layouts (the
mesh one over strides 8-20, count words 0-3, every count to past
maxDrawCount); tests/test_mesh_count_fallback.py + mesh_count_fallback_test.cpp
model the shader, the header steps and the epilogue's allocation under
random workgroup orders (each record draws what it draws alone, in order;
the census counts what a full area cuts) and check the source;
tests/test_mesh_drawid.py + mesh_drawid_ir.cpp emit fallback stages with and
without DrawID through the real back end (spirv-val, the pushed member, no
builtin; the others byte for byte the base's); test_count_draws,
test_resolver_coverage (SITES_MESH), test_mesh_fallback, test_mesh_census and
seg_xfer_test follow. README and VULKAN.md say so.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit e9508f4b810c2e1859914c353b9a76cd2b8c4fa4)
The compute fallback's mesh workgroups take records for their corners from
one area a recording thread, a fixed 64 MB (MESH_FALLBACK_RECORD_WORDS), and
a workgroup whose records did not fit was not drawn, silently
(reports/2026-11-14-mesh-gap.md 1.3). A mesh count draw's records now share
that area too.

- The header's word 10 (MESH_FALLBACK_AREA_WORD) holds the area's words, set
  by the host with the rest of the header; MeshFallbackEpilogue's fit is
  that word over the record's words, not a constant. A workgroup that does
  not fit adds the words its draw needed (saturated) to the census
  (MESH_CENSUS_RECORDS_NEED, now in words).
- fallback_buffers_of sizes the area by mesh_area::words_for(the census's
  most), a power of two from 64 MB up to the shader's u32 word index, read
  at most once a frame (mesh_area_need). It grows only when
  mesh_area::affordable says the renderer's allowance holds the new buffers:
  vram_usage, which on unified memory is gpu-budget's unified_budget.h
  allowance (RAM less the process's CPU memory and a reserve, and what is
  available less a headroom), elsewhere the driver's budget; else it keeps
  the largest area it has, logged, and what does not fit stays counted. The
  fixed 128 MB refusal is gone. Grown areas are kept, as before (a command
  buffer recorded earlier may read the smaller).

Exact from the recording after a draw first outgrew the area, within the
allowance: the draws that outgrew it before the census was read are cut once,
and counted (vk: mesh fallback cuts). No translator change but the fit; every
fallback program's SPIR-V changes with it.

Tests: tests/test_mesh_area.py + mesh_area_test.cpp (the growth rule, the
allowance, and the source: the fit from the header, the need in words, both
header updates, the growth bounded by vram_usage); tests/seg_xfer_test.cpp
stubs the area word (its common case's commands are unchanged).

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit d49a49913d41245b7264c322086a772a7148867b)
The full suite at d49a4991 found six fixtures that compile vk_draw.cpp's
code with stand-ins: test_draw_side_effects and test_vk_occlusion_queries
(mesh_tally, mesh_in_batch: f27457c0), test_null_descriptors,
test_null_layout_push, test_texel_views_plan (fallback_draw_id: e9508f4b),
test_fallback_buffer_cleanup (VK_BUFFER_USAGE_TRANSFER_SRC_BIT: e9508f4b;
mesh_area, mesh_area_need, vram_usage, logf: d49a4991). Stand-ins only; the
pushed bytes and the 96-byte area those tests check are unchanged.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 3ea03577bdb0a9e4bd3a277bc8d27f398fca4621)
fix(vk): the mesh count draws bind their stages without RX_VK_LMEM_LOG's hook, which this base lacks
Some checks failed
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been skipped
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been skipped
Third-party parts / The same as upstream (pull_request) Has been skipped
Website / Website on Cloudflare (pull_request) Has been skipped
CI / Conventional Commit title (pull_request) Successful in 4s
CI / Workflow lint (actionlint) (pull_request) Successful in 6s
Guide screenshots / Guide images and local UI source review (pull_request) Failing after 5s
Third-party parts / As their notes say (pull_request) Successful in 10s
PR description / Description follows the template (pull_request_target) Successful in 4s
Website / Website on Cloudflare (pull_request_target) Has been skipped
833ac3b900
MeshCountDraws was written on the macOS integration branch, where fallback_record names each compute pipeline it
binds for RX_VK_LMEM_LOG (private_memory_bound, MetalScratch). fallback_record_count's bind copied that line. This
branch is on the count-draw rewrite, which has no RX_VK_LMEM_LOG, so vk_draw.cpp did not compile ("use of undeclared
identifier 'private_memory_bound'"; the CPU tests cut the code out and passed). The line goes; the pipeline is bound as
before. Whichever of this PR and the RX_VK_LMEM_LOG one lands second adds the line back, as fallback_record has it.

vk_draw.cpp compiled whole for arm64 and x86_64 after the change.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The mesh fallback's rotation (ProvokingFirst) and its count draws and growing records area (MeshCountDraws) meet in
fallback_record and draw()'s fallback branch. Resolved as Integration 36/37 did (dcf593d9): includes both; the
report declarations and calls both; the fallback draw keeps ProvokingFirst's rotated draw and otherwise draws
MeshCountDraws' maxDrawCount commands at fallback_draw_offset; the mesh draw counts carry MeshCountDraws' tally and
ProvokingFirst's provoking_note. The rotation does not yet follow the count draws or a grown area: the next commit
(Integration 37's interim guard) keeps it to what its list covers, and the one after (MeshProvoking) makes it follow
both.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Semantic merge fix (Integration 37), after provoking-first met mesh-count, indirect-quads and fan-indirect:

- ProvokingFirst draws a dependent triangle program's mesh fallback through a static rotation list as one indexed
  record from offset 0 of fallback_draw_records, the list sized to the first records area
  (MESH_FALLBACK_RECORD_WORDS / fallback_record_words). MeshCountDraws gives a count draw maxDrawCount commands in
  the arena at fallback_draw_offset, and grows the records area (the header's area word, the epilogue's fit). So a
  rotated count draw would read the arena from offset 0, and a grown area's corners would lie past the list (an
  index read out of range). Until the rotation follows MeshCountDraws (lane MeshProvoking), provoking_fallback
  keeps it to a single draw whose area is still the first, which the list covers exactly. A count draw, or a draw
  whose area has grown (fallback_record notes the draw's area words, fallback_draw_area), is drawn as before (FIRST),
  logged at error level and counted as kept (provoking_kept). Nothing else changes on any driver.
- Checks: test_provoking_first (the guards; the header and fit as MeshCountDraws has them).

vk_draw.cpp compiled whole on arm64 and x86_64 before the commit (i37-dev-05).

(Picked into mac/mesh-provoking, the stack of mac/provoking-first and fix/mesh-count, without the parts that need
branches not in it: the indirect quads' count (IndirectQuads, a sixth provoking_note site, with test_quad_draws and
test_provoking_first's six sites), test_perf_lines (PerfInstr) and test_fan_indirect (FanIndirect). Those are
sibling merge items, named in the PRs. The next commits replace this guard.)

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit eb4d020462338c573ad420d8bd64bdd8ae7da580)
Semantic merge fix (Integration 37). tests/seg_xfer_test.cpp compiles fallback_record cut out of vk_draw.cpp
with stand-ins for the thread's fallback draw state (fallback_draw_records, fallback_draw_offset,
fallback_draw_count). eb4d0204 made fallback_record note the draw's records area in fallback_draw_area, so the
fixture no longer compiled ("use of undeclared identifier 'fallback_draw_area'", i37-check-50-14/-15). It gets
the same stand-in; nothing else changes.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 85675f30379af284a52f8f944bddad3f44e2400b)
ProvokingFirst draws a dependent triangle program's mesh fallback through a static rotation list (each triangle's last
corner first). MeshCountDraws gives the fallback count draws (maxDrawCount commands in the arena) and a records area that
grows past the first 64 MB. Integration 37's interim guard drew count draws and grown areas FIRST-provoking; now they are
rotated, exactly, only on a device without provokingVertexLast:

- fallback_record decides the rotation after fallback_buffers_of and the pipelines (provoking_fallback, now returning a
  FallbackRotation that draw() draws and counts): the list covers what the draw's area holds
  (provoking::fallback_corners); past the first area's corners it grows only within the renderer's allowance
  (mesh_area::affordable against vram_usage, the area's own rule), else it stays at the first area's corners.
- Where the list covers fewer corners than the area holds, the header's area word is what it covers
  (provoking::fallback_area): the epilogue cuts the corners past it as past the area, and the census counts them
  (RECORDS_PAST/UNFIT/NEED). No index past the list is read. Such draws are logged at error level and counted
  (provoking_tally.cut, on the vk: provoking vertex line), as the guard's draws were.
- A rotated count draw's commands are VkDrawIndexedIndirectCommands 20 bytes apart (provoking::fallback_command_bytes): their
  first instance filled with 0 once, the header's four draw words copied over it per record (vertexCount as indexCount, the
  record's first corner, a multiple of 3, as firstIndex, vertexOffset 0). draw() draws them with vkCmdDrawIndexedIndirect over
  the list in the same `most` loop at fallback_draw_offset. A count draw needs no drawIndirectFirstInstance; a single draw
  (the header, its allocator word the first instance) still does.
- Every other driver: the header's area is the set's, the commands 16 bytes, the draw as before (the gate is
  provoking_fallback's first statement); the interim fallback_draw_area is gone.

Tests: the new tests/test_mesh_provoking.py cuts this code into tests/mesh_provoking_test.cpp and runs it against a model of
the GPU (rotated against unrotated, single and count draws of N > 1 records, grown areas, the allowance holding the list
back), with ten mutations that must fail (among them provoking-first's draw from offset 0, the interim guard, the list sized
to the first area); it fails on I37 85675f30. test_provoking_first, test_mesh_count_fallback, test_mesh_area,
test_mesh_census, test_mesh_drawid and test_mesh_fallback follow the moved code; test_seg_xfer's fixture stands in the
decision. VULKAN.md says so.

(Picked onto mac/provoking-first + fix/mesh-count with the interim guard: VULKAN.md without IndirectQuads' quads
paragraph, which is not in this stack. The new code's gates ask the device as the topic's did; the next commit puts them
on provoking_first(), as mac/provoking-first's other gates.)

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 3f872b0cb21f3280b19038ea395e5e5ac2e94426)
mac/provoking-first asks provoking_first() (the device's answer on macOS, false on Windows and Linux) at each of
its gates, so a Windows or Linux device without provokingVertexLast records as on main. MeshProvoking's two new
functions asked the device alone: provoking_fallback (the rotation's decision in fallback_record) and
provoking_fallback_drawn (its count at the draw). Both now ask provoking_first(). Off Apple the fallback is drawn
as before: the header carries the area's own words and the commands are 16 bytes, as MeshCountDraws alone draws them.

Tests: test_provoking_first and test_mesh_provoking require provoking_first() as provoking_fallback's first
statement (the mutant that drops it still fails); tests/mesh_provoking_test.cpp stands in provoking_first() as it
reads on macOS.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
fix(macos): a rotated mesh fallback draw's area is cut only when its rotation list covers fewer corners than it holds
All checks were successful
PR description / Description follows the template (pull_request_target) Successful in 6s
Website / Website on Cloudflare (pull_request_target) Has been skipped
881408f9ae
Run #32 (I38) counted 53,047 rotated fallback draws as cut, with 69 error lines, each taking 10-16 words off the 16,777,216-word
records area, while the mesh fallback's census (records past the area) stayed 0. The header's area word was
min(area, list x words); the list holds floor(area / words) corners (fallback_corners), so for the program whose records set the
list's size (a list of 621,378 corners at 27 words, then 762,600 at 22) that product is the area less its remainder, fewer words
than one record (2^24 mod 27 = 10, mod 22 = 16). The epilogue's fit, the area word over a record's words, was the same either way:
no corner was cut, none could be, and the draws were counted and logged as cut wrongly.

provoking::fallback_area now leaves the area whole unless the list holds fewer corners than the area does (n < area / words); only
then is the fit smaller, and only then is the draw counted as cut and logged at error level (provoking_fallback_drawn, unchanged:
area < records). The error line now says how many corners the list covers of how many the area holds. Draws that fit the list
are drawn exactly as before (same fit); the header of a draw whose list covers the area is now the area's own, as on other drivers.

tests/test_mesh_provoking.py: areas that are not a multiple of a record's words with a list of exactly the corners they hold (the
first area at 31 and 7 words, a grown one at 7), not cut; an eleventh mutant, 3f872b0c's trim, must fail. The new fixture fails on
3f872b0c ("the header carries the area, or what the list covers").

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 79e2e006760970a55ea52baf1c4957bb9d217343)
A render pass whose first operations clear whole attachments now begins with those attachments' LOAD_OP_CLEAR instead of
LOAD_OP_LOAD followed by vkCmdClearAttachments. On a tile GPU the load reads the attachment into tile memory and the clear then
overwrites it; Metal's clear load action only fills the tiles. Run #31's hold: 37.8 passes a frame begin with whole clears, 46.8
attachments' loads (76 MB of the 650 MB loaded a frame) are dead.

- begin_pass holds a pass's vkCmdBeginRendering when a whole clear opens it (Pass::lead, load_clear.h Held); each leading whole
  clear becomes its attachment's load op (the depth's and the stencil's each its own); pass_settle records the begin before anything
  else: begin_pass for a draw or another clear, end_pass, and the replay before any operation that is neither a clear nor a change of
  state (keeps_lead). Nothing is recorded between a pass's begin and its settle, so the command buffer is the one before, less the
  folded clears.
- Only where it is exact: every attachment the render area's size and single-sampled, no BC slice or raw view of BC (Targets::
  whole_extent: MoltenVK uses Metal's clear load action once the render area is the last attachment's level-0 extent, and that
  clears every attachment whole), the clear of the whole render area and every channel or aspect, and a value both of MoltenVK's
  ways write alike (load_clear.h: float formats' +0 and own normal values, integers in range and within 2^24, normalized 0 and 1,
  -1 for signed, D32's +0 and normal values, the unorm depths' +0 and 1). Everything else keeps vkCmdClearAttachments.
- MoltenVK's driver only (vk_vendor.h load_clear, RX_VK_LOAD_CLEAR=0/1). RX_VK_BENCH_AB=load_clear: off for the run but in the
  bench's B block. PerfInstr's L2 line counts what each leading whole clear became.
- tests/test_load_clear.py: the values format by format, whole_extent, and fixed and 8000 random sequences replayed with LS's own
  code against a model of MoltenVK's passes (the same memory, Vulkan's rules, the same commands but the folded clears).

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit e0d3af8f616b3e5829b900affd4531432147f0de)
The full suite at e0d3af8f failed four source checks this change broke:
- test_bank_gen, test_parallel_dry_marks and test_same_bind find replay()'s cases by the first "case NOP_..." text in vk_replay.cpp,
  which keeps_lead's case labels now came before. keeps_lead is a set of bits (op_bit) instead, with no case labels.
- test_env_on: the device line read RX_VK_LOAD_CLEAR with getenv (set-means-on). It is now said on MoltenVK, or wherever the fix is
  on, from the vendor fixes' block (the switch itself is read by vendor_fixes_env, 0 off).

tests/test_load_clear.py reads the set of bits; its fixture cuts op_bit with keeps_lead.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 0d15403fb9ae2823a44b8c4f0e046b5571485a37)
fix(vk): LoadClear off by default (mirrors macos/integrated 2b4b7b8e, I40)
Some checks failed
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been skipped
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been skipped
Third-party parts / The same as upstream (pull_request) Has been skipped
CI / Conventional Commit title (pull_request) Successful in 3s
CI / Workflow lint (actionlint) (pull_request) Successful in 7s
Guide screenshots / Guide images and local UI source review (pull_request) Failing after 6s
Third-party parts / As their notes say (pull_request) Successful in 10s
PR description / Description follows the template (pull_request_target) Successful in 6s
Website / Website on Cloudflare (pull_request_target) Has been skipped
7c6f0e9cee
LoadClear's half of macos/integrated's 2b4b7b8e (Integration 40), on the parent's word from the E-series
(reports/2026-11-15-mac-perf-before-after.md, field-dawn save, 600 s holds on an M4 Max; run-to-run noise 0.17 ms):
all on but LoadClear (E4c) 33.7 fps, GPU 19.2 ms, against all on (E2) 33.7 fps, GPU 19.1 ms. Measured under the noise,
and only what is measured faster is adopted, so it is off by default:

- vk_vendor.h: load_clear default false on every driver; RX_VK_LOAD_CLEAR=1 turns it on.
- docs/development.md: the switch row and the bench row say so.
- test_load_clear: the fix off on MoltenVK by default, =1 on; the source check pins x.load_clear = false.

The changed lines are 2b4b7b8e's for these files, but for the bench row (RX_VK_BENCH_AB): the same change to its
load_clear sentence, on the row as this branch has it, without the widen8 and mesh_batch arms the later topics add
(with them, at mac/mesh-batch, the row is 2b4b7b8e's). Widen8's half is its own commit on mac/widen8.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Widen8 builds on #678's code: its report goes out with draw_index8_report's line, its direct-draw bind replaces
#678's sized bind (index8_bind_size) and its Device fields follow #678's index8_size. #678 sits on #677 and this
branch on #704 (#679, #677), so the merge brings in #678's two commits only.

Conflicts, each kept both sides, in Integration 33's order (macos/integrated):
- vk_vendor.h: the index8_size field, default and switch, then load_clear's.
- vk_main.cpp create_device: #678's maintenance5 block, then #704's GPU timeline extension, then #678's
  `vk: 8-bit indices` line.
- docs/development.md switch table: RX_VK_INDEX8_SIZE's row, then RX_VK_LOAD_CLEAR's.
The merge's change from the first parent is #678's change from #677, line for line.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Widen8's draw() asks for a restart-off 8-bit draw's widening only where ProvokingFirst made no list for it
(`!pv.made`: a converted draw binds its 32-bit list, no 8-bit indices), and test_widen8 reads draw()'s indirect
branch up to ProvokingFirst's `} else if (pv.made) {`. #703 sits on #680 + #701 on #679, which this branch
(#704 on #679) carries, so the merge brings in #680, #701 and #703.

Conflicts, each kept both sides, as Integration 37 (macos/integrated 3f872b0c) has them:
- vk_draw.cpp: the includes (drawid_direct.h, provoking.h, fan_key.h, then load_clear.h); #678's 8-bit tally and
  report, then FanKey's census report; DrawIdDirect's section and report, then PerfInstr's perf_drawn, then
  ProvokingFirst's section and report (right before draw(), as integrated); at draw()'s five counted draws
  ProvokingFirst's count line (and DrawIdDirect's record at the direct quads, FanKey's census at the quads and the
  rest), then PerfInstr's perf hook.
- vk_draw.h: the DrawIdDirect, ProvokingFirst and FanKey report declarations, then draw_perf_op.
- tests/seg_gathers_test.cpp: drawid_direct.h's include, then perf_lines.h's.
The merge's change from the first parent is #703's change from #679, line for line. test_perf_lines' count of
the draw sites follows in the next commit.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
#704's check finds each counted draw by its count line, `census_draw(s, p.key);`, with the perf hook right after.
With #703 merged (and #701 under it) the count line ends `provoking_note(s, p, d, game_topo);` and at the quads and
the common end FanKey's census line sits between the count and the hook, which test_fan_key requires right after
the count. The check now reads that form, as Integration 36/37 made it (344388701, eb4d0204's test_perf_lines
part), at this tree's five places (no indirect quads, no FanIndirect line here). Nothing else changes.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
MoltenVK widens every 8-bit indexed draw's indices to 16 bits in a compute encoder, which ends the Metal render
pass (attachments stored) and begins it again with loads (MVKCmdDraw.mm convertUint8IndexBuffer): 57.5 a frame
in run #31's hold. On MoltenVK, begin_pass now widens the ranges of the 8-bit indexed draws ahead of the pass
(widen8_scan.h, a look ahead from the replay's cursor) in one compute run between its pass-start barrier and the
pass, with MoltenVK's own values (0xFF to 0xFFFF), from the binding each draw makes (a direct draw's count, an
indirect draw's extent to the buffer's end), into the recording's arena; the draws bind those as
VK_INDEX_TYPE_UINT16 where widen8::Table::find allows: the same pass instance, the same binding, no draw before
it in the pass that may write memory, and on MoltenVK's private-API build restart on. Everything else binds its
8-bit indices as before.

- gpu/vk/widen8.h, widen8_scan.h, shaders/widen8.comp + widen8_pair.glsl (+ its SPIR-V header).
- vk_memory.cpp gpu_buffer_bind_imported: a binding of a range an imported pool already holds, nothing imported
  or synced (asked for ahead of the draws' admission).
- vk_replay.cpp: the replay's cursor; tests that cut draw() or count its binds given the new sites.
- vk_vendor.h widen8 (MoltenVK only), RX_VK_WIDEN8=0 turns it off; PerfInstr B1 arm RX_VK_BENCH_AB=widen8.
- PerfInstr L3 counts a draw bound with widened indices as no MoltenVK split; a report line with the frame
  statistics.
- tests/test_widen8.py + widen8_test.cpp.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 2634d098ee13f4c53d01ca21b7a918698374d1fb)
MoltenVK's public build widens 8-bit indices with 0xFF to 0xFFFF whatever the draw's restart state
(convertUint8Indices), and Metal restarts there: an 8-bit triangle or line list (restart is never on for
lists) or a restart-off strip that uses vertex 255 lost those primitives. NVN draws vertex 255 there.

New vendor fix index8_raw (MoltenVK only, on by default, with widen8 on or off; RX_VK_INDEX8_RAW=0:
MoltenVK's values as before):
- widen8::mapped picks the form a draw binds from its own restart (last_dyn): restart's (0xFF to 0xFFFF)
  where it is on, zero-extended where it is off. With the fix off, MoltenVK's values on either build, so
  the private-API special case (usable) is gone.
- widen8_before_pass widens both forms of each range ahead (one arena slot, widen8::raw_at; two
  dispatches, the shader's push `restart` 1 and 0); Table::find picks the form.
- widen8_before_draw (before begin_pass): a restart-off 8-bit draw no range of the open pass serves gets
  its own range widened zero-extended at the draw, from its own binding (a gathered extent too; an
  indirect draw's to the end of the pool buffer bound in place), the arena taken before its pass ends, the pass ended
  (PerfInstr L1 "restart-off 8-bit indices") and begun again with the widening recorded by
  widen8_before_pass, as MoltenVK's own widening ends the Metal pass. Restart-on draws left to MoltenVK
  keep its values, which are NVN's there.
- A log-only census: the shader adds the 0xFF it meets in a zero-extended widening to a host-visible
  word, one for the ranges ahead and one for those at the draw; the report line gives them with the
  restart-off draws by how they were bound (ahead, at the draw, still 8-bit).
- docs: RX_VK_INDEX8_RAW; the bench's widen8 arms both keep it on (a speed-only comparison).
- tests/widen8_test.cpp: NVN's reference model (direct and indirect draws, restart on and off, both
  builds, both switch states), both forms in one slot, the census, Table by form, a timeline with
  random restart per draw and draws widened at the draw (widen8 on and off, index8_raw off);
  test_widen8.py: the at-draw path's order and the switch.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 47f4695bf24401a1f53a3f56c3cf21d7c1b6d4f9)
Widen8 r2's widen8_before_draw (index8_raw) binds a restart-off 8-bit draw's indices before begin_pass with the
arguments draw()'s 8-bit binds use (a direct draw's index_bytes(d), an indirect draw's 4 bytes inside its
demanded index extent), from draw() after its seam: listed in a Widen8 block with draw_demands as its resolver,
and its one caller.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit a0e18543c59aac736815d1d2790bac400af6d688)
Semantic merge fix (Integration 39), after load-clear and widen8:

- Both inputs took begin_pass's "same targets" test out into a function (load-clear's pass_on, widen8's
  pass_keeps, the same body). One is kept, pass_keeps; load-clear's begin_pass (which settles a held begin there)
  and lead_why ask it. test_load_clear cuts it in pass_on's place.
- The two act at a pass's begin. Widen8's widening (widen8_before_pass: a dispatch and barriers) is recorded in
  begin_pass after the pass-start barrier and before load-clear either holds the begin or records it, so on
  MoltenVK a pass's order is: barrier, widening, then the begin, recorded at once or held and recorded by
  pass_settle with the leading clears as its load ops. Nothing else is recorded between a held begin and its
  clears: the clears are the begin's load ops, and the replay settles before any operation that records
  (keeps_lead). widen8_before_draw ends a kept pass with end_pass, which settles a held begin first.
- test_load_clear's fixture runs begin_pass with Widen8's call in it: each pass begun has one widening, made outside
  any pass with no begin held, and the next command recorded after it is that pass's begin, off and on, in every
  sequence the test makes. A mutant with the widening after the begin fails it (i39-dev-03).
- test_widen8 reads begin_pass with load-clear's lead parameter and settle, and requires the widening before the hold.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 11fe233a898d5c5ad0191899bc6185d940b1e208)
fix(vk): Widen8's look ahead off by default; INDEX8_RAW stays on (mirrors macos/integrated 2b4b7b8e, I40)
Some checks failed
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been skipped
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been skipped
Third-party parts / The same as upstream (pull_request) Has been skipped
CI / Conventional Commit title (pull_request) Successful in 3s
CI / Workflow lint (actionlint) (pull_request) Successful in 5s
Guide screenshots / Guide images and local UI source review (pull_request) Failing after 5s
Third-party parts / As their notes say (pull_request) Successful in 10s
PR description / Description follows the template (pull_request_target) Successful in 5s
Website / Website on Cloudflare (pull_request_target) Has been skipped
7fd975fb05
Widen8's half of macos/integrated's 2b4b7b8e (Integration 40), on the parent's word from the E-series
(reports/2026-11-15-mac-perf-before-after.md, field-dawn save, 600 s holds on an M4 Max; run-to-run noise 0.17 ms):
all on but Widen8's look ahead (E4b) 33.7 fps, GPU 19.2 ms, against all on (E2) 33.7 fps, GPU 19.1 ms. Measured under
the noise, and only what is measured faster is adopted, so the look ahead is off by default:

- vk_vendor.h: widen8 default false on every driver; RX_VK_WIDEN8=1 turns it on (on MoltenVK only, widen8_on).
  index8_raw stays on on MoltenVK: the NVN-correct widening of restart-off 8-bit draws at the draw, which does not
  need the look ahead.
- docs/development.md: the switch row says so.
- test_widen8 pins x.widen8 = false, and that with the look ahead off index8_raw's widening at the draw is as it was:
  widen8_before_draw asks dev.index8_raw alone and widen8_before_pass records a draw's own range whether or not it
  scans ahead. widen8_test's comments and messages name the new default; its model with widen8 off and index8_raw on
  draws every restart-off draw widened at the draw, its pass ended for it, the fetched vertices NVN's.

The changed lines are 2b4b7b8e's for these files. Its bench row (RX_VK_BENCH_AB) change is LoadClear's sentence, made
on mac/load-clear.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
MeshBatch builds on the mesh fallback as #707 and #708 leave it: it holds back fallback_record_count's pass end
(#707's count draws), gives a joining draw a records set the size of its set's records area (#707's growing
area, so #708's provoking_fallback decides its rotation alike), and its tests follow #708's test_mesh_provoking
and fixtures. #708 carries #707 merged into #703, which this branch carries already, so the merge brings in #707
and #708's own commits.

Conflicts, resolved as Integration 36/37 (macos/integrated 3f872b0c) has them:
- vk_draw.cpp: the includes (widen8_scan.h, mesh_area.h, then load_clear.h); count_rewrite_records keeps #704's
  `perf_lines::why(perf_lines::PassEnd::CountDraw);` before its end_pass, the pass breaks counted by count_rewrite
  as #707 counts them.
- vk_draw.h: draw_perf_op's declaration, then draw_mesh_report's.
- tests/test_resolver_coverage.py: MeshCountDraws' block, then Widen8's, both in merged_sites.
The merge's change from the first parent is #708's change from #703, line for line, but for merged_sites' list.
The checks that follow #707's moves come in the next commit.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Semantic merge fix (Integration 36), after mesh-count:
- MeshCountDraws moved a count draw's rewrite, and its end_pass, from count_rewrite into count_rewrite_records,
  which count_rewrite and the mesh fallback's count draws (fallback_record_count) both call. The merge kept
  PerfInstr's perf_lines::why(CountDraw) before that end_pass. fallback_record_count ends the pass itself first
  (fallback_record returns into it before its own why(MeshFallback)), so that end was unsaid: it is now said
  MeshFallback, as fallback_record's. Log-only (RX_VK_PERF_LINES); nothing recorded changes.
  test_perf_lines checks the said reason in count_rewrite_records (CountDraw) and fallback_record_count
  (MeshFallback) where it checked count_rewrite.
- MeshCountDraws declares BuiltIn DrawIndex for a stage loading DrawID except a mesh fallback's task or mesh stage
  (a GLCompute stage, its draw index pushed at offset 76). test_drawid_direct pinned the old condition; it now pins
  the new one (DrawIdDirect's direct draws never come from those stages), and program_reads_draw_index's comment
  says so.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit b4732392089e74e7f8f21877927bff7eea541a87)
On MoltenVK a run of mesh fallback draws in one pass now draws in that
pass, their dispatches first, instead of ending the pass at every draw
(run #31: 74.8 of 76.9 fallback draws a frame split their pass, 72.2 of
them in runs one pass could hold).

The first fallback draw's pass is recorded into a secondary command
buffer; a later fallback draw that may join (gpu/vk/mesh_batch.h: same
targets, no memory written, no attachment of the batch sampled, batch not
sealed, under RX_VK_MESH_BATCH members, a records set of its own within
the allowance, bindings carriable) records its conversion and dispatches
into the recording's command buffer before the pass and its draw into a
new segment. When the pass ends the segments are executed in rendering
begun with VK_RENDERING_CONTENTS_SECONDARY_COMMAND_BUFFERS_BIT, a real
split (end, pass-start barrier, begin) wherever an attachment written
before a segment is sampled after it, one sampled is written, or a draw
may write memory (which also seals the batch). The stream's draws, state
and order are unchanged; only the members' dispatches move.

RX_VK_MESH_BATCH=0 turns it off (unset: 8 members; 2-64); B1 arm
RX_VK_BENCH_AB=mesh_batch. Off with the recorder, a parallel replay,
draw stats, barrier-every-draw, probes and dumps. vkCmdExecuteCommands
is loaded and carried by the recorder. A "vk: mesh batch" line reports
batches, pass ends saved and refusals. Fixtures that cut the touched
code get stand-ins and the new forms; tests/test_mesh_batch.py replays
recorded command shapes against a tile-GPU model.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit e73a3a1faeba006b694d456770fbef55b4f34507)
MeshBatchPass holds vectors; a thread_local with a constructor or
destructor is against test_tls_objects' rule (TLS_OBJECT: made on the
thread's first use, deleted with the thread). mesh_bp() replaces the
thread_local; the source checks follow.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 29b9374085714f723da7c92aacda6182f16f7885)
test: LoadClear, Widen8 and MeshBatch checked together at a pass's begin and end
Some checks failed
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been skipped
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been skipped
Third-party parts / The same as upstream (pull_request) Has been skipped
CI / Conventional Commit title (pull_request) Successful in 4s
CI / Workflow lint (actionlint) (pull_request) Successful in 6s
Guide screenshots / Guide images and local UI source review (pull_request) Failing after 5s
Third-party parts / As their notes say (pull_request) Successful in 10s
PR description / Description follows the template (pull_request_target) Successful in 5s
Website / Website on Cloudflare (pull_request_target) Has been skipped
94fca509f7
Semantic merge fix (Integration 39), after mesh-batch met load-clear and widen8. No code changes: the merge's
begin_pass, clears and end_pass are what these checks hold.

- LoadClear x MeshBatch. A batch opens only for a fallback draw's begin_pass (mesh_batch_opens, set in draw() around
  begin_pass(cmd, at)), and a begin is held only for a foldable clear's (lead), so a pass is never both. A clear
  in an open batch on its targets is Begun (pass_keeps, not held), not folded, and goes into the segment; a clear
  on other targets ends the batch (end_pass: pass_settle, a no-op then, and the emission) before its pass is held.
  test_load_clear's fixture now has MeshBatch: fallback draws whose dispatches end the pass and whose begin_pass
  may open a batch, its emission recorded where it opens. In 6,000 random sequences (5,512 batches) each batch's
  begin loads every attachment, none opens inside a pass or on a held begin, and the switch off and on give the
  same memory and commands but the folded clears. Its source checks allow vkCmdBeginRendering in
  mesh_batch_emit too, require the held begin and the batch's apart in begin_pass, mesh_batch_opens set in one
  place, and end_pass's pass_settle with the emission between it and vkCmdEndRendering. A mutant recording the
  begin when a batch opened fails it (i39-dev-09).
- Widen8 x MeshBatch. widen8_before_draw (which may end the pass, a batch's too) is asked before draw() moves the
  draw into a batch's segment; a batch's pass begins at a mesh draw, where widen8's look ahead stops, so no range
  serves its draws: restart-on 8-bit draws bind 8-bit indices (MoltenVK widens them, as before Widen8) and
  restart-off ones end the batch for the widening at the draw. test_widen8 holds that order, the scan's stop at a
  mesh draw, and reads fallback_record's call as MeshBatch has it.
- test_mesh_batch reads begin_pass and the clears with LoadClear's lead.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 7a7726fee9c0b211e723f555bb826aa6be4cb10c)
mooki force-pushed mac/mesh-batch from 94fca509f7
Some checks failed
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been skipped
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been skipped
Third-party parts / The same as upstream (pull_request) Has been skipped
CI / Conventional Commit title (pull_request) Successful in 4s
CI / Workflow lint (actionlint) (pull_request) Successful in 6s
Guide screenshots / Guide images and local UI source review (pull_request) Failing after 5s
Third-party parts / As their notes say (pull_request) Successful in 10s
PR description / Description follows the template (pull_request_target) Successful in 5s
Website / Website on Cloudflare (pull_request_target) Has been skipped
to 7a196c0a99
Some checks failed
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been skipped
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been skipped
Third-party parts / The same as upstream (pull_request) Has been skipped
Website / Website on Cloudflare (pull_request) Has been skipped
PR description / Description follows the template (pull_request_target) Successful in 7s
CI / Conventional Commit title (pull_request) Successful in 4s
CI / Workflow lint (actionlint) (pull_request) Successful in 7s
Guide screenshots / Guide images and local UI source review (pull_request) Failing after 6s
Third-party parts / As their notes say (pull_request) Successful in 11s
2026-11-15 15:05:34 +00:00
Compare
Author
Member

Folded into #852 (perf(macos): Mac performance on MoltenVK)

Folded into #852 (perf(macos): Mac performance on MoltenVK)
mooki closed this pull request 2026-11-15 18:09:29 +00:00
Some checks failed
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been skipped
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been skipped
Third-party parts / The same as upstream (pull_request) Has been skipped
Website / Website on Cloudflare (pull_request) Has been skipped
PR description / Description follows the template (pull_request_target) Successful in 7s
CI / Conventional Commit title (pull_request) Successful in 4s
CI / Workflow lint (actionlint) (pull_request) Successful in 7s
Guide screenshots / Guide images and local UI source review (pull_request) Failing after 6s
Third-party parts / As their notes say (pull_request) Successful in 11s

Pull request closed

Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
MagicBeanDev/Longshot!783
No description provided.