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
@@ -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);
}
}