WIP: fix(vk): OoT's clustered lights sample their own shadow maps (handle tables as descriptor arrays, from Lichii) #107

Draft
pmwork-personal wants to merge 1 commit from pmwork-personal/Longshot:fix/light-table-shadow-handles into main
First-time contributor

Why

On Zora's Domain's ice and water, dark stair-step blocks sit around Link, and the snow shows green blocks (#52). OoT's clustered lights read each light's shadow-map handle per pixel from the light table; the shader translation could not compute such a handle, so those shadow tests bound no texture.

What changed

A port of Lichii bd0171f7 ("bind OoT light-table shadow maps through descriptor arrays … fixes the dark water grid", its #1485) and 282a585a (its #1523), by Zortos, to Longshot's copy of the recompiler, which had the same code as Lichii before them.

  • texture pass, gpu/vk/eden/shader_recompiler/ir_opt/texture_pass.cpp:
    • Track() no longer searches through texture instructions or float math. Where the shader reuses the handle's register for a sample result, it used to take c6[0x430], a float, as the handle.
    • BuildHandleNode() reads a word of a 12- or 16-byte load (CompositeExtractU32x3/x4: the shadow map's handle is the first word of a 16-byte load from the light's 96-byte entry). Its new address/index_word lambdas take one word read at a per-invocation address, scaled to a byte offset, as the element index of a descriptor array (HandleOp::Index).
    • MakeInst() retries with that index word where the host allows. Where the word dominates the sample (Dominance), the texture becomes an array of the table's first 64 entries (HANDLE_TABLE_SIZE), indexed nonuniformly with the word. TrimTrailingTees() lets the samples of one table share one handle program and one array. A word the earlier search still takes that reaches the handle only through a load's address is a pointer word (ReachesOutsideLoads()).
    • TexturePass(): the stage cap leaves handle-table arrays alone (it is 1 here, because host_info() sets no descriptor limits); the index word is the element itself.
  • shader_info.h: HandleOp::Index, EvaluateHandleProgram(..., element), TextureDescriptor::handle_is_pointer_word. host_translate_info.h: support_handle_table_arrays.
  • gpu/vk/vk_shader.cpp: stage_handle(..., element); new stage_handles() computes every element, reading the constant words and storage addresses once; new handle_tables_on() needs shaderSampledImageArrayNonUniformIndexing and the descriptor limits and logs its answer. It is read by host_info(), and by translator_stamp() when off.
  • gpu/vk/vk_draw.cpp build_descriptors(): binds a table's elements from stage_handles(), looks a repeated entry up once, and binds nothing for an entry that names a colour texture for a depth comparison (logged, at most 8 lines). handle_is_storage_pointer() honours the pointer word.
  • tests: new tests/test_handle_table_arrays.py. The stage_handle stubs in test_texture_handle_banks.py and test_program_answer_domains.py take the element.
  • docs: RX_VK_HANDLE_TABLES in docs/development.md.

Flags and settings

Flag What it does Default now Recommendation Helps on Doesn't help on Untested on
RX_VK_HANDLE_TABLES=0 Handle tables off: these samples are translated as on main (no texture). Part of the program cache's stamp only when off On where the GPU has shaderSampledImageArrayNonUniformIndexing On by default: it is the fix. The switch is for A/B and fps comparisons Every GPU with descriptor indexing A GPU without that feature: off there, as main In game on every GPU; AMD and Intel drivers

Measurements

main: b9cdc432 (2026-11-13)
this PR: fix/light-table-shadow-handles @ a801fd0c
Hardware: offline (RTX 5090, NVIDIA 621.24, Ryzen 7 9800X3D, Windows 11); no game run yet
Scene: the 4,672 fragment stages dumped in Kokiri Forest (HOS_SHADER_DUMP), through the translator built from each commit (frontend and IR passes; for this PR also the SPIR-V back end)
Method: a scratch copy of texture_pass.cpp logs how each bindless texture instruction got its handle; spirv-val v2026.4 (--target-env vulkan1.3 --uniform-buffer-standard-layout --scalar-block-layout, as the device has them) on each module with a table; tests/test_handle_table_arrays.py
How to reproduce: HOS_SHADER_DUMP=<folder> in longshot_env.txt and one visit to Kokiri Forest; then gpu/shadertool.cpp's path over the dump with that logging (the scripts are not part of this PR; on request). In game: see Testing.

main @ b9cdc432 this PR @ a801fd0c
Bindless depth-compare samples given the light table's address c0[0x520] (no texture at draw time) 6,494 in 687 programs 0
Bindless depth-compare samples given c6[0x430] (a float) 25 in 25 programs (all also in the 687) 0
Bindless depth-compare samples reading a handle-table array of 64 entries 0 6,519 in 687 programs, one array each
Other bindless samples (handle programs) 5,423 in 987 programs 5,423 in 987 programs
Fragment programs translated / failed 4,665 / 2 4,665 / 2 (the 2 are dumps cut off at 64 KiB)
SPIR-V of the 687 programs, spirv-val not run 687 of 687 valid; SampledImageArrayNonUniformIndexing, NonUniform on the index
tests/test_handle_table_arrays.py fails passes
main @ b9cdc432:
  words found (class, word, depth-compare): ('D', 'c0[0x520]', True) 6494, ('B', 'c6[0x430]', True) 25
  handle builder stopped at: CompositeExtractU32x4 6494
  tests/test_handle_table_arrays.py: AssertionError: Missing anchor: size_t HandleOperands(HandleOp op)
this PR @ a801fd0c:
  ('1', False) 5423 in 987 programs      (handle programs)
  ('3', True) 6519 in 687 programs       (handle-table arrays)
  largest descriptor count per program: (0, 52), (1, 3926), (64, 687); one table array per program: 687
  spirv-val: valid 687 invalid 0
  tests/test_handle_table_arrays.py: handle table checks passed / handle table source checks passed

In game (by eye), on this PC: RTX 5090, NVIDIA 621.24, Windows 11; options file output_res=3840x2160, dlss=2, frame_generation=3 (the baseline's 2), upscaling=0. Scene: Zora's River, child Link, reached with the Debug tab's Teleport from a Market save. Neither build is a Longshot build (none on this PC yet); the second carries this PR's change unchanged (af4d3774 = a801fd0c's diff on upstream Hookshot's main e0e60920), and the first has no change to the texture pass or the draw's texture binding.

Baseline: Hookshot dev build @ 06ad86fe (upstream ba3d3f73 + motion-vector research)

Hookshot dev @ 06ad86fe (no fix) Hookshot e0e60920 + this fix @ af4d3774
Dark stair-step blocks around Link, green blocks on lit surfaces present gone
vk: handle tables as descriptor arrays (no such line) on
fix run (hookshot_log.txt):
[hos host] hookshot v2.2.0-rc.1-7-gaf4d3774 (source 4c2318de)
[hos host] vk: handle tables as descriptor arrays: on
[hos BaseProcExecutor0] cheats: teleport child to ZoraRiver_Child at ZoraRiver_Entrance ...: request sent, result 0x1
[hos Presentation Thread] vk: handle table: element 43 of program 2fd8c7ca964e5a1d names texture #5722 (format 37) for a depth comparison: bound as nothing
  (8 such lines: elements 43-50 of two programs name colour textures (RGBA8, R11G11B10), probably entries past the last light)

Testing

Tested:

  • In game, by eye (see Measurements): at Zora's River with child Link, the blocks show on a build without this change and are gone with it (RTX 5090, NVIDIA 621.24, Windows 11). No errors in the log.
  • Offline as above.
  • python -I -B tests/run_all.py (Windows, 16 jobs): 489 passed, 6 failed, 36 skipped. The 6 fail on main @ b9cdc432 too on this PC: test_fpx_sticky, test_spawn_emit, test_portable_builder_startup, test_setup_runtime, test_check_third_party, test_release.
  • The new test caught 4 of 4 mutations: Index ignoring the element, stage_handles() passing no element, trailing Tees kept, the 16-byte extract dropped.
  • A full Windows build of the same change on upstream Hookshot's main (the changed code is the same there) prepared every shipped program with the handle tables on: vk: handle tables as descriptor arrays: on; prepared 38809 shader programs (46666 new, 1 failed ...), the same counts as that main's own build; 24,453 pipelines built by the driver, 0 not made.

Not tested:

  • In game: frozen Zora's Domain (adult), the Chamber of Sages and Jabu-Jabu; a Longshot build of this PR's commit (the run above used the same change on upstream Hookshot).
  • The fps cost: each draw of the 687 programs binds a 64-element array, and these programs leave push descriptors for the descriptor pool.
  • AMD and Intel drivers.
  • A Linux build.

Needs other testers:

  • Any GPU, with a save at frozen Zora's Domain (adult), the Chamber of Sages floor, Jabu-Jabu's water, child Zora's Domain's water or the Sacred Realm medallion scene: no stair-step blocks around Link on ice or water and no green blocks on the snow, expecting them back with RX_VK_HANDLE_TABLES=0; fps with it on and off (T-5ECM).
  • AMD (Windows and RADV): the same, expecting no green tint (Lichii saw AMD tint from these samples) (T-7DJC).

Screenshots

main: the two screenshots in #52 (the reporter's v2.2.0-rc.1). This PR: none yet (the fix was checked by eye at Zora's River).

Risk and rollback

  • CPU time per draw for the 687 programs (64 handles computed and bound per draw): to be measured.
  • A stale table entry that names another depth texture would shadow with the wrong map for its light: that light's shadow is wrong, as it was on main.
  • Nonuniform indexing on other drivers.
  • To turn it off: RX_VK_HANDLE_TABLES=0 (these samples as on main), or revert the commit.

Reviewer notes

Start at MakeInst() and BuildHandleNode()'s address/index_word in texture_pass.cpp, then the table path in build_descriptors(). Not ported from Lichii, on purpose:

  • its pipeline-wide fitting of descriptor arrays (host_info() sets no limits here, and handle_tables_on() asks for at least 256 per stage);
  • its HandleTableNotes log;
  • the dual-vertex join of handle programs;
  • its pipeline-cache record drop (Longshot has no such records).

Issues

Fixes #52

Checklist

For everyone

  • One topic.
  • The title is a Conventional Commit (type(scope): description).
  • Rebased on current main.
  • python -I -B tests/run_all.py run and passing (any failure that also fails on main is named above).
  • Tested in the game, or what wasn't is named under Testing.
  • Testing filled in: what was tested, what wasn't, and what needs other testers (with test requests).
  • Measured: main and this PR at named commits, on the same PC, scene and settings, at least 2 interleaved runs each, the median reported.
  • 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.

If applicable

  • AI assistance recorded with Assisted-by: / Co-Authored-By: trailers.
  • Flags noted, and the switch reference updated (docs/development.md, the README).
  • Screenshots for a change that shows on screen.
  • 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.
  • Host code builds on both Windows (llvm-mingw) and Linux, with UTF-8 paths.
  • Docs updated.
## Why On Zora's Domain's ice and water, dark stair-step blocks sit around Link, and the snow shows green blocks (#52). OoT's clustered lights read each light's shadow-map handle per pixel from the light table; the shader translation could not compute such a handle, so those shadow tests bound no texture. ## What changed A port of Lichii `bd0171f7` ("bind OoT light-table shadow maps through descriptor arrays … fixes the dark water grid", its #1485) and `282a585a` (its #1523), by Zortos, to Longshot's copy of the recompiler, which had the same code as Lichii before them. - texture pass, `gpu/vk/eden/shader_recompiler/ir_opt/texture_pass.cpp`: - `Track()` no longer searches through texture instructions or float math. Where the shader reuses the handle's register for a sample result, it used to take `c6[0x430]`, a float, as the handle. - `BuildHandleNode()` reads a word of a 12- or 16-byte load (`CompositeExtractU32x3`/`x4`: the shadow map's handle is the first word of a 16-byte load from the light's 96-byte entry). Its new `address`/`index_word` lambdas take one word read at a per-invocation address, scaled to a byte offset, as the element index of a descriptor array (`HandleOp::Index`). - `MakeInst()` retries with that index word where the host allows. Where the word dominates the sample (`Dominance`), the texture becomes an array of the table's first 64 entries (`HANDLE_TABLE_SIZE`), indexed nonuniformly with the word. `TrimTrailingTees()` lets the samples of one table share one handle program and one array. A word the earlier search still takes that reaches the handle only through a load's address is a pointer word (`ReachesOutsideLoads()`). - `TexturePass()`: the stage cap leaves handle-table arrays alone (it is 1 here, because `host_info()` sets no descriptor limits); the index word is the element itself. - `shader_info.h`: `HandleOp::Index`, `EvaluateHandleProgram(..., element)`, `TextureDescriptor::handle_is_pointer_word`. `host_translate_info.h`: `support_handle_table_arrays`. - `gpu/vk/vk_shader.cpp`: `stage_handle(..., element)`; new `stage_handles()` computes every element, reading the constant words and storage addresses once; new `handle_tables_on()` needs `shaderSampledImageArrayNonUniformIndexing` and the descriptor limits and logs its answer. It is read by `host_info()`, and by `translator_stamp()` when off. - `gpu/vk/vk_draw.cpp` `build_descriptors()`: binds a table's elements from `stage_handles()`, looks a repeated entry up once, and binds nothing for an entry that names a colour texture for a depth comparison (logged, at most 8 lines). `handle_is_storage_pointer()` honours the pointer word. - tests: new `tests/test_handle_table_arrays.py`. The `stage_handle` stubs in `test_texture_handle_banks.py` and `test_program_answer_domains.py` take the element. - docs: `RX_VK_HANDLE_TABLES` in `docs/development.md`. ## Flags and settings | Flag | What it does | Default now | Recommendation | Helps on | Doesn't help on | Untested on | |---|---|---|---|---|---|---| | `RX_VK_HANDLE_TABLES=0` | Handle tables off: these samples are translated as on `main` (no texture). Part of the program cache's stamp only when off | On where the GPU has `shaderSampledImageArrayNonUniformIndexing` | On by default: it is the fix. The switch is for A/B and fps comparisons | Every GPU with descriptor indexing | A GPU without that feature: off there, as `main` | In game on every GPU; AMD and Intel drivers | ## Measurements main: b9cdc432 (2026-11-13) this PR: fix/light-table-shadow-handles @ a801fd0c Hardware: offline (RTX 5090, NVIDIA 621.24, Ryzen 7 9800X3D, Windows 11); no game run yet Scene: the 4,672 fragment stages dumped in Kokiri Forest (`HOS_SHADER_DUMP`), through the translator built from each commit (frontend and IR passes; for this PR also the SPIR-V back end) Method: a scratch copy of `texture_pass.cpp` logs how each bindless texture instruction got its handle; `spirv-val` v2026.4 (`--target-env vulkan1.3 --uniform-buffer-standard-layout --scalar-block-layout`, as the device has them) on each module with a table; `tests/test_handle_table_arrays.py` How to reproduce: `HOS_SHADER_DUMP=<folder>` in `longshot_env.txt` and one visit to Kokiri Forest; then `gpu/shadertool.cpp`'s path over the dump with that logging (the scripts are not part of this PR; on request). In game: see Testing. | | main @ b9cdc432 | this PR @ a801fd0c | |---|---|---| | Bindless depth-compare samples given the light table's address `c0[0x520]` (no texture at draw time) | 6,494 in 687 programs | 0 | | Bindless depth-compare samples given `c6[0x430]` (a float) | 25 in 25 programs (all also in the 687) | 0 | | Bindless depth-compare samples reading a handle-table array of 64 entries | 0 | 6,519 in 687 programs, one array each | | Other bindless samples (handle programs) | 5,423 in 987 programs | 5,423 in 987 programs | | Fragment programs translated / failed | 4,665 / 2 | 4,665 / 2 (the 2 are dumps cut off at 64 KiB) | | SPIR-V of the 687 programs, `spirv-val` | not run | 687 of 687 valid; `SampledImageArrayNonUniformIndexing`, `NonUniform` on the index | | `tests/test_handle_table_arrays.py` | fails | passes | ``` main @ b9cdc432: words found (class, word, depth-compare): ('D', 'c0[0x520]', True) 6494, ('B', 'c6[0x430]', True) 25 handle builder stopped at: CompositeExtractU32x4 6494 tests/test_handle_table_arrays.py: AssertionError: Missing anchor: size_t HandleOperands(HandleOp op) this PR @ a801fd0c: ('1', False) 5423 in 987 programs (handle programs) ('3', True) 6519 in 687 programs (handle-table arrays) largest descriptor count per program: (0, 52), (1, 3926), (64, 687); one table array per program: 687 spirv-val: valid 687 invalid 0 tests/test_handle_table_arrays.py: handle table checks passed / handle table source checks passed ``` In game (by eye), on this PC: RTX 5090, NVIDIA 621.24, Windows 11; options file `output_res=3840x2160`, `dlss=2`, `frame_generation=3` (the baseline's 2), `upscaling=0`. Scene: Zora's River, child Link, reached with the Debug tab's Teleport from a Market save. Neither build is a Longshot build (none on this PC yet); the second carries this PR's change unchanged (`af4d3774` = `a801fd0c`'s diff on upstream Hookshot's `main` `e0e60920`), and the first has no change to the texture pass or the draw's texture binding. Baseline: Hookshot dev build @ 06ad86fe (upstream `ba3d3f73` + motion-vector research) | | Hookshot dev @ 06ad86fe (no fix) | Hookshot `e0e60920` + this fix @ af4d3774 | |---|---|---| | Dark stair-step blocks around Link, green blocks on lit surfaces | present | gone | | `vk: handle tables as descriptor arrays` | (no such line) | `on` | ``` fix run (hookshot_log.txt): [hos host] hookshot v2.2.0-rc.1-7-gaf4d3774 (source 4c2318de) [hos host] vk: handle tables as descriptor arrays: on [hos BaseProcExecutor0] cheats: teleport child to ZoraRiver_Child at ZoraRiver_Entrance ...: request sent, result 0x1 [hos Presentation Thread] vk: handle table: element 43 of program 2fd8c7ca964e5a1d names texture #5722 (format 37) for a depth comparison: bound as nothing (8 such lines: elements 43-50 of two programs name colour textures (RGBA8, R11G11B10), probably entries past the last light) ``` ## Testing **Tested:** - In game, by eye (see Measurements): at Zora's River with child Link, the blocks show on a build without this change and are gone with it (RTX 5090, NVIDIA 621.24, Windows 11). No errors in the log. - Offline as above. - `python -I -B tests/run_all.py` (Windows, 16 jobs): 489 passed, 6 failed, 36 skipped. The 6 fail on `main` @ b9cdc432 too on this PC: `test_fpx_sticky`, `test_spawn_emit`, `test_portable_builder_startup`, `test_setup_runtime`, `test_check_third_party`, `test_release`. - The new test caught 4 of 4 mutations: `Index` ignoring the element, `stage_handles()` passing no element, trailing Tees kept, the 16-byte extract dropped. - A full Windows build of the same change on upstream Hookshot's `main` (the changed code is the same there) prepared every shipped program with the handle tables on: `vk: handle tables as descriptor arrays: on`; `prepared 38809 shader programs (46666 new, 1 failed ...)`, the same counts as that main's own build; 24,453 pipelines built by the driver, 0 not made. **Not tested:** - In game: frozen Zora's Domain (adult), the Chamber of Sages and Jabu-Jabu; a Longshot build of this PR's commit (the run above used the same change on upstream Hookshot). - The fps cost: each draw of the 687 programs binds a 64-element array, and these programs leave push descriptors for the descriptor pool. - AMD and Intel drivers. - A Linux build. **Needs other testers:** - Any GPU, with a save at frozen Zora's Domain (adult), the Chamber of Sages floor, Jabu-Jabu's water, child Zora's Domain's water or the Sacred Realm medallion scene: no stair-step blocks around Link on ice or water and no green blocks on the snow, expecting them back with `RX_VK_HANDLE_TABLES=0`; fps with it on and off (T-5ECM). - AMD (Windows and RADV): the same, expecting no green tint (Lichii saw AMD tint from these samples) (T-7DJC). ## Screenshots `main`: the two screenshots in #52 (the reporter's v2.2.0-rc.1). This PR: none yet (the fix was checked by eye at Zora's River). ## Risk and rollback - CPU time per draw for the 687 programs (64 handles computed and bound per draw): to be measured. - A stale table entry that names another depth texture would shadow with the wrong map for its light: that light's shadow is wrong, as it was on `main`. - Nonuniform indexing on other drivers. - To turn it off: `RX_VK_HANDLE_TABLES=0` (these samples as on `main`), or revert the commit. ## Reviewer notes Start at `MakeInst()` and `BuildHandleNode()`'s `address`/`index_word` in `texture_pass.cpp`, then the table path in `build_descriptors()`. Not ported from Lichii, on purpose: - its pipeline-wide fitting of descriptor arrays (`host_info()` sets no limits here, and `handle_tables_on()` asks for at least 256 per stage); - its `HandleTableNotes` log; - the dual-vertex join of handle programs; - its pipeline-cache record drop (Longshot has no such records). ## Issues Fixes #52 ## Checklist ### For everyone - [x] One topic. - [x] The title is a Conventional Commit (`type(scope): description`). - [x] Rebased on current `main`. - [x] `python -I -B tests/run_all.py` run and passing (any failure that also fails on `main` is named above). - [x] Tested in the game, or what wasn't is named under Testing. - [x] Testing filled in: what was tested, what wasn't, and what needs other testers (with test requests). - [ ] Measured: main and this PR at named commits, on the same PC, scene and settings, at least 2 interleaved runs each, the median reported. - [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. ### If applicable - [x] AI assistance recorded with `Assisted-by:` / `Co-Authored-By:` trailers. - [x] Flags noted, and the switch reference updated (docs/development.md, the README). - [ ] Screenshots for a change that shows on screen. - [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. - [ ] Host code builds on both Windows (llvm-mingw) and Linux, with UTF-8 paths. - [x] Docs updated.
fix(vk): OoT's clustered lights sample their own shadow maps (handle tables as descriptor arrays, from Lichii)
Some checks are pending
CI / Conventional Commit title (pull_request) Blocked by required conditions
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Blocked by required conditions
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Blocked by required conditions
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Blocked by required conditions
CI / Workflow lint (actionlint) (pull_request) Blocked by required conditions
Third-party parts / As their notes say (pull_request) Blocked by required conditions
Third-party parts / The same as upstream (pull_request) Blocked by required conditions
PR description / Description follows the template (pull_request_target) Successful in 2s
a801fd0c34
OoT lights the world with clustered lighting: each world fragment shader loops over the lights of the pixel's cluster,
reads each light's shadow-map handle from the light table (the first word of a 16-byte load from the light's 96-byte
entry, at a light index read per pixel) and samples it with depth comparison. The translation could not compute such a
handle: the handle builder stopped at CompositeExtractU32x4, and the earlier search through loads took c0[0x520], the
light table's address, which the Fritchii guard binds as no texture. Where the program reuses the handle's register for
a sample result, Track walked into the sample's coordinates and took c6[0x430] (a float). So no clustered light's shadow
test read its shadow map: the dark stair-step blocks around Link on Zora's Domain's ice and water, and green blocks on
its snow (issue 52; NotRexed/Hookshot issues 2509 and 1727 on GitHub).

A port of Lichii bd0171f7 (#1485, "fixes the dark water grid") and 282a585a (#1523), by Zortos, to Longshot's copy of the recompiler (the same as Hookshot's):

- Track does not search through texture instructions or float math.
- The handle builder reads a word of a 12- or 16-byte load, and takes one word read at a per-invocation address,
  scaled to a byte offset, as the element index of a descriptor array (HandleOp::Index). Such a texture gets an array of
  the table's first 64 entries, indexed nonuniformly with that word, where the word dominates the sample. Trailing Tees
  are trimmed, so the samples of one table share one handle program and one array.
- The draw evaluates the program for each element (stage_handles: the constant words and storage addresses it reads
  are read once) and binds the array; a table entry naming a colour texture for a depth comparison binds nothing.
- A word the earlier search still takes, which reaches the handle only through a load's address, is a pointer and
  binds no texture (handle_is_pointer_word), storage buffer or not.
- Handle tables need nonuniform sampled image array indexing; RX_VK_HANDLE_TABLES=0 turns them off (a test; in the
  cache's stamp). The stage cap (1 with this host_info, which sets no descriptor limits) leaves a handle table's array alone.

Offline, the translator over 4,672 dumped fragment stages (Kokiri Forest): all 6,519 bindless depth-compare samples
(687 programs) now read one 64-entry table array each (before: 6,494 took c0[0x520], 25 took c6[0x430]); the 5,423
other bindless samples are unchanged; no new translation failures; the 687 modules pass spirv-val (vulkan1.3,
standard and scalar block layouts). tests/test_handle_table_arrays.py checks HandleOp::Index, the Tee trim and
stage_handles from the sources' own text, and the translation and draw paths; two tests' stage_handle stubs take the
element; docs/development.md lists RX_VK_HANDLE_TABLES.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Author
First-time contributor

Tested in game: the fix works.

At Zora's River with child Link (reached with the Debug tab's Teleport), on an RTX 5090 with NVIDIA 621.24 and Windows 11:

  • Without the change (a Hookshot dev build, 06ad86fe, with no change to the texture pass or the texture binding): the dark stair-step blocks are there.
  • With the change (af4d3774: this PR's diff on upstream Hookshot's main): they are gone. The log says vk: handle tables as descriptor arrays: on and shows no errors.

The description's Measurements and Testing now say so. Still open before ready: a Longshot build of a801fd0c, fps with RX_VK_HANDLE_TABLES=0 against on, screenshots, frozen Zora's Domain, and AMD/Linux (T-5ECM, T-7DJC).

Tested in game: the fix works. At Zora's River with child Link (reached with the Debug tab's Teleport), on an RTX 5090 with NVIDIA 621.24 and Windows 11: - **Without the change** (a Hookshot dev build, `06ad86fe`, with no change to the texture pass or the texture binding): the dark stair-step blocks are there. - **With the change** (`af4d3774`: this PR's diff on upstream Hookshot's `main`): they are gone. The log says `vk: handle tables as descriptor arrays: on` and shows no errors. The description's Measurements and Testing now say so. Still open before ready: a Longshot build of `a801fd0c`, fps with `RX_VK_HANDLE_TABLES=0` against on, screenshots, frozen Zora's Domain, and AMD/Linux (T-5ECM, T-7DJC).
HumanBean added this to the v3.0.0 project 2026-11-13 04:16:41 +00:00
HumanBean left a comment

No blocking bugs found: the port looks right. A few things inline, none of them blockers.

What I checked

  • It's on current main (b9cdc432).
  • On Linux, tests/test_handle_table_arrays.py and tests/test_texture_handle_banks.py pass. The full run_all was 451 passed, 11 failed, 69 skipped. The cache tests among the failures fail the same way on main in the same environment, and a lot of GPU tests were skipped because there are no Vulkan headers here.
  • The program cache gets invalidated: LONGSHOT_SHADER_STAMP hashes eden/ and vk_shader.cpp, and HandleOp::Index is added at the end of the enum.
  • shaderSampledImageArrayNonUniformIndexing really is enabled: vk_main.cpp passes the queried feature chain to vkCreateDevice.
  • Variant answers (record_answers) skip count>1, so the tables stay out of them.
  • A failed build rolls back g_handle_index.
  • HandleOperands matches EvaluateHandleProgram.
  • A transformed index word stays consistent, since element k goes through the same f.

Still needed before pr ready (you list these yourself):

  • A Longshot build of a801fd0c run in the game. The in-game check so far used the same diff on upstream Hookshot.
  • Interleaved before/after fps.
  • Screenshots.
  • A Linux build.
  • AMD and Intel drivers (T-5ECM, T-7DJC).
No blocking bugs found: the port looks right. A few things inline, none of them blockers. **What I checked** - It's on current main (b9cdc432). - On Linux, `tests/test_handle_table_arrays.py` and `tests/test_texture_handle_banks.py` pass. The full `run_all` was 451 passed, 11 failed, 69 skipped. The cache tests among the failures fail the same way on main in the same environment, and a lot of GPU tests were skipped because there are no Vulkan headers here. - The program cache gets invalidated: `LONGSHOT_SHADER_STAMP` hashes `eden/` and `vk_shader.cpp`, and `HandleOp::Index` is added at the end of the enum. - `shaderSampledImageArrayNonUniformIndexing` really is enabled: `vk_main.cpp` passes the queried feature chain to `vkCreateDevice`. - Variant answers (`record_answers`) skip count>1, so the tables stay out of them. - A failed build rolls back `g_handle_index`. - `HandleOperands` matches `EvaluateHandleProgram`. - A transformed index word stays consistent, since element k goes through the same f. **Still needed before `pr ready`** (you list these yourself): - A Longshot build of a801fd0c run in the game. The in-game check so far used the same diff on upstream Hookshot. - Interleaved before/after fps. - Screenshots. - A Linux build. - AMD and Intel drivers (T-5ECM, T-7DJC).
@ -425,0 +447,4 @@
// made from them, while a register the program reuses for the handle and for a sample
// result (a Phi of both: OoT's clustered lights) would lead the search through the
// sample's coordinates to an unrelated word (c6[0x430], a float).
if (IsTextureInstruction(*inst) || IsFloatValue(*inst)) {
Owner

This skip applies to every bindless sample, whatever support_handle_table_arrays says. So do TrimTrailingTees and the pointer_word marking. That means RX_VK_HANDLE_TABLES=0 doesn't translate "as on main", which is what the Flags table and docs/development.md say.

It's probably harmless: the offline numbers show the other 5,423 samples unchanged. Still, either have the docs and PR text say what the switch actually turns back, or gate these changes on the switch too.

This skip applies to every bindless sample, whatever `support_handle_table_arrays` says. So do `TrimTrailingTees` and the `pointer_word` marking. That means `RX_VK_HANDLE_TABLES=0` doesn't translate "as on main", which is what the Flags table and docs/development.md say. It's probably harmless: the offline numbers show the other 5,423 samples unchanged. Still, either have the docs and PR text say what the switch actually turns back, or gate these changes on the switch too.
@ -964,0 +1233,4 @@
// The shader indexes the array with the index word, which must be defined wherever
// the texture instruction runs; where it isn't, every invocation takes the first
// element.
u32 count{1};
Owner

When the index word doesn't dominate the sample, the descriptor stays count 1, but its handle program still contains HandleOp::Index, which then gets evaluated with element 0. Every pixel would get table entry 0's shadow map. That's a new kind of wrong: main showed nothing there.

Does any program in the dump hit this case? If one does, I'd fall back to the legacy search there, or mark the word as a pointer word, rather than bind entry 0.

When the index word doesn't dominate the sample, the descriptor stays count 1, but its handle program still contains `HandleOp::Index`, which then gets evaluated with element 0. Every pixel would get table entry 0's shadow map. That's a new kind of wrong: main showed nothing there. Does any program in the dump hit this case? If one does, I'd fall back to the legacy search there, or mark the word as a pointer word, rather than bind entry 0.
@ -1423,0 +1713,4 @@
const IR::U32 element{size_shift != 0
? IR::U32{ir.ShiftRightLogical(cbuf.dynamic_offset, ir.Imm32(size_shift))}
: cbuf.dynamic_offset};
inst->SetArg(0, ir.UMin(element, ir.Imm32(count - 1)));
Owner

With HANDLE_TABLE_SIZE = 64, any light index of 64 or more gets clamped to entry 63's shadow map, and nothing is logged. That's fine at Zora's River (about 43 lights going by your log), but a busier scene would get the wrong shadows without any sign of it.

Suggestion: log a warning in build_descriptors when table entry 63 is non-zero, since that means the table probably runs past 64.

With `HANDLE_TABLE_SIZE = 64`, any light index of 64 or more gets clamped to entry 63's shadow map, and nothing is logged. That's fine at Zora's River (about 43 lights going by your log), but a busier scene would get the wrong shadows without any sign of it. Suggestion: log a warning in `build_descriptors` when table entry 63 is non-zero, since that means the table probably runs past 64.
@ -3591,0 +3595,4 @@
// shadow map). The handles of all elements at once: the constant words and storage addresses the program reads
// are read once
static thread_local std::vector<uint32_t> table;
const bool is_table = d.handle_program != ~0U && d.count > 1;
Owner

For each draw, the 687 table programs run 64 handle evaluations and up to 64 texture_view_of lookups, and the reuse check only catches an entry that equals the one right before it. You already list this as unmeasured. It's the number to get before ready. If it shows up, a per-draw handle-to-view cache (or checking against the entries already seen) would help.

For each draw, the 687 table programs run 64 handle evaluations and up to 64 `texture_view_of` lookups, and the reuse check only catches an entry that equals the one right before it. You already list this as unmeasured. It's the number to get before ready. If it shows up, a per-draw handle-to-view cache (or checking against the entries already seen) would help.
@ -560,0 +573,4 @@
const bool want = !e || atoi(e) != 0;
logf("vk: handle tables as descriptor arrays: %s", !want ? "off (RX_VK_HANDLE_TABLES=0)"
: can ? "on"
: "off (no nonuniform sampled image array indexing)");
Owner

can is also false when the descriptor limits are too low (under 256 per stage, or under 512 per set), but the log always says "no nonuniform sampled image array indexing". Could it say which check failed (the feature, or which limit)? That would save a round trip on a tester's log.

`can` is also false when the descriptor limits are too low (under 256 per stage, or under 512 per set), but the log always says "no nonuniform sampled image array indexing". Could it say which check failed (the feature, or which limit)? That would save a round trip on a tester's log.
First-time contributor

Test request T-5ECM: mixed (no regression found; the bug didn't show on main here, so the fix couldn't be confirmed)

Hardware: RTX 3080 10 GB, NVIDIA 621.14, i9-12900K, Windows 11. Built main @ b9cdc432 and this PR @ a801fd0c the same way (standalone, llvm-mingw). 1280x720 window, default options (no DLSS: these builds have no NGX SDK), frame cap 60.

What I ran, on each build: an adult save, the debug teleport to Zora's Domain (frozen, ZoraDomain_Adult), then 8 steps forward (2.5 s of stick each) through the domain onto the frozen lake and past the pillar to the snow by the cave, with a screenshot after each step; on main also adult Zora's River with a short walk. Each build had a warm-up run first, then the compared run.

  • On main, I couldn't reproduce issue 52. On the frozen lake around Link (Navi beside him) and on the snow at the same spots as the PR run, there are no dark stair-step blocks and no green blocks, also with the contrast raised. Zora's River (adult) showed none either. There are magenta/purple patches on the snow near the cave and by the pillar, but they look the same on main and on this PR, so I take them for scene content, not this bug.
  • With this PR: the same spots look the same as main; nothing new drawn wrong, no flicker, no missing textures. The log says vk: handle tables as descriptor arrays: on. No device-lost or die() lines in any run.
  • fps, Zora's Domain entrance, settled (median of the 5 s frames presented lines after arrival): main 45.6 and 47.6, this PR 48.7 and 46.5 (one warm-up and one compared run each). Level within run-to-run noise. Further in (the lake, the pillar, the cave) both held the 60 cap.
  • Not run: RX_VK_HANDLE_TABLES=0 (with no visible difference between main and the PR here, it would show nothing either), child Zora's River, Jabu-Jabu's water, the Chamber of Sages and the Sacred Realm (no save there), DLSS or DLAA, and higher resolutions. The reporter of issue 52 used an RTX 3080 Ti at 1920x1080 fullscreen with DLAA; your own repro was 4K with DLSS. The blocks may depend on the upscaler or the resolution, which this PC's builds didn't match.

So on an RTX 3080 / Windows the PR changes nothing visible and costs nothing measurable at these spots; whether it removes the blocks needs a run where main shows them.

Test request T-5ECM: mixed (no regression found; the bug didn't show on main here, so the fix couldn't be confirmed) Hardware: RTX 3080 10 GB, NVIDIA 621.14, i9-12900K, Windows 11. Built main @ b9cdc432 and this PR @ a801fd0c the same way (standalone, llvm-mingw). 1280x720 window, default options (no DLSS: these builds have no NGX SDK), frame cap 60. What I ran, on each build: an adult save, the debug teleport to Zora's Domain (frozen, `ZoraDomain_Adult`), then 8 steps forward (2.5 s of stick each) through the domain onto the frozen lake and past the pillar to the snow by the cave, with a screenshot after each step; on main also adult Zora's River with a short walk. Each build had a warm-up run first, then the compared run. - **On main, I couldn't reproduce issue 52.** On the frozen lake around Link (Navi beside him) and on the snow at the same spots as the PR run, there are no dark stair-step blocks and no green blocks, also with the contrast raised. Zora's River (adult) showed none either. There are magenta/purple patches on the snow near the cave and by the pillar, but they look the same on main and on this PR, so I take them for scene content, not this bug. - **With this PR:** the same spots look the same as main; nothing new drawn wrong, no flicker, no missing textures. The log says `vk: handle tables as descriptor arrays: on`. No device-lost or die() lines in any run. - **fps**, Zora's Domain entrance, settled (median of the 5 s `frames presented` lines after arrival): main 45.6 and 47.6, this PR 48.7 and 46.5 (one warm-up and one compared run each). Level within run-to-run noise. Further in (the lake, the pillar, the cave) both held the 60 cap. - **Not run:** `RX_VK_HANDLE_TABLES=0` (with no visible difference between main and the PR here, it would show nothing either), child Zora's River, Jabu-Jabu's water, the Chamber of Sages and the Sacred Realm (no save there), DLSS or DLAA, and higher resolutions. The reporter of issue 52 used an RTX 3080 Ti at 1920x1080 fullscreen with DLAA; your own repro was 4K with DLSS. The blocks may depend on the upscaler or the resolution, which this PC's builds didn't match. So on an RTX 3080 / Windows the PR changes nothing visible and costs nothing measurable at these spots; whether it removes the blocks needs a run where main shows them.
Some checks are pending
CI / Conventional Commit title (pull_request) Blocked by required conditions
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Blocked by required conditions
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Blocked by required conditions
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Blocked by required conditions
CI / Workflow lint (actionlint) (pull_request) Blocked by required conditions
Third-party parts / As their notes say (pull_request) Blocked by required conditions
Third-party parts / The same as upstream (pull_request) Blocked by required conditions
PR description / Description follows the template (pull_request_target) Successful in 2s
This pull request has changes conflicting with the target branch.
  • gpu/vk/vk_draw.cpp
Some workflows are waiting to be reviewed.
View command line instructions

Manual merge helper

Use this merge commit message when completing the merge manually.

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u 'fix/light-table-shadow-handles:pmwork-personal-fix/light-table-shadow-handles'
git switch 'pmwork-personal-fix/light-table-shadow-handles'
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
3 participants
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!107
No description provided.