fix(client): restore retail interaction parity
All checks were successful
CI / linux-portable (push) Successful in 3m27s
CI / windows-gate (push) Successful in 6m42s
CI / release (push) Successful in 2m12s

Harden keyboard and camera routing, inventory and vendor interactions, chat/emotes, relog portal flow, and paperdoll rendering. Add retail research, connected gate coverage, and release-gate validation.
This commit is contained in:
Erik 2026-08-26 20:45:11 +02:00
parent 0c699240e0
commit f6fe0f2a4f
151 changed files with 10162 additions and 1211 deletions

View file

@ -316,6 +316,88 @@ internal sealed class CameraPointerInputController
}
}
public bool HandleCameraAction(
InputAction action,
ActivationType activation)
{
if (activation != ActivationType.Press
|| !_playerMode.IsPlayerMode
|| !_camera.IsChaseMode)
{
return false;
}
bool handled = action is
InputAction.CameraViewDefault
or InputAction.CameraAlternateViewDefault
or InputAction.CameraViewFirstPerson
or InputAction.CameraAlternateViewFirstPerson
or InputAction.CameraViewLookDown
or InputAction.CameraAlternateViewLookDown
or InputAction.CameraViewMapMode
or InputAction.CameraAlternateViewMapMode;
if (!handled)
return false;
ApplyCameraPreset(_chase.Retail, action);
ApplyCameraPreset(_chase.Legacy, action);
return true;
}
private static void ApplyCameraPreset(
RetailChaseCamera? camera,
InputAction action)
{
if (camera is null)
return;
switch (action)
{
case InputAction.CameraViewDefault:
case InputAction.CameraAlternateViewDefault:
camera.SetRetailDefaultView();
break;
case InputAction.CameraViewFirstPerson:
case InputAction.CameraAlternateViewFirstPerson:
camera.SetRetailFirstPersonView();
break;
case InputAction.CameraViewLookDown:
case InputAction.CameraAlternateViewLookDown:
camera.ToggleRetailLookDownView();
break;
case InputAction.CameraViewMapMode:
case InputAction.CameraAlternateViewMapMode:
camera.ToggleRetailMapModeView();
break;
}
}
private static void ApplyCameraPreset(
ChaseCamera? camera,
InputAction action)
{
if (camera is null)
return;
switch (action)
{
case InputAction.CameraViewDefault:
case InputAction.CameraAlternateViewDefault:
camera.SetRetailDefaultView();
break;
case InputAction.CameraViewFirstPerson:
case InputAction.CameraAlternateViewFirstPerson:
camera.SetRetailFirstPersonView();
break;
case InputAction.CameraViewLookDown:
case InputAction.CameraAlternateViewLookDown:
camera.ToggleRetailLookDownView();
break;
case InputAction.CameraViewMapMode:
case InputAction.CameraAlternateViewMapMode:
camera.ToggleRetailMapModeView();
break;
}
}
public string AdjustSensitivity(float factor)
{
string mode;

View file

@ -15,7 +15,9 @@ internal readonly record struct ChaseCameraAdjustmentInput(
bool ZoomIn,
bool ZoomOut,
bool Raise,
bool Lower);
bool Lower,
bool RotateLeft,
bool RotateRight);
internal interface ICameraFrameInputSource
{
@ -71,9 +73,21 @@ internal sealed class DispatcherCameraInputSource : ICameraFrameInputSource
return default;
return new ChaseCameraAdjustmentInput(
dispatcher.IsActionHeld(InputAction.CameraZoomIn),
dispatcher.IsActionHeld(InputAction.CameraZoomOut),
dispatcher.IsActionHeld(InputAction.CameraRaise),
dispatcher.IsActionHeld(InputAction.CameraLower));
dispatcher.IsActionHeld(InputAction.CameraZoomIn)
|| dispatcher.IsActionHeld(InputAction.CameraMoveToward)
|| dispatcher.IsActionHeld(InputAction.CameraAlternateMoveToward),
dispatcher.IsActionHeld(InputAction.CameraZoomOut)
|| dispatcher.IsActionHeld(InputAction.CameraMoveAway)
|| dispatcher.IsActionHeld(InputAction.CameraAlternateMoveAway),
dispatcher.IsActionHeld(InputAction.CameraRaise)
|| dispatcher.IsActionHeld(InputAction.CameraRotateUp)
|| dispatcher.IsActionHeld(InputAction.CameraAlternateRotateUp),
dispatcher.IsActionHeld(InputAction.CameraLower)
|| dispatcher.IsActionHeld(InputAction.CameraRotateDown)
|| dispatcher.IsActionHeld(InputAction.CameraAlternateRotateDown),
dispatcher.IsActionHeld(InputAction.CameraRotateLeft)
|| dispatcher.IsActionHeld(InputAction.CameraAlternateRotateLeft),
dispatcher.IsActionHeld(InputAction.CameraRotateRight)
|| dispatcher.IsActionHeld(InputAction.CameraAlternateRotateRight));
}
}

View file

