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>
271 lines
10 KiB
C#
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);
|
|
}
|
|
}
|