Compare commits
10 Commits
Author | SHA1 | Date | |
---|---|---|---|
b75cc8f240 | |||
407b34a884 | |||
890d26e883 | |||
2aff5834b9 | |||
69f2c83109 | |||
dbbd80c6c3 | |||
3479804dca | |||
aaeed669df | |||
7084f40404 | |||
f7e9d7d7db |
30
CHANGELOG.md
30
CHANGELOG.md
@ -1,3 +1,33 @@
|
||||
## 0.13.0
|
||||
|
||||
- Added profile imports/exports
|
||||
- Fixed error when trying to open an empty Ongeki profile
|
||||
- Switched the default color scheme from invisible to purple
|
||||
|
||||
## 0.12.1
|
||||
|
||||
- Chunithm: fixed crash when using mempatcher
|
||||
|
||||
## 0.12.0
|
||||
|
||||
- Ongeki: cache and mu3.ini config are now split per-profile (requires mu3-mods 3.7+)
|
||||
- Ongeki: added the few config options of mu3.ini that aren't available in TestMenuConfig, or require a restart
|
||||
- Chunithm: added Lumi+ patches
|
||||
- Added support for a non-standard `games` manifest entry intended for local packages
|
||||
- Expected to be an array containing "ongeki", "chunithm" or both
|
||||
- Example: { "games": ["ongeki"] }
|
||||
- Added a button linking to the profile config folder
|
||||
- Fixed the button linking to the data folder showing up when the folder does not exist
|
||||
- Uninstalled tool packages are no longer automatically deselected, as that caused issues
|
||||
|
||||
## 0.11.1
|
||||
|
||||
- Improved help pages
|
||||
|
||||
## 0.11.0
|
||||
|
||||
- Added help pages
|
||||
|
||||
## 0.10.1
|
||||
|
||||
- Fixed the order of cells in the CHUNITHM keyboard
|
||||
|
22
README.md
22
README.md
@ -1,17 +1,21 @@
|
||||
Looking for maimai DX players willing to help develop/test maimai DX support
|
||||
|
||||
# STARTLINER
|
||||
|
||||
A simple and easy to use launcher, configuration tool and mod manager
|
||||
for O.N.G.E.K.I. and CHUNITHM, using [Rainycolor Watercolor](https://rainy.patafour.zip).
|
||||
This is a program that seeks to streamline game data configuration, currently supporting O.N.G.E.K.I. and CHUNITHM.
|
||||
|
||||
STARTLINER is four things:
|
||||
|
||||
- a mod installer and updater, powered by [Rainycolor Watercolor](https://rainy.patafour.zip),
|
||||
- a configuration GUI for segatools,
|
||||
- a glorified `start.bat` clicker, with automatic monitor setup and rollback,
|
||||
- [an abstraction allowing data configuration without touching the game directory](https://gitea.tendokyu.moe/akanyan/STARTLINER/wiki/Architecture-details).
|
||||
|
||||
STARTLINER's core design principle is to modify, configure and launch games without tampering with them.
|
||||
This makes it possible to keep data cleaner than ever, and to have several configurations pointing at the same data.
|
||||
|
||||
Made with Rust (Tauri) and Vue. Technically multiplatform. Contributions welcome.
|
||||
|
||||
## Features
|
||||
|
||||
- [Clean](https://gitea.tendokyu.moe/akanyan/STARTLINER/wiki/Architecture-details) data modding
|
||||
- Segatools configuration
|
||||
- Monitor configuration with automatic rollback
|
||||
- Support for multiple configurations pointing at the same data
|
||||
|
||||
## Usage
|
||||
|
||||
Download a prebuilt binary from [Releases](https://gitea.tendokyu.moe/akanyan/STARTLINER/releases) or build it yourself:
|
||||
|
@ -1,7 +1,8 @@
|
||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||
use std::path::Path;
|
||||
use std::time::SystemTime;
|
||||
use crate::model::config::GlobalConfig;
|
||||
use crate::model::patch::PatchFileVec;
|
||||
use crate::model::patch::{PatchFileVec, PatchList};
|
||||
use crate::pkg::{Feature, Status};
|
||||
use crate::profiles::types::Profile;
|
||||
use crate::{model::misc::Game, pkg::PkgKey};
|
||||
@ -165,4 +166,22 @@ impl AppData {
|
||||
panic!("unable to initialize the logger? {:?}", e);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn patches_enabled(&self, game_target: impl AsRef<Path>, amd_target: impl AsRef<Path>) -> Result<Vec<&PatchList>> {
|
||||
let ch1 = sha256::try_digest(game_target.as_ref())?;
|
||||
let ch2 = sha256::try_digest(amd_target.as_ref())?;
|
||||
|
||||
let mut res = Vec::new();
|
||||
for pfile in &self.patch_vec.0 {
|
||||
for plist in &pfile.0 {
|
||||
let this_hash = plist.sha256.to_ascii_lowercase();
|
||||
log::debug!("checking {}", this_hash);
|
||||
if this_hash == ch1 || this_hash == ch2 {
|
||||
log::debug!("enabling {this_hash}");
|
||||
res.push(plist);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(res)
|
||||
}
|
||||
}
|
||||
|
@ -69,9 +69,15 @@ pub async fn startline(app: AppHandle, refresh: bool) -> Result<(), String> {
|
||||
}
|
||||
if let Some(p) = &appd.profile {
|
||||
log::debug!("{}", hash);
|
||||
|
||||
let patches_enabled = appd.patches_enabled(
|
||||
&p.data.sgt.target,
|
||||
&p.data.sgt.target.parent().unwrap().join("amdaemon.exe")
|
||||
).map_err(|e| e.to_string())?;
|
||||
|
||||
let info = p.prepare_display()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let lineup_res = p.line_up(hash, refresh, &appd.patch_vec).await
|
||||
let lineup_res = p.line_up(hash, refresh, patches_enabled).await
|
||||
.map_err(|e| e.to_string());
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
@ -404,6 +410,33 @@ pub async fn create_shortcut(app: AppHandle, profile_meta: ProfileMeta) -> Resul
|
||||
util::create_shortcut(app, &profile_meta).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn export_profile(state: State<'_, Mutex<AppData>>, export_keychip: bool, files: Vec<String>) -> Result<(), String> {
|
||||
log::debug!("invoke: export_profile({:?}, {:?} files)", export_keychip, files.len());
|
||||
|
||||
let appd = state.lock().await;
|
||||
match &appd.profile {
|
||||
Some(p) => {
|
||||
p.export(export_keychip, files)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
None => {
|
||||
let err = "export_profile: no profile".to_owned();
|
||||
log::error!("{}", err);
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn import_profile(state: State<'_, Mutex<AppData>>, path: PathBuf) -> Result<(), String> {
|
||||
log::debug!("invoke: import_profile({:?})", path);
|
||||
|
||||
Profile::import(path).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_platform_capabilities() -> Result<Vec<String>, ()> {
|
||||
log::debug!("invoke: list_platform_capabilities");
|
||||
|
@ -205,6 +205,8 @@ pub async fn run(_args: Vec<String>) {
|
||||
cmd::save_current_profile,
|
||||
cmd::load_segatools_ini,
|
||||
cmd::create_shortcut,
|
||||
cmd::export_profile,
|
||||
cmd::import_profile,
|
||||
|
||||
cmd::get_global_config,
|
||||
cmd::set_global_config,
|
||||
|
@ -1,6 +1,6 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::pkg::{Status, PkgKey, PkgKeyVersion};
|
||||
use crate::pkg::{PkgKey, PkgKeyVersion};
|
||||
|
||||
use super::misc::Game;
|
||||
|
||||
@ -14,7 +14,10 @@ pub struct PackageManifest {
|
||||
pub dependencies: BTreeSet<PkgKeyVersion>,
|
||||
|
||||
#[serde(default)]
|
||||
pub installers: Vec<BTreeMap<String, serde_json::Value>>
|
||||
pub installers: Vec<BTreeMap<String, serde_json::Value>>,
|
||||
|
||||
#[serde(default)]
|
||||
pub games: Option<Vec<Game>>,
|
||||
}
|
||||
|
||||
pub type PackageList = BTreeMap<PkgKey, PackageListEntry>;
|
||||
@ -22,6 +25,5 @@ pub type PackageList = BTreeMap<PkgKey, PackageListEntry>;
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct PackageListEntry {
|
||||
pub version: String,
|
||||
pub status: Status,
|
||||
pub games: Vec<Game>,
|
||||
}
|
@ -166,11 +166,26 @@ pub enum Mu3Audio {
|
||||
Excl2Ch,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, Default)]
|
||||
#[derive(Deserialize, Serialize, Clone, Debug)]
|
||||
#[serde(default)]
|
||||
pub struct Mu3Ini {
|
||||
pub audio: Option<Mu3Audio>,
|
||||
pub sample_rate: i32,
|
||||
pub blacklist: Option<(i32, i32)>,
|
||||
pub gp: i32,
|
||||
pub enable_bonus_tracks: bool,
|
||||
}
|
||||
|
||||
impl Default for Mu3Ini {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
audio: Some(Mu3Audio::Shared),
|
||||
sample_rate: 48_000,
|
||||
blacklist: Some((10000, 19999)),
|
||||
gp: 999,
|
||||
enable_bonus_tracks: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug)]
|
||||
|
@ -1,25 +1,23 @@
|
||||
use std::path::Path;
|
||||
use anyhow::Result;
|
||||
use crate::model::patch::{Patch, PatchData, PatchFileVec, PatchSelection, PatchSelectionData};
|
||||
use crate::model::patch::{Patch, PatchData, PatchList, PatchSelection, PatchSelectionData};
|
||||
|
||||
impl PatchSelection {
|
||||
pub async fn render_to_file(
|
||||
&self,
|
||||
filename: &str,
|
||||
patches: &PatchFileVec,
|
||||
patch_lists: &Vec<&PatchList>,
|
||||
path: impl AsRef<Path>
|
||||
) -> Result<()> {
|
||||
let mut res = "".to_owned();
|
||||
|
||||
for file in &patches.0 {
|
||||
for list in &file.0 {
|
||||
if list.filename != filename {
|
||||
continue;
|
||||
}
|
||||
for patch in &list.patches {
|
||||
if let Some(selection) = self.0.get(&patch.id) {
|
||||
res += &Self::render(filename, patch, selection);
|
||||
}
|
||||
for list in patch_lists {
|
||||
if list.filename != filename {
|
||||
continue;
|
||||
}
|
||||
for patch in &list.patches {
|
||||
if let Some(selection) = self.0.get(&patch.id) {
|
||||
res += &Self::render(filename, patch, selection);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,11 +1,11 @@
|
||||
use std::path::Path;
|
||||
use anyhow::Result;
|
||||
use anyhow::{anyhow, Result};
|
||||
use ini::Ini;
|
||||
use crate::model::profile::{Mu3Audio, Mu3Ini};
|
||||
|
||||
impl Mu3Ini {
|
||||
pub fn line_up(&self, game_path: impl AsRef<Path>) -> Result<()> {
|
||||
let file = game_path.as_ref().join("mu3.ini");
|
||||
pub fn line_up(&self, data_dir: impl AsRef<Path>, cfg_dir: impl AsRef<Path>) -> Result<()> {
|
||||
let file = cfg_dir.as_ref().join("mu3.ini");
|
||||
|
||||
if !file.exists() {
|
||||
std::fs::write(&file, "")?;
|
||||
@ -20,9 +20,26 @@ impl Mu3Ini {
|
||||
Mu3Audio::Excl2Ch => "2",
|
||||
};
|
||||
|
||||
ini.with_section(Some("Sound")).set("WasapiExclusive", value);
|
||||
ini.with_section(Some("Sound"))
|
||||
.set("WasapiExclusive", value)
|
||||
.set("SampleRate", self.sample_rate.to_string());
|
||||
}
|
||||
|
||||
if let Some(blacklist) = self.blacklist {
|
||||
ini.with_section(Some("Extra"))
|
||||
.set("BlacklistMin", blacklist.0.to_string())
|
||||
.set("BlacklistMax", blacklist.1.to_string());
|
||||
}
|
||||
|
||||
let cache_path = data_dir.as_ref().join("mu3-mods-cache");
|
||||
let cache_path = cache_path.to_str()
|
||||
.ok_or_else(|| anyhow!("Invalid cache path"))?;
|
||||
|
||||
ini.with_section(Some("Extra"))
|
||||
.set("GP", self.gp.to_string())
|
||||
.set("CacheDir", cache_path)
|
||||
.set("UnlockBonusTracks", crate::util::bool_to_01(self.enable_bonus_tracks));
|
||||
|
||||
ini.write_to_file(file)?;
|
||||
|
||||
Ok(())
|
||||
|
@ -5,30 +5,30 @@ use crate::{model::{misc::{ConfigHook, ConfigHookAime, ConfigHookAimeUnit, Confi
|
||||
use crate::pkg_store::PackageStore;
|
||||
|
||||
impl Segatools {
|
||||
pub fn fix(&mut self, store: &PackageStore) {
|
||||
macro_rules! remove_if_nonpresent {
|
||||
($item:expr,$key:expr,$emptyval:expr,$store:expr) => {
|
||||
if let Ok(pkg) = $store.get($key) {
|
||||
if pkg.loc.is_none() {
|
||||
$item = $emptyval;
|
||||
}
|
||||
} else {
|
||||
$item = $emptyval;
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn fix(&mut self, _store: &PackageStore) {
|
||||
// macro_rules! remove_if_nonpresent {
|
||||
// ($item:expr,$key:expr,$emptyval:expr,$store:expr) => {
|
||||
// if let Ok(pkg) = $store.get($key) {
|
||||
// if pkg.loc.is_none() {
|
||||
// $item = $emptyval;
|
||||
// }
|
||||
// } else {
|
||||
// $item = $emptyval;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
if let Some(key) = &self.hook {
|
||||
remove_if_nonpresent!(self.hook, key, None, store);
|
||||
}
|
||||
if let IOSelection::Custom(key) = &self.io2 {
|
||||
remove_if_nonpresent!(self.io2, key, IOSelection::default(), store);
|
||||
}
|
||||
match &self.aime {
|
||||
Aime::AMNet(key) => remove_if_nonpresent!(self.aime, key, Aime::BuiltIn, store),
|
||||
Aime::Other(key) => remove_if_nonpresent!(self.aime, key, Aime::BuiltIn, store),
|
||||
_ => {},
|
||||
}
|
||||
// if let Some(key) = &self.hook {
|
||||
// remove_if_nonpresent!(self.hook, key, None, store);
|
||||
// }
|
||||
// if let IOSelection::Custom(key) = &self.io2 {
|
||||
// remove_if_nonpresent!(self.io2, key, IOSelection::default(), store);
|
||||
// }
|
||||
// match &self.aime {
|
||||
// Aime::AMNet(key) => remove_if_nonpresent!(self.aime, key, Aime::BuiltIn, store),
|
||||
// Aime::Other(key) => remove_if_nonpresent!(self.aime, key, Aime::BuiltIn, store),
|
||||
// _ => {},
|
||||
// }
|
||||
}
|
||||
pub fn load_from_ini(&mut self, ini: &Ini, config_dir: impl AsRef<Path>) -> Result<()> {
|
||||
log::debug!("loading sgt");
|
||||
|
@ -15,10 +15,14 @@ impl PatchFileVec {
|
||||
let mut res = Vec::new();
|
||||
for f in std::fs::read_dir(path)? {
|
||||
let f = f?;
|
||||
let f = f.path();
|
||||
res.push(
|
||||
serde_json5::from_str::<PatchFile>(&std::fs::read_to_string(f)?)?
|
||||
);
|
||||
let f = &f.path();
|
||||
match serde_json5::from_str::<PatchFile>(&std::fs::read_to_string(f)?) {
|
||||
Ok(parsed) => res.push(parsed),
|
||||
Err(e) => {
|
||||
log::error!("Error parsing {f:?}: {e}");
|
||||
anyhow::bail!("Error parsing {f:?}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(PatchFileVec(res))
|
||||
}
|
||||
@ -26,20 +30,21 @@ impl PatchFileVec {
|
||||
pub fn find_patches(&self, target: impl AsRef<Path>) -> Result<Vec<Patch>> {
|
||||
let checksum = try_digest(target.as_ref())?;
|
||||
|
||||
let mut res = Vec::new();
|
||||
let mut res_patches = Vec::new();
|
||||
for pfile in &self.0 {
|
||||
for plist in &pfile.0 {
|
||||
log::debug!("checking {}", plist.sha256);
|
||||
if plist.sha256 == checksum {
|
||||
let this_hash = plist.sha256.to_ascii_lowercase();
|
||||
log::debug!("checking {}", this_hash);
|
||||
if this_hash == checksum {
|
||||
let mut cloned = plist.clone().patches;
|
||||
res.append(&mut cloned);
|
||||
res_patches.append(&mut cloned);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if res.len() == 0 {
|
||||
if res_patches.len() == 0 {
|
||||
log::warn!("no matching patchset for {:?} ({})", target.as_ref(), checksum);
|
||||
}
|
||||
Ok(res)
|
||||
Ok(res_patches)
|
||||
}
|
||||
}
|
@ -120,7 +120,7 @@ impl Package {
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn from_dir(dir: PathBuf, source: PackageSource) -> Result<Package> {
|
||||
pub async fn from_dir(dir: PathBuf, source: PackageSource) -> Result<(Package, Option<Vec<Game>>)> {
|
||||
let str = fs::read_to_string(dir.join("manifest.json")).await?;
|
||||
let mft: local::PackageManifest = serde_json::from_str(&str)?;
|
||||
|
||||
@ -133,7 +133,7 @@ impl Package {
|
||||
let status = Self::parse_status(&mft, &dir);
|
||||
let dependencies = Self::sanitize_deps(mft.dependencies);
|
||||
|
||||
Ok(Package {
|
||||
Ok((Package {
|
||||
namespace: Self::dir_to_namespace(&dir)?,
|
||||
name: mft.name.clone(),
|
||||
description: mft.description.clone(),
|
||||
@ -146,7 +146,7 @@ impl Package {
|
||||
}),
|
||||
rmt: None,
|
||||
source
|
||||
})
|
||||
}, mft.games))
|
||||
}
|
||||
|
||||
pub fn key(&self) -> PkgKey {
|
||||
|
@ -83,7 +83,7 @@ impl PackageStore {
|
||||
|
||||
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, PackageSource::Rainy).await {
|
||||
if let Ok((pkg, _)) = Package::from_dir(dir, PackageSource::Rainy).await {
|
||||
self.update_nonremote(key, pkg);
|
||||
} else {
|
||||
log::error!("couldn't reload {}", key);
|
||||
@ -102,7 +102,13 @@ impl PackageStore {
|
||||
}
|
||||
|
||||
while let Some(res) = futures.join_next().await {
|
||||
if let Ok(Ok(pkg)) = res {
|
||||
if let Ok(Ok((pkg, locally_declared_games))) = res {
|
||||
if let Some(games) = locally_declared_games {
|
||||
self.meta_list.insert(pkg.key(), PackageListEntry {
|
||||
version: pkg.loc.as_ref().unwrap().version.clone(),
|
||||
games
|
||||
});
|
||||
}
|
||||
self.update_nonremote(pkg.key(), pkg);
|
||||
}
|
||||
}
|
||||
@ -152,7 +158,6 @@ impl PackageStore {
|
||||
PackageListEntry {
|
||||
// from_rainy() is guaranteed to include rmt
|
||||
version: r.rmt.as_ref().unwrap().version.clone(),
|
||||
status: Status::Unchecked,
|
||||
games: vec![ game ],
|
||||
}
|
||||
});
|
||||
|
@ -1,6 +1,6 @@
|
||||
pub use types::{Profile, ProfileData, ProfileMeta, ProfilePaths, StartPayload};
|
||||
use std::{collections::{BTreeMap, BTreeSet}, path::{Path, PathBuf}};
|
||||
use crate::{model::{misc::Game, patch::{PatchFileVec, PatchSelection}, profile::{Aime, ChunithmKeyboard, IOSelection, Keyboard, Mu3Ini, OngekiKeyboard, ProfileModule}}, modules::{display_windows::DisplayInfo, package::prepare_packages}, pkg::PkgKey, pkg_store::PackageStore, util};
|
||||
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;
|
||||
@ -10,6 +10,7 @@ use std::fs::File;
|
||||
use tokio::process::Command;
|
||||
use tokio::task::JoinSet;
|
||||
|
||||
pub mod template;
|
||||
pub mod types;
|
||||
|
||||
impl Profile {
|
||||
@ -28,7 +29,7 @@ impl Profile {
|
||||
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 { audio: None, blacklist: None }) } else { None },
|
||||
mu3_ini: if meta.game == Game::Ongeki { Some(Mu3Ini::default()) } else { None },
|
||||
keyboard:
|
||||
if meta.game == Game::Ongeki {
|
||||
Some(Keyboard::Ongeki(OngekiKeyboard::default()))
|
||||
@ -67,6 +68,18 @@ impl Profile {
|
||||
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());
|
||||
}
|
||||
|
||||
Self::load_existing_mu3_ini(&data, &ProfileMeta { game, name: name.clone() })?;
|
||||
}
|
||||
if game == Game::Chunithm {
|
||||
if data.keyboard.is_none() {
|
||||
@ -168,7 +181,7 @@ impl Profile {
|
||||
|
||||
Ok(info)
|
||||
}
|
||||
pub async fn line_up(&self, pkg_hash: String, refresh: bool, patch_files: &PatchFileVec) -> Result<()> {
|
||||
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?;
|
||||
}
|
||||
@ -203,13 +216,13 @@ impl Profile {
|
||||
}
|
||||
|
||||
if let Some(mu3ini) = &self.data.mu3_ini {
|
||||
mu3ini.line_up(&self.data.sgt.target.parent().unwrap())?;
|
||||
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", patch_files, self.data_dir().join("patch-amd.mph")),
|
||||
patches.render_to_file("chusanApp.exe", patch_files, self.data_dir().join("patch-game.mph"))
|
||||
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"))
|
||||
)?;
|
||||
}
|
||||
|
||||
@ -283,6 +296,14 @@ impl Profile {
|
||||
"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")
|
||||
@ -403,6 +424,19 @@ impl Profile {
|
||||
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 {
|
||||
|
90
rust/src/profiles/template.rs
Normal file
90
rust/src/profiles/template.rs
Normal file
@ -0,0 +1,90 @@
|
||||
use std::{fs::File, io::{Read, Write}, path::PathBuf};
|
||||
use zip::{write::FileOptions, ZipArchive, ZipWriter};
|
||||
use crate::util;
|
||||
use super::{Profile, ProfilePaths};
|
||||
|
||||
impl Profile {
|
||||
fn find_template_json(archive: &mut ZipArchive<File>) -> anyhow::Result<String> {
|
||||
if let Ok(mut file) = archive.by_name("template.json") {
|
||||
let mut contents = Vec::new();
|
||||
file.read_to_end(&mut contents)?;
|
||||
Ok(String::from_utf8(contents)?)
|
||||
} else {
|
||||
anyhow::bail!("invalid template: no template.json found")
|
||||
}
|
||||
}
|
||||
pub fn import(path: PathBuf) -> anyhow::Result<()> {
|
||||
let file = File::open(path)?;
|
||||
let mut archive = ZipArchive::new(file)?;
|
||||
|
||||
match Self::find_template_json(&mut archive) {
|
||||
Ok(raw_p) => {
|
||||
let p = serde_json::from_str::<Profile>(&raw_p)?;
|
||||
let dir = util::config_dir().join(format!("profile-{}-{}", &p.meta.game, &p.meta.name));
|
||||
if dir.exists() {
|
||||
anyhow::bail!("profile {} already exists", &p.meta.name);
|
||||
}
|
||||
std::fs::create_dir(&dir)?;
|
||||
archive.extract(&dir)?;
|
||||
std::fs::remove_file(dir.join("template.json"))?;
|
||||
std::fs::write(dir.join("profile.json"), serde_json::to_string_pretty(&p.data)?)?;
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
pub fn export(&self, export_keychip: bool, extra_files: Vec<String>) -> anyhow::Result<()> {
|
||||
let mut prf = self.clone();
|
||||
|
||||
let dir = util::config_dir().join("exports");
|
||||
|
||||
if !dir.exists() {
|
||||
std::fs::create_dir(&dir)?;
|
||||
}
|
||||
|
||||
let path = dir.join(format!("{}-{}-template.zip", &self.meta.game, &self.meta.name));
|
||||
|
||||
{
|
||||
let sgt = &mut prf.data.sgt;
|
||||
sgt.target = PathBuf::new();
|
||||
if sgt.amfs.is_absolute() {
|
||||
sgt.amfs = PathBuf::new();
|
||||
}
|
||||
if sgt.option.is_absolute() {
|
||||
sgt.option = PathBuf::new();
|
||||
}
|
||||
if sgt.appdata.is_absolute() {
|
||||
sgt.appdata = PathBuf::new();
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let network = &mut prf.data.network;
|
||||
if network.local_path.is_absolute() {
|
||||
network.local_path = PathBuf::new();
|
||||
}
|
||||
if !export_keychip {
|
||||
network.keychip = String::new();
|
||||
}
|
||||
}
|
||||
|
||||
let file = File::create(&path)?;
|
||||
let mut zip = ZipWriter::new(file);
|
||||
let options: FileOptions<'_, ()> = FileOptions::default();
|
||||
zip.start_file("template.json", options)?;
|
||||
zip.write_all(&serde_json::to_string_pretty(&prf)?.as_bytes())?;
|
||||
|
||||
for file in extra_files {
|
||||
log::debug!("extra file: {file}");
|
||||
zip.start_file(&file, options)?;
|
||||
zip.write_all(&std::fs::read(self.config_dir().join(file))?)?;
|
||||
}
|
||||
|
||||
zip.finish()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
@ -199,7 +199,7 @@ pub fn create_shortcut(
|
||||
obj.SetDescription(&format!("{} – {} (STARTLINER)", &meta.game.print(), &meta.name))?;
|
||||
obj.SetArguments(&format!("--start --game {} --profile {}", &meta.game, &meta.name))?;
|
||||
obj.SetIconLocation(
|
||||
target_dir.join(format!("icon-{}.ico", &meta.game)).to_str().ok_or_else(|| anyhow!("Illegal icon path"))?,
|
||||
target_dir.join(format!("icon-{}.ico", &meta.game)).to_str().ok_or_else(|| anyhow!("Illegal icon path"))?,
|
||||
0
|
||||
)?;
|
||||
|
||||
|
@ -1,4 +1,154 @@
|
||||
[
|
||||
{
|
||||
filename: 'chusanApp.exe',
|
||||
version: '2.26.00',
|
||||
sha256: 'AD2DCC02CE52B3FFF24A2919F8617854581DD2E2C0378EA13D84438FCCA2D522',
|
||||
patches: [
|
||||
{
|
||||
id: 'standard-shared-audio',
|
||||
name: "Force shared audio mode, system audio sample rate must be 48000Hz",
|
||||
tooltip: "Improves compatibility, but may increase latency",
|
||||
patches: [
|
||||
{offset: 0xF233DA, off: [0x01], on: [0x00]}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'standard-2ch',
|
||||
name: "Force 2 channel audio output",
|
||||
tooltip: "May cause bass overload",
|
||||
patches: [
|
||||
{offset: 0xF234B1, off: [0x75, 0x3f], on: [0x90, 0x90]}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'standard-song-timer',
|
||||
name: "Disable song select timer",
|
||||
patches: [
|
||||
{offset: 0xA03916, off: [0x74], on: [0xeb]}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'standard-map-timer',
|
||||
name: "Map selection timer",
|
||||
tooltip: "If set to negative, the timer becomes 968 + value (e.g. 968 + -1 = 967)",
|
||||
type: "number",
|
||||
offset: 0x965B37,
|
||||
default: 30,
|
||||
size: 1,
|
||||
min: -128,
|
||||
max: 127,
|
||||
},
|
||||
{
|
||||
id: 'standard-ticket-timer',
|
||||
name: "Ticket selection timer",
|
||||
tooltip: "If set to negative, the timer becomes 968 + value (e.g. 968 + -1 = 967)",
|
||||
type: "number",
|
||||
offset: 0x9592C2,
|
||||
default: 60,
|
||||
size: 1,
|
||||
min: -128,
|
||||
max: 127,
|
||||
},
|
||||
{
|
||||
id: 'standard-course-timer',
|
||||
name: "Course selection timer",
|
||||
tooltip: "If set to negative, the timer becomes 968 + value (e.g. 968 + -1 = 967)",
|
||||
type: "number",
|
||||
offset: 0xA0EADB,
|
||||
default: 30,
|
||||
size: 1,
|
||||
min: -128,
|
||||
max: 127,
|
||||
},
|
||||
{
|
||||
id: 'standard-unlimited-tracks',
|
||||
name: "Unlimited maximum tracks",
|
||||
tooltip: "Must check to play more than 7 tracks per credit",
|
||||
patches: [
|
||||
{offset: 0x71E2E0, off: [0xf0], on: [0xc0]}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'standard-maximum-tracks',
|
||||
type: "number",
|
||||
name: "Maximum tracks",
|
||||
offset: 0x3980C1,
|
||||
default: 3,
|
||||
size: 1,
|
||||
min: 3,
|
||||
max: 12
|
||||
},
|
||||
{
|
||||
id: 'standard-no-encryption',
|
||||
name: "No encryption",
|
||||
tooltip: "Will also disable TLS",
|
||||
patches: [
|
||||
{offset: 0x1DE29E8, off: [0xE1], on: [0x00]},
|
||||
{offset: 0x1DE29EC, off: [0xE1], on: [0x00]}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'standard-no-tls',
|
||||
name: "No TLS",
|
||||
tooltip: "Title server workaround",
|
||||
patches: [
|
||||
{offset: 0xF06447, off: [0x80], on: [0x00]}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'standard-head-to-head',
|
||||
name: "Patch for head-to-head play",
|
||||
tooltip: "Fix infinite sync while trying to connect to head to head play",
|
||||
patches: [
|
||||
{offset: 0x6533A3, off: [0x01], on: [0x00]}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'standard-bypass-1080p',
|
||||
name: "Bypass 1080p monitor check",
|
||||
patches: [
|
||||
{offset: 0x1CCBF, off: [0x81, 0xbc, 0x24, 0xb8, 0x02, 0x00, 0x00, 0x80, 0x07, 0x00, 0x00, 0x75, 0x1f, 0x81, 0xbc, 0x24, 0xbc, 0x02, 0x00, 0x00, 0x38, 0x04, 0x00, 0x00, 0x75, 0x12], on: [0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90]}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'standard-bypass-120hz',
|
||||
name: "Bypass 120Hz monitor check",
|
||||
patches: [
|
||||
{offset: 0x1CCB1, off: [0x85, 0xc0], on: [0xeb, 0x30]}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'standard-force-free-play-text',
|
||||
name: "Force FREE PLAY credit text",
|
||||
tooltip: "Replaces the credit count with FREE PLAY",
|
||||
patches: [
|
||||
{offset: 0x3875A4, off: [0x3c, 0x01], on: [0x38, 0xc0]}
|
||||
]
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
filename: 'amdaemon.exe',
|
||||
version: '2.25.00',
|
||||
sha256: '00FB867D1EE821033101B8773FAC116A45DF1939D23C38E9DAFC9B86CD5A3777',
|
||||
patches: [
|
||||
{
|
||||
id: 'standard-localhost',
|
||||
name: "Allow 127.0.0.1/localhost as the network server",
|
||||
patches: [
|
||||
{ offset: 0x6E28A4, off: [0x31, 0x32, 0x37, 0x2F], on: [0x30, 0x2F, 0x38, 0x00] },
|
||||
{ offset: 0x3C94C4, off: [0xFF, 0x15, 0xC6, 0x2F, 0x1B, 0x00, 0x8B], on: [0x33, 0xC0, 0x48, 0x83, 0xC4, 0x28, 0xC3] }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'standard-credit-freeze',
|
||||
name: "Infinite credits",
|
||||
patches: [
|
||||
{ offset: 0x2BBBC8, off: [0x28], on: [0x08] }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
filename: 'chusanApp.exe',
|
||||
version: '2.30.00',
|
||||
|
@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "STARTLINER",
|
||||
"version": "0.11.0",
|
||||
"version": "0.13.0",
|
||||
"identifier": "zip.patafour.startliner",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run dev",
|
||||
|
@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ComputedRef, computed, onMounted } from 'vue';
|
||||
import { ComputedRef, computed, onMounted, ref } from 'vue';
|
||||
import Button from 'primevue/button';
|
||||
import Carousel from 'primevue/carousel';
|
||||
import Dialog from 'primevue/dialog';
|
||||
@ -11,7 +11,7 @@ import { VueMarkdownIt } from '@f3ve/vue-markdown-it';
|
||||
const prf = usePrfStore();
|
||||
const client = useClientStore();
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
visible: Boolean,
|
||||
firstTime: Boolean,
|
||||
onFinish: Function,
|
||||
@ -101,6 +101,14 @@ onMounted(async () => {
|
||||
image: '/help-finale-chunithm.png',
|
||||
};
|
||||
});
|
||||
|
||||
const counter = ref(0);
|
||||
|
||||
const exitLabel = computed(() => {
|
||||
return props.firstTime === true && counter.value < data.value.length - 1
|
||||
? 'Skip'
|
||||
: 'Close';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -115,7 +123,13 @@ onMounted(async () => {
|
||||
"
|
||||
:style="{ width: '760px', scale: client.scaleValue }"
|
||||
>
|
||||
<Carousel :value="data" :num-visible="1" :num-scroll="1">
|
||||
<Carousel
|
||||
:value="data"
|
||||
:num-visible="1"
|
||||
:num-scroll="1"
|
||||
:page="counter"
|
||||
v-on:update:page="(p) => (counter = p)"
|
||||
>
|
||||
<template #item="slotProps">
|
||||
<div class="md-container markdown">
|
||||
<vue-markdown-it
|
||||
@ -135,9 +149,15 @@ onMounted(async () => {
|
||||
</template>
|
||||
</Carousel>
|
||||
<div style="width: 100%; text-align: center">
|
||||
<Button
|
||||
v-if="counter < data.length - 1"
|
||||
class="m-auto mr-4"
|
||||
label="Next"
|
||||
@click="() => (counter += 1)"
|
||||
/>
|
||||
<Button
|
||||
class="m-auto"
|
||||
label="OK"
|
||||
:label="exitLabel"
|
||||
@click="() => onFinish && onFinish()"
|
||||
/>
|
||||
</div>
|
||||
|
@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import InputNumber from 'primevue/inputnumber';
|
||||
import SelectButton from 'primevue/selectbutton';
|
||||
import ToggleSwitch from 'primevue/toggleswitch';
|
||||
import FileEditor from './FileEditor.vue';
|
||||
@ -16,53 +17,35 @@ import { usePrfStore } from '../stores';
|
||||
|
||||
const prf = usePrfStore();
|
||||
|
||||
const audioModel = computed({
|
||||
const blacklistMinModel = computed({
|
||||
get() {
|
||||
return prf.current?.data.mu3_ini?.audio ?? null;
|
||||
},
|
||||
set(value: 'Shared' | 'Excl6Ch' | 'Excl2Ch') {
|
||||
if (prf.current!.data.mu3_ini === undefined) {
|
||||
prf.current!.data.mu3_ini = {};
|
||||
if (prf.current?.data.mu3_ini?.blacklist === undefined) {
|
||||
return null;
|
||||
}
|
||||
prf.current!.data.mu3_ini!.audio = value;
|
||||
return prf.current?.data.mu3_ini?.blacklist[0];
|
||||
},
|
||||
set(value: number) {
|
||||
prf.current!.data.mu3_ini!.blacklist = [
|
||||
value,
|
||||
prf.current!.data.mu3_ini!.blacklist?.[1] ?? 19999,
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
// const blacklistMinModel = computed({
|
||||
// get() {
|
||||
// if (prf.current?.data.mu3_ini?.blacklist === undefined) {
|
||||
// return null;
|
||||
// }
|
||||
// return prf.current?.data.mu3_ini?.blacklist[0];
|
||||
// },
|
||||
// set(value: number) {
|
||||
// if (prf.current!.data.mu3_ini === undefined) {
|
||||
// prf.current!.data.mu3_ini = {};
|
||||
// }
|
||||
// prf.current!.data.mu3_ini!.blacklist = [
|
||||
// value,
|
||||
// prf.current!.data.mu3_ini!.blacklist?.[1] ?? 19999,
|
||||
// ];
|
||||
// },
|
||||
// });
|
||||
|
||||
// const blacklistMaxModel = computed({
|
||||
// get() {
|
||||
// if (prf.current?.data.mu3_ini?.blacklist === undefined) {
|
||||
// return null;
|
||||
// }
|
||||
// return prf.current?.data.mu3_ini?.blacklist[1];
|
||||
// },
|
||||
// set(value: number) {
|
||||
// if (prf.current!.data.mu3_ini === undefined) {
|
||||
// prf.current!.data.mu3_ini = {};
|
||||
// }
|
||||
// prf.current!.data.mu3_ini!.blacklist = [
|
||||
// prf.current!.data.mu3_ini!.blacklist?.[0] ?? 10000,
|
||||
// value,
|
||||
// ];
|
||||
// },
|
||||
// });
|
||||
const blacklistMaxModel = computed({
|
||||
get() {
|
||||
if (prf.current?.data.mu3_ini?.blacklist === undefined) {
|
||||
return null;
|
||||
}
|
||||
return prf.current?.data.mu3_ini.blacklist[1];
|
||||
},
|
||||
set(value: number) {
|
||||
prf.current!.data.mu3_ini!.blacklist = [
|
||||
prf.current!.data.mu3_ini!.blacklist?.[0] ?? 10000,
|
||||
value,
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
prf.reload();
|
||||
</script>
|
||||
@ -102,34 +85,59 @@ prf.reload();
|
||||
|
||||
<OptionRow
|
||||
title="Audio mode"
|
||||
tooltip="Exclusive 2-channel mode requires a patch"
|
||||
tooltip="Exclusive 2-channel mode requires 7EVENDAYSHOLIDAYS-ExclusiveAudio"
|
||||
>
|
||||
<SelectButton
|
||||
v-model="audioModel"
|
||||
v-model="prf.current!.data.mu3_ini!.audio"
|
||||
:options="[
|
||||
{ title: 'Shared', value: 'Shared' },
|
||||
{ title: 'Exclusive 6-channel', value: 'Excl6Ch' },
|
||||
{ title: 'Exclusive 2-channel', value: 'Excl2Ch' },
|
||||
]"
|
||||
:allow-empty="true"
|
||||
:allow-empty="false"
|
||||
option-label="title"
|
||||
option-value="value"
|
||||
/></OptionRow>
|
||||
|
||||
<!-- <OptionRow
|
||||
<OptionRow
|
||||
title="Sample rate"
|
||||
v-if="
|
||||
prf.current?.data.mods.includes(
|
||||
'7EVENDAYSHOLIDAYS-ExclusiveAudio'
|
||||
)
|
||||
"
|
||||
>
|
||||
<SelectButton
|
||||
v-model="prf.current!.data.mu3_ini!.sample_rate"
|
||||
:disabled="prf.current!.data.mu3_ini!.audio === 'Shared'"
|
||||
:options="[
|
||||
{ title: '44.1KHz', value: 44100 },
|
||||
{ title: '48KHz', value: 48000 },
|
||||
{ title: '96KHz', value: 96000 },
|
||||
{ title: '192KHz', value: 192000 },
|
||||
]"
|
||||
:allow-empty="false"
|
||||
option-label="title"
|
||||
option-value="value"
|
||||
/></OptionRow>
|
||||
|
||||
<OptionRow
|
||||
v-if="
|
||||
prf.current?.data.mods.includes('7EVENDAYSHOLIDAYS-Blacklist')
|
||||
"
|
||||
class="number-input"
|
||||
title="Song ID Blacklist"
|
||||
tooltip="Requires a patch"
|
||||
tooltip="Scores on charts within this ID range will not be saved nor uploaded"
|
||||
><InputNumber
|
||||
class="shrink"
|
||||
size="small"
|
||||
:min="10000"
|
||||
:min="9000"
|
||||
:max="99999"
|
||||
placeholder="10000"
|
||||
:use-grouping="false"
|
||||
:allow-empty="false"
|
||||
v-model="blacklistMinModel" />
|
||||
x
|
||||
~
|
||||
<InputNumber
|
||||
class="shrink"
|
||||
size="small"
|
||||
@ -139,7 +147,36 @@ prf.reload();
|
||||
:use-grouping="false"
|
||||
:allow-empty="false"
|
||||
v-model="blacklistMaxModel"
|
||||
/></OptionRow> -->
|
||||
/></OptionRow>
|
||||
<OptionRow
|
||||
class="number-input"
|
||||
title="GP"
|
||||
v-if="
|
||||
prf.current?.data.mods.includes('7EVENDAYSHOLIDAYS-DisableGP')
|
||||
"
|
||||
><InputNumber
|
||||
class="shrink"
|
||||
size="small"
|
||||
:min="0"
|
||||
:max="9999"
|
||||
:use-grouping="false"
|
||||
:allow-empty="false"
|
||||
v-model="prf.current!.data.mu3_ini!.gp"
|
||||
/>
|
||||
</OptionRow>
|
||||
<OptionRow
|
||||
title="Unlock Bonus Tracks"
|
||||
tooltip="Disabling this option can help declutter the song list"
|
||||
v-if="
|
||||
prf.current?.data.mods.includes(
|
||||
'7EVENDAYSHOLIDAYS-UnlockAllMusic'
|
||||
)
|
||||
"
|
||||
>
|
||||
<ToggleSwitch
|
||||
v-model="prf.current!.data.mu3_ini!.enable_bonus_tracks"
|
||||
/>
|
||||
</OptionRow>
|
||||
</OptionCategory>
|
||||
<KeyboardOptions />
|
||||
<StartlinerOptions />
|
||||
|
@ -1,29 +1,154 @@
|
||||
<script setup lang="ts">
|
||||
import { Ref, ref } from 'vue';
|
||||
import Button from 'primevue/button';
|
||||
import Dialog from 'primevue/dialog';
|
||||
import ToggleSwitch from 'primevue/toggleswitch';
|
||||
import * as path from '@tauri-apps/api/path';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import ProfileListEntry from './ProfileListEntry.vue';
|
||||
import { usePrfStore } from '../stores';
|
||||
import { invoke } from '../invoke';
|
||||
import { useClientStore, useGeneralStore, usePrfStore } from '../stores';
|
||||
|
||||
const prf = usePrfStore();
|
||||
const client = useClientStore();
|
||||
const general = useGeneralStore();
|
||||
|
||||
const exportVisible = ref(false);
|
||||
const exportKeychip = ref(false);
|
||||
const files = new Set<string>();
|
||||
|
||||
const exportTemplate = async () => {
|
||||
const fl = [...files.values()];
|
||||
exportVisible.value = false;
|
||||
await invoke('export_profile', {
|
||||
exportKeychip: exportKeychip.value,
|
||||
files: fl,
|
||||
});
|
||||
await invoke('open_file', {
|
||||
path: await path.join(general.configDir, 'exports'),
|
||||
});
|
||||
};
|
||||
|
||||
const fileList = {
|
||||
ongeki: ['aime.txt', 'inohara.cfg', 'mu3.ini', 'segatools-base.ini'],
|
||||
chunithm: ['aime.txt', 'saekawa.toml', 'segatools-base.ini'],
|
||||
};
|
||||
|
||||
const fileListCurrent: Ref<string[]> = ref([]);
|
||||
|
||||
const recalcFileList = async () => {
|
||||
const res: string[] = [];
|
||||
files.clear();
|
||||
for (const idx in fileList[prf.current!.meta.game]) {
|
||||
const f = fileList[prf.current!.meta.game][idx];
|
||||
const p = await path.join(await prf.configDir, f);
|
||||
if (await invoke('file_exists', { path: p })) {
|
||||
res.push(f);
|
||||
files.add(f);
|
||||
}
|
||||
}
|
||||
fileListCurrent.value = res;
|
||||
};
|
||||
|
||||
const openExportDialog = async () => {
|
||||
await recalcFileList();
|
||||
exportVisible.value = true;
|
||||
};
|
||||
|
||||
const importPick = async () => {
|
||||
const res = await open({
|
||||
multiple: false,
|
||||
directory: false,
|
||||
filters: [
|
||||
{
|
||||
name: 'STARTLINER template',
|
||||
extensions: ['zip'],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (res != null) {
|
||||
await invoke('import_profile', { path: res });
|
||||
await prf.reloadList();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
modal
|
||||
:visible="exportVisible"
|
||||
:closable="false /*this shit doesn't work */"
|
||||
:header="`Export ${prf.current?.meta.name}`"
|
||||
:style="{ width: '300px', scale: client.scaleValue }"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-row">
|
||||
<div class="grow">Export keychip</div>
|
||||
<ToggleSwitch v-model="exportKeychip" />
|
||||
</div>
|
||||
<div class="flex flex-row" v-for="f in fileListCurrent">
|
||||
<div class="grow">Export {{ f }}</div>
|
||||
<ToggleSwitch
|
||||
:model-value="true"
|
||||
@update:model-value="
|
||||
(v) => {
|
||||
if (v === true) {
|
||||
files.add(f);
|
||||
} else {
|
||||
files.delete(f);
|
||||
}
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div style="width: 100%; text-align: center">
|
||||
<Button
|
||||
class="m-auto mr-3"
|
||||
style="width: 80px"
|
||||
label="OK"
|
||||
@click="() => exportTemplate()"
|
||||
/>
|
||||
<Button
|
||||
class="m-auto"
|
||||
style="width: 80px"
|
||||
label="Cancel"
|
||||
@click="() => (exportVisible = false)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
<div v-if="prf.list.length === 0">
|
||||
Welcome to STARTLINER! Start by creating a profile.
|
||||
</div>
|
||||
<div class="mt-4 flex flex-row flex-wrap align-middle gap-4">
|
||||
<Button
|
||||
label="O.N.G.E.K.I. profile"
|
||||
icon="pi pi-plus"
|
||||
icon="pi pi-file-plus"
|
||||
class="ongeki-button profile-button"
|
||||
@click="() => prf.create('ongeki')"
|
||||
/>
|
||||
<Button
|
||||
label="CHUNITHM profile"
|
||||
icon="pi pi-plus"
|
||||
icon="pi pi-file-plus"
|
||||
class="chunithm-button profile-button"
|
||||
@click="() => prf.create('chunithm')"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-4 flex flex-row flex-wrap align-middle gap-4">
|
||||
<Button
|
||||
label="Import template"
|
||||
icon="pi pi-file-import"
|
||||
class="import-button profile-button"
|
||||
@click="() => importPick()"
|
||||
/>
|
||||
<Button
|
||||
:disabled="prf.current === null"
|
||||
label="Export template"
|
||||
icon="pi pi-file-export"
|
||||
class="profile-button"
|
||||
@click="() => openExportDialog()"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-12 flex flex-col flex-wrap align-middle gap-4">
|
||||
<div v-for="p in prf.list">
|
||||
<ProfileListEntry :p="p" />
|
||||
@ -57,4 +182,14 @@ const prf = usePrfStore();
|
||||
background-color: var(--p-yellow-300) !important;
|
||||
border-color: var(--p-yellow-300) !important;
|
||||
}
|
||||
|
||||
.import-button {
|
||||
background-color: var(--p-purple-400) !important;
|
||||
border-color: var(--p-purple-400) !important;
|
||||
}
|
||||
.import-button:hover,
|
||||
.import-button:active {
|
||||
background-color: var(--p-purple-300) !important;
|
||||
border-color: var(--p-purple-300) !important;
|
||||
}
|
||||
</style>
|
||||
|
@ -65,6 +65,14 @@ const promptDeleteProfile = async () => {
|
||||
accept: deleteProfile,
|
||||
});
|
||||
};
|
||||
|
||||
const dataExists = ref(false);
|
||||
|
||||
path.join(general.dataDir, `profile-${props.p!.game}-${props.p!.name}`).then(
|
||||
async (p) => {
|
||||
dataExists.value = await invoke('file_exists', { path: p });
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -124,10 +132,27 @@ const promptDeleteProfile = async () => {
|
||||
@click="isEditing = true"
|
||||
/>
|
||||
<Button
|
||||
rounded
|
||||
icon="pi pi-cog"
|
||||
severity="help"
|
||||
aria-label="open-config-directory"
|
||||
size="small"
|
||||
class="self-center"
|
||||
style="width: 2rem; height: 2rem"
|
||||
@click="
|
||||
path
|
||||
.join(general.configDir, `profile-${p!.game}-${p!.name}`)
|
||||
.then(async (path) => {
|
||||
await invoke('open_file', { path });
|
||||
})
|
||||
"
|
||||
/>
|
||||
<Button
|
||||
v-if="dataExists"
|
||||
rounded
|
||||
icon="pi pi-folder"
|
||||
severity="help"
|
||||
aria-label="open-directory"
|
||||
aria-label="open-data-directory"
|
||||
size="small"
|
||||
class="self-center"
|
||||
style="width: 2rem; height: 2rem"
|
||||
@ -135,9 +160,7 @@ const promptDeleteProfile = async () => {
|
||||
path
|
||||
.join(general.dataDir, `profile-${p!.game}-${p!.name}`)
|
||||
.then(async (path) => {
|
||||
if (await invoke('file_exists', { path })) {
|
||||
await invoke('open_file', { path });
|
||||
}
|
||||
await invoke('open_file', { path });
|
||||
})
|
||||
"
|
||||
/>
|
||||
|
@ -107,7 +107,10 @@ export interface BepInExConfig {
|
||||
|
||||
export interface Mu3IniConfig {
|
||||
audio?: 'Shared' | 'Excl6Ch' | 'Excl2Ch';
|
||||
// blacklist?: [number, number];
|
||||
sample_rate: number;
|
||||
blacklist?: [number, number];
|
||||
gp: number;
|
||||
enable_bonus_tracks: boolean;
|
||||
}
|
||||
|
||||
export interface OngekiButtons {
|
||||
|
@ -3,11 +3,7 @@ import { Feature, Game, Package } from './types';
|
||||
|
||||
export const changePrimaryColor = (game: Game | null) => {
|
||||
const color =
|
||||
game === 'ongeki'
|
||||
? 'pink'
|
||||
: game === 'chunithm'
|
||||
? 'yellow'
|
||||
: 'bluegray';
|
||||
game === 'ongeki' ? 'pink' : game === 'chunithm' ? 'yellow' : 'purple';
|
||||
|
||||
updatePrimaryPalette({
|
||||
50: `{${color}.50}`,
|
||||
|
Reference in New Issue
Block a user