# SaveManager.gd (добавить в AutoLoad) extends Node # ~/Library/Application Support/Godot/app_userdata/Owniverse Adventure/savegame.cfg const SETTING_SECTION := "Settings" const SAVE_PATH = "user://settings.cfg" const FORMAT_VERSION: int = 2 const SimulationTime = preload("res://world/owniverse/simulation_time.gd") const WorldParameters = preload("res://world/owniverse/world_parameters.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 func _fail(message: String) -> bool: last_error = message push_error(message) return false 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 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 "Invalid save: " + problem var path: String = job.path var temporary: String = path + ".tmp" var file := FileAccess.open_encrypted_with_pass(temporary, FileAccess.WRITE, job.password) if file == null: 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, job.password) if verification == null: return "Cannot verify encrypted save: " + temporary var restored: Variant = verification.get_var(false) verification.close() if not restored is Dictionary or restored != payload: return "Encrypted save verification failed: " + temporary if DirAccess.rename_absolute(temporary, path) != OK: 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 "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 "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 = save_directory.path_join(filename + ".adventure") if not FileAccess.file_exists(path): return {} var file := FileAccess.open_encrypted_with_pass(path, FileAccess.READ, password) if file == null: blocked_saves[filename] = true _fail("Cannot decrypt save: " + path) 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 _fail("Invalid save " + path + ": " + problem) return {} blocked_saves.erase(filename) return data 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) static func _number_map(value: Variant, whole: bool = false, nonnegative: bool = true) -> bool: if not value is Dictionary: return false for key in value: if not (key is String or key is StringName) or not _number(value[key], whole, nonnegative): return false return true 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]): return false return true if value is Array: for item in value: if not _plain_tree(item): return false return true return value == null or value is String or value is StringName or value is bool or _number(value, false, false) ## 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: return "unsupported save format" if not data.get("ui") is Dictionary or not data.ui.get("current_page") is int or data.ui.current_page < 0: return "invalid ui.current_page" if not data.get("state") is Dictionary: return "state must be a Dictionary" var saved: Dictionary = data.state var required := [ "version", "time", "speed", "speed_in_year", "energy", "batches", "batch_origins", "binds", "amount", "distribution", "auto_resources", "auto_star_deficits", "production_remainders", "resource_remainders", "evolution_remainders", "life_remainders", "planet_remainders", "blast_remainders", "destruction_remainders", "civilization_remainders", "civilization_stats", "civilization_homes", "ethics", "stats", ] # Optional additions to the current format. Saves predating indicators keep # their quantities and receive neutral defaults from Owniverse on load. for field in saved: if field not in required and field not in ["world_parameters", "trends"]: return "unsupported state structure" if saved.has("world_parameters"): if not _number_map(saved.world_parameters) or saved.world_parameters.size() != WorldParameters.defaults().size(): return "invalid state.world_parameters" for key in WorldParameters.defaults(): if not saved.world_parameters.has(key) or saved.world_parameters[key] < 1.0: return "invalid world parameter " + key if key in ["Bur", "Exp", "Agg"] and saved.world_parameters[key] > 19.0: return "invalid world scale " + key if saved.has("trends"): if not _number_map(saved.trends) or saved.trends.size() != WorldParameters.TRENDS.size(): return "invalid state.trends" for key in WorldParameters.TRENDS: if not saved.trends.has(key) or saved.trends[key] >= 1.0: return "invalid trend " + key for field in required: if not saved.has(field): return "missing state." + field if saved.version != STATE_VERSION: return "unsupported state.version" if not saved.time is Dictionary or not _number(saved.time.get("datetime")) or not _number(saved.time.get("year")): 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 > 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" if not _number(saved.energy, true) or not _number_map(saved.amount, true): return "invalid state resources" if not saved.stats is Dictionary or not _number_map(saved.stats.get("total_amount"), true): return "invalid state.stats.total_amount" if saved.amount.keys() != saved.stats.total_amount.keys(): return "amount and total_amount keys differ" if not saved.binds is Dictionary or not saved.batches is Dictionary: return "state.binds and state.batches must be Dictionaries" for owner_id in saved.binds: if not saved.amount.has(owner_id) or not _number_map(saved.binds[owner_id], true): return "invalid state.binds." + owner_id for id in saved.binds[owner_id]: if not saved.amount.has(id): return "unknown bound resource " + id for id in saved.batches: if not saved.amount.has(id) or not saved.batches[id] is Array: return "invalid state.batches." + id for batch in saved.batches[id]: if not batch is Dictionary or not _number(batch.get("amount"), true) or not _number(batch.get("start_year")) or not _number(batch.get("end_year")): return "invalid batch in " + id if batch.end_year < batch.start_year: return "reversed batch interval in " + id if batch.has("created_from") != batch.has("created_to"): return "incomplete batch creation interval in " + id if batch.has("created_from") and (not _number(batch.created_from) or not _number(batch.created_to) or batch.created_to <= batch.created_from or batch.start_year < batch.created_from): return "invalid batch creation interval in " + id if batch.has("processed_until") and (not _number(batch.processed_until) or batch.processed_until < batch.start_year or batch.processed_until > batch.end_year): return "invalid batch.processed_until in " + id if batch.has("withdraw_remainder") and (not _number(batch.withdraw_remainder) or batch.withdraw_remainder > batch.amount): return "invalid batch.withdraw_remainder in " + id for field in ["batch_origins", "distribution", "production_remainders", "evolution_remainders", "planet_remainders", "blast_remainders", "destruction_remainders", "life_remainders", "civilization_remainders", "civilization_stats"]: if not _number_map(saved[field], false, false): return "invalid state." + field if not _number_map(saved.auto_resources, true) or not _number_map(saved.auto_star_deficits, false, false): return "invalid automatic production state" if not saved.resource_remainders is Dictionary or saved.resource_remainders.keys().size() != 2 or not saved.resource_remainders.has("basic") or not saved.resource_remainders.has("automatic"): return "invalid state.resource_remainders" for channel in ["basic", "automatic"]: if not _number_map(saved.resource_remainders[channel], false, false): return "invalid state.resource_remainders." + channel if not saved.ethics is Dictionary or not saved.civilization_homes is Dictionary: return "invalid civilization state" for id in saved.ethics: 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"]: return "invalid ethic " + str(ethic) for id in saved.civilization_homes: if not _number_map(saved.civilization_homes[id], true): return "invalid civilization homes" return "" #endregion #region Settings func save_settings(): var config = ConfigFile.new() for item in Global.Settings: config.set_value(SETTING_SECTION, item, Global.Settings[item]) config.save(SAVE_PATH) func load_settings(): var config = ConfigFile.new() var err = config.load(SAVE_PATH) if err != OK: return false for item in Global.Settings: Global.Settings[item] = config.get_value(SETTING_SECTION, item, Global.Settings[item]) return true #endregion