feat: initial support for segatools pkgs

This commit is contained in:
2025-03-15 00:08:33 +01:00
parent b525e74467
commit caa20a3aa0
20 changed files with 246 additions and 112 deletions

View File

@ -1,30 +1,39 @@
## STARTLINER
# 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).
Intended for those who just want a glorified `start.bat` clicker, without VHDs, keychips etc.
(for an all-in-one solution, check out the [BlueSteel launcher](https://yozora.bluesteel.737.jp.net/HarmonyPublic/SOS-Kongou)).
Made with Rust (Tauri) and Vue. Technically multiplatform. Contributions welcome.
### Usage
## Features
- [Clean](https://gitea.tendokyu.moe/akanyan/STARTLINER/wiki/Architecture-details) data modding
- Segatools configuration
- Display configuration with automatic rollback
- Support for multiple configurations pointing at the same data
## Usage
Download a prebuilt binary from [Modding Re:Fresh](https://discord.gg/jxvzHjjEmc) or build it yourself:
```sh
bun install
bun run tauri dev
bun run tauri build
```
Once a profile is set up, it is possible to bypass the GUI:
Create a profile, then click on things in the configuration tab (game path, `amfs` and network at the least). STARTLINER expects clean data with unpacked binaries. Anything else you can have in the game directory (segatools, BepInEx, etc.) can be present, but will not be used.
Once a profile has been set up, it is possible to bypass the GUI:
```sh
startliner --start --game ongeki --profile <name>
```
### Package format
To create a desktop shortcut: `Copy -> Paste Shortcut -> Properties`, and then append `--start --game ongeki --profile <name>` to `Target`.
## Package format
- [Package format requirements](https://rainy.patafour.zip/package/create/docs/)
- A subset of [the simple Rainycolor format](https://yozora.bluesteel.737.jp.net/HarmonyPublic/SOS-Kongou/wiki/Create-Module#user-content-rainycolor-simple) is currently supported.
- A subset of the simple BlueSteel Rainycolor format is currently supported. [Full reference (CW: vore)](https://yozora.bluesteel.737.jp.net/HarmonyPublic/SOS-Kongou/wiki/Create-Module#user-content-rainycolor-simple)
```
├───app
@ -42,35 +51,6 @@ More file overrides may be supported in the future.
Arbitrary scripts are not supported by design and that will probably never change.
### Features
## See also
- Clean data modding
- Monitor selection
- segatools configuration UI
- Etc
### Architecture details
- Downloaded packages are stored in `%LOCALAPPDATA%\STARTLINER\data\pkg` (or `$XDG_DATA_HOME/startliner/pkg`).
- Each profile is associated with a prefix directory `%LOCALAPPDATA%\STARTLINER\data\profile-x` which includes:
- `option` with junctions of vanilla opts as well as Rainycolor package opts
- `BepInEx` with Rainycolor mods copied over
- It's currently pointless to symlink those since they are measured in kilobytes
- `segatools.ini` generated on-the-fly and pointing at `option`
- It's currently based on the existing `segatools.ini` but that will change in the future
- logs
- Persistent configuration is stored in `%APPDATA%\STARTLINER` (or `$XDG_CONFIG_HOME/startliner`).
### Todo
- Auto-updates
- CHUNITHM support
- segatools as a special package
- Progress bars and other GUI sugar
- Search bar
- Start check
### Endgame
- IO DLLs and artemis as special packages
- Other arcade games (if there is demand)
- [BlueSteel launcher (CW: vore)](https://yozora.bluesteel.737.jp.net/HarmonyPublic/SOS-Kongou)

12
TODO.md Normal file
View File

@ -0,0 +1,12 @@
### Short-term
- CHUNITHM support
- Start checks
- https://gitea.tendokyu.moe/TeamTofuShop/segatools/issues/63
### Long-term
- Auto-updates
- Progress bars and other GUI sugar
- IO DLLs and artemis as special packages
- Other arcade games (if there is demand)

View File

@ -45,11 +45,15 @@ pub async fn kill() -> Result<(), String> {
}
#[tauri::command]
pub async fn install_package(state: State<'_, tokio::sync::Mutex<AppData>>, key: PkgKey) -> Result<InstallResult, String> {
pub async fn install_package(
state: State<'_, tokio::sync::Mutex<AppData>>,
key: PkgKey,
force: bool
) -> Result<InstallResult, String> {
log::debug!("invoke: install_package({})", key);
let mut appd = state.lock().await;
appd.pkgs.install_package(&key, true, true)
appd.pkgs.install_package(&key, force, true)
.await
.map_err(|e| e.to_string())
}

View File

@ -1,9 +1,12 @@
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::pkg::PkgKey;
#[derive(Deserialize, Serialize, Clone)]
pub struct Segatools {
pub target: PathBuf,
pub hook: Option<PkgKey>,
pub io: Option<PkgKey>,
pub amfs: PathBuf,
pub option: PathBuf,
pub appdata: PathBuf,
@ -15,6 +18,8 @@ impl Default for Segatools {
fn default() -> Self {
Segatools {
target: PathBuf::default(),
hook: Some(PkgKey("segatools-mu3hook".to_owned())),
io: None,
amfs: PathBuf::default(),
option: PathBuf::default(),
appdata: PathBuf::from("appdata"),

View File

@ -1,14 +1,16 @@
use std::collections::BTreeSet;
use std::collections::{BTreeMap, BTreeSet};
use serde::Deserialize;
use crate::pkg::PkgKeyVersion;
// manifest.json
#[derive(Deserialize)]
#[allow(dead_code)]
pub struct PackageManifest {
pub name: String,
pub version_number: String,
pub description: String,
pub dependencies: BTreeSet<PkgKeyVersion>
}
pub dependencies: BTreeSet<PkgKeyVersion>,
#[serde(default)]
pub installers: Vec<BTreeMap<String, serde_json::Value>>
}

View File

@ -1,9 +1,5 @@
pub fn segatools_base() -> String {
"; mu3io is TBD
[mu3io]
path=
[vfd]
"[vfd]
; Enable VFD emulation. Disable to use a real VFD
; GP1232A02A FUTABA assembly.
enable=1

View File

@ -57,6 +57,14 @@ impl Segatools {
.set("enable", "0");
}
if let Some(io) = &self.io {
ini_out.with_section(Some("mu3io"))
.set("path", util::pkg_dir().join(io.to_string()).join("mu3io.dll").stringify()?);
} else {
ini_out.with_section(Some("mu3io"))
.set("path", "");
}
log::debug!("option dir: {:?} -> {:?}", opt_dir_in, opt_dir_out);
if !opt_dir_out.exists() {

View File

@ -3,7 +3,7 @@ use derive_more::Display;
use serde::{Deserialize, Serialize};
use std::{collections::BTreeSet, path::{Path, PathBuf}};
use tokio::fs;
use crate::{model::{local, rainy}, util};
use crate::{model::{local::{self, PackageManifest}, rainy}, util};
// {namespace}-{name}
#[derive(Eq, Hash, PartialEq, PartialOrd, Ord, Clone, Serialize, Deserialize, Display)]
@ -27,8 +27,10 @@ pub struct Package {
#[derive(Clone, Default, PartialEq, Serialize, Deserialize)]
pub enum Kind {
Unchecked,
Unsupported,
#[default] Mod,
Unsupported
Hook,
IO,
}
#[derive(Clone, Default, Serialize, Deserialize)]
@ -84,6 +86,7 @@ impl Package {
.unwrap()
.to_owned();
let kind = Self::parse_kind(&mft);
let dependencies = Self::sanitize_deps(mft.dependencies);
Ok(Package {
@ -94,7 +97,7 @@ impl Package {
loc: Some(Local {
version: mft.version_number,
path: dir.to_owned(),
kind: Kind::Mod,
kind,
dependencies
}),
rmt: None
@ -166,4 +169,25 @@ impl Package {
}
res
}
fn parse_kind(mft: &PackageManifest) -> Kind {
if mft.installers.len() == 0 {
return Kind::Mod;//Unchecked
} else if mft.installers.len() == 1 {
if let Some(serde_json::Value::String(id)) = &mft.installers[0].get("identifier") {
if id == "rainycolor" {
return Kind::Mod
} else if id == "segatools" {
if let Some(serde_json::Value::String(module)) = mft.installers[0].get("module") {
if module.ends_with("hook") {
return Kind::Hook;
} else if module.ends_with("io") {
return Kind::IO;
}
}
}
}
}
return Kind::Unsupported
}
}

View File

@ -120,13 +120,14 @@ impl PackageStore {
}
pub async fn install_package(&mut self, key: &PkgKey, force: bool, install_deps: bool) -> Result<InstallResult> {
log::debug!("Installing {}", key);
log::info!("installation request: {}/{}/{}", key, force, install_deps);
let pkg = self.store.get(key)
.ok_or_else(|| anyhow!("Attempted to install a nonexistent pkg"))?
.clone();
if pkg.loc.is_some() && !force {
log::debug!("installation skipped");
return Ok(InstallResult::Ready);
}
@ -152,7 +153,7 @@ impl PackageStore {
if !zip_path.exists() {
self.dlh.download_zip(&zip_path, &pkg)?;
log::debug!("Deferring {}", key);
log::debug!("deferring {}", key);
return Ok(InstallResult::Deferred);
}
@ -170,13 +171,13 @@ impl PackageStore {
pkg: key.to_owned()
})?;
log::info!("Installed {}", key);
log::info!("installed {}", key);
Ok(InstallResult::Ready)
}
pub async fn delete_package(&mut self, key: &PkgKey, force: bool) -> Result<()> {
log::debug!("Will delete {} {}", key, force);
log::debug!("will delete {} {}", key, force);
let pkg = self.store.get_mut(key)
.ok_or_else(|| anyhow!("Attempted to delete a nonexistent pkg"))?;
@ -191,7 +192,7 @@ impl PackageStore {
self.app.emit("install-end", Payload {
pkg: key.to_owned()
})?;
log::info!("Deleted {}", key);
log::info!("deleted {}", key);
}
rv
} else {
@ -216,7 +217,7 @@ impl PackageStore {
if path.exists() {
tokio::fs::remove_dir_all(path)
.await
.map_err(|e| anyhow!("Could not delete /{}: {}", name, e))?;
.map_err(|e| anyhow!("could not delete {}: {}", name, e))?;
}
Ok(())
@ -237,6 +238,7 @@ impl PackageStore {
// todo case sensitivity for linux
Self::clean_up_dir(&path, "app").await?;
Self::clean_up_dir(&path, "option").await?;
Self::clean_up_dir(&path, "segatools").await?;
Self::clean_up_file(&path, "icon.png", true).await?;
Self::clean_up_file(&path, "manifest.json", true).await?;
Self::clean_up_file(&path, "README.md", true).await?;

View File

@ -3,7 +3,7 @@ use ongeki::OngekiProfile;
use serde::{Deserialize, Serialize};
use tauri::AppHandle;
use std::{collections::BTreeSet, path::{Path, PathBuf}};
use crate::{model::{config::Display, misc::Game}, modules::package::prepare_packages, pkg::PkgKey, util};
use crate::{model::misc::Game, modules::package::prepare_packages, pkg::PkgKey, util};
pub mod ongeki;
@ -72,14 +72,15 @@ impl AnyProfile {
}
pub async fn line_up(&self, pkg_hash: String, _app: AppHandle) -> Result<()> {
match self {
Self::OngekiProfile(p) => {
Self::OngekiProfile(_p) => {
#[cfg(target_os = "windows")]
let info = p.display.line_up()?;
let info = _p.display.line_up()?;
let res = self.line_up_the_rest(pkg_hash).await;
#[cfg(target_os = "windows")]
if let Some(info) = info {
use crate::model::config::Display;
if res.is_ok() {
Display::wait_for_exit(_app, info);
} else {

View File

@ -88,10 +88,13 @@ impl Profile for OngekiProfile {
let target_path = PathBuf::from(&self.sgt.target);
let exe_dir = target_path.parent().ok_or_else(|| anyhow!("Invalid target path"))?;
let sgt_dir = util::pkg_dir()
.join(self.sgt.hook.as_ref().ok_or_else(|| anyhow!("No hook"))?.to_string())
.join("segatools");
#[cfg(target_os = "windows")]
{
game_builder = Command::new(exe_dir.join("inject.exe"));
game_builder = Command::new(sgt_dir.join("inject.exe"));
amd_builder = Command::new("cmd.exe");
}
#[cfg(target_os = "linux")]
@ -99,7 +102,7 @@ impl Profile for OngekiProfile {
game_builder = Command::new(&self.wine.runtime);
amd_builder = Command::new(&self.wine.runtime);
game_builder.arg(exe_dir.join("inject.exe"));
game_builder.arg(sgt_dir.join("inject.exe"));
amd_builder.arg("cmd.exe");
}
@ -109,10 +112,10 @@ impl Profile for OngekiProfile {
)
.current_dir(&exe_dir)
.arg("/C")
.arg(&exe_dir.join("inject.exe"))
.args([
"-d", "-k", "mu3hook.dll",
"amdaemon.exe", "-f", "-c", "config_common.json", "config_server.json", "config_client.json"
.arg(&sgt_dir.join("inject.exe"))
.args(["-d", "-k"])
.arg(sgt_dir.join("mu3hook.dll"))
.args(["amdaemon.exe", "-f", "-c", "config_common.json", "config_server.json", "config_client.json"
]);
game_builder
.env(
@ -124,8 +127,9 @@ impl Profile for OngekiProfile {
self.config_dir().join("inohara.cfg"),
)
.current_dir(&exe_dir)
.args(["-d", "-k"])
.arg(sgt_dir.join("mu3hook.dll"))
.args([
"-d", "-k", "mu3hook.dll",
"mu3.exe", "-monitor 1",
"-screen-width", &self.display.rez.0.to_string(),
"-screen-height", &self.display.rez.1.to_string(),

View File

@ -1,6 +1,8 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { Ref, computed, onMounted, ref } from 'vue';
import Button from 'primevue/button';
import InputIcon from 'primevue/inputicon';
import InputText from 'primevue/inputtext';
import Tab from 'primevue/tab';
import TabList from 'primevue/tablist';
import TabPanel from 'primevue/tabpanel';
@ -21,7 +23,9 @@ const general = useGeneralStore();
pkg.setupListeners();
const currentTab = ref('3');
const currentTab: Ref<string | number> = ref(3);
const searchPkg = ref('');
const searchCfg = ref('');
const isProfileDisabled = computed(() => prf.current === null);
@ -30,47 +34,81 @@ onMounted(async () => {
general.dirs = d as Dirs;
});
const fetch_promise = pkg.fetch();
await Promise.all([prf.reloadList(), prf.reload()]);
if (prf.current !== null) {
await pkg.reloadAll();
currentTab.value = '0';
currentTab.value = 0;
}
fetch_promise.then(async () => {
await invoke('install_package', {
key: 'segatools-mu3hook',
force: false,
});
});
});
</script>
<template>
<main>
<Tabs lazy :value="currentTab" class="h-screen">
<Tabs
lazy
:value="currentTab"
v-on:update:value="(value) => (currentTab = value)"
class="h-screen"
>
<div class="fixed w-full flex z-100">
<TabList class="grow">
<Tab :disabled="isProfileDisabled" value="0"
<Tab :disabled="isProfileDisabled" :value="0"
><div class="pi pi-list-check"></div
></Tab>
<Tab :disabled="isProfileDisabled" value="1"
<Tab :disabled="isProfileDisabled" :value="1"
><div class="pi pi-download"></div
></Tab>
<Tab :disabled="isProfileDisabled" value="2"
<Tab :disabled="isProfileDisabled" :value="2"
><div class="pi pi-cog"></div
></Tab>
<Tab value="3"
<Tab :value="3"
><div class="pi pi-question-circle"></div
></Tab>
<div class="grow"></div>
<div class="flex" v-if="currentTab !== 3">
<InputIcon class="self-center mr-2">
<i class="pi pi-search" />
</InputIcon>
<InputText
v-if="currentTab === 2"
class="self-center"
size="small"
placeholder="Search"
v-model="searchCfg"
/>
<InputText
v-else
class="self-center"
size="small"
placeholder="Search"
v-model="searchPkg"
/>
</div>
<div class="grow"></div>
<StartButton />
</TabList>
</div>
<TabPanels class="w-full grow mt-[3rem]">
<TabPanel value="0">
<ModList />
<TabPanel :value="0">
<ModList :search="searchPkg" />
</TabPanel>
<TabPanel value="1">
<ModStore />
<TabPanel :value="1">
<ModStore :search="searchPkg" />
</TabPanel>
<TabPanel value="2">
<TabPanel :value="2">
<OptionList />
</TabPanel>
<TabPanel value="3">
<TabPanel :value="3">
<strong>UNDER CONSTRUCTION</strong><br />Some features are
missing.<br />Existing features are expected to break
sometimes.

View File

@ -14,7 +14,10 @@ const install = async () => {
}
try {
await invoke('install_package', { key: pkgKey(props.pkg) });
await invoke('install_package', {
key: pkgKey(props.pkg),
force: true,
});
} catch (err) {
console.error(err);
if (props.pkg !== undefined) {

View File

@ -1,29 +1,44 @@
<script setup lang="ts">
import { ref } from 'vue';
import Fieldset from 'primevue/fieldset';
import ModListEntry from './ModListEntry.vue';
import { usePkgStore, usePrfStore } from '../stores';
import { usePkgStore } from '../stores';
const props = defineProps({
search: String,
});
const pkg = usePkgStore();
const prf = usePrfStore();
const empty = ref(true);
const group = () => {
const a = Object.assign(
{},
Object.groupBy(
pkg.allLocal
.filter(
(p) =>
props.search === undefined ||
p.name
.toLowerCase()
.includes(props.search.toLowerCase()) ||
p.namespace
.toLowerCase()
.includes(props.search.toLowerCase())
)
.sort((p1, p2) => p1.namespace.localeCompare(p2.namespace))
.sort((p1, p2) => p1.name.localeCompare(p2.name)),
({ namespace }) => namespace
)
);
empty.value = Object.keys(a).length === 0;
return a;
};
prf.reload();
</script>
<template>
<Fieldset v-for="(namespace, key) in group()" :legend="key.toString()">
<ModListEntry v-for="p in namespace" :pkg="p" />
</Fieldset>
<div v-if="empty" class="text-3xl"></div>
</template>

View File

@ -1,24 +1,37 @@
<script setup lang="ts">
import { onMounted } from 'vue';
import { ref } from 'vue';
import ModStoreEntry from './ModStoreEntry.vue';
import { usePkgStore } from '../stores';
const pkgs = usePkgStore();
const empty = ref(true);
onMounted(() => {
pkgs.fetch();
const props = defineProps({
search: String,
});
const list = () => {
const res = pkgs.allRemote
.filter(
(p) =>
props.search === undefined ||
p.name.toLowerCase().includes(props.search.toLowerCase()) ||
p.namespace
.toLowerCase()
.includes(props.search.toLowerCase()) ||
p.description.toLowerCase().includes(props.search.toLowerCase())
)
.sort((p1, p2) => p1.name.localeCompare(p2.name));
empty.value = res.length === 0;
return res;
};
</script>
<template>
<div
v-for="p in pkgs.allRemote.sort((p1, p2) =>
p1.name.localeCompare(p2.name)
)"
class="flex flex-row"
>
<div v-for="p in list()" class="flex flex-row">
<ModStoreEntry :pkg="p" />
</div>
<div v-if="empty" class="text-3xl"></div>
</template>
<style lang="scss">

View File

@ -12,8 +12,10 @@ import FilePicker from './FilePicker.vue';
import OptionCategory from './OptionCategory.vue';
import OptionRow from './OptionRow.vue';
import { invoke } from '../invoke';
import { usePrfStore } from '../stores';
import { usePkgStore, usePrfStore } from '../stores';
import { pkgKey } from '../util';
const pkg = usePkgStore();
const prf = usePrfStore();
const aimeCode = ref('');
@ -26,13 +28,6 @@ const displayList: Ref<{ title: string; value: string }[]> = ref([
},
]);
// const hookList: Ref<{ title: string; value: string }[]> = ref([
// {
// title: 'segatools-mu3hook',
// value: 'segatools-mu3hook',
// },
// ]);
invoke('list_platform_capabilities')
.then(async (v: unknown) => {
if (Array.isArray(v)) {
@ -86,14 +81,18 @@ const extraDisplayOptionsDisabled = computed(() => {
:callback="(value: string) => (prf.current!.sgt.target = value)"
></FilePicker>
</OptionRow>
<!-- <OptionRow title="mu3hook">
<OptionRow title="mu3hook">
<Select
model-value="segatools-mu3hook"
:options="hookList"
v-model="prf.current!.sgt.hook"
:options="
pkg.hooks.map((p) => {
return { title: pkgKey(p), value: pkgKey(p) };
})
"
option-label="title"
option-value="value"
></Select>
</OptionRow> -->
</OptionRow>
<OptionRow title="amfs">
<FilePicker
:directory="true"
@ -119,6 +118,20 @@ const extraDisplayOptionsDisabled = computed(() => {
"
></FilePicker>
</OptionRow>
<OptionRow title="mu3io">
<Select
v-model="prf.current!.sgt.io"
placeholder="segatools built-in"
:options="[
{ title: 'segatools built-in', value: null },
...pkg.ios.map((p) => {
return { title: pkgKey(p), value: pkgKey(p) };
}),
]"
option-label="title"
option-value="value"
></Select>
</OptionRow>
</OptionCategory>
<OptionCategory title="Display">
<OptionRow

View File

@ -31,6 +31,9 @@ const disabledTooltip = computed(() => {
if (prf.current?.sgt.amfs.length === 0) {
return 'The amfs path must be specified';
}
if (prf.current?.sgt.hook === null || prf.current?.sgt.hook === undefined) {
return 'A segatools hook package is necessary';
}
return null;
});

View File

@ -14,7 +14,10 @@ const install = async () => {
}
try {
await invoke('install_package', { key: pkgKey(props.pkg) });
await invoke('install_package', {
key: pkgKey(props.pkg),
force: true,
});
} catch (err) {
if (props.pkg !== undefined) {
props.pkg.js.busy = false;

View File

@ -46,8 +46,13 @@ export const usePkgStore = defineStore('pkg', {
fromName: (state) => (namespace: string, name: string) =>
state.pkg[`${namespace}-${name}`] ?? null,
all: (state) => Object.values(state),
allLocal: (state) => Object.values(state.pkg).filter((p) => p.loc),
allLocal: (state) =>
Object.values(state.pkg).filter((p) => p.loc?.kind === 'Mod'),
allRemote: (state) => Object.values(state.pkg).filter((p) => p.rmt),
hooks: (state) =>
Object.values(state.pkg).filter((p) => p.loc?.kind === 'Hook'),
ios: (state) =>
Object.values(state.pkg).filter((p) => p.loc?.kind === 'IO'),
},
actions: {
setupListeners() {

View File

@ -7,6 +7,7 @@ export interface Package {
version: string;
path: string;
dependencies: string[];
kind: 'Unchecked' | 'Unsupported' | 'Mod' | 'Hook' | 'IO';
} | null;
rmt: {
version: string;
@ -28,6 +29,8 @@ export interface ProfileMeta {
export interface SegatoolsConfig {
target: string;
hook: string | null;
io: string | null;
amfs: string;
option: string;
appdata: string;