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>
This commit is contained in:
@@ -52,6 +52,11 @@ var _prev_visible: Dictionary = {} # Tiles visible last frame (for incremental
|
||||
## Toggle via FogState.debug_exploration = true in the console.
|
||||
var debug_exploration: bool = false
|
||||
|
||||
## Deterministic shader time for visual test captures.
|
||||
## When >= 0, fog_shader.gd uses this instead of Time.get_ticks_msec().
|
||||
## Set before settle frames so noise phase is reproducible across runs.
|
||||
var override_time: float = -1.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_resize(Rect2i(0, 0, 64, 64))
|
||||
|
||||
@@ -4,6 +4,9 @@ extends Node2D
|
||||
## Reads textures from FogState autoload, positions rect to cover viewport.
|
||||
## Architecture: docs/architecture/fog-shader-spec.md
|
||||
|
||||
signal fog_noise_ready
|
||||
var _noise_ready: bool = false
|
||||
|
||||
var _fog_rect: ColorRect
|
||||
var _shader_mat: ShaderMaterial
|
||||
|
||||
@@ -36,10 +39,11 @@ func _ready() -> void:
|
||||
noise_tex.width = 256
|
||||
noise_tex.height = 256
|
||||
noise_tex.seamless = true
|
||||
noise_tex.changed.connect(func(): _noise_ready = true; fog_noise_ready.emit())
|
||||
_shader_mat.set_shader_parameter("noise_tex", noise_tex)
|
||||
_shader_mat.set_shader_parameter("tile_size", TILE_SIZE)
|
||||
|
||||
print("FogShader: Initialized (D-059 5-layer)")
|
||||
print("FogShader: Initialized (D-059 3-state)")
|
||||
|
||||
|
||||
func update_fog() -> void:
|
||||
@@ -68,5 +72,6 @@ func update_fog() -> void:
|
||||
_shader_mat.set_shader_parameter("rect_sz", _fog_rect.size)
|
||||
_shader_mat.set_shader_parameter("map_offset", Vector2(FogState.map_bounds.position))
|
||||
_shader_mat.set_shader_parameter("map_size", Vector2(FogState.map_bounds.size))
|
||||
_shader_mat.set_shader_parameter("time", Time.get_ticks_msec() / 1000.0)
|
||||
var t: float = FogState.override_time if FogState.override_time >= 0.0 else Time.get_ticks_msec() / 1000.0
|
||||
_shader_mat.set_shader_parameter("time", t)
|
||||
_shader_mat.set_shader_parameter("debug_exploration", FogState.debug_exploration)
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
## 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()])
|
||||
@@ -0,0 +1,143 @@
|
||||
extends RefCounted
|
||||
## Setup hooks for visual test scenarios.
|
||||
## Config (tests/visual.json) controls WHAT to run; this file controls HOW to
|
||||
## set up each scenario in-engine. Called by visual_capture.gd after main.tscn
|
||||
## is loaded and settled.
|
||||
##
|
||||
## Autoload globals (FogState, SimBridge, GameState) are NOT available as
|
||||
## compile-time identifiers in -s mode. All methods receive tree_root and
|
||||
## look up autoloads via get_node("/root/...").
|
||||
|
||||
## Apply scenario-specific setup before tick advancement.
|
||||
## Returns true if the scenario is recognized, false otherwise.
|
||||
func apply_setup(scenario_name: String, tree_root: Node) -> bool:
|
||||
var sim_bridge := tree_root.get_node("/root/SimBridge")
|
||||
var fog_state := tree_root.get_node("/root/FogState")
|
||||
var game_state := tree_root.get_node("/root/GameState")
|
||||
|
||||
match scenario_name:
|
||||
"fog_3state":
|
||||
# Default TestHarness state — player at (10,10), 8x8 room.
|
||||
# All 3 fog states naturally visible: clear (cone), explored (out of cone), unexplored.
|
||||
pass
|
||||
|
||||
"fog_diagonal":
|
||||
# Diagonal LOS boundary — staircase regression target.
|
||||
# Default room works: the diamond-shaped visibility radius creates diagonal edges.
|
||||
# 10 ticks builds enough explored tiles to show the boundary.
|
||||
pass
|
||||
|
||||
"fog_boundary":
|
||||
# Explored/unexplored transition centered.
|
||||
# Move player south so the northern explored boundary is center-frame.
|
||||
if sim_bridge.harness:
|
||||
sim_bridge.harness.player_pos = Vector2i(10, 12)
|
||||
|
||||
"fog_zone_tint":
|
||||
# Bar zone warm amber tint (D-046).
|
||||
# Inject zone_id into visible_tiles so fog_state.gd writes bar tint.
|
||||
# TestHarness tiles don't include zone_id by default — patch them.
|
||||
if sim_bridge.harness:
|
||||
sim_bridge.harness.player_pos = Vector2i(10, 10)
|
||||
# Zone tint is written from visible_tiles with zone_id field.
|
||||
# We patch GameState.visible_tiles after the first snapshot in post_setup().
|
||||
pass
|
||||
|
||||
"fog_debug":
|
||||
# Raw exploration overlay (green/blue/red).
|
||||
fog_state.debug_exploration = true
|
||||
|
||||
"npc_in_fog":
|
||||
# NPC sprite visible above fog (D-033).
|
||||
# Position player so NPC at (12,9) is at the fog edge.
|
||||
if sim_bridge.harness:
|
||||
sim_bridge.harness.player_pos = Vector2i(10, 10)
|
||||
|
||||
"hud_default":
|
||||
# Full HUD: time, Careful stance, minimap, monologue.
|
||||
# Tick 1 triggers monologue from TestHarness.
|
||||
# Inject Careful stance for visible green indicator.
|
||||
game_state.player_stance = "Careful"
|
||||
|
||||
"dialogue_open":
|
||||
# Dialogue box + interaction prompt + key bar.
|
||||
# Move adjacent to NPC and interact.
|
||||
if sim_bridge.harness:
|
||||
sim_bridge.harness.player_pos = Vector2i(11, 9)
|
||||
sim_bridge.harness.process_input("Interact")
|
||||
|
||||
"dialogue_with_monologue":
|
||||
# Confrontation beat: dialogue + monologue overlap.
|
||||
if sim_bridge.harness:
|
||||
sim_bridge.harness.player_pos = Vector2i(11, 9)
|
||||
sim_bridge.harness.process_input("Interact")
|
||||
# Monologue will be injected in post_setup after dialogue is active.
|
||||
|
||||
"minimap_stance":
|
||||
# Minimap + Sprint stance indicator overlap (top-right).
|
||||
game_state.player_stance = "Sprint"
|
||||
|
||||
"cursor_menu":
|
||||
# Cursor rendering over dialogue option area.
|
||||
if sim_bridge.harness:
|
||||
sim_bridge.harness.player_pos = Vector2i(11, 9)
|
||||
sim_bridge.harness.process_input("Interact")
|
||||
|
||||
_:
|
||||
push_warning("VisualScenarios: unknown scenario '%s'" % scenario_name)
|
||||
return false
|
||||
|
||||
return true
|
||||
|
||||
|
||||
## Post-tick setup — called after N ticks have been advanced.
|
||||
## Use for state that depends on tick processing (e.g. zone tint patching).
|
||||
func post_setup(scenario_name: String, tree_root: Node) -> void:
|
||||
var fog_state := tree_root.get_node("/root/FogState")
|
||||
var game_state := tree_root.get_node("/root/GameState")
|
||||
|
||||
match scenario_name:
|
||||
"fog_zone_tint":
|
||||
# Patch visible_tiles with zone_id for fog_state.gd zone tint pipeline.
|
||||
var patched: Array = []
|
||||
for tile in game_state.visible_tiles:
|
||||
if tile is Dictionary:
|
||||
var t: Dictionary = tile.duplicate()
|
||||
t["zone_id"] = "bar"
|
||||
patched.append(t)
|
||||
game_state.visible_tiles = patched
|
||||
# Force fog_state to re-process with zone tint data
|
||||
fog_state.update_from_state()
|
||||
|
||||
"dialogue_with_monologue":
|
||||
# Inject monologue to create the overlap condition.
|
||||
game_state.current_monologue = {
|
||||
"id": "test_confrontation",
|
||||
"text": "Something about that answer doesn't add up.",
|
||||
"duration_seconds": 5.0,
|
||||
"priority": "high",
|
||||
"is_urgent": true,
|
||||
}
|
||||
|
||||
"hud_default":
|
||||
# Ensure stance is still Careful after ticks (TestHarness may override).
|
||||
game_state.player_stance = "Careful"
|
||||
|
||||
|
||||
## Apply flow action — called for each step in a movie flow.
|
||||
func apply_flow_action(action: String, tree_root: Node) -> void:
|
||||
var sim_bridge := tree_root.get_node("/root/SimBridge")
|
||||
|
||||
match action:
|
||||
"wait":
|
||||
pass # No-op — just captures current state
|
||||
|
||||
"select_option_1":
|
||||
# Simulate selecting the first dialogue option
|
||||
if sim_bridge.harness:
|
||||
sim_bridge.harness.process_input("DialogueOption1")
|
||||
|
||||
_:
|
||||
# Movement actions and Interact pass through to TestHarness
|
||||
if sim_bridge.harness:
|
||||
sim_bridge.harness.process_input(action)
|
||||
Reference in New Issue
Block a user