Files
TheTree_mock/tests/s6/test_save_operations.gd
T
Takeshi b84bade045 Save The Tree implementation and acceptance work in progress
Preserve production code, regression fixtures, audio assets and provenance, test infrastructure, and the original-completion plan and progress ledger. Final game and Windows acceptance remain pending; excluded local artifacts and native experiments are not part of this snapshot.
2026-10-11 15:58:00 +09:00

613 lines
41 KiB
GDScript

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()
_test_migration_hold_precedes_ordinary_backup()
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)
func _test_migration_hold_precedes_ordinary_backup() -> void:
for promoted: bool in [false, true]:
for entry: String in ["load", "inspect"]:
var slot := "user://saves/s6_migration_priority_%s_%s" % [str(promoted), entry]
var candidate_slot := slot + "_candidate"
SaveServiceType.delete_save(slot); SaveServiceType.delete_save(candidate_slot)
var previous := _snapshot(90, &"previous_ordinary")
var original := _snapshot(100, &"migration_original")
var candidate := _snapshot(110, &"migration_candidate")
_expect(SaveServiceType.save_snapshot(slot, previous)[&"ok"], "migration priority previous formal")
_expect(SaveServiceType.save_snapshot(slot, original)[&"ok"], "migration priority real ordinary backup")
_expect(SaveServiceType.save_snapshot(candidate_slot, candidate)[&"ok"], "migration priority verified candidate")
var target := ProjectSettings.globalize_path(slot)
_copy_fixture_tree(target, target + SaveServiceType.MIGRATION_BACKUP_SUFFIX)
_expect(DirAccess.rename_absolute(target, target + ".migration.original") == OK, "migration source actually held")
_copy_fixture_tree(ProjectSettings.globalize_path(candidate_slot), target if promoted else target + ".migration.tmp")
var ordinary_world_before := FileAccess.get_file_as_bytes((target + ".bak").path_join(SaveServiceType.WORLD_FILE))
var loaded: Dictionary = SaveServiceType.load_snapshot(slot) if entry == "load" else SaveServiceType.inspect_recovery_candidates(slot)
_expect(loaded[&"ok"], "migration-owned recovery succeeds via " + entry)
_expect(SaveServiceType.inspect_snapshot(slot).get(&"snapshot", {}) == candidate, "migration candidate outranks stale ordinary backup")
_expect(FileAccess.get_file_as_bytes((target + ".bak").path_join(SaveServiceType.WORLD_FILE)) == ordinary_world_before, "migration recovery never moves ordinary backup")
_expect(SaveServiceType.load_recovery_candidate(slot, &"backup").get(&"snapshot", {}) == previous, "ordinary backup whole snapshot unchanged")
_expect(SaveServiceType.load_recovery_candidate(slot, &"pre_migration_backup").get(&"snapshot", {}) == original, "migration backup whole original unchanged")
_expect(not DirAccess.dir_exists_absolute(target + ".migration.original") and not DirAccess.dir_exists_absolute(target + ".migration.tmp"), "verified migration clears only owned crash markers")
_expect(SaveServiceType.load_snapshot(slot).get(&"snapshot", {}) == candidate, "repeated migrated load exact")
SaveServiceType.delete_save(slot); SaveServiceType.delete_save(candidate_slot)