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.
295 lines
13 KiB
GDScript
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]))
|