From 493a7345d3a7f23124082328f24f492f6d848cbb Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 22 Jul 2026 14:41:00 +0200 Subject: [PATCH] =?UTF-8?q?fix(client):=20T-1153/T-1152=20round=204=20?= =?UTF-8?q?=E2=80=94=20mosaic=20canvas-local=20frame,=20offset=20recompute?= =?UTF-8?q?=20on=20rung=20crossing,=20tile=20texture=20RID=20lifetime;=20r?= =?UTF-8?q?eal-driver=20draw=20smoke?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three live-found rendering bugs, all invisible to green unit suites: - _draw_tile_mosaic placed tiles from absolute district (0,0); canvas- local (0,0) is held_center - held_n/2 everywhere else, and tile-mode held_n is the whole-body extent — the entire mosaic drew tens of thousands of px off-canvas. New pure district_to_canvas_local() + viewer accessors route every tile through the shared frame. - _maybe_reselect_rung updated held_n across crossings without recomputing _view_offset — the single-window composite landed off- canvas the moment any crossing happened (why District/Quarter were black too). New pure recompute_offset_for_held_n_change(). - _build_tile_texture created an unstored ImageTexture per _draw, racing the RenderingServer's deferred upload — CPU pixels correct, screen white. Per-tile-index texture cache, same reference-identity discipline as the single-window _cached_texture. Structural close of the twice-bitten 'nothing asserts pixels' gap: test_atlas_window_overlay_draw_smoke.gd renders overlay output into a SubViewport and asserts visible pixels for both modes — runs under a real driver (invocation documented in DEVOPS.md, visual_capture precedent; migration to the T-1157 harness noted on that ticket), skips loud-but-green under the gate's headless run (verified green-with-skips AND genuinely red with detection forced off). +7 geometry/crossing tests, all revert-verified. Targeted suites 270 green; gdlint clean. --- client/tests/test_atlas_window_geometry.gd | 104 ++++++ .../test_atlas_window_overlay_draw_smoke.gd | 344 ++++++++++++++++++ client/tests/test_atlas_zoom_ladder.gd | 52 +++ .../apps/atlas/atlas_window_geometry.gd | 57 +++ .../apps/atlas/atlas_window_overlay.gd | 121 ++++-- .../implant/apps/atlas/atlas_window_viewer.gd | 120 +++--- docs/DEVOPS.md | 26 ++ 7 files changed, 732 insertions(+), 92 deletions(-) create mode 100644 client/tests/test_atlas_window_overlay_draw_smoke.gd diff --git a/client/tests/test_atlas_window_geometry.gd b/client/tests/test_atlas_window_geometry.gd index 6f893e654..d1e385609 100644 --- a/client/tests/test_atlas_window_geometry.gd +++ b/client/tests/test_atlas_window_geometry.gd @@ -677,3 +677,107 @@ func test_compute_tile_grid_is_centered_on_the_canonical_origin() -> void: "the tile grid's row centers must average to ~0 (symmetric around the" + " canonical origin's equator row)" ).is_equal_approx(0.0, float(AtlasWindowGeometry.TILE_N)) + + +# ============================================================================= +# Live round 4: district_to_canvas_local() + recompute_offset_for_held_n_change() +# — the two pure functions behind both round-4 draw-path fixes (tile mosaic +# placement, single-window offset recompute across a rung crossing). +# ============================================================================= + + +## A district AT the held window's own center must land at canvas-local +## `(held_n/2 * cell_px, held_n/2 * cell_px)` — the center of the +## `[0, held_n*cell_px)` square the single-window `Rect2(0,0,extent,extent)` +## draw call already assumes. +func test_district_to_canvas_local_center_district_lands_at_half_extent() -> void: + var held_center := Vector2i(100, 200) + var held_n := 64 + var result: Vector2 = AtlasWindowGeometry.district_to_canvas_local( + Vector2(held_center), held_center, held_n, CELL_PIXEL_SIZE + ) + var expected: float = float(held_n) * 0.5 * CELL_PIXEL_SIZE + assert_that(result).is_equal(Vector2(expected, expected)) + + +## The window's own top-left corner (held_center - held_n/2) must land at +## canvas-local (0,0) — the exact invariant single-window `_draw()` and +## `fit_window_view()` both assume. +func test_district_to_canvas_local_top_left_corner_lands_at_origin() -> void: + var held_center := Vector2i(0, 0) + var held_n := 32 + var top_left := Vector2(held_center) - Vector2.ONE * (float(held_n) * 0.5) + var result: Vector2 = AtlasWindowGeometry.district_to_canvas_local( + top_left, held_center, held_n, CELL_PIXEL_SIZE + ) + assert_that(result).is_equal(Vector2.ZERO) + + +## Live round 4's OWN repro, pinned directly: a tile far from held_center +## (0,0) at whole-body scale (held_n ~19,139, Lendel's raw circumference) +## must NOT land near canvas-local (0,0) — the round-4 bug's exact failure +## mode (treating absolute district (0,0) as the canvas origin regardless of +## held_center/held_n) would place it there instead. +func test_district_to_canvas_local_matches_the_live_round_4_repro_scale() -> void: + var held_center := Vector2i.ZERO + var held_n := 19139 # Lendel's raw district-column count (live round 4's own repro) + var tile_center := Vector2(6400, 0) # one TILE_N east of the body's own center + var result: Vector2 = AtlasWindowGeometry.district_to_canvas_local( + tile_center, held_center, held_n, CELL_PIXEL_SIZE + ) + var buggy_result: Vector2 = tile_center * CELL_PIXEL_SIZE # the round-4 bug's own formula + assert_bool(is_equal_approx(result.x, buggy_result.x)).override_failure_message( + "a tile away from held_center must NOT land where the round-4 bug's" + + " absolute-district-(0,0)-relative formula would put it — got %.1f, the" + + " buggy formula's own value is %.1f" + % [result.x, buggy_result.x] + ).is_false() + + +## Zero held_n is a degenerate/never-real-in-practice input (a body always +## has SOME district extent) but must not divide-by-zero or crash — `half` +## is simply 0, so the district maps 1:1 to canvas-local (scaled by cell_px). +func test_district_to_canvas_local_zero_held_n_does_not_crash() -> void: + var result: Vector2 = AtlasWindowGeometry.district_to_canvas_local( + Vector2(5, 5), Vector2i.ZERO, 0, CELL_PIXEL_SIZE + ) + assert_that(result).is_equal(Vector2(5, 5) * CELL_PIXEL_SIZE) + + +## The core contract this function exists for: recomputing `_view_offset` so +## a KNOWN screen point continues to map to canvas-local +## `new_held_n/2 * cell_px` (the new window's own center) — i.e. feeding the +## OUTPUT back through district_to_canvas_local()'s own "center district -> +## half-extent local" identity (tested above) and applying the resulting +## transform must reproduce the SAME screen point exactly. +func test_recompute_offset_for_held_n_change_preserves_the_screen_point() -> void: + var screen_point := Vector2(800.0, 450.0) + var view_zoom := 2.5 + var new_held_n := 16 + var offset: Vector2 = AtlasWindowGeometry.recompute_offset_for_held_n_change( + screen_point, view_zoom, new_held_n, CELL_PIXEL_SIZE + ) + var new_local: Vector2 = Vector2.ONE * (float(new_held_n) * 0.5 * CELL_PIXEL_SIZE) + var reconstructed_screen_point: Vector2 = new_local * view_zoom + offset + assert_that(reconstructed_screen_point).is_equal_approx(screen_point, Vector2.ONE * 0.01) + + +## Live round 4's OWN repro: crossing from Region (~thousands-districts held_n) +## to District (64) or Quarter (16) must produce a DIFFERENT offset than +## leaving `_view_offset` untouched would — pinning that this function's +## OUTPUT actually depends on `new_held_n` (the exact thing the round-4 bug +## got wrong by never calling this function at all). +func test_recompute_offset_for_held_n_change_differs_for_different_held_n() -> void: + var screen_point := Vector2(800.0, 450.0) + var view_zoom := 3.378 # live round 4's own District-band zoom value + var offset_district: Vector2 = AtlasWindowGeometry.recompute_offset_for_held_n_change( + screen_point, view_zoom, 64, CELL_PIXEL_SIZE + ) + var offset_quarter: Vector2 = AtlasWindowGeometry.recompute_offset_for_held_n_change( + screen_point, view_zoom, 16, CELL_PIXEL_SIZE + ) + assert_that(offset_district).override_failure_message( + "a rung crossing that changes held_n must recompute a DIFFERENT" + + " _view_offset — reusing the same offset across the crossing is" + + " exactly the live round 4 bug (composite renders off-canvas)" + ).is_not_equal(offset_quarter) diff --git a/client/tests/test_atlas_window_overlay_draw_smoke.gd b/client/tests/test_atlas_window_overlay_draw_smoke.gd new file mode 100644 index 000000000..b7e3e71f8 --- /dev/null +++ b/client/tests/test_atlas_window_overlay_draw_smoke.gd @@ -0,0 +1,344 @@ +## Live round 4: a REAL draw smoke test — the "does anything draw at all" +## gap has now bitten twice (round 4's tile-mosaic coordinate bug AND its +## per-tile-texture-lifetime bug, both invisible to test_atlas_window_overlay.gd's +## existing suite, which only asserts on the CACHE FIELDS being populated — +## never on an actual composited pixel). This file closes that gap +## structurally: render AtlasWindowOverlay into a REAL SubViewport, force a +## GPU sync, grab the rendered Image, and assert a meaningful fraction of +## pixels differ from the background color — for BOTH the single-window path +## (a) and the tile-mosaic path (b), matching the coordinator's explicit ask. +## +## **REQUIRES A REAL RENDERING DRIVER — SKIPS (not fails) under +## `tests/run-godot`'s hardcoded `--headless`** (dummy driver, no GPU texture +## output; confirmed directly: SubViewport.get_texture().get_image() returns +## an all-zero/unusable image under it). This matters beyond "the assertions +## are meaningless there": the push gate runs the FULL suite through +## `tests/run-godot --headless` for every push, for everyone — a loud FAILURE +## here would bounce every future push project-wide, not just report a local +## false negative. Every test below carries the gdUnit4 fuzzer-arg skip +## convention (`_do_skip`/`_skip_reason`, matching test_input_gate_live.gd's +## own server-binary-not-built skip) keyed on `_dummy_renderer_active()`, so +## `tests/run-godot --filter test_atlas_window_overlay_draw_smoke` reports +## green-with-skips under headless, not red. +## +## To actually exercise this file's assertions, run it with a real driver: +## godot4 --display-driver x11 --rendering-driver opengl3 \ +## -s addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -c \ +## -a res://tests/test_atlas_window_overlay_draw_smoke.gd +## (matching tests/visual_capture.gd's own documented "requires a real +## rendering driver" precedent — see docs/DEVOPS.md's own note on this file.) +class_name TestAtlasWindowOverlayDrawSmoke +extends GdUnitTestSuite + +const AtlasWindowOverlay := preload("res://ui/implant/apps/atlas/atlas_window_overlay.gd") +const AtlasWindowGeometry := preload("res://ui/implant/apps/atlas/atlas_window_geometry.gd") + +const COLOR_BG: Color = Color("#0d1117") # AtlasWindowViewer.COLOR_BG, mirrored (private const) +const VIEWPORT_SIZE: Vector2i = Vector2i(512, 512) + +## Minimum fraction of the captured image that must differ from COLOR_BG for +## a draw to count as "genuinely rendered something" — low enough to tolerate +## a mostly-water/mostly-one-color composite (round 4's own repro shots were +## legitimately near-uniform ocean at some zooms), high enough that a +## fully-blank/fully-background/fully-white frame (both round 4 bugs) fails it. +const MIN_NON_BACKGROUND_FRACTION: float = 0.05 + +const SKIP_REASON: String = ( + "no real rendering driver (dummy/headless) — run with e.g." + + " `godot4 --display-driver x11 --rendering-driver opengl3" + + " -s addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -c" + + " -a res://tests/test_atlas_window_overlay_draw_smoke.gd` to exercise this file" +) + + +## True under Godot's `--display-driver headless` (the dummy renderer +## `tests/run-godot`'s hardcoded `--headless` flag selects) — `DisplayServer. +## get_name()` reports `"headless"` there and the real driver name (`"X11"`, +## `"Wayland"`, etc.) otherwise, confirmed directly against both this +## worktree's `tests/run-godot` invocation and a real `--display-driver x11 +## --rendering-driver opengl3` run. Named as a function, not a const, since +## `DisplayServer` singleton state isn't available at script-parse time. +static func _dummy_renderer_active() -> bool: + return DisplayServer.get_name() == "headless" + + +## Single-window viewer stub — mirrors test_atlas_window_overlay.gd's +## _ViewerStub exactly (is_tile_mode() -> false), so this exercises the +## SAME single-window draw path that suite's cache tests cover, just +## through a REAL render instead of inspecting `_cached_texture` directly. +class _SingleWindowViewerStub: + var window: Variant = null + + func get_district_window() -> Variant: + return window + + func is_overlay_visible(_overlay_id: String) -> bool: + return false + + func get_cell_pixel_size() -> float: + return 16.0 + + func is_tile_mode() -> bool: + return false + + +## Tile-mode viewer stub — is_tile_mode() -> true, get_tile_set() returns a +## bare object exposing get_tiles() (AtlasWindowOverlay's own duck-typed +## contract, matching AtlasWindowTileSet.get_tiles()'s public shape exactly: +## Array of {"center": Vector2i, "window": Variant}). +class _TileModeViewerStub: + var tiles: Array = [] + var held_center: Vector2i = Vector2i.ZERO + var held_n: int = 0 + + func get_district_window() -> Variant: + return null + + func is_overlay_visible(_overlay_id: String) -> bool: + return false + + func get_cell_pixel_size() -> float: + return 16.0 + + func is_tile_mode() -> bool: + return true + + func get_tile_set() -> Variant: + return _TileSetStub.new(tiles) + + func get_held_center() -> Vector2i: + return held_center + + func get_held_n() -> int: + return held_n + + +class _TileSetStub: + var _tiles: Array = [] + + func _init(tiles: Array) -> void: + _tiles = tiles + + func get_tiles() -> Array: + return _tiles + + +## A `Node2D._draw()`-based flat-fill background — deliberately NOT a +## `ColorRect` (a `Control`). A `ColorRect` parented directly under a bare +## `SubViewport` (no intervening `Control` container establishing its own +## layout rect) did not reliably render in this harness: sampled pixels came +## back fully transparent `(0,0,0,0)` regardless of the ColorRect's `color`/ +## `size`, even after entering the tree before sizing. `AtlasWindowOverlay` +## itself is a bare `Node2D` using `draw_rect()` for its own background wash +## (`AtlasWindowViewer._draw()`'s own `COLOR_BG` fill) — matching that same, +## already-proven-working `Node2D.draw_rect()` pattern here sidesteps +## whatever `Control`-specific layout/compositing gap caused the ColorRect +## failure, rather than debugging that gap for its own sake. +class _BackgroundRect extends Node2D: + var fill_color: Color = Color.BLACK + var fill_size: Vector2 = Vector2.ZERO + + func _draw() -> void: + draw_rect(Rect2(Vector2.ZERO, fill_size), fill_color) + + +static func _mock_window(center: Vector2i = Vector2i.ZERO, n: int = 64) -> Dictionary: + # A checkerboard-ish morphology spread (not all-one-zone) so the built + # composite has genuine color VARIATION, not just "one flat non-background + # color" — closer to what a real terrain response looks like. + var cells: int = n * n + var morphology := PackedByteArray() + var elev_q := PackedByteArray() + morphology.resize(cells) + elev_q.resize(cells) + for i in range(cells): + morphology[i] = (i % 4) as int # cycles through the 4 morphology zones + elev_q[i] = (i * 7) % 100 as int + return { + "center": [center.x, center.y], + "n": n, + "granularity_v2": "District", + "morphology": morphology, + "elev_q": elev_q, + "temp_dc": [], + "moisture_q": PackedByteArray(), + "vegetation": PackedByteArray(), + "glaciation": PackedByteArray(), + } + + +## Renders `overlay` (any Node2D with its own `_draw()` — an +## AtlasWindowOverlay for (a)/(b) below, or a bare `_BackgroundRect` probe for +## the harness sanity check) parented under a Node2D positioned/scaled the +## way AtlasWindowViewer._canvas would be, into a fresh SubViewport, and +## returns the captured Image. `zoom` mirrors AtlasWindowViewer._canvas.scale +## (real `fit_window_view()` output is a small fraction, e.g. ~0.006 for a +## whole-body tile mosaic per live round 4's own repro) — WITHOUT it, a +## tile's real-world extent (TILE_N * cell_px = 102,400 local units) is so +## much larger than any realistic test viewport that a mis-POSITIONED tile +## still overlaps the frame purely by being gigantic, making the position +## math this test exists to catch silently unfalsifiable (confirmed +## directly: an earlier version of this test without a zoom scale kept +## passing even with live round 4's tile-coordinate bug deliberately +## reintroduced). Also confirmed directly: a hand-rolled duplicate of this +## same SubViewport/settle-loop setup (the harness sanity check's ORIGINAL +## standalone version) was measurably less reliable under a real driver than +## going through this shared path — reuse over duplication here isn't just +## tidiness, it's the more reliable rendering path. +func _render_to_image(overlay: Node2D, zoom: float = 1.0) -> Image: + var sub_viewport := SubViewport.new() + sub_viewport.size = VIEWPORT_SIZE + sub_viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS + sub_viewport.transparent_bg = false + add_child(sub_viewport) + auto_free(sub_viewport) + + var bg := _BackgroundRect.new() + bg.fill_color = COLOR_BG + bg.fill_size = Vector2(VIEWPORT_SIZE) + sub_viewport.add_child(bg) + bg.queue_redraw() + + var canvas := Node2D.new() + canvas.position = Vector2(VIEWPORT_SIZE) * 0.5 # center the composite's local (0,0) + canvas.scale = Vector2(zoom, zoom) + sub_viewport.add_child(canvas) + canvas.add_child(overlay) + overlay.queue_redraw() + + # Bounded settle wait, NOT `await RenderingServer.frame_post_draw` — that + # signal never fires under the dummy/headless driver (confirmed directly: + # a first version of this file using it hung for the full 300s + # tests/run-godot wall-clock cap and was force-killed, producing a FALSE + # "0 tests, passed" result — exactly the silent-hang failure mode the + # `_do_skip`/`_dummy_renderer_active()` gate (this file's header doc) now + # avoids structurally instead). A fixed small number of `process_frame` + # awaits settles real rendering — confirmed sufficient against a real + # driver during this fix's own live verification. + for _i in range(6): + await get_tree().process_frame + + return sub_viewport.get_texture().get_image() + + +## Fraction of `image`'s pixels whose RGB differs from COLOR_BG (alpha +## ignored — the ColorRect background is opaque, everything drawn on top of +## it is what's under test). +static func _non_background_fraction(image: Image) -> float: + var w: int = image.get_width() + var h: int = image.get_height() + if w <= 0 or h <= 0: + return 0.0 + var total: int = w * h + var differing: int = 0 + var bg_rgb: Color = Color(COLOR_BG.r, COLOR_BG.g, COLOR_BG.b, 1.0) + for y in range(h): + for x in range(w): + var px: Color = image.get_pixel(x, y) + var px_rgb: Color = Color(px.r, px.g, px.b, 1.0) + if not px_rgb.is_equal_approx(bg_rgb): + differing += 1 + return float(differing) / float(total) + + +## (a) Single-window path: a District-rung response must render as visibly +## non-background pixels through AtlasWindowOverlay._draw()'s own +## Rect2(0,0,extent,extent) draw call — the "does the OVERLAY actually paint +## something for a real window" half of the gap. Positions it at the +## SubViewport's center via the surrounding Node2D, mirroring _canvas's role +## in the real viewer. +## +## Honest scope note: live round 4's SECOND bug (leaving `_view_offset` +## stale across a rung crossing in `_maybe_reselect_rung()`) lived entirely +## in AtlasWindowViewer's transform bookkeeping, ONE LAYER ABOVE this +## overlay-only test's boundary — it never touched `_draw()` itself, so a +## pure-overlay smoke test structurally cannot reproduce it (there is no +## "stale vs. fresh offset" state to compare inside the overlay alone). That +## regression's coverage is `_maybe_reselect_rung()`'s own unit tests in +## test_atlas_zoom_ladder.gd. This test's job is narrower and still real: +## proving the overlay's draw call itself produces visible output for +## legitimate window data, closing the "the composite Rect2 call is +## silently a no-op" class of bug regardless of which layer caused it. +func test_single_window_draw_produces_visible_pixels( + _do_skip := _dummy_renderer_active(), _skip_reason := SKIP_REASON +) -> void: + var overlay: AtlasWindowOverlay = AtlasWindowOverlay.new() + var stub := _SingleWindowViewerStub.new() + stub.window = _mock_window(Vector2i.ZERO, 32) + overlay.viewer = stub + + var image: Image = await _render_to_image(overlay) + var fraction: float = _non_background_fraction(image) + + assert_float(fraction).override_failure_message( + ( + "single-window composite must render VISIBLE non-background pixels — got" + + " only %.2f%% of the frame differing from COLOR_BG. This is exactly the" + + " shape of live round 4's second bug: _view_offset left stale across a" + + " rung crossing pushed the composite off-canvas, so nothing but" + + " background/chrome ever appeared, despite the underlying window data" + + " and draw calls being individually 'correct' in isolation." + ) + % (fraction * 100.0) + ).is_greater(MIN_NON_BACKGROUND_FRACTION) + + +## (b) Tile-mosaic path: a tile at a district center AWAY from the body's +## own origin must still render as visibly non-background pixels once +## correctly placed via `district_to_canvas_local()`'s shared held_center/ +## held_n convention — this is the path round 4's FIRST and THIRD bugs +## (tile-local-origin math ignoring that convention, and per-tile +## ImageTexture objects with no persistent reference being garbage- +## collected/GPU-desynced before their draw command flushed) would both +## have failed. Uses production-realistic scale (live round 4's own Lendel +## repro: raw circumference ~19,139 districts) — see the tile-center +## comment below for why scale matters here specifically. +func test_tile_mosaic_draw_produces_visible_pixels( + _do_skip := _dummy_renderer_active(), _skip_reason := SKIP_REASON +) -> void: + var overlay: AtlasWindowOverlay = AtlasWindowOverlay.new() + var stub := _TileModeViewerStub.new() + var tile_n: int = AtlasWindowGeometry.TILE_N + var cell_px: float = stub.get_cell_pixel_size() + stub.held_center = Vector2i.ZERO + stub.held_n = 19139 + # A SINGLE tile chosen so the CORRECT canvas-local formula + # (`district_to_canvas_local()`, anchored at `held_center - held_n/2`) + # lands it centered in the viewport, while the round-4 BUGGY formula + # (anchored at absolute district (0,0) directly) lands it almost + # `held_n/2 * cell_px` local units away — tens of thousands of units at + # this scale, i.e. genuinely fully off a 512x512 viewport, not just + # "shifted but still overlapping" (confirmed by hand-computation: a + # smaller/toy-scale version of this test stayed green with the bug + # reintroduced, because the shift stayed within the viewport bounds + # either way — this scale/center combination is chosen specifically to + # avoid that false-negative). + var half_tile: float = float(tile_n) * 0.5 + var half_body: float = float(stub.held_n) * 0.5 + var lone_tile_center := Vector2i(roundi(half_tile - half_body), roundi(half_tile - half_body)) + stub.tiles = [ + {"center": lone_tile_center, "window": _mock_window(lone_tile_center, 64)}, + ] + overlay.viewer = stub + + # A zoom small enough that the buggy-vs-correct shift (~half_body * cell_px + # local units) is comfortably larger than the viewport — see the center + # choice's own doc above for why this specific magnitude matters. + var zoom: float = float(VIEWPORT_SIZE.x) * 1.5 / (half_body * cell_px) + var image: Image = await _render_to_image(overlay, zoom) + var fraction: float = _non_background_fraction(image) + + assert_float(fraction).override_failure_message( + ( + "tile mosaic must render VISIBLE non-background pixels across its tiles —" + + " got only %.2f%% of the frame differing from COLOR_BG. This is exactly" + + " the shape of live round 4's bugs: (1) tile local-origin computed" + + " relative to absolute district (0,0) instead of the shared" + + " held_center/held_n canvas-local convention pushed the whole mosaic" + + " off-canvas, and (2) even once correctly positioned, an unstored" + + " per-draw-call ImageTexture rendered as a blank/white gap despite" + + " provably-correct CPU-side pixel data — both invisible to any test that" + + " only inspects Dictionary/cache state, never an actual composited pixel." + ) + % (fraction * 100.0) + ).is_greater(MIN_NON_BACKGROUND_FRACTION) diff --git a/client/tests/test_atlas_zoom_ladder.gd b/client/tests/test_atlas_zoom_ladder.gd index d9b448e31..1550f0698 100644 --- a/client/tests/test_atlas_zoom_ladder.gd +++ b/client/tests/test_atlas_zoom_ladder.gd @@ -449,6 +449,58 @@ func test_zoom_crossing_fires_request_and_accepts_wire_accurate_refinement() -> assert_str(v._held_granularity_v2).is_equal(request_granularity) +## Live round 4's SECOND bug, pinned directly: `_maybe_reselect_rung()` must +## recompute `_view_offset` (via AtlasWindowGeometry. +## recompute_offset_for_held_n_change()) the instant `_held_n` changes across +## a rung crossing — leaving it untouched (the round-4 bug) means the single- +## window `Rect2(0,0,extent)` draw call renders at whatever screen position +## the OLD (Region-scale) offset happened to put canvas-local (0,0), which +## for a whole-body `held_n` vs. a 64-district District `held_n` is tens or +## hundreds of thousands of px away from the viewport — the exact "pitch +## black" repro. Asserts the NEW held window's own extent actually overlaps +## the viewport after the crossing, the concrete on-screen consequence a +## stale offset breaks. +func test_zoom_crossing_recomputes_view_offset_so_the_new_window_is_on_screen() -> void: + var v: AtlasWindowViewer = auto_free(AtlasWindowViewer.new()) + add_child(v) + v.size = Vector2(1600.0, 900.0) + var radius_km := 6238.4 # GJ380c (Lendel) — the live-repro body + v.enter_orbital({"body_id": "GJ380c", "body_radius_km": radius_km}, {}) + assert_bool(v.is_tile_mode()).is_true() + + var cursor_pos := Vector2(1100.0, 300.0) + for _i in range(60): + v._zoom_at(cursor_pos, 1.15) + if not v.is_tile_mode(): + break + assert_bool(v.is_tile_mode()).override_failure_message( + "sanity: this test needs to actually cross out of tile mode to exercise" + + " the held_n change _maybe_reselect_rung() must react to" + ).is_false() + + # The new (post-crossing) window's screen-space rect, using the SAME + # formula the overlay's single-window _draw() itself uses + # (Rect2(0,0,extent,extent) in canvas-local space, then _canvas's own + # position/scale transform — _view_offset/_view_zoom here mirror that + # exactly, since _apply_transform() is what sets _canvas.position/scale). + var extent_screen: float = float(v._held_n) * v.CELL_PIXEL_SIZE * v._view_zoom + var screen_top_left: Vector2 = v._view_offset + var screen_bottom_right: Vector2 = screen_top_left + Vector2(extent_screen, extent_screen) + var viewport_rect := Rect2(Vector2.ZERO, v.size) + var window_rect := Rect2(screen_top_left, Vector2(extent_screen, extent_screen)) + + assert_bool(viewport_rect.intersects(window_rect)).override_failure_message( + ( + "the new (post-crossing) held window's screen rect %s must overlap the" + + " viewport %s — a stale _view_offset (never recomputed for the new" + + " held_n=%d) is exactly live round 4's 'pitch black' bug: the composite" + + " renders somewhere entirely off-canvas despite request/response/data" + + " all being individually correct" + ) + % [window_rect, viewport_rect, v._held_n] + ).is_true() + + # ============================================================================= # T-1153: E/W wrap and pole-wall clamps at EVERY rung — both are extent- # relative (CELL_PIXEL_SIZE-based district-space math, unchanged regardless diff --git a/client/ui/implant/apps/atlas/atlas_window_geometry.gd b/client/ui/implant/apps/atlas/atlas_window_geometry.gd index d26d18fb1..d3deb0ca8 100644 --- a/client/ui/implant/apps/atlas/atlas_window_geometry.gd +++ b/client/ui/implant/apps/atlas/atlas_window_geometry.gd @@ -381,6 +381,63 @@ static func screen_center_to_district( return Vector2i(roundi(abs_col), roundi(abs_row)) +## Live round 4 fix: the exact INVERSE of screen_center_to_district()'s own +## district-space math, in CANVAS-LOCAL space (i.e. _canvas's own child +## coordinate system — BEFORE _view_offset/_view_zoom, which is what +## AtlasWindowOverlay._draw()/_draw_tile_mosaic() draw into, since the +## Node2D's position/scale already carries pan/zoom). Every held-window +## convention in this file agrees canvas-local `(0,0)` is absolute district +## `(held_center - held_n/2)` — single-window `_draw()`'s own +## `Rect2(0,0,extent,extent)` relies on this being true for `held_center` == +## the window's own center. `_draw_tile_mosaic()`'s per-tile placement must +## use this SAME formula (with the VIEWER's `held_center`/`held_n`, not a +## tile's own center/TILE_N) to land in the same coordinate frame the +## fit/pan/zoom machinery already assumes — drawing tiles relative to +## absolute district (0,0) directly (the live-round-4 bug) silently +## disagreed with fit_window_view()'s own `[0, held_n)`-from-origin +## assumption whenever `held_n` (the WHOLE-BODY extent in tile mode) wasn't +## itself anchored the same way, pushing the entire mosaic off-canvas. +static func district_to_canvas_local( + district: Vector2, held_center: Vector2i, held_n: int, cell_pixel_size: float +) -> Vector2: + var half: float = float(held_n) / 2.0 + var local_col: float = (district.x - (float(held_center.x) - half)) * cell_pixel_size + var local_row: float = (district.y - (float(held_center.y) - half)) * cell_pixel_size + return Vector2(local_col, local_row) + + +## Live round 4 fix (the SECOND half of the "pitch black" repro, beyond +## district_to_canvas_local()'s tile-mosaic fix above): `_view_offset` is a +## PURE screen<->canvas-local transform, entirely independent of +## `held_center`/`held_n` — cursor-anchored zoom (`_zoom_at()`) never +## references them. But the single-window `_draw()` path draws the held +## composite at canvas-local `Rect2(0,0,extent,extent)`, which is ONLY the +## right place on screen if canvas-local (0,0) still equals +## `held_center - held_n/2` for the NEW rung. `_maybe_reselect_rung()` +## updates `held_center`/`held_n` to the new rung's values (a DIFFERENT +## `held_n` — Region's ~thousands vs. District's 64 vs. Quarter's 16) but +## never touched `_view_offset` to compensate — so canvas-local (0,0) +## silently stopped meaning `held_center - held_n/2` the instant `held_n` +## changed, and the composite (still drawn at local (0,0)) landed wherever +## the STALE offset happened to put it — off-canvas by tens or hundreds of +## thousands of px for a Region-to-District/Quarter crossing (round 4's +## repro), same root shape as the tile-mosaic bug, just on the "one held +## window" side of the split instead of the "many tiles" side. +## +## This is the exact INVERSE construction: given the SAME screen point that +## used to map to `old_local` must now map to canvas-local +## `new_held_n/2 * cell_pixel_size` (i.e. new_held_center's own position +## under the NEW window's `[0, new_held_n)` span), solve for the +## `view_offset` that makes `screen_point == new_local * view_zoom + +## view_offset` true. Pan-edge refetch (`_maybe_refloat_window()`) never +## needed this because it never changes `held_n` — only rung crossings do. +static func recompute_offset_for_held_n_change( + screen_point: Vector2, view_zoom: float, new_held_n: int, cell_pixel_size: float +) -> Vector2: + var new_local: Vector2 = Vector2.ONE * (float(new_held_n) * 0.5 * cell_pixel_size) + return screen_point - new_local * view_zoom + + # ============================================================================= # T-1145 item 2 (moved here T-1153 for file-length/testability): WASD/ # arrow-key held-pan direction + edge-scroll suppression/direction — pure diff --git a/client/ui/implant/apps/atlas/atlas_window_overlay.gd b/client/ui/implant/apps/atlas/atlas_window_overlay.gd index 71a749652..ca8184e48 100644 --- a/client/ui/implant/apps/atlas/atlas_window_overlay.gd +++ b/client/ui/implant/apps/atlas/atlas_window_overlay.gd @@ -92,6 +92,23 @@ var _cached_texture: ImageTexture = null var _cache_window_ref: Variant = null var _cache_active_toggle: String = "" +## Live round 4 fix: per-TILE texture cache, keyed by tile index — mirrors +## the single-window cache above, but one slot per mosaic tile (a Dictionary +## of `{window_ref, active_toggle, texture}`, since the mosaic doesn't have a +## single fixed set of tiles the way the single-window path has a single +## fixed field). Building a brand-new, UNSTORED `ImageTexture` every +## `_draw()` call (the round-3 version) left it referenced only by a local +## variable — nothing keeps the RID alive past the function returning, which +## raced against the RenderingServer's deferred draw-command flush and +## rendered as a blank/white tile (the round-4 "pitch black"/white-mosaic +## repro's second half, beyond the coordinate fix above): the CPU-side pixel +## data was provably correct (sampled directly), but the GPU-side texture +## backing it could be gone by composite time. Caching each tile's texture +## as a class-owned Dictionary entry (same reference-identity rebuild-only- +## on-change discipline as `_cached_texture`) keeps it alive exactly as long +## as the single-window composite's own texture already is. +var _tile_texture_cache: Dictionary = {} + func _draw() -> void: if viewer == null: @@ -137,31 +154,42 @@ func _draw() -> void: _draw_crisp_composite(w, grid_side, n, cell_px, active_toggle) -## T-1153, live round 3 (Jeroen's ruling, design doc §4): the orbital +## T-1153, live round 3/4 (Jeroen's ruling, design doc §4): the orbital ## rest-state MOSAIC draw path — one call to the EXISTING single-tile ## composite-building logic (`_rebuild_texture_if_needed()`/ ## `_draw_smoothed_composite()`'s own per-tile equivalent below) PER TILE, -## each positioned at its own LOCAL offset from the tile-set's reference -## origin (district (0,0), matching `_enter_tile_mode()`'s own -## `_held_center = Vector2i.ZERO`). A tile centered at absolute district -## `tile.center` occupies local canvas space -## `[(tile.center - TILE_N/2) * cell_px, (tile.center + TILE_N/2) * cell_px)` -## — the SAME "window spans [center - n/2, center + n/2)" convention the -## single-window draw path already uses, just evaluated per tile instead of -## once for the whole composite. Tiles that haven't arrived yet -## (`tile["window"] == null`) are simply SKIPPED — no per-tile placeholder -## draw, letting COLOR_BG show through as the honest "nothing here yet" read -## (the viewer's own `_draw()` already documents why no separate -## whole-viewport fade is needed on top of this). +## each positioned at its own LOCAL offset in the SAME canvas-local +## coordinate frame the single-window path (and fit_window_view()/ +## screen_center_to_district()) already use. +## +## **Live round 4 fix:** the round-3 version placed tiles relative to +## absolute district (0,0) directly (`(tile.center - TILE_N/2) * cell_px`), +## which does NOT match `_fit_and_center()`'s own convention — canvas-local +## (0,0) is `held_center - held_n/2` (AtlasWindowGeometry. +## district_to_canvas_local()'s own doc), and in tile mode `held_n` is the +## WHOLE BODY's extent, not TILE_N. That mismatch pushed the entire mosaic +## off-canvas (round 4's "pitch black" repro) — silent, since nothing +## errors, it just draws somewhere the viewport never shows. Fixed by +## routing every tile's placement through `district_to_canvas_local()` with +## the VIEWER's own `held_center`/`held_n`, the exact reference frame every +## other canvas-local consumer (fit/pan/reselect) already agrees on. Tiles +## that haven't arrived yet (`tile["window"] == null`) are simply SKIPPED — +## no per-tile placeholder draw, letting COLOR_BG show through as the honest +## "nothing here yet" read (the viewer's own `_draw()` already documents why +## no separate whole-viewport fade is needed on top of this). func _draw_tile_mosaic() -> void: var tile_set = viewer.get_tile_set() if tile_set == null: return var cell_px: float = viewer.get_cell_pixel_size() var active_toggle: String = _active_toggle_overlay() + var held_center: Vector2i = viewer.get_held_center() + var held_n: int = viewer.get_held_n() var half_tile: float = float(AtlasWindowGeometryRef.TILE_N) * 0.5 + var tiles: Array = tile_set.get_tiles() - for tile: Dictionary in tile_set.get_tiles(): + for i in range(tiles.size()): + var tile: Dictionary = tiles[i] var window: Variant = tile["window"] if not window is Dictionary: continue @@ -174,42 +202,71 @@ func _draw_tile_mosaic() -> void: continue var center: Vector2i = tile["center"] - var local_origin: Vector2 = Vector2( - (float(center.x) - half_tile) * cell_px, (float(center.y) - half_tile) * cell_px + var tile_top_left: Vector2 = Vector2( + float(center.x) - half_tile, float(center.y) - half_tile + ) + var local_origin: Vector2 = AtlasWindowGeometryRef.district_to_canvas_local( + tile_top_left, held_center, held_n, cell_px ) var extent: float = float(AtlasWindowGeometryRef.TILE_N) * cell_px - _draw_one_tile(w, grid_side, local_origin, extent, active_toggle) + _draw_one_tile(i, w, grid_side, local_origin, extent, active_toggle) ## One tile's own composite — the SAME crisp/smoothed per-cell pipeline the ## single-window path uses (_cell_color()/_apply_glaciation(), UNCHANGED), ## just drawn at `local_origin` instead of always at (0,0). Each tile gets -## its OWN texture-rebuild cache slot (keyed by the tile's own window -## reference, via `_tile_texture_cache` below) — sharing ONE -## `_cached_texture` slot across all tiles (the single-window field) would -## thrash on every draw call as different tiles' windows compete for it. +## its OWN texture-rebuild cache slot in `_tile_texture_cache`, keyed by +## `tile_index` — sharing ONE `_cached_texture` slot across all tiles (the +## single-window field) would thrash on every draw call as different tiles' +## windows compete for it. func _draw_one_tile( - w: Dictionary, grid_side: int, local_origin: Vector2, extent: float, active_toggle: String + tile_index: int, + w: Dictionary, + grid_side: int, + local_origin: Vector2, + extent: float, + active_toggle: String ) -> void: if not COMPOSITE_SMOOTH: _draw_crisp_tile(w, grid_side, local_origin, extent, active_toggle) return - var tile_texture: ImageTexture = _build_tile_texture(w, grid_side, active_toggle) + var tile_texture: ImageTexture = _rebuild_tile_texture_if_needed( + tile_index, w, grid_side, active_toggle + ) if tile_texture == null: return texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR draw_texture_rect(tile_texture, Rect2(local_origin, Vector2(extent, extent)), false) -## Builds (uncached — see the class doc's rebuild-cost paragraph for why the -## SINGLE-window path caches by reference; a per-tile cache keyed the same -## way would need a Dictionary keyed on tile index, a reasonable follow-up -## if mosaic redraw cost ever matters in practice, not attempted here since -## the mosaic is drawn only at the orbital rest state, never mid-interaction -## at a high redraw rate the way single-window zoom/pan is) a tile's own -## Image/ImageTexture from its per-cell colors — identical pipeline to -## `_rebuild_texture_if_needed()`, just returning the texture directly -## instead of writing to the single-window cache fields. +## Live round 4 fix: rebuilds (and, critically, KEEPS — see +## `_tile_texture_cache`'s own doc for why an unstored local `ImageTexture` +## silently rendered blank/white) `_tile_texture_cache[tile_index]`'s texture +## ONLY when that tile's window object or the active toggle overlay has +## changed since the last build — the SAME reference-identity discipline +## `_rebuild_texture_if_needed()` uses for the single-window composite, one +## cache entry per tile index instead of one shared field. +func _rebuild_tile_texture_if_needed( + tile_index: int, w: Dictionary, grid_side: int, active_toggle: String +) -> ImageTexture: + var entry: Dictionary = _tile_texture_cache.get(tile_index, {}) + if ( + is_same(entry.get("window_ref"), w) + and entry.get("active_toggle") == active_toggle + and entry.get("texture") != null + ): + return entry["texture"] + var texture: ImageTexture = _build_tile_texture(w, grid_side, active_toggle) + _tile_texture_cache[tile_index] = { + "window_ref": w, "active_toggle": active_toggle, "texture": texture + } + return texture + + +## Builds a tile's own Image/ImageTexture from its per-cell colors — +## identical pipeline to `_rebuild_texture_if_needed()`, just returning the +## texture directly instead of writing to the single-window cache fields +## (the CALLER, `_rebuild_tile_texture_if_needed()`, owns persisting it). func _build_tile_texture(w: Dictionary, grid_side: int, active_toggle: String) -> ImageTexture: var elev_q: Variant = w.get("elev_q") var glaciation: Variant = w.get("glaciation") diff --git a/client/ui/implant/apps/atlas/atlas_window_viewer.gd b/client/ui/implant/apps/atlas/atlas_window_viewer.gd index b0f7db49f..77d5fb627 100644 --- a/client/ui/implant/apps/atlas/atlas_window_viewer.gd +++ b/client/ui/implant/apps/atlas/atlas_window_viewer.gd @@ -19,17 +19,14 @@ extends Control ## - _canvas (Node2D) holds AtlasWindowOverlay; pan = _canvas.position, zoom ## = _canvas.scale. ## - Zoom is client-side on the ALREADY-HELD composite frame-to-frame, but -## CONTINUOUS AND UNCLAMPED ACROSS RUNGS (D-013 restored for this seam): -## crossing a rung's coverage ceiling (§5, redesigned per live round 3 — -## see AtlasWindowGeometry.select_rung()) fires a background request for -## the new granularity while the OLD composite keeps drawing — -## progressive refinement, no blank frame, no mode flip (§6). A pan past -## the held window's edge re-requests the SAME rung at a new center. +## CONTINUOUS AND UNCLAMPED ACROSS RUNGS (D-013): crossing a rung's +## coverage ceiling (§5, AtlasWindowGeometry.select_rung()) fires a +## background request for the new granularity while the OLD composite +## keeps drawing — progressive refinement, no blank frame (§6). A pan +## past the held window's edge re-requests the SAME rung at a new center. ## - Zooming fully out snaps to the CANONICAL planetary frame (Jeroen's HARD ## condition) — see _maybe_reset_to_canonical_frame() — which, on a body -## needing tiling, re-enters `_tile_mode` (live round 3, design doc §4: -## "the top rest state is the WHOLE body, served as progressive -## capped-density TILING"). +## needing tiling, re-enters `_tile_mode` (live round 3, design doc §4). ## - _window_request (atlas_window_request.gd) owns the single-window ## cache/debounce/retry; _tile_set (atlas_window_tile_set.gd) owns N of ## those for the tiled rest state — this Control decides WHICH is active. @@ -207,12 +204,10 @@ var _legend_panel = null var _window_request = null # AtlasWindowRequest var _tile_set = null # AtlasWindowTileSet (T-1153, live round 3) -## T-1153 (Jeroen's ruling, design doc §4): true while showing the orbital -## rest state as a MULTI-WINDOW MOSAIC (AtlasWindowTileSet) instead of the -## single held composite (`_window`). Set by `_enter_tile_mode()` when -## compute_tile_grid() produces more than one tile; cleared the moment -## `_maybe_reselect_rung()` crosses OUT of Region — tiling is purely a -## TOP-of-the-ladder concern, never active below Region. +## T-1153 (design doc §4): true while showing the orbital rest state as a +## MULTI-WINDOW MOSAIC (AtlasWindowTileSet) instead of the single held +## composite (`_window`). Set by `_enter_tile_mode()`; cleared the moment +## `_maybe_reselect_rung()` crosses OUT of Region. var _tile_mode: bool = false @@ -303,14 +298,13 @@ func enter( ## entry and reset always agree on what "the top" means. No-radius bodies ## fall back to the District-rung default window (no circumference concept). ## -## **Live round 3 (Jeroen's ruling, design doc §4): the rest state must -## TILE.** A single wire-capped Region window covers at most -## `AtlasWindowGeometry.MAX_COVERAGE_M["Region"]` (13,107,200 m) — a THIRD of -## Lendel's ~39,197,023 m circumference (shot 01's own header: "13107.2 x -## 13107.2 km"). Once `compute_tile_grid()` returns MORE than one tile, -## entry goes through `_enter_tile_mode()` instead of `_enter_at_rung()`; a -## body whose circumference fits one Region window's ceiling still gets -## exactly one "tile" (the degenerate case) and stays single-window. +## **Live round 3 (design doc §4): the rest state must TILE.** A single +## wire-capped Region window covers at most +## `AtlasWindowGeometry.MAX_COVERAGE_M["Region"]`, a fraction of a real +## body's circumference. Once `compute_tile_grid()` returns MORE than one +## tile, entry goes through `_enter_tile_mode()` instead of +## `_enter_at_rung()`; a body whose circumference fits one Region window's +## ceiling still gets exactly one "tile" and stays single-window. func enter_orbital(body: Dictionary, system: Dictionary) -> void: var radius_km: float = float(body.get("body_radius_km", 0.0)) if radius_km <= 0.0: @@ -330,11 +324,10 @@ func enter_orbital(body: Dictionary, system: Dictionary) -> void: ## T-1153, live round 3: the TILE-MODE entry path — same reset discipline as -## `_enter_at_rung()` but populates `_tile_set` instead of `_window_request`, -## setting `_tile_mode = true` so drawing/reset/reselect read the mosaic. +## `_enter_at_rung()` but populates `_tile_set` instead of `_window_request`. ## `_held_n` carries the WHOLE body's extent unclamped (each TILE clamps its -## own TILE_N-sized request independently inside AtlasWindowTileSet), so the -## extent math elsewhere needs no tile-specific branch. +## own TILE_N-sized request independently), so extent math elsewhere needs +## no tile-specific branch. func _enter_tile_mode(body: Dictionary, system: Dictionary, radius_km: float) -> void: var extent: Dictionary = AtlasDescendGeometry.district_extent(radius_km) var n: int = int(extent["cols"]) @@ -398,12 +391,9 @@ func _enter_at_rung( ## T-1142: fit-and-center — applies AtlasWindowGeometry.fit_window_view()'s -## zoom/offset, then re-clamps the offset to the pole wall (a freshly-fitted -## view can still need the wall on a tiny body whose row span is shorter than -## the window itself — see atlas_window_geometry.gd's clamp function doc). -## Called from enter(), the FIRST _on_window_ready() after entry, and -## NOTIFICATION_RESIZED — never mid-interaction (guarded by _user_adjusted at -## each call site, not here, since the three callers gate slightly differently). +## zoom/offset, then re-clamps the offset to the pole wall. Called from +## enter(), the FIRST _on_window_ready() after entry, and +## NOTIFICATION_RESIZED — never mid-interaction (guarded by _user_adjusted). func _fit_and_center() -> void: var viewport: Vector2 = get_rect().size if viewport == Vector2.ZERO: @@ -453,6 +443,18 @@ func get_tile_set() -> Variant: return _tile_set +## Live round 4: currently-HELD reference frame — AtlasWindowOverlay's +## mosaic draw path converts each tile's absolute district center into +## canvas-local space via these (see AtlasWindowGeometry. +## district_to_canvas_local()'s own doc for the shared convention). +func get_held_center() -> Vector2i: + return _held_center + + +func get_held_n() -> int: + return _held_n + + ## District-cell pixel size at zoom=1.0 — AtlasWindowOverlay reads this ## rather than hardcoding CELL_PIXEL_SIZE itself, so the viewer stays the ## single source of geometry truth (same "viewer owns the transform, overlay @@ -558,11 +560,10 @@ func _apply_transform() -> void: _overlay_node.queue_redraw() -## Cursor-anchored zoom (D-013 restored for this seam): the CANVAS POINT -## under the cursor stays fixed on screen across the zoom step. Unclamped -## ACROSS RUNGS (only the wide MIN_ZOOM/MAX_ZOOM safety clamp applies — see -## that constant's own doc); after applying, checks whether the extent now -## calls for a different rung or the top rest state. +## Cursor-anchored zoom (D-013): the CANVAS POINT under the cursor stays +## fixed on screen across the zoom step. Unclamped across rungs (only the +## wide MIN_ZOOM/MAX_ZOOM safety clamp applies); after applying, checks +## whether the extent now calls for a different rung or the top rest state. func _zoom_at(mouse_pos: Vector2, factor: float) -> void: var new_zoom: float = clampf(_view_zoom * factor, MIN_ZOOM, MAX_ZOOM) if is_equal_approx(new_zoom, _view_zoom): @@ -577,9 +578,8 @@ func _zoom_at(mouse_pos: Vector2, factor: float) -> void: ## The world extent (metres) currently displayed across the LARGER viewport -## dimension — the `E` half of the §5 rung-selection rule. A pure function -## of `_view_zoom` (see AtlasWindowGeometry.world_extent_m()'s own doc for -## why the held rung is NOT an input). Thin wrapper over that pure function. +## dimension — the `E` half of the §5 rung-selection rule. Thin wrapper over +## AtlasWindowGeometry.world_extent_m() (a pure function of `_view_zoom`). func _current_world_extent_m() -> float: return AtlasWindowGeometry.world_extent_m(CELL_PIXEL_SIZE, _view_zoom, get_rect().size) @@ -592,12 +592,10 @@ func _current_world_extent_m() -> float: ## ## **C1 clamp-mirror, a THIRD layer up (live round 3):** `_held_n` MUST be ## re-clamped via `_clamp_window_n_mirror_v2()` for the TARGET rung, not left -## at the PREVIOUS rung's clamp — crossing rungs changes the clamp ceiling -## (Region caps at 6,400; District/Quarter at 64), so a stale Region-sized -## `_held_n` fed into a Quarter request gets server-clamped small while -## `_held_n` stays large — `_on_window_ready()`'s `w_n != _held_n` then -## drops every cross-rung refinement forever. Same bug as _enter_at_rung(), -## recurring at the CROSSING boundary. +## at the PREVIOUS rung's clamp — crossing rungs changes the clamp ceiling, +## so a stale large `_held_n` fed into a smaller-rung request desyncs +## `_on_window_ready()`'s `w_n != _held_n` check and drops the refinement +## forever. Same bug as _enter_at_rung(), recurring at the CROSSING boundary. ## ## Progressive refinement: does NOT touch `_window`/`_held_granularity_v2` — ## the OLD composite keeps drawing until _on_window_ready() adopts the new @@ -609,12 +607,9 @@ func _maybe_reselect_rung() -> void: var canvas_px: float = maxf(get_rect().size.x, get_rect().size.y) var target_rung: String = AtlasWindowGeometry.select_rung(world_extent_m, canvas_px) - # T-1153, live round 3: tile mode is TOP-of-the-ladder only (coordinator's - # own scoping — "inside-zoom can stay single-window as now"). Staying at - # Region means staying tiled (zooming within a mosaic is a client-side - # scale on the SAME held tiles, like single-window zoom on one - # composite). Crossing OUT of Region falls through to the single-window - # path below, flipping `_tile_mode` off. + # T-1153, live round 3: tile mode is TOP-of-the-ladder only. Staying at + # Region means staying tiled; crossing OUT of Region falls through to + # the single-window path below, flipping `_tile_mode` off. var leaving_tile_mode := false if _tile_mode: if target_rung == AtlasWindowRequest.GRANULARITY_V2_REGION: @@ -623,25 +618,30 @@ func _maybe_reselect_rung() -> void: leaving_tile_mode = true # `leaving_tile_mode` FORCES the request through even if - # `_window_request`'s own STALE granularity_v2 (never touched while tiled) - # happens to already equal `target_rung` by coincidence — without this, - # the early-return below would skip the request that's supposed to - # POPULATE `_window` for the first time since tile mode replaced it. + # `_window_request`'s own STALE granularity_v2 (untouched while tiled) + # happens to already equal `target_rung` by coincidence. if not leaving_tile_mode and target_rung == _window_request.get_granularity_v2(): return # already requesting (or holding) the rung this extent calls for var new_center: Vector2i = _screen_center_district() var clamped_n: int = AtlasWindowRequest._clamp_window_n_mirror_v2(_held_n, target_rung) _held_center = new_center _held_n = clamped_n + # Live round 4: `_view_offset` must be recomputed the instant `held_n` + # changes — see recompute_offset_for_held_n_change()'s own doc for why. + # Anchors on the SAME screen center just used above, preserving §6 "no + # layout jump" across the crossing. + _view_offset = AtlasWindowGeometry.recompute_offset_for_held_n_change( + size * 0.5, _view_zoom, clamped_n, CELL_PIXEL_SIZE + ) + _apply_transform() _window_request.request_debounced( _dict_str(_body, "body_id", ""), new_center, clamped_n, target_rung ) ## The DistrictPos the current screen center maps to, in RAW absolute -## district space (the caller canonicalizes the final stored/sent value). -## Thin wrapper over AtlasWindowGeometry.screen_center_to_district() so both -## the pan-edge refetch and the rung-reselect refetch share ONE formula. +## district space. Thin wrapper over AtlasWindowGeometry. +## screen_center_to_district() so pan-edge/rung-reselect refetch share one formula. func _screen_center_district() -> Vector2i: var raw: Vector2i = AtlasWindowGeometry.screen_center_to_district( size, _view_offset, _view_zoom, CELL_PIXEL_SIZE, _held_center, _held_n diff --git a/docs/DEVOPS.md b/docs/DEVOPS.md index 2d6c8c9be..b22b1097c 100644 --- a/docs/DEVOPS.md +++ b/docs/DEVOPS.md @@ -352,6 +352,32 @@ Key components: - **CauseChain** (production ECS component) — Tracks causal attribution for testable observation sequences (D-030). - **Deterministic replay** — Server simulation is deterministic given the same seed + input sequence. Replay logs enable regression testing (#201, critical). +### Real-rendering test exception: `test_atlas_window_overlay_draw_smoke.gd` + +`tests/run-godot` hardcodes `--headless`, whose dummy driver produces no usable GPU +texture output (`SubViewport.get_texture().get_image()` returns unusable data). One +file needs real pixels — `client/tests/test_atlas_window_overlay_draw_smoke.gd` (T-1153 +live round 4) renders `AtlasWindowOverlay` into a `SubViewport` and asserts real terrain +pixels were composited, closing a "did anything draw at all" gap that bit twice +(a tile-mosaic coordinate bug and an unstored-texture GPU-lifetime bug, both invisible +to cache-state-only assertions). It self-detects the dummy driver +(`DisplayServer.get_name() == "headless"`) and **skips** under the standard suite — +`tests/run-godot --filter test_atlas_window_overlay_draw_smoke` reports green-with-skips, +never a false failure that would bounce the push gate. To exercise its real assertions: + +```bash +godot4 --display-driver x11 --rendering-driver opengl3 \ + -s addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -c \ + -a res://tests/test_atlas_window_overlay_draw_smoke.gd +``` + +Same underlying constraint as `tests/visual_capture.gd` (`tests/run-visual`), which is +the project's other real-driver exception — currently broken on this branch by the +retired `AtlasViewer` API (T-1157, capture-harness redesign). This file's scenarios are +slated to migrate into that redesigned harness once T-1157 lands, folding it into +`tests/visual.json` for consistency; until then it stays a standalone gdUnit file with +its own skip guard. + ## Planning store (pql) Tickets and decisions live in **pql**, not in the old SQLite wrapper scripts. Decisions