feat(builder): add relocatable Windows portable installations #2367

Merged
Arkex-ai merged 9 commits from feat/windows-portable-builder into main 2026-11-11 21:28:23 +00:00
Arkex-ai commented 2026-11-11 16:13:58 +00:00 (Migrated from github.com)

Summary

Windows Builder could not stay portable on an external SSD: setup, app storage and the game updater selected different locations, while saved absolute paths broke after a move. This adds opt-in --portable setup with one marked, relocatable Builder home. Standard installs retain their existing location.

Fixes #2258. Claim.

Technical changes

  • Share the home across setup, WPF settings/work/SDKs, child builds and self-updates, using process-scoped context.
  • Keep same-volume paths and the game's Builder association relative. Invalid portable associations fail instead of selecting AppData.
  • Stage setup/ZIP repair publication, retain installed parts until success, and reject truncated native paths before file operations.
  • Add source-owned native, .NET, archive and published-app regressions. The startup fixture now compares directory identity, including DOS aliases, and executes the shipped self-contained Release package.

Validation

Current tested head f75c156f, rebased onto main 355453de. A successful standard rebuild now removes the former portable association before publishing standard Builder metadata. The transition regression rejects the previous production source; it also checks repeated standard builds and denied removal preserving old metadata. All eight focused Windows fixtures pass, including native setup/transactions, archive/relocation and actual published self-contained WPF CLI startup. ZIP fallback repair limits and portable setup discovery are documented. Physical removable-drive relocation and a complete public-release/game repair remain untested. Fresh hosted checks are separate exact-head evidence.

Earlier validation evidence and limits

Earlier tested head 6affe74b, rebased onto main 7759a6f3:

  • All eight focused portable checks pass on Windows/Python 3.12, plus the worker fixture's 104 assertions.
  • Published Release app: seven startup/child/relocation/error scenarios, including an owned DOS alias, with installed-runtime discovery isolated. Restoring the old textual path comparison reproduces the hosted false failure; the rejected-home startup mutation is still caught.
  • Fresh hosted checks will run on this head. Earlier hosted failures include the missing env_on fixtures now fixed on main by #2380; the old WPF failure was a short/long path assertion, not selection of a different home.

At 64856415 on b744b1ec: Windows 359 passed / 5 failed / 31 skipped; Linux 362 passed / 2 failed / 31 skipped. Both hit the two missing-env_on fixtures; Windows also retained the documented recorder negative control, NUP link-privilege and compute-guard fixture failures. These are earlier-head results, not current-head green checks.

At 9e3aa287: complete native setup/host-update compilation and self-contained WPF publish passed; ten broken variants were rejected. Full Linux was 351/0/31; Windows349/3/30 had the same failure identities as its exact main baseline.

Tests use authored packages/files, with no game data, downloaded SDKs or built game. Actual removable-media/drive-letter changes, live release download/repair and gameplay remain untested. #2373's new launcher shares setup/storage paths and needs the portable contract preserved whichever lands second.

Portable folders in an isolated preview, captured at 9e3aa287 and cropped without changing pixels

