facett-core/src/render/adapter.rs
//! **GPU ADAPTER SELECTION** — the one policy that decides, at startup, which
//! physical device facett renders on.
//!
//! This module is the *pure* half: it depends on **nothing** (no `wgpu`, no
//! feature gate), so the policy is unit-testable on a box with no GPU at all and
//! consumable by every facett crate whether or not it links wgpu. The wgpu glue
//! (`wgpu::AdapterInfo` → [`AdapterFacts`], the `egui_wgpu` configuration, the
//! headless `request_adapter` replacement) lives in
//! [`crate::render::gpu::adapter_wgpu`] behind the `wgpu` feature.
//!
//! ## Why this exists
//!
//! A workstation enumerates *more than one* Vulkan/GL adapter, and the extra ones
//! are traps:
//!
//! - **`llvmpipe` / `lavapipe` / SwiftShader** — a pure software rasteriser that
//! is itself a Vulkan ICD. Restricting the backends to Vulkan does **not**
//! exclude it. Landing here costs 100–1000× and looks like "the app is slow".
//! - **A BMC / server-board management display** — ASPEED (`AST2500`/`AST2600`)
//! on Supermicro/ASRock Rack boards, Matrox `G200` on Dell/HP iDRAC/iLO boards.
//! These exist to paint a 1024×768 remote console; they have no usable 3D
//! engine. On a headless-managed server they are often *device 0*, so a naive
//! "take the first adapter" lands on the BMC while a 4090 sits idle.
//! - An **integrated GPU** next to a discrete one (laptops, Ryzen/Xeon desktops).
//!
//! The policy below ranks every enumerated adapter and takes the best; software
//! and management-display adapters are only ever chosen when there is literally
//! nothing else.
//!
//! ## Observability + override (L4)
//!
//! [`record_selection`] stores the decision in a process-global so a host can
//! publish it in `state_json` via [`selected_adapter_json`] — the choice is
//! **robot-testable**, not a log line a human has to read. `FACETT_GPU_ADAPTER`
//! (see [`env_override`]) forces a specific device by index, name substring, or
//! one of the keywords `discrete` / `integrated` / `software` / `auto`.
use std::sync::{Mutex, OnceLock};
// ── the feature-free facts ──────────────────────────────────────────────────
/// The device class of an adapter — a `wgpu::DeviceType` mirror that carries no
/// dependency on wgpu, so the ranking policy compiles (and is tested) in a build
/// with no GPU stack at all.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub enum AdapterKind {
/// A real add-in card with its own VRAM (RTX 4090, Radeon RX, …). Always first.
DiscreteGpu,
/// An on-die GPU sharing system memory. Real hardware — far better than software.
IntegratedGpu,
/// A paravirtualised GPU (virtio-gpu / venus / a passthrough shim).
VirtualGpu,
/// A CPU software rasteriser: llvmpipe, lavapipe, SwiftShader, softpipe. LAST.
Cpu,
/// Anything the driver would not classify.
Other,
}
impl AdapterKind {
/// The ordering tier. Higher is better; the tier **dominates** every tie-break
/// bonus below, so a discrete GPU can never lose to an integrated one and a
/// hardware GPU can never lose to software.
#[must_use]
pub fn tier(self) -> i64 {
match self {
AdapterKind::DiscreteGpu => 5000,
AdapterKind::IntegratedGpu => 4000,
AdapterKind::VirtualGpu => 3000,
// `Other` still outranks a software rasteriser: an unclassified real
// driver is at worst as bad as llvmpipe and usually much better.
AdapterKind::Other => 2000,
AdapterKind::Cpu => 0,
}
}
/// Short stable token used in `state_json` and logs.
#[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",
}
}
}
/// Everything the policy needs to know about one candidate adapter — the plain
/// mirror of `wgpu::AdapterInfo`, constructible in a test without a GPU.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct AdapterFacts {
/// Driver-reported device name, e.g. `NVIDIA GeForce RTX 4090`, `llvmpipe (LLVM 21.1.8)`.
pub name: String,
/// PCI vendor id (`0x10de` NVIDIA, `0x1002` AMD, `0x1a03` ASPEED, …).
pub vendor: u32,
/// PCI device id.
pub device: u32,
/// Device class.
pub kind: AdapterKind,
/// wgpu backend name (`Vulkan`, `Gl`, `Dx12`, `Metal`, `BrowserWebGpu`, `Noop`).
pub backend: String,
/// Driver name, e.g. `NVIDIA`, `llvmpipe`. Empty when unknown.
pub driver: String,
}
impl AdapterFacts {
/// Build a facts record for tests / non-wgpu callers.
#[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(),
}
}
/// Set the PCI vendor id (builder form, for the BMC-detection tests).
#[must_use]
pub fn with_vendor(mut self, vendor: u32) -> Self {
self.vendor = vendor;
self
}
/// One-line human form used in the startup log.
#[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
)
}
}
// ── vendor ids we must recognise by number, not just by name ────────────────
/// NVIDIA Corporation.
pub const VENDOR_NVIDIA: u32 = 0x10de;
/// Advanced Micro Devices.
pub const VENDOR_AMD: u32 = 0x1002;
/// Intel Corporation.
pub const VENDOR_INTEL: u32 = 0x8086;
/// **ASPEED Technology** — the BMC graphics core (AST2400/2500/2600) soldered onto
/// essentially every server board. A management console framebuffer, never a
/// renderer: matched by vendor id so a driver that renames the device (`ASPEED
/// Graphics Family`, `AST`, `ASPEEDDRMFB`) is still caught.
pub const VENDOR_ASPEED: u32 = 0x1a03;
/// **Matrox Electronic Systems** — the other BMC graphics core (`G200`, as shipped
/// on Dell iDRAC / HPE iLO boards). Same reasoning as ASPEED.
pub const VENDOR_MATROX: u32 = 0x102b;
/// Mesa's `VK_VENDOR_ID_MESA` — reported by lavapipe (software Vulkan).
pub const VENDOR_MESA: u32 = 0x1_0005;
/// Is this a **baseboard-management-controller / remote-console display** rather
/// than a GPU?
///
/// Matched by **PCI vendor id first** (ASPEED `0x1a03`, Matrox `0x102b`) because
/// the device *name* varies by driver (`ASPEED Graphics Family`, `AST`,
/// `MGA G200e`, `Matrox MGA-G200eW WPCM450`), then by name substring as a
/// belt-and-braces fallback for backends that report vendor `0`.
///
/// These parts exist to paint a 2D remote console over IPMI. They have no usable
/// 3D pipeline; selecting one means the whole UI crawls or fails to create a
/// device. They are ranked below software so they are chosen only if there is
/// nothing else at all.
#[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();
// "AST" is checked as a whole word-ish token so it cannot match e.g. "FASTER".
n.contains("ASPEED")
|| n.contains("MGA G200")
|| n.contains("MGA-G200")
|| n.contains("G200E")
|| n.starts_with("AST")
|| n.contains(" AST ")
|| n.contains("MATROX")
}
/// **Facts from the browser's `GPUAdapter.info`** — the four strings a page can read
/// (`vendor`, `architecture`, `device`, `description`), composed into the record the
/// policy reads. `name` = `description` when the browser gives one, else the non-empty
/// fields joined; `driver` = `vendor` + `architecture`, so [`is_software_rasteriser`]
/// sees Chrome's SwiftShader (`google` / `swiftshader`, empty description) in either
/// field. Empty in, empty out: a browser that masks everything yields an empty name,
/// never a manufactured identity.
///
/// THE one copy (lane M, 2026-10-02): facet-wrapped's `gpu::facts_from_browser_info`,
/// the start-time software refusal in [`crate::adapter_retry`] and the probes'
/// `browser_bench::software_adapter` all build their facts here.
#[must_use]
pub fn facts_from_browser_info(
vendor: &str,
architecture: &str,
device: &str,
description: &str,
backend: &str,
) -> AdapterFacts {
let join = |parts: &[&str]| parts.iter().map(|s| s.trim()).filter(|s| !s.is_empty()).collect::<Vec<_>>().join(" ");
let name = if description.trim().is_empty() { join(&[vendor, architecture, device]) } else { description.trim().to_string() };
let mut facts = AdapterFacts::new(name, AdapterKind::Other, backend);
facts.driver = join(&[vendor, architecture]);
facts
}
/// Is this a **CPU software rasteriser** masquerading as a GPU?
///
/// `llvmpipe` / `lavapipe` (Mesa), `softpipe`, SwiftShader (Google), Microsoft's
/// WARP. Most report `DeviceType::Cpu` and are caught by the tier alone, but a
/// few report `Other` (or, under some ICD loaders, `VirtualGpu`), so the *name*
/// is checked independently and forces the software tier.
#[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")
}
// ── the ranking policy ──────────────────────────────────────────────────────
/// Score one adapter. Higher = better. The **class tier dominates**; the name and
/// backend bonuses only ever break ties *inside* a tier.
///
/// Order of precedence, highest first:
/// 1. `DiscreteGpu` — a real card.
/// 2. `IntegratedGpu` — real hardware sharing system RAM.
/// 3. `VirtualGpu`, then `Other`.
/// 4. **software** (`llvmpipe`/`lavapipe`/SwiftShader) — explicit last resort.
/// 5. **management display** (ASPEED/Matrox BMC) — below even software, because a
/// BMC framebuffer is not merely slow, it usually cannot render at all.
#[must_use]
pub fn adapter_score(facts: &AdapterFacts) -> i64 {
// A BMC console is never a rendering target while anything else exists.
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();
// Within a tier prefer the vendor that is actually the fast path here. This is
// a TIE-BREAK ONLY (max +30 against a 1000-wide tier gap): it can never lift a
// software or integrated device above a discrete one.
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
};
// Prefer a native Vulkan/Dx12/Metal adapter over the GL wrapper for the SAME
// physical device (wgpu enumerates both; GL is the slower, older path).
let backend_bonus = match facts.backend.as_str() {
"Vulkan" | "Metal" | "Dx12" => 8,
"Gl" => 1,
_ => 0,
};
tier + vendor_bonus + backend_bonus
}
/// Rank every adapter best-first. Ties keep enumeration order (stable sort), so a
/// two-identical-GPU box is deterministic.
#[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
}
/// **THE** selection function: the index of the adapter facett should render on,
/// or `None` when the list is empty.
///
/// Pure — no globals, no env, no GPU. [`choose_adapter`] wraps it with the env
/// override and the reporting record.
#[must_use]
pub fn choose_adapter_index(facts: &[AdapterFacts]) -> Option<usize> {
rank_adapters(facts).first().copied()
}
// ── the env override ────────────────────────────────────────────────────────
/// The env var that forces a device: `FACETT_GPU_ADAPTER`.
pub const ENV_ADAPTER: &str = "FACETT_GPU_ADAPTER";
/// wgpu's own standard power-preference override, honoured for familiarity:
/// `WGPU_POWER_PREF=low|high`.
pub const ENV_POWER_PREF: &str = "WGPU_POWER_PREF";
/// Apply an override string to a ranked list. Returns the chosen index.
///
/// Accepted forms (case-insensitive), in order of checking:
/// - `auto` / empty → the normal policy;
/// - a decimal **index** into the enumeration (`2`) → that adapter, verbatim,
/// even if it is software or a BMC (the escape hatch is an escape hatch);
/// - `discrete` / `integrated` / `virtual` / `software` / `cpu` → best adapter of
/// that class, falling back to the normal policy when none matches;
/// - anything else → a **case-insensitive substring** of the adapter name or
/// driver (`nvidia`, `4090`, `llvmpipe`), best-ranked match wins.
#[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>() && 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;
}
// `llvmpipe` also reads as a name substring below; other kinds fall through
// to the normal policy rather than failing to open a window.
}
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())
}
/// Read `FACETT_GPU_ADAPTER` from the environment (`None` when unset/empty).
#[must_use]
pub fn env_override() -> Option<String> {
std::env::var(ENV_ADAPTER).ok().filter(|s| !s.trim().is_empty())
}
/// Read `WGPU_POWER_PREF`; `true` when it asks for the LOW-power device.
/// facett requests high performance by default, so this is the only value that
/// changes anything.
#[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)
}
// ── the decision, as data ───────────────────────────────────────────────────
/// The recorded outcome of adapter selection — what a host publishes in
/// `state_json` so a robot test can assert "facett is on the 4090", not a human
/// squinting at a log line.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct AdapterSelection {
/// Index into the enumerated list.
pub index: usize,
/// The chosen adapter.
pub chosen: AdapterFacts,
/// Every adapter that was enumerated, in enumeration order.
pub considered: Vec<AdapterFacts>,
/// `true` when the chosen adapter is a CPU rasteriser — the "everything will
/// be slow" flag. A host should surface this.
pub software: bool,
/// `true` when the choice came from `FACETT_GPU_ADAPTER` rather than the policy.
pub forced: bool,
/// Human-readable justification, e.g. `discrete GPU preferred over 2 others`.
pub reason: String,
}
impl AdapterSelection {
/// Decide, from a list of enumerated adapters plus an optional override.
///
/// This is the pure entry point the wgpu glue and every test call.
#[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 })
}
/// The `state_json` fragment: the whole decision as data.
#[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<_>>(),
})
}
/// The loud one-line startup banner.
#[must_use]
pub fn banner(&self) -> String {
format!(
"facett GPU: {} — {} ({} adapter(s) enumerated)",
self.chosen.describe(),
self.reason,
self.considered.len()
)
}
}
/// Process-global record of the adapter facett actually opened.
static SELECTED: OnceLock<Mutex<Option<AdapterSelection>>> = OnceLock::new();
fn selected_slot() -> &'static Mutex<Option<AdapterSelection>> {
SELECTED.get_or_init(|| Mutex::new(None))
}
/// Record the selection so any host can publish it (`state_json`) without having
/// to thread the value through every pane. Also prints the banner to stderr —
/// loudly, once — because the demo installs no logger.
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);
}
}
/// The recorded selection, if a GPU backend has been brought up in this process.
#[must_use]
pub fn selected_adapter() -> Option<AdapterSelection> {
selected_slot().lock().ok().and_then(|g| g.clone())
}
/// The `state_json` value for the recorded selection — `null` when no GPU backend
/// was brought up (a CPU-painter / headless build), so the key is always present
/// and a robot can distinguish "no GPU lane" from "software GPU".
#[must_use]
pub fn selected_adapter_json() -> serde_json::Value {
match selected_adapter() {
Some(s) => s.to_json(),
None => serde_json::Value::Null,
}
}
/// **Why facett has no GPU lane** — the typed failure GFX_V2 Decision 0 requires
/// in place of a silent soft-render fallback.
///
/// Dropping `Backends::GL` means a host without a WebGPU-class device now gets
/// *nothing* where it previously got a degraded picture. That is the intended
/// trade, but only if the absence is STATED: an unexplained blank window is the
/// false-green shape this constellation keeps getting burned by. The two ways it
/// can happen are kept distinct because their remedies share nothing — "this
/// machine has no GPU driver" is not "this GPU is not good enough".
#[derive(Debug, Clone)]
pub enum GpuUnavailable {
/// Nothing at all was enumerated over the required backends: no driver, no
/// device, or a headless session with no GPU exposed.
NothingEnumerated {
/// The backend mask facett asked for, rendered for humans.
requested: String,
},
/// Devices exist but the policy refused every one.
///
/// ⚠ **UNREACHABLE from the production path today, and deliberately kept.**
/// [`AdapterSelection::decide`] returns `None` **if and only if** the facts list is
/// empty — with a non-empty list it always picks something, because
/// [`choose_adapter_index_with_override`] ends in
/// `.or_else(|| ranked.first().copied())` and even an unmatched
/// `FACETT_GPU_ADAPTER` falls through to it. That is this module's stated intent:
/// *"software and management-display adapters are only ever chosen when there is
/// literally nothing else"* — the policy **flags** them
/// ([`AdapterSelection::software`], and a `reason` naming the BMC case); it does not
/// refuse them. So [`classify_unavailable`] can only ever hand back
/// [`Self::NothingEnumerated`], and `facet-gpu-2` has no live raise site.
///
/// It is not deleted, because the *distinction* is the thing worth keeping: if the
/// policy is ever made strict (Decision 0's "does NOT silently soft-render" argument
/// points that way for llvmpipe), this is the error that must fire — already coded,
/// bannered and remedied. `the_policy_never_refuses_a_non_empty_adapter_list` states
/// the current truth and goes red the moment it stops holding, which is the signal to
/// wire this variant up rather than a failure.
///
/// Read [`Self::remedy`] with that in mind: its advice describes a refusal the policy
/// does not currently perform.
AllRejected {
/// The backend mask facett asked for, rendered for humans.
requested: String,
/// Every adapter that was enumerated and then rejected.
considered: Vec<AdapterFacts>,
},
}
/// **PURE: classify a failed GPU bring-up.** The decision `adapter_wgpu`'s
/// `gpu_unavailable_for` makes, lifted out of the wgpu-only edge so it is testable on a
/// box with no GPU — which is the only way to see which branch is actually reachable.
///
/// `considered` is every adapter that was enumerated (already converted to facts); empty
/// means nothing was found at all. See [`GpuUnavailable::AllRejected`] for why the second
/// branch cannot currently be produced by the production caller.
#[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 {
/// The stable code from the `errcode` REGISTRY (`facet-gpu-<n>`).
#[must_use]
pub fn code(&self) -> &'static str {
match self {
Self::NothingEnumerated { .. } => "facet-gpu-1",
Self::AllRejected { .. } => "facet-gpu-2",
}
}
/// What the operator should actually DO. Platform-specific, because "install
/// a driver" and "use a newer browser" are not interchangeable advice.
#[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."
}
}
}
/// The loud one-line banner, mirroring [`AdapterSelection::banner`] so the
/// success and failure paths read alike in a log.
#[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(" | ")
),
}
}
/// The `state_json` value, so a pane or robot test can key off the code.
#[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 {}
/// Process-global record of a GPU bring-up FAILURE, mirroring `SELECTED`.
static UNAVAILABLE: OnceLock<Mutex<Option<GpuUnavailable>>> = OnceLock::new();
fn unavailable_slot() -> &'static Mutex<Option<GpuUnavailable>> {
UNAVAILABLE.get_or_init(|| Mutex::new(None))
}
/// Record the failure so a host can publish it without threading it through every
/// pane, and print banner + remedy to stderr — loudly, because the demo installs
/// no logger and a blank window explains nothing on its own.
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);
}
}
/// The recorded GPU failure, if bring-up was attempted and failed.
#[must_use]
pub fn gpu_unavailable() -> Option<GpuUnavailable> {
unavailable_slot().lock().ok().and_then(|g| g.clone())
}
/// The `state_json` value for a GPU failure — `null` when there was none, so the
/// key is always present and a robot can tell "no GPU lane attempted" from
/// "GPU lane REFUSED".
#[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)
}
/// **`facet-gpu-2` HAS NO LIVE RAISE SITE, and this is the test that says so.**
///
/// `gpu_unavailable_carries_its_registry_code` below builds
/// [`GpuUnavailable::AllRejected`] **by hand** and checks its code, banner and
/// remedy. That is green, and it proves only that the type is well-formed — nothing
/// in production can construct it, so a registered error code, its remedy text and
/// that test's second half all guard a branch that cannot occur. Exactly the shape
/// this codebase keeps finding: a check that can never report red.
///
/// The cause is one line: [`choose_adapter_index_with_override`] ends in
/// `.or_else(|| ranked.first().copied())`, and every override path falls through to
/// it — so `decide` returns `None` **iff** the list is empty, and
/// [`classify_unavailable`] therefore only ever yields `NothingEnumerated`. That is
/// this module's stated intent (software/BMC are *flagged* and chosen as a last
/// resort, never refused), so it is not a bug — but it was nowhere stated, and
/// `AllRejected`'s remedy describes a refusal the policy does not perform.
///
/// Pinned on **applied output** — the actual `Option` the policy returns — over every
/// list a refusal could plausibly apply to and every override form. It goes RED the
/// day the policy is made strict, which is the signal to wire `AllRejected` up to a
/// real raise site and fix its remedy, not a failure.
#[test]
fn the_policy_never_refuses_a_non_empty_adapter_list() {
// Every list a "we refuse all of these" error could plausibly describe.
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")]),
];
// Including overrides that match NOTHING — the one place a `None` might hide.
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."
);
}
}
// The one and only way to get `None`, and therefore the one and only branch
// `classify_unavailable` can reach from production.
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)"
);
// And the classifier DOES still produce the other branch when handed a non-empty
// list — so the dead branch is dead because of the POLICY, not because the
// classifier lost its second arm. Without this, everything above would also pass
// for a classifier that could no longer report `AllRejected` at all.
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"
);
}
/// GFX_V2 Decision 0's contract: a GPU bring-up failure must be TYPED and
/// must carry a registry code. If this goes red the UI is back to an
/// unexplained blank window — the exact false-green the decision forbids.
///
/// ⚠ Its `AllRejected` half is constructed BY HAND and has no live raise site — see
/// `the_policy_never_refuses_a_non_empty_adapter_list`.
#[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");
// The code must be IN the banner — a log line without it cannot be keyed
// off by a robot test or matched back to the registry by a human.
assert!(nothing.banner().contains("facet-gpu-1"), "banner: {}", nothing.banner());
assert!(rejected.banner().contains("facet-gpu-2"), "banner: {}", rejected.banner());
// Both codes must actually EXIST in the errcode registry, or the code is
// a dangling string that resolves to nothing.
for c in [nothing.code(), rejected.code()] {
assert!(crate::errcode::lookup(c).is_some(), "{c} missing from the errcode REGISTRY");
}
}
/// The two failures must stay DISTINGUISHABLE. Collapsing them is what the
/// old bare `"no wgpu adapters enumerated"` string did — it claimed nothing
/// was enumerated even when adapters had been found and refused, sending the
/// operator to install a driver they already had.
#[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");
// "all rejected" must NAME what it rejected, or the operator cannot tell
// a software rasteriser from a BMC console.
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}");
// ...and "nothing enumerated" must not pretend it saw any.
assert!(nothing.to_json()["considered"].as_array().expect("array").is_empty());
assert_eq!(rejected.to_json()["considered"].as_array().expect("array").len(), 2);
// Display is what eframe surfaces: it must carry code AND remedy, since
// that string may be the only thing the operator ever sees.
let shown = rejected.to_string();
assert!(shown.contains("facet-gpu-2") && shown.contains(rejected.remedy()), "{shown}");
}
/// `state_json` must always carry the key, so a robot can tell "no GPU lane
/// was attempted" (null) from "the GPU lane was REFUSED" (an object).
#[test]
fn gpu_unavailable_json_is_null_until_something_fails() {
assert!(gpu_unavailable_json().is_null(), "nothing has failed in this test process yet");
}
/// **RED-when-broken — THE headline test.** The real oden enumeration: a
/// discrete 4090, an integrated GPU, the ASPEED BMC console and llvmpipe. The
/// ranker MUST return the discrete GPU. If this goes red, facett renders on
/// software or on a management framebuffer and every GPU optimisation in the
/// repo is invisible.
#[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");
// The full ranking, best-first: discrete > integrated > software > BMC.
assert_eq!(rank_adapters(&all), vec![3, 2, 1, 0]);
}
/// A BMC must lose even to an integrated GPU, and even to software: the ASPEED
/// console cannot render a UI at all.
#[test]
fn bmc_never_wins() {
assert!(adapter_score(&aspeed()) < adapter_score(&llvmpipe()));
assert!(adapter_score(&aspeed()) < adapter_score(&igpu()));
// Name-only detection (a backend reporting vendor 0) must still catch it.
let named = AdapterFacts::new("ASPEED Graphics Family", AdapterKind::IntegratedGpu, "Gl");
assert!(is_management_display(&named));
assert!(adapter_score(&named) < adapter_score(&llvmpipe()));
// The Matrox G200 BMC on Dell/HPE boards, by vendor id and by name.
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)
));
}
/// Software must never win over ANY real GPU — including one that reports
/// `Other`, and including a software adapter that lies about its device type.
#[test]
fn software_never_beats_hardware() {
assert!(adapter_score(&nvidia()) > adapter_score(&llvmpipe()));
assert!(adapter_score(&igpu()) > adapter_score(&llvmpipe()));
// lavapipe reporting itself as a *virtual* GPU is still software.
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));
// SwiftShader / WARP too.
assert!(is_software_rasteriser(&AdapterFacts::new(
"SwiftShader Device",
AdapterKind::Other,
"Vulkan"
)));
}
/// With ONLY llvmpipe present the selector must still return something (a slow
/// window beats no window) and mark itself software.
#[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));
}
/// Empty enumeration → no choice (the caller reports "no GPU").
#[test]
fn none_when_empty() {
assert_eq!(choose_adapter_index(&[]), None);
assert!(AdapterSelection::decide(&[], None).is_none());
}
/// Within a tier the vendor/backend bonuses break ties, and never more.
#[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");
// …but the tie-break can never lift an integrated GPU over a discrete one.
let all = [nvidia(), AdapterFacts::new("NVIDIA RTX iGPU-ish", AdapterKind::IntegratedGpu, "Vulkan").with_vendor(VENDOR_NVIDIA)];
assert_eq!(choose_adapter_index(&all), Some(0));
// The same physical device over Vulkan beats itself over GL.
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;
}
/// The env override forces a device by index, by class keyword and by name
/// substring — including deliberately forcing software for A/B benching.
#[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));
// A no-match override degrades to the policy rather than failing to start.
assert_eq!(choose_adapter_index_with_override(&all, Some("matrox-9000")), Some(3));
// An out-of-range index likewise.
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);
}
/// The published decision is complete enough to assert on from outside: the
/// chosen device, why, and every rejected candidate with its score.
#[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"));
}
/// A `Noop`/unknown backend adapter with no name still ranks sanely and never
/// panics — the policy must be total over garbage input.
#[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());
}
}