Skip to main content

retch_sysinfo/
gpu.rs

1// SPDX-FileCopyrightText: 2026 Ken Tobias
2// SPDX-License-Identifier: GPL-3.0-or-later
3
4//! GPU detection and identification.
5//!
6//! Handles parsing PCI IDs and querying the system for graphics card
7//! vendor and model information.
8
9#[cfg(not(any(target_os = "macos", target_os = "windows")))]
10use std::collections::HashSet;
11use std::fs;
12
13/// Information about a detected GPU.
14#[derive(Debug, Clone, Default)]
15pub struct GpuInfo {
16    /// The marketing or model name of the GPU.
17    pub name: String,
18    /// Total VRAM in bytes, if detected.
19    pub vram_bytes: Option<u64>,
20}
21
22impl GpuInfo {
23    /// Formats the GPU name and VRAM into a human-readable string.
24    pub fn format(&self) -> String {
25        if let Some(vram) = self.vram_bytes {
26            let vram_gb = vram as f64 / 1024.0 / 1024.0 / 1024.0;
27            if vram_gb >= 1.0 {
28                format!("{} ({:.0} GB)", self.name, vram_gb)
29            } else {
30                let vram_mb = vram / 1024 / 1024;
31                format!("{} ({} MB)", self.name, vram_mb)
32            }
33        } else {
34            self.name.clone()
35        }
36    }
37}
38
39/// Refines AMD GPU names by mapping codenames to marketing names.
40///
41/// Fallback only: on Linux, [`lookup_amdgpu_ids`] is consulted first — it resolves the
42/// exact marketing name from the device *and revision*, which this table cannot (e.g.
43/// Strix Halo `1586` is an 8040S, 8050S, or 8060S depending on revision).
44///
45/// Matching is first-substring-wins, so more specific codenames MUST come before their
46/// prefixes: "Strix Halo" before "Strix" (a `1586` "Strix Halo [...]" pci.ids name once
47/// matched the "Strix" entry and was mislabeled as the Strix Point 880M/890M).
48pub fn improve_amd_gpu_name(name: &str) -> String {
49    let codenames = [
50        ("Phoenix1", "Radeon 780M"),
51        ("Phoenix2", "Radeon 740M / 760M"),
52        ("Renoir", "Radeon Graphics (Renoir)"),
53        ("Lucienne", "Radeon Graphics (Lucienne)"),
54        ("Cezanne", "Radeon Graphics (Cezanne)"),
55        ("Barcelo", "Radeon Graphics (Barcelo)"),
56        ("Rembrandt", "Radeon 680M"),
57        ("Raphael", "Radeon Graphics (Raphael)"),
58        ("Mendocino", "Radeon 610M"),
59        ("Strix Halo", "Radeon 8050S / 8060S"),
60        ("Strix", "Radeon 880M / 890M"),
61        ("Krackan", "Radeon 840M / 860M"),
62    ];
63
64    for (codename, marketing) in codenames {
65        if name.contains(codename) {
66            return marketing.to_string();
67        }
68    }
69
70    name.to_string()
71}
72
73/// Looks up an AMD GPU's exact marketing name in libdrm's `amdgpu.ids` database content.
74///
75/// The database is keyed by *(device id, revision id)*, which is what disambiguates
76/// same-device variants that pci.ids lumps together (Strix Halo `1586` rev `C1` is the
77/// "AMD Radeon 8060S Graphics", rev `C2` the 8050S, rev `D5` the 8040S). This mirrors how
78/// fastfetch resolves AMD names on Linux.
79///
80/// Format (after `#` comments and a bare version line): `device_id,\trevision_id,\tname`.
81/// IDs are bare uppercase hex; sysfs-style `0x`-prefixed lowercase input is accepted.
82/// Malformed lines are skipped. The name is the remainder after the second comma, so
83/// commas in product names are safe.
84pub fn lookup_amdgpu_ids_in(content: &str, device_id: &str, revision_id: &str) -> Option<String> {
85    let device_id = device_id.trim().trim_start_matches("0x");
86    let revision_id = revision_id.trim().trim_start_matches("0x");
87
88    for line in content.lines() {
89        let line = line.trim();
90        if line.is_empty() || line.starts_with('#') {
91            continue;
92        }
93        let mut parts = line.splitn(3, ',');
94        let (Some(dev), Some(rev), Some(name)) = (parts.next(), parts.next(), parts.next()) else {
95            continue; // version line or malformed
96        };
97        if dev.trim().eq_ignore_ascii_case(device_id)
98            && rev.trim().eq_ignore_ascii_case(revision_id)
99        {
100            let name = name.trim();
101            if !name.is_empty() {
102                return Some(name.to_string());
103            }
104        }
105    }
106    None
107}
108
109/// Reads libdrm's `amdgpu.ids` and resolves the exact AMD marketing name, if present.
110#[cfg(not(any(target_os = "macos", target_os = "windows")))]
111fn lookup_amdgpu_ids(device_id: &str, revision_id: &str) -> Option<String> {
112    let content = fs::read_to_string("/usr/share/libdrm/amdgpu.ids").ok()?;
113    lookup_amdgpu_ids_in(&content, device_id, revision_id)
114}
115
116/// Helper to lookup PCI device name in standard system pci.ids files.
117pub fn lookup_pci_device(vendor_id: &str, device_id: &str) -> Option<String> {
118    let vendor_id = vendor_id.trim_start_matches("0x").to_lowercase();
119    let device_id = device_id.trim_start_matches("0x").to_lowercase();
120
121    let paths = ["/usr/share/hwdata/pci.ids", "/usr/share/misc/pci.ids"];
122
123    for path in &paths {
124        if let Ok(content) = fs::read_to_string(path) {
125            let mut in_vendor = false;
126            for line in content.lines() {
127                if line.starts_with('#') || line.is_empty() {
128                    continue;
129                }
130
131                if !line.starts_with('\t') {
132                    // Vendor line: "vendor_id  Vendor Name"
133                    in_vendor = line.starts_with(&vendor_id);
134                } else if in_vendor && line.starts_with('\t') && !line.starts_with("\t\t") {
135                    // Device line: "\tdevice_id  Device Name"
136                    let trimmed = line.trim_start();
137                    if let Some(stripped) = trimmed.strip_prefix(&device_id) {
138                        let name = stripped.trim();
139                        return Some(name.to_string());
140                    }
141                }
142            }
143        }
144    }
145    None
146}
147
148/// Parses VRAM string (e.g. "8 GB", "1536 MB") into bytes.
149pub fn parse_vram_str(s: &str) -> Option<u64> {
150    let parts: Vec<&str> = s.split_whitespace().collect();
151    if parts.len() >= 2 {
152        if let Ok(val) = parts[0].parse::<f64>() {
153            let unit = parts[1].to_uppercase();
154            if unit.starts_with("GB") {
155                return Some((val * 1024.0 * 1024.0 * 1024.0) as u64);
156            } else if unit.starts_with("MB") {
157                return Some((val * 1024.0 * 1024.0) as u64);
158            } else if unit.starts_with("KB") {
159                return Some((val * 1024.0) as u64);
160            }
161        }
162    }
163    None
164}
165
166/// Parses the output of `system_profiler SPDisplaysDataType` on macOS.
167pub fn parse_system_profiler_displays(stdout: &str) -> Vec<GpuInfo> {
168    let mut gpus = Vec::new();
169    let mut current_gpu = None;
170    for line in stdout.lines() {
171        let trimmed = line.trim();
172        if let Some(stripped) = trimmed.strip_prefix("Chipset Model:") {
173            if let Some(gpu) = current_gpu.take() {
174                gpus.push(gpu);
175            }
176            let name = stripped.trim().to_string();
177            current_gpu = Some(GpuInfo {
178                name,
179                vram_bytes: None,
180            });
181        } else if let Some(stripped) = trimmed.strip_prefix("VRAM (Total):") {
182            if let Some(ref mut gpu) = current_gpu {
183                let vram_str = stripped.trim();
184                gpu.vram_bytes = parse_vram_str(vram_str);
185            }
186        } else if let Some(stripped) = trimmed.strip_prefix("VRAM (Dynamic, Max):") {
187            if let Some(ref mut gpu) = current_gpu {
188                let vram_str = stripped.trim();
189                gpu.vram_bytes = parse_vram_str(vram_str);
190            }
191        } else if trimmed.starts_with("Displays:") {
192            if let Some(gpu) = current_gpu.take() {
193                gpus.push(gpu);
194            }
195        }
196    }
197    if let Some(gpu) = current_gpu {
198        gpus.push(gpu);
199    }
200    gpus
201}
202
203/// Parses the output of `wmic path win32_VideoController` or PowerShell CIM query for GPU information.
204pub fn parse_wmi_videocontroller(output: &str) -> Vec<GpuInfo> {
205    let mut gpus = Vec::new();
206    let mut name = String::new();
207    let mut vram = None;
208
209    for line in output.lines() {
210        let trimmed = line.trim();
211        let parts: Vec<&str> = if trimmed.contains('=') {
212            trimmed.splitn(2, '=').collect()
213        } else if trimmed.contains(':') {
214            trimmed.splitn(2, ':').collect()
215        } else {
216            continue;
217        };
218
219        if parts.len() == 2 {
220            let key = parts[0].trim().to_lowercase();
221            let val = parts[1].trim();
222
223            if key == "name" {
224                if !name.is_empty() {
225                    gpus.push(GpuInfo {
226                        name: name.clone(),
227                        vram_bytes: vram,
228                    });
229                    vram = None;
230                }
231                name = val.to_string();
232            } else if key == "adapterram" {
233                if vram.is_some() && !name.is_empty() {
234                    gpus.push(GpuInfo {
235                        name: name.clone(),
236                        vram_bytes: vram,
237                    });
238                    name = String::new();
239                    vram = None;
240                }
241                if let Ok(bytes) = val.parse::<u64>() {
242                    vram = Some(bytes);
243                }
244            }
245        }
246    }
247
248    if !name.is_empty() {
249        gpus.push(GpuInfo {
250            name,
251            vram_bytes: vram,
252        });
253    }
254
255    gpus
256}
257
258/// Detects GPUs using system APIs or profiles.
259pub fn detect_gpus() -> Vec<GpuInfo> {
260    #[cfg(target_os = "macos")]
261    {
262        crate::macos_ffi::get_gpus()
263            .into_iter()
264            .map(|(name, vram_bytes)| GpuInfo { name, vram_bytes })
265            .collect()
266    }
267
268    #[cfg(target_os = "windows")]
269    {
270        // Read GPU info from the display adapter device class in the registry.
271        // HKLM\SYSTEM\CurrentControlSet\Control\Class\{4d36e968-e325-11ce-bfc1-08002be10318}\<NNNN>
272        // Each numbered subkey represents one adapter.
273        use crate::win_reg;
274        const ADAPTER_CLASS: &str =
275            "SYSTEM\\CurrentControlSet\\Control\\Class\\{4d36e968-e325-11ce-bfc1-08002be10318}";
276
277        let mut gpus = Vec::new();
278        for subkey_name in win_reg::enum_reg_subkeys(win_reg::HKEY_LOCAL_MACHINE, ADAPTER_CLASS) {
279            // Skip the "Properties" subkey and any non-numeric entries
280            if subkey_name.eq_ignore_ascii_case("Properties") {
281                continue;
282            }
283            let subkey = format!("{}\\{}", ADAPTER_CLASS, subkey_name);
284            let name = win_reg::get_reg_string(win_reg::HKEY_LOCAL_MACHINE, &subkey, "DriverDesc")
285                .unwrap_or_default();
286            if name.is_empty() {
287                continue;
288            }
289            // VRAM is stored as REG_BINARY (little-endian u64) in HardwareInformation.MemorySize
290            let vram_bytes = win_reg::get_reg_binary(
291                win_reg::HKEY_LOCAL_MACHINE,
292                &subkey,
293                "HardwareInformation.MemorySize",
294            )
295            .and_then(|b| {
296                if b.len() >= 8 {
297                    Some(u64::from_le_bytes(b[..8].try_into().ok()?))
298                } else if b.len() >= 4 {
299                    Some(u32::from_le_bytes(b[..4].try_into().ok()?) as u64)
300                } else {
301                    None
302                }
303            })
304            .filter(|&v| v > 0);
305            gpus.push(GpuInfo {
306                name: name.clone(),
307                vram_bytes,
308            });
309        }
310
311        if gpus.is_empty() {
312            eprintln!("warning: GPU detection failed on Windows (registry adapter class returned no results)");
313        }
314
315        gpus
316    }
317
318    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
319    {
320        let mut gpus = Vec::new();
321        let mut seen_devices = HashSet::new();
322
323        // Scan /sys/class/drm for all card* and renderD* entries
324        if let Ok(entries) = std::fs::read_dir("/sys/class/drm") {
325            for entry in entries.flatten() {
326                let name = entry.file_name().into_string().unwrap_or_default();
327                if !name.starts_with("card") && !name.starts_with("renderD") {
328                    continue;
329                }
330
331                let device_path = entry.path().join("device");
332                if let Ok(real_path) = std::fs::canonicalize(&device_path) {
333                    if !seen_devices.insert(real_path) {
334                        continue;
335                    }
336
337                    // Try to identify vendor and model
338                    let vendor_id = fs::read_to_string(device_path.join("vendor"))
339                        .unwrap_or_default()
340                        .trim()
341                        .to_string();
342                    let device_id = fs::read_to_string(device_path.join("device"))
343                        .unwrap_or_default()
344                        .trim()
345                        .to_string();
346
347                    if vendor_id.is_empty() || device_id.is_empty() {
348                        continue;
349                    }
350
351                    // AMD: prefer libdrm's amdgpu.ids, keyed by device + revision — the
352                    // only source that separates same-device variants (8040S/8050S/8060S).
353                    // pci.ids + the codename table remain the fallback.
354                    let amdgpu_ids_name = if vendor_id.contains("1002") {
355                        fs::read_to_string(device_path.join("revision"))
356                            .ok()
357                            .and_then(|rev| lookup_amdgpu_ids(&device_id, rev.trim()))
358                    } else {
359                        None
360                    };
361
362                    let mut gpu_name = amdgpu_ids_name.unwrap_or_else(|| {
363                        let mut name =
364                            lookup_pci_device(&vendor_id, &device_id).unwrap_or_else(|| {
365                                if vendor_id.contains("10de") {
366                                    "NVIDIA GPU".to_string()
367                                } else if vendor_id.contains("1002") {
368                                    "AMD GPU".to_string()
369                                } else if vendor_id.contains("8086") {
370                                    "Intel GPU".to_string()
371                                } else {
372                                    "Unknown GPU".to_string()
373                                }
374                            });
375                        // Refine AMD GPU names (codename table fallback)
376                        if vendor_id.contains("1002") {
377                            name = improve_amd_gpu_name(&name);
378                        }
379                        name
380                    });
381
382                    // NVIDIA special case: try /proc for even better name
383                    if vendor_id.contains("10de") {
384                        if let Ok(pci_slot_path) = fs::read_link(&device_path) {
385                            if let Some(slot_name) = pci_slot_path.file_name() {
386                                let proc_info_path = format!(
387                                    "/proc/driver/nvidia/gpus/{}/information",
388                                    slot_name.to_string_lossy()
389                                );
390                                if let Ok(info) = fs::read_to_string(proc_info_path) {
391                                    for line in info.lines() {
392                                        if line.starts_with("Model:") {
393                                            gpu_name =
394                                                line.replace("Model:", "").trim().to_string();
395                                            break;
396                                        }
397                                    }
398                                }
399                            }
400                        }
401                    }
402
403                    let mut vram_bytes = None;
404                    // Try to get VRAM from common sysfs locations (mainly AMD)
405                    let vram_path = device_path.join("mem_info_vram_total");
406                    if let Ok(vram_str) = fs::read_to_string(vram_path) {
407                        if let Ok(v) = vram_str.trim().parse::<u64>() {
408                            vram_bytes = Some(v);
409                        }
410                    }
411
412                    gpus.push(GpuInfo {
413                        name: gpu_name,
414                        vram_bytes,
415                    });
416                }
417            }
418        }
419
420        if gpus.is_empty() {
421            // Fallback for non-standard setups or if /sys/class/drm is empty
422            if let Ok(model) = fs::read_to_string("/sys/class/drm/card0/device/model") {
423                let model = model.trim();
424                if !model.is_empty() {
425                    gpus.push(GpuInfo {
426                        name: model.to_string(),
427                        vram_bytes: None,
428                    });
429                }
430            }
431        }
432
433        gpus
434    }
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440
441    #[test]
442    fn test_gpu_info_format() {
443        let info = GpuInfo {
444            name: "NVIDIA GeForce RTX 4090".to_string(),
445            vram_bytes: Some(24 * 1024 * 1024 * 1024),
446        };
447        assert_eq!(info.format(), "NVIDIA GeForce RTX 4090 (24 GB)");
448
449        let info = GpuInfo {
450            name: "Intel Arc A770".to_string(),
451            vram_bytes: Some(16 * 1024 * 1024 * 1024),
452        };
453        assert_eq!(info.format(), "Intel Arc A770 (16 GB)");
454
455        let info = GpuInfo {
456            name: "Radeon 780M".to_string(),
457            vram_bytes: Some(512 * 1024 * 1024),
458        };
459        assert_eq!(info.format(), "Radeon 780M (512 MB)");
460
461        let info = GpuInfo {
462            name: "Generic GPU".to_string(),
463            vram_bytes: None,
464        };
465        assert_eq!(info.format(), "Generic GPU");
466    }
467
468    #[test]
469    fn test_improve_amd_gpu_name() {
470        assert_eq!(
471            improve_amd_gpu_name("AMD Radeon Phoenix1 Graphics"),
472            "Radeon 780M"
473        );
474        assert_eq!(improve_amd_gpu_name("AMD Rembrandt"), "Radeon 680M");
475        assert_eq!(improve_amd_gpu_name("Unknown GPU"), "Unknown GPU");
476    }
477
478    #[test]
479    fn test_improve_amd_gpu_name_strix_halo_before_strix() {
480        // Regression: "Strix Halo" (8050S/8060S) must not be swallowed by the "Strix"
481        // (Strix Point, 880M/890M) substring — first-substring-wins makes order load-bearing.
482        assert_eq!(
483            improve_amd_gpu_name(
484                "Strix Halo [Radeon Graphics / Radeon 8050S Graphics / Radeon 8060S Graphics]"
485            ),
486            "Radeon 8050S / 8060S"
487        );
488        assert_eq!(
489            improve_amd_gpu_name("Strix [Radeon 880M / 890M]"),
490            "Radeon 880M / 890M"
491        );
492        assert_eq!(
493            improve_amd_gpu_name("Krackan [Radeon 840M / 860M Graphics]"),
494            "Radeon 840M / 860M"
495        );
496    }
497
498    // Shape of /usr/share/libdrm/amdgpu.ids: comments, a bare version line, then
499    // "device_id,\trevision_id,\tname" rows (bare uppercase hex ids).
500    const AMDGPU_IDS_FIXTURE: &str = "\
501# List of AMDGPU IDs
502#
503# Syntax:
504# device_id,\trevision_id,\tproduct_name        <-- single tab after comma
505
5061.0.0
5071114,\tC2,\tAMD Radeon 860M Graphics
5081586,\tC1,\tAMD Radeon 8060S Graphics
5091586,\tC2,\tAMD Radeon 8050S Graphics
5101586,\tD5,\tAMD Radeon 8040S Graphics
51115DD,\tC3,\tAMD Radeon(TM) Vega 8 Graphics
512garbage line without commas
513731F,\tC1,\tAMD Radeon RX 5700 XT
514";
515
516    #[test]
517    fn test_lookup_amdgpu_ids_revision_disambiguates() {
518        // The same device id resolves to different products by revision — the reason this
519        // source is preferred over pci.ids (which lumps all 1586 variants together).
520        assert_eq!(
521            lookup_amdgpu_ids_in(AMDGPU_IDS_FIXTURE, "1586", "C1"),
522            Some("AMD Radeon 8060S Graphics".to_string())
523        );
524        assert_eq!(
525            lookup_amdgpu_ids_in(AMDGPU_IDS_FIXTURE, "1586", "C2"),
526            Some("AMD Radeon 8050S Graphics".to_string())
527        );
528        assert_eq!(
529            lookup_amdgpu_ids_in(AMDGPU_IDS_FIXTURE, "1586", "D5"),
530            Some("AMD Radeon 8040S Graphics".to_string())
531        );
532    }
533
534    #[test]
535    fn test_lookup_amdgpu_ids_accepts_sysfs_style_ids() {
536        // sysfs reports "0x1586" / "0xc1"; the database stores "1586" / "C1".
537        assert_eq!(
538            lookup_amdgpu_ids_in(AMDGPU_IDS_FIXTURE, "0x1586", "0xc1"),
539            Some("AMD Radeon 8060S Graphics".to_string())
540        );
541    }
542
543    #[test]
544    fn test_lookup_amdgpu_ids_no_match_and_junk_lines() {
545        // Unknown device or revision → None (caller falls back to pci.ids); comments,
546        // the version line, and malformed rows must not match or panic.
547        assert_eq!(lookup_amdgpu_ids_in(AMDGPU_IDS_FIXTURE, "9999", "C1"), None);
548        assert_eq!(lookup_amdgpu_ids_in(AMDGPU_IDS_FIXTURE, "1586", "FF"), None);
549        assert_eq!(lookup_amdgpu_ids_in("", "1586", "C1"), None);
550    }
551
552    #[test]
553    fn test_parse_vram_str() {
554        assert_eq!(parse_vram_str("8 GB"), Some(8 * 1024 * 1024 * 1024));
555        assert_eq!(parse_vram_str("1536 MB"), Some(1536 * 1024 * 1024));
556        assert_eq!(parse_vram_str("512 MB"), Some(512 * 1024 * 1024));
557        assert_eq!(parse_vram_str("1024 KB"), Some(1024 * 1024));
558        assert_eq!(parse_vram_str("invalid"), None);
559        assert_eq!(parse_vram_str("8"), None);
560    }
561
562    #[test]
563    fn test_parse_system_profiler_displays() {
564        let mock_output = r#"
565Graphics/Displays:
566
567    Apple M1 Max:
568
569      Chipset Model: Apple M1 Max
570      Type: GPU
571      Bus: Built-In
572      Total Number of Cores: 32
573      Vendor: Apple (0x106b)
574      Metal Support: Metal 3
575      VRAM (Dynamic, Max): 8192 MB
576      Displays:
577        Color LCD:
578          Display Type: Built-In Retina LCD
579
580    Intel UHD Graphics 630:
581
582      Chipset Model: Intel UHD Graphics 630
583      Type: GPU
584      Bus: Built-In
585      VRAM (Total): 1536 MB
586      Vendor: Intel (0x8086)
587"#;
588        let gpus = parse_system_profiler_displays(mock_output);
589        assert_eq!(gpus.len(), 2);
590        assert_eq!(gpus[0].name, "Apple M1 Max");
591        assert_eq!(gpus[0].vram_bytes, Some(8192 * 1024 * 1024));
592        assert_eq!(gpus[1].name, "Intel UHD Graphics 630");
593        assert_eq!(gpus[1].vram_bytes, Some(1536 * 1024 * 1024));
594    }
595
596    #[test]
597    fn test_parse_wmi_videocontroller() {
598        let wmic_output = r#"
599AdapterRAM=4294967296
600Name=NVIDIA GeForce RTX 4090
601
602AdapterRAM=2147483648
603Name=Intel(R) UHD Graphics 770
604"#;
605        let gpus = parse_wmi_videocontroller(wmic_output);
606        assert_eq!(gpus.len(), 2);
607        assert_eq!(gpus[0].name, "NVIDIA GeForce RTX 4090");
608        assert_eq!(gpus[0].vram_bytes, Some(4294967296));
609        assert_eq!(gpus[1].name, "Intel(R) UHD Graphics 770");
610        assert_eq!(gpus[1].vram_bytes, Some(2147483648));
611
612        let powershell_output = r#"
613Name       : NVIDIA GeForce RTX 4090
614AdapterRAM : 4294967296
615
616Name       : Intel(R) UHD Graphics 770
617AdapterRAM : 2147483648
618"#;
619        let gpus = parse_wmi_videocontroller(powershell_output);
620        assert_eq!(gpus.len(), 2);
621        assert_eq!(gpus[0].name, "NVIDIA GeForce RTX 4090");
622        assert_eq!(gpus[0].vram_bytes, Some(4294967296));
623        assert_eq!(gpus[1].name, "Intel(R) UHD Graphics 770");
624        assert_eq!(gpus[1].vram_bytes, Some(2147483648));
625    }
626}