Last active
January 2, 2025 07:24
-
-
Save Thisisnotdalton/bf438eb08d9fc104c6ced6407b47dd73 to your computer and use it in GitHub Desktop.
Godot Steam Cloud saving singleton
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# This code was made possible using the following references: | |
# 1. Softwool's godot-steam-cloud plugin: https://github.com/softwoolco/godot-steam-cloud | |
# 2. GodotSteam's remote storage documentation: https://godotsteam.com/classes/remote_storage | |
# 3. Godot's hashing documentation: https://docs.godotengine.org/en/stable/classes/class_hashingcontext.html | |
# MIT License | |
# | |
# Copyright (c) 2024 Pukiru | |
# | |
# Permission is hereby granted, free of charge, to any person obtaining a copy | |
# of this software and associated documentation files (the "Software"), to deal | |
# in the Software without restriction, including without limitation the rights | |
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
# copies of the Software, and to permit persons to whom the Software is | |
# furnished to do so, subject to the following conditions: | |
# | |
# The above copyright notice and this permission notice shall be included in all | |
# copies or substantial portions of the Software. | |
# | |
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |
# SOFTWARE. | |
# A node with this script attached can ideally be used with the following two methods | |
# 1. upload_files(local_files_to_steam_files: Dictionary, omit_unchanged: bool = true, omit_older: bool = true) -> bool: | |
# Required parameters: | |
# * local_files_to_steam_files - dictionary mapping the local file paths to the remote Steam cloud file paths. | |
# Optional flags: | |
# * omit_unchanged - skip uploading files which have the same file size and last time modified as their corresponding Steam file. | |
# * omit_old - skip uploading files which have a last time modified which is older than the corresponding Steam file's. | |
# 2. download_files(local_files_to_steam_files: Dictionary, omit_unchanged: bool = true, omit_older: bool = true) -> bool: | |
# Required parameters: | |
# * local_files_to_steam_files - dictionary mapping the local file paths to the remote Steam cloud file paths. | |
# Optional flags: | |
# * omit_unchanged - skip downloading files which have the same file size and last time modified as their corresponding Steam file. | |
# * omit_old - skip downloading Steam files which have a last time modified which is older than the corresponding local file's. | |
# Example Usage: | |
# var SAVE_PATH: String = "user://my_data.sav" # local save file path | |
# var STEAM_SAVE_PATH: String = "my_data.sav" # remote, Steam Cloud save path | |
# To save files to the Steam Cloud, after saving your data locally to a file: | |
# var success: bool = SteamCloudSave.upload_files({SAVE_PATH: STEAM_SAVE_PATH}) | |
# print('Upload "%s" to Steam Cloud file "%s" success: %s' %[SAVE_PATH, STEAM_SAVE_PATH, success]) | |
# To download files from the Steam Cloud (if they exist): | |
# var success: bool = SteamCloudSave.download_files({SAVE_PATH: STEAM_SAVE_PATH}) | |
# print('Download "%s" from Steam Cloud file to "%s" success: %s' %[STEAM_SAVE_PATH, SAVE_PATH, success]) | |
# The code below should be ready to use in a Godot 3.5 project, | |
# but changes for adapting this code for Godot 4 are also listed. | |
# class_name SteamCloudSave # omitted for autoloading this node as a singleton in Godot 3.5 | |
extends Node | |
# The following functions exist primarily to centralize breaking changes moving between Godot 3 and 4 | |
func _wait_for_signal(signal_name): | |
# Godot 3 | |
return yield(self, signal_name) | |
# Godot 4 | |
# return await self.get(signal_name) | |
func _wait_for_coroutine(coroutine): | |
# Godot 3 | |
return yield(coroutine, "completed") | |
# Godot 4 | |
# return await coroutine | |
func _read_file(file_path: String): | |
# Godot 3 | |
var file = File.new() | |
file.open(file_path, File.READ) | |
return file | |
# Godot 4 | |
# var file: FileAccess = FileAccess.open(file_path, FileAccess.READ) | |
# return file | |
func _write_file(file_path: String, buffer): | |
# Godot 3 | |
var file = File.new() | |
file.open(file_path, File.WRITE) | |
# Godot 4 | |
# var file: FileAccess = FileAccess.open(file_path, FileAccess.WRITE) | |
# Both | |
file.store_buffer(buffer) | |
file.close() | |
func _get_file_modified_time(file_path: String) -> int: | |
# Godot 3 | |
var file = File.new() | |
var result = file.get_modified_time(file_path) | |
return result | |
# Godot 4 | |
# return FileAccess.get_modified_time(file_path) | |
func _get_file_size(file_path: String) -> int: | |
var file = _read_file(file_path) | |
var file_size: int = 0 | |
# Godot 3 | |
file_size = file.get_len() | |
# Godot 4 | |
# file_size = file.get_length() | |
# Both | |
file.close() | |
return file_size | |
# The rest of the code below should ideally be unchanged from Godot 3.5 -> Godot 4.0+ | |
func _get_local_file_meta(file_path: String) -> Dictionary: | |
var meta: Dictionary = {} | |
meta['modified'] = _get_file_modified_time(file_path) | |
meta['size'] = _get_file_size(file_path) | |
return meta | |
func _on_file_write_async_completed(result: int) -> void: | |
emit_signal("file_uploaded", Steam.RESULT_OK == result) | |
func _on_file_read_async_completed(remote_file: Dictionary) -> void: | |
var buffer = null | |
if remote_file.get('result') == Steam.RESULT_OK: | |
buffer = remote_file.get('buffer') | |
emit_signal("file_downloaded", buffer) | |
signal file_uploaded(success) | |
signal file_downloaded(buffer) | |
func _connect_steam_signals() -> void: | |
Steam.connect("file_write_async_complete", self, "_on_file_write_async_completed") | |
Steam.connect("file_read_async_complete", self, "_on_file_read_async_completed") | |
func _get_steam_file_meta(steam_file: String) -> Dictionary: | |
var meta: Dictionary = {} | |
meta['size'] = Steam.getFileSize(steam_file) | |
if meta['size'] > 0: | |
meta['modified'] = Steam.getFileTimestamp(steam_file) | |
else: | |
meta['modified'] = -1 | |
return meta | |
func get_file_metas(files, steam = false) -> Dictionary: | |
var file_metas: Dictionary = {} | |
for file_path in files: | |
if steam: | |
file_metas[file_path] = _get_steam_file_meta(file_path) | |
else: | |
file_metas[file_path] = _get_local_file_meta(file_path) | |
return file_metas | |
func _upload_file(local_file_path: String, steam_file_path: String) -> bool: | |
var file_size: int = _get_file_size(local_file_path) | |
var local_file = _read_file(local_file_path) | |
local_file.close() | |
Steam.fileWriteAsync(steam_file_path, local_file.get_buffer(file_size)) | |
var uploaded: bool = _wait_for_signal("file_uploaded") | |
return uploaded | |
func upload_files(local_files_to_steam_files: Dictionary, omit_unchanged: bool = true, omit_older: bool = true) -> bool: | |
# if user does not have steam cloud enabled for this game, return false | |
if not Steam.isCloudEnabledForApp(): | |
printerr('Steam cloud is not enabled for this app!') | |
return false | |
# Determine the state of all of the files in this batch | |
var local_file_metas: Dictionary = get_file_metas(local_files_to_steam_files.keys()) | |
var steam_file_metas: Dictionary = get_file_metas(local_files_to_steam_files.values(), true) | |
# Determine total quota file size changes after uploads complete (accounting for overwritting any existing cloud files) | |
var bytes_required: int = 0 | |
for local_file in local_files_to_steam_files.keys(): | |
var steam_file: String = local_files_to_steam_files[local_file] | |
bytes_required += local_file_metas[local_file]['size'] - steam_file_metas[steam_file]['size'] | |
# if insufficient file quota for this batch of files, return false | |
var quota: Dictionary = Steam.getQuota() | |
if bytes_required > quota.get('available_bytes', 0): | |
printerr('Insufficient quota for upload. Need %d bytes. Quota: %s'%[bytes_required, quota]) | |
return false | |
Steam.beginFileWriteBatch() | |
var files_to_check: Array = [] | |
for local_file in local_files_to_steam_files.keys(): | |
var steam_file: String = local_files_to_steam_files[local_file] | |
var current_file_meta: Dictionary = local_file_metas[local_file] | |
var steam_file_meta: Dictionary = steam_file_metas[steam_file] | |
var same_size: bool = current_file_meta['size'] == steam_file_meta['size'] | |
var same_time: bool = current_file_meta['modified'] == steam_file_meta['modified'] | |
if (omit_unchanged and same_size and same_time) or (omit_older and steam_file_meta['modified'] > current_file_meta['modified']): | |
print('Skipping upload of "%s" to "%s".' %[local_file, steam_file]) | |
continue | |
files_to_check.append(local_file) | |
var uploaded: bool = _wait_for_coroutine(_upload_file(local_file, steam_file)) | |
if not uploaded: | |
printerr('Failed to upload "%s" to Steam Cloud as "%s".' %[local_file, steam_file]) | |
break | |
Steam.endFileWriteBatch() | |
# If the new steam cloud files do not have the correct file meta, return false | |
var new_steam_file_metas: Dictionary = get_file_metas(local_files_to_steam_files.values(), true) | |
for local_file in files_to_check: | |
var steam_file: String = local_files_to_steam_files[local_file] | |
var current_file_meta: Dictionary = local_file_metas[local_file] | |
var steam_file_meta: Dictionary = new_steam_file_metas[steam_file] | |
var same_size: bool = current_file_meta['size'] == steam_file_meta['size'] | |
var same_time: bool = current_file_meta['modified'] == steam_file_meta['modified'] | |
if not (same_size and same_time): | |
printerr('File mismatch in Steam Cloud for "%s". Size match: %s. Timestamp match: %s.' %[steam_file, same_size, same_time]) | |
return false | |
return true | |
func _download_file(local_file_path: String, steam_file_path: String) -> bool: | |
var steam_file_size: int = _get_steam_file_meta(local_file_path)['size'] | |
Steam.fileReadAsync(steam_file_path, steam_file_size) | |
var buffer = _wait_for_signal("file_downloaded") | |
if buffer and buffer.size() > 0: | |
_write_file(local_file_path, buffer) | |
var local_file_size: int = _get_file_size(local_file_path) | |
return local_file_size == steam_file_size | |
return false | |
func download_files(local_files_to_steam_files: Dictionary, omit_unchanged: bool = true, omit_older: bool = true) -> bool: | |
# Determine the state of all of the files in this batch | |
var local_file_metas: Dictionary = get_file_metas(local_files_to_steam_files.keys()) | |
var steam_file_metas: Dictionary = get_file_metas(local_files_to_steam_files.values(), true) | |
var files_to_check: Array = [] | |
for local_file in local_files_to_steam_files.keys(): | |
var steam_file: String = local_files_to_steam_files[local_file] | |
var current_file_meta: Dictionary = local_file_metas[local_file] | |
var steam_file_meta: Dictionary = steam_file_metas[steam_file] | |
var same_size: bool = current_file_meta['size'] == steam_file_meta['size'] | |
var same_time: bool = current_file_meta['modified'] == steam_file_meta['modified'] | |
if not Steam.fileExists(steam_file) or \ | |
(omit_unchanged and same_size and same_time) or \ | |
(omit_older and steam_file_meta['modified'] < current_file_meta['modified']): | |
print('Skipping download of "%s" to "%s".' %[steam_file, local_file]) | |
continue | |
files_to_check.append(local_file) | |
var downloaded: bool = _wait_for_coroutine(_download_file(local_file, steam_file)) | |
if not downloaded: | |
printerr('Failed to download Steam Cloud "%s" to local file "%s".' %[steam_file, local_file]) | |
break | |
# If the new steam cloud files do not have the correct file meta, return false | |
var new_local_file_metas: Dictionary = get_file_metas(local_files_to_steam_files.keys()) | |
for local_file in files_to_check: | |
var steam_file: String = local_files_to_steam_files[local_file] | |
var current_file_meta: Dictionary = new_local_file_metas[local_file] | |
var steam_file_meta: Dictionary = steam_file_metas[steam_file] | |
var same_size: bool = current_file_meta['size'] == steam_file_meta['size'] | |
if not same_size: | |
printerr('File mismatch in Steam Cloud for "%s". Size match: %s.' %[steam_file, same_size]) | |
return false | |
return true | |
func _ready(): | |
_connect_steam_signals() | |
# Necessary for Steam API responses | |
func _process(_delta: float) -> void: | |
Steam.run_callbacks() | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment