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:
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user