Files
riojoy/docs/PLAN.md
T
CydandClaude Opus 4.8 094637b0a4 Editor: rework to the original Win32 RIO control-panel layout
Reworked the profile/mapping editor to mirror the original unfinished Win32
RIO driver's control-panel design (docs/Win32RIO, by FASA / Michel Lowrance)
instead of the flat config-sheet grid. The wallpaper region positions are a
VGA chroma-split display artifact, not the cockpit's logical shape.

- RioJoy.Core.Editing.CockpitPanel: the functional layout — five MFD clusters,
  four board columns (Throttle/Secondary/Screen/Joystick-Hat), an encoder-gauge
  strip, and the two later-added 4x4 keypads (Internal/External). Places every
  address 0x00-0x47 / 0x50-0x6F exactly once (unit-tested).
- Tray PanelView/PanelCanvas render the panel; ProfileEditorForm drives it.
  Buttons show label + assigned function (ButtonBinding.Describe -> CTL-N, JOY1,
  POV-U, Mse LB, RIO command names; unit-tested). Lamp shade is driven by the
  IsLit flag (not by whether a function is assigned); keypads are neutral blue
  with no Lit checkbox. Added an Unassign button next to Apply.
- Removed the superseded sheet-grid UI (SheetView/SheetCanvas); SheetLayout +
  config-sheet.csv stay as reference data.

docs/Win32RIO: the original driver (tasgame.sys, a 32-bit kernel HID minidriver
that opened the serial port, set baud/8N1/DTR, and spoke the *identical* RIO
protocol -- AnalogRequest/Reply, Button Pressed/Released, Check/Lamp/Reset/
Version -- validating our protocol port), oemsetup.inf, RemoteDriver.doc, and the
extracted control-panel / game-controllers mockups.

~236 xUnit tests green. PLAN.md updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 19:48:14 -05:00

16 KiB
Raw Blame History

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 iRIO table 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, remote https://gitea.mysticmachines.com/VWE/RIOJoy.git.
  • Legacy C++ moved to legacy/; cockpit art to docs/reference/.
  • Solution RioJoy.sln with src/RioJoy.Core (lib) + src/RioJoy.Tray (tray app); driver/ placeholder for the WDK project.
  • This plan + PROTOCOL.md.

Phase 1 — Virtual HID driver — test-signed, installed, verified

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_REPORTVhfReadReportSubmit; the driver is a thin relay, with the 25-byte report layout pinned in Public.h.
  • C# side of the contract: RioJoy.Core.Hid.RioHidReport packs axes/hat/buttons into that exact report (unit-tested). Replaces the throwaway test harness idea.
  • Test-signed + pnputil-installed on the cabinet; INF declares vhf as a lower filter (LowerFilters AddReg) — without it VhfCreate fails and the device shows Code 31. The EWDK's in-build catalog/sign tasks are bypassed; the .cat is made with inf2cat/signtool at install time (driver/*.ps1, driver/README.md).
  • End-to-end verified: the real HidFeederJoystickSink opens the device and submits reports; axes (min/mid/max), buttons, and the POV hat all read back correctly through winmm joyGetPosEx / joy.cpl. The controller's joy.cpl name is set via the DirectInput OEMName registry value at install (VHF can't supply a HID product string).
  • Remaining: none for the driver itself; redistribution off owned cabinets would need attestation signing (Phase 6).

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-in VerifyInboundChecksum), analog poll timer + >5 s reset-recovery.
  • IRioTransport abstraction with a SerialPort-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):

  • 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.
  • The joystick sink's real adapter — the HID feeder → RioGamepad driver via DeviceIoControl (Output/HidFeederJoystickSink) — is implemented, wired in (RioCoordinator selects it when the driver is present, else NullJoystickSink), and verified end-to-end against the installed driver (Phase 1).
  • Remaining: the legacy default map / RIO.ini becomes 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):

  • AxisCalibrator ports UpdateThrottle/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 documented 0..32766 range.
  • IJoystickSink gains SetAxis(JoyAxis, value) so calibrated axes reach the HID feeder; AxisOutputs carries the six values.
  • PlasmaCommands ports the CPlasma ESC command set (clear/cursor/font/attr/box draw+fill/text) + GetFontSize + the PlasmaPosText auto-fit/centering; PlasmaDisplay writes them over the secondary COM transport.
  • Remaining: hardware verification of axis feel + plasma output; the game-specific PlasmaScoreDraw layout 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 (136 xUnit tests total across the suite):

  • RioProfile + AppConfig model; ConfigStore JSON persistence (round-trip tested); RioIniImporter ports the legacy RIO.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.
  • RioRuntime assembles 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: NotifyIcon menu mirroring the legacy console menu (axis resets, version/status, diagnostic toggles, quit) + profile selection (auto vs. manual)
    • "start with Windows"; RioCoordinator owns the serial acquire/release tied to the watcher (native-game COM-port yield); OS adapters (ForegroundProcessProvider, AutoStartManager).
  • Joystick output now uses the real HidFeederJoystickSink when the driver is present (verified end-to-end); NullJoystickSink remains only as the no-driver fallback.
  • Remaining: full on-cabinet verification of the auto-switch + acquire/release lifecycle against real RIO hardware.

