Files
Unlucky-Launcher/packaging/docs/gpu-evidence.md
T
worker-gpushot 1f7dfd7bdd feat(packaging): GPU 截图证据判定 check_gpu_shot.py(M249 GPUSHOTGATE)
把 M246「这张图真是 GPU 档出来的」的人读判据固定成可复跑的门禁:
入参 = 一份 stderr 日志 + 一张 PNG(可选 --expect-refuse),逐条点名失败
的判据(不只印 FAILED),退出码 0/1/2。

判据:① `[shot] … renderer=wgpu`(不是 `[probe] renderer =`,那只是意图)
② `Selected: AdapterInfo` 的 device_type ∉ {Cpu, Other}(与 probe.rs:165-167
同口径)③ PNG 能解码且非空 ④ PNG 宽高 = 日志 [shot] 的宽高 ⑤ `[backend]
final=wgpu result=started` ⑥ 复用 check_screenshot.py 的非空白判据。
PNG 解码直接 import 同目录 check_screenshot,不复制第二份解码器。

--expect-refuse 是 A6 负例口径:via=refuse + 无出图行 + 目标 PNG 不存在。
--self-test 跑 tests/gpu_shot_vectors.json 的 16 条向量(红 13 / 绿 3),
每条给出期望的失败判据清单,变异(允许 Cpu / 删掉 renderer=wgpu 判据)
会让清单对不上而报红。

docs/gpu-evidence.md 记录能证什么(字形栅格与边缘 AA、面板 α 与玻璃、
两后端一致性)与不能证什么(首帧与帧率、整窗 α 与圆角观感 —— 走 DWM,
任何截图都拍不到),并写明历史 --diff 基线已永久不可复核。

本脚本尚未接进 CI(另一条 mission),本 mission 不改 Rust、不改 workflow。
2026-11-11 13:01:44 +08:00

