Phase 3: input mapping + output routing (RioJoy.Core.Mapping)

Port the iRIO decode and Press_V2/Release_V2 routing into testable C#:

- Mapping/: RioMapEntry decodes the 16-bit map word (lamp/mouse/hat/joy/extended/
  alt/ctrl/shift flags + value) and resolves the routing Kind by the legacy
  precedence (joy+hat+mouse => RIO command; none => keyboard; else joy>hat>mouse).
  RioAddress translates button/keypad events to table addresses (+0x50/+0x60
  keypad offsets); RioInputMap is the 112-entry per-profile table replacing the
  hard-coded iRIO[].
- InputRouter ports Press_V2/Release_V2: modifier press/release ordering,
  scancode keys, joystick buttons, POV hat, mouse move/click (deltas corrected
  per PROTOCOL.md), RIO-command dispatch, and lamp feedback (bright on press, dim
  on release; RIO commands carry none). InitializeLamps() dims all lamp entries.
- Output is split behind sink interfaces (IInputSink/IJoystickSink/ILampSink/
  IRioCommandSink) so routing is pure + unit-tested; Output/SendInputSink is the
  real SendInput keyboard/mouse adapter (scancode injection).
- tests: 30 new xUnit tests (84 total) for entry decode, address translation,
  and router routing/precedence/lamp/modifier ordering via a recording sink.

