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
@@ -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()
{