5 Commits

Author SHA1 Message Date
e569d57788 feat: hex patches 2025-04-21 22:05:37 +00:00
b75cc8f240 docs: update README.md 2025-04-21 04:19:12 -12:00
407b34a884 feat: profile imports/exports 2025-04-21 04:15:52 -12:00
890d26e883 chore: bump ver 2025-04-20 06:38:44 +00:00
2aff5834b9 fix: chunithm crashing with mempatcher 2025-04-20 06:37:46 +00:00
17 changed files with 421 additions and 46 deletions

View File

@ -1,3 +1,17 @@
## 0.14.0
- Added the custom FREE PLAY patch for Verse
## 0.13.0
- Added profile imports/exports
- Fixed error when trying to open an empty Ongeki profile
- Switched the default color scheme from invisible to purple
## 0.12.1
- Chunithm: fixed crash when using mempatcher
## 0.12.0 ## 0.12.0
- Ongeki: cache and mu3.ini config are now split per-profile (requires mu3-mods 3.7+) - Ongeki: cache and mu3.ini config are now split per-profile (requires mu3-mods 3.7+)

View File

@ -1,3 +1,8 @@
Looking for
- maimai DX players willing to help develop/test maimai DX support
- translators (any language other than English)
# STARTLINER # STARTLINER
This is a program that seeks to streamline game data configuration, currently supporting O.N.G.E.K.I. and CHUNITHM. This is a program that seeks to streamline game data configuration, currently supporting O.N.G.E.K.I. and CHUNITHM.

View File

@ -1,5 +1,6 @@
### Short-term ### Short-term
- i18n
- https://gitea.tendokyu.moe/TeamTofuShop/segatools/issues/63 - https://gitea.tendokyu.moe/TeamTofuShop/segatools/issues/63
### Long-term ### Long-term

View File

@ -1,7 +1,8 @@
use std::hash::{DefaultHasher, Hash, Hasher}; use std::hash::{DefaultHasher, Hash, Hasher};
use std::path::Path;
use std::time::SystemTime; use std::time::SystemTime;
use crate::model::config::GlobalConfig; use crate::model::config::GlobalConfig;
use crate::model::patch::PatchFileVec; use crate::model::patch::{PatchFileVec, PatchList};
use crate::pkg::{Feature, Status}; use crate::pkg::{Feature, Status};
use crate::profiles::types::Profile; use crate::profiles::types::Profile;
use crate::{model::misc::Game, pkg::PkgKey}; use crate::{model::misc::Game, pkg::PkgKey};
@ -165,4 +166,22 @@ impl AppData {
panic!("unable to initialize the logger? {:?}", e); panic!("unable to initialize the logger? {:?}", e);
} }
} }
pub fn patches_enabled(&self, game_target: impl AsRef<Path>, amd_target: impl AsRef<Path>) -> Result<Vec<&PatchList>> {
let ch1 = sha256::try_digest(game_target.as_ref())?;
let ch2 = sha256::try_digest(amd_target.as_ref())?;
let mut res = Vec::new();
for pfile in &self.patch_vec.0 {
for plist in &pfile.0 {
let this_hash = plist.sha256.to_ascii_lowercase();
log::debug!("checking {}", this_hash);
if this_hash == ch1 || this_hash == ch2 {
log::debug!("enabling {this_hash}");
res.push(plist);
}
}
}
Ok(res)
}
} }

View File

