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:
+14
-4
@@ -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).'
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 <name></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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user