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() {