feat(client): capture harness redesigned for the stepped Atlas — (body, rung) goldens (T-1157)

_setup_atlas_golden_shot and _run_atlas_matrix rebuilt on the real D-255
surface: nav.push('regional') through AtlasApp's own body-selection tail,
then StepCanvasViewer.jump_to at a fixed center (inventory item 5) — no
shim over the retired continuous-zoom API. is_pending()-aware bounded
settle (mirroring atlas_agent_driver.gd) and view-transform logging
(inventory item 3) wired into both capture paths. The 12 z2_0/z4_0/zfit
goldens are replaced by 12 (body, rung) goldens captured live against
fresh --test-mode servers; atlas_shots.json/visual.json re-keyed;
atlas_gen_open's stale gen_l1_* overlay ids fixed to gen_dw_temp.

Verification: two consecutive District runs byte-identical, and a
cold-vs-warm disk-cache invariance proof on both terrain draw branches
(Global/NEAREST, District/LINEAR) — byte-identical either way, so
capture output does not depend on the shared user://atlas_cache state.
DEVOPS.md's real-rendering exception note now records the fold-target
mapping for the smoke file T-1182 already deleted (Global + District
goldens exercise its two real-pixel draw branches). Old legacy-tracked
.import sidecars go with their PNGs; new goldens ship bare per
.gitignore's client/**/*.import rule.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 22:13:37 +02:00
co-authored by Claude Fable 5
parent 0822390f7a
commit a45d4a5f58
42 changed files with 313 additions and 828 deletions
+136 -176
View File
@@ -10,9 +10,10 @@
## godot ... -- --flow flow_dialogue --output /abs/path --interval 3
## godot ... -- --list
##
## T-1120: --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/zoom/overlay-set combo in ONE Godot boot).
## 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.
@@ -240,6 +241,19 @@ func _run_scenario(_main_node: Node) -> void:
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
@@ -248,6 +262,82 @@ func _run_scenario(_main_node: Node) -> void:
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".
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
print(
(
"visual_capture: view-transform[%s] rung=%s world_center=%s held_extent=%s "
+ "canvas_position=%s canvas_scale=%s footprint_px=%dx%d"
)
% [
scenario_name,
str(summary.get("rung", "")),
str(summary.get("world_center", [])),
str(summary.get("held_extent", [])),
str(canvas_pos),
str(canvas_scale),
int(summary.get("canvas_width", 0)),
int(summary.get("canvas_height", 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):
@@ -304,19 +394,23 @@ func _run_flow(_main_node: Node) -> void:
print("visual_capture: manifest -> %s" % manifest_path)
## T-1120: 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 × zoom × overlay-set
## combos from tests/atlas_shots.json, capturing a PNG per combo.
## 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) — atlas data (heightmap/markers/generation
## layers) all come from server/data/systems.db via the running server, same
## 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): the 12 curated atlas_* goldens promoted from
## this matrix are machine-local — captured/verified exact-match on one box,
## and goldens are known NOT to port across GPU/font stacks at the current
## 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.
@@ -338,9 +432,6 @@ func _run_atlas_matrix() -> void:
for b: Dictionary in bodies:
body_lookup[str(b.get("body_id", ""))] = b
var gen_timeout: float = float(matrix_cfg.get("gen_layers_timeout_seconds", 15.0))
var city_timeout: float = float(matrix_cfg.get("city_names_timeout_seconds", 15.0))
print(
(
"visual_capture: atlas_matrix — %d bodies, %d shots, output %s"
@@ -360,7 +451,8 @@ func _run_atlas_matrix() -> void:
return
# First body establishes the "regional" screen via nav.push (matches the
# production entry path — SystemScreen -> nav.push("regional", ...)).
# 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
@@ -380,8 +472,6 @@ func _run_atlas_matrix() -> void:
return
var current_body_id: String = ""
var ready_bodies: Array = []
var no_layers_bodies: Array = []
for shot: Dictionary in shots:
var body_id: String = str(shot.get("body_id", ""))
@@ -390,36 +480,31 @@ func _run_atlas_matrix() -> void:
push_warning("visual_capture: shot references unknown body_id '%s'" % body_id)
continue
# Only re-enter (reload heightmap/markers/generation layers) 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.
# 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)}
)
var layers_ready := await _wait_for_atlas_layers_ready(body_id, gen_timeout)
if layers_ready:
ready_bodies.append(body_id)
else:
no_layers_bodies.append(body_id)
await _wait_for_city_names(viewer, city_timeout)
# Settle frames: heightmap/marker draw + overlay queue_redraw calls.
for i in range(4):
await process_frame
await _settle_atlas_viewer_if_pending(root)
# Apply overlay set: clear every gen_* toggle, then enable this shot's set.
# Apply overlay set: clear every gen_dw_* toggle, then enable this
# shot's own set.
_apply_overlay_set(viewer, shot.get("overlays", []))
# Apply zoom. Some bodies have "fit" shots ordered AFTER a 2.0/4.0 shot
# in atlas_shots.json (e.g. set B's zfit comes after z2.0/z4.0), so this
# can't assume show_body()/enter()'s one-time internal _fit_to_view()
# call is still the current view — every "fit" shot recomputes it.
var zoom_val: Variant = shot.get("zoom", "fit")
if zoom_val is String and zoom_val == "fit":
_apply_fit_zoom(viewer)
else:
_apply_centered_zoom(viewer, float(zoom_val))
# 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
@@ -428,21 +513,16 @@ func _run_atlas_matrix() -> void:
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/%d bodies had Ready generation layers"
% [ready_bodies.size(), bodies.size()]
)
)
if not no_layers_bodies.is_empty():
print("visual_capture: atlas_matrix — no Ready layers for: %s" % str(no_layers_bodies))
print("visual_capture: atlas_matrix done — %d shots captured" % shots.size())
## Build the {system_id, proper_name} dict AtlasViewer.show_body()/enter()
## expects, from an atlas_shots.json body entry (which already carries
## system_id/system_proper_name inline — no separate system lookup needed).
## 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", "")),
@@ -450,10 +530,9 @@ func _system_dict_for(body: Dictionary) -> Dictionary:
}
## Clear every toggleable overlay (the "always" group — terrain/infrastructure/
## etc. — is never in _overlay_visibility's toggle set and set_overlay_visible
## is a no-op for locked ids, so this only ever touches gen_*/population_density/
## production_zones/corp_presence toggles) then enable exactly the requested set.
## 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", ""))
@@ -463,125 +542,6 @@ func _apply_overlay_set(viewer: Variant, overlay_ids: Array) -> void:
viewer.set_overlay_visible(overlay_id, true)
## Zoom to `zoom`, re-centering the offset the same way _fit_to_view() centers
## it (formula read from atlas_viewer.gd) — reusing the current (fit) offset
## verbatim at a different zoom would leave most of a 4x-zoomed texture off-
## screen, since that offset was computed to center the much-smaller fit-zoom
## texture. Uses only AtlasViewer's public surface (get_heightmap_texture(),
## get_rect(), set_view()) — no private field access.
func _apply_centered_zoom(viewer: Variant, zoom: float) -> void:
var tex: Texture2D = viewer.get_heightmap_texture()
if tex == null:
viewer.set_view(zoom, viewer.get_view_offset())
return
var tex_w: float = float(tex.get_width())
var tex_h: float = float(tex.get_height())
var sz: Vector2 = viewer.get_rect().size
if sz == Vector2.ZERO:
sz = Vector2(1280.0, 720.0)
var scaled: Vector2 = Vector2(tex_w, tex_h) * zoom
var offset: Vector2 = (sz - scaled) * 0.5 + Vector2(0, 20)
viewer.set_view(zoom, offset)
## Recomputes AtlasViewer's default "fit" zoom+offset (same formula as its
## private _fit_to_view(), read from atlas_viewer.gd: fit zoom = min(avail.x/
## tex_w, avail.y/tex_h) * 0.92, avail = viewport size minus a 60px header
## allowance) using only public surface — needed because some shots order a
## "fit" zoom AFTER a 2.0/4.0 shot for the same body (set B), so the harness
## can't rely on show_body()'s one-time internal _fit_to_view() call still
## being the current view. Does NOT replicate _fit_to_view()'s MIN_ZOOM/
## MAX_ZOOM clamp — set_view() already clamps internally (and MIN_ZOOM/
## MAX_ZOOM aren't reachable here anyway: AtlasViewer is a class_name type,
## unresolvable by identifier in -s mode per this file's header note).
func _apply_fit_zoom(viewer: Variant) -> void:
var tex: Texture2D = viewer.get_heightmap_texture()
if tex == null:
viewer.set_view(1.0, Vector2.ZERO)
return
var tex_w: float = float(tex.get_width())
var tex_h: float = float(tex.get_height())
var sz: Vector2 = viewer.get_rect().size
if sz == Vector2.ZERO:
sz = Vector2(1280.0, 720.0)
var avail: Vector2 = sz - Vector2(0, 60)
var fit_x: float = avail.x / tex_w
var fit_y: float = avail.y / tex_h
var fit_zoom: float = minf(fit_x, fit_y) * 0.92
var scaled: Vector2 = Vector2(tex_w, tex_h) * fit_zoom
var offset: Vector2 = (sz - scaled) * 0.5 + Vector2(0, 20)
viewer.set_view(fit_zoom, offset)
## Poll SimBridge.atlas_layers_received until this body's response is Ready,
## or the timeout elapses. Returns true on Ready, false on timeout — caller
## captures either way and logs which bodies never reached Ready (per the
## ticket: "capture anyway + log a warning naming the body").
## AtlasViewer already re-polls Pending responses internally
## (_schedule_gen_retry, GEN_RETRY_DELAY/GEN_MAX_RETRIES ~10s ceiling) via its
## own atlas_layers_received connection — this just watches the same signal
## from the outside to know when to stop waiting and capture.
##
## State is carried in a single-element Array (`state[0]`/`state[1]`), NOT
## plain bool locals — GDScript lambda closures capture value-type locals
## (bool/int/float) BY VALUE at closure-creation time, so a `func() -> void:
## received = true` assignment inside the lambda does NOT propagate back to
## an outer `var received := false`. Confirmed by direct test (godot4
## --headless -s closure_test.gd): a bool set true inside a lambda reads back
## false outside it. Arrays/Dictionaries are reference types, so mutating
## through a captured reference DOES propagate — this was the actual reason
## every body in the first real run timed out at exactly `timeout_seconds`
## despite the server reaching Ready in ~2s (verified via a standalone probe
## script against a live --test-mode server).
func _wait_for_atlas_layers_ready(body_id: String, timeout_seconds: float) -> bool:
var sim_bridge := root.get_node("/root/SimBridge")
var state: Array = [false, false] # [received, is_ready]
var handler := func(response: Dictionary) -> void:
if str(response.get("body_id", "")) != body_id:
return
if str(response.get("status", "")) == "Ready":
state[0] = true
state[1] = true
elif str(response.get("status", "")) not in ["Pending", ""]:
# NotFound/Error — stop waiting, this body will never reach Ready.
state[0] = true
state[1] = false
sim_bridge.atlas_layers_received.connect(handler)
var elapsed := 0.0
var frame_budget := 1.0 / 60.0
while not state[0] and elapsed < timeout_seconds:
await process_frame
elapsed += frame_budget
sim_bridge.atlas_layers_received.disconnect(handler)
if not state[0]:
push_warning(
(
"visual_capture: atlas_matrix — no Ready/terminal status for '%s' after %.1fs, capturing terrain-only"
% [body_id, timeout_seconds]
)
)
return false
if not state[1]:
push_warning("visual_capture: atlas_matrix — '%s' returned a non-Ready terminal status" % body_id)
return state[1]
## Poll until city names have arrived for the viewer's current body, or the
## timeout elapses (city dots/labels are optional dressing on the shot, not a
## hard requirement — captures proceed either way).
func _wait_for_city_names(viewer: Variant, timeout_seconds: float) -> void:
var elapsed := 0.0
var frame_budget := 1.0 / 60.0
while viewer.has_pending_city_names_request() and elapsed < timeout_seconds:
await process_frame
elapsed += frame_budget
func _capture(path: String) -> void:
# Wait for GPU to finish rendering current frame
await RenderingServer.frame_post_draw
@@ -703,11 +663,11 @@ func _print_list() -> void:
continue
var desc: String = scenarios[name].get("description", "")
print(" %s — %s" % [name, desc])
# T-1120: atlas_matrix isn't a tests/visual.json scenarios{} entry (it reads
# 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 — T-1120 Atlas screenshot capture matrix (tests/atlas_shots.json, live-mode only)")
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", [])