diff --git a/src/RioJoy.Core/Output/SendInputSink.cs b/src/RioJoy.Core/Output/SendInputSink.cs
index a5c116d..33f1b26 100644
--- a/src/RioJoy.Core/Output/SendInputSink.cs
+++ b/src/RioJoy.Core/Output/SendInputSink.cs
@@ -70,12 +70,8 @@ public sealed class SendInputSink : IInputSink
Send(input);
}
- private static void Send(INPUT input)
- {
- Span inputs = stackalloc INPUT[1];
- inputs[0] = input;
- SendInput(1, inputs, Marshal.SizeOf());
- }
+ private static void Send(INPUT input) =>
+ SendInput(1, new[] { input }, Marshal.SizeOf());
// --- Win32 interop --------------------------------------------------------
@@ -130,7 +126,7 @@ public sealed class SendInputSink : IInputSink
}
[DllImport("user32.dll", SetLastError = true)]
- private static extern uint SendInput(uint nInputs, Span pInputs, int cbSize);
+ private static extern uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize);
[DllImport("user32.dll")]
private static extern uint MapVirtualKey(uint uCode, uint uMapType);
diff --git a/src/RioJoy.Core/RioRuntime.cs b/src/RioJoy.Core/RioRuntime.cs
index 1759a8f..867a3ee 100644
--- a/src/RioJoy.Core/RioRuntime.cs
+++ b/src/RioJoy.Core/RioRuntime.cs
@@ -20,6 +20,7 @@ public sealed class RioRuntime : IRioCommandSink, IDisposable
private readonly InputRouter _router;
private readonly ILampSink _lamp;
private bool _started;
+ private bool _lampsInitialized;
public RioRuntime(
RioSerialLink link,
@@ -61,18 +62,23 @@ public sealed class RioRuntime : IRioCommandSink, IDisposable
/// Raised for each board/lamp status item in a .
public event Action? CheckReceived;
- /// Subscribe to the link and set all lamps to their idle state.
+ ///
+ /// Subscribe to the link. Lit lamps are dimmed on the first reply from the board
+ /// () rather than here: lamp commands sent in
+ /// the first milliseconds after the DTR reset (on port open) are dropped before
+ /// the board finishes booting, so the idle-dim state never takes.
+ ///
public void Start()
{
if (_started)
return;
_started = true;
+ _lampsInitialized = false;
_link.PacketReceived += OnPacket;
_link.AnalogReceived += OnAnalog;
_link.VersionReceived += OnVersion;
_link.CheckReceived += OnCheck;
- _router.InitializeLamps();
}
/// Unsubscribe from the link.
@@ -97,19 +103,37 @@ public sealed class RioRuntime : IRioCommandSink, IDisposable
/// Invoke a RIO command directly (e.g. from the tray menu).
public void Trigger(RioCommandCode command) => ((IRioCommandSink)this).Execute(command);
+ // Once the board replies (alive after the reset), set every lit button to dim.
+ private void EnsureLampsInitialized()
+ {
+ if (_lampsInitialized)
+ return;
+ _lampsInitialized = true;
+ _router.InitializeLamps();
+ }
+
private void OnAnalog(AnalogReport report)
{
+ EnsureLampsInitialized();
AxisOutputs axes = _calibrator.Update(report);
- _joystick.SetAxis(JoyAxis.X, axes.X);
- _joystick.SetAxis(JoyAxis.Y, axes.Y);
- _joystick.SetAxis(JoyAxis.Z, axes.Z);
- _joystick.SetAxis(JoyAxis.Rx, axes.Rx);
- _joystick.SetAxis(JoyAxis.Ry, axes.Ry);
- _joystick.SetAxis(JoyAxis.Rz, axes.Rz);
+ try
+ {
+ _joystick.SetAxis(JoyAxis.X, axes.X);
+ _joystick.SetAxis(JoyAxis.Y, axes.Y);
+ _joystick.SetAxis(JoyAxis.Z, axes.Z);
+ _joystick.SetAxis(JoyAxis.Rx, axes.Rx);
+ _joystick.SetAxis(JoyAxis.Ry, axes.Ry);
+ _joystick.SetAxis(JoyAxis.Rz, axes.Rz);
+ }
+ catch
+ {
+ // Output is best-effort: a faulty joystick sink must not kill the link.
+ }
}
private void OnPacket(RioPacket packet)
{
+ EnsureLampsInitialized();
ReadOnlySpan p = packet.Payload.Span;
switch (packet.Command)
{
@@ -131,10 +155,17 @@ public sealed class RioRuntime : IRioCommandSink, IDisposable
// Route the event through the input pipeline and notify any live-activity listener.
private void Activity(int address, bool pressed)
{
- if (pressed)
- _router.Press(address);
- else
- _router.Release(address);
+ try
+ {
+ if (pressed)
+ _router.Press(address);
+ else
+ _router.Release(address);
+ }
+ catch
+ {
+ // Output is best-effort: a faulty sink must never kill the serial link.
+ }
// Light the button on the RIO even when it is unmapped (the router only lamps
// mapped lamp-buttons), so the editor's check-function workflow always responds.
diff --git a/tests/RioJoy.Core.Tests/Output/SendInputSinkTests.cs b/tests/RioJoy.Core.Tests/Output/SendInputSinkTests.cs
new file mode 100644
index 0000000..5cc04ad
--- /dev/null
+++ b/tests/RioJoy.Core.Tests/Output/SendInputSinkTests.cs
@@ -0,0 +1,21 @@
+using RioJoy.Core.Output;
+using Xunit;
+
+namespace RioJoy.Core.Tests.Output;
+
+public class SendInputSinkTests
+{
+ [Fact]
+ public void SendInput_DoesNotThrow_OnMarshalling()
+ {
+ var sink = new SendInputSink();
+
+ // A zero relative mouse move is a no-op, but it exercises the same SendInput
+ // INPUT[] marshalling path that previously threw MarshalDirectiveException with
+ // a Span parameter — which killed the serial read thread when a button
+ // with output enabled was pressed.
+ Exception? ex = Record.Exception(() => sink.MouseMove(0, 0));
+
+ Assert.Null(ex);
+ }
+}
diff --git a/tests/RioJoy.Core.Tests/RioRuntimeTests.cs b/tests/RioJoy.Core.Tests/RioRuntimeTests.cs
index 4380b95..4bdafba 100644
--- a/tests/RioJoy.Core.Tests/RioRuntimeTests.cs
+++ b/tests/RioJoy.Core.Tests/RioRuntimeTests.cs
@@ -72,6 +72,40 @@ public class RioRuntimeTests
await run;
}
+ [Fact]
+ public async Task LitLamps_Dimmed_OnFirstBoardReply_NotOnStart()
+ {
+ var fake = new FakeTransport();
+ var link = new RioSerialLink(fake, new RioSerialLinkOptions { AutoPollAnalog = false });
+ var recorder = new RecordingSink();
+
+ var map = new RioInputMap { [0x05] = new RioMapEntry(0x9001) }; // lit joystick button
+ using var runtime = new RioRuntime(link, map, recorder, recorder);
+ runtime.Start();
+
+ using var cts = new CancellationTokenSource();
+ Task run = link.RunAsync(cts.Token);
+
+ // Nothing is sent on Start: lamp commands would be dropped during the board's
+ // post-reset boot, so no write happens until the board first replies.
+ await Assert.ThrowsAnyAsync(
+ () => fake.NextWriteAsync(TimeSpan.FromMilliseconds(150)));
+
+ // A reply proves the board is alive → the lit lamp is dimmed.
+ fake.Enqueue(PacketBuilder.Build(RioCommand.AnalogReply, new byte[10]));
+
+ bool dimmed = false;
+ for (int i = 0; i < 8 && !dimmed; i++)
+ {
+ byte[] w = await fake.NextWriteAsync(TimeSpan.FromSeconds(2));
+ dimmed = w.Length >= 3 && w[0] == (byte)RioCommand.LampRequest && w[1] == 0x05;
+ }
+ Assert.True(dimmed, "expected a LampRequest dimming address 0x05 after the first reply");
+
+ cts.Cancel();
+ await run;
+ }
+
[Fact]
public async Task ButtonActivity_FiresForPressAndRelease_EvenWhenUnmapped()
{