Files
Unlucky-Launcher/crates/app-iced/src/renderer/decision.rs
T
AskaEth 31cdfd5bbd fix(renderer): M97 后端判据 —— 静默降级改具名错误,取证通道拒绝降级出图
M97 的入口是一份 macOS M1 真机报告:探测认出了 Apple M1(adapters=1 /
hardware=true),日志却写着 `renderer = auto (wgpu -> tiny-skia)`,看起来
像在跑 CPU。读源码发现那行是**常量**:`probe.rs` 按 `ICED_BACKEND` 未设就把
`"auto (wgpu -> tiny-skia)"` 打出来,与实际后端无关。真正的失败被 iced 吞掉:
`iced_renderer-0.14.0/src/fallback.rs` 的 `Compositor::with_backend` 把 wgpu 的
错误装进 `errors` 向量,tiny-skia 一成功就整体丢弃。

## 判据改为单项,不再交给 iced 回退

`ICED_BACKEND` 现在**永远是单项**(`wgpu` / `tiny-skia`):能跑起来就必定是它;
起不来就必然是 `iced::Error::GraphicsCreationFailed` 里的具名错误。判据是纯函数
`renderer::decide(request, probe, gpu_compiled)`,落在 `renderer/decision.rs`,
表驱动测试逐行钉死 1~6 行 + 「除强制 CPU / 未编 wgpu 外必须留兜底」的守门。
判据表(条件 / 选哪个 / 日志该打什么 / 谁能验证)在 `renderer/mod.rs` 顶部。

## 安全网改成换进程

第一版是「打印错误 → 在本进程再 run() 一次」,Windows 实测直接 panic:
`Create event loop: RecreationAttempt`(winit 一个进程只能建一次事件循环)。
改成 `respawn`:子进程只换 `ICED_BACKEND` + `ULL_GPU_FALLBACK`,父进程透传它的
退出码。子进程看到 `ULL_GPU_FALLBACK` 必须跳过探测,否则会无限递归重开。

## 取证通道不许自己降级

带 `--screenshot`(含 `--screen=gallery`)时 wgpu 起不来就**不出图**、退出码 1,
因为换了后端出的图会撒谎;交互启动才允许降级。每张图的日志现在打**实际**后端
(`[shot] … renderer=wgpu`),以前打的是请求值 `auto`——同一个病(在需要结果的
地方打请求)在仓库里出现了两次,这是第二次。

## 可观测性

- `log` 桥(`renderer/log_bridge.rs`)把 `iced_wgpu` 的
  `Selected: AdapterInfo{…}` 接到 stderr:这是本仓库第一次能自证「真在用 GPU」。
  按 target 分级(`iced_wgpu*` Info、`wgpu*` Warn),否则 Vulkan loader 会刷 90 行噪声。
- `renderer/preflight.rs`(默认关,`ULL_PREFLIGHT=1`):开窗前拿 wgpu 的具名错误。
  实测 Windows RX 6600 XT 上 took=717ms,所以不进默认路径。
- `app/backend.rs` 的档位判据跟着改成读单项;**真降级到 CPU 的那一次面板 α 现在会
  正确强制 1.0**(以前静默降级后仍按 GPU 档判)。

Windows 实测:auto → wgpu + `Selected: AdapterInfo{…backend: Vulkan}` 出图;
`--renderer=cpu` 逐字不变;`WGPU_BACKEND=metal` 造失败 → 具名错误 → 交互路径降级
不崩(首帧 531ms)/ 取证路径拒绝出图 exit 1。macOS Metal 路径需真机复核,
命令与期望日志行写在 `renderer/mod.rs` 的「真机验收」一节。
2026-11-03 00:26:55 +08:00

