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
+4 -2
View File
@@ -35,5 +35,7 @@ dotnet test RioJoy.sln
## Status ## Status
Phase 2 (serial + RIO protocol core) is code-complete and unit-tested; hardware Phases 23 (serial + RIO protocol core, input mapping + output routing) are
verification is pending. See [`docs/PLAN.md`](docs/PLAN.md) for the full roadmap. 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.
+19 -4
View File
@@ -129,10 +129,25 @@ Implemented in `src/RioJoy.Core/Protocol` + `Serial`, covered by
-**Remaining:** verify against real hardware (version reply, check reply, -**Remaining:** verify against real hardware (version reply, check reply,
analog stream) — needs a cabinet; can't be done off-device. analog stream) — needs a cabinet; can't be done off-device.
### Phase 3 — Input mapping + output routing ### Phase 3 — Input mapping + output routing — code-complete ✅
- Port `iRIO` decode and routing precedence (keyboard/mouse/joy/hat/RIO-command). Implemented in `src/RioJoy.Core/Mapping` + `Output`, covered by the
- Keyboard/mouse via `SendInput` P/Invoke; lamp feedback over serial. `Mapping` tests (xUnit, 84 tests total):
- HID-report feeder → the Phase 1 driver via `DeviceIoControl`. - `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 ### Phase 4 — Axis calibration + plasma display
- Port `UpdateJoystick/Throttle/Padal` math (deadzones, ratchet, rudder). - Port `UpdateJoystick/Throttle/Padal` math (deadzones, ratchet, rudder).
+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);
}
@@ -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);
}
}
@@ -0,0 +1,28 @@
using RioJoy.Core.Mapping;
namespace RioJoy.Core.Tests.Mapping;
/// <summary>
/// Records every routed output as an ordered, human-readable log so tests can
/// assert both <i>what</i> happened and <i>in what order</i>.
/// </summary>
internal sealed class RecordingSink : IInputSink, IJoystickSink, ILampSink, IRioCommandSink
{
public List<string> 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})");
}
@@ -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<ArgumentOutOfRangeException>(() => 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<ArgumentOutOfRangeException>(() => RioAddress.FromKeypad(2, 0));
Assert.Throws<ArgumentOutOfRangeException>(() => RioAddress.FromKeypad(0, 0x10));
}
[Fact]
public void TableSize_Is112()
{
Assert.Equal(112, RioAddress.TableSize);
Assert.Equal(0x6F, RioAddress.MaxAddress);
}
}
@@ -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);
}
}