Files
settled-reach/client/ui/implant/apps/atlas/atlas_window_request.gd
T
jpmschweitzer 3891c83206 fix(client): cold-body Pending responses reach the retry path — the launch-shape starvation root cause
The true cause of the black first launch, server-confirmed after four
disproven theories: a stone-cold body answers the FIRST window request
with a whole-response status 'Pending' (only the whole-body cache-hit
branch sets Ready), and on_response()'s very first check — status !=
Ready -> return — swallowed it before the retry machinery could run.
retries stayed 0 forever; the DERIVING state never resolved. Second
connections worked by luck (the first request warms the whole-body
cache, so they read Ready and take the healthy path). Both tile fan-out
AND single-window first-descents were affected — one shared function,
one fix: branch on the outer status first (the atlas_generation_proxy
reference shape): Pending -> retry, Ready -> existing null-window retry,
NotFound/Error -> give up immediately (the principled give-up policy,
replacing the elapsed-retries ceiling).

Hardening in the same round: deterministic exponential backoff (0.5s
doubling, 4s cap) + per-tile stagger (0.1s * index — six tiles retry at
0.5/0.6/0.7/0.8/0.9/1.0s, strictly-increasing asserted, not jittered);
MAX_RETRIES 20->30 (~110s horizon under backoff).

Regressions are wire-accurate by construction: whole-response Pending
(body_id only, no center/n/granularity — verified against the server's
own response construction) delivered through the REAL fan-out
(tile_set._on_atlas_layers_received, never tile.on_response directly),
mirrored at single-window level. Two first-draft tests that passed with
the bug reverted were caught and strengthened before reporting; every
fix and hardening piece revert-verified independently (7+1 failing
tests without them).
2026-07-22 20:56:46 +02:00

465 lines
23 KiB
GDScript

