fix(client): the Atlas map scrolled itself off the world
Jeroen: 'it scrolls out of screen automatically.' It did, with no input. _gui_input only fires while the pointer is over the Control, so _last_mouse_pos freezes at wherever it was last seen. Leaving the map ALWAYS means crossing an edge, so the frozen value is always inside the 24px edge margin — and the viewer went on believing the cursor was held there, panning forever. Moving the mouse elsewhere could not stop it, because 'elsewhere' generates no events this Control ever hears. NOTIFICATION_MOUSE_EXIT now resets to the same (-1,-1) sentinel the field is born with, making 'pointer is not over the map' and 'pointer has never been over the map' the same state. Neither should scroll, and there was already a test asserting the second case — the first had no equivalent. Considered and rejected: reading get_local_mouse_position() live instead of caching. It is arguably cleaner, but it cannot be injected in a headless test, so it would have traded a bug for the inability to prove the fix — and the existing edge-scroll suite drives _last_mouse_pos directly. This also explains the drift I had blamed on the capture harness (T-1236): same defect, and the harness was simply exercising it faithfully. Client suite 1833 / 1807 passed / 0 failed / 26 skipped. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user