feat(acceptance): M98 —— 八类诊断日志 + 一次运行的全覆盖验收矩阵

验收要的是「一次运行、多角度覆盖、输出能直接判读」,而不是把朋友当测试机反复问 7 个问题。
这一轮把产品进程能**自己观测**的那部分日志补齐,并把人工问题压缩到 3 条。

新增 `renderer/acceptance/`(三块,全部 ≤400 行):

- `mod.rs`:`[backend]`(plan = 判据,final = **结果**:`result=started|failed` + 具名错误 +
  `via=respawn|refuse|none`)、`[perf]`(boot 三段实测 / session / scene 帧率只算首帧之后的间隔)、
  `[theme]`(来源 = 哪个参数决定,系统那条写 `pending` 等异步真值)、`[window]`(**请求值**,
  字段名自证 `requested` / `scale_requested`);开关 `ULL_ACCEPTANCE=1`、尺寸覆盖
  `ULL_WINDOW_SIZE=1520x900` 与它的解析/夹取(含 `size_source=` 四态)。
- `locale.rs`:`[env]`(真适配器枚举 + 硬件判定;显示器写 `needs-window` 不猜)、`[i18n]`
  (四级链每一级的原始值 + 结论; macOS 走 `defaults read -g AppleLanguages`)。
- `visual.rs`:`[font] chain`(令牌声明的回退链,与已有的实际落点 `[font] platform=` 互补)、
  `[caps]`(`bundle` / `menu_bar_name` 从可执行文件路径**读**出来;`controls=self-drawn` 由
  `decorations=false` 推出)。

接线:`app/boot.rs` 在正确的时刻把真值喂进去(探测产物 / 窗口设置 / 实测 `Instant`),
`pages/harness.rs` 同一套前缀(含 `[perf] scene`),`main.rs` 用法里写出两个开关。

反模式的防线(M97 那类「在需要结果的地方打印请求」):每一格都有能改坏的断言
(`acceptance/tests.rs` + `locale_tests.rs`,共 20 条新增),失败路径的 `error="…"` 会把
wgpu 自带的双引号换成单引号,保证单行可解析。

本机(Windows)实测:auto → `[backend] final=wgpu result=started` + `Selected: AdapterInfo`;
cpu → `tiny-skia`;`WGPU_BACKEND=metal` 负例 → exit 1 + 无图 + `via=refuse`;宽屏覆盖生效。
macOS 分支(Metal / AppleLanguages / 菜单栏名)需真机复核,期望字符串见 `renderer/mod.rs` 文档。

验收脚本不入库(用户要求):落在 gitignore 的 `DevDocs/ops/macos-acceptance.sh`,
由 tower 拷到分发目录。
This commit is contained in:
AskaEth committed 2026-11-03 01:09:25 +08:00
1 parent 18e36c6514
commit f986981c12
9 files changed
+1429 -129

No files matched your search

