gpuviewer_core/wddm.rs
1//! Windows cross-vendor WDDM backend (docs/design/cross-platform.md §3) — AMD and Intel
2//! on Windows (and NVIDIA when NVML is absent), entirely from OS surfaces: DXGI for
3//! enumeration and VRAM totals, PDH GPU counters for utilization and memory, D3DKMT for
4//! the LUID→PCI identity. `pdh.dll`/`gdi32.dll`/`dxgi.dll` are OS system libraries —
5//! linking them via the `windows` crate does NOT violate the no-vendor-SDK rule (that rule
6//! targets vendor SDKs with soname churn, not the OS itself).
7//!
8//! Honesty contract, per metric (§3.4 — these are load-bearing semantics, not trivia):
9//!
10//! - **util_pct is scheduler duty-cycle, not capacity.** The PDH `GPU Engine` counters
11//! report how busy the WDDM scheduler (VidSch) kept each engine — time with work
12//! resident, exactly the same *kind* of number as NVML's duty-cycle "utilization", and
13//! it must be labeled exactly as honestly: a GPU can read 100% here while its execution
14//! units idle. The device headline is the **busiest single engine** (summed across pids
15//! per engine first), which is what Task Manager's Performance tab shows — comparable on
16//! purpose, so users cross-checking against Task Manager see agreement, not a mystery.
17//! - **mem_used_bytes comes from `GPU Adapter Memory\Dedicated Usage`** — the VidMm
18//! (Windows video memory manager) number, the adapter-level counter Microsoft confirms
19//! stays correct (KB4490156). DXGI's `QueryVideoMemoryInfo` is **never** used for
20//! device-used: it reports the *calling process's* budget/usage by design and would show
21//! gpuviewer's own ~0 — a silently-wrong number, the worst kind.
22//! - **temp_c, power_mw, fan_pct, sm_clock_mhz, mem_clock_mhz are `None` on this
23//! backend** — Windows exposes no public temperature/power/clock API for AMD/Intel GPUs
24//! without installing vendor SDKs (ADLX/IGCL), which the no-vendor-SDK rule forbids.
25//! The UI renders these as unavailable; fabricating them is not an option. The two
26//! known semi-public paths are explicitly out of scope per §3.6: D3DKMT
27//! `KMTQAITYPE_ADAPTERPERFDATA` (driver-optional kernel thunk, deci-°C, power in 0.1%
28//! units) is a future opportunistic probe, and DXCore's QueryState telemetry is still
29//! prerelease. Do not "fix" these in.
30//! - **throttle is `None`**: this source cannot observe throttling at all, and the §5.4
31//! `Option<ThrottleReasons>` model makes that unobservability representable — `None`
32//! means "unobserved", and the event engine/UI/rollups treat it as a blind spot. The
33//! all-false struct (a fabricated fact-grade "not throttling") is never emitted here.
34//! - **Per-process rows** come from PDH `GPU Engine` / `GPU Process Memory` instances
35//! joined by (pid, LUID). `kind` is `Unknown` — PDH does not distinguish
36//! compute/graphics; an engtype of `Compute`/`Cuda` upgrades to `Compute` as a labeled
37//! heuristic only. `cpu_pct`/`container` are `None` (no `/proc` on Windows).
38//! - **Absence is normal.** A GPU-less machine (CI runner, RDP session without vGPU) has
39//! no `GPU Engine` PDH object at all (`PDH_CSTATUS_NO_OBJECT`) and possibly no hardware
40//! DXGI adapter — every such outcome is `None`/empty/skipped-backend, never an error.
41//! The first PDH collection legitimately yields no rate data (rate counters need two
42//! collections) — the first frame is honestly empty.
43//!
44//! Identity (§3.1): the in-session join key is the adapter **LUID** (matches PDH instance
45//! tokens and D3DKMT). A LUID is session-scoped — it changes on reboot/driver update — so
46//! it is **never persisted as identity**. The persistent `DeviceId` is the normalized PCI
47//! BDF (`"0000:bb:dd.f"`) obtained via D3DKMT `ADAPTERADDRESS`, the same key shape NVML
48//! and sysfs produce, so history identity works and the collector's first-wins PCI dedupe
49//! lets NVML claim NVIDIA boards ahead of this backend (registry order nvidia → wddm,
50//! §3.7). If the D3DKMT thunk fails, the fallback id `wddm:<vendor>:<device>:<ordinal>`
51//! deliberately does NOT parse as a PCI address, so `normalize_pci_id` refuses to dedupe
52//! it: listing a device twice beats wrongly merging two.
53//!
54//! Layout: the [`pdh`] and [`adapters`] submodules carry the §9 interface-freeze surface
55//! (`pdh::shared()`, `SharedPdh::snapshot`, `parse_instance`, `adapters::enumerate`); the
56//! integrator may re-export them as `win::pdh`/`win::adapters` or split them into files
57//! later. Everything that touches a Windows API is `#[cfg(target_os = "windows")]`; the
58//! counter-instance grammar and all aggregation math are pure functions that compile and
59//! unit-test on every OS (CI has no GPUs — the Linux leg runs those tests from string
60//! fixtures).
61
62/// PDH GPU counters: instance-name grammar, aggregation math, and (on Windows) the shared
63/// process-wide query both Windows backends read from.
64pub mod pdh {
65 use std::collections::{hash_map::Entry, HashMap};
66
67 // ---- PDH status codes (§3.2 absence-is-normal table) -------------------------------
68 //
69 // Defined here (hex from pdhmsg.h) rather than imported so the classification is a
70 // pure, any-OS-testable contract: future refactors must not turn these into errors.
71
72 /// `ERROR_SUCCESS` — and `PDH_CSTATUS_VALID_DATA`, which shares the value 0.
73 pub const PDH_OK: u32 = 0;
74 /// `PDH_CSTATUS_NEW_DATA`: valid value, instance appeared since the previous collect.
75 pub const PDH_CSTATUS_NEW_DATA: u32 = 0x1;
76 /// No such performance object — exactly what a GPU-less CI runner reports for
77 /// `GPU Engine` (no WDDM 2.0 GPU/driver in the session).
78 pub const PDH_CSTATUS_NO_OBJECT: u32 = 0xC000_0BB8;
79 /// Object exists but the counter does not (older WDDM driver).
80 pub const PDH_CSTATUS_NO_COUNTER: u32 = 0xC000_0BB9;
81 /// Counter exists but its value could not be validated (first-sample case per item).
82 pub const PDH_CSTATUS_INVALID_DATA: u32 = 0xC000_0BBA;
83 /// No instances right now (e.g. no process currently touches the GPU). Normal.
84 pub const PDH_CSTATUS_NO_INSTANCE: u32 = 0x8000_07D1;
85 /// Buffer too small — the "call again with a bigger buffer" half of the two-call
86 /// pattern, not a failure.
87 pub const PDH_MORE_DATA: u32 = 0x8000_07D2;
88 /// The query has no data yet (first collection of rate counters). Normal.
89 pub const PDH_NO_DATA: u32 = 0x8000_07D5;
90 /// Query-level first-sample/invalid-data case. Normal.
91 pub const PDH_INVALID_DATA: u32 = 0xC000_0BC6;
92 /// The perf-data provider timed out — a transient miss, NOT a lost device.
93 pub const PDH_QUERY_PERF_DATA_TIMEOUT: u32 = 0xC000_0BFE;
94
95 /// The §3.2 contract: each of these maps to `None` (plus at most one collector
96 /// self-honesty event, emitted upstream), never an `Err`. A refactor that starts
97 /// treating any of them as a failure breaks the GPU-less-CI-runner path — that is the
98 /// case the any-OS unit test pins.
99 pub fn status_is_normal_absence(status: u32) -> bool {
100 matches!(
101 status,
102 PDH_CSTATUS_NO_OBJECT
103 | PDH_CSTATUS_NO_COUNTER
104 | PDH_CSTATUS_NO_INSTANCE
105 | PDH_NO_DATA
106 | PDH_CSTATUS_INVALID_DATA
107 | PDH_INVALID_DATA
108 | PDH_QUERY_PERF_DATA_TIMEOUT
109 )
110 }
111
112 /// Per-item `CStatus` gate: only VALID/NEW values are trusted (§3.2 "per-item CStatus
113 /// checked before trusting any value"). Anything else means *this item* is `None` this
114 /// tick — not the whole snapshot.
115 pub fn item_value_is_trustworthy(cstatus: u32) -> bool {
116 cstatus == PDH_OK || cstatus == PDH_CSTATUS_NEW_DATA
117 }
118
119 // ---- Counter-instance grammar (pure — fixture-tested on any OS) --------------------
120
121 /// The two hex DWORDs of an instance-name `luid` token, **in printed order**.
122 ///
123 /// WHY not "high, low": the HighPart-then-LowPart order is inferred from observation,
124 /// not documented anywhere by Microsoft. Matching therefore verifies BOTH parts
125 /// against an enumerated adapter LUID (either order) and treats no-match as
126 /// "unattributed" (§3.2) — a wrong attribution is worse than an honest gap.
127 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
128 pub struct InstanceLuid(pub u32, pub u32);
129
130 impl InstanceLuid {
131 /// Does this instance belong to the adapter with (`HighPart`, `LowPart`)?
132 /// Accepts the pair in either order — see the type-level WHY. A mirrored
133 /// collision between two real adapters would require LUIDs (x, y) and (y, x)
134 /// alive in one session; the kernel allocates LowPart monotonically with
135 /// HighPart almost always 0, so the ambiguity is theoretical.
136 pub fn matches(&self, high: i32, low: u32) -> bool {
137 let h = high as u32;
138 (self.0 == h && self.1 == low) || (self.1 == h && self.0 == low)
139 }
140 }
141
142 /// One parsed PDH GPU counter-instance name. Every field optional: the three counter
143 /// families share one grammar but carry different token subsets
144 /// (`GPU Engine` = pid+luid+phys+eng+engtype, `GPU Process Memory` = pid+luid+phys,
145 /// `GPU Adapter Memory` = luid+phys).
146 #[derive(Clone, Debug, Default, PartialEq)]
147 pub struct ParsedInstance {
148 pub pid: Option<u32>,
149 pub luid: Option<InstanceLuid>,
150 pub phys: Option<u32>,
151 pub part: Option<u32>,
152 pub eng: Option<u32>,
153 /// Engine type — an **opaque string from an open set**, never an exhaustive enum:
154 /// drivers and HAGS rename/add engtypes across releases (§3.2), so any
155 /// match against it is by name and tolerant of strangers.
156 pub engtype: Option<String>,
157 }
158
159 /// Parse one hex DWORD token (`0x0000C739`). The `0x` prefix is mandatory — a token
160 /// without it is not a luid part, and accepting it would let a malformed name parse
161 /// into a wrong (never-matching, but noise-generating) LUID.
162 fn parse_hex_dword(tok: &str) -> Option<u32> {
163 let hex = tok.strip_prefix("0x").or_else(|| tok.strip_prefix("0X"))?;
164 if hex.is_empty() || hex.len() > 8 {
165 return None;
166 }
167 u32::from_str_radix(hex, 16).ok()
168 }
169
170 /// Parse a PDH GPU counter-instance name, e.g.
171 /// `pid_1234_luid_0x00000000_0x0000C739_phys_0_eng_3_engtype_3D`.
172 ///
173 /// Grammar (mirrors windows_exporter's production parser, §3.2): split on `_`;
174 /// keyword tokens consume their values — `pid`/`phys`/`part`/`eng` one decimal,
175 /// `luid` two hex DWORDs, `engtype` the **rest of the string** (underscores
176 /// preserved — the engtype set is open and future names may contain them). The
177 /// grammar is keyword-driven, not positional, so token reordering still parses.
178 /// Anything malformed → `None`: an instance we cannot read is an instance we must
179 /// not guess about (it counts as unattributed, same as a LUID mismatch).
180 pub fn parse_instance(name: &str) -> Option<ParsedInstance> {
181 let mut out = ParsedInstance::default();
182 let mut toks = name.split('_');
183 while let Some(tok) = toks.next() {
184 match tok {
185 "pid" => out.pid = Some(toks.next()?.parse().ok()?),
186 "luid" => {
187 let a = parse_hex_dword(toks.next()?)?;
188 let b = parse_hex_dword(toks.next()?)?;
189 out.luid = Some(InstanceLuid(a, b));
190 }
191 "phys" => out.phys = Some(toks.next()?.parse().ok()?),
192 "part" => out.part = Some(toks.next()?.parse().ok()?),
193 "eng" => out.eng = Some(toks.next()?.parse().ok()?),
194 "engtype" => {
195 let rest = toks.collect::<Vec<_>>().join("_");
196 if rest.is_empty() {
197 return None;
198 }
199 out.engtype = Some(rest);
200 break;
201 }
202 // Unknown keyword (or a non-GPU instance name entirely): refuse to guess.
203 _ => return None,
204 }
205 }
206 // A name of nothing but separators ("___") would fold to the all-None default;
207 // that is not a GPU counter instance.
208 if out == ParsedInstance::default() {
209 None
210 } else {
211 Some(out)
212 }
213 }
214
215 // ---- Aggregation math (pure — scripted-snapshot-tested on any OS) ------------------
216
217 /// One adapter's busiest engine: the device-headline utilization (§3.4).
218 #[derive(Clone, Debug, PartialEq)]
219 pub struct EngineHeadline {
220 /// Name of the busiest engine (opaque engtype, surfaced in the UI so the headline
221 /// is self-explaining: "3D 97%" reads differently from "Copy 97%").
222 pub engtype: String,
223 /// Busy % of that engine. NOT clamped to 100: reads use `PDH_FMT_NOCAP100`
224 /// (mandatory — silent capping would make summed numbers quietly wrong) and
225 /// sampling skew can push a sum slightly past 100. Honest > tidy.
226 pub pct: f64,
227 }
228
229 /// Engine identity within one adapter: (phys, part, eng, engtype).
230 type EngineKey = (Option<u32>, Option<u32>, Option<u32>, String);
231
232 /// Per-engine busy% for one adapter: instances filtered to the adapter's LUID, then
233 /// summed per engine — keyed by (phys, part, eng, engtype) — **across pids** (§3.4).
234 /// Instances whose LUID does not match (or did not parse) are unattributed: skipped.
235 fn engine_busy(
236 engine_util: &[(ParsedInstance, f64)],
237 high: i32,
238 low: u32,
239 ) -> HashMap<EngineKey, f64> {
240 let mut per_engine = HashMap::new();
241 for (inst, v) in engine_util {
242 if !inst.luid.is_some_and(|l| l.matches(high, low)) {
243 continue;
244 }
245 let key = (
246 inst.phys,
247 inst.part,
248 inst.eng,
249 inst.engtype.clone().unwrap_or_default(),
250 );
251 *per_engine.entry(key).or_insert(0.0) += v;
252 }
253 per_engine
254 }
255
256 /// Device-headline utilization for one adapter: the **busiest single engine** after
257 /// per-engine pid-summing — deliberately Task-Manager-Performance-tab-comparable
258 /// (§3.4). `None` when no instance matched: GPU-less runner, first PDH sample, or an
259 /// unmatched LUID — all normal.
260 pub fn device_util(
261 engine_util: &[(ParsedInstance, f64)],
262 high: i32,
263 low: u32,
264 ) -> Option<EngineHeadline> {
265 engine_busy(engine_util, high, low)
266 .into_iter()
267 .max_by(|a, b| a.1.total_cmp(&b.1))
268 .map(|((_, _, _, engtype), pct)| EngineHeadline { engtype, pct })
269 }
270
271 /// Busy% of the busiest engine of one engtype (e.g. `VideoEncode`/`VideoDecode` for
272 /// the encoder/decoder fields), after per-engine pid-summing. Name comparison is
273 /// case-insensitive (the engtype set is open; drivers vary casing). Absent engtype →
274 /// `None` (§3.4) — never 0, which would claim an idle encoder this GPU may not have.
275 pub fn engtype_util(
276 engine_util: &[(ParsedInstance, f64)],
277 high: i32,
278 low: u32,
279 engtype: &str,
280 ) -> Option<f64> {
281 engine_busy(engine_util, high, low)
282 .into_iter()
283 .filter(|((_, _, _, ty), _)| ty.eq_ignore_ascii_case(engtype))
284 .map(|(_, v)| v)
285 .max_by(f64::total_cmp)
286 }
287
288 /// One pid's utilization on one adapter (§2.4/§3.5).
289 #[derive(Clone, Debug, PartialEq)]
290 pub struct PidUtil {
291 /// Max across the pid's engine instances — the Task-Manager-comparable per-process
292 /// number. Any *summed* per-engtype figure a UI shows instead must be labeled
293 /// "engine-sum, can exceed 100%".
294 pub pct: f64,
295 /// Which engine was busiest (UI tooltip/evidence — names the claim's source).
296 pub busiest_engtype: String,
297 /// True if any of the pid's engines was named `Compute`/`Cuda` — a **heuristic
298 /// only** (§3.5): PDH does not distinguish compute from graphics clients.
299 pub compute_hint: bool,
300 }
301
302 /// Per-pid utilization on one adapter: max across each pid's engine instances on this
303 /// LUID. Unattributed instances (LUID mismatch/unparsed) are skipped.
304 pub fn per_pid_util(
305 engine_util: &[(ParsedInstance, f64)],
306 high: i32,
307 low: u32,
308 ) -> HashMap<u32, PidUtil> {
309 let mut out: HashMap<u32, PidUtil> = HashMap::new();
310 for (inst, v) in engine_util {
311 if !inst.luid.is_some_and(|l| l.matches(high, low)) {
312 continue;
313 }
314 let Some(pid) = inst.pid else { continue };
315 let engtype = inst.engtype.clone().unwrap_or_default();
316 let compute =
317 engtype.eq_ignore_ascii_case("compute") || engtype.eq_ignore_ascii_case("cuda");
318 match out.entry(pid) {
319 Entry::Occupied(mut o) => {
320 let e = o.get_mut();
321 if *v > e.pct {
322 e.pct = *v;
323 e.busiest_engtype = engtype;
324 }
325 e.compute_hint |= compute;
326 }
327 Entry::Vacant(slot) => {
328 slot.insert(PidUtil {
329 pct: *v,
330 busiest_engtype: engtype,
331 compute_hint: compute,
332 });
333 }
334 }
335 }
336 out
337 }
338
339 /// Per-pid byte totals (for the `GPU Process Memory` Dedicated/Shared Usage streams)
340 /// on one adapter, summed across a pid's phys/part instances. Values are raw counter
341 /// doubles; negatives (a provider glitch) clamp to 0 rather than wrap.
342 pub fn per_pid_bytes(
343 readings: &[(ParsedInstance, f64)],
344 high: i32,
345 low: u32,
346 ) -> HashMap<u32, u64> {
347 let mut out: HashMap<u32, u64> = HashMap::new();
348 for (inst, v) in readings {
349 if !inst.luid.is_some_and(|l| l.matches(high, low)) {
350 continue;
351 }
352 let Some(pid) = inst.pid else { continue };
353 *out.entry(pid).or_insert(0) += v.max(0.0) as u64;
354 }
355 out
356 }
357
358 /// Adapter-level byte total (for the `GPU Adapter Memory` streams) for one adapter,
359 /// summed across its phys/part instances. `None` when nothing matched — a GPU with no
360 /// counter is "unknown", not "0 bytes used".
361 pub fn adapter_bytes(readings: &[(ParsedInstance, f64)], high: i32, low: u32) -> Option<u64> {
362 let mut total: Option<u64> = None;
363 for (inst, v) in readings {
364 if !inst.luid.is_some_and(|l| l.matches(high, low)) {
365 continue;
366 }
367 *total.get_or_insert(0) += v.max(0.0) as u64;
368 }
369 total
370 }
371
372 /// One formatted collection of every GPU counter stream — pure data, so the
373 /// aggregation functions above are testable from scripted values on any OS. Built on
374 /// Windows by [`SharedPdh::snapshot`]; both Windows backends read the same snapshot.
375 ///
376 /// Empty vectors are the *normal* GPU-less/first-sample state, not an error.
377 #[derive(Clone, Debug, Default, PartialEq)]
378 pub struct PdhSnapshot {
379 /// When this collection happened (unix millis) — one timestamp per frame.
380 pub at_ms: u64,
381 /// `\GPU Engine(*)\Utilization Percentage` — busy % per (pid, engine) instance.
382 pub engine_util: Vec<(ParsedInstance, f64)>,
383 /// `\GPU Process Memory(*)\Dedicated Usage` — bytes per (pid, adapter).
384 pub proc_dedicated: Vec<(ParsedInstance, f64)>,
385 /// `\GPU Process Memory(*)\Shared Usage` — bytes per (pid, adapter). Carried
386 /// separately to the UI; **never** added into `mem_bytes` (dedicated vs shared is
387 /// the honest split, §2.4).
388 pub proc_shared: Vec<(ParsedInstance, f64)>,
389 /// `\GPU Adapter Memory(*)\Dedicated Usage` — bytes per adapter (VidMm truth).
390 pub adapter_dedicated: Vec<(ParsedInstance, f64)>,
391 /// `\GPU Adapter Memory(*)\Shared Usage` — bytes per adapter, shown separately.
392 pub adapter_shared: Vec<(ParsedInstance, f64)>,
393 }
394
395 // ---- Shared process-wide query (Windows-only from here down) -----------------------
396
397 #[cfg(target_os = "windows")]
398 pub use windows_impl::{shared, SharedPdh};
399
400 #[cfg(target_os = "windows")]
401 mod windows_impl {
402 use std::sync::{Mutex, OnceLock};
403
404 use windows::core::{PCWSTR, PWSTR};
405 use windows::Win32::System::Performance::{
406 PdhAddCounterW, PdhAddEnglishCounterW, PdhCollectQueryData, PdhExpandWildCardPathW,
407 PdhGetFormattedCounterArrayW, PdhOpenQueryW, PdhRemoveCounter, PDH_FMT,
408 PDH_FMT_COUNTERVALUE_ITEM_W, PDH_FMT_DOUBLE, PDH_HCOUNTER, PDH_HQUERY,
409 };
410
411 /// `PDH_FMT_NOCAP100` (pdh.h) is missing from windows-rs 0.62.2's metadata, so
412 /// the header value is restated here. It is mandatory (§3.2): without it PDH
413 /// silently caps values at 100, which would make summed multi-engine numbers
414 /// quietly wrong — a trust-thesis violation, not a crash.
415 const PDH_FMT_NOCAP100: PDH_FMT = PDH_FMT(0x0000_8000);
416
417 use super::{
418 item_value_is_trustworthy, parse_instance, status_is_normal_absence, ParsedInstance,
419 PdhSnapshot, PDH_CSTATUS_NO_COUNTER, PDH_CSTATUS_NO_OBJECT, PDH_MORE_DATA, PDH_OK,
420 PDH_QUERY_PERF_DATA_TIMEOUT,
421 };
422
423 /// Snapshot cache lifetime: the nvidia and wddm backends polling in the same
424 /// Engine tick must share ONE `PdhCollectQueryData` (§3.2) — 250 ms comfortably
425 /// covers a tick's worth of refresh calls while never spanning two 1 Hz ticks.
426 const SNAPSHOT_REUSE_MS: u64 = 250;
427
428 /// In expanded-path (non-English-locale fallback) mode, instances churn with
429 /// process lifecycle and explicitly-added paths do NOT pick up newcomers the way
430 /// a wildcard does — so re-expand at this cadence. Two seconds bounds both the
431 /// staleness (a new process appears in ≤2 s) and the cost (expansion walks the
432 /// registry provider).
433 const REEXPAND_MS: u64 = 2_000;
434
435 /// The five counter streams, by English wildcard path. `PdhAddEnglishCounterW` is
436 /// the localization-safe entry point; the expansion fallback below exists because
437 /// wildcard-add is only proven on English Windows (§3.2).
438 const STREAM_PATHS: [&str; 5] = [
439 r"\GPU Engine(*)\Utilization Percentage",
440 r"\GPU Process Memory(*)\Dedicated Usage",
441 r"\GPU Process Memory(*)\Shared Usage",
442 r"\GPU Adapter Memory(*)\Dedicated Usage",
443 r"\GPU Adapter Memory(*)\Shared Usage",
444 ];
445
446 fn to_wide(s: &str) -> Vec<u16> {
447 s.encode_utf16().chain(std::iter::once(0)).collect()
448 }
449
450 enum StreamMode {
451 /// One wildcard counter handle — new instances arrive automatically.
452 Wildcard(PDH_HCOUNTER),
453 /// Per-instance handles from `PdhExpandWildCardPathW` (locale fallback);
454 /// requires periodic re-expansion to see instance churn.
455 Expanded {
456 handles: Vec<PDH_HCOUNTER>,
457 last_expand_ms: u64,
458 },
459 /// The object/counter does not exist (`NO_OBJECT`/`NO_COUNTER`) — the normal
460 /// GPU-less outcome. The stream stays permanently empty; never an error.
461 Absent,
462 }
463
464 struct Stream {
465 /// English wildcard path, kept for re-expansion in fallback mode.
466 wildcard: &'static str,
467 mode: StreamMode,
468 }
469
470 struct QueryState {
471 query: PDH_HQUERY,
472 streams: Vec<Stream>,
473 cache: Option<PdhSnapshot>,
474 }
475
476 /// The process-wide PDH query (§3.2): opened once, shared by every backend.
477 pub struct SharedPdh {
478 /// `None` = `PdhOpenQueryW` itself failed — every snapshot is `None`.
479 state: Mutex<Option<QueryState>>,
480 /// Whether the `GPU Engine` object existed at open time. False on GPU-less
481 /// machines (`PDH_CSTATUS_NO_OBJECT`) — drives the "no WDDM 2.0 GPU/driver"
482 /// process hint, not an error.
483 engine_object_present: bool,
484 }
485
486 // SAFETY: the raw PDH handles inside QueryState are only ever touched while
487 // holding the Mutex; PDH itself is documented thread-safe per-query with
488 // external synchronization, which the Mutex provides.
489 unsafe impl Send for SharedPdh {}
490 unsafe impl Sync for SharedPdh {}
491
492 /// The one process-wide query. `OnceLock` + `Mutex`: opened on first use, shared
493 /// by the nvidia and wddm backends so one tick costs one `PdhCollectQueryData`.
494 pub fn shared() -> &'static SharedPdh {
495 static SHARED: OnceLock<SharedPdh> = OnceLock::new();
496 SHARED.get_or_init(SharedPdh::open)
497 }
498
499 impl SharedPdh {
500 fn open() -> Self {
501 let mut query = PDH_HQUERY::default();
502 // No data source (live machine), no user data.
503 let status = unsafe { PdhOpenQueryW(PCWSTR::null(), 0, &mut query) };
504 if status != PDH_OK {
505 return SharedPdh {
506 state: Mutex::new(None),
507 engine_object_present: false,
508 };
509 }
510 let streams: Vec<Stream> = STREAM_PATHS
511 .iter()
512 .map(|path| Stream {
513 wildcard: path,
514 mode: add_stream(query, path),
515 })
516 .collect();
517 let engine_object_present = !matches!(streams[0].mode, StreamMode::Absent);
518 SharedPdh {
519 state: Mutex::new(Some(QueryState {
520 query,
521 streams,
522 cache: None,
523 })),
524 engine_object_present,
525 }
526 }
527
528 /// Whether the `GPU Engine` PDH object exists in this session. False means
529 /// "no WDDM 2.0 GPU/driver" — the per-process hint, not a failure.
530 pub fn engine_object_present(&self) -> bool {
531 self.engine_object_present
532 }
533
534 /// The shared formatted snapshot, re-collected only if the cache is older
535 /// than ~250 ms (§3.2). `None` only when PDH itself never opened; every
536 /// counter-level absence is an *empty stream inside* `Some` — callers map
537 /// that to per-metric `None`s.
538 pub fn snapshot(&self, now_ms: u64) -> Option<PdhSnapshot> {
539 let mut guard = self.state.lock().ok()?;
540 let st = guard.as_mut()?;
541
542 if let Some(cache) = &st.cache {
543 if now_ms.saturating_sub(cache.at_ms) < SNAPSHOT_REUSE_MS {
544 return Some(cache.clone());
545 }
546 }
547
548 // Locale-fallback streams: re-expand on cadence so instance churn
549 // (process start/exit) is visible despite the explicit paths.
550 for stream in &mut st.streams {
551 if let StreamMode::Expanded {
552 handles,
553 last_expand_ms,
554 } = &mut stream.mode
555 {
556 if now_ms.saturating_sub(*last_expand_ms) >= REEXPAND_MS {
557 for h in handles.drain(..) {
558 // Best effort: a failed remove leaks one stale counter,
559 // which is preferable to aborting the snapshot.
560 let _ = unsafe { PdhRemoveCounter(h) };
561 }
562 *handles = expand_and_add(st.query, stream.wildcard);
563 *last_expand_ms = now_ms;
564 }
565 }
566 }
567
568 let status = unsafe { PdhCollectQueryData(st.query) };
569 if status != PDH_OK {
570 if status == PDH_QUERY_PERF_DATA_TIMEOUT {
571 // Transient provider miss (§3.2): reuse the stale snapshot if
572 // one exists — NOT a device_lost, NOT an error.
573 return Some(st.cache.clone().unwrap_or_default());
574 }
575 if status_is_normal_absence(status) {
576 // e.g. PDH_NO_DATA: nothing in the query has data (GPU-less, or
577 // very first collection). An honestly-empty frame.
578 let snap = PdhSnapshot {
579 at_ms: now_ms,
580 ..Default::default()
581 };
582 st.cache = Some(snap.clone());
583 return Some(snap);
584 }
585 return None;
586 }
587
588 let mut snap = PdhSnapshot {
589 at_ms: now_ms,
590 ..Default::default()
591 };
592 for (i, stream) in st.streams.iter().enumerate() {
593 let out = match i {
594 0 => &mut snap.engine_util,
595 1 => &mut snap.proc_dedicated,
596 2 => &mut snap.proc_shared,
597 3 => &mut snap.adapter_dedicated,
598 _ => &mut snap.adapter_shared,
599 };
600 let handles: &[PDH_HCOUNTER] = match &stream.mode {
601 StreamMode::Wildcard(h) => std::slice::from_ref(h),
602 StreamMode::Expanded { handles, .. } => handles,
603 StreamMode::Absent => continue,
604 };
605 for &h in handles {
606 read_formatted_array(h, out);
607 }
608 }
609 st.cache = Some(snap.clone());
610 Some(snap)
611 }
612 }
613
614 /// Add one stream: localization-safe English wildcard first; `NO_OBJECT`/
615 /// `NO_COUNTER` is the permanent normal absence; any other failure goes through
616 /// the documented non-English-locale fallback chain (expand → add per path).
617 fn add_stream(query: PDH_HQUERY, path: &'static str) -> StreamMode {
618 let wide = to_wide(path);
619 let mut handle = PDH_HCOUNTER::default();
620 let status =
621 unsafe { PdhAddEnglishCounterW(query, PCWSTR(wide.as_ptr()), 0, &mut handle) };
622 if status == PDH_OK {
623 return StreamMode::Wildcard(handle);
624 }
625 if status == PDH_CSTATUS_NO_OBJECT || status == PDH_CSTATUS_NO_COUNTER {
626 return StreamMode::Absent;
627 }
628 // Wildcard-add is only proven on English Windows (§3.2): expand the wildcard
629 // into concrete instance paths and add each one.
630 let handles = expand_and_add(query, path);
631 if handles.is_empty() {
632 StreamMode::Absent
633 } else {
634 StreamMode::Expanded {
635 handles,
636 last_expand_ms: 0,
637 }
638 }
639 }
640
641 /// `PdhExpandWildCardPathW` two-call pattern → `PdhAddCounterW` per expanded
642 /// path. Empty on any failure — absence over error, always.
643 fn expand_and_add(query: PDH_HQUERY, path: &str) -> Vec<PDH_HCOUNTER> {
644 let wide = to_wide(path);
645 let mut len: u32 = 0;
646 let status = unsafe {
647 PdhExpandWildCardPathW(PCWSTR::null(), PCWSTR(wide.as_ptr()), None, &mut len, 0)
648 };
649 if status != PDH_MORE_DATA || len == 0 {
650 return Vec::new();
651 }
652 let mut buf = vec![0u16; len as usize];
653 let status = unsafe {
654 PdhExpandWildCardPathW(
655 PCWSTR::null(),
656 PCWSTR(wide.as_ptr()),
657 Some(PWSTR(buf.as_mut_ptr())),
658 &mut len,
659 0,
660 )
661 };
662 if status != PDH_OK {
663 return Vec::new();
664 }
665 // MULTI_SZ: NUL-separated strings, double-NUL terminated.
666 let mut handles = Vec::new();
667 for entry in buf.split(|&c| c == 0) {
668 if entry.is_empty() {
669 continue;
670 }
671 let mut entry_z: Vec<u16> = entry.to_vec();
672 entry_z.push(0);
673 let mut h = PDH_HCOUNTER::default();
674 let status = unsafe { PdhAddCounterW(query, PCWSTR(entry_z.as_ptr()), 0, &mut h) };
675 if status == PDH_OK {
676 handles.push(h);
677 }
678 }
679 handles
680 }
681
682 /// Read one counter's formatted instance array (two-call buffer pattern,
683 /// `PDH_FMT_DOUBLE | PDH_FMT_NOCAP100`) and append the parseable items.
684 ///
685 /// NOCAP100 is mandatory (§3.2): without it values are silently capped at 100,
686 /// which would make summed multi-engine numbers quietly wrong — a trust-thesis
687 /// violation, not a crash. Per-item `CStatus` is checked before trusting any
688 /// value; an untrusted item is skipped (that item is None this tick), and an
689 /// unparseable instance name is skipped (unattributed) — neither aborts the read.
690 fn read_formatted_array(counter: PDH_HCOUNTER, out: &mut Vec<(ParsedInstance, f64)>) {
691 let fmt = PDH_FMT(PDH_FMT_DOUBLE.0 | PDH_FMT_NOCAP100.0);
692 let mut buf_bytes: u32 = 0;
693 let mut count: u32 = 0;
694 let status = unsafe {
695 PdhGetFormattedCounterArrayW(counter, fmt, &mut buf_bytes, &mut count, None)
696 };
697 if status != PDH_MORE_DATA {
698 // Includes the per-counter absence statuses (NO_INSTANCE, first-sample
699 // INVALID_DATA, ...) — normal: this stream is simply empty this tick.
700 return;
701 }
702 // u64-backed buffer: PDH_FMT_COUNTERVALUE_ITEM_W contains an f64 union and
703 // needs 8-byte alignment, which Vec<u8> does not guarantee.
704 let mut buf = vec![0u64; (buf_bytes as usize).div_ceil(8)];
705 let items_ptr = buf.as_mut_ptr() as *mut PDH_FMT_COUNTERVALUE_ITEM_W;
706 let status = unsafe {
707 PdhGetFormattedCounterArrayW(
708 counter,
709 fmt,
710 &mut buf_bytes,
711 &mut count,
712 Some(items_ptr),
713 )
714 };
715 if status != PDH_OK {
716 return;
717 }
718 let items = unsafe { std::slice::from_raw_parts(items_ptr, count as usize) };
719 for item in items {
720 if !item_value_is_trustworthy(item.FmtValue.CStatus) {
721 continue;
722 }
723 let name = match unsafe { item.szName.to_string() } {
724 Ok(n) => n,
725 Err(_) => continue,
726 };
727 if let Some(parsed) = parse_instance(&name) {
728 // SAFETY: PDH_FMT_DOUBLE was requested, so the union's doubleValue
729 // arm is the one PDH populated.
730 let value = unsafe { item.FmtValue.Anonymous.doubleValue };
731 out.push((parsed, value));
732 }
733 }
734 }
735 }
736}
737
738/// DXGI adapter enumeration + D3DKMT LUID→PCI identity (§3.1/§3.3).
739pub mod adapters {
740 use crate::model::Vendor;
741
742 /// Everything the backend needs about one DXGI adapter — pure data, any OS.
743 #[derive(Clone, Debug, PartialEq)]
744 pub struct AdapterInfo {
745 /// DXGI enumeration ordinal (only used in the synthetic-id fallback).
746 pub ordinal: u32,
747 /// `DXGI_ADAPTER_DESC1.Description`.
748 pub name: String,
749 pub vendor_id: u32,
750 pub device_id: u32,
751 /// Adapter LUID — the in-session join key for PDH instances and D3DKMT.
752 /// Session-scoped (changes on reboot/driver update): NEVER persisted as identity.
753 pub luid_high: i32,
754 pub luid_low: u32,
755 /// `DedicatedVideoMemory` — real VRAM on discrete boards; on iGPU/UMA a small
756 /// carve-out (~0 is normal) that must not be summed with the shared budget.
757 pub dedicated_video_bytes: u64,
758 /// `SharedSystemMemory` — the UMA/shared budget, carried separately so the UI can
759 /// label it as such (never folded into "VRAM total", §3.4).
760 pub shared_system_bytes: u64,
761 /// Normalized PCI BDF (`"0000:bb:dd.f"`) from D3DKMT `ADAPTERADDRESS`, when the
762 /// thunk worked. `None` → the synthetic-id fallback (which refuses to dedupe).
763 pub pci_bdf: Option<String>,
764 }
765
766 /// PCI vendor-id → vendor. Pure, tested on any OS. Unknown ids (including 0x1414
767 /// Microsoft, whose software adapters are skipped before this is ever consulted) map
768 /// to `Unknown` — which renders as the honest generic "GPU".
769 pub fn vendor_of(vendor_id: u32) -> Vendor {
770 match vendor_id {
771 0x10DE => Vendor::Nvidia,
772 0x1002 => Vendor::Amd,
773 0x8086 => Vendor::Intel,
774 _ => Vendor::Unknown,
775 }
776 }
777
778 /// Format a D3DKMT `ADAPTERADDRESS` as the normalized BDF the whole tool keys on:
779 /// `"0000:bb:dd.f"`, lowercase hex. The struct has no PCI-domain field — client
780 /// Windows is effectively always domain 0 (§2.5), hence the literal `0000`. This is
781 /// byte-compatible with what `normalize_pci_id` produces from NVML's
782 /// 8-hex-digit-domain form, which is exactly what makes first-wins dedupe work.
783 ///
784 /// Out-of-range parts (bus > 0xff, device > 0x1f, function > 7 — not expressible in a
785 /// PCI BDF) mean the thunk returned something we do not understand: `None`, so the
786 /// caller falls back to the synthetic id instead of fabricating a plausible-looking
787 /// address that might wrongly dedupe against a real device.
788 pub fn bdf_string(bus: u32, device: u32, function: u32) -> Option<String> {
789 if bus > 0xFF || device > 0x1F || function > 0x7 {
790 return None;
791 }
792 Some(format!("0000:{bus:02x}:{device:02x}.{function:x}"))
793 }
794
795 /// Fallback `DeviceId` when the D3DKMT address query fails (§3.1):
796 /// `wddm:<vendor>:<device>:<ordinal>`. The `wddm:` prefix is not hex, so
797 /// `normalize_pci_id` correctly refuses to dedupe it — listing a device twice beats
798 /// wrongly merging two. Stable within a session only; good enough for a degraded
799 /// "device-level only with synthetic id" state.
800 pub fn synthetic_device_id(vendor_id: u32, device_id: u32, ordinal: u32) -> String {
801 format!("wddm:{vendor_id:04x}:{device_id:04x}:{ordinal}")
802 }
803
804 #[cfg(target_os = "windows")]
805 pub use windows_impl::enumerate;
806
807 #[cfg(target_os = "windows")]
808 mod windows_impl {
809 use windows::Win32::Foundation::LUID;
810 use windows::Win32::Graphics::Dxgi::{
811 CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory1, DXGI_ADAPTER_FLAG_SOFTWARE,
812 };
813
814 use super::{bdf_string, AdapterInfo};
815
816 /// Enumerate hardware adapters: `CreateDXGIFactory1` → `EnumAdapters1` until
817 /// `DXGI_ERROR_NOT_FOUND` → `GetDesc1` (§3.1). Software adapters (WARP /
818 /// Microsoft Basic Render) are skipped — they are not GPUs and monitoring them
819 /// would be noise. Empty on any failure: a machine where DXGI cannot enumerate
820 /// is a machine where this backend honestly has nothing to show.
821 pub fn enumerate() -> Vec<AdapterInfo> {
822 let factory: IDXGIFactory1 = match unsafe { CreateDXGIFactory1() } {
823 Ok(f) => f,
824 Err(_) => return Vec::new(),
825 };
826 let mut out = Vec::new();
827 for ordinal in 0.. {
828 // Any enumeration error ends the loop: DXGI_ERROR_NOT_FOUND is the
829 // documented terminator, and anything else means no further adapters
830 // are reachable either.
831 let adapter: IDXGIAdapter1 = match unsafe { factory.EnumAdapters1(ordinal) } {
832 Ok(a) => a,
833 Err(_) => break,
834 };
835 let desc = match unsafe { adapter.GetDesc1() } {
836 Ok(d) => d,
837 Err(_) => continue,
838 };
839 if desc.Flags & DXGI_ADAPTER_FLAG_SOFTWARE.0 as u32 != 0 {
840 continue;
841 }
842 let name = {
843 let len = desc
844 .Description
845 .iter()
846 .position(|&c| c == 0)
847 .unwrap_or(desc.Description.len());
848 String::from_utf16_lossy(&desc.Description[..len])
849 };
850 out.push(AdapterInfo {
851 ordinal,
852 name,
853 vendor_id: desc.VendorId,
854 device_id: desc.DeviceId,
855 luid_high: desc.AdapterLuid.HighPart,
856 luid_low: desc.AdapterLuid.LowPart,
857 dedicated_video_bytes: desc.DedicatedVideoMemory as u64,
858 shared_system_bytes: desc.SharedSystemMemory as u64,
859 pci_bdf: pci_bdf_of(desc.AdapterLuid),
860 });
861 }
862 out
863 }
864
865 /// LUID → PCI BDF via the WDK-documented gdi32 thunks (Windows 8+, §3.3):
866 /// `D3DKMTOpenAdapterFromLuid` → `D3DKMTQueryAdapterInfo(ADAPTERADDRESS)` →
867 /// `D3DKMTCloseAdapter`. This is the least-contractual API in the chain, so it is
868 /// isolated here and every failure degrades to `None` (synthetic id), never a
869 /// crash. `D3DKMTQueryStatistics` is "Reserved for system use" — never used.
870 fn pci_bdf_of(luid: LUID) -> Option<String> {
871 use windows::Wdk::Graphics::Direct3D::{
872 D3DKMTCloseAdapter, D3DKMTOpenAdapterFromLuid, D3DKMTQueryAdapterInfo,
873 D3DKMT_ADAPTERADDRESS, D3DKMT_CLOSEADAPTER, D3DKMT_OPENADAPTERFROMLUID,
874 D3DKMT_QUERYADAPTERINFO, KMTQAITYPE_ADAPTERADDRESS,
875 };
876
877 let mut open = D3DKMT_OPENADAPTERFROMLUID {
878 AdapterLuid: luid,
879 hAdapter: 0,
880 };
881 if unsafe { D3DKMTOpenAdapterFromLuid(&mut open) }.is_err() {
882 return None;
883 }
884 let mut addr = D3DKMT_ADAPTERADDRESS::default();
885 let mut query = D3DKMT_QUERYADAPTERINFO {
886 hAdapter: open.hAdapter,
887 Type: KMTQAITYPE_ADAPTERADDRESS,
888 pPrivateDriverData: &mut addr as *mut _ as *mut core::ffi::c_void,
889 PrivateDriverDataSize: std::mem::size_of::<D3DKMT_ADAPTERADDRESS>() as u32,
890 };
891 let status = unsafe { D3DKMTQueryAdapterInfo(&mut query) };
892 let close = D3DKMT_CLOSEADAPTER {
893 hAdapter: open.hAdapter,
894 };
895 // Best-effort close: a leak of one kernel adapter handle is survivable; a
896 // panic here is not.
897 let _ = unsafe { D3DKMTCloseAdapter(&close) };
898 if status.is_err() {
899 return None;
900 }
901 bdf_string(addr.BusNumber, addr.DeviceNumber, addr.FunctionNumber)
902 }
903 }
904}
905
906/// Trim a process image path to its basename — same rule as nvidia.rs (both separators,
907/// because NVML on Windows reports `C:\...\foo.exe` while other sources may use `/`).
908/// Pure so it unit-tests on any OS.
909#[cfg(any(target_os = "windows", test))]
910fn image_basename(path: &str) -> &str {
911 match path.rsplit(['/', '\\']).next() {
912 Some(base) if !base.is_empty() => base,
913 _ => path,
914 }
915}
916
917/// Resolve a pid to its executable basename via the OS process query (§3.5), shared with
918/// the nvidia backend's §2.4 PDH-only-pid rows. `OpenProcess` legitimately fails for other
919/// users' / protected processes when unprivileged — the row still renders, named by pid:
920/// an unnamed process is honest, a dropped one is not.
921#[cfg(target_os = "windows")]
922pub(crate) fn os_process_name(pid: u32) -> String {
923 use windows::core::PWSTR;
924 use windows::Win32::Foundation::CloseHandle;
925 use windows::Win32::System::Threading::{
926 OpenProcess, QueryFullProcessImageNameW, PROCESS_NAME_WIN32,
927 PROCESS_QUERY_LIMITED_INFORMATION,
928 };
929
930 let handle = match unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) } {
931 Ok(h) => h,
932 Err(_) => return format!("pid {pid}"),
933 };
934 let mut buf = [0u16; 260];
935 let mut len = buf.len() as u32;
936 let queried = unsafe {
937 QueryFullProcessImageNameW(
938 handle,
939 PROCESS_NAME_WIN32,
940 PWSTR(buf.as_mut_ptr()),
941 &mut len,
942 )
943 };
944 // Best-effort close — see pci_bdf_of for the rationale.
945 let _ = unsafe { CloseHandle(handle) };
946 if queried.is_ok() && len > 0 {
947 let full = String::from_utf16_lossy(&buf[..len as usize]);
948 let base = image_basename(&full);
949 if !base.is_empty() {
950 return base.to_string();
951 }
952 }
953 format!("pid {pid}")
954}
955
956#[cfg(target_os = "windows")]
957pub use backend_impl::WddmBackend;
958
959#[cfg(target_os = "windows")]
960mod backend_impl {
961 use super::adapters::{self, AdapterInfo};
962 use super::{os_process_name, pdh};
963 use crate::backend::{BackendError, GpuBackend};
964 use crate::model::{now_ms, DeviceId, DynamicSample, ProcessKind, ProcessSample, StaticInfo};
965
966 pub struct WddmBackend {
967 /// (adapter, persistent id) established at init. The LUID inside `AdapterInfo`
968 /// is the session-scoped PDH/D3DKMT join key; the `DeviceId` is the persistent
969 /// identity (PCI BDF, or the non-deduping `wddm:` synthetic fallback).
970 devs: Vec<(AdapterInfo, DeviceId)>,
971 }
972
973 impl WddmBackend {
974 pub fn init() -> Result<Self, BackendError> {
975 let infos = adapters::enumerate();
976 if infos.is_empty() {
977 // Normal on GPU-less machines (CI runners, some VMs): only software
978 // adapters (or none) exist. Backend skipped, mock fallback covers the UI.
979 return Err(BackendError::Unavailable(
980 "no hardware DXGI adapters (software/WARP only)".into(),
981 ));
982 }
983 // Touch the shared PDH query once at init so the first Engine tick is the
984 // *second* collection and rate counters can already produce values.
985 let _ = pdh::shared().snapshot(now_ms());
986 let devs = infos
987 .into_iter()
988 .map(|a| {
989 let id = DeviceId(a.pci_bdf.clone().unwrap_or_else(|| {
990 adapters::synthetic_device_id(a.vendor_id, a.device_id, a.ordinal)
991 }));
992 (a, id)
993 })
994 .collect();
995 Ok(Self { devs })
996 }
997
998 fn adapter_of(&self, dev: &DeviceId) -> Result<&AdapterInfo, BackendError> {
999 self.devs
1000 .iter()
1001 .find(|(_, id)| id == dev)
1002 .map(|(a, _)| a)
1003 .ok_or_else(|| BackendError::DeviceNotFound(dev.clone()))
1004 }
1005 }
1006
1007 impl GpuBackend for WddmBackend {
1008 fn name(&self) -> &'static str {
1009 "wddm"
1010 }
1011
1012 fn devices(&mut self) -> Vec<DeviceId> {
1013 self.devs.iter().map(|(_, id)| id.clone()).collect()
1014 }
1015
1016 fn static_info(&mut self, dev: &DeviceId) -> Result<StaticInfo, BackendError> {
1017 let a = self.adapter_of(dev)?;
1018 // §2.7-analog hint for this backend: with no GPU Engine PDH object there is
1019 // no per-process attribution at all — say why the table is empty up front.
1020 let process_hint = if pdh::shared().engine_object_present() {
1021 None
1022 } else {
1023 Some(
1024 "per-process GPU stats unavailable: no WDDM 2.0 GPU/driver \
1025 (Windows exposes them via GPU performance counters)"
1026 .into(),
1027 )
1028 };
1029 Ok(StaticInfo {
1030 id: dev.clone(),
1031 vendor: adapters::vendor_of(a.vendor_id),
1032 name: if a.name.is_empty() {
1033 "WDDM adapter".into()
1034 } else {
1035 a.name.clone()
1036 },
1037 backend: "wddm".into(),
1038 // DXGI DedicatedVideoMemory. On iGPU/UMA adapters a dedicated segment of
1039 // 0 is normal — but reporting Some(0) as "total VRAM" would turn every
1040 // usage percentage into a division-by-zero lie, so 0 maps to None until
1041 // the model can carry the shared budget (AdapterInfo.shared_system_bytes)
1042 // as the separately-labeled number it must be (§3.4: never sum them).
1043 mem_total_bytes: (a.dedicated_video_bytes > 0).then_some(a.dedicated_video_bytes),
1044 // No public API without vendor SDKs (§3.4) — same story as the dynamic
1045 // power/temp/clock fields below.
1046 power_limit_mw: None,
1047 max_sm_clock_mhz: None,
1048 temp_slowdown_c: None,
1049 // DXCore DriverVersion is a follow-up (§3.4); DXGI has no driver version.
1050 driver_version: None,
1051 process_hint,
1052 // §3.4/§5.4: the mandatory honesty label for this source's headline —
1053 // utilization is the busiest single engine's scheduler duty-cycle (the
1054 // per-tick engine name rides in DynamicSample::util_engine), and the
1055 // missing power/temp/clock columns are an OS gap, not a device gap.
1056 source_caveat: Some(
1057 "utilization is the busiest engine's WDDM scheduler (VidSch) \
1058 duty-cycle, not whole-GPU capacity; Windows exposes no public \
1059 power/temperature/clock API for this GPU"
1060 .into(),
1061 ),
1062 })
1063 }
1064
1065 fn refresh_dynamic(&mut self, dev: &DeviceId) -> Result<DynamicSample, BackendError> {
1066 let a = self.adapter_of(dev)?;
1067 // PDH unavailable / first sample / GPU-less: every PDH-sourced field is None.
1068 // Absence is a normal outcome, never an Err (the device did not fall off the
1069 // bus — we just cannot observe it this tick).
1070 let snap = pdh::shared().snapshot(now_ms()).unwrap_or_default();
1071 let (high, low) = (a.luid_high, a.luid_low);
1072
1073 // Busiest-single-engine headline — scheduler (VidSch) duty-cycle, labeled by
1074 // the static source_caveat and deliberately Task-Manager-comparable. The
1075 // busiest engine's *name* travels with the number (util_engine) so the UI
1076 // can say WHICH engine made the claim: "Copy 97%" ≠ "3D 97%".
1077 let headline = pdh::device_util(&snap.engine_util, high, low);
1078
1079 Ok(DynamicSample {
1080 ts_ms: now_ms(),
1081 util_pct: headline.as_ref().map(|h| h.pct as f32),
1082 util_engine: headline.map(|h| h.engtype),
1083 // Adapter-level Dedicated Usage: the VidMm number (KB4490156). NEVER
1084 // QueryVideoMemoryInfo — that is the calling process's own view (§3.4).
1085 mem_used_bytes: pdh::adapter_bytes(&snap.adapter_dedicated, high, low),
1086 // Windows exposes no public power/temperature/fan/clock API for AMD and
1087 // Intel GPUs without vendor SDKs (which the no-vendor-SDK rule forbids):
1088 // honest None, rendered as "unavailable" by the UI. The driver-optional
1089 // D3DKMT ADAPTERPERFDATA probe and DXCore telemetry are explicitly out of
1090 // scope for v1.5 (§3.6) — do not "fix" these in.
1091 power_mw: None,
1092 temp_c: None,
1093 fan_pct: None,
1094 sm_clock_mhz: None,
1095 mem_clock_mhz: None,
1096 // Per-engtype busy% — engtype names are an open set matched by name; a
1097 // GPU without that engine type yields None, not 0 (§3.4).
1098 encoder_pct: pdh::engtype_util(&snap.engine_util, high, low, "VideoEncode")
1099 .map(|v| v as f32),
1100 decoder_pct: pdh::engtype_util(&snap.engine_util, high, low, "VideoDecode")
1101 .map(|v| v as f32),
1102 // This source cannot observe throttling — `None` is the §5.4 spelling of
1103 // "unobserved". An all-false struct here would be a fabricated negative
1104 // ("not throttling" asserted as fact), which this model change exists to
1105 // make unrepresentable.
1106 throttle: None,
1107 })
1108 }
1109
1110 fn refresh_processes(
1111 &mut self,
1112 dev: &DeviceId,
1113 ) -> Result<Vec<ProcessSample>, BackendError> {
1114 let a = self.adapter_of(dev)?;
1115 let snap = pdh::shared().snapshot(now_ms()).unwrap_or_default();
1116 let (high, low) = (a.luid_high, a.luid_low);
1117
1118 // Join GPU Engine (util) and GPU Process Memory (dedicated bytes) by pid on
1119 // this LUID. Shared Usage is in the snapshot for the UI's dedicated-vs-shared
1120 // split, but is NEVER added into mem_bytes (§2.4).
1121 let util = pdh::per_pid_util(&snap.engine_util, high, low);
1122 let mem = pdh::per_pid_bytes(&snap.proc_dedicated, high, low);
1123
1124 let mut pids: Vec<u32> = util.keys().chain(mem.keys()).copied().collect();
1125 pids.sort_unstable();
1126 pids.dedup();
1127
1128 Ok(pids
1129 .into_iter()
1130 .map(|pid| {
1131 let u = util.get(&pid);
1132 ProcessSample {
1133 pid,
1134 name: os_process_name(pid),
1135 // PDH does not distinguish compute from graphics clients; a
1136 // Compute/Cuda engtype upgrades to Compute as a heuristic only
1137 // (§3.5). Everything else is honestly Unknown.
1138 kind: if u.is_some_and(|u| u.compute_hint) {
1139 ProcessKind::Compute
1140 } else {
1141 ProcessKind::Unknown
1142 },
1143 mem_bytes: mem.get(&pid).copied(),
1144 // Max-across-engines, scheduler duty-cycle (module docs). A pid
1145 // seen only by the memory counters has no engine instance yet:
1146 // util unknown, not 0.
1147 util_pct: u.map(|u| u.pct as f32),
1148 // No /proc on Windows: both honestly None (§3.5).
1149 cpu_pct: None,
1150 container: None,
1151 }
1152 })
1153 .collect())
1154 }
1155 }
1156}
1157
1158#[cfg(test)]
1159mod tests {
1160 use super::adapters::{bdf_string, synthetic_device_id, vendor_of};
1161 use super::image_basename;
1162 use super::pdh::{
1163 adapter_bytes, device_util, engtype_util, item_value_is_trustworthy, parse_instance,
1164 per_pid_bytes, per_pid_util, status_is_normal_absence, InstanceLuid, ParsedInstance,
1165 PdhSnapshot, PDH_CSTATUS_INVALID_DATA, PDH_CSTATUS_NEW_DATA, PDH_CSTATUS_NO_COUNTER,
1166 PDH_CSTATUS_NO_INSTANCE, PDH_CSTATUS_NO_OBJECT, PDH_INVALID_DATA, PDH_MORE_DATA,
1167 PDH_NO_DATA, PDH_OK, PDH_QUERY_PERF_DATA_TIMEOUT,
1168 };
1169 use crate::model::Vendor;
1170
1171 // ---- counter-instance grammar (string fixtures — these run on every OS) ----
1172
1173 #[test]
1174 fn parse_engine_instance_full_form() {
1175 // The canonical \GPU Engine(*) instance shape, as Task Manager's data source
1176 // emits it on real Windows 10/11 machines.
1177 let p = parse_instance("pid_1234_luid_0x00000000_0x0000C739_phys_0_eng_3_engtype_3D")
1178 .expect("canonical engine instance must parse");
1179 assert_eq!(p.pid, Some(1234));
1180 assert_eq!(p.luid, Some(InstanceLuid(0x0000_0000, 0x0000_C739)));
1181 assert_eq!(p.phys, Some(0));
1182 assert_eq!(p.eng, Some(3));
1183 assert_eq!(p.engtype.as_deref(), Some("3D"));
1184 assert_eq!(p.part, None);
1185 }
1186
1187 #[test]
1188 fn parse_process_memory_instance_without_engine_tokens() {
1189 // \GPU Process Memory(*) instances carry pid+luid+phys but no eng/engtype.
1190 let p = parse_instance("pid_8232_luid_0x00000000_0x0000C32D_phys_0")
1191 .expect("process-memory instance must parse");
1192 assert_eq!(p.pid, Some(8232));
1193 assert_eq!(p.luid, Some(InstanceLuid(0, 0x0000_C32D)));
1194 assert_eq!(p.phys, Some(0));
1195 assert_eq!(p.eng, None);
1196 assert_eq!(p.engtype, None);
1197 }
1198
1199 #[test]
1200 fn parse_adapter_memory_instance_has_no_pid() {
1201 // \GPU Adapter Memory(*) instances are device-level: luid+phys only.
1202 let p = parse_instance("luid_0x00000000_0x0000C32D_phys_0")
1203 .expect("adapter-memory instance must parse");
1204 assert_eq!(p.pid, None);
1205 assert_eq!(p.luid, Some(InstanceLuid(0, 0x0000_C32D)));
1206 assert_eq!(p.phys, Some(0));
1207 }
1208
1209 #[test]
1210 fn parse_part_token_and_multi_word_engtype() {
1211 // The optional part_N token, and an engtype containing an underscore: engtype is
1212 // "rest of string", underscores preserved — the engtype set is open (§3.2) and a
1213 // parser that split it would silently rename future engine types.
1214 let p = parse_instance(
1215 "pid_4_luid_0x00000000_0x0000ABCD_phys_0_part_1_eng_2_engtype_Video_Decode",
1216 )
1217 .expect("part + multi-token engtype must parse");
1218 assert_eq!(p.part, Some(1));
1219 assert_eq!(p.eng, Some(2));
1220 assert_eq!(p.engtype.as_deref(), Some("Video_Decode"));
1221 }
1222
1223 #[test]
1224 fn parse_is_keyword_driven_not_positional() {
1225 // Decoy for a positional parser: tokens reordered. The grammar is keyword-driven
1226 // (§3.2), so this still parses — a parser that assumed pid-first would read the
1227 // luid bytes as a pid here.
1228 let p = parse_instance("luid_0x00000000_0x0000C739_pid_77_phys_0")
1229 .expect("keyword-driven grammar must accept reordered tokens");
1230 assert_eq!(p.pid, Some(77));
1231 assert_eq!(p.luid, Some(InstanceLuid(0, 0xC739)));
1232 }
1233
1234 #[test]
1235 fn parse_rejects_malformed_decoys() {
1236 // Each decoy is a string a wrong code path would happily mis-parse. Refusing to
1237 // guess means the instance counts as unattributed — never a wrong attribution.
1238 for (decoy, why) in [
1239 ("", "empty string"),
1240 ("_Total", "classic non-GPU PDH instance name"),
1241 ("Processor Information", "non-GPU object instance"),
1242 ("pid_12x4_luid_0x0_0x1_phys_0", "non-numeric pid"),
1243 ("pid_1234_luid_0x00000000", "luid missing its second DWORD"),
1244 (
1245 "pid_1234_luid_00000000_0000C739_phys_0",
1246 "luid parts without 0x prefix",
1247 ),
1248 ("pid_luid_0x0_0x1", "keyword where a value belongs"),
1249 (
1250 "pid_1234_luid_0x0_0x1_phys_0_eng_0_engtype_",
1251 "empty engtype",
1252 ),
1253 ("pid_1234_bogus_7", "unknown keyword"),
1254 (
1255 "pid_1234_luid_0x123456789_0x1_phys_0",
1256 "luid DWORD wider than 32 bits",
1257 ),
1258 ("___", "nothing but separators"),
1259 ] {
1260 assert_eq!(
1261 parse_instance(decoy),
1262 None,
1263 "must reject: {why} ({decoy:?})"
1264 );
1265 }
1266 }
1267
1268 #[test]
1269 fn luid_matching_verifies_both_parts_in_either_order() {
1270 // The printed HighPart/LowPart order is inferred from observation, not
1271 // documented — so matching accepts either order but always verifies BOTH parts.
1272 let l = InstanceLuid(0x0000_0000, 0x0000_C739);
1273 assert!(l.matches(0, 0xC739), "observed order must match");
1274 assert!(
1275 InstanceLuid(0x0000_C739, 0x0000_0000).matches(0, 0xC739),
1276 "swapped printed order must also match (both parts verified)"
1277 );
1278 assert!(!l.matches(0, 0xC740), "one wrong part must not match");
1279 assert!(!l.matches(1, 0xC739), "one wrong part must not match");
1280 // HighPart is a signed LONG: a negative bit-pattern must round-trip.
1281 assert!(InstanceLuid(0xFFFF_FFFF, 0x10).matches(-1, 0x10));
1282 }
1283
1284 // ---- aggregation math (scripted snapshots — any OS) ----
1285
1286 /// Engine-utilization reading fixture: (pid, luid, eng, engtype, value).
1287 fn eng(pid: u32, luid: (u32, u32), engn: u32, ty: &str, v: f64) -> (ParsedInstance, f64) {
1288 (
1289 ParsedInstance {
1290 pid: Some(pid),
1291 luid: Some(InstanceLuid(luid.0, luid.1)),
1292 phys: Some(0),
1293 part: None,
1294 eng: Some(engn),
1295 engtype: Some(ty.into()),
1296 },
1297 v,
1298 )
1299 }
1300
1301 /// Memory reading fixture: (pid or device-level, luid, bytes).
1302 fn memr(pid: Option<u32>, luid: (u32, u32), v: f64) -> (ParsedInstance, f64) {
1303 (
1304 ParsedInstance {
1305 pid,
1306 luid: Some(InstanceLuid(luid.0, luid.1)),
1307 phys: Some(0),
1308 ..Default::default()
1309 },
1310 v,
1311 )
1312 }
1313
1314 const LUID_A: (u32, u32) = (0, 0xC739);
1315 const LUID_B: (u32, u32) = (0, 0xBEEF);
1316
1317 #[test]
1318 fn device_util_headline_is_busiest_engine_after_pid_sum() {
1319 // eng0/3D: 30+25=55 across two pids; eng1/Copy: 70 — headline must be the
1320 // busiest single ENGINE (Task-Manager-comparable), not the busiest pid or a
1321 // device-wide sum.
1322 let readings = vec![
1323 eng(1, LUID_A, 0, "3D", 30.0),
1324 eng(2, LUID_A, 0, "3D", 25.0),
1325 eng(1, LUID_A, 1, "Copy", 70.0),
1326 ];
1327 let h = device_util(&readings, 0, 0xC739).expect("matching instances → headline");
1328 assert_eq!(h.engtype, "Copy");
1329 assert_eq!(h.pct, 70.0);
1330 }
1331
1332 #[test]
1333 fn device_util_preserves_nocap100_sums_over_100() {
1334 // PDH_FMT_NOCAP100 semantics: a per-engine sum across pids may exceed 100 from
1335 // sampling skew. Capping would be a silent lie (§3.2) — the value passes through.
1336 let readings = vec![eng(1, LUID_A, 0, "3D", 60.0), eng(2, LUID_A, 0, "3D", 55.0)];
1337 let h = device_util(&readings, 0, 0xC739).unwrap();
1338 assert_eq!(h.pct, 115.0, "NOCAP100 sums must not be clamped to 100");
1339 }
1340
1341 #[test]
1342 fn luid_grouping_excludes_other_adapters() {
1343 // A second adapter's 99%-busy engine must not leak into adapter A's headline.
1344 let readings = vec![eng(1, LUID_A, 0, "3D", 40.0), eng(9, LUID_B, 0, "3D", 99.0)];
1345 let h = device_util(&readings, 0, 0xC739).unwrap();
1346 assert_eq!(h.pct, 40.0);
1347 // And adapter B sees only its own.
1348 let h = device_util(&readings, 0, 0xBEEF).unwrap();
1349 assert_eq!(h.pct, 99.0);
1350 }
1351
1352 #[test]
1353 fn unmatched_luid_instances_are_unattributed() {
1354 // No reading matches the queried adapter: everything is "unattributed" — None /
1355 // empty, never a guessed attribution (§3.2).
1356 let readings = vec![eng(1, LUID_A, 0, "3D", 40.0)];
1357 assert_eq!(device_util(&readings, 7, 0x1234), None);
1358 assert!(per_pid_util(&readings, 7, 0x1234).is_empty());
1359 let mem = vec![memr(Some(1), LUID_A, 1024.0)];
1360 assert!(per_pid_bytes(&mem, 7, 0x1234).is_empty());
1361 assert_eq!(adapter_bytes(&mem, 7, 0x1234), None);
1362 }
1363
1364 #[test]
1365 fn engtype_sums_for_encoder_decoder_and_absent_is_none() {
1366 let readings = vec![
1367 // Two VideoEncode engines: eng4 sums to 30+20=50, eng5 holds 60 → max 60.
1368 eng(1, LUID_A, 4, "VideoEncode", 30.0),
1369 eng(2, LUID_A, 4, "VideoEncode", 20.0),
1370 eng(3, LUID_A, 5, "VideoEncode", 60.0),
1371 eng(1, LUID_A, 0, "3D", 90.0),
1372 ];
1373 assert_eq!(
1374 engtype_util(&readings, 0, 0xC739, "VideoEncode"),
1375 Some(60.0)
1376 );
1377 // Engtype matching is by name, case-insensitively — the set is open (§3.2).
1378 assert_eq!(
1379 engtype_util(&readings, 0, 0xC739, "videoencode"),
1380 Some(60.0)
1381 );
1382 // No VideoDecode engine on this GPU: None, never a fabricated 0 (§3.4).
1383 assert_eq!(engtype_util(&readings, 0, 0xC739, "VideoDecode"), None);
1384 }
1385
1386 #[test]
1387 fn per_pid_util_is_max_across_engines_and_names_the_busiest() {
1388 let readings = vec![
1389 eng(1, LUID_A, 0, "3D", 30.0),
1390 eng(1, LUID_A, 1, "Copy", 70.0),
1391 eng(2, LUID_A, 6, "Cuda", 15.0),
1392 ];
1393 let m = per_pid_util(&readings, 0, 0xC739);
1394 let p1 = &m[&1];
1395 // Max-across-engines is the Task-Manager-comparable number; the engine name
1396 // travels with it so the UI can say WHICH engine made the claim.
1397 assert_eq!(p1.pct, 70.0);
1398 assert_eq!(p1.busiest_engtype, "Copy");
1399 assert!(!p1.compute_hint);
1400 // Cuda/Compute engtype presence is the (heuristic-only) compute upgrade signal.
1401 assert!(m[&2].compute_hint);
1402 }
1403
1404 #[test]
1405 fn per_pid_dedicated_bytes_join_by_pid() {
1406 let readings = vec![
1407 memr(Some(8232), LUID_A, 1_073_741_824.0),
1408 memr(Some(444), LUID_A, 52_428_800.0),
1409 memr(Some(8232), LUID_B, 999.0), // other adapter — excluded
1410 ];
1411 let m = per_pid_bytes(&readings, 0, 0xC739);
1412 assert_eq!(m.get(&8232), Some(&1_073_741_824));
1413 assert_eq!(m.get(&444), Some(&52_428_800));
1414 assert_eq!(m.len(), 2);
1415 }
1416
1417 #[test]
1418 fn adapter_bytes_sums_this_adapters_instances_only() {
1419 // Linked adapters can expose one instance per phys for the same LUID — they sum.
1420 let a_phys1 = {
1421 let mut r = memr(None, LUID_A, 1_000.0);
1422 r.0.phys = Some(1);
1423 r
1424 };
1425 let readings = vec![
1426 memr(None, LUID_A, 2_147_483_648.0),
1427 a_phys1,
1428 memr(None, LUID_B, 7.0),
1429 ];
1430 assert_eq!(adapter_bytes(&readings, 0, 0xC739), Some(2_147_484_648));
1431 }
1432
1433 #[test]
1434 fn absent_counters_yield_empty_aggregation() {
1435 // THE GPU-less CI runner case (§3.2): no GPU Engine object exists, the snapshot
1436 // is honestly empty, and every aggregate is None/empty — a normal outcome a
1437 // future refactor must not turn into an error.
1438 let snap = PdhSnapshot::default();
1439 assert_eq!(device_util(&snap.engine_util, 0, 0xC739), None);
1440 assert_eq!(
1441 engtype_util(&snap.engine_util, 0, 0xC739, "VideoEncode"),
1442 None
1443 );
1444 assert!(per_pid_util(&snap.engine_util, 0, 0xC739).is_empty());
1445 assert!(per_pid_bytes(&snap.proc_dedicated, 0, 0xC739).is_empty());
1446 assert_eq!(adapter_bytes(&snap.adapter_dedicated, 0, 0xC739), None);
1447 }
1448
1449 #[test]
1450 fn pdh_absence_status_codes_are_normal_outcomes() {
1451 // The §3.2 absence-is-normal table, pinned: each code maps to None + at most one
1452 // self-honesty event upstream — never an error.
1453 for code in [
1454 PDH_CSTATUS_NO_OBJECT, // no WDDM 2.0 GPU — GPU-less CI runners
1455 PDH_CSTATUS_NO_COUNTER, // object without this counter
1456 PDH_CSTATUS_NO_INSTANCE, // nothing currently touches the GPU
1457 PDH_NO_DATA, // first collection
1458 PDH_CSTATUS_INVALID_DATA, // per-item first-sample
1459 PDH_INVALID_DATA, // query-level first-sample
1460 PDH_QUERY_PERF_DATA_TIMEOUT, // transient provider miss, NOT device_lost
1461 ] {
1462 assert!(
1463 status_is_normal_absence(code),
1464 "0x{code:08X} is a normal absence, not an error"
1465 );
1466 }
1467 // Success and "more data" are not absences — a classifier that swallowed them
1468 // would hide real values.
1469 assert!(!status_is_normal_absence(PDH_OK));
1470 assert!(!status_is_normal_absence(PDH_MORE_DATA));
1471 // Per-item trust gate: only VALID (0) and NEW_DATA pass.
1472 assert!(item_value_is_trustworthy(PDH_OK));
1473 assert!(item_value_is_trustworthy(PDH_CSTATUS_NEW_DATA));
1474 assert!(!item_value_is_trustworthy(PDH_CSTATUS_INVALID_DATA));
1475 }
1476
1477 // ---- identity (any OS) ----
1478
1479 #[test]
1480 fn bdf_string_matches_collector_normalization() {
1481 // Must be byte-identical to what normalize_pci_id produces from NVML's form —
1482 // that equality IS the cross-backend dedupe (§2.5). Domain is the literal 0000:
1483 // D3DKMT has no domain field and client Windows is effectively domain 0.
1484 assert_eq!(bdf_string(1, 0, 0).as_deref(), Some("0000:01:00.0"));
1485 // Lowercase hex, zero-padded 2/2/1 — same shape as sysfs/NVML-normalized ids.
1486 assert_eq!(bdf_string(0x0A, 2, 0).as_deref(), Some("0000:0a:02.0"));
1487 assert_eq!(bdf_string(0xFF, 0x1F, 7).as_deref(), Some("0000:ff:1f.7"));
1488 // Values a PCI BDF cannot express mean the thunk returned something we do not
1489 // understand — None (synthetic fallback), never a fabricated plausible address.
1490 assert_eq!(bdf_string(0x100, 0, 0), None);
1491 assert_eq!(bdf_string(0, 0x20, 0), None);
1492 assert_eq!(bdf_string(0, 0, 8), None);
1493 }
1494
1495 #[test]
1496 fn synthetic_id_refuses_pci_shape() {
1497 // The fallback id must NOT parse as a PCI address: normalize_pci_id requires a
1498 // hex first segment, and "wddm" is not hex — so the collector never dedupes it
1499 // (listing a device twice beats wrongly merging two, §3.1).
1500 let id = synthetic_device_id(0x10DE, 0x2684, 0);
1501 assert_eq!(id, "wddm:10de:2684:0");
1502 let first_segment = id.split(':').next().unwrap();
1503 assert!(
1504 first_segment.bytes().any(|b| !b.is_ascii_hexdigit()),
1505 "first segment must not be pure hex, or it could dedupe as PCI: {id}"
1506 );
1507 }
1508
1509 #[test]
1510 fn vendor_of_maps_pci_vendor_ids() {
1511 assert_eq!(vendor_of(0x10DE), Vendor::Nvidia);
1512 assert_eq!(vendor_of(0x1002), Vendor::Amd);
1513 assert_eq!(vendor_of(0x8086), Vendor::Intel);
1514 // 0x1414 (Microsoft) adapters are software and skipped before vendor mapping;
1515 // anything unrecognized renders as the honest generic "GPU".
1516 assert_eq!(vendor_of(0x1414), Vendor::Unknown);
1517 assert_eq!(vendor_of(0xABCD), Vendor::Unknown);
1518 }
1519
1520 #[test]
1521 fn image_basename_trims_both_separator_kinds() {
1522 // Same trim rule as nvidia.rs: Windows paths use `\`, other sources may use `/`.
1523 assert_eq!(image_basename(r"C:\Windows\System32\dwm.exe"), "dwm.exe");
1524 assert_eq!(image_basename("/usr/bin/python3"), "python3");
1525 assert_eq!(image_basename("bare.exe"), "bare.exe");
1526 // A path ending in a separator must not yield an empty name.
1527 assert_eq!(image_basename(r"C:\odd\"), r"C:\odd\");
1528 }
1529}