mirror of
https://github.com/ddeverill/SteamlessController.git
synced 2026-10-22 22:52:16 -05:00
Acquiring against a running Steam was losing a race we were not really running. The old sequence waited for WM_DEVICECHANGE, enumerated every HID interface on the machine, opened the device shared, closed it, and only then reopened it exclusively. Steam had to be anywhere in that. Measured first, because every timing figure in #79 so far had come from reading log line order and that was wrong twice. A losing attempt cost 656ms, of which about 545ms was the retry burst sleeping, 46.6ms was eleven full enumerations, and 0.2ms was the claim itself. A winning attempt took 31ms start to finish. The in-process work was never the problem; the time was going somewhere the log could not see. The claim now happens before the probe. A live slot answers a state probe in a report or two but an empty one costs the whole timeout, so walking the list probe-first left the occupied slot unclaimed until every empty slot ahead of it had timed out — up to 75ms on a four-slot receiver, against the ~180ms Steam takes to reopen the device here and under 73ms on the machine in #79. A sweep claims every slot up front and the per-slot path uses that result; slots that turn out to be empty hand theirs straight back. The pounce thread stops waiting to be told. WM_DEVICECHANGE is one of the last events in an arrival, not the first: PnP registers the interface, dispatches to every listener, and ours queues behind whatever else is pending. Since we are the ones who asked for the cycle, a thread armed before it spins CreateFileW on the paths we already know and takes the device exclusively on the first open. It only keeps a handle after seeing the path disappear — grabbing earlier would hold the very handle that vetoes our own cycle, and PnP does not care that the handle is ours. Adopted slots skip the claim sweep, because reopening would give the device back for exactly as long as it takes to ask for it again. Adoption happens before the in-flight guard. A caught handle is proof the cycle delivered, and adopting after the guard meant sitting on a device we had already won for the whole CYCLE_MIN_GAP_MS: measured at 4030ms between the grab and the adopt, now 19ms. The burst no longer runs on a blocked claim. A held handle does not free itself, so retrying it ten times at 50ms intervals only delayed the cycle that could actually free it. It still runs for a slot that has not started streaming, which is what it is good for. The cycle is narrowed to the occupied interface. A receiver publishes one per slot and only one is ever occupied, so cycling all of them multiplied the downtime and gave Steam an arrival per interface to react to. The app names the target in cycle.request and the helper consumes it on read, so a stale request cannot narrow a later cycle. Only on the first attempt: if narrowing did not work the assumption behind it may be wrong, and later attempts cycle everything. Measured after: a losing attempt is 15ms rather than 656ms, one enumeration rather than eleven, and the whole blocked-claim-to-game-mode path is 1.20s, nearly all of it the devnode being down. Two things make this observable rather than another round of inference. The acquire logs where its milliseconds went, won or lost. And when we do settle for a shared handle, the helper is asked who is holding the device, so the answer lands in a log the reporter already sends instead of needing a command run by hand — the helper's own scan only runs when PnP vetoes a cycle, and the case worth explaining is the opposite one, where every cycle succeeds and the controller is taken back anyway. The scan's counters also stop lying. "queried" counts handles whose names were resolved, which is far fewer than those examined and is not a coverage figure — candidates are skipped when their process already has a known holder, when OpenProcess is refused, when DuplicateHandle fails, or when the object is not device-like. Read as coverage it says 7% when the scan in fact examined everything, which is how a complete answer got mistaken for a partial one. It now reports examined against total, and says that a deadline means a wedged worker rather than a short scan. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>