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        let mut gpus = Vec::new();
232        if let Ok(output) = std::process::Command::new("wmic")
233            .args([
234                "path",
235                "win32_VideoController",
236                "get",
237                "Name,AdapterRAM",
238                "/value",
239            ])
240            .output()
241        {
242            if let Ok(stdout) = String::from_utf8(output.stdout) {
243                gpus = parse_wmi_videocontroller(&stdout);
244                if !gpus.is_empty() {
245                    return gpus;
246                }
247                wmic_ok = true;
248            }
249        }
250
251        // Fallback to PowerShell
252        if !wmic_ok {
253            if let Ok(output) = std::process::Command::new("powershell")
254                .args(["-Command", "Get-CimInstance Win32_VideoController | Select-Object Name, AdapterRAM | Format-List"])
255                .output()
256            {
257                if let Ok(stdout) = String::from_utf8(output.stdout) {
258                    gpus = parse_wmi_videocontroller(&stdout);
259                }
260            }
261        }
262
263        if gpus.is_empty() {
264            eprintln!("warning: GPU detection failed on Windows (both wmic and powershell query returned no results)");
265        }
266
267        gpus
268    }
269
270    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
271    {
272        let mut gpus = Vec::new();
273        let mut seen_devices = HashSet::new();
274
275        // Scan /sys/class/drm for all card* and renderD* entries
276        if let Ok(entries) = std::fs::read_dir("/sys/class/drm") {
277            for entry in entries.flatten() {
278                let name = entry.file_name().into_string().unwrap_or_default();
279                if !name.starts_with("card") && !name.starts_with("renderD") {
280                    continue;
281                }
282
283                let device_path = entry.path().join("device");
284                if let Ok(real_path) = std::fs::canonicalize(&device_path) {
285                    if !seen_devices.insert(real_path) {
286                        continue;
287                    }
288
289                    // Try to identify vendor and model
290                    let vendor_id = fs::read_to_string(device_path.join("vendor"))
291                        .unwrap_or_default()
292                        .trim()
293                        .to_string();
294                    let device_id = fs::read_to_string(device_path.join("device"))
295                        .unwrap_or_default()
296                        .trim()
297                        .to_string();
298
299                    if vendor_id.is_empty() || device_id.is_empty() {
300                        continue;
301                    }
302
303                    let mut gpu_name =
304                        lookup_pci_device(&vendor_id, &device_id).unwrap_or_else(|| {
305                            if vendor_id.contains("10de") {
306                                "NVIDIA GPU".to_string()
307                            } else if vendor_id.contains("1002") {
308                                "AMD GPU".to_string()
309                            } else if vendor_id.contains("8086") {
310                                "Intel GPU".to_string()
311                            } else {
312                                "Unknown GPU".to_string()
313                            }
314                        });
315
316                    // Refine AMD GPU names
317                    if vendor_id.contains("1002") {
318                        gpu_name = improve_amd_gpu_name(&gpu_name);
319                    }
320
321                    // NVIDIA special case: try /proc for even better name
322                    if vendor_id.contains("10de") {
323                        if let Ok(pci_slot_path) = fs::read_link(&device_path) {
324                            if let Some(slot_name) = pci_slot_path.file_name() {
325                                let proc_info_path = format!(
326                                    "/proc/driver/nvidia/gpus/{}/information",
327                                    slot_name.to_string_lossy()
328                                );
329                                if let Ok(info) = fs::read_to_string(proc_info_path) {
330                                    for line in info.lines() {
331                                        if line.starts_with("Model:") {
332                                            gpu_name =
333                                                line.replace("Model:", "").trim().to_string();
334                                            break;
335                                        }
336                                    }
337                                }
338                            }
339                        }
340                    }
341
342                    let mut vram_bytes = None;
343                    // Try to get VRAM from common sysfs locations (mainly AMD)
344                    let vram_path = device_path.join("mem_info_vram_total");
345                    if let Ok(vram_str) = fs::read_to_string(vram_path) {
346                        if let Ok(v) = vram_str.trim().parse::<u64>() {
347                            vram_bytes = Some(v);
348                        }
349                    }
350
351                    gpus.push(GpuInfo {
352                        name: gpu_name,
353                        vram_bytes,
354                    });
355                }
356            }
357        }
358
359        if gpus.is_empty() {
360            // Fallback for non-standard setups or if /sys/class/drm is empty
361            if let Ok(model) = fs::read_to_string("/sys/class/drm/card0/device/model") {
362                let model = model.trim();
363                if !model.is_empty() {
364                    gpus.push(GpuInfo {
365                        name: model.to_string(),
366                        vram_bytes: None,
367                    });
368                }
369            }
370        }
371
372        gpus
373    }
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379
380    #[test]
381    fn test_gpu_info_format() {
382        let info = GpuInfo {
383            name: "NVIDIA GeForce RTX 4090".to_string(),
384            vram_bytes: Some(24 * 1024 * 1024 * 1024),
385        };
386        assert_eq!(info.format(), "NVIDIA GeForce RTX 4090 (24 GB)");
387
388        let info = GpuInfo {
389            name: "Intel Arc A770".to_string(),
390            vram_bytes: Some(16 * 1024 * 1024 * 1024),
391        };
392        assert_eq!(info.format(), "Intel Arc A770 (16 GB)");
393
394        let info = GpuInfo {
395            name: "Radeon 780M".to_string(),
396            vram_bytes: Some(512 * 1024 * 1024),
397        };
398        assert_eq!(info.format(), "Radeon 780M (512 MB)");
399
400        let info = GpuInfo {
401            name: "Generic GPU".to_string(),
402            vram_bytes: None,
403        };
404        assert_eq!(info.format(), "Generic GPU");
405    }
406
407    #[test]
408    fn test_improve_amd_gpu_name() {
409        assert_eq!(
410            improve_amd_gpu_name("AMD Radeon Phoenix1 Graphics"),
411            "Radeon 780M"
412        );
413        assert_eq!(improve_amd_gpu_name("AMD Rembrandt"), "Radeon 680M");
414        assert_eq!(improve_amd_gpu_name("Unknown GPU"), "Unknown GPU");
415    }
416
417    #[test]
418    fn test_parse_vram_str() {
419        assert_eq!(parse_vram_str("8 GB"), Some(8 * 1024 * 1024 * 1024));
420        assert_eq!(parse_vram_str("1536 MB"), Some(1536 * 1024 * 1024));
421        assert_eq!(parse_vram_str("512 MB"), Some(512 * 1024 * 1024));
422        assert_eq!(parse_vram_str("1024 KB"), Some(1024 * 1024));
423        assert_eq!(parse_vram_str("invalid"), None);
424        assert_eq!(parse_vram_str("8"), None);
425    }
426
427    #[test]
428    fn test_parse_system_profiler_displays() {
429        let mock_output = r#"
430Graphics/Displays:
431
432    Apple M1 Max:
433
434      Chipset Model: Apple M1 Max
435      Type: GPU
436      Bus: Built-In
437      Total Number of Cores: 32
438      Vendor: Apple (0x106b)
439      Metal Support: Metal 3
440      VRAM (Dynamic, Max): 8192 MB
441      Displays:
442        Color LCD:
443          Display Type: Built-In Retina LCD
444
445    Intel UHD Graphics 630:
446
447      Chipset Model: Intel UHD Graphics 630
448      Type: GPU
449      Bus: Built-In
450      VRAM (Total): 1536 MB
451      Vendor: Intel (0x8086)
452"#;
453        let gpus = parse_system_profiler_displays(mock_output);
454        assert_eq!(gpus.len(), 2);
455        assert_eq!(gpus[0].name, "Apple M1 Max");
456        assert_eq!(gpus[0].vram_bytes, Some(8192 * 1024 * 1024));
457        assert_eq!(gpus[1].name, "Intel UHD Graphics 630");
458        assert_eq!(gpus[1].vram_bytes, Some(1536 * 1024 * 1024));
459    }
460
461    #[test]
462    fn test_parse_wmi_videocontroller() {
463        let wmic_output = r#"
464AdapterRAM=4294967296
465Name=NVIDIA GeForce RTX 4090
466
467AdapterRAM=2147483648
468Name=Intel(R) UHD Graphics 770
469"#;
470        let gpus = parse_wmi_videocontroller(wmic_output);
471        assert_eq!(gpus.len(), 2);
472        assert_eq!(gpus[0].name, "NVIDIA GeForce RTX 4090");
473        assert_eq!(gpus[0].vram_bytes, Some(4294967296));
474        assert_eq!(gpus[1].name, "Intel(R) UHD Graphics 770");
475        assert_eq!(gpus[1].vram_bytes, Some(2147483648));
476
477        let powershell_output = r#"
478Name       : NVIDIA GeForce RTX 4090
479AdapterRAM : 4294967296
480
481Name       : Intel(R) UHD Graphics 770
482AdapterRAM : 2147483648
483"#;
484        let gpus = parse_wmi_videocontroller(powershell_output);
485        assert_eq!(gpus.len(), 2);
486        assert_eq!(gpus[0].name, "NVIDIA GeForce RTX 4090");
487        assert_eq!(gpus[0].vram_bytes, Some(4294967296));
488        assert_eq!(gpus[1].name, "Intel(R) UHD Graphics 770");
489        assert_eq!(gpus[1].vram_bytes, Some(2147483648));
490    }
491}