Skip to content

feat(linux-launcher): add profiles, each with saves of its own - #1958

Open
timstr97 wants to merge 11 commits into
NotRexed:mainfrom
timstr97:linux-profiles
Open

timstr97 wants to merge 11 commits into
NotRexed:mainfrom
timstr97:linux-profiles

Conversation

@timstr97

@timstr97 timstr97 commented Nov 10, 2026 •

Copy link
Copy Markdown

On Linux everyone who plays on one PC shares one set of saves, and moving a save over from an emulator means running scripts/oot_saves.py by hand. This PR adds profiles to the Linux launcher, so each player gets their own saves, and lets the launcher import a save from an emulator in a few clicks.

Issues

What changed

Launcher (builder/linux/hookshot_builder.py, new builder/linux/profiles.py)

  • A Profile choice sits above Play, with Manage and New profile… beside it. Play starts the game with the profile chosen, and the launcher remembers it (profile= in builder.ini).
  • Manage renames the profile, imports a save into it, exports its save, opens its save folder, or removes it. Removing a profile moves its saves to save-backups/<id>-<time>: nothing is deleted. The default profile cannot be removed, because the game plays it when it is started without the launcher.
  • Import a save lists the saves found in the emulators' usual folders, each with the user's name from Eden's user list (profiles.dat). Other folder… takes any other folder. When a folder holds saves of several emulator users, the launcher asks whose. The copy runs on a worker thread behind a "Copying the save…" screen, and a failure is shown in an error box.
  • Export copies the profile's save into a new folder, Hookshot save - <name> - <date and time>. It never writes into an emulator and replaces nothing.
  • Folder and file choices use the desktop's own chooser (kdialog on KDE, zenity elsewhere) when there is one, at the desktop's scale and in its light or dark look. Tk's own dialog is the fallback.
  • The builder's Play opens the launcher page first, so the profile can be seen and changed. Return presses the focused button, so Return on Manage or Update no longer starts the game.
  • The launcher offers profiles only for a game folder whose hookshot_version.txt has profiles=1, which nx2/make_game.py now writes on every build. An older folder plays the default profile's saves, as before.

Game (host/nn_applet.c, host/hos_main.c)

  • HOOKSHOT_PROFILE=<32 hex digits> picks the user, and so the save folder save/<id>. The game reads it once at start-up (hos_account_init), after the user folder is known.
  • An id that is not in profiles.txt in the user folder (a profile removed since it was chosen, or one from another user folder) falls back to the default profile, as does a missing or malformed value. The default profile's uid is unchanged, so existing saves stay where they were.

hookshot-launch passes the ini's profile= on as HOOKSHOT_PROFILE. Whether it is still on the list is the game's to check. It also starts with HOME unset.

scripts/oot_saves.py

  • New saves command: the saves of a folder, or of every emulator's usual folder, as JSON. list shows users' names.
  • --hookshot can name one profile's save folder, which an import creates if it isn't there yet.
  • A save with an empty file is refused. A copy that fails partway puts the target back from the backup.
  • Eden's mods folder (load/<title id>) is no longer taken for a user. User folders copied into a folder of any name are still found by their ids.
  • The AppImage that runs the launcher is not taken for a running game.

Docs: readme ("Profiles, and saves from emulators"), docs/saves.md ("Copy from Hookshot's launcher"), and the HOOKSHOT_PROFILE row in docs/development.md.

How it was tested

  • python -I -B tests/run_all.py on CachyOS: 331 passed, 2 failed, 13 skipped. Both failures also happen on main: test_wayland_libdecor_hint.py (-Wunused-but-set-global in its harness with this clang) and nx2/test_nup_read_name.py (zstandard is not installed).
  • New or extended tests:
    • test_launcher_profiles.py: add, rename, remove, ids never reused, a list in another encoding, import and export, a failed import shown as an error, an id starting with -.
    • test_account_profile.py: builds the real hos_account_init, covering listed and unlisted ids, case, comments and malformed values.
    • test_hookshot_launch.py: the profile passed on, and a start without HOME.
    • test_oot_saves.py: saves, a new profile's folder, empty files, a failed copy restored, Eden's mods folder, copied user folders.
  • The launcher was run on CachyOS (kernel 7.3.1, KDE Plasma, Wayland): profile list, Manage, New profile and import from emulator folders. The screenshots below come from made-up data.
  • In the game, on CachyOS with an RX 9070 XT (Mesa 26.2.6), build v2.0.8-604-g829b783 (this branch before its review fixes): Play starts the game with the profile chosen in the launcher, and the game plays that profile's saves. Four starts, two profiles (account: profile …0002, …0003 in hookshot_log.txt). A row is added to docs/tested-configurations.md.

