feedback: game-to-cockpit endpoint (pipe/UDP lamps+plasma), rumble lamp flash

Phase 9: FeedbackPipeServer (\\.\pipe\riojoy-feedback) + loopback UDP share a
forgiving text line protocol into FeedbackRouter; CoalescingLampScheduler rate-
governs the 9600-baud link; plasma finally wired into activation (greeting,
teardown blank, PlasmaDisplay write lock); ViGEm FeedbackReceived drives
RumbleLampAdapter. Per-profile Feedback config, docs/FEEDBACK.md, 425 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-31 19:31:03 -05:00
co-authored by Claude Fable 5
parent d13d434e88
commit ad7ac19ab2
33 changed files with 2977 additions and 18 deletions
+198
View File
@@ -0,0 +1,198 @@
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 with a latest-pending-wins slot, so a
/// flooding client cannot queue unbounded 9600-baud text writes.
/// </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();
}
private readonly object _gate = new();
private Target? _target;
private bool _plasmaBusy;
private FeedbackCommand? _plasmaPending;
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);
_plasmaPending = null; // pending text belonged to the previous profile
}
}
/// <summary>Drop the target; subsequent commands are dropped (counted).</summary>
public void Detach()
{
lock (_gate)
{
_target = null;
_plasmaPending = null;
}
}
/// <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:
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)
{
if (_plasmaBusy)
{
if (_plasmaPending is not null)
Interlocked.Increment(ref _dropped); // superseded before it ran
_plasmaPending = command; // latest wins
return;
}
_plasmaBusy = true;
}
StartPlasmaWrite(target, command);
}
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);
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)
{
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)
{
_plasmaBusy = false; // chain ends; a racing Dispatch starts a fresh one
if (next is not null)
Interlocked.Increment(ref _dropped);
return;
}
// Busy stays true across the chained write, so latest-wins ordering
// holds — concurrent dispatches keep landing in the pending slot.
}
StartPlasmaWrite(current, next);
}, TaskContinuationOptions.ExecuteSynchronously);
}
}