From 167e1b8066215542dbd1ac2b08ae707e4160deb1 Mon Sep 17 00:00:00 2001 From: neoapps-dev Date: Fri, 14 Aug 2026 21:24:23 +0300 Subject: [PATCH] feat: game options support! --- schemas/args.json | 260 +++++++++++++ src-tauri/src/commands/game.rs | 18 +- src-tauri/src/config.rs | 1 + src-tauri/src/lib.rs | 1 + src-tauri/src/types.rs | 8 + src/components/modals/OptionsModal.tsx | 511 +++++++++++++++++++++++++ src/components/views/VersionsView.tsx | 112 ++++++ src/context/LauncherContext.tsx | 5 +- src/hooks/useAppConfig.ts | 9 +- src/services/TauriService.ts | 8 + src/utils/argsSchema.ts | 360 +++++++++++++++++ 11 files changed, 1289 insertions(+), 4 deletions(-) create mode 100644 schemas/args.json create mode 100644 src/components/modals/OptionsModal.tsx create mode 100644 src/utils/argsSchema.ts diff --git a/schemas/args.json b/schemas/args.json new file mode 100644 index 0000000..2c452cd --- /dev/null +++ b/schemas/args.json @@ -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" } + } + } + ] + } + } +} diff --git a/src-tauri/src/commands/game.rs b/src-tauri/src/commands/game.rs index 8231924..c6569f0 100644 --- a/src-tauri/src/commands/game.rs +++ b/src-tauri/src/commands/game.rs @@ -28,8 +28,9 @@ pub async fn launch_game( state: State<'_, GameState>, instance_id: String, servers: Vec, - extra_args: Vec, + mut extra_args: Vec, ) -> 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 { + 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 { + 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) diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 160a726..ecac205 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -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, } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 61d4219..d55ac64 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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)] diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index f51e828..621f4b3 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -32,6 +32,13 @@ pub struct CustomizationEntry { pub panorama: Option, } +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct InstanceLaunchArgs { + pub values: std::collections::HashMap, + pub args: Vec, +} + #[derive(Serialize, Deserialize, Clone, Debug)] #[serde(rename_all = "camelCase")] pub struct AppConfig { @@ -59,6 +66,7 @@ pub struct AppConfig { pub extra_launch_args: Option>, pub launch_prefix: Option, pub launch_env_vars: Option>, + pub instance_launch_args: Option>, } #[derive(Serialize, Deserialize, Clone, Debug)] diff --git a/src/components/modals/OptionsModal.tsx b/src/components/modals/OptionsModal.tsx new file mode 100644 index 0000000..45adac5 --- /dev/null +++ b/src/components/modals/OptionsModal.tsx @@ -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; + onSave: ( + instanceId: string, + values: Record, + args: string[], + ) => void; +}) { + const [schema, setSchema] = useState(null); + const [values, setValues] = useState>({}); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const [focusIndex, setFocusIndex] = useState(0); + const rowRefs = useRef<(HTMLElement | null)[]>([]); + const inputRefs = useRef<(HTMLElement | null)[]>([]); + const resetRef = useRef(null); + const cancelRef = useRef(null); + const saveRef = useRef(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(); + 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) => ( +
+
+ {option.title} +
+ {option.description && ( +
+ {option.description} +
+ )} +
+ ); + + 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 ( + { + 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 ( + { + 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 ( + + ); + } + }; + + 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 ( + + ); + } + return ( +
{ + 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" : ""}`} + > +
+ {control(option, index, disabled)} + {titleDesc(option)} +
+
+ ); + }; + + const actionButton = ( + ref: RefObject, + index: number, + label: string, + onClick: () => void, + danger?: boolean, + ) => ( + + ); + + return ( +
{ + if (e.target === e.currentTarget) { + playBackSound(); + onClose(); + } + }} + > +
+

+ Options +

+

+ {instanceName} +

+ + {loading ? ( +
+
+

+ Loading options... +

+
+ ) : error ? ( +
+

+ {error} +

+
+ {actionButton(cancelRef, optionOrder.length + 1, "OK", () => { + playBackSound(); + onClose(); + })} +
+
+ ) : schema ? ( + <> +
+ {sections.sections.map((section) => ( +
+

+ {section.title} +

+ {section.description && ( +

+ {section.description} +

+ )} + {section.options.map(renderOption)} +
+ ))} + {sections.general.length > 0 && ( +
+

+ General +

+ {sections.general.map(renderOption)} +
+ )} +
+ +
+ {actionButton( + resetRef, + optionOrder.length, + "Reset", + handleReset, + true, + )} + {actionButton(cancelRef, optionOrder.length + 1, "Cancel", () => { + playBackSound(); + onClose(); + })} + {actionButton( + saveRef, + optionOrder.length + 2, + "Save", + handleSave, + )} +
+ + ) : null} +
+
+ ); +} diff --git a/src/components/views/VersionsView.tsx b/src/components/views/VersionsView.tsx index 499f7ef..22cb4ac 100644 --- a/src/components/views/VersionsView.tsx +++ b/src/components/views/VersionsView.tsx @@ -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( null, ); + const [isOptionsModalOpen, setIsOptionsModalOpen] = useState(false); + const [optionsTarget, setOptionsTarget] = useState<{ + id: string; + name: string; + } | null>(null); + const [argsSchemas, setArgsSchemas] = useState>({}); const containerRef = useRef(null); const listRef = useRef(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 = {}; + 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 ) : null} + {argsSchemas[edition.instanceId] && ( + + )} {Array.isArray(edition.branches) && edition.branches.length > 0 && (