8 Commits

Author SHA1 Message Date
890d26e883 chore: bump ver 2025-04-20 06:38:44 +00:00
2aff5834b9 fix: chunithm crashing with mempatcher 2025-04-20 06:37:46 +00:00
69f2c83109 chore: update CHANGELOG.md 2025-04-19 20:13:11 +00:00
dbbd80c6c3 feat: add 'games' to the manifest 2025-04-19 20:09:32 +00:00
3479804dca feat: 0.12 update 2025-04-19 19:48:08 +00:00
aaeed669df chore: bump ver 2025-04-19 11:46:07 +00:00
7084f40404 fix: improve help pages 2025-04-19 11:44:16 +00:00
f7e9d7d7db docs: rewrite README.md 2025-04-18 19:55:42 +00:00
20 changed files with 499 additions and 136 deletions

View File

@ -1,3 +1,27 @@
## 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 ## 0.10.1
- Fixed the order of cells in the CHUNITHM keyboard - Fixed the order of cells in the CHUNITHM keyboard

View File

@ -1,17 +1,19 @@
# STARTLINER # STARTLINER
A simple and easy to use launcher, configuration tool and mod manager This is a program that seeks to streamline game data configuration, currently supporting O.N.G.E.K.I. and CHUNITHM.
for O.N.G.E.K.I. and CHUNITHM, using [Rainycolor Watercolor](https://rainy.patafour.zip).
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. 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 ## Usage
Download a prebuilt binary from [Releases](https://gitea.tendokyu.moe/akanyan/STARTLINER/releases) or build it yourself: Download a prebuilt binary from [Releases](https://gitea.tendokyu.moe/akanyan/STARTLINER/releases) or build it yourself:

View File

@ -1,7 +1,8 @@
use std::hash::{DefaultHasher, Hash, Hasher}; use std::hash::{DefaultHasher, Hash, Hasher};
use std::path::Path;
use std::time::SystemTime; use std::time::SystemTime;
use crate::model::config::GlobalConfig; use crate::model::config::GlobalConfig;
use crate::model::patch::PatchFileVec; use crate::model::patch::{PatchFileVec, PatchList};
use crate::pkg::{Feature, Status}; use crate::pkg::{Feature, Status};
use crate::profiles::types::Profile; use crate::profiles::types::Profile;
use crate::{model::misc::Game, pkg::PkgKey}; use crate::{model::misc::Game, pkg::PkgKey};
@ -165,4 +166,22 @@ impl AppData {
panic!("unable to initialize the logger? {:?}", e); 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)
}
} }

View File

@ -69,9 +69,15 @@ pub async fn startline(app: AppHandle, refresh: bool) -> Result<(), String> {
} }
if let Some(p) = &appd.profile { if let Some(p) = &appd.profile {
log::debug!("{}", hash); 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() let info = p.prepare_display()
.map_err(|e| e.to_string())?; .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()); .map_err(|e| e.to_string());
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]

View File

