feat: game options support!

This commit is contained in:
neoapps-dev 2026-08-14 21:24:23 +03:00
parent f7c3502b61
commit 167e1b8066
11 changed files with 1289 additions and 4 deletions

260
schemas/args.json Normal file
View file

@ -0,0 +1,260 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://emerald-legacy-launcher/schemas/args.json",
"title": "ArgsSchema",
"description": "Schema describing launcher command-line argument options, groups, and conditional dependencies.",
"type": "object",
"additionalProperties": false,
"required": ["$schema", "groups", "options", "dependencies"],
"properties": {
"$schema": {
"type": "string",
"description": "URI of the JSON schema this file conforms to."
},
"schemaVersion": {
"type": "integer",
"const": 1,
"description": "Version of the schema format. Currently always 1."
},
"meta": {
"type": "object",
"additionalProperties": true,
"description": "Arbitrary metadata about this schema."
},
"groups": {
"type": "array",
"description": "Groups used to organize options.",
"items": {
"$ref": "#/$defs/schemaGroup"
}
},
"options": {
"type": "array",
"description": "The command-line options.",
"items": {
"$ref": "#/$defs/schemaOption"
}
},
"dependencies": {
"type": "array",
"description": "Conditional dependencies that disable or hide options.",
"items": {
"$ref": "#/$defs/schemaDependency"
}
}
},
"$defs": {
"schemaGroup": {
"type": "object",
"additionalProperties": false,
"required": ["id", "title"],
"properties": {
"id": {
"type": "string",
"description": "Unique identifier for the group."
},
"title": {
"type": "string",
"description": "Display title of the group."
},
"description": {
"type": "string",
"description": "Optional description of the group."
}
}
},
"schemaOption": {
"type": "object",
"additionalProperties": false,
"required": ["id", "title", "type", "arg"],
"properties": {
"id": {
"type": "string",
"description": "Unique identifier for the option."
},
"title": {
"type": "string",
"description": "Display title of the option."
},
"type": {
"enum": ["boolean", "int", "number", "string", "choice"],
"description": "The kind of value the option accepts."
},
"arg": {
"type": "string",
"description": "The command-line argument string emitted for this option."
},
"description": {
"type": "string",
"description": "Optional human-readable description."
},
"group": {
"type": "string",
"description": "Identifier of the group this option belongs to."
},
"default": {
"description": "Default value for the option. Must match the option's type."
},
"min": {
"type": "number",
"description": "Minimum value for int/number options."
},
"max": {
"type": "number",
"description": "Maximum value for int/number options."
},
"step": {
"type": "number",
"description": "Stepping increment for int/number options."
},
"placeholder": {
"type": "string",
"description": "Placeholder text for string options."
},
"choices": {
"type": "array",
"description": "Allowed choices. Required and must be non-empty when type is 'choice'.",
"minItems": 1,
"items": {
"$ref": "#/$defs/schemaChoice"
}
}
},
"allOf": [
{
"if": {
"properties": {
"type": { "const": "choice" }
},
"required": ["type"]
},
"then": {
"required": ["choices"]
}
},
{
"if": {
"properties": {
"type": { "const": "boolean" }
},
"required": ["type"]
},
"then": {
"properties": {
"default": { "type": "boolean" }
}
}
},
{
"if": {
"properties": {
"type": { "const": "string" }
},
"required": ["type"]
},
"then": {
"properties": {
"default": { "type": "string" }
}
}
}
]
},
"schemaChoice": {
"type": "object",
"additionalProperties": false,
"required": ["value"],
"properties": {
"value": {
"type": "string",
"description": "The value stored/emitted for this choice."
},
"label": {
"type": "string",
"description": "Optional display label. Defaults to the value."
}
}
},
"schemaDependency": {
"type": "object",
"additionalProperties": false,
"required": ["target", "when"],
"properties": {
"target": {
"type": "string",
"description": "Identifier of the option affected by this dependency."
},
"when": {
"$ref": "#/$defs/condition",
"description": "Condition that, when true, applies the effect."
},
"effect": {
"enum": ["disable", "hide"],
"description": "Whether the target option is disabled or hidden. Defaults to 'disable'."
}
}
},
"condition": {
"oneOf": [
{
"type": "object",
"additionalProperties": false,
"required": ["option"],
"properties": {
"option": {
"type": "string",
"description": "Identifier of the option to inspect."
},
"equals": {
"description": "True when the option value strictly equals this."
},
"in": {
"type": "array",
"description": "True when the option value is one of these.",
"items": true
},
"not": {
"description": "True when the option value is not strictly equal to this."
},
"exists": {
"type": "boolean",
"description": "True when the option value is present (or absent when false)."
}
}
},
{
"type": "object",
"additionalProperties": false,
"required": ["all"],
"properties": {
"all": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/$defs/condition" }
}
}
},
{
"type": "object",
"additionalProperties": false,
"required": ["any"],
"properties": {
"any": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/$defs/condition" }
}
}
},
{
"type": "object",
"additionalProperties": false,
"required": ["not"],
"properties": {
"not": { "$ref": "#/$defs/condition" }
}
}
]
}
}
}