@ -3,6 +3,7 @@ using AcDream.App.Rendering;
using AcDream.App.UI;
using AcDream.Core.Combat;
using AcDream.Runtime;
using AcDream.Runtime.Gameplay;
using AcDream.UI.Abstractions.Input;
namespace AcDream.App.Input;
@ -14,6 +15,8 @@ internal interface IGameplayInputActionSurface
void RemoveFired(Action<InputAction, ActivationType> callback);
void SetCombatScope(InputScope? scope);
void SetCameraAlternateScope(bool active);
}
internal sealed class DispatcherGameplayInputActionSurface(InputDispatcher dispatcher)
@ -30,6 +33,9 @@ internal sealed class DispatcherGameplayInputActionSurface(InputDispatcher dispa
public void SetCombatScope(InputScope? scope) =>
_dispatcher.SetCombatScope(scope);
public void SetCameraAlternateScope(bool active) =>
_dispatcher.SetCameraAlternateScope(active);
}
internal interface ICombatModeEventSurface
@ -66,6 +72,8 @@ internal interface IGameplayInputPriorityTargets
bool HandleRetainedUiAction(InputAction action);
bool HandleCharacterOptionAction(InputAction action);
bool HandleSelectionAction(InputAction action);
bool HandlePressedMovementAction(InputAction action);
@ -87,6 +95,7 @@ internal sealed class RuntimeGameplayInputPriorityTargets
private readonly IGameRuntimeView _runtimeView;
private readonly IRuntimeSelectionCommands _runtimeSelection;
private readonly IRuntimeMovementCommands _runtimeMovement;
private readonly IRuntimeCharacterCommands _runtimeCharacter;
private readonly IGameplayInputCommandTarget _commands;
public RuntimeGameplayInputPriorityTargets(
@ -97,6 +106,7 @@ internal sealed class RuntimeGameplayInputPriorityTargets
IGameRuntimeView runtimeView,
IRuntimeSelectionCommands runtimeSelection,
IRuntimeMovementCommands runtimeMovement,
IRuntimeCharacterCommands runtimeCharacter,
IGameplayInputCommandTarget commands)
{
_frame = frame ?? throw new ArgumentNullException(nameof(frame));
@ -109,11 +119,14 @@ internal sealed class RuntimeGameplayInputPriorityTargets
?? throw new ArgumentNullException(nameof(runtimeSelection));
_runtimeMovement = runtimeMovement
?? throw new ArgumentNullException(nameof(runtimeMovement));
_runtimeCharacter = runtimeCharacter
?? throw new ArgumentNullException(nameof(runtimeCharacter));
_commands = commands ?? throw new ArgumentNullException(nameof(commands));
}
public bool HandlePointerAction(InputAction action, ActivationType activation) =>
_frame.HandlePointerAction(action, activation);
_frame.HandlePointerAction(action, activation)
|| _pointer.HandleCameraAction(action, activation);
public void HandleScroll(InputAction action) =>
_pointer.HandleScroll(action);
@ -122,10 +135,74 @@ internal sealed class RuntimeGameplayInputPriorityTargets
_frame.HandleCombatAction(action, activation);
public bool HandleRetainedUiAction(InputAction action) =>
_retainedUi?.HandleInputAction(action) == true;
FinishJumpBeforeUi(action)
|| _retainedUi?.HandleInputAction(action) == true;
public bool HandleCharacterOptionAction(InputAction action)
{
if (!RetailActionIdentityTable.TryGetCharacterOptionId(
action,
out uint optionId)
|| !CharacterOptionTable.TryGet(
optionId,
out CharacterOptionTableEntry entry))
{
return false;
}
RuntimeCharacterOptionsSnapshot options =
_runtimeView.Character.Snapshot.Options;
uint word = entry.IsOptions1 ? options.Options1 : options.Options2;
bool current = (word & entry.Mask) != 0u;
_runtimeCharacter.SetSingleOption(
_runtimeView.Generation,
optionId,
!current);
return true;
}
private bool FinishJumpBeforeUi(InputAction action)
{
RuntimeMovementCommand? command = ResolveEscapeMovementCommand(
action,
_runtimeView.Movement.IsStandingStill,
_runtimeView.Movement.JumpCharge,
_runtimeView.Actions.Snapshot.CombatAttack);
if (command != RuntimeMovementCommand.FinishJump)
{
return false;
}
_runtimeMovement.Execute(
_runtimeView.Generation,
command.Value);
return true;
}
public bool HandleSelectionAction(InputAction action)
{
if (action == InputAction.EscapeKey)
{
IRuntimeMovementView movement = _runtimeView.Movement;
RuntimeCombatAttackSnapshot attack = _runtimeView.Actions.Snapshot
.CombatAttack;
RuntimeMovementCommand? escapeCommand =
ResolveEscapeMovementCommand(
action,
movement.IsStandingStill,
movement.JumpCharge,
attack);
if (escapeCommand == RuntimeMovementCommand.StopCompletely)
{
_runtimeMovement.Execute(
_runtimeView.Generation,
escapeCommand.Value);
if (attack.RepeatAttackInProgress)
_frame.AbortAutomaticAttack();
return true;
}
}
RuntimeSelectionCommand? command = action switch
{
InputAction.SelectionClosestMonster =>
@ -149,15 +226,32 @@ internal sealed class RuntimeGameplayInputPriorityTargets
return _selection?.HandleInputAction(action) == true;
}
internal static RuntimeMovementCommand? ResolveEscapeMovementCommand(
InputAction action,
bool isStandingStill,
in AcDream.Runtime.Gameplay.JumpChargeSnapshot jumpCharge,
in RuntimeCombatAttackSnapshot attack)
{
if (action != InputAction.EscapeKey)
return null;
if (jumpCharge.IsCharging)
return RuntimeMovementCommand.FinishJump;
if (!isStandingStill || attack.RepeatAttackInProgress)
return RuntimeMovementCommand.StopCompletely;
return null;
}
public bool HandlePressedMovementAction(InputAction action)
{
RuntimeMovementCommand? command = action switch
if (RetailEmoteMotionTable.TryGetMotion(action, out uint motion))
{
InputAction.MovementRunLock =>
RuntimeMovementCommand.ToggleRunLock,
InputAction.MovementStop => RuntimeMovementCommand.Stop,
_ => null,
};
_runtimeMovement.ExecuteMotion(
_runtimeView.Generation,
motion);
return true;
}
RuntimeMovementCommand? command = ResolvePressedMovementCommand(action);
if (command is { } typed)
{
_runtimeMovement.Execute(_runtimeView.Generation, typed);
@ -167,6 +261,18 @@ internal sealed class RuntimeGameplayInputPriorityTargets
return _frame.HandlePressedMovementAction(action);
}
internal static RuntimeMovementCommand? ResolvePressedMovementCommand(
InputAction action) => action switch
{
InputAction.MovementRunLock => RuntimeMovementCommand.ToggleRunLock,
InputAction.MovementStop => RuntimeMovementCommand.Stop,
InputAction.Ready => RuntimeMovementCommand.Ready,
InputAction.Sitting => RuntimeMovementCommand.Sit,
InputAction.Crouch => RuntimeMovementCommand.Crouch,
InputAction.Sleeping => RuntimeMovementCommand.Sleep,
_ => null,
};
public void HandleCommand(InputAction action) =>
_commands.Handle(action);
}
@ -298,6 +404,14 @@ internal sealed class GameplayInputActionRouter : IDisposable
{
_log($"[input] {action} {activation}");
if (action == InputAction.CameraActivateAlternateMode)
{
if (activation == ActivationType.Press)
_actions.SetCameraAlternateScope(true);
else if (activation == ActivationType.Release)
_actions.SetCameraAlternateScope(false);
}
if (_targets.HandlePointerAction(action, activation))
return;
@ -320,6 +434,8 @@ internal sealed class GameplayInputActionRouter : IDisposable
if (_targets.HandleRetainedUiAction(action))
return;
if (_targets.HandleCharacterOptionAction(action))
return;
if (_targets.HandleSelectionAction(action))
return;
if (_targets.HandlePressedMovementAction(action))

View file

@ -1,6 +1,5 @@
using AcDream.App.Combat;
using AcDream.App.Diagnostics;
using AcDream.App.Rendering;
using AcDream.App.UI;
using AcDream.Runtime;
using AcDream.UI.Abstractions.Input;
@ -24,6 +23,12 @@ internal interface IRetainedGameplayWindowCommands
/// <c>RetailUiRuntime.BindToolbarPanelButtons</c>.
/// </summary>
void ToggleOptionsPanel();
void ToggleGameplayOptionsPage();
void FocusChatEntry();
void LogOutCharacter();
}
internal sealed class RetainedGameplayWindowCommands(RetailUiRuntime? runtime)
@ -39,35 +44,31 @@ internal sealed class RetainedGameplayWindowCommands(RetailUiRuntime? runtime)
public void ToggleOptionsPanel() =>
_runtime?.ToggleWindow(WindowNames.Options);
public void ToggleGameplayOptionsPage() =>
_runtime?.ToggleGameplayOptionsPage();
public void FocusChatEntry() => _runtime?.FocusChatEntry();
public void LogOutCharacter() => _runtime?.LogOutCharacter();
}
internal interface IPlayerModeGameplayCommands
{
bool IsPlayerMode { get; }
void ToggleFlyOrChase();
void TogglePlayerMode();
void ExitPlayerMode();
}
internal sealed class PlayerModeGameplayCommands(
ILocalPlayerModeSource mode,
PlayerModeController controller) : IPlayerModeGameplayCommands
internal sealed class PlayerModeGameplayCommands(PlayerModeController controller)
: IPlayerModeGameplayCommands
{
private readonly ILocalPlayerModeSource _mode = mode
?? throw new ArgumentNullException(nameof(mode));
private readonly PlayerModeController _controller = controller
?? throw new ArgumentNullException(nameof(controller));
public bool IsPlayerMode => _mode.IsPlayerMode;
public void ToggleFlyOrChase() => _controller.ToggleFlyOrChase();
public void TogglePlayerMode() => _controller.Toggle();
public void ExitPlayerMode() => _controller.Exit();
}
internal interface IItemTargetModeCommands
@ -88,37 +89,6 @@ internal sealed class ItemTargetModeCommands(ItemInteractionController items)
public void CancelTargetMode() => _items.CancelTargetMode();
}
internal interface IGameplayCameraModeCommands
{
bool IsFlyMode { get; }
void ExitFlyMode();
}
internal sealed class GameplayCameraModeCommands(CameraController camera)
: IGameplayCameraModeCommands
{
private readonly CameraController _camera = camera
?? throw new ArgumentNullException(nameof(camera));
public bool IsFlyMode => _camera.IsFlyMode;
public void ExitFlyMode() => _camera.ToggleFly();
}
internal interface IGameplayWindowCommands
{
void Close();
}
internal sealed class GameplayWindowCommands(Action close) : IGameplayWindowCommands
{
private readonly Action _close = close
?? throw new ArgumentNullException(nameof(close));
public void Close() => _close();
}
internal interface IGameplayInputCommandTarget
{
bool Handle(InputAction action);
@ -135,10 +105,8 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg
private readonly IRuntimeDiagnosticCommands _diagnostics;
private readonly IPlayerModeGameplayCommands _playerMode;
private readonly IItemTargetModeCommands _targetMode;
private readonly IGameplayCameraModeCommands _camera;
private readonly IGameRuntimeView _runtimeView;
private readonly IRuntimeCombatCommands _combat;
private readonly IGameplayWindowCommands _window;
private readonly Action? _toggleAudioMute;
public GameplayInputCommandController(
@ -146,21 +114,17 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg
IRuntimeDiagnosticCommands diagnostics,
IPlayerModeGameplayCommands playerMode,
IItemTargetModeCommands targetMode,
IGameplayCameraModeCommands camera,
IGameRuntimeView runtimeView,
IRuntimeCombatCommands combat,
IGameplayWindowCommands window,
Action? toggleAudioMute = null)
{
_retained = retained ?? throw new ArgumentNullException(nameof(retained));
_diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics));
_playerMode = playerMode ?? throw new ArgumentNullException(nameof(playerMode));
_targetMode = targetMode ?? throw new ArgumentNullException(nameof(targetMode));
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
_runtimeView = runtimeView
?? throw new ArgumentNullException(nameof(runtimeView));
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
_window = window ?? throw new ArgumentNullException(nameof(window));
_toggleAudioMute = toggleAudioMute;
}
@ -206,10 +170,12 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg
_playerMode.TogglePlayerMode();
return true;
case InputAction.ToggleChatEntry:
// OP9: IDevToolsGameplayCommands.FocusChatInput() retired —
// same shape as AcdreamToggleDebugPanel above (its ImGui
// ChatPanel target was already gone). Tab is still consumed
// here, matching the prior no-op's "handled" contract.
case InputAction.EnterChatMode:
// Physical Tab/Enter are normally consumed by UiRoot before
// the dispatcher. This semantic route is what makes a rebound
// key and headless/UI automation reach that same retained
// chat field.
_retained.FocusChatEntry();
return true;
case InputAction.ToggleOptionsPanel:
// Campaign OP slice OP3 (D1): F11 opens the RETAIL Options
@ -227,6 +193,9 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg
_runtimeView.Generation,
RuntimeCombatCommand.ToggleMode);
return true;
case InputAction.LOGOUT:
_retained.LogOutCharacter();
return true;
case InputAction.EscapeKey:
HandleEscape();
return true;
@ -239,9 +208,7 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg
{
if (_targetMode.IsAnyTargetModeActive)
_targetMode.CancelTargetMode();
else if (_playerMode.IsPlayerMode)
_playerMode.ExitPlayerMode();
else
_window.Close();
_retained.ToggleGameplayOptionsPage();
}
}

