fix(vk): an indexed indirect draw binds the indices it can read, a whole vertex stream its pool #434
No reviewers
Labels
No labels
area/accessibility
area/audio
area/input
area/install
area/menu
area/renderer
area/saves
area/upscaling
beanstalk: approved @ 1697603
beanstalk: approved @ 2c5a62e
beanstalk: approved @ 4fd82b7
beanstalk: approved @ 64d6d09
beanstalk: approved @ 6f91fae
beanstalk: approved @ 70a78f9
beanstalk: approved @ 8d361dc
beanstalk: approved @ 9d8356e
beanstalk: approved @ abf2227
beanstalk: approved @ d82f202
beanstalk: approved @ e5f1e40
beanstalk: approved @ ee8ab5f
ci: approved
good first issue
gpu/amd
gpu/igpu
gpu/intel
gpu/nvidia
help wanted
kind
bug
kind
chore
kind
docs
kind
feature
kind
performance
kind
question
needs measurements
platform/linux
platform/macos
platform/steam-deck
platform/windows
preview
priority
critical
priority
high
priority
low
regression
status
blocked
status
confirmed
status
duplicate
status
invalid
status
needs-info
status
wontfix
testing wanted
ci: approved
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
MagicBeanDev/Longshot!434
Loading…
Reference in a new issue
No description provided.
Delete branch "mooki/Longshot:fix/indexed-indirect-extent"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Type: fix · Risk: medium · Tested on: macOS 26.7.1, Apple M4 Max (game-free checks, arm64 and x86_64 object compiles; no game or GPU run) · Needs: Windows or Linux, a discrete AMD or NVIDIA GPU
Why
An indexed indirect draw reads its first index and its count from records on the GPU, but the renderer bound only 4 bytes
of its index buffer. A vertex stream bound whole (size 0,
VK_WHOLE_SIZE) was bound by 1 byte. The import holding thosebytes ends where it happens to, a 4 MB chunk on with VRAM mirrors and 16 MB on without. Indices or vertices past that end
read 0 where robust buffer access is on and undefined bytes where it is off: MoltenVK, the 'Robust buffers' Off row, and
drivers without
robustBufferAccess2. Found by reading the code; nobody has reported it from the game.How to reproduce
Seen on: found by reading the code; nobody has reported it from the game. The game-free test shows it on macOS
26.7.1, Apple M4 Max, against main 6c9edffe's
vk_draw.cpp. The binding is the same code on every OS; undefined bytes(rather than 0) are read where robust buffer access is off.
Settings and switches: Robust buffers (Debug tab, Developer) Off, MoltenVK, or a driver without
robustBufferAccess2;with VRAM mirrors on, the import ends 4 MB on, 16 MB without.
Steps: game-free, from this branch's checkout:
python -I -B tests/test_draw_extent.py --cxx clang++ --headers <Vulkan include> --source <a tree of main>. In the game: an indexed indirect draw whose indices, or a stream bound wholewhose vertices, run past the import holding their first bytes (whether the game has one is unknown).
Expected: the bound buffer holds every byte the draw may read.
Actual: on main the bound index buffer holds 64 of the 122,683,456 bytes the draw may read, and a stream bound whole
16 of 118,489,104; past that, indices or vertices read 0 or undefined bytes. The test fails 6 checks on main.
What changed
An indexed indirect draw now binds every index it may read, and a vertex stream bound whole binds up to its game pool's
end, where one import can hold that; otherwise the binding is main's, with a log line. Bindings past 1 GB or whose import
fails stay as before.
index_extent,vertex_whole_extent,kImportMaxandextent_bind(vk_draw.cpp, besidevertex_bind_reuse), then their two call sites indraw().gpu/vk/vk_draw.cpp, three helpers besidevertex_bind_reuse, plus a constant.index_extent(d): the bytes an indexed indirect draw may read of its index buffer. Any index in[ibuf, ibuf + 2^33 units) may be read (a 32-bit first index plus a 32-bit count), up to the end of the game pool
holding ibuf (
gpu_pool_end); 4 where no pool holds it.vertex_whole_extent(addr): the bytes a stream bound whole may read, up to its game pool's end; 1 where no poolholds it.
kImportMax(1 GB): the most one import holds.pool_lookupmerges no more into one, mirrors are refused above it,and the import table's scans assume none is larger.
extent_bind(addr, extent, token, what)binds the whole extent where one import may hold it and does. Otherwise itbinds the first
tokenbytes, as before, and logs a line (at most 16 lines):vk: index buffer 0x2000000100: no single import takes the 8589934592 bytes a draw may read there; bound by its first 4 as before.That happens when the extent is over 1 GB or its import fails (the device's allocation or buffer limit).
draw()binds through them.index_extent(d)instead of 4 bytes.vertex_whole_extentfor a stream bound whole. A stream with a size binds that size, asbefore. The Vulkan binding size is unchanged (
VK_WHOLE_SIZEor the stream's size).vertex_bind_reuse) is still taken without a lookup. Its pool's end is asked only when the stream islooked up again.
tests/test_draw_extent.py(new).draw()'s own vertex-stream loop and indirect branch, with the structs and helpers they use, cut from thesource.
pool_lookupwith mirrors on),a device limit on an import's size, and recorded Vulkan binds.
which the test works out on its own.
bound as before, logged); an import refused by a 64 MB device limit (as before, logged); an address above 1 TB with
no pool end (4 bytes, as before); a non-indexed indirect draw; a stream with a size, a stream bound whole 16 bytes
before a chunk's end, a stream bound whole above 1 TB, and a stream bound whole in the 10 GB pool. A kept stream is
not looked up again, and no binding asks for an import over 1 GB.
tests/test_vertex_bind.pyandtests/test_fallback_pipeline_state.pyfollow the stream loop's binding intoextent_bind.test_vertex_bind.pystill checks that the kept stream is asked before any lookup, nowextent_bindandvertex_whole_extent.test_fallback_pipeline_state.pypastes the loop, so it now also pastesvertex_whole_extent,kImportMaxandextent_bindwhen the source has them, with a pool end of 0. Its checks are unchanged and still pass on main'ssource.
index_extentis the formula the macOS port's admission demands for these draws (SegBind), with the same nameand body. There, segment gathers take an extent across a segment's cut. Here an extent no single import holds keeps
main's binding; the pieces for that are the read-side fallback work, a separate change.
kImportMax. It sets the number inthe log line and keeps the formula the port's.
d.countindices) and the quad path (indices read on the CPU): they already bind orread what they draw.
without robust access is unknown, and it is left for a measurement first.
Measurements
Verdict: fixed in the game-free test: it fails on main (6 checks; the index buffer holds 64 of 122,683,456 bytes,
the whole stream 16 of 118,489,104) and passes here. The full game-free suite changes only by the new test. The in-game
cost of the larger bindings (fps, VRAM, hitches) is not measured yet: it is the test request (Testing).
Commits: main
6c9edffe5d(2026-11-14); this PR fix/indexed-indirect-extent @5bde466843.Hardware: Apple M4 Max (16 CPUs), macOS 26.7.1, Apple clang 21. Game-free: no GPU or game involved
Scene: none. This PR's proof is the test, as CONTRIBUTING's
fixrow allows. The in-game cost is a test request(Testing).
Method:
tests/test_draw_extent.pyagainst main'sgpu/vk/vk_draw.cpp,vk_draw.handvk_internal.h(6c9edffe)and against this PR; the full game-free suite on both
How to reproduce:
python -I -B tests/test_draw_extent.py --cxx clang++ --headers <Vulkan include>(add--source <a tree of main>for main's side)6c9edffe5bde4668tests/test_draw_extent.pytests/run_all.py --jobs 6)On main, four of the six failures are only the missing log line: there the binding is the one main makes. The other two
are the bug: the bound buffer ends before what the draw may read.
Screenshots: none; nothing on screen in the game-free tests.
Flags and settings
None.
Testing
Tested:
tests/test_draw_extent.pyfails on main and passes here.tests/test_vertex_bind.py,tests/test_draw_binds.pyandtests/test_fallback_pipeline_state.pypass.test_fallback_pipeline_state.pyalso passes against main'svk_draw.cpp(--source,6c9edffe).test_draw_extent, absent onmain, passes here.
requests, some are checks main added or changed since
5e072fc8):test_appimage_package_retry,test_appimage_player_build,test_collect_wide,test_control,test_cpm_discovery,test_crash_dialog,test_crash_record,test_env_on,test_game_layout,test_lift_cleanup_error,test_linux_preflight,test_longshot_ctl,test_low_memory_notice,test_make_game_cache,test_mem_gate,test_mem_gate_children,test_mirror_trim,test_oot_state_tap,test_pcache_header_read,test_pipeline_cache_file,test_pipeline_cache_save,test_pipeline_play_cache,test_pipeline_tour,test_prof_symbols,test_program_cache_cleanup,test_program_cache_one_variant,test_readback_late,test_report_run,test_split_giants,test_vk_pgo,test_vram_budget_row,test_walk_key.recorder_sync_test.exe-2026-11-14-180744.ips).
gpu/vk/vk_draw.cppwith the renderer's flags (nx2/build.py), Apple clang 21, arm64 andx86_64, on main and here.
vk_draw.cppstops atposix_fallocate(
pipeline_cache_file.h).environandposix_fallocate, all 4 objects build, 2 on main and 2 here.aa95d35f, at4b31c5bd(5bde4668before the rebases ontoe090cc48andaa95d35f):Sweep3's checks of this head (the C/C++ it changes compiled, and its own tests run, on arm64 and x86_64 where they
apply) gave the same results as before;
python -I -B tests/run_all.pyon wave S1's merge11a5b4e7, which carriesthis head: 568/22/17 (passed/failed/skipped) against main aa95d35f's 543/33/17 on this Mac, no new failure
(
reports/2026-11-15-rebase-sweep-3.md).Not tested:
gpu_pool_end,gpu_buffer_bind,logfandstd::atomic, as the code around it does.game pool, where before it took a chunk around its first bytes. On a discrete GPU each import gets a VRAM mirror of
its whole range (
mirror_on), within the mirrors' budget. A pool whose merged import passes 1 GB gets no mirror, andthe GPU reads it over PCIe. The first binding of a larger range merges the chunks already imported into one new
import on the replay thread. Main's comments in
vk_memory.cppput an import at 5-45 ms, and one that took twochunks of a pool as one at 130-147 ms, the one hitch of a panning run in Kokiri. How often that happens with this PR,
and what it costs in fps, VRAM and hitches, is unmeasured.
whether anything changes on screen, is unknown.
least 2 interleaved runs each, the median) is the test request below.
Must be tested:
4b31c5bdand mainaa95d35fthe same way; in Kokiri Forest and on a Hyrule Field walk, the same settings, 2 interleaved runs of 120 s eachafter a warm-up, the FPS counter at Breakdown, report fps (average and 1% low), median and p99 frame time, and the GPU's
longest frame, and from
longshot_log.txtthe import lines (vk: pool … imported in … ms as … (N imports, M ms in all)), the lastvk: N pools mirrored in VRAM (M MB), any… stays in system memory: the mirrors have … MB of …andwould take in … pools: left overlapping, and this PR'sno single import takes …lines (count them: each is abinding left as main makes it), expecting fps and frame times within run-to-run noise and the import count and mirror
total within a few percent of main's; then the same scenes once each on main and this PR with Robust buffers (Debug
tab, Developer) Off, looking for stretched or missing geometry on main that this PR fixes, and for none new, expecting
the same picture or a fix (by hand: test request T-CL66).
Risks
What could break:
with them.
would take in … pools: left overlapping), as any binding near 1 GB does today.mainor with the other macOS-port PRs rebased on6c9edffe(git merge-treeof thenew heads, 19:49 CST).
Turning it off: no switch. Reverting the commit is enough.
RX_VK_POOL_END_IMPORTED=1(an existing A/B switch) makesgpu_pool_endanswer the import's end, which narrows these extents to the import already there. It also changes thestorage-buffer bindings that ask the pool's end, so it is not an off switch for this PR.
Compatibility: Nothing changes:
vk_draw.cppis not in the translator stamp (nx2/translator_files.py), and saves,the options file, the shader and pipeline caches and the vk ABI are unchanged.
Checklist
Everyone
type(scope): description) of the status line's type.main: onaa95d35f(#761, 2026-11-15), head4b31c5bd, without a conflict; this PR's owncommits, old → new:
5bde4668→4b31c5bd; the commit names elsewhere in this description are from before therebase.
python -I -B tests/run_all.pypasses (any failure that also fails onmainis named). On macOS: 532 passed, 17skipped, and 32 failures that also fail on main
6c9edffeon this Mac, named under Testing. After the rebase onto mainaa95d35f(head4b31c5bd): wave S1's merge11a5b4e7, 568/22/17 against main aa95d35f's 543/33/17, no new failure.third_party/untouched (a new folder with aPROVENANCE.mdinstead).When it applies
Assisted-by:/Co-Authored-By:trailers.mainand passing here.tests/that reads changed source updated in this PR, not weakened.05c373755e5bde466843WIP: fix(vk): an indexed indirect draw binds the indices it can read, a whole vertex stream its poolto fix(vk): an indexed indirect draw binds the indices it can read, a whole vertex stream its pool5bde46684399c1327a5c99c1327a5c4b31c5bdbaView command line instructions
Checkout
From your project repository, check out a new branch and test the changes.