5 Commits
0.4.0 ... 0.5.0

Author SHA1 Message Date
b10c797d52 docs: update README.md 2025-04-10 14:10:47 +00:00
ff0a37dfdc feat: skeleton of proper local package support 2025-04-10 14:07:44 +00:00
d63d81e349 feat: also copy aime.txt 2025-04-10 13:53:20 +00:00
9ea66dbeab feat: segatools.ini loading 2025-04-10 13:32:49 +00:00
4e795257ad feat: add dont_switch_primary 2025-04-08 21:49:15 +00:00
29 changed files with 920 additions and 234 deletions

View File

@ -1,6 +1,6 @@
# STARTLINER
A simple and easy to use launcher, configuration tool and mod manager for [many games](https://silentblue.remywiki.com/ONGEKI:bright_MEMORY) (more to come) using [Rainycolor Watercolor](https://rainy.patafour.zip).
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).
Made with Rust (Tauri) and Vue. Technically multiplatform. Contributions welcome.

View File

@ -1,5 +1,7 @@
use ini::Ini;
use log;
use std::collections::HashMap;
use std::path::PathBuf;
use tokio::sync::Mutex;
use tokio::fs;
use tauri::{AppHandle, Manager, State};
@ -319,7 +321,7 @@ pub async fn get_current_profile(state: State<'_, Mutex<AppData>>) -> Result<Opt
#[tauri::command]
pub async fn sync_current_profile(state: State<'_, Mutex<AppData>>, data: ProfileData) -> Result<(), String> {
log::debug!("invoke: sync_current_profile");
log::debug!("invoke: sync_current_profile {:?}", data);
let mut appd = state.lock().await;
if let Some(p) = &mut appd.profile {
@ -345,6 +347,27 @@ pub async fn save_current_profile(state: State<'_, Mutex<AppData>>) -> Result<()
}
}
#[tauri::command]
pub async fn load_segatools_ini(state: State<'_, Mutex<AppData>>, path: PathBuf) -> Result<(), String> {
log::debug!("invoke: load_segatools_ini({:?})", path);
let mut appd = state.lock().await;
if let Some(p) = &mut appd.profile {
let str = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
// Stupid path escape hack for the ini reader
let str = str.replace("\\", "\\\\").replace("\\\\\\\\", "\\\\");
let ini = Ini::load_from_str(&str).map_err(|e| e.to_string())?;
p.data.sgt.load_from_ini(&ini, p.config_dir()).map_err(|e| e.to_string())?;
p.data.network.load_from_ini(&ini).map_err(|e| e.to_string())?;
if let Some(kb) = &mut p.data.keyboard {
kb.load_from_ini(&ini).map_err(|e| e.to_string())?;
}
p.save().map_err(|e| e.to_string())?;
}
Ok(())
}
#[tauri::command]
pub async fn list_platform_capabilities() -> Result<Vec<String>, ()> {
log::debug!("invoke: list_platform_capabilities");
@ -413,4 +436,10 @@ pub async fn list_directories() -> Result<util::Dirs, ()> {
log::debug!("invoke: list_directores");
Ok(util::all_dirs().clone())
}
// Tauri fs api is useless
#[tauri::command]
pub async fn file_exists(path: String) -> Result<bool, ()> {
Ok(std::fs::exists(path).unwrap_or(false))
}

View File

@ -71,8 +71,8 @@ pub async fn run(_args: Vec<String>) {
} else {
tauri::WebviewWindowBuilder::new(app, "main", tauri::WebviewUrl::App("index.html".into()))
.title("STARTLINER")
.inner_size(760f64, 480f64)
.min_inner_size(760f64, 480f64)
.inner_size(900f64, 480f64)
.min_inner_size(900f64, 480f64)
.build()?;
start_immediately = false;
}
@ -199,6 +199,7 @@ pub async fn run(_args: Vec<String>) {
cmd::get_current_profile,
cmd::sync_current_profile,
cmd::save_current_profile,
cmd::load_segatools_ini,
cmd::get_global_config,
cmd::set_global_config,
@ -206,6 +207,7 @@ pub async fn run(_args: Vec<String>) {
cmd::list_displays,
cmd::list_platform_capabilities,
cmd::list_directories,
cmd::file_exists,
])
.build(tauri::generate_context!())
.expect("error while building tauri application");

View File

@ -65,8 +65,8 @@ impl Game {
pub fn has_module(&self, module: ProfileModule) -> bool {
match self {
Game::Ongeki => make_bitflags!(ProfileModule::{Segatools | Display | Network | BepInEx | Mu3Ini}),
Game::Chunithm => make_bitflags!(ProfileModule::{Segatools | Network}),
Game::Ongeki => make_bitflags!(ProfileModule::{Segatools | Display | Network | BepInEx | Mu3Ini | Keyboard}),
Game::Chunithm => make_bitflags!(ProfileModule::{Segatools | Network | Keyboard}),
}.contains(module)
}
}

View File

@ -75,6 +75,12 @@ pub struct Display {
pub rotation: i32,
pub frequency: i32,
pub borderless_fullscreen: bool,
#[serde(default)]
pub dont_switch_primary: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub monitor_index_override: Option<i32>,
}
impl Display {
@ -92,6 +98,8 @@ impl Display {
Game::Ongeki => 60,
},
borderless_fullscreen: true,
dont_switch_primary: false,
monitor_index_override: None,
}
}
}
@ -154,6 +162,75 @@ pub struct Mu3Ini {
pub blacklist: Option<(i32, i32)>,
}
#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct OngekiKeyboard {
pub use_mouse: bool,
pub coin: i32,
pub svc: i32,
pub test: i32,
pub lmenu: i32,
pub rmenu: i32,
pub l1: i32,
pub l2: i32,
pub l3: i32,
pub r1: i32,
pub r2: i32,
pub r3: i32,
pub lwad: i32,
pub rwad: i32,
}
impl Default for OngekiKeyboard {
fn default() -> Self {
Self {
use_mouse: true,
test: 0x70,
svc: 0x71,
coin: 0x72,
lmenu: 0x55,
rmenu: 0x4F,
lwad: 0x01,
rwad: 0x02,
l1: 0x41,
l2: 0x53,
l3: 0x44,
r1: 0x4A,
r2: 0x4B,
r3: 0x4C
}
}
}
#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct ChunithmKeyboard {
pub split_ir: bool,
pub coin: i32,
pub svc: i32,
pub test: i32,
pub cell: [i32; 32],
pub ir: [i32; 6],
}
impl Default for ChunithmKeyboard {
fn default() -> Self {
Self {
split_ir: false,
test: 0x70,
svc: 0x71,
coin: 0x72,
cell: Default::default(),
ir: Default::default(),
}
}
}
#[derive(Deserialize, Serialize, Clone, Debug)]
#[serde(tag = "game", content = "data")]
pub enum Keyboard {
Ongeki(OngekiKeyboard),
Chunithm(ChunithmKeyboard),
}
#[bitflags]
#[repr(u8)]
#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)]
@ -162,5 +239,6 @@ pub enum ProfileModule {
Network,
Display,
BepInEx,
Mu3Ini
Mu3Ini,
Keyboard,
}

