#29: Start tracking Steam Cloud metadata
This commit is contained in:
parent
7d54fb7c06
commit
ce9ac99a1d
6 changed files with 354 additions and 4 deletions
|
@ -662831,6 +662831,19 @@ Zzzzz:
|
|||
steam:
|
||||
id: 871810
|
||||
</reality>:
|
||||
files:
|
||||
"<home>/.renpy/reality-1474579375/*.save":
|
||||
when:
|
||||
- os: linux
|
||||
store: steam
|
||||
"<home>/Library/RenPy/reality-1474579375/*.save":
|
||||
when:
|
||||
- os: mac
|
||||
store: steam
|
||||
"<winAppData>/RenPy/reality-1474579375/*.save":
|
||||
when:
|
||||
- os: windows
|
||||
store: steam
|
||||
installDir:
|
||||
reality: {}
|
||||
launch:
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
247
src/steam.rs
247
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<String>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
|
@ -83,6 +92,58 @@ pub struct SteamCacheEntry {
|
|||
pub name_localized: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
#[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<CloudSave>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub overrides: Vec<CloudOverride>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
#[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<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub os: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub os_compare: Option<String>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub path_transforms: Vec<CloudTransform>,
|
||||
#[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<String>,
|
||||
}
|
||||
|
||||
#[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<bool, D::Error>
|
||||
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<String, App>,
|
||||
|
@ -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<String>,
|
||||
pub ownsdlc: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, serde::Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct AppUfs {
|
||||
#[serde(rename = "savefiles")]
|
||||
pub save_files: BTreeMap<String, AppUfsSaveFile>,
|
||||
#[serde(rename = "rootoverrides")]
|
||||
pub root_overrides: BTreeMap<String, AppUfsRootOverride>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, serde::Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct AppUfsSaveFile {
|
||||
pub path: String,
|
||||
pub pattern: String,
|
||||
pub platforms: BTreeMap<String, String>,
|
||||
#[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<String>,
|
||||
pub os: Option<String>,
|
||||
#[serde(rename = "oscompare")]
|
||||
pub os_compare: Option<String>,
|
||||
#[serde(rename = "pathtransforms")]
|
||||
pub path_transforms: Option<BTreeMap<String, AppUfsPathTransform>>,
|
||||
pub platforms: BTreeMap<String, String>,
|
||||
#[serde(deserialize_with = "parse_bool")]
|
||||
pub recursive: bool,
|
||||
pub root: String,
|
||||
#[serde(rename = "useinstead")]
|
||||
pub use_instead: Option<String>,
|
||||
}
|
||||
|
||||
#[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<Option<Self>, 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::<product_info::Response>(&stdout).map_err(|_| Error::SteamProductInfo)?;
|
||||
let response =
|
||||
serde_json::from_str::<product_info::Response>(&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::<serde_json::Value>(&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("<home>/Library/Application Support"),
|
||||
"madocuments" => Some("<home>/Documents"),
|
||||
"machome" => Some(placeholder::HOME),
|
||||
"winappdataroaming" => Some(placeholder::WIN_APP_DATA),
|
||||
"winappdatalocallow" => Some("<home>/AppData/LocalLow"),
|
||||
"winmydocuments" => Some(placeholder::WIN_DOCUMENTS),
|
||||
"winsavedgames" => Some("<home>/Saved Games"),
|
||||
_ => {
|
||||
println!("[Steam] unknown root: {}", value);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_platform(value: &str) -> Option<Os> {
|
||||
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<String>, comparison: Option<String>) -> Option<Os> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
|
Reference in a new issue