feat(launcher): added profile management and save importing #1179

Closed
timstr97 wants to merge 11 commits from builder/profiles into main
timstr97 commented 2026-11-09 16:29:55 +00:00 (Migrated from github.com)

What

The launcher (Windows and Linux) gets profiles, each with saves of its own, and can import a save from a console or an emulator.

  • Profile choice above Play. Play starts the game with HOOKSHOT_PROFILE=<id>; the game uses that profile as its user, so it mounts save/<id> in the user folder (host/nn_applet.c).
  • Manage (rename, import a save into the chosen profile, open its save folder, remove) and New profile… next to it.
  • Import takes the game's save folder (account.sav, option.sav, slot_00 …) or an emulator's whole save folder (save/0000000000000000/<user>/0400486024DEC000); when that holds several users, the launcher asks which one, with each save's slot count and last-saved time.
  • Nothing is deleted. The profile's saves before an import, and a removed profile's saves, are moved to save-backups/<id>-<time>. The copy goes to a staging folder first, so a failed import leaves the profile as it was.
  • Play in the builder window (the game already built, and after a build) plays at once when there is one profile. With more, it opens the launcher's page, so the profile is shown and can be changed before playing.
  • The default profile is the uid every build had (5a3c9e1f0b7d24680000000000000001): existing saves stay where they are. It is marked "(default)" in the list, even after a rename, and cannot be removed, since the game plays it when started without the launcher.

Why

There was one user and one set of saves per PC. Several players on one PC overwrote each other's files, and saves from a console or an emulator had to be copied by hand into the right hashed folder.

