## Visual test capture engine — boots 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 ## ## 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) # Load main scene (autoloads already initialized from project.godot) var main_scene = load("res://scenes/main.tscn") if main_scene == null: push_error("visual_capture: failed to load res://scenes/main.tscn") 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: 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) 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()]) 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 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: var desc: String = scenarios[name].get("description", "") print(" %s — %s" % [name, desc]) print("flows:") for name in flows: var steps: Array = flows[name].get("steps", []) print(" %s — %d steps" % [name, steps.size()])