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