using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.Json; using Silk.NET.Input; namespace AcDream.UI.Abstractions.Input; /// /// Mutable collection of s. Owns lookup by chord /// (for the dispatcher) and lookup by action (for the Settings UI). /// Insertion-order preserved — first-match-wins on lookup, so a user /// can add a custom binding ahead of a default and have it take effect /// without removing the default. /// /// /// K.1c: now returns the full retail-faithful /// preset (byte-precise to retail-default.keymap.txt); /// + persist the /// table to JSON under with version-based /// migration. is preserved as a /// reference for tests pinning the older WASD-only behavior, but is no /// longer the GameWindow startup source. /// /// public sealed class KeyBindings { private const int CurrentSchemaVersion = 1; private readonly List _bindings = new(); /// All bindings in insertion order. public IReadOnlyList All => _bindings; /// Append a binding. Duplicates are allowed; first match wins. public void Add(Binding b) => _bindings.Add(b); /// Remove the first occurrence of the given binding (structural equality). public bool Remove(Binding b) => _bindings.Remove(b); /// Drop every binding. public void Clear() => _bindings.Clear(); /// /// First binding (in insertion order) whose chord matches AND whose /// activation type matches the requested phase. Null if none. /// public Binding? Find(KeyChord chord, ActivationType activation) { for (int i = 0; i < _bindings.Count; i++) { var b = _bindings[i]; if (b.Chord == chord && b.Activation == activation) return b; } return null; } /// /// All bindings for a given action (one action can have multiple keys /// — retail's W and Up-arrow both → MovementForward). /// public IEnumerable ForAction(InputAction action) { for (int i = 0; i < _bindings.Count; i++) if (_bindings[i].Action == action) yield return _bindings[i]; } /// /// Acdream-current keymap (W=fwd, S=back, A/D=turn, Z/X=strafe, /// Shift=run, Tab=toggle player↔fly mode, F1=DebugPanel, F2=collision /// wires, F3=dump, F7=cycle time, F8/F9=sensitivity, F10=cycle /// weather, F=fly toggle, Space=jump). Preserved as a regression /// anchor for tests that pin the older modifier-blind WASD layout — /// NOT the GameWindow startup source after K.1c. /// public static KeyBindings AcdreamCurrentDefaults() { var b = new KeyBindings(); // Movement (current acdream — wrong for retail but preserved for // legacy tests). Each movement key gets BOTH a bare-mods chord // and a Shift-mods chord so the per-frame IsActionHeld query // stays true while the user holds Shift (the acdream-current // run-modifier). b.Add(new(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward)); b.Add(new(new KeyChord(Key.W, ModifierMask.Shift), InputAction.MovementForward)); b.Add(new(new KeyChord(Key.S, ModifierMask.None), InputAction.MovementBackup)); b.Add(new(new KeyChord(Key.S, ModifierMask.Shift), InputAction.MovementBackup)); b.Add(new(new KeyChord(Key.A, ModifierMask.None), InputAction.MovementTurnLeft)); b.Add(new(new KeyChord(Key.A, ModifierMask.Shift), InputAction.MovementTurnLeft)); b.Add(new(new KeyChord(Key.D, ModifierMask.None), InputAction.MovementTurnRight)); b.Add(new(new KeyChord(Key.D, ModifierMask.Shift), InputAction.MovementTurnRight)); b.Add(new(new KeyChord(Key.Z, ModifierMask.None), InputAction.MovementStrafeLeft)); b.Add(new(new KeyChord(Key.Z, ModifierMask.Shift), InputAction.MovementStrafeLeft)); b.Add(new(new KeyChord(Key.X, ModifierMask.None), InputAction.MovementStrafeRight)); b.Add(new(new KeyChord(Key.X, ModifierMask.Shift), InputAction.MovementStrafeRight)); b.Add(new(new KeyChord(Key.ShiftLeft, ModifierMask.Shift), InputAction.MovementRunLock, ActivationType.Hold)); b.Add(new(new KeyChord(Key.ShiftRight, ModifierMask.Shift), InputAction.MovementRunLock, ActivationType.Hold)); b.Add(new(new KeyChord(Key.Space, ModifierMask.None), InputAction.MovementJump)); b.Add(new(new KeyChord(Key.Space, ModifierMask.Shift), InputAction.MovementJump)); b.Add(new(new KeyChord(Key.ControlLeft, ModifierMask.Ctrl), InputAction.AcdreamFlyDown, ActivationType.Hold)); b.Add(new(new KeyChord(Key.ControlRight, ModifierMask.Ctrl), InputAction.AcdreamFlyDown, ActivationType.Hold)); b.Add(new(new KeyChord(Key.F1, ModifierMask.None), InputAction.AcdreamToggleDebugPanel)); b.Add(new(new KeyChord(Key.F2, ModifierMask.None), InputAction.AcdreamToggleCollisionWires)); b.Add(new(new KeyChord(Key.F3, ModifierMask.None), InputAction.AcdreamDumpNearby)); b.Add(new(new KeyChord(Key.F7, ModifierMask.None), InputAction.AcdreamCycleTimeOfDay)); b.Add(new(new KeyChord(Key.F8, ModifierMask.None), InputAction.AcdreamSensitivityDown)); b.Add(new(new KeyChord(Key.F9, ModifierMask.None), InputAction.AcdreamSensitivityUp)); b.Add(new(new KeyChord(Key.F10, ModifierMask.None), InputAction.AcdreamCycleWeather)); b.Add(new(new KeyChord(Key.F, ModifierMask.None), InputAction.AcdreamToggleFlyMode)); b.Add(new(new KeyChord(Key.Tab, ModifierMask.None), InputAction.AcdreamTogglePlayerMode)); b.Add(new(new KeyChord(Key.Escape, ModifierMask.None), InputAction.EscapeKey)); b.Add(new( new KeyChord(InputDispatcher.MouseButtonToKey(Silk.NET.Input.MouseButton.Right), ModifierMask.None, Device: 1), InputAction.AcdreamRmbOrbitHold, ActivationType.Hold)); return b; } /// /// Canonical AC retail-default keymap, byte-precise to /// docs/research/named-retail/retail-default.keymap.txt (the /// user's Throne of Destiny test.keymap). All bindings /// categorized by the Bindings [...] sections of the keymap /// text. Default movement mode = RUN; hold LShift toggles to walk. /// Note: K.1c puts the bindings in the table; the dispatcher / /// MovementInput wiring that interprets MovementWalkMode + the /// "default-run" semantics doesn't change in K.1c — that's K.2. /// public static KeyBindings RetailDefaults() { var b = new KeyBindings(); // ── MovementCommands ─────────────────────────────────── b.Add(new(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward)); b.Add(new(new KeyChord(Key.Up, ModifierMask.None), InputAction.MovementForward)); b.Add(new(new KeyChord(Key.X, ModifierMask.None), InputAction.MovementBackup)); b.Add(new(new KeyChord(Key.Down, ModifierMask.None), InputAction.MovementBackup)); b.Add(new(new KeyChord(Key.A, ModifierMask.None), InputAction.MovementTurnLeft)); b.Add(new(new KeyChord(Key.Left, ModifierMask.None), InputAction.MovementTurnLeft)); b.Add(new(new KeyChord(Key.D, ModifierMask.None), InputAction.MovementTurnRight)); b.Add(new(new KeyChord(Key.Right, ModifierMask.None), InputAction.MovementTurnRight)); b.Add(new(new KeyChord(Key.Z, ModifierMask.None), InputAction.MovementStrafeLeft)); b.Add(new(new KeyChord(Key.A, ModifierMask.Alt), InputAction.MovementStrafeLeft)); b.Add(new(new KeyChord(Key.Left, ModifierMask.Alt), InputAction.MovementStrafeLeft)); b.Add(new(new KeyChord(Key.C, ModifierMask.None), InputAction.MovementStrafeRight)); b.Add(new(new KeyChord(Key.D, ModifierMask.Alt), InputAction.MovementStrafeRight)); b.Add(new(new KeyChord(Key.Right, ModifierMask.Alt), InputAction.MovementStrafeRight)); // Walk-mode modifier — Hold so a subscriber can latch state on // press and unlatch on release. K-fix1 (2026-04-26): the chord // modifier MUST be Shift, not None — when LShift/RShift is the // primary key the OS keyboard reports CurrentModifiers=Shift // alongside the key-down. Bind both left + right shift to match. // This is the same pattern AcdreamCurrentDefaults uses for its // Shift→RunLock binding (see lines 98-99 above). b.Add(new(new KeyChord(Key.ShiftLeft, ModifierMask.Shift), InputAction.MovementWalkMode, ActivationType.Hold)); b.Add(new(new KeyChord(Key.ShiftRight, ModifierMask.Shift), InputAction.MovementWalkMode, ActivationType.Hold)); b.Add(new(new KeyChord(Key.Q, ModifierMask.None), InputAction.MovementRunLock)); b.Add(new(new KeyChord(Key.S, ModifierMask.None), InputAction.MovementStop)); b.Add(new(new KeyChord(Key.Y, ModifierMask.None), InputAction.Ready)); b.Add(new(new KeyChord(Key.G, ModifierMask.None), InputAction.Sitting)); b.Add(new(new KeyChord(Key.H, ModifierMask.None), InputAction.Crouch)); b.Add(new(new KeyChord(Key.B, ModifierMask.None), InputAction.Sleeping)); b.Add(new(new KeyChord(Key.Space, ModifierMask.None), InputAction.MovementJump)); // ── ItemSelectionCommands ────────────────────────────── b.Add(new(new KeyChord(Key.F, ModifierMask.None), InputAction.SelectionPickUp)); b.Add(new(new KeyChord(Key.T, ModifierMask.None), InputAction.SelectionSplitStack)); b.Add(new(new KeyChord(Key.P, ModifierMask.None), InputAction.SelectionPreviousSelection)); b.Add(new(new KeyChord(Key.Backspace, ModifierMask.None), InputAction.SelectionClosestCompassItem)); b.Add(new(new KeyChord(Key.Minus, ModifierMask.None), InputAction.SelectionPreviousCompassItem)); b.Add(new(new KeyChord(Key.Equal, ModifierMask.None), InputAction.SelectionNextCompassItem)); b.Add(new(new KeyChord(Key.BackSlash, ModifierMask.None), InputAction.SelectionClosestItem)); b.Add(new(new KeyChord(Key.LeftBracket, ModifierMask.None), InputAction.SelectionPreviousItem)); b.Add(new(new KeyChord(Key.RightBracket, ModifierMask.None), InputAction.SelectionNextItem)); b.Add(new(new KeyChord(Key.Apostrophe, ModifierMask.None), InputAction.SelectionClosestMonster)); b.Add(new(new KeyChord(Key.L, ModifierMask.None), InputAction.SelectionPreviousMonster)); // Silk.NET names this Semicolon (lowercase c), not SemiColon. b.Add(new(new KeyChord(Key.Semicolon, ModifierMask.None), InputAction.SelectionNextMonster)); b.Add(new(new KeyChord(Key.Home, ModifierMask.None), InputAction.SelectionLastAttacker)); b.Add(new(new KeyChord(Key.Slash, ModifierMask.None), InputAction.SelectionClosestPlayer)); b.Add(new(new KeyChord(Key.Comma, ModifierMask.None), InputAction.SelectionPreviousPlayer)); b.Add(new(new KeyChord(Key.Period, ModifierMask.None), InputAction.SelectionNextPlayer)); b.Add(new(new KeyChord(Key.N, ModifierMask.None), InputAction.SelectionPreviousFellow)); b.Add(new(new KeyChord(Key.M, ModifierMask.None), InputAction.SelectionNextFellow)); // ── UICommands ───────────────────────────────────────── b.Add(new(new KeyChord(Key.E, ModifierMask.None), InputAction.SelectionExamine)); b.Add(new(new KeyChord(Key.KeypadMultiply, ModifierMask.None), InputAction.CaptureScreenshot)); b.Add(new(new KeyChord(Key.F1, ModifierMask.None), InputAction.ToggleHelp)); b.Add(new(new KeyChord(Key.F1, ModifierMask.Shift | ModifierMask.Ctrl), InputAction.TogglePluginManager)); b.Add(new(new KeyChord(Key.F3, ModifierMask.None), InputAction.ToggleAllegiancePanel)); b.Add(new(new KeyChord(Key.F4, ModifierMask.None), InputAction.ToggleFellowshipPanel)); b.Add(new(new KeyChord(Key.F5, ModifierMask.None), InputAction.ToggleSpellbookPanel)); b.Add(new(new KeyChord(Key.F6, ModifierMask.None), InputAction.ToggleSpellComponentsPanel)); b.Add(new(new KeyChord(Key.F8, ModifierMask.None), InputAction.ToggleAttributesPanel)); b.Add(new(new KeyChord(Key.F9, ModifierMask.None), InputAction.ToggleSkillsPanel)); b.Add(new(new KeyChord(Key.F10, ModifierMask.None), InputAction.ToggleWorldPanel)); b.Add(new(new KeyChord(Key.F11, ModifierMask.None), InputAction.ToggleOptionsPanel)); b.Add(new(new KeyChord(Key.F12, ModifierMask.None), InputAction.ToggleInventoryPanel)); b.Add(new(new KeyChord(Key.Number1, ModifierMask.Alt), InputAction.ToggleFloatingChatWindow1)); b.Add(new(new KeyChord(Key.Number2, ModifierMask.Alt), InputAction.ToggleFloatingChatWindow2)); b.Add(new(new KeyChord(Key.Number3, ModifierMask.Alt), InputAction.ToggleFloatingChatWindow3)); b.Add(new(new KeyChord(Key.Number4, ModifierMask.Alt), InputAction.ToggleFloatingChatWindow4)); b.Add(new(new KeyChord(Key.R, ModifierMask.None), InputAction.UseSelected)); b.Add(new(new KeyChord(Key.Escape, ModifierMask.None), InputAction.EscapeKey)); b.Add(new(new KeyChord(Key.Escape, ModifierMask.Shift), InputAction.LOGOUT)); // ── QuickslotCommands ────────────────────────────────── // Number1..9 → UseQuickSlot_1..9 with bare-mod and Ctrl-mod chords. for (int i = 1; i <= 9; i++) { var k = (Key)((int)Key.Number0 + i); // Number1..Number9 var action = (InputAction)((int)InputAction.UseQuickSlot_1 + i - 1); b.Add(new(new KeyChord(k, ModifierMask.None), action)); b.Add(new(new KeyChord(k, ModifierMask.Ctrl), action)); } // Alt+5..9 → UseQuickSlot_14..18. for (int i = 5; i <= 9; i++) { var k = (Key)((int)Key.Number0 + i); var action = (InputAction)((int)InputAction.UseQuickSlot_14 + i - 5); b.Add(new(new KeyChord(k, ModifierMask.Alt), action)); } b.Add(new(new KeyChord(Key.Number0, ModifierMask.None), InputAction.CreateShortcut)); b.Add(new(new KeyChord(Key.Number0, ModifierMask.Ctrl), InputAction.CreateShortcut)); // ── Chat ──────────────────────────────────────────────── b.Add(new(new KeyChord(Key.Tab, ModifierMask.None), InputAction.ToggleChatEntry)); b.Add(new(new KeyChord(Key.Enter, ModifierMask.None), InputAction.EnterChatMode)); // ── Combat (mode-dependent — dormant in K, lights up in Phase L) ── b.Add(new(new KeyChord(Key.GraveAccent, ModifierMask.None), InputAction.CombatToggleCombat)); // Melee mode (active when MeleeCombat scope pushed). b.Add(new(new KeyChord(Key.Insert, ModifierMask.None), InputAction.CombatDecreaseAttackPower)); b.Add(new(new KeyChord(Key.PageUp, ModifierMask.None), InputAction.CombatIncreaseAttackPower)); b.Add(new(new KeyChord(Key.Delete, ModifierMask.None), InputAction.CombatLowAttack)); b.Add(new(new KeyChord(Key.End, ModifierMask.None), InputAction.CombatMediumAttack)); b.Add(new(new KeyChord(Key.PageDown, ModifierMask.None), InputAction.CombatHighAttack)); // Missile + Magic + Spell-tab — same chords; resolved by scope at // runtime per InputDispatcher's stack lookup. Add the bindings; // subscribers arrive in Phase L when CombatState.CurrentMode is // wired. b.Add(new(new KeyChord(Key.Insert, ModifierMask.None), InputAction.CombatDecreaseMissileAccuracy)); b.Add(new(new KeyChord(Key.PageUp, ModifierMask.None), InputAction.CombatIncreaseMissileAccuracy)); b.Add(new(new KeyChord(Key.Delete, ModifierMask.None), InputAction.CombatAimLow)); b.Add(new(new KeyChord(Key.End, ModifierMask.None), InputAction.CombatAimMedium)); b.Add(new(new KeyChord(Key.PageDown, ModifierMask.None), InputAction.CombatAimHigh)); b.Add(new(new KeyChord(Key.Insert, ModifierMask.None), InputAction.CombatPrevSpellTab)); b.Add(new(new KeyChord(Key.PageUp, ModifierMask.None), InputAction.CombatNextSpellTab)); b.Add(new(new KeyChord(Key.Delete, ModifierMask.None), InputAction.CombatPrevSpell)); b.Add(new(new KeyChord(Key.End, ModifierMask.None), InputAction.CombatCastCurrentSpell)); b.Add(new(new KeyChord(Key.PageDown, ModifierMask.None), InputAction.CombatNextSpell)); b.Add(new(new KeyChord(Key.Insert, ModifierMask.Ctrl), InputAction.CombatFirstSpellTab)); b.Add(new(new KeyChord(Key.PageUp, ModifierMask.Ctrl), InputAction.CombatLastSpellTab)); b.Add(new(new KeyChord(Key.Delete, ModifierMask.Ctrl), InputAction.CombatFirstSpell)); b.Add(new(new KeyChord(Key.PageDown, ModifierMask.Ctrl), InputAction.CombatLastSpell)); for (int i = 1; i <= 9; i++) { var k = (Key)((int)Key.Number0 + i); var action = (InputAction)((int)InputAction.UseSpellSlot_1 + i - 1); b.Add(new(new KeyChord(k, ModifierMask.None), action)); } // ── Emotes ────────────────────────────────────────────── b.Add(new(new KeyChord(Key.U, ModifierMask.None), InputAction.Cry)); b.Add(new(new KeyChord(Key.I, ModifierMask.None), InputAction.Laugh)); b.Add(new(new KeyChord(Key.J, ModifierMask.None), InputAction.Wave)); b.Add(new(new KeyChord(Key.O, ModifierMask.None), InputAction.Cheer)); b.Add(new(new KeyChord(Key.K, ModifierMask.None), InputAction.PointState)); // ── Camera ───────────────────────────────────────────── b.Add(new(new KeyChord(Key.KeypadDivide, ModifierMask.None), InputAction.CameraActivateAlternateMode)); b.Add(new(new KeyChord(Key.F2, ModifierMask.None), InputAction.CameraActivateAlternateMode)); // CameraInstantMouseLook (MMB hold) — encoded as a mouse chord // via the K.1a Device=1 convention. K.2 lights up the actual // camera+yaw drive logic. b.Add(new( new KeyChord(InputDispatcher.MouseButtonToKey(MouseButton.Middle), ModifierMask.None, Device: 1), InputAction.CameraInstantMouseLook, ActivationType.Hold)); // Numpad cluster. b.Add(new(new KeyChord(Key.Keypad4, ModifierMask.None), InputAction.CameraRotateLeft)); b.Add(new(new KeyChord(Key.Keypad6, ModifierMask.None), InputAction.CameraRotateRight)); b.Add(new(new KeyChord(Key.Keypad8, ModifierMask.None), InputAction.CameraRotateUp)); b.Add(new(new KeyChord(Key.Keypad2, ModifierMask.None), InputAction.CameraRotateDown)); b.Add(new(new KeyChord(Key.KeypadSubtract, ModifierMask.None), InputAction.CameraMoveToward)); b.Add(new(new KeyChord(Key.KeypadAdd, ModifierMask.None), InputAction.CameraMoveAway)); b.Add(new(new KeyChord(Key.Keypad0, ModifierMask.None), InputAction.CameraViewDefault)); b.Add(new(new KeyChord(Key.KeypadDecimal, ModifierMask.None), InputAction.CameraViewFirstPerson)); b.Add(new(new KeyChord(Key.Keypad5, ModifierMask.None), InputAction.CameraViewLookDown)); b.Add(new(new KeyChord(Key.KeypadEnter, ModifierMask.None), InputAction.CameraViewMapMode)); // ── Mouse selection ──────────────────────────────────── // Retail keymap: SelectLeft = LMB, SelectRight = RMB, SelectMid = MMB, // and the doubles fire on MouseDblClick. Encoded as Device=1 chords; // dispatcher resolves the mouse button via MouseButtonToKey. b.Add(new( new KeyChord(InputDispatcher.MouseButtonToKey(MouseButton.Left), ModifierMask.None, Device: 1), InputAction.SelectLeft)); b.Add(new( new KeyChord(InputDispatcher.MouseButtonToKey(MouseButton.Right), ModifierMask.None, Device: 1), InputAction.SelectRight)); b.Add(new( new KeyChord(InputDispatcher.MouseButtonToKey(MouseButton.Middle), ModifierMask.None, Device: 1), InputAction.SelectMid)); b.Add(new( new KeyChord(InputDispatcher.MouseButtonToKey(MouseButton.Left), ModifierMask.None, Device: 1), InputAction.SelectDblLeft, ActivationType.DoubleClick)); b.Add(new( new KeyChord(InputDispatcher.MouseButtonToKey(MouseButton.Right), ModifierMask.None, Device: 1), InputAction.SelectDblRight, ActivationType.DoubleClick)); b.Add(new( new KeyChord(InputDispatcher.MouseButtonToKey(MouseButton.Middle), ModifierMask.None, Device: 1), InputAction.SelectDblMid, ActivationType.DoubleClick)); // ── Scrollable ───────────────────────────────────────── // Mouse wheel → ScrollUp/Down handled by dispatcher's OnScroll path. b.Add(new(new KeyChord(Key.Up, ModifierMask.Ctrl), InputAction.ScrollUp)); b.Add(new(new KeyChord(Key.Down, ModifierMask.Ctrl), InputAction.ScrollDown)); // ── Acdream debug actions: relocated to Ctrl+F* to avoid retail // conflicts. AcdreamTogglePlayerMode has NO keyboard binding // in retail-default (player-mode is auto-entered at login). // AcdreamRmbOrbitHold is intentionally absent — retail binds // RMB to SelectRight; the chase-camera orbit lives behind a // debug-mode flag in K.2. b.Add(new(new KeyChord(Key.F1, ModifierMask.Ctrl), InputAction.AcdreamToggleDebugPanel)); b.Add(new(new KeyChord(Key.F2, ModifierMask.Ctrl), InputAction.AcdreamToggleCollisionWires)); b.Add(new(new KeyChord(Key.F3, ModifierMask.Ctrl), InputAction.AcdreamDumpNearby)); b.Add(new(new KeyChord(Key.F7, ModifierMask.Ctrl), InputAction.AcdreamCycleTimeOfDay)); b.Add(new(new KeyChord(Key.F8, ModifierMask.Ctrl), InputAction.AcdreamSensitivityDown)); b.Add(new(new KeyChord(Key.F9, ModifierMask.Ctrl), InputAction.AcdreamSensitivityUp)); b.Add(new(new KeyChord(Key.F10, ModifierMask.Ctrl), InputAction.AcdreamCycleWeather)); // K-fix2 (2026-04-26): free-fly toggle keyboard shortcut. // Retail leaves Ctrl+Shift+F unbound (retail F = SelectionPickUp, // Ctrl+F = unused) so this is non-conflicting. Also discoverable // via View → Camera in the ImGui MainMenuBar and the // "Toggle Free-Fly Mode" button in the Debug panel. b.Add(new( new KeyChord(Key.F, ModifierMask.Ctrl | ModifierMask.Shift), InputAction.AcdreamToggleFlyMode)); // K-fix1 (2026-04-26): RMB-hold camera orbit. Coexists with the // SelectRight Press binding above — Press fires on click, // AcdreamRmbOrbitHold fires on hold/release transitions so the // chase camera can free-orbit while the user drags the mouse. // Without this, RMB-orbit silently broke when K.1c flipped the // default keymap from AcdreamCurrentDefaults to RetailDefaults. b.Add(new( new KeyChord(InputDispatcher.MouseButtonToKey(MouseButton.Right), ModifierMask.None, Device: 1), InputAction.AcdreamRmbOrbitHold, ActivationType.Hold)); return b; } /// /// Load bindings from . If the file doesn't /// exist, returns . If the file exists /// but is missing actions present in the current default set, those /// missing actions get the default binding (merge-over-defaults). /// On corrupt file: warns + returns RetailDefaults without writing /// (don't blow away the user's file silently). /// public static KeyBindings LoadOrDefault(string path) { if (!File.Exists(path)) return RetailDefaults(); try { using var stream = File.OpenRead(path); var doc = JsonDocument.Parse(stream); var root = doc.RootElement; // version is currently advisory — read so future migrations // can branch on it; the field's presence is non-fatal. _ = root.TryGetProperty("version", out var vEl) ? vEl.GetInt32() : 0; var defaults = RetailDefaults(); var loaded = new KeyBindings(); if (root.TryGetProperty("actions", out var actionsEl) && actionsEl.ValueKind == JsonValueKind.Object) { foreach (var actionProp in actionsEl.EnumerateObject()) { if (!Enum.TryParse(actionProp.Name, out var action)) continue; // unknown action → skip if (actionProp.Value.ValueKind != JsonValueKind.Array) continue; foreach (var bindingEl in actionProp.Value.EnumerateArray()) { if (!bindingEl.TryGetProperty("key", out var keyEl)) continue; var key = keyEl.GetString(); if (key is null) continue; if (!Enum.TryParse(key, out var silkKey)) continue; // unknown key → skip var mods = ParseModifiers(bindingEl); var activation = ActivationType.Press; if (bindingEl.TryGetProperty("activation", out var actEl) && actEl.ValueKind == JsonValueKind.String && Enum.TryParse(actEl.GetString(), out var parsedAct)) { activation = parsedAct; } byte device = 0; if (bindingEl.TryGetProperty("device", out var dEl) && dEl.ValueKind == JsonValueKind.Number) { device = (byte)dEl.GetInt32(); } loaded.Add(new(new KeyChord(silkKey, mods, device), action, activation)); } } } // Merge: any action that has bindings in defaults but NOT in // loaded picks up the defaults. Preserves user customizations // for actions they DID rebind, while not silently losing // newly-added actions if the user file is older. foreach (var actionInDefaults in Enum.GetValues()) { if (!loaded.ForAction(actionInDefaults).Any() && defaults.ForAction(actionInDefaults).Any()) { foreach (var def in defaults.ForAction(actionInDefaults)) loaded.Add(def); } } return loaded; } catch (Exception ex) { Console.WriteLine($"keybinds: failed to load {path}: {ex.Message} — using retail defaults"); return RetailDefaults(); } } /// /// Persist this binding set to as JSON. /// Format: { "version": N, "actions": { "ActionName": [ {key,mod,activation,device}... ] } }. /// Sorted keys for deterministic diffs. /// public void SaveToFile(string path) { var dir = Path.GetDirectoryName(path); if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); var actions = new SortedDictionary>(StringComparer.Ordinal); foreach (var binding in _bindings) { if (!actions.TryGetValue(binding.Action.ToString(), out var list)) { list = new List(); actions[binding.Action.ToString()] = list; } var entry = new SortedDictionary(StringComparer.Ordinal) { ["key"] = binding.Chord.Key.ToString(), }; if (binding.Chord.Modifiers != ModifierMask.None) entry["mod"] = binding.Chord.Modifiers.ToString(); if (binding.Chord.Device != 0) entry["device"] = (int)binding.Chord.Device; if (binding.Activation != ActivationType.Press) entry["activation"] = binding.Activation.ToString(); list.Add(entry); } var root = new SortedDictionary(StringComparer.Ordinal) { ["version"] = CurrentSchemaVersion, ["actions"] = actions, }; var json = JsonSerializer.Serialize(root, new JsonSerializerOptions { WriteIndented = true }); File.WriteAllText(path, json); } private static ModifierMask ParseModifiers(JsonElement bindingEl) { if (!bindingEl.TryGetProperty("mod", out var modEl)) return ModifierMask.None; if (modEl.ValueKind != JsonValueKind.String) return ModifierMask.None; var modString = modEl.GetString(); if (string.IsNullOrEmpty(modString)) return ModifierMask.None; var result = ModifierMask.None; foreach (var part in modString.Split( new[] { '|', ',', ' ' }, StringSplitOptions.RemoveEmptyEntries)) { if (Enum.TryParse(part, ignoreCase: true, out var single)) result |= single; } return result; } /// /// Default path: %LOCALAPPDATA%\acdream\keybinds.json. /// public static string DefaultPath() => Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "acdream", "keybinds.json"); }