Files
settled-reach/client/tests/visual_capture.gd
T
jpmschweitzerandClaude Opus 4.6 6bcdc48412 feat(client): add visual test harness with golden regression
Gives Claude eyes: `make screenshot` captures a rendered frame,
`make test-visual` compares against golden PNGs, `make visual-update`
regenerates goldens. Built to debug the Sprint 22 fog regression and
prevent future visual regressions across fog, HUD, dialogue, and UI.

Config-driven via tests/visual.json (11 scenarios, 2 flows).
Capture engine boots main.tscn with real GPU rendering (not --headless),
waits for NoiseTexture2D async gen, uses deterministic shader time.

Components:
- visual_capture.gd: SceneTree capture engine (scenario + movie modes)
- visual_scenarios.gd: per-scenario setup hooks
- tooling/visual-diff: pixel comparator (PIL primary, struct fallback)
- tooling/visual-thumbnail: contact sheet + crop tool
- tests/run-visual: suite script (xvfb wrapping, golden workflow)
- fog_state.gd: override_time for deterministic captures
- fog_shader.gd: fog_noise_ready signal for settle sequencing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 16:33:55 +01:00

271 lines
7.9 KiB
GDScript

## 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 = {}
func _init():
_run.call_deferred()
func _run():
# 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()
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
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)
# 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()])