pub use types::{Profile, ProfileData, ProfileMeta, ProfilePaths, StartPayload}; use std::{collections::{BTreeMap, BTreeSet}, path::{Path, PathBuf}}; use crate::{model::{misc::Game, patch::{PatchList, PatchSelection}, profile::{Aime, ChunithmKeyboard, IOSelection, Keyboard, Mu3Ini, OngekiKeyboard, ProfileModule}}, modules::{display_windows::DisplayInfo, package::prepare_packages}, pkg::PkgKey, pkg_store::PackageStore, util}; use tauri::Emitter; use std::process::Stdio; use crate::model::profile::BepInEx; use crate::model::profile::{Display, DisplayMode, Network, Segatools}; use anyhow::{anyhow, Result}; use std::fs::File; use tokio::process::Command; use tokio::task::JoinSet; pub mod template; pub mod types; impl Profile { pub fn new(mut meta: ProfileMeta) -> Result { meta.name = fixed_name(&meta, true); log::debug!("created profile-{:?}", &meta); let p = Profile { data: ProfileData { mods: BTreeSet::new(), sgt: Segatools::default_for(meta.game), #[cfg(target_os = "windows")] display: Some(Display::default_for(meta.game)), #[cfg(not(target_os = "windows"))] display: None, network: Network::default(), bepinex: if meta.game == Game::Ongeki { Some(BepInEx::default()) } else { None }, #[cfg(not(target_os = "windows"))] wine: crate::model::profile::Wine::default(), mu3_ini: if meta.game == Game::Ongeki { Some(Mu3Ini::default()) } else { None }, keyboard: if meta.game == Game::Ongeki { Some(Keyboard::Ongeki(OngekiKeyboard::default())) } else { Some(Keyboard::Chunithm(ChunithmKeyboard::default())) }, patches: Some(PatchSelection(BTreeMap::new())) }, meta: meta.clone() }; p.save()?; std::fs::create_dir_all(p.config_dir())?; std::fs::create_dir_all(p.data_dir())?; match meta.game { Game::Ongeki => std::fs::write(p.config_dir().join("segatools-base.ini"), include_bytes!("../../static/segatools-ongeki.ini"))?, Game::Chunithm => std::fs::write(p.config_dir().join("segatools-base.ini"), include_bytes!("../../static/segatools-chunithm.ini"))?, }; Ok(p) } pub fn load(game: Game, name: String) -> Result { let path = util::profile_config_dir(game, &name).join("profile.json"); if let Ok(s) = std::fs::read_to_string(&path) { let mut data = serde_json::from_str::(&s) .map_err(|e| anyhow!("Unable to parse {:?}: {:?}", path, e))?; log::debug!("{:?}", data); // Backwards compat if game == Game::Ongeki { if data.keyboard.is_none() { data.keyboard = Some(Keyboard::Ongeki(OngekiKeyboard::default())); } if let Some(io) = data.sgt.io { data.sgt.io2 = IOSelection::Custom(io); data.sgt.io = None; } if let Some(ini) = &mut data.mu3_ini { if ini.audio.is_none() { ini.audio = Some(crate::model::profile::Mu3Audio::Shared); } if ini.blacklist.is_none() { ini.blacklist = Some((10000, 19999)); } } else { data.mu3_ini = Some(Mu3Ini::default()); } if data.patches.is_none() { data.patches = Some(PatchSelection(BTreeMap::new())); } Self::load_existing_mu3_ini(&data, &ProfileMeta { game, name: name.clone() })?; } if game == Game::Chunithm { if data.keyboard.is_none() { data.keyboard = Some(Keyboard::Chunithm(ChunithmKeyboard::default())); } if data.patches.is_none() { data.patches = Some(PatchSelection(BTreeMap::new())); } if data.display.is_none() { data.display = Some(Display::default_for(Game::Chunithm)); } } Ok(Profile { meta: ProfileMeta { game, name }, data }) } else { Err(anyhow!("Unable to open {:?}", path)) } } pub fn save(&self) -> Result<()> { let path = self.config_dir().join("profile.json"); let s = serde_json::to_string_pretty(&self.data)?; if !self.config_dir().exists() { std::fs::create_dir(self.config_dir()) .map_err(|e| anyhow!("error when creating profile directory: {}", e))?; } std::fs::write(&path, s) .map_err(|e| anyhow!("error when writing to {:?}: {}", path, e))?; log::info!("profile saved to {:?}", path); Ok(()) } pub fn rename(&mut self, name: String) { self.meta.name = fixed_name(&ProfileMeta { game: self.meta.game, name}, false); } pub fn mod_pkgs(&self) -> &BTreeSet { &self.data.mods } pub fn mod_pkgs_mut(&mut self) -> &mut BTreeSet { &mut self.data.mods } pub fn special_pkgs(&self) -> Vec { let mut res = Vec::new(); if let Some(hook) = &self.data.sgt.hook { res.push(hook.clone()); } if let IOSelection::Custom(io) = &self.data.sgt.io2 { res.push(io.clone()); } if let Aime::AMNet(aime) = &self.data.sgt.aime { res.push(aime.clone()); } else if let Aime::Other(aime) = &self.data.sgt.aime { res.push(aime.clone()); } res } pub fn fix(&mut self, store: &PackageStore) { self.data.sgt.fix(store); } pub fn sync(&mut self, source: ProfileData) { if self.meta.game.has_module(ProfileModule::BepInEx) && source.bepinex.is_some() { self.data.bepinex = source.bepinex; } if self.meta.game.has_module(ProfileModule::Display) && source.display.is_some() { self.data.display = source.display; } if self.meta.game.has_module(ProfileModule::Network) { self.data.network = source.network; } if self.meta.game.has_module(ProfileModule::Segatools) { self.data.sgt = source.sgt; } if self.meta.game.has_module(ProfileModule::Mu3Ini) && source.mu3_ini.is_some() { self.data.mu3_ini = source.mu3_ini; } if self.meta.game.has_module(ProfileModule::Keyboard) && source.keyboard.is_some() { self.data.keyboard = source.keyboard; } if self.data.patches.is_some() && source.patches.is_some() { self.data.patches = source.patches; } } pub fn prepare_display(&self) -> Result> { let info = match &self.data.display { None => None, Some(display) => display.prepare()? }; Ok(info) } pub async fn line_up(&self, pkg_hash: String, refresh: bool, patchlists_enabled: Vec<&PatchList>) -> Result<()> { if !self.data_dir().exists() { tokio::fs::create_dir(self.data_dir()).await?; } let hash_path = self.data_dir().join(".sl-state"); util::clean_up_opts(self.data_dir().join("option"))?; let hash_check = Self::hash_check(&hash_path, &pkg_hash).await? || refresh; prepare_packages(&self.meta, &self.data.mods, hash_check).await .map_err(|e| anyhow!("package configuration failed:\n{:?}", e))?; let mut ini = self.data.sgt.line_up(&self.meta, self.meta.game).await .map_err(|e| anyhow!("segatools configuration failed:\n{:?}", e))?; self.data.network.line_up(&mut ini)?; if let Some(display) = &self.data.display { display.line_up(self.meta.game, &mut ini); } if let Some(keyboard) = &self.data.keyboard { keyboard.line_up(&mut ini)?; } ini.write_to_file(self.data_dir().join("segatools.ini")) .map_err(|e| anyhow!("Error writing segatools.ini: {}", e))?; if let Some(bepinex) = &self.data.bepinex { bepinex.line_up(&self.meta)?; } if let Some(mu3ini) = &self.data.mu3_ini { mu3ini.line_up(&self.data_dir(), &self.config_dir())?; } if let Some(patches) = &self.data.patches { futures::try_join!( patches.render_to_file("amdaemon.exe", &patchlists_enabled, self.data_dir().join("patch-amd.mph")), patches.render_to_file("chusanApp.exe", &patchlists_enabled, self.data_dir().join("patch-game.mph")) )?; } Ok(()) } pub async fn start(&self, payload: StartPayload) -> Result<()> { let ini_path = self.data_dir().join("segatools.ini"); log::debug!("With path {:?}", ini_path); let mut game_builder; let mut amd_builder; let target_path = PathBuf::from(&self.data.sgt.target); let exe_dir = target_path.parent().ok_or_else(|| anyhow!("Invalid target path"))?; let sgt_dir = self.data.sgt.hook_dir()?; #[cfg(target_os = "windows")] { game_builder = Command::new(sgt_dir.join(self.meta.game.inject_exe())); amd_builder = Command::new("cmd.exe"); } #[cfg(target_os = "linux")] { game_builder = Command::new(&self.wine.runtime); amd_builder = Command::new(&self.wine.runtime); game_builder.arg(sgt_dir.join(self.meta.game.inject_exe())); amd_builder.arg("cmd.exe"); } amd_builder.env( "SEGATOOLS_CONFIG_PATH", &ini_path, ) .current_dir(&exe_dir) .raw_arg("/C") .arg(&sgt_dir.join(self.meta.game.inject_amd())) .raw_arg("-d"); for dll in payload.amd_dlls { amd_builder.raw_arg("-k"); amd_builder.arg(dll); } amd_builder .raw_arg("-k") .arg(sgt_dir.join(self.meta.game.hook_amd())) .arg("amdaemon.exe") .args(self.meta.game.amd_args()); amd_builder.arg(self.data_dir().join("config_hook.json")); game_builder .env( "SEGATOOLS_CONFIG_PATH", ini_path, ) .env( "INOHARA_CONFIG_PATH", self.config_dir().join("inohara.cfg"), ) .env( "SAEKAWA_CONFIG_PATH", self.config_dir().join("saekawa.toml"), ) .env( "ONGEKI_LANG_PATH", self.data_dir().join("lang"), ) .env( "MU3_MODS_CONFIG_PATH", self.config_dir().join("mu3.ini"), ) .env( "STARTLINER", "1" ) .current_dir(&exe_dir) .raw_arg("-d") .raw_arg("-k") .arg(sgt_dir.join(self.meta.game.hook_exe())); for dll in payload.game_dlls { game_builder.raw_arg("-k"); game_builder.arg(dll); } game_builder.arg(self.meta.game.exe()); if self.meta.game.has_module(ProfileModule::BepInEx) { if let Some(display) = &self.data.display { if display.dont_switch_primary && display.target != "default" { game_builder.args(["-monitor", &display.monitor_index_override.unwrap_or_else(|| 1).to_string()]); } else { game_builder.args(["-monitor", "1"]); } game_builder.args([ "-screen-width", &display.rez.0.to_string(), "-screen-height", &display.rez.1.to_string(), "-screen-fullscreen", if display.mode == DisplayMode::Fullscreen { "1" } else { "0" } ]); if display.mode == DisplayMode::Borderless { game_builder.arg("-popupwindow"); } } } if self.meta.game.has_module(ProfileModule::Mempatcher) { amd_builder .env("MEMPATCHER_PATCH_PATH", self.data_dir().join("patch-amd.mph")) .env("MEMPATCHER_LOG_PATH", self.data_dir().join("mempatcher-amdaemon.log")); game_builder .raw_arg("--mempatch") .arg(self.data_dir().join("patch-game.mph")) .env("MEMPATCHER_LOG_PATH", self.data_dir().join("mempatcher-game.log")); } #[cfg(target_os = "linux")] { amd_builder.env("WINEPREFIX", &self.wine.prefix); game_builder.env("WINEPREFIX", &self.wine.prefix); } let amd_log = File::create(self.data_dir().join("amdaemon.exe.log"))?; let game_log = File::create(self.data_dir().join(format!("{}.log", self.meta.game.exe())))?; amd_builder .stdout(Stdio::from(amd_log)); // do they use stderr? game_builder .stdout(Stdio::from(game_log)); #[cfg(target_os = "windows")] { amd_builder.creation_flags(util::CREATE_NO_WINDOW); game_builder.creation_flags(util::CREATE_NO_WINDOW); } if self.data.sgt.intel == true { amd_builder.env("OPENSSL_ia32cap", ":~0x20000000"); } util::pkill("amdaemon.exe").await; log::info!("launching amdaemon: {:?}", amd_builder); log::info!("launching {}: {:?}", self.meta.game, game_builder); let mut amd = amd_builder.spawn()?; let mut game = game_builder.spawn()?; let mut set = JoinSet::new(); set.spawn(async move { (amd.wait().await.expect("amdaemon failed to run"), "amdaemon") }); set.spawn(async move { (game.wait().await.expect("game failed to run"), "game") }); if let Err(e) = payload.app.emit("launch-start", "") { log::warn!("Unable to emit launch-start: {}", e); } let (rc, process) = set.join_next().await.expect("No spawn").expect("No result"); log::info!("{} died with return code {}", process, rc); if process == "amdaemon" { util::pkill(self.meta.game.exe()).await; } else { util::pkill("amdaemon.exe").await; } set.join_next().await.expect("No spawn").expect("No result"); log::debug!("Fin"); if let Err(e) = payload.app.emit("launch-end", "") { log::warn!("Unable to emit launch-end: {}", e); } Ok(()) } async fn hash_check(prev_hash_path: &impl AsRef, new_hash: &str) -> Result { let prev_hash = tokio::fs::read_to_string(&prev_hash_path).await.unwrap_or_default(); if prev_hash != new_hash { log::debug!("state {} -> {}", prev_hash, new_hash); tokio::fs::write(prev_hash_path, new_hash).await .map_err(|e| anyhow!("Unable to write the state file: {}", e))?; Ok(true) } else { Ok(false) } } fn load_existing_mu3_ini(data: &ProfileData, meta: &ProfileMeta) -> Result<()> { if let Some(parent) = data.sgt.target.parent() { let mu3_ini_target_path = parent.join("mu3.ini"); let mu3_ini_profile_path = util::profile_config_dir(meta.game, &meta.name).join("mu3.ini"); log::debug!("mu3.ini paths: {:?} {:?}", mu3_ini_target_path, mu3_ini_profile_path); if mu3_ini_target_path.exists() && !mu3_ini_profile_path.exists() { std::fs::copy(&mu3_ini_target_path, &mu3_ini_profile_path)?; log::info!("copied mu3.ini from {:?}", &mu3_ini_target_path); } } Ok(()) } } impl ProfilePaths for Profile { fn config_dir(&self) -> PathBuf { self.meta.config_dir() } fn data_dir(&self) -> PathBuf { self.meta.data_dir() } } impl std::fmt::Debug for Profile { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_tuple(&self.meta.game.to_string()).field(&self.meta.name).finish() } } pub async fn list_profiles() -> Result> { let path = std::fs::read_dir(util::config_dir())?; let mut res = Vec::new(); for f in path { let f = f?; if let Ok(meta) = f.metadata() { if !meta.is_dir() { continue; } log::debug!("{:?}", f); if let Some(meta) = meta_from_path(f.path()) { res.push(meta); } } } Ok(res) } fn meta_from_path(path: impl AsRef) -> Option { let regex = regex::Regex::new( r"^profile-([^\-]+)-(.+)$" ).expect("Invalid regex"); let fname = path.as_ref().file_name().unwrap_or_default().to_string_lossy(); if let Some(caps) = regex.captures(&fname) { let game = caps.get(1).unwrap().as_str(); let name = caps.get(2).unwrap().as_str().to_owned(); if let Some(game) = Game::from_str(game) { return Some(ProfileMeta { game, name }); } } None } pub fn fixed_name(meta: &ProfileMeta, prepend_new: bool) -> String { let mut name = meta.name.trim() .replace(" ", "-") .replace("..", "").replace("/", "").replace("\\", ""); while prepend_new && util::profile_config_dir(meta.game, &name).exists() { name = format!("new-{}", name); } name }