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). 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. 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 ```sh
bun install bun install
bun run tauri dev
bun run tauri build 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 ```sh
startliner --start --game ongeki --profile <name> 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/) - [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 ├───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. Arbitrary scripts are not supported by design and that will probably never change.
### Features ## See also
- Clean data modding - [BlueSteel launcher (CW: vore)](https://yozora.bluesteel.737.jp.net/HarmonyPublic/SOS-Kongou)
- 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)

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] #[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); log::debug!("invoke: install_package({})", key);
let mut appd = state.lock().await; let mut appd = state.lock().await;
appd.pkgs.install_package(&key, true, true) appd.pkgs.install_package(&key, force, true)
.await .await
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }

View File

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

View File

@ -1,14 +1,16 @@
use std::collections::BTreeSet; use std::collections::{BTreeMap, BTreeSet};
use serde::Deserialize; use serde::Deserialize;
use crate::pkg::PkgKeyVersion; use crate::pkg::PkgKeyVersion;
// manifest.json // manifest.json
#[derive(Deserialize)] #[derive(Deserialize)]
#[allow(dead_code)]
pub struct PackageManifest { pub struct PackageManifest {
pub name: String, pub name: String,
pub version_number: String, pub version_number: String,
pub description: 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 { pub fn segatools_base() -> String {
"; mu3io is TBD "[vfd]
[mu3io]
path=
[vfd]
; Enable VFD emulation. Disable to use a real VFD ; Enable VFD emulation. Disable to use a real VFD
; GP1232A02A FUTABA assembly. ; GP1232A02A FUTABA assembly.
enable=1 enable=1

View File

@ -57,6 +57,14 @@ impl Segatools {
.set("enable", "0"); .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); log::debug!("option dir: {:?} -> {:?}", opt_dir_in, opt_dir_out);
if !opt_dir_out.exists() { if !opt_dir_out.exists() {

View File

@ -3,7 +3,7 @@ use derive_more::Display;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::{collections::BTreeSet, path::{Path, PathBuf}}; use std::{collections::BTreeSet, path::{Path, PathBuf}};
use tokio::fs; use tokio::fs;
use crate::{model::{local, rainy}, util}; use crate::{model::{local::{self, PackageManifest}, rainy}, util};
// {namespace}-{name} // {namespace}-{name}
#[derive(Eq, Hash, PartialEq, PartialOrd, Ord, Clone, Serialize, Deserialize, Display)] #[derive(Eq, Hash, PartialEq, PartialOrd, Ord, Clone, Serialize, Deserialize, Display)]
@ -27,8 +27,10 @@ pub struct Package {
#[derive(Clone, Default, PartialEq, Serialize, Deserialize)] #[derive(Clone, Default, PartialEq, Serialize, Deserialize)]
pub enum Kind { pub enum Kind {
Unchecked, Unchecked,
Unsupported,
#[default] Mod, #[default] Mod,
Unsupported Hook,
IO,
} }
#[derive(Clone, Default, Serialize, Deserialize)] #[derive(Clone, Default, Serialize, Deserialize)]
@ -84,6 +86,7 @@ impl Package {
.unwrap() .unwrap()
.to_owned(); .to_owned();
let kind = Self::parse_kind(&mft);
let dependencies = Self::sanitize_deps(mft.dependencies); let dependencies = Self::sanitize_deps(mft.dependencies);
Ok(Package { Ok(Package {
@ -94,7 +97,7 @@ impl Package {
loc: Some(Local { loc: Some(Local {
version: mft.version_number, version: mft.version_number,
path: dir.to_owned(), path: dir.to_owned(),
kind: Kind::Mod, kind,
dependencies dependencies
}), }),
rmt: None rmt: None
@ -166,4 +169,25 @@ impl Package {
} }
res 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> { 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) let pkg = self.store.get(key)
.ok_or_else(|| anyhow!("Attempted to install a nonexistent pkg"))? .ok_or_else(|| anyhow!("Attempted to install a nonexistent pkg"))?
.clone(); .clone();
if pkg.loc.is_some() && !force { if pkg.loc.is_some() && !force {
log::debug!("installation skipped");
return Ok(InstallResult::Ready); return Ok(InstallResult::Ready);
} }
@ -152,7 +153,7 @@ impl PackageStore {
if !zip_path.exists() { if !zip_path.exists() {
self.dlh.download_zip(&zip_path, &pkg)?; self.dlh.download_zip(&zip_path, &pkg)?;
log::debug!("Deferring {}", key); log::debug!("deferring {}", key);
return Ok(InstallResult::Deferred); return Ok(InstallResult::Deferred);
} }
@ -170,13 +171,13 @@ impl PackageStore {
pkg: key.to_owned() pkg: key.to_owned()
})?; })?;
log::info!("Installed {}", key); log::info!("installed {}", key);
Ok(InstallResult::Ready) Ok(InstallResult::Ready)
} }
pub async fn delete_package(&mut self, key: &PkgKey, force: bool) -> Result<()> { 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) let pkg = self.store.get_mut(key)
.ok_or_else(|| anyhow!("Attempted to delete a nonexistent pkg"))?; .ok_or_else(|| anyhow!("Attempted to delete a nonexistent pkg"))?;
@ -191,7 +192,7 @@ impl PackageStore {
self.app.emit("install-end", Payload { self.app.emit("install-end", Payload {
pkg: key.to_owned() pkg: key.to_owned()
})?; })?;
log::info!("Deleted {}", key); log::info!("deleted {}", key);
} }
rv rv
} else { } else {
@ -216,7 +217,7 @@ impl PackageStore {
if path.exists() { if path.exists() {
tokio::fs::remove_dir_all(path) tokio::fs::remove_dir_all(path)
.await .await
.map_err(|e| anyhow!("Could not delete /{}: {}", name, e))?; .map_err(|e| anyhow!("could not delete {}: {}", name, e))?;
} }
Ok(()) Ok(())
@ -237,6 +238,7 @@ impl PackageStore {
// todo case sensitivity for linux // todo case sensitivity for linux
Self::clean_up_dir(&path, "app").await?; Self::clean_up_dir(&path, "app").await?;
Self::clean_up_dir(&path, "option").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, "icon.png", true).await?;
Self::clean_up_file(&path, "manifest.json", true).await?; Self::clean_up_file(&path, "manifest.json", true).await?;
Self::clean_up_file(&path, "README.md", true).await?; Self::clean_up_file(&path, "README.md", true).await?;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,24 +1,37 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted } from 'vue'; import { ref } from 'vue';
import ModStoreEntry from './ModStoreEntry.vue'; import ModStoreEntry from './ModStoreEntry.vue';
import { usePkgStore } from '../stores'; import { usePkgStore } from '../stores';
const pkgs = usePkgStore(); const pkgs = usePkgStore();
const empty = ref(true);
onMounted(() => { const props = defineProps({
pkgs.fetch(); 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> </script>
<template> <template>
<div <div v-for="p in list()" class="flex flex-row">
v-for="p in pkgs.allRemote.sort((p1, p2) =>
p1.name.localeCompare(p2.name)
)"
class="flex flex-row"
>
<ModStoreEntry :pkg="p" /> <ModStoreEntry :pkg="p" />
</div> </div>
<div v-if="empty" class="text-3xl"></div>
</template> </template>
<style lang="scss"> <style lang="scss">

View File

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

View File

@ -31,6 +31,9 @@ const disabledTooltip = computed(() => {
if (prf.current?.sgt.amfs.length === 0) { if (prf.current?.sgt.amfs.length === 0) {
return 'The amfs path must be specified'; 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; return null;
}); });

View File

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

View File

@ -46,8 +46,13 @@ export const usePkgStore = defineStore('pkg', {
fromName: (state) => (namespace: string, name: string) => fromName: (state) => (namespace: string, name: string) =>
state.pkg[`${namespace}-${name}`] ?? null, state.pkg[`${namespace}-${name}`] ?? null,
all: (state) => Object.values(state), 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), 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: { actions: {
setupListeners() { setupListeners() {

View File

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