From 1f7dfd7bddfc67892ea40b01c0e656b36809aba9 Mon Sep 17 00:00:00 2001 From: worker-gpushot Date: Wed, 11 Nov 2026 13:01:44 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(packaging):=20GPU=20=E6=88=AA=E5=9B=BE?= =?UTF-8?q?=E8=AF=81=E6=8D=AE=E5=88=A4=E5=AE=9A=20check=5Fgpu=5Fshot.py?= =?UTF-8?q?=EF=BC=88M249=20GPUSHOTGATE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把 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。 --- packaging/README.md | 3 + packaging/check_gpu_shot.py | 380 ++++++++++++++++++++++++++ packaging/docs/gpu-evidence.md | 129 +++++++++ packaging/tests/gpu_shot_vectors.json | 102 +++++++ 4 files changed, 614 insertions(+) create mode 100644 packaging/check_gpu_shot.py create mode 100644 packaging/docs/gpu-evidence.md create mode 100644 packaging/tests/gpu_shot_vectors.json diff --git a/packaging/README.md b/packaging/README.md index f9060c7..4a9e93f 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -68,6 +68,8 @@ Git Bash 下会静默落进 Docker VM(脚本内部用 `cygpath -m` 归一) | `packaging/make_artifacts.py` | 打包与校验(本地 + CI 共用) | ≤400 | | `packaging/release-notes.md` | GitHub Release 说明模板(`{{VERSION}}` / `{{SUMS}}` 占位) | ≤400 | | `packaging/check_screenshot.py` | 截图非空白判定(CI 门禁 + 本地手动跑,零第三方依赖) | ≤400 | +| `packaging/check_gpu_shot.py` | GPU 截图证据判定(M249):`[shot] … renderer=wgpu` + `Selected: AdapterInfo` 的 `device_type ∉ {Cpu, Other}` + PNG 非空/尺寸对账/非空白,逐条点名;`--expect-refuse` 判拒绝降级出图;`--self-test` 跑 `tests/gpu_shot_vectors.json` 的 16 条向量。**尚未接进 CI**,口径与能证/不能证见 `docs/gpu-evidence.md` | ≤400 | +| `packaging/tests/gpu_shot_vectors.json` | `check_gpu_shot.py --self-test` 的向量:16 条(红 13 / 绿 3),每条给出**期望的失败判据清单**,数据与代码分开 | ≤400 | | `packaging/check_publish_slots.py` | 发布名单守卫(M164/M184):名单 ⇄ 产物两个方向 + `mode: ci` 槽位无产物的警告;`--self-test` 注入 11 条回归 | ≤400 | | `packaging/check_pe_subsystem.py` | PE 子系统守卫(M196):**读产物 PE 头的 `Subsystem`**(GUI 必须 2 / CLI 必须 3),非 2 即红;`--self-test` 注入 8 条回归(红 6 / 绿 2)(见 §15) | ≤400 | | `packaging/check_test_source_off.py` | 测试通道守门(M169):workflow / 打包脚本 / `Cargo.toml default` 都不许打开 `update-test-source`;归属按 `jobs.*.steps[]` 列表项判、默认拒绝;`--self-test` 按 `tests/guard_vectors.json` 注入 | ≤400 | @@ -75,6 +77,7 @@ Git Bash 下会静默落进 Docker VM(脚本内部用 `cygpath -m` 归一) | `packaging/tests/guard_vectors.json` | 守门自测向量(基线 + 32 条:26 红 / 6 绿),数据与代码分开 | ≤400 | | `packaging/ci-runtime-smoke.md` | §11 的细节:断言口径/阈值依据/失效模式/复跑 | ≤400 | | `packaging/docs/linux-evidence.md` | Linux 槽位的证据账(M184):现有证据盘点、WSLg 真机实测输出、xvfb 复跑、CI 增量估算、未验清单 | ≤400 | +| `packaging/docs/gpu-evidence.md` | GPU 截图证据账(M249):判据逐条「抓得住什么/抓不住什么」、本机实跑退出码、**能证**(字形栅格与边缘 AA / 面板 α 与玻璃 / 两后端一致性)与**不能证**(首帧与帧率 / 整窗 α 与圆角观感,走 DWM 任何截图都拍不到)、历史 `--diff` 基线永久不可复核 | ≤400 | | `packaging/linux-smoke-docker.sh` | 本地复跑 `runtime-smoke`(Docker + xvfb) | ≤400 | | `packaging/linux-check-docker.sh` | 本地复跑 `ci.yml` 的两条 ubuntu 门禁(clippy / test,Docker) | ≤400 | | `.github/workflows/release.yml` | tag 触发 → 选槽位(`plan`)→ 调下面两个可复用工作流 | ≤400 | diff --git a/packaging/check_gpu_shot.py b/packaging/check_gpu_shot.py new file mode 100644 index 0000000..1d220c4 --- /dev/null +++ b/packaging/check_gpu_shot.py @@ -0,0 +1,380 @@ +#!/usr/bin/env python3 +"""GPU 截图证据判定(M249):把「这张图真是 GPU 档出来的」变成机器判据,而不是靠人读日志。 + +为什么需要它:M246 实测到 `--renderer=gpu` 落到 RX 6600 XT / Vulkan(DiscreteGpu)并出图,但那份 +结论是**人读 258 行日志**得出的。日志少一行、`Selected` 换了适配器、PNG 是上一次的残留,人眼复核 +一遍就得重来。本脚本把那套判据固定成三分钟能复跑一遍的门禁,且**缺哪条就点名哪条**。 + +入参 = 一份 stderr 日志 + 一张 PNG(可选 `--expect-refuse` 走负例口径)。正例六条判据(每条抓得住 +什么、抓不住什么见 `packaging/docs/gpu-evidence.md` §2): + +1. 截图那一行 `[shot] … renderer=wgpu`——**不是** `[probe] renderer = wgpu`:后者只是启动前的意图, + M246 的 A6 负例里它照样打印 wgpu,却一个像素都没画。 +2. `Selected: AdapterInfo { … device_type: X }` 的 `X` 不在 `DISALLOWED_DEVICE_TYPES` 内。 +3. PNG 能解码且像素数 > 0。 +4. PNG 宽高 = 日志里 `[shot] W×H` 声明的宽高(把图和日志钉在一起)。 +5. `[backend] final=wgpu result=started`(后端自己的结论行与上面几条不矛盾)。 +6. PNG 过 `check_screenshot.py` 的非空白判据(复用它的解码器与阈值,不复制第二份)。 + +`--expect-refuse` 是负例口径(M246 的 A6):要求 `[backend] final=… via=refuse`、**没有**任何 +`[shot]` 出图行、且目标 PNG 不存在。它证明「该拒绝时真拒绝了」,不证明「GPU 能出图」。 + +零第三方依赖;PNG 只解码一次,用的是同目录 `check_screenshot.py` 的 `decode_png` / `measure` / `verdict`。 + +用法:: + + python packaging/check_gpu_shot.py shot.stderr.log shot.png # 正例:判定,0/1 退出 + python packaging/check_gpu_shot.py a6.stderr.log --expect-refuse # 负例:判定拒绝是否成立 + python packaging/check_gpu_shot.py --self-test # 16 条向量(数据在 tests/) +""" + +from __future__ import annotations + +import json +import re +import sys +import tempfile +import zlib +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) # 同目录 import(-m 方式调用时也要成立) +from check_screenshot import ( # noqa: E402 + DEFAULT_MIN_EDGE_RATIO, + DEFAULT_MIN_STDDEV, + DEFAULT_MIN_UNIQUE, + PngError, + decode_png, + encode_png, + measure, + verdict, +) + +#: 这些 `device_type` 等于「不是硬件 GPU」:`Cpu` = WARP/软渲染适配器,`Other` = 未分类(本机那条 +#: `Other` 是 Gl 兼容层)。用 `not in` 而不是 `in {DiscreteGpu, IntegratedGpu}`,与 +#: `crates/app-iced/src/probe.rs:165-167` 的硬件判据同口径:`VirtualGpu` 之类新词条不会因为本脚本 +#: 没登记就被当成「不是硬件」。反过来,把 `Cpu` 从这张禁令里拿掉正是最想抓的回归(向量第 3 条)。 +DISALLOWED_DEVICE_TYPES = frozenset({"Cpu", "Other"}) + +#: 截图行里的 renderer。只认 `[shot]` 行 —— `[probe] renderer = …` 是启动前的意图,不是结果。 +SHOT_RENDERER_RE = re.compile(r"^\[shot\][^\n]*?\brenderer=(\S+)", re.MULTILINE) +#: `[shot] 1180x760 scale=1 -> …`:出图行声明的像素尺寸。 +SHOT_SIZE_RE = re.compile(r"^\[shot\]\s+(\d+)x(\d+)\b", re.MULTILINE) +#: `[gpu] …compositor: Selected: AdapterInfo { … }`:真正建出来的那块适配器。这两处约束不是洁癖: +#: 每份日志里还有一行 `[probe] 取证:出现 …Selected: AdapterInfo…` 的**提示文本**(含同样几个字、 +#: 但没有 `{`),锚 `^\[gpu\]` + 要求紧跟 `{` 才不会被那行骗过去。 +SELECTED_RE = re.compile(r"^\[gpu\][^\n]*Selected:\s*AdapterInfo\s*\{(?P[^\n]*)", re.MULTILINE) +ADAPTER_NAME_RE = re.compile(r'name:\s*"([^"]*)"') +DEVICE_TYPE_RE = re.compile(r"device_type:\s*(\w+)") +ADAPTER_BACKEND_RE = re.compile(r"backend:\s*(\w+)") +#: `[backend] final=… result=… … via=…`:后端自己的结论行。 +BACKEND_FINAL_RE = re.compile(r"^\[backend\] final=(\S+)\s+result=(\S+)(?P[^\n]*)", re.MULTILINE) +VIA_RE = re.compile(r"\bvia=(\w+)") + +#: 判据编号 → 标签。编号是报告里的次序,也在 `--self-test` 的向量里被逐条点名。 +LABELS = { + 1: "截图行 renderer=wgpu", + 2: "Selected: AdapterInfo 的 device_type 不是 Cpu/Other", + 3: "PNG 能解码且非空", + 4: "PNG 宽高 = 日志 [shot] 的宽高", + 5: "后端结论行 final=wgpu result=started", + 6: "PNG 不是空白帧", + 7: "后端结论行 via=refuse", + 8: "没有出图行([shot] 尺寸 + renderer 都不该有)", + 9: "目标 PNG 不存在(拒绝出图就该一张都没有)", +} + + +class Report: + """逐条判据的结论。失败时由 `print_report` 逐条点名,绝不只印一个 FAILED。""" + + def __init__(self) -> None: + self.items: list[tuple[int, bool, str]] = [] + + def add(self, criterion: int, ok: bool, detail: str = "") -> bool: + self.items.append((criterion, ok, detail)) + return ok + + def problems(self) -> list[str]: + return [LABELS[criterion] for criterion, ok, _detail in self.items if not ok] + + def print_report(self, log_path: Path, png_path: Path | None) -> None: + for criterion, ok, detail in self.items: + print(f"{'[ok]' if ok else '[fail]'} {criterion}. {LABELS[criterion]}" + (f":{detail}" if detail else "")) + target = str(log_path) if png_path is None else f"{log_path} + {png_path}" + problems = self.problems() + if problems: + print(f"[fail] 判据没过 {len(problems)}/{len(self.items)} 条({target})", file=sys.stderr) + else: + print(f"[ok] {len(self.items)} 条判据全过({target})") + + +def read_log(path: Path) -> str: + """日志按 UTF-8 读、坏字节替换 —— 判据只看 ASCII 标记,不该因为一行中文乱码就崩。""" + return path.read_text(encoding="utf-8", errors="replace") + + +def shot_renderers(text: str) -> list[str]: + return SHOT_RENDERER_RE.findall(text) + + +def shot_size(text: str) -> tuple[int, int] | None: + match = SHOT_SIZE_RE.search(text) + return (int(match.group(1)), int(match.group(2))) if match else None + + +def _pick(pattern: re.Pattern[str], text: str, default: str = "?") -> str: + match = pattern.search(text) + return match.group(1) if match else default + + +def selected_adapter(text: str) -> dict[str, str] | None: + """只取 `Selected:` 那一行:`Available adapters: [...]` 里有 12 条 `AdapterInfo`,取错就全错。""" + match = SELECTED_RE.search(text) + if match is None: + return None + body = match.group("body") + return { + "name": _pick(ADAPTER_NAME_RE, body), + "device_type": _pick(DEVICE_TYPE_RE, body), + "backend": _pick(ADAPTER_BACKEND_RE, body), + } + + +def backend_final(text: str) -> dict[str, str] | None: + match = BACKEND_FINAL_RE.search(text) + if match is None: + return None + via = VIA_RE.search(match.group("rest")) + return {"final": match.group(1), "result": match.group(2), "via": via.group(1) if via else ""} + + +def decode_shot(png_path: Path, report: Report) -> tuple[int, int, dict | None]: + """解码 PNG 并核对像素数,返回 (宽, 高, 统计)。读不出就记一条失败并返回 0 尺寸。""" + try: + width, height, rows, channels = decode_png(png_path) + except (PngError, OSError, zlib.error) as error: + report.add(3, False, f"读不出 {png_path}:{error}") + return 0, 0, None + pixels = width * height + report.add(3, pixels > 0, f"{width}x{height} = {pixels} 像素") + return (width, height, measure(width, height, rows, channels)) if pixels > 0 else (width, height, None) + + +def judge_evidence(log_path: Path, png_path: Path) -> Report: + """正例口径:这份日志 + 这张 PNG 能不能当「GPU 档真的出了这一帧」的证据。""" + report = Report() + try: + text = read_log(log_path) + except OSError as error: + report.add(1, False, f"日志读不出 {log_path}:{error}") + return report + + renderers = list(dict.fromkeys(shot_renderers(text))) + report.add( + 1, + "wgpu" in renderers, + "实际 renderer=" + "/".join(renderers) if renderers else "日志里没有 `[shot] … renderer=…` 行(没走到出图那一步?)", + ) + + adapter = selected_adapter(text) + if adapter is None: + report.add(2, False, "日志里没有 `Selected: AdapterInfo { … }`(wgpu 没建成设备,或只打印了 Available adapters)") + else: + report.add( + 2, + adapter["device_type"] not in DISALLOWED_DEVICE_TYPES, + f'device_type={adapter["device_type"]}({adapter["name"]} / {adapter["backend"]})', + ) + + width, height, stats = decode_shot(png_path, report) + + size = shot_size(text) + if size is None: + report.add(4, False, "日志里没有 `[shot] WxH …` 出图行,尺寸无从对账") + elif width == 0: + report.add(4, False, f"PNG 没解出来,无法核对日志声明的 {size[0]}x{size[1]}") + else: + report.add(4, (width, height) == size, f"日志 {size[0]}x{size[1]} vs PNG {width}x{height}") + + final = backend_final(text) + if final is None: + report.add(5, False, "日志里没有 `[backend] final=…` 行(ULL_ACCEPTANCE=1 才有)") + else: + report.add( + 5, + final["final"] == "wgpu" and final["result"] == "started", + f"final={final['final']} result={final['result']}" + (f" via={final['via']}" if final["via"] else ""), + ) + + if stats is None: + report.add(6, False, "上一步没解出像素,无从统计") + else: + blank = verdict(stats, DEFAULT_MIN_UNIQUE, DEFAULT_MIN_STDDEV, DEFAULT_MIN_EDGE_RATIO) + report.add( + 6, + not blank, + f"唯一色 {stats['unique_colors']} 亮度σ {stats['luma_stddev']} 强边缘 {stats['edge_ratio']}", + ) + return report + + +def judge_refuse(log_path: Path, png_path: Path | None) -> Report: + """负例口径:这次启动是不是**确实拒绝**降级出图(而不是悄悄换后端出了一张)。""" + report = Report() + try: + text = read_log(log_path) + except OSError as error: + report.add(7, False, f"日志读不出 {log_path}:{error}") + return report + + final = backend_final(text) + if final is None: + report.add(7, False, "日志里没有 `[backend] final=…` 行") + else: + report.add(7, final["via"] == "refuse", f"final={final['final']} result={final['result']} via={final['via'] or '<缺>'}") + + renderers = list(dict.fromkeys(shot_renderers(text))) + size = shot_size(text) + report.add(8, not renderers and size is None, "存在出图行:renderer=" + "/".join(renderers) if renderers else "") + + if png_path is None: + return report + if png_path.exists(): + report.add(9, False, f"{png_path} 存在({png_path.stat().st_size} 字节)") + else: + report.add(9, True, f"{png_path} 不存在") + return report + + +def main(argv: list[str]) -> int: + args = list(argv) + positional: list[str] = [] + expect_refuse = False + while args: + arg = args.pop(0) + if arg == "--expect-refuse": + expect_refuse = True + elif arg == "--self-test": + return self_test() + elif arg in ("-h", "--help"): + print(__doc__) + return 0 + elif arg.startswith("-"): + print(f"::error::不认识的参数:{arg}", file=sys.stderr) + return 2 + else: + positional.append(arg) + + if not positional: + print("::error::至少要给一份 stderr 日志(--help 看用法)", file=sys.stderr) + return 2 + if len(positional) > 2: + print(f"::error::最多两个位置参数(日志 + PNG),实际 {len(positional)} 个", file=sys.stderr) + return 2 + log_path = Path(positional[0]) + png_path = Path(positional[1]) if len(positional) == 2 else None + if png_path is None and not expect_refuse: + print("::error::正例必须给出那张 PNG(只有负例可用 --expect-refuse 省略)", file=sys.stderr) + return 2 + + report = judge_refuse(log_path, png_path) if expect_refuse else judge_evidence(log_path, png_path) + report.print_report(log_path, png_path) + return 1 if report.problems() else 0 + + +# ────────────────────────── 自测:合成日志 + 合成 PNG ────────────────────────── +# +# 向量在 `packaging/tests/gpu_shot_vectors.json`(数据与代码分开,同 guard_vectors.json 的做法), +# 每条的 `expect_fail` 是**期望的失败判据清单**,逐条对:关掉一条判据、或让某条判据被别的东西 +# 掩盖,清单就对不上、本节报红。真图不在仓库里(`DevDocs/` 被 ignore),所以这颗钉子不能指望 +# 真实产物——它守的是判据本身,真实产物由报告里的实跑退出码守。 + +VECTORS_PATH = Path(__file__).resolve().parent / "tests" / "gpu_shot_vectors.json" + + +def synth_log( + *, + renderer: str | None = "wgpu", + device_type: str | None = "DiscreteGpu", + backend: str | None = "final=wgpu result=started", + size: tuple[int, int] | None = (200, 120), +) -> str: + """按 M246 真实日志的**行格式**拼一份最小日志(传 `None` = 该行故意不出现)。""" + lines = [ + "[probe] renderer = wgpu reason=forced-gpu 兜底=tiny-skia(失败会先打印具名错误)", + # 真实日志里永远有这一行提示文本,且它含 `Selected: AdapterInfo` 字样 —— 留着它, + # 「只有提示、没有真 Selected 行」的向量才是在测真东西。 + "[probe] 取证:出现 `[gpu] iced_wgpu: …Selected: AdapterInfo…` ⇒ GPU 真生效;出现 `[gpu] wgpu 初始化失败` ⇒ 已降级 CPU", + ] + if device_type is not None: + lines.append( + '[gpu] iced_wgpu::window::compositor: Selected: AdapterInfo { name: "AMD Radeon RX 6600 XT",' + ' vendor: 4098, device: 29695, device_type: %s, driver: "AMD proprietary driver",' + ' driver_info: "22.8.2", backend: Vulkan, }' % device_type + ) + if size is not None: + lines.append(f"[shot] {size[0]}x{size[1]} scale=1 -> selftest.png") + if renderer is not None: + lines.append(f'[shot] screen=gallery theme=light renderer={renderer} route=Detail("survival")') + if backend is not None: + lines.append(f"[backend] {backend} elapsed=1000ms evidence=selftest") + return "\n".join(lines) + "\n" + + +def synth_png(kind: str, size: tuple[int, int]) -> bytes: + """`content` = 有内容的合成帧(渐变 + 8px 棋盘格);`flat` = 全白。""" + width, height = size + + def content_row(y: int) -> bytes: + return b"".join( + bytes((x * 255 // max(width - 1, 1), y * 255 // max(height - 1, 1), 40)) + if ((x // 8) + (y // 8)) % 2 == 0 + else b"\x14\x14\x14" + for x in range(width) + ) + + if kind == "flat": + return encode_png(width, height, lambda _y: b"\xff" * (width * 3)) + return encode_png(width, height, content_row) + + +def run_vector(root: Path, index: int, case: dict, default_size: tuple[int, int]) -> Report: + overrides = {key: (tuple(value) if isinstance(value, list) else value) for key, value in case.get("log", {}).items()} + log_path = root / f"case{index:02d}.log" + log_path.write_text(synth_log(**{"size": default_size, **overrides}), encoding="utf-8") + png_path = root / f"case{index:02d}.png" + png_size = tuple(case.get("png_size", default_size)) + if case.get("png") == "truncated": + png_path.write_bytes(synth_png("content", png_size)[:200]) + elif case.get("png") is not None: + png_path.write_bytes(synth_png(case["png"], png_size)) + return judge_refuse(log_path, png_path) if case.get("mode") == "refuse" else judge_evidence(log_path, png_path) + + +def self_test() -> int: + """跑 `tests/gpu_shot_vectors.json` 的全部向量,逐条对 `expect_fail`。零第三方依赖。""" + data = json.loads(VECTORS_PATH.read_text(encoding="utf-8")) + default_size = tuple(data["size"]) + failures = 0 + with tempfile.TemporaryDirectory(prefix="ull-gpu-shot-") as tmp: + root = Path(tmp) + for index, case in enumerate(data["cases"], start=1): + got = run_vector(root, index, case, default_size).problems() + want = case["expect_fail"] + if got != want: + print(f"::error::自测失败:{case['label']} 期望失败 {want},实际 {got}", file=sys.stderr) + failures += 1 + else: + print(f"OK: {case['label']} → {'红 ' + ' / '.join(got) if got else '绿'}") + red = sum(1 for case in data["cases"] if case["expect_fail"]) + if failures: + return 1 + print(f"OK: GPU 截图证据判定自测通过({len(data['cases'])} 条向量:红 {red} / 绿 {len(data['cases']) - red})") + return 0 + + +if __name__ == "__main__": + for stream in (sys.stdout, sys.stderr): + if hasattr(stream, "reconfigure"): + stream.reconfigure(errors="backslashreplace") + sys.exit(main(sys.argv[1:])) diff --git a/packaging/docs/gpu-evidence.md b/packaging/docs/gpu-evidence.md new file mode 100644 index 0000000..3ba45f6 --- /dev/null +++ b/packaging/docs/gpu-evidence.md @@ -0,0 +1,129 @@ +# 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 # 正例:六条判据,0/1 退出 +python packaging/check_gpu_shot.py --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`。 diff --git a/packaging/tests/gpu_shot_vectors.json b/packaging/tests/gpu_shot_vectors.json new file mode 100644 index 0000000..be6e92b --- /dev/null +++ b/packaging/tests/gpu_shot_vectors.json @@ -0,0 +1,102 @@ +{ + "note": "M249 check_gpu_shot.py --self-test 的向量。log = synth_log 的关键字覆盖(缺省键取脚本默认值,null = 该行不出现);png = content/flat/truncated/null(null = 不写文件);mode = evidence/refuse;expect_fail = **期望的失败判据清单(逐条对,多一条少一条都算自测失败)**——判据被关掉或被别的东西掩盖,都在这里现形。PNG 由 check_screenshot.encode_png 现造,不进仓库。", + "size": [200, 120], + "cases": [ + { + "label": "基线:wgpu + DiscreteGpu + 有内容 PNG", + "png": "content", + "expect_fail": [] + }, + { + "label": "基线:IntegratedGpu 也算硬件(与 probe.rs 同口径)", + "log": { "device_type": "IntegratedGpu" }, + "png": "content", + "expect_fail": [] + }, + { + "label": "变异靶子:renderer=wgpu 但 Selected 是 Cpu(WARP)", + "log": { "device_type": "Cpu" }, + "png": "content", + "expect_fail": ["Selected: AdapterInfo 的 device_type 不是 Cpu/Other"] + }, + { + "label": "变异靶子:Selected 是 Other(Gl 兼容层)", + "log": { "device_type": "Other" }, + "png": "content", + "expect_fail": ["Selected: AdapterInfo 的 device_type 不是 Cpu/Other"] + }, + { + "label": "变异靶子:删掉 [shot] renderer 行(只剩 Selected + backend)", + "log": { "renderer": null }, + "png": "content", + "expect_fail": ["截图行 renderer=wgpu"] + }, + { + "label": "回归:CPU 档出的图(renderer=tiny-skia)", + "log": { "renderer": "tiny-skia", "device_type": null, "backend": "final=tiny-skia result=started" }, + "png": "content", + "expect_fail": ["截图行 renderer=wgpu", "Selected: AdapterInfo 的 device_type 不是 Cpu/Other", "后端结论行 final=wgpu result=started"] + }, + { + "label": "回归:没有 Selected 行(wgpu 没建成设备)", + "log": { "device_type": null }, + "png": "content", + "expect_fail": ["Selected: AdapterInfo 的 device_type 不是 Cpu/Other"] + }, + { + "label": "回归:后端结论行是失败", + "log": { "backend": "final=none result=failed via=refuse" }, + "png": "content", + "expect_fail": ["后端结论行 final=wgpu result=started"] + }, + { + "label": "回归:完全空白帧(全白 PNG)", + "png": "flat", + "expect_fail": ["PNG 不是空白帧"] + }, + { + "label": "回归:PNG 尺寸与日志 [shot] 不符", + "png": "content", + "png_size": [640, 480], + "expect_fail": ["PNG 宽高 = 日志 [shot] 的宽高"] + }, + { + "label": "回归:PNG 根本不存在", + "png": null, + "expect_fail": ["PNG 能解码且非空", "PNG 宽高 = 日志 [shot] 的宽高", "PNG 不是空白帧"] + }, + { + "label": "回归:PNG 半截(签名对、字节不够)", + "png": "truncated", + "expect_fail": ["PNG 能解码且非空", "PNG 宽高 = 日志 [shot] 的宽高", "PNG 不是空白帧"] + }, + { + "label": "负例基线:via=refuse + 无出图行 + 无 PNG", + "log": { "renderer": null, "device_type": null, "size": null, "backend": "final=none result=failed via=refuse" }, + "png": null, + "mode": "refuse", + "expect_fail": [] + }, + { + "label": "负例回归:拒绝日志旁边却躺着一张 PNG", + "log": { "renderer": null, "device_type": null, "size": null, "backend": "final=none result=failed via=refuse" }, + "png": "content", + "mode": "refuse", + "expect_fail": ["目标 PNG 不存在(拒绝出图就该一张都没有)"] + }, + { + "label": "负例回归:CPU 档日志不是「拒绝」(via 缺)", + "log": { "renderer": "tiny-skia", "device_type": null, "backend": "final=tiny-skia result=started" }, + "png": null, + "mode": "refuse", + "expect_fail": ["后端结论行 via=refuse", "没有出图行([shot] 尺寸 + renderer 都不该有)"] + }, + { + "label": "负例回归:final=… via=refuse 却仍有出图行(自相矛盾)", + "log": { "backend": "final=none result=failed via=refuse" }, + "png": "content", + "mode": "refuse", + "expect_fail": ["没有出图行([shot] 尺寸 + renderer 都不该有)", "目标 PNG 不存在(拒绝出图就该一张都没有)"] + } + ] +} From d7a9ae15a54cb4b1acf679a32bc34e8349805308 Mon Sep 17 00:00:00 2001 From: worker-gpushot Date: Wed, 11 Nov 2026 13:03:13 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(packaging):=20=E8=B4=9F=E4=BE=8B?= =?UTF-8?q?=E5=88=A4=E6=8D=AE=208=20=E7=9A=84=E5=A4=B1=E8=B4=A5=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E8=A1=A5=E5=85=A8=EF=BC=88M249=20=E8=87=AA=E6=9F=A5?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原来只在「有 renderer= 行」时给详情;只有 `[shot] WxH` 行、没有 renderer 行的 日志会打出一条没有原因的 `[fail] 8.`——正是本脚本承诺不做的事。改成把两样 多余的出图痕迹都列出来(`存在出图行:renderer=…、[shot] 1180x760`)。 向量与真实样本复跑结论不变(16 条向量红 13 / 绿 3,A1/A2/A4 0、A3/A5/A6 1)。 --- packaging/check_gpu_shot.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packaging/check_gpu_shot.py b/packaging/check_gpu_shot.py index 1d220c4..16a666e 100644 --- a/packaging/check_gpu_shot.py +++ b/packaging/check_gpu_shot.py @@ -235,7 +235,10 @@ def judge_refuse(log_path: Path, png_path: Path | None) -> Report: renderers = list(dict.fromkeys(shot_renderers(text))) size = shot_size(text) - report.add(8, not renderers and size is None, "存在出图行:renderer=" + "/".join(renderers) if renderers else "") + surplus = (["renderer=" + "/".join(renderers)] if renderers else []) + ( + [f"[shot] {size[0]}x{size[1]}"] if size is not None else [] + ) + report.add(8, not surplus, "存在出图行:" + "、".join(surplus) if surplus else "") if png_path is None: return report From 9aefb6a5b085f43b5be71e2e4eff44eb90d63231 Mon Sep 17 00:00:00 2001 From: worker-gpushot Date: Wed, 11 Nov 2026 13:12:59 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(packaging):=20=E5=A0=B5=E4=B8=A4?= =?UTF-8?q?=E5=A4=84=E5=88=A4=E5=AE=9A=E5=81=87=E7=BB=BF=20+=20=E8=AF=81?= =?UTF-8?q?=E6=8D=AE=E8=B4=A6=E8=AF=BB=E6=95=B0=E5=8F=AF=E5=A4=8D=E8=B7=91?= =?UTF-8?q?=EF=BC=88M249=20r1=20P2-1/2/3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2-1 判据 2:`Selected: AdapterInfo {` 与字段不在同一行时(pretty-print Debug 漂移) `device_type` 取不到、旧写法默认 `"?"` 而 `"?" ∉ {Cpu,Other}` ⇒ 判绿。改成取不到即判红。 P2-2 判据 1:`"wgpu" in renderers` 是「曾经出现过」,把 CPU 那次的 stderr >> 追加进来 再配 CPU 那张图会判绿。改成 `set(renderers) == {"wgpu"}`(全部 [shot] 行都声称 wgpu)。 P2-3 doc §2:A6 `--expect-refuse` 那行的「3/3」需要传 PNG 路径才成立,按表复跑是 2/2; 命令列补上 PNG,并单列「不传 PNG = 2/2」一行。 行数:判据脚本 383 → 311(自测脚手架搬到 tests/gpu_shot_selftest.py,124 行;向量 16 → 19 条,红 16 / 绿 3,新增「多行 Debug」「空体」「混入异种 renderer」三条靶子)。 --- packaging/README.md | 5 +- packaging/check_gpu_shot.py | 130 ++++++-------------------- packaging/docs/gpu-evidence.md | 22 +++-- packaging/tests/gpu_shot_selftest.py | 124 ++++++++++++++++++++++++ packaging/tests/gpu_shot_vectors.json | 24 ++++- 5 files changed, 191 insertions(+), 114 deletions(-) create mode 100644 packaging/tests/gpu_shot_selftest.py diff --git a/packaging/README.md b/packaging/README.md index 4a9e93f..87638df 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -68,8 +68,9 @@ Git Bash 下会静默落进 Docker VM(脚本内部用 `cygpath -m` 归一) | `packaging/make_artifacts.py` | 打包与校验(本地 + CI 共用) | ≤400 | | `packaging/release-notes.md` | GitHub Release 说明模板(`{{VERSION}}` / `{{SUMS}}` 占位) | ≤400 | | `packaging/check_screenshot.py` | 截图非空白判定(CI 门禁 + 本地手动跑,零第三方依赖) | ≤400 | -| `packaging/check_gpu_shot.py` | GPU 截图证据判定(M249):`[shot] … renderer=wgpu` + `Selected: AdapterInfo` 的 `device_type ∉ {Cpu, Other}` + PNG 非空/尺寸对账/非空白,逐条点名;`--expect-refuse` 判拒绝降级出图;`--self-test` 跑 `tests/gpu_shot_vectors.json` 的 16 条向量。**尚未接进 CI**,口径与能证/不能证见 `docs/gpu-evidence.md` | ≤400 | -| `packaging/tests/gpu_shot_vectors.json` | `check_gpu_shot.py --self-test` 的向量:16 条(红 13 / 绿 3),每条给出**期望的失败判据清单**,数据与代码分开 | ≤400 | +| `packaging/check_gpu_shot.py` | GPU 截图证据判定(M249):`[shot] … renderer=wgpu` + `Selected: AdapterInfo` 的 `device_type ∉ {Cpu, Other}` + PNG 非空/尺寸对账/非空白,逐条点名;`--expect-refuse` 判拒绝降级出图;`--self-test` 跑 `tests/gpu_shot_vectors.json` 的 19 条向量(脚手架 `tests/gpu_shot_selftest.py`)。**尚未接进 CI**,口径与能证/不能证见 `docs/gpu-evidence.md` | ≤400 | +| `packaging/tests/gpu_shot_vectors.json` | `check_gpu_shot.py --self-test` 的向量:19 条(红 16 / 绿 3),每条给出**期望的失败判据清单**,数据与代码分开 | ≤400 | +| `packaging/tests/gpu_shot_selftest.py` | 上面那 19 条向量的执行器(按真实行格式手拼日志 + 用 `check_screenshot.encode_png` 现造 PNG):判据脚本自己守 ≤400 行,脚手架放这里 | ≤400 | | `packaging/check_publish_slots.py` | 发布名单守卫(M164/M184):名单 ⇄ 产物两个方向 + `mode: ci` 槽位无产物的警告;`--self-test` 注入 11 条回归 | ≤400 | | `packaging/check_pe_subsystem.py` | PE 子系统守卫(M196):**读产物 PE 头的 `Subsystem`**(GUI 必须 2 / CLI 必须 3),非 2 即红;`--self-test` 注入 8 条回归(红 6 / 绿 2)(见 §15) | ≤400 | | `packaging/check_test_source_off.py` | 测试通道守门(M169):workflow / 打包脚本 / `Cargo.toml default` 都不许打开 `update-test-source`;归属按 `jobs.*.steps[]` 列表项判、默认拒绝;`--self-test` 按 `tests/guard_vectors.json` 注入 | ≤400 | diff --git a/packaging/check_gpu_shot.py b/packaging/check_gpu_shot.py index 16a666e..8d4c08f 100644 --- a/packaging/check_gpu_shot.py +++ b/packaging/check_gpu_shot.py @@ -25,15 +25,13 @@ python packaging/check_gpu_shot.py shot.stderr.log shot.png # 正例:判定,0/1 退出 python packaging/check_gpu_shot.py a6.stderr.log --expect-refuse # 负例:判定拒绝是否成立 - python packaging/check_gpu_shot.py --self-test # 16 条向量(数据在 tests/) + python packaging/check_gpu_shot.py --self-test # 19 条向量(数据+脚手架都在 tests/) """ from __future__ import annotations -import json import re import sys -import tempfile import zlib from pathlib import Path @@ -44,7 +42,6 @@ from check_screenshot import ( # noqa: E402 DEFAULT_MIN_UNIQUE, PngError, decode_png, - encode_png, measure, verdict, ) @@ -122,20 +119,24 @@ def shot_size(text: str) -> tuple[int, int] | None: return (int(match.group(1)), int(match.group(2))) if match else None -def _pick(pattern: re.Pattern[str], text: str, default: str = "?") -> str: +def _pick(pattern: re.Pattern[str], text: str, default: str | None = "?") -> str | None: match = pattern.search(text) return match.group(1) if match else default -def selected_adapter(text: str) -> dict[str, str] | None: - """只取 `Selected:` 那一行:`Available adapters: [...]` 里有 12 条 `AdapterInfo`,取错就全错。""" +def selected_adapter(text: str) -> dict[str, str | None] | None: + """只取 `Selected:` 那一行:`Available adapters: [...]` 里有 12 条 `AdapterInfo`,取错就全错。 + + `device_type` 取不到时返回 `None`(**不是** `"?"`):一旦 iced 改成 pretty-print Debug, + 字段就落不到 `{` 同一行,此时按「取不到 = 判红」处理,不许静默给绿(M249 r1 P2-1)。 + """ match = SELECTED_RE.search(text) if match is None: return None body = match.group("body") return { "name": _pick(ADAPTER_NAME_RE, body), - "device_type": _pick(DEVICE_TYPE_RE, body), + "device_type": _pick(DEVICE_TYPE_RE, body, None), "backend": _pick(ADAPTER_BACKEND_RE, body), } @@ -170,9 +171,11 @@ def judge_evidence(log_path: Path, png_path: Path) -> Report: return report renderers = list(dict.fromkeys(shot_renderers(text))) + # 「这张图是 wgpu 出的」= 日志里**每一行** `[shot] … renderer=` 都说 wgpu。用 `in` 就退化成 + # 「曾经出现过」:把 CPU 那次的 stderr `>>` 追加进来、配 CPU 那张图,会静默判绿(M249 r1 P2-2)。 report.add( 1, - "wgpu" in renderers, + set(renderers) == {"wgpu"}, "实际 renderer=" + "/".join(renderers) if renderers else "日志里没有 `[shot] … renderer=…` 行(没走到出图那一步?)", ) @@ -180,10 +183,15 @@ def judge_evidence(log_path: Path, png_path: Path) -> Report: if adapter is None: report.add(2, False, "日志里没有 `Selected: AdapterInfo { … }`(wgpu 没建成设备,或只打印了 Available adapters)") else: + device_type = adapter["device_type"] + # `None` = 字段不在 `{` 同一行(多行 Debug 漂移)或字段缺失:按取不到即失败处理, + # 宁可假红也不许「格式一变就静默给绿」。 report.add( 2, - adapter["device_type"] not in DISALLOWED_DEVICE_TYPES, - f'device_type={adapter["device_type"]}({adapter["name"]} / {adapter["backend"]})', + device_type not in (None, "?") and device_type not in DISALLOWED_DEVICE_TYPES, + f'device_type={device_type}({adapter["name"]} / {adapter["backend"]})' + if device_type not in (None, "?") + else "取不到 device_type:`Selected: AdapterInfo {` 与字段不在同一行(多行 Debug),或字段缺失", ) width, height, stats = decode_shot(png_path, report) @@ -258,7 +266,10 @@ def main(argv: list[str]) -> int: if arg == "--expect-refuse": expect_refuse = True elif arg == "--self-test": - return self_test() + sys.path.insert(0, str(Path(__file__).resolve().parent / "tests")) + from gpu_shot_selftest import run_selftest # 延迟 import:脚手架反过来要拿本模块的判据函数 + + return run_selftest(judge_evidence, judge_refuse) elif arg in ("-h", "--help"): print(__doc__) return 0 @@ -285,96 +296,13 @@ def main(argv: list[str]) -> int: return 1 if report.problems() else 0 -# ────────────────────────── 自测:合成日志 + 合成 PNG ────────────────────────── +# ────────────────────────── 自测 ────────────────────────── # -# 向量在 `packaging/tests/gpu_shot_vectors.json`(数据与代码分开,同 guard_vectors.json 的做法), -# 每条的 `expect_fail` 是**期望的失败判据清单**,逐条对:关掉一条判据、或让某条判据被别的东西 -# 掩盖,清单就对不上、本节报红。真图不在仓库里(`DevDocs/` 被 ignore),所以这颗钉子不能指望 -# 真实产物——它守的是判据本身,真实产物由报告里的实跑退出码守。 - -VECTORS_PATH = Path(__file__).resolve().parent / "tests" / "gpu_shot_vectors.json" - - -def synth_log( - *, - renderer: str | None = "wgpu", - device_type: str | None = "DiscreteGpu", - backend: str | None = "final=wgpu result=started", - size: tuple[int, int] | None = (200, 120), -) -> str: - """按 M246 真实日志的**行格式**拼一份最小日志(传 `None` = 该行故意不出现)。""" - lines = [ - "[probe] renderer = wgpu reason=forced-gpu 兜底=tiny-skia(失败会先打印具名错误)", - # 真实日志里永远有这一行提示文本,且它含 `Selected: AdapterInfo` 字样 —— 留着它, - # 「只有提示、没有真 Selected 行」的向量才是在测真东西。 - "[probe] 取证:出现 `[gpu] iced_wgpu: …Selected: AdapterInfo…` ⇒ GPU 真生效;出现 `[gpu] wgpu 初始化失败` ⇒ 已降级 CPU", - ] - if device_type is not None: - lines.append( - '[gpu] iced_wgpu::window::compositor: Selected: AdapterInfo { name: "AMD Radeon RX 6600 XT",' - ' vendor: 4098, device: 29695, device_type: %s, driver: "AMD proprietary driver",' - ' driver_info: "22.8.2", backend: Vulkan, }' % device_type - ) - if size is not None: - lines.append(f"[shot] {size[0]}x{size[1]} scale=1 -> selftest.png") - if renderer is not None: - lines.append(f'[shot] screen=gallery theme=light renderer={renderer} route=Detail("survival")') - if backend is not None: - lines.append(f"[backend] {backend} elapsed=1000ms evidence=selftest") - return "\n".join(lines) + "\n" - - -def synth_png(kind: str, size: tuple[int, int]) -> bytes: - """`content` = 有内容的合成帧(渐变 + 8px 棋盘格);`flat` = 全白。""" - width, height = size - - def content_row(y: int) -> bytes: - return b"".join( - bytes((x * 255 // max(width - 1, 1), y * 255 // max(height - 1, 1), 40)) - if ((x // 8) + (y // 8)) % 2 == 0 - else b"\x14\x14\x14" - for x in range(width) - ) - - if kind == "flat": - return encode_png(width, height, lambda _y: b"\xff" * (width * 3)) - return encode_png(width, height, content_row) - - -def run_vector(root: Path, index: int, case: dict, default_size: tuple[int, int]) -> Report: - overrides = {key: (tuple(value) if isinstance(value, list) else value) for key, value in case.get("log", {}).items()} - log_path = root / f"case{index:02d}.log" - log_path.write_text(synth_log(**{"size": default_size, **overrides}), encoding="utf-8") - png_path = root / f"case{index:02d}.png" - png_size = tuple(case.get("png_size", default_size)) - if case.get("png") == "truncated": - png_path.write_bytes(synth_png("content", png_size)[:200]) - elif case.get("png") is not None: - png_path.write_bytes(synth_png(case["png"], png_size)) - return judge_refuse(log_path, png_path) if case.get("mode") == "refuse" else judge_evidence(log_path, png_path) - - -def self_test() -> int: - """跑 `tests/gpu_shot_vectors.json` 的全部向量,逐条对 `expect_fail`。零第三方依赖。""" - data = json.loads(VECTORS_PATH.read_text(encoding="utf-8")) - default_size = tuple(data["size"]) - failures = 0 - with tempfile.TemporaryDirectory(prefix="ull-gpu-shot-") as tmp: - root = Path(tmp) - for index, case in enumerate(data["cases"], start=1): - got = run_vector(root, index, case, default_size).problems() - want = case["expect_fail"] - if got != want: - print(f"::error::自测失败:{case['label']} 期望失败 {want},实际 {got}", file=sys.stderr) - failures += 1 - else: - print(f"OK: {case['label']} → {'红 ' + ' / '.join(got) if got else '绿'}") - red = sum(1 for case in data["cases"] if case["expect_fail"]) - if failures: - return 1 - print(f"OK: GPU 截图证据判定自测通过({len(data['cases'])} 条向量:红 {red} / 绿 {len(data['cases']) - red})") - return 0 - +# 向量数据在 `packaging/tests/gpu_shot_vectors.json`,造样本与逐条比对的脚手架在 +# `packaging/tests/gpu_shot_selftest.py`(判据脚本自己守 ≤400 行,脚手架不占这里的行数)。 +# 每条的 `expect_fail` 是**期望的失败判据清单**,逐条对:关掉一条判据、或让某条判据被别的 +# 东西掩盖,清单就对不上。真图不在仓库里(`DevDocs/` 被 ignore),所以这颗钉子守判据,真实 +# 产物由报告里的实跑退出码守。 if __name__ == "__main__": for stream in (sys.stdout, sys.stderr): diff --git a/packaging/docs/gpu-evidence.md b/packaging/docs/gpu-evidence.md index 3ba45f6..ad7c2b0 100644 --- a/packaging/docs/gpu-evidence.md +++ b/packaging/docs/gpu-evidence.md @@ -1,6 +1,6 @@ # GPU 截图证据账(M249) -判据的**唯一实现**是 `packaging/check_gpu_shot.py`(零第三方依赖,16 条自测向量)。 +判据的**唯一实现**是 `packaging/check_gpu_shot.py`(零第三方依赖,19 条自测向量)。 样本是 M246 在本机的实测产物 `DevDocs/artifacts/gpu-probe/`(`DevDocs/` 被 `.gitignore:6` 忽略, **图不进仓库**,所以这里只引它的读数,不引它的字节)。 @@ -12,7 +12,7 @@ ```text python packaging/check_gpu_shot.py # 正例:六条判据,0/1 退出 python packaging/check_gpu_shot.py --expect-refuse [PNG] # 负例:拒绝降级出图是否成立 -python packaging/check_gpu_shot.py --self-test # 16 条向量(数据在 tests/gpu_shot_vectors.json) +python packaging/check_gpu_shot.py --self-test # 19 条向量(数据 tests/gpu_shot_vectors.json,脚手架 tests/gpu_shot_selftest.py) ``` 退出码:`0` = 判据全过;`1` = 有判据没过(**逐条点名**,不只印 FAILED);`2` = 用法错(少参数 / 认不出的参数)。 @@ -32,7 +32,8 @@ python packaging/check_gpu_shot.py --self-test # 16 条向 | 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 不存在 | +| A6 负例,`--expect-refuse`(传 PNG 路径) | `a6-negative-refuse.stderr.log --expect-refuse a6-negative-refuse.png` | **0** | **3/3** 过:`via=refuse`、无出图行、目标 PNG 不存在 | +| A6 负例,`--expect-refuse`(不传 PNG) | `a6-negative-refuse.stderr.log --expect-refuse` | **0** | **2/2** 过(判据 9 不登记:`--expect-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) | @@ -45,8 +46,8 @@ A6 的负例形状(`WGPU_BACKEND=metal`):`Available adapters: []` → `wgp | # | 判据 | 抓得住 | 抓不住 | |---|------|--------|--------| -| 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` 那条 —— 也就抓不住「枚举里有独显、实际选了集显」之外的更细的驱动行为 | +| 1 | **全部** `[shot] … renderer=` 行都是 `wgpu`(`set(renderers) == {"wgpu"}`) | 「其实走了 CPU 档」——CPU 档在这行写 `renderer=tiny-skia`(M97 起这行打的是**实际**后端,不是请求值,见 `crates/app-iced/src/renderer/mod.rs:52`) | 只证明这些行被打印过;日志被手工拼装、或从别的进程拷来的行,它分不清(判据只读文本);「一次运行里混了两种 renderer」(`>>` 拼接、`via=respawn` 降级)**一律判红**,但**同种 renderer 的多次运行拼在一起**看不出来(§5 第 7 条) | +| 2 | `Selected: AdapterInfo { device_type: X }` 且 `X ∉ {Cpu, Other}`;**取不到 `X` 一律判红** | 「wgpu 真建成了设备,而且选中的不是 WARP(`Cpu`) / Gl 兼容层(`Other`)」;与 `crates/app-iced/src/probe.rs:165-167` 的硬件判据同口径 | 选中的是**哪一块**卡不判(独显 vs 集显都算硬件);不看 `Available adapters` 那 12 条,只看 `Selected` 那条 —— 也就抓不住「枚举里有独显、实际选了集显」之外的更细的驱动行为;**多行 Debug / 字段缺失**时不知道真值是啥,只按「取不到即判红」处理(宁可假红:iced 换成 pretty-print Debug 时真 GPU 图会被判红,得回来改正则) | | 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 那种「一行都没出图」和「出了图但日志没写」分不清 | @@ -63,7 +64,8 @@ A6 的负例形状(`WGPU_BACKEND=metal`):`Available adapters: []` → `wgp | 变异 | 抓它的向量 | 结果 | |------|-----------|------| | `DISALLOWED_DEVICE_TYPES` 里删掉 `Cpu`(= 允许 WARP 当 GPU) | 「renderer=wgpu 但 Selected 是 Cpu」 | 由红转绿 ⇒ 清单对不上 ⇒ `--self-test` 退出 1 | -| 删掉判据 1(不再要求 `renderer=wgpu`) | 「删掉 [shot] renderer 行」 | 由红转绿 ⇒ `--self-test` 退出 1 | +| 删掉判据 1(不再要求 `renderer=wgpu`) | 「删掉 [shot] renderer 行」、「日志里混入第二种 renderer」 | 由红转绿 ⇒ 清单对不上 ⇒ `--self-test` 退出 1 | +| 判据 2 不再把「取不到 device_type」当失败(恢复旧写法) | 「Selected 换成多行 Debug」、「`Selected: AdapterInfo {` 空体」 | 由红转绿 ⇒ 清单对不上 ⇒ `--self-test` 退出 1 | ## 4. 这份证据**能**证明什么 @@ -106,9 +108,13 @@ A6 的负例形状(`WGPU_BACKEND=metal`):`Available adapters: []` → `wgp 6. **换机器不成立。** 全部证据来自一台机器(RX 6600 XT / Raphael iGPU、12 个 adapter、RDP 断开会话)。 `VirtualGpu` 会不会被判成硬件只在**代码层**确认(`probe.rs:165-167` 会放行),**没在真 `VirtualGpu` 适配器上验过**(本机 12 条里一条都没有)。 -7. **交互启动那一支没验。** A6 验的是「取证启动(`--screenshot`)下拒绝降级出图」;交互启动的 +7. **同种 renderer 的多次运行拼在一起,无法分辨图属于哪一次。** 判据 1 只挡住「混入**异种** renderer」 + (wgpu + tiny-skia 拼一起);两次都是 `renderer=wgpu` 的日志 `>>` 到同一个文件时所有行都自洽, + 判据 4 又只比尺寸 ⇒ 两张同为 1180×760 的 GPU 图互换仍然判绿。要堵它得把 PNG 的 sha256 / 运行 id + 打进日志(改 Rust)。⇒ 一次取证 = 一份干净的 stderr,别往同一个文件里续写。 +8. **交互启动那一支没验。** A6 验的是「取证启动(`--screenshot`)下拒绝降级出图」;交互启动的 `via=respawn`(换进程降级)要求真窗口,本轮没开。 -8. **dark 主题、其它屏、`ULL_WINDOW_SIZE` 变尺寸、macOS / Linux** 全部没跑:只有 gallery + shell 的 light。 +9. **dark 主题、其它屏、`ULL_WINDOW_SIZE` 变尺寸、macOS / Linux** 全部没跑:只有 gallery + shell 的 light。 ## 6. 历史 `--diff` 基线:**永久不可复核** diff --git a/packaging/tests/gpu_shot_selftest.py b/packaging/tests/gpu_shot_selftest.py new file mode 100644 index 0000000..4341be0 --- /dev/null +++ b/packaging/tests/gpu_shot_selftest.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""`packaging/check_gpu_shot.py --self-test` 的执行器(M249):合成日志 + 合成 PNG + 向量比对。 + +为什么单独一份而不是塞进判据脚本:那一份要守 ≤400 行硬线,而这里全是「按真实行格式手拼日志、 +现造 PNG、逐条对 `expect_fail`」的**测试脚手架**,与判据逻辑无关。数据另外放 +`gpu_shot_vectors.json`(同 `guard_vectors.json` 的做法),被测的判据函数由调用方**以参数传入** +——本文件不反向 import `check_gpu_shot`,免得循环导入。 + +一颗诚实的钉子:本文件的样本是**按今天的日志格式**手拼的,所以它守**判据**,不守**格式漂移**。 +产品真换了 Debug 形状时,靠的是判据里「取不到 device_type 就算失败」这类保守口径(见 doc §3)。 +""" + +from __future__ import annotations + +import json +import sys +import tempfile +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) # `check_screenshot` 在 packaging/ +from check_screenshot import encode_png # noqa: E402 + +#: 向量数据。`log` 是 `synth_log` 的关键字覆盖(缺省键取函数默认值,`null` = 该行不出现)。 +VECTORS_PATH = Path(__file__).resolve().parent / "gpu_shot_vectors.json" + +SELFTEST_SIZE = (200, 120) + + +def synth_log( + *, + renderer: str | None = "wgpu", + device_type: str | None = "DiscreteGpu", + backend: str | None = "final=wgpu result=started", + size: tuple[int, int] | None = None, + no_size_line: bool = False, + multiline_selected: bool = False, + empty_selected: bool = False, + extra_shot_renderer: str | None = None, +) -> str: + """按 M246 真实日志的**行格式**拼一份最小日志(传 `None` = 该行故意不出现)。""" + lines = [ + "[probe] renderer = wgpu reason=forced-gpu 兜底=tiny-skia(失败会先打印具名错误)", + # 真实日志里永远有这一行提示文本,且它含 `Selected: AdapterInfo` 字样 —— 留着它, + # 「只有提示、没有真 Selected 行」的向量才是在测真东西。 + "[probe] 取证:出现 `[gpu] iced_wgpu: …Selected: AdapterInfo…` ⇒ GPU 真生效;出现 `[gpu] wgpu 初始化失败` ⇒ 已降级 CPU", + ] + if empty_selected: # `Selected: AdapterInfo {` 之后再没有内容(字段全丢) + lines.append("[gpu] iced_wgpu::window::compositor: Selected: AdapterInfo {") + elif device_type is not None: + fields = [ + ' name: "AMD Radeon RX 6600 XT",', + f" device_type: {device_type},", + ' backend: Vulkan,', + ] + if multiline_selected: # pretty-print Debug:字段不在 `{` 那一行(P2-1 的形状) + lines.append("[gpu] iced_wgpu::window::compositor: Selected: AdapterInfo {") + lines.extend(fields) + lines.append("}") + else: + lines.append("[gpu] iced_wgpu::window::compositor: Selected: AdapterInfo {" + "".join(fields) + " }") + if not no_size_line: + dims = size or SELFTEST_SIZE + lines.append(f"[shot] {dims[0]}x{dims[1]} scale=1 -> selftest.png") + for value in (renderer, extra_shot_renderer): + if value is not None: + lines.append(f'[shot] screen=gallery theme=light renderer={value} route=Detail("survival")') + if backend is not None: + lines.append(f"[backend] {backend} elapsed=1000ms evidence=selftest") + return "\n".join(lines) + "\n" + + +def synth_png(kind: str, size: tuple[int, int]) -> bytes: + """`content` = 有内容的合成帧(渐变 + 8px 棋盘格);`flat` = 全白。""" + width, height = size + + def content_row(y: int) -> bytes: + return b"".join( + bytes((x * 255 // max(width - 1, 1), y * 255 // max(height - 1, 1), 40)) + if ((x // 8) + (y // 8)) % 2 == 0 + else b"\x14\x14\x14" + for x in range(width) + ) + + if kind == "flat": + return encode_png(width, height, lambda _y: b"\xff" * (width * 3)) + return encode_png(width, height, content_row) + + +def run_vector(root: Path, index: int, case: dict, default_size: tuple[int, int], judges: tuple) -> list[str]: + """跑一条向量,返回它实际红的判据清单。`judges` = (judge_evidence, judge_refuse)。""" + judge_evidence, judge_refuse = judges + overrides = {key: (tuple(value) if isinstance(value, list) else value) for key, value in case.get("log", {}).items()} + log_path = root / f"case{index:02d}.log" + log_path.write_text(synth_log(**{"size": default_size, **overrides}), encoding="utf-8") + png_path = root / f"case{index:02d}.png" + png_size = tuple(case.get("png_size", default_size)) + if case.get("png") == "truncated": + png_path.write_bytes(synth_png("content", png_size)[:200]) + elif case.get("png") is not None: + png_path.write_bytes(synth_png(case["png"], png_size)) + report = judge_refuse(log_path, png_path) if case.get("mode") == "refuse" else judge_evidence(log_path, png_path) + return report.problems() + + +def run_selftest(judge_evidence, judge_refuse) -> int: + """跑 `gpu_shot_vectors.json` 的全部向量,逐条对 `expect_fail`(多一条少一条都算失败)。""" + data = json.loads(VECTORS_PATH.read_text(encoding="utf-8")) + default_size = tuple(data["size"]) + failures = 0 + with tempfile.TemporaryDirectory(prefix="ull-gpu-shot-") as tmp: + root = Path(tmp) + for index, case in enumerate(data["cases"], start=1): + got = run_vector(root, index, case, default_size, (judge_evidence, judge_refuse)) + want = case["expect_fail"] + if got != want: + print(f"::error::自测失败:{case['label']} 期望失败 {want},实际 {got}", file=sys.stderr) + failures += 1 + else: + print(f"OK: {case['label']} → {'红 ' + ' / '.join(got) if got else '绿'}") + red = sum(1 for case in data["cases"] if case["expect_fail"]) + if failures: + return 1 + print(f"OK: GPU 截图证据判定自测通过({len(data['cases'])} 条向量:红 {red} / 绿 {len(data['cases']) - red})") + return 0 diff --git a/packaging/tests/gpu_shot_vectors.json b/packaging/tests/gpu_shot_vectors.json index be6e92b..90cee4e 100644 --- a/packaging/tests/gpu_shot_vectors.json +++ b/packaging/tests/gpu_shot_vectors.json @@ -1,5 +1,5 @@ { - "note": "M249 check_gpu_shot.py --self-test 的向量。log = synth_log 的关键字覆盖(缺省键取脚本默认值,null = 该行不出现);png = content/flat/truncated/null(null = 不写文件);mode = evidence/refuse;expect_fail = **期望的失败判据清单(逐条对,多一条少一条都算自测失败)**——判据被关掉或被别的东西掩盖,都在这里现形。PNG 由 check_screenshot.encode_png 现造,不进仓库。", + "note": "M249 check_gpu_shot.py --self-test 的向量。log = synth_log 的关键字覆盖(缺省键取函数默认值;null = 该行不出现;no_size_line = 只省掉 [shot] WxH 那一行;multiline_selected / empty_selected = `Selected: AdapterInfo {` 的字段换了形状;extra_shot_renderer = 再追加一行异种 renderer 的 [shot] 行);png = content/flat/truncated/null(null = 不写文件);mode = evidence/refuse;expect_fail = **期望的失败判据清单(逐条对,多一条少一条都算自测失败)**——判据被关掉或被别的东西掩盖,都在这里现形。PNG 由 check_screenshot.encode_png 现造,不进仓库。", "size": [200, 120], "cases": [ { @@ -25,12 +25,30 @@ "png": "content", "expect_fail": ["Selected: AdapterInfo 的 device_type 不是 Cpu/Other"] }, + { + "label": "变异靶子:Selected 换成多行 Debug(字段不在 { 那一行)", + "log": { "multiline_selected": true }, + "png": "content", + "expect_fail": ["Selected: AdapterInfo 的 device_type 不是 Cpu/Other"] + }, + { + "label": "变异靶子:`Selected: AdapterInfo {` 空体(字段全丢)", + "log": { "device_type": null, "empty_selected": true }, + "png": "content", + "expect_fail": ["Selected: AdapterInfo 的 device_type 不是 Cpu/Other"] + }, { "label": "变异靶子:删掉 [shot] renderer 行(只剩 Selected + backend)", "log": { "renderer": null }, "png": "content", "expect_fail": ["截图行 renderer=wgpu"] }, + { + "label": "变异靶子:日志里混入第二种 renderer(CPU 那次 >> 拼进来)", + "log": { "extra_shot_renderer": "tiny-skia" }, + "png": "content", + "expect_fail": ["截图行 renderer=wgpu"] + }, { "label": "回归:CPU 档出的图(renderer=tiny-skia)", "log": { "renderer": "tiny-skia", "device_type": null, "backend": "final=tiny-skia result=started" }, @@ -72,14 +90,14 @@ }, { "label": "负例基线:via=refuse + 无出图行 + 无 PNG", - "log": { "renderer": null, "device_type": null, "size": null, "backend": "final=none result=failed via=refuse" }, + "log": { "renderer": null, "device_type": null, "no_size_line": true, "backend": "final=none result=failed via=refuse" }, "png": null, "mode": "refuse", "expect_fail": [] }, { "label": "负例回归:拒绝日志旁边却躺着一张 PNG", - "log": { "renderer": null, "device_type": null, "size": null, "backend": "final=none result=failed via=refuse" }, + "log": { "renderer": null, "device_type": null, "no_size_line": true, "backend": "final=none result=failed via=refuse" }, "png": "content", "mode": "refuse", "expect_fail": ["目标 PNG 不存在(拒绝出图就该一张都没有)"] From cb061d1da223a314faf91bd2b49a023380a9a360 Mon Sep 17 00:00:00 2001 From: worker-gpushot Date: Wed, 11 Nov 2026 13:14:57 +0800 Subject: [PATCH 4/4] =?UTF-8?q?docs(packaging):=20=E5=88=A4=E6=8D=AE=201/2?= =?UTF-8?q?=20=E7=9A=84=E6=96=B0=E5=8F=A3=E5=BE=84=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E5=88=B0=20docstring=20=E4=B8=8E=E8=AF=81=E6=8D=AE=E8=B4=A6?= =?UTF-8?q?=EF=BC=88M249=20r1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 9aefb6a 改了行为没改说明:docstring 还写着判据 1 是「截图那一行 renderer=wgpu」 (现在是「全部 [shot] 行都是 wgpu」)、判据 2 只写「X 不在禁令里」(现在取不到也算 失败)。doc §3.1 也从「两个变异」改成三条。 --- packaging/check_gpu_shot.py | 8 +++++--- packaging/docs/gpu-evidence.md | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packaging/check_gpu_shot.py b/packaging/check_gpu_shot.py index 8d4c08f..694063a 100644 --- a/packaging/check_gpu_shot.py +++ b/packaging/check_gpu_shot.py @@ -8,9 +8,11 @@ 入参 = 一份 stderr 日志 + 一张 PNG(可选 `--expect-refuse` 走负例口径)。正例六条判据(每条抓得住 什么、抓不住什么见 `packaging/docs/gpu-evidence.md` §2): -1. 截图那一行 `[shot] … renderer=wgpu`——**不是** `[probe] renderer = wgpu`:后者只是启动前的意图, - M246 的 A6 负例里它照样打印 wgpu,却一个像素都没画。 -2. `Selected: AdapterInfo { … device_type: X }` 的 `X` 不在 `DISALLOWED_DEVICE_TYPES` 内。 +1. **全部** `[shot] … renderer=` 行都是 `wgpu`——不是「出现过」(把 CPU 那次的 stderr `>>` 拼进来 + 就会骗过旧写法),也**不是** `[probe] renderer = wgpu`:后者只是启动前的意图,M246 的 A6 负例里 + 它照样打印 wgpu,却一个像素都没画。 +2. `Selected: AdapterInfo { … device_type: X }` 的 `X` 不在 `DISALLOWED_DEVICE_TYPES` 内; + **取不到 `X` 也算失败**(多行 Debug 漂移不许静默给绿)。 3. PNG 能解码且像素数 > 0。 4. PNG 宽高 = 日志里 `[shot] W×H` 声明的宽高(把图和日志钉在一起)。 5. `[backend] final=wgpu result=started`(后端自己的结论行与上面几条不矛盾)。 diff --git a/packaging/docs/gpu-evidence.md b/packaging/docs/gpu-evidence.md index ad7c2b0..004dd96 100644 --- a/packaging/docs/gpu-evidence.md +++ b/packaging/docs/gpu-evidence.md @@ -59,7 +59,7 @@ A6 的负例形状(`WGPU_BACKEND=metal`):`Available adapters: []` → `wgp ### 3.1 判据对「行为被改坏」敏感(变异自证) `--self-test` 的向量在 `packaging/tests/gpu_shot_vectors.json`,每条的 `expect_fail` 是**期望的失败判据 -清单,逐条对**。两个变异都能被抓住: +清单,逐条对**。三个变异都能被抓住(本轮 r1 又补了判据 2 的「取不到即红」): | 变异 | 抓它的向量 | 结果 | |------|-----------|------|