Add supply auto-purchase and preserve save recovery state
This commit is contained in:
+140
-19
@@ -14,6 +14,10 @@ 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()
|
||||
|
||||
@@ -48,6 +52,19 @@ static func load_snapshot(save_path: String) -> Dictionary:
|
||||
return _complete_timed_operation(result, &"load", started_at_us)
|
||||
|
||||
|
||||
## Reads only the named formal package. Unlike load_snapshot, this never
|
||||
## reconciles, promotes, removes or migrates interrupted-save artifacts. A
|
||||
## missing formal package stays missing even when a .tmp/.bak could recover it.
|
||||
static func inspect_snapshot(save_path: String) -> Dictionary:
|
||||
var started_at_us := Time.get_ticks_usec()
|
||||
var resolved := _resolve_safe_save_path(save_path)
|
||||
var result: Dictionary = resolved
|
||||
if resolved[&"ok"]:
|
||||
result = _load_snapshot_absolute(resolved[&"path"], FORMAT_VERSION)
|
||||
result[&"read_only"] = true
|
||||
return _complete_timed_operation(result, &"load", started_at_us)
|
||||
|
||||
|
||||
static func get_performance_report(metric_id: StringName) -> Dictionary:
|
||||
return _performance_budget.get_report(metric_id)
|
||||
|
||||
@@ -314,9 +331,11 @@ static func _load_recovery_candidate_impl(
|
||||
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
|
||||
# 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":
|
||||
@@ -393,12 +412,29 @@ static func _migrate_save_impl(
|
||||
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 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",
|
||||
@@ -734,10 +770,16 @@ static func _write_package_directory(
|
||||
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),
|
||||
@@ -775,7 +817,11 @@ static func _write_package_directory(
|
||||
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"))
|
||||
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))
|
||||
@@ -813,15 +859,23 @@ static func _load_snapshot_absolute(
|
||||
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 manifest_value: Variant = JSON.parse_string(manifest_text)
|
||||
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 manifest_version := int(manifest.get("format_version", manifest.get(&"format_version", -1)))
|
||||
if manifest_version < 0:
|
||||
return _failure(&"INVALID_MANIFEST", "missing format_version")
|
||||
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))
|
||||
|
||||
@@ -832,21 +886,33 @@ static func _load_snapshot_absolute(
|
||||
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 expected_checksum := str(manifest.get("checksum_world", manifest.get(&"checksum_world", "")))
|
||||
if expected_checksum.is_empty() or _sha256(compressed_world) != expected_checksum:
|
||||
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)
|
||||
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)
|
||||
# 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)
|
||||
@@ -866,6 +932,58 @@ static func _load_snapshot_absolute(
|
||||
}
|
||||
|
||||
|
||||
static func _is_integral_json_number(value: Variant, minimum: int, maximum: int) -> bool:
|
||||
if typeof(value) == TYPE_INT:
|
||||
return value >= minimum and value <= maximum
|
||||
if typeof(value) != TYPE_FLOAT or not is_finite(value):
|
||||
return false
|
||||
return value >= minimum and value <= maximum and value == floor(value)
|
||||
|
||||
|
||||
static func _world_size_is_supported(size: int) -> bool:
|
||||
return size > 0 and size <= MAX_WORLD_BYTES
|
||||
|
||||
|
||||
## Probe only bounded metadata and the fixed binary prefix. An identifiable
|
||||
## unsupported formal format must be rejected before any transaction artifact
|
||||
## is reconciled, even when its compressed payload/checksum is damaged.
|
||||
static func _guard_identified_formal_format(target_path: String) -> Dictionary:
|
||||
var versions: Array[int] = []
|
||||
var manifest_path := target_path.path_join(MANIFEST_FILE)
|
||||
if FileAccess.file_exists(manifest_path):
|
||||
if not _package_file_is_safe_for_read(manifest_path):
|
||||
return _failure(&"UNSAFE_PATH", manifest_path)
|
||||
var manifest_file := FileAccess.open(manifest_path, FileAccess.READ)
|
||||
if manifest_file == null:
|
||||
return _failure(&"INVALID_MANIFEST", error_string(FileAccess.get_open_error()))
|
||||
if manifest_file.get_length() <= MAX_MANIFEST_BYTES:
|
||||
var parser := JSON.new()
|
||||
if parser.parse(manifest_file.get_as_text()) == OK and parser.data is Dictionary:
|
||||
var value: Variant = parser.data.get("format_version", null)
|
||||
if _is_integral_json_number(value, 0, 0xffffffff):
|
||||
versions.append(int(value))
|
||||
manifest_file = null
|
||||
var world_path := target_path.path_join(WORLD_FILE)
|
||||
if FileAccess.file_exists(world_path):
|
||||
if not _package_file_is_safe_for_read(world_path):
|
||||
return _failure(&"UNSAFE_PATH", world_path)
|
||||
var world_file := FileAccess.open(world_path, FileAccess.READ)
|
||||
if world_file == null:
|
||||
return _failure(&"READ_WORLD_FAILED", error_string(FileAccess.get_open_error()))
|
||||
if world_file.get_length() >= 8 and world_file.get_buffer(4).get_string_from_ascii() == MAGIC:
|
||||
var version := world_file.get_32()
|
||||
if version not in versions: versions.append(version)
|
||||
world_file = null
|
||||
for version: int in versions:
|
||||
if version != FORMAT_VERSION:
|
||||
var failure := _failure(&"UNSUPPORTED_FORMAT", str(version))
|
||||
failure[&"identified_format_version"] = version
|
||||
failure[&"identified_format_versions"] = versions
|
||||
failure[&"formal_and_artifacts_preserved"] = true
|
||||
return failure
|
||||
return {&"ok": true, &"code": &"OK"}
|
||||
|
||||
|
||||
## Resolves a normal-save promotion interrupted after the old formal package
|
||||
## moved to .bak. Only checksum/schema-verified packages may become formal.
|
||||
## The marker is durable, but verified .tmp/.bak artifacts also recover saves
|
||||
@@ -876,6 +994,9 @@ static func _reconcile_interrupted_formal_save(target_path: String) -> Dictionar
|
||||
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)
|
||||
|
||||
@@ -15,16 +15,24 @@ static func capture(clock: SimulationKernel, state: Node, extra := {}) -> Dictio
|
||||
|
||||
static func validate(snapshot: Dictionary) -> Dictionary:
|
||||
var errors: Array[String] = []
|
||||
if int(snapshot.get(&"schema_version", -1)) != SCHEMA_VERSION:
|
||||
var schema_value: Variant = snapshot.get(&"schema_version", null)
|
||||
if not _is_nonnegative_integer(schema_value) or int(schema_value) != SCHEMA_VERSION:
|
||||
errors.append("unsupported_schema_version")
|
||||
if not snapshot.get(&"clock", null) is Dictionary:
|
||||
errors.append("missing_clock")
|
||||
else:
|
||||
var clock_data: Dictionary = snapshot[&"clock"]
|
||||
if int(clock_data.get(&"absolute_minute", -1)) < 0:
|
||||
if not _is_nonnegative_integer(clock_data.get(&"absolute_minute", null)):
|
||||
errors.append("invalid_absolute_minute")
|
||||
if int(clock_data.get(&"speed", 0)) not in [1, 2, 3, 4]:
|
||||
var speed_value: Variant = clock_data.get(&"speed", null)
|
||||
if not _is_nonnegative_integer(speed_value) or int(speed_value) not in [1, 2, 3, 4]:
|
||||
errors.append("invalid_speed")
|
||||
# Legacy envelopes may omit these fields and retain restore_clock's
|
||||
# paused=true/version=0 defaults. Present fields cannot be coerced.
|
||||
if typeof(clock_data.get(&"paused", true)) != TYPE_BOOL:
|
||||
errors.append("invalid_paused")
|
||||
if not _is_nonnegative_integer(clock_data.get(&"snapshot_version", 0)):
|
||||
errors.append("invalid_snapshot_version")
|
||||
if not snapshot.get(&"game_state", null) is Dictionary:
|
||||
errors.append("missing_game_state")
|
||||
return {
|
||||
@@ -32,6 +40,15 @@ static func validate(snapshot: Dictionary) -> Dictionary:
|
||||
&"errors": errors,
|
||||
}
|
||||
|
||||
static func _is_nonnegative_integer(value: Variant) -> bool:
|
||||
if typeof(value) == TYPE_INT:
|
||||
return value >= 0
|
||||
# JSON numbers are floats, but exact integral values remain compatible.
|
||||
# Avoid an overflowing float-to-int conversion at the signed int64 limit.
|
||||
if typeof(value) != TYPE_FLOAT or not is_finite(value):
|
||||
return false
|
||||
return value >= 0 and value < 9223372036854775808.0 and value == floor(value)
|
||||
|
||||
static func restore(snapshot: Dictionary, clock: SimulationKernel, state: Node) -> bool:
|
||||
var validation := validate(snapshot)
|
||||
if not validation[&"ok"]:
|
||||
|
||||
Reference in New Issue
Block a user