@ -69,9 +69,15 @@ pub async fn startline(app: AppHandle, refresh: bool) -> Result<(), String> {
} }
if let Some(p) = &appd.profile { if let Some(p) = &appd.profile {
log::debug!("{}", hash); log::debug!("{}", hash);
let patches_enabled = appd.patches_enabled(
&p.data.sgt.target,
&p.data.sgt.target.parent().unwrap().join("amdaemon.exe")
).map_err(|e| e.to_string())?;
let info = p.prepare_display() let info = p.prepare_display()
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let lineup_res = p.line_up(hash, refresh, &appd.patch_vec).await let lineup_res = p.line_up(hash, refresh, patches_enabled).await
.map_err(|e| e.to_string()); .map_err(|e| e.to_string());
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
@ -404,6 +410,33 @@ pub async fn create_shortcut(app: AppHandle, profile_meta: ProfileMeta) -> Resul
util::create_shortcut(app, &profile_meta).map_err(|e| e.to_string()) util::create_shortcut(app, &profile_meta).map_err(|e| e.to_string())
} }
#[tauri::command]
pub async fn export_profile(state: State<'_, Mutex<AppData>>, 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)
.map_err(|e| e.to_string())?;
}
None => {
let err = "export_profile: no profile".to_owned();
log::error!("{}", err);
return Err(err);
}
}
Ok(())
}
#[tauri::command]
pub async fn import_profile(path: PathBuf) -> Result<(), String> {
log::debug!("invoke: import_profile({:?})", path);
Profile::import(path).map_err(|e| e.to_string())
}
#[tauri::command] #[tauri::command]
pub async fn list_platform_capabilities() -> Result<Vec<String>, ()> { pub async fn list_platform_capabilities() -> Result<Vec<String>, ()> {
log::debug!("invoke: list_platform_capabilities"); log::debug!("invoke: list_platform_capabilities");

View File

@ -205,6 +205,8 @@ pub async fn run(_args: Vec<String>) {
cmd::save_current_profile, cmd::save_current_profile,
cmd::load_segatools_ini, cmd::load_segatools_ini,
cmd::create_shortcut, cmd::create_shortcut,
cmd::export_profile,
cmd::import_profile,
cmd::get_global_config, cmd::get_global_config,
cmd::set_global_config, cmd::set_global_config,

View File

@ -42,6 +42,7 @@ pub struct Patch {
pub enum PatchData { pub enum PatchData {
Normal(NormalPatch), Normal(NormalPatch),
Number(NumberPatch), Number(NumberPatch),
Hex(HexPatch),
} }
#[derive(Deserialize, Serialize, Clone, Debug)] #[derive(Deserialize, Serialize, Clone, Debug)]
@ -65,6 +66,12 @@ pub struct NumberPatch {
pub max: i32 pub max: i32
} }
#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct HexPatch {
pub offset: u64,
pub off: Vec<u8>,
}
impl Serialize for Patch { impl Serialize for Patch {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where S: Serializer { where S: Serializer {
@ -83,6 +90,11 @@ impl Serialize for Patch {
state.serialize_field("size", &patch.size)?; state.serialize_field("size", &patch.size)?;
state.serialize_field("min", &patch.min)?; state.serialize_field("min", &patch.min)?;
state.serialize_field("max", &patch.max)?; state.serialize_field("max", &patch.max)?;
},
PatchData::Hex(patch) => {
state.serialize_field("type", "hex")?;
state.serialize_field("offset", &patch.offset)?;
state.serialize_field("off", &patch.off)?;
} }
} }
state.end() state.end()
@ -114,6 +126,23 @@ impl<'de> serde::Deserialize<'de> for Patch {
.ok_or_else(|| de::Error::missing_field("max"))? .ok_or_else(|| de::Error::missing_field("max"))?
).map_err(|_| de::Error::missing_field("max"))? ).map_err(|_| de::Error::missing_field("max"))?
}), }),
Some("hex") => {
let mut off_list = Vec::new();
for off in value.get("off").and_then(Value::as_array).unwrap() {
off_list.push(u8::try_from(
off.as_u64().ok_or_else(|| de::Error::missing_field("off"))?
).map_err(|_| de::Error::missing_field("off"))?);
}
// for off in value.get("off").and_then(Value::as_str).unwrap().bytes() {
// off_list.push(off);
// }
PatchData::Hex(HexPatch {
offset: value.get("offset")
.and_then(Value::as_u64)
.ok_or_else(|| de::Error::missing_field("offset"))?,
off: off_list
})
},
None => { None => {
let mut patches = vec![]; let mut patches = vec![];
for patch in value.get("patches").and_then(Value::as_array).unwrap() { for patch in value.get("patches").and_then(Value::as_array).unwrap() {

View File

@ -1,25 +1,23 @@
use std::path::Path; use std::path::Path;
use anyhow::Result; use anyhow::Result;
use crate::model::patch::{Patch, PatchData, PatchFileVec, PatchSelection, PatchSelectionData}; use crate::model::patch::{Patch, PatchData, PatchList, PatchSelection, PatchSelectionData};
impl PatchSelection { impl PatchSelection {
pub async fn render_to_file( pub async fn render_to_file(
&self, &self,
filename: &str, filename: &str,
patches: &PatchFileVec, patch_lists: &Vec<&PatchList>,
path: impl AsRef<Path> path: impl AsRef<Path>
) -> Result<()> { ) -> Result<()> {
let mut res = "".to_owned(); let mut res = "".to_owned();
for file in &patches.0 { for list in patch_lists {
for list in &file.0 { if list.filename != filename {
if list.filename != filename { continue;
continue; }
} for patch in &list.patches {
for patch in &list.patches { if let Some(selection) = self.0.get(&patch.id) {
if let Some(selection) = self.0.get(&patch.id) { res += &Self::render(filename, patch, selection);
res += &Self::render(filename, patch, selection);
}
} }
} }
} }
@ -52,6 +50,21 @@ impl PatchSelection {
} else { } else {
log::error!("invalid number patch {:?}", patch); log::error!("invalid number patch {:?}", patch);
} }
},
PatchData::Hex(data) => {
if let PatchSelectionData::Hex(val) = sel {
res += &format!("{} F+{:X} ", filename, data.offset);
for byte in val {
res += &format!("{:02X}", byte);
}
res += " ";
for byte in &data.off {
res += &format!("{:02X}", byte);
}
} else {
log::error!("invalid number patch {:?}", patch);
}
} }
} }
format!("{}\n", res) format!("{}\n", res)

View File

@ -30,20 +30,21 @@ impl PatchFileVec {
pub fn find_patches(&self, target: impl AsRef<Path>) -> Result<Vec<Patch>> { pub fn find_patches(&self, target: impl AsRef<Path>) -> Result<Vec<Patch>> {
let checksum = try_digest(target.as_ref())?; let checksum = try_digest(target.as_ref())?;
let mut res = Vec::new(); let mut res_patches = Vec::new();
for pfile in &self.0 { for pfile in &self.0 {
for plist in &pfile.0 { for plist in &pfile.0 {
log::debug!("checking {}", plist.sha256.to_ascii_lowercase()); let this_hash = plist.sha256.to_ascii_lowercase();
if plist.sha256.to_ascii_lowercase() == checksum { log::debug!("checking {}", this_hash);
if this_hash == checksum {
let mut cloned = plist.clone().patches; let mut cloned = plist.clone().patches;
res.append(&mut cloned); res_patches.append(&mut cloned);
} }
} }
} }
if res.len() == 0 { if res_patches.len() == 0 {
log::warn!("no matching patchset for {:?} ({})", target.as_ref(), checksum); log::warn!("no matching patchset for {:?} ({})", target.as_ref(), checksum);
} }
Ok(res) Ok(res_patches)
} }
} }

View File

@ -1,6 +1,6 @@
pub use types::{Profile, ProfileData, ProfileMeta, ProfilePaths, StartPayload}; pub use types::{Profile, ProfileData, ProfileMeta, ProfilePaths, StartPayload};
use std::{collections::{BTreeMap, BTreeSet}, path::{Path, PathBuf}}; use std::{collections::{BTreeMap, BTreeSet}, path::{Path, PathBuf}};
use crate::{model::{misc::Game, patch::{PatchFileVec, PatchSelection}, profile::{Aime, ChunithmKeyboard, IOSelection, Keyboard, Mu3Ini, OngekiKeyboard, ProfileModule}}, modules::{display_windows::DisplayInfo, package::prepare_packages}, pkg::PkgKey, pkg_store::PackageStore, util}; use crate::{model::{misc::Game, patch::{PatchList, PatchSelection}, profile::{Aime, ChunithmKeyboard, IOSelection, Keyboard, Mu3Ini, OngekiKeyboard, ProfileModule}}, modules::{display_windows::DisplayInfo, package::prepare_packages}, pkg::PkgKey, pkg_store::PackageStore, util};
use tauri::Emitter; use tauri::Emitter;
use std::process::Stdio; use std::process::Stdio;
use crate::model::profile::BepInEx; use crate::model::profile::BepInEx;
@ -10,6 +10,7 @@ use std::fs::File;
use tokio::process::Command; use tokio::process::Command;
use tokio::task::JoinSet; use tokio::task::JoinSet;
pub mod template;
pub mod types; pub mod types;
impl Profile { impl Profile {
@ -43,12 +44,6 @@ impl Profile {
std::fs::create_dir_all(p.config_dir())?; std::fs::create_dir_all(p.config_dir())?;
std::fs::create_dir_all(p.data_dir())?; std::fs::create_dir_all(p.data_dir())?;
if meta.game == Game::Ongeki {
if let Err(e) = Self::load_existing_mu3_ini(&p.data, &p.meta) {
log::error!("unable to load existing mu3.ini: {e}");
}
}
match meta.game { match meta.game {
Game::Ongeki => std::fs::write(p.config_dir().join("segatools-base.ini"), include_bytes!("../../static/segatools-ongeki.ini"))?, Game::Ongeki => std::fs::write(p.config_dir().join("segatools-base.ini"), include_bytes!("../../static/segatools-ongeki.ini"))?,
Game::Chunithm => std::fs::write(p.config_dir().join("segatools-base.ini"), include_bytes!("../../static/segatools-chunithm.ini"))?, Game::Chunithm => std::fs::write(p.config_dir().join("segatools-base.ini"), include_bytes!("../../static/segatools-chunithm.ini"))?,
@ -186,7 +181,7 @@ impl Profile {
Ok(info) Ok(info)
} }
pub async fn line_up(&self, pkg_hash: String, refresh: bool, patch_files: &PatchFileVec) -> Result<()> { pub async fn line_up(&self, pkg_hash: String, refresh: bool, patchlists_enabled: Vec<&PatchList>) -> Result<()> {
if !self.data_dir().exists() { if !self.data_dir().exists() {
tokio::fs::create_dir(self.data_dir()).await?; tokio::fs::create_dir(self.data_dir()).await?;
} }
@ -226,8 +221,8 @@ impl Profile {
if let Some(patches) = &self.data.patches { if let Some(patches) = &self.data.patches {
futures::try_join!( futures::try_join!(
patches.render_to_file("amdaemon.exe", patch_files, self.data_dir().join("patch-amd.mph")), patches.render_to_file("amdaemon.exe", &patchlists_enabled, self.data_dir().join("patch-amd.mph")),
patches.render_to_file("chusanApp.exe", patch_files, self.data_dir().join("patch-game.mph")) patches.render_to_file("chusanApp.exe", &patchlists_enabled, self.data_dir().join("patch-game.mph"))
)?; )?;
} }
@ -431,12 +426,14 @@ impl Profile {
} }
fn load_existing_mu3_ini(data: &ProfileData, meta: &ProfileMeta) -> Result<()> { fn load_existing_mu3_ini(data: &ProfileData, meta: &ProfileMeta) -> Result<()> {
let mu3_ini_target_path = data.sgt.target.parent().ok_or_else(|| anyhow!("invalid target directory"))?.join("mu3.ini"); if let Some(parent) = data.sgt.target.parent() {
let mu3_ini_profile_path = util::profile_config_dir(meta.game, &meta.name).join("mu3.ini"); let mu3_ini_target_path = parent.join("mu3.ini");
log::debug!("mu3.ini paths: {:?} {:?}", mu3_ini_target_path, mu3_ini_profile_path); let mu3_ini_profile_path = util::profile_config_dir(meta.game, &meta.name).join("mu3.ini");
if mu3_ini_target_path.exists() && !mu3_ini_profile_path.exists() { log::debug!("mu3.ini paths: {:?} {:?}", mu3_ini_target_path, mu3_ini_profile_path);
std::fs::copy(&mu3_ini_target_path, &mu3_ini_profile_path)?; if mu3_ini_target_path.exists() && !mu3_ini_profile_path.exists() {
log::info!("copied mu3.ini from {:?}", &mu3_ini_target_path); std::fs::copy(&mu3_ini_target_path, &mu3_ini_profile_path)?;
log::info!("copied mu3.ini from {:?}", &mu3_ini_target_path);
}
} }
Ok(()) Ok(())
} }

View File

@ -0,0 +1,90 @@
use std::{fs::File, io::{Read, Write}, path::PathBuf};
use zip::{write::FileOptions, ZipArchive, ZipWriter};
use crate::util;
use super::{Profile, ProfilePaths};
impl Profile {
fn find_template_json(archive: &mut ZipArchive<File>) -> anyhow::Result<String> {
if let Ok(mut file) = archive.by_name("template.json") {
let mut contents = Vec::new();
file.read_to_end(&mut contents)?;
Ok(String::from_utf8(contents)?)
} else {
anyhow::bail!("invalid template: no template.json found")
}
}
pub fn import(path: PathBuf) -> anyhow::Result<()> {
let file = File::open(path)?;
let mut archive = ZipArchive::new(file)?;
match Self::find_template_json(&mut archive) {
Ok(raw_p) => {
let p = serde_json::from_str::<Profile>(&raw_p)?;
let dir = util::config_dir().join(format!("profile-{}-{}", &p.meta.game, &p.meta.name));
if dir.exists() {
anyhow::bail!("profile {} already exists", &p.meta.name);
}
std::fs::create_dir(&dir)?;
archive.extract(&dir)?;
std::fs::remove_file(dir.join("template.json"))?;
std::fs::write(dir.join("profile.json"), serde_json::to_string_pretty(&p.data)?)?;
}
Err(e) => {
return Err(e);
}
}
Ok(())
}
pub fn export(&self, export_keychip: bool, extra_files: Vec<String>) -> anyhow::Result<()> {
let mut prf = self.clone();
let dir = util::config_dir().join("exports");
if !dir.exists() {
std::fs::create_dir(&dir)?;
}
let path = dir.join(format!("{}-{}-template.zip", &self.meta.game, &self.meta.name));
{
let sgt = &mut prf.data.sgt;
sgt.target = PathBuf::new();
if sgt.amfs.is_absolute() {
sgt.amfs = PathBuf::new();
}
if sgt.option.is_absolute() {
sgt.option = PathBuf::new();
}
if sgt.appdata.is_absolute() {
sgt.appdata = PathBuf::new();
}
}
{
let network = &mut prf.data.network;
if network.local_path.is_absolute() {
network.local_path = PathBuf::new();
}
if !export_keychip {
network.keychip = String::new();
}
}
let file = File::create(&path)?;
let mut zip = ZipWriter::new(file);
let options: FileOptions<'_, ()> = FileOptions::default();
zip.start_file("template.json", options)?;
zip.write_all(&serde_json::to_string_pretty(&prf)?.as_bytes())?;
for file in extra_files {
log::debug!("extra file: {file}");
zip.start_file(&file, options)?;
zip.write_all(&std::fs::read(self.config_dir().join(file))?)?;
}
zip.finish()?;
Ok(())
}
}

View File

@ -326,6 +326,26 @@
}, },
], ],
}, },
{
id: 'standard-custom-free-play-length',
type: 'number',
name: 'Custom FREE PLAY text length',
tooltip: 'Changes the length of the text displayed when Force FREE PLAY credit text is enabled',
danger: 'If this is longer than 11 characters, \"Force FREE PLAY credit text\" MUST be enabled.',
offset: 0x3875A9,
size: 1,
default: 9,
min: 0,
max: 27,
},
{
id: 'standard-custom-free-play-text',
type: 'hex',
name: 'Custom FREE PLAY text',
tooltip: 'Replace the FREE PLAY text when using Infinite credits',
offset: 0x1A5DFB4,
off: [0x46, 0x52, 0x45, 0x45, 0x20, 0x50, 0x4c, 0x41, 0x59],
},
], ],
}, },
{ {

View File

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

View File

@ -1,5 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue';
import InputNumber from 'primevue/inputnumber'; import InputNumber from 'primevue/inputnumber';
import InputText from 'primevue/inputtext';
import ToggleSwitch from 'primevue/toggleswitch'; import ToggleSwitch from 'primevue/toggleswitch';
import OptionRow from './OptionRow.vue'; import OptionRow from './OptionRow.vue';
import { usePrfStore } from '../stores'; import { usePrfStore } from '../stores';
@ -23,9 +25,26 @@ const setNumber = (key: string, val: number) => {
} }
}; };
defineProps({ const props = defineProps({
patch: Object as () => Patch, patch: Object as () => Patch,
}); });
// One day, I will repent
const hexModel = computed({
get() {
const hex = (prf.current!.data.patches[props.patch!.id!] as any)?.hex;
if (hex !== undefined) {
return new TextDecoder().decode(new Int8Array(hex).buffer);
} else {
return 'FREE PLAY';
}
},
set(value: string) {
(prf.current!.data.patches[props.patch!.id!] as any) = {
hex: new TextEncoder().encode(value),
};
},
});
</script> </script>
<template> <template>
@ -50,5 +69,6 @@ defineProps({
:max="patch?.max" :max="patch?.max"
:placeholder="(patch?.default ?? 0).toString()" :placeholder="(patch?.default ?? 0).toString()"
/> />
<InputText v-else-if="patch?.type === 'hex'" v-model="hexModel" />
</OptionRow> </OptionRow>
</template> </template>

View File

@ -1,29 +1,154 @@
<script setup lang="ts"> <script setup lang="ts">
import { Ref, ref } from 'vue';
import Button from 'primevue/button'; import Button from 'primevue/button';
import Dialog from 'primevue/dialog';
import ToggleSwitch from 'primevue/toggleswitch';
import * as path from '@tauri-apps/api/path';
import { open } from '@tauri-apps/plugin-dialog';
import ProfileListEntry from './ProfileListEntry.vue'; import ProfileListEntry from './ProfileListEntry.vue';
import { usePrfStore } from '../stores'; import { invoke } from '../invoke';
import { useClientStore, useGeneralStore, usePrfStore } from '../stores';
const prf = usePrfStore(); const prf = usePrfStore();
const client = useClientStore();
const general = useGeneralStore();
const exportVisible = ref(false);
const exportKeychip = ref(false);
const files = new Set<string>();
const exportTemplate = async () => {
const fl = [...files.values()];
exportVisible.value = false;
await invoke('export_profile', {
exportKeychip: exportKeychip.value,
files: fl,
});
await invoke('open_file', {
path: await path.join(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 () => {
const res: string[] = [];
files.clear();
for (const idx in fileList[prf.current!.meta.game]) {
const f = fileList[prf.current!.meta.game][idx];
const p = await path.join(await prf.configDir, f);
if (await invoke('file_exists', { path: p })) {
res.push(f);
files.add(f);
}
}
fileListCurrent.value = res;
};
const openExportDialog = async () => {
await recalcFileList();
exportVisible.value = true;
};
const importPick = async () => {
const res = await open({
multiple: false,
directory: false,
filters: [
{
name: 'STARTLINER template',
extensions: ['zip'],
},
],
});
if (res != null) {
await invoke('import_profile', { path: res });
await prf.reloadList();
}
};
</script> </script>
<template> <template>
<Dialog
modal
:visible="exportVisible"
:closable="false /*this shit doesn't work */"
:header="`Export ${prf.current?.meta.name}`"
:style="{ width: '300px', scale: client.scaleValue }"
>
<div class="flex flex-col gap-4">
<div class="flex flex-row">
<div class="grow">Export keychip</div>
<ToggleSwitch v-model="exportKeychip" />
</div>
<div class="flex flex-row" v-for="f in fileListCurrent">
<div class="grow">Export {{ f }}</div>
<ToggleSwitch
:model-value="true"
@update:model-value="
(v) => {
if (v === true) {
files.add(f);
} else {
files.delete(f);
}
}
"
/>
</div>
<div style="width: 100%; text-align: center">
<Button
class="m-auto mr-3"
style="width: 80px"
label="OK"
@click="() => exportTemplate()"
/>
<Button
class="m-auto"
style="width: 80px"
label="Cancel"
@click="() => (exportVisible = false)"
/>
</div>
</div>
</Dialog>
<div v-if="prf.list.length === 0"> <div v-if="prf.list.length === 0">
Welcome to STARTLINER! Start by creating a profile. Welcome to STARTLINER! Start by creating a profile.
</div> </div>
<div class="mt-4 flex flex-row flex-wrap align-middle gap-4"> <div class="mt-4 flex flex-row flex-wrap align-middle gap-4">
<Button <Button
label="O.N.G.E.K.I. profile" label="O.N.G.E.K.I. profile"
icon="pi pi-plus" icon="pi pi-file-plus"
class="ongeki-button profile-button" class="ongeki-button profile-button"
@click="() => prf.create('ongeki')" @click="() => prf.create('ongeki')"
/> />
<Button <Button
label="CHUNITHM profile" label="CHUNITHM profile"
icon="pi pi-plus" icon="pi pi-file-plus"
class="chunithm-button profile-button" class="chunithm-button profile-button"
@click="() => prf.create('chunithm')" @click="() => prf.create('chunithm')"
/> />
</div> </div>
<div class="mt-4 flex flex-row flex-wrap align-middle gap-4">
<Button
label="Import template"
icon="pi pi-file-import"
class="import-button profile-button"
@click="() => importPick()"
/>
<Button
:disabled="prf.current === null"
label="Export template"
icon="pi pi-file-export"
class="profile-button"
@click="() => openExportDialog()"
/>
</div>
<div class="mt-12 flex flex-col flex-wrap align-middle gap-4"> <div class="mt-12 flex flex-col flex-wrap align-middle gap-4">
<div v-for="p in prf.list"> <div v-for="p in prf.list">
<ProfileListEntry :p="p" /> <ProfileListEntry :p="p" />
@ -57,4 +182,14 @@ const prf = usePrfStore();
background-color: var(--p-yellow-300) !important; background-color: var(--p-yellow-300) !important;
border-color: var(--p-yellow-300) !important; border-color: var(--p-yellow-300) !important;
} }
.import-button {
background-color: var(--p-purple-400) !important;
border-color: var(--p-purple-400) !important;
}
.import-button:hover,
.import-button:active {
background-color: var(--p-purple-300) !important;
border-color: var(--p-purple-300) !important;
}
</style> </style>

View File

@ -167,7 +167,7 @@ export interface Patch {
id: string; id: string;
name: string; name: string;
tooltip: string; tooltip: string;
type: undefined | 'number'; type: undefined | 'number' | 'hex';
default: number; default: number;
min: number; min: number;
max: number; max: number;

View File

@ -3,11 +3,7 @@ import { Feature, Game, Package } from './types';
export const changePrimaryColor = (game: Game | null) => { export const changePrimaryColor = (game: Game | null) => {
const color = const color =
game === 'ongeki' game === 'ongeki' ? 'pink' : game === 'chunithm' ? 'yellow' : 'purple';
? 'pink'
: game === 'chunithm'
? 'yellow'
: 'bluegray';
updatePrimaryPalette({ updatePrimaryPalette({
50: `{${color}.50}`, 50: `{${color}.50}`,