pod: fully explicit activation (--profile) + RIOJoy.Tray.Ready signal

No detection anywhere in the pod chain (Cyd: foreground detection is too
slow - the pad appears after the game already enumerated controllers).
--profile <name> activates immediately at startup, never runs the
auto-switch watcher, and on success signals the named manual-reset event
RIOJoy.Tray.Ready (process-lifetime, never stale) so a launcher waits on
the signal instead of counting winmm devices. Legible failures for the
launcher: exit 4 unknown profile, exit 5 activation failed with the reason
on stderr - both verified live against the built exe. Editor close
re-activates the explicit profile. build-pod start scripts now pass
--profile <Name> --exit-with <exe>; docs updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-31 23:01:44 -05:00
co-authored by Claude Fable 5
parent 5befd1d511
commit bdd30678e3
5 changed files with 164 additions and 28 deletions
+14 -4
View File
@@ -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).'
+28 -8
View File
@@ -218,16 +218,36 @@ entry points the game's package wires up:
game:
```
start "" "...\riojoy\app\RioJoy.Tray.exe" --exit-with <game exe>
start "" "...\riojoy\app\RioJoy.Tray.exe" --profile "<Name>" --exit-with <game exe>
```
`--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);
+12
View File
@@ -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 <name>`** — 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 <exe|pid>`** (`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,
+40 -8
View File
@@ -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.</para>
///
/// <para><c>--profile &lt;name&gt;</c> activates that profile immediately
/// and explicitly — <b>no foreground detection at all</b>. Pod launch
/// scripts pass it so the ViGEm pad and the ports exist <i>before</i> the
/// game starts and enumerates controllers; the auto-switch watcher never
/// runs. On success the named event
/// <see cref="TrayApplicationContext.ReadyEventName"/> 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.</para>
/// </summary>
[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 <exe|pid>] ({ex.Message})");
Console.Error.WriteLine($"usage: RioJoy.Tray [--profile <name>] [--exit-with <exe|pid>] ({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;
}
+70 -8
View File
@@ -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).
}
/// <summary>
/// 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.
/// </summary>
internal const string ReadyEventName = "RIOJoy.Tray.Ready";
private EventWaitHandle? _readyEvent;
/// <summary>
/// Explicit (--profile) activation: activate now, and signal
/// <see cref="ReadyEventName"/> 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.
/// </summary>
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);