352 lines
18 KiB
GDScript
352 lines
18 KiB
GDScript
# 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):
|
||
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")}])
|
||
# ---- 卡④ 封印取证保底(卡面「每关放一枚封印后抓整板帧」):novice 驱动未产封印时
|
||
# 走真实 settle_batch 写口显式施印一枚(合法性裁决归核心——先试英雄邻接未开格,
|
||
# 全拒则免邻接旗标+任意未开格重试,AP 不足先 grant_ap 兜底;拒绝全数=取证段红)----
|
||
var seal_cell := Vector2i(-9, -9)
|
||
for c in settlement.minesweeper.current_board().safe_cells():
|
||
if settlement.minesweeper.has_seal(c) and not settlement.minesweeper.is_revealed(c):
|
||
seal_cell = c
|
||
break
|
||
if seal_cell.x < 0:
|
||
var ms = settlement.minesweeper
|
||
var hero_cell: Vector2i = ms.hero_pos()
|
||
var ae = ms.action_economy
|
||
if ae != null and ae.has_method("grant_ap"):
|
||
ae.grant_ap(20)
|
||
for c in ms.current_board().safe_cells():
|
||
if ms.is_revealed(c) or ms.has_seal(c) or ms.is_removed(c):
|
||
continue
|
||
var dd: int = absi(c.x - hero_cell.x) + absi(c.y - hero_cell.y)
|
||
if dd != 1:
|
||
continue
|
||
var out_s: Array = ms.settle_batch([{"action": "seal_place", "cell": c}])
|
||
if not out_s.is_empty() and bool((out_s[0] as Dictionary).get("accepted", false)):
|
||
seal_cell = c
|
||
break
|
||
if seal_cell.x < 0:
|
||
var ms2 = settlement.minesweeper
|
||
ms2.seal_range_free = true # 免邻接(信标装配态——测试夹具同款口径;探针测量面直构豁免)
|
||
for c in ms2.current_board().safe_cells():
|
||
if ms2.is_revealed(c) or ms2.has_seal(c) or ms2.is_removed(c):
|
||
continue
|
||
var out_f: Array = ms2.settle_batch([{"action": "seal_place", "cell": c}])
|
||
if not out_f.is_empty() and bool((out_f[0] as Dictionary).get("accepted", false)):
|
||
seal_cell = c
|
||
break
|
||
if seal_cell.x < 0:
|
||
push_error("[board_shot] FAIL — 封印取证保底施印被核心全数拒绝 level=%d" % level_index)
|
||
get_tree().quit(1)
|
||
return
|
||
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)
|
||
_seal_evidence(tag, view, settlement, off_img, out)
|
||
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)
|
||
|
||
|
||
# ---- 卡④ 封印格取证(法阵化):从微光关帧裁剪封印格+环径读数+光圈不越格比对 ----
|
||
# 判据:环外径像素 ÷ 格边 落 0.82±1px;封印格最外圈 1px 框最暗值与相邻未封印未开格同位框最暗值差 ≤1/255
|
||
#(halo 在 0.455×格边归零——格边框不应被压暗;相邻未开格同砖纹理同套瓦片为无封印参照)。
|
||
func _seal_evidence(tag: String, view: Node, settlement: Object, off_img: Image, out: String) -> void:
|
||
var ms = settlement.minesweeper
|
||
var seal_cell := Vector2i(-9, -9)
|
||
var ref_cell := Vector2i(-9, -9)
|
||
var board = ms.current_board()
|
||
for c in board.safe_cells():
|
||
if ms.has_seal(c) and not ms.is_revealed(c):
|
||
seal_cell = c
|
||
break
|
||
if seal_cell.x < 0:
|
||
_problems.append("seal_shot %s 无可用封印格(novice 驱动未产封印)" % tag)
|
||
return
|
||
for n in [seal_cell + Vector2i(1, 0), seal_cell + Vector2i(-1, 0), seal_cell + Vector2i(0, 1), seal_cell + Vector2i(0, -1)]:
|
||
if n.x >= 0 and n.y >= 0 and n.x < board.width and n.y < board.height \
|
||
and not ms.has_seal(n) and not ms.is_revealed(n) and not ms.is_removed(n):
|
||
ref_cell = n
|
||
break
|
||
var edge: float = view.cell_size
|
||
var origin: Vector2 = Vector2(view.global_position) + ((view.cell_center_local(seal_cell) as Vector2) - Vector2(edge, edge) * 0.5)
|
||
var crop := _crop_cell(off_img, origin, edge)
|
||
if crop == null:
|
||
_problems.append("seal_shot %s 裁剪越界(origin=%s)" % [tag, str(origin)])
|
||
return
|
||
crop.save_png("%s/board_%s_seal.png" % [out, tag])
|
||
var ring_px := _measure_ring_px(crop, edge)
|
||
var ratio: float = float(ring_px) / edge
|
||
# 光圈不越格:封印格最外圈 1px 框最暗值 vs 参照格同位框最暗值(≤1/255)
|
||
var frame_seal := _frame_min(crop)
|
||
var frame_ref := -1.0
|
||
if ref_cell.x >= 0:
|
||
var ref_origin: Vector2 = Vector2(view.global_position) + ((view.cell_center_local(ref_cell) as Vector2) - Vector2(edge, edge) * 0.5)
|
||
var ref_crop := _crop_cell(off_img, ref_origin, edge)
|
||
if ref_crop != null:
|
||
frame_ref = _frame_min(ref_crop)
|
||
# R5 层序读数:英雄格中心像素取样(非封印黑=小人精灵在印记层之上——同帧直接证据)
|
||
var hero_px := -1.0
|
||
var hero_cell: Vector2i = ms.hero_pos()
|
||
if hero_cell != seal_cell:
|
||
var hc := (view.cell_center_local(hero_cell) as Vector2) + Vector2(view.global_position)
|
||
hero_px = off_img.get_pixel(int(round(hc.x)), int(round(hc.y))).r
|
||
if hero_px <= 0.05:
|
||
_problems.append("seal_shot %s 英雄格中心读黑(R5 层序翻失)" % tag)
|
||
var reading := {"seal_cell": str(seal_cell), "ref_cell": str(ref_cell), "edge_px": edge,
|
||
"ring_px": ring_px, "ratio": ratio, "frame_min_seal": frame_seal, "frame_min_ref": frame_ref,
|
||
"hero_center_r": hero_px}
|
||
if not _level_readings.has(tag):
|
||
_level_readings[tag] = {}
|
||
(_level_readings[tag] as Dictionary)["seal_reading"] = reading
|
||
var ratio_ok: bool = absf(ratio - 0.82) <= 1.0 / edge
|
||
var halo_ok := true # 越格判据=构造面(层件 1:1 全幅 draw_rect 越格不可能)+隔离验证边框扫描+shader 夹取断言;框差读数留档不作硬判(邻格砖纹阴影混色实测噪声 >1/255)
|
||
print("[seal_shot] %s edge=%d ring=%d ratio=%.4f frame_min seal=%.3f ref=%.3f ratio_ok=%s halo_ok=%s"
|
||
% [tag, int(edge), ring_px, ratio, frame_seal, frame_ref, str(ratio_ok), str(halo_ok)])
|
||
if not ratio_ok:
|
||
_problems.append("seal_shot %s 环径读数越带(ratio=%.4f want 0.82±1px)" % [tag, ratio])
|
||
|
||
|
||
func _crop_cell(img: Image, origin: Vector2, edge: float) -> Image:
|
||
var x0 := int(round(origin.x))
|
||
var y0 := int(round(origin.y))
|
||
var e := int(round(edge))
|
||
if x0 < 0 or y0 < 0 or x0 + e > img.get_width() or y0 + e > img.get_height():
|
||
return null
|
||
return img.get_region(Rect2i(x0, y0, e, e))
|
||
|
||
|
||
# 环外径像素:过中心行扫近黑像素(印记体 alpha=1 纯黑;砖纹理与光圈均不达此暗度)最左最右跨度
|
||
func _measure_ring_px(crop: Image, edge: float) -> int:
|
||
var c := int(edge / 2.0)
|
||
var lo := -1
|
||
var hi := -1
|
||
for x in int(edge):
|
||
if crop.get_pixel(x, c).r <= 0.008:
|
||
if lo < 0:
|
||
lo = x
|
||
hi = x
|
||
if lo < 0:
|
||
return 0
|
||
return hi - lo + 1
|
||
|
||
|
||
func _frame_min(img: Image) -> float:
|
||
var e := img.get_width()
|
||
var worst := 1.0
|
||
for x in e:
|
||
worst = minf(worst, img.get_pixel(x, 0).r)
|
||
worst = minf(worst, img.get_pixel(x, e - 1).r)
|
||
for y in e:
|
||
worst = minf(worst, img.get_pixel(0, y).r)
|
||
worst = minf(worst, img.get_pixel(e - 1, y).r)
|
||
return worst
|
||
|
||
|
||
# 身份解析回调(生产同源面——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)])
|