The joystick sink's real adapter (HID feeder -> RioGamepad via DeviceIoControl)
is blocked on the Phase 1 driver; routing already targets IJoystickSink.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-06-26 15:10:06 -05:00
co-authored by Claude Opus 4.8
parent b3cb764f4d
commit cc64d241c9
12 changed files with 898 additions and 6 deletions
+152
View File
@@ -0,0 +1,152 @@
using RioJoy.Core.Protocol;
namespace RioJoy.Core.Mapping;
/// <summary>
/// Decodes RIO button/keypad press &amp; release events through a
/// <see cref="RioInputMap"/> and routes them to the keyboard/mouse, joystick,
/// lamp, and RIO-command sinks. Faithful port of <c>Press_V2</c>/<c>Release_V2</c>
/// (riovjoy2.cpp#L1944); see docs/PROTOCOL.md §5.
///
/// <para>Routing precedence and lamp feedback follow the legacy exactly: a RIO
/// command runs and returns (no lamp); keyboard/joystick/hat/mouse all apply lamp
/// feedback when the entry has the lamp flag (bright on press, dim on release).
/// Modifier keys are pressed before the key and released after it, in reverse.</para>
/// </summary>
public sealed class InputRouter
{
private readonly RioInputMap _map;
private readonly IInputSink _input;
private readonly IJoystickSink _joystick;
private readonly ILampSink _lamp;
private readonly IRioCommandSink _commands;
private readonly int _mouseStep;
public InputRouter(
RioInputMap map,
IInputSink input,
IJoystickSink joystick,
ILampSink lamp,
IRioCommandSink commands,
int mouseMoveStep = 50)
{
_map = map ?? throw new ArgumentNullException(nameof(map));
_input = input ?? throw new ArgumentNullException(nameof(input));
_joystick = joystick ?? throw new ArgumentNullException(nameof(joystick));
_lamp = lamp ?? throw new ArgumentNullException(nameof(lamp));
_commands = commands ?? throw new ArgumentNullException(nameof(commands));
_mouseStep = mouseMoveStep;
}
/// <summary>
/// Set every lighted button to its idle (dim) state. The legacy app does this
/// at startup for all entries with the lamp flag; call it on profile load.
/// </summary>
public void InitializeLamps()
{
for (int address = 0; address < _map.Count; address++)
{
if (_map[address].HasLamp)
_lamp.SetLamp(address, RioLampState.SolidDim);
}
}
/// <summary>Handle a button/keypad press at the given RIO address.</summary>
public void Press(int address)
{
RioMapEntry entry = _map[address];
if (entry.IsUnmapped)
return;
switch (entry.Kind)
{
case RioRouteKind.Keyboard:
if (entry.Shift) _input.KeyDown(VirtualKey.Shift, extended: false);
if (entry.Ctrl) _input.KeyDown(VirtualKey.Control, extended: false);
if (entry.Alt) _input.KeyDown(VirtualKey.Menu, extended: false);
_input.KeyDown(entry.Value, entry.Extended);
break;
case RioRouteKind.RioCommand:
_commands.Execute((RioCommandCode)entry.Value);
return; // RIO commands are not outputs and carry no lamp feedback
case RioRouteKind.Joystick:
_joystick.SetButton(entry.Value, pressed: true);
break;
case RioRouteKind.Hat:
_joystick.SetHat((RioHat)entry.Value);
break;
case RioRouteKind.Mouse:
MousePress((MouseAction)entry.Value);
break;
}
if (entry.HasLamp)
_lamp.SetLamp(address, RioLampState.SolidBright);
}
/// <summary>Handle a button/keypad release at the given RIO address.</summary>
public void Release(int address)
{
RioMapEntry entry = _map[address];
if (entry.IsUnmapped)
return;
switch (entry.Kind)
{
case RioRouteKind.Keyboard:
_input.KeyUp(entry.Value, entry.Extended);
// Release modifiers in reverse order.
if (entry.Alt) _input.KeyUp(VirtualKey.Menu, extended: false);
if (entry.Ctrl) _input.KeyUp(VirtualKey.Control, extended: false);
if (entry.Shift) _input.KeyUp(VirtualKey.Shift, extended: false);
break;
case RioRouteKind.RioCommand:
return; // nothing happens on release; no lamp
case RioRouteKind.Joystick:
_joystick.SetButton(entry.Value, pressed: false);
break;
case RioRouteKind.Hat:
_joystick.SetHat(RioHat.Centered);
break;
case RioRouteKind.Mouse:
MouseRelease((MouseAction)entry.Value);
break;
}
if (entry.HasLamp)
_lamp.SetLamp(address, RioLampState.SolidDim);
}
// Mouse move deltas corrected vs. the legacy (which mixed dx/dy); the action
// codes are the contract (docs/PROTOCOL.md §5 ⚠️). Moves act on press only.
private void MousePress(MouseAction action)
{
switch (action)
{
case MouseAction.MoveUp: _input.MouseMove(0, -_mouseStep); break;
case MouseAction.MoveRight: _input.MouseMove(_mouseStep, 0); break;
case MouseAction.MoveDown: _input.MouseMove(0, _mouseStep); break;
case MouseAction.MoveLeft: _input.MouseMove(-_mouseStep, 0); break;
case MouseAction.LeftClick: _input.MouseButton(MouseButton.Left, down: true); break;
case MouseAction.RightClick: _input.MouseButton(MouseButton.Right, down: true); break;
}
}
private void MouseRelease(MouseAction action)
{
switch (action)
{
case MouseAction.LeftClick: _input.MouseButton(MouseButton.Left, down: false); break;
case MouseAction.RightClick: _input.MouseButton(MouseButton.Right, down: false); break;
// Move actions have no release behavior.
}
}
}
+96
View File
@@ -0,0 +1,96 @@
namespace RioJoy.Core.Mapping;
/// <summary>Mouse actions encoded in a mouse entry's low byte (docs/PROTOCOL.md §5).</summary>
public enum MouseAction : byte
{
MoveUp = 0,
MoveRight = 1,
MoveDown = 2,
MoveLeft = 3,
LeftClick = 4,
RightClick = 5,
}
/// <summary>Which mouse button a click action targets.</summary>
public enum MouseButton
{
Left,
Right,
}
/// <summary>Discrete 4-way POV hat position. <see cref="Centered"/> = released.</summary>
public enum RioHat
{
Centered = -1,
Up = 0,
Right = 1,
Down = 2,
Left = 3,
}
/// <summary>
/// Internal RIO commands (the low byte when an entry is a RIO command). Port of
/// the <c>RIOcmd</c> switch (riovjoy2.cpp#L1852).
/// </summary>
public enum RioCommandCode : byte
{
ResetAllAxes = 0,
ResetThrottle = 1,
ResetLeftPedal = 2,
ResetRightPedal = 3,
ResetVerticalJoystick = 4,
ResetHorizontalJoystick = 5,
GeneralReset = 6,
RequestVersionAndCheck = 7,
ToggleRawAxesReadout = 8,
TogglePollRateReadout = 9,
}
/// <summary>
/// Keyboard + mouse output (the <c>SendInput</c> target). The router holds the
/// ordering logic and emits virtual-key codes; a sink implementation maps VK to
/// scancode and calls <c>SendInput</c>. Keys are addressed by Windows virtual-key
/// code; modifiers are sent as ordinary keys (VK_SHIFT/CONTROL/MENU).
/// </summary>
public interface IInputSink
{
void KeyDown(byte virtualKey, bool extended);
void KeyUp(byte virtualKey, bool extended);
/// <summary>Relative mouse move by (<paramref name="dx"/>, <paramref name="dy"/>) pixels.</summary>
void MouseMove(int dx, int dy);
void MouseButton(MouseButton button, bool down);
}
/// <summary>
/// Virtual joystick output (the HID feeder → RioGamepad driver in Phase 1).
/// Button numbers are 0-based into the 96-button report; the hat is the single POV.
/// </summary>
public interface IJoystickSink
{
void SetButton(int button, bool pressed);
void SetHat(RioHat position);
}
/// <summary>Lamp (lighted-button) feedback, sent back to the RIO over serial.</summary>
public interface ILampSink
{
void SetLamp(int address, byte lampState);
}
/// <summary>Handles internal RIO commands routed from the input map.</summary>
public interface IRioCommandSink
{
void Execute(RioCommandCode command);
}
/// <summary>Well-known Windows virtual-key codes used by the router for modifiers.</summary>
public static class VirtualKey
{
public const byte Shift = 0x10; // VK_SHIFT
public const byte Control = 0x11; // VK_CONTROL
public const byte Menu = 0x12; // VK_MENU (ALT)
}
+51
View File
@@ -0,0 +1,51 @@
namespace RioJoy.Core.Mapping;
/// <summary>
/// Translates raw RIO events into an <c>iRIO</c> table address. The 72 digital
/// buttons map to <c>0x000x47</c> directly; keypad 0 is offset by <c>0x50</c>
/// and keypad 1 by <c>0x60</c> (port of <c>KeypadEvent</c>, riovjoy2.cpp#L1185);
/// see docs/PROTOCOL.md §5.
/// </summary>
public static class RioAddress
{
/// <summary>Number of digital button inputs (addresses 0x000x47).</summary>
public const int ButtonCount = 72;
/// <summary>Address offset added to keypad-0 key indices.</summary>
public const int Keypad0Base = 0x50;
/// <summary>Address offset added to keypad-1 key indices.</summary>
public const int Keypad1Base = 0x60;
/// <summary>Highest valid address (keypad 1, index 0x0F).</summary>
public const int MaxAddress = Keypad1Base + 0x0F; // 0x6F
/// <summary>Size of the <c>iRIO</c> table (addresses 0x00..0x6F inclusive).</summary>
public const int TableSize = MaxAddress + 1; // 112
/// <summary>Address for a digital button event (<paramref name="index"/> 0x000x47).</summary>
public static int FromButton(byte index)
{
if (index >= ButtonCount)
throw new ArgumentOutOfRangeException(
nameof(index), $"Button index must be 0..{ButtonCount - 1}.");
return index;
}
/// <summary>
/// Address for a keypad key event: <paramref name="pad"/> 0 or 1,
/// <paramref name="index"/> 0x000x0F. Mirrors the legacy offsets (+0x50 / +0x60).
/// </summary>
public static int FromKeypad(byte pad, byte index)
{
if (index > 0x0F)
throw new ArgumentOutOfRangeException(nameof(index), "Keypad index must be 0..0x0F.");
return pad switch
{
0 => Keypad0Base + index,
1 => Keypad1Base + index,
_ => throw new ArgumentOutOfRangeException(nameof(pad), "Keypad must be 0 or 1."),
};
}
}
+48
View File
@@ -0,0 +1,48 @@
namespace RioJoy.Core.Mapping;
/// <summary>
/// The per-profile <c>iRIO</c> table: 112 entries indexed by RIO address
/// (<c>0x00..0x6F</c>), each a <see cref="RioMapEntry"/>. Replaces the legacy
/// hard-coded <c>iRIO[]</c> array; a profile supplies the values (the legacy
/// defaults / <c>RIO.ini</c> become one importable profile in Phase 5/7).
/// </summary>
public sealed class RioInputMap
{
private readonly ushort[] _entries = new ushort[RioAddress.TableSize];
/// <summary>Create an empty map (all entries unmapped).</summary>
public RioInputMap() { }
/// <summary>Create a map from raw 16-bit words indexed by address.</summary>
public RioInputMap(IReadOnlyDictionary<int, ushort> entries)
{
ArgumentNullException.ThrowIfNull(entries);
foreach ((int address, ushort raw) in entries)
this[address] = new RioMapEntry(raw);
}
/// <summary>Number of addressable slots (<see cref="RioAddress.TableSize"/>).</summary>
public int Count => _entries.Length;
/// <summary>Get or set the entry at a RIO address (0x00..0x6F).</summary>
public RioMapEntry this[int address]
{
get
{
ValidateAddress(address);
return new RioMapEntry(_entries[address]);
}
set
{
ValidateAddress(address);
_entries[address] = value.Raw;
}
}
private static void ValidateAddress(int address)
{
if (address < 0 || address >= RioAddress.TableSize)
throw new ArgumentOutOfRangeException(
nameof(address), $"Address must be 0..0x{RioAddress.MaxAddress:X2}.");
}
}
+92
View File
@@ -0,0 +1,92 @@
namespace RioJoy.Core.Mapping;
/// <summary>How a decoded <see cref="RioMapEntry"/> should be routed.</summary>
public enum RioRouteKind
{
/// <summary>Send a keyboard key (with optional SHIFT/CTRL/ALT modifiers).</summary>
Keyboard,
/// <summary>Press a virtual joystick button.</summary>
Joystick,
/// <summary>Set the virtual POV hat direction.</summary>
Hat,
/// <summary>Perform a mouse action (move or click).</summary>
Mouse,
/// <summary>
/// An internal RIO command (axis reset, recalibrate, status, toggle) — not an
/// output to the host. Encoded as joy+hat+mouse all set (<c>0x7000</c>).
/// </summary>
RioCommand,
}
/// <summary>
/// One entry of the <c>iRIO</c> map: a 16-bit word whose high byte is routing
/// flags and low byte is the payload (VK code / button# / hat direction / mouse
/// action). Faithful decode of <c>Press_V2</c>/<c>Release_V2</c>
/// (riovjoy2.cpp#L1944); see docs/PROTOCOL.md §5.
/// </summary>
public readonly struct RioMapEntry
{
// High-byte routing flags.
private const ushort FlagHasLamp = 0x8000;
private const ushort FlagMouse = 0x4000;
private const ushort FlagHat = 0x2000;
private const ushort FlagJoy = 0x1000;
private const ushort FlagExtended = 0x0800;
private const ushort FlagAlt = 0x0400;
private const ushort FlagCtrl = 0x0200;
private const ushort FlagShift = 0x0100;
private const ushort RioCommandMask = FlagJoy | FlagHat | FlagMouse; // 0x7000
/// <summary>The raw 16-bit map word.</summary>
public ushort Raw { get; }
public RioMapEntry(ushort raw) => Raw = raw;
/// <summary>This input drives a lighted button (lamp feedback on press/release).</summary>
public bool HasLamp => (Raw & FlagHasLamp) != 0;
/// <summary>Keyboard: apply <c>KEYEVENTF_EXTENDEDKEY</c> to the key.</summary>
public bool Extended => (Raw & FlagExtended) != 0;
public bool Alt => (Raw & FlagAlt) != 0;
public bool Ctrl => (Raw & FlagCtrl) != 0;
public bool Shift => (Raw & FlagShift) != 0;
/// <summary>Low byte: VK code, joystick button, hat direction, or mouse action.</summary>
public byte Value => (byte)(Raw & 0xFF);
private bool IsJoyFlag => (Raw & FlagJoy) != 0;
private bool IsHatFlag => (Raw & FlagHat) != 0;
private bool IsMouseFlag => (Raw & FlagMouse) != 0;
/// <summary>
/// The routing destination, applying the legacy precedence: all three of
/// joy/hat/mouse set ⇒ RIO command; none set ⇒ keyboard; otherwise joy, then
/// hat, then mouse.
/// </summary>
public RioRouteKind Kind
{
get
{
if ((Raw & RioCommandMask) == RioCommandMask)
return RioRouteKind.RioCommand;
if (!IsJoyFlag && !IsHatFlag && !IsMouseFlag)
return RioRouteKind.Keyboard;
if (IsJoyFlag)
return RioRouteKind.Joystick;
if (IsHatFlag)
return RioRouteKind.Hat;
return RioRouteKind.Mouse;
}
}
/// <summary>True if this entry has no mapping (a "do nothing" slot).</summary>
public bool IsUnmapped => Raw == 0;
public override string ToString() => $"0x{Raw:X4} ({Kind} {Value})";
}
+137
View File
@@ -0,0 +1,137 @@
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using RioJoy.Core.Mapping;
using MouseButtonId = RioJoy.Core.Mapping.MouseButton;
namespace RioJoy.Core.Output;
/// <summary>
/// <see cref="IInputSink"/> backed by the Win32 <c>SendInput</c> API. Keys are
/// sent by <b>scancode</b> (VK → scancode via <c>MapVirtualKey</c>), matching the
/// legacy <c>Key</c> function (riovjoy2.cpp#L2055), so games that read raw
/// scancodes (most do) see the keypress. Targets the interactive session, which
/// is why RIOJoy is a tray app rather than a service (see docs/PLAN.md risks).
/// </summary>
[SupportedOSPlatform("windows")]
public sealed class SendInputSink : IInputSink
{
public void KeyDown(byte virtualKey, bool extended) => SendKey(virtualKey, extended, keyUp: false);
public void KeyUp(byte virtualKey, bool extended) => SendKey(virtualKey, extended, keyUp: true);
public void MouseMove(int dx, int dy)
{
var input = new INPUT
{
type = INPUT_MOUSE,
U = { mi = { dx = dx, dy = dy, dwFlags = MOUSEEVENTF_MOVE } },
};
Send(input);
}
public void MouseButton(MouseButton button, bool down)
{
uint flags = (button, down) switch
{
(MouseButtonId.Left, true) => MOUSEEVENTF_LEFTDOWN,
(MouseButtonId.Left, false) => MOUSEEVENTF_LEFTUP,
(MouseButtonId.Right, true) => MOUSEEVENTF_RIGHTDOWN,
(MouseButtonId.Right, false) => MOUSEEVENTF_RIGHTUP,
_ => 0,
};
var input = new INPUT
{
type = INPUT_MOUSE,
U = { mi = { dwFlags = flags } },
};
Send(input);
}
private static void SendKey(byte virtualKey, bool extended, bool keyUp)
{
uint flags = KEYEVENTF_SCANCODE;
if (keyUp) flags |= KEYEVENTF_KEYUP;
if (extended) flags |= KEYEVENTF_EXTENDEDKEY;
var input = new INPUT
{
type = INPUT_KEYBOARD,
U =
{
ki = new KEYBDINPUT
{
wVk = 0,
wScan = (ushort)MapVirtualKey(virtualKey, MAPVK_VK_TO_VSC),
dwFlags = flags,
},
},
};
Send(input);
}
private static void Send(INPUT input)
{
Span<INPUT> inputs = stackalloc INPUT[1];
inputs[0] = input;
SendInput(1, inputs, Marshal.SizeOf<INPUT>());
}
// --- Win32 interop --------------------------------------------------------
private const uint INPUT_MOUSE = 0;
private const uint INPUT_KEYBOARD = 1;
private const uint KEYEVENTF_EXTENDEDKEY = 0x0001;
private const uint KEYEVENTF_KEYUP = 0x0002;
private const uint KEYEVENTF_SCANCODE = 0x0008;
private const uint MOUSEEVENTF_MOVE = 0x0001;
private const uint MOUSEEVENTF_LEFTDOWN = 0x0002;
private const uint MOUSEEVENTF_LEFTUP = 0x0004;
private const uint MOUSEEVENTF_RIGHTDOWN = 0x0008;
private const uint MOUSEEVENTF_RIGHTUP = 0x0010;
private const uint MAPVK_VK_TO_VSC = 0;
[StructLayout(LayoutKind.Sequential)]
private struct INPUT
{
public uint type;
public InputUnion U;
}
[StructLayout(LayoutKind.Explicit)]
private struct InputUnion
{
[FieldOffset(0)] public MOUSEINPUT mi;
[FieldOffset(0)] public KEYBDINPUT ki;
}
[StructLayout(LayoutKind.Sequential)]
private struct MOUSEINPUT
{
public int dx;
public int dy;
public uint mouseData;
public uint dwFlags;
public uint time;
public nuint dwExtraInfo;
}
[StructLayout(LayoutKind.Sequential)]
private struct KEYBDINPUT
{
public ushort wVk;
public ushort wScan;
public uint dwFlags;
public uint time;
public nuint dwExtraInfo;
}
[DllImport("user32.dll", SetLastError = true)]
private static extern uint SendInput(uint nInputs, Span<INPUT> pInputs, int cbSize);
[DllImport("user32.dll")]
private static extern uint MapVirtualKey(uint uCode, uint uMapType);
}