Skip to main content

gpuviewer_core/
amd.rs

1//! AMD Linux backend — hand-rolled `std::fs` readers over sysfs/hwmon/fdinfo. No library
2//! linkage at all: librocm_smi64 is explicitly off the table (soname churn broke btop
3//! twice — btop #774) and libdrm ioctls are unnecessary for everything v1 needs.
4//!
5//! Per the domain rules in CLAUDE.md:
6//! - Every path derives from a root-dir parameter (`with_root`), so the whole backend runs
7//!   against committed fixture trees; `init()` is just `with_root("/")`.
8//! - A missing file or unparsable value is `None`, never a failure — an APU without hwmon
9//!   or `pp_dpm_*` tables is a normal device, not a broken one (Intel-iGPU-style absence).
10//! - Throttle bits live in the `gpu_metrics` binary struct, which is versioned (v1.0–v3.0)
11//!   with per-version field offsets AND units, and therefore needs per-version decoders
12//!   backed by fixtures. `decode_gpu_metrics_throttle` parses it directly off the sysfs
13//!   blob (offsets derived from the in-tree kernel header — see that function). A blob that
14//!   is absent, truncated, of an unknown revision, or whose self-declared `structure_size`
15//!   is inconsistent decodes to `None`: that source is unobservable, and an asserted
16//!   all-false would fabricate an "observed: not throttling" fact (§5.4) from bytes we
17//!   never understood.
18//! - Only SMU-backed sysfs is polled (`gpu_busy_percent`, hwmon) — never GRBM registers,
19//!   whose polling breaks GFXOFF (the monitor must not change what it measures).
20//! - Per-process attribution is DRM fdinfo (kernel 5.14+, standardized 5.19+): cumulative
21//!   per-engine busy-ns → delta over wall time = util%; `drm-pdev` ties a client to a
22//!   device; keys missing on older kernels degrade to `None` fields, never lost processes.
23//!   Other users' fdinfo needs root/CAP_SYS_PTRACE, so unprivileged runs carry an honest
24//!   "your processes only" `process_hint` instead of pretending the list is complete.
25
26use std::collections::HashMap;
27use std::fs;
28use std::path::{Path, PathBuf};
29
30use crate::backend::{BackendError, GpuBackend};
31use crate::model::{
32    now_ms, DeviceId, DynamicSample, ProcessKind, ProcessSample, StaticInfo, ThrottleReasons,
33    Vendor,
34};
35
36/// Read a sysfs file and parse its trimmed contents; absent file or bad value → `None`.
37fn read_parse<T: std::str::FromStr>(path: &Path) -> Option<T> {
38    fs::read_to_string(path).ok()?.trim().parse().ok()
39}
40
41/// Read a sysfs file as a trimmed string; absent file → `None`.
42fn read_trim(path: &Path) -> Option<String> {
43    fs::read_to_string(path).ok().map(|s| s.trim().to_string())
44}
45
46/// Read a sysfs PCI id ("0x744c\n" → "744c", lowercased). An empty file is not an id —
47/// `None`, so the name fallback says "AMD GPU" rather than "AMD GPU [1002:]".
48fn read_hex_id(path: &Path) -> Option<String> {
49    let s = read_trim(path)?;
50    let s = s.strip_prefix("0x").unwrap_or(&s).to_ascii_lowercase();
51    (!s.is_empty()).then_some(s)
52}
53
54/// MHz of one `pp_dpm_*` table line ("1: 1138Mhz *"). The unit's casing varies across
55/// kernels ("Mhz"/"MHz"), so only the leading digits after the colon are trusted.
56fn dpm_line_mhz(line: &str) -> Option<u32> {
57    let after = line.split(':').nth(1)?.trim_start();
58    let digits: String = after.chars().take_while(|c| c.is_ascii_digit()).collect();
59    digits.parse().ok()
60}
61
62/// The '*'-marked level of a DPM table is the currently selected one.
63fn dpm_current_mhz(table: &str) -> Option<u32> {
64    table
65        .lines()
66        .find(|l| l.contains('*'))
67        .and_then(dpm_line_mhz)
68}
69
70/// Levels are listed ascending — the last line is the hardware maximum.
71fn dpm_max_mhz(table: &str) -> Option<u32> {
72    table
73        .lines()
74        .rev()
75        .find(|l| !l.trim().is_empty())
76        .and_then(dpm_line_mhz)
77}
78
79/// hwmon temps are MILLI-degrees C. Prefer the sensor labeled "edge" (the die-edge value
80/// every vendor tool headlines); junction/mem run hotter and would overstate. Fall back
81/// to `temp1_input` when labels are absent.
82fn edge_temp_c(hwmon: &Path) -> Option<f32> {
83    let millic = edge_temp_millic(hwmon)?;
84    Some(millic as f32 / 1000.0)
85}
86
87fn edge_temp_millic(hwmon: &Path) -> Option<i64> {
88    if let Ok(entries) = fs::read_dir(hwmon) {
89        for entry in entries.flatten() {
90            let name = entry.file_name();
91            let Some(stem) = name.to_str().and_then(|n| n.strip_suffix("_label")) else {
92                continue;
93            };
94            if !stem.starts_with("temp") {
95                continue;
96            }
97            if read_trim(&entry.path()).as_deref() == Some("edge") {
98                if let Some(millic) = read_parse(&hwmon.join(format!("{stem}_input"))) {
99                    return Some(millic);
100                }
101            }
102        }
103    }
104    read_parse(&hwmon.join("temp1_input"))
105}
106
107/// hwmon power is MICROwatts; the model carries milliwatts. RDNA3 may expose only the
108/// instantaneous `power1_input` (kernel 6.7+) and no `power1_average` — probe both.
109fn power_mw(hwmon: &Path) -> Option<u32> {
110    let uw: u64 = read_parse(&hwmon.join("power1_average"))
111        .or_else(|| read_parse(&hwmon.join("power1_input")))?;
112    Some((uw / 1000) as u32)
113}
114
115/// `power1_cap` is MICROwatts too.
116fn power_cap_mw(hwmon: &Path) -> Option<u32> {
117    let uw: u64 = read_parse(&hwmon.join("power1_cap"))?;
118    Some((uw / 1000) as u32)
119}
120
121/// `fan1_input`/`fan1_max` are RPM; percent-of-max is what the model carries.
122fn fan_pct(hwmon: &Path) -> Option<f32> {
123    let rpm: f32 = read_parse(&hwmon.join("fan1_input"))?;
124    let max: f32 = read_parse(&hwmon.join("fan1_max"))?;
125    fan_pct_of_max(rpm, max)
126}
127
128/// f32's parser happily accepts "nan"/"inf", and a broken sensor can report a negative
129/// RPM — none of those is a fan reading. Garbage in must be `None` out, never a
130/// confident percentage.
131fn fan_pct_of_max(rpm: f32, max: f32) -> Option<f32> {
132    if !rpm.is_finite() || !max.is_finite() || rpm < 0.0 || max <= 0.0 {
133        return None;
134    }
135    Some((rpm / max * 100.0).clamp(0.0, 100.0))
136}
137
138// ---- gpu_metrics throttle decoding ------------------------------------------------------
139//
140// The `gpu_metrics` sysfs node is a memcpy of the SMU firmware metrics table into a kernel
141// C struct that is then exposed verbatim to userspace. The structs (kgd_pp_interface.h) are
142// NOT `__attribute__((packed))`, so the on-disk layout is the firmware's NATURAL-aligned
143// layout: every field sits at an offset that is a multiple of its own size (u16→2, u32→4,
144// u64→8), and the kernel devs ordered members to minimise — but not always eliminate —
145// inter-field padding. All offsets below are derived by walking the member list with that
146// rule; the workings are shown inline so a future header bump can be re-verified by hand.
147//
148// Everything is little-endian (the only architectures amdgpu runs on). Every read is bounds-
149// and structure_size-gated: a short, lying, or unknown-revision blob is absence, never an
150// error and never a misattributed cause.
151
152/// The common header every `gpu_metrics_v*` opens with (kgd_pp_interface.h
153/// `struct metrics_table_header`): `u16 structure_size; u8 format_revision;
154/// u8 content_revision;` — 4 bytes, no trailing padding.
155const HEADER_LEN: usize = 4;
156
157/// Read a little-endian `u32` at `off`, or `None` if it would run past the buffer.
158fn read_u32_le(buf: &[u8], off: usize) -> Option<u32> {
159    let bytes = buf.get(off..off.checked_add(4)?)?;
160    Some(u32::from_le_bytes(bytes.try_into().ok()?))
161}
162
163/// Read a little-endian `u64` at `off`, or `None` if it would run past the buffer.
164fn read_u64_le(buf: &[u8], off: usize) -> Option<u64> {
165    let bytes = buf.get(off..off.checked_add(8)?)?;
166    Some(u64::from_le_bytes(bytes.try_into().ok()?))
167}
168
169// ASIC-INDEPENDENT throttler bits (`indep_throttle_status`), from amdgpu_smu.h
170// `SMU_THROTTLER_*_BIT`. These are normalised by the driver across ASICs, so unlike the
171// legacy `throttle_status` they are safe to map to specific causes.
172//
173// Power group (PPT = package power tracking, SPL/FPPT/SPPT = APU power limits):
174const SMU_THROTTLER_PPT0_BIT: u32 = 0;
175const SMU_THROTTLER_PPT1_BIT: u32 = 1;
176const SMU_THROTTLER_PPT2_BIT: u32 = 2;
177const SMU_THROTTLER_PPT3_BIT: u32 = 3;
178const SMU_THROTTLER_SPL_BIT: u32 = 4;
179const SMU_THROTTLER_FPPT_BIT: u32 = 5;
180const SMU_THROTTLER_SPPT_BIT: u32 = 6;
181const SMU_THROTTLER_SPPT_APU_BIT: u32 = 7;
182// Current group (TDC = thermal design current, EDC = electrical design current, APCC):
183const SMU_THROTTLER_TDC_GFX_BIT: u32 = 16;
184const SMU_THROTTLER_TDC_SOC_BIT: u32 = 17;
185const SMU_THROTTLER_TDC_MEM_BIT: u32 = 18;
186const SMU_THROTTLER_TDC_VDD_BIT: u32 = 19;
187const SMU_THROTTLER_TDC_CVIP_BIT: u32 = 20;
188const SMU_THROTTLER_EDC_CPU_BIT: u32 = 21;
189const SMU_THROTTLER_EDC_GFX_BIT: u32 = 22;
190const SMU_THROTTLER_APCC_BIT: u32 = 23;
191// Temperature group:
192const SMU_THROTTLER_TEMP_GPU_BIT: u32 = 32;
193const SMU_THROTTLER_TEMP_CORE_BIT: u32 = 33;
194const SMU_THROTTLER_TEMP_MEM_BIT: u32 = 34;
195const SMU_THROTTLER_TEMP_EDGE_BIT: u32 = 35;
196const SMU_THROTTLER_TEMP_HOTSPOT_BIT: u32 = 36;
197const SMU_THROTTLER_TEMP_SOC_BIT: u32 = 37;
198const SMU_THROTTLER_TEMP_VR_GFX_BIT: u32 = 38;
199const SMU_THROTTLER_TEMP_VR_SOC_BIT: u32 = 39;
200const SMU_THROTTLER_TEMP_VR_MEM0_BIT: u32 = 40;
201const SMU_THROTTLER_TEMP_VR_MEM1_BIT: u32 = 41;
202const SMU_THROTTLER_TEMP_LIQUID0_BIT: u32 = 42;
203const SMU_THROTTLER_TEMP_LIQUID1_BIT: u32 = 43;
204const SMU_THROTTLER_VRHOT0_BIT: u32 = 44;
205const SMU_THROTTLER_VRHOT1_BIT: u32 = 45;
206// PROCHOT (external "processor hot" assertion → hardware-forced slowdown):
207const SMU_THROTTLER_PROCHOT_CPU_BIT: u32 = 46;
208const SMU_THROTTLER_PROCHOT_GFX_BIT: u32 = 47;
209// Other:
210const SMU_THROTTLER_PPM_BIT: u32 = 56;
211const SMU_THROTTLER_FIT_BIT: u32 = 57;
212
213/// Thermal-group `indep_throttle_status` bits: any of these is a temperature limit. VRHOT
214/// (voltage-regulator over-temp) and the liquid-cooling sensors belong here too.
215const INDEP_THERMAL_MASK: u64 = (1 << SMU_THROTTLER_TEMP_GPU_BIT)
216    | (1 << SMU_THROTTLER_TEMP_CORE_BIT)
217    | (1 << SMU_THROTTLER_TEMP_MEM_BIT)
218    | (1 << SMU_THROTTLER_TEMP_EDGE_BIT)
219    | (1 << SMU_THROTTLER_TEMP_HOTSPOT_BIT)
220    | (1 << SMU_THROTTLER_TEMP_SOC_BIT)
221    | (1 << SMU_THROTTLER_TEMP_VR_GFX_BIT)
222    | (1 << SMU_THROTTLER_TEMP_VR_SOC_BIT)
223    | (1 << SMU_THROTTLER_TEMP_VR_MEM0_BIT)
224    | (1 << SMU_THROTTLER_TEMP_VR_MEM1_BIT)
225    | (1 << SMU_THROTTLER_TEMP_LIQUID0_BIT)
226    | (1 << SMU_THROTTLER_TEMP_LIQUID1_BIT)
227    | (1 << SMU_THROTTLER_VRHOT0_BIT)
228    | (1 << SMU_THROTTLER_VRHOT1_BIT);
229
230/// Power/current-group bits: PPT* (power-cap) and the TDC/EDC current limits all mean "you
231/// are being pulled back to stay inside an electrical/power envelope".
232const INDEP_POWER_MASK: u64 = (1 << SMU_THROTTLER_PPT0_BIT)
233    | (1 << SMU_THROTTLER_PPT1_BIT)
234    | (1 << SMU_THROTTLER_PPT2_BIT)
235    | (1 << SMU_THROTTLER_PPT3_BIT)
236    | (1 << SMU_THROTTLER_SPL_BIT)
237    | (1 << SMU_THROTTLER_FPPT_BIT)
238    | (1 << SMU_THROTTLER_SPPT_BIT)
239    | (1 << SMU_THROTTLER_SPPT_APU_BIT)
240    | (1 << SMU_THROTTLER_TDC_GFX_BIT)
241    | (1 << SMU_THROTTLER_TDC_SOC_BIT)
242    | (1 << SMU_THROTTLER_TDC_MEM_BIT)
243    | (1 << SMU_THROTTLER_TDC_VDD_BIT)
244    | (1 << SMU_THROTTLER_TDC_CVIP_BIT)
245    | (1 << SMU_THROTTLER_EDC_CPU_BIT)
246    | (1 << SMU_THROTTLER_EDC_GFX_BIT);
247
248/// PROCHOT is an external slowdown forced on the GPU by the platform — the AMD analogue of
249/// NVML's HW_SLOWDOWN.
250const INDEP_HW_SLOWDOWN_MASK: u64 =
251    (1 << SMU_THROTTLER_PROCHOT_CPU_BIT) | (1 << SMU_THROTTLER_PROCHOT_GFX_BIT);
252
253/// Recognised-but-uncategorised bits (APCC interconnect throttle, PPM, FIT failure-in-time).
254/// Kept explicit so the "unknown future bit" branch in [`map_indep_throttle`] only fires for
255/// genuinely new bits, not for ones we know about but do not split into a column.
256const INDEP_OTHER_MASK: u64 =
257    (1 << SMU_THROTTLER_APCC_BIT) | (1 << SMU_THROTTLER_PPM_BIT) | (1 << SMU_THROTTLER_FIT_BIT);
258
259/// Map an `indep_throttle_status` word to [`ThrottleReasons`]. Tolerant like the NVIDIA
260/// decoder: any bit outside the masks we recognise lands in `other` rather than being
261/// dropped, so a firmware/header that adds a throttler still surfaces "something is
262/// throttling" honestly.
263fn map_indep_throttle(bits: u64) -> ThrottleReasons {
264    let known = INDEP_THERMAL_MASK | INDEP_POWER_MASK | INDEP_HW_SLOWDOWN_MASK | INDEP_OTHER_MASK;
265    ThrottleReasons {
266        thermal: bits & INDEP_THERMAL_MASK != 0,
267        power_cap: bits & INDEP_POWER_MASK != 0,
268        hw_slowdown: bits & INDEP_HW_SLOWDOWN_MASK != 0,
269        // AMD has no cross-GPU sync-boost concept in this status word.
270        sync_boost: false,
271        other: (bits & INDEP_OTHER_MASK != 0) || (bits & !known != 0),
272    }
273}
274
275/// Where the throttle words live in each supported `(format_revision, content_revision)`,
276/// plus the struct's own byte length (for the `structure_size` sanity gate).
277struct ThrottleLayout {
278    /// Offset of `indep_throttle_status` (ASIC-independent u64), when the version carries it.
279    indep: Option<usize>,
280    /// Offset of the legacy ASIC-specific `throttle_status` (u32). Decoded only as a coarse
281    /// "something is throttling" when no `indep` word exists — or when the `indep` word
282    /// reads as the firmware's 0xFF "never written" sentinel.
283    legacy: Option<usize>,
284    /// `sizeof` the struct (natural-aligned). The blob must be at least this big and its
285    /// self-declared `structure_size` must match, or it is treated as absence.
286    size: usize,
287}
288
289/// Resolve the throttle layout for a `(format, content)` revision, or `None` for revisions
290/// that carry no throttle data we trust. Offsets are walked from the kernel structs in
291/// kgd_pp_interface.h under natural C alignment (see the module note above).
292fn throttle_layout(format: u8, content: u8) -> Option<ThrottleLayout> {
293    match (format, content) {
294        // gpu_metrics_v1_x (dGPU). Shared prefix, naturally aligned with no padding:
295        //   header(4) + 10×u16 temps/activity/power(20)=24 → energy_accumulator u64 @24,
296        //   system_clock_counter u64 @32, then 14×u16 avg+current clocks(28) @40 →
297        //   throttle_status u32 @68. v1.0 reorders the prefix; we map only v1.1+.
298        (1, 1) => Some(ThrottleLayout {
299            indep: None,
300            legacy: Some(68),
301            // … fan_speed/pcie(3×u16)+padding(u16)@72, gfx/mem_activity_acc(2×u32)@80,
302            // temperature_hbm[4] u16 @88 → 96.
303            size: 96,
304        }),
305        (1, 2) => Some(ThrottleLayout {
306            indep: None,
307            legacy: Some(68),
308            // … as v1.1 up to @96, then firmware_timestamp u64 @96 → 104.
309            size: 104,
310        }),
311        (1, 3) => Some(ThrottleLayout {
312            // … v1.2 up to firmware_timestamp@96(→104), voltage_soc/gfx/mem+padding1
313            // (4×u16)@104 → indep_throttle_status u64 @112 → 120.
314            indep: Some(112),
315            legacy: Some(68),
316            size: 120,
317        }),
318        // gpu_metrics_v2_x (APU). v2.0's prefix differs (system_clock_counter first), so it
319        // needs its own offsets; v2.1+ share a prefix where throttle_status lands at @108.
320        (2, 0) => Some(ThrottleLayout {
321            indep: None,
322            // header(4) → pad(4) → system_clock_counter u64 @8; then temps/core/l3
323            // (1+1+8+2 u16)@16=24 → @40, activity/power(6 u16)@40 → @52,
324            // average_core_power[8](16)@52 → @68, 6 avg + 6 current clocks(12 u16)@68 → @92,
325            // current_coreclk[8](16)@92 → @108, current_l3clk[2](4)@108 → @112 →
326            // throttle_status u32 @112.
327            legacy: Some(112),
328            // fan_pwm u16 @116, padding u16 @118 → 120.
329            size: 120,
330        }),
331        (2, 1) => Some(ThrottleLayout {
332            indep: None,
333            // header(4) → temps/core/l3(1+1+8+2 u16)@4=24 → @28, gfx/mm activity(2 u16) →
334            // @32, system_clock_counter u64 @32 → @40, 4 power u16 @40 → @48,
335            // average_core_power[8](16)@48 → @64, 6 avg+6 current clocks(12 u16)@64 → @88,
336            // current_coreclk[8](16)@88 → @104, current_l3clk[2](4)@104 → @108 →
337            // throttle_status u32 @108.
338            legacy: Some(108),
339            // fan_pwm u16 @112, padding[3] u16 @114 → 120.
340            size: 120,
341        }),
342        (2, 2) => Some(ThrottleLayout {
343            // v2.1 prefix → throttle_status@108, fan_pwm@112, padding[3]@114 → @120 →
344            // indep_throttle_status u64 @120 → 128. (The kernel struct carries indep from
345            // v2.2 onward, one content-rev earlier than some docs claim — header wins.)
346            indep: Some(120),
347            legacy: Some(108),
348            size: 128,
349        }),
350        (2, 3) => Some(ThrottleLayout {
351            // v2.2 up to indep@120(→128), then average_temperature_gfx/soc/core[8]/l3[2]
352            // (12 u16)@128 → 152.
353            indep: Some(120),
354            legacy: Some(108),
355            size: 152,
356        }),
357        (2, 4) => Some(ThrottleLayout {
358            // v2.3 up to @152, then average cpu/soc/gfx voltage+current (6 u16)@152 → 164
359            // DATA bytes — but `sizeof` is 168, not 164: the struct's u64 members align the
360            // whole struct to 8, adding 4 tail-pad bytes, and the kernel sets
361            // structure_size = sizeof (smu_cmn.h). Real Van Gogh blobs (Steam Deck,
362            // kernel 6.6+ program-6 firmware) declare 168; gating on 164 rejected them all.
363            indep: Some(120),
364            legacy: Some(108),
365            size: 168,
366        }),
367        // gpu_metrics_v3_0 (newer APU) carries no instantaneous throttle word at all —
368        // see the explicit (3, 0) branch in `decode_gpu_metrics_throttle` for why its
369        // residency ACCUMULATORS cannot honestly decode from a single sample.
370        _ => None,
371    }
372}
373
374/// Decode AMD throttle status from a raw `gpu_metrics` sysfs blob.
375///
376/// Offsets are derived from the in-tree kernel headers
377/// (`drivers/gpu/drm/amd/include/kgd_pp_interface.h` for the struct layouts and
378/// `drivers/gpu/drm/amd/pm/swsmu/inc/amdgpu_smu.h` for the `SMU_THROTTLER_*_BIT` values).
379/// The structs are naturally aligned, little-endian.
380///
381/// The contract is *honest absence on any doubt*: a buffer too short for the header, a
382/// `structure_size` that disagrees with the version's known length (or is shorter than the
383/// fields we read), an unknown `(format, content)` revision, or an out-of-bounds field all
384/// yield `None`. `Some` is returned only off a successfully decoded struct, because under
385/// §5.4 an all-false `ThrottleReasons` is an *observation* of quiet — emitting it from a
386/// blob we could not decode would fabricate a fact-grade "not throttling" forever on any
387/// kernel shipping a revision newer than this table. Garbage content is a broken interface,
388/// which is unobservable too — refusing beats guessing. We never wrap, never panic, and
389/// never narrate a byte we are not certain of.
390///
391/// Mapping precedence: prefer `indep_throttle_status` (ASIC-independent bits) and split it
392/// into thermal / power_cap / hw_slowdown / other. For older revisions that expose only the
393/// legacy ASIC-specific `throttle_status`, the per-bit meaning varies by ASIC and is not
394/// safe to map; a nonzero value reliably means "some throttler is active", so it surfaces
395/// as `other` alone rather than as a fabricated specific cause.
396///
397/// 0xFF sentinel: the SMU memsets the whole metrics table to 0xFF before writing the
398/// fields this ASIC's firmware supports (smu_cmn.h), so an all-ones word — `u64::MAX`
399/// indep / `u32::MAX` legacy — is the firmware's "never wrote this" marker, NOT 64
400/// simultaneous throttlers. A sentinel word is unobservable: decoding falls through
401/// indep → legacy → `None` as each word in turn proves unwritten. Cyan Skillfish (BC-250
402/// class) is the known shipper: it emits v2_2 without ever writing `indep_throttle_status`,
403/// and without this guard every sample would narrate thermal+power+hw_slowdown+other,
404/// permanently.
405///
406/// v3_0 is recognised but its per-sample throttle decode is deliberately `None` — see the
407/// `(3, 0)` branch below.
408pub fn decode_gpu_metrics_throttle(buf: &[u8]) -> Option<ThrottleReasons> {
409    // Common header: structure_size u16 @0, format_revision u8 @2, content_revision u8 @3.
410    if buf.len() < HEADER_LEN {
411        return None;
412    }
413    let structure_size = u16::from_le_bytes([buf[0], buf[1]]) as usize;
414    let format = buf[2];
415    let content = buf[3];
416
417    // The blob must hold at least as many bytes as it claims, and the claim must be a real
418    // header (a zeroed/garbage node reads structure_size 0). Both guards reject truncation.
419    if structure_size < HEADER_LEN || buf.len() < structure_size {
420        return None;
421    }
422
423    if let Some(layout) = throttle_layout(format, content) {
424        // structure_size must match the version's known size exactly — a mismatch means we
425        // are not looking at the struct we think we are, so we decode nothing.
426        if structure_size != layout.size {
427            return None;
428        }
429        if let Some(off) = layout.indep {
430            // An OOB read after the size gate would mean a wrong layout table, not a quiet
431            // GPU — propagate the doubt as None rather than asserting all-false.
432            let bits = read_u64_le(buf, off)?;
433            // All-ones is the SMU's 0xFF-memset "field never written" sentinel (see the
434            // doc comment): this word is unobservable — fall through to the legacy word
435            // rather than asserting every throttler active.
436            if bits != u64::MAX {
437                return Some(map_indep_throttle(bits));
438            }
439        }
440        if let Some(off) = layout.legacy {
441            // Legacy throttle_status is ASIC-specific: nonzero = throttling, cause
442            // unmappable. Honest coarse signal beats both silence and a guessed column;
443            // a decoded zero is a genuine observation of quiet.
444            let v = read_u32_le(buf, off)?;
445            // All-ones is the same memset sentinel: "unsupported", not "throttling hard".
446            if v != u32::MAX {
447                return Some(ThrottleReasons {
448                    other: v != 0,
449                    ..Default::default()
450                });
451            }
452        }
453        // Every throttle word this version carries was either absent or the 0xFF
454        // sentinel: this firmware exposes no throttle observation at all.
455        return None;
456    }
457
458    // gpu_metrics_v3_0 (Strix Point / Strix Halo / Krackan class APUs, kernel 6.7+) is
459    // recognised but deliberately NOT decoded into a per-sample throttle state. It drops
460    // the status bitfields entirely; what it carries instead are `throttle_residency_*`
461    // u32 fields (compiled offsets 228..252, behind a 2-byte pad after
462    // `current_gfx_maxfreq` u16 @224) that are firmware ACCUMULATOR counters —
463    // "incremented on every metrics table update while X was engaged"
464    // (smu14_driver_if_v14_0_0.h). A nonzero accumulator records that the throttler fired
465    // at SOME point in the firmware's window, not that it is active now: decoding
466    // raw-nonzero as "throttling" would turn one historic PROCHOT into a permanent
467    // hw_slowdown claim on every subsequent sample. Deriving a live state needs a
468    // watermark DELTA between two reads (the fdinfo engine-ns pattern), which this
469    // single-blob pure decoder cannot hold state for — so per the honesty contract the
470    // per-sample decode is None (unobservable), never a stale-nonzero false positive.
471    // (Any future stateful delta decoder must also treat u32::MAX counters as the 0xFF
472    // memset sentinel — unsupported, not saturated.)
473    if (format, content) == (3, 0) {
474        return None;
475    }
476
477    // Unknown future revision: we cannot observe throttling through a struct we cannot
478    // decode, so this is absence — not an asserted "no throttle".
479    None
480}
481
482/// The fdinfo keys this backend consumes. Anything missing (older kernel, non-DRM fd)
483/// simply stays `None` — kernel gates: engine busy-ns 5.14+, standardized keys 5.19+.
484#[derive(Default)]
485struct FdinfoDrm {
486    pdev: Option<String>,
487    vram_kib: Option<u64>,
488    gfx_ns: Option<u64>,
489    compute_ns: Option<u64>,
490}
491
492impl FdinfoDrm {
493    /// Max-merge across one pid's many fds on the same device: the fds describe the same
494    /// client's buffers, so summing would double-count — max keeps the fullest view.
495    fn merge_max(&mut self, other: FdinfoDrm) {
496        fn mx(a: &mut Option<u64>, b: Option<u64>) {
497            *a = match (*a, b) {
498                (Some(x), Some(y)) => Some(x.max(y)),
499                (x, y) => x.or(y),
500            };
501        }
502        mx(&mut self.vram_kib, other.vram_kib);
503        mx(&mut self.gfx_ns, other.gfx_ns);
504        mx(&mut self.compute_ns, other.compute_ns);
505    }
506}
507
508/// Parse one fdinfo blob ("key:\tvalue" lines). Unit suffixes are part of the fdinfo ABI
509/// ("<n> ns", "<n> KiB") and are required — guessing units is exactly the classic
510/// AMD-parsing bug this module's tests exist to prevent.
511fn parse_fdinfo(contents: &str) -> FdinfoDrm {
512    let mut out = FdinfoDrm::default();
513    for line in contents.lines() {
514        let Some((key, val)) = line.split_once(':') else {
515            continue;
516        };
517        let val = val.trim();
518        match key.trim() {
519            "drm-pdev" => out.pdev = Some(val.to_ascii_lowercase()),
520            "drm-memory-vram" => out.vram_kib = parse_suffixed(val, "KiB"),
521            "drm-engine-gfx" => out.gfx_ns = parse_suffixed(val, "ns"),
522            "drm-engine-compute" => out.compute_ns = parse_suffixed(val, "ns"),
523            _ => {}
524        }
525    }
526    out
527}
528
529fn parse_suffixed(val: &str, unit: &str) -> Option<u64> {
530    val.strip_suffix(unit)?.trim().parse().ok()
531}
532
533/// KiB → bytes, overflow-checked: a count that exceeds u64 bytes cannot be real memory.
534/// Unchecked `* 1024` would panic a debug build (taking down the scan for EVERY device,
535/// since blobs parse before the pdev filter) and silently wrap to a fabricated number in
536/// release — exactly the confidently-wrong output this product must never emit.
537fn kib_to_bytes(kib: u64) -> Option<u64> {
538    kib.checked_mul(1024)
539}
540
541/// fdinfo engine counters are cumulative busy-ns; utilization is the delta between two
542/// sightings over wall time. No baseline (first sighting) or a counter that went
543/// backwards (pid reuse re-created the client) → `None`, never a guess.
544fn engine_util_pct(prev_ns: u64, prev_ts_ms: u64, cur_ns: u64, cur_ts_ms: u64) -> Option<f32> {
545    let wall_ms = cur_ts_ms.checked_sub(prev_ts_ms)?;
546    if wall_ms == 0 {
547        return None;
548    }
549    let busy_ns = cur_ns.checked_sub(prev_ns)?;
550    let pct = busy_ns as f64 / (wall_ms as f64 * 1_000_000.0) * 100.0;
551    Some(pct.min(100.0) as f32)
552}
553
554/// `/proc/self/status` says whether the fdinfo scan can see every user's processes:
555/// euid 0, or CAP_SYS_PTRACE (bit 19) in the effective capability mask.
556fn status_grants_full_proc_scan(status: &str) -> bool {
557    const CAP_SYS_PTRACE: u32 = 19;
558    for line in status.lines() {
559        if let Some(uids) = line.strip_prefix("Uid:") {
560            // Fields: real, effective, saved, fs — effective is what access checks use.
561            if uids.split_whitespace().nth(1) == Some("0") {
562                return true;
563            }
564        }
565        if let Some(mask) = line.strip_prefix("CapEff:") {
566            if let Ok(bits) = u64::from_str_radix(mask.trim(), 16) {
567                if bits & (1 << CAP_SYS_PTRACE) != 0 {
568                    return true;
569                }
570            }
571        }
572    }
573    false
574}
575
576/// `StaticInfo::process_hint` for unprivileged runs: other users' fdinfo is unreadable
577/// without root/CAP_SYS_PTRACE, so the process table is honestly incomplete — say so up
578/// front instead of pretending it covers the machine. An unreadable status file reads as
579/// unprivileged: overstating incompleteness is safe, understating it would be a lie.
580fn fdinfo_process_hint(root: &Path) -> Option<String> {
581    let full = fs::read_to_string(root.join("proc/self/status"))
582        .is_ok_and(|s| status_grants_full_proc_scan(&s));
583    (!full)
584        .then(|| "showing your processes only — others need root or CAP_SYS_PTRACE (fdinfo)".into())
585}
586
587/// One `amdgpu.ids` line is `DEVICE_ID,\tREV_ID,\tname` (hex ids, no 0x); comment and
588/// version lines have no commas and fall through.
589fn amdgpu_ids_name(ids: &str, device: &str, revision: &str) -> Option<String> {
590    for line in ids.lines() {
591        let line = line.trim();
592        if line.is_empty() || line.starts_with('#') {
593            continue;
594        }
595        let mut fields = line.splitn(3, ',');
596        let (Some(dev), Some(rev), Some(name)) = (fields.next(), fields.next(), fields.next())
597        else {
598            continue;
599        };
600        if dev.trim().eq_ignore_ascii_case(device) && rev.trim().eq_ignore_ascii_case(revision) {
601            let name = name.trim();
602            if !name.is_empty() {
603                return Some(name.to_string());
604            }
605        }
606    }
607    None
608}
609
610/// Marketing name from libdrm's `amdgpu.ids` (keyed by device id + revision id) when the
611/// file ships on the system; otherwise a recognizable PCI-id fallback — never an error.
612fn gpu_name(root: &Path, dev_path: &Path) -> String {
613    let device = read_hex_id(&dev_path.join("device"));
614    if let (Some(dev_id), Some(rev_id)) = (&device, read_hex_id(&dev_path.join("revision"))) {
615        if let Ok(ids) = fs::read_to_string(root.join("usr/share/libdrm/amdgpu.ids")) {
616            if let Some(name) = amdgpu_ids_name(&ids, dev_id, &rev_id) {
617                return name;
618            }
619        }
620    }
621    match device {
622        Some(id) => format!("AMD GPU [1002:{id}]"),
623        None => "AMD GPU".into(),
624    }
625}
626
627/// Process name from `{root}/proc/<pid>/comm` (kernel-truncated to 15 chars); a pid
628/// placeholder when even that is unreadable.
629fn comm_name(root: &Path, pid: u32) -> String {
630    if let Ok(comm) = fs::read_to_string(root.join(format!("proc/{pid}/comm"))) {
631        let comm = comm.trim();
632        if !comm.is_empty() {
633            return comm.to_string();
634        }
635    }
636    format!("pid {pid}")
637}
638
639/// PCI address from the uevent's PCI_SLOT_NAME, lowercased — the same identity fdinfo's
640/// `drm-pdev` carries and the registry dedupes on. An empty value is no identity at all
641/// (`None`), so the card is skipped per `discover`'s contract rather than registered as
642/// a ghost device with a blank id.
643fn pci_slot_name(dev_path: &Path) -> Option<String> {
644    fs::read_to_string(dev_path.join("uevent"))
645        .ok()?
646        .lines()
647        .find_map(|l| l.strip_prefix("PCI_SLOT_NAME="))
648        .map(|s| s.trim().to_ascii_lowercase())
649        .filter(|s| !s.is_empty())
650}
651
652/// First hwmon dir under the device. hwmon indices are not stable across boots, so it is
653/// resolved through the device dir at init; absence (APUs, fixtures) is normal.
654fn first_hwmon(dev_path: &Path) -> Option<PathBuf> {
655    let entries = fs::read_dir(dev_path.join("hwmon")).ok()?;
656    let mut dirs: Vec<PathBuf> = entries.flatten().map(|e| e.path()).collect();
657    dirs.sort(); // read_dir order is arbitrary
658    dirs.into_iter().next()
659}
660
661/// One enumerated amdgpu PCI device, resolved at init.
662struct AmdDevice {
663    id: DeviceId,
664    /// `{root}/sys/class/drm/cardN/device` — the PCI dir all metric files hang off.
665    dev_path: PathBuf,
666    hwmon: Option<PathBuf>,
667}
668
669/// Enumerate `{root}/sys/class/drm/cardN/device` dirs whose vendor id is AMD (0x1002).
670/// Connector nodes ("card1-DP-1") and render nodes are skipped; a card without a
671/// PCI_SLOT_NAME has no stable identity and is skipped rather than guessed.
672fn discover(root: &Path) -> Vec<AmdDevice> {
673    let Ok(entries) = fs::read_dir(root.join("sys/class/drm")) else {
674        return Vec::new();
675    };
676    let mut cards: Vec<(u32, PathBuf)> = entries
677        .flatten()
678        .filter_map(|e| {
679            let name = e.file_name().into_string().ok()?;
680            let idx: u32 = name.strip_prefix("card")?.parse().ok()?;
681            Some((idx, e.path().join("device")))
682        })
683        .collect();
684    cards.sort_by_key(|(idx, _)| *idx); // deterministic device order
685
686    let mut devs = Vec::new();
687    for (_, dev_path) in cards {
688        if read_trim(&dev_path.join("vendor")).as_deref() != Some("0x1002") {
689            continue;
690        }
691        let Some(pci) = pci_slot_name(&dev_path) else {
692            continue;
693        };
694        let hwmon = first_hwmon(&dev_path);
695        devs.push(AmdDevice {
696            id: DeviceId(pci),
697            dev_path,
698            hwmon,
699        });
700    }
701    devs
702}
703
704pub struct AmdBackend {
705    root: PathBuf,
706    devs: Vec<AmdDevice>,
707    /// Per-(device, pid) fdinfo gfx-engine watermark: (cumulative busy-ns, wall ms).
708    last_gfx: HashMap<(DeviceId, u32), (u64, u64)>,
709    /// Set once at init: explanation for a known-incomplete process list, if any.
710    process_hint: Option<String>,
711    /// Turns the kernel's cumulative per-PID CPU counter into a per-tick rate. The CPU%/
712    /// container columns come from `/proc` (never the device root: a fixture tree's pids are
713    /// not real processes), shared with the other Linux backends via `crate::proc_meta`.
714    #[cfg(target_os = "linux")]
715    cpu: crate::proc_meta::CpuTracker,
716}
717
718impl AmdBackend {
719    /// Production entry point: the live sysfs/procfs under `/`.
720    pub fn init() -> Result<Self, BackendError> {
721        Self::with_root("/")
722    }
723
724    /// Fixture entry point: every path below derives from `root`, so tests run against
725    /// committed trees (see `tests/fixtures/`).
726    pub fn with_root(root: impl Into<PathBuf>) -> Result<Self, BackendError> {
727        let root = root.into();
728        let devs = discover(&root);
729        if devs.is_empty() {
730            return Err(BackendError::Unavailable(
731                "no amdgpu devices under sys/class/drm".into(),
732            ));
733        }
734        let process_hint = fdinfo_process_hint(&root);
735        Ok(Self {
736            root,
737            devs,
738            last_gfx: HashMap::new(),
739            process_hint,
740            #[cfg(target_os = "linux")]
741            cpu: crate::proc_meta::CpuTracker::new(),
742        })
743    }
744
745    fn device(&self, dev: &DeviceId) -> Result<&AmdDevice, BackendError> {
746        self.devs
747            .iter()
748            .find(|d| &d.id == dev)
749            .ok_or_else(|| BackendError::DeviceNotFound(dev.clone()))
750    }
751}
752
753impl GpuBackend for AmdBackend {
754    fn name(&self) -> &'static str {
755        "amd"
756    }
757
758    fn devices(&mut self) -> Vec<DeviceId> {
759        self.devs.iter().map(|d| d.id.clone()).collect()
760    }
761
762    fn static_info(&mut self, dev: &DeviceId) -> Result<StaticInfo, BackendError> {
763        let d = self.device(dev)?;
764        let p = &d.dev_path;
765
766        Ok(StaticInfo {
767            id: dev.clone(),
768            vendor: Vendor::Amd,
769            name: gpu_name(&self.root, p),
770            backend: "amd".into(),
771            mem_total_bytes: read_parse(&p.join("mem_info_vram_total")),
772            power_limit_mw: d.hwmon.as_deref().and_then(power_cap_mw),
773            max_sm_clock_mhz: fs::read_to_string(p.join("pp_dpm_sclk"))
774                .ok()
775                .as_deref()
776                .and_then(dpm_max_mhz),
777            // hwmon's temp1_crit is the shutdown-adjacent critical point, not the knee
778            // where the SMU starts pulling clocks — claiming it as the slowdown threshold
779            // would mis-narrate throttle events. Honest absence until the gpu_metrics
780            // decoder provides the real limit.
781            temp_slowdown_c: None,
782            // amdgpu is an in-tree driver: there is no driver version distinct from the
783            // kernel, and the uevent DRIVER= field is a name, not a version.
784            driver_version: None,
785            process_hint: self.process_hint.clone(),
786            // sysfs numbers carry their plain meanings — nothing to qualify.
787            source_caveat: None,
788        })
789    }
790
791    fn refresh_dynamic(&mut self, dev: &DeviceId) -> Result<DynamicSample, BackendError> {
792        let d = self.device(dev)?;
793        let p = &d.dev_path;
794        let hwmon = d.hwmon.as_deref();
795
796        Ok(DynamicSample {
797            ts_ms: now_ms(),
798            // SMU activity metric — duty-cycle-flavored like every vendor's "util".
799            util_pct: read_parse(&p.join("gpu_busy_percent")),
800            util_engine: None, // device-wide number, not an engine headline
801            mem_used_bytes: read_parse(&p.join("mem_info_vram_used")),
802            power_mw: hwmon.and_then(power_mw),
803            temp_c: hwmon.and_then(edge_temp_c),
804            fan_pct: hwmon.and_then(fan_pct),
805            sm_clock_mhz: fs::read_to_string(p.join("pp_dpm_sclk"))
806                .ok()
807                .as_deref()
808                .and_then(dpm_current_mhz),
809            mem_clock_mhz: fs::read_to_string(p.join("pp_dpm_mclk"))
810                .ok()
811                .as_deref()
812                .and_then(dpm_current_mhz),
813            // VCN (enc/dec) activity also lives in gpu_metrics, but its per-version offsets
814            // and units are a separate job — absent until that decoder lands.
815            encoder_pct: None,
816            decoder_pct: None,
817            // Throttle status lives in the versioned `gpu_metrics` binary node. An
818            // absent/unreadable file (APUs/older kernels without the node) means the
819            // source cannot observe throttling at all → `None` (§5.4), never an asserted
820            // all-false. A present blob the decoder cannot trust (truncated, lying
821            // structure_size, unknown future revision) is equally unobservable — the
822            // decoder returns None for those, so Some here always means "decoded".
823            throttle: fs::read(p.join("gpu_metrics"))
824                .ok()
825                .and_then(|buf| decode_gpu_metrics_throttle(&buf)),
826        })
827    }
828
829    fn refresh_processes(&mut self, dev: &DeviceId) -> Result<Vec<ProcessSample>, BackendError> {
830        let pci = self.device(dev)?.id.0.clone();
831        // One wall timestamp for the whole scan (one timestamp per frame, per CLAUDE.md).
832        let ts = now_ms();
833
834        // pid → max-merged fdinfo values across that pid's fds on this device.
835        let mut by_pid: HashMap<u32, FdinfoDrm> = HashMap::new();
836        if let Ok(entries) = fs::read_dir(self.root.join("proc")) {
837            for entry in entries.flatten() {
838                let Some(pid) = entry
839                    .file_name()
840                    .to_str()
841                    .and_then(|s| s.parse::<u32>().ok())
842                else {
843                    continue;
844                };
845                // Other users' fdinfo is unreadable without root/CAP_SYS_PTRACE — skip
846                // silently; the static-info hint already explains the incompleteness.
847                let Ok(fds) = fs::read_dir(entry.path().join("fdinfo")) else {
848                    continue;
849                };
850                for fd in fds.flatten() {
851                    let Ok(contents) = fs::read_to_string(fd.path()) else {
852                        continue;
853                    };
854                    let info = parse_fdinfo(&contents);
855                    if info.pdev.as_deref() != Some(pci.as_str()) {
856                        continue;
857                    }
858                    by_pid.entry(pid).or_default().merge_max(info);
859                }
860            }
861        }
862
863        let mut out: Vec<ProcessSample> = Vec::with_capacity(by_pid.len());
864        for (&pid, agg) in &by_pid {
865            // Engine-ns watermark: busy-ns delta over wall time = util%. First sighting
866            // has no baseline → None.
867            let util_pct = agg.gfx_ns.and_then(|cur| {
868                let prev = self.last_gfx.insert((dev.clone(), pid), (cur, ts));
869                prev.and_then(|(p_ns, p_ts)| engine_util_pct(p_ns, p_ts, cur, ts))
870            });
871            out.push(ProcessSample {
872                pid,
873                name: comm_name(&self.root, pid),
874                // A nonzero compute engine is decisive. (ROCm/KFD compute is known to
875                // show ~0 engine-ns in fdinfo — the /sys/class/kfd cover comes later.)
876                kind: if agg.compute_ns.unwrap_or(0) > 0 {
877                    ProcessKind::Compute
878                } else {
879                    ProcessKind::Graphics
880                },
881                mem_bytes: agg.vram_kib.and_then(kib_to_bytes),
882                util_pct,
883                cpu_pct: None,
884                container: None,
885            });
886        }
887        // Drop watermarks for pids that vanished from this device (exited processes).
888        self.last_gfx
889            .retain(|(d, pid), _| d != dev || by_pid.contains_key(pid));
890
891        // CPU% and container identity come from /proc, mirroring the NVIDIA backend. The
892        // CpuTracker holds per-PID state, so prune it to the PIDs we still see to keep it
893        // from growing across a long session; container_of is stateless.
894        #[cfg(target_os = "linux")]
895        {
896            for p in &mut out {
897                p.cpu_pct = self.cpu.sample(p.pid);
898                p.container = crate::proc_meta::container_of(p.pid);
899            }
900            let live: Vec<u32> = out.iter().map(|p| p.pid).collect();
901            self.cpu.prune(&live);
902        }
903
904        out.sort_by_key(|p| p.pid); // deterministic order for the table and tests
905        Ok(out)
906    }
907}
908
909#[cfg(test)]
910mod tests {
911    use super::*;
912
913    #[test]
914    fn dpm_table_parses_current_and_max_levels() {
915        let table = "0: 500Mhz\n1: 1138Mhz *\n2: 2890Mhz\n";
916        assert_eq!(dpm_current_mhz(table), Some(1138));
917        assert_eq!(dpm_max_mhz(table), Some(2890));
918        assert_eq!(dpm_current_mhz("garbage"), None);
919        assert_eq!(dpm_max_mhz(""), None);
920    }
921
922    #[test]
923    fn fdinfo_unit_suffixes_are_mandatory() {
924        let blob = "drm-pdev:\t0000:03:00.0\ndrm-engine-gfx:\t123 ns\ndrm-memory-vram:\t456 KiB\n";
925        let f = parse_fdinfo(blob);
926        assert_eq!(f.pdev.as_deref(), Some("0000:03:00.0"));
927        assert_eq!(f.gfx_ns, Some(123));
928        assert_eq!(f.vram_kib, Some(456));
929        assert_eq!(f.compute_ns, None, "absent key stays None");
930        // A value with the wrong/missing suffix is a key we do not understand.
931        assert_eq!(parse_suffixed("456", "KiB"), None);
932        assert_eq!(parse_suffixed("456 MiB", "KiB"), None);
933    }
934
935    #[test]
936    fn engine_util_needs_baseline_and_handles_resets() {
937        // 500ms of busy-ns over 1000ms of wall = 50%.
938        assert_eq!(engine_util_pct(0, 0, 500_000_000, 1_000), Some(50.0));
939        // Counter went backwards (pid reuse re-created the client): no claim.
940        assert_eq!(engine_util_pct(900, 0, 100, 1_000), None);
941        // Zero wall delta cannot produce a rate.
942        assert_eq!(engine_util_pct(0, 1_000, 100, 1_000), None);
943        // More busy-ns than wall time (multi-queue accounting) clamps, never exceeds.
944        assert_eq!(engine_util_pct(0, 0, 10_000_000_000, 1_000), Some(100.0));
945    }
946
947    #[test]
948    fn hostile_fdinfo_vram_cannot_panic_or_wrap() {
949        assert_eq!(kib_to_bytes(456), Some(466_944));
950        // u64::MAX KiB cannot be a real byte count: None — never a debug panic, never
951        // a release-mode wrap to a fabricated number.
952        assert_eq!(kib_to_bytes(u64::MAX), None);
953    }
954
955    #[test]
956    fn fan_pct_rejects_non_physical_readings() {
957        assert_eq!(fan_pct_of_max(1650.0, 3300.0), Some(50.0));
958        // Faster than fan1_max (worn sensor, boost) clamps, never exceeds.
959        assert_eq!(fan_pct_of_max(4000.0, 3300.0), Some(100.0));
960        // "nan"/"inf" parse as f32 but are not fan readings.
961        assert_eq!(fan_pct_of_max(f32::NAN, f32::NAN), None);
962        assert_eq!(fan_pct_of_max(f32::INFINITY, 3300.0), None);
963        assert_eq!(fan_pct_of_max(1650.0, f32::NAN), None);
964        // A negative RPM is a broken sensor, not a negative percentage.
965        assert_eq!(fan_pct_of_max(-500.0, 3300.0), None);
966        assert_eq!(fan_pct_of_max(1650.0, 0.0), None);
967    }
968
969    #[test]
970    fn proc_status_privilege_detection() {
971        // euid is the second Uid field — root euid grants the full scan.
972        assert!(status_grants_full_proc_scan(
973            "Uid:\t1000\t0\t1000\t1000\nCapEff:\t0000000000000000"
974        ));
975        // CAP_SYS_PTRACE (bit 19) alone suffices.
976        assert!(status_grants_full_proc_scan(
977            "Uid:\t1000\t1000\t1000\t1000\nCapEff:\t0000000000080000"
978        ));
979        assert!(!status_grants_full_proc_scan(
980            "Uid:\t1000\t1000\t1000\t1000\nCapEff:\t0000000000000000"
981        ));
982        assert!(!status_grants_full_proc_scan(""));
983    }
984
985    // ---- gpu_metrics throttle decoding -------------------------------------------------
986    //
987    // The blob builders below write every field at the SAME offset the decoder reads, and
988    // deliberately plant a DECOY nonzero value in the field adjacent to each word we care
989    // about. An off-by-N decoder would read the decoy and produce the wrong category, so
990    // these tests fail loudly on an offset slip rather than silently passing.
991
992    /// Build a naturally-aligned `gpu_metrics_v1_3` blob (size 120) with the given
993    /// `indep_throttle_status`. Plants a thermal-bit decoy 8 bytes BEFORE indep (the voltage
994    /// block) and a nonzero legacy throttle_status, so a correct decoder must read indep at
995    /// exactly @112 and must prefer it over the legacy word.
996    fn build_v1_3(indep: u64) -> Vec<u8> {
997        let mut b = vec![0u8; 120];
998        b[0..2].copy_from_slice(&120u16.to_le_bytes()); // structure_size
999        b[2] = 1; // format_revision
1000        b[3] = 3; // content_revision
1001        b[68..72].copy_from_slice(&0x0000_0080u32.to_le_bytes()); // legacy decoy (nonzero)
1002        b[104..112].copy_from_slice(&(1u64 << 32).to_le_bytes()); // off-by-8 thermal decoy
1003        b[112..120].copy_from_slice(&indep.to_le_bytes()); // the real word
1004        b
1005    }
1006
1007    /// Build a `gpu_metrics_v2_3` blob (size 152, APU) with the given indep word. indep lives
1008    /// at @120; the off-by-8 decoy goes at @112 (fan_pwm/padding region) and a legacy decoy
1009    /// at @108.
1010    fn build_v2_3(indep: u64) -> Vec<u8> {
1011        let mut b = vec![0u8; 152];
1012        b[0..2].copy_from_slice(&152u16.to_le_bytes());
1013        b[2] = 2;
1014        b[3] = 3;
1015        b[108..112].copy_from_slice(&0x0000_0080u32.to_le_bytes()); // legacy decoy
1016        b[112..120].copy_from_slice(&(1u64 << 32).to_le_bytes()); // off-by-8 thermal decoy
1017        b[120..128].copy_from_slice(&indep.to_le_bytes());
1018        b
1019    }
1020
1021    /// Build a legacy-only `gpu_metrics_v2_1` blob (size 120, no indep word): throttle_status
1022    /// at @108, with a nonzero decoy in the adjacent fan_pwm/padding bytes.
1023    fn build_v2_1(throttle_status: u32) -> Vec<u8> {
1024        let mut b = vec![0u8; 120];
1025        b[0..2].copy_from_slice(&120u16.to_le_bytes());
1026        b[2] = 2;
1027        b[3] = 1;
1028        b[112..114].copy_from_slice(&0xBEEFu16.to_le_bytes()); // fan_pwm decoy (adjacent)
1029        b[108..112].copy_from_slice(&throttle_status.to_le_bytes());
1030        b
1031    }
1032
1033    /// Build a `gpu_metrics_v2_2` blob (size 128): legacy@108, indep@120. Cyan Skillfish
1034    /// (BC-250 class) ships exactly this revision with `indep_throttle_status` never
1035    /// written by firmware — it reads as the SMU's 0xFF-memset sentinel — so the builder
1036    /// takes both words to let tests model that hardware.
1037    fn build_v2_2(indep: u64, legacy: u32) -> Vec<u8> {
1038        let mut b = vec![0u8; 128];
1039        b[0..2].copy_from_slice(&128u16.to_le_bytes());
1040        b[2] = 2;
1041        b[3] = 2;
1042        b[108..112].copy_from_slice(&legacy.to_le_bytes());
1043        b[112..114].copy_from_slice(&0xBEEFu16.to_le_bytes()); // fan_pwm decoy (adjacent)
1044        b[120..128].copy_from_slice(&indep.to_le_bytes());
1045        b
1046    }
1047
1048    /// Build a `gpu_metrics_v2_4` blob at the kernel's real `sizeof` = **168**: 164 data
1049    /// bytes (v2.3's 152 + 6×u16 avg voltage/current @152) plus 4 tail-pad bytes from the
1050    /// struct's u64 alignment, with structure_size = sizeof (smu_cmn.h). Offsets are
1051    /// hardcoded literals cross-checked against the audit's compiled offsetof — never
1052    /// derived from `throttle_layout()`. Kernel-true decoys: the tail pad @164-167 is 0xFF
1053    /// (the SMU memset) and the voltage/current block is nonzero, so a decoder gating on
1054    /// 164 or misreading an offset fails loudly.
1055    fn build_v2_4(indep: u64, legacy: u32) -> Vec<u8> {
1056        let mut b = vec![0u8; 168];
1057        b[0..2].copy_from_slice(&168u16.to_le_bytes());
1058        b[2] = 2;
1059        b[3] = 4;
1060        b[108..112].copy_from_slice(&legacy.to_le_bytes()); // legacy throttle_status
1061        b[112..120].copy_from_slice(&(1u64 << 32).to_le_bytes()); // off-by-8 thermal decoy
1062        b[120..128].copy_from_slice(&indep.to_le_bytes()); // the real indep word
1063        b[152..164].copy_from_slice(&[0xAB; 12]); // avg voltage/current decoys (nonzero)
1064        b[164..168].copy_from_slice(&[0xFF; 4]); // 0xFF'd tail pad — kernel-true
1065        b
1066    }
1067
1068    /// Build a `gpu_metrics_v3_0` blob (sizeof 264) with the named residency ACCUMULATORS
1069    /// set at the compiler-verified offsets 228..252 — a 2-byte pad follows
1070    /// `current_gfx_maxfreq` u16 @224. Kernel-true decoy: that pad @226-227 is 0xFF (the
1071    /// SMU memset), exactly what an off-by−2 decoder would misread as a 0xFFFF prochot.
1072    fn build_v3_0(prochot: u32, spl: u32, thm_gfx: u32) -> Vec<u8> {
1073        let mut b = vec![0u8; 264];
1074        b[0..2].copy_from_slice(&264u16.to_le_bytes());
1075        b[2] = 3;
1076        b[3] = 0;
1077        b[224..226].copy_from_slice(&2900u16.to_le_bytes()); // current_gfx_maxfreq decoy
1078        b[226..228].copy_from_slice(&[0xFF, 0xFF]); // 0xFF'd pad — the killer decoy
1079        b[228..232].copy_from_slice(&prochot.to_le_bytes()); // throttle_residency_prochot
1080        b[232..236].copy_from_slice(&spl.to_le_bytes()); // throttle_residency_spl
1081        b[248..252].copy_from_slice(&thm_gfx.to_le_bytes()); // throttle_residency_thm_gfx
1082        b
1083    }
1084
1085    #[test]
1086    fn indep_thermal_bit_decodes_to_thermal_only() {
1087        // TEMP_HOTSPOT (bit 36) is purely thermal.
1088        let t = decode_gpu_metrics_throttle(&build_v1_3(1 << SMU_THROTTLER_TEMP_HOTSPOT_BIT))
1089            .expect("well-formed v1_3 must decode");
1090        assert!(t.thermal);
1091        assert!(!t.power_cap && !t.hw_slowdown && !t.sync_boost && !t.other);
1092    }
1093
1094    #[test]
1095    fn indep_ppt_bit_decodes_to_power_cap_only() {
1096        // PPT0 (bit 0) is a package-power limit; the legacy/off-by-8 decoys must be ignored.
1097        let t = decode_gpu_metrics_throttle(&build_v1_3(1 << SMU_THROTTLER_PPT0_BIT))
1098            .expect("well-formed v1_3 must decode");
1099        assert!(t.power_cap);
1100        assert!(!t.thermal && !t.hw_slowdown && !t.sync_boost && !t.other);
1101    }
1102
1103    #[test]
1104    fn indep_prochot_bit_decodes_to_hw_slowdown() {
1105        let t = decode_gpu_metrics_throttle(&build_v1_3(1 << SMU_THROTTLER_PROCHOT_GFX_BIT))
1106            .expect("well-formed v1_3 must decode");
1107        assert!(t.hw_slowdown);
1108        assert!(!t.thermal && !t.power_cap && !t.sync_boost && !t.other);
1109    }
1110
1111    #[test]
1112    fn indep_combined_bits_decode_to_multiple_reasons() {
1113        // Thermal + power + prochot + an unknown future bit (bit 60) at once.
1114        let bits = (1 << SMU_THROTTLER_TEMP_MEM_BIT)
1115            | (1 << SMU_THROTTLER_TDC_GFX_BIT)
1116            | (1 << SMU_THROTTLER_PROCHOT_CPU_BIT)
1117            | (1u64 << 60);
1118        let t =
1119            decode_gpu_metrics_throttle(&build_v2_3(bits)).expect("well-formed v2_3 must decode");
1120        assert!(t.thermal && t.power_cap && t.hw_slowdown);
1121        // The unrecognised bit lands in `other` (tolerant decoding), not dropped.
1122        assert!(t.other);
1123        assert!(!t.sync_boost);
1124    }
1125
1126    #[test]
1127    fn legacy_only_nonzero_is_coarse_other_never_a_guessed_cause() {
1128        // v2_1 has no indep word: a nonzero ASIC-specific throttle_status is real, but its
1129        // per-bit meaning is unknowable cross-ASIC, so it surfaces as `other` alone.
1130        let t = decode_gpu_metrics_throttle(&build_v2_1(0x0000_00FF))
1131            .expect("well-formed v2_1 must decode");
1132        assert!(t.other);
1133        assert!(!t.thermal && !t.power_cap && !t.hw_slowdown && !t.sync_boost);
1134        // Zero legacy status in a decoded struct is an OBSERVED quiet — Some(all-false).
1135        assert_eq!(
1136            decode_gpu_metrics_throttle(&build_v2_1(0)),
1137            Some(ThrottleReasons::default())
1138        );
1139    }
1140
1141    #[test]
1142    fn v2_4_decodes_at_kernel_sizeof_168() {
1143        // SPPT_APU (bit 7) is an APU power limit — the Van Gogh / Steam Deck case. The
1144        // legacy decoy, off-by-8 thermal decoy, and 0xFF tail pad must all be ignored.
1145        let t = decode_gpu_metrics_throttle(&build_v2_4(1 << SMU_THROTTLER_SPPT_APU_BIT, 0x40))
1146            .expect("a real 168-byte v2_4 blob must decode");
1147        assert!(t.power_cap);
1148        assert!(!t.thermal && !t.hw_slowdown && !t.sync_boost && !t.other);
1149    }
1150
1151    #[test]
1152    fn v2_4_blob_claiming_164_is_rejected() {
1153        // 164 was this decoder's old (wrong) gate: the kernel's sizeof is 168 because the
1154        // struct's u64 members add 4 tail-pad bytes and structure_size = sizeof
1155        // (smu_cmn.h). A blob declaring 164 is therefore NOT the struct we know —
1156        // exact-size honesty rejects it (None), never best-effort decodes it.
1157        let mut b = build_v2_4(1 << SMU_THROTTLER_SPPT_APU_BIT, 0);
1158        b.truncate(164);
1159        b[0..2].copy_from_slice(&164u16.to_le_bytes());
1160        assert_eq!(
1161            decode_gpu_metrics_throttle(&b),
1162            None,
1163            "a structure_size of 164 does not match v2_4's real sizeof"
1164        );
1165    }
1166
1167    #[test]
1168    fn indep_all_ff_sentinel_falls_through_to_legacy() {
1169        // Cyan Skillfish: v2_2 with indep_throttle_status never written → 0xFF…FF from
1170        // the SMU memset. That means "word unsupported", NOT 64 simultaneous throttlers.
1171        // With a quiet legacy word the decode is an OBSERVED quiet — Some(all-false) —
1172        // never a permanent all-reasons-throttling narration.
1173        assert_eq!(
1174            decode_gpu_metrics_throttle(&build_v2_2(u64::MAX, 0)),
1175            Some(ThrottleReasons::default())
1176        );
1177        // A nonzero legacy word behind the sentinel still surfaces as coarse `other`.
1178        let t = decode_gpu_metrics_throttle(&build_v2_2(u64::MAX, 0x2))
1179            .expect("legacy word must still decode behind an indep sentinel");
1180        assert!(t.other);
1181        assert!(!t.thermal && !t.power_cap && !t.hw_slowdown && !t.sync_boost);
1182        // The dGPU path falls through the same way: v1_3 with sentinel indep reads the
1183        // legacy word at @68 (nonzero in the builder) → coarse `other`.
1184        let t = decode_gpu_metrics_throttle(&build_v1_3(u64::MAX))
1185            .expect("v1_3 legacy word must decode behind an indep sentinel");
1186        assert!(t.other);
1187        assert!(!t.thermal && !t.power_cap && !t.hw_slowdown && !t.sync_boost);
1188    }
1189
1190    #[test]
1191    fn all_words_sentinel_decodes_to_none() {
1192        // Both words carry the 0xFF memset sentinel: this firmware exposes no throttle
1193        // observation at all — unobservable (None), never Some(anything).
1194        assert_eq!(
1195            decode_gpu_metrics_throttle(&build_v2_2(u64::MAX, u32::MAX)),
1196            None
1197        );
1198    }
1199
1200    #[test]
1201    fn legacy_only_sentinel_decodes_to_none() {
1202        // v2_1 has no indep word; a 0xFF'd throttle_status means "unsupported", not
1203        // "every ASIC-specific throttler active".
1204        assert_eq!(decode_gpu_metrics_throttle(&build_v2_1(u32::MAX)), None);
1205    }
1206
1207    #[test]
1208    fn v3_residency_accumulators_never_assert_per_sample_throttle() {
1209        // v3_0 carries only firmware accumulator counters (no status word): nonzero means
1210        // "fired at some point in the firmware's window", which a single sample cannot
1211        // turn into a live state. The per-sample decode is None (unobservable) — a
1212        // historic PROCHOT must never narrate as a current hw_slowdown.
1213        assert_eq!(decode_gpu_metrics_throttle(&build_v3_0(37, 9, 12)), None);
1214        // All-zero accumulators are equally non-instantaneous — still None, not an
1215        // asserted "observed quiet" fabricated from counters we cannot interpret
1216        // per-sample. (The 0xFF'd pad @226-227 must never leak into any decode either.)
1217        assert_eq!(decode_gpu_metrics_throttle(&build_v3_0(0, 0, 0)), None);
1218        // Counters that are themselves the 0xFF memset sentinel change nothing: None.
1219        assert_eq!(
1220            decode_gpu_metrics_throttle(&build_v3_0(u32::MAX, u32::MAX, u32::MAX)),
1221            None
1222        );
1223    }
1224
1225    #[test]
1226    fn unknown_revision_decodes_to_none() {
1227        // A well-formed header with a future (format=9, content=9) revision: no offsets we
1228        // trust → None, never a guess at byte positions and never a fabricated "observed
1229        // quiet" (an asserted all-false here would be a permanent fact-grade lie on every
1230        // kernel newer than the layout table — §5.4).
1231        let mut b = build_v1_3(1 << SMU_THROTTLER_PPT0_BIT);
1232        b[2] = 9;
1233        b[3] = 9;
1234        assert_eq!(
1235            decode_gpu_metrics_throttle(&b),
1236            None,
1237            "unknown revision must be unobservable, not quiet"
1238        );
1239    }
1240
1241    #[test]
1242    fn truncated_blob_decodes_to_none_without_panic() {
1243        // The corrupt/short-sysfs honesty case: every prefix length must yield None,
1244        // never a panic, never a half-read word, never an asserted all-false.
1245        let full = build_v1_3(1 << SMU_THROTTLER_PPT0_BIT);
1246        for len in 0..full.len() {
1247            assert_eq!(
1248                decode_gpu_metrics_throttle(&full[..len]),
1249                None,
1250                "a {len}-byte prefix of a v1_3 blob must decode to None"
1251            );
1252        }
1253        // An empty buffer is the extreme of the same case.
1254        assert_eq!(decode_gpu_metrics_throttle(&[]), None);
1255    }
1256
1257    #[test]
1258    fn lying_structure_size_decodes_to_none() {
1259        // structure_size claims a smaller struct than the version's real length: we are not
1260        // looking at the struct we think we are, so decode nothing even though a PPT bit is
1261        // physically present at @112.
1262        let mut b = build_v1_3(1 << SMU_THROTTLER_PPT0_BIT);
1263        b[0..2].copy_from_slice(&100u16.to_le_bytes()); // real v1_3 is 120
1264        assert_eq!(
1265            decode_gpu_metrics_throttle(&b),
1266            None,
1267            "a structure_size that disagrees with the version size is not trusted"
1268        );
1269        // The mirror case: structure_size larger than the buffer (claims bytes we lack).
1270        let mut b = build_v1_3(1 << SMU_THROTTLER_PPT0_BIT);
1271        b[0..2].copy_from_slice(&200u16.to_le_bytes());
1272        assert_eq!(decode_gpu_metrics_throttle(&b), None);
1273    }
1274
1275    #[test]
1276    fn amdgpu_ids_lookup_is_keyed_by_device_and_revision() {
1277        let ids = "# header\n1.0.0\n744C,\tC8,\tAMD Radeon RX 7900 XTX\n744C,\tCC,\tAMD Radeon RX 7900 XT\n";
1278        assert_eq!(
1279            amdgpu_ids_name(ids, "744c", "c8").as_deref(),
1280            Some("AMD Radeon RX 7900 XTX")
1281        );
1282        assert_eq!(
1283            amdgpu_ids_name(ids, "744c", "cc").as_deref(),
1284            Some("AMD Radeon RX 7900 XT")
1285        );
1286        assert_eq!(amdgpu_ids_name(ids, "744c", "ff"), None);
1287    }
1288}