Files
gplan-2/game/probe/board_shot_probe.gd
T

218 lines
12 KiB
GDScript
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.
# VERTICAL SLICE - NOT FOR PRODUCTION
# 棋盘渲染帧取证探针(目视验证面:机检≠看得清)。
# 必须**窗口化**运行(headless 无渲染面):
# Godot_console.exe --path . res://probe/board_shot_probe.tscn -- <输出目录>
# 流程:L1/L2/L3 各起一板(真实 LevelRunner+settle_batch 驱动 ~18 步 novice——自然揭示数字/封印),
# BoardView 真渲染,frame_post_draw 同步后抓视口 PNG。零存档触碰(无 save_manager 引用)。
# 护栏:headless fail-fast(shot_probe 同款);空纹理取证 fail——取证段失败=闸红,
# 零字节/空纹理不允许静默落盘;board_meta.json 取样坐标落盘(棋盘视图全局矩形——
# golden 前置裁剪坐标源,禁硬编码像素)。
# 双帧取证:每关抓 微光开(生产默认态·Q4 丙案)/关(golden 基线保护面)
# 两帧——① 开帧逐像素判定「非生效格零变化」(禁整格染色/禁越圈;生效格内只准落在内缘
# 带 = cell÷8)② 每个生效格须有实绘像素(微光不空转)③ 关帧即 golden 对比帧(原名落盘,
# 基线不重制——契约 1 off 态基线)。隔离纪律 = 与 type_glow_shot_probe 同款:
# 前置 UiKit.motion_scale=0.5 冻结接触带呼吸相位(静态带色),两帧差异唯一定位在类型微光。
extends Node
const AutoPlayer := preload("res://sim/auto_player.gd")
const ActionEconomy := preload("res://core/action_economy.gd")
var _meta := {} # 棋盘视图实测几何(全局 rect——tools/golden_board_crop.py 取样坐标源)
var _problems: Array = []
var _level_readings := {} # 关 → 双帧比对读数(实现报告读数表源)
var _cur_settlement = null # 当关 settlement(身份回调读取面——仿生产 run_scope.settlement 现值;
# 不用 Callable.bind 传参:bind 的 Callable 动态派发时对类型化参数做 Object 硬校验,
# 报「Cannot convert argument 2 from Object to Vector2i」——直接方法引用+成员现值无此坑)
func _ready() -> void:
# 运行前提=窗口化真渲染面(shot_probe 同款护栏——headless 下 frame_post_draw 永不触发+视口纹理恒空)
if DisplayServer.get_name() == "headless":
print("[board_shot] FAIL — 本探针必须窗口化运行(headless 无渲染面,截图必空);现行事实源=tools/render_gate.sh 取证段")
get_tree().quit(1)
return
var args := OS.get_cmdline_user_args()
var out := args[0] if args.size() >= 1 and args[0] != "" else "user://board-shots"
DirAccess.make_dir_recursive_absolute(out)
await get_tree().process_frame
var db = load("res://core/config_db.gd").new()
var ok: bool = db.load_tables("res://data/tables", true)
if not ok:
push_error("[board_shot] 表加载失败")
get_tree().quit(1)
return
# 双帧单一变量隔离:冻结接触带呼吸相位——motion_scale<1.0 ⇒ _apply_contact_bands
# 施静态色(减动效停格路径)。必须在 rebuild 前置位(rebuild/set_dig_glow_enabled 按当时相位
# 重施带色)。golden 侧影响:静态带色=呼吸周期中值,与基线捕获相的像素差最坏 ≈灰度24 于
# 接触带像素(占比 <0.4%),≤2% 判据不受影响;停格同时令「非生效格零变化」可精确判定。
UiKit.motion_scale = 0.5
var ui_font: Font = load("res://presentation/views/ui_kit.gd").load_font(load("res://presentation/views/ui_kit.gd").FONT_UI)
var targets := [[1, "L1_dungeon"], [2, "L2_underdark"], [3, "L3_deep_underdark"]]
for t in targets:
var level_index: int = t[0]
var tag: String = t[1]
var rng = load("res://core/rng_service.gd").new()
rng.set_master_seed(420000 + level_index)
var runner = load("res://core/level_runner.gd").new(db, rng)
if not runner.start_level(level_index, 420000 + level_index, {}, 0):
push_error("[board_shot] 开关失败 level=%d" % level_index)
get_tree().quit(1)
return
var settlement = runner.current_battle()
_cur_settlement = settlement
for i in range(18):
var act: Dictionary = AutoPlayer.novice_action(settlement.minesweeper)
var kind := String(act.get("kind", "stop"))
if kind == "stop":
break
var op := kind
if kind == "seal":
op = "seal_place"
settlement.minesweeper.settle_batch([{"action": op, "cell": act.get("cell")}])
var view = load("res://presentation/views/board_view.gd").new()
view.size = Vector2(900, 900)
view.position = Vector2(40, 40)
if ui_font != null:
view.set_world_font(ui_font)
add_child(view)
# 身份解析接线(生产同源——main.gd:251 set_identity_resolver(_identity_lookup)):必须在
# rebuild / set_dig_glow_enabled 前接好,二者尾链 _recompute_glow 同步读身份建作用集。
# 裸视图不接 = _identity_cb 无效 ⇒ info 恒 null ⇒ 作用集恒空(三关 glow=0 根因——
# 双帧比对空转、PASS 假绿)。现值经 _cur_settlement 成员读入
# (仿生产 run_scope.settlement;bind 传参有 Callable 类型校验坑,见成员注)。
view.set_identity_resolver(_probe_identity_lookup)
view.rebuild(settlement.minesweeper.current_board(), settlement.minesweeper)
# 进挖掘相位(生产态 = hud_presenter.set_dig_glow_enabled(not settling) 接线——裸视图默认
# _dig_glow=false,接触带与类型微光同源门控都不渲染;golden 基线即捕获于
# 该裸态。双帧取证要求生产默认态,故显式开——两帧均含静态接触带(motion_scale=0.5 停格),
# 差异唯一定位在类型微光开关)
view.set_dig_glow_enabled(true)
# 抓帧前等入场动画完结(S2 观察项根因):rebuild 有全板淡入
# motion(0.2)+R0 涟漪逐格延迟显形(motion(0.04×离板心距离)+motion(0.12),最远格 ≈0.56s),
# 原制 6 个 process_frame 热机后 ≈0.1s=捕在涟漪中途——逐跑帧时序抖动使揭示 alpha 漂移,
# golden L2/L3 跨 2% 容差线波动(实测 0.00-2.53%)。改时间制 1.2s(2×余量)=锚定动画完结态。
await get_tree().create_timer(1.2).timeout
await get_tree().process_frame
await RenderingServer.frame_post_draw
var on_img := get_viewport().get_texture().get_image()
if on_img == null or on_img.is_empty():
push_error("[board_shot] FAIL — 抓帧空纹理: %s(Q7 取证语义=闸红)" % tag)
get_tree().quit(1)
return
var on_path := "%s/board_%s_glow_on.png" % [out, tag]
on_img.save_png(on_path)
# 作用集快照必须在关开关之前取(set_type_glow_enabled(false) 会清空 _glow 重算)
var glow_cells: Dictionary = (view.type_glow_probe().get("cells", {}) as Dictionary).duplicate()
# 帧 B = 微光关(golden 基线保护面——与基线同文件名落盘,golden 对比取本帧)
view.set_type_glow_enabled(false)
await get_tree().process_frame
await RenderingServer.frame_post_draw
var off_img := get_viewport().get_texture().get_image()
if off_img == null or off_img.is_empty():
push_error("[board_shot] FAIL — 抓帧空纹理: %s_off(Q7 取证语义=闸红)" % tag)
get_tree().quit(1)
return
var path := "%s/board_%s.png" % [out, tag]
off_img.save_png(path)
_meta[tag] = {"x": view.global_position.x, "y": view.global_position.y,
"w": view.size.x, "h": view.size.y}
print("[board_shot] saved %s (%dx%d) + %s" % [path, off_img.get_width(), off_img.get_height(), on_path])
_check_dual_frames(tag, view, glow_cells, off_img, on_img)
view.queue_free()
await get_tree().process_frame
var mf := FileAccess.open(out.path_join("board_meta.json"), FileAccess.WRITE)
if mf != null:
mf.store_string(JSON.stringify(_meta))
mf.close()
var rf := FileAccess.open(out.path_join("board_dual_frame_readings.json"), FileAccess.WRITE)
if rf != null:
rf.store_string(JSON.stringify(_level_readings, " "))
rf.close()
if _problems.is_empty():
print("[board_shot] PASS — 三关双帧取证:非生效格零变化/生效格内缘带外零侵入/生效格实绘非空;golden 帧=微光关态(契约 1 基线保护面)")
get_tree().quit(0)
else:
for p in _problems:
push_error("[board_shot] FAIL — " + p)
print("[board_shot] FAIL — " + "; ".join(_problems))
get_tree().quit(1)
# 身份解析回调(生产同源面——main.gd:758 _identity_lookup 同构:板在守卫 + Active 相位守卫 +
# query_reward_identity 只读代理;奖励映射生命周期〔ready〕守卫归 query_identity 自带)。
# 相位为何是 Active:每轮 settle_batch 末尾 _exit_distribution 已 notify_round_start 兑现下轮
# AP ⇒ 18 步 novice 驱动后相位回 Active,即生产「挖掘期看板」态;本回调零玩法数据写入
# (🔴 契约 1——只读投影面)。
func _probe_identity_lookup(c: Vector2i):
if _cur_settlement == null or _cur_settlement.minesweeper.current_board() == null:
return null
if String(_cur_settlement.action_economy.phase) != ActionEconomy.PHASE_ACTIVE:
return null
return _cur_settlement.query_reward_identity(c)
# 双帧逐像素比对:所有变化像素必须落在生效格的内缘带内
# (de < cell÷8——带外含砖心零侵入=禁整格染色/禁越圈);每个生效格须有实绘像素(不空转)。
func _check_dual_frames(tag: String, view, glow_cells: Dictionary, off_img: Image, on_img: Image) -> void:
var board = view.board()
if board == null:
_problems.append("%s 双帧比对取板失败(view.board() 为 null)" % tag)
return
var w: int = off_img.get_width()
var h: int = off_img.get_height()
var cell: float = view.cell_size
var band_px: float = cell * 0.125
# 逐格窗口矩形 + 到格缘距离判据(与 type_glow_shot_probe 同口径)
var rects := {}
for gy in range(board.height):
for gx in range(board.width):
var c := Vector2i(gx, gy)
rects[c] = view.cell_rect_probe(c)
var changed_total := 0
var changed_outside := 0
var first_outside := ""
var glow_hits := {}
for c in glow_cells:
glow_hits[c] = 0
for gy in range(board.height):
for gx in range(board.width):
var c2 := Vector2i(gx, gy)
var r: Rect2 = rects[c2]
if r.size.x <= 0.0:
continue
var is_glow: bool = glow_cells.has(c2)
var x0: int = int(floor(r.position.x))
var y0: int = int(floor(r.position.y))
var x1: int = int(ceil(r.position.x + r.size.x))
var y1: int = int(ceil(r.position.y + r.size.y))
for yy in range(maxi(0, y0), mini(h, y1)):
for xx in range(maxi(0, x0), mini(w, x1)):
var de: int = mini(mini(xx - x0, x1 - 1 - xx), mini(yy - y0, y1 - 1 - yy))
var p1: Color = off_img.get_pixel(xx, yy)
var p2: Color = on_img.get_pixel(xx, yy)
if p1.r == p2.r and p1.g == p2.g and p1.b == p2.b and p1.a == p2.a:
continue
changed_total += 1
if is_glow:
if float(de) < band_px:
glow_hits[c2] = int(glow_hits[c2]) + 1
else:
changed_outside += 1
if first_outside == "":
first_outside = "%s 带外(de=%d≥%.0f)" % [str(c2), de, band_px]
else:
changed_outside += 1
if first_outside == "":
first_outside = "%s 非生效格" % str(c2)
_level_readings[tag] = {"glow_cells": glow_cells.size(), "changed_total": changed_total,
"changed_outside": changed_outside, "band_px": band_px}
print("[board_shot] %s 双帧读数 glow=%d changed=%d outside=%d band=%.0fpx"
% [tag, glow_cells.size(), changed_total, changed_outside, band_px])
if changed_outside != 0:
_problems.append("%s 非生效格/带外像素变化 %d 个(首例 %s——禁整格染色/禁越圈)" % [tag, changed_outside, first_outside])
if glow_cells.size() > 0 and changed_total == 0:
_problems.append("%s 微光开帧整板零变化(微光未生效)" % tag)
for c in glow_cells:
if int(glow_hits[c]) == 0:
_problems.append("%s 生效格 %s 零实绘像素(微光空转)" % [tag, str(c)])