- new functionality to create expected cloud save dirs at startup

- new helper function to search for a substring with case insensitive support
This commit is contained in:
a
2025-10-14 22:48:42 +03:00
parent 673c3a3cee
commit dc9e5dc42b
11 changed files with 566 additions and 10 deletions

View File

@@ -165,6 +165,66 @@ bool check_timedout(std::chrono::high_resolution_clock::time_point old, double t
return false;
}
std::string get_full_exe_path()
{
// https://github.com/gpakosz/whereami/blob/master/src/whereami.c
// https://stackoverflow.com/q/1023306
static std::string exe_path{};
static std::recursive_mutex mtx{};
if (!exe_path.empty()) {
return exe_path;
}
std::lock_guard lock(mtx);
// check again in case we didn't win this thread arbitration
if (!exe_path.empty()) {
return exe_path;
}
#if defined(__WINDOWS__)
static wchar_t path[8192]{};
auto ret = ::GetModuleFileNameW(nullptr, path, _countof(path));
if (ret >= _countof(path) || 0 == ret) {
path[0] = '.';
path[1] = 0;
}
exe_path = canonical_path(utf8_encode(path));
#else
// https://man7.org/linux/man-pages/man5/proc.5.html
// https://linux.die.net/man/5/proc
// https://man7.org/linux/man-pages/man2/readlink.2.html
// https://linux.die.net/man/3/readlink
static char path[8192]{};
auto read = ::readlink("/proc/self/exe", path, sizeof(path) - 1);
if (-1 == read) {
path[0] = '.';
read = 1;
}
path[read] = 0;
exe_path = canonical_path(path);
#endif // __WINDOWS__
return exe_path;
}
std::string get_exe_dirname()
{
std::string env_exe_dir = get_env_variable("GseExeDir");
if (!env_exe_dir.empty()) {
if (env_exe_dir.back() != PATH_SEPARATOR[0]) {
env_exe_dir = env_exe_dir.append(PATH_SEPARATOR);
}
return env_exe_dir;
}
std::string full_exe_path = get_full_exe_path();
return full_exe_path.substr(0, full_exe_path.rfind(PATH_SEPARATOR)).append(PATH_SEPARATOR);
}
#ifdef __LINUX__
std::string get_lib_path()
{
@@ -692,4 +752,3 @@ void set_whitelist_ips(uint32_t *from, uint32_t *to, unsigned num_ips)
}
#endif // EMU_EXPERIMENTAL_BUILD

View File

@@ -46,6 +46,8 @@ CSteamID generate_steam_id_user();
CSteamID generate_steam_id_server();
CSteamID generate_steam_id_anonserver();
CSteamID generate_steam_id_lobby();
std::string get_full_exe_path();
std::string get_exe_dirname();
std::string get_full_lib_path();
std::string get_full_program_path();
std::string get_current_path();

View File

@@ -136,6 +136,7 @@ static inline void reset_LastError()
#include <netdb.h>
#include <dlfcn.h>
#include <utime.h>
#include <pwd.h>
#include "crash_printer/linux.hpp"

View File

@@ -53,6 +53,7 @@ public:
static constexpr char screenshots_folder[] = "screenshots";
static constexpr char game_settings_folder[] = "steam_settings";
static std::string get_exe_dir();
static std::string get_program_path();
static std::string get_game_settings_path();
static std::string get_user_appdata_path();

View File

@@ -0,0 +1,30 @@
/* Copyright (C) 2019 Mr Goldberg
This file is part of the Goldberg Emulator
The Goldberg Emulator is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 3 of the License, or (at your option) any later version.
The Goldberg Emulator is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with the Goldberg Emulator; if not, see
<http://www.gnu.org/licenses/>. */
#ifndef SETTINGS_PARSER_UFS_INCLUDE_H
#define SETTINGS_PARSER_UFS_INCLUDE_H
#define SI_CONVERT_GENERIC
#define SI_SUPPORT_IOSTREAMS
#define SI_NO_MBCS
#include "simpleini/SimpleIni.h"
#include "settings.h"
void parse_cloud_save(CSimpleIniA *ini, class Settings *settings_client, class Settings *settings_server, class Local_Storage *local_storage);
#endif // SETTINGS_PARSER_UFS_INCLUDE_H

View File

