feat: add validated quicksave persistence

This commit is contained in:
2026-07-05 18:10:33 +02:00
parent 2fcf641152
commit 08b7bee0e5
10 changed files with 264 additions and 0 deletions
@@ -0,0 +1,21 @@
extends Node
@export var simulation_manager: Node
var store := SaveSlotStore.new()
func _unhandled_key_input(event: InputEvent) -> void:
if not event.pressed or event.echo:
return
if event.keycode == KEY_F5:
if store.save(simulation_manager):
print("[SaveSlot] Quicksave written")
else:
push_error("[SaveSlot] " + store.last_error)
get_viewport().set_input_as_handled()
elif event.keycode == KEY_F9:
if store.load_into(simulation_manager):
print("[SaveSlot] Quicksave restored")
else:
push_error("[SaveSlot] " + store.last_error)
get_viewport().set_input_as_handled()
@@ -0,0 +1 @@
uid://cwvdljsh148wh
+131
View File
@@ -0,0 +1,131 @@
class_name SaveSlotStore
extends RefCounted
const DEFAULT_DIRECTORY := "user://saves"
const DEFAULT_SLOT := "quicksave"
const MAX_SAVE_BYTES := 16 * 1024 * 1024
var save_directory: String
var last_error := ""
func _init(directory: String = DEFAULT_DIRECTORY) -> void:
save_directory = directory
func save(manager: Node, slot_name: String = DEFAULT_SLOT) -> bool:
last_error = ""
if manager == null or not manager.has_method("serialize_state"):
return _fail("Save source cannot serialize simulation state")
if not _is_valid_slot_name(slot_name):
return _fail("Invalid save slot name")
var json_text: String = manager.serialize_state()
if json_text.to_utf8_buffer().size() > MAX_SAVE_BYTES:
return _fail("Save exceeds the supported size limit")
if SimulationStateRecord.from_json(json_text) == null:
return _fail("Simulation produced an invalid save record")
if not _ensure_directory():
return false
var final_path := get_slot_path(slot_name)
var temporary_path := final_path + ".tmp"
var backup_path := final_path + ".bak"
_remove_if_present(temporary_path)
var file := FileAccess.open(temporary_path, FileAccess.WRITE)
if file == null:
return _fail("Could not open temporary save file")
file.store_string(json_text)
file.flush()
file.close()
if _read_record(temporary_path) == null:
_remove_if_present(temporary_path)
return _fail("Temporary save failed validation")
var had_existing := FileAccess.file_exists(final_path)
if had_existing:
_remove_if_present(backup_path)
if DirAccess.rename_absolute(final_path, backup_path) != OK:
_remove_if_present(temporary_path)
return _fail("Could not preserve the previous save")
if DirAccess.rename_absolute(temporary_path, final_path) != OK:
if had_existing:
DirAccess.rename_absolute(backup_path, final_path)
_remove_if_present(temporary_path)
return _fail("Could not install the new save")
_remove_if_present(backup_path)
return true
func load_into(manager: Node, slot_name: String = DEFAULT_SLOT) -> bool:
last_error = ""
if manager == null or not manager.has_method("restore_state"):
return _fail("Save target cannot restore simulation state")
if not _is_valid_slot_name(slot_name):
return _fail("Invalid save slot name")
var final_path := get_slot_path(slot_name)
var record := _read_record(final_path)
if record == null:
record = _read_record(final_path + ".bak")
if record == null:
return _fail("Save slot is missing, invalid, or unsupported")
if not bool(manager.restore_state(record)):
return _fail("Simulation refused the validated save record")
return true
func has_slot(slot_name: String = DEFAULT_SLOT) -> bool:
if not _is_valid_slot_name(slot_name):
return false
var final_path := get_slot_path(slot_name)
return (
_read_record(final_path) != null
or _read_record(final_path + ".bak") != null
)
func delete_slot(slot_name: String = DEFAULT_SLOT) -> bool:
if not _is_valid_slot_name(slot_name):
return false
var final_path := get_slot_path(slot_name)
_remove_if_present(final_path)
_remove_if_present(final_path + ".tmp")
_remove_if_present(final_path + ".bak")
return true
func get_slot_path(slot_name: String = DEFAULT_SLOT) -> String:
return save_directory.path_join(slot_name + ".json")
func _read_record(path: String) -> SimulationStateRecord:
if not FileAccess.file_exists(path):
return null
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
return null
if file.get_length() > MAX_SAVE_BYTES:
file.close()
return null
var json_text := file.get_as_text()
file.close()
return SimulationStateRecord.from_json(json_text)
func _ensure_directory() -> bool:
var absolute_directory := ProjectSettings.globalize_path(save_directory)
var error := DirAccess.make_dir_recursive_absolute(absolute_directory)
if error != OK and error != ERR_ALREADY_EXISTS:
return _fail("Could not create the save directory")
return true
func _is_valid_slot_name(slot_name: String) -> bool:
if slot_name.is_empty() or slot_name.length() > 32:
return false
var allowed := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-"
for character in slot_name:
if not allowed.contains(character):
return false
return true
func _remove_if_present(path: String) -> void:
if FileAccess.file_exists(path):
DirAccess.remove_absolute(path)
func _fail(message: String) -> bool:
last_error = message
return false
@@ -0,0 +1 @@
uid://3w3wsbtr4gpw