Files
riojoy/docs/PLAN.md
T
CydandClaude Opus 4.8 8d2d0b71aa Phase 7: cockpit overlay generator, region extraction, and profile editor
Overlay generator (pure, tested) — RioJoy.Core/Overlay:
- FontFitter ports the legacy calc-fontsize auto-fit search (validated against a
  brute-force oracle); OverlayLayoutEngine ports create-data-layer's fit + h/v
  justification and adds per-region 90 deg CCW rotation; OverlayTemplate/Region +
  OverlayTemplateStore hold cell geometry/colour/rotation (regions.json).
- GoobieDataImporter parses the legacy .data label sheet into label rows.
- src/RioJoy.Overlay: SkiaSharp rasterizer (SkiaTextMeasurer shared with the engine
  so measured layout == drawn output; SkiaOverlayRenderer -> PNG;
  ProfileWallpaperGenerator). Verified end-to-end on the real cockpit art
  (regions.json + riojoy.png + TEST.data) by OverlayRenderIntegrationTests.
- RioJoy.Tray/WallpaperApplier applies via SystemParametersInfo; RioCoordinator
  generates+applies on profile activation (opt-in via AppConfig.OverlayTemplatePath).

Region geometry — tools/XcfRegionExtract parses riojoy.xcf (a GIMP-format binary
reader, no GIMP needed) into the 119-cell regions.json: per-layer offsets/size/
font/colour, with 90 deg CCW rotation on a-00 + b-10..b-1F. Base image riojoy.png.
NOTE: the wallpaper positions are a VGA chroma-split display artifact (6 displays),
not the cockpit's logical layout.

Mapping editor (first cut) — RioJoy.Tray/Editor/ProfileEditorForm renders the
config sheet's logical grid (SheetLayout parses the sheet CSV export). The iRIO
word is edited via ButtonBinding <-> RioMapEntry.Create (no hex bit-twiddling), with
a context-sensitive picker: keyboard keys by name (KeyCatalog VK names), joystick
Button N, hat direction, mouse/RIO-command enum names; modifiers only for keyboard.
Opened from the tray ("Edit profile..."). The sheet-grid layout is a starting point
that needs rework from a better-formatted source.

~220 xUnit tests green. Docs (PLAN.md, README) updated.

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

271 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 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](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`
(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`](../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/`](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 — first cut done ✅.** `RioJoy.Tray/Editor/ProfileEditorForm`
shows the cockpit as a scrollable, clickable grid that mirrors the legacy
authoring spreadsheet's **logical** layout — parsed from the sheet's CSV export
(`docs/reference/customBackground/config-sheet.csv`) by `SheetLayout`, coloured
by section. (The layout deliberately follows the sheet, **not** the wallpaper
region positions: the wallpaper drives 6 displays via VGA chroma-channel
splitting, so its label positions stack and don't reflect the physical cockpit.)
Clicking an address cell edits its label and action/modifiers/lamp; the `iRIO`
word is read/written through `ButtonBinding``RioMapEntry.Create` (round-trip
tested) — no hex bit-twiddling. The value field is a **context-sensitive picker**:
keyboard actions pick a key by name (`KeyCatalog` VK names), joystick a Button N,
hat a direction, mouse/RIO-command the enum name; modifiers enable only for
keyboard. Opened from the tray ("Edit profile…"); Save persists the profile.
⏳ Still to refine: grouping a button's two bank addresses (edit once → both),
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.