Not tested

  • In the game: the review fixes' hos_account_init, read once at start-up, and its fall-back to the default profile for an id that is not in profiles.txt. The game run above predates them; test_account_profile.py builds and checks that code.
  • The Windows build of the host/ change. The Windows launcher has no profiles yet and plays the default profile.
  • Importing from emulators other than Eden (Eglantine, NX2navi). Those are covered only by the game-free tests.

Screenshots

The launcher, with a profile chosen:

1-launcher

The profile list, and the Manage menu for the chosen profile:

2-profile-list 3-manage

New profile:

4-new-profile

Import a save, from Eden with two users:

5-import

After the import:

6-imported

timstr97 and others added 9 commits November 10, 2026 21:56
- Update hookshot-launch to correctly handle XDG_CONFIG_HOME and HOME variables.
- Remove unused user directory functions and improve profile validation in profiles.py.
- Enhance error handling in account initialization and ensure profiles are checked against the list.
- Update documentation to clarify profile usage and behavior in various scenarios.
- Add tests for profile management and user folder handling.
# Conflicts:
#	builder/linux/hookshot_builder.py
#	docs/tested-configurations.md
#	readme.md
@timstr97

Copy link
Copy Markdown
Author

Thanks for reading all of it. Point by point:

Conflicts. I merged main (e19d969) into the branch instead of rebasing, because the branch already had merges from main and a rebase would rewrite commits you have reviewed. The PR is mergeable now. Three files conflicted:

  • builder/linux/hookshot_builder.py: play() keeps HOOKSHOT_PROFILE and starts the game through main's new play-diagnostics.sh. The profile methods and main's track methods (track_id, switching, on_track_changed, older_ok) are both kept.
  • readme.md and docs/tested-configurations.md: both sides kept.

tests/test_linux_play_diagnostics.py, which came with main, takes play() out of the file by its text and failed with a NameError on chosen_profile. It now stubs chosen_profile and checks that the game gets HOOKSHOT_PROFILE. tests/run_all.py after the merge: 359 passed, 3 failed, 13 skipped. The three failures also happen on main here: test_wayland_libdecor_hint.py (-Wunused-but-set-global with this clang), and nx2/test_nup_read_name.py and test_bake_portability.py (no zstandard).

CI. Understood. It needs a maintainer to approve the workflows.

Profiles as a feature. Agreed, that is NotRexed's decision. I'd rather hear "no" or "not like this" now than polish it further.

Empty files. Neither Eden nor NX2navi writes the files under slot_*; the game does, through nn::fs, which they map to plain files. The game writes only progress.sav and caption.sav there. I checked the real saves on my PC (two Eden users with 12 slots, and one Hookshot profile with 6): every slot has just those two files, none of them empty, and account.sav and option.sav are not empty either. Eden's own bookkeeping files would be in the title folder, which empty_files() does not look at. NX2navi isn't installed here, so I can't show it. Its pending saves go to profile/save-tx, outside the slots, and its slot files come from the same game code, so I kept the check.

/.config nit. Fixed. hookshot-launch now reads no ini when neither XDG_CONFIG_HOME nor HOME is set. test_hookshot_launch.py runs the script under sh -x without them and checks the trace for no /.config. That check fails on the old script.

Windows build. Still untested: I have no llvm-mingw here. I've said so in the description.

I've updated the description to match.

@mooki000

Copy link
Copy Markdown
Contributor

Tried this on Linux (Arch, kernel 7.2.15, Python 3.12), head 8b6f26b, without the Tk window: the tests, then
profiles.py and oot_saves.py called the way import_profile_save and export_profile_save call them.

1. One of the new tests fails on a PC that has an NX2navi save in its usual place.
tests/test_launcher_profiles.py, test_import_offers_the_emulators_usual_folders_with_their_users_names:

AssertionError: Lists differ: ['Svenja', None] != ['Svenja']

The test points XDG_DATA_HOME at its temporary folder, but scripts/oot_saves.py also looks for NX2navi in
{home}/Games/Ocarina (the Program("nx2navi", ...) entry), and this PC has a save there, so the launcher offers it
beside the fixture's Eden save. That is the launcher doing the right thing; only the test sees the real home folder.
With HOME set to an empty folder the file passes (15 tests). Adding "HOME": tmp to that test's
mock.patch.dict(os.environ, ...) should be all it needs. The other four (test_account_profile,
test_hookshot_launch, test_oot_saves, test_linux_play_diagnostics) pass here.

