diff --git a/README.md b/README.md
index 1b29865..0e109c0 100644
--- a/README.md
+++ b/README.md
@@ -35,5 +35,7 @@ dotnet test RioJoy.sln
## Status
-Phase 2 (serial + RIO protocol core) is code-complete and unit-tested; hardware
-verification is pending. See [`docs/PLAN.md`](docs/PLAN.md) for the full roadmap.
+Phases 2–3 (serial + RIO protocol core, input mapping + output routing) are
+code-complete and unit-tested; the virtual-HID feeder and hardware verification
+are pending on the Phase 1 driver. See [`docs/PLAN.md`](docs/PLAN.md) for the full
+roadmap.
diff --git a/docs/PLAN.md b/docs/PLAN.md
index 45e1fbe..c321df5 100644
--- a/docs/PLAN.md
+++ b/docs/PLAN.md
@@ -129,10 +129,25 @@ Implemented in `src/RioJoy.Core/Protocol` + `Serial`, covered by
- ⏳ **Remaining:** verify against real hardware (version reply, check reply,
analog stream) — needs a cabinet; can't be done off-device.
-### Phase 3 — Input mapping + output routing
-- Port `iRIO` decode and routing precedence (keyboard/mouse/joy/hat/RIO-command).
-- Keyboard/mouse via `SendInput` P/Invoke; lamp feedback over serial.
-- HID-report feeder → the Phase 1 driver via `DeviceIoControl`.
+### Phase 3 — Input mapping + output routing — code-complete ✅
+Implemented in `src/RioJoy.Core/Mapping` + `Output`, covered by the
+`Mapping` tests (xUnit, 84 tests total):
+- `RioMapEntry` decodes the 16-bit `iRIO` word (flags + value) and resolves the
+ routing `Kind` by the legacy precedence (joy+hat+mouse ⇒ RIO command; none ⇒
+ keyboard; else joy → hat → mouse).
+- `RioAddress` (button + keypad→address offsets) and `RioInputMap` (112-entry
+ per-profile table) replace the hard-coded `iRIO[]`.
+- `InputRouter` ports `Press_V2`/`Release_V2`: modifier ordering, scancode keys,
+ joystick buttons, POV hat, mouse move/click, RIO-command dispatch, and lamp
+ feedback (bright on press / dim on release; RIO commands carry none).
+- Output is split behind sink interfaces (`IInputSink`, `IJoystickSink`,
+ `ILampSink`, `IRioCommandSink`) so routing is pure and unit-tested; `SendInputSink`
+ is the real `SendInput` keyboard/mouse adapter.
+- ⏳ **Remaining:** the joystick sink's real adapter is the **HID feeder →
+ RioGamepad driver via `DeviceIoControl`**, which is blocked on the Phase 1
+ driver. Routing already targets `IJoystickSink`; wire the IOCTL adapter once the
+ driver exists. The legacy default map / `RIO.ini` becomes an importable profile
+ (Phase 5/7).
### Phase 4 — Axis calibration + plasma display
- Port `UpdateJoystick/Throttle/Padal` math (deadzones, ratchet, rudder).
diff --git a/src/RioJoy.Core/Mapping/InputRouter.cs b/src/RioJoy.Core/Mapping/InputRouter.cs
new file mode 100644
index 0000000..d2d3255
--- /dev/null
+++ b/src/RioJoy.Core/Mapping/InputRouter.cs
@@ -0,0 +1,152 @@
+using RioJoy.Core.Protocol;
+
+namespace RioJoy.Core.Mapping;
+
+///
+/// Decodes RIO button/keypad press & release events through a
+/// and routes them to the keyboard/mouse, joystick,
+/// lamp, and RIO-command sinks. Faithful port of Press_V2/Release_V2
+/// (riovjoy2.cpp#L1944); see docs/PROTOCOL.md §5.
+///
+/// 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.
+///
+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;
+ }
+
+ ///
+ /// 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.
+ ///
+ public void InitializeLamps()
+ {
+ for (int address = 0; address < _map.Count; address++)
+ {
+ if (_map[address].HasLamp)
+ _lamp.SetLamp(address, RioLampState.SolidDim);
+ }
+ }
+
+ /// Handle a button/keypad press at the given RIO address.
+ 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);
+ }
+
+ /// Handle a button/keypad release at the given RIO address.
+ 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.
+ }
+ }
+}
diff --git a/src/RioJoy.Core/Mapping/OutputSinks.cs b/src/RioJoy.Core/Mapping/OutputSinks.cs
new file mode 100644
index 0000000..29e4c5e
--- /dev/null
+++ b/src/RioJoy.Core/Mapping/OutputSinks.cs
@@ -0,0 +1,96 @@
+namespace RioJoy.Core.Mapping;
+
+/// Mouse actions encoded in a mouse entry's low byte (docs/PROTOCOL.md §5).
+public enum MouseAction : byte
+{
+ MoveUp = 0,
+ MoveRight = 1,
+ MoveDown = 2,
+ MoveLeft = 3,
+ LeftClick = 4,
+ RightClick = 5,
+}
+
+/// Which mouse button a click action targets.
+public enum MouseButton
+{
+ Left,
+ Right,
+}
+
+/// Discrete 4-way POV hat position. = released.
+public enum RioHat
+{
+ Centered = -1,
+ Up = 0,
+ Right = 1,
+ Down = 2,
+ Left = 3,
+}
+
+///
+/// Internal RIO commands (the low byte when an entry is a RIO command). Port of
+/// the RIOcmd switch (riovjoy2.cpp#L1852).
+///
+public enum RioCommandCode : byte
+{
+ ResetAllAxes = 0,
+ ResetThrottle = 1,
+ ResetLeftPedal = 2,
+ ResetRightPedal = 3,
+ ResetVerticalJoystick = 4,
+ ResetHorizontalJoystick = 5,
+ GeneralReset = 6,
+ RequestVersionAndCheck = 7,
+ ToggleRawAxesReadout = 8,
+ TogglePollRateReadout = 9,
+}
+
+///
+/// Keyboard + mouse output (the SendInput target). The router holds the
+/// ordering logic and emits virtual-key codes; a sink implementation maps VK to
+/// scancode and calls SendInput. Keys are addressed by Windows virtual-key
+/// code; modifiers are sent as ordinary keys (VK_SHIFT/CONTROL/MENU).
+///
+public interface IInputSink
+{
+ void KeyDown(byte virtualKey, bool extended);
+
+ void KeyUp(byte virtualKey, bool extended);
+
+ /// Relative mouse move by (, ) pixels.
+ void MouseMove(int dx, int dy);
+
+ void MouseButton(MouseButton button, bool down);
+}
+
+///
+/// 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.
+///
+public interface IJoystickSink
+{
+ void SetButton(int button, bool pressed);
+
+ void SetHat(RioHat position);
+}
+
+/// Lamp (lighted-button) feedback, sent back to the RIO over serial.
+public interface ILampSink
+{
+ void SetLamp(int address, byte lampState);
+}
+
+/// Handles internal RIO commands routed from the input map.
+public interface IRioCommandSink
+{
+ void Execute(RioCommandCode command);
+}
+
+/// Well-known Windows virtual-key codes used by the router for modifiers.
+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)
+}
diff --git a/src/RioJoy.Core/Mapping/RioAddress.cs b/src/RioJoy.Core/Mapping/RioAddress.cs
new file mode 100644
index 0000000..b7d4fdc
--- /dev/null
+++ b/src/RioJoy.Core/Mapping/RioAddress.cs
@@ -0,0 +1,51 @@
+namespace RioJoy.Core.Mapping;
+
+///
+/// Translates raw RIO events into an iRIO table address. The 72 digital
+/// buttons map to 0x00–0x47 directly; keypad 0 is offset by 0x50
+/// and keypad 1 by 0x60 (port of KeypadEvent, riovjoy2.cpp#L1185);
+/// see docs/PROTOCOL.md §5.
+///
+public static class RioAddress
+{
+ /// Number of digital button inputs (addresses 0x00–0x47).
+ public const int ButtonCount = 72;
+
+ /// Address offset added to keypad-0 key indices.
+ public const int Keypad0Base = 0x50;
+
+ /// Address offset added to keypad-1 key indices.
+ public const int Keypad1Base = 0x60;
+
+ /// Highest valid address (keypad 1, index 0x0F).
+ public const int MaxAddress = Keypad1Base + 0x0F; // 0x6F
+
+ /// Size of the iRIO table (addresses 0x00..0x6F inclusive).
+ public const int TableSize = MaxAddress + 1; // 112
+
+ /// Address for a digital button event ( 0x00–0x47).
+ public static int FromButton(byte index)
+ {
+ if (index >= ButtonCount)
+ throw new ArgumentOutOfRangeException(
+ nameof(index), $"Button index must be 0..{ButtonCount - 1}.");
+ return index;
+ }
+
+ ///
+ /// Address for a keypad key event: 0 or 1,
+ /// 0x00–0x0F. Mirrors the legacy offsets (+0x50 / +0x60).
+ ///
+ 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."),
+ };
+ }
+}
diff --git a/src/RioJoy.Core/Mapping/RioInputMap.cs b/src/RioJoy.Core/Mapping/RioInputMap.cs
new file mode 100644
index 0000000..06290e8
--- /dev/null
+++ b/src/RioJoy.Core/Mapping/RioInputMap.cs
@@ -0,0 +1,48 @@
+namespace RioJoy.Core.Mapping;
+
+///
+/// The per-profile iRIO table: 112 entries indexed by RIO address
+/// (0x00..0x6F), each a . Replaces the legacy
+/// hard-coded iRIO[] array; a profile supplies the values (the legacy
+/// defaults / RIO.ini become one importable profile in Phase 5/7).
+///
+public sealed class RioInputMap
+{
+ private readonly ushort[] _entries = new ushort[RioAddress.TableSize];
+
+ /// Create an empty map (all entries unmapped).
+ public RioInputMap() { }
+
+ /// Create a map from raw 16-bit words indexed by address.
+ public RioInputMap(IReadOnlyDictionary entries)
+ {
+ ArgumentNullException.ThrowIfNull(entries);
+ foreach ((int address, ushort raw) in entries)
+ this[address] = new RioMapEntry(raw);
+ }
+
+ /// Number of addressable slots ().
+ public int Count => _entries.Length;
+
+ /// Get or set the entry at a RIO address (0x00..0x6F).
+ 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}.");
+ }
+}
diff --git a/src/RioJoy.Core/Mapping/RioMapEntry.cs b/src/RioJoy.Core/Mapping/RioMapEntry.cs
new file mode 100644
index 0000000..d62feeb
--- /dev/null
+++ b/src/RioJoy.Core/Mapping/RioMapEntry.cs
@@ -0,0 +1,92 @@
+namespace RioJoy.Core.Mapping;
+
+/// How a decoded should be routed.
+public enum RioRouteKind
+{
+ /// Send a keyboard key (with optional SHIFT/CTRL/ALT modifiers).
+ Keyboard,
+
+ /// Press a virtual joystick button.
+ Joystick,
+
+ /// Set the virtual POV hat direction.
+ Hat,
+
+ /// Perform a mouse action (move or click).
+ Mouse,
+
+ ///
+ /// An internal RIO command (axis reset, recalibrate, status, toggle) — not an
+ /// output to the host. Encoded as joy+hat+mouse all set (0x7000).
+ ///
+ RioCommand,
+}
+
+///
+/// One entry of the iRIO 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 Press_V2/Release_V2
+/// (riovjoy2.cpp#L1944); see docs/PROTOCOL.md §5.
+///
+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
+
+ /// The raw 16-bit map word.
+ public ushort Raw { get; }
+
+ public RioMapEntry(ushort raw) => Raw = raw;
+
+ /// This input drives a lighted button (lamp feedback on press/release).
+ public bool HasLamp => (Raw & FlagHasLamp) != 0;
+
+ /// Keyboard: apply KEYEVENTF_EXTENDEDKEY to the key.
+ public bool Extended => (Raw & FlagExtended) != 0;
+
+ public bool Alt => (Raw & FlagAlt) != 0;
+ public bool Ctrl => (Raw & FlagCtrl) != 0;
+ public bool Shift => (Raw & FlagShift) != 0;
+
+ /// Low byte: VK code, joystick button, hat direction, or mouse action.
+ public byte Value => (byte)(Raw & 0xFF);
+
+ private bool IsJoyFlag => (Raw & FlagJoy) != 0;
+ private bool IsHatFlag => (Raw & FlagHat) != 0;
+ private bool IsMouseFlag => (Raw & FlagMouse) != 0;
+
+ ///
+ /// 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.
+ ///
+ 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;
+ }
+ }
+
+ /// True if this entry has no mapping (a "do nothing" slot).
+ public bool IsUnmapped => Raw == 0;
+
+ public override string ToString() => $"0x{Raw:X4} ({Kind} {Value})";
+}
diff --git a/src/RioJoy.Core/Output/SendInputSink.cs b/src/RioJoy.Core/Output/SendInputSink.cs
new file mode 100644
index 0000000..a5c116d
--- /dev/null
+++ b/src/RioJoy.Core/Output/SendInputSink.cs
@@ -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;
+
+///
+/// backed by the Win32 SendInput API. Keys are
+/// sent by scancode (VK → scancode via MapVirtualKey), matching the
+/// legacy Key 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).
+///
+[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 inputs = stackalloc INPUT[1];
+ inputs[0] = input;
+ SendInput(1, inputs, Marshal.SizeOf());
+ }
+
+ // --- 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 pInputs, int cbSize);
+
+ [DllImport("user32.dll")]
+ private static extern uint MapVirtualKey(uint uCode, uint uMapType);
+}
diff --git a/tests/RioJoy.Core.Tests/Mapping/InputRouterTests.cs b/tests/RioJoy.Core.Tests/Mapping/InputRouterTests.cs
new file mode 100644
index 0000000..df91fdf
--- /dev/null
+++ b/tests/RioJoy.Core.Tests/Mapping/InputRouterTests.cs
@@ -0,0 +1,168 @@
+using RioJoy.Core.Mapping;
+using Xunit;
+
+namespace RioJoy.Core.Tests.Mapping;
+
+public class InputRouterTests
+{
+ private const byte SolidDim = 0x14;
+ private const byte SolidBright = 0x3C;
+
+ private static (InputRouter router, RecordingSink sink) Build(
+ int address, ushort raw, int mouseStep = 50)
+ {
+ var map = new RioInputMap { [address] = new RioMapEntry(raw) };
+ var sink = new RecordingSink();
+ var router = new InputRouter(map, sink, sink, sink, sink, mouseStep);
+ return (router, sink);
+ }
+
+ [Fact]
+ public void Keyboard_WithModifiers_PressesInOrderAndLights()
+ {
+ // 0x8749 = hasLamp + alt + ctrl + shift, VK 0x49
+ (InputRouter router, RecordingSink sink) = Build(0x05, 0x8749);
+
+ router.Press(0x05);
+ Assert.Equal(new[]
+ {
+ "KeyDown(0x10,ext=False)", // SHIFT
+ "KeyDown(0x11,ext=False)", // CTRL
+ "KeyDown(0x12,ext=False)", // ALT
+ "KeyDown(0x49,ext=False)", // key
+ $"Lamp(0x05,0x{SolidBright:X2})",
+ }, sink.Log);
+
+ sink.Log.Clear();
+ router.Release(0x05);
+ Assert.Equal(new[]
+ {
+ "KeyUp(0x49,ext=False)", // key first
+ "KeyUp(0x12,ext=False)", // then ALT, CTRL, SHIFT (reverse)
+ "KeyUp(0x11,ext=False)",
+ "KeyUp(0x10,ext=False)",
+ $"Lamp(0x05,0x{SolidDim:X2})",
+ }, sink.Log);
+ }
+
+ [Fact]
+ public void Keyboard_Extended_NoLamp()
+ {
+ // 0x08BE = extended, VK 0xBE, no lamp
+ (InputRouter router, RecordingSink sink) = Build(0x10, 0x08BE);
+
+ router.Press(0x10);
+ Assert.Equal(new[] { "KeyDown(0xBE,ext=True)" }, sink.Log);
+
+ sink.Log.Clear();
+ router.Release(0x10);
+ Assert.Equal(new[] { "KeyUp(0xBE,ext=True)" }, sink.Log);
+ }
+
+ [Fact]
+ public void Joystick_PressReleaseWithLamp()
+ {
+ // 0x9009 = hasLamp + joy, button 9
+ (InputRouter router, RecordingSink sink) = Build(0x03, 0x9009);
+
+ router.Press(0x03);
+ Assert.Equal(new[] { "Joy(9,True)", $"Lamp(0x03,0x{SolidBright:X2})" }, sink.Log);
+
+ sink.Log.Clear();
+ router.Release(0x03);
+ Assert.Equal(new[] { "Joy(9,False)", $"Lamp(0x03,0x{SolidDim:X2})" }, sink.Log);
+ }
+
+ [Fact]
+ public void Hat_PressSetsDirection_ReleaseCenters()
+ {
+ // 0x2002 = hat, value 2 = Down
+ (InputRouter router, RecordingSink sink) = Build(0x04, 0x2002);
+
+ router.Press(0x04);
+ Assert.Equal(new[] { "Hat(Down)" }, sink.Log);
+
+ sink.Log.Clear();
+ router.Release(0x04);
+ Assert.Equal(new[] { "Hat(Centered)" }, sink.Log);
+ }
+
+ [Theory]
+ [InlineData(0x4000, "MouseMove(0,-50)")] // up
+ [InlineData(0x4001, "MouseMove(50,0)")] // right
+ [InlineData(0x4002, "MouseMove(0,50)")] // down
+ [InlineData(0x4003, "MouseMove(-50,0)")] // left
+ public void Mouse_MovePressOnly(ushort raw, string expectedMove)
+ {
+ (InputRouter router, RecordingSink sink) = Build(0x06, raw);
+
+ router.Press(0x06);
+ Assert.Equal(new[] { expectedMove }, sink.Log);
+
+ sink.Log.Clear();
+ router.Release(0x06); // moves do nothing on release
+ Assert.Empty(sink.Log);
+ }
+
+ [Fact]
+ public void Mouse_ClickDownAndUp()
+ {
+ (InputRouter router, RecordingSink sink) = Build(0x06, 0x4004); // left click
+
+ router.Press(0x06);
+ router.Release(0x06);
+ Assert.Equal(new[] { "MouseButton(Left,True)", "MouseButton(Left,False)" }, sink.Log);
+ }
+
+ [Fact]
+ public void Mouse_MoveStepIsConfigurable()
+ {
+ (InputRouter router, RecordingSink sink) = Build(0x06, 0x4001, mouseStep: 10);
+ router.Press(0x06);
+ Assert.Equal(new[] { "MouseMove(10,0)" }, sink.Log);
+ }
+
+ [Fact]
+ public void RioCommand_Executes_AndSkipsLampEvenIfFlagged()
+ {
+ // 0xF006 = hasLamp + joy + hat + mouse (command), value 6 = GeneralReset
+ (InputRouter router, RecordingSink sink) = Build(0x07, 0xF006);
+
+ router.Press(0x07);
+ Assert.Equal(new[] { "Cmd(GeneralReset)" }, sink.Log); // no lamp
+
+ sink.Log.Clear();
+ router.Release(0x07);
+ Assert.Empty(sink.Log); // release does nothing
+ }
+
+ [Fact]
+ public void Unmapped_Entry_DoesNothing()
+ {
+ (InputRouter router, RecordingSink sink) = Build(0x08, 0x0000);
+ router.Press(0x08);
+ router.Release(0x08);
+ Assert.Empty(sink.Log);
+ }
+
+ [Fact]
+ public void InitializeLamps_DimsOnlyLampEntries()
+ {
+ var map = new RioInputMap
+ {
+ [0x00] = new RioMapEntry(0x8041), // hasLamp keyboard
+ [0x01] = new RioMapEntry(0x0041), // no lamp
+ [0x02] = new RioMapEntry(0x9009), // hasLamp joystick
+ };
+ var sink = new RecordingSink();
+ var router = new InputRouter(map, sink, sink, sink, sink);
+
+ router.InitializeLamps();
+
+ Assert.Equal(new[]
+ {
+ $"Lamp(0x00,0x{SolidDim:X2})",
+ $"Lamp(0x02,0x{SolidDim:X2})",
+ }, sink.Log);
+ }
+}
diff --git a/tests/RioJoy.Core.Tests/Mapping/RecordingSink.cs b/tests/RioJoy.Core.Tests/Mapping/RecordingSink.cs
new file mode 100644
index 0000000..dd96593
--- /dev/null
+++ b/tests/RioJoy.Core.Tests/Mapping/RecordingSink.cs
@@ -0,0 +1,28 @@
+using RioJoy.Core.Mapping;
+
+namespace RioJoy.Core.Tests.Mapping;
+
+///
+/// Records every routed output as an ordered, human-readable log so tests can
+/// assert both what happened and in what order.
+///
+internal sealed class RecordingSink : IInputSink, IJoystickSink, ILampSink, IRioCommandSink
+{
+ public List Log { get; } = new();
+
+ public void KeyDown(byte virtualKey, bool extended) => Log.Add($"KeyDown(0x{virtualKey:X2},ext={extended})");
+
+ public void KeyUp(byte virtualKey, bool extended) => Log.Add($"KeyUp(0x{virtualKey:X2},ext={extended})");
+
+ public void MouseMove(int dx, int dy) => Log.Add($"MouseMove({dx},{dy})");
+
+ public void MouseButton(MouseButton button, bool down) => Log.Add($"MouseButton({button},{down})");
+
+ public void SetButton(int button, bool pressed) => Log.Add($"Joy({button},{pressed})");
+
+ public void SetHat(RioHat position) => Log.Add($"Hat({position})");
+
+ public void SetLamp(int address, byte lampState) => Log.Add($"Lamp(0x{address:X2},0x{lampState:X2})");
+
+ public void Execute(RioCommandCode command) => Log.Add($"Cmd({command})");
+}
diff --git a/tests/RioJoy.Core.Tests/Mapping/RioAddressTests.cs b/tests/RioJoy.Core.Tests/Mapping/RioAddressTests.cs
new file mode 100644
index 0000000..7cd2aee
--- /dev/null
+++ b/tests/RioJoy.Core.Tests/Mapping/RioAddressTests.cs
@@ -0,0 +1,44 @@
+using RioJoy.Core.Mapping;
+using Xunit;
+
+namespace RioJoy.Core.Tests.Mapping;
+
+public class RioAddressTests
+{
+ [Fact]
+ public void Button_MapsDirectly()
+ {
+ Assert.Equal(0x00, RioAddress.FromButton(0x00));
+ Assert.Equal(0x47, RioAddress.FromButton(0x47));
+ }
+
+ [Fact]
+ public void Button_RejectsOutOfRange()
+ {
+ Assert.Throws(() => RioAddress.FromButton(0x48));
+ }
+
+ [Theory]
+ [InlineData(0, 0x00, 0x50)]
+ [InlineData(0, 0x0F, 0x5F)]
+ [InlineData(1, 0x00, 0x60)]
+ [InlineData(1, 0x0F, 0x6F)]
+ public void Keypad_AppliesOffset(byte pad, byte index, int expected)
+ {
+ Assert.Equal(expected, RioAddress.FromKeypad(pad, index));
+ }
+
+ [Fact]
+ public void Keypad_RejectsBadPadOrIndex()
+ {
+ Assert.Throws(() => RioAddress.FromKeypad(2, 0));
+ Assert.Throws(() => RioAddress.FromKeypad(0, 0x10));
+ }
+
+ [Fact]
+ public void TableSize_Is112()
+ {
+ Assert.Equal(112, RioAddress.TableSize);
+ Assert.Equal(0x6F, RioAddress.MaxAddress);
+ }
+}
diff --git a/tests/RioJoy.Core.Tests/Mapping/RioMapEntryTests.cs b/tests/RioJoy.Core.Tests/Mapping/RioMapEntryTests.cs
new file mode 100644
index 0000000..4a5bdb7
--- /dev/null
+++ b/tests/RioJoy.Core.Tests/Mapping/RioMapEntryTests.cs
@@ -0,0 +1,59 @@
+using RioJoy.Core.Mapping;
+using Xunit;
+
+namespace RioJoy.Core.Tests.Mapping;
+
+public class RioMapEntryTests
+{
+ [Fact]
+ public void Keyboard_NoRoutingFlags_DecodesModifiersAndValue()
+ {
+ // 0x8749 = hasLamp + alt + ctrl + shift, VK 0x49 ('I')
+ var e = new RioMapEntry(0x8749);
+ Assert.Equal(RioRouteKind.Keyboard, e.Kind);
+ Assert.True(e.HasLamp);
+ Assert.True(e.Alt);
+ Assert.True(e.Ctrl);
+ Assert.True(e.Shift);
+ Assert.False(e.Extended);
+ Assert.Equal(0x49, e.Value);
+ }
+
+ [Fact]
+ public void Extended_FlagDecodes()
+ {
+ // 0x88BE = hasLamp + extended, VK 0xBE
+ var e = new RioMapEntry(0x88BE);
+ Assert.Equal(RioRouteKind.Keyboard, e.Kind);
+ Assert.True(e.HasLamp);
+ Assert.True(e.Extended);
+ Assert.Equal(0xBE, e.Value);
+ }
+
+ [Theory]
+ [InlineData(0x1009, RioRouteKind.Joystick)] // joy flag
+ [InlineData(0x2003, RioRouteKind.Hat)] // hat flag
+ [InlineData(0x4002, RioRouteKind.Mouse)] // mouse flag
+ [InlineData(0x7005, RioRouteKind.RioCommand)] // joy+hat+mouse → command
+ [InlineData(0x0041, RioRouteKind.Keyboard)] // none → keyboard
+ public void Kind_FollowsPrecedence(ushort raw, RioRouteKind expected)
+ {
+ Assert.Equal(expected, new RioMapEntry(raw).Kind);
+ }
+
+ [Fact]
+ public void Joystick_BeatsHatAndMouse_WhenMultipleSet()
+ {
+ // joy + hat set (but not mouse) → joystick wins
+ Assert.Equal(RioRouteKind.Joystick, new RioMapEntry(0x3001).Kind);
+ // hat + mouse set (but not joy) → hat wins
+ Assert.Equal(RioRouteKind.Hat, new RioMapEntry(0x6001).Kind);
+ }
+
+ [Fact]
+ public void Unmapped_IsZero()
+ {
+ Assert.True(new RioMapEntry(0).IsUnmapped);
+ Assert.False(new RioMapEntry(0x0041).IsUnmapped);
+ }
+}