extends Node
## District-window request orchestration for AtlasWindowViewer (T-1138, D-226
## T-1124 amendment §1/§4). Owns the cache, the pan-triggered re-request
## policy, the post-drag-release debounce, and the retry loop for the
## queue-based background derive (PR #185 finding: a window response arrives
## on a LATER TICK, not synchronously — the exact same "None until derived,
## re-poll" contract atlas_generation_proxy.gd's Layer1 path already handles,
## reused here rather than re-invented).
##
## This script has no `class_name` on purpose, matching every other
## viewer-owned helper in this cluster (atlas_overlay_bar.gd/
## atlas_legend_panel.gd/atlas_generation_proxy.gd, review #8 precedent): the
## owner (AtlasWindowViewer) passes itself to _init(), and a `class_name` +
## required-arg _init() combo is a Godot editor footgun. `extends Node` (not
## RefCounted) because it needs get_tree() for the debounce/retry timers —
## added as a child via
## load("res://ui/implant/apps/atlas/atlas_window_request.gd").new(self).
##
## §4 policy fixed here (the constants + the debounce, NOT the pan-edge
## detection — that's the viewer's job, since it owns the screen-to-district
## geometry):
## - DISTRICT_WINDOW_DEFAULT_N = 32 (client's interactive default, half the
## server's DISTRICT_WINDOW_MAX_N = 64 hard cap — §4 pins both numbers;
## the cap itself is a server-side clamp this client never needs to
## duplicate, only stay under so a request is never silently clamped in
## a way the client didn't expect).
## - 150ms post-drag-release debounce — long enough to collapse a
## flick-and-resettle into one request, short enough that a deliberate
## single pan-and-stop never feels delayed (§4/§5 wording, identical).
## - Cache-hit is instant (no request at all) — §4's "D-227 makes exact-
## repeat the common case for Esc-then-re-enter and pan-back" is what
## makes this the common path, not the minority one.
signal window_ready(window: Dictionary) # emitted on a cache hit OR a fresh Ready response
const AtlasWindowCache := preload("res://ui/implant/apps/atlas/atlas_window_cache.gd")
const DISTRICT_WINDOW_DEFAULT_N: int = 32
const DEBOUNCE_DELAY: float = 0.15 # 150ms, §4/§5
## Cold-start dossier (PR #192 round 3): the retry-on-PENDING loop used to be
## a flat RETRY_DELAY=0.5s / MAX_RETRIES=20 (~10s ceiling), copied verbatim
## from atlas_generation_proxy.gd's Layer1 poll — a DIFFERENT, typically
## faster derive. The real starvation bug turned out to be the status-gate
## fix in on_response() (see that function's own doc) — this backoff/stagger
## work is HARDENING landed alongside it, not the fix itself: once the
## status-gate fix makes 6 independent tiles all correctly retry on a
## whole-response Pending, they do so in perfect lockstep (all six went
## pending at entry within the same frame, so all six retry timers fire
## within the same frame too) — six re-requests every RETRY_DELAY, in sync,
## is exactly the "storm" shape worth damping even though it isn't what
## caused the starvation. Exponential backoff (INITIAL_RETRY_DELAY doubling
## to MAX_RETRY_DELAY) plus a DETERMINISTIC per-tile stagger
## (STAGGER_STEP * stagger_index, set once by the owning AtlasWindowTileSet
## at construction — see `_stagger_index`) spread that pulse into a trickle:
## tile 0 retries at 0.5s, tile 1 at 0.6s, tile 2 at 0.7s, etc. — deterministic
## and directly assertable in a test, not a randomized jitter a test would
## have to tolerance-check. Backoff ALSO buys a much longer wall-clock window
## from a modest MAX_RETRIES increase (~110s at 30 retries, see
## _retry_delay_for()'s own doc) without ever polling aggressively for that
## whole span. A fast (already-warm) response still resolves on retry #1,
## unaffected — backoff/stagger only matter once a request is genuinely
## still pending past the first cycle.
const INITIAL_RETRY_DELAY: float = 0.5 # first retry, matches the old flat RETRY_DELAY
const MAX_RETRY_DELAY: float = 4.0 # backoff ceiling — never polls slower than this
const STAGGER_STEP: float = 0.1 # per-tile-index offset — tile i retries STAGGER_STEP*i later
const MAX_RETRIES: int = 30 # ~110s wall-clock at the backoff schedule above
## T-1150 struct/key plumbing: legacy int granularity — district is the
## default for every caller that doesn't request quarter/Region explicitly.
const DEFAULT_GRANULARITY: int = AtlasWindowCache.DISTRICT_GRANULARITY
const DEFAULT_MIN_WL_M: int = 0
## T-1152/T-1153: the R5-redesigned string-tag granularity — "Quarter" |
## "District" | "Region". This is the axis request_now()/request_debounced()'s
## `granularity_v2` parameter actually varies; the legacy int
## (DEFAULT_GRANULARITY) stays pinned at district for every call this object
## makes, since v2 always wins server-side once present
## (resolve_window_granularity_v2()'s documented precedence) and the legacy
## int cannot express Region at all.
const GRANULARITY_V2_QUARTER: String = AtlasWindowCache.GRANULARITY_V2_QUARTER
const GRANULARITY_V2_DISTRICT: String = AtlasWindowCache.GRANULARITY_V2_DISTRICT
const GRANULARITY_V2_REGION: String = AtlasWindowCache.GRANULARITY_V2_REGION
const DEFAULT_GRANULARITY_V2: String = AtlasWindowCache.DEFAULT_GRANULARITY_V2
## Mirrors server/src/atlas/layer_proxy.rs's DISTRICT_WINDOW_MAX_N /
## WIRE_CAP_CELLS exactly (PR #191 review, Tyre C1). `_clamp_window_n_mirror()`
## below reproduces `clamp_window_n()` bit-for-bit — the load-bearing-mirror
## pattern `AtlasDescendGeometry.canonicalize_district_center()` already uses
## for the server's `normalize_window_center()`. Keep both numbers in sync
## with the server constants of the same name if either ever changes.
const SERVER_DISTRICT_WINDOW_MAX_N: int = 64
const SERVER_WIRE_CAP_CELLS: int = 4_096
## T-1152/T-1153: mirrors server/src/atlas/layer_proxy.rs's
## `DISTRICT_WINDOW_MAX_N_REGION` — the Region-only per-axis ceiling on `n`
## (still window extent in DISTRICTS, per WindowGranularity::cell_grid_side's
## doc: `sqrt(WIRE_CAP_CELLS) * DISTRICTS_PER_REGION = 64 * 100`). Keep in
## sync with the server constant of the same name.
const SERVER_DISTRICT_WINDOW_MAX_N_REGION: int = 6_400
## Mirrors server/src/atlas/scale.rs's DISTRICTS_PER_REGION (D-243: one region
## = 100 districts/side) — the divisor `_cell_grid_side_region_mirror()` needs
## to reproduce `WindowGranularity::cell_grid_side`'s Region branch.
const SERVER_DISTRICTS_PER_REGION: int = 100
var _owner = null # AtlasWindowViewer (untyped to avoid cyclic ref)
var _cache = null # AtlasWindowCache
var _body_id: String = ""
var _center: Vector2i = Vector2i.ZERO
var _n: int = DISTRICT_WINDOW_DEFAULT_N
var _granularity: int = DEFAULT_GRANULARITY
var _granularity_v2: String = DEFAULT_GRANULARITY_V2
var _min_wl_m: int = DEFAULT_MIN_WL_M
var _pending: bool = false
var _retries: int = 0
var _debounce_timer: Timer = null
## Cold-start dossier round 3: deterministic per-request stagger index for
## the retry backoff (see STAGGER_STEP's own doc) — 0 for the single-window
## viewer's own request (no fan-out, nothing to desync from), the tile's own
## index (0..5) for a tile-set-owned request (AtlasWindowTileSet.enter()
## sets this once at construction, right after AtlasWindowRequest.new()).
var _stagger_index: int = 0
func _init(owner_ref = null) -> void:
_owner = owner_ref
_cache = AtlasWindowCache.new()
func _ready() -> void:
_debounce_timer = Timer.new()
_debounce_timer.name = "DebounceTimer"
_debounce_timer.one_shot = true
_debounce_timer.wait_time = DEBOUNCE_DELAY
_debounce_timer.timeout.connect(_on_debounce_timeout)
add_child(_debounce_timer)
## Reset for a fresh entry into the regional window mode (new body/center) —
## clears in-flight retry bookkeeping but NOT the cache (D-227: a cached
## window is valid forever regardless of which body/center the viewer is
## currently showing; clearing on every entry would throw away exactly the
## Esc-then-re-enter hit §4 promises).
func reset() -> void:
_pending = false
_retries = 0
if _debounce_timer:
_debounce_timer.stop()
## Mirrors server/src/atlas/layer_proxy.rs's `clamp_window_n(raw_n,
## granularity)` EXACTLY (PR #191 review, Tyre C1 — "the sharpest" finding):
## `serve_district_window` echoes the CLAMPED `n` back in
## `DistrictWindowLayer.n`, but `on_response()`'s staleness guard compares the
## echo against `_n`. Without this mirror, `_n` would hold the RAW requested
## value while the server echoes the CLAMPED one — the moment a caller
## requests quarter (granularity=4) at n=32, the server clamps to n=16 and
## echoes THAT, `on_response()` sees `echoed_n=16 != _n=32`, decides the
## response is stale, and the window silently never loads (no error, no log
## on this side — just an eternally-pending request).
##
## Clamping HERE, before `_n` is ever stored or sent, means `_n` already
## equals what the server will echo — no drift between the two sides, the
## SAME load-bearing-mirror pattern `AtlasDescendGeometry.
## canonicalize_district_center()` uses for the server's
## `normalize_window_center()` (see that function's docstring for the general
## rationale: canonicalizing before the request is sent means the client's
## held state already equals what the server will echo back).
##
## Formula, bit-for-bit: `n = raw_n.clamp(1, SERVER_DISTRICT_WINDOW_MAX_N)`,
## then `n = min(n, floor(sqrt(SERVER_WIRE_CAP_CELLS) / max(granularity, 1)))`
## — applied in that order (per-axis cap first, then the granularity-aware
## wire-size ceiling), matching `clamp_window_n`'s own comment ("Applied AFTER
## the per-axis clamp so a request that already satisfies
## DISTRICT_WINDOW_MAX_N still shrinks further at granularity 4").
static func _clamp_window_n_mirror(raw_n: int, granularity: int) -> int:
var n: int = clampi(raw_n, 1, SERVER_DISTRICT_WINDOW_MAX_N)
var g: int = maxi(granularity, 1)
var cap_n: int = int(floor(sqrt(float(SERVER_WIRE_CAP_CELLS)) / float(g)))
return mini(n, maxi(cap_n, 1))
## [`WindowGranularity`]-aware twin of `_clamp_window_n_mirror()` (T-1152/
## T-1153) — mirrors server/src/atlas/layer_proxy.rs's `clamp_window_n_v2`
## EXACTLY, including its Region branch, per the ticket's explicit
## instruction ("replicate the loop exactly, there is NO closed form"). For
## District/Quarter this delegates straight to `_clamp_window_n_mirror()`
## (byte-identical clamped `n`, matching the server's own
## `clamp_window_n_v2_matches_legacy_for_finer_than_district_rungs`
## guarantee). For Region: per-axis clamp to
## `SERVER_DISTRICT_WINDOW_MAX_N_REGION` (6,400), then halve `n` in a bounded
## loop while `_cell_grid_side_region_mirror(n)^2 > SERVER_WIRE_CAP_CELLS` and
## `n > 1` — there is no closed-form inverse of the rounding division
## `cell_grid_side` uses at Region granularity, so this loop is the correct
## (and only) mirror, not an approximation of one.
static func _clamp_window_n_mirror_v2(raw_n: int, granularity_v2: String) -> int:
if granularity_v2 != AtlasWindowCache.GRANULARITY_V2_REGION:
var legacy_granularity: int = (
DEFAULT_GRANULARITY
if granularity_v2 == AtlasWindowCache.GRANULARITY_V2_DISTRICT
else AtlasWindowCache.DISTRICT_GRANULARITY * 4 # "Quarter" — WINDOW_GRANULARITY_QUARTER
)
return _clamp_window_n_mirror(raw_n, legacy_granularity)
var n: int = clampi(raw_n, 1, SERVER_DISTRICT_WINDOW_MAX_N_REGION)
while (
_cell_grid_side_region_mirror(n) * _cell_grid_side_region_mirror(n) > SERVER_WIRE_CAP_CELLS
and n > 1
):
n = int(n / 2.0)
return maxi(n, 1)
## Mirrors `WindowGranularity::cell_grid_side`'s Region branch EXACTLY:
## `round(n / DISTRICTS_PER_REGION).max(1)` — the derived region-cell-grid
## side length (in CELLS) for a window whose extent is `n` DISTRICTS. Rust's
## `f64::round()` is round-half-away-from-zero; GDScript's `roundi()` matches
## that for non-negative inputs (the only domain `n` — always >= 1 here —
## can produce), so this is a faithful mirror, not an approximation.
static func _cell_grid_side_region_mirror(n: int) -> int:
var side: int = roundi(float(n) / float(SERVER_DISTRICTS_PER_REGION))
return maxi(side, 1)
## Entry point + pan re-request: request the window centered on `center`
## (a DistrictPos-equivalent Vector2i) for `body_id`, at `granularity_v2`
## ("Quarter" | "District" | "Region", T-1152/T-1153 — District is the
## default for every caller that doesn't ask for a different rung explicitly,
## matching the legacy behavior byte-for-byte when omitted). Cache hit ->
## immediate synchronous window_ready emit, no network traffic at all. Cache
## miss -> fire the request now (the caller — either the initial entry or a
## debounce-fired pan/zoom — has already decided this call SHOULD fire; the
## 150ms debounce itself lives in request_debounced() below, not here, so
## this function is also the one entry-mechanic click-through uses directly
## with no debounce at all, matching §5's "first window" contract).
func request_now(
body_id: String,
center: Vector2i,
n: int = DISTRICT_WINDOW_DEFAULT_N,
granularity_v2: String = DEFAULT_GRANULARITY_V2
) -> void:
_body_id = body_id
_center = center
_granularity = DEFAULT_GRANULARITY # legacy int stays pinned at district — v2 always wins server-side
_granularity_v2 = granularity_v2
_min_wl_m = DEFAULT_MIN_WL_M
_n = _clamp_window_n_mirror_v2(n, _granularity_v2) # Tyre C1, extended T-1152 — mirror BEFORE storing
_debounce_timer.stop() # a direct request supersedes any pending debounced one
var cached: Variant = _cache.get_window(
body_id, center, _n, _granularity, _min_wl_m, _granularity_v2
)
if cached != null:
_pending = false
_retries = 0
window_ready.emit(cached)
return
_pending = true
_retries = 0
SimBridge.request_atlas_layers(
body_id, "Topography", center, _n, _granularity, _min_wl_m, _granularity_v2
)
## Pan-triggered re-request (§4/§5: "150ms after the last drag-release, not
## per-drag-frame"). The viewer calls this on every pan-edge-crossing
## candidate; only the LAST call within the debounce window actually fires
## (Timer.start() on an already-running one-shot timer restarts it — Godot's
## documented behavior — so a flick-and-resettle collapses to one request).
func request_debounced(
body_id: String,
center: Vector2i,
n: int = DISTRICT_WINDOW_DEFAULT_N,
granularity_v2: String = DEFAULT_GRANULARITY_V2
) -> void:
_body_id = body_id
_center = center
_granularity = DEFAULT_GRANULARITY
_granularity_v2 = granularity_v2
_min_wl_m = DEFAULT_MIN_WL_M
_n = _clamp_window_n_mirror_v2(n, _granularity_v2) # Tyre C1, extended T-1152 — mirror BEFORE storing
_debounce_timer.start()
func _on_debounce_timeout() -> void:
request_now(_body_id, _center, _n, _granularity_v2)
## Handle an AtlasLayerResponse (routed by the owning viewer from its own
## SimBridge.atlas_layers_received subscription — this object has no signal
## connection of its own, matching atlas_generation_proxy.gd's on_response()
## shape). Ignores responses for a stale body/center/n/min_wl_m/granularity
## (legacy OR v2, see below) — the player panned, zoomed across a rung
## boundary, or navigated away while a request was in flight, or a different
## rung's derive answers a request for a different rung, T-1150/T-1152 — the
## echoed fields ARE the staleness guard (§2, extended T-1150/T-1152),
## compared here against what THIS object most recently asked for.
##
## **Live-round finding (the second C1-shaped bug): v2 is AUTHORITATIVE over
## the legacy field whenever v2 is present — the legacy comparison is
## SKIPPED entirely, not run alongside it.** A T-1152-aware server (this
## codebase's) ALWAYS populates `granularity_v2` on the wire (Dudley's
## contract, `DistrictWindowLayer.granularity_v2`'s own doc: "Always
## populated (never `None`)"), and for `Region` responses specifically the
## LEGACY `granularity` slot carries `WINDOW_GRANULARITY_REGION_KEY`
## (`u32::MAX` = 4294967295) — a reserved KEY-SPACE TAG, not a real
## multiplier, that can never equal this object's own stored `_granularity`
## (which stays pinned at `DEFAULT_GRANULARITY`=1 for every rung this object
## requests, per that field's own doc — the legacy slot has no concept of
## Region at all). Comparing the legacy field UNCONDITIONALLY alongside v2
## therefore drops EVERY Region response as stale forever, even though the
## v2 comparison alone would have correctly accepted it — exactly the live
## bug (`_held_n` fixed; this is the same "old comparison still active
## alongside the new one" class of bug, one layer up in the staleness
## checks). Fix: branch on whether `granularity_v2` is actually PRESENT in
## the response dict (`w.has(...)`, not `w.get(..., default)` — the
## presence/absence distinction is the whole point here) — present (every
## real server, always) -> v2 is the ONLY granularity comparison; absent (a
## hypothetically old, pre-T-1152 server) -> fall back to the legacy
## comparison alone, matching this object's own pre-T-1152 behavior exactly.
## PR #192 cold-start round 3: the coordinator's live cold-server capture
## (retries=0, pending=true, forever) exposed that the OLD version of this
## function returned unconditionally whenever the WHOLE response's status
## wasn't "Ready" — treating a cold body's `status: "Pending"` (the FIRST
## request against a whole-body cache miss, before ANY layer including the
## window has even been queued — `serve_district_window`/`get_or_generate()`
## in server/src/atlas/layer_proxy.rs) identically to `NotFound`/`Error`: a
## silent no-op, never reaching the retry-scheduling code at all. Confirmed
## server-side: `status: Ready` is set ONLY on the whole-body cache-HIT
## branch, entirely independent of whether the WINDOW itself has resolved —
## so a cold body's first-ever window request gets `Pending` at the OUTER
## layer, while a body someone has already warmed (a later connection, or
## this SAME connection's own re-request once its own AnalyzeBody has
## landed) gets `Ready` with `district_window: null` inside it, correctly
## reaching the retry branch below. Same "still generating" signal, two
## different wire shapes depending on which cache warmed first — the fix is
## to treat BOTH as the identical retry-worthy state, matching
## atlas_generation_proxy.gd's own on_response() `match` shape exactly
## (Ready -> handle, Pending -> retry, NotFound/Error -> give up now, not
## after MAX_RETRIES: a real error is never going to resolve by waiting).
func on_response(response: Dictionary) -> void:
if str(response.get("body_id", "")) != _body_id:
return
var status := str(response.get("status", ""))
if status == "Pending":
_retry_if_pending()
return
if status != "Ready":
_pending = false # NotFound / Error — a real failure, not a queue wait; give up now
return
var window: Variant = response.get("district_window")
if window == null:
# §1: an as-yet-underived window rides as `district_window: None`
# inside an OUTER-Ready response — the whole-body cache already
# warmed, but this specific window hasn't derived yet. Same
# "still generating" signal the outer-Pending branch above handles,
# just the OTHER wire shape it can arrive in.
_retry_if_pending()
return
var w: Dictionary = window
var echoed_center := _vec_from_center(w.get("center", [0, 0]))
var echoed_n := int(w.get("n", 0))
var echoed_min_wl_m := int(w.get("min_wl_m", 0))
var granularity_matches: bool = _echoed_granularity_matches(w)
if (
echoed_center != _center
or echoed_n != _n
or echoed_min_wl_m != _min_wl_m
or not granularity_matches
):
return # stale — answers a window we've since panned/zoomed away from, or a different rung
_pending = false
_retries = 0
_cache.put(_body_id, _center, _n, w, _granularity, _min_wl_m, _granularity_v2)
window_ready.emit(w)
## Shared "still generating, re-poll" logic for BOTH wire shapes on_response()
## can see it in (outer status=="Pending", or inner district_window==null
## inside an outer Ready) — re-request until the derive lands or the retry
## ceiling is hit (queue-based serving, PR #185 — the response lands on a
## LATER tick, never this same round-trip).
func _retry_if_pending() -> void:
if not _pending:
return
if _retries < MAX_RETRIES:
_retries += 1
_schedule_retry()
else:
_pending = false # gave up — caller's border-fade / empty state persists
## The granularity half of on_response()'s staleness check, split out for the
## v2-authoritative-when-present precedence rule (see on_response()'s own
## doc for the full live-round rationale). Presence, not value, is the
## branch: `w.has("granularity_v2")` — a real server ALWAYS sets this key
## (even if its value happened to coincidentally equal a default), so
## checking presence rather than "is it the default value" is the only
## correct way to distinguish "an old server that never heard of this field"
## from "a new server whose value happens to match."
func _echoed_granularity_matches(w: Dictionary) -> bool:
if w.has("granularity_v2"):
return str(w.get("granularity_v2")) == _granularity_v2
var echoed_granularity := int(w.get("granularity", AtlasWindowCache.DISTRICT_GRANULARITY))
return echoed_granularity == _granularity
## Pure: the exponential-backoff delay for retry attempt number `retry_count`
## (1-indexed — the FIRST retry, right after the initial request's own
## PENDING answer, uses `retry_count=1`), staggered by `stagger_index`
## (STAGGER_STEP*stagger_index added on top — deterministic, not randomized,
## so a test can assert the exact delay sequence for tile N directly). Split
## out from _schedule_retry() as a pure function for the same reason every
## other formula in this file is: directly unit-testable without a live
## Timer/SceneTree.
static func _retry_delay_for(retry_count: int, stagger_index: int) -> float:
var base: float = INITIAL_RETRY_DELAY * pow(2.0, float(maxi(retry_count - 1, 0)))
var capped: float = minf(base, MAX_RETRY_DELAY)
return capped + STAGGER_STEP * float(stagger_index)
func _schedule_retry() -> void:
var delay: float = _retry_delay_for(_retries, _stagger_index)
var timer := get_tree().create_timer(delay)
timer.timeout.connect(
func() -> void:
if _pending:
SimBridge.request_atlas_layers(
_body_id, "Topography", _center, _n, _granularity, _min_wl_m, _granularity_v2
)
)
func is_pending() -> bool:
return _pending
## The v2 granularity ("Quarter" | "District" | "Region") this object most
## recently asked for — the viewer reads this to know which rung the HELD
## window (once it arrives) actually is, without threading a second copy of
## the state through window_ready's payload.
func get_granularity_v2() -> String:
return _granularity_v2
## Current window extent in districts, as CLAMPED — the viewer's rung-
## selection math needs this to compute the held composite's real-world
## extent regardless of which rung last resolved it.
func get_n() -> int:
return _n
func get_cache() -> Variant:
return _cache
static func _vec_from_center(center: Variant) -> Vector2i:
if center is Array and center.size() >= 2:
return Vector2i(int(center[0]), int(center[1]))
return Vector2i.ZERO