@@ -50,6 +50,11 @@ std::string Local_Storage::saves_folder_name = "GSE Saves";
static const std::string empty_str{};
std::string Local_Storage::get_exe_dir()
{
return " ";
}
std::string Local_Storage::get_program_path()
{
return " ";
@@ -455,6 +460,11 @@ static std::vector<struct File_Data> get_filenames_recursive(std::string base_pa
#endif
std::string Local_Storage::get_exe_dir()
{
return get_exe_dirname();
}
std::string Local_Storage::get_program_path()
{
return get_full_program_path();
@@ -565,7 +575,7 @@ void Local_Storage::setAppId(uint32 appid)
int Local_Storage::store_file_data(std::string folder, std::string file, const char *data, unsigned int length)
{
if (folder.back() != *PATH_SEPARATOR) {
if (!folder.empty() && folder.back() != *PATH_SEPARATOR) {
folder.append(PATH_SEPARATOR);
}

View File

@@ -15,14 +15,15 @@
License along with the Goldberg Emulator; if not, see
<http://www.gnu.org/licenses/>. */
#include "dll/settings_parser.h"
#include "dll/base64.h"
#define SI_CONVERT_GENERIC
#define SI_SUPPORT_IOSTREAMS
#define SI_NO_MBCS
#include "simpleini/SimpleIni.h"
#include "dll/settings_parser.h"
#include "dll/settings_parser_ufs.h"
#include "dll/base64.h"
constexpr const static char config_ini_app[] = "configs.app.ini";
constexpr const static char config_ini_main[] = "configs.main.ini";
@@ -1872,6 +1873,7 @@ uint32 create_localstorage_settings(Settings **settings_client_out, Settings **s
parse_overlay_general_config(settings_client, settings_server);
load_overlay_appearance(settings_client, settings_server, local_storage);
parse_steam_game_stats_reports_dir(settings_client, settings_server);
parse_cloud_save(&ini, settings_client, settings_server, local_storage);
*settings_client_out = settings_client;
*settings_server_out = settings_server;

318
dll/settings_parser_ufs.cpp Normal file
View File

@@ -0,0 +1,318 @@
/* Copyright (C) 2019 Mr Goldberg
This file is part of the Goldberg Emulator
The Goldberg Emulator is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 3 of the License, or (at your option) any later version.
The Goldberg Emulator is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with the Goldberg Emulator; if not, see
<http://www.gnu.org/licenses/>. */
#include "dll/settings_parser_ufs.h"
#include <unordered_map>
#include <functional>
//https://developer.valvesoftware.com/wiki/SteamID
// given the Steam64 format:
// [Universe "X": 8-bit] [Account type: 4-bit] [Account instance: 20-bit] [Account number: 30-bit] ["Y": 1-bit]
//
// "X represents the "Universe" the steam account belongs to."
// "Y is part of the ID number for the account. Y is either 0 or 1."
// "Z is the "account number")."
// "Let X, Y and Z constants be defined by the SteamID: STEAM_X:Y:Z"
// "W=Z*2+Y"
//
// W is the Steam3 number we want
static inline uint32 convert_to_steam3(CSteamID steamID)
{
uint32 component_y = steamID.GetAccountID() & 0x01U;
uint32 component_z = (steamID.GetAccountID() & 0xFFFFFFFEU) >> 1;
return component_z * 2 + component_y;
}
static std::string iden_factory_Steam3AccountID(CSimpleIniA *ini, class Settings *settings_client, class Settings *settings_server, class Local_Storage *local_storage)
{
return std::to_string(convert_to_steam3(settings_client->get_local_steam_id()));
}
static std::string iden_factory_64BitSteamID(CSimpleIniA *ini, class Settings *settings_client, class Settings *settings_server, class Local_Storage *local_storage)
{
return std::to_string(settings_client->get_local_steam_id().ConvertToUint64());
}
static std::string iden_factory_gameinstall(CSimpleIniA *ini, class Settings *settings_client, class Settings *settings_server, class Local_Storage *local_storage)
{
// should be: [Steam Install]\SteamApps\common\[Game Folder]
auto str = Local_Storage::get_exe_dir();
str.pop_back(); // we don't want the trailing backslash
return str;
}
static std::string iden_factory_EmuSteamInstall(CSimpleIniA *ini, class Settings *settings_client, class Settings *settings_server, class Local_Storage *local_storage)
{
auto steam_path = get_env_variable("SteamPath");
if (steam_path.empty()) {
steam_path = get_env_variable("InstallPath");
}
if (steam_path.empty()) {
steam_path = Local_Storage::get_program_path();
}
if (steam_path.empty()) {
steam_path = Local_Storage::get_exe_dir();
}
if (steam_path.empty()) {
return {};
}
if (steam_path.size() > 1 && PATH_SEPARATOR[0] == steam_path.back()) {
steam_path = steam_path.substr(0, steam_path.find_last_not_of(PATH_SEPARATOR) + 1);
}
return steam_path;
}
#if defined(__WINDOWS__)
// https://learn.microsoft.com/en-us/windows/win32/shell/knownfolderid
static inline std::string get_winSpecialFolder(REFKNOWNFOLDERID rfid)
{
wchar_t *pszPath = nullptr;
HRESULT hr = SHGetKnownFolderPath(rfid, 0, NULL, &pszPath);
if (SUCCEEDED(hr)) {
auto path = utf8_encode(pszPath);
CoTaskMemFree(pszPath);
return path;
}
return {};
}
static std::string iden_factory_WinMyDocuments(CSimpleIniA *ini, class Settings *settings_client, class Settings *settings_server, class Local_Storage *local_storage)
{
return get_winSpecialFolder(FOLDERID_Documents);
}
static std::string iden_factory_WinAppDataLocal(CSimpleIniA *ini, class Settings *settings_client, class Settings *settings_server, class Local_Storage *local_storage)
{
return get_winSpecialFolder(FOLDERID_LocalAppData);
}
static std::string iden_factory_WinAppDataLocalLow(CSimpleIniA *ini, class Settings *settings_client, class Settings *settings_server, class Local_Storage *local_storage)
{
return get_winSpecialFolder(FOLDERID_LocalAppDataLow);
}
static std::string iden_factory_WinAppDataRoaming(CSimpleIniA *ini, class Settings *settings_client, class Settings *settings_server, class Local_Storage *local_storage)
{
return get_winSpecialFolder(FOLDERID_RoamingAppData);
}
static std::string iden_factory_WinSavedGames(CSimpleIniA *ini, class Settings *settings_client, class Settings *settings_server, class Local_Storage *local_storage)
{
return get_winSpecialFolder(FOLDERID_SavedGames);
}
#else
typedef struct _t_nix_user_info {
std::string name{};
std::string home_dir{};
} t_nix_user_info;
static t_nix_user_info get_nix_user_info()
{
t_nix_user_info res{};
// https://linux.die.net/man/3/getpwuid_r
// https://man7.org/linux/man-pages/man2/geteuid.2.html
struct passwd pwd{};
struct passwd* result = nullptr;
char buffer[1024]{};
auto ret = getpwuid_r(getuid(), &pwd, buffer, sizeof(buffer), &result);
if (0 == ret && result != nullptr) {
res.name = pwd.pw_name;
res.home_dir = pwd.pw_dir;
}
return res;
}
static std::string get_nix_home()
{
auto str = get_env_variable("HOME");
if (str.empty()) {
str = get_nix_user_info().home_dir;
}
if (!str.empty()) {
if (str.size() > 1 && PATH_SEPARATOR[0] == str.back()) {
str = str.substr(0, str.find_last_not_of(PATH_SEPARATOR) + 1);
}
return str;
}
return {};
}
static std::string iden_factory_LinuxHome(CSimpleIniA *ini, class Settings *settings_client, class Settings *settings_server, class Local_Storage *local_storage)
{
return get_nix_home();
}
static std::string iden_factory_SteamCloudDocuments(CSimpleIniA *ini, class Settings *settings_client, class Settings *settings_server, class Local_Storage *local_storage)
{
std::string home = get_nix_home();
std::string username = settings_client->get_local_name();
if (username.empty()) {
username = get_nix_user_info().name;
}
if (username.empty()) {
username = DEFAULT_NAME;
}
std::string game_folder = iden_factory_gameinstall(ini, settings_client, settings_server, local_storage);
{
auto last_sep = game_folder.rfind(PATH_SEPARATOR);
if (last_sep != std::string::npos) {
game_folder = game_folder.substr(last_sep + 1);
}
}
return home + PATH_SEPARATOR + ".SteamCloud" + PATH_SEPARATOR + username + PATH_SEPARATOR + game_folder;
}
static std::string iden_factory_LinuxXdgDataHome(CSimpleIniA *ini, class Settings *settings_client, class Settings *settings_server, class Local_Storage *local_storage)
{
// https://specifications.freedesktop.org/basedir-spec/latest/#variables
auto datadir = get_env_variable("XDG_DATA_HOME");
if (datadir.empty()) {
auto homedir = get_nix_home();
if (!homedir.empty()) {
datadir = std::move(homedir) + PATH_SEPARATOR + ".local" + PATH_SEPARATOR + "share";
}
}
if (datadir.size() > 1 && PATH_SEPARATOR[0] == datadir.back()) {
datadir = datadir.substr(0, datadir.find_last_not_of(PATH_SEPARATOR) + 1);
}
return datadir;
}
#endif // __WINDOWS__
static std::unordered_map<
std::string_view,
std::function<std::string (CSimpleIniA*, class Settings*, class Settings*, class Local_Storage*)>
> identifiers_factories {
{ "{::Steam3AccountID::}", iden_factory_Steam3AccountID, },
{ "{::64BitSteamID::}", iden_factory_64BitSteamID, },
{ "{::gameinstall::}", iden_factory_gameinstall, },
{ "{::EmuSteamInstall::}", iden_factory_EmuSteamInstall, },
#if defined(__WINDOWS__)
{ "{::WinMyDocuments::}", iden_factory_WinMyDocuments, },
{ "{::WinAppDataLocal::}", iden_factory_WinAppDataLocal, },
{ "{::WinAppDataLocalLow::}", iden_factory_WinAppDataLocalLow, },
{ "{::WinAppDataRoaming::}", iden_factory_WinAppDataRoaming, },
{ "{::WinSavedGames::}", iden_factory_WinSavedGames, },
#else
{ "{::LinuxHome::}", iden_factory_LinuxHome, },
{ "{::SteamCloudDocuments::}", iden_factory_SteamCloudDocuments, },
{ "{::LinuxXdgDataHome::}", iden_factory_LinuxXdgDataHome, },
#endif // __WINDOWS__
};
static std::filesystem::path factory_default_cloud_dir(CSimpleIniA *ini, class Settings *settings_client, class Settings *settings_server, class Local_Storage *local_storage)
{
auto steam_path = iden_factory_EmuSteamInstall(ini, settings_client, settings_server, local_storage);
if (steam_path.empty()) {
return {};
}
auto steam3_account_id = std::to_string(convert_to_steam3(settings_client->get_local_steam_id()));
auto app_id = std::to_string(settings_client->get_local_game_id().AppID());
return
std::filesystem::u8path(steam_path)
/ std::filesystem::u8path("userdata")
/ std::filesystem::u8path(steam3_account_id)
/ std::filesystem::u8path(app_id)
;
}
// app::cloud_save
void parse_cloud_save(CSimpleIniA *ini, class Settings *settings_client, class Settings *settings_server, class Local_Storage *local_storage)
{
constexpr static bool DEFAULT_CREATE_DEFAULT_DIR = true;
constexpr static bool DEFAULT_CREATE_SPECIFIC_DIRS = true;
constexpr static const char SPECIFIC_INI_KEY[] =
"app::cloud_save::"
// then concat the OS specific part
#if defined(__WINDOWS__)
"win"
#else
"linux"
#endif
;
bool create_default_dir = ini->GetBoolValue("app::cloud_save::general", "create_default_dir", DEFAULT_CREATE_DEFAULT_DIR);
if (create_default_dir) {
auto default_cloud_dir = factory_default_cloud_dir(ini, settings_client, settings_server, local_storage);
if (default_cloud_dir.empty()) {
PRINT_DEBUG("[X] cannot resolve default cloud save dir");
} else if (std::filesystem::is_directory(default_cloud_dir) || std::filesystem::create_directories(default_cloud_dir)) {
PRINT_DEBUG(
"successfully created default cloud save dir '%s'",
default_cloud_dir.u8string().c_str()
);
} else {
PRINT_DEBUG(
"[X] failed to create default cloud save dir '%s'",
default_cloud_dir.u8string().c_str()
);
}
}
bool create_specific_dirs = ini->GetBoolValue("app::cloud_save::general", "create_specific_dirs", DEFAULT_CREATE_SPECIFIC_DIRS);
if (!create_specific_dirs) return;
std::list<CSimpleIniA::Entry> specific_keys{};
if (!ini->GetAllKeys(SPECIFIC_INI_KEY, specific_keys) || specific_keys.empty()) return;
PRINT_DEBUG("processing all cloud save dirs under [%s]", SPECIFIC_INI_KEY);
for (const auto &dir_key : specific_keys) {
auto dirname_raw = ini->GetValue(SPECIFIC_INI_KEY, dir_key.pItem, "");
if (!dirname_raw || !dirname_raw[0]) continue;
// parse specific dir
std::string dirname = dirname_raw;
for (auto &[iden_name, iden_factory] : identifiers_factories) {
auto iden_val = iden_factory(ini, settings_client, settings_server, local_storage);
if (!iden_val.empty()) {
dirname = common_helpers::str_replace_all(dirname, iden_name, iden_val);
} else {
PRINT_DEBUG(" [?] cannot resolve cloud save identifier '%s'", iden_name.data());
}
}
PRINT_DEBUG(" parsed cloud save dir [%s]:\n '%s'\n ->\n '%s'", dir_key.pItem, dirname_raw, dirname.c_str());
// create specific dir
if (common_helpers::str_find(dirname, "{::") == static_cast<size_t>(-1)) {
auto dirname_p = std::filesystem::u8path(dirname);
if (std::filesystem::is_directory(dirname_p) || std::filesystem::create_directories(dirname_p)) {
PRINT_DEBUG(" successfully created cloud save dir");
} else {
PRINT_DEBUG(" [X] failed to create cloud save dir");
}
} else {
PRINT_DEBUG(" [X] cloud save dir has unprocessed identifiers, skipping");
}
}
}

View File

@@ -472,7 +472,7 @@ std::string common_helpers::to_str(std::wstring_view wstr)
return {};
}
std::string common_helpers::str_replace_all(std::string_view source, std::string_view substr, std::string_view replace)
std::string common_helpers::str_replace_all(std::string_view source, std::string_view substr, std::string_view replace, bool case_insensitive)
{
if (source.empty() || substr.empty()) return std::string(source);
@@ -480,8 +480,8 @@ std::string common_helpers::str_replace_all(std::string_view source, std::string
out.reserve(source.size() / 4); // out could be bigger or smaller than source, start small
size_t start_offset = 0;
auto f_idx = source.find(substr);
while (std::string::npos != f_idx) {
auto f_idx = str_find(source, substr, 0, case_insensitive);
while (static_cast<size_t>(-1) != f_idx) {
// copy the chars before the match
auto chars_count_until_match = f_idx - start_offset;
out.append(source, start_offset, chars_count_until_match);
@@ -491,7 +491,7 @@ std::string common_helpers::str_replace_all(std::string_view source, std::string
// adjust the start offset to point at the char after this match
start_offset = f_idx + substr.size();
// search for next match
f_idx = source.find(substr, start_offset);
f_idx = str_find(source, substr, start_offset, case_insensitive);
}
// copy last remaining part
@@ -499,3 +499,74 @@ std::string common_helpers::str_replace_all(std::string_view source, std::string
return out;
}
size_t common_helpers::str_find(std::string_view str_src, std::string_view str_query, size_t start, bool case_insensitive)
{
if (start > str_src.size()) {
start = str_src.size();
}
if (start > 0) {
str_src = str_src.substr(start);
}
if (str_src.empty() != str_query.empty()) return static_cast<size_t>(-1);
if (str_src.empty() && str_query.empty()) return start;
if (str_src.size() < str_query.size()) return static_cast<size_t>(-1);
const auto cmp_fn = case_insensitive
? [](const char c1, const char c2){
return std::toupper(c1) == std::toupper(c2);
}
: [](const char c1, const char c2){
return c1 == c2;
};
for (size_t idx = 0; idx < str_src.size(); ++idx) {
auto str_src_cbegin = str_src.cbegin() + idx;
auto str_src_cend = str_src_cbegin + str_query.size();
if (std::equal(str_src_cbegin, str_src_cend, str_query.cbegin(), cmp_fn)) {
return idx + start;
}
// if remaining str.length <= find.length
if ((str_src.size() - idx) <= str_query.size()) {
return static_cast<size_t>(-1);
}
}
return static_cast<size_t>(-1);
}
std::vector<std::string> common_helpers::str_split(std::string_view str, std::string_view splitter, bool ignore_empty, bool case_insensitive)
{
if (str.empty()) return {};
if (splitter.empty()) return {
std::string(str)
};
std::vector<std::string> out{};
out.reserve(str.size() / 4); // start with a reasonable size
size_t start_offset = 0;
auto f_idx = str_find(str, splitter, 0, case_insensitive);
while (static_cast<size_t>(-1) != f_idx) {
// copy the chars before the match
auto chars_count_until_match = f_idx - start_offset;
if (chars_count_until_match > 0 || !ignore_empty) {
out.emplace_back(std::string(str, start_offset, chars_count_until_match));
}
// adjust the start offset to point at the char after this match
start_offset = f_idx + splitter.size();
// search for next match
f_idx = str_find(str, splitter, start_offset, case_insensitive);
}
// copy last remaining part
auto chars_count_until_end = str.size() - start_offset;
if (chars_count_until_end > 0 || !ignore_empty) {
out.emplace_back(std::string(str, start_offset, str.size()));
}
return out;
}

View File

@@ -108,6 +108,8 @@ std::string get_utc_time();
std::wstring to_wstr(std::string_view str);
std::string to_str(std::wstring_view wstr);
std::string str_replace_all(std::string_view source, std::string_view substr, std::string_view replace);
std::string str_replace_all(std::string_view source, std::string_view substr, std::string_view replace, bool case_insensitive = true);
size_t str_find(std::string_view str_src, std::string_view str_query, size_t start = 0, bool case_insensitive = true);
std::vector<std::string> str_split(std::string_view str, std::string_view splitter, bool ignore_empty = true, bool case_insensitive = true);
}

View File

@@ -34,3 +34,63 @@ unlock_all=0
# however some other games might expect this function to return empty paths to properly load DLCs
# you can deliberately set the path to be empty to specify this behavior like lines below
1337=
[app::cloud_save::general]
# should the emu create the default directory for cloud saves on startup:
# [Steam Install]/userdata/{Steam3AccountID}/{AppID}/
# default=1
create_default_dir=1
# should the emu create the directories specified in the cloud saves section of the current OS on startup
# default=1
create_specific_dirs=1
# directories which should be created on startup, this is used for cloud saves
# some games refuse to work unless these directories exist
# there are reserved identifiers which are replaced at runtime
# you can find a list of them here:
# https://partner.steamgames.com/doc/features/cloud#setup
#
# the identifiers must be wrapped with double colons "::" like this:
# original value: {SteamCloudDocuments}
# ini value: {::SteamCloudDocuments::}
# notice the braces "{" and "}", they are not changed
# the double colons are added between them as shown above
#
# === known identifiers:
# ---
# --- general:
# ---
# Steam3AccountID=current account ID in Steam3 format
# 64BitSteamID=current account ID in Steam64 format
# gameinstall=[Steam Install]\SteamApps\common\[Game Folder]\
# EmuSteamInstall=this is an emu specific variable, the value preference is as follows:
# - from environment variable: SteamPath
# - or from environment variable: InstallPath
# - or if using coldclientloader: directory of steamclient
# - or if NOT using coldclientloader: directory of steam_api
# - or directory of exe
# ---
# --- Windows only:
# ---
# WinMyDocuments=%USERPROFILE%\My Documents\
# WinAppDataLocal=%USERPROFILE%\AppData\Local\
# WinAppDataLocalLow=%USERPROFILE%\AppData\LocalLow\
# WinAppDataRoaming=%USERPROFILE%\AppData\Roaming\
# WinSavedGames=%USERPROFILE%\Saved Games\
# ---
# --- Linux only:
# ---
# LinuxHome=~/
# SteamCloudDocuments=
# - Linux: ~/.SteamCloud/[username]/[Game Folder]/
# - Windows: X
# - MAcOS: X
# LinuxXdgDataHome=
# - if 'XDG_DATA_HOME' is defined: $XDG_DATA_HOME/
# - otherwise: $HOME/.local/share
[app::cloud_save::win]
dir1={::WinAppDataRoaming::}/publisher_name/some_game
dir2={::WinMyDocuments::}/publisher_name/some_game/{::Steam3AccountID::}
[app::cloud_save::linux]
dir1={::LinuxXdgDataHome::}/publisher_name/some_game
dir2={::LinuxHome::}/publisher_name/some_game/{::64BitSteamID::}