facett-core 0.1.19

facett — visual kernel: render a node/edge Scene into egui (wgpu fast path to come)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
//! **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")
}

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