Author the custom virtual HID gamepad that replaces vJoy, and pin its wire format on both sides. Builds clean to RioGamepad.sys against the EWDK (KMDF 1.15 + VHF, x64, warnings-as-errors). driver/RioGamepad/: - ReportDescriptor.h: 6x16-bit axes (X,Y,Z,Rx,Ry,Rz), one 4-direction hat with null state, and 96 buttons — the legacy vJoy layout. 25-byte input report. - Device.c/Driver.c: KMDF root-enumerated device that creates the VHF virtual HID device (VhfCreate in DeviceAdd, VhfStart in D0Entry, VhfDelete on cleanup) and exposes a device interface + IOCTL_RIO_SUBMIT_REPORT that forwards the caller's report bytes to VhfReadReportSubmit. Thin relay: no report logic in the kernel. - Public.h: device-interface GUID, IOCTL, and the report byte layout shared with the C# client. RioGamepad.inf + build.cmd (EWDK build, catalog/sign disabled). src/RioJoy.Core/Hid/RioHidReport.cs: packs AxisOutputs + hat + 96 buttons into the exact 25-byte report (LE axes, hat nibble with 0x0F=centered, button bitmap). 13 new xUnit tests (136 total). Remaining (deploy-side): test-sign + pnputil install + verify in joy.cpl, and wire the real DeviceIoControl feeder sink (replacing NullJoystickSink). The EWDK's in-build catalog task (DrvCat) can't load Microsoft.Kits.Logger on this image, so the .cat is produced with inf2cat/signtool at install time instead. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
13 KiB
RIOJoy — modernization plan
Modernize the cockpit RIO interface app for Windows 10/11, removing the vJoy dependency and replacing it with a custom virtual HID device, rewritten in C#/.NET as a background tray app with per-game profiles.
The legacy app is preserved under legacy/ as the behavioral
reference. The RIO wire format and input map are documented in
PROTOCOL.md.
Purpose
The cockpits run two native games (Firestorm, Red Planet) that talk to the RIO hardware directly and never use this app. RIOJoy exists to broaden which other games can run in the cockpits: arbitrary games don't know about the cockpit's extra hardware (5 analog axes, 96 lighted buttons, the plasma/VFD display, the labeled wallpaper), so RIOJoy bridges the RIO to whatever input those games do understand — joystick, keyboard, and mouse — and drives the cockpit's outputs on their behalf.
Target architecture
┌─────────────────────── C# / .NET 8 tray app (x64) ───────────────────────┐
│ Serial (RIO protocol) → Input mapper (profile) → Output router │
│ COM port, 9600 8N1 72 inputs + keypads ├─ Keyboard/Mouse: SendInput (P/Invoke)
│ packet parse + ACK/NAK decode iRIO bitfield ├─ Joystick: HID report → DeviceIoControl ↓
│ analog poll + recovery axis calibration └─ Lamps: LampRequest back over serial
│ Plasma/VFD on 2nd COM · Profiles + auto-switch + tray UI + logging │
└───────────────────────────────────────────────┬───────────────────────────┘
│ IOCTL (input report bytes)
┌─────────────────────────────────────────────────▼──────────────────────────┐
│ RioGamepad.sys — KMDF + VHF (vhf.sys) virtual HID device │
│ Report descriptor: X,Y,Z,Rx,Ry,Rz (16-bit) · 1 hat · 96 buttons │
│ Control device + custom IOCTL → VhfReadReportSubmit() → Windows sees │
│ a HID gamepad │
└─────────────────────────────────────────────────────────────────────────────┘
Decisions (confirmed)
- Virtual joystick: custom VHF/UMDF HID driver (full fidelity: 6 axes, 96 buttons, 1 hat — exactly the legacy vJoy layout). Not ViGEm (can't hold 96 buttons).
- Stack: C# / .NET 8 (LTS), x64. Driver is C (WDK), separate toolchain.
- Form: background tray app (NotifyIcon), auto-start with Windows.
- Targets: Windows 10/11 x64 only. The legacy x86 / WinXP targets are dropped.
What ports over vs. what's new
| Concern | Legacy | Modern |
|---|---|---|
| Serial + RIO protocol | overlapped I/O + watch thread | SerialPort + async loop; faithful port of the packet state machine |
| Input decode | iRIO[] bitfield + Press_V2 |
same semantics, ported to C# |
| Keyboard/mouse | SendInput scancode |
SendInput via P/Invoke (≈verbatim) |
| Joystick | vJoy SetAxis/SetBtn/SetDiscPov |
HID report → IOCTL → RioGamepad.sys |
| Lamps | LampRequest over serial |
same |
| Axis calibration | UpdateJoystick/Throttle/Padal |
ported math |
| Plasma display | CPlasma (COM2) |
ported; content per-profile |
| Config | SimpleIni, single file, hard-coded COM1 | profile library (JSON), configurable ports; importer for legacy RIO.ini |
Profiles (the core abstraction)
A profile fully describes how the cockpit behaves for one non-native game. Everything is per-profile, not global:
- Button/keypad mapping (the decoded
iRIOtable for this game) - Axis calibration, curves, and invert flags
- Lamp behavior
- Plasma/VFD content (or "off")
- Cockpit overlay labels + the generated wallpaper (Phase 7)
- Display/resolution targets
Profiles never describe the native games — those are the "hands-off" case.
Serial-port yield & the auto-switch state machine
Because the native games own the RIO's COM port directly, RIOJoy must yield it. A process/window watcher drives three states:
| Detected running app | RIOJoy behavior |
|---|---|
| Native game (Firestorm, Red Planet) | Release COM port, go fully dormant — no serial, no HID, no overlay |
| Supported non-native game | Acquire port, load that game's profile, drive HID / keyboard / mouse / lamps / plasma / wallpaper |
| Nothing / desktop | Idle — port released or a configurable neutral default |
Config therefore holds: the per-game profile library, a list of native games to yield to, and the executable→profile match rules. Manual override from the tray menu is always available.
Phases
Phase 0 — Repo & scaffold ✅ (this commit)
git init, remotehttps://gitea.mysticmachines.com/VWE/riovjoy2.git.- Legacy C++ moved to
legacy/; cockpit art todocs/reference/. - Solution
RioJoy.slnwithsrc/RioJoy.Core(lib) +src/RioJoy.Tray(tray app);driver/placeholder for the WDK project. - This plan + PROTOCOL.md.
Phase 1 — Virtual HID driver — compiling ✅ (sign/install pending)
Implemented in driver/RioGamepad/; builds to
RioGamepad.sys against the EWDK (KMDF 1.15 + VHF, x64, warnings-as-errors).
- KMDF + VHF virtual HID gamepad: report descriptor = 6×16-bit axes, 1 hat,
96 buttons (
ReportDescriptor.h). - Device interface + custom
IOCTL_RIO_SUBMIT_REPORT→VhfReadReportSubmit; the driver is a thin relay, with the 25-byte report layout pinned inPublic.h. - C# side of the contract:
RioJoy.Core.Hid.RioHidReportpacks axes/hat/buttons into that exact report (unit-tested). Replaces the throwaway test harness idea. - ⏳ Remaining (Phase 6 / on-cabinet): test-sign +
pnputilinstall + verify injoy.cpl; wire the realDeviceIoControlfeeder sink (replacingNullJoystickSink). The EWDK's in-build catalog/sign tasks are bypassed; the.catis made withinf2cat/signtoolat install time.
Phase 2 — Serial + RIO protocol core (RioJoy.Core) — code-complete ✅
Implemented in src/RioJoy.Core/Protocol + Serial, covered by
tests/RioJoy.Core.Tests (xUnit, 54 tests):
- Packet parser/builder: command/length table, 7-bit checksum, control chars,
framing resync on a high-bit byte mid-packet (
PacketParser,PacketBuilder). - Typed RIO→PC decodes:
AnalogReport(14-bit sign-extend),VersionInfo,CheckStatus; lamp-state composition (RioLampState). RioSerialLink: async receive loop with ACK/NAK policy (legacy force-accept vs. opt-inVerifyInboundChecksum), analog poll timer + >5 s reset-recovery.IRioTransportabstraction with aSerialPort-backed implementation (9600 8N1, DTR reset pulse); clean COM-port acquire/release = create/dispose the transport (foundation for serial yield). The read loop is tested against an in-memory fake transport.- ⏳ 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 — code-complete ✅
Implemented in src/RioJoy.Core/Mapping + Output, covered by the
Mapping tests (xUnit, 84 tests total):
RioMapEntrydecodes the 16-bitiRIOword (flags + value) and resolves the routingKindby the legacy precedence (joy+hat+mouse ⇒ RIO command; none ⇒ keyboard; else joy → hat → mouse).RioAddress(button + keypad→address offsets) andRioInputMap(112-entry per-profile table) replace the hard-codediRIO[].InputRouterportsPress_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;SendInputSinkis the realSendInputkeyboard/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 targetsIJoystickSink; wire the IOCTL adapter once the driver exists. The legacy default map /RIO.inibecomes an importable profile (Phase 5/7).
Phase 4 — Axis calibration + plasma display — code-complete ✅
Implemented in src/RioJoy.Core/Calibration + Plasma (105 xUnit tests total):
AxisCalibratorportsUpdateThrottle/UpdatePadal/UpdateJoystick: throttle deadzone + ratchet field, pedal deadzones, X/Y auto-ranging from observed min/max, rudder mixing (enableZR), and all per-axis invert flags. Stateful (start positions, last outputs) like the legacy globals, with the RIOcmd axis resets. Outputs clamp to the documented0..32766range.IJoystickSinkgainsSetAxis(JoyAxis, value)so calibrated axes reach the HID feeder;AxisOutputscarries the six values.PlasmaCommandsports theCPlasmaESC command set (clear/cursor/font/attr/box draw+fill/text) +GetFontSize+ thePlasmaPosTextauto-fit/centering;PlasmaDisplaywrites them over the secondary COM transport.- ⏳ Remaining: hardware verification of axis feel + plasma output; the
game-specific
PlasmaScoreDrawlayout is profile content (Phase 5/7).
Phase 5 — Tray app + profiles — code-complete ✅
Core logic in src/RioJoy.Core/Profiles + RioRuntime; UI/OS in src/RioJoy.Tray
(123 xUnit tests total):
RioProfile+AppConfigmodel;ConfigStoreJSON persistence (round-trip tested);RioIniImporterports the legacyRIO.ini(buttons/inverts/greeting).AutoSwitchResolver+AutoSwitchWatcher: the three-state decision (Yield native / Activate profile / Idle) from the foreground executable, native always winning; raises only on change. Pure + tested.RioRuntimeassembles a profile's live pipeline: serial button/keypad packets →InputRouter; analog replies →AxisCalibrator→ the six joystick axes; RIO commands → calibration resets + serial requests + lamp re-init. End-to-end tested over the fake transport.- Tray:
NotifyIconmenu mirroring the legacy console menu (axis resets, version/status, diagnostic toggles, quit) + profile selection (auto vs. manual)- "start with Windows";
RioCoordinatorowns the serial acquire/release tied to the watcher (native-game COM-port yield); OS adapters (ForegroundProcessProvider,AutoStartManager).
- "start with Windows";
- ⏳ Remaining: the joystick output is a
NullJoystickSinkplaceholder until the Phase 1 HID feeder exists; full on-cabinet verification of the auto-switch + acquire/release lifecycle.
Phase 6 — Packaging / signing / deploy
- Driver install via
pnputil; app installer; test-signing script. - Cabinet deployment doc. (Production option: attestation signing.)
Phase 7 — Profile/mapping editor + cockpit overlay generator
Replaces the legacy Google-Sheet → .data → GIMP → Script-Fu pipeline
(see docs/reference/customBackground/).
- Mapping editor: per-button UI to set action + label + lamp, no hex bit-twiddling; clone-from-existing profile.
- Overlay generator: render labels into named regions over a base cockpit
image using SkiaSharp, porting the auto-fit/justification logic from
sg-goobie-MFD.scm; export the per-profile wallpaper and re-apply viaSystemParametersInfo. - Region authoring: extract the label rectangles from
riojoy.xcfinto aregions.json; in-app box editor for future tweaks. - The unified profile JSON supersedes both
RIO.iniand the Google Sheet, with importers for each. - To confirm at Phase 7: target wallpaper resolution(s); static wallpaper vs. live overlay (e.g. lit-button highlighting mirroring lamp state).
Open items / risks
- Driver signing is the main friction point. Test-signing is fine for owned cabinets; redistribution needs attestation signing (EV cert + Partner Center).
- Input injection vs. session:
SendInputtargets the interactive session — fine for a tray app, which is why a Windows service was rejected. - Legacy quirks to decide on (see PROTOCOL.md ⚠️ notes): disabled inbound checksum verification; odd mouse-move deltas.