2. The profile logic does what the description says. On a new user folder: the list starts as Player; two
profiles added get ...0002 and ...0003; a second zelda is refused. An import from a folder holding a Hookshot
save (saves_in finds the one save) copies 6 slots, account.sav and option.sav byte for byte into save/...0002
and leaves the still empty ...0003 without a folder; a second import backs the first up to
save-backups/import-folder-<time>. Export makes Hookshot save - Zelda - 2026-11-11 07-08, a second one in the
same minute (2), equal to the profile's save, and saves_in finds it again; exporting the empty profile is refused.

One thing a player can run into: the import refuses while any Hookshot runs on the PC (close Hookshot first ...),
also one that plays another profile or another user folder. Here other test runs of the game were up, and the import
only went through with running_games stubbed as the tests stub it. Safe, and probably right; it may be worth saying
in the error which game it saw.

Not done yet: the game side (HOOKSHOT_PROFILE in headless runs on an RTX 4090: a listed profile, an unlisted id, a malformed value, a listed profile without a save). Those runs are queued here and I'll add what they show.

@mooki000

Copy link
Copy Markdown
Contributor

Follow-up to my comment above: the game side, which the description lists as not tested (this head built from source, RTX 4090, NVIDIA 615.78.08, no window, scripted input; a user folder
made with the launcher's own functions, as in my comment above: the default profile with a child save in Kokiri Forest, ...0002 with an adult save,
...0003 with none).

HOOKSHOT_PROFILE hookshot_log.txt What the game played
...0002 (listed) account: profile 5a3c9e1f0b7d24680000000000000002 save: mounted on save/...0002; Continue, Load: six slots with 6 hearts, then adult Link in play
...0009 (not in profiles.txt) account: profile ...0009 is not in .../profiles.txt: the default profile save: mounted on save/...0001; the Load list says Kokiri Forest, 3 hearts, then child Link by his house
zelda account: HOOKSHOT_PROFILE=zelda is not 32 hex digits: the default profile (22 s run: the log line only)
unset no account: line (22 s run)
...0003 (listed, no save yet) account: profile 5a3c9e1f0b7d24680000000000000003 the game made save/...0003/account.sav (320 bytes); the other profiles' folders were not touched

So hos_account_init and its fall-back do in the game what test_account_profile.py checks. No crash or device-lost
line in any of the five runs.

@Joey-Magnus Joey-Magnus left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The game side is well covered now: mooki000's runs show HOOKSHOT_PROFILE and its fallback doing what test_account_profile.py checks. I also read profiles.py's remove and export paths. Ids are checked with _ID.fullmatch, removing moves to save-backups/ rather than deleting, and export only ever rmtrees the folder it just created. I don't see a data-loss risk there.

Three things before it can merge:

  • The decision is still open. NotRexed closed #1594, #1820 and #1868 into #1007, and nobody has said yet whether profiles are wanted in this form.
  • It conflicts with main again, in nx2/make_game.py, docs/tested-configurations.md and tests/test_linux_play_diagnostics.py. Main has changed the builder's Play path and make_game.py since (#2279, #2367, #2391).
  • mooki000's test fix isn't in: test_import_offers_the_emulators_usual_folders_with_their_users_names (tests/test_launcher_profiles.py:245) only patches XDG_DATA_HOME. On a PC with an NX2navi save in ~/Games/Ocarina it fails. Adding "HOME": tmp to that dict fixes it.

The Windows build of the host/ change is still untested.

@timstr97

Copy link
Copy Markdown
Author

Currently it's only for Linux. But sure, but i haven't done it yet for windows. I thought splitting it up for the different launchers would be easier to review.

Maybe someone else wants to implement it for windows?

@Joey-Magnus Joey-Magnus left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

My two concrete points are fixed. The branch merges cleanly with current main again, and test_import_offers_the_emulators_usual_folders_with_their_users_names now patches HOME as well as XDG_DATA_HOME (tests/test_launcher_profiles.py:246). I merged 61d19af into current main and ran the whole tests/run_all.py: 428 passed, and the only failure is test_lift_cache, which fails on main here too. That run covers test_launcher_profiles, test_account_profile, test_hookshot_launch, test_oot_saves and test_linux_play_diagnostics. CI's builds still wait for approval, so the Windows build of the host/ change (nn_applet.c, hos_main.c) hasn't been run anywhere.

What's left is the product decision. NotRexed closed #1594, #1820 and #1868 into #1007, and nobody has said yet whether profiles are wanted in this form. This is also Linux only. The Windows launcher would play the default profile, so the two launchers would differ until someone ports it, which hckrman101 asked about.

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.

3 participants