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