5cd92254e1
- 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`)。
220 lines
7.8 KiB
Python
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())
|