RimWorld's technique 4, the reference this ticket names, works by CONTRAST: the
Rockies and Appalachians carry dense hatching and the Great Plains carry none.
Ferrath's Global carried an even wash of dots over every landmass instead —
texture present, information absent.
The cause was a constant measured on the wrong rung. RUGGEDNESS_FULL_SCALE_Q = 4
comes from Region ("d4 2.38"), but the elev_q path it gates only ever RUNS at the
orbital rungs, where relief_q is flat and the stipple falls back to it. Ferrath
Global measures a mean 1-cell elev_q gradient of 0.99, so a coherent 4-cell
baseline reaches ~4 and saturates the constant exactly: every land cell read as
fully rugged.
It now normalizes against the canvas's own measured gradient — the same
self-calibrating shape T-1240 gave the hillshade, and for the same reason: one
constant cannot serve rungs whose sample spacing differs by four orders of
magnitude. A contrast curve rides on top, because even unsaturated the linear
reading puts ordinary ground mid-range and paints grain everywhere.
Measured on Global, before -> after: 1,679 -> 1,900 distinct colours, 145.90 ->
147.39 lum spread, and the pale uplands now stipple visibly denser than the
lowlands beside them.
Recorded because it cost two wrong turns: I first guessed saturation, then
talked myself out of it after measuring a 1-CELL gradient (0.99) against a full
scale meant for the 4-CELL baseline, and shipped a contrast curve alone — which
measurably did nothing (1,679 -> 1,698), because a curve cannot separate values
already clamped to 1.0. The gamma is kept; it does its job now that there is a
range to curve. The elev_q gradient joins relief_q's in the capture readout, so
the next person tuning this can read the number instead of guessing at it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
716 lines
27 KiB
GDScript
716 lines
27 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 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()])
|