class_name SaveStore extends RefCounted ## The game's files on the player's machine: the player key, the save, the ## session recordings for bug reports and the player's settings. const KEY_FILE := "player.key" const SAVE_FILE := "world.grw" ## Holds the previous save while write_save replaces it. const BACKUP_FILE := SAVE_FILE + ".bak" const RECORDINGS_DIR := "recordings" const SETTINGS_FILE := "settings.cfg" ## Recordings beyond this number are deleted, oldest first. const KEEP_RECORDINGS := 10 const KEY_BYTES := 16 var base_dir: String ## The result of the last attempt to read the key, the save or the settings: ## OK, or why the file could not be read. A file that exists but cannot be ## read right now, for example because another program holds it, is never ## replaced. var last_read_error: Error = OK ## Opens the folder and creates it if it is missing. If a crash interrupted a ## save and left only the backup of the previous one (see write_save), the ## backup becomes the save again. func _init(directory: String = "user://") -> void: base_dir = directory DirAccess.make_dir_recursive_absolute(base_dir) _restore_backup() ## The key that identifies this installation in every world. It is created ## on first use and kept from then on. Returns an empty key if the key file ## exists but cannot be read; last_read_error then says why. func player_key() -> PackedByteArray: var path := base_dir.path_join(KEY_FILE) if FileAccess.file_exists(path): var stored := _read_file(path) if last_read_error != OK: return PackedByteArray() if stored.size() == KEY_BYTES: return stored var key := Crypto.new().generate_random_bytes(KEY_BYTES) if not _write(path, key): push_error("player key could not be stored at %s" % path) return key func has_save() -> bool: return FileAccess.file_exists(base_dir.path_join(SAVE_FILE)) ## The bytes of the save. If it cannot be read, they are empty and ## last_read_error says why. func read_save() -> PackedByteArray: return _read_file(base_dir.path_join(SAVE_FILE)) ## Writes the save through a temporary file, so that a crash or a failed ## write never destroys the previous save. Returns false if the new save was ## not stored completely; the previous save then stays as it was. ## ## The temporary file replaces the previous save in two moves: the previous ## save becomes the backup, then the temporary file becomes the save. A single ## move onto the previous save would not be safe, because on Windows Godot ## deletes the target first and only then moves the file. If that move failed, ## for example because a virus scanner holds the temporary file, the previous ## save would already be gone. If the second move fails, the backup is moved ## back. A crash between the two moves leaves only the backup; the next ## SaveStore on the folder restores it. func write_save(bytes: PackedByteArray) -> bool: if bytes.is_empty(): return false var path := base_dir.path_join(SAVE_FILE) var temporary := path + ".tmp" var backup := base_dir.path_join(BACKUP_FILE) if not _write(temporary, bytes) or FileAccess.get_file_as_bytes(temporary) != bytes: DirAccess.remove_absolute(temporary) return false var has_previous := FileAccess.file_exists(path) if has_previous and DirAccess.rename_absolute(path, backup) != OK: DirAccess.remove_absolute(temporary) return false if DirAccess.rename_absolute(temporary, path) != OK: if has_previous: DirAccess.rename_absolute(backup, path) return false if has_previous: DirAccess.remove_absolute(backup) return true ## Moves an unusable save out of the way, so that the next save does not ## overwrite it. An older set-aside save is replaced. Returns the new path, ## or an empty text if there was no save or it could not be moved. func set_aside_save() -> String: var path := base_dir.path_join(SAVE_FILE) var aside := path + ".broken" if DirAccess.rename_absolute(path, aside) != OK: return "" return aside ## Stores a session recording and deletes the oldest ones beyond ## KEEP_RECORDINGS. Returns the path, or an empty text if nothing was written. func write_recording(bytes: PackedByteArray) -> String: if bytes.is_empty(): return "" var directory := base_dir.path_join(RECORDINGS_DIR) DirAccess.make_dir_recursive_absolute(directory) # Microseconds since 1970, padded so that names sort by age. var stamp := int(Time.get_unix_time_from_system() * 1_000_000.0) var path := directory.path_join("session-%016d.grwr" % stamp) if not _write(path, bytes): return "" var names := recordings() while names.size() > KEEP_RECORDINGS: DirAccess.remove_absolute(directory.path_join(names[0])) names.remove_at(0) return path ## File names of the stored recordings, oldest first. func recordings() -> PackedStringArray: var names := DirAccess.get_files_at(base_dir.path_join(RECORDINGS_DIR)) names.sort() return names ## The name the player gave last time, or an empty text. func player_name() -> String: return _setting("player", "name") func set_player_name(new_name: String) -> void: _set_setting("player", "name", new_name) ## The address of the host the player joined last time, or an empty text. func last_address() -> String: return _setting("network", "address") func set_last_address(address: String) -> void: _set_setting("network", "address", address) ## The key of the style the player chose last time, or an empty text. func style() -> String: return _setting("display", "style") func set_style(key: String) -> void: _set_setting("display", "style", key) func _setting(section: String, key: String) -> String: var settings := _read_settings(base_dir.path_join(SETTINGS_FILE)) if settings == null: return "" return str(settings.get_value(section, key, "")) ## Changes one setting and keeps the others. Settings that cannot be read ## right now stay as they are, because writing them would lose the others. func _set_setting(section: String, key: String, value: String) -> void: var path := base_dir.path_join(SETTINGS_FILE) var settings := _read_settings(path) if settings == null: push_warning("settings could not be read at %s and stay as they are: %s" % [path, error_string(last_read_error)]) return settings.set_value(section, key, value) if settings.save(path) != OK: push_warning("settings could not be stored at %s" % path) ## The settings on disk, or null if the file exists but cannot be read right ## now; last_read_error then says why. A missing file gives empty settings. ## Of a damaged file, Godot reports the error and keeps what it read before ## it, and the next change of a setting writes the file anew. func _read_settings(path: String) -> ConfigFile: var settings := ConfigFile.new() if not FileAccess.file_exists(path): return settings var text := _read_file(path).get_string_from_utf8() if last_read_error != OK: return null settings.parse(text) return settings ## Moves the backup back to the save if there is no save, see write_save. func _restore_backup() -> void: var path := base_dir.path_join(SAVE_FILE) var backup := base_dir.path_join(BACKUP_FILE) if FileAccess.file_exists(path) or not FileAccess.file_exists(backup): return if DirAccess.rename_absolute(backup, path) != OK: push_warning("the previous save could not be restored from %s" % backup) ## Reads a whole file and records the result in last_read_error. func _read_file(path: String) -> PackedByteArray: var bytes := FileAccess.get_file_as_bytes(path) last_read_error = FileAccess.get_open_error() return bytes func _write(path: String, bytes: PackedByteArray) -> bool: var file := FileAccess.open(path, FileAccess.WRITE) if file == null: return false var stored := file.store_buffer(bytes) file.close() return stored