From d2e436a05c6bcd4e9a9a61344615cb849d291382 Mon Sep 17 00:00:00 2001 From: Nikita Podvirnyi Date: Mon, 27 May 2024 18:24:52 +0200 Subject: [PATCH] feat: initial wuwa support --- Cargo.toml | 9 +- src/games/mod.rs | 3 + src/games/wuwa/config/mod.rs | 58 ++++ src/games/wuwa/config/schema/components.rs | 60 ++++ .../wuwa/config/schema/game/enhancements.rs | 36 +++ src/games/wuwa/config/schema/game/mod.rs | 107 +++++++ .../config/schema/launcher/discord_rpc.rs | 73 +++++ src/games/wuwa/config/schema/launcher/mod.rs | 130 ++++++++ src/games/wuwa/config/schema/mod.rs | 128 ++++++++ src/games/wuwa/config/schema/patch.rs | 35 +++ src/games/wuwa/consts.rs | 36 +++ src/games/wuwa/game.rs | 277 ++++++++++++++++++ src/games/wuwa/mod.rs | 13 + src/games/wuwa/sessions.rs | 95 ++++++ src/games/wuwa/states.rs | 141 +++++++++ src/lib.rs | 3 + 16 files changed, 1199 insertions(+), 5 deletions(-) create mode 100644 src/games/wuwa/config/mod.rs create mode 100644 src/games/wuwa/config/schema/components.rs create mode 100644 src/games/wuwa/config/schema/game/enhancements.rs create mode 100644 src/games/wuwa/config/schema/game/mod.rs create mode 100644 src/games/wuwa/config/schema/launcher/discord_rpc.rs create mode 100644 src/games/wuwa/config/schema/launcher/mod.rs create mode 100644 src/games/wuwa/config/schema/mod.rs create mode 100644 src/games/wuwa/config/schema/patch.rs create mode 100644 src/games/wuwa/consts.rs create mode 100644 src/games/wuwa/game.rs create mode 100644 src/games/wuwa/mod.rs create mode 100644 src/games/wuwa/sessions.rs create mode 100644 src/games/wuwa/states.rs diff --git a/Cargo.toml b/Cargo.toml index 2854c87..6546203 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ edition = "2021" [dependencies.anime-game-core] git = "https://github.com/an-anime-team/anime-game-core" -tag = "1.17.9" +tag = "1.18.0" features = ["all"] # path = "../anime-game-core" # ! for dev purposes only @@ -34,13 +34,12 @@ genshin = ["anime-game-core/genshin"] star-rail = ["anime-game-core/star-rail"] honkai = ["anime-game-core/honkai"] pgr = ["anime-game-core/pgr"] +wuwa = ["anime-game-core/wuwa"] star-rail-patch = ["anime-game-core/patch-jadeite"] -honkai-patch = [ - "anime-game-core/patch-jadeite", - "anime-game-core/patch-mfplat" -] +honkai-patch = ["anime-game-core/patch-jadeite"] pgr-patch = ["anime-game-core/patch-mfc140"] +wuwa-patch = ["anime-game-core/patch-mfc140"] # Common features states = [] diff --git a/src/games/mod.rs b/src/games/mod.rs index af3b9eb..46973be 100644 --- a/src/games/mod.rs +++ b/src/games/mod.rs @@ -9,3 +9,6 @@ pub mod honkai; #[cfg(feature = "pgr")] pub mod pgr; + +#[cfg(feature = "wuwa")] +pub mod wuwa; diff --git a/src/games/wuwa/config/mod.rs b/src/games/wuwa/config/mod.rs new file mode 100644 index 0000000..32e9af5 --- /dev/null +++ b/src/games/wuwa/config/mod.rs @@ -0,0 +1,58 @@ +use std::path::PathBuf; + +pub mod schema; + +pub use schema::Schema; + +use crate::config::ConfigExt; +use crate::wuwa::consts::config_file; + +static mut CONFIG: Option = None; + +pub struct Config; + +impl ConfigExt for Config { + type Schema = schema::Schema; + + #[inline] + fn config_file() -> PathBuf { + config_file().expect("Failed to resolve config file path") + } + + #[inline] + fn default_schema() -> Self::Schema { + Self::Schema::default() + } + + #[inline] + fn serialize_schema(schema: Self::Schema) -> anyhow::Result { + Ok(serde_json::to_string_pretty(&schema)?) + } + + #[inline] + fn deserialize_schema>(schema: T) -> anyhow::Result { + Ok(Self::Schema::from(&serde_json::from_str(schema.as_ref())?)) + } + + #[inline] + fn clone_schema(schema: &Self::Schema) -> Self::Schema { + schema.clone() + } + + #[inline] + fn get() -> anyhow::Result { + unsafe { + match &CONFIG { + Some(config) => Ok(config.clone()), + None => Self::get_raw() + } + } + } + + #[inline] + fn update(schema: Self::Schema) { + unsafe { + CONFIG = Some(schema); + } + } +} diff --git a/src/games/wuwa/config/schema/components.rs b/src/games/wuwa/config/schema/components.rs new file mode 100644 index 0000000..cd723d9 --- /dev/null +++ b/src/games/wuwa/config/schema/components.rs @@ -0,0 +1,60 @@ +use std::path::PathBuf; + +use serde::{Serialize, Deserialize}; +use serde_json::Value as JsonValue; + +use crate::wuwa::consts::launcher_dir; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Components { + pub path: PathBuf, + pub servers: Vec +} + +impl Default for Components { + #[inline] + fn default() -> Self { + let launcher_dir = launcher_dir().expect("Failed to get launcher dir"); + + Self { + path: launcher_dir.join("components"), + servers: vec![ + String::from("https://github.com/an-anime-team/components") + ] + } + } +} + +impl From<&JsonValue> for Components { + fn from(value: &JsonValue) -> Self { + let default = Self::default(); + + Self { + path: match value.get("path") { + Some(value) => match value.as_str() { + Some(value) => PathBuf::from(value), + None => default.path + }, + None => default.path + }, + + servers: match value.get("servers") { + Some(value) => match value.as_array() { + Some(values) => { + let mut servers = Vec::new(); + + for value in values { + if let Some(server) = value.as_str() { + servers.push(server.to_string()); + } + } + + servers + }, + None => default.servers + }, + None => default.servers + } + } + } +} diff --git a/src/games/wuwa/config/schema/game/enhancements.rs b/src/games/wuwa/config/schema/game/enhancements.rs new file mode 100644 index 0000000..06a619d --- /dev/null +++ b/src/games/wuwa/config/schema/game/enhancements.rs @@ -0,0 +1,36 @@ +use serde::{Serialize, Deserialize}; +use serde_json::Value as JsonValue; + +use crate::config::schema_blanks::prelude::*; + +#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Enhancements { + pub fsr: Fsr, + pub gamemode: bool, + pub hud: HUD, + pub gamescope: Gamescope +} + +impl From<&JsonValue> for Enhancements { + fn from(value: &JsonValue) -> Self { + let default = Self::default(); + + Self { + fsr: value.get("fsr") + .map(Fsr::from) + .unwrap_or(default.fsr), + + gamemode: value.get("gamemode") + .and_then(JsonValue::as_bool) + .unwrap_or(default.gamemode), + + hud: value.get("hud") + .map(HUD::from) + .unwrap_or(default.hud), + + gamescope: value.get("gamescope") + .map(Gamescope::from) + .unwrap_or(default.gamescope) + } + } +} diff --git a/src/games/wuwa/config/schema/game/mod.rs b/src/games/wuwa/config/schema/game/mod.rs new file mode 100644 index 0000000..03eed1c --- /dev/null +++ b/src/games/wuwa/config/schema/game/mod.rs @@ -0,0 +1,107 @@ +use std::collections::HashMap; +use std::path::PathBuf; + +use serde::{Serialize, Deserialize}; +use serde_json::Value as JsonValue; + +use crate::config::schema_blanks::prelude::*; +use crate::wuwa::consts::launcher_dir; + +crate::config_impl_wine_schema!(launcher_dir); +crate::config_impl_dxvk_schema!(launcher_dir); + +pub mod enhancements; + +pub mod prelude { + pub use super::Wine; + pub use super::Dxvk; + pub use super::enhancements::Enhancements; +} + +use prelude::*; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Game { + pub path: PathBuf, + pub wine: Wine, + pub dxvk: Dxvk, + pub enhancements: Enhancements, + pub environment: HashMap, + pub command: Option +} + +impl Default for Game { + #[inline] + fn default() -> Self { + let launcher_dir = launcher_dir().expect("Failed to get launcher dir"); + + Self { + path: launcher_dir.join("PGR"), + wine: Wine::default(), + dxvk: Dxvk::default(), + enhancements: Enhancements::default(), + environment: HashMap::new(), + command: None + } + } +} + +impl From<&JsonValue> for Game { + fn from(value: &JsonValue) -> Self { + let default = Self::default(); + + Self { + path: match value.get("path") { + Some(value) => match value.as_str() { + Some(value) => PathBuf::from(value), + None => default.path + }, + None => default.path + }, + + wine: value.get("wine") + .map(Wine::from) + .unwrap_or(default.wine), + + dxvk: value.get("dxvk") + .map(Dxvk::from) + .unwrap_or(default.dxvk), + + enhancements: value.get("enhancements") + .map(Enhancements::from) + .unwrap_or(default.enhancements), + + environment: match value.get("environment") { + Some(value) => match value.as_object() { + Some(values) => { + let mut vars = HashMap::new(); + + for (name, value) in values { + if let Some(value) = value.as_str() { + vars.insert(name.clone(), value.to_string()); + } + } + + vars + }, + None => default.environment + }, + None => default.environment + }, + + command: match value.get("command") { + Some(value) => { + if value.is_null() { + None + } else { + match value.as_str() { + Some(value) => Some(value.to_string()), + None => default.command + } + } + }, + None => default.command + } + } + } +} diff --git a/src/games/wuwa/config/schema/launcher/discord_rpc.rs b/src/games/wuwa/config/schema/launcher/discord_rpc.rs new file mode 100644 index 0000000..9b7cd47 --- /dev/null +++ b/src/games/wuwa/config/schema/launcher/discord_rpc.rs @@ -0,0 +1,73 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value as JsonValue; + +use crate::discord_rpc::DiscordRpcParams; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DiscordRpc { + pub app_id: u64, + pub enabled: bool, + pub title: String, + pub subtitle: String, + pub icon: String +} + +impl From for DiscordRpcParams { + #[inline] + fn from(config: DiscordRpc) -> Self { + Self { + app_id: config.app_id, + enabled: config.enabled, + title: config.title, + subtitle: config.subtitle, + icon: config.icon + } + } +} + +impl Default for DiscordRpc { + #[inline] + fn default() -> Self { + Self { + app_id: 1116308640945688677, + enabled: false, + + title: String::from("Commanding the"), + subtitle: String::from("Grey Raven unit"), + icon: String::from("launcher") + } + } +} + +impl From<&JsonValue> for DiscordRpc { + fn from(value: &JsonValue) -> Self { + let default = Self::default(); + + Self { + app_id: match value.get("app_id") { + Some(value) => value.as_u64().unwrap_or(default.app_id), + None => default.app_id + }, + + enabled: match value.get("enabled") { + Some(value) => value.as_bool().unwrap_or(default.enabled), + None => default.enabled + }, + + title: match value.get("title") { + Some(value) => value.as_str().unwrap_or(&default.title).to_string(), + None => default.title + }, + + subtitle: match value.get("subtitle") { + Some(value) => value.as_str().unwrap_or(&default.subtitle).to_string(), + None => default.subtitle + }, + + icon: match value.get("icon") { + Some(value) => value.as_str().unwrap_or(&default.icon).to_string(), + None => default.icon + } + } + } +} diff --git a/src/games/wuwa/config/schema/launcher/mod.rs b/src/games/wuwa/config/schema/launcher/mod.rs new file mode 100644 index 0000000..81c6eac --- /dev/null +++ b/src/games/wuwa/config/schema/launcher/mod.rs @@ -0,0 +1,130 @@ +use std::path::PathBuf; + +use serde::{Serialize, Deserialize}; +use serde_json::Value as JsonValue; + +use enum_ordinalize::Ordinalize; + +#[cfg(feature = "discord-rpc")] +pub mod discord_rpc; + +use crate::config::schema_blanks::prelude::*; +use crate::wuwa::consts::launcher_dir; + +pub mod prelude { + pub use super::{ + Launcher, + LauncherStyle, + LauncherBehavior + }; + + #[cfg(feature = "discord-rpc")] + pub use super::discord_rpc::DiscordRpc; +} + +use prelude::*; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Ordinalize, Serialize, Deserialize)] +pub enum LauncherStyle { + Modern, + Classic +} + +impl Default for LauncherStyle { + #[inline] + fn default() -> Self { + Self::Modern + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Ordinalize, Serialize, Deserialize)] +pub enum LauncherBehavior { + Nothing, + Hide, + Close +} + +impl Default for LauncherBehavior { + #[inline] + fn default() -> Self { + Self::Hide + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Launcher { + pub language: String, + pub style: LauncherStyle, + pub temp: Option, + pub repairer: Repairer, + + #[cfg(feature = "discord-rpc")] + pub discord_rpc: DiscordRpc, + + pub behavior: LauncherBehavior +} + +impl Default for Launcher { + #[inline] + fn default() -> Self { + Self { + language: String::from("en-us"), + style: LauncherStyle::default(), + temp: launcher_dir().ok(), + repairer: Repairer::default(), + + #[cfg(feature = "discord-rpc")] + discord_rpc: DiscordRpc::default(), + + behavior: LauncherBehavior::default() + } + } +} + +impl From<&JsonValue> for Launcher { + fn from(value: &JsonValue) -> Self { + let default = Self::default(); + + Self { + language: match value.get("language") { + Some(value) => value.as_str().unwrap_or(&default.language).to_string(), + None => default.language + }, + + style: match value.get("style") { + Some(value) => serde_json::from_value(value.to_owned()).unwrap_or_default(), + None => default.style + }, + + temp: match value.get("temp") { + Some(value) => { + if value.is_null() { + None + } else { + match value.as_str() { + Some(value) => Some(PathBuf::from(value)), + None => default.temp + } + } + }, + None => default.temp + }, + + repairer: match value.get("repairer") { + Some(value) => Repairer::from(value), + None => default.repairer + }, + + #[cfg(feature = "discord-rpc")] + discord_rpc: match value.get("discord_rpc") { + Some(value) => DiscordRpc::from(value), + None => default.discord_rpc + }, + + behavior: match value.get("behavior") { + Some(value) => serde_json::from_value(value.clone()).unwrap_or(default.behavior), + None => default.behavior + } + } + } +} diff --git a/src/games/wuwa/config/schema/mod.rs b/src/games/wuwa/config/schema/mod.rs new file mode 100644 index 0000000..1e74b9e --- /dev/null +++ b/src/games/wuwa/config/schema/mod.rs @@ -0,0 +1,128 @@ +use std::path::PathBuf; + +use serde::{Serialize, Deserialize}; +use serde_json::Value as JsonValue; + +use wincompatlib::prelude::*; + +#[cfg(feature = "sandbox")] +use crate::config::schema_blanks::sandbox::Sandbox; + +#[cfg(feature = "components")] +use crate::components::{ + wine::{ + UnifiedWine, + Version as WineVersion + }, + dxvk::Version as DxvkVersion +}; + +pub mod launcher; +pub mod game; +pub mod patch; + +#[cfg(feature = "components")] +pub mod components; + +pub mod prelude { + pub use super::launcher::prelude::*; + pub use super::game::prelude::*; + pub use super::game::*; + pub use super::patch::*; + + #[cfg(feature = "components")] + pub use super::components::*; +} + +use prelude::*; + +#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Schema { + pub launcher: Launcher, + pub game: Game, + + #[cfg(feature = "sandbox")] + pub sandbox: Sandbox, + + #[cfg(feature = "components")] + pub components: Components, + + pub patch: Patch +} + +impl From<&JsonValue> for Schema { + fn from(value: &JsonValue) -> Self { + let default = Self::default(); + + Self { + launcher: match value.get("launcher") { + Some(value) => Launcher::from(value), + None => default.launcher + }, + + game: match value.get("game") { + Some(value) => Game::from(value), + None => default.game + }, + + #[cfg(feature = "sandbox")] + sandbox: match value.get("sandbox") { + Some(value) => Sandbox::from(value), + None => default.sandbox + }, + + #[cfg(feature = "components")] + components: match value.get("components") { + Some(value) => Components::from(value), + None => default.components + }, + + patch: match value.get("patch") { + Some(value) => Patch::from(value), + None => default.patch + } + } + } +} + +impl Schema { + #[cfg(feature = "components")] + /// Get selected wine version + pub fn get_selected_wine(&self) -> anyhow::Result> { + match &self.game.wine.selected { + Some(selected) => WineVersion::find_in(&self.components.path, selected), + None => Ok(None) + } + } + + #[cfg(feature = "components")] + /// Get selected dxvk version + pub fn get_selected_dxvk(&self) -> anyhow::Result> { + match wincompatlib::dxvk::Dxvk::get_version(&self.game.wine.prefix)? { + Some(version) => DxvkVersion::find_in(&self.components.path, version), + None => Ok(None) + } + } + + #[cfg(feature = "components")] + /// Resolve real wine prefix path using wincompatlib + /// + /// - For general wine build returns `game.wine.prefix` + /// - For proton-like builds return `game.wine.prefix`/`pfx` + pub fn get_wine_prefix_path(&self) -> PathBuf { + if let Ok(Some(wine)) = self.get_selected_wine() { + let wine = wine + .to_wine(&self.components.path, Some(&self.game.wine.builds.join(&wine.name))) + .with_prefix(&self.game.wine.prefix); + + let prefix = match wine { + UnifiedWine::Default(wine) => wine.prefix, + UnifiedWine::Proton(proton) => proton.wine().prefix.clone() + }; + + return prefix; + } + + self.game.wine.prefix.clone() + } +} diff --git a/src/games/wuwa/config/schema/patch.rs b/src/games/wuwa/config/schema/patch.rs new file mode 100644 index 0000000..1b9cf42 --- /dev/null +++ b/src/games/wuwa/config/schema/patch.rs @@ -0,0 +1,35 @@ +use std::path::PathBuf; + +use serde::{Serialize, Deserialize}; +use serde_json::Value as JsonValue; + +use crate::wuwa::consts::launcher_dir; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Patch { + pub path: PathBuf +} + +impl Default for Patch { + #[inline] + fn default() -> Self { + let launcher_dir = launcher_dir().expect("Failed to get launcher dir"); + + Self { + path: launcher_dir.join("patch") + } + } +} + +impl From<&JsonValue> for Patch { + fn from(value: &JsonValue) -> Self { + let default = Self::default(); + + Self { + path: match value.get("path").and_then(|path| path.as_str()).map(PathBuf::from) { + Some(path) => path, + None => default.path + } + } + } +} diff --git a/src/games/wuwa/consts.rs b/src/games/wuwa/consts.rs new file mode 100644 index 0000000..fef4b61 --- /dev/null +++ b/src/games/wuwa/consts.rs @@ -0,0 +1,36 @@ +use std::path::PathBuf; + +pub const FOLDER_NAME: &str = "wavey-launcher"; + +/// Get default launcher dir path +/// +/// If `LAUNCHER_FOLDER` variable is set, then its value will be returned. Otherwise return `$HOME/.local/share/wavey-launcher` +pub fn launcher_dir() -> anyhow::Result { + if let Ok(folder) = std::env::var("LAUNCHER_FOLDER") { + return Ok(folder.into()); + } + + Ok(std::env::var("XDG_DATA_HOME") + .or_else(|_| std::env::var("HOME").map(|home| home + "/.local/share")) + .map(|home| PathBuf::from(home).join(FOLDER_NAME))?) +} + +/// Get launcher's cache dir path +/// +/// If `CACHE_FOLDER` variable is set, then its value will be returned. Otherwise return `$HOME/.cache/wavey-launcher` +pub fn cache_dir() -> anyhow::Result { + if let Ok(folder) = std::env::var("CACHE_FOLDER") { + return Ok(folder.into()); + } + + Ok(std::env::var("XDG_CACHE_HOME") + .or_else(|_| std::env::var("HOME").map(|home| home + "/.cache")) + .map(|home| PathBuf::from(home).join(FOLDER_NAME))?) +} + +/// Get config file path +/// +/// Default is `$HOME/.local/share/wavey-launcher/config.json` +pub fn config_file() -> anyhow::Result { + launcher_dir().map(|dir| dir.join("config.json")) +} diff --git a/src/games/wuwa/game.rs b/src/games/wuwa/game.rs new file mode 100644 index 0000000..eeae0ed --- /dev/null +++ b/src/games/wuwa/game.rs @@ -0,0 +1,277 @@ +use std::process::{Command, Stdio}; +use std::path::PathBuf; + +use anime_game_core::wuwa::telemetry; + +use crate::components::wine::Bundle as WineBundle; + +use crate::config::ConfigExt; +use crate::wuwa::config::Config; + +use crate::config::schema_blanks::prelude::{ + WineDrives, + AllowedDrives +}; + +use crate::wuwa::consts; + +#[cfg(feature = "discord-rpc")] +use crate::discord_rpc::*; + +#[cfg(feature = "sessions")] +use crate::{ + sessions::SessionsExt, + wuwa::sessions::Sessions +}; + +#[derive(Debug, Clone)] +struct Folders { + pub wine: PathBuf, + pub prefix: PathBuf, + pub game: PathBuf, + pub temp: PathBuf +} + +fn replace_keywords(command: impl ToString, folders: &Folders) -> String { + command.to_string() + .replace("%build%", folders.wine.to_str().unwrap()) + .replace("%prefix%", folders.prefix.to_str().unwrap()) + .replace("%temp%", folders.game.to_str().unwrap()) + .replace("%launcher%", &consts::launcher_dir().unwrap().to_string_lossy()) + .replace("%game%", folders.temp.to_str().unwrap()) +} + +/// Try to run the game +/// +/// This function will freeze thread it was called from while the game is running +#[tracing::instrument(level = "info", ret)] +pub fn run() -> anyhow::Result<()> { + tracing::info!("Preparing to run the game"); + + let config = Config::get()?; + + if !config.game.path.exists() { + return Err(anyhow::anyhow!("Game is not installed")); + } + + let Some(wine) = config.get_selected_wine()? else { + anyhow::bail!("Couldn't find wine executable"); + }; + + let features = wine.features(&config.components.path)?.unwrap_or_default(); + + let mut folders = Folders { + wine: config.game.wine.builds.join(&wine.name), + prefix: config.game.wine.prefix.clone(), + game: config.game.path.clone(), + temp: config.launcher.temp.clone().unwrap_or(std::env::temp_dir()) + }; + + // Check telemetry servers + + tracing::info!("Checking telemetry"); + + if let Ok(Some(server)) = telemetry::is_disabled() { + return Err(anyhow::anyhow!("Telemetry server is not disabled: {server}")); + } + + // Prepare wine prefix drives + + config.game.wine.drives.map_folders(&folders.game, &folders.prefix)?; + + // Workaround for sandboxing feature + if config.sandbox.enabled { + WineDrives::map_folder(&folders.prefix, AllowedDrives::C, "../drive_c")?; + WineDrives::map_folder(&folders.prefix, AllowedDrives::Z, "/")?; + } + + // Prepare bash -c '' + // %command% = %bash_command% %windows_command% %launch_args% + + let mut bash_command = String::new(); + let mut windows_command = String::new(); + let mut launch_args = String::new(); + + if config.game.enhancements.gamemode { + bash_command += "gamemoderun "; + } + + let run_command = features.command + .map(|command| replace_keywords(command, &folders)) + .unwrap_or(format!("'{}'", folders.wine.join(wine.files.wine64.unwrap_or(wine.files.wine)).to_string_lossy())); + + bash_command += &run_command; + bash_command += " "; + + if let Some(virtual_desktop) = config.game.wine.virtual_desktop.get_command("pgr") { + windows_command += &virtual_desktop; + windows_command += " "; + } + + windows_command += "PGR.exe "; + + if config.game.wine.borderless { + launch_args += "-screen-fullscreen 0 -popupwindow "; + } + + // https://notabug.org/Krock/dawn/src/master/TWEAKS.md + if config.game.enhancements.fsr.enabled { + launch_args += "-window-mode exclusive "; + } + + // gamescope -- + if let Some(gamescope) = config.game.enhancements.gamescope.get_command() { + bash_command = format!("{gamescope} -- {bash_command}"); + } + + // Bundle all windows arguments used to run the game into a single file + if features.compact_launch { + std::fs::write(folders.game.join("compact_launch.bat"), format!("start {windows_command} {launch_args}\nexit"))?; + + windows_command = String::from("compact_launch.bat"); + launch_args = String::new(); + } + + // bwrap -- + #[cfg(feature = "sandbox")] + if config.sandbox.enabled { + let bwrap = config.sandbox.get_command( + folders.wine.to_str().unwrap(), + folders.prefix.to_str().unwrap(), + folders.game.to_str().unwrap() + ); + + let sandboxed_folders = Folders { + wine: PathBuf::from("/tmp/sandbox/wine"), + prefix: PathBuf::from("/tmp/sandbox/prefix"), + game: PathBuf::from("/tmp/sandbox/game"), + temp: PathBuf::from("/tmp") + }; + + bash_command = bash_command + .replace(folders.wine.to_str().unwrap(), sandboxed_folders.wine.to_str().unwrap()) + .replace(folders.prefix.to_str().unwrap(), sandboxed_folders.prefix.to_str().unwrap()) + .replace(folders.game.to_str().unwrap(), sandboxed_folders.game.to_str().unwrap()) + .replace(folders.temp.to_str().unwrap(), sandboxed_folders.temp.to_str().unwrap()); + + windows_command = windows_command + .replace(folders.wine.to_str().unwrap(), sandboxed_folders.wine.to_str().unwrap()) + .replace(folders.prefix.to_str().unwrap(), sandboxed_folders.prefix.to_str().unwrap()) + .replace(folders.game.to_str().unwrap(), sandboxed_folders.game.to_str().unwrap()) + .replace(folders.temp.to_str().unwrap(), sandboxed_folders.temp.to_str().unwrap()); + + bash_command = format!("{bwrap} --chdir /tmp/sandbox/game -- {bash_command}"); + folders = sandboxed_folders; + } + + // Finalize launching command + bash_command = match &config.game.command { + // Use user-given launch command + Some(command) => replace_keywords(command, &folders) + .replace("%command%", &format!("{bash_command} {windows_command} {launch_args}")) + .replace("%bash_command%", &bash_command) + .replace("%windows_command%", &windows_command) + .replace("%launch_args%", &launch_args), + + // Combine bash and windows parts of the command + None => format!("{bash_command} {windows_command} {launch_args}") + }; + + let mut command = Command::new("bash"); + + command.arg("-c"); + command.arg(&bash_command); + + // Setup environment + + command.env("WINEARCH", "win64"); + command.env("WINEPREFIX", &folders.prefix); + + // Add environment flags for selected wine + for (key, value) in features.env.into_iter() { + command.env(key, replace_keywords(value, &folders)); + } + + // Add environment flags for selected dxvk + if let Ok(Some(dxvk )) = config.get_selected_dxvk() { + if let Ok(Some(features)) = dxvk.features(&config.components.path) { + for (key, value) in features.env.iter() { + command.env(key, replace_keywords(value, &folders)); + } + } + } + + let mut wine_folder = folders.wine.clone(); + + if features.bundle == Some(WineBundle::Proton) { + wine_folder.push("files"); + } + + command.envs(config.game.enhancements.hud.get_env_vars(config.game.enhancements.gamescope.enabled)); + command.envs(config.game.enhancements.fsr.get_env_vars()); + + command.envs(config.game.wine.sync.get_env_vars()); + command.envs(config.game.wine.language.get_env_vars()); + command.envs(config.game.wine.shared_libraries.get_env_vars(wine_folder)); + + command.envs(&config.game.environment); + + #[cfg(feature = "sessions")] + if let Some(current) = Sessions::get_current()? { + Sessions::apply(current, config.get_wine_prefix_path())?; + } + + // Run command + + let variables = command + .get_envs() + .map(|(key, value)| format!("{}=\"{}\"", key.to_string_lossy(), value.unwrap_or_default().to_string_lossy())) + .fold(String::new(), |acc, env| acc + " " + &env); + + tracing::info!("Running the game with command: {variables} bash -c \"{bash_command}\""); + + // We use real current dir here because sandboxed one + // obviously doesn't exist + command.current_dir(&config.game.path) + .spawn()?.wait_with_output()?; + + #[cfg(feature = "discord-rpc")] + let rpc = if config.launcher.discord_rpc.enabled { + Some(DiscordRpc::new(config.launcher.discord_rpc.clone().into())) + } else { + None + }; + + #[cfg(feature = "discord-rpc")] + if let Some(rpc) = &rpc { + rpc.update(RpcUpdates::Connect)?; + } + + loop { + std::thread::sleep(std::time::Duration::from_secs(3)); + + let output = Command::new("ps").arg("-A").stdout(Stdio::piped()).output()?; + let output = String::from_utf8_lossy(&output.stdout); + + if !output.contains("PGR.exe") { + break; + } + + #[cfg(feature = "discord-rpc")] + if let Some(rpc) = &rpc { + rpc.update(RpcUpdates::Update)?; + } + } + + #[cfg(feature = "discord-rpc")] + if let Some(rpc) = &rpc { + rpc.update(RpcUpdates::Disconnect)?; + } + + #[cfg(feature = "sessions")] + if let Some(current) = Sessions::get_current()? { + Sessions::update(current, config.get_wine_prefix_path())?; + } + + Ok(()) +} diff --git a/src/games/wuwa/mod.rs b/src/games/wuwa/mod.rs new file mode 100644 index 0000000..de66d04 --- /dev/null +++ b/src/games/wuwa/mod.rs @@ -0,0 +1,13 @@ +pub mod consts; + +#[cfg(feature = "config")] +pub mod config; + +#[cfg(feature = "states")] +pub mod states; + +#[cfg(feature = "game")] +pub mod game; + +#[cfg(feature = "sessions")] +pub mod sessions; diff --git a/src/games/wuwa/sessions.rs b/src/games/wuwa/sessions.rs new file mode 100644 index 0000000..9d93b81 --- /dev/null +++ b/src/games/wuwa/sessions.rs @@ -0,0 +1,95 @@ +use std::path::{Path, PathBuf}; + +use serde::{Serialize, Deserialize}; + +use crate::sessions::{ + SessionsExt, + Sessions as SessionsDescriptor +}; + +use super::consts::launcher_dir; + +/// Get default sessions file path +/// +/// `$HOME/.local/share/wavey-launcher/sessions.json` +#[inline] +pub fn sessions_file() -> anyhow::Result { + launcher_dir().map(|dir| dir.join("sessions.json")) +} + +// FIXME: update registry entries names + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionData { + // [Software\\kurogame\\PGR] + pub game_reg: String +} + +pub struct Sessions; + +impl SessionsExt for Sessions { + type SessionData = SessionData; + + fn get_sessions() -> anyhow::Result> { + let path = sessions_file()?; + + if !path.exists() { + tracing::warn!("Session file doesn't exist. Returning default value"); + + return Ok(SessionsDescriptor::default()); + } + + Ok(serde_json::from_slice(&std::fs::read(path)?)?) + } + + fn set_sessions(sessions: SessionsDescriptor) -> anyhow::Result<()> { + Ok(std::fs::write(sessions_file()?, serde_json::to_string_pretty(&sessions)?)?) + } + + fn update(name: String, prefix: impl AsRef) -> anyhow::Result<()> { + let mut sessions = Self::get_sessions()?; + + tracing::info!("Updating session '{name}' from prefix: {:?}", prefix.as_ref()); + + let mut new_session = Self::SessionData { + game_reg: String::new() + }; + + for entry in std::fs::read_to_string(prefix.as_ref().join("user.reg"))?.split("\n\n") { + if entry.starts_with("[Software\\\\kurogame\\\\PGR]") { + new_session.game_reg = entry.to_owned(); + } + } + + sessions.sessions.insert(name, new_session); + + Self::set_sessions(sessions) + } + + fn apply(name: String, prefix: impl AsRef) -> anyhow::Result<()> { + let sessions = Self::get_sessions()?; + + let Some(session) = sessions.sessions.get(&name) else { + anyhow::bail!("Session with given name doesn't exist"); + }; + + tracing::info!("Applying session '{name}' to prefix: {:?}", prefix.as_ref()); + + let entries: String = std::fs::read_to_string(prefix.as_ref().join("user.reg"))? + .split("\n\n") + .map(|entry| { + let new_entry = if entry.starts_with("[Software\\\\kurogame\\\\PGR]") { + session.game_reg.clone() + } + + else { + entry.to_owned() + }; + + new_entry + "\n\n" + }) + .collect(); + + Ok(std::fs::write(prefix.as_ref().join("user.reg"), format!("{}\n", entries.trim_end()))?) + } +} diff --git a/src/games/wuwa/states.rs b/src/games/wuwa/states.rs new file mode 100644 index 0000000..bd58c41 --- /dev/null +++ b/src/games/wuwa/states.rs @@ -0,0 +1,141 @@ +use std::path::PathBuf; + +use wincompatlib::wine::ext::Font; + +use anime_game_core::prelude::*; +use anime_game_core::wuwa::prelude::*; + +use crate::config::ConfigExt; + +#[derive(Debug, Clone)] +pub enum LauncherState { + Launch, + + #[cfg(feature = "components")] + WineNotInstalled, + + PrefixNotExists, + + FontsNotInstalled(Vec), + + TelemetryNotDisabled, + + // Always contains `VersionDiff::Diff` + GameUpdateAvailable(VersionDiff), + + /// Always contains `VersionDiff::NotInstalled` + GameNotInstalled(VersionDiff) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StateUpdating { + Components, + Game +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LauncherStateParams { + pub wine_prefix: PathBuf, + pub game_path: PathBuf, + pub fast_verify: bool, + pub status_updater: F +} + +impl LauncherState { + pub fn get(params: LauncherStateParams) -> anyhow::Result { + tracing::debug!("Trying to get launcher state"); + + // Check prefix existence + if !params.wine_prefix.join("drive_c").exists() { + return Ok(Self::PrefixNotExists); + } + + // Check wine components installation status + (params.status_updater)(StateUpdating::Components); + + let mut fonts = Vec::new(); + + // In future, wincompatlib's Font might contain fonts that won't be actually needed + // That's why I listed only needed fonts here + const COREFONTS: &[Font] = &[ + Font::Andale, + Font::Arial, + Font::Courier, + Font::Georgia, + Font::Impact, + Font::Times, + Font::Trebuchet, + Font::Verdana, + Font::Webdings, + + // Who even needs it? + Font::ComicSans + ]; + + for font in COREFONTS.iter().copied() { + if !font.is_installed(¶ms.wine_prefix) { + fonts.push(font); + } + } + + if !fonts.is_empty() { + return Ok(Self::FontsNotInstalled(fonts)); + } + + // Check telemetry servers + let disabled = telemetry::is_disabled() + + // Return true if there's no domain name resolved, or false otherwise + .map(|result| result.is_none()) + + // And return true if there's an error happened during domain name resolving + // FIXME: might not be a good idea? Idk + .unwrap_or_else(|err| { + tracing::warn!("Failed to check telemetry servers: {err}. Assuming they're disabled"); + + true + }); + + if !disabled { + return Ok(Self::TelemetryNotDisabled); + } + + // Check game installation status + (params.status_updater)(StateUpdating::Game); + + let game = Game::new(¶ms.game_path, ()) + .with_fast_verify(params.fast_verify); + + let diff = game.try_get_diff()?; + + match diff { + VersionDiff::Latest(_) => Ok(Self::Launch), + VersionDiff::Outdated { .. } => Ok(Self::GameUpdateAvailable(diff)), + VersionDiff::NotInstalled { .. } => Ok(Self::GameNotInstalled(diff)) + } + } + + #[cfg(feature = "config")] + pub fn get_from_config(status_updater: T) -> anyhow::Result { + tracing::debug!("Trying to get launcher state"); + + let config = crate::wuwa::config::Config::get()?; + + match &config.game.wine.selected { + #[cfg(feature = "components")] + Some(selected) if !config.game.wine.builds.join(selected).exists() => return Ok(Self::WineNotInstalled), + + None => return Ok(Self::WineNotInstalled), + + _ => () + } + + Self::get(LauncherStateParams { + wine_prefix: config.get_wine_prefix_path(), + game_path: config.game.path, + fast_verify: config.launcher.repairer.fast, + + status_updater + }) + } +} diff --git a/src/lib.rs b/src/lib.rs index 1cc1884..c60ac16 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -17,6 +17,9 @@ pub use games::honkai; #[cfg(feature = "pgr")] pub use games::pgr; +#[cfg(feature = "wuwa")] +pub use games::wuwa; + #[cfg(feature = "config")] pub mod config;