Files
orisys-app/tools/scene_assets/probe_gl.py
T
ykcheungab 5cd92254e1 feat(studio-3d): 阶段 5 —— 发布保护(签名/可选加密/平台锁定)与性能长时报告
- What: 新增 `scene3d/protect.py`(封套 v2:Ed25519 签名 + 可选 AES-256-GCM,nonce/salt 每包唯一、
  元数据作 AAD、明文摘要进签名、KeyProvider 只问 get_content_key)与 `record/visualization3d.py`
  (session.json 的 visualization3d 块:写入版本化、读取时缺块=不支持);`package.py` 支持 v2
  (payload=plain 仅签名 / aes-gcm 签名+加密)并继续读 v1,新增解压比上限与"写入后自读回"校验;
  `contracts.py` 新增 PlatformLock/PlatformInfo/check_platform_lock、Sealing、platform_mismatch 诊断码;
  `Scene3DView` 在装配前查 OS/架构、拿到 capabilities 后查图形半区,违反即撤下场景并在面板显示原因;
  工具 `compile_scene.py` 新增 --sign-key/--write-verify-key/--encrypt-key-id/--key-file/--lock-from-probe,
  `validate_scene.py` 新增 --key-file/--key-id/--verify-key/--require-signature(发布门禁),
  新增 `bench_scene.py`(p50/p95 分阶段 + --cpu-only + --vertices N + --package)与
  `soak_scene.py`(RSS 四分位速率 + 队列上界 + 抽稀采样);两份报告落到 docs/architecture/。
- Why: 规格 §5.2 的保护目标是"提高复制成本"而不是"防提取",所以要的是签名(来源)+ 可选认证加密(机密性)
  + 密钥隔离,而不是自制加密或把 ZIP 当加密;§12 的"包保护/性能/长时"三行需要可复现的数字与判定标准,
  而不是一次性的观感。平台锁定还必须只降级三维:锁定不通过时二维照常。
- Verification: shared 全量通过(新增 test_scene3d_protect.py 35 项:封套往返/篡改/密钥/锁定/记录块/
  compile→validate 端到端发布流程;test_scene3d_package.py 20 项含 v1↔v2 兼容);apps/studio 全量通过
  (test_scene3d_view.py 30 项含锁定拒绝与 session.json 块);ui_check 566 项 0 失败;locale_check 0 缺失;
  真实资产端到端:1032 顶点编译出 signed+sealed+locked 包(56 927 B 密文),validate --require-signature 退出 0,
  改一个字节退出 2;性能报告(perf-report-windows-dev.json):1032 顶点 compose p50 0.50 ms(30 fps 上限内),
  20022 顶点/39480 三角形 compose p50 6.15 ms(约 33 ms 帧的 18%),采集线程 submit p95 < 0.4 ms(合成器被人为放慢到 50 ms);
  长时报告(soak-report-windows-dev.json):无窗口 10 min/60 fps 尾段 0.354 MB/min,GUI 4 min/30 fps 尾段 0.809 MB/min,
  队列上界(mailbox=1、单飞行合成)两者均成立。过程中修掉:GUI 进程里的合成器计时被 GIL/vsync 污染(98.8 ms vs 真值 6.1 ms)、
  跨线程读 last_stats 撕裂、key id 无 provider 时会静默写明文包、soak 工具自身记时间列表会无界增长、RSS 读取失败伪装成零增长、
  平台锁定不是终态诊断。
- Not in this change: ARM 板卡的能力报告与 2 小时门禁运行(按用户要求暂缓;命令与判定标准已写入阶段 5 文档);
  GPU 显存随时间的测量(无可移植 API,报告说明渲染器缓冲估计恒定且更新路径不新建 GPU 对象);
  `cryptography` 在此环境无法联网安装,本机验收用的是本机已有的 cp310 构建(pyproject 里已声明为可选 extra
  `protected-assets`)。
2026-09-14 16:44:33 +08:00

220 lines
7.8 KiB
Python

