## Visual test capture engine — boots the entry's "scene" (default main.tscn), ## runs a named scenario or flow, captures viewport PNGs for golden comparison ## or ad-hoc inspection. ## ## Usage: ## godot --rendering-driver opengl3 --fixed-fps 60 --resolution 960x540 \ ## --path client -s res://tests/visual_capture.gd -- \ ## --scenario atlas_GJ820Bc_Global --output /abs/path ## ## godot ... -- --flow flow_dialogue --output /abs/path --interval 3 ## godot ... -- --list ## ## T-1157 (D-255 rewrite): --scenario atlas_matrix --output /abs/path ## (SR_LIVE=1 SR_PORT=N required — reads tests/atlas_shots.json instead of ## tests/visual.json's scenarios{}, loops every body/rung combo in ONE Godot ## boot; goldens key on (body, rung) per the stepped Atlas ladder). ## ## Follows gen_client_fixtures.gd pattern: extends SceneTree, _init -> _run, quit(). ## Requires a real rendering driver (not --headless) for shader execution. extends SceneTree # Load VisualScenarios explicitly — class_name registry not populated in -s mode. # Must create an instance: static methods on loaded GDScript resources aren't # callable via variable (only via class_name, which isn't available in -s mode). var _scenarios: RefCounted = null var _output_dir: String = "" var _scenario: String = "" var _flow: String = "" var _interval: float = 3.0 var _list_mode: bool = false var _config: Dictionary = {} var _is_live: bool = false func _init(): _run.call_deferred() func _run(): # gdlint:disable=max-returns # Parse CLI args (after -- separator) _parse_args() # Load scenarios script and create instance (class_name not available in -s mode) var scenarios_script: GDScript = load("res://tests/visual_scenarios.gd") _scenarios = scenarios_script.new() # Load config _config = _load_config() if _config.is_empty(): push_error("visual_capture: failed to load tests/visual.json") quit(1) return # List mode: print scenario/flow names and exit if _list_mode: _print_list() quit() return # Validate mode if _scenario.is_empty() and _flow.is_empty(): push_error("visual_capture: specify --scenario NAME or --flow NAME (or --list)") quit(1) return if _output_dir.is_empty(): push_error("visual_capture: --output DIR is required") quit(1) return # Ensure output directory exists DirAccess.make_dir_recursive_absolute(_output_dir) # Per-entry overrides (T-1088, design §10.2 — both additive; existing entries # without these keys behave byte-identically): # "scene": alternate root scene (default res://scenes/main.tscn) # "env": env vars set before the scene boots (e.g. SR_AUTOPILOT) — OS-level, # so the scene's _ready() reads them exactly like shell-exported vars; # run-visual has no per-scenario env mechanism, this is it. var entry_cfg := _entry_config() var env_vars: Dictionary = entry_cfg.get("env", {}) for env_key: String in env_vars: OS.set_environment(env_key, str(env_vars[env_key])) # Load root scene (autoloads already initialized from project.godot) var scene_path: String = entry_cfg.get("scene", "res://scenes/main.tscn") var main_scene = load(scene_path) if main_scene == null: push_error("visual_capture: failed to load %s" % scene_path) quit(1) return var main_node = main_scene.instantiate() # Live mode: configure server port BEFORE main.gd._ready() calls connect_to_sim() _is_live = OS.get_environment("SR_LIVE") == "1" if _is_live: var sim_bridge := root.get_node("/root/SimBridge") var port_env := OS.get_environment("SR_PORT") if port_env.is_empty(): push_error("visual_capture: SR_LIVE=1 but SR_PORT not set") quit(1) return sim_bridge.server_port = int(port_env) print("visual_capture: live mode — server port %d" % sim_bridge.server_port) root.add_child(main_node) # Wait for NoiseTexture2D async generation var fog_overlay = main_node.get_node_or_null("World/FogOverlay") if fog_overlay and not fog_overlay._noise_ready: print("visual_capture: waiting for fog noise texture...") await fog_overlay.fog_noise_ready print("visual_capture: fog noise ready") # Set deterministic shader time BEFORE settle frames # Autoload globals not available as identifiers in -s mode — use tree path. root.get_node("/root/FogState").override_time = 1.0 # Settle frames (camera smoothing, fog uniform init, UI layout) var settle_count: int = _config.get("settle_frames", 30) print("visual_capture: settling %d frames..." % settle_count) for i in range(settle_count): await process_frame # Live mode: wait for server connection and first snapshot if _is_live: var sim_bridge := root.get_node("/root/SimBridge") var game_state := root.get_node("/root/GameState") print("visual_capture: waiting for server connection...") var max_frames := 300 # 5 seconds at 60fps var waited := 0 while sim_bridge.state != sim_bridge.ConnectionState.CONNECTED: if sim_bridge.state == sim_bridge.ConnectionState.ERROR: push_error("visual_capture: server connection failed") quit(1) return await process_frame waited += 1 if waited >= max_frames: push_error("visual_capture: connection timeout after %d frames" % waited) quit(1) return print("visual_capture: connected after %d frames" % waited) # Wait for first snapshot from server waited = 0 while game_state.current_tick == 0: await process_frame waited += 1 if waited >= max_frames: push_error("visual_capture: no snapshot after %d frames" % waited) quit(1) return print( "visual_capture: first snapshot tick=%d (%d frames)" % [game_state.current_tick, waited] ) if not _scenario.is_empty(): await _run_scenario(main_node) elif not _flow.is_empty(): await _run_flow(main_node) quit() func _run_scenario(_main_node: Node) -> void: # T-1120: atlas_matrix is not a tests/visual.json scenario entry — it drives # its own config (tests/atlas_shots.json) and loops many captures per Godot # boot instead of one. Dispatch before the tests/visual.json lookup below so # it never needs a (meaningless) scenarios{} entry there. if _scenario == "atlas_matrix": await _run_atlas_matrix() return var scenarios: Dictionary = _config.get("scenarios", {}) if not scenarios.has(_scenario): push_error("visual_capture: unknown scenario '%s'" % _scenario) quit(1) return var scenario_cfg: Dictionary = scenarios[_scenario] var ticks: int = scenario_cfg.get("ticks", 5) print("visual_capture: scenario '%s' — %d ticks" % [_scenario, ticks]) # Apply scenario setup hook (pass root for autoload access) if not _scenarios.apply_setup(_scenario, root): push_warning("visual_capture: no setup hook for '%s'" % _scenario) # Advance simulation ticks (each process_frame polls one snapshot) for i in range(ticks): await process_frame # Post-tick setup (e.g. zone tint patching) _scenarios.post_setup(_scenario, root) # Replay snapshot: inject a real server snapshot through the FULL client pipeline. # Loads MessagePack bytes (exact wire format from server), decodes via Protocol.gd, # then applies through GameState → FogState → shader — same path as live game. var replay_path: String = scenario_cfg.get("replay_snapshot", "") if not replay_path.is_empty(): var project_root := ProjectSettings.globalize_path("res://") var repo_root := project_root.rstrip("/").get_base_dir() var abs_path := repo_root.path_join(replay_path) var rf := FileAccess.open(abs_path, FileAccess.READ) if rf == null: push_error("visual_capture: cannot open replay snapshot %s" % abs_path) quit(1) return var replay_bytes := rf.get_buffer(rf.get_length()) rf.close() # Decode through Protocol.decode_snapshot() — same as live IPC receive path. # This exercises: msgpack decode → entity decode → tile_kind→type mapping → etc. # class_name `Protocol` isn't resolvable in -s mode (see header note); # instantiate the script and call the static decoder on the instance, # then free it — the decoded Dictionary is independent of the node. var protocol_node: Node = load("res://scripts/protocol/protocol.gd").new() var replay_data: Variant = protocol_node.decode_snapshot(replay_bytes) protocol_node.free() if replay_data == null or not replay_data is Dictionary: push_error("visual_capture: Protocol.decode_snapshot failed for %s" % abs_path) quit(1) return print( ( "visual_capture: replaying %s (%d bytes, tick=%s, %d tiles)" % [ replay_path, replay_bytes.size(), str(replay_data.get("tick", "?")), replay_data.get("visible_tiles", []).size() ] ) ) var game_state := root.get_node("/root/GameState") var fog_state := root.get_node("/root/FogState") game_state.apply_snapshot(replay_data) fog_state.update_from_state() # Extra frames for fog uniform propagation for i in range(4): await process_frame # T-1157 (D-255 rewrite): atlas_* golden scenarios navigate the stepped # ladder (visual_scenarios.gd's _setup_atlas_golden_shot) — the fixed tick # count above is not a settle guarantee for a live step-canvas fetch (a # slow server round-trip can still be in flight after `ticks` frames). # Poll StepCanvasRequest.is_pending() the same is_pending()-aware way # atlas_agent_driver.gd's _settle_after_intent() does, with the same # bounded fallback (never an unbounded await), before capturing. Also logs # the view-transform snapshot (T-1157 inventory item 3) right before the # capture it describes. if _scenario.begins_with("atlas_"): await _settle_atlas_viewer_if_pending(root) _log_atlas_view_transform(root, _scenario) # Extra frames for state propagation + viewport texture lag await process_frame await process_frame # Capture await _capture("%s/%s.png" % [_output_dir, _scenario]) ## T-1157 (D-255 rewrite) — is_pending()-aware settle for an atlas_* golden, ## matching atlas_agent_driver.gd's own _settle_after_intent() bounded-fallback ## discipline exactly (same SETTLE_MAX_FRAMES bound, same "log and move on ## rather than hang the whole run" behavior on timeout — inventory item 4, ## full-run-restart-on-anomaly, is the CALLER's responsibility: this function ## never retries, it just stops waiting and lets the capture proceed, so a ## caller comparing two runs sees a real discrepancy instead of a silent hang). const ATLAS_SETTLE_MAX_FRAMES: int = 600 func _settle_atlas_viewer_if_pending(tree_root: Node) -> void: var viewer: Variant = _get_atlas_regional_viewer(tree_root) if viewer == null: return var request: Variant = viewer.get_request() var waited := 0 while request.is_pending() and waited < ATLAS_SETTLE_MAX_FRAMES: await process_frame waited += 1 if request.is_pending(): print( ( "visual_capture: atlas settle timed out after %d frames — request still pending" % ATLAS_SETTLE_MAX_FRAMES ) ) ## T-1157 harness-inventory item 3 (view-transform logging): world_center, ## the held rung, the held canvas extent, and the drawing canvas Node2D's own ## on-screen position/scale — logged at every atlas_* capture so a pixel ## mismatch is root-causable (PR #204's own precedent: this exact log shape ## identified an edge-scroll drift bug by its PAN_SPEED_PX_S*frame_time ## fingerprint) instead of a bare "pixels don't match". ## ## PR #212 review (Tyre finding 1): `footprint_px` previously sourced ## canvas_width/canvas_height off get_current_canvas_summary() — those are the ## held canvas's CELL dimensions (gridunits), not its on-screen PIXEL ## footprint, which is exactly the wrong quantity for a log whose whole job is ## catching a fit-scale-multiplier bug (the T-1192 Global integer-fit ratio, ## PR #204's own drift class). The real on-screen footprint is ## StepCanvasTerrainLayer's own `_footprint_px` — private to that node, so ## rather than reach into it (this cluster's own "no private-field reach" ## discipline — get_current_canvas_summary()'s doc makes the same call for ## `_canvas_ref`), this recomputes the identical value from the SAME public, ## pure function the terrain layer itself calls to produce it ## (StepCanvasTransport.canvas_footprint_px(rung, extent) — ## step_canvas_terrain_layer.gd's own rebuild_from_canvas(): ## `_footprint_px = StepCanvasTransport.canvas_footprint_px(rung, new_size)`), ## then multiplies by the SAME canvas_scale already logged here (the T-1192 ## Global integer-fit multiplier, 1.0 elsewhere) — matching ## _centered_view_offset()'s own `raw_footprint * scale` pattern for the ## identical "true on-screen size" quantity. canvas_cells is kept alongside ## it (relabeled from the old footprint_px name) since the cell count is ## still useful log context, just correctly named now. func _log_atlas_view_transform(tree_root: Node, scenario_name: String) -> void: var viewer: Variant = _get_atlas_regional_viewer(tree_root) if viewer == null: return var summary: Dictionary = viewer.get_current_canvas_summary() var canvas_node: Node2D = viewer.get_node_or_null("StepCanvas") var canvas_pos: Vector2 = canvas_node.position if canvas_node else Vector2.ZERO var canvas_scale: Vector2 = canvas_node.scale if canvas_node else Vector2.ONE var rung: String = str(summary.get("rung", "")) var held_extent_raw: Variant = summary.get("held_extent", [0, 0]) var extent_cells := Vector2i(int(held_extent_raw[0]), int(held_extent_raw[1])) var StepCanvasTransport := load( "res://ui/implant/apps/atlas/step_canvas/step_canvas_transport.gd" ) var raw_footprint_px: Vector2 = StepCanvasTransport.canvas_footprint_px(rung, extent_cells) var on_screen_footprint_px: Vector2 = raw_footprint_px * canvas_scale print( ( ( "visual_capture: view-transform[%s] rung=%s world_center=%s held_extent=%s " + "canvas_position=%s canvas_scale=%s footprint_px=%s canvas_cells=%dx%d " + "courses=%d runs=%d longest=%.1fpx drawn=%d settlements=%d " + "planes=%s relief_grad=%.2f elev_grad=%.2f" ) % [ scenario_name, rung, str(summary.get("world_center", [])), str(held_extent_raw), str(canvas_pos), str(canvas_scale), str(on_screen_footprint_px), int(summary.get("canvas_width", 0)), int(summary.get("canvas_height", 0)), int(summary.get("course_count", 0)), int(summary.get("runs_built", 0)), float(summary.get("longest_run_px", 0.0)), int(summary.get("drawn_course_count", 0)), int(summary.get("settlement_count", 0)), str(summary.get("plane_variety", {})), float(summary.get("relief_gradient", 0.0)), float(summary.get("elev_gradient", 0.0)), ] ) ) ## Shared "regional" screen -> StepCanvasViewer accessor for the atlas_* ## golden path — mirrors AtlasAgentInterface's own _get_viewer() null-safety ## (app not instantiated, or "regional" not registered yet), returning null ## rather than erroring so callers can no-op cleanly on a non-atlas or ## not-yet-navigated scenario. func _get_atlas_regional_viewer(tree_root: Node) -> Variant: var registry: Node = tree_root.get_node_or_null("/root/ImplantRegistry") if registry == null: return null var app: Variant = registry.get_app_instance("implant/map") if app == null: return null var regional_screen: Variant = app.get_screen("regional") if regional_screen == null: return null return regional_screen.get_viewer() func _run_flow(_main_node: Node) -> void: var flows: Dictionary = _config.get("flows", {}) if not flows.has(_flow): push_error("visual_capture: unknown flow '%s'" % _flow) quit(1) return var flow_cfg: Dictionary = flows[_flow] var steps: Array = flow_cfg.get("steps", []) var interval: float = flow_cfg.get("interval", _interval) print("visual_capture: flow '%s' — %d steps, %.1fs interval" % [_flow, steps.size(), interval]) # Create output subdirectory for flow frames var flow_dir := "%s/%s" % [_output_dir, _flow] DirAccess.make_dir_recursive_absolute(flow_dir) var manifest_lines: PackedStringArray = [] var frame_idx: int = 0 for step in steps: if not step is Dictionary: continue var action: String = step.get("action", "wait") var label: String = step.get("label", action) # Apply action _scenarios.apply_flow_action(action, root) # Wait for interval (timer yields back to main loop — frames keep rendering) await create_timer(interval).timeout # Extra frames for viewport texture to catch up await process_frame await process_frame # Capture frame var frame_name := "%s_%03d.png" % [_flow, frame_idx] await _capture("%s/%s" % [flow_dir, frame_name]) # Build manifest line var total_seconds: int = int((frame_idx + 1) * interval) var timecode := "%d:%02d" % [total_seconds / 60, total_seconds % 60] manifest_lines.append("%03d %s %s" % [frame_idx, timecode, label]) frame_idx += 1 # Write manifest sidecar var manifest_path := "%s/%s_manifest.txt" % [flow_dir, _flow] var f := FileAccess.open(manifest_path, FileAccess.WRITE) if f: f.store_string("\n".join(manifest_lines) + "\n") f.close() print("visual_capture: manifest -> %s" % manifest_path) ## T-1157 (D-255 rewrite, supersedes T-1120's zoom/overlay-set matrix): Atlas ## screenshot capture matrix — ONE Godot boot, opens the live Atlas app once ## (same opener as the atlas_gen_open scenario precedent: ## HudGroups.open_app("implant/map")), then loops bodies × rungs from ## tests/atlas_shots.json, capturing a PNG per (body, rung) combo. Rungs ## replace the retired continuous zoom/overlay-set triples per D-255: a ## capture key is now (body, rung), navigated via StepCanvasViewer's real ## jump_to()/is_pending() surface — no set_view()/get_heightmap_texture() ## shim, matching the PR #203 ruling. ## ## Live-mode only (SR_LIVE=1) — every rung's canvas comes from the running ## server over the StepCanvasRequest/Response tagged envelope (D-255(c)), same ## as every other Atlas path. Requires _run()'s live-mode server-connection ## wait to have already completed before this is called. ## ## GOLDEN PROVENANCE (T-1121, carried forward): goldens captured/verified on ## one box are known NOT to port across GPU/font stacks at the current ## tolerance. A golden failing on a different machine is expected drift, not ## a regression, until T-1121 settles the canonical capture box. See the ## _comment_atlas_goldens note in tests/visual.json. func _run_atlas_matrix() -> void: var matrix_cfg := _load_atlas_shots_config() if matrix_cfg.is_empty(): push_error("visual_capture: failed to load tests/atlas_shots.json") quit(1) return var bodies: Array = matrix_cfg.get("bodies", []) var shots: Array = matrix_cfg.get("shots", []) if bodies.is_empty() or shots.is_empty(): push_error("visual_capture: tests/atlas_shots.json has no bodies/shots") quit(1) return var body_lookup: Dictionary = {} for b: Dictionary in bodies: body_lookup[str(b.get("body_id", ""))] = b print( ( "visual_capture: atlas_matrix — %d bodies, %d shots, output %s" % [bodies.size(), shots.size(), _output_dir] ) ) # Open the Atlas the same way a player would (precedent: atlas_gen_open # scenario, visual_scenarios.gd) — HudGroups is the production opener. var hud_groups: Node = root.get_node("/root/HudGroups") var registry: Node = root.get_node("/root/ImplantRegistry") hud_groups.open_app("implant/map") var app: Variant = registry.get_app_instance("implant/map") if app == null: push_error("visual_capture: atlas app not instantiated") quit(1) return # First body establishes the "regional" screen via nav.push (matches the # production entry path — SystemScreen -> nav.push("regional", ...), the # same AtlasApp._on_body_selected() tail every real click reaches). # Subsequent bodies call RegionalScreen.enter() DIRECTLY instead of # nav.push/replace: ImplantApp._on_screen_changed() only calls enter() when # the screen id CHANGES (`if _current_screen_id != new_id`), so pushing or # replacing "regional" -> "regional" again for body #2 onward would be a # silent no-op that leaves the viewer showing body #1 (found via reading # implant_app.gd — not previously documented in the recon blueprint). var first_body_id: String = str(shots[0].get("body_id", "")) var first_body: Dictionary = body_lookup.get(first_body_id, {}) app.nav.push("regional", {"body": first_body, "system": _system_dict_for(first_body)}) var viewer: Variant = app._regional_screen._viewer if viewer == null: push_error("visual_capture: regional screen has no viewer") quit(1) return var current_body_id: String = "" for shot: Dictionary in shots: var body_id: String = str(shot.get("body_id", "")) var body: Dictionary = body_lookup.get(body_id, {}) if body.is_empty(): push_warning("visual_capture: shot references unknown body_id '%s'" % body_id) continue # Only re-enter (reload the Global canvas fresh) when the body # actually changes — shots are grouped per-body in atlas_shots.json, # so this only fires once per body, not once per shot. enter() always # lands on Global (rung 0, D-255(a)); each shot's own rung (below) # then navigates from there. if body_id != current_body_id: current_body_id = body_id app._regional_screen.enter({"body": body, "system": _system_dict_for(body)}) await _settle_atlas_viewer_if_pending(root) # Apply overlay set: clear every gen_dw_* toggle, then enable this # shot's own set. _apply_overlay_set(viewer, shot.get("overlays", [])) # Navigate to this shot's rung. "Global" needs no jump — enter()/the # body-change branch above already landed on it. Every other rung # uses jump_to()'s fixed-center revisit seam at world-metre ZERO (the # center of every body's own region grid, D-255(a)) — a literal, # reproducible point rather than a cursor-anchored derivation. var rung: String = str(shot.get("rung", "Global")) if rung != "Global": viewer.jump_to(Vector2.ZERO, rung) await _settle_atlas_viewer_if_pending(root) await process_frame await process_frame var filename: String = str(shot.get("filename", "")) if filename.is_empty(): push_warning("visual_capture: shot for '%s' has no filename, skipping" % body_id) continue _log_atlas_view_transform(root, filename) await _capture("%s/%s" % [_output_dir, filename]) print("visual_capture: atlas_matrix done — %d shots captured" % shots.size()) ## Build the {system_id, proper_name} dict RegionalScreen.enter()/ ## StepCanvasViewer.enter() expects, from an atlas_shots.json body entry ## (which already carries system_id/system_proper_name inline — no separate ## system lookup needed). func _system_dict_for(body: Dictionary) -> Dictionary: return { "system_id": str(body.get("system_id", "")), "proper_name": str(body.get("system_proper_name", "")), } ## Clear every toggleable overlay (StepCanvasViewer's OVERLAY_DEFS — the three ## gen_dw_* region-ramp colorizers, D-255 post-stepped vocabulary) then enable ## exactly the requested set. func _apply_overlay_set(viewer: Variant, overlay_ids: Array) -> void: for def: Dictionary in viewer.get_overlay_defs(): var overlay_id: String = str(def.get("id", "")) if str(def.get("group", "")) == "toggle": viewer.set_overlay_visible(overlay_id, overlay_ids.has(overlay_id)) for overlay_id: String in overlay_ids: viewer.set_overlay_visible(overlay_id, true) func _capture(path: String) -> void: # Wait for GPU to finish rendering current frame await RenderingServer.frame_post_draw var image := root.get_viewport().get_texture().get_image() if image == null: push_error("visual_capture: viewport image is null — GPU rendering may not be available") quit(1) return var err := image.save_png(path) if err != OK: push_error("visual_capture: failed to save PNG to %s (error %d)" % [path, err]) quit(1) return print("visual_capture: captured -> %s (%dx%d)" % [path, image.get_width(), image.get_height()]) ## Config entry for the active scenario or flow ({} when the name is unknown — ## the mode runners report that error themselves). func _entry_config() -> Dictionary: var section: Dictionary = {} if not _scenario.is_empty(): section = _config.get("scenarios", {}) return section.get(_scenario, {}) if not _flow.is_empty(): section = _config.get("flows", {}) return section.get(_flow, {}) return {} func _load_config() -> Dictionary: # Config is at tests/visual.json relative to repo root. # Repo root = parent of Godot project root (client/). var project_root := ProjectSettings.globalize_path("res://") var repo_root := project_root.rstrip("/").get_base_dir() var config_path := repo_root.path_join("tests/visual.json") var f := FileAccess.open(config_path, FileAccess.READ) if f == null: push_error("visual_capture: cannot open %s" % config_path) return {} var json_text := f.get_as_text() f.close() var parsed = JSON.parse_string(json_text) if parsed == null or not parsed is Dictionary: push_error("visual_capture: invalid JSON in %s" % config_path) return {} return parsed ## T-1120: atlas_shots.json — same load shape as _load_config() (tests/visual.json), ## a sibling file in the same tests/ directory, so the repo_root derivation is ## identical. Kept as a separate function (not folded into _load_config()) since ## the two files have unrelated schemas — merging them would make _config's ## Dictionary shape ambiguous depending on which mode is active. func _load_atlas_shots_config() -> Dictionary: var project_root := ProjectSettings.globalize_path("res://") var repo_root := project_root.rstrip("/").get_base_dir() var config_path := repo_root.path_join("tests/atlas_shots.json") var f := FileAccess.open(config_path, FileAccess.READ) if f == null: push_error("visual_capture: cannot open %s" % config_path) return {} var json_text := f.get_as_text() f.close() var parsed = JSON.parse_string(json_text) if parsed == null or not parsed is Dictionary: push_error("visual_capture: invalid JSON in %s" % config_path) return {} return parsed func _parse_args() -> void: var args := OS.get_cmdline_user_args() var i := 0 while i < args.size(): match args[i]: "--scenario": i += 1 if i < args.size(): _scenario = args[i] "--flow": i += 1 if i < args.size(): _flow = args[i] "--output": i += 1 if i < args.size(): _output_dir = args[i] "--interval": i += 1 if i < args.size(): _interval = float(args[i]) "--list": _list_mode = true _: push_warning("visual_capture: unknown arg '%s'" % args[i]) i += 1 func _print_list() -> void: var scenarios: Dictionary = _config.get("scenarios", {}) var flows: Dictionary = _config.get("flows", {}) print("scenarios:") for name in scenarios: # Skip "_"-prefixed comment keys and any non-Dictionary value — a bare # string here crashed --list once (PR #180: a provenance _comment key # placed inside scenarios{}; now top-level, but stay robust). if String(name).begins_with("_") or not (scenarios[name] is Dictionary): continue var desc: String = scenarios[name].get("description", "") print(" %s — %s" % [name, desc]) # T-1157: atlas_matrix isn't a tests/visual.json scenarios{} entry (it reads # tests/atlas_shots.json instead — see _run_atlas_matrix()), so it can't be # discovered by iterating _config above. Listed by hand so --list stays a # complete index of every --scenario value this script accepts. print( " atlas_matrix — D-255 stepped Atlas capture matrix, keyed on (body, rung) (tests/atlas_shots.json, live-mode only)" ) print("flows:") for name in flows: var steps: Array = flows[name].get("steps", []) print(" %s — %d steps" % [name, steps.size()])