Skip to content

fix: switches that behave as players expect, and dead ones removed - #2042

Merged
hckrman101 merged 21 commits into
NotRexed:mainfrom
hckrman101:knobs-cleanup
Nov 11, 2026
Merged

hckrman101 merged 21 commits into
NotRexed:mainfrom
hckrman101:knobs-cleanup

Conversation

@hckrman101

@hckrman101 hckrman101 commented Nov 11, 2026 •

Copy link
Copy Markdown
Collaborator

Several switches did things a player would not expect: a test run's RX_VK_PACE=1 stayed in hookshot_options.ini for good, a line in hookshot_env.txt beat a switch set for one launch, and switches that change the shader translation did nothing on a PC that already had a program cache. This PR fixes those, removes switches left over from mkw-recomp and TOTK, makes the AMD ASTC workaround a menu row, and makes =0 mean off for every on/off switch. The V-Sync row is #2041.

It is a series of commits, one per part, in this order.

1. Bug fixes

Environment overrides last for their run. RX_VK_FRAMEGEN_MULT, RX_VK_PACE, RX_VK_UPSCALE, HOS_REALTIME_STEP, HOS_DYNRES and HOS_OUTPUT_RES were copied into their rows in menu_init, and the next change of any row saved them. They now apply to the run only: save() writes the player's own value until the player changes that row (env_override, saved_value in vk_menu.cpp; a custom output size of the player's is kept too). tests/test_menu_env_overrides.py compiles the real save() and the overrides' part of menu_init.

Translation switches are in the program cache's stamp. RX_VK_NO_CBUF_LOAD_RULE, RX_VK_PTR_SPLIT_FALLBACK, RX_VK_ALL_GLOBAL_PROGS, RX_VK_NO_EXTREGS, RX_VK_NO_DRAW_PARAMS, and the miss log's RX_VK_MISS_LOG and RX_VK_BLOCK_PROBE, each change what the translator makes (checked at each read) but were not in disk_stamp's list. tests/test_program_stamp_knobs.py reads every RX_VK_ switch the translator reads and fails when one is missing (two that only log are listed as such). Nothing changes for a run that sets none of them.

Rows no preset sets keep the Quality preset. Changing Reflex, Anti-Lag 2, Output resolution, Anisotropic filtering, Ultrawide, Ultrawide menus, Robust buffers, Mesh shaders, Text speed, Text size, Controller in background, Layout preset, Descriptor reuse or Sampler cache turned the preset to Custom. They are preset_exempt now; tests/test_preset_exempt.py derives the rule from apply_preset. The Upscaler stays as it was, because changing it moves Render resolution to the upscaler's own steps.

The environment goes over hookshot_env.txt. The file used setenv(..., 1), so a switch set for one launch (HOS_FULLSCREEN=0 ./hookshot, a launcher's or a script's own) lost to the saved line. A name already set when the game starts now keeps its value, and the log says the file's line was left out; within the file a later line still replaces an earlier one. tests/test_env_file.py covers it. The readme and the development guide now give the order: the environment, then hookshot_env.txt, then the menu's saved rows.

2. Dead switches removed, duplicates merged

