NamedPipeTransport connects as a client to vRIO's \\.\pipe\vrio (the DOSBox-X fork's role) and speaks the shared typed-frame contract (PipeFraming: 0x00 data / 0x01 modem lines, null-modem crossed). The COM path's DTR reset pulse is replayed in-band on connect. Peer disconnects and framing violations surface as the 0-byte transport-closed read the link already understands. RioTransportFactory routes endpoint strings — pipe:name (vRIO's own picker syntax) to the pipe transport, everything else to SerialPortTransport — and is wired into RioCoordinator and all three RioSerialMonitor modes, so profiles (RioComPort/DefaultRioComPort) and the bench tools take pipe endpoints anywhere a COM name went. Gotcha baked into the design: named pipes here have 0-byte buffers, so a write blocks until the peer reads it, and vRIO also writes its lines frame before reading — the on-connect pulse frames are therefore queued as overlapped writes (pipe writes drain in issue order, preserving the edge positions) instead of blocking the constructor into a mutual write-first deadlock. Verified end-to-end against the real VRioDevice + VRioPipeService over \\.\pipe\vrio: version 4.2 + check replies, 137 analog polls, lamp commands ACKed, zero framing errors. 322 tests green, both flavors. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
380 lines
14 KiB
C#
380 lines
14 KiB
C#
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;
|
|
#if !NET40
|
|
using RioJoy.Overlay; // SkiaSharp wallpaper generation — modern flavor only
|
|
#endif
|
|
|
|
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>
|
|
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;
|
|
|
|
// The user's own desktop wallpaper, captured the first time we override it with
|
|
// a cockpit wallpaper. null = we are not currently overriding (nothing to
|
|
// restore). "" is a valid captured value (the user had no wallpaper).
|
|
private string? _savedWallpaper;
|
|
|
|
public RioCoordinator(Func<AppConfig> config, Func<string, IRioTransport>? transportFactory = null)
|
|
{
|
|
_config = config ?? throw new ArgumentNullException(nameof(config));
|
|
_transportFactory = transportFactory
|
|
?? (port => RioTransportFactory.Open(port, _config().RioBaudRate));
|
|
}
|
|
|
|
/// <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 !NET40 // ViGEmBus is Win10+; on XP the HID feeder drives RioGamepadXP.sys
|
|
if (ViGEmJoystickSink.TryCreate(out ViGEmJoystickSink? vigem))
|
|
{
|
|
_joystick = vigem;
|
|
note = string.Empty;
|
|
return vigem!;
|
|
}
|
|
#endif
|
|
|
|
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 !NET40
|
|
// Per-profile ViGEm axis routing, applied the same way the profile's
|
|
// Calibration reaches the AxisCalibrator below (null = default legacy
|
|
// routing). Also neutralizes the pad so nothing persists across a switch.
|
|
if (realJoystick is ViGEmJoystickSink vigemPad)
|
|
vigemPad.SetRouting(profile.AxisRouting);
|
|
#endif
|
|
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, new RioSerialLinkOptions
|
|
{
|
|
AnalogPollInterval = TimeSpan.FromMilliseconds(
|
|
Math.Max(10, config.AnalogPollMs)), // floor guards a typo'd config
|
|
});
|
|
_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)
|
|
{
|
|
#if NET40
|
|
// XP flavor: no SkiaSharp, so no generation — apply the profile's
|
|
// pre-rendered wallpaper (authored on a modern machine) if it exists.
|
|
if (string.IsNullOrWhiteSpace(profile.WallpaperPath) || !File.Exists(profile.WallpaperPath))
|
|
return;
|
|
try
|
|
{
|
|
CaptureWallpaperOnce();
|
|
WallpaperApplier.Apply(profile.WallpaperPath!);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
SetStatus($"{Status} [wallpaper: {ex.Message}]");
|
|
}
|
|
#else
|
|
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;
|
|
CaptureWallpaperOnce();
|
|
WallpaperApplier.Apply(outPath);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
SetStatus($"{Status} [wallpaper: {ex.Message}]");
|
|
}
|
|
#endif
|
|
}
|
|
|
|
/// <summary>
|
|
/// Remember the user's current desktop wallpaper the first time we override it,
|
|
/// so it can be restored when we go dormant. No-op once captured (so switching
|
|
/// between cockpit profiles never records a cockpit wallpaper as the "previous").
|
|
/// </summary>
|
|
private void CaptureWallpaperOnce()
|
|
{
|
|
if (_savedWallpaper != null)
|
|
return;
|
|
try { _savedWallpaper = WallpaperApplier.GetCurrent(); }
|
|
catch { _savedWallpaper = string.Empty; } // best-effort; empty just clears on restore
|
|
}
|
|
|
|
/// <summary>
|
|
/// Put the user's pre-activation wallpaper back, if we overrode it. Called when
|
|
/// going dormant and on shutdown; best-effort and idempotent.
|
|
/// </summary>
|
|
private void RestoreWallpaper()
|
|
{
|
|
if (_savedWallpaper == null)
|
|
return; // never overrode — leave the desktop alone
|
|
try { WallpaperApplier.Restore(_savedWallpaper); }
|
|
catch { /* best-effort — never block going dormant */ }
|
|
_savedWallpaper = null;
|
|
}
|
|
|
|
private static string SafeFileName(string name)
|
|
{
|
|
foreach (char c in Path.GetInvalidFileNameChars())
|
|
name = name.Replace(c, '_');
|
|
return name;
|
|
}
|
|
|
|
private void GoDormant(string status)
|
|
{
|
|
Teardown();
|
|
RestoreWallpaper(); // put the user's own desktop back
|
|
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();
|
|
RestoreWallpaper(); // clean exit shouldn't leave a cockpit wallpaper behind
|
|
}
|
|
}
|