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
+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);