From 44b636ddd310a7f428ed693f735e279ffc91f365 Mon Sep 17 00:00:00 2001 From: Cyd Date: Fri, 31 Jul 2026 20:45:38 -0500 Subject: [PATCH] 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 `. 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 --- README.md | 2 +- docs/FEEDBACK.md | 17 +++- docs/OUTPUT-INTEGRATION.md | 43 +++++--- docs/PLAN.md | 8 +- src/RioJoy.Core/Feedback/FeedbackCommand.cs | 9 +- .../Feedback/FeedbackLineParser.cs | 72 +++++++++++++- src/RioJoy.Core/Feedback/FeedbackRouter.cs | 99 +++++++++++++++---- src/RioJoy.Core/Plasma/PlasmaCommands.cs | 48 +++++++++ src/RioJoy.Core/Plasma/PlasmaDisplay.cs | 7 ++ .../Feedback/FeedbackLineParserTests.cs | 35 +++++++ .../Feedback/FeedbackRouterTests.cs | 70 +++++++++++++ .../Feedback/FeedbackServiceTests.cs | 22 +++++ .../Plasma/PlasmaCommandsTests.cs | 31 ++++++ .../Plasma/PlasmaDisplayTests.cs | 13 +++ 14 files changed, 435 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index f7c90f0..cb34e19 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ dotnet test RioJoy.sln ## Status -Phases 1–5 and 9 are implemented and tested (425 unit tests). Games (or sim +Phases 1–5 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 diff --git a/docs/FEEDBACK.md b/docs/FEEDBACK.md index 4f7f66f..83573b7 100644 --- a/docs/FEEDBACK.md +++ b/docs/FEEDBACK.md @@ -47,6 +47,7 @@ lamp set one lamp lamp-all set every lamp plasma text [x y] write text to the plasma display plasma clear clear the plasma display +plasma row write one full 128-px bitmap row ``` - **``** — RIO lamp address, decimal or `0x` hex. Valid: `0x00–0x47` @@ -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: `` 0–31 (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 diff --git a/docs/OUTPUT-INTEGRATION.md b/docs/OUTPUT-INTEGRATION.md index af02e9e..59a799c 100644 --- a/docs/OUTPUT-INTEGRATION.md +++ b/docs/OUTPUT-INTEGRATION.md @@ -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 `** — 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 <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 <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, 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 diff --git a/docs/PLAN.md b/docs/PLAN.md index 3ce8365..d851888 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -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 ` **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 diff --git a/src/RioJoy.Core/Feedback/FeedbackCommand.cs b/src/RioJoy.Core/Feedback/FeedbackCommand.cs index 9a02f6c..17f6925 100644 --- a/src/RioJoy.Core/Feedback/FeedbackCommand.cs +++ b/src/RioJoy.Core/Feedback/FeedbackCommand.cs @@ -14,6 +14,9 @@ public enum FeedbackCommandKind /// Clear the plasma display (plasma clear). PlasmaClear, + + /// One full 128-px bitmap row (plasma row <y> <32 hex digits>). + PlasmaRow, } /// @@ -36,8 +39,12 @@ public sealed record FeedbackCommand /// Display text ( only). public string? Text { get; init; } - /// Plasma cursor position; (0,0) = auto-fit/center (PlasmaPosText). + /// Plasma cursor position; (0,0) = auto-fit/center (PlasmaPosText). + /// For , is the row. public byte X { get; init; } public byte Y { get; init; } + + /// Row pixel bytes ( only; 16 bytes, MSB leftmost). + public byte[]? Data { get; init; } } diff --git a/src/RioJoy.Core/Feedback/FeedbackLineParser.cs b/src/RioJoy.Core/Feedback/FeedbackLineParser.cs index ae4247a..eb8d72f 100644 --- a/src/RioJoy.Core/Feedback/FeedbackLineParser.cs +++ b/src/RioJoy.Core/Feedback/FeedbackLineParser.cs @@ -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 <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) { diff --git a/src/RioJoy.Core/Feedback/FeedbackRouter.cs b/src/RioJoy.Core/Feedback/FeedbackRouter.cs index 4d7080f..968feef 100644 --- a/src/RioJoy.Core/Feedback/FeedbackRouter.cs +++ b/src/RioJoy.Core/Feedback/FeedbackRouter.cs @@ -17,8 +17,13 @@ namespace RioJoy.Core.Feedback; /// misconfigured client is diagnosable. lamp-all 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: text coalesces (only the newest +/// queued text survives — a flooding score updater shows the latest value), +/// clear flushes everything queued before it, and bitmap rows +/// 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. /// public sealed class FeedbackRouter { @@ -40,10 +45,13 @@ public sealed class FeedbackRouter public HashSet 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 _plasmaQueue = new(); private Target? _target; private bool _plasmaBusy; - private FeedbackCommand? _plasmaPending; private long _dropped; /// Diagnostics (dropped profile-owned lamp writes, plasma faults). @@ -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); diff --git a/src/RioJoy.Core/Plasma/PlasmaCommands.cs b/src/RioJoy.Core/Plasma/PlasmaCommands.cs index ead3704..86bf1ed 100644 --- a/src/RioJoy.Core/Plasma/PlasmaCommands.cs +++ b/src/RioJoy.Core/Plasma/PlasmaCommands.cs @@ -37,6 +37,15 @@ public static class PlasmaCommands /// Select font (ESC K font). public static byte[] Font(byte font) => new[] { Esc, (byte)'K', font }; + /// Panel width in pixels (columns 0–127). + public const int Columns = 128; + + /// Panel height in pixel rows (0–31). + public const int Rows = 32; + + /// Bytes per full bitmap row (128 px / 8, byte columns 0–15). + public const int RowBytes = Columns / 8; + /// Draw a box outline (ESC X l t r b). 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 }; + /// + /// Bitmap graphics write (ESC P s y x w h data…): 1-bpp pixels, + /// MSB = leftmost, starting at row (0–31) and byte + /// column (0–15), bytes + /// across rows. Command set recovered in vRIO's + /// PlasmaProtocol.cs (Tesla 4.10 sources + firmware dump); the + /// native game streams whole changed rows (x=0, w=16, h=1 — + /// ). + /// + 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; + } + + /// One full 128-px bitmap row at (16 bytes, MSB leftmost). + public static byte[] GraphicsRow(byte y, byte[] row) => + GraphicsWrite(y, 0, (byte)RowBytes, 1, row); + /// Encode display text as raw bytes (Latin-1, one byte per char). public static byte[] Text(string text) { diff --git a/src/RioJoy.Core/Plasma/PlasmaDisplay.cs b/src/RioJoy.Core/Plasma/PlasmaDisplay.cs index 5e2205d..3b5a3d8 100644 --- a/src/RioJoy.Core/Plasma/PlasmaDisplay.cs +++ b/src/RioJoy.Core/Plasma/PlasmaDisplay.cs @@ -31,6 +31,13 @@ public sealed class PlasmaDisplay public Task TextAsync(string text, CancellationToken ct = default) => WriteLockedAsync(new[] { PlasmaCommands.Text(text) }, ct); + /// + /// Write one full 128-px bitmap row at (0–31): + /// 16 bytes, MSB = leftmost pixel (). + /// + public Task RowAsync(byte y, byte[] row, CancellationToken ct = default) => + WriteLockedAsync(new[] { PlasmaCommands.GraphicsRow(y, row) }, ct); + /// /// Position the cursor, set attribute + font, and write text — the /// PlasmaPosText sequence (auto-fit via diff --git a/tests/RioJoy.Core.Tests/Feedback/FeedbackLineParserTests.cs b/tests/RioJoy.Core.Tests/Feedback/FeedbackLineParserTests.cs index 04f0a64..67e1d39 100644 --- a/tests/RioJoy.Core.Tests/Feedback/FeedbackLineParserTests.cs +++ b/tests/RioJoy.Core.Tests/Feedback/FeedbackLineParserTests.cs @@ -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 diff --git a/tests/RioJoy.Core.Tests/Feedback/FeedbackRouterTests.cs b/tests/RioJoy.Core.Tests/Feedback/FeedbackRouterTests.cs index 9f73be0..d029e24 100644 --- a/tests/RioJoy.Core.Tests/Feedback/FeedbackRouterTests.cs +++ b/tests/RioJoy.Core.Tests/Feedback/FeedbackRouterTests.cs @@ -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() { diff --git a/tests/RioJoy.Core.Tests/Feedback/FeedbackServiceTests.cs b/tests/RioJoy.Core.Tests/Feedback/FeedbackServiceTests.cs index fc64d09..af1d630 100644 --- a/tests/RioJoy.Core.Tests/Feedback/FeedbackServiceTests.cs +++ b/tests/RioJoy.Core.Tests/Feedback/FeedbackServiceTests.cs @@ -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() { diff --git a/tests/RioJoy.Core.Tests/Plasma/PlasmaCommandsTests.cs b/tests/RioJoy.Core.Tests/Plasma/PlasmaCommandsTests.cs index 65a280e..6f874f6 100644 --- a/tests/RioJoy.Core.Tests/Plasma/PlasmaCommandsTests.cs +++ b/tests/RioJoy.Core.Tests/Plasma/PlasmaCommandsTests.cs @@ -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(() => PlasmaCommands.GraphicsRow(32, new byte[16])); + Assert.Throws( + () => PlasmaCommands.GraphicsWrite(0, 15, 2, 1, new byte[2])); // spills past byte column 15 + Assert.Throws( + () => PlasmaCommands.GraphicsWrite(31, 0, 16, 2, new byte[32])); // spills past row 31 + Assert.Throws( + () => PlasmaCommands.GraphicsWrite(0, 0, 16, 1, new byte[15])); // data length mismatch + } + [Theory] [InlineData(0, 5, 7)] [InlineData(3, 5, 7)] diff --git a/tests/RioJoy.Core.Tests/Plasma/PlasmaDisplayTests.cs b/tests/RioJoy.Core.Tests/Plasma/PlasmaDisplayTests.cs index 2221720..c7e7a4c 100644 --- a/tests/RioJoy.Core.Tests/Plasma/PlasmaDisplayTests.cs +++ b/tests/RioJoy.Core.Tests/Plasma/PlasmaDisplayTests.cs @@ -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() {