Skip to content

feat(dev): test a change against a base, as a pack of what it changes - #2732

Open
klimovich008 wants to merge 20 commits into
NotRexed:mainfrom
klimovich008:feat/dev-packs
Open

klimovich008 wants to merge 20 commits into
NotRexed:mainfrom
klimovich008:feat/dev-packs

Conversation

@klimovich008

@klimovich008 klimovich008 commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

Testing a change in the game means a new full build: a new work folder translates the game again, and a new game folder prepares every shader from cold, 69 minutes in all on an RX 9070 XT. Most of that is the same for every change. This adds a development tool that builds the game once as a base and each change as a small pack over it.

What changed

  • scripts/dev_packs.py (new, development only):
    • base builds a checkout at a commit. --seed copies an older game folder's shader caches first, as an update keeps them.
    • pack builds a change. Commits, uncommitted edits and untracked files all count.
      • Renderer alone (RX_VK_ONLY, seconds): when every changed file is a renderer file the game isn't built from. Those files are found in the checkout from host/'s includes and build.py's dependencies.
      • Otherwise make_game.py, which builds both modules.
    • What a pack keeps:
      • A module when an object in its link list (objs.rsp, vk_objs.rsp) differs from the base's. Bytes can't decide this: nx2/version.py puts a fingerprint of every source into both modules.
      • Both modules when a build script, a non-translator data file under nx2/, or a non-C file changed.
      • Generated files that differ, such as the shader seed and the prompts. Files the base's own build made that the change no longer makes are removed.
    • rt makes a runtime: the base with romfs hard-linked and the other files copied, the packs' files over them. It runs a prepare when the shader translator or the seed changed. Two packs that bring the same file are refused, and so is a pack built on another base.
  • docs/development.md: a new section, Testing a change against a base.
  • AGENTS.md: one bullet pointing agents to it.
  • Nothing players build changes. It calls make_game.py and build.py as they are; no build or CI file is touched.

Testing

  • tests/test_dev_packs.py (no game):

    • the build route, and the renderer files the game reads;
    • that the translator list matches build.py's stamp, and that the version-stamped objects are build.py's;
    • that link-only inputs keep both modules;
    • the lift key's dependence on the folders;
    • generated files that change or are removed, and a clean output folder;
    • a runtime from packs, and every refusal by its own reason.
    • Negative controls: every rule above has a sabotaged variant of the script that fails the test.
  • Ten review rounds with gpt-6.1-sol, every finding fixed; the last found none.

  • End to end on Windows (Ryzen 7 5700X3D, RX 9070 XT), packs over a full build of 57ed3aa:

    Pack Changed Kept Time
    host only host/nvn_native.c hookshot.exe 14 min first (one translation), 48 s later
    host + renderer host/nvdrv.c, vk_memory.cpp both 34 s
    renderer only vk_replay.cpp hookshot_vk.dll 16 s first, 2 s later
    runtime of two packs exe from one, DLL from the other 3 min 51 s

    Each pack's modules are byte-identical to that change's own earlier full build, apart from 2 PE header bytes. The renderer-only route gives the same DLL as make_game.py for the same tree.

  • Seeded base on main 4678050 from 57ed3aa's caches:

    • 52.6 min, against 69 min for 57ed3aa cold.
    • Shader programs: 290 s against 822 s. main changed the translator, so the program cache was rebuilt, but the driver's pipeline cache carried over.
    • Precompile: 258 s against 602 s.
    • Predicted pipelines: 1,790 s against 2,150 s.
  • In the game: a runtime made by rt from two packs over 57ed3aa (the host-only pack's exe, with its new switches off, and the renderer-only pack's DLL):

    • The run: the standard Hyrule scene (32:9, unlocked frame rate, camera turning), 3 runs, RX 9070 XT, Windows 11.
    • Result: the game started and played the scene every time, at 40.4–41.9 fps. 57ed3aa's own runtime measured 39.8 fps on the same test that morning.
    • Not a measurement: n=1 after two warm-ups, and the game clock dipped to 0.94–0.98 in these runs, as it did in the base's own runs that day.
    • No tested-configurations row: this was a development runtime with unmerged changes, not a player build.
  • Not tested: Linux. The module names there are hookshot and hookshot_vk.so, and RX_TARGET=windows uses the Windows names; neither pack nor rt has run there.