View File

@ -38,49 +38,7 @@ cabLedOutputSerial=0
; Output slider LED data to the named pipe
controllerLedOutputPipe=1
; Output slider LED data to the serial port
controllerLedOutputSerial=0
[io4]
; Test button virtual-key code. Default is the F1 key.
test=0x70
; Service button virtual-key code. Default is the F2 key.
service=0x71
; Keyboard button to increment coin counter. Default is the F3 key.
coin=0x72
; Set \"1\" to enable mouse lever emulation, \"0\" to use XInput
mouse=1
; XInput input bindings
;
; Left Stick Lever
; Left Trigger Lever (move to the left)
; Right Trigger Lever (move to the right)
; Left Left red button
; Up Left green button
; Right Left blue button
; Left Shoulder Left side button
; Right Shoulder Right side button
; X Right red button
; Y Right green button
; A Right blue button
; Back Left menu button
; Start Right menu button
; Keyboard input bindings
left1=0x41 ; A
left2=0x53 ; S
left3=0x44 ; D
leftSide=0x01 ; Mouse Left
rightSide=0x02 ; Mouse Right
right1=0x4A ; J
right2=0x4B ; K
right3=0x4C ; L
leftMenu=0x55 ; U
rightMenu=0x4F ; O".to_owned(),
controllerLedOutputSerial=0".to_owned(),
Game::Chunithm => "
[vfd]
; Enable VFD emulation. Disable to use a real VFD
@ -179,120 +137,6 @@ controllerLedOutputOpeNITHM=0
; 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

