Files
riojoy/src/RioJoy.Tray/RioCoordinator.cs
T
CydandClaude Opus 4.8 2f434517b7 Add signed ViGEmBus virtual-joystick support (Xbox 360)
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>
2026-06-29 09:28:48 -05:00

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();
}