forked from akanyan/STARTLINER
511 lines
18 KiB
Rust
511 lines
18 KiB
Rust
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<Self> {
|
|
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<Self> {
|
|
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::<ProfileData>(&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<PkgKey> {
|
|
&self.data.mods
|
|
}
|
|
pub fn mod_pkgs_mut(&mut self) -> &mut BTreeSet<PkgKey> {
|
|
&mut self.data.mods
|
|
}
|
|
pub fn special_pkgs(&self) -> Vec<PkgKey> {
|
|
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<Option<DisplayInfo>> {
|
|
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<Path>, new_hash: &str) -> Result<bool> {
|
|
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<Vec<ProfileMeta>> {
|
|
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<Path>) -> Option<ProfileMeta> {
|
|
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
|
|
} |