@ -1,6 +1,6 @@
use std::collections::{BTreeMap, BTreeSet}; use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::pkg::{Status, PkgKey, PkgKeyVersion}; use crate::pkg::{PkgKey, PkgKeyVersion};
use super::misc::Game; use super::misc::Game;
@ -14,7 +14,10 @@ pub struct PackageManifest {
pub dependencies: BTreeSet<PkgKeyVersion>, pub dependencies: BTreeSet<PkgKeyVersion>,
#[serde(default)] #[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>; pub type PackageList = BTreeMap<PkgKey, PackageListEntry>;
@ -22,6 +25,5 @@ pub type PackageList = BTreeMap<PkgKey, PackageListEntry>;
#[derive(Serialize, Deserialize, Clone)] #[derive(Serialize, Deserialize, Clone)]
pub struct PackageListEntry { pub struct PackageListEntry {
pub version: String, pub version: String,
pub status: Status,
pub games: Vec<Game>, pub games: Vec<Game>,
} }

View File

@ -166,11 +166,26 @@ pub enum Mu3Audio {
Excl2Ch, Excl2Ch,
} }
#[derive(Deserialize, Serialize, Clone, Debug, Default)] #[derive(Deserialize, Serialize, Clone, Debug)]
#[serde(default)] #[serde(default)]
pub struct Mu3Ini { pub struct Mu3Ini {
pub audio: Option<Mu3Audio>, pub audio: Option<Mu3Audio>,
pub sample_rate: i32,
pub blacklist: Option<(i32, 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)] #[derive(Deserialize, Serialize, Clone, Debug)]

View File

@ -1,25 +1,23 @@
use std::path::Path; use std::path::Path;
use anyhow::Result; use anyhow::Result;
use crate::model::patch::{Patch, PatchData, PatchFileVec, PatchSelection, PatchSelectionData}; use crate::model::patch::{Patch, PatchData, PatchList, PatchSelection, PatchSelectionData};
impl PatchSelection { impl PatchSelection {
pub async fn render_to_file( pub async fn render_to_file(
&self, &self,
filename: &str, filename: &str,
patches: &PatchFileVec, patch_lists: &Vec<&PatchList>,
path: impl AsRef<Path> path: impl AsRef<Path>
) -> Result<()> { ) -> Result<()> {
let mut res = "".to_owned(); let mut res = "".to_owned();
for file in &patches.0 { for list in patch_lists {
for list in &file.0 { if list.filename != filename {
if list.filename != filename { continue;
continue; }
} for patch in &list.patches {
for patch in &list.patches { if let Some(selection) = self.0.get(&patch.id) {
if let Some(selection) = self.0.get(&patch.id) { res += &Self::render(filename, patch, selection);
res += &Self::render(filename, patch, selection);
}
} }
} }
} }

View File

@ -1,11 +1,11 @@
use std::path::Path; use std::path::Path;
use anyhow::Result; use anyhow::{anyhow, Result};
use ini::Ini; use ini::Ini;
use crate::model::profile::{Mu3Audio, Mu3Ini}; use crate::model::profile::{Mu3Audio, Mu3Ini};
impl Mu3Ini { impl Mu3Ini {
pub fn line_up(&self, game_path: impl AsRef<Path>) -> Result<()> { pub fn line_up(&self, data_dir: impl AsRef<Path>, cfg_dir: impl AsRef<Path>) -> Result<()> {
let file = game_path.as_ref().join("mu3.ini"); let file = cfg_dir.as_ref().join("mu3.ini");
if !file.exists() { if !file.exists() {
std::fs::write(&file, "")?; std::fs::write(&file, "")?;
@ -20,9 +20,26 @@ impl Mu3Ini {
Mu3Audio::Excl2Ch => "2", 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)?; ini.write_to_file(file)?;
Ok(()) Ok(())

View File

@ -5,30 +5,30 @@ use crate::{model::{misc::{ConfigHook, ConfigHookAime, ConfigHookAimeUnit, Confi
use crate::pkg_store::PackageStore; use crate::pkg_store::PackageStore;
impl Segatools { impl Segatools {
pub fn fix(&mut self, store: &PackageStore) { pub fn fix(&mut self, _store: &PackageStore) {
macro_rules! remove_if_nonpresent { // macro_rules! remove_if_nonpresent {
($item:expr,$key:expr,$emptyval:expr,$store:expr) => { // ($item:expr,$key:expr,$emptyval:expr,$store:expr) => {
if let Ok(pkg) = $store.get($key) { // if let Ok(pkg) = $store.get($key) {
if pkg.loc.is_none() { // if pkg.loc.is_none() {
$item = $emptyval; // $item = $emptyval;
} // }
} else { // } else {
$item = $emptyval; // $item = $emptyval;
} // }
} // }
} // }
if let Some(key) = &self.hook { // if let Some(key) = &self.hook {
remove_if_nonpresent!(self.hook, key, None, store); // remove_if_nonpresent!(self.hook, key, None, store);
} // }
if let IOSelection::Custom(key) = &self.io2 { // if let IOSelection::Custom(key) = &self.io2 {
remove_if_nonpresent!(self.io2, key, IOSelection::default(), store); // remove_if_nonpresent!(self.io2, key, IOSelection::default(), store);
} // }
match &self.aime { // match &self.aime {
Aime::AMNet(key) => remove_if_nonpresent!(self.aime, key, Aime::BuiltIn, store), // Aime::AMNet(key) => remove_if_nonpresent!(self.aime, key, Aime::BuiltIn, store),
Aime::Other(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<()> { pub fn load_from_ini(&mut self, ini: &Ini, config_dir: impl AsRef<Path>) -> Result<()> {
log::debug!("loading sgt"); log::debug!("loading sgt");

View File

@ -15,10 +15,14 @@ impl PatchFileVec {
let mut res = Vec::new(); let mut res = Vec::new();
for f in std::fs::read_dir(path)? { for f in std::fs::read_dir(path)? {
let f = f?; let f = f?;
let f = f.path(); let f = &f.path();
res.push( match serde_json5::from_str::<PatchFile>(&std::fs::read_to_string(f)?) {
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)) Ok(PatchFileVec(res))
} }
@ -26,20 +30,21 @@ impl PatchFileVec {
pub fn find_patches(&self, target: impl AsRef<Path>) -> Result<Vec<Patch>> { pub fn find_patches(&self, target: impl AsRef<Path>) -> Result<Vec<Patch>> {
let checksum = try_digest(target.as_ref())?; let checksum = try_digest(target.as_ref())?;
let mut res = Vec::new(); let mut res_patches = Vec::new();
for pfile in &self.0 { for pfile in &self.0 {
for plist in &pfile.0 { for plist in &pfile.0 {
log::debug!("checking {}", plist.sha256); let this_hash = plist.sha256.to_ascii_lowercase();
if plist.sha256 == checksum { log::debug!("checking {}", this_hash);
if this_hash == checksum {
let mut cloned = plist.clone().patches; 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); log::warn!("no matching patchset for {:?} ({})", target.as_ref(), checksum);
} }
Ok(res) Ok(res_patches)
} }
} }

View File

@ -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 str = fs::read_to_string(dir.join("manifest.json")).await?;
let mft: local::PackageManifest = serde_json::from_str(&str)?; let mft: local::PackageManifest = serde_json::from_str(&str)?;
@ -133,7 +133,7 @@ impl Package {
let status = Self::parse_status(&mft, &dir); let status = Self::parse_status(&mft, &dir);
let dependencies = Self::sanitize_deps(mft.dependencies); let dependencies = Self::sanitize_deps(mft.dependencies);
Ok(Package { Ok((Package {
namespace: Self::dir_to_namespace(&dir)?, namespace: Self::dir_to_namespace(&dir)?,
name: mft.name.clone(), name: mft.name.clone(),
description: mft.description.clone(), description: mft.description.clone(),
@ -146,7 +146,7 @@ impl Package {
}), }),
rmt: None, rmt: None,
source source
}) }, mft.games))
} }
pub fn key(&self) -> PkgKey { pub fn key(&self) -> PkgKey {

View File

@ -83,7 +83,7 @@ impl PackageStore {
pub async fn reload_package(&mut self, key: PkgKey) { pub async fn reload_package(&mut self, key: PkgKey) {
let dir = util::pkg_dir().join(&key.0); 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); self.update_nonremote(key, pkg);
} else { } else {
log::error!("couldn't reload {}", key); log::error!("couldn't reload {}", key);
@ -102,7 +102,13 @@ impl PackageStore {
} }
while let Some(res) = futures.join_next().await { 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); self.update_nonremote(pkg.key(), pkg);
} }
} }
@ -152,7 +158,6 @@ impl PackageStore {
PackageListEntry { PackageListEntry {
// from_rainy() is guaranteed to include rmt // from_rainy() is guaranteed to include rmt
version: r.rmt.as_ref().unwrap().version.clone(), version: r.rmt.as_ref().unwrap().version.clone(),
status: Status::Unchecked,
games: vec![ game ], games: vec![ game ],
} }
}); });

View File

@ -1,6 +1,6 @@
pub use types::{Profile, ProfileData, ProfileMeta, ProfilePaths, StartPayload}; pub use types::{Profile, ProfileData, ProfileMeta, ProfilePaths, StartPayload};
use std::{collections::{BTreeMap, BTreeSet}, path::{Path, PathBuf}}; 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 tauri::Emitter;
use std::process::Stdio; use std::process::Stdio;
use crate::model::profile::BepInEx; use crate::model::profile::BepInEx;
@ -28,7 +28,7 @@ impl Profile {
bepinex: if meta.game == Game::Ongeki { Some(BepInEx::default()) } else { None }, bepinex: if meta.game == Game::Ongeki { Some(BepInEx::default()) } else { None },
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
wine: crate::model::profile::Wine::default(), 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: keyboard:
if meta.game == Game::Ongeki { if meta.game == Game::Ongeki {
Some(Keyboard::Ongeki(OngekiKeyboard::default())) Some(Keyboard::Ongeki(OngekiKeyboard::default()))
@ -43,6 +43,12 @@ impl Profile {
std::fs::create_dir_all(p.config_dir())?; std::fs::create_dir_all(p.config_dir())?;
std::fs::create_dir_all(p.data_dir())?; std::fs::create_dir_all(p.data_dir())?;
if meta.game == Game::Ongeki {
if let Err(e) = Self::load_existing_mu3_ini(&p.data, &p.meta) {
log::error!("unable to load existing mu3.ini: {e}");
}
}
match meta.game { match meta.game {
Game::Ongeki => std::fs::write(p.config_dir().join("segatools-base.ini"), include_bytes!("../../static/segatools-ongeki.ini"))?, Game::Ongeki => std::fs::write(p.config_dir().join("segatools-base.ini"), include_bytes!("../../static/segatools-ongeki.ini"))?,
Game::Chunithm => std::fs::write(p.config_dir().join("segatools-base.ini"), include_bytes!("../../static/segatools-chunithm.ini"))?, Game::Chunithm => std::fs::write(p.config_dir().join("segatools-base.ini"), include_bytes!("../../static/segatools-chunithm.ini"))?,
@ -67,6 +73,18 @@ impl Profile {
data.sgt.io2 = IOSelection::Custom(io); data.sgt.io2 = IOSelection::Custom(io);
data.sgt.io = None; 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 game == Game::Chunithm {
if data.keyboard.is_none() { if data.keyboard.is_none() {
@ -168,7 +186,7 @@ impl Profile {
Ok(info) 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() { if !self.data_dir().exists() {
tokio::fs::create_dir(self.data_dir()).await?; tokio::fs::create_dir(self.data_dir()).await?;
} }
@ -203,13 +221,13 @@ impl Profile {
} }
if let Some(mu3ini) = &self.data.mu3_ini { 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 { if let Some(patches) = &self.data.patches {
futures::try_join!( futures::try_join!(
patches.render_to_file("amdaemon.exe", patch_files, self.data_dir().join("patch-amd.mph")), patches.render_to_file("amdaemon.exe", &patchlists_enabled, 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("chusanApp.exe", &patchlists_enabled, self.data_dir().join("patch-game.mph"))
)?; )?;
} }
@ -283,6 +301,14 @@ impl Profile {
"ONGEKI_LANG_PATH", "ONGEKI_LANG_PATH",
self.data_dir().join("lang"), self.data_dir().join("lang"),
) )
.env(
"MU3_MODS_CONFIG_PATH",
self.config_dir().join("mu3.ini"),
)
.env(
"STARTLINER",
"1"
)
.current_dir(&exe_dir) .current_dir(&exe_dir)
.raw_arg("-d") .raw_arg("-d")
.raw_arg("-k") .raw_arg("-k")
@ -403,6 +429,17 @@ impl Profile {
Ok(false) Ok(false)
} }
} }
fn load_existing_mu3_ini(data: &ProfileData, meta: &ProfileMeta) -> Result<()> {
let mu3_ini_target_path = data.sgt.target.parent().ok_or_else(|| anyhow!("invalid target directory"))?.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 { impl ProfilePaths for Profile {

View File

@ -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', filename: 'chusanApp.exe',
version: '2.30.00', version: '2.30.00',

View File

@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "STARTLINER", "productName": "STARTLINER",
"version": "0.11.0", "version": "0.12.1",
"identifier": "zip.patafour.startliner", "identifier": "zip.patafour.startliner",
"build": { "build": {
"beforeDevCommand": "bun run dev", "beforeDevCommand": "bun run dev",

View File

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ComputedRef, computed, onMounted } from 'vue'; import { ComputedRef, computed, onMounted, ref } from 'vue';
import Button from 'primevue/button'; import Button from 'primevue/button';
import Carousel from 'primevue/carousel'; import Carousel from 'primevue/carousel';
import Dialog from 'primevue/dialog'; import Dialog from 'primevue/dialog';
@ -11,7 +11,7 @@ import { VueMarkdownIt } from '@f3ve/vue-markdown-it';
const prf = usePrfStore(); const prf = usePrfStore();
const client = useClientStore(); const client = useClientStore();
defineProps({ const props = defineProps({
visible: Boolean, visible: Boolean,
firstTime: Boolean, firstTime: Boolean,
onFinish: Function, onFinish: Function,
@ -101,6 +101,14 @@ onMounted(async () => {
image: '/help-finale-chunithm.png', 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> </script>
<template> <template>
@ -115,7 +123,13 @@ onMounted(async () => {
" "
:style="{ width: '760px', scale: client.scaleValue }" :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"> <template #item="slotProps">
<div class="md-container markdown"> <div class="md-container markdown">
<vue-markdown-it <vue-markdown-it
@ -135,9 +149,15 @@ onMounted(async () => {
</template> </template>
</Carousel> </Carousel>
<div style="width: 100%; text-align: center"> <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 <Button
class="m-auto" class="m-auto"
label="OK" :label="exitLabel"
@click="() => onFinish && onFinish()" @click="() => onFinish && onFinish()"
/> />
</div> </div>

View File

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import InputNumber from 'primevue/inputnumber';
import SelectButton from 'primevue/selectbutton'; import SelectButton from 'primevue/selectbutton';
import ToggleSwitch from 'primevue/toggleswitch'; import ToggleSwitch from 'primevue/toggleswitch';
import FileEditor from './FileEditor.vue'; import FileEditor from './FileEditor.vue';
@ -16,53 +17,35 @@ import { usePrfStore } from '../stores';
const prf = usePrfStore(); const prf = usePrfStore();
const audioModel = computed({ const blacklistMinModel = computed({
get() { get() {
return prf.current?.data.mu3_ini?.audio ?? null; if (prf.current?.data.mu3_ini?.blacklist === undefined) {
}, return null;
set(value: 'Shared' | 'Excl6Ch' | 'Excl2Ch') {
if (prf.current!.data.mu3_ini === undefined) {
prf.current!.data.mu3_ini = {};
} }
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({ const blacklistMaxModel = computed({
// get() { get() {
// if (prf.current?.data.mu3_ini?.blacklist === undefined) { if (prf.current?.data.mu3_ini?.blacklist === undefined) {
// return null; return null;
// } }
// return prf.current?.data.mu3_ini?.blacklist[0]; return prf.current?.data.mu3_ini.blacklist[1];
// }, },
// set(value: number) { set(value: number) {
// if (prf.current!.data.mu3_ini === undefined) { prf.current!.data.mu3_ini!.blacklist = [
// prf.current!.data.mu3_ini = {}; prf.current!.data.mu3_ini!.blacklist?.[0] ?? 10000,
// } value,
// 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,
// ];
// },
// });
prf.reload(); prf.reload();
</script> </script>
@ -102,34 +85,59 @@ prf.reload();
<OptionRow <OptionRow
title="Audio mode" title="Audio mode"
tooltip="Exclusive 2-channel mode requires a patch" tooltip="Exclusive 2-channel mode requires 7EVENDAYSHOLIDAYS-ExclusiveAudio"
> >
<SelectButton <SelectButton
v-model="audioModel" v-model="prf.current!.data.mu3_ini!.audio"
:options="[ :options="[
{ title: 'Shared', value: 'Shared' }, { title: 'Shared', value: 'Shared' },
{ title: 'Exclusive 6-channel', value: 'Excl6Ch' }, { title: 'Exclusive 6-channel', value: 'Excl6Ch' },
{ title: 'Exclusive 2-channel', value: 'Excl2Ch' }, { title: 'Exclusive 2-channel', value: 'Excl2Ch' },
]" ]"
:allow-empty="true" :allow-empty="false"
option-label="title" option-label="title"
option-value="value" option-value="value"
/></OptionRow> /></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" class="number-input"
title="Song ID Blacklist" title="Song ID Blacklist"
tooltip="Requires a patch" tooltip="Scores on charts within this ID range will not be saved nor uploaded"
><InputNumber ><InputNumber
class="shrink" class="shrink"
size="small" size="small"
:min="10000" :min="9000"
:max="99999" :max="99999"
placeholder="10000" placeholder="10000"
:use-grouping="false" :use-grouping="false"
:allow-empty="false" :allow-empty="false"
v-model="blacklistMinModel" /> v-model="blacklistMinModel" />
x ~
<InputNumber <InputNumber
class="shrink" class="shrink"
size="small" size="small"
@ -139,7 +147,36 @@ prf.reload();
:use-grouping="false" :use-grouping="false"
:allow-empty="false" :allow-empty="false"
v-model="blacklistMaxModel" 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> </OptionCategory>
<KeyboardOptions /> <KeyboardOptions />
<StartlinerOptions /> <StartlinerOptions />

View File

@ -65,6 +65,14 @@ const promptDeleteProfile = async () => {
accept: deleteProfile, 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> </script>
<template> <template>
@ -124,10 +132,27 @@ const promptDeleteProfile = async () => {
@click="isEditing = true" @click="isEditing = true"
/> />
<Button <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 rounded
icon="pi pi-folder" icon="pi pi-folder"
severity="help" severity="help"
aria-label="open-directory" aria-label="open-data-directory"
size="small" size="small"
class="self-center" class="self-center"
style="width: 2rem; height: 2rem" style="width: 2rem; height: 2rem"
@ -135,9 +160,7 @@ const promptDeleteProfile = async () => {
path path
.join(general.dataDir, `profile-${p!.game}-${p!.name}`) .join(general.dataDir, `profile-${p!.game}-${p!.name}`)
.then(async (path) => { .then(async (path) => {
if (await invoke('file_exists', { path })) { await invoke('open_file', { path });
await invoke('open_file', { path });
}
}) })
" "
/> />

View File

@ -107,7 +107,10 @@ export interface BepInExConfig {
export interface Mu3IniConfig { export interface Mu3IniConfig {
audio?: 'Shared' | 'Excl6Ch' | 'Excl2Ch'; audio?: 'Shared' | 'Excl6Ch' | 'Excl2Ch';
// blacklist?: [number, number]; sample_rate: number;
blacklist?: [number, number];
gp: number;
enable_bonus_tracks: boolean;
} }
export interface OngekiButtons { export interface OngekiButtons {