Skip to content

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

Open
timstr97 wants to merge 6 commits into
NotRexed:mainfrom
timstr97:builder/profiles
Open

timstr97 wants to merge 6 commits into
NotRexed:mainfrom
timstr97:builder/profiles

Conversation

@timstr97

@timstr97 timstr97 commented Nov 9, 2026

Copy link
Copy Markdown

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

timstr97 and others added 4 commits November 9, 2026 17:16
The launcher (Windows and Linux) gets a Profile choice above Play. Each
profile is a user of the game with its own save folder, save/<id> in the
user folder; Play starts the game with HOOKSHOT_PROFILE=<id> and the game
mounts that profile's saves (host/nn_applet.c). Without the variable the
game plays the default profile, the uid every build had, so existing saves
stay where they are and an older game simply ignores it.

Manage renames the chosen profile, imports a save into it, opens its save
folder or removes it; New profile makes one. Import takes the game's save
folder (account.sav, option.sav, slot_00 ...) or an emulator's whole save
folder, and asks which user when it holds several. Nothing is deleted:
the profile's saves before an import, and a removed profile's saves, are
moved to save-backups. The default profile is marked "(default)" and
cannot be removed.

builder/linux/profiles.py and builder/HookshotBuilder/Profiles.cs keep the
same profiles.txt. Tests: tests/test_launcher_profiles.py,
tests/test_account_profile.py (pick_user built from the source),
tests/launcher_profiles (C#, wired into source-regressions on Windows) and
tests/test_hookshot_launch.py (--play passes the chosen profile).

Assisted-by: Claude Code
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ofiles

Play on the builder's pages (the game already built, and after a build)
started the game with the profile chosen last, without showing it. With
one profile it still plays at once; with more, it opens the launcher's
page, where the profile is shown and can be changed before Play.

Assisted-by: Claude Code
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@hckrman101

Copy link
Copy Markdown
Collaborator

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 and others added 2 commits November 9, 2026 18:48
Resolve the conflict in tests/run_all.py: both sides added entries to
ARGS (test_account_profile / test_audio_in) and SKIP
(test_launcher_profiles / test_oot_saves); keep all of them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCCdGrn9VxB4X9GqKrBGUV
Resolve the conflict in builder/HookshotBuilder/MainWindow.xaml.cs: both
sides added a field to BuilderSettings (Profile here, Dlss on main) and
set it where the build form saves its settings; keep both.

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

timstr97 commented Nov 9, 2026 •

Copy link
Copy Markdown
Author

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

@hckrman101 hckrman101 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Comment thread builder/linux/profiles.py
staging = saves + ".importing"
shutil.rmtree(staging, ignore_errors=True)
try:
shutil.copytree(src, staging)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Comment thread builder/linux/profiles.py
except BaseException:
shutil.rmtree(staging, ignore_errors=True)
raise
moved = _backup(user, uid)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

# (--play already chose Play. The game's own update question stays for a bin/hookshot the player started themselves)
export HOOKSHOT_NO_UPDATE_CHECK=1
if [ -n "$profile" ]; then
export HOOKSHOT_PROFILE="$profile"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

return $"{where} ({slots} save slot{(slots == 1 ? "" : "s")}, last saved {when})";
}

static void CopyTree(string from, string to)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

try { Directory.Delete(staging, recursive: true); } catch (Exception) { }
throw;
}
var moved = Backup(user, id);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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

title="The save to import: its folder, or an emulator's save folder")
if not chosen:
return
found = profiles.find_saves(chosen)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants