extends SceneTree const SaveServiceType = preload("res://scripts/core/save_service.gd") const MigrationRegistryType = preload("res://scripts/core/save_migration_registry.gd") var _checks := 0 var _failures := 0 func _initialize() -> void: call_deferred("_run") func _run() -> void: print("S6: save rotation, recovery and migration") _test_path_safety() _test_package_internal_symlink_load_rejection() _test_package_contract_identity_and_timing() _test_autosave_rotation_and_formal_atomicity() _test_interrupted_formal_save_recovery() _test_interrupted_autosave_rotation_recovery() _test_autosave_retention_shrink_prunes_verified_generations() _test_recovery_priority_checksum_and_read_only_boundary() _test_migration_registry_continuity_and_purity() _test_current_format_snapshot_validation() _test_save_package_migration_and_pre_migration_backup() _test_migration_failure_preserves_source() if _failures == 0: print("PASS: %d checks" % _checks) else: push_error("FAIL: %d of %d checks failed" % [_failures, _checks]) quit(_failures) func _test_path_safety() -> void: var snapshot := _snapshot(10, &"safe") var traversal := SaveServiceType.save_snapshot("user://../s6_escape", snapshot) _expect(not traversal[&"ok"] and traversal[&"code"] == &"UNSAFE_PATH", "parent traversal is rejected before any write") var relative := SaveServiceType.save_snapshot("relative/s6_save", snapshot) _expect(not relative[&"ok"] and relative[&"code"] == &"INVALID_PATH", "ambiguous relative save paths are rejected") var external := SaveServiceType.save_snapshot("/tmp/the_tree_s6_escape", snapshot) _expect(not external[&"ok"] and external[&"code"] == &"UNSAFE_PATH", "absolute paths outside the Godot user directory are rejected") _expect(not SaveServiceType.delete_save("user://"), "delete refuses the broad user data root") _expect(not SaveServiceType.delete_save("/"), "delete refuses the filesystem root") var non_package_path := ProjectSettings.globalize_path("user://saves/s6_non_package_parent") DirAccess.make_dir_recursive_absolute(non_package_path) var keep_file := FileAccess.open(non_package_path.path_join("keep.txt"), FileAccess.WRITE) keep_file.store_string("must remain") keep_file = null _expect(not SaveServiceType.delete_save("user://saves/s6_non_package_parent"), "delete refuses an ordinary parent directory that is not a save package") _expect(FileAccess.file_exists(non_package_path.path_join("keep.txt")), "refused parent deletion preserves unrelated user data") _expect(not SaveServiceType.save_snapshot("user://saves/s6_non_package_parent", snapshot)[&"ok"], "save refuses to rotate an ordinary directory into a package backup") DirAccess.remove_absolute(non_package_path.path_join("keep.txt")) DirAccess.remove_absolute(non_package_path) var external_path := "/tmp/the_tree_s6_symlink_%d" % OS.get_process_id() var link_path := ProjectSettings.globalize_path("user://saves/s6_symlink_parent") DirAccess.make_dir_recursive_absolute(external_path) var sentinel := FileAccess.open(external_path.path_join("outside.txt"), FileAccess.WRITE) sentinel.store_string("outside") sentinel = null DirAccess.remove_absolute(link_path) var link_exit := OS.execute("ln", PackedStringArray(["-s", external_path, link_path])) _expect(link_exit == 0, "symlink escape fixture is available") if link_exit == 0: var linked_save := SaveServiceType.save_snapshot("user://saves/s6_symlink_parent/escaped", snapshot) _expect(not linked_save[&"ok"] and linked_save[&"code"] == &"UNSAFE_PATH", "write refuses a save directory reached through a symbolic link") _expect(not SaveServiceType.delete_save("user://saves/s6_symlink_parent/escaped"), "delete refuses a directory reached through a symbolic link") _expect(FileAccess.get_file_as_string(external_path.path_join("outside.txt")) == "outside", "symlink rejection never touches data outside user storage") DirAccess.remove_absolute(link_path) var recursive_save_path := "user://saves/s6_recursive_symlink" SaveServiceType.delete_save(recursive_save_path) SaveServiceType.save_snapshot(recursive_save_path, snapshot) var embedded_link := ProjectSettings.globalize_path(recursive_save_path).path_join("external_link") var embedded_link_exit := OS.execute("ln", PackedStringArray(["-s", external_path, embedded_link])) _expect(embedded_link_exit == 0, "recursive symlink fixture is available") if embedded_link_exit == 0: var recursive_write := SaveServiceType.save_snapshot(recursive_save_path, snapshot) _expect(not recursive_write[&"ok"] and recursive_write[&"code"] == &"UNSAFE_PATH", "overwrite refuses a symbolic link nested anywhere inside the package") _expect(not SaveServiceType.delete_save(recursive_save_path), "recursive delete refuses a package containing a symbolic link") _expect(FileAccess.file_exists(external_path.path_join("outside.txt")), "recursive symlink guard preserves its external target") DirAccess.remove_absolute(embedded_link) SaveServiceType.delete_save(recursive_save_path) DirAccess.remove_absolute(external_path.path_join("outside.txt")) DirAccess.remove_absolute(external_path) func _test_package_internal_symlink_load_rejection() -> void: var save_path := "user://saves/s6_internal_world_link" var external_package := "user://saves/s6_external_valid_world" SaveServiceType.delete_save(save_path) SaveServiceType.delete_save(external_package) var shared_snapshot := _snapshot(44, &"external_valid") _expect(SaveServiceType.save_snapshot(save_path, shared_snapshot)[&"ok"], "internal-link fixture target package is valid before substitution") _expect(SaveServiceType.save_snapshot(external_package, shared_snapshot)[&"ok"], "external world fixture is a separately verified package") var target_world := ProjectSettings.globalize_path(save_path).path_join(SaveServiceType.WORLD_FILE) var external_world := ProjectSettings.globalize_path(external_package).path_join(SaveServiceType.WORLD_FILE) DirAccess.remove_absolute(target_world) var link_exit := OS.execute("ln", PackedStringArray(["-s", external_world, target_world])) _expect(link_exit == 0, "known package-file symlink fixture is available") if link_exit == 0: var direct := SaveServiceType.load_snapshot(save_path) _expect(not direct[&"ok"] and direct[&"code"] == &"UNSAFE_PATH", "direct load rejects a world.bin symlink even when its external bytes are valid") var explicit := SaveServiceType.load_recovery_candidate(save_path, &"formal") _expect(not explicit[&"ok"] and explicit[&"code"] == &"UNSAFE_PATH", "explicit recovery-candidate load rejects an internal package symlink") var inspected := SaveServiceType.inspect_recovery_candidates(save_path) _expect(not inspected[&"ok"] and inspected[&"code"] == &"UNSAFE_PATH", "recovery inspection rejects an internal package symlink before opening it") _expect(FileAccess.file_exists(external_world), "load rejection leaves the external valid world untouched") DirAccess.remove_absolute(target_world) SaveServiceType.delete_save(save_path) SaveServiceType.delete_save(external_package) func _test_package_contract_identity_and_timing() -> void: var save_path := "user://saves/s6_contract" SaveServiceType.delete_save(save_path) SaveServiceType.clear_performance_reports() var first := SaveServiceType.save_snapshot(save_path, _snapshot(60, &"first")) _expect(first[&"ok"] and int(first[&"duration_us"]) >= 0, "save result reports actual elapsed time") var package_path := ProjectSettings.globalize_path(save_path) var manifest: Dictionary = JSON.parse_string(FileAccess.get_file_as_string(package_path.path_join(SaveServiceType.MANIFEST_FILE))) _expect(str(manifest["save_id"]) == "s6_contract" and str(manifest["display_name"]) == "s6_contract", "default identity comes from the logical slot rather than its temporary directory") var contract_keys := ["format_version", "game_version", "save_id", "display_name", "created_at_real", "updated_at_real", "absolute_minute", "day", "seed", "checksum_world", "content_pack_ids", "enabled_filter_profile"] var all_contract_keys := true for key: String in contract_keys: all_contract_keys = all_contract_keys and manifest.has(key) _expect(all_contract_keys, "manifest generates every required package-contract field") var thumbnail_bytes := FileAccess.get_file_as_bytes(package_path.path_join(SaveServiceType.THUMBNAIL_FILE)) var thumbnail := Image.new() _expect(not thumbnail_bytes.is_empty() and thumbnail.load_png_from_buffer(thumbnail_bytes) == OK, "save package always contains a valid thumbnail PNG") var created_at := str(manifest["created_at_real"]) var second := SaveServiceType.save_snapshot(save_path, _snapshot(61, &"second"), { &"display_name": "Chosen Name", &"content_pack_ids": ["base"], &"custom_note": "preserved", }) _expect(second[&"ok"], "package overwrite accepts supplied metadata") var overwritten := SaveServiceType.load_snapshot(save_path) var second_manifest: Dictionary = overwritten[&"manifest"] _expect(str(second_manifest["save_id"]) == "s6_contract" and str(second_manifest["display_name"]) == "Chosen Name", "overwrite preserves prior ID while honoring an explicit display name") _expect(str(second_manifest["created_at_real"]) == created_at and second_manifest["content_pack_ids"] == ["base"] and str(second_manifest["custom_note"]) == "preserved", "overwrite preserves creation identity and custom contract metadata") _expect(FileAccess.get_file_as_bytes(package_path.path_join(SaveServiceType.THUMBNAIL_FILE)) == thumbnail_bytes, "overwrite preserves the previous thumbnail when none is supplied") var third := SaveServiceType.save_snapshot(save_path, _snapshot(62, &"third")) _expect(third[&"ok"] and str(third[&"manifest"]["display_name"]) == "Chosen Name", "later overwrite retains prior display identity by default") _expect(SaveServiceType.get_performance_report(&"save")[&"lifetime_sample_count"] == 3, "every completed save attempt feeds the save performance budget") _expect(SaveServiceType.get_performance_report(&"load")[&"lifetime_sample_count"] == 1 and int(overwritten[&"duration_us"]) >= 0, "load operations feed the load performance budget and report elapsed time") SaveServiceType.delete_save(save_path) func _test_autosave_rotation_and_formal_atomicity() -> void: var save_path := "user://saves/s6_rotation" SaveServiceType.delete_save(save_path) _expect(SaveServiceType.save_snapshot(save_path, _snapshot(10, &"formal_old"))[&"ok"], "initial formal save succeeds") _expect(SaveServiceType.save_snapshot(save_path, _snapshot(20, &"formal_current"))[&"ok"], "formal save keeps a verified backup") for minute in [101, 102, 103, 104]: var result := SaveServiceType.save_autosave(save_path, _snapshot(minute, StringName("auto_%d" % minute))) _expect(result[&"ok"] and result[&"formal_save_preserved"], "autosave %d rotates without replacing the formal save" % minute) var expected_minutes := [104, 103, 102] for generation in range(3): var loaded := SaveServiceType.load_snapshot(SaveServiceType.get_autosave_path(save_path, generation)) _expect(loaded[&"ok"], "autosave generation %d remains checksum-valid" % generation) if loaded[&"ok"]: _expect(int(loaded[&"snapshot"][&"clock"][&"absolute_minute"]) == expected_minutes[generation], "generation %d has deterministic newest-first order" % generation) _expect(not DirAccess.dir_exists_absolute(ProjectSettings.globalize_path(save_path + ".autosave.3")), "rotation retains exactly three generations") var formal_world_path := ProjectSettings.globalize_path(save_path).path_join(SaveServiceType.WORLD_FILE) var formal_before := FileAccess.get_file_as_bytes(formal_world_path) var invalid := _snapshot(999, &"invalid") invalid[&"schema_version"] = 999 var failed := SaveServiceType.save_autosave(save_path, invalid) _expect(not failed[&"ok"] and failed[&"code"] == &"INVALID_SNAPSHOT", "invalid autosave candidate fails before rotation") _expect(failed[&"formal_save_preserved"] and FileAccess.get_file_as_bytes(formal_world_path) == formal_before, "failed autosave leaves the formal package byte-identical") var formal_loaded := SaveServiceType.load_snapshot(save_path) _expect(formal_loaded[&"ok"] and _marker(formal_loaded[&"snapshot"]) == &"formal_current", "formal save remains loadable after autosave failure") var newest := SaveServiceType.load_snapshot(SaveServiceType.get_autosave_path(save_path, 0)) _expect(newest[&"ok"] and _marker(newest[&"snapshot"]) == &"auto_104", "failed candidate does not disturb the previous newest autosave") SaveServiceType.delete_save(save_path) func _test_interrupted_formal_save_recovery() -> void: var save_path := "user://saves/s6_formal_transaction" var candidate_path := "user://saves/s6_formal_transaction_candidate" SaveServiceType.delete_save(save_path) SaveServiceType.delete_save(candidate_path) _expect(SaveServiceType.save_snapshot(save_path, _snapshot(10, &"durable_old"))[&"ok"], "formal crash fixture writes its durable predecessor") _expect(SaveServiceType.save_snapshot(candidate_path, _snapshot(20, &"durable_new"))[&"ok"], "formal crash fixture writes a verified replacement candidate") var target_absolute := ProjectSettings.globalize_path(save_path) var candidate_absolute := ProjectSettings.globalize_path(candidate_path) _copy_fixture_tree(candidate_absolute, target_absolute + ".tmp") DirAccess.rename_absolute(target_absolute, target_absolute + ".bak") _write_transaction_marker(target_absolute) var promoted := SaveServiceType.load_snapshot(save_path) _expect(promoted[&"ok"] and promoted[&"interrupted_save_reconciled"], "load reconciles a crash between formal-to-backup and candidate promotion") _expect(_marker(promoted[&"snapshot"]) == &"durable_new", "reconciliation promotes only the verified temporary package") var preserved_backup := SaveServiceType.load_snapshot(save_path + ".bak") _expect(preserved_backup[&"ok"] and _marker(preserved_backup[&"snapshot"]) == &"durable_old", "candidate promotion retains the verified prior formal backup") _expect(not FileAccess.file_exists(target_absolute + SaveServiceType.SAVE_TRANSACTION_SUFFIX), "successful formal reconciliation clears its durable marker") SaveServiceType.delete_save(save_path) _expect(SaveServiceType.save_snapshot(save_path, _snapshot(30, &"rollback_old"))[&"ok"], "corrupt-candidate fixture writes its verified backup source") _copy_fixture_tree(candidate_absolute, target_absolute + ".tmp") _corrupt_world(save_path + ".tmp") DirAccess.rename_absolute(target_absolute, target_absolute + ".bak") _write_transaction_marker(target_absolute) var rolled_back := SaveServiceType.load_snapshot(save_path) _expect(rolled_back[&"ok"] and _marker(rolled_back[&"snapshot"]) == &"rollback_old", "corrupt temporary candidate is never promoted and verified backup is restored") _expect(not DirAccess.dir_exists_absolute(target_absolute + ".tmp"), "verified backup recovery discards only the marker-owned corrupt temporary package") _expect(not FileAccess.file_exists(target_absolute + SaveServiceType.SAVE_TRANSACTION_SUFFIX), "verified backup recovery clears the completed transaction marker") SaveServiceType.delete_save(save_path) _expect(SaveServiceType.save_snapshot(save_path, _snapshot(40, &"backup_only"))[&"ok"], "backup-only crash fixture writes a verified formal source") DirAccess.rename_absolute(target_absolute, target_absolute + ".bak") _write_transaction_marker(target_absolute) var inspected := SaveServiceType.inspect_recovery_candidates(save_path) _expect(inspected[&"ok"] and inspected[&"reconciliation"][&"formal_save"][&"action"] == &"restored_verified_backup", "candidate inspection restores a verified backup when no temporary candidate exists") _expect(_marker(inspected[&"snapshot"]) == &"backup_only" and inspected[&"selected"][&"id"] == &"formal", "backup-only reconciliation recreates the formal package before candidate selection") SaveServiceType.delete_save(save_path) SaveServiceType.delete_save(candidate_path) func _test_interrupted_autosave_rotation_recovery() -> void: var save_path := "user://saves/s6_rotation_recovery" SaveServiceType.delete_save(save_path) SaveServiceType.save_snapshot(save_path, _snapshot(5, &"formal")) for minute in [10, 20, 30]: SaveServiceType.save_autosave(save_path, _snapshot(minute, StringName("auto_%d" % minute))) var target_path := ProjectSettings.globalize_path(save_path) var pending_path := target_path + ".autosave.pending" _copy_fixture_tree(target_path + SaveServiceType.AUTOSAVE_SUFFIX + "0", pending_path) for generation in range(3): var generation_path := target_path + SaveServiceType.AUTOSAVE_SUFFIX + str(generation) DirAccess.rename_absolute(generation_path, generation_path + ".rotate_tmp") var after_rollback := SaveServiceType.save_autosave(save_path, _snapshot(40, &"auto_40")) _expect(after_rollback[&"ok"] and after_rollback[&"interrupted_rotation_reconciled"], "next autosave rolls back an interrupted pre-promotion rotation before proceeding") var expected_after_rollback := [40, 30, 20] for generation in range(3): var loaded := SaveServiceType.load_snapshot(SaveServiceType.get_autosave_path(save_path, generation)) _expect(int(loaded[&"snapshot"][&"clock"][&"absolute_minute"]) == expected_after_rollback[generation], "rollback recovery preserves generation %d order" % generation) # Simulate a crash after the new generation zero was promoted but before old # generations were shifted out of their transaction holds. for generation in range(3): var generation_path := target_path + SaveServiceType.AUTOSAVE_SUFFIX + str(generation) DirAccess.rename_absolute(generation_path, generation_path + ".rotate_tmp") _copy_fixture_tree(target_path, target_path + SaveServiceType.AUTOSAVE_SUFFIX + "0") var recovery := SaveServiceType.inspect_recovery_candidates(save_path) _expect(recovery[&"ok"] and recovery[&"reconciliation"][&"autosave"][&"action"] == &"completed", "recovery inspection completes an interrupted post-promotion rotation") var completed_zero := SaveServiceType.load_snapshot(SaveServiceType.get_autosave_path(save_path, 0)) var completed_one := SaveServiceType.load_snapshot(SaveServiceType.get_autosave_path(save_path, 1)) var completed_two := SaveServiceType.load_snapshot(SaveServiceType.get_autosave_path(save_path, 2)) _expect(_marker(completed_zero[&"snapshot"]) == &"formal", "post-promotion recovery keeps the already-promoted newest package") _expect(_marker(completed_one[&"snapshot"]) == &"auto_40" and _marker(completed_two[&"snapshot"]) == &"auto_30", "post-promotion recovery completes the remaining generation shifts exactly once") _expect(not DirAccess.dir_exists_absolute(target_path + SaveServiceType.AUTOSAVE_SUFFIX + "0.rotate_tmp"), "rotation recovery removes durable hold markers after verification") SaveServiceType.delete_save(save_path) func _test_autosave_retention_shrink_prunes_verified_generations() -> void: var save_path := "user://saves/s6_retention_shrink" SaveServiceType.delete_save(save_path) for minute in [10, 20, 30, 40, 50]: _expect(SaveServiceType.save_autosave(save_path, _snapshot(minute, StringName("wide_%d" % minute)), {}, 4)[&"ok"], "four-generation autosave fixture accepts minute %d" % minute) var shrink := SaveServiceType.save_autosave(save_path, _snapshot(60, &"narrow_60"), {}, 2) _expect(shrink[&"ok"] and shrink[&"pruned_generations"] == [2, 3], "successful 4-to-2 retention change reports every pruned verified generation") var newest := SaveServiceType.load_snapshot(SaveServiceType.get_autosave_path(save_path, 0)) var previous := SaveServiceType.load_snapshot(SaveServiceType.get_autosave_path(save_path, 1)) _expect(_marker(newest[&"snapshot"]) == &"narrow_60" and _marker(previous[&"snapshot"]) == &"wide_50", "retention shrink preserves deterministic newest-first generations") var target_absolute := ProjectSettings.globalize_path(save_path) _expect(not DirAccess.dir_exists_absolute(target_absolute + SaveServiceType.AUTOSAVE_SUFFIX + "2") and not DirAccess.dir_exists_absolute(target_absolute + SaveServiceType.AUTOSAVE_SUFFIX + "3"), "retention shrink removes all verified generations above the new limit") SaveServiceType.delete_save(save_path) var conflict_path := "user://saves/s6_retention_conflict" SaveServiceType.delete_save(conflict_path) _expect(SaveServiceType.save_autosave(conflict_path, _snapshot(10, &"conflict_base"), {}, 4)[&"ok"], "retention conflict fixture starts with a valid owned generation") var conflict_absolute := ProjectSettings.globalize_path(conflict_path) var foreign_generation := conflict_absolute + SaveServiceType.AUTOSAVE_SUFFIX + "3" DirAccess.make_dir_recursive_absolute(foreign_generation) var sentinel := FileAccess.open(foreign_generation.path_join("foreign.txt"), FileAccess.WRITE) sentinel.store_string("preserve") sentinel = null var newest_before := FileAccess.get_file_as_bytes((conflict_absolute + SaveServiceType.AUTOSAVE_SUFFIX + "0").path_join(SaveServiceType.WORLD_FILE)) var foreign_rejected := SaveServiceType.save_autosave(conflict_path, _snapshot(20, &"must_not_commit"), {}, 2) _expect(not foreign_rejected[&"ok"] and foreign_rejected[&"code"] == &"AUTOSAVE_PRUNE_CONFLICT", "retention shrink refuses a foreign non-package generation") _expect(FileAccess.get_file_as_string(foreign_generation.path_join("foreign.txt")) == "preserve" and FileAccess.get_file_as_bytes((conflict_absolute + SaveServiceType.AUTOSAVE_SUFFIX + "0").path_join(SaveServiceType.WORLD_FILE)) == newest_before, "foreign prune conflict preserves both foreign data and the prior newest autosave") DirAccess.remove_absolute(foreign_generation.path_join("foreign.txt")) DirAccess.remove_absolute(foreign_generation) var external_path := ProjectSettings.globalize_path("user://saves/s6_retention_external") DirAccess.make_dir_recursive_absolute(external_path) var external_sentinel := FileAccess.open(external_path.path_join("outside.txt"), FileAccess.WRITE) external_sentinel.store_string("outside") external_sentinel = null var linked_generation := conflict_absolute + SaveServiceType.AUTOSAVE_SUFFIX + "3" var link_exit := OS.execute("ln", PackedStringArray(["-s", external_path, linked_generation])) _expect(link_exit == 0, "retention prune symlink fixture is available") if link_exit == 0: var linked_rejected := SaveServiceType.save_autosave(conflict_path, _snapshot(30, &"must_not_follow"), {}, 2) _expect(not linked_rejected[&"ok"] and linked_rejected[&"code"] == &"UNSAFE_PATH", "retention shrink refuses a symlink generation before prune") _expect(FileAccess.get_file_as_string(external_path.path_join("outside.txt")) == "outside", "prune rejection never follows or deletes the symlink target") DirAccess.remove_absolute(linked_generation) SaveServiceType.delete_save(conflict_path) DirAccess.remove_absolute(external_path.path_join("outside.txt")) DirAccess.remove_absolute(external_path) func _test_recovery_priority_checksum_and_read_only_boundary() -> void: var save_path := "user://saves/s6_recovery" SaveServiceType.delete_save(save_path) SaveServiceType.save_snapshot(save_path, _snapshot(10, &"backup")) SaveServiceType.save_snapshot(save_path, _snapshot(20, &"formal")) for minute in [101, 102, 103, 104]: SaveServiceType.save_autosave(save_path, _snapshot(minute, StringName("auto_%d" % minute))) var all_valid := SaveServiceType.inspect_recovery_candidates(save_path) _expect(all_valid[&"ok"] and all_valid[&"selected"][&"id"] == &"formal", "recovery prefers a valid formal save") var ids: Array[StringName] = [] for candidate: Dictionary in all_valid[&"candidates"]: ids.append(StringName(candidate[&"id"])) _expect(ids == [&"formal", &"backup", &"migration_original", &"pre_migration_backup", &"autosave_0", &"autosave_1", &"autosave_2"], "recovery candidate order explicitly includes migration safety packages") _corrupt_world(save_path) var formal_corrupt := SaveServiceType.inspect_recovery_candidates(save_path) _expect(formal_corrupt[&"ok"] and formal_corrupt[&"selected"][&"id"] == &"backup", "checksum failure in formal save selects the verified formal backup") _expect(formal_corrupt[&"candidates"][0][&"code"] == &"CHECKSUM_MISMATCH", "corrupt formal world is reported as checksum mismatch") _corrupt_world(save_path + ".bak") _corrupt_world(SaveServiceType.get_autosave_path(save_path, 0)) var observed_manifest_path := ProjectSettings.globalize_path(SaveServiceType.get_autosave_path(save_path, 1)).path_join(SaveServiceType.MANIFEST_FILE) var manifest_before := FileAccess.get_file_as_string(observed_manifest_path) var fallback := SaveServiceType.load_with_recovery(save_path) var manifest_after := FileAccess.get_file_as_string(observed_manifest_path) _expect(fallback[&"ok"] and fallback[&"selected"][&"id"] == &"autosave_1", "corrupt latest candidates fall back to the next valid autosave generation") _expect(_marker(fallback[&"snapshot"]) == &"auto_103", "fallback returns the exact older generation snapshot") _expect(manifest_before == manifest_after, "recovery inspection is read-only and does not promote or rewrite candidates") var explicit := SaveServiceType.load_recovery_candidate(save_path, &"autosave", 2) _expect(explicit[&"ok"] and explicit[&"read_only"] and _marker(explicit[&"snapshot"]) == &"auto_102", "caller can explicitly select a valid recovery generation") var report: Dictionary = explicit[&"recovery_report"] _expect(not report[&"automatic_repairs_applied"] and report[&"repair_policy"] == &"REPORT_ONLY_AUTHORITATIVE_SERVICE_REQUIRED", "recovery report never performs implicit domain repairs") _expect(report[&"findings"].has(&"orphan_tasks") and report[&"findings"].has(&"orphan_event_locks"), "recovery report exposes Task and Event-lock handoff boundaries") SaveServiceType.delete_save(save_path) func _test_migration_registry_continuity_and_purity() -> void: var registry := MigrationRegistryType.new() _expect(registry.register_step(1, &"add_first", Callable(self, "_migration_add_first"))[&"ok"], "adjacent migration step 1 -> 2 registers") _expect(registry.register_step(2, &"add_second", Callable(self, "_migration_add_second"))[&"ok"], "adjacent migration step 2 -> 3 registers") var source := {&"nested": {&"history": []}, &"stable": 7} var source_guard := source.duplicate(true) var migrated := registry.migrate(source, 1, 3) _expect(migrated[&"ok"], "N -> N+2 migration succeeds through continuous adjacent steps") _expect(migrated[&"applied_migration_ids"] == [&"add_first", &"add_second"], "migration registry applies every step in exact version order") _expect(migrated[&"data"][&"nested"][&"history"] == [&"one", &"two"], "second step observes the first step output") _expect(source == source_guard and source[&"nested"][&"history"].is_empty(), "migration callbacks cannot mutate the caller-owned source") var missing := MigrationRegistryType.new() missing.register_step(1, &"only_first", Callable(self, "_migration_add_first")) var missing_result := missing.migrate(source, 1, 3) _expect(not missing_result[&"ok"] and missing_result[&"code"] == &"MIGRATION_STEP_MISSING", "registry refuses to skip a missing N+1 -> N+2 step") _expect(missing_result[&"required_migration_id"] == &"format_2_to_3", "missing step returns its stable required migration ID") _expect(missing_result[&"applied_migration_ids"] == [&"only_first"], "failure report preserves the exact completed-step audit trail") var failing := MigrationRegistryType.new() failing.register_step(1, &"known_failure", Callable(self, "_migration_reject")) var failed := failing.migrate(source, 1, 2) _expect(not failed[&"ok"] and failed[&"failed_migration_id"] == &"known_failure", "failed migration reports the exact registered migration ID") _expect(failed[&"code"] == &"FIXTURE_REJECTED" and source == source_guard, "failed migration propagates its reason and preserves source purity") func _test_current_format_snapshot_validation() -> void: var save_path := "user://saves/s6_current_invalid" SaveServiceType.delete_save(save_path) SaveServiceType.save_snapshot(save_path, _snapshot(180, &"current_invalid")) _make_legacy_format_zero(save_path, SaveServiceType.FORMAT_VERSION) var direct_load := SaveServiceType.load_snapshot(save_path) _expect(not direct_load[&"ok"] and direct_load[&"code"] == &"INVALID_SNAPSHOT", "current-format load still enforces the current snapshot schema") var migration_attempt := SaveServiceType.migrate_save(save_path, MigrationRegistryType.new(), SaveServiceType.FORMAT_VERSION) _expect(not migration_attempt[&"ok"] and migration_attempt[&"code"] == &"INVALID_SNAPSHOT", "no-op migration cannot bypass current snapshot validation") SaveServiceType.delete_save(save_path) func _test_save_package_migration_and_pre_migration_backup() -> void: var save_path := "user://saves/s6_migration" SaveServiceType.delete_save(save_path) SaveServiceType.save_snapshot(save_path, _snapshot(200, &"legacy"), {&"display_name": "Legacy fixture"}) _make_legacy_format_zero(save_path) var package_path := ProjectSettings.globalize_path(save_path) var world_before := FileAccess.get_file_as_bytes(package_path.path_join(SaveServiceType.WORLD_FILE)) var manifest_before := FileAccess.get_file_as_string(package_path.path_join(SaveServiceType.MANIFEST_FILE)) var unsupported := SaveServiceType.load_snapshot(save_path) _expect(not unsupported[&"ok"] and unsupported[&"code"] == &"UNSUPPORTED_FORMAT", "legacy package is not loaded before explicit migration") var registry := MigrationRegistryType.new() registry.register_step(0, &"legacy_zero_to_current", Callable(self, "_migration_package_zero_to_one")) var result := SaveServiceType.migrate_save(save_path, registry, SaveServiceType.FORMAT_VERSION) _expect(result[&"ok"] and result[&"backup_created"], "package migration verifies and promotes a current-format candidate") _expect(result[&"applied_migration_ids"] == [&"legacy_zero_to_current"], "package migration returns its applied migration audit trail") var current := SaveServiceType.load_snapshot(save_path) _expect(current[&"ok"] and current[&"snapshot"][&"systems"][&"migrated_from"] == 0, "promoted package contains the pure migration output") _expect(str(current[&"manifest"]["migration_note"]) == "zero-to-one", "migration preserves step-authored manifest metadata") var backup_path := ProjectSettings.globalize_path(save_path + SaveServiceType.MIGRATION_BACKUP_SUFFIX) _expect(DirAccess.dir_exists_absolute(backup_path), "migration creates a dedicated pre-migration backup package") _expect(FileAccess.get_file_as_bytes(backup_path.path_join(SaveServiceType.WORLD_FILE)) == world_before, "pre-migration backup preserves world bytes exactly") _expect(FileAccess.get_file_as_string(backup_path.path_join(SaveServiceType.MANIFEST_FILE)) == manifest_before, "pre-migration backup preserves the original manifest exactly") var recovery_before_crash := SaveServiceType.inspect_recovery_candidates(save_path) var migration_backup_info: Dictionary = recovery_before_crash[&"candidates"][3] _expect(migration_backup_info[&"id"] == &"pre_migration_backup" and migration_backup_info[&"package_valid"] and migration_backup_info[&"requires_migration"], "recovery inventory retains a checksum-valid legacy migration backup without treating it as runtime-loadable") var backup_old_path := backup_path + ".old" _copy_fixture_tree(backup_path, backup_old_path) _corrupt_world(save_path + SaveServiceType.MIGRATION_BACKUP_SUFFIX) var repaired_backup_inventory := SaveServiceType.inspect_recovery_candidates(save_path) _expect(repaired_backup_inventory[&"candidates"][3][&"package_valid"] and not DirAccess.dir_exists_absolute(backup_old_path), "recovery replaces a torn pre-migration backup from its verified durable predecessor") # Crash boundary: original was durably held and candidate was complete, but # candidate had not yet been promoted to the formal name. var candidate_path := package_path + ".migration.tmp" var original_path := package_path + ".migration.original" _copy_fixture_tree(package_path, candidate_path) DirAccess.rename_absolute(package_path, original_path) var recovered := SaveServiceType.inspect_recovery_candidates(save_path) _expect(recovered[&"ok"] and recovered[&"reconciliation"][&"migration"][&"action"] == &"completed_promotion", "recovery completes an interrupted migration promotion from durable markers") _expect(SaveServiceType.load_snapshot(save_path)[&"ok"] and not DirAccess.dir_exists_absolute(original_path) and not DirAccess.dir_exists_absolute(candidate_path), "migration reconciliation verifies the formal package before clearing crash markers") DirAccess.rename_absolute(package_path, original_path) var rolled_back := SaveServiceType.inspect_recovery_candidates(save_path) _expect(rolled_back[&"ok"] and rolled_back[&"reconciliation"][&"migration"][&"action"] == &"rolled_back_to_original", "recovery rolls back a held original when no promotable candidate exists") _expect(SaveServiceType.load_snapshot(save_path)[&"ok"] and not DirAccess.dir_exists_absolute(original_path), "rollback restores the original formal name without losing the verified package") SaveServiceType.delete_save(save_path) func _test_migration_failure_preserves_source() -> void: var save_path := "user://saves/s6_migration_failure" SaveServiceType.delete_save(save_path) SaveServiceType.save_snapshot(save_path, _snapshot(300, &"must_survive")) _make_legacy_format_zero(save_path) var package_path := ProjectSettings.globalize_path(save_path) var world_before := FileAccess.get_file_as_bytes(package_path.path_join(SaveServiceType.WORLD_FILE)) var manifest_before := FileAccess.get_file_as_string(package_path.path_join(SaveServiceType.MANIFEST_FILE)) var registry := MigrationRegistryType.new() registry.register_step(0, &"reject_legacy", Callable(self, "_migration_reject")) var result := SaveServiceType.migrate_save(save_path, registry, SaveServiceType.FORMAT_VERSION) _expect(not result[&"ok"] and result[&"failed_migration_id"] == &"reject_legacy", "package migration exposes the failing step ID") _expect(FileAccess.get_file_as_bytes(package_path.path_join(SaveServiceType.WORLD_FILE)) == world_before, "failed step leaves the formal world byte-identical") _expect(FileAccess.get_file_as_string(package_path.path_join(SaveServiceType.MANIFEST_FILE)) == manifest_before, "failed step leaves the formal manifest byte-identical") _expect(not DirAccess.dir_exists_absolute(ProjectSettings.globalize_path(save_path + SaveServiceType.MIGRATION_BACKUP_SUFFIX)), "failed in-memory migration creates no misleading backup or partial promotion") SaveServiceType.delete_save(save_path) func _migration_add_first(data: Dictionary, from_version: int, to_version: int) -> Dictionary: data[&"nested"][&"history"].append(&"one") data[&"first_edge"] = [from_version, to_version] return data func _migration_add_second(data: Dictionary, from_version: int, to_version: int) -> Dictionary: data[&"nested"][&"history"].append(&"two") data[&"second_edge"] = [from_version, to_version] return {&"ok": true, &"data": data} func _migration_reject(data: Dictionary, _from_version: int, _to_version: int) -> Dictionary: data[&"would_have_mutated"] = true return {&"ok": false, &"code": &"FIXTURE_REJECTED", &"message": "intentional test rejection"} func _migration_package_zero_to_one(data: Dictionary, from_version: int, to_version: int) -> Dictionary: var legacy_snapshot: Dictionary = data[&"snapshot"] var restored := _snapshot( int(legacy_snapshot[&"legacy_absolute_minute"]), StringName(legacy_snapshot[&"legacy_marker"]) ) restored[&"systems"][&"migrated_from"] = from_version restored[&"systems"][&"migrated_to"] = to_version data[&"snapshot"] = restored data[&"manifest"][&"migration_note"] = "zero-to-one" return data func _make_legacy_format_zero(save_path: String, package_format_version := 0) -> void: var package_path := ProjectSettings.globalize_path(save_path) var current := SaveServiceType.load_snapshot(save_path) var current_snapshot: Dictionary = current[&"snapshot"] var legacy_snapshot := { &"legacy_schema": 0, &"legacy_absolute_minute": int(current_snapshot[&"clock"][&"absolute_minute"]), &"legacy_marker": _marker(current_snapshot), } var raw_world := var_to_bytes(legacy_snapshot) var compressed_world := raw_world.compress(FileAccess.COMPRESSION_ZSTD) var manifest_path := package_path.path_join(SaveServiceType.MANIFEST_FILE) var manifest_value: Variant = JSON.parse_string(FileAccess.get_file_as_string(manifest_path)) var manifest: Dictionary = manifest_value manifest["format_version"] = package_format_version manifest["checksum_world"] = _sha256(compressed_world) manifest["uncompressed_size"] = raw_world.size() var manifest_file := FileAccess.open(manifest_path, FileAccess.WRITE) manifest_file.store_string(JSON.stringify(manifest, "\t")) manifest_file = null var world_path := package_path.path_join(SaveServiceType.WORLD_FILE) var world_file := FileAccess.open(world_path, FileAccess.WRITE) world_file.store_buffer(SaveServiceType.MAGIC.to_ascii_buffer()) world_file.store_32(package_format_version) world_file.store_64(raw_world.size()) world_file.store_buffer(compressed_world) world_file = null func _copy_fixture_tree(source_path: String, destination_path: String) -> void: DirAccess.make_dir_recursive_absolute(destination_path) var source := DirAccess.open(source_path) source.list_dir_begin() var name := source.get_next() while not name.is_empty(): if name != "." and name != "..": var source_child := source_path.path_join(name) var destination_child := destination_path.path_join(name) if source.current_is_dir(): _copy_fixture_tree(source_child, destination_child) else: DirAccess.copy_absolute(source_child, destination_child) name = source.get_next() source.list_dir_end() func _write_transaction_marker(target_absolute: String) -> void: var marker := FileAccess.open(target_absolute + SaveServiceType.SAVE_TRANSACTION_SUFFIX, FileAccess.WRITE) marker.store_string(SaveServiceType.SAVE_TRANSACTION_MARKER) marker = null func _sha256(bytes: PackedByteArray) -> String: var context := HashingContext.new() context.start(HashingContext.HASH_SHA256) context.update(bytes) return context.finish().hex_encode() func _corrupt_world(save_path: String) -> void: var world_path := ProjectSettings.globalize_path(save_path).path_join(SaveServiceType.WORLD_FILE) var bytes := FileAccess.get_file_as_bytes(world_path) if bytes.is_empty(): return bytes[bytes.size() - 1] = bytes[bytes.size() - 1] ^ 0x5a var file := FileAccess.open(world_path, FileAccess.WRITE) file.store_buffer(bytes) func _snapshot(absolute_minute: int, marker: StringName) -> Dictionary: return { &"schema_version": 1, &"clock": { &"day": 1 + absolute_minute / 1_440, &"hour": (absolute_minute / 60) % 24, &"minute": absolute_minute % 60, &"absolute_minute": absolute_minute, &"speed": 1, &"paused": true, }, &"game_state": { &"schema_version": 1, &"revision": absolute_minute, &"resources": {&"gold": 5_000}, &"flags": {}, }, &"systems": { &"marker": marker, }, } func _marker(snapshot: Dictionary) -> StringName: return StringName(snapshot[&"systems"][&"marker"]) func _expect(condition: bool, label: String) -> void: _checks += 1 if condition: print(" ✓ %s" % label) return _failures += 1 push_error(" ✗ %s" % label)