View file

@ -9,6 +9,7 @@ internal interface ICombatInputFrameController
{
void Tick();
void HandleMovementInput(InputAction action, ActivationType activation);
void AbortAutomaticAttack();
bool HandleInputAction(InputAction action, ActivationType activation);
}
@ -38,6 +39,11 @@ internal sealed class CombatAttackInputFrameAdapter : ICombatInputFrameControlle
RuntimeInputActivation.Press));
}
public void AbortAutomaticAttack() =>
_owner.HandleCommand(new RuntimeCombatAttackInput(
RuntimeCombatAttackCommand.AbortForMovement,
RuntimeInputActivation.Press));
public bool HandleInputAction(InputAction action, ActivationType activation)
{
RuntimeCombatAttackCommand? command = action switch
@ -52,16 +58,37 @@ internal sealed class CombatAttackInputFrameAdapter : ICombatInputFrameControlle
RuntimeCombatAttackCommand.DecreasePower,
InputAction.CombatIncreaseAttackPower =>
RuntimeCombatAttackCommand.IncreasePower,
InputAction.CombatDecreaseMissileAccuracy =>
RuntimeCombatAttackCommand.DecreasePower,
InputAction.CombatIncreaseMissileAccuracy =>
RuntimeCombatAttackCommand.IncreasePower,
InputAction.CombatAimLow =>
RuntimeCombatAttackCommand.LowAttack,
InputAction.CombatAimMedium =>
RuntimeCombatAttackCommand.MediumAttack,
InputAction.CombatAimHigh =>
RuntimeCombatAttackCommand.HighAttack,
_ => null,
};
if (command is null)
return false;
// A retail Hold binding emits Press once, Hold every input frame, then
// Release on key-up. RuntimeCombatAttackState already measures the
// Press-to-Release interval; forwarding the repeated Hold pulse as a
// Release made Delete/End/PageDown attack on the first frame instead
// of charging until the player released the key.
if (activation == ActivationType.Hold)
return true;
return _owner.HandleCommand(new RuntimeCombatAttackInput(
command.Value,
activation == ActivationType.Press
? RuntimeInputActivation.Press
: RuntimeInputActivation.Release));
activation switch
{
ActivationType.Press => RuntimeInputActivation.Press,
ActivationType.Release => RuntimeInputActivation.Release,
_ => RuntimeInputActivation.Press,
}));
}
}
@ -114,6 +141,8 @@ internal sealed class GameplayInputFrameController
public bool HandlePressedMovementAction(InputAction action) =>
_movement.HandlePressedAction(action);
public void AbortAutomaticAttack() => _combat.AbortAutomaticAttack();
public void QueueRawMouseDelta(float dx, float dy) =>
_mouseLook?.QueueRawDelta(dx, dy);

