Skip to content

fix: security hardening passes (update offer, builder and release downloads, setup lists, shared env file, files the game reads and writes, game folders from elsewhere, HLE guest ranges) - #2430

Open
Joey-Magnus wants to merge 39 commits into
NotRexed:mainfrom
Joey-Magnus:security/hardening-passes

Conversation

@Joey-Magnus

Copy link
Copy Markdown
Contributor

Takes over #2326 from @hckrman101, who asked for someone to pick it up. His 34 commits are unchanged; this adds a merge with main and three fixes on top. His description follows below the line.

Added in the takeover

  • CI fix: tests/test_oot_menu_mouse.py (Windows only) failed to build because its stand-in hos.h had no hos_guest_range, which host/oot_menu.c now calls. The stand-in now has it.
  • Merge with main (5cbfead0). Ten files conflicted. The two that needed a decision:
    • host/hos_update.c: since fix(update): on Linux "Skip this version" is kept, and no longer costs the game its saves #2283, "Skip this version" is kept with the saves (hos_user_dir), not in <game>/user/. write_skip now writes there and still never through a link at the file's name. The saves folder itself may be a link, because a player can keep it elsewhere. tests/test_update_skip_file.py covers that case now.
    • host/hos_main.c: main made the environment go over hookshot_env.txt. A name is now noted for the builder's start only when the file actually set it. Otherwise a switch the player set in their own environment would be taken from the builder.
  • Main's new writes go through hos_fopen_write: the driver faults note, and the predicted pipelines' marker and sample in gpu/vk/vk_draw.cpp. tests/test_game_writes_no_links.py found them.
  • HOS_CONTROL and HOS_CONTROL_MEM are refused in a shared hookshot_env.txt. The control bridge (feat(control): drive a running game over a local socket, with a CLI #926) landed after this PR started. Its socket name passed the file's no-path rule as a bare name, and HOS_CONTROL_MEM=rw opens guest memory writes. tests/test_env_file.py found it.
  • Test fixtures that compile code from both sides now get hos_fopen_write.h, hookshot_env.h and hos_env_file_names. test_env_file.py also finds HOOKSHOT_ names read with env_on.

Tested (takeover)

Linux: python -I -B tests/run_all.py: 367 passed, 0 failed, 53 skipped. The skips are Vulkan/SPIR-V headers, dotnet and Windows-only tests, which are not installed here. host/hos_update.c passes a gcc -Wall -Wextra syntax check.

Not tested (takeover)

  • The Windows-only test_oot_menu_mouse.py was not run: there is no MinGW here. A clang syntax check of oot_menu.c against the new stand-in no longer reports the hos_guest_range errors.
  • No game build or run after the merge. Everything listed under hckrman101's "Not tested" still applies.

Supersedes #2326.


Some files that players run, download or pass around are still trusted more than they should be. A game folder's text file can pick the program the update offer starts. A shared hookshot_env.txt can still set switches that write files or change how an update builds. A game folder unpacked from an archive can hold links that the game or the builder writes through. This PR closes those gaps and adds tests so they stay closed. None of it changes what a normal player sees.

What changes

Update offer and builders

  • Update offer (host/hos_update.c). The update offer now uses builder= / builder_args= from hookshot_version.txt only when they look like what our builders write:

    • Windows: an absolute path to HookshotBuilder.exe, or to python(w).exe with -I <path>\hookshot_builder_start.py;
    • Linux: an AppImage or AppRun.

    Anything else counts as "no builder", and the offer opens the download page instead. Only the shape of the path is checked, not where it is, so a builder unpacked anywhere still works.

  • "Skip this version" no longer writes through a user folder or skip file that is a link. On Linux it uses lstat and O_NOFOLLOW. On Windows it refuses symbolic links and junctions but still allows OneDrive's own reparse points.

  • The builder an update starts no longer gets the switches that the game's hookshot_env.txt set. It inherits the rest of the game's environment, but the builder reads some of the same names for its build (RX_MARCH, RX_VK_ONLY and others). On Linux the builder is started with execve and an environment built before the fork. On Windows those switches are cleared before ShellExecuteW, and put back if the builder does not start.

  • One tag rule for every builder. A tag may use [0-9A-Za-z._+-], be at most 128 characters, and not be . or ... The WPF, Python and Linux builders apply it to --tag and to every tag they read from GitHub. Before, the WPF builder's nightly lookup passed the list's tag line, unchecked, into a release API link. The WPF builder's patterns end in \z, because .NET's $ also matches before a final line break.

  • Windows Python builder downloads (setup_parts.py, updater.py).

    • Every redirect hop must stay on https, as the Linux updater already requires, and a refused redirect is not retried.
    • A part stops downloading as soon as it is longer than the setup list says.
    • A list or API answer stops at 8 MB.
    • The latest-release check uses the same opener and only returns a tag that passes the list's name rule.
  • Setup lists' parts (builder/setup/setup.c, setup_parts.py, builder/linux/linux_update.py). A part's link must now be under this project's GitHub releases, or llvm-mingw's for the Windows lists (the one part that is not ours). The rest of the link must have no .. step, escape, query or other host. Before, the checksums pinned each part's bytes, but a link could name any https host. A list given for testing (HOOKSHOT_SETUP_LIST) and the Linux updater's lists from this PC still take any https (or file) link.

  • WPF builder downloads (Updater.cs). A download now stops as soon as it passes GitHub's size for the file. Before, a body with no Content-Length was written to disk in full and only then compared.

Shared hookshot_env.txt (host/hos_main.c)

  • Dump switches: every switch with _DUMP in its name is now refused as a group. RX_SCREENSHOT, RX_FLASH_LOG, HOS_SCAN_DT and RX_VK_FRAME_TRACE are refused by name.
  • SDL switches that write files are refused: the *_SAVE_FRAMES switches (a picture each frame) and the disk audio driver. Other SDL_ switches still work.
  • HOOKSHOT_ names: only the game's own are taken: HOOKSHOT_NO_UPDATE_CHECK, HOOKSHOT_NO_AV1_NOTICE and HOOKSHOT_OCCLUSION_OFF. The rest are the builder's.
  • RX_VK_DLSS_DIR is still the one path the file may set, but only as a full local path (C:\... or /...) with no .. step. Before, a relative value was resolved against whatever folder the game was started from.
  • Tests: tests/test_env_file.py scans host/ and gpu/ for switches whose value is used as a path, and the builder's sources for HOOKSHOT_ names. It fails if the shared file could set one of them. The readme says the same.
  • Not affected: the process environment and a file named by HOS_ENV_FILE still set anything.

Files the game writes, and game folders from elsewhere

  • Files the game writes by itself (new host/hos_fopen_write.h). This covers hookshot_options.ini, the pipeline caches, the state lists and their markers, the button-prompt textures, mouse_sens.txt, language.txt and the moved-saves note. A game folder unpacked from an archive can hold links, and before, the game wrote through a link at one of these names, emptying the file it pointed to.

    • On Linux these files are now opened without following a symbolic link at their name, and only as regular files.
    • On Windows a name that is a symbolic link or a junction is refused, because archive tools make those in Developer Mode. OneDrive's own reparse points are still written.
    • The renderer's object keys (nx2/build.py) now include the host headers it uses, so a change to one of them rebuilds the renderer's objects.
  • Files the game reads from its folder at start-up. These are now read only when they are regular files. Before, a FIFO under one of their names (an archive can carry one) made the game wait forever before its window opened. The files covered:

    • the text files: hookshot_env.txt, hookshot_options.ini, language.txt and mouse_sens.txt, through the new hos_fopen_read;
    • the renderer's pipeline caches, notes, state lists, the preparation's records and markers, and the game's font files, through hos_fopen_read_binary;
    • the button-prompt files.

    This applies on Linux. On Windows the read is plain fopen.

  • Mouse sensitivity (host/nn_hid.c). A value from mouse_sens.txt or HOS_MOUSE_SENS is taken only when it is a finite number, and it is kept to the menu's 10-500%. Before, a value like inf went into the menu's option as an undefined number.

  • Saves moved from an old user/ folder (Linux). The game moves user/ into $XDG_DATA_HOME/hookshot and copies an entry when it cannot rename it. Moving, copying and the clean-up afterwards no longer go through a link. A link with a full path is moved as a link, so a save folder kept elsewhere still works. A relative link, a link that cannot be moved, or a folder that holds a link or a FIFO stays where it is, and the saves stay in user/.

  • Rebuilding or updating an existing game folder (nx2/make_game.py, nx2/nup.py).

    • Each line of .hookshot_romfs_heads has to name a .zs file inside the romfs, under the same path rule the extraction uses. If any line does not, the list is dropped and every file is checked instead.
    • A damaged texture's stand-in is copied into a new temp file, not a fixed <name>.part.
    • A link inside the kept .hookshot_exefs, or inside the romfs the build hard-links into, stops the build and asks for an empty folder. On Windows only symbolic links, junctions and WSL links count, so a folder under OneDrive still updates.
    • In a game folder OneDrive keeps, the extraction's list of damaged textures is still used. Before, OneDrive's own reparse points made the builder drop it and open all ~100,000 files again, which takes minutes. Only reparse points that are links count there now.
    • A link at a name the builder writes in the folder (language.txt, hookshot_version.txt, bin, prompts, the staged .new files and so on) stops the build before anything is written. Links at other names, such as a cache moved to another drive, are left alone. A test checks that every name the builder writes is on the list.
  • Pipeline cache (gpu/vk/vk_draw.cpp). mkw_vk_pipelines.bin and the play caches are now checked by their header before the rest is read, and a file over 4 GB is not read. Before, the whole file was loaded into memory first.

  • Button-prompt files (host/prompts.c). A file is read only when its magic is right, and never past 256 MB. Before, the whole file was loaded into memory first.

  • Pipeline notes and state lists (gpu/vk/vk_draw.cpp). A record is used only with a sample count the GPU supports, vertex strides up to 2048 (Vulkan's least limit) and divisors up to 65535. Before, any power of two up to 64 and any stride or divisor were handed to the driver.

  • Contributed pipeline state lists (nx2/pipeline_states.py).

    • add, tour and check (which CI runs) now stop on a record with a value no pipeline has: a count past its array, a sample count or topology Vulkan does not have, a yes/no value other than 0 or 1, non-zero padding, or a stride or divisor past those limits. They name the record and the reason.
    • An xz list is unpacked only up to 64 MB.
    • Every list in the repository passes.
  • A refused write is logged. The first time a file stops at a link or a non-file in hos_fopen_write, it writes files: <path> is a link or not a plain file: the game does not write through it to the game's log. That gives a setting that does not stick a visible reason.

Host functions the game calls (HLE)

  • Guest memory checks. GP() turns a guest address into a host pointer without checking it. Before, a size the game passed could carry a copy past the guest window into the host's own memory. These functions now use hos_guest_range on the whole span, as nn::fs and the libc copy hooks already do:
    • nn::audio's wave buffers and ADPCM coefficients. The mixer reads these for as long as a voice plays, so a buffer outside guest memory is now refused.
    • nn::os GenerateRandomBytes.
    • nn::hid GetNpadStates, now capped at 16 states, and GetSixAxisSensorStates.
    • The socket Poll stand-in.
    • OutputDebugString and nn::diag's log lines, which copy into the log.
    • The event-name and item-key copies in hos_main.c and oot_menu.c.
  • NVN proc names (host/nvn_native.c). nvnDeviceGetProcAddress and nvnBootstrapLoader now copy the name the game passes, at most 255 characters and only from inside guest memory. Before, they read it in place to its end.

Release and CI

  • Python packages in releases.
    • The AppImage's Python (scripts/linux/deps.sh) and the Windows builder's Python part (scripts/release_parts.py) now install their pinned packages with pip --require-hashes, from a new requirements-lock.txt.
    • That file holds the SHA-256 of each pin's wheels for Linux x86_64 and Windows x64. python -I scripts/python_lock.py writes it after a pin changes.
    • tests/test_python_lock.py fails when the lock and the pins differ.
    • The AppImage's dependency cache key includes the lock.
  • scripts/ffx_sdk.py and scripts/ngx_sdk.py. Downloads and copies go to a new temp file next to the target instead of a fixed <name>.part. Each file is hashed while it downloads, and a download stops past 1 GB. The pinned commits and SHA-256s are unchanged.
  • scripts/release_rc.sh. It copies the newest release candidate's AppImage and .zsync into the rc release only when GitHub gives a SHA-256 digest for each file. This is checked before the tag moves or anything uploads. Before, a file without a digest was copied unchecked.
  • Dependabot. It now proposes updates to the pinned Python packages (requirements.txt, nx2/prompt_requirements.txt) weekly, with the same 7-day cooldown as the actions. After it changes a pin, scripts/python_lock.py must write the lock again; tests/test_python_lock.py fails until it does. The builder's .NET project has no NuGet packages, so there is no NuGet entry.
  • scripts/linux/deps.sh. The four license texts the AppImage ships now go through fetch with SHA-256 pins, like every other download there.
  • New tests/test_workflow_hardening.py. It checks every workflow for:
    • actions pinned by full commit;
    • token permissions set at the top;
    • checkouts with persist-credentials: false (except the release record job's);
    • no ${{ }} of event data or inputs inside a run: script;
    • no checkout in a pull_request_target workflow.

Tested

Linux, RTX 3090. The branch merges main@origin ebbba2ea, which carries scripts/release_rc.sh and the setup-list code changed here; the merge had no conflicts. Testing used merges with main as it moved (e19d9693, d7a73062, ebbba2ea, 49f75dc8), all without conflicts. The link and env game runs used the e19d9693 merge, the FIFO run and the last game build the d7a73062 merge, and the last full suite the 49f75dc8 merge.

  • New and changed checks. Each one fails on the code before its fix and passes after. Each commit's own checks pass at that commit. The existing checks that compile code cut from the changed files now include the helpers, and they pass. The ones that also run on Windows were compiled with llvm-mingw too.
  • Full suite (python -I -B tests/run_all.py). 373 passed, 1 failed, 12 skipped. The failure, tests/test_wayland_libdecor_hint.py, also fails on main on this PC.
  • Python package lock. pip downloaded every pinned wheel with --require-hashes for Linux x86_64 / Python 3.12 (with dependencies) and for Windows x64 / Python 3.12, 3.13 and 3.14 (as the Windows part installs them): 10 of 10 each time.
  • Compile.
    • host/hos_update.c compiles with no warnings (-Wall -Wextra) under gcc (Linux) and llvm-mingw clang (Windows).
    • The other changed host files pass a syntax check on both.
    • The Windows renderer (hookshot_vk.dll) cross-built with llvm-mingw before the last round. After it, vk_draw.cpp and vk_main.cpp pass a syntax check for both Linux and Windows. host/nvn_native.c, builder/setup/setup.c and the host files that include hos_fopen_write.h pass one for both too.
  • Game build. A full build of the merge with hookshot-build finished, including shader preparation. A rebuild into the existing game folder took the "read from this .nup before" path, which the new link check does not refuse. All 25,251 shipped pipeline states were noted, and the 996 MB pipeline cache was read. With a language.txt link planted in the folder, the build stopped at the start and named it, and the link's target was left as it was.
  • Headless game runs (HOS_HEADLESS=1 HOS_NO_SDL=1, separate data folder, links planted in the game folder; final run on the branch head):
    • nothing was written through the link at hookshot_options.ini or at the pipeline marker;
    • user/ was moved to the saves folder, and its user/save link was moved as a link with its target left alone;
    • a relative RX_VK_DLSS_DIR was left out of hookshot_env.txt;
    • SDL's disk audio and save-frames switches, RX_VK_FRAME_TRACE, HOOKSHOT_MEM_LIMIT_GB, HOOKSHOT_JOBS and HOOKSHOT_PREPARE_ONLY were left out, and no sdlaudio.raw or hookshot_frames.csv appeared;
    • the game's own audio wave buffers and controller reads passed the new guest-memory checks (all 8 wave buffers logged as taken, the controller read as before);
    • the game started and ran its shader warm-up.
  • Headless run with FIFOs at language.txt, hookshot_env.txt and hookshot_options.ini. On a build that had moved only the host's reads, the game hung after listing the GPUs, on the renderer's own read of hookshot_options.ini. With every start-up read moved over, it started and ran as usual and never waited on the FIFOs.
  • .NET (SDK 8.0.428 on Linux).
    • tests/test_updater_asset_digest.py builds the whole Updater.cs with its fixture: 22 of 22 cases pass. Its two new cases fail on the code before the fix.
    • tests/test_builder_dlss_update.py passes (57 checks).
    • The WPF builder project builds with EnableWindowsTargeting=true, with no warnings.

Not tested

  • Windows, nothing was run:
    • the update offer with a real WPF or Python builder, and the environment hand-off to it;
    • the junction / OneDrive check for the skip file;
    • the Python builder against GitHub.
    • the Windows branch of hos_fopen_write, which was only tested with a stand-in windows.h. The Windows host files were only syntax-checked.
  • The WPF builder was not run, only built. Its updater was run through the test fixture.
  • The Linux update offer end to end (it opens a zenity window). The checks behind it are unit-tested, the environment hand-off included.
  • The pip lock in CI: it has not run in the AppImage or Windows release jobs yet.
  • Gameplay: the headless runs stopped after 60-120 s, before gameplay. There was no windowed run with a controller.
  • The last round's game changes were never in a built game. That covers the vertex stride and divisor limits, the bounded NVN proc names and the log line for a refused write. The build queue was full. They are covered by their tests and the syntax checks above.
  • Left for later:
    • signing the release lists;
    • a structural check of cached SPIR-V and an install key for the program cache. The same file's reads and writes and its shader seed also need the regular-file, no-link and size rules, after the change being made to that file now;
    • the Landlock sandbox;
    • the nightly CI cache policy;

hckrman101 and others added 30 commits November 11, 2026 05:09
…te the skip file without following links

hookshot_version.txt sits in the game folder, and its builder= and
builder_args= lines chose the program the update offer starts and the
arguments before the game's own --update and --tag. They are now taken
only in the shapes the builders write: an absolute path to
HookshotBuilder.exe, or to python(w).exe with
"-I <path>\hookshot_builder_start.py" on Windows, an AppImage or AppRun
on Linux. Anything else counts as no builder, and the offer opens the
download page instead.

"Skip this version" no longer writes through a user folder or skip file
that is a link: lstat + O_NOFOLLOW on Linux, and on Windows a symbolic
link or junction is refused (OneDrive's own reparse points are not
links and still work).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…Windows Python builder

The Python builder for Windows followed urllib's default redirects and
looked only at the last link, so an https -> http -> https chain passed;
each hop must now stay on https, as the Linux updater already does, and a
refused redirect is not tried again. A part stops downloading as soon as
it is longer than the setup list says (it was written whole and compared
afterwards), and a list or API answer stops at 8 MB. The latest-release
check reads through the same opener, with the same ceiling, and only
returns a tag the setup list's name rules accept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… a shared hookshot_env.txt

A hookshot_env.txt is passed around between players, so it may not set
switches that name a file or folder. The list had drifted: RX_DUMP_IR,
RX_SCREENSHOT, RX_FLASH_LOG and HOS_SCAN_DT take a folder or file
prefix and were still accepted with a bare name, and RX_VK_DUMP_NATIVE
wrote files into the current folder. Dump switches are now refused by
family (any name with _DUMP), the four are refused by name, and
tests/test_env_file.py scans host/ and gpu/ for switches whose value is
used as a path and fails when the shared file could set one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the tags read from GitHub

The builders took --tag as given and relied on a later mismatch with
the setup list's own (checked) tag. The WPF builder's nightly lookup
also returned the list's tag line unchecked, and that value went into
a release API link. --tag is now checked at the start of an update in
the WPF, Python and Linux builders, and every tag read from GitHub
goes through the same rule ([0-9A-Za-z._+-], at most 128, not . or ..)
as the Linux updater's list check, now one tag_ok function.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ey come and capped

scripts/ffx_sdk.py and scripts/ngx_sdk.py downloaded and copied through
a fixed <name>.part, opened for writing as it was: a link left in that
place (in the game folder's ffx/ or ngx/ too) had the write land on what
it points to. Each download and copy now goes to a new temp file beside
the target (mkstemp), is hashed while it is read, and stops once it is
larger than 1 GB rather than being written whole before the SHA-256 is
checked. The pinned commits and SHA-256s are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ked download

The four license texts the AppImage ships were bare curl calls: not
limited to https, and not hash-checked like every other download in
the script. They now go through fetch with a pinned SHA-256 variable (for the
LLVM, FFmpeg and dav1d versions the script pins). A test keeps any curl
outside the CURL array out of the script and runs fetch over those
lines with a stand-in curl.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A game-free check over .github/workflows: actions pinned by full commit,
top-level token permissions, checkouts without a persisted token (but
the release record job's), no event data or inputs expanded inside a
run: script, and no checkout in a pull_request_target workflow. Every
workflow passes it as it is; the check's own cases show it catches each
kind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ll local path

RX_VK_DLSS_DIR is the one path a shared hookshot_env.txt may still set.
A relative value (ngx, ., ../x) was taken and NGX resolved it against
whatever folder the game was started from, and a .. step could lead
out of the folder the value names. The file now takes only a folder
named in full on this PC (C:\x on Windows, /x on Linux) with no ..
step; shares and device paths stay refused. The process environment and
HOS_ENV_FILE still take any value.

tests/test_env_file.py checks a table of values for both systems.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e-hashes

The AppImage's Python (scripts/linux/deps.sh) and the Windows builder's
Python part (scripts/release_parts.py package --python-packages) took
their pinned packages from PyPI by version only. Every other download a
release makes is checked against a SHA-256.

requirements-lock.txt, written by the new scripts/python_lock.py from
requirements.txt's pins, lists each pin's wheels for Linux x86_64 and
Windows x64 with their SHA-256, and both installers now use pip
--require-hashes with it, so a wheel added to PyPI later under a pinned
version is refused. The AppImage's dependency cache key and deps.sh's
install mark include the lock.

tests/test_python_lock.py checks the lock against the pins and that both
installers use it; tests/test_release_prompt_packages.py checks the
hashes reach pip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…de it

A game folder can come from elsewhere, and an update or a rebuild into it
trusted what it found there:
- .hookshot_romfs_heads (the damaged textures the extraction found) was
  joined to the romfs as it was, so a line with .., an absolute path or a
  linked folder chose a file outside the romfs to replace;
- the stand-in was copied through a fixed <name>.zs.part, which could be
  a link to a file elsewhere;
- the kept .hookshot_exefs was copied with its links followed, and the
  romfs links were made into an existing romfs folder that could hold a
  link.

Each heads line now goes through the extraction's own path rule (no ..,
no absolute path, no link on the way, a .zs name); one that does not
pass drops the list and every file is checked instead. A stand-in is
copied into a new file of its own (mkstemp) and a damaged file that is a
link is left alone. A link inside the kept exefs or the romfs being
linked into stops the build and asks for an empty folder.

New tests/test_game_folder_links.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ir name

The game rewrites hookshot_options.ini when an option changes and writes
its pipeline caches, state lists and their markers, the button-prompt
textures, mouse_sens.txt, language.txt and the moved-saves note by
itself. A game folder unpacked from an archive someone shared can hold
symbolic links (tar and unzip keep them on Linux), and a plain
fopen("w") through one emptied and rewrote the file it named.

host/hos_fopen_write.h opens these with O_NOFOLLOW, only as a regular
file (not a FIFO or a device), and empties it only once that is known.
Windows is unchanged: an archive's links are not made there without a
privilege. The program cache (gpu/vk/vk_shader.cpp) is left for later.

New tests/test_game_writes_no_links.py: the helper's behaviour, and every
write in those files goes through it but the switch-gated debugging ones.
The checks that cut those writers out of vk_draw.cpp now build them with
the helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t the device draws with

targets_ok let any power of two up to 64 through as a record's sample
count, and the precompile handed it to the driver as the pipeline's
rasterizationSamples. A shareable state list (or hookshot_pipelines.bin)
with 16 or 64 on a GPU that draws with up to 8 then asked the driver for
an invalid pipeline at every start. A record is now made only with a
count in the device's framebuffer sample counts.

tests/test_pipeline_state_counts.py gains the case; both state-list tests'
stand-in device reports 1 to 8 samples.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…es through a link

On Linux the game moves the user/ folder it finds beside itself into
$XDG_DATA_HOME/hookshot, entry by entry, at the start. When an entry
could not be renamed (another file system), it was copied, and a link
was followed: the files it pointed to, anywhere, were copied in among
the saves, and links inside a copied folder likewise. A link by a full
path is now moved as the link it is (a player's save folder kept
elsewhere still works); a relative one, which would name something else
from its new place, or one that cannot be moved, is left where it is
with the saves staying in user/. A folder that has to be copied and
holds a link is not copied: it stays too. delete_tree takes a link away
without going into what it names.

New tests/test_saves_move_links.py builds saves_dir over the posix
layer's file functions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s size

Updater.cs DownloadOnce compared the length only after the whole body was
on disk; with no Content-Length it wrote whatever the server sent until
it ended. It now stops as soon as the body passes GitHub's size of the
file (or the answer's own length), as the Windows Python builder and the
Linux updater already do.

tests/updater_asset_digest_test.cs gains a case: a longer body with no
Content-Length is refused before it is read to its end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…st 4 GB

pcache_file_read read mkw_vk_pipelines.bin (and the play caches beside
it) whole into memory, then looked at its header. A large or sparse file
under that name, such as another PC's, ran the game out of memory at
the start. The header is now read and checked first, the rest only for
this device's cache, and a file larger than 4 GB (NVIDIA's after a full
preparation is about 1.1 GB) is not read, with a line in the log.

New tests/test_pipeline_cache_file_read.py runs the reader with its
memory held to 1 GB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… a shared hookshot_env.txt

A shared hookshot_env.txt may set SDL_ switches. Two kinds of them make
the game write files where it was started, without end: the dummy and
offscreen video drivers' *_SAVE_FRAMES (a picture each frame) and the
disk audio driver (SDL_AUDIODRIVER / SDL_AUDIO_DRIVER=disk: the sound to
sdlaudio.raw). The file now leaves those out, as it does the game's own
dump switches; other SDL_ switches (SDL_VIDEODRIVER=x11 and the like)
are taken as before. RX_VK_FRAME_TRACE, which adds a line to
hookshot_frames.csv each frame, joins the switches it takes no value for.

tests/test_env_file.py gains the cases; the readme says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…_ switches

hookshot_env.txt took every HOOKSHOT_ name without a path in its value.
Most of them are the builder's (HOOKSHOT_MEM_LIMIT_GB, HOOKSHOT_JOBS,
HOOKSHOT_LATEST_TAG, HOOKSHOT_KEEP_BUILD_FILES...), and the builder an
update starts from the game inherits the game's environment, so a shared
file could set how the update's build ran. HOOKSHOT_PREPARE_ONLY, set by
--prepare-shaders, kept the game from ever playing. The file now takes
only the game's own HOOKSHOT_NO_UPDATE_CHECK, HOOKSHOT_NO_AV1_NOTICE and
HOOKSHOT_OCCLUSION_OFF; the process environment and HOS_ENV_FILE still
set any.

tests/test_env_file.py looks through the builder's sources for HOOKSHOT_
names and checks the file takes none but the game's; the readme says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ot_env.txt set

When the player says yes to an update, the game starts the builder,
which inherits the game's environment, and with it every switch the
game's shared hookshot_env.txt set. The builder reads some of the same
names for the build it makes (RX_MARCH, RX_HOT_OPT, RX_VK_ONLY, RX_PGO...),
so a file someone handed the player could change how the update's build
ran. The game now notes the names that file set (hos_env_file_names),
and starts the builder without them: on Linux through execve with an
environment made before the fork, on Windows by clearing them before
ShellExecuteW (and putting them back if it does not start). A
HOS_ENV_FILE's switches are not noted: a test run brings those on
purpose.
A switch the list has no room for is not set at all, so none is set that
the builder's start could not take back.

tests/test_update_tracks.py checks the filter on its own and where it is
used; tests/test_env_file.py checks which names are noted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An update builds into the game folder as it is, and the builder writes
language.txt, hookshot_version.txt, hookshot.png, the seeded pipeline
files, the shader list, prompts/ and bin/ there by name, and stages the
game's files as <name>.new. A folder from elsewhere can hold a link at
one of those names, and the builder wrote through it (a dangling .new
link too: copy2 created what it named). Before anything is written, a
link (a symbolic link, a junction or a WSL link) at one of the names it
writes now stops the build and asks to remove it or choose an empty
folder; links elsewhere in the folder (a cache moved to another drive)
are left alone. A staged .new that is a dangling link is removed first.

The check for links inside the kept exefs and the romfs (the commit
before) now counts only symbolic links and junctions on Windows, not the
reparse points OneDrive marks its files with: a game folder under a
OneDrive-synced Documents or Desktop updates as before.

tests/test_game_folder_links.py checks the refusal, and that every name
make_game.py joins to the game folder is on the list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… a trailing line break

Updater.cs TagOk anchored its pattern with $, which in .NET also
matches before a final line break, so "v2.1.0\n" passed the rule the
other builders apply with a full match. The patterns for release and
nightly versions had the same end. They now end in \z, and the
release-candidate lookup also puts the tags it reads through TagOk.

tests/updater_asset_digest_test.cs gains a tag-rule case that runs
TagOk and TrackOf themselves on good and bad names (it fails on the rule before);
tests/test_update_tracks.py, which compared the rule's text with Python's
semantics, now requires the \z anchor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… and not past 256 MB

The button prompts' files in the game folder (prompts/<face>/textures.bin,
ring.bin, hint.bin, External.bfotf) are a few MB, made by the builder.
read_all allocated and read any file under those names whole before it
looked at its magic, so a large or sparse one (a folder from elsewhere)
ran the game out of memory at the start. The magic is now read first,
and a file over 256 MB is not read: the game shows its own icons, as it
does without the files.

New tests/test_prompt_file_read.py counts what read_all asks malloc for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r junction too

hos_fopen_write wrote through any name on Windows, on the grounds that an
archive's links are not made there without a privilege. Archive tools do
make them in Developer Mode, which many players turn on. It now refuses
a name that is a symbolic link, a junction or WSL's link (by its reparse
tag), and writes as before through other reparse points, which OneDrive
puts on its files.

The back end's object keys (nx2/build.py) now hold the host headers it
includes, hos_fopen_write.h and hos_wide_size.h: a change to one of them
rebuilt no vk object, so vk_draw.cpp kept the header's old code (seen
here in the Windows build).

