WIP: fix(vk): Hookshot's renderer fixes merged after Longshot branched, restored #287

Draft
TrashPanda wants to merge 22 commits from TrashPanda/Longshot:restore/renderer-fixes into main
Contributor

Why

Renderer fixes Hookshot merged after Longshot branched: missing or stale textures after a failed upload, the FSR 4 tail
written out of order, Intel's split submissions flashing white, upscaler smears after every camera cut, the ultrawide
sides open during loading, FSR 3.1 losing HDR highlights, and juliotec's Vulkan robustness fixes. Good for rc.4, but they
need a run in the game first. Split out of #209.

What changed

Split out of #209 as its reviewer asked (review on #209, point 4, group d). Each commit is the merged Hookshot pull request under its original author, carried through Longshot's rename; a Restored-from: trailer names it, and the bare Hookshot numbers in the messages and the restored text are now full URLs.

Credit to the authors of the restored work: jarynclouatre, unixman69, NotRexed, Mathieu Sellier, juliotec.

  • juliotec's eight Vulkan robustness fixes: blits validated, ASTC clears skipped, mesh indirect ranges kept apart,
    unsupported texel-buffer formats skipped, the cooperative-matrix probe checked, sparse binds retried and flushed, the
    read-back command buffer not reused while a timed-out submission holds it
  • textures: a failed upload is tried again (texture_touch); a GPU buffer write into a texture's storage is found under a
    later, smaller write
  • upscalers: the FSR 4 tail write order; a camera cut starts a new history (dlss_math::camera_cut); FSR 3.1 on the
    chain keeps HDR (shader fg_scale.comp and its SPIR-V headers)
  • Intel: a split submission's uniform updates are written by its last part
  • ultrawide: the sides stay closed while the loading cover shows (MkwVkHost.wide_cover, MKWVK_API_VERSION 23)
  • pipelines: a background-prepared update builds the state list too; a half-written record is cut off; left-out ML
    networks are not shader failures
  • docs: RX_VK_TEMPORAL_CUT and RX_VK_SPLIT_UBFLUSH in the switch reference

Split siblings: tools, checks and docs #284, test-request pinning #285, rc.4 player fixes #286, new defaults and speed-ups #288.

Commits, in order:

  • 98d7dc3f fix(fsr4): the tail is written before its reader also when the unrolled shader arrives between bind and dispatch (Hookshot 2952, by jarynclouatre)
  • cb7fa397 fix(vk): a background-prepared update makes the state list's pipelines behind the preparing screen too (Hookshot 2982, by jarynclouatre)
  • 28d15628 fix(vk): a half-written last record is cut off before the pipeline lists are added to (Hookshot 2983, by jarynclouatre)
  • 0c340b48 fix(vk): the networks a card leaves out are not logged or counted as shader failures (Hookshot 2987, by jarynclouatre)
  • 602e4ec4 fix(vk): a GPU buffer write into a texture's storage is found under a later, smaller write (Hookshot 3002, by jarynclouatre)
  • 0031584f fix(vk): a texture whose upload failed is tried again, not left empty or stale (Hookshot 3003, by jarynclouatre)
  • 2ce58147 feat(vk): FSR 3.1 on the chain keeps the game's native HDR highlights (Hookshot 2614, by unixman69)
  • 24dac2f9 fix(ultrawide): the sides stay closed while the loading cover shows in the middle 16:9 (Hookshot 2939, by jarynclouatre)
  • fe326b54 fix(vk): a camera cut starts a new upscaler history (DLSS, FSR, their frame generation) (Hookshot 2927, by NotRexed)
  • 4d9be5ec fix(vk): on Intel a split submission's uniform updates are written by its last part (Hookshot 3000, by Mathieu Sellier)
  • 1f494405 fix(vk): bind a sparse texture's queued blocks when its upload runs out of staging (Hookshot 3031, by juliotec)
  • 7877b7f1 fix(vk): do not reuse the read-back command buffer while a timed-out submission holds it (Hookshot 3032, by juliotec)
  • 0e9e458e fix(vk): validate texture blits before vkCmdBlitImage (Hookshot 2691, by juliotec)
  • cf9ebf7f fix(vk): skip clears of textures kept in ASTC blocks (Hookshot 2718, by juliotec)
  • bd4d9ebb fix(vk): keep mesh indirect argument ranges from overlapping (Hookshot 2719, by juliotec)
  • 1fed49df fix(vk): skip texel buffer views of formats the device cannot use (Hookshot 2741, by juliotec)
  • 7b1ed3c9 fix(vk): check the cooperative matrix probe's mapping, fence and timeout (Hookshot 2742, by juliotec)
  • 747dd3cc fix(vk): retry sparse blocks whose bind failed (Hookshot 2743, by juliotec)
  • bdd0a85d fix(vk): MKWVK_API_VERSION 23 for MkwVkHost.wide_cover (mine)
  • 42af7869 test: a compile_all stand-in in the check of the two step sequences (mine)
  • c6601f6f docs: the restored Hookshot references as full URLs (mine)
  • a7b863c5 docs(development): RX_VK_TEMPORAL_CUT and RX_VK_SPLIT_UBFLUSH in the switches (mine)

Flags and settings

Flag What it does Default now Recommendation Helps on Doesn't help on Untested on
RX_VK_TEMPORAL_CUT=0 No new upscaler history at a camera cut, as before On Default DLSS, FSR and their frame generation The game's own upscaler Longshot builds in the game
RX_VK_SPLIT_UBFLUSH=1 / =0 A split submission's uniform updates at each part's end / at the last part only Last part on Intel, each part elsewhere Default Intel Arc - AMD, NVIDIA with =0
RX_VK_FSR3_HDR=0 FSR 3.1 on the chain clamps HDR colour at paper white, as before HDR colour kept Default HDR on Native with FSR 3.1 SDR Longshot builds in the game
RX_VK_IR Also logs each left-out network's program Off Diagnostics only - - -

Measurements

No numbers of my own: each fix was measured or shown upstream (commit messages and its tested-configuration rows).
Here: game-free checks and builds, main and this PR on the same PC.