View file

@ -149,7 +149,9 @@ internal sealed class MouseLookController : IMouseLookInputFrameController
return true;
}
if (action != InputAction.CameraInstantMouseLook)
if (action is not (
InputAction.CameraInstantMouseLook
or InputAction.CameraActivateAlternateMode))
return false;
if (activation == ActivationType.Press)

View file

@ -0,0 +1,129 @@
using AcDream.UI.Abstractions.Input;
namespace AcDream.App.Input;
/// <summary>
/// Verbatim Sept-2013 <c>ACCmdInterp::InitializeEmoteInputActionHash</c>
/// (<c>0x0058B510</c>). <c>ACCmdInterp::OnAction</c>
/// (<c>0x0058B370</c>) resolves one of these input actions and submits the
/// corresponding raw motion through <c>SetMotion</c> with start=true.
/// </summary>
internal static class RetailEmoteMotionTable
{
private const uint EmoteInputMap = 0x10000006u;
private const uint FirstEmoteAction = 0x10000098u;
// Action ids 0x10000098..0x100000EE are contiguous in retail's ActionMap.
// Values come from the named Motion_* globals used by the initializer.
private static readonly uint[] Motions =
[
0x43000118u, // AFKState
0x13000088u, // Akimbo
0x420000F9u, // ATOYOT
0x430000F2u, // AkimboState
0x43000146u, // AtEaseState
0x1300007Au, // Beckon
0x1300007Bu, // BeSeeingYou
0x1300007Cu, // BlowKiss
0x1300007Du, // BowDeep
0x430000ECu, // BowDeepState
0x1300004Cu, // Cheer
0x1300007Eu, // ClapHands
0x430000EDu, // ClapHandsState
0x13000091u, // Cringe
0x430000EEu, // CrossArmsState
0x1300007Fu, // Cry
0x43000117u, // CurtseyState
0x1300014Eu, // DrudgeDance
0x43000141u, // DrudgeDanceState
0x1300014Fu, // HaveASeat
0x43000145u, // HaveASeatState
0x13000089u, // HeartyLaugh
0x13000132u, // Helper
0x13000092u, // Kneel
0x430000F7u, // KneelState
0x1300014Cu, // Knock
0x13000080u, // Laugh
0x430000F6u, // LeanState
0x43000119u, // MeditateState
0x13000082u, // MimeDrink
0x13000081u, // MimeEat
0x130000CBu, // Mock
0x13000083u, // Nod
0x13000147u, // NudgeLeft
0x13000148u, // NudgeRight
0x13000093u, // Plead
0x430000F8u, // PleadState
0x13000084u, // Point
0x430000F0u, // PointState
0x1300014Bu, // PointDown
0x43000140u, // PointDownState
0x13000149u, // PointLeft
0x4300013Du, // PointLeftState
0x1300014Au, // PointRight
0x4300013Eu, // PointRightState
0x43000142u, // PossumState
0x130000CAu, // Pray
0x430000EBu, // PrayState
0x43000143u, // ReadState
0x1300008Au, // Salute
0x430000F3u, // SaluteState
0x1300014Du, // ScanHorizon
0x1300008Bu, // ScratchHead
0x430000F4u, // ScratchHeadState
0x13000079u, // ShakeFist
0x430000EAu, // ShakeFistState
0x13000085u, // ShakeHead
0x13000094u, // Shiver
0x430000EFu, // ShiverState
0x13000095u, // Shoo
0x13000086u, // Shrug
0x4300013Au, // SitState
0x4300013Cu, // SitBackState
0x4300013Bu, // SitCrossleggedState
0x13000096u, // Slouch
0x430000FAu, // SlouchState
0x1300008Cu, // SmackHead
0x43000115u, // SnowAngelState
0x13000097u, // Spit
0x13000098u, // Surrender
0x430000FBu, // SurrenderState
0x4300013Fu, // TalktotheHandState
0x1300008Du, // TapFoot
0x430000F5u, // TapFootState
0x130000CCu, // Teapot
0x43000144u, // ThinkerState
0x13000116u, // WarmHands
0x13000087u, // Wave
0x430000F1u, // WaveState
0x1300008Fu, // WaveLow
0x1300008Eu, // WaveHigh
0x1300009Au, // Winded
0x430000FDu, // WindedState
0x13000099u, // Woah
0x430000FCu, // WoahState
0x13000090u, // YawnStretch
0x1200009Bu, // YMCA
];
public static int Count => Motions.Length;
public static bool TryGetMotion(InputAction action, out uint motion)
{
motion = 0u;
if (!RetailActionIdentityTable.TryGetRetailIdentity(
action,
out var identity)
|| identity.InputMapId != EmoteInputMap)
{
return false;
}
uint index = identity.ActionId - FirstEmoteAction;
if (index >= Motions.Length)
return false;
motion = Motions[index];
return true;
}
}

