feat: initial chunithm support

This commit is contained in:
2025-03-19 17:39:12 +00:00
parent 1191cdd95c
commit 8ac45df3e1
31 changed files with 1368 additions and 884 deletions

View File

@ -7,6 +7,11 @@
"core:default",
"opener:default",
"core:window:allow-close",
"core:window:allow-minimize",
"core:window:allow-unminimize",
"core:window:allow-set-focus",
"core:window:allow-hide",
"core:window:allow-show",
"core:app:allow-app-hide",
"shell:default",
"dialog:default",

View File

@ -1,7 +1,7 @@
use std::hash::{DefaultHasher, Hash, Hasher};
use crate::model::config::GlobalConfig;
use crate::pkg::{Feature, Status};
use crate::profiles::AnyProfile;
use crate::profiles::Profile;
use crate::{model::misc::Game, pkg::PkgKey};
use crate::pkg_store::PackageStore;
use crate::util;
@ -13,7 +13,7 @@ pub struct GlobalState {
}
pub struct AppData {
pub profile: Option<AnyProfile>,
pub profile: Option<Profile>,
pub pkgs: PackageStore,
pub cfg: GlobalConfig,
pub state: GlobalState,
@ -33,7 +33,7 @@ impl AppData {
.unwrap_or_default();
let profile = match cfg.recent_profile {
Some((ref game, ref name)) => AnyProfile::load(game.clone(), name.clone()).ok(),
Some((game, ref name)) => Profile::load(game, name.clone()).ok(),
None => None
};
@ -52,7 +52,7 @@ impl AppData {
}
pub fn switch_profile(&mut self, game: Game, name: String) -> Result<()> {
match AnyProfile::load(game.clone(), name.clone()) {
match Profile::load(game.clone(), name.clone()) {
Ok(profile) => {
self.profile = Some(profile);
self.cfg.recent_profile = Some((game, name));
@ -103,7 +103,7 @@ impl AppData {
Ok(())
}
pub fn sum_packages(&self, p: &AnyProfile) -> String {
pub fn sum_packages(&self, p: &Profile) -> String {
let mut hasher = DefaultHasher::new();
for key in p.mod_pkgs().into_iter() {
if let Ok(pkg) = self.pkgs.get(&key) {

View File

@ -6,8 +6,7 @@ use tauri::{AppHandle, Manager, State};
use crate::model::misc::Game;
use crate::pkg::{Package, PkgKey};
use crate::pkg_store::{InstallResult, PackageStore};
use crate::profiles::ongeki::OngekiProfile;
use crate::profiles::{self, AnyProfile, Profile, ProfileMeta, ProfilePaths};
use crate::profiles::{self, Profile, ProfileData, ProfileMeta, ProfilePaths};
use crate::appdata::{AppData, ToggleAction};
use crate::model::misc::StartCheckError;
use crate::util;
@ -147,10 +146,21 @@ pub async fn get_all_packages(state: State<'_, Mutex<AppData>>) -> Result<HashMa
Ok(appd.pkgs.get_all())
}
#[tauri::command]
pub async fn get_game_packages(state: State<'_, Mutex<AppData>>, game: Game) -> Result<Vec<PkgKey>, ()> {
log::debug!("invoke: get_game_packages {game}");
let appd = state.lock().await;
Ok(appd.pkgs.get_game_list(game))
}
#[tauri::command]
pub async fn fetch_listings(state: State<'_, Mutex<AppData>>) -> Result<(), String> {
log::debug!("invoke: fetch_listings");
// let game;
{
let appd = state.lock().await;
if !appd.pkgs.is_offline() {
@ -161,13 +171,26 @@ pub async fn fetch_listings(state: State<'_, Mutex<AppData>>) -> Result<(), Stri
log::info!("fetch_listings: skipped");
return Err("offline mode".to_owned());
}
// if let Some(profile) = &appd.profile {
// game = profile.meta.game;
// } else {
// return Err("No profile".to_owned());
// }
}
let listings = PackageStore::fetch_listings().await
// Can be this lazy for now as there are only two short lists
let listings1 = PackageStore::fetch_listings(Game::Ongeki).await
.map_err(|e| e.to_string())?;
let listings2 = PackageStore::fetch_listings(Game::Chunithm).await
.map_err(|e| e.to_string())?;
let mut appd = state.lock().await;
appd.pkgs.process_fetched_listings(listings);
appd.pkgs.process_fetched_listings(listings1, Game::Ongeki);
appd.pkgs.process_fetched_listings(listings2, Game::Chunithm);
appd.pkgs.save().await
.map_err(|e| e.to_string())?;
Ok(())
}
@ -189,15 +212,10 @@ pub async fn init_profile(
log::debug!("invoke: init_profile({}, {})", game, name);
let mut appd = state.lock().await;
let new_profile = OngekiProfile::new(name)
let new_profile = Profile::new(ProfileMeta { game, name })
.map_err(|e| format!("Unable to create profile: {}", e))?;
fs::create_dir_all(new_profile.config_dir()).await
.map_err(|e| format!("Unable to create the profile config directory: {}", e))?;
fs::create_dir_all(new_profile.data_dir()).await
.map_err(|e| format!("Unable to create the profile data directory: {}", e))?;
appd.profile = Some(AnyProfile::OngekiProfile(new_profile.clone()));
appd.profile = Some(new_profile);
Ok(())
}
@ -242,7 +260,7 @@ pub async fn rename_profile(
let mut appd = state.lock().await;
if let Some(current) = &mut appd.profile {
if current.meta() == profile {
if current.meta == profile {
current.rename(new_meta.name);
}
}
@ -277,7 +295,7 @@ pub async fn delete_profile(state: State<'_, Mutex<AppData>>, profile: ProfileMe
let mut appd = state.lock().await;
if let Some(current) = &mut appd.profile {
if current.meta() == profile {
if current.meta == profile {
appd.profile = None;
}
}
@ -286,7 +304,7 @@ pub async fn delete_profile(state: State<'_, Mutex<AppData>>, profile: ProfileMe
}
#[tauri::command]
pub async fn get_current_profile(state: State<'_, Mutex<AppData>>) -> Result<Option<AnyProfile>, ()> {
pub async fn get_current_profile(state: State<'_, Mutex<AppData>>) -> Result<Option<Profile>, ()> {
log::debug!("invoke: get_current_profile");
let appd = state.lock().await;
@ -294,12 +312,12 @@ pub async fn get_current_profile(state: State<'_, Mutex<AppData>>) -> Result<Opt
}
#[tauri::command]
pub async fn sync_current_profile(state: State<'_, Mutex<AppData>>, profile: AnyProfile) -> Result<(), String> {
pub async fn sync_current_profile(state: State<'_, Mutex<AppData>>, data: ProfileData) -> Result<(), String> {
log::debug!("invoke: sync_current_profile");
let mut appd = state.lock().await;
if let Some(p) = &mut appd.profile {
p.sync(profile);
p.sync(data);
}
Ok(())

View File

@ -69,8 +69,8 @@ pub async fn run(_args: Vec<String>) {
} else {
tauri::WebviewWindowBuilder::new(app, "main", tauri::WebviewUrl::App("index.html".into()))
.title("STARTLINER")
.inner_size(640f64, 480f64)
.min_inner_size(640f64, 480f64)
.inner_size(720f64, 480f64)
.min_inner_size(720f64, 480f64)
.build()?;
start_immediately = false;
}
@ -176,6 +176,7 @@ pub async fn run(_args: Vec<String>) {
cmd::get_package,
cmd::get_all_packages,
cmd::get_game_packages,
cmd::reload_all_packages,
cmd::fetch_listings,
cmd::install_package,

View File

@ -1,6 +1,8 @@
use std::collections::{BTreeMap, BTreeSet};
use serde::Deserialize;
use crate::pkg::PkgKeyVersion;
use serde::{Deserialize, Serialize};
use crate::pkg::{Status, PkgKey, PkgKeyVersion};
use super::misc::Game;
// manifest.json
@ -14,3 +16,12 @@ pub struct PackageManifest {
#[serde(default)]
pub installers: Vec<BTreeMap<String, serde_json::Value>>
}
pub type PackageList = BTreeMap<PkgKey, PackageListEntry>;
#[derive(Serialize, Deserialize, Clone)]
pub struct PackageListEntry {
pub version: String,
pub status: Status,
pub games: Vec<Game>,
}

View File

@ -1,7 +1,7 @@
use serde::{Deserialize, Serialize};
use crate::pkg::PkgKey;
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Copy)]
pub enum Game {
#[serde(rename = "ongeki")]
Ongeki,
@ -17,6 +17,48 @@ impl Game {
_ => None
}
}
pub fn hook_exe(&self) -> &'static str {
match self {
Game::Ongeki => "mu3hook.dll",
Game::Chunithm => "chusanhook_x86.dll",
}
}
pub fn hook_amd(&self) -> &'static str {
match self {
Game::Ongeki => "mu3hook.dll",
Game::Chunithm => "chusanhook_x64.dll",
}
}
pub fn inject_exe(&self) -> &'static str {
match self {
Game::Ongeki => "inject.exe",
Game::Chunithm => "inject_x86.exe",
}
}
pub fn inject_amd(&self) -> &'static str {
match self {
Game::Ongeki => "inject.exe",
Game::Chunithm => "inject_x64.exe",
}
}
pub fn exe(&self) -> &'static str {
match self {
Game::Ongeki => "mu3.exe",
Game::Chunithm => "chusanApp.exe",
}
}
pub fn amd_args(&self) -> Vec<&'static str> {
match self {
Game::Ongeki => vec!["-f", "-c", "config_common.json", "config_server.json", "config_client.json"],
Game::Chunithm => vec!["-c", "config_common.json", "config_server.json", "config_client.json", "config_cvt.json", "config_sp.json", "config_hook.json"]
}
}
}
impl std::fmt::Display for Game {

View File

@ -2,7 +2,9 @@ use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::pkg::PkgKey;
#[derive(Deserialize, Serialize, Clone, Default, PartialEq)]
use super::misc::Game;
#[derive(Deserialize, Serialize, Clone, Default, PartialEq, Debug)]
pub enum Aime {
Disabled,
#[default] BuiltIn,
@ -10,7 +12,7 @@ pub enum Aime {
Other(PkgKey),
}
#[derive(Deserialize, Serialize, Clone)]
#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct AMNet {
pub name: String,
pub addr: String,
@ -23,7 +25,7 @@ impl Default for AMNet {
}
}
#[derive(Deserialize, Serialize, Clone)]
#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct Segatools {
pub target: PathBuf,
pub hook: Option<PkgKey>,
@ -38,11 +40,14 @@ pub struct Segatools {
pub amnet: AMNet,
}
impl Default for Segatools {
fn default() -> Self {
impl Segatools {
pub fn default_for(game: Game) -> Self {
Segatools {
target: PathBuf::default(),
hook: Some(PkgKey("segatools-mu3hook".to_owned())),
hook: match game {
Game::Ongeki => Some(PkgKey("segatools-mu3hook".to_owned())),
Game::Chunithm => Some(PkgKey("segatools-chusanhook".to_owned()))
},
io: None,
amfs: PathBuf::default(),
option: PathBuf::default(),
@ -54,14 +59,14 @@ impl Default for Segatools {
}
}
#[derive(Deserialize, Serialize, Clone, Default, PartialEq)]
#[derive(Deserialize, Serialize, Clone, Default, PartialEq, Debug)]
pub enum DisplayMode {
Window,
#[default] Borderless,
Fullscreen
}
#[derive(Deserialize, Serialize, Clone)]
#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct Display {
pub target: String,
pub rez: (i32, i32),
@ -71,26 +76,32 @@ pub struct Display {
pub borderless_fullscreen: bool,
}
impl Default for Display {
fn default() -> Self {
impl Display {
pub fn default_for(game: Game) -> Self {
Display {
target: "default".to_owned(),
rez: (1080, 1920),
rez: match game {
Game::Chunithm => (1920, 1080),
Game::Ongeki => (1080, 1920),
},
mode: DisplayMode::Borderless,
rotation: 0,
frequency: 60,
frequency: match game {
Game::Chunithm => 120,
Game::Ongeki => 60,
},
borderless_fullscreen: true,
}
}
}
#[derive(Deserialize, Serialize, Clone, Default, PartialEq)]
#[derive(Deserialize, Serialize, Clone, Default, PartialEq, Debug)]
pub enum NetworkType {
#[default] Remote,
Artemis,
}
#[derive(Deserialize, Serialize, Clone, Default)]
#[derive(Deserialize, Serialize, Clone, Default, Debug)]
pub struct Network {
pub network_type: NetworkType,
@ -104,7 +115,7 @@ pub struct Network {
pub suffix: Option<i32>,
}
#[derive(Deserialize, Serialize, Clone, Default)]
#[derive(Deserialize, Serialize, Clone, Default, Debug)]
pub struct BepInEx {
pub console: bool,
}

View File

@ -1,4 +1,8 @@
pub fn segatools_base() -> String {
use super::misc::Game;
pub fn segatools_base(game: Game) -> String {
match game {
Game::Ongeki =>
"[vfd]
; Enable VFD emulation. Disable to use a real VFD
; GP1232A02A FUTABA assembly.
@ -76,5 +80,219 @@ right2=0x4B ; K
right3=0x4C ; L
leftMenu=0x55 ; U
rightMenu=0x4F ; O".to_owned()
rightMenu=0x4F ; O".to_owned(),
Game::Chunithm => "
[vfd]
; Enable VFD emulation. Disable to use a real VFD
; GP1232A02A FUTABA assembly.
enable=1
[system]
; Enable ALLS system settings.
enable=1
; Enable freeplay mode. This will disable the coin slot and set the game to
; freeplay. Keep in mind that some game modes (e.g. Freedom/Time Modes) will not
; allow you to start a game in freeplay mode.
freeplay=0
; LAN Install: If multiple machines are present on the same LAN then set
; this to 1 on exactly one machine and set this to 0 on all others.
dipsw1=1
; Monitor type: 0 = 120FPS, 1 = 60FPS
dipsw2=1
; Cab type: 0 = SP, 1 = CVT. SP will enable VFD and eMoney. This setting will switch
; the LED 837-15093-06 COM port and the AiMe reder hardware generation as well.
dipsw3=1
; -----------------------------------------------------------------------------
; Misc. hooks settings
; -----------------------------------------------------------------------------
[gfx]
; Enables the graphics hook.
enable=1
; Force the game to run windowed.
windowed=1
; Add a frame to the game window if running windowed.
framed=0
; Select the monitor to run the game on. (Fullscreen only, 0 =primary screen)
monitor=0
; -----------------------------------------------------------------------------
; LED settings
; -----------------------------------------------------------------------------
[led15093]
; Enable emulation of the 15093-06 controlled lights, which handle the air tower
; RGBs and the rear LED panel (billboard) on the cabinet.
enable=1
[led]
; Output billboard LED strip data to a named pipe called \"\\\\.\\pipe\\chuni_led\"
cabLedOutputPipe=1
; Output billboard LED strip data to serial
cabLedOutputSerial=0
; Output slider LED data to the named pipe
controllerLedOutputPipe=1
; Output slider LED data to the serial port
controllerLedOutputSerial=0
; Use the OpeNITHM protocol for serial LED output
controllerLedOutputOpeNITHM=0
; Serial port to send data to if using serial output. Default is COM5.
;serialPort=COM5
; Baud rate for serial data (set to 115200 if using OpeNITHM)
;serialBaud=921600
; Data output a sequence of bytes, with JVS-like framing.
; Each \"packet\" starts with 0xE0 as a sync. To avoid E0 appearing elsewhere,
; 0xD0 is used as an escape character -- if you receive D0 in the output, ignore
; it and use the next sent byte plus one instead.
;
; After the sync is one byte for the board number that was updated, followed by
; the red, green and blue values for each LED.
;
; Board 0 has 53 LEDs:
; [0]-[49]: snakes through left half of billboard (first column starts at top)
; [50]-[52]: left side partition LEDs
;
; Board 1 has 63 LEDs:
; [0]-[59]: right half of billboard (first column starts at bottom)
; [60]-[62]: right side partition LEDs
;
; Board 2 is the slider and has 31 LEDs:
; [0]-[31]: slider LEDs right to left BRG, alternating between keys and dividers
; -----------------------------------------------------------------------------
; Custom IO settings
; -----------------------------------------------------------------------------
[chuniio]
; Uncomment this if you have custom chuniio implementation comprised of a single 32bit DLL.
; (will use chu2to3 engine internally)
;path=
; Uncomment both of these if you have custom chuniio implementation comprised of two DLLs.
; x86 chuniio to path32, x64 to path64. Both are necessary.
;path32=
;path64=
; -----------------------------------------------------------------------------
; Input settings
; -----------------------------------------------------------------------------
; Keyboard bindings are specified as hexadecimal (prefixed with 0x) or decimal
; (not prefixed with 0x) virtual-key codes, a list of which can be found here:
;
; https://docs.microsoft.com/en-us/windows/win32/inputdev/virtual-key-codes
;
; This is, admittedly, not the most user-friendly configuration method in the
; world. An improved solution will be provided later.
[io3]
test=0x31
service=0x32
coin=0x33
ir=0x00
ir6=0x39
ir5=0x38
ir4=0x37
ir3=0x36
ir2=0x35
ir1=0x34
[ir]
ir6=0x39
ir5=0x38
ir4=0x37
ir3=0x36
ir2=0x35
ir1=0x34
[slider]
cell32=0x51
cell30=0x5A
cell28=0x53
cell26=0x45
cell24=0x43
cell22=0x46
cell20=0x54
cell18=0x42
cell16=0x48
cell14=0x55
cell12=0x4D
cell10=0x4B
cell8=0x4F
cell6=190
cell4=186
cell2=219
cell31=0x41
cell29=0x57
cell27=0x58
cell25=0x44
cell23=0x52
cell21=0x56
cell19=0x47
cell17=0x59
cell15=0x4E
cell13=0x4A
cell11=0x49
cell9=188
cell7=0x4C
cell5=0x50
cell3=191
cell1=222
".to_owned()
}
}

View File

@ -4,15 +4,17 @@ use crate::pkg::PkgKey;
use crate::util;
use crate::profiles::ProfilePaths;
pub async fn prepare_packages<'a>(p: &'a impl ProfilePaths, pkgs: &BTreeSet<PkgKey>) -> Result<()> {
pub async fn prepare_packages<'a>(p: &'a impl ProfilePaths, pkgs: &BTreeSet<PkgKey>, redo_bepinex: bool) -> Result<()> {
log::debug!("begin prepare packages");
let pfx_dir = p.data_dir();
let opt_dir = pfx_dir.join("option");
if redo_bepinex {
if pfx_dir.join("BepInEx").exists() {
tokio::fs::remove_dir_all(pfx_dir.join("BepInEx")).await?;
}
}
if !opt_dir.exists() {
tokio::fs::create_dir(opt_dir).await?;
@ -21,12 +23,15 @@ pub async fn prepare_packages<'a>(p: &'a impl ProfilePaths, pkgs: &BTreeSet<PkgK
for m in pkgs {
log::debug!("preparing {}", m);
let (namespace, name) = m.0.split_at(m.0.find("-").expect("Invalid mod definition"));
if redo_bepinex {
let bpx_dir = util::pkg_dir_of(namespace, &name[1..]) // cut the hyphen
.join("app")
.join("BepInEx");
if bpx_dir.exists() {
util::copy_directory(&bpx_dir, &pfx_dir.join("BepInEx"), true)?;
}
}
let opt_dir = util::pkg_dir_of(namespace, &name[1..]).join("option");
if opt_dir.exists() {

View File

@ -2,7 +2,7 @@ use std::path::PathBuf;
use anyhow::{anyhow, Result};
use ini::Ini;
use crate::{model::{profile::{Aime, Segatools}, segatools_base::segatools_base}, profiles::ProfilePaths, util::{self, PathStr}};
use crate::{model::{misc::Game, profile::{Aime, Segatools}, segatools_base::segatools_base}, profiles::ProfilePaths, util::{self, PathStr}};
use crate::pkg_store::PackageStore;
impl Segatools {
@ -31,7 +31,7 @@ impl Segatools {
_ => {},
}
}
pub async fn line_up(&self, p: &impl ProfilePaths) -> Result<Ini> {
pub async fn line_up(&self, p: &impl ProfilePaths, game: Game) -> Result<Ini> {
log::debug!("begin line-up: segatools");
let pfx_dir = p.data_dir();
@ -42,7 +42,7 @@ impl Segatools {
let ini_path = p.config_dir().join("segatools-base.ini");
if !ini_path.exists() {
tokio::fs::write(&ini_path, segatools_base()).await
tokio::fs::write(&ini_path, segatools_base(game)).await
.map_err(|e| anyhow!("Error creating {:?}: {}", ini_path, e))?;
}
if !pfx_dir.exists() {
@ -69,12 +69,14 @@ impl Segatools {
.set("amfs", self.amfs.stringify()?)
.set("appdata", self.appdata.stringify()?);
if game == Game::Ongeki {
ini_out.with_section(Some("unity"))
.set("enable", "1")
.set(
"targetAssembly",
pfx_dir.join("BepInEx").join("core").join("BepInEx.Preloader.dll").stringify()?
);
}
if self.aime != Aime::Disabled {
ini_out.with_section(Some("aime"))
@ -84,11 +86,16 @@ impl Segatools {
let mut aimeio = ini_out.with_section(Some("aimeio"));
aimeio
.set("path", util::pkg_dir().join(key.to_string()).join("segatools").join("aimeio.dll").stringify()?)
.set("gameId", "SDDT")
.set("serverAddress", &self.amnet.addr)
.set("useAimeDBForPhysicalCards", if self.amnet.physical { "1" } else { "0" })
.set("enableKeyboardMode", "0");
match game {
Game::Ongeki => aimeio.set("gameId", "SDDT"),
Game::Chunithm => aimeio.set("gameId", "SDHD")
};
if let Ok(keyboard_code) = std::fs::read_to_string(p.config_dir().join("aime.txt")) {
log::debug!("{} {}", keyboard_code, keyboard_code.len());
if keyboard_code.len() == 20 {
@ -105,6 +112,7 @@ impl Segatools {
.set("enable", "0");
}
if game == Game::Ongeki {
if let Some(io) = &self.io {
ini_out.with_section(Some("mu3io"))
.set("path", util::pkg_dir().join(io.to_string()).join("segatools").join("mu3io.dll").stringify()?);
@ -112,6 +120,7 @@ impl Segatools {
ini_out.with_section(Some("mu3io"))
.set("path", "");
}
}
log::debug!("option dir: {:?} -> {:?}", opt_dir_in, opt_dir_out);

View File

@ -7,11 +7,11 @@ use enumflags2::{bitflags, make_bitflags, BitFlags};
use crate::{model::{local::{self, PackageManifest}, rainy}, util};
// {namespace}-{name}
#[derive(Eq, Hash, PartialEq, PartialOrd, Ord, Clone, Serialize, Deserialize, Display)]
#[derive(Eq, Hash, PartialEq, PartialOrd, Ord, Clone, Serialize, Deserialize, Display, Debug)]
pub struct PkgKey(pub String);
// {namespace}-{name}-{version}
#[derive(Eq, Hash, PartialEq, PartialOrd, Ord, Clone, Serialize, Deserialize, Display)]
#[derive(Eq, Hash, PartialEq, PartialOrd, Ord, Clone, Serialize, Deserialize, Display, Debug)]
pub struct PkgKeyVersion(String);
#[derive(Clone, Default, Serialize, Deserialize)]
@ -36,10 +36,11 @@ pub enum Status {
#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum Feature {
Mod,
Hook,
GameIO,
Aime,
AMNet,
Mu3Hook,
Mu3IO,
ChusanHook,
}
#[derive(Clone, Serialize, Deserialize)]
@ -196,14 +197,16 @@ impl Package {
// Multiple features in the same dll (yubideck etc.) should be supported at some point
let mut flags = BitFlags::default();
if let Some(serde_json::Value::String(module)) = mft.installers[0].get("module") {
if module.ends_with("hook") {
flags |= Feature::Hook;
if module == "mu3hook" {
flags |= Feature::Mu3Hook;
} else if module == "chusanhook" {
flags |= Feature::ChusanHook;
} else if module == "amnet" {
flags |= Feature::AMNet | Feature::Aime;
} else if module == "aimeio" {
flags |= Feature::Aime;
} else if module.ends_with("io") {
flags |= Feature::GameIO;
} else if module == "mu3io" {
flags |= Feature::Mu3IO;
}
}
return Status::OK(flags);

View File

@ -5,15 +5,20 @@ use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter};
use tokio::fs;
use tokio::task::JoinSet;
use crate::model::local::{PackageList, PackageListEntry};
use crate::model::misc::Game;
use crate::model::rainy;
use crate::pkg::{Package, PkgKey, Remote};
use crate::pkg::{Package, PkgKey, Remote, Status};
use crate::util;
use crate::download_handler::DownloadHandler;
pub struct PackageStore {
store: HashMap<PkgKey, Package>,
app: AppHandle,
meta_list: PackageList,
dlh: DownloadHandler,
app: AppHandle,
offline: bool,
}
@ -29,8 +34,17 @@ pub enum InstallResult {
impl PackageStore {
pub fn new(app: AppHandle) -> PackageStore {
let meta_list = std::fs::read_to_string(util::config_dir().join("package-list.json"))
.map_err(|e| anyhow!(e))
.and_then(|s| serde_json::from_str::<PackageList>(&s).map_err(|e| anyhow!(e)))
.unwrap_or_else(|e| {
log::warn!("unable to read package-list: {e}");
PackageList::new()
});
PackageStore {
store: HashMap::new(),
meta_list,
app: app.clone(),
dlh: DownloadHandler::new(app),
offline: true
@ -46,6 +60,13 @@ impl PackageStore {
self.store.clone()
}
pub fn get_game_list(&self, game: Game) -> Vec<PkgKey> {
self.meta_list.iter()
.filter(|(_, v)| v.games.contains(&game))
.map(|(k, _)| k.clone())
.collect()
}
pub async fn reload_package(&mut self, key: PkgKey) {
let dir = util::pkg_dir().join(&key.0);
if let Ok(pkg) = Package::from_dir(dir).await {
@ -75,14 +96,23 @@ impl PackageStore {
Ok(())
}
pub async fn fetch_listings() -> Result<Vec<rainy::V1Package>> {
pub async fn save(&self) -> Result<()> {
tokio::fs::write(
util::config_dir().join("package-list.json"),
serde_json::to_string_pretty(&self.meta_list)?
).await?;
Ok(())
}
pub async fn fetch_listings(game: Game) -> Result<Vec<rainy::V1Package>> {
use async_compression::futures::bufread::GzipDecoder;
use futures::{
io::{self, BufReader, ErrorKind},
prelude::*,
};
let response = reqwest::get("https://rainy.patafour.zip/c/ongeki/api/v1/package/").await?;
let response = reqwest::get(format!("https://rainy.patafour.zip/c/{game}/api/v1/package/")).await?;
let reader = response
.bytes_stream()
@ -100,11 +130,23 @@ impl PackageStore {
self.offline
}
pub fn process_fetched_listings(&mut self, listings: Vec<rainy::V1Package>) {
pub fn process_fetched_listings(&mut self, listings: Vec<rainy::V1Package>, game: Game) {
for listing in listings {
// This is None if the package has no versions for whatever reason
if let Some(r) = Package::from_rainy(listing) {
//log::warn!("D {}", &r.rmt.as_ref().unwrap().dependencies.first().unwrap_or(&"Nothing".to_owned()));
let mut meta_entry = self.meta_list.remove(&r.key()).unwrap_or_else(|| {
PackageListEntry {
// from_rainy() is guaranteed to include rmt
version: r.rmt.as_ref().unwrap().version.clone(),
status: Status::Unchecked,
games: vec![ game ],
}
});
if !meta_entry.games.contains(&game) {
meta_entry.games.push(game);
}
self.meta_list.insert(r.key(), meta_entry);
match self.store.get_mut(&r.key()) {
Some(l) => {
l.rmt = r.rmt;
@ -226,8 +268,7 @@ impl PackageStore {
async fn clean_up_file(path: impl AsRef<Path>, name: &str, force: bool) -> Result<()> {
let path = path.as_ref().join(name);
if force || path.exists() {
tokio::fs::remove_file(path)
.await
tokio::fs::remove_file(path).await
.map_err(|e| anyhow!("Could not delete /{}: {}", name, e))?;
}
@ -242,6 +283,7 @@ impl PackageStore {
Self::clean_up_file(&path, "icon.png", true).await?;
Self::clean_up_file(&path, "manifest.json", true).await?;
Self::clean_up_file(&path, "README.md", true).await?;
Self::clean_up_file(&path, "post_load.ps1", false).await?;
tokio::fs::remove_dir(path.as_ref())
.await

View File

@ -1,15 +1,19 @@
use anyhow::{Result, anyhow};
use ongeki::OngekiProfile;
use serde::{Deserialize, Serialize};
use tauri::AppHandle;
use std::{collections::BTreeSet, path::{Path, PathBuf}};
use crate::{model::misc::Game, modules::package::prepare_packages, pkg::PkgKey, pkg_store::PackageStore, util};
use crate::{model::{misc::Game, profile::Aime}, modules::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}, segatools_base::segatools_base};
use anyhow::{anyhow, Result};
use std::fs::File;
use tokio::process::Command;
use tokio::task::JoinSet;
pub mod ongeki;
#[derive(Deserialize, Serialize, Clone)]
pub enum AnyProfile {
OngekiProfile(OngekiProfile)
pub trait ProfilePaths {
fn config_dir(&self) -> PathBuf;
fn data_dir(&self) -> PathBuf;
}
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)]
@ -18,96 +22,140 @@ pub struct ProfileMeta {
pub name: String
}
pub trait Profile: Sized {
fn new(name: String) -> Result<Self>;
fn load(name: String) -> Result<Self>;
fn save(&self) -> Result<()>;
async fn start(&self, app: AppHandle) -> Result<()>;
impl ProfilePaths for ProfileMeta {
fn config_dir(&self) -> PathBuf {
util::profile_config_dir(self.game, &self.name)
}
fn data_dir(&self) -> PathBuf {
util::data_dir().join(format!("profile-{}-{}", &self.game, &self.name))
}
}
pub trait ProfilePaths {
fn config_dir(&self) -> PathBuf;
fn data_dir(&self) -> PathBuf;
#[derive(Deserialize, Serialize, Clone)]
pub struct Profile {
pub meta: ProfileMeta,
pub data: ProfileData,
}
impl AnyProfile {
#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct ProfileData {
pub mods: BTreeSet<PkgKey>,
pub sgt: Segatools,
pub display: Option<Display>,
pub network: Network,
#[serde(skip_serializing_if = "Option::is_none")]
pub bepinex: Option<BepInEx>,
#[cfg(not(target_os = "windows"))]
pub wine: crate::model::profile::Wine,
}
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: if meta.game == Game::Ongeki { Some(Display::default_for(meta.game)) } else { None },
#[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(),
},
meta: meta.clone()
};
p.save()?;
std::fs::create_dir_all(p.config_dir())?;
std::fs::create_dir_all(p.data_dir())?;
std::fs::write(p.config_dir().join("segatools-base.ini"), segatools_base(meta.game))?;
Ok(p)
}
pub fn load(game: Game, name: String) -> Result<Self> {
Ok(match game {
Game::Ongeki => AnyProfile::OngekiProfile(OngekiProfile::load(name)?),
Game::Chunithm => panic!("Not implemented")
let path = util::profile_config_dir(game, &name).join("profile.json");
if let Ok(s) = std::fs::read_to_string(&path) {
let data = serde_json::from_str::<ProfileData>(&s)
.map_err(|e| anyhow!("Unable to parse {:?}: {:?}", path, e))?;
log::debug!("{:?}", data);
Ok(Profile {
meta: ProfileMeta {
game, name
},
data
})
} else {
Err(anyhow!("Unable to open {:?}", path))
}
}
pub fn save(&self) -> Result<()> {
match self {
Self::OngekiProfile(p) => p.save()
}
}
pub fn meta(&self) -> ProfileMeta {
match self {
Self::OngekiProfile(p) => {
ProfileMeta {
game: Game::Ongeki,
name: p.name.as_ref().unwrap().clone()
}
}
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!("Written to {:?}", path);
Ok(())
}
pub fn rename(&mut self, name: String) {
match self {
Self::OngekiProfile(p) => {
p.name = Some(fixed_name(&ProfileMeta { name, game: Game::Ongeki }, false));
}
}
self.meta.name = fixed_name(&ProfileMeta { game: self.meta.game, name}, false);
}
pub fn mod_pkgs(&self) -> &BTreeSet<PkgKey> {
match self {
Self::OngekiProfile(p) => &p.mods
}
&self.data.mods
}
pub fn mod_pkgs_mut(&mut self) -> &mut BTreeSet<PkgKey> {
match self {
Self::OngekiProfile(p) => &mut p.mods
}
&mut self.data.mods
}
pub fn special_pkgs(&self) -> Vec<PkgKey> {
let mut res = Vec::new();
match self {
Self::OngekiProfile(p) => {
if let Some(hook) = &p.sgt.hook {
if let Some(hook) = &self.data.sgt.hook {
res.push(hook.clone());
}
if let Some(io) = &p.sgt.io {
if let Some(io) = &self.data.sgt.io {
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) {
match self {
Self::OngekiProfile(p) => p.sgt.fix(store)
}
}
pub fn sync(&mut self, source: AnyProfile) {
match self {
Self::OngekiProfile(p) => {
#[allow(irrefutable_let_patterns)]
if let AnyProfile::OngekiProfile(source) = source {
p.bepinex = source.bepinex;
p.display = source.display;
p.network = source.network;
p.sgt = source.sgt;
} else {
log::error!("sync: invalid profile type {:?}", source);
self.data.sgt.fix(store);
}
pub fn sync(&mut self, source: ProfileData) {
if self.data.bepinex.is_some() {
self.data.bepinex = source.bepinex;
}
if self.data.display.is_some() {
self.data.display = source.display;
}
// if self.data.network.is_some() {
self.data.network = source.network;
// }
// if self.data.sgt.is_some() {
self.data.sgt = source.sgt;
// }
}
pub async fn line_up(&self, pkg_hash: String, _app: AppHandle) -> Result<()> {
match self {
Self::OngekiProfile(_p) => {
#[cfg(target_os = "windows")]
let info = _p.display.line_up()?;
let info = match &self.data.display {
None => None,
Some(display) => display.line_up()?
};
let res = self.line_up_the_rest(pkg_hash).await;
@ -123,42 +171,162 @@ impl AnyProfile {
res
}
}
}
async fn line_up_the_rest(&self, pkg_hash: String) -> Result<()> {
match self {
Self::OngekiProfile(p) => {
if !p.data_dir().exists() {
tokio::fs::create_dir(p.data_dir()).await?;
if !self.data_dir().exists() {
tokio::fs::create_dir(self.data_dir()).await?;
}
let hash_path = p.data_dir().join(".sl-state");
let meta = self.meta();
let hash_path = self.data_dir().join(".sl-state");
util::clean_up_opts(p.data_dir().join("option"))?;
util::clean_up_opts(self.data_dir().join("option"))?;
if Self::hash_check(&hash_path, &pkg_hash).await? == true {
prepare_packages(&meta, &p.mods).await
let hash_check = Self::hash_check(&hash_path, &pkg_hash).await?;
prepare_packages(&self.meta, &self.data.mods, hash_check).await
.map_err(|e| anyhow!("package configuration failed:\n{:?}", e))?;
}
let mut ini = p.sgt.line_up(&meta).await
let mut ini = self.data.sgt.line_up(&self.meta, self.meta.game).await
.map_err(|e| anyhow!("segatools configuration failed:\n{:?}", e))?;
p.network.line_up(&mut ini)?;
self.data.network.line_up(&mut ini)?;
ini.write_to_file(p.data_dir().join("segatools.ini"))
ini.write_to_file(self.data_dir().join("segatools.ini"))
.map_err(|e| anyhow!("Error writing segatools.ini: {}", e))?;
p.bepinex.line_up(&meta)?;
if let Some(bepinex) = &self.data.bepinex {
bepinex.line_up(&self.meta)?;
}
Ok(())
}
pub async fn start(&self, app: AppHandle) -> 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)
.arg("/C")
.arg(&sgt_dir.join(self.meta.game.inject_amd()))
.args(["-d", "-k"])
.arg(sgt_dir.join(self.meta.game.hook_amd()))
.arg("amdaemon.exe")
.args(self.meta.game.amd_args());
game_builder
.env(
"SEGATOOLS_CONFIG_PATH",
ini_path,
)
.env(
"INOHARA_CONFIG_PATH",
self.config_dir().join("inohara.cfg"),
)
.current_dir(&exe_dir)
.args(["-d", "-k"])
.arg(sgt_dir.join(self.meta.game.hook_exe()))
.arg(self.meta.game.exe());
if let Some(display) = &self.data.display {
game_builder.args([
"-monitor 1",
"-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");
}
}
pub async fn start(&self, app: AppHandle) -> Result<()> {
match self {
Self::OngekiProfile(p) => p.start(app).await
#[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) = 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) = 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> {
@ -174,11 +342,19 @@ impl AnyProfile {
}
}
impl std::fmt::Debug for AnyProfile {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::OngekiProfile(p) => f.debug_tuple("ongeki").field(&p.name).finish(),
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()
}
}
@ -227,7 +403,7 @@ pub fn fixed_name(meta: &ProfileMeta, prepend_new: bool) -> String {
.replace(" ", "-")
.replace("..", "").replace("/", "").replace("\\", "");
while prepend_new && util::profile_config_dir(&meta.game, &name).exists() {
while prepend_new && util::profile_config_dir(meta.game, &name).exists() {
name = format!("new-{}", name);
}

View File

@ -1,229 +0,0 @@
use serde::{Deserialize, Serialize};
use tauri::AppHandle;
use tauri::Emitter;
use std::{collections::BTreeSet, path::PathBuf, process::Stdio};
use crate::model::profile::BepInEx;
use crate::profiles::fixed_name;
use crate::{model::{profile::{Display, DisplayMode, Network, Segatools}, misc::Game, segatools_base::segatools_base}, pkg::PkgKey, util};
use super::{Profile, ProfileMeta, ProfilePaths};
use anyhow::{anyhow, Result};
use std::fs::File;
use tokio::process::Command;
use tokio::task::JoinSet;
#[derive(Deserialize, Serialize, Clone)]
pub struct OngekiProfile {
// this is an Option only to deceive serde
// file serialization doesn't need the name, but IPC does
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
pub mods: BTreeSet<PkgKey>,
pub sgt: Segatools,
pub display: Display,
pub network: Network,
pub bepinex: BepInEx,
#[cfg(not(target_os = "windows"))]
pub wine: crate::model::profile::Wine,
}
impl Profile for OngekiProfile {
fn new(name: String) -> Result<Self> {
let name = fixed_name(&ProfileMeta { name, game: Game::Ongeki }, true);
let p = OngekiProfile {
name: Some(name.clone()),
mods: BTreeSet::new(),
sgt: Segatools::default(),
display: Display::default(),
network: Network::default(),
bepinex: BepInEx::default(),
#[cfg(not(target_os = "windows"))]
wine: crate::model::profile::Wine::default(),
};
p.save()?;
std::fs::write(p.config_dir().join("segatools-base.ini"), segatools_base())?;
log::debug!("created profile-ongeki-{}", &name);
Ok(p)
}
fn load(name: String) -> Result<Self> {
let path = util::profile_config_dir(&Game::Ongeki, &name).join("profile.json");
if let Ok(s) = std::fs::read_to_string(&path) {
let mut data = serde_json::from_str::<OngekiProfile>(&s)
.map_err(|e| anyhow!("Unable to parse {:?}: {:?}", path, e))?;
data.name = Some(name);
Ok(data)
} else {
Err(anyhow!("Unable to open {:?}", path))
}
}
fn save(&self) -> Result<()> {
let path = self.config_dir().join("profile.json");
let mut cpy = self.clone();
cpy.name = None;
let s = serde_json::to_string_pretty(&cpy)?;
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!("Written to {:?}", path);
Ok(())
}
async fn start(&self, app: AppHandle) -> 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.sgt.target);
let exe_dir = target_path.parent().ok_or_else(|| anyhow!("Invalid target path"))?;
let sgt_dir = self.sgt.hook_dir()?;
#[cfg(target_os = "windows")]
{
game_builder = Command::new(sgt_dir.join("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("inject.exe"));
amd_builder.arg("cmd.exe");
}
amd_builder.env(
"SEGATOOLS_CONFIG_PATH",
&ini_path,
)
.current_dir(&exe_dir)
.arg("/C")
.arg(&sgt_dir.join("inject.exe"))
.args(["-d", "-k"])
.arg(sgt_dir.join("mu3hook.dll"))
.args(["amdaemon.exe", "-f", "-c", "config_common.json", "config_server.json", "config_client.json"
]);
game_builder
.env(
"SEGATOOLS_CONFIG_PATH",
ini_path,
)
.env(
"INOHARA_CONFIG_PATH",
self.config_dir().join("inohara.cfg"),
)
.current_dir(&exe_dir)
.args(["-d", "-k"])
.arg(sgt_dir.join("mu3hook.dll"))
.args([
"mu3.exe", "-monitor 1",
"-screen-width", &self.display.rez.0.to_string(),
"-screen-height", &self.display.rez.1.to_string(),
"-screen-fullscreen", if self.display.mode == DisplayMode::Fullscreen { "1" } else { "0" }
]);
if self.display.mode == DisplayMode::Borderless {
game_builder.arg("-popupwindow");
}
#[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.log"))?;
let game_log = File::create(self.data_dir().join("mu3.log"))?;
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.sgt.intel == true {
amd_builder.env("OPENSSL_ia32cap", ":~0x20000000");
}
util::pkill("amdaemon.exe").await;
log::info!("Launching amdaemon: {:?}", amd_builder);
log::info!("Launching mu3: {:?}", 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.exe")
});
set.spawn(async move {
(game.wait().await.expect("mu3 failed to run"), "mu3.exe")
});
if let Err(e) = app.emit("launch-start", "") {
log::warn!("Unable to emit launch-start: {}", e);
}
let (rc, process_name) = set.join_next().await.expect("No spawn").expect("No result");
log::info!("{} died with return code {}", process_name, rc);
if process_name == "amdaemon.exe" {
util::pkill("mu3.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) = app.emit("launch-end", "") {
log::warn!("Unable to emit launch-end: {}", e);
}
Ok(())
}
}
impl ProfilePaths for OngekiProfile {
fn config_dir(&self) -> PathBuf {
util::profile_config_dir(&Game::Ongeki, &self.name.as_ref().unwrap())
}
fn data_dir(&self) -> PathBuf {
util::data_dir().join(format!("profile-{}-{}", &Game::Ongeki, self.name.as_ref().unwrap()))
}
}
impl ProfilePaths for ProfileMeta {
fn config_dir(&self) -> PathBuf {
util::profile_config_dir(&self.game, &self.name)
}
fn data_dir(&self) -> PathBuf {
util::data_dir().join(format!("profile-{}-{}", &self.game, &self.name))
}
}

View File

@ -47,7 +47,7 @@ pub fn config_dir() -> &'static Path {
&DIRS.get().expect("Directories uninitialized").config_dir
}
pub fn profile_config_dir(game: &Game, name: &str) -> PathBuf {
pub fn profile_config_dir(game: Game, name: &str) -> PathBuf {
config_dir().join(format!("profile-{}-{}", game, name))
}

View File

@ -16,6 +16,7 @@ import StartButton from './StartButton.vue';
import { invoke } from '../invoke';
import { useGeneralStore, usePkgStore, usePrfStore } from '../stores';
import { Dirs } from '../types';
import { listen } from '@tauri-apps/api/event';
const pkg = usePkgStore();
const prf = usePrfStore();
@ -27,6 +28,7 @@ const currentTab: Ref<string | number> = ref(3);
const pkgSearchTerm = ref('');
const isProfileDisabled = computed(() => prf.current === null);
const isRunning = ref(false);
onMounted(async () => {
invoke('list_directories').then((d) => {
@ -47,8 +49,23 @@ onMounted(async () => {
key: 'segatools-mu3hook',
force: false,
});
await invoke('install_package', {
key: 'segatools-chusanhook',
force: false,
});
});
});
listen('launch-start', () => {
isRunning.value = true;
currentTab.value = 5;
});
listen('launch-end', () => {
isRunning.value = false;
currentTab.value = 0;
});
</script>
<template>
@ -61,20 +78,27 @@ onMounted(async () => {
>
<div class="fixed w-full flex z-100">
<TabList class="grow">
<Tab :disabled="isProfileDisabled" :value="0"
><div class="pi pi-list-check"></div
<Tab :value="3" :disabled="isRunning"
><div class="pi pi-question-circle"></div
></Tab>
<Tab :disabled="isProfileDisabled || isRunning" :value="0"
><div class="pi pi-box"></div
></Tab>
<Tab v-if="prf.current?.meta.game === 'chunithm'" :disabled="isRunning" :value="4"
><div class="pi pi-ticket"></div
></Tab>
<Tab
v-if="pkg.networkStatus === 'online'"
:disabled="isProfileDisabled"
:disabled="isProfileDisabled || isRunning"
:value="1"
><div class="pi pi-download"></div
></Tab>
<Tab :disabled="isProfileDisabled" :value="2"
<Tab :disabled="isProfileDisabled || isRunning" :value="2"
><div class="pi pi-cog"></div
></Tab>
<Tab :value="3"
><div class="pi pi-question-circle"></div
<Tab :value="5" v-if="isRunning"
><div class="pi pi-sparkles"></div
></Tab>
<div class="grow"></div>
<div class="flex gap-4">
@ -131,16 +155,6 @@ onMounted(async () => {
missing.<br />Existing features are expected to break
sometimes.
<ProfileList />
<img
v-if="prf.current?.game === 'ongeki'"
src="/sticker-ongeki.svg"
class="fixed bottom-0 right-0 z-999"
/>
<img
v-else-if="prf.current?.game === 'chunithm'"
src="/sticker-chunithm.svg"
class="fixed bottom-0 right-0 z-999"
/>
<br /><br /><br />
<footer>
<Button
@ -151,7 +165,24 @@ onMounted(async () => {
/>
</footer>
</TabPanel>
<TabPanel :value="4">
CHUNITHM patches are not yet implemented.<br />Use
<a href=https://patcher.two-torial.xyz/ target="_blank" style="text-decoration: underline;">patcher.two-torial.xyz</a>
</TabPanel>
<TabPanel :value="5">Running!</TabPanel>
</TabPanels>
<div v-if="currentTab === 5 || currentTab === 3">
<img
v-if="prf.current?.meta.game === 'ongeki'"
src="/sticker-ongeki.svg"
class="fixed bottom-0 right-0 z-999"
/>
<img
v-else-if="prf.current?.meta.game === 'chunithm'"
src="/sticker-chunithm.svg"
class="fixed bottom-0 right-0 z-999"
/>
</div>
</Tabs>
</main>
</template>

View File

@ -1,11 +1,13 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { Ref, computed, ref } from 'vue';
import Button from 'primevue/button';
import Fieldset from 'primevue/fieldset';
import ModListEntry from './ModListEntry.vue';
import ModTitlecard from './ModTitlecard.vue';
import { invoke } from '../invoke';
import { usePkgStore, usePrfStore } from '../stores';
import { Package } from '../types';
import { pkgKey } from '../util';
const props = defineProps({
search: String,
@ -14,12 +16,20 @@ const props = defineProps({
const pkgs = usePkgStore();
const prf = usePrfStore();
const empty = ref(true);
const gameSublist: Ref<string[]> = ref([]);
const group = () => {
const a = Object.assign(
invoke('get_game_packages', {
game: prf.current?.meta.game,
}).then((list) => {
gameSublist.value = list as string[];
});
const group = computed(() => {
const res = Object.assign(
{},
Object.groupBy(
pkgs.allLocal
.filter((p) => gameSublist.value.includes(pkgKey(p)))
.filter(
(p) =>
props.search === undefined ||
@ -35,12 +45,12 @@ const group = () => {
({ namespace }) => namespace
)
);
empty.value = Object.keys(a).length === 0;
return a;
};
empty.value = Object.keys(res).length === 0;
return res;
});
const missing = computed(() => {
return prf.current?.mods.filter((m) => !pkgs.hasLocal(m)) ?? [];
return prf.current?.data.mods.filter((m) => !pkgs.hasLocal(m)) ?? [];
});
</script>
@ -68,7 +78,7 @@ const missing = computed(() => {
/>
</div>
</Fieldset>
<Fieldset v-for="(namespace, key) in group()" :legend="key.toString()">
<Fieldset v-for="(namespace, key) in group" :legend="key.toString()">
<ModListEntry v-for="p in namespace" :pkg="p" />
</Fieldset>
<div v-if="empty" class="text-3xl"></div>

View File

@ -32,7 +32,6 @@ const model = computed({
<div class="flex items-center">
<ModTitlecard show-version show-icon show-description :pkg="pkg" />
<UpdateButton :pkg="pkg" />
<!-- @vue-expect-error Can't 'as any' because it breaks VSCode -->
<ToggleSwitch
v-if="hasFeature(pkg, Feature.Mod)"
class="scale-[1.33] shrink-0"

View File

@ -1,20 +1,32 @@
<script setup lang="ts">
import { ref } from 'vue';
import { Ref, ref } from 'vue';
import Divider from 'primevue/divider';
import MultiSelect from 'primevue/multiselect';
import ToggleSwitch from 'primevue/toggleswitch';
import ModStoreEntry from './ModStoreEntry.vue';
import { usePkgStore } from '../stores';
import { invoke } from '../invoke';
import { usePkgStore, usePrfStore } from '../stores';
import { pkgKey } from '../util';
const pkgs = usePkgStore();
const prf = usePrfStore();
const empty = ref(true);
const props = defineProps({
search: String,
});
const gameSublist: Ref<string[]> = ref([]);
invoke('get_game_packages', {
game: prf.current?.meta.game,
}).then((list) => {
gameSublist.value = list as string[];
});
const list = () => {
const res = pkgs.allRemote
.filter((p) => gameSublist.value.includes(pkgKey(p)))
.filter(
(p) =>
props.search === undefined ||

View File

@ -53,13 +53,16 @@ const iconSrc = computed(() => {
>
</span>
<span
v-if="hasFeature(pkg, Feature.Hook)"
v-if="
hasFeature(pkg, Feature.ChusanHook) ||
hasFeature(pkg, Feature.Mu3Hook)
"
v-tooltip="'Hook'"
class="pi pi-wrench ml-1 text-blue-400"
>
</span>
<span
v-if="hasFeature(pkg, Feature.GameIO)"
v-if="hasFeature(pkg, Feature.Mu3IO)"
v-tooltip="'IO'"
class="pi pi-wrench ml-1 text-green-400"
>

View File

@ -1,399 +1,30 @@
<script setup lang="ts">
import { Ref, computed, ref } from 'vue';
import InputNumber from 'primevue/inputnumber';
import InputText from 'primevue/inputtext';
import Select from 'primevue/select';
import SelectButton from 'primevue/selectbutton';
import ToggleSwitch from 'primevue/toggleswitch';
import * as path from '@tauri-apps/api/path';
import { readTextFile, writeTextFile } from '@tauri-apps/plugin-fs';
import FileEditor from './FileEditor.vue';
import FilePicker from './FilePicker.vue';
import OptionCategory from './OptionCategory.vue';
import OptionRow from './OptionRow.vue';
import { invoke } from '../invoke';
import { usePkgStore, usePrfStore } from '../stores';
import { Feature } from '../types';
import { hasFeature, pkgKey } from '../util';
import AimeOptions from './options/Aime.vue';
import DisplayOptions from './options/Display.vue';
import MiscOptions from './options/Misc.vue';
import NetworkOptions from './options/Network.vue';
import SegatoolsOptions from './options/Segatools.vue';
import { usePrfStore } from '../stores';
const pkgs = usePkgStore();
const prf = usePrfStore();
const aimeCode = ref('');
const capabilities: Ref<string[]> = ref([]);
const displayList: Ref<{ title: string; value: string }[]> = ref([
{
title: 'Primary',
value: 'default',
},
]);
const loadDisplays = () => {
const newList = [
{
title: 'Primary',
value: 'default',
},
];
invoke('list_platform_capabilities')
.then(async (v: unknown) => {
let different = false;
if (Array.isArray(v)) {
capabilities.value.push(...v);
}
if (capabilities.value.includes('display')) {
for (const [devName, devString] of (await invoke(
'list_displays'
)) as Array<[string, string]>) {
newList.push({
title: `${devName.replace('\\\\.\\', '')} (${devString})`,
value: devName,
});
if (
displayList.value.find(
(item) => item.value === devName
) === undefined
) {
different = true;
}
}
}
if (displayList.value.length !== newList.length) {
different = true;
}
if (different) {
displayList.value = newList;
}
})
.catch(() => {});
};
loadDisplays();
prf.reload();
const aimeCodeModel = computed({
get() {
return aimeCode.value;
},
async set(value: string) {
aimeCode.value = value;
if (value.match(/^[0-9]{20}$/) || value.length === 0) {
const aime_path = await path.join(await prf.configDir, 'aime.txt');
await writeTextFile(aime_path, aimeCode.value);
}
},
});
const extraDisplayOptionsDisabled = computed(() => {
return prf.current?.display.target === 'default';
});
(async () => {
const aime_path = await path.join(await prf.configDir, 'aime.txt');
aimeCode.value = await readTextFile(aime_path).catch(() => '');
})();
</script>
<template>
<OptionCategory title="General">
<OptionRow title="mu3.exe">
<FilePicker
:directory="false"
promptname="mu3.exe"
extension="exe"
:value="prf.current!.sgt.target"
:callback="(value: string) => (prf.current!.sgt.target = value)"
></FilePicker>
</OptionRow>
<OptionRow title="amfs">
<FilePicker
:directory="true"
placeholder="amfs"
:value="prf.current!.sgt.amfs"
:callback="(value: string) => (prf.current!.sgt.amfs = value)"
></FilePicker>
</OptionRow>
<OptionRow title="option">
<FilePicker
:directory="true"
placeholder="option"
:value="prf.current!.sgt.option"
:callback="(value: string) => (prf.current!.sgt.option = value)"
></FilePicker>
</OptionRow>
<OptionRow title="appdata">
<FilePicker
:directory="true"
:value="prf.current!.sgt.appdata"
:callback="
(value: string) => (prf.current!.sgt.appdata = value)
"
></FilePicker>
</OptionRow>
<OptionRow title="mu3hook">
<Select
v-model="prf.current!.sgt.hook"
:options="
pkgs.hooks.map((p) => {
return { title: pkgKey(p), value: pkgKey(p) };
})
"
option-label="title"
option-value="value"
></Select>
</OptionRow>
<OptionRow title="mu3io">
<Select
v-model="prf.current!.sgt.io"
placeholder="segatools built-in"
:options="[
{ title: 'segatools built-in', value: null },
...pkgs.gameIOs.map((p) => {
return { title: pkgKey(p), value: pkgKey(p) };
}),
]"
option-label="title"
option-value="value"
></Select>
</OptionRow>
</OptionCategory>
<OptionCategory title="Display">
<OptionRow
v-if="capabilities.includes('display')"
title="Target display"
<SegatoolsOptions />
<DisplayOptions v-if="prf.current!.meta.game === 'ongeki'" />
<NetworkOptions />
<AimeOptions />
<MiscOptions />
<OptionCategory
title="Extensions"
v-if="prf.current!.meta.game === 'ongeki'"
>
<Select
v-model="prf.current!.display.target"
:options="displayList"
option-label="title"
option-value="value"
placeholder="(Disconnected)"
@show="loadDisplays"
></Select>
</OptionRow>
<OptionRow class="number-input" title="Game resolution">
<InputNumber
class="shrink"
size="small"
:min="480"
:max="9999"
:use-grouping="false"
v-model="prf.current!.display.rez[0]"
/>
x
<InputNumber
class="shrink"
size="small"
:min="640"
:max="9999"
:use-grouping="false"
v-model="prf.current!.display.rez[1]"
/>
</OptionRow>
<OptionRow title="Display mode">
<SelectButton
v-model="prf.current!.display.mode"
:options="[
{ title: 'Window', value: 'Window' },
{ title: 'Borderless window', value: 'Borderless' },
{ title: 'Fullscreen', value: 'Fullscreen' },
]"
:allow-empty="false"
option-label="title"
option-value="value"
/>
</OptionRow>
<OptionRow
title="Display rotation"
v-if="capabilities.includes('display')"
>
<SelectButton
v-model="prf.current!.display.rotation"
:options="[
{ title: 'Unchanged', value: 0 },
{ title: 'Portrait', value: 90 },
{ title: 'Portrait (flipped)', value: 270 },
]"
:allow-empty="false"
option-label="title"
option-value="value"
:disabled="extraDisplayOptionsDisabled"
/>
</OptionRow>
<OptionRow
v-if="capabilities.includes('display')"
class="number-input"
title="Refresh Rate"
>
<InputNumber
class="shrink"
size="small"
:min="60"
:max="999"
:use-grouping="false"
v-model="prf.current!.display.frequency"
:disabled="extraDisplayOptionsDisabled"
/>
</OptionRow>
<OptionRow
title="Borderless fullscreen"
v-if="capabilities.includes('display')"
tooltip="Match display resolution with the game."
>
<ToggleSwitch
:disabled="
extraDisplayOptionsDisabled ||
prf.current?.display.mode !== 'Borderless'
"
v-model="prf.current!.display.borderless_fullscreen"
/>
</OptionRow>
</OptionCategory>
<OptionCategory title="Network">
<OptionRow title="Network type">
<SelectButton
v-model="prf.current!.network.network_type"
:options="[
{ title: 'Remote', value: 'Remote' },
{ title: 'Local (ARTEMiS)', value: 'Artemis' },
]"
:allow-empty="false"
option-label="title"
option-value="value"
/>
</OptionRow>
<OptionRow
v-if="prf.current!.network.network_type == 'Artemis'"
title="ARTEMiS path"
>
<FilePicker
:directory="false"
promptname="index.py"
extension="py"
:value="prf.current!.network.local_path"
:callback="
(value: string) => (prf.current!.network.local_path = value)
"
></FilePicker>
</OptionRow>
<!-- <OptionRow
v-if="prf.current!.network.network_type == 'Artemis'"
title="ARTEMiS console"
>
<ToggleSwitch v-model="prf.current!.network.local_console" />
</OptionRow> -->
<OptionRow
v-if="prf.current!.network.network_type == 'Remote'"
title="Server address"
>
<InputText
class="shrink"
size="small"
:maxlength="40"
placeholder="192.168.1.234"
v-model="prf.current!.network.remote_address"
/> </OptionRow
><OptionRow
v-if="prf.current!.network.network_type == 'Remote'"
title="Keychip"
>
<InputText
class="shrink"
size="small"
:maxlength="16"
placeholder="A123-01234567890"
v-model="prf.current!.network.keychip"
/> </OptionRow
><OptionRow title="Subnet">
<InputText
class="shrink"
size="small"
:maxlength="15"
placeholder="192.168.1.0"
v-model="prf.current!.network.subnet"
/>
</OptionRow>
<OptionRow title="Address suffix">
<InputNumber
class="shrink"
size="small"
:maxlength="3"
:min="0"
:max="255"
placeholder="12"
v-model="prf.current!.network.suffix"
/>
</OptionRow>
</OptionCategory>
<OptionCategory title="Aime">
<OptionRow title="Aime emulation">
<Select
v-model="prf.current!.sgt.aime"
:options="[
{ title: 'none', value: 'Disabled' },
{ title: 'segatools built-in', value: 'BuiltIn' },
...pkgs.aimes.map((p) => {
return {
title: pkgKey(p),
value: hasFeature(p, Feature.AMNet)
? { AMNet: pkgKey(p) }
: { Other: pkgKey(p) },
};
}),
]"
placeholder="none"
option-label="title"
option-value="value"
></Select>
</OptionRow>
<OptionRow title="Aime code">
<InputText
class="shrink"
size="small"
:disabled="prf.current!.sgt.aime === 'Disabled'"
:maxlength="20"
placeholder="00000000000000000000"
v-model="aimeCodeModel"
/>
</OptionRow>
<div v-if="prf.current!.sgt.aime?.hasOwnProperty('AMNet')">
<OptionRow title="Server name">
<InputText
class="shrink"
size="small"
placeholder="CHUNI-PENGUIN"
:maxlength="50"
v-model="prf.current!.sgt.amnet.name"
/>
</OptionRow>
<OptionRow title="Server address">
<InputText
class="shrink"
size="small"
placeholder="http://+:6070"
:maxlength="50"
v-model="prf.current!.sgt.amnet.addr"
/>
</OptionRow>
<OptionRow
title="Use AiMeDB for physical cards"
tooltip="Whether physical cards should use AiMeDB to retrieve access codes. If the game is using a hosted network, enable this option to load the same account data/profile as you would get on a physical cab."
>
<ToggleSwitch v-model="prf.current!.sgt.amnet.physical" />
</OptionRow>
</div>
</OptionCategory>
<OptionCategory title="Misc">
<OptionRow title="OpenSSL bug workaround for Intel ≥10th gen">
<ToggleSwitch v-model="prf.current!.sgt.intel" />
</OptionRow>
<OptionRow title="More segatools options">
<FileEditor filename="segatools-base.ini" />
</OptionRow>
</OptionCategory>
<OptionCategory title="Extensions">
<OptionRow title="Inohara config">
<FileEditor
filename="inohara.cfg"
@ -402,7 +33,8 @@ const extraDisplayOptionsDisabled = computed(() => {
/>
</OptionRow>
<OptionRow title="BepInEx console">
<ToggleSwitch v-model="prf.current!.bepinex.console" />
<!-- @vue-expect-error -->
<ToggleSwitch v-model="prf.current!.data.bepinex.console" />
</OptionRow>
</OptionCategory>
</template>

View File

@ -19,7 +19,6 @@ const prf = usePrfStore();
icon="pi pi-plus"
class="chunithm-button profile-button"
@click="() => prf.create('chunithm')"
:disabled="true"
/>
</div>
<div class="mt-12 flex flex-col flex-wrap align-middle gap-4">

View File

@ -61,7 +61,8 @@ const deleteProfile = async () => {
<div class="flex flex-row flex-wrap align-middle gap-2">
<Button
:disabled="
prf.current?.game === p!.game && prf.current?.name === p!.name
prf.current?.meta.game === p!.game &&
prf.current?.meta.name === p!.name
"
:class="
(p!.game === 'chunithm' ? 'chunithm-button' : 'ongeki-button') +

View File

@ -5,6 +5,7 @@ import ConfirmDialog from 'primevue/confirmdialog';
import ScrollPanel from 'primevue/scrollpanel';
import { useConfirm } from 'primevue/useconfirm';
import { listen } from '@tauri-apps/api/event';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { invoke } from '../invoke';
import { usePrfStore } from '../stores';
@ -54,17 +55,19 @@ const startline = async (force: boolean) => {
const kill = async () => {
await invoke('kill');
startStatus.value = 'ready';
};
const disabledTooltip = computed(() => {
if (prf.current?.sgt.target.length === 0) {
if (prf.current?.data.sgt.target.length === 0) {
return 'The game path must be specified';
}
if (prf.current?.sgt.amfs.length === 0) {
if (prf.current?.data.sgt.amfs.length === 0) {
return 'The amfs path must be specified';
}
if (prf.current?.sgt.hook === null || prf.current?.sgt.hook === undefined) {
if (
prf.current?.data.sgt.hook === null ||
prf.current?.data.sgt.hook === undefined
) {
return 'A segatools hook package is necessary';
}
return null;
@ -72,10 +75,13 @@ const disabledTooltip = computed(() => {
listen('launch-start', () => {
startStatus.value = 'running';
getCurrentWindow().minimize();
});
listen('launch-end', () => {
startStatus.value = 'ready';
getCurrentWindow().unminimize();
getCurrentWindow().setFocus();
});
const messageSplit = (message: any) => {

View File

@ -0,0 +1,99 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import InputText from 'primevue/inputtext';
import Select from 'primevue/select';
import ToggleSwitch from 'primevue/toggleswitch';
import * as path from '@tauri-apps/api/path';
import { readTextFile, writeTextFile } from '@tauri-apps/plugin-fs';
import OptionCategory from '../OptionCategory.vue';
import OptionRow from '../OptionRow.vue';
import { usePkgStore, usePrfStore } from '../../stores';
import { Feature } from '../../types';
import { hasFeature, pkgKey } from '../../util';
const pkgs = usePkgStore();
const prf = usePrfStore();
const aimeCode = ref('');
prf.reload();
const aimeCodeModel = computed({
get() {
return aimeCode.value;
},
async set(value: string) {
aimeCode.value = value;
if (value.match(/^[0-9]{20}$/) || value.length === 0) {
const aime_path = await path.join(await prf.configDir, 'aime.txt');
await writeTextFile(aime_path, aimeCode.value);
}
},
});
(async () => {
const aime_path = await path.join(await prf.configDir, 'aime.txt');
aimeCode.value = await readTextFile(aime_path).catch(() => '');
})();
</script>
<template>
<OptionCategory title="Aime">
<OptionRow title="Aime emulation">
<Select
v-model="prf.current!.data.sgt.aime"
:options="[
{ title: 'none', value: 'Disabled' },
{ title: 'segatools built-in', value: 'BuiltIn' },
...pkgs.byFeature(Feature.Aime).map((p) => {
return {
title: pkgKey(p),
value: hasFeature(p, Feature.AMNet)
? { AMNet: pkgKey(p) }
: { Other: pkgKey(p) },
};
}),
]"
placeholder="none"
option-label="title"
option-value="value"
></Select>
</OptionRow>
<OptionRow title="Aime code">
<InputText
class="shrink"
size="small"
:disabled="prf.current!.data.sgt.aime === 'Disabled'"
:maxlength="20"
placeholder="00000000000000000000"
v-model="aimeCodeModel"
/>
</OptionRow>
<div v-if="prf.current!.data.sgt.aime?.hasOwnProperty('AMNet')">
<OptionRow title="Server name">
<InputText
class="shrink"
size="small"
placeholder="CHUNI-PENGUIN"
:maxlength="50"
v-model="prf.current!.data.sgt.amnet.name"
/>
</OptionRow>
<OptionRow title="Server address">
<InputText
class="shrink"
size="small"
placeholder="http://+:6070"
:maxlength="50"
v-model="prf.current!.data.sgt.amnet.addr"
/>
</OptionRow>
<OptionRow
title="Use AiMeDB for physical cards"
tooltip="Whether physical cards should use AiMeDB to retrieve access codes. If the game is using a hosted network, enable this option to load the same account data/profile as you would get on a physical cab."
>
<ToggleSwitch v-model="prf.current!.data.sgt.amnet.physical" />
</OptionRow>
</div>
</OptionCategory>
</template>

View File

@ -0,0 +1,161 @@
<script setup lang="ts">
import { Ref, computed, ref } from 'vue';
import InputNumber from 'primevue/inputnumber';
import Select from 'primevue/select';
import SelectButton from 'primevue/selectbutton';
import ToggleSwitch from 'primevue/toggleswitch';
import OptionCategory from '../OptionCategory.vue';
import OptionRow from '../OptionRow.vue';
import { invoke } from '../../invoke';
import { usePrfStore } from '../../stores';
const capabilities: Ref<string[]> = ref([]);
const displayList: Ref<{ title: string; value: string }[]> = ref([
{
title: 'Primary',
value: 'default',
},
]);
const prf = usePrfStore();
const extraDisplayOptionsDisabled = computed(() => {
return prf.current?.data.display.target === 'default';
});
const loadDisplays = () => {
const newList = [
{
title: 'Primary',
value: 'default',
},
];
invoke('list_platform_capabilities')
.then(async (v: unknown) => {
let different = false;
if (Array.isArray(v)) {
capabilities.value.push(...v);
}
if (capabilities.value.includes('display')) {
for (const [devName, devString] of (await invoke(
'list_displays'
)) as Array<[string, string]>) {
newList.push({
title: `${devName.replace('\\\\.\\', '')} (${devString})`,
value: devName,
});
if (
displayList.value.find(
(item) => item.value === devName
) === undefined
) {
different = true;
}
}
}
if (displayList.value.length !== newList.length) {
different = true;
}
if (different) {
displayList.value = newList;
}
})
.catch(() => {});
};
loadDisplays();
</script>
<template>
<OptionCategory title="Display">
<OptionRow
v-if="capabilities.includes('display')"
title="Target display"
>
<Select
v-model="prf.current!.data.display.target"
:options="displayList"
option-label="title"
option-value="value"
placeholder="(Disconnected)"
@show="loadDisplays"
></Select>
</OptionRow>
<OptionRow class="number-input" title="Game resolution">
<InputNumber
class="shrink"
size="small"
:min="480"
:max="9999"
:use-grouping="false"
v-model="prf.current!.data.display.rez[0]"
/>
x
<InputNumber
class="shrink"
size="small"
:min="640"
:max="9999"
:use-grouping="false"
v-model="prf.current!.data.display.rez[1]"
/>
</OptionRow>
<OptionRow title="Display mode">
<SelectButton
v-model="prf.current!.data.display.mode"
:options="[
{ title: 'Window', value: 'Window' },
{ title: 'Borderless window', value: 'Borderless' },
{ title: 'Fullscreen', value: 'Fullscreen' },
]"
:allow-empty="false"
option-label="title"
option-value="value"
/>
</OptionRow>
<OptionRow
title="Display rotation"
v-if="capabilities.includes('display')"
>
<SelectButton
v-model="prf.current!.data.display.rotation"
:options="[
{ title: 'Unchanged', value: 0 },
{ title: 'Portrait', value: 90 },
{ title: 'Portrait (flipped)', value: 270 },
]"
:allow-empty="false"
option-label="title"
option-value="value"
:disabled="extraDisplayOptionsDisabled"
/>
</OptionRow>
<OptionRow
v-if="capabilities.includes('display')"
class="number-input"
title="Refresh Rate"
>
<InputNumber
class="shrink"
size="small"
:min="60"
:max="999"
:use-grouping="false"
v-model="prf.current!.data.display.frequency"
:disabled="extraDisplayOptionsDisabled"
/>
</OptionRow>
<OptionRow
title="Borderless fullscreen"
v-if="capabilities.includes('display')"
tooltip="Match display resolution with the game."
>
<ToggleSwitch
:disabled="
extraDisplayOptionsDisabled ||
prf.current?.data.display.mode !== 'Borderless'
"
v-model="prf.current!.data.display.borderless_fullscreen"
/>
</OptionRow>
</OptionCategory>
</template>

View File

@ -0,0 +1,20 @@
<script setup lang="ts">
import ToggleSwitch from 'primevue/toggleswitch';
import FileEditor from '../FileEditor.vue';
import OptionCategory from '../OptionCategory.vue';
import OptionRow from '../OptionRow.vue';
import { usePrfStore } from '../../stores';
const prf = usePrfStore();
</script>
<template>
<OptionCategory title="Misc">
<OptionRow title="OpenSSL bug workaround for Intel ≥10th gen">
<ToggleSwitch v-model="prf.current!.data.sgt.intel" />
</OptionRow>
<OptionRow title="More segatools options">
<FileEditor filename="segatools-base.ini" />
</OptionRow>
</OptionCategory>
</template>

View File

@ -0,0 +1,91 @@
<script setup lang="ts">
import InputNumber from 'primevue/inputnumber';
import InputText from 'primevue/inputtext';
import SelectButton from 'primevue/selectbutton';
import FilePicker from '../FilePicker.vue';
import OptionCategory from '../OptionCategory.vue';
import OptionRow from '../OptionRow.vue';
import { usePrfStore } from '../../stores';
const prf = usePrfStore();
</script>
<template>
<OptionCategory title="Network">
<OptionRow title="Network type">
<SelectButton
v-model="prf.current!.data.network.network_type"
:options="[
{ title: 'Remote', value: 'Remote' },
{ title: 'Local (ARTEMiS)', value: 'Artemis' },
]"
:allow-empty="false"
option-label="title"
option-value="value"
/>
</OptionRow>
<OptionRow
v-if="prf.current!.data.network.network_type == 'Artemis'"
title="ARTEMiS path"
>
<FilePicker
:directory="false"
promptname="index.py"
extension="py"
:value="prf.current!.data.network.local_path"
:callback="
(value: string) =>
(prf.current!.data.network.local_path = value)
"
></FilePicker>
</OptionRow>
<!-- <OptionRow
v-if="prf.current!.data.network.network_type == 'Artemis'"
title="ARTEMiS console"
>
<ToggleSwitch v-model="prf.current!.data.network.local_console" />
</OptionRow> -->
<OptionRow
v-if="prf.current!.data.network.network_type == 'Remote'"
title="Server address"
>
<InputText
class="shrink"
size="small"
:maxlength="40"
placeholder="192.168.1.234"
v-model="prf.current!.data.network.remote_address"
/> </OptionRow
><OptionRow
v-if="prf.current!.data.network.network_type == 'Remote'"
title="Keychip"
>
<InputText
class="shrink"
size="small"
:maxlength="16"
placeholder="A123-01234567890"
v-model="prf.current!.data.network.keychip"
/> </OptionRow
><OptionRow title="Subnet">
<InputText
class="shrink"
size="small"
:maxlength="15"
placeholder="192.168.1.0"
v-model="prf.current!.data.network.subnet"
/>
</OptionRow>
<OptionRow title="Address suffix">
<InputNumber
class="shrink"
size="small"
:maxlength="3"
:min="0"
:max="255"
placeholder="12"
v-model="prf.current!.data.network.suffix"
/>
</OptionRow>
</OptionCategory>
</template>

View File

@ -0,0 +1,112 @@
<script setup lang="ts">
import { computed } from 'vue';
import Select from 'primevue/select';
import FilePicker from '../FilePicker.vue';
import OptionCategory from '../OptionCategory.vue';
import OptionRow from '../OptionRow.vue';
import { usePkgStore, usePrfStore } from '../../stores';
import { Feature } from '../../types';
import { pkgKey } from '../../util';
const prf = usePrfStore();
const pkgs = usePkgStore();
const names = computed(() => {
switch (prf.current?.meta.game) {
case 'ongeki': {
return {
exe: 'mu3.exe',
hook: 'mu3hook',
io: 'mu3io',
};
}
case 'chunithm': {
return {
exe: 'chusanApp.exe',
hook: 'chusanhook',
io: 'chuniio',
};
}
case undefined:
throw new Error('Option tab without a profile');
}
});
</script>
<template>
<OptionCategory title="General">
<OptionRow :title="names.exe">
<FilePicker
:directory="false"
:promptname="names.exe"
extension="exe"
:value="prf.current!.data.sgt.target"
:callback="
(value: string) => (prf.current!.data.sgt.target = value)
"
></FilePicker>
</OptionRow>
<OptionRow title="amfs">
<FilePicker
:directory="true"
placeholder="amfs"
:value="prf.current!.data.sgt.amfs"
:callback="
(value: string) => (prf.current!.data.sgt.amfs = value)
"
></FilePicker>
</OptionRow>
<OptionRow title="option">
<FilePicker
:directory="true"
placeholder="option"
:value="prf.current!.data.sgt.option"
:callback="
(value: string) => (prf.current!.data.sgt.option = value)
"
></FilePicker>
</OptionRow>
<OptionRow title="appdata">
<FilePicker
:directory="true"
:value="prf.current!.data.sgt.appdata"
:callback="
(value: string) => (prf.current!.data.sgt.appdata = value)
"
></FilePicker>
</OptionRow>
<OptionRow :title="names.hook">
<Select
v-model="prf.current!.data.sgt.hook"
:options="
pkgs
.byFeature(
prf.current?.meta.game === 'ongeki'
? Feature.Mu3Hook
: Feature.ChusanHook
)
.map((p) => {
return { title: pkgKey(p), value: pkgKey(p) };
})
"
option-label="title"
option-value="value"
></Select>
</OptionRow>
<OptionRow :title="names.io" v-if="prf.current?.meta.game === 'ongeki'">
<Select
v-model="prf.current!.data.sgt.io"
placeholder="segatools built-in"
:options="[
{ title: 'segatools built-in', value: null },
...pkgs.byFeature(Feature.Mu3IO).map((p) => {
return { title: pkgKey(p), value: pkgKey(p) };
}),
]"
option-label="title"
option-value="value"
></Select>
</OptionRow>
</OptionCategory>
</template>

View File

@ -100,14 +100,8 @@ export const usePkgStore = defineStore('pkg', {
(c) => !state.excludeCategories.includes(c)
))
),
hooks: (state) =>
Object.values(state.pkg).filter((p) => hasFeature(p, Feature.Hook)),
gameIOs: (state) =>
Object.values(state.pkg).filter((p) =>
hasFeature(p, Feature.GameIO)
),
aimes: (state) =>
Object.values(state.pkg).filter((p) => hasFeature(p, Feature.Aime)),
byFeature: (state) => (feature: Feature) =>
Object.values(state.pkg).filter((p) => hasFeature(p, feature)),
},
actions: {
setupListeners() {
@ -189,18 +183,14 @@ export const usePrfStore = defineStore('prf', () => {
() =>
pkg !== undefined &&
current.value !== null &&
current.value?.mods.includes(pkgKey(pkg))
current.value?.data.mods.includes(pkgKey(pkg))
);
const reload = async () => {
const p = (await invoke('get_current_profile')) as any;
if (p != null && 'OngekiProfile' in p) {
current.value = { ...p.OngekiProfile, game: 'ongeki' };
} else {
current.value = null;
}
const p = (await invoke('get_current_profile')) as Profile;
current.value = p;
if (current.value !== null) {
changePrimaryColor(current.value.game);
changePrimaryColor(current.value.meta.game);
} else {
changePrimaryColor(null);
}
@ -231,10 +221,10 @@ export const usePrfStore = defineStore('prf', () => {
});
if (
current.value?.game === profile.game &&
current.value.name === profile.name
current.value?.meta.game === profile.game &&
current.value.meta.name === profile.name
) {
current.value.name = name;
current.value.meta.name = name;
}
await reloadList();
@ -272,7 +262,7 @@ export const usePrfStore = defineStore('prf', () => {
const configDir = computed(async () => {
return await path.join(
generalStore.configDir,
`profile-${current.value?.game}-${current.value?.name}`
`profile-${current.value?.meta.game}-${current.value?.meta.name}`
);
});
@ -283,7 +273,7 @@ export const usePrfStore = defineStore('prf', () => {
watchEffect(async () => {
if (current.value !== null) {
await invoke('sync_current_profile', {
profile: { OngekiProfile: current.value },
data: current.value.data,
});
if (timeout !== null) {
clearTimeout(timeout);

View File

@ -24,11 +24,12 @@ export interface Package {
}
export enum Feature {
Mod = 0b00001,
Hook = 0b00010,
GameIO = 0b00100,
Aime = 0b01000,
AMNet = 0b10000,
Mod = 1 << 0,
Aime = 1 << 1,
AMNet = 1 << 2,
Mu3Hook = 1 << 3,
Mu3IO = 1 << 4,
ChusanHook = 1 << 5,
}
export type Status =
@ -45,6 +46,14 @@ export interface ProfileMeta {
name: string;
}
export interface ProfileData {
mods: string[];
sgt: SegatoolsConfig;
display: DisplayConfig;
network: NetworkConfig;
bepinex: BepInExConfig;
}
export interface SegatoolsConfig {
target: string;
hook: string | null;
@ -83,12 +92,9 @@ export interface BepInExConfig {
console: boolean;
}
export interface Profile extends ProfileMeta {
mods: string[];
sgt: SegatoolsConfig;
display: DisplayConfig;
network: NetworkConfig;
bepinex: BepInExConfig;
export interface Profile {
meta: ProfileMeta;
data: ProfileData;
}
export type Module = 'sgt' | 'display' | 'network';