use std::sync::{Mutex, OnceLock};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub enum AdapterKind {
DiscreteGpu,
IntegratedGpu,
VirtualGpu,
Cpu,
Other,
}
impl AdapterKind {
#[must_use]
pub fn tier(self) -> i64 {
match self {
AdapterKind::DiscreteGpu => 5000,
AdapterKind::IntegratedGpu => 4000,
AdapterKind::VirtualGpu => 3000,
AdapterKind::Other => 2000,
AdapterKind::Cpu => 0,
}
}
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
AdapterKind::DiscreteGpu => "discrete",
AdapterKind::IntegratedGpu => "integrated",
AdapterKind::VirtualGpu => "virtual",
AdapterKind::Cpu => "cpu",
AdapterKind::Other => "other",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct AdapterFacts {
pub name: String,
pub vendor: u32,
pub device: u32,
pub kind: AdapterKind,
pub backend: String,
pub driver: String,
}
impl AdapterFacts {
#[must_use]
pub fn new(name: impl Into<String>, kind: AdapterKind, backend: impl Into<String>) -> Self {
Self {
name: name.into(),
vendor: 0,
device: 0,
kind,
backend: backend.into(),
driver: String::new(),
}
}
#[must_use]
pub fn with_vendor(mut self, vendor: u32) -> Self {
self.vendor = vendor;
self
}
#[must_use]
pub fn describe(&self) -> String {
format!(
"{} [{}/{}] vendor=0x{:04x} device=0x{:04x}",
self.name,
self.kind.as_str(),
self.backend,
self.vendor,
self.device
)
}
}
pub const VENDOR_NVIDIA: u32 = 0x10de;
pub const VENDOR_AMD: u32 = 0x1002;
pub const VENDOR_INTEL: u32 = 0x8086;
pub const VENDOR_ASPEED: u32 = 0x1a03;
pub const VENDOR_MATROX: u32 = 0x102b;
pub const VENDOR_MESA: u32 = 0x1_0005;
#[must_use]
pub fn is_management_display(facts: &AdapterFacts) -> bool {
if facts.vendor == VENDOR_ASPEED || facts.vendor == VENDOR_MATROX {
return true;
}
let n = facts.name.to_ascii_uppercase();
n.contains("ASPEED")
|| n.contains("MGA G200")
|| n.contains("MGA-G200")
|| n.contains("G200E")
|| n.starts_with("AST")
|| n.contains(" AST ")
|| n.contains("MATROX")
}
#[must_use]
pub fn is_software_rasteriser(facts: &AdapterFacts) -> bool {
if facts.kind == AdapterKind::Cpu {
return true;
}
let n = facts.name.to_ascii_lowercase();
let d = facts.driver.to_ascii_lowercase();
let hit = |s: &str| n.contains(s) || d.contains(s);
hit("llvmpipe")
|| hit("lavapipe")
|| hit("swiftshader")
|| hit("softpipe")
|| hit("software rasterizer")
|| hit("microsoft basic render")
|| n.contains("warp")
}
#[must_use]
pub fn adapter_score(facts: &AdapterFacts) -> i64 {
if is_management_display(facts) {
return -1000;
}
let tier = if is_software_rasteriser(facts) {
AdapterKind::Cpu.tier()
} else {
facts.kind.tier()
};
let name = facts.name.to_ascii_uppercase();
let vendor_bonus = if facts.vendor == VENDOR_NVIDIA || name.contains("NVIDIA") || name.contains("RTX")
{
20
} else if facts.vendor == VENDOR_AMD || name.contains("RADEON") {
10
} else if facts.vendor == VENDOR_INTEL || name.contains("INTEL ARC") {
5
} else {
0
};
let backend_bonus = match facts.backend.as_str() {
"Vulkan" | "Metal" | "Dx12" => 8,
"Gl" => 1,
_ => 0,
};
tier + vendor_bonus + backend_bonus
}
#[must_use]
pub fn rank_adapters(facts: &[AdapterFacts]) -> Vec<usize> {
let mut idx: Vec<usize> = (0..facts.len()).collect();
idx.sort_by(|&a, &b| {
adapter_score(&facts[b])
.cmp(&adapter_score(&facts[a]))
.then(a.cmp(&b))
});
idx
}
#[must_use]
pub fn choose_adapter_index(facts: &[AdapterFacts]) -> Option<usize> {
rank_adapters(facts).first().copied()
}
pub const ENV_ADAPTER: &str = "FACETT_GPU_ADAPTER";
pub const ENV_POWER_PREF: &str = "WGPU_POWER_PREF";
#[must_use]
pub fn choose_adapter_index_with_override(
facts: &[AdapterFacts],
over: Option<&str>,
) -> Option<usize> {
let ranked = rank_adapters(facts);
let raw = over.unwrap_or("").trim();
if raw.is_empty() || raw.eq_ignore_ascii_case("auto") {
return ranked.first().copied();
}
if let Ok(i) = raw.parse::<usize>() {
if i < facts.len() {
return Some(i);
}
}
let want_kind = match raw.to_ascii_lowercase().as_str() {
"discrete" | "dgpu" => Some(AdapterKind::DiscreteGpu),
"integrated" | "igpu" => Some(AdapterKind::IntegratedGpu),
"virtual" => Some(AdapterKind::VirtualGpu),
"software" | "cpu" | "llvmpipe" => Some(AdapterKind::Cpu),
_ => None,
};
if let Some(kind) = want_kind {
let hit = ranked.iter().copied().find(|&i| {
if kind == AdapterKind::Cpu {
is_software_rasteriser(&facts[i])
} else {
facts[i].kind == kind
}
});
if hit.is_some() {
return hit;
}
}
let needle = raw.to_ascii_lowercase();
ranked
.iter()
.copied()
.find(|&i| {
facts[i].name.to_ascii_lowercase().contains(&needle)
|| facts[i].driver.to_ascii_lowercase().contains(&needle)
})
.or_else(|| ranked.first().copied())
}
#[must_use]
pub fn env_override() -> Option<String> {
std::env::var(ENV_ADAPTER).ok().filter(|s| !s.trim().is_empty())
}
#[must_use]
pub fn env_prefers_low_power() -> bool {
std::env::var(ENV_POWER_PREF)
.map(|s| s.trim().eq_ignore_ascii_case("low") || s.trim().eq_ignore_ascii_case("lowpower"))
.unwrap_or(false)
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct AdapterSelection {
pub index: usize,
pub chosen: AdapterFacts,
pub considered: Vec<AdapterFacts>,
pub software: bool,
pub forced: bool,
pub reason: String,
}
impl AdapterSelection {
#[must_use]
pub fn decide(considered: &[AdapterFacts], over: Option<&str>) -> Option<Self> {
let forced = over.map(|s| !s.trim().is_empty() && !s.eq_ignore_ascii_case("auto")).unwrap_or(false);
let index = choose_adapter_index_with_override(considered, over)?;
let chosen = considered[index].clone();
let software = is_software_rasteriser(&chosen);
let bmc = is_management_display(&chosen);
let reason = if forced {
format!("forced by {ENV_ADAPTER}={}", over.unwrap_or(""))
} else if bmc {
"ONLY a management-display (BMC) adapter was enumerated — no GPU present".to_owned()
} else if software {
"no hardware adapter enumerated — SOFTWARE rasteriser fallback".to_owned()
} else {
format!(
"{} GPU preferred over {} other adapter(s)",
chosen.kind.as_str(),
considered.len().saturating_sub(1)
)
};
Some(Self { index, chosen, considered: considered.to_vec(), software, forced, reason })
}
#[must_use]
pub fn to_json(&self) -> serde_json::Value {
serde_json::json!({
"index": self.index,
"name": self.chosen.name,
"kind": self.chosen.kind.as_str(),
"backend": self.chosen.backend,
"vendor": format!("0x{:04x}", self.chosen.vendor),
"driver": self.chosen.driver,
"software": self.software,
"forced": self.forced,
"reason": self.reason,
"considered": self.considered.iter().map(|a| serde_json::json!({
"name": a.name,
"kind": a.kind.as_str(),
"backend": a.backend,
"vendor": format!("0x{:04x}", a.vendor),
"score": adapter_score(a),
"software": is_software_rasteriser(a),
"management_display": is_management_display(a),
})).collect::<Vec<_>>(),
})
}
#[must_use]
pub fn banner(&self) -> String {
format!(
"facett GPU: {} — {} ({} adapter(s) enumerated)",
self.chosen.describe(),
self.reason,
self.considered.len()
)
}
}
static SELECTED: OnceLock<Mutex<Option<AdapterSelection>>> = OnceLock::new();
fn selected_slot() -> &'static Mutex<Option<AdapterSelection>> {
SELECTED.get_or_init(|| Mutex::new(None))
}
pub fn record_selection(sel: AdapterSelection) {
eprintln!("{}", sel.banner());
if sel.software {
eprintln!(
"facett GPU: WARNING — rendering on a CPU software rasteriser; expect \
seconds-per-frame. Set {ENV_ADAPTER}=<name substring|index> to force a device."
);
}
if let Ok(mut g) = selected_slot().lock() {
*g = Some(sel);
}
}
#[must_use]
pub fn selected_adapter() -> Option<AdapterSelection> {
selected_slot().lock().ok().and_then(|g| g.clone())
}
#[must_use]
pub fn selected_adapter_json() -> serde_json::Value {
match selected_adapter() {
Some(s) => s.to_json(),
None => serde_json::Value::Null,
}
}
#[derive(Debug, Clone)]
pub enum GpuUnavailable {
NothingEnumerated {
requested: String,
},
AllRejected {
requested: String,
considered: Vec<AdapterFacts>,
},
}
#[must_use]
pub fn classify_unavailable(requested: String, considered: Vec<AdapterFacts>) -> GpuUnavailable {
if considered.is_empty() {
GpuUnavailable::NothingEnumerated { requested }
} else {
GpuUnavailable::AllRejected { requested, considered }
}
}
impl GpuUnavailable {
#[must_use]
pub fn code(&self) -> &'static str {
match self {
Self::NothingEnumerated { .. } => "facet-gpu-1",
Self::AllRejected { .. } => "facet-gpu-2",
}
}
#[must_use]
pub fn remedy(&self) -> &'static str {
if cfg!(target_arch = "wasm32") {
return "this browser exposes no WebGPU adapter — facett needs WebGPU \
(Chrome/Edge 113+, Safari 18+, or Firefox with dom.webgpu.enabled). \
WebGL is deliberately NOT used.";
}
match self {
Self::NothingEnumerated { .. } => {
"no Vulkan/Metal/DX12 device was found — install or enable a GPU driver \
(on Linux: the Vulkan loader plus your vendor's ICD). Over SSH, export a \
display or run the headless CPU-painter build instead."
}
Self::AllRejected { .. } => {
"every device found is a software rasteriser or a management/BMC console, \
which facett refuses to render on — set FACETT_GPU_ADAPTER=<name substring|index> \
to force one anyway, or run the CPU-painter build."
}
}
}
#[must_use]
pub fn banner(&self) -> String {
match self {
Self::NothingEnumerated { requested } => format!(
"facett GPU: UNAVAILABLE [{}] — no adapter enumerated over {requested}",
self.code()
),
Self::AllRejected { requested, considered } => format!(
"facett GPU: UNAVAILABLE [{}] — {} adapter(s) over {requested}, ALL rejected: {}",
self.code(),
considered.len(),
considered.iter().map(AdapterFacts::describe).collect::<Vec<_>>().join(" | ")
),
}
}
#[must_use]
pub fn to_json(&self) -> serde_json::Value {
serde_json::json!({
"code": self.code(),
"message": self.banner(),
"remedy": self.remedy(),
"considered": match self {
Self::NothingEnumerated { .. } => Vec::new(),
Self::AllRejected { considered, .. } => {
considered.iter().map(AdapterFacts::describe).collect::<Vec<_>>()
}
},
})
}
}
impl std::fmt::Display for GpuUnavailable {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} — {}", self.banner(), self.remedy())
}
}
impl std::error::Error for GpuUnavailable {}
static UNAVAILABLE: OnceLock<Mutex<Option<GpuUnavailable>>> = OnceLock::new();
fn unavailable_slot() -> &'static Mutex<Option<GpuUnavailable>> {
UNAVAILABLE.get_or_init(|| Mutex::new(None))
}
pub fn record_gpu_unavailable(err: GpuUnavailable) {
eprintln!("{}", err.banner());
eprintln!("facett GPU: remedy — {}", err.remedy());
if let Ok(mut g) = unavailable_slot().lock() {
*g = Some(err);
}
}
#[must_use]
pub fn gpu_unavailable() -> Option<GpuUnavailable> {
unavailable_slot().lock().ok().and_then(|g| g.clone())
}
#[must_use]
pub fn gpu_unavailable_json() -> serde_json::Value {
match gpu_unavailable() {
Some(e) => e.to_json(),
None => serde_json::Value::Null,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn nvidia() -> AdapterFacts {
AdapterFacts::new("NVIDIA GeForce RTX 4090", AdapterKind::DiscreteGpu, "Vulkan")
.with_vendor(VENDOR_NVIDIA)
}
fn igpu() -> AdapterFacts {
AdapterFacts::new("AMD Radeon Graphics (RADV RAPHAEL)", AdapterKind::IntegratedGpu, "Vulkan")
.with_vendor(VENDOR_AMD)
}
fn aspeed() -> AdapterFacts {
AdapterFacts::new("ASPEED Graphics Family", AdapterKind::Other, "Gl").with_vendor(VENDOR_ASPEED)
}
fn llvmpipe() -> AdapterFacts {
AdapterFacts::new("llvmpipe (LLVM 21.1.8, 256 bits)", AdapterKind::Cpu, "Vulkan")
.with_vendor(VENDOR_MESA)
}
#[test]
fn the_policy_never_refuses_a_non_empty_adapter_list() {
let cases: Vec<(&str, Vec<AdapterFacts>)> = vec![
("software only", vec![llvmpipe()]),
("BMC only", vec![aspeed()]),
("BMC + software, nothing real", vec![aspeed(), llvmpipe()]),
("an unclassifiable driver", vec![AdapterFacts::new("???", AdapterKind::Other, "Vulkan")]),
];
let overrides =
[None, Some(""), Some("auto"), Some("discrete"), Some("integrated"), Some("no-such-gpu"), Some("999")];
for (label, facts) in &cases {
for over in overrides {
assert!(
AdapterSelection::decide(facts, over).is_some(),
"the policy picked NOTHING for {label} with FACETT_GPU_ADAPTER={over:?}. If \
that is now intended, `GpuUnavailable::AllRejected` / `facet-gpu-2` finally \
has a raise site: wire it up in `classify_unavailable`'s caller and correct \
its remedy text, which currently claims a refusal the policy did not make."
);
}
}
assert!(
AdapterSelection::decide(&[], None).is_none(),
"an EMPTY enumeration must be the failing case, or `NothingEnumerated` is unreachable too"
);
assert!(
matches!(classify_unavailable("PRIMARY".into(), vec![]), GpuUnavailable::NothingEnumerated { .. }),
"an empty enumeration classifies as NothingEnumerated (facet-gpu-1)"
);
assert!(
matches!(
classify_unavailable("PRIMARY".into(), vec![llvmpipe()]),
GpuUnavailable::AllRejected { .. }
),
"the classifier's AllRejected arm still works — it is the policy that never asks for it"
);
}
#[test]
fn gpu_unavailable_carries_its_registry_code() {
let nothing = GpuUnavailable::NothingEnumerated { requested: "PRIMARY".into() };
let rejected = GpuUnavailable::AllRejected {
requested: "PRIMARY".into(),
considered: vec![llvmpipe(), aspeed()],
};
assert_eq!(nothing.code(), "facet-gpu-1");
assert_eq!(rejected.code(), "facet-gpu-2");
assert!(nothing.banner().contains("facet-gpu-1"), "banner: {}", nothing.banner());
assert!(rejected.banner().contains("facet-gpu-2"), "banner: {}", rejected.banner());
for c in [nothing.code(), rejected.code()] {
assert!(crate::errcode::lookup(c).is_some(), "{c} missing from the errcode REGISTRY");
}
}
#[test]
fn the_two_gpu_failures_do_not_collapse_into_one_message() {
let nothing = GpuUnavailable::NothingEnumerated { requested: "PRIMARY".into() };
let rejected = GpuUnavailable::AllRejected {
requested: "PRIMARY".into(),
considered: vec![llvmpipe(), aspeed()],
};
assert_ne!(nothing.code(), rejected.code());
assert_ne!(nothing.remedy(), rejected.remedy(), "the remedies must differ — they share nothing");
let b = rejected.banner();
assert!(b.contains("llvmpipe"), "must name the rejected adapters: {b}");
assert!(b.contains("ASPEED"), "must name the rejected adapters: {b}");
assert!(b.contains('2'), "must state how many were considered: {b}");
assert!(nothing.to_json()["considered"].as_array().expect("array").is_empty());
assert_eq!(rejected.to_json()["considered"].as_array().expect("array").len(), 2);
let shown = rejected.to_string();
assert!(shown.contains("facet-gpu-2") && shown.contains(rejected.remedy()), "{shown}");
}
#[test]
fn gpu_unavailable_json_is_null_until_something_fails() {
assert!(gpu_unavailable_json().is_null(), "nothing has failed in this test process yet");
}
#[test]
fn ranks_discrete_over_integrated_bmc_and_software() {
let all = [aspeed(), llvmpipe(), igpu(), nvidia()];
let pick = choose_adapter_index(&all).expect("must pick an adapter");
assert_eq!(pick, 3, "must select the discrete NVIDIA RTX 4090");
assert_eq!(all[pick].kind, AdapterKind::DiscreteGpu);
assert!(!is_software_rasteriser(&all[pick]), "must never land on llvmpipe");
assert!(!is_management_display(&all[pick]), "must NEVER land on the ASPEED BMC");
assert_eq!(rank_adapters(&all), vec![3, 2, 1, 0]);
}
#[test]
fn bmc_never_wins() {
assert!(adapter_score(&aspeed()) < adapter_score(&llvmpipe()));
assert!(adapter_score(&aspeed()) < adapter_score(&igpu()));
let named = AdapterFacts::new("ASPEED Graphics Family", AdapterKind::IntegratedGpu, "Gl");
assert!(is_management_display(&named));
assert!(adapter_score(&named) < adapter_score(&llvmpipe()));
assert!(is_management_display(
&AdapterFacts::new("MGA G200eW WPCM450", AdapterKind::Other, "Gl")
));
assert!(is_management_display(
&AdapterFacts::new("whatever", AdapterKind::Other, "Gl").with_vendor(VENDOR_MATROX)
));
}
#[test]
fn software_never_beats_hardware() {
assert!(adapter_score(&nvidia()) > adapter_score(&llvmpipe()));
assert!(adapter_score(&igpu()) > adapter_score(&llvmpipe()));
let liar = AdapterFacts::new("llvmpipe (LLVM 21)", AdapterKind::VirtualGpu, "Vulkan");
assert!(is_software_rasteriser(&liar));
assert!(adapter_score(&igpu()) > adapter_score(&liar));
assert!(adapter_score(&nvidia()) > adapter_score(&liar));
assert!(is_software_rasteriser(&AdapterFacts::new(
"SwiftShader Device",
AdapterKind::Other,
"Vulkan"
)));
}
#[test]
fn software_alone_is_selected_and_flagged() {
let only = [llvmpipe()];
assert_eq!(choose_adapter_index(&only), Some(0));
let sel = AdapterSelection::decide(&only, None).expect("must decide");
assert!(sel.software, "must flag itself as SOFTWARE so the host can warn");
assert!(!sel.forced);
assert!(sel.reason.contains("SOFTWARE"));
assert_eq!(sel.to_json()["software"], serde_json::json!(true));
}
#[test]
fn none_when_empty() {
assert_eq!(choose_adapter_index(&[]), None);
assert!(AdapterSelection::decide(&[], None).is_none());
}
#[test]
fn tie_breaks_stay_inside_their_tier() {
let amd_card = AdapterFacts::new("AMD Radeon RX 7900 XTX", AdapterKind::DiscreteGpu, "Vulkan")
.with_vendor(VENDOR_AMD);
let all = [amd_card.clone(), nvidia()];
assert_eq!(choose_adapter_index(&all), Some(1), "NVIDIA wins the discrete tie-break");
let all = [nvidia(), AdapterFacts::new("NVIDIA RTX iGPU-ish", AdapterKind::IntegratedGpu, "Vulkan").with_vendor(VENDOR_NVIDIA)];
assert_eq!(choose_adapter_index(&all), Some(0));
let gl = AdapterFacts::new("NVIDIA GeForce RTX 4090", AdapterKind::DiscreteGpu, "Gl")
.with_vendor(VENDOR_NVIDIA);
let all = [gl, nvidia()];
assert_eq!(choose_adapter_index(&all), Some(1), "Vulkan beats the GL wrapper");
let _ = amd_card;
}
#[test]
fn env_override_forces_a_device() {
let all = [aspeed(), llvmpipe(), igpu(), nvidia()];
assert_eq!(choose_adapter_index_with_override(&all, None), Some(3));
assert_eq!(choose_adapter_index_with_override(&all, Some("auto")), Some(3));
assert_eq!(choose_adapter_index_with_override(&all, Some("1")), Some(1));
assert_eq!(choose_adapter_index_with_override(&all, Some("software")), Some(1));
assert_eq!(choose_adapter_index_with_override(&all, Some("integrated")), Some(2));
assert_eq!(choose_adapter_index_with_override(&all, Some("RADEON")), Some(2));
assert_eq!(choose_adapter_index_with_override(&all, Some("4090")), Some(3));
assert_eq!(choose_adapter_index_with_override(&all, Some("matrox-9000")), Some(3));
assert_eq!(choose_adapter_index_with_override(&all, Some("99")), Some(3));
let sel = AdapterSelection::decide(&all, Some("software")).expect("decides");
assert!(sel.forced && sel.software);
}
#[test]
fn selection_json_is_robot_readable() {
let all = [aspeed(), llvmpipe(), igpu(), nvidia()];
let sel = AdapterSelection::decide(&all, None).expect("decides");
let j = sel.to_json();
assert_eq!(j["kind"], serde_json::json!("discrete"));
assert_eq!(j["name"], serde_json::json!("NVIDIA GeForce RTX 4090"));
assert_eq!(j["software"], serde_json::json!(false));
assert_eq!(j["vendor"], serde_json::json!("0x10de"));
let considered = j["considered"].as_array().expect("array");
assert_eq!(considered.len(), 4);
assert_eq!(considered[0]["management_display"], serde_json::json!(true));
assert_eq!(considered[1]["software"], serde_json::json!(true));
assert!(sel.banner().contains("RTX 4090"));
}
#[test]
fn total_over_garbage() {
let junk = [
AdapterFacts::new("", AdapterKind::Other, ""),
AdapterFacts::new("", AdapterKind::Cpu, ""),
];
assert_eq!(choose_adapter_index(&junk), Some(0));
assert!(AdapterSelection::decide(&junk, Some("")).is_some());
}
}