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
@@ -0,0 +1,141 @@
using System.Globalization;
using RioJoy.Core.Feedback;
using RioJoy.Core.Mapping;
using RioJoy.Core.Protocol;
using RioJoy.Core.Tests.Mapping;
using RioJoy.Core.Tests.Serial;
using Xunit;
namespace RioJoy.Core.Tests.Feedback;
public class CoalescingLampSchedulerTests
{
private static readonly TimeSpan Fast = TimeSpan.FromMilliseconds(1); // pump as fast as timers allow
private static Task WaitFor(Func<bool> condition, int timeoutMs = 5000) =>
FeedbackWait.For(condition, timeoutMs);
// "Lamp(0x12,0x3C)" → (0x12, 0x3C)
private static (int Address, byte State) ParseLamp(string entry)
{
string[] parts = entry["Lamp(0x".Length..^1].Split(new[] { ",0x" }, StringSplitOptions.None);
return (int.Parse(parts[0], NumberStyles.HexNumber, CultureInfo.InvariantCulture),
byte.Parse(parts[1], NumberStyles.HexNumber, CultureInfo.InvariantCulture));
}
[Fact]
public async Task Post_SameAddressRepeatedly_SendsOnlyTheLatestState()
{
var sink = new RecordingSink();
var scheduler = new CoalescingLampScheduler(sink, Fast);
for (byte s = 0; s <= 0x30; s++)
scheduler.Post(0x12, s); // a burst of updates while nothing pumps
using var cts = new CancellationTokenSource();
Task pump = scheduler.RunAsync(cts.Token);
await WaitFor(() => sink.Snapshot().Length >= 1);
await Task.Delay(50); // give a buggy scheduler time to send the rest
cts.Cancel();
await pump.WithTimeout();
string entry = Assert.Single(sink.Snapshot());
Assert.Equal((0x12, (byte)0x30), ParseLamp(entry)); // burst collapsed to the last state
}
[Fact]
public async Task Post_UnchangedState_IsNotResent()
{
var sink = new RecordingSink();
var scheduler = new CoalescingLampScheduler(sink, Fast);
using var cts = new CancellationTokenSource();
Task pump = scheduler.RunAsync(cts.Token);
scheduler.Post(0x05, RioLampState.SolidBright);
await WaitFor(() => sink.Snapshot().Length >= 1);
scheduler.Post(0x05, RioLampState.SolidBright); // same state again
await Task.Delay(50);
cts.Cancel();
await pump.WithTimeout();
Assert.Single(sink.Snapshot()); // no resend for an unchanged lamp
}
[Fact]
public async Task Pump_SendsAtMostOneLampPerTick()
{
var sink = new RecordingSink();
var scheduler = new CoalescingLampScheduler(sink, TimeSpan.FromMilliseconds(200));
scheduler.Post(0x01, RioLampState.SolidBright);
scheduler.Post(0x02, RioLampState.SolidBright);
scheduler.Post(0x03, RioLampState.SolidBright);
using var cts = new CancellationTokenSource();
Task pump = scheduler.RunAsync(cts.Token);
await WaitFor(() => sink.Snapshot().Length >= 1);
// The next tick is ~200 ms out; three pending lamps must not burst.
Assert.Single(sink.Snapshot());
cts.Cancel();
await pump.WithTimeout();
}
[Fact]
public async Task PostAll_CoversExactlyTheValidAddressSet()
{
var sink = new RecordingSink();
var scheduler = new CoalescingLampScheduler(sink, Fast);
scheduler.PostAll(RioLampState.SolidOff);
int validCount = Enumerable.Range(0, RioAddress.TableSize).Count(RioAddress.IsValid);
Assert.Equal(104, validCount); // 72 buttons + 2×16 keypad keys; 0x48-0x4F is a gap
using var cts = new CancellationTokenSource();
Task pump = scheduler.RunAsync(cts.Token);
await WaitFor(() => sink.Snapshot().Length >= validCount);
await Task.Delay(50);
cts.Cancel();
await pump.WithTimeout();
var sent = sink.Snapshot().Select(ParseLamp).ToArray();
Assert.Equal(validCount, sent.Length); // nothing sent twice, no gap addresses
Assert.All(sent, s => Assert.Equal(RioLampState.SolidOff, s.State));
Assert.Equal(
Enumerable.Range(0, RioAddress.TableSize).Where(RioAddress.IsValid),
sent.Select(s => s.Address).OrderBy(a => a));
}
[Fact]
public async Task Post_InvalidAddress_Ignored()
{
var sink = new RecordingSink();
var scheduler = new CoalescingLampScheduler(sink, Fast);
scheduler.Post(0x48, RioLampState.SolidBright); // gap address (rumble config is unvalidated)
scheduler.Post(0x70, RioLampState.SolidBright);
scheduler.Post(-1, RioLampState.SolidBright);
using var cts = new CancellationTokenSource();
Task pump = scheduler.RunAsync(cts.Token);
await Task.Delay(100);
cts.Cancel();
await pump.WithTimeout();
Assert.Empty(sink.Snapshot());
}
[Fact]
public async Task Cancel_StopsThePump()
{
var sink = new RecordingSink();
var scheduler = new CoalescingLampScheduler(sink, Fast);
using var cts = new CancellationTokenSource();
Task pump = scheduler.RunAsync(cts.Token);
cts.Cancel();
await pump.WithTimeout(); // exits cleanly, no OperationCanceledException
scheduler.Post(0x01, RioLampState.SolidBright);
await Task.Delay(50);
Assert.Empty(sink.Snapshot()); // a stopped pump sends nothing
}
}
@@ -0,0 +1,250 @@
using System.Text;
using RioJoy.Core.Feedback;
using RioJoy.Core.Protocol;
using Xunit;
namespace RioJoy.Core.Tests.Feedback;
public class FeedbackLineParserTests
{
private static FeedbackCommand Parse(string line)
{
bool ok = FeedbackLineParser.TryParse(line, out FeedbackCommand? command, out string? error);
Assert.True(ok, $"expected '{line}' to parse, got error: {error}");
return command!;
}
private static string ParseError(string line)
{
bool ok = FeedbackLineParser.TryParse(line, out _, out string? error);
Assert.False(ok);
Assert.NotNull(error);
return error!;
}
[Theory]
[InlineData("lamp 0x12 bright", 0x12, 0x3C)] // solid implied; = SolidBright
[InlineData("lamp 18 bright", 18, 0x3C)] // decimal address
[InlineData("lamp 0x12 dim", 0x12, 0x14)] // = SolidDim
[InlineData("lamp 0x12 off", 0x12, 0x00)] // = SolidOff
[InlineData("lamp 0x12 fast bright", 0x12, 0x3F)]
[InlineData("lamp 0x12 slow dim", 0x12, 0x15)]
[InlineData("lamp 0x12 med bright", 0x12, 0x3E)]
[InlineData("lamp 0x12 solid bright", 0x12, 0x3C)]
[InlineData("LAMP 0x12 FAST BRIGHT", 0x12, 0x3F)] // keywords case-insensitive
[InlineData("lamp 0x12 0x36", 0x12, 0x36)] // raw state byte
[InlineData("lamp 0x12 20", 0x12, 20)] // raw state, decimal
public void Lamp_Forms_ComposeTheDocumentedStateByte(string line, int address, int state)
{
FeedbackCommand cmd = Parse(line);
Assert.Equal(FeedbackCommandKind.Lamp, cmd.Kind);
Assert.Equal(address, cmd.Address);
Assert.Equal((byte)state, cmd.LampState);
}
[Fact]
public void Lamp_WordStates_MatchRioLampStateCompose()
{
Assert.Equal(RioLampState.SolidBright, Parse("lamp 0 bright").LampState);
Assert.Equal(RioLampState.SolidDim, Parse("lamp 0 dim").LampState);
Assert.Equal(RioLampState.SolidOff, Parse("lamp 0 off").LampState);
Assert.Equal(
RioLampState.Compose(LampFlash.FlashFast, LampField1.Bright, LampField2.Bright),
Parse("lamp 0 fast bright").LampState);
}
[Theory]
[InlineData(0x00)]
[InlineData(0x47)]
[InlineData(0x50)]
[InlineData(0x5F)]
[InlineData(0x60)]
[InlineData(0x6F)]
public void Lamp_AddressRangeEdges_Accepted(int address)
{
Assert.Equal(address, Parse($"lamp 0x{address:X2} dim").Address);
}
[Theory]
[InlineData("lamp 0x48 dim")] // gap between buttons and keypad 0
[InlineData("lamp 0x4F dim")]
[InlineData("lamp 0x70 dim")] // beyond MaxAddress
[InlineData("lamp 200 dim")]
public void Lamp_AddressOutOfRange_Rejected(string line)
{
Assert.Contains("out of range", ParseError(line));
}
[Theory]
[InlineData("lamp 0x12 0x40")] // raw state above 6 lamp-state bits
[InlineData("lamp 0x12 64")]
public void Lamp_RawStateAboveSixBits_Rejected(string line)
{
Assert.Contains("0x00-0x3F", ParseError(line));
}
[Theory]
[InlineData("lamp")]
[InlineData("lamp 0x12")]
[InlineData("lamp 0x12 blinky")]
[InlineData("lamp 0x12 fast blinky")]
[InlineData("lamp 0x12 fast bright extra")]
[InlineData("lamp banana dim")]
[InlineData("bogus 1 2")]
public void Lamp_Malformed_ReturnsErrorText(string line)
{
Assert.NotEmpty(ParseError(line));
}
[Fact]
public void LampAll_ParsesStateWithoutAddress()
{
FeedbackCommand cmd = Parse("lamp-all off");
Assert.Equal(FeedbackCommandKind.LampAll, cmd.Kind);
Assert.Equal(RioLampState.SolidOff, cmd.LampState);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("# a comment")]
[InlineData("; also a comment")]
[InlineData(" # indented comment")]
public void BlankAndComment_SkippedWithoutError(string line)
{
bool ok = FeedbackLineParser.TryParse(line, out FeedbackCommand? command, out string? error);
Assert.False(ok);
Assert.Null(command);
Assert.Null(error);
}
[Fact]
public void PlasmaClear_Parses()
{
Assert.Equal(FeedbackCommandKind.PlasmaClear, Parse("plasma clear").Kind);
}
[Fact]
public void PlasmaText_Quoted_StripsQuotes()
{
FeedbackCommand cmd = Parse("plasma text \"VIPER 1-1\"");
Assert.Equal(FeedbackCommandKind.PlasmaText, cmd.Kind);
Assert.Equal("VIPER 1-1", cmd.Text);
Assert.Equal(0, cmd.X); // (0,0) = auto-center
Assert.Equal(0, cmd.Y);
}
[Fact]
public void PlasmaText_Unquoted_TakesRestOfLine()
{
Assert.Equal("VIPER 1-1", Parse("plasma text VIPER 1-1").Text);
}
[Fact]
public void PlasmaText_TwoLeadingNumbers_ArePosition()
{
FeedbackCommand cmd = Parse("plasma text 12 3 \"FUEL LOW\"");
Assert.Equal(12, cmd.X);
Assert.Equal(3, cmd.Y);
Assert.Equal("FUEL LOW", cmd.Text);
}
[Fact]
public void PlasmaText_OneLeadingNumber_IsText()
{
// Only two consecutive numeric tokens form a position; a single one is text.
FeedbackCommand cmd = Parse("plasma text 42 kills");
Assert.Equal(0, cmd.X);
Assert.Equal(0, cmd.Y);
Assert.Equal("42 kills", cmd.Text);
}
[Fact]
public void PlasmaText_QuotedNumber_IsText()
{
Assert.Equal("42", Parse("plasma text \"42\"").Text);
}
[Fact]
public void PlasmaText_Latin1Chars_Preserved()
{
// Latin-1 range survives the parser untouched (plasma wire encoding).
Assert.Equal("CAFÉ ÜBER", Parse("plasma text \"CAFÉ ÜBER\"").Text);
}
[Theory]
[InlineData("plasma")]
[InlineData("plasma bogus")]
[InlineData("plasma clear extra")]
[InlineData("plasma text")]
[InlineData("plasma text \"unterminated")]
[InlineData("plasma text \"done\" trailing")]
[InlineData("plasma text 300 1 \"X\"")] // position out of byte range
public void Plasma_Malformed_ReturnsErrorText(string line)
{
Assert.NotEmpty(ParseError(line));
}
}
public class FeedbackLineBufferTests
{
private static byte[] Latin1(string s) => Encoding.GetEncoding(28591).GetBytes(s);
[Fact]
public void Feed_SplitsOnLf_AndStripsCr()
{
var buffer = new FeedbackLineBuffer();
byte[] data = Latin1("lamp 1 dim\r\nplasma clear\n");
Assert.Equal(
new[] { "lamp 1 dim", "plasma clear" },
buffer.Feed(data, data.Length));
}
[Fact]
public void Feed_ReassemblesLinesSplitAcrossChunks()
{
var buffer = new FeedbackLineBuffer();
byte[] a = Latin1("lamp 0x12 fa");
byte[] b = Latin1("st bright\n");
Assert.Empty(buffer.Feed(a, a.Length));
Assert.Equal(new[] { "lamp 0x12 fast bright" }, buffer.Feed(b, b.Length));
}
[Fact]
public void Feed_DiscardsOverlongLine_ThenRecovers()
{
var buffer = new FeedbackLineBuffer();
byte[] junk = Latin1(new string('x', FeedbackLineBuffer.MaxLineLength + 50) + "\nlamp 1 dim\n");
Assert.Equal(new[] { "lamp 1 dim" }, buffer.Feed(junk, junk.Length));
}
[Fact]
public void Feed_DecodesLatin1Bytes()
{
var buffer = new FeedbackLineBuffer();
byte[] data = Latin1("plasma text \"CAFÉ\"\n");
Assert.Equal(new[] { "plasma text \"CAFÉ\"" }, buffer.Feed(data, data.Length));
}
[Fact]
public void Flush_ReturnsTrailingLineWithoutLf()
{
// UDP datagrams may omit the final LF; end-of-datagram is a terminator.
var buffer = new FeedbackLineBuffer();
byte[] data = Latin1("lamp 1 dim\nlamp 2 off");
Assert.Equal(new[] { "lamp 1 dim" }, buffer.Feed(data, data.Length));
Assert.Equal("lamp 2 off", buffer.Flush());
Assert.Null(buffer.Flush()); // flushed state is consumed
}
[Fact]
public void Flush_EmptyOrDiscarding_ReturnsNull()
{
var buffer = new FeedbackLineBuffer();
Assert.Null(buffer.Flush());
byte[] junk = Latin1(new string('x', FeedbackLineBuffer.MaxLineLength + 50));
Assert.Empty(buffer.Feed(junk, junk.Length));
Assert.Null(buffer.Flush()); // overlong tail is discarded, not returned
}
}
@@ -0,0 +1,156 @@
using System.IO.Pipes;
using System.Text;
using RioJoy.Core.Feedback;
using Xunit;
namespace RioJoy.Core.Tests.Feedback;
public class FeedbackPipeServerTests
{
private static string UniqueName() => $"riojoy-fb-test-{Guid.NewGuid():N}";
private static byte[] Latin1(string s) => Encoding.GetEncoding(28591).GetBytes(s);
/// <summary>Collector whose Count/Snapshot are safe against the reader threads.</summary>
private sealed class Lines
{
private readonly List<string> _lines = new();
public void Add(string line)
{
lock (_lines) _lines.Add(line);
}
public int Count
{
get { lock (_lines) return _lines.Count; }
}
public string[] Snapshot()
{
lock (_lines) return _lines.ToArray();
}
}
// The accept loop arms asynchronously after Start; retry until it listens.
private static NamedPipeClientStream Connect(string name, int timeoutMs = 5000)
{
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
while (true)
{
var client = new NamedPipeClientStream(".", name, PipeDirection.Out);
try
{
client.Connect(200);
return client;
}
catch (Exception ex) when (ex is IOException or TimeoutException)
{
client.Dispose();
Assert.True(DateTime.UtcNow < deadline, $"could not connect to {name}: {ex.Message}");
Thread.Sleep(20);
}
}
}
private static void Send(NamedPipeClientStream client, string text)
{
byte[] data = Latin1(text);
client.Write(data, 0, data.Length);
client.Flush();
}
[Fact]
public async Task Lines_AreDeliveredInOrder()
{
string name = UniqueName();
var lines = new Lines();
using var server = new FeedbackPipeServer(name, lines.Add);
server.Start();
using NamedPipeClientStream client = Connect(name);
Send(client, "lamp 1 dim\r\nplasma clear\n");
await FeedbackWait.For(() => lines.Count >= 2);
Assert.Equal(new[] { "lamp 1 dim", "plasma clear" }, lines.Snapshot());
}
[Fact]
public async Task Line_SplitAcrossWrites_Reassembles()
{
string name = UniqueName();
var lines = new Lines();
using var server = new FeedbackPipeServer(name, lines.Add);
server.Start();
using NamedPipeClientStream client = Connect(name);
Send(client, "lamp 0x12 fa");
Send(client, "st bright\n");
await FeedbackWait.For(() => lines.Count >= 1);
Assert.Equal("lamp 0x12 fast bright", Assert.Single(lines.Snapshot()));
}
[Fact]
public async Task TwoConcurrentClients_BothDeliver()
{
string name = UniqueName();
var lines = new Lines();
using var server = new FeedbackPipeServer(name, lines.Add);
server.Start();
using NamedPipeClientStream a = Connect(name);
using NamedPipeClientStream b = Connect(name); // second instance while A stays connected
Send(a, "lamp 1 dim\n");
Send(b, "lamp 2 off\n");
await FeedbackWait.For(() => lines.Count >= 2);
Assert.Equal(new[] { "lamp 1 dim", "lamp 2 off" }, lines.Snapshot().OrderBy(l => l));
}
[Fact]
public async Task ClientDisconnect_ThenReconnect_Works()
{
string name = UniqueName();
var lines = new Lines();
using var server = new FeedbackPipeServer(name, lines.Add);
server.Start();
using (NamedPipeClientStream first = Connect(name))
Send(first, "lamp 1 dim\n");
await FeedbackWait.For(() => lines.Count >= 1);
using NamedPipeClientStream second = Connect(name); // server re-arms after the EOF
Send(second, "lamp 2 off\n");
await FeedbackWait.For(() => lines.Count >= 2);
}
[Fact]
public async Task ThrowingLineHandler_DoesNotKillTheConnection()
{
string name = UniqueName();
var lines = new Lines();
using var server = new FeedbackPipeServer(name, line =>
{
if (line.Contains("boom"))
throw new InvalidOperationException("handler bug");
lines.Add(line);
});
server.Start();
using NamedPipeClientStream client = Connect(name);
Send(client, "boom\nlamp 1 dim\n");
await FeedbackWait.For(() => lines.Count >= 1); // the good line still lands
Assert.Equal("lamp 1 dim", Assert.Single(lines.Snapshot()));
}
[Fact]
public void Dispose_UnblocksThePendingAccept()
{
var server = new FeedbackPipeServer(UniqueName(), _ => { });
server.Start();
Thread.Sleep(100); // let the accept loop park in WaitForConnection
server.Dispose(); // must not hang on the pending accept (poke-connect)
}
}
@@ -0,0 +1,154 @@
using RioJoy.Core.Feedback;
using RioJoy.Core.Mapping;
using RioJoy.Core.Plasma;
using RioJoy.Core.Protocol;
using RioJoy.Core.Tests.Mapping;
using RioJoy.Core.Tests.Serial;
using Xunit;
namespace RioJoy.Core.Tests.Feedback;
public class FeedbackRouterTests : IDisposable
{
private readonly RecordingSink _sink = new();
private readonly CoalescingLampScheduler _scheduler;
private readonly CancellationTokenSource _cts = new();
private readonly Task _pump;
public FeedbackRouterTests()
{
_scheduler = new CoalescingLampScheduler(_sink, TimeSpan.FromMilliseconds(1));
_pump = _scheduler.RunAsync(_cts.Token);
}
public void Dispose()
{
_cts.Cancel();
_pump.Wait(TimeSpan.FromSeconds(5));
_cts.Dispose();
}
private static FeedbackCommand Lamp(int address, byte state) =>
new() { Kind = FeedbackCommandKind.Lamp, Address = address, LampState = state };
private static FeedbackCommand Text(string text) =>
new() { Kind = FeedbackCommandKind.PlasmaText, Text = text };
[Fact]
public async Task Lamp_ProfileOwnedAddressDropped_UnownedApplied()
{
var map = new RioInputMap();
map[0x10] = RioMapEntry.Create(RioRouteKind.Keyboard, 0x41, lit: true); // InputRouter owns this lamp
var router = new FeedbackRouter();
var logged = new List<string>();
router.Logged += logged.Add;
router.Attach(_scheduler, map, plasma: null, new ProfileFeedbackConfig());
router.Dispatch(Lamp(0x11, RioLampState.SolidBright)); // unowned → applied
await FeedbackWait.For(() => _sink.Snapshot().Length >= 1);
Assert.Equal("Lamp(0x11,0x3C)", Assert.Single(_sink.Snapshot()));
router.Dispatch(Lamp(0x10, RioLampState.SolidBright)); // owned → dropped
router.Dispatch(Lamp(0x10, RioLampState.SolidOff));
await Task.Delay(50);
Assert.Single(_sink.Snapshot());
Assert.Equal(2, router.DroppedCommands);
Assert.Single(logged); // logged once per address per attach, not per drop
}
[Fact]
public async Task Detached_CommandsDroppedAndCounted()
{
var router = new FeedbackRouter();
router.Dispatch(Lamp(0x01, RioLampState.SolidBright)); // never attached
Assert.Equal(1, router.DroppedCommands);
router.Attach(_scheduler, new RioInputMap(), null, new ProfileFeedbackConfig());
router.Detach();
router.Dispatch(Lamp(0x01, RioLampState.SolidBright));
Assert.Equal(2, router.DroppedCommands);
await Task.Delay(50);
Assert.Empty(_sink.Snapshot());
}
[Fact]
public async Task AllowFlags_GateLampAndPlasma()
{
var transport = new FakeTransport();
var router = new FeedbackRouter();
router.Attach(_scheduler, new RioInputMap(), new PlasmaDisplay(transport),
new ProfileFeedbackConfig { AllowLampCommands = false, AllowPlasmaText = false });
router.Dispatch(Lamp(0x01, RioLampState.SolidBright));
router.Dispatch(Text("NOPE"));
await Task.Delay(50);
Assert.Empty(_sink.Snapshot());
Assert.False(transport.Writes.TryRead(out _));
Assert.Equal(2, router.DroppedCommands);
}
[Fact]
public async Task LampAll_SkipsProfileOwnedLamps()
{
var map = new RioInputMap();
map[0x00] = RioMapEntry.Create(RioRouteKind.Joystick, 1, lit: true);
var router = new FeedbackRouter();
router.Attach(_scheduler, map, null, new ProfileFeedbackConfig());
router.Dispatch(new FeedbackCommand
{
Kind = FeedbackCommandKind.LampAll,
LampState = RioLampState.SolidDim,
});
int expected = Enumerable.Range(0, RioAddress.TableSize).Count(RioAddress.IsValid) - 1;
await FeedbackWait.For(() => _sink.Snapshot().Length >= expected);
await Task.Delay(50);
string[] sent = _sink.Snapshot();
Assert.Equal(expected, sent.Length);
Assert.DoesNotContain("Lamp(0x00,0x14)", sent); // the profile-owned lamp is untouched
}
[Fact]
public async Task Plasma_FloodCoalesces_FirstAndLatestOnly()
{
// Park the first text mid-write; everything dispatched meanwhile collapses
// to the single latest pending command.
var transport = new GatedTransport();
var router = new FeedbackRouter();
router.Attach(_scheduler, new RioInputMap(), new PlasmaDisplay(transport),
new ProfileFeedbackConfig());
router.Dispatch(Text("FIRST")); // goes busy, parked on the gate
router.Dispatch(Text("MID-1")); // pending
router.Dispatch(Text("MID-2")); // supersedes MID-1
router.Dispatch(Text("LAST")); // supersedes MID-2
transport.Open();
var writes = new List<byte[]>();
for (int i = 0; i < 10; i++)
writes.Add(await transport.NextWriteAsync());
await Task.Delay(50);
Assert.Equal(PlasmaCommands.Text("FIRST"), writes[4]); // FIRST's text chunk
Assert.Equal(PlasmaCommands.Text("LAST"), writes[9]); // then only LAST's
Assert.True(transport.NoMoreWrites);
Assert.Equal(2, router.DroppedCommands); // the two superseded middles
}
[Fact]
public async Task PlasmaClear_WritesTheClearCommand()
{
var transport = new FakeTransport();
var router = new FeedbackRouter();
router.Attach(_scheduler, new RioInputMap(), new PlasmaDisplay(transport),
new ProfileFeedbackConfig());
router.Dispatch(new FeedbackCommand { Kind = FeedbackCommandKind.PlasmaClear });
Assert.Equal(PlasmaCommands.Clear(), await transport.NextWriteAsync());
}
}
@@ -0,0 +1,96 @@
using System.IO.Pipes;
using System.Text;
using RioJoy.Core.Feedback;
using RioJoy.Core.Mapping;
using RioJoy.Core.Plasma;
using RioJoy.Core.Tests.Mapping;
using RioJoy.Core.Tests.Serial;
using Xunit;
namespace RioJoy.Core.Tests.Feedback;
/// <summary>
/// End-to-end: pipe client → line assembly → parse → router → lamp scheduler /
/// plasma, the exact path a sim export script exercises.
/// </summary>
public class FeedbackServiceTests
{
private static string UniqueName() => $"riojoy-fb-svc-{Guid.NewGuid():N}";
private static NamedPipeClientStream Connect(string name, int timeoutMs = 5000)
{
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
while (true)
{
var client = new NamedPipeClientStream(".", name, PipeDirection.Out);
try
{
client.Connect(200);
return client;
}
catch (Exception ex) when (ex is IOException or TimeoutException)
{
client.Dispose();
Assert.True(DateTime.UtcNow < deadline, $"could not connect: {ex.Message}");
Thread.Sleep(20);
}
}
}
private static void Send(NamedPipeClientStream client, string text)
{
byte[] data = Encoding.GetEncoding(28591).GetBytes(text);
client.Write(data, 0, data.Length);
client.Flush();
}
[Fact]
public async Task PipeClient_DrivesLampsAndPlasma_MalformedLinesSurvive()
{
string name = UniqueName();
var lamps = new RecordingSink();
var plasmaTransport = new FakeTransport();
using var service = new FeedbackService(new FeedbackEndpointConfig { PipeName = name });
service.Start();
service.Attach(lamps, new RioInputMap(), new PlasmaDisplay(plasmaTransport),
new ProfileFeedbackConfig());
using NamedPipeClientStream client = Connect(name);
Send(client, "# cockpit warmup\nlamp 0x11 fast bright\nbogus nonsense\nplasma clear\n");
await FeedbackWait.For(() => lamps.Snapshot().Length >= 1);
Assert.Equal("Lamp(0x11,0x3F)", Assert.Single(lamps.Snapshot()));
Assert.Equal(PlasmaCommands.Clear(), await plasmaTransport.NextWriteAsync());
Assert.Equal(1, service.MalformedLines); // the bogus line, not the comment
Send(client, "lamp 0x11 off\n"); // the connection survived the bad line
await FeedbackWait.For(() => lamps.Snapshot().Length >= 2);
Assert.Equal("Lamp(0x11,0x00)", lamps.Snapshot()[1]);
}
[Fact]
public async Task Detach_DropsCommands_ReattachAppliesAgain()
{
string name = UniqueName();
var lamps = new RecordingSink();
using var service = new FeedbackService(new FeedbackEndpointConfig { PipeName = name });
service.Start();
using NamedPipeClientStream client = Connect(name);
// Dormant (never attached): commands drop, the client stays connected.
Send(client, "lamp 0x01 bright\n");
await FeedbackWait.For(() => service.DroppedCommands >= 1);
Assert.Empty(lamps.Snapshot());
// Profile activates: the same client now drives lamps.
service.Attach(lamps, new RioInputMap(), null, new ProfileFeedbackConfig());
Send(client, "lamp 0x01 bright\n");
await FeedbackWait.For(() => lamps.Snapshot().Length >= 1);
// Yield to a native game: back to dropping, still connected.
service.Detach();
Send(client, "lamp 0x01 off\n");
await FeedbackWait.For(() => service.DroppedCommands >= 2);
Assert.Single(lamps.Snapshot());
}
}
@@ -0,0 +1,17 @@
using Xunit;
namespace RioJoy.Core.Tests.Feedback;
internal static class FeedbackWait
{
/// <summary>Poll until <paramref name="condition"/> holds, failing at the deadline.</summary>
public static async Task For(Func<bool> condition, int timeoutMs = 5000)
{
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
while (!condition())
{
Assert.True(DateTime.UtcNow < deadline, "condition not reached in time");
await Task.Delay(10);
}
}
}
@@ -0,0 +1,100 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
using RioJoy.Core.Feedback;
using Xunit;
namespace RioJoy.Core.Tests.Feedback;
public class FeedbackUdpListenerTests
{
private static byte[] Latin1(string s) => Encoding.GetEncoding(28591).GetBytes(s);
private sealed class Lines
{
private readonly List<string> _lines = new();
public void Add(string line)
{
lock (_lines) _lines.Add(line);
}
public int Count
{
get { lock (_lines) return _lines.Count; }
}
public string[] Snapshot()
{
lock (_lines) return _lines.ToArray();
}
}
private static void Send(int port, byte[] datagram)
{
using var udp = new UdpClient();
udp.Send(datagram, datagram.Length, new IPEndPoint(IPAddress.Loopback, port));
}
[Fact]
public async Task Datagram_WithoutTrailingLf_IsOneLine()
{
var lines = new Lines();
using var listener = new FeedbackUdpListener(0, lines.Add); // 0 → ephemeral
Assert.NotEqual(0, listener.Port);
listener.Start();
Send(listener.Port, Latin1("lamp 1 dim")); // datagram end terminates the line
await FeedbackWait.For(() => lines.Count >= 1);
Assert.Equal("lamp 1 dim", Assert.Single(lines.Snapshot()));
}
[Fact]
public async Task Datagram_WithMultipleLines_DeliversEach()
{
var lines = new Lines();
using var listener = new FeedbackUdpListener(0, lines.Add);
listener.Start();
Send(listener.Port, Latin1("lamp 1 dim\nlamp 2 off\nplasma clear"));
await FeedbackWait.For(() => lines.Count >= 3);
Assert.Equal(new[] { "lamp 1 dim", "lamp 2 off", "plasma clear" }, lines.Snapshot());
}
[Fact]
public async Task ThrowingLineHandler_DoesNotKillTheListener()
{
var lines = new Lines();
using var listener = new FeedbackUdpListener(0, line =>
{
if (line.Contains("boom"))
throw new InvalidOperationException("handler bug");
lines.Add(line);
});
listener.Start();
Send(listener.Port, Latin1("boom\n"));
Send(listener.Port, Latin1("lamp 1 dim\n"));
await FeedbackWait.For(() => lines.Count >= 1);
Assert.Equal("lamp 1 dim", Assert.Single(lines.Snapshot()));
}
[Fact]
public void Dispose_UnblocksThePendingReceive()
{
var listener = new FeedbackUdpListener(0, _ => { });
listener.Start();
Thread.Sleep(50); // let the loop park in Receive
listener.Dispose(); // Close must unblock it without hanging
}
[Fact]
public void PortInUse_ThrowsSocketException()
{
using var first = new FeedbackUdpListener(0, _ => { });
Assert.Throws<SocketException>(() => new FeedbackUdpListener(first.Port, _ => { }));
}
}
@@ -0,0 +1,108 @@
using RioJoy.Core.Feedback;
using RioJoy.Core.Protocol;
using RioJoy.Core.Tests.Mapping;
using Xunit;
namespace RioJoy.Core.Tests.Feedback;
public class RumbleLampAdapterTests
{
// Flash + Bright/Bright state bytes the bands resolve to.
private const byte SlowBright = 0x3D;
private const byte MedBright = 0x3E;
private const byte FastBright = 0x3F;
[Theory]
[InlineData(0, 24, 0x00)] // below threshold → off
[InlineData(23, 24, 0x00)]
[InlineData(24, 24, SlowBright)] // first third of the remaining range
[InlineData(100, 24, SlowBright)]
[InlineData(101, 24, MedBright)] // second third
[InlineData(177, 24, MedBright)]
[InlineData(178, 24, FastBright)] // top third
[InlineData(255, 24, FastBright)]
[InlineData(0, 0, SlowBright)] // threshold 0 = never off
public void MapMotor_BandsResolveToDocumentedStates(int value, int threshold, byte expected)
{
Assert.Equal(expected, RumbleLampAdapter.MapMotor((byte)value, (byte)threshold));
}
[Fact]
public void MapMotor_MatchesRioLampStateCompose()
{
Assert.Equal(
RioLampState.Compose(LampFlash.FlashFast, LampField1.Bright, LampField2.Bright),
RumbleLampAdapter.MapMotor(255, 24));
Assert.Equal(RioLampState.SolidOff, RumbleLampAdapter.MapMotor(0, 24));
}
[Fact]
public async Task OnRumble_DrivesEachMotorsConfiguredAddresses()
{
var sink = new RecordingSink();
var scheduler = new CoalescingLampScheduler(sink, TimeSpan.FromMilliseconds(1));
using var cts = new CancellationTokenSource();
Task pump = scheduler.RunAsync(cts.Token);
var adapter = new RumbleLampAdapter(new RumbleLampConfig
{
LargeMotorLamps = { 0x20, 0x21 },
SmallMotorLamps = { 0x30 },
}, scheduler);
adapter.OnRumble(255, 0); // big hit, no small motor
await FeedbackWait.For(() => sink.Snapshot().Length >= 3);
cts.Cancel();
await pump;
string[] sent = sink.Snapshot();
Assert.Contains("Lamp(0x20,0x3F)", sent); // large motor lamps flash fast
Assert.Contains("Lamp(0x21,0x3F)", sent);
Assert.Contains("Lamp(0x30,0x00)", sent); // small motor lamps confirmed off
}
[Fact]
public async Task OnRumble_RepeatedIdenticalValues_PostNothingNew()
{
var sink = new RecordingSink();
var scheduler = new CoalescingLampScheduler(sink, TimeSpan.FromMilliseconds(1));
using var cts = new CancellationTokenSource();
Task pump = scheduler.RunAsync(cts.Token);
var adapter = new RumbleLampAdapter(
new RumbleLampConfig { LargeMotorLamps = { 0x20 }, SmallMotorLamps = { 0x30 } },
scheduler);
// XInput-style spam: same vibration reported over and over.
for (int i = 0; i < 200; i++)
adapter.OnRumble(200, 0);
await FeedbackWait.For(() => sink.Snapshot().Length >= 2);
await Task.Delay(50);
cts.Cancel();
await pump;
Assert.Equal(2, sink.Snapshot().Length); // one state per motor, ever
}
[Fact]
public async Task OnRumble_ZeroAfterRumble_TurnsTheLampsOff()
{
var sink = new RecordingSink();
var scheduler = new CoalescingLampScheduler(sink, TimeSpan.FromMilliseconds(1));
using var cts = new CancellationTokenSource();
Task pump = scheduler.RunAsync(cts.Token);
var adapter = new RumbleLampAdapter(
new RumbleLampConfig { LargeMotorLamps = { 0x20 } }, scheduler);
adapter.OnRumble(255, 0);
await FeedbackWait.For(() => sink.Snapshot().Contains("Lamp(0x20,0x3F)"));
adapter.OnRumble(0, 0);
await FeedbackWait.For(() => sink.Snapshot().Contains("Lamp(0x20,0x00)"));
cts.Cancel();
await pump;
}
}
@@ -0,0 +1,79 @@
using RioJoy.Core.Plasma;
using RioJoy.Core.Tests.Serial;
using Xunit;
namespace RioJoy.Core.Tests.Plasma;
public class PlasmaDisplayTests
{
private static byte[][] PosTextChunks(string text, byte x = 0, byte y = 0, byte attr = 0, byte font = 0)
{
(byte rx, byte ry, byte rfont, int len) = PlasmaCommands.ResolvePosText(text, x, y, font);
return new[]
{
PlasmaCommands.CursorX(rx),
PlasmaCommands.CursorY(ry),
PlasmaCommands.FontAttr(attr),
PlasmaCommands.Font(rfont),
PlasmaCommands.Text(text[..len]),
};
}
[Fact]
public async Task PosTextAsync_EmitsThePosTextSequenceInOrder()
{
var transport = new FakeTransport();
var display = new PlasmaDisplay(transport);
await display.PosTextAsync("VIPER 1-1").WithTimeout();
foreach (byte[] expected in PosTextChunks("VIPER 1-1"))
Assert.Equal(expected, await transport.NextWriteAsync());
}
[Fact]
public async Task PosTextAsync_EmptyText_WritesNothing()
{
var transport = new FakeTransport();
var display = new PlasmaDisplay(transport);
await display.PosTextAsync("").WithTimeout();
Assert.False(transport.Writes.TryRead(out _));
}
[Fact]
public async Task ClearAsync_WritesTheClearCommand()
{
var transport = new FakeTransport();
var display = new PlasmaDisplay(transport);
await display.ClearAsync().WithTimeout();
Assert.Equal(PlasmaCommands.Clear(), await transport.NextWriteAsync());
}
[Fact]
public async Task PosTextAsync_ConcurrentCalls_DoNotInterleave()
{
// Without the write lock, B's cursor/font fragments land between A's five
// writes and corrupt the ESC stream. Gate A's first write so B has every
// chance to sneak in, then assert the ten writes arrive as A's five
// followed by B's five.
var transport = new GatedTransport();
var display = new PlasmaDisplay(transport);
Task a = display.PosTextAsync("AAAA");
Task b = display.PosTextAsync("BBBB");
transport.Open();
await Task.WhenAll(a, b).WithTimeout();
var writes = new List<byte[]>();
for (int i = 0; i < 10; i++)
writes.Add(await transport.NextWriteAsync());
byte[][] expected = PosTextChunks("AAAA").Concat(PosTextChunks("BBBB")).ToArray();
for (int i = 0; i < expected.Length; i++)
Assert.Equal(expected[i], writes[i]);
}
}
@@ -1,4 +1,5 @@
using RioJoy.Core.Calibration;
using RioJoy.Core.Feedback;
using RioJoy.Core.Output;
using RioJoy.Core.Profiles;
using Xunit;
@@ -86,6 +87,69 @@ public class ConfigStoreTests
Assert.Null(Assert.Single(ConfigStore.Deserialize(json).Profiles).AxisRouting);
}
[Fact]
public void RoundTrips_FeedbackSections()
{
var config = new AppConfig
{
Feedback = new FeedbackEndpointConfig { PipeName = "riojoy-fb-test", UdpPort = 19900 },
Profiles =
{
new RioProfile
{
Name = "DCS",
Feedback = new ProfileFeedbackConfig
{
AllowPlasmaText = false,
Rumble = new RumbleLampConfig
{
LargeMotorLamps = { 0x12, 0x13 },
SmallMotorLamps = { 0x60 },
Threshold = 32,
},
},
},
},
};
AppConfig back = ConfigStore.Deserialize(ConfigStore.Serialize(config));
Assert.NotNull(back.Feedback);
Assert.True(back.Feedback!.PipeEnabled);
Assert.Equal("riojoy-fb-test", back.Feedback.PipeName);
Assert.Equal(19900, back.Feedback.UdpPort);
// These records hold List<int>, so no record value equality — per-property.
ProfileFeedbackConfig fb = Assert.Single(back.Profiles).Feedback!;
Assert.True(fb.AllowLampCommands);
Assert.False(fb.AllowPlasmaText);
Assert.NotNull(fb.Rumble);
Assert.Equal(new[] { 0x12, 0x13 }, fb.Rumble!.LargeMotorLamps);
Assert.Equal(new[] { 0x60 }, fb.Rumble.SmallMotorLamps);
Assert.Equal(32, fb.Rumble.Threshold);
}
[Fact]
public void Feedback_Unset_StaysNull_AndOffJson()
{
// null = feedback off / endpoint defaults; NullValueHandling.Ignore keeps
// both sections out of the JSON, so pre-Phase-9 files stay byte-compatible.
string json = ConfigStore.Serialize(new AppConfig { Profiles = { new RioProfile { Name = "P" } } });
Assert.DoesNotContain("Feedback", json);
AppConfig back = ConfigStore.Deserialize(json);
Assert.Null(back.Feedback);
Assert.Null(Assert.Single(back.Profiles).Feedback);
}
[Fact]
public void ShippedDescentProfile_ParsesWithFeedbackOff()
{
string json = File.ReadAllText(Path.Combine(TestRepo.Root(), "profiles", "descent-d1x.json"));
RioProfile p = Assert.Single(ConfigStore.Deserialize($"{{\"Profiles\":[{json}]}}").Profiles);
Assert.Null(p.Feedback); // pre-Phase-9 profile documents deserialize with feedback off
}
[Fact]
public void ShippedDescentProfile_ParsesWithDescentRouting_NoTriggerTargets()
{
@@ -48,6 +48,30 @@ public class RioRuntimeTests
await run;
}
[Fact]
public async Task Lamps_SetLamp_SendsALampRequestOverTheLink()
{
var fake = new FakeTransport();
var link = new RioSerialLink(fake, new RioSerialLinkOptions { AutoPollAnalog = false });
var recorder = new RecordingSink();
using var runtime = new RioRuntime(link, new RioInputMap(), recorder, recorder);
runtime.Start();
using var cts = new CancellationTokenSource();
Task run = link.RunAsync(cts.Token);
// The accessor the feedback endpoint's lamp scheduler drives (Phase 9).
runtime.Lamps.SetLamp(0x12, RioLampState.SolidBright);
Assert.Equal(
PacketBuilder.Build(RioCommand.LampRequest, new byte[] { 0x12, RioLampState.SolidBright }),
await fake.NextWriteAsync());
cts.Cancel();
await run;
}
[Fact]
public async Task AnalogReply_DrivesAllSixAxes()
{
@@ -0,0 +1,50 @@
using System.Threading.Channels;
using RioJoy.Core.Serial;
namespace RioJoy.Core.Tests.Serial;
/// <summary>
/// <see cref="IRioTransport"/> whose first write blocks until <see cref="Open"/>
/// — lets a test park one writer mid-sequence while another tries to cut in
/// (write-lock and latest-wins assertions).
/// </summary>
internal sealed class GatedTransport : IRioTransport
{
private readonly Channel<byte[]> _writes = Channel.CreateUnbounded<byte[]>();
private readonly SemaphoreSlim _gate = new(0, 1);
private readonly object _armLock = new();
private bool _gateArmed = true;
public string Description => "gated";
/// <summary>Release the parked first write.</summary>
public void Open() => _gate.Release();
public Task<int> ReadAsync(byte[] buffer, CancellationToken cancellationToken) =>
Task.FromResult(0);
public async Task WriteAsync(byte[] data, CancellationToken cancellationToken)
{
bool wait;
lock (_armLock)
{
wait = _gateArmed;
_gateArmed = false;
}
if (wait)
await _gate.WaitAsync(cancellationToken);
_writes.Writer.TryWrite((byte[])data.Clone());
}
/// <summary>Read the next write, failing if none arrives in time.</summary>
public async Task<byte[]> NextWriteAsync(TimeSpan? timeout = null)
{
using var cts = new CancellationTokenSource(timeout ?? TimeSpan.FromSeconds(5));
return await _writes.Reader.ReadAsync(cts.Token);
}
/// <summary>True when no further write has arrived.</summary>
public bool NoMoreWrites => !_writes.Reader.TryPeek(out _);
public void Dispose() { }
}