Files
settled-reach/client/tests/visual_capture.gd
T
jpmschweitzerandClaude Fable 5 fcf913a413 fix(client): move golden-provenance key out of scenarios{} — crashed visual_capture --list (PR #180 review, Hoshe)
The _comment_atlas_goldens key inside scenarios{} hit _print_list's .get() on a bare String (reproduced: Invalid call on String at visual_capture.gd:699). Moved to a top-level key alongside resolution/golden_dir; _print_list now also skips _-prefixed and non-Dictionary entries so future comment keys cannot crash it. Verified: --list runs clean, JSON valid.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 21:40:03 +02:00

715 lines
26 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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 fog_3state --output /abs/path
##
## godot ... -- --flow flow_dialogue --output /abs/path --interval 3
## godot ... -- --list
##
## T-1120: --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/zoom/overlay-set combo in ONE Godot boot).
##
## 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
# Extra frames for state propagation + viewport texture lag
await process_frame
await process_frame
# Capture
await _capture("%s/%s.png" % [_output_dir, _scenario])
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-1120: 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 × zoom × overlay-set
## combos from tests/atlas_shots.json, capturing a PNG per combo.
##
## Live-mode only (SR_LIVE=1) — atlas data (heightmap/markers/generation
## layers) all come from server/data/systems.db via the running server, 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): the 12 curated atlas_* goldens promoted from
## this matrix are machine-local — captured/verified exact-match on one box,
## and goldens 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
var gen_timeout: float = float(matrix_cfg.get("gen_layers_timeout_seconds", 15.0))
var city_timeout: float = float(matrix_cfg.get("city_names_timeout_seconds", 15.0))
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", ...)).
# 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 = ""
var ready_bodies: Array = []
var no_layers_bodies: Array = []
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 heightmap/markers/generation layers) 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.
if body_id != current_body_id:
current_body_id = body_id
app._regional_screen.enter(
{"body": body, "system": _system_dict_for(body)}
)
var layers_ready := await _wait_for_atlas_layers_ready(body_id, gen_timeout)
if layers_ready:
ready_bodies.append(body_id)
else:
no_layers_bodies.append(body_id)
await _wait_for_city_names(viewer, city_timeout)
# Settle frames: heightmap/marker draw + overlay queue_redraw calls.
for i in range(4):
await process_frame
# Apply overlay set: clear every gen_* toggle, then enable this shot's set.
_apply_overlay_set(viewer, shot.get("overlays", []))
# Apply zoom. Some bodies have "fit" shots ordered AFTER a 2.0/4.0 shot
# in atlas_shots.json (e.g. set B's zfit comes after z2.0/z4.0), so this
# can't assume show_body()/enter()'s one-time internal _fit_to_view()
# call is still the current view — every "fit" shot recomputes it.
var zoom_val: Variant = shot.get("zoom", "fit")
if zoom_val is String and zoom_val == "fit":
_apply_fit_zoom(viewer)
else:
_apply_centered_zoom(viewer, float(zoom_val))
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
await _capture("%s/%s" % [_output_dir, filename])
print(
(
"visual_capture: atlas_matrix done — %d/%d bodies had Ready generation layers"
% [ready_bodies.size(), bodies.size()]
)
)
if not no_layers_bodies.is_empty():
print("visual_capture: atlas_matrix — no Ready layers for: %s" % str(no_layers_bodies))
## Build the {system_id, proper_name} dict AtlasViewer.show_body()/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 (the "always" group — terrain/infrastructure/
## etc. — is never in _overlay_visibility's toggle set and set_overlay_visible
## is a no-op for locked ids, so this only ever touches gen_*/population_density/
## production_zones/corp_presence toggles) 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)
## Zoom to `zoom`, re-centering the offset the same way _fit_to_view() centers
## it (formula read from atlas_viewer.gd) — reusing the current (fit) offset
## verbatim at a different zoom would leave most of a 4x-zoomed texture off-
## screen, since that offset was computed to center the much-smaller fit-zoom
## texture. Uses only AtlasViewer's public surface (get_heightmap_texture(),
## get_rect(), set_view()) — no private field access.
func _apply_centered_zoom(viewer: Variant, zoom: float) -> void:
var tex: Texture2D = viewer.get_heightmap_texture()
if tex == null:
viewer.set_view(zoom, viewer.get_view_offset())
return
var tex_w: float = float(tex.get_width())
var tex_h: float = float(tex.get_height())
var sz: Vector2 = viewer.get_rect().size
if sz == Vector2.ZERO:
sz = Vector2(1280.0, 720.0)
var scaled: Vector2 = Vector2(tex_w, tex_h) * zoom
var offset: Vector2 = (sz - scaled) * 0.5 + Vector2(0, 20)
viewer.set_view(zoom, offset)
## Recomputes AtlasViewer's default "fit" zoom+offset (same formula as its
## private _fit_to_view(), read from atlas_viewer.gd: fit zoom = min(avail.x/
## tex_w, avail.y/tex_h) * 0.92, avail = viewport size minus a 60px header
## allowance) using only public surface — needed because some shots order a
## "fit" zoom AFTER a 2.0/4.0 shot for the same body (set B), so the harness
## can't rely on show_body()'s one-time internal _fit_to_view() call still
## being the current view. Does NOT replicate _fit_to_view()'s MIN_ZOOM/
## MAX_ZOOM clamp — set_view() already clamps internally (and MIN_ZOOM/
## MAX_ZOOM aren't reachable here anyway: AtlasViewer is a class_name type,
## unresolvable by identifier in -s mode per this file's header note).
func _apply_fit_zoom(viewer: Variant) -> void:
var tex: Texture2D = viewer.get_heightmap_texture()
if tex == null:
viewer.set_view(1.0, Vector2.ZERO)
return
var tex_w: float = float(tex.get_width())
var tex_h: float = float(tex.get_height())
var sz: Vector2 = viewer.get_rect().size
if sz == Vector2.ZERO:
sz = Vector2(1280.0, 720.0)
var avail: Vector2 = sz - Vector2(0, 60)
var fit_x: float = avail.x / tex_w
var fit_y: float = avail.y / tex_h
var fit_zoom: float = minf(fit_x, fit_y) * 0.92
var scaled: Vector2 = Vector2(tex_w, tex_h) * fit_zoom
var offset: Vector2 = (sz - scaled) * 0.5 + Vector2(0, 20)
viewer.set_view(fit_zoom, offset)
## Poll SimBridge.atlas_layers_received until this body's response is Ready,
## or the timeout elapses. Returns true on Ready, false on timeout — caller
## captures either way and logs which bodies never reached Ready (per the
## ticket: "capture anyway + log a warning naming the body").
## AtlasViewer already re-polls Pending responses internally
## (_schedule_gen_retry, GEN_RETRY_DELAY/GEN_MAX_RETRIES ~10s ceiling) via its
## own atlas_layers_received connection — this just watches the same signal
## from the outside to know when to stop waiting and capture.
##
## State is carried in a single-element Array (`state[0]`/`state[1]`), NOT
## plain bool locals — GDScript lambda closures capture value-type locals
## (bool/int/float) BY VALUE at closure-creation time, so a `func() -> void:
## received = true` assignment inside the lambda does NOT propagate back to
## an outer `var received := false`. Confirmed by direct test (godot4
## --headless -s closure_test.gd): a bool set true inside a lambda reads back
## false outside it. Arrays/Dictionaries are reference types, so mutating
## through a captured reference DOES propagate — this was the actual reason
## every body in the first real run timed out at exactly `timeout_seconds`
## despite the server reaching Ready in ~2s (verified via a standalone probe
## script against a live --test-mode server).
func _wait_for_atlas_layers_ready(body_id: String, timeout_seconds: float) -> bool:
var sim_bridge := root.get_node("/root/SimBridge")
var state: Array = [false, false] # [received, is_ready]
var handler := func(response: Dictionary) -> void:
if str(response.get("body_id", "")) != body_id:
return
if str(response.get("status", "")) == "Ready":
state[0] = true
state[1] = true
elif str(response.get("status", "")) not in ["Pending", ""]:
# NotFound/Error — stop waiting, this body will never reach Ready.
state[0] = true
state[1] = false
sim_bridge.atlas_layers_received.connect(handler)
var elapsed := 0.0
var frame_budget := 1.0 / 60.0
while not state[0] and elapsed < timeout_seconds:
await process_frame
elapsed += frame_budget
sim_bridge.atlas_layers_received.disconnect(handler)
if not state[0]:
push_warning(
(
"visual_capture: atlas_matrix — no Ready/terminal status for '%s' after %.1fs, capturing terrain-only"
% [body_id, timeout_seconds]
)
)
return false
if not state[1]:
push_warning("visual_capture: atlas_matrix — '%s' returned a non-Ready terminal status" % body_id)
return state[1]
## Poll until city names have arrived for the viewer's current body, or the
## timeout elapses (city dots/labels are optional dressing on the shot, not a
## hard requirement — captures proceed either way).
func _wait_for_city_names(viewer: Variant, timeout_seconds: float) -> void:
var elapsed := 0.0
var frame_budget := 1.0 / 60.0
while viewer.has_pending_city_names_request() and elapsed < timeout_seconds:
await process_frame
elapsed += frame_budget
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-1120: 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 — T-1120 Atlas screenshot capture matrix (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()])