View file

@ -0,0 +1,601 @@
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
using AcDream.UI.Abstractions.Input;
namespace AcDream.App.Input;
/// <summary>
/// Parser/writer for retail's editable <c>Documents\Asheron's Call\*.keymap</c>
/// PFile text. Only the fourteen user-bindable input maps are replaced when a
/// profile is loaded; acdream-only commands and retail's fixed system/edit/
/// pointer maps remain owned by the host's base <see cref="KeyBindings"/>.
/// </summary>
public static class RetailKeymapFile
{
private static readonly Regex BindingLine = new(
"^(?<action>[A-Za-z0-9_]+)\\s*\\[\\s*\"\"\\s*\\[\\s*"
+ "(?<device>[0-9]+)\\s+(?<control>[A-Za-z0-9_]+)"
+ "(?:\\s+(?<sub>[A-Za-z]+))?\\s*\\]"
+ "(?:\\s+(?<modifier>0x[0-9A-Fa-f]+|[0-9]+))?"
+ "(?:\\s+(?<activation>[A-Za-z]+))?\\s*\\]$",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly (uint Id, string Name)[] GroupOrder =
{
(0x00000004u, "MovementCommands"),
(0x10000007u, "ItemSelectionCommands"),
(0x10000009u, "UICommands"),
(0x1000000Cu, "QuickslotCommands"),
(0x1000000Du, "ToggleChatEntry"),
(0x1000000Au, "ChatCommands"),
(0x10000002u, "Combat"),
(0x10000003u, "MeleeCombat"),
(0x10000004u, "MissileCombat"),
(0x10000005u, "MagicCombat"),
(0x10000006u, "Emotes"),
(0x00000005u, "CameraControls"),
(0x00000006u, "CameraAlternateControls"),
(0x10000008u, "CharacterOptionCommands"),
};
private static readonly IReadOnlyDictionary<string, uint> GroupIds =
GroupOrder.ToDictionary(static group => group.Name, static group => group.Id,
StringComparer.OrdinalIgnoreCase);
// Lazy because the explicit character-option semantic table is declared
// later in this type; field initializers otherwise observe it as null.
private static readonly Lazy<IReadOnlyDictionary<InputAction, string>> ActionNamesHolder =
new(BuildActionNames);
private static readonly Lazy<IReadOnlyDictionary<string, InputAction>> ActionsByFileNameHolder =
new(BuildActionsByFileName);
private static IReadOnlyDictionary<InputAction, string> ActionNames => ActionNamesHolder.Value;
private static IReadOnlyDictionary<string, InputAction> ActionsByFileName =>
ActionsByFileNameHolder.Value;
public static KeyBindings Parse(string text, KeyBindings baseBindings)
{
ArgumentNullException.ThrowIfNull(text);
ArgumentNullException.ThrowIfNull(baseBindings);
var result = new KeyBindings();
foreach (Binding binding in baseBindings.All)
{
if (!RetailActionIdentityTable.ReverseMap.ContainsKey(binding.Action))
result.Add(binding);
}
bool foundBindings = false;
bool inBindings = false;
uint? currentGroup = null;
int lineNumber = 0;
foreach (string rawLine in text.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n'))
{
lineNumber++;
string line = rawLine.Trim();
if (line.Length == 0 || line.StartsWith('#'))
continue;
if (line.Equals("Bindings", StringComparison.OrdinalIgnoreCase))
{
foundBindings = true;
inBindings = true;
currentGroup = null;
continue;
}
if (!inBindings)
continue;
// In the PFile grammar every input-map name is a bare identifier on
// the line before its opening bracket. An unknown map clears the
// user-map context so fixed SystemKeys/EditControls rows are ignored.
if (Regex.IsMatch(line, "^[A-Za-z][A-Za-z0-9_]*$",
RegexOptions.CultureInvariant))
{
currentGroup = GroupIds.TryGetValue(line, out uint groupId)
? groupId
: null;
continue;
}
if (currentGroup is not uint inputMapId || line is "[" or "]")
continue;
Match match = BindingLine.Match(line);
if (!match.Success)
throw new FormatException(
$"Malformed retail key binding at line {lineNumber}: {line}");
string actionName = match.Groups["action"].Value;
if (!ActionsByFileName.TryGetValue(FileIdentity(inputMapId, actionName), out InputAction action))
{
// Several fixed/non-user-bindable controls live inside an
// otherwise editable map (UICommands.EscapeKey/LOGOUT in the
// shipped file). They remain in baseBindings just like the
// wholly fixed maps below the user maps.
continue;
}
string control = match.Groups["control"].Value;
if (!RetailScanCodeMap.TryFromFileControl(control, out uint scan, out uint tokenDevice)
|| !uint.TryParse(match.Groups["device"].Value,
NumberStyles.None, CultureInfo.InvariantCulture, out uint device)
|| device != tokenDevice
|| RetailScanCodeMap.ToSilkKey(scan, device) is not { } key)
{
throw new FormatException(
$"Unsupported retail control '{control}' at line {lineNumber}.");
}
uint fileModifier = 0u;
if (match.Groups["modifier"].Success)
{
string value = match.Groups["modifier"].Value;
NumberStyles style = value.StartsWith("0x", StringComparison.OrdinalIgnoreCase)
? NumberStyles.AllowHexSpecifier
: NumberStyles.None;
string digits = style == NumberStyles.AllowHexSpecifier ? value[2..] : value;
if (!uint.TryParse(digits, style, CultureInfo.InvariantCulture, out fileModifier))
throw new FormatException($"Invalid modifier at line {lineNumber}.");
}
var chord = new KeyChord(key, (ModifierMask)(fileModifier & 0x0Fu), (byte)device);
result.Add(new Binding(
chord,
action,
RetailActionIdentityTable.ActivationFor(inputMapId,
RetailActionIdentityTable.ReverseMap[action].ActionId),
RetailActionIdentityTable.ScopeForInputMap(inputMapId)));
}
if (!foundBindings)
throw new FormatException("The file does not contain a retail Bindings section.");
return result;
}
public static string Write(KeyBindings bindings)
{
ArgumentNullException.ThrowIfNull(bindings);
var output = new StringBuilder(24_000);
output.AppendLine("#Asheron's Call: Throne of Destiny Keymap File")
.AppendLine("#")
.AppendLine("#Generated by acdream's retail Configure Keyboard screen.")
.AppendLine("#This file is compatible with the Sept-2013 retail PFile keymap grammar.")
.AppendLine("#")
.AppendLine("\"User Defined Keymap\" [ 00000000-0000-0000-0000-000000000000 ]")
.AppendLine()
.AppendLine("Devices")
.AppendLine("[")
.AppendLine(" Keyboard [ GUID_SysKeyboard ]")
.AppendLine(" Mouse [ GUID_SysMouse ]")
.AppendLine(" Virtual [ GUID_Virtual ]")
.AppendLine("]")
.AppendLine()
.AppendLine("MetaKeys")
.AppendLine("[")
.AppendLine(" 1 [ 0 DIK_LSHIFT ]")
.AppendLine(" 2 [ 0 DIK_LCONTROL ]")
.AppendLine(" 2 [ 0 DIK_RCONTROL ]")
.AppendLine(" 3 [ 0 DIK_LMENU ]")
.AppendLine(" 3 [ 0 DIK_RALT ]")
.AppendLine(" 4 [ 0 DIK_LWIN ]")
.AppendLine(" 4 [ 0 DIK_RWIN ]")
.AppendLine(" 5 [ 1 DIMOFS_BUTTON3 ]")
.AppendLine(" 6 [ 1 DIMOFS_BUTTON4 ]")
.AppendLine("]")
.AppendLine()
.AppendLine("Bindings")
.AppendLine("[");
foreach ((uint inputMapId, string groupName) in GroupOrder)
{
output.Append(" ").AppendLine(groupName).AppendLine(" [");
foreach (((uint InputMapId, uint ActionId) identity, InputAction action) in
RetailActionIdentityTable.Map
.Where(pair => pair.Key.InputMapId == inputMapId)
.OrderBy(static pair => pair.Key.ActionId))
{
foreach (Binding binding in bindings.ForAction(action))
{
if (!RetailScanCodeMap.TryToFileControl(binding.Chord, out string control))
{
throw new InvalidOperationException(
$"{binding.Chord} cannot be represented by the retail DirectInput keymap.");
}
output.Append(" ").Append(ActionNames[action])
.Append(" [ \"\" [ ").Append(binding.Chord.Device)
.Append(' ').Append(control).Append(" ]");
uint modifier = (uint)binding.Chord.Modifiers & 0x0Fu;
if (modifier != 0u)
output.Append(" 0x").Append(modifier.ToString("X8", CultureInfo.InvariantCulture));
output.AppendLine(" ]");
}
}
// Bare Escape is a fixed MasterInputMap control rather than one of
// the user-bindable ActionMap rows (LOGOUT is a normal row and was
// emitted above). Keep it in exported files so the Sept-2013 client
// retains its priority Escape ladder when opening our profile.
if (inputMapId == 0x10000009u)
output.AppendLine(" EscapeKey [ \"\" [ 0 DIK_ESCAPE ] ]");
output.AppendLine(" ]").AppendLine();
}
// Retail's fixed maps are included so a file can also be opened by the
// 2013 client. They are deliberately not imported into the 306-row GUI.
output.Append(FixedRetailMaps);
output.AppendLine("]");
return output.ToString();
}
private static IReadOnlyDictionary<InputAction, string> BuildActionNames()
{
var names = new Dictionary<InputAction, string>();
foreach (InputAction action in RetailActionIdentityTable.ReverseMap.Keys)
names[action] = FileActionName(action);
return names;
}
private static IReadOnlyDictionary<string, InputAction> BuildActionsByFileName()
{
var actions = new Dictionary<string, InputAction>(StringComparer.OrdinalIgnoreCase);
foreach (((uint InputMapId, uint ActionId) identity, InputAction action) in
RetailActionIdentityTable.Map)
{
actions.Add(FileIdentity(identity.InputMapId, ActionNames[action]), action);
}
return actions;
}
private static string FileIdentity(uint inputMapId, string actionName) =>
$"{inputMapId:X8}:{actionName}";
private static string GroupName(uint inputMapId) =>
GroupOrder.First(group => group.Id == inputMapId).Name;
private static string FileActionName(InputAction action)
{
if (CharacterOptionNames.TryGetValue(action, out string? characterOption))
return characterOption;
if (action == InputAction.SelectionPlaceInInventory) return "SelectionPickUp";
if (action == InputAction.UseSelected) return "USE";
string name = action.ToString();
if (name.StartsWith("CameraAlternate", StringComparison.Ordinal))
return "Camera" + name["CameraAlternate".Length..];
if (!name.StartsWith("Emote", StringComparison.Ordinal))
return name;
string emote = name["Emote".Length..];
return emote switch
{
"AfkState" => "AFKState",
"AToyotState" => "ATOYOT",
"MimeDrinking" => "MimeDrink",
"MimeEating" => "MimeEat",
"TalkToTheHandState" => "TalktotheHandState",
"YawnAndStretch" => "YawnStretch",
"Ymca" => "YMCA",
_ => emote,
};
}
private static readonly IReadOnlyDictionary<InputAction, string> CharacterOptionNames =
new Dictionary<InputAction, string>
{
[InputAction.ToggleCharacterOptionAutoRepeatAttack] = "AutoRepeatAttacks",
[InputAction.ToggleCharacterOptionIgnoreAllegianceRequests] = "IgnoreAllegianceRequests",
[InputAction.ToggleCharacterOptionIgnoreFellowshipRequests] = "IgnoreFellowshipRequests",
[InputAction.ToggleCharacterOptionIgnoreTradeRequests] = "IgnoreTradeRequests",
[InputAction.ToggleCharacterOptionPersistentAtDay] = "PersistentAtDay",
[InputAction.ToggleCharacterOptionAllowGive] = "LetPlayersGiveYouItems",
[InputAction.ToggleCharacterOptionViewCombatTarget] = "AutoTrackCombatTargets",
[InputAction.ToggleCharacterOptionShowTooltips] = "DisplayTooltips",
[InputAction.ToggleCharacterOptionUseDeception] = "AttemptToDeceivePlayers",
[InputAction.ToggleCharacterOptionToggleRun] = "RunAsDefaultMovement",
[InputAction.ToggleCharacterOptionStayInChatMode] = "StayInChatModeAfterSend",
[InputAction.ToggleCharacterOptionAdvancedCombatUi] = "AdvancedCombatInterface",
[InputAction.ToggleCharacterOptionAutoTarget] = "AutoTarget",
[InputAction.ToggleCharacterOptionVividTargetingIndicator] = "VividTargetIndicator",
[InputAction.ToggleCharacterOptionFellowshipShareXp] = "ShareFellowshipXP",
[InputAction.ToggleCharacterOptionAcceptLootPermits] = "AcceptCorpseLooting",
[InputAction.ToggleCharacterOptionFellowshipShareLoot] = "ShareFellowshipLoot",
[InputAction.ToggleCharacterOptionFellowshipAutoAcceptRequests] = "AutomaticallyAcceptFellowshipRequests",
[InputAction.ToggleCharacterOptionCoordinatesOnRadar] = "ShowRadarCoordinates",
[InputAction.ToggleCharacterOptionSpellDuration] = "ShowSpellDurations",
[InputAction.ToggleCharacterOptionDisableHouseRestrictionEffects] = "DisableHouseEffect",
[InputAction.ToggleCharacterOptionDragItemOnPlayerOpensSecureTrade] = "DragItemOnPlayerOpensSecureTrade",
[InputAction.ToggleCharacterOptionDisplayAllegianceLogonNotifications] = "DisplayAllegianceLogonNotifications",
[InputAction.ToggleCharacterOptionUseChargeAttack] = "UseChargeAttack",
[InputAction.ToggleCharacterOptionUseCraftSuccessDialog] = "ToggleCraftingChanceOfSuccessDialog",
[InputAction.ToggleCharacterOptionListenToAllegianceChat] = "AllegianceChat",
[InputAction.ToggleCharacterOptionDisplayDateOfBirth] = "DisplayDateOfBirth",
[InputAction.ToggleCharacterOptionDisplayAge] = "DisplayAge",
[InputAction.ToggleCharacterOptionDisplayChessRank] = "DisplayChessRank",
[InputAction.ToggleCharacterOptionDisplayFishingSkill] = "Fishing",
[InputAction.ToggleCharacterOptionDisplayNumberDeaths] = "DisplayNumberDeaths",
[InputAction.ToggleCharacterOptionDisplayTimeStamps] = "DisplayTimeStamps",
[InputAction.ToggleCharacterOptionSalvageMultiple] = "SalvageMultiple",
[InputAction.ToggleCharacterOptionListenToGeneralChat] = "GeneralChat",
[InputAction.ToggleCharacterOptionListenToTradeChat] = "TradeChat",
[InputAction.ToggleCharacterOptionListenToLfgChat] = "LFGChat",
[InputAction.ToggleCharacterOptionListenToRoleplayChat] = "RoleplayChat",
[InputAction.ToggleCharacterOptionDisplayNumberCharacterTitles] = "DisplayNumberCharacterTitles",
[InputAction.ToggleCharacterOptionMainPackPreferred] = "MainPackPreferred",
[InputAction.ToggleCharacterOptionLeadMissileTargets] = "LeadMissileTargets",
[InputAction.ToggleCharacterOptionUseFastMissiles] = "UseFastMissiles",
[InputAction.ToggleCharacterOptionFilterLanguage] = "FilterLanguage",
[InputAction.ToggleCharacterOptionConfirmVolatileRareUse] = "ConfirmVolatileRareUse",
[InputAction.ToggleCharacterOptionListenToSocietyChat] = "SocietyChat",
[InputAction.ToggleCharacterOptionShowHelm] = "ShowHelm",
[InputAction.ToggleCharacterOptionDisableDistanceFog] = "DisableDistanceFog",
[InputAction.ToggleCharacterOptionShowCloak] = "ShowCloak",
[InputAction.ToggleCharacterOptionSideBySideVitals] = "SideBySideVitals",
};
private const string FixedRetailMaps = """
TargetedUsage
[
SelectLeft [ "" [ 1 DIMOFS_BUTTON0 ] ]
SelectRight [ "" [ 1 DIMOFS_BUTTON1 ] ]
]
SystemKeys
[
AltEnter [ "" [ 0 DIK_RETURN ] 0x00000004 ]
AltTab [ "" [ 0 DIK_TAB ] 0x00000004 ]
AltF4 [ "" [ 0 DIK_F4 ] 0x00000004 ]
CtrlShiftEsc [ "" [ 0 DIK_ESCAPE ] 0x00000003 ]
]
MouseCommands
[
PointerX [ "" [ 1 DIMOFS_X ] 0x00000000 Analog ]
PointerY [ "" [ 1 DIMOFS_Y ] 0x00000000 Analog ]
SelectLeft [ "" [ 1 DIMOFS_BUTTON0 ] ]
SelectRight [ "" [ 1 DIMOFS_BUTTON1 ] ]
SelectMid [ "" [ 1 DIMOFS_BUTTON2 ] ]
SelectDblLeft [ "" [ 1 DIMOFS_BUTTON0 ] 0x00000000 MouseDblClick ]
SelectDblRight [ "" [ 1 DIMOFS_BUTTON1 ] 0x00000000 MouseDblClick ]
SelectDblMid [ "" [ 1 DIMOFS_BUTTON2 ] 0x00000000 MouseDblClick ]
]
ScrollableControls
[
ScrollUp [ "" [ 1 DIMOFS_Z AxisPositive ] ]
ScrollDown [ "" [ 1 DIMOFS_Z AxisNegative ] ]
ScrollUp [ "" [ 0 DIK_UPARROW ] 0x00000002 ]
ScrollDown [ "" [ 0 DIK_DOWNARROW ] 0x00000002 ]
]
EditControls
[
CursorCharLeft [ "" [ 0 DIK_LEFT ] ]
CursorCharRight [ "" [ 0 DIK_RIGHTARROW ] ]
CursorPreviousLine [ "" [ 0 DIK_UPARROW ] ]
CursorNextLine [ "" [ 0 DIK_DOWNARROW ] ]
CursorPreviousPage [ "" [ 0 DIK_PGUP ] ]
CursorNextPage [ "" [ 0 DIK_PGDN ] ]
CursorWordLeft [ "" [ 0 DIK_LEFT ] 0x00000002 ]
CursorWordRight [ "" [ 0 DIK_RIGHTARROW ] 0x00000002 ]
CursorStartOfLine [ "" [ 0 DIK_HOME ] ]
CursorStartOfDocument [ "" [ 0 DIK_HOME ] 0x00000002 ]
CursorEndOfLine [ "" [ 0 DIK_END ] ]
CursorEndOfDocument [ "" [ 0 DIK_END ] 0x00000002 ]
EscapeKey [ "" [ 0 DIK_ESCAPE ] ]
AcceptInput [ "" [ 0 DIK_RETURN ] ]
DeleteKey [ "" [ 0 DIK_DELETE ] ]
BackspaceKey [ "" [ 0 DIK_BACK ] ]
]
CopyAndPasteControls
[
CopyText [ "" [ 0 DIK_C ] 0x00000002 ]
CopyText [ "" [ 0 DIK_INSERT ] 0x00000002 ]
CutText [ "" [ 0 DIK_X ] 0x00000002 ]
CutText [ "" [ 0 DIK_DELETE ] 0x00000001 ]
PasteText [ "" [ 0 DIK_V ] 0x00000002 ]
PasteText [ "" [ 0 DIK_INSERT ] 0x00000001 ]
]
DialogBoxes
[
EscapeKey [ "" [ 0 DIK_ESCAPE ] ]
AcceptInput [ "" [ 0 DIK_RETURN ] ]
]
""";
}
public enum RetailKeymapSaveStatus
{
Saved,
Exists,
ReadOnly,
InvalidName,
Failed,
}
public readonly record struct RetailKeymapSaveResult(
RetailKeymapSaveStatus Status,
string FileName,
string? Error = null);
/// <summary>
/// Owns retail's active-profile preference and <c>*.keymap</c> directory.
/// The profile selector lives beside acdream's portable JSON mirror; profile
/// files live in retail's Documents/Asheron's Call folder.
/// </summary>
public sealed class RetailKeymapProfileStore
{
public const string DefaultFileName = "acdream.keymap";
private readonly string _jsonPath;
private readonly string _directory;
private readonly string _selectorPath;
public RetailKeymapProfileStore(string jsonPath, string? keymapDirectory = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(jsonPath);
_jsonPath = Path.GetFullPath(jsonPath);
string configDirectory = Path.GetDirectoryName(_jsonPath)
?? Directory.GetCurrentDirectory();
_selectorPath = Path.Combine(configDirectory, "active-keymap.txt");
_directory = keymapDirectory ?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments),
"Asheron's Call");
}
public string DirectoryPath => _directory;
public string CurrentFileName
{
get
{
try
{
if (File.Exists(_selectorPath))
{
string selected = NormalizeFileName(File.ReadAllText(_selectorPath));
if (selected.Length != 0) return selected;
}
}
catch (Exception failure)
{
Console.WriteLine($"keymap: active-profile preference could not be read: {failure.Message}");
}
return DefaultFileName;
}
}
public IReadOnlyList<string> ListFiles()
{
try
{
if (!Directory.Exists(_directory)) return Array.Empty<string>();
return Directory.EnumerateFiles(_directory, "*.keymap", SearchOption.TopDirectoryOnly)
.Select(Path.GetFileName)
.Where(static name => !string.IsNullOrEmpty(name))
.Cast<string>()
.OrderBy(static name => name, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
catch (Exception failure)
{
Console.WriteLine($"keymap: profile list failed: {failure.Message}");
return Array.Empty<string>();
}
}
public bool TryLoad(
string fileName,
KeyBindings baseBindings,
out KeyBindings bindings,
out string? error)
{
bindings = baseBindings;
error = null;
string normalized = NormalizeFileName(fileName);
if (normalized.Length == 0)
{
error = "The keymap filename is invalid.";
return false;
}
try
{
string text = File.ReadAllText(Path.Combine(_directory, normalized));
bindings = RetailKeymapFile.Parse(text, baseBindings);
WriteSelector(normalized);
return true;
}
catch (Exception failure)
{
error = failure.Message;
return false;
}
}
public RetailKeymapSaveResult Save(
string fileName,
KeyBindings bindings,
bool overwrite)
{
string normalized = NormalizeFileName(fileName);
if (normalized.Length == 0)
return new(RetailKeymapSaveStatus.InvalidName, string.Empty);
string path = Path.Combine(_directory, normalized);
try
{
if (File.Exists(path))
{
if (!overwrite)
return new(RetailKeymapSaveStatus.Exists, normalized);
if ((File.GetAttributes(path) & FileAttributes.ReadOnly) != 0)
return new(RetailKeymapSaveStatus.ReadOnly, normalized);
}
Directory.CreateDirectory(_directory);
AtomicWrite(path, RetailKeymapFile.Write(bindings));
WriteSelector(normalized);
return new(RetailKeymapSaveStatus.Saved, normalized);
}
catch (UnauthorizedAccessException failure)
{
return new(RetailKeymapSaveStatus.ReadOnly, normalized, failure.Message);
}
catch (Exception failure)
{
return new(RetailKeymapSaveStatus.Failed, normalized, failure.Message);
}
}
public RetailKeymapSaveResult SaveActive(KeyBindings bindings) =>
Save(CurrentFileName, bindings, overwrite: true);
public static KeyBindings LoadActiveOrJson(
string jsonPath,
out string profileName)
{
KeyBindings fallback = KeyBindings.LoadOrDefault(jsonPath);
var store = new RetailKeymapProfileStore(jsonPath);
profileName = store.CurrentFileName;
string profilePath = Path.Combine(store.DirectoryPath, profileName);
if (!File.Exists(profilePath)) return fallback;
if (store.TryLoad(profileName, fallback, out KeyBindings loaded, out string? error))
return loaded;
Console.WriteLine($"keymap: '{profileName}' could not be loaded; using JSON/defaults: {error}");
return fallback;
}
public static string NormalizeFileName(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return string.Empty;
string trimmed = value.Trim();
if (!string.Equals(trimmed, Path.GetFileName(trimmed), StringComparison.Ordinal))
return string.Empty;
if (trimmed.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0)
return string.Empty;
return trimmed.EndsWith(".keymap", StringComparison.OrdinalIgnoreCase)
? trimmed
: trimmed + ".keymap";
}
private void WriteSelector(string fileName)
{
string? directory = Path.GetDirectoryName(_selectorPath);
if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory);
AtomicWrite(_selectorPath, fileName + Environment.NewLine);
}
private static void AtomicWrite(string path, string content)
{
string temp = path + ".tmp-" + Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture);
try
{
File.WriteAllText(temp, content, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
File.Move(temp, path, overwrite: true);
}
finally
{
if (File.Exists(temp)) File.Delete(temp);
}
}
}