Add supply auto-purchase and preserve save recovery state

This commit is contained in:
Takeshi
2026-10-02 07:35:03 +09:00
parent 285528b7a3
commit 39f6068461
44 changed files with 2625 additions and 61 deletions
+140 -19
View File
@@ -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)
+20 -3
View File
@@ -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"]: