#![cfg(target_os = "linux")]
use std::path::{Path, PathBuf};
use gpuviewer_core::amd::{decode_gpu_metrics_throttle, AmdBackend};
use gpuviewer_core::{BackendError, DeviceId, GpuBackend, ProcessKind, ThrottleReasons, Vendor};
fn fixture(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures")
.join(name)
}
#[test]
fn rx7900xtx_enumeration_and_static_info() {
let mut b = AmdBackend::with_root(fixture("amd-rx7900xtx-kernel6.8")).unwrap();
let devs = b.devices();
assert_eq!(devs, vec![DeviceId("0000:03:00.0".into())]);
let info = b.static_info(&devs[0]).unwrap();
assert_eq!(info.vendor, Vendor::Amd);
assert_eq!(info.backend, "amd");
assert_eq!(info.name, "AMD Radeon RX 7900 XTX");
assert_eq!(info.mem_total_bytes, Some(25_769_803_776));
assert_eq!(info.power_limit_mw, Some(339_000));
assert_eq!(info.max_sm_clock_mhz, Some(2890));
assert_eq!(info.driver_version, None);
assert_eq!(
info.process_hint.as_deref(),
Some("showing your processes only — others need root or CAP_SYS_PTRACE (fdinfo)")
);
}
#[test]
fn rx7900xtx_dynamic_sample_unit_conversions() {
let mut b = AmdBackend::with_root(fixture("amd-rx7900xtx-kernel6.8")).unwrap();
let dev = b.devices().remove(0);
let s = b.refresh_dynamic(&dev).unwrap();
assert_eq!(s.util_pct, Some(67.0));
assert_eq!(s.mem_used_bytes, Some(8_489_271_296));
assert_eq!(s.temp_c, Some(53.0));
assert_eq!(s.power_mw, Some(284_000)); assert_eq!(s.fan_pct, Some(50.0)); assert_eq!(s.sm_clock_mhz, Some(1138)); assert_eq!(s.mem_clock_mhz, Some(1249)); assert_eq!(s.encoder_pct, None);
assert_eq!(s.decoder_pct, None);
let t = s
.throttle
.expect("gpu_metrics present → throttle observable");
assert!(t.power_cap, "PPT0 bit decodes to power_cap");
assert!(!t.thermal, "no thermal bit set — decoy must be ignored");
assert!(!t.other, "indep is preferred over the legacy word");
}
#[test]
fn rx7900xtx_fdinfo_processes() {
let mut b = AmdBackend::with_root(fixture("amd-rx7900xtx-kernel6.8")).unwrap();
let dev = b.devices().remove(0);
let procs = b.refresh_processes(&dev).unwrap();
assert_eq!(
procs.len(),
2,
"the pid on the other GPU's pdev must be filtered out"
);
let shell = &procs[0];
assert_eq!((shell.pid, shell.name.as_str()), (2210, "gnome-shell"));
assert_eq!(shell.kind, ProcessKind::Graphics); assert_eq!(shell.mem_bytes, Some(536_870_912));
let py = &procs[1];
assert_eq!((py.pid, py.name.as_str()), (4521, "python3"));
assert_eq!(py.kind, ProcessKind::Compute);
assert_eq!(py.mem_bytes, Some(6_979_321_856));
assert_eq!(py.util_pct, None);
assert_eq!(shell.util_pct, None);
}
#[test]
fn fdinfo_engine_delta_yields_util_on_second_sighting() {
let scratch =
std::env::temp_dir().join(format!("gpuviewer-amd-fixture-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&scratch);
copy_tree(&fixture("amd-rx7900xtx-kernel6.8"), &scratch);
let mut b = AmdBackend::with_root(&scratch).unwrap();
let dev = b.devices().remove(0);
let first = b.refresh_processes(&dev).unwrap();
assert!(first.iter().all(|p| p.util_pct.is_none()));
std::thread::sleep(std::time::Duration::from_millis(60));
let fd = scratch.join("proc/4521/fdinfo/12");
let bumped = std::fs::read_to_string(&fd).unwrap().replace(
"drm-engine-gfx:\t1500000000 ns",
"drm-engine-gfx:\t901500000000 ns",
);
std::fs::write(&fd, bumped).unwrap();
let second = b.refresh_processes(&dev).unwrap();
let py = second.iter().find(|p| p.pid == 4521).unwrap();
assert_eq!(py.util_pct, Some(100.0));
let shell = second.iter().find(|p| p.pid == 2210).unwrap();
assert_eq!(shell.util_pct, Some(0.0));
let _ = std::fs::remove_dir_all(&scratch);
}
#[test]
fn temp_without_labels_falls_back_to_temp1_input() {
let scratch = std::env::temp_dir().join(format!("gpuviewer-amd-temps-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&scratch);
copy_tree(&fixture("amd-rx7900xtx-kernel6.8"), &scratch);
let hwmon = scratch.join("sys/class/drm/card1/device/hwmon/hwmon3");
for n in 1..=3 {
std::fs::remove_file(hwmon.join(format!("temp{n}_label"))).unwrap();
}
let mut b = AmdBackend::with_root(&scratch).unwrap();
let dev = b.devices().remove(0);
assert_eq!(b.refresh_dynamic(&dev).unwrap().temp_c, Some(61.0));
let _ = std::fs::remove_dir_all(&scratch);
}
#[test]
fn power_falls_back_to_power1_input_when_average_is_absent() {
let scratch = std::env::temp_dir().join(format!("gpuviewer-amd-power-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&scratch);
copy_tree(&fixture("amd-rx7900xtx-kernel6.8"), &scratch);
let hwmon = scratch.join("sys/class/drm/card1/device/hwmon/hwmon3");
std::fs::remove_file(hwmon.join("power1_average")).unwrap();
std::fs::write(hwmon.join("power1_input"), "297500000\n").unwrap();
let mut b = AmdBackend::with_root(&scratch).unwrap();
let dev = b.devices().remove(0);
assert_eq!(b.refresh_dynamic(&dev).unwrap().power_mw, Some(297_500));
let _ = std::fs::remove_dir_all(&scratch);
}
#[test]
fn igpu_minimal_everything_optional_is_none() {
let mut b = AmdBackend::with_root(fixture("amd-igpu-minimal")).unwrap();
let devs = b.devices();
assert_eq!(devs, vec![DeviceId("0000:c1:00.0".into())]);
let info = b.static_info(&devs[0]).unwrap();
assert_eq!(info.name, "AMD GPU [1002:15bf]"); assert_eq!(info.mem_total_bytes, None);
assert_eq!(info.power_limit_mw, None);
assert_eq!(info.max_sm_clock_mhz, None);
let s = b.refresh_dynamic(&devs[0]).unwrap();
assert_eq!(s.util_pct, None);
assert_eq!(s.mem_used_bytes, None);
assert_eq!(s.temp_c, None);
assert_eq!(s.power_mw, None);
assert_eq!(s.fan_pct, None);
assert_eq!(s.sm_clock_mhz, None);
assert_eq!(s.mem_clock_mhz, None);
assert_eq!(s.throttle, None);
assert!(b.refresh_processes(&devs[0]).unwrap().is_empty());
}
#[test]
fn strixpoint_v3_0_tree_decodes_metrics_with_throttle_unobservable() {
let mut b = AmdBackend::with_root(fixture("amd-strixpoint-kernel6.10")).unwrap();
let devs = b.devices();
assert_eq!(devs, vec![DeviceId("0000:c4:00.0".into())]);
let info = b.static_info(&devs[0]).unwrap();
assert_eq!(info.vendor, Vendor::Amd);
assert_eq!(info.name, "AMD GPU [1002:150e]"); assert_eq!(info.mem_total_bytes, Some(536_870_912));
assert_eq!(info.power_limit_mw, None); assert_eq!(info.max_sm_clock_mhz, Some(2900));
let s = b.refresh_dynamic(&devs[0]).unwrap();
assert_eq!(s.util_pct, Some(23.0));
assert_eq!(s.mem_used_bytes, Some(201_326_592));
assert_eq!(s.temp_c, Some(58.0)); assert_eq!(s.power_mw, Some(14_237));
assert_eq!(s.fan_pct, None); assert_eq!(s.sm_clock_mhz, Some(1900));
assert_eq!(s.mem_clock_mhz, Some(1334));
assert_eq!(
s.throttle, None,
"v3_0 per-sample throttle must be unobservable"
);
}
#[test]
fn strixpoint_blob_pins_v3_0_kernel_offsets_and_pad_sentinel() {
let blob = std::fs::read(fixture(
"amd-strixpoint-kernel6.10/sys/class/drm/card0/device/gpu_metrics",
))
.unwrap();
let u32_at = |off: usize| u32::from_le_bytes(blob[off..off + 4].try_into().unwrap());
assert_eq!(blob.len(), 264); assert_eq!(u16::from_le_bytes([blob[0], blob[1]]), 264);
assert_eq!((blob[2], blob[3]), (3, 0));
assert_eq!(u16::from_le_bytes([blob[224], blob[225]]), 2900); assert_eq!(blob[226..228], [0xFF, 0xFF]);
assert_eq!(u32_at(228), 37); assert_eq!(u32_at(232), 9); assert_eq!(u32_at(236), 0); assert_eq!(u32_at(240), 4); assert_eq!(u32_at(244), 0); assert_eq!(u32_at(248), 12); assert_eq!(u32_at(252), 3); assert_eq!(decode_gpu_metrics_throttle(&blob), None);
}
#[test]
fn vangogh_steamdeck_v2_4_decodes_at_sizeof_168_regression() {
let mut b = AmdBackend::with_root(fixture("amd-vangogh-steamdeck-kernel6.8")).unwrap();
let devs = b.devices();
assert_eq!(devs, vec![DeviceId("0000:04:00.0".into())]);
let info = b.static_info(&devs[0]).unwrap();
assert_eq!(info.name, "AMD GPU [1002:163f]");
assert_eq!(info.mem_total_bytes, Some(1_073_741_824));
assert_eq!(info.power_limit_mw, None);
assert_eq!(info.max_sm_clock_mhz, Some(1600));
let s = b.refresh_dynamic(&devs[0]).unwrap();
assert_eq!(s.util_pct, Some(88.0));
assert_eq!(s.temp_c, Some(67.0));
assert_eq!(s.power_mw, Some(8_123));
assert_eq!(s.fan_pct, None);
assert_eq!(s.sm_clock_mhz, Some(700));
assert_eq!(s.mem_clock_mhz, None); let t = s
.throttle
.expect("a real 168-byte v2_4 blob must decode (164→168 gate fix)");
assert!(t.power_cap, "SPPT_APU (bit 7) decodes to power_cap");
assert!(
!t.thermal && !t.hw_slowdown && !t.sync_boost && !t.other,
"legacy 0x40 decoy, 0xFF'd fan_pwm/padding, and 0xFF tail pad must all be ignored"
);
}
#[test]
fn vangogh_blob_pins_v2_4_size_and_word_offsets() {
let blob = std::fs::read(fixture(
"amd-vangogh-steamdeck-kernel6.8/sys/class/drm/card0/device/gpu_metrics",
))
.unwrap();
assert_eq!(blob.len(), 168);
assert_eq!(
u16::from_le_bytes([blob[0], blob[1]]),
168,
"structure_size = sizeof (168), never the 164 data length"
);
assert_eq!((blob[2], blob[3]), (2, 4));
assert_eq!(
u32::from_le_bytes(blob[108..112].try_into().unwrap()),
0x40,
"legacy throttle_status decoy"
);
assert_eq!(blob[112..120], [0xFF; 8]);
assert_eq!(
u64::from_le_bytes(blob[120..128].try_into().unwrap()),
1 << 7, );
assert_eq!(blob[164..168], [0xFF; 4], "kernel-true 0xFF tail pad");
let mut short = blob.clone();
short.truncate(164);
short[0..2].copy_from_slice(&164u16.to_le_bytes());
assert_eq!(decode_gpu_metrics_throttle(&short), None);
}
#[test]
fn vangogh_fdinfo_game_and_media_pids() {
let mut b = AmdBackend::with_root(fixture("amd-vangogh-steamdeck-kernel6.8")).unwrap();
let dev = b.devices().remove(0);
let procs = b.refresh_processes(&dev).unwrap();
assert_eq!(procs.len(), 2);
let game = &procs[0];
assert_eq!((game.pid, game.name.as_str()), (1337, "portal2"));
assert_eq!(game.kind, ProcessKind::Graphics);
assert_eq!(game.mem_bytes, Some(268_435_456));
assert_eq!(game.util_pct, None);
let media = &procs[1];
assert_eq!((media.pid, media.name.as_str()), (2001, "ffmpeg"));
assert_eq!(media.util_pct, None);
assert_eq!(media.mem_bytes, None);
assert_eq!(media.kind, ProcessKind::Graphics); }
#[test]
fn cyanskillfish_v2_2_ff_sentinel_is_quiet_never_active_throttle() {
let mut b = AmdBackend::with_root(fixture("amd-cyanskillfish-bc250-kernel6.8")).unwrap();
let devs = b.devices();
assert_eq!(devs, vec![DeviceId("0000:05:00.0".into())]);
let s = b.refresh_dynamic(&devs[0]).unwrap();
let t = s
.throttle
.expect("legacy word stays observable behind the indep sentinel");
assert!(
!t.any(),
"the 0xFF sentinel must never surface as an active throttle"
);
assert_eq!(t, ThrottleReasons::default());
assert_eq!(s.util_pct, Some(41.0));
assert_eq!(s.temp_c, Some(54.0));
assert_eq!(s.power_mw, Some(95_000));
assert_eq!(s.sm_clock_mhz, None); assert_eq!(s.mem_clock_mhz, None);
assert_eq!(s.fan_pct, None);
}
#[test]
fn cyanskillfish_blob_pins_v2_2_sentinel_bytes() {
let blob = std::fs::read(fixture(
"amd-cyanskillfish-bc250-kernel6.8/sys/class/drm/card0/device/gpu_metrics",
))
.unwrap();
assert_eq!(blob.len(), 128); assert_eq!(u16::from_le_bytes([blob[0], blob[1]]), 128);
assert_eq!((blob[2], blob[3]), (2, 2));
assert_eq!(
u32::from_le_bytes(blob[108..112].try_into().unwrap()),
0,
"legacy throttle_status: an observed quiet"
);
assert_eq!(
blob[120..128],
[0xFF; 8],
"indep_throttle_status never written — the memset sentinel"
);
assert_eq!(
decode_gpu_metrics_throttle(&blob),
Some(ThrottleReasons::default())
);
}
struct KernelLayout {
name: &'static str,
format: u8,
content: u8,
size: usize,
legacy: Option<usize>,
indep: Option<usize>,
}
#[rustfmt::skip]
const KERNEL_LAYOUTS: &[KernelLayout] = &[
KernelLayout { name: "v1_1", format: 1, content: 1, size: 96, legacy: Some(68), indep: None },
KernelLayout { name: "v1_2", format: 1, content: 2, size: 104, legacy: Some(68), indep: None },
KernelLayout { name: "v1_3", format: 1, content: 3, size: 120, legacy: Some(68), indep: Some(112) },
KernelLayout { name: "v2_0", format: 2, content: 0, size: 120, legacy: Some(112), indep: None },
KernelLayout { name: "v2_1", format: 2, content: 1, size: 120, legacy: Some(108), indep: None },
KernelLayout { name: "v2_2", format: 2, content: 2, size: 128, legacy: Some(108), indep: Some(120) },
KernelLayout { name: "v2_3", format: 2, content: 3, size: 152, legacy: Some(108), indep: Some(120) },
KernelLayout { name: "v2_4", format: 2, content: 4, size: 168, legacy: Some(108), indep: Some(120) },
];
fn build_at_literals(
l: &KernelLayout,
fill: u8,
legacy: Option<u32>,
indep: Option<u64>,
) -> Vec<u8> {
let mut b = vec![fill; l.size];
b[0..2].copy_from_slice(&(l.size as u16).to_le_bytes());
b[2] = l.format;
b[3] = l.content;
if let (Some(off), Some(v)) = (l.legacy, legacy) {
b[off..off + 4].copy_from_slice(&v.to_le_bytes());
}
if let (Some(off), Some(v)) = (l.indep, indep) {
b[off..off + 8].copy_from_slice(&v.to_le_bytes());
}
b
}
#[test]
fn every_version_indep_word_is_read_at_the_kernel_offset() {
for l in KERNEL_LAYOUTS.iter().filter(|l| l.indep.is_some()) {
let b = build_at_literals(l, 0xAB, None, Some(1 << 36));
let t = decode_gpu_metrics_throttle(&b)
.unwrap_or_else(|| panic!("{}: a well-formed blob must decode", l.name));
assert_eq!(
t,
ThrottleReasons {
thermal: true,
..Default::default()
},
"{}: indep must be read at exactly @{} and preferred over the legacy word",
l.name,
l.indep.unwrap()
);
}
}
#[test]
fn every_version_legacy_word_is_read_at_the_kernel_offset() {
for l in KERNEL_LAYOUTS.iter().filter(|l| l.legacy.is_some()) {
let b = build_at_literals(l, 0x00, Some(0x2), Some(u64::MAX));
let t = decode_gpu_metrics_throttle(&b)
.unwrap_or_else(|| panic!("{}: a well-formed blob must decode", l.name));
assert_eq!(
t,
ThrottleReasons {
other: true,
..Default::default()
},
"{}: nonzero legacy @{} is a coarse `other`, never a specific cause",
l.name,
l.legacy.unwrap()
);
let b = build_at_literals(l, 0xAB, Some(0), Some(u64::MAX));
assert_eq!(
decode_gpu_metrics_throttle(&b),
Some(ThrottleReasons::default()),
"{}: zero legacy @{} is an observed quiet",
l.name,
l.legacy.unwrap()
);
}
}
#[test]
fn every_version_sentinel_words_decode_to_absence_never_throttle() {
for l in KERNEL_LAYOUTS {
match (l.indep, l.legacy) {
(Some(_), Some(_)) => {
let b = build_at_literals(l, 0x00, Some(0), Some(u64::MAX));
assert_eq!(
decode_gpu_metrics_throttle(&b),
Some(ThrottleReasons::default()),
"{}: indep 0xFF sentinel must fall through to the legacy word",
l.name
);
let b = build_at_literals(l, 0x00, Some(u32::MAX), Some(u64::MAX));
assert_eq!(
decode_gpu_metrics_throttle(&b),
None,
"{}: all-sentinel words are unobservable, never Some(anything)",
l.name
);
}
(None, Some(_)) => {
let b = build_at_literals(l, 0x00, Some(u32::MAX), None);
assert_eq!(
decode_gpu_metrics_throttle(&b),
None,
"{}: a 0xFF'd legacy word means unsupported, not throttling hard",
l.name
);
}
_ => unreachable!("every supported layout carries a legacy word"),
}
}
}
#[test]
fn every_version_size_gate_matches_the_kernel_sizeof_exactly() {
for l in KERNEL_LAYOUTS {
let ok = build_at_literals(l, 0x00, Some(0), Some(0));
assert_eq!(
decode_gpu_metrics_throttle(&ok),
Some(ThrottleReasons::default()),
"{}: a blob at the kernel sizeof {} must decode",
l.name,
l.size
);
let mut short = build_at_literals(l, 0x00, Some(0), Some(0));
short.truncate(l.size - 4);
short[0..2].copy_from_slice(&((l.size - 4) as u16).to_le_bytes());
assert_eq!(
decode_gpu_metrics_throttle(&short),
None,
"{}: structure_size {} (sizeof − 4) must be rejected",
l.name,
l.size - 4
);
let mut long = build_at_literals(l, 0x00, Some(0), Some(0));
long.extend_from_slice(&[0u8; 8]);
long[0..2].copy_from_slice(&((l.size + 8) as u16).to_le_bytes());
assert_eq!(
decode_gpu_metrics_throttle(&long),
None,
"{}: structure_size {} (sizeof + 8) must be rejected",
l.name,
l.size + 8
);
}
}
#[test]
fn no_amd_devices_is_a_clean_unavailable() {
match AmdBackend::with_root(fixture("")) {
Err(e) => assert!(matches!(e, BackendError::Unavailable(_))),
Ok(_) => panic!("expected Unavailable for a tree without sys/class/drm"),
}
}
fn copy_tree(src: &Path, dst: &Path) {
std::fs::create_dir_all(dst).unwrap();
for entry in std::fs::read_dir(src).unwrap().flatten() {
let to = dst.join(entry.file_name());
if entry.path().is_dir() {
copy_tree(&entry.path(), &to);
} else {
std::fs::copy(entry.path(), &to).unwrap();
}
}
}