Files
riojoy/src/RioJoy.Core/Feedback/FeedbackRouter.cs
T
CydandClaude Opus 5 46e108de89 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>
2026-08-02 11:45:59 -05:00

271 lines
10 KiB
C#

using RioJoy.Core.Mapping;
using RioJoy.Core.Plasma;
namespace RioJoy.Core.Feedback;
/// <summary>
/// Applies inbound <see cref="FeedbackCommand"/>s to the active profile's
/// outputs. The listeners dispatch here from their reader threads; the target
/// (scheduler + map + plasma + per-profile config) is attached on profile
/// activation and detached on teardown — detached, everything drops silently
/// (dormancy and native-game yield are normal, not errors).
///
/// Precedence: a <c>lamp</c> write to an address whose map entry has
/// <see cref="RioMapEntry.HasLamp"/> is dropped — the <see cref="InputRouter"/>
/// owns those lamps (bright on press / dim on release) and feedback must not
/// fight it. Such drops are logged once per address per attach so a
/// misconfigured client is diagnosable. <c>lamp-all</c> silently skips
/// 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 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
/// 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
{
private sealed class Target
{
public Target(CoalescingLampScheduler lamps, RioInputMap map,
PlasmaDisplay? plasma, ProfileFeedbackConfig config)
{
Lamps = lamps;
Map = map;
Plasma = plasma;
Config = config;
}
public CoalescingLampScheduler Lamps { get; }
public RioInputMap Map { get; }
public PlasmaDisplay? Plasma { get; }
public ProfileFeedbackConfig Config { get; }
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 long _dropped;
/// <summary>Diagnostics (dropped profile-owned lamp writes, plasma faults).</summary>
public event Action<string>? Logged;
/// <summary>Commands dropped for any reason (detached, disallowed, profile-owned).</summary>
public long DroppedCommands => Interlocked.Read(ref _dropped);
/// <summary>Point feedback at the just-activated profile's outputs.</summary>
public void Attach(CoalescingLampScheduler lamps, RioInputMap map,
PlasmaDisplay? plasma, ProfileFeedbackConfig config)
{
if (lamps is null) throw new ArgumentNullException(nameof(lamps));
if (map is null) throw new ArgumentNullException(nameof(map));
if (config is null) throw new ArgumentNullException(nameof(config));
lock (_gate)
{
_target = new Target(lamps, map, plasma, config);
_plasmaQueue.Clear(); // queued content belonged to the previous profile
}
}
/// <summary>Drop the target; subsequent commands are dropped (counted).</summary>
public void Detach()
{
lock (_gate)
{
_target = null;
_plasmaQueue.Clear();
}
}
/// <summary>Apply one command. Thread-safe, non-blocking.</summary>
public void Dispatch(FeedbackCommand command)
{
if (command is null)
return;
Target? target;
lock (_gate)
target = _target;
if (target is null)
{
Interlocked.Increment(ref _dropped);
return;
}
switch (command.Kind)
{
case FeedbackCommandKind.Lamp:
DispatchLamp(target, command);
break;
case FeedbackCommandKind.LampAll:
DispatchLampAll(target, command);
break;
case FeedbackCommandKind.PlasmaText:
case FeedbackCommandKind.PlasmaClear:
case FeedbackCommandKind.PlasmaRow:
case FeedbackCommandKind.PlasmaBox:
DispatchPlasma(target, command);
break;
}
}
private void DispatchLamp(Target target, FeedbackCommand command)
{
if (!target.Config.AllowLampCommands)
{
Interlocked.Increment(ref _dropped);
return;
}
if (target.Map[command.Address].HasLamp)
{
Interlocked.Increment(ref _dropped);
bool firstTime;
lock (_gate)
firstTime = target.LoggedOwnedDrops.Add(command.Address);
if (firstTime)
Logged?.Invoke(
$"feedback: lamp 0x{command.Address:X2} is profile-mapped (HasLamp) — dropped");
return;
}
target.Lamps.Post(command.Address, command.LampState);
}
private void DispatchLampAll(Target target, FeedbackCommand command)
{
if (!target.Config.AllowLampCommands)
{
Interlocked.Increment(ref _dropped);
return;
}
for (int a = 0; a < RioAddress.TableSize; a++)
{
if (RioAddress.IsValid(a) && !target.Map[a].HasLamp)
target.Lamps.Post(a, command.LampState);
}
}
private void DispatchPlasma(Target target, FeedbackCommand command)
{
if (target.Plasma is null || !target.Config.AllowPlasmaText)
{
Interlocked.Increment(ref _dropped);
return;
}
lock (_gate)
{
switch (command.Kind)
{
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 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 &&
_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/PlasmaBox: 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 switch
{
FeedbackCommandKind.PlasmaClear => target.Plasma!.ClearAsync(),
FeedbackCommandKind.PlasmaRow => target.Plasma!.RowAsync(command.Y, command.Data!),
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 =>
{
if (w.Exception is not null) // observe: an unobserved fault kills net40
Logged?.Invoke($"feedback: plasma write failed: {w.Exception.GetBaseException().Message}");
FeedbackCommand? next;
Target? current;
lock (_gate)
{
current = _target; // queued content applies to the *current* profile's display
if (current?.Plasma is null || !current.Config.AllowPlasmaText)
{
for (int i = 0; i < _plasmaQueue.Count; i++)
Interlocked.Increment(ref _dropped);
_plasmaQueue.Clear();
_plasmaBusy = false;
return;
}
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);
}
}