The formal collector's roster includes 46 suites the Makefile never ran; the first formal attempt stopped on test_required_kitchen and a sweep found: - v15 MIMI purchase saves could not be written once Runtime saves carried mana_reserve (P06-01): the format detector now validates that section and excludes it before the unchanged v15 allowlist, like special_peeping_rooms. - test_required_kitchen's legacy14 exact-load comparison now maps the two approved migrations (fresh A06 reserve at the restored boundary, elevator snapshot v1->v2) after checking each against its contract, in Runtime key order; every other byte must still match. - test_runtime_defense_autonomy allowed Mana to change only by the A06 flow. - test_p08_normal_placement_ops expects 25 palette slots (two warps); its suite-policy pin is updated with flags unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1988 lines
84 KiB
GDScript
1988 lines
84 KiB
GDScript
class_name SaveService
|
|
extends RefCounted
|
|
|
|
const FORMAT_VERSION := 1
|
|
const MIMI_PURCHASE_FORMAT_VERSION := 2
|
|
const SUPPORTED_CURRENT_FORMAT := -2
|
|
const PurchaseQuoteV2 := preload("res://scripts/supply/supply_purchase_quote_v2.gd")
|
|
const PurchaseQuotePlansV2 := preload("res://scripts/supply/supply_auto_purchase_quote_plans.gd")
|
|
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 selected_format := _snapshot_package_format(snapshot)
|
|
if selected_format < 0:
|
|
return _complete_timed_operation(_failure(&"INVALID_SNAPSHOT", "unsupported purchase Runtime/domain"), &"save", started_at_us)
|
|
var result := _save_snapshot_absolute(
|
|
resolved[&"path"],
|
|
save_path,
|
|
snapshot,
|
|
metadata,
|
|
selected_format,
|
|
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"], SUPPORTED_CURRENT_FORMAT)
|
|
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"], SUPPORTED_CURRENT_FORMAT)
|
|
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 selected_format := _snapshot_package_format(snapshot)
|
|
if selected_format < 0:
|
|
return _complete_timed_operation(_failure(&"INVALID_SNAPSHOT", "unsupported purchase Runtime/domain"), &"save", started_at_us)
|
|
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,
|
|
selected_format,
|
|
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: Dictionary = (
|
|
formal_reconcile[&"migration_reconciliation"]
|
|
if formal_reconcile.has(&"migration_reconciliation")
|
|
else _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 = _package_domain_matches(loaded)
|
|
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, SUPPORTED_CURRENT_FORMAT)
|
|
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()
|
|
var selected_format := _snapshot_package_format(candidate)
|
|
if selected_format < 0: return _failure(&"MIGRATION_INVALID_SNAPSHOT", "unsupported purchase Runtime/domain")
|
|
return _complete_timed_operation(_migrate_save_impl(save_path, null, selected_format, 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:
|
|
if _snapshot_package_format(snapshot) != format_version:
|
|
return _failure(&"INVALID_SNAPSHOT", "package/domain format differs")
|
|
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:
|
|
var format_guard := _guard_ordinary_save_format(target_path, format_version)
|
|
if not format_guard[&"ok"]:
|
|
return format_guard
|
|
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)),
|
|
}
|
|
|
|
|
|
## Ordinary Save must not act as an implicit physical-format migration.
|
|
## This preflight only reads the formal package and the candidate which the
|
|
## existing normal-save reconciliation rules could promote. Explicit migration
|
|
## APIs and autosave rotation keep their own unchanged contracts.
|
|
static func _guard_ordinary_save_format(target_path: String, requested_format: int) -> Dictionary:
|
|
var formal_guard := _guard_identified_formal_format(target_path)
|
|
if not formal_guard[&"ok"]:
|
|
return formal_guard
|
|
var marker_path := target_path + SAVE_TRANSACTION_SUFFIX
|
|
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 identified := _inspect_identified_save_formats(target_path)
|
|
if not identified[&"ok"]:
|
|
return identified
|
|
for existing_format: int in identified[&"format_versions"]:
|
|
if existing_format != requested_format:
|
|
return _ordinary_format_migration_required(target_path, existing_format, requested_format)
|
|
var target_status := _package_status(target_path)
|
|
if target_status[&"current_loadable"] or (target_status[&"exists"] and not marker_exists):
|
|
return {&"ok": true, &"code": &"OK"}
|
|
if target_status[&"exists"] and not _looks_like_save_package(target_path):
|
|
return _failure(&"FORMAL_RECOVERY_TARGET_CONFLICT", target_path)
|
|
# Reconciliation chooses a verified .tmp first, then a verified .bak. A
|
|
# missing/invalid formal does not make a retained old-format source a new slot.
|
|
for candidate_path: String in [target_path + ".tmp", target_path + ".bak"]:
|
|
var status := _package_status(candidate_path)
|
|
if not status[&"current_loadable"]:
|
|
continue
|
|
var existing_format := int(status[&"format_version"])
|
|
if existing_format != requested_format:
|
|
return _ordinary_format_migration_required(candidate_path, existing_format, requested_format)
|
|
break
|
|
return {&"ok": true, &"code": &"OK"}
|
|
|
|
|
|
## A corrupt format1 checksum/manifest still identifies a retained format when
|
|
## either its bounded manifest number or fixed TTWB header says so. Never use a
|
|
## schema-looking nested string or coerce a non-integral manifest value.
|
|
static func _inspect_identified_save_formats(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
|
|
return {&"ok": true, &"code": &"OK", &"format_versions": versions}
|
|
|
|
|
|
static func _ordinary_format_migration_required(source_path: String, existing_format: int, requested_format: int) -> Dictionary:
|
|
return _with_context(_failure(&"FORMAT_MIGRATION_REQUIRED", "%d -> %d requires explicit migration or a new slot" % [existing_format, requested_format]), {
|
|
&"existing_format_version": existing_format,
|
|
&"requested_format_version": requested_format,
|
|
&"migration_source_path": source_path,
|
|
&"formal_and_artifacts_preserved": true,
|
|
})
|
|
|
|
|
|
static func _write_package_directory(
|
|
package_path: String,
|
|
snapshot: Dictionary,
|
|
metadata: Dictionary,
|
|
format_version: int,
|
|
logical_target_path := ""
|
|
) -> Dictionary:
|
|
if format_version in [FORMAT_VERSION, MIMI_PURCHASE_FORMAT_VERSION] and _snapshot_package_format(snapshot) != format_version:
|
|
return _failure(&"INVALID_SNAPSHOT", "package/domain format differs")
|
|
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(),
|
|
}
|
|
if format_version == MIMI_PURCHASE_FORMAT_VERSION:
|
|
manifest[&"runtime_schema_version"] = 15
|
|
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_v1(
|
|
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,
|
|
}
|
|
|
|
|
|
|
|
|
|
## Format choice comes only from the decoded owned Runtime, never from a
|
|
## recursively found schema-looking string or the duplicate manifest field.
|
|
static func _snapshot_package_format(snapshot: Dictionary) -> int:
|
|
var systems: Variant = snapshot.get(&"systems", {})
|
|
if not systems is Dictionary: return FORMAT_VERSION # existing generic extra payload
|
|
var runtime_data: Variant = systems.get(&"runtime", null)
|
|
if runtime_data == null: return FORMAT_VERSION # existing generic envelope
|
|
if not runtime_data is Dictionary: return FORMAT_VERSION # no owned Runtime authority
|
|
var version: Variant = runtime_data.get(&"runtime_schema_version", 1)
|
|
if typeof(version) != TYPE_INT: return -1
|
|
if runtime_data.has(&"special_peeping_rooms"):
|
|
var section_clock: Variant = snapshot.get(&"clock")
|
|
if not section_clock is Dictionary or typeof(section_clock.get(&"absolute_minute")) != TYPE_INT: return -1
|
|
if not preload("res://scripts/special/peeping_room_service.gd").valid_runtime_section(runtime_data, section_clock[&"absolute_minute"]): return -1
|
|
# Keep PurchaseQuotePlansV2's exact original allowlist and all format rules.
|
|
runtime_data = runtime_data.duplicate()
|
|
runtime_data.erase(&"special_peeping_rooms")
|
|
if runtime_data.has(&"mana_reserve"):
|
|
# A06 finite reserve: validate the section, then keep the exact original
|
|
# v15 purchase allowlist unchanged, as for the peeping-room extension.
|
|
if not preload("res://scripts/mana/mana_reserve_cycle.gd").new().restore_snapshot(runtime_data[&"mana_reserve"]): return -1
|
|
runtime_data = runtime_data.duplicate()
|
|
runtime_data.erase(&"mana_reserve")
|
|
if version >= 1 and version <= 14:
|
|
return -1 if PurchaseQuoteV2.has_history(runtime_data) else FORMAT_VERSION
|
|
if version != 15: return -1
|
|
if not _canonical_owned_key(snapshot, &"systems") or not _canonical_owned_key(systems, &"runtime"): return -1
|
|
var clock: Variant = snapshot.get(&"clock")
|
|
if not clock is Dictionary or typeof(clock.get(&"absolute_minute")) != TYPE_INT: return -1
|
|
return MIMI_PURCHASE_FORMAT_VERSION if PurchaseQuotePlansV2.valid_runtime_format(runtime_data, clock[&"absolute_minute"]) else -1
|
|
|
|
|
|
static func _canonical_owned_key(owner: Dictionary, key: StringName) -> bool:
|
|
for raw: Variant in owner:
|
|
if raw == key: return typeof(raw) == TYPE_STRING_NAME
|
|
return false
|
|
|
|
|
|
static func _package_domain_matches(loaded: Dictionary) -> bool:
|
|
if not bool(loaded.get(&"ok", false)): return false
|
|
var manifest: Dictionary = loaded[&"manifest"]
|
|
var format_version := int(manifest.get("format_version", -1))
|
|
if format_version not in [FORMAT_VERSION, MIMI_PURCHASE_FORMAT_VERSION]: return false
|
|
if format_version != _snapshot_package_format(loaded[&"snapshot"]): return false
|
|
if format_version == MIMI_PURCHASE_FORMAT_VERSION:
|
|
var duplicate_version: Variant = manifest.get("runtime_schema_version")
|
|
return _is_integral_json_number(duplicate_version, 15, 15)
|
|
return true
|
|
|
|
|
|
static func _load_snapshot_absolute(target_path: String, expected_format_version: int,
|
|
validate_snapshot := true) -> Dictionary:
|
|
# -2 means either supported current format; -1 still decodes historical
|
|
# physical packages for explicit migration/reporting. Positive verification
|
|
# requests keep their exact original physical-format check.
|
|
if expected_format_version == SUPPORTED_CURRENT_FORMAT:
|
|
var guard := _guard_identified_formal_format(target_path)
|
|
if not guard[&"ok"]: return guard
|
|
var expected := -1 if expected_format_version == SUPPORTED_CURRENT_FORMAT else expected_format_version
|
|
var loaded := _load_snapshot_absolute_v1(target_path, expected, validate_snapshot)
|
|
if not loaded[&"ok"]: return loaded
|
|
var version := int(loaded[&"manifest"].get("format_version", -1))
|
|
if version in [FORMAT_VERSION, MIMI_PURCHASE_FORMAT_VERSION]:
|
|
if not _package_domain_matches(loaded):
|
|
return _with_context(_failure(&"UNSUPPORTED_FORMAT", "package/domain crosslink differs"), {&"formal_and_artifacts_preserved": true})
|
|
elif expected_format_version == SUPPORTED_CURRENT_FORMAT:
|
|
return _failure(&"UNSUPPORTED_FORMAT", str(version))
|
|
return loaded
|
|
|
|
|
|
static func _guard_identified_formal_format(target_path: String) -> Dictionary:
|
|
var original := _guard_identified_formal_format_v1(target_path)
|
|
if not original[&"ok"]:
|
|
# A checksum-damaged future format remains protected by the original
|
|
# prefix/manifest probe. Only one coherent, fully decoded known2 passes.
|
|
if original[&"code"] != &"UNSUPPORTED_FORMAT" \
|
|
or original.get(&"identified_format_version") != MIMI_PURCHASE_FORMAT_VERSION \
|
|
or original.get(&"identified_format_versions") != [MIMI_PURCHASE_FORMAT_VERSION]: return original
|
|
var known := _load_snapshot_absolute_v1(target_path, MIMI_PURCHASE_FORMAT_VERSION, true)
|
|
return {&"ok": true, &"code": &"OK"} if _package_domain_matches(known) else original
|
|
# Existing malformed/checksum-broken format1 recovery remains allowed. A
|
|
# successfully decoded Runtime15 hidden in format1 may not be overwritten.
|
|
var known := _load_snapshot_absolute_v1(target_path, FORMAT_VERSION, false)
|
|
if known[&"ok"] and not _package_domain_matches(known):
|
|
var invalid := _failure(&"UNSUPPORTED_FORMAT", "format1 contains reserved/unsupported Runtime history")
|
|
invalid[&"identified_format_version"] = FORMAT_VERSION
|
|
invalid[&"identified_format_versions"] = [FORMAT_VERSION]
|
|
invalid[&"formal_and_artifacts_preserved"] = true
|
|
return invalid
|
|
return original
|
|
|
|
|
|
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_v1(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)
|
|
|
|
# A held migration source owns the absent/formally promoted target. Normal
|
|
# .bak is an older ordinary-save generation and must not take that name.
|
|
# Verify migration artifacts before allowing normal-save reconciliation.
|
|
if DirAccess.dir_exists_absolute(target_path + ".migration.original"):
|
|
if marker_exists:
|
|
return _failure(&"MIGRATION_FORMAL_TRANSACTION_CONFLICT", target_path)
|
|
var migration := _reconcile_interrupted_migration(target_path)
|
|
return _with_context(migration, {&"migration_reconciliation": migration.duplicate(true)})
|
|
|
|
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 := _package_domain_matches(loaded)
|
|
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)
|