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>
715 lines
26 KiB
GDScript
715 lines
26 KiB
GDScript
## 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()])
|