From cc64d241c9f66ee1d6772a183aaaad2281a4e74d Mon Sep 17 00:00:00 2001 From: Cyd Date: Fri, 26 Jun 2026 15:10:06 -0500 Subject: [PATCH] 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 --- README.md | 6 +- docs/PLAN.md | 23 ++- src/RioJoy.Core/Mapping/InputRouter.cs | 152 ++++++++++++++++ src/RioJoy.Core/Mapping/OutputSinks.cs | 96 ++++++++++ src/RioJoy.Core/Mapping/RioAddress.cs | 51 ++++++ src/RioJoy.Core/Mapping/RioInputMap.cs | 48 +++++ src/RioJoy.Core/Mapping/RioMapEntry.cs | 92 ++++++++++ src/RioJoy.Core/Output/SendInputSink.cs | 137 ++++++++++++++ .../Mapping/InputRouterTests.cs | 168 ++++++++++++++++++ .../Mapping/RecordingSink.cs | 28 +++ .../Mapping/RioAddressTests.cs | 44 +++++ .../Mapping/RioMapEntryTests.cs | 59 ++++++ 12 files changed, 898 insertions(+), 6 deletions(-) create mode 100644 src/RioJoy.Core/Mapping/InputRouter.cs create mode 100644 src/RioJoy.Core/Mapping/OutputSinks.cs create mode 100644 src/RioJoy.Core/Mapping/RioAddress.cs create mode 100644 src/RioJoy.Core/Mapping/RioInputMap.cs create mode 100644 src/RioJoy.Core/Mapping/RioMapEntry.cs create mode 100644 src/RioJoy.Core/Output/SendInputSink.cs create mode 100644 tests/RioJoy.Core.Tests/Mapping/InputRouterTests.cs create mode 100644 tests/RioJoy.Core.Tests/Mapping/RecordingSink.cs create mode 100644 tests/RioJoy.Core.Tests/Mapping/RioAddressTests.cs create mode 100644 tests/RioJoy.Core.Tests/Mapping/RioMapEntryTests.cs 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); + } +}