+176 -87
View File
@@ -1,24 +1,23 @@
//! 启动:后端探测 → 字体求值 → `iced::application(..).run()`。
//! 启动:后端探测 → 字体求值 → `iced::application(..).run()`,外加 M98 的八类验收诊断。
//!
//! ## M97:起步后端起不来时怎么办(两条路,策略不同,别合成一条)
//!
//! `ICED_BACKEND` 现在永远是**单项**(见 [`crate::renderer`] 的判据表)。单项的代价是
//! iced 不再自己做回退,于是「wgpu 起不来」会变成 `iced::Error::GraphicsCreationFailed`
//! ——**这正是我们要的**:那里面装着 wgpu 的具名错误(哪个 backend、卡在 adapter 还是 device)。
//!
//! 拿到具名错误之后分两条路(判据表第 8 行 / 第 9 行):
//! `ICED_BACKEND` 永远是**单项**(见 [`crate::renderer`] 的判据表)。单项的代价是 iced 不再自己
//! 回退,于是「wgpu 起不来」会变成 `iced::Error::GraphicsCreationFailed`——**这正是我们要的**:
//! 那里面装着 wgpu 的具名错误(哪个 backend、卡在 adapter 还是 device)。
//!
//! - **交互启动**:换一个**进程**用 `tiny-skia` 重开([`OnFailure::Respawn`])——安全网,用户还能用。
//! 代价是子进程把启动流程**重跑一遍**(重新探测?不:`ULL_GPU_FALLBACK` 让它跳过探测;
//! 但**字体栈、配置、presenter 都会重算**,实测首帧 531 ms)。这条代价只落在失败路径上。
//! - **取证启动**(带 `--screenshot`,含 `--screen=gallery`):**拒绝出图**、退出码 1
//! ([`OnFailure::Refuse`])——换了后端出的图会让报告撒谎。
//!
//! 为什么必须是「换进程」而不是「在本进程里再 `run()` 一次」:Windows 实测直接 panic
//! (`iced_winit-0.14.1/src/lib.rs:81` → `Create event loop: RecreationAttempt`)——
//! winit 一个进程只能建一次事件循环。猜测式重试别再加回来。
use std::time::Instant;
//! 为什么必须换进程:Windows 实测本进程里再 `run()` 一次直接 panic
//! (`iced_winit-0.14.1/src/lib.rs:81` → `Create event loop: RecreationAttempt`)。别再猜着改回去。
//!
//! ## M98:诊断的落点
//!
//! 八类日志由 [`crate::renderer::acceptance`] 排版;本文件只在**正确的时刻**把真值喂给它(探测
//! 产物 / 窗口设置 / 实测 `Instant`);判据与证据的边界写在那边的模块文档里。
use std::time::{Duration, Instant};
use iced::{window, Task};
@@ -27,50 +26,80 @@ use unlucky_design::color::SchemeFlavor;
use crate::cli::Options;
use crate::fonts;
use crate::probe;
use crate::renderer::acceptance;
use crate::renderer::{self, Decision, Kind};
use crate::theme::Families;
use super::message::Message;
use super::state::{App, STARTED};
use super::{MIN_SIZE, WINDOW_SIZE};
use super::{APP_TITLE, MIN_SIZE, WINDOW_SIZE};
/// 启动 iced(阻塞到退出)。`--diff` 不走这里,见 `main.rs`。
pub(crate) fn run(options: Options) -> iced::Result {
let _ = STARTED.set(Instant::now());
let boot_started = Instant::now();
let decision = boot_probe(&options);
// 贵的诊断(适配器枚举 / `defaults` / `reg.exe`)只在验收模式下跑;便宜的行恒开。
if acceptance::enabled() {
acceptance::announce();
}
let boot = boot_probe(&options);
let decision = boot.decision;
acceptance::report_backend_plan(&decision, options.backend);
if acceptance::enabled() {
acceptance::report_startup(&boot.probe, options.scale_factor);
}
let font_started = Instant::now();
let resolved = fonts::resolve();
eprintln!(
"[font] 求值耗时 {:.0} ms",
font_started.elapsed().as_secs_f64() * 1000.0
);
let font_ms = font_started.elapsed();
eprintln!("[font] 求值耗时 {:.0} ms", font_ms.as_secs_f64() * 1000.0);
eprintln!("{}", resolved.describe());
if resolved.has_synthetic() {
eprintln!(
"[font] 警告:有字重落在「合成加粗」档(观感降级),D28 后半(CDN 字体/按档换族)要处理"
"[font] 警告:有字重落在「合成加粗」档(观感降级),D28 后半(CDN 字体/按档换字族)要处理"
);
}
if acceptance::enabled() {
acceptance::report_font_chain(resolved.platform);
}
let families = resolved.families();
// 截图模式:`--screenshot-theme` > `--theme` > **Light**(唯一取值口在 `Options::capture_flavor`)。
// 这里**不再有**「等系统主题到达」的闸门——CI 的 xvfb 没有主题会话,等它就是挂死(见 cli 文档)。
// 截图模式:`--screenshot-theme` > `--theme` > **Light**(唯一取值口 `Options::capture_flavor`)。
// **不再有**「等系统主题到达」的闸门——CI 的 xvfb 没有主题会话,等它就是挂死(见 cli 文档)。
if options.screenshot.is_some() && options.shot_flavor().is_none() {
eprintln!("[shot] 未指定主题,按 Light 出图(D30 可复现;CI 冒烟依赖这条默认值)");
}
let flavor = options.capture_flavor().unwrap_or(SchemeFlavor::Light);
// `[theme]`:系统那条是异步的 ⇒ `follows_system=true` 时 `flavor=pending`,真值随后由
// `app/update.rs` 的 `[theme] 系统主题 = …` 给出(两行合起来才是完整答案)。
let (source, follows) = acceptance::theme_source(
options.screenshot_theme.is_some(),
options.theme.is_some(),
options.screenshot.is_some(),
);
let explicit = (!follows).then_some(if flavor.is_dark() { "dark" } else { "light" });
acceptance::report_theme(source, explicit, follows);
if acceptance::enabled() {
acceptance::report_perf_boot(boot.probe_ms, font_ms, boot_started.elapsed());
}
// 起步后端起不来时按判据表第 8/9 行分流(交互降级 vs 取证拒绝)。具名原因在分流之前
// 就打到同一个 stderr 上了。
match launch(&options, families, flavor, decision.primary) {
let launch_started = Instant::now();
let result = match launch(&options, families, flavor, decision.primary) {
Err(iced::Error::GraphicsCreationFailed(error)) => {
renderer::report_failure(decision.primary, &error);
match on_failure(decision, options.screenshot.as_ref()) {
Some(OnFailure::Respawn(fallback)) => respawn(decision.primary, fallback),
Some(OnFailure::Respawn(fallback)) => {
respawn(decision.primary, fallback, &error.to_string())
}
Some(OnFailure::Refuse) => {
// 取证启动(`--screenshot`):**拒绝降级出图**。图是要拿去下结论的,
// 换了后端出的图会撒谎——以前就是这么把 CPU 截图当 GPU 结论用的。
// 取证启动:**拒绝降级出图**(换了后端出的图会让报告撒谎)。
eprintln!(
"[gpu] 拒绝降级出图:本次是取证启动(--screenshot {}),换后端出的图会撒谎。",
options
@@ -87,7 +116,36 @@ pub(crate) fn run(options: Options) -> iced::Result {
}
}
other => other,
};
let launch_elapsed = launch_started.elapsed();
// `[backend] final`:**结果**(不是请求)。判据是单项 `ICED_BACKEND`(判据表第 1~7 行):
// 候选只有一项 ⇒ 进程能起来就必定是它,没有静默回退的空间。
match &result {
Ok(()) => acceptance::report_backend_started(decision.primary, launch_elapsed),
Err(error) => {
let named = renderer::named_graphics_error(error).unwrap_or_else(|| error.to_string());
acceptance::report_backend_failed(
decision.primary,
decision.fallback,
acceptance::failure_via(decision.fallback.is_some(), options.screenshot.is_some()),
&named,
);
}
}
if acceptance::enabled() {
acceptance::report_perf_session(
if options.screenshot.is_some() {
"screenshot"
} else {
"interactive"
},
launch_elapsed,
result.is_ok(),
);
}
result
}
/// 起步后端失败后的去向(判据表第 8 行 vs 第 9 行——**两条路策略不同,别合成一条**)。
@@ -101,11 +159,9 @@ enum OnFailure {
/// 纯判据:起步后端失败时走哪条路。
///
/// `evidence` 非空 = 本次是取证启动(带 `--screenshot`,含 `--screen=gallery`),路径只用于日志。
/// 返回 `None` = 没有兜底可用(`--renderer=cpu` / 未编 wgpu),原样把错误交回去。
///
/// 抽成纯函数只为一个理由:这条分支以前**没有任何测试**,`respawn` / `Refuse` 两条路可以被
/// 静默改反(复审 M97 r1 p2-1 就是这么测出来的)。
/// `evidence` 非空 = 本次是取证启动(带 `--screenshot`);返回 `None` = 没有兜底可用
/// (`--renderer=cpu` / 未编 wgpu),原样把错误交回去。抽成纯函数只为一个理由:这条分支以前
/// **没有任何测试**,`respawn` / `Refuse` 两条路可以被静默改反(M97 r1 p2-1 就是这么测出来的)。
fn on_failure(decision: Decision, evidence: Option<&std::path::PathBuf>) -> Option<OnFailure> {
let fallback = decision.fallback?;
Some(match evidence {
@@ -120,8 +176,8 @@ fn on_failure(decision: Decision, evidence: Option<&std::path::PathBuf>) -> Opti
/// ② [`renderer::FALLBACK_ENV`]:**防无限重开的闸门**——子进程见它就必须跳过探测
/// (`boot_probe` 的第一个分支),否则会算出同一个结论、再失败、再 respawn。
///
/// 抽成纯函数是为了让「漏传其中之一」变成**测试能抓到**的错误:这两条都是静默失效型
/// (漏 ① 会让降级重开又跑一次 wgpu、漏 ② 会无限重开),线上都极难看出来。
/// 抽成纯函数是为了让「漏传其中之一」变成**测试能抓到**的错误:两条都是静默失效型
/// (漏 ① 重开又跑一遍 wgpu、漏 ② 无限重开),线上极难看出来。
fn fallback_env(fallback: Kind) -> [(&'static str, &'static str); 2] {
[
("ICED_BACKEND", fallback.env_value()),
@@ -131,19 +187,9 @@ fn fallback_env(fallback: Kind) -> [(&'static str, &'static str); 2] {
/// 安全网:起一个**新进程**用 `fallback` 后端重开,然后带着它的退出码退出。
///
/// 为什么必须是新进程:winit 一个进程只能建一次事件循环
/// (`iced_winit-0.14.1/src/lib.rs:81` → `Create event loop: RecreationAttempt`)。
///
/// 只在**交互启动**时走到这里;取证启动(`--screenshot`)宁可红着退出也不换后端出图
/// (判据表第 8 行 vs 第 9 行——**这两条路的策略不同,别合成一条**)。
///
/// ## `ULL_GPU_FALLBACK` 是防自杀循环的唯一闸门
///
/// 子进程看到 [`renderer::FALLBACK_ENV`] **必须跳过探测**(`boot_probe` 的第一段),换成
/// `tiny-skia` 直接跑;否则子进程会算出和父进程一样的结论(auto + 有硬件 → wgpu)、再失败、
/// 再 respawn —— **无限递归重开**。这种循环极难复现也极难诊断(每次重开都像是「刚刚启动」),
/// 所以闸门放在探测入口的第一个分支,不放在 respawn 那一侧。
fn respawn(failed: Kind, fallback: Kind) -> ! {
/// 只在**交互启动**时走到这里(取证启动宁可红着退出也不换后端出图);子进程看到
/// [`renderer::FALLBACK_ENV`] 必须跳过探测,否则无限递归重开(闸门在 `boot_probe` 入口)。
fn respawn(failed: Kind, fallback: Kind, error: &str) -> ! {
let exe = std::env::current_exe().unwrap_or_else(|err| {
eprintln!("[gpu] 降级重开失败:拿不到自身路径({err})");
std::process::exit(exit_code(None));
@@ -154,12 +200,13 @@ fn respawn(failed: Kind, fallback: Kind) -> ! {
fallback.name(),
failed.name()
);
// `[backend] final` 一行把「哪个后端失败了、去了哪、为什么」收在一起(可 grep 的单一结论行)。
acceptance::report_backend_failed(failed, Some(fallback), "respawn", error);
let status = fallback_command(&exe, std::env::args_os().skip(1), fallback).status();
// 退出码**透传**子进程的:上层脚本/CI 靠它判断这次到底是成功还是失败。
// 写错这一段的失效方式是「安静地给出一个错结论」——不崩、不静默降级、不无限重开,
// 只是让 CI 把失败当成功。所以它拆成 [`exit_code`] 这个纯函数,由测试钉住。
// 退出码**透传**子进程的:上层脚本/CI 靠它判断成败。写错的失效方式是「安静地给出一个错
// 结论」——不崩、不静默降级、不无限重开,只是让 CI 把失败当成功。
let code = match status {
Ok(status) => exit_code(status.code()),
Err(err) => {
@@ -172,9 +219,8 @@ fn respawn(failed: Kind, fallback: Kind) -> ! {
/// 子进程退出码 → 本进程退出码。
///
/// `None` = 子进程被信号杀死(只有 Unix 会这样,`ExitStatus::code` 返回 `None`)。
/// 这种情况走兜底码而**不是** panic,也不是 0:上层的判据是「非零 = 失败」,
/// 「拿不到退出码」和「退出码是某个非零值」对脚本是一回事。
/// `None` = 子进程被信号杀死(只有 Unix):走兜底码而**不是** panic、也不是 0——上层判据是
/// 「非零 = 失败」,「拿不到退出码」和「退出码是某个非零值」对脚本是一回事。
fn exit_code(code: Option<i32>) -> i32 {
/// 重开失败 / 拿不到退出码时的兜底码(与 `main` 的「参数或运行错误」一致)。
const RESPAWN_FAILED: i32 = 2;
@@ -183,8 +229,11 @@ fn exit_code(code: Option<i32>) -> i32 {
/// 构造降级重开的子进程命令(**可测**:program / argv / env 都能在测试里断言)。
///
/// `program` / `args` 是参数而不是在函数里读 `std::env`,唯一目的就是让测试能喂一个假命令
/// (`cmd /c exit 7` / `sh -c "exit 7"`)来验退出码透传——真用 `current_exe()` 就没法测了。
/// `program` / `args` 是参数而不是在函数里读 `std::env`,唯一目的是让测试能喂假命令
/// (`cmd /c exit 7` / `sh -c "exit 7"`)来验退出码透传——真用 `current_exe()` 就没法测。
///
/// 三路 stdio **显式**继承:子进程日志必须和父进程同一路输出,否则验收只抓到父进程那两行具名
/// 错误。`Command` 没有 stdio 的 getter(stable 没有),所以这条只能靠真跑一次证明。
fn fallback_command(
program: &std::path::Path,
args: impl IntoIterator<Item = std::ffi::OsString>,
@@ -192,12 +241,6 @@ fn fallback_command(
) -> std::process::Command {
let mut command = std::process::Command::new(program);
command.args(args).envs(fallback_env(fallback));
// 三路 stdio **显式**继承。`Command` 默认就是继承,但显式写出来有两个作用:
// ① 将来为了「安静」加上 `.stderr(Stdio::null())` 时,diff 里看得见;
// ② 降级那次的子进程日志(`[probe] 本次是降级重开`)**必须和父进程同一路输出**,
// 否则验收脚本只抓到父进程那两行具名错误,看不到降级后到底用什么跑起来了。
// 注:`Command` 没有 stdio 的 getter(stable 没有),所以这条**只能靠真跑一次**证明
// (见 §Windows 实测的 `interactive.log`:父子两段日志在同一个 stderr 文件里)。
command
.stdin(std::process::Stdio::inherit())
.stdout(std::process::Stdio::inherit())
@@ -216,6 +259,41 @@ fn launch(
let boot_options = options.clone();
// M98:`ULL_WINDOW_SIZE=1520x900` 覆盖尺寸(矩阵的「最小 / 宽屏」两档靠它,且必须走**真外壳**
// ——`pages_demo` example 的标题栏是留白带,出不了「三键位置」那张图)。
let (size, size_source) = acceptance::window_size(
(WINDOW_SIZE.width, WINDOW_SIZE.height),
(MIN_SIZE.width, MIN_SIZE.height),
);
let settings = window::Settings {
size: iced::Size::new(size.0, size.1),
min_size: Some(MIN_SIZE),
decorations: false,
resizable: true,
// M93 / D36:整窗 α 需要 `WS_EX_LAYERED`,但这里给不了——`window::Settings` 没有扩展样式
// 字段,写它只有 `Get/SetWindowLongPtrW`(`unsafe`,全仓只允许出现在
// `platform/win32/transparency.rs`)。所以 α 下发在拿到句柄之后(`Message::WindowHandle`)。
transparent: false,
..window::Settings::default()
};
// 值**直接取自**上面那个 `settings`(另抄一份就会漂;验收靠「日志说的 = 给窗口的」才有意义)。
acceptance::report_window(
(settings.size.width, settings.size.height),
Some((MIN_SIZE.width, MIN_SIZE.height)),
options.scale_factor,
size_source,
Some(APP_TITLE),
);
let exe = std::env::current_exe().ok();
acceptance::report_caps(
exe.as_deref(),
settings.transparent,
settings.resizable,
settings.decorations,
);
let application = iced::application(
move || {
(
@@ -230,22 +308,7 @@ fn launch(
.theme(App::theme)
.subscription(App::subscription)
.default_font(default_font(families))
.window(window::Settings {
size: WINDOW_SIZE,
min_size: Some(MIN_SIZE),
decorations: false,
resizable: true,
// M93 / D36:整窗 α 需要 `WS_EX_LAYERED`,但**这里给不了**——
// ① iced 0.14 的 `window::Settings` 没有扩展样式字段;
// ② winit 0.30 只在鼠标穿透(`IGNORE_CURSOR_EVENT`)时置这一位,`transparent: true`
// 走的是 DWM 那条路,与 `LWA_ALPHA` 无关;
// ③ 加扩展样式只有 `Get/SetWindowLongPtrW`,是 `unsafe`,而全仓 `unsafe` 只允许出现在
// `platform/win32/transparency.rs`(CI 的 `unsafe-scope` 是集合相等)。
// 所以窗口创建侧只负责开窗,样式补齐与 α 下发都在拿到句柄之后:
// `app::update` 的 `Message::WindowHandle` → `window_alpha::dispatch`(先 ensure_layered)。
transparent: false,
..window::Settings::default()
});
.window(settings);
match options.scale_factor {
Some(scale) => application.scale_factor(move |_| scale).run(),
@@ -253,10 +316,20 @@ fn launch(
}
}
/// [`boot_probe`] 的产物:判据 + 探测到的适配器 + 探测耗时。
///
/// 为什么把 `Probe` 带出来:`[env]` 的 `gpu=` / `hardware=` 必须是**这一次真枚举到的**结果
/// (再枚举一遍多花 0.65~1.2 s,还可能给出不同状态)。判据与诊断看同一份数据。
struct Boot {
decision: Decision,
probe: probe::Probe,
probe_ms: Duration,
}
/// 渲染后端探测 + 写 `ICED_BACKEND`,返回判据产物(供 `run` 决定要不要换进程重开)。
fn boot_probe(options: &Options) -> Decision {
// 本进程是「上一步失败后重开」的那一次:别再探一次(会得到同一个结论,然后无限重开)。
// 这一步的决策**兜底必须是 None**——由 `decision::fallback_round` 单点保证。
fn boot_probe(options: &Options) -> Boot {
// 「上一步失败后重开」的那一次:别再探(会得到同一个结论,然后无限重开)。兜底必须是 None
// ——由 `decision::fallback_round` 单点保证。
if let Some(kind) = renderer::forced_fallback() {
let decision = renderer::fallback_round(kind);
std::env::set_var("ICED_BACKEND", kind.env_value());
@@ -267,35 +340,51 @@ fn boot_probe(options: &Options) -> Decision {
kind.env_value()
);
eprintln!("[probe] {}", decision.describe());
return decision;
// 验收要 `[env]` 里的真实适配器:这一轮也枚举(否则日志会写 `adapters=0`,与事实相反
// ——GPU 刚刚才被枚举到、只是起不来)。只有验收开关打开时才付这 0.65~1.2 s。
let probe = if acceptance::enabled() {
probe::Probe::run()
} else {
probe::Probe::default()
};
return Boot {
decision,
probe,
probe_ms: Duration::ZERO,
};
}
let probe_started = Instant::now();
// 只有 `auto` 需要探测:`cpu` 与 `gpu` 的决策与「有没有硬件 GPU」无关,
// 而枚举 13 个适配器在本机要 0.65~1.2 s(实测),纯属白花。
let needs_probe = options.backend == crate::Backend::Auto;
// 只有 `auto` 需要探测(`cpu`/`gpu` 的决策与硬件无关,枚举 13 个适配器本机要 0.65~1.2 s)。
// 验收模式例外:`[env]` 那行要 GPU 名,不管请求是哪个后端。
let needs_probe = options.backend == crate::Backend::Auto || acceptance::enabled();
let probe = if needs_probe {
probe::Probe::run()
} else {
probe::Probe::default()
};
let probe_ms = probe_started.elapsed().as_secs_f64() * 1000.0;
let probe_ms = probe_started.elapsed();
// `ULL_PREFLIGHT=1` 时先自己验一遍 wgpu(判据表第 7 行);默认不跑,见 preflight 模块文档。
let preflight = renderer::preflight::run_if_requested();
let decision = probe::apply_with_preflight(options.backend, &probe, preflight.as_ref());
if cfg!(feature = "gpu") {
let ms = probe_ms.as_secs_f64() * 1000.0;
if needs_probe {
eprintln!("[probe] 探测耗时 {probe_ms:.0} ms(wgpu 实例 + 全后端适配器枚举)");
eprintln!("[probe] 探测耗时 {ms:.0} ms(wgpu 实例 + 全后端适配器枚举)");
} else {
eprintln!(
"[probe] 探测耗时 {probe_ms:.0} ms(--renderer={} 与硬件无关,跳过枚举)",
"[probe] 探测耗时 {ms:.0} ms(--renderer={} 与硬件无关,跳过枚举)",
options.backend.name()
);
}
}
decision
Boot {
decision,
probe,
probe_ms,
}
}
/// 默认字体(正文档解析出来的族)。