Current-head workflow Result
Game-free source regressions Queued
Linux AppImage Queued
PR title Queued
Vulkan back end with NVIDIA DLSS Queued
Windows builder Queued
## Summary Windows Builder could not stay portable on an external SSD: setup, app storage and the game updater selected different locations, while saved absolute paths broke after a move. This adds opt-in `--portable` setup with one marked, relocatable Builder home. Standard installs retain their existing location. Fixes #2258. [Claim](https://discord.com/channels/1568810484818378804/1569019786766188665/1570073925902270599). ## Technical changes - Share the home across setup, WPF settings/work/SDKs, child builds and self-updates, using process-scoped context. - Keep same-volume paths and the game's Builder association relative. Invalid portable associations fail instead of selecting AppData. - Stage setup/ZIP repair publication, retain installed parts until success, and reject truncated native paths before file operations. - Add source-owned native, .NET, archive and published-app regressions. The startup fixture now compares directory identity, including DOS aliases, and executes the shipped self-contained Release package. ## Validation Current tested head **`f75c156f`**, rebased onto main **`355453de`**. A successful standard rebuild now removes the former portable association before publishing standard Builder metadata. The transition regression rejects the previous production source; it also checks repeated standard builds and denied removal preserving old metadata. All eight focused Windows fixtures pass, including native setup/transactions, archive/relocation and actual published self-contained WPF CLI startup. ZIP fallback repair limits and portable setup discovery are documented. Physical removable-drive relocation and a complete public-release/game repair remain untested. Fresh [hosted checks](https://git.magicbean.dev/MagicBeanDev/Hookshot/pulls/2367/checks) are separate exact-head evidence. <details> <summary>Earlier validation evidence and limits</summary> Earlier tested head **`6affe74b`**, rebased onto main **`7759a6f3`**: - All eight focused portable checks pass on Windows/Python 3.12, plus the worker fixture's **104 assertions**. - Published Release app: **seven startup/child/relocation/error scenarios**, including an owned DOS alias, with installed-runtime discovery isolated. Restoring the old textual path comparison reproduces the hosted false failure; the rejected-home startup mutation is still caught. - Fresh hosted checks will run on this head. Earlier hosted failures include the missing `env_on` fixtures now fixed on main by #2380; the old WPF failure was a short/long path assertion, not selection of a different home. At `64856415` on `b744b1ec`: Windows **359 passed / 5 failed / 31 skipped**; Linux **362 passed / 2 failed / 31 skipped**. Both hit the two missing-`env_on` fixtures; Windows also retained the documented recorder negative control, NUP link-privilege and compute-guard fixture failures. These are earlier-head results, not current-head green checks. At `9e3aa287`: complete native setup/host-update compilation and self-contained WPF publish passed; ten broken variants were rejected. Full Linux was 351/0/31; Windows349/3/30 had the same failure identities as its exact main baseline. Tests use authored packages/files, with no game data, downloaded SDKs or built game. Actual removable-media/drive-letter changes, live release download/repair and gameplay remain untested. #2373's new launcher shares setup/storage paths and needs the portable contract preserved whichever lands second. ![Portable folders in an isolated preview, captured at 9e3aa287 and cropped without changing pixels](https://raw.githubusercontent.com/Arkex-ai/Hookshot/9e3aa287381926d8d80e007cf7565af7cdd18c47/docs/images/windows-portable-folders.png) </details> <!-- pr-ci-status --> | Current-head workflow | Result | |---|---| | [Game-free source regressions](https://github.com/NotRexed/Hookshot/actions/runs/43095610848) | Queued | | [Linux AppImage](https://github.com/NotRexed/Hookshot/actions/runs/43095610874) | Queued | | [PR title](https://github.com/NotRexed/Hookshot/actions/runs/43095609270) | Queued | | [Vulkan back end with NVIDIA DLSS](https://github.com/NotRexed/Hookshot/actions/runs/43095610937) | Queued | | [Windows builder](https://github.com/NotRexed/Hookshot/actions/runs/43095610860) | Queued | <!-- /pr-ci-status -->
Arkex-ai commented 2026-11-11 16:51:21 +00:00 (Migrated from github.com)

Integration note for #2373: the launcher and this opt-in portable-home package have distinct behavior but share setup/storage paths. A merge-tree check at 64856415 + launcher 6adbe93b reports conflicts in MainWindow.xaml.cs, Storage.cs and setup.c. Whichever lands second needs to preserve the marked home, relative game association and process-only child context, including the launcher's new --engine --tag route. I'll handle the portable rebase if the launcher lands first. No launcher UI is included here.

Integration note for #2373: the launcher and this opt-in portable-home package have distinct behavior but share setup/storage paths. A merge-tree check at `64856415` + launcher `6adbe93b` reports conflicts in MainWindow.xaml.cs, Storage.cs and setup.c. Whichever lands second needs to preserve the marked home, relative game association and process-only child context, including the launcher's new `--engine --tag` route. I'll handle the portable rebase if the launcher lands first. No launcher UI is included here.
Joey-Magnus (Migrated from github.com) reviewed 2026-11-11 20:08:16 +00:00
Joey-Magnus (Migrated from github.com) left a comment

