Files
Unlucky-Launcher/crates/app-iced/src/renderer/mod.rs
T
AskaEth 8153e54f87 fix(acceptance): M98 r1 —— [window] 绑定真 settings、语言维度补断言、boot.rs 拆分
复审 r1(reviewer-accscope-code,p2-2items / fix-then-merge)的两条阻塞 + 四条顺手的 p3。

## p2-1 `[window]` 的三个窗口属性曾是格式串常量(U1:常量冒充观测结果)

第一版把 `resizable=true decorations=false transparent=false` 写在格式化串里,与交给 iced 的
`window::Settings` 没有任何绑定;定向变异把 `settings.decorations` 改成 `true`,**371 个测试全绿**,
而验收包里 `[caps]` 与 `[window]` 会互相矛盾。

- 新增 `acceptance::window::WindowFacts` + `from_settings()`:字段只能从**真实 settings** 抽出来,
  没有手抄入口;`window_text()` 逐字段打印。
- 窗口设置收成单一产地 `app/boot/diagnostics::window_settings()`,`launch()` 与 `[window]`/`[caps]`
  两行日志共用同一个 `&window::Settings`;`pages/harness.rs` 同样从自己那份 settings 取。
- 守门测试 `window_settings_match_what_the_acceptance_logs_claim`,含「from_settings 必须逐字段
  搬运」的反向断言。变异实证:`window_settings` 的 `decorations → true` ⇒ 红并点名;
  `from_settings` 里写死 `decorations: false` ⇒ 红(第一次写测试时这条漏抓,补强后实测红)。

## p2-2 语言维度空转、且没有任何结论断言

`lang::resolve` 是 setting → 平台源 → env,macOS 的 AppleLanguages 几乎恒有值 ⇒ `LANG=zh_CN` /
`LANG=en_US` 两档同值,而原脚本只断言「`[i18n]` 这行存在」——整条链退化成
`source=default lang=en` 时核心集一条都不红。

- 脚本加两条结论断言:`^\[i18n\].*platform=applelanguages:[A-Za-z-]+` 与 ` source=applelanguages`
  (退化日志上实测红、健康日志上实测绿)。
- `REPORT.md` 语言行下注明:env 优先级低于平台源,正常 Mac 上两档同值,这一维验的是「平台源
  真读到了 + 中文字体回退」,不是中英对照。

## p3(复审 + 第二路自跑复核同源的三条)

- `[perf] boot probe=0ms` → `probe=skipped`(没计时就不报数;降级重开那一轮改为**真计时**)。
  变异实证:把 skipped 写成 0ms ⇒ `perf_lines_carry_measured_millis` 红。
- `[env] gpu=` 排序改成 `DiscreteGpu > IntegratedGpu > 其它`:本机原来报核显,而 iced 实际选的是
  独显(现在报 `AMD Radeon RX 6600 XT [DiscreteGpu]`,与 `Selected:` 一致)。
- `[i18n] setting=<未设置>` → `<未接线>`(配置侧根本没读取点,写「未设置」等于把「我们没读」
  说成「用户没设」)。
- `[backend] final=` 失败行:`refuse` / `none` 两条路是 `final=none`(什么都没跑起来),只有
  `respawn` 才是兜底名;`pages/harness.rs` 不再写死 `via="refuse"`,改用共用的 `failure_via()`。
  实跑负例:`[backend] final=none result=failed primary=wgpu fallback=tiny-skia via=refuse`。
- 脚本 `[tick]` 那处唯一未锚定的 `field()`:加 `^` 锚定、正则改 `clock=[a-z0-9-]+`(真值是
  `idle-1hz`,旧 `[a-z]+` 把它截成 `idle`,靠前缀巧合通过);断言也改成 `clock=idle-1hz`。
- 脚本 `[backend] final` 取值改 `field_backend()`:优先 `result=started`(respawn 场景一条日志里
  有父 failed + 子 started 两条,旧 `head -1` 会把「这次没跑起来」写进报告)。
