Files
TheTree_mock/scripts/core/save_service.gd
T

1781 lines
72 KiB
GDScript

class_name SaveService
extends RefCounted
const FORMAT_VERSION := 1
const MAGIC := "TTWB"
const WORLD_FILE := "world.bin"
const MANIFEST_FILE := "manifest.json"
const THUMBNAIL_FILE := "thumbnail.png"
const JOURNAL_FILE := "journal.log"
const DEFAULT_AUTOSAVE_GENERATIONS := 3
const MAX_AUTOSAVE_GENERATIONS := 16
const AUTOSAVE_SUFFIX := ".autosave."
const MIGRATION_BACKUP_SUFFIX := ".pre_migration.bak"
const SAVE_TRANSACTION_SUFFIX := ".save_transaction"
const SAVE_TRANSACTION_MARKER := "THE_TREE_FORMAL_SAVE_V1\n"
# Technical resource limits, independent of gameplay/content values. Check
# authoritative file lengths and advertised expansion before allocating reads.
const MAX_WORLD_BYTES := 256 * 1024 * 1024
const MAX_MANIFEST_BYTES := 4 * 1024 * 1024
static var _performance_budget := SimulationPerformanceBudget.new()
static func save_snapshot(save_path: String, snapshot: Dictionary, metadata := {}) -> Dictionary:
var started_at_us := Time.get_ticks_usec()
var resolved := _resolve_safe_save_path(save_path)
if not resolved[&"ok"]:
return _complete_timed_operation(resolved, &"save", started_at_us)
var result := _save_snapshot_absolute(
resolved[&"path"],
save_path,
snapshot,
metadata,
FORMAT_VERSION,
resolved[&"path"],
resolved[&"path"]
)
return _complete_timed_operation(result, &"save", started_at_us)
static func load_snapshot(save_path: String) -> Dictionary:
var started_at_us := Time.get_ticks_usec()
var resolved := _resolve_safe_save_path(save_path)
if not resolved[&"ok"]:
return _complete_timed_operation(resolved, &"load", started_at_us)
var reconcile := _reconcile_interrupted_formal_save(resolved[&"path"])
if not reconcile[&"ok"]:
return _complete_timed_operation(reconcile, &"load", started_at_us)
var result := _load_snapshot_absolute(resolved[&"path"], FORMAT_VERSION)
result[&"interrupted_save_reconciled"] = bool(reconcile.get(&"reconciled", false))
return _complete_timed_operation(result, &"load", started_at_us)
## Reads only the named formal package. Unlike load_snapshot, this never
## reconciles, promotes, removes or migrates interrupted-save artifacts. A
## missing formal package stays missing even when a .tmp/.bak could recover it.
static func inspect_snapshot(save_path: String) -> Dictionary:
var started_at_us := Time.get_ticks_usec()
var resolved := _resolve_safe_save_path(save_path)
var result: Dictionary = resolved
if resolved[&"ok"]:
result = _load_snapshot_absolute(resolved[&"path"], FORMAT_VERSION)
result[&"read_only"] = true
return _complete_timed_operation(result, &"load", started_at_us)
static func get_performance_report(metric_id: StringName) -> Dictionary:
return _performance_budget.get_report(metric_id)
static func get_performance_reports() -> Array[Dictionary]:
return _performance_budget.get_all_reports()
static func clear_performance_reports() -> void:
_performance_budget.clear()
## Writes an autosave without touching the formal save or its .bak package.
## Generation zero is newest. Existing generations are moved transactionally;
## if candidate creation or rotation fails, the previously formal save remains
## byte-for-byte untouched.
static func save_autosave(
save_path: String,
snapshot: Dictionary,
metadata := {},
generation_count := DEFAULT_AUTOSAVE_GENERATIONS
) -> Dictionary:
var started_at_us := Time.get_ticks_usec()
if generation_count < 1 or generation_count > MAX_AUTOSAVE_GENERATIONS:
return _complete_timed_operation(
_failure(&"INVALID_GENERATION_COUNT", str(generation_count)),
&"save",
started_at_us
)
var resolved := _resolve_safe_save_path(save_path)
if not resolved[&"ok"]:
return _complete_timed_operation(resolved, &"save", started_at_us)
var target_path: String = resolved[&"path"]
var pending_path := target_path + ".autosave.pending"
var reconcile := _reconcile_autosave_rotation(target_path, pending_path, generation_count)
if not reconcile[&"ok"]:
return _complete_timed_operation(reconcile, &"save", started_at_us)
var owned_paths: Array[String] = [pending_path, pending_path + ".tmp", pending_path + ".bak"]
for generation in range(MAX_AUTOSAVE_GENERATIONS):
owned_paths.append(target_path + AUTOSAVE_SUFFIX + str(generation))
owned_paths.append(target_path + AUTOSAVE_SUFFIX + str(generation) + ".rotate_tmp")
if not _operation_paths_are_safe(owned_paths):
return _complete_timed_operation(
_failure(&"UNSAFE_PATH", "autosave path contains a symbolic link"),
&"save",
started_at_us
)
var prune_preflight := _preflight_autosave_prune(target_path, generation_count)
if not prune_preflight[&"ok"]:
return _complete_timed_operation(
_with_context(prune_preflight, {&"formal_save_preserved": true}),
&"save",
started_at_us
)
for artifact_path: String in [pending_path, pending_path + ".tmp", pending_path + ".bak"]:
if not _path_is_missing_or_package(artifact_path):
return _complete_timed_operation(
_failure(&"PACKAGE_PATH_CONFLICT", artifact_path),
&"save",
started_at_us
)
_remove_tree(pending_path)
_remove_tree(pending_path + ".tmp")
_remove_tree(pending_path + ".bak")
var candidate_metadata: Dictionary = metadata.duplicate(true) if metadata is Dictionary else {}
candidate_metadata[&"save_kind"] = &"autosave"
candidate_metadata[&"autosave_generation"] = 0
var candidate := _save_snapshot_absolute(
pending_path,
get_autosave_path(save_path, 0),
snapshot,
candidate_metadata,
FORMAT_VERSION,
target_path + AUTOSAVE_SUFFIX + "0",
target_path,
false
)
if not candidate[&"ok"]:
_remove_tree(pending_path)
_remove_tree(pending_path + ".bak")
return _complete_timed_operation(
_with_context(candidate, {&"formal_save_preserved": true}),
&"save",
started_at_us
)
var rotation := _rotate_autosaves(target_path, pending_path, generation_count)
_remove_tree(pending_path + ".bak")
if not rotation[&"ok"]:
return _complete_timed_operation(
_with_context(rotation, {&"formal_save_preserved": true}),
&"save",
started_at_us
)
var pruned_generations: Array[int] = []
for generation_value: Variant in prune_preflight[&"generations"]:
var generation := int(generation_value)
_remove_tree(target_path + AUTOSAVE_SUFFIX + str(generation))
pruned_generations.append(generation)
var result := {
&"ok": true,
&"code": &"OK",
&"path": save_path,
&"autosave_path": get_autosave_path(save_path, 0),
&"generation": 0,
&"generation_count": generation_count,
&"manifest": candidate[&"manifest"],
&"formal_save_preserved": true,
&"interrupted_rotation_reconciled": bool(reconcile.get(&"reconciled", false)),
&"pruned_generations": pruned_generations,
}
return _complete_timed_operation(result, &"save", started_at_us)
static func get_autosave_path(save_path: String, generation: int) -> String:
if generation < 0:
return ""
return save_path + AUTOSAVE_SUFFIX + str(generation)
## Returns checksum/schema status for every recovery candidate. Before reading,
## it completes or rolls back interrupted package renames using only verified
## packages. Selection priority is explicit and stable.
static func inspect_recovery_candidates(
save_path: String,
generation_count := DEFAULT_AUTOSAVE_GENERATIONS
) -> Dictionary:
if generation_count < 1 or generation_count > MAX_AUTOSAVE_GENERATIONS:
return _failure(&"INVALID_GENERATION_COUNT", str(generation_count))
var resolved := _resolve_safe_save_path(save_path)
if not resolved[&"ok"]:
return resolved
var target_path: String = resolved[&"path"]
var formal_reconcile := _reconcile_interrupted_formal_save(target_path)
if not formal_reconcile[&"ok"]:
return formal_reconcile
var migration_reconcile := _reconcile_interrupted_migration(target_path)
if not migration_reconcile[&"ok"]:
return migration_reconcile
var autosave_reconcile := _reconcile_autosave_rotation(
target_path,
target_path + ".autosave.pending",
generation_count
)
if not autosave_reconcile[&"ok"]:
return autosave_reconcile
var descriptors: Array[Dictionary] = [
{&"id": &"formal", &"kind": &"formal", &"generation": -1, &"path": target_path},
{&"id": &"backup", &"kind": &"backup", &"generation": -1, &"path": target_path + ".bak"},
{&"id": &"migration_original", &"kind": &"migration_original", &"generation": -1, &"path": target_path + ".migration.original"},
{&"id": &"pre_migration_backup", &"kind": &"migration_backup", &"generation": -1, &"path": target_path + MIGRATION_BACKUP_SUFFIX},
]
for generation in range(generation_count):
descriptors.append({
&"id": StringName("autosave_%d" % generation),
&"kind": &"autosave",
&"generation": generation,
&"path": target_path + AUTOSAVE_SUFFIX + str(generation),
})
var candidates: Array[Dictionary] = []
var selected: Dictionary = {}
var selected_snapshot: Dictionary = {}
for priority in range(descriptors.size()):
var descriptor: Dictionary = descriptors[priority]
var loaded := _load_snapshot_absolute(descriptor[&"path"], -1, false)
var package_valid := bool(loaded[&"ok"])
var current_schema_valid := false
var current_format := false
if package_valid:
current_format = int(loaded[&"manifest"].get("format_version", -1)) == FORMAT_VERSION
current_schema_valid = bool(SimulationSnapshot.validate(loaded[&"snapshot"])[&"ok"])
var loadable := package_valid and current_format and current_schema_valid
var candidate_code: StringName = StringName(loaded[&"code"])
if package_valid and not current_format:
candidate_code = &"UNSUPPORTED_FORMAT"
elif package_valid and not current_schema_valid:
candidate_code = &"INVALID_SNAPSHOT"
var candidate_info := {
&"id": descriptor[&"id"],
&"kind": descriptor[&"kind"],
&"generation": descriptor[&"generation"],
&"priority": priority,
&"path": descriptor[&"path"],
&"valid": loadable,
&"package_valid": package_valid,
&"requires_migration": package_valid and not loadable,
&"code": candidate_code,
&"message": str(loaded.get(&"message", "")),
}
if package_valid:
candidate_info[&"manifest"] = loaded[&"manifest"].duplicate(true)
if loadable and selected.is_empty():
selected = candidate_info.duplicate(true)
selected_snapshot = loaded[&"snapshot"].duplicate(true)
candidates.append(candidate_info)
if selected.is_empty():
return {
&"ok": false,
&"code": &"NO_VALID_RECOVERY_CANDIDATE",
&"message": save_path,
&"selection_rule": &"FORMAL_BACKUP_MIGRATION_AUTOSAVE_NEWEST_FIRST",
&"candidates": candidates,
&"reconciliation": {
&"formal_save": formal_reconcile,
&"migration": migration_reconcile,
&"autosave": autosave_reconcile,
},
}
return {
&"ok": true,
&"code": &"OK",
&"selection_rule": &"FORMAL_BACKUP_MIGRATION_AUTOSAVE_NEWEST_FIRST",
&"selected": selected,
&"snapshot": selected_snapshot,
&"manifest": selected[&"manifest"].duplicate(true),
&"candidates": candidates,
&"reconciliation": {
&"formal_save": formal_reconcile,
&"migration": migration_reconcile,
&"autosave": autosave_reconcile,
},
&"recovery_report": create_recovery_report(selected_snapshot, {
&"candidate_id": selected[&"id"],
&"candidate_path": selected[&"path"],
}),
}
static func list_recovery_candidates(
save_path: String,
generation_count := DEFAULT_AUTOSAVE_GENERATIONS
) -> Dictionary:
return inspect_recovery_candidates(save_path, generation_count)
static func load_with_recovery(
save_path: String,
generation_count := DEFAULT_AUTOSAVE_GENERATIONS
) -> Dictionary:
var started_at_us := Time.get_ticks_usec()
var result := inspect_recovery_candidates(save_path, generation_count)
return _complete_timed_operation(result, &"load", started_at_us)
## Loads one explicitly named recovery package. This is read-only: it never
## promotes the selected package and never repairs the snapshot.
static func load_recovery_candidate(
save_path: String,
candidate_id: StringName,
generation := -1
) -> Dictionary:
var started_at_us := Time.get_ticks_usec()
var result := _load_recovery_candidate_impl(save_path, candidate_id, generation)
return _complete_timed_operation(result, &"load", started_at_us)
static func _load_recovery_candidate_impl(
save_path: String,
candidate_id: StringName,
generation: int
) -> Dictionary:
var resolved := _resolve_safe_save_path(save_path)
if not resolved[&"ok"]:
return resolved
var target_path: String = resolved[&"path"]
# Explicit candidate selection is a read-only operation. Normal load and
# load_with_recovery retain transaction reconciliation at their own entry.
var formal_guard := _guard_identified_formal_format(target_path)
if not formal_guard[&"ok"]:
return formal_guard
var candidate_path := ""
match candidate_id:
&"formal":
candidate_path = target_path
&"backup":
candidate_path = target_path + ".bak"
&"migration_original":
candidate_path = target_path + ".migration.original"
&"pre_migration_backup":
candidate_path = target_path + MIGRATION_BACKUP_SUFFIX
&"autosave":
if generation < 0 or generation >= MAX_AUTOSAVE_GENERATIONS:
return _failure(&"INVALID_AUTOSAVE_GENERATION", str(generation))
candidate_path = target_path + AUTOSAVE_SUFFIX + str(generation)
_:
var candidate_text := str(candidate_id)
if candidate_text.begins_with("autosave_") and candidate_text.trim_prefix("autosave_").is_valid_int():
generation = candidate_text.trim_prefix("autosave_").to_int()
if generation < 0 or generation >= MAX_AUTOSAVE_GENERATIONS:
return _failure(&"INVALID_AUTOSAVE_GENERATION", str(generation))
candidate_path = target_path + AUTOSAVE_SUFFIX + str(generation)
else:
return _failure(&"UNKNOWN_RECOVERY_CANDIDATE", str(candidate_id))
var result := _load_snapshot_absolute(candidate_path, FORMAT_VERSION)
if not result[&"ok"]:
return result
result[&"candidate_id"] = candidate_id
result[&"candidate_path"] = candidate_path
result[&"read_only"] = true
result[&"recovery_report"] = create_recovery_report(result[&"snapshot"], {
&"candidate_id": candidate_id,
&"candidate_path": candidate_path,
})
return result
## Performs a continuous, explicit format migration and preserves an exact
## pre-migration package at <save>.pre_migration.bak before promotion. The
## formal package is replaced only after both the migrated candidate and backup
## have passed checksum verification.
static func migrate_save(
save_path: String,
registry: SaveMigrationRegistry,
target_version := FORMAT_VERSION
) -> Dictionary:
var started_at_us := Time.get_ticks_usec()
var result := _migrate_save_impl(save_path, registry, target_version)
return _complete_timed_operation(result, &"save", started_at_us)
## Domain migrations keep the package format version but still require the
## same checksum-verified backup and atomic promotion as format migrations.
## The caller validates domain invariants before supplying the candidate.
static func replace_snapshot_with_backup(save_path: String, expected: Dictionary, candidate: Dictionary) -> Dictionary:
if expected.is_empty() or candidate.is_empty():
return _failure(&"MIGRATION_SNAPSHOT_REQUIRED", save_path)
var validation := SimulationSnapshot.validate(candidate)
if not validation[&"ok"]:
return _failure(&"MIGRATION_INVALID_SNAPSHOT", ", ".join(validation[&"errors"]))
var started := Time.get_ticks_usec()
return _complete_timed_operation(_migrate_save_impl(save_path, null, FORMAT_VERSION, expected, candidate), &"save", started)
static func _migrate_save_impl(
save_path: String,
registry: SaveMigrationRegistry,
target_version: int,
expected_snapshot: Dictionary = {},
replacement_snapshot: Dictionary = {}
) -> Dictionary:
if registry == null and replacement_snapshot.is_empty():
return _failure(&"MIGRATION_REGISTRY_REQUIRED", save_path)
var resolved := _resolve_safe_save_path(save_path)
if not resolved[&"ok"]:
return resolved
var target_path: String = resolved[&"path"]
var formal_guard := _guard_identified_formal_format(target_path)
var migrating_registered_legacy := false
if not formal_guard[&"ok"]:
if formal_guard[&"code"] != &"UNSUPPORTED_FORMAT": return formal_guard
var identified_version := int(formal_guard[&"identified_format_version"])
if identified_version > target_version:
return _failure(&"MIGRATION_DOWNGRADE_UNSUPPORTED", "%d -> %d" % [identified_version, target_version])
if registry == null or not replacement_snapshot.is_empty():
return formal_guard
if target_version != FORMAT_VERSION or identified_version >= target_version \
or formal_guard[&"identified_format_versions"].size() != 1:
return formal_guard
for version in range(identified_version, target_version):
if not registry.has_step(version):
return _failure(&"MIGRATION_STEP_MISSING", "missing adjacent migration %d -> %d" % [version, version + 1])
# A registered old formal source is decoded/migrated directly. Do not
# reconcile stale normal/migration artifacts before source validation.
migrating_registered_legacy = true
if not migrating_registered_legacy:
var formal_reconcile := _reconcile_interrupted_formal_save(target_path)
if not formal_reconcile[&"ok"]: return formal_reconcile
var reconcile := _reconcile_interrupted_migration(target_path)
if not reconcile[&"ok"]: return reconcile
var operation_paths: Array[String] = [
target_path,
target_path + ".migration.tmp",
target_path + ".migration.original",
target_path + MIGRATION_BACKUP_SUFFIX,
target_path + MIGRATION_BACKUP_SUFFIX + ".tmp",
target_path + MIGRATION_BACKUP_SUFFIX + ".old",
]
if not _operation_paths_are_safe(operation_paths):
return _failure(&"UNSAFE_PATH", "migration path contains a symbolic link")
for artifact_path: String in [
target_path + ".migration.tmp",
target_path + ".migration.original",
target_path + MIGRATION_BACKUP_SUFFIX + ".tmp",
target_path + MIGRATION_BACKUP_SUFFIX + ".old",
]:
if not _path_is_missing_or_package(artifact_path):
return _failure(&"PACKAGE_PATH_CONFLICT", artifact_path)
# Old format snapshots are intentionally decoded and checksum-verified before
# migration, but validated only after the final adjacent migration step.
var source := _load_snapshot_absolute(target_path, -1, false)
if not source[&"ok"]:
return _with_context(source, {&"migration_started": false})
var from_version := int(source[&"manifest"].get("format_version", -1))
if target_version < from_version:
return _failure(&"MIGRATION_DOWNGRADE_UNSUPPORTED", "%d -> %d" % [from_version, target_version])
if not replacement_snapshot.is_empty():
if from_version != target_version:
return _failure(&"FORMAT_MIGRATION_REQUIRED", save_path)
if source[&"snapshot"] == replacement_snapshot:
return {&"ok": true, &"code": &"ALREADY_CURRENT", &"backup_created": false, &"path": save_path}
if source[&"snapshot"] != expected_snapshot:
return _failure(&"MIGRATION_SOURCE_CHANGED", save_path)
if target_version == from_version and replacement_snapshot.is_empty():
var current_validation := SimulationSnapshot.validate(source[&"snapshot"])
if target_version == FORMAT_VERSION and not current_validation[&"ok"]:
return _failure(&"INVALID_SNAPSHOT", ", ".join(current_validation[&"errors"]))
return {
&"ok": true,
&"code": &"ALREADY_CURRENT",
&"path": save_path,
&"from_version": from_version,
&"target_version": target_version,
&"applied_migration_ids": [],
&"backup_created": false,
}
var payload := {
&"manifest": source[&"manifest"].duplicate(true),
&"snapshot": source[&"snapshot"].duplicate(true),
}
var migrated: Dictionary
if replacement_snapshot.is_empty():
migrated = registry.migrate(payload, from_version, target_version)
else:
payload[&"snapshot"] = replacement_snapshot.duplicate(true)
migrated = {&"ok": true, &"data": payload, &"applied_migration_ids": [&"domain_snapshot_replacement"]}
if not migrated[&"ok"]:
return _with_context(migrated, {
&"path": save_path,
&"from_version": from_version,
&"target_version": target_version,
&"migration_started": false,
})
var migrated_payload: Dictionary = migrated[&"data"]
if not migrated_payload.get(&"manifest", null) is Dictionary:
return _failure(&"MIGRATION_INVALID_MANIFEST", save_path)
if not migrated_payload.get(&"snapshot", null) is Dictionary:
return _failure(&"MIGRATION_INVALID_SNAPSHOT", save_path)
var migrated_snapshot: Dictionary = migrated_payload[&"snapshot"]
var validation := SimulationSnapshot.validate(migrated_snapshot)
if not validation[&"ok"]:
return _failure(&"MIGRATION_INVALID_SNAPSHOT", ", ".join(validation[&"errors"]))
var migrated_manifest: Dictionary = migrated_payload[&"manifest"].duplicate(true)
migrated_manifest[&"format_version"] = target_version
var candidate_path := target_path + ".migration.tmp"
var backup_path := target_path + MIGRATION_BACKUP_SUFFIX
var backup_temp_path := backup_path + ".tmp"
var backup_old_path := backup_path + ".old"
var original_hold_path := target_path + ".migration.original"
_remove_tree(candidate_path)
_remove_tree(backup_temp_path)
var candidate_write := _write_package_directory(
candidate_path,
migrated_snapshot,
migrated_manifest,
target_version,
target_path
)
if not candidate_write[&"ok"]:
_remove_tree(candidate_path)
return _with_context(candidate_write, {&"migration_started": false})
var candidate_verify := _load_snapshot_absolute(candidate_path, target_version)
if not candidate_verify[&"ok"]:
_remove_tree(candidate_path)
return _failure(&"MIGRATION_VERIFY_FAILED", str(candidate_verify.get(&"message", candidate_verify[&"code"])))
var copy_error := _copy_tree(target_path, backup_temp_path)
if copy_error != OK:
_remove_tree(candidate_path)
_remove_tree(backup_temp_path)
return _failure(&"MIGRATION_BACKUP_COPY_FAILED", error_string(copy_error))
var backup_verify := _load_snapshot_absolute(backup_temp_path, from_version, false)
if not backup_verify[&"ok"]:
_remove_tree(candidate_path)
_remove_tree(backup_temp_path)
return _failure(&"MIGRATION_BACKUP_VERIFY_FAILED", str(backup_verify.get(&"message", backup_verify[&"code"])))
if DirAccess.dir_exists_absolute(backup_path):
var old_backup_error := DirAccess.rename_absolute(backup_path, backup_old_path)
if old_backup_error != OK:
_remove_tree(candidate_path)
_remove_tree(backup_temp_path)
return _failure(&"MIGRATION_BACKUP_ROTATE_FAILED", error_string(old_backup_error))
var backup_promote_error := DirAccess.rename_absolute(backup_temp_path, backup_path)
if backup_promote_error != OK:
if DirAccess.dir_exists_absolute(backup_old_path):
DirAccess.rename_absolute(backup_old_path, backup_path)
_remove_tree(candidate_path)
_remove_tree(backup_temp_path)
return _failure(&"MIGRATION_BACKUP_PROMOTE_FAILED", error_string(backup_promote_error))
var hold_error := DirAccess.rename_absolute(target_path, original_hold_path)
if hold_error != OK:
_restore_previous_migration_backup(backup_path, backup_old_path)
_remove_tree(candidate_path)
return _failure(&"MIGRATION_HOLD_FAILED", error_string(hold_error))
var promote_error := DirAccess.rename_absolute(candidate_path, target_path)
if promote_error != OK:
DirAccess.rename_absolute(original_hold_path, target_path)
_restore_previous_migration_backup(backup_path, backup_old_path)
_remove_tree(candidate_path)
return _failure(&"MIGRATION_PROMOTE_FAILED", error_string(promote_error))
var promoted_verify := _load_snapshot_absolute(target_path, target_version)
if not promoted_verify[&"ok"]:
_remove_tree(target_path)
DirAccess.rename_absolute(original_hold_path, target_path)
_restore_previous_migration_backup(backup_path, backup_old_path)
return _failure(
&"MIGRATION_PROMOTED_VERIFY_FAILED",
str(promoted_verify.get(&"message", promoted_verify[&"code"]))
)
_remove_tree(original_hold_path)
_remove_tree(backup_old_path)
return {
&"ok": true,
&"code": &"OK",
&"path": save_path,
&"backup_path": save_path + MIGRATION_BACKUP_SUFFIX,
&"from_version": from_version,
&"target_version": target_version,
&"applied_migration_ids": migrated[&"applied_migration_ids"].duplicate(),
&"backup_created": true,
&"manifest": candidate_verify[&"manifest"].duplicate(true),
}
## Boundary for post-load diagnostics. It intentionally does not inspect or
## mutate domain ownership such as Task reservations or Event actor locks; those
## authoritative services can append findings and decide repairs transactionally.
static func create_recovery_report(snapshot: Dictionary, source := {}) -> Dictionary:
var validation := SimulationSnapshot.validate(snapshot)
return {
&"report_version": 1,
&"snapshot_schema_valid": bool(validation[&"ok"]),
&"snapshot_errors": validation[&"errors"].duplicate(),
&"source": source.duplicate(true) if source is Dictionary else {},
&"automatic_repairs_applied": false,
&"repair_policy": &"REPORT_ONLY_AUTHORITATIVE_SERVICE_REQUIRED",
&"findings": {
&"orphan_tasks": [],
&"orphan_event_locks": [],
&"orphan_inventory_reservations": [],
},
}
static func delete_save(save_path: String) -> bool:
var resolved := _resolve_safe_save_path(save_path)
if not resolved[&"ok"]:
return false
var target_path: String = resolved[&"path"]
if DirAccess.dir_exists_absolute(target_path) and not _looks_like_save_package(target_path):
# A save slot is a package directory, never an arbitrary parent directory.
return false
var owned_paths: Array[String] = [
target_path,
target_path + ".tmp",
target_path + ".bak",
target_path + ".autosave.pending",
target_path + ".autosave.pending.tmp",
target_path + ".autosave.pending.bak",
target_path + MIGRATION_BACKUP_SUFFIX,
target_path + MIGRATION_BACKUP_SUFFIX + ".tmp",
target_path + MIGRATION_BACKUP_SUFFIX + ".old",
target_path + ".migration.tmp",
target_path + ".migration.original",
target_path + SAVE_TRANSACTION_SUFFIX,
]
for generation in range(MAX_AUTOSAVE_GENERATIONS):
owned_paths.append(target_path + AUTOSAVE_SUFFIX + str(generation))
owned_paths.append(target_path + AUTOSAVE_SUFFIX + str(generation) + ".rotate_tmp")
if not _operation_paths_are_safe(owned_paths):
return false
var transaction_marker_path := target_path + SAVE_TRANSACTION_SUFFIX
if (FileAccess.file_exists(transaction_marker_path) or DirAccess.dir_exists_absolute(transaction_marker_path)) \
and not _formal_transaction_marker_is_valid(transaction_marker_path):
return false
for path: String in owned_paths:
if not FileAccess.file_exists(path) and not DirAccess.dir_exists_absolute(path):
continue
if path == target_path + SAVE_TRANSACTION_SUFFIX:
if _formal_transaction_marker_is_valid(path):
DirAccess.remove_absolute(path)
elif _looks_like_save_package(path):
_remove_tree(path)
return true
static func _save_snapshot_absolute(
target_path: String,
reported_path: String,
snapshot: Dictionary,
metadata: Variant,
format_version: int,
logical_target_path := "",
identity_source_path := "",
use_transaction_marker := true
) -> Dictionary:
var validation := SimulationSnapshot.validate(snapshot)
if not validation[&"ok"]:
return _failure(&"INVALID_SNAPSHOT", ", ".join(validation[&"errors"]))
var temporary_path := target_path + ".tmp"
var backup_path := target_path + ".bak"
var transaction_marker_path := target_path + SAVE_TRANSACTION_SUFFIX
if not _operation_paths_are_safe([target_path, temporary_path, backup_path, transaction_marker_path]):
return _failure(&"UNSAFE_PATH", "save path contains a symbolic link")
var interrupted_reconcile := {&"ok": true, &"reconciled": false, &"action": &"none"}
if use_transaction_marker:
interrupted_reconcile = _reconcile_interrupted_formal_save(target_path)
if not interrupted_reconcile[&"ok"]:
return interrupted_reconcile
if not _path_is_missing_or_package(target_path):
return _failure(&"NOT_A_SAVE_PACKAGE", target_path)
if not _path_is_missing_or_package(temporary_path):
return _failure(&"PACKAGE_PATH_CONFLICT", temporary_path)
if not _path_is_missing_or_package(backup_path):
return _failure(&"BACKUP_PATH_CONFLICT", backup_path)
var logical_path: String = logical_target_path if not logical_target_path.is_empty() else target_path
var identity_path: String = identity_source_path if not identity_source_path.is_empty() else target_path
var effective_metadata := _merge_save_metadata(
metadata if metadata is Dictionary else {},
_read_manifest_identity(identity_path),
logical_path
)
if not effective_metadata.has(&"thumbnail_png"):
var previous_thumbnail_path := identity_path.path_join(THUMBNAIL_FILE)
if FileAccess.file_exists(previous_thumbnail_path):
effective_metadata[&"_thumbnail_png"] = FileAccess.get_file_as_bytes(previous_thumbnail_path)
_remove_tree(temporary_path)
var write_result := _write_package_directory(
temporary_path,
snapshot,
effective_metadata,
format_version,
logical_path
)
if not write_result[&"ok"]:
_remove_tree(temporary_path)
return write_result
var verification := _load_snapshot_absolute(temporary_path, format_version)
if not verification[&"ok"]:
_remove_tree(temporary_path)
return _failure(&"VERIFY_FAILED", str(verification.get(&"message", "unknown")))
if use_transaction_marker:
var marker_error := _write_formal_transaction_marker(transaction_marker_path)
if marker_error != OK:
_remove_tree(temporary_path)
return _failure(&"TRANSACTION_MARKER_WRITE_FAILED", error_string(marker_error))
_remove_tree(backup_path)
if DirAccess.dir_exists_absolute(target_path):
var backup_error := DirAccess.rename_absolute(target_path, backup_path)
if backup_error != OK:
_remove_tree(temporary_path)
_remove_formal_transaction_marker(transaction_marker_path)
return _failure(&"BACKUP_FAILED", error_string(backup_error))
var promote_error := DirAccess.rename_absolute(temporary_path, target_path)
if promote_error != OK:
var rollback_verified := false
if DirAccess.dir_exists_absolute(backup_path):
var rollback_error := DirAccess.rename_absolute(backup_path, target_path)
rollback_verified = rollback_error == OK and bool(_package_status(target_path)[&"current_loadable"])
_remove_tree(temporary_path)
if rollback_verified:
_remove_formal_transaction_marker(transaction_marker_path)
return _failure(&"PROMOTE_FAILED", error_string(promote_error))
var promoted_status := _package_status(target_path)
if not promoted_status[&"current_loadable"]:
if _looks_like_save_package(target_path):
_remove_tree(target_path)
var rollback_verified := false
if DirAccess.dir_exists_absolute(backup_path):
var rollback_error := DirAccess.rename_absolute(backup_path, target_path)
rollback_verified = rollback_error == OK and bool(_package_status(target_path)[&"current_loadable"])
if rollback_verified:
_remove_formal_transaction_marker(transaction_marker_path)
return _failure(&"PROMOTE_VERIFY_FAILED", str(promoted_status.get(&"code", &"INVALID_PACKAGE")))
if use_transaction_marker:
_remove_formal_transaction_marker(transaction_marker_path)
return {
&"ok": true,
&"code": &"OK",
&"path": reported_path,
&"manifest": verification[&"manifest"].duplicate(true),
&"interrupted_save_reconciled": bool(interrupted_reconcile.get(&"reconciled", false)),
}
static func _write_package_directory(
package_path: String,
snapshot: Dictionary,
metadata: Dictionary,
format_version: int,
logical_target_path := ""
) -> Dictionary:
if not _operation_paths_are_safe([package_path]):
return _failure(&"UNSAFE_PATH", "package path contains a symbolic link")
if not _path_is_missing_or_package(package_path):
return _failure(&"PACKAGE_PATH_CONFLICT", package_path)
_remove_tree(package_path)
var make_error := DirAccess.make_dir_recursive_absolute(package_path)
if make_error != OK:
return _failure(&"CREATE_DIRECTORY_FAILED", error_string(make_error))
var raw_world := var_to_bytes(snapshot)
if not _world_size_is_supported(raw_world.size()):
_remove_tree(package_path)
return _failure(&"SAVE_SIZE_LIMIT_EXCEEDED", "uncompressed world exceeds technical resource limit")
var compressed_world := raw_world.compress(FileAccess.COMPRESSION_ZSTD)
if compressed_world.is_empty() and not raw_world.is_empty():
_remove_tree(package_path)
return _failure(&"COMPRESSION_FAILED", WORLD_FILE)
if not _world_size_is_supported(compressed_world.size()):
_remove_tree(package_path)
return _failure(&"SAVE_SIZE_LIMIT_EXCEEDED", "compressed world exceeds technical resource limit")
var checksum := _sha256(compressed_world)
var world_error := _write_world(
package_path.path_join(WORLD_FILE),
raw_world.size(),
compressed_world,
format_version
)
if world_error != OK:
_remove_tree(package_path)
return _failure(&"WRITE_WORLD_FAILED", error_string(world_error))
var logical_name := (
logical_target_path.get_file()
if not logical_target_path.is_empty()
else package_path.get_file()
)
var now := Time.get_datetime_string_from_system(true)
var manifest := {
&"format_version": format_version,
&"game_version": str(ProjectSettings.get_setting("application/config/version", "0.1.0")),
&"save_id": str(metadata.get(&"save_id", logical_name)),
&"display_name": str(metadata.get(&"display_name", logical_name)),
&"created_at_real": str(metadata.get(&"created_at_real", now)),
&"updated_at_real": now,
&"absolute_minute": int(snapshot[&"clock"][&"absolute_minute"]),
&"day": int(snapshot[&"clock"].get(&"day", int(snapshot[&"clock"][&"absolute_minute"]) / 1_440 + 1)),
&"seed": int(metadata.get(&"seed", _snapshot_seed(snapshot))),
&"content_pack_ids": _metadata_array(metadata.get(&"content_pack_ids", [])),
&"enabled_filter_profile": metadata.get(&"enabled_filter_profile", &"default"),
&"checksum_world": checksum,
&"uncompressed_size": raw_world.size(),
}
for key: Variant in metadata:
if key in [&"thumbnail_png", &"thumbnail_path", &"_thumbnail_png"]:
continue
if not manifest.has(key):
manifest[key] = metadata[key]
var manifest_text := JSON.stringify(manifest, "\t")
if manifest_text.to_utf8_buffer().size() > MAX_MANIFEST_BYTES:
_remove_tree(package_path)
return _failure(&"SAVE_SIZE_LIMIT_EXCEEDED", "manifest exceeds technical resource limit")
var manifest_error := _write_text(package_path.path_join(MANIFEST_FILE), manifest_text)
if manifest_error != OK:
_remove_tree(package_path)
return _failure(&"WRITE_MANIFEST_FAILED", error_string(manifest_error))
var thumbnail_error := _ensure_thumbnail(package_path, metadata)
if thumbnail_error != OK:
_remove_tree(package_path)
return _failure(&"WRITE_THUMBNAIL_FAILED", error_string(thumbnail_error))
var journal_error := _write_text(
package_path.path_join(JOURNAL_FILE),
"save_completed %s\n" % manifest[&"updated_at_real"]
)
if journal_error != OK:
_remove_tree(package_path)
return _failure(&"WRITE_JOURNAL_FAILED", error_string(journal_error))
return {&"ok": true, &"code": &"OK", &"manifest": manifest}
static func _load_snapshot_absolute(
target_path: String,
expected_format_version: int,
validate_snapshot := true
) -> Dictionary:
var read_boundary := _validate_package_read_boundary(target_path)
if not read_boundary[&"ok"]:
return read_boundary
var manifest_path := target_path.path_join(MANIFEST_FILE)
var world_path := target_path.path_join(WORLD_FILE)
if not FileAccess.file_exists(manifest_path) or not FileAccess.file_exists(world_path):
return _failure(&"SAVE_NOT_FOUND", target_path)
# Recheck the exact entry immediately before each open. The recursive package
# scan above protects all known package entries; these checks narrow the
# remaining TOCTOU window at the two authoritative reads.
if not _package_file_is_safe_for_read(manifest_path):
return _failure(&"UNSAFE_PATH", "symbolic link at package read boundary: %s" % manifest_path)
var manifest_file := FileAccess.open(manifest_path, FileAccess.READ)
if manifest_file == null:
return _failure(&"INVALID_MANIFEST", error_string(FileAccess.get_open_error()))
if manifest_file.get_length() > MAX_MANIFEST_BYTES:
return _failure(&"SAVE_SIZE_LIMIT_EXCEEDED", "manifest exceeds technical resource limit")
var manifest_text := manifest_file.get_as_text()
manifest_file = null
var parser := JSON.new()
if parser.parse(manifest_text) != OK:
return _failure(&"INVALID_MANIFEST", manifest_path)
var manifest_value: Variant = parser.data
if not manifest_value is Dictionary:
return _failure(&"INVALID_MANIFEST", manifest_path)
var manifest: Dictionary = manifest_value
var version_value: Variant = manifest.get("format_version", -1)
# Godot JSON decodes numbers as floats. Permit exact integral JSON numbers,
# while rejecting strings, booleans and fractions instead of coercing them.
if not _is_integral_json_number(version_value, 0, 0xffffffff):
return _failure(&"INVALID_MANIFEST", "format_version must be an unsigned integer")
var manifest_version := int(version_value)
if expected_format_version >= 0 and manifest_version != expected_format_version:
return _failure(&"UNSUPPORTED_FORMAT", str(manifest_version))
if not _package_file_is_safe_for_read(world_path):
return _failure(&"UNSAFE_PATH", "symbolic link at package read boundary: %s" % world_path)
var world_file := FileAccess.open(world_path, FileAccess.READ)
if world_file == null:
return _failure(&"READ_WORLD_FAILED", error_string(FileAccess.get_open_error()))
if world_file.get_length() < 16:
return _failure(&"INVALID_WORLD_HEADER", world_path)
if world_file.get_length() - 16 > MAX_WORLD_BYTES:
return _failure(&"SAVE_SIZE_LIMIT_EXCEEDED", "compressed world exceeds technical resource limit")
var magic := world_file.get_buffer(4).get_string_from_ascii()
var stored_version := world_file.get_32()
var uncompressed_size := world_file.get_64()
if not _world_size_is_supported(uncompressed_size):
return _failure(&"SAVE_SIZE_LIMIT_EXCEEDED", "uncompressed world exceeds technical resource limit")
var compressed_size := world_file.get_length() - world_file.get_position()
if compressed_size < 0:
return _failure(&"INVALID_WORLD_HEADER", world_path)
var compressed_world := world_file.get_buffer(compressed_size)
if magic != MAGIC or stored_version != manifest_version:
return _failure(&"INVALID_WORLD_HEADER", world_path)
var checksum_value: Variant = manifest.get("checksum_world", null)
if not checksum_value is String or checksum_value.length() != 64 or not checksum_value.is_valid_hex_number(false):
return _failure(&"INVALID_MANIFEST", "checksum_world must be a SHA-256 string")
var expected_checksum: String = checksum_value
if _sha256(compressed_world) != expected_checksum:
return _failure(&"CHECKSUM_MISMATCH", world_path)
# Older packages may omit this duplicate size field; the binary header still
# supplies a required bounded size. If present it must be exact and agree.
if manifest.has("uncompressed_size"):
var size_value: Variant = manifest["uncompressed_size"]
if not _is_integral_json_number(size_value, 1, MAX_WORLD_BYTES):
return _failure(&"INVALID_MANIFEST", "uncompressed_size must be a supported positive integer")
if int(size_value) != uncompressed_size:
return _failure(&"SIZE_MISMATCH", world_path)
var raw_world := compressed_world.decompress(uncompressed_size, FileAccess.COMPRESSION_ZSTD)
if raw_world.size() != uncompressed_size:
return _failure(&"DECOMPRESSION_FAILED", world_path)
var snapshot_value: Variant = bytes_to_var(raw_world)
if not snapshot_value is Dictionary:
return _failure(&"INVALID_WORLD_DATA", world_path)
var snapshot: Dictionary = snapshot_value
if validate_snapshot:
var validation := SimulationSnapshot.validate(snapshot)
if not validation[&"ok"]:
return _failure(&"INVALID_SNAPSHOT", ", ".join(validation[&"errors"]))
return {
&"ok": true,
&"code": &"OK",
&"manifest": manifest,
&"snapshot": snapshot,
}
static func _is_integral_json_number(value: Variant, minimum: int, maximum: int) -> bool:
if typeof(value) == TYPE_INT:
return value >= minimum and value <= maximum
if typeof(value) != TYPE_FLOAT or not is_finite(value):
return false
return value >= minimum and value <= maximum and value == floor(value)
static func _world_size_is_supported(size: int) -> bool:
return size > 0 and size <= MAX_WORLD_BYTES
## Probe only bounded metadata and the fixed binary prefix. An identifiable
## unsupported formal format must be rejected before any transaction artifact
## is reconciled, even when its compressed payload/checksum is damaged.
static func _guard_identified_formal_format(target_path: String) -> Dictionary:
var versions: Array[int] = []
var manifest_path := target_path.path_join(MANIFEST_FILE)
if FileAccess.file_exists(manifest_path):
if not _package_file_is_safe_for_read(manifest_path):
return _failure(&"UNSAFE_PATH", manifest_path)
var manifest_file := FileAccess.open(manifest_path, FileAccess.READ)
if manifest_file == null:
return _failure(&"INVALID_MANIFEST", error_string(FileAccess.get_open_error()))
if manifest_file.get_length() <= MAX_MANIFEST_BYTES:
var parser := JSON.new()
if parser.parse(manifest_file.get_as_text()) == OK and parser.data is Dictionary:
var value: Variant = parser.data.get("format_version", null)
if _is_integral_json_number(value, 0, 0xffffffff):
versions.append(int(value))
manifest_file = null
var world_path := target_path.path_join(WORLD_FILE)
if FileAccess.file_exists(world_path):
if not _package_file_is_safe_for_read(world_path):
return _failure(&"UNSAFE_PATH", world_path)
var world_file := FileAccess.open(world_path, FileAccess.READ)
if world_file == null:
return _failure(&"READ_WORLD_FAILED", error_string(FileAccess.get_open_error()))
if world_file.get_length() >= 8 and world_file.get_buffer(4).get_string_from_ascii() == MAGIC:
var version := world_file.get_32()
if version not in versions: versions.append(version)
world_file = null
for version: int in versions:
if version != FORMAT_VERSION:
var failure := _failure(&"UNSUPPORTED_FORMAT", str(version))
failure[&"identified_format_version"] = version
failure[&"identified_format_versions"] = versions
failure[&"formal_and_artifacts_preserved"] = true
return failure
return {&"ok": true, &"code": &"OK"}
## Resolves a normal-save promotion interrupted after the old formal package
## moved to .bak. Only checksum/schema-verified packages may become formal.
## The marker is durable, but verified .tmp/.bak artifacts also recover saves
## created by older builds which could crash before this marker existed.
static func _reconcile_interrupted_formal_save(target_path: String) -> Dictionary:
var temporary_path := target_path + ".tmp"
var backup_path := target_path + ".bak"
var marker_path := target_path + SAVE_TRANSACTION_SUFFIX
if not _operation_paths_are_safe([target_path, temporary_path, backup_path, marker_path]):
return _failure(&"UNSAFE_PATH", "formal save recovery path contains a symbolic link")
var formal_guard := _guard_identified_formal_format(target_path)
if not formal_guard[&"ok"]:
return formal_guard
var marker_exists := FileAccess.file_exists(marker_path) or DirAccess.dir_exists_absolute(marker_path)
if marker_exists and not _formal_transaction_marker_is_valid(marker_path):
return _failure(&"TRANSACTION_MARKER_CONFLICT", marker_path)
var target_status := _package_status(target_path)
var temporary_status := _package_status(temporary_path)
var backup_status := _package_status(backup_path)
if target_status[&"current_loadable"]:
if not marker_exists:
return {&"ok": true, &"code": &"OK", &"reconciled": false, &"action": &"none"}
if temporary_status[&"exists"]:
if not _looks_like_save_package(temporary_path):
return _failure(&"FORMAL_RECOVERY_TEMP_CONFLICT", temporary_path)
_remove_tree(temporary_path)
_remove_formal_transaction_marker(marker_path)
return {
&"ok": true,
&"code": &"OK",
&"reconciled": true,
&"action": &"kept_verified_formal",
}
# Without a durable marker, an invalid visible formal package remains a
# recovery candidate and may fall back read-only to .bak/autosaves. Never
# destroy it merely because it failed verification.
if target_status[&"exists"] and not marker_exists:
return {&"ok": true, &"code": &"OK", &"reconciled": false, &"action": &"none"}
if target_status[&"exists"]:
if not _looks_like_save_package(target_path):
return _failure(&"FORMAL_RECOVERY_TARGET_CONFLICT", target_path)
var replacement_path := ""
var replacement_action: StringName = &""
if temporary_status[&"current_loadable"]:
replacement_path = temporary_path
replacement_action = &"replaced_invalid_formal_with_candidate"
elif backup_status[&"current_loadable"]:
replacement_path = backup_path
replacement_action = &"replaced_invalid_formal_with_backup"
if replacement_path.is_empty():
return _failure(&"FORMAL_RECOVERY_NO_VALID_PACKAGE", target_path)
_remove_tree(target_path)
var replace_error := DirAccess.rename_absolute(replacement_path, target_path)
if replace_error != OK:
return _failure(&"FORMAL_RECOVERY_PROMOTE_FAILED", error_string(replace_error))
if not bool(_package_status(target_path)[&"current_loadable"]):
return _failure(&"FORMAL_RECOVERY_VERIFY_FAILED", target_path)
if replacement_path != temporary_path and temporary_status[&"exists"] \
and _looks_like_save_package(temporary_path):
_remove_tree(temporary_path)
_remove_formal_transaction_marker(marker_path)
return {
&"ok": true,
&"code": &"OK",
&"reconciled": true,
&"action": replacement_action,
}
var source_path := ""
var action: StringName = &""
if temporary_status[&"current_loadable"]:
source_path = temporary_path
action = &"promoted_verified_candidate"
elif backup_status[&"current_loadable"]:
source_path = backup_path
action = &"restored_verified_backup"
if source_path.is_empty():
if marker_exists:
return _failure(&"FORMAL_RECOVERY_NO_VALID_PACKAGE", target_path)
return {&"ok": true, &"code": &"OK", &"reconciled": false, &"action": &"none"}
var promote_error := DirAccess.rename_absolute(source_path, target_path)
if promote_error != OK:
return _failure(&"FORMAL_RECOVERY_PROMOTE_FAILED", error_string(promote_error))
if not bool(_package_status(target_path)[&"current_loadable"]):
return _failure(&"FORMAL_RECOVERY_VERIFY_FAILED", target_path)
if source_path != temporary_path and marker_exists and temporary_status[&"exists"] \
and _looks_like_save_package(temporary_path):
_remove_tree(temporary_path)
_remove_formal_transaction_marker(marker_path)
return {
&"ok": true,
&"code": &"OK",
&"reconciled": true,
&"action": action,
}
static func _rotate_autosaves(target_path: String, pending_path: String, generation_count: int) -> Dictionary:
var reconciliation := _reconcile_autosave_rotation(target_path, pending_path, generation_count)
if not reconciliation[&"ok"]:
_remove_tree(pending_path)
return reconciliation
var moved_to_transaction: Array[int] = []
for generation in range(generation_count):
var generation_path := target_path + AUTOSAVE_SUFFIX + str(generation)
if not DirAccess.dir_exists_absolute(generation_path):
continue
var transaction_path := generation_path + ".rotate_tmp"
var hold_error := DirAccess.rename_absolute(generation_path, transaction_path)
if hold_error != OK:
_rollback_autosave_holds(target_path, moved_to_transaction)
_remove_tree(pending_path)
return _failure(&"AUTOSAVE_ROTATION_HOLD_FAILED", error_string(hold_error))
moved_to_transaction.append(generation)
var newest_path := target_path + AUTOSAVE_SUFFIX + "0"
var promote_error := DirAccess.rename_absolute(pending_path, newest_path)
if promote_error != OK:
_rollback_autosave_holds(target_path, moved_to_transaction)
_remove_tree(pending_path)
return _failure(&"AUTOSAVE_PROMOTE_FAILED", error_string(promote_error))
var shifted: Array[int] = []
for generation in moved_to_transaction:
if generation >= generation_count - 1:
continue
var transaction_path := target_path + AUTOSAVE_SUFFIX + str(generation) + ".rotate_tmp"
var destination_path := target_path + AUTOSAVE_SUFFIX + str(generation + 1)
var shift_error := DirAccess.rename_absolute(transaction_path, destination_path)
if shift_error != OK:
_remove_tree(newest_path)
for shifted_generation in shifted:
var shifted_path := target_path + AUTOSAVE_SUFFIX + str(shifted_generation + 1)
var original_hold := target_path + AUTOSAVE_SUFFIX + str(shifted_generation) + ".rotate_tmp"
DirAccess.rename_absolute(shifted_path, original_hold)
_rollback_autosave_holds(target_path, moved_to_transaction)
return _failure(&"AUTOSAVE_ROTATION_SHIFT_FAILED", error_string(shift_error))
shifted.append(generation)
for generation in moved_to_transaction:
_remove_tree(target_path + AUTOSAVE_SUFFIX + str(generation) + ".rotate_tmp")
return {&"ok": true, &"code": &"OK"}
## Retention shrink is allowed to delete only checksum-valid packages which
## occupy generation names owned by this slot. Preflight happens before the new
## autosave is committed so a foreign directory or link cannot cause a partial
## rotation followed by a prune failure.
static func _preflight_autosave_prune(target_path: String, generation_count: int) -> Dictionary:
var generations: Array[int] = []
for generation in range(generation_count, MAX_AUTOSAVE_GENERATIONS):
var generation_path := target_path + AUTOSAVE_SUFFIX + str(generation)
if not FileAccess.file_exists(generation_path) and not DirAccess.dir_exists_absolute(generation_path):
continue
var status := _package_status(generation_path)
if not status[&"package_valid"]:
return _failure(&"AUTOSAVE_PRUNE_CONFLICT", generation_path)
generations.append(generation)
return {&"ok": true, &"code": &"OK", &"generations": generations}
## Repairs a rotation interrupted at any rename boundary. If the pending
## candidate still exists, the old generations are rolled back. Once generation
## zero has been promoted, the remaining shifts are completed instead.
static func _reconcile_autosave_rotation(
target_path: String,
pending_path: String,
generation_count: int
) -> Dictionary:
var paths: Array[String] = [pending_path]
var held_generations: Array[int] = []
# Scan the full supported range because the configured retention count may
# have changed between the interrupted run and recovery.
for generation in range(MAX_AUTOSAVE_GENERATIONS):
var generation_path := target_path + AUTOSAVE_SUFFIX + str(generation)
var hold_path := generation_path + ".rotate_tmp"
paths.append(generation_path)
paths.append(hold_path)
if DirAccess.dir_exists_absolute(hold_path):
held_generations.append(generation)
if not _operation_paths_are_safe(paths):
return _failure(&"UNSAFE_PATH", "autosave recovery path contains a symbolic link")
if held_generations.is_empty():
return {&"ok": true, &"code": &"OK", &"reconciled": false, &"action": &"none"}
var pending_exists := DirAccess.dir_exists_absolute(pending_path)
var newest_exists := DirAccess.dir_exists_absolute(target_path + AUTOSAVE_SUFFIX + "0")
if pending_exists or not newest_exists:
# A hold and its original slot may never coexist in a valid rollback state.
for generation in held_generations:
if DirAccess.dir_exists_absolute(target_path + AUTOSAVE_SUFFIX + str(generation)):
return _failure(
&"AUTOSAVE_ROTATION_AMBIGUOUS",
"generation %d has both live and held packages" % generation
)
_rollback_autosave_holds(target_path, held_generations)
for generation in held_generations:
if DirAccess.dir_exists_absolute(target_path + AUTOSAVE_SUFFIX + str(generation) + ".rotate_tmp"):
return _failure(
&"AUTOSAVE_RECONCILE_ROLLBACK_FAILED",
"generation %d remained held" % generation
)
return {
&"ok": true,
&"code": &"OK",
&"reconciled": true,
&"action": &"rolled_back",
}
# The new generation zero is already visible: complete every remaining
# oldest-to-newest shift. Preflight conflicts before making any rename.
for generation in held_generations:
if generation >= generation_count - 1:
continue
var destination_path := target_path + AUTOSAVE_SUFFIX + str(generation + 1)
if DirAccess.dir_exists_absolute(destination_path):
return _failure(
&"AUTOSAVE_ROTATION_AMBIGUOUS",
"held generation %d conflicts with destination" % generation
)
for generation in held_generations:
var hold_path := target_path + AUTOSAVE_SUFFIX + str(generation) + ".rotate_tmp"
if generation >= generation_count - 1:
continue
var destination_path := target_path + AUTOSAVE_SUFFIX + str(generation + 1)
var shift_error := DirAccess.rename_absolute(hold_path, destination_path)
if shift_error != OK:
return _failure(&"AUTOSAVE_RECONCILE_SHIFT_FAILED", error_string(shift_error))
for generation in held_generations:
_remove_tree(target_path + AUTOSAVE_SUFFIX + str(generation) + ".rotate_tmp")
return {
&"ok": true,
&"code": &"OK",
&"reconciled": true,
&"action": &"completed",
}
static func _rollback_autosave_holds(target_path: String, generations: Array[int]) -> void:
for generation in generations:
var transaction_path := target_path + AUTOSAVE_SUFFIX + str(generation) + ".rotate_tmp"
var generation_path := target_path + AUTOSAVE_SUFFIX + str(generation)
if DirAccess.dir_exists_absolute(transaction_path) and not DirAccess.dir_exists_absolute(generation_path):
DirAccess.rename_absolute(transaction_path, generation_path)
## Resolves the two durable migration markers. The source hold is never removed
## until a current target and a checksum-valid pre-migration backup both exist.
static func _reconcile_interrupted_migration(target_path: String) -> Dictionary:
var candidate_path := target_path + ".migration.tmp"
var original_path := target_path + ".migration.original"
var backup_path := target_path + MIGRATION_BACKUP_SUFFIX
var backup_temp_path := backup_path + ".tmp"
var backup_old_path := backup_path + ".old"
var operation_paths: Array[String] = [
target_path,
candidate_path,
original_path,
backup_path,
backup_temp_path,
backup_old_path,
]
if not _operation_paths_are_safe(operation_paths):
return _failure(&"UNSAFE_PATH", "migration recovery path contains a symbolic link")
var original_status := _package_status(original_path)
var target_status := _package_status(target_path)
var candidate_status := _package_status(candidate_path)
var backup_status := _package_status(backup_path)
var backup_temp_status := _package_status(backup_temp_path)
var backup_old_status := _package_status(backup_old_path)
# Recover a backup rename that was interrupted before the formal source moved.
if not backup_status[&"exists"]:
if backup_temp_status[&"package_valid"]:
var temp_promote_error := DirAccess.rename_absolute(backup_temp_path, backup_path)
if temp_promote_error != OK:
return _failure(&"MIGRATION_BACKUP_RECOVERY_FAILED", error_string(temp_promote_error))
backup_status = _package_status(backup_path)
elif backup_old_status[&"package_valid"]:
var old_restore_error := DirAccess.rename_absolute(backup_old_path, backup_path)
if old_restore_error != OK:
return _failure(&"MIGRATION_BACKUP_RECOVERY_FAILED", error_string(old_restore_error))
backup_status = _package_status(backup_path)
elif backup_status[&"package_valid"]:
if backup_temp_status[&"package_valid"]:
_remove_tree(backup_temp_path)
if backup_old_status[&"package_valid"] and not original_status[&"exists"]:
_remove_tree(backup_old_path)
else:
# A torn backup package never outranks either verified durable neighbor.
# It is removed only when it still has recognizable package contents.
if not _looks_like_save_package(backup_path):
return _failure(&"MIGRATION_BACKUP_PATH_CONFLICT", backup_path)
if backup_temp_status[&"package_valid"]:
_remove_tree(backup_path)
var temp_replace_error := DirAccess.rename_absolute(backup_temp_path, backup_path)
if temp_replace_error != OK:
return _failure(&"MIGRATION_BACKUP_RECOVERY_FAILED", error_string(temp_replace_error))
backup_status = _package_status(backup_path)
elif backup_old_status[&"package_valid"]:
_remove_tree(backup_path)
var old_replace_error := DirAccess.rename_absolute(backup_old_path, backup_path)
if old_replace_error != OK:
return _failure(&"MIGRATION_BACKUP_RECOVERY_FAILED", error_string(old_replace_error))
backup_status = _package_status(backup_path)
if original_status[&"exists"]:
if not original_status[&"package_valid"]:
return _failure(
&"MIGRATION_RECOVERY_SOURCE_INVALID",
str(original_status.get(&"code", &"INVALID_PACKAGE"))
)
if target_status[&"current_loadable"]:
if not backup_status[&"package_valid"]:
var backup_result := _create_verified_migration_backup(
original_path,
backup_path,
backup_temp_path,
backup_old_path
)
if not backup_result[&"ok"]:
return backup_result
_remove_tree(original_path)
if candidate_status[&"package_valid"]:
_remove_tree(candidate_path)
if DirAccess.dir_exists_absolute(backup_old_path):
_remove_tree(backup_old_path)
return {
&"ok": true,
&"code": &"OK",
&"reconciled": true,
&"action": &"finalized_promoted_target",
}
if not target_status[&"exists"] and candidate_status[&"current_loadable"] \
and backup_status[&"package_valid"]:
var candidate_promote_error := DirAccess.rename_absolute(candidate_path, target_path)
if candidate_promote_error != OK:
return _failure(&"MIGRATION_RECOVERY_PROMOTE_FAILED", error_string(candidate_promote_error))
var promoted_status := _package_status(target_path)
if not promoted_status[&"current_loadable"]:
_remove_tree(target_path)
DirAccess.rename_absolute(original_path, target_path)
return _failure(&"MIGRATION_RECOVERY_VERIFY_FAILED", target_path)
_remove_tree(original_path)
if DirAccess.dir_exists_absolute(backup_old_path):
_remove_tree(backup_old_path)
return {
&"ok": true,
&"code": &"OK",
&"reconciled": true,
&"action": &"completed_promotion",
}
# The candidate cannot be safely committed. Restore the verified original.
if target_status[&"exists"]:
if not _looks_like_save_package(target_path):
return _failure(&"MIGRATION_RECOVERY_TARGET_CONFLICT", target_path)
_remove_tree(target_path)
var rollback_error := DirAccess.rename_absolute(original_path, target_path)
if rollback_error != OK:
return _failure(&"MIGRATION_RECOVERY_ROLLBACK_FAILED", error_string(rollback_error))
if candidate_status[&"package_valid"]:
_remove_tree(candidate_path)
if backup_old_status[&"package_valid"]:
_restore_previous_migration_backup(backup_path, backup_old_path)
return {
&"ok": true,
&"code": &"OK",
&"reconciled": true,
&"action": &"rolled_back_to_original",
}
# A current formal package makes a pre-hold candidate disposable. If the
# formal package vanished, a verified candidate plus backup can still commit.
if target_status[&"current_loadable"]:
if candidate_status[&"package_valid"]:
_remove_tree(candidate_path)
return {&"ok": true, &"code": &"OK", &"reconciled": false, &"action": &"none"}
if not target_status[&"exists"] and candidate_status[&"current_loadable"] \
and backup_status[&"package_valid"]:
var orphan_promote_error := DirAccess.rename_absolute(candidate_path, target_path)
if orphan_promote_error != OK:
return _failure(&"MIGRATION_RECOVERY_PROMOTE_FAILED", error_string(orphan_promote_error))
return {
&"ok": true,
&"code": &"OK",
&"reconciled": true,
&"action": &"promoted_verified_candidate",
}
return {&"ok": true, &"code": &"OK", &"reconciled": false, &"action": &"none"}
static func _create_verified_migration_backup(
source_path: String,
backup_path: String,
backup_temp_path: String,
backup_old_path: String
) -> Dictionary:
_remove_tree(backup_temp_path)
var copy_error := _copy_tree(source_path, backup_temp_path)
if copy_error != OK:
return _failure(&"MIGRATION_BACKUP_COPY_FAILED", error_string(copy_error))
var verification := _load_snapshot_absolute(backup_temp_path, -1, false)
if not verification[&"ok"]:
_remove_tree(backup_temp_path)
return _failure(&"MIGRATION_BACKUP_VERIFY_FAILED", str(verification[&"code"]))
if DirAccess.dir_exists_absolute(backup_path):
if DirAccess.dir_exists_absolute(backup_old_path):
return _failure(&"MIGRATION_BACKUP_RECOVERY_AMBIGUOUS", backup_old_path)
var rotate_error := DirAccess.rename_absolute(backup_path, backup_old_path)
if rotate_error != OK:
return _failure(&"MIGRATION_BACKUP_ROTATE_FAILED", error_string(rotate_error))
var promote_error := DirAccess.rename_absolute(backup_temp_path, backup_path)
if promote_error != OK:
if DirAccess.dir_exists_absolute(backup_old_path):
DirAccess.rename_absolute(backup_old_path, backup_path)
return _failure(&"MIGRATION_BACKUP_PROMOTE_FAILED", error_string(promote_error))
return {&"ok": true, &"code": &"OK"}
static func _package_status(path: String) -> Dictionary:
var exists := DirAccess.dir_exists_absolute(path) or FileAccess.file_exists(path)
if not exists:
return {
&"exists": false,
&"package_valid": false,
&"current_loadable": false,
&"code": &"SAVE_NOT_FOUND",
}
var loaded := _load_snapshot_absolute(path, -1, false)
if not loaded[&"ok"]:
return {
&"exists": true,
&"package_valid": false,
&"current_loadable": false,
&"code": loaded[&"code"],
}
var current_format := int(loaded[&"manifest"].get("format_version", -1)) == FORMAT_VERSION
var schema_valid := bool(SimulationSnapshot.validate(loaded[&"snapshot"])[&"ok"])
return {
&"exists": true,
&"package_valid": true,
&"current_loadable": current_format and schema_valid,
&"code": &"OK" if current_format and schema_valid else &"REQUIRES_MIGRATION",
&"format_version": int(loaded[&"manifest"].get("format_version", -1)),
}
static func _restore_previous_migration_backup(backup_path: String, old_backup_path: String) -> void:
_remove_tree(backup_path)
if DirAccess.dir_exists_absolute(old_backup_path):
DirAccess.rename_absolute(old_backup_path, backup_path)
static func _copy_tree(source_path: String, destination_path: String) -> Error:
if not DirAccess.dir_exists_absolute(source_path):
return ERR_DOES_NOT_EXIST
if not _operation_paths_are_safe([source_path, destination_path]):
return ERR_UNAUTHORIZED
_remove_tree(destination_path)
var make_error := DirAccess.make_dir_recursive_absolute(destination_path)
if make_error != OK:
return make_error
var source_directory := DirAccess.open(source_path)
if source_directory == null:
return DirAccess.get_open_error()
source_directory.list_dir_begin()
var name := source_directory.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)
var copy_error := OK
if source_directory.current_is_dir():
copy_error = _copy_tree(source_child, destination_child)
else:
copy_error = DirAccess.copy_absolute(source_child, destination_child)
if copy_error != OK:
source_directory.list_dir_end()
_remove_tree(destination_path)
return copy_error
name = source_directory.get_next()
source_directory.list_dir_end()
return OK
static func _resolve_safe_save_path(save_path: String) -> Dictionary:
if save_path.is_empty():
return _failure(&"INVALID_PATH", save_path)
var user_root := ProjectSettings.globalize_path("user://").simplify_path()
var target_path := ""
if save_path.begins_with("user://"):
var relative_path := save_path.trim_prefix("user://")
if relative_path.is_empty() or relative_path.is_absolute_path():
return _failure(&"INVALID_PATH", save_path)
var segments := relative_path.replace("\\", "/").split("/", false)
for segment in segments:
if segment == "." or segment == ".." or segment.is_empty():
return _failure(&"UNSAFE_PATH", save_path)
target_path = ProjectSettings.globalize_path(save_path).simplify_path()
elif save_path.is_absolute_path():
target_path = save_path.simplify_path()
else:
return _failure(&"INVALID_PATH", save_path)
if target_path == user_root or not target_path.begins_with(user_root + "/"):
return _failure(&"UNSAFE_PATH", save_path)
if _path_has_symbolic_link_component(target_path, user_root):
return _failure(&"UNSAFE_PATH", "symbolic link component: %s" % save_path)
return {&"ok": true, &"code": &"OK", &"path": target_path}
static func _write_world(
path: String,
uncompressed_size: int,
compressed_world: PackedByteArray,
format_version: int
) -> Error:
var file := FileAccess.open(path, FileAccess.WRITE)
if file == null:
return FileAccess.get_open_error()
file.store_buffer(MAGIC.to_ascii_buffer())
file.store_32(format_version)
file.store_64(uncompressed_size)
file.store_buffer(compressed_world)
return OK
static func _write_text(path: String, content: String) -> Error:
var file := FileAccess.open(path, FileAccess.WRITE)
if file == null:
return FileAccess.get_open_error()
file.store_string(content)
return OK
static func _write_formal_transaction_marker(path: String) -> Error:
var file := FileAccess.open(path, FileAccess.WRITE)
if file == null:
return FileAccess.get_open_error()
file.store_string(SAVE_TRANSACTION_MARKER)
file.flush()
return OK
static func _sha256(bytes: PackedByteArray) -> String:
var context := HashingContext.new()
var start_error := context.start(HashingContext.HASH_SHA256)
if start_error != OK:
return ""
context.update(bytes)
return context.finish().hex_encode()
static func _complete_timed_operation(
result: Dictionary,
metric_id: StringName,
started_at_us: int
) -> Dictionary:
var duration_us := maxi(int(Time.get_ticks_usec() - started_at_us), 0)
_performance_budget.record_sample(metric_id, duration_us)
var timed := result.duplicate(true)
timed[&"duration_us"] = duration_us
return timed
static func _read_manifest_identity(package_path: String) -> Dictionary:
var manifest_path := package_path.path_join(MANIFEST_FILE)
if not FileAccess.file_exists(manifest_path):
return {}
var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(manifest_path))
if not parsed is Dictionary:
return {}
var manifest: Dictionary = parsed
var identity := {}
for key: StringName in [&"save_id", &"display_name", &"created_at_real"]:
if manifest.has(key) or manifest.has(String(key)):
identity[key] = manifest.get(key, manifest.get(String(key)))
return identity
static func _merge_save_metadata(
metadata: Dictionary,
prior_identity: Dictionary,
logical_target_path: String
) -> Dictionary:
var merged := prior_identity.duplicate(true)
for key: Variant in metadata:
merged[key] = metadata[key]
var logical_name := logical_target_path.get_file()
if str(merged.get(&"save_id", "")).is_empty():
merged[&"save_id"] = logical_name
if str(merged.get(&"display_name", "")).is_empty():
merged[&"display_name"] = logical_name
return merged
static func _metadata_array(value: Variant) -> Array:
if value is Array:
return value.duplicate(true)
if value is PackedStringArray:
var unpacked: Array = []
for item: String in value:
unpacked.append(item)
return unpacked
return []
static func _snapshot_seed(snapshot: Dictionary) -> int:
var systems_value: Variant = snapshot.get(&"systems", {})
if not systems_value is Dictionary:
return 0
var systems: Dictionary = systems_value
var runtime_value: Variant = systems.get(&"runtime", {})
if runtime_value is Dictionary:
return int(runtime_value.get(&"world_seed", 0))
return int(systems.get(&"world_seed", 0))
static func _ensure_thumbnail(package_path: String, metadata: Dictionary) -> Error:
var thumbnail_path := package_path.path_join(THUMBNAIL_FILE)
var supplied_bytes := PackedByteArray()
for key: StringName in [&"thumbnail_png", &"_thumbnail_png"]:
var value: Variant = metadata.get(key, null)
if value is PackedByteArray and not value.is_empty():
supplied_bytes = value.duplicate()
break
if supplied_bytes.is_empty():
var supplied_path := str(metadata.get(&"thumbnail_path", ""))
if not supplied_path.is_empty() and FileAccess.file_exists(supplied_path):
supplied_bytes = FileAccess.get_file_as_bytes(supplied_path)
if not supplied_bytes.is_empty():
var supplied_image := Image.new()
var decode_error := supplied_image.load_png_from_buffer(supplied_bytes)
if decode_error != OK:
return decode_error
var file := FileAccess.open(thumbnail_path, FileAccess.WRITE)
if file == null:
return FileAccess.get_open_error()
file.store_buffer(supplied_bytes)
return OK
var placeholder := Image.create_empty(1, 1, false, Image.FORMAT_RGBA8)
placeholder.fill(Color(0.08, 0.12, 0.10, 1.0))
return placeholder.save_png(thumbnail_path)
static func _looks_like_save_package(path: String) -> bool:
if not DirAccess.dir_exists_absolute(path):
return false
if FileAccess.file_exists(path.path_join(WORLD_FILE)):
return true
var has_manifest := FileAccess.file_exists(path.path_join(MANIFEST_FILE))
var has_companion := (
FileAccess.file_exists(path.path_join(THUMBNAIL_FILE))
or FileAccess.file_exists(path.path_join(JOURNAL_FILE))
)
return has_manifest and has_companion
static func _path_is_missing_or_package(path: String) -> bool:
if not FileAccess.file_exists(path) and not DirAccess.dir_exists_absolute(path):
return true
return _looks_like_save_package(path)
static func _validate_package_read_boundary(package_path: String) -> Dictionary:
if not _operation_paths_are_safe([package_path]):
return _failure(&"UNSAFE_PATH", "save package contains a symbolic link: %s" % package_path)
if not DirAccess.dir_exists_absolute(package_path):
return _failure(&"SAVE_NOT_FOUND", package_path)
for file_name: String in [WORLD_FILE, MANIFEST_FILE, THUMBNAIL_FILE, JOURNAL_FILE]:
var known_path := package_path.path_join(file_name)
if _is_symbolic_link(known_path):
return _failure(&"UNSAFE_PATH", "symbolic link in save package: %s" % known_path)
if DirAccess.dir_exists_absolute(known_path):
return _failure(&"INVALID_PACKAGE_ENTRY", known_path)
return {&"ok": true, &"code": &"OK"}
static func _package_file_is_safe_for_read(path: String) -> bool:
var user_root := ProjectSettings.globalize_path("user://").simplify_path()
var normalized := path.simplify_path()
if normalized == user_root or not normalized.begins_with(user_root + "/"):
return false
if _path_has_symbolic_link_component(normalized, user_root):
return false
return not _is_symbolic_link(normalized) and not DirAccess.dir_exists_absolute(normalized)
static func _formal_transaction_marker_is_valid(marker_path: String) -> bool:
if _is_symbolic_link(marker_path) or DirAccess.dir_exists_absolute(marker_path):
return false
if not FileAccess.file_exists(marker_path):
return false
return FileAccess.get_file_as_string(marker_path) == SAVE_TRANSACTION_MARKER
static func _remove_formal_transaction_marker(marker_path: String) -> void:
if _formal_transaction_marker_is_valid(marker_path):
DirAccess.remove_absolute(marker_path)
static func _operation_paths_are_safe(paths: Array) -> bool:
var user_root := ProjectSettings.globalize_path("user://").simplify_path()
for raw_path: Variant in paths:
var path := str(raw_path).simplify_path()
if path == user_root or not path.begins_with(user_root + "/"):
return false
if _path_has_symbolic_link_component(path, user_root):
return false
if DirAccess.dir_exists_absolute(path) and _tree_contains_symbolic_link(path):
return false
return true
static func _path_has_symbolic_link_component(path: String, root_path: String) -> bool:
if path == root_path:
return false
var relative := path.trim_prefix(root_path + "/")
var current := root_path
for segment: String in relative.split("/", false):
var parent := DirAccess.open(current)
if parent != null and parent.is_link(segment):
return true
current = current.path_join(segment)
return false
static func _tree_contains_symbolic_link(path: String) -> bool:
var directory := DirAccess.open(path)
if directory == null:
return false
directory.list_dir_begin()
var name := directory.get_next()
while not name.is_empty():
if name != "." and name != "..":
if directory.is_link(name):
directory.list_dir_end()
return true
if directory.current_is_dir() and _tree_contains_symbolic_link(path.path_join(name)):
directory.list_dir_end()
return true
name = directory.get_next()
directory.list_dir_end()
return false
static func _is_symbolic_link(path: String) -> bool:
var parent := DirAccess.open(path.get_base_dir())
return parent != null and parent.is_link(path.get_file())
static func _with_context(result: Dictionary, context: Dictionary) -> Dictionary:
var enriched := result.duplicate(true)
for key: Variant in context:
enriched[key] = context[key]
return enriched
static func _failure(code: StringName, message: String) -> Dictionary:
return {
&"ok": false,
&"code": code,
&"message": message,
}
static func _remove_tree(path: String) -> void:
if _is_symbolic_link(path):
DirAccess.remove_absolute(path)
return
if FileAccess.file_exists(path):
DirAccess.remove_absolute(path)
return
if not DirAccess.dir_exists_absolute(path):
return
var directory := DirAccess.open(path)
if directory == null:
return
directory.list_dir_begin()
var name := directory.get_next()
while not name.is_empty():
if name != "." and name != "..":
var child_path := path.path_join(name)
if directory.current_is_dir():
_remove_tree(child_path)
else:
DirAccess.remove_absolute(child_path)
name = directory.get_next()
directory.list_dir_end()
DirAccess.remove_absolute(path)