This is a well-contained feature, and I couldn't find a regression for standard installs, which is what most players run. I focused on the native setup (portable_home.h, setup.c), the game-side association (make_game.py, host/hos_update.c) and BuilderHome/PortableArchive. For a standard install:

  • init_home resolves to %LOCALAPPDATA%\HookshotBuilder as before;
  • publish_install is the old MoveFileW, reuse_part still moves, and rmtree(final_dir) is unchanged;
  • home_path only fails on truncation for portable homes;
  • make_game.py's --work default, HOOKSHOT_SETUP_BASE or else LOCALAPPDATA, resolves to the same folder.

On the game side, local_builder runs <game>\hookshot.exe, which is the setup launcher and not the game, because hos_game_path_w maps bin\hookshot.exe back to the game folder. The relative association is accepted only when it resolves to a marked home, so it can't point anywhere a plain builder= line couldn't already. All checks pass on this head, including Hookshot Builder package and setup.

should fix nx2/make_game.py:1469-1472 with builder/setup/portable_home.h:110-133: the association is written when the build is portable, but never removed when it isn't. If a game folder built by a portable Builder is later built again (or updated) by a standard one, hookshot_version.txt gets builder= and no builder_local=1, while the old .hookshot_builder_home stays. hb_home_game reads that file first, without looking at builder_local, so the game's launcher keeps opening the portable Builder's Play/Update window while hos_update_check hands updates to the standard one. If the portable home has since been moved or deleted, hb_home_game returns -1, g_home_ok is 0, and the launcher silently skips the Builder altogether. The simplest fix is for write_game_home(out, None) to delete an existing .hookshot_builder_home. Alternatively (or as well), hb_home_game could honour the file only when hookshot_version.txt has builder_local=1. A case for this in test_portable_game_home.py would pin it down.

question builder/HookshotBuilder/PortableArchive.cs:23: an existing versions\<tag> folder is reused whenever it has HookshotBuilder.exe and a matching tools\version.txt. That means the ZIP fallback can never repair a version that antivirus has partly removed: it validates and starts the same broken folder. I understand not overwriting a possibly running installation, and the ZIP route is only the fallback for releases without the setup's list, so its reach is small. Is the setup route (MainWindow.xaml.cs's repair through HookshotBuilder-Setup.exe) the intended way to repair in portable mode? If so, a line about it in docs/windows-portable-builder.md would help.

minor: --portable is a player-facing option, but it's only documented in docs/windows-portable-builder.md, which is linked from CONTRIBUTING.md. A sentence and a link in the readme's Windows install section would let players who want an external-SSD install find it.

Not covered by me: the WPF startup fixture and the .NET tests beyond reading them (they need Windows; CI ran them), and real removable-drive moves, which the PR says are untested. The heads-up about #2373 overlapping in setup.c, Storage.cs and MainWindow.xaml.cs is useful. Whichever lands second needs to keep BuilderHome.PassTo on the launcher's new child processes.