tests/test_game_writes_no_links.py builds the Windows branch with a
stand-in windows.h and checks each kind, and that every host header the
back end includes is in its object keys; tests/test_shader_stamp_cache.py's
stand-in tree has the two headers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…more host functions

GP() turns a guest address into a host pointer without a check, so a
size from the game walks a copy past the guest window into the host's
own memory. nn::fs and the libc copy hooks were moved onto
hos_guest_range before; these were not:
- nn::audio AppendWaveBuffer took a wave buffer's data and ADPCM context
  as given, and the mixer read them for as long as the voice played
  (the host's memory into the sound, or a fault in the mixer); a buffer
  outside guest memory is now refused. AcquireVoiceSlot's coefficients
  likewise;
- nn::os::GenerateRandomBytes wrote ARG(1) bytes from ARG(0);
- nn::hid GetNpadStates wrote any count of states (now at most 16, as
  the console keeps) and GetSixAxisSensorStates its states unchecked;
- the socket Poll stand-in cleared up to 4096 descriptors unchecked;
- OutputDebugString and nn::diag's log lines copied up to 4 KB into the
  log from an unchecked pointer;
- the event-name copies (hos_main.c) and the item key (oot_menu.c, whose
  own bound could wrap) wrote through pointers read from game objects.

New tests/test_hle_guest_ranges.py runs wb_in_guest and checks the
functions' text; the wave-buffer and input fixtures have hos_guest_range
over their own guest memory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…y are regular files

The game reads hookshot_env.txt, hookshot_options.ini, language.txt and
mouse_sens.txt from its folder at the start. A folder unpacked from an
archive can hold a FIFO under one of those names (tar keeps them), and
fopen("r") on it waited forever, before the window opened, at every
start. New host/hos_fopen_read.h has hos_fopen_read: on Linux it opens
without waiting and takes only a regular file (a link to one is fine);
on Windows it is fopen. The reads of those files go through it, and
hos_fopen_read_binary takes the renderer's reads of its caches, notes,
state lists, preparation records and markers (gpu/vk/vk_draw.cpp) and the
button-prompt files (host/prompts.c) the same way, as do the renderer's
start-up reads of hookshot_options.ini and its skip marker
(gpu/vk/vk_main.cpp) and of the game's font files (vk_game_font.h). On
Linux, the copy that moves an old user/ folder to the saves folder reads
through it too, so a FIFO there fails the copy (the entry stays) instead
of holding the start. The helpers live in
host/hos_fopen_read.h, which needs no windows.h, so the checks that cut a
reader out of the sources still build on Windows beside their own
stand-ins; hos_fopen_write.h includes it, and the renderer's object keys
take it in.

tests/test_game_writes_no_links.py checks the helper (a FIFO and a folder
refused at once, a file and a link to one read) and that each read goes
through it; the checks that cut those readers out include the header.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mouse_setup (host/nn_hid.c) reads the sensitivity kept in
<user>/mouse_sens.txt, and HOS_MOUSE_SENS, and any positive number went
on: "inf" or "1e999" reached lround() when the value moved into the
menu's option, an undefined result kept in hookshot_options.ini. Saves
can come from elsewhere. A value is now taken only when finite, and kept
to the menu's 0.1-5 (10-500%).

New tests/test_mouse_sens_file.py cuts mouse_setup out of the source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
release_rc.sh and the setup lists' code changed on main since this
branch began; the next fixes are to them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…f it

release_rc.sh copies the newest release candidate's AppImage and .zsync
into the rc release. A file without GitHub's SHA-256 digest was
downloaded and uploaded unchecked. Now every file must have a well-formed
sha256 digest, checked before the tag moves or anything is uploaded; the
download is then always compared with it.

tests/test_release_scripts.py: a .zsync without a digest leaves the tag,
the releases and the uploads as they were.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The setup list a release ships names each part's checksum and link.
The checksums pinned the bytes, but the links could name any https host.
The Windows setup (builder/setup/setup.c), the Python builder
(setup_parts.py) and the Linux updater (linux_update.py) now take a
part only from this project's GitHub releases (and, for the Windows
lists, llvm-mingw's, the one part that is not ours), with no ".." step,
escape, query or other host in the rest of the link. A list given for
testing (HOOKSHOT_SETUP_LIST), and the Linux updater's lists from this
PC, take any https (or file) link as before.

tests/test_setup_list_names.py checks both readers on Windows' side (the
C rule compiled from setup.c); tests/test_linux_update.py the Linux one.

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

