RioRuntime raises AxesUpdated with the calibrated AxisOutputs after every analog reply (independent of the joystick sink, so the gauges work while editor input routing is suppressed). The editor strip now renders the values live: Z and the L/R pedals (Rx/Ry) fill bottom-up, Rz deflects from a center tick, and the X/Y box tracks the stick as a dot over a crosshair. Fraction/deflection math is pure in Core.Editing.AxisGauges (clamped, unit-tested); the canvas repaints only the strip and only on change. Verified by offline form screenshots against injected values. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
306 lines
19 KiB
Markdown
306 lines
19 KiB
Markdown
# 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/`](../legacy/) as the behavioral
|
||
reference. The RIO wire format and input map are documented in
|
||
[PROTOCOL.md](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 Framework 4.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 Framework 4.8**, x64 — in-box on Windows 10/11, so
|
||
deployed builds are framework-dependent with no runtime to install. (Modern C#
|
||
language features that net48 lacks are supplied by the PolySharp source
|
||
generator + a few NuGet shims; see `src/RioJoy.Core/Compat/`.) Driver is C
|
||
(WDK), separate toolchain.
|
||
- **Form:** background **tray app** (NotifyIcon); start/stop managed by the
|
||
TeslaConsole launcher (no logon auto-start).
|
||
- **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](PROTOCOL.md).
|
||
|
||
### Phase 1 — Virtual HID driver — test-signed, installed, verified ✅
|
||
Implemented in [`driver/RioGamepad/`](../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`](../driver/RioGamepad/ReportDescriptor.h)).
|
||
- Device interface + custom `IOCTL_RIO_SUBMIT_REPORT` → `VhfReadReportSubmit`; the
|
||
driver is a thin relay, with the 25-byte report layout pinned in
|
||
[`Public.h`](../driver/RioGamepad/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`](../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`
|
||
(241 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);
|
||
`RioCoordinator` owns the serial acquire/release tied to the watcher (native-game
|
||
COM-port yield); OS adapters (`ForegroundProcessProvider`). The app's start/stop
|
||
lifecycle is owned by the TeslaConsole launcher (no logon auto-start).
|
||
- 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 — done ✅
|
||
- Driver test-signing + `pnputil` install scripted (`driver/sign.ps1`,
|
||
`driver/install.ps1`, `driver/uninstall.ps1`, [`driver/README.md`](../driver/README.md));
|
||
proven on the cabinet.
|
||
- **Deployment package** ([`deploy/`](../deploy/)): `build-package.ps1` produces
|
||
`dist/RIOJoy-<version>.zip` (framework-dependent net48 app + `postinstall.bat` /
|
||
`install-rio.ps1` / `pre-uninstall.bat` / `uninstall-rio.ps1`, all idempotent) with
|
||
the cabinet doc [`README-DEPLOY.txt`](../deploy/README-DEPLOY.txt). Deployed builds
|
||
use the **signed ViGEmBus** virtual controller (Xbox 360 layout, 11 buttons) so no
|
||
test signing / Secure Boot change / reboot is needed; the custom RioGamepad driver
|
||
remains the full-fidelity option for owned cabinets. App lifecycle is owned by the
|
||
TeslaConsole launcher (no auto-start). ⏳ Verify remaining: first real deploy on a
|
||
cabinet via TeslaConsole.
|
||
|
||
### Phase 7 — Profile/mapping editor + cockpit overlay generator — in progress
|
||
Replaces the legacy Google-Sheet → `.data` → GIMP → Script-Fu pipeline
|
||
(see [`docs/reference/customBackground/`](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 — 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`.
|
||
The **in-app box editor** lives in the wallpaper maker ("Edit cell boxes"): the
|
||
selected cell grows resize handles — drag to move/resize (plain clicks still
|
||
cycle stacked cells), or type exact X/Y/W/H — with the move/resize math in
|
||
`RioJoy.Core/Overlay/OverlayRegionEdit` (unit-tested, min-size clamped).
|
||
"Save template" persists the shared geometry back to the regions.json;
|
||
"Reload template" discards unsaved box edits.
|
||
- **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.
|
||
- **Wallpaper maker — done ✅.** `RioJoy.Tray/Editor/WallpaperMakerForm` (tray →
|
||
"Wallpaper maker") is the interactive replacement for the Sheet → GIMP pipeline:
|
||
it renders the profile's wallpaper live on the template base image, outlines all
|
||
119 cells, and lets the user click any cell — including the heading/banner cells
|
||
the button editor can't reach — to edit its text with debounced re-render.
|
||
Clicking a stacked cell cycles through the regions at that pixel
|
||
(`RioJoy.Core/Overlay/OverlayHitTester`, unit-tested — stacking is legitimate:
|
||
the image feeds six chroma-split displays). Imports a legacy `.data` sheet row
|
||
(game picker for multi-row sheets), exports the PNG, and can apply the desktop
|
||
wallpaper immediately (same per-profile path the runtime uses). Prompts for and
|
||
remembers `AppConfig.OverlayTemplatePath` on first use.
|
||
- **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 0x00–0x47 / 0x50–0x6F 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
|
||
`ButtonBinding` ↔ `RioMapEntry.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. The encoder
|
||
gauges are **live**: `RioRuntime.AxesUpdated` streams the calibrated axis values
|
||
(post invert/deadzone/mix — exactly what the virtual joystick receives) into the
|
||
strip at the analog poll rate — Z and the L/R pedals fill bottom-up, Rz deflects
|
||
from its center tick, and the X/Y box tracks the stick as a dot (pure fraction
|
||
math in `RioJoy.Core.Editing.AxisGauges`, unit-tested).
|
||
⏳ Still to refine: 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.
|