@ -1,4 +1,3 @@
use crate::model::profile::{Display, DisplayMode};
use anyhow::Result;
use displayz::{query_displays, DisplaySet};
@ -7,14 +6,14 @@ use tauri::{AppHandle, Listener};
#[derive(Clone)]
pub struct DisplayInfo {
pub primary: String,
pub set: Option<DisplaySet>
pub set: Option<DisplaySet>,
}
impl Default for DisplayInfo {
fn default() -> Self {
DisplayInfo {
primary: "default".to_owned(),
set: query_displays().ok()
set: query_displays().ok(),
}
}
}
@ -52,14 +51,16 @@ impl Display {
.find(|display| display.name() == self.target)
.ok_or_else(|| anyhow!("Display {} not found", self.target))?;
target.set_primary()?;
if !self.dont_switch_primary {
target.set_primary()?;
}
let settings = target.settings()
.as_ref()
.ok_or_else(|| anyhow!("Unable to query display settings"))?;
let res = DisplayInfo {
primary: primary.name().to_owned(),
set: Some(display_set.clone())
set: Some(display_set.clone()),
};
if self.rotation == 90 || self.rotation == 270 {

View File

@ -0,0 +1,120 @@
use ini::Ini;
use anyhow::Result;
use crate::model::profile::Keyboard;
macro_rules! parse_int_field {
($section:expr,$sgt:expr,$sl:expr) => {
if let Some(field) = $section.get($sgt) {
let field = &field[0..field.chars().position(|c| c == ';').unwrap_or(field.len())].trim();
log::debug!("loading {}={}", $sgt, field);
let res = if field.starts_with("0x") {
i32::from_str_radix(&field.trim()[2..], 16)
} else {
field.trim().parse::<i32>()
};
match res {
Ok(v) => $sl = v,
Err(e) => log::warn!("unable to read a segatools.ini field key={} value={}: {:?}", $sgt, field.trim(), e)
};
} else {
log::debug!("unable to load {}", $sgt);
}
}
}
impl Keyboard {
pub fn load_from_ini(&mut self, ini: &Ini) -> Result<()> {
log::debug!("loading kb");
match self {
Keyboard::Ongeki(kb) => {
if let Some(s) = ini.section(Some("io4")) {
parse_int_field!(s, "test", kb.test);
parse_int_field!(s, "service", kb.svc);
parse_int_field!(s, "coin", kb.coin);
parse_int_field!(s, "left1", kb.l1);
parse_int_field!(s, "left2", kb.l2);
parse_int_field!(s, "left3", kb.l3);
parse_int_field!(s, "right1", kb.r1);
parse_int_field!(s, "right2", kb.r2);
parse_int_field!(s, "right3", kb.r3);
parse_int_field!(s, "leftMenu", kb.lmenu);
parse_int_field!(s, "rightMenu", kb.rmenu);
parse_int_field!(s, "leftSide", kb.lwad);
parse_int_field!(s, "rightSide", kb.rwad);
let mut mouse: i32 = 1;
parse_int_field!(s, "mouse", mouse);
kb.use_mouse = if mouse == 1 { true } else { false };
}
}
Keyboard::Chunithm(kb) => {
if let Some(s) = ini.section(Some("io3")) {
parse_int_field!(s, "test", kb.test);
parse_int_field!(s, "service", kb.svc);
parse_int_field!(s, "coin", kb.coin);
let mut ir: i32 = 1;
parse_int_field!(s, "ir", ir);
kb.split_ir = if ir == 0 { true } else { false };
}
if let Some(s) = ini.section(Some("slider")) {
for i in 0..kb.cell.len() {
parse_int_field!(s, format!("cell{}", i + 1), kb.cell[i]);
}
}
if let Some(s) = ini.section(Some("ir")) {
for i in 0..kb.ir.len() {
parse_int_field!(s, format!("ir{}", i + 1), kb.ir[i]);
}
}
}
}
Ok(())
}
// This is assumed to run in sync after the segatools module
pub fn line_up(&self, ini: &mut Ini) -> Result<()> {
match self {
Keyboard::Ongeki(kb) => {
ini.with_section(Some("io4"))
.set("test", kb.test.to_string())
.set("service", kb.svc.to_string())
.set("coin", kb.coin.to_string())
.set("left1", kb.l1.to_string())
.set("left2", kb.l2.to_string())
.set("left3", kb.l3.to_string())
.set("right1", kb.r1.to_string())
.set("right2", kb.r2.to_string())
.set("right3", kb.r3.to_string())
.set("leftSide", kb.lwad.to_string())
.set("rightSide", kb.rwad.to_string())
.set("leftMenu", kb.lmenu.to_string())
.set("rightMenu", kb.rmenu.to_string())
.set("mouse", if kb.use_mouse { "1" } else { "0" });
}
Keyboard::Chunithm(kb) => {
for (i, cell) in kb.cell.iter().enumerate() {
ini.with_section(Some("slider")).set(format!("cell{}", i + 1), cell.to_string());
}
if kb.split_ir {
for (i, ir) in kb.ir.iter().enumerate() {
ini.with_section(Some("ir")).set(format!("ir{}", i + 1), ir.to_string());
}
} else {
ini.with_section(Some("io3")).set("ir", kb.ir[0].to_string());
}
ini.with_section(Some("io3"))
.set("test", kb.test.to_string())
.set("service", kb.svc.to_string())
.set("coin", kb.coin.to_string());
}
}
Ok(())
}
}

View File

@ -3,6 +3,7 @@ pub mod segatools;
pub mod network;
pub mod bepinex;
pub mod mu3ini;
pub mod keyboard;
#[cfg(target_os = "windows")]
pub mod display_windows;

View File

@ -5,6 +5,32 @@ use ini::Ini;
use crate::model::profile::{Network, NetworkType};
impl Network {
pub fn load_from_ini(&mut self, ini: &Ini) -> Result<()> {
log::debug!("loading network");
if let Some(s) = ini.section(Some("dns")) {
if let Some(default) = s.get("default") {
if default.starts_with("192.") || default.starts_with("127.") {
self.network_type = NetworkType::Artemis;
} else {
self.network_type = NetworkType::Remote;
self.remote_address = default.to_owned();
}
}
}
if let Some(s) = ini.section(Some("netenv")) {
s.get("addrSuffix").map(|v|
self.suffix = v.parse::<i32>().ok()
);
}
if let Some(s) = ini.section(Some("keychip")) {
s.get("subnet").map(|v| self.subnet = v.to_owned());
s.get("id").map(|v| self.keychip = v.to_owned());
}
Ok(())
}
pub fn line_up(&self, ini: &mut Ini) -> Result<()> {
log::debug!("begin line-up: network");

View File

@ -1,5 +1,4 @@
use std::path::PathBuf;
use std::path::{PathBuf, Path};
use anyhow::{anyhow, Result};
use ini::Ini;
use crate::{model::{misc::Game, profile::{Aime, Segatools}, segatools_base::segatools_base}, profiles::ProfilePaths, util::{self, PathStr}};
@ -31,6 +30,31 @@ impl Segatools {
_ => {},
}
}
pub fn load_from_ini(&mut self, ini: &Ini, config_dir: impl AsRef<Path>) -> Result<()> {
log::debug!("loading sgt");
if let Some(s) = ini.section(Some("vfs")) {
s.get("amfs").map(|v| self.amfs = PathBuf::from(v));
s.get("appdata").map(|v| self.appdata = PathBuf::from(v));
s.get("option").map(|v| self.option = PathBuf::from(v));
}
if let Some(s) = ini.section(Some("aime")) {
if s.get("enable").unwrap_or("0") == "1" {
if let Some(aime_path) = s.get("aimePath") {
if let Some(game_dir) = self.target.parent() {
let target = game_dir.join(aime_path);
std::fs::copy(target, config_dir.as_ref().join("aime.txt"))?;
} else {
log::error!("profile doesn't have a game directory");
}
} else {
log::warn!("aime emulation is enabled, but no aimePath specified");
}
}
}
Ok(())
}
pub async fn line_up(&self, p: &impl ProfilePaths, game: Game) -> Result<Ini> {
log::debug!("begin line-up: segatools");

View File

@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
use std::{collections::BTreeSet, path::{Path, PathBuf}};
use tokio::fs;
use enumflags2::{bitflags, make_bitflags, BitFlags};
use crate::{model::{local::{self, PackageManifest}, rainy}, util};
use crate::{model::{local::{self, PackageManifest}, misc::Game, rainy}, util};
// {namespace}-{name}
#[derive(Eq, Hash, PartialEq, PartialOrd, Ord, Clone, Serialize, Deserialize, Display, Debug)]
@ -14,6 +14,12 @@ pub struct PkgKey(pub String);
#[derive(Eq, Hash, PartialEq, PartialOrd, Ord, Clone, Serialize, Deserialize, Display, Debug)]
pub struct PkgKeyVersion(String);
#[derive(Copy, Clone, Display, Debug, Serialize, Deserialize, Default)]
pub enum PackageSource {
#[default] Rainy,
Local(Game)
}
#[derive(Clone, Default, Serialize, Deserialize)]
#[allow(dead_code)]
pub struct Package {
@ -21,7 +27,8 @@ pub struct Package {
pub name: String,
pub description: String,
pub loc: Option<Local>,
pub rmt: Option<Remote>
pub rmt: Option<Remote>,
pub source: PackageSource,
}
#[derive(Clone, PartialEq, Serialize, Deserialize)]
@ -88,11 +95,12 @@ impl Package {
version: v.version_number,
categories: p.categories,
dependencies: Self::sanitize_deps(v.dependencies)
})
}),
source: PackageSource::Rainy,
})
}
pub async fn from_dir(dir: PathBuf) -> Result<Package> {
pub async fn from_dir(dir: PathBuf, source: PackageSource) -> Result<Package> {
let str = fs::read_to_string(dir.join("manifest.json")).await?;
let mft: local::PackageManifest = serde_json::from_str(&str)?;
@ -116,7 +124,8 @@ impl Package {
status,
dependencies
}),
rmt: None
rmt: None,
source
})
}
@ -125,7 +134,15 @@ impl Package {
}
pub fn path(&self) -> PathBuf {
util::pkg_dir().join(self.key().0)
match self.source {
PackageSource::Rainy => util::pkg_dir().join(self.key().0),
PackageSource::Local(game) =>
util::pkg_dir()
.parent()
.unwrap()
.join(format!("pkg-{game}"))
.join(&self.name),
}
}
pub fn _dir_to_key(dir: &Path) -> Result<String> {

View File

@ -8,7 +8,7 @@ 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, Status};
use crate::pkg::{Package, PackageSource, PkgKey, Remote, Status};
use crate::util;
use crate::download_handler::DownloadHandler;
@ -69,7 +69,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).await {
if let Ok(pkg) = Package::from_dir(dir, PackageSource::Rainy).await {
self.update_nonremote(key, pkg);
} else {
log::error!("couldn't reload {}", key);
@ -83,7 +83,7 @@ impl PackageStore {
for dir in dirents {
if let Ok(dir) = dir {
let path = dir.path();
futures.spawn(Package::from_dir(path));
futures.spawn(Package::from_dir(path, PackageSource::Rainy));
}
}

View File

@ -1,7 +1,7 @@
use serde::{Deserialize, Serialize};
use tauri::AppHandle;
use std::{collections::BTreeSet, path::{Path, PathBuf}};
use crate::{model::{misc::Game, profile::{Aime, Mu3Ini, ProfileModule}}, modules::package::prepare_packages, pkg::PkgKey, pkg_store::PackageStore, util};
use crate::{model::{misc::Game, profile::{Aime, ChunithmKeyboard, Keyboard, Mu3Ini, OngekiKeyboard, ProfileModule}}, modules::package::prepare_packages, pkg::PkgKey, pkg_store::PackageStore, util};
use tauri::Emitter;
use std::process::Stdio;
use crate::model::profile::BepInEx;
@ -54,7 +54,10 @@ pub struct ProfileData {
pub wine: crate::model::profile::Wine,
#[serde(skip_serializing_if = "Option::is_none")]
pub mu3_ini: Option<Mu3Ini>
pub mu3_ini: Option<Mu3Ini>,
#[serde(skip_serializing_if = "Option::is_none")]
pub keyboard: Option<Keyboard>
}
impl Profile {
@ -74,6 +77,12 @@ impl Profile {
#[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 },
keyboard:
if meta.game == Game::Ongeki {
Some(Keyboard::Ongeki(OngekiKeyboard::default()))
} else {
Some(Keyboard::Chunithm(ChunithmKeyboard::default()))
},
},
meta: meta.clone()
};
@ -87,11 +96,18 @@ impl Profile {
pub fn load(game: Game, name: String) -> Result<Self> {
let path = util::profile_config_dir(game, &name).join("profile.json");
if let Ok(s) = std::fs::read_to_string(&path) {
let data = serde_json::from_str::<ProfileData>(&s)
let mut data = serde_json::from_str::<ProfileData>(&s)
.map_err(|e| anyhow!("Unable to parse {:?}: {:?}", path, e))?;
log::debug!("{:?}", data);
if game == Game::Ongeki && data.keyboard.is_none() {
data.keyboard = Some(Keyboard::Ongeki(OngekiKeyboard::default()));
}
if game == Game::Chunithm && data.keyboard.is_none() {
data.keyboard = Some(Keyboard::Chunithm(ChunithmKeyboard::default()));
}
Ok(Profile {
meta: ProfileMeta {
game, name
@ -163,6 +179,10 @@ impl Profile {
if self.meta.game.has_module(ProfileModule::Mu3Ini) && source.mu3_ini.is_some() {
self.data.mu3_ini = source.mu3_ini;
}
if self.meta.game.has_module(ProfileModule::Keyboard) && source.keyboard.is_some() {
self.data.keyboard = source.keyboard;
}
}
pub async fn line_up(&self, pkg_hash: String, refresh: bool, _app: AppHandle) -> Result<()> {
let info = match &self.data.display {
@ -200,6 +220,10 @@ impl Profile {
.map_err(|e| anyhow!("segatools configuration failed:\n{:?}", e))?;
self.data.network.line_up(&mut ini)?;
if let Some(keyboard) = &self.data.keyboard {
keyboard.line_up(&mut ini)?;
}
ini.write_to_file(self.data_dir().join("segatools.ini"))
.map_err(|e| anyhow!("Error writing segatools.ini: {}", e))?;
@ -266,8 +290,12 @@ impl Profile {
.arg(self.meta.game.exe());
if let Some(display) = &self.data.display {
if display.dont_switch_primary && display.target != "default" {
game_builder.args(["-monitor", &display.monitor_index_override.unwrap_or_else(|| 1).to_string()]);
} else {
game_builder.args(["-monitor", "1"]);
}
game_builder.args([
"-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" }

View File

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

View File

@ -1,9 +1,11 @@
<script setup lang="ts">
import { Ref, computed, onMounted, ref } from 'vue';
import Button from 'primevue/button';
import ConfirmDialog from 'primevue/confirmdialog';
import Dialog from 'primevue/dialog';
import InputIcon from 'primevue/inputicon';
import InputText from 'primevue/inputtext';
import ScrollPanel from 'primevue/scrollpanel';
import Tab from 'primevue/tab';
import TabList from 'primevue/tablist';
import TabPanel from 'primevue/tabpanel';
@ -23,6 +25,7 @@ import {
usePrfStore,
} from '../stores';
import { Dirs } from '../types';
import { messageSplit } from '../util';
const pkg = usePkgStore();
const prf = usePrfStore();
@ -86,6 +89,23 @@ listen<{ message: string; header: string }>('invoke-error', (event) => {
: 'main-scale-xl'
"
>
<ConfirmDialog>
<template #message="{ message }">
<ScrollPanel
v-if="messageSplit(message).length > 5"
style="width: 100%; height: 40vh"
>
<p v-for="m in messageSplit(message)">
{{ m }}
</p></ScrollPanel
>
<div v-else>
<p v-for="m in messageSplit(message)">
{{ m }}
</p>
</div>
</template>
</ConfirmDialog>
<Dialog
modal
:visible="errorVisible"

View File

@ -0,0 +1,245 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import InputText from 'primevue/inputtext';
import { usePrfStore } from '../stores';
import { OngekiButtons } from '../types';
const prf = usePrfStore();
const hasClickedM1Once = ref(false);
const handleKey = (
button: string | undefined,
event: KeyboardEvent,
index?: number
) => {
event.preventDefault();
const keycode = toKeycode(event.code);
if (keycode !== null && button !== undefined) {
const data = prf.current!.data.keyboard!.data as any;
if (index !== undefined) {
data[button][index] = keycode;
} else {
data[button] = keycode;
}
}
};
const handleMouse = (
button: string | undefined,
event: MouseEvent,
index?: number
) => {
if (button === undefined || button == 'use_mouse') {
return;
}
if (event.button === 0) {
if (hasClickedM1Once.value === false) {
hasClickedM1Once.value = true;
return;
}
} else {
event.preventDefault();
}
let keycode;
switch (event.button) {
case 0:
keycode = 1;
break;
case 1:
keycode = 4;
break;
case 2:
keycode = 2;
break;
case 3:
keycode = 5;
break;
case 4:
keycode = 6;
break;
default:
break;
}
if (keycode !== undefined) {
const data = prf.current!.data.keyboard!.data as any;
if (index !== undefined) {
data[button][index] = keycode;
} else {
data[button] = keycode;
}
}
};
const getKey = (key: keyof OngekiButtons, index?: number) =>
computed(() => {
const data = prf.current!.data.keyboard?.data as any;
const keycode =
index === undefined
? (data[key] as number | undefined)
: (data[key]?.[index] as number | undefined);
return keycode && fromKeycode(keycode) ? fromKeycode(keycode) : '';
});
const KEY_MAP: { [key: number]: string } = {
1: 'M1',
2: 'M2',
4: 'M3',
5: 'M4',
6: 'M5',
8: 'Backspace',
9: 'Tab',
13: 'Enter',
19: 'Pause',
20: 'CapsLock',
27: 'Escape',
32: 'Space',
33: 'PageUp',
34: 'PageDown',
35: 'End',
36: 'Home',
37: 'ArrowLeft',
38: 'ArrowUp',
39: 'ArrowRight',
40: 'ArrowDown',
45: 'Insert',
46: 'Delete',
48: 'Digit0',
49: 'Digit1',
50: 'Digit2',
51: 'Digit3',
52: 'Digit4',
53: 'Digit5',
54: 'Digit6',
55: 'Digit7',
56: 'Digit8',
57: 'Digit9',
65: 'KeyA',
66: 'KeyB',
67: 'KeyC',
68: 'KeyD',
69: 'KeyE',
70: 'KeyF',
71: 'KeyG',
72: 'KeyH',
73: 'KeyI',
74: 'KeyJ',
75: 'KeyK',
76: 'KeyL',
77: 'KeyM',
78: 'KeyN',
79: 'KeyO',
80: 'KeyP',
81: 'KeyQ',
82: 'KeyR',
83: 'KeyS',
84: 'KeyT',
85: 'KeyU',
86: 'KeyV',
87: 'KeyW',
88: 'KeyX',
89: 'KeyY',
90: 'KeyZ',
91: 'MetaLeft',
92: 'MetaRight',
93: 'ContextMenu',
96: 'Numpad0',
97: 'Numpad1',
98: 'Numpad2',
99: 'Numpad3',
100: 'Numpad4',
101: 'Numpad5',
102: 'Numpad6',
103: 'Numpad7',
104: 'Numpad8',
105: 'Numpad9',
106: 'NumpadMultiply',
107: 'NumpadAdd',
109: 'NumpadSubtract',
110: 'NumpadDecimal',
111: 'NumpadDivide',
112: 'F1',
113: 'F2',
114: 'F3',
115: 'F4',
116: 'F5',
117: 'F6',
118: 'F7',
119: 'F8',
120: 'F9',
121: 'F10',
122: 'F11',
123: 'F12',
144: 'NumLock',
145: 'ScrollLock',
160: 'ShiftLeft',
161: 'ShiftRight',
162: 'ControlLeft',
163: 'ControlRight',
164: 'AltLeft',
165: 'AltRight',
186: 'Semicolon',
187: 'Equal',
188: 'Comma',
189: 'Minus',
190: 'Period',
191: 'Slash',
192: 'Backquote',
219: 'BracketLeft',
220: 'Backslash',
221: 'BracketRight',
222: 'Quote',
};
const fromKeycode = (keyCode: number): string | null => {
return KEY_MAP[keyCode] ?? null;
};
const toKeycode = (key: string): number | null => {
const res = Object.entries(KEY_MAP).find(([_, v]) => v === key)?.[0];
return res ? parseInt(res) : null;
};
defineProps({
small: Boolean,
verySmall: Boolean,
tall: Boolean,
tooltip: String,
button: String,
color: String,
index: Number,
});
</script>
<template>
<InputText
:style="{
width: small ? '3em' : '5em',
height: small ? '3em' : tall ? '10em' : '5em',
fontSize: small ? '0.9em' : '1em',
backgroundColor: color,
}"
unstyled
class="text-center buttoninputtext"
v-tooltip="tooltip"
@contextmenu.prevent="() => {}"
@keydown="(ev: KeyboardEvent) => handleKey(button, ev, index)"
@mousedown="
(ev: MouseEvent) =>
handleMouse(button as keyof OngekiButtons, ev, index)
"
@focusout="() => (hasClickedM1Once = false)"
:model-value="getKey(button as keyof OngekiButtons, index) as any"
/>
</template>
<style scoped lang="css">
.buttoninputtext {
border-radius: 6px;
border: 1px solid rgba(200, 200, 200, 0.3);
}
</style>

View File

@ -6,6 +6,7 @@ const general = useGeneralStore();
defineProps({
title: String,
collapsed: Boolean,
});
</script>
@ -14,6 +15,7 @@ defineProps({
:legend="title"
:toggleable="true"
v-show="general.cfgCategories.has(title ?? '')"
:collapsed="collapsed"
>
<div class="flex w-full flex-col gap-1">
<slot />

View File

@ -7,6 +7,7 @@ import OptionCategory from './OptionCategory.vue';
import OptionRow from './OptionRow.vue';
import AimeOptions from './options/Aime.vue';
import DisplayOptions from './options/Display.vue';
import KeyboardOptions from './options/Keyboard.vue';
import MiscOptions from './options/Misc.vue';
import NetworkOptions from './options/Network.vue';
import SegatoolsOptions from './options/Segatools.vue';
@ -129,6 +130,7 @@ prf.reload();
v-model="blacklistMaxModel"
/></OptionRow> -->
</OptionCategory>
<KeyboardOptions />
<StartlinerOptions />
</template>

View File

@ -8,6 +8,7 @@ const category = getCurrentInstance()?.parent?.parent?.parent?.parent; // yes in
const props = defineProps({
title: String,
tooltip: String,
dangerousTooltip: String,
});
const searched = computed(() => {
@ -32,6 +33,11 @@ const searched = computed(() => {
class="pi pi-question-circle ml-2"
v-tooltip="tooltip"
></span>
<span
v-if="dangerousTooltip"
class="pi pi-exclamation-circle ml-2 text-red-500"
v-tooltip="dangerousTooltip"
></span>
</div>
<slot />

View File

@ -1,9 +1,7 @@
<script setup lang="ts">
import { Ref, computed, ref } from 'vue';
import Button from 'primevue/button';
import ConfirmDialog from 'primevue/confirmdialog';
import ContextMenu from 'primevue/contextmenu';
import ScrollPanel from 'primevue/scrollpanel';
import { useConfirm } from 'primevue/useconfirm';
import { listen } from '@tauri-apps/api/event';
import { getCurrentWindow } from '@tauri-apps/api/window';
@ -38,6 +36,8 @@ const startline = async (force: boolean, refresh: boolean) => {
confirmDialog.require({
message: message.join('\n'),
header: 'Start check failed',
acceptLabel: 'Run anyway',
rejectLabel: 'Cancel',
accept: () => {
startline(true, refresh);
},
@ -85,10 +85,6 @@ listen('launch-end', () => {
getCurrentWindow().setFocus();
});
const messageSplit = (message: any) => {
return message.message?.split('\n');
};
const menuItems = [
{
label: 'Refresh and start',
@ -111,45 +107,6 @@ const showContextMenu = (event: Event) => {
<template>
<ContextMenu ref="menu" :model="menuItems" />
<ConfirmDialog>
<template #container="{ message, acceptCallback, rejectCallback }">
<div
class="flex flex-col p-8 bg-surface-0 dark:bg-surface-900 rounded"
>
<span class="font-bold self-center text-2xl block mb-2 mt-2">{{
message.header
}}</span>
<ScrollPanel
v-if="messageSplit(message).length > 5"
style="width: 100%; height: 40vh"
>
<p v-for="m in messageSplit(message)">
{{ m }}
</p></ScrollPanel
>
<div v-else>
<p v-for="m in messageSplit(message)">
{{ m }}
</p>
</div>
<div class="flex self-center items-center gap-2 mt-6">
<Button
label="Run anyway"
@click="acceptCallback"
size="small"
class="w-32"
></Button>
<Button
label="Cancel"
outlined
size="small"
@click="rejectCallback"
class="w-32"
></Button>
</div>
</div>
</template>
</ConfirmDialog>
<Button
v-if="startStatus === 'ready'"
v-tooltip="disabledTooltip"

View File

@ -3,6 +3,7 @@ import { computed, ref } from 'vue';
import InputText from 'primevue/inputtext';
import Select from 'primevue/select';
import ToggleSwitch from 'primevue/toggleswitch';
import { listen } from '@tauri-apps/api/event';
import * as path from '@tauri-apps/api/path';
import { readTextFile, writeTextFile } from '@tauri-apps/plugin-fs';
import OptionCategory from '../OptionCategory.vue';
@ -40,10 +41,14 @@ const aimeCodePaste = (ev: ClipboardEvent) => {
.join('') ?? '';
};
(async () => {
const load = async () => {
const aime_path = await path.join(await prf.configDir, 'aime.txt');
aimeCode.value = await readTextFile(aime_path).catch(() => '');
})();
};
listen('reload-aime-code', load);
load();
</script>
<template>

View File

@ -157,5 +157,40 @@ loadDisplays();
v-model="prf.current!.data.display.borderless_fullscreen"
/>
</OptionRow>
<OptionRow
title="Skip switching primary display"
v-if="
capabilities.includes('display') &&
prf.current?.data.display.target !== 'default' &&
(prf.current!.data.display.dont_switch_primary ||
displayList.length > 2)
"
dangerous-tooltip="Only enable this option if switching the primary display causes issues. The monitors must have a matching refresh rate."
>
<ToggleSwitch
:disabled="extraDisplayOptionsDisabled"
v-model="prf.current!.data.display.dont_switch_primary"
/>
</OptionRow>
<OptionRow
title="Unity display index"
class="number-input"
v-if="
capabilities.includes('display') &&
prf.current?.data.display.target !== 'default' &&
prf.current!.data.display.dont_switch_primary
"
>
<InputNumber
class="shrink"
size="small"
:min="1"
:max="32"
:use-grouping="false"
v-model="prf.current!.data.display.monitor_index_override"
:disabled="extraDisplayOptionsDisabled"
:allow-empty="true"
/>
</OptionRow>
</OptionCategory>
</template>

View File

@ -0,0 +1,177 @@
<script setup lang="ts">
import SelectButton from 'primevue/selectbutton';
import ToggleSwitch from 'primevue/toggleswitch';
import KeyboardKey from '../KeyboardKey.vue';
import OptionCategory from '../OptionCategory.vue';
import OptionRow from '../OptionRow.vue';
import { usePrfStore } from '../../stores';
import { ChunithmButtons } from '@/types';
ToggleSwitch;
const prf = usePrfStore();
</script>
<template>
<OptionCategory title="Keyboard">
<OptionRow
title="Lever mode"
v-if="prf.current!.data.keyboard!.game === 'Ongeki'"
>
<SelectButton
v-model="prf.current!.data.keyboard!.data.use_mouse"
:options="[
{ title: 'XInput', value: false },
{ title: 'Mouse', value: true },
]"
:allow-empty="false"
option-label="title"
option-value="value"
/>
</OptionRow>
<OptionRow
title="Enable multiple IRs"
v-if="prf.current!.data.keyboard!.game === 'Chunithm'"
>
<ToggleSwitch v-model="prf.current!.data.keyboard!.data.split_ir" />
</OptionRow>
<div
:style="`position: relative; height: ${prf.current!.data.keyboard!.game === 'Ongeki' ? 400 : 250}px`"
>
<div
class="absolute left-1/6 top-1/10"
style="transform: translateX(-30%) translateY(-50%)"
>
<div class="flex flex-row gap-2 self-center w-full">
<KeyboardKey button="test" small tooltip="Test" />
<KeyboardKey button="svc" small tooltip="Service" />
<KeyboardKey button="coin" small tooltip="Coin" />
</div>
</div>
<div v-if="prf.current?.meta.game === 'ongeki'">
<div
class="absolute left-1/2 top-1/2"
style="transform: translateX(-540%) translateY(-200%)"
>
<KeyboardKey
button="lmenu"
small
color="rgba(255, 0, 0, 0.2)"
/>
</div>
<div
class="absolute right-1/2 top-1/2"
style="transform: translateX(540%) translateY(-200%)"
>
<KeyboardKey
button="rmenu"
small
color="rgba(255, 255, 0, 0.2)"
/>
</div>
<div
class="absolute left-1/2 top-1/2"
style="transform: translateX(-50%) translateY(-20%)"
>
<div class="flex flex-row gap-2 self-center w-full">
<KeyboardKey
button="lwad"
tall
color="rgba(180, 0, 255, 0.2)"
/>
<div style="width: 0.7em"></div>
<KeyboardKey button="l1" color="rgba(255, 0, 0, 0.2)" />
<KeyboardKey button="l2" color="rgba(0, 255, 0, 0.2)" />
<KeyboardKey button="l3" color="rgba(0, 0, 255, 0.2)" />
<div style="width: 0.7em"></div>
<KeyboardKey button="r1" color="rgba(255, 0, 0, 0.2)" />
<KeyboardKey button="r2" color="rgba(0, 255, 0, 0.2)" />
<KeyboardKey button="r3" color="rgba(0, 0, 255, 0.2)" />
<div style="width: 0.7em"></div>
<KeyboardKey
button="rwad"
tall
color="rgba(255, 0, 180, 0.2)"
/>
</div>
</div>
</div>
<div v-if="prf.current?.meta.game === 'chunithm'">
<div class="absolute left-1/2 top-1/5">
<div
class="flex flex-row flex-nowrap gap-2 self-center w-full"
>
<div
v-if="
(
prf.current!.data.keyboard!
.data as ChunithmButtons
).split_ir
"
v-for="idx in Array(6)
.fill(0)
.map((_, i) => i + 1)"
>
<KeyboardKey
button="ir"
:index="idx - 1"
:tooltip="`ir${idx}`"
small
color="rgba(0, 255, 0, 0.2)"
/>
</div>
<div v-else>
<KeyboardKey
button="ir"
:index="0"
:tooltip="`ir0`"
small
color="rgba(0, 255, 0, 0.2)"
/>
</div>
</div>
</div>
<div
class="absolute left-1/2 top-1/2"
style="transform: translateX(-50%) translateY(-5%)"
>
<div
class="flex flex-row flex-nowrap gap-2 self-center w-full"
>
<div
v-for="idx in Array(16)
.fill(0)
.map((_, i) => 16 - i)"
>
<KeyboardKey
button="cell"
:index="idx - 1"
:tooltip="`cell${idx}`"
small
color="rgba(255, 255, 0, 0.2)"
/>
</div>
</div>
<div style="height: 0.6em"></div>
<div
class="flex flex-row flex-nowrap gap-2 self-center w-full"
>
<div
v-for="idx in Array(16)
.fill(0)
.map((_, i) => 32 - i)"
>
<KeyboardKey
button="cell"
:index="idx - 1"
:tooltip="`cell${idx}`"
small
color="rgba(255, 255, 0, 0.2)"
/>
</div>
</div>
</div>
</div>
</div>
</OptionCategory>
</template>

View File

@ -17,6 +17,7 @@ const prf = usePrfStore();
title="More segatools options"
tooltip="Advanced options not covered by STARTLINER"
>
<!-- <Button icon="pi pi-refresh" size="small" /> -->
<FileEditor filename="segatools-base.ini" />
</OptionRow>
</OptionCategory>

View File

@ -1,15 +1,20 @@
<script setup lang="ts">
import { computed } from 'vue';
import Select from 'primevue/select';
import { useConfirm } from 'primevue/useconfirm';
import { emit } from '@tauri-apps/api/event';
import * as path from '@tauri-apps/api/path';
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 { pkgKey } from '../../util';
const prf = usePrfStore();
const pkgs = usePkgStore();
const confirmDialog = useConfirm();
const names = computed(() => {
switch (prf.current?.meta.game) {
@ -31,6 +36,21 @@ const names = computed(() => {
throw new Error('Option tab without a profile');
}
});
const checkSegatoolsIni = async (target: string) => {
const iniPath = await path.join(target, '../segatools.ini');
if (await invoke('file_exists', { path: iniPath })) {
confirmDialog.require({
message: 'Would you like to load the existing configuration data?',
header: 'segatools.ini found',
accept: async () => {
await invoke('load_segatools_ini', { path: iniPath });
await prf.reload();
await emit('reload-aime-code');
},
});
}
};
</script>
<template>
@ -45,7 +65,10 @@ const names = computed(() => {
extension="exe"
:value="prf.current!.data.sgt.target"
:callback="
(value: string) => (prf.current!.data.sgt.target = value)
(value: string) => (
(prf.current!.data.sgt.target = value),
checkSegatoolsIni(value)
)
"
></FilePicker>
</OptionRow>

View File

@ -347,7 +347,7 @@ export const useClientStore = defineStore('client', () => {
scaleFactor.value = value;
const window = getCurrentWindow();
const w = Math.floor(scaleValue(value) * 760);
const w = Math.floor(scaleValue(value) * 900);
const h = Math.floor(scaleValue(value) * 480);
let size = await window.innerSize();

View File

@ -53,6 +53,7 @@ export interface ProfileData {
network: NetworkConfig;
bepinex: BepInExConfig;
mu3_ini: Mu3IniConfig | undefined;
keyboard: KeyboardConfig | undefined;
}
export interface SegatoolsConfig {
@ -78,6 +79,8 @@ export interface DisplayConfig {
rotation: number;
frequency: number;
borderless_fullscreen: boolean;
dont_switch_primary: boolean;
monitor_index_override: number | null;
}
export interface NetworkConfig {
@ -99,6 +102,42 @@ export interface Mu3IniConfig {
// blacklist?: [number, number];
}
export interface OngekiButtons {
use_mouse: boolean;
coin: number;
svc: number;
test: number;
lmenu: number;
rmenu: number;
l1: number;
l2: number;
l3: number;
r1: number;
r2: number;
r3: number;
lwad: number;
rwad: number;
}
export interface ChunithmButtons {
split_ir: boolean;
coin: number;
svc: number;
test: number;
cell: number[];
ir: number[];
}
export type KeyboardConfig =
| {
game: 'Ongeki';
data: OngekiButtons;
}
| {
game: 'Chunithm';
data: ChunithmButtons;
};
export interface Profile {
meta: ProfileMeta;
data: ProfileData;

View File

@ -55,3 +55,7 @@ export const hasFeature = (pkg: Package | undefined, feature: Feature) => {
pkg.loc.status.OK & feature
);
};
export const messageSplit = (message: any) => {
return message.message?.split('\n');
};