- 脚本 `exec 3>&1` 全程留痕(终端提示也进 `logs/00-run.log`)、跑前清空 `$OUT/shots/`、
  python3 缺失时在 REPORT 里写明「几何列未采」而不是留空。
- `renderer/mod.rs` 补一段依据:为什么「单项 ICED_BACKEND ⇒ 没有静默回退」成立(`iced_tiny_skia`
  的 `with_backend` 只接受 `tiny-skia`),以及它依赖 iced 实现细节的两层缓解。

## 结构

`app/boot.rs` 400 行零余量 → 拆成 `boot.rs`(248) + `boot/fallback.rs`(117,兜底/重开/退出码) +
`boot/diagnostics.rs`(110,窗口设置与四段报告);`run()` 回到「探测 → 字体 → 打印 → 起 iced」。
`acceptance` 再把 `[window]` 拆成 `window.rs`(142)。

门禁:`cargo fmt --check` / `clippy --all-targets -- -D warnings` / `test`(373 lib + 6 integration)全绿。
2026-11-03 12:59:57 +08:00

250 lines
16 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.
//! 渲染后端的**判据表**(M97)+ 三段可观测性基建。
//!
//! 本模块回答一个问题:**这个进程到底跑在哪个渲染器上,凭什么这么断定。**
//!
//! ## 判据表(验收直接对照这张表)
//!
//! `ICED_BACKEND` **永远是单项**(不是候选列表)。理由见下节「为什么不再用候选列表」:
//! 单项 ⇒ 「跑起来了 ⇒ 就是它」,不存在需要事后猜的空间。`兜底` 列是**本 crate 自己的重试**,
//! 不是 iced 的回退,所以它一定伴随一行具名错误日志。
//!
//! | # | 条件 | 选哪个 | 日志该打印什么 | 谁能验证 |
//! |---|---|---|---|---|
//! | 1 | `--renderer=cpu` / `--software-renderer` | `tiny-skia` | `renderer = tiny-skia reason=forced-cpu` | 三平台(CI 只用这条) |
//! | 2 | `--renderer=gpu` 但本构建没编 wgpu | `tiny-skia` | `reason=gpu-not-compiled` + `;本构建未编入 wgpu` | 三平台(`--no-default-features`) |
//! | 3 | `--renderer=gpu` 且编了 wgpu | `wgpu`,兜底 `tiny-skia` | `renderer = wgpu reason=forced-gpu 兜底=tiny-skia` | Windows / Linux 本机;macOS 需真机 |
//! | 4 | `--renderer=auto` 但没编 wgpu | `tiny-skia` | `reason=auto-gpu-not-compiled` | 三平台(`--no-default-features`) |
//! | 5 | `auto` + 枚举不到硬件适配器(只有 WARP / llvmpipe) | `tiny-skia`,兜底 `wgpu` | `reason=auto-no-hardware-adapter` | Windows 本机(软渲染 VM) |
//! | 6 | `auto` + 有硬件适配器 | `wgpu`,兜底 `tiny-skia` | `reason=auto-hardware-adapter` | Windows 本机 **√**;macOS 需真机 |
//! | 7 | 同上但 `ULL_PREFLIGHT=1` 预检失败 | `tiny-skia`,兜底 `wgpu` | `[gpu-preflight] FAILED stage=… error=…` + `reason=preflight-failed` | 三平台(可用 `WGPU_BACKEND=gl` 造) |
//! | 8 | 任何一行选了 wgpu,但 iced 真初始化失败,且**交互启动**(无 `--screenshot`) | 具名错误 → **换一个进程**重开 `tiny-skia` | `[gpu] wgpu 初始化失败(具名):…` → `[gpu] 降级 → tiny-skia(…换一个进程重开…)` → 子进程 `[probe] 本次是降级重开(ULL_GPU_FALLBACK=tiny-skia)` | **Windows 已验**(`WGPU_BACKEND=metal` 造);macOS 需真机 |
//! | 9 | 同上行,但**取证启动**(带 `--screenshot`,含 `--screen=gallery`) | 具名错误 → **不出图**,退出码 1 | `[gpu] wgpu 初始化失败(具名):…` → `[gpu] 拒绝降级出图:本次是取证启动(--screenshot …)` | **Windows 已验**(同造法);macOS 需真机 |
//!
//! **第 8/9 行的硬规则(M97 定)**:
//!
//! - 取证工具(`--screenshot`)宁可红着退出,也不能悄悄换一种后端继续出图。以前 gallery 在
//! wgpu 挂掉时会静默降级到 tiny-skia 照样落 PNG,而报告上写的是「GPU 渲染截图」——所有视觉
//! 结论的可信度都被悄悄打折,而且从输出上看不出来。现在每张图的日志里都带
//! **实际**后端名(`[shot] … renderer=wgpu`),图能自证来源。
//! - 交互启动保留安全网:真无 GPU / wgpu 起不来时降级到 tiny-skia,不崩。
//!
//! **降级为什么是换进程**:第一版写的是「打印错误 → 在本进程里再 `run()` 一次」,Windows 实测
//! 直接 panic(`iced_winit-0.14.1/src/lib.rs:81` → `Create event loop: RecreationAttempt`)——
//! winit 不允许一个进程建两次事件循环。所以安全网是 `respawn`:起一个子进程、只换
//! `ICED_BACKEND` + `ULL_GPU_FALLBACK`,然后带着子进程的退出码退出。子进程看到
//! `ULL_GPU_FALLBACK` 就跳过探测(否则会得到同一个结论、无限重开)。
//!
//! `ULL_PREFLIGHT` 开关的代价(**实测**,不是估计):Windows RX 6600 XT / Vulkan 上
//! `took=717ms`(首次要扫一遍 Vulkan loader),不可用时 `took=0ms`。打开它等于启动多 ~0.7 s,
//! 所以默认关。它测不了 `create_surface`(那时还没有窗口),代替不了真跑一次,只用来「开窗前拿原因」。
//! 注意:`ULL_PREFLIGHT=1` + 预检失败 + `--screenshot` 时**仍会**降级出图(`reason=preflight-failed`,
//! 图上是 `renderer=tiny-skia`)——那条路径不是静默的,但图确实是 CPU 的,读图前先读日志。
//!
//! ## 怎么从日志判断「有没有静默降级」
//!
//! | 你看到 | 结论 |
//! |---|---|
//! | `[probe] renderer = wgpu` 且之后有 `[gpu] iced_wgpu: …Selected: AdapterInfo…` | **真在走 GPU**(这行是 iced_wgpu 自己在创建设备时打的,不是我们的猜测) |
//! | `[gpu] wgpu 初始化失败(具名):…` | wgpu 起不来,**原因就在这行里**;交互启动已换进程降级 CPU,取证启动已拒绝出图 |
//! | `[gpu] 拒绝降级出图:…` | 这张图**没有**产出(取证启动 + wgpu 起不来)。想用 CPU 出图请显式 `--renderer=cpu` |
//! | `[gpu] 降级 → tiny-skia(…换一个进程重开…)` | 交互启动降级了;后面必然跟一行 `[probe] 本次是降级重开` |
//! | `[probe] renderer = tiny-skia reason=…`(`reason` 不是 `forced-cpu`) | 探测判定不该上 GPU,**原因在 `reason`** |
//! | `[shot] … renderer=wgpu` / `renderer=tiny-skia` | 这张图**实际**由哪个后端产出(M97 之前这里打的是请求值 `auto`,等于没打) |
//! | 只有 `[probe] renderer = …` 一行、没有 `[gpu] iced_wgpu:` 也没有取证行 | 那是 M97 之前的旧日志——**它从来没观测过实际后端**,不可采信 |
//!
//! ## 为什么不再用候选列表(M97 的根因订正)
//!
//! M97 之前:`auto` + 有硬件 → **不设** `ICED_BACKEND`,把决策交给 iced 的
//! `fallback::Compositor`;那条路径里 wgpu 的失败被 `errors` 向量装起来、tiny-skia 一成功就整体丢弃
//! (`iced_renderer-0.14.0/src/fallback.rs`,我们改不了)。日志只剩 `renderer = auto (wgpu -> tiny-skia)`
//! ——而这行是 `probe.rs` 里按 `env == None` 打出来的**常量字符串**,跟实际后端无关。
//! 于是 macOS M1 上「枚举到 Apple M1」与「最终是不是真在用 GPU」彻底脱钩:**看起来正常但可能在跑 CPU**。
//!
//! 现在的唯一判据:`ICED_BACKEND` 只写一项 ⇒ 能跑起来就必定是它;起不来就必然是
//! `iced::Error::GraphicsCreationFailed` 里那串具名错误(哪个 backend、reason 是什么),
//! 由 `app/boot.rs` 打印后决定「换进程降级」还是「拒绝出图」。
//!
//! ## 四段基建
//!
//! | 模块 | 作用 | 默认 |
//! |---|---|---|
//! | [`preflight`] | 在 iced 之前自己跑一遍 wgpu 的「实例→适配器→设备」,把失败原因拿在手里 | **关**(`ULL_PREFLIGHT=1` 打开;会重复创建实例/设备,别放默认路径) |
//! | [`log_bridge`] | 把 `iced_wgpu` / `wgpu` 的 `log` 输出转到 stderr(`Selected: AdapterInfo{…}` 这行就是 GPU 生效的实证) | 开(`ULL_GPU_LOG=off` 可关) |
//! | [`acceptance`] | **验收诊断(M98)**:`[env]` `[backend]` `[window]` `[font] chain` `[theme]` `[perf]` `[i18n]` `[caps]` 八类里本 crate 能观测的那部分,固定前缀、单行可 grep | 便宜的行恒开;贵的(适配器枚举)由 `ULL_ACCEPTANCE=1` 打开 |
//!
//! ### 「单项 ⇒ 没有静默回退」这条推理的依据(M98 r1 复审挖到源码层)
//!
//! `iced_renderer` 的 `fallback::Compositor` 拿到 `ICED_BACKEND` 后**仍会把每个候选依次拿去试
//! wgpu 和 tiny-skia**,两个子渲染器收同一个 backend 字符串,所以「单项」本身并不阻止它去试
//! tiny-skia。真正挡住它的是 `iced_tiny_skia` 的 `with_backend`:它只接受
//! `None | "tiny-skia" | "tiny_skia"`,`Some("wgpu")` 直接返回 `GraphicsAdapterNotFound`。
//! 于是 `ICED_BACKEND=wgpu` 这条路**不可能**落到 tiny-skia:起不来就两项都失败、返回
//! `GraphicsCreationFailed`(判据表第 8/9 行接住它)。
//!
//! 这条依赖 iced 的实现细节,所以:① `iced = "=0.14.0"` 精确钉版本;② 验收脚本另有
//! `[gpu] …Selected: AdapterInfo` 这条**不依赖本 crate 推理**的实证断言。升级 iced 时这两条都要重看。
//!
//! **本模块自己只回答「判据」**;「结果」由三处分别取证,别混:① `[backend] final=`(本 crate 的
//! 起步结果,判据是单项 `ICED_BACKEND`);② `[gpu] iced_wgpu: …Selected:`(iced 自己打的,
//! GPU 真生效的实证);③ `[shot] … renderer=`(这张图是哪个后端出的)。
//!
//! ## 真机验收(macOS Metal 路径 —— **需真机复核**,Windows 验不了这一段)
//!
//! 判据表第 3/6/8/9 行的 macOS 分支只能真机验。下面每段都可直接粘贴;`# 期望` 后面是**必须能在
//! stderr 里 grep 到的原文**(M98 的验收包会直接把这些字符串编成断言)。
//!
//! ```bash
//! # 0) 一次
//! cargo build -p unlucky-app-iced
//!
//! # 1) 交互启动:这才是判「M1 到底有没有在用 GPU」的那次。看完关窗即可。
//! ./target/debug/unlucky-launcher-iced --renderer=auto
//! # 期望(这两行同一次运行里都要有):
//! # [probe] renderer = wgpu reason=auto-hardware-adapter 兜底=tiny-skia
//! # [gpu] iced_wgpu::window::compositor: Selected: AdapterInfo { name: "Apple M1",
//! # 上面第二行里的 backend 必须是 Metal(同一行靠后)。
//! # 若出现下面任一行 ⇒ 这次**不是** GPU,把那段原文(含上一行)发回来:
//! # [gpu] wgpu 初始化失败(具名):
//! # [gpu] 降级 → tiny-skia
//! # [probe] 本次是降级重开(ULL_GPU_FALLBACK=tiny-skia)
//!
//! # 2) 开窗前拿原因(不建窗;多花 ~0.7 s)
//! ULL_PREFLIGHT=1 ./target/debug/unlucky-launcher-iced --renderer=auto
//! # 期望:[gpu-preflight] ok adapter="Apple M1" type=IntegratedGpu backend=Metal took=
//!
//! # 3) 取证出图(D30 通道;这一张图自证来源)
//! ./target/debug/unlucky-launcher-iced --screen=gallery --screenshot /tmp/gallery.png
//! # 期望(三行都该有):
//! # [gpu] iced_wgpu::window::compositor: Selected: AdapterInfo { name: "Apple M1",
//! # [shot] 1180x760 scale=1 -> /tmp/gallery.png
//! # [shot] screen=gallery theme=light renderer=wgpu
//! # 若 wgpu 起不来 ⇒ 没有 /tmp/gallery.png、退出码 1、且打印 [gpu] 拒绝降级出图:
//!
//! # 4) 强制 CPU 对照(必须与 M97 之前逐字一致)
//! ./target/debug/unlucky-launcher-iced --renderer=cpu --screenshot /tmp/cpu.png
//! # 期望:
//! # [probe] adapters=0 hardware=false :: request=cpu -> ICED_BACKEND=tiny-skia
//! # [probe] renderer = tiny-skia reason=forced-cpu 兜底=none(候选表唯一项)
//! # [shot] screen=shell theme=light renderer=tiny-skia
//!
//! # 5) 负例:取证启动遇到 wgpu 不可用时必须红着退出(Mac 上用一个不存在的后端来造)
//! WGPU_BACKEND=dx12 ./target/debug/unlucky-launcher-iced --screen=gallery --screenshot /tmp/bad.png
//! # 期望:**没有** /tmp/bad.png、退出码 1、并且打印:
//! # [gpu] wgpu 初始化失败(具名):multiple errors occurred: [GraphicsAdapterNotFound { backend: "wgpu", reason: RequestFailed("no adapter was found for the options requested:
//! # [gpu] 拒绝降级出图:本次是取证启动(--screenshot /tmp/bad.png),换后端出的图会撒谎。
//! ```
//!
//! 第 5 段在 Windows 上已用同构的 `WGPU_BACKEND=metal` 验过(`metal support not compiled in`
//! → 具名错误 → 拒绝出图 → exit 1);macOS 上要确认打印的是 `dx12 support not compiled in` 那类
//! wgpu 原话,而不是我们自己的话。
pub mod acceptance;
pub mod decision;
pub mod log_bridge;
pub mod preflight;
pub use decision::{decide, demote_after_preflight, fallback_round, strict, Decision, Kind};
/// 把 `iced::Error` 里的**具名**图形错误掏出来(`Display` 那句笼统的话不算原因)。
///
/// `iced::Error::GraphicsCreationFailed(inner)` 的 `Display` 只有
/// 「the application graphics context could not be created」,**没有** `{0}`;
/// 真正的 backend 名与 `Reason` 在内层 `graphics::Error` 里。所以这里同时给 Display 与 Debug。
#[must_use]
pub fn named_graphics_error(error: &iced::Error) -> Option<String> {
match error {
iced::Error::GraphicsCreationFailed(inner) => Some(format!("{inner}|详情 {inner:?}")),
_ => None,
}
}
/// 跑一次 iced;起步后端起不来时把**具名**原因交给调用方(不在这里重试)。
///
/// ## 为什么不能在本进程里重试(M97 实测)
///
/// 第一版这里是「打印错误 + 再 `run()` 一次」。Windows 实测直接 panic:
///
/// ```text
/// thread 'main' panicked at iced_winit-0.14.1/src/lib.rs:81:
/// Create event loop: RecreationAttempt
/// ```
///
/// winit 不允许一个进程建两次事件循环,所以**进程内重试在架构上就不成立**。
/// 安全网因此改成**换一个进程重开**(`app/boot.rs` 的 `respawn`),
/// 猜测的写法别再加回来。
pub fn run_once(decision: Decision, attempt: impl FnOnce(Kind) -> iced::Result) -> iced::Result {
attempt(decision.primary)
}
/// 降级重开用的环境变量:子进程看到它就跳过探测、直接用它指向的后端(见 `app::boot::boot_probe`)。
///
/// ⚠️ 这是**防无限重开**的唯一闸门:子进程若不跳过探测,会算出同一个结论(auto + 有硬件 → wgpu)、
/// 再失败、再 respawn,变成自杀式递归。子进程侧的判据在 `boot_probe` 的第一个分支。
///
/// 它也是**进程间**的交接约定,不是给用户设的开关(用户入口仍是 `--renderer`)。
pub const FALLBACK_ENV: &str = "ULL_GPU_FALLBACK";
/// 读 [`FALLBACK_ENV`]:本进程是不是「上一步失败后重开」的那一次。
#[must_use]
pub fn forced_fallback() -> Option<Kind> {
match std::env::var(FALLBACK_ENV).ok()?.trim() {
"tiny-skia" => Some(Kind::TinySkia),
"wgpu" => Some(Kind::Wgpu),
_ => None,
}
}
/// 打印**具名**失败原因(三行以内,`stage`/`backend`/`reason` 都可 grep)。
///
/// `iced::Error::GraphicsCreationFailed` 的 `Display` **不包含**内部错误
/// (`#[error("the application graphics context could not be created")]`,没有 `{0}`),
/// 所以这里必须显式把内层错误打出来,否则又变回「只知道失败,不知道为什么」。
pub fn report_failure(backend: Kind, error: &(impl std::fmt::Display + std::fmt::Debug)) {
eprintln!("[gpu] {} 初始化失败(具名):{error}", backend.name());
eprintln!("[gpu] 失败详情(具名):{error:?}");
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Backend, Probe};
/// `run_once` **必须**把决策里的起步后端传下去。
///
/// 抓得住:`run_once` 被改成传死值(例如恒 `Kind::Wgpu`)——那会让「强制 CPU」也去试 GPU。
/// 抓不住:真 iced 启动成功/失败(那条只能真跑,见判据表第 6/8/9 行的实测记录)。
#[test]
fn run_once_passes_the_decision_primary() {
for request in [Backend::Cpu, Backend::Gpu] {
let decision = decide(request, &Probe::default(), true);
let mut seen = None;
let result = run_once(decision, |kind| {
seen = Some(kind);
Ok(())
});
assert!(result.is_ok(), "闭包返回 Ok 时 run_once 必须原样返回");
assert_eq!(
seen,
Some(decision.primary),
"run_once 传给 iced 的后端必须是决策里的起步项"
);
}
}
/// `run_once` 只跑一次:失败也**不**在进程内重试(winit 只允许建一次事件循环)。
///
/// 抓得住:谁把「进程内重试」加回 `run_once`(会 panic:`RecreationAttempt`)。
/// 抓不住:`respawn` 那条换进程的路(见 `app::boot::tests`)。
#[test]
fn run_once_never_retries_in_process() {
let mut calls = 0;
let _ = run_once(decide(Backend::Gpu, &Probe::default(), true), |_| {
calls += 1;
Err(iced::Error::WindowCreationFailed(Box::new(
std::io::Error::other("测试用"),
)))
});
assert_eq!(calls, 1, "进程内只许跑一次;重试只能换进程");
}
}