# 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 = 1 const STATE_VERSION: String = "0.1.0" var last_error: String = "" var blocked_saves: Dictionary = {} #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: 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) if problem != "": return _fail("Invalid save: " + problem) var path: String = "user://" + filename + ".adventure" var temporary: String = path + ".tmp" var file := FileAccess.open_encrypted_with_pass(temporary, FileAccess.WRITE, password) if file == null: return _fail("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) if verification == null: return _fail("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) if DirAccess.rename_absolute(temporary, path) != OK: return _fail("Cannot replace encrypted save: " + path) if Global.save_plaintext_copy: 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) 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 func load_data(filename: String, password: String) -> Dictionary: last_error = "" # Plaintext is diagnostic only: never use it as a fallback. var path: String = "user://" + 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() var problem: String = validate_data(data) if problem != "": blocked_saves[filename] = true _fail("Invalid save " + path + ": " + problem) return {} blocked_saves.erase(filename) return data 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: 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 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: if not data is Dictionary or not data.get("state") is Dictionary: return "state must be a Dictionary" if not _plain_tree(data): return "unsupported value or non-finite number" # Legacy files contained only {state}; accept their known state schema. if data.has("format_version") and (not data.format_version is int or data.format_version != FORMAT_VERSION): return "unsupported format_version" if data.has("ui"): if not data.ui is Dictionary or not data.ui.get("current_page") is int or data.ui.current_page < 0: return "invalid ui.current_page" var saved: Dictionary = data.state if saved.get("version") != STATE_VERSION: return "unsupported state.version" if not saved.get("time") is Dictionary: return "state.time must be a Dictionary" if not _number(saved.time.get("datetime")) or not _number(saved.time.get("year"), true): return "invalid state.time" if saved.time.year != floorf(saved.time.datetime): return "state.time.year must equal floor(datetime)" if not _number(saved.get("speed"), true) or saved.speed < 1 or saved.speed > Global.SPEED_LIMIT: return "invalid state.speed" if not _number(saved.get("speed_in_year")) or saved.speed_in_year != pow(10.0, saved.speed - 1): return "invalid state.speed_in_year" if not _number(saved.get("energy"), true): return "invalid state.energy" if not _number_map(saved.get("amount"), true): return "invalid state.amount" if not saved.get("stats") is Dictionary or not _number_map(saved.stats.get("total_amount"), true): return "invalid state.stats.total_amount" for id in saved.amount: if not saved.stats.total_amount.has(id): return "missing total_amount for " + id if not saved.get("binds") is Dictionary or not saved.get("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: return "batch must be a Dictionary" if 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") or batch.has("created_to"): if not _number(batch.get("created_from")) or not _number(batch.get("created_to")): return "invalid batch creation interval in " + id if batch.created_to <= batch.created_from or batch.start_year < batch.created_from: return "reversed batch creation interval in " + id if batch.has("processed_until"): if 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", "auto_resources", "production_remainders", "auto_star_deficits", "evolution_remainders"]: if saved.has(field) and not _number_map(saved[field], field == "auto_resources", field != "auto_star_deficits"): return "invalid state." + field if saved.has("resource_remainders"): if not saved.resource_remainders is Dictionary: return "invalid state.resource_remainders" for channel in ["basic", "automatic"]: # Signed allocation errors are valid, even though resource balances are whole. if not _number_map(saved.resource_remainders.get(channel), false, false): return "invalid state.resource_remainders." + channel 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