using RioJoy.Core.Mapping; using RioJoy.Core.Plasma; namespace RioJoy.Core.Feedback; /// /// Applies inbound 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 lamp write to an address whose map entry has /// is dropped — the /// 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. lamp-all 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: text 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), /// clear flushes everything queued before it, and bitmap rows /// and boxes 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. /// 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 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 _plasmaQueue = new(); private Target? _target; private bool _plasmaBusy; private long _dropped; /// Diagnostics (dropped profile-owned lamp writes, plasma faults). public event Action? Logged; /// Commands dropped for any reason (detached, disallowed, profile-owned). public long DroppedCommands => Interlocked.Read(ref _dropped); /// Point feedback at the just-activated profile's outputs. 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 } } /// Drop the target; subsequent commands are dropped (counted). public void Detach() { lock (_gate) { _target = null; _plasmaQueue.Clear(); } } /// Apply one command. Thread-safe, non-blocking. 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); } }