Files
SteamlessController/src/app/GameLiveness.h
T
Dylan DeverillandClaude Opus 5 5796177be9 Track the pad-type hold by process, not by profile selection
Two fixes to the hold, both found by forcing paths that do not occur on
this machine.

The liveness fallback never released. It asked whether the pid could be
opened and read success as proof of life, but a pid stays valid for as
long as anything holds a handle to the process object — and a launcher or
an anti-cheat service generally does. Measured against Fortnite with the
fallback forced: the game exited, the pid stayed openable, and the hold
stood forever, leaving a PlayStation pad that never went back to Xbox.
It now asks whether the process has exited rather than whether it can be
opened. Both remaining ways to fool it — an exit code of 259, or pid
reuse between two checks — leave a hold standing too long, which costs
the deferred rebuild rather than causing a wrong one.

The hold was also gated on the profile selection changing, having been
written behind the early return that skips a foreground change selecting
the profile already selected. But a hold follows a process and a
selection follows a profile, and those do not change together: a loader
handing off to the game it launched is the same profile wearing a new
pid, so the selection does not change, the return fires, and the hold
stays on the old process. When that one exits the pad rebuilds under a
game that has only just started.

Both liveness paths are now confirmed on hardware — Marathon and Fortnite
by handle, Fortnite by pid with the fallback forced — holding across
alt-tabs and releasing within the poll interval of the game closing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 23:33:38 -07:00

68 lines
3.3 KiB
C++

#pragma once
#include <Windows.h>
#include <string>
// Is the game a profile was matched against still running?
//
// The foreground is the only push-based signal this app has for "what is the
// user doing" (see ForegroundWatcher), and it answers a different question
// from the one profiles are really asking. Alt-tabbing to a browser during a
// matchmaking queue makes the foreground say "browser" while the honest answer
// to "what am I playing" is still the game. Asking whether the game's process
// is alive is what tells the two apart.
//
// This exists for exactly one decision: which kind of virtual pad to present.
// A profile that changes platform tears the pad down and builds it again,
// which a running game sees as its controller being unplugged, so that must
// not happen every time somebody alt-tabs. Bindings stay tied to the
// foreground, because they describe what the user is doing right now — and
// when the desktop is in front, the desktop's bindings are what is wanted.
//
// Liveness is deliberately a poor man's check rather than a process watcher.
// It is asked a few times a second at most, and it never needs to be right the
// instant a process dies — nothing is watching the pad in the moment a game
// exits, so noticing a second or two late costs nothing.
class GameLiveness {
public:
GameLiveness() = default;
~GameLiveness() { Clear(); }
GameLiveness(const GameLiveness&) = delete;
GameLiveness& operator=(const GameLiveness&) = delete;
// Start holding for this profile and process. Replaces whatever was held.
void Hold(const std::wstring& profileId, DWORD pid);
// Stop holding, whatever the reason.
void Clear();
bool IsHeld() const { return !m_profileId.empty(); }
const std::wstring& ProfileId() const { return m_profileId; }
// The process the hold is against, so a caller can notice the same game
// running under a new one — a relaunch inside the poll interval would
// otherwise leave the hold pinned to a pid that has already gone.
DWORD Pid() const { return m_pid; }
// Whether the held process is still alive. False when nothing is held.
//
// Anti-cheat and elevated games routinely refuse an unelevated process the
// SYNCHRONIZE right, so a handle is not always available and its absence
// says nothing about whether the game is running. The fallback re-opens by
// pid and asks for the exit code, because opening it only proves the
// process OBJECT exists: a pid stays valid for as long as anything holds a
// handle to the process, which a launcher or an anti-cheat service
// generally does. Where it cannot be opened at all, being DENIED access is
// itself proof the process is there.
//
// The fallback can be fooled two ways, both benign. A game exiting with
// code 259 reads as STILL_ACTIVE forever, and pid reuse needs the id to be
// handed to something new between two checks seconds apart. Either leaves a
// hold standing too long, costing the pad rebuild it was deferring — which
// is the behaviour this class was added to improve on, not worse than it.
bool StillRunning() const;
private:
std::wstring m_profileId;
DWORD m_pid = 0;
HANDLE m_handle = nullptr; // null when the process refused us one
};