From ce9ac99a1de9a8e1e9220a564bf77d7d7bdd0c20 Mon Sep 17 00:00:00 2001 From: mtkennerly Date: Mon, 18 Mar 2024 22:30:31 -0400 Subject: [PATCH] #29: Start tracking Steam Cloud metadata --- data/manifest.yaml | 13 ++ data/missing.md | 1 - data/steam-game-cache.yaml | 17 +++ src/main.rs | 3 + src/manifest.rs | 77 +++++++++++- src/steam.rs | 247 ++++++++++++++++++++++++++++++++++++- 6 files changed, 354 insertions(+), 4 deletions(-) diff --git a/data/manifest.yaml b/data/manifest.yaml index da6ec490..373d7053 100644 --- a/data/manifest.yaml +++ b/data/manifest.yaml @@ -662831,6 +662831,19 @@ Zzzzz: steam: id: 871810 </reality>: + files: + "/.renpy/reality-1474579375/*.save": + when: + - os: linux + store: steam + "/Library/RenPy/reality-1474579375/*.save": + when: + - os: mac + store: steam + "/RenPy/reality-1474579375/*.save": + when: + - os: windows + store: steam installDir: reality: {} launch: diff --git a/data/missing.md b/data/missing.md index b998dca9..535e517e 100644 --- a/data/missing.md +++ b/data/missing.md @@ -37844,4 +37844,3 @@ * [누가 그녀를 죽였나](https://www.pcgamingwiki.com/wiki/?curid=136414) * [무연](https://www.pcgamingwiki.com/wiki/?curid=144415) * [암전:Blackout](https://www.pcgamingwiki.com/wiki/?curid=129985) -* [</reality>](https://www.pcgamingwiki.com/wiki/?curid=57848) diff --git a/data/steam-game-cache.yaml b/data/steam-game-cache.yaml index 5723c089..07c13379 100644 --- a/data/steam-game-cache.yaml +++ b/data/steam-game-cache.yaml @@ -163009,6 +163009,23 @@ executable: ShotShotTactic.exe type: option1 562280: + cloud: + saves: + - path: RenPy/reality-1474579375 + pattern: "*.save" + platforms: + - Windows + root: WinAppDataRoaming + - path: /Library/RenPy/reality-1474579375 + pattern: "*.save" + platforms: + - MacOS + root: MacHome + - path: ".renpy/reality-1474579375" + pattern: "*.save" + platforms: + - Linux + root: LinuxHome installDir: reality launch: - config: diff --git a/src/main.rs b/src/main.rs index 2c09b62d..d546e4c4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -64,6 +64,8 @@ pub enum Error { PageMissing, #[error("Could not find product info")] SteamProductInfo, + #[error("Could not decode product info: {0:?}")] + SteamProductInfoDecoding(serde_json::Error), #[error("Schema validation failed for manifest")] ManifestSchema, #[error("Subprocess: {0}")] @@ -77,6 +79,7 @@ impl Error { | Error::WikiData(_) | Error::PageMissing | Error::SteamProductInfo + | Error::SteamProductInfoDecoding(_) | Error::Subprocess(_) => false, Error::ManifestSchema => true, } diff --git a/src/manifest.rs b/src/manifest.rs index e6d370a6..db93d465 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -2,7 +2,7 @@ use std::collections::{BTreeMap, BTreeSet}; use crate::{ resource::ResourceFile, - steam::{SteamCache, SteamCacheEntry}, + steam::{self, SteamCache, SteamCacheEntry}, wiki::{PathKind, WikiCache, WikiCacheEntry}, Error, }; @@ -249,6 +249,10 @@ impl Game { } } + fn add_file_constraint(&mut self, path: String, constraint: GameFileConstraint) { + self.files.entry(path).or_default().when.insert(constraint); + } + pub fn integrate_steam(&mut self, cache: &SteamCacheEntry) { if let Some(install_dir) = &cache.install_dir { self.install_dir.insert(install_dir.to_string(), GameInstallDirEntry {}); @@ -311,6 +315,77 @@ impl Game { .or_insert_with(|| vec![candidate]); } } + + // We only integrate cloud saves if there's no other save info. + let need_cloud = self.files.is_empty() && self.registry.is_empty(); + + for save in &cache.cloud.saves { + if !need_cloud { + break; + } + + let Some(root) = steam::parse_root(&save.root) else { + continue; + }; + let os = save.platforms.first().and_then(|x| steam::parse_platform(x)); + let constraint = GameFileConstraint { + os, + store: Some(Store::Steam), + }; + + let path = save.path.trim_matches(['/', '\\']); + let pattern = save.pattern.trim_matches(['/', '\\']); + + if &save.pattern == "*" { + self.add_file_constraint(format!("{}/{}", &root, path), constraint.clone()); + } else if save.recursive { + self.add_file_constraint(format!("{}/{}/**/{}", &root, path, pattern), constraint.clone()); + } else { + self.add_file_constraint(format!("{}/{}/{}", &root, path, pattern), constraint.clone()); + } + + for alt in &cache.cloud.overrides { + if save.root != alt.root { + continue; + } + + let alt_os = steam::parse_os_comparison(alt.os.clone(), alt.os_compare.clone()); + let constraint = GameFileConstraint { + os: alt_os.or(os), + store: Some(Store::Steam), + }; + + let root = if let Some(instead) = alt.use_instead.as_ref() { + steam::parse_root(instead) + } else { + steam::parse_root(&alt.root) + }; + let Some(root) = root else { continue }; + + let mut path = if let Some(add) = alt.add_path.as_ref() { + if &save.pattern == "*" { + format!("{}/{}/{}", &root, add, path) + } else if save.recursive { + format!("{}/{}/{}/**/{}", &root, add, path, pattern) + } else { + format!("{}/{}/{}/{}", &root, add, path, pattern) + } + } else { + format!("{}/{}/{}", &root, path, pattern) + }; + + for transform in &alt.path_transforms { + path = path.replace(&transform.find, &transform.replace); + } + + path = path + .replace('\\', "/") + .replace("{64BitSteamID}", placeholder::STORE_USER_ID) + .replace("{Steam3AccountID}", placeholder::STORE_USER_ID); + + self.add_file_constraint(path, constraint.clone()); + } + } } pub fn integrate_overrides(&mut self, overridden: &OverrideGame) { diff --git a/src/steam.rs b/src/steam.rs index 3dd0cbf4..2c6232a5 100644 --- a/src/steam.rs +++ b/src/steam.rs @@ -1,6 +1,11 @@ use std::{collections::BTreeMap, process::Command}; -use crate::{resource::ResourceFile, wiki::WikiCache, Error, State, REPO}; +use crate::{ + manifest::{placeholder, Os}, + resource::ResourceFile, + wiki::WikiCache, + Error, State, REPO, +}; const SAVE_INTERVAL: u32 = 100; @@ -75,6 +80,10 @@ impl SteamCache { pub struct SteamCacheEntry { #[serde(skip_serializing_if = "State::is_handled")] pub state: State, + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub irregular: bool, + #[serde(skip_serializing_if = "Cloud::is_empty")] + pub cloud: Cloud, #[serde(skip_serializing_if = "Option::is_none")] pub install_dir: Option, #[serde(skip_serializing_if = "Vec::is_empty")] @@ -83,6 +92,58 @@ pub struct SteamCacheEntry { pub name_localized: BTreeMap, } +#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Cloud { + #[serde(skip_serializing_if = "Vec::is_empty")] + pub saves: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub overrides: Vec, +} + +impl Cloud { + pub fn is_empty(&self) -> bool { + self.saves.is_empty() && self.overrides.is_empty() + } +} + +#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct CloudSave { + pub path: String, + pub pattern: String, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub platforms: Vec, + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub recursive: bool, + pub root: String, +} + +#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct CloudOverride { + #[serde(skip_serializing_if = "Option::is_none")] + pub add_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub os: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub os_compare: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub path_transforms: Vec, + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub recursive: bool, + pub root: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub use_instead: Option, +} + +#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct CloudTransform { + pub find: String, + pub replace: String, +} + #[derive(Debug, Default, Clone, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct Launch { @@ -133,6 +194,14 @@ impl LaunchConfig { mod product_info { use super::*; + fn parse_bool<'de, D>(deserializer: D) -> Result + where + D: serde::de::Deserializer<'de>, + { + let s: &str = serde::de::Deserialize::deserialize(deserializer)?; + Ok(s == "1") + } + #[derive(Debug, Default, Clone, serde::Deserialize)] pub struct Response { pub apps: BTreeMap, @@ -143,6 +212,7 @@ mod product_info { pub struct App { pub common: AppCommon, pub config: AppConfig, + pub ufs: AppUfs, } #[derive(Debug, Default, Clone, serde::Deserialize)] @@ -177,11 +247,57 @@ mod product_info { pub oslist: Option, pub ownsdlc: Option, } + + #[derive(Debug, Default, Clone, serde::Deserialize)] + #[serde(default)] + pub struct AppUfs { + #[serde(rename = "savefiles")] + pub save_files: BTreeMap, + #[serde(rename = "rootoverrides")] + pub root_overrides: BTreeMap, + } + + #[derive(Debug, Default, Clone, serde::Deserialize)] + #[serde(default)] + pub struct AppUfsSaveFile { + pub path: String, + pub pattern: String, + pub platforms: BTreeMap, + #[serde(deserialize_with = "parse_bool")] + pub recursive: bool, + pub root: String, + } + + #[derive(Debug, Default, Clone, serde::Deserialize)] + #[serde(default)] + pub struct AppUfsRootOverride { + #[serde(rename = "addpath")] + pub add_path: Option, + pub os: Option, + #[serde(rename = "oscompare")] + pub os_compare: Option, + #[serde(rename = "pathtransforms")] + pub path_transforms: Option>, + pub platforms: BTreeMap, + #[serde(deserialize_with = "parse_bool")] + pub recursive: bool, + pub root: String, + #[serde(rename = "useinstead")] + pub use_instead: Option, + } + + #[derive(Debug, Default, Clone, serde::Deserialize)] + #[serde(default)] + pub struct AppUfsPathTransform { + pub find: String, + pub replace: String, + } } impl SteamCacheEntry { pub fn fetch_from_id(app_id: u32) -> Result, Error> { println!("Steam: {}", app_id); + let mut irregular = false; let mut cmd = Command::new("python"); cmd.arg(format!("{}/scripts/get-steam-app-info.py", REPO)); @@ -194,12 +310,46 @@ impl SteamCacheEntry { } let stdout = String::from_utf8_lossy(&output.stdout); - let response = serde_json::from_str::(&stdout).map_err(|_| Error::SteamProductInfo)?; + let response = + serde_json::from_str::(&stdout).map_err(Error::SteamProductInfoDecoding)?; let Some(app) = response.apps.get(&app_id.to_string()).cloned() else { eprintln!("No results for Steam ID: {}", app_id); return Ok(None); }; + // Debugging: + let raw = serde_json::from_str::(&stdout).map_err(Error::SteamProductInfoDecoding)?; + if let Some(ufs) = raw["apps"][app_id.to_string()]["ufs"]["save_files"].as_object() { + let keys: Vec<_> = ufs.keys().collect(); + for key in keys { + let key = key.to_string(); + if !["path", "pattern", "platforms", "recursive", "root"].contains(&key.as_str()) { + irregular = true; + println!("[Steam] Unknown save key: {}", key); + } + } + } + if let Some(ufs) = raw["apps"][app_id.to_string()]["ufs"]["root_overrides"].as_object() { + let keys: Vec<_> = ufs.keys().collect(); + for key in keys { + let key = key.to_string(); + if ![ + "add_path", + "os", + "os_compare", + "path_transforms", + "recursive", + "root", + "use_instead", + ] + .contains(&key.as_str()) + { + irregular = true; + println!("[Steam] Unknown override key: {}", key); + } + } + } + let launch: Vec<_> = app .config .launch @@ -220,11 +370,104 @@ impl SteamCacheEntry { .filter(|x| !x.is_empty()) .collect(); + let cloud = Cloud { + saves: app + .ufs + .save_files + .into_values() + .map(|x| CloudSave { + path: x.path, + pattern: x.pattern, + platforms: x.platforms.into_values().collect(), + recursive: x.recursive, + root: x.root, + }) + .collect(), + overrides: app + .ufs + .root_overrides + .into_values() + .map(|x| CloudOverride { + add_path: x.add_path, + os: x.os, + os_compare: x.os_compare, + path_transforms: x + .path_transforms + .map(|x| { + x.into_values() + .map(|x| CloudTransform { + find: x.find, + replace: x.replace, + }) + .collect() + }) + .unwrap_or_default(), + recursive: x.recursive, + root: x.root, + use_instead: x.use_instead, + }) + .collect(), + }; + Ok(Some(Self { state: State::Handled, + irregular, + cloud, install_dir: app.config.installdir, name_localized: app.common.name_localized, launch, })) } } + +pub fn parse_root(value: &str) -> Option<&'static str> { + match value.to_lowercase().as_ref() { + "gameinstall" => Some(placeholder::BASE), + "linuxhome" => Some(placeholder::HOME), + "linuxxdgdatahome" => Some(placeholder::XDG_DATA), + "macappsupport" => Some("/Library/Application Support"), + "madocuments" => Some("/Documents"), + "machome" => Some(placeholder::HOME), + "winappdataroaming" => Some(placeholder::WIN_APP_DATA), + "winappdatalocallow" => Some("/AppData/LocalLow"), + "winmydocuments" => Some(placeholder::WIN_DOCUMENTS), + "winsavedgames" => Some("/Saved Games"), + _ => { + println!("[Steam] unknown root: {}", value); + None + } + } +} + +pub fn parse_platform(value: &str) -> Option { + match value.to_lowercase().as_ref() { + "linux" => Some(Os::Linux), + "macos" => Some(Os::Mac), + "windows" => Some(Os::Windows), + "all" => None, + _ => { + println!("[Steam] unknown platform: {}", value); + None + } + } +} + +pub fn parse_os_comparison(os: Option, comparison: Option) -> Option { + let comparison = comparison.unwrap_or_else(|| "=".to_string()); + let os = os.map(|x| x.to_lowercase()).unwrap_or_default(); + + match (comparison.as_ref(), os.as_ref()) { + ("=", "windows") => Some(Os::Windows), + ("=", "linux") => Some(Os::Linux), + ("=", "macos") => Some(Os::Mac), + (x, _) if x != "=" => { + println!("[Steam] unknown OS operator: {}", x); + None + } + (_, x) if !x.is_empty() => { + println!("[Steam] unknown OS: {}", x); + None + } + _ => None, + } +}