New SR_LIVE sandbox scene: CharacterVisual composited in a 3D greybox world derived from server snapshots. Per-leg constant-velocity interpolation keyed to the stance throttle, 'server feet / client eyes' facing (wire octant while moving, client aim octant idle), cadence-synced gait state machine on AnimationPlayer custom blends, D-148 orthographic follow camera (-30deg default, T-cycle presets), sim-space grid shader, camera-side wall cutaway, accumulating never-evict tile store with four-state visibility tint. Additive seams only: InputMapper.facing_angle_provider (2D path unchanged), CharacterVisual.play_animation blend_time param + get_animation_player(). Visual harness gains per-scenario scene field + SR_AUTOPILOT input scripting. 210 new gdUnit assertions across five suites; verified live (230/230 total, clean smoke, screenshot at .cache/screenshots/locomotion_idle_live.png). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
389 lines
12 KiB
GDScript
389 lines
12 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
|
|
##
|
|
## 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:
|
|
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()])
|
|
|
|
|
|
## 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
|
|
|
|
|
|
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()])
|