6 Commits

Author SHA1 Message Date
2e17e0ae75 feat: diagnostic exports 2025-04-30 21:19:15 +00:00
edef5cc6dc fix: also replace download URLs 2025-04-30 07:35:54 +00:00
2dad0de4f1 fix: update rainycolor's domain 2025-04-30 06:59:38 +00:00
14a65eb5bb fix: keyboard unbinding and IR fixes 2025-04-29 19:59:21 +00:00
0add9200a6 feat: new grouping options 2025-04-28 22:00:33 +00:00
ee49da3665 fix: category sort 2025-04-28 16:47:45 +00:00
18 changed files with 355 additions and 67 deletions

View File

@ -1,3 +1,20 @@
## 0.18.3
- Updated Rainycolor's domain・真
## 0.18.2
- Updated Rainycolor's domain
## 0.18.1
- Keys can now be unbinded with Esc
- Fixed CHUNITHM IR behavior on actual keyboards
## 0.18.0
- Added new grouping options to the package list
## 0.17.0
- Added a package creation prompt

View File

@ -9,7 +9,7 @@ This is a program that seeks to streamline game data configuration, currently su
STARTLINER is four things:
- a mod installer and updater, powered by [Rainycolor Watercolor](https://rainy.patafour.zip),
- a mod installer and updater, powered by [Rainycolor Watercolor](https://rainycolor.org),
- 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).

View File

@ -480,13 +480,18 @@ pub async fn create_shortcut(_app: AppHandle, profile_meta: ProfileMeta) -> Resu
}
#[tauri::command]
pub async fn export_profile(state: State<'_, Mutex<AppData>>, export_keychip: bool, files: Vec<String>) -> Result<(), String> {
pub async fn export_profile(
state: State<'_, Mutex<AppData>>,
is_diagnostic: bool,
export_keychip: bool,
files: Vec<String>
) -> Result<(), String> {
log::debug!("invoke: export_profile({:?}, {:?} files)", export_keychip, files.len());
let appd = state.lock().await;
match &appd.profile {
Some(p) => {
p.export(export_keychip, files)
p.export(export_keychip, files, is_diagnostic)
.map_err(|e| e.to_string())?;
}
None => {

View File

@ -117,12 +117,16 @@ impl Keyboard {
}
}
Keyboard::Chunithm(kb) => {
let mut enabled_ir = false;
if kb.enabled {
for (i, cell) in kb.cell.iter().enumerate() {
ini.with_section(Some("slider")).set(format!("cell{}", i + 1), cell.to_string());
}
for (i, ir) in kb.ir.iter().enumerate() {
ini.with_section(Some("ir")).set(format!("ir{}", i + 1), ir.to_string());
ini.with_section(Some("ir")).set(format!("ir{}", i + 1), (*ir).to_string());
if i > 0 && *ir != 0 {
enabled_ir = true;
}
}
ini.with_section(Some("io3"))
.set("test", kb.test.to_string())
@ -140,8 +144,13 @@ impl Keyboard {
.set("service", "0")
.set("coin", "0");
}
ini.with_section(Some("io3"))
.set("ir", "0");
if enabled_ir {
ini.with_section(Some("io3"))
.set("ir", "0");
} else {
ini.with_section(Some("io3"))
.set("ir", kb.ir[0].to_string());
}
}
}

View File

@ -29,6 +29,10 @@ impl PatchFileVec {
}
pub fn find_patches(&self, target: impl AsRef<Path>) -> Result<Vec<Patch>> {
if !target.as_ref().exists() {
log::warn!("invalid target path: {:?}", target.as_ref());
anyhow::bail!("Unable to open {:?}. Make sure the game path is correct.", target.as_ref());
}
let checksum = try_digest(target.as_ref())?;
let mut res_patches = Vec::new();

View File

@ -109,7 +109,7 @@ impl Package {
loc: None,
rmt: Some(Remote {
package_url: p.package_url,
download_url: v.download_url,
download_url: v.download_url.replace("https://rainy.patafour.zip/", "https://www.rainycolor.org/"),
icon: v.icon,
deprecated: p.is_deprecated,
nsfw: p.has_nsfw_content,

View File

@ -132,7 +132,7 @@ impl PackageStore {
prelude::*,
};
let response = reqwest::get(format!("https://rainy.patafour.zip/c/{game}/api/v1/package/")).await?;
let response = reqwest::get(format!("https://www.rainycolor.org/c/{game}/api/v1/package/")).await?;
let reader = response
.bytes_stream()

View File

@ -36,7 +36,7 @@ impl Profile {
Ok(())
}
pub fn export(&self, export_keychip: bool, extra_files: Vec<String>) -> anyhow::Result<()> {
pub fn export(&self, export_keychip: bool, extra_files: Vec<String>, is_diagnostic: bool) -> anyhow::Result<()> {
let mut prf = self.clone();
let dir = util::config_dir().join("exports");
@ -45,7 +45,7 @@ impl Profile {
std::fs::create_dir(&dir)?;
}
let path = dir.join(format!("{}-{}-template.zip", &self.meta.game, &self.meta.name));
let path = dir.join(format!("{}-{}-{}.zip", &self.meta.game, &self.meta.name, if is_diagnostic { "diagnostic" } else { "template" } ));
{
let sgt = &mut prf.data.sgt;
@ -66,7 +66,7 @@ impl Profile {
if network.local_path.is_absolute() {
network.local_path = PathBuf::new();
}
if !export_keychip {
if !export_keychip || is_diagnostic {
network.keychip = String::new();
}
}
@ -83,6 +83,29 @@ impl Profile {
zip.write_all(&std::fs::read(self.config_dir().join(file))?)?;
}
if is_diagnostic {
let name = "mu3.exe.log";
let path = self.data_dir().join(name);
if path.exists() {
zip.start_file(name, options)?;
zip.write_all(&std::fs::read(path)?)?;
}
let name = "chusanApp.exe.log";
let path = self.data_dir().join(name);
if path.exists() {
zip.start_file(name, options)?;
zip.write_all(&std::fs::read(path)?)?;
}
let name = "amdaemon.exe.log";
let path = self.data_dir().join(name);
if path.exists() {
zip.start_file(name, options)?;
zip.write_all(&std::fs::read(path)?)?;
}
}
zip.finish()?;
Ok(())

View File

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

View File

@ -4,6 +4,9 @@ import InputText from 'primevue/inputtext';
import { fromKeycode, toKeycode } from '../keyboard';
import { usePrfStore } from '../stores';
import { OngekiButtons } from '../types';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const prf = usePrfStore();
@ -61,6 +64,10 @@ const handleKey = (
}
}
if (event.code === 'Escape') {
keycode = 0;
}
if (index !== undefined) {
data[button][index] = keycode;
} else {
@ -160,13 +167,24 @@ const fontSize = computed(() => {
<InputText
:style="{
width: small ? '2.8rem' : '5rem',
height: small ? '2.8rem' : tall ? '10rem' : '5rem',
height:
small && tall
? '5rem'
: small
? '2.8rem'
: tall
? '10rem'
: '5rem',
fontSize,
backgroundColor: color,
}"
unstyled
class="text-center buttoninputtext"
v-tooltip="tooltip ? `${tooltip}: ${modelValue}` : undefined"
v-tooltip="
tooltip
? `${tooltip}: ${modelValue} ${tooltip.startsWith('ir') ? `\n${t('cfg.keyboard.irTooltip')}` : ''}`
: undefined
"
@contextmenu.prevent="() => {}"
@keydown="(ev: KeyboardEvent) => handleKey(button, ev, index)"
@mousedown="

View File

@ -4,13 +4,15 @@ import Button from 'primevue/button';
import Dialog from 'primevue/dialog';
import Fieldset from 'primevue/fieldset';
import InputText from 'primevue/inputtext';
import MultiSelect from 'primevue/multiselect';
import SelectButton from 'primevue/selectbutton';
import ToggleSwitch from 'primevue/toggleswitch';
import { emit } from '@tauri-apps/api/event';
import ModListEntry from './ModListEntry.vue';
import ModTitlecard from './ModTitlecard.vue';
import { invoke } from '../invoke';
import { useClientStore, usePkgStore, usePrfStore } from '../stores';
import { Game, Package } from '../types';
import { Feature, Game, Package } from '../types';
import { pkgKey } from '../util';
import { useI18n } from 'vue-i18n';
@ -35,32 +37,105 @@ const loadPackages = () => {
loadPackages();
const group = computed(() => {
const grouped = Object.groupBy(
pkgs.allLocal
.filter((p) => gameSublist.value.includes(pkgKey(p)))
.filter(
(p) =>
props.search === undefined ||
p.name.toLowerCase().includes(props.search.toLowerCase()) ||
p.namespace
.toLowerCase()
.includes(props.search.toLowerCase())
),
({ namespace }) => namespace
);
if (!('local' in grouped)) {
grouped['local'] = [];
const allCategories = computed(() => {
const res = new Set<string>();
for (const pkg of pkgs.allLocal) {
for (const cat of pkg.rmt?.categories ?? []) {
res.add(cat);
}
}
const res: [string, Package[]][] = [];
return [...res.values()].sort((a, b) => a.localeCompare(b));
});
const local = computed(() => {
return pkgs.allLocal
.filter((p) => gameSublist.value.includes(pkgKey(p)))
.filter((p) => p.namespace === 'local');
});
const groupedList = computed(() => {
const searchedPkgs = pkgs.allLocal
.filter((p) => gameSublist.value.includes(pkgKey(p)))
.filter((p) => p.namespace !== 'local')
.filter(
(p) =>
props.search === undefined ||
p.name.toLowerCase().includes(props.search.toLowerCase()) ||
p.namespace.toLowerCase().includes(props.search.toLowerCase())
);
let grouped;
if (client.pkgListMode === 'namespace') {
grouped = Object.groupBy(searchedPkgs, ({ namespace }) => namespace);
} else if (client.pkgListMode === 'type') {
grouped = {
standard: [] as Package[],
native: [] as Package[],
segatools: [] as Package[],
unsupported: [] as Package[] | undefined,
};
grouped.unsupported = [];
for (const pkg of searchedPkgs) {
const loc = pkg.loc;
if (!loc || !loc.status || typeof loc.status === 'string') {
grouped.unsupported.push(pkg);
} else {
if (
loc.status.OK[0] &
(Feature.GameDLL | Feature.Mempatcher | Feature.AmdDLL)
) {
grouped.native.push(pkg);
} else if (loc.status.OK[0] & Feature.Mod) {
grouped.standard.push(pkg);
}
if (
loc.status.OK[0] &
(Feature.AMNet |
Feature.Aime |
Feature.ChuniIO |
Feature.ChusanHook |
Feature.Mu3IO |
Feature.Mu3Hook)
) {
grouped.segatools.push(pkg);
}
}
}
if (grouped.unsupported.length === 0) {
delete grouped.unsupported;
}
} else {
grouped = {} as { [key: string]: Package[] };
for (const pkg of searchedPkgs) {
for (const cat of pkg.rmt?.categories ?? []) {
if (client.hiddenCategories.includes(cat)) {
continue;
}
if (!(cat in grouped)) {
grouped[cat] = [] as Package[];
}
grouped[cat].push(pkg);
}
}
}
let res: [string, Package[]][] = [];
for (const [k, v] of Object.entries(grouped)) {
if (v !== undefined) {
res.push([k, v]);
}
}
res.sort((a, b) => {
return a[0] === 'local' ? -1000000 : `${a[0]}`.localeCompare(`${b[0]}`);
});
if (
client.pkgListMode === 'namespace' ||
client.pkgListMode === 'category'
) {
res.sort((a, b) => `${a[0]}`.localeCompare(`${b[0]}`));
} else if (client.pkgListMode === 'type') {
for (const entry of res) {
entry[0] = t(`pkglist.${entry[0]}`);
}
}
return res;
});
@ -195,7 +270,43 @@ const gameModelChunithm = gameModel('chunithm');
/>
</div>
</Dialog>
<Fieldset :legend="t('store.missing')" v-if="(missing?.length ?? 0) > 0">
<div class="flex flex-row">
<SelectButton
:options="[
{ title: t('pkglist.namespace'), value: 'namespace' },
{ title: t('pkglist.type'), value: 'type' },
{ title: t('pkglist.category'), value: 'category' },
]"
v-model="client.pkgListMode"
v-on:update:model-value="
client.save();
emit('reload-icons');
"
:allow-empty="false"
option-label="title"
option-value="value"
/>
<div
class="grow text-right mr-2 self-center"
v-if="client.pkgListMode === 'category'"
>
{{ t('pkglist.exclusions') }}
</div>
<MultiSelect
v-if="client.pkgListMode === 'category'"
style="width: 30%"
:showToggleAll="false"
v-model="client.hiddenCategories"
v-on:value-change="
client.save();
emit('reload-icons');
"
:options="allCategories"
class="w-full grow"
/>
</div>
<Fieldset :legend="t('pkglist.missing')" v-if="(missing?.length ?? 0) > 0">
<div class="flex items-center" v-for="p in missing">
<ModTitlecard
show-namespace
@ -218,20 +329,21 @@ const gameModelChunithm = gameModel('chunithm');
/>
</div>
</Fieldset>
<Fieldset v-for="[namespace, pkgs] in group" :legend="namespace">
<Fieldset :legend="t('pkglist.local')">
<ModListEntry v-for="p in local" :pkg="p" />
<Button
rounded
icon="pi pi-plus"
severity="success"
aria-label="install"
size="small"
class="self-center"
style="width: 2rem; height: 2rem"
v-on:click="() => (dialogVisible = true)"
/>
</Fieldset>
<Fieldset v-for="[namespace, pkgs] in groupedList" :legend="namespace">
<ModListEntry v-for="p in pkgs" :pkg="p" />
<div v-if="namespace === 'local'">
<Button
rounded
icon="pi pi-plus"
severity="success"
aria-label="install"
size="small"
class="self-center"
style="width: 2rem; height: 2rem"
v-on:click="() => (dialogVisible = true)"
/>
</div>
</Fieldset>
</template>

View File

@ -7,7 +7,7 @@ import LinkButton from './LinkButton.vue';
import ModTitlecard from './ModTitlecard.vue';
import UpdateButton from './UpdateButton.vue';
import { invoke } from '../invoke';
import { usePkgStore, usePrfStore } from '../stores';
import { useClientStore, usePkgStore, usePrfStore } from '../stores';
import { Feature, Package } from '../types';
import { hasFeature } from '../util';
import { useI18n } from 'vue-i18n';
@ -16,6 +16,7 @@ const { t } = useI18n();
const prf = usePrfStore();
const pkgs = usePkgStore();
const client = useClientStore();
const props = defineProps({
pkg: Object as () => Package,
@ -39,7 +40,13 @@ if (unsupported.value === true && model.value === true) {
<template>
<div class="flex items-center">
<ModTitlecard show-version show-icon show-description :pkg="pkg" />
<ModTitlecard
show-version
show-icon
show-description
:show-namespace="client.pkgListMode !== 'namespace'"
:pkg="pkg"
/>
<UpdateButton :pkg="pkg" />
<span v-tooltip="unsupported && t('store.incompatible')">
<ToggleSwitch

View File

@ -2,9 +2,13 @@
import { ref } from 'vue';
import Chip from 'primevue/chip';
import { convertFileSrc } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { invoke } from '../invoke';
import { Feature, Package } from '../types';
import { hasFeature, needsUpdate } from '../util';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const props = defineProps({
pkg: Object as () => Package,
@ -17,7 +21,7 @@ const props = defineProps({
const icon = ref('/no-icon.png');
(async () => {
const reloadIcons = async () => {
const src = props.pkg?.loc?.icon ?? props.pkg?.rmt?.icon;
if (src === undefined) {
@ -32,7 +36,11 @@ const icon = ref('/no-icon.png');
icon.value = '/no-icon.png';
}
}
})();
};
reloadIcons();
listen('reload-icons', reloadIcons);
</script>
<template>
@ -97,7 +105,12 @@ const icon = ref('/no-icon.png');
v-if="showNamespace && pkg?.namespace"
class="text-sm opacity-75"
>
by&nbsp;{{ pkg.namespace }}
&nbsp;{{
t('by', { namespace: pkg.namespace }).replaceAll(
' ',
'&nbsp;'
)
}}
</span>
<span class="m-2">
<span

View File

@ -29,23 +29,34 @@ const files = new Set<string>();
).includes('chunithm');
})();
const fileList = {
ongeki: ['aime.txt', 'inohara.cfg', 'mu3.ini', 'segatools-base.ini'],
chunithm: ['aime.txt', 'saekawa.toml', 'segatools-base.ini'],
};
const diagnosticList = {
ongeki: ['mu3.ini', 'segatools-base.ini'],
chunithm: ['segatools-base.ini'],
};
const diagnostic = ref(false);
const exportTemplate = async () => {
const fl = [...files.values()];
exportVisible.value = false;
await invoke('export_profile', {
exportKeychip: exportKeychip.value,
files: fl,
isDiagnostic: diagnostic.value,
files:
diagnostic.value === true
? diagnosticList[prf.current!.meta.game]
: fl,
});
await invoke('open_file', {
path: await path.join(await general.configDir, 'exports'),
});
};
const fileList = {
ongeki: ['aime.txt', 'inohara.cfg', 'mu3.ini', 'segatools-base.ini'],
chunithm: ['aime.txt', 'saekawa.toml', 'segatools-base.ini'],
};
const fileListCurrent: Ref<string[]> = ref([]);
const recalcFileList = async () => {
@ -91,16 +102,36 @@ const importPick = async () => {
:visible="exportVisible"
:closable="false /*this shit doesn't work */"
:header="`${t('profile.export')} ${prf.current?.meta.name}`"
:style="{ width: '300px', scale: client.scaleValue }"
:style="{ width: '330px', scale: client.scaleValue }"
>
<div class="flex flex-col gap-4">
<div class="flex flex-col items-center">
<SelectButton
v-model="diagnostic"
:options="[
{
title: t('profile.standardExport'),
value: false,
},
{
title: t('profile.diagnostic'),
value: true,
},
]"
:allow-empty="false"
option-label="title"
option-value="value"
>
</SelectButton>
</div>
<div class="flex flex-row">
<div class="grow">{{ t('profile.export') }} keychip</div>
<ToggleSwitch v-model="exportKeychip" />
<ToggleSwitch :disabled="diagnostic" v-model="exportKeychip" />
</div>
<div class="flex flex-row" v-for="f in fileListCurrent">
<div class="grow">{{ t('profile.export') }} {{ f }}</div>
<ToggleSwitch
:disabled="diagnostic"
:model-value="true"
@update:model-value="
(v) => {

View File

@ -95,7 +95,7 @@ const prf = usePrfStore();
</div>
</div>
<div v-if="prf.current?.meta.game === 'chunithm'">
<div class="absolute left-1/2 top-1/5">
<div class="absolute left-9/17 top-1/12">
<div
class="flex flex-row flex-nowrap gap-2 self-center w-full"
>
@ -108,6 +108,7 @@ const prf = usePrfStore();
button="ir"
:index="idx - 1"
:tooltip="`ir${idx}`"
tall
small
color="rgba(0, 255, 0, 0.2)"
/>

View File

@ -8,6 +8,7 @@ export default {
next: 'Next',
skip: 'Skip',
close: 'Close',
by: 'by {namespace}',
start: {
failed: 'Start check failed',
accept: 'Run anyway',
@ -43,8 +44,10 @@ export default {
reallyDelete: 'Are you sure you want to delete {profile}?',
template: 'STARTLINER template',
importTemplate: 'Import template',
exportTemplate: 'Export template',
exportTemplate: 'Export profile',
export: 'Export',
standardExport: 'Template',
diagnostic: 'Diagnostic',
},
creator: {
header: 'Package creator',
@ -65,10 +68,22 @@ export default {
deprecated: 'Show deprecated',
nsfw: 'Show NSFW',
incompatible: 'This package is currently incompatible with STARTLINER.',
missing: 'Missing',
includeCategories: 'Include categories',
excludeCategories: 'Exclude categories',
},
pkglist: {
missing: 'Missing',
local: 'Local packages',
namespace: 'By namespace',
type: 'By type',
category: 'By category',
standard: 'Standard mods',
native: 'Native mods',
segatools: 'segatools',
unsupported: 'Unsupported',
exclusions: 'Exclusions:',
},
patch: {
loading: 'Loading...',
noneFound:
@ -176,6 +191,8 @@ export default {
'Only applicable if the IO module is set to segatools built-in (keyboard) or a compatible third-party module (like mu3io.NET)',
leverMode: 'Lever mode',
mouse: 'Mouse',
irTooltip:
'When playing on an actual keyboard, only bind ir1; leave the rest unbound',
},
wine: {
prefix: 'Wine prefix',

View File

@ -8,6 +8,7 @@ export default {
next: 'Dalej',
skip: 'Pomiń',
close: 'Zamknij',
by: 'od {namespace}',
start: {
failed: 'Uruchomienie nie powiodło się',
accept: 'Uruchom mimo to',
@ -43,8 +44,10 @@ export default {
reallyDelete: 'Czy na pewno chcesz usunąć {profile}?',
template: 'Szablon',
importTemplate: 'Importuj szablon',
exportTemplate: 'Eksportuj szablon',
exportTemplate: 'Eksportuj profil',
export: 'Eksportuj',
standardExport: 'Szablon',
diagnostic: 'Diagnostyka',
},
creator: {
header: 'Kreator pakietów',
@ -66,10 +69,21 @@ export default {
nsfw: 'Pokaż mityczny O.N.G.E.K.I. Sex Mod dlaczego ta opcja w ogóle tu jest',
incompatible:
'Ten pakiet jest obecnie niekompatybilny ze STARTLINEREM.',
missing: 'Niedostępne',
includeCategories: 'Włącz kategorie',
excludeCategories: 'Wyłącz kategorie',
},
pkglist: {
missing: 'Niedostępne',
local: 'Lokalne pakiety',
namespace: 'Po przestrzeni nazw',
type: 'Po typie',
category: 'Po kategorii',
standard: 'Standardowe mody',
native: 'Natywne mody',
segatools: 'segatools',
unsupported: 'Niewspierane',
exclusions: 'Czarna lista:',
},
patch: {
loading: 'Wczytuję...',
noneFound:
@ -217,6 +231,8 @@ export default {
'Dotyczy tylko wtedy, gdy moduł IO jest ustawiony na wbudowaną emulację lub zgodny moduł (np. mu3io.NET)',
leverMode: 'Tryb wajchy',
mouse: 'Mysz',
irTooltip:
'Jeśli grasz na klawiaturze, ustaw tylko ir1; pozostałe zostaw wyłączone',
},
wine: {
prefix: 'Wine prefix',

View File

@ -375,6 +375,9 @@ export const useClientStore = defineStore('client', () => {
const onboarded: Ref<Game[]> = ref([]);
const locale: Ref<Locale> = ref('en');
const currentTab: Ref<string> = ref('users');
const pkgListMode: Ref<'namespace' | 'type' | 'category'> =
ref('namespace');
const hiddenCategories: Ref<string[]> = ref([]);
const _scaleValue = (value: ScaleType) =>
value === 's' ? 1 : value === 'm' ? 1.25 : value === 'l' ? 1.5 : 2;
@ -445,6 +448,14 @@ export const useClientStore = defineStore('client', () => {
if (input.currentTab) {
currentTab.value = input.currentTab;
}
if (input.pkgListMode) {
pkgListMode.value = input.pkgListMode;
}
if (input.hiddenCategories) {
hiddenCategories.value = input.hiddenCategories;
}
await setLocale(locale.value);
await setTheme(theme.value);
} catch (e) {
@ -484,6 +495,8 @@ export const useClientStore = defineStore('client', () => {
onboarded: onboarded.value,
locale: locale.value,
currentTab: currentTab.value,
pkgListMode: pkgListMode.value,
hiddenCategories: hiddenCategories.value,
})
);
};
@ -560,6 +573,8 @@ export const useClientStore = defineStore('client', () => {
timeout,
scaleModel,
currentTab,
pkgListMode,
hiddenCategories,
_scaleValue,
scaleValue,
load,