Replace the test-signed RioGamepad driver as the default joystick output with a ViGEmBus virtual Xbox 360 controller (Nefarius.ViGEm.Client) — properly signed, so it installs with no test-signing / Secure Boot change / reboot. The coordinator now selects ViGEm first, then the RioGamepad HID feeder, then a no-op. The XInput layout caps joystick buttons at 11, so the editor's joystick picker lists the named Xbox buttons (A, B, X, Y, LB, RB, Back, Start, L3, R3, Guide); the hat maps to the D-pad and the six axes to the sticks + triggers. Bind anything beyond 11 to the keyboard. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
313 lines
11 KiB
C#
313 lines
11 KiB
C#
using System.Runtime.Versioning;
|
|
using RioJoy.Core;
|
|
using RioJoy.Core.Calibration;
|
|
using RioJoy.Core.Mapping;
|
|
using RioJoy.Core.Output;
|
|
using RioJoy.Core.Overlay;
|
|
using RioJoy.Core.Profiles;
|
|
using RioJoy.Core.Serial;
|
|
using RioJoy.Overlay;
|
|
|
|
namespace RioJoy.Tray;
|
|
|
|
/// <summary>
|
|
/// Owns the active RIO connection and drives the three-state auto-switch: it
|
|
/// opens/closes the serial link and assembles a <see cref="RioRuntime"/> for the
|
|
/// active profile, releasing the COM port when a native game runs or nothing
|
|
/// matches (see docs/PLAN.md §Profiles). Manual override from the tray takes
|
|
/// precedence over the watcher.
|
|
///
|
|
/// The coordinator starts <b>dormant</b> and only takes the serial port when a
|
|
/// profiled game is in the foreground (via the watcher) or while a profile is being
|
|
/// edited (<see cref="BeginEditorSession"/>). This keeps the COM port free the rest
|
|
/// of the time so the native games can open it without clashing.
|
|
/// </summary>
|
|
[SupportedOSPlatform("windows")]
|
|
public sealed class RioCoordinator : IDisposable
|
|
{
|
|
private readonly Func<AppConfig> _config;
|
|
private readonly Func<string, IRioTransport> _transportFactory;
|
|
|
|
private bool _auto = true;
|
|
private bool _editorSession;
|
|
|
|
// While editing, real keyboard/mouse + joystick run through these gates (off until
|
|
// the editor's "send output" toggle opens them).
|
|
private GatedInputSink? _editorInput;
|
|
private GatedJoystickSink? _editorJoystick;
|
|
|
|
// The active connection (null when dormant).
|
|
private IRioTransport? _transport;
|
|
private RioSerialLink? _link;
|
|
private RioRuntime? _runtime;
|
|
private CancellationTokenSource? _cts;
|
|
private IDisposable? _joystick;
|
|
private string? _activeProfileName;
|
|
|
|
public RioCoordinator(Func<AppConfig> config, Func<string, IRioTransport>? transportFactory = null)
|
|
{
|
|
_config = config ?? throw new ArgumentNullException(nameof(config));
|
|
_transportFactory = transportFactory ?? (port => new SerialPortTransport(port));
|
|
}
|
|
|
|
/// <summary>Raised (with a short status string) whenever the active state changes.</summary>
|
|
public event Action<string>? StatusChanged;
|
|
|
|
/// <summary>Current status line for the tray.</summary>
|
|
public string Status { get; private set; } = "Dormant";
|
|
|
|
/// <summary>Whether a profile is currently being edited (port held for the editor).</summary>
|
|
public bool IsEditing => _editorSession;
|
|
|
|
/// <summary>The name of the active profile, or null when dormant.</summary>
|
|
public string? ActiveProfileName => _activeProfileName;
|
|
|
|
/// <summary>Whether the auto-switch watcher controls the active profile.</summary>
|
|
public bool IsAuto => _auto;
|
|
|
|
/// <summary>The currently running runtime, for manual RIO-command triggers.</summary>
|
|
public RioRuntime? Runtime => _runtime;
|
|
|
|
/// <summary>Apply a watcher decision (ignored while a manual override is in effect).</summary>
|
|
public void ApplyDecision(SwitchDecision decision)
|
|
{
|
|
if (!_auto)
|
|
return;
|
|
|
|
switch (decision.Mode)
|
|
{
|
|
case SwitchMode.Activate when decision.Profile is not null:
|
|
Activate(decision.Profile);
|
|
break;
|
|
case SwitchMode.Yield:
|
|
GoDormant("Dormant — native game has the port");
|
|
break;
|
|
default:
|
|
GoDormant("Idle");
|
|
break;
|
|
}
|
|
}
|
|
|
|
/// <summary>Hand control back to the auto-switch watcher.</summary>
|
|
public void SetAuto()
|
|
{
|
|
_auto = true;
|
|
SetStatus("Auto");
|
|
}
|
|
|
|
/// <summary>Manually activate a profile; disables auto until <see cref="SetAuto"/>.</summary>
|
|
public void SetManualProfile(RioProfile profile)
|
|
{
|
|
_auto = false;
|
|
Activate(profile);
|
|
}
|
|
|
|
/// <summary>Manually go dormant (release the port); disables auto.</summary>
|
|
public void SetManualDormant()
|
|
{
|
|
_auto = false;
|
|
GoDormant("Dormant (manual)");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Acquire the RIO for <paramref name="profile"/> while its editor is open. The
|
|
/// editor takes precedence over the watcher (so the port stays put while editing);
|
|
/// call <see cref="EndEditorSession"/> when the editor closes.
|
|
/// </summary>
|
|
public void BeginEditorSession(RioProfile profile)
|
|
{
|
|
_editorSession = true;
|
|
_auto = false; // editor override beats the auto-switch watcher
|
|
Activate(profile, routeInput: false); // hold the port but don't inject keystrokes/joystick
|
|
}
|
|
|
|
/// <summary>
|
|
/// Open or close the editor's output gates: when enabled, RIO button presses
|
|
/// drive real keyboard/mouse and the virtual joystick; when disabled they don't.
|
|
/// No-op outside an editor session.
|
|
/// </summary>
|
|
public void SetEditorOutputs(bool enabled)
|
|
{
|
|
if (_editorInput is null || _editorJoystick is null)
|
|
return;
|
|
|
|
_editorInput.Enabled = enabled;
|
|
_editorJoystick.Enabled = enabled;
|
|
|
|
if (_activeProfileName is not null)
|
|
SetStatus($"Editing: {_activeProfileName} — outputs {(enabled ? "ON (sending to PC)" : "off")}");
|
|
}
|
|
|
|
/// <summary>Release the editor's port hold and hand control back to the watcher.</summary>
|
|
public void EndEditorSession()
|
|
{
|
|
_editorSession = false;
|
|
_auto = true;
|
|
GoDormant("Dormant"); // the next watcher poll re-activates if a profiled game is foreground
|
|
}
|
|
|
|
// Pick the best available virtual-joystick sink and record it for disposal: the
|
|
// signed ViGEmBus Xbox 360 pad first, then the (test-signed) RioGamepad HID
|
|
// feeder, then a no-op when neither driver is present.
|
|
private IJoystickSink CreateJoystickSink(out string note)
|
|
{
|
|
if (ViGEmJoystickSink.TryCreate(out ViGEmJoystickSink? vigem))
|
|
{
|
|
_joystick = vigem;
|
|
note = string.Empty;
|
|
return vigem!;
|
|
}
|
|
|
|
if (HidFeederJoystickSink.TryCreate(out HidFeederJoystickSink? feeder))
|
|
{
|
|
_joystick = feeder;
|
|
note = string.Empty;
|
|
return feeder!;
|
|
}
|
|
|
|
_joystick = null;
|
|
note = " [no joystick driver]";
|
|
return new NullJoystickSink();
|
|
}
|
|
|
|
// routeInput=false holds the RIO link (and lamp feedback) but sends keyboard/
|
|
// mouse/joystick output to no-op sinks — used for the editor's live button check.
|
|
private void Activate(RioProfile profile, bool routeInput = true)
|
|
{
|
|
if (_activeProfileName == profile.Name && _runtime is not null && !_editorSession)
|
|
return; // already running this profile (editor sessions always re-arm)
|
|
|
|
Teardown();
|
|
|
|
AppConfig config = _config();
|
|
string port = profile.RioComPort ?? config.DefaultRioComPort;
|
|
|
|
try
|
|
{
|
|
// Keyboard/mouse + joystick are gated off while editing so the RIO can be
|
|
// exercised without injecting input (the editor toggle opens the gates);
|
|
// lamp feedback over serial still applies.
|
|
IInputSink input;
|
|
IJoystickSink joystick;
|
|
string note;
|
|
IJoystickSink realJoystick = CreateJoystickSink(out string joystickNote);
|
|
if (!routeInput)
|
|
{
|
|
// Keyboard/mouse + joystick are gated off while editing so the RIO can
|
|
// be exercised without injecting input (the editor toggle opens them).
|
|
_editorInput = new GatedInputSink(new SendInputSink());
|
|
_editorJoystick = new GatedJoystickSink(realJoystick);
|
|
input = _editorInput;
|
|
joystick = _editorJoystick;
|
|
note = " [editing — outputs off]";
|
|
}
|
|
else
|
|
{
|
|
input = new SendInputSink();
|
|
joystick = realJoystick;
|
|
note = joystickNote;
|
|
}
|
|
|
|
_transport = _transportFactory(port);
|
|
_link = new RioSerialLink(_transport);
|
|
_runtime = new RioRuntime(
|
|
_link,
|
|
profile.ToInputMap(),
|
|
input,
|
|
joystick,
|
|
new AxisCalibrator(profile.Calibration));
|
|
|
|
_runtime.EchoAllLamps = !routeInput; // editor: light any pressed button on the RIO
|
|
|
|
_cts = new CancellationTokenSource();
|
|
_ = _link.RunAsync(_cts.Token);
|
|
_runtime.Start();
|
|
_activeProfileName = profile.Name;
|
|
SetStatus($"{(routeInput ? "Active" : "Editing")}: {profile.Name} ({_link.Description}){note}");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Teardown();
|
|
SetStatus($"Error opening {port}: {ex.Message}");
|
|
return;
|
|
}
|
|
|
|
if (routeInput)
|
|
ApplyWallpaper(profile);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Generate the profile's cockpit wallpaper from the configured overlay template
|
|
/// and apply it. Best-effort and opt-in (only when <see cref="AppConfig.OverlayTemplatePath"/>
|
|
/// is set), so a missing template or render failure never breaks activation.
|
|
/// </summary>
|
|
private void ApplyWallpaper(RioProfile profile)
|
|
{
|
|
string? templatePath = _config().OverlayTemplatePath;
|
|
if (string.IsNullOrWhiteSpace(templatePath) || !File.Exists(templatePath))
|
|
return;
|
|
|
|
try
|
|
{
|
|
OverlayTemplate template = OverlayTemplateStore.Load(templatePath);
|
|
string templateDir = Path.GetDirectoryName(Path.GetFullPath(templatePath)) ?? ".";
|
|
|
|
string outDir = Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
|
"RIOJoy", "wallpapers");
|
|
string outPath = Path.Combine(outDir, SafeFileName(profile.Name) + ".png");
|
|
|
|
new ProfileWallpaperGenerator().Generate(template, templateDir, profile.OverlayLabels, outPath);
|
|
profile.WallpaperPath = outPath;
|
|
WallpaperApplier.Apply(outPath);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
SetStatus($"{Status} [wallpaper: {ex.Message}]");
|
|
}
|
|
}
|
|
|
|
private static string SafeFileName(string name)
|
|
{
|
|
foreach (char c in Path.GetInvalidFileNameChars())
|
|
name = name.Replace(c, '_');
|
|
return name;
|
|
}
|
|
|
|
private void GoDormant(string status)
|
|
{
|
|
Teardown();
|
|
SetStatus(status);
|
|
}
|
|
|
|
private void Teardown()
|
|
{
|
|
_runtime?.Dispose();
|
|
_runtime = null;
|
|
|
|
_cts?.Cancel();
|
|
_cts?.Dispose();
|
|
_cts = null;
|
|
|
|
_link = null;
|
|
|
|
_joystick?.Dispose(); // closes the HID feeder handle
|
|
_joystick = null;
|
|
_editorInput = null;
|
|
_editorJoystick = null;
|
|
|
|
_transport?.Dispose(); // releases the COM port
|
|
_transport = null;
|
|
|
|
_activeProfileName = null;
|
|
}
|
|
|
|
private void SetStatus(string status)
|
|
{
|
|
Status = status;
|
|
StatusChanged?.Invoke(status);
|
|
}
|
|
|
|
public void Dispose() => Teardown();
|
|
}
|