main: d9648863 (2026-11-13)
this PR: restore/renderer-fixes @ 0e45532f (the runs; now a7b863c5, after the docs-only rebase onto main's 514591a3)
Hardware: RTX 4070 Ti (driver 595.99.02), Ryzen 7 9800X3D, Ubuntu 26.04.1
Scene: none (no game run)
Method: python3 -I -B tests/run_all.py --jobs 6, once on each; the builds below
How to reproduce: the same command on each commit

main @ d9648863 this PR @ 0e45532f
run_all.py: passed / failed / skipped 513/2/18 524/2/18
failing test_lift_cache.py, test_menu_i18n.py test_lift_cache.py, test_menu_i18n.py
no-game build, Linux: files / compile failures / undefined at link 406 / 0 / 18 406 / 0 / 18 (the same symbols)
no-game build, Windows (llvm-mingw): files / compile failures / undefined at link 405 / 0 / 19 405 / 0 / 19 (the same symbols)

The numbers still hold on 514591a3: main's #274 changed a Discord link in 11 docs files and nothing these checks or builds compile; the checks that read those docs were run again on this head.

Testing

Tested: python3 -I -B tests/run_all.py --jobs 6 on Linux (Ubuntu 26.04.1, Python 3.14, Vulkan and SPIR-V headers found): this PR @ 0e45532f: 524 passed, 2 failed, 18 skipped (test_lift_cache.py, test_menu_i18n.py); main @ d9648863 on the same PC: 513 passed, 2 failed, 18 skipped (test_lift_cache.py, test_menu_i18n.py). test_lift_cache fails on main the same way (the lift's scripts do not import here), and test_menu_i18n fails on that main too. Main then gained 514591a3 (#274, a Discord link in 11 docs files only); after the rebase onto it, the checks that read those docs passed again at a7b863c5.

Built at 0e45532f with no game, on Linux (system clang 21, lld) and for Windows (cross-built here with tools.lock's llvm-mingw 20261104 and its windows-x64 CPM cache, which has FFmpeg and dav1d): (before the docs-only rebase onto #274) nx2/build.py --standalone with an empty lift compiled everything in the repository that the game links, the host, its libraries and the Vulkan back end (406 files on Linux, 405 for Windows), with no failure, and its link stopped only at the symbols the game's lifted code provides (18 on Linux, 19 on Windows: the same list as main @ d9648863). RX_VK_ONLY=1 RX_RELEASE=1 linked longshot_vk.so and longshot_vk.dll, and builder/setup/setup.c linked as LongshotBuilder-Setup.exe with build-windows.yml's command. The five split branches merged together locally (the API version taken as 23) compiled and linked the same way, so they also build together.

Not tested: a run in the game: the game build needs the game's lifted code, and I had no run here.

Needs other testers:

  • Windows (and Linux), any GPU: a full build of this branch, then a short run through an area load and a cutscene with
    DLSS or FSR, expecting no smear after the cut, no missing textures and no device lost (T-TQY6, claimable once a maintainer adds the testing wanted label)
  • Intel Arc: Kokiri Forest, expecting no white blocks or flash every 8 frames (T-TQY6)
  • An ultrawide screen: an area load, expecting the sides black while the loading cover shows (T-TQY6)

Screenshots

None: no run in the game yet (the ultrawide sides and the camera-cut smears show on screen).

Risk and rollback

Rendering could go wrong on a GPU the upstream runs did not cover; each commit reverts on its own, and the new behaviours
have switches above. MKWVK_API_VERSION is 23 here, assuming the rc.4 fixes (#286, 22) merge first; should this merge
first, it takes 22 and the other 23.

Reviewer notes

Please merge with rebase, not squash, so that each author keeps their commit. Two commits are mine: the API version,
and a compile_all stand-in in the check that https://github.com/NotRexed/Hookshot/pull/2982 extends.

Issues

See #209

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 run: see Testing; WIP)
  • 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

  • Screenshots for a change that shows on screen. (None yet: no run in the game.)
  • AI assistance recorded with Assisted-by: / Co-Authored-By: trailers.
  • Flags noted, and the switch reference updated (docs/development.md, the README).
  • A row in docs/tested-configurations.md for each measured run. (The restored rows, from the upstream runs.)
  • MKWVK_API_VERSION bumped (gpu/vk/vk_api.h's MkwVkHost or MkwVkRenderer changed).
  • The SPIR-V headers regenerated with glslang (a shader changed). (Upstream; they name the SHA-256 of fg_scale.comp as it is 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. (Compiled with no game; see Testing.)
  • Docs updated.
## Why Renderer fixes Hookshot merged after Longshot branched: missing or stale textures after a failed upload, the FSR 4 tail written out of order, Intel's split submissions flashing white, upscaler smears after every camera cut, the ultrawide sides open during loading, FSR 3.1 losing HDR highlights, and juliotec's Vulkan robustness fixes. Good for rc.4, but they need a run in the game first. Split out of [#209](https://git.magicbean.dev/MagicBeanDev/Longshot/pulls/209). ## What changed Split out of [#209](https://git.magicbean.dev/MagicBeanDev/Longshot/pulls/209) as its reviewer asked (review on #209, point 4, group d). Each commit is the merged Hookshot pull request under its original author, carried through Longshot's rename; a `Restored-from:` trailer names it, and the bare Hookshot numbers in the messages and the restored text are now full URLs. Credit to the authors of the restored work: jarynclouatre, unixman69, NotRexed, Mathieu Sellier, juliotec. - juliotec's eight Vulkan robustness fixes: blits validated, ASTC clears skipped, mesh indirect ranges kept apart, unsupported texel-buffer formats skipped, the cooperative-matrix probe checked, sparse binds retried and flushed, the read-back command buffer not reused while a timed-out submission holds it - textures: a failed upload is tried again (`texture_touch`); a GPU buffer write into a texture's storage is found under a later, smaller write - upscalers: the FSR 4 tail write order; a camera cut starts a new history (`dlss_math::camera_cut`); FSR 3.1 on the chain keeps HDR (shader `fg_scale.comp` and its SPIR-V headers) - Intel: a split submission's uniform updates are written by its last part - ultrawide: the sides stay closed while the loading cover shows (`MkwVkHost.wide_cover`, `MKWVK_API_VERSION` 23) - pipelines: a background-prepared update builds the state list too; a half-written record is cut off; left-out ML networks are not shader failures - docs: `RX_VK_TEMPORAL_CUT` and `RX_VK_SPLIT_UBFLUSH` in the switch reference Split siblings: tools, checks and docs [#284](https://git.magicbean.dev/MagicBeanDev/Longshot/pulls/284), test-request pinning [#285](https://git.magicbean.dev/MagicBeanDev/Longshot/pulls/285), rc.4 player fixes [#286](https://git.magicbean.dev/MagicBeanDev/Longshot/pulls/286), new defaults and speed-ups [#288](https://git.magicbean.dev/MagicBeanDev/Longshot/pulls/288). Commits, in order: - `98d7dc3f` fix(fsr4): the tail is written before its reader also when the unrolled shader arrives between bind and dispatch ([Hookshot 2952](https://github.com/NotRexed/Hookshot/pull/2952), by jarynclouatre) - `cb7fa397` fix(vk): a background-prepared update makes the state list's pipelines behind the preparing screen too ([Hookshot 2982](https://github.com/NotRexed/Hookshot/pull/2982), by jarynclouatre) - `28d15628` fix(vk): a half-written last record is cut off before the pipeline lists are added to ([Hookshot 2983](https://github.com/NotRexed/Hookshot/pull/2983), by jarynclouatre) - `0c340b48` fix(vk): the networks a card leaves out are not logged or counted as shader failures ([Hookshot 2987](https://github.com/NotRexed/Hookshot/pull/2987), by jarynclouatre) - `602e4ec4` fix(vk): a GPU buffer write into a texture's storage is found under a later, smaller write ([Hookshot 3002](https://github.com/NotRexed/Hookshot/pull/3002), by jarynclouatre) - `0031584f` fix(vk): a texture whose upload failed is tried again, not left empty or stale ([Hookshot 3003](https://github.com/NotRexed/Hookshot/pull/3003), by jarynclouatre) - `2ce58147` feat(vk): FSR 3.1 on the chain keeps the game's native HDR highlights ([Hookshot 2614](https://github.com/NotRexed/Hookshot/pull/2614), by unixman69) - `24dac2f9` fix(ultrawide): the sides stay closed while the loading cover shows in the middle 16:9 ([Hookshot 2939](https://github.com/NotRexed/Hookshot/pull/2939), by jarynclouatre) - `fe326b54` fix(vk): a camera cut starts a new upscaler history (DLSS, FSR, their frame generation) ([Hookshot 2927](https://github.com/NotRexed/Hookshot/pull/2927), by NotRexed) - `4d9be5ec` fix(vk): on Intel a split submission's uniform updates are written by its last part ([Hookshot 3000](https://github.com/NotRexed/Hookshot/pull/3000), by Mathieu Sellier) - `1f494405` fix(vk): bind a sparse texture's queued blocks when its upload runs out of staging ([Hookshot 3031](https://github.com/NotRexed/Hookshot/pull/3031), by juliotec) - `7877b7f1` fix(vk): do not reuse the read-back command buffer while a timed-out submission holds it ([Hookshot 3032](https://github.com/NotRexed/Hookshot/pull/3032), by juliotec) - `0e9e458e` fix(vk): validate texture blits before vkCmdBlitImage ([Hookshot 2691](https://github.com/NotRexed/Hookshot/pull/2691), by juliotec) - `cf9ebf7f` fix(vk): skip clears of textures kept in ASTC blocks ([Hookshot 2718](https://github.com/NotRexed/Hookshot/pull/2718), by juliotec) - `bd4d9ebb` fix(vk): keep mesh indirect argument ranges from overlapping ([Hookshot 2719](https://github.com/NotRexed/Hookshot/pull/2719), by juliotec) - `1fed49df` fix(vk): skip texel buffer views of formats the device cannot use ([Hookshot 2741](https://github.com/NotRexed/Hookshot/pull/2741), by juliotec) - `7b1ed3c9` fix(vk): check the cooperative matrix probe's mapping, fence and timeout ([Hookshot 2742](https://github.com/NotRexed/Hookshot/pull/2742), by juliotec) - `747dd3cc` fix(vk): retry sparse blocks whose bind failed ([Hookshot 2743](https://github.com/NotRexed/Hookshot/pull/2743), by juliotec) - `bdd0a85d` fix(vk): MKWVK_API_VERSION 23 for MkwVkHost.wide_cover (mine) - `42af7869` test: a compile_all stand-in in the check of the two step sequences (mine) - `c6601f6f` docs: the restored Hookshot references as full URLs (mine) - `a7b863c5` docs(development): RX_VK_TEMPORAL_CUT and RX_VK_SPLIT_UBFLUSH in the switches (mine) ## Flags and settings | Flag | What it does | Default now | Recommendation | Helps on | Doesn't help on | Untested on | |---|---|---|---|---|---|---| | `RX_VK_TEMPORAL_CUT=0` | No new upscaler history at a camera cut, as before | On | Default | DLSS, FSR and their frame generation | The game's own upscaler | Longshot builds in the game | | `RX_VK_SPLIT_UBFLUSH=1` / `=0` | A split submission's uniform updates at each part's end / at the last part only | Last part on Intel, each part elsewhere | Default | Intel Arc | - | AMD, NVIDIA with `=0` | | `RX_VK_FSR3_HDR=0` | FSR 3.1 on the chain clamps HDR colour at paper white, as before | HDR colour kept | Default | HDR on Native with FSR 3.1 | SDR | Longshot builds in the game | | `RX_VK_IR` | Also logs each left-out network's program | Off | Diagnostics only | - | - | - | ## Measurements No numbers of my own: each fix was measured or shown upstream (commit messages and its tested-configuration rows). Here: game-free checks and builds, main and this PR on the same PC. main: d9648863 (2026-11-13) this PR: restore/renderer-fixes @ 0e45532f (the runs; now a7b863c5, after the docs-only rebase onto main's 514591a3) Hardware: RTX 4070 Ti (driver 595.99.02), Ryzen 7 9800X3D, Ubuntu 26.04.1 Scene: none (no game run) Method: `python3 -I -B tests/run_all.py --jobs 6`, once on each; the builds below How to reproduce: the same command on each commit | | main @ d9648863 | this PR @ 0e45532f | |---|---|---| | `run_all.py`: passed / failed / skipped | 513/2/18 | 524/2/18 | | failing | test_lift_cache.py, test_menu_i18n.py | test_lift_cache.py, test_menu_i18n.py | | no-game build, Linux: files / compile failures / undefined at link | 406 / 0 / 18 | 406 / 0 / 18 (the same symbols) | | no-game build, Windows (llvm-mingw): files / compile failures / undefined at link | 405 / 0 / 19 | 405 / 0 / 19 (the same symbols) | The numbers still hold on `514591a3`: main's #274 changed a Discord link in 11 docs files and nothing these checks or builds compile; the checks that read those docs were run again on this head. ## Testing **Tested:** `python3 -I -B tests/run_all.py --jobs 6` on Linux (Ubuntu 26.04.1, Python 3.14, Vulkan and SPIR-V headers found): this PR @ `0e45532f`: 524 passed, 2 failed, 18 skipped (test_lift_cache.py, test_menu_i18n.py); main @ `d9648863` on the same PC: 513 passed, 2 failed, 18 skipped (test_lift_cache.py, test_menu_i18n.py). `test_lift_cache` fails on main the same way (the lift's scripts do not import here), and `test_menu_i18n` fails on that main too. Main then gained `514591a3` (#274, a Discord link in 11 docs files only); after the rebase onto it, the checks that read those docs passed again at `a7b863c5`. **Built** at `0e45532f` with no game, on Linux (system clang 21, lld) and for Windows (cross-built here with tools.lock's llvm-mingw 20261104 and its windows-x64 CPM cache, which has FFmpeg and dav1d): (before the docs-only rebase onto #274) `nx2/build.py --standalone` with an empty lift compiled everything in the repository that the game links, the host, its libraries and the Vulkan back end (406 files on Linux, 405 for Windows), with no failure, and its link stopped only at the symbols the game's lifted code provides (18 on Linux, 19 on Windows: the same list as main @ `d9648863`). `RX_VK_ONLY=1 RX_RELEASE=1` linked `longshot_vk.so` and `longshot_vk.dll`, and `builder/setup/setup.c` linked as `LongshotBuilder-Setup.exe` with build-windows.yml's command. The five split branches merged together locally (the API version taken as 23) compiled and linked the same way, so they also build together. **Not tested:** a run in the game: the game build needs the game's lifted code, and I had no run here. **Needs other testers:** - Windows (and Linux), any GPU: a full build of this branch, then a short run through an area load and a cutscene with DLSS or FSR, expecting no smear after the cut, no missing textures and no device lost (T-TQY6, claimable once a maintainer adds the `testing wanted` label) - Intel Arc: Kokiri Forest, expecting no white blocks or flash every 8 frames (T-TQY6) - An ultrawide screen: an area load, expecting the sides black while the loading cover shows (T-TQY6) ## Screenshots None: no run in the game yet (the ultrawide sides and the camera-cut smears show on screen). ## Risk and rollback Rendering could go wrong on a GPU the upstream runs did not cover; each commit reverts on its own, and the new behaviours have switches above. `MKWVK_API_VERSION` is 23 here, assuming the rc.4 fixes ([#286](https://git.magicbean.dev/MagicBeanDev/Longshot/pulls/286), 22) merge first; should this merge first, it takes 22 and the other 23. ## Reviewer notes Please merge with **rebase**, not squash, so that each author keeps their commit. Two commits are mine: the API version, and a `compile_all` stand-in in the check that https://github.com/NotRexed/Hookshot/pull/2982 extends. ## Issues See #209 ## 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. (no game run: see Testing; WIP) - [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 - [ ] Screenshots for a change that shows on screen. (None yet: no run in the game.) - [x] AI assistance recorded with `Assisted-by:` / `Co-Authored-By:` trailers. - [x] Flags noted, and the switch reference updated (docs/development.md, the README). - [x] A row in `docs/tested-configurations.md` for each measured run. (The restored rows, from the upstream runs.) - [x] `MKWVK_API_VERSION` bumped (`gpu/vk/vk_api.h`'s `MkwVkHost` or `MkwVkRenderer` changed). - [x] The SPIR-V headers regenerated with glslang (a shader changed). (Upstream; they name the SHA-256 of `fg_scale.comp` as it is here.) - [x] A check under `tests/` that reads changed source updated in this PR, not weakened. - [x] Host code builds on both Windows (llvm-mingw) and Linux, with UTF-8 paths. (Compiled with no game; see Testing.) - [x] Docs updated.
With AMD FSR 4's shader tuning (https://github.com/NotRexed/Hookshot/pull/2798), a tuned pipeline reads its
weight tables from a tail at the end of the library's storage buffer,
and `tune::cmd_dispatch` writes that tail before the first dispatch that
reads it. The unrolled pipeline is built by a background job and
published into `better` from its own thread. `cmd_bind_pipeline` binds
the library's pipeline when no unrolled one exists yet, but
`cmd_dispatch` asked `better` again at dispatch time. When the job
published between the two, the dispatch took the unrolled pipeline as
bound: it skipped the tail's upload (`due` stayed set) and the fallback
to AMD's own pipeline, while the tail-reading pipeline ran once on a
tail not written yet.

## Change

`bound` keeps, per command buffer, the library's pipeline and whether
the bind put the unrolled one in its place (`struct Bind`).
`cmd_dispatch` decides from that, not from `better` at dispatch time.
The twin path still gets the library's pipeline from it. No lock is held
across a driver call that was not held before.

## Testing

- `tests/test_fsr4_bind_dispatch.py` (new): cuts `cmd_bind_pipeline`,
`cmd_dispatch` and the structs they use out of `vk_fsr4.cpp` and runs
them against recording stand-ins for the driver's calls, with no GPU and
no `spirv-val`. Three cases: the library's pipeline bound and the
unrolled one published before the dispatch (the tail is written and
`due` cleared); the unrolled one bound (no tail written); a tail that
cannot be read with the unrolled one published in between (AMD's own
pipeline is bound). The first and third fail on main and pass here.
- Linux renderer built with `HOOKSHOT_FSR=1`: `vk_fsr4.cpp` compiles.
- Full suite on Linux: 2 failures, each for a tool this machine lacks
(`test_bake_portability`: `zstandard`;
`nx2/test_texture_init_barrier.py`: `clang++`); both fail the same on
main.
- Not run on an RDNA 4 card.

Restored-from: https://github.com/NotRexed/Hookshot/pull/2952
With `RX_VK_PREPARE_BACKGROUND=1`, an update builds what the notes name
before the game and the rest behind it. A start with console progress
(`started_up` in `gpu/vk/vk_main.cpp`) makes the shipped state list's
pipelines first: `if (update) state_list_run();` before
`precompile_run`. The preparing screen's worker (`prepare_with_screen`)
has no such call, so a start that shows the screen left those pipelines
out, and they were built when first drawn, in play.

## Change

The screen's worker calls `state_list_run()` for an update at the same
point, before `precompile_run`, inside its `!prepare_stopped()` check. A
stopped preparation still skips it. Nothing is added to the screen:
`state_list_run` takes no progress callback.

## Testing

- New `tests/test_prepare_screen_sequence.py`: both step sequences are
cut from the source and run against stand-ins that record each call. For
an update, both make the state list once, before `precompile_run`, then
start the background pass. When it is not an update, neither makes the
list. A stopped screen worker makes neither the list nor the precompile.
On main the screen's update sequence has no state list; here it passes.
- Full suite on Linux: 474 passed, 53 skipped, 2 failed, both the same
on main on this machine (`test_bake_portability`: `zstandard` is not
installed; `nx2/test_texture_init_barrier.py`: `clang++` is not
installed).
- No game ran.

Restored-from: https://github.com/NotRexed/Hookshot/pull/2982
A crash or a full disk while the game writes
`hookshot_pipeline_states.bin` can leave it ending in part of a record.
`state_open_locked` in `gpu/vk/vk_draw.cpp` reads the whole records,
sets `keep`, and opens the file with `"ab"`, so every later run added
its new pipelines after the broken end. They were never read back: those
pipelines were built again in play at each start, and "Save my pipeline
list" sent a list whose end was garbage. `notes_open_locked` does the
same with the pipeline notes, also on its `known` path, where the file
is not read at all.

## Change

A small `cut_part_record(path, header, record)` cuts the file back to
its last whole record with `std::filesystem::resize_file` and logs it.
Both opens call it before appending: the state list when it keeps the
list, the notes when the stamp and record size match. The whole records
stay; a list that ends on a record's edge is left alone, and one shorter
than its header starts new as before.

## Testing

- New `tests/test_state_list_part_record.py`: `state_open_locked`,
`notes_open_locked` and the helper are cut from the source and run on
lists of their own: one whole record followed by 0 bytes, 1 byte, and a
record's size less one of the next. A new record is added as play adds
one, then the file is read back: both records there, the file a whole
number of records. The notes the same, with their records read before
the open and without. On main the part-record cases fail; here they
pass.
- `tests/test_pipeline_state_computes.py` compiles `state_open_locked`
too; its fixture now takes the helper along.
- Linux renderer built (`hookshot_vk.so`).
- Full suite on Linux: 474 passed, 53 skipped, 2 failed, both the same
on main on this machine (`test_bake_portability`: `zstandard` is not
installed; `nx2/test_texture_init_barrier.py`: `clang++` is not
installed).
- No game ran; not built on Windows.

Restored-from: https://github.com/NotRexed/Hookshot/pull/2983
On a card that is not NVIDIA (and with `RX_VK_NO_F16`, or without
`shaderFloat16`), `translate_into` in `gpu/vk/vk_shader.cpp` leaves the
movie upscaler's networks out on purpose: a linear upscale takes their
place (`upscale_stub`). It threw a plain `std::runtime_error`, so its
one catch counted each of them in `st_failed` and logged it as "vk:
program ... not translated: a network left out on this card". Those
lines used up the 30 the catch allows for real failures (https://github.com/NotRexed/Hookshot/pull/2943 is
exactly 30 of them), so a program that really failed to translate after
them was never named. The "prepared N shader programs" line and the
statistics lines also counted the networks as failed.

## Change

- The left-out case throws `LeftOut` (a `std::runtime_error`, same
text). The throw stays one line.
- It has its own catch branch:
- The program is still marked failed, so what is drawn does not change.
- It is counted in `st_left_out`, not `st_failed`, and does not touch
the 30-line allowance.
- One line the first time says the networks are left out and that this
is not an error. With `RX_VK_IR` set, each key is logged too.
- The "prepared" line names the count: `N failed, M networks left out`,
read through a new `shader_left_out()`.
- Real failures keep their text, their count and their 30 lines. Answers
`seed_compute` tries are not counted, as before.

## Testing

- New `tests/test_left_out_not_failed.py`: `translate_into`'s catch and
the left-out throw are cut from the source and run with a recording
`logf`. It runs 40 left-out networks, then one real failure, then three
more networks with `RX_VK_IR` set, then one network under a seed trial.
Expected and seen here:
  - the real failure's line is there;
  - failed 1, left out 43;
  - one line for the networks, and each key with `RX_VK_IR`.
- On main the same driver gives failed 44 and no line for the real
failure.
- The fixture also compiles with llvm-mingw for Windows.
- Linux renderer built (`hookshot_vk.so`).
- Full suite on Linux: 474 passed, 53 skipped, 2 failed, both the same
on main on this machine (`test_bake_portability`: `zstandard` is not
installed; `nx2/test_texture_init_barrier.py`: `clang++` is not
installed).
- No AMD or Intel card ran it, and no game ran.

Ported to Longshot: Longshot's #177 had made these a trace line by their text (network_left_out); with this change they
are their own exception, counted apart and said once, so that branch and its constant go, and test_log_levels.py
checks the LeftOut path instead.

Restored-from: https://github.com/NotRexed/Hookshot/pull/2987
`texture_gpu_wrote` in `gpu/vk/vk_texture.cpp` notes each GPU buffer
write into guest memory as a record, start to end plus the submission
that wrote it. `gpu_wrote_into` finds the finished ones a texture was
not uploaded after. It starts at the one record before the texture's
address and walks forward, which is right only when records do not
overlap. The writer erased only the records a new write covered whole,
so a later, smaller write inside an older one left two records that
overlap.

For example, take `[0x100000, 0x140000)` at submission 10, then
`[0x110000, 0x120000)` at 11. For a texture at `0x130000`, the record
before it is the second one, which ends before the texture, and the
first is never looked at. The texture kept its old texels after the GPU
wrote them.

## Change

The writer keeps the records apart:
- A record the new write lands in keeps its parts before and after it,
each with its own submission. This applies to the one record that starts
before the write, and to one that starts inside it and runs past its
end.
- Then the new record is added.
- The "same start, covers it" shortcut stays.
- The cap of 65536 records is now enforced with a loop, because a write
inside a record adds two records.

`gpu_wrote_into` is unchanged.

## Testing

- New `tests/test_gpu_write_intervals.py`: both functions and the map
are cut from the source and run on such writes:
- The texture past the inner write finds 10, one inside it 11, one
before it 10.
- With only 10 finished, the outer ones are found and the inner one
waits.
- A write that runs past a record's end leaves the record's first part
with the old submission.
  - A write seen once is not found again, and a later one is.
  - After each write no two records overlap.
  - 100 interior writes into a full map keep it at 65536 records.
- On main the overlap cases fail. The fixture also compiles with
llvm-mingw for Windows.
- Linux renderer built (`hookshot_vk.so`).
- Full suite on Linux: 474 passed, 53 skipped, 2 failed, both the same
on main on this machine (`test_bake_portability`: `zstandard` is not
installed; `nx2/test_texture_init_barrier.py`: `clang++` is not
installed).
- No game ran.

Restored-from: https://github.com/NotRexed/Hookshot/pull/3002
When `upload()` fails in `texture_touch` (`gpu/vk/vk_texture.cpp`), for
example for want of staging memory with the VRAM or RAM full, a BC3
texture is tried again 30 frames on (https://github.com/NotRexed/Hookshot/pull/1505). Every other kind was marked
uploaded with `upload_gen` moved on ("not retried every draw; a write to
its memory tries again"):
- A first upload that failed left an empty image, drawn as it was until
the game wrote that memory again, which for most textures is never.
- A re-upload that failed dropped what the CPU had written since the
last upload. It also dropped the GPU buffer write `gpu_wrote_into` had
just marked as seen.

This is the "textures degrade until a restart" kind of report on cards
with little memory (https://github.com/NotRexed/Hookshot/pull/993's family).

## Change

- One rule for every kind. `Texture::bc_retry_frame` becomes
`retry_frame`.
- On a failed upload:
  - `uploaded` and `upload_gen` stay as they were.
  - `gpu_wrote_seen` goes back to its value from before this touch.
  - `retry_frame` is set 30 frames on.
- A line is logged (40 at most). The BC3 line keeps its text, and the
other kinds get one like it.
- The wait moves up, right after the epoch check and before
`gpu_wrote_into` and the dirty scan. A waiting texture costs a compare
and loses nothing.
- A successful upload clears `retry_frame`.
- A texture evicted and made again over storage render targets took has
its `evicted_from_memory` entry taken before the upload. If the upload
fails, the entry is put back. Otherwise the retry would take the texture
for rendered storage and mark it uploaded, empty.
- An image no upload takes (no GPU address, multisampled, empty or too
big, packed depth-stencil) still fails at once, every time. It is still
marked uploaded, so it is not tried forever. Those checks move from the
top of `upload_inner` into `upload_never`, used by both.
- Nothing changes while uploads succeed.

## Testing

- New `tests/test_upload_retry.py` checks the source, because
`texture_touch` cannot run outside the renderer:
- The failed branch marks nothing done, sets the retry, puts
`gpu_wrote_seen` back and logs.
- The wait stands before `gpu_wrote_into` and the dirty scan, and
`gpu_wrote_seen`'s old value is taken before it.
  - A success clears the retry.
- `upload_never` is shared with `upload_inner` and still marks those
images uploaded.
  - A taken `evicted_from_memory` entry is put back on a failure.
- It fails on main.
- `tests/test_bc_upload_retry.py` follows the rename.
- Linux renderer built (`hookshot_vk.so`).
- Full suite on Linux: 474 passed, 53 skipped, 2 failed, both the same
on main on this machine (`test_bake_portability`: `zstandard` is not
installed; `nx2/test_texture_init_barrier.py`: `clang++` is not
installed).
- Not tested: a failed upload in the game. No card here runs out of
memory on demand, and no game ran.
- Not covered: for a texture in a virtual pool (GPU address at or above
1 << 40), the dirty scan has already taken a remap (`page_map`,
`map_sig`) when the upload fails. The retry uploads what the CPU wrote,
but not pages that were only remapped. That is the same as before;
replaying it would mean a whole re-upload of a sparse array while memory
is short.

Restored-from: https://github.com/NotRexed/Hookshot/pull/3003
With HDR on **Native**, the temporal chain gives DLSS (https://github.com/NotRexed/Hookshot/pull/2243) and FSR 4
(https://github.com/NotRexed/Hookshot/pull/2342) the game's colour as linear light, past 1.
FSR 3.1 on the chain (https://github.com/NotRexed/Hookshot/pull/2305) still got it clamped at 1, so with FSR 3.1
every highlight stopped at the paper white. This
gives FSR 3.1 the same HDR colour. Its passes are already built for HDR
colour (`FFX_FSR3UPSCALER_OPTION_HDR_COLOR_INPUT`)
and work out their own exposure, so no pass or shader changes: only the
chain engine says it takes HDR.

**What changed**
- `vk_fsr3.cpp`, the chain engine:
- `chain_prepare` decides HDR each frame from `present_native_pq`, as
DLSS and FSR 4 do: what the game presents, not
the option. `RX_VK_FSR3_HDR=0` keeps the clamped colour, for
comparisons.
- `chain_hdr` tells the chain (`TemporalEngine::hdr`), so the chain's
existing HDR prep/out pair runs for it: linear
    light in (`kSquare`, ceiling 16384), its picture taken back linear.
- When that changes, the next evaluation starts a new history (`reset`),
since the history kept was in the other
encoding. DLSS and FSR 4 get the same by making their feature or context
again.
- The log says `vk: fsr3 on the chain: HDR colour, linear (the game's
native HDR)` when it turns on.
- Docs: the readme's FSR 3.1 section, `docs/development.md`
(`RX_VK_FSR3_HDR=0`), and a row in `docs/tested-configurations.md`.
- Tests: `test_hdr_encode.py` gets `check_fsr3`, beside `check_dlss` and
`check_fsr4`: HDR decided from
`present_native_pq` (not `opts.hdr`), the switch, the history reset, the
engine's `.hdr`, and the passes built for HDR
  colour. Removing the reset or the engine's `.hdr` each fails it.

**Not changed:**
- SDR, and HDR with the SDR picture expanded: `chain_hdr` is false
there, so the chain runs the same LDR passes as on main.
- FSR 3.1 with its own inputs (`RX_VK_FSR3_CHAIN=0`) stays LDR.

**Testing**
- `python -I -B tests/run_all.py`: 449 passed, 2 failed, 21 skipped
(Linux). The two failures fail on main too:
`test_env_on` (`HOS_FPS_LOG` in `host/oot_high_fps.c`) and
`test_linux_saves_move`.
- In game: RX 9070 XT 16 GB, Mesa RADV 26.2.6, Gentoo Linux (kernel
7.3.3), KDE Plasma Wayland, windowed, 2560x1440 HDR10
(HDR Native, paper white 110 nits = PQ code 132, peak 400). Host
v2.1.0-main-local; renderer main ef81aee7 plus this
  branch. "Off" is `RX_VK_FSR3_HDR=0`, which is what main does.
- Every HDR run logged `vk: fsr3 on the chain: HDR colour, linear (the
game's native HDR)` once, then drew FSR 3.1 on
every frame (600 of 600 per log line), with no device lost and no
validation messages. The only fallback to the
network was one frame at the teleport (`no scene camera this frame`), in
every FSR 3.1 run, with or without this.
- Castle Town market at noon, the still frames' PQ codes (0-255 of the
HDR10 window's 8-bit shot):

| Upscaler | Brightest pixel | 99.9th percentile | Pixels above the
paper white, per million |
    |---|---|---|---|
| FSR 3.1 100%, this change (2 runs) | 163, 162 | 139 | 16745, 16510 |
    | FSR 3.1 100%, off (2 runs) | 147, 147 | 138 | 14465, 15115 |
    | FSR 3.1 Quality (67%), this change | 160 | 138 | 14344 |
    | FSR 3.1 Quality (67%), off | 147 | 138 | 13857 |
    | The game's own upscaler, 100% (reference) | 182 | 139 | 17578 |
    | FSR 4 Native AA, HDR (https://github.com/NotRexed/Hookshot/pull/2342) | 156 | 138 | 13276 |

Off, every run stops at the same code, 147. With the change, the sunlit
highlights go past it, towards the game's
own. The rest of the picture is the same: mean code 63.5 in both, and no
colour shift side by side.
- Kokiri Forest showed no difference: nearly all the pixels there above
the paper white are Navi's, as bright with the
    change as without it.
- The Quality and off rows were measured on an earlier revision of this
branch that also made FSR 3.1's luma history
RGBA16F (see below); with the history as it is here, 100% gave the same
highlights (163, 162 against 163, 162).

**Left out, a possible follow-up:** FSR 3.1's luma history
(`rw_luma_history`, the luma instability pass) is RGBA8,
because AMD's GLSL callbacks declare it `rgba8`, so it holds luma up to
1 only. I tried an RGBA16F history (a patched copy
of the callbacks header for `scripts/fsr3_shaders.py`). It made no
measurable difference to highlights or flicker in HDR,
nor to flicker in SDR (5 alternated pairs, within run-to-run noise), so
it isn't in this PR. If someone sees highlights
flicker with FSR 3.1 in HDR, that's where to look.

**Not tested:**
- other GPUs and drivers;
- Windows;
- Steam Deck;
- switching HDR on and off while FSR 3.1 is drawing (the history reset
is code-reviewed, not exercised in game).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Restored-from: https://github.com/NotRexed/Hookshot/pull/2614
With Ultrawide menus on either Centred setting and a picture wider than
16:9, entering a new area flashes that area on both sides of the screen,
overbright at first, beside a black middle (https://github.com/NotRexed/Hookshot/pull/2220).

## Why

The loading cover between two areas (`UILoadingFadeScreen`) is one of
the screens the setting keeps in the middle 16:9, and the game draws the
new area under the cover before it opens. On a build without the change
(main ef81aee7 with https://github.com/NotRexed/Hookshot/pull/2545) two entries had 12 and 10 presents with the
middle black and the sides at 194 to 244 of 255. The sides showed those
frames.

EjianYT found the cause in https://github.com/NotRexed/Hookshot/pull/2220 and showed that leaving the cover at
the picture's full width (`kMenus` 0xa -> 0x2) hides it. That stretches
the cover's loading icon across the wide picture with it, so this change
leaves the cover where it is and closes the sides for as long as it
shows.

## The change

- `host/hos_main.c`: `hos_wide_cover()` reads how far the cover is
closed, 0 to 1, from the cover screen's layout: the opacity of its black
picture's pane times those of the two panes above it.
`nx2/oot_wide.py`'s HUD site notes the screen's address as its
projection is set up. Each link is checked before it is used (the
screen's vtable, the pane's own layout pointer, its 3840 x 2160 size,
its parents in the tree, each opacity within 0 to 1, no address in the
first MB, which is never mapped); anything else reads as 0, the sides as
they were. The offsets are this game version's.
- `MkwVkHost.wide_cover`, API version 22.
- The renderer asks as the game presents each frame, on that thread, and
carries the level with the frame. The window's scale pass
(`fg_scale.comp`) darkens the picture outside the middle by it. A pixel
is darkened as it is read, before the filter and the sharpen, so the
edge stays where the game's cover ends at any window size and no side
light is filtered into the middle. The blurred menu sides are darkened
by the same level.
- The game blends its cover over the picture's light, not over its codes
(measured on shots of an opening cover: a pixel's decoded light is the
same fraction of its final light at every brightness, its code is not).
So the sides' light is scaled: the code to light, times 1 - level, back
to the code, through sRGB, or SMPTE ST 2084 when the game presents its
own HDR10 picture. Scaling the codes had the sides open darker than the
middle.
- The game may have moved the cover on for its next frame by the time it
presents one: with every present shot and the level logged, the reading
ran one present ahead of the picture through one area's entry and by
none or one through another's. A frame's sides take the larger of its
reading and the one before, so they are never more open than the middle
and at most one frame behind it.
- With frame generation the sides are closed in its copy of the game's
frame (`fg_copy.comp`, 12 more bytes of push constants) instead, before
the frames between are made, so the renderer's interpolation, FSR 3.1's
and DLSS Frame Generation treat the sides as they do the middle; the
display thread's scale pass then leaves them alone.
- 16:9 and narrower, Ultrawide menus on Full width, and no cover
showing: the level is 0 and nothing changes (the scale pass is skipped
as before when it has nothing else to do).

## Tested

Windows 11, RTX 4070 Ti SUPER, NVIDIA 621.24, no window
(`HOS_HEADLESS=1`), the picture in 3440x1440's shape
(`HOS_ASPECT=2.3889`), Ultrawide menus on Centred, blurred sides. Four
runs of one scripted route with two area entries (teleports), every
present around them saved as a picture and read back in linear light.
The first three were on this branch's commit before its rebase (on
0ec31ff6, API version 20); the rebase onto 9b6072cd had conflicts only
where main added to the same lists (`vk_api.h`, `vk_framegen.h`,
`rx_backend.c`, one test). The fourth is the rebased commit.

- The change's own build: while the cover is closed the sides are black,
mean light 0 in all 181 and 135 presents of the two entries (before: the
12 and 10 lit presents above).
- While the cover opens (33 and 22 presents), a strip just outside the
middle 16:9's edge against the strip just inside it, each as a fraction
of its own light after the cover: the outside one is behind in every one
of them, by 0.04 to 0.19.
- A build that also logs the level it uses, per present: the outside
strip follows 1 - level and is under the inside one on every present the
cover shows in both openings (24 and 29 presents).
- Frame generation 2x (the renderer's own), game frames and made frames
saved: sides black while the cover is closed (125 and 116 game
presents); 42 made frames around the two openings lie between their two
game frames in the sides (2 in the middle are 0.05 under both, after the
cover has gone).
- The rebased commit, one run: the sides are black, mean light 0, in all
190 and 141 presents with the cover closed. While it opens (23 and 35
presents) the outside strip is behind the inside one in 22 and 35 of
them, by up to 0.09 and 0.26; in the first opening's last present it is
0.03 ahead (0.985 against 0.959 of the light after the cover).
- `python -I -B tests/run_all.py` on the rebased commit, Linux: 473
passed, 51 skipped, 4 failed. `test_bake_portability` (no `zstandard`
here), `nx2/test_mesh_first_types` and `nx2/test_texture_init_barrier`
fail the same way without the change; `test_recorder_sync` failed in the
full run and passes alone. New: `tests/test_wide_cover.py`,
`tests/test_wide_cover_light.py`, and cases in
`upscale_independent_test.cpp` and `present_source_crop_test.cpp`.

**Not tested:** a real ultrawide window (the runs had no window), HDR
output (the ST 2084 path is covered by `test_wide_cover_light.py` only),
FSR 3.1 and DLSS Frame Generation, Linux in the game, AMD and Intel
cards, other game versions.

## Other work on https://github.com/NotRexed/Hookshot/pull/2220

https://github.com/NotRexed/Hookshot/pull/2851 (draft) keeps the cover at the full width through the keep mask,
and its review asks for a per-pane version on top of https://github.com/NotRexed/Hookshot/pull/2843 instead,
which would widen only the cover's black pane. This change needs
neither: it works on main as it is and leaves the layout alone. If the
pane version lands first I am glad to close this, or to cut it down to
whatever is still useful beside it.

Fixes https://github.com/NotRexed/Hookshot/issues/2220

Restored-from: https://github.com/NotRexed/Hookshot/pull/2939
**The problem.** The temporal chain (`gpu/vk/vk_temporal.cpp`) starts a
new history only when the camera is lost, the size or target changes, or
a frame was skipped. An area load, a spawn or a cutscene cut moves the
camera 5 to 180 m in one frame. DLSS and FSR then blend the new view
with the old one (smears and ghosting right after loads), and their
frame generation makes frames between two unrelated pictures.

**The fix**
- `dlss_math::camera_pose` reads the camera's position and look
direction straight from the view-projection: the point where clip x, y
and w are all 0, and the w row. A jitter doesn't move either.
- `dlss_math::camera_cut` treats either of these as a cut, per 60 Hz
frame, scaled by the time between frames (¼ to 4 frames):
  - a move over 5 m, or
  - a turn over 30° together with a move over 0.5 m.
- Normal play (~0.05 m and 0.08° a frame) and a quick camera swing round
Link (11–18° and ~1 m a frame) don't count. A lower turn threshold
restarted the history on every frame of a swing and showed the blocky
render size.
- A cut sets the frame's `reset`, which already reaches DLSS, FSR 3.1/4
and their frame generation.
- Each cut is logged, at most 24. `RX_VK_TEMPORAL_CUT=0` turns the test
off.

**Tests**
- `tests/test_camera_cut.py` builds real look-at × perspective matrices.
It recovers the camera exactly (also jittered) and checks play, a swing,
a 35° turn with a move, an area load, the time scaling, an orthographic
matrix and the first frame.
- `test_temporal_history` (fixture updated), `test_dlss_math`,
`test_dlss_checks`, `test_dlss_native_transition`,
`test_temporal_jitter` and `test_upscaler_rows` pass.
- `vk_temporal.cpp` compiles.

**Not yet verified in-game:** teleporting with DLSS or FSR on (the log
line plus no ghosting after an area load).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <[email protected]>
Restored-from: https://github.com/NotRexed/Hookshot/pull/2927
On Intel GPUs, Kokiri Forest shows big white glowing blocks and haze
over the trees and houses, and save thumbnails come out white. The cause
is in how a long submission is cut into parts: each part's end wrote the
stream's uniform updates into guest memory halfway through the
submission, so the draws of the later parts read a state an unsplit
submission never shows.

Part of https://github.com/NotRexed/Hookshot/pull/999. The mechanism was found by ploob-boop on an Arc 140V
(their comment in https://github.com/NotRexed/Hookshot/pull/999: a whole-screen white flash every 8 frames); this
PR checks it on a Core Ultra 7 155H and against current main.

- `maybe_split` (`gpu/vk/vk_replay.cpp`) no longer calls
`flush_ub_updates` at a part's end, nor moves `ub_first_of_recording`.
The submission's last part writes all its updates, exactly as an unsplit
submission does in `finish_recording` (`RX_VK_CHUNK=0` behaves that way
already).
- Measured on Intel only, so it is the default on Intel only (`vendorID
== 0x8086`). `RX_VK_SPLIT_UBFLUSH=1` keeps the old behaviour; `=0` turns
the fix on for any GPU, for anyone who wants to try it on AMD or NVIDIA.
- Other GPUs run exactly the code they ran before.

Core Ultra 7 155H (Arc Graphics, Meteor Lake, 8 Xe-cores), driver
32.0.101.9175, Windows 11, 32 GB. Settings: output 1920x1080, Render
resolution 4, handheld profile. Same save in Kokiri Forest (outside
Link's house), scripted start, camera still, 300 s per run, a screenshot
every 60 frames, the game closed normally.

Same binary for both arms: main 9b6072c plus this PR (the same patch as
on current main), https://github.com/NotRexed/Hookshot/pull/2999 and a local change that lets Intel compute
stages go of the 32-lane pin as https://github.com/NotRexed/Hookshot/pull/2564 does; the "old" arm is
`RX_VK_SPLIT_UBFLUSH=1 RX_VK_PIN_NEEDED=0` (pin as main). Runs
alternated old/new.

| | white-block frames in play (of ~25-40 captured) | fps (median) |
|---|---|---|
| old 1 / new 1 | 0 / 0 | 5.4 / 5.7 |
| old 2 / new 2 | 9 / lost the device at 80 s* | 5.5 / - |
| old 4 / new 4 | 7 / 0 (lost the device at 176 s*) | 4.7 / 5.9 |
| old 5 / new 5 | 11 / 0 | 5.7 / 6.0 |

An earlier series with this change alone on the same build: 7, 1 and 1
white-block frames in the three runs without it, 0, 0 and 0 with it, no
fps difference.

\* The device losses are main's known ones (https://github.com/NotRexed/Hookshot/pull/999), in compute programs
ab7699ee7f536755, aba5e6af8d4683eb and 499dbfd0721d2858; plain main
9b6072c loses the device in the same programs. Counting both series, the
runs without this change lost it in 2 of 8 and the runs with it in 5 of
8. I do not think this change causes them (it only moves when uniform
data is written, and the programs lost are compute ones that read none
of it), but at this number of runs I cannot rule it out either; more
runs on other Intel GPUs are welcome.

Screenshots (same frame of the same scripted run, left without the fix,
right with it):

<img width="1280" height="720" alt="kokiri_before_split_flush"
src="https://github.com/user-attachments/assets/0221080a-cba9-473b-8722-2989133c46b5"
/>
<img width="1280" height="720" alt="kokiri_after_split_flush"
src="https://github.com/user-attachments/assets/7101303a-f798-4f77-8602-a49e9cb485ed"
/>
`python -I -B tests/run_all.py`: the checks that read `maybe_split` pass
(`test_parallel_small_cb`, `test_readback_part`,
`test_value_rec_last_part`); the 10 failures this Windows PC also shows
on plain main (builder, updater, portable archive) are unchanged.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: NotRexed <[email protected]>
Restored-from: https://github.com/NotRexed/Hookshot/pull/3000
### The bug

`upload_inner` (gpu/vk/vk_texture.cpp) uploads a texture in runs of
rows. For a sparse texture, each run first queues the binds of the tiles
it touches with `texture_resident_queue`: the tiles are marked bound at
once, and the binds go into `sparse_binds`, one list shared by every
texture. `texture_resident_flush` later binds the whole list with
`vkQueueBindSparse`, into one image only: `VkSparseImageMemoryBindInfo`
takes a single `image` for the list.

Each run takes its staging memory with `staging()`. When that failed
part-way, the code was:

```cpp
if (!st.buffer) return false;
```

so it returned with the binds of the earlier rows still in
`sparse_binds`. Step by step:

1. Texture A (sparse) uploads. Rows 0..N queue the binds of their tiles;
those tiles are marked bound.
2. The staging for row N+1 fails, and `upload_inner` returns false. A's
binds are still in `sparse_binds` and were never made.
3. Texture B (sparse) uploads. Its `texture_resident_flush` binds the
whole list into **B's** image, including A's binds. The memory meant for
A's tiles goes to the same coordinates of B's image.
4. A's tiles stay marked bound, so no later write queues them again. A's
later copies into those tiles land in unbacked memory, and B gets memory
it did not ask for.

The other exits of that loop (a new staging arena, the end of the
upload) already call `texture_resident_flush(&tex)` first. Only the
staging-failure exit did not.

### What changed

Only that exit, in `upload_inner`:

```cpp
if (!st.buffer) {
	texture_resident_flush(&tex);
	return false;
}
```

The binds the earlier rows queued are now made into A's own image before
giving up, as the other exits do. The success path is unchanged.

A texture is only sparse when it is a 2D array over `RX_VK_TEX_MAX_MB`
(1.5 GB) on a device with sparse support, which is MKW's course-map
case.

### Test

`tests/test_sparse_staging_failure.py` is a source check on
`upload_inner`. In the part of the loop after the first `staging()` call
and before `texture_resident_queue`, every `return false;` must have a
`texture_resident_flush(&tex);` in the same block. The old `if
(!st.buffer) return false;` must be gone. On main it fails with "a
staging failure returns without binding the sparse blocks queued before
it"; with the fix it passes.

### How tested

- The new test fails on main and passes with the fix.
- The tests that reference vk_texture.cpp pass.
- Release build OK.
- Zelda OoT on Radeon RX 9070 XT (Windows, Adrenalin), nightly with this
change, about 40 minutes, log clean. This checks that non-sparse uploads
are unaffected; the sparse path itself is not reached there.

Assisted-by: GitHub Copilot
Restored-from: https://github.com/NotRexed/Hookshot/pull/3031
### The bug

Read-backs (texture write-backs to guest memory) use their own command
pool `rb_pool`, command buffer `rb_cmd` and fence `rb_fence`
(gpu/vk/vk_texture.cpp):

- `readback_begin` resets the pool and begins `rb_cmd`.
- `readback_submit_wait` resets `rb_fence`, submits `rb_cmd` with it,
and waits up to 5 s.

When that wait timed out, the function returned false, but nothing
remembered that the submission was still running. Step by step:

1. A read-back is submitted. The GPU is slow (heavy load, a long batch
before it), and `vkWaitForFences` returns `VK_TIMEOUT` after 5 s.
`rb_cmd` is still in the pending state and `rb_fence` is still tied to
it.
2. The next read-back calls `readback_begin`:
- `vkResetCommandPool(rb_pool)` while `rb_cmd` is pending. This violates
**VUID-vkResetCommandPool-commandPool-00040**: "All VkCommandBuffer
objects allocated from commandPool must not be in the pending state".
- `vkBeginCommandBuffer(rb_cmd)` while it is pending. This violates
**VUID-vkBeginCommandBuffer-commandBuffer-00049**: "commandBuffer must
not be in the pending state".
3. `readback_submit_wait` then calls `vkResetFences(rb_fence)` while it
is still tied to the unfinished submission. This violates
**VUID-vkResetFences-pFences-01123**: "Each element of pFences must not
be currently associated with any queue command that has not yet
completed execution on that queue".

This is undefined behaviour: the GPU can be running commands that the
CPU is rewriting.

There was a second problem in the creation. `readback_begin` makes the
pool, then the command buffer, then the fence, all only when `rb_pool`
is null. If `vkAllocateCommandBuffers` or `vkCreateFence` failed,
`rb_pool` stayed set with `rb_cmd` or `rb_fence` null. Every later call
then skipped the creation and used a null command buffer or fence.

### What changed

- New `bool rb_pending`. `readback_submit_wait` sets it when its wait
does not succeed:

  ```cpp
rb_pending = vkWaitForFences(dev.device, 1, &rb_fence, VK_TRUE,
5000000000ull) != VK_SUCCESS;
  return !rb_pending;
  ```

- `readback_begin` first waits for a pending submission, before any
reset. If it is still not done, this read-back is skipped (returns
false), as a failed read-back already was:

  ```cpp
  if (rb_pending) {
if (vkWaitForFences(dev.device, 1, &rb_fence, VK_TRUE, 5000000000ull) !=
VK_SUCCESS) return false;
	  rb_pending = false;
  }
  ```

- If the command buffer or the fence cannot be made, the pool is
destroyed and all three handles go back to null, so the next call makes
them again instead of using them half made.

When no wait times out, the behaviour is the same as before.

### Test

`tests/test_readback_pending.py` is a source check on `readback_begin`
and `readback_submit_wait`. It checks that:

- `rb_pending` exists.
- In `readback_begin`, `if (rb_pending)` and its `vkWaitForFences` on
`rb_fence` come **before** `vkResetCommandPool`.
- The failure branch of the creation destroys `rb_pool` and sets it back
to null.
- `readback_submit_wait` stores the result of its wait in `rb_pending`.

On main it fails ("a timed-out read-back is not remembered"); with the
fix it passes.

### How tested

- The new test fails on main and passes with the fix.
- The tests that reference vk_texture.cpp pass.
- Release build OK.
- Zelda OoT on Radeon RX 9070 XT (Windows, Adrenalin), nightly with this
change, about 40 minutes, log clean. Read-backs run normally there, but
none timed out, so the timeout path itself was not reached.

Assisted-by: GitHub Copilot
Restored-from: https://github.com/NotRexed/Hookshot/pull/3032
### The bug

`copy_t2t` (gpu/vk/vk_xfer.cpp) makes a texture-to-texture copy in one
of three ways:

1. `vkCmdResolveImage`: a multisampled source into a single-sampled
destination of the same size.
2. `vkCmdCopyImage`: same size, same block size, same sample count.
3. `vkCmdBlitImage`: everything else, which means scaled or converting
copies.

Branch 3 called `vkCmdBlitImage` without checking any of the rules the
spec puts on it. Step by step, with one example:

1. The game copies a texture into another of a different size or a
different format, for example an unsigned-integer texture into a
normalized-colour one.
2. Neither the resolve nor the copy branch applies, so the copy goes to
`vkCmdBlitImage`.
Linear is used when the game asks for it, or when the destination keeps
its level at a smaller size than the source (`d.down > s.down`).
4. When one of those rules is broken, the command is invalid usage and
the result is undefined. Some drivers tolerate it; others may write
garbage or lose the device.

The rules, with their exact text from the spec (`validusage.json`):

| VUID | Text |
|---|---|
| VUID-vkCmdBlitImage-srcImage-01999 | The format features of srcImage
must contain VK_FORMAT_FEATURE_BLIT_SRC_BIT |
| VUID-vkCmdBlitImage-dstImage-02000 | The format features of dstImage
must contain VK_FORMAT_FEATURE_BLIT_DST_BIT |
| VUID-vkCmdBlitImage-filter-02001 | If filter is VK_FILTER_LINEAR, then
the format features of srcImage must contain
VK_FORMAT_FEATURE_SAMPLED_IMAGE_FILTER_LINEAR_BIT |
| VUID-vkCmdBlitImage-srcImage-00229 | If either of srcImage or dstImage
was created with a signed integer VkFormat, the other must also have
been created with a signed integer VkFormat |
| VUID-vkCmdBlitImage-srcImage-00230 | If either of srcImage or dstImage
was created with an unsigned integer color VkFormat, the other must also
have been created with an unsigned integer color VkFormat |
| VUID-vkCmdBlitImage-srcImage-00231 | If either of srcImage or dstImage
was created with a depth/stencil format and the maintenance12 feature is
not enabled the other must have exactly the same format |
| VUID-vkCmdBlitImage-srcImage-00233 | srcImage must have been created
with a samples value of VK_SAMPLE_COUNT_1_BIT |
| VUID-vkCmdBlitImage-dstImage-00234 | dstImage must have been created
with a samples value of VK_SAMPLE_COUNT_1_BIT |

Hookshot does not enable maintenance12, so -00231 applies as written.
The images are created with `VK_IMAGE_TILING_OPTIMAL`, so the format
features that matter are `optimalTilingFeatures`.

### What changed (gpu/vk/vk_xfer.cpp)

Before, branch 3 went straight to the blit:

```cpp
} else {   // scaled or converting
	VkImageBlit bl{};
	...
	const bool linear = ((p.flags & NVN_COPY_FLAGS_LINEAR_FILTER_BIT) || d.down > s.down) && color;
	vkCmdBlitImage(..., linear ? VK_FILTER_LINEAR : VK_FILTER_NEAREST);
}
```

Now it first checks the rules above, and skips a blit that breaks any of
them:

```cpp
if (!(tiling_features(s.tex->format) & VK_FORMAT_FEATURE_BLIT_SRC_BIT) ||        // -01999
	!(tiling_features(d.tex->format) & VK_FORMAT_FEATURE_BLIT_DST_BIT) ||        // -02000
	format_class(s.tex->format) != format_class(d.tex->format) ||                // -00229, -00230
	(!color && s.tex->format != d.tex->format) ||                                // -00231
	s.tex->samples != VK_SAMPLE_COUNT_1_BIT || d.tex->samples != VK_SAMPLE_COUNT_1_BIT) {   // -00233, -00234
	warn(13, "texture blit the device cannot make: skipped");
	full_barrier(cmd);
	return;
}
```

Linear filtering is only used when the source format supports it
(-02001), and only for float or normalized formats:

```cpp
const bool linear = ((p.flags & NVN_COPY_FLAGS_LINEAR_FILTER_BIT) || d.down > s.down) && color &&
					(tiling_features(s.tex->format) & VK_FORMAT_FEATURE_SAMPLED_IMAGE_FILTER_LINEAR_BIT) &&
					format_class(s.tex->format) == 0;
```

Two new helpers in the same file:

- `format_class(VkFormat)` returns:
  - 1 for the unsigned-integer formats,
  - 2 for the signed-integer formats,
  - 0 for everything else (float, normalized, sRGB).
- `tiling_features(VkFormat)` returns the format's
`optimalTilingFeatures`:
- For the core formats (values below 256), it asks the driver once per
format and keeps the answer in a lock-free `std::atomic<uint64_t>`
table. Bit 63 marks a format as already asked.
  - Formats from extensions (256 and above) are asked each time.
- A scaled copy checks both sides, so the cache avoids querying the
driver twice per copy on the replay thread.

The depth/stencil condition is already caught earlier in `copy_t2t`, by
warn 7 ("texture copy between unlike depth formats: skipped"), so it
never fires here. It is kept in the check so the blit's own rules stay
in one place.

`warn` logs each key at most 3 times.

### Behaviour

- A blit that follows the rules goes through exactly as before.
- A blit that breaks a rule is skipped, and its destination is left as
it was. Before, it was undefined: it may have looked right on some
drivers, for example an integer/float mix or a multisampled image on a
driver that tolerates it. Now the destination is stale instead.
- This matters most on devices with a narrower format list (mobile and
integrated GPUs, layered drivers such as MoltenVK), and for games or
scenes that blit less common formats.

### Changes after review

- The format features come from the `tiling_features` cache, so they are
not queried twice per scaled copy.
- The first push of that change left one use of the old local variable
in the linear-filter check. The vk_xfer tests don't compile that code;
the full release build caught it, and it is fixed.

### Test

There is no dedicated unit test for these rules. The change is reviewed
against the VUIDs above. The existing checks that compile or read
vk_xfer.cpp pass (6, including `test_vulkan_copy_span_gpu_compile`).

### How tested

- Release build (`nx2/build.py --standalone`, Windows, llvm-mingw).
- The 6 vk_xfer.cpp checks pass.
- Zelda OoT on Radeon RX 9070 XT (Windows, Adrenalin):
  - Two full sessions on build g54d01844.
- One session of about 40 minutes on nightly g57471723, which includes
this change.
  - No visible regression, no device loss.
- "texture blit the device cannot make" does not appear in any of those
logs. Every blit OoT makes on this device follows the rules, so the skip
path is not reached there. This change guards against invalid usage; it
does not fix a visible glitch in OoT.

Assisted-by: GitHub Copilot
Restored-from: https://github.com/NotRexed/Hookshot/pull/2691
### The bug

ASTC textures are kept in one of three ways, depending on the GPU:

- **Decoded to RGBA8** on most GPUs.
- **Re-encoded as BC3** for some big textures.
- **Kept as ASTC blocks** when the device samples ASTC natively
(`textureCompressionASTC_LDR`, `astc_native()`). The image then has an
ASTC `VkFormat`.

`clear_tex` (gpu/vk/vk_xfer.cpp) clears a texture in one of two ways:

1. **Whole level**: when the clear covers the whole level, the image is
not 3D, and the format is not BC (`raw`), it uses `vkCmdClearColorImage`
for colour, or `vkCmdClearDepthStencilImage`.
2. **Region**: otherwise, a render pass over that level and layer with
`vkCmdClearAttachments`. For BC images (`raw`) the rectangle is
converted to blocks.

`raw` is computed only for the BC range, so an image kept in ASTC blocks
was treated as uncompressed. Step by step:

1. On a device with native ASTC, the game clears an ASTC texture.
2. If the clear covers the whole level, `clear_tex` calls
`vkCmdClearColorImage` on the ASTC image. This breaks
**VUID-vkCmdClearColorImage-image-00007**: "image must not have a
compressed or depth/stencil format".
3. If it covers part of the level, the region path below only converts
the rectangle to blocks for BC formats, not for ASTC.
4. Either way, a colour written over ASTC blocks would be garbage, since
the blocks are compressed data, not texels.

### What changed (gpu/vk/vk_xfer.cpp, clear_tex)

Before, no ASTC check:

```cpp
full_barrier(cmd);
const bool volume = s.tex->type == VK_IMAGE_TYPE_3D;
const bool raw = s.tex->format >= VK_FORMAT_BC1_RGB_UNORM_BLOCK && s.tex->format <= VK_FORMAT_BC7_SRGB_BLOCK;
```

After:

```cpp
full_barrier(cmd);
if (s.tex->format >= VK_FORMAT_ASTC_4x4_UNORM_BLOCK && s.tex->format <= VK_FORMAT_ASTC_12x12_SRGB_BLOCK) {
	warn(14, "clear of an ASTC texture: skipped");
	return;
}
const bool volume = s.tex->type == VK_IMAGE_TYPE_3D;
```

A clear of an image whose format is ASTC is skipped, and the image is
left as it was. `warn` logs it at most 3 times.

The early return also skips `texture_written` at the end of `clear_tex`,
so the texture is not marked as GPU-written. That is on purpose, because
nothing was written to it.

ASTC textures decoded to RGBA8, ASTC kept as BC3, and every other format
are cleared exactly as before.

### Who reaches this

Only devices that expose `textureCompressionASTC_LDR` and sample ASTC
natively, such as Intel iGPUs. AMD and NVIDIA desktop drivers don't
expose it, so on them the image is never in an ASTC format and the new
check never matches.

### Changes after review

- The description no longer claims that drivers hang. The rule comes
from the spec (the VUID above).
- Skipping `texture_written` was confirmed as intended.

### Test

There is no dedicated unit test. In the review run,
`test_bc_raw_targets.py` and `test_vulkan_clear_mip.py` pass with this
change.

### How tested

- Release build.
- Zelda OoT on Radeon RX 9070 XT (Windows, Adrenalin), including about
40 minutes on nightly g57471723, which includes this change. Log clean,
and "clear of an ASTC texture" does not appear.
- This GPU has no native ASTC, so the new path was not run. The test
only checks that a GPU without native ASTC is unaffected.

Assisted-by: GitHub Copilot
Restored-from: https://github.com/NotRexed/Hookshot/pull/2718
### The bug

`mesh_args_take(n)` (gpu/vk/vk_draw.cpp) hands out `n` consecutive
16-byte entries of the mesh-argument ring (`kMeshArgs` entries). Each
indirect mesh draw gets its own range: the GPU copies the draw's counts
and firsts into it, and the draw reads them from there. The code was:

```cpp
const uint32_t at = next.fetch_add(n) % (kMeshArgs - n);
return at + 1;
```

The modulus depends on `n`, so two draws taken one after another with
different counts can land on the same entries. A worked example with the
old `kMeshArgs = 262144`:

1. The counter is at 262130, and a draw takes `n = 100`. The modulus is
262144 - 100 = 262044, so `at = 262130 % 262044 = 86`. The draw gets
entries **87..186**.
2. The counter is now 262230, and the next draw takes `n = 1`. The
modulus is 262143, so `at = 262230 % 262143 = 87`. The draw gets entry
**88**.
3. Entry 88 is inside the first draw's range. The second draw is a
direct draw with a first task, so it writes its first into entry 88 from
the CPU while recording (`mesh_args_words[at * 4 + 3] = first`). The
first draw's GPU copy, which runs later, writes its own count and first
over entry 88. The second draw then reads the first draw's values
instead of its own. Two indirect draws that overlap overwrite each other
in the same way, through their GPU copies.
4. On screen, this shows as wrong, flickering or missing mesh geometry.

Who calls `mesh_args_take`:

- **Without `RX_VK_MESH_ARGS` (the default):** only direct mesh draws
with a non-zero first task, always with `n = 1`. With a constant `n`,
the modulus is constant and nothing overlaps.
- **With `RX_VK_MESH_ARGS=1`:** the rewritten indirect draws also call
it, with `n = d.max` (from 1 up to the old `kMeshArgs - 1`). They mix
with the `n = 1` calls, and that mix is what overlaps.

### What changed (gpu/vk/vk_draw.cpp)

The modulus is now fixed at `kMeshArgs / 2`, and `n` is clamped to at
most `kMeshArgs / 2`:

```cpp
// before
const uint32_t at = next.fetch_add(n) % (kMeshArgs - n);
// after
n = std::clamp<uint32_t>(n, 1, kMeshArgs / 2);
const uint32_t at = next.fetch_add(n) % (kMeshArgs / 2);
```

- Every range starts in the first half of the ring and is at most half a
ring long, so it never runs past the end.
- With one modulus for every `n`, ranges handed out one after another
follow each other in the ring. An entry is only reused when the counter
has advanced by a whole modulus.
- The indirect caller clamps `n` the same way: `std::clamp(d.max, 1,
(int)(kMeshArgs / 2))` instead of `kMeshArgs - 1`.
That keeps the reuse distance at 262144 entries, as before; halving the
modulus alone would have halved it.
- A power-of-two modulus also stays continuous when the 32-bit counter
wraps, which the old one did not.

### Cost

- The ring buffer is host-visible and grows from 4 MB to 8 MB. It is
also created for direct mesh draws with a non-zero first task, flag or
not, so the extra 4 MB applies to everyone who reaches that path.
- The `RX_VK_LOG_MESH_PERPROG` diagnostic `prog_at` vector (`kMeshArgs`
entries) doubles too.

### Test

`tests/mesh_args_cleanup_test.cpp` checked that the ring allocation was
at most 4 MB, which the new size breaks. It now requires the allocation
to be exactly `kMeshArgs * 16` bytes, computed from the `kMeshArgs` the
test extracts from vk_draw.cpp, so it follows the real size instead of a
fixed bound. `tests/test_mesh_args_cleanup.py` passes its 5 cases.

### Changes after review

- The test fixture's 4 MB bound was replaced by the size computed from
`kMeshArgs`.
- The description now covers the extra 4 MB on the direct-draw path and
the doubled `prog_at` vector.

### How tested

- Release build.
- `tests/test_mesh_args_cleanup.py`: 5 cases pass.
- Zelda OoT on Radeon RX 9070 XT (Windows, Adrenalin):
- With the flag off (the default; the `n = 1` direct-draw path),
including about 40 minutes on nightly g57471723, which includes this
change. Log clean.
- With `RX_VK_MESH_ARGS=1` (the rewritten indirect draws, mixed counts).

Assisted-by: GitHub Copilot
Restored-from: https://github.com/NotRexed/Hookshot/pull/2719
### The bug

`texel_buffer_view` (gpu/vk/vk_texture.cpp) creates the `VkBufferView`
for a texture the game binds as a texel buffer:

- as a **uniform** texel buffer when it is sampled,
- as a **storage** texel buffer when it is written.

The view's format is the one the texture maps to
(`vk_format(nf).format`). The only check was that the format is not
`VK_FORMAT_UNDEFINED`. Nothing checked whether the device supports texel
buffers of that format. Step by step:

1. The game binds a texture as a texel buffer, in a format such as
`VK_FORMAT_R32G32B32_SFLOAT`.
2. The device does not list `VK_FORMAT_FEATURE_UNIFORM_TEXEL_BUFFER_BIT`
(or `STORAGE_TEXEL_BUFFER_BIT`) for it in the format's `bufferFeatures`.
Which formats are listed varies by driver.
3. `texel_buffer_view` creates the view anyway. This is invalid usage:

| VUID | Text |
|---|---|
| VUID-VkBufferViewCreateInfo-format-08778 | If the buffer view usage
contains VK_BUFFER_USAGE_UNIFORM_TEXEL_BUFFER_BIT, then format features
of format must contain VK_FORMAT_FEATURE_UNIFORM_TEXEL_BUFFER_BIT |
| VUID-VkBufferViewCreateInfo-format-08779 | If the buffer view usage
contains VK_BUFFER_USAGE_STORAGE_TEXEL_BUFFER_BIT, then format features
of format must contain VK_FORMAT_FEATURE_STORAGE_TEXEL_BUFFER_BIT |

4. What the shader then reads or writes through that view is undefined.

### What changed (gpu/vk/vk_texture.cpp, texel_buffer_view)

A check right after the format is known, before anything is created,
looked up, or marked as written:

```cpp
VkFormat f = vk_format(nf).format;
if (f == VK_FORMAT_UNDEFINED) return VK_NULL_HANDLE;
// new:
if (!(format_props(f).bufferFeatures & (storage ? VK_FORMAT_FEATURE_STORAGE_TEXEL_BUFFER_BIT
												: VK_FORMAT_FEATURE_UNIFORM_TEXEL_BUFFER_BIT))) {
	static std::atomic<int> logged{0};
	if (logged.fetch_add(1, std::memory_order_relaxed) < 8)
		logf("vk: texel buffer of format %d (%s) not supported by the device: unbound", (int)f, storage ? "storage" : "uniform");
	return VK_NULL_HANDLE;
}
```

- A format without the needed feature gets no view, and the slot stays
unbound. Callers already handle a null view; it is the same result as
the existing `VK_FORMAT_UNDEFINED` case.
- Because it returns before `texture_gpu_wrote`, a storage bind that is
refused does not mark the texture as written by the GPU.
- The answer comes from the existing `format_props` cache, which takes
one short lock (`fmt_mu`) per texel-buffer bind but does not query the
driver again.
- The message is logged at most 8 times.
- Supported formats follow the same path as before.

### Test

`tests/test_texel_buffer_ownership.py` cuts `texel_buffer_view` out of
vk_texture.cpp and compiles it against the stubs in
`tests/texel_buffer_ownership_test.cpp`:

- The fixture gets a `format_props` stub. It reports both texel-buffer
features for every format except the one mapped from NVN format 44
(`VK_FORMAT_R32G32B32_SFLOAT`).
- A new case, `unsupported`, binds format 44 as uniform and as storage.
It checks that:
  - neither returns a view;
  - no view is created (`creates == 0`);
  - no buffer is looked up (`lookups == 0`);
  - the texture is not marked as written (`writes == 0`).
- All 24 cases pass. On main, the case list has 23.

### Changes after review

- The first version did not add the stub, so the test failed to compile
("'format_props' was not declared in this scope"). The stub and the
`unsupported` case were added.

### How tested

- Release build (`nx2/build.py --standalone`, Windows, llvm-mingw).
- `tests/test_texel_buffer_ownership.py`: 24 cases pass.
- Zelda OoT on Radeon RX 9070 XT (Windows, Adrenalin):
  - Two full sessions on build g54d01844.
- One session of about 40 minutes on nightly g57471723, which includes
this change.
  - No regression.
- "texel buffer of format" does not appear in any of those logs. Every
texel-buffer format OoT uses is supported on this device, so the new
path is not reached there. This change guards against invalid usage; it
does not fix a visible glitch in OoT.

Assisted-by: GitHub Copilot
Restored-from: https://github.com/NotRexed/Hookshot/pull/2741
### The bug

At startup, `coopmat_init` (gpu/vk/vk_coopmat.cpp) checks whether native
matrix multiplies can be used with cooperative matrices. `measure_order`
and `test_multiply` each run a small compute shader through
`run_shader`. `run_shader` does the following:

1. Creates a host-visible buffer and its memory, maps it, and copies the
input into it.
2. Creates the pipeline, a descriptor pool, a command pool and a fence.
3. Submits one dispatch, waits for it for up to 5 s, and reads the
result back.
4. Destroys everything.

It had three problems.

**1. The mapping was not checked.**

```cpp
uint32_t* words = nullptr;
vkMapMemory(dev.device, memory, 0, size, 0, (void**)&words);
std::memcpy(words, data.data(), size);
```

If `vkMapMemory` failed (for example, out of host memory), `words`
stayed null and the `memcpy` crashed the emulator at startup.

**2. A dispatch that did not finish was destroyed under the GPU.**

```cpp
ok = vkQueueSubmit2(dev.queue, 1, &si, fence) == VK_SUCCESS &&
	 vkWaitForFences(dev.device, 1, &fence, VK_TRUE, 5000000000ull) == VK_SUCCESS;
...
vkDestroyFence(...); vkDestroyCommandPool(...); vkDestroyDescriptorPool(...); vkDestroyPipeline(...);
// and at the end of the function:
vkDestroyBuffer(...); vkFreeMemory(...);
```

When the submit succeeded but the wait timed out (a slow or hung
driver), the code still destroyed everything while the GPU could still
be running the dispatch. Each of those calls breaks a rule:

| VUID | Text |
|---|---|
| VUID-vkDestroyFence-fence-01120 | All queue submission commands that
refer to fence must have completed execution |
| VUID-vkDestroyCommandPool-commandPool-00041 | All VkCommandBuffer
objects allocated from commandPool must not be in the pending state |
| VUID-vkDestroyDescriptorPool-descriptorPool-00303 | All submitted
commands that refer to descriptorPool (via any allocated descriptor
sets) must have completed execution |
| VUID-vkDestroyPipeline-pipeline-00765 | All submitted commands that
refer to pipeline must have completed execution |
| VUID-vkDestroyBuffer-buffer-00922 | All submitted commands that refer
to buffer, either directly or via a VkBufferView, must have completed
execution |
| VUID-vkFreeMemory-memory-00677 | All submitted commands that refer to
memory (via images or buffers) must have completed execution |

**3. The fence was created unchecked.**

If `vkCreateFence` failed, the fence was a null handle, and the code
still submitted with it and then waited on it. `vkWaitForFences`
requires valid handles (VUID-vkWaitForFences-pFences-parameter: "pFences
must be a valid pointer to an array of fenceCount valid VkFence
handles").

### What changed (gpu/vk/vk_coopmat.cpp, run_shader)

- A failed mapping now logs "cooperative matrix test: the buffer could
not be mapped", frees the buffer and memory, and fails the probe:

  ```cpp
if (vkMapMemory(dev.device, memory, 0, size, 0, (void**)&words) !=
VK_SUCCESS || !words) {
	  logf("vk: cooperative matrix test: the buffer could not be mapped");
	  vkDestroyBuffer(dev.device, buffer, nullptr);
	  vkFreeMemory(dev.device, memory, nullptr);
	  return false;
  }
  ```

- A fence that cannot be created makes the probe skip the submit.
Nothing was queued, so everything is freed as usual and the probe fails:

  ```cpp
if (vkCreateFence(dev.device, &fci, nullptr, &fence) != VK_SUCCESS)
fence = VK_NULL_HANDLE;
const bool submitted = fence && vkQueueSubmit2(dev.queue, 1, &si, fence)
== VK_SUCCESS;
ok = submitted && vkWaitForFences(dev.device, 1, &fence, VK_TRUE,
5000000000ull) == VK_SUCCESS;
  ```

- A dispatch that was submitted but did not complete leaves the fence,
command pool, descriptor pool, pipeline, buffer and memory alive,
instead of destroying them under the GPU. Only the shader module is
destroyed, which is allowed once the pipeline exists. This is a one-time
leak at startup; on a device that really hung, later submits would queue
behind it anyway.

  ```cpp
if (submitted && !ok) { // (it may still be running: what it uses is
left alive rather than destroyed under it)
	  if (module) vkDestroyShaderModule(dev.device, module, nullptr);
	  return false;
  }
  ```

The probe's result and every successful path are unchanged. A probe that
fails falls back to software matrix multiplies, as before.

### Note for https://github.com/NotRexed/Hookshot/pull/1975

https://github.com/NotRexed/Hookshot/pull/1975 (cooperative matrices for the AA networks) changes `run_shader`'s
signature. It merges cleanly with this PR, but whichever lands second
should check that the new early returns still free what https://github.com/NotRexed/Hookshot/pull/1975 adds.

### Test

There is no dedicated unit test. The failure paths need a driver that
fails `vkMapMemory` or `vkCreateFence`, or a dispatch that takes more
than 5 s.

### How tested

- Release build.
- Zelda OoT on Radeon RX 9070 XT (Windows, Adrenalin), including about
40 minutes on nightly g57471723, which includes this change. Log clean.
- On this GPU, `coopmat_init` stops before the probe: the log says
"matrix multiplies in software (the cooperative matrices lack 16x8x16
f16)". So `run_shader` is not reached there, and none of the changed
paths ran. The test only shows that startup is unaffected.

Assisted-by: GitHub Copilot
Restored-from: https://github.com/NotRexed/Hookshot/pull/2742
### The bug

A sparse texture gets device memory per block (tile) as it is written,
instead of all at once (gpu/vk/vk_texture.cpp):

- `sparse_queue` walks the tiles a write reaches. For each tile that has
no memory yet, it takes memory with `sparse_take`, appends a bind to
`sparse_binds`, and **marks the tile bound at once** (`bound = 1`). The
mip tail works the same way, with `sparse_opaque` and `tail_bound`.
- `sparse_flush` then binds the whole list with `vkQueueBindSparse` and
waits on a fence.

When the bind failed, `sparse_flush` only logged it:

```cpp
res = vkQueueBindSparse(dev.queue, 1, &bi, fence);
...
if (res == VK_SUCCESS) {
	vkWaitForFences(...); vkResetFences(...);
} else {
	logf("vk: sparse texture %#llx: binding failed (%d)", ...);
}
...
sparse_binds.clear(); sparse_opaque.clear();
return res == VK_SUCCESS;
```

Step by step:

1. A write to a sparse texture queues the binds of tiles A, B and C.
Their `tiles[...]` entries are set to 1.
2. `vkQueueBindSparse` fails (for example, out of memory). The list is
cleared, but the tiles stay marked bound.
3. The upload's copies go ahead and write tiles A, B and C, which have
no memory behind them.
4. Every later write to those tiles sees them marked bound, so it never
queues them again. Those tiles stay unbacked for the life of the
texture, and every write to them is lost.

Two more problems were in the same function:

- The fence was created without checking. If `vkCreateFence` failed,
every bind was submitted and waited on with a null fence.
`vkWaitForFences` requires valid handles
(VUID-vkWaitForFences-pFences-parameter: "pFences must be a valid
pointer to an array of fenceCount valid VkFence handles").
- The memory `sparse_take` gave for the failed binds was simply lost to
that texture.

### What changed (gpu/vk/vk_texture.cpp and vk_texture.h)

- **New `sparse_unqueue(tex)`.** When the binds are not made, it:
- marks every queued tile unbound again (`map[i] = 0`), finding the tile
with the same math `sparse_queue` uses;
  - marks every queued mip tail unbound again (`tail_bound[layer] = 0`);
  - puts the memory those binds had taken on the texture's free list;
  - clears the queue, and counts `sparse_added` as `sparse_flush` does.

The next write to those tiles queues them again, so the bind is retried.

- **`sparse_flush` calls it in both failure cases**, and returns false:

  ```cpp
  if (vkCreateFence(dev.device, &fi, nullptr, &fence) != VK_SUCCESS) {
	  fence = VK_NULL_HANDLE;
	  static int logged;
if (logged++ < 8) logf("vk: sparse texture %#llx: no fence for its
binds", ...);
	  sparse_unqueue(tex);
	  return false;
  }
  ...
  } else {
	  static int failed;
if (failed++ < 8) logf("vk: sparse texture %#llx: binding failed (%d)",
...);
	  sparse_unqueue(tex);
	  return false;
  }
  ```

- **New per-texture free list** (`Texture::sparse_free`, with memory,
offset and size). `sparse_take` gives it out before taking new chunk
memory. A bind that keeps failing reuses the same memory instead of
taking new memory on every retry. The list only points at the texture's
own chunks, which are freed with the texture.

- **Both messages are capped at 8.** A failing bind is now retried on
later writes, so without the cap it would log on every write to that
texture.

The success path is unchanged.

### Who reaches this

A texture is only sparse when it is a 2D array larger than
`RX_VK_TEX_MAX_MB` (1.5 GB) on a device with sparse support. That is
MKW's course-map case. OoT likely never makes one.

### Changes after review

- The "binding failed" and "no fence for its binds" messages are capped
at 8, like the other sparse messages.
- The description now says when a texture is sparse, and that the OoT
test does not reach this path.

### Test

There is no dedicated unit test. The failure needs `vkQueueBindSparse`
or `vkCreateFence` to fail on a sparse texture.

### How tested

- Release build.
- Zelda OoT on Radeon RX 9070 XT (Windows, Adrenalin), including about
40 minutes on nightly g57471723, which includes this change. Log clean,
with no "sparse texture" line.
- The sparse path itself was not run. The test only shows that
non-sparse textures are unaffected.

Assisted-by: GitHub Copilot
Restored-from: https://github.com/NotRexed/Hookshot/pull/2743
MkwVkHost gains wide_cover here and HOS_PAD_TOUCHPAD moves inside HOS_PAD_N in the
rc.4 fixes split out of #209 beside this; each change to the interface takes its
own version. The rc.4 fixes take 22 and are meant to merge first; should this
merge first, it takes 22 and the other 23.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Longshot's start sequence asks compile_all() (Compile shaders: All) before a
prediction; the check that https://github.com/NotRexed/Hookshot/pull/2982 extends
had no stand-in for it, which broke only the check's build.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
A bare #NNNN in Longshot's tree links to Longshot's own number. The restored
text's Hookshot pull requests and issues are written as
https://github.com/NotRexed/Hookshot/pull/NNNN (or /issues/NNNN).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
docs(development): RX_VK_TEMPORAL_CUT and RX_VK_SPLIT_UBFLUSH in the switches
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 / The same as upstream (pull_request) Blocked by required conditions
Third-party parts / As their notes say (pull_request) Blocked by required conditions
PR description / Description follows the template (pull_request_target) Successful in 4s
a7b863c551
The two switches the camera-cut and Intel split-submission fixes add were not in the
switch reference.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
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 / The same as upstream (pull_request) Blocked by required conditions
Third-party parts / As their notes say (pull_request) Blocked by required conditions
PR description / Description follows the template (pull_request_target) Successful in 4s
This pull request is marked as a work in progress.
Some workflows are waiting to be reviewed.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u 'restore/renderer-fixes:TrashPanda-restore/renderer-fixes'
git switch 'TrashPanda-restore/renderer-fixes'
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!287
No description provided.