docs: a proposal for a faster development loop #2636
Open
jordanella wants to merge 2 commits from
jordanella/docs/dev-loop-proposal into main
pull from: jordanella/docs/dev-loop-proposal
merge into: MagicBeanDev:main
MagicBeanDev:main
MagicBeanDev:github/pr/2893
MagicBeanDev:github/pr/2890
MagicBeanDev:github/pr/2887
MagicBeanDev:github/pr/2878
MagicBeanDev:github/pr/2875
MagicBeanDev:github/pr/2874
MagicBeanDev:github/pr/2873
MagicBeanDev:github/pr/2871
MagicBeanDev:github/pr/2756
MagicBeanDev:github/pr/2897
MagicBeanDev:github/pr/2896
MagicBeanDev:github/pr/2718
MagicBeanDev:github/pr/2842
MagicBeanDev:github/pr/2691
MagicBeanDev:github/pr/2895
MagicBeanDev:github/pr/2743
MagicBeanDev:github/pr/2765
MagicBeanDev:github/pr/2708
MagicBeanDev:github/pr/2732
MagicBeanDev:github/pr/2755
MagicBeanDev:github/pr/2817
MagicBeanDev:github/pr/2876
MagicBeanDev:release-please--branches--main
MagicBeanDev:release-please--branches--main--release-notes
MagicBeanDev:github/pr/2778
MagicBeanDev:github/pr/2888
MagicBeanDev:github/pr/2866
MagicBeanDev:github/pr/2877
MagicBeanDev:github/pr/2800
MagicBeanDev:github/pr/2829
MagicBeanDev:github/pr/2850
MagicBeanDev:github/pr/2889
MagicBeanDev:github/pr/2882
MagicBeanDev:github/pr/2860
MagicBeanDev:github/pr/2849
MagicBeanDev:github/pr/2886
MagicBeanDev:github/pr/2794
MagicBeanDev:github/pr/2840
MagicBeanDev:github/pr/2830
MagicBeanDev:github/pr/2834
MagicBeanDev:github/pr/2719
MagicBeanDev:github/pr/2742
MagicBeanDev:github/pr/2741
MagicBeanDev:github/pr/2820
MagicBeanDev:github/pr/2771
MagicBeanDev:github/pr/2859
MagicBeanDev:github/pr/2858
MagicBeanDev:github/pr/2823
MagicBeanDev:github/pr/2757
MagicBeanDev:github/pr/2769
MagicBeanDev:github/pr/2844
MagicBeanDev:github/pr/2843
MagicBeanDev:github/pr/2673
MagicBeanDev:github/pr/2855
MagicBeanDev:github/pr/2739
MagicBeanDev:github/pr/2807
MagicBeanDev:github/pr/2851
MagicBeanDev:github/pr/2789
MagicBeanDev:github/pr/2814
MagicBeanDev:github/pr/2818
MagicBeanDev:github/pr/2774
MagicBeanDev:github/pr/2772
MagicBeanDev:github/pr/2750
MagicBeanDev:github/pr/2786
MagicBeanDev:github/pr/2785
MagicBeanDev:github/pr/2782
MagicBeanDev:github/pr/2779
MagicBeanDev:github/pr/2775
MagicBeanDev:github/pr/2706
MagicBeanDev:github/pr/2766
MagicBeanDev:github/pr/2747
MagicBeanDev:github/pr/2759
MagicBeanDev:github/pr/2745
MagicBeanDev:github/pr/2740
MagicBeanDev:github/pr/2733
MagicBeanDev:github/pr/2725
MagicBeanDev:github/pr/2727
MagicBeanDev:github/pr/2728
MagicBeanDev:github/pr/2729
MagicBeanDev:github/pr/2721
MagicBeanDev:github/pr/2722
MagicBeanDev:github/pr/2715
MagicBeanDev:github/pr/2714
MagicBeanDev:github/pr/2707
MagicBeanDev:github/pr/2703
MagicBeanDev:github/pr/2705
MagicBeanDev:github/pr/2697
MagicBeanDev:github/pr/2679
MagicBeanDev:github/pr/2676
MagicBeanDev:github/pr/2669
MagicBeanDev:docs/what-hookshot-connects-to
MagicBeanDev:fix/hdr-encode-test
MagicBeanDev:fix/main-tests
MagicBeanDev:fix/play-cache-files
MagicBeanDev:perf/game-barrier-order
MagicBeanDev:feat/heap-lazy
MagicBeanDev:feat/texture-detail
MagicBeanDev:perf/astc-opaque-bc1
MagicBeanDev:fix/fs-dir-lock
MagicBeanDev:fix/framegen-native-kept
MagicBeanDev:fix/update-no-downgrade
MagicBeanDev:port/k10-gcn-f16-mirrors
MagicBeanDev:fix/framegen-mult-cap
MagicBeanDev:chore/release-as-2.1.1
MagicBeanDev:perf/pcache-load-switch
MagicBeanDev:fix/prepare-progress-log
MagicBeanDev:fix/update-closes-stale-game
MagicBeanDev:fix/hdr-scaled-sampled
MagicBeanDev:perf/first-start-preset
MagicBeanDev:perf/zero-upload
MagicBeanDev:fix/vcore-escape-2s
MagicBeanDev:test/fake-gh-atomic
MagicBeanDev:perf/batch-idle-flush
MagicBeanDev:test/finish-stub-a
MagicBeanDev:chore/record-2.1.0
MagicBeanDev:fix/main-red-tests
MagicBeanDev:fix/test-display-startup-driver-cache
MagicBeanDev:feat/mesh-shader-mode
MagicBeanDev:fix/menu-probes-at-startup
MagicBeanDev:fix/fps-counter-classic
MagicBeanDev:fix/test-log-batch-flake
MagicBeanDev:fix/narrow-screen-hud
MagicBeanDev:ci/prereleases
MagicBeanDev:release/v2.0.15
MagicBeanDev:hotfix/v2.0.15
MagicBeanDev:fix/present-ahead-replay
MagicBeanDev:release/v2.0.13
MagicBeanDev:fix/bracket-paths-2013
MagicBeanDev:fix/hdr-overlays-2013
MagicBeanDev:fix/fps-counter-scale-2013
MagicBeanDev:fix/stick-seen-at-rest
MagicBeanDev:hotfix/menu-fits-screen
MagicBeanDev:fix/f1-always-opens-menu
MagicBeanDev:fix/ini-keep-lines
MagicBeanDev:claude/cranky-pike-7cf247
MagicBeanDev:perf/texture-answer-flat
MagicBeanDev:fix/stamp-test-modules
MagicBeanDev:fix/appimage-fonts
MagicBeanDev:fix/display-awake
MagicBeanDev:release/v2.0.12
MagicBeanDev:fix/discord-windows
MagicBeanDev:rc/v2.0.12-tests
MagicBeanDev:fix/dlss-keep-feature
MagicBeanDev:rc/v2.0.12
MagicBeanDev:button-icons
No reviewers
Labels
Clear labels
accessibility
Barrier affecting people with disabilities
autorelease: pending
autorelease: tagged
bug
Something isn't working
dependencies
Pull requests that update a dependency file
documentation
Improvements or additions to documentation
duplicate
This issue or pull request already exists
enhancement
New feature or request
github_actions
Pull requests that update GitHub Actions code
good first issue
Good for newcomers
help wanted
Extra attention is needed
invalid
This doesn't seem right
question
Further information is requested
testing wanted
wontfix
This will not be worked on
No labels
accessibility
autorelease: pending
autorelease: tagged
bug
dependencies
documentation
duplicate
enhancement
github_actions
good first issue
help wanted
invalid
question
testing wanted
wontfix
Milestone
Clear milestone
No items
No milestone
Assignees
Clear assignees
No assignees
2 participants
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".
No due date set.
Dependencies
No dependencies set
Reference
MagicBeanDev/Hookshot!2636
Loading…
Reference in a new issue
No description provided.
Delete branch "jordanella/docs/dev-loop-proposal"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Today a one-line renderer or host change costs a relink of the 426 MB exe, a cold start and a trip back to the scene, and a stray
RX_*switch or a failed build makes the next build translate the whole game again. This adds a proposal, docs/dev-loop-proposal.md, for making those edits cheap and predictable and the code easier to navigate, without changing what players build or run.Decisions needed (top of the doc, each before its work starts):
work/, single excerpts on request, or local models only.scripts/cpu_categories.pyalready treats "a map of the game's code" as local; the same question covers a hand-written table of game-function addresses in the repo.nx2/dev.py(proposed) orscripts/dev.py.scenarios/ortests/scenarios/.What it plans, as small PRs behind a week of measurement (Phase 0):
compile_commands.json, and onenx2/dev.pywhosestatusnames why anything rebuilds.relaunchcommand on the control bridge (#926) with a staged DLL swap in dev builds, so a renderer edit reaches a scripted scene in one command. Built-in shaders loadable from disk. A live renderer swap is designed but deferred: the renderer has no teardown today.run_all.py --changed, one shared source-cutting helper in place of about 165 copies ofblock(), a sanitizer lane, scenario files onhookshot_ctl.py, and run summaries that can be compared.docs/README.mdindex, subsystem maps, every generated file naming its generator, adev.py whererepo map that tells people and agents which tests cut the code they're changing, and the data boundary for agents written into AGENTS.md.How it scores. Under workflow.md's formula, contributor tooling is S3 with no player reach, so it shouldn't take a worklist row from anyone. The case for it is throughput. The three pieces that do reach players (the full rebuild after a failed link, the blocking update check, the single-process emit on Windows) get their own worklist rows, scored normally.
Where the facts come from. Read from the source at
7297d93d, partly by AI agents, and rechecked againstmainwhere the plan leans on it. #2324's kept lift and #2518'sCOMPILE_ONLY_ENVwere already doing parts of it, and the doc builds on them rather than re-proposing them. Nothing was measured; timings are quoted from the PRs that measured them.Testing. Docs only: one new file, no code changed. Every relative link and anchor in it resolves;
tests/test_hookshot_ctl.py(the one check that readsdocs/) passes. I didn't run the fullrun_all.pylocally, since nothing it checks changed; CI runs it. No screenshots: nothing changes on screen.🤖 Generated with Claude Code
Thanks for writing this up: §2 is the clearest description of the build and the boot path we have, and most of the plan is right. I've checked the parts of §2 the plan leans on against
main(the lift key,forget_failed_build,object_key/flag_text,load_vulkan) and they hold. CI's failures are source checks unrelated to a docs-only change.I run a local dev setup that already does a few of these things, so here are numbers and lessons for Phase 0, plus what I'd change.
What it does, with numbers (Linux,
main, 22 jobs):object_keyalready does), so every workspace produces the same names. Each build gets a private object dir, hard-linked from the store before the build and linked back after (atomic link, every few seconds), so two builds at once never race on the store.mainin a second fresh worktree, warm store: 18 s, 0 of 258 files compiled, lift reused, both links included.gpu/vkheader + one.cppchanged: 26 s (16 renderer objects, because of the header blob §2.2 describes, plus 1 other).lift_keyminus the work/game paths (the lifted C is byte-identical across folders), snapshotted with reflinks, and reflinked into a new work folder with the same key, somake_gameskips the lift.RX_VK_ONLY, ~30 s cold, seconds warm) dropped into a game folder, with an ABI check first (below).What that means for the plan
lift_keyhashesenv, and make_game'senvcarriesOOT_WORK,OOT_EXEFS,OOT_ROMFSandOOT_DUMP. Two checkouts of the same tree, or a moved work folder, therefore never share a lift, although the exefs contents are already hashed.LIFT_ENVshould leave out path-valued variables (or hash what they point to), not listOOT_EXEFS. Dropping the paths is what makes our snapshot cache work. (inline)OBJ_CACHEis<work>/objcache, so every new worktree pays the full ~720 s even thoughflag_textalready makes the keys folder-independent. An opt-in shared store (a switch pointingobjcacheelsewhere, private per-build dir, atomic link-back) took that to 18 s here. That's the worktree-per-PR workflow agents use, and it needs no change for players. Two catches we hit:work/(hot_funcs.txt,hot_order.txt,chunks.txt) must be seeded into a new checkout, or its chunk boundaries and so every lifted object name differ (make_game copies them fromROOT/work, which is empty in a fresh worktree);MKWVK_API_VERSION. The renderer.soalso takes symbols from the exe. An exe and a.sobuilt from different trees fail at load, beforemkwvk_init's version check runs, and to the user it looks like the game silently never opens a window. We checknm -D --undefined-only hookshot_vk.so | c++filt | grep mkwvkis empty before dropping it in.dev.pyshould do the equivalent before renaming.newover the DLL, andload_vulkan's failure line on Linux should carrydlerror()'s text. (inline)--compareagainst the last run would mostly report noise. What worked: an A/B mode in the scenario itself (a file-based switch flipped every N seconds, per-half results), always with a sanity metric next to the target one. We record mean luma: one "fix" showed 0 flashes because it had blown the exposure out. I'd make in-session A/B a first-class scenario option and give D5's summaries a variance/sanity column. A fresh copy of a clean save per run (which D4's per-scenario user folder covers) mattered too.dev.py run --watchrebuilding on save that will happen by default. A simple lock (a build waits for a timing run, a timing run waits for builds) indev.pycovers it.I'm happy to upstream the shared-store wrapper (as an opt-in on
build.py/make_game.py) and the in-session A/B pieces if they'd help. Say if you want them; they'd come as their own PRs under A and D.On the decisions
work/or anything made from the game's files: lifted C,functions.txt,image.bin, linker maps of a build, dumps, captures with game data. That follows directly from CONTRIBUTING.md and is howscripts/cpu_categories.pyalready treats a map of the game's code. The related question gets the same answer: a hand-written table of game-function addresses with names and meanings is a map of the game's code and stays local, not in the repo. F4's in-repo part goes with it; a local-only lookup is fine. (inline)-Wno-everythingtoday, so the first baseline will be large; let it settle before anything fails CI.Across all of it: anything shared or dev-only stays opt-in, and players' paths and the work-folder layout the Builders use don't change. 2, 3 and 5 I'll come back to; for 5 I'd wait for the Windows link numbers.
Nit: the doc is long for a proposal. Once the decisions are in, §2 could move into the subsystem maps (F1) it is meant to seed, and the proposal could keep just the plan.
@ -0,0 +1,910 @@# Proposal: a faster development loopMy position: no. A hand-written table of game-function addresses with names and meanings is a map of the game's code, the same thing
cpu_categories.pyalready keeps local. It can live in a local file outside the repo that a local tool reads with--local, but not indocs/. That also settles F4: the in-repo table goes, and a local-only lookup is fine.OOT_EXEFSis a path.lift_keyalready hashes the exefs files' contents, and today it also hashesenv'sOOT_WORK/OOT_EXEFS/OOT_ROMFS/OOT_DUMPvalues, so two checkouts of the same tree (or a moved work folder) never share a lift. I'd makeLIFT_ENVcover only the variables whose values change the lifted C, and hash path-valued inputs by content. In our setup, keying the lift without the paths gave byte-identical lifted C across folders.Comparing against the last run is weaker than it looks: for a flicker-type metric, counts varied 5–20x between sessions of the same build here. An in-session A/B mode (a switch flipped every N seconds through a file, results per half) was the only comparison that held up. Also record a sanity metric beside the target one; mean luma caught a "fix" whose 0 flashes came from a blown-out exposure.
@ -0,0 +104,4 @@**Compiling** ([`nx2/build.py`](../nx2/build.py)):- **Objects are keyed by content.** `object_key` is a sha1 of the source, the normalised flags and a blob of headers. Objects are hard-linked from `<work>/objcache/<key>.o` when present (a home-grown ccache).The cache is per work folder (
OBJ_CACHE = <work>/objcache), butflag_textalready makes keys folder-independent. An opt-in store shared across work folders, each build using a private dir linked from it and atomically linked back, took a fresh worktree ofmainfrom ~720 s to 18 s here, byte-identical. I think it deserves an item under A. It also needs the git-ignoredwork/inputs (hot list, hot order, chunk boundaries) seeded into new checkouts, or their object names differ.@ -0,0 +458,4 @@The bridge already does the hard part: launch, wait for `in_play` or a map, teleport, screenshots, saves. B1 adds:1. **A `relaunch` command** in the bridge (`host/control.c`) and in `hookshot_ctl.py`. It takes the Restart path the Insert menu uses, and the client reconnects to the new process.2. **A staged DLL swap, dev only** (`HOS_DEV_DLL_SWAP`). `dev.py` builds `hookshot_vk.dll.new`. At start-up, before `load_vulkan`, the host renames it over the old DLL. The existing `HOS_RESTART_OF` wait already orders that after the old process has exited. Release never looks for `.new`.Worth adding an ABI check before the rename. The
.so/DLL also resolves symbols from the exe, so an exe and a renderer built from different trees fail at load, beforemkwvk_init's version check. On Linux it looks like the game just never opens a window. We checknm -D --undefined-only hookshot_vk.so | c++filt | grep mkwvkis empty first. On Linuxload_vulkan's failure log should also carrydlerror()so the missing symbol is named.On the decisions still open after my review:
nx2/dev.py. It imports make_game's profiles (the proposednx2/profiles.py), andscripts/is for standalone tools.tests/scenarios/, no new top-level folder. Mark them as needing the game, sorun_all.pyskips them in CI like the other game-only checks.With those settled, the A items can go ahead as small PRs once Phase 0 has numbers.
@ -0,0 +1,910 @@# Proposal: a faster development loopAgreed; it's decision 1 now. F4 is local lookups only:
dev.py fn --localandoot_annotate.py --mapread a map from a path outside the repo (HOOKSHOT_GAME_MAP), agents don't run them, and nothing indocs/lists game functions.@ -0,0 +1,910 @@# Proposal: a faster development loopRight, the paths only add a difference. A2 now keeps
OOT_WORK/OOT_EXEFS/OOT_ROMFS/OOT_DUMPout of the key and hashes what they point to.LIFT_ENVlists only variables whose values change the lifted C, and the check accepts a variable the lift reads only if it's inLIFT_ENVor is a path whose target is hashed. Your snapshot cache is in as the optional follow-on.@ -0,0 +458,4 @@The bridge already does the hard part: launch, wait for `in_play` or a map, teleport, screenshots, saves. B1 adds:1. **A `relaunch` command** in the bridge (`host/control.c`) and in `hookshot_ctl.py`. It takes the Restart path the Insert menu uses, and the client reconnects to the new process.2. **A staged DLL swap, dev only** (`HOS_DEV_DLL_SWAP`). `dev.py` builds `hookshot_vk.dll.new`. At start-up, before `load_vulkan`, the host renames it over the old DLL. The existing `HOS_RESTART_OF` wait already orders that after the old process has exited. Release never looks for `.new`.Added. Before the rename,
dev.pychecks every undefined non-runtime symbol in the.newfile against the running exe's exports, the pairingcheck_vk_symbolscan't see at build time, and keeps the old DLL if one is missing, naming it. I also found that the POSIXLoadLibraryAshim setsERROR_FILE_NOT_FOUNDon anydlopenfailure and dropsdlerror()'s text, so the fix goes there, andload_vulkanprints it.@ -0,0 +104,4 @@**Compiling** ([`nx2/build.py`](../nx2/build.py)):- **Objects are keyed by content.** `object_key` is a sha1 of the source, the normalised flags and a blob of headers. Objects are hard-linked from `<work>/objcache/<key>.o` when present (a home-grown ccache).Added as A10, in Phase 1, with both catches and your numbers. Happy for it to be your wrapper.
@ -0,0 +1,910 @@# Proposal: a faster development loopAgreed. D4 now has in-session A/B as a first-class option (
ab: <switch> every <seconds>) and a fresh copy of a clean save per run. D5 treats the A/B halves as the comparison to trust, shows each metric's spread when comparing across sessions, and puts a sanity metric (mean luma by default) beside every target metric. That needs the A/B switches to be read live, which is noted against E1.Thanks for this. It's exactly the data Phase 0 was missing. Pushed
3c32ad62:nx2/dev.py.tests/scenarios/, marked as needing the game.hot_funcs.txt/hot_order.txt/chunks.txt, and the PGO profile at a content-addressed path) and your numbers, credited to your setup.Yes please to upstreaming the shared-store wrapper and the in-session A/B pieces as their own PRs.
We'll take Phase 0 on Windows: a no-op rebuild, a one-file renderer edit and a host header edit, with the link and compile times from today's output posted here, then A1 to cover the phases that output doesn't show. The first code PR follows from those numbers: A4 if Windows' links are slow, otherwise the rest of A7.
Phase 0 on Windows, and the two fixes it turned up.
The setup: Ryzen 9 3900X (24 threads), 64 GB, NVMe work folder,
mainat0ec31ff6, llvm-mingw clang 23.1.5, a work folder kept between builds (--work= the checkout'swork/),--no-prepare-shaders. The game folder was on a hard drive, which only slows the read stage.gpu/vk/vk_draw.cppedithost/hos.hedithost/hos_update.cWhat follows from these numbers:
image/hot_pieces.txt, which the first lift in a work folder writes, but the key was taken before that lift. Every new work folder's second build translated again, and so did a player's first update after a new install. With it: the second build takes 14 s instead of 319 s.All numbers are from one PC, read off
make_game.py's own lines (build_times.json, thelinked ... (Ns)andcompiled inlines); the logs stay local.Claiming A3 (the emit in several processes on Windows), with it already working: the PR is #2748.
image.bin.mainvs the branch, three runs each:emit and write64.9–66.5 s → 20.9–21.2 s with 16 processes, the whole standalone lift 117–120 s → 73–74 s. The gen folder is byte-identical.If you'd rather fold it into your step 4.1, say so and I'll adapt or close it.
Thanks @jordanella—Phase 0 has already turned into useful fixes. #2674 and your A5 #2702 are merged, and @Jiro131's A3 #2748 has landed too; #2686, A7 #2675, A1 #2693 and A6 #2706 are still open. #2732 also supplies a concrete base/pack approach to the middle loop, still under review rather than something to design from scratch.
My prompt skip #2698 is merged: the finish stage here went from roughly 67 s to 0.6 s on the i9-13900K/Arch source build, with other work running—not a whole-build or gameplay measurement. Its cleanup/wording follow-up #2710 remains under review, with an open finding being addressed.
Only two concrete proposal details I'd still flag:
work/, so the tool intended for agents is consistent with the strict data boundary recorded in decision 1.The landed/open links above would also let the proposal retire its “not started” status and keep the remaining plan short.
Thanks @mooki000, both taken:
nx2/kit.pyalready had,.bench-lockbeside the checkout, so every worktree in that folder shares it. What I added (local for now): builds mark themselves beside it while they run, andnx2/dev.py bench -- <command>takes the lock and waits for those marks, so a timing run also waits for a build already going. And timing is now explicit: a scenario run (dev.py run) holds the lock only when the scenario says"timing": true; screenshots and assertions alone don't stop builds.dev.py wherekeeps no index at all: it scans the repo's own source (neverwork/) when asked, in about 3 s, so there is nothing to put anywhere.I'll refresh the proposal's status (landed, open, local) when I next push it.
I read the doc against current main and the review thread. hckrman101's points are all reflected: the decisions are recorded at the top, A2 now leaves the path variables out, B1 has the ABI check, A10 and D6 are in, and D4/D5 have the in-session A/B. The red CI is main's own breakage from early Nov 12 and has nothing to do with a docs-only change.
What I'd still change before merging:
work/". That's one lock per worktree, but the point is that a timing run must not share the machine with a build in another checkout, so it needs a machine-wide location (as mooki000 also said).work/index/, while decision 1 and F6 say agents never readwork/. The tool meant for agents should keep its source-only index outsidework/.With those fixed I'd be happy to see it merged as the plan of record.
Thanks @Joey-Magnus, all three are in 295a2e89:
nx2/kit.py's.bench-lockbeside the checkout, which every worktree there shares. A timing run asks for it explicitly and waits for the builds already running; screenshots and assertions alone take nothing.dev.py wherescans the repository's own source when asked, so nothing goes inwork/.