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,109 @@
|
||||
using RioJoy.Core.Compat;
|
||||
using RioJoy.Core.Mapping;
|
||||
|
||||
namespace RioJoy.Core.Feedback;
|
||||
|
||||
/// <summary>
|
||||
/// The rate governor between feedback lamp traffic and the 9600-baud RIO link.
|
||||
/// Every lamp command crosses the link's stop-and-wait command gate (worst case
|
||||
/// ~150 ms with retransmits) shared with the ~55 ms analog poll, and nothing
|
||||
/// downstream coalesces — so feedback paths must post here, never call
|
||||
/// <see cref="ILampSink"/> directly. Keeps a desired/last-sent shadow of all
|
||||
/// 112 addresses; the pump sends at most one <i>changed</i> lamp per tick
|
||||
/// (round-robin for fairness), so bursts of identical states collapse to
|
||||
/// nothing and a flooding client cannot starve the analog poll. One instance
|
||||
/// per profile activation, so shadow state never leaks across profiles.
|
||||
/// </summary>
|
||||
public sealed class CoalescingLampScheduler
|
||||
{
|
||||
/// <summary>Default pump tick: ≤40 lamp commands/s at 9600 baud stays polite.</summary>
|
||||
public static readonly TimeSpan DefaultSendInterval = TimeSpan.FromMilliseconds(25);
|
||||
|
||||
private readonly ILampSink _sink;
|
||||
private readonly TimeSpan _sendInterval;
|
||||
private readonly object _gate = new();
|
||||
private readonly byte?[] _desired = new byte?[RioAddress.TableSize];
|
||||
private readonly byte?[] _lastSent = new byte?[RioAddress.TableSize];
|
||||
private int _cursor;
|
||||
|
||||
public CoalescingLampScheduler(ILampSink sink, TimeSpan? sendInterval = null)
|
||||
{
|
||||
_sink = sink ?? throw new ArgumentNullException(nameof(sink));
|
||||
TimeSpan interval = sendInterval ?? DefaultSendInterval;
|
||||
// Floor at 1 ms: Task.Delay(0) completes synchronously, which would turn
|
||||
// RunAsync into an infinite synchronous loop that never yields.
|
||||
_sendInterval = interval > TimeSpan.Zero ? interval : TimeSpan.FromMilliseconds(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set the desired state for one lamp. Thread-safe and non-blocking (safe
|
||||
/// from the ViGEm callback thread and pipe reader threads). Invalid
|
||||
/// addresses are ignored — rumble config addresses arrive here unvalidated.
|
||||
/// </summary>
|
||||
public void Post(int address, byte state)
|
||||
{
|
||||
if (!RioAddress.IsValid(address))
|
||||
return;
|
||||
lock (_gate)
|
||||
_desired[address] = state;
|
||||
}
|
||||
|
||||
/// <summary>Set the desired state for every valid lamp address.</summary>
|
||||
public void PostAll(byte state)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
for (int a = 0; a < RioAddress.TableSize; a++)
|
||||
{
|
||||
if (RioAddress.IsValid(a))
|
||||
_desired[a] = state;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The pump loop: send one changed lamp, sleep a tick, repeat until
|
||||
/// cancelled. Exits cleanly on cancellation (started fire-and-forget, so it
|
||||
/// must never fault).
|
||||
/// </summary>
|
||||
public async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
SendNextChanged();
|
||||
await TaskCompat.Delay(_sendInterval, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Normal shutdown.
|
||||
}
|
||||
}
|
||||
|
||||
private void SendNextChanged()
|
||||
{
|
||||
int address = -1;
|
||||
byte state = 0;
|
||||
lock (_gate)
|
||||
{
|
||||
for (int i = 0; i < _desired.Length; i++)
|
||||
{
|
||||
int a = (_cursor + i) % _desired.Length;
|
||||
if (_desired[a] is byte want && _lastSent[a] != want)
|
||||
{
|
||||
address = a;
|
||||
state = want;
|
||||
_lastSent[a] = want;
|
||||
_cursor = a + 1; // resume after this one — round-robin fairness
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Outside the lock: SetLamp is fire-and-forget but no reason to hold it.
|
||||
if (address >= 0)
|
||||
_sink.SetLamp(address, state);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
namespace RioJoy.Core.Feedback;
|
||||
|
||||
/// <summary>What a parsed feedback line asks the cockpit to do.</summary>
|
||||
public enum FeedbackCommandKind
|
||||
{
|
||||
/// <summary>Set one lamp to a state (<c>lamp <addr> <state></c>).</summary>
|
||||
Lamp,
|
||||
|
||||
/// <summary>Set every valid lamp address to a state (<c>lamp-all <state></c>).</summary>
|
||||
LampAll,
|
||||
|
||||
/// <summary>Write text to the plasma display (<c>plasma text [x y] <text></c>).</summary>
|
||||
PlasmaText,
|
||||
|
||||
/// <summary>Clear the plasma display (<c>plasma clear</c>).</summary>
|
||||
PlasmaClear,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One inbound cockpit-feedback command, produced by
|
||||
/// <see cref="FeedbackLineParser"/> from a protocol line (docs/FEEDBACK.md) and
|
||||
/// consumed by the feedback router. Addresses are already validated against the
|
||||
/// RIO address space; lamp states are complete state bytes
|
||||
/// (<see cref="Protocol.RioLampState"/>).
|
||||
/// </summary>
|
||||
public sealed record FeedbackCommand
|
||||
{
|
||||
public FeedbackCommandKind Kind { get; init; }
|
||||
|
||||
/// <summary>RIO lamp address (<see cref="FeedbackCommandKind.Lamp"/> only).</summary>
|
||||
public int Address { get; init; }
|
||||
|
||||
/// <summary>Lamp state byte (<see cref="FeedbackCommandKind.Lamp"/>/<see cref="FeedbackCommandKind.LampAll"/>).</summary>
|
||||
public byte LampState { get; init; }
|
||||
|
||||
/// <summary>Display text (<see cref="FeedbackCommandKind.PlasmaText"/> only).</summary>
|
||||
public string? Text { get; init; }
|
||||
|
||||
/// <summary>Plasma cursor position; (0,0) = auto-fit/center (<c>PlasmaPosText</c>).</summary>
|
||||
public byte X { get; init; }
|
||||
|
||||
public byte Y { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
namespace RioJoy.Core.Feedback;
|
||||
|
||||
/// <summary>
|
||||
/// App-level inbound-feedback endpoint settings
|
||||
/// (<see cref="Profiles.AppConfig.Feedback"/>; null there = these defaults:
|
||||
/// named pipe on under <see cref="DefaultPipeName"/>, UDP off). The endpoint is
|
||||
/// app-lifetime — clients keep their connection across profile switches and
|
||||
/// dormancy; per-profile settings only gate what gets applied
|
||||
/// (<see cref="ProfileFeedbackConfig"/>). Serialized into config.json, so no
|
||||
/// vendor types.
|
||||
/// </summary>
|
||||
public sealed record FeedbackEndpointConfig
|
||||
{
|
||||
public const string DefaultPipeName = "riojoy-feedback";
|
||||
|
||||
/// <summary>Listen on <c>\\.\pipe\<PipeName></c> for feedback lines.</summary>
|
||||
public bool PipeEnabled { get; init; } = true;
|
||||
|
||||
public string PipeName { get; init; } = DefaultPipeName;
|
||||
|
||||
/// <summary>
|
||||
/// UDP loopback port to also listen on; null = UDP off. Datagrams carry one
|
||||
/// or more complete protocol lines (docs/FEEDBACK.md).
|
||||
/// </summary>
|
||||
public int? UdpPort { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-profile feedback application settings
|
||||
/// (<see cref="Profiles.RioProfile.Feedback"/>; null there = inbound feedback
|
||||
/// is not applied for this profile — commands are dropped).
|
||||
/// </summary>
|
||||
public sealed record ProfileFeedbackConfig
|
||||
{
|
||||
/// <summary>Apply inbound <c>lamp</c>/<c>lamp-all</c> commands.</summary>
|
||||
public bool AllowLampCommands { get; init; } = true;
|
||||
|
||||
/// <summary>Apply inbound <c>plasma</c> commands.</summary>
|
||||
public bool AllowPlasmaText { get; init; } = true;
|
||||
|
||||
/// <summary>XInput rumble → lamp flash mapping; null = off.</summary>
|
||||
public RumbleLampConfig? Rumble { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps ViGEm pad vibration onto cockpit lamps: each motor drives its listed
|
||||
/// RIO lamp addresses through flash states scaled by intensity (off below
|
||||
/// <see cref="Threshold"/>, then slow/med/fast thirds — the board sustains the
|
||||
/// blink, so constant rumble costs one lamp command).
|
||||
/// </summary>
|
||||
public sealed record RumbleLampConfig
|
||||
{
|
||||
/// <summary>RIO lamp addresses driven by the large (low-frequency) motor.</summary>
|
||||
public List<int> LargeMotorLamps { get; init; } = new();
|
||||
|
||||
/// <summary>RIO lamp addresses driven by the small (high-frequency) motor.</summary>
|
||||
public List<int> SmallMotorLamps { get; init; } = new();
|
||||
|
||||
/// <summary>Motor value (0-255) below which the lamps turn off.</summary>
|
||||
public byte Threshold { get; init; } = 24;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.Text;
|
||||
|
||||
namespace RioJoy.Core.Feedback;
|
||||
|
||||
/// <summary>
|
||||
/// Assembles raw endpoint bytes into protocol lines for
|
||||
/// <see cref="FeedbackLineParser"/>: LF terminates a line, a preceding CR is
|
||||
/// stripped (CRLF and LF both work), and bytes decode as Latin-1 (one byte =
|
||||
/// one char — the plasma wire encoding, so every byte 0x20-0xFF round-trips).
|
||||
/// A line longer than <see cref="MaxLineLength"/> is discarded through its next
|
||||
/// LF, which keeps a binary client that connected by mistake from ballooning
|
||||
/// the buffer. Not thread-safe; each connection/datagram reader owns one.
|
||||
/// </summary>
|
||||
public sealed class FeedbackLineBuffer
|
||||
{
|
||||
public const int MaxLineLength = 256;
|
||||
|
||||
private readonly StringBuilder _line = new();
|
||||
private bool _discarding;
|
||||
|
||||
/// <summary>Feed <paramref name="count"/> bytes; returns the completed lines.</summary>
|
||||
public IEnumerable<string> Feed(byte[] buffer, int count)
|
||||
{
|
||||
List<string>? lines = null;
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
byte b = buffer[i];
|
||||
if (b == (byte)'\n')
|
||||
{
|
||||
if (!_discarding)
|
||||
{
|
||||
if (_line.Length > 0 && _line[_line.Length - 1] == '\r')
|
||||
_line.Length--;
|
||||
(lines ??= new List<string>()).Add(_line.ToString());
|
||||
}
|
||||
_line.Length = 0;
|
||||
_discarding = false;
|
||||
}
|
||||
else if (!_discarding)
|
||||
{
|
||||
if (_line.Length >= MaxLineLength)
|
||||
{
|
||||
_line.Length = 0;
|
||||
_discarding = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_line.Append((char)b); // Latin-1: byte == code point
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines ?? Enumerable.Empty<string>(); // net40: no Array.Empty
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// End-of-datagram flush (UDP): the remaining buffered content is one final
|
||||
/// line even without a trailing LF. Returns <see langword="null"/> when
|
||||
/// there is nothing buffered. Pipe readers never flush — they wait for LF.
|
||||
/// </summary>
|
||||
public string? Flush()
|
||||
{
|
||||
if (_discarding)
|
||||
{
|
||||
_discarding = false;
|
||||
_line.Length = 0;
|
||||
return null;
|
||||
}
|
||||
if (_line.Length == 0)
|
||||
return null;
|
||||
if (_line[_line.Length - 1] == '\r')
|
||||
_line.Length--;
|
||||
string s = _line.ToString();
|
||||
_line.Length = 0;
|
||||
return s.Length == 0 ? null : s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
using System.Globalization;
|
||||
using RioJoy.Core.Mapping;
|
||||
using RioJoy.Core.Protocol;
|
||||
|
||||
namespace RioJoy.Core.Feedback;
|
||||
|
||||
/// <summary>
|
||||
/// Parses one line of the inbound feedback protocol (docs/FEEDBACK.md) into a
|
||||
/// <see cref="FeedbackCommand"/>. Pure and forgiving: keywords are
|
||||
/// case-insensitive, malformed lines produce an error string (the caller logs
|
||||
/// and drops them — a bad line must never cost a client its connection).
|
||||
/// This is also the validation boundary for lamp addresses:
|
||||
/// <c>SerialLampSink</c> casts to <c>byte</c> unchecked, so out-of-range
|
||||
/// addresses are rejected here.
|
||||
/// </summary>
|
||||
public static class FeedbackLineParser
|
||||
{
|
||||
/// <summary>
|
||||
/// Parse one line. Returns <see langword="true"/> with a command when the
|
||||
/// line is actionable. Returns <see langword="false"/> with
|
||||
/// <paramref name="error"/> <see langword="null"/> for blank/comment lines
|
||||
/// (skip silently) or an error message for malformed ones (log + drop).
|
||||
/// </summary>
|
||||
public static bool TryParse(string line, out FeedbackCommand? command, out string? error)
|
||||
{
|
||||
command = null;
|
||||
error = null;
|
||||
if (string.IsNullOrEmpty(line))
|
||||
return false;
|
||||
|
||||
string s = line.Trim();
|
||||
if (s.Length == 0 || s[0] == '#' || s[0] == ';')
|
||||
return false; // blank or comment
|
||||
|
||||
int pos = 0;
|
||||
string keyword = NextToken(s, ref pos)!;
|
||||
switch (keyword.ToLowerInvariant())
|
||||
{
|
||||
case "lamp":
|
||||
return TryParseLamp(s, pos, all: false, out command, out error);
|
||||
case "lamp-all":
|
||||
return TryParseLamp(s, pos, all: true, out command, out error);
|
||||
case "plasma":
|
||||
return TryParsePlasma(s, pos, out command, out error);
|
||||
default:
|
||||
error = $"unknown command '{keyword}'";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryParseLamp(
|
||||
string s, int pos, bool all, out FeedbackCommand? command, out string? error)
|
||||
{
|
||||
command = null;
|
||||
error = null;
|
||||
int address = 0;
|
||||
|
||||
if (!all)
|
||||
{
|
||||
string? addrToken = NextToken(s, ref pos);
|
||||
if (addrToken is null)
|
||||
{
|
||||
error = "lamp needs an address and a state";
|
||||
return false;
|
||||
}
|
||||
if (!TryParseNumber(addrToken, out address))
|
||||
{
|
||||
error = $"bad lamp address '{addrToken}'";
|
||||
return false;
|
||||
}
|
||||
if (!RioAddress.IsValid(address))
|
||||
{
|
||||
error = $"lamp address 0x{address:X2} out of range " +
|
||||
"(valid: 0x00-0x47, 0x50-0x5F, 0x60-0x6F)";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
string? first = NextToken(s, ref pos);
|
||||
if (first is null)
|
||||
{
|
||||
error = "missing lamp state";
|
||||
return false;
|
||||
}
|
||||
string? second = NextToken(s, ref pos);
|
||||
if (NextToken(s, ref pos) is string extra)
|
||||
{
|
||||
error = $"unexpected token '{extra}'";
|
||||
return false;
|
||||
}
|
||||
|
||||
byte state;
|
||||
if (second is null)
|
||||
{
|
||||
// Single token: a raw state byte, or a brightness word (flash = solid).
|
||||
if (TryParseNumber(first, out int raw))
|
||||
{
|
||||
if (raw is < 0 or > 0x3F)
|
||||
{
|
||||
error = $"raw lamp state must be 0x00-0x3F, got '{first}'";
|
||||
return false;
|
||||
}
|
||||
state = (byte)raw;
|
||||
}
|
||||
else if (TryBrightness(first, out LampField1 f1, out LampField2 f2))
|
||||
{
|
||||
state = RioLampState.Compose(LampFlash.Solid, f1, f2);
|
||||
}
|
||||
else
|
||||
{
|
||||
error = $"unrecognized lamp state '{first}'";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!TryFlash(first, out LampFlash flash))
|
||||
{
|
||||
error = $"unrecognized flash mode '{first}' (solid|slow|med|fast)";
|
||||
return false;
|
||||
}
|
||||
if (!TryBrightness(second, out LampField1 f1, out LampField2 f2))
|
||||
{
|
||||
error = $"unrecognized brightness '{second}' (off|dim|bright)";
|
||||
return false;
|
||||
}
|
||||
state = RioLampState.Compose(flash, f1, f2);
|
||||
}
|
||||
|
||||
command = new FeedbackCommand
|
||||
{
|
||||
Kind = all ? FeedbackCommandKind.LampAll : FeedbackCommandKind.Lamp,
|
||||
Address = address,
|
||||
LampState = state,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryParsePlasma(
|
||||
string s, int pos, out FeedbackCommand? command, out string? error)
|
||||
{
|
||||
command = null;
|
||||
error = null;
|
||||
|
||||
string? sub = NextToken(s, ref pos);
|
||||
if (sub is null)
|
||||
{
|
||||
error = "plasma needs a subcommand (text|clear)";
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (sub.ToLowerInvariant())
|
||||
{
|
||||
case "clear":
|
||||
if (NextToken(s, ref pos) is string extra)
|
||||
{
|
||||
error = $"unexpected token '{extra}'";
|
||||
return false;
|
||||
}
|
||||
command = new FeedbackCommand { Kind = FeedbackCommandKind.PlasmaClear };
|
||||
return true;
|
||||
|
||||
case "text":
|
||||
return TryParsePlasmaText(s, pos, out command, out error);
|
||||
|
||||
default:
|
||||
error = $"unknown plasma subcommand '{sub}'";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryParsePlasmaText(
|
||||
string s, int pos, out FeedbackCommand? command, out string? error)
|
||||
{
|
||||
command = null;
|
||||
error = null;
|
||||
|
||||
// Optional "x y" position: taken only when the first TWO tokens are both
|
||||
// numeric (so `plasma text 42` displays "42"; use quotes to force text).
|
||||
byte x = 0, y = 0;
|
||||
int textStart = pos;
|
||||
int peek = pos;
|
||||
string? t1 = NextToken(s, ref peek);
|
||||
if (t1 is not null && TryParseNumber(t1, out int xv))
|
||||
{
|
||||
string? t2 = NextToken(s, ref peek);
|
||||
if (t2 is not null && TryParseNumber(t2, out int yv))
|
||||
{
|
||||
if (xv is < 0 or > 255 || yv is < 0 or > 255)
|
||||
{
|
||||
error = $"plasma position ({xv},{yv}) out of range (0-255)";
|
||||
return false;
|
||||
}
|
||||
x = (byte)xv;
|
||||
y = (byte)yv;
|
||||
textStart = peek;
|
||||
}
|
||||
// t1 numeric but t2 not: the whole remainder (from textStart) is text
|
||||
}
|
||||
|
||||
if (!TryTakeText(s, textStart, out string? text, out error))
|
||||
return false;
|
||||
if (text is null)
|
||||
{
|
||||
error = "plasma text needs text to display";
|
||||
return false;
|
||||
}
|
||||
|
||||
command = new FeedbackCommand
|
||||
{
|
||||
Kind = FeedbackCommandKind.PlasmaText,
|
||||
Text = text,
|
||||
X = x,
|
||||
Y = y,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
// Rest-of-line text: quoted (quotes stripped, no escapes, nothing may follow
|
||||
// the closing quote) or the trimmed remainder. Null = nothing there.
|
||||
private static bool TryTakeText(string s, int pos, out string? text, out string? error)
|
||||
{
|
||||
text = null;
|
||||
error = null;
|
||||
|
||||
while (pos < s.Length && char.IsWhiteSpace(s[pos]))
|
||||
pos++;
|
||||
if (pos >= s.Length)
|
||||
return true;
|
||||
|
||||
if (s[pos] == '"')
|
||||
{
|
||||
int close = s.IndexOf('"', pos + 1);
|
||||
if (close < 0)
|
||||
{
|
||||
error = "unterminated quote in plasma text";
|
||||
return false;
|
||||
}
|
||||
if (close + 1 < s.Length && s.Substring(close + 1).Trim().Length != 0)
|
||||
{
|
||||
error = "unexpected content after closing quote";
|
||||
return false;
|
||||
}
|
||||
text = s.Substring(pos + 1, close - pos - 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
text = s.Substring(pos).TrimEnd();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string? NextToken(string s, ref int pos)
|
||||
{
|
||||
while (pos < s.Length && char.IsWhiteSpace(s[pos]))
|
||||
pos++;
|
||||
if (pos >= s.Length)
|
||||
return null;
|
||||
int start = pos;
|
||||
while (pos < s.Length && !char.IsWhiteSpace(s[pos]))
|
||||
pos++;
|
||||
return s[start..pos];
|
||||
}
|
||||
|
||||
// Decimal, or hex with an 0x/0X prefix.
|
||||
private static bool TryParseNumber(string token, out int value)
|
||||
{
|
||||
if (token.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
|
||||
return int.TryParse(
|
||||
token.Substring(2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out value);
|
||||
return int.TryParse(token, NumberStyles.None, CultureInfo.InvariantCulture, out value);
|
||||
}
|
||||
|
||||
private static bool TryFlash(string token, out LampFlash flash)
|
||||
{
|
||||
switch (token.ToLowerInvariant())
|
||||
{
|
||||
case "solid": flash = LampFlash.Solid; return true;
|
||||
case "slow": flash = LampFlash.FlashSlow; return true;
|
||||
case "med": flash = LampFlash.FlashMed; return true;
|
||||
case "fast": flash = LampFlash.FlashFast; return true;
|
||||
default: flash = LampFlash.Solid; return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Brightness words set both fields, matching SolidOff/SolidDim/SolidBright.
|
||||
private static bool TryBrightness(string token, out LampField1 f1, out LampField2 f2)
|
||||
{
|
||||
switch (token.ToLowerInvariant())
|
||||
{
|
||||
case "off": f1 = LampField1.Off; f2 = LampField2.Off; return true;
|
||||
case "dim": f1 = LampField1.Dim; f2 = LampField2.Dim; return true;
|
||||
case "bright": f1 = LampField1.Bright; f2 = LampField2.Bright; return true;
|
||||
default: f1 = LampField1.Off; f2 = LampField2.Off; return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
using System.IO.Pipes;
|
||||
|
||||
namespace RioJoy.Core.Feedback;
|
||||
|
||||
/// <summary>
|
||||
/// Named-pipe listener for the inbound feedback protocol: serves
|
||||
/// <c>\\.\pipe\<name></c>, reassembles lines
|
||||
/// (<see cref="FeedbackLineBuffer"/>), and hands each to the owner — parsing
|
||||
/// and routing live in <see cref="FeedbackService"/>, so this class is pure
|
||||
/// transport. Modeled on vRIO's <c>VRioPipeService</c> (dedicated background
|
||||
/// threads — net40 has no <c>WaitForConnectionAsync</c>; throwaway poke-connect
|
||||
/// on stop because a pending <c>WaitForConnection</c> can survive Dispose on
|
||||
/// net48), with two deliberate differences: the pipe is
|
||||
/// <see cref="PipeDirection.In"/> — the server never writes, so the 0-byte
|
||||
/// pipe-buffer write deadlock class cannot occur and no reply path exists — and
|
||||
/// up to <see cref="MaxClients"/> clients may stay connected at once (a sim
|
||||
/// export script and a SimHub plugin both live here). Clients reconnect
|
||||
/// forever; a malformed or overlong line never costs a client its connection.
|
||||
/// </summary>
|
||||
public sealed class FeedbackPipeServer : IDisposable
|
||||
{
|
||||
/// <summary>Concurrent client cap (pipe instances of the served name).</summary>
|
||||
public const int MaxClients = 4;
|
||||
|
||||
private readonly string _pipeName;
|
||||
private readonly Action<string> _onLine;
|
||||
private readonly Action<string>? _log;
|
||||
private readonly SemaphoreSlim _slots = new(MaxClients, MaxClients);
|
||||
private readonly object _stateGate = new();
|
||||
private readonly List<NamedPipeServerStream> _open = new();
|
||||
private readonly List<Thread> _readers = new();
|
||||
private Thread? _accept;
|
||||
private volatile bool _running;
|
||||
|
||||
public FeedbackPipeServer(string pipeName, Action<string> onLine, Action<string>? log = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pipeName))
|
||||
throw new ArgumentException("Pipe name is required.", nameof(pipeName));
|
||||
_pipeName = pipeName;
|
||||
_onLine = onLine ?? throw new ArgumentNullException(nameof(onLine));
|
||||
_log = log;
|
||||
}
|
||||
|
||||
/// <summary>The served pipe name (without the <c>\\.\pipe\</c> prefix).</summary>
|
||||
public string PipeName => _pipeName;
|
||||
|
||||
/// <summary>Start listening (idempotent). Clients may come and go forever.</summary>
|
||||
public void Start()
|
||||
{
|
||||
if (_running)
|
||||
return;
|
||||
_running = true;
|
||||
|
||||
_accept = new Thread(AcceptLoop)
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = $"RIOJoy feedback pipe ({_pipeName})",
|
||||
};
|
||||
_accept.Start();
|
||||
_log?.Invoke($@"feedback: listening on \\.\pipe\{_pipeName}");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (!_running)
|
||||
return;
|
||||
_running = false;
|
||||
|
||||
// A WaitForConnection pending on a disposed stream can survive the
|
||||
// Dispose on net48; a throwaway client connect releases it either way.
|
||||
try
|
||||
{
|
||||
using var poke = new NamedPipeClientStream(".", _pipeName, PipeDirection.Out);
|
||||
poke.Connect(100);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or TimeoutException or UnauthorizedAccessException) { }
|
||||
|
||||
NamedPipeServerStream[] open;
|
||||
Thread[] readers;
|
||||
lock (_stateGate)
|
||||
{
|
||||
open = _open.ToArray();
|
||||
_open.Clear();
|
||||
readers = _readers.ToArray();
|
||||
_readers.Clear();
|
||||
}
|
||||
foreach (NamedPipeServerStream pipe in open)
|
||||
{
|
||||
try { pipe.Dispose(); }
|
||||
catch (IOException) { }
|
||||
}
|
||||
|
||||
_accept?.Join(1000);
|
||||
_accept = null;
|
||||
foreach (Thread reader in readers)
|
||||
reader.Join(1000);
|
||||
}
|
||||
|
||||
private void AcceptLoop()
|
||||
{
|
||||
bool busyLogged = false; // log a name collision once, not per retry
|
||||
|
||||
while (_running)
|
||||
{
|
||||
// At capacity, park until a reader frees its slot (timed, so
|
||||
// shutdown can't wedge on a missed release).
|
||||
if (!_slots.Wait(200))
|
||||
continue;
|
||||
if (!_running)
|
||||
{
|
||||
_slots.Release();
|
||||
return;
|
||||
}
|
||||
|
||||
NamedPipeServerStream pipe;
|
||||
try
|
||||
{
|
||||
pipe = new NamedPipeServerStream(_pipeName, PipeDirection.In, MaxClients,
|
||||
PipeTransmissionMode.Byte, PipeOptions.Asynchronous);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// Name already served — most likely a second RIOJoy instance.
|
||||
_slots.Release();
|
||||
if (!busyLogged)
|
||||
{
|
||||
busyLogged = true;
|
||||
_log?.Invoke($@"feedback: \\.\pipe\{_pipeName} is busy ({ex.Message.TrimEnd('.')}) — retrying");
|
||||
}
|
||||
for (int i = 0; i < 20 && _running; i++)
|
||||
Thread.Sleep(100);
|
||||
continue;
|
||||
}
|
||||
busyLogged = false;
|
||||
lock (_stateGate)
|
||||
_open.Add(pipe);
|
||||
|
||||
try
|
||||
{
|
||||
pipe.WaitForConnection();
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or ObjectDisposedException or InvalidOperationException)
|
||||
{
|
||||
Drop(pipe);
|
||||
continue; // disposed by Dispose(), or the client vanished mid-connect
|
||||
}
|
||||
|
||||
if (!_running)
|
||||
{
|
||||
Drop(pipe);
|
||||
return;
|
||||
}
|
||||
|
||||
var reader = new Thread(() => ReadUntilDisconnect(pipe))
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = $"RIOJoy feedback pipe reader ({_pipeName})",
|
||||
};
|
||||
lock (_stateGate)
|
||||
_readers.Add(reader);
|
||||
reader.Start(); // the reader owns the slot + stream from here
|
||||
}
|
||||
}
|
||||
|
||||
private void ReadUntilDisconnect(NamedPipeServerStream pipe)
|
||||
{
|
||||
var buffer = new byte[512];
|
||||
var lines = new FeedbackLineBuffer();
|
||||
try
|
||||
{
|
||||
while (_running)
|
||||
{
|
||||
int n;
|
||||
try
|
||||
{
|
||||
n = pipe.Read(buffer, 0, buffer.Length);
|
||||
}
|
||||
catch (Exception ex) when (
|
||||
ex is IOException or ObjectDisposedException or InvalidOperationException)
|
||||
{
|
||||
return; // client gone or shutdown
|
||||
}
|
||||
|
||||
if (n == 0)
|
||||
return; // client closed its end
|
||||
|
||||
foreach (string line in lines.Feed(buffer, n))
|
||||
Handle(line);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Drop(pipe);
|
||||
lock (_stateGate)
|
||||
_readers.Remove(Thread.CurrentThread);
|
||||
}
|
||||
}
|
||||
|
||||
private void Handle(string line)
|
||||
{
|
||||
try
|
||||
{
|
||||
_onLine(line);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// The line sink must never kill a reader; log and keep serving.
|
||||
_log?.Invoke($"feedback: line handler failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void Drop(NamedPipeServerStream pipe)
|
||||
{
|
||||
lock (_stateGate)
|
||||
_open.Remove(pipe);
|
||||
try { pipe.Dispose(); }
|
||||
catch (IOException) { }
|
||||
_slots.Release();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using RioJoy.Core.Mapping;
|
||||
using RioJoy.Core.Plasma;
|
||||
|
||||
namespace RioJoy.Core.Feedback;
|
||||
|
||||
/// <summary>
|
||||
/// The inbound game-feedback endpoint, assembled: pipe + UDP listeners feed
|
||||
/// protocol lines here; lines parse into <see cref="FeedbackCommand"/>s and
|
||||
/// route to the active profile's outputs. App-lifetime by design — the
|
||||
/// coordinator creates one lazily and keeps it across profile switches, so
|
||||
/// external clients hold their connection through switches and dormancy;
|
||||
/// <see cref="Attach"/>/<see cref="Detach"/> only swap where commands land
|
||||
/// (detached = dropped). <see cref="Attach"/> owns the per-activation
|
||||
/// <see cref="CoalescingLampScheduler"/> (creates it, runs its pump, cancels it
|
||||
/// on detach) and returns it so the rumble adapter can share the one rate
|
||||
/// governor.
|
||||
/// </summary>
|
||||
public sealed class FeedbackService : IDisposable
|
||||
{
|
||||
// A misbehaving client can emit garbage at line rate; log the first few and
|
||||
// go quiet instead of flooding the tray status/log.
|
||||
private const int MaxMalformedLogs = 5;
|
||||
|
||||
private readonly FeedbackEndpointConfig _config;
|
||||
private readonly FeedbackRouter _router = new();
|
||||
private FeedbackPipeServer? _pipe;
|
||||
private FeedbackUdpListener? _udp;
|
||||
private CancellationTokenSource? _schedulerCts;
|
||||
private bool _started;
|
||||
private long _malformed;
|
||||
|
||||
public FeedbackService(FeedbackEndpointConfig? config)
|
||||
{
|
||||
_config = config ?? new FeedbackEndpointConfig();
|
||||
_router.Logged += message => Logged?.Invoke(message);
|
||||
}
|
||||
|
||||
/// <summary>Diagnostics: listener lifecycle, malformed lines, dropped lamp writes.</summary>
|
||||
public event Action<string>? Logged;
|
||||
|
||||
/// <summary>Total lines that failed to parse (all clients).</summary>
|
||||
public long MalformedLines => Interlocked.Read(ref _malformed);
|
||||
|
||||
/// <summary>Commands dropped (detached, disallowed, or profile-owned lamps).</summary>
|
||||
public long DroppedCommands => _router.DroppedCommands;
|
||||
|
||||
/// <summary>Start the configured listeners (idempotent).</summary>
|
||||
public void Start()
|
||||
{
|
||||
if (_started)
|
||||
return;
|
||||
_started = true;
|
||||
|
||||
if (_config.PipeEnabled)
|
||||
{
|
||||
_pipe = new FeedbackPipeServer(_config.PipeName, HandleLine, OnLog);
|
||||
_pipe.Start();
|
||||
}
|
||||
|
||||
if (_config.UdpPort is int port)
|
||||
{
|
||||
try
|
||||
{
|
||||
_udp = new FeedbackUdpListener(port, HandleLine, OnLog);
|
||||
_udp.Start();
|
||||
}
|
||||
catch (System.Net.Sockets.SocketException ex)
|
||||
{
|
||||
// Port taken — feedback still works over the pipe; say so and go on.
|
||||
OnLog($"feedback: UDP port {port} unavailable ({ex.Message}) — pipe only");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Point inbound feedback at a just-activated profile's outputs. Returns the
|
||||
/// live lamp scheduler (share it with the rumble adapter — one governor for
|
||||
/// all feedback lamp traffic).
|
||||
/// </summary>
|
||||
public CoalescingLampScheduler Attach(
|
||||
ILampSink lamps, RioInputMap map, PlasmaDisplay? plasma, ProfileFeedbackConfig config)
|
||||
{
|
||||
Detach();
|
||||
|
||||
var scheduler = new CoalescingLampScheduler(lamps);
|
||||
_schedulerCts = new CancellationTokenSource();
|
||||
_ = scheduler.RunAsync(_schedulerCts.Token); // exits cleanly on cancel, never faults
|
||||
_router.Attach(scheduler, map, plasma, config);
|
||||
return scheduler;
|
||||
}
|
||||
|
||||
/// <summary>Drop the profile target; subsequent commands are dropped (counted).</summary>
|
||||
public void Detach()
|
||||
{
|
||||
_router.Detach();
|
||||
_schedulerCts?.Cancel();
|
||||
_schedulerCts?.Dispose();
|
||||
_schedulerCts = null;
|
||||
}
|
||||
|
||||
private void HandleLine(string line)
|
||||
{
|
||||
if (FeedbackLineParser.TryParse(line, out FeedbackCommand? command, out string? error))
|
||||
{
|
||||
_router.Dispatch(command!);
|
||||
}
|
||||
else if (error is not null)
|
||||
{
|
||||
long count = Interlocked.Increment(ref _malformed);
|
||||
if (count <= MaxMalformedLogs)
|
||||
OnLog($"feedback: bad line ({error})" +
|
||||
(count == MaxMalformedLogs ? " — further malformed lines suppressed" : string.Empty));
|
||||
}
|
||||
}
|
||||
|
||||
private void OnLog(string message) => Logged?.Invoke(message);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Detach();
|
||||
_pipe?.Dispose();
|
||||
_pipe = null;
|
||||
_udp?.Dispose();
|
||||
_udp = null;
|
||||
_started = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace RioJoy.Core.Feedback;
|
||||
|
||||
/// <summary>
|
||||
/// UDP loopback listener for the inbound feedback protocol — the transport sim
|
||||
/// export scripts speak natively (DCS Export.lua, SimHub, X-Plane). Binds
|
||||
/// <see cref="IPAddress.Loopback"/> only, so nothing off-machine can inject
|
||||
/// commands and no firewall prompt appears. Each datagram carries one or more
|
||||
/// complete protocol lines; end-of-datagram terminates the final line even
|
||||
/// without a trailing LF, and nothing fragments across datagrams. Blocking
|
||||
/// <c>Receive</c> on a background thread (net40 has no <c>ReceiveAsync</c> —
|
||||
/// one code path for both flavors); <c>Close</c> unblocks it on dispose.
|
||||
/// </summary>
|
||||
public sealed class FeedbackUdpListener : IDisposable
|
||||
{
|
||||
/// <summary>Datagrams larger than this are dropped (guards a hostile/broken sender).</summary>
|
||||
public const int MaxDatagramBytes = 4096;
|
||||
|
||||
private readonly UdpClient _udp;
|
||||
private readonly Action<string> _onLine;
|
||||
private readonly Action<string>? _log;
|
||||
private Thread? _thread;
|
||||
private volatile bool _running;
|
||||
|
||||
/// <summary>Binds immediately; throws <see cref="SocketException"/> if the port is taken.</summary>
|
||||
public FeedbackUdpListener(int port, Action<string> onLine, Action<string>? log = null)
|
||||
{
|
||||
_onLine = onLine ?? throw new ArgumentNullException(nameof(onLine));
|
||||
_log = log;
|
||||
_udp = new UdpClient(new IPEndPoint(IPAddress.Loopback, port));
|
||||
Port = ((IPEndPoint)_udp.Client.LocalEndPoint!).Port;
|
||||
}
|
||||
|
||||
/// <summary>The bound port (resolves a requested port of 0 to the ephemeral one).</summary>
|
||||
public int Port { get; }
|
||||
|
||||
/// <summary>Start receiving (idempotent).</summary>
|
||||
public void Start()
|
||||
{
|
||||
if (_running)
|
||||
return;
|
||||
_running = true;
|
||||
|
||||
_thread = new Thread(ReceiveLoop)
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = $"RIOJoy feedback UDP (:{Port})",
|
||||
};
|
||||
_thread.Start();
|
||||
_log?.Invoke($"feedback: listening on udp://127.0.0.1:{Port}");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (!_running)
|
||||
{
|
||||
_udp.Close();
|
||||
return;
|
||||
}
|
||||
_running = false;
|
||||
_udp.Close(); // unblocks the pending Receive with a SocketException
|
||||
_thread?.Join(1000);
|
||||
_thread = null;
|
||||
}
|
||||
|
||||
private void ReceiveLoop()
|
||||
{
|
||||
var lines = new FeedbackLineBuffer(); // reset per datagram via Flush
|
||||
while (_running)
|
||||
{
|
||||
IPEndPoint? remote = null;
|
||||
byte[] datagram;
|
||||
try
|
||||
{
|
||||
datagram = _udp.Receive(ref remote!);
|
||||
}
|
||||
catch (Exception ex) when (ex is SocketException or ObjectDisposedException)
|
||||
{
|
||||
if (!_running)
|
||||
return; // closed by Dispose
|
||||
continue; // e.g. ICMP port-unreachable reflected as SocketException
|
||||
}
|
||||
|
||||
if (datagram.Length > MaxDatagramBytes)
|
||||
{
|
||||
_log?.Invoke($"feedback: dropped oversize {datagram.Length}-byte datagram");
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (string line in lines.Feed(datagram, datagram.Length))
|
||||
Handle(line);
|
||||
if (lines.Flush() is string tail) // datagram end terminates the last line
|
||||
Handle(tail);
|
||||
}
|
||||
}
|
||||
|
||||
private void Handle(string line)
|
||||
{
|
||||
try
|
||||
{
|
||||
_onLine(line);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log?.Invoke($"feedback: line handler failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using RioJoy.Core.Protocol;
|
||||
|
||||
namespace RioJoy.Core.Feedback;
|
||||
|
||||
/// <summary>
|
||||
/// Maps XInput vibration onto cockpit lamp flash: each motor's intensity
|
||||
/// becomes off / slow / med / fast (bright) on that motor's configured lamp
|
||||
/// addresses. Subscribed to <c>ViGEmJoystickSink.RumbleChanged</c>, which fires
|
||||
/// on a ViGEm-owned thread at XInput rates — <see cref="OnRumble"/> therefore
|
||||
/// only computes a state byte and posts to the shared
|
||||
/// <see cref="CoalescingLampScheduler"/> when it changed. The board sustains
|
||||
/// the blink from the state byte, so a game holding constant rumble costs one
|
||||
/// lamp command, and XInput's stream of identical values costs nothing.
|
||||
/// (TFM-neutral; only the ViGEm hookup is net48-only.)
|
||||
/// </summary>
|
||||
public sealed class RumbleLampAdapter
|
||||
{
|
||||
private readonly RumbleLampConfig _config;
|
||||
private readonly CoalescingLampScheduler _lamps;
|
||||
private readonly object _gate = new();
|
||||
private int _lastLarge = -1; // last posted state byte; -1 = none yet
|
||||
private int _lastSmall = -1;
|
||||
|
||||
public RumbleLampAdapter(RumbleLampConfig config, CoalescingLampScheduler lamps)
|
||||
{
|
||||
_config = config ?? throw new ArgumentNullException(nameof(config));
|
||||
_lamps = lamps ?? throw new ArgumentNullException(nameof(lamps));
|
||||
}
|
||||
|
||||
/// <summary>Vibration update from the pad. Thread-safe, non-blocking.</summary>
|
||||
public void OnRumble(byte large, byte small)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
Apply(large, _config.LargeMotorLamps, ref _lastLarge);
|
||||
Apply(small, _config.SmallMotorLamps, ref _lastSmall);
|
||||
}
|
||||
}
|
||||
|
||||
private void Apply(byte value, List<int> addresses, ref int lastState)
|
||||
{
|
||||
byte state = MapMotor(value, _config.Threshold);
|
||||
if (state == lastState)
|
||||
return;
|
||||
lastState = state;
|
||||
foreach (int address in addresses)
|
||||
_lamps.Post(address, state); // invalid config addresses drop in Post
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Motor byte → lamp state: below <paramref name="threshold"/> is off; the
|
||||
/// remaining range splits into thirds of slow / med / fast flash, bright.
|
||||
/// </summary>
|
||||
public static byte MapMotor(byte value, byte threshold)
|
||||
{
|
||||
if (value < threshold)
|
||||
return RioLampState.SolidOff;
|
||||
|
||||
int span = 256 - threshold;
|
||||
int offset = value - threshold;
|
||||
LampFlash flash = offset < span / 3 ? LampFlash.FlashSlow
|
||||
: offset < span * 2 / 3 ? LampFlash.FlashMed
|
||||
: LampFlash.FlashFast;
|
||||
return RioLampState.Compose(flash, LampField1.Bright, LampField2.Bright);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user