Companion to OUTPUT-INTEGRATION.md: the three input surfaces (ViGEm x360 pad, SendInput scancode keyboard/mouse, RioGamepad HID), per-button routing kinds incl. the fixed 11-button pad order, axis calibration + routing with the triggers-are-buttons trap and the shipped Descent pattern, per-game strategy, profile building/triggers workflow, benchless testing, checklist. Cross-linked from README and the output guide. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
270 lines
12 KiB
Markdown
270 lines
12 KiB
Markdown
# Plasma & output integration guide
|
||
|
||
How to make a game, sim, or companion tool drive the cockpit's **outputs**
|
||
through RIOJoy: the lighted buttons (lamps) and the plasma/VFD text display.
|
||
This is the integrator's view — what the hardware can show, which addresses
|
||
mean what, and how to feed them. The exact wire grammar lives in
|
||
[FEEDBACK.md](FEEDBACK.md); the RIO serial protocol in
|
||
[PROTOCOL.md](PROTOCOL.md); the mirror direction (cockpit inputs → game) in
|
||
[INPUT-INTEGRATION.md](INPUT-INTEGRATION.md).
|
||
|
||
## The four output channels
|
||
|
||
| Channel | Trigger | Effort | Good for |
|
||
|---|---|---|---|
|
||
| **Automatic lamp feedback** | button press/release | none — built in | lighting the button the player just pressed |
|
||
| **Feedback endpoint** (`lamp` / `plasma` lines) | your code writes a text line to a pipe or UDP | small script | warning lights, status flashes, callsigns, scores |
|
||
| **Rumble → lamps** | game sets XInput vibration | config only, no code | damage/fire effects from **unmodified** games |
|
||
| **Plasma greeting** | profile activation | config only | a static per-game banner |
|
||
|
||
All four apply only while a profile is **active**; the profile opts into the
|
||
endpoint and rumble channels with its `"Feedback"` JSON section (see
|
||
[Gating in FEEDBACK.md](FEEDBACK.md#gating)). When RIOJoy is dormant or a
|
||
native game owns the ports, endpoint clients stay connected but commands drop.
|
||
|
||
## The lamp model
|
||
|
||
### What a lamp is
|
||
|
||
Each of the **72 cockpit buttons** (RIO addresses `0x00–0x47`) has a built-in
|
||
lamp. A lamp is set with a single state byte combining a **flash mode**
|
||
(solid / slow / med / fast) and a **brightness** (off / dim / bright). The
|
||
**board runs the blink itself** — one command starts a sustained flash, another
|
||
ends it. There is no per-frame cost to a flashing lamp.
|
||
|
||
The two 4×4 keypads (`0x50–0x5F` internal, `0x60–0x6F` external) are valid
|
||
protocol addresses but have **no physical lamps** — writes to them are
|
||
accepted and do nothing visible.
|
||
|
||
### Address map (functional groups)
|
||
|
||
From the panel model (`RioJoy.Core.Editing.CockpitPanel`, mirrored in the
|
||
profile editor):
|
||
|
||
| Group | Addresses | Layout |
|
||
|---|---|---|
|
||
| Lower Right MFD | `0x00–0x07` | 4×2; top row `07 06 05 04`, bottom `03 02 01 00` |
|
||
| Lower Left MFD | `0x08–0x0F` | 4×2; top `0F 0E 0D 0C`, bottom `0B 0A 09 08` |
|
||
| Secondary column | `0x10–0x17` | vertical 8 |
|
||
| Screen column | `0x18–0x1F` | vertical 8 |
|
||
| Upper Middle MFD | `0x20–0x27` | 4×2; top `27 26 25 24`, bottom `23 22 21 20` |
|
||
| Upper Left MFD | `0x28–0x2F` | 4×2; top `2F 2E 2D 2C`, bottom `2B 2A 29 28` |
|
||
| Upper Right MFD | `0x30–0x37` | 4×2; top `37 36 35 34`, bottom `33 32 31 30` |
|
||
| Throttle column | `0x38–0x3F` | vertical 8 (`3D` Panic, `3F` Throttle) |
|
||
| Joystick cluster | `0x40–0x47` | `40` Main, `41–44` hat B/U/R/L, `45` Pinky, `46` Middle, `47` Upper |
|
||
| Internal keypad | `0x50–0x5F` | 4×4, **no lamps** |
|
||
| External keypad | `0x60–0x6F` | 4×4, **no lamps** |
|
||
|
||
(`0x48–0x4F` is a gap — not valid addresses.)
|
||
|
||
### Ownership: pick lamps the profile doesn't use
|
||
|
||
Lamps on buttons the active profile maps as *lighted* (`Lit` in the editor,
|
||
`iRIO` bit `0x8000`) belong to the automatic press/release feedback — endpoint
|
||
writes to them are **dropped** (logged once per address) so your effect can't
|
||
fight the built-in behavior. Design your effects on buttons the profile leaves
|
||
unlit — a dedicated "warning" MFD cluster the game doesn't bind, for example —
|
||
or deliberately leave the target buttons un-Lit in the profile.
|
||
|
||
### Rate budget
|
||
|
||
The RIO link is 9600 baud, shared with the ~55 ms analog poll. RIOJoy
|
||
coalesces lamp state per address (latest wins) and sends **at most one changed
|
||
lamp per 25 ms** (~40/s). Practical consequences:
|
||
|
||
- Send *state changes*, not periodic refreshes. Repeating the current state
|
||
costs nothing but also does nothing.
|
||
- A whole-panel effect (`lamp-all`, or sweeping many addresses) takes ~25 ms ×
|
||
changed-lamp-count to fully land — a 104-lamp sweep is ~3 s. Fine for an
|
||
attract mode; wrong for a fast strobe. For fast effects, use the board's own
|
||
flash modes on a few lamps instead.
|
||
|
||
## The plasma display model
|
||
|
||
The plasma is a **128 × 32 dot-matrix panel** on its own serial port at 9600
|
||
8N1 (so display updates never contend with the input link). It is fully
|
||
dot-addressable: it has a text mode (cursor + fonts + attributes) **and a raw
|
||
bitmap mode** (`ESC P` graphics write — see
|
||
[Bitmap graphics](#bitmap-graphics-esc-p)). The command set is documented in
|
||
vRIO's `VPlasma.Core/Protocol/PlasmaProtocol.cs`, recovered from the Tesla
|
||
4.10 sources and the display firmware dump.
|
||
|
||
Text mode has two glyph sizes:
|
||
|
||
| Font | Cell | Fits per line | Auto-selected when |
|
||
|---|---|---|---|
|
||
| large (font 5) | 10×14 px | ~12 chars | text ≤ 9 chars |
|
||
| small (font 2) | 5×7 px | ~25 chars | text 10–20 chars (longer is truncated to 20) |
|
||
|
||
What the endpoint exposes (v1):
|
||
|
||
- **`plasma text <text>`** — auto-fit: short text renders large, longer text
|
||
small, centered on the display (the `PlasmaPosText` behavior the original
|
||
games used; its pivot is x=56, faithfully a touch left of the true 128-px
|
||
center). This is the right default for callsigns, scores, and status words.
|
||
- **`plasma text <x> <y> <text>`** — explicit cursor position in **pixels**,
|
||
top-left origin; the firmware accepts x 0–127, y 0–31. You choose the
|
||
position; the font still auto-fits by length. Use this to keep two fields on
|
||
screen at once (e.g. callsign top line, score bottom line:
|
||
`plasma text 2 2 "VIPER 1-1"` + `plasma text 2 18 "SCORE 4200"`).
|
||
- **`plasma clear`** — blank the display.
|
||
- **`plasma row <y> <hex32>`** — one full 128-px bitmap row; see
|
||
[Bitmap graphics](#bitmap-graphics-esc-p).
|
||
|
||
Text is **Latin-1** (one byte per char) — don't send UTF-8.
|
||
|
||
Not exposed through the endpoint yet (small extensions when needed): explicit
|
||
font/attribute selection and box draw/fill.
|
||
|
||
### Bitmap graphics (`ESC P`)
|
||
|
||
The display accepts **raw bitmap rows** — this is how the native Red Planet
|
||
game draws everything (it renders into a local 1-bpp buffer and streams the
|
||
*changed* rows). The wire command:
|
||
|
||
```
|
||
ESC P s y x w h data…
|
||
```
|
||
|
||
`s` = screen (single-screen hardware, ignored), `y` = top row (0–31),
|
||
`x` = left **byte column** (0–15), `w` = bytes per row, `h` = rows, followed
|
||
by `w×h` data bytes, **MSB = leftmost pixel**. The native game always sends
|
||
whole rows: `x=0, w=16, h=1` — 16 bytes covering one full 128-px row.
|
||
|
||
Budget the bandwidth: a full-frame repaint is 32 rows × (7-byte header +
|
||
16 data bytes) ≈ 740 bytes ≈ **0.77 s** at 9600 baud. That's why the native
|
||
game diffs and streams only changed rows — an animation that touches a few
|
||
rows per tick is smooth; full-frame repaints are ~1 fps. Text mode is far
|
||
cheaper for text; reserve bitmaps for logos, custom gauges, and icons.
|
||
|
||
The endpoint exposes whole-row writes as a line command:
|
||
|
||
```
|
||
plasma row <y> <32 hex digits>
|
||
```
|
||
|
||
`y` is 0–31; the 32 hex digits are the row's 16 bytes left-to-right, MSB =
|
||
leftmost pixel. Rows stream strictly in arrival order (up to 128 queued;
|
||
overflow drops the incoming row and counts it), so push a frame as rows 0–31
|
||
and it lands intact. Example — a horizontal rule across row 16 and a lit
|
||
top-left corner block:
|
||
|
||
```
|
||
plasma row 16 FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF
|
||
plasma row 0 F0000000000000000000000000000000
|
||
```
|
||
|
||
For animation, keep a 1-bpp frame buffer client-side and send only the rows
|
||
that changed since the last tick — exactly what the native game does. The
|
||
partial-span form of `ESC P` (arbitrary `x/w/h`) exists in
|
||
`PlasmaCommands.GraphicsWrite` for host-side code but is not exposed as a
|
||
line command.
|
||
|
||
### Update semantics
|
||
|
||
Plasma writes are **single-flight over a bounded queue** whose rules follow
|
||
what each command means: flooded `text` updates coalesce to the newest value
|
||
(safe to spam a score — the display shows the latest), `clear` discards
|
||
everything queued before it, and bitmap `row`s stream strictly in order
|
||
(never coalesced — a frame is many rows). Still: update fields on change, not
|
||
on a timer.
|
||
|
||
Lifecycle: on profile activation the display clears and shows the profile's
|
||
`PlasmaGreeting` (if set); your first `plasma` command replaces it. On
|
||
teardown (profile switch, dormancy, native-game yield) RIOJoy blanks the
|
||
display and releases the port.
|
||
|
||
`plasma text` does **not** clear the rest of the display — it draws at a
|
||
position. When a new value is shorter than the old one (`SCORE 900` after
|
||
`SCORE 1200`), stale pixels can remain; pad the text to a fixed width or
|
||
`plasma clear` first when the layout changes.
|
||
|
||
## Recipes
|
||
|
||
### Any XInput game — rumble, zero code
|
||
|
||
Add to the profile's JSON (`%APPDATA%\RIOJoy\config.json`):
|
||
|
||
```json
|
||
"Feedback": { "Rumble": { "LargeMotorLamps": [32, 33], "SmallMotorLamps": [34], "Threshold": 24 } }
|
||
```
|
||
|
||
Vibration now flashes Upper-Middle-MFD lamps `0x20–0x22`: off below the
|
||
threshold, slow/med/fast flash as intensity rises, per motor. Works with any
|
||
game that rumbles the ViGEm pad (net48 flavor only).
|
||
|
||
### DCS World — Export.lua
|
||
|
||
A pipe opens as a file on Windows; write lines, flush, done:
|
||
|
||
```lua
|
||
local rio = io.open("\\\\.\\pipe\\riojoy-feedback", "w")
|
||
local wasCaution = nil
|
||
|
||
function LuaExportAfterNextFrame()
|
||
if not rio then return end
|
||
local caution = LoGetMCPState and LoGetMCPState().MasterWarning
|
||
if caution ~= wasCaution then -- send changes, not frames
|
||
wasCaution = caution
|
||
rio:write(caution and "lamp 0x12 fast bright\n" or "lamp 0x12 off\n")
|
||
rio:flush()
|
||
end
|
||
end
|
||
|
||
function LuaExportStart()
|
||
if rio then
|
||
rio:write('plasma text "' .. (LoGetPilotName() or "PILOT") .. '"\n')
|
||
rio:flush()
|
||
end
|
||
end
|
||
```
|
||
|
||
(Or configure `"UdpPort"` and use DCS's `socket` library — same lines over
|
||
UDP, one or more per datagram.)
|
||
|
||
### SimHub / other telemetry hubs
|
||
|
||
Any plugin that can emit custom TCP/UDP/serial output can target the UDP
|
||
endpoint (`127.0.0.1:<UdpPort>`) with protocol lines. Map telemetry properties
|
||
to `lamp` lines (e.g. shift light → `lamp 0x3D fast bright`) and text
|
||
properties to `plasma text`.
|
||
|
||
### Games with no API — log tailing
|
||
|
||
```powershell
|
||
$p = New-Object IO.Pipes.NamedPipeClientStream '.', 'riojoy-feedback', ([IO.Pipes.PipeDirection]::Out)
|
||
$p.Connect(2000)
|
||
$w = New-Object IO.StreamWriter $p, ([Text.Encoding]::GetEncoding(28591))
|
||
Get-Content 'C:\games\thegame\events.log' -Wait -Tail 0 | ForEach-Object {
|
||
if ($_ -match 'PLAYER_HIT') { $w.WriteLine('lamp 0x24 fast bright'); $w.Flush() }
|
||
if ($_ -match 'SCORE=(\d+)') { $w.WriteLine("plasma text SCORE $($Matches[1])"); $w.Flush() }
|
||
}
|
||
```
|
||
|
||
## Testing without hardware
|
||
|
||
Both output ports accept `pipe:` endpoints, so the [vRIO](https://gitea.mysticmachines.com/VWE/VRIO)
|
||
emulators stand in for the cabinet:
|
||
|
||
- Profile `RioComPort: "pipe:vrio"` → vRIO's board emulator; its panel shows
|
||
lamp states, including flash.
|
||
- Profile `PlasmaComPort: "pipe:vplasma"` → the vPlasma display emulator
|
||
renders the 112×32 output. On a machine with neither display nor COM2, set
|
||
`PlasmaComPort: "off"` instead (the app default is `COM2`).
|
||
|
||
Then drive the feedback pipe from PowerShell (snippets in
|
||
[FEEDBACK.md](FEEDBACK.md#client-snippets)) and watch the emulators.
|
||
|
||
## Checklist for a new integration
|
||
|
||
1. Give the game's profile a `"Feedback"` section (it's off otherwise).
|
||
2. Choose effect lamps that the profile does **not** mark Lit; note their
|
||
addresses from the map above.
|
||
3. Decide plasma layout: one auto-centered field, or fixed pixel positions for
|
||
multiple fields (pad to fixed width).
|
||
4. Emit on **state change** only; let the board do the blinking.
|
||
5. Reconnect logic: on pipe write failure, close, reopen, retry — RIOJoy's
|
||
endpoint accepts reconnects forever, and commands sent while dormant are
|
||
dropped by design (your client doesn't need to track RIOJoy's state).
|
||
6. Bench-test against `pipe:vrio` / `pipe:vplasma` before touching the cabinet.
|