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:
Cyd
2026-07-31 20:45:38 -05:00
co-authored by Claude Fable 5
parent 66c3cbdb57
commit 44b636ddd3
14 changed files with 435 additions and 41 deletions
+8 -1
View File
@@ -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 &lt;y&gt; &lt;32 hex digits&gt;</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; }
}
+71 -1
View File
@@ -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)
{
+78 -21
View File
@@ -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);