diff --git a/docs/FEEDBACK.md b/docs/FEEDBACK.md index 83573b7..4a3ea7b 100644 --- a/docs/FEEDBACK.md +++ b/docs/FEEDBACK.md @@ -43,11 +43,12 @@ case-insensitive. Lines over 256 bytes and UDP datagrams over 4 KB are dropped. ``` # comment (also ;) -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 +lamp set one lamp +lamp-all set every lamp +plasma text [x y [font]] write text to the plasma display +plasma clear clear the plasma display +plasma row write one full 128-px bitmap row +plasma box outlined box with a blanked interior ``` - **``** — RIO lamp address, decimal or `0x` hex. Valid: `0x00–0x47` @@ -62,17 +63,30 @@ plasma row write one full 128-px bitmap row (`"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. Encoding is **Latin-1** (one byte = one char, the plasma's wire - encoding) — do not send UTF-8 for accented characters. + 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: `` 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); + 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` 0–127, `y` 0–31, 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. diff --git a/docs/OUTPUT-INTEGRATION.md b/docs/OUTPUT-INTEGRATION.md index 9b7f657..a047bef 100644 --- a/docs/OUTPUT-INTEGRATION.md +++ b/docs/OUTPUT-INTEGRATION.md @@ -107,14 +107,22 @@ What the endpoint exposes (v1): position; the font still auto-fits by length. Use this to keep two fields on 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 text `** — as above with an explicit font + (`2` small 5×7, `5` large 10×14, `0` auto). Auto picks the font by LENGTH, + so short positioned text always renders large; the explicit form is how a + short field ("1", "1000") fits inside a score box. - **`plasma clear`** — blank the display. - **`plasma row `** — one full 128-px bitmap row; see [Bitmap graphics](#bitmap-graphics-esc-p). +- **`plasma box `** — outlined box, interior blanked: the + overlay chrome for a field drawn on top of other content (the original + games' rank|score box over the callsign). Byte-aligned horizontally — put + box edges on 8-px boundaries where neighbors matter. 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 and box draw/fill. +Not exposed through the endpoint yet (small extensions when needed): text +attribute selection (intensity/underline/reverse/flash) and filled-only boxes. ### Bitmap graphics (`ESC P`) diff --git a/src/RioJoy.Core/Feedback/FeedbackCommand.cs b/src/RioJoy.Core/Feedback/FeedbackCommand.cs index 17f6925..9d21c0f 100644 --- a/src/RioJoy.Core/Feedback/FeedbackCommand.cs +++ b/src/RioJoy.Core/Feedback/FeedbackCommand.cs @@ -17,6 +17,9 @@ public enum FeedbackCommandKind /// One full 128-px bitmap row (plasma row <y> <32 hex digits>). PlasmaRow, + + /// Outlined box with a blanked interior (plasma box <x> <y> <w> <h>). + PlasmaBox, } /// @@ -47,4 +50,14 @@ public sealed record FeedbackCommand /// Row pixel bytes ( only; 16 bytes, MSB leftmost). public byte[]? Data { get; init; } + + /// Explicit font id for ; + /// 0 = auto-fit by length (the default, and the only pre-font behavior). + public byte Font { get; init; } + + /// Box width in pixels ( only). + public byte Width { get; init; } + + /// Box height in pixels ( only). + public byte Height { get; init; } } diff --git a/src/RioJoy.Core/Feedback/FeedbackLineParser.cs b/src/RioJoy.Core/Feedback/FeedbackLineParser.cs index eb8d72f..a65e430 100644 --- a/src/RioJoy.Core/Feedback/FeedbackLineParser.cs +++ b/src/RioJoy.Core/Feedback/FeedbackLineParser.cs @@ -166,6 +166,9 @@ public static class FeedbackLineParser case "row": return TryParsePlasmaRow(s, pos, out command, out error); + case "box": + return TryParsePlasmaBox(s, pos, out command, out error); + default: error = $"unknown plasma subcommand '{sub}'"; return false; @@ -231,6 +234,55 @@ public static class FeedbackLineParser return true; } + // plasma box : outlined box with a blanked interior, pixel + // coordinates. The wire's graphics command spans whole bytes, so the write + // covers the byte-aligned span containing x..x+w-1; pixels inside that + // span but outside the box are cleared (see PlasmaDisplay.BoxAsync). + private static bool TryParsePlasmaBox( + string s, int pos, out FeedbackCommand? command, out string? error) + { + command = null; + error = null; + + int[] v = new int[4]; + string[] names = { "x", "y", "w", "h" }; + for (int i = 0; i < 4; i++) + { + string? tok = NextToken(s, ref pos); + if (tok is null || !TryParseNumber(tok, out v[i])) + { + error = "plasma box needs four numbers: x y w h"; + return false; + } + } + if (NextToken(s, ref pos) is string extra) + { + error = $"unexpected token '{extra}'"; + return false; + } + + if (v[0] is < 0 or > 127 || v[1] is < 0 or > 31) + { + error = $"plasma box position ({v[0]},{v[1]}) out of range (x 0-127, y 0-31)"; + return false; + } + if (v[2] < 1 || v[0] + v[2] > 128 || v[3] < 1 || v[1] + v[3] > 32) + { + error = $"plasma box {v[2]}x{v[3]} at ({v[0]},{v[1]}) exceeds the 128x32 panel"; + return false; + } + + command = new FeedbackCommand + { + Kind = FeedbackCommandKind.PlasmaBox, + X = (byte)v[0], + Y = (byte)v[1], + Width = (byte)v[2], + Height = (byte)v[3], + }; + return true; + } + private static int HexNibble(char c) => c switch { >= '0' and <= '9' => c - '0', @@ -247,7 +299,12 @@ public static class FeedbackLineParser // Optional "x y" position: taken only when the first TWO tokens are both // numeric (so `plasma text 42` displays "42"; use quotes to force text). - byte x = 0, y = 0; + // A THIRD numeric token after a position, with text still following, is + // an explicit font id (0 = auto-fit by length; 2 small 5x7, 5 large + // 10x14) - short positioned text otherwise always renders large, which + // cannot fit inside a score box. Unpositioned text takes no font (the + // auto-center math chooses it); quote text that starts with numbers. + byte x = 0, y = 0, font = 0; int textStart = pos; int peek = pos; string? t1 = NextToken(s, ref peek); @@ -264,6 +321,25 @@ public static class FeedbackLineParser x = (byte)xv; y = (byte)yv; textStart = peek; + + int fontPeek = peek; + string? t3 = NextToken(s, ref fontPeek); + if (t3 is not null && TryParseNumber(t3, out int fv)) + { + // Only a font when text still follows - `plasma text 2 2 7` + // keeps displaying "7" as it always has. + if (TryTakeText(s, fontPeek, out string? peekText, out _) && + !string.IsNullOrEmpty(peekText)) + { + if (fv is < 0 or > 7) + { + error = $"plasma font {fv} out of range (0-7; 0 = auto)"; + return false; + } + font = (byte)fv; + textStart = fontPeek; + } + } } // t1 numeric but t2 not: the whole remainder (from textStart) is text } @@ -282,6 +358,7 @@ public static class FeedbackLineParser Text = text, X = x, Y = y, + Font = font, }; return true; } diff --git a/src/RioJoy.Core/Feedback/FeedbackRouter.cs b/src/RioJoy.Core/Feedback/FeedbackRouter.cs index 968feef..04f1e78 100644 --- a/src/RioJoy.Core/Feedback/FeedbackRouter.cs +++ b/src/RioJoy.Core/Feedback/FeedbackRouter.cs @@ -18,12 +18,13 @@ namespace RioJoy.Core.Feedback; /// profile-owned lamps for the same reason. /// /// 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), +/// match what each command means: text coalesces per position (only the +/// newest queued text at the same (x,y) survives — a flooding score updater +/// shows the latest value, while other fields on the glass keep theirs), /// 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. +/// and boxes 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 { @@ -111,6 +112,7 @@ public sealed class FeedbackRouter case FeedbackCommandKind.PlasmaText: case FeedbackCommandKind.PlasmaClear: case FeedbackCommandKind.PlasmaRow: + case FeedbackCommandKind.PlasmaBox: DispatchPlasma(target, command); break; } @@ -172,20 +174,30 @@ public sealed class FeedbackRouter break; case FeedbackCommandKind.PlasmaText: - // Only the newest text survives (a score updater shows the - // latest value); queued rows keep their place. + // Only the newest text FOR THE SAME POSITION survives (a + // score updater shows the latest value). Texts at other + // positions are other fields - the documented multi-field + // layout (callsign top, score bottom) sends several in a + // burst, and the original global coalescing ate all but + // the last of them. Queued rows/boxes keep their place. for (int i = _plasmaQueue.Count - 1; i >= 0; i--) { - if (_plasmaQueue[i].Kind == FeedbackCommandKind.PlasmaText) + if (_plasmaQueue[i].Kind == FeedbackCommandKind.PlasmaText && + _plasmaQueue[i].X == command.X && _plasmaQueue[i].Y == command.Y) { _plasmaQueue.RemoveAt(i); Interlocked.Increment(ref _dropped); // superseded before it ran } } + if (_plasmaQueue.Count >= MaxPlasmaQueue) + { + Interlocked.Increment(ref _dropped); + return; + } _plasmaQueue.Add(command); break; - default: // PlasmaRow: strict FIFO — a bitmap frame is many rows + default: // PlasmaRow/PlasmaBox: strict FIFO — a bitmap frame is many rows if (_plasmaQueue.Count >= MaxPlasmaQueue) { Interlocked.Increment(ref _dropped); // client outran the display @@ -219,7 +231,10 @@ public sealed class FeedbackRouter { 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), + FeedbackCommandKind.PlasmaBox => + target.Plasma!.BoxAsync(command.X, command.Y, command.Width, command.Height), + _ => target.Plasma!.PosTextAsync(command.Text ?? string.Empty, command.X, command.Y, + 0, command.Font), }; write.ContinueWith(w => diff --git a/src/RioJoy.Core/Plasma/PlasmaCommands.cs b/src/RioJoy.Core/Plasma/PlasmaCommands.cs index 86bf1ed..a3450a8 100644 --- a/src/RioJoy.Core/Plasma/PlasmaCommands.cs +++ b/src/RioJoy.Core/Plasma/PlasmaCommands.cs @@ -113,10 +113,13 @@ public static class PlasmaCommands /// /// Compute the auto-fit font and centered (x, y) for positioned text, porting - /// the PlasmaPosText layout logic (riovjoy2.cpp#L2235). For non-score - /// text ( ≠ 2), the font is chosen from the length - /// (≤9 → font 5, else font 2, capping length at 20). When the caller passes - /// (0, 0), the text is centered around cell (56, 15) for the chosen font. + /// the PlasmaPosText layout logic (riovjoy2.cpp#L2235). Font 0 = auto: + /// chosen from the length (≤9 → font 5, else font 2, capping length at 20). + /// A nonzero is honored as given — the legacy code + /// special-cased only its Score font (2); generalizing lets short text + /// render small, e.g. digits inside a score box, which auto-fit never + /// would. When the caller passes (0, 0), the text is centered around cell + /// (56, 15) for the chosen font. /// public static (byte x, byte y, byte font, int length) ResolvePosText( string text, byte x, byte y, byte font) @@ -124,7 +127,7 @@ public static class PlasmaCommands if (text is null) throw new ArgumentNullException(nameof(text)); int len = text.Length; - if (font != 2) // not the Score font + if (font == 0) // auto-fit by length { if (len <= 9) font = 5; else { font = 2; if (len > 20) len = 20; } diff --git a/src/RioJoy.Core/Plasma/PlasmaDisplay.cs b/src/RioJoy.Core/Plasma/PlasmaDisplay.cs index aeea3e1..135f104 100644 --- a/src/RioJoy.Core/Plasma/PlasmaDisplay.cs +++ b/src/RioJoy.Core/Plasma/PlasmaDisplay.cs @@ -47,6 +47,46 @@ public sealed class PlasmaDisplay public Task RowAsync(byte y, byte[] row, CancellationToken ct = default) => WriteLockedAsync(new[] { PlasmaCommands.GraphicsRow(y, row) }, ct); + /// + /// Draw an outlined box with a blanked interior — the overlay chrome the + /// original games drew for their rank|score field over the callsign + /// (L4GAUGE.cpp's outlined 63×12 box). Pixel coordinates; a single + /// graphics write (ESC P). + /// + /// The wire's graphics command addresses whole bytes horizontally, + /// so the write covers the byte-aligned span containing + /// ..+-1; + /// pixels inside that span but outside the box are cleared. Callers who + /// care should place boxes on 8-px boundaries. + /// + public Task BoxAsync(byte x, byte y, byte w, byte h, CancellationToken ct = default) + { + if (w == 0 || h == 0 || x + w > PlasmaCommands.Columns || y + h > PlasmaCommands.Rows) + throw new ArgumentOutOfRangeException(nameof(w), "Box exceeds the 128x32 panel."); + + int right = x + w - 1; + int firstByte = x / 8; + int lastByte = right / 8; + int spanBytes = lastByte - firstByte + 1; + + var data = new byte[spanBytes * h]; + for (int r = 0; r < h; r++) + { + bool edgeRow = r == 0 || r == h - 1; + for (int px = firstByte * 8; px <= lastByte * 8 + 7; px++) + { + if (px < x || px > right) + continue; // inside the byte span, outside the box: stays 0 + bool lit = edgeRow || px == x || px == right; + if (lit) + data[r * spanBytes + (px / 8 - firstByte)] |= (byte)(0x80 >> (px % 8)); + } + } + + return WriteLockedAsync( + new[] { PlasmaCommands.GraphicsWrite(y, (byte)firstByte, (byte)spanBytes, h, data) }, 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 67e1d39..5fdeb37 100644 --- a/tests/RioJoy.Core.Tests/Feedback/FeedbackLineParserTests.cs +++ b/tests/RioJoy.Core.Tests/Feedback/FeedbackLineParserTests.cs @@ -219,6 +219,65 @@ public class FeedbackLineParserTests { Assert.NotEmpty(ParseError(line)); } + + [Fact] + public void PlasmaText_ThirdNumberWithTextFollowing_IsFont() + { + FeedbackCommand cmd = Parse("plasma text 35 21 2 \"1\""); + Assert.Equal(35, cmd.X); + Assert.Equal(21, cmd.Y); + Assert.Equal(2, cmd.Font); + Assert.Equal("1", cmd.Text); + } + + [Fact] + public void PlasmaText_ThirdNumberAsLastToken_IsTextNotFont() + { + // `plasma text 2 2 7` keeps displaying "7", as it always has. + FeedbackCommand cmd = Parse("plasma text 2 2 7"); + Assert.Equal(2, cmd.X); + Assert.Equal(2, cmd.Y); + Assert.Equal(0, cmd.Font); + Assert.Equal("7", cmd.Text); + } + + [Fact] + public void PlasmaText_NoFontGiven_IsAuto() + { + Assert.Equal(0, Parse("plasma text 12 3 \"FUEL LOW\"").Font); + Assert.Equal(0, Parse("plasma text \"VIPER 1-1\"").Font); + } + + [Fact] + public void PlasmaText_FontOutOfRange_Rejected() + { + Assert.Contains("font", ParseError("plasma text 2 2 99 \"X\"")); + } + + [Fact] + public void PlasmaBox_ParsesGeometry() + { + FeedbackCommand cmd = Parse("plasma box 32 19 64 12"); + Assert.Equal(FeedbackCommandKind.PlasmaBox, cmd.Kind); + Assert.Equal(32, cmd.X); + Assert.Equal(19, cmd.Y); + Assert.Equal(64, cmd.Width); + Assert.Equal(12, cmd.Height); + } + + [Theory] + [InlineData("plasma box")] // nothing + [InlineData("plasma box 32 19 64")] // missing h + [InlineData("plasma box 32 19 64 12 x")] // trailing token + [InlineData("plasma box 128 0 1 1")] // x out of range + [InlineData("plasma box 0 32 1 1")] // y out of range + [InlineData("plasma box 100 0 40 1")] // spills off the right edge + [InlineData("plasma box 0 28 1 8")] // spills off the bottom + [InlineData("plasma box 0 0 0 5")] // zero width + public void PlasmaBox_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 d029e24..94677cf 100644 --- a/tests/RioJoy.Core.Tests/Feedback/FeedbackRouterTests.cs +++ b/tests/RioJoy.Core.Tests/Feedback/FeedbackRouterTests.cs @@ -146,6 +146,66 @@ public class FeedbackRouterTests : IDisposable Assert.Equal(2, router.DroppedCommands); // the two superseded middles } + [Fact] + public async Task PlasmaText_CoalescesPerPosition_OtherFieldsSurvive() + { + // The documented multi-field layout sends several positioned texts in a + // burst (callsign top, score bottom). Coalescing is per (x,y): a newer + // score supersedes the queued score, never the queued callsign. + var transport = new GatedTransport(); + var router = new FeedbackRouter(); + router.Attach(_scheduler, new RioInputMap(), new PlasmaDisplay(transport), + new ProfileFeedbackConfig()); + + router.Dispatch(Text("PARK")); // goes busy, parked on the gate + router.Dispatch(new FeedbackCommand + { Kind = FeedbackCommandKind.PlasmaText, Text = "VIPER", X = 2, Y = 2 }); + router.Dispatch(new FeedbackCommand + { Kind = FeedbackCommandKind.PlasmaText, Text = "SCORE 1", X = 2, Y = 18 }); + router.Dispatch(new FeedbackCommand + { Kind = FeedbackCommandKind.PlasmaText, Text = "SCORE 2", X = 2, Y = 18 }); // supersedes SCORE 1 only + transport.Open(); + + var texts = new List(); + for (int i = 0; i < 15; i++) + { + byte[] w = await transport.NextWriteAsync(); + string s = System.Text.Encoding.GetEncoding(28591).GetString(w); + if (w.Length > 0 && w[0] != 0x1B) + texts.Add(s); // the text chunks, minus ESC command prefixes + if (texts.Count == 3) + break; + } + Assert.Equal(new[] { "PARK", "VIPER", "SCORE 2" }, texts); + Assert.Equal(1, router.DroppedCommands); // only SCORE 1 superseded + } + + [Fact] + public async Task PlasmaBox_WritesOutlineWithBlankedInterior() + { + var transport = new GatedTransport(); + var router = new FeedbackRouter(); + router.Attach(_scheduler, new RioInputMap(), new PlasmaDisplay(transport), + new ProfileFeedbackConfig()); + + // 16px-wide box at a byte boundary, 4 rows: bytes are exact. + router.Dispatch(new FeedbackCommand + { Kind = FeedbackCommandKind.PlasmaBox, X = 32, Y = 10, Width = 16, Height = 4 }); + transport.Open(); + + byte[] w = await transport.NextWriteAsync(); + // ESC P s=0 y=10 x=4 w=2 h=4, then 8 data bytes. + Assert.Equal(new byte[] { 0x1B, (byte)'P', 0, 10, 4, 2, 4 }, w.Take(7).ToArray()); + byte[] data = w.Skip(7).ToArray(); + Assert.Equal(new byte[] + { + 0xFF, 0xFF, // top edge: all lit + 0x80, 0x01, // interior row: only the side walls + 0x80, 0x01, + 0xFF, 0xFF, // bottom edge + }, data); + } + [Fact] public async Task PlasmaRows_StreamInFifoOrder_NotCoalesced() {