Three endpoint gaps, found building the Descent 3 score overlay - the boxed place|score field the original games drew over the callsign: plasma box <x> <y> <w> <h> draws an outlined box with a blanked interior, the overlay chrome, as one ESC P graphics write. The wire addresses whole bytes horizontally, so the write covers the byte-aligned span containing the box and clears span pixels outside it; documented, with 8-px alignment the advice. Boxes queue FIFO with rows. plasma text gains an explicit font: a third numeric token after the position, with text still following, so `plasma text 2 2 7` still displays "7". Auto-fit picks the font by LENGTH - short text always rendered large, and a "1" that must fit a 12-px box simply could not be sent before. ResolvePosText generalizes the legacy Score-font special case: 0 = auto, nonzero honored. Text coalescing is now per position. The global rule - any queued text superseded every other queued text - meant the documented two-field layout (callsign top, score bottom) could not survive its own send burst: the second field silently ate the first whenever both were queued. A newer text now replaces only a queued text at the same (x,y). 15 new tests; 472 pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 KiB
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; the RIO serial protocol in PROTOCOL.md; the mirror direction (cockpit inputs → game) in 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). 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). 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 (thePlasmaPosTextbehavior 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 text <x> <y> <font> <text>— as above with an explicit font (2small 5×7,5large 10×14,0auto). Auto picks the font by LENGTH, so short positioned text always renders large; the explicit form is how a short field ("1", "1000") fits inside a score box.plasma clear— blank the display.plasma row <y> <hex32>— one full 128-px bitmap row; see Bitmap graphics.plasma box <x> <y> <w> <h>— outlined box, interior blanked: the overlay chrome for a field drawn on top of other content (the original games' rank|score box over the callsign). Byte-aligned horizontally — put box edges on 8-px boundaries where neighbors matter.
Text is Latin-1 (one byte per char) — don't send UTF-8.
Not exposed through the endpoint yet (small extensions when needed): text attribute selection (intensity/underline/reverse/flash) and filled-only boxes.
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 rows 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):
"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:
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
$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
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, setPlasmaComPort: "off"instead (the app default isCOM2).
Then drive the feedback pipe from PowerShell (snippets in FEEDBACK.md) and watch the emulators.
Checklist for a new integration
- Give the game's profile a
"Feedback"section (it's off otherwise). - Choose effect lamps that the profile does not mark Lit; note their addresses from the map above.
- Decide plasma layout: one auto-centered field, or fixed pixel positions for multiple fields (pad to fixed width).
- Emit on state change only; let the board do the blinking.
- 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).
- Bench-test against
pipe:vrio/pipe:vplasmabefore touching the cabinet.