130 lines
12 KiB
Markdown
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.
# GPU 截图证据账(M249)
判据的**唯一实现**是 `packaging/check_gpu_shot.py`(零第三方依赖,16 条自测向量)。
样本是 M246 在本机的实测产物 `DevDocs/artifacts/gpu-probe/`(`DevDocs/` 被 `.gitignore:6` 忽略,
**图不进仓库**,所以这里只引它的读数,不引它的字节)。
一句话:这套判据把「这张图是 GPU 档出的」从**人读日志**变成**可复跑的退出码**,但它证明的是
「像素由哪条光栅化路径产出」,**不是**「用户看到的窗口长这样」。第 4/5 节逐条划线。
## 1. 怎么跑
```text
python packaging/check_gpu_shot.py <stderr 日志> <PNG> # 正例:六条判据,0/1 退出
python packaging/check_gpu_shot.py <stderr 日志> --expect-refuse [PNG] # 负例:拒绝降级出图是否成立
python packaging/check_gpu_shot.py --self-test # 16 条向量(数据在 tests/gpu_shot_vectors.json)
```
退出码:`0` = 判据全过;`1` = 有判据没过(**逐条点名**,不只印 FAILED);`2` = 用法错(少参数 / 认不出的参数)。
判据不看日志的**请求值**(`[probe] renderer = wgpu` 只是意图),只看**结果行**(`[shot] … renderer=`)。
**取日志必须带 `ULL_GPU_LOG=info`**:`Selected: AdapterInfo { … }` 那一行是 wgpu 的 info 日志,
默认级别下根本不落盘(实测:a1b 那份 24 行 stderr 里 `^\[gpu\]` **0 条**,见 §5 第 2 条)。
## 2. 本机实跑退出码(M246 产物,逐条)
| 用例 | 命令(节选) | 退出码 | 判据怎么说话 |
|------|-------------|--------|-------------|
| A1 正例 GPU | `a1-gpu.stderr.log + a1-gpu-light.png` | **0** | 6/6 过:`renderer=wgpu`、`device_type=DiscreteGpu`(RX 6600 XT / Vulkan)、1180x760、`final=wgpu result=started`、唯一色 1856 / σ 33.21 / 强边缘 0.05778 |
| A2 正例 GPU(preflight) | `a2-preflight.stderr.log + a2-preflight-gpu.png` | **0** | 6/6 过(与 A1 字节相同的图,另带 `[gpu-preflight] ok type=DiscreteGpu`) |
| A4 正例 GPU(auto→shell) | `a4-auto.stderr.log + a4-auto-shell.png` | **0** | 6/6 过:`reason=auto-hardware-adapter` 也真的落到 wgpu |
| A3 CPU 档 | `a3-cpu.stderr.log + a3-cpu-light.png` | **1** | 3 条红:`renderer=tiny-skia`、无 `Selected` 行、`final=tiny-skia`(PNG 本身没问题:唯一色 2519 / σ 35.43) |
| A3b CPU 档重复 | `a3b-cpu-repeat.stderr.log + a3b-cpu-light.png` | **1** | 同上(重复性对照,结论一致) |
| A5 CPU 档 shell | `a5-cpu-shell.stderr.log + a5-cpu-shell.png` | **1** | 同上 |
| A6 负例,按正例口径判 | `a6-negative-refuse.stderr.log + a6-negative-refuse.png` | **1** | **6 条全红**(其中 3 条是「PNG 不存在」——负例本来就没图) |
| A6 负例,`--expect-refuse` | `a6-negative-refuse.stderr.log --expect-refuse` | **0** | 3/3 过:`via=refuse`、无出图行、PNG 不存在 |
| A1b 日志 + A1b 图 | `a1b-gpu-repeat.stderr.log + a1b-gpu-light.png` | **1** | 见 §5 第 2 条:这份日志没开 `ULL_GPU_LOG=info`,**没有** `Selected` 行 ⇒ 单独看它无法自证适配器 |
| 串味:A1 日志 + A3 的 CPU 图 | `a1-gpu.stderr.log + a3-cpu-light.png` | **0** | **这是已知盲区**,见 §5 第 1 条(两张图同为 1180x760) |
A6 的负例形状(`WGPU_BACKEND=metal`):`Available adapters: []` → `wgpu 初始化失败(具名)` →
`拒绝降级出图` → `[backend] final=none result=failed … via=refuse`,退出码 1,**没有 PNG**。
## 3. 六条判据:每条抓得住什么、抓不住什么
判据 1–3 来自 M246 的实测口径,4–6 是为「日志与图对不上号」补的钉子。每条都能单独判红、单独打印。
| # | 判据 | 抓得住 | 抓不住 |
|---|------|--------|--------|
| 1 | `[shot] … renderer=wgpu` 存在 | 「其实走了 CPU 档」——CPU 档在这行写 `renderer=tiny-skia`(M97 起这行打的是**实际**后端,不是请求值,见 `crates/app-iced/src/renderer/mod.rs:52`) | 只证明这行被打印过;日志被手工拼装、或从别的进程拷来的行,它分不清(判据只读文本) |
| 2 | `Selected: AdapterInfo { device_type: X }` 且 `X ∉ {Cpu, Other}` | 「wgpu 真建成了设备,而且选中的不是 WARP(`Cpu`) / Gl 兼容层(`Other`)」;与 `crates/app-iced/src/probe.rs:165-167` 的硬件判据同口径 | 选中的是**哪一块**卡不判(独显 vs 集显都算硬件);不看 `Available adapters` 那 12 条,只看 `Selected` 那条 —— 也就抓不住「枚举里有独显、实际选了集显」之外的更细的驱动行为 |
| 3 | PNG 能解码且像素数 > 0 | 0 字节 / 半截写入 / 根本不是 PNG(签名、IHDR、IDAT 逐层查) | 只管「能解出来」,不管内容对不对(那是判据 6 的事);不解调色板与隔行 PNG(沿用 `check_screenshot.py` 的同一条限制) |
| 4 | PNG 宽高 = 日志 `[shot]` 声明的宽高 | 拿错文件的第一步:日志说 1180x760、图是别的尺寸 | **同尺寸互换抓不住**(见 §5 第 1 条);分辨率和内容是否同一帧也抓不住 |
| 5 | `[backend] final=wgpu result=started` | 后端结论行与判据 1/2 自相矛盾(例如 `final=none … via=refuse`) | 需要 `ULL_ACCEPTANCE=1` 才有这行;只看这一行的话,A6 那种「一行都没出图」和「出了图但日志没写」分不清 |
| 6 | PNG 过 `check_screenshot.py` 的非空白阈值(唯一色 ≥64 / 亮度σ ≥3.0 / 强边缘 ≥0.001) | 「进程起来了、PNG 合法、但一个像素都没画」的空帧 | 阈值很低(这是它的设计:跨主题/字体不误报),**不判视觉正确性**,也不判元素齐全 |
`--expect-refuse` 的三条(负例口径):`via=refuse` 存在、**没有**任何 `[shot]` 出图行、目标 PNG 不存在。
它证明「该拒绝时真拒绝了」,**不证明**「GPU 能出图」——两者互为对照,缺一个都能蒙混过关。
### 3.1 判据对「行为被改坏」敏感(变异自证)
`--self-test` 的向量在 `packaging/tests/gpu_shot_vectors.json`,每条的 `expect_fail` 是**期望的失败判据
清单,逐条对**。两个变异都能被抓住:
| 变异 | 抓它的向量 | 结果 |
|------|-----------|------|
| `DISALLOWED_DEVICE_TYPES` 里删掉 `Cpu`(= 允许 WARP 当 GPU) | 「renderer=wgpu 但 Selected 是 Cpu」 | 由红转绿 ⇒ 清单对不上 ⇒ `--self-test` 退出 1 |
| 删掉判据 1(不再要求 `renderer=wgpu`) | 「删掉 [shot] renderer 行」 | 由红转绿 ⇒ `--self-test` 退出 1 |
## 4. 这份证据**能**证明什么
逐条都能指到「哪个文件 + 哪条判据过了」:
1. **字形栅格与文字边缘的抗锯齿是 GPU 路径给的。** `a1-gpu-light.png` 的 1180×760 像素由 wgpu/Vulkan
在 RX 6600 XT 上光栅化(判据 1+2+5 全过,`Selected` 是 `DiscreteGpu`),而同一屏的 CPU 档(`a3`)
被同一套判据判红。⇒ 「12px 规格文本的覆盖率采样来自 MSAAx4 的那条路」这件事,是**图+日志**一起证的。
2. **面板 α 与玻璃只有 GPU 档能出。** CPU 档在代码层就把它们强制掉了:`crates/app-iced/src/pages/
appearance/alpha.rs` 的 `panel_is_forced_opaque(Backend::Cpu) == true` ⇒ `effective_panel_alpha`
恒 1.0、`effective_glass` 恒 false。所以只要是**真的** GPU 档出的图,它拍到的面板 α/玻璃是产品
在 GPU 档下的真实合成结果,而不是「CPU 兜底也能喂出来」的近似。
3. **两个后端在同一屏上的一致性是可复核的。** `--diff` 的读数(M246):gallery `differing=51055/896800
(5.69%) max_delta=200 best_shift=(0,0)`、shell `19088/896800 (2.13%) max_delta=75 best_shift=(0,0)`;
差异 **99.3%** 落在亮度边缘上、`delta>32` 的 9301 像素连成 3002 块(最大 83 px);两个后端各自连跑
两次 PNG **字节相同**(`52d4c1d6e464da16` ×2 / `10696a043bd9e7b9` ×2)。⇒ 「几何没有平移、差的是
边缘覆盖率」这个结论成立,5.69% 不是抖动。
4. **`--renderer=auto` 不是「悄悄走 CPU」。** A4 的 `reason=auto-hardware-adapter` 与 `Selected:
DiscreteGpu` 同时成立,判据 0 退出。
5. **断开的 RDP 会话也能出 GPU 图**(M246 的现场条件:`quser` 显示 `2 断开`)。⇒ 「断连 ⇒ GPU 截不了图」
这个说法不成立;成立的是 M245 §4 的「断连打折首帧耗时与整窗 α,不打折像素内容」。
6. **「该拒绝时真拒绝」有机器判据。** A6 的 `via=refuse` + 无 PNG + 退出码 1 由 `--expect-refuse` 判 0。
## 5. 这份证据**不能**证明什么(逐条,别把「没证」读成「没问题」)
1. **图与日志的绑定只到「尺寸」一级。** 同一尺寸的两张正经图互换,本脚本判绿(实测:A1 日志 + A3 的
CPU 图 = 退出码 **0**)。要堵这个口得让产品把 PNG 的 sha256 打进日志(改 Rust,不在本 mission)。
⇒ 现有用法是「一次运行、当场取日志与图」,**不是**「从一堆历史产物里随便挑两个配对」。
2. **日志级别不够时判据 2 无从判定。** `Selected: AdapterInfo` 是 wgpu 的 info 级日志:a1b / a5 那两轮
没带 `ULL_GPU_LOG=info`,两份 stderr 里 `^\[gpu\]` 各 **0 条**。a1b 的「合格」来自它与 a1 **字节相同**
(`52d4c1d6e464da16`),**不是**来自它自己的日志 —— 所以本脚本对 a1b 判红是**如实**(这份日志单独
确实证不了适配器),不是误报。
3. **首帧 / 帧率不能证。** 全部 `[perf]` 都是墙钟,且冷热差距极大(A1 首次 `probe=1281ms`、`font=749ms`;
后续进程 `584/587ms`、`55~57ms`)。A1 `3661ms` vs A3 `3146ms` **不能**读成「GPU 比 CPU 慢」。
4. **整窗 α 与圆角观感不能证,任何截图都不行。** `window::screenshot` 读的是渲染目标,而整窗 α
(`LWA_ALPHA` 字节)与圆角(`DWMWA_WINDOW_CORNER_PREFERENCE`)由 **DWM 在合成时**施加,不在渲染
目标里 —— 同一份判据在 Windows 上拍到的是「DWM 之前」的像素。观感只能人眼看真显示器。
5. **「窗口被 DWM 合成过」这件事没证。** 图是渲染目标,不是屏幕。DWM 是否真把这帧合成上屏、有没有掉帧、
有没有被别的窗口遮挡,这张图一概不知道。
6. **换机器不成立。** 全部证据来自一台机器(RX 6600 XT / Raphael iGPU、12 个 adapter、RDP 断开会话)。
`VirtualGpu` 会不会被判成硬件只在**代码层**确认(`probe.rs:165-167` 会放行),**没在真 `VirtualGpu`
适配器上验过**(本机 12 条里一条都没有)。
7. **交互启动那一支没验。** A6 验的是「取证启动(`--screenshot`)下拒绝降级出图」;交互启动的
`via=respawn`(换进程降级)要求真窗口,本轮没开。
8. **dark 主题、其它屏、`ULL_WINDOW_SIZE` 变尺寸、macOS / Linux** 全部没跑:只有 gallery + shell 的 light。
## 6. 历史 `--diff` 基线:**永久不可复核**
`DevDocs/00-债务台账-p1.md:29` 记着一条 `differing=8134/896800 (0.91%) max_delta=60 best_shift=(0,0)`,
M246 本机实测是 **5.69% / 200**(gallery)与 **2.13% / 75**(shell)。两个数字**不可比**,原因是记录本身
缺项:那条没有 commit、没有屏幕、对应图(`DevDocs/artifacts/m187/`)**已丢失**。
⇒ 这条基线在任何将来都**无法复核**:没有图就没有分母,没有 commit 就没有树状态,重跑也只会得到新读数。
唯一还能比的是**几何判据**(`best_shift=(0,0)`),它一致。别把 0.91% 当「回归阈值」用;本账不承认它是基线。
## 7. 没验的部分(M249 自己)
- **没接进 CI**:本脚本目前**只手动跑**,`.github/workflows/**` 一行未动。接 CI 是另一条 mission
(需要 runner 上有真 GPU 或接受「跑不起来就跳过」,是个策略决定,不该顺手改 workflow)。
- **没在任何非本机环境跑过**:Windows + 上述单卡 + RDP 断开会话之外,Linux/macOS/其它 GPU 全没跑。
- **没改 Rust**:判据只读现有日志格式。若产品将来改了 `[shot]` / `Selected:` 的行格式,这里的正则
会同时失效(`--self-test` 用的是**按现格式手拼**的合成日志,抓不到格式漂移——它守判据,不守格式)。
- **没验 `--diff` 阈值化**:本文第 4 节引的是 M246 的读数,本 mission 没有重跑 `--diff`。