Complete the retail cast-intent, target, component, enchantment, and busy-state paths; mount the DAT-authored spell bar, spellbook, component book, effects panels, and shared panel lifecycle; and add scoped input plus conformance coverage. Co-Authored-By: Codex <noreply@openai.com>
589 lines
35 KiB
C#
589 lines
35 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 under <see cref="DefaultPath"/> 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 = 4;
|
|
|
|
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.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));
|
|
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;
|
|
}
|
|
|
|
/// <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);
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Default path: <c>%LOCALAPPDATA%\acdream\keybinds.json</c>.
|
|
/// </summary>
|
|
public static string DefaultPath() => Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
|
"acdream",
|
|
"keybinds.json");
|
|
}
|