# SaveManager.gd (добавить в AutoLoad) extends Node const E = preload("res://world/owniverse/math/exact.gd") # ~/Library/Application Support/Godot/app_userdata/Owniverse Adventure/savegame.cfg const SETTING_SECTION = "Settings" const SAVE_PATH = "user://settings.cfg" const FORMAT_VERSION : int = 6 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: Variant) -> void: if (E.ne(_save_thread, null) and (not _save_thread.is_alive())): _finish_save() func is_saving() -> bool: return (E.ne(_save_thread, null) or (not _pending_saves.is_empty())) func _snapshot(filename: String, password: String, data: Dictionary) -> Dictionary: var payload: Dictionary = E.encode(data) payload.format_version = FORMAT_VERSION payload.app_version = str(ProjectSettings.get_setting("application/config/version", "")) return {"filename": filename, "path": save_directory.path_join(E.add(filename, ".adventure")), "password": password, "payload": payload, "plaintext": Global.save_plaintext_copy, "speed_limit": Global.SPEED_LIMIT, "stages": Global.CivilizationRules.keys(), "entities": Global.OwniverseEntities.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(E.add("Save blocked to preserve an unreadable save: ", filename)) _pending_saves[filename] = _snapshot(filename, password, data) if E.eq(_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 E.ne(error, OK): _save_thread = null _fail(E.add("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 E.ne(error, ""): push_error(error) save_completed.emit(filename, E.eq(error, ""), error) if E.eq(_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 E.eq(_save_thread, null): _start_save() if E.ne(_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(E.add("Save blocked to preserve an unreadable save: ", filename)) var error: String = _write_snapshot(_snapshot(filename, password, data)) return (_fail(error) if E.ne(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, job.entities) if E.ne(problem, ""): return E.add("Invalid save: ", problem) var path: String = job.path var temporary: String = E.add(path, ".tmp") var file = FileAccess.open_encrypted_with_pass(temporary, FileAccess.WRITE, job.password) if E.eq(file, null): return E.add("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 E.eq(verification, null): return E.add("Cannot verify encrypted save: ", temporary) var restored: Variant = verification.get_var(false) verification.close() if ((not (restored is Dictionary)) or E.ne(restored, payload)): return E.add("Encrypted save verification failed: ", temporary) if E.ne(DirAccess.rename_absolute(temporary, path), OK): return E.add("Cannot replace encrypted save: ", path) if job.plaintext: var plain_path: String = E.add(path, ".json") file = FileAccess.open(E.add(plain_path, ".tmp"), FileAccess.WRITE) if E.eq(file, null): return E.add("Encrypted save written, but cannot write diagnostic copy: ", plain_path) file.store_string(E.add(JSON.stringify(payload, " ", true, true), "\n")) file.flush() var write_error: Error = file.get_error() file.close() if (E.ne(write_error, OK) or E.ne(DirAccess.rename_absolute(E.add(plain_path, ".tmp"), plain_path), OK)): return E.add("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(E.add(filename, ".adventure")) if (not FileAccess.file_exists(path)): return {} var file = FileAccess.open_encrypted_with_pass(path, FileAccess.READ, password) if E.eq(file, null): blocked_saves[filename] = true _fail(E.add("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 E.ne(data.get("format_version"), FORMAT_VERSION)): var archive: String = E.add(path, ".incompatible.bak") var index: int = 0 while FileAccess.file_exists(archive): index = E.add(index, 1) archive = E.add(E.add(path, ".incompatible.bak."), str(index)) var error: Error = DirAccess.rename_absolute(path, archive) if E.ne(error, OK): blocked_saves[filename] = true _fail(E.add("Cannot archive incompatible save: ", error_string(error))) return {} blocked_saves.erase(filename) push_warning(E.add(E.add("Incompatible save preserved at ", archive), "; starting a new game.")) return {} var problem: String = validate_data(data) if E.ne(problem, ""): blocked_saves[filename] = true _fail(E.add(E.add(E.add("Invalid save ", path), ": "), problem)) return {} blocked_saves.erase(filename) return E.decode(data) ## Preserve a structurally readable but game-incompatible save, then allow the ## already initialized fresh session to use the original filename. func archive_incompatible_save(filename: String) -> bool: flush_saves() var path: String = save_directory.path_join(E.add(filename, ".adventure")) if not FileAccess.file_exists(path): blocked_saves.erase(filename) last_error = "" return true var archive: String = E.add(path, ".incompatible.bak") var index: int = 0 while FileAccess.file_exists(archive): index = E.add(index, 1) archive = E.add(E.add(path, ".incompatible.bak."), str(index)) var error: Error = DirAccess.rename_absolute(path, archive) if E.ne(error, OK): blocked_saves[filename] = true return _fail(E.add("Cannot archive incompatible save: ", error_string(error))) blocked_saves.erase(filename) last_error = "" push_warning(E.add(E.add("Incompatible save preserved at ", archive), "; starting a new game.")) return true static func _number(value: Variant, whole: bool = false, nonnegative: bool = true) -> bool: return E.numeric(value) and E.finite(value) and (not value is float or absf(value) < 1e28) and (not whole or E.eq(value, E.floor_value(value))) and (not nonnegative or E.ge(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 ((((E.eq(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(), Global.OwniverseEntities.keys()) static func _validate_payload(data: Variant, speed_limit: int, stages: Array, entities: Array) -> String: if not E.valid_encoded(data): return "invalid exact number encoding" data = E.decode(data) if ((not (data is Dictionary)) or (not _plain_tree(data))): return "save must contain plain finite values" if (E.ne(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 E.numeric(data.ui.get("current_page")))) or E.lt(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 (not E.contains(required, field) and not E.contains(["world_parameters", "trends"], field)): return "unsupported state structure" if saved.has("world_parameters"): if ((not _number_map(saved.world_parameters)) or E.ne(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 E.lt(saved.world_parameters[key], 1)): return E.add("invalid world parameter ", key) if (E.contains(["Bur", "Exp", "Agg"], key) and E.gt(saved.world_parameters[key], 19)): return E.add("invalid world scale ", key) if saved.has("trends"): if ((not _number_map(saved.trends)) or E.ne(saved.trends.size(), WorldParameters.TRENDS.size())): return "invalid state.trends" for key in WorldParameters.TRENDS: if ((not saved.trends.has(key)) or E.ge(saved.trends[key], 1)): return E.add("invalid trend ", key) for field in required: if (not saved.has(field)): return E.add("missing state.", field) if E.ne(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 E.ne(saved.time.year, SimulationTime.processed_year(saved.time.datetime)): return "invalid processed time" if saved.time.has("tick_remainder"): if not E.finite(saved.time.tick_remainder) or E.ge(E.absolute(saved.time.tick_remainder), 1): return "invalid fractional tick" if (((not _number(saved.speed, true)) or E.lt(saved.speed, 1)) or E.gt(saved.speed, speed_limit)): return "invalid state.speed" if ((not _number(saved.speed_in_year)) or E.ne(saved.speed_in_year, E.power(10, E.subtract(saved.speed, 1)))): return "invalid state.speed_in_year" if data.has("simulation"): var checkpoint: Variant = data.simulation if not checkpoint is Dictionary or not checkpoint.get("productions") is Array: return "invalid simulation checkpoint" if not _number(checkpoint.get("speed"), true) or E.lt(checkpoint.speed, 1) or E.gt(checkpoint.speed, speed_limit): return "invalid simulation speed" for name in ["requested_clock", "active_clock"]: var clock: Variant = checkpoint.get(name) if not clock is Dictionary: return "invalid simulation clock" if clock.is_empty(): continue if not _number(clock.get("datetime")) or not _number(clock.get("year")): return "invalid simulation clock" if E.lt(clock.datetime, saved.time.datetime) or E.ne(clock.year, SimulationTime.processed_year(clock.datetime)): return "invalid simulation clock" if not E.finite(clock.get("tick_remainder", 0)) or E.ge(E.absolute(clock.get("tick_remainder", 0)), 1): return "invalid simulation tick" if not checkpoint.active_clock.is_empty(): if checkpoint.requested_clock.is_empty() or E.gt(checkpoint.active_clock.datetime, checkpoint.requested_clock.datetime): return "invalid simulation clock order" for command in checkpoint.productions: if not command is Dictionary or not entities.has(command.get("id")) or not command.get("resource") is bool or not _number(command.get("amount")): return "invalid queued production" if command.resource and command.id not in ["S", "H"]: return "invalid queued resource production" 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 E.ne(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 E.add("invalid state.binds.", owner_id) for id in saved.binds[owner_id]: if (not saved.amount.has(id)): return E.add("unknown bound resource ", id) for id in saved.batches: if ((not saved.amount.has(id)) or (not (saved.batches[id] is Array))): return E.add("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 E.add("invalid batch in ", id) if E.lt(batch.end_year, batch.start_year): return E.add("reversed batch interval in ", id) if E.ne(batch.has("created_from"), batch.has("created_to")): return E.add("incomplete batch creation interval in ", id) if (batch.has("created_from") and (((((not _number(batch.created_from)) or (not _number(batch.created_to))) or E.le(batch.created_to, batch.created_from)) or E.lt(batch.start_year, batch.created_from)))): return E.add("invalid batch creation interval in ", id) if (batch.has("processed_until") and ((((not _number(batch.processed_until)) or E.lt(batch.processed_until, batch.start_year)) or E.gt(batch.processed_until, batch.end_year)))): return E.add("invalid batch.processed_until in ", id) if (batch.has("withdraw_remainder") and (((not _number(batch.withdraw_remainder)) or E.gt(batch.withdraw_remainder, batch.amount)))): return E.add("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 E.add("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 E.ne(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 E.add("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 (not E.contains(stages, id) or (not (saved.ethics[id] is Dictionary))): return E.add("invalid state.ethics.", str(id)) if E.ne(saved.ethics[id].keys().size(), 6): return E.add("invalid ethic profile size for ", str(id)) var share_sum: float = 0.0 for ethic in saved.ethics[id]: var share: Variant = saved.ethics[id][ethic] var share_value: float = 0.0 + share if share is int or share is float else -1.0 if not E.contains(["m", "l", "w", "d", "c", "b"], ethic) or not is_finite(share_value) or share_value < 0.0 or share_value > 1.0: return E.add("invalid ethic ", str(ethic)) share_sum += share_value if absf(share_sum - 1.0) > 0.000001: return E.add("invalid ethic profile total for ", str(id)) 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 E.ne(err, OK): Global.set_locale(str(Global.Settings.language)) return false for item in Global.Settings: Global.Settings[item] = config.get_value(SETTING_SECTION, item, Global.Settings[item]) Global.set_locale(str(Global.Settings.language)) return true #endregion