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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +