Files
aetherbound-guild/runtime/tools/m63_entry_visual_probe.gd
T

360 lines
15 KiB
GDScript

extends SceneTree
## M63 entry-page visual probe (W5/DEV-1R). It captures the migrated entry
## states at the shipped fixtures with the runtime content, the chrome-only
## static view and the content-only dynamic view on a neutral field, and it
## runs the shared ink checker on every build. Boot is strict from M63-A; the
## later entry pages join this probe as they migrate.
const EntryShell = preload("res://entry/entry_shell.gd")
const SettingsStore = preload("res://entry/settings_store.gd")
const InkCheck = preload("res://tools/ui_ink_check.gd")
const SIZES := [Vector2i(1280, 720)]
# Owner viewport policy: 1280x720 is the single design authority and owns the
# strict ink verdict; smaller landscape sizes are smoke captures only.
const AUTHORITY_SIZE := 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]
const STATES := ["running", "error"]
const TITLE_DRAFT_PATH := "user://m63_title_probe_draft.json"
const TITLE_COMPANY_PATH := "user://m63_title_probe_company.json"
const TITLE_COMPANY_BACKUP_PATH := "user://m63_title_probe_company.json.bak"
const TITLE_SEED := "13579bdf2468ace00eca8642fdb97531"
const DraftService = preload("res://entry/initial_company_draft_service.gd")
var output_root := ""
var ink_scanned := 0
var probe_text_scale := 1.0
var title_recoverable_ok := true
var ink_violations: Array = []
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
output_root = OS.get_environment("ABG_EVIDENCE_DIR")
if output_root.is_empty():
push_error("M63 entry probe requires the owned test-attempt runner")
quit(1)
return
DirAccess.make_dir_recursive_absolute(output_root)
var self_test := InkCheck.self_test(root)
if self_test.size() < 3:
push_error("M63 ink self-test expected at least 3 violations, got %d" % self_test.size())
quit(1)
return
print("ABG_M63_INK_SELF_TEST_OK violations=%d" % self_test.size())
for size in SIZES:
for locale in LOCALES:
for text_scale in TEXT_SCALES:
probe_text_scale = float(text_scale)
for state in STATES:
await _capture_boot(size, locale, state)
await _capture_setup(size, locale)
await _capture_title(size, locale, "fresh")
await _capture_title(size, locale, "recoverable")
if size == AUTHORITY_SIZE:
await _capture_settings(size, locale, "display")
await _capture_settings(size, locale, "audio")
await _capture_opening(size, locale, "choice")
await _capture_opening(size, locale, "inspect")
await _capture_opening(size, locale, "compare")
await _capture_opening(size, locale, "review")
var report_path := output_root.path_join("ink-violations-m63-entry.json")
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():
print("ABG_M63_ENTRY_INK_OK scanned=%d violations=0" % ink_scanned)
else:
for violation in ink_violations:
push_error("M63 ink violation %s %s: %s" % [violation.node, violation.kind, violation.detail])
print("ABG_M63_ENTRY_INK_FAIL scanned=%d violations=%d" % [ink_scanned, ink_violations.size()])
quit(1)
return
if title_recoverable_ok:
print("ABG_M63_TITLE_RECOVERABLE_OK new_game=enabled continue=enabled")
print("ABG_M63_ENTRY_VISUAL_PROBE_OK sizes=1 locales=2 pages=boot,setup,title,settings,opening states=running,error,fresh,recoverable,display,audio,choice,inspect,compare,review views=static,dynamic,final")
quit(0)
func _capture_boot(size: Vector2i, locale: String, state: String) -> void:
var viewport := SubViewport.new()
viewport.size = size
viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
root.add_child(viewport)
var shell := EntryShell.new()
shell.auto_start = false
viewport.add_child(shell)
await _frames(3)
shell.settings = SettingsStore.new().defaults()
shell.settings.locale = locale
shell.settings.text_scale = probe_text_scale
shell.boot_snapshot = (
{"state": "error", "completed": 2, "total": 4, "step": "preferences", "code": "SETTINGS_FILE_INVALID"}
if state == "error"
else {"state": "running", "completed": 2, "total": 4, "step": "preferences", "code": ""}
)
shell._render_boot_state()
await _frames(4)
_scan_ink(shell, locale, "boot-%dx%d-%s" % [size.x, size.y, state], size == AUTHORITY_SIZE)
await _save(viewport, "boot-%dx%d-%s-%s" % [size.x, size.y, locale, state], "final")
# static: chrome only, runtime copy hidden.
for node_name in ["BootIdentity", "BootStatus", "BootDetail", "BootProgressValue", "BootDiagnostics"]:
var control := shell.find_child(node_name, true, false) as CanvasItem
if control != null:
control.visible = false
await _frames(2)
await _save(viewport, "boot-%dx%d-%s-%s" % [size.x, size.y, locale, state], "static")
for node_name in ["BootIdentity", "BootStatus", "BootDetail", "BootProgressValue", "BootDiagnostics"]:
var control := shell.find_child(node_name, true, false) as CanvasItem
if control != null:
control.visible = true
# dynamic: content only on a neutral field (world hidden).
var world := shell.find_child("BootWorld", true, false) as CanvasItem
var shade := shell.find_child("BootWorldShade", true, false) as CanvasItem
if world != null:
world.visible = false
if shade != null:
shade.visible = false
var neutral := ColorRect.new()
neutral.name = "M63NeutralField"
neutral.color = Color("#5b6165")
neutral.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
neutral.mouse_filter = Control.MOUSE_FILTER_IGNORE
shell.add_child(neutral)
shell.move_child(neutral, 0)
await _frames(2)
await _save(viewport, "boot-%dx%d-%s-%s" % [size.x, size.y, locale, state], "dynamic")
viewport.queue_free()
await process_frame
func _capture_setup(size: Vector2i, locale: String) -> void:
var viewport := SubViewport.new()
viewport.size = size
viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
root.add_child(viewport)
var shell := EntryShell.new()
shell.auto_start = false
viewport.add_child(shell)
await _frames(3)
shell.settings = SettingsStore.new().defaults()
shell.settings.locale = locale
shell.settings.text_scale = probe_text_scale
shell.size = Vector2(size)
shell.show_first_launch()
await _frames(5)
# The shell lays out on resize; drive it once for the offscreen capture.
shell._layout_first_launch()
shell._refresh_first_launch_labels()
await _frames(3)
_scan_ink(shell, locale, "setup-%dx%d" % [size.x, size.y], size == AUTHORITY_SIZE)
await _save(viewport, "setup-%dx%d-%s" % [size.x, size.y, locale], "final")
for node_name in ["FirstLaunchHeading", "FirstLaunchSubheading", "FirstLaunchPreviewHeading", "SetupPreviewTitle", "SetupPreviewDetail"]:
var control := shell.find_child(node_name, true, false) as CanvasItem
if control != null:
control.visible = false
for row_name in ["SetupLanguage", "SetupSafeArea", "SetupTextScale", "SetupReducedMotion", "SetupReducedFlashes", "SetupCaptions"]:
var row := shell.find_child(row_name, true, false) as Control
if row != null:
var label := row.get_node_or_null("RowLabel") as CanvasItem
var value := row.get_node_or_null("ValuePlate") as CanvasItem
if label != null:
label.visible = false
if value != null:
value.visible = false
await _frames(2)
await _save(viewport, "setup-%dx%d-%s" % [size.x, size.y, locale], "static")
viewport.queue_free()
await process_frame
func _capture_title(size: Vector2i, locale: String, state: String) -> void:
var viewport := SubViewport.new()
viewport.size = size
viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
root.add_child(viewport)
var shell := EntryShell.new()
shell.auto_start = false
viewport.add_child(shell)
await _frames(3)
shell.settings = SettingsStore.new().defaults()
shell.settings.setup_completed = true
shell.settings.locale = locale
shell.settings.text_scale = probe_text_scale
shell.initial_company_service.path = TITLE_DRAFT_PATH
shell.initial_company_service.company_path = TITLE_COMPANY_PATH
DirAccess.remove_absolute(ProjectSettings.globalize_path(TITLE_COMPANY_PATH))
DirAccess.remove_absolute(ProjectSettings.globalize_path(TITLE_COMPANY_BACKUP_PATH))
if state == "recoverable":
# M60-I04: only a leftover backup exists; New Game must stay usable.
# Build a real, valid record through the service, then move the primary
# aside so recovery takes the backup path.
var service = DraftService.new()
service.path = TITLE_DRAFT_PATH
service.company_path = TITLE_COMPANY_PATH
var created: Dictionary = service.begin_or_load(TITLE_SEED)
if created.ok:
var offers: Array = created.draft.offers
service.set_selection([String(offers[0].offer_id), String(offers[2].offer_id)])
service.commit_company()
# Move the committed record aside so recovery genuinely takes the
# backup path (a lingering primary would load normally).
var primary := FileAccess.open(TITLE_COMPANY_PATH, FileAccess.READ)
if primary != null:
var company_text := primary.get_as_text()
primary.close()
var backup := FileAccess.open(TITLE_COMPANY_BACKUP_PATH, FileAccess.WRITE)
backup.store_string(company_text)
backup.close()
DirAccess.remove_absolute(ProjectSettings.globalize_path(TITLE_COMPANY_PATH))
shell.size = Vector2(size)
shell.show_title()
await _frames(4)
var new_game := shell.find_child("TitleNewGame", true, false) as Button
var continue_button := shell.find_child("TitleContinue", true, false) as Button
if state == "recoverable" and size == AUTHORITY_SIZE:
if new_game == null or new_game.disabled:
print("ERROR: M63 M60-I04 regression: New Game disabled with a recoverable record")
title_recoverable_ok = false
if continue_button == null or continue_button.disabled:
print("ERROR: M63 M60-I04 regression: Continue disabled with a recoverable record")
title_recoverable_ok = false
if state == "fresh" and size == AUTHORITY_SIZE:
if new_game == null or new_game.disabled:
print("ERROR: M63 Title: New Game disabled with no local record")
title_recoverable_ok = false
_scan_ink(shell, locale, "title-%s-%dx%d" % [state, size.x, size.y], size == AUTHORITY_SIZE)
await _save(viewport, "title-%s-%dx%d-%s" % [state, size.x, size.y, locale], "final")
for node_name in ["TitleOverlay", "TitleOverline", "TitleIdentity", "TitleChronicle", "TitleNotice", "TitleSaveSummary", "TitleContinueReason"]:
var control := shell.find_child(node_name, true, false) as CanvasItem
if control != null:
control.visible = false
await _frames(2)
await _save(viewport, "title-%s-%dx%d-%s" % [state, size.x, size.y, locale], "static")
if state == "recoverable":
DirAccess.remove_absolute(ProjectSettings.globalize_path(TITLE_COMPANY_BACKUP_PATH))
viewport.queue_free()
await process_frame
func _capture_opening(size: Vector2i, locale: String, mode: String) -> void:
var viewport := SubViewport.new()
viewport.size = size
viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
root.add_child(viewport)
var shell := EntryShell.new()
shell.auto_start = false
viewport.add_child(shell)
await _frames(3)
shell.settings = SettingsStore.new().defaults()
shell.settings.setup_completed = true
shell.settings.locale = locale
shell.initial_company_service.path = TITLE_DRAFT_PATH
shell.initial_company_service.company_path = TITLE_COMPANY_PATH
DirAccess.remove_absolute(ProjectSettings.globalize_path(TITLE_DRAFT_PATH))
DirAccess.remove_absolute(ProjectSettings.globalize_path(TITLE_DRAFT_PATH + ".bak"))
DirAccess.remove_absolute(ProjectSettings.globalize_path(TITLE_COMPANY_PATH))
DirAccess.remove_absolute(ProjectSettings.globalize_path(TITLE_COMPANY_BACKUP_PATH))
shell.size = Vector2(size)
shell.show_initial_company()
var page = shell.initial_company_page
await _frames(4)
if page != null and mode == "inspect":
page.inspect_candidate(1)
await _frames(4)
if page != null and mode == "compare":
page.compare_a_index = 0
page.compare_b_index = 2
page.show_compare()
await _frames(4)
if page != null and mode == "review":
var offers: Array = page.draft.offers
page.toggle_candidate(0)
page.toggle_candidate(2)
page.request_review()
await _frames(4)
_scan_ink(shell, locale, "opening-%s-%dx%d" % [mode, size.x, size.y], true)
await _save(viewport, "opening-%s-%dx%d-%s" % [mode, size.x, size.y, locale], "final")
for node_name in ["ChoiceHeader", "CandidateInfo", "ChoiceNotice", "ReviewNotice", "ReviewUnchosen"]:
var control := shell.find_child(node_name, true, false) as CanvasItem
if control != null:
control.visible = false
await _frames(2)
await _save(viewport, "opening-%s-%dx%d-%s" % [mode, size.x, size.y, locale], "static")
DirAccess.remove_absolute(ProjectSettings.globalize_path(TITLE_DRAFT_PATH))
DirAccess.remove_absolute(ProjectSettings.globalize_path(TITLE_DRAFT_PATH + ".bak"))
DirAccess.remove_absolute(ProjectSettings.globalize_path(TITLE_COMPANY_PATH))
DirAccess.remove_absolute(ProjectSettings.globalize_path(TITLE_COMPANY_BACKUP_PATH))
viewport.queue_free()
await process_frame
func _capture_settings(size: Vector2i, locale: String, category: String) -> void:
var viewport := SubViewport.new()
viewport.size = size
viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
root.add_child(viewport)
var shell := EntryShell.new()
shell.auto_start = false
viewport.add_child(shell)
await _frames(3)
shell.settings = SettingsStore.new().defaults()
shell.settings.setup_completed = true
shell.settings.locale = locale
shell.size = Vector2(size)
shell.show_settings(category)
await _frames(4)
_scan_ink(shell, locale, "settings-%s-%dx%d" % [category, size.x, size.y], true)
await _save(viewport, "settings-%s-%dx%d-%s" % [category, size.x, size.y, locale], "final")
for node_name in ["SettingsTitle", "SettingsCaller", "SettingsNotice"]:
var control := shell.find_child(node_name, true, false) as CanvasItem
if control != null:
control.visible = false
var content := shell.find_child("SettingsContent", true, false) as Control
if content != null:
for child in content.get_children():
var canvas := child as CanvasItem
if canvas != null:
canvas.visible = false
await _frames(2)
await _save(viewport, "settings-%s-%dx%d-%s" % [category, size.x, size.y, locale], "static")
viewport.queue_free()
await process_frame
func _scan_ink(view: Control, locale: String, label: String, strict := true) -> void:
var violations := InkCheck.scan(view)
if not strict:
# Smoke sizes report for the record but do not gate: the authority size
# owns the strict verdict (owner viewport policy, 2026-09-13).
for violation in violations:
print("ABG_M63_SMOKE_NOTE %s %s %s" % [label, locale, String(violation.get("kind", "?"))])
return
ink_scanned += 1
for violation in violations:
violation["locale"] = locale
violation["fixture"] = label
ink_violations.append(violation)
func _save(viewport: SubViewport, stem: String, mode: String) -> void:
await RenderingServer.frame_post_draw
var texture := viewport.get_texture()
if texture == null:
return
var image := texture.get_image()
if image != null and not image.is_empty():
image.save_png(output_root.path_join("%s-%s.png" % [stem, mode]))
func _frames(count: int) -> void:
for _index in count:
await process_frame