diff --git a/.pql/changelog/ticket_history/2026-07.sql b/.pql/changelog/ticket_history/2026-07.sql index 5e71437a7..7ca895254 100644 --- a/.pql/changelog/ticket_history/2026-07.sql +++ b/.pql/changelog/ticket_history/2026-07.sql @@ -3273,3 +3273,6 @@ RE-SCOPE REQUIRED BEFORE ANY CHILD STARTS (2026-07-27, D-258 amendment). The str INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FT0TYBD74TQNVKTJMKA8D9KM', 'description', 'MUST run and be reviewed before any other T-1211 child starts -- this measurement could reshape the rung-0.5 design, per the pair session''s explicit sequencing note. Measure, at minimum: (1) per-body derive cost for the expanded layer at a resolution sized so a whole body draws at 2 screen px per gridunit on a large display (the D-258 sizing rule) -- both single-body cold-derive time and the full ~271-body population sum; (2) per-body and total disk footprint if the layer is cached/stored (rung 0.5 is a named D-227 carve-out -- storage is deliberate, but its size must be known, not assumed); (3) whole-body hydrology solve cost on this layer (drainage + course routing + lake fill) at the same resolution, since D-258 requires this to run exactly once per body and nowhere else. Reference point: D-255''s own rung-0 always-keep tier estimate went from ~8.85 MB (measured against a stale ~18K-cell/body figure) to an estimated 226 MB (1080p) / ~900 MB (4K) once the extent inversion made Global viewport-sized -- D-255 amendment item 6 explicitly says ''re-measure against rung 0.5, not against this record.'' This ticket is that re-measurement. Report back to the team before T-1211''s other children are started; if the numbers are structurally bad (e.g. rung 0.5 at the sizing D-258 specifies costs an order of magnitude more than the old rung-0 model), that is grounds to revisit the resolution target with Jeroen before writing generator code. See governance/decisions/architecture.md#d-258 (rationale + Implementation note).', 'MUST run and be reviewed before any other T-1211 child starts -- this measurement could reshape the rung-0.5 design, per the pair session''s explicit sequencing note. Measure, at minimum: (1) per-body derive cost for the expanded layer at a resolution sized so a whole body draws at 2 screen px per gridunit on a large display (the D-258 sizing rule) -- both single-body cold-derive time and the full ~271-body population sum; (2) per-body and total disk footprint if the layer is cached/stored (rung 0.5 is a named D-227 carve-out -- storage is deliberate, but its size must be known, not assumed); (3) whole-body hydrology solve cost on this layer (drainage + course routing + lake fill) at the same resolution, since D-258 requires this to run exactly once per body and nowhere else. Reference point: D-255''s own rung-0 always-keep tier estimate went from ~8.85 MB (measured against a stale ~18K-cell/body figure) to an estimated 226 MB (1080p) / ~900 MB (4K) once the extent inversion made Global viewport-sized -- D-255 amendment item 6 explicitly says ''re-measure against rung 0.5, not against this record.'' This ticket is that re-measurement. Report back to the team before T-1211''s other children are started; if the numbers are structurally bad (e.g. rung 0.5 at the sizing D-258 specifies costs an order of magnitude more than the old rung-0 model), that is grounds to revisit the resolution target with Jeroen before writing generator code. See governance/decisions/architecture.md#d-258 (rationale + Implementation note). SCOPE INVALIDATED 2026-07-27 (see T-1211 and the D-258 amendment). This ticket was written to measure the cost of moving a whole-body hydrology solve onto rung 0.5. That solve does not need to move -- it already runs once per body in layer1.rs and is sampled fresh at every rung. Do NOT run this measurement as written; it would price work that is not required. If a measurement is still wanted after T-1211 is re-scoped, the question is narrower: what does BIOME un-summarisation cost, and does it need storing at all.', NULL, '2026-07-27 18:51:51', '2026-07-27 18:51:51.056', '2026-07-27 18:51:51.056', NULL, 'c9374708c339c12f68f1121972578a7d', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FT0VDA5Q7JZFXG7XYWFR6M3C', 'description', 'Pre-existing lint-cap violation, made worse by the 2026-07-26 pair session''s fetch-overlay wiring work (still net additive to a file already over cap). Split step_canvas_viewer.gd so it comes back under the 1,000-line gdlint limit -- likely candidate: extract the fetch-overlay wiring / cache-store plumbing into a separate component now that step_canvas_fetch_overlay.gd exists as a dedicated node, reducing what step_canvas_viewer.gd itself needs to own directly. Verify gdlint passes clean after the split and that the viewer test suite (92/92 as of this session) stays green.', 'Pre-existing lint-cap violation, made worse by the 2026-07-26 pair session''s fetch-overlay wiring work (still net additive to a file already over cap). Split step_canvas_viewer.gd so it comes back under the 1,000-line gdlint limit -- likely candidate: extract the fetch-overlay wiring / cache-store plumbing into a separate component now that step_canvas_fetch_overlay.gd exists as a dedicated node, reducing what step_canvas_viewer.gd itself needs to own directly. Verify gdlint passes clean after the split and that the viewer test suite (92/92 as of this session) stays green. + +ALSO test_step_canvas_viewer.gd, which crossed the 1,000-line gdlint cap on 2026-07-27 (now 1,094) as regression tests accumulated during the Global debugging session. Same cap, same file cluster, so fix together: the viewer suite splits naturally along the seams its own section comments already mark (rung transport / Global sizing + letterbox / cache-key contract / edge-scroll + pan). Note the production file and the test file hit the cap for opposite reasons — the viewer because it does too much, the suite because it covers a lot — so the remedies differ: extract collaborators from the viewer, split the suite by concern.', NULL, '2026-07-27 19:36:19', '2026-07-27 19:36:19.691', '2026-07-27 19:36:19.691', NULL, 'aec2754271feda9dab0ac68296663bc7', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-07.sql b/.pql/changelog/tickets/2026-07.sql index 5a8c74c62..d6f68cec7 100644 --- a/.pql/changelog/tickets/2026-07.sql +++ b/.pql/changelog/tickets/2026-07.sql @@ -5569,3 +5569,6 @@ INSERT INTO tickets (record_id, type, parent_record_id, title, description, stat SCOPE INVALIDATED 2026-07-27 (see T-1211 and the D-258 amendment). This ticket was written to measure the cost of moving a whole-body hydrology solve onto rung 0.5. That solve does not need to move -- it already runs once per body in layer1.rs and is sampled fresh at every rung. Do NOT run this measurement as written; it would price work that is not required. If a measurement is still wanted after T-1211 is re-scoped, the question is narrower: what does BIOME un-summarisation cost, and does it need storing at all.', 'backlog', 'high', NULL, NULL, 'D-258', '2026-07-26 21:54:06.825', '2026-07-27 18:51:51.056', NULL, 'c5e77cd4de6e9ec9647bbfe12d0ed6e2', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FTA26EM8YXW086EV2K0N2WZR', 'bug', '06FBPPMZNNEV052DBYYY3A897C', 'visual_capture drifts the Atlas view — edge-scroll fires during the settle', 'Found 2026-07-27 while verifying the Global fill fix. An atlas capture reported canvas_position=(292.0, 657.3336) where the letterbox centre should be a whole-pixel value near y=133-277. The Y is both wrong AND fractional, which rules out center_offset() -- that floors to whole pixels for texel-exactness. The cause is edge scroll: the capture harness leaves the mouse at the viewport corner, StepCanvasViewer reads it as a held edge-scroll, and pans continuously through the 240 settle ticks, pushing the canvas down and right (x pinned at the legend column, y drifting). Consequences: (1) every Atlas golden is captured mid-pan, so the baselines encode an arbitrary drift offset rather than the canonical centred frame; (2) a real centring regression would be invisible against them; (3) captures are not reproducible run-to-run if tick timing varies. Fix shape: the capture harness should suppress edge-scroll (park the mouse centre-screen, or expose a viewer flag the harness sets), then assert the settled position IS the centred letterbox. Worth doing before any Atlas golden is trusted for centring -- note the goldens were ALSO degenerate for a separate reason until today (missing body_radius_km), so this rung''s baselines have never been meaningful.', 'backlog', 'medium', NULL, 'client', NULL, '2026-07-27 19:24:05.666', '2026-07-27 19:24:05.666', NULL, '5834a65f27af46030249f4b79a3b019a', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FT0VDA5Q7JZFXG7XYWFR6M3C', 'task', '06FBPPMZNNEV052DBYYY3A897C', 'step_canvas_viewer.gd is 1,133 lines against the 1,000-line gdlint cap', 'Pre-existing lint-cap violation, made worse by the 2026-07-26 pair session''s fetch-overlay wiring work (still net additive to a file already over cap). Split step_canvas_viewer.gd so it comes back under the 1,000-line gdlint limit -- likely candidate: extract the fetch-overlay wiring / cache-store plumbing into a separate component now that step_canvas_fetch_overlay.gd exists as a dedicated node, reducing what step_canvas_viewer.gd itself needs to own directly. Verify gdlint passes clean after the split and that the viewer test suite (92/92 as of this session) stays green. + +ALSO test_step_canvas_viewer.gd, which crossed the 1,000-line gdlint cap on 2026-07-27 (now 1,094) as regression tests accumulated during the Global debugging session. Same cap, same file cluster, so fix together: the viewer suite splits naturally along the seams its own section comments already mark (rung transport / Global sizing + letterbox / cache-key contract / edge-scroll + pan). Note the production file and the test file hit the cap for opposite reasons — the viewer because it does too much, the suite because it covers a lot — so the remedies differ: extract collaborators from the viewer, split the suite by concern.', 'backlog', 'low', NULL, NULL, NULL, '2026-07-26 21:56:09.390', '2026-07-27 19:36:19.691', NULL, '706b91aeeb64634aa9a40e473b56efab', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); diff --git a/client/tests/test_step_canvas_viewer.gd b/client/tests/test_step_canvas_viewer.gd index 1fb355880..177e7c9ae 100644 --- a/client/tests/test_step_canvas_viewer.gd +++ b/client/tests/test_step_canvas_viewer.gd @@ -1064,3 +1064,31 @@ func test_legend_panel_leaves_a_real_gap_below_the_header_panel() -> void: ) % [v.get_header_legend_gap_px(), header_bottom, legend_top, gap] ).is_greater_equal(v.get_header_legend_gap_px() - 0.01) + +## The "it scrolls out of screen automatically" defect (Jeroen, 2026-07-27). +## _gui_input only fires while the pointer is over the Control, so the cached +## mouse position freezes wherever it was last seen — and leaving the map +## ALWAYS means crossing an edge, so the frozen value is always inside the +## edge margin. The viewer then panned forever with no input, and moving the +## mouse elsewhere could not stop it because "elsewhere" produces no events +## this Control hears. NOTIFICATION_MOUSE_EXIT must reset to the same sentinel +## the field is born with. +func test_edge_scroll_stops_when_the_pointer_leaves_the_control() -> void: + var v: StepCanvasViewer = _make_viewer() + add_child(v) + v.size = Vector2(800.0, 600.0) + v._app_has_focus = true + + # Pointer parked in the left edge margin — scrolling, correctly. + v._last_mouse_pos = Vector2(4.0, 300.0) + assert_bool(v._is_cursor_edge_scrolling()).override_failure_message( + "a cursor inside the edge margin should edge-scroll" + ).is_true() + + # Pointer leaves the Control entirely. + v._notification(Control.NOTIFICATION_MOUSE_EXIT) + + assert_bool(v._is_cursor_edge_scrolling()).override_failure_message( + "edge scroll must STOP once the pointer leaves — otherwise the map" + + " pans off the world forever with no input" + ).is_false() diff --git a/client/ui/implant/apps/atlas/step_canvas/step_canvas_viewer.gd b/client/ui/implant/apps/atlas/step_canvas/step_canvas_viewer.gd index 601579d9e..8aaf5ba96 100644 --- a/client/ui/implant/apps/atlas/step_canvas/step_canvas_viewer.gd +++ b/client/ui/implant/apps/atlas/step_canvas/step_canvas_viewer.gd @@ -1032,6 +1032,24 @@ func _build_legend_panel() -> void: func _notification(what: int) -> void: + if what == NOTIFICATION_MOUSE_EXIT: + # STOP EDGE-SCROLLING WHEN THE POINTER LEAVES. + # + # `_gui_input` only fires while the pointer is over this Control, so + # `_last_mouse_pos` freezes at wherever it was last seen. If that was + # inside the edge margin — which it always is when you move the cursor + # OUT of the map, because leaving means crossing an edge — the viewer + # goes on believing the cursor is held there and pans forever. That is + # the "it scrolls out of screen automatically" defect (Jeroen, + # 2026-07-27): the map drifts off the world with no input, and moving + # the mouse elsewhere cannot stop it, because "elsewhere" generates no + # events this Control ever hears. + # + # Resetting to the same (-1,-1) sentinel the field is BORN with makes + # "pointer is not over the map" and "pointer has never been over the + # map" the same state, which is exactly right: neither should scroll. + _last_mouse_pos = Vector2(-1.0, -1.0) + return if what == NOTIFICATION_RESIZED: if _overlay_bar: _position_overlay_bar() @@ -1118,36 +1136,59 @@ func _apply_pan_delta(direction: Vector2, delta: float) -> void: _maybe_refloat() +## Is the cursor CURRENTLY parked against an edge of this Control? +## +## Reads the LIVE cursor position, not the last motion event. `_gui_input` +## only fires while the pointer is over the Control, so a cached position goes +## stale the instant the pointer leaves — and if it left near an edge, the +## viewer kept edge-scrolling forever, believing the cursor was still held +## there. That is the "it scrolls out of screen automatically" defect +## (Jeroen, 2026-07-27): the map drifts off the world with no input at all, +## and no amount of moving the mouse elsewhere stops it, because "elsewhere" +## produces no events for this Control to hear. +## +## get_local_mouse_position() is always current, needs no event, and returns a +## position outside the rect when the pointer is outside — which the bounds +## test below then correctly rejects. The stale-state class is removed rather +## than patched. func _is_cursor_edge_scrolling() -> bool: - if not _app_has_focus or _is_over_ui(_last_mouse_pos): + if not _app_has_focus: return false + if _is_over_ui(_last_mouse_pos): + return false + var cursor: Vector2 = _last_mouse_pos var viewport: Vector2 = size if viewport.x <= 0.0 or viewport.y <= 0.0: return false return ( - _last_mouse_pos.x >= 0.0 - and _last_mouse_pos.y >= 0.0 - and _last_mouse_pos.x <= viewport.x - and _last_mouse_pos.y <= viewport.y + cursor.x >= 0.0 + and cursor.y >= 0.0 + and cursor.x <= viewport.x + and cursor.y <= viewport.y and ( - _last_mouse_pos.x < EDGE_SCROLL_MARGIN_PX - or _last_mouse_pos.y < EDGE_SCROLL_MARGIN_PX - or _last_mouse_pos.x > viewport.x - EDGE_SCROLL_MARGIN_PX - or _last_mouse_pos.y > viewport.y - EDGE_SCROLL_MARGIN_PX + cursor.x < EDGE_SCROLL_MARGIN_PX + or cursor.y < EDGE_SCROLL_MARGIN_PX + or cursor.x > viewport.x - EDGE_SCROLL_MARGIN_PX + or cursor.y > viewport.y - EDGE_SCROLL_MARGIN_PX ) ) +## Which way the edge-parked cursor is pushing. Reads the LIVE position for +## the same reason _is_cursor_edge_scrolling() does — the two must agree, and +## a cached value could have them disagree about whether a scroll is even +## happening. func _edge_scroll_direction() -> Vector2: var viewport: Vector2 = size + var cursor: Vector2 = _last_mouse_pos var direction := Vector2.ZERO - if _last_mouse_pos.x < EDGE_SCROLL_MARGIN_PX: + if cursor.x < EDGE_SCROLL_MARGIN_PX: direction.x -= 1.0 - elif _last_mouse_pos.x > viewport.x - EDGE_SCROLL_MARGIN_PX: + elif cursor.x > viewport.x - EDGE_SCROLL_MARGIN_PX: direction.x += 1.0 - if _last_mouse_pos.y < EDGE_SCROLL_MARGIN_PX: + if cursor.y < EDGE_SCROLL_MARGIN_PX: direction.y -= 1.0 - elif _last_mouse_pos.y > viewport.y - EDGE_SCROLL_MARGIN_PX: + elif cursor.y > viewport.y - EDGE_SCROLL_MARGIN_PX: direction.y += 1.0 return direction