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:
@@ -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
|
||||
|
||||
+14
-3
@@ -47,6 +47,7 @@ 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
|
||||
```
|
||||
|
||||
- **`<addr>`** — 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: `<y>` 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
|
||||
|
||||
|
||||
+31
-12
@@ -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 <y> <hex32>`** — 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 <y> <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 <y> <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
|
||||
|
||||
+6
-2
@@ -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 <y> <hex32>` **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
|
||||
|
||||
@@ -14,6 +14,9 @@ public enum FeedbackCommandKind
|
||||
|
||||
/// <summary>Clear the plasma display (<c>plasma clear</c>).</summary>
|
||||
PlasmaClear,
|
||||
|
||||
/// <summary>One full 128-px bitmap row (<c>plasma row <y> <32 hex digits></c>).</summary>
|
||||
PlasmaRow,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -36,8 +39,12 @@ public sealed record FeedbackCommand
|
||||
/// <summary>Display text (<see cref="FeedbackCommandKind.PlasmaText"/> only).</summary>
|
||||
public string? Text { get; init; }
|
||||
|
||||
/// <summary>Plasma cursor position; (0,0) = auto-fit/center (<c>PlasmaPosText</c>).</summary>
|
||||
/// <summary>Plasma cursor position; (0,0) = auto-fit/center (<c>PlasmaPosText</c>).
|
||||
/// For <see cref="FeedbackCommandKind.PlasmaRow"/>, <see cref="Y"/> is the row.</summary>
|
||||
public byte X { get; init; }
|
||||
|
||||
public byte Y { get; init; }
|
||||
|
||||
/// <summary>Row pixel bytes (<see cref="FeedbackCommandKind.PlasmaRow"/> only; 16 bytes, MSB leftmost).</summary>
|
||||
public byte[]? Data { get; init; }
|
||||
}
|
||||
|
||||
@@ -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 <y> <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)
|
||||
{
|
||||
|
||||
@@ -17,8 +17,13 @@ namespace RioJoy.Core.Feedback;
|
||||
/// misconfigured client is diagnosable. <c>lamp-all</c> 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: <c>text</c> coalesces (only the newest
|
||||
/// queued text survives — a flooding score updater shows the latest value),
|
||||
/// <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.
|
||||
/// </summary>
|
||||
public sealed class FeedbackRouter
|
||||
{
|
||||
@@ -40,10 +45,13 @@ public sealed class FeedbackRouter
|
||||
public HashSet<int> 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<FeedbackCommand> _plasmaQueue = new();
|
||||
private Target? _target;
|
||||
private bool _plasmaBusy;
|
||||
private FeedbackCommand? _plasmaPending;
|
||||
private long _dropped;
|
||||
|
||||
/// <summary>Diagnostics (dropped profile-owned lamp writes, plasma faults).</summary>
|
||||
@@ -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);
|
||||
|
||||
@@ -37,6 +37,15 @@ public static class PlasmaCommands
|
||||
/// <summary>Select font (<c>ESC K font</c>).</summary>
|
||||
public static byte[] Font(byte font) => new[] { Esc, (byte)'K', font };
|
||||
|
||||
/// <summary>Panel width in pixels (columns 0–127).</summary>
|
||||
public const int Columns = 128;
|
||||
|
||||
/// <summary>Panel height in pixel rows (0–31).</summary>
|
||||
public const int Rows = 32;
|
||||
|
||||
/// <summary>Bytes per full bitmap row (128 px / 8, byte columns 0–15).</summary>
|
||||
public const int RowBytes = Columns / 8;
|
||||
|
||||
/// <summary>Draw a box outline (<c>ESC X l t r b</c>).</summary>
|
||||
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 };
|
||||
|
||||
/// <summary>
|
||||
/// Bitmap graphics write (<c>ESC P s y x w h data…</c>): 1-bpp pixels,
|
||||
/// MSB = leftmost, starting at row <paramref name="y"/> (0–31) and byte
|
||||
/// column <paramref name="x"/> (0–15), <paramref name="bytesPerRow"/> bytes
|
||||
/// across <paramref name="rows"/> rows. Command set recovered in vRIO's
|
||||
/// <c>PlasmaProtocol.cs</c> (Tesla 4.10 sources + firmware dump); the
|
||||
/// native game streams whole changed rows (<c>x=0, w=16, h=1</c> —
|
||||
/// <see cref="GraphicsRow"/>).
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>One full 128-px bitmap row at <paramref name="y"/> (16 bytes, MSB leftmost).</summary>
|
||||
public static byte[] GraphicsRow(byte y, byte[] row) =>
|
||||
GraphicsWrite(y, 0, (byte)RowBytes, 1, row);
|
||||
|
||||
/// <summary>Encode display text as raw bytes (Latin-1, one byte per char).</summary>
|
||||
public static byte[] Text(string text)
|
||||
{
|
||||
|
||||
@@ -31,6 +31,13 @@ public sealed class PlasmaDisplay
|
||||
public Task TextAsync(string text, CancellationToken ct = default) =>
|
||||
WriteLockedAsync(new[] { PlasmaCommands.Text(text) }, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Write one full 128-px bitmap row at <paramref name="y"/> (0–31):
|
||||
/// 16 bytes, MSB = leftmost pixel (<see cref="PlasmaCommands.GraphicsRow"/>).
|
||||
/// </summary>
|
||||
public Task RowAsync(byte y, byte[] row, CancellationToken ct = default) =>
|
||||
WriteLockedAsync(new[] { PlasmaCommands.GraphicsRow(y, row) }, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Position the cursor, set attribute + font, and write text — the
|
||||
/// <c>PlasmaPosText</c> sequence (auto-fit via
|
||||
|
||||
@@ -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