View file

@ -28,8 +28,9 @@ pub async fn launch_game(
state: State<'_, GameState>,
instance_id: String,
servers: Vec<McServer>,
extra_args: Vec<String>,
mut extra_args: Vec<String>,
) -> Result<(), String> {
extra_args.extend(load_instance_args(&app, &instance_id));
#[cfg(target_os = "android")]
{
let _ = state;
@ -407,6 +408,21 @@ pub fn get_instance_path(app: AppHandle, instance_id: String) -> String {
.to_string()
}
fn load_instance_args(app: &AppHandle, instance_id: &str) -> Vec<String> {
let config_val = config::load_config_raw(app.clone());
config_val
.instance_launch_args
.and_then(|m| m.get(instance_id).cloned())
.map(|entry| entry.args)
.unwrap_or_default()
}
#[tauri::command]
pub fn get_instance_args_schema(app: AppHandle, instance_id: String) -> Option<String> {
let dir = util::get_instance_working_dir(&app, &instance_id);
fs::read_to_string(dir.join("Arguments.Schema.json")).ok()
}
#[tauri::command]
pub fn get_playtime(app: AppHandle, instance_id: String) -> PlaytimeResponse {
playtime::get_playtime(&app, &instance_id)

View file

@ -37,6 +37,7 @@ pub fn load_config_raw(app: AppHandle) -> AppConfig {
extra_launch_args: None,
launch_prefix: None,
launch_env_vars: None,
instance_launch_args: None,
}
}

View file

@ -124,6 +124,7 @@ pub fn run() {
game::get_instance_path,
game::get_playtime,
game::get_playtime_daily,
game::get_instance_args_schema,
#[cfg(desktop)]
game::backup_instance,
#[cfg(desktop)]

View file

@ -32,6 +32,13 @@ pub struct CustomizationEntry {
pub panorama: Option<String>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct InstanceLaunchArgs {
pub values: std::collections::HashMap<String, serde_json::Value>,
pub args: Vec<String>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct AppConfig {
@ -59,6 +66,7 @@ pub struct AppConfig {
pub extra_launch_args: Option<Vec<String>>,
pub launch_prefix: Option<String>,
pub launch_env_vars: Option<std::collections::HashMap<String, String>>,
pub instance_launch_args: Option<std::collections::HashMap<String, InstanceLaunchArgs>>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]

View file

@ -0,0 +1,511 @@
import { useState, useEffect, useMemo, useRef, type RefObject } from "react";
import { TauriService } from "../../services/TauriService";
import {
parseSchema,
mergeValues,
defaultValues,
computeEffects,
buildArgs,
type ArgsSchema,
type SchemaOption,
} from "../../utils/argsSchema";
export default function OptionsModal({
isOpen,
onClose,
playPressSound,
playBackSound,
instanceId,
instanceName,
savedValues,
onSave,
}: {
isOpen: boolean;
onClose: () => void;
playPressSound: (s?: string) => void;
playBackSound: (s?: string) => void;
instanceId: string;
instanceName: string;
savedValues?: Record<string, unknown>;
onSave: (
instanceId: string,
values: Record<string, unknown>,
args: string[],
) => void;
}) {
const [schema, setSchema] = useState<ArgsSchema | null>(null);
const [values, setValues] = useState<Record<string, unknown>>({});
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [focusIndex, setFocusIndex] = useState(0);
const rowRefs = useRef<(HTMLElement | null)[]>([]);
const inputRefs = useRef<(HTMLElement | null)[]>([]);
const resetRef = useRef<HTMLButtonElement | null>(null);
const cancelRef = useRef<HTMLButtonElement | null>(null);
const saveRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
if (!isOpen) return;
let cancelled = false;
setLoading(true);
setError(null);
setSchema(null);
setValues({});
setFocusIndex(0);
TauriService.getInstanceArgsSchema(instanceId)
.then((raw) => {
if (cancelled) return;
if (!raw) {
setError("This instance does not provide a launch options schema.");
setLoading(false);
return;
}
const parsed = parseSchema(raw);
if (!parsed) {
setError("The launch options schema is invalid or unsupported.");
setLoading(false);
return;
}
setSchema(parsed);
setValues(mergeValues(parsed, savedValues));
setLoading(false);
})
.catch((e) => {
if (cancelled) return;
setError(e instanceof Error ? e.message : String(e));
setLoading(false);
});
return () => {
cancelled = true;
};
}, [isOpen, instanceId, savedValues]);
const effects = useMemo(
() => (schema ? computeEffects(schema, values) : {}),
[schema, values],
);
const visibleOptions = useMemo(
() =>
schema
? schema.options.filter((o) => !effects[o.id]?.hidden)
: ([] as SchemaOption[]),
[schema, effects],
);
const optionOrder = useMemo(
() => visibleOptions.map((o) => o.id),
[visibleOptions],
);
useEffect(() => {
setFocusIndex((prev) => Math.min(prev, optionOrder.length + 2));
}, [optionOrder.length]);
const sections = useMemo(() => {
if (!schema)
return {
sections: [] as {
title: string;
description?: string;
options: SchemaOption[];
}[],
general: [] as SchemaOption[],
};
const byGroup = new Map<string, SchemaOption[]>();
const general: SchemaOption[] = [];
const declared = new Set(schema.groups.map((g) => g.id));
for (const option of visibleOptions) {
if (option.group && declared.has(option.group)) {
const list = byGroup.get(option.group);
if (list) list.push(option);
else byGroup.set(option.group, [option]);
} else {
general.push(option);
}
}
const sections = schema.groups
.map((group) => ({
title: group.title,
description: group.description,
options: byGroup.get(group.id) ?? ([] as SchemaOption[]),
}))
.filter((s) => s.options.length > 0);
return { sections, general };
}, [schema, visibleOptions]);
const handleReset = () => {
playPressSound();
if (!schema) return;
setValues(defaultValues(schema));
};
const handleSave = () => {
if (!schema) return;
playPressSound("save_click.wav");
const finalValues = { ...values };
onSave(
instanceId,
finalValues,
buildArgs(schema, finalValues, computeEffects(schema, finalValues)),
);
onClose();
};
useEffect(() => {
if (!isOpen) return;
const handleKey = (e: KeyboardEvent) => {
const activeTag = document.activeElement?.tagName;
if (
activeTag === "INPUT" ||
activeTag === "SELECT" ||
activeTag === "TEXTAREA"
) {
if (e.key === "Escape") {
playBackSound();
onClose();
}
return;
}
const total = optionOrder.length + 3;
if (e.key === "Escape") {
playBackSound();
onClose();
} else if (e.key === "ArrowDown" || e.key === "Tab") {
e.preventDefault();
setFocusIndex((prev) => (prev + 1) % total);
} else if (e.key === "ArrowUp") {
e.preventDefault();
setFocusIndex((prev) => (prev - 1 + total) % total);
} else if (e.key === "Enter") {
e.preventDefault();
if (focusIndex < optionOrder.length) {
const id = optionOrder[focusIndex];
const option = schema?.options.find((o) => o.id === id);
if (!option) return;
const effect = effects[id];
if (option.type === "boolean") {
if (!effect?.disabled) {
setValues((prev) => ({ ...prev, [id]: !prev[id] }));
}
} else {
const input = inputRefs.current[focusIndex];
if (input) input.focus();
}
} else if (focusIndex === optionOrder.length) {
handleReset();
} else if (focusIndex === optionOrder.length + 1) {
playBackSound();
onClose();
} else {
handleSave();
}
}
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [
isOpen,
optionOrder,
focusIndex,
schema,
values,
effects,
playPressSound,
playBackSound,
onClose,
]);
useEffect(() => {
if (!isOpen) return;
if (focusIndex < optionOrder.length) {
rowRefs.current[focusIndex]?.focus();
} else if (focusIndex === optionOrder.length) {
resetRef.current?.focus();
} else if (focusIndex === optionOrder.length + 1) {
cancelRef.current?.focus();
} else {
saveRef.current?.focus();
}
}, [isOpen, focusIndex, optionOrder]);
if (!isOpen) return null;
const flatIndex = (optionId: string) => optionOrder.indexOf(optionId);
const titleDesc = (option: SchemaOption) => (
<div className="flex-1 min-w-0">
<div className="text-sm text-[#222222] mc-text-shadow truncate">
{option.title}
</div>
{option.description && (
<div className="text-[11px] text-[#666666] leading-tight">
{option.description}
</div>
)}
</div>
);
const control = (option: SchemaOption, index: number, disabled: boolean) => {
const value = values[option.id];
switch (option.type) {
case "boolean":
return null;
case "int":
case "number":
return (
<input
ref={(el) => {
inputRefs.current[index] = el;
}}
type="number"
disabled={disabled}
min={option.min}
max={option.max}
step={option.step ?? (option.type === "int" ? 1 : "any")}
value={typeof value === "number" ? value : ""}
onChange={(e) => {
const raw = e.target.value;
setValues((prev) => ({
...prev,
[option.id]: raw === "" ? "" : Number(raw),
}));
}}
onFocus={() => setFocusIndex(index)}
className={`w-24 h-8 bg-black/40 border-2 border-[#373737] text-white text-sm px-2 outline-none text-center font-['Mojangles'] focus:border-[#FFFF55] [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none ${
disabled ? "opacity-40 cursor-not-allowed" : ""
}`}
style={{ imageRendering: "pixelated" }}
/>
);
case "string":
return (
<input
ref={(el) => {
inputRefs.current[index] = el;
}}
type="text"
disabled={disabled}
placeholder={option.placeholder}
value={typeof value === "string" ? value : ""}
onChange={(e) => {
setValues((prev) => ({ ...prev, [option.id]: e.target.value }));
}}
onFocus={() => setFocusIndex(index)}
className={`w-44 h-8 bg-black/40 border-2 border-[#373737] text-white text-sm px-2 outline-none font-['Mojangles'] focus:border-[#FFFF55] ${
disabled ? "opacity-40 cursor-not-allowed" : ""
}`}
style={{ imageRendering: "pixelated" }}
/>
);
case "choice":
return (
<select
ref={(el) => {
inputRefs.current[index] = el;
}}
disabled={disabled}
value={typeof value === "string" ? value : ""}
onChange={(e) => {
setValues((prev) => ({ ...prev, [option.id]: e.target.value }));
}}
onFocus={() => setFocusIndex(index)}
className={`w-44 h-8 bg-white border-2 border-[#373737] text-black text-sm px-2 outline-none font-['Mojangles'] focus:border-[#FFFF55] ${
disabled ? "opacity-40 cursor-not-allowed" : ""
}`}
style={{ imageRendering: "pixelated" }}
>
{option.choices?.map((choice) => (
<option key={choice.value} value={choice.value}>
{choice.label ?? choice.value}
</option>
))}
</select>
);
}
};
const renderOption = (option: SchemaOption) => {
const index = flatIndex(option.id);
const effect = effects[option.id];
const disabled = !!effect?.disabled;
const isFocused = focusIndex === index;
if (option.type === "boolean") {
return (
<button
key={option.id}
ref={(el) => {
rowRefs.current[index] = el;
inputRefs.current[index] = null;
}}
onFocus={() => setFocusIndex(index)}
onClick={() => {
if (disabled) return;
playPressSound();
setValues((prev) => ({ ...prev, [option.id]: !prev[option.id] }));
}}
className={`w-full flex items-center gap-3 px-3 py-2 text-left outline-none border-2 ${
isFocused ? "border-[#FFFF55] bg-black/10" : "border-transparent"
} ${disabled ? "opacity-50 cursor-not-allowed" : ""}`}
>
<div className="relative w-6 h-6 flex-shrink-0 flex items-center justify-center">
<img
src={
isFocused
? "/images/checkbox_highlighted.png"
: "/images/checkbox.png"
}
alt=""
className="absolute inset-0 w-full h-full object-contain"
style={{ imageRendering: "pixelated" }}
/>
{values[option.id] === true && (
<img
src="/images/check.png"
alt=""
className="relative z-10 w-6 h-6 object-contain"
style={{ imageRendering: "pixelated" }}
/>
)}
</div>
{titleDesc(option)}
</button>
);
}
return (
<div
key={option.id}
ref={(el) => {
rowRefs.current[index] = el;
}}
tabIndex={-1}
className={`w-full flex items-center gap-3 px-3 py-2 outline-none border-2 ${
isFocused ? "border-[#FFFF55] bg-black/10" : "border-transparent"
} ${disabled ? "opacity-50" : ""}`}
>
<div className="flex-1 min-w-0 flex items-center gap-3">
{control(option, index, disabled)}
{titleDesc(option)}
</div>
</div>
);
};
const actionButton = (
ref: RefObject<HTMLButtonElement | null>,
index: number,
label: string,
onClick: () => void,
danger?: boolean,
) => (
<button
ref={ref}
onMouseEnter={() => setFocusIndex(index)}
onClick={onClick}
className={`flex-1 h-12 flex items-center justify-center text-xl mc-text-shadow transition-colors outline-none border-none bg-transparent ${
focusIndex === index
? "text-[#FFFF55]"
: danger
? "text-red-500"
: "text-white"
}`}
style={{
backgroundImage:
focusIndex === index
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
{label}
</button>
);
return (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 outline-none border-none"
onMouseDown={(e) => {
if (e.target === e.currentTarget) {
playBackSound();
onClose();
}
}}
>
<div className="relative w-[620px] max-w-[95vw] max-h-[88vh] p-5 flex flex-col items-center font-['Mojangles'] mc-options-bg">
<h2 className="text-xl text-black mc-text-shadow mb-1 text-center">
Options
</h2>
<p className="text-[#333333] text-sm mb-4 text-center truncate max-w-full">
{instanceName}
</p>
{loading ? (
<div className="flex flex-col items-center gap-4 py-10">
<div className="w-12 h-12 border-4 border-[#FFFF55] border-t-transparent rounded-full animate-spin" />
<p className="text-black text-lg mc-text-shadow">
Loading options...
</p>
</div>
) : error ? (
<div className="flex flex-col items-center gap-4 py-8">
<p className="text-red-600 text-sm mc-text-shadow text-center max-w-md">
{error}
</p>
<div className="flex gap-4 mt-2 w-full">
{actionButton(cancelRef, optionOrder.length + 1, "OK", () => {
playBackSound();
onClose();
})}
</div>
</div>
) : schema ? (
<>
<div className="w-full flex-1 min-h-0 max-h-[52vh] overflow-y-auto custom-scrollbar mb-4">
{sections.sections.map((section) => (
<div key={section.title} className="mb-3">
<h3 className="text-[#333333] mc-text-shadow uppercase tracking-widest text-sm px-3 pt-2 pb-1">
{section.title}
</h3>
{section.description && (
<p className="text-[#666666] text-xs px-3 pb-1">
{section.description}
</p>
)}
{section.options.map(renderOption)}
</div>
))}
{sections.general.length > 0 && (
<div className="mb-3">
<h3 className="text-[#333333] mc-text-shadow uppercase tracking-widest text-sm px-3 pt-2 pb-1">
General
</h3>
{sections.general.map(renderOption)}
</div>
)}
</div>
<div className="flex gap-4 w-full flex-shrink-0">
{actionButton(
resetRef,
optionOrder.length,
"Reset",
handleReset,
true,
)}
{actionButton(cancelRef, optionOrder.length + 1, "Cancel", () => {
playBackSound();
onClose();
})}
{actionButton(
saveRef,
optionOrder.length + 2,
"Save",
handleSave,
)}
</div>
</>
) : null}
</div>
</div>
);
}

View file

@ -7,6 +7,8 @@ import ImportWorldModal from "../modals/ImportWorldModal";
import PlaytimeModal from "../modals/PlaytimeModal";
import CustomizeModal from "../modals/CustomizeModal";
import DownloadDlcModal from "../modals/DownloadDlcModal";
import OptionsModal from "../modals/OptionsModal";
import { parseSchema } from "../../utils/argsSchema";
import {
useUI,
useConfig,
@ -64,6 +66,8 @@ const VersionsView = memo(function VersionsView() {
profile: selectedProfile,
setProfile: setSelectedProfile,
animationsEnabled,
instanceLaunchArgs,
setInstanceLaunchArgs,
} = useConfig();
const { playPressSound, playBackSound } = useAudio();
const {
@ -119,12 +123,30 @@ const VersionsView = memo(function VersionsView() {
const [dlcTargetEdition, setDlcTargetEdition] = useState<Edition | null>(
null,
);
const [isOptionsModalOpen, setIsOptionsModalOpen] = useState(false);
const [optionsTarget, setOptionsTarget] = useState<{
id: string;
name: string;
} | null>(null);
const [argsSchemas, setArgsSchemas] = useState<Record<string, boolean>>({});
const containerRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const ITEM_COUNT = editions.length + 3;
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (document.activeElement?.tagName === "INPUT") return;
if (
isImportModalOpen ||
isSetUidModalOpen ||
isImportWorldModalOpen ||
isPlaytimeModalOpen ||
isCustomizeModalOpen ||
isDlcModalOpen ||
isOptionsModalOpen ||
deleteConfirmEdition
) {
return;
}
if (e.key === "Escape" || e.key === "Backspace") {
playBackSound();
@ -213,6 +235,14 @@ const VersionsView = memo(function VersionsView() {
handleCancelDownload,
addToSteam,
isDayTime,
isImportModalOpen,
isSetUidModalOpen,
isImportWorldModalOpen,
isPlaytimeModalOpen,
isCustomizeModalOpen,
isDlcModalOpen,
isOptionsModalOpen,
deleteConfirmEdition,
]);
useEffect(() => {
@ -243,6 +273,29 @@ const VersionsView = memo(function VersionsView() {
fetchPlaytimes();
}, [installedVersions]);
useEffect(() => {
let cancelled = false;
const checkSchemas = async () => {
const map: Record<string, boolean> = {};
await Promise.all(
installedVersions.map(async (id) => {
try {
const schema = await TauriService.getInstanceArgsSchema(id);
map[id] = !!schema && parseSchema(schema) !== null;
} catch (e) {
console.error(e);
map[id] = false;
}
}),
);
if (!cancelled) setArgsSchemas(map);
};
checkSchemas();
return () => {
cancelled = true;
};
}, [installedVersions]);
const handleEditionClick = (edition: Edition, index: number) => {
const isInstalled = installedVersions.includes(edition.instanceId);
if (isInstalled) {
@ -614,6 +667,42 @@ const VersionsView = memo(function VersionsView() {
Download DLC
</button>
) : null}
{argsSchemas[edition.instanceId] && (
<button
onClick={(e) => {
e.stopPropagation();
playPressSound();
setOptionsTarget({
id: edition.instanceId,
name: edition.name,
});
setIsOptionsModalOpen(true);
setOpenMenuId(null);
}}
className="w-full text-left px-3 py-2 text-xs text-[#dddddd] flex items-center gap-2 mc-text-shadow"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
className="w-3.5 h-3.5"
>
<line x1="4" y1="21" x2="4" y2="14" />
<line x1="4" y1="10" x2="4" y2="3" />
<line x1="12" y1="21" x2="12" y2="12" />
<line x1="12" y1="8" x2="12" y2="3" />
<line x1="20" y1="21" x2="20" y2="16" />
<line x1="20" y1="12" x2="20" y2="3" />
<line x1="1" y1="14" x2="7" y2="14" />
<line x1="9" y1="8" x2="15" y2="8" />
<line x1="17" y1="16" x2="23" y2="16" />
</svg>
Options
</button>
)}
{Array.isArray(edition.branches) &&
edition.branches.length > 0 && (
<button
@ -1070,6 +1159,29 @@ const VersionsView = memo(function VersionsView() {
officialDLC={dlcTargetEdition?.officialDLC ?? ""}
/>
<OptionsModal
isOpen={isOptionsModalOpen}
onClose={() => {
setIsOptionsModalOpen(false);
setOptionsTarget(null);
}}
playPressSound={playPressSound}
playBackSound={playBackSound}
instanceId={optionsTarget?.id ?? ""}
instanceName={optionsTarget?.name ?? ""}
savedValues={
optionsTarget
? instanceLaunchArgs[optionsTarget.id]?.values
: undefined
}
onSave={(instanceId, values, args) => {
setInstanceLaunchArgs((prev) => ({
...prev,
[instanceId]: { values, args },
}));
}}
/>
{deleteConfirmEdition && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
<div

View file

@ -71,7 +71,7 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
configRaw.customizations,
configRaw.legacyMode, configRaw.animationsEnabled, configRaw.mangohudEnabled,
configRaw.extraLaunchArgs, configRaw.launchPrefix, configRaw.launchEnvVars, configRaw.startFullscreen,
configRaw.skipIntro,
configRaw.skipIntro, configRaw.instanceLaunchArgs,
]);
const game = useMemo(() => gameRaw, [
@ -145,6 +145,7 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
launchEnvVars: config.launchEnvVars,
startFullscreen: config.startFullscreen,
skipIntro: config.skipIntro,
instanceLaunchArgs: config.instanceLaunchArgs,
}).catch(console.error);
}
}, [
@ -155,7 +156,7 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
config.rpcEnabled, config.musicVol, config.sfxVol, config.legacyMode,
config.mangohudEnabled, config.extraLaunchArgs, config.launchPrefix,
config.launchEnvVars, config.isLoaded, config.startFullscreen,
config.skipIntro,
config.skipIntro, config.instanceLaunchArgs,
]);
useEffect(() => {

View file

@ -26,6 +26,9 @@ export function useAppConfig() {
const [launchPrefix, setLaunchPrefix] = useState<string | undefined>();
const [launchEnvVars, setLaunchEnvVars] = useState<Record<string, string> | undefined>();
const [skipIntro, setSkipIntro] = useLocalStorage("lce-skip-intro", false);
const [instanceLaunchArgs, setInstanceLaunchArgs] = useState<
Record<string, { values: Record<string, unknown>; args: string[] }>
>({});
useEffect(() => {
TauriService.loadConfig().then((config) => {
if (config.username) setUsername(config.username);
@ -49,6 +52,7 @@ export function useAppConfig() {
if (config.launchPrefix) setLaunchPrefix(config.launchPrefix);
if (config.launchEnvVars) setLaunchEnvVars(config.launchEnvVars);
if (config.skipIntro !== undefined) setSkipIntro(config.skipIntro);
if (config.instanceLaunchArgs) setInstanceLaunchArgs(config.instanceLaunchArgs);
setIsLoaded(true);
});
}, []);
@ -76,9 +80,10 @@ export function useAppConfig() {
launchPrefix,
launchEnvVars,
skipIntro,
instanceLaunchArgs,
}).catch(console.error);
}
}, [username, theme, linuxRunner, perfBoost, profile, customEditions, customPaths, customizations, animationsEnabled, vfxEnabled, rpcEnabled, startFullscreen, musicVol, sfxVol, legacyMode, mangohudEnabled, extraLaunchArgs, launchPrefix, launchEnvVars, skipIntro, isLoaded]);
}, [username, theme, linuxRunner, perfBoost, profile, customEditions, customPaths, customizations, animationsEnabled, vfxEnabled, rpcEnabled, startFullscreen, musicVol, sfxVol, legacyMode, mangohudEnabled, extraLaunchArgs, launchPrefix, launchEnvVars, skipIntro, isLoaded, instanceLaunchArgs]);
return {
username,
@ -128,5 +133,7 @@ export function useAppConfig() {
setLaunchEnvVars,
skipIntro,
setSkipIntro,
instanceLaunchArgs,
setInstanceLaunchArgs,
};
}

View file

@ -48,6 +48,10 @@ export interface AppConfig {
customizations?: Record<string, { titleImage?: string; panorama?: string }>;
customPaths?: Record<string, string>;
skipIntro?: boolean;
instanceLaunchArgs?: Record<
string,
{ values: Record<string, unknown>; args: string[] }
>;
}
export interface ThemePalette {
@ -387,6 +391,10 @@ export class TauriService {
return invoke("get_instance_path", { instanceId });
}
static async getInstanceArgsSchema(instanceId: string): Promise<string | null> {
return invoke("get_instance_args_schema", { instanceId });
}
static async readScreenshotAsDataUrl(path: string): Promise<string> {
return invoke("read_screenshot_as_data_url", { path });
}

360
src/utils/argsSchema.ts Normal file
View file

@ -0,0 +1,360 @@
export type OptionType = "boolean" | "int" | "number" | "string" | "choice";
export interface SchemaChoice {
value: string;
label?: string;
}
export interface SchemaOption {
id: string;
title: string;
type: OptionType;
arg: string;
description?: string;
group?: string;
default?: unknown;
min?: number;
max?: number;
step?: number;
placeholder?: string;
choices?: SchemaChoice[];
}
export interface SchemaGroup {
id: string;
title: string;
description?: string;
}
export type Condition =
| { all: Condition[] }
| { any: Condition[] }
| { not: Condition }
| {
option: string;
equals?: unknown;
in?: unknown[];
not?: unknown;
exists?: boolean;
};
export interface SchemaDependency {
target: string;
when: Condition;
effect?: "disable" | "hide";
}
export interface ArgsSchema {
$schema: string;
schemaVersion?: number;
meta?: Record<string, unknown>;
groups: SchemaGroup[];
options: SchemaOption[];
dependencies: SchemaDependency[];
}
export interface OptionEffects {
hidden: boolean;
disabled: boolean;
}
const VALID_TYPES: OptionType[] = [
"boolean",
"int",
"number",
"string",
"choice",
];
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isFiniteNumber(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value);
}
function isCondition(value: unknown): value is Condition {
if (!isObject(value)) return false;
if ("option" in value) return typeof value.option === "string";
if ("all" in value)
return Array.isArray(value.all) && value.all.every(isCondition);
if ("any" in value)
return Array.isArray(value.any) && value.any.every(isCondition);
if ("not" in value) return isCondition(value.not);
return false;
}
export function parseSchema(raw: string): ArgsSchema | null {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return null;
}
if (!isObject(parsed)) return null;
if (parsed.$schema === undefined) return null; //neo: cheap. i know.
if (!Array.isArray(parsed.options)) return null;
const optionKeys = new Set<string>();
const options: SchemaOption[] = [];
for (const entry of parsed.options) {
if (!isObject(entry)) continue;
const id = typeof entry.id === "string" ? entry.id : "";
const title = typeof entry.title === "string" ? entry.title : "";
const type = entry.type;
const arg = typeof entry.arg === "string" ? entry.arg : "";
if (!id || !title || !arg || optionKeys.has(id)) continue;
if (typeof type !== "string" || !VALID_TYPES.includes(type as OptionType))
continue;
if (type === "choice") {
if (!Array.isArray(entry.choices) || entry.choices.length === 0) continue;
const choices: SchemaChoice[] = [];
const seen = new Set<string>();
for (const c of entry.choices) {
if (!isObject(c) || typeof c.value !== "string" || seen.has(c.value))
continue;
seen.add(c.value);
choices.push({
value: c.value,
label: typeof c.label === "string" ? c.label : c.value,
});
}
if (choices.length === 0) continue;
options.push({
id,
title,
type: "choice",
arg,
description:
typeof entry.description === "string" ? entry.description : undefined,
group: typeof entry.group === "string" ? entry.group : undefined,
default: typeof entry.default === "string" ? entry.default : undefined,
choices,
});
continue;
}
const option: SchemaOption = {
id,
title,
type: type as OptionType,
arg,
description:
typeof entry.description === "string" ? entry.description : undefined,
group: typeof entry.group === "string" ? entry.group : undefined,
};
if (entry.default !== undefined) option.default = entry.default;
if (isFiniteNumber(entry.min)) option.min = entry.min;
if (isFiniteNumber(entry.max)) option.max = entry.max;
if (isFiniteNumber(entry.step)) option.step = entry.step;
if (typeof entry.placeholder === "string")
option.placeholder = entry.placeholder;
options.push(option);
}
const groups: SchemaGroup[] = [];
if (Array.isArray(parsed.groups)) {
const seen = new Set<string>();
for (const g of parsed.groups) {
if (!isObject(g) || typeof g.id !== "string" || seen.has(g.id)) continue;
seen.add(g.id);
groups.push({
id: g.id,
title: typeof g.title === "string" ? g.title : g.id,
description:
typeof g.description === "string" ? g.description : undefined,
});
}
}
const dependencies: SchemaDependency[] = [];
if (Array.isArray(parsed.dependencies)) {
for (const d of parsed.dependencies) {
if (!isObject(d) || typeof d.target !== "string") continue;
if (!isCondition(d.when)) continue;
dependencies.push({
target: d.target,
when: d.when,
effect:
d.effect === "hide" || d.effect === "disable" ? d.effect : "disable",
});
}
}
if (options.length === 0) return null;
return {
$schema: parsed.$schema!.toString(),
schemaVersion: 1,
meta: isObject(parsed.meta) ? parsed.meta : undefined,
groups,
options,
dependencies,
};
}
export function defaultValues(schema: ArgsSchema): Record<string, unknown> {
const values: Record<string, unknown> = {};
for (const option of schema.options) {
values[option.id] = optionDefault(option);
}
return values;
}
export function optionDefault(option: SchemaOption): unknown {
switch (option.type) {
case "boolean":
return typeof option.default === "boolean" ? option.default : false;
case "int":
case "number":
return isFiniteNumber(option.default) ? option.default : 0;
case "string":
return typeof option.default === "string" ? option.default : "";
case "choice": {
if (typeof option.default === "string") {
const match = option.choices?.find((c) => c.value === option.default);
if (match) return match.value;
}
return option.choices?.[0]?.value ?? "";
}
}
}
export function sanitizeValue(option: SchemaOption, value: unknown): unknown {
switch (option.type) {
case "boolean":
return typeof value === "boolean" ? value : optionDefault(option);
case "int":
return isFiniteNumber(value) ? Math.trunc(value) : optionDefault(option);
case "number":
return isFiniteNumber(value) ? value : optionDefault(option);
case "string":
return typeof value === "string" ? value : optionDefault(option);
case "choice": {
if (
typeof value === "string" &&
option.choices?.some((c) => c.value === value)
) {
return value;
}
return optionDefault(option);
}
}
}
export function mergeValues(
schema: ArgsSchema,
saved: Record<string, unknown> | undefined,
): Record<string, unknown> {
const values = defaultValues(schema);
if (!saved) return values;
for (const option of schema.options) {
if (saved[option.id] !== undefined) {
values[option.id] = sanitizeValue(option, saved[option.id]);
}
}
return values;
}
function evaluateLeaf(
condition: Record<string, unknown>,
values: Record<string, unknown>,
): boolean {
const optionId = typeof condition.option === "string" ? condition.option : "";
const value = optionId in values ? values[optionId] : undefined;
if (condition.equals !== undefined) return value === condition.equals;
if (Array.isArray(condition.in)) {
return condition.in.some((candidate) => candidate === value);
}
if (condition.not !== undefined) return value !== condition.not;
if (condition.exists !== undefined) {
return condition.exists
? value !== undefined && value !== null && value !== "" && value !== false
: value === undefined ||
value === null ||
value === "" ||
value === false;
}
return false;
}
export function evaluateCondition(
condition: Condition,
values: Record<string, unknown>,
): boolean {
if ("option" in condition) return evaluateLeaf(condition, values);
if ("all" in condition)
return condition.all.every((c) => evaluateCondition(c, values));
if ("any" in condition)
return condition.any.some((c) => evaluateCondition(c, values));
if ("not" in condition) return !evaluateCondition(condition.not, values);
return false;
}
export function computeEffects(
schema: ArgsSchema,
values: Record<string, unknown>,
): Record<string, OptionEffects> {
const effects: Record<string, OptionEffects> = {};
for (const option of schema.options) {
effects[option.id] = { hidden: false, disabled: false };
}
for (const dep of schema.dependencies) {
if (!(dep.target in effects)) continue;
if (!evaluateCondition(dep.when, values)) continue;
const effect = effects[dep.target];
if (dep.effect === "hide") {
effect.hidden = true;
effect.disabled = true;
} else {
effect.disabled = true;
}
}
return effects;
}
export function buildArgs(
schema: ArgsSchema,
values: Record<string, unknown>,
effects: Record<string, OptionEffects>,
): string[] {
const args: string[] = [];
for (const option of schema.options) {
const effect = effects[option.id];
if (!effect) continue;
if (effect.hidden || effect.disabled) continue;
const value = values[option.id];
switch (option.type) {
case "boolean":
if (value === true) args.push(option.arg);
break;
case "int":
case "number":
if (isFiniteNumber(value)) args.push(option.arg, String(value));
break;
case "string":
if (typeof value === "string" && value.trim() !== "") {
args.push(option.arg, value);
}
break;
case "choice":
if (typeof value === "string" && value !== "") {
args.push(option.arg, value);
}
break;
}
}
return args;
}
export function displayValue(option: SchemaOption, value: unknown): string {
switch (option.type) {
case "boolean":
return value === true ? "true" : "false";
case "int":
case "number":
return isFiniteNumber(value) ? String(value) : "";
case "string":
return typeof value === "string" ? value : "";
case "choice": {
if (typeof value !== "string") return "";
return option.choices?.find((c) => c.value === value)?.label ?? value;
}
}
}