plasma: ESC P bitmap rows through the feedback endpoint (plasma row)

PlasmaCommands.GraphicsWrite/GraphicsRow port the display firmware graphics
command (ESC P s y x w h, MSB-left); PlasmaDisplay.RowAsync writes a locked
whole-row update; the line protocol gains `plasma row <y> <hex32>`. The
router plasma slot becomes a bounded FIFO queue: rows stream in order (a
frame must not tear), texts still coalesce to the newest, clear flushes, cap
128 with counted drops. Docs updated; 442 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-31 20:45:38 -05:00
co-authored by Claude Fable 5
parent 66c3cbdb57
commit 44b636ddd3
14 changed files with 435 additions and 41 deletions
+1 -1
View File
@@ -43,7 +43,7 @@ dotnet test RioJoy.sln
## Status
Phases 15 and 9 are implemented and tested (425 unit tests). Games (or sim
Phases 15 and 9 are implemented and tested (442 unit tests). Games (or sim
export scripts) can drive the cockpit lamps and plasma display back through the
running app — see [`docs/FEEDBACK.md`](docs/FEEDBACK.md). The `RioGamepad` virtual
HID driver is built (KMDF + VHF), **test-signed, installed, and verified**: it
+14 -3
View File
@@ -47,6 +47,7 @@ lamp <addr> <state> set one lamp
lamp-all <state> set every lamp
plasma text [x y] <text> write text to the plasma display
plasma clear clear the plasma display
plasma row <y> <hex32> write one full 128-px bitmap row
```
- **`<addr>`** — RIO lamp address, decimal or `0x` hex. Valid: `0x000x47`
@@ -63,6 +64,15 @@ plasma clear clear the plasma display
(`PlasmaPosText`). To display something that starts with two numbers, quote
it. Encoding is **Latin-1** (one byte = one char, the plasma's wire
encoding) — do not send UTF-8 for accented characters.
- **`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 to the newest — 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).
Malformed lines are dropped and counted (first few are logged); they **never**
cost a client its connection. The endpoint sends no replies.
@@ -99,9 +109,10 @@ 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,
latest-pending-wins: flood-updating a score means only the newest pending text
is written.
`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
+31 -12
View File
@@ -107,11 +107,13 @@ What the endpoint exposes (v1):
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, box draw/fill, and the bitmap mode below.
font/attribute selection and box draw/fill.
### Bitmap graphics (`ESC P`)
@@ -134,20 +136,37 @@ 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.
Status: the hardware and the vPlasma emulator fully support `ESC P`, but
RIOJoy's `PlasmaCommands` port and the feedback endpoint don't expose it yet.
The natural extension is a line command (e.g.
`plasma row <y> <32-hex-digit row>` or a base64 block form) — if an
integration needs it, that plus a `PlasmaCommands.GraphicsWrite` builder is a
small, self-contained addition.
The endpoint exposes whole-row writes as a line command:
```
plasma row <y> <32 hex digits>
```
`y` is 031; 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 031
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, latest-pending-wins**: while one text is
being written, only the *newest* pending command survives. Flooding a score
update every frame is safe — the display shows the latest value — but
interleaving *two different fields* at high rate from one client can starve
one of them. Update fields on change, not on a timer.
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
+6 -2
View File
@@ -436,7 +436,7 @@ XP consumes pre-rendered wallpapers.
### Phase 9 — Game feedback (game → cockpit) — code-complete ✅
Inbound feedback endpoint + plasma runtime wiring + rumble→lamp mapping, in
`src/RioJoy.Core/Feedback` (425 xUnit tests total across the suite); protocol
`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`
@@ -447,7 +447,11 @@ spec + client snippets in [`docs/FEEDBACK.md`](FEEDBACK.md). Delivers the
`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). `FeedbackService` façades the lot; it lives in
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
+8 -1
View File
@@ -14,6 +14,9 @@ public enum FeedbackCommandKind
/// <summary>Clear the plasma display (<c>plasma clear</c>).</summary>
PlasmaClear,
/// <summary>One full 128-px bitmap row (<c>plasma row &lt;y&gt; &lt;32 hex digits&gt;</c>).</summary>
PlasmaRow,
}
/// <summary>
@@ -36,8 +39,12 @@ public sealed record FeedbackCommand
/// <summary>Display text (<see cref="FeedbackCommandKind.PlasmaText"/> only).</summary>
public string? Text { get; init; }
/// <summary>Plasma cursor position; (0,0) = auto-fit/center (<c>PlasmaPosText</c>).</summary>
/// <summary>Plasma cursor position; (0,0) = auto-fit/center (<c>PlasmaPosText</c>).
/// For <see cref="FeedbackCommandKind.PlasmaRow"/>, <see cref="Y"/> is the row.</summary>
public byte X { get; init; }
public byte Y { get; init; }
/// <summary>Row pixel bytes (<see cref="FeedbackCommandKind.PlasmaRow"/> only; 16 bytes, MSB leftmost).</summary>
public byte[]? Data { get; init; }
}
+71 -1
View File
@@ -145,7 +145,7 @@ public static class FeedbackLineParser
string? sub = NextToken(s, ref pos);
if (sub is null)
{
error = "plasma needs a subcommand (text|clear)";
error = "plasma needs a subcommand (text|clear|row)";
return false;
}
@@ -163,12 +163,82 @@ public static class FeedbackLineParser
case "text":
return TryParsePlasmaText(s, pos, out command, out error);
case "row":
return TryParsePlasmaRow(s, pos, out command, out error);
default:
error = $"unknown plasma subcommand '{sub}'";
return false;
}
}
// plasma row <y> <32 hex digits>: one full 128-px bitmap row (16 bytes,
// MSB = leftmost pixel), matching the native game's whole-row streaming.
private static bool TryParsePlasmaRow(
string s, int pos, out FeedbackCommand? command, out string? error)
{
command = null;
error = null;
string? yToken = NextToken(s, ref pos);
if (yToken is null || !TryParseNumber(yToken, out int y))
{
error = "plasma row needs a row number and 32 hex digits";
return false;
}
if (y is < 0 or > 31)
{
error = $"plasma row {y} out of range (0-31)";
return false;
}
string? hex = NextToken(s, ref pos);
if (hex is null)
{
error = "plasma row needs 32 hex digits of row data";
return false;
}
if (NextToken(s, ref pos) is string extra)
{
error = $"unexpected token '{extra}'";
return false;
}
if (hex.Length != 32)
{
error = $"plasma row data must be exactly 32 hex digits (16 bytes), got {hex.Length}";
return false;
}
var data = new byte[16];
for (int i = 0; i < 16; i++)
{
int hi = HexNibble(hex[i * 2]);
int lo = HexNibble(hex[i * 2 + 1]);
if (hi < 0 || lo < 0)
{
error = $"plasma row data has a non-hex character ('{hex[hi < 0 ? i * 2 : i * 2 + 1]}')";
return false;
}
data[i] = (byte)((hi << 4) | lo);
}
command = new FeedbackCommand
{
Kind = FeedbackCommandKind.PlasmaRow,
Y = (byte)y,
Data = data,
};
return true;
}
private static int HexNibble(char c) => c switch
{
>= '0' and <= '9' => c - '0',
>= 'a' and <= 'f' => c - 'a' + 10,
>= 'A' and <= 'F' => c - 'A' + 10,
_ => -1,
};
private static bool TryParsePlasmaText(
string s, int pos, out FeedbackCommand? command, out string? error)
{
+78 -21
View File
@@ -17,8 +17,13 @@ namespace RioJoy.Core.Feedback;
/// misconfigured client is diagnosable. <c>lamp-all</c> silently skips
/// profile-owned lamps for the same reason.
///
/// Plasma writes are single-flight with a latest-pending-wins slot, so a
/// flooding client cannot queue unbounded 9600-baud text writes.
/// Plasma writes are single-flight over a small bounded queue whose rules
/// match what each command means: <c>text</c> coalesces (only the newest
/// queued text survives — a flooding score updater shows the latest value),
/// <c>clear</c> flushes everything queued before it, and bitmap <c>row</c>s
/// are FIFO in arrival order (a frame is many rows; coalescing would tear
/// it). The bound caps what a flooding client can queue against the
/// 9600-baud display port.
/// </summary>
public sealed class FeedbackRouter
{
@@ -40,10 +45,13 @@ public sealed class FeedbackRouter
public HashSet<int> LoggedOwnedDrops { get; } = new();
}
// ~4 full bitmap frames; beyond this an incoming row is dropped (counted).
private const int MaxPlasmaQueue = 128;
private readonly object _gate = new();
private readonly List<FeedbackCommand> _plasmaQueue = new();
private Target? _target;
private bool _plasmaBusy;
private FeedbackCommand? _plasmaPending;
private long _dropped;
/// <summary>Diagnostics (dropped profile-owned lamp writes, plasma faults).</summary>
@@ -63,7 +71,7 @@ public sealed class FeedbackRouter
lock (_gate)
{
_target = new Target(lamps, map, plasma, config);
_plasmaPending = null; // pending text belonged to the previous profile
_plasmaQueue.Clear(); // queued content belonged to the previous profile
}
}
@@ -73,7 +81,7 @@ public sealed class FeedbackRouter
lock (_gate)
{
_target = null;
_plasmaPending = null;
_plasmaQueue.Clear();
}
}
@@ -102,6 +110,7 @@ public sealed class FeedbackRouter
break;
case FeedbackCommandKind.PlasmaText:
case FeedbackCommandKind.PlasmaClear:
case FeedbackCommandKind.PlasmaRow:
DispatchPlasma(target, command);
break;
}
@@ -152,23 +161,66 @@ public sealed class FeedbackRouter
lock (_gate)
{
if (_plasmaBusy)
switch (command.Kind)
{
if (_plasmaPending is not null)
Interlocked.Increment(ref _dropped); // superseded before it ran
_plasmaPending = command; // latest wins
return;
case FeedbackCommandKind.PlasmaClear:
// A clear supersedes everything queued before it.
for (int i = 0; i < _plasmaQueue.Count; i++)
Interlocked.Increment(ref _dropped);
_plasmaQueue.Clear();
_plasmaQueue.Add(command);
break;
case FeedbackCommandKind.PlasmaText:
// Only the newest text survives (a score updater shows the
// latest value); queued rows keep their place.
for (int i = _plasmaQueue.Count - 1; i >= 0; i--)
{
if (_plasmaQueue[i].Kind == FeedbackCommandKind.PlasmaText)
{
_plasmaQueue.RemoveAt(i);
Interlocked.Increment(ref _dropped); // superseded before it ran
}
}
_plasmaQueue.Add(command);
break;
default: // PlasmaRow: strict FIFO — a bitmap frame is many rows
if (_plasmaQueue.Count >= MaxPlasmaQueue)
{
Interlocked.Increment(ref _dropped); // client outran the display
return;
}
_plasmaQueue.Add(command);
break;
}
if (_plasmaBusy)
return;
_plasmaBusy = true;
command = TakeQueuedPlasma()!;
}
StartPlasmaWrite(target, command);
}
// Caller holds _gate.
private FeedbackCommand? TakeQueuedPlasma()
{
if (_plasmaQueue.Count == 0)
return null;
FeedbackCommand head = _plasmaQueue[0];
_plasmaQueue.RemoveAt(0);
return head;
}
private void StartPlasmaWrite(Target target, FeedbackCommand command)
{
Task write = command.Kind == FeedbackCommandKind.PlasmaClear
? target.Plasma!.ClearAsync()
: target.Plasma!.PosTextAsync(command.Text ?? string.Empty, command.X, command.Y);
Task write = command.Kind switch
{
FeedbackCommandKind.PlasmaClear => target.Plasma!.ClearAsync(),
FeedbackCommandKind.PlasmaRow => target.Plasma!.RowAsync(command.Y, command.Data!),
_ => target.Plasma!.PosTextAsync(command.Text ?? string.Empty, command.X, command.Y),
};
write.ContinueWith(w =>
{
@@ -179,18 +231,23 @@ public sealed class FeedbackRouter
Target? current;
lock (_gate)
{
next = _plasmaPending;
_plasmaPending = null;
current = _target; // pending text applies to the *current* profile's display
if (next is null || current?.Plasma is null || !current.Config.AllowPlasmaText)
current = _target; // queued content applies to the *current* profile's display
if (current?.Plasma is null || !current.Config.AllowPlasmaText)
{
_plasmaBusy = false; // chain ends; a racing Dispatch starts a fresh one
if (next is not null)
for (int i = 0; i < _plasmaQueue.Count; i++)
Interlocked.Increment(ref _dropped);
_plasmaQueue.Clear();
_plasmaBusy = false;
return;
}
// Busy stays true across the chained write, so latest-wins ordering
// holds — concurrent dispatches keep landing in the pending slot.
next = TakeQueuedPlasma();
if (next is null)
{
_plasmaBusy = false; // queue drained; a racing Dispatch starts fresh
return;
}
// Busy stays true across the chained write, so queue ordering
// holds — concurrent dispatches keep appending behind us.
}
StartPlasmaWrite(current, next);
}, TaskContinuationOptions.ExecuteSynchronously);
+48
View File
@@ -37,6 +37,15 @@ public static class PlasmaCommands
/// <summary>Select font (<c>ESC K font</c>).</summary>
public static byte[] Font(byte font) => new[] { Esc, (byte)'K', font };
/// <summary>Panel width in pixels (columns 0127).</summary>
public const int Columns = 128;
/// <summary>Panel height in pixel rows (031).</summary>
public const int Rows = 32;
/// <summary>Bytes per full bitmap row (128 px / 8, byte columns 015).</summary>
public const int RowBytes = Columns / 8;
/// <summary>Draw a box outline (<c>ESC X l t r b</c>).</summary>
public static byte[] BoxDraw(byte left, byte top, byte right, byte bottom) =>
new[] { Esc, (byte)'X', left, top, right, bottom };
@@ -45,6 +54,45 @@ public static class PlasmaCommands
public static byte[] BoxFill(byte left, byte top, byte right, byte bottom) =>
new[] { Esc, (byte)'x', (byte)0, left, top, right, bottom };
/// <summary>
/// Bitmap graphics write (<c>ESC P s y x w h data…</c>): 1-bpp pixels,
/// MSB = leftmost, starting at row <paramref name="y"/> (031) and byte
/// column <paramref name="x"/> (015), <paramref name="bytesPerRow"/> bytes
/// across <paramref name="rows"/> rows. Command set recovered in vRIO's
/// <c>PlasmaProtocol.cs</c> (Tesla 4.10 sources + firmware dump); the
/// native game streams whole changed rows (<c>x=0, w=16, h=1</c> —
/// <see cref="GraphicsRow"/>).
/// </summary>
public static byte[] GraphicsWrite(byte y, byte x, byte bytesPerRow, byte rows, byte[] data)
{
if (data is null) throw new ArgumentNullException(nameof(data));
if (y >= Rows)
throw new ArgumentOutOfRangeException(nameof(y), $"Row must be 0..{Rows - 1}.");
if (x >= RowBytes)
throw new ArgumentOutOfRangeException(nameof(x), $"Byte column must be 0..{RowBytes - 1}.");
if (bytesPerRow == 0 || x + bytesPerRow > RowBytes)
throw new ArgumentOutOfRangeException(nameof(bytesPerRow), "Row span exceeds the panel width.");
if (rows == 0 || y + rows > Rows)
throw new ArgumentOutOfRangeException(nameof(rows), "Row span exceeds the panel height.");
if (data.Length != bytesPerRow * rows)
throw new ArgumentException($"Expected {bytesPerRow * rows} data bytes, got {data.Length}.", nameof(data));
var command = new byte[7 + data.Length];
command[0] = Esc;
command[1] = (byte)'P';
command[2] = 0; // screen — single-screen hardware
command[3] = y;
command[4] = x;
command[5] = bytesPerRow;
command[6] = rows;
Array.Copy(data, 0, command, 7, data.Length);
return command;
}
/// <summary>One full 128-px bitmap row at <paramref name="y"/> (16 bytes, MSB leftmost).</summary>
public static byte[] GraphicsRow(byte y, byte[] row) =>
GraphicsWrite(y, 0, (byte)RowBytes, 1, row);
/// <summary>Encode display text as raw bytes (Latin-1, one byte per char).</summary>
public static byte[] Text(string text)
{
+7
View File
@@ -31,6 +31,13 @@ public sealed class PlasmaDisplay
public Task TextAsync(string text, CancellationToken ct = default) =>
WriteLockedAsync(new[] { PlasmaCommands.Text(text) }, ct);
/// <summary>
/// Write one full 128-px bitmap row at <paramref name="y"/> (031):
/// 16 bytes, MSB = leftmost pixel (<see cref="PlasmaCommands.GraphicsRow"/>).
/// </summary>
public Task RowAsync(byte y, byte[] row, CancellationToken ct = default) =>
WriteLockedAsync(new[] { PlasmaCommands.GraphicsRow(y, row) }, ct);
/// <summary>
/// Position the cursor, set attribute + font, and write text — the
/// <c>PlasmaPosText</c> sequence (auto-fit via
@@ -184,6 +184,41 @@ public class FeedbackLineParserTests
{
Assert.NotEmpty(ParseError(line));
}
[Fact]
public void PlasmaRow_ParsesRowAndHexData()
{
FeedbackCommand cmd = Parse("plasma row 5 80000000000000000000000000000001");
Assert.Equal(FeedbackCommandKind.PlasmaRow, cmd.Kind);
Assert.Equal(5, cmd.Y);
Assert.NotNull(cmd.Data);
Assert.Equal(16, cmd.Data!.Length);
Assert.Equal(0x80, cmd.Data[0]); // leftmost pixel lit (MSB-first)
Assert.Equal(0x01, cmd.Data[15]);
Assert.All(cmd.Data.Skip(1).Take(14), b => Assert.Equal(0, b));
}
[Fact]
public void PlasmaRow_HexRowNumber_AndMixedCaseHex()
{
FeedbackCommand cmd = Parse("PLASMA ROW 0x1F AaBbCcDdEeFf00112233445566778899");
Assert.Equal(31, cmd.Y);
Assert.Equal(0xAA, cmd.Data![0]);
Assert.Equal(0x99, cmd.Data[15]);
}
[Theory]
[InlineData("plasma row")] // no row
[InlineData("plasma row 5")] // no data
[InlineData("plasma row 32 80000000000000000000000000000001")] // row out of range
[InlineData("plasma row 5 8000")] // too short
[InlineData("plasma row 5 800000000000000000000000000000010A")] // too long
[InlineData("plasma row 5 8000000000000000000000000000000G")] // non-hex char
[InlineData("plasma row 5 80000000000000000000000000000001 x")] // trailing token
public void PlasmaRow_Malformed_ReturnsErrorText(string line)
{
Assert.NotEmpty(ParseError(line));
}
}
public class FeedbackLineBufferTests
@@ -34,6 +34,13 @@ public class FeedbackRouterTests : IDisposable
private static FeedbackCommand Text(string text) =>
new() { Kind = FeedbackCommandKind.PlasmaText, Text = text };
private static FeedbackCommand Row(byte y)
{
var data = new byte[16];
data[0] = y; // distinguishable payload per row
return new FeedbackCommand { Kind = FeedbackCommandKind.PlasmaRow, Y = y, Data = data };
}
[Fact]
public async Task Lamp_ProfileOwnedAddressDropped_UnownedApplied()
{
@@ -139,6 +146,69 @@ public class FeedbackRouterTests : IDisposable
Assert.Equal(2, router.DroppedCommands); // the two superseded middles
}
[Fact]
public async Task PlasmaRows_StreamInFifoOrder_NotCoalesced()
{
// A bitmap frame is many rows — unlike text, rows must all land, in order.
var transport = new GatedTransport();
var router = new FeedbackRouter();
router.Attach(_scheduler, new RioInputMap(), new PlasmaDisplay(transport),
new ProfileFeedbackConfig());
router.Dispatch(Row(0)); // goes busy, parked on the gate
router.Dispatch(Row(1));
router.Dispatch(Row(2));
transport.Open();
Assert.Equal(PlasmaCommands.GraphicsRow(0, Row(0).Data!), await transport.NextWriteAsync());
Assert.Equal(PlasmaCommands.GraphicsRow(1, Row(1).Data!), await transport.NextWriteAsync());
Assert.Equal(PlasmaCommands.GraphicsRow(2, Row(2).Data!), await transport.NextWriteAsync());
Assert.Equal(0, router.DroppedCommands);
}
[Fact]
public async Task PlasmaClear_FlushesQueuedRowsAndTexts()
{
var transport = new GatedTransport();
var router = new FeedbackRouter();
router.Attach(_scheduler, new RioInputMap(), new PlasmaDisplay(transport),
new ProfileFeedbackConfig());
router.Dispatch(Row(0)); // in flight, parked
router.Dispatch(Row(1)); // queued…
router.Dispatch(Text("STALE"));
router.Dispatch(new FeedbackCommand { Kind = FeedbackCommandKind.PlasmaClear }); // …flushed
transport.Open();
Assert.Equal(PlasmaCommands.GraphicsRow(0, Row(0).Data!), await transport.NextWriteAsync());
Assert.Equal(PlasmaCommands.Clear(), await transport.NextWriteAsync());
await Task.Delay(50);
Assert.True(transport.NoMoreWrites);
Assert.Equal(2, router.DroppedCommands); // the flushed row + text
}
[Fact]
public async Task PlasmaRowQueue_IsBounded()
{
var transport = new GatedTransport();
var router = new FeedbackRouter();
router.Attach(_scheduler, new RioInputMap(), new PlasmaDisplay(transport),
new ProfileFeedbackConfig());
router.Dispatch(Row(0)); // parked in flight; everything below queues
for (int i = 0; i < 140; i++)
router.Dispatch(Row(1));
Assert.Equal(140 - 128, router.DroppedCommands); // over the 128-entry bound
transport.Open();
// Drain: the parked row + the 128 queued ones.
for (int i = 0; i < 129; i++)
await transport.NextWriteAsync();
await Task.Delay(50);
Assert.True(transport.NoMoreWrites);
}
[Fact]
public async Task PlasmaClear_WritesTheClearCommand()
{
@@ -68,6 +68,28 @@ public class FeedbackServiceTests
Assert.Equal("Lamp(0x11,0x00)", lamps.Snapshot()[1]);
}
[Fact]
public async Task PipeClient_StreamsBitmapRows()
{
string name = UniqueName();
var plasmaTransport = new FakeTransport();
using var service = new FeedbackService(new FeedbackEndpointConfig { PipeName = name });
service.Start();
service.Attach(new RecordingSink(), new RioInputMap(),
new PlasmaDisplay(plasmaTransport), new ProfileFeedbackConfig());
using NamedPipeClientStream client = Connect(name);
Send(client, "plasma row 0 FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF\n" +
"plasma row 1 80000000000000000000000000000001\n");
var solid = Enumerable.Repeat((byte)0xFF, 16).ToArray();
var edges = new byte[16];
edges[0] = 0x80;
edges[15] = 0x01;
Assert.Equal(PlasmaCommands.GraphicsRow(0, solid), await plasmaTransport.NextWriteAsync());
Assert.Equal(PlasmaCommands.GraphicsRow(1, edges), await plasmaTransport.NextWriteAsync());
}
[Fact]
public async Task Detach_DropsCommands_ReattachAppliesAgain()
{
@@ -30,6 +30,37 @@ public class PlasmaCommandsTests
Assert.Equal(new byte[] { (byte)'A', (byte)'B', (byte)'C' }, PlasmaCommands.Text("ABC"));
}
[Fact]
public void GraphicsWrite_LaysOutHeaderThenData()
{
// ESC P s y x w h data… (s=0, single-screen hardware).
byte[] cmd = PlasmaCommands.GraphicsWrite(5, 2, 2, 2, new byte[] { 0xAA, 0xBB, 0xCC, 0xDD });
Assert.Equal(new byte[] { 27, (byte)'P', 0, 5, 2, 2, 2, 0xAA, 0xBB, 0xCC, 0xDD }, cmd);
}
[Fact]
public void GraphicsRow_IsAWholeRowWrite()
{
// The native game's shape: x=0, w=16, h=1 — one full 128-px row.
var row = new byte[16];
row[0] = 0x80; // leftmost pixel (MSB-first)
byte[] cmd = PlasmaCommands.GraphicsRow(31, row);
byte[] expected = new byte[] { 27, (byte)'P', 0, 31, 0, 16, 1 }.Concat(row).ToArray();
Assert.Equal(expected, cmd);
}
[Fact]
public void GraphicsWrite_RejectsOutOfPanelSpans()
{
Assert.Throws<ArgumentOutOfRangeException>(() => PlasmaCommands.GraphicsRow(32, new byte[16]));
Assert.Throws<ArgumentOutOfRangeException>(
() => PlasmaCommands.GraphicsWrite(0, 15, 2, 1, new byte[2])); // spills past byte column 15
Assert.Throws<ArgumentOutOfRangeException>(
() => PlasmaCommands.GraphicsWrite(31, 0, 16, 2, new byte[32])); // spills past row 31
Assert.Throws<ArgumentException>(
() => PlasmaCommands.GraphicsWrite(0, 0, 16, 1, new byte[15])); // data length mismatch
}
[Theory]
[InlineData(0, 5, 7)]
[InlineData(3, 5, 7)]
@@ -53,6 +53,19 @@ public class PlasmaDisplayTests
Assert.Equal(PlasmaCommands.Clear(), await transport.NextWriteAsync());
}
[Fact]
public async Task RowAsync_WritesOneGraphicsRowCommand()
{
var transport = new FakeTransport();
var display = new PlasmaDisplay(transport);
var row = new byte[16];
row[3] = 0xF0;
await display.RowAsync(12, row).WithTimeout();
Assert.Equal(PlasmaCommands.GraphicsRow(12, row), await transport.NextWriteAsync());
}
[Fact]
public async Task PosTextAsync_ConcurrentCalls_DoNotInterleave()
{