Root cause, confirmed by a full-production-wiring repro test rather than
guessed: InputAction.AcdreamToggleAudioMute was bound to Ctrl+M only in
KeyBindings.AcdreamCurrentDefaults() -- the pre-K.1c WASD-only preset,
whose own doc comment says it is preserved solely as a regression anchor
and is explicitly NOT the GameWindow startup source after K.1c.
KeyBindings.RetailDefaults() -- what KeyBindings.LoadOrDefault actually
falls back to when no keybinds.json exists on disk (the verified state
on the affected machine) -- has its own "Acdream debug actions" block
(Ctrl+F1/F2/F3/F7/F8/F9/F10, Ctrl+Shift+F) but never carried the Ctrl+M
mute binding over into it. The live dispatcher therefore had no Ctrl+M
entry in its binding table at all -- not a modifier-matching bug, not a
scope bug, not a retained-UI-capture bug. Same class as the a5a7eb4f
jump fix (two construction paths, one wired to production), except here
it's two default-binding-set methods rather than two controller
instances, and the binding was simply added to the wrong one. This also
explains the prior "loaded 152 bindings both before and after" mystery:
the count correctly didn't change, because the earlier addition went
into a method nothing in production loads or counts.
MuteChordDispatchTests.CtrlM_WithNoWidgetFocused_FiresAcdreamToggleAudioMute
reproduces the full production shape (real RetailDefaults(), the
dispatcher's actual default [Always, Game] scope stack -- production
never calls PushScope/PopScope anywhere, grepped clean across
src/AcDream.App -- and a synthetic Ctrl+M keydown) and failed with an
EMPTY fired collection before this fix, which is what pinpointed
"missing table entry" over the other ranked hypotheses. A second test,
CtrlM_WhileAnyWidgetHoldsKeyboardFocus_IsSuppressed, pins a separate but
real mechanism found along the way (InputDispatcher.OnKeyDown returns
before FindActive when WantCaptureKeyboard is true, and production wires
that to "any focused widget", not just chat text entry) that was ruled
out as #358's cause since the baseline repro failed with nothing
focused.
Fix: KeyBindings.RetailDefaults() now also binds Ctrl+M to
AcdreamToggleAudioMute. Retail's own keymap has no Ctrl+M binding, so
this doesn't collide with anything retail-faithful. #358 closed in
ISSUES.md with the confirmed mechanism; connected verification (does
Ctrl+M actually mute in a live client) is still owed -- this session's
hard constraints excluded client launches.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
609 lines
36 KiB
C#
609 lines
36 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Mutable collection of <see cref="Binding"/>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.
|
|
///
|
|
/// <para>
|
|
/// K.1c: <see cref="RetailDefaults"/> now returns the full retail-faithful
|
|
/// preset (byte-precise to <c>retail-default.keymap.txt</c>);
|
|
/// <see cref="LoadOrDefault"/> + <see cref="SaveToFile"/> persist the
|
|
/// table to JSON at a host-provided portable path with version-based
|
|
/// migration. <see cref="AcdreamCurrentDefaults"/> is preserved as a
|
|
/// reference for tests pinning the older WASD-only behavior, but is no
|
|
/// longer the GameWindow startup source.
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class KeyBindings
|
|
{
|
|
private const int CurrentSchemaVersion = 5;
|
|
|
|
private readonly List<Binding> _bindings = new();
|
|
|
|
/// <summary>All bindings in insertion order.</summary>
|
|
public IReadOnlyList<Binding> All => _bindings;
|
|
|
|
/// <summary>Append a binding. Duplicates are allowed; first match wins.</summary>
|
|
public void Add(Binding b) => _bindings.Add(b);
|
|
|
|
/// <summary>Remove the first occurrence of the given binding (structural equality).</summary>
|
|
public bool Remove(Binding b) => _bindings.Remove(b);
|
|
|
|
/// <summary>Drop every binding.</summary>
|
|
public void Clear() => _bindings.Clear();
|
|
|
|
/// <summary>
|
|
/// First binding (in insertion order) whose chord matches AND whose
|
|
/// activation type matches the requested phase. Null if none.
|
|
/// </summary>
|
|
public Binding? Find(KeyChord chord, ActivationType activation)
|
|
{
|
|
for (int i = 0; i < _bindings.Count; i++)
|
|
{
|
|
Binding binding = _bindings[i];
|
|
if (binding.Chord == chord && binding.Activation == activation)
|
|
return binding;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
public Binding? Find(KeyChord chord, ActivationType activation, InputScope scope)
|
|
{
|
|
for (int i = 0; i < _bindings.Count; i++)
|
|
{
|
|
var b = _bindings[i];
|
|
if (b.Chord == chord && b.Activation == activation && b.Scope == scope) return b;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// All bindings for a given action (one action can have multiple keys
|
|
/// — retail's W and Up-arrow both → MovementForward).
|
|
/// </summary>
|
|
public IEnumerable<Binding> ForAction(InputAction action)
|
|
{
|
|
for (int i = 0; i < _bindings.Count; i++)
|
|
if (_bindings[i].Action == action) yield return _bindings[i];
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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.M, ModifierMask.Ctrl), InputAction.AcdreamToggleAudioMute));
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Canonical AC retail-default keymap, byte-precise to
|
|
/// <c>docs/research/named-retail/retail-default.keymap.txt</c> (the
|
|
/// user's Throne of Destiny <c>test.keymap</c>). All bindings
|
|
/// categorized by the <c>Bindings [...]</c> 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.
|
|
/// </summary>
|
|
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 ──────────────────────────────────
|
|
// Retail gmToolbarUI::ListenToGlobalMessage @ 0x004BE4E0 receives
|
|
// distinct action-id ranges: bare 1..9 USE slots 0..8, while
|
|
// Ctrl+1..9 SELECT those slots. The keymap repeats the display name
|
|
// UseQuickSlot_N for both bindings, so our semantic action layer must
|
|
// preserve the differing intent explicitly.
|
|
for (int i = 1; i <= 9; i++)
|
|
{
|
|
var k = (Key)((int)Key.Number0 + i); // Number1..Number9
|
|
var useAction = (InputAction)((int)InputAction.UseQuickSlot_1 + i - 1);
|
|
var selectAction = (InputAction)((int)InputAction.SelectQuickSlot_1 + i - 1);
|
|
b.Add(new(new KeyChord(k, ModifierMask.None), useAction));
|
|
b.Add(new(new KeyChord(k, ModifierMask.Ctrl), selectAction));
|
|
}
|
|
// 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, Scope: InputScope.MeleeCombat));
|
|
b.Add(new(new KeyChord(Key.PageUp, ModifierMask.None), InputAction.CombatIncreaseAttackPower, Scope: InputScope.MeleeCombat));
|
|
// Retail HandleCombatAction consumes key-down AND key-up for attack
|
|
// height actions: down starts charging, up ends the request. Hold is
|
|
// our binding representation for that transition pair.
|
|
b.Add(new(new KeyChord(Key.Delete, ModifierMask.None), InputAction.CombatLowAttack, ActivationType.Hold, InputScope.MeleeCombat));
|
|
b.Add(new(new KeyChord(Key.End, ModifierMask.None), InputAction.CombatMediumAttack, ActivationType.Hold, InputScope.MeleeCombat));
|
|
b.Add(new(new KeyChord(Key.PageDown, ModifierMask.None), InputAction.CombatHighAttack, ActivationType.Hold, InputScope.MeleeCombat));
|
|
// 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, Scope: InputScope.MissileCombat));
|
|
b.Add(new(new KeyChord(Key.PageUp, ModifierMask.None), InputAction.CombatIncreaseMissileAccuracy, Scope: InputScope.MissileCombat));
|
|
b.Add(new(new KeyChord(Key.Delete, ModifierMask.None), InputAction.CombatAimLow, Scope: InputScope.MissileCombat));
|
|
b.Add(new(new KeyChord(Key.End, ModifierMask.None), InputAction.CombatAimMedium, Scope: InputScope.MissileCombat));
|
|
b.Add(new(new KeyChord(Key.PageDown, ModifierMask.None), InputAction.CombatAimHigh, Scope: InputScope.MissileCombat));
|
|
b.Add(new(new KeyChord(Key.Insert, ModifierMask.None), InputAction.CombatPrevSpellTab, Scope: InputScope.MagicCombat));
|
|
b.Add(new(new KeyChord(Key.PageUp, ModifierMask.None), InputAction.CombatNextSpellTab, Scope: InputScope.MagicCombat));
|
|
b.Add(new(new KeyChord(Key.Delete, ModifierMask.None), InputAction.CombatPrevSpell, Scope: InputScope.MagicCombat));
|
|
b.Add(new(new KeyChord(Key.End, ModifierMask.None), InputAction.CombatCastCurrentSpell, Scope: InputScope.MagicCombat));
|
|
b.Add(new(new KeyChord(Key.PageDown, ModifierMask.None), InputAction.CombatNextSpell, Scope: InputScope.MagicCombat));
|
|
b.Add(new(new KeyChord(Key.Insert, ModifierMask.Ctrl), InputAction.CombatFirstSpellTab, Scope: InputScope.MagicCombat));
|
|
b.Add(new(new KeyChord(Key.PageUp, ModifierMask.Ctrl), InputAction.CombatLastSpellTab, Scope: InputScope.MagicCombat));
|
|
b.Add(new(new KeyChord(Key.Delete, ModifierMask.Ctrl), InputAction.CombatFirstSpell, Scope: InputScope.MagicCombat));
|
|
b.Add(new(new KeyChord(Key.PageDown, ModifierMask.Ctrl), InputAction.CombatLastSpell, Scope: InputScope.MagicCombat));
|
|
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, Scope: InputScope.MagicCombat));
|
|
}
|
|
|
|
// ── 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,
|
|
ActivationType.Click));
|
|
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));
|
|
// #358: AcdreamToggleAudioMute was bound in AcdreamCurrentDefaults()
|
|
// (line ~123 above) but never carried over here when K.1c made
|
|
// RetailDefaults the GameWindow startup source — AcdreamCurrentDefaults
|
|
// is preserved only as a WASD-layout regression anchor and is not
|
|
// loaded in production, so the chord was registered in a table the
|
|
// live dispatcher never consults. Retail's own keymap has no Ctrl+M
|
|
// binding (grepped retail-default.keymap.txt clean), so this doesn't
|
|
// collide with anything retail-faithful.
|
|
b.Add(new(new KeyChord(Key.M, ModifierMask.Ctrl), InputAction.AcdreamToggleAudioMute));
|
|
|
|
// 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 Click binding above resolves only after a stationary
|
|
// release. 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Load bindings from <paramref name="path"/>. If the file doesn't
|
|
/// exist, returns <see cref="RetailDefaults"/>. 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).
|
|
/// </summary>
|
|
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;
|
|
// Missing/older versions are valid and drive narrow migrations;
|
|
// the field's presence remains non-fatal.
|
|
int version = 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<InputAction>(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>(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<ActivationType>(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();
|
|
}
|
|
var chord = new KeyChord(silkKey, mods, device);
|
|
action = MigrateLegacyQuickSlotIntent(version, action, chord, activation);
|
|
activation = MigrateCombatAttackActivation(version, action, activation);
|
|
activation = MigrateSelectRightActivation(version, action, activation);
|
|
InputScope scope = defaults.ForAction(action)
|
|
.Select(binding => binding.Scope)
|
|
.DefaultIfEmpty(InputScope.Game)
|
|
.First();
|
|
if (bindingEl.TryGetProperty("scope", out var scopeEl)
|
|
&& scopeEl.ValueKind == JsonValueKind.String
|
|
&& Enum.TryParse(scopeEl.GetString(), out InputScope parsedScope))
|
|
{
|
|
scope = parsedScope;
|
|
}
|
|
loaded.Add(new(chord, action, activation, scope));
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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<InputAction>())
|
|
{
|
|
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();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Persist this binding set to <paramref name="path"/> as JSON.
|
|
/// Format: <c>{ "version": N, "actions": { "ActionName": [ {key,mod,activation,device}... ] } }</c>.
|
|
/// Sorted keys for deterministic diffs.
|
|
/// </summary>
|
|
public void SaveToFile(string path)
|
|
{
|
|
var dir = Path.GetDirectoryName(path);
|
|
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
|
|
|
var actions = new SortedDictionary<string, List<object>>(StringComparer.Ordinal);
|
|
foreach (var binding in _bindings)
|
|
{
|
|
if (!actions.TryGetValue(binding.Action.ToString(), out var list))
|
|
{
|
|
list = new List<object>();
|
|
actions[binding.Action.ToString()] = list;
|
|
}
|
|
var entry = new SortedDictionary<string, object>(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();
|
|
if (binding.Scope != InputScope.Game)
|
|
entry["scope"] = binding.Scope.ToString();
|
|
list.Add(entry);
|
|
}
|
|
|
|
var root = new SortedDictionary<string, object>(StringComparer.Ordinal)
|
|
{
|
|
["version"] = CurrentSchemaVersion,
|
|
["actions"] = actions,
|
|
};
|
|
var json = JsonSerializer.Serialize(root, new JsonSerializerOptions { WriteIndented = true });
|
|
File.WriteAllText(path, json);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Schema v1 repeated <c>UseQuickSlot_N</c> for bare and Ctrl chords,
|
|
/// losing retail's use-vs-select distinction. Migrate only the exact old
|
|
/// default Ctrl+matching-number shape; arbitrary user rebindings remain
|
|
/// attached to the action the user chose.
|
|
/// </summary>
|
|
private static InputAction MigrateLegacyQuickSlotIntent(
|
|
int version,
|
|
InputAction action,
|
|
KeyChord chord,
|
|
ActivationType activation)
|
|
{
|
|
if (version >= 2
|
|
|| activation != ActivationType.Press
|
|
|| chord.Device != 0
|
|
|| chord.Modifiers != ModifierMask.Ctrl)
|
|
return action;
|
|
|
|
int offset = (int)action - (int)InputAction.UseQuickSlot_1;
|
|
if ((uint)offset >= 9u)
|
|
return action;
|
|
|
|
var expectedKey = (Key)((int)Key.Number1 + offset);
|
|
return chord.Key == expectedKey
|
|
? (InputAction)((int)InputAction.SelectQuickSlot_1 + offset)
|
|
: action;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Schema v2 stored the three attack-height commands as one-shot Press
|
|
/// bindings. Retail requires both press and release, so old and customized
|
|
/// chords retain their key while upgrading to Hold transition semantics.
|
|
/// </summary>
|
|
private static ActivationType MigrateCombatAttackActivation(
|
|
int version,
|
|
InputAction action,
|
|
ActivationType activation)
|
|
{
|
|
if (version >= 3 || activation != ActivationType.Press)
|
|
return activation;
|
|
return action is InputAction.CombatLowAttack
|
|
or InputAction.CombatMediumAttack
|
|
or InputAction.CombatHighAttack
|
|
? ActivationType.Hold
|
|
: activation;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Schema v4 fired SelectRight on mouse-down. Retail's SmartBox resolves
|
|
/// right-click examination on mouse-up only when the pointer did not turn
|
|
/// into a drag, so preserve the user's chord while upgrading its gesture.
|
|
/// </summary>
|
|
private static ActivationType MigrateSelectRightActivation(
|
|
int version,
|
|
InputAction action,
|
|
ActivationType activation)
|
|
=> version < 5
|
|
&& action == InputAction.SelectRight
|
|
&& activation == ActivationType.Press
|
|
? ActivationType.Click
|
|
: activation;
|
|
|
|
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<ModifierMask>(part, ignoreCase: true, out var single))
|
|
result |= single;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
}
|