Phase 6 — Packaging / signing / deploy

  • Driver test-signing + pnputil install already scripted (driver/sign.ps1, driver/install.ps1, driver/uninstall.ps1, driver/README.md); proven on the cabinet. Still to do: app installer, a single bundled deploy, and a cabinet deployment doc. (Production option: attestation signing.)

Phase 7 — Profile/mapping editor + cockpit overlay generator — in progress

Replaces the legacy Google-Sheet → .data → GIMP → Script-Fu pipeline (see docs/reference/customBackground/).

  • Overlay generator — done , verified on real assets. RioJoy.Core/Overlay is the pure, unit-tested layout engine: FontFitter is a faithful port of the calc-fontsize auto-fit search (validated against a brute-force oracle), OverlayLayoutEngine ports create-data-layer's fit + horizontal/vertical justification, and OverlayTemplate/OverlayRegion (a regions.json via OverlayTemplateStore) hold the cell geometry/color. Label text is per-profile (RioProfile.OverlayLabels); GoobieDataImporter reads the legacy .data sheet into label rows. The rasterizer lives in src/RioJoy.Overlay (SkiaSharp): SkiaTextMeasurer (shared with the engine so measured layout == drawn output) + SkiaOverlayRenderer (draw labels → PNG). The full chain is exercised end-to-end on the real cockpit art (regions.json + riojoy.png + TEST.data) by OverlayRenderIntegrationTests. RioJoy.Tray/WallpaperApplier applies the result via SystemParametersInfo.
  • Region authoring — extraction done . tools/XcfRegionExtract parses the GIMP source (riojoy.xcf) and writes the 119-cell docs/reference/customBackground/regions.json (per-layer offsets/size/font/color, BaseImagePath → exported riojoy.png), anchored by CockpitRegionsTests. Still to do: an in-app box editor for tweaks.
  • Runtime wiring — done (opt-in). RioJoy.Overlay/ProfileWallpaperGenerator renders a profile's labels onto the template base image; RioCoordinator generates + applies the wallpaper on profile activation when AppConfig.OverlayTemplatePath is set (best-effort, off by default, never breaks activation). The live SystemParametersInfo apply changes a user setting, so it is gated behind config and not exercised by tests. Optional: restore the prior wallpaper when going dormant.
  • Mapping editor — done (cockpit control-panel layout). RioJoy.Tray/Editor/ProfileEditorForm shows the cockpit as a clickable control panel matching the original Win32 RIO design (docs/Win32RIO/, by FASA/Michel Lowrance): five MFD clusters, four board columns (Throttle/Secondary/Screen/ Joystick-Hat), an encoder-gauge strip, and the two later-added 4×4 keypads (rendered without lamps). The layout (RioJoy.Core.Editing.CockpitPanel, which places every address 0x000x47 / 0x500x6F exactly once, unit-tested) deliberately follows the design, not the wallpaper positions (those are a VGA chroma-split display artifact). Lamp buttons shade Off/assigned; keypads are neutral. Clicking a button edits its label and action/modifiers/lamp; the iRIO word goes through ButtonBindingRioMapEntry.Create — no hex. The value field is a context-sensitive picker (keyboard key by name via KeyCatalog, joystick Button N, hat direction, mouse/RIO-command enum); modifiers enable only for keyboard. Opened from the tray ("Edit profile…"); Save persists the profile. Still to refine: live encoder gauges, showing each button's assigned key as a caption, grouping a button's two bank addresses, and clone-from-existing.
  • The unified profile JSON supersedes both RIO.ini and 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: SendInput targets 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.