Dependabot proposed only the actions. The Python packages the release's
Python carries (requirements.txt, nx2/prompt_requirements.txt) are pinned
and installed with --require-hashes, so a fixed release of one was never
proposed. They are now watched weekly with the actions' 7-day cooldown,
in one group. A proposal changes the pins; scripts/python_lock.py writes
the lock again (tests/test_python_lock.py fails on a stale lock). The
builder's .NET project has no NuGet packages, so nothing is added there.

tests/test_workflow_hardening.py checks the entries and their cooldowns,
and asks for a NuGet entry once the builder has a package.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…strides and divisors Vulkan does

A note in hookshot_pipelines.bin and a record of the shareable state
list carry the draw's vertex stream strides and divisors, and any number
reached vkCreateGraphicsPipelines. streams_ok now refuses a stride past
2048 (Vulkan's least maxVertexInputBindingStride) or a divisor past
65535; the game's own are under 100 and 0-1. note_sane and both
versions' record readers use it, as targets_ok and formats_ok.

tests/test_pipeline_state_counts.py: a record with a stride of 4096 or a
divisor of 65536 is left out, one at the limits kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hckrman101 and others added 8 commits November 11, 2026 07:46
…, merges or ships them

The pipeline state lists contributors add (assets/pipeline_states) are
merged into the list every release ships, and the game builds each
record's pipeline at the first start. pipeline_states.py checked a list's
header and length only. Now add, tour and check (which CI runs) stop on a
record with a value no pipeline has: counts past their arrays (targets,
attributes, answers, questions), a sample count or topology Vulkan does
not have, a yes/no value other than 0 or 1, non-zero padding, or a
vertex stride or divisor past Vulkan's limits, saying which record and
why. An xz list is unpacked only up to 64 MB: a contributed file of a few
KB could unpack to gigabytes in CI. Every shipped list passes.

tests/test_pipeline_state_sources.py: such records are refused by add and
check, and an xz list of 128 MB is refused unread; the fixtures' records
have one sample.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nvnDeviceGetProcAddress and nvnBootstrapLoader read the name the game
passes in place, to its end: a pointer near the guest window's end, or
a name with no end, read past the guest's memory into the host's. A new
guest_name copies at most 255 characters, only from inside the window,
and gives "" when there is no string there (which asks for no proc).

tests/test_hle_guest_ranges.py checks both callers and runs guest_name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hos_fopen_write refused a link or a non-file at the name silently, so a
setting that did not stick (hookshot_options.ini a link from an archive)
left no reason. The first refusal in each source file now writes
"files: <path> is a link or not a plain file: the game does not write
through it" on stderr, which is the game's log; on Linux only for a link
or a non-file (ELOOP, ENXIO, not S_ISREG), not for a missing folder.

tests/test_game_writes_no_links.py checks the line is said once, naming
the file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ged-texture list

stand_in_damaged_files takes the extraction's list of damaged textures
(.hookshot_romfs_heads) only when each line passes nup.extraction_path,
which refused any reparse point on the way: in a folder OneDrive keeps,
whose files carry OneDrive's own reparse points, the list was dropped and
all ~100,000 files were opened again (minutes). extraction_path takes
links_only, which counts only the reparse points that are links (a
symbolic link, a junction, WSL's link); the read-back uses it, and the
unpack itself still refuses any.

tests/test_game_folder_links.py: with OneDrive's tag the list is taken;
with a symbolic link's it is dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Conflicts resolved:
- host/hos_update.c: "Skip this version" is kept with the saves (hos_user_dir, NotRexed#2283);
  write_skip writes there, still never through a link at the file's name. The saves
  folder itself may be a link (a player keeps it elsewhere).
- host/hos_main.c: the environment goes over hookshot_env.txt first; only a name the
  file actually sets is noted for the builder's start, so a switch the player set in
  the environment stays in the builder's environment.
- nx2/make_game.py: write_stamp (NotRexed#2246) with the checked damaged-texture list.
- gpu/vk/vk_draw.cpp, host/nn_audio.c, host/nn_hid.c, host/prompts.c: both includes;
  env_on with hos_fopen_read_binary.
- readme.md: the env file's rules here, without TOTK_, and main's paragraph on the
  environment going first.

After the merge:
- gpu/vk/vk_draw.cpp: main's new writes (driver faults note, predicted pipelines'
  marker and sample) go through hos_fopen_write.
- tests: fixtures that compile code from both sides get hos_fopen_write.h,
  hookshot_env.h, hos_env_file_names and the skip file's new place;
  test_env_file.py finds HOOKSHOT_ names read with env_on too.

Co-authored-by: Cursor <cursoragent@cursor.com>
… (HOS_CONTROL, HOS_CONTROL_MEM)

HOS_CONTROL's value is a socket path, and a bare name passed the file's
no-path rule (a socket in the current folder). HOS_CONTROL_MEM opens guest
memory reads and writes over it. Both are refused by name, like the other
switches that name a file; tests/test_env_file.py found HOS_CONTROL.

Co-authored-by: Cursor <cursoragent@cursor.com>
…t the header's windows.h (Sleep clashed on Windows)

Co-authored-by: Cursor <cursoragent@cursor.com>
@Joey-Magnus
Joey-Magnus marked this pull request as ready for review November 11, 2026 19:18

@hckrman101 hckrman101 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for picking this up from #2326 and for the clear takeover notes. I went through the update offer, the builders' download and list checks, the env file rules, hos_fopen_write/hos_fopen_read, the saves move, the builder's folder-link checks and the HLE ranges. Mostly this does what it says. A few things below.

What holds up

  • Update offer: builder_ok matches what the three builders actually write (Runner.cs ProcessPath, paths.launcher_command() with list2cmdline(["-I", start]), AppRun's $APPIMAGE / $HERE/AppRun). The env hand-off is correct on both sides: Windows _putenv updates the process block ShellExecuteW passes on, and Linux builds envp before the fork.
  • Downloads: every redirect hop is checked now, not just the final URL. The size cap applies while reading, before anything is written, and the part URL prefix plus no ../%/?/@/: closes the "any https host" gap. release_rc.sh checks every digest before the tag moves.
  • hos_fopen_write on Linux: O_NOFOLLOW|O_NONBLOCK, then fstat for a regular file, then truncate. That order closes the link and FIFO cases. The pipeline caches still go .tmp then rename, so a link at the final name gets replaced, never written through.
  • HLE: the new hos_guest_range checks work like the existing fs/libc ones, and the wave-buffer mixer already bounds each read by w->size, so checking [buffer, size) up front is the right span. I didn't find a check that would reject a valid call.
  • Every new test is game-free, and the network ones only use localhost. Each one has a RUN_ALL_SKIP where it needs one.

Worth a look

  1. Windows "Skip this version" refuses a user folder that is a junction (inline). That's where Windows keeps the saves, and a junction is the usual way to move them to another drive. In that case the skip silently fails and the offer comes back every start. On Linux the takeover deliberately allows a linked saves folder. I'd keep only the file-name check on Windows too.
  2. A related trade-off on Linux: a player who deliberately symlinks hookshot_options.ini, mouse_sens.txt or a pipeline cache (dotfile managers, a cache on another disk) now gets no write, just one log line. It's defensible for archive-borne links, but a line in the readme's file section would save a confused report.
  3. Overlap with open PRs. The order needs deciding:
    • #2367 (portable Windows builder) records a relative builder_portable, which builder_ok would refuse. Arkex-ai already raised it on #2326 and it isn't settled yet.
    • #1414 makes the setup and updaters follow the repo they came from, while part_url_ok / PART_SOURCES hardcode NotRexed/Hookshot. Whichever lands second has to reconcile the two.
    • #2317 (per-user data folder) touches 22 of these files, including the paths the new write/read helpers wrap.

CI: the only red job is Windows part 1/2, on test_native_astc_texture_count.py (scanf deprecated under -Werror). That's main's: #2397 fixed it, and main's source checks are green since. A merge with main should clear it. GitHub reports no conflicts right now.

Splitting: the commits are already split by area, so I wouldn't force a split. If @NotRexed would rather take it in pieces, the natural cut is (a) release/CI/lock/dependabot, (b) builders and update offer, (c) game file I/O and env file, (d) HLE ranges and pipeline record limits. Only (b) collides with #2367/#1414.

Not run on Windows yet: the update offer with a real builder, the junction check and the hos_fopen_write Windows branch. A Windows run of the update offer before merge would be good.

Comment thread host/hos_update.c
/* (not through a link or a junction: the user folder or the file put there in its place would have the write
land somewhere else) */
const DWORD ua = GetFileAttributesW(user), fa = GetFileAttributesW(file);
FILE *f = ua != INVALID_FILE_ATTRIBUTES && (ua & FILE_ATTRIBUTE_DIRECTORY) && !is_link(user, ua) &&

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

!is_link(user, ua) refuses a junctioned <game>\user, which is where Windows keeps the saves and the usual way to move them to another drive. In that case the skip is silently not written and the offer comes back every start. The Linux write_skip deliberately allows a linked saves folder and only refuses a link at the file's own name. Dropping the folder check here and keeping !is_link(file, fa) would match that.

…sses

Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	nx2/make_game.py

@hckrman101 hckrman101 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for the merge. I compared the PR's patch at 348d26c with the one at d827111 file by file: all 84 files are line-for-line the same, so this push is the main merge only.

The make_game.py resolution is right. stand_in_damaged_files keeps all of #2096's flow (the resolved-v1 checked stamp and removing an old one, the note written before the stop, the fail on unresolved files, the stamp and heads removal only after that) and wraps the PR's checks around the heads list: .zs only, nup.extraction_path(..., links_only=True) for each entry, falling back to opening every file if one doesn't pass. Nothing from either side is dropped.

Earlier points, none changed by this push:

  1. Windows "Skip this version" still refuses a junctioned user folder (!is_link(user, ua) in host/hos_update.c, inline thread above). Still open; I'd keep only the file-name check, as on Linux.
  2. The readme line for players who symlink hookshot_options.ini, mouse_sens.txt or a pipeline cache on purpose isn't there yet. Small, but still open.
  3. Overlaps: #2367, #1414 and #2317 are all still open, and the builder_ok vs relative builder_portable question with #2367 is still unsettled. Nothing to fix here on its own, but the order needs deciding with @NotRexed.
  4. Still no Windows run of the update offer, the junction check or the hos_fopen_write Windows branch. The 414 local checks are a good sign, but they don't cover those.

CI: the new head's runs are only queued so far. Main now has #2397, so the Windows test_native_astc_texture_count.py failure from last time should be gone; worth a look once they finish.

Nothing new from the merge. Point 1 and a Windows run are what I'd still want before merge.

@hckrman101

Copy link
Copy Markdown
Collaborator

This now conflicts with main in builder/linux/hookshot_builder.py, host/hos_main.c, host/hos_update.c, nx2/nup.py, tests/test_bound_keys.py. Could you merge main into the branch (or rebase) when you get a chance? I'll take another look once it's up to date.

hckrman101 pushed a commit that referenced this pull request Nov 12, 2026
)

**What this is for:** contributors rebuilding from source should not
spend another minute drawing the same button prompts. **What is
measured:** on an Intel Core i9-13900K running Arch Linux, the finish
stage at main `c544828f` took 67.707 and 66.898 seconds; two unchanged
runs took 0.603 and 0.651 seconds, and the final head took 0.616
seconds, clearing the run-time files as well. Other work was running
throughout. **Not measured:** Windows, a packaged Builder update, a
complete compile/lift, or gameplay performance.

Refs #2636.

## Problem

`nx2/make_game.py` removes the game folder's `prompts` directory and
runs `prompt_assets.py` on every build when fontTools is available. Most
source edits do not change anything those assets depend on.

This avoids drawing unchanged assets; it does not make the drawing
itself faster. #2636's A1 plans to measure the finish stage, but its
A1–A10 items do not already propose this prompt cache. It complements
#2674 and #2675, which address unnecessary retranslation rather than
prompt generation.

## What changed

- **`nx2/make_game.py`:** source-checkout builds request incremental
prompt generation. Packaged builders still regenerate every time, using
the finish stage's existing launcher distinction. `RX_RELEASE` cannot
distinguish them: make_game sets it for source builds too. Missing
fontTools still removes the replacement prompts, keeping the existing
fallback to the game's icons.
- **`nx2/prompt_assets.py`:** fingerprint the inputs before generating,
draw into a fresh sibling directory, and record the successful outputs
only if a second fingerprint still matches. Reuse requires both matching
inputs and every recorded output's size and SHA-256. **A failed
generation still removes the prompts, as before**, so the game shows its
own icons. An interrupted builder can leave the previous complete set
without a stamp; the next build regenerates it. The drawing, encoding
and asset formats are unchanged.
- **`tests/test_prompt_skip.py`:** game-free fixtures run the actual
finish-stage prompt call and production cache/publication functions,
with small authored files and a stand-in for expensive drawing.
- **`docs/development.md`:** explain reuse, its limits, and
`HOOKSHOT_FORCE_PROMPTS=1`. The force switch follows `env_on`: unset,
empty, `0`, `false`, `off` and `no` are off, ignoring case and
surrounding spaces.

### What prevents a wrong skip

The input record covers:

- The four prompt source modules, including controller, keyboard and
binding tables.
- The game's icon font and optional bold label font, compression
dictionary, common texture/layout archives, and matching resource-size
table names and contents.
- The repository's controller and keyboard fonts, and added, changed or
removed PNG overrides.
- Python/platform; fontTools, Pillow, ASTC encoder, texture decoder,
NumPy, zstandard and archspec availability and versions; Pillow's native
library versions and the selected ASTC backend.
- Generation parameters and relevant library switches, including
`SOURCE_DATE_EPOCH`, `FREETYPE_PROPERTIES` and the encoder's custom CPU
definitions.

Languages and saved bindings are not build inputs here: the generator
uses its source tables, and the runtime applies the selected controls.

The output record includes files **and directories**: the keyboard
directory itself is a runtime marker. Legitimately absent optional
outputs stay absent; a missing, truncated or same-size-corrupted
recorded file forces regeneration. The runtime's own compressed copies
and control-specific keys are excluded before hashing, not mistaken for
generator output. **The files the game derives at run time are still
cleared by every build**, including an unchanged hit: their own keys do
not cover changes to `host/prompts.c`. Only the known runtime filenames
are removed; if one cannot be cleared, the build regenerates instead of
keeping on doubt.

An unreadable input cannot authorize reuse. The log gives the reason,
even if a forced regeneration subsequently fails. A source edited during
generation leaves no stamp, addressing the stale-input certification
problem discussed in [#2674's
review](#2674 (comment)).

## How it was tested

2026-11-12; Arch Linux, kernel `7.2.15-1-omarchy`, x86-64, Intel Core
i9-13900K, 32 logical CPUs, Python 3.12.16. Base `c544828ff`; final head
`2ec89de8`. No game or GPU program was launched for these checks.

### Finish-stage measurements

These execute the real finish-stage statements through completion,
including binary/image copies, the unchanged shader-list check,
pipeline-list work and prompt generation, in a disposable copy of an
existing built game folder. Compilation, translation and shader
preparation are not included. They are **not whole rebuild times**.

All optional prompt packages were present: fontTools 4.68.0, Pillow
13.0.0, astc-encoder-py 0.1.12, texture2ddecoder 1.0.6, archspec 0.2.7,
NumPy 2.5.4 and zstandard 0.25.0.

| Run | Source | Finish time | 1-minute load before → after | Top other
work |
|---|---|---:|---:|---|
| Before 1 | `c544828f` | 67.707 s | 7.13 → 7.40 | Existing game 316 →
315% CPU; file indexing 66 → 79%; relay check also active |
| Before 2 | `c544828f` | 66.898 s | 7.06 → 8.24 | Existing game 315 →
318%; terminal process about 41% |
| First generation with cache | `63d5c965` | 66.400 s | 8.24 → 8.19 |
Existing game 318 → 320%; terminal process about 42% |
| Unchanged 1 | `63d5c965` | 0.603 s | 8.19 → 8.19 | Existing game 320%;
terminal process about 42% |
| Unchanged 2 | `63d5c965` | 0.651 s | 8.19 → 8.19 | Existing game 320%;
terminal process about 42% |
| Changed table source | `681208dd` | 63.147 s | 6.99 → 6.95 | Existing
game 320 → 321%; terminal process about 42% |
| Regenerate, final head | `2ec89de8` | 65.007 s | 5.77 → 5.86 |
Existing game 317 → 316%; terminal process about 43% |
| Unchanged, final head, with run-time files present | `2ec89de8` |
0.616 s | 5.79 → 5.79 | Existing game 316%; terminal process about 43% |

Other work was running; none was a quiet measurement. CPU percentages
are lifetime averages from snapshots, not interval measurements. The
first generation is included to show that this is a skip, not a faster
drawing algorithm.

The unchanged runs print:

```text
prompts: unchanged, kept
```

Adding a harmless comment to the button-table source forces a fresh
generation:

```text
prompts: regenerated (table changed)
```

The comment was removed afterwards. A timestamp-only touch does not
regenerate: the key checks content. An additional run on the final head
verifies an unchanged hit with all six runtime-derived files present:
all six are removed, while the 16 generated originals and the stamp
retain their sizes and SHA-256 values. The focused fixture also compares
the retained originals byte for byte. The final timing rows above
include that cleanup.

**Asset parity:** all 16 regenerated files, 274,704,680 bytes, compare
byte for byte with a full generation by main `c544828f`. Only the new
stamp is excluded. Both sides used the same `SOURCE_DATE_EPOCH` because
fontTools otherwise writes the current time into the OpenType header.
This comparison setting is not introduced into normal builds. A
development cache hit retains the earlier font timestamp; packaged
builds retain their old full-generation behavior.

### Game-free checks

- **23 focused tests pass** on the final change. They cover unchanged
reuse, every input kind, package versions/presence, switch/parameter
changes, missing/truncated/same-size-corrupted output, empty
directories, clearing runtime derivatives while retaining byte-identical
originals, regeneration when runtime cleanup fails, interruption,
mid-generation edits, unreadable input, force-switch parsing, incomplete
output, publication failure, and the packaged/no-fontTools paths. A
non-zero generator result removes prompts for both source and packaged
builds; interruption before that result leaves the old set unstamped.
- The same unchanged-build test against main's finish stage fails
behaviorally: `2 != 1: unchanged finish must not draw the prompts
twice`.
- Removing each of **18 guards/key components** in memory makes its
corresponding behavior test fail on the final change. Three later
corrections also fail against the earlier version of this patch:
returned failure retained old prompts in both build paths, runtime files
survived a hit, and an uncleared runtime file did not force
regeneration. The unreadable-input diagnostic has a separate
before-fails/after-passes proof.
- The whole suite one commit earlier, at `f20ea58b` (before a failed
generation was made to remove the prompts again and the run-time files
to be cleared), `python -I -B tests/run_all.py --jobs 8`:

  ```text
  472 passed, 0 failed, 20 skipped in 133 s (linux, 8 at once)
  ```

Load was 7.37 → 19.48. The first full run, at 32 checks at once, was
**471 passed, 1 failed, 20 skipped**: a clang++ child compiling
`texture_pass.cpp` for `nx2/test_depth_sample_types.py` died with
SIGKILL. That fixture and its input trees are unchanged from main. No
kernel OOM line was found, so the cause is not established. The complete
run with eight workers passes, including that check. The failed run is
retained, not counted as a pass. On the final head `2ec89de8` the
focused checks, the baseline failure and all guard mutations were rerun,
and the whole suite once more with eight workers: **471 passed, 1
failed, 20 skipped in 136 s** (load 11.14 → 22.62). The one failure is
`tests/test_release_scripts.py`,
`test_nightly_two_runs_at_once_end_at_the_newer_commit`, the
intermittent race reported in #2626; this change touches none of its
files.
- No core dumps were recorded. Nothing generated from the game is in
this change.

## Not tested

- Windows filesystem behavior or a Windows build.
- The packaged Windows Builder or Linux AppImage build/update end to
end. Their always-regenerate selection is covered by a game-free test
only.
- A full compile/lift or a new gameplay run, other CPUs, or
gameplay/frame-rate benefits.

No screenshots: the regenerated asset bytes match, and no visual change
is intended.

## Risks

The input list must grow when the generator gains new file dependencies
or outputs outside the prompt directory. A regression flags new local
module imports, but it does not replace reviewing new file reads.
Generation temporarily needs room for both old and new assets.
Publication uses completed directories and rollback on a rename error;
interruption cannot create a stamped half-set, though a process killed
between the two directory renames may require regeneration on the next
build.

## Overlaps

The open-PR scan found 47 PRs touching one of these files. The actual
conflict hunks were read:

- **#2151:** adjacent item-button generation conflicts with the
finish-stage prompt call/error branch. Preserve both steps and account
for added prompt dependencies.
- **#2261:** substantive overlap. It adds `--wheel-icons`, another input
folder and output outside `prompts`. Keep that work outside this skip or
extend the input/output proof and staging; accepting both hunks
mechanically is not enough.
- #1872, #1958, #2304, #2317, #2430, #2465, #2474, #2478, #2608, #2673,
#2675 and #2686 share code files but merged cleanly in the scan. That is
not proof that any newly added generator dependency is covered.

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

Co-authored-by: GPT-6 Astra <noreply@openai.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.

2 participants