"""Probe the real GL stack from inside a Qt window and write a diagnostic report.
Stage 1 deliverable (``docs/architecture/studio-3d-stages.md``).
It creates an **actual** Qt widget and reads the context that Qt actually
obtained — asking the system whether ``libGL`` exists proves nothing, and the
``offscreen`` platform cannot stand in for a board (measured: offscreen answers
"this plugin does not support createPlatformOpenGLContext").
Exit code is the gate: 0 only for a context that a GPU is really driving. A
software rasteriser (llvmpipe and friends) reports 3 unless ``--allow-software``
is passed, so a board cannot be signed off on one by accident.
Usage::
python tools/scene_assets/probe_gl.py --profile desktop33 --output gl-report.json
python tools/scene_assets/probe_gl.py --profile gles30 --windowed
"""
from __future__ import annotations
import argparse
import json
import os
import platform
import sys
import time
from pathlib import Path
#: Exit codes: distinct values so a CI script can tell the two failures apart.
EXIT_OK = 0
EXIT_SOFTWARE = 3
EXIT_NO_CONTEXT = 4
def _bootstrap_paths() -> None:
here = Path(__file__).resolve()
repo = here.parents[2]
for path in (repo / "apps" / "studio" / "src", repo / "shared" / "src"):
if path.is_dir() and str(path) not in sys.path:
sys.path.insert(0, str(path))
def collect(profile: str, *, keep_open_s: float) -> dict:
"""Create a real window, let Qt initialise it, and report what it got."""
from PyQt5.QtCore import QTimer
from PyQt5.QtWidgets import QApplication, QOpenGLWidget
from orisys_studio.scene3d.gl_backend import (
GLDispatch,
GLRenderer,
ThreeDUnsupported,
configure_surface_format,
lut_rgba,
)
configure_surface_format(profile) # type: ignore[arg-type]
app = QApplication.instance() or QApplication(sys.argv)
report: dict = {
"profile_requested": profile,
"platform": {
"qt_platform": app.platformName(),
"python": sys.version.split()[0],
"os": f"{platform.system()} {platform.release()}",
},
# The facts a platform lock is built from (`compile_scene.py
# --lock-from-probe`), named as `PlatformInfo` names them so the two cannot
# drift: changing one of these keys changes what a release can be locked to.
"host": {
"os_family": platform.system().lower(),
"machine": (platform.machine() or "unknown").lower(),
"os": f"{platform.system()} {platform.release()}",
},
}
try:
from OpenGL import __version__ as pyopengl_version
report["platform"]["pyopengl"] = str(pyopengl_version)
except Exception: # noqa: BLE001
report["platform"]["pyopengl"] = "missing"
class Probe(QOpenGLWidget):
def __init__(self) -> None:
super().__init__()
self.renderer = GLRenderer()
self.dispatch = GLDispatch()
self.error: dict | None = None
self.ready = False
def initializeGL(self) -> None:
try:
capabilities = self.dispatch.bind_current_context(self.context())
self.renderer.initialize(capabilities)
except ThreeDUnsupported as exc:
caps = exc.capabilities
self.report_caps(caps)
self.error = {"code": exc.diagnostic.code, "message": exc.diagnostic.message}
except Exception as exc: # noqa: BLE001
self.error = {"code": "gl_unsupported", "message": f"{type(exc).__name__}: {exc}"}
else:
self.report_caps(capabilities)
self.ready = True
def report_caps(self, capabilities) -> None:
from PyQt5.QtGui import QSurfaceFormat
fmt: QSurfaceFormat = self.context().format() if self.context() else QSurfaceFormat()
ratio = float(self.devicePixelRatioF() or 1.0)
report["context"] = {
"valid": bool(self.context().isValid()) if self.context() else False,
"vendor": capabilities.vendor,
"renderer": capabilities.renderer,
"gl_version": capabilities.gl_version,
"glsl_version": capabilities.glsl_version,
"profile_used": capabilities.profile,
"software_renderer": capabilities.software_renderer,
"max_texture_size": capabilities.max_texture_size,
"qt_format": {
"version": f"{fmt.majorVersion()}.{fmt.minorVersion()}",
"renderable_type": int(fmt.renderableType()),
"profile": int(fmt.profile()),
"depth_buffer": int(fmt.depthBufferSize()),
"samples": int(fmt.samples()),
},
"device_pixel_ratio": ratio,
"framebuffer_px": [
int(round(self.width() * ratio)),
int(round(self.height() * ratio)),
],
"diagnostics": [
{"code": item.code, "severity": item.severity, "message": item.message}
for item in capabilities.diagnostics
],
}
report["lut_rgba_bytes"] = int(lut_rgba("sequential").nbytes)
report["renderer_initialized"] = bool(self.renderer.is_initialized)
widget = Probe()
widget.resize(320, 240)
if keep_open_s >= 0:
widget.show()
deadline = time.perf_counter() + max(keep_open_s, 0.3)
while time.perf_counter() < deadline and not widget.ready:
app.processEvents()
time.sleep(0.01)
app.processEvents()
if widget.error is not None:
report["error"] = widget.error
try:
widget.makeCurrent()
widget.renderer.release()
widget.doneCurrent()
except Exception as exc: # noqa: BLE001
report["release_error"] = f"{type(exc).__name__}: {exc}"
report["released"] = True
widget.deleteLater()
app.processEvents()
QTimer.singleShot(0, app.quit)
app.processEvents()
return report
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument(
"--profile",
choices=("desktop33", "gles30"),
default="desktop33",
help="the start-up profile to probe (one per process)",
)
parser.add_argument(
"--output",
type=Path,
default=None,
help="write the diagnostic report here as JSON",
)
parser.add_argument(
"--windowed",
action="store_true",
help="keep a visible window instead of a short-lived one",
)
parser.add_argument(
"--allow-software",
action="store_true",
help="treat a software rasteriser as a pass (for a development box only)",
)
args = parser.parse_args(argv)
_bootstrap_paths()
report = collect(args.profile, keep_open_s=0.6 if args.windowed else 0.3)
context = report.get("context")
if not context or not context.get("valid"):
report["verdict"] = "no_context"
code = EXIT_NO_CONTEXT
elif context.get("software_renderer") and not args.allow_software:
report["verdict"] = "software_renderer"
code = EXIT_SOFTWARE
elif not report.get("renderer_initialized"):
report["verdict"] = "shader_failed"
code = EXIT_NO_CONTEXT
else:
report["verdict"] = "ok"
code = EXIT_OK
text = json.dumps(report, indent=2, ensure_ascii=False)
if args.output is not None:
args.output.write_text(text + "\n", encoding="utf-8")
print(text)
print(
f"\nverdict={report['verdict']} profile={args.profile} "
f"renderer={(context or {}).get('renderer', '?')}",
file=sys.stderr,
)
return code
if __name__ == "__main__":
sys.exit(main())