Wire the Core pieces into a runnable tray app with per-game profiles and the three-state serial-yield auto-switch: - Profiles/: RioProfile + AppConfig model; ConfigStore (System.Text.Json, round-tripped); RioIniImporter ports the legacy RIO.ini (button table, invert flags, plasma greeting); AutoSwitchResolver + AutoSwitchWatcher resolve the foreground executable into Yield (native game) / Activate (profile) / Idle, with native always winning and change-only notifications. IForegroundProcessProvider abstracts the OS. - RioRuntime assembles a profile's live pipeline: serial ButtonPressed/Released + KeyPressed/Released → InputRouter (via RioAddress); AnalogReply → AxisCalibrator → the six joystick axes; RIO commands → calibration resets + version/check requests + lamp re-init. SerialLampSink sends lamp feedback over the link; NullJoystickSink is a placeholder until the Phase 1 HID feeder exists. - RioJoy.Tray: NotifyIcon menu mirroring the legacy console menu (axis resets, version/status, raw-axes & poll-rate toggles, quit) + profile selection (auto vs. manual) + "start with Windows"; RioCoordinator owns the serial acquire/release tied to the watcher (native-game COM-port yield). OS adapters: ForegroundProcessProvider (Win32 foreground PID→exe) and AutoStartManager (HKCU Run key). - tests: 18 new xUnit tests (123 total) for config round-trip, ini import, the three-state resolver + watcher, and RioRuntime end-to-end over the fake transport (button→joystick, keypad-offset→keyboard, analog→six axes). The joystick output stays a no-op until the Phase 1 driver; on-cabinet verification of the acquire/release lifecycle remains. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
139 lines
4.9 KiB
C#
139 lines
4.9 KiB
C#
using RioJoy.Core.Calibration;
|
|
using RioJoy.Core.Mapping;
|
|
using RioJoy.Core.Protocol;
|
|
using RioJoy.Core.Serial;
|
|
|
|
namespace RioJoy.Core;
|
|
|
|
/// <summary>
|
|
/// Ties the live RIO link to the input/output pipeline for one active profile:
|
|
/// serial button/keypad packets drive the <see cref="InputRouter"/>; analog
|
|
/// replies drive the <see cref="AxisCalibrator"/> and the six joystick axes; RIO
|
|
/// commands trigger calibration resets and serial requests. This is the runtime
|
|
/// assembled per-profile by the tray app (Phase 5).
|
|
/// </summary>
|
|
public sealed class RioRuntime : IRioCommandSink, IDisposable
|
|
{
|
|
private readonly RioSerialLink _link;
|
|
private readonly IJoystickSink _joystick;
|
|
private readonly AxisCalibrator _calibrator;
|
|
private readonly InputRouter _router;
|
|
private bool _started;
|
|
|
|
public RioRuntime(
|
|
RioSerialLink link,
|
|
RioInputMap map,
|
|
IInputSink input,
|
|
IJoystickSink joystick,
|
|
AxisCalibrator? calibrator = null)
|
|
{
|
|
_link = link ?? throw new ArgumentNullException(nameof(link));
|
|
ArgumentNullException.ThrowIfNull(map);
|
|
ArgumentNullException.ThrowIfNull(input);
|
|
_joystick = joystick ?? throw new ArgumentNullException(nameof(joystick));
|
|
_calibrator = calibrator ?? new AxisCalibrator();
|
|
|
|
var lamp = new SerialLampSink(link);
|
|
_router = new InputRouter(map, input, joystick, lamp, this);
|
|
}
|
|
|
|
/// <summary>Raised when a diagnostic toggle RIO command fires (raw-axes / poll-rate).</summary>
|
|
public event Action<RioCommandCode>? DiagnosticToggle;
|
|
|
|
/// <summary>Subscribe to the link and set all lamps to their idle state.</summary>
|
|
public void Start()
|
|
{
|
|
if (_started)
|
|
return;
|
|
_started = true;
|
|
|
|
_link.PacketReceived += OnPacket;
|
|
_link.AnalogReceived += OnAnalog;
|
|
_router.InitializeLamps();
|
|
}
|
|
|
|
/// <summary>Unsubscribe from the link.</summary>
|
|
public void Stop()
|
|
{
|
|
if (!_started)
|
|
return;
|
|
_started = false;
|
|
|
|
_link.PacketReceived -= OnPacket;
|
|
_link.AnalogReceived -= OnAnalog;
|
|
}
|
|
|
|
public void Dispose() => Stop();
|
|
|
|
/// <summary>Invoke a RIO command directly (e.g. from the tray menu).</summary>
|
|
public void Trigger(RioCommandCode command) => ((IRioCommandSink)this).Execute(command);
|
|
|
|
private void OnAnalog(AnalogReport report)
|
|
{
|
|
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);
|
|
}
|
|
|
|
private void OnPacket(RioPacket packet)
|
|
{
|
|
ReadOnlySpan<byte> p = packet.Payload.Span;
|
|
switch (packet.Command)
|
|
{
|
|
case RioCommand.ButtonPressed when p[0] < RioAddress.ButtonCount:
|
|
_router.Press(RioAddress.FromButton(p[0]));
|
|
break;
|
|
case RioCommand.ButtonReleased when p[0] < RioAddress.ButtonCount:
|
|
_router.Release(RioAddress.FromButton(p[0]));
|
|
break;
|
|
case RioCommand.KeyPressed when IsKeypad(p):
|
|
_router.Press(RioAddress.FromKeypad(p[0], p[1]));
|
|
break;
|
|
case RioCommand.KeyReleased when IsKeypad(p):
|
|
_router.Release(RioAddress.FromKeypad(p[0], p[1]));
|
|
break;
|
|
}
|
|
}
|
|
|
|
private static bool IsKeypad(ReadOnlySpan<byte> p) => p[0] is 0 or 1 && p[1] <= 0x0F;
|
|
|
|
// IRioCommandSink: RIO commands routed from a button (RIOcmd port).
|
|
void IRioCommandSink.Execute(RioCommandCode command)
|
|
{
|
|
switch (command)
|
|
{
|
|
case RioCommandCode.ResetAllAxes:
|
|
case RioCommandCode.ResetThrottle:
|
|
case RioCommandCode.ResetLeftPedal:
|
|
case RioCommandCode.ResetRightPedal:
|
|
case RioCommandCode.ResetVerticalJoystick:
|
|
case RioCommandCode.ResetHorizontalJoystick:
|
|
_calibrator.Reset(command);
|
|
FireAndForget(_link.ResetAsync((RioResetTarget)(byte)command));
|
|
break;
|
|
|
|
case RioCommandCode.GeneralReset:
|
|
FireAndForget(_link.ResetAsync(RioResetTarget.All));
|
|
_router.InitializeLamps();
|
|
break;
|
|
|
|
case RioCommandCode.RequestVersionAndCheck:
|
|
FireAndForget(_link.RequestVersionAsync());
|
|
FireAndForget(_link.RequestCheckAsync());
|
|
break;
|
|
|
|
case RioCommandCode.ToggleRawAxesReadout:
|
|
case RioCommandCode.TogglePollRateReadout:
|
|
DiagnosticToggle?.Invoke(command);
|
|
break;
|
|
}
|
|
}
|
|
|
|
private static void FireAndForget(Task task) =>
|
|
task.ContinueWith(static t => _ = t.Exception, TaskContinuationOptions.OnlyOnFaulted);
|
|
}
|