Files
aetherbound-guild/runtime/tools/m53_guild_visual_probe.gd
T
wushenghua 56935992d3 fix(m84e): make the guild board copy single-sourced so the gates measure the visible labels
The page built four labels and then hid them behind a same-named shared module
that kept painting the old geometry and the plain danger red, so every name
lookup measured the hidden twins. The module instantiation and the visibility
loop are gone, the wrapping behaviour it provided moved onto the page labels,
and the probe now fails on same-named nodes, hidden labels or missing sources.
Widening GuildMarketReason and GuildNotice fixed the two overflows the gate
could finally see.
2026-09-18 08:28:35 +08:00

295 lines
13 KiB
GDScript

extends SceneTree
const GuildView = preload("res://entry/views/guild_page_view.gd")
const InkCheck = preload("res://tools/ui_ink_check.gd")
const FIXTURES := [Vector2i(1280, 720)]
const LOCALES := ["zh_CN", "en"]
# Accessibility pass: 1.0 plus the shipped 130% text scale (M73).
const TEXT_SCALES := [1.0, 1.3]
# M84-A measured master regions on the 1280x720 canvas (master px x 0.625).
const MASTER_PATH := "res://assets/backgrounds/m52_q_guild_background.png"
const PAPER := Rect2(838.0, 144.0, 1172.0 - 838.0, 347.0 - 144.0)
const PLATE := Rect2(878.0, 139.0, 1185.0 - 878.0, 355.0 - 139.0)
const CURTAIN := Rect2(812.0, 119.0, 1187.0 - 812.0, 181.0 - 119.0)
const CONTRAST_MIN := 4.5
const CONTRAST_LABELS := ["GuildKioskText", "GuildContractReason", "GuildMarketReason", "GuildNotice", "GuildProgressStatus", "GuildCoinStatus", "GuildAnchorCompanyStatus"]
var master_image: Image
var m84_failures: Array = []
var notice_node_path := "(unresolved)"
var ratio_reports: Array = []
var ink_scanned := 0
var probe_text_scale := 1.0
var ink_violations: Array = []
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var out := OS.get_environment("ABG_EVIDENCE_DIR")
if out.is_empty():
push_error("requires ABG_EVIDENCE_DIR")
quit(1)
return
DirAccess.make_dir_recursive_absolute(out)
var ink_self := InkCheck.self_test(root)
if ink_self.size() < 3:
push_error("M62 ink self-test expected at least 3 violations, got %d" % ink_self.size())
quit(1)
return
print("ABG_M62_INK_SELF_TEST_OK violations=%d" % ink_self.size())
ink_scanned = 0
ink_violations = []
for fixture in FIXTURES:
for locale in LOCALES:
for text_scale in TEXT_SCALES:
probe_text_scale = float(text_scale)
await _capture_fixture(fixture, locale, out)
var report_path := OS.get_environment("ABG_EVIDENCE_DIR").path_join("ink-violations-%s.json" % _probe_id())
var file := FileAccess.open(report_path, FileAccess.WRITE)
if file != null:
file.store_string(JSON.stringify(ink_violations, " "))
file.close()
if ink_violations.is_empty() and m84_failures.is_empty():
print("ABG_M84_CONTRAST_RATIOS %s" % " | ".join(ratio_reports))
print("ABG_M84_NOTICE_SEMANTIC_OK ink=color_danger_deep#8c2f2f paper=cream source=single node=%s" % _notice_path())
print("ABG_M84_GUILD_BOARD_OK paper=%s curtain_clear=true contrast_min=%.1f" % [str(PAPER), CONTRAST_MIN])
print("ABG_M53_GUILD_INK_OK scanned=%d violations=0" % ink_scanned)
else:
for failure in m84_failures:
push_error("M84-A guild board: %s" % failure)
for violation in ink_violations:
push_error("M53 ink violation %s: text=%s slot=%s font=%s" % [String(violation.get("node", "?")), String(violation.get("text_width", "?")), String(violation.get("slot_width", "?")), String(violation.get("font_size", "?"))])
print("ABG_M53_GUILD_INK_FAIL scanned=%d violations=%d" % [ink_scanned, ink_violations.size()])
quit(1)
return
print("ABG_M53_GUILD_VISUAL_PROBE_OK states=members locales=%d scans=%d views=static,dynamic,final" % [LOCALES.size(), ink_scanned])
quit(0)
func _probe_id() -> String:
return "m53-guild"
func _scan_ink(view: Control, locale: String, label: String) -> void:
# Legacy-chrome pages (pre-M62) record their ink defects as a baseline for
# the W5 migration; migrated pages (Market, Readiness once moved) must be
# strict. The report file is the machine-readable work list.
var violations := InkCheck.scan(view)
ink_scanned += 1
for violation in violations:
violation["locale"] = locale
violation["fixture"] = label
ink_violations.append(violation)
func _capture_fixture(fixture: Vector2i, locale: String, out: String) -> void:
root.size = Vector2(fixture)
var capture_viewport := SubViewport.new()
capture_viewport.name = "GuildCaptureViewport"
capture_viewport.size = fixture
capture_viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
capture_viewport.transparent_bg = false
root.add_child(capture_viewport)
var view := GuildView.new()
view.name = "GuildVisualProbeView"
view.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
view.configure_presentation({"safe_area": 24, "text_scale": probe_text_scale, "locale": locale})
capture_viewport.add_child(view)
view.render({
"ok": true,
"locale": locale,
"company": {
"owned_count": 2,
"coin": 38,
"display_recruits": [
{"recruit_id": "owned-001", "profession_id": "PF-B01", "rendered_name_snapshot": {"en": "Ari", "zh_CN": "阿里"}, "profession_name": {"en": "Vanguard", "zh_CN": "先锋"}},
{"recruit_id": "owned-002", "profession_id": "PF-B06", "rendered_name_snapshot": {"en": "Bram", "zh_CN": "布拉姆"}, "profession_name": {"en": "Arcanist", "zh_CN": "奥术师"}},
],
},
"destinations": {},
"recruits": [],
"notice": "同名委托需先返回再接受" if locale == "zh_CN" else "Return before accepting the same contract",
})
await _settle()
_scan_ink(view, locale, "%dx%d" % [fixture.x, fixture.y])
_save(capture_viewport, out, locale, fixture, "final")
_check_m84(view, locale, out.path_join("guild-%dx%d-%s-final.png" % [fixture.x, fixture.y, locale]))
var overlay := view.find_child("GuildAnchorRoot", true, false) as Control
if overlay != null:
overlay.visible = false
await _settle()
_save(capture_viewport, out, locale, fixture, "static")
if overlay != null:
overlay.visible = true
var world := view.find_child("GuildMasterWorldExtension", true, false) as Control
if world != null:
world.visible = false
await _settle()
_save(capture_viewport, out, locale, fixture, "dynamic")
view.queue_free()
capture_viewport.queue_free()
await process_frame
func _master() -> Image:
if master_image == null:
master_image = Image.new()
master_image.load(MASTER_PATH)
return master_image
func _rect_of(view: Control, node_name: String) -> Rect2:
var node := view.find_child(node_name, true, false) as Control
if node == null:
return Rect2()
# Global rects are already in 1280x720 canvas pixels; only the master image
# sample below needs the x1.6 master scale.
return node.get_global_rect()
func _ink_rect(view: Control, node_name: String) -> Rect2:
var node := view.find_child(node_name, true, false) as Control
if node == null:
return Rect2()
var label := node as Label
if label != null:
return InkCheck.ink_rect(label)
return _rect_of(view, node_name)
func _luminance(color: Color) -> float:
var channels := [color.r, color.g, color.b]
var linear: Array = []
for value in channels:
linear.append(value / 12.92 if value <= 0.03928 else pow((value + 0.055) / 1.055, 2.4))
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]
func _carrier_p95(image: Image, rect: Rect2) -> Color:
# 95th-percentile luminance carrier inside the label band: the surface the glyphs
# actually sit on, robust to dark corners.
var samples: Array = []
var x0 := clampi(int(rect.position.x), 0, image.get_width() - 1)
var x1 := clampi(int(rect.end.x), 0, image.get_width() - 1)
var y0 := clampi(int(rect.position.y), 0, image.get_height() - 1)
var y1 := clampi(int(rect.end.y), 0, image.get_height() - 1)
for x in range(x0, maxi(x0 + 1, x1), 2):
for y in range(y0, maxi(y0 + 1, y1), 2):
samples.append(image.get_pixel(x, y))
if samples.is_empty():
return Color.BLACK
samples.sort_custom(func(a, b): return _luminance(a) < _luminance(b))
return samples[mini(samples.size() - 1, int(float(samples.size() - 1) * 0.95))]
func _notice_path() -> String:
return notice_node_path
func _check_m84(view: Control, locale: String, frame_path: String) -> void:
var inset := 4.0
var copy := _rect_of(view, "GuildKioskCopyArea")
if copy.size.x <= 0.0:
m84_failures.append("%s copy area missing" % locale)
return
var inner := Rect2(PLATE.position + Vector2(inset, inset), PLATE.size - Vector2(inset * 2.0, inset * 2.0))
if not inner.encloses(copy):
m84_failures.append("%s copy area %s escapes the plate inset %s" % [locale, str(copy), str(inner)])
if copy.position.y < PLATE.position.y:
m84_failures.append("%s copy area top %.1f above the plate top %.1f" % [locale, copy.position.y, PLATE.position.y])
if CURTAIN.intersects(copy):
m84_failures.append("%s copy area intersects the red curtain" % locale)
for child in ["GuildKioskText", "GuildContractReason", "GuildMarketReason", "GuildNotice"]:
# M84-E: the board used to carry two same-named node sets, one hidden, and
# every name lookup measured the hidden twin while the visible one painted.
# The single-source rule is a gate from now on.
var matches := view.find_children(child, "", true, false)
if matches.size() != 1:
m84_failures.append("%s %s has %d same-named nodes (single source required)" % [locale, child, matches.size()])
continue
var resolved := matches[0] as Label
if child == "GuildNotice" and resolved != null:
notice_node_path = String(resolved.get_path())
if resolved == null or not resolved.is_visible_in_tree():
m84_failures.append("%s %s is not a visible label (hidden twin?)" % [locale, child])
continue
var rect := _rect_of(view, child)
var ink := _ink_rect(view, child)
if rect.size.x <= 0.0:
m84_failures.append("%s %s missing" % [locale, child])
continue
if not inner.encloses(rect):
m84_failures.append("%s %s rect %s escapes the plate inset" % [locale, child, str(rect)])
if not inner.encloses(ink):
m84_failures.append("%s %s ink %s escapes the plate inset" % [locale, child, str(ink)])
if CURTAIN.intersects(rect) or CURTAIN.intersects(ink):
m84_failures.append("%s %s intersects the red curtain" % [locale, child])
if not PAPER.encloses(_rect_of(view, "GuildNotice")):
m84_failures.append("%s GuildNotice is not on the paper" % locale)
# M84-D: the notice is a warning and must keep the semantic danger ink.
var notice_label := view.find_child("GuildNotice", true, false) as Label
if notice_label == null:
m84_failures.append("%s GuildNotice missing for the semantic check" % locale)
elif notice_label.get_theme_color("font_color") != Color("#8c2f2f"):
m84_failures.append("%s GuildNotice ink is not COLOR_DANGER" % locale)
# Contrast is measured on the rendered frame: the top-rail plates only exist at
# runtime, so sampling the master art would misjudge them. The background probe
# point sits just below the ink, on the same carrier surface.
var image := Image.new()
if image.load(frame_path) != OK:
m84_failures.append("%s rendered frame could not be read" % locale)
return
for node_name in CONTRAST_LABELS:
var label := view.find_child(node_name, true, false) as Label
if label == null:
m84_failures.append("%s %s missing for contrast" % [locale, node_name])
continue
if label.text.strip_edges().is_empty():
continue
var ink := _ink_rect(view, node_name)
# M84-D: sample the actual carrier under the text run. The four inset corners
# of the label rect sit on the carrier (glyphs are centred), and the median
# luminance represents it without catching a neighbouring shadow or stroke.
var rect := _rect_of(view, node_name)
var candidates := [
Vector2i(int(rect.position.x) + 4, int(rect.position.y) + 2),
Vector2i(int(rect.end.x) - 4, int(rect.position.y) + 2),
Vector2i(int(rect.position.x) + 4, int(rect.end.y) - 2),
Vector2i(int(rect.end.x) - 4, int(rect.end.y) - 2),
]
var samples: Array = []
for point in candidates:
var px := clampi(point.x, 0, image.get_width() - 1)
var py := clampi(point.y, 0, image.get_height() - 1)
samples.append(image.get_pixel(px, py))
samples.sort_custom(func(a, b): return _luminance(a) < _luminance(b))
# Carrier = the band's P95 luminance (the surface the glyphs sit on). The
# corner minimum measures the paper's shadowed margin instead, which M84-D
# showed can under-read a warning line by ~0.5 ratio points.
var background: Color = _carrier_p95(image, rect)
var foreground: Color = label.get_theme_color("font_color")
var lighter := maxf(_luminance(foreground), _luminance(background))
var darker := minf(_luminance(foreground), _luminance(background))
var ratio := (lighter + 0.05) / (darker + 0.05)
# Diagnostics: the corner samples and the P95 carrier value make a failure
# readable without digging into the frame by hand.
var p95 := _carrier_p95(image, rect)
ratio_reports.append("%s %s=%.2f:1 (p95=#%s)" % [locale, node_name, ratio, p95.to_html(false)])
if ratio < CONTRAST_MIN:
var corners := PackedStringArray()
for sample_px in samples:
corners.append("#%s" % sample_px.to_html(false))
m84_failures.append("%s %s contrast %.2f:1 below %.1f:1 corners=[%s] p95=#%s" % [
locale, node_name, ratio, CONTRAST_MIN, ", ".join(corners), p95.to_html(false)])
func _settle() -> void:
for _i in range(3):
await process_frame
func _save(viewport: SubViewport, out: String, locale: String, fixture: Vector2i, kind: String) -> void:
var viewport_texture := viewport.get_texture()
if viewport_texture == null:
return
var image := viewport_texture.get_image()
if image != null:
image.save_png(out.path_join("guild-%dx%d-%s-%s.png" % [fixture.x, fixture.y, locale, kind]))