Skip to main content

retch_sysinfo/
gpu.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright (C) 2026 l1a
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.
40pub fn improve_amd_gpu_name(name: &str) -> String {
41    let codenames = [
42        ("Phoenix1", "Radeon 780M"),
43        ("Phoenix2", "Radeon 740M / 760M"),
44        ("Renoir", "Radeon Graphics (Renoir)"),
45        ("Lucienne", "Radeon Graphics (Lucienne)"),
46        ("Cezanne", "Radeon Graphics (Cezanne)"),
47        ("Barcelo", "Radeon Graphics (Barcelo)"),
48        ("Rembrandt", "Radeon 680M"),
49        ("Raphael", "Radeon Graphics (Raphael)"),
50        ("Mendocino", "Radeon 610M"),
51        ("Strix", "Radeon 880M / 890M"),
52    ];
53
54    for (codename, marketing) in codenames {
55        if name.contains(codename) {
56            return marketing.to_string();
57        }
58    }
59
60    name.to_string()
61}
62
63/// Helper to lookup PCI device name in standard system pci.ids files.
64pub fn lookup_pci_device(vendor_id: &str, device_id: &str) -> Option<String> {
65    let vendor_id = vendor_id.trim_start_matches("0x").to_lowercase();
66    let device_id = device_id.trim_start_matches("0x").to_lowercase();
67
68    let paths = ["/usr/share/hwdata/pci.ids", "/usr/share/misc/pci.ids"];
69
70    for path in &paths {
71        if let Ok(content) = fs::read_to_string(path) {
72            let mut in_vendor = false;
73            for line in content.lines() {
74                if line.starts_with('#') || line.is_empty() {
75                    continue;
76                }
77
78                if !line.starts_with('\t') {
79                    // Vendor line: "vendor_id  Vendor Name"
80                    in_vendor = line.starts_with(&vendor_id);
81                } else if in_vendor && line.starts_with('\t') && !line.starts_with("\t\t") {
82                    // Device line: "\tdevice_id  Device Name"
83                    let trimmed = line.trim_start();
84                    if let Some(stripped) = trimmed.strip_prefix(&device_id) {
85                        let name = stripped.trim();
86                        return Some(name.to_string());
87                    }
88                }
89            }
90        }
91    }
92    None
93}
94
95/// Parses VRAM string (e.g. "8 GB", "1536 MB") into bytes.
96pub fn parse_vram_str(s: &str) -> Option<u64> {
97    let parts: Vec<&str> = s.split_whitespace().collect();
98    if parts.len() >= 2 {
99        if let Ok(val) = parts[0].parse::<f64>() {
100            let unit = parts[1].to_uppercase();
101            if unit.starts_with("GB") {
102                return Some((val * 1024.0 * 1024.0 * 1024.0) as u64);
103            } else if unit.starts_with("MB") {
104                return Some((val * 1024.0 * 1024.0) as u64);
105            } else if unit.starts_with("KB") {
106                return Some((val * 1024.0) as u64);
107            }
108        }
109    }
110    None
111}
112
113/// Parses the output of `system_profiler SPDisplaysDataType` on macOS.
114pub fn parse_system_profiler_displays(stdout: &str) -> Vec<GpuInfo> {
115    let mut gpus = Vec::new();
116    let mut current_gpu = None;
117    for line in stdout.lines() {
118        let trimmed = line.trim();
119        if let Some(stripped) = trimmed.strip_prefix("Chipset Model:") {
120            if let Some(gpu) = current_gpu.take() {
121                gpus.push(gpu);
122            }
123            let name = stripped.trim().to_string();
124            current_gpu = Some(GpuInfo {
125                name,
126                vram_bytes: None,
127            });
128        } else if let Some(stripped) = trimmed.strip_prefix("VRAM (Total):") {
129            if let Some(ref mut gpu) = current_gpu {
130                let vram_str = stripped.trim();
131                gpu.vram_bytes = parse_vram_str(vram_str);
132            }
133        } else if let Some(stripped) = trimmed.strip_prefix("VRAM (Dynamic, Max):") {
134            if let Some(ref mut gpu) = current_gpu {
135                let vram_str = stripped.trim();
136                gpu.vram_bytes = parse_vram_str(vram_str);
137            }
138        } else if trimmed.starts_with("Displays:") {
139            if let Some(gpu) = current_gpu.take() {
140                gpus.push(gpu);
141            }
142        }
143    }
144    if let Some(gpu) = current_gpu {
145        gpus.push(gpu);
146    }
147    gpus
148}
149
150/// Parses the output of `wmic path win32_VideoController` or PowerShell CIM query for GPU information.
151pub fn parse_wmi_videocontroller(output: &str) -> Vec<GpuInfo> {
152    let mut gpus = Vec::new();
153    let mut name = String::new();
154    let mut vram = None;
155
156    for line in output.lines() {
157        let trimmed = line.trim();
158        let parts: Vec<&str> = if trimmed.contains('=') {
159            trimmed.splitn(2, '=').collect()
160        } else if trimmed.contains(':') {
161            trimmed.splitn(2, ':').collect()
162        } else {
163            continue;
164        };
165
166        if parts.len() == 2 {
167            let key = parts[0].trim().to_lowercase();
168            let val = parts[1].trim();
169
170            if key == "name" {
171                if !name.is_empty() {
172                    gpus.push(GpuInfo {
173                        name: name.clone(),
174                        vram_bytes: vram,
175                    });
176                    vram = None;
177                }
178                name = val.to_string();
179            } else if key == "adapterram" {
180                if vram.is_some() && !name.is_empty() {
181                    gpus.push(GpuInfo {
182                        name: name.clone(),
183                        vram_bytes: vram,
184                    });
185                    name = String::new();
186                    vram = None;
187                }
188                if let Ok(bytes) = val.parse::<u64>() {
189                    vram = Some(bytes);
190                }
191            }
192        }
193    }
194
195    if !name.is_empty() {
196        gpus.push(GpuInfo {
197            name,
198            vram_bytes: vram,
199        });
200    }
201
202    gpus
203}
204
205/// Detects GPUs using system APIs or profiles.
206pub fn detect_gpus() -> Vec<GpuInfo> {
207    #[cfg(target_os = "macos")]
208    {
209        match std::process::Command::new("system_profiler")
210            .arg("SPDisplaysDataType")
211            .output()
212        {
213            Ok(output) => {
214                if let Ok(stdout) = String::from_utf8(output.stdout) {
215                    return parse_system_profiler_displays(&stdout);
216                } else {
217                    eprintln!("warning: system_profiler output was not valid UTF-8");
218                }
219            }
220            Err(e) => {
221                eprintln!("warning: failed to run system_profiler SPDisplaysDataType: {} (GPU detection skipped)", e);
222            }
223        }
224        Vec::new()
225    }
226
227    #[cfg(target_os = "windows")]
228    {
229        // Try WMIC first
230        let mut wmic_ok = false;
231        match std::process::Command::new("wmic")
232            .args([
233                "path",
234                "win32_VideoController",
235                "get",
236                "Name,AdapterRAM",
237                "/value",
238            ])
239            .output()
240        {
241            Ok(output) => {
242                if let Ok(stdout) = String::from_utf8(output.stdout) {
243                    let gpus = parse_wmi_videocontroller(&stdout);
244                    if !gpus.is_empty() {
245                        return gpus;
246                    }
247                    wmic_ok = true;
248                }
249            }
250            Err(e) => {
251                eprintln!("warning: wmic failed: {} (trying PowerShell fallback)", e);
252            }
253        }
254
255        // Fallback to PowerShell
256        if !wmic_ok {
257            match std::process::Command::new("powershell")
258                .args(["-Command", "Get-CimInstance Win32_VideoController | Select-Object Name, AdapterRAM | Format-List"])
259                .output()
260            {
261                Ok(output) => {
262                    if let Ok(stdout) = String::from_utf8(output.stdout) {
263                        return parse_wmi_videocontroller(&stdout);
264                    } else {
265                        eprintln!("warning: powershell output was not valid UTF-8");
266                    }
267                }
268                Err(e) => {
269                    eprintln!("warning: powershell failed: {} (GPU detection skipped)", e);
270                }
271            }
272        }
273
274        Vec::new()
275    }
276
277    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
278    {
279        let mut gpus = Vec::new();
280        let mut seen_devices = HashSet::new();
281
282        // Scan /sys/class/drm for all card* and renderD* entries
283        if let Ok(entries) = std::fs::read_dir("/sys/class/drm") {
284            for entry in entries.flatten() {
285                let name = entry.file_name().into_string().unwrap_or_default();
286                if !name.starts_with("card") && !name.starts_with("renderD") {
287                    continue;
288                }
289
290                let device_path = entry.path().join("device");
291                if let Ok(real_path) = std::fs::canonicalize(&device_path) {
292                    if !seen_devices.insert(real_path) {
293                        continue;
294                    }
295
296                    // Try to identify vendor and model
297                    let vendor_id = fs::read_to_string(device_path.join("vendor"))
298                        .unwrap_or_default()
299                        .trim()
300                        .to_string();
301                    let device_id = fs::read_to_string(device_path.join("device"))
302                        .unwrap_or_default()
303                        .trim()
304                        .to_string();
305
306                    if vendor_id.is_empty() || device_id.is_empty() {
307                        continue;
308                    }
309
310                    let mut gpu_name =
311                        lookup_pci_device(&vendor_id, &device_id).unwrap_or_else(|| {
312                            if vendor_id.contains("10de") {
313                                "NVIDIA GPU".to_string()
314                            } else if vendor_id.contains("1002") {
315                                "AMD GPU".to_string()
316                            } else if vendor_id.contains("8086") {
317                                "Intel GPU".to_string()
318                            } else {
319                                "Unknown GPU".to_string()
320                            }
321                        });
322
323                    // Refine AMD GPU names
324                    if vendor_id.contains("1002") {
325                        gpu_name = improve_amd_gpu_name(&gpu_name);
326                    }
327
328                    // NVIDIA special case: try /proc for even better name
329                    if vendor_id.contains("10de") {
330                        if let Ok(pci_slot_path) = fs::read_link(&device_path) {
331                            if let Some(slot_name) = pci_slot_path.file_name() {
332                                let proc_info_path = format!(
333                                    "/proc/driver/nvidia/gpus/{}/information",
334                                    slot_name.to_string_lossy()
335                                );
336                                if let Ok(info) = fs::read_to_string(proc_info_path) {
337                                    for line in info.lines() {
338                                        if line.starts_with("Model:") {
339                                            gpu_name =
340                                                line.replace("Model:", "").trim().to_string();
341                                            break;
342                                        }
343                                    }
344                                }
345                            }
346                        }
347                    }
348
349                    let mut vram_bytes = None;
350                    // Try to get VRAM from common sysfs locations (mainly AMD)
351                    let vram_path = device_path.join("mem_info_vram_total");
352                    if let Ok(vram_str) = fs::read_to_string(vram_path) {
353                        if let Ok(v) = vram_str.trim().parse::<u64>() {
354                            vram_bytes = Some(v);
355                        }
356                    }
357
358                    gpus.push(GpuInfo {
359                        name: gpu_name,
360                        vram_bytes,
361                    });
362                }
363            }
364        }
365
366        if gpus.is_empty() {
367            // Fallback for non-standard setups or if /sys/class/drm is empty
368            if let Ok(model) = fs::read_to_string("/sys/class/drm/card0/device/model") {
369                let model = model.trim();
370                if !model.is_empty() {
371                    gpus.push(GpuInfo {
372                        name: model.to_string(),
373                        vram_bytes: None,
374                    });
375                }
376            }
377        }
378
379        gpus
380    }
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386
387    #[test]
388    fn test_gpu_info_format() {
389        let info = GpuInfo {
390            name: "NVIDIA GeForce RTX 4090".to_string(),
391            vram_bytes: Some(24 * 1024 * 1024 * 1024),
392        };
393        assert_eq!(info.format(), "NVIDIA GeForce RTX 4090 (24 GB)");
394
395        let info = GpuInfo {
396            name: "Intel Arc A770".to_string(),
397            vram_bytes: Some(16 * 1024 * 1024 * 1024),
398        };
399        assert_eq!(info.format(), "Intel Arc A770 (16 GB)");
400
401        let info = GpuInfo {
402            name: "Radeon 780M".to_string(),
403            vram_bytes: Some(512 * 1024 * 1024),
404        };
405        assert_eq!(info.format(), "Radeon 780M (512 MB)");
406
407        let info = GpuInfo {
408            name: "Generic GPU".to_string(),
409            vram_bytes: None,
410        };
411        assert_eq!(info.format(), "Generic GPU");
412    }
413
414    #[test]
415    fn test_improve_amd_gpu_name() {
416        assert_eq!(
417            improve_amd_gpu_name("AMD Radeon Phoenix1 Graphics"),
418            "Radeon 780M"
419        );
420        assert_eq!(improve_amd_gpu_name("AMD Rembrandt"), "Radeon 680M");
421        assert_eq!(improve_amd_gpu_name("Unknown GPU"), "Unknown GPU");
422    }
423
424    #[test]
425    fn test_parse_vram_str() {
426        assert_eq!(parse_vram_str("8 GB"), Some(8 * 1024 * 1024 * 1024));
427        assert_eq!(parse_vram_str("1536 MB"), Some(1536 * 1024 * 1024));
428        assert_eq!(parse_vram_str("512 MB"), Some(512 * 1024 * 1024));
429        assert_eq!(parse_vram_str("1024 KB"), Some(1024 * 1024));
430        assert_eq!(parse_vram_str("invalid"), None);
431        assert_eq!(parse_vram_str("8"), None);
432    }
433
434    #[test]
435    fn test_parse_system_profiler_displays() {
436        let mock_output = r#"
437Graphics/Displays:
438
439    Apple M1 Max:
440
441      Chipset Model: Apple M1 Max
442      Type: GPU
443      Bus: Built-In
444      Total Number of Cores: 32
445      Vendor: Apple (0x106b)
446      Metal Support: Metal 3
447      VRAM (Dynamic, Max): 8192 MB
448      Displays:
449        Color LCD:
450          Display Type: Built-In Retina LCD
451
452    Intel UHD Graphics 630:
453
454      Chipset Model: Intel UHD Graphics 630
455      Type: GPU
456      Bus: Built-In
457      VRAM (Total): 1536 MB
458      Vendor: Intel (0x8086)
459"#;
460        let gpus = parse_system_profiler_displays(mock_output);
461        assert_eq!(gpus.len(), 2);
462        assert_eq!(gpus[0].name, "Apple M1 Max");
463        assert_eq!(gpus[0].vram_bytes, Some(8192 * 1024 * 1024));
464        assert_eq!(gpus[1].name, "Intel UHD Graphics 630");
465        assert_eq!(gpus[1].vram_bytes, Some(1536 * 1024 * 1024));
466    }
467
468    #[test]
469    fn test_parse_wmi_videocontroller() {
470        let wmic_output = r#"
471AdapterRAM=4294967296
472Name=NVIDIA GeForce RTX 4090
473
474AdapterRAM=2147483648
475Name=Intel(R) UHD Graphics 770
476"#;
477        let gpus = parse_wmi_videocontroller(wmic_output);
478        assert_eq!(gpus.len(), 2);
479        assert_eq!(gpus[0].name, "NVIDIA GeForce RTX 4090");
480        assert_eq!(gpus[0].vram_bytes, Some(4294967296));
481        assert_eq!(gpus[1].name, "Intel(R) UHD Graphics 770");
482        assert_eq!(gpus[1].vram_bytes, Some(2147483648));
483
484        let powershell_output = r#"
485Name       : NVIDIA GeForce RTX 4090
486AdapterRAM : 4294967296
487
488Name       : Intel(R) UHD Graphics 770
489AdapterRAM : 2147483648
490"#;
491        let gpus = parse_wmi_videocontroller(powershell_output);
492        assert_eq!(gpus.len(), 2);
493        assert_eq!(gpus[0].name, "NVIDIA GeForce RTX 4090");
494        assert_eq!(gpus[0].vram_bytes, Some(4294967296));
495        assert_eq!(gpus[1].name, "Intel(R) UHD Graphics 770");
496        assert_eq!(gpus[1].vram_bytes, Some(2147483648));
497    }
498}