Related: the dev-loop proposal (#2636). This covers part of its "middle loop" without changing the build.

🤖 Generated with Claude Code

klimovich008 and others added 11 commits November 12, 2026 10:44
scripts/dev_packs.py builds the game once as a base and each change as a
pack: the game for host/ and nx2/, the renderer alone for gpu/, a prepare
when the shader translator changed, chosen by the changed paths. A runtime
is the base with romfs hard-linked and the packs' modules over it. New
bases seed their shader caches from an older game folder. It calls
make_game.py and build.py as they are; nothing players build changes.

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

A review found that choosing modules by path missed renderer files the game
is built from, non-gpu files the renderer is built from, generated files
(button prompts) and untracked sources. A pack now builds the renderer alone
only for gpu/vk files the game does not read (found from host/'s includes and
build.py's dependencies), and otherwise builds the game and keeps each module
whose linked objects differ from the base's, plus generated files that differ.
Module names follow RX_TARGET.

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

Review round 2: a header change can reach only hos_main.c or vk_menu.cpp, the
objects that hold the version, so any header change now counts them; an nx2/
file other than the translator's, or a non-C file under host/, gpu/ or
third_party/, reaches the modules outside their objects (flags, link options,
the manifest), so both modules are kept; a generated prompt the change no
longer makes is removed from the runtime.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… a clean output folder

Review round 3: a64-kit's toolchain.py is read by the lift and also sets the
link options, so the build's own scripts (build.py and what it imports) now
keep both modules whether or not the lift reads them; the output folder is
cleared but for romfs before each game pack, so an earlier pack's files are not
taken for this one's; any of the base's files outside romfs that the change's
build no longer makes (the shipped pipeline list) is removed from the runtime,
the files the game writes as it runs aside.

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

Review round 4: make_game.py sets build.py's environment (the hot order, the
profile), so a change there keeps both modules; and a played base holds saves
and settings no build makes, so a pack now only removes files the base's own
build made, which `base` records (an adopted base records none).

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

Review round 5: a pack name such as ../game would delete the base, so names
are plain; a base adopted from its own packs' folders would be rebuilt by them,
so that is refused; a base is built only into a new game folder, so a played
folder's saves are never recorded as build outputs. The test writes the map
only where the platform has one (Linux has none).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…played-folder check comes first

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the base's language

Review round 6: a base folder inside a pack folder (or the reverse) is refused, not only the same folder; a
runtime inside the base or a pack folder is refused before anything is copied; the base records its language
(base --language, adopt reads language.txt) and its packs are built in it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s; a launcher layout refused

Review round 7: overlaps() resolves links and junctions (realpath), and folders on two drives never overlap
instead of raising; a game folder with its modules in bin/ (make_game.py's launcher layout) is refused with a
reason rather than failing on a missing module. The link case is tested with a junction where links need a
right Windows has not given.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review round 8: a base whose work or game folder is on another drive than its packs or a runtime failed at
the first hard link; the objects and romfs are copied there instead, as make_game.py does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…uilt on Linux prepares at its first start

Review round 9: RX_TARGET=Windows chose the Linux names; and rt ran hookshot.exe on Linux for a prepare, which
make_game.py leaves to the game's first start on Windows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@klimovich008
klimovich008 marked this pull request as ready for review November 12, 2026 10:49
@klimovich008

Copy link
Copy Markdown
Contributor Author

Thanks, all three are done in c3f666f:

  1. Never leaves the PC. Testing a change against a base now says it plainly: a base, a pack and a runtime are built from the game (the pack's modules have the lifted game linked in, its files are generated from it, a runtime is a whole game folder), so they are never attached, uploaded or sent to a tester or the chat; what you share is the branch, and the tester builds their own. It links CONTRIBUTING.md's rule. The AGENTS.md bullet says the same in its last sentence.
  2. rt and pack.json. Before anything is made, rt now refuses a pack whose files names anything but the two modules, and any generated or deleted path that is absolute or resolves (realpath) outside the runtime. test_guards has four refusal cases: an absolute path, ../x, romfs/../../x, and a module key ../x. Each checks that nothing was made and that the file outside is untouched.
  3. AGENTS.md is down to three lines: the pointer, one shared base agreed before moving, development and A/B only, never send them.

The Linux line stays in the description.

klimovich008 and others added 2 commits November 12, 2026 13:32
…d runtimes never leave the PC

Review (hckrman101): pack.json is a plain file, so rt now refuses a module name
that is not hookshot/hookshot_vk and any generated or deleted path that is
absolute or resolves outside the runtime, before anything is made; the docs say
that bases, packs and runtimes are built from the game and are never sent; the
AGENTS.md bullet is cut to the pointer and the rules agents need.

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

A second review of the guard (sol, on c3f666f) found two ways past it:
- the check ran before the runtime existed, so a link in the base's romfs,
  cloned into the runtime, could carry a deleted path outside it;
- normcase let ../Runtime pass as the runtime in a case-sensitive folder.

in_runtime now judges a pack path by its text alone: relative, not rooted
("/x" is not isabs on Windows since Python 3.13), no "..", not ".". No name
comparison is left to fool. Where each file is written or removed, after the
runtime exists, resolves_inside checks that its folder, links followed, is
the runtime or below it, compared by file identity; a link itself is removed,
never written through.

Tests: the reviewer's cloned-link case (skipped where the account cannot make
a file link, as on Windows without developer mode; CI's Linux runs it),
resolves_inside with a folder link or junction, and in_runtime's text cases.
With resolves_inside disabled the junction case fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…h the base)

Sol on ac5af7b: romfs/foo:evil passed both checks and named a stream of the
hard link romfs/foo shares with the base, so removing or writing it changed
the base too. Pack paths never hold a ':'; in_runtime refuses one. The case
test now runs only where the temporary folder is case-insensitive.

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

Copy link
Copy Markdown
Contributor Author

Extra hardening of point 2 in ac5af7b and 1307585: rt now judges a pack path by its text first (no .., not rooted, no : stream names), then re-checks each destination where it is written, once the runtime exists, by file identity through links, so a link cloned from the base's romfs can't lead a write or delete outside the runtime; tests cover each case.

klimovich008 and others added 3 commits November 12, 2026 15:13
…the runners)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…se at a time

A development base prepares the whole game's ~51,000 predicted pipelines, about
30 of a base's 41-60 minutes, though a test draws a few scenes. base --predict-s N
puts RX_VK_PREDICT_S=N into make_game.py's environment (and rt's re-prepare):
the rest are built in play when first drawn (0, the default, builds them all; a
player's build never uses it). warm plays a runtime with no pack once per --run
command and copies the caches that grew back into the base, so later runtimes
start warm for the tested scenes. base, pack, rt and warm hold
<base>/dev_packs.busy, so a second job on one base is refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review: make_game.py's lift key hashes every RX_ variable, so a budgeted base
now builds with HOOKSHOT_NO_PREPARE=1 and runs the game's preparation itself
with RX_VK_PREDICT_S. warm copies back only plain cache files of the runtime
(never a link), through a new temporary file in the base's game folder.

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

@Joey-Magnus Joey-Magnus left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I merged 1307585 into current main and ran tests/test_dev_packs.py, which passes. The two red jobs are not from this PR. One is test_release_scripts.py (test_nightly_two_runs_at_once_end_at_the_newer_commit), which is flaky. The other is test_pipeline_cache_save.py on Windows part 1, a timing wait on the in-play save. This PR only adds scripts/dev_packs.py, its test and docs, so a re-run should clear both.

hckrman101's points are all addressed. Docs and AGENTS.md now say plainly that bases, packs and runtimes never leave the PC. rt refuses absolute paths, .. paths and module keys other than the two modules, and re-checks each destination by file identity, and test_guards has a refusal case for each. The AGENTS.md bullet is down to three lines. I also read clear_output and the pack rmtree: both only work inside the dev folder, and the pack name is validated before the delete.

The one gap is Linux (hookshot/hookshot_vk.so), which the description already states. That's fine for a development tool.

klimovich008 and others added 3 commits November 12, 2026 16:57
…n the Linux runner)

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>
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