How

  • host/nn_applet.c: pick_user() reads HOOKSHOT_PROFILE (32 hex digits, the save folder's name). It is an environment variable rather than an argument so that an older game, started by a newer launcher before it updates, ignores it instead of reading it as an image path. Anything that is not 32 hex digits plays the default profile.
  • builder/linux/profiles.py and builder/HookshotBuilder/Profiles.cs hold the same logic and write the same profiles.txt (<id> <name> per line) in the user folder: user/ in the game folder on Windows, ~/.local/share/hookshot on Linux.
  • Linux: hookshot_builder.py (launch page) and hookshot-launch (--play passes the profile chosen last, from builder.ini).
  • Windows: MainWindow.xaml(.cs). The chosen profile is kept in builder-settings.json, and the ready page's Play uses it too. The Manage menu is a popup in the app's dark style (new MenuRow style in App.xaml).
  • README: a "Profiles and imported saves" section.

Testing

  • python -I -B tests/run_all.py on Linux, after merging current main: 143 passed, 0 failed.
  • New game-free tests:
    • tests/test_launcher_profiles.py: profiles.py; Linux only, added to the skip list like the other Linux-launcher tests.
    • tests/test_account_profile.py: builds pick_user from nn_applet.c and checks which save folder each value leads to, including invalid and path-like values.
    • tests/launcher_profiles: C# tests for Profiles.cs, including reading a profiles.txt as the Linux launcher writes it. Added to source-regressions.yml on Windows.
    • tests/test_hookshot_launch.py: --play passes the chosen profile.
  • HookshotBuilder.csproj builds with 0 warnings (checked on Linux with EnableWindowsTargeting).
  • In game, Linux (CachyOS):
    • Saves from an emulator, imported with the launcher, show in the file select and load into gameplay (Kakariko).
    • A game built from this branch with HOOKSHOT_PROFILE=…0002 logs account: profile …0002, mounts save/…0002 and lists that profile's imported saves.
    • The Linux launcher page and its Manage menu were checked in screenshots.
  • Not tested: the Windows launcher on Windows. It compiles and its logic is tested, but I have not seen the page on a Windows PC.

🤖 Generated with Claude Code

## What The launcher (Windows and Linux) gets **profiles**, each with saves of its own, and can **import a save** from a console or an emulator. - **Profile** choice above **Play**. Play starts the game with `HOOKSHOT_PROFILE=<id>`; the game uses that profile as its user, so it mounts `save/<id>` in the user folder (`host/nn_applet.c`). - **Manage** (rename, import a save into the chosen profile, open its save folder, remove) and **New profile…** next to it. - **Import** takes the game's save folder (`account.sav`, `option.sav`, `slot_00` …) or an emulator's whole save folder (`save/0000000000000000/<user>/0400486024DEC000`); when that holds several users, the launcher asks which one, with each save's slot count and last-saved time. - **Nothing is deleted.** The profile's saves before an import, and a removed profile's saves, are moved to `save-backups/<id>-<time>`. The copy goes to a staging folder first, so a failed import leaves the profile as it was. - **Play in the builder window** (the game already built, and after a build) plays at once when there is one profile. With more, it opens the launcher's page, so the profile is shown and can be changed before playing. - The **default profile** is the uid every build had (`5a3c9e1f0b7d24680000000000000001`): existing saves stay where they are. It is marked "(default)" in the list, even after a rename, and cannot be removed, since the game plays it when started without the launcher. ## Why There was one user and one set of saves per PC. Several players on one PC overwrote each other's files, and saves from a console or an emulator had to be copied by hand into the right hashed folder. ## How - `host/nn_applet.c`: `pick_user()` reads `HOOKSHOT_PROFILE` (32 hex digits, the save folder's name). It is an environment variable rather than an argument so that an older game, started by a newer launcher before it updates, ignores it instead of reading it as an image path. Anything that is not 32 hex digits plays the default profile. - `builder/linux/profiles.py` and `builder/HookshotBuilder/Profiles.cs` hold the same logic and write the same `profiles.txt` (`<id> <name>` per line) in the user folder: `user/` in the game folder on Windows, `~/.local/share/hookshot` on Linux. - Linux: `hookshot_builder.py` (launch page) and `hookshot-launch` (`--play` passes the profile chosen last, from `builder.ini`). - Windows: `MainWindow.xaml(.cs)`. The chosen profile is kept in `builder-settings.json`, and the ready page's Play uses it too. The Manage menu is a popup in the app's dark style (new `MenuRow` style in `App.xaml`). - README: a "Profiles and imported saves" section. ## Testing - `python -I -B tests/run_all.py` on Linux, after merging current `main`: 143 passed, 0 failed. - New game-free tests: - `tests/test_launcher_profiles.py`: profiles.py; Linux only, added to the skip list like the other Linux-launcher tests. - `tests/test_account_profile.py`: builds `pick_user` from `nn_applet.c` and checks which save folder each value leads to, including invalid and path-like values. - `tests/launcher_profiles`: C# tests for `Profiles.cs`, including reading a `profiles.txt` as the Linux launcher writes it. Added to `source-regressions.yml` on Windows. - `tests/test_hookshot_launch.py`: `--play` passes the chosen profile. - `HookshotBuilder.csproj` builds with 0 warnings (checked on Linux with `EnableWindowsTargeting`). - In game, Linux (CachyOS): - Saves from an emulator, imported with the launcher, show in the file select and load into gameplay (Kakariko). - A game built from this branch with `HOOKSHOT_PROFILE=…0002` logs `account: profile …0002`, mounts `save/…0002` and lists that profile's imported saves. - The Linux launcher page and its Manage menu were checked in screenshots. - **Not tested:** the Windows launcher on Windows. It compiles and its logic is tested, but I have not seen the page on a Windows PC. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
hckrman101 commented 2026-11-09 18:39:50 +00:00 (Migrated from github.com)

Hi! This PR has merge conflicts with main now (a lot has landed today). Could you merge main in or rebase and resolve them? Happy to give it a proper review once it's up to date.

Hi! This PR has merge conflicts with `main` now (a lot has landed today). Could you merge `main` in or rebase and resolve them? Happy to give it a proper review once it's up to date.
timstr97 commented 2026-11-09 19:37:06 +00:00 (Migrated from github.com)

Merged it with main. Please review the windows version as i have not tested it in person

Merged it with main. Please review the windows version as i have not tested it in person
hckrman101 (Migrated from github.com) reviewed 2026-11-09 20:07:25 +00:00
hckrman101 (Migrated from github.com) left a comment

Thanks for this, Tim. The branch merges cleanly with main now. Profiles as save/<id> selected through HOOKSHOT_PROFILE, the default profile keeping the old uid, and moving saves to save-backups instead of deleting them are a good design, and I like that the env var keeps older games from misreading an argument. The pick_user() word order matches what nn_fs.c expects for the default uid, and the staging copy plus the two confirmation dialogs cover the main data-safety cases. I haven't run the code, this is from reading the diff. A few things before merge:

Import takes more than the save and checks very little. is_save / IsSave accept a folder if it contains any one of account.sav, option.sav or a slot_NN directory, and then the whole folder is copied. If someone points the importer at a bigger folder that happens to contain an option.sav, everything in it ends up in the profile. Nothing looks at file sizes, and an empty or truncated account.sav is accepted. I'd copy only account.sav, option.sav and slot_NN/{progress,caption}.sav, and reject zero-byte files at least. The existing checks are a good start but the size check is the cheap win.

Backup and replace aren't one step. In both implementations the profile's folder is moved to save-backups and then the staging folder is moved into place. If that second move fails, the profile is left with no saves (they're safe in the backup, but the user is told nothing). A try/except that moves the backup back would close that.

Game running. Nothing stops Import or Remove while the game is running on that profile. On Linux the saves get moved from under a running game, which can then write them back out on its next save. Either detect the process or at least mention it in the confirmation text.

hookshot-launch --play doesn't validate the profile. chosen_profile() in the Python builder falls back to the default when the id is no longer in the list, but the shell script exports whatever is in builder.ini. After a removal, --play would start the game on a fresh, empty save folder for the removed id. Checking against profiles.txt there (or having the game fall back) would be enough.

Import folder scan runs on the UI thread (both launchers), with depth 6 and symlinks skipped. Choosing something like the home folder will freeze the window for a while. A lower depth, or a worker thread, would help.

Overlap with #1190. #1190 adds scripts/oot_saves.py and docs/saves.md for moving the same files between Hookshot and the emulators (known folders per emulator, copies only the known files, checks that the game or emulator isn't running, backs up the side it overwrites). The two don't touch the same files, but they do the same job: locating emulator saves, picking the right user, copying and backing up. It would be good to agree on one place for the "what is a valid OoT save folder" logic, and for the launcher to adopt the running-process check from there. #1190's --profile is the emulator's profile, not a Hookshot launcher profile, so the two should compose fine once the import target is a profile folder.

Testing. The Windows launcher UI hasn't been seen on Windows (as noted in the description), and no checks have run on the PR yet, so the C# tests in source-regressions.yml haven't run in CI. Please keep an eye on that once a maintainer approves the workflows.

The Linux unit tests and the pick_user test look thorough. None of the above is a big rewrite.

Thanks for this, Tim. The branch merges cleanly with main now. Profiles as `save/<id>` selected through `HOOKSHOT_PROFILE`, the default profile keeping the old uid, and moving saves to `save-backups` instead of deleting them are a good design, and I like that the env var keeps older games from misreading an argument. The `pick_user()` word order matches what `nn_fs.c` expects for the default uid, and the staging copy plus the two confirmation dialogs cover the main data-safety cases. I haven't run the code, this is from reading the diff. A few things before merge: **Import takes more than the save and checks very little.** `is_save` / `IsSave` accept a folder if it contains any one of `account.sav`, `option.sav` or a `slot_NN` directory, and then the whole folder is copied. If someone points the importer at a bigger folder that happens to contain an `option.sav`, everything in it ends up in the profile. Nothing looks at file sizes, and an empty or truncated `account.sav` is accepted. I'd copy only `account.sav`, `option.sav` and `slot_NN/{progress,caption}.sav`, and reject zero-byte files at least. The existing checks are a good start but the size check is the cheap win. **Backup and replace aren't one step.** In both implementations the profile's folder is moved to `save-backups` and then the staging folder is moved into place. If that second move fails, the profile is left with no saves (they're safe in the backup, but the user is told nothing). A try/except that moves the backup back would close that. **Game running.** Nothing stops Import or Remove while the game is running on that profile. On Linux the saves get moved from under a running game, which can then write them back out on its next save. Either detect the process or at least mention it in the confirmation text. **`hookshot-launch --play` doesn't validate the profile.** `chosen_profile()` in the Python builder falls back to the default when the id is no longer in the list, but the shell script exports whatever is in `builder.ini`. After a removal, `--play` would start the game on a fresh, empty save folder for the removed id. Checking against `profiles.txt` there (or having the game fall back) would be enough. **Import folder scan runs on the UI thread** (both launchers), with depth 6 and symlinks skipped. Choosing something like the home folder will freeze the window for a while. A lower depth, or a worker thread, would help. **Overlap with #1190.** #1190 adds `scripts/oot_saves.py` and docs/saves.md for moving the same files between Hookshot and the emulators (known folders per emulator, copies only the known files, checks that the game or emulator isn't running, backs up the side it overwrites). The two don't touch the same files, but they do the same job: locating emulator saves, picking the right user, copying and backing up. It would be good to agree on one place for the "what is a valid OoT save folder" logic, and for the launcher to adopt the running-process check from there. #1190's `--profile` is the emulator's profile, not a Hookshot launcher profile, so the two should compose fine once the import target is a profile folder. **Testing.** The Windows launcher UI hasn't been seen on Windows (as noted in the description), and no checks have run on the PR yet, so the C# tests in `source-regressions.yml` haven't run in CI. Please keep an eye on that once a maintainer approves the workflows. The Linux unit tests and the pick_user test look thorough. None of the above is a big rewrite.
@ -0,0 +1,162 @@
using System.IO;
hckrman101 (Migrated from github.com) commented 2026-11-09 20:07:25 +00:00

Same as on Linux: this copies the whole folder, and follows junctions/links. Limiting it to the known save files and checking sizes would be safer.

Same as on Linux: this copies the whole folder, and follows junctions/links. Limiting it to the known save files and checking sizes would be safer.
hckrman101 (Migrated from github.com) commented 2026-11-09 20:07:25 +00:00

If Directory.Move fails here after Backup, the profile is left empty. Restoring the backup in a catch would avoid that.

If `Directory.Move` fails here after `Backup`, the profile is left empty. Restoring the backup in a catch would avoid that.
hckrman101 (Migrated from github.com) commented 2026-11-09 20:07:25 +00:00

The id from builder.ini is exported without checking it is still in profiles.txt, unlike chosen_profile() in hookshot_builder.py. After removing a profile, --play would start the game on a new empty save folder for that id.

The id from builder.ini is exported without checking it is still in profiles.txt, unlike `chosen_profile()` in hookshot_builder.py. After removing a profile, `--play` would start the game on a new empty save folder for that id.
hckrman101 (Migrated from github.com) commented 2026-11-09 20:07:25 +00:00

find_saves walks up to 6 levels on the UI thread, so choosing a big folder (home) freezes the window. A lower depth or a thread would help.

`find_saves` walks up to 6 levels on the UI thread, so choosing a big folder (home) freezes the window. A lower depth or a thread would help.
@ -0,0 +1,161 @@
"""The launcher's profiles: each one is a user of the game with saves of its own, save/<id> in the game's user folder.
hckrman101 (Migrated from github.com) commented 2026-11-09 20:07:25 +00:00

This copies everything in src, not just the game's files. Copying only account.sav, option.sav and slot_NN/{progress,caption}.sav (and refusing zero-byte files) would make the import safer.

This copies everything in `src`, not just the game's files. Copying only account.sav, option.sav and slot_NN/{progress,caption}.sav (and refusing zero-byte files) would make the import safer.
hckrman101 (Migrated from github.com) commented 2026-11-09 20:07:25 +00:00

If os.replace below fails after _backup, the profile is left without saves (they're in the backup). Moving the backup back on failure would keep the 'a failed import leaves the profile as it was' promise.

If `os.replace` below fails after `_backup`, the profile is left without saves (they're in the backup). Moving the backup back on failure would keep the 'a failed import leaves the profile as it was' promise.
hckrman101 (Migrated from github.com) reviewed 2026-11-10 16:20:20 +00:00
hckrman101 (Migrated from github.com) left a comment

Thanks Tim, the last push closes nearly everything from my first review. I read the diff only, nothing was run.

Fixed:

  • Import and export now go through scripts/oot_saves.py on both launchers, so only the known save files are copied, zero-byte files and links are refused, and the old profile folder is put back if the copy fails. That covers the "copies the whole folder", the "no size check" and the "failed move leaves the profile empty" points, and removes the duplicated is_save / IsSave logic (and the overlap with #1190).
  • Import and export are refused while a game runs (oot_saves' check), and Remove checks for a running Hookshot on both launchers.
  • hookshot-launch --play now checks the id against profiles.txt before exporting HOOKSHOT_PROFILE.
  • The folder scan is now oot_saves' fixed layouts, not a depth 6 walk, so a big folder no longer freezes the window.

Still open:

  • Several users in one emulator folder. The PR description says the launcher asks which user to import. That code is gone: oot_saves.pick() now fails with "pick one with --profile ", and neither launcher can pass --profile. So choosing an emulator's save folder that has more than one user ends in an error naming a command-line flag. Please either ask the user in both launchers (list the ids from oot_saves.find_saves, pass --profile) or change the description and the message. The inline comment is on the Linux call.
  • Description. It still describes the old import (own copy code, the user picker, save-backups/<id>-<time> for imports). Imports now back up as save-backups/import-<name>-<time>. Please update it, and the test list if it changed.
  • Merge. Main has moved on (42 commits since your merge) and touches MainWindow.xaml, MainWindow.xaml.cs, BuilderSettings.cs, hookshot_builder.py, readme.md and tests/run_all.py, which you also change. GitHub hasn't computed mergeability yet, so I can't say whether it conflicts, but please merge main again.
  • CI. No checks have run on this branch yet, so the C# tests in source-regressions.yml and the Windows build are unverified, and so is the Windows launcher itself (as you said).

Nit: Profiles.remove / Remove store the new list after moving the saves, so if writing profiles.txt fails the profile stays listed with its saves in the backup. Rare, fine to leave.
Nit: the Windows import reads the script's output synchronously on the UI thread, so the window pauses for the copy and the process check. Saves are small, so this is only worth a note.

Thanks Tim, the last push closes nearly everything from my first review. I read the diff only, nothing was run. Fixed: - Import and export now go through `scripts/oot_saves.py` on both launchers, so only the known save files are copied, zero-byte files and links are refused, and the old profile folder is put back if the copy fails. That covers the "copies the whole folder", the "no size check" and the "failed move leaves the profile empty" points, and removes the duplicated `is_save` / `IsSave` logic (and the overlap with #1190). - Import and export are refused while a game runs (oot_saves' check), and Remove checks for a running Hookshot on both launchers. - `hookshot-launch --play` now checks the id against `profiles.txt` before exporting `HOOKSHOT_PROFILE`. - The folder scan is now oot_saves' fixed layouts, not a depth 6 walk, so a big folder no longer freezes the window. Still open: - **Several users in one emulator folder.** The PR description says the launcher asks which user to import. That code is gone: `oot_saves.pick()` now fails with "pick one with --profile <id>", and neither launcher can pass `--profile`. So choosing an emulator's save folder that has more than one user ends in an error naming a command-line flag. Please either ask the user in both launchers (list the ids from `oot_saves.find_saves`, pass `--profile`) or change the description and the message. The inline comment is on the Linux call. - **Description.** It still describes the old import (own copy code, the user picker, `save-backups/<id>-<time>` for imports). Imports now back up as `save-backups/import-<name>-<time>`. Please update it, and the test list if it changed. - **Merge.** Main has moved on (42 commits since your merge) and touches `MainWindow.xaml`, `MainWindow.xaml.cs`, `BuilderSettings.cs`, `hookshot_builder.py`, `readme.md` and `tests/run_all.py`, which you also change. GitHub hasn't computed mergeability yet, so I can't say whether it conflicts, but please merge main again. - **CI.** No checks have run on this branch yet, so the C# tests in `source-regressions.yml` and the Windows build are unverified, and so is the Windows launcher itself (as you said). Nit: `Profiles.remove` / `Remove` store the new list after moving the saves, so if writing `profiles.txt` fails the profile stays listed with its saves in the backup. Rare, fine to leave. Nit: the Windows import reads the script's output synchronously on the UI thread, so the window pauses for the copy and the process check. Saves are small, so this is only worth a note.
@ -374,13 +387,190 @@ class Builder(tk.Tk):
ttk.Label(f, text=folder, wraplength=self.px(560)).pack(anchor="w", **self.pad)
hckrman101 (Migrated from github.com) commented 2026-11-10 16:20:20 +00:00

When the chosen folder holds saves for more than one user, oot_saves fails here with "pick one with --profile " and the launcher shows that as is. The launcher has no way to pass --profile, so the user is stuck (the description says the launcher asks which one). Same on Windows. Listing oot_saves.find_saves(chosen) and passing the pick as --profile would fix it.

When the chosen folder holds saves for more than one user, `oot_saves` fails here with "pick one with --profile <id>" and the launcher shows that as is. The launcher has no way to pass `--profile`, so the user is stuck (the description says the launcher asks which one). Same on Windows. Listing `oot_saves.find_saves(chosen)` and passing the pick as `--profile` would fix it.
Commenting is not possible because the repository is archived.
No description provided.