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