Each removal was checked against the code, tests, scripts, workflows, the builders and the docs first.

  • RX_BACKEND=eden: it loaded mkw_eden.dll, which nothing builds, then fell back to WebGPU. gpu/vk/VULKAN.md and vk_api.h now name hookshot_vk.dll.
  • HOS_FIX_RES and HOS_SCENE_NATIVE, with nx2/oot_native_resolution.py, the lift patch they switched (off since Output resolution (2048x1152, 2560x1440, 3840x2160) and a full-size 3D at 4K #849). The lift changes, so the next build lifts again.
  • RX_VK_FOLLOW_MESH ("tried for the foliage, which it did not bring back").
  • TOTK_MINIMAL_LAYOUT, and TOTK_ in the names hookshot_env.txt accepts.
  • RX_VK_OOT_VISUAL, RX_VK_OOT_FOG, RX_VK_OOT_EXPOSURE and the Orchid fog and exposure workaround (off since 11-07; its factors were tuned against bugs since fixed). The NaN guard that shared its header is now in ir_opt/fragment_output_guard.h. The readme's credits say what Orchid's workaround was used for, and the license line no longer lists Orchid's code.
  • nx2/build.py --lib, --only, --opt and --no-polls: nothing passes them. --dll stays (nx2/test_dll.py and nx2/dllmap.py use it).
  • Merged: RX_VK_DESC_SLOTGEN, RX_VK_NO_DESC_REUSE and RX_VK_DESC_REUSE_DGEN are now one switch, RX_VK_DESC_REUSE=0/1/never (the row's values: 0 is its Off, the global check; 1 is its On, by slot, the default; never turns reuse off), which goes over the Descriptor reuse row. The DGEN switch only turned off issue 1126's fix and is gone.
  • Merged: RX_VK_NAN_GUARD=0 turns off both NaN guards; RX_VK_NO_NAN_GUARD is gone.
  • nx2/bake_ds's copy of the stamp's list matches the live one again, and the stamp check now compares them.
  • Kept, documented side by side in the development guide: RX_VK_FRAMELOG (pictures) and RX_VK_FRAME_LOG (frame times), RX_VK_PRESENT_AHEAD (frame generation's copies) and RX_VK_PRESENTS_AHEAD (the replay's queue depth). Renaming them would break scripts that use them for little gain.

Looked at and left in place:

  • HOS_FPS_MAX is not a duplicate of the Game frame rate cap row: it takes any rate (75, 100, 180 Hz screens) where the row has steps, and tests/test_frame_rate_cap_step.py drives the cap through it. Its comment no longer calls it a test.
  • RX_JOBS is no longer in make_game.py; the Linux builder still honours it deliberately (tests/test_linux_builder_jobs.py), and third_party/a64-kit reads it.
  • RX_VK_SPARSE_IDLE turns on the stricter ordering of sparse binds, which was left off for MKW's sake. Whether OoT should have it on is a measurement, not a removal.

3. Menu rows

The Debug tab's Developer section, with Robust buffers and then ASTC as RGBA8 selected (headless capture, 1600x900, RTX 3090, Linux):

Robust buffers in the Developer section
ASTC as RGBA8 in the Developer section

4. One rule for on/off switches

About 230 switches read as on whenever they were set, so RX_VK_NO_PROGRAM_CACHE=0 or HOS_HEADLESS=0 turned the feature on. env_on(name) (gpu/vk/compat/hookshot_env.h, for both host/ and gpu/vk) is off when the variable is unset, empty, 0, false, off or no, and on otherwise. Every check that only asked whether a switch was set now uses it; =1 works as before.

  • 230 switches in 39 files under host/ and gpu/vk (the translator's own switches included) now use it. A switch that some other place reads for a value is left exactly as it was: 25 of them, such as RX_VK_WARP64=0, HOS_FULLSCREEN=0, the lists and the sizes, where 0 already means something.
  • nx2/kit.py has the same rule as env_on() for the build's RX_VK_ONLY. nx2/build.py counts the header in the runtime's object keys, as it does dlss_math.h. The AppImage now packs headers host/ includes from below gpu/vk/.
  • The development guide says what "set" means now.
  • tests/test_env_on.py checks the rule in C, C++ and Python, and fails if a switch is read the old way again. Checks that compile code cut out of the changed files now force-include the header (their getenv stand-ins pass env_on through them). The checks that look for a switch's read by its text now look for env_on. test_display_startup_failure's HOS_HEADLESS=0 case and test_program_answer_domains' RX_VK_REGISTER_AT_ONCE=0 case now expect off.
  • RX_VK_FIFO and RX_VK_IMMEDIATE keep their old reads here because feat(menu): a V-Sync row (Auto, On, Off) #2041 (the V-Sync row) replaces them.
  • Nothing in tests/, scripts/, .github/, the builders or the docs set any of the converted switches to 0 or empty to turn it on (checked by grep).

This part touches many files that other PRs also edit (vk_draw.cpp above all). Most of it is a mechanical rewrite, done by a script that only rewrites presence checks whose value is not read on the same line, so on a conflict it is simplest to drop this commit and run the rewrite again on the new base.

Testing

  • python -I -B tests/run_all.py on the branch, rebased on main 667ba79: 342 passed, 13 skipped. The three failures are the same as on main: nx2/test_nup_read_name.py, tests/test_builder_log_read_only.py and tests/test_wayland_libdecor_hint.py, all failing on this machine without these changes. New checks: test_menu_env_overrides, test_program_stamp_knobs, test_preset_exempt, test_env_on; extended: test_env_file, test_menu_keys.
  • A full Linux build of the branch before the rebase (game and hookshot_vk.so, its exported-symbol check clean, the shaders prepared), and after it a build of hookshot_vk.so (exported-symbol check clean); a headless run with the Insert menu drawn into the shots: the Debug tab's Developer rows show and change. The log has rx: renderer: Vulkan and robust buffer access on (Auto), as before.
  • Not tested in a run: ASTC as RGBA8 turned on (the log should then lack the BC3 line), an AMD card, Windows.

For the maintainer to decide (not changed here)

  • The WebGPU fallback renderer (gpu/render, about 226 switches). It is reached only when hookshot_vk fails to load or start, and the builders still ship wgpu-native. It could be dropped, or frozen with its switches cut to a handful.
  • The A/B revert switches (49 switches whose own comment says "A/B" or "as before", for defaults that shipped and held: RX_VK_NO_PARTIAL, RX_VK_NO_STATE_LIST, RX_VK_PUSH_ALL, RX_VK_PUSH_STATE_OLD, RX_VK_UB_CUR_ONLY, RX_VK_POOL_END_IMPORTED, RX_VK_MIRROR_KEEP, RX_VK_READBACK_NO_CATCHUP, RX_VK_FORCE_LINEAR, RX_VK_UPLOAD_ALL, RX_VK_NO_TABLE_SHRINK and others). One policy that would work: an A/B switch lives for one release after its default flips, then goes.

hckrman101 and others added 6 commits November 10, 2026 19:07
… cache's stamp

RX_VK_NO_CBUF_LOAD_RULE, RX_VK_PTR_SPLIT_FALLBACK, RX_VK_ALL_GLOBAL_PROGS, RX_VK_NO_EXTREGS, RX_VK_NO_DRAW_PARAMS,
RX_VK_MISS_LOG and RX_VK_BLOCK_PROBE change what the translator makes but were not in disk_stamp's list, so on a PC
with hookshot_programs.bin they did nothing until the cache was deleted. A new check reads every RX_VK_ switch the
translator reads and fails when one is missing from the list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hookshot_env.txt replaced any switch already set in the environment (setenv(..., 1)), so a one-off launch flag
(HOS_FULLSCREEN=0 in a shell, a launcher's or a test script's own switch) lost to a line saved in the file. A name
already set when the game starts now keeps its value, and the log says the file's line was left out; within the file a
later line still replaces an earlier one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ets keep the preset

RX_VK_FRAMEGEN_MULT, RX_VK_PACE, RX_VK_UPSCALE, HOS_REALTIME_STEP, HOS_DYNRES and HOS_OUTPUT_RES were copied into
their rows in menu_init, and the next change of any row saved them into hookshot_options.ini for good. They now apply
to the run only: save() writes the player's value until the player changes that row (env_override, saved_value).

Rows no Quality preset sets (Reflex, Anti-Lag 2, Output resolution, Anisotropic filtering, Ultrawide, Ultrawide menus,
Robust buffers, Text speed, Text size, Controller in background, Layout preset, Descriptor reuse, Sampler cache) turned
the preset to Custom when changed. They are preset_exempt now; tests/test_preset_exempt.py derives the rule from
apply_preset. The Upscaler stays as it was: changing it moves Render resolution to its own steps.

The readme and the development guide say which goes first: the environment, then hookshot_env.txt, then the menu's
saved rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Switches left over from mkw-recomp and TOTK, or superseded since, each doubled the paths a bug report could be on.
Removed, each after checking that nothing in the code, tests, scripts, workflows, builders or docs uses it:

- RX_BACKEND=eden: it loaded mkw_eden.dll, which nothing builds, then fell back to WebGPU. RX_BACKEND unset or vulkan
  is unchanged; the comments and gpu/vk/VULKAN.md name hookshot_vk.dll.
- HOS_FIX_RES and HOS_SCENE_NATIVE, with nx2/oot_native_resolution.py, the lift patch they switched (off since NotRexed#849).
- RX_VK_FOLLOW_MESH (tried for the foliage, which it did not bring back).
- TOTK_MINIMAL_LAYOUT and the TOTK_ prefix in hookshot_env.txt's allowed names.
- RX_VK_OOT_VISUAL, RX_VK_OOT_FOG and RX_VK_OOT_EXPOSURE with the Orchid fog and exposure workaround (off since 11-07:
  the factors were tuned against bugs since fixed); the readme's credit says what it was used for. The NaN guard that
  shared its header is in ir_opt/fragment_output_guard.h.
- nx2/build.py --lib, --only, --opt and --no-polls: nothing passes them (--dll stays: nx2/test_dll.py uses it).

Merged:

- RX_VK_DESC_SLOTGEN, RX_VK_NO_DESC_REUSE and RX_VK_DESC_REUSE_DGEN are one switch, RX_VK_DESC_REUSE=0/1/2 (never,
  the global check, by slot), over the Descriptor reuse row. The DGEN switch only turned off issue 1126's fix and is gone.
- RX_VK_NAN_GUARD=0 turns off both NaN guards; RX_VK_NO_NAN_GUARD is gone.

nx2/bake_ds's copy of the program stamp's list matches the live one again (tests/test_program_stamp_knobs.py checks
it). Kept and now documented side by side: RX_VK_FRAMELOG / RX_VK_FRAME_LOG and RX_VK_PRESENT_AHEAD /
RX_VK_PRESENTS_AHEAD. HOS_FPS_MAX stays (it takes any rate, where the row has steps); its comment no longer calls it a test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ffers moved there

The readme told players with AMD texture artifacts to hand-edit RX_VK_ASTC_RGBA8 into hookshot_env.txt, and Robust
buffers, a workaround for driver faults that players cannot judge, sat among the Graphics tab's picture settings.

- Debug, Developer: ASTC as RGBA8 (after a restart), the AMD texture workaround, with a hint saying what it is for and
  what it costs. RX_VK_ASTC_RGBA8 goes over it for a run (=0 now turns it off). Not turned on by itself for any GPU:
  issue 993's report says it took effect on a 9070 XT and did not help.
- Robust buffers moves from Graphics to the Developer section. The Debug tab draws a Developer row with more than two
  values as a choice with arrows.

The readme's menu table, its notes and the AMD section follow, and the table lists the Debug tab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
About 230 switches were read as on whenever they were set, so RX_VK_NO_PROGRAM_CACHE=0 left the program cache out
and HOS_HEADLESS=0 opened no window, while others parsed the number and read 0 as off. env_on(name), in
gpu/vk/compat/hookshot_env.h for host/ (C) and gpu/vk with its translator (C++), is off when the variable is unset,
empty, 0, false, off or no (any case) and on otherwise; env_int(name, default) reads a number. Every check that only
asked whether a switch was set now uses it, so =1 works as before and =0 turns the switch off. A name that some other
place reads for a value (RX_VK_WARP64=0, HOS_FULLSCREEN=0, the lists and sizes) keeps its reads as they were.

nx2/kit.py has the same rule as env_on() for the build's RX_VK_ONLY. nx2/build.py counts the header in the
runtime's object keys, as it does dlss_math.h. nx2/bake_ds reads RX_VK_SHARED_48K and RX_VK_NO_DRAW_PARAMS as the
live back end does. The AppImage packs the header with the others host/ includes. The development guide says what
"set" means now.

tests/test_env_on.py checks the rule in C, C++ and Python, and that no on/off switch is read the old way. The checks
that compile code cut out of the changed files force-include the header (and their getenv stand-ins route env_on
through them); the ones that look for a switch's read by its text look for env_on; test_display_startup_failure's
HOS_HEADLESS=0 case now expects a window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nullifyshot

Copy link
Copy Markdown
Contributor

I read through this one and found nothing that blocks it. Two things need fixing before it can merge, plus a few small ones. I only read the code and haven't run the tests locally. CI hasn't run them either, since GitHub skips the test workflows while the PR has conflicts.

Before merge

  1. Conflicts with main in gpu/vk/vk_menu.cpp and gpu/vk/vk_menu.h. All of them are small:

    After the rebase, Mesh shaders probably needs preset_exempt as well (apply_preset doesn't set it). test_preset_exempt should flag it.

  2. Restart on Windows ignores changes to hookshot_env.txt. relaunch() in host/hos_relaunch.c passes NULL for the environment to CreateProcessW. The new process therefore inherits this one's environment, which already holds every value _putenv set from the file. With the environment now going first, the restarted game skips each line of the file, so edits made while the game runs are lost. The log also reports every line as "set in the environment, which goes first". Linux doesn't have this problem, because it execs with /proc/self/environ, the environment the game started with. A fix: save GetEnvironmentStringsW() in main before load_hookshot_env(), then pass that block, with HOS_RESTART_OF added and CREATE_UNICODE_ENVIRONMENT.

Minor

  • host/posix/display_sdl.c: SDL_VIDEODRIVER and SDL_VIDEO_DRIVER are now read with env_on. They're SDL's own variables and hold a driver name, not an on/off value, so getenv fits them better. These are the only two converted names outside RX_, HOS_, HOOKSHOT_, EDEN_ and NVN_.
  • Removed switches are now ignored silently. Older issues tell players to use RX_VK_NO_DESC_REUSE=1 or RX_VK_NO_NAN_GUARD=1. If those are in someone's hookshot_env.txt, they now do nothing, and nothing says so. A log line that names the replacement (RX_VK_DESC_REUSE=0, RX_VK_NAN_GUARD=0) would help bug reports.
  • load_hookshot_env tracks at most 64 names, each under 96 characters. Past that, a later line for an untracked name is skipped as if it came from the environment. Harmless in practice, but worth a comment or a higher limit.

Checked and looks right

  • The on/off rewrite. I checked that every name now read with env_on isn't read for its value anywhere else. I also checked that nothing in scripts/, builder/, .github/ or the tests sets one to 0 to turn it on, apart from the RX_VK_REGISTER_AT_ONCE=0 case this PR updates.
  • env_override and saved_value: a run's override stays out of hookshot_options.ini until the player changes the row, and a custom Output resolution size is kept.
  • The new stamp names only change the hash when they're set, so existing program caches survive.
  • RX_VK_DESC_REUSE levels match the old switches: row Off is 1, row On is 2, and 0 is the old RX_VK_NO_DESC_REUSE. The removed RX_VK_OOT_*, HOS_FIX_RES and HOS_SCENE_NATIVE were already off by default.

Suggestion: part 4 (the env_on rule) carries most of the risk, and the description already says it may need redoing after conflicts. It could go in a PR of its own, so the bug fixes in parts 1 to 3 don't wait on it.

@hckrman101 hckrman101 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I went through all six commits against the description. Most of it holds up. The removed switches really are gone: nothing in head reads RX_BACKEND=eden/mkw_eden, HOS_FIX_RES, HOS_SCENE_NATIVE, oot_native_resolution, RX_VK_FOLLOW_MESH, TOTK_*, RX_VK_OOT_*, RX_VK_DESC_SLOTGEN/NO_DESC_REUSE/DESC_REUSE_DGEN or RX_VK_NO_NAN_GUARD, and that includes scripts, workflows, builders, docs, the readme and tests. Nothing calls the removed build.py flags either. The Eden changes are only switch plumbing. With nothing set, the translator does what it did before: FOLLOW_MESH was off by default, GuardFragmentOutputs moved over byte for byte, and both NaN guards are still on by default. MkwVkHost/MkwVkRenderer are untouched (only comments in vk_api.h), so no API bump is needed. No new option ids. The edited tests aren't weakened: the needle changes follow the rename, HOS_HEADLESS=0 and RX_VK_REGISTER_AT_ONCE=0 now expect off, and every ENV_ON_H addition has Path imported first. No Windows macro clashes in hookshot_env.h, and every file that calls env_on includes it.

Blocking

  • The branch conflicts with main and CI hasn't run. It is 19 commits behind (base c09e0ac): #2020 Movement speed, #1971 Mesh shaders row, #2000, #2046 and others touch vk_menu.cpp, vk_draw.cpp, vk_shader.cpp, vk_api.h, hos_main.c and 7 of the edited tests. Only the title check ran. The head's vk_api.h still lacks HOS_OPT_CHEAT_SPEED because #2020 isn't in the base yet. After the rebase, the new test_preset_exempt will flag main's Mesh shaders row: apply_preset doesn't set mesh_mode, and the row isn't marked exempt. That's the right call, it just needs the true.
  • The screenshots don't render. The description links debug-robust-buffers.png and debug-astc-rgba8.png as relative paths, so GitHub serves broken images. They need to be uploaded into the description.

Should fix (inline)

  • On Windows, the environment-goes-first rule makes the menu's Restart ignore edits to hookshot_env.txt, and the log then says every line was "left out".
  • RX_VK_DESC_REUSE=1 means the row's Off in desc_reuse_level. test_menu_env_on still checks the env through menu_env_on, where 1 means on, and the real code never takes that path.
  • A few on/off switches still read set-means-on (HOS_NO_HANDOFF, RX_VK_NO_F16, RX_VK_NO_DMABUF, RX_VK_PROF_ALL, half of RX_VK_LOW_SPEC). test_env_on only catches the != NULL form.
  • Players who followed the old readme still have RX_VK_ASTC_RGBA8=1 in hookshot_env.txt. For them, the new row can't turn the workaround off.

What a player with an existing ini / hookshot_env.txt sees after updating. No saved choice flips, so no options_version bump or migration is needed:

  • Any on/off switch set to 0, false, off, no or empty is now off, where it was on before. A leftover RX_VK_NO_NAN_GUARD=1 or RX_VK_DESC_SLOTGEN=1 line now does nothing.
  • A launch's own environment (Steam launch options, a shell) now beats the same name in hookshot_env.txt.
  • RX_VK_PACE, HOS_DYNRES and the other menu-row switches no longer write themselves into the ini. Values saved by earlier runs stay.
  • Robust buffers moves to Debug, under Developer. Its ini key is the same (robust_buffers), so the saved value carries over. ASTC as RGBA8 is a new row, Off by default.
  • The listed rows no longer turn the preset to Custom. Saved presets are unchanged.
  • The first start re-translates the shaders, as with any change under gpu/vk/eden, compat or vk_shader.cpp. The stamp's new prefixes change nothing when they aren't set.

Nits

  • saved_value: if a preset or the upscaler's snap sets an overridden row (resolution, realtime, pace) to the same value the environment forced, save() writes the player's old value instead of the preset's.
  • from_file[64][96]: past 64 names, or for a name of 96 characters or more, a later line no longer replaces an earlier one. On Windows the file is read twice, so the second read also logs "left out" for those names.
  • Stale --no-polls mentions remain in comments at nx2/oot_patches.py:315 and tests/test_text_speed.py:24.

The two maintainer questions at the end (the WebGPU fallback, the A/B switch policy) are NotRexed's call, so I've only noted them here.

Comment thread host/hos_main.c
}
int ours = 0;
for (int i = 0; i < from_file_n && !ours; i++) ours = !strcmp(from_file[i], s);
if (!ours && getenv(s)) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On Windows this breaks the menu's Restart. hos_relaunch.c starts the new process with CreateProcessW(..., NULL /* env */, ...), so it inherits this process's environment, and _putenv has already put every line from the file there. In the new process from_file starts out empty, so each of the file's names is "set in the environment" and gets left out. If the player edits hookshot_env.txt and then picks Restart, the edit is ignored, and the log lists every line as left out. Linux is fine, because it re-execs with /proc/self/environ, the startup environment.

One fix: save the environment block before load_hookshot_env (GetEnvironmentStringsW) and give that to CreateProcessW with CREATE_UNICODE_ENVIRONMENT, plus HOS_RESTART_OF. Another: pass the file's names to the child, e.g. a HOS_ENV_FROM_FILE list that pre-fills from_file.

Comment thread tests/test_menu_env_on.py Outdated
int main(int argc, char **argv) {
if (argc != 3) return 2;
const bool reuse = menu_env_on("RX_VK_DESC_SLOTGEN", "descriptor_reuse", true, 4);
const bool reuse = menu_env_on("RX_VK_DESC_REUSE", "descriptor_reuse", true, 4);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This now checks behaviour the real code doesn't have. desc_reuse_level() reads RX_VK_DESC_REUSE itself, as clamp(atoi, 0, 2), and only calls menu_env_on when the variable is unset. So the env cases here ("1" meaning reuse on, "0" meaning the row's Off) go through a path the game never takes. In the game, RX_VK_DESC_REUSE=1 gives level 1, which is the row's Off (no slotgen), and =0 turns reuse off completely. That's also a trap for anyone used to RX_VK_DESC_SLOTGEN=1 meaning on. Could the test cut desc_reuse_level too and check 0/1/2 plus the unset case? Making the env values match the row (0/1) with a separate value for "never" would also avoid the surprise.

Comment thread tests/test_env_on.py Outdated
reads[name] = reads.get(name, 0) + 1
if "gpu/render" in path.as_posix() or "third_party" in path.as_posix():
continue
for name in re.findall(r'getenv\("([A-Z0-9_]+)"\)\s*[!=]=\s*(?:nullptr|NULL)', text):

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This only matches getenv("X") != NULL and == NULL. The forms the rewrite skipped still read set-means-on, so =0 turns them on:

  • host/nn_os_sync.c:446 getenv("HOS_NO_HANDOFF") ? 0 : 1
  • gpu/vk/vk_shader.cpp:572 !getenv("RX_VK_NO_F16") (also :1559 and vk_draw.cpp:6747; it's a translation switch)
  • gpu/vk/vk_memory.cpp:276 getenv("RX_VK_NO_DMABUF") ? nullptr : ...
  • gpu/vk/vk_internal.h:358 getenv("RX_VK_PROF_ALL") || ...
  • RX_VK_LOW_SPEC: vk_menu.cpp reads atoi != 0, but vk_memory.cpp:1410 treats any value as set, so =0 is half on.

Converting these and widening the pattern (a bare getenv("X") followed by ), ?, &&, ||, or preceded by !) would make "every check that only asked whether a switch was set now uses it" true, and keep it true.

Comment thread readme.md Outdated

This is a workaround for some artifacts, not a general lighting fix. It can substantially increase graphics-memory
use and cause stalls when the card runs out of headroom. To undo it, remove that line and restart the game.
use and cause stalls when the card runs out of headroom. To undo it, turn the row off and restart the game.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Players who followed the old instructions still have RX_VK_ASTC_RGBA8=1 in hookshot_env.txt. That line goes over the row, so turning the row off does nothing for them, and the row shows Off while the workaround is on. Could this say "turn the row off (and remove any RX_VK_ASTC_RGBA8 line from hookshot_env.txt)"? Even better, the row could show as fixed while the variable is set, the way row_fixed does for other env-held rows.

Brings in main (HKSTATE2 NotRexed#2046 and the Mesh shaders row); the tree is the knobs series rebased on it, built and tested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jarynclouatre

Copy link
Copy Markdown
Contributor

Two more dead ones in the same area, in case you want them in this pass: RX_VK_DUMP_AT and RX_VK_DUMP_TEX are named in gpu/vk/VULKAN.md (lines 72-73 on f915af9) and in the comment at gpu/vk/vk_texture.h:211, but nothing in the source reads either one any more.

hckrman101 and others added 4 commits November 10, 2026 21:59
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…epare check read env_on

After the merge with main: the V-Sync row's switches in vk_menu.cpp read through env_on again, and its include was put
after vk_work.h, which tests/test_recorder_sync.py wants last. tests/test_prepare_cache_save.py, new on main, looked for
the prepare's RX_VK_PREPARE_AGAIN read by its old text and compiled it without the header: it now looks for env_on and
force-includes gpu/vk/compat/hookshot_env.h, as the other checks that cut code out of the sources do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hckrman101 and others added 7 commits November 11, 2026 05:09
Main's new switches (RX_VK_LOW_SPEC, RX_VK_AUTO_PRESET, RX_VK_SEED_CHECK) read through env_on/env_int. SDL_VIDEODRIVER
is a value, not a switch: back to getenv. hookshot_env.h sets _CRT_SECURE_NO_WARNINGS for its own stdlib.h on Windows
(the checks paste it ahead of files that set it themselves), and the checks that compile env_on code get the header.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Its stdio.h comes before hookshot_env.h, so on Windows the CRT had already marked getenv deprecated and -Werror
failed the compile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
relaunch() gave CreateProcessW no environment block, so the new process inherited this one's, with every line of
hookshot_env.txt already _putenv'd: each line was then taken for the environment's own and left out, edits made while the
game ran were lost, and the log listed every line as left out. main keeps the environment block it started with
(GetEnvironmentStringsW, before the file is read) and the restart passes that, with HOS_RESTART_OF put in at its place,
as Linux's execve with /proc/self/environ already did.

Also in host/hos_main.c: a switch this build no longer reads (RX_VK_NO_DESC_REUSE, RX_VK_DESC_SLOTGEN, RX_VK_NO_NAN_GUARD
and the other removed ones) gets a log line naming what replaced it, and the file's names are kept up to 256 of up to 127
characters (was 64 of 95).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… takes the row's values

HOS_NO_HANDOFF, RX_NVN_TEXELS_LOG, RX_VK_NO_F16, RX_VK_HANDLES_LIVE, RX_VK_NO_DMABUF, RX_VK_PROF_ALL and vk_memory.cpp's
RX_VK_LOW_SPEC were still on when set to 0. test_env_on now also finds !getenv(x), getenv(x) before ?, && or ||, and a
bare getenv(x) in a condition, not only getenv(x) != NULL.

RX_VK_DESC_REUSE=1 was level 1, the Descriptor reuse row's Off, where RX_VK_DESC_SLOTGEN=1 used to mean On. It now has
the row's values (0 Off, 1 On, through menu_env_on and the env_on rule), and never for no reuse at all (the old
RX_VK_NO_DESC_REUSE). test_menu_env_on cuts desc_reuse_level and checks the levels the game takes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eset saves the rows it sets

Players who followed the old readme have RX_VK_ASTC_RGBA8=1 in hookshot_env.txt, which goes over the row: the row now
shows that value, fixed, with a line saying to remove it, and the readme says so too. A preset or the Upscaler's snap
that sets a row the environment held to the very value it forced now saves that value, not the player's older one.
RX_VK_PACE, HOS_REALTIME_STEP and HOS_DYNRES read their value with the switches' rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-no-polls build mentions

Nothing reads RX_VK_DUMP_AT or RX_VK_DUMP_TEX any more (RX_VK_DUMP takes the present now), and the build no longer has
--no-polls (nx2/lift.py by hand still does).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@hckrman101

Copy link
Copy Markdown
Collaborator Author

nullifyshot, thanks for the careful read.

Here is where each point ended up:

Before merge

  1. Conflicts: main is merged in and CI runs again. Mesh shaders (feat(menu): a Mesh shaders row (Automatic, Native, Compute) #1971) has preset_exempt set (and the restart flag); test_preset_exempt passes on it.
  2. Windows Restart: fixed in fix(restart): Windows' Restart reads hookshot_env.txt again, the way you suggested. main keeps GetEnvironmentStringsW() before load_hookshot_env() (hos_relaunch_keep_env). relaunch() passes that block with CREATE_UNICODE_ENVIRONMENT. HOS_RESTART_OF replaces any older value and is placed in the block's sorted order (hos_relaunch_env_block in host/hos_relaunch.h). test_menu_restart checks the block-building on its own and checks that main and relaunch() wire it up. If saving the block failed, the restart falls back to inheriting the current environment, as before.

Minor

  • SDL_VIDEODRIVER / SDL_VIDEO_DRIVER: back to getenv. You're right, they hold a driver name.
  • Removed switches: the log now has one line per removed name that is still set, with its replacement where there is one: RX_VK_NO_DESC_REUSE → RX_VK_DESC_REUSE=never, RX_VK_DESC_SLOTGEN → RX_VK_DESC_REUSE (same 0/1), RX_VK_NO_NAN_GUARD → RX_VK_NAN_GUARD=0. The rest (RX_VK_OOT*, HOS_FIX_RES, HOS_SCENE_NATIVE, RX_VK_FOLLOW_MESH, RX_VK_DESC_REUSE_DGEN, TOTK_MINIMAL_LAYOUT) are logged as doing nothing.
  • from_file limits: raised to 256 names of up to 127 characters, with a comment saying what happens past that.

Suggestion (splitting out the env_on rule): I kept it in this PR. The merges and CI fixes have already been done on top of it. Splitting it now would mean re-resolving the same files twice. If a maintainer would still rather have it separate, I can split it.

jarynclouatre:

Thanks, both removed. gpu/vk/VULKAN.md now shows the current RX_VK_DUMP=<present> form and no longer names RX_VK_DUMP_TEX. The vk_texture.h comment now describes texture_read_texels itself (docs: drop the dead RX_VK_DUMP_AT and RX_VK_DUMP_TEX, ...).

hckrman101 and others added 2 commits November 11, 2026 06:09
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…elease

test_capture_copy_helpers, test_texture_handle_banks and test_taau_env_lookup cut code that now reads RX_VK_LOW_SPEC,
RX_VK_HANDLES_LIVE and RX_VK_NO_F16 with env_on: they get hookshot_env.h first (test_taau_env_lookup's env_on reads
its own stand-in environment). test_rumble_menu and test_framegen_preset get an env_release stand-in for set_row and
apply_preset, and test_program_answer_domains expects RX_VK_HANDLES_LIVE=0 off, as the other switches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
blessedplummer added a commit to blessedplummer/Hookshot-optimi that referenced this pull request Nov 11, 2026
A dGPU has its own fast memory and a slower bus to system RAM; stock mirrors
every guest pool into VRAM and keeps both copies coherent with captures,
shadows and write-backs. At 4K that coherence is CPU-bound: the write-back
diff compares ~3.1 GB per 600 submits and mirror syncs move ~137k dirty
pages, for 35.6 fps where 1080p runs 60 (issue NotRexed#2099).

Under RX_VK_DGPU=1 a pool's first binding makes no mirror (MIRROR_REFUSED,
the state iGPU runs use today): reads take the lock-free TLS path and GPU
reads/writes land in the imported guest memory, coherent by construction -
no mirror sync, no write-back. Every 600-submit window the heaviest
guest-memory-read pool with >=1 MB of reads and not write-dominated runs a
measured trial (120 frames direct -> 12 settle, held until the mirror is
really on -> 120 mirrored, on the recordings' own timestamps); the mirror
stays only when the win is both >10% (RX_VK_DGPU_GAIN) and >0.25 ms
(RX_VK_DGPU_FLOOR) on present-to-present frame time, so the verdict sees
the mirror's CPU cost and its PCIe benefit alike. Trials whose windows did
not measure the same workload (zero binds, a >2x bind shift, a mirror that
never came) abort with no verdict and no skip line; a pool that aborts
three times sits out the session, and iGPUs run no trials. A lost verdict
persists to hookshot_mirror_skip.txt beside the game's own state files,
keyed to the device and driver (a foreign or header-less file is ignored
and re-trialled). RX_VK_DGPU_TRIALS=0 is pure direct. Trials need serial
replay (documented); under RX_VK_PARALLEL the policy runs trial-less.
Unset = stock exactly: the added counters and logging are gated on the
switch.

Refused pools read uniform buffers as submitted (NotRexed#1795's per-pool form:
the snapshot engages for mirror-less pools at the read, trial-won pools
keep in-place mirror binds), so the artifact class the issue documents on
mirror-less GPUs is covered under the policy too.

Measured on RX 9070 XT / RADV, 4K, Uncapped frame cap, warp-normalized
adjacent pairs (Hyrule Field): 70.2->78.0, 71.0->77.0, 59.6->76.6 fps
(+9.7% median), draw parity 705 vs 709, GPU 9.2 ms of a ~14 ms frame.
Mirror-sync dirty pages 136,872 -> 0; write-back diff 3.06-3.13 GB ->
330 MB or less per 600 submits.

Review round 2 (external): RX_VK_DGPU_TRIALS=0's predicate was inverted
and now matches the docs (=0 is pure direct); the window's read counter
moved off a shared map into the pool (dgpu_read, both direct paths);
Settle holds until the mirror is actually live; zero-bind baselines and
>2x bind drift between the direct and mirrored windows abort the trial
un-blacklisted; keep_line refuses signed and trailing-garbage lines.

Review round 3 (hckrman101, Joey-Magnus): refused pools read UBs as
submitted (NotRexed#1795 per-pool, live-smoked at 1.26M copies fault-free);
verdicts decide on present-to-present frame time instead of GPU-only
time (the mirror's CPU and upload-buffer cost is what the frame pays);
the policy runs trial-less under RX_VK_PARALLEL and its fast-path
counters are parallel-safe; no trials on iGPUs and a 3-abort cap; the
skip list carries a device+driver header and ignores foreign files; the
test driver uses {cxx}. Rebased onto main's env_on switch rule (NotRexed#2042)
and the split-submission fence work (NotRexed#2419).

Co-authored-by: GLM-5.3-Flash <glm-5.3-flash@opencode.noreply.github.com>
blessedplummer added a commit to blessedplummer/Hookshot-optimi that referenced this pull request Nov 11, 2026
A dGPU has its own fast memory and a slower bus to system RAM; stock mirrors
every guest pool into VRAM and keeps both copies coherent with captures,
shadows and write-backs. At 4K that coherence is CPU-bound: the write-back
diff compares ~3.1 GB per 600 submits and mirror syncs move ~137k dirty
pages, for 35.6 fps where 1080p runs 60 (issue NotRexed#2099).

Under RX_VK_DGPU=1 a pool's first binding makes no mirror (MIRROR_REFUSED,
the state iGPU runs use today): reads take the lock-free TLS path and GPU
reads/writes land in the imported guest memory, coherent by construction -
no mirror sync, no write-back. Every 600-submit window the heaviest
guest-memory-read pool with >=1 MB of reads and not write-dominated runs a
measured trial (120 frames direct -> 12 settle, held until the mirror is
really on -> 120 mirrored, on the recordings' own timestamps); the mirror
stays only when the win is both >10% (RX_VK_DGPU_GAIN) and >0.25 ms
(RX_VK_DGPU_FLOOR) on present-to-present frame time, so the verdict sees
the mirror's CPU cost and its PCIe benefit alike. Trials whose windows did
not measure the same workload (zero binds, a >2x bind shift, a mirror that
never came) abort with no verdict and no skip line; a pool that aborts
three times sits out the session, and iGPUs run no trials. A lost verdict
persists to hookshot_mirror_skip.txt beside the game's own state files,
keyed to the device and driver (a foreign or header-less file is ignored
and re-trialled). RX_VK_DGPU_TRIALS=0 is pure direct. Trials need serial
replay (documented); under RX_VK_PARALLEL the policy runs trial-less.
Unset = stock exactly: the added counters and logging are gated on the
switch.

Refused pools read uniform buffers as submitted (NotRexed#1795's per-pool form:
the snapshot engages for mirror-less pools at the read, trial-won pools
keep in-place mirror binds), so the artifact class the issue documents on
mirror-less GPUs is covered under the policy too.

Measured on RX 9070 XT / RADV, 4K, Uncapped frame cap, warp-normalized
adjacent pairs (Hyrule Field): 70.2->78.0, 71.0->77.0, 59.6->76.6 fps
(+9.7% median), draw parity 705 vs 709, GPU 9.2 ms of a ~14 ms frame.
Mirror-sync dirty pages 136,872 -> 0; write-back diff 3.06-3.13 GB ->
330 MB or less per 600 submits.

Review round 2 (external): RX_VK_DGPU_TRIALS=0's predicate was inverted
and now matches the docs (=0 is pure direct); the window's read counter
moved off a shared map into the pool (dgpu_read, both direct paths);
Settle holds until the mirror is actually live; zero-bind baselines and
>2x bind drift between the direct and mirrored windows abort the trial
un-blacklisted; keep_line refuses signed and trailing-garbage lines.

Review round 3 (hckrman101, Joey-Magnus): refused pools read UBs as
submitted (NotRexed#1795 per-pool, live-smoked at 1.26M copies fault-free);
verdicts decide on present-to-present frame time instead of GPU-only
time (the mirror's CPU and upload-buffer cost is what the frame pays);
the policy runs trial-less under RX_VK_PARALLEL and its fast-path
counters are parallel-safe; no trials on iGPUs and a 3-abort cap; the
skip list carries a device+driver header and ignores foreign files; the
test driver uses {cxx}. Rebased onto main's env_on switch rule (NotRexed#2042)
and the split-submission fence work (NotRexed#2419).

Co-authored-by: GLM-5.3-Flash <glm-5.3-flash@opencode.noreply.github.com>
hckrman101 added a commit that referenced this pull request Nov 11, 2026
…t compile sooner and stay put (#2324)

Rebuilding the game spends most of a warm build in the lift (translating
the game to C), and most of a cold build in the eight hot pieces. This
PR makes both shorter. The game's code comes out the same.

## What changes

1. **A player's update skips the lift when the tools did not change**
(`make_game.keep_translation` / `kept_before`, `build.py` `KEPT`).
- Before, the clean-up after a player's build deleted all of `sa/`,
including the 1.5 GB of lifted C. So every update lifted again (1-3
minutes), even when nothing the lift reads had changed.
- Now the clean-up deletes only the lifted `code_*.c` files and the
build outputs. It keeps:
     - the rest of the lift (~124 MB: headers, runtime tables, image);
- a list that build.py writes after the compile: each lifted file's
object key, its size, and a hash of its flags and headers
(`gen/lifted_objects.txt`). Its first line names the lift key it was
written for and a format number; a new lift deletes the list of the one
before.
   - The lift key in `.lift_key` gets a third line, `kept`.
- The next build with the same lift key, and a list naming that key,
skips the lift. make_game passes the list as `build.py --kept-lift`.
build.py then takes the lifted files' objects from the object cache by
the keys in that list. These are the same objects as before, so nothing
is compiled again.
- If an object is missing, or the flags or headers changed, build.py
stops before compiling anything (exit status 75, `KEPT_STALE`; not 3,
which is `abort()`'s on Windows). make_game then lifts again and
compiles as usual.
- If the clean-up cannot remove a lifted file (Windows: one still held
open), the build still succeeds: it lets `sa/` go as far as it can, and
the next update lifts again.
- The two builder apps' after-build clean-up (`Storage.Free(afterBuild)`
/ `core.free(after_build=True)`) also deleted `sa/`. It now leaves a
kept lift in place. "Free up space" still deletes it.
2. **The big chunks are split in one process per file**
(`chunks.split_big`, using the same worker pool as the edits). Before,
it read all 1.5 GB of chunks on one core.
3. **The runtime tables and standalone files are written beside the
edits.** These are `a64_rtgen`, `hos_image.c`, the import stubs and
`nvn2_exports.c`. They read the image, not the lifted C, so a forked
process now makes them while the C is being edited. Without fork
(Windows), or if that process fails, they are made after the edits, as
before.
4. **The shader list is made again only when its inputs changed**
(`make_game.shader_list_current`).
- Before, every build ran `shader_census.py` (about 4 s) and rewrote the
514 MB `hookshot_shaders.bin`.
- Now a note in the work folder records what the list was made from: the
script, the Python, the names, sizes and times of the files the census
reads, and the list file itself. If none of these changed, the census is
skipped.
5. **The Windows setup parts are zipped at the same time**
(`scripts/make_setup_parts.py`), one thread each, as the Linux
`make_parts.py` has done since #2079. The zips come out byte for byte
the same.
6. (Dropped: compiling the hot pieces without loop unrolling. It was in
this PR and is reverted by a later commit; see "Not in this PR".)
7. **The hot pieces of the last lift are kept** (`split_hot.py`).
- Before, split_hot dealt the hot functions out into eight pieces from
scratch at every lift. So a few bytes more in one hot function (a patch)
moved functions between most of the pieces, and every player compiled
all eight again. That is the longest third of a cold compile.
- Now the assignment is kept in `<work>/image/hot_pieces.txt`, next to
the chunk boundaries:
     - functions from the last lift stay in their piece;
- a new function goes to the piece of the function it shares the most
calls with;
- a fresh split is made when the kept split's longest piece would cost
more than 15% over a fresh split's.
- Each piece now declares only the functions and host calls it uses. A
new declaration therefore no longer changes every piece.
- The first split after this PR puts the same functions in each piece as
before.
- `hot_pieces.txt` is part of the lift key, as `chunks.txt` is: the
pieces' C follows it.
8. **On Windows, the lift's per-file passes run in several processes**
(`chunks.file_jobs`, `_each`). Before, they used forked workers only, so
without fork (Windows) they ran in one process. Their workers take a
file's path and read the file themselves, so without fork they now start
as new processes (spawn). The emit still runs in one process there.
`HOOKSHOT_LIFT_NO_FORK=1` takes this path on Linux, for testing.
9. Earlier in this PR (first pass):
   - the lift's edits run in one process per file;
   - the lift prints a timing line for each step;
   - the lift key covers only the modules the lift imports;
- a compile that failed before the link keeps the objects it already
made.

## Measured

Setup: Linux, Ryzen 9 7900X (24 threads), 30 GB RAM, other jobs running
on the PC.

- Timed runs used `oot-status --lock --timing`, which gives the run its
own core complex (6 cores / 12 threads).
- Old and new were alternated.
- The bundled clang 23 and Python were used.

**Player update, same tools** (`make_game.py --nup`, no
`HOOKSHOT_KEEP_BUILD_FILES`, objects cached, shader prepare skipped).
Times are from start to `@@done`:

| | update 1 | update 2 |
|---|---|---|
| before (lifts each time) | 100 s (lift 83 s) | 193 s (lift 175 s, PC
busier) |
| after (lift kept) | 9 s | 7 s |

Caveat: these four runs went through `make_game.py` under `oot-status
--timing`. A queue bug that night moved `make_game.py` / `build.py`
processes onto the busy build CPUs during timed runs, so the absolute
seconds of both rows are inflated by load. The gap does not come from
load, though: the "before" runs spent 83 / 175 s in a lift that the
"after" runs skip entirely. The lift-step, hot-piece and game-speed
numbers below ran lift.py, clang or the game directly, and the bug did
not affect them.

A second pair after the queue bug was fixed (`--timing --cpu-only`, one
round, the PC heavily loaded and the builds at idle priority, so both
absolute times are high again): make_game took 640 s before (lift 614 s:
discovery alone 162 s, against 26 s on a quiet PC) and 42 s after (lift
skipped; the rest is the finish stage's file copies).

- After a full build at the same commit, a kept update produced the same
`hookshot`, `hookshot_vk.so` and `image.bin` (sha1).
- Fallback test: I deleted one kept object (`code_010`) and ran
make_game again. It printed "the translation kept from the last build
does not fit this one (1 of 80 files: code_010.c): translating the game
again", lifted, compiled that one file, and produced the same `hookshot`
sha1 as the full build.
- The kept files take 124 MB in the work folder, on top of the objects.

**Lift steps** (lift.py + split_hot.py run alone, two rounds each). All
95 output files and `chunks.txt` were sha1-identical to the old lift:

| step | old (round 1 / 2) | new (round 1 / 2) |
|---|---|---|
| big chunks split | 6.0 / 5.6 s | 2.3 / 2.1 s |
| patches | 2.7 / 2.6 s | 3.5 / 3.1 s |
| output size + page-mark groups | 5.7 / 5.7 s | 6.0 / 6.2 s |
| runtime tables + standalone files | 4.3 / 4.0 s | 0.0 s (beside the
edits) |
| these four steps | 18.7 / 17.9 s | 11.8 / 11.4 s |

**Shader list:** the finish stage took 1 s when the list was kept,
against 4-7 s when it was made.

**Windows setup parts:** I could not run a real package here. On a 104
MB stand-in package (three parts), three rounds took 1.81 / 1.50 / 1.50
s before and 1.05 / 1.15 / 1.04 s after. The zips and
`hookshot-setup.txt` were identical.

**Windows path for the per-file passes:** measured on Linux with
`HOOKSHOT_LIFT_NO_FORK=1`, against the old one-process lift
(`HOOKSHOT_LIFT_JOBS=1`), two rounds. The three per-file passes (big
chunk split, patches, output size + page-mark groups) took 9.9 / 16.1 s,
against 40.6 / 35.6 s. The non-hot lifted files were byte-identical. The
hot pieces differ only because of item 7. The whole lift's time varied
more than that, because the one-process emit took 56-84 s from run to
run.

**Hot pieces kept:** this was measured without a game run, on the game's
real `code_hot.c` (849 functions). In 12 trials, each adding 1-60 lines
to one random hot function, the next split changed 5.42 of the 8 pieces
on average before (1-6 per trial) and exactly 1 after. A fresh split put
the same functions in each piece as the old code.

**Tried and dropped:**

- A faster discovery walk in the lifter (reading the image through a
32-bit view). The output was identical, but discovery ended at 21 vs 23
s in one round and 26 vs 25 s in the other: no clear gain.
- The coverage report in a side process during the emit. The output was
identical, but over four rounds the lift took 92.5 / 61.7 / 82.0 / 68.4
s against 71.6 / 72.4 / 88.4 / 69.8 s without it, a mean change of +0.6
s. The emit slowed by about what the report saved.
- Writing the lifter's small constant-count loops (NEON permutes and
lane loops) out as straight-line C in the emitter. The instruction test
gave the same results as before (Unicorn compare, 41,552 cases, 744 of
749 forms pass, and the same 5 forms fail without the change). But one
hot piece then compiled for over 16 minutes against about 3, so the idea
was dropped.

**Full build of the branch** (before merging main, still with the
dropped item 6): lift 94 s, 9 of 264 files compiled (the eight hot
pieces, 165-363 s each, and one more), then a kept update from it as
above.

**Merge with main:** main removed `oot_native_resolution.py` (#2042) and
added patches to `oot_patches.apply` (the text hooks of #2366, Camera
speed of #1945). They are in the per-file `patch_file` / `report` split
now; the ripple fix of #2409 goes through the frame-time table as
before. A full build of the merge booted from the test save into Kokiri
Forest and played 20 s of scripted running and sword swings (headless)
without an error. It lifted with no patch warnings: every patch found
once, the camera speed at its 4 functions, the three text hooks, both
step divides and both frame counters (the ripple one included). 3 files
compiled.

**Tests:** `tests/run_all.py` at the head (the merge and the review
fixes): 402 passed, 0 failed, 12 skipped. New or changed for the fixes:
a kept list of another lift or format is not used, a clean-up that
cannot remove a file is no failed build, the kept hot pieces change the
lift key, and no module of the lift imports by a name made at run time.
Before the merge: 364 passed, 1 failed (`test_wayland_libdecor_hint`, a
harness `-Werror` warning that also fails without this PR). New tests:
`test_kept_translation.py`, `test_shader_list_skip.py` and
`test_sticky_hot_pieces.py`. Updated tests that cut functions out of
build.py and make_game.py: `test_lift_cleanup_error.py` and
`test_compile_retry_jobs.py`.

## Not tested

- Windows, for all of it (CI runs the Windows test suite; two of its
tests failed on this branch at first, line endings and a missing `lz4`,
and were fixed):
- the kept lift on Windows, and the C# builder change (`Storage.cs`): no
.NET SDK here, only the Python builder's twin was run;
  - the Windows setup zips with a real package;
- the lift's per-file passes in spawned processes on Windows (item 8:
only the Linux spawn path was run), and the side process for the tables
there (it falls back to one process).
- While checking this I noticed that the play profile is out of date for
this lift: clang ignored the profile for 58 of the 134 functions in
`code_hot_1.c` (`-Wprofile-instr-out-of-date`). This happens without
this PR too.
- A kept lift across a real release-to-release update. It was tested
with two builds of the same tools, and with a deleted object for the
fallback.
- Only one round of the re-timed update (above); the first pair is in
the table.
- A full build of the last head, with the review fixes (unrolling back,
the kept list's header, the hot pieces in the lift key): the merge just
before them was built and run, the fixes only by the game-free tests.
With unrolling back, the hot pieces compile with main's flags again.

## Not in this PR (measured or written, for later)

- Compiling the hot pieces without loop unrolling (`-fno-unroll-loops`).
It cut the hot pieces' compile a lot (`code_hot_4.c` 261 → 216 s alone;
all eight 4547 → 2807 s of CPU in a loaded build), and Kokiri Forest ran
as fast (62.8/64.8 fps before, 65.1/65.2 after). But a review found that
it also stops clang from fully unrolling the lifter's ~25,000
fixed-count lane loops (`for (_i < 4)` and the like): on six lane-heavy
functions of `code_hot_5.c` the loops left in the code went from 65 to
263 and the stack accesses from 744 to 1165. The scenes measured barely
run those functions, so they could not show it. Reverted. A narrower set
(`-mllvm -unroll-runtime=false -mllvm -unroll-allow-partial=false`,
perhaps `-mllvm -unroll-max-upperbound=0`) gave code byte-identical to
today's on that sample; its compile-time gain with the play profile is
not measured yet.

- Smaller chunks of ordinary code: three 24 MB chunks compiled whole
took 145 s of CPU and 1.4-1.6 GB each; the same code cut in thirds (at
function starts, as `split_big` cuts) took 117 s (-19%) and 0.52-0.70 GB
each. More compilers would fit on a 16 GB PC, and less CPU on any PC. It
changes every ordinary chunk's object (one full compile for everyone)
and needs a game-speed check, since fewer callees share a file.
- Unpacking the romfs while the game is translated and compiled (about
as long as the unpack, ~1-2 minutes, off a first build): written and
tested game-free, not measured end to end.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
blessedplummer pushed a commit to blessedplummer/Hookshot-optimi that referenced this pull request Nov 11, 2026
Upstream's NotRexed#2042 gave the switches one rule (env_on: unset, empty, 0, false,
off or no all mean off). RX_VK_DGPU joins it; the trials' switch keeps its
default-on ternary (env_on has no default-on form), and the two numeric knobs
refuse an empty value like env_int does.

Assisted-by: GLM-5.3-Flash (OpenCode)
Co-authored-by: GLM-5.3-Flash (OpenCode) <glm-5.3-flash@opencode.noreply.github.com>
blessedplummer added a commit to blessedplummer/Hookshot-optimi that referenced this pull request Nov 11, 2026
A dGPU has its own fast memory and a slower bus to system RAM; stock mirrors
every guest pool into VRAM and keeps both copies coherent with captures,
shadows and write-backs. At 4K that coherence is CPU-bound: the write-back
diff compares ~3.1 GB per 600 submits and mirror syncs move ~137k dirty
pages, for 35.6 fps where 1080p runs 60 (issue NotRexed#2099).

Under RX_VK_DGPU=1 a pool's first binding makes no mirror (MIRROR_REFUSED,
the state iGPU runs use today): reads take the lock-free TLS path and GPU
reads/writes land in the imported guest memory, coherent by construction -
no mirror sync, no write-back. Every 600-submit window the heaviest
guest-memory-read pool with >=1 MB of reads and not write-dominated runs a
measured trial (120 frames direct -> 12 settle, held until the mirror is
really on -> 120 mirrored, on the recordings' own timestamps); the mirror
stays only when the win is both >10% (RX_VK_DGPU_GAIN) and >0.25 ms
(RX_VK_DGPU_FLOOR) on present-to-present frame time, so the verdict sees
the mirror's CPU cost and its PCIe benefit alike. Trials whose windows did
not measure the same workload (zero binds, a >2x bind shift, a mirror that
never came) abort with no verdict and no skip line; a pool that aborts
three times sits out the session, and iGPUs run no trials. A lost verdict
persists to hookshot_mirror_skip.txt beside the game's own state files,
keyed to the device and driver (a foreign or header-less file is ignored
and re-trialled). RX_VK_DGPU_TRIALS=0 is pure direct. Trials need serial
replay (documented); under RX_VK_PARALLEL the policy runs trial-less.
Unset = stock exactly: the added counters and logging are gated on the
switch.

Refused pools read uniform buffers as submitted (NotRexed#1795's per-pool form:
the snapshot engages for mirror-less pools at the read, trial-won pools
keep in-place mirror binds), so the artifact class the issue documents on
mirror-less GPUs is covered under the policy too.

Measured on RX 9070 XT / RADV, 4K, Uncapped frame cap, warp-normalized
adjacent pairs (Hyrule Field): 70.2->78.0, 71.0->77.0, 59.6->76.6 fps
(+9.7% median), draw parity 705 vs 709, GPU 9.2 ms of a ~14 ms frame.
Mirror-sync dirty pages 136,872 -> 0; write-back diff 3.06-3.13 GB ->
330 MB or less per 600 submits.

Review round 2 (external): RX_VK_DGPU_TRIALS=0's predicate was inverted
and now matches the docs (=0 is pure direct); the window's read counter
moved off a shared map into the pool (dgpu_read, both direct paths);
Settle holds until the mirror is actually live; zero-bind baselines and
>2x bind drift between the direct and mirrored windows abort the trial
un-blacklisted; keep_line refuses signed and trailing-garbage lines.

Review round 3 (hckrman101, Joey-Magnus): refused pools read UBs as
submitted (NotRexed#1795 per-pool, live-smoked at 1.26M copies fault-free);
verdicts decide on present-to-present frame time instead of GPU-only
time (the mirror's CPU and upload-buffer cost is what the frame pays);
the policy runs trial-less under RX_VK_PARALLEL and its fast-path
counters are parallel-safe; no trials on iGPUs and a 3-abort cap; the
skip list carries a device+driver header and ignores foreign files; the
test driver uses {cxx}. Rebased onto main's env_on switch rule (NotRexed#2042) and the split-submission
fence work (NotRexed#2419); the trials' functions sit outside the write-back
observation's compile-span check (NotRexed#2131) and the GCN mirror-less default
(NotRexed#2343) composes unchanged (on GCN the switch runs trial-less direct plus
the UB snapshot).

Co-authored-by: GLM-5.3-Flash <glm-5.3-flash@opencode.noreply.github.com>
hckrman101 pushed a commit that referenced this pull request Nov 11, 2026
The Graphics tab is one list of 24 settings, with the window, HDR,
upscaling, frame generation and GPU options mixed
together, and about ten more are on their way in open PRs. This puts its
rows under section titles, so the upscaler, frame
generation or HDR can be found without scrolling the whole list. It is
the first step of a menu organization laid out below
for discussion.

**What changed**
- The Graphics rows in six sections, under heading rows like the Debug
tab's Developer (a gold title, never selected,
  nothing to click):
  - Quality preset (first: it sets rows in every section)
- **Resolution and upscaling:** Output resolution, Profile, Upscaler,
DLSS model, Render resolution, Sharpness
- **Frame generation and latency:** Frame generation, Frame pacing, Game
frame rate cap, NVIDIA Reflex, AMD Anti-Lag 2
- **Screen:** Fullscreen, V-Sync, Window scaling filter, Aspect ratio
(#2119), Ultrawide menus
- **HDR:** HDR, HDR paper white, HDR peak brightness, HDR contrast
(#2342), HDR tone mapper (#2481)
  - **Detail:** Anisotropic filtering
- **GPU and memory:** GPU device, VRAM budget (#1859), Mesh shaders
(Robust buffers is on Debug ▸ Developer since #2042)
- Moves only: every row's definition is unchanged, word for word; no row
is renamed and no value or saved key changes, so
options files carry over, and #1864's tables (keyed by the English text)
still find every row. They need the six
  headings.
- No new drawing or input code: the menu already draws heading rows on
any tab, and the keys, the pad and the mouse step
  over them.
- `readme.md`: a sentence on the sections, and the option table in the
new order. (Ultrawide menus and Mesh shaders have
no line yet; #2105 adds them.) The **Frame generation by** row from
#2118 gets its line in the table here too: main's
readme has none yet. So do #1859's **VRAM budget** (under GPU and
memory) and #2342's **HDR contrast** (under HDR).
- `tests/test_upscaler_rows.py`: its order list follows the picture's
path (output, upscaler, render size, sharpening,
then the frame rows, then the window). New
`tests/test_menu_sections.py`: the Graphics sections and which rows they
hold,
and on every tab no section that `row_shown` could leave empty (a title
over nothing on a GPU without Reflex, say).

**The plan this is step 1 of (for discussion)**

Each tab answers one question; a section groups the rows you set
together; a row that depends on another follows it; vendor
rows sit beside their counterpart and hide where they can't run (no
vendor sections, so nothing is left empty on AMD); rows
to touch only when something is wrong go last; moves only, no renames.

| Tab | Holds | Sections |
|---|---|---|
| **Game** (was Gameplay; first, where the menu opens) | how the game
behaves and reads, and Restart and Quit, as a pause menu | flat; Restart
and Quit last |
| Graphics | how the picture is made, and what it costs | Quality preset
· Resolution and upscaling · Frame generation and latency · Detail · GPU
and memory |
| **Display** (new) | how it gets to your screen, and what is drawn over
it | Screen · HDR · Overlays (FPS counter, Menu size, from Game) |
| Audio | sound out and in | + Microphone (from Game: it picks the input
device) |
| Controls | how you play | Camera · Controller · Buttons (optional) |
| Debug | tools and developer switches | Robust buffers in Developer
(#2042, merged) |

Next PRs, one topic each: (2) a Display tab, taking the Screen and HDR
sections over whole and adding Overlays; (3) the
Gameplay tab titled Game, first in the strip, where the menu opens; (4)
Microphone to Audio; (5) optional, sections on
Controls. If a sixth tab is a no, this PR stands on its own.

Open questions:
1. A sixth tab (Display)?
2. V-Sync on Display (the PC convention) or with frame generation (it
changes with it)?
3. Sharpness with the upscaling rows (where players look for it) or with
the window filter (where it runs)?
4. DLSS 5 (#1590) and #1834's Neural rows as a page opened from a
Graphics row, like Motion, rather than a seventh tab?

<details><summary>Where open PRs' rows would go</summary>

- Resolution and upscaling: Sharpening method (#1979), DLSS 5 page
(#1590), Native 3D resolution (#2017)
- Frame generation and latency: Frame generation by (#2118, merged: in
this section now), Frame generation method (#1834)
- Detail: Shadow resolution (#1892), Screen filter and Filter strength
(#1488)
- GPU and memory: Texture streaming cache (#2017), Refresh memory
(#888); VRAM budget (#1859, merged) is in this section now
- Game (was Gameplay): Game language and Menu language (#1864), Skip
title screen (#1560), Amiibo (#1521)
- Controls: Camera speed (#1945)
- #2042 (merged) moved Robust buffers to Debug ▸ Developer.
- #2105: its readme check would read the heading rows as rows; it needs
to skip `{TAB_..., "...", nullptr, 0, 0, 0, ...,
  true}` headings, or this PR adds that if it lands first.

Whichever PR lands second moves one row on rebase; happy to do it for
anyone.

</details>

**Testing**
- `tests/run_all.py` on Windows 11 (llvm-mingw clang, 8 at once) after
merging main at 2dfe901 (with #2119's Aspect ratio, #2481's HDR tone
mapper, #2118's Frame generation by,
#2342's HDR contrast and #1859's VRAM budget): 422 passed, 33 skipped, 2
failed, both outside this PR:
`test_vram_budget_row.py` (fails the same on main's own sources here)
and `test_portable_builder_startup.py` (its
`dotnet publish` needs packages this PC does not have). A build of the
branch compiles. All 81 of main's rows keep
main's definitions word for word; the only additions are the six
headings.
`test_menu_sections.py` fails on main as it stands (no sections) and
passes here; a layout with a section of Reflex and
  Anti-Lag 2 alone fails it.
- A build of this branch (Windows 11, RTX 5090), headless with scripted
menu keys at Small, Medium and Large: 12 Downs
from the Quality preset land on V-Sync and 13 Ups come back past it to
the last row, so every heading is stepped over
both ways and Up from the first row wraps to the last; the help box
always shows the selected row (at the first head,
when Robust buffers was the last row). Again at this head (25feb16,
after #2042, #1859 and #2119): Up from the Quality preset
wraps to Mesh shaders, the last row now, under VRAM budget (the bottom
screenshot).

**Not tested**: in a window with a pad, the mouse wheel and a click just
under a heading (the headless runs drive
the keys); values kept across a restart (no row's saved key changes);
AMD and Intel (their rows hide through `row_shown` as before; the new
check makes sure no section can end up
empty), Linux.

**Screenshots**

The Graphics tab at 1920x1080, Medium menu size: the top, then scrolled
with the keys to V-Sync and to the last row
(the selection steps over every heading; Up from the first row wraps to
the last row). The bottom one is retaken at this
head (25feb16, a 16:9 picture: `HOS_ASPECT=1.7778`, as on a 16:9
monitor); the others are from the first head (634ef65).

![Graphics tab, top: Quality preset, then Resolution and upscaling and
Frame generation and
latency](https://raw.githubusercontent.com/pmwork-personal/Hookshot-pr/pr-screenshots/menu-sections/graphics-sections-medium-1.webp)

![Graphics tab, middle: Frame generation and latency, then Screen,
V-Sync
selected](https://raw.githubusercontent.com/pmwork-personal/Hookshot-pr/pr-screenshots/menu-sections/graphics-sections-medium-2.webp)

![Graphics tab, bottom: Screen with Aspect ratio, HDR, Detail, then GPU
and memory with GPU device, VRAM budget and Mesh shaders, Mesh shaders
selected](https://raw.githubusercontent.com/pmwork-personal/Hookshot-pr/pr-screenshots/menu-sections/graphics-sections-medium-3c.webp)

Small and Large:

![Graphics tab at Small menu
size](https://raw.githubusercontent.com/pmwork-personal/Hookshot-pr/pr-screenshots/menu-sections/graphics-sections-small.webp)

![Graphics tab at Large menu
size](https://raw.githubusercontent.com/pmwork-personal/Hookshot-pr/pr-screenshots/menu-sections/graphics-sections-large.webp)

_Taken from headless runs of this branch (`HOS_HEADLESS=1`,
`RX_VK_SHOT_MENU=1`, the keys from `RX_VK_MENU_SCRIPT`), RTX
5090. Headless, the list never scrolls on main either:
`menu_record_image` leaves `ease_step` at 0, so the build behind
the scrolled shots had one more line there (`ease_step = 1.0f`, hence
"-dirty" in the first head's build label), not part of
this PR.
The windowed menu eases as before._

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
pmwork-personal added a commit to pmwork-personal/Hookshot-pr that referenced this pull request Nov 11, 2026
…ches' frame said

Review notes (Joey-Magnus, hckrman101):
- RX_VK_REFLEX_SUBMIT_IDS is read with env_value_off (hookshot_env.h, the
  switches' rule since NotRexed#2042) instead of atoi(...) == 0, which took =on or
  =true as off.
- vk_reflex.cpp and the batch start in vk_replay.cpp say how a batch is
  credited: to the frame of its first command buffer, read when the batch
  starts, so one spanning two game frames counts as the first's and one
  begun before the frame's sleep returned goes untagged. Batching is on by
  default only with RADV, which has no Reflex.
- docs/development.md: the switch in the Reflex row.
- test_reflex_swapchain_lifetime.py runs its fixture with
  RX_VK_REFLEX_SUBMIT_IDS=on (tags on; the atoi reading fails it, check 21)
  and with =0 (untagged).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cashcon57 added a commit to cashcon57/Hookshot that referenced this pull request Nov 11, 2026
…rule (env_value_off / env_on, NotRexed#2042)

Review: stage_on() used atoi, so RX_VK_SHADOW_STAGE=on turned staging off.

Assisted-by: Claude Opus 5.5
blessedplummer added a commit to blessedplummer/Hookshot-optimi that referenced this pull request Nov 11, 2026
A dGPU has its own fast memory and a slower bus to system RAM; stock mirrors
every guest pool into VRAM and keeps both copies coherent with captures,
shadows and write-backs. At 4K that coherence is CPU-bound: the write-back
diff compares ~3.1 GB per 600 submits and mirror syncs move ~137k dirty
pages, for 35.6 fps where 1080p runs 60 (issue NotRexed#2099).

Under RX_VK_DGPU=1 a pool's first binding makes no mirror (MIRROR_REFUSED,
the state iGPU runs use today): reads take the lock-free TLS path and GPU
reads/writes land in the imported guest memory, coherent by construction -
no mirror sync, no write-back. Every 600-submit window the heaviest
guest-memory-read pool with >=1 MB of reads and not write-dominated runs a
measured trial (120 frames direct -> 12 settle, held until the mirror is
really on -> 120 mirrored, on the recordings' own timestamps); the mirror
stays only when the win is both >10% (RX_VK_DGPU_GAIN) and >0.25 ms
(RX_VK_DGPU_FLOOR) on present-to-present frame time, so the verdict sees
the mirror's CPU cost and its PCIe benefit alike. Trials whose windows did
not measure the same workload (zero binds, a >2x bind shift, a mirror that
never came) abort with no verdict and no skip line; a pool that aborts
three times sits out the session, and iGPUs run no trials. A lost verdict
persists to hookshot_mirror_skip.txt beside the game's own state files,
keyed to the device and driver (a foreign or header-less file is ignored
and re-trialled). RX_VK_DGPU_TRIALS=0 is pure direct. Trials need serial
replay (documented); under RX_VK_PARALLEL the policy runs trial-less.
Unset = stock exactly: the added counters and logging are gated on the
switch.

Refused pools read uniform buffers as submitted (NotRexed#1795's per-pool form:
the snapshot engages for mirror-less pools at the read, trial-won pools
keep in-place mirror binds), so the artifact class the issue documents on
mirror-less GPUs is covered under the policy too.

Measured on RX 9070 XT / RADV, 4K, Uncapped frame cap, warp-normalized
adjacent pairs (Hyrule Field): 70.2->78.0, 71.0->77.0, 59.6->76.6 fps
(+9.7% median), draw parity 705 vs 709, GPU 9.2 ms of a ~14 ms frame.
Mirror-sync dirty pages 136,872 -> 0; write-back diff 3.06-3.13 GB ->
330 MB or less per 600 submits.

Review round 2 (external): RX_VK_DGPU_TRIALS=0's predicate was inverted
and now matches the docs (=0 is pure direct); the window's read counter
moved off a shared map into the pool (dgpu_read, both direct paths);
Settle holds until the mirror is actually live; zero-bind baselines and
>2x bind drift between the direct and mirrored windows abort the trial
un-blacklisted; keep_line refuses signed and trailing-garbage lines.

Review round 3 (hckrman101, Joey-Magnus): refused pools read UBs as
submitted (NotRexed#1795 per-pool, live-smoked at 1.26M copies fault-free);
verdicts decide on present-to-present frame time instead of GPU-only
time (the mirror's CPU and upload-buffer cost is what the frame pays);
the policy runs trial-less under RX_VK_PARALLEL and its fast-path
counters are parallel-safe; no trials on iGPUs and a 3-abort cap; the
skip list carries a device+driver header and ignores foreign files; the
test driver uses {cxx}. Follow-up fix: the drift check counted served
volume, so a working mirror's read collapse voided 40+ trials a run and
trials never converged; binds now count once at the serving path and the
drift rule compares the scene's pool set, with a three-no-verdict cap. Rebased onto main's env_on switch rule (NotRexed#2042) and the split-submission
fence work (NotRexed#2419); the trials' functions sit outside the write-back
observation's compile-span check (NotRexed#2131) and the GCN mirror-less default
(NotRexed#2343) composes unchanged (on GCN the switch runs trial-less direct plus
the UB snapshot).

Co-authored-by: GLM-5.3-Flash <glm-5.3-flash@opencode.noreply.github.com>
blessedplummer pushed a commit to blessedplummer/Hookshot-optimi that referenced this pull request Nov 11, 2026
Upstream's NotRexed#2042 gave the switches one rule (env_on: unset, empty, 0, false,
off or no all mean off). RX_VK_DGPU joins it; the trials' switch keeps its
default-on ternary (env_on has no default-on form), and the two numeric knobs
refuse an empty value like env_int does.

Assisted-by: GLM-5.3-Flash (OpenCode)
Co-authored-by: GLM-5.3-Flash (OpenCode) <glm-5.3-flash@opencode.noreply.github.com>
blessedplummer added a commit to blessedplummer/Hookshot-optimi that referenced this pull request Nov 11, 2026
A dGPU has its own fast memory and a slower bus to system RAM; stock mirrors
every guest pool into VRAM and keeps both copies coherent with captures,
shadows and write-backs. At 4K that coherence is CPU-bound: the write-back
diff compares ~3.1 GB per 600 submits and mirror syncs move ~137k dirty
pages, for 35.6 fps where 1080p runs 60 (issue NotRexed#2099).

Under RX_VK_DGPU=1 a pool's first binding makes no mirror (MIRROR_REFUSED,
the state iGPU runs use today): reads take the lock-free TLS path and GPU
reads/writes land in the imported guest memory, coherent by construction -
no mirror sync, no write-back. Every 600-submit window the heaviest
guest-memory-read pool with >=1 MB of reads and not write-dominated runs a
measured trial (120 frames direct -> 12 settle, held until the mirror is
really on -> 120 mirrored, on the recordings' own timestamps); the mirror
stays only when the win is both >10% (RX_VK_DGPU_GAIN) and >0.25 ms
(RX_VK_DGPU_FLOOR) on present-to-present frame time, so the verdict sees
the mirror's CPU cost and its PCIe benefit alike. Trials whose windows did
not measure the same workload (zero binds, a >2x bind shift, a mirror that
never came) abort with no verdict and no skip line; a pool that aborts
three times sits out the session, and iGPUs run no trials. A lost verdict
persists to hookshot_mirror_skip.txt beside the game's own state files,
keyed to the device and driver (a foreign or header-less file is ignored
and re-trialled). RX_VK_DGPU_TRIALS=0 is pure direct. Trials need serial
replay (documented); under RX_VK_PARALLEL the policy runs trial-less.
Unset = stock exactly: the added counters and logging are gated on the
switch.

Refused pools read uniform buffers as submitted (NotRexed#1795's per-pool form:
the snapshot engages for mirror-less pools at the read, trial-won pools
keep in-place mirror binds), so the artifact class the issue documents on
mirror-less GPUs is covered under the policy too.

Measured on RX 9070 XT / RADV, 4K, Uncapped frame cap, warp-normalized
adjacent pairs (Hyrule Field): 70.2->78.0, 71.0->77.0, 59.6->76.6 fps
(+9.7% median), draw parity 705 vs 709, GPU 9.2 ms of a ~14 ms frame.
Mirror-sync dirty pages 136,872 -> 0; write-back diff 3.06-3.13 GB ->
330 MB or less per 600 submits.

Review round 2 (external): RX_VK_DGPU_TRIALS=0's predicate was inverted
and now matches the docs (=0 is pure direct); the window's read counter
moved off a shared map into the pool (dgpu_read, both direct paths);
Settle holds until the mirror is actually live; zero-bind baselines and
>2x bind drift between the direct and mirrored windows abort the trial
un-blacklisted; keep_line refuses signed and trailing-garbage lines.

Review round 3 (hckrman101, Joey-Magnus): refused pools read UBs as
submitted (NotRexed#1795 per-pool, live-smoked at 1.26M copies fault-free);
verdicts decide on present-to-present frame time instead of GPU-only
time (the mirror's CPU and upload-buffer cost is what the frame pays);
the policy runs trial-less under RX_VK_PARALLEL and its fast-path
counters are parallel-safe; no trials on iGPUs and a 3-abort cap; the
skip list carries a device+driver header and ignores foreign files; the
test driver uses {cxx}. Follow-up fix: the drift check counted served
volume, so a working mirror's read collapse voided 40+ trials a run and
trials never converged; binds now count once at the serving path and the
drift rule compares the scene's pool set, with a three-no-verdict cap. Rebased onto main's env_on switch rule (NotRexed#2042) and the split-submission
fence work (NotRexed#2419); the trials' functions sit outside the write-back
observation's compile-span check (NotRexed#2131) and the GCN mirror-less default
(NotRexed#2343) composes unchanged (on GCN the switch runs trial-less direct plus
the UB snapshot). After the presenter-thread refactor (NotRexed#2325) the
present-to-present tick lives in present_finish, which every made present
passes exactly once in either threading mode, with a headless twin so
offscreen runs stay measurable.

Co-authored-by: GLM-5.3-Flash <glm-5.3-flash@opencode.noreply.github.com>
mooki000 added a commit to mooki000/Hookshot that referenced this pull request Nov 11, 2026
…he loop

It was read with atoi, which takes any value that is not a number for 0: RX_VK_NATIVE_FADD=on turned the native float
add off. native_fadd_wanted reads it with env_value_off (gpu/vk/compat/hookshot_env.h, NotRexed#2042) now: 0, false, off, no
or empty keep the compare-exchange loop, any other value asks for the native add, unset as before.
tests/test_native_float_atomic_add.py compiles native_fadd_wanted alone against 13 values.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
blessedplummer added a commit to blessedplummer/Hookshot-optimi that referenced this pull request Nov 12, 2026
A dGPU has its own fast memory and a slower bus to system RAM; stock mirrors
every guest pool into VRAM and keeps both copies coherent with captures,
shadows and write-backs. At 4K that coherence is CPU-bound: the write-back
diff compares ~3.1 GB per 600 submits and mirror syncs move ~137k dirty
pages, for 35.6 fps where 1080p runs 60 (issue NotRexed#2099).

Under RX_VK_DGPU=1 a pool's first binding makes no mirror (MIRROR_REFUSED,
the state iGPU runs use today): reads take the lock-free TLS path and GPU
reads/writes land in the imported guest memory, coherent by construction -
no mirror sync, no write-back. Every 600-submit window the heaviest
guest-memory-read pool with >=1 MB of reads and not write-dominated runs a
measured trial (120 frames direct -> 12 settle, held until the mirror is
really on -> 120 mirrored, on the recordings' own timestamps); the mirror
stays only when the win is both >10% (RX_VK_DGPU_GAIN) and >0.25 ms
(RX_VK_DGPU_FLOOR) on present-to-present frame time, so the verdict sees
the mirror's CPU cost and its PCIe benefit alike. Trials whose windows did
not measure the same workload (zero binds, a >2x bind shift, a mirror that
never came) abort with no verdict and no skip line; a pool that aborts
three times sits out the session, and iGPUs run no trials. A lost verdict
persists to hookshot_mirror_skip.txt beside the game's own state files,
keyed to the device and driver (a foreign or header-less file is ignored
and re-trialled). RX_VK_DGPU_TRIALS=0 is pure direct. Trials need serial
replay (documented); under RX_VK_PARALLEL the policy runs trial-less.
Unset = stock exactly: the added counters and logging are gated on the
switch.

Refused pools read uniform buffers as submitted (NotRexed#1795's per-pool form:
the snapshot engages for mirror-less pools at the read, trial-won pools
keep in-place mirror binds), so the artifact class the issue documents on
mirror-less GPUs is covered under the policy too.

Measured on RX 9070 XT / RADV, 4K, Uncapped frame cap, warp-normalized
adjacent pairs (Hyrule Field): 70.2->78.0, 71.0->77.0, 59.6->76.6 fps
(+9.7% median), draw parity 705 vs 709, GPU 9.2 ms of a ~14 ms frame.
Mirror-sync dirty pages 136,872 -> 0; write-back diff 3.06-3.13 GB ->
330 MB or less per 600 submits.

Review round 2 (external): RX_VK_DGPU_TRIALS=0's predicate was inverted
and now matches the docs (=0 is pure direct); the window's read counter
moved off a shared map into the pool (dgpu_read, both direct paths);
Settle holds until the mirror is actually live; zero-bind baselines and
>2x bind drift between the direct and mirrored windows abort the trial
un-blacklisted; keep_line refuses signed and trailing-garbage lines.

Review round 3 (hckrman101, Joey-Magnus): refused pools read UBs as
submitted (NotRexed#1795 per-pool, live-smoked at 1.26M copies fault-free);
verdicts decide on present-to-present frame time instead of GPU-only
time (the mirror's CPU and upload-buffer cost is what the frame pays);
the policy runs trial-less under RX_VK_PARALLEL and its fast-path
counters are parallel-safe; no trials on iGPUs and a 3-abort cap; the
skip list carries a device+driver header and ignores foreign files; the
test driver uses {cxx}. Follow-up fix: the drift check counted served
volume, so a working mirror's read collapse voided 40+ trials a run and
trials never converged; binds now count once at the serving path and the
drift rule compares the scene's pool set, with a three-no-verdict cap.
The set rule still voided every trial in a streaming scene, so anchors
now match by guest range (a recycled pool is the same anchor), anchor at
4x the floor, void only when a substantial fraction of the scene moves,
and the candidate floor rose to 256 MB of reads a window (the verdict
floor is unreachable below it - a trial there is churn by construction).
Rebased onto main's env_on switch rule (NotRexed#2042) and the split-submission
fence work (NotRexed#2419); the trials' functions sit outside the write-back
observation's compile-span check (NotRexed#2131) and the GCN mirror-less default
(NotRexed#2343) composes unchanged (on GCN the switch runs trial-less direct plus
the UB snapshot). After the presenter-thread refactor (NotRexed#2325) the
present-to-present tick lives in present_finish, which every made present
passes exactly once in either threading mode, with a headless twin so
offscreen runs stay measurable.

Co-authored-by: GLM-5.3-Flash <glm-5.3-flash@opencode.noreply.github.com>
hckrman101 pushed a commit that referenced this pull request Nov 12, 2026
…ess (#2347)

**What this is for:** a player who meets a problem presses one row in
the Insert menu, **Gameplay → Bug report**, and gets a folder with
everything the game problem form asks for: the form's answers in
`report.txt` (version, the map he is in, graphics card and driver
version, CPU, RAM and system, the options changed and the switches set),
this run's log and the run before's (the crashed one after a restart)
with his home folder and user name taken out, and a picture of the game.
Maintainers get reports with the log and the hardware from the start
instead of asking for them. **What is measured:** on Linux (Omarchy, an
Arch Linux; RTX 4090, NVIDIA 615.78.08), on `main` `ba6eef70`: the new
test passes with g++ 16 and clang 23 and fails on `main`; its cases for
the review's points (NUL bytes in a log, a picture that comes late,
which switch values are written, the reason a folder cannot be made);
`tests/run_all.py`; headless runs of this head that pressed the row (one
pressed it in Kokiri Forest, one after a `kill -9` and a restart: both
reports written, read back by a script against the logs they came from
(no file holds the home folder or the user name; each log copy is the
original line for line apart from those two), the after-crash report's
`hookshot_log.1.txt` is the killed start's whole log, and its picture
came through #926's `shot_ask`); the length GitHub takes for the link,
signed out. **Not measured:** Windows at run time (compiled and linked
only), a windowed run, the link opened in a browser (signed out or in),
AMD and Intel drivers' strings, a Steam Deck.

Refs #2346 (closed by the maintainer: tracked in this pull request).

## Problem

- The game problem form (`.github/ISSUE_TEMPLATE/1-game-problem.yml`)
requires the version, where in the game, the graphics card **and driver
version**, CPU, RAM and operating system, and `hookshot_log.txt` (and
`hookshot_log.1.txt` "if you started the game again after the problem").
The player has to find each one by hand.
- `hookshot_log.txt` answers only part of it: its first line has the
version, but the driver is only Vulkan's packed number (`vk: device 0:
NVIDIA GeForce RTX 4090, Vulkan 1.4, driver 0x99d38200, type 2`), there
is no line for the system, the CPU or the options changed, and nothing
logged where the player was (the host reads `Sequence_CurrentBanc` only
for the debug tools).
- After a crash a player starts the game again, and the crashed run's
log becomes `hookshot_log.1.txt` (`host/hos_main.c`, the rotation added
because players sent the new run's log and the crash was gone). It still
has to be found and attached.
- The logs carry the home folder and the user name in their paths (`fs:
mounted content: -> <home>/...`, `vk: pipeline cache <home>/...`), which
a player may not want to post.
- So reports arrive without the log or the hardware, and
`docs/workflow.md` scores such a report at confidence 0.4 and asks for
the log first. This gathers what CONTRIBUTING.md already asks ("Include
the version, your hardware, where you were and `hookshot_log.txt`";
"Report the version you actually ran: ... the first line of
`hookshot_log.txt`"). It changes nothing about where reports go.

## What changed

- `gpu/vk/vk_bug_report.h`, `vk_bug_report.cpp` (new, 59 + 602 lines):
the packer, without Vulkan and without the menu, so the test compiles it
alone. It writes `bug-reports/<YYYY-MM-DD_HH-MM-SS>/` in the game folder
on a thread of its own:
- `report.txt`: the form's questions under the form's own `### `
headings, so `scripts/hardware_report.py`'s `section()` reads it as an
issue: **Hookshot version** (`HOOKSHOT_VERSION`, as the log's first
line); **Where in the game** (the last `map:` line of this run's log,
and the run before's, and a request to add what you were doing);
**Graphics card and driver version** (the device and
`VkPhysicalDeviceDriverProperties`' `driverName` + `driverInfo`, e.g.
`NVIDIA GeForce RTX 4090, NVIDIA 615.78.08`); **CPU, RAM and operating
system** (the CPU's brand string and threads, RAM, `os-release`, desktop
and session type on Linux; `RtlGetVersion` and Wine on Windows);
**Changes from the defaults** (each Insert-menu row not at its default
as the menu shows it; a microphone chosen by its name is not named; then
the switches set, below); **hookshot_log.txt** (what is attached, the
run before's version and date, each log's first `FATAL`, `CRASH` or
`device lost` line); **Screenshots or video**; then a link that opens
the form with version, where, GPU, system and settings filled in (GitHub
fills issue-form fields from query parameters named by the fields' ids).
- **The switches.** CONTRIBUTING.md lists `hookshot_env.txt` among the
files never to share, so the report never copies it, and of the switches
set (the names `hookshot_env.txt` takes: `RX_`, `HOS_`, `HOOKSHOT_`,
`EDEN_`, `NVN_`, `SDL_`; and what hooks other code in:
`VK_INSTANCE_LAYERS`, `MANGOHUD`, `ENABLE_VKBASALT`, `LD_PRELOAD` ...)
it writes each name, and the value only when it cannot hold anything
private: a number of at most 12 characters, a size such as `1920x1080`,
or on/off, true/false, yes/no, and never for a name with `TOKEN`, `KEY`,
`ROOM`, `SECRET`, `PASS`, `SERVER` or `USER` in it. Every other value
reads `(value left out)`: paths, addresses, scripts. Other variables are
not listed at all.
- **The link** stays within 2000 characters and carries no switch value:
the options changed and the switches' names, or, when the names do not
fit, how many switches are set. A player who is not signed in is sent to
GitHub's sign-in page with the whole link inside it, and that page
failed for links from about 3500 characters of escaped text (table
below).
- `hookshot_log.txt` (this run's log up to the press) and
`hookshot_log.1.txt` (the run before's; when this run logs elsewhere,
e.g. from a terminal, the game folder's `hookshot_log.txt`). Read by
length (`fread`, split on newlines), so NUL bytes, which a run ended by
a power cut or a hard kill can leave, are copied as they are. Each line:
the home folder (a whole folder; on Windows either slash and any case)
becomes `~` or `%USERPROFILE%`, the user's name as a folder of any path
`<user>`. A log over 10 MB keeps its first MB and its last 9 MB (GitHub
takes files up to 25 MB).
- `picture.png`: 1280x720, the game's next frame, without the menu
(below). If none comes within 3 s, the report goes without it and says
so; a picture that comes later is deleted again.
- A folder that cannot be made is reported with the system's reason
(`GetLastError()` on Windows, `errno` elsewhere).
- `gpu/vk/vk_menu.cpp` (+89): the row before Restart and Quit
(`kBugReportRow = kRestartRow - 1`), its help (what goes in, that no
saves or game files go in and nothing is sent), its value (Press to save
/ Saving... / Saved / Not saved), Save in both prompt lines (the full
menu's and the compact bar's), the press, `changed_rows_text()` (rows
against a default-constructed `Options`), `bug_report_save()`, and the
picture's ask, `bug_report_picture()`. It follows Save my pipeline
list's pattern (#2447): its value in `row_value_cell`, its line under
the help in `draw_options` once pressed (where the folder is and how to
send it: File Explorer on Windows, a file manager on Linux; on a Steam
Deck, Desktop Mode), and room for that line's longest form in
`measure_help`. The game opens no browser or file manager: Game Mode on
a Deck has neither.
- `gpu/vk/vk_replay.cpp` (+2): `shot_ask_next()`, which calls #926's
`shot_ask(path, -1, done, ctx)` from outside its anonymous namespace
(declared in `vk_internal.h`). The report's picture is the control
bridge's kind of shot: an image and a buffer of its own (1280x720, made
at the first ask), copied from `src`, the game's frame, which the menu
is never drawn into (`menu_record_image` draws only into the
screenshots' image), read back once the GPU is past it and written on a
thread. No other line of `do_present` changes.
- `gpu/vk/vk_main.cpp` (+11), `vk_internal.h` (+6):
`device_report_text()`, and the declaration above.
- `host/hos_main.c` (+12): `map: <Sequence_CurrentBanc> (child|adult)`
when the map or the age changes, in `oot_inventory_refresh` (on Link's
update, at most every 500 ms, where the age is already read for the
Debug tab). It is where the report's place comes from, and it puts the
place into every log. The names are the game's own map names, which the
debug tools' teleport lines already print.
- `readme.md`: the row in the menu table, a paragraph on what it saves
and which values it leaves out, a pointer from "If the problem
continues".
- `tests/test_bug_report.py` (new), and `tests/test_menu_tune.py`: its
check of the compact bar's prompt line now reads the line with the Bug
report in it (the check is otherwise unchanged).
- Unchanged: nothing is sent anywhere; no saves, no game files, not
`hookshot_options.ini` or `hookshot_env.txt` themselves; no new option
or switch; the logs' own rotation; the screenshot path.

## How to try it

Insert menu (Insert, F1 or Esc, or the menu button) → **Gameplay** →
**Bug report** → Enter or A.
The row reads Saved and its help names the folder:
`bug-reports/<date>_<time>` in the game folder, with `report.txt`,
`hookshot_log.txt`, `hookshot_log.1.txt` and `picture.png`.

## How it was tested

**2026-11-11, after the two reviews, head `a75f3ca5` on `main`
`ba6eef70`.** Rebased twice today: onto #926, then onto #2042, #2378,
#2447 and the rest. The conflicts, each kept on both sides: #926's
`shot_ask_record` beside the old readback (which is now gone); #2042's
Debug rows where the Bug report row goes before Restart; #2378's
`row_value_cell()` and #2447's share row, where the row's value cell and
prompt went (the row now follows the share row's pattern, above); a
forward declaration in `host/hos_main.c`. #2042's rules hold for the
row: it has no value, so `preset_exempt` does not apply
(`tests/test_preset_exempt.py` reads only rows with one), and the branch
reads no on/off switch. #2042 also took `TOTK_` out of the names
`hookshot_env.txt` takes; the report follows (the test compares the two
lists).

Environment: Omarchy (an Arch Linux), kernel 7.2.15-1-omarchy, RTX 4090
with NVIDIA 615.78.08, i9-13900K, 94 GB, g++ 16.2.1 and clang 23.1.3.

- The new test on the branch (`python -I -B tests/test_bug_report.py`,
with `--cxx g++` and with `--cxx clang++`; the same line from both):
  ```
bug report: ok (report.txt as the issue form, both logs redacted, the
picture, the long log, the terminal start, switch values, NUL bytes, the
folder's reason)
  ```
Against `main`'s source (the test copied into an export of `ba6eef70`;
with both compilers): it fails at its first step, the menu row:
  ```
  at = menu.index('{TAB_GAMEPLAY, "Bug report", nullptr')
  ValueError: substring not found
  ```
And the review's NUL case against the old log copy (`fgets` and
`line.back()`, put back on its own):
  ```
bug report: the copy of a log with NUL bytes differs: b'[hos host]
hookshot v2.1.0-rc.3-39-gbdfe09c (source 763271ad)\n[hos Main] fs:
mounted content: -> ~/Games\n[hos Main] map: KokiriForest_Child
(child)\n[hos Main] half a line'
  ```
It compiles `vk_bug_report.cpp` alone with a driver and writes reports
from a made-up game folder inside a made-up home folder
(`.../home/alice/Games/Hookshot/`), with paths of the home and of
`/run/media/alice/...` in the logs, map lines and a `CRASH:` line in the
run before's. It checks: the folder holds exactly the report, both logs
and the picture; no file names the home folder, the user as a folder or
the game folder's path (`alice123` and `Malice` stay);
`hardware_report.py`'s `section`, `classify` and `os_of` read the
version, `NVIDIA` / `RTX 40` and Linux from `report.txt`; the place is
the last map, and the run before's; the switches: values kept
(`HOS_HEADLESS=1`, `60`, `off`, `3840x2160`, `-1`, `1.5`) and left out
(a path, a drive path, a menu script, an IP address, a `host:port`, a
13-digit number, and numbers under ROOM, KEY, SERVER, USER and PASSWORD
names), none of those values anywhere in `report.txt`, `PATH` and a
token not listed, no `%3D` in the link; the run before's crash line; the
link's fields and its length (with 98 switches: within 2000, the options
and the count in it, every switch in `report.txt`); the menu's way (a
thread, one report at a time); no frame means no picture, said so; a
picture that comes after the report went without it is deleted; a start
from a terminal; two reports in one second; a 22 MB log cut to its first
and last part with its middle's map still found; a log with a NUL in a
line and 4 KB of NULs at its end copied byte for byte, the map after the
NUL found; a folder that cannot be made, with the system's reason; the
redaction's Windows cases (either slash, any case); this PC's system
line. And the wiring by text: the row and what its help promises, Save
in both prompt lines, the press, the picture asked of `shot_ask_next`
and copied from the game's frame without the menu, the host's `map:`
line, that the switches it lists are the names `hookshot_env.txt` takes
(`env_line_allowed`'s), the form's labels and ids. Eight runs at once at
load 280 all pass (the first version of the menu's case read the
report's state too late under load in the full suite; it now holds the
report until it has looked).
- The checks the review named and those that read the menu's rows, on
this head (with g++ and clang++ where they compile C++):
`test_capture_dimensions.py`, `test_capture_slot_ownership.py`,
`test_menu_env_overrides.py`, `test_menu_keys.py`,
`test_pipeline_share.py`, `test_menu_tune.py`,
`test_stick_dead_zone.py`, `test_preset_exempt.py` (62 rows),
`test_menu_env_on.py`, `test_env_on.py`, `test_fps_stats.py`,
`test_gpu_choice.py`, `test_menu_restart_slots.py`,
`test_mouse_settings.py`: all pass. `vk_bug_report.cpp` alone with
`-Wall -Wextra -Wpedantic -Werror`: no warning from either compiler.
- `python -I -B tests/run_all.py` on Linux, on this head:
  ```
  440 passed, 1 failed, 20 skipped in 600 s (linux, 32 at once)
  ```
The one failure is `test_recorder_sync.py`, which ran out of its 600 s
at load 250-320 (other work ran beside it) and touches nothing of this
branch. An earlier run of the whole suite on this branch failed
`test_bug_report.py` once under that load: its menu case read the
report's state too late (fixed in the test, above).
- Merged into the newest `main` (`fce53453`, nine commits later; it
merges cleanly): `test_bug_report.py` (both compilers),
`test_menu_tune.py`, `test_pipeline_share.py`, `test_menu_keys.py`,
`test_menu_env_overrides.py`, `test_preset_exempt.py` (63 rows),
`test_menu_restart_slots.py`, both capture tests: pass. `test_env_on.py`
fails there and on `fce53453` alone (`HOS_FPS_LOG
(host/oot_high_fps.c:15)`, from #2470), not from this branch.
- Windows: `vk_bug_report.cpp` with a small driver compiles and links
for x86_64-w64-mingw32 (clang 23 with llvm-mingw 20261104's headers and
libraries, `-Wall -Wextra -Werror`: a PE32+ console executable). Not
run.
- In the game, on a build of this head's code
(`v2.1.0-rc.3-177-g9c538c91 (source c4f2afbc)`; the head differs only in
two test files), headless, RTX 4090, `RX_VK_PRECOMPILE=0` (shaders
compiled when first used), load 200 to 320 (other work ran beside it),
the save loaded into Kokiri Forest and the menu script pressing
**Gameplay → Bug report**:

  | Run | What | Harness result | The report |
  |---|---|---|---|
| br-menu2 | the row pressed 55-63 s after the first frame | exit 124
(its 120 s), fatal 0, crash 0, device lost 0, kernel driver lines 0 |
`bug-reports/2026-11-11_16-58-06`: report.txt and both logs, **no
picture**, and it says so. Rightly: the game showed no frame from 39 s
to 107 s (the save loading, shaders compiled as they came, at load 300),
took the scripted keys when frames came again, and stopped once more at
109 s |
| br-crash3 | a first start killed with `kill -9` after 110 s, then a
second start; the row pressed 63 s after its first frame | exit 124 (its
260 s), fatal 0, crash 0, device lost 0, kernel driver lines 0 |
`bug-reports/2026-11-11_17-13-48`: report.txt, both logs
(`hookshot_log.1.txt` is the killed start's whole log), picture.png
(present 1772) |
| br-menu3 | the row pressed 130-138 s after the first frame, in play |
exit 124 (its 200 s), fatal 0, crash 0, device lost 0, kernel driver
lines 0 | `bug-reports/2026-11-11_17-25-32`: report.txt, both logs,
picture.png (present 3063, 138 s) |
  The after-crash run's log lines (the picture's path shortened):
  ```
  [hos BaseProcExecutor5] map: KokiriForest_Child (child)
  [hos Presentation Thread] menu: bug report asked for
[hos host] vk: present 1772 written to
~/Games/.../bug-reports/2026-11-11_17-13-48/picture.png (the control
bridge)
[hos host] menu: bug report written to bug-reports/2026-11-11_17-13-48:
report.txt, hookshot_log.txt (245 KB), hookshot_log.1.txt (117 KB),
picture.png
  ```
Its folder read back against the game folder's logs (`check_report.py`:
each copy compared line by line with its original, with the home folder
and the user name replaced as the game does):
  ```
files: hookshot_log.1.txt (119514 bytes), hookshot_log.txt (250199
bytes), picture.png (2765798 bytes), report.txt (3212 bytes)
no file holds the home folder or the user name (any case, anywhere) [the
checker's line, with the two values left out here]
hookshot_log.txt: 1827 of the original's 2991 lines, 12 of them with the
home or the user replaced, nothing else changed
hookshot_log.1.txt: 991 of the original's 991 lines, 11 of them with the
home or the user replaced, nothing else changed
hardware_report.py: vendor and class ('NVIDIA', 'RTX 40'), OS Linux /
SteamOS
  ok
  ```
br-menu2's and br-menu3's folders pass the same check (1387 of 1571 and
3230 of 3230 lines; 2861 of 3679 and 3230 of 3230 lines; only the home
folder and the user name replaced). A picture's pixel data is not read
as text: br-menu3's picture.png has the bytes `4d 4f 4f 4b 49` among its
pixels (row 176), which an any-case byte search reads as a name; the PNG
holds only IHDR, IDAT and IEND chunks. The host name is in no text file
of either.
- Pictures (headless, 1280x720; the first two are the harness's shots
with the menu drawn in, the third the report's own file):

![1: the Bug report row selected, Press to save. 2: after Enter, Saved,
with the folder under the help. 3: that report's picture.png: the game's
frame without the
menu](https://gist.githubusercontent.com/mooki000/b86bd3f35969803fb032f71f035844f8/raw/a943230353c26f026eef40ea371a06b754cc084f/hookshot-bugreport-shotask.jpg)

1. br-menu3, the row before the press: **Bug report** selected, **Press
to save**, its help (what goes in; no saves or game files; nothing is
sent), the prompt bar **Save Enter**.
2. After Enter: **Saved**, and the paragraph under the help names the
folder (`bug-reports/2026-11-11_17-25-32`) and how to send it.
3. That report's `picture.png`, taken through `shot_ask` at present
3063: child Link and Navi on the path in Kokiri Forest, hearts and
minimap; no menu, not dimmed.

![4: after a kill -9 and a restart, Saved, with its folder under the
help. 5: that report's picture.png: the game's frame without the
menu](https://gist.githubusercontent.com/mooki000/b86bd3f35969803fb032f71f035844f8/raw/a943230353c26f026eef40ea371a06b754cc084f/hookshot-bugreport-aftercrash.jpg)

4. br-crash3, after the `kill -9` and the restart: **Saved**, the folder
`bug-reports/2026-11-11_17-13-48` under the help.
5. That report's `picture.png` (present 1772): Kokiri Forest from low
down, facing the bright sky, hearts and minimap; no menu.

- The `report.txt` the after-crash run wrote, as a player would attach
it (the switches are the test harness's: see which values are written
and which are left out):

<details><summary>The report.txt that run wrote</summary>

```text
Hookshot bug report, made by the Insert menu's Bug report on 2026-11-11 at 17:13.

To send it: open https://github.com/NotRexed/Hookshot/issues/new/choose and choose Game problem (or the link at the end of this file, which fills in the answers below), copy the answers below into the fields of the same names, say what happened, and drag the files of this folder, this one too, into the issue.

### Hookshot version

v2.1.0-rc.3-177-g9c538c91 (source c4f2afbc)

### Where in the game

KokiriForest_Child (child)
(The map this run's log names last: add what you were doing there.)

### Graphics card and driver version

NVIDIA GeForce RTX 4090, NVIDIA 615.78.08

### CPU, RAM and operating system

13th Gen Intel(R) Core(TM) i9-13900K (32 threads), 94 GB, Omarchy (Linux 7.2.15-1-omarchy), Hyprland (wayland)

### Changes from the defaults

Every Insert menu option is at its default.
Switches set (the environment, hookshot_env.txt; a value only when it is a number or on/off, and never for a name with TOKEN, KEY, ROOM, SECRET, PASS, SERVER or USER in it):
HOOKSHOT_NO_UPDATE_CHECK=1
HOS_AUDIO=0
HOS_DISCORD=0
HOS_ENV_FILE=(value left out)
HOS_HEADLESS=1
HOS_INPUT_BACKGROUND=0
HOS_INPUT_EXCLUSIVE=1
HOS_INPUT_FRAME_CLOCK=1
HOS_INPUT_SCRIPT=(value left out)
HOS_PRESENT_TRACE=(value left out)
RX_CPM=(value left out)
RX_VK_DEVICE=0
RX_VK_FRAME_LOG=(value left out)
RX_VK_MENU_SCRIPT=(value left out)
RX_VK_PRECOMPILE=0
RX_VK_SHOTS=(value left out)
RX_VK_SHOT_EVERY=300
RX_VK_SHOT_MENU=1
SDL_AUDIODRIVER=(value left out)
SDL_AUDIO_DRIVER=(value left out)
VK_DRIVER_FILES=(value left out)

### hookshot_log.txt

hookshot_log.txt: this run's log as it was when the report was made, 245 KB.
hookshot_log.1.txt: the run before's log (hookshot v2.1.0-rc.3-177-g9c538c91 (source c4f2afbc), last written 2026-11-11 17:12), 117 KB.
In both, the home folder is written ~ and the user's name <user>.

### Screenshots or video

picture.png: the game's picture when the report was made, without the menu.

### What this folder holds

report.txt (this file), hookshot_log.txt, hookshot_log.1.txt, picture.png. Nothing else from this PC: no saves, no game files, not the options file itself.

The link with the answers filled in:
https://github.com/NotRexed/Hookshot/issues/new?template=1-game-problem.yml&version=v2.1.0-rc.3-177-g9c538c91%20%28source%20c4f2afbc%29&where=KokiriForest_Child%20%28child%29&gpu=NVIDIA%20GeForce%20RTX%204090%2C%20NVIDIA%20615.78.08&system=13th%20Gen%20Intel%28R%29%20Core%28TM%29%20i9-13900K%20%2832%20threads%29%2C%2094%20GB%2C%20Omarchy%20%28Linux%207.2.15-1-omarchy%29%2C%20Hyprland%20%28wayland%29&settings=Every%20Insert%20menu%20option%20is%20at%20its%20default.%0ASwitches%20set%20%28their%20values%20are%20in%20the%20attached%20report.txt%29%3A%20HOOKSHOT_NO_UPDATE_CHECK%2C%20HOS_AUDIO%2C%20HOS_DISCORD%2C%20HOS_ENV_FILE%2C%20HOS_HEADLESS%2C%20HOS_INPUT_BACKGROUND%2C%20HOS_INPUT_EXCLUSIVE%2C%20HOS_INPUT_FRAME_CLOCK%2C%20HOS_INPUT_SCRIPT%2C%20HOS_PRESENT_TRACE%2C%20RX_CPM%2C%20RX_VK_DEVICE%2C%20RX_VK_FRAME_LOG%2C%20RX_VK_MENU_SCRIPT%2C%20RX_VK_PRECOMPILE%2C%20RX_VK_SHOTS%2C%20RX_VK_SHOT_EVERY%2C%20RX_VK_SHOT_MENU%2C%20SDL_AUDIODRIVER%2C%20SDL_AUDIO_DRIVER%2C%20VK_DRIVER_FILES.%0A
```

</details>
- The link's length, signed out (`curl`, dummy links to the form,
2026-11-10; nothing of this PC in them):

  | Link length | Padding | GitHub's answer |
  |---|---|---|
| 1238 | the report's own link (then) | sign-in page, the link kept in
`return_to` |
| 1999, 2500, 2998, 3199 | `%20` | sign-in page, the link kept in
`return_to` |
  | 3500 | `%20` | 400 |
  | 4000, 4500 | `%20` | 500 after the redirect |
| 6000 | `%20` | sign-in page, but `return_to` dropped (the answers
lost) |
  | 6000, 6400 | `x` | sign-in page, the link kept |
  | 6700, 7000, 7500, 7900 | `x` | 500 |
  | 8192 and more | `x` | 414 |

## Not tested

- **Windows, at run time.** The report's GPU line there
(`VkPhysicalDeviceDriverProperties` on a Windows driver), its CPU, RAM
and system line (`__cpuid`, `GlobalMemoryStatusEx`, `RtlGetVersion`,
Wine), the path of the log stderr writes to
(`GetFinalPathNameByHandleA`), `GetLastError`'s reason, the redaction of
real `C:\Users\...` logs, File Explorer: compiled and linked only.
- **A windowed run.** Only headless runs: the picture taken with a
window, HDR (a native HDR frame is copied to 8-bit as the control
bridge's shots are) and frame generation were not seen.
- **The link in a browser.** Not opened in a browser, signed out or
signed in, and no issue was opened from it; only `curl` signed out
(table above). That GitHub fills the form's fields from it is its
documented behaviour, not seen here.
- **A real crash's last lines in the game.** The after-crash run ended
its first start with `kill -9`, which leaves no `CRASH:` line, and under
that load the first start had not reached a map yet; the test covers the
`CRASH:` line and the run before's map with made-up logs.
- AMD and Intel drivers' `driverName`/`driverInfo` text (only NVIDIA's
was read; `hardware_report.py` classifies typical RADV, AMD and Intel
strings correctly when written by hand), a Steam Deck, the AppImage, and
the Linux Builder's Play button (since #2279 it gives the game its
`hookshot_log.txt` again, so the report finds this run's log there; not
run here).

## Risks

- **Something private in a report.** The home folder (as a whole folder;
on Windows either slash and any case) and the user's name as a folder of
any path are replaced in both logs; a switch's value is written only
when it is a number, a size or on/off and its name holds none of the
words above; other environment variables are not listed; a microphone
chosen by name is not named; no saves, no options or env file, no game
files go in. Guarded by the test (made-up home and user, Windows cases,
`alice123` kept, values kept and left out) and, on this machine's real
logs, by the read-back above. Not guarded: something private a log line
carries in another form (a game folder outside the home named after a
person, a machine name in a network path), or a future switch whose
secret is a short number under a name without those words.
- **A report written while the game is busy.** The press does only cheap
work on the menu's thread (flushing the log, reading the environment,
`/etc/os-release`, the device's properties, the menu's rows); copying
the logs and writing the folder run on a thread of their own, one report
at a time (a second press while it saves does nothing). This run's log
is copied as far as it was when the copy began. The picture is waited
for at most 3 s.
- **The picture.** It shares #926's queue (16 asks at most) and its one
readback: a control-bridge shot being read back delays the report's by a
present or two. Without a report nothing changes on the present path.
The control bridge's log line for a written shot also appears for the
report's picture (`vk: present N written to ... (the control bridge)`).
- **The `map:` line in every log.** It is written only when the map or
Link's age changes, from `oot_inventory_refresh` (at most every 500 ms,
where the age is already read). A log that a player posts now says where
he was; the names are the game's own map names, which the debug tools'
teleport lines already print.
- A crash that leaves no line (a kill, a freeze, a power cut): the run
before's log is attached, but nothing in it says how it ended. A game
that stops before its menu can be used cannot make a report; the readme
says to attach `hookshot_log.1.txt` by hand then.

## Overlaps

`pr_overlap.py --branch bug-report` (run on this head, 2026-11-11): 79
open pull requests touch one of these files; 73 merge cleanly with it.
The row stores nothing (no `Options` field, no ini key, no option id),
so no option id can clash. Six conflict in this branch's files, each
hunk read:

- #2417 (the menu in 16 languages): it wraps the menu's texts in
`i18n::tr(...)`, among them the lines of `measure_help`,
`row_value_cell`'s branch for rows without a value, the help line in
`draw_options` and the compact bar's prompt (and the same line in
`tests/test_menu_tune.py`), where this branch adds the Bug report beside
the share row. Whichever lands second wraps the Bug report's texts the
same way; both stay.
- #1521, #1864, #2105, #2459, #2479 (the readme): a row each after
Microphone in the menu table (#2459 moves the Microphone row), where
this branch adds the Bug report's row; both stay.
- #2317 (saves and settings in a per-user data folder, logs in `logs/`)
now merges cleanly, but overlaps in meaning: this branch finds the game
folder through `ini_path()` and the logs in the game folder; after #2317
the settings are in the data folder and the logs in `logs/`. Whichever
lands second moves those two reads to `game_folder()` and `log_path()`.

## Questions for you

1. Is the Insert menu the place you want this, and the game folder the
place for the reports (with #2317, the data folder or `logs/` could be)?
2. The report takes the strict reading of CONTRIBUTING.md on
`hookshot_env.txt` (names, and only harmless values). Should
CONTRIBUTING.md say so, next to the line that lists the file?
3. The link carries the form's one-line answers and, when short enough,
the options and the switches' names. Do you want it at all, or only
`report.txt` to copy from?

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
DevL0rd pushed a commit to DevL0rd/Hookshot that referenced this pull request Nov 12, 2026
…es on main again

host/oot_high_fps.c read its log switch as getenv("HOS_FPS_LOG") != NULL
(NotRexed#2470, kept by NotRexed#2552), the set-means-on form NotRexed#2042 retired:
HOS_FPS_LOG=0 turned the log on. tests/test_env_on.py finds such reads, so
it fails on main since NotRexed#2470: in run 43120809833 (eed4d91) in the Python
source checks' ubuntu-24.04 part 2/2 and windows-2022 part 1/2.

The switch is read with env_on now, as host/nn_vi.c reads the same name:
unset, empty, 0, false, off and no are off.

Assisted-by: Claude Fable 5.2
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
NotRexed pushed a commit that referenced this pull request Nov 12, 2026
…es on main again (#2575)

## What

`host/oot_high_fps.c` reads its log switch with `env_on("HOS_FPS_LOG")`
instead of `getenv("HOS_FPS_LOG") != NULL`. One line, one include, and
the comment that describes the switch.

## Why

- The old form is the set-means-on read that #2042 retired: with it
`HOS_FPS_LOG=0` turned this log on, while `host/nn_vi.c` reads the same
name with `env_on`.
- `tests/test_env_on.py` looks for such reads, so it fails on `main`
since #2470 brought the line (#2552 kept it). In run 43120809833 on
`main` (`eed4d912`) that is the failure of `Python source checks
(ubuntu-24.04, part 2/2)` and one of the two in `Python source checks
(windows-2022, part 1/2)`:

  ```
FAIL: on/off switches still read as set-means-on (set to 0 they are on):
use env_on:
    HOS_FPS_LOG (host/oot_high_fps.c:15)
  ```

## How it was tested

- `tests/test_env_on.py` on `main` `9df055f2`: fails, naming
`host/oot_high_fps.c:16`. With this change: passes.
- `tests/run_all.py --strict` with `test_env_on`, `test_menu_env_on` and
`test_high_fps_fixes`: 3 passed (Arch Linux).
- `host/oot_high_fps.c` compiles with `gcc` and `clang` (`-fsyntax-only
-Wall -Wextra`, with the generated headers of a local build).
- Not run: the game with the switch set. The change is the read of one
environment variable through the function the other host files use.

## Not in this change

The same run has two more failures on Windows that this does not touch:
`tests/test_psychov35.py` (part 1/2) and
`tests/test_pcache_header_read.py` (part 2/2).

No issue was opened for this: it is one line and the failing test is the
report.

Assisted-by: Claude Fable 5.2

Co-authored-by: Claude Fable 5.2 <noreply@anthropic.com>
hckrman101 added a commit to FIocker/Hookshot that referenced this pull request Nov 12, 2026
vk_shader.cpp: main (NotRexed#2042) removed the dead oot_visual workaround; the
fragment translation this PR moves ahead of the vertex stage drops its
call too.
hckrman101 pushed a commit that referenced this pull request Nov 12, 2026
**What this is for:** restore ten game-free checks whose extracted
fixtures or expectations no longer match intentional changes on main;
[main's CI
log](https://github.com/NotRexed/Hookshot/actions/runs/43151254256)
shows these failures. **What is measured:** those ten checks on Linux
with GCC 16.2.1 and Clang 23.1.3, plus the strict full suite and
deliberate regression mutations, as detailed below. **Not measured:** a
game run, Windows execution, audio output or GPU behavior; no production
file changes in this diff.

## Cause and changes

Each row is a separate commit, naming the change that made the fixture
stale; the quoted diagnostics come from [main's failed
jobs](https://github.com/NotRexed/Hookshot/actions/runs/43149508038).

| Introduced by | Checks / CI diagnostic | Repair |
|---|---|---|
| #1544, after #2042's switch helper | `audio_aux`, `audio_biquad`:
`implicit declaration of function ‘env_on’` | Include the real helper
before the extracted DSP. |
| #2116 | `display_startup_failure`, `wayland_libdecor_hint`: `use of
undeclared identifier 'window_title'` | Extract the real title helper
along with its callers. |
| #1906 | `texel_buffer_ownership`: `use of undeclared identifier
'dead_import_bytes'` | Model the counter and check that it stays charged
until completion. |
| #2458 | `readback_part`: `extraneous closing brace ('}')` | Select the
complete `wait_recorded` definition, not its new forward declaration. |
| #2574 | `dlss_checks`: `FAIL under a third wide at line 40`;
`framegen_preset` / `rumble_menu`: undeclared `temporal_reset_failure` |
Check both sides of the intended two-pixel tolerance; supply a counted
reset hook and exercise the real scaling rows. |
| #2435 | `mods_size_table`: `undefined reference to hos_xinput_pads` |
Supply the fixture's no-controller answer; retain real merge and routing
checks. |

The diff retains the existing assertions: no check is deleted or
skipped, and it does not revert the production behavior named above.

## How it was tested

Recorded on 2026-11-11, Linux kernel `7.2.15-1-omarchy`; base
`de866017`, head `eeb09c09`, GCC 16.2.1 and Clang 23.1.3 (compiler
version output and test records).

- Untouched main `a016b7a6`: `455 passed, 15 failed, 20 skipped`; each
of these ten failures also reproduced alone with both compilers,
matching the linked CI diagnostics.
- Branch: all ten repaired checks pass independently with each compiler
(`python -I -B tests/run_all.py --strict --jobs 1 <test>` records).
- Negative controls: 14 deliberate source regressions were each rejected
with both compilers, without a compile failure—28 behavioral rejections
(mutation records). These remove Aux sizing, biquad filtering, startup
failure handling, the libdecor hint, view ownership release,
part-specific waits, preset preservation, temporal reset or mod-table
routing; the DLSS controls independently make each axis's tolerance too
narrow and too broad.
- One full `python -I -B tests/run_all.py --strict` run: **`464 passed,
6 failed, 20 skipped in 141 s`**, 32 workers, load 7.24 → 28.38
(complete suite output).

### Remaining failures and the path to green

The complete branch run names these six failures; the first five are
outside this fixture-only diff:

| Remaining check | Separate fix |
|---|---|
| `test_env_on.py` | `fix/capture-switch-values`: the three
presence-only capture switch reads from #2437. |
| `test_linux_docker_packages.py` | `test/docker-ppa-options`:
distinguish `--no-update` from its PPA operand. |
| `test_mouse_native.py` | `fix/mouse-camera-handover`: discard retained
angles while another camera owns the view. |
| `test_menu_i18n.py` | `fix/menu-hint-translations`: update three
complete hint keys and their translations. |
| `test_hdr_encode.py` | Already repaired in #2614; deliberately not
duplicated here. |
| `test_release_scripts.py` | An intermittent, unchanged release-script
concurrency failure: `SHA256SUMS still differs from the files after 5
uploads`. It passed untouched main and current main CI; isolated reruns
passed once and failed once. Neither run invokes a C/C++ compiler. |

## Not tested / risks

No Windows, game, display, controller, listening or GPU run was
performed; the evidence above is game-free only. The fixture extraction
still follows source structure, so future helper/signature changes must
update its inputs rather than remove its behavioral guards.

## Overlaps

The inspected merge hunks overlap #1834's frame-generation fixture
helpers and #2622's older readback extraction; neither contains these
repairs. #2614 owns the separate HDR initializer check. Other same-file
changes merge cleanly in the recorded overlap comparison.

Assisted-by: GPT-6 Astra
Co-Authored-By: GPT-6 Astra <noreply@openai.com>

---------

Co-authored-by: GPT-6 Astra <noreply@openai.com>
mooki000 added a commit to mooki000/Hookshot that referenced this pull request Nov 12, 2026
…he loop

It was read with atoi, which takes any value that is not a number for 0: RX_VK_NATIVE_FADD=on turned the native float
add off. native_fadd_wanted reads it with env_value_off (gpu/vk/compat/hookshot_env.h, NotRexed#2042) now: 0, false, off, no
or empty keep the compare-exchange loop, any other value asks for the native add, unset as before.
tests/test_native_float_atomic_add.py compiles native_fadd_wanted alone against 13 values.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hckrman101 pushed a commit that referenced this pull request Nov 12, 2026
…t a compare-and-swap loop (#2306)

Compute programs that run every frame in Kakariko and on Death Mountain
Trail add floats to storage buffers, and the translator makes each of
those adds a compare-and-swap loop: on an RTX 4090 the busiest one takes
77-80 attempts per add on average, and with the GPU's own float add the
frame's GPU time is 0.4-0.6 ms (about 7%) lower there. On NVIDIA's
driver the add is now one instruction. On AMD and Intel it stays the
loop by default, because the native add was tried in the game only on
that RTX 4090. That default changes once someone posts a run on an RDNA
3/4 (or Arc) card in Kakariko and on Death Mountain Trail with
`RX_VK_NATIVE_FADD=1`.

Refs #2336 (closed by the maintainer: tracked in this pull request).

This takes up the idea of yassine-milal's #886 (closed to keep the queue
clear), written again on current `main` with three faults of that diff
fixed; no code was copied from it.

## What changed

- `vk_main.cpp`: on NVIDIA's driver (`VkPhysicalDeviceDriverProperties`'
`driverID`, asked before the extensions are chosen; NVK does not count),
the device asks for `VK_EXT_shader_atomic_float` and turns on only
`shaderBufferFloat32AtomicAdd`, the one feature used.
`RX_VK_NATIVE_FADD=1` asks for it on any GPU that reports the feature,
and `=0` keeps the loop everywhere without enabling the extension. The
switch follows #2042's rule, so `on`, `yes` or `true` mean on and `0`,
`false`, `off`, `no` or empty mean off (`native_fadd_wanted`). The log
names the choice and why: `vk: float atomic add on storage buffers:
native (VK_EXT_shader_atomic_float), NVIDIA's driver`, or
`compare-exchange loop, by default only with NVIDIA's driver
(RX_VK_NATIVE_FADD=1: native where the GPU has it)`, or
`compare-exchange loop, RX_VK_NATIVE_FADD=0`, or the feature that is
missing.
- `vk_shader.cpp`: the profile tells the translator; the program cache's
stamp includes the answer, so a `hookshot_programs.bin` made with the
native add is never read on a GPU without it (#886 keyed only the
environment switch).
- Eden's back end: with the feature, `RED/ATOM.ADD.F32` on a storage
buffer is one `OpAtomicFAddEXT` on an f32 view of the buffer, and no
loop is made. The global-address fallback (#598) adds natively too,
through the f32 views and the page table; the f32 views are searched
with their bound length, as the u32 ones are (#886 skipped that check
for them). Sirit gains `OpAtomicFAddEXT`.
- Without the native add nothing changes: the compare-and-swap loop with
its 1,024-attempt ceiling (#548) stays. That covers every GPU that is
not on NVIDIA's driver unless the switch asks for it.
- `docs/development.md`: the `RX_VK_NATIVE_FADD=0` or `1` row, with the
default.

Which GPUs report the feature, from the drivers' sources: NVIDIA (the
RTX 4090 here reports `shaderBufferFloat32AtomicAdd = true`); AMD RDNA 3
and later on Mesa RADV (`gfx_level >= GFX11`) and RDNA 3 on AMD's own
driver (PAL's Gfx11); Intel Arc and later on Mesa ANV (`has_lsc`). RDNA
1 and 2 (RX 5000, RX 6000, Steam Deck) and Intel's older integrated
graphics have the extension without this feature and keep the loop
either way. Of these, only NVIDIA's driver gets the native add by
default. The AMD and Intel ones need `RX_VK_NATIVE_FADD=1` until they
have a run here (Joey-Magnus's review).

## How it was tested

- `tests/test_native_float_atomic_add.py`: cuts the storage-buffer setup
and the float-add emitter out of the back end into a Sirit fixture,
translates a compute shader with the feature, without it and without
descriptor aliasing, and checks one `OpAtomicFAddEXT` and no
compare-and-swap in the first, the loop in the others, `spirv-val
--target-env vulkan1.3` on all three; and that the device, the profile,
the cache stamp and the fallback follow. It also compiles
`native_fadd_wanted` alone against 28 cases: unset, NVIDIA's driver
alone gets the native add (not RADV, AMD's, Intel's or NVK); set, the
switch's value by #2042's rule on any driver. It fails on `main`. The
decision fails it when `native_fadd_wanted` reads the switch with `atoi`
as before (`RX_VK_NATIVE_FADD=[on]: the loop`) or wants the native add
on every driver (`[(unset)], driver 1: native`).
- On the head (rebased onto `main` a5b955f), through `tests/run_all.py
--strict` with `CXX=g++` and with `CXX=clang++`:
`test_native_float_atomic_add`, `test_amd_subgroup_profiles`,
`test_program_stamp_knobs`, `test_env_on`, `test_global_atomic_add_f32`
and `test_cas_loop_ceiling` pass (6 of 6 each). Against `main`'s source,
`test_native_float_atomic_add` fails with both compilers.
- `python -I -B tests/run_all.py` on the head (Arch Linux, 32 at once,
at a load of 60 to 140 from other work on this PC): 435 passed, 3
failed, 20 skipped; `main` a5b955f the same way: 436 passed, 1 failed,
20 skipped. The four checks that failed in one of the two runs
(`test_appimage_package_retry`, `test_bake_mimo`, and
`test_recorder_sync` and `test_control` by timeout) were run again alone
on both trees: all four pass on the branch, and
`test_appimage_package_retry` fails on `main` again. So they are timing
under load, not this change.
- `clang++ -Wall -Wextra` on the changed files against `main`: no new
warning besides four `missing field 'pNext' initializer` on new structs
(two for the driver query, two for `afloat`), the form `vk_main.cpp`
already has 37 of.
- A stress program with the translator's exact loop: invocations each
add 1.0 to one float; lost adds are the count minus the sum (median of 5
runs).

| adds to one float | RTX 4090: loop, adds lost | RTX 4090:
`OpAtomicFAddEXT`, adds lost | RTX 4090: loop / native time | Intel UHD
770: loop, adds lost |
  |---|---|---|---|---|
  | 1,024 | 0 | 0 | 0.200 / 0.003 ms | 0 |
  | 4,096 | 2,068 | 0 | 1.37 / 0.007 ms | 2,442 |
  | 16,384 | 13,450 | 0 | 6.61 / 0.022 ms | 10,704 |
  | 65,536 | 62,884 | 0 | 28.1 / 0.084 ms | 46,654 |

(RTX 4090: NVIDIA 615.78.08; Intel UHD 770: Mesa 26.2.5, which has no
buffer float add, so only the loop.) Up to 1,024 adds on one float the
loop loses nothing; beyond that it does.

## In the game

RTX 4090, NVIDIA 615.78.08, Arch Linux, no window (scripted input), the
game's default output 2048x1152, Resolution 100%, Game speed per frame;
this branch's build, with and without `RX_VK_NATIVE_FADD=0`, each
prepared first; a save loaded and Link left standing; 150 s each,
numbers from present 1800 on (the 60 fps cap holds both ways, so this is
GPU time, not frame rate). These were measured on the first version of
this branch, on `main` fab8cb3. On NVIDIA's driver the review's changes
leave the same native path as the default, so they were not repeated.
The two runs after the rebase are below the table.

| per frame | Kakariko, child, day: native / loop | Death Mountain
Trail, adult, night: native / loop |
|---|---|---|
| GPU time (the log's `vk: GPU time`, every 120 presents: median, range)
| **7.2** (7.1-7.5) / **7.8** (7.5-8.0) ms | **5.4** (4.4-5.7) / **5.8**
(4.6-6.0) ms |
| all timed GPU work (`RX_VK_PASS_TIMING=2`, mean of 12 reports of 600
presents) | 9.05 / 9.57 ms | 6.38 / 6.78 ms |
| `ab7699ee7f536755`'s dispatch, once a frame | under 0.065 ms (not
among the 25 costliest spans in any report) / 0.153 ms (in all 12) |
under 0.051 ms / 0.147 ms |
| `435a0138b9013040`'s dispatch, once a frame | under 0.065 ms / 0.197
ms (in all 12) | under 0.051 ms / 0.186 ms (in all 12) |
| game frames | 59.3 / 59.3 fps | 59.4 / 59.6 fps |

The other four float-add dispatches (below) are cheaper: not among the
25 costliest spans either way, except `592079442d7129da` once with the
loop on Death Mountain Trail (0.059 ms).

- Which programs add floats, and how hard their loops work (a local
counting build, not part of this change, run with the loop for about
five minutes in each place): six compute programs, all adding to storage
buffers: `499dbfd0721d2858` (#816's sky light), `ab7699ee7f536755`
(#886's Kakariko pass), `435a0138b9013040`, `592079442d7129da`,
`aba5e6af8d4683eb` (Kakariko only) and `6214a97bec4cc0bf`. Of 111.7
million adds in the two places, none reached the 1,024-attempt ceiling;
the busiest, `ab7699ee7f536755` (about 3,000 adds a frame), took 77-80
attempts an add on average and 704 at most. So on this card no add is
lost, and the gain is time. #886's fog program `7984161db3e053b5` runs
every frame in both places but has no 32-bit float add; this change does
not touch it.
- Pictures of the same present both ways (native, loop, difference x4)
show the same light, haze and sky; the differences are animation (grass,
Navi, Link's idle pose), as a present number is not exactly the same
game frame in two runs.

![Kakariko, present 4500: native, loop, difference
x4](https://gist.githubusercontent.com/mooki000/b86bd3f35969803fb032f71f035844f8/raw/70b6daa1af37b68393b13a47de41f0e4ff896ae1/atomic-add-kakariko-native-loop-diff.jpg)
![Death Mountain Trail, present 3300: native, loop, difference
x4](https://gist.githubusercontent.com/mooki000/b86bd3f35969803fb032f71f035844f8/raw/70b6daa1af37b68393b13a47de41f0e4ff896ae1/atomic-add-death-mountain-trail-native-loop-diff.jpg)
- After the review and the rebase: two headless runs of the rebased
build (head 7219b3fd on `main` a5b955f, the same save in Kakariko, 360
s each, `RX_VK_PRECOMPILE=0` so programs were made as met). Both reached
Kakariko and ran to the end with no fatal error, no device loss and no
kernel driver message. Their pictures show the same light and sky both
ways. Other work kept this PC at a load of 60 to 165, so they say
nothing about speed. The log line of each:

![1: Kakariko with the default on NVIDIA's driver, the native float add,
at present 6000. 2: the same with RX_VK_NATIVE_FADD=0, the loop, at
present
6000](https://gist.githubusercontent.com/mooki000/b86bd3f35969803fb032f71f035844f8/raw/38a2692029b2d8287b2fcd23a1817feda6c72362/hookshot-atomic-add-kakariko-nvidia-default-loop.jpg)

- Rebased once more on 2026-11-11, onto `main` `9df055f2`, as
`50579bab`: the only conflict was this change's row in
`docs/development.md` beside a new row of main's; the three commits'
code and tests are unchanged (compared hunk by hunk). Rerun on that
head: `test_native_float_atomic_add`, `test_amd_subgroup_profiles`,
`test_global_atomic_add_f32`, `test_cas_loop_ceiling` and the program
cache's four checks pass through `tests/run_all.py --strict`, and
`test_native_float_atomic_add.py` fails on `main`'s tree. The whole
suite and the two game runs above are from `7219b3fd` on `a5b955fb`.

  ```
vk: float atomic add on storage buffers: native
(VK_EXT_shader_atomic_float), NVIDIA's driver
vk: float atomic add on storage buffers: compare-exchange loop,
RX_VK_NATIVE_FADD=0
  ```

## Not tested

- **AMD and Intel**: the native add is off there by default until
someone posts an RDNA 3/4 (or Arc) run in Kakariko and on Death Mountain
Trail; with `RX_VK_NATIVE_FADD=1` it has not been run on either (which
of them report the feature is from the drivers' sources above). Windows
with NVIDIA is now tested by @nullifyshot (RTX 5080, NVIDIA 621.14,
Windows 11, headless): [child, Kakariko and Death Mountain
Trail](#2306 (comment))
and [adult, Death Mountain
Trail](#2306 (comment));
the default picks the native add there too, about 4 % less GPU time. The
team's test request T-54W3 asks for an AMD RX 7000 or newer or an Intel
Arc. Still not tested: adult at night on Windows; NVK; the Steam Deck
(keeps the loop either way). Frame rates: the RTX 4090 holds the 60 fps
cap both ways here, so only GPU time was compared. #886's thin fog and
20-53 ms passes (Quadro P4000, Windows, v2.0.2) did not reproduce on
this card: no add was dropped and the pictures match.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants