docs: a proposal for a faster development loop #2636

Open
jordanella wants to merge 2 commits from jordanella/docs/dev-loop-proposal into main
jordanella commented 2026-11-12 03:38:55 +00:00 (Migrated from github.com)

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

  1. What agents may read: never work/, single excerpts on request, or local models only. scripts/cpu_categories.py already 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.
  2. Where the entry point lives: nx2/dev.py (proposed) or scripts/dev.py.
  3. Where scenario files live: scenarios/ or tests/scenarios/.
  4. Dev warnings and clang-tidy in CI: advisory or failing, given a baseline.
  5. Whether a dev-only split of the host out of the exe is acceptable at all, if the measurements call for it.
  6. Whether to make players' start-up update check asynchronous: a player-facing change, decided on its own.

What it plans, as small PRs behind a week of measurement (Phase 0):

  • Build graph. Skip links whose inputs haven't changed. Use depfiles instead of header blobs. Make the lift key hash only the environment the lift reads. Don't relift after a compile error in a tracked source. Add compile_commands.json, and one nx2/dev.py whose status names why anything rebuilds.
  • Middle loop. A relaunch command 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.
  • Testing. run_all.py --changed, one shared source-cutting helper in place of about 165 copies of block(), a sanitizer lane, scenario files on hookshot_ctl.py, and run summaries that can be compared.
  • Diagnostics. One list of switches with a check, a header on captures, memory counters, and warnings and clang-tidy on our own code.
  • Docs and code maps. A docs/README.md index, subsystem maps, every generated file naming its generator, a dev.py where repo 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 against main where the plan leans on it. #2324's kept lift and #2518's COMPILE_ONLY_ENV were 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 reads docs/) passes. I didn't run the full run_all.py locally, since nothing it checks changed; CI runs it. No screenshots: nothing changes on screen.

🤖 Generated with Claude Code

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](https://github.com/jordanella/Hookshot/blob/docs/dev-loop-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): 1. What agents may read: never `work/`, single excerpts on request, or local models only. `scripts/cpu_categories.py` already 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. 2. Where the entry point lives: `nx2/dev.py` (proposed) or `scripts/dev.py`. 3. Where scenario files live: `scenarios/` or `tests/scenarios/`. 4. Dev warnings and clang-tidy in CI: advisory or failing, given a baseline. 5. Whether a dev-only split of the host out of the exe is acceptable at all, if the measurements call for it. 6. Whether to make players' start-up update check asynchronous: a player-facing change, decided on its own. **What it plans**, as small PRs behind a week of measurement (Phase 0): - **Build graph.** Skip links whose inputs haven't changed. Use depfiles instead of header blobs. Make the lift key hash only the environment the lift reads. Don't relift after a compile error in a tracked source. Add `compile_commands.json`, and one `nx2/dev.py` whose `status` names why anything rebuilds. - **Middle loop.** A `relaunch` command 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. - **Testing.** `run_all.py --changed`, one shared source-cutting helper in place of about 165 copies of `block()`, a sanitizer lane, scenario files on `hookshot_ctl.py`, and run summaries that can be compared. - **Diagnostics.** One list of switches with a check, a header on captures, memory counters, and warnings and clang-tidy on our own code. - **Docs and code maps.** A `docs/README.md` index, subsystem maps, every generated file naming its generator, a `dev.py where` repo 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 against `main` where the plan leans on it. #2324's kept lift and #2518's `COMPILE_ONLY_ENV` were 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 reads `docs/`) passes. I didn't run the full `run_all.py` locally, since nothing it checks changed; CI runs it. No screenshots: nothing changes on screen. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
hckrman101 (Migrated from github.com) reviewed 2026-11-12 03:49:21 +00:00
hckrman101 (Migrated from github.com) left a comment

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

  • One content-addressed object store shared by every checkout/worktree. Objects are named by source + flags + headers (as object_key already 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.
    • Fresh worktree, cold store: ~720 s (renderer 81 s, lift 107 s, compile 515 s).
    • The same main in a second fresh worktree, warm store: 18 s, 0 of 258 files compiled, lift reused, both links included.
    • One gpu/vk header + one .cpp changed: 26 s (16 renderer objects, because of the header blob §2.2 describes, plus 1 other).
    • Outputs byte-identical either way.
  • A lift snapshot cache. The finished lift is keyed by lift_key minus 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, so make_game skips the lift.
  • A renderer-only path (RX_VK_ONLY, ~30 s cold, seconds warm) dropped into a game folder, with an ABI check first (below).
  • A build/GPU queue: builds and game runs take tickets (exclusive, shared, timing = exclusive with no builds), so fps measurements never run beside a compile.
  • In-session A/B runs: one game session with a file-based switch toggled every N seconds, so both halves see the same scenes and load, plus scripted live input and a fresh copy of a clean save per run.

What that means for the plan

  • A2 misses the paths. lift_key hashes env, and make_game's env carries OOT_WORK, OOT_EXEFS, OOT_ROMFS and OOT_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_ENV should leave out path-valued variables (or hash what they point to), not list OOT_EXEFS. Dropping the paths is what makes our snapshot cache work. (inline)
  • The biggest lever isn't in the doc: an object store shared across work folders. OBJ_CACHE is <work>/objcache, so every new worktree pays the full ~720 s even though flag_text already makes the keys folder-independent. An opt-in shared store (a switch pointing objcache elsewhere, 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:
    • the git-ignored inputs in 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 from ROOT/work, which is empty in a fresh worktree);
    • the PGO profile's path ends up in the hot pieces' keys, so it has to sit at a content-addressed path to be shared.
  • A4 and C2: data point for Phase 0. With nothing to compile, the whole build including both links was 18 s on Linux with lld, so the exe link there is seconds, not minutes. A4 is still worth doing (it's S), but on this evidence I'd expect C2 to be unnecessary; it's worth measuring the link on Windows before designing anything.
  • A6 is validated: the one-header edit above rebuilt 16 renderer objects where a depfile would rebuild its includers.
  • B1's staged swap needs an ABI check, not just MKWVK_API_VERSION. The renderer .so also takes symbols from the exe. An exe and a .so built from different trees fail at load, before mkwvk_init's version check runs, and to the user it looks like the game silently never opens a window. We check nm -D --undefined-only hookshot_vk.so | c++filt | grep mkwvk is empty before dropping it in. dev.py should do the equivalent before renaming .new over the DLL, and load_vulkan's failure line on Linux should carry dlerror()'s text. (inline)
  • D4/D5: compare inside a session, not between sessions. For a flicker-type metric, counts varied 5–20x from one session to the next on the same build, so --compare against 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.
  • Missing from D5/§12: runs must not share the machine with a build. fps and frame-time numbers taken beside a compile are worthless, and with dev.py run --watch rebuilding on save that will happen by default. A simple lock (a build waits for a timing run, a timing run waits for builds) in dev.py covers 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

  1. What agents may read: strict. Agents never read 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 how scripts/cpu_categories.py already 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)
  2. Warnings and clang-tidy: advisory to start. Everything builds with -Wno-everything today, so the first baseline will be large; let it settle before anything fails CI.
  3. Async update check: agreed it's its own player-facing PR with a worklist row, measured on a slow network, not part of this work.

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.

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): - **One content-addressed object store shared by every checkout/worktree.** Objects are named by source + flags + headers (as `object_key` already 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. - Fresh worktree, cold store: ~720 s (renderer 81 s, lift 107 s, compile 515 s). - The same `main` in a second fresh worktree, warm store: **18 s**, 0 of 258 files compiled, lift reused, both links included. - One `gpu/vk` header + one `.cpp` changed: 26 s (16 renderer objects, because of the header blob §2.2 describes, plus 1 other). - Outputs byte-identical either way. - **A lift snapshot cache.** The finished lift is keyed by `lift_key` minus 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, so `make_game` skips the lift. - **A renderer-only path** (`RX_VK_ONLY`, ~30 s cold, seconds warm) dropped into a game folder, with an ABI check first (below). - **A build/GPU queue:** builds and game runs take tickets (exclusive, shared, timing = exclusive with no builds), so fps measurements never run beside a compile. - **In-session A/B runs:** one game session with a file-based switch toggled every N seconds, so both halves see the same scenes and load, plus scripted live input and a fresh copy of a clean save per run. **What that means for the plan** - **A2 misses the paths.** `lift_key` hashes `env`, and make_game's `env` carries `OOT_WORK`, `OOT_EXEFS`, `OOT_ROMFS` and `OOT_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_ENV` should leave out path-valued variables (or hash what they point to), not list `OOT_EXEFS`. Dropping the paths is what makes our snapshot cache work. (inline) - **The biggest lever isn't in the doc: an object store shared across work folders.** `OBJ_CACHE` is `<work>/objcache`, so every new worktree pays the full ~720 s even though `flag_text` already makes the keys folder-independent. An opt-in shared store (a switch pointing `objcache` elsewhere, 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: - the git-ignored inputs in `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 from `ROOT/work`, which is empty in a fresh worktree); - the PGO profile's path ends up in the hot pieces' keys, so it has to sit at a content-addressed path to be shared. - **A4 and C2: data point for Phase 0.** With nothing to compile, the whole build including both links was 18 s on Linux with lld, so the exe link there is seconds, not minutes. A4 is still worth doing (it's S), but on this evidence I'd expect C2 to be unnecessary; it's worth measuring the link on Windows before designing anything. - **A6 is validated:** the one-header edit above rebuilt 16 renderer objects where a depfile would rebuild its includers. - **B1's staged swap needs an ABI check, not just `MKWVK_API_VERSION`.** The renderer `.so` also takes symbols from the exe. An exe and a `.so` built from different trees fail at load, before `mkwvk_init`'s version check runs, and to the user it looks like the game silently never opens a window. We check `nm -D --undefined-only hookshot_vk.so | c++filt | grep mkwvk` is empty before dropping it in. `dev.py` should do the equivalent before renaming `.new` over the DLL, and `load_vulkan`'s failure line on Linux should carry `dlerror()`'s text. (inline) - **D4/D5: compare inside a session, not between sessions.** For a flicker-type metric, counts varied 5–20x from one session to the next on the same build, so `--compare` against 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. - **Missing from D5/§12: runs must not share the machine with a build.** fps and frame-time numbers taken beside a compile are worthless, and with `dev.py run --watch` rebuilding on save that will happen by default. A simple lock (a build waits for a timing run, a timing run waits for builds) in `dev.py` covers 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** 1. **What agents may read: strict.** Agents never read `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 how `scripts/cpu_categories.py` already 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) 4. **Warnings and clang-tidy: advisory to start.** Everything builds with `-Wno-everything` today, so the first baseline will be large; let it settle before anything fails CI. 6. **Async update check:** agreed it's its own player-facing PR with a worklist row, measured on a slow network, not part of this work. 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 loop
hckrman101 (Migrated from github.com) commented 2026-11-12 03:49:21 +00:00

My 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.py already keeps local. It can live in a local file outside the repo that a local tool reads with --local, but not in docs/. That also settles F4: the in-repo table goes, and a local-only lookup is fine.

My 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.py` already keeps local. It can live in a local file outside the repo that a local tool reads with `--local`, but not in `docs/`. That also settles F4: the in-repo table goes, and a local-only lookup is fine.
hckrman101 (Migrated from github.com) commented 2026-11-12 03:49:21 +00:00

OOT_EXEFS is a path. lift_key already hashes the exefs files' contents, and today it also hashes env's OOT_WORK / OOT_EXEFS / OOT_ROMFS / OOT_DUMP values, so two checkouts of the same tree (or a moved work folder) never share a lift. I'd make LIFT_ENV cover 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.

`OOT_EXEFS` is a path. `lift_key` already hashes the exefs files' *contents*, and today it also hashes `env`'s `OOT_WORK` / `OOT_EXEFS` / `OOT_ROMFS` / `OOT_DUMP` values, so two checkouts of the same tree (or a moved work folder) never share a lift. I'd make `LIFT_ENV` cover 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.
hckrman101 (Migrated from github.com) commented 2026-11-12 03:49:21 +00:00

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.

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).
hckrman101 (Migrated from github.com) commented 2026-11-12 03:49:21 +00:00

The cache is per work folder (OBJ_CACHE = <work>/objcache), but flag_text already 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 of main from ~720 s to 18 s here, byte-identical. I think it deserves an item under A. It also needs the git-ignored work/ inputs (hot list, hot order, chunk boundaries) seeded into new checkouts, or their object names differ.

The cache is per work folder (`OBJ_CACHE = <work>/objcache`), but `flag_text` already 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 of `main` from ~720 s to 18 s here, byte-identical. I think it deserves an item under A. It also needs the git-ignored `work/` 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`.
hckrman101 (Migrated from github.com) commented 2026-11-12 03:49:21 +00:00

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, before mkwvk_init's version check. On Linux it looks like the game just never opens a window. We check nm -D --undefined-only hookshot_vk.so | c++filt | grep mkwvk is empty first. On Linux load_vulkan's failure log should also carry dlerror() so the missing symbol is named.

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, before `mkwvk_init`'s version check. On Linux it looks like the game just never opens a window. We check `nm -D --undefined-only hookshot_vk.so | c++filt | grep mkwvk` is empty first. On Linux `load_vulkan`'s failure log should also carry `dlerror()` so the missing symbol is named.
hckrman101 commented 2026-11-12 03:54:58 +00:00 (Migrated from github.com)

On the decisions still open after my review:

  1. Entry point: nx2/dev.py. It imports make_game's profiles (the proposed nx2/profiles.py), and scripts/ is for standalone tools.
  2. Scenarios: tests/scenarios/, no new top-level folder. Mark them as needing the game, so run_all.py skips them in CI like the other game-only checks.
  3. Warnings / clang-tidy: advisory to start, as I said. Once the baseline has settled, new findings in changed renderer files can fail CI. That gets its own PR.
  4. Dev-only host split: not now. Linux links in seconds here, so decide it only if Phase 0's Windows link times say the exe link is the bottleneck.
  5. Async update check: yes, as its own player-facing PR with a worklist row, measured on a slow network.

With those settled, the A items can go ahead as small PRs once Phase 0 has numbers.

On the decisions still open after my review: 2. **Entry point:** `nx2/dev.py`. It imports make_game's profiles (the proposed `nx2/profiles.py`), and `scripts/` is for standalone tools. 3. **Scenarios:** `tests/scenarios/`, no new top-level folder. Mark them as needing the game, so `run_all.py` skips them in CI like the other game-only checks. 4. **Warnings / clang-tidy:** advisory to start, as I said. Once the baseline has settled, new findings in changed renderer files can fail CI. That gets its own PR. 5. **Dev-only host split:** not now. Linux links in seconds here, so decide it only if Phase 0's Windows link times say the exe link is the bottleneck. 6. **Async update check:** yes, as its own player-facing PR with a worklist row, measured on a slow network. With those settled, the A items can go ahead as small PRs once Phase 0 has numbers.
jordanella (Migrated from github.com) reviewed 2026-11-12 04:12:15 +00:00
@ -0,0 +1,910 @@
# Proposal: a faster development loop
jordanella (Migrated from github.com) commented 2026-11-12 04:12:15 +00:00

Agreed; it's decision 1 now. F4 is local lookups only: dev.py fn --local and oot_annotate.py --map read a map from a path outside the repo (HOOKSHOT_GAME_MAP), agents don't run them, and nothing in docs/ lists game functions.

Agreed; it's decision 1 now. F4 is local lookups only: `dev.py fn --local` and `oot_annotate.py --map` read a map from a path outside the repo (`HOOKSHOT_GAME_MAP`), agents don't run them, and nothing in `docs/` lists game functions.
jordanella (Migrated from github.com) reviewed 2026-11-12 04:12:17 +00:00
@ -0,0 +1,910 @@
# Proposal: a faster development loop
jordanella (Migrated from github.com) commented 2026-11-12 04:12:17 +00:00

Right, the paths only add a difference. A2 now keeps OOT_WORK/OOT_EXEFS/OOT_ROMFS/OOT_DUMP out of the key and hashes what they point to. LIFT_ENV lists only variables whose values change the lifted C, and the check accepts a variable the lift reads only if it's in LIFT_ENV or is a path whose target is hashed. Your snapshot cache is in as the optional follow-on.

Right, the paths only add a difference. A2 now keeps `OOT_WORK`/`OOT_EXEFS`/`OOT_ROMFS`/`OOT_DUMP` out of the key and hashes what they point to. `LIFT_ENV` lists only variables whose values change the lifted C, and the check accepts a variable the lift reads only if it's in `LIFT_ENV` or is a path whose target is hashed. Your snapshot cache is in as the optional follow-on.
jordanella (Migrated from github.com) reviewed 2026-11-12 04:12:19 +00:00
@ -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`.
jordanella (Migrated from github.com) commented 2026-11-12 04:12:19 +00:00

Added. Before the rename, dev.py checks every undefined non-runtime symbol in the .new file against the running exe's exports, the pairing check_vk_symbols can't see at build time, and keeps the old DLL if one is missing, naming it. I also found that the POSIX LoadLibraryA shim sets ERROR_FILE_NOT_FOUND on any dlopen failure and drops dlerror()'s text, so the fix goes there, and load_vulkan prints it.

Added. Before the rename, `dev.py` checks every undefined non-runtime symbol in the `.new` file against the running exe's exports, the pairing `check_vk_symbols` can't see at build time, and keeps the old DLL if one is missing, naming it. I also found that the POSIX `LoadLibraryA` shim sets `ERROR_FILE_NOT_FOUND` on any `dlopen` failure and drops `dlerror()`'s text, so the fix goes there, and `load_vulkan` prints it.
jordanella (Migrated from github.com) reviewed 2026-11-12 04:12:20 +00:00
@ -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).
jordanella (Migrated from github.com) commented 2026-11-12 04:12:20 +00:00

Added as A10, in Phase 1, with both catches and your numbers. Happy for it to be your wrapper.

Added as A10, in Phase 1, with both catches and your numbers. Happy for it to be your wrapper.
jordanella (Migrated from github.com) reviewed 2026-11-12 04:12:22 +00:00
@ -0,0 +1,910 @@
# Proposal: a faster development loop
jordanella (Migrated from github.com) commented 2026-11-12 04:12:22 +00:00

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

Agreed. 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.
Owner

@jordanella commented on GitHub · 2026-11-12 04:12 UTC · original

Thanks for this. It's exactly the data Phase 0 was missing. Pushed 3c32ad62:

  • Decisions, recorded at the top as you settled them:
    1. Strict, including the address table.
    2. nx2/dev.py.
    3. tests/scenarios/, marked as needing the game.
    4. Advisory to start; a later PR of its own makes new findings in changed renderer files fail CI.
    5. Not now, pending Windows link times.
    6. Its own PR with a worklist row.
  • New A10: the opt-in object store shared across work folders. It has both catches (seeding hot_funcs.txt/hot_order.txt/chunks.txt, and the PGO profile at a content-addressed path) and your numbers, credited to your setup.
  • New D6: the lock so timing runs never share the machine with a build.
  • A2, B1, D4/D5: changed as your inline comments asked (replies there). A4/A6/C2: your Linux data points are in; C2 is "not now".
  • The length: agreed. §2 stays until F1's subsystem maps exist, then moves into them, and the proposal keeps only the plan. The status line says so.

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.

> [@jordanella](https://github.com/jordanella) commented on GitHub · 2026-11-12 04:12 UTC · [original](https://github.com/NotRexed/Hookshot/pull/2636#issuecomment-6814271718) Thanks for this. It's exactly the data Phase 0 was missing. Pushed 3c32ad62: - **Decisions**, recorded at the top as you settled them: 1. Strict, including the address table. 2. `nx2/dev.py`. 3. `tests/scenarios/`, marked as needing the game. 4. Advisory to start; a later PR of its own makes new findings in changed renderer files fail CI. 5. Not now, pending Windows link times. 6. Its own PR with a worklist row. - **New A10:** the opt-in object store shared across work folders. It has both catches (seeding `hot_funcs.txt`/`hot_order.txt`/`chunks.txt`, and the PGO profile at a content-addressed path) and your numbers, credited to your setup. - **New D6:** the lock so timing runs never share the machine with a build. - **A2, B1, D4/D5:** changed as your inline comments asked (replies there). **A4/A6/C2:** your Linux data points are in; C2 is "not now". - **The length:** agreed. §2 stays until F1's subsystem maps exist, then moves into them, and the proposal keeps only the plan. The status line says so. 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. <!-- github-sync:c:6814271718 -->
Owner

@jordanella commented on GitHub · 2026-11-12 05:49 UTC · original

Phase 0 on Windows, and the two fixes it turned up.

The setup: Ryzen 9 3900X (24 threads), 64 GB, NVMe work folder, main at 0ec31ff6, llvm-mingw clang 23.1.5, a work folder kept between builds (--work = the checkout's work/), --no-prepare-shaders. The game folder was on a hard drive, which only slows the read stage.

Build Total Notes
Clean (new worktree) 2,265 s lift 331 s (of it the emit: 170.5 s in 1 process), compile 1,282 s at 24 jobs, link 3 s (440 MB exe) + <1 s (DLL); the other ~650 s is reading the .nup onto the hard drive and finishing the game folder
No-op, the next build 319 s translated the game again: #2674
One gpu/vk/vk_draw.cpp edit 33 s compile 18 s, links 3 s
One host/hos.h edit 26 s compile 11 s (the host blob, as §2.2 says)
A typo in host/hos_update.c fails in 16 s
The build after the typo 301 s translated the game again: #2675

What follows from these numbers:

  • Windows links in seconds, as Linux does, so C2 stays dropped, and A4 is worth only the 3 s it saves. I've put the fixes first instead.
  • #2674: the lift key hashes 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.
  • #2675 (A7): a compile error in a repository source no longer drops the translation. With it: the build after the typo takes 21 s instead of 301 s.
  • A3: the emit is 145–170 s of a 280–330 s lift on Windows (#2324 measured 56–84 s on Linux). Since #2324, that's paid only when the lift really changes.
  • A1: a typical edit's ~10 s outside compile and link isn't broken down by today's output. That's next.

All numbers are from one PC, read off make_game.py's own lines (build_times.json, the linked ... (Ns) and compiled in lines); the logs stay local.

> [@jordanella](https://github.com/jordanella) commented on GitHub · 2026-11-12 05:49 UTC · [original](https://github.com/NotRexed/Hookshot/pull/2636#issuecomment-6815837464) Phase 0 on Windows, and the two fixes it turned up. **The setup:** Ryzen 9 3900X (24 threads), 64 GB, NVMe work folder, `main` at 0ec31ff6, llvm-mingw clang 23.1.5, a work folder kept between builds (`--work` = the checkout's `work/`), `--no-prepare-shaders`. The game folder was on a hard drive, which only slows the read stage. | Build | Total | Notes | |---|---|---| | Clean (new worktree) | 2,265 s | lift 331 s (of it **the emit: 170.5 s in 1 process**), compile 1,282 s at 24 jobs, link **3 s** (440 MB exe) + **<1 s** (DLL); the other ~650 s is reading the .nup onto the hard drive and finishing the game folder | | No-op, the next build | **319 s** | translated the game again: **#2674** | | One `gpu/vk/vk_draw.cpp` edit | 33 s | compile 18 s, links 3 s | | One `host/hos.h` edit | 26 s | compile 11 s (the host blob, as §2.2 says) | | A typo in `host/hos_update.c` | fails in 16 s | | | The build after the typo | **301 s** | translated the game again: **#2675** | **What follows from these numbers:** - **Windows links in seconds, as Linux does,** so C2 stays dropped, and A4 is worth only the 3 s it saves. I've put the fixes first instead. - **#2674:** the lift key hashes `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. - **#2675 (A7):** a compile error in a repository source no longer drops the translation. With it: the build after the typo takes 21 s instead of 301 s. - **A3:** the emit is 145–170 s of a 280–330 s lift on Windows (#2324 measured 56–84 s on Linux). Since #2324, that's paid only when the lift really changes. - **A1:** a typical edit's ~10 s outside compile and link isn't broken down by today's output. That's next. All numbers are from one PC, read off `make_game.py`'s own lines (`build_times.json`, the `linked ... (Ns)` and `compiled in` lines); the logs stay local. <!-- github-sync:c:6815837464 -->
Owner

@Jiro131 commented on GitHub · 2026-11-12 10:45 UTC · original

Claiming A3 (the emit in several processes on Windows), with it already working: the PR is #2748.

  • Spawned workers get the lifter's small tables once and the bodies with each run of functions; they translate the words themselves rather than mapping image.bin.
  • On an i9-13900K (32 threads, 64 GB, NVMe), main vs the branch, three runs each: emit and write 64.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.
  • Linux and macOS keep their forked workers.

If you'd rather fold it into your step 4.1, say so and I'll adapt or close it.

> [@Jiro131](https://github.com/Jiro131) commented on GitHub · 2026-11-12 10:45 UTC · [original](https://github.com/NotRexed/Hookshot/pull/2636#issuecomment-6821462236) Claiming **A3** (the emit in several processes on Windows), with it already working: the PR is #2748. - Spawned workers get the lifter's small tables once and the bodies with each run of functions; they translate the words themselves rather than mapping `image.bin`. - On an i9-13900K (32 threads, 64 GB, NVMe), `main` vs the branch, three runs each: `emit and write` 64.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. - Linux and macOS keep their forked workers. If you'd rather fold it into your step 4.1, say so and I'll adapt or close it. <!-- github-sync:c:6821462236 -->
Owner

@mooki000 commented on GitHub · 2026-11-12 12:46 UTC · original

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:

  • D6: the timing lock needs one shared scope across worktrees, not a separate lock in each work folder. Make timing an explicit request; screenshots and correctness assertions alone need not block every build.
  • F5: put the source-only index outside 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.

> [@mooki000](https://github.com/mooki000) commented on GitHub · 2026-11-12 12:46 UTC · [original](https://github.com/NotRexed/Hookshot/pull/2636#issuecomment-6823990648) 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: - **D6:** the timing lock needs one shared scope across worktrees, not a separate lock in each work folder. Make timing an explicit request; screenshots and correctness assertions alone need not block every build. - **F5:** put the source-only index outside `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. <!-- github-sync:c:6823990648 -->
Owner

@jordanella commented on GitHub · 2026-11-12 14:28 UTC · original

Thanks @mooki000, both taken:

  • D6: the lock is the one nx2/kit.py already had, .bench-lock beside the checkout, so every worktree in that folder shares it. What I added (local for now): builds mark themselves beside it while they run, and nx2/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.
  • F5: dev.py where keeps no index at all: it scans the repo's own source (never work/) when asked, in about 3 s, so there is nothing to put anywhere.
  • #2732: noted as the middle-loop approach to build on; B1's relaunch and DLL swap would sit on its packs rather than redo them.

I'll refresh the proposal's status (landed, open, local) when I next push it.

> [@jordanella](https://github.com/jordanella) commented on GitHub · 2026-11-12 14:28 UTC · [original](https://github.com/NotRexed/Hookshot/pull/2636#issuecomment-6826368531) Thanks @mooki000, both taken: - **D6:** the lock is the one `nx2/kit.py` already had, `.bench-lock` beside the checkout, so every worktree in that folder shares it. What I added (local for now): builds mark themselves beside it while they run, and `nx2/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. - **F5:** `dev.py where` keeps no index at all: it scans the repo's own source (never `work/`) when asked, in about 3 s, so there is nothing to put anywhere. - **#2732:** noted as the middle-loop approach to build on; B1's relaunch and DLL swap would sit on its packs rather than redo them. I'll refresh the proposal's status (landed, open, local) when I next push it. <!-- github-sync:c:6826368531 -->
Owner

@Joey-Magnus reviewed on GitHub · 2026-11-12 14:33 UTC · original

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:

  • The status line (line 3) still says "not started". #2674 (A7's lift-key half), #2702 (A5) and #2748 (A3) are merged, and #2675, #2693 and #2706 are open. A short "done / open" list would keep the doc honest.
  • D6 puts the lock "in 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).
  • F5 puts the index in work/index/, while decision 1 and F6 say agents never read work/. The tool meant for agents should keep its source-only index outside work/.

With those fixed I'd be happy to see it merged as the plan of record.

> [@Joey-Magnus](https://github.com/Joey-Magnus) reviewed on GitHub · 2026-11-12 14:33 UTC · [original](https://github.com/NotRexed/Hookshot/pull/2636#pullrequestreview-5876226099) 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: - The status line (line 3) still says "not started". #2674 (A7's lift-key half), #2702 (A5) and #2748 (A3) are merged, and #2675, #2693 and #2706 are open. A short "done / open" list would keep the doc honest. - D6 puts the lock "in `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). - F5 puts the index in `work/index/`, while decision 1 and F6 say agents never read `work/`. The tool meant for agents should keep its source-only index outside `work/`. With those fixed I'd be happy to see it merged as the plan of record. <!-- github-sync:r:5876226099 -->
Owner

@jordanella commented on GitHub · 2026-11-12 16:09 UTC · original

Thanks @Joey-Magnus, all three are in 295a2e89:

  • Status: the top now lists what's merged (#2674, #2702, #2748), what's open (#2675, #2686, #2693, #2706) and what's built and measured but not opened yet.
  • D6: the lock is machine-wide: nx2/kit.py's .bench-lock beside 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.
  • F5: no stored index: dev.py where scans the repository's own source when asked, so nothing goes in work/.
> [@jordanella](https://github.com/jordanella) commented on GitHub · 2026-11-12 16:09 UTC · [original](https://github.com/NotRexed/Hookshot/pull/2636#issuecomment-6828922040) Thanks @Joey-Magnus, all three are in 295a2e89: - **Status:** the top now lists what's merged (#2674, #2702, #2748), what's open (#2675, #2686, #2693, #2706) and what's built and measured but not opened yet. - **D6:** the lock is machine-wide: `nx2/kit.py`'s `.bench-lock` beside 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. - **F5:** no stored index: `dev.py where` scans the repository's own source when asked, so nothing goes in `work/`. <!-- github-sync:c:6828922040 -->
Commenting is not possible because the repository is archived.
No description provided.