Skip to content

Instantly share code, notes, and snippets.

@Thisisnotdalton
Last active January 2, 2025 07:24
Show Gist options
  • Save Thisisnotdalton/bf438eb08d9fc104c6ced6407b47dd73 to your computer and use it in GitHub Desktop.
Save Thisisnotdalton/bf438eb08d9fc104c6ced6407b47dd73 to your computer and use it in GitHub Desktop.
Godot Steam Cloud saving singleton
# 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