Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

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());
    }
}