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:
@@ -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.
|
||||
|
||||
+19
-4
@@ -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).
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
using RioJoy.Core.Protocol;
|
||||
|
||||
namespace RioJoy.Core.Mapping;
|
||||
|
||||
/// <summary>
|
||||
/// Decodes RIO button/keypad press & 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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>0x00–0x47</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 0x00–0x47).</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"/> 0x00–0x47).</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"/> 0x00–0x0F. 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."),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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}.");
|
||||
}
|
||||
}
|
||||
@@ -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})";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user