This is a well-contained feature, and I couldn't find a regression for standard installs, which is what most players run. I focused on the native setup (`portable_home.h`, `setup.c`), the game-side association (`make_game.py`, `host/hos_update.c`) and `BuilderHome`/`PortableArchive`. For a standard install: - `init_home` resolves to `%LOCALAPPDATA%\HookshotBuilder` as before; - `publish_install` is the old `MoveFileW`, `reuse_part` still moves, and `rmtree(final_dir)` is unchanged; - `home_path` only fails on truncation for portable homes; - `make_game.py`'s `--work` default, `HOOKSHOT_SETUP_BASE` or else `LOCALAPPDATA`, resolves to the same folder. On the game side, `local_builder` runs `<game>\hookshot.exe`, which is the setup launcher and not the game, because `hos_game_path_w` maps `bin\hookshot.exe` back to the game folder. The relative association is accepted only when it resolves to a marked home, so it can't point anywhere a plain `builder=` line couldn't already. All checks pass on this head, including *Hookshot Builder package and setup*. **should fix** `nx2/make_game.py:1469-1472` with `builder/setup/portable_home.h:110-133`: the association is written when the build is portable, but never removed when it isn't. If a game folder built by a portable Builder is later built again (or updated) by a standard one, `hookshot_version.txt` gets `builder=` and no `builder_local=1`, while the old `.hookshot_builder_home` stays. `hb_home_game` reads that file first, without looking at `builder_local`, so the game's launcher keeps opening the portable Builder's Play/Update window while `hos_update_check` hands updates to the standard one. If the portable home has since been moved or deleted, `hb_home_game` returns -1, `g_home_ok` is 0, and the launcher silently skips the Builder altogether. The simplest fix is for `write_game_home(out, None)` to delete an existing `.hookshot_builder_home`. Alternatively (or as well), `hb_home_game` could honour the file only when `hookshot_version.txt` has `builder_local=1`. A case for this in `test_portable_game_home.py` would pin it down. **question** `builder/HookshotBuilder/PortableArchive.cs:23`: an existing `versions\<tag>` folder is reused whenever it has `HookshotBuilder.exe` and a matching `tools\version.txt`. That means the ZIP fallback can never repair a version that antivirus has partly removed: it validates and starts the same broken folder. I understand not overwriting a possibly running installation, and the ZIP route is only the fallback for releases without the setup's list, so its reach is small. Is the setup route (`MainWindow.xaml.cs`'s repair through `HookshotBuilder-Setup.exe`) the intended way to repair in portable mode? If so, a line about it in `docs/windows-portable-builder.md` would help. **minor**: `--portable` is a player-facing option, but it's only documented in `docs/windows-portable-builder.md`, which is linked from CONTRIBUTING.md. A sentence and a link in the readme's Windows install section would let players who want an external-SSD install find it. Not covered by me: the WPF startup fixture and the `.NET` tests beyond reading them (they need Windows; CI ran them), and real removable-drive moves, which the PR says are untested. The heads-up about #2373 overlapping in `setup.c`, `Storage.cs` and `MainWindow.xaml.cs` is useful. Whichever lands second needs to keep `BuilderHome.PassTo` on the launcher's new child processes.
Arkex-ai commented 2026-11-11 20:22:02 +00:00 (Migrated from github.com)

Thanks for the review. Fixed in f75c156f on main 355453de: a successful standard rebuild removes the old portable association before writing standard Builder metadata. The new transition case fails against the previous source and passes now, including a second standard build and denied removal preserving the old metadata. All eight focused Windows fixtures pass again.

I also linked portable setup from the readme and clarified repair: the packaged setup is the repair route; the legacy ZIP fallback reuses a matching installed version and does not audit all its files. Fresh CI is running.

Thanks for the review. Fixed in `f75c156f` on main `355453de`: a successful standard rebuild removes the old portable association before writing standard Builder metadata. The new transition case fails against the previous source and passes now, including a second standard build and denied removal preserving the old metadata. All eight focused Windows fixtures pass again. I also linked portable setup from the readme and clarified repair: the packaged setup is the repair route; the legacy ZIP fallback reuses a matching installed version and does not audit all its files. Fresh CI is running.
Joey-Magnus (Migrated from github.com) approved these changes 2026-11-11 21:23:05 +00:00
Joey-Magnus (Migrated from github.com) left a comment

write_game_home(out, None) removes a stale .hookshot_builder_home on a standard rebuild, and it's tested. Standard installs resolve HOOKSHOT_SETUP_BASE to the same %LOCALAPPDATA%\HookshotBuilder as before. The new deletions are portable-only and scoped: rmtree(to) touches only this attempt's staging folder, the publish_install backup is restored on failure, and the tag is checked against an allow-list before it's used in a path.

Merge order: this before #2373. They share setup.c, Storage.cs, MainWindow.xaml.cs, Updater.cs and App.xaml.cs, and #2373 then needs BuilderHome.PassTo on its --engine setup and the portable home in FreeAfterBuild.

`write_game_home(out, None)` removes a stale `.hookshot_builder_home` on a standard rebuild, and it's tested. Standard installs resolve `HOOKSHOT_SETUP_BASE` to the same `%LOCALAPPDATA%\HookshotBuilder` as before. The new deletions are portable-only and scoped: `rmtree(to)` touches only this attempt's staging folder, the `publish_install` backup is restored on failure, and the tag is checked against an allow-list before it's used in a path. Merge order: this before #2373. They share `setup.c`, `Storage.cs`, `MainWindow.xaml.cs`, `Updater.cs` and `App.xaml.cs`, and #2373 then needs `BuilderHome.PassTo` on its `--engine` setup and the portable home in `FreeAfterBuild`.
Commenting is not possible because the repository is archived.
No description provided.