Files
settled-reach/client/tests/visual_capture.gd
T
jpmschweitzerandClaude Opus 5 9b146f9e1f fix(simulation): derive at the octaves a rung can actually reconstruct (T-1240)
Region rendered as fine uniform stucco while District and Quarter, on identical
code, read as terrain. The cause was sampling: `min_wl_m` arrives as an LOD
request and defaults to 0, so every invented octave contributed at every rung.
MIN_WL_BANDS_M was meant to be the floor but is built from the rung's CELL SIZE
(2 x DISTRICT_M), which stopped being the sample spacing at the D-255 extent
inversion — a rung fixes EXTENT now and spacing falls out of the canvas size.
The bands were off by roughly the cell count, and the served path never consulted
them anyway.

The cutoff is now derived from the resolved spacing, which is what this ticket
asked for. Two things had to be measured rather than reasoned to get it right,
and both corrected me.

FIRST: the field was the culprit, not the renderer. I attributed the stucco to
the client stipple painting noise onto a smooth field. Surfacing the terrain
layer's own mean |relief_q gradient| in the capture readout settled it in one
shot: Region 18.24 steps per cell — 144 m of relief between NEIGHBOURING cells —
against District's 0.30 and Quarter's 0.07. The server was sending noise. That
diagnostic ships here for the same reason `plane_variety` did in T-1213: a noisy
field and a renderer inventing noise look identical, and one number separates
them.

SECOND: Nyquist is the wrong threshold. The first version floored at 2 x spacing,
the aliasing limit, and Region barely moved (56.16 -> 59.73 lum spread, gradient
still 18.24) because 2 samples per cycle is unaliased but renders jagged. The
rungs that already worked say what the real bar is: District reconstructs its
finest surviving octave at 34 samples per cycle, Quarter at 135. At 8x, Region
goes to 1.08 gradient and 70.01 spread, and shows ridges and valleys.

THE TRADE, taken deliberately and recorded in the tests: an 8x floor also
truncates the coast warp's 2,048 and 1,024 m octaves at Region, the band T-1160
added for "one coastline at every rung". An earlier test here asserted that band
must survive; it now asserts the opposite. Same reasoning as the relief: a
1,024 m coastline wiggle at 379.3 m per cell is 2.7 samples per cycle, so drawing
it draws noise rather than coastline character — a rung cannot show shape finer
than its own cell. The warp is amplitude-capped sub-pixel on the working grid, so
what is lost is small. If a future pass wants the warp exempt, the fix is a
relief-only floor threaded through derive_at_metres, NOT a lower multiple, which
takes the stucco back.

Global is exempt: its floor would be ~70 km and would truncate the whole warp
band, and it needs none — the orbital derive leaves relief_q flat at 50. District
(3.79 m spacing) and Quarter (0.948 m) floor below every octave in play and
derive byte-identically, which their own test pins.

Cache-safe by construction: the floor is a pure function of (rung, extent,
body_radius), all three already in the step-canvas cache key. 0.4.12 is required
anyway — this changes derived BYTES at Region, so a 0.4.11 entry holds a field
this build would never produce.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 12:43:46 +02:00

715 lines
27 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 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"
)
% [
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)),
]
)
)
## 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()])