Save robustness: no soft lock on a missing level, safe writes, backup fallback #17
Loading…
Reference in a new issue
No description provided.
Delete branch "hiccup444/da-game-hiccup:save-and-level-load-robustness"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Two related robustness bugs in the run save and level loading.
A save naming a level that is not installed soft-locked the game.
LevelLoader.load_level()unloaded the current level before it checked that the new one existed. Continue with such a save (the expected case once an addon that added a level is removed, since saved content is meant to stay "inert, never deleted") left the player in an empty world with no floor and no menu, whileGame._levelpointed at the freed level. Continue stayed enabled, so every launch repeated it. From the pause menu, Load unpaused first and then failed, leaving the player unpaused but still anchored, and pause could not be reopened becauseopen_pause()sees the freed level as null.The run save's backup did not protect much.
save_state()was documented as backup-first, but nothing ever readrun.json.bak. The backup step copied whatever was at the primary, so after a bad write the next save replaced the last good backup with the corrupt file. The write went straight intorun.jsonand its result was not checked.has_state()only checked that the file existed, so Continue and Load stayed enabled for a save that could not load, and pressing them did nothing.Changes
Level load (first commit):
LevelLoader.load_level()loads the new scene, instantiates it and checks that its root is aLevel, and only then unloads the current level. A scene that will not load, or whose root is not aLevel(a broken addon), is freed and leaves the player where they were.Game.continue_game()checksLevelRegistry.has_level()before touching anything. For a missing level it keeps the save (inert, never deleted), logs a warning, leaves the player where they are and returnsfalse. It now returns a bool so callers know whether it worked.continue_game()while still paused, and only on success callsclose_pause(), which also un-anchors the rig. On failure the game stays paused with the panel up.Game._save_usable()): a save that reads and names an installed level, meaning it is registered and its scene file is present. This goes one step beyond a readable file, so the button does not light up and then silently do nothing when an addon level is missing. Pause Save refreshes Load the same way instead of assuming the save worked.SaveGame.peek_state(), which emits no signals, so lighting a button never firesload_failedorload_completed. Save files are parsed with aJSONinstance, becauseJSON.parse_string()prints an engine error for every corrupt file.Save writes and backup (second commit):
save_state()writes<path>.tmp, checksstore_string()(a bool in 4.6) andget_error(), and reads the temp file back, which also catches a failure that only shows whenclose()flushes. It then rotates the previous save to.bakonly if that file still loads, and renames the temp file into place. Any failure removes the temp file and emitssave_failed.load_state()falls back to.bakwhen the save will not load, logs a warning, and emitsload_completedwith the file it actually used. When neither file loads,load_failedcarries both reasons.has_state()andpeek_state()consider both files and stay side-effect free.Tests
tests/save_check.gd:.tmpis left after a successful save (the unit save and the game's own saves).has_state()is true,peek_state()andload_state()return the backup,load_completednames the.bak, andhas_state()andpeek_state()emitted nothing..bakstill holds the old good state, and the new state is in place.has_state()is false andload_state()returns empty with oneload_failed. In the game, Continue is disabled,continue_game()returns false and the mode stays MENU (after a check that a good save does light Continue).no_such_level, Continued from MENU: Continue is disabled,continue_game()returns false,_levelis the same level and still in the tree, the mode is MENU, the menu panel is in the tree and visible, Continue is still disabled, and the save is kept.Level, Continue goes through the loader and returns false, with the same level still in the tree, the mode still MENU and the menu panel in the tree and visible.tests/pause_check.gd:continue_game()already un-anchors the rig, so they cannot tellclose_pause()from a plain unpause.no_such_levelsave through pause Load: Load is disabled, and the action anyway stays paused with the panel visible, the same level still in the tree, the mode still GAME and the rig anchored. The save is kept.Level: Load is offered, since only loading shows the problem, and the action stays paused with the panel visible, the same level in the tree, the mode GAME and the rig anchored.The broken-level checks register a temporary entry in
LevelRegistryand restore it withLevelRegistry.reload(). The checks remove every file they create, including.tmpand.bak.Negative controls, each change reverted on its own against the final code:
load_level(), as onmain: 3 checks fail.load()but before instantiating: 3 fail.The read-back and the
store_string()/get_error()checks have no test, because a script cannot force a short write portably.Full suite list from the CI workflow: smoke_menu, arms_check, addon_check, save_check, rig_motion_check, pause_check and input_check all PASS, on the final commit and on the first commit alone (terrain_check is not on this branch). The local run was on Windows with Godot 4.6.3 and
--headless --xr-mode off; CI covers Linux. The only new log lines are expected: the warnings for a save that needs a missing level and for a save loaded from its backup, and, in the broken-level checks, the loader's error that the scene is not a Level pluscontinue_game()'s error for an unloadable level.Corrections to the review
run.jsonbefore renaming.tmpinto place, becauseDirAccess.rename_absolute()replacing an existing target differs by platform. On 4.6.3 it already replaces an existing target on both. On Windows (tested) it returned OK and the target held the new content; the engine source deletes the target and then callsMoveFileW. On Linux it callsrename(), which replaces atomically. An explicit remove is not needed, and on Linux it would add a moment with no save file. One Windows quirk: a rename whose source is missing still deletes the target and returns FAILED, so the code only renames files it knows exist.run.jsonis briefly missing between the rotation and the final move. The load fallback to.bakis what covers that window._read()sketch usedJSON.parse_string(), which on 4.6.3 printsERROR: Parse JSON failedfor each corrupt or empty file. Availability checks now read the file whenever the menu refreshes, so this uses aJSONinstance, which reports the error code silently._level.process_modeon the freed node, a script error. On 4.6.3 a reference to a freed node compares equal tonull(tested), so the_level == nullguard inopen_pause()returns early. There is no script error, but pause can no longer be opened at all, so the player cannot reach Resume and has to quit. The fix covers it the same way.load_level()unloads afterload()but beforeinstantiate(). A scene that loads but whose root is not aLevel(tested with a plainNode3Dscene) then still strands the player: the menu level leaves the tree andGame._levelkeeps pointing at it. The loader now instantiates and checks the root first.Overlap with open PRs
#11 changes the same spots in
game.gd. It adds anin_traversalearly return at the top ofcontinue_game()and a_place_paths()call at its end, and addsnot in_traversalto the Load availability line inopen_pause()and to the pause Load branch. On rebase, the early return becomesreturn false,_place_paths()goes beforereturn true, and the two conditions becomemode == Level.ContentMode.GAME and not in_traversal and _save_usable()andmode == Level.ContentMode.GAME and not in_traversal and continue_game().level_loader.gdmerges cleanly: #11'sadopt()and theunload_current()call in itsbegin_traversal()are unaffected, and every level it loads throughload_level()is aLevel. #14 touches none of these files.Sibling PRs from the same review: #15, #16, #18 and #19. Their source changes merge cleanly with each other. Several of them add checks at the same spot in
tests/pause_check.gd(and two intests/save_check.gd), so whichever merges second may need a small rebase that keeps both blocks.View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.Merge
Merge the changes and update on Forgejo.Warning: The "Autodetect manual merge" setting is not enabled for this repository, you will have to mark this pull request as manually merged afterwards.