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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user