Files
riojoy/docs/PLAN.md
T
CydandClaude Fable 5 bdd30678e3 pod: fully explicit activation (--profile) + RIOJoy.Tray.Ready signal
No detection anywhere in the pod chain (Cyd: foreground detection is too
slow - the pad appears after the game already enumerated controllers).
--profile <name> activates immediately at startup, never runs the
auto-switch watcher, and on success signals the named manual-reset event
RIOJoy.Tray.Ready (process-lifetime, never stale) so a launcher waits on
the signal instead of counting winmm devices. Legible failures for the
launcher: exit 4 unknown profile, exit 5 activation failed with the reason
on stderr - both verified live against the built exe. Editor close
re-activates the explicit profile. build-pod start scripts now pass
--profile <Name> --exit-with <exe>; docs updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 23:01:44 -05:00

564 lines
37 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 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. Runtime
plasma wiring (secondary port open, greeting, teardown) landed in **Phase 9**;
the legacy game-specific `PlasmaScoreDraw` layout is superseded by the Phase 9
feedback endpoint (external clients draw score/status content —
[`docs/FEEDBACK.md`](FEEDBACK.md)).
### 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.
- **Per-profile ViGEm axis routing — code-complete ✅.** `RioProfile.AxisRouting`
(`Output/AxisRoutingConfig`: one route per calibrated axis = pad target —
four thumbs, two triggers, or None — + output mode; shared types with no
ViGEm references, so net40 keeps compiling) re-routes the six axes on the
Xbox 360 pad. Modes: `Centered` (legacy bipolar) and `UnipolarPositive`
(calibrated 0 → thumb center — for the ratcheted unipolar throttle). Null/
absent = the historical fixed routing, so existing profiles are untouched.
Resolution + conversion is the pure unit-tested `AxisRouter`;
`ViGEmJoystickSink.SetRouting` applies it thread-safely and neutralizes all
thumbs/triggers each profile switch (no stale axes); wired in
`RioCoordinator.Activate` beside the calibrator config. First consumer:
`profiles/descent-d1x.json` (throttle→RightThumbY unipolar, rudder→
RightThumbX, triggers untouched — DXX fires on the LT/RT axis-buttons).
The HID feeder (native 6-axis report) intentionally ignores routing.
⏳ Remaining: on-cabinet throttle/rudder feel check (first Descent flight).
-**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. **Restore-on-dormant — done ✅:**
`RioCoordinator` captures the user's own wallpaper (`WallpaperApplier.GetCurrent`)
the first time it overrides it, and puts it back (`WallpaperApplier.Restore`) on
every `GoDormant` and on `Dispose` — so idle/native-game/exit return the desktop
to what the user had. Capture is once-per-override (switching between cockpit
profiles never records a cockpit wallpaper as the "previous"); only engages when
`OverlayTemplatePath` is set. Verified 2026-07-19 on Win11 by a capture→apply→
restore round-trip against the compiled `WallpaperApplier` — including the
empty-wallpaper case (`GetCurrent``''`, apply, `Restore("")` clears back to the
original solid-color desktop). Known gap: a hard crash between apply and restore
leaves the cockpit wallpaper (SPIF_UPDATEINIFILE persists it) — a future
crash-recovery could persist the saved path to config.
- **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 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
`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 an `AxisReadout` (the six
virtual-joystick outputs plus the calibrated pre-mix pedal positions) 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). L/R read the pedals
from before the ZR mix, since the mix pins Rx/Ry to center.
⏳ 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).
### Phase 8 — Windows XP compatibility (dual-target) — in progress
(8A/8C/8D done ✅; remaining: 8B driver + 8E XP-VM/cabinet verification)
Bring RIOJoy back to the original XP-era cabinets (x86, XP SP3) **without
regressing Windows 10/11**. Strategy: one codebase, two flavors, **one
universal deployment archive**. The mapping editor ships everywhere
(decided); only wallpaper *generation* (SkiaSharp) stays modern-only —
XP consumes pre-rendered wallpapers.
| | Windows 10/11 (unchanged) | Windows XP SP3 |
|---|---|---|
| TFM / arch | net48, x64 | **net40, x86** (.NET 4.0 is XP's ceiling; 4.5+ needs Vista) |
| Virtual joystick | ViGEm → RioGamepad → none (unchanged) | **RioGamepadXP.sys** — our own thin WDM HID minidriver (same feeder contract) — full 6-axis/96-button fidelity |
| Overlay render | SkiaSharp (generate + apply) | consume pre-rendered wallpaper only (**PNG→BMP** — XP's `SystemParametersInfo` takes BMP only) |
| Editor | full (incl. wallpaper maker) | **mapping editor included** (decided; pure WinForms/GDI+); wallpaper maker gated (Skia) |
| JSON | System.Text.Json → **Newtonsoft 13** | Newtonsoft 13 (STJ needs net461+; one serializer for both flavors) |
| Install scripts | PowerShell | **.bat only** (XP has no in-box PowerShell) |
- **8A — Core retarget — done ✅** (`net48;net40` multi-target): de-Span the
protocol/serial layer (≈20 uses / 11 files → `byte[]`/`ArraySegment`;
System.Memory doesn't go below net45, and at 9600 baud Span buys nothing);
swap System.Text.Json → Newtonsoft in `ConfigStore`/`OverlayTemplateStore`
(both TFMs, so the config format can't drift); async on net40 via
**Microsoft.Bcl.Async** + a `Compat/TaskCompat` shim (6 call sites:
Task.Run/Delay/WhenAny/WhenAll → TaskEx; XP prereq: KB2468871, bundle it);
shim the one `HashCode.Combine`; `#if`-gate the net48-only sinks
(`ViGEmJoystickSink`, `HidFeederJoystickSink`, `Hid/`).
*Risk fallback:* if Bcl.Async misbehaves on real XP, the receive loop
reverts to a dedicated thread (the legacy `CommWatchProc` shape) for net40.
- **8B — RioGamepadXP.sys — built ✅ (static; XP bring-up pending in 8E).**
Source in [`driver/RioGamepadXP/`](../driver/RioGamepadXP/): a WDM HID
minidriver (`HidRegisterMinidriver`, **polled mode** so there's no pending-
IRP/cancel machinery) presenting the 6-axis/hat/96-button joystick, plus a
named sideband control device (`\\.\RioGamepadXP`) that takes
`IOCTL_RIO_SUBMIT_REPORT` — the **identical `Public.h` contract** as the
modern driver (same 25-byte report, VID/PID). A dispatch wrapper routes the
control device's CREATE/CLOSE/DEVICE_CONTROL to us and forwards the HID FDO's
to hidclass. Builds with WDK 7.1.0 (`build.cmd``setenv … fre x86 WXP
no_oacr`) to `RioGamepadXP.sys` (x86, subsystem 5.01). XP enforces no kernel
signing, so install is unsigned; the device is root-enumerated so it needs
**devcon** (`devcon install RioGamepadXP.inf root\RioGamepadXP`, also built
from the WDK) rather than InstallHinfSection. The net40 feeder opens the
driver by name (`#if NET40` branch in `HidFeederJoystickSink`); XP can't
register a device interface on a bare control device (no PDO), the one
contract nuance. Bundled into the package's `vendor\xp\`. Precedent: the
original FASA `tasgame.sys` was itself an XP HID minidriver (docs/Win32RIO/,
analyzed); ours stays thin with serial in user mode. No third-party virtual
joystick (vJoy/PPJoy unmaintained). Acquisition order: net48 ViGEm →
RioGamepad → Null (unchanged); net40 RioGamepadXP → Null. ⏳ **8E:** the
driver compiles clean but is **unverified at runtime** — needs an XP target
to confirm joy.cpl enumeration + report flow. *Staging:* the app is useful
before the driver — milestone 1 ships keyboard/mouse + lamps + plasma
(joystick = Null sink) if the driver isn't present.
- **8C — Tray on net40/x86 — done ✅:** multi-target `RioJoy.Tray` (net40 drops the
ViGEm + RioJoy.Overlay references); gate `WallpaperMakerForm` + overlay
generation; `WallpaperApplier` converts PNG→BMP via GDI+ before
`SystemParametersInfo` (harmless on 10/11, required on XP); profile editor
stays (Segoe UI falls back to Tahoma). XP cabinets consume wallpapers
pre-rendered on a modern machine (`RioProfile.WallpaperPath` travels with
the config).
- **8D — Packaging — done ✅ (one universal archive, two install entry
points).** `build-package.ps1` produces a single `RIOJoy-<ver>.zip` (~75 MB
with the offline XP redistributables) that deploys on **both** XP and 10/11;
the RioGamepadXP driver files bundle automatically once 8B lands
(warn/skip until then). The shared OS-detecting install logic lives in
`RIOJoy\install-core.bat` (pure cmd on the XP path); shortcuts via
`make-shortcut.vbs` (works on XP and 10/11 alike):
- Layout: `RIOJoy\app\` (net48 x64) + `RIOJoy\app-xp\` (net40 x86) +
`RIOJoy\vendor\` (ViGEmBus installer for 10/11; RioGamepadXP.sys + INF
for XP; **.NET 4.0 Full + KB2468871 redistributables** so an offline XP
cabinet needs nothing else — adds ~70 MB, XP can't download anymore) +
`VERSION.txt` + README-DEPLOY (payload-internal detail doc).
- **`README.txt`** (zip root): written for a person installing on a
standalone computer — which Windows versions are supported, what the
two .bat entry points are and which one to run (`install.bat` for a
standalone machine; `postinstall.bat` is the cabinet launcher's hook),
that all prerequisites are bundled for offline install, and where the
app lands. Plain ASCII so XP-era Notepad renders it cleanly.
- **`postinstall.bat`** (zip root, unattended): the TeslaConsole/launcher
entry point, as today — detects the OS (`ver` → 5.1 = XP, 10.x = modern),
installs the matching prereqs (ViGEmBus silently via the existing
PowerShell on 10/11; .NET 4.0 + KB2468871 + driver INF on XP), and wires
the matching app flavor. No prompts, idempotent. **Its final step
deletes `install.bat` and the root `README.txt`** — on cabinet deploys
the zip extracts into `C:\games`, and only launcher-managed files may
stay at that root (`del` tolerating already-missing files, so re-runs
stay idempotent).
- **`install.bat`** (zip root, freestanding computers): same OS detection
and prereq install, plus what a machine without the launcher needs —
Start Menu/desktop shortcut to the right flavor's exe (still no logon
auto-start; starting RIOJoy stays deliberate). Pure cmd.exe on the XP
path; may call PowerShell only on the 10/11 path. Leaves the README in
place (it's the standalone machine's documentation).
- `pre-uninstall.bat` / uninstall mirror both scenarios. Release the one
zip to Gitea per the established process.
- **8E — Verification:** full suite stays net48-hosted (xUnit needs
net452+; shared sources are what's tested) + a tiny net40 console
self-test for the shims, run on XP. Ladder: net40/x86 binary boots on
Win10 → XP VM with vRIO over a virtual COM pair (app milestone; the
driver needs real/virtualized XP too — vhidmini-class drivers run fine
in a VM) → real cabinet (joy.cpl shows 6 axes + 96 buttons, SendInput
into a game, lamps, plasma, auto-switch yield, BMP wallpaper).
- **All open decisions resolved:**
1. The Xbox 360 pad (ViGEm) remains the preferred controller on
Windows 10/11 whenever ViGEmBus is present.
2. **No third-party virtual joystick drivers** (vJoy/PPJoy are
unmaintained) — XP gets our own RioGamepadXP.sys.
3. The **mapping editor ships in all instances**, XP included.
4. **Both install scenarios** in one archive: `postinstall.bat`
(TeslaConsole/launcher, unattended) and `install.bat` (freestanding
computers, adds shortcuts); the single dist zip carries everything
needed for both XP and 10/11, including offline redistributables.
### Phase 9 — Game feedback (game → cockpit) — code-complete ✅
Inbound feedback endpoint + plasma runtime wiring + rumble→lamp mapping, in
`src/RioJoy.Core/Feedback` (442 xUnit tests total across the suite); protocol
spec + client snippets in [`docs/FEEDBACK.md`](FEEDBACK.md). Delivers the
§Profiles promises "Lamp behavior" and "Plasma/VFD content (or 'off')".
- **Endpoint**: `FeedbackPipeServer` serves `\\.\pipe\riojoy-feedback`
(read-only — no replies ever, which sidesteps the 0-buffer pipe write
deadlock class; ≤4 concurrent clients; reconnect forever; vRIO's
`VRioPipeService` server pattern incl. the poke-connect stop) and
`FeedbackUdpListener` binds loopback-only UDP (off by default,
`AppConfig.Feedback.UdpPort` — the transport sim export scripts speak
natively). One shared text line protocol: `FeedbackLineParser` +
`FeedbackLineBuffer` (Latin-1, LF/CRLF, forgiving — malformed lines drop and
log, never the connection), including `plasma row <y> <hex32>` **bitmap
streaming** (`PlasmaCommands.GraphicsWrite` ports the display's `ESC P`
graphics command per vRIO's recovered `PlasmaProtocol`; the router queues
rows strictly FIFO while texts coalesce and clear flushes, bounded at 128).
`FeedbackService` façades the lot; it lives in
`RioCoordinator` for the **app lifetime**, so clients keep their connection
across profile switches and dormancy — only command *application* is gated.
- **Rate governor**: `CoalescingLampScheduler` — per-address desired/last-sent
shadow state, at most one *changed* lamp per 25 ms tick, round-robin. All
feedback lamp traffic (pipe/UDP and rumble) posts here; nothing feedback-side
calls `ILampSink` directly, because every lamp command crosses the link's
stop-and-wait command gate (~150 ms worst case) shared with the ~55 ms
analog poll.
- **Routing/precedence**: `FeedbackRouter` — per-profile gating
(`RioProfile.Feedback`, null = feedback off; `AllowLampCommands`/
`AllowPlasmaText`), profile-owned lamps (`HasLamp`) protected from
press/release fights (dropped, logged once per address per attach), plasma
writes single-flight with a latest-pending-wins slot.
- **Plasma wired at last** (closes the Phase 4 ⏳ wiring): `RioCoordinator.
Activate` opens `PlasmaComPort ?? DefaultPlasmaComPort` via the transport
factory (`pipe:` endpoints work for benchless testing; `"off"`/empty skips;
failure becomes a status suffix and never breaks activation), shows
`PlasmaGreeting` auto-centered, blanks + releases the port on teardown (the
native games open this port too). `PlasmaDisplay` gained its missing write
lock — `PosTextAsync` is five transport writes, and concurrent callers used
to interleave ESC fragments (`PlasmaDisplayTests` pins both the sequence and
the no-interleave guarantee).
- **Rumble → lamps** (net48 only): `ViGEmJoystickSink.RumbleChanged` (plain
byte delegate over ViGEm's `FeedbackReceived`; fires on a ViGEm-owned
thread) → `RumbleLampAdapter`: off below `Threshold`, then slow/med/fast
thirds at full brightness per motor, posting only state **changes** so
XInput's identical-value spam costs nothing — the board sustains the blink
from the state byte. Works with unmodified games that set XInput vibration.
- Config: `FeedbackEndpointConfig` (app-wide) + `ProfileFeedbackConfig` /
`RumbleLampConfig` (per-profile); nullable sections = off/defaults, keeping
pre-Phase-9 JSON byte-compatible (round-trip, unset-stays-null, and
shipped-profile cases in `ConfigStoreTests`).
- ⏳ **Remaining:** on-cabinet verification (real lamps + plasma glass, link
feel under game load); timed flash-then-restore effects (the scheduler's
shadow state is the designed hook); editor UI for the per-profile feedback
settings (JSON-only today); shipped client examples (SimHub plugin / DCS
export script) beyond the FEEDBACK.md snippets.
### Phase 10 — Pod-bundled deployment — code-complete ✅
Deployment topology decision (2026-07-31): RIO hardware exists only on **pods**
(the cockpit cabinets) and dev boxes — no freestanding end-user PCs. Production
model is therefore **one RIOJoy copy bundled inside each podized game's
folder**, started by the game's launch script and exiting with the game; no
resident RIOJoy runs on a pod, and the native games simply don't bundle one
(making the COM-port yield machinery vestigial in production). The resident
tray + auto-switch reclassifies as the development harness. 455 xUnit tests
total across the suite.
- **Portable config**: `ConfigLocator.Resolve` — a `config.json` beside the
exe wins over `%APPDATA%\RIOJoy\config.json`; `TrayApplicationContext.
ConfigPath` resolves through it, so `--import-profile` targets the same
store. A pod bundle needs no import step: its config *is* the profile.
- **`--profile <name>`** — explicit immediate activation, **no detection**:
in pod mode the auto-switch watcher never runs. The rationale is
enumeration timing: games enumerate controllers at startup, and foreground
detection activates ~1 s after the window appears — too late, so the pod
launch script activates the profile *before* starting the game and the
ViGEm pad already exists when the game looks. On success RIOJoy signals the
named event `RIOJoy.Tray.Ready` (process-lifetime — never stale) so a pod
launcher waits on the signal instead of counting input devices. Exit code 4
for an unknown profile name (validated up front so a pod-script typo is
scriptable, not a silently idle tray) and 5 for failed activation (reason
on stderr) — the launcher can always tell "failed with reason" from
"hung". Closing the editor re-activates the explicit profile.
- **`--exit-with <exe|pid>`** (`CompanionTarget.Parse` — pid, or a name
normalized like auto-switch triggers): the tray polls the companion on its
existing 1 s timer and quits through the normal teardown (ports released,
wallpaper restored, plasma blanked) once the game has run and then gone.
`CompanionExit` holds the pure decision — launch order isn't guaranteed, so
a never-seen companion only triggers exit after a 60 s startup grace
(also covers "game failed to launch"). Clock-free and unit-tested
(`tests/.../Hosting/CompanionExitTests`).
- **Instance handoff**: with `--exit-with`, a starting instance waits up to
15 s for the predecessor's single-instance mutex (game A's copy tearing
down while game B's starts) instead of the historical silent exit-0; plain
launches keep the instant-exit behavior. Abandoned mutex (predecessor
crash) counts as acquired.
- **`deploy/build-pod.ps1`**: emits the **self-contained** per-game drop-in —
nothing is ever installed on a pod by hand. Inner layout mirrors the
universal package (`app`/`app-xp`, `vendor`, `install-core.bat`,
`install-rio.ps1` reused **verbatim** — no forked install logic), plus the
portable `config.json` beside the exe (the game's profile document
verbatim), `start-riojoy.bat` (`--exit-with` prefilled from the profile's
first trigger), and `install-riojoy.bat`: called from the **game's
`postinstall.bat`**, self-elevating, **idempotent** (installs only what's
absent — ViGEmBus on net48; .NET 4.0 + KB2468871 + RioGamepadXP via devcon
on net40 — and never removes anything). There is deliberately **no
uninstall step**: drivers are abandoned in place on game removal, since
nothing can know whether another podized game still uses them and idle
drivers are harmless. Zipped as `RIOJoy-pod-<name>-<stamp>.zip` (~8.4 MB
net48 incl. ViGEmBus). Verified by building the Descent bundle and
round-tripping its emitted config through `ConfigStore.Load`.
Gotcha for future edits: PS 5.1 reads BOM-less scripts as ANSI, where a
UTF-8 em dash decodes into a smart quote that *terminates strings* — keep
deploy scripts pure ASCII.
- ⏳ **Remaining:** on-pod verification of the full launch/handoff cycle
(launcher → game A → quit → game B); podize a first real game with the
bundle; revisit the universal zip's `install.bat` framing (dev-setup only)
once pod deploys are routine.
---
## 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.