Repository navigation
fix(saves): the Linux saves' move leaves a linked folder and its saves alone - #2282
Conversation
…s alone
The first start after the saves went to $XDG_DATA_HOME/hookshot moves the
user/ folder beside the game there. A symbolic link in it, to saves the
player keeps elsewhere (a synced folder, another drive), was moved as a
file:
- with the game on another drive than the home folder, the link was copied
as the folder it names and delete_tree then emptied that folder, which is
not Hookshot's. That start still found the saves, in the copy. The next
found the link left in user/, kept the old place for good, and showed no
saves: the game read the folder it had emptied;
- on one drive the link was renamed as it stood, and one written from
where it stood ("../../sync") named nothing from the new place: no saves,
and none could be written.
A link is now made again in the new place, to the folder it names in full,
at any depth, and delete_tree removes a link without entering it, as
nn_fs.c's does. One to an entry that moves too names it where it will be;
one that names nothing goes as written. A folder with a link in it is
copied, not renamed, so its links are seen. A user/ that is a link itself
is the player's choice of place and is used as it is.
The move also looks before it moves: with an entry the new place has
already, the old place is kept, and it now keeps every entry. The others
used to go all the same, and the game then ran in the old place without
them.
Joey-Magnus
left a comment
There was a problem hiding this comment.
This fixes a real data-loss path, and the fix is careful. delete_tree no longer follows a link, so the cross-drive copy can't empty the player's synced folder any more. Links are recreated with their resolved target, and the look-then-move pass stops the half-moved state when the new place already has an entry. I read copy_link, has_link and the reworked saves_dir and found nothing blocking:
has_linkandcopy_treetestis_linkbeforeis_dir, so neither enters a linked folder (no loops, no walking into the player's data). Dangling links are handled too, sinceis_diris false for them anddelete_treeunlinks them.- The cleanup after a failed copy (
delete_tree(to)) is now safe as well. Without theis_linkcheck there, it would have walked into the linkscopy_linkhad just made. realpathintochar[MAX_PATH]is fine on Linux becausehost/posix/windows.hdefinesMAX_PATHas 4096.- Merged with current main (which has #2283's change to the same start-up code),
test_linux_saves_move,test_linux_update_skipandtest_oot_savesall pass.
minor host/hos_main.c:272-273,373: move_old is left pointing at saves_dir's stack buffer real after the function returns. Today only saves_dir reaches copy_tree/copy_link, so nothing reads it, but the next caller of copy_tree that meets a link would read a dead stack pointer. Resetting move_old = move_new = NULL before saves_dir returns (or passing them down as parameters) closes that off.
minor (it predates this PR, but this PR's comment now promises otherwise) host/hos_main.c:386-388: in the moving pass, a copy that fails does kept++ but the do … while keeps moving the remaining entries, and the function then returns the old place without them. That's the same split the new first pass was added to prevent, just reached through a failure (a full disk, a read-only file) instead of a collision. Stopping the inner loop once kept is set would at least stop the damage spreading. Fully undoing the earlier moves can wait for a later PR.
coordination: #2317 (the per-user data folder) moves saves_dir and its helpers into host/hos_data.c, and the two branches conflict. Whichever merges second has to carry the link handling over, together with test_linux_saves_move.py, which cuts the functions out of hos_main.c by text and would need to point at the new file. Worth agreeing on the order with @LNDF. The appimage failure on this run is unrelated: the mirror bootstrap timed out.
Joey-Magnus
left a comment
There was a problem hiding this comment.
This fixes real data loss: delete_tree could empty a linked sync folder. is_link now comes before is_dir in copy_tree, has_link and delete_tree, and the test passes, including the other-drive case. The appimage failure is infrastructure.
Two minor points, both optional:
host/hos_main.c:373leavesmove_oldpointing atsaves_dir's stack buffer after return. It's harmless today, and a one-line reset fixes it.:388, which predates this PR: a failed copy doeskept++but keeps moving the other entries, so on a full disk saves can still end up split between the two places.
This should merge before #2317, which moves saves_dir into hos_data.c and should carry the link handling with it.
…ves in two places, API 19 - NotRexed#2282 (the Linux saves' move leaves a linked folder alone) is carried into host/hos_data.c: on Linux a link in the saves is made again in the data folder to the same folder (never renamed or copied through), a link to an entry that moves too follows it, and a user/ that is a link itself is used as it is. On Windows a link or junction keeps the saves where they are. tests/test_linux_saves_move.py runs its cases on hos_data.c. - Saves in both <game>/user and the data folder (a downgrade, then the upgrade again): this folder's are used, nothing is merged, and saves-in-two-places.txt and the log say where both are and how to switch. - MKWVK_API_VERSION 19: main's 18 is NotRexed#1990's pad inputs. - Conflicts in nn_hid.c, docs/saves.md and docs/development.md: main's text with the data folder's places. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s alone (NotRexed#2282) On Linux, the one-time move of the saves to `~/.local/share/hookshot` (7eeb91a) can empty a folder outside the game folder and leave the game showing no saves, when `user/` holds a symbolic link (saves kept in a synced folder or on another drive). ## What happens `saves_dir` moves each entry of `user/` with a rename, or a copy and delete when the rename fails. A link is treated as the folder it names: - **Game on another drive than the home folder** (rename fails): the link is copied as a real folder, then `delete_tree` deletes everything in the folder the link names. That folder is the player's, not Hookshot's. This start still finds the saves, in the copy. The link itself is left in `user/`, so the next start sees `save` in both places, keeps the old place for good, and reads the folder it emptied: no saves. - **Same drive**: the link is renamed as it stands. One written relative to its place (`../../sync`) names nothing from `~/.local/share/hookshot`: no saves, and none can be written. Also in the same loop: when the new place already has one of the entries, the old place is kept, but the entries looked at before that one have already gone, and the game runs in the old place without them. ## The change - A link is made again in the new place, to the folder it names in full, at any depth. `delete_tree` removes a link without entering it, as `nn_fs.c`'s does since the DeleteDirectoryRecursively fix. - A link to an entry that moves too names it where it will be. A link that names nothing goes as written. - A folder with a link in it is copied rather than renamed, so the links in it are seen. - A `user/` that is itself a link is the player's choice of place: used as it is, nothing moved. - The loop looks at every entry before it moves any: with an entry already in the new place, nothing moves. Windows is not touched (the code is under `#ifndef _WIN32`). ## Tested `tests/test_linux_saves_move.py` (new) builds `saves_dir` and what it calls from `hos_main.c` as it is, over `host/posix/win32_posix.c`, and starts it twice on folders of its own: a link written in full, one written relative, one deeper in (`user/save/<user id>`), one to an entry that moves too, one that names nothing, a linked `user/`, a new place that has an entry already, and a plain `user/`; with the game on another drive too (`/dev/shm` against the temp folder) where the PC has one. - On this change: passes, the other-drive cases included. - On `main`: 12 of its checks fail in 6 of the cases, among them "the linked folder lost its save" and "start 2 did not use the new place". - `python -I -B tests/run_all.py`: nothing new fails. Not tested in a built game: I checked the function as the test builds it (gcc, `-D_GNU_SOURCE` as `nx2/build.py` passes), not a full Linux build of the game.
Eight files conflicted; both sides are kept in each. - builder/linux/hookshot_builder.py: --tag is still checked with linux_update.tag_ok before the builder starts, and main's --allow-downgrade-from is passed on as main does. - builder/setup/setup.c: this branch's part_url_ok and main's builder_ready (NotRexed#2367's startup check) sit side by side. - gpu/vk/vk_draw.cpp: main's pcache_file_read reads the 32-byte header before seeking to the end. It keeps that order and now opens the file with hos_fopen_read_binary and refuses a file past kPcacheMax (logged), as this branch did. - host/hos_main.c: main's saves move (NotRexed#2282) makes every link in user/ again in the new place, by the full path it names, and looks at every entry before moving any. That replaces this branch's own link handling in saves_dir (move a full-path link as it is, keep a relative one). copy_tree no longer fails on a link inside a folder, since copy_link now makes it again instead of following it. The file copy still goes through hos_fopen_read_binary / hos_fopen_write, so a FIFO fails the copy and nothing is written through a link. tests/test_saves_move_links.py now expects the links to be made again (a relative one by its full path) and still checks that nothing they name is copied or deleted, and that a FIFO fails the copy. tests/test_linux_saves_move.py gets hos_fopen_write.h, which copy_tree calls. - host/hos_update.c: main's builder_local (NotRexed#2367, a hookshot.exe in the game folder) is taken as main takes it. Otherwise builder= still has to pass builder_ok with builder_args=, and must be a file. The builder_args read moved up before the offer, where builder_ok needs it, so main's second read after the answer is dropped. is_link and local_builder are both kept. - nx2/nup.py: main split out package_parts, is_link and Destination. is_link takes links_only, so make_game's read-back of a OneDrive folder still counts only symlinks, junctions and WSL links, through extraction_path(..., links_only=True). LINK_TAGS is kept. - scripts/ngx_sdk.py: main's loop over RUNTIMES (DLSS and Frame Generation) copies each runtime through _part, a new temp file beside it, rather than a fixed <name>.part. - tests/test_bound_keys.py: main's longer options file, and the fixture still includes hos_fopen_read.h. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com>
On Linux, the one-time move of the saves to
~/.local/share/hookshot(7eeb91a) can empty a folder outside the game folder and leave the game showing no saves, whenuser/holds a symbolic link (saves kept in a synced folder or on another drive).What happens
saves_dirmoves each entry ofuser/with a rename, or a copy and delete when the rename fails. A link is treated as the folder it names:delete_treedeletes everything in the folder the link names. That folder is the player's, not Hookshot's. This start still finds the saves, in the copy. The link itself is left inuser/, so the next start seessavein both places, keeps the old place for good, and reads the folder it emptied: no saves.../../sync) names nothing from~/.local/share/hookshot: no saves, and none can be written.Also in the same loop: when the new place already has one of the entries, the old place is kept, but the entries looked at before that one have already gone, and the game runs in the old place without them.
The change
delete_treeremoves a link without entering it, asnn_fs.c's does since the DeleteDirectoryRecursively fix.user/that is itself a link is the player's choice of place: used as it is, nothing moved.Windows is not touched (the code is under
#ifndef _WIN32).Tested
tests/test_linux_saves_move.py(new) buildssaves_dirand what it calls fromhos_main.cas it is, overhost/posix/win32_posix.c, and starts it twice on folders of its own: a link written in full, one written relative, one deeper in (user/save/<user id>), one to an entry that moves too, one that names nothing, a linkeduser/, a new place that has an entry already, and a plainuser/; with the game on another drive too (/dev/shmagainst the temp folder) where the PC has one.main: 12 of its checks fail in 6 of the cases, among them "the linked folder lost its save" and "start 2 did not use the new place".python -I -B tests/run_all.py: nothing new fails.Not tested in a built game: I checked the function as the test builds it (gcc,
-D_GNU_SOURCEasnx2/build.pypasses), not a full Linux build of the game.