Implement asynchronous save manager with UI update optimizations and add tests for save functionality
This commit is contained in:
+122
-22
@@ -10,6 +10,80 @@ const SimulationTime = preload("res://world/owniverse/simulation_time.gd")
|
||||
const STATE_VERSION: String = SimulationTime.STATE_VERSION
|
||||
var last_error: String = ""
|
||||
var blocked_saves: Dictionary = {}
|
||||
var save_directory: String = "user://"
|
||||
signal save_completed(filename: String, success: bool, error: String)
|
||||
signal saves_drained
|
||||
|
||||
# Queue ownership stays on the main thread. The worker only sees its snapshot.
|
||||
var _save_thread: Thread
|
||||
var _active_filename: String = ""
|
||||
var _pending_saves: Dictionary = {}
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if _save_thread != null and not _save_thread.is_alive():
|
||||
_finish_save()
|
||||
|
||||
|
||||
func is_saving() -> bool:
|
||||
return _save_thread != null or not _pending_saves.is_empty()
|
||||
|
||||
|
||||
func _snapshot(filename: String, password: String, data: Dictionary) -> Dictionary:
|
||||
var payload: Dictionary = data.duplicate(true)
|
||||
payload.format_version = FORMAT_VERSION
|
||||
payload.app_version = str(ProjectSettings.get_setting("application/config/version", ""))
|
||||
return {"filename": filename, "path": save_directory.path_join(filename + ".adventure"), "password": password, "payload": payload,
|
||||
"plaintext": Global.save_plaintext_copy, "speed_limit": Global.SPEED_LIMIT,
|
||||
"stages": Global.CivilizationRules.keys()}
|
||||
|
||||
|
||||
## True means queued, not written. Completion is reported by save_completed.
|
||||
func request_save(filename: String, password: String, data: Dictionary) -> bool:
|
||||
if blocked_saves.has(filename):
|
||||
return _fail("Save blocked to preserve an unreadable save: " + filename)
|
||||
_pending_saves[filename] = _snapshot(filename, password, data)
|
||||
if _save_thread == null:
|
||||
_start_save()
|
||||
return true
|
||||
|
||||
|
||||
func _start_save() -> void:
|
||||
if _pending_saves.is_empty():
|
||||
return
|
||||
_active_filename = _pending_saves.keys()[0]
|
||||
var job: Dictionary = _pending_saves[_active_filename]
|
||||
_pending_saves.erase(_active_filename)
|
||||
_save_thread = Thread.new()
|
||||
var error: Error = _save_thread.start(_write_snapshot.bind(job))
|
||||
if error != OK:
|
||||
_save_thread = null
|
||||
_fail("Cannot start save worker: " + error_string(error))
|
||||
save_completed.emit(_active_filename, false, last_error)
|
||||
_start_save()
|
||||
if not is_saving(): saves_drained.emit()
|
||||
|
||||
|
||||
func _finish_save() -> void:
|
||||
var error: String = _save_thread.wait_to_finish()
|
||||
var filename: String = _active_filename
|
||||
_save_thread = null
|
||||
last_error = error
|
||||
if error != "": push_error(error)
|
||||
save_completed.emit(filename, error == "", error)
|
||||
if _save_thread == null: _start_save()
|
||||
if not is_saving(): saves_drained.emit()
|
||||
|
||||
|
||||
## Used for application suspension and teardown, when frames may stop arriving.
|
||||
func flush_saves() -> void:
|
||||
while is_saving():
|
||||
if _save_thread == null: _start_save()
|
||||
if _save_thread != null: _finish_save()
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
flush_saves()
|
||||
|
||||
#region Save Load routines
|
||||
|
||||
@@ -20,50 +94,56 @@ func _fail(message: String) -> bool:
|
||||
|
||||
|
||||
func save_data(filename: String, password: String, data: Dictionary) -> bool:
|
||||
# Synchronous compatibility API must never race an asynchronous writer.
|
||||
flush_saves()
|
||||
last_error = ""
|
||||
if blocked_saves.has(filename):
|
||||
return _fail("Save blocked to preserve an unreadable save: " + filename)
|
||||
var payload: Dictionary = data.duplicate(true)
|
||||
payload.format_version = FORMAT_VERSION
|
||||
payload.app_version = str(ProjectSettings.get_setting("application/config/version", ""))
|
||||
var problem: String = validate_data(payload)
|
||||
var error: String = _write_snapshot(_snapshot(filename, password, data))
|
||||
return _fail(error) if error != "" else true
|
||||
|
||||
|
||||
static func _write_snapshot(job: Dictionary) -> String:
|
||||
var payload: Dictionary = job.payload
|
||||
var problem: String = _validate_payload(payload, job.speed_limit, job.stages)
|
||||
if problem != "":
|
||||
return _fail("Invalid save: " + problem)
|
||||
var path: String = "user://" + filename + ".adventure"
|
||||
return "Invalid save: " + problem
|
||||
var path: String = job.path
|
||||
var temporary: String = path + ".tmp"
|
||||
var file := FileAccess.open_encrypted_with_pass(temporary, FileAccess.WRITE, password)
|
||||
var file := FileAccess.open_encrypted_with_pass(temporary, FileAccess.WRITE, job.password)
|
||||
if file == null:
|
||||
return _fail("Cannot write encrypted save: " + temporary)
|
||||
return "Cannot write encrypted save: " + temporary
|
||||
file.store_var(payload, false)
|
||||
file.close()
|
||||
# Check the actual encrypted file, including finalization, before replacing the old save.
|
||||
var verification := FileAccess.open_encrypted_with_pass(temporary, FileAccess.READ, password)
|
||||
var verification := FileAccess.open_encrypted_with_pass(temporary, FileAccess.READ, job.password)
|
||||
if verification == null:
|
||||
return _fail("Cannot verify encrypted save: " + temporary)
|
||||
return "Cannot verify encrypted save: " + temporary
|
||||
var restored: Variant = verification.get_var(false)
|
||||
verification.close()
|
||||
if not restored is Dictionary or restored != payload:
|
||||
return _fail("Encrypted save verification failed: " + temporary)
|
||||
return "Encrypted save verification failed: " + temporary
|
||||
if DirAccess.rename_absolute(temporary, path) != OK:
|
||||
return _fail("Cannot replace encrypted save: " + path)
|
||||
if Global.save_plaintext_copy:
|
||||
return "Cannot replace encrypted save: " + path
|
||||
if job.plaintext:
|
||||
var plain_path: String = path + ".json"
|
||||
file = FileAccess.open(plain_path + ".tmp", FileAccess.WRITE)
|
||||
if file == null:
|
||||
return _fail("Encrypted save written, but cannot write diagnostic copy: " + plain_path)
|
||||
return "Encrypted save written, but cannot write diagnostic copy: " + plain_path
|
||||
file.store_string(JSON.stringify(payload, " ", true, true) + "\n")
|
||||
file.flush()
|
||||
var write_error: Error = file.get_error()
|
||||
file.close()
|
||||
if write_error != OK or DirAccess.rename_absolute(plain_path + ".tmp", plain_path) != OK:
|
||||
return _fail("Encrypted save written, but diagnostic copy failed: " + plain_path)
|
||||
return true
|
||||
return "Encrypted save written, but diagnostic copy failed: " + plain_path
|
||||
return ""
|
||||
|
||||
|
||||
func load_data(filename: String, password: String) -> Dictionary:
|
||||
flush_saves()
|
||||
last_error = ""
|
||||
# Plaintext is diagnostic only: never use it as a fallback.
|
||||
var path: String = "user://" + filename + ".adventure"
|
||||
var path: String = save_directory.path_join(filename + ".adventure")
|
||||
if not FileAccess.file_exists(path):
|
||||
return {}
|
||||
var file := FileAccess.open_encrypted_with_pass(path, FileAccess.READ, password)
|
||||
@@ -73,6 +153,22 @@ func load_data(filename: String, password: String) -> Dictionary:
|
||||
return {}
|
||||
var data: Variant = file.get_var(false)
|
||||
file.close()
|
||||
# Unsupported formats are archived, never converted or overwritten.
|
||||
# Returning no data lets the stage start a new, saveable session.
|
||||
if data is Dictionary and data.get("format_version") != FORMAT_VERSION:
|
||||
var archive: String = path + ".incompatible.bak"
|
||||
var index: int = 0
|
||||
while FileAccess.file_exists(archive):
|
||||
index += 1
|
||||
archive = path + ".incompatible.bak." + str(index)
|
||||
var error: Error = DirAccess.rename_absolute(path, archive)
|
||||
if error != OK:
|
||||
blocked_saves[filename] = true
|
||||
_fail("Cannot archive incompatible save: " + error_string(error))
|
||||
return {}
|
||||
blocked_saves.erase(filename)
|
||||
push_warning("Incompatible save preserved at " + archive + "; starting a new game.")
|
||||
return {}
|
||||
var problem: String = validate_data(data)
|
||||
if problem != "":
|
||||
blocked_saves[filename] = true
|
||||
@@ -82,13 +178,13 @@ func load_data(filename: String, password: String) -> Dictionary:
|
||||
return data
|
||||
|
||||
|
||||
func _number(value: Variant, whole: bool = false, nonnegative: bool = true) -> bool:
|
||||
static func _number(value: Variant, whole: bool = false, nonnegative: bool = true) -> bool:
|
||||
if not (value is int or value is float) or not is_finite(float(value)):
|
||||
return false
|
||||
return (not whole or float(value) == floorf(float(value))) and (not nonnegative or value >= 0)
|
||||
|
||||
|
||||
func _number_map(value: Variant, whole: bool = false, nonnegative: bool = true) -> bool:
|
||||
static func _number_map(value: Variant, whole: bool = false, nonnegative: bool = true) -> bool:
|
||||
if not value is Dictionary:
|
||||
return false
|
||||
for key in value:
|
||||
@@ -97,7 +193,7 @@ func _number_map(value: Variant, whole: bool = false, nonnegative: bool = true)
|
||||
return true
|
||||
|
||||
|
||||
func _plain_tree(value: Variant) -> bool:
|
||||
static func _plain_tree(value: Variant) -> bool:
|
||||
if value is Dictionary:
|
||||
for key in value:
|
||||
if not (key is String or key is StringName) or not _plain_tree(value[key]):
|
||||
@@ -113,6 +209,10 @@ func _plain_tree(value: Variant) -> bool:
|
||||
|
||||
## Return a diagnostic path on failure; an empty string means the structure is valid.
|
||||
func validate_data(data: Variant) -> String:
|
||||
return _validate_payload(data, Global.SPEED_LIMIT, Global.CivilizationRules.keys())
|
||||
|
||||
|
||||
static func _validate_payload(data: Variant, speed_limit: int, stages: Array) -> String:
|
||||
if not data is Dictionary or not _plain_tree(data):
|
||||
return "save must contain plain finite values"
|
||||
if data.get("format_version") != FORMAT_VERSION or not data.get("app_version") is String:
|
||||
@@ -140,7 +240,7 @@ func validate_data(data: Variant) -> String:
|
||||
return "invalid state.time"
|
||||
if not is_finite(float(saved.time.datetime) * 10.0) or saved.time.year != SimulationTime.processed_year(saved.time.datetime):
|
||||
return "invalid processed time"
|
||||
if not _number(saved.speed, true) or saved.speed < 1 or saved.speed > Global.SPEED_LIMIT:
|
||||
if not _number(saved.speed, true) or saved.speed < 1 or saved.speed > speed_limit:
|
||||
return "invalid state.speed"
|
||||
if not _number(saved.speed_in_year) or saved.speed_in_year != pow(10.0, saved.speed - 1):
|
||||
return "invalid state.speed_in_year"
|
||||
@@ -187,7 +287,7 @@ func validate_data(data: Variant) -> String:
|
||||
if not saved.ethics is Dictionary or not saved.civilization_homes is Dictionary:
|
||||
return "invalid civilization state"
|
||||
for id in saved.ethics:
|
||||
if not Global.CivilizationRules.has(id) or not _number_map(saved.ethics[id], true):
|
||||
if id not in stages or not _number_map(saved.ethics[id], true):
|
||||
return "invalid state.ethics." + str(id)
|
||||
for ethic in saved.ethics[id]:
|
||||
if ethic not in ["m", "l", "w", "d", "c", "b"]:
|
||||
|
||||
Reference in New Issue
Block a user