Files
riojoy/docs/FEEDBACK.md
T
CydandClaude Opus 5 46e108de89 feedback: plasma box, explicit fonts, and per-position text coalescing
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>
2026-08-02 11:45:59 -05:00

179 lines
8.1 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.
# Game feedback endpoint (game → RIOJoy → cockpit)
Phase 9 lets external programs drive the cockpit's **output** hardware through
the running RIOJoy tray app: the 72 button lamps (with board-side flash) and
the plasma/VFD text display. Anything that can write a line of text to a named
pipe or a UDP socket can use it — a DCS `Export.lua`, a SimHub plugin, a game
mod, a PowerShell one-liner. XInput **rumble** is mapped separately (no client
needed; see [Rumble → lamps](#rumble--lamps)).
This page is the wire-protocol reference. For the integrator's view — which
lamp addresses map to which physical buttons, the plasma display's geometry
and fonts, and per-game recipes — see
[OUTPUT-INTEGRATION.md](OUTPUT-INTEGRATION.md).
## Endpoints
| Transport | Address | Default |
|---|---|---|
| Named pipe | `\\.\pipe\riojoy-feedback` | **on** |
| UDP (loopback only) | `udp://127.0.0.1:<port>` | off (`UdpPort` unset) |
Both feed the same line protocol; the pipe accepts up to 4 concurrent clients.
Configured app-wide in `%APPDATA%\RIOJoy\config.json`:
```json
{ "Feedback": { "PipeEnabled": true, "PipeName": "riojoy-feedback", "UdpPort": 19910 } }
```
Omitting the `Feedback` section entirely = pipe on under the default name, UDP
off. Endpoint config is read once at the first profile activation; changes take
effect on app restart.
The endpoint is **app-lifetime**: clients keep their connection across profile
switches and dormancy. Whether commands *apply* is per-profile (see
[Gating](#gating)).
## Line protocol
One command per line. LF or CRLF line endings; on UDP, one datagram carries one
or more complete lines and the end of the datagram terminates the last line
(no trailing LF needed, no fragments across datagrams). Keywords are
case-insensitive. Lines over 256 bytes and UDP datagrams over 4 KB are dropped.
```
# comment (also ;)
lamp <addr> <state> set one lamp
lamp-all <state> set every lamp
plasma text [x y [font]] <text> write text to the plasma display
plasma clear clear the plasma display
plasma row <y> <hex32> write one full 128-px bitmap row
plasma box <x> <y> <w> <h> outlined box with a blanked interior
```
- **`<addr>`** — RIO lamp address, decimal or `0x` hex. Valid: `0x000x47`
(the 72 buttons), `0x500x5F` (keypad 0), `0x600x6F` (keypad 1). See
[PROTOCOL.md §5](PROTOCOL.md).
- **`<state>`** — either words: `[solid|slow|med|fast] off|dim|bright` (flash
defaults to `solid`), or a raw state byte `0x000x3F` (the `LampRequest`
state, [PROTOCOL.md §3](PROTOCOL.md)). `lamp 0x12 fast bright` = flash-fast
at full brightness. **The board sustains the blink** — one command starts a
flash, another (`solid dim`, `off`, …) ends it.
- **`plasma text`** — the rest of the line is the text, or quote it
(`"VIPER 1-1"`; quotes stripped, no escapes). Two leading *numeric* tokens
are a cursor position `x y`; omitted (or `0 0`) auto-fits and centers
(`PlasmaPosText`). To display something that starts with two numbers, quote
it. A **third** numeric token after the position — with text still following
— is an explicit font: `0` auto (by length: ≤9 chars large, else small),
`2` small 5×7, `5` large 10×14. Short positioned text otherwise always
renders large, which cannot fit inside a `plasma box`. Encoding is
**Latin-1** (one byte = one char, the plasma's wire encoding) — do not send
UTF-8 for accented characters. Text coalesces **per position**: a newer
queued text replaces an older one at the same `x y` only, so multi-field
layouts (callsign + score) can update one field without losing the others.
- **`plasma row`** — one full bitmap row: `<y>` 031 (decimal or `0x` hex),
then exactly **32 hex digits** = 16 bytes = 128 pixels, **MSB = leftmost**.
Rows are written strictly in arrival order (unlike `plasma text`, which
coalesces — a bitmap frame is many rows and must not tear);
`plasma clear` discards any queued rows/text. Up to 128 commands queue;
beyond that incoming rows are dropped and counted — pace full-frame pushes
(a 32-row frame is ~0.77 s of wire time at 9600 baud; stream changed rows,
as the native games did). See
[OUTPUT-INTEGRATION.md](OUTPUT-INTEGRATION.md#bitmap-graphics-esc-p).
- **`plasma box`** — an outlined 1-px box with its interior blanked, in pixel
coordinates (`x` 0127, `y` 031, must fit the panel). This is the overlay
chrome the original games drew for their rank|score field over the callsign
bitmap. The wire's graphics command spans whole bytes horizontally, so the
write covers the byte-aligned span containing the box; pixels inside the
span but outside the box are cleared — place boxes on 8-px boundaries when
that matters. Boxes queue FIFO with rows.
Malformed lines are dropped and counted (first few are logged); they **never**
cost a client its connection. The endpoint sends no replies.
## Gating
| RIOJoy state | Listeners | Commands |
|---|---|---|
| Profile active, profile has a `Feedback` section | up | applied |
| Profile active, no `Feedback` section | up | dropped |
| Dormant / native game owns the ports | up | dropped |
| Editor session | up | dropped |
Per-profile, in the profile's JSON:
```json
{
"Feedback": {
"AllowLampCommands": true,
"AllowPlasmaText": true,
"Rumble": { "LargeMotorLamps": [18, 19], "SmallMotorLamps": [96], "Threshold": 24 }
}
}
```
A profile without `"Feedback"` never applies inbound commands. (Editor UI for
these settings is a Phase 9 remaining item — edit the JSON for now.)
**Lamp ownership:** a `lamp` write to an address the profile maps as a *lighted
button* (`iRIO` bit `0x8000`, `HasLamp`) is dropped — the input router owns
those lamps (bright on press / dim on release) and feedback must not fight it.
Such drops are logged once per address. `lamp-all` silently skips owned lamps.
**Rate:** lamp commands share the 9600-baud RIO link with the ~55 ms analog
poll, so RIOJoy coalesces per-lamp state (latest wins) and sends at most one
*changed* lamp per 25 ms. Spam freely — identical states cost nothing — but a
`lamp-all` sweep takes ~3 s to fully land. Plasma writes are single-flight
over a bounded queue: flooded `text` updates coalesce to the newest value,
`clear` flushes everything queued before it, and bitmap `row`s stream in
order.
## Rumble → lamps
With a `Rumble` config (above) and the ViGEm pad active, XInput vibration set
by the game flashes the configured lamps — works with **unmodified games**:
below `Threshold` (0255) the lamps are off; the rest of the range maps to
slow / med / fast flash at full brightness, per motor. Constant rumble costs
one lamp command (the board blinks on its own). net48 flavor only (the XP
flavor has no ViGEm).
## Client snippets
DCS-style `Export.lua` (a pipe opens as a file on Windows):
```lua
local rio = io.open("\\\\.\\pipe\\riojoy-feedback", "w")
-- in your export tick:
if masterCaution then rio:write("lamp 0x12 fast bright\n")
else rio:write("lamp 0x12 off\n") end
rio:write('plasma text "' .. callsign .. '"\n')
rio:flush()
```
PowerShell, pipe (hand-testing on the cabinet):
```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))
$w.WriteLine('lamp 0x12 fast bright'); $w.WriteLine('plasma text "VIPER 1-1"'); $w.Flush()
```
PowerShell, UDP (with `"UdpPort": 19910` configured):
```powershell
$u = New-Object Net.Sockets.UdpClient
$b = [Text.Encoding]::GetEncoding(28591).GetBytes("lamp 0x12 fast bright`nplasma text 42 kills")
$u.Send($b, $b.Length, '127.0.0.1', 19910) | Out-Null
```
## Implementation map
`src/RioJoy.Core/Feedback/`: `FeedbackLineParser` (grammar → `FeedbackCommand`),
`FeedbackLineBuffer` (bytes → lines), `FeedbackPipeServer` / `FeedbackUdpListener`
(transports), `CoalescingLampScheduler` (the rate governor — all feedback lamp
traffic goes through it, never straight to `ILampSink`), `FeedbackRouter`
(gating + ownership + plasma single-flight), `RumbleLampAdapter`, and
`FeedbackService` (the façade `RioCoordinator` owns). Tests mirror the layout in
`tests/RioJoy.Core.Tests/Feedback/`.