feedback: plasma box, explicit fonts, and per-position text coalescing

Three endpoint gaps, found building the Descent 3 score overlay - the boxed
place|score field the original games drew over the callsign:

plasma box <x> <y> <w> <h> draws an outlined box with a blanked interior, the
overlay chrome, as one ESC P graphics write. The wire addresses whole bytes
horizontally, so the write covers the byte-aligned span containing the box and
clears span pixels outside it; documented, with 8-px alignment the advice.
Boxes queue FIFO with rows.

plasma text gains an explicit font: a third numeric token after the position,
with text still following, so `plasma text 2 2 7` still displays "7". Auto-fit
picks the font by LENGTH - short text always rendered large, and a "1" that
must fit a 12-px box simply could not be sent before. ResolvePosText
generalizes the legacy Score-font special case: 0 = auto, nonzero honored.

Text coalescing is now per position. The global rule - any queued text
superseded every other queued text - meant the documented two-field layout
(callsign top, score bottom) could not survive its own send burst: the second
field silently ate the first whenever both were queued. A newer text now
replaces only a queued text at the same (x,y).

15 new tests; 472 pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-08-02 11:45:59 -05:00
co-authored by Claude Opus 5
parent 23453667d2
commit 46e108de89
9 changed files with 315 additions and 26 deletions
+22 -8
View File
@@ -43,11 +43,12 @@ case-insensitive. Lines over 256 bytes and UDP datagrams over 4 KB are dropped.
```
# comment (also ;)
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
lamp <addr> <state> set one lamp
lamp-all <state> set every lamp
plasma text [x y [font]] <text> write text to the plasma display
plasma clear clear the plasma display
plasma row <y> <hex32> write one full 128-px bitmap row
plasma box <x> <y> <w> <h> outlined box with a blanked interior
```
- **`<addr>`** — RIO lamp address, decimal or `0x` hex. Valid: `0x000x47`
@@ -62,17 +63,30 @@ plasma row <y> <hex32> 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: `<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);
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` 0127, `y` 031, 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.
+10 -2
View File
@@ -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 <x> <y> <font> <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 <y> <hex32>`** — one full 128-px bitmap row; see
[Bitmap graphics](#bitmap-graphics-esc-p).
- **`plasma box <x> <y> <w> <h>`** — 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`)
@@ -17,6 +17,9 @@ public enum FeedbackCommandKind
/// <summary>One full 128-px bitmap row (<c>plasma row &lt;y&gt; &lt;32 hex digits&gt;</c>).</summary>
PlasmaRow,
/// <summary>Outlined box with a blanked interior (<c>plasma box &lt;x&gt; &lt;y&gt; &lt;w&gt; &lt;h&gt;</c>).</summary>
PlasmaBox,
}
/// <summary>
@@ -47,4 +50,14 @@ public sealed record FeedbackCommand
/// <summary>Row pixel bytes (<see cref="FeedbackCommandKind.PlasmaRow"/> only; 16 bytes, MSB leftmost).</summary>
public byte[]? Data { get; init; }
/// <summary>Explicit font id for <see cref="FeedbackCommandKind.PlasmaText"/>;
/// 0 = auto-fit by length (the default, and the only pre-font behavior).</summary>
public byte Font { get; init; }
/// <summary>Box width in pixels (<see cref="FeedbackCommandKind.PlasmaBox"/> only).</summary>
public byte Width { get; init; }
/// <summary>Box height in pixels (<see cref="FeedbackCommandKind.PlasmaBox"/> only).</summary>
public byte Height { get; init; }
}
+78 -1
View File
@@ -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 <x> <y> <w> <h>: 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;
}
+25 -10
View File
@@ -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: <c>text</c> coalesces (only the newest
/// queued text survives — a flooding score updater shows the latest value),
/// match what each command means: <c>text</c> 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),
/// <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.
/// and <c>box</c>es 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
{
@@ -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 =>
+8 -5
View File
@@ -113,10 +113,13 @@ public static class PlasmaCommands
/// <summary>
/// Compute the auto-fit font and centered (x, y) for positioned text, porting
/// the <c>PlasmaPosText</c> layout logic (riovjoy2.cpp#L2235). For non-score
/// text (<paramref name="font"/> ≠ 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 <c>PlasmaPosText</c> layout logic (riovjoy2.cpp#L2235). Font 0 = auto:
/// chosen from the length (≤9 → font 5, else font 2, capping length at 20).
/// A nonzero <paramref name="font"/> 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.
/// </summary>
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; }
+40
View File
@@ -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);
/// <summary>
/// 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 (<c>ESC P</c>).
///
/// <para>The wire's graphics command addresses whole bytes horizontally,
/// so the write covers the byte-aligned span containing
/// <paramref name="x"/>..<paramref name="x"/>+<paramref name="w"/>-1;
/// pixels inside that span but outside the box are cleared. Callers who
/// care should place boxes on 8-px boundaries.</para>
/// </summary>
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);
}
/// <summary>
/// Position the cursor, set attribute + font, and write text — the
/// <c>PlasmaPosText</c> sequence (auto-fit via
@@ -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
@@ -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<string>();
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()
{