From c7af8cac7bf25f6167af4e19db79a7b7cca1db8b Mon Sep 17 00:00:00 2001 From: Geoffrey Vancoetsem <10533139+geeooff@users.noreply.github.com> Date: Sun, 20 Sep 2026 11:55:42 +0200 Subject: [PATCH 01/12] Record the game the presence writer never saw, and propose Lot 15 Death Stranding 2, marked "Remember this is a game" days earlier, ran for an hour on 2026-09-20 with the watcher silent: Windows recorded the game (LastGameActivity and the entry's LastAccessed three seconds after the launch) and never started the presence writer. Its list entry has Revision 1 and no Xbox TitleId, where every title the watcher has ever detected carries the distributed revision and a TitleId; the writer is presence, and a title named by hand has none to write. The detection page keeps the figures and corrects its promise in place; How it works and Getting started say what the toggle does and does not do for this program. Lot 15 proposes reading Windows' own list as a second signal -- at launch and at the tick mid-game, on a registry change notification rather than a poll -- with the measurements to make first. Co-Authored-By: Claude Opus 5 --- docs/design/00-detection.md | 17 +++++ docs/design/15-marked-games.md | 126 +++++++++++++++++++++++++++++++++ docs/design/README.md | 1 + docs/getting-started.md | 6 +- docs/how-it-works.md | 16 ++++- 5 files changed, 163 insertions(+), 3 deletions(-) create mode 100644 docs/design/15-marked-games.md diff --git a/docs/design/00-detection.md b/docs/design/00-detection.md index c24a90a..dccaefd 100644 --- a/docs/design/00-detection.md +++ b/docs/design/00-detection.md @@ -72,6 +72,23 @@ title** — measured from 2.7 s to over two minutes for the same game on the same afternoon. [Lot 1](01-console-watcher.md) has those figures and the decision they led to. +## The limit found in the field, 2026-09-20 + +Every title measured above is in the Known Game List Microsoft distributes, +with an Xbox `TitleId`. A title Windows knows only because the person ticked +*Remember this is a game* in the Game Bar is not: its entry under +`HKCU\System\GameConfigStore\Children` carries `Revision = 1`, no +`TitleId`, no `GameDVR_GameGUID`. For such a title — *Death Stranding 2*, +ticked days earlier, launched at 11:33:57 — Windows recorded the game +(`GameDVR\LastGameActivity` and the entry's `LastAccessed` at 11:34:00, +GameDVR's encoder rebuilt the same second) **and never started the presence +writer**; the maintainer's own Xbox status stayed *Online* rather than +*Playing*. The writer is Windows' verdict for the titles Windows can name to +Xbox, and no verdict at all for the ones the person named. The sentence +above this section that promised "anything Windows treats as a game" held +for every title until that one; [Lot 15](15-marked-games.md) takes the +finding and adds Windows' list itself as a second signal. + ## Why a user-session program and not a Windows service - **Session 0 isolation.** A service cannot see the interactive desktop, and diff --git a/docs/design/15-marked-games.md b/docs/design/15-marked-games.md new file mode 100644 index 0000000..6201301 --- /dev/null +++ b/docs/design/15-marked-games.md @@ -0,0 +1,126 @@ +# Lot 15 — Games Windows knows only from you + +**Status: proposed 2026-09-20, taken next, ahead of everything else +pending** — the maintainer's decision the same morning, on the finding +below. It waits only for [Lot 9](09-robustness.md)'s configuration work to +close its field run. + +- [ ] The instrument: a `watch-games` command in `presence-probe` that logs registry change notifications on Windows' game list and which entry's `LastAccessed` moved, through the launch of several hand-marked titles +- [ ] The measurements below, before any line of the watcher changes +- [ ] Detection from Windows' list as well as from the presence writer: a hand-marked title is a session from its launch +- [ ] A title marked *while it runs* becomes a session within the settle time, and the start commands run then +- [ ] The idle cost measured and written down: no polling of the registry, and whatever polling of processes remains, with its figure +- [ ] Verified in the field on DS2 and the other hand-marked titles on the maintainer's machine + +**Done when** a game the Game Bar knows only because the person ticked +*Remember this is a game* is detected at its launch, or at the tick if that +comes mid-game, with the same start and stop commands as any other title — +and the watcher still does nothing measurable while no game runs. + +## What was found, 2026-09-20 + +The maintainer started *Death Stranding 2: On the Beach* from Steam at +11:33:57 and the watcher, running since 10:09, never said a word. The +machine, read at 11:38–11:42 while the game ran: + +- `DS2.exe` up, `GameBar.exe` up from 11:34:07 — the maintainer had opened + the overlay to check the toggle. The toggle was on; it had been ticked + days before, so the list already knew the game when it was launched. +- Windows had *seen* the game: `GameDVR\LastGameActivity` read 11:34:00, + three seconds after the launch, the same relation the record measured for + Starfield and Farming Simulator in [Detection](00-detection.md); the game + list entry's own `LastAccessed` carried the same second; `bcastdvr` had + rebuilt its encoder topology at 11:34:00.990. +- **`GameBarPresenceWriter.exe` never started.** Not at 11:38, not after a + minute's watch at 11:39. And the maintainer's own Xbox profile, in the + screenshot they sent, read *Online* — not *Playing DEATH STRANDING 2* — + while a friend's read *Palworld*. +- The entry itself, against the entries of titles the watcher has + detected: + + | | DS2 | cs2, Skyrim, bf6, RDR2 | + | --- | --- | --- | + | `Revision` | 1 | 2691, the revision of the list Microsoft distributes | + | `TitleId` | none | an Xbox title id | + | `GameDVR_GameGUID` | none | a guid | + | `Flags`, `Type` | 17, 1 | 17 or 19, 1 | + +So the presence writer — *presence*, as in the Xbox status "playing such +and such" — is activated for titles Windows knows by their Xbox identity, +and a title the person taught it by hand has none: a game to the Game Bar, +to GameDVR, to Game Mode, and nothing for the writer to write. Inferred from +the three facts above, not read in any documentation; the figures are the +record. It also explains the nephew's machine on 2026-09-18: `chrome.exe` +was a hand-made entry there, the writer had been started by Overwatch, and +Chrome only supplied the first name. + +The detection page promised that "anything Windows treats as a game" +triggers the watcher. That was true of every title measured before this +one, and it is false for this class; corrected in place there and in the +user pages the same day. + +## What is decided + +- **The signal stays Windows' verdict, and gains Windows' list.** The + program already reads `HKCU\System\GameConfigStore\Children` to *name* + the game; it is Windows' own list, grown by Microsoft's revisions and by + the person's own ticks. A process on that list running is Windows saying + a game is running, as much as the writer is. No list of our own, no + fullscreen or GPU heuristics: a title neither Windows nor the person has + named a game stays invisible, and that is right. +- **Marking mid-game counts.** The person starts a title Windows does not + know, the fans stay on idle, they open the Game Bar and tick the box: the + session starts *then*, as soon as the change reaches the watcher, and the + start commands run. Nothing to relaunch. +- **No polling of the registry.** `RegNotifyChangeKeyValue` on the list's + key, subtree, for names and values, gives a kernel event the watcher can + park on beside the stop event — documented, unelevated for `HKCU`, and the + same shape as the writer's handle. The Game Bar writes several values into + a new entry, so the wake-up settles for a moment before the list is read + again, as the configuration reload does with its folder. +- **The cost while idle is the budget.** Today it is one process lookup + every `poll_interval`. Whatever the design below adds while no game runs + is measured and written here before it is accepted. + +## To measure first, in this order + +1. **Does the list's `LastAccessed` move at every launch, for every kind of + entry?** It did for DS2 today (a hand-made entry) and it agrees with + `LastGameActivity` for listed titles. If Windows touches the entry of + *every* game it detects at launch, then a registry notification on the + subtree is a wake-up for *every* game start, listed or hand-made — and + the idle poll for the writer could go with it, leaving the watcher parked + on two kernel objects and nothing else. That would be the best outcome + and it is the first thing the probe looks at. +2. **What `RegNotifyChangeKeyValue` delivers**: one-shot, so re-armed after + each wake; whether a new subkey (the tick) and a value set on an existing + one (the launch) both fire with `REG_NOTIFY_CHANGE_NAME | + REG_NOTIFY_CHANGE_LAST_SET`; how many wake-ups one tick produces, for + the settle; `REG_NOTIFY_THREAD_AGNOSTIC`, so the event can be waited on + from the engine's thread rather than the one that armed it. +3. **Which process.** The entry names the executable's full path; the + running process is found the way naming finds it today, from a Toolhelp + snapshot matched against the list. Measure what a snapshot costs on this + machine — it is what an idle poll would pay if step 1 disappoints. +4. **Waiting on the game's own handle.** `OpenProcess(SYNCHRONIZE)` on the + matched process, so the session ends when it exits with nothing polled + meanwhile — the writer's shape again. Measure whether an elevated game + (a launcher that asks for administrator rights) grants `SYNCHRONIZE` to + an unelevated watcher; if not, that title is named in the log as one + the watcher can only poll. +5. **The end of a session** when both signals exist: the writer's grace + (`stop_delay`) is for the writer's blinking; a process exit is an exit. + And the odd case: the person unticks the box mid-game. The entry goes, + Windows no longer calls it a game; the session ends and the stop + commands run, which is what the person asked for. To confirm on the + probe. + +## What it changes in the program, once measured + +The engine's sensor gains a second question — which listed processes are +running, and a handle to wait on for one of them — and its loop treats +"the writer runs" and "a listed process runs" as one session with two +possible anchors. The refinement, the marker, the handover and the reload +are untouched: a session is a session. `status` says which signal it sees. +The user pages say plainly what *Remember this is a game* does for this +program, once it does something. diff --git a/docs/design/README.md b/docs/design/README.md index 03c2a16..13778cc 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -26,6 +26,7 @@ session rather than when the code compiles. Each has its own page. | 12 | [Editing the configuration without breaking it](12-editing-on-a-copy.md) | proposed | | 13 | [Updating](13-updating.md) | shipped in 0.2.0; closes on the first update from it | | 14 | [Release notes people can read](14-release-notes.md) | done | +| 15 | [Games Windows knows only from you](15-marked-games.md) | proposed 2026-09-20; taken next, after Lot 9's field run | **Dependency order:** 1 → 2 → 4 → 5 → 6 → 7, with 3 independent and 7 needing both 3 and 6. Logging sits before the icon deliberately — the icon logs too, diff --git a/docs/getting-started.md b/docs/getting-started.md index b15ea8f..70e9d0e 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -276,7 +276,11 @@ too. **Nothing happens when I start a game.** Run `gamemode-executor status` while the game is running. If it says no game is running, Windows itself is not flagging that title — see -[How it works](how-it-works.md). +[How it works](how-it-works.md). One case to know about: a game Windows +did not recognise, that you had to mark yourself with *Remember this is a +game* in the Game Bar, is not seen by this program yet even with the box +ticked. [How it works](how-it-works.md#how-it-knows-a-game-is-running) says +why, and what is being done about it. **It says another instance is already running.** The background watcher is doing its job. That message means you tried to start a diff --git a/docs/how-it-works.md b/docs/how-it-works.md index 8180c8f..51ceec8 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -26,8 +26,20 @@ exists. that process ends. Zero activity while you play — which is rather the point of a program that runs during games. -The result is that anything Windows treats as a game triggers it, including -titles released after this program was written. +The result is that anything Windows knows as a game triggers it, including +titles released after this program was written — Windows keeps its list of +games up to date on its own. + +**One class it does not see yet**, found on 2026-09-20: a title Windows did +not recognise, that you taught it by opening the Game Bar over it and +ticking *Remember this is a game*. Windows treats it as a game from then on +— overlay, capture, Game Mode — but does not start the process this +program watches for it, because that process exists to tell Xbox what you +are playing and a title you named by hand has no Xbox identity to tell. So +the watcher stays quiet on such a title, whatever the toggle says. The next +piece of work, [Lot 15](design/15-marked-games.md), makes the watcher read +Windows' list as well, so that a title you marked counts — at its launch, +or at the moment you tick it. Which process to watch is read from the registry at startup rather than hard-coded, so a machine where that registration differs still works. From dfdb0db4f7df39f34ccd910a54bd568359bd99d8 Mon Sep 17 00:00:00 2001 From: Geoffrey Vancoetsem <10533139+geeooff@users.noreply.github.com> Date: Wed, 23 Sep 2026 11:20:01 +0200 Subject: [PATCH 02/12] Add watch-games to the probe, to measure what Windows writes to its list Lot 15's first box. `presence-probe watch-games` parks on a registry change notification over Windows' game list and says, at each wake, which entry was added, removed, or had its LastAccessed moved, labelled hand-made or listed, with the presence writer logged beside it. An optional key lets the probe be checked on a key one can write to; done on a scratch key under HKCU\Software, which also showed that an entry created with its values arrives as two notifications. `registry::Key` gains a QWORD reader and its raw handle for the one call the wrapper does not cover. Co-Authored-By: Claude Opus 5.5 --- Cargo.toml | 2 + docs/design/15-marked-games.md | 22 ++- src/bin/presence-probe.rs | 240 +++++++++++++++++++++++++++++++-- src/registry.rs | 12 ++ 4 files changed, 265 insertions(+), 11 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 3bc85f3..29e132d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,6 +48,8 @@ windows = { version = "0.62", features = [ # FindFirstChangeNotificationW: the configuration folder, watched for the # live reload. "Win32_Storage_FileSystem", + # RegNotifyChangeKeyValue: Windows' game list, watched rather than polled. + "Win32_System_Registry", # WNDCLASSEXW names HBRUSH, HICON and HCURSOR, so the window class needs Gdi # even though this program never draws anything. "Win32_Graphics_Gdi", diff --git a/docs/design/15-marked-games.md b/docs/design/15-marked-games.md index 6201301..3f08abc 100644 --- a/docs/design/15-marked-games.md +++ b/docs/design/15-marked-games.md @@ -5,7 +5,7 @@ pending** — the maintainer's decision the same morning, on the finding below. It waits only for [Lot 9](09-robustness.md)'s configuration work to close its field run. -- [ ] The instrument: a `watch-games` command in `presence-probe` that logs registry change notifications on Windows' game list and which entry's `LastAccessed` moved, through the launch of several hand-marked titles +- [x] The instrument: a `watch-games` command in `presence-probe` that logs registry change notifications on Windows' game list and which entry's `LastAccessed` moved — built 2026-09-23 and checked on a scratch key, below - [ ] The measurements below, before any line of the watcher changes - [ ] Detection from Windows' list as well as from the presence writer: a hand-marked title is a session from its launch - [ ] A title marked *while it runs* becomes a session within the settle time, and the start commands run then @@ -115,6 +115,26 @@ user pages the same day. commands run, which is what the person asked for. To confirm on the probe. +## The instrument, checked 2026-09-23 + +`presence-probe watch-games [secs] [key]` parks on `RegNotifyChangeKeyValue` +over the list's key — subtree, names and values, `THREAD_AGNOSTIC`, re-armed +after each wake — and on each wake reads the list again and says what moved: +an entry added or removed, an entry whose `LastAccessed` moved (with how long +ago that time is), an entry changed otherwise, or nothing it compares. Each +entry is labelled *hand-made* (`Revision = 1`, no `TitleId`) or *listed*. It +also looks at the presence writer every 250 ms, so the two signals read side +by side in one log. The optional `key` is there to check the probe itself on +a key one can write to. + +Checked that way on a scratch key under `HKCU\Software`, deleted after: a +`LastAccessed` set on an entry, an entry created with two values, a value +changed, an entry removed — six wake-ups, each said as it happened, within a +few milliseconds of the write. **An entry created and then given its values +is two notifications, not one**: the first saw the entry already complete, +the second found nothing new. The settle the watcher will need is measured +on the Game Bar's own writes next, not on these. + ## What it changes in the program, once measured The engine's sensor gains a second question — which listed processes are diff --git a/src/bin/presence-probe.rs b/src/bin/presence-probe.rs index fbf177f..92744b2 100644 --- a/src/bin/presence-probe.rs +++ b/src/bin/presence-probe.rs @@ -14,9 +14,12 @@ //! Usage: //! //! ```text -//! presence-probe status the current registration and the log path -//! presence-probe watch [secs] log when Windows' own presence writer runs -//! presence-probe activate activate the class ourselves and time it +//! presence-probe status the current registration and the log path +//! presence-probe watch [secs] log when Windows' own presence writer runs +//! presence-probe watch-games [secs] [key] +//! log what Windows writes to its game list, and when; +//! `key`, under HKCU, checks the probe on one you can write +//! presence-probe activate activate the class ourselves and time it //! ``` #[cfg(not(windows))] @@ -308,20 +311,237 @@ fn cmd_status() -> windows::core::Result<()> { Ok(()) } +// ------------------------------------------------ watching the game list -- + +/// One entry of Windows' game list, as much of it as the probe compares. +#[derive(Clone, PartialEq, Eq)] +struct Entry { + name: String, + exe: Option, + package: Option, + revision: Option, + title_id: Option, + last_accessed: Option, +} + +impl Entry { + fn label(&self) -> String { + let what = self + .exe + .as_deref() + .map(|exe| exe.rsplit(['\\', '/']).next().unwrap_or(exe).to_owned()) + .or_else(|| self.package.clone()) + .unwrap_or_else(|| "(no exe, no package)".to_owned()); + let kind = match (self.revision, &self.title_id) { + (Some(1), None) => "hand-made", + (_, Some(_)) => "listed, with a title id", + _ => "listed, no title id", + }; + format!("{what} [{kind}, {}]", self.name) + } +} + +/// Windows' game list, per user. +const GAME_LIST: &str = r"System\GameConfigStore\Children"; + +fn read_entries(key: &str) -> Vec { + use game_mode_executor::registry::Key; + let Ok(root) = Key::open_current_user(key) else { + return Vec::new(); + }; + let mut entries: Vec = root + .subkey_names() + .into_iter() + .filter_map(|name| { + let child = root.open_subkey(&name).ok()?; + Some(Entry { + exe: child.string_value("MatchedExeFullPath"), + package: child.string_value("UtmItemId"), + revision: child.dword_value("Revision"), + title_id: child + .string_value("TitleId") + .or_else(|| child.dword_value("TitleId").map(|id| id.to_string())), + last_accessed: child.qword_value("LastAccessed"), + name, + }) + }) + .collect(); + entries.sort_by(|a, b| a.name.cmp(&b.name)); + entries +} + +/// How long ago a `FILETIME` was, against the clock now. +fn ago(filetime: u64) -> String { + use windows::Win32::System::SystemInformation::GetSystemTimeAsFileTime; + // SAFETY: takes no input and only returns a struct. + let now = unsafe { GetSystemTimeAsFileTime() }; + let now = (u64::from(now.dwHighDateTime) << 32) | u64::from(now.dwLowDateTime); + let delta = now.abs_diff(filetime) as f64 / 10_000_000.0; + if now >= filetime { + format!("{delta:.1}s ago") + } else { + format!("{delta:.1}s ahead") + } +} + +/// Park on a change notification for Windows' game list and say what moved: +/// entries added, removed, or touched -- and which `LastAccessed` moved, +/// which is the question Lot 15 asks: does Windows touch the entry of every +/// game it detects at launch, listed or hand-made? The presence writer is +/// logged beside it, so the two signals can be read against each other. +/// Nothing is modified, nothing needs administrator rights. `key` is the +/// game list unless the probe itself is being checked against a key one +/// can write to. +fn cmd_watch_games(seconds: u64, key: &str) -> windows::core::Result<()> { + use game_mode_executor::registry::Key; + use windows::Win32::Foundation::{CloseHandle, WAIT_OBJECT_0}; + use windows::Win32::System::Registry::{ + REG_NOTIFY_CHANGE_LAST_SET, REG_NOTIFY_CHANGE_NAME, REG_NOTIFY_THREAD_AGNOSTIC, + RegNotifyChangeKeyValue, + }; + use windows::Win32::System::Threading::{CreateEventW, WaitForSingleObject}; + + let root = match Key::open_current_user(key) { + Ok(root) => root, + Err(error) => { + log(&format!("watch-games: cannot open HKCU\\{key} ({error:#})")); + return Ok(()); + } + }; + let exe = writer_exe(); + // SAFETY: no security attributes, no name; closed at the end. + let event = unsafe { CreateEventW(None, false, false, None) }?; + + let mut before = read_entries(key); + let mut writer = presence_writer::running_pid(&exe); + log(&format!( + "watch-games: {} entries, {} hand-made (Revision 1, no TitleId); writer {}; watching for {seconds}s", + before.len(), + before + .iter() + .filter(|entry| entry.revision == Some(1) && entry.title_id.is_none()) + .count(), + match writer { + Some(pid) => format!("running (pid {pid})"), + None => "not running".to_owned(), + } + )); + + let started = std::time::Instant::now(); + let mut wakeups = 0u32; + while started.elapsed().as_secs() < seconds { + // One-shot: re-armed before every wait. THREAD_AGNOSTIC so the event + // could be waited on from another thread, which the watcher will. + // SAFETY: `root` outlives the call, `event` is a live event handle. + let armed = unsafe { + RegNotifyChangeKeyValue( + root.raw(), + true, + REG_NOTIFY_CHANGE_NAME | REG_NOTIFY_CHANGE_LAST_SET | REG_NOTIFY_THREAD_AGNOSTIC, + Some(event), + true, + ) + }; + if armed.is_err() { + log(&format!( + "watch-games: RegNotifyChangeKeyValue failed ({armed:?})" + )); + break; + } + // Wake every 250 ms anyway, to log the writer coming and going. + // SAFETY: `event` is live for the whole loop. + let woke = unsafe { WaitForSingleObject(event, 250) } == WAIT_OBJECT_0; + let at = started.elapsed().as_secs_f32(); + + let current_writer = presence_writer::running_pid(&exe); + match (writer, current_writer) { + (None, Some(pid)) => log(&format!( + "watch-games: WRITER STARTED pid {pid} at +{at:.1}s" + )), + (Some(pid), None) => log(&format!( + "watch-games: WRITER EXITED pid {pid} at +{at:.1}s" + )), + _ => {} + } + writer = current_writer; + + if !woke { + continue; + } + wakeups += 1; + let after = read_entries(key); + let mut said = 0; + for entry in &after { + match before.iter().find(|old| old.name == entry.name) { + None => { + said += 1; + log(&format!( + "watch-games: #{wakeups} +{at:.1}s ADDED {}", + entry.label() + )); + } + Some(old) if old.last_accessed != entry.last_accessed => { + said += 1; + log(&format!( + "watch-games: #{wakeups} +{at:.1}s LastAccessed moved for {} -> {}", + entry.label(), + entry.last_accessed.map_or("(none)".to_owned(), ago) + )); + } + Some(old) if old != entry => { + said += 1; + log(&format!( + "watch-games: #{wakeups} +{at:.1}s changed (not LastAccessed) {}", + entry.label() + )); + } + Some(_) => {} + } + } + for old in &before { + if !after.iter().any(|entry| entry.name == old.name) { + said += 1; + log(&format!( + "watch-games: #{wakeups} +{at:.1}s REMOVED {}", + old.label() + )); + } + } + if said == 0 { + log(&format!( + "watch-games: #{wakeups} +{at:.1}s notification, nothing the probe compares moved" + )); + } + before = after; + } + log(&format!( + "watch-games: done, {wakeups} wake-ups in {seconds}s" + )); + // SAFETY: the event created above, closed once. + unsafe { _ = CloseHandle(event) }; + Ok(()) +} + fn main() -> windows::core::Result<()> { + let seconds = || { + std::env::args() + .nth(2) + .and_then(|value| value.parse().ok()) + .unwrap_or(600) + }; match std::env::args().nth(1).as_deref() { None | Some("status") => cmd_status(), - Some("watch") => { - let seconds = std::env::args() - .nth(2) - .and_then(|value| value.parse().ok()) - .unwrap_or(600); - cmd_watch(seconds) + Some("watch") => cmd_watch(seconds()), + Some("watch-games") => { + let key = std::env::args().nth(3); + cmd_watch_games(seconds(), key.as_deref().unwrap_or(GAME_LIST)) } Some("activate") => cmd_activate(5, 60), Some(other) => { eprintln!("unknown command `{other}`"); - eprintln!("usage: presence-probe [status|watch [seconds]|activate]"); + eprintln!( + "usage: presence-probe [status|watch [seconds]|watch-games [seconds]|activate]" + ); std::process::exit(2); } } diff --git a/src/registry.rs b/src/registry.rs index 42e7bbf..c84acbf 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -52,6 +52,18 @@ impl Key { .filter(|value| !value.is_empty()) } + /// A `REG_QWORD` value, or `None` when it is absent or not one. Windows' + /// game list keeps its `LastAccessed` times this way, as `FILETIME`s. + pub fn qword_value(&self, name: &str) -> Option { + self.0.get_u64(name).ok() + } + + /// The raw handle, for the one Win32 call the wrapper does not cover: + /// `RegNotifyChangeKeyValue`. Valid as long as `self` is. + pub fn raw(&self) -> windows::Win32::System::Registry::HKEY { + windows::Win32::System::Registry::HKEY(self.0.as_raw()) + } + /// A `REG_DWORD` value, or `None` when it is absent or not one. /// /// Windows keeps several of its own switches this way -- the taskbar theme From 7d3e6403e78e070f7f9ffa7a40271b67c76e242d Mon Sep 17 00:00:00 2001 From: Geoffrey Vancoetsem <10533139+geeooff@users.noreply.github.com> Date: Wed, 23 Sep 2026 11:48:30 +0200 Subject: [PATCH 03/12] Record the first probe run: a hand-ticked entry shadows Microsoft's own The maintainer played five titles with the probe watching. DS2 and Wreckfest 2 were in Microsoft's list all along: unticked and relaunched, each got a new entry from the distributed list (Revision 2691, a TitleId) and the presence writer started. The hand-made entry had been shadowing Microsoft's. The Other Side, not listed, stayed unseen. So the class splits: titles ticked before Microsoft listed them, which unticking brings back today -- said now in How it works and Getting started -- and titles Microsoft does not list, which only Lot 15 reaches. Windows writes the game's entry at every launch, listed or not, within the same quarter-second as the writer. But the registry notification never woke the probe through those writes; the probe now arms once per wake and reads the list every 250 ms to log any change that arrives without one, for a second run. Co-Authored-By: Claude Opus 5.5 --- docs/design/00-detection.md | 8 +++ docs/design/15-marked-games.md | 60 +++++++++++++++- docs/getting-started.md | 6 +- docs/how-it-works.md | 19 +++-- src/bin/presence-probe.rs | 123 +++++++++++++++++++-------------- 5 files changed, 157 insertions(+), 59 deletions(-) diff --git a/docs/design/00-detection.md b/docs/design/00-detection.md index dccaefd..42ac990 100644 --- a/docs/design/00-detection.md +++ b/docs/design/00-detection.md @@ -89,6 +89,14 @@ above this section that promised "anything Windows treats as a game" held for every title until that one; [Lot 15](15-marked-games.md) takes the finding and adds Windows' list itself as a second signal. +Corrected 2026-09-23, with the probe watching: DS2 was in Microsoft's list +all along. Unticked and relaunched, it got a new entry from the distributed +list — `Revision 2691`, a `TitleId` — and the writer started; *Wreckfest 2* +did the same. The person's hand-made entry had been shadowing Microsoft's +own. So the class splits in two: titles ticked before Microsoft listed them, +which unticking brings back, and titles Microsoft does not list at all — +*The Other Side* on this machine — which only Lot 15 reaches. + ## Why a user-session program and not a Windows service - **Session 0 isolation.** A service cannot see the interactive desktop, and diff --git a/docs/design/15-marked-games.md b/docs/design/15-marked-games.md index 3f08abc..aad056e 100644 --- a/docs/design/15-marked-games.md +++ b/docs/design/15-marked-games.md @@ -118,8 +118,9 @@ user pages the same day. ## The instrument, checked 2026-09-23 `presence-probe watch-games [secs] [key]` parks on `RegNotifyChangeKeyValue` -over the list's key — subtree, names and values, `THREAD_AGNOSTIC`, re-armed -after each wake — and on each wake reads the list again and says what moved: +over the list's key — subtree, names and values, `THREAD_AGNOSTIC`, armed +once and re-armed after each wake — and on each wake reads the list again +and says what moved: an entry added or removed, an entry whose `LastAccessed` moved (with how long ago that time is), an entry changed otherwise, or nothing it compares. Each entry is labelled *hand-made* (`Revision = 1`, no `TitleId`) or *listed*. It @@ -135,6 +136,61 @@ is two notifications, not one**: the first saw the entry already complete, the second found nothing new. The settle the watcher will need is measured on the Game Bar's own writes next, not on these. +## The first run, 2026-09-23, 11:20–11:40 + +The maintainer played while the probe watched, and noted the times: +Starfield launched and quit; Wreckfest 2 launched and quit; DS2 launched, +its *Remember this is a game* unticked, relaunched; Wreckfest 2 the same; +*The Other Side* launched, unticked, relaunched, ticked again, quit. What +the probe and the registry said: + +| Time | Game | Presence writer | The game's entry afterwards | +| --- | --- | --- | --- | +| 11:22:44 | Starfield (packaged) | started 11:22:44.844, exited 11:25:52 | `Revision 2`, no `TitleId`, `LastAccessed` 11:22:44, key written 11:22:44.836 | +| 11:26 | Wreckfest 2, ticked by hand | never | — (the ticked entry, removed at 11:34) | +| 11:29 | DS2, ticked by hand | never | — (the ticked entry, removed at 11:30) | +| 11:31:10 | DS2, after the untick | started 11:31:10.129, exited 11:32:32 | **a new entry**: `Revision 2691`, `TitleId 1653303105`, a `GameDVR_GameGUID`; key written 11:31:10.082 | +| 11:35:21 | Wreckfest 2, after the untick | started 11:35:21.756, exited 11:36:31 | **a new entry**: `Revision 2691`, `TitleId 2086335033`; key written 11:35:21.712 | +| 11:39:48 | *The Other Side*, ticked again mid-game | never | `Revision 1`, no `TitleId`, key written 11:39:48.103 | + +Four things, the first of which changes this lot: + +- **A hand-made entry shadows the one Microsoft distributes.** DS2 and + Wreckfest 2 were in Microsoft's list all along — the entries Windows + created for them the moment the hand-made ones were gone carry + `Revision 2691`, and `KGLRevision` read 2691 on 2026-09-20 as it does + today, so the list Windows held when DS2 went unseen already knew it. + While the person's own entry matched, Windows used it — no `TitleId`, no + writer, *Online* in the Xbox overlay. Unticked, the next launch matched + the distributed list, the writer started, the overlay said *Playing*. + The maintainer's reading — Xbox added the game after its release, after + the box had been ticked — fits every figure; when Microsoft's list + gained it is not recorded anywhere this machine can see. *The Other + Side* is the control: not in the list, unticked and relaunched it stayed + unknown, and ticked again it got a `Revision 1` entry and no writer. +- **So there are two classes, not one.** Titles ticked before Microsoft + listed them: a remedy exists today — untick, relaunch — and the program + can say which entries are candidates, since a hand-made entry for an + executable the distributed list also names is exactly that. Titles + Microsoft does not list at all: only the rest of this lot reaches them. +- **Windows writes the entry at every launch, listed or not**, in the + same quarter-second as the writer: the key 8, 47 and 44 ms before the + probe's look found the writer, which looks every 250 ms, so the order + within that quarter-second is not measured. `LastAccessed` moved for + Starfield, a listed packaged title, as it had for the hand-made DS2 on + the 20th. +- **The registry notification did not come.** Not once in twenty minutes, + through the writes the keys' own last-write times confirm — 11:22:44, + 11:31:10, 11:35:21, 11:39:48 — and the removals the unticks made. + The shell the probe ran from is not packaged — `GetCurrentPackageFullName` + said so — so no container stood between it and the hive. The probe + re-armed every 250 ms, which Microsoft documents as piling up waits; on + a scratch key 480 re-arms later a write still woke it in 3 ms, so that is + not shown to be the cause either. The probe now arms once per wake, and + reads the list every 250 ms regardless, to log any change that arrives + *without* a notification as `MISSED`. Its second run started at 11:48. + Until it answers, the notification is not a design this lot can lean on. + ## What it changes in the program, once measured The engine's sensor gains a second question — which listed processes are diff --git a/docs/getting-started.md b/docs/getting-started.md index 70e9d0e..994d756 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -279,8 +279,10 @@ running, Windows itself is not flagging that title — see [How it works](how-it-works.md). One case to know about: a game Windows did not recognise, that you had to mark yourself with *Remember this is a game* in the Game Bar, is not seen by this program yet even with the box -ticked. [How it works](how-it-works.md#how-it-knows-a-game-is-running) says -why, and what is being done about it. +ticked. Try unticking it and starting the game again: Microsoft may have +listed it since, and then Windows — and this program — recognise it on +their own. [How it works](how-it-works.md#how-it-knows-a-game-is-running) +says why, and what is being done about the games Microsoft does not list. **It says another instance is already running.** The background watcher is doing its job. That message means you tried to start a diff --git a/docs/how-it-works.md b/docs/how-it-works.md index 51ceec8..dcb5b3f 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -36,10 +36,21 @@ ticking *Remember this is a game*. Windows treats it as a game from then on — overlay, capture, Game Mode — but does not start the process this program watches for it, because that process exists to tell Xbox what you are playing and a title you named by hand has no Xbox identity to tell. So -the watcher stays quiet on such a title, whatever the toggle says. The next -piece of work, [Lot 15](design/15-marked-games.md), makes the watcher read -Windows' list as well, so that a title you marked counts — at its launch, -or at the moment you tick it. +the watcher stays quiet on such a title, whatever the toggle says. + +Often there is a way out today. Microsoft adds games to its list after +they are released, and a box you ticked before that keeps Windows on your +own entry rather than its own — measured on 2026-09-23 with *Death +Stranding 2* and *Wreckfest 2*, both listed by Microsoft by then and both +unseen until the box was unticked. So for a game you once marked by hand: +open the Game Bar over it, **untick** *Remember this is a game*, quit the +game and start it again. If Microsoft knows it now, Windows recognises it +by itself — the Xbox overlay shows you *playing* it — and so does this +program. If it does not, the Game Bar offers the box again and you can +tick it back. +The next piece of work, [Lot 15](design/15-marked-games.md), makes the +watcher read Windows' list as well, so that a title only you marked +counts — at its launch, or at the moment you tick it. Which process to watch is read from the registry at startup rather than hard-coded, so a machine where that registration differs still works. diff --git a/src/bin/presence-probe.rs b/src/bin/presence-probe.rs index 92744b2..16f92b3 100644 --- a/src/bin/presence-probe.rs +++ b/src/bin/presence-probe.rs @@ -384,6 +384,43 @@ fn ago(filetime: u64) -> String { } } +/// Say what moved between two reads of the list, each line tagged; returns +/// how many lines it said. +fn diff(before: &[Entry], after: &[Entry], tag: &str) -> usize { + let mut said = 0; + for entry in after { + match before.iter().find(|old| old.name == entry.name) { + None => { + said += 1; + log(&format!("watch-games: {tag} ADDED {}", entry.label())); + } + Some(old) if old.last_accessed != entry.last_accessed => { + said += 1; + log(&format!( + "watch-games: {tag} LastAccessed moved for {} -> {}", + entry.label(), + entry.last_accessed.map_or("(none)".to_owned(), ago) + )); + } + Some(old) if old != entry => { + said += 1; + log(&format!( + "watch-games: {tag} changed (not LastAccessed) {}", + entry.label() + )); + } + Some(_) => {} + } + } + for old in before { + if !after.iter().any(|entry| entry.name == old.name) { + said += 1; + log(&format!("watch-games: {tag} REMOVED {}", old.label())); + } + } + said +} + /// Park on a change notification for Windows' game list and say what moved: /// entries added, removed, or touched -- and which `LastAccessed` moved, /// which is the question Lot 15 asks: does Windows touch the entry of every @@ -427,12 +464,13 @@ fn cmd_watch_games(seconds: u64, key: &str) -> windows::core::Result<()> { } )); - let started = std::time::Instant::now(); - let mut wakeups = 0u32; - while started.elapsed().as_secs() < seconds { - // One-shot: re-armed before every wait. THREAD_AGNOSTIC so the event - // could be waited on from another thread, which the watcher will. - // SAFETY: `root` outlives the call, `event` is a live event handle. + // One-shot, and armed once per wake: each call while one is pending adds + // another wait on the key, which Microsoft documents as a leak -- the + // first version of this probe re-armed every 250 ms. THREAD_AGNOSTIC so + // the event could be waited on from another thread, which the watcher + // will. + let arm = || { + // SAFETY: `root` outlives every call, `event` is a live event handle. let armed = unsafe { RegNotifyChangeKeyValue( root.raw(), @@ -446,9 +484,20 @@ fn cmd_watch_games(seconds: u64, key: &str) -> windows::core::Result<()> { log(&format!( "watch-games: RegNotifyChangeKeyValue failed ({armed:?})" )); - break; } - // Wake every 250 ms anyway, to log the writer coming and going. + armed.is_ok() + }; + if !arm() { + return Ok(()); + } + + let started = std::time::Instant::now(); + let mut wakeups = 0u32; + let mut missed = 0u32; + while started.elapsed().as_secs() < seconds { + // Wake every 250 ms anyway, to log the writer coming and going and + // to read the list without being told: a change found that way is + // one the notification did not deliver, which is the other question. // SAFETY: `event` is live for the whole loop. let woke = unsafe { WaitForSingleObject(event, 250) } == WAIT_OBJECT_0; let at = started.elapsed().as_secs_f32(); @@ -465,57 +514,29 @@ fn cmd_watch_games(seconds: u64, key: &str) -> windows::core::Result<()> { } writer = current_writer; - if !woke { - continue; - } - wakeups += 1; let after = read_entries(key); - let mut said = 0; - for entry in &after { - match before.iter().find(|old| old.name == entry.name) { - None => { - said += 1; - log(&format!( - "watch-games: #{wakeups} +{at:.1}s ADDED {}", - entry.label() - )); - } - Some(old) if old.last_accessed != entry.last_accessed => { - said += 1; - log(&format!( - "watch-games: #{wakeups} +{at:.1}s LastAccessed moved for {} -> {}", - entry.label(), - entry.last_accessed.map_or("(none)".to_owned(), ago) - )); - } - Some(old) if old != entry => { - said += 1; - log(&format!( - "watch-games: #{wakeups} +{at:.1}s changed (not LastAccessed) {}", - entry.label() - )); - } - Some(_) => {} - } - } - for old in &before { - if !after.iter().any(|entry| entry.name == old.name) { - said += 1; - log(&format!( - "watch-games: #{wakeups} +{at:.1}s REMOVED {}", - old.label() - )); - } + let tag = if woke { + wakeups += 1; + format!("#{wakeups} +{at:.1}s") + } else { + format!("MISSED +{at:.1}s, no notification:") + }; + let said = diff(&before, &after, &tag); + if !woke && said > 0 { + missed += 1; } - if said == 0 { + if woke && said == 0 { log(&format!( - "watch-games: #{wakeups} +{at:.1}s notification, nothing the probe compares moved" + "watch-games: {tag} notification, nothing the probe compares moved" )); } before = after; + if woke && !arm() { + break; + } } log(&format!( - "watch-games: done, {wakeups} wake-ups in {seconds}s" + "watch-games: done, {wakeups} wake-ups and {missed} changes found without one in {seconds}s" )); // SAFETY: the event created above, closed once. unsafe { _ = CloseHandle(event) }; From 635b1998006991344b5b48657f3da40987be7f6f Mon Sep 17 00:00:00 2001 From: Geoffrey Vancoetsem <10533139+geeooff@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:06:42 +0200 Subject: [PATCH 04/12] Record the second probe run: the registry notification never comes Seven writes to Windows' game list -- Starfield's launch, three launches of The Other Side, an untick and a tick mid-game -- and not one change notification; the probe found each by reading the list every 250 ms. The lot's plan to park on RegNotifyChangeKeyValue is contradicted in place, and replaced by what the runs decide: no new polling -- the second signal reads the process snapshot the idle poll already takes, matching hand-made entries by exact path -- and the list is read again only when its key's last-write time moves, which a tick or an untick does. Three measurements left before building. Co-Authored-By: Claude Opus 5.5 --- docs/design/15-marked-games.md | 83 ++++++++++++++++++++++++++++++++-- 1 file changed, 80 insertions(+), 3 deletions(-) diff --git a/docs/design/15-marked-games.md b/docs/design/15-marked-games.md index aad056e..4af45cc 100644 --- a/docs/design/15-marked-games.md +++ b/docs/design/15-marked-games.md @@ -6,7 +6,7 @@ below. It waits only for [Lot 9](09-robustness.md)'s configuration work to close its field run. - [x] The instrument: a `watch-games` command in `presence-probe` that logs registry change notifications on Windows' game list and which entry's `LastAccessed` moved — built 2026-09-23 and checked on a scratch key, below -- [ ] The measurements below, before any line of the watcher changes +- [ ] The measurements below, before any line of the watcher changes — the first two answered 2026-09-23: Windows writes the entry at every launch, and the registry notification never comes; three to go - [ ] Detection from Windows' list as well as from the presence writer: a hand-marked title is a session from its launch - [ ] A title marked *while it runs* becomes a session within the settle time, and the start commands run then - [ ] The idle cost measured and written down: no polling of the registry, and whatever polling of processes remains, with its figure @@ -52,7 +52,9 @@ to GameDVR, to Game Mode, and nothing for the writer to write. Inferred from the three facts above, not read in any documentation; the figures are the record. It also explains the nephew's machine on 2026-09-18: `chrome.exe` was a hand-made entry there, the writer had been started by Overwatch, and -Chrome only supplied the first name. +Chrome only supplied the first name. (Corrected 2026-09-23: that Chrome's +entry there is hand-made is inferred — nobody has read that registry — and +the first run below amends the rest of this paragraph.) The detection page promised that "anything Windows treats as a game" triggers the watcher. That was true of every title measured before this @@ -77,7 +79,10 @@ user pages the same day. park on beside the stop event — documented, unelevated for `HKCU`, and the same shape as the writer's handle. The Game Bar writes several values into a new entry, so the wake-up settles for a moment before the list is read - again, as the configuration reload does with its folder. + again, as the configuration reload does with its folder. **Contradicted + by measurement on 2026-09-23**: the notification never comes for the Game + Bar's writes — see the second run below. What replaces it is in *What + the runs decide*. - **The cost while idle is the budget.** Today it is one process lookup every `poll_interval`. Whatever the design below adds while no game runs is measured and written here before it is accepted. @@ -191,8 +196,80 @@ Four things, the first of which changes this lot: *without* a notification as `MISSED`. Its second run started at 11:48. Until it answers, the notification is not a design this lot can lean on. +## The second run, 2026-09-23, 11:48–12:05 + +The probe armed once per wake this time, and read the list every 250 ms +whatever happened, logging a change found without a notification as +`MISSED`. The maintainer launched Starfield, then *The Other Side* three +times — the first stuck on a black loading screen, killed — and on the +third unticked and ticked the box mid-game. + +| Time | What happened | The probe | +| --- | --- | --- | +| 11:58:42.151 | Starfield launched | writer started; **MISSED** `LastAccessed` moved, 6 ms later in the same look | +| 12:00:14 | Starfield quit | writer exited | +| 12:00:47, 12:02:31, 12:03:45 | *The Other Side* launched three times | **MISSED** `LastAccessed` moved, each time; no writer, ever | +| 12:04:07 | the box unticked, game running | **MISSED** REMOVED, the hand-made entry `1782914d` | +| 12:04:31 | the box ticked again, game running | **MISSED** ADDED, a new hand-made entry `e2467066` | + +Seven writes, seven changes found by reading, **no notification at all**. +The first run's silence was not the re-arming: the notification is not +delivered for these writes, to this unpackaged, unelevated reader, while +the same code on a key under `HKCU\Software` wakes within milliseconds. Why +is not established — the Game Bar is a packaged application and its writes +may reach the hive through a layer the notification does not watch — and it +does not need to be: it is measured twice, and a design that waits on it +waits forever. + +What the run confirms besides: + +- **Every launch writes the entry**, listed or hand-made, a launch that + hangs on its loading screen included. +- **A tick mid-game creates an entry at once**, with a new key name; an + untick removes it. One tick is one change for a reader that looks every + 250 ms; the Game Bar writes the entry's values close enough together that + no look saw it half-made. +- **The hand-made mark is `Revision = 1`** on every entry known to have + been ticked by hand — DS2, Wreckfest 2, *The Other Side* twice — and on + the two other entries of that shape here, Fallout and 3DMark. Listed + entries carry the distributed revision, 2691 here, or 2 for packaged + titles. + +## What the runs decide + +- **No notification, and no new polling either.** The watcher already takes + a process snapshot every `poll_interval` while no game runs, to look for + the presence writer. The second signal reads the same snapshot: a running + process whose full path is the `MatchedExeFullPath` of a hand-made entry + is a session. Names are compared first, from the snapshot, and only a + name that matches is asked for its full path, so the cost of a poll is a + few string comparisons more than today. +- **The list is read again only when it changed**: one `RegQueryInfoKey` on + the list's key per poll, for its last-write time, which a tick or an + untick moves — measured on 2026-09-23, 11:39:48.102 for the key and + 11:39:48.103 for the entry the tick created. A launch that only moves a + `LastAccessed` does not move it, and does not need to: the executable is + on the list already. So a tick mid-game becomes a session at the next + poll — two seconds by default — and no sooner, which is the price of not + polling harder. +- **Hand-made entries only, by exact path.** Listed titles are the writer's; + a hand-made entry shadowing a listed one is matched like any other. The + parent-directory and package rules that *naming* uses stay out of + detection: they match too loosely to decide that a session exists. +- **A title ticked by mistake becomes a session whenever it runs** — a + browser, say. That is what Windows was told, and the Game Bar's own box + is how to take it back. `status` will list the hand-made entries the + watcher follows, and the log will name the entry a session started + from, so the person can find which box to untick. + ## What it changes in the program, once measured +Still to measure before building: what a snapshot and the path lookups +cost on this machine (the third item above), whether an elevated game +grants `SYNCHRONIZE` to the watcher (the fourth), and the end of a session +when the box is unticked mid-game (the fifth — the entry goes at once, so +the session can end at the next poll). + The engine's sensor gains a second question — which listed processes are running, and a handle to wait on for one of them — and its loop treats "the writer runs" and "a listed process runs" as one session with two From 8f6a5c1ea5400a289b1dab6c1828f98633e7fab8 Mon Sep 17 00:00:00 2001 From: Geoffrey Vancoetsem <10533139+geeooff@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:08:45 +0200 Subject: [PATCH 05/12] Measure what the second signal costs a poll, and an elevated process `presence-probe cost` times the idle poll's steps over 300 rounds: the process snapshot the poll already takes is about 4 ms, the hand-made entries' names compared against it 13 us, the list key's last-write time 11 us, and a full-path query, paid only for a name that matches, 55 us. The second signal adds about 25 us to a 4 ms poll and nothing that wakes the watcher. FanControl, elevated through its task, granted SYNCHRONIZE and the limited query to an unelevated reader, so an elevated game can be waited on. The measurements box of Lot 15 is ticked; the untick mid-game is left to the field run. Co-Authored-By: Claude Opus 5.5 --- docs/design/15-marked-games.md | 35 +++++++-- src/bin/presence-probe.rs | 131 ++++++++++++++++++++++++++++++++- 2 files changed, 158 insertions(+), 8 deletions(-) diff --git a/docs/design/15-marked-games.md b/docs/design/15-marked-games.md index 4af45cc..5269500 100644 --- a/docs/design/15-marked-games.md +++ b/docs/design/15-marked-games.md @@ -6,7 +6,7 @@ below. It waits only for [Lot 9](09-robustness.md)'s configuration work to close its field run. - [x] The instrument: a `watch-games` command in `presence-probe` that logs registry change notifications on Windows' game list and which entry's `LastAccessed` moved — built 2026-09-23 and checked on a scratch key, below -- [ ] The measurements below, before any line of the watcher changes — the first two answered 2026-09-23: Windows writes the entry at every launch, and the registry notification never comes; three to go +- [x] The measurements below, before any line of the watcher changes — 2026-09-23: Windows writes the entry at every launch, the registry notification never comes, the second signal adds about 25 us to a 4 ms poll, an elevated process grants `SYNCHRONIZE`; the untick mid-game is left to the field run - [ ] Detection from Windows' list as well as from the presence writer: a hand-marked title is a session from its launch - [ ] A title marked *while it runs* becomes a session within the settle time, and the start commands run then - [ ] The idle cost measured and written down: no polling of the registry, and whatever polling of processes remains, with its figure @@ -262,13 +262,34 @@ What the run confirms besides: watcher follows, and the log will name the entry a session started from, so the person can find which box to untick. -## What it changes in the program, once measured +## The cost, and an elevated process, 2026-09-23 + +`presence-probe cost` times, over 300 rounds ten milliseconds apart, the +release build on this machine with 282 processes and 3 hand-made entries: -Still to measure before building: what a snapshot and the path lookups -cost on this machine (the third item above), whether an elevated game -grants `SYNCHRONIZE` to the watcher (the fourth), and the end of a session -when the box is unticked mid-game (the fifth — the entry goes at once, so -the session can end at the next poll). +| Step | Median | 95th | Max | +| --- | --- | --- | --- | +| the process snapshot, which the idle poll takes today | 4181 us | 4609 us | 5334 us | +| the writer found in it, today | 3 us | 3 us | 6 us | +| the hand-made entries' names compared against it | 13 us | 13 us | 14 us | +| one full-path query, paid only for a name that matches | 55 us | 64 us | 167 us | +| the list key's last-write time | 11 us | 13 us | 17 us | + +The second signal adds about 25 us to a poll that already costs about 4 ms +— the snapshot is the price, and it is paid today. Nothing new wakes the +watcher: the same poll, every `poll_interval`, two seconds by default. + +And the fourth question: FanControl, which runs elevated here through its +scheduled task, granted both `SYNCHRONIZE` and +`PROCESS_QUERY_LIMITED_INFORMATION` to the unelevated shell — so an +elevated game can be waited on and its path read. A protected process, as +some anti-cheat runs, is not measured. + +The fifth, the untick mid-game, is behaviour rather than a question: the +entry goes at once (12:04:07 above), so the session ends at the next poll +and the stop commands run. The field run checks it. + +## What it changes in the program, once measured The engine's sensor gains a second question — which listed processes are running, and a handle to wait on for one of them — and its loop treats diff --git a/src/bin/presence-probe.rs b/src/bin/presence-probe.rs index 16f92b3..6f574ff 100644 --- a/src/bin/presence-probe.rs +++ b/src/bin/presence-probe.rs @@ -19,6 +19,7 @@ //! presence-probe watch-games [secs] [key] //! log what Windows writes to its game list, and when; //! `key`, under HKCU, checks the probe on one you can write +//! presence-probe cost [rounds] time what an idle poll costs, today and with Lot 15 //! presence-probe activate activate the class ourselves and time it //! ``` @@ -543,6 +544,128 @@ fn cmd_watch_games(seconds: u64, key: &str) -> windows::core::Result<()> { Ok(()) } +// ------------------------------------------------------ what a poll costs -- + +/// Median, 95th percentile and maximum of `samples`, in microseconds. +fn spread(samples: &mut [std::time::Duration]) -> String { + samples.sort(); + let at = |q: f64| samples[((samples.len() - 1) as f64 * q).round() as usize]; + format!( + "median {:>6.0} us, p95 {:>6.0} us, max {:>6.0} us", + at(0.5).as_secs_f64() * 1e6, + at(0.95).as_secs_f64() * 1e6, + at(1.0).as_secs_f64() * 1e6 + ) +} + +/// Time, over `rounds` rounds, what the watcher's idle poll does today and +/// what Lot 15's second signal would add to it: the process snapshot the +/// poll already takes, the writer's lookup in it, the hand-made entries' +/// file names compared against the same snapshot, one full-path query -- +/// what a name that matches would cost -- and the last-write time of the +/// list's key, which says whether the list must be read again. +fn cmd_cost(rounds: usize) -> windows::core::Result<()> { + use game_mode_executor::detect::process::{Snapshot, full_path}; + use game_mode_executor::registry::Key; + use std::time::{Duration, Instant}; + use windows::Win32::Foundation::FILETIME; + use windows::Win32::System::Registry::RegQueryInfoKeyW; + + let exe = writer_exe(); + let hand_made: Vec = read_entries(GAME_LIST) + .into_iter() + .filter(|entry| entry.revision == Some(1) && entry.title_id.is_none()) + .filter_map(|entry| entry.exe) + .filter_map(|exe| exe.rsplit(['\\', '/']).next().map(str::to_ascii_lowercase)) + .collect(); + let Ok(root) = Key::open_current_user(GAME_LIST) else { + log("cost: cannot open the game list"); + return Ok(()); + }; + + let mut snapshot = Vec::with_capacity(rounds); + let mut writer = Vec::with_capacity(rounds); + let mut names = Vec::with_capacity(rounds); + let mut path = Vec::with_capacity(rounds); + let mut stamp = Vec::with_capacity(rounds); + let mut processes = 0; + let mut matched = 0; + for _ in 0..rounds { + let started = Instant::now(); + let Ok(taken) = Snapshot::take() else { + continue; + }; + snapshot.push(started.elapsed()); + processes = taken.processes.len(); + + let started = Instant::now(); + let _ = presence_writer::find_in(&taken, &exe); + writer.push(started.elapsed()); + + let started = Instant::now(); + matched = taken + .processes + .iter() + .filter(|process| hand_made.contains(&process.name.to_ascii_lowercase())) + .count(); + names.push(started.elapsed()); + + let started = Instant::now(); + let _ = full_path(std::process::id()); + path.push(started.elapsed()); + + let started = Instant::now(); + let mut written = FILETIME::default(); + // SAFETY: `root` is open for the call; only the last-write time is + // asked for, into a local that outlives it. + let _ = unsafe { + RegQueryInfoKeyW( + root.raw(), + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + Some(&mut written), + ) + }; + stamp.push(started.elapsed()); + + std::thread::sleep(Duration::from_millis(10)); + } + log(&format!( + "cost: {} rounds, {processes} processes, {} hand-made entries, {matched} running", + snapshot.len(), + hand_made.len() + )); + log(&format!( + "cost: process snapshot {}", + spread(&mut snapshot) + )); + log(&format!( + "cost: writer found in it (today) {}", + spread(&mut writer) + )); + log(&format!( + "cost: hand-made names compared {}", + spread(&mut names) + )); + log(&format!( + "cost: one full-path query {}", + spread(&mut path) + )); + log(&format!( + "cost: the list's last-write time {}", + spread(&mut stamp) + )); + Ok(()) +} + fn main() -> windows::core::Result<()> { let seconds = || { std::env::args() @@ -558,10 +681,16 @@ fn main() -> windows::core::Result<()> { cmd_watch_games(seconds(), key.as_deref().unwrap_or(GAME_LIST)) } Some("activate") => cmd_activate(5, 60), + Some("cost") => cmd_cost( + std::env::args() + .nth(2) + .and_then(|value| value.parse().ok()) + .unwrap_or(200), + ), Some(other) => { eprintln!("unknown command `{other}`"); eprintln!( - "usage: presence-probe [status|watch [seconds]|watch-games [seconds]|activate]" + "usage: presence-probe [status|watch [seconds]|watch-games [seconds]|cost [rounds]|activate]" ); std::process::exit(2); } From 06c9198a5254472ea5bf3d0fc65efa08b32f2ae9 Mon Sep 17 00:00:00 2001 From: Geoffrey Vancoetsem <10533139+geeooff@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:50:45 +0200 Subject: [PATCH 06/12] Try nine ways of being told the game list changed; WMI is told On the maintainer's question -- was the notification the wrong one, or asked the wrong way? -- `presence-probe watch-methods` armed eight RegNotifyChangeKeyValue variants at once: through HKEY_USERS\, through RegOpenCurrentUser, without the subtree, on the parent key and on all of HKCU\System with every filter, on each entry on its own, and synchronously on a blocking thread. A ninth, WMI's RegistryTreeChangeEvent, ran from an unelevated PowerShell. Across a Starfield launch and a tick and untick of The Other Side, WMI fired for every write, as it happened; the eight fired once between them, the removed entry waking its own handle. The record keeps the table and the WMI provider's cost as far as an unelevated reader can see it. Co-Authored-By: Claude Opus 5.5 --- docs/design/15-marked-games.md | 37 ++++++ src/bin/presence-probe.rs | 224 +++++++++++++++++++++++++++++++++ 2 files changed, 261 insertions(+) diff --git a/docs/design/15-marked-games.md b/docs/design/15-marked-games.md index 5269500..8588e9c 100644 --- a/docs/design/15-marked-games.md +++ b/docs/design/15-marked-games.md @@ -235,6 +235,43 @@ What the run confirms besides: entries carry the distributed revision, 2691 here, or 2 for packaged titles. +## The third run: nine ways of being told, 2026-09-23, 12:40–12:45 + +The maintainer's question after the second run: was the notification the +wrong one, or asked the wrong way? The first two runs had tried one way. +`presence-probe watch-methods` armed eight at once, each on its own event — +the predefined `HKCU` handle over the list's subtree as before; the same key +opened through `HKEY_USERS\`, and through `RegOpenCurrentUser`; the list +alone without its subtree; the parent `GameConfigStore` and all of +`HKCU\System` with every filter; each of the 141 entries watched on its own; +and the call made synchronously on a thread that blocks in it. A ninth ran +beside it, outside the probe: WMI's `RegistryTreeChangeEvent` on +`HKEY_USERS\\System\GameConfigStore\Children`, from an unelevated +PowerShell. Starfield launched at 12:42 and quit; *The Other Side* launched +at 12:44, unticked, ticked again, quit. + +| Change | Found by reading | WMI | The eight `RegNotifyChangeKeyValue` ways | +| --- | --- | --- | --- | +| Starfield's `LastAccessed`, launch | 12:42:09.407 | three events, 12:42:09.257–.267 | none | +| *The Other Side*'s `LastAccessed`, launch | 12:44:26.120 | two, 12:44:25.978–.986 | none | +| its entry removed, the untick | 12:44:44.463 | one, 12:44:44.454 | only the entry's own handle, 12:44:44.452 — then `ERROR_KEY_DELETED` on re-arming it | +| a new entry, the tick | 12:44:49.464 | four, 12:44:49.411–.424 | none | + +So Windows does report these writes, as they happen — to WMI's registry +provider, which runs in `WmiPrvSE.exe` under a system account — and does not +report them to an ordinary process asking the same question of the same +key, whichever way it asks, except that deleting a key still wakes whoever +watches that very key. Why is not established, and this record does not +guess further than that. + +What WMI costs could only be seen from outside, the provider's process not +being open to an unelevated reader: over one idle minute with the +subscription, the busier of the two `WmiPrvSE` processes averaged 0.78 % of +a core, and 0.68 % over the next minute with the subscription gone — the +same, other programs on this machine use WMI too — and the only difference +the counters show is one burst of about twenty I/O operations in the minute +with it. + ## What the runs decide - **No notification, and no new polling either.** The watcher already takes diff --git a/src/bin/presence-probe.rs b/src/bin/presence-probe.rs index 6f574ff..fe78528 100644 --- a/src/bin/presence-probe.rs +++ b/src/bin/presence-probe.rs @@ -19,6 +19,8 @@ //! presence-probe watch-games [secs] [key] //! log what Windows writes to its game list, and when; //! `key`, under HKCU, checks the probe on one you can write +//! presence-probe watch-methods +//! several ways of being told the game list changed, at once //! presence-probe cost [rounds] time what an idle poll costs, today and with Lot 15 //! presence-probe activate activate the class ourselves and time it //! ``` @@ -666,6 +668,221 @@ fn cmd_cost(rounds: usize) -> windows::core::Result<()> { Ok(()) } +// ------------------------------------ other ways of being told of a change -- + +/// Arm several ways of asking Windows to say when the game list changes, at +/// once, each on its own event, and log which of them fire -- beside the +/// changes found by reading the list every 250 ms. The first two runs used +/// one way only, the predefined `HKCU` handle over the list's subtree, and +/// it never fired for the Game Bar's writes; this asks whether another way +/// does. `sid` is the account's, for the `HKEY_USERS` path. +fn cmd_watch_methods(seconds: u64, sid: &str) -> windows::core::Result<()> { + use windows::Win32::Foundation::{CloseHandle, HANDLE, WAIT_OBJECT_0}; + use windows::Win32::System::Registry::{ + HKEY, HKEY_CURRENT_USER, HKEY_USERS, KEY_READ, REG_NOTIFY_CHANGE_ATTRIBUTES, + REG_NOTIFY_CHANGE_LAST_SET, REG_NOTIFY_CHANGE_NAME, REG_NOTIFY_CHANGE_SECURITY, + REG_NOTIFY_FILTER, REG_NOTIFY_THREAD_AGNOSTIC, RegNotifyChangeKeyValue, RegOpenCurrentUser, + RegOpenKeyExW, + }; + use windows::Win32::System::Threading::{ + CreateEventW, WaitForMultipleObjects, WaitForSingleObject, + }; + use windows::core::HSTRING; + + fn open(root: HKEY, path: &str) -> Option { + let mut key = HKEY::default(); + // SAFETY: the name outlives the call and `key` is a local out + // pointer. The keys are never closed: the process ends with them. + let rc = unsafe { RegOpenKeyExW(root, &HSTRING::from(path), None, KEY_READ, &mut key) }; + if rc.is_err() { + log(&format!("methods: cannot open `{path}` ({rc:?})")); + return None; + } + Some(key) + } + + /// One way of being told: the keys it watches, how, and its event. + struct Method { + name: String, + keys: Vec, + subtree: bool, + filter: REG_NOTIFY_FILTER, + event: HANDLE, + fired: u32, + } + + impl Method { + fn arm(&self) { + for key in &self.keys { + // SAFETY: the key is open for the life of the process and + // the event outlives the loop. + let armed = unsafe { + RegNotifyChangeKeyValue( + *key, + self.subtree, + self.filter | REG_NOTIFY_THREAD_AGNOSTIC, + Some(self.event), + true, + ) + }; + if armed.is_err() { + log(&format!( + "methods: {} could not be armed ({armed:?})", + self.name + )); + } + } + } + } + + let usual = REG_NOTIFY_CHANGE_NAME | REG_NOTIFY_CHANGE_LAST_SET; + let every = usual | REG_NOTIFY_CHANGE_ATTRIBUTES | REG_NOTIFY_CHANGE_SECURITY; + let mut current_user = HKEY::default(); + // SAFETY: a local out pointer. + let _ = unsafe { RegOpenCurrentUser(KEY_READ.0, &mut current_user) }; + let entries: Vec = read_entries(GAME_LIST) + .iter() + .filter_map(|entry| open(HKEY_CURRENT_USER, &format!(r"{GAME_LIST}\{}", entry.name))) + .collect(); + + let specs: Vec<(String, Vec, bool, REG_NOTIFY_FILTER)> = vec![ + ( + "1 HKCU, the list, subtree (the first two runs)".into(), + open(HKEY_CURRENT_USER, GAME_LIST).into_iter().collect(), + true, + usual, + ), + ( + r"2 HKEY_USERS\, the list, subtree".into(), + open(HKEY_USERS, &format!(r"{sid}\{GAME_LIST}")) + .into_iter() + .collect(), + true, + usual, + ), + ( + "3 RegOpenCurrentUser, the list, subtree".into(), + open(current_user, GAME_LIST).into_iter().collect(), + true, + usual, + ), + ( + "4 HKCU, the list, its own subkeys only".into(), + open(HKEY_CURRENT_USER, GAME_LIST).into_iter().collect(), + false, + usual, + ), + ( + "5 HKCU, GameConfigStore, subtree, every filter".into(), + open(HKEY_CURRENT_USER, r"System\GameConfigStore") + .into_iter() + .collect(), + true, + every, + ), + ( + "6 HKCU, System, subtree, every filter".into(), + open(HKEY_CURRENT_USER, "System").into_iter().collect(), + true, + every, + ), + ( + format!("7 each of the {} entries on its own", entries.len()), + entries, + false, + every, + ), + ]; + let mut methods: Vec = specs + .into_iter() + .filter(|(_, keys, _, _)| !keys.is_empty()) + .map(|(name, keys, subtree, filter)| Method { + name, + keys, + subtree, + filter, + // SAFETY: no security attributes, no name; auto-reset. Never + // closed: the process ends with it. + event: unsafe { CreateEventW(None, false, false, None) }.unwrap_or_default(), + fired: 0, + }) + .collect(); + for method in &methods { + method.arm(); + log(&format!("methods: armed {}", method.name)); + } + + // 8: the same question asked synchronously, on a thread of its own that + // blocks inside the call until it is answered. + std::thread::spawn(|| { + let Some(key) = open(HKEY_CURRENT_USER, GAME_LIST) else { + return; + }; + loop { + // SAFETY: the key is open for the life of the process; no event, + // the call blocks this thread until a change or an error. + let answered = unsafe { + RegNotifyChangeKeyValue( + key, + true, + REG_NOTIFY_CHANGE_NAME | REG_NOTIFY_CHANGE_LAST_SET, + None, + false, + ) + }; + log(&format!( + "methods: FIRED 8 synchronous, HKCU, the list, subtree ({answered:?})" + )); + if answered.is_err() { + return; + } + } + }); + log("methods: armed 8 synchronous, HKCU, the list, subtree"); + + let events: Vec = methods.iter().map(|method| method.event).collect(); + let exe = writer_exe(); + let mut writer = presence_writer::running_pid(&exe); + let mut before = read_entries(GAME_LIST); + let started = std::time::Instant::now(); + while started.elapsed().as_secs() < seconds { + // SAFETY: every event is live for the whole loop. + let first = unsafe { WaitForMultipleObjects(&events, false, 250) }; + let at = started.elapsed().as_secs_f32(); + for (index, method) in methods.iter_mut().enumerate() { + // The one the wait returned was reset by it; the others are + // asked without waiting, which resets them too. + let fired = first.0 == WAIT_OBJECT_0.0 + index as u32 + // SAFETY: a live event, not waited on. + || unsafe { WaitForSingleObject(method.event, 0) } == WAIT_OBJECT_0; + if fired { + method.fired += 1; + log(&format!("methods: +{at:.1}s FIRED {}", method.name)); + method.arm(); + } + } + let current_writer = presence_writer::running_pid(&exe); + match (writer, current_writer) { + (None, Some(pid)) => log(&format!("methods: +{at:.1}s WRITER STARTED pid {pid}")), + (Some(pid), None) => log(&format!("methods: +{at:.1}s WRITER EXITED pid {pid}")), + _ => {} + } + writer = current_writer; + let after = read_entries(GAME_LIST); + diff(&before, &after, &format!("+{at:.1}s CHANGE")); + before = after; + } + for method in &methods { + log(&format!( + "methods: {} fired {} times", + method.name, method.fired + )); + // SAFETY: the event created above, closed once, after the loop. + unsafe { _ = CloseHandle(method.event) }; + } + Ok(()) +} + fn main() -> windows::core::Result<()> { let seconds = || { std::env::args() @@ -681,6 +898,13 @@ fn main() -> windows::core::Result<()> { cmd_watch_games(seconds(), key.as_deref().unwrap_or(GAME_LIST)) } Some("activate") => cmd_activate(5, 60), + Some("watch-methods") => match std::env::args().nth(3) { + Some(sid) => cmd_watch_methods(seconds(), &sid), + None => { + eprintln!("usage: presence-probe watch-methods "); + std::process::exit(2); + } + }, Some("cost") => cmd_cost( std::env::args() .nth(2) From 744bff21b712ca26b5bc475d1082cea190fc3792 Mon Sep 17 00:00:00 2001 From: Geoffrey Vancoetsem <10533139+geeooff@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:04:58 +0200 Subject: [PATCH 07/12] Record why the notification may be silent, and a poll 130 times cheaper The maintainer set WMI aside until the reason is known and asked for the literature. Microsoft's page for RegNotifyChangeKeyValue says it cannot see changes made by RegRestoreKey; Raymond Chen explains why a deleted key ends its notifications, which accounts for the removed entry and not for Starfield; WindowsAppSDK issue 4075 reports the same silence the other way round, for a packaged reader. Two documented mechanisms fit -- a restore, or a write through a packaged app's merged registry -- and neither is confirmed; Process Monitor would show which. Asked whether a longer interval would spend less: the probe's `cost` now times K32EnumProcesses, the process ids alone, at 32 us against the Toolhelp snapshot's 4 ms. A poll that names only processes it has not seen would cost about 50 us, so the interval need not move; process id reuse is the risk to measure first. Co-Authored-By: Claude Opus 5.5 --- Cargo.toml | 3 ++ docs/design/15-marked-games.md | 51 ++++++++++++++++++++++++++++++++++ src/bin/presence-probe.rs | 21 ++++++++++++++ 3 files changed, 75 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index 29e132d..152288f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,6 +61,9 @@ windows = { version = "0.62", features = [ "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_System_Diagnostics_ToolHelp", + # K32EnumProcesses: the process ids alone, measured against the Toolhelp + # snapshot as the idle poll's cost (the probe's `cost`). + "Win32_System_ProcessStatus", "Win32_System_Performance", "Win32_System_SystemInformation", "Win32_System_Threading", diff --git a/docs/design/15-marked-games.md b/docs/design/15-marked-games.md index 8588e9c..0a9d305 100644 --- a/docs/design/15-marked-games.md +++ b/docs/design/15-marked-games.md @@ -272,6 +272,44 @@ same, other programs on this machine use WMI too — and the only difference the counters show is one burst of about twenty I/O operations in the minute with it. +## Why the notification does not come: what the literature says + +Decided 2026-09-23 by the maintainer: WMI is not taken — it adds a COM +client, a dependency on a service, and a fail-over between two sources of +wake-ups, for a behaviour nobody here can explain — until the reason the +direct notification stays silent is known. What was looked for, and found: + +- **Microsoft's page for `RegNotifyChangeKeyValue`** names one kind of change + the function cannot see: *"This function cannot be used to detect changes + to the registry that result from using the RegRestoreKey function."* If + whatever writes the game list does it by restoring keys rather than setting + values, the silence follows. The same page says a second call on a handle + with different parameters *"will succeed but the changes will be ignored"* + — not the case here, every method had its own handle — and that repeated + calls with the same parameters leak waits, which the probe no longer does. +- **Raymond Chen** (*The Old New Thing*, 2020-05-07): a deleted key ends + its notifications for good, and a key recreated under the same name is a + new key nobody is watching. That explains why the removed entry woke its + own handle and nothing else; it does not explain Starfield, whose entry + was not deleted and whose own handle still heard nothing when a value on + it changed. +- **A reported case in the other direction**, `microsoft/WindowsAppSDK` + issue 4075: a *packaged* WinUI 3 application watching `HKCU` receives no + notification where the same code unpackaged does, while reading the value + sees the change. Here the reader is unpackaged and the Game Bar, which + writes, is packaged. Packaged applications see a registry merged from the + real hive and per-application hives; whether their writes through that + merged view reach watchers of the real hive is not documented. +- Nothing found names `GameConfigStore` with change notifications, on + Microsoft's pages, Stack Overflow or elsewhere. + +So two documented mechanisms fit the evidence — a restore, or a write +through a packaged application's merged registry — and neither is +confirmed. What would settle it is seeing the operation itself: Process +Monitor, filtered on `GameConfigStore`, shows which process writes and with +which call. It needs administrator rights to install its driver, on the +maintainer's machine and by the maintainer's hand; the watcher never will. + ## What the runs decide - **No notification, and no new polling either.** The watcher already takes @@ -316,6 +354,19 @@ The second signal adds about 25 us to a poll that already costs about 4 ms — the snapshot is the price, and it is paid today. Nothing new wakes the watcher: the same poll, every `poll_interval`, two seconds by default. +Asked the same day whether the interval should grow to spend less: measured +first whether the snapshot is the only way to see new processes. It is not. +`K32EnumProcesses`, the process ids alone, took **32 us** median over 300 +rounds (p95 38, max 77) against the snapshot's 4068 us, with 290 processes +running — about 130 times less. A poll that lists the ids, and asks only +the processes it has not seen before for their name (52 us each, a handful +a minute on an idle desktop), would cost about 50 us instead of 4 ms: at +two seconds, some 0.003 % of a core instead of 0.2 %. The interval then +stops being the lever, and the two seconds of reaction stay. One risk to +measure before relying on it: Windows reuses process ids, and a game that +takes the id of a process that exited since the last poll would look like +a process already seen. + And the fourth question: FanControl, which runs elevated here through its scheduled task, granted both `SYNCHRONIZE` and `PROCESS_QUERY_LIMITED_INFORMATION` to the unelevated shell — so an diff --git a/src/bin/presence-probe.rs b/src/bin/presence-probe.rs index fe78528..01b9993 100644 --- a/src/bin/presence-probe.rs +++ b/src/bin/presence-probe.rs @@ -590,6 +590,7 @@ fn cmd_cost(rounds: usize) -> windows::core::Result<()> { let mut names = Vec::with_capacity(rounds); let mut path = Vec::with_capacity(rounds); let mut stamp = Vec::with_capacity(rounds); + let mut ids = Vec::with_capacity(rounds); let mut processes = 0; let mut matched = 0; for _ in 0..rounds { @@ -638,6 +639,22 @@ fn cmd_cost(rounds: usize) -> windows::core::Result<()> { }; stamp.push(started.elapsed()); + // The process ids alone, without names: what a poll would pay if it + // only asked for the names of processes it had not seen before. + let started = Instant::now(); + let mut pids = [0u32; 4096]; + let mut needed = 0u32; + // SAFETY: the buffer and its size in bytes are passed together, and + // `needed` is a local out pointer. + let _ = unsafe { + windows::Win32::System::ProcessStatus::K32EnumProcesses( + pids.as_mut_ptr(), + std::mem::size_of_val(&pids) as u32, + &mut needed, + ) + }; + ids.push(started.elapsed()); + std::thread::sleep(Duration::from_millis(10)); } log(&format!( @@ -665,6 +682,10 @@ fn cmd_cost(rounds: usize) -> windows::core::Result<()> { "cost: the list's last-write time {}", spread(&mut stamp) )); + log(&format!( + "cost: process ids alone {}", + spread(&mut ids) + )); Ok(()) } From 9288f740dea9165a394322a78ecbe9dc42568b6d Mon Sep 17 00:00:00 2001 From: Geoffrey Vancoetsem <10533139+geeooff@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:20:32 +0200 Subject: [PATCH 08/12] Decide the cheap poll and the hint, from Microsoft's documentation first Process ids: Microsoft documents that an id is unique only while its process lives and can be reused after, and Raymond Chen that the id lasts as long as the process object -- running, or held by a handle. Nothing documents how soon; a spike of 400 short processes at fifty a second saw ids come back after 2.8 s at the soonest, never within 2 s. So the idle poll lists the ids alone, names only processes it has not seen, and keeps a full snapshot every thirty seconds as the net for a reused id: about 0.016 % of a core at two seconds against 0.2 % today. The hint the maintainer asked for: Microsoft's list is the undocumented KnownGameList.bin under the local profile; matched as whole fields -- the executable's name and its folder -- it knows DS2 and Wreckfest 2 and not chrome.exe, and the GUIDs it carries are those of the entries Windows created from it. At start, each hand-made entry it covers gets one info line saying the box can be unticked; a file that does not parse as described gives no hint. Co-Authored-By: Claude Opus 5.5 --- docs/design/15-marked-games.md | 82 ++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/docs/design/15-marked-games.md b/docs/design/15-marked-games.md index 0a9d305..a94f2a4 100644 --- a/docs/design/15-marked-games.md +++ b/docs/design/15-marked-games.md @@ -10,6 +10,8 @@ close its field run. - [ ] Detection from Windows' list as well as from the presence writer: a hand-marked title is a session from its launch - [ ] A title marked *while it runs* becomes a session within the settle time, and the start commands run then - [ ] The idle cost measured and written down: no polling of the registry, and whatever polling of processes remains, with its figure +- [ ] The idle poll made cheap: the process ids alone every `poll_interval`, names only for processes not seen before, a full snapshot every 30 s as the net for a reused id — decided 2026-09-23, below +- [ ] At start, a log line for each hand-made entry Microsoft's own list now covers, so the box can be unticked — asked for by the maintainer 2026-09-23, below - [ ] Verified in the field on DS2 and the other hand-marked titles on the maintainer's machine **Done when** a game the Game Bar knows only because the person ticked @@ -377,6 +379,86 @@ The fifth, the untick mid-game, is behaviour rather than a question: the entry goes at once (12:04:07 above), so the session ends at the next poll and the stop commands run. The field run checks it. +## Process ids, from Microsoft's documentation first — 2026-09-23 + +The maintainer's rule, the same day: Microsoft's documentation first, a +spike only for what it leaves open. What it says: + +- An id identifies a process *"until the process terminates"*, and *"after + the process has terminated, the system can reuse the Id property value for + an unrelated process"* (`System.Diagnostics.Process.Id`); `Win32_Process` + says the same of the WMI class; *Process Handles and Identifiers*, that + the identifier *"is valid from the time the process is created until the + process has been terminated."* +- Raymond Chen gives the exact rule (*When does a process ID become + available for reuse?*, 2011-01-07): the id belongs to the process object, + which lives as long as the process runs *or anyone holds a handle to it*. +- Nothing documents how soon a freed id comes back. + +So the spike measured that: 400 short `cmd.exe /c exit` processes started +one after another in 7.9 s — about fifty a second, far above an idle +desktop's churn — each handle closed as soon as it had exited. 345 distinct +ids; 55 came back, the soonest **2.8 s** after the id's previous process +started, the median 5.6 s, none within 2 s. Reuse is real and can be quick +under churn, and it is not instant. + +What the poll does with that: + +- **Every `poll_interval`, the ids alone** (32 us), compared with the last + poll's; a process not seen before is asked its name (52 us), and its full + path only when that name is the writer's or a hand-made entry's. +- **Every thirty seconds, the full snapshot** it takes today (4 ms), which + names every process afresh. It is the net for an id reused between two + polls: a game that took a freed id is still found, within thirty seconds + instead of two. At an idle desktop's churn that case needs a process to + exit and a game to start under its id within one poll interval; the spike + never saw it happen within two seconds even at fifty processes a second. +- **Holding a handle to every process** would close the gap by the rule + above, and was set aside: a few hundred handles in the watcher, to every + process including games, is a footprint players and anti-cheat both look + at, for a case the net already covers. + +The cost: about 50 us a poll instead of 4 ms, and the snapshot's 4 ms every +thirty seconds — some 0.016 % of a core at two seconds, against 0.2 % +today. The maintainer's own `poll_interval`, raised to five seconds to +spend less, goes back to two once this is in. + +## Microsoft's list itself, and the hint at start — 2026-09-23 + +The maintainer asked for one more thing: say, at start, which hand-made +entries Microsoft's list now covers, so the box can be unticked and Windows +recognise the game by itself. That needs Microsoft's list, which is not the +registry's `Children` key — that holds the entries Windows *created* from +it. The list is a file, `%LOCALAPPDATA%\Microsoft\GameDVR\KnownGameList.bin`, +1.8 MB here, dated 2026-08-18; its header carries 2691 at offset 8, the +`KGLRevision` the registry reports. Its format is not documented. + +What reading it showed: + +- A search for a name is wrong: `DS2.exe` is the end of `borderlands2.exe`, + `chrome.exe` the end of *Blazing Chrome*'s `blazing chrome.exe`. +- The executable's name is a field of its own, UTF-16 and preceded by its + length in bytes as 16 bits; the title's folder names, a GUID and the Xbox + `TitleId` follow. Matched as whole fields, the answers are exactly right: + `DS2.exe` once, with `DEATH STRANDING 2 - ON THE BEACH`; `Wreckfest2.exe` + once, with `wreckfest 2`; `cs2.exe` once, with `Counter-Strike Global + Offensive` and `win64`; `chrome.exe`, *The Other Side*, Fallout and 3DMark + not at all. +- The GUID each record carries is the `GameDVR_GameGUID` of the entry + Windows created from it: `f5ec2e1c-…` for DS2, `24762c8e-…` for Wreckfest + 2, in the file and in the registry alike. The file is the source. + +So at start, for each hand-made entry, the watcher looks for a record whose +executable field is that entry's file name and one of whose folder names is +a folder of that entry's path — the two things Windows' own record says it +matches on — and says, at `info`: *DS2.exe is marked as a game by hand, and +Microsoft's list knows it now: untick "Remember this is a game" in the Game +Bar, and Windows will recognise it by itself.* Once per start, never again +until the next. Because the format is undocumented, a file that cannot be +read or does not parse the way described here produces no hint and one +`debug` line saying why — the hint is advice, and wrong advice is worse +than none. + ## What it changes in the program, once measured The engine's sensor gains a second question — which listed processes are From d36b6a030ee397ac36dca3a8dddae57faeab582f Mon Sep 17 00:00:00 2001 From: Geoffrey Vancoetsem <10533139+geeooff@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:32:02 +0200 Subject: [PATCH 09/12] Detect the games marked by hand, and make the idle look cheap Lot 15's program side. The idle look now answers a Sighting: the presence writer, or a process whose full path is a hand-made entry of Windows' game list (Revision 1, no TitleId) -- the titles for which Windows never starts the writer. The engine parks on either's handle; a game marked by hand is not refined, its entry names it exactly. The list is read again only when its key's last-write time moves, so a tick mid-game becomes a session at the next look; an untick takes effect at the next launch, since noticing it would mean waking during the game. The look itself costs a twelfth of what it did: K32EnumProcesses every look, a name only for an id not seen before, the Toolhelp snapshot every thirty seconds as the net for a reused id. At start, one info line per hand-made entry Microsoft's own list (KnownGameList.bin, read for whole fields only) now covers, so the box can be unticked; status lists the games marked by hand. A first reading of the list wanted each folder name's length echoed after it and missed DS2; the rule is now two characters or more, with DS2's record as a byte-for-byte test. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 9 +- CHANGELOG.md | 10 + README.md | 3 +- config.example.toml | 10 +- docs/design/15-marked-games.md | 58 ++++-- docs/getting-started.md | 22 ++- docs/how-it-works.md | 51 +++--- docs/reference.md | 9 +- src/bin/presence-probe.rs | 33 ++++ src/cli.rs | 36 ++++ src/config.rs | 7 +- src/detect/hand_made.rs | 242 ++++++++++++++++++++++++ src/detect/microsoft_list.rs | 323 +++++++++++++++++++++++++++++++++ src/detect/mod.rs | 9 +- src/detect/presence_writer.rs | 11 +- src/detect/process.rs | 131 +++++++++++++ src/engine.rs | 91 +++++++--- src/engine/tests.rs | 154 +++++++++++++--- src/registry.rs | 32 +++- src/sensor.rs | 169 ++++++++++++++--- src/service.rs | 2 + 21 files changed, 1262 insertions(+), 150 deletions(-) create mode 100644 src/detect/hand_made.rs create mode 100644 src/detect/microsoft_list.rs diff --git a/AGENTS.md b/AGENTS.md index 36da81a..cf15ea0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,8 @@ GameModeExecutor is a Rust program for Windows that runs configured executables when a game starts and stops. Detection is Windows' own verdict — -the lifetime of the Game Bar presence writer process — never a list of games. +the lifetime of the Game Bar presence writer process, or of a process the +person marked as a game in the Game Bar — never a list of our own. It runs unelevated, connects to nothing, and shows only a notification area icon. @@ -153,9 +154,9 @@ section, so that two agents write it the same way: - Once a version is published its section is history: corrected in place only for an error of fact, never rewritten for taste. -Three tests read this machine's registry — the Known Game List, the Game Bar -registration, the real sensor — which a GitHub-hosted Windows Server runner -does not have. They are `#[ignore]`d with that reason and the script runs +A few tests read this machine — the Known Game List, the games marked by +hand in it, Microsoft's own list file, the Game Bar registration, the real +sensor — which a GitHub-hosted Windows Server runner does not have. They are `#[ignore]`d with that reason and the script runs them when `CI` is not set. CI must stay green on a stock runner: a test that needs a real Windows client, a GPU or a game says so with `#[ignore]`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6137901..d26bb03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,8 +30,18 @@ a section is written. icon back, with a notification saying the watcher is watching again — also when the fix came while the watcher was stopped. +- Games you marked yourself with *Remember this is a game* in the Game Bar + are detected like any other, from their launch — or from the moment you + tick the box, in the middle of a game. Windows never tells the rest of the + system about those, which is why they used to go unnoticed. +- At start, the log says which of the games you marked Microsoft's own list + now knows, so you can untick them and let Windows recognise them itself. + `status` lists the games you marked and says the same. + ### Changed +- Looking for a game while none runs costs about a twelfth of what it did: + some 0.016 % of one processor core, against 0.2 % before. - The watcher starts whatever the configuration file says, rather than exiting with a code and no icon when the file is wrong at logon. `validate` still reports the exit codes 3 and 4 for scripts. diff --git a/README.md b/README.md index a4f93db..257b69b 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,8 @@ or anything else with a command line. **There is no list of games to maintain.** Windows itself decides when a game is running — the watcher observes the Game Bar presence writer that Windows -starts for one — and it does not poll while you play. It runs as you, with no +starts for one, and follows the games you marked yourself in the Game Bar — +and it does not poll while you play. It runs as you, with no administrator rights, connects to nothing unless you ask it to look for an update, and shows nothing but a small icon in the notification area. diff --git a/config.example.toml b/config.example.toml index 83cd2ab..64e356f 100644 --- a/config.example.toml +++ b/config.example.toml @@ -5,7 +5,8 @@ # # There is no list of games to maintain here. Detection is Windows' own: the # program watches the Game Bar presence writer, which Windows starts for a game -# and releases when it is gone. +# and releases when it is gone -- and, for a game Windows did not recognise and +# you marked with "Remember this is a game" in the Game Bar, the game itself. # # Paths are easiest written between single quotes: TOML takes those literally, # so Windows separators need no doubling. @@ -53,9 +54,10 @@ log_level = "info" [detection] # --------------------------------------------------------------------------- -# How often to look for the presence writer while no game is running. This is -# the only polling the program does: once a game starts it parks on the -# writer's process handle and does nothing until Windows releases it. +# How often to look for a game while none is running: the presence writer, or +# a game you marked by hand. This is the only polling the program does, and a +# look costs a few hundredths of a millisecond; once a game starts it parks on +# the process's handle and does nothing until it ends. poll_interval = "2s" # After the writer exits, how long to wait for it to come back before running diff --git a/docs/design/15-marked-games.md b/docs/design/15-marked-games.md index a94f2a4..1f691de 100644 --- a/docs/design/15-marked-games.md +++ b/docs/design/15-marked-games.md @@ -1,17 +1,17 @@ # Lot 15 — Games Windows knows only from you -**Status: proposed 2026-09-20, taken next, ahead of everything else -pending** — the maintainer's decision the same morning, on the finding -below. It waits only for [Lot 9](09-robustness.md)'s configuration work to -close its field run. +**Status: built 2026-09-23, waiting for its field run.** Proposed +2026-09-20 and taken ahead of everything else pending, on the finding +below; taken up once [Lot 9](09-robustness.md)'s configuration work had +closed its field run. - [x] The instrument: a `watch-games` command in `presence-probe` that logs registry change notifications on Windows' game list and which entry's `LastAccessed` moved — built 2026-09-23 and checked on a scratch key, below - [x] The measurements below, before any line of the watcher changes — 2026-09-23: Windows writes the entry at every launch, the registry notification never comes, the second signal adds about 25 us to a 4 ms poll, an elevated process grants `SYNCHRONIZE`; the untick mid-game is left to the field run -- [ ] Detection from Windows' list as well as from the presence writer: a hand-marked title is a session from its launch -- [ ] A title marked *while it runs* becomes a session within the settle time, and the start commands run then -- [ ] The idle cost measured and written down: no polling of the registry, and whatever polling of processes remains, with its figure -- [ ] The idle poll made cheap: the process ids alone every `poll_interval`, names only for processes not seen before, a full snapshot every 30 s as the net for a reused id — decided 2026-09-23, below -- [ ] At start, a log line for each hand-made entry Microsoft's own list now covers, so the box can be unticked — asked for by the maintainer 2026-09-23, below +- [x] Detection from Windows' list as well as from the presence writer: a hand-marked title is a session from its launch — built 2026-09-23, three engine scenarios +- [x] A title marked *while it runs* becomes a session at the next idle look, and the start commands run then — built 2026-09-23: the list is read again when its key's last-write time moves +- [x] The idle cost measured and written down: no polling of the registry, and whatever polling of processes remains, with its figure — 2026-09-23, below +- [x] The idle poll made cheap: the process ids alone every `poll_interval`, names only for processes not seen before, a full snapshot every 30 s as the net for a reused id — built 2026-09-23 +- [x] At start, a log line for each hand-made entry Microsoft's own list now covers, so the box can be unticked; `status` says the same — built 2026-09-23, checked on the real list: DS2, Wreckfest 2 and cs2 covered, *The Other Side* and a browser not - [ ] Verified in the field on DS2 and the other hand-marked titles on the maintainer's machine **Done when** a game the Game Bar knows only because the person ticked @@ -377,7 +377,12 @@ some anti-cheat runs, is not measured. The fifth, the untick mid-game, is behaviour rather than a question: the entry goes at once (12:04:07 above), so the session ends at the next poll -and the stop commands run. The field run checks it. +and the stop commands run. The field run checks it. **Changed when built, +2026-09-23:** a session on a game marked by hand parks on the game's +handle and looks at nothing while it runs, as a session on the writer +does; noticing an untick would have meant waking every poll interval for +the whole game. The untick takes effect at the game's next launch, and the +session ends when the game does. ## Process ids, from Microsoft's documentation first — 2026-09-23 @@ -459,12 +464,27 @@ read or does not parse the way described here produces no hint and one `debug` line saying why — the hint is advice, and wrong advice is worse than none. -## What it changes in the program, once measured - -The engine's sensor gains a second question — which listed processes are -running, and a handle to wait on for one of them — and its loop treats -"the writer runs" and "a listed process runs" as one session with two -possible anchors. The refinement, the marker, the handover and the reload -are untouched: a session is a session. `status` says which signal it sees. -The user pages say plainly what *Remember this is a game* does for this -program, once it does something. +## What it changed in the program — built 2026-09-23 + +- **`sensor::Sighting`**: the idle look answers *the writer*, or *a game + marked by hand* with its exact name, path and process. The engine parks + on either's handle; a game marked by hand is not refined, since its entry + names it exactly; the grace after an exit accepts either coming back. The + marker, the resume, the handover and the reload are untouched — a session + is a session. Three scenarios in `engine/tests.rs`: a session from launch + with both edges and no rename, a resume after a handover, a relaunch + within the grace. +- **`detect::hand_made`**: the entries with `Revision = 1`, no `TitleId` + and a path; the list's key kept open to ask its last-write time each look. +- **`detect::process::Tracker`**: the ids every look, a name for a new id + only, the full snapshot every thirty seconds. +- **`detect::microsoft_list`**: `KnownGameList.bin` read for whole-field + matches only, as described above; at start, one `info` line per hand-made + entry it covers. A first version also required each folder name's length + to be repeated after it, which Counter-Strike's record seemed to show; on + the real file DS2 came out *not listed*, its record having `01 00` there. + The rule is now two characters or more for a folder name, which keeps the + one-character artefact out, and DS2's record is a test byte for byte. +- **`status`** lists the games marked by hand, each with whether it runs and + whether Microsoft's list knows it; **`presence-probe microsoft-list`** asks + the list about any path. diff --git a/docs/getting-started.md b/docs/getting-started.md index 994d756..146d855 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -276,13 +276,21 @@ too. **Nothing happens when I start a game.** Run `gamemode-executor status` while the game is running. If it says no game is running, Windows itself is not flagging that title — see -[How it works](how-it-works.md). One case to know about: a game Windows -did not recognise, that you had to mark yourself with *Remember this is a -game* in the Game Bar, is not seen by this program yet even with the box -ticked. Try unticking it and starting the game again: Microsoft may have -listed it since, and then Windows — and this program — recognise it on -their own. [How it works](how-it-works.md#how-it-knows-a-game-is-running) -says why, and what is being done about the games Microsoft does not list. +[How it works](how-it-works.md). A game Windows does not recognise, you can +mark yourself: open the Game Bar over it (`Win+G`), and in its settings tick +*Remember this is a game*. The watcher follows those too, from the next two +seconds on — [How it works](how-it-works.md#how-it-knows-a-game-is-running) +says how. + +**A program that is not a game starts my game commands.** +Someone ticked *Remember this is a game* over it. The log says so on the +*Game detected* line, and `gamemode-executor status` lists every program +marked that way: open the Game Bar over it and untick the box. + +**The log says a game I marked is now known to Microsoft.** +Microsoft added it to its own list since you ticked the box. Untick +*Remember this is a game* for it and start the game again: Windows then +recognises it by itself, and so does this program. **It says another instance is already running.** The background watcher is doing its job. That message means you tried to start a diff --git a/docs/how-it-works.md b/docs/how-it-works.md index dcb5b3f..83a747a 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -21,7 +21,11 @@ down. That process existing *is* Windows' own verdict that you are playing. So the watcher does something very simple: it watches whether that process exists. -- **No game:** it looks every two seconds. That is the only polling it ever does. +- **No game:** it looks every two seconds. That is the only polling it ever + does, and it is cheap: it asks Windows for the list of process numbers + alone, which takes a few hundredths of a millisecond, and asks a process + its name only the first time it sees it — a full list of every process by + name, which costs a hundred times more, only every thirty seconds. - **Game running:** it stops looking entirely, and asks Windows to wake it when that process ends. Zero activity while you play — which is rather the point of a program that runs during games. @@ -30,27 +34,30 @@ The result is that anything Windows knows as a game triggers it, including titles released after this program was written — Windows keeps its list of games up to date on its own. -**One class it does not see yet**, found on 2026-09-20: a title Windows did -not recognise, that you taught it by opening the Game Bar over it and -ticking *Remember this is a game*. Windows treats it as a game from then on -— overlay, capture, Game Mode — but does not start the process this -program watches for it, because that process exists to tell Xbox what you -are playing and a title you named by hand has no Xbox identity to tell. So -the watcher stays quiet on such a title, whatever the toggle says. - -Often there is a way out today. Microsoft adds games to its list after -they are released, and a box you ticked before that keeps Windows on your -own entry rather than its own — measured on 2026-09-23 with *Death -Stranding 2* and *Wreckfest 2*, both listed by Microsoft by then and both -unseen until the box was unticked. So for a game you once marked by hand: -open the Game Bar over it, **untick** *Remember this is a game*, quit the -game and start it again. If Microsoft knows it now, Windows recognises it -by itself — the Xbox overlay shows you *playing* it — and so does this -program. If it does not, the Game Bar offers the box again and you can -tick it back. -The next piece of work, [Lot 15](design/15-marked-games.md), makes the -watcher read Windows' list as well, so that a title only you marked -counts — at its launch, or at the moment you tick it. +**Games you marked yourself.** A title Windows does not recognise, you can +teach it: open the Game Bar over it and tick *Remember this is a game*. +Windows treats it as a game from then on — overlay, capture, Game Mode — but +does not start the process above for it, because that process exists to +tell Xbox what you are playing, and a title you named by hand has no Xbox +identity to tell. So the watcher also reads Windows' own list of the games +you marked: when one of those executables runs, that is a game session, with +the same commands at both ends. It is the same two-second look, and the +list is read again only when you tick or untick a box, so ticking it in the +middle of a game starts the session within those two seconds — nothing to +relaunch. While that game runs, the watcher waits on the game itself, and +does nothing else. The log names such a game as one *marked as a game by +hand*: a program ticked by mistake — a browser, say — would be a game +session whenever it runs, and that line is how you find the box to untick. + +**Boxes you no longer need.** Microsoft adds games to its list after they +are released, and a box you ticked before that keeps Windows on your own +entry rather than its own — measured on 2026-09-23 with *Death Stranding 2* +and *Wreckfest 2*, both listed by Microsoft by then. Each time the watcher +starts, it compares the games you marked with Microsoft's list and says, in +the log, which ones Microsoft now knows. For those, untick *Remember this is +a game*, quit the game and start it again: Windows recognises it by itself +— the Xbox overlay shows you *playing* it — and so does this program. +`gamemode-executor status` lists the games you marked and says the same. Which process to watch is read from the registry at startup rather than hard-coded, so a machine where that registration differs still works. diff --git a/docs/reference.md b/docs/reference.md index 1f97d3d..b03dab1 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -32,7 +32,7 @@ way. | Command | What it does | | --- | --- | | `run` | Watch and react, in this console. The default command. For an unattended instance use `gamemode-executorw.exe`. | -| `status` | The build, the presence writer's registration and whether it runs, the session marker, what the Known Game List holds and which running processes match it, ranked by GPU rendering share. | +| `status` | The build, the presence writer's registration and whether it runs, the session marker, what the Known Game List holds and which running processes match it, ranked by GPU rendering share, and the games marked by hand — each with whether it runs and whether Microsoft's own list now knows it. | | `check ` | Ask whether Windows knows a given executable as a game. | | `trigger start\|stop` | Run one set of actions immediately, ignoring detection. Handy to test your commands. | | `validate` | Parse and check the configuration. The command to script against: it returns 3 or 4 without starting anything. | @@ -62,7 +62,7 @@ log_level = "info" # error | warn | info | debug | trace #log_dir = 'C:\somewhere' # default: %LOCALAPPDATA%\GameModeExecutor\logs [detection] -poll_interval = "2s" # how often to look for a game while idle +poll_interval = "2s" # how often to look for a game while idle: the writer, or a game marked by hand stop_delay = "2s" # grace after the writer exits before the session ends identify_after = "20s" # when to ask the GPU which matched process is the game gpu_sample = "1s" @@ -178,7 +178,10 @@ One log serves two readers, and `log_level` is the dial between them: `info` is reserved for what the program is for: a game detected, named or gone, the watcher starting or stopping, a session recovered at start, the configuration reloaded — and what was done to this machine to set it up, -which is the same story one chapter earlier. Nothing else competes with +which is the same story one chapter earlier. A game you marked by hand in +the Game Bar is detected as `Game detected: TOS.exe, which is marked as a +game by hand in the Game Bar`, and at start one line per such game that +Microsoft's own list now knows says the box can be unticked. Nothing else competes with those lines. A configuration the watcher cannot use is an `error`, the one line in the log that asks something of you: `The configuration cannot be used, so nothing is watched until it is fixed: line 3: unknown field diff --git a/src/bin/presence-probe.rs b/src/bin/presence-probe.rs index 01b9993..f414ce6 100644 --- a/src/bin/presence-probe.rs +++ b/src/bin/presence-probe.rs @@ -22,6 +22,8 @@ //! presence-probe watch-methods //! several ways of being told the game list changed, at once //! presence-probe cost [rounds] time what an idle poll costs, today and with Lot 15 +//! presence-probe microsoft-list ... +//! whether Microsoft's own game list covers each executable //! presence-probe activate activate the class ourselves and time it //! ``` @@ -926,6 +928,37 @@ fn main() -> windows::core::Result<()> { std::process::exit(2); } }, + Some("microsoft-list") => { + use game_mode_executor::detect::microsoft_list; + let Some(path) = microsoft_list::path() else { + eprintln!("no local profile"); + std::process::exit(1); + }; + let list = match microsoft_list::read(&path) { + Ok(list) => list, + Err(error) => { + eprintln!("cannot read {}: {error}", path.display()); + std::process::exit(1); + } + }; + println!( + "{}: {} bytes, revision {}", + path.display(), + list.len(), + list.get(8..12) + .map(|bytes| u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])) + .unwrap_or_default() + ); + for exe in std::env::args().skip(2) { + let answer = if microsoft_list::covers(&list, &exe) { + "listed" + } else { + "not listed" + }; + println!(" {answer:<10} {exe}"); + } + Ok(()) + } Some("cost") => cmd_cost( std::env::args() .nth(2) diff --git a/src/cli.rs b/src/cli.rs index 93aa94e..c78ca70 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -460,6 +460,42 @@ fn status() -> Result<()> { Err(error) => println!("Known Game List : unavailable ({error:#})"), } + // The second signal: each game marked by hand is a session whenever its + // executable runs, so a box ticked by mistake shows up here, and so does + // one Microsoft's list has made unnecessary. + match detect::hand_made::load() { + Ok(entries) if entries.is_empty() => { + println!("Marked by hand : none (Remember this is a game, in the Game Bar)") + } + Ok(entries) => { + let covered = detect::hand_made::covered_by_microsoft(&entries).ok(); + println!( + "Marked by hand : {} -- each is a game whenever it runs", + entries.len() + ); + for entry in &entries { + let running = snapshot.processes.iter().any(|process| { + process.name.eq_ignore_ascii_case(entry.display_name()) + && detect::process::full_path(process.pid) + .is_some_and(|path| entry.is(&path)) + }); + let microsoft = match &covered { + Some(covered) if covered.contains(&entry) => { + "Microsoft's list knows it now: untick the box" + } + Some(_) => "not in Microsoft's list", + None => "Microsoft's list unreadable", + }; + println!( + " {} ({}; {microsoft})", + entry.path, + if running { "running" } else { "not running" } + ); + } + } + Err(error) => println!("Marked by hand : unavailable ({error:#})"), + } + print_foreground(&snapshot, known.as_ref().ok()); println!("Processes visible : {}", snapshot.processes.len()); diff --git a/src/config.rs b/src/config.rs index 4e2a463..fbb36e3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -87,9 +87,10 @@ impl Default for General { #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(default, deny_unknown_fields)] pub struct Detection { - /// How often to look for the presence writer while no game is running. - /// This is the only polling the program does: once a game starts, the - /// watcher parks on the writer's process handle until Windows releases it. + /// How often to look for a game while none is running: the presence + /// writer, or a process marked as a game by hand. This is the only polling + /// the program does: once a game starts, the watcher parks on that + /// process's handle until it ends. #[serde(with = "humantime_serde")] pub poll_interval: Duration, /// After the writer exits, how long to wait for it to come back before diff --git a/src/detect/hand_made.rs b/src/detect/hand_made.rs new file mode 100644 index 0000000..b502319 --- /dev/null +++ b/src/detect/hand_made.rs @@ -0,0 +1,242 @@ +//! The games the person marked by hand. +//! +//! Ticking *Remember this is a game* in the Game Bar writes an entry into +//! Windows' game list, `HKCU\System\GameConfigStore\Children`, with +//! `Revision = 1` and no Xbox `TitleId`. Windows treats the title as a game +//! from then on -- the overlay, capture, Game Mode -- but never starts the +//! presence writer for it: the writer tells Xbox what is being played, and +//! a title named by hand has no Xbox identity to tell. Measured on +//! 2026-09-20 and 2026-09-23; `docs/design/15-marked-games.md` has the runs. +//! +//! So these entries are the second signal: a running process whose full +//! path is one of theirs is a game session, by Windows' own list. Matched on +//! the exact path only -- the parent-directory and package rules naming uses +//! match too loosely to decide that a session exists. +//! +//! Change notifications on the list were measured and never arrive for the +//! Game Bar's writes, so the list is read again only when its key's +//! last-write time moves, which a tick or an untick does: one query a poll. + +use anyhow::Result; + +use crate::registry::Key; + +/// Windows' game list, per user. +pub const LIST_KEY: &str = r"System\GameConfigStore\Children"; + +/// The revision every entry ticked by hand carries. Entries Windows creates +/// from Microsoft's list carry that list's revision -- 2691 on the machine +/// the lot was measured on -- or 2 for packaged titles. +const HAND_MADE_REVISION: u32 = 1; + +/// One entry the person made. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Entry { + /// The executable's full path, as Windows wrote it. + pub path: String, + /// The same, lowercased, for comparing with a process's path. + lower_path: String, + /// The executable's file name, lowercased, for comparing with a + /// process's name before its path is asked for. + file_name: String, +} + +impl Entry { + pub fn new(path: &str) -> Self { + let lower_path = path.to_lowercase(); + let file_name = file_name_of(&lower_path).to_owned(); + Self { + path: path.to_owned(), + lower_path, + file_name, + } + } + + /// The executable's file name as Windows wrote it, for the log. + pub fn display_name(&self) -> &str { + file_name_of(&self.path) + } + + /// The executable's file name, lowercased, to find its processes among + /// the running ones before their paths are asked for. + pub fn file_name_lower(&self) -> &str { + &self.file_name + } + + /// Whether a process at this full path is this entry's. + pub fn is(&self, path: &str) -> bool { + path.to_lowercase() == self.lower_path + } +} + +/// The file name of a path, whichever separator it uses. +pub fn file_name_of(path: &str) -> &str { + path.rsplit(['\\', '/']).next().unwrap_or(path) +} + +/// The rule, apart from the registry: an entry with an executable path, the +/// hand-made revision, and no Xbox title id. +pub fn is_hand_made(revision: Option, has_title_id: bool, exe: Option<&str>) -> bool { + revision == Some(HAND_MADE_REVISION) && !has_title_id && exe.is_some_and(|exe| !exe.is_empty()) +} + +/// The hand-made entries in the list now. +pub fn load() -> Result> { + let root = Key::open_current_user(LIST_KEY)?; + let mut entries = Vec::new(); + for name in root.subkey_names() { + let Ok(child) = root.open_subkey(&name) else { + continue; + }; + let exe = child.string_value("MatchedExeFullPath"); + // Seen as a string on every entry read so far; a number is accepted + // too, since only its presence matters. + let has_title_id = + child.string_value("TitleId").is_some() || child.dword_value("TitleId").is_some(); + if is_hand_made(child.dword_value("Revision"), has_title_id, exe.as_deref()) + && let Some(exe) = exe + { + entries.push(Entry::new(&exe)); + } + } + Ok(entries) +} + +/// Which of `entries` Microsoft's own list covers, read from the file +/// Windows keeps it in. `Err` when that file cannot be read. +pub fn covered_by_microsoft(entries: &[Entry]) -> std::io::Result> { + let path = super::microsoft_list::path() + .ok_or_else(|| std::io::Error::other("no local profile to find it in"))?; + let list = super::microsoft_list::read(&path)?; + Ok(entries + .iter() + .filter(|entry| super::microsoft_list::covers(&list, &entry.path)) + .collect()) +} + +/// Say, once, which games marked by hand Microsoft's list now knows, so the +/// person can untick them and let Windows recognise them by itself -- asked +/// for by the maintainer on 2026-09-23, to clean up boxes ticked before +/// Microsoft listed the game. Called at the watcher's start and never again +/// until the next: the list changes with Windows' own updates, not by the +/// minute. +pub fn say_what_microsoft_now_covers() { + let entries = match load() { + Ok(entries) => entries, + Err(error) => { + tracing::debug!( + target: crate::logging::target::GAME, + error = %format!("{error:#}"), + "The games marked by hand cannot be read, so nothing is said about them" + ); + return; + } + }; + if entries.is_empty() { + return; + } + let covered = match covered_by_microsoft(&entries) { + Ok(covered) => covered, + Err(error) => { + tracing::debug!( + target: crate::logging::target::GAME, + error = %error, + "Microsoft's game list cannot be read, so nothing is said about the games \ + marked by hand" + ); + return; + } + }; + tracing::debug!( + target: crate::logging::target::GAME, + marked = entries.len(), + covered = covered.len(), + "Games marked by hand, and how many Microsoft's list now covers" + ); + for entry in covered { + tracing::info!( + target: crate::logging::target::GAME, + path = %entry.path, + "{} is marked as a game by hand, and Microsoft's own list knows it now: untick \ + \"Remember this is a game\" in the Game Bar, and Windows will recognise it by itself", + entry.display_name() + ); + } +} + +/// The list's key, kept open to ask when it was last written. +pub struct Watch { + key: Option, + stamp: Option, +} + +impl Watch { + pub fn new() -> Self { + Self { + key: Key::open_current_user(LIST_KEY).ok(), + stamp: None, + } + } + + /// Whether the list may have gained or lost an entry since the last + /// call. The first call always says yes. A key that cannot be opened or + /// asked says no, and the entries read before stay. + pub fn changed(&mut self) -> bool { + let Some(stamp) = self.key.as_ref().and_then(Key::last_write) else { + return false; + }; + let changed = self.stamp != Some(stamp); + self.stamp = Some(stamp); + changed + } +} + +impl Default for Watch { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// What the maintainer's machine showed on 2026-09-23: the entries ticked + /// by hand carry revision 1 and no title id; the ones Windows made from + /// Microsoft's list carry its revision and a title id; packaged titles + /// carry revision 2 and a package id instead of a path. + #[test] + fn only_entries_ticked_by_hand_count() { + let exe = Some(r"C:\Games\The Other Side\TheOtherSide-Win64-Shipping.exe"); + assert!(is_hand_made(Some(1), false, exe)); + assert!(!is_hand_made(Some(2691), true, exe), "listed by Microsoft"); + assert!(!is_hand_made(Some(1), true, exe), "a title id is Xbox's"); + assert!(!is_hand_made(Some(2), false, None), "a packaged title"); + assert!( + !is_hand_made(Some(1), false, Some("")), + "no path, nothing to match" + ); + assert!(!is_hand_made(None, false, exe)); + } + + #[test] + fn a_process_is_matched_by_its_whole_path_whatever_the_case() { + let entry = Entry::new(r"D:\Games\Steam\steamapps\common\The Other Side\TOS.exe"); + assert_eq!(entry.display_name(), "TOS.exe"); + assert_eq!(entry.file_name_lower(), "tos.exe"); + assert!(entry.is(r"d:\games\steam\steamapps\common\the other side\tos.exe")); + assert!( + !entry.is(r"C:\Elsewhere\TOS.exe"), + "same name, another game" + ); + } + + #[test] + #[ignore = "reads Windows' game list, absent on Windows Server runners"] + fn the_real_list_and_its_stamp_are_readable() { + load().expect("the game list is readable"); + let mut watch = Watch::new(); + assert!(watch.changed(), "the first look always says changed"); + assert!(!watch.changed(), "and then not, with nothing written"); + } +} diff --git a/src/detect/microsoft_list.rs b/src/detect/microsoft_list.rs new file mode 100644 index 0000000..a6d4e33 --- /dev/null +++ b/src/detect/microsoft_list.rs @@ -0,0 +1,323 @@ +//! Microsoft's own list of games, read for one question only: does it know a +//! title the person marked by hand? +//! +//! Windows keeps the Known Game List Microsoft distributes as a file, +//! `%LOCALAPPDATA%\Microsoft\GameDVR\KnownGameList.bin`, and creates the +//! registry entries of `HKCU\System\GameConfigStore\Children` from it. A +//! title ticked *Remember this is a game* before Microsoft listed it keeps +//! Windows on the hand-made entry -- no Xbox identity, no presence writer -- +//! until the box is unticked: measured on 2026-09-23 with DS2 and +//! Wreckfest 2. So at start the watcher says which hand-made entries this +//! file covers, and the person can untick them. +//! +//! **The format is not documented.** What is relied on was read from the +//! file on 2026-09-23 and is recorded in `docs/design/15-marked-games.md`: +//! the executable's name is a field of its own, UTF-16, preceded by its +//! length in bytes as 16 bits; the title's folder names follow as UTF-16 +//! strings each preceded by their length as 32 bits, with other bytes +//! between them, and a GUID in the same shape ends them. Anything else in +//! the file is ignored. A file that is missing, or holds no record shaped +//! like that for a name, gives no hint: the hint is advice, and wrong advice +//! -- untick a game Windows would then not recognise -- is worse than none. + +use std::path::{Path, PathBuf}; + +/// Where Windows keeps the list for this user. +pub fn path() -> Option { + std::env::var_os("LOCALAPPDATA").map(|local| { + PathBuf::from(local) + .join("Microsoft") + .join("GameDVR") + .join("KnownGameList.bin") + }) +} + +/// How far after the executable's name its folder names are looked for. +/// The records read on 2026-09-23 had theirs within 200 bytes. +const RECORD_REACH: usize = 600; + +/// The longest folder name taken as one, in bytes: a path component. +const LONGEST_NAME: usize = 520; + +/// The shortest, in bytes: two characters. +const SHORTEST_NAME: usize = 4; + +/// Whether the list has a record for the executable at `exe_path`: one +/// whose executable field is that file name, whole, and each of whose folder +/// names is a folder of that path -- the two things the record gives +/// Windows to match on. A record with no folder names is not taken as a +/// match: too little to be sure of. +pub fn covers(list: &[u8], exe_path: &str) -> bool { + let lower = exe_path.to_lowercase(); + let mut components: Vec<&str> = lower.split(['\\', '/']).collect(); + let Some(file_name) = components.pop() else { + return false; + }; + let folders_of_path: Vec<&str> = components; + records(list, file_name).iter().any(|folders| { + !folders.is_empty() + && folders + .iter() + .all(|folder| folders_of_path.contains(&folder.to_lowercase().as_str())) + }) +} + +/// The folder names of every record whose executable field is `file_name`, +/// compared without regard to ASCII case. +fn records(list: &[u8], file_name: &str) -> Vec> { + let needle: Vec = file_name.encode_utf16().collect(); + if needle.is_empty() { + return Vec::new(); + } + let length = (needle.len() * 2) as u16; + let mut found = Vec::new(); + // Two alignments: nothing says a record starts on an even byte. + for start in 0..2 { + let units: Vec = list[start.min(list.len())..] + .as_chunks::<2>() + .0 + .iter() + .map(|pair| u16::from_le_bytes(*pair)) + .collect(); + for at in 1..=units.len().saturating_sub(needle.len()) { + if units[at - 1] != length + || !same_ignoring_case(&units[at..at + needle.len()], &needle) + { + continue; + } + let after = start + (at + needle.len()) * 2; + found.push(folders_after(list, after)); + } + } + found +} + +fn same_ignoring_case(left: &[u16], right: &[u16]) -> bool { + left.iter().zip(right).all(|(a, b)| lower(*a) == lower(*b)) +} + +fn lower(unit: u16) -> u16 { + if (u16::from(b'A')..=u16::from(b'Z')).contains(&unit) { + unit + 32 + } else { + unit + } +} + +/// The strings that follow a record's executable name up to the GUID that +/// ends them, each preceded by its length in bytes as 32 bits. A folder +/// name is taken from two characters up: the bytes between two fields can +/// read as a one-character string -- `02 00 00 00` then the low bytes of the +/// next length, `>` before `Counter-Strike Global Offensive` in a first +/// reading -- and no folder of a record read so far was that short. +fn folders_after(list: &[u8], from: usize) -> Vec { + let end = (from + RECORD_REACH).min(list.len()); + let mut folders = Vec::new(); + let mut at = from; + while at + 4 <= end { + let size = + u32::from_le_bytes([list[at], list[at + 1], list[at + 2], list[at + 3]]) as usize; + let text_end = at + 4 + size; + if (SHORTEST_NAME..=LONGEST_NAME).contains(&size) + && size.is_multiple_of(2) + && text_end <= list.len() + && let Some(text) = printable(&list[at + 4..text_end]) + { + if is_guid(&text) { + return folders; + } + folders.push(text); + at = text_end; + continue; + } + at += 2; + } + // No GUID within reach: not a record of the shape this relies on. + Vec::new() +} + +/// UTF-16 made only of printable characters, or `None`. +fn printable(bytes: &[u8]) -> Option { + let units: Vec = bytes + .as_chunks::<2>() + .0 + .iter() + .map(|pair| u16::from_le_bytes(*pair)) + .collect(); + let text = String::from_utf16(&units).ok()?; + text.chars().all(|c| !c.is_control()).then_some(text) +} + +fn is_guid(text: &str) -> bool { + text.len() == 36 + && text.char_indices().all(|(i, c)| match i { + 8 | 13 | 18 | 23 => c == '-', + _ => c.is_ascii_hexdigit(), + }) +} + +/// Read the file once. A file missing or unreadable is said at `debug` by +/// the caller, and gives no hint. +pub fn read(path: &Path) -> std::io::Result> { + std::fs::read(path) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn utf16(text: &str) -> Vec { + text.encode_utf16().flat_map(u16::to_le_bytes).collect() + } + + /// A record shaped like the ones read on 2026-09-23: some bytes, the + /// executable's name with its length as 16 bits, a few bytes, each + /// folder name with its length as 32 bits, the GUID the same way, the + /// title id after. + fn record(exe: &str, folders: &[&str]) -> Vec { + let mut bytes = vec![0xe0, 0x00, 0x00, 0x00]; + let name = utf16(exe); + bytes.extend((name.len() as u16).to_le_bytes()); + bytes.extend(name); + bytes.extend([0x02, 0x00, 0x02, 0x00, 0x02, 0x00, 0x00, 0x00]); + for folder in folders { + let text = utf16(folder); + bytes.extend((text.len() as u32).to_le_bytes()); + bytes.extend(text); + bytes.extend([0x01, 0x00, 0x00, 0x00]); + } + let guid = utf16("e26eb51c-9cba-4cc5-9e7e-bb1628b17f80"); + bytes.extend((guid.len() as u32).to_le_bytes()); + bytes.extend(guid); + bytes.extend([0x02, 0x00, 0x00, 0x20]); + let title = utf16("2076696971"); + bytes.extend((title.len() as u32).to_le_bytes()); + bytes.extend(title); + bytes + } + + fn list() -> Vec { + let mut list = vec![ + 0xc4, 0x02, 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x83, 0x0a, 0x00, 0x00, + ]; + list.extend(record("borderlands2.exe", &["Borderlands 2", "Win32"])); + list.extend(record("blazing chrome.exe", &["Blazing Chrome"])); + list.extend(record( + "cs2.exe", + &["Counter-Strike Global Offensive", "win64"], + )); + list.extend(record("DS2.exe", &["DEATH STRANDING 2 - ON THE BEACH"])); + list + } + + #[test] + fn a_listed_title_is_found_by_its_name_and_its_folders() { + let list = list(); + assert!(covers( + &list, + r"D:\Games\Steam\steamapps\common\Counter-Strike Global Offensive\game\bin\win64\cs2.exe" + )); + assert!(covers( + &list, + r"C:\Games\Steam\steamapps\common\DEATH STRANDING 2 - ON THE BEACH\DS2.exe" + )); + } + + /// Both were a searched-for name inside a longer field in the real file: + /// `DS2.exe` ends `borderlands2.exe`, `chrome.exe` ends + /// `blazing chrome.exe`. + #[test] + fn a_name_inside_another_field_is_not_a_record() { + let list = list(); + assert!(!covers( + &list, + r"C:\Program Files\Google\Chrome\Application\chrome.exe" + )); + let mut only_borderlands = Vec::new(); + only_borderlands.extend(record("borderlands2.exe", &["Borderlands 2"])); + assert!(!covers( + &only_borderlands, + r"C:\Games\Borderlands 2\DS2.exe" + )); + } + + #[test] + fn the_same_name_in_another_folder_is_another_title() { + let list = list(); + assert!(!covers(&list, r"C:\Games\Something Else\DS2.exe")); + assert!( + !covers(&list, r"D:\Counter-Strike Global Offensive\cs2.exe"), + "every folder of the record must be there, win64 included" + ); + } + + #[test] + fn an_unlisted_title_and_an_unreadable_list_say_nothing() { + assert!(!covers( + &list(), + r"D:\Games\The Other Side\TheOtherSide-Win64-Shipping.exe" + )); + assert!(!covers(&[], r"C:\Games\DS2.exe")); + assert!(!covers(&[0xff; 64], r"C:\Games\DS2.exe")); + } + + /// DS2's record, byte for byte as the real file has it: `common` then the + /// title's folder, with `01 00 00 00` between them and nothing after the + /// second. A first version wanted each name's length repeated after it, + /// which Counter-Strike's record seemed to show, and missed this one. + #[test] + fn death_strandings_record_as_the_file_has_it() { + let mut list = vec![0x33, 0x00, 0xe0, 0x00, 0x00, 0x00]; + let name = utf16("ds2.exe"); + list.extend((name.len() as u16).to_le_bytes()); + list.extend(name); + list.extend([0x02, 0x00, 0x02, 0x00, 0x02, 0x00, 0x00, 0x00]); + list.extend(12u32.to_le_bytes()); + list.extend(utf16("common")); + list.extend([0x01, 0x00, 0x00, 0x00]); + list.extend(64u32.to_le_bytes()); + list.extend(utf16("DEATH STRANDING 2 - ON THE BEACH")); + list.extend([0x00, 0x00, 0x00, 0x20]); + list.extend(72u32.to_le_bytes()); + list.extend(utf16("f5ec2e1c-0624-402c-8cf2-34c8fd856704")); + assert!(covers( + &list, + r"C:\Games\Steam\steamapps\common\DEATH STRANDING 2 - ON THE BEACH\DS2.exe" + )); + assert!( + !covers(&list, r"C:\Games\DEATH STRANDING 2 - ON THE BEACH\DS2.exe"), + "`common` is one of the record's folders too" + ); + } + + #[test] + fn a_record_with_no_guid_within_reach_is_not_trusted() { + let mut list = vec![0u8; 4]; + let name = utf16("DS2.exe"); + list.extend((name.len() as u16).to_le_bytes()); + list.extend(name); + let folder = utf16("DEATH STRANDING 2 - ON THE BEACH"); + list.extend((folder.len() as u32).to_le_bytes()); + list.extend(folder); + assert!(!covers( + &list, + r"C:\Games\Steam\steamapps\common\DEATH STRANDING 2 - ON THE BEACH\DS2.exe" + )); + } + + /// The real file, on a client machine. Microsoft's list has carried + /// Counter-Strike for years and has no reason to carry a browser. + #[test] + #[ignore = "reads Microsoft's game list, absent on Windows Server runners"] + fn the_real_list_knows_counter_strike_and_not_a_browser() { + let list = read(&path().unwrap()).expect("the list is on this machine"); + assert!(covers( + &list, + r"D:\Games\Steam\steamapps\common\Counter-Strike Global Offensive\game\bin\win64\cs2.exe" + )); + assert!(!covers( + &list, + r"C:\Program Files\Google\Chrome\Application\chrome.exe" + )); + } +} diff --git a/src/detect/mod.rs b/src/detect/mod.rs index 193b8a6..be3882e 100644 --- a/src/detect/mod.rs +++ b/src/detect/mod.rs @@ -1,11 +1,16 @@ //! Game detection, delegated to Windows. //! //! `presence_writer` is the detector: the lifetime of the Game Bar presence -//! writer process is the game session. `known_games` only puts a name on what -//! it found. +//! writer process is the game session. `hand_made` is the second, for the +//! titles the person marked as games by hand, for which Windows never starts +//! the writer. `known_games` only puts a name on what they found, and +//! `microsoft_list` only says which hand-made entries Microsoft's list has +//! since covered. pub mod gpu; +pub mod hand_made; pub mod known_games; +pub mod microsoft_list; pub mod presence_writer; pub mod process; diff --git a/src/detect/presence_writer.rs b/src/detect/presence_writer.rs index a88da4e..0814b0b 100644 --- a/src/detect/presence_writer.rs +++ b/src/detect/presence_writer.rs @@ -75,11 +75,12 @@ pub fn find_in(snapshot: &super::process::Snapshot, exe: &Path) -> Option { /// Why a wait on the writer process ended. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum WaitOutcome { - /// The writer exited: Windows released it, so the game session is over. - WriterExited, + /// The process waited on exited: the writer, which Windows released, + /// or the game marked by hand, which quit. + Exited, /// The program was asked to shut down. Stopped, - /// The requested time passed and the writer is still alive. + /// The requested time passed and the process is still alive. TimedOut, } @@ -106,7 +107,7 @@ pub fn wait_for_exit_until( let Ok(process) = (unsafe { OpenProcess(PROCESS_SYNCHRONIZE, false, pid) }) else { // Already gone, or not ours to wait on: treat as exited rather than // spinning on a handle we cannot get. - return Ok(WaitOutcome::WriterExited); + return Ok(WaitOutcome::Exited); }; let millis = match timeout { @@ -123,7 +124,7 @@ pub fn wait_for_exit_until( unsafe { _ = CloseHandle(process) }; Ok(if result == WAIT_OBJECT_0 { - WaitOutcome::WriterExited + WaitOutcome::Exited } else if result == WAIT_TIMEOUT { WaitOutcome::TimedOut } else { diff --git a/src/detect/process.rs b/src/detect/process.rs index ed251c0..25d9974 100644 --- a/src/detect/process.rs +++ b/src/detect/process.rs @@ -6,6 +6,7 @@ use windows::Win32::Storage::Packaging::Appx::GetPackageFamilyName; use windows::Win32::System::Diagnostics::ToolHelp::{ CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW, TH32CS_SNAPPROCESS, }; +use windows::Win32::System::ProcessStatus::K32EnumProcesses; use windows::Win32::System::Threading::{ OpenProcess, PROCESS_NAME_WIN32, PROCESS_QUERY_LIMITED_INFORMATION, QueryFullProcessImageNameW, }; @@ -111,6 +112,91 @@ pub fn full_path(pid: u32) -> Option { identity(pid).path } +/// Every process id, and nothing else: `K32EnumProcesses`, about 32 us on +/// the machine Lot 15 was measured on against the snapshot's 4 ms. +pub fn ids() -> Result> { + let mut ids = vec![0u32; 1024]; + loop { + let mut needed = 0u32; + let size = (ids.len() * size_of::()) as u32; + // SAFETY: the buffer and its size in bytes are passed together, and + // `needed` is a local out pointer. + unsafe { K32EnumProcesses(ids.as_mut_ptr(), size, &mut needed) } + .ok() + .context("K32EnumProcesses failed")?; + // The documented sign of a buffer too small is a full one. + if needed < size { + ids.truncate(needed as usize / size_of::()); + return Ok(ids); + } + ids.resize(ids.len() * 2, 0); + } +} + +/// Which processes run, each known by its lowercased file name, kept up to +/// date for little: the ids alone every look, a name asked only of a +/// process not seen before, and a full snapshot every [`Tracker::FULL_EVERY`]. +/// +/// The snapshot is the net for a process id reused between two looks: an id +/// identifies a process only while it lives (Microsoft's documentation), and +/// a freed one came back 2.8 s later at the soonest under fifty new +/// processes a second (measured 2026-09-23). A game that took such an id +/// looks already seen until the next snapshot names it afresh. +#[derive(Debug, Default)] +pub struct Tracker { + /// `None` for a process that would not say its name. + names: std::collections::HashMap>, + snapshot_at: Option, +} + +impl Tracker { + pub const FULL_EVERY: std::time::Duration = std::time::Duration::from_secs(30); + + /// Bring the names up to date. + pub fn refresh(&mut self, now: std::time::Instant) -> Result<()> { + let due = self + .snapshot_at + .is_none_or(|at| now.saturating_duration_since(at) >= Self::FULL_EVERY); + if due { + let snapshot = Snapshot::take()?; + self.names = snapshot + .processes + .into_iter() + .map(|process| (process.pid, Some(process.name.to_lowercase()))) + .collect(); + self.snapshot_at = Some(now); + return Ok(()); + } + let ids = ids()?; + self.update(&ids, |pid| { + full_path(pid).map(|path| { + path.rsplit(['\\', '/']) + .next() + .unwrap_or(&path) + .to_lowercase() + }) + }); + Ok(()) + } + + /// The ids now running: the gone are forgotten, the new are named. + fn update(&mut self, ids: &[u32], name_of: impl Fn(u32) -> Option) { + let running: std::collections::HashSet = ids.iter().copied().collect(); + self.names.retain(|pid, _| running.contains(pid)); + for &pid in ids { + self.names.entry(pid).or_insert_with(|| name_of(pid)); + } + } + + /// The processes of this lowercased file name. + pub fn named<'a>(&'a self, lower_name: &'a str) -> impl Iterator + 'a { + self.names + .iter() + .filter(move |(_, name)| name.as_deref() == Some(lower_name)) + .map(|(&pid, _)| pid) + } +} + /// Process owning the foreground window. pub fn foreground_pid() -> Option { // SAFETY: no arguments and no preconditions; a null handle is checked. @@ -186,6 +272,51 @@ mod tests { assert!(path.to_ascii_lowercase().ends_with(".exe"), "got {path}"); } + #[test] + fn the_ids_include_this_process() { + assert!(ids().unwrap().contains(&std::process::id())); + } + + /// Only the processes not seen before are asked their name; the gone are + /// forgotten, so a later process under a freed id is asked afresh. + #[test] + fn the_tracker_names_only_what_is_new() { + let asked = std::cell::RefCell::new(Vec::new()); + let name_of = |pid: u32| { + asked.borrow_mut().push(pid); + (pid != 666).then(|| format!("p{pid}.exe")) + }; + let mut tracker = Tracker::default(); + tracker.update(&[4, 8, 666], name_of); + assert_eq!(*asked.borrow(), vec![4, 8, 666]); + assert_eq!(tracker.named("p8.exe").collect::>(), vec![8]); + assert_eq!( + tracker.named("p666.exe").count(), + 0, + "unnamed stays unnamed" + ); + + asked.borrow_mut().clear(); + tracker.update(&[4, 12], name_of); + assert_eq!(*asked.borrow(), vec![12], "4 was known, 8 went, 12 is new"); + assert_eq!(tracker.named("p8.exe").count(), 0); + + asked.borrow_mut().clear(); + tracker.update(&[4, 8], name_of); + assert_eq!(*asked.borrow(), vec![8], "a returning id is a new process"); + } + + #[test] + fn the_tracker_starts_from_a_full_snapshot() { + let mut tracker = Tracker::default(); + tracker.refresh(std::time::Instant::now()).unwrap(); + let own = identity(std::process::id()) + .path + .map(|path| path.rsplit('\\').next().unwrap().to_lowercase()) + .unwrap(); + assert!(tracker.named(&own).any(|pid| pid == std::process::id())); + } + #[test] fn an_unpackaged_process_has_no_package_family_name() { // The test binary is a plain Win32 executable. diff --git a/src/engine.rs b/src/engine.rs index 35c7763..47cb67e 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -10,6 +10,11 @@ //! - active: park on the writer's process handle, so nothing runs at all until //! Windows lets it go. //! +//! A title the person marked as a game by hand never gets a writer, so the +//! idle look also asks for the processes of those entries, and a session +//! found that way parks on the game's own handle instead: the same shape, +//! a second anchor. Decided 2026-09-23 in `docs/design/15-marked-games.md`. +//! //! The engine decides and never reads the OS itself: everything it observes //! comes through a [`Sensor`], which is what lets `engine/tests.rs` drive whole //! sessions in milliseconds with a scripted one. @@ -25,7 +30,7 @@ use crate::detect::presence_writer::WaitOutcome; use crate::detect::{self, GameSignal}; use crate::logging::{self, target}; use crate::marker::Marker; -use crate::sensor::Sensor; +use crate::sensor::{HAND_MADE, Sensor, Sighting}; use crate::win::{StopReason, StopSignal}; /// What the engine tells the outside world about the session. @@ -138,12 +143,12 @@ impl Engine { /// watcher crashed under it. Then nothing runs, neither stop nor start, /// and the session is taken up where it was. Looking for the writer /// *before* recovering is what keeps a game still on from getting the - /// idle and then the gaming configuration seconds apart. Returns the - /// writer to park on and the name to show when resuming. - fn recover(&self) -> Option<(u32, Option)> { + /// idle and then the gaming configuration seconds apart. Returns what + /// to park on and the name to show when resuming. + fn recover(&self) -> Option<(Sighting, Option)> { let marker = self.marker.as_ref()?; let pending = marker.pending()?; - if let Some(pid) = self.sensor.writer_pid() { + if let Some(sighting) = self.sensor.sighting() { let signal = pending.game.clone().map(|name| GameSignal { source: "resumed", process_name: Some(name), @@ -163,7 +168,7 @@ impl Engine { ), } self.report(&Session::Playing(signal.clone())); - return Some((pid, signal)); + return Some((sighting, signal)); } // The session is over. Said before the commands, as `fire_stop` does, // and said at all because the icon may still show the session the @@ -209,10 +214,14 @@ impl Engine { // A resumed session already had its start: no commands, no // marker to write, and no refinement -- the name in the marker // is the refined one when there was one. - let (mut pid, mut signal, fresh) = match resumed.take() { - Some((pid, signal)) => (pid, signal, false), - None => match self.await_writer(stop) { - Some(pid) => (pid, self.identify(), true), + let (mut anchor, mut signal, fresh) = match resumed.take() { + Some((anchor, signal)) => (anchor, signal, false), + None => match self.await_sighting(stop) { + Some(Sighting::Writer(pid)) => (Sighting::Writer(pid), self.identify(), true), + // The entry names the game exactly: nothing to guess. + Some(Sighting::HandMade(game)) => { + (Sighting::HandMade(game.clone()), Some(game), true) + } None => break, }, }; @@ -228,14 +237,17 @@ impl Engine { // Starfield and Skyrim each left a single candidate and this pass // found nothing to arbitrate. So: once, a little way into the // session, ask which candidate is actually rendering, and expect - // "no better answer" more often than not. - let mut refine_due = fresh && !self.config.detection.identify_after.is_zero(); + // "no better answer" more often than not. A game marked by hand + // was found by its exact path, so there is nothing to refine. + let mut refine_due = fresh + && matches!(anchor, Sighting::Writer(_)) + && !self.config.detection.identify_after.is_zero(); let stopped = loop { let timeout = refine_due.then_some(self.config.detection.identify_after); - // Waiting on the writer's handle rather than sleeping keeps the + // Waiting on the handle rather than sleeping keeps the // refinement from being blind to a game ending in the meantime. - match self.sensor.wait_for_writer_exit(pid, stop, timeout)? { + match self.sensor.wait_for_exit(anchor.pid(), stop, timeout)? { WaitOutcome::Stopped => break true, WaitOutcome::TimedOut => { refine_due = false; @@ -247,18 +259,26 @@ impl Engine { } continue; } - WaitOutcome::WriterExited => { - self.log_writer_exit(session_start, signal.as_ref(), fresh); - } + WaitOutcome::Exited => match &anchor { + Sighting::Writer(_) => { + self.log_writer_exit(session_start, signal.as_ref(), fresh); + } + Sighting::HandMade(game) => tracing::debug!( + target: target::GAME, + pid = game.process_id, + session = ?session_start.elapsed(), + "The game marked by hand exited" + ), + }, } - match self.writer_returns(stop) { - Some(new_pid) => { + match self.comes_back(stop) { + Some(again) => { tracing::debug!( target: target::GAME, - pid = new_pid, - "Presence writer came back, the session is still running" + pid = again.pid(), + "A game is running again within the grace, so the session goes on" ); - pid = new_pid; + anchor = again; } // The writer's exit and the watcher's stop can arrive // together. At logoff on 2026-09-17 Windows killed the @@ -331,13 +351,13 @@ impl Engine { } /// Idle: the only polling in the program. Returns `None` when stopped. - fn await_writer(&self, stop: &StopSignal) -> Option { + fn await_sighting(&self, stop: &StopSignal) -> Option { loop { if stop.is_set() { return None; } - if let Some(pid) = self.sensor.writer_pid() { - return Some(pid); + if let Some(sighting) = self.sensor.sighting() { + return Some(sighting); } if stop.wait_timeout(self.config.detection.poll_interval) { return None; @@ -345,9 +365,10 @@ impl Engine { } } - /// After the writer exits, give it `stop_delay` to come back before - /// declaring the session over. - fn writer_returns(&self, stop: &StopSignal) -> Option { + /// After the process a session waits on exits, give a game `stop_delay` + /// to be running again -- the writer blinking, or the game relaunched -- + /// before declaring the session over. + fn comes_back(&self, stop: &StopSignal) -> Option { let grace = self.config.detection.stop_delay; if grace.is_zero() { return None; @@ -364,8 +385,8 @@ impl Engine { if stop.wait_timeout(self.config.detection.poll_interval.min(remaining)) { return None; } - if let Some(pid) = self.sensor.writer_pid() { - return Some(pid); + if let Some(sighting) = self.sensor.sighting() { + return Some(sighting); } } } @@ -538,6 +559,16 @@ impl Engine { // session to close. self.remember(signal); match signal { + // Said in full at `info`: a program marked by mistake -- a + // browser -- becomes a session whenever it runs, and this line + // is where the person learns which box to untick. + Some(signal) if signal.source == HAND_MADE => tracing::info!( + target: target::GAME, + pid = signal.process_id, + path = signal.process_path.as_deref(), + "Game detected: {}, which is marked as a game by hand in the Game Bar", + signal.name() + ), Some(signal) => tracing::info!( target: target::GAME, pid = signal.process_id, diff --git a/src/engine/tests.rs b/src/engine/tests.rs index b4d5f0d..b5b8947 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -18,10 +18,10 @@ use crate::config::{Action, Event, Mode}; /// A machine that says what the test told it to, in order. struct Scripted { - /// Answers to `writer_pid`, one per call. When they run out the stop is + /// Answers to `sighting`, one per call. When they run out the stop is /// signalled, which is how `run` is made to return. - writer: RefCell>>, - /// Answers to `wait_for_writer_exit`, one per call. + writer: RefCell>>, + /// Answers to `wait_for_exit`, one per call. waits: RefCell>, /// Answers to `candidates`, one per call; the last one repeats. candidates: RefCell>>, @@ -33,13 +33,13 @@ struct Scripted { /// the counters. counters_unreadable: bool, /// The session ends as the writer does: the stop is signalled in the - /// same instant `wait_for_writer_exit` reports the exit, as a logoff + /// same instant `wait_for_exit` reports the exit, as a logoff /// does when Windows kills the writer before the watcher is told. session_ends_with_writer: bool, - /// A stop reported by `wait_for_writer_exit` is a handover, as + /// A stop reported by `wait_for_exit` is a handover, as /// `stop --handover` from an update makes it. stops_by_handover: bool, - /// A stop reported by `wait_for_writer_exit` is a reload, as a change + /// A stop reported by `wait_for_exit` is a reload, as a change /// to the configuration file makes it. stops_by_reload: bool, stop: Arc, @@ -87,8 +87,17 @@ impl Scripted { self } + /// Answers to `sighting` that are the presence writer, or nothing. fn writer(self, answers: &[Option]) -> Self { - self.writer.borrow_mut().extend(answers.iter().copied()); + self.writer + .borrow_mut() + .extend(answers.iter().map(|answer| answer.map(Sighting::Writer))); + self + } + + /// Answers to `sighting` of any kind. + fn sightings(self, answers: &[Option]) -> Self { + self.writer.borrow_mut().extend(answers.iter().cloned()); self } @@ -116,7 +125,7 @@ impl Scripted { } impl Sensor for Scripted { - fn writer_pid(&self) -> Option { + fn sighting(&self) -> Option { match self.writer.borrow_mut().pop_front() { Some(answer) => answer, None => { @@ -126,7 +135,7 @@ impl Sensor for Scripted { } } - fn wait_for_writer_exit( + fn wait_for_exit( &self, _pid: u32, _stop: &StopSignal, @@ -137,7 +146,7 @@ impl Sensor for Scripted { .borrow_mut() .pop_front() .expect("the script ran out of waits"); - if self.session_ends_with_writer && outcome == WaitOutcome::WriterExited { + if self.session_ends_with_writer && outcome == WaitOutcome::Exited { self.stop.signal(); } if self.stops_by_handover && outcome == WaitOutcome::Stopped { @@ -263,7 +272,7 @@ fn a_session_is_reported_on_both_edges_and_leaves_no_marker() { let stop = Arc::new(StopSignal::new().unwrap()); let sensor = Scripted::new(&stop) .writer(&[Some(7)]) - .waits(&[WaitOutcome::WriterExited]) + .waits(&[WaitOutcome::Exited]) .candidates(&[&[game(10, "game.exe")]]); let (sink, log) = recorder(); let marker = Marker::in_dir(&scratch()); @@ -292,7 +301,7 @@ fn a_game_windows_does_not_name_is_still_a_session() { let stop = Arc::new(StopSignal::new().unwrap()); let sensor = Scripted::new(&stop) .writer(&[Some(7)]) - .waits(&[WaitOutcome::WriterExited]) + .waits(&[WaitOutcome::Exited]) .candidates(&[&[]]); let (sink, log) = recorder(); @@ -302,6 +311,103 @@ fn a_game_windows_does_not_name_is_still_a_session() { assert_eq!(seen(&log), vec![Session::Playing(None), Session::Idle]); } +/// A process the person marked as a game by hand, as the sensor reports it. +fn marked(pid: u32, name: &str) -> GameSignal { + GameSignal { + source: HAND_MADE, + process_name: Some(name.to_owned()), + process_id: Some(pid), + process_path: Some(format!(r"C:\Games\{name}")), + } +} + +#[test] +fn a_game_marked_by_hand_is_a_session_from_its_launch() { + // Windows never starts the writer for a title the person marked by + // hand; its entry's process running is the session. The commands run on + // both edges, and the name is the entry's: the GPU is not asked, so a + // candidate drawing more cannot rename it. + let stop = Arc::new(StopSignal::new().unwrap()); + let sensor = Scripted::new(&stop) + .sightings(&[None, Some(Sighting::HandMade(marked(30, "TOS.exe")))]) + .waits(&[WaitOutcome::Exited]) + .candidates(&[&[game(31, "other.exe")]]) + .rendering(&[(31, 90.0)]); + let dir = scratch(); + let started = dir.join("start-ran"); + let stopped = dir.join("stop-ran"); + let mut config = quick_config(); + config.on_game_start = stop_event(vec![touch(&started)]); + config.on_game_stop = stop_event(vec![touch(&stopped)]); + let (sink, log) = recorder(); + + let mut engine = Engine::new(config, sensor) + .reporting_to(sink) + .remembering(Marker::in_dir(&dir)); + engine.run(&stop).unwrap(); + + assert!(started.exists() && stopped.exists(), "both edges ran"); + assert_eq!( + seen(&log), + vec![Session::Playing(Some(marked(30, "TOS.exe"))), Session::Idle], + "named by its entry, and never renamed" + ); + assert!(Marker::in_dir(&dir).pending().is_none()); +} + +#[test] +fn a_session_marked_by_hand_is_resumed_after_a_handover() { + // The same resume as for the writer: the marker open, the game marked by + // hand still running, nothing run until it ends. + let stop = Arc::new(StopSignal::new().unwrap()); + let sensor = Scripted::new(&stop) + .sightings(&[Some(Sighting::HandMade(marked(30, "TOS.exe")))]) + .waits(&[WaitOutcome::Exited]); + let dir = scratch(); + let marker = Marker::in_dir(&dir); + marker.open(Some("TOS.exe"), "earlier").unwrap(); + let started = dir.join("start-ran"); + let stopped = dir.join("stop-ran"); + let mut config = quick_config(); + config.on_game_start = stop_event(vec![touch(&started)]); + config.on_game_stop = stop_event(vec![touch(&stopped)]); + + let mut engine = Engine::new(config, sensor).remembering(marker); + engine.run(&stop).unwrap(); + + assert!(!started.exists(), "the start commands did not run again"); + assert!( + stopped.exists(), + "the stop commands ran when the game ended" + ); + assert!(Marker::in_dir(&dir).pending().is_none()); +} + +#[test] +fn a_game_relaunched_within_the_grace_keeps_the_session() { + // The game marked by hand quits and is running again before + // `stop_delay` is out -- a launcher restarting it: one session. + let stop = Arc::new(StopSignal::new().unwrap()); + let sensor = Scripted::new(&stop) + .sightings(&[ + Some(Sighting::HandMade(marked(30, "TOS.exe"))), + Some(Sighting::HandMade(marked(32, "TOS.exe"))), + ]) + .waits(&[WaitOutcome::Exited, WaitOutcome::Exited]); + let (sink, log) = recorder(); + let mut config = quick_config(); + config.detection.stop_delay = Duration::from_millis(50); + + let mut engine = Engine::new(config, sensor).reporting_to(sink); + engine.run(&stop).unwrap(); + + assert_eq!( + seen(&log), + vec![Session::Playing(Some(marked(30, "TOS.exe"))), Session::Idle], + "one start, one end" + ); +} + #[test] fn the_marker_is_written_when_the_game_starts_and_names_it() { let stop = Arc::new(StopSignal::new().unwrap()); @@ -333,7 +439,7 @@ fn the_launcher_that_died_is_replaced_by_the_one_match_left() { let stop = Arc::new(StopSignal::new().unwrap()); let sensor = Scripted::new(&stop) .writer(&[Some(7)]) - .waits(&[WaitOutcome::TimedOut, WaitOutcome::WriterExited]) + .waits(&[WaitOutcome::TimedOut, WaitOutcome::Exited]) .candidates(&[&[game(5, "launcher.exe")], &[game(10, "bf6.exe")]]) .alive(&[10]); let (sink, log) = recorder(); @@ -362,7 +468,7 @@ fn a_living_name_is_kept_when_the_only_candidate_is_another() { let stop = Arc::new(StopSignal::new().unwrap()); let sensor = Scripted::new(&stop) .writer(&[Some(7)]) - .waits(&[WaitOutcome::TimedOut, WaitOutcome::WriterExited]) + .waits(&[WaitOutcome::TimedOut, WaitOutcome::Exited]) .candidates(&[&[game(5, "first.exe")], &[game(10, "other.exe")]]) .alive(&[5, 10]); let (sink, log) = recorder(); @@ -381,7 +487,7 @@ fn the_gpu_hands_the_session_to_the_process_that_is_drawing() { let stop = Arc::new(StopSignal::new().unwrap()); let sensor = Scripted::new(&stop) .writer(&[Some(7)]) - .waits(&[WaitOutcome::TimedOut, WaitOutcome::WriterExited]) + .waits(&[WaitOutcome::TimedOut, WaitOutcome::Exited]) .candidates(&[&[game(5, "anticheat.exe"), game(10, "bf6.exe")]]) .alive(&[5, 10]) .rendering(&[(5, 0.0), (10, 75.0)]); @@ -407,7 +513,7 @@ fn nothing_rendering_keeps_the_first_name() { let stop = Arc::new(StopSignal::new().unwrap()); let sensor = Scripted::new(&stop) .writer(&[Some(7)]) - .waits(&[WaitOutcome::TimedOut, WaitOutcome::WriterExited]) + .waits(&[WaitOutcome::TimedOut, WaitOutcome::Exited]) .candidates(&[&[game(5, "anticheat.exe"), game(10, "bf6.exe")]]) .alive(&[5, 10]) .rendering(&[(5, 0.0), (10, 0.0)]); @@ -430,7 +536,7 @@ fn a_confirmed_name_is_not_reported_again() { let stop = Arc::new(StopSignal::new().unwrap()); let sensor = Scripted::new(&stop) .writer(&[Some(7)]) - .waits(&[WaitOutcome::TimedOut, WaitOutcome::WriterExited]) + .waits(&[WaitOutcome::TimedOut, WaitOutcome::Exited]) .candidates(&[&[game(10, "bf6.exe"), game(5, "anticheat.exe")]]) .alive(&[5, 10]) .rendering(&[(10, 90.0)]); @@ -451,7 +557,7 @@ fn refinement_is_skipped_when_identify_after_is_zero() { // asks for one, so a script that offered it would go unconsumed. let sensor = Scripted::new(&stop) .writer(&[Some(7)]) - .waits(&[WaitOutcome::WriterExited]) + .waits(&[WaitOutcome::Exited]) .candidates(&[&[game(5, "first.exe")]]); let (sink, log) = recorder(); let mut config = quick_config(); @@ -471,7 +577,7 @@ fn a_writer_that_comes_back_within_the_grace_keeps_the_session_open() { // Writer 7 exits, 8 appears while the grace runs, then 8 exits for good. let sensor = Scripted::new(&stop) .writer(&[Some(7), Some(8)]) - .waits(&[WaitOutcome::WriterExited, WaitOutcome::WriterExited]) + .waits(&[WaitOutcome::Exited, WaitOutcome::Exited]) .candidates(&[&[game(10, "game.exe")]]); let (sink, log) = recorder(); let mut config = quick_config(); @@ -571,7 +677,7 @@ fn a_writer_killed_by_the_session_ending_is_a_stop_mid_game() { let stop = Arc::new(StopSignal::new().unwrap()); let sensor = Scripted::new(&stop) .writer(&[Some(7)]) - .waits(&[WaitOutcome::WriterExited]) + .waits(&[WaitOutcome::Exited]) .session_ends_with_writer() .candidates(&[&[game(10, "game.exe")]]); let dir = scratch(); @@ -709,7 +815,7 @@ fn a_session_handed_over_is_resumed_without_running_anything() { let stop = Arc::new(StopSignal::new().unwrap()); let sensor = Scripted::new(&stop) .writer(&[Some(7)]) - .waits(&[WaitOutcome::WriterExited]); + .waits(&[WaitOutcome::Exited]); let dir = scratch(); let marker = Marker::in_dir(&dir); marker.open(Some("game.exe"), "earlier").unwrap(); @@ -829,7 +935,7 @@ fn an_unreadable_game_list_gives_an_unnamed_session_not_a_failure() { let stop = Arc::new(StopSignal::new().unwrap()); let sensor = Scripted::new(&stop) .writer(&[Some(7)]) - .waits(&[WaitOutcome::TimedOut, WaitOutcome::WriterExited]) + .waits(&[WaitOutcome::TimedOut, WaitOutcome::Exited]) .list_unreadable(); let (sink, log) = recorder(); @@ -847,7 +953,7 @@ fn unreadable_counters_keep_the_first_name() { // Two candidates, so the GPU is consulted -- and refuses. let sensor = Scripted::new(&stop) .writer(&[Some(7)]) - .waits(&[WaitOutcome::TimedOut, WaitOutcome::WriterExited]) + .waits(&[WaitOutcome::TimedOut, WaitOutcome::Exited]) .candidates(&[&[game(5, "first.exe"), game(10, "second.exe")]]) .alive(&[5, 10]) .counters_unreadable(); @@ -871,7 +977,7 @@ fn the_writer_exit_is_recorded_whether_the_game_is_still_running_or_not() { let stop = Arc::new(StopSignal::new().unwrap()); let sensor = Scripted::new(&stop) .writer(&[Some(7)]) - .waits(&[WaitOutcome::WriterExited]) + .waits(&[WaitOutcome::Exited]) .candidates(&[&[game(10, "game.exe")]]) .alive(alive); let (sink, log) = recorder(); diff --git a/src/registry.rs b/src/registry.rs index c84acbf..5f30bf6 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -58,12 +58,40 @@ impl Key { self.0.get_u64(name).ok() } - /// The raw handle, for the one Win32 call the wrapper does not cover: - /// `RegNotifyChangeKeyValue`. Valid as long as `self` is. + /// The raw handle, for the Win32 calls the wrapper does not cover. Valid + /// as long as `self` is. pub fn raw(&self) -> windows::Win32::System::Registry::HKEY { windows::Win32::System::Registry::HKEY(self.0.as_raw()) } + /// When the key itself was last written, as a `FILETIME` count. A + /// subkey created or deleted under it moves this; a value set on a + /// subkey moves only that subkey's. + pub fn last_write(&self) -> Option { + let mut written = windows::Win32::Foundation::FILETIME::default(); + // SAFETY: the key is open for as long as `self` lives, and only the + // last-write time is asked for, into a local that outlives the call. + let status = unsafe { + windows::Win32::System::Registry::RegQueryInfoKeyW( + self.raw(), + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + Some(&mut written), + ) + }; + status + .is_ok() + .then(|| (u64::from(written.dwHighDateTime) << 32) | u64::from(written.dwLowDateTime)) + } + /// A `REG_DWORD` value, or `None` when it is absent or not one. /// /// Windows keeps several of its own switches this way -- the taskbar theme diff --git a/src/sensor.rs b/src/sensor.rs index 2dc1079..2f0b133 100644 --- a/src/sensor.rs +++ b/src/sensor.rs @@ -1,32 +1,66 @@ //! What the engine observes about the machine, behind one trait. //! //! The engine decides; it does not read the OS itself. Everything it needs to -//! know arrives through [`Sensor`]: whether the presence writer runs, when it -//! exits, which processes the Known Game List matches, whether a process is -//! still alive, and what the GPU is drawing. [`Windows`] answers from the real -//! machine. The engine's tests answer from a script, which is the only reason -//! the trait exists -- one implementation would not have earned one. +//! know arrives through [`Sensor`]: whether Windows says a game runs -- its +//! presence writer, or a process the person marked as a game by hand -- +//! when that process exits, which processes the Known Game List matches, +//! whether a process is still alive, and what the GPU is drawing. +//! [`Windows`] answers from the real machine. The engine's tests answer from +//! a script, which is the only reason the trait exists -- one implementation +//! would not have earned one. +//! +//! The idle look is the one thing in the program that runs on a timer, so +//! [`Windows`] keeps it cheap: the process ids alone every look, a name only +//! for a process not seen before, a full snapshot every thirty seconds, and +//! the hand-made entries read again only when the list's key was written. +//! About 50 us a look instead of the 4 ms a snapshot costs, measured +//! 2026-09-23 in `docs/design/15-marked-games.md`. +use std::cell::RefCell; use std::collections::HashMap; use std::path::{Path, PathBuf}; -use std::time::Duration; +use std::time::{Duration, Instant}; use anyhow::{Context, Result}; use crate::detect::known_games::KnownGames; use crate::detect::presence_writer::{self, WaitOutcome}; -use crate::detect::process::Snapshot; -use crate::detect::{GameSignal, gpu}; +use crate::detect::process::{self, Snapshot, Tracker}; +use crate::detect::{GameSignal, gpu, hand_made}; use crate::logging::target; use crate::win::StopSignal; +/// What an idle look found running that makes a game session. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Sighting { + /// Windows' presence writer: a title Windows knows. + Writer(u32), + /// A process the person marked as a game by hand, for which Windows + /// never starts the writer. The signal names it exactly. + HandMade(GameSignal), +} + +impl Sighting { + /// The process a session waits on. + pub fn pid(&self) -> u32 { + match self { + Self::Writer(pid) => *pid, + Self::HandMade(signal) => signal.process_id.unwrap_or_default(), + } + } +} + +/// How a session found by a hand-made entry says where it came from. +pub const HAND_MADE: &str = "hand-made entry"; + pub trait Sensor { - /// The presence writer's pid, when Windows has one running. - fn writer_pid(&self) -> Option; + /// What runs now that makes a session, if anything: the writer first, + /// then a hand-made entry's process. + fn sighting(&self) -> Option; - /// Park until the writer exits, the stop is signalled, or `timeout` + /// Park until the process exits, the stop is signalled, or `timeout` /// passes -- whichever comes first. - fn wait_for_writer_exit( + fn wait_for_exit( &self, pid: u32, stop: &StopSignal, @@ -50,17 +84,17 @@ pub trait Sensor { /// `Windows` for the life of the process and builds an engine on it for /// each configuration. impl Sensor for &S { - fn writer_pid(&self) -> Option { - (**self).writer_pid() + fn sighting(&self) -> Option { + (**self).sighting() } - fn wait_for_writer_exit( + fn wait_for_exit( &self, pid: u32, stop: &StopSignal, timeout: Option, ) -> Result { - (**self).wait_for_writer_exit(pid, stop, timeout) + (**self).wait_for_exit(pid, stop, timeout) } fn candidates(&self) -> Result> { @@ -80,6 +114,19 @@ impl Sensor for &S { pub struct Windows { /// Resolved from the registry once at startup, never hard-coded. writer_exe: PathBuf, + /// Its file name, lowercased, to find it among the running processes. + writer_name: String, + /// What the idle looks keep between them. One thread asks, so a cell + /// is enough. + look: RefCell, +} + +/// What an idle look keeps between two looks. +#[derive(Default)] +struct Look { + processes: Tracker, + list: hand_made::Watch, + hand_made: Vec, } impl Windows { @@ -93,7 +140,12 @@ impl Windows { detection follows whatever is registered" ); } - Ok(Self { writer_exe }) + let writer_name = hand_made::file_name_of(&writer_exe.to_string_lossy()).to_lowercase(); + Ok(Self { + writer_exe, + writer_name, + look: RefCell::new(Look::default()), + }) } pub fn writer_exe(&self) -> &Path { @@ -101,12 +153,73 @@ impl Windows { } } +impl Look { + /// The hand-made entries, read again when the list's key was written: + /// a tick or an untick. A list that cannot be read keeps the last ones. + fn refresh_list(&mut self) { + if !self.list.changed() { + return; + } + match hand_made::load() { + Ok(entries) => { + if entries != self.hand_made { + tracing::debug!( + target: target::GAME, + entries = entries.len(), + names = ?entries.iter().map(hand_made::Entry::display_name).collect::>(), + "The games marked by hand in the Game Bar, as the list now has them" + ); + } + self.hand_made = entries; + } + Err(error) => tracing::debug!( + target: target::GAME, + error = %format!("{error:#}"), + "The games marked by hand cannot be read; the last ones read stay" + ), + } + } +} + impl Sensor for Windows { - fn writer_pid(&self) -> Option { - presence_writer::running_pid(&self.writer_exe) + fn sighting(&self) -> Option { + let mut look = self.look.borrow_mut(); + look.refresh_list(); + if let Err(error) = look.processes.refresh(Instant::now()) { + tracing::debug!( + target: target::GAME, + error = %format!("{error:#}"), + "The running processes cannot be listed this time" + ); + return None; + } + let writer = self.writer_exe.to_string_lossy().to_lowercase(); + for pid in look.processes.named(&self.writer_name) { + // Same name elsewhere on disk is not the registered writer; a + // path that cannot be read is taken as it, as before. + match process::full_path(pid) { + Some(path) if path.to_lowercase() != writer => continue, + _ => return Some(Sighting::Writer(pid)), + } + } + for entry in &look.hand_made { + for pid in look.processes.named(entry.file_name_lower()) { + if let Some(path) = process::full_path(pid) + && entry.is(&path) + { + return Some(Sighting::HandMade(GameSignal { + source: HAND_MADE, + process_name: Some(entry.display_name().to_owned()), + process_id: Some(pid), + process_path: Some(path), + })); + } + } + } + None } - fn wait_for_writer_exit( + fn wait_for_exit( &self, pid: u32, stop: &StopSignal, @@ -145,7 +258,8 @@ mod tests { assert!(sensor.writer_exe().is_absolute()); // Any answer is fine; the point is that none of them panics or fails // on a client machine. - let _ = sensor.writer_pid(); + let _ = sensor.sighting(); + let _ = sensor.sighting(); sensor .candidates() .expect("the known game list is readable"); @@ -177,11 +291,15 @@ mod tests { }; use windows::core::HSTRING; - let sensor = Windows::new().expect("Game Bar is registered here"); - if sensor.writer_pid().is_some() { + // A sensor keeps the game list's key open, which stays on the thread + // that opened it: this one looks first, the engine's own is built on + // the engine's thread. + let look = Windows::new().expect("Game Bar is registered here"); + if look.sighting().is_some() { eprintln!("skipped: a game is running, the writer is not ours to release"); return; } + drop(look); let mut config = crate::config::Config::default(); config.detection.poll_interval = Duration::from_millis(50); @@ -196,7 +314,10 @@ mod tests { let stop = Arc::new(StopSignal::new().unwrap()); let worker = { let stop = Arc::clone(&stop); - std::thread::spawn(move || Engine::new(config, sensor).reporting_to(sink).run(&stop)) + std::thread::spawn(move || { + let sensor = Windows::new()?; + Engine::new(config, sensor).reporting_to(sink).run(&stop) + }) }; // SAFETY: initialises the Windows Runtime for this thread; no pointers. diff --git a/src/service.rs b/src/service.rs index d0a964a..54b7aa6 100644 --- a/src/service.rs +++ b/src/service.rs @@ -199,6 +199,8 @@ pub fn serve(config_path: &Path, level: Option<&str>, console: bool) -> Result<( fault_marker, }; let worker = std::thread::spawn(move || { + // Once a start, off the window's thread: it reads a 2 MB file. + crate::detect::hand_made::say_what_microsoft_now_covers(); let outcome = sensor::Windows::new().and_then(|sensor| supervised.run(&sensor, loaded, &run_stop)); // Order matters: release WM_ENDSESSION first, then wake the loop. From ad4fd1c02e63a01c14b6b994954bdb44f40ea4a1 Mon Sep 17 00:00:00 2001 From: Geoffrey Vancoetsem <10533139+geeooff@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:28:12 +0200 Subject: [PATCH 10/12] Close Lot 15 on its field run, and measure what the watcher holds The maintainer's run, 13:36-14:26: The Other Side detected from launch and from a tick mid-game, an untick mid-game leaving the session to end with the game, Starfield through the writer unchanged. The installed watcher at idle, nine minutes each against the published 0.2.0: 0.026 % of a core against 0.324 % -- twelve and a half times less, measured on the whole process. A first reading of 5.1 MB and 424 handles came after four sessions; `presence-probe footprint` runs the watcher's steps one by one and shows this lot's own cost is nil and no step leaks, while reading the GPU counters for the refinement, there since Lot 3, keeps 3.7 MB from the first session on. That and the handles gained over the first sessions are left for a separate piece of work. Co-Authored-By: Claude Opus 5.5 --- docs/design/15-marked-games.md | 41 ++++++++++++++- docs/design/README.md | 2 +- src/bin/presence-probe.rs | 92 ++++++++++++++++++++++++++++++++++ 3 files changed, 132 insertions(+), 3 deletions(-) diff --git a/docs/design/15-marked-games.md b/docs/design/15-marked-games.md index 1f691de..51e318f 100644 --- a/docs/design/15-marked-games.md +++ b/docs/design/15-marked-games.md @@ -1,6 +1,6 @@ # Lot 15 — Games Windows knows only from you -**Status: built 2026-09-23, waiting for its field run.** Proposed +**Status: done 2026-09-23.** Proposed 2026-09-20 and taken ahead of everything else pending, on the finding below; taken up once [Lot 9](09-robustness.md)'s configuration work had closed its field run. @@ -12,7 +12,7 @@ closed its field run. - [x] The idle cost measured and written down: no polling of the registry, and whatever polling of processes remains, with its figure — 2026-09-23, below - [x] The idle poll made cheap: the process ids alone every `poll_interval`, names only for processes not seen before, a full snapshot every 30 s as the net for a reused id — built 2026-09-23 - [x] At start, a log line for each hand-made entry Microsoft's own list now covers, so the box can be unticked; `status` says the same — built 2026-09-23, checked on the real list: DS2, Wreckfest 2 and cs2 covered, *The Other Side* and a browser not -- [ ] Verified in the field on DS2 and the other hand-marked titles on the maintainer's machine +- [x] Verified in the field on the maintainer's machine — 2026-09-23, 13:36–14:26: *The Other Side* from launch and from a tick mid-game, Starfield through the writer unchanged, the idle processor measured against 0.2.0; DS2 and Wreckfest 2 needed no box any more, Microsoft's list having them **Done when** a game the Game Bar knows only because the person ticked *Remember this is a game* is detected at its launch, or at the tick if that @@ -464,6 +464,43 @@ read or does not parse the way described here produces no hint and one `debug` line saying why — the hint is advice, and wrong advice is worse than none. +## The field run, and what the watcher costs — 2026-09-23 + +The maintainer, with the lot's build installed at 13:32: `poll_interval` +back to two seconds in their own file at 13:36 — reloaded live —, then +*The Other Side*, marked by hand: detected at 13:59:39 (*Game detected: +TheOtherSide-Win64-Shipping.exe, which is marked as a game by hand in the +Game Bar*), ended 14:00:21, the stop commands two seconds later, the fans +following both times. Launched again and unticked mid-game at 14:01: the +session went on until the game quit, as decided when built, and the list +read afterwards had two entries. Launched unticked at 14:02 and ticked +mid-game: the list was read again and the session started in the same +look, 14:02:36.629. Starfield at 14:04, through the writer, unchanged. + +The installed watcher at idle, measured over nine minutes each, same +machine, same two-second interval, same minute: + +| | 0.2.0 as published | this lot | +| --- | --- | --- | +| processor, idle | 1750 ms in 540 s — **0.324 %** of a core | 140.6 ms in 540 s — **0.026 %** | +| private memory, just started | 1.9 MB | 2.0 MB | +| handles, just started | 157 | 163 | + +Twelve and a half times less processor at idle, measured on the whole +process rather than estimated from its steps. The first measurement of +this lot's build read 5.1 MB and 424 handles — but it had played four +sessions since it started and 0.2.0 none. `presence-probe footprint` runs +the watcher's steps one at a time and reports what each leaves: this lot's +look, its hand-made entries and its reading of Microsoft's list together +leave 0.24 MB and two handles, and the same after a hundred looks — no +leak. Naming a game (every process asked its path and package) leaves +0.13 MB. **Reading the GPU counters for the refinement — there since +[Lot 3](03-game-naming.md) — leaves 3.7 MB** the first time, and no more +after: a cost paid once, at the first session, not a leak. It accounts +for about twenty handles in the probe; the watcher gains some 260 over its +first sessions, and the rest is not yet traced. Both are proposed as a +separate piece of work — the lot's own cost is nil. + ## What it changed in the program — built 2026-09-23 - **`sensor::Sighting`**: the idle look answers *the writer*, or *a game diff --git a/docs/design/README.md b/docs/design/README.md index 13778cc..93c972b 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -26,7 +26,7 @@ session rather than when the code compiles. Each has its own page. | 12 | [Editing the configuration without breaking it](12-editing-on-a-copy.md) | proposed | | 13 | [Updating](13-updating.md) | shipped in 0.2.0; closes on the first update from it | | 14 | [Release notes people can read](14-release-notes.md) | done | -| 15 | [Games Windows knows only from you](15-marked-games.md) | proposed 2026-09-20; taken next, after Lot 9's field run | +| 15 | [Games Windows knows only from you](15-marked-games.md) | done | **Dependency order:** 1 → 2 → 4 → 5 → 6 → 7, with 3 independent and 7 needing both 3 and 6. Logging sits before the icon deliberately — the icon logs too, diff --git a/src/bin/presence-probe.rs b/src/bin/presence-probe.rs index f414ce6..13d0c7e 100644 --- a/src/bin/presence-probe.rs +++ b/src/bin/presence-probe.rs @@ -22,6 +22,7 @@ //! presence-probe watch-methods //! several ways of being told the game list changed, at once //! presence-probe cost [rounds] time what an idle poll costs, today and with Lot 15 +//! presence-probe footprint what each step of the watcher leaves in memory and handles //! presence-probe microsoft-list ... //! whether Microsoft's own game list covers each executable //! presence-probe activate activate the class ourselves and time it @@ -906,6 +907,96 @@ fn cmd_watch_methods(seconds: u64, sid: &str) -> windows::core::Result<()> { Ok(()) } +// ------------------------------------------------ what the watcher holds -- + +/// This process's private bytes and handle count, now. +fn footprint() -> (f64, u32) { + use windows::Win32::System::ProcessStatus::{ + K32GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS, PROCESS_MEMORY_COUNTERS_EX, + }; + use windows::Win32::System::Threading::{GetCurrentProcess, GetProcessHandleCount}; + let mut memory = PROCESS_MEMORY_COUNTERS_EX { + cb: size_of::() as u32, + ..Default::default() + }; + let mut handles = 0u32; + // SAFETY: the pseudo handle needs no closing; the counters struct is + // passed with its own size, which bounds the write; `handles` is a local. + unsafe { + let _ = K32GetProcessMemoryInfo( + GetCurrentProcess(), + (&raw mut memory).cast::(), + memory.cb, + ); + let _ = GetProcessHandleCount(GetCurrentProcess(), &mut handles); + } + (memory.PrivateUsage as f64 / (1024.0 * 1024.0), handles) +} + +/// Run, one step at a time, what the watcher does at start and at every +/// idle look, and say what each step leaves behind in private memory and +/// handles -- a step repeated ten times that leaves more each time is a +/// leak, one that leaves the same is a cost. +fn cmd_footprint() -> windows::core::Result<()> { + use game_mode_executor::detect::hand_made; + use game_mode_executor::detect::known_games::KnownGames; + use game_mode_executor::detect::process::Snapshot; + use game_mode_executor::sensor::{self, Sensor}; + + let say = |step: &str| { + let (private, handles) = footprint(); + println!("{private:>7.2} MB private {handles:>5} handles after {step}"); + }; + say("start"); + let entries = hand_made::load().unwrap_or_default(); + say("reading the games marked by hand once"); + for _ in 0..10 { + let _ = hand_made::load(); + } + say("reading them ten times more"); + let _ = hand_made::covered_by_microsoft(&entries); + say("comparing them with Microsoft's list once"); + for _ in 0..10 { + let _ = hand_made::covered_by_microsoft(&entries); + } + say("comparing ten times more"); + for _ in 0..10 { + let _ = KnownGames::load(); + } + say("loading the Known Game List ten times, for naming"); + for _ in 0..10 { + let _ = Snapshot::take(); + } + say("ten full process snapshots"); + let Ok(sensor) = sensor::Windows::new() else { + println!("no sensor: the Game Bar is not registered here"); + return Ok(()); + }; + say("building the sensor"); + let _ = sensor.sighting(); + say("its first look"); + for _ in 0..100 { + let _ = sensor.sighting(); + } + say("a hundred looks more"); + // What a session's start does, which 0.2.0 did too: every process asked + // for its path and package to name the game, then the GPU counters for + // the refinement. + let _ = sensor.candidates(); + say("naming a game once: every process asked its path and package"); + for _ in 0..3 { + let _ = sensor.candidates(); + } + say("naming three times more"); + let _ = sensor.rendering_load(std::time::Duration::from_millis(200)); + say("reading the GPU counters once, for the refinement"); + for _ in 0..3 { + let _ = sensor.rendering_load(std::time::Duration::from_millis(200)); + } + say("reading them three times more"); + Ok(()) +} + fn main() -> windows::core::Result<()> { let seconds = || { std::env::args() @@ -921,6 +1012,7 @@ fn main() -> windows::core::Result<()> { cmd_watch_games(seconds(), key.as_deref().unwrap_or(GAME_LIST)) } Some("activate") => cmd_activate(5, 60), + Some("footprint") => cmd_footprint(), Some("watch-methods") => match std::env::args().nth(3) { Some(sid) => cmd_watch_methods(seconds(), &sid), None => { From a246596f07c5cd16561816c20476004dddf39673 Mon Sep 17 00:00:00 2001 From: Geoffrey Vancoetsem <10533139+geeooff@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:12:13 +0200 Subject: [PATCH 11/12] Tighten Lot 15's code after review, and test its rules without Windows A hand-made sighting carried its process id as an Option that a missing value would have turned into 0, a wait that ends at once; it is a field now. The game list's key, missing at start on a profile the Game Bar had not written yet, is tried again instead of never. Microsoft's list is decoded once into a List instead of twice per entry, names are compared in lower case beyond ASCII, and a record is read over three long folder names' worth instead of 600 bytes. The writer's path is lowercased once. Two rules sat inside system calls: which process is a sighting, and which hand-made entries Microsoft's list covers. Both are now pure functions -- sensor::sighting_among takes the path lookup as a parameter, hand_made::covered takes the decoded list -- with tests on made-up processes and records, a tracker test on a real child process born between two snapshots, and the relaunch scenario checks both waits. On a hosted runner sensor.rs goes from 8 % to 43 % of its lines, hand_made.rs from 37 % to 48 %; on a client machine the library is at 72.8 %. Co-Authored-By: Claude Opus 5.5 --- Cargo.toml | 4 +- src/bin/presence-probe.rs | 18 +--- src/detect/hand_made.rs | 42 +++++++-- src/detect/microsoft_list.rs | 155 ++++++++++++++++++++------------ src/detect/process.rs | 30 ++++++- src/engine.rs | 9 +- src/engine/tests.rs | 23 +++-- src/sensor.rs | 169 +++++++++++++++++++++++++++++------ 8 files changed, 332 insertions(+), 118 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 152288f..81ab6cb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,8 +61,8 @@ windows = { version = "0.62", features = [ "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_System_Diagnostics_ToolHelp", - # K32EnumProcesses: the process ids alone, measured against the Toolhelp - # snapshot as the idle poll's cost (the probe's `cost`). + # K32EnumProcesses: the process ids alone, which the idle look takes + # every poll instead of a Toolhelp snapshot, about 130 times cheaper. "Win32_System_ProcessStatus", "Win32_System_Performance", "Win32_System_SystemInformation", diff --git a/src/bin/presence-probe.rs b/src/bin/presence-probe.rs index 13d0c7e..678a430 100644 --- a/src/bin/presence-probe.rs +++ b/src/bin/presence-probe.rs @@ -642,20 +642,9 @@ fn cmd_cost(rounds: usize) -> windows::core::Result<()> { }; stamp.push(started.elapsed()); - // The process ids alone, without names: what a poll would pay if it - // only asked for the names of processes it had not seen before. + // The process ids alone, as the watcher's idle look takes them. let started = Instant::now(); - let mut pids = [0u32; 4096]; - let mut needed = 0u32; - // SAFETY: the buffer and its size in bytes are passed together, and - // `needed` is a local out pointer. - let _ = unsafe { - windows::Win32::System::ProcessStatus::K32EnumProcesses( - pids.as_mut_ptr(), - std::mem::size_of_val(&pids) as u32, - &mut needed, - ) - }; + let _ = game_mode_executor::detect::process::ids(); ids.push(started.elapsed()); std::thread::sleep(Duration::from_millis(10)); @@ -1041,8 +1030,9 @@ fn main() -> windows::core::Result<()> { .map(|bytes| u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])) .unwrap_or_default() ); + let list = microsoft_list::List::new(list); for exe in std::env::args().skip(2) { - let answer = if microsoft_list::covers(&list, &exe) { + let answer = if list.covers(&exe) { "listed" } else { "not listed" diff --git a/src/detect/hand_made.rs b/src/detect/hand_made.rs index b502319..cefd311 100644 --- a/src/detect/hand_made.rs +++ b/src/detect/hand_made.rs @@ -107,11 +107,16 @@ pub fn load() -> Result> { pub fn covered_by_microsoft(entries: &[Entry]) -> std::io::Result> { let path = super::microsoft_list::path() .ok_or_else(|| std::io::Error::other("no local profile to find it in"))?; - let list = super::microsoft_list::read(&path)?; - Ok(entries + let list = super::microsoft_list::List::new(super::microsoft_list::read(&path)?); + Ok(covered(entries, &list)) +} + +/// Which of `entries` `list` covers: the rule, apart from the file. +pub fn covered<'a>(entries: &'a [Entry], list: &super::microsoft_list::List) -> Vec<&'a Entry> { + entries .iter() - .filter(|entry| super::microsoft_list::covers(&list, &entry.path)) - .collect()) + .filter(|entry| list.covers(&entry.path)) + .collect() } /// Say, once, which games marked by hand Microsoft's list now knows, so the @@ -179,9 +184,14 @@ impl Watch { } /// Whether the list may have gained or lost an entry since the last - /// call. The first call always says yes. A key that cannot be opened or - /// asked says no, and the entries read before stay. + /// call. The first call always says yes. A key that could not be opened + /// is tried again -- a profile where the Game Bar has written nothing yet + /// has no list until the first game -- and until it opens, and whenever + /// it cannot be asked, the answer is no and the entries read before stay. pub fn changed(&mut self) -> bool { + if self.key.is_none() { + self.key = Key::open_current_user(LIST_KEY).ok(); + } let Some(stamp) = self.key.as_ref().and_then(Key::last_write) else { return false; }; @@ -231,6 +241,26 @@ mod tests { ); } + /// The two titles the maintainer had ticked before Microsoft listed them, + /// and one it has never listed: only the first two are said. + #[test] + fn only_the_entries_microsofts_list_covers_are_said() { + use crate::detect::microsoft_list::{List, fixture::record}; + let mut bytes = record("DS2.exe", &["common", "DEATH STRANDING 2 - ON THE BEACH"]); + bytes.extend(record("Wreckfest2.exe", &["common", "Wreckfest 2"])); + let list = List::new(bytes); + let entries = [ + Entry::new(r"C:\Games\Steam\steamapps\common\DEATH STRANDING 2 - ON THE BEACH\DS2.exe"), + Entry::new(r"D:\Games\Steam\steamapps\common\The Other Side\TOS.exe"), + Entry::new(r"C:\Games\Steam\steamapps\common\Wreckfest 2\Wreckfest2.exe"), + ]; + let said: Vec<&str> = covered(&entries, &list) + .into_iter() + .map(Entry::display_name) + .collect(); + assert_eq!(said, ["DS2.exe", "Wreckfest2.exe"]); + } + #[test] #[ignore = "reads Windows' game list, absent on Windows Server runners"] fn the_real_list_and_its_stamp_are_readable() { diff --git a/src/detect/microsoft_list.rs b/src/detect/microsoft_list.rs index a6d4e33..ba9d151 100644 --- a/src/detect/microsoft_list.rs +++ b/src/detect/microsoft_list.rs @@ -32,9 +32,10 @@ pub fn path() -> Option { }) } -/// How far after the executable's name its folder names are looked for. -/// The records read on 2026-09-23 had theirs within 200 bytes. -const RECORD_REACH: usize = 600; +/// How far after the executable's name its folder names are looked for: +/// room for three folder names of the longest length taken. The records +/// read on 2026-09-23 had theirs within 200 bytes. +const RECORD_REACH: usize = 3 * (4 + LONGEST_NAME) + 64; /// The longest folder name taken as one, in bytes: a path component. const LONGEST_NAME: usize = 520; @@ -42,65 +43,84 @@ const LONGEST_NAME: usize = 520; /// The shortest, in bytes: two characters. const SHORTEST_NAME: usize = 4; -/// Whether the list has a record for the executable at `exe_path`: one -/// whose executable field is that file name, whole, and each of whose folder -/// names is a folder of that path -- the two things the record gives -/// Windows to match on. A record with no folder names is not taken as a -/// match: too little to be sure of. -pub fn covers(list: &[u8], exe_path: &str) -> bool { - let lower = exe_path.to_lowercase(); - let mut components: Vec<&str> = lower.split(['\\', '/']).collect(); - let Some(file_name) = components.pop() else { - return false; - }; - let folders_of_path: Vec<&str> = components; - records(list, file_name).iter().any(|folders| { - !folders.is_empty() - && folders - .iter() - .all(|folder| folders_of_path.contains(&folder.to_lowercase().as_str())) - }) +/// The list, read once and decoded once: the bytes, for the fields that +/// follow a name, and the same bytes as UTF-16 at both alignments, for +/// finding the names -- nothing says a record starts on an even byte. +pub struct List { + bytes: Vec, + units: [Vec; 2], } -/// The folder names of every record whose executable field is `file_name`, -/// compared without regard to ASCII case. -fn records(list: &[u8], file_name: &str) -> Vec> { - let needle: Vec = file_name.encode_utf16().collect(); - if needle.is_empty() { - return Vec::new(); +impl List { + pub fn new(bytes: Vec) -> Self { + let decode = |start: usize| -> Vec { + bytes[start.min(bytes.len())..] + .as_chunks::<2>() + .0 + .iter() + .map(|pair| u16::from_le_bytes(*pair)) + .collect() + }; + let units = [decode(0), decode(1)]; + Self { bytes, units } + } + + /// Whether the list has a record for the executable at `exe_path`: one + /// whose executable field is that file name, whole, and each of whose + /// folder names is a folder of that path -- the two things the record + /// gives Windows to match on. A record with no folder names is not taken + /// as a match: too little to be sure of. + pub fn covers(&self, exe_path: &str) -> bool { + let mut components: Vec = + exe_path.split(['\\', '/']).map(str::to_lowercase).collect(); + let Some(file_name) = exe_path.rsplit(['\\', '/']).next() else { + return false; + }; + components.pop(); + self.records(file_name).iter().any(|folders| { + !folders.is_empty() + && folders + .iter() + .all(|folder| components.contains(&folder.to_lowercase())) + }) } - let length = (needle.len() * 2) as u16; - let mut found = Vec::new(); - // Two alignments: nothing says a record starts on an even byte. - for start in 0..2 { - let units: Vec = list[start.min(list.len())..] - .as_chunks::<2>() - .0 - .iter() - .map(|pair| u16::from_le_bytes(*pair)) - .collect(); - for at in 1..=units.len().saturating_sub(needle.len()) { - if units[at - 1] != length - || !same_ignoring_case(&units[at..at + needle.len()], &needle) - { - continue; + + /// The folder names of every record whose executable field is + /// `file_name`, compared without regard to case. + fn records(&self, file_name: &str) -> Vec> { + let needle: Vec = file_name.encode_utf16().map(lower).collect(); + if needle.is_empty() { + return Vec::new(); + } + let length = (needle.len() * 2) as u16; + let mut found = Vec::new(); + for (start, units) in self.units.iter().enumerate() { + for at in 1..=units.len().saturating_sub(needle.len()) { + if units[at - 1] == length + && units[at..at + needle.len()] + .iter() + .zip(&needle) + .all(|(unit, wanted)| lower(*unit) == *wanted) + { + let after = start + (at + needle.len()) * 2; + found.push(folders_after(&self.bytes, after)); + } } - let after = start + (at + needle.len()) * 2; - found.push(folders_after(list, after)); } + found } - found -} - -fn same_ignoring_case(left: &[u16], right: &[u16]) -> bool { - left.iter().zip(right).all(|(a, b)| lower(*a) == lower(*b)) } +/// One UTF-16 unit in lower case, when its lower case is one unit too -- +/// `É` to `é` as well as `A` to `a` -- and as it is otherwise. fn lower(unit: u16) -> u16 { - if (u16::from(b'A')..=u16::from(b'Z')).contains(&unit) { - unit + 32 - } else { - unit + let Some(character) = char::from_u32(u32::from(unit)) else { + return unit; + }; + let mut lowered = character.to_lowercase(); + match (lowered.next(), lowered.next()) { + (Some(one), None) => u16::try_from(u32::from(one)).unwrap_or(unit), + _ => unit, } } @@ -162,11 +182,11 @@ pub fn read(path: &Path) -> std::io::Result> { std::fs::read(path) } +/// Records shaped like the real file's, for the tests here and in +/// `hand_made`. #[cfg(test)] -mod tests { - use super::*; - - fn utf16(text: &str) -> Vec { +pub(crate) mod fixture { + pub(crate) fn utf16(text: &str) -> Vec { text.encode_utf16().flat_map(u16::to_le_bytes).collect() } @@ -174,7 +194,7 @@ mod tests { /// executable's name with its length as 16 bits, a few bytes, each /// folder name with its length as 32 bits, the GUID the same way, the /// title id after. - fn record(exe: &str, folders: &[&str]) -> Vec { + pub(crate) fn record(exe: &str, folders: &[&str]) -> Vec { let mut bytes = vec![0xe0, 0x00, 0x00, 0x00]; let name = utf16(exe); bytes.extend((name.len() as u16).to_le_bytes()); @@ -195,6 +215,16 @@ mod tests { bytes.extend(title); bytes } +} + +#[cfg(test)] +mod tests { + use super::fixture::{record, utf16}; + use super::*; + + fn covers(list: &[u8], exe_path: &str) -> bool { + List::new(list.to_vec()).covers(exe_path) + } fn list() -> Vec { let mut list = vec![ @@ -290,6 +320,15 @@ mod tests { ); } + /// The names are compared without regard to case beyond ASCII: a title + /// whose executable carries an accent is found whatever its case. + #[test] + fn an_accented_name_is_found_whatever_its_case() { + let list = record("Élan.exe", &["Élan Vital"]); + assert!(covers(&list, r"D:\Games\élan vital\élan.exe")); + assert!(covers(&list, r"D:\Games\ÉLAN VITAL\ÉLAN.EXE")); + } + #[test] fn a_record_with_no_guid_within_reach_is_not_trusted() { let mut list = vec![0u8; 4]; diff --git a/src/detect/process.rs b/src/detect/process.rs index 25d9974..6539708 100644 --- a/src/detect/process.rs +++ b/src/detect/process.rs @@ -180,7 +180,7 @@ impl Tracker { } /// The ids now running: the gone are forgotten, the new are named. - fn update(&mut self, ids: &[u32], name_of: impl Fn(u32) -> Option) { + pub(crate) fn update(&mut self, ids: &[u32], name_of: impl Fn(u32) -> Option) { let running: std::collections::HashSet = ids.iter().copied().collect(); self.names.retain(|pid, _| running.contains(pid)); for &pid in ids { @@ -306,6 +306,34 @@ mod tests { assert_eq!(*asked.borrow(), vec![8], "a returning id is a new process"); } + /// Between two snapshots the tracker sees a new process by its id alone + /// and names it; thirty seconds on, a snapshot names everything afresh. + #[test] + fn the_tracker_names_a_process_born_between_two_snapshots() { + let start = std::time::Instant::now(); + let mut tracker = Tracker::default(); + tracker.refresh(start).unwrap(); + + let mut child = std::process::Command::new("cmd.exe") + .args(["/c", "ping", "-n", "4", "127.0.0.1"]) + .stdout(std::process::Stdio::null()) + .spawn() + .unwrap(); + tracker + .refresh(start + std::time::Duration::from_secs(1)) + .unwrap(); + let seen_by_id = tracker.named("cmd.exe").any(|pid| pid == child.id()); + tracker + .refresh(start + Tracker::FULL_EVERY + std::time::Duration::from_secs(1)) + .unwrap(); + let seen_by_snapshot = tracker.named("cmd.exe").any(|pid| pid == child.id()); + let _ = child.kill(); + let _ = child.wait(); + + assert!(seen_by_id, "named from its id, between two snapshots"); + assert!(seen_by_snapshot, "and still named by the next snapshot"); + } + #[test] fn the_tracker_starts_from_a_full_snapshot() { let mut tracker = Tracker::default(); diff --git a/src/engine.rs b/src/engine.rs index 47cb67e..9eab52a 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -219,8 +219,9 @@ impl Engine { None => match self.await_sighting(stop) { Some(Sighting::Writer(pid)) => (Sighting::Writer(pid), self.identify(), true), // The entry names the game exactly: nothing to guess. - Some(Sighting::HandMade(game)) => { - (Sighting::HandMade(game.clone()), Some(game), true) + Some(Sighting::HandMade { pid, game }) => { + let signal = Some(game.clone()); + (Sighting::HandMade { pid, game }, signal, true) } None => break, }, @@ -263,9 +264,9 @@ impl Engine { Sighting::Writer(_) => { self.log_writer_exit(session_start, signal.as_ref(), fresh); } - Sighting::HandMade(game) => tracing::debug!( + Sighting::HandMade { pid, .. } => tracing::debug!( target: target::GAME, - pid = game.process_id, + pid, session = ?session_start.elapsed(), "The game marked by hand exited" ), diff --git a/src/engine/tests.rs b/src/engine/tests.rs index b5b8947..91f072c 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -321,6 +321,14 @@ fn marked(pid: u32, name: &str) -> GameSignal { } } +/// What the sensor reports for it. +fn hand_made(pid: u32, name: &str) -> Sighting { + Sighting::HandMade { + pid, + game: marked(pid, name), + } +} + #[test] fn a_game_marked_by_hand_is_a_session_from_its_launch() { // Windows never starts the writer for a title the person marked by @@ -329,7 +337,7 @@ fn a_game_marked_by_hand_is_a_session_from_its_launch() { // candidate drawing more cannot rename it. let stop = Arc::new(StopSignal::new().unwrap()); let sensor = Scripted::new(&stop) - .sightings(&[None, Some(Sighting::HandMade(marked(30, "TOS.exe")))]) + .sightings(&[None, Some(hand_made(30, "TOS.exe"))]) .waits(&[WaitOutcome::Exited]) .candidates(&[&[game(31, "other.exe")]]) .rendering(&[(31, 90.0)]); @@ -361,7 +369,7 @@ fn a_session_marked_by_hand_is_resumed_after_a_handover() { // hand still running, nothing run until it ends. let stop = Arc::new(StopSignal::new().unwrap()); let sensor = Scripted::new(&stop) - .sightings(&[Some(Sighting::HandMade(marked(30, "TOS.exe")))]) + .sightings(&[Some(hand_made(30, "TOS.exe"))]) .waits(&[WaitOutcome::Exited]); let dir = scratch(); let marker = Marker::in_dir(&dir); @@ -390,17 +398,22 @@ fn a_game_relaunched_within_the_grace_keeps_the_session() { let stop = Arc::new(StopSignal::new().unwrap()); let sensor = Scripted::new(&stop) .sightings(&[ - Some(Sighting::HandMade(marked(30, "TOS.exe"))), - Some(Sighting::HandMade(marked(32, "TOS.exe"))), + Some(hand_made(30, "TOS.exe")), + Some(hand_made(32, "TOS.exe")), ]) .waits(&[WaitOutcome::Exited, WaitOutcome::Exited]); let (sink, log) = recorder(); let mut config = quick_config(); config.detection.stop_delay = Duration::from_millis(50); - let mut engine = Engine::new(config, sensor).reporting_to(sink); + // Borrowed, so the script can be asked afterwards what was used. + let mut engine = Engine::new(config, &sensor).reporting_to(sink); engine.run(&stop).unwrap(); + assert!( + sensor.waits.borrow().is_empty(), + "the relaunched game was waited on too" + ); assert_eq!( seen(&log), vec![Session::Playing(Some(marked(30, "TOS.exe"))), Session::Idle], diff --git a/src/sensor.rs b/src/sensor.rs index 2f0b133..bdcd008 100644 --- a/src/sensor.rs +++ b/src/sensor.rs @@ -37,15 +37,14 @@ pub enum Sighting { Writer(u32), /// A process the person marked as a game by hand, for which Windows /// never starts the writer. The signal names it exactly. - HandMade(GameSignal), + HandMade { pid: u32, game: GameSignal }, } impl Sighting { /// The process a session waits on. pub fn pid(&self) -> u32 { match self { - Self::Writer(pid) => *pid, - Self::HandMade(signal) => signal.process_id.unwrap_or_default(), + Self::Writer(pid) | Self::HandMade { pid, .. } => *pid, } } } @@ -114,6 +113,8 @@ impl Sensor for &S { pub struct Windows { /// Resolved from the registry once at startup, never hard-coded. writer_exe: PathBuf, + /// The same, lowercased, to compare with a running process's path. + writer_path: String, /// Its file name, lowercased, to find it among the running processes. writer_name: String, /// What the idle looks keep between them. One thread asks, so a cell @@ -140,9 +141,11 @@ impl Windows { detection follows whatever is registered" ); } - let writer_name = hand_made::file_name_of(&writer_exe.to_string_lossy()).to_lowercase(); + let writer_path = writer_exe.to_string_lossy().to_lowercase(); + let writer_name = hand_made::file_name_of(&writer_path).to_owned(); Ok(Self { writer_exe, + writer_path, writer_name, look: RefCell::new(Look::default()), }) @@ -181,6 +184,45 @@ impl Look { } } +/// The rule of an idle look, apart from the machine: among the processes +/// the tracker knows, the registered writer first, then a process at the +/// exact path of an entry marked by hand. `path_of` asks a process its full +/// path, and only processes whose name matches are asked. +fn sighting_among( + processes: &Tracker, + writer_name: &str, + writer_path: &str, + hand_made: &[hand_made::Entry], + path_of: impl Fn(u32) -> Option, +) -> Option { + for pid in processes.named(writer_name) { + // Same name elsewhere on disk is not the registered writer; a path + // that cannot be read is taken as it, as before. + match path_of(pid) { + Some(path) if path.to_lowercase() != writer_path => continue, + _ => return Some(Sighting::Writer(pid)), + } + } + for entry in hand_made { + for pid in processes.named(entry.file_name_lower()) { + if let Some(path) = path_of(pid) + && entry.is(&path) + { + return Some(Sighting::HandMade { + pid, + game: GameSignal { + source: HAND_MADE, + process_name: Some(entry.display_name().to_owned()), + process_id: Some(pid), + process_path: Some(path), + }, + }); + } + } + } + None +} + impl Sensor for Windows { fn sighting(&self) -> Option { let mut look = self.look.borrow_mut(); @@ -193,30 +235,13 @@ impl Sensor for Windows { ); return None; } - let writer = self.writer_exe.to_string_lossy().to_lowercase(); - for pid in look.processes.named(&self.writer_name) { - // Same name elsewhere on disk is not the registered writer; a - // path that cannot be read is taken as it, as before. - match process::full_path(pid) { - Some(path) if path.to_lowercase() != writer => continue, - _ => return Some(Sighting::Writer(pid)), - } - } - for entry in &look.hand_made { - for pid in look.processes.named(entry.file_name_lower()) { - if let Some(path) = process::full_path(pid) - && entry.is(&path) - { - return Some(Sighting::HandMade(GameSignal { - source: HAND_MADE, - process_name: Some(entry.display_name().to_owned()), - process_id: Some(pid), - process_path: Some(path), - })); - } - } - } - None + sighting_among( + &look.processes, + &self.writer_name, + &self.writer_path, + &look.hand_made, + process::full_path, + ) } fn wait_for_exit( @@ -249,6 +274,94 @@ impl Sensor for Windows { mod tests { use super::*; + const WRITER: &str = r"c:\windows\system32\gamebarpresencewriter.exe"; + + /// A tracker that knows these processes by name, and a path lookup that + /// answers from the same table. + fn machine(processes: &[(u32, &str)]) -> (Tracker, impl Fn(u32) -> Option) { + let mut tracker = Tracker::default(); + let table: HashMap = processes + .iter() + .map(|(pid, path)| (*pid, (*path).to_owned())) + .collect(); + let ids: Vec = table.keys().copied().collect(); + tracker.update(&ids, |pid| { + table + .get(&pid) + .map(|path| hand_made::file_name_of(path).to_lowercase()) + }); + let path_of = move |pid: u32| table.get(&pid).cloned(); + (tracker, path_of) + } + + fn look(processes: &[(u32, &str)], marked: &[&str]) -> Option { + let (tracker, path_of) = machine(processes); + let entries: Vec = marked + .iter() + .map(|path| hand_made::Entry::new(path)) + .collect(); + sighting_among( + &tracker, + "gamebarpresencewriter.exe", + WRITER, + &entries, + path_of, + ) + } + + #[test] + fn the_registered_writer_is_a_sighting() { + let seen = look( + &[ + (4, r"C:\Windows\explorer.exe"), + (7, r"C:\Windows\System32\GameBarPresenceWriter.exe"), + ], + &[], + ); + assert_eq!(seen, Some(Sighting::Writer(7))); + } + + #[test] + fn a_writer_of_the_same_name_elsewhere_is_not() { + let seen = look(&[(7, r"D:\Elsewhere\GameBarPresenceWriter.exe")], &[]); + assert_eq!(seen, None); + } + + #[test] + fn a_game_marked_by_hand_is_found_by_its_exact_path() { + let game = r"D:\Games\The Other Side\TOS.exe"; + let seen = look(&[(4, r"C:\Windows\explorer.exe"), (30, game)], &[game]); + let Some(Sighting::HandMade { pid, game: signal }) = seen else { + panic!("expected the game marked by hand, got {seen:?}"); + }; + assert_eq!(pid, 30); + assert_eq!(signal.source, HAND_MADE); + assert_eq!(signal.process_name.as_deref(), Some("TOS.exe")); + assert_eq!(signal.process_path.as_deref(), Some(game)); + } + + #[test] + fn the_same_file_name_in_another_folder_is_not_the_game() { + let seen = look( + &[(30, r"C:\Temp\TOS.exe")], + &[r"D:\Games\The Other Side\TOS.exe"], + ); + assert_eq!(seen, None); + } + + #[test] + fn the_writer_comes_before_a_game_marked_by_hand() { + let game = r"D:\Games\The Other Side\TOS.exe"; + let seen = look( + &[ + (30, game), + (7, r"C:\Windows\System32\GameBarPresenceWriter.exe"), + ], + &[game], + ); + assert_eq!(seen, Some(Sighting::Writer(7))); + } + // The real sensor against the real machine. Skipped on a hosted runner // for the same reason as the registration test it wraps. #[test] From 28eeaea1cece8c35462d733f586490ba4834a042 Mon Sep 17 00:00:00 2001 From: Geoffrey Vancoetsem <10533139+geeooff@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:12:13 +0200 Subject: [PATCH 12/12] Write down what Lot 15 taught: documentation first, cost at rest AGENTS.md gains two principles -- Microsoft's documentation before any spike, and a reader of real data is not done until it has read the real data; and the idle look as the program's only timer, measured on the whole installed process against the last release -- the rule that a check next to a system call is split from it and tested alone, and three pitfalls: script string literals eating backslashes, a packaged host's shell reading a stale private %APPDATA%, and the registry notification that never comes for the Game Bar's writes. The changelog gives the idle figures as measured, and the lot page records the review. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 43 +++++++++++++++++++++++++++------- CHANGELOG.md | 3 ++- docs/design/15-marked-games.md | 16 +++++++++++++ 3 files changed, 53 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index cf15ea0..c35760e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,11 +21,24 @@ These decide most questions before they are asked. workaround, and a workaround that fails *visibly and harmlessly* to one that fails silently. `SetPreferredAppMode` is the one undocumented call in the program and `docs/design/06-notification-icon.md` says why it was let in. -- **Measure before deciding.** When the documentation leaves the deciding - question open, build the smallest thing that logs what the system actually - does, then decide. Several early designs here were wrong until measured; - the design record keeps the numbers. Do not report a mechanism as working - until a real game session has exercised it. +- **Microsoft's documentation first, then measure.** Before designing on a + Windows behaviour, read what Microsoft documents about it and cite it in + the record; a spike answers only what the documentation leaves open -- + how soon a process id is reused, which the pages do not say. When the + deciding question is open, build the smallest thing that logs what the + system actually does, then decide. Several designs here were wrong until + measured, and the design record keeps the numbers. Do not report a + mechanism as working until a real game session has exercised it, and do + not call a reader of real data done until it has read the real data: + a parser of Microsoft's game list passed its own tests and missed the + very title it was written for. +- **Cheap at rest, silent in game.** What players check first is what the + program costs them. The idle look is the only timer the program has and + must stay the cheapest thing it does; during a game the watcher waits on + a handle and does nothing. A change to either is measured on the whole + installed process against the last release -- processor over minutes, + private memory, handles -- with `presence-probe cost` and `footprint` + for the steps, and the figures go in the record and the changelog. - **Strict and simple over clever.** An unambiguous state ("it is off, fix the file") beats a fallback whose behaviour needs explaining. Put the strict option first and argue for a fallback only if it protects something @@ -98,7 +111,10 @@ deleted. OS only through `sensor::Sensor`, and `engine/tests.rs` scripts one to run whole sessions; a change to the loop gets a scenario there. The updater reads the network only through `update::feed::Feed`, scripted the same - way. + way. A rule that sits next to a system call is split from it -- the call + passed in as a function, as `sensor::sighting_among` takes the process + path lookup -- so the rule is tested without Windows and the call stays + a line. - **No test calls an external host**, ignored or not: the script runs the ignored tests on every developer machine, and a test that needs GitHub is a test that fails with the Wi-Fi. The network path is measured by hand @@ -180,8 +196,19 @@ commands; do not. ## Pitfalls that have already cost time - `sed` and shell substitutions eat backslashes: `GameModeExecutor\FanControl` - becomes `GameModeExecutorFanControl` and `validate` accepts it. Edit files - with a tool that takes literal strings, and grep the result. + becomes `GameModeExecutorFanControl` and `validate` accepts it. So do + string literals in a script that writes a file -- `target\release` became a + carriage return in a Python heredoc. Edit files with a tool that takes + literal strings, and grep the result. +- A shell started by a packaged host -- the Claude desktop application is + one -- may read `%APPDATA%` through the package's private copy: a file + read there need not be the one the watcher reads, and a stale copy of the + maintainer's configuration was quoted as theirs. What the watcher runs + is in its log; ask the maintainer for the file itself. +- Windows' registry change notification never arrives for the Game Bar's + writes to its game list, from an ordinary process, whichever way it is + asked -- eight ways measured, `docs/design/15-marked-games.md`. Do not + build on it again without the reason. - In PowerShell, `$LASTEXITCODE` is set by native commands only; after a cmdlet it is stale. Use `try { … -ErrorAction Stop } catch`. - `Select-Object -First N` **stops the upstream pipeline** once it has N diff --git a/CHANGELOG.md b/CHANGELOG.md index d26bb03..0a0e5e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,7 +41,8 @@ a section is written. ### Changed - Looking for a game while none runs costs about a twelfth of what it did: - some 0.016 % of one processor core, against 0.2 % before. + at rest the watcher now uses 0.026 % of one processor core, against + 0.324 % for 0.2.0, measured nine minutes each on the same machine. - The watcher starts whatever the configuration file says, rather than exiting with a code and no icon when the file is wrong at logon. `validate` still reports the exit codes 3 and 4 for scripts. diff --git a/docs/design/15-marked-games.md b/docs/design/15-marked-games.md index 51e318f..3224581 100644 --- a/docs/design/15-marked-games.md +++ b/docs/design/15-marked-games.md @@ -525,3 +525,19 @@ separate piece of work — the lot's own cost is nil. - **`status`** lists the games marked by hand, each with whether it runs and whether Microsoft's list knows it; **`presence-probe microsoft-list`** asks the list about any path. + +**The review before the pull request, 2026-09-23**, found and fixed: a +hand-made sighting carried its process id as an `Option` that a missing +value would have turned into 0, a wait that ends at once — now a field; +the list's key, when missing at start on a profile the Game Bar had not +written yet, was never tried again; Microsoft's list was decoded twice for +every entry, compared names in ASCII only and looked 600 bytes past a name, +less than two long folder names — now decoded once, compared in lower case +beyond ASCII, and looked at over three folder names' worth; the writer's +path was lowercased again at every look. And two rules sat inside system +calls: which process is a sighting, and which entries Microsoft's list +covers. Both are now pure functions the tests drive with made-up processes +and records — `sensor.rs` went from 8 % of its lines covered to 43 % on a +hosted runner, `hand_made.rs` from 37 % to 48 %; on a client machine, with +the tests that read it, the new modules are covered at 81 to 98 % and the +library at 72.8 %, up from 70 % at Lot 13.