From 28df66cc36a457708d25e91ba89222a2348d9605 Mon Sep 17 00:00:00 2001
From: NoobNotFound <82730163+NoobNotFound@users.noreply.github.com>
Date: Fri, 15 May 2026 01:11:31 +0530
Subject: [PATCH] Add Minecraft game options editor
Introduce a full-featured game options editor for Minecraft instances. Adds CoreX.GameOptions (service, models, enum, and loader) to parse/options.txt, load lang entries from the version JAR, classify option types, and round-trip unknown keys on save. Implements a GameOptionsViewModel, GameOptionsDialog (XAML + code-behind) with data templates and an OptionTemplateSelector, and registers the service and ViewModel in DI. Also wires an "Edit Options" menu item into GamesPage to open the dialog.
---
.../GameOptions/IMinecraftOptionsService.cs | 34 ++
.../GameOptions/MinecraftOptionEntry.cs | 96 +++++
.../GameOptions/MinecraftOptionType.cs | 12 +
.../GameOptions/MinecraftOptionsService.cs | 381 ++++++++++++++++++
Emerald/App.xaml.cs | 5 +
Emerald/ViewModels/GameOptionsViewModel.cs | 143 +++++++
Emerald/Views/GameOptionsDialog.xaml | 313 ++++++++++++++
Emerald/Views/GameOptionsDialog.xaml.cs | 65 +++
Emerald/Views/GamesPage.xaml | 7 +
Emerald/Views/GamesPage.xaml.cs | 8 +
10 files changed, 1064 insertions(+)
create mode 100644 Emerald.CoreX/GameOptions/IMinecraftOptionsService.cs
create mode 100644 Emerald.CoreX/GameOptions/MinecraftOptionEntry.cs
create mode 100644 Emerald.CoreX/GameOptions/MinecraftOptionType.cs
create mode 100644 Emerald.CoreX/GameOptions/MinecraftOptionsService.cs
create mode 100644 Emerald/ViewModels/GameOptionsViewModel.cs
create mode 100644 Emerald/Views/GameOptionsDialog.xaml
create mode 100644 Emerald/Views/GameOptionsDialog.xaml.cs
diff --git a/Emerald.CoreX/GameOptions/IMinecraftOptionsService.cs b/Emerald.CoreX/GameOptions/IMinecraftOptionsService.cs
new file mode 100644
index 00000000..0e065403
--- /dev/null
+++ b/Emerald.CoreX/GameOptions/IMinecraftOptionsService.cs
@@ -0,0 +1,34 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Emerald.CoreX.GameOptions;
+
+public interface IMinecraftOptionsService
+{
+ ///
+ /// Parses options.txt and loads the lang file from the version JAR.
+ /// Returns an empty list if options.txt does not exist yet.
+ ///
+ Task LoadAsync(
+ Game game,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Writes entries back to options.txt, preserving any keys that were
+ /// not parsed into MinecraftOptionEntry objects.
+ ///
+ Task SaveAsync(
+ Game game,
+ IEnumerable entries,
+ CancellationToken cancellationToken = default);
+}
+
+public sealed class MinecraftOptionsLoadResult
+{
+ public IReadOnlyList Entries { get; init; } = [];
+ public bool OptionsFileExists { get; init; }
+ /// Full original key→value map, used to round-trip unknown keys on save.
+ public IReadOnlyDictionary OriginalRaw { get; init; }
+ = new Dictionary();
+}
diff --git a/Emerald.CoreX/GameOptions/MinecraftOptionEntry.cs b/Emerald.CoreX/GameOptions/MinecraftOptionEntry.cs
new file mode 100644
index 00000000..a60241ea
--- /dev/null
+++ b/Emerald.CoreX/GameOptions/MinecraftOptionEntry.cs
@@ -0,0 +1,96 @@
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using CommunityToolkit.Mvvm.ComponentModel;
+
+namespace Emerald.CoreX.GameOptions;
+
+public partial class MinecraftOptionEntry : ObservableObject
+{
+ public required string Key { get; init; }
+ public required string DisplayName { get; init; }
+ public required MinecraftOptionType Type { get; init; }
+ public string Category { get; init; } = "General";
+ public double SliderMin { get; init; }
+ public double SliderMax { get; init; } = 1.0;
+ public double SliderStep { get; init; } = 0.01;
+ public bool SliderIsInt { get; init; }
+ public string? SliderSuffix { get; init; }
+ public IReadOnlyList EnumOptions { get; init; } = [];
+
+ [ObservableProperty]
+ private string _rawValue = string.Empty;
+
+ partial void OnRawValueChanged(string value)
+ {
+ OnPropertyChanged(nameof(IsBooleanTrue));
+ OnPropertyChanged(nameof(SliderValue));
+ OnPropertyChanged(nameof(EnumRawValue));
+ OnPropertyChanged(nameof(SelectedEnumOption));
+ OnPropertyChanged(nameof(DisplayValueLabel));
+ }
+
+ // ── Boolean ──────────────────────────────────────────────────────────────
+ public bool IsBooleanTrue
+ {
+ get => RawValue == "true";
+ set => RawValue = value ? "true" : "false";
+ }
+
+ // ── Slider ───────────────────────────────────────────────────────────────
+ public double SliderValue
+ {
+ get => double.TryParse(RawValue, NumberStyles.Float,
+ CultureInfo.InvariantCulture, out var d) ? d : SliderMin;
+ set
+ {
+ var rounded = SliderIsInt ? Math.Round(value) : value;
+ RawValue = SliderIsInt
+ ? ((int)rounded).ToString(CultureInfo.InvariantCulture)
+ : rounded.ToString(CultureInfo.InvariantCulture);
+ OnPropertyChanged();
+ }
+ }
+
+ // ── Enum ─────────────────────────────────────────────────────────────────
+ public string? EnumRawValue
+ {
+ get => RawValue.Trim('"');
+ set
+ {
+ RawValue = value is null ? string.Empty : $"\"{value}\"";
+ OnPropertyChanged();
+ }
+ }
+
+ public MinecraftEnumOption? SelectedEnumOption
+ {
+ get => EnumOptions.FirstOrDefault(o => o.RawValue == EnumRawValue);
+ set
+ {
+ if (value is not null) EnumRawValue = value.RawValue;
+ OnPropertyChanged();
+ }
+ }
+
+ // ── Display label ─────────────────────────────────────────────────────────
+ public string DisplayValueLabel => Type switch
+ {
+ MinecraftOptionType.Boolean => IsBooleanTrue ? "On" : "Off",
+ MinecraftOptionType.SoundVolume => $"{(int)(SliderValue * 100)}%",
+ MinecraftOptionType.IntSlider => SliderSuffix is null
+ ? $"{(int)SliderValue}"
+ : $"{(int)SliderValue}{SliderSuffix}",
+ MinecraftOptionType.FloatSlider => SliderSuffix is null
+ ? $"{SliderValue:F2}"
+ : $"{SliderValue:F1}{SliderSuffix}",
+ MinecraftOptionType.Enum => EnumOptions
+ .FirstOrDefault(o => o.RawValue == EnumRawValue)?.DisplayLabel
+ ?? EnumRawValue
+ ?? RawValue,
+ MinecraftOptionType.KeyBind => RawValue.TrimStart("key.".ToCharArray()),
+ _ => RawValue
+ };
+}
+
+public sealed record MinecraftEnumOption(string RawValue, string DisplayLabel);
diff --git a/Emerald.CoreX/GameOptions/MinecraftOptionType.cs b/Emerald.CoreX/GameOptions/MinecraftOptionType.cs
new file mode 100644
index 00000000..5080cfef
--- /dev/null
+++ b/Emerald.CoreX/GameOptions/MinecraftOptionType.cs
@@ -0,0 +1,12 @@
+namespace Emerald.CoreX.GameOptions;
+
+public enum MinecraftOptionType
+{
+ Boolean,
+ IntSlider,
+ FloatSlider,
+ Enum,
+ KeyBind,
+ SoundVolume,
+ Skip
+}
diff --git a/Emerald.CoreX/GameOptions/MinecraftOptionsService.cs b/Emerald.CoreX/GameOptions/MinecraftOptionsService.cs
new file mode 100644
index 00000000..4fe05797
--- /dev/null
+++ b/Emerald.CoreX/GameOptions/MinecraftOptionsService.cs
@@ -0,0 +1,381 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.IO.Compression;
+using System.Linq;
+using System.Text;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+
+namespace Emerald.CoreX.GameOptions;
+
+public sealed class MinecraftOptionsService : IMinecraftOptionsService
+{
+ // ── Keys to hide from the editor ─────────────────────────────────────────
+ private static readonly HashSet SkippedKeys = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "version", "lang", "lastServer", "resourcePacks", "incompatibleResourcePacks",
+ "soundDevice", "tutorialStep", "startedCleanly", "realmsNotifications",
+ "joinedFirstServer", "hideServerAddress", "advanced_itemtooltips",
+ "pauseOnLostFocus", "log4jFixedProtocolVersion", "enableVsync",
+ "glDebugVerbosity", "skipMultiplayerWarning", "skipRealmsWarning",
+ "hideMatchedNames", "chatLinks", "chatLinksPrompt"
+ };
+
+ // ── Known slider ranges ───────────────────────────────────────────────────
+ private static readonly Dictionary
+ SliderMeta = new(StringComparer.OrdinalIgnoreCase)
+ {
+ ["renderDistance"] = (2, 32, true, 1, " chunks"),
+ ["simulationDistance"] = (5, 32, true, 1, " chunks"),
+ ["maxFps"] = (10, 260, true, 1, " fps"),
+ ["fov"] = (30, 110, false, 1, "°"),
+ ["gamma"] = (0, 1, false, 0.01, null),
+ ["fovEffectScale"] = (0, 1, false, 0.01, null),
+ ["mouseSensitivity"] = (0, 1, false, 0.01, null),
+ ["mipmapLevels"] = (0, 4, true, 1, null),
+ ["biomeBlendRadius"] = (0, 7, true, 1, null),
+ ["screenEffectScale"] = (0, 1, false, 0.01, null),
+ ["chatOpacity"] = (0, 1, false, 0.01, null),
+ ["chatLineSpacing"] = (0, 1, false, 0.01, null),
+ ["chatDelay"] = (0, 6, false, 0.1, "s"),
+ ["chatWidth"] = (0, 1, false, 0.01, null),
+ ["chatHeightFocused"] = (0, 1, false, 0.01, null),
+ ["chatHeightUnfocused"] = (0, 1, false, 0.01, null),
+ ["chatScale"] = (0, 1, false, 0.01, null),
+ ["textBackgroundOpacity"] = (0, 1, false, 0.01, null),
+ ["notificationDisplayTime"] = (0.5, 5, false, 0.5, "s"),
+ ["menuBackgroundBlurriness"] = (0, 10, true, 1, null),
+ ["entityDistanceScaling"] = (0.5, 5, false, 0.1, "×"),
+ };
+
+ // ── Category heuristics ───────────────────────────────────────────────────
+ private static readonly Dictionary CategoryMap =
+ new(StringComparer.OrdinalIgnoreCase)
+ {
+ ["renderDistance"] = "Graphics",
+ ["simulationDistance"] = "Graphics",
+ ["maxFps"] = "Graphics",
+ ["fov"] = "Graphics",
+ ["gamma"] = "Graphics",
+ ["graphicsMode"] = "Graphics",
+ ["ao"] = "Graphics",
+ ["fancyGraphics"] = "Graphics",
+ ["graphics"] = "Graphics",
+ ["particles"] = "Graphics",
+ ["entityShadows"] = "Graphics",
+ ["renderClouds"] = "Graphics",
+ ["mipmapLevels"] = "Graphics",
+ ["vsync"] = "Graphics",
+ ["entityDistanceScaling"] = "Graphics",
+ ["chunkBuilderType"] = "Graphics",
+ ["prioritizeChunkUpdates"] = "Graphics",
+ ["fullscreen"] = "Display",
+ ["guiScale"] = "Display",
+ ["fullscreenResolution"] = "Display",
+ ["narrator"] = "Accessibility",
+ ["subtitles"] = "Accessibility",
+ ["highContrast"] = "Accessibility",
+ ["darkMojangStudiosBackground"] = "Accessibility",
+ ["menuBackgroundBlurriness"] = "Accessibility",
+ ["mouseSensitivity"] = "Mouse & Controls",
+ ["invertYMouse"] = "Mouse & Controls",
+ ["discreteMouseScroll"] = "Mouse & Controls",
+ ["smoothCamera"] = "Mouse & Controls",
+ ["touchscreen"] = "Mouse & Controls",
+ ["chatVisibility"] = "Chat",
+ ["chatColors"] = "Chat",
+ ["chatOpacity"] = "Chat",
+ ["chatLineSpacing"] = "Chat",
+ ["chatScale"] = "Chat",
+ ["chatWidth"] = "Chat",
+ ["chatHeightFocused"] = "Chat",
+ ["chatHeightUnfocused"] = "Chat",
+ ["chatDelay"] = "Chat",
+ ["textBackgroundOpacity"] = "Chat",
+ };
+
+ private readonly ILogger _logger;
+
+ public MinecraftOptionsService(ILogger logger)
+ => _logger = logger;
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Public API
+ // ─────────────────────────────────────────────────────────────────────────
+
+ public async Task LoadAsync(
+ Game game, CancellationToken cancellationToken = default)
+ {
+ var optionsPath = Path.Combine(game.Path.BasePath, "options.txt");
+ if (!File.Exists(optionsPath))
+ {
+ _logger.LogInformation(
+ "options.txt not found at {Path} — game hasn't been launched yet.", optionsPath);
+ return new MinecraftOptionsLoadResult { OptionsFileExists = false };
+ }
+
+ var raw = await ParseOptionsFileAsync(optionsPath, cancellationToken);
+ var lang = await TryLoadLangAsync(game, cancellationToken);
+ var entries = BuildEntries(raw, lang);
+
+ _logger.LogInformation(
+ "Loaded {Count} option entries for {Name}.", entries.Count, game.Version.DisplayName);
+
+ return new MinecraftOptionsLoadResult
+ {
+ Entries = entries,
+ OptionsFileExists = true,
+ OriginalRaw = raw
+ };
+ }
+
+ public async Task SaveAsync(
+ Game game, IEnumerable entries,
+ CancellationToken cancellationToken = default)
+ {
+ var optionsPath = Path.Combine(game.Path.BasePath, "options.txt");
+
+ // Re-read the file so we preserve lines we never showed in the UI.
+ var original = File.Exists(optionsPath)
+ ? await ParseOptionsFileAsync(optionsPath, cancellationToken)
+ : new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var e in entries)
+ original[e.Key] = e.RawValue;
+
+ var lines = original.Select(kvp => $"{kvp.Key}:{kvp.Value}");
+ await File.WriteAllLinesAsync(optionsPath, lines, cancellationToken);
+ _logger.LogInformation("Saved options.txt for {Name}.", game.Version.DisplayName);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Parsing
+ // ─────────────────────────────────────────────────────────────────────────
+
+ private static async Task> ParseOptionsFileAsync(
+ string path, CancellationToken ct)
+ {
+ var map = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var line in await File.ReadAllLinesAsync(path, ct))
+ {
+ var idx = line.IndexOf(':');
+ if (idx < 1) continue;
+ var key = line[..idx].Trim();
+ var val = line[(idx + 1)..]; // preserve value exactly
+ if (!string.IsNullOrEmpty(key))
+ map[key] = val;
+ }
+ return map;
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Lang loading
+ // ─────────────────────────────────────────────────────────────────────────
+
+ private async Task> TryLoadLangAsync(Game game, CancellationToken ct)
+ {
+ try
+ {
+ // Use the *vanilla* JAR (BasedOn), even for modded instances.
+ var jarPath = Path.Combine(
+ game.Path.Versions,
+ game.Version.BasedOn,
+ $"{game.Version.BasedOn}.jar");
+
+ if (!File.Exists(jarPath))
+ {
+ _logger.LogDebug("JAR not found at {Path}; will prettify keys instead.", jarPath);
+ return [];
+ }
+
+ return await LoadLangFromJarAsync(jarPath, ct);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to load lang for {Name}.", game.Version.DisplayName);
+ return [];
+ }
+ }
+
+ private static async Task> LoadLangFromJarAsync(
+ string jarPath, CancellationToken ct)
+ {
+ using var jar = ZipFile.OpenRead(jarPath);
+
+ // 1.13+ JSON
+ var jsonEntry = jar.GetEntry("assets/minecraft/lang/en_us.json");
+ if (jsonEntry is not null)
+ {
+ await using var stream = jsonEntry.Open();
+ var doc = await JsonDocument.ParseAsync(stream, cancellationToken: ct);
+ return doc.RootElement
+ .EnumerateObject()
+ .ToDictionary(p => p.Name, p => p.Value.GetString() ?? string.Empty,
+ StringComparer.OrdinalIgnoreCase);
+ }
+
+ // Pre-1.13 .lang
+ var legacyEntry = jar.GetEntry("assets/minecraft/lang/en_US.lang");
+ if (legacyEntry is null) return [];
+
+ var lang = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ await using var legacyStream = legacyEntry.Open();
+ using var reader = new StreamReader(legacyStream);
+ string? line;
+ while ((line = await reader.ReadLineAsync(ct)) is not null)
+ {
+ var eq = line.IndexOf('=');
+ if (eq > 0) lang[line[..eq].Trim()] = line[(eq + 1)..].Trim();
+ }
+ return lang;
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Entry building
+ // ─────────────────────────────────────────────────────────────────────────
+
+ private List BuildEntries(
+ Dictionary raw,
+ Dictionary lang)
+ {
+ var list = new List();
+
+ foreach (var (key, rawValue) in raw)
+ {
+ var type = ClassifyOption(key, rawValue);
+ if (type == MinecraftOptionType.Skip) continue;
+
+ var (min, max, isInt, step, suffix) = GetSliderMeta(key, type);
+
+ list.Add(new MinecraftOptionEntry
+ {
+ Key = key,
+ DisplayName = ResolveDisplayName(key, lang),
+ Category = ResolveCategory(key),
+ Type = type,
+ RawValue = rawValue,
+ SliderMin = min,
+ SliderMax = max,
+ SliderIsInt = isInt,
+ SliderStep = step,
+ SliderSuffix = suffix,
+ EnumOptions = type == MinecraftOptionType.Enum
+ ? ResolveEnumOptions(key, rawValue, lang)
+ : []
+ });
+ }
+
+ return list
+ .OrderBy(e => e.Category)
+ .ThenBy(e => e.DisplayName, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Classification
+ // ─────────────────────────────────────────────────────────────────────────
+
+ private static MinecraftOptionType ClassifyOption(string key, string rawValue)
+ {
+ if (SkippedKeys.Contains(key)) return MinecraftOptionType.Skip;
+ if (key.StartsWith("key_", StringComparison.OrdinalIgnoreCase)) return MinecraftOptionType.KeyBind;
+ if (key.StartsWith("soundCategory_", StringComparison.OrdinalIgnoreCase)) return MinecraftOptionType.SoundVolume;
+
+ if (rawValue is "true" or "false") return MinecraftOptionType.Boolean;
+ if (rawValue.StartsWith('"')) return MinecraftOptionType.Enum;
+
+ if (!double.TryParse(rawValue, NumberStyles.Float, CultureInfo.InvariantCulture, out var d))
+ return MinecraftOptionType.Enum; // unrecognised string → treat as enum
+
+ // Known int sliders
+ if (SliderMeta.TryGetValue(key, out var meta) && meta.IsInt)
+ return MinecraftOptionType.IntSlider;
+
+ // Heuristic: no decimal point and value looks like a small int → int slider
+ if (!rawValue.Contains('.') && d >= 0 && d <= 512)
+ return MinecraftOptionType.IntSlider;
+
+ return MinecraftOptionType.FloatSlider;
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Helpers
+ // ─────────────────────────────────────────────────────────────────────────
+
+ private static string ResolveDisplayName(string key, Dictionary lang)
+ {
+ if (key.StartsWith("soundCategory_", StringComparison.OrdinalIgnoreCase))
+ {
+ var cat = key["soundCategory_".Length..];
+ return lang.TryGetValue($"soundCategory.{cat}", out var n) ? n : PrettifyKey(cat);
+ }
+ return lang.TryGetValue($"options.{key}", out var name) ? name : PrettifyKey(key);
+ }
+
+ private static string ResolveCategory(string key)
+ {
+ if (key.StartsWith("key_", StringComparison.OrdinalIgnoreCase)) return "Key Bindings";
+ if (key.StartsWith("soundCategory_", StringComparison.OrdinalIgnoreCase)) return "Sound";
+ return CategoryMap.TryGetValue(key, out var cat) ? cat : "General";
+ }
+
+ private static (double Min, double Max, bool IsInt, double Step, string? Suffix)
+ GetSliderMeta(string key, MinecraftOptionType type)
+ {
+ if (type == MinecraftOptionType.SoundVolume) return (0, 1, false, 0.01, null);
+ if (SliderMeta.TryGetValue(key, out var m)) return m;
+ return type == MinecraftOptionType.IntSlider
+ ? (0, 100, true, 1, null)
+ : (0, 1, false, 0.01, null);
+ }
+
+ private static IReadOnlyList ResolveEnumOptions(
+ string key, string rawValue, Dictionary lang)
+ {
+ var opts = new List();
+ var pfx = $"options.{key}.";
+
+ foreach (var kvp in lang)
+ {
+ if (kvp.Key.StartsWith(pfx, StringComparison.OrdinalIgnoreCase))
+ opts.Add(new MinecraftEnumOption(kvp.Key[pfx.Length..], kvp.Value));
+ }
+
+ // Fall back to shared labels (options.on / options.off)
+ if (opts.Count == 0)
+ {
+ var shared = new[] { "true", "false", "on", "off" };
+ foreach (var s in shared)
+ if (lang.TryGetValue($"options.{s}", out var lbl))
+ opts.Add(new MinecraftEnumOption(s, lbl));
+ }
+
+ // Always include the current raw value so the ComboBox has something to select.
+ var bare = rawValue.Trim('"');
+ if (!string.IsNullOrEmpty(bare) && opts.All(o => o.RawValue != bare))
+ opts.Insert(0, new MinecraftEnumOption(bare, PrettifyKey(bare)));
+
+ return opts;
+ }
+
+ private static string PrettifyKey(string key)
+ {
+ // "key_key.forward" → "Forward"
+ if (key.StartsWith("key_key.", StringComparison.OrdinalIgnoreCase))
+ key = key["key_key.".Length..];
+
+ var sb = new StringBuilder();
+ for (var i = 0; i < key.Length; i++)
+ {
+ var c = key[i];
+ if (c is '_' or '.') { sb.Append(' '); continue; }
+ if (i > 0 && char.IsUpper(c) && !char.IsUpper(key[i - 1])) sb.Append(' ');
+ sb.Append(i == 0 ? char.ToUpperInvariant(c) : c);
+ }
+ return sb.ToString().Trim();
+ }
+}
diff --git a/Emerald/App.xaml.cs b/Emerald/App.xaml.cs
index 3cafe41d..db0e8e73 100644
--- a/Emerald/App.xaml.cs
+++ b/Emerald/App.xaml.cs
@@ -148,6 +148,10 @@ private void ConfigureCoreServices(IServiceCollection services)
services.AddTransient();
services.AddTransient();
+
+ // Options.txt
+ services.AddTransient();
}
private void ConfigureStoreServices(IServiceCollection services)
@@ -204,6 +208,7 @@ private void ConfigureUiServices(IServiceCollection services)
services.AddSingleton();
services.AddTransient();
services.AddTransient();
+ services.AddTransient();
}
#endregion
diff --git a/Emerald/ViewModels/GameOptionsViewModel.cs b/Emerald/ViewModels/GameOptionsViewModel.cs
new file mode 100644
index 00000000..7207a445
--- /dev/null
+++ b/Emerald/ViewModels/GameOptionsViewModel.cs
@@ -0,0 +1,143 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
+using System.Threading.Tasks;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using Emerald.CoreX;
+using Emerald.CoreX.GameOptions;
+using Emerald.CoreX.Notifications;
+using Microsoft.Extensions.Logging;
+
+namespace Emerald.ViewModels;
+
+public partial class GameOptionsViewModel : ObservableObject
+{
+ private readonly IMinecraftOptionsService _service;
+ private readonly INotificationService _notify;
+ private readonly ILogger _logger;
+
+ private Game? _game;
+ private IReadOnlyList _allEntries = [];
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(HasOptions))]
+ private bool _isLoading;
+
+ [ObservableProperty]
+ private bool _optionsFileExists = true;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(ShowEmptySearch))]
+ private string _searchQuery = string.Empty;
+
+ [ObservableProperty]
+ private string _selectedCategory = "All";
+
+ [ObservableProperty]
+ private string _gameDisplayName = string.Empty;
+
+ public ObservableCollection FilteredEntries { get; } = [];
+ public ObservableCollection Categories { get; } = [];
+
+ public bool HasOptions => !IsLoading && FilteredEntries.Count > 0 && OptionsFileExists;
+ public bool ShowEmptySearch => !IsLoading && FilteredEntries.Count == 0 && OptionsFileExists
+ && !string.IsNullOrWhiteSpace(SearchQuery);
+
+ public GameOptionsViewModel(
+ IMinecraftOptionsService service,
+ INotificationService notify,
+ ILogger logger)
+ {
+ _service = service;
+ _notify = notify;
+ _logger = logger;
+ }
+
+ [RelayCommand]
+ public async Task LoadAsync(Game game)
+ {
+ _game = game;
+ GameDisplayName = game.Version.DisplayName;
+ IsLoading = true;
+
+ try
+ {
+ var result = await _service.LoadAsync(game);
+ OptionsFileExists = result.OptionsFileExists;
+ _allEntries = result.Entries;
+
+ RebuildCategories();
+ ApplyFilter();
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to load options for {Name}.", game.Version.DisplayName);
+ _notify.Error("OptionsLoadError",
+ $"Failed to load options for {game.Version.DisplayName}.", ex: ex);
+ }
+ finally
+ {
+ IsLoading = false;
+ }
+ }
+
+ [RelayCommand]
+ public async Task SaveAsync()
+ {
+ if (_game is null || _allEntries.Count == 0) return;
+
+ try
+ {
+ await _service.SaveAsync(_game, _allEntries);
+ _notify.Info("OptionsSaved", $"Saved options for {_game.Version.DisplayName}.");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to save options for {Name}.", _game.Version.DisplayName);
+ _notify.Error("OptionsSaveError",
+ $"Failed to save options for {_game.Version.DisplayName}.", ex: ex);
+ }
+ }
+
+ partial void OnSearchQueryChanged(string value) => ApplyFilter();
+ partial void OnSelectedCategoryChanged(string value) => ApplyFilter();
+
+ private void RebuildCategories()
+ {
+ Categories.Clear();
+ Categories.Add("All");
+ foreach (var cat in _allEntries
+ .Select(e => e.Category)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .OrderBy(c => c, StringComparer.OrdinalIgnoreCase))
+ {
+ Categories.Add(cat);
+ }
+ }
+
+ private void ApplyFilter()
+ {
+ FilteredEntries.Clear();
+
+ var filtered = _allEntries.AsEnumerable();
+
+ if (SelectedCategory != "All")
+ filtered = filtered.Where(e => e.Category == SelectedCategory);
+
+ if (!string.IsNullOrWhiteSpace(SearchQuery))
+ {
+ var q = SearchQuery.Trim();
+ filtered = filtered.Where(e =>
+ e.DisplayName.Contains(q, StringComparison.OrdinalIgnoreCase) ||
+ e.Key.Contains(q, StringComparison.OrdinalIgnoreCase));
+ }
+
+ foreach (var entry in filtered)
+ FilteredEntries.Add(entry);
+
+ OnPropertyChanged(nameof(HasOptions));
+ OnPropertyChanged(nameof(ShowEmptySearch));
+ }
+}
diff --git a/Emerald/Views/GameOptionsDialog.xaml b/Emerald/Views/GameOptionsDialog.xaml
new file mode 100644
index 00000000..e43acb44
--- /dev/null
+++ b/Emerald/Views/GameOptionsDialog.xaml
@@ -0,0 +1,313 @@
+
+
+
+ 4096
+ 2048
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Emerald/Views/GameOptionsDialog.xaml.cs b/Emerald/Views/GameOptionsDialog.xaml.cs
new file mode 100644
index 00000000..e21dae35
--- /dev/null
+++ b/Emerald/Views/GameOptionsDialog.xaml.cs
@@ -0,0 +1,65 @@
+using CommunityToolkit.Mvvm.DependencyInjection;
+using Emerald.CoreX;
+using Emerald.CoreX.GameOptions;
+using Emerald.Helpers;
+using Emerald.Services;
+using Emerald.ViewModels;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+
+namespace Emerald.Views;
+
+public sealed partial class GameOptionsDialog : ContentDialog
+{
+ public GameOptionsViewModel ViewModel { get; }
+
+ public GameOptionsDialog(Game game)
+ {
+ ViewModel = Ioc.Default.GetService()!;
+ DataContext = ViewModel;
+ InitializeComponent();
+ Style = Application.Current.Resources["DefaultContentDialogStyle"] as Style;
+ this.StretchToWindow();
+ RequestedTheme = (ElementTheme)Ioc.Default
+ .GetService()!.Settings.App.Appearance.Theme;
+
+ // Fire-and-forget load; the ViewModel shows a spinner while loading.
+ _ = ViewModel.LoadCommand.ExecuteAsync(game);
+ }
+
+ private void Close_Click(object sender, RoutedEventArgs e) => Hide();
+
+ private async void Save_Click(object sender, RoutedEventArgs e)
+ {
+ await ViewModel.SaveCommand.ExecuteAsync(null);
+ Hide();
+ }
+}
+
+/// Selects the correct DataTemplate based on .
+public sealed class OptionTemplateSelector : DataTemplateSelector
+{
+ public DataTemplate? BooleanTemplate { get; set; }
+ public DataTemplate? SliderTemplate { get; set; }
+ public DataTemplate? EnumTemplate { get; set; }
+ public DataTemplate? SoundTemplate { get; set; }
+ public DataTemplate? KeyBindTemplate { get; set; }
+
+ protected override DataTemplate SelectTemplateCore(object item)
+ {
+ if (item is MinecraftOptionEntry e)
+ {
+ return e.Type switch
+ {
+ MinecraftOptionType.Boolean => BooleanTemplate,
+ MinecraftOptionType.IntSlider => SliderTemplate,
+ MinecraftOptionType.FloatSlider => SliderTemplate,
+ MinecraftOptionType.Enum => EnumTemplate,
+ MinecraftOptionType.SoundVolume => SoundTemplate,
+ MinecraftOptionType.KeyBind => KeyBindTemplate,
+ _ => BooleanTemplate
+ } ?? base.SelectTemplateCore(item);
+ }
+ return base.SelectTemplateCore(item);
+ }
+}
diff --git a/Emerald/Views/GamesPage.xaml b/Emerald/Views/GamesPage.xaml
index a670a40e..9a2f13ce 100644
--- a/Emerald/Views/GamesPage.xaml
+++ b/Emerald/Views/GamesPage.xaml
@@ -112,6 +112,13 @@
Foreground="{ThemeResource SystemFillColorCriticalBrush}"
Icon="{helpers:FontIcon Glyph=}"
Text="Remove with Files" />
+
+
diff --git a/Emerald/Views/GamesPage.xaml.cs b/Emerald/Views/GamesPage.xaml.cs
index dec632d7..0efec768 100644
--- a/Emerald/Views/GamesPage.xaml.cs
+++ b/Emerald/Views/GamesPage.xaml.cs
@@ -217,4 +217,12 @@ private void NavigateToAccounts()
Frame?.Navigate(typeof(AccountsPage), null, new EntranceNavigationTransitionInfo());
}
+
+ private async void EditOptions_Click(object sender, RoutedEventArgs e)
+ {
+ if ((sender as FrameworkElement)?.Tag is not Game game) return;
+
+ var dialog = new GameOptionsDialog(game) { XamlRoot = XamlRoot };
+ await dialog.ShowAsync();
+ }
}