306 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 判据的**纯函数**部分:`请求 × 探测 × 是否编入 wgpu` → 起步后端 + 兜底后端。
//!
//! 表本身(什么条件选什么、日志该打什么、谁能验证)在 [`super`] 的模块文档顶部。
//! 这里只放可测的东西:判据一旦掺进运行期副作用(写环境变量、看日志、开窗),
//! 「降级矩阵」就再也写不成表驱动测试了——M97 的教训。
use crate::{Backend, Probe};
/// 一个具体渲染后端(`ICED_BACKEND` 的单项取值)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
/// `ICED_BACKEND=wgpu`。
Wgpu,
/// `ICED_BACKEND=tiny-skia`。
TinySkia,
}
impl Kind {
/// 写进 `ICED_BACKEND` 的取值(也是 `iced_renderer` 认的名字)。
#[must_use]
pub fn env_value(self) -> &'static str {
match self {
Kind::Wgpu => "wgpu",
Kind::TinySkia => "tiny-skia",
}
}
/// 日志里的名字。
#[must_use]
pub fn name(self) -> &'static str {
self.env_value()
}
/// 对应的「档位」(D36 的 CPU 档 / GPU 档判据用)。
#[must_use]
pub fn tier(self) -> Backend {
match self {
Kind::Wgpu => Backend::Gpu,
Kind::TinySkia => Backend::Cpu,
}
}
}
/// 判据产物:起步后端 + **本 crate 自己**的兜底(不是 iced 的候选回退)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Decision {
/// 写进 `ICED_BACKEND` 的唯一一项。
pub primary: Kind,
/// 起步失败后重开的后端;`None` = 没有兜底(强制 CPU / 没编 wgpu)。
pub fallback: Option<Kind>,
/// 为什么这么判(进日志,验收就靠它)。
pub reason: &'static str,
}
impl Decision {
/// 一行给人看的决策描述。
#[must_use]
pub fn describe(&self) -> String {
match self.fallback {
Some(fallback) => format!(
"renderer = {} reason={} 兜底={}(失败会先打印具名错误)",
self.primary.name(),
self.reason,
fallback.name()
),
None => format!(
"renderer = {} reason={} 兜底=none(候选表唯一项)",
self.primary.name(),
self.reason
),
}
}
}
/// 判据纯函数:请求 × 探测 × 是否编入 wgpu → 决策。
///
/// **必须是纯函数**:M97 的教训是判据一旦掺了运行期副作用(写环境变量、看日志),
/// 就再也写不出「降级矩阵」的表驱动测试了。
#[must_use]
pub fn decide(request: Backend, probe: &Probe, gpu_compiled: bool) -> Decision {
match (request, gpu_compiled) {
// 1. 用户点名 CPU:只有 CPU,且**不给兜底**(强制就是强制)。
(Backend::Cpu, _) => Decision {
primary: Kind::TinySkia,
fallback: None,
reason: "forced-cpu",
},
// 2. 点名 GPU 但没编 wgpu:只能 CPU,没得兜。
(Backend::Gpu, false) => Decision {
primary: Kind::TinySkia,
fallback: None,
reason: "gpu-not-compiled",
},
// 3. 点名 GPU 且编了:GPU 起步,CPU 兜底(绝不硬失败)。
(Backend::Gpu, true) => Decision {
primary: Kind::Wgpu,
fallback: Some(Kind::TinySkia),
reason: "forced-gpu",
},
// 4. auto + 没编 wgpu。
(Backend::Auto, false) => Decision {
primary: Kind::TinySkia,
fallback: None,
reason: "auto-gpu-not-compiled",
},
// 5. auto + 只有 WARP/llvmpipe 这类软光栅:CPU 起步,wgpu 留作最后兜底。
(Backend::Auto, true) if !probe.hardware => Decision {
primary: Kind::TinySkia,
fallback: Some(Kind::Wgpu),
reason: "auto-no-hardware-adapter",
},
// 6. auto + 有硬件适配器:GPU 起步,CPU 兜底。
(Backend::Auto, true) => Decision {
primary: Kind::Wgpu,
fallback: Some(Kind::TinySkia),
reason: "auto-hardware-adapter",
},
}
}
/// 预检失败时把 wgpu 降下来(判据表第 7 行)。
///
/// 只在 `ULL_PREFLIGHT=1` 且预检真的失败时调用:这时候**别去开那个窗**——重试一次要闪两下窗,
/// 而且原因已经拿在手里了。
#[must_use]
pub fn demote_after_preflight(decision: Decision) -> Decision {
if decision.primary != Kind::Wgpu {
return decision;
}
Decision {
primary: Kind::TinySkia,
fallback: Some(Kind::Wgpu),
reason: "preflight-failed",
}
}
/// 取证通道(`--screen=gallery`)用:**剥掉兜底**。
///
/// 硬规则(M97 定):**取证工具宁可红着退出,也不能悄悄换一种后端继续出图**。
/// 以前 gallery 在 wgpu 挂掉时会静默出 CPU 截图,而报告上写的是「GPU 渲染截图」——
/// 所有视觉结论的可信度都被这一条悄悄打折,而且从输出上看不出来。
#[must_use]
pub fn strict(decision: Decision) -> Decision {
Decision {
fallback: None,
reason: "gallery-strict",
..decision
}
}
#[cfg(test)]
mod tests {
use super::*;
fn probe(types: &[&'static str]) -> Probe {
Probe {
adapters: types
.iter()
.map(|device_type| crate::probe::AdapterSummary {
name: "adapter".to_owned(),
device_type,
})
.collect(),
hardware: types
.iter()
.any(|device_type| !matches!(*device_type, "Cpu" | "Other")),
}
}
/// 判据表的一行:`请求, 探测结论, 是否编入 wgpu, 期望起步, 期望兜底, 期望 reason`。
type Case = (Backend, Probe, bool, Kind, Option<Kind>, &'static str);
/// 判据表第 1~6 行逐行钉死(表驱动)。谁动了 `decide` 的矩阵,这条先红。
#[test]
fn criterion_table_rows_1_to_6() {
let hardware = || probe(&["IntegratedGpu"]);
let software = || probe(&["Cpu"]);
let cases: &[Case] = &[
// 1 强制 CPU:不给兜底
(
Backend::Cpu,
hardware(),
true,
Kind::TinySkia,
None,
"forced-cpu",
),
(
Backend::Cpu,
software(),
false,
Kind::TinySkia,
None,
"forced-cpu",
),
// 2 点名 GPU 但没编
(
Backend::Gpu,
hardware(),
false,
Kind::TinySkia,
None,
"gpu-not-compiled",
),
// 3 点名 GPU 且编了
(
Backend::Gpu,
hardware(),
true,
Kind::Wgpu,
Some(Kind::TinySkia),
"forced-gpu",
),
(
Backend::Gpu,
software(),
true,
Kind::Wgpu,
Some(Kind::TinySkia),
"forced-gpu",
),
// 4 auto + 没编
(
Backend::Auto,
hardware(),
false,
Kind::TinySkia,
None,
"auto-gpu-not-compiled",
),
// 5 auto + 只有软光栅
(
Backend::Auto,
software(),
true,
Kind::TinySkia,
Some(Kind::Wgpu),
"auto-no-hardware-adapter",
),
// 6 auto + 有硬件(macOS M1 走这条)
(
Backend::Auto,
hardware(),
true,
Kind::Wgpu,
Some(Kind::TinySkia),
"auto-hardware-adapter",
),
];
for (request, probe, gpu_compiled, primary, fallback, reason) in cases {
let got = decide(*request, probe, *gpu_compiled);
assert_eq!(got.primary, *primary, "request={request:?} reason={reason}");
assert_eq!(
got.fallback, *fallback,
"request={request:?} reason={reason}"
);
assert_eq!(got.reason, *reason);
}
}
/// **安全网守门**:除了「强制 CPU」和「没编 wgpu」,任何一条判据都必须留兜底。
///
/// 抓不住:兜底那次重开本身失败(那是 iced 的事,见判据表第 8 行)。
#[test]
fn every_decision_keeps_an_exit() {
for request in [Backend::Auto, Backend::Gpu, Backend::Cpu] {
for gpu_compiled in [true, false] {
for types in [&[][..], &["Cpu"][..], &["IntegratedGpu"][..]] {
let decision = decide(request, &probe(types), gpu_compiled);
let forced = request == Backend::Cpu || !gpu_compiled;
assert_eq!(
decision.fallback.is_none(),
forced,
"{decision:?} 必须留兜底(request={request:?} gpu_compiled={gpu_compiled})"
);
// 兜底不能和起步项相同,否则重试等于原地打转。
assert_ne!(decision.fallback, Some(decision.primary), "{decision:?}");
}
}
}
}
/// 预检失败只降 wgpu 起步的那些行;`--renderer=cpu` 一行都不许动。
#[test]
fn preflight_demotes_only_wgpu_decisions() {
let hardware = probe(&["IntegratedGpu"]);
let auto = decide(Backend::Auto, &hardware, true);
let demoted = demote_after_preflight(auto);
assert_eq!(demoted.primary, Kind::TinySkia);
assert_eq!(demoted.reason, "preflight-failed");
assert_eq!(demoted.fallback, Some(Kind::Wgpu), "wgpu 还能当最后兜底");
let cpu = decide(Backend::Cpu, &hardware, true);
assert_eq!(demote_after_preflight(cpu), cpu, "强制 CPU 不许被预检改写");
}
#[test]
fn describe_names_the_backend_and_the_reason() {
let hardware = probe(&["DiscreteGpu"]);
let text = decide(Backend::Auto, &hardware, true).describe();
assert!(text.contains("renderer = wgpu"), "{text}");
assert!(text.contains("reason=auto-hardware-adapter"), "{text}");
assert!(text.contains("兜底=tiny-skia"), "{text}");
}
}