1#[cfg(not(any(target_os = "macos", target_os = "windows")))]
10use std::collections::HashSet;
11use std::fs;
12
13#[derive(Debug, Clone, Default)]
15pub struct GpuInfo {
16 pub name: String,
18 pub vram_bytes: Option<u64>,
20}
21
22impl GpuInfo {
23 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
39pub 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
73pub 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; };
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#[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
116pub 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 in_vendor = line.starts_with(&vendor_id);
134 } else if in_vendor && line.starts_with('\t') && !line.starts_with("\t\t") {
135 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
148pub 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
166pub 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
203pub 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
258pub 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 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 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 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 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 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 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 if vendor_id.contains("1002") {
377 name = improve_amd_gpu_name(&name);
378 }
379 name
380 });
381
382 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 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 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 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 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 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 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 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}