diff --git a/AGENTS.md b/AGENTS.md index 5f1ad40..22d4753 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,7 +71,17 @@ own; propose, with the reasoning, and let the maintainer decide. **Needs explicit approval, every time:** creating the public repository, pushing to it, publishing a release, changing the scheduled tasks or the -configuration on the maintainer's machine, and any history rewrite. +configuration on the maintainer's machine, and rewriting the history of +`main`. + +**A branch's history is the agent's to rewrite until it reaches `main`**, +unpushed or pushed for a pull request, and it should be: before a push, +reduce a lot's commits to its logical steps. A fix to code written on the +same branch, a second attempt, a page touched again for the same purpose +belong in the commit they complete, not after it. A reader of `main` sees +one commit per thing done; nobody returns to a state corrected an hour +later, and the repository does not carry it. Force-pushing a pull +request's branch is part of this; `main` is never rewritten. **The standing rule:** a change to what the user sees updates `docs/getting-started.md` and `docs/how-it-works.md` in the same commit. A @@ -235,8 +245,6 @@ commands; do not. - A process started after `WM_QUERYENDSESSION` dies with `STATUS_DLL_INIT_FAILED`. Nothing can run a command at logoff; the session marker runs it at the next start instead. -- The refinement's single timed attempt is a known margin, not a calibration - — `docs/design/09-robustness.md`. - `.git/HEAD` does not change on commit; `build.rs` watches the ref it names and `packed-refs` too, or the stamp goes stale. diff --git a/CHANGELOG.md b/CHANGELOG.md index 8024204..c9e8f13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,12 @@ a section is written. file: a short-lived helper opens them and takes that cost with it when it ends. +### Fixed + +- A game still on its loading screen twenty seconds in no longer keeps its + launcher's name for the whole session: the watcher asks again every + twenty seconds, for two minutes at most. + ### Removed - The *Writing your own* recipe: the starter configuration's comments and the diff --git a/config.example.toml b/config.example.toml index b298fc8..4f41f2d 100644 --- a/config.example.toml +++ b/config.example.toml @@ -68,9 +68,11 @@ stop_delay = "2s" # A title brings several processes that all match Windows' known game list: a # launcher stub, an anti-cheat service, and the game. The satellites usually # start first, so the name captured at the very start is often the wrong one. -# Once, this far into a session, the per-process GPU counters are read and the -# candidate that is actually rendering wins. Set to "0s" to skip it and keep -# whichever process matched first. +# This far into a session, the per-process GPU counters are read and the +# candidate that is actually rendering wins. While there is no answer yet -- +# nothing rendering, no match -- they are read again at the same interval, six +# times at most. Set to "0s" to skip it and keep whichever process matched +# first. # # This only ever changes the *name*: detection never depends on it, and being # unable to read the counters simply means no answer. diff --git a/docs/design/00-detection.md b/docs/design/00-detection.md index 54cad6e..92fda07 100644 --- a/docs/design/00-detection.md +++ b/docs/design/00-detection.md @@ -62,6 +62,17 @@ back during the Starfield session did not release the reference, so the writer does not blink on focus changes — its lifetime is the session. Starfield was launched into the background and the writer still started, with another application in the foreground, so detection does not depend on focus. +*Corrected 2026-09-25:* it can. Battlefield 6, launched and left loading +behind another window, ran from 15:05:34 without a writer; the writer +started at 15:08:02, the second the maintainer brought the game to the +front, and stayed when the focus went away again. Windows' page says the +writer is told when a game gets focus, and this is Windows starting it on +that event. Starfield's run stands as measured; what differs between the +two was not looked at. Either way it is Windows' verdict, and a game +loading behind another window has no need of its gaming commands yet. +The same day, Wreckfest 2's *Change settings* window started the writer +too: it is `Wreckfest2.exe` itself, and Windows took it for the game, a +14-second session. `GameDVR\LastGameActivity` matched the start timestamp to the second in both sessions. As a negative control, launching and focusing Notepad spawned nothing. @@ -73,7 +84,16 @@ same afternoon. [Lot 1](01-console-watcher.md) has those figures and the decision they led to. *Corrected 2026-09-25:* what it waits for is the user's next keyboard or mouse input after the game has exited — some twenty seconds after it, and 2 h 22 min once, with nobody at the machine; -[Lot 18](18-game-gone-nobody-there.md). +[Lot 18](18-game-gone-nobody-there.md). *And later that day:* not twenty +seconds. With the maintainer at the keyboard, six titles took 0.6 s to +85 s. The writer is never released while nobody touches the machine; what +decides the time otherwise is not known. + +Nor is the writer one per title. Two games running together share it: +American Truck Simulator and Euro Truck Simulator 2, then Wreckfest 2 from +Steam and Starfield from the Store, on 2026-09-25. Each pair had one writer +process from the first game's start to after the second's exit +([Lot 9](09-robustness.md)). ## The limit found in the field, 2026-09-20 diff --git a/docs/design/01-console-watcher.md b/docs/design/01-console-watcher.md index 6339c44..578eb46 100644 --- a/docs/design/01-console-watcher.md +++ b/docs/design/01-console-watcher.md @@ -111,6 +111,9 @@ the party chat change nothing. The per-title theory stays dead; what the two sessions of each title above differed in is most likely when the maintainer next touched the machine, which was not noted then — inferred, not measured. [Lot 18](18-game-gone-nobody-there.md) has the details. +*Corrected the same afternoon:* not twenty seconds. With the maintainer at +the keyboard, six titles took 0.6 s to 85 s; only "never while nobody +touches the machine" stands. The second sessions were measured from independent sources, since the log did not yet record the writer's exit: Steam's `gameoverlay_ui.txt` for Battlefield diff --git a/docs/design/03-game-naming.md b/docs/design/03-game-naming.md index 1505473..d767f62 100644 --- a/docs/design/03-game-naming.md +++ b/docs/design/03-game-naming.md @@ -54,12 +54,15 @@ allowed in exactly one narrow form, and its absence is a normal outcome. ## The refinement -Once, a little way into the session (`identify_after`, 20 s by default), the +A little way into the session (`identify_after`, 20 s by default), the candidates the Known Game List produced are ranked by their share of the 3D GPU engine over a one-second sample. The one rendering wins. The rule never -promotes a process the list did not match; a single candidate, unreadable -counters, or a game still on its loading screen all mean "keep the current -name", and the log says which. +promotes a process the list did not match; a single candidate that is the +name in use, or the GPU confirming it, means "keep the current name", and +the log says which. *Corrected 2026-09-25:* this said "once", and that +unreadable counters and a loading screen kept the name too. Those, and no +match at all, are no answer rather than a verdict, and the question is asked +again at the same interval, six times at most — [Lot 9](09-robustness.md). **One match left is not nothing to say.** Measured 2026-09-15 on Battlefield 6: the EA anti-cheat *launcher* matches the install folder, starts before the @@ -93,7 +96,9 @@ harmless. session, `status` read 0.0 % for every candidate; ten seconds later, 75 %. The single timed attempt landed just inside the window that makes it work. That is a margin, not a calibration — [Lot 9](09-robustness.md) has the design that -would replace the timer. +would replace the timer. *Since 2026-09-25* an attempt that reads nothing +rendering is followed by another, so a loading screen costs twenty seconds +rather than the name. ## Packaged titles are matched wherever they are installed diff --git a/docs/design/05-windowless-watcher.md b/docs/design/05-windowless-watcher.md index 881cddb..04a05f2 100644 --- a/docs/design/05-windowless-watcher.md +++ b/docs/design/05-windowless-watcher.md @@ -131,4 +131,8 @@ next start — [Lot 9](09-robustness.md). No icon, no menu, nothing drawn. No `ShutdownBlockReasonCreate` either ("Restoring the fan profile…" on Windows' shutdown screen) — worth having, -belongs with the rest of the shutdown work in Lot 9. +belongs with the rest of the shutdown work in Lot 9. *Corrected +2026-09-25:* not worth having. Windows never lists a program without a +visible window on that screen, registering a reason is what would put it +there, and nothing is restored at session end to announce; decided against +in [Lot 9](09-robustness.md). diff --git a/docs/design/09-robustness.md b/docs/design/09-robustness.md index dd2c9ca..be5015c 100644 --- a/docs/design/09-robustness.md +++ b/docs/design/09-robustness.md @@ -2,13 +2,15 @@ **Status: partly done.** The session marker is built and verified; the configuration faults and the live reload are built and verified in the -field; three smaller items remain. +field. Of the three smaller items, on 2026-09-25: the refinement's retry is +built and waits for a game to meet its case, `ShutdownBlockReasonCreate` +was decided against, and two games back to back are measured and closed. - [x] Restore at the next start what a logoff could not — done 2026-09-16, a race fixed and re-verified 2026-09-17 - [x] Configuration faults shown in the tray, and live reload — built 2026-09-19, measured without a game and then verified across two Starfield sessions on 2026-09-20, below -- [ ] Stop timing the refinement; let the OS say when — below -- [ ] `ShutdownBlockReasonCreate`, so Windows' shutdown screen says what is being restored rather than naming the process -- [ ] Behaviour across two games launched back to back +- [ ] Stop timing the refinement; let the OS say when — the first of the two changes below built 2026-09-25, an attempt with no verdict followed by another; not yet met in a game, four Battlefield 6 sessions settling at the first attempt. The second, waiting on the named process, goes with [Lot 18](18-game-gone-nobody-there.md) +- [x] `ShutdownBlockReasonCreate`, so Windows' shutdown screen says what is being restored rather than naming the process — decided against on 2026-09-25, from Microsoft's documentation and the log: the screen never lists the watcher, a reason is what would put it there, and nothing is restored at session end to announce. Below +- [x] Behaviour across two games launched back to back — measured 2026-09-25 with four titles from Steam and the Store and pinned by two scenarios, below: two sessions in a row, one when the two run together, named after the first. Closed the same day, the name left to Lot 18 - [x] Give the engine a seam, so its loop can be tested without a game — done 2026-09-17, below ## The session marker @@ -295,6 +297,209 @@ Two changes worth weighing, in order of appetite: mattered — the launcher exiting — would have woken it exactly then. The same OS-native shape the rest of detection uses. +**Decided 2026-09-25: the first, now.** The maintainer took the +recommendation: retry until a verdict, with a cap, and leave the wait on the +named process to [Lot 18](18-game-gone-nobody-there.md), whose second option +is the same handle put to a bigger use — ending the session on it — and +should be measured once for both. + +**As built.** `refine` returns one of three answers instead of a name or +nothing, and only the third is followed by another attempt: + +| What the attempt found | Answer | Why | +| --- | --- | --- | +| A candidate the GPU finds drawing, not the name in use; or one match left, the named process gone | renamed | the case the refinement exists for | +| The GPU finds the name in use drawing; or one match, and it is the name in use or that process is still alive | kept | a verdict: Starfield and Skyrim, most sessions | +| No process matches the list | undecided | a session that started unnamed, or a list Windows has not written yet | +| The list or the counters cannot be read | undecided | may be passing; if not, the cap ends it | +| Nothing rendering | undecided | the loading screen, the case that cost Battlefield 6 its name | + +The attempts are `identify_after` apart — 20 s by default, so the first is +where it always was — and six at most, `REFINE_ATTEMPTS` in the engine: two +minutes, longer than any loading screen measured here, and a game compiling +its shaders draws its progress screen, so it is rendering. No new key: the +interval is the one the file already has, and a count nobody has a reason +to change is a constant. After the sixth the name stays, said at `debug`, +and the wait on the writer asks for no timeout again. + +**The cost, measured 2026-09-25** with `presence-probe footprint` and a +temporary thirty more reads: the counters' first read leaves 18 handles and +0.4 MB, three more no handle and at most 0.1 MB, thirty more nothing at all. So +the retries cost their one-second sample each, in the first two minutes of a +session that has not settled, and nothing after. A session that settles at +the first attempt — every one in this record but one Battlefield 6 — +costs what it did. + +**Scenarios.** Five in `engine/tests.rs`: nothing rendering, then the game +drawing at the second attempt — Battlefield 6's shape; nothing ever +rendering, six attempts and no seventh; the launcher still alive at the +first attempt and gone at the second, the survivor rule getting its chance; +an unnamed session named when a match appears; one match that is the name +in use, a verdict at the first attempt. The scripted sensor now records the +timeout each wait asked for, which is how "no seventh" is seen. With the +count set to one — the old rule — the three that need a second attempt +fail, and pass with six. + +**To see in the field.** A session whose first attempt finds nothing +rendering, then the rename at a later one: at `debug`, *No verdict on the +game's name yet, so the refinement asks again in 20s* between the two. Not +seen yet; the item stays open until it is. + +**Two Battlefield 6 sessions on this build, 2026-09-25**, and neither +needed a second attempt. At 14:59:34 the session was named after the EA +anti-cheat launcher, and the first attempt, 21 s in, read `bf6.exe` at 16 % +of the rendering in the game's menu and renamed it. At 15:08:02 `bf6.exe` +was named directly, the only match, a verdict at the first attempt. Since +Windows can wait for the game to come to the front before starting the +writer ([Detection](00-detection.md), corrected the same day), the game is +often drawing by the time the first attempt comes. + +**Provoking it failed, and why, 2026-09-25.** Two more launches, the +interval shortened in the maintainer's file before each. At `3s`, `bf6.exe` +had started 5 s before the session and read 2 % at the first attempt: +renamed. At `10s`, the maintainer went to another window at the start beep +and stayed away: `bf6.exe` read 32 % at the first attempt, still drawing +behind that window, and was renamed. Four sessions of Battlefield 6 that +day, a verdict at the first attempt each time. On this machine and this +title the case the retry covers does not come up any more, and nothing +short of it exercises the retry. The five scenarios cover it; the item +stays open until a session meets it, and the `debug` line will say so. +Changing the file mid-game resumed the session as Lot 9's reload says, +nothing run. + +## `ShutdownBlockReasonCreate`: decided against + +The item assumed that Windows' shutdown screen names the watcher while it +restores the profile, and that a reason string would say what it restores +instead. Read against Microsoft's documentation and the log on 2026-09-25, +both halves of that are wrong. + +**What Microsoft documents.** [Shutdown Changes for Windows +Vista](https://learn.microsoft.com/en-us/windows/win32/shutdown/shutdown-changes-for-windows-vista), +[Application Shutdown Changes in Windows +Vista](https://learn.microsoft.com/en-us/previous-versions/windows/desktop/ms700677(v=vs.85)) +and the [function's own +page](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-shutdownblockreasoncreate): + +- The full-screen shutdown screen lists the applications that *block*: + that answer `FALSE` to `WM_QUERYENDSESSION`, or take more than five + seconds over it or over `WM_ENDSESSION`. An application that answers + `TRUE` is closed whether or not it registered a reason. +- An application with no visible top-level window is not allowed to block + at all: it is terminated if it refuses, or after five seconds on either + message. It is never listed. +- Registering a reason changes that: Windows then treats the application + as one with a visible window. It is listed when another application + blocks, shown after five seconds of its own, and given thirty seconds for + `WM_ENDSESSION` once the user forces the shutdown. The archived article + offers that as a usage model of its own: a windowless application that + needs more than five seconds for `WM_ENDSESSION`. +- Microsoft's first rule for all of it: applications should not block + shutdown. + +**What the watcher does.** It answers `TRUE` at once, starts stopping, +and holds `WM_ENDSESSION` until the engine returns. Its window is +`WS_OVERLAPPED` and never shown, so it has no visible top-level window; the +notification icon is not a window of its own. The log on the maintainer's +machine has nine session ends from 2026-09-18 to 2026-09-25, all without a +game: from Windows' request to *Stopped* in 2 to 51 ms. Of the two mid-game +logoffs recorded here, the one in [Lot 5](05-windowless-watcher.md) +released the session in 62 ms, and in the race above the command had +failed 110 ms after the request; both commands were dead on arrival. So +today the screen shows nothing of the watcher. That is read from the +documentation and the log, not watched on a shutdown mid-game. + +**Why not build it.** + +1. Nothing is restored at session end. A process started after + `WM_QUERYENDSESSION` dies with `STATUS_DLL_INIT_FAILED` + ([Lot 5](05-windowless-watcher.md)), and the profile comes back at the + next start, through the marker. A reason reading "Restoring the fan + profile" would be untrue. +2. The reason is what would put the watcher on the screen. With one + registered for the length of a game, a shutdown that some other program + holds up would list GameModeExecutor beside it, as a program blocking. + That runs against *Discreet* and against Microsoft's first rule. +3. Its one real use, more than five seconds for `WM_ENDSESSION`, has no + work to spend them on: the commands die within 100 ms. + +**Decided 2026-09-25:** not built. The maintainer confirmed the +recommendation, and Lot 5's "worth having" line is corrected in place. + +## Two games back to back: measured + +**What the engine does**, read from the code on 2026-09-25: + +| Case | The engine | +| --- | --- | +| The second game starts before Windows releases the writer, and the same writer serves it | One session. The first game's name for both, in the tray and in the stop commands' `{process_name}`. The start and stop commands run once. | +| The writer is released and a writer is back within `stop_delay` | The same: `comes_back` goes on with the session, and nothing is named again. | +| The next writer comes after `stop_delay` | Two sessions: the stop commands, then the start commands, seconds apart. | +| Both games running at once | Whatever the writer does. If one serves both, one session until it is released, named after the first game. | +| A game marked by hand, then a game with a writer | The engine waits on the marked game's handle, so the other game is seen only when the first exits. The session then goes on, still under the marked name. | + +**The protocol.** `presence-probe watch 3600` in a terminal logs the writer +starting, exiting or restarting, with its process id, and each game +identified and exited. The installed watcher runs at `debug` beside it. + +- A: a game, quit, a second game within ten seconds of quitting. +- B: the same with a minute between the two games. +- C: the second game started while the first is on. C′, the maintainer's + addition: the same with a Steam game and a Store one. + +Each game has to come to the front at least once: Windows may not start +the writer for a game loading behind another window +([Detection](00-detection.md)). + +**Measured 2026-09-25, 16:02–16:15**, by the maintainer with American Truck +Simulator and Euro Truck Simulator 2 from Steam, on the installed build +`babdcf4d`: + +| Run | Game | Writer | Game exited | Writer released | The watcher | +| --- | --- | --- | --- | --- | --- | +| A | ATS | 13512, from 16:03:24 | 16:03:54.2 | 16:03:54.8 | `amtrucks.exe`, 16:03:24 to 16:03:56 | +| A | ETS2, 12 s after | 33200, from 16:04:06 | 16:05:04.4 | 16:06:02.9 | `eurotrucks2.exe`, 16:04:06 to 16:06:04 | +| B | ATS | 34704, from 16:06:10 | 16:07:34.2 | 16:08:24.5 | `amtrucks.exe`, 16:06:10 to 16:08:26 | +| B | ETS2 | 30796, from 16:08:42 | 16:10:55.9 | 16:10:56.9 | `eurotrucks2.exe`, 16:08:42 to 16:10:58 | +| C | ATS, then ETS2 beside it for 15–20 s | 18008, from 16:12:06 | ATS 16:13:28.7, ETS2 16:14:09.7 | 16:15:34.4 | `amtrucks.exe`, 16:12:07 to 16:15:36 | +| C′ | Wreckfest 2 from Steam, then Starfield from the Store beside it for 30–50 s | 34536, from 16:26:04 | Wreckfest 2 16:29:22.4, Starfield 16:30:33.3 | 16:30:52.3 | `Wreckfest2.exe`, 16:26:05 to 16:30:54 | + +- **A second game gets a writer of its own when the first was released.** + A and B are two sessions each, each named right, both edges under its + own name. In A the stop and start commands ran ten seconds apart. A + second game launched before the release did not happen: ATS let its + writer go 0.6 s after it exited. +- **Two games at once share one writer.** In C, writer 18008 lasted from + the first game's start to 85 s after the second's exit. One session, the + commands right and once on each edge. But the name was `amtrucks.exe` + throughout, in the tray and in the stop commands, while ETS2 ran alone + for 41 s. C′ is the same across a Win32 game and a packaged one: one + writer, one session named `Wreckfest2.exe`, Starfield alone for 71 s + under that name. The table's first case, a second game launched before + the release, would be the same shape: inferred from C, not seen. +- Before C′, Wreckfest 2's *Change settings* window was a session of its + own, 14 s: it is `Wreckfest2.exe`, and Windows took it for the game + ([Detection](00-detection.md)). +- Battlefield 6, relaunched earlier the same day after its writer was + released, gave B's shape with one title. +- The release came 0.6 s to 85 s after the game's exit, the maintainer + at the keyboard throughout. That corrects + [Lot 18](18-game-gone-nobody-there.md)'s "some twenty seconds after the + first input", and its table has the rows. + +**Scenarios.** Two in `engine/tests.rs`, pinning both shapes as measured: +two games one after the other are two sessions, each identified and +refined afresh, the edges logged by a command under each game's name; two +games under one writer are one session under the first name. + +**Closed 2026-09-25 with this record**, the maintainer agreeing. Two games +in a row are two sessions and two at once are one, which is what the +commands need. Someone who would rather keep A as one session can lengthen +`stop_delay`. The one flaw is C's name, and naming the second game takes +the wait on the named process. That is the third time it has come up, after the +refinement's second option and [Lot 18](18-game-gone-nobody-there.md)'s +option 2, and it would be measured once for all three. + ## The engine has no tests, and the reason is structural Measured 2026-09-17 with `cargo llvm-cov`, ignored tests included: the diff --git a/docs/design/18-game-gone-nobody-there.md b/docs/design/18-game-gone-nobody-there.md index 9647be4..c4bff89 100644 --- a/docs/design/18-game-gone-nobody-there.md +++ b/docs/design/18-game-gone-nobody-there.md @@ -23,12 +23,33 @@ input from the maintainer's own notes: | 2026-09-24, A | hands off four minutes, screen on, then the mouse at about 02:37 | 02:33:08 | 02:37:22 | **4 min 14 s** | | 2026-09-24, B | the machine used throughout | 16:53:04 | 16:53:20 | **16.8 s** | | 2026-09-25, C | as A, an Xbox party chat open and pinned in front; the mouse at about 02:13 | 02:09:18 | 02:13:23 | **4 min 5 s** | +| 2026-09-25, Battlefield 6 | the machine used throughout: the log opened from the menu, the game relaunched | 15:05:04 | 15:05:22 | **18 s** | +| 2026-09-25, American Truck Simulator | the machine used throughout, the next game launched from Steam | 16:03:54 | 16:03:55 | **0.6 s** | +| 2026-09-25, Euro Truck Simulator 2 | the same | 16:05:04 | 16:06:03 | **58.5 s** | +| 2026-09-25, American Truck Simulator | the same | 16:07:34 | 16:08:25 | **50.3 s** | +| 2026-09-25, Euro Truck Simulator 2 | the same | 16:10:56 | 16:10:57 | **1.0 s** | +| 2026-09-25, Euro Truck Simulator 2, after the first beside it | writing a message | 16:14:10 | 16:15:34 | **85 s** | +| 2026-09-25, Wreckfest 2's settings window | the machine used throughout | 16:24:54 | 16:24:59 | **5.2 s** | +| 2026-09-25, Starfield, after Wreckfest 2 beside it | the same | 16:30:33 | 16:30:52 | **19 s** | + +The Battlefield 6 row's exit is read from the EA anti-cheat's file-system +filter unloading (`Microsoft-Windows-FilterManager` event 1), the rows +after it from `presence-probe watch`, which sees a process gone within +200 ms; they come from [Lot 9](09-robustness.md)'s runs of two games back +to back. The maintainer was at the keyboard throughout all of them, idle +two or three seconds at most. **Measured:** Windows does not release its "a game is running" signal while nobody touches the machine, and releases it some twenty seconds after the first keyboard or mouse input that follows the game's exit. The screen is not the trigger — it stayed on in A and C — and the party chat changes nothing. The watcher's own share was its two-second `stop_delay` every time. +*Corrected 2026-09-25, afternoon:* the twenty seconds do not hold. With the +maintainer at the keyboard, the rows from American Truck Simulator on range +from 0.6 s to 85 s, and the foreground window at the release — Steam, +Claude, Explorer — does not sort the fast from the slow. What stands is the +first half: never while nobody touches the machine. What decides the time +once someone does is not known. **Inferred, not measured:** that the delays of 52 seconds and two minutes recorded in [Lot 1](01-console-watcher.md) were the time until the @@ -84,7 +105,9 @@ Against: 1. **Say it, and nothing more.** Done on 2026-09-25: How it works and Getting started now say the wait lasts until the next touch of the - mouse or the keyboard. The evening case stays as it is. + mouse or the keyboard. The evening case stays as it is. Reworded the + same afternoon: the wait never ends while nobody touches the PC, and + took from under a second to a minute and a half at the keyboard. 2. **End on the game's own exit, when the game is named.** The engine would wait on the named process as well as on the writer, and end the session once no process matching the title's Known Game List entry is left — @@ -92,7 +115,10 @@ Against: its session, and whatever the name, since the game itself still matches while it runs. An unnamed session keeps the writer alone. It rewrites Lot 3's rule and reopens the Lot 1 decision, and the two risks above are - what it would have to be measured against. + what it would have to be measured against. The same wait is + [Lot 9](09-robustness.md)'s second way to name the game — re-identify + when the named process exits — left here on 2026-09-25 so that the + handle is measured once, for both uses. 3. **A Windows signal not found yet.** Microsoft's documentation read again first, for a documented signal of a game's end that does not wait for input — Game Mode, GameDVR, the Game Bar's own writes. None is known @@ -110,4 +136,6 @@ clever* argues against, unless the maintainer wants one. few weeks of the maintainer's titles: Forza, Battlefield 6 and its anti-cheat launcher, Starfield. - Whether the input rule holds for other titles and on the second machine, - with the same protocol as A and B. + with the same protocol as A and B. At the keyboard, six titles measured + on 2026-09-25 took 0.6 s to 85 s; A, hands off, has been run on GTA + Online alone. diff --git a/docs/design/README.md b/docs/design/README.md index 7141885..db3ae61 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -20,7 +20,7 @@ session rather than when the code compiles. Each has its own page. | 6 | [Notification area icon](06-notification-icon.md) | done | | 7 | [Icon, tooltip and menu as one state](07-tray-state.md) | done | | 8 | [Distribution](08-distribution.md) | done; the second machine's three proposals built and verified 2026-09-25 | -| 9 | [Robustness](09-robustness.md) | partly done; faults and live reload done, three smaller items open | +| 9 | [Robustness](09-robustness.md) | partly done; faults and live reload done; 2026-09-25: the refinement's retry built, `ShutdownBlockReasonCreate` decided against, two games back to back measured and closed | | 10 | [Configuration window](10-configuration-window.md) | proposed; set aside until a UX/UI analysis refines it | | 11 | [Documentation for the people who use it](11-user-documentation.md) | done | | 12 | [Editing the configuration without breaking it](12-editing-on-a-copy.md) | proposed | diff --git a/docs/getting-started.md b/docs/getting-started.md index fc7d1be..e319b29 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -287,8 +287,9 @@ too. ## When something is not right **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 +Bring it to the front first: Windows may not count a game still loading +behind another window. Then run `gamemode-executor status`. If it says no +game is running, Windows itself is not flagging that title — see [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 @@ -337,10 +338,11 @@ and `%LOCALAPPDATA%\GameModeExecutor\updates\install.log` has its own account. The version you had keeps running either way. **The fans take ages to calm down after I quit.** -That wait is Windows': it keeps its "a game is running" signal until you next -touch the mouse or the keyboard, then lets go about twenty seconds later. Quit -and walk away, and the fans wait for your return. Nothing to tune; -`log_level = "debug"` shows where the time went. *(Corrected 2026-09-25.)* +That wait is Windows': it keeps its "a game is running" signal for as long as +nobody touches the PC, and at the keyboard lets go within a second to a +minute and a half. Quit and walk away, and the fans wait for your return. +Nothing to tune; `log_level = "debug"` shows where the time went. +*(Corrected 2026-09-25.)* ## Turning it off diff --git a/docs/how-it-works.md b/docs/how-it-works.md index 6f9ed1a..c187199 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -17,6 +17,8 @@ Windows ships a component for the Game Bar — the overlay you get with `Win+G`. When Windows decides a game is running, it starts a small process called `GameBarPresenceWriter.exe`. When the game is gone, Windows shuts that process down. That process existing *is* Windows' own verdict that you are playing. +Windows may wait for the game to first come to the front: Battlefield 6, left +loading behind another window, counted only once it was brought forward. So the watcher does something very simple: it watches whether that process exists. @@ -89,25 +91,26 @@ A real session looked like this: 17:53:35 Game identified more precisely: bf6.exe (74% of the rendering) ``` -This only ever changes the *name*. If the counters cannot be read, or the game -is still on a loading screen, the watcher keeps whatever matched first and -carries on. Nothing about detection depends on it. +Still on a loading screen, nothing drawing yet? It asks again every twenty +seconds, for two minutes at most, then keeps whatever matched first. This +only ever changes the *name*: nothing about detection depends on it. ## The wait after you quit The commands for "game stopped" can fire well after you have quit. -That wait is Windows'. It keeps its "a game is running" signal until you next -touch the mouse or the keyboard after the game has closed, then lets go about -twenty seconds later. Keep using the PC and it takes seconds; quit and walk -away, and the commands wait for your return — two hours, once. No setting -changes that. This program adds about two seconds, a grace period in case -Windows briefly restarts the signal mid-session. +That wait is Windows'. It never lets go of its "a game is running" signal +while nobody touches the PC: quit and walk away, and the commands wait for +your return — two hours, once. At the keyboard it has taken from under a +second to a minute and a half. No setting changes that. This program adds +about two seconds, a grace period in case Windows briefly restarts the +signal mid-session. Set `log_level = "debug"` and the log says when Windows let go and whether the game had already exited. -*Corrected 2026-09-25: this page called the wait unpredictable.* +*Corrected 2026-09-25: this page called the wait unpredictable, then said +about twenty seconds.* ## What it costs your machine @@ -119,7 +122,7 @@ size* column in *Details*; its *Memory* column shows less. | --- | --- | --- | | Waiting for a game, looking every two seconds | 0.03 % of one core | about 2 MB | | During a game | nothing measurable: it waits for Windows to wake it | unchanged | -| Naming a game from what the graphics card draws, once a session | | about 0.25 MB, once | +| Naming a game from what the graphics card draws, early in a session | | about 0.25 MB, once; asking again adds nothing | | The icon's menu, the first time it opens | | about 1 MB, once — Windows' cost for a program's first menu | | *Edit configuration*, *Open log*, *Documentation* | | nothing that stays: a short-lived helper opens them and takes the cost with it | | *Check for updates* | | about 1 MB, until the watcher next starts | diff --git a/docs/reference.md b/docs/reference.md index 13caecd..0b4be33 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -65,7 +65,7 @@ log_level = "info" # error | warn | info | debug | trace [detection] 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 +identify_after = "20s" # when to ask the GPU which matched process is the game, and again while it has no answer (six times at most) gpu_sample = "1s" [on_game_start] diff --git a/src/config.rs b/src/config.rs index fbb36e3..a8896ac 100644 --- a/src/config.rs +++ b/src/config.rs @@ -99,7 +99,8 @@ pub struct Detection { #[serde(with = "humantime_serde")] pub stop_delay: Duration, /// How long into a session to wait before asking which of the matched - /// processes is really the game. Zero skips the question entirely. + /// processes is really the game, and between the attempts that follow + /// while there is no answer. Zero skips the question entirely. #[serde(with = "humantime_serde")] pub identify_after: Duration, /// How long to sample the GPU counters for. Utilisation is a rate, so it diff --git a/src/engine.rs b/src/engine.rs index 9eab52a..776456f 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -33,6 +33,30 @@ use crate::marker::Marker; use crate::sensor::{HAND_MADE, Sensor, Sighting}; use crate::win::{StopReason, StopSignal}; +/// How many times a session asks which process is the game, `identify_after` +/// apart, before it settles for the name in use: two minutes at the default. +/// +/// Only an attempt that reaches no verdict is followed by another -- nothing +/// rendering yet, no match, counters that did not answer. Two Battlefield 6 +/// sessions an hour apart read 0.0 % for every candidate at 10 s and 75 % at +/// 20 s, so a single timed attempt was a lottery; a loading screen longer +/// than the first interval now costs a retry, not the name. Measured +/// 2026-09-25: the counters' first read leaves 18 handles, thirty more leave +/// nothing, so a retry costs its second of sampling and no more. +const REFINE_ATTEMPTS: u32 = 6; + +/// What one attempt at naming the game more precisely came to. +#[derive(Debug)] +enum Refinement { + /// A better name than the one in use. + Renamed(GameSignal), + /// The name in use stands: the GPU confirmed it, or the one match gives + /// no reason to change it. + Kept, + /// Nothing to decide on yet, so the attempt does not settle the name. + Undecided, +} + /// What the engine tells the outside world about the session. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Session { @@ -236,27 +260,48 @@ impl Engine { // matched first is not necessarily the one rendering: Battlefield 6 // came up as its EA anti-cheat. Measured, that is the minority case. // 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. A game marked by hand - // was found by its exact path, so there is nothing to refine. - let mut refine_due = fresh + // found nothing to arbitrate. So: a little way into the session, + // ask which candidate is actually rendering, and expect "no better + // answer" more often than not -- asking again only while there is + // no answer at all. A game marked by hand was found by its exact + // path, so there is nothing to refine. + let mut attempts_left = if fresh && matches!(anchor, Sighting::Writer(_)) - && !self.config.detection.identify_after.is_zero(); + && !self.config.detection.identify_after.is_zero() + { + REFINE_ATTEMPTS + } else { + 0 + }; let stopped = loop { - let timeout = refine_due.then_some(self.config.detection.identify_after); + let timeout = (attempts_left > 0).then_some(self.config.detection.identify_after); // 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_exit(anchor.pid(), stop, timeout)? { WaitOutcome::Stopped => break true, WaitOutcome::TimedOut => { - refine_due = false; - if let Some(better) = self.refine(signal.as_ref()) { - signal = Some(better); - // The name on screen was the launcher's until now. - self.report(&Session::Playing(signal.clone())); - self.remember(signal.as_ref()); + attempts_left = attempts_left.saturating_sub(1); + match self.refine(signal.as_ref()) { + Refinement::Renamed(better) => { + attempts_left = 0; + signal = Some(better); + // The name on screen was the launcher's until now. + self.report(&Session::Playing(signal.clone())); + self.remember(signal.as_ref()); + } + Refinement::Kept => attempts_left = 0, + Refinement::Undecided if attempts_left > 0 => tracing::debug!( + target: target::GAME, + attempts_left, + "No verdict on the game's name yet, so the refinement asks again in {}", + humantime::format_duration(self.config.detection.identify_after) + ), + Refinement::Undecided => tracing::debug!( + target: target::GAME, + "No verdict on the game's name after {REFINE_ATTEMPTS} attempts, \ + so the current name stays for the session" + ), } continue; } @@ -458,23 +503,32 @@ impl Engine { /// Ask the GPU which of the matched processes is really the game. /// - /// Returns `None` when there is nothing better to say: counters this - /// account may not read, a game still on its loading screen, or a single - /// candidate that is already the name in use. A single candidate that is - /// *not* the name in use is news, and needs no GPU to establish. - /// Naming is a convenience: no answer is a fine answer. - fn refine(&self, current: Option<&GameSignal>) -> Option { + /// The name in use is [`Refinement::Kept`] when the GPU confirms it, or + /// when one process matches and it is that one or the named process is + /// still alive. One match that is *not* the name in use, the named + /// process gone, is news, and needs no GPU to establish. [`Refinement::Undecided`] + /// is everything that says nothing about the game yet: no match, a list + /// or counters that cannot be read, nothing rendering. Naming is a + /// convenience: no answer is a fine answer. + fn refine(&self, current: Option<&GameSignal>) -> Refinement { let candidates = match self.sensor.candidates() { Ok(candidates) => candidates, Err(error) => { tracing::debug!( target: target::GAME, error = %format!("{error:#}"), - "Refinement skipped" + "Cannot read the game list to refine the name" ); - return None; + return Refinement::Undecided; } }; + if candidates.is_empty() { + tracing::debug!( + target: target::GAME, + "No running process matches the game list" + ); + return Refinement::Undecided; + } if candidates.len() < 2 { // One match left is not the same as nothing to say. Battlefield 6 // does this every session: the EA anti-cheat *launcher* matches the @@ -485,7 +539,6 @@ impl Engine { let named_is_alive = current .and_then(|signal| signal.process_id) .is_some_and(|pid| self.sensor.is_running(pid)); - let count = candidates.len(); if let Some(survivor) = detect::lone_survivor(candidates, current, named_is_alive) { tracing::info!( target: target::GAME, @@ -494,14 +547,13 @@ impl Engine { "Game identified more precisely: {} (the only match left)", survivor.name() ); - return Some(survivor); + return Refinement::Renamed(survivor); } tracing::debug!( target: target::GAME, - candidates = count, "Refinement has nothing to arbitrate, keeping the current name" ); - return None; + return Refinement::Kept; } let load = match self.sensor.rendering_load(self.config.detection.gpu_sample) { @@ -510,13 +562,15 @@ impl Engine { tracing::debug!( target: target::GAME, error = %format!("{error:#}"), - "Cannot read the GPU counters, keeping the first match" + "Cannot read the GPU counters" ); - return None; + return Refinement::Undecided; } }; - let best = detect::most_active(candidates, &load)?; + let Some(best) = detect::most_active(candidates, &load) else { + return Refinement::Undecided; + }; let share = best .process_id .and_then(|pid| load.get(&pid)) @@ -527,9 +581,9 @@ impl Engine { if share <= 0.0 { tracing::debug!( target: target::GAME, - "None of the matched processes is rendering yet, keeping the current name" + "None of the matched processes is rendering yet" ); - return None; + return Refinement::Undecided; } if current.and_then(|signal| signal.process_id) == best.process_id { tracing::debug!( @@ -539,7 +593,7 @@ impl Engine { "The GPU confirms the name already in use: {}", best.name() ); - return None; + return Refinement::Kept; } tracing::info!( target: target::GAME, @@ -549,7 +603,7 @@ impl Engine { "Game identified more precisely: {} ({share:.0}% of the rendering)", best.name() ); - Some(best) + Refinement::Renamed(best) } fn fire_start(&self, signal: Option<&GameSignal>) { diff --git a/src/engine/tests.rs b/src/engine/tests.rs index 91f072c..661c1be 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -26,7 +26,10 @@ struct Scripted { /// Answers to `candidates`, one per call; the last one repeats. candidates: RefCell>>, alive: HashSet, - load: HashMap, + /// Answers to `rendering_load`, one per call; the last one repeats. + loads: RefCell>>, + /// The timeout each `wait_for_exit` was asked for, in order. + timeouts: RefCell>>, /// `candidates` fails, as it does when the list cannot be read. list_unreadable: bool, /// `rendering_load` fails, as it does on an account that may not read @@ -52,7 +55,8 @@ impl Scripted { waits: RefCell::new(VecDeque::new()), candidates: RefCell::new(VecDeque::new()), alive: HashSet::new(), - load: HashMap::new(), + loads: RefCell::new(VecDeque::new()), + timeouts: RefCell::new(Vec::new()), list_unreadable: false, counters_unreadable: false, session_ends_with_writer: false, @@ -118,8 +122,11 @@ impl Scripted { self } - fn rendering(mut self, load: &[(u32, f64)]) -> Self { - self.load.extend(load.iter().copied()); + /// One answer to `rendering_load`; call again for the next attempt's. + fn rendering(self, load: &[(u32, f64)]) -> Self { + self.loads + .borrow_mut() + .push_back(load.iter().copied().collect()); self } } @@ -139,8 +146,9 @@ impl Sensor for Scripted { &self, _pid: u32, _stop: &StopSignal, - _timeout: Option, + timeout: Option, ) -> Result { + self.timeouts.borrow_mut().push(timeout); let outcome = self .waits .borrow_mut() @@ -177,7 +185,11 @@ impl Sensor for Scripted { if self.counters_unreadable { anyhow::bail!("scripted: the counters are unreadable"); } - Ok(self.load.clone()) + let mut answers = self.loads.borrow_mut(); + if answers.len() > 1 { + return Ok(answers.pop_front().unwrap()); + } + Ok(answers.front().cloned().unwrap_or_default()) } } @@ -233,6 +245,34 @@ fn touch(path: &Path) -> Action { } } +/// A command that appends its event and the game's name to `path`, so a +/// test can read which edges ran, in order, and under which name. +/// +/// One argument per word, for the reason `touch` gives. +fn log_edge(path: &Path) -> Action { + let name = path.file_name().unwrap().to_string_lossy().into_owned(); + Action { + name: Some("log edge".to_owned()), + program: "cmd.exe".into(), + args: ["/c", "echo", "{event}", "{process_name}", ">>", &name] + .map(str::to_owned) + .to_vec(), + working_dir: Some(path.parent().unwrap().to_path_buf()), + wait: true, + timeout: Some(Duration::from_secs(10)), + ..Action::default() + } +} + +/// The edges `log_edge` wrote, one `event name` per line. +fn edges(path: &Path) -> Vec { + std::fs::read_to_string(path) + .unwrap_or_default() + .lines() + .map(|line| line.trim().to_owned()) + .collect() +} + fn stop_event(actions: Vec) -> Event { Event { mode: Mode::Series, @@ -519,20 +559,68 @@ fn the_gpu_hands_the_session_to_the_process_that_is_drawing() { ); } +/// The timeouts a session asks for when the refinement makes `attempts` +/// attempts and then waits for the end with none. +fn asked(attempts: u32) -> Vec> { + let mut timeouts = vec![Some(quick_config().detection.identify_after); attempts as usize]; + timeouts.push(None); + timeouts +} + #[test] -fn nothing_rendering_keeps_the_first_name() { - // A game still on its loading screen: the one attempt is spent and the - // name stays. Recorded as a known margin in the design record. +fn nothing_rendering_yet_is_asked_again_until_something_is() { + // Battlefield 6 on 2026-09-16: every candidate at 0.0 % ten seconds + // before the attempt, bf6.exe at 75 % ten seconds after. A loading + // screen longer than the first interval used to keep the first name for + // the whole session; the attempt that reads nothing now does not count. let stop = Arc::new(StopSignal::new().unwrap()); let sensor = Scripted::new(&stop) .writer(&[Some(7)]) - .waits(&[WaitOutcome::TimedOut, WaitOutcome::Exited]) + .waits(&[ + WaitOutcome::TimedOut, + WaitOutcome::TimedOut, + WaitOutcome::Exited, + ]) + .candidates(&[&[game(5, "anticheat.exe"), game(10, "bf6.exe")]]) + .alive(&[5, 10]) + .rendering(&[(5, 0.0), (10, 0.0)]) + .rendering(&[(5, 0.0), (10, 75.0)]); + let (sink, log) = recorder(); + + let mut engine = Engine::new(quick_config(), &sensor).reporting_to(sink); + engine.run(&stop).unwrap(); + + assert_eq!( + seen(&log), + vec![ + Session::Playing(Some(game(5, "anticheat.exe"))), + Session::Playing(Some(game(10, "bf6.exe"))), + Session::Idle, + ] + ); + assert_eq!( + *sensor.timeouts.borrow(), + asked(2), + "the rename was the verdict: no third attempt" + ); +} + +#[test] +fn a_session_with_no_verdict_stops_asking_after_the_last_attempt() { + // Nothing ever renders: the attempts run out and the first name stays, + // and the wait for the end asks for no timeout any more. + let stop = Arc::new(StopSignal::new().unwrap()); + let mut waits = vec![WaitOutcome::TimedOut; REFINE_ATTEMPTS as usize]; + waits.push(WaitOutcome::Exited); + let sensor = Scripted::new(&stop) + .writer(&[Some(7)]) + .waits(&waits) .candidates(&[&[game(5, "anticheat.exe"), game(10, "bf6.exe")]]) .alive(&[5, 10]) .rendering(&[(5, 0.0), (10, 0.0)]); let (sink, log) = recorder(); - let mut engine = Engine::new(quick_config(), sensor).reporting_to(sink); + let mut engine = Engine::new(quick_config(), &sensor).reporting_to(sink); engine.run(&stop).unwrap(); assert_eq!( @@ -542,6 +630,91 @@ fn nothing_rendering_keeps_the_first_name() { Session::Idle ] ); + assert_eq!(*sensor.timeouts.borrow(), asked(REFINE_ATTEMPTS)); +} + +#[test] +fn the_launcher_that_outlives_the_first_attempt_is_replaced_on_a_later_one() { + // The launcher still there and nothing rendering at the first attempt; + // by the second the launcher has gone and the game is the one match + // left. The survivor rule needs no GPU, and it gets its chance. + let stop = Arc::new(StopSignal::new().unwrap()); + let sensor = Scripted::new(&stop) + .writer(&[Some(7)]) + .waits(&[ + WaitOutcome::TimedOut, + WaitOutcome::TimedOut, + WaitOutcome::Exited, + ]) + .candidates(&[ + &[game(5, "launcher.exe")], + &[game(5, "launcher.exe"), game(10, "bf6.exe")], + &[game(10, "bf6.exe")], + ]) + .alive(&[10]) + .rendering(&[(5, 0.0), (10, 0.0)]); + let (sink, log) = recorder(); + + let mut engine = Engine::new(quick_config(), &sensor).reporting_to(sink); + engine.run(&stop).unwrap(); + + assert_eq!( + seen(&log), + vec![ + Session::Playing(Some(game(5, "launcher.exe"))), + Session::Playing(Some(game(10, "bf6.exe"))), + Session::Idle, + ] + ); + assert_eq!(*sensor.timeouts.borrow(), asked(2)); +} + +#[test] +fn a_game_the_list_did_not_match_at_first_is_named_when_it_does() { + // No match is not a verdict either: the session started unnamed, and a + // later attempt that finds one match takes it. + let stop = Arc::new(StopSignal::new().unwrap()); + let sensor = Scripted::new(&stop) + .writer(&[Some(7)]) + .waits(&[ + WaitOutcome::TimedOut, + WaitOutcome::TimedOut, + WaitOutcome::Exited, + ]) + .candidates(&[&[], &[], &[game(10, "game.exe")]]); + let (sink, log) = recorder(); + + let mut engine = Engine::new(quick_config(), &sensor).reporting_to(sink); + engine.run(&stop).unwrap(); + + assert_eq!( + seen(&log), + vec![ + Session::Playing(None), + Session::Playing(Some(game(10, "game.exe"))), + Session::Idle, + ] + ); + assert_eq!(*sensor.timeouts.borrow(), asked(2)); +} + +#[test] +fn the_one_match_being_the_name_in_use_is_a_verdict() { + // Starfield and Skyrim, most sessions: one process matches and it is + // the one named. Nothing to arbitrate, and nothing to ask again. + let stop = Arc::new(StopSignal::new().unwrap()); + let sensor = Scripted::new(&stop) + .writer(&[Some(7)]) + .waits(&[WaitOutcome::TimedOut, WaitOutcome::Exited]) + .candidates(&[&[game(10, "Starfield.exe")]]) + .alive(&[10]); + let (sink, log) = recorder(); + + let mut engine = Engine::new(quick_config(), &sensor).reporting_to(sink); + engine.run(&stop).unwrap(); + + assert_eq!(seen(&log).len(), 2); + assert_eq!(*sensor.timeouts.borrow(), asked(1)); } #[test] @@ -555,12 +728,17 @@ fn a_confirmed_name_is_not_reported_again() { .rendering(&[(10, 90.0)]); let (sink, log) = recorder(); - let mut engine = Engine::new(quick_config(), sensor).reporting_to(sink); + let mut engine = Engine::new(quick_config(), &sensor).reporting_to(sink); engine.run(&stop).unwrap(); // Two changes only: the start and the stop. The confirmation is a log // line, not a report, or the tray would redraw the same icon. assert_eq!(seen(&log).len(), 2); + assert_eq!( + *sensor.timeouts.borrow(), + asked(1), + "and a verdict: nothing to ask again" + ); } #[test] @@ -576,10 +754,11 @@ fn refinement_is_skipped_when_identify_after_is_zero() { let mut config = quick_config(); config.detection.identify_after = Duration::ZERO; - let mut engine = Engine::new(config, sensor).reporting_to(sink); + let mut engine = Engine::new(config, &sensor).reporting_to(sink); engine.run(&stop).unwrap(); assert_eq!(seen(&log).len(), 2); + assert_eq!(*sensor.timeouts.borrow(), vec![None]); } // ------------------------------------------------- the writer blinking -- @@ -744,6 +923,112 @@ fn opting_out_of_stop_on_exit_runs_nothing_and_closes_the_marker() { ); } +// ------------------------------------------------- two games in a row -- + +#[test] +fn two_games_one_after_the_other_are_two_sessions_each_named() { + // Measured 2026-09-25 with American Truck Simulator and Euro Truck + // Simulator 2: Windows released the writer within a second of the + // first game's exit and started another for the second, twelve seconds + // later. Two sessions, each identified and refined afresh, each edge + // under its own name. + let stop = Arc::new(StopSignal::new().unwrap()); + let sensor = Scripted::new(&stop) + .writer(&[Some(7), None, Some(8)]) + .waits(&[ + WaitOutcome::TimedOut, + WaitOutcome::Exited, + WaitOutcome::TimedOut, + WaitOutcome::Exited, + ]) + .candidates(&[ + &[game(10, "amtrucks.exe")], + &[game(10, "amtrucks.exe")], + &[game(20, "eurotrucks2.exe")], + ]) + .alive(&[10, 20]); + let dir = scratch(); + let log_file = dir.join("edges.txt"); + let mut config = quick_config(); + config.on_game_start = stop_event(vec![log_edge(&log_file)]); + config.on_game_stop = stop_event(vec![log_edge(&log_file)]); + let (sink, log) = recorder(); + + let mut engine = Engine::new(config, &sensor) + .reporting_to(sink) + .remembering(Marker::in_dir(&dir)); + engine.run(&stop).unwrap(); + + assert_eq!( + edges(&log_file), + [ + "game_start amtrucks.exe", + "game_stop amtrucks.exe", + "game_start eurotrucks2.exe", + "game_stop eurotrucks2.exe", + ] + ); + assert_eq!( + seen(&log), + vec![ + Session::Playing(Some(game(10, "amtrucks.exe"))), + Session::Idle, + Session::Playing(Some(game(20, "eurotrucks2.exe"))), + Session::Idle, + ] + ); + let mut asked_twice = asked(1); + asked_twice.extend(asked(1)); + assert_eq!( + *sensor.timeouts.borrow(), + asked_twice, + "the second session had its own refinement" + ); + assert!(Marker::in_dir(&dir).pending().is_none()); +} + +#[test] +fn two_games_under_one_writer_are_one_session_named_after_the_first() { + // Measured 2026-09-25: Euro Truck Simulator 2 started while American + // Truck Simulator ran, and Windows kept the one writer for both, until + // after the second had exited; the same with Wreckfest 2 from Steam and + // Starfield from the Store. One session: the commands right, once + // on each edge, but the name the first game's throughout -- the + // refinement had settled before the second game started. Naming it + // would take the wait on the named process, left with Lot 18. + let stop = Arc::new(StopSignal::new().unwrap()); + let sensor = Scripted::new(&stop) + .writer(&[Some(7)]) + .waits(&[WaitOutcome::TimedOut, WaitOutcome::Exited]) + .candidates(&[ + &[game(10, "amtrucks.exe")], + &[game(10, "amtrucks.exe")], + &[game(10, "amtrucks.exe"), game(20, "eurotrucks2.exe")], + ]) + .alive(&[10, 20]); + let dir = scratch(); + let log_file = dir.join("edges.txt"); + let mut config = quick_config(); + config.on_game_start = stop_event(vec![log_edge(&log_file)]); + config.on_game_stop = stop_event(vec![log_edge(&log_file)]); + let (sink, log) = recorder(); + + let mut engine = Engine::new(config, &sensor).reporting_to(sink); + engine.run(&stop).unwrap(); + + assert_eq!( + edges(&log_file), + ["game_start amtrucks.exe", "game_stop amtrucks.exe"] + ); + assert_eq!( + seen(&log), + vec![ + Session::Playing(Some(game(10, "amtrucks.exe"))), + Session::Idle + ] + ); +} + // ------------------------------------------------------------- handover -- #[test]