1633 lines
64 KiB
GDScript
1633 lines
64 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"
|
|
|
|
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)
|
|
|
|
|
|
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"]
|
|
var formal_reconcile := _reconcile_interrupted_formal_save(target_path)
|
|
if not formal_reconcile[&"ok"]:
|
|
return formal_reconcile
|
|
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)
|
|
|
|
|
|
static func _migrate_save_impl(
|
|
save_path: String,
|
|
registry: SaveMigrationRegistry,
|
|
target_version: int
|
|
) -> Dictionary:
|
|
if registry == null:
|
|
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_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 target_version == from_version:
|
|
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 := registry.migrate(payload, from_version, target_version)
|
|
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)
|
|
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)
|
|
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_error := _write_text(package_path.path_join(MANIFEST_FILE), JSON.stringify(manifest, "\t"))
|
|
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()))
|
|
var manifest_text := manifest_file.get_as_text()
|
|
manifest_file = null
|
|
var manifest_value: Variant = JSON.parse_string(manifest_text)
|
|
if not manifest_value is Dictionary:
|
|
return _failure(&"INVALID_MANIFEST", manifest_path)
|
|
var manifest: Dictionary = manifest_value
|
|
var manifest_version := int(manifest.get("format_version", manifest.get(&"format_version", -1)))
|
|
if manifest_version < 0:
|
|
return _failure(&"INVALID_MANIFEST", "missing format_version")
|
|
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)
|
|
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()
|
|
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 expected_checksum := str(manifest.get("checksum_world", manifest.get(&"checksum_world", "")))
|
|
if expected_checksum.is_empty() or _sha256(compressed_world) != expected_checksum:
|
|
return _failure(&"CHECKSUM_MISMATCH", world_path)
|
|
var manifest_size := int(manifest.get("uncompressed_size", manifest.get(&"uncompressed_size", -1)))
|
|
if manifest_size >= 0 and manifest_size != 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,
|
|
}
|
|
|
|
|
|
## 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 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)
|