#!/usr/bin/env bash # 探针闸正主——headless 安全段固化,禁再手拼同款 Godot 命令。 # 三闸:①每段 timeout 守卫+超时整树回收(挂死=FAIL 不静默;taskkill /T 连引擎子进程一起杀, # 不再只杀 wrapper 留孤儿)②段前预清+段后清本 game 目录孤儿 Godot # (按命令行精确匹配本 game 路径,不误杀其他 worktree 会话的合法进程)③末尾残留清点=批末闸(必须 0)。 # 段(单一形态,十件恒跑): # 默认段五件:ui_input_probe/ui_struct_probe( # 全 UI 屏结构断言:按钮互压/等宽/出屏/零尺寸/标题屏双列)/ui_contrast_probe( # UI 文字对比度机检 ≥4.5:1 无区域例外,测点表随改动维护)/screen_flow_probe(L3 屏壳切换)。 # 行为走查段:ui_click_probe(自退出+退出码闸;超时多半=探针未适配新停点层,需做探针适配)。 # layout_probe=默认段五件之一(分辨率几何断言,档位表是数据/断言是一份函数, # 增删档位只改表内一行;用 content_scale_size 改逻辑视口,headless 下真生效—— # get_window().size 在 headless 下不改变视口,其「1920 档恒定」类断言是 # 永远为真的假断言,禁用)。 # 判定口径(三硬闸):每段走 verdict 函数——①退出码为唯一权威判据(PASS 串降级为日志) # ②脚本错误扫描命中即红(tools/error_scan.py,标记与 run_smoke.py 同源)③PASS 串保留为附加判据。 # 扩展段(带断言探针四件):ui_probe/ui_tune_probe/t17_defer_probe/fx_probe。 # 渲染面归 render_gate.sh 正主(窗口化:shot_probe 七屏+fx_shot/board_shot 取证+棋盘 golden 逐像素)。 # 用法: tools/probe_gate.sh [game目录] 缺省 game 目录=脚本所在仓库的 game/(worktree 内自动指向该 worktree) # 依赖: Git Bash(coreutils timeout/taskkill) + PowerShell(Windows 侧);macOS/Linux 走 tools/gate_platform.sh # 共享跨平台件(Godot 路径两平台都可用 GODOT_BIN 环境变量覆盖)。 set -u G="${GODOT_BIN:-/d/code/godot/Godot_v4.7.2-stable_win64_console.exe}" REPO="$(cd "$(dirname "$0")/.." && pwd)" . "$REPO/tools/gate_platform.sh" # 跨平台件(平台探测/路径形式/带超时执行/孤儿清杀) GAME="" for a in "$@"; do case "$a" in *) GAME="$a" ;; esac done GAME="${GAME:-$REPO/game}" GAME_WIN="$(gate_game_path "$GAME")" # Godot --path 接受形式(Windows 混合斜杠,其他平台原样) TMP="$(mktemp -d /tmp/probegate_XXXXXX)" trap 'rm -rf "$TMP"' EXIT _rc=0 # 错误扫描闸件用纯标准库 python(不依赖 PIL),优先带 PIL 的 gamedev venv 以保持与全量域闸同解释器。 PY="" for cand in "/d/dev/python/envs/gamedev/Scripts/python.exe" "python" "python3"; do if "$cand" -c "import sys" >/dev/null 2>&1; then PY="$cand"; break; fi done if [ -z "$PY" ]; then echo "[probe_gate] 警告:未找到 python,脚本错误扫描闸不生效(三硬闸缺一)" fi # 判定三硬闸——各探针段统一走本函数,禁再手拼判定: # ①退出码为唯一权威判据(PASS 串降级为日志,不得再当判据) # ②脚本错误扫描命中即红(SCRIPT ERROR/Parse Error 不改退出码,只能外部扫) # ③签名标记保留为附加判据(退出码 0 却无该探针的签名行=静默退出,仍判红)。 # 标记默认 PASS;ui_click_probe 本就走退出码语义、日志不写 PASS,传它自己的摘要标记。 verdict() { # verdict <探针名> <日志路径> <退出码> [签名标记] local name="$1" log="$2" st="$3" mark="${4:-PASS}" if [ "$st" -eq 124 ] || [ "$st" -eq 137 ]; then # macOS quit(0) 挂死受控例外:守卫回收后若 PASS 签名在位且错误扫描净=探针已完成判定, # 挂死只发生在退出段(run26 实证:headless 探针同样挂)——按完成处理,签名缺失仍判红。 if [ -n "$PY" ] && "$PY" "$REPO/tools/error_scan.py" "$log" >"$TMP/err.txt" 2>&1 && grep -q "$mark" "$log"; then echo "[probe_gate] OK — $name 判定完成(exit=$st 为 macOS 退出段挂死回收,签名+扫描双证齐全)" return 0 fi echo "[probe_gate] FAIL — $name 超时挂死(exit=$st),孤儿已清"; return 1 fi if [ "$st" -ne 0 ]; then echo "[probe_gate] FAIL — $name 退出码=${st}(退出码为唯一权威判据,PASS 串不抵)"; return 1 fi if [ -n "$PY" ] && ! "$PY" "$REPO/tools/error_scan.py" "$log" >"$TMP/err.txt" 2>&1; then echo "[probe_gate] FAIL — $name 脚本错误扫描命中:" sed -n '1,6p' "$TMP/err.txt" | sed 's/^/ /' return 1 fi if ! grep -q "$mark" "$log"; then echo "[probe_gate] FAIL — $name 未见签名标记「${mark}」(exit=${st}——退出码 0 但零输出亦判红)"; return 1 fi return 0 } orphans() { # 列出命令行含本 game 路径的 Godot 进程 PID(实现单点在 gate_platform.sh,双平台) gate_orphans "$GAME_WIN" } kill_orphans() { gate_kill_orphans "$GAME_WIN" } # 每段跑批统一走本函数:后台启动记 MSYS PID→WINPID,守卫进程到点先 taskkill /T 整树回收 # (顺序要紧:先杀 wrapper 再清树会让 /T 走不到已死的父进程,引擎子进程照样泄漏—— # 2026-10-25 在册挂死根源),后 kill -9 收 MSYS 侧。正常退出不动进程树;段后 kill_orphans 照跑兜底。 run_suite() { # run_suite <日志> <场景 res:// 路径> local t="$1" log="$2" scene="$3" if ! gate_is_win; then # macOS/Linux:无 console wrapper(Godot 单进程),走共享带超时执行+孤儿清杀兜底。 local st gate_run_timeout "$t" "$log" "$G" --headless --path "$GAME_WIN" "$scene" st=$? gate_kill_orphans "$GAME_WIN" return "$st" fi local kf="$TMP/killed.$RANDOM" rm -f "$kf" "$G" --headless --path "$GAME_WIN" "$scene" >"$log" 2>&1 & local pid=$! local wpid="" wpid="$(ps -W 2>/dev/null | awk -v p="$pid" '$1==p{print $4; exit}')" ( sleep "$t" 2>/dev/null touch "$kf" [ -n "$wpid" ] && taskkill //F //T //PID "$wpid" >/dev/null 2>&1 kill -9 "$pid" 2>/dev/null true ) & local guard=$! wait "$pid"; local st=$? kill "$guard" 2>/dev/null; wait "$guard" 2>/dev/null # taskkill 强杀后 MSYS wait 可能报 0——超时判定以守卫落下的标志文件为准(与 124 同判)。 if [ "$st" -ge 128 ] || [ -f "$kf" ]; then st=124 echo "[probe_gate] 套件超时(${t}s),已整树回收(PID=$pid/WINPID=${wpid:-未取到——靠孤儿清杀兜底})" fi rm -f "$kf" kill_orphans return "$st" } # 冷检出刷导入(.godot 不入 git,fresh worktree 首跑一次性付) if [ ! -f "$GAME/.godot/uid_cache.bin" ]; then echo "[probe_gate] 冷检出:editor --quit 刷导入(一次性,可到 1-2 分钟)" gate_run_timeout 300 "$TMP/refresh.log" "$G" --headless --path "$GAME_WIN" --editor --quit >/dev/null 2>&1 kill_orphans fi pre="$(orphans | wc -l | tr -d ' ')" if [ "$pre" -gt 0 ]; then echo "[probe_gate] 预清本闸残留 Godot×$pre"; kill_orphans; fi echo "======== ui_input_probe ========" run_suite 120 "$TMP/uip.log" res://probe/ui_input_probe.tscn st=$? grep -E "PASS|FAIL|问题" "$TMP/uip.log" | head -12 verdict "ui_input_probe" "$TMP/uip.log" "$st" || _rc=1 echo "======== level_map_viewport_probe(卷轴视口批 R1-R8) ========" run_suite 120 "$TMP/lvp.log" res://probe/level_map_viewport_probe.tscn st=$? grep -E "PASS|FAIL" "$TMP/lvp.log" | head -12 verdict "level_map_viewport_probe" "$TMP/lvp.log" "$st" || _rc=1 echo "======== ui_struct_probe ========" run_suite 120 "$TMP/stp.log" res://probe/ui_struct_probe.tscn st=$? grep -E "PASS|FAIL" "$TMP/stp.log" | head -12 verdict "ui_struct_probe" "$TMP/stp.log" "$st" || _rc=1 echo "======== layout_probe(分辨率几何·两档) ========" run_suite 120 "$TMP/lay.log" res://probe/layout_probe.tscn st=$? grep -E "PASS|FAIL" "$TMP/lay.log" | head -12 verdict "layout_probe" "$TMP/lay.log" "$st" || _rc=1 echo "======== ui_contrast_probe ========" run_suite 120 "$TMP/ctp.log" res://probe/ui_contrast_probe.tscn st=$? grep -E "PASS|FAIL" "$TMP/ctp.log" | head -12 verdict "ui_contrast_probe" "$TMP/ctp.log" "$st" || _rc=1 echo "======== screen_flow_probe(L3 屏壳切换·迭代 29 T4) ========" run_suite 120 "$TMP/sfp.log" res://probe/screen_flow_probe.tscn st=$? grep -E "screen_flow_probe" "$TMP/sfp.log" | tail -3 verdict "screen_flow_probe" "$TMP/sfp.log" "$st" "screen_flow_probe" || _rc=1 echo "======== ui_click_probe 行为走查 ========" run_suite 240 "$TMP/clk.log" res://probe/ui_click_probe.tscn st=$? grep -E "click_probe" "$TMP/clk.log" | tail -4 verdict "ui_click_probe" "$TMP/clk.log" "$st" "click_probe" || _rc=1 # ── 扩展段(四件带断言探针;实测时长 ui_probe 17.4s/ # ui_tune_probe 49.6s/t17_defer_probe 63.0s/fx_probe 21.9s,timeout 120=2-7×余量) ── echo "======== ui_probe(扩展段) ========" run_suite 120 "$TMP/flp.log" res://probe/ui_probe.tscn st=$? grep -E "\[ui_probe\]" "$TMP/flp.log" | tail -2 verdict "ui_probe" "$TMP/flp.log" "$st" || _rc=1 echo "======== ui_tune_probe(扩展段) ========" run_suite 120 "$TMP/flt.log" res://probe/ui_tune_probe.tscn st=$? grep -E "\[tune_probe\]" "$TMP/flt.log" | tail -2 verdict "ui_tune_probe" "$TMP/flt.log" "$st" || _rc=1 echo "======== t17_defer_probe(扩展段) ========" run_suite 120 "$TMP/fld.log" res://probe/t17_defer_probe.tscn st=$? grep -E "\[t17_defer\]" "$TMP/fld.log" | tail -2 verdict "t17_defer_probe" "$TMP/fld.log" "$st" || _rc=1 echo "======== fx_probe(扩展段) ========" run_suite 120 "$TMP/flx.log" res://probe/fx_probe.tscn st=$? grep -E "\[fx_probe\]" "$TMP/flx.log" | tail -2 verdict "fx_probe" "$TMP/flx.log" "$st" || _rc=1 echo "======== 残留检查(批末闸) ========" left="$(orphans | wc -l | tr -d ' ')" total="$(gate_godot_count_all)" echo "本闸残留 Godot=${left}(必须0) 全局 Godot 进程=${total}(其他会话的仅报数不算账)" if [ "$left" -ne 0 ]; then echo "[probe_gate] FAIL — 本闸残留未清零"; kill_orphans; _rc=1; fi if [ "$_rc" -eq 0 ]; then echo "[probe_gate] 全绿"; else echo "[probe_gate] 存在 FAIL,见上"; fi exit "$_rc"