Skip to main content

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}