Repository navigation
Conversation
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>
|
Hi! This PR has merge conflicts with |
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>
|
Merged it with main. Please review the windows version as i have not tested it in person |
hckrman101
left a comment
There was a problem hiding this 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.
| staging = saves + ".importing" | ||
| shutil.rmtree(staging, ignore_errors=True) | ||
| try: | ||
| shutil.copytree(src, staging) |
There was a problem hiding this comment.
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.
| except BaseException: | ||
| shutil.rmtree(staging, ignore_errors=True) | ||
| raise | ||
| moved = _backup(user, uid) |
There was a problem hiding this comment.
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" |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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.
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.
HOOKSHOT_PROFILE=<id>; the game uses that profile as its user, so it mountssave/<id>in the user folder (host/nn_applet.c).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.save-backups/<id>-<time>. The copy goes to a staging folder first, so a failed import leaves the profile as it was.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()readsHOOKSHOT_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.pyandbuilder/HookshotBuilder/Profiles.cshold the same logic and write the sameprofiles.txt(<id> <name>per line) in the user folder:user/in the game folder on Windows,~/.local/share/hookshoton Linux.hookshot_builder.py(launch page) andhookshot-launch(--playpasses the profile chosen last, frombuilder.ini).MainWindow.xaml(.cs). The chosen profile is kept inbuilder-settings.json, and the ready page's Play uses it too. The Manage menu is a popup in the app's dark style (newMenuRowstyle inApp.xaml).Testing
python -I -B tests/run_all.pyon Linux, after merging currentmain: 143 passed, 0 failed.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: buildspick_userfromnn_applet.cand checks which save folder each value leads to, including invalid and path-like values.tests/launcher_profiles: C# tests forProfiles.cs, including reading aprofiles.txtas the Linux launcher writes it. Added tosource-regressions.ymlon Windows.tests/test_hookshot_launch.py:--playpasses the chosen profile.HookshotBuilder.csprojbuilds with 0 warnings (checked on Linux withEnableWindowsTargeting).HOOKSHOT_PROFILE=…0002logsaccount: profile …0002, mountssave/…0002and lists that profile's imported saves.🤖 Generated with Claude Code