diff --git a/deploy/build-pod.ps1 b/deploy/build-pod.ps1 index 3b8e789..6993579 100644 --- a/deploy/build-pod.ps1 +++ b/deploy/build-pod.ps1 @@ -164,12 +164,17 @@ try { Set-Content -Path (Join-Path $staging 'install-riojoy.bat') -Value $installBat -Encoding Ascii # 5. Start script: the game's launch script calls this before the game. + # Everything explicit - no foreground detection: --profile activates + # immediately so the virtual pad exists before the game enumerates + # controllers, and --exit-with ends RIOJoy when the game exits. $startBat = @( '@echo off' "rem RIOJoy pod companion for $($profileDoc.Name)." - 'rem Call from the game''s launch script BEFORE starting the game;' + 'rem Call from the game''s launch script BEFORE starting the game:' + 'rem the profile activates immediately (no foreground detection), so' + 'rem the virtual controller exists before the game enumerates devices.' 'rem RIOJoy exits by itself when the game exits (--exit-with).' - "start `"`" `"%~dp0riojoy\$appDirName\RioJoy.Tray.exe`" --exit-with $GameExe" + "start `"`" `"%~dp0riojoy\$appDirName\RioJoy.Tray.exe`" --profile `"$($profileDoc.Name)`" --exit-with $GameExe" ) -join "`r`n" Set-Content -Path (Join-Path $staging 'start-riojoy.bat') -Value $startBat -Encoding Ascii @@ -187,8 +192,13 @@ try { ' Idempotent - safe on every install, reinstall and update; it only' ' adds what is missing and never removes anything.' ' 3. Call start-riojoy.bat from the game''s launch script before the' - ' game. RIOJoy activates when the game window comes foreground and' - ' exits by itself when the game exits.' + ' game. The profile activates immediately and explicitly - no' + ' foreground detection - so the virtual controller exists before' + ' the game enumerates devices; RIOJoy exits by itself when the' + ' game exits. A launcher should wait (with a timeout) on the named' + ' event "RIOJoy.Tray.Ready" before starting the game: signaled =' + ' pad + ports ready; RIOJoy exit code 4/5 = config/activation' + ' failure (reason on stderr); neither = stuck, report.' ' 4. Put NOTHING in the game''s pre-uninstall: drivers stay in place by' ' design (another podized game may use them; idle drivers are' ' harmless).' diff --git a/docs/INPUT-INTEGRATION.md b/docs/INPUT-INTEGRATION.md index 8001ce8..9696ca7 100644 --- a/docs/INPUT-INTEGRATION.md +++ b/docs/INPUT-INTEGRATION.md @@ -218,16 +218,36 @@ entry points the game's package wires up: game: ``` -start "" "...\riojoy\app\RioJoy.Tray.exe" --exit-with +start "" "...\riojoy\app\RioJoy.Tray.exe" --profile "" --exit-with ``` -`--exit-with` makes RIOJoy self-managing: it activates when the game's window -comes foreground, and once the game process has run and then exited it tears -itself down completely (ports released, wallpaper restored, plasma blanked) -and quits. If the game never appears within 60 s it also quits, so a failed -launch can't strand it. Back-to-back launches hand over cleanly: a starting -`--exit-with` instance waits up to 15 s for the previous game's copy to -release the single-instance lock. +In pod mode **everything is explicit — there is no detection on either +side**. `--profile` activates the named profile immediately at startup, so +the virtual controller and the ports exist *before* the game launches and +enumerates input devices (foreground detection activates ~1 s after the +window appears — too late for startup enumeration, and the auto-switch +watcher never runs in this mode). On success RIOJoy signals the named event +**`RIOJoy.Tray.Ready`** — a launcher waits on that (with a timeout) instead +of guessing from device enumeration, and the failure modes stay legible: + +| Launcher observes | Meaning | +|---|---| +| `RIOJoy.Tray.Ready` signaled | profile active; pad + ports exist — start the game | +| RIOJoy exited, code 4 | profile name not in the config (script typo) | +| RIOJoy exited, code 5 | activation failed — reason on stderr (port busy, bad endpoint) | +| no signal, still running | genuinely stuck — timeout and report | + +The event is process-lifetime (it can never go stale); either side may create +it first — same name, manual-reset, both converge on one object. Batch-only +integrations without a launcher can simply order the script: `start` +RIOJoy, then the game — but a real launcher should wait on the event. + +`--exit-with` handles the other end: once the game process has run and then +exited, RIOJoy tears itself down completely (ports released, wallpaper +restored, plasma blanked) and quits. If the game never appears within 60 s it +also quits, so a failed launch can't strand it. Back-to-back launches hand +over cleanly: a starting pod instance waits up to 15 s for the previous +game's copy to release the single-instance lock. Properties that matter on a cabinet: each game pins the RIOJoy build it was verified with (updating RIOJoy for a new game can't regress an old one); diff --git a/docs/PLAN.md b/docs/PLAN.md index 2595e37..d7824d3 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -503,6 +503,18 @@ total across the suite. exe wins over `%APPDATA%\RIOJoy\config.json`; `TrayApplicationContext. ConfigPath` resolves through it, so `--import-profile` targets the same store. A pod bundle needs no import step: its config *is* the profile. +- **`--profile `** — explicit immediate activation, **no detection**: + in pod mode the auto-switch watcher never runs. The rationale is + enumeration timing: games enumerate controllers at startup, and foreground + detection activates ~1 s after the window appears — too late, so the pod + launch script activates the profile *before* starting the game and the + ViGEm pad already exists when the game looks. On success RIOJoy signals the + named event `RIOJoy.Tray.Ready` (process-lifetime — never stale) so a pod + launcher waits on the signal instead of counting input devices. Exit code 4 + for an unknown profile name (validated up front so a pod-script typo is + scriptable, not a silently idle tray) and 5 for failed activation (reason + on stderr) — the launcher can always tell "failed with reason" from + "hung". Closing the editor re-activates the explicit profile. - **`--exit-with `** (`CompanionTarget.Parse` — pid, or a name normalized like auto-switch triggers): the tray polls the companion on its existing 1 s timer and quits through the normal teardown (ports released, diff --git a/src/RioJoy.Tray/Program.cs b/src/RioJoy.Tray/Program.cs index 7a4573e..bfe37fc 100644 --- a/src/RioJoy.Tray/Program.cs +++ b/src/RioJoy.Tray/Program.cs @@ -33,6 +33,17 @@ internal static class Program /// then gone away (or never appeared within the startup grace). Also makes /// startup wait briefly for a predecessor instance instead of exiting, so /// back-to-back game launches hand the cockpit over cleanly. + /// + /// --profile <name> activates that profile immediately + /// and explicitly — no foreground detection at all. Pod launch + /// scripts pass it so the ViGEm pad and the ports exist before the + /// game starts and enumerates controllers; the auto-switch watcher never + /// runs. On success the named event + /// is signaled — a pod + /// launcher waits on it (with a timeout) instead of counting input + /// devices. Exit code 4 when the named profile is not in the config; 5 + /// when explicit activation fails (reason on stderr) — distinguishable + /// from a hang, so launcher failures stay legible. /// [STAThread] private static int Main(string[] args) @@ -41,37 +52,58 @@ internal static class Program return ImportProfile(args); CompanionTarget? exitWith; + string? profileName; try { - exitWith = ParseExitWith(args); + exitWith = ParseValue(args, "--exit-with", "a process name or pid") is string target + ? CompanionTarget.Parse(target) + : null; + profileName = ParseValue(args, "--profile", "a profile name"); } catch (ArgumentException ex) { - Console.Error.WriteLine($"usage: RioJoy.Tray [--exit-with ] ({ex.Message})"); + Console.Error.WriteLine($"usage: RioJoy.Tray [--profile ] [--exit-with ] ({ex.Message})"); return 2; } + // Validate the explicit profile up front so a pod script's typo is a + // scriptable failure, not a silently idle tray. + if (profileName is not null && + ConfigStore.Load(TrayApplicationContext.ConfigPath).FindProfile(profileName) is null) + { + Console.Error.WriteLine( + $"profile '{profileName}' not found in {TrayApplicationContext.ConfigPath}"); + return 4; + } + using var instance = new Mutex(initiallyOwned: true, SingleInstanceMutex, out bool createdNew); - if (!createdNew && !WaitForPredecessor(instance, wait: exitWith is not null)) + bool podMode = exitWith is not null || profileName is not null; + if (!createdNew && !WaitForPredecessor(instance, wait: podMode)) return 0; // another RIOJoy is already running in this session // net48 has no source-generated ApplicationConfiguration.Initialize(); // do the equivalent setup directly. Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); - Application.Run(new TrayApplicationContext(exitWith)); + var context = new TrayApplicationContext(exitWith, profileName); + if (!context.TryActivateExplicitProfile()) + { + context.Dispose(); // failure reason already on stderr + return 5; + } + Application.Run(context); return 0; } - private static CompanionTarget? ParseExitWith(string[] args) + private static string? ParseValue(string[] args, string flag, string what) { for (int i = 0; i < args.Length; i++) { - if (!string.Equals(args[i], "--exit-with", StringComparison.OrdinalIgnoreCase)) + if (!string.Equals(args[i], flag, StringComparison.OrdinalIgnoreCase)) continue; if (i + 1 >= args.Length) - throw new ArgumentException("--exit-with needs a process name or pid"); - return CompanionTarget.Parse(args[i + 1]); + throw new ArgumentException($"{flag} needs {what}"); + return args[i + 1]; } return null; } diff --git a/src/RioJoy.Tray/TrayApplicationContext.cs b/src/RioJoy.Tray/TrayApplicationContext.cs index 3c32dff..f28c0f4 100644 --- a/src/RioJoy.Tray/TrayApplicationContext.cs +++ b/src/RioJoy.Tray/TrayApplicationContext.cs @@ -30,7 +30,7 @@ internal sealed class TrayApplicationContext : ApplicationContext private readonly AppConfig _config; private readonly RioCoordinator _coordinator; - private readonly AutoSwitchWatcher _watcher; + private readonly AutoSwitchWatcher? _watcher; // null = explicit (--profile) mode private readonly System.Windows.Forms.Timer _pollTimer; private readonly NotifyIcon _trayIcon; private readonly ToolStripMenuItem _statusItem; @@ -39,17 +39,27 @@ internal sealed class TrayApplicationContext : ApplicationContext private readonly CompanionTarget? _exitWith; private readonly CompanionExit _companionExit = new(); private readonly Stopwatch _sinceStart = Stopwatch.StartNew(); + private readonly string? _explicitProfile; - public TrayApplicationContext(CompanionTarget? exitWith = null) + public TrayApplicationContext(CompanionTarget? exitWith = null, string? explicitProfile = null) { _exitWith = exitWith; + _explicitProfile = explicitProfile; _config = ConfigStore.Load(ConfigPath); _coordinator = new RioCoordinator(() => _config); _coordinator.StatusChanged += _ => RefreshOnUiThread(); - _watcher = new AutoSwitchWatcher(new ForegroundProcessProvider(), () => _config); - _watcher.DecisionChanged += d => _coordinator.ApplyDecision(d); + // Explicit (--profile) mode runs NO foreground detection at all: pod + // launch scripts activate the profile before the game starts, so the + // ViGEm pad and the ports already exist when the game enumerates + // controllers — a watcher activating ~1 s after the window appears is + // too late for startup enumeration. + if (explicitProfile is null) + { + _watcher = new AutoSwitchWatcher(new ForegroundProcessProvider(), () => _config); + _watcher.DecisionChanged += d => _coordinator.ApplyDecision(d); + } _statusItem = new ToolStripMenuItem("Status: starting…") { Enabled = false }; @@ -65,11 +75,54 @@ internal sealed class TrayApplicationContext : ApplicationContext _pollTimer = new System.Windows.Forms.Timer { Interval = (int)PollInterval.TotalMilliseconds }; _pollTimer.Tick += (_, _) => { - _watcher.Poll(); + _watcher?.Poll(); CheckCompanion(); }; _pollTimer.Start(); - _watcher.Poll(); + + if (explicitProfile is null) + _watcher!.Poll(); + // Explicit activation runs via TryActivateExplicitProfile (called by + // Program between construction and the message loop, so a failure can + // become an exit code the launcher chain can read). + } + + /// + /// The named event a pod launcher waits on instead of guessing from device + /// enumeration: signaled once the explicit profile is active (ports + /// acquired; virtual pad present when ViGEmBus is installed). Dies with + /// the process, so it can never go stale. + /// + internal const string ReadyEventName = "RIOJoy.Tray.Ready"; + + private EventWaitHandle? _readyEvent; + + /// + /// Explicit (--profile) activation: activate now, and signal + /// on success so the launcher can start the + /// game knowing the virtual pad already exists. False = activation failed + /// (port busy, bad endpoint, …) — the caller reports and exits. + /// + internal bool TryActivateExplicitProfile() + { + if (_explicitProfile is null) + return true; // resident mode — nothing to do + + // Program.Main validated the name against the same store already. + RioProfile profile = _config.FindProfile(_explicitProfile)!; + _coordinator.SetManualProfile(profile); + if (_coordinator.Runtime is null) + { + Console.Error.WriteLine($"RioJoy: explicit activation failed: {_coordinator.Status}"); + return false; + } + + // Either side may create the event first (launcher-waits-then-start or + // start-then-wait); ManualReset + same name converge on one object. + _readyEvent = new EventWaitHandle( + initialState: false, EventResetMode.ManualReset, ReadyEventName); + _readyEvent.Set(); + return true; } // Pod-bundled mode: quit (full teardown — ports released, wallpaper restored, @@ -338,8 +391,16 @@ internal sealed class TrayApplicationContext : ApplicationContext { unhook?.Invoke(); _coordinator.EndEditorSession(); - _watcher.Reset(); // re-sync with the foreground app (may re-activate a game) - _watcher.Poll(); + if (_watcher is not null) + { + _watcher.Reset(); // re-sync with the foreground app (may re-activate a game) + _watcher.Poll(); + } + else if (_explicitProfile is not null && + _config.FindProfile(_explicitProfile) is RioProfile explicitProfile) + { + _coordinator.SetManualProfile(explicitProfile); // back to the explicit pod profile + } }; editor.Show(); } @@ -439,6 +500,7 @@ internal sealed class TrayApplicationContext : ApplicationContext _pollTimer.Dispose(); _coordinator.Dispose(); _trayIcon.Dispose(); + _readyEvent?.Dispose(); } base.Dispose(disposing);