Merge remote-tracking branch 'origin/zoom-ladder-foundations'
This commit is contained in:
@@ -432,12 +432,24 @@ func send_named_action(action_name: String, action_data: Variant = null) -> void
|
||||
## not a specific whole-body layer to be cached first). Omitted by every
|
||||
## whole-body-layer caller (show_body()'s existing request), so their wire
|
||||
## traffic is byte-unchanged.
|
||||
##
|
||||
## window_granularity/window_min_wl_m (T-1150): struct/key plumbing for the
|
||||
## zoom-ladder quarter rung — district (0/omitted) stays the default for
|
||||
## every caller in this codebase today; requesting quarter granularity is
|
||||
## T-1153's job, not wired here.
|
||||
func request_atlas_layers(
|
||||
body_id: String, up_to: String = "Topography", window_center: Variant = null, window_n: int = 0
|
||||
body_id: String,
|
||||
up_to: String = "Topography",
|
||||
window_center: Variant = null,
|
||||
window_n: int = 0,
|
||||
window_granularity: int = 0,
|
||||
window_min_wl_m: int = 0
|
||||
) -> void:
|
||||
if test_mode or _bridge == null or state != ConnectionState.CONNECTED:
|
||||
return
|
||||
var bytes := Protocol.encode_atlas_layer_request(body_id, up_to, window_center, window_n)
|
||||
var bytes := Protocol.encode_atlas_layer_request(
|
||||
body_id, up_to, window_center, window_n, window_granularity, window_min_wl_m
|
||||
)
|
||||
if bytes.is_empty():
|
||||
return
|
||||
var err: int = _bridge.send_message(bytes)
|
||||
|
||||
@@ -31,18 +31,42 @@ class_name AtlasMapProtocol
|
||||
## [1, DISTRICT_WINDOW_MAX_N] itself and never trusts the wire value; the
|
||||
## client-side default/cap constants (DISTRICT_WINDOW_DEFAULT_N/MAX_N) live on
|
||||
## the regional-window viewer, not duplicated into the codec.
|
||||
##
|
||||
## `window_granularity`/`window_min_wl_m` (T-1150): the derivation-granularity
|
||||
## axis (district=1/omitted vs. quarter=4) and the octave cutoff, in whole
|
||||
## metres. Both OMITTED (not sent as 0) when at their default — this is
|
||||
## struct/key plumbing only (T-1150 scope): no caller in this codebase
|
||||
## requests quarter granularity yet (that's T-1153); this function just makes
|
||||
## it possible to ask, byte-compatible with every existing caller that
|
||||
## doesn't pass them.
|
||||
##
|
||||
## **Quantization split (PR #191 review, Hoshe 1 / Tyre C3):** `window_min_wl_m`
|
||||
## is sent HERE as a raw, unquantized value — this codec does NOT snap it to
|
||||
## the design doc §5 fixed band set. The SERVER is the one place quantization
|
||||
## happens (`serve_district_window` → `quantize_min_wl_m`, `layer_proxy.rs`):
|
||||
## it snaps every request's value to the nearest band before touching the
|
||||
## cache key or the echo, so a caller here is free to send a
|
||||
## viewport-continuous estimate (e.g. `E/C` from the rung-selection rule) —
|
||||
## don't pre-quantize client-side, it would just duplicate logic the server
|
||||
## already owns and could drift out of sync with it.
|
||||
static func encode_atlas_layer_request(
|
||||
mp,
|
||||
body_id: String,
|
||||
up_to: String = "Topography",
|
||||
window_center: Variant = null,
|
||||
window_n: int = 0
|
||||
window_n: int = 0,
|
||||
window_granularity: int = 0,
|
||||
window_min_wl_m: int = 0
|
||||
) -> PackedByteArray:
|
||||
var msg := {"body_id": body_id, "up_to": up_to}
|
||||
if window_center != null:
|
||||
var center: Vector2i = window_center
|
||||
msg["window_center"] = [center.x, center.y]
|
||||
msg["window_n"] = window_n
|
||||
if window_granularity != 0:
|
||||
msg["window_granularity"] = window_granularity
|
||||
if window_min_wl_m != 0:
|
||||
msg["window_min_wl_m"] = window_min_wl_m
|
||||
var result = mp.encode(msg)
|
||||
if result.status != null:
|
||||
push_error("Protocol: encode_atlas_layer_request failed: %s" % result.status)
|
||||
|
||||
@@ -780,14 +780,19 @@ static func encode_request_bookmark_catalog() -> PackedByteArray:
|
||||
## windowed district-resolution regional-map query — see
|
||||
## atlas_map_protocol.gd's encode_atlas_layer_request doc for the wire shape.
|
||||
## Omitted callers (every whole-body-layer call site predating T-1138) are
|
||||
## byte-unchanged.
|
||||
## byte-unchanged. window_granularity/window_min_wl_m (T-1150): same
|
||||
## byte-compatibility contract, see atlas_map_protocol.gd.
|
||||
static func encode_atlas_layer_request(
|
||||
body_id: String,
|
||||
up_to: String = "Topography",
|
||||
window_center: Variant = null,
|
||||
window_n: int = 0
|
||||
window_n: int = 0,
|
||||
window_granularity: int = 0,
|
||||
window_min_wl_m: int = 0
|
||||
) -> PackedByteArray:
|
||||
return _amp().encode_atlas_layer_request(_mp(), body_id, up_to, window_center, window_n)
|
||||
return _amp().encode_atlas_layer_request(
|
||||
_mp(), body_id, up_to, window_center, window_n, window_granularity, window_min_wl_m
|
||||
)
|
||||
|
||||
|
||||
## Decode an AtlasLayerResponse (#969, D-225). Returns a Dictionary
|
||||
|
||||
Binary file not shown.
@@ -214,6 +214,32 @@ func test_encode_atlas_layer_request_carries_window_params() -> void:
|
||||
assert_that(decoded.value.get("window_n")).is_equal(32)
|
||||
|
||||
|
||||
## T-1150: window_granularity/window_min_wl_m are OMITTED (not sent as 0)
|
||||
## when at their default — a windowed request that doesn't pass them (every
|
||||
## pre-T-1150 window caller) is byte-identical to pre-T-1150 wire traffic,
|
||||
## same contract as window_center/window_n's own default-omission above.
|
||||
func test_encode_atlas_layer_request_omits_granularity_and_min_wl_by_default() -> void:
|
||||
var bytes := Protocol.encode_atlas_layer_request(
|
||||
"GJ1c", "Topography", Vector2i(140, 260), 32
|
||||
)
|
||||
var decoded = Messagepack.decode(bytes)
|
||||
assert_bool(decoded.value.has("window_granularity")).is_false()
|
||||
assert_bool(decoded.value.has("window_min_wl_m")).is_false()
|
||||
|
||||
|
||||
## T-1150: a quarter-granularity request with an octave cutoff carries both
|
||||
## new fields verbatim, unclamped (the server owns
|
||||
## resolve_window_granularity()/clamp_window_n() — never trusted from the
|
||||
## wire, same posture as window_n).
|
||||
func test_encode_atlas_layer_request_carries_granularity_and_min_wl() -> void:
|
||||
var bytes := Protocol.encode_atlas_layer_request(
|
||||
"GJ1c", "Topography", Vector2i(140, 260), 32, 4, 512
|
||||
)
|
||||
var decoded = Messagepack.decode(bytes)
|
||||
assert_that(decoded.value.get("window_granularity")).is_equal(4)
|
||||
assert_that(decoded.value.get("window_min_wl_m")).is_equal(512)
|
||||
|
||||
|
||||
## §2: district_window is a distinct payload (echoes center/n for the
|
||||
## client's staleness guard) but the codec passthrough is the same shape as
|
||||
## every sibling layer — raw.get(), no reshaping. Field types follow the
|
||||
@@ -224,6 +250,8 @@ func test_atlas_response_district_window_passthrough() -> void:
|
||||
var window := {
|
||||
"center": [140, 260],
|
||||
"n": 32,
|
||||
"granularity": 4, # T-1150: quarter granularity, passed through same as every other field
|
||||
"min_wl_m": 512,
|
||||
"morphology": PackedByteArray([8, 14, 0, 5]),
|
||||
"elev_q": PackedByteArray([40, 62, 5, 88]),
|
||||
"temp_dc": [120, 95, AtlasOverlayColors.REGION_TEMP_NONE_DC, 60],
|
||||
|
||||
@@ -119,3 +119,64 @@ func test_clear_empties_the_cache() -> void:
|
||||
cache.clear()
|
||||
assert_int(cache.size()).is_equal(0)
|
||||
assert_bool(cache.has("GJ1c", Vector2i(0, 0), 32)).is_false()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# granularity / min_wl_m (T-1150, zoom ladder design doc §3 aliasing risk)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## **MANDATORY aliasing regression (client half, T-1150):** a granularity-4
|
||||
## (quarter) key and a granularity-1 (district) key at the IDENTICAL
|
||||
## (body_id, center, n) must be DISTINCT cache keys — this is what prevents a
|
||||
## quarter-spacing request from silently reading (or overwriting) a
|
||||
## district-spacing window's cache entry, and vice versa.
|
||||
func test_make_key_distinguishes_granularity_at_identical_body_center_n() -> void:
|
||||
var k_district := AtlasWindowCache.make_key(
|
||||
"GJ1c", Vector2i(10, 20), 32, AtlasWindowCache.DISTRICT_GRANULARITY
|
||||
)
|
||||
var k_quarter := AtlasWindowCache.make_key("GJ1c", Vector2i(10, 20), 32, 4)
|
||||
assert_str(k_district).is_not_equal(k_quarter)
|
||||
|
||||
|
||||
## Same aliasing risk, the other new axis: two requests identical except for
|
||||
## `min_wl_m` (the octave cutoff) must not collide either — different cutoffs
|
||||
## are different derived payloads (T-1149/T-1150).
|
||||
func test_make_key_distinguishes_min_wl_m_at_identical_body_center_n_granularity() -> void:
|
||||
var k_uncut := AtlasWindowCache.make_key(
|
||||
"GJ1c", Vector2i(10, 20), 32, AtlasWindowCache.DISTRICT_GRANULARITY, 0
|
||||
)
|
||||
var k_cut := AtlasWindowCache.make_key(
|
||||
"GJ1c", Vector2i(10, 20), 32, AtlasWindowCache.DISTRICT_GRANULARITY, 512
|
||||
)
|
||||
assert_str(k_uncut).is_not_equal(k_cut)
|
||||
|
||||
|
||||
## Omitting granularity/min_wl_m (every pre-T-1150 call site) must produce the
|
||||
## SAME key as passing the explicit district/no-cutoff defaults — byte/string
|
||||
## compatibility for existing callers, not just "doesn't crash".
|
||||
func test_omitted_granularity_and_min_wl_m_match_explicit_district_defaults() -> void:
|
||||
var k_omitted := AtlasWindowCache.make_key("GJ1c", Vector2i(10, 20), 32)
|
||||
var k_explicit := AtlasWindowCache.make_key(
|
||||
"GJ1c", Vector2i(10, 20), 32, AtlasWindowCache.DISTRICT_GRANULARITY, 0
|
||||
)
|
||||
assert_str(k_omitted).is_equal(k_explicit)
|
||||
|
||||
|
||||
## End-to-end through put()/get_window()/has() (not just make_key() in
|
||||
## isolation): a quarter-granularity window and a district-granularity window
|
||||
## at the identical (body, center, n) must both be independently retrievable,
|
||||
## neither one clobbering or masking the other.
|
||||
func test_district_and_quarter_windows_coexist_at_identical_body_center_n() -> void:
|
||||
var cache := AtlasWindowCache.new()
|
||||
var district_window := {"granularity": AtlasWindowCache.DISTRICT_GRANULARITY, "id": "district"}
|
||||
var quarter_window := {"granularity": 4, "id": "quarter"}
|
||||
|
||||
cache.put("GJ1c", Vector2i(10, 20), 32, district_window, AtlasWindowCache.DISTRICT_GRANULARITY)
|
||||
cache.put("GJ1c", Vector2i(10, 20), 32, quarter_window, 4)
|
||||
|
||||
assert_int(cache.size()).is_equal(2)
|
||||
assert_that(
|
||||
cache.get_window("GJ1c", Vector2i(10, 20), 32, AtlasWindowCache.DISTRICT_GRANULARITY)
|
||||
).is_equal(district_window)
|
||||
assert_that(cache.get_window("GJ1c", Vector2i(10, 20), 32, 4)).is_equal(quarter_window)
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
## T-1150 (PR #191 review, Hoshe 4): atlas_window_request.gd had NO test file
|
||||
## at all before this — direct coverage of the granularity/min_wl_m staleness
|
||||
## guard, the n-clamp mirror (Tyre C1), and the old-server-shape default
|
||||
## disposition. Follows test_atlas_window_viewer.gd's own
|
||||
## "AtlasWindowRequest — cache reuse" section conventions (same
|
||||
## instantiation pattern: `AtlasWindowRequest.new(owner_stub)`, `add_child()`
|
||||
## for the debounce Timer, hand-built response dicts) rather than
|
||||
## re-inventing a shape.
|
||||
class_name TestAtlasWindowRequest
|
||||
extends GdUnitTestSuite
|
||||
|
||||
# atlas_window_request.gd has no class_name (review #8 precedent throughout
|
||||
# this cluster) — preloaded once here, not re-load()ed per test (gdlint
|
||||
# duplicated-load).
|
||||
const AtlasWindowRequest := preload("res://ui/implant/apps/atlas/atlas_window_request.gd")
|
||||
|
||||
|
||||
## Build a hand-authored DistrictWindowLayer dict, granularity-aware
|
||||
## (T-1150) — mirrors test_atlas_window_viewer.gd's own _mock_window(), with
|
||||
## granularity/min_wl_m added as optional params so callers can build both
|
||||
## rungs' echo shapes with one helper.
|
||||
static func _mock_window(
|
||||
center: Vector2i, n: int = 2, granularity: int = 1, min_wl_m: int = 0
|
||||
) -> Dictionary:
|
||||
return {
|
||||
"center": [center.x, center.y],
|
||||
"n": n,
|
||||
"granularity": granularity,
|
||||
"min_wl_m": min_wl_m,
|
||||
"morphology": PackedByteArray([8, 14, 0, 1]),
|
||||
"elev_q": PackedByteArray([40, 90, 5, 60]),
|
||||
"temp_dc": [120, 95, -32768, 60],
|
||||
"moisture_q": PackedByteArray([50, 30, 90, 20]),
|
||||
"vegetation": PackedByteArray([2, 1, 6, 3]),
|
||||
"glaciation": PackedByteArray([0, 0, 1, 2]),
|
||||
}
|
||||
|
||||
|
||||
static func _mock_response(body_id: String, window: Variant) -> Dictionary:
|
||||
return {"body_id": body_id, "status": "Ready", "district_window": window}
|
||||
|
||||
|
||||
func _make_request() -> Variant:
|
||||
var owner_stub := RefCounted.new()
|
||||
var req = auto_free(AtlasWindowRequest.new(owner_stub))
|
||||
add_child(req)
|
||||
return req
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# (a) granularity mismatch on the echo -> dropped as stale
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## The mandatory item-(a) case: request_now() asks at the default district
|
||||
## granularity (1); a response echoing granularity=4 (quarter) for the SAME
|
||||
## center/n must be dropped as stale, not accepted — a different rung's
|
||||
## derive answering a request for a different rung is exactly as stale as a
|
||||
## mismatched center (T-1150 extends §2's guard to this axis).
|
||||
func test_on_response_with_mismatched_granularity_is_dropped_as_stale() -> void:
|
||||
var req = _make_request()
|
||||
req.request_now("GJ380c", Vector2i(2, 2), 2)
|
||||
assert_bool(req.is_pending()).is_true()
|
||||
|
||||
var quarter_window: Dictionary = _mock_window(Vector2i(2, 2), 2, 4, 0)
|
||||
req.on_response(_mock_response("GJ380c", quarter_window))
|
||||
|
||||
assert_bool(req.is_pending()).override_failure_message(
|
||||
"a granularity-mismatched response must be dropped as stale, leaving the district request still pending"
|
||||
).is_true()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# (b) old-server-shape response (no granularity/min_wl_m keys) -> defaults
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## A response from a hypothetical pre-T-1150 server (or any response whose
|
||||
## district_window dict simply omits the new keys) must decode granularity
|
||||
## as district (1) and min_wl_m as 0 via the same defaulting on_response()
|
||||
## already applies — and since request_now()'s own defaults are identical,
|
||||
## the response is ACCEPTED, not treated as stale just because two keys are
|
||||
## missing.
|
||||
func test_on_response_missing_granularity_and_min_wl_defaults_and_is_accepted() -> void:
|
||||
var req = _make_request()
|
||||
req.request_now("GJ380c", Vector2i(3, 3), 2)
|
||||
assert_bool(req.is_pending()).is_true()
|
||||
|
||||
# Old-shape window: no "granularity"/"min_wl_m" keys at all.
|
||||
var old_shape_window := {
|
||||
"center": [3, 3],
|
||||
"n": 2,
|
||||
"morphology": PackedByteArray([8, 14, 0, 1]),
|
||||
"elev_q": PackedByteArray([40, 90, 5, 60]),
|
||||
"temp_dc": [120, 95, -32768, 60],
|
||||
"moisture_q": PackedByteArray([50, 30, 90, 20]),
|
||||
"vegetation": PackedByteArray([2, 1, 6, 3]),
|
||||
"glaciation": PackedByteArray([0, 0, 1, 2]),
|
||||
}
|
||||
req.on_response(_mock_response("GJ380c", old_shape_window))
|
||||
|
||||
assert_bool(req.is_pending()).override_failure_message(
|
||||
(
|
||||
"an old-server-shape response (missing granularity/min_wl_m) must "
|
||||
+ "default to district/0 and be ACCEPTED, not dropped as stale"
|
||||
)
|
||||
).is_false()
|
||||
|
||||
var received: Array = []
|
||||
req.window_ready.connect(func(w: Dictionary) -> void: received.append(w))
|
||||
# Re-request the same (body, center, n) — must now be a cache hit, proving
|
||||
# on_response() actually stored the old-shape window under the
|
||||
# district/0 key, not silently discarding it.
|
||||
req.request_now("GJ380c", Vector2i(3, 3), 2)
|
||||
assert_int(received.size()).is_equal(1)
|
||||
assert_bool(req.is_pending()).is_false()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# (c) n-clamp mirror (Tyre C1) — quarter n=32 stores clamped n=16
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## **Item (c) as literally scoped by the ticket** ("the clamp-mirror from
|
||||
## item 1"): `_clamp_window_n_mirror()` reproduces the server's
|
||||
## `clamp_window_n(raw_n, granularity)` bit-for-bit, INCLUDING the quarter
|
||||
## n=32 -> 16 case — pinned directly against the static helper, independent
|
||||
## of the request/response plumbing (`request_now()` has no public
|
||||
## "request quarter" entry point today; T-1150 is struct/key plumbing only,
|
||||
## requesting quarter is T-1153's job — see the class-level docstring on
|
||||
## `_clamp_window_n_mirror()` for why calling `request_now()` at district
|
||||
## granularity can never itself exercise the quarter branch: it unconditionally
|
||||
## resets `_granularity` to district BEFORE clamping, by design, since no
|
||||
## caller can ask for quarter yet).
|
||||
func test_clamp_window_n_mirror_matches_server_formula_at_quarter_n32() -> void:
|
||||
assert_int(AtlasWindowRequest._clamp_window_n_mirror(32, 4)).is_equal(16)
|
||||
# District granularity: the per-axis cap (64) governs, matching the
|
||||
# server's clamp_window_n_district_granularity_uses_per_axis_cap test.
|
||||
assert_int(AtlasWindowRequest._clamp_window_n_mirror(640, 1)).is_equal(64)
|
||||
# Small n well under budget at quarter granularity stays unclamped,
|
||||
# matching clamp_window_n_quarter_granularity_leaves_small_n_unclamped.
|
||||
assert_int(AtlasWindowRequest._clamp_window_n_mirror(8, 4)).is_equal(8)
|
||||
|
||||
|
||||
## **Item (c), the request/response half:** `request_now()` actually WIRES
|
||||
## the mirror in (not just defines it) — a request for a district-legal but
|
||||
## per-axis-oversized `n` (e.g. 640, mirroring the server's own
|
||||
## `DISTRICT_WINDOW_MAX_N*10` oversized-request test) stores the CLAMPED
|
||||
## `_n=64`, so a server response echoing the server's OWN clamped n=64 is
|
||||
## ACCEPTED, not rejected as stale for "not matching" the raw 640 that was
|
||||
## asked for. This is the exact n-clamp/echo/staleness triangle Tyre C1
|
||||
## flagged, exercised through the reachable (district) path today; the
|
||||
## quarter-specific n=32->16 number is pinned by the formula test above since
|
||||
## no public API can drive quarter through `request_now()` yet.
|
||||
func test_oversized_n_request_stores_clamped_n_and_accepts_matching_echo() -> void:
|
||||
var req = _make_request()
|
||||
req.request_now("GJ380c", Vector2i(4, 4), 640)
|
||||
|
||||
assert_int(req._n).override_failure_message(
|
||||
(
|
||||
"request_now() must mirror the server's clamp_window_n(640, granularity=1) "
|
||||
+ "== 64 BEFORE storing _n, not store the raw requested 640"
|
||||
)
|
||||
).is_equal(64)
|
||||
assert_bool(req.is_pending()).is_true()
|
||||
|
||||
# The server's real response for this request echoes n=64 (its own
|
||||
# clamp_window_n() result) — must be ACCEPTED, not stale.
|
||||
var clamped_echo: Dictionary = _mock_window(Vector2i(4, 4), 64, 1, 0)
|
||||
req.on_response(_mock_response("GJ380c", clamped_echo))
|
||||
|
||||
assert_bool(req.is_pending()).override_failure_message(
|
||||
(
|
||||
"a response echoing the CLAMPED n=64 must be accepted, since _n was "
|
||||
+ "already clamped to 64 before the request fired"
|
||||
)
|
||||
).is_false()
|
||||
@@ -511,6 +511,27 @@ func test_decode_atlas_response_not_found() -> void:
|
||||
assert_that(resp.status).is_equal("NotFound")
|
||||
|
||||
|
||||
## PR #191 review, Hoshe 3: `atlas_response_ready_with_window.msgpack` had NO
|
||||
## consumer anywhere in client/tests — regenerated by the T-1150 `granularity`/
|
||||
## `min_wl_m` field additions but nothing decoded it through the real IPC path.
|
||||
## This is that consumer, matching the sibling `test_decode_atlas_response_*`
|
||||
## tests' style/fixture-dir convention above: full decode_atlas_layer_response()
|
||||
## round trip (not a hand-built Dictionary like test_atlas_data_delivery.gd's
|
||||
## passthrough tests), confirming `district_window.granularity`/`.min_wl_m`
|
||||
## (T-1150's two new echo fields) survive the real client decode path.
|
||||
func test_decode_atlas_response_ready_with_window() -> void:
|
||||
var bytes := _load_fixture("atlas_response_ready_with_window")
|
||||
var resp = Protocol.decode_atlas_layer_response(bytes)
|
||||
assert_that(resp).is_not_null()
|
||||
assert_that(resp.status).is_equal("Ready")
|
||||
assert_that(resp.district_window).is_not_null()
|
||||
var window: Dictionary = resp.district_window
|
||||
assert_that(window.get("center")).is_equal([10, -5])
|
||||
assert_that(int(window.get("n"))).is_equal(2)
|
||||
assert_that(int(window.get("granularity"))).is_equal(1)
|
||||
assert_that(int(window.get("min_wl_m"))).is_equal(0)
|
||||
|
||||
|
||||
func test_snapshot_is_not_decoded_as_atlas_response() -> void:
|
||||
# Disambiguation: an ObserverSnapshot has no "status" key, so the atlas
|
||||
# decoder rejects it. receive_bytes relies on this to route correctly.
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
extends RefCounted
|
||||
|
||||
## Client-side LRU cache for DistrictWindowLayer responses (T-1138, D-226
|
||||
## T-1124 amendment §4 "Client cache policy").
|
||||
## T-1124 amendment §4 "Client cache policy"; extended T-1150 for the
|
||||
## granularity/min_wl axes).
|
||||
##
|
||||
## Keyed on (body_id, center, n) — D-227's determinism guarantee (same seed +
|
||||
## body + position -> same derived output, always) means a previously-fetched
|
||||
## window is valid FOREVER for that body+seed. This is an LRU-evict-only
|
||||
## cache: no freshness check, no TTL, no invalidation path at all. The only
|
||||
## reason an entry ever leaves is capacity pressure.
|
||||
## Keyed on (body_id, center, n, granularity, min_wl_m) — D-227's determinism
|
||||
## guarantee (same seed + body + position + derivation params -> same derived
|
||||
## output, always) means a previously-fetched window is valid FOREVER for
|
||||
## that body+seed. This is an LRU-evict-only cache: no freshness check, no
|
||||
## TTL, no invalidation path at all. The only reason an entry ever leaves is
|
||||
## capacity pressure.
|
||||
##
|
||||
## granularity/min_wl_m default to DISTRICT_GRANULARITY/0 (district spacing,
|
||||
## no octave cutoff) — every pre-T-1150 caller that doesn't pass them keeps
|
||||
## its existing key shape and cache behavior unchanged. This is the client
|
||||
## half of the mandatory aliasing fix (T-1150 design doc §3): a
|
||||
## quarter-granularity request and a district-granularity request at the
|
||||
## identical (body, center, n) MUST NOT collide on the same cache slot.
|
||||
##
|
||||
## Godot's Dictionary preserves insertion order, so "move to the end on
|
||||
## touch, evict from the front on overflow" is the whole LRU implementation —
|
||||
@@ -20,6 +29,10 @@ extends RefCounted
|
||||
|
||||
const DEFAULT_MAX_ENTRIES: int = 24
|
||||
|
||||
## Mirrors the server's WINDOW_GRANULARITY_DISTRICT (layer_proxy.rs) — the
|
||||
## default granularity every pre-T-1150 caller implicitly requests.
|
||||
const DISTRICT_GRANULARITY: int = 1
|
||||
|
||||
var _max_entries: int = DEFAULT_MAX_ENTRIES
|
||||
var _entries: Dictionary = {} # key String -> DistrictWindowLayer Dictionary
|
||||
|
||||
@@ -28,19 +41,34 @@ func _init(max_entries: int = DEFAULT_MAX_ENTRIES) -> void:
|
||||
_max_entries = maxi(1, max_entries)
|
||||
|
||||
|
||||
## Build the cache key from the three fields D-227 makes sufficient:
|
||||
## body_id (which world+body), center (a [row, col] pair or Vector2i), and n
|
||||
## (window side length). String-keyed rather than a nested Dictionary/Array
|
||||
## key — Godot Dictionary keys compare by value for primitives but a
|
||||
## consistent stringification sidesteps any Vector2i-vs-Array identity
|
||||
## mismatch between what a caller happens to hand in.
|
||||
static func make_key(body_id: String, center: Vector2i, n: int) -> String:
|
||||
return "%s:%d,%d:%d" % [body_id, center.x, center.y, n]
|
||||
## Build the cache key from the five fields D-227 + T-1150 make sufficient:
|
||||
## body_id (which world+body), center (a [row, col] pair or Vector2i), n
|
||||
## (window extent in districts), granularity (district=1 / quarter=4), and
|
||||
## min_wl_m (the octave cutoff, 0 = none). String-keyed rather than a nested
|
||||
## Dictionary/Array key — Godot Dictionary keys compare by value for
|
||||
## primitives but a consistent stringification sidesteps any
|
||||
## Vector2i-vs-Array identity mismatch between what a caller happens to hand
|
||||
## in.
|
||||
static func make_key(
|
||||
body_id: String,
|
||||
center: Vector2i,
|
||||
n: int,
|
||||
granularity: int = DISTRICT_GRANULARITY,
|
||||
min_wl_m: int = 0
|
||||
) -> String:
|
||||
return "%s:%d,%d:%d:%d:%d" % [body_id, center.x, center.y, n, granularity, min_wl_m]
|
||||
|
||||
|
||||
## True if a window is already cached for this exact (body, center, n).
|
||||
func has(body_id: String, center: Vector2i, n: int) -> bool:
|
||||
return _entries.has(make_key(body_id, center, n))
|
||||
## True if a window is already cached for this exact (body, center, n,
|
||||
## granularity, min_wl_m).
|
||||
func has(
|
||||
body_id: String,
|
||||
center: Vector2i,
|
||||
n: int,
|
||||
granularity: int = DISTRICT_GRANULARITY,
|
||||
min_wl_m: int = 0
|
||||
) -> bool:
|
||||
return _entries.has(make_key(body_id, center, n, granularity, min_wl_m))
|
||||
|
||||
|
||||
## Fetch a cached window, touching it (move-to-most-recently-used). Returns
|
||||
@@ -48,8 +76,14 @@ func has(body_id: String, center: Vector2i, n: int) -> bool:
|
||||
## response, which is a different concept (§1: an as-yet-underived window is
|
||||
## carried as `district_window: None` inside a `Ready` AtlasLayerResponse,
|
||||
## not a cache state).
|
||||
func get_window(body_id: String, center: Vector2i, n: int) -> Variant:
|
||||
var key := make_key(body_id, center, n)
|
||||
func get_window(
|
||||
body_id: String,
|
||||
center: Vector2i,
|
||||
n: int,
|
||||
granularity: int = DISTRICT_GRANULARITY,
|
||||
min_wl_m: int = 0
|
||||
) -> Variant:
|
||||
var key := make_key(body_id, center, n, granularity, min_wl_m)
|
||||
if not _entries.has(key):
|
||||
return null
|
||||
var value: Variant = _entries[key]
|
||||
@@ -61,8 +95,15 @@ func get_window(body_id: String, center: Vector2i, n: int) -> Variant:
|
||||
|
||||
## Store a window, evicting the least-recently-used entry(ies) if over
|
||||
## capacity. Overwriting an existing key also counts as a touch.
|
||||
func put(body_id: String, center: Vector2i, n: int, window: Dictionary) -> void:
|
||||
var key := make_key(body_id, center, n)
|
||||
func put(
|
||||
body_id: String,
|
||||
center: Vector2i,
|
||||
n: int,
|
||||
window: Dictionary,
|
||||
granularity: int = DISTRICT_GRANULARITY,
|
||||
min_wl_m: int = 0
|
||||
) -> void:
|
||||
var key := make_key(body_id, center, n, granularity, min_wl_m)
|
||||
if _entries.has(key):
|
||||
_entries.erase(key)
|
||||
_entries[key] = window
|
||||
|
||||
@@ -41,11 +41,29 @@ const DEBOUNCE_DELAY: float = 0.15 # 150ms, §4/§5
|
||||
const RETRY_DELAY: float = 0.5 # matches atlas_generation_proxy.gd's GEN_RETRY_DELAY
|
||||
const MAX_RETRIES: int = 20 # ~10s ceiling, matches atlas_generation_proxy.gd's GEN_MAX_RETRIES
|
||||
|
||||
## T-1150 struct/key plumbing: this viewer only ever REQUESTS district
|
||||
## granularity today (requesting quarter is T-1153's job) — these constants
|
||||
## exist so the cache key / staleness guard below are granularity-aware from
|
||||
## day one, not bolted on later.
|
||||
const DEFAULT_GRANULARITY: int = AtlasWindowCache.DISTRICT_GRANULARITY
|
||||
const DEFAULT_MIN_WL_M: int = 0
|
||||
|
||||
## 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
|
||||
|
||||
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 _min_wl_m: int = DEFAULT_MIN_WL_M
|
||||
var _pending: bool = false
|
||||
var _retries: int = 0
|
||||
var _debounce_timer: Timer = null
|
||||
@@ -77,6 +95,38 @@ func reset() -> void:
|
||||
_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))
|
||||
|
||||
|
||||
## Entry point + pan re-request: request the window centered on `center`
|
||||
## (a DistrictPos-equivalent Vector2i) for `body_id`. Cache hit -> immediate
|
||||
## synchronous window_ready emit, no network traffic at all. Cache miss ->
|
||||
@@ -88,10 +138,12 @@ func reset() -> void:
|
||||
func request_now(body_id: String, center: Vector2i, n: int = DISTRICT_WINDOW_DEFAULT_N) -> void:
|
||||
_body_id = body_id
|
||||
_center = center
|
||||
_n = n
|
||||
_granularity = DEFAULT_GRANULARITY
|
||||
_min_wl_m = DEFAULT_MIN_WL_M
|
||||
_n = _clamp_window_n_mirror(n, _granularity) # Tyre C1 — mirror BEFORE storing/requesting
|
||||
_debounce_timer.stop() # a direct request supersedes any pending debounced one
|
||||
|
||||
var cached: Variant = _cache.get_window(body_id, center, n)
|
||||
var cached: Variant = _cache.get_window(body_id, center, _n, _granularity, _min_wl_m)
|
||||
if cached != null:
|
||||
_pending = false
|
||||
_retries = 0
|
||||
@@ -100,7 +152,7 @@ func request_now(body_id: String, center: Vector2i, n: int = DISTRICT_WINDOW_DEF
|
||||
|
||||
_pending = true
|
||||
_retries = 0
|
||||
SimBridge.request_atlas_layers(body_id, "Topography", center, n)
|
||||
SimBridge.request_atlas_layers(body_id, "Topography", center, _n, _granularity, _min_wl_m)
|
||||
|
||||
|
||||
## Pan-triggered re-request (§4/§5: "150ms after the last drag-release, not
|
||||
@@ -111,7 +163,9 @@ func request_now(body_id: String, center: Vector2i, n: int = DISTRICT_WINDOW_DEF
|
||||
func request_debounced(body_id: String, center: Vector2i, n: int = DISTRICT_WINDOW_DEFAULT_N) -> void:
|
||||
_body_id = body_id
|
||||
_center = center
|
||||
_n = n
|
||||
_granularity = DEFAULT_GRANULARITY
|
||||
_min_wl_m = DEFAULT_MIN_WL_M
|
||||
_n = _clamp_window_n_mirror(n, _granularity) # Tyre C1 — mirror BEFORE storing/requesting
|
||||
_debounce_timer.start()
|
||||
|
||||
|
||||
@@ -122,10 +176,11 @@ func _on_debounce_timeout() -> void:
|
||||
## 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 (the player panned or
|
||||
## navigated away while a request was in flight) — the echoed center/n IS the
|
||||
## staleness guard (§2), compared here against what THIS object most recently
|
||||
## asked for.
|
||||
## shape). Ignores responses for a stale body/center/n/granularity/min_wl_m
|
||||
## (the player panned or navigated away while a request was in flight, or a
|
||||
## different rung's derive answers a request for a different rung, T-1150) —
|
||||
## the echoed fields ARE the staleness guard (§2, extended T-1150), compared
|
||||
## here against what THIS object most recently asked for.
|
||||
func on_response(response: Dictionary) -> void:
|
||||
if str(response.get("body_id", "")) != _body_id:
|
||||
return
|
||||
@@ -150,12 +205,19 @@ func on_response(response: Dictionary) -> void:
|
||||
var w: Dictionary = window
|
||||
var echoed_center := _vec_from_center(w.get("center", [0, 0]))
|
||||
var echoed_n := int(w.get("n", 0))
|
||||
if echoed_center != _center or echoed_n != _n:
|
||||
return # stale — answers a window we've since panned away from (§2)
|
||||
var echoed_granularity := int(w.get("granularity", AtlasWindowCache.DISTRICT_GRANULARITY))
|
||||
var echoed_min_wl_m := int(w.get("min_wl_m", 0))
|
||||
if (
|
||||
echoed_center != _center
|
||||
or echoed_n != _n
|
||||
or echoed_granularity != _granularity
|
||||
or echoed_min_wl_m != _min_wl_m
|
||||
):
|
||||
return # stale — answers a window we've since panned away from, or a different rung (§2/T-1150)
|
||||
|
||||
_pending = false
|
||||
_retries = 0
|
||||
_cache.put(_body_id, _center, _n, w)
|
||||
_cache.put(_body_id, _center, _n, w, _granularity, _min_wl_m)
|
||||
window_ready.emit(w)
|
||||
|
||||
|
||||
@@ -164,7 +226,9 @@ func _schedule_retry() -> void:
|
||||
timer.timeout.connect(
|
||||
func() -> void:
|
||||
if _pending:
|
||||
SimBridge.request_atlas_layers(_body_id, "Topography", _center, _n)
|
||||
SimBridge.request_atlas_layers(
|
||||
_body_id, "Topography", _center, _n, _granularity, _min_wl_m
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1669,7 +1669,7 @@ Technical foundation decisions that constrain implementation: engine, client-ser
|
||||
|
||||
**Amended 2026-07-21 (T-1145 — Jeroen, second companion hands-on, KALLAST window):** three regional-window presentation fixes, all client-only. **Cover-fit supersedes contain:** `fit_window_view()`'s zoom now derives from the LARGER viewport dimension with no margin factor (`max(viewport.x, viewport.y) / composite_native`, not the old `0.9 * min(...)`), so the square district-window composite fills a wide/tall viewport edge to edge instead of leaving side margins, with the shorter axis' data extending into pan-space (the same "cover" concept as CSS `object-fit: cover`) — the existing §4 pan-edge refetch is unaffected (it keys off the screen-center-to-DistrictPos mapping, which any fit already centers on `_held_center` by construction, so no refetch churn at rest). **WASD + edge-scroll supersedes drag-pan:** LMB-drag panning is removed entirely (Jeroen's ruling — drag conflicts with click semantics for the map objects, e.g. settlements, this window will host later); panning is now held WASD/arrow keys (continuous, frame-rate-independent, `_process`-polled, physical-keycode reads to stay independent of the project's existing `move_north`/etc. gameplay-movement InputMap actions bound to the same keys) plus edge-scrolling (cursor within ~24px of a viewport edge, suppressed over UI and while the OS window lacks focus); wheel zoom is unchanged; pole-wall (§5 amendment above) and east-west wrap (T-1142) semantics are preserved unchanged under the new input source. **Smoothing is an interim presentation, pending T-1143:** the composite renders as an `n`×`n` `Image`/`ImageTexture` (one pixel per district, the identical existing per-cell color pipeline) drawn scaled with linear filtering — the same treatment the planetary heightmap already gets — instead of `n`×`n` flat rects, so GPU bilinear sampling reads as a terrain gradient rather than hard blocks; the original crisp per-cell path survives behind a compile-time const specifically so T-1143's design pass can compare both directly, and this smoothing is **not** T-1143's answer to district-tier legibility, only a stopgap ahead of it.
|
||||
|
||||
**Amended 2026-07-21 (T-1143 design-pass rulings — Jeroen, after the zoom-ladder design pass, `docs/architecture/atlas-zoom-ladder-t1143.md`):** three rulings on the pass's reserved decisions. **(1) The item-(d) ceiling is opened for the Atlas ladder** — Jeroen: *"we set a new BHAG so old restrictions are up for debate."* The D-166 2026-07-21 zoom-ladder condition ("down to tile scale") is read **literally**: the Atlas windowed viewport may descend below quarter (512 m) toward block/tile granularity. This is an explicit ruling, not erosion — exactly the deliberate revisit the T-1112 §2 anti-erosion clause was hardened to force into the open. Item (d)'s substance survives in narrowed form: chunk/tile/voxel output still never appears as a **whole-body planetary map layer**, and the below-quarter rungs are implementation-gated on their own **measurement pass** (costs/wire for block and tile rungs are unmeasured — design pass §2/§7); the harness-verification path for L5 fill remains primary until that pass lands. **(2) Planetary rung wire carrier: progressive capped-density tiling** riding the generalized `district_window` carrier (granularity parameter, §3 of the design pass) — no new dense-raster wire shape, no forced tagged-envelope migration. **(3) The §5 entry click-through cut is superseded by continuous cursor-anchored zoom**: wheel-zoom carries the view from the orbital frame down through regional granularities continuously, anchored at the cursor, **with the condition that a full zoom-out resets to the original canonical planetary frame and location** (the fixed orbital framing is the ladder's top rest state, not a drifted pan state). The click-through descent and rectangle reticle are retired as the *sole* entry (T-1138's shipped mechanic stands until the continuous ladder replaces it in the same change — close inspection is never stranded, same discipline as the §5 fixed-view transition). D-013's "the zoom gesture owns spatial descent" reading is **restored** for this seam.
|
||||
**Amended 2026-07-21 (T-1143 design-pass rulings — Jeroen, after the zoom-ladder design pass, `docs/architecture/atlas-zoom-ladder-t1143.md`):** three rulings on the pass's reserved decisions. **(1) The item-(d) ceiling is opened for the Atlas ladder** — Jeroen: *"we set a new BHAG so old restrictions are up for debate."* The D-166 2026-07-21 zoom-ladder condition ("down to tile scale") is read **literally**: the Atlas windowed viewport may descend below quarter (512 m) toward block/tile granularity. This is an explicit ruling, not erosion — exactly the deliberate revisit the T-1112 §2 anti-erosion clause was hardened to force into the open. Item (d)'s substance survives in narrowed form: chunk/tile/voxel output still never appears as a **whole-body planetary map layer**, and the below-quarter rungs are implementation-gated on their own **measurement pass** (costs/wire for block and tile rungs are unmeasured — design pass §2/§7); the harness-verification path for L5 fill remains primary until that pass lands. **(2) Planetary rung wire carrier: progressive capped-density tiling** riding the generalized `district_window` carrier (granularity parameter, §3 of the design pass) — no new dense-raster wire shape, no forced tagged-envelope migration. **(3) The §5 entry click-through cut is superseded by continuous cursor-anchored zoom**: wheel-zoom carries the view from the orbital frame down through regional granularities continuously, anchored at the cursor, **with the condition that a full zoom-out resets to the original canonical planetary frame and location** (the fixed orbital framing is the ladder's top rest state, not a drifted pan state). The click-through descent and rectangle reticle are retired as the *sole* entry (T-1138's shipped mechanic stands until the continuous ladder replaces it in the same change — close inspection is never stranded, same discipline as the §5 fixed-view transition). D-013's "the zoom gesture owns spatial descent" reading is **restored** for this seam. **Wire-contract note (T-1150, PR #191 review — Tyre):** the `window_granularity` field that ruling (2) rides on expresses **finer-than-district integer multiples only** (1 = district, 4 = quarter today; each new rung is a deliberate widening of `resolve_window_granularity`'s whitelist — the single widening point; unknown values fall back to district, never trusted from the wire). Coarser-than-district reuse (the region/orbital rungs of ruling (2)'s progressive tiling) requires the design pass's R5 signed/log-scale-or-enum redesign of the field — a new magic value is not the path. Recorded here so the type's limit is contract, not only a design-doc risk row.
|
||||
- **Rationale:** Reusing the real UI — rather than a parallel offline renderer or dumped files — means the debug/review surface never diverges from what ships, and a dropped artifact can't go stale. Agent-navigability converts qualitative "does the synthesis look natural?" review from a manual eyeball pass into an automatable sweep that flags the few outliers for a human. The harness rides seams that already exist (`TickRate::Paused`, the paused-allowlist, `gameplay_occluded`, the bridge framing, the `run-visual` capture primitive) — a naming-and-contract exercise, not a new subsystem.
|
||||
- **New surface:** server pause-gating (run-conditions on the world phases keyed to a pause command); client `AtlasAgentInterface` (`observe`/`act`, Control-tree walker) + its local transport; the generation overlay rendering + selector + legend; interactive capture wired to `run-visual`.
|
||||
- **Implementation:** Phase 4 (epic T-750), built bottom-up — auto-pause substrate, T-969 proxy (D-225), T-960 viewer, agent channel, agent capture. Geography is the first consumer.
|
||||
|
||||
@@ -382,7 +382,7 @@ mod tests {
|
||||
);
|
||||
let ch = coast_character_at(&env, 42, 1e6, 1e6, 20.0, GlaciationGrade::None, 60);
|
||||
let (wdx, _) = coast_warp_px(42, 1e6, 1e6, &ch);
|
||||
let scatter = crate::atlas::detail_scatter::terrain_detail(42, 1e6, 1e6, 1.0, 0.5);
|
||||
let scatter = crate::atlas::detail_scatter::terrain_detail(42, 1e6, 1e6, 1.0, 0.5, 0.0);
|
||||
assert_ne!(wdx, scatter);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,15 @@ use crate::seed::splitmix64;
|
||||
/// Mid-scale octave wavelengths in metres — the 2–40 km band. Coarsest first.
|
||||
/// Below the finest (~4 km) the district→voxel layers own the detail; above the
|
||||
/// coarsest (~33 km) the heightmap itself carries the shape.
|
||||
const OCTAVE_WAVELENGTHS_M: [f64; 4] = [32_768.0, 16_384.0, 8_192.0, 4_096.0];
|
||||
///
|
||||
/// `pub(crate)`: also the quantization band set for `layer_proxy`'s
|
||||
/// `window_min_wl_m` (T-1150, zoom ladder design doc §5 — "quantize
|
||||
/// `min_wl_m` to a small fixed set of bands per rung, **matching the rung's
|
||||
/// own octave bands**"). District/quarter windows both derive via
|
||||
/// `terrain_detail`, so this IS "the rung's own octave bands" for both rungs
|
||||
/// today — one array, no duplicated magic numbers that could drift out of
|
||||
/// sync with the actual cutoff behavior.
|
||||
pub(crate) const OCTAVE_WAVELENGTHS_M: [f64; 4] = [32_768.0, 16_384.0, 8_192.0, 4_096.0];
|
||||
|
||||
/// Voxel-tier octave wavelengths in metres — the ≈0.13–1 km **sub-district** band
|
||||
/// (all finer than the 2 km district planning unit) that the district-tier
|
||||
@@ -78,8 +86,29 @@ pub(crate) fn value_noise(seed: u64, wx: f64, wy: f64, wavelength_m: f64) -> f64
|
||||
///
|
||||
/// Returns roughly `[-envelope, +envelope]`, smaller and smoother as `ruggedness`
|
||||
/// drops toward 0 (gentle flats), larger and ridged as it rises toward 1.
|
||||
pub fn terrain_detail(seed: u64, wx: f64, wy: f64, envelope: f64, ruggedness: f64) -> f64 {
|
||||
enveloped_fbm(seed, wx, wy, envelope, ruggedness, &OCTAVE_WAVELENGTHS_M)
|
||||
///
|
||||
/// `min_wavelength_m` (T-1149, zoom ladder §2): octaves whose wavelength is
|
||||
/// below this cutoff are skipped entirely — the Nyquist truncation a coarse
|
||||
/// sample density needs (no point paying for detail finer than the sample
|
||||
/// spacing can resolve). `0.0` = no cutoff = every octave, byte-identical to
|
||||
/// pre-T-1149 behavior.
|
||||
pub fn terrain_detail(
|
||||
seed: u64,
|
||||
wx: f64,
|
||||
wy: f64,
|
||||
envelope: f64,
|
||||
ruggedness: f64,
|
||||
min_wavelength_m: f64,
|
||||
) -> f64 {
|
||||
enveloped_fbm(
|
||||
seed,
|
||||
wx,
|
||||
wy,
|
||||
envelope,
|
||||
ruggedness,
|
||||
&OCTAVE_WAVELENGTHS_M,
|
||||
min_wavelength_m,
|
||||
)
|
||||
}
|
||||
|
||||
/// The voxel-tier mid-scale relief perturbation in `[0,1]`-normalized units
|
||||
@@ -91,7 +120,16 @@ pub fn terrain_detail(seed: u64, wx: f64, wy: f64, envelope: f64, ruggedness: f6
|
||||
/// Seed must be **body-global** (constant across the body) — the position
|
||||
/// `(wx, wy)` carries the variation. A per-voxel seed would make every voxel a
|
||||
/// fresh lattice (white noise, not smooth hills).
|
||||
pub fn voxel_relief(seed: u64, wx: f64, wy: f64, envelope: f64, ruggedness: f64) -> f64 {
|
||||
///
|
||||
/// `min_wavelength_m` — see [`terrain_detail`]; `0.0` = no cutoff.
|
||||
pub fn voxel_relief(
|
||||
seed: u64,
|
||||
wx: f64,
|
||||
wy: f64,
|
||||
envelope: f64,
|
||||
ruggedness: f64,
|
||||
min_wavelength_m: f64,
|
||||
) -> f64 {
|
||||
enveloped_fbm(
|
||||
seed,
|
||||
wx,
|
||||
@@ -99,6 +137,7 @@ pub fn voxel_relief(seed: u64, wx: f64, wy: f64, envelope: f64, ruggedness: f64)
|
||||
envelope,
|
||||
ruggedness,
|
||||
&VOXEL_OCTAVE_WAVELENGTHS_M,
|
||||
min_wavelength_m,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -134,6 +173,23 @@ pub fn voxel_mosaic(seed: u64, wx: f64, wy: f64) -> f64 {
|
||||
/// Shared adaptive-fBm core for [`terrain_detail`] and [`voxel_relief`] — the only
|
||||
/// difference between the two tiers is the octave wavelength band. Returns the
|
||||
/// enveloped, ruggedness-modulated perturbation (roughly `[-envelope, +envelope]`).
|
||||
///
|
||||
/// `min_wavelength_m` (T-1149): octaves with `wl < min_wavelength_m` are
|
||||
/// skipped — HARD TRUNCATE, not amplitude-faded. `amp`/`norm` still advance
|
||||
/// through the skipped octave's weight step (`i` keeps its position in the
|
||||
/// wavelength array for the seed-salt term), so the surviving octaves keep
|
||||
/// their same relative weighting as if the truncated tail were simply cut
|
||||
/// off the sum, not renormalized against a smaller octave count. `0.0` = no
|
||||
/// octave is ever skipped = today's behavior, byte-for-byte.
|
||||
///
|
||||
/// **Seam (not built, see T-1149/design doc §2 + §9 R1):** a hard truncate can
|
||||
/// pop when the sample density crosses an octave boundary between two
|
||||
/// requests (an octave present at one zoom step vanishes at the next,
|
||||
/// discontinuously). The documented fix is fading the highest surviving
|
||||
/// octave's amplitude toward zero as `wl` approaches `min_wavelength_m` from
|
||||
/// above, rather than an on/off cut. Not implemented here — it needs a
|
||||
/// visual A/B against a real client zoom ladder, which does not exist yet
|
||||
/// (T-1150/T-1153); building it speculatively risks tuning against nothing.
|
||||
fn enveloped_fbm(
|
||||
seed: u64,
|
||||
wx: f64,
|
||||
@@ -141,6 +197,7 @@ fn enveloped_fbm(
|
||||
envelope: f64,
|
||||
ruggedness: f64,
|
||||
wavelengths: &[f64],
|
||||
min_wavelength_m: f64,
|
||||
) -> f64 {
|
||||
let env = envelope.clamp(0.0, 1.0);
|
||||
let rug = ruggedness.clamp(0.0, 1.0);
|
||||
@@ -152,6 +209,14 @@ fn enveloped_fbm(
|
||||
let mut amp = 1.0;
|
||||
let mut norm = 0.0;
|
||||
for (i, &wl) in wavelengths.iter().enumerate() {
|
||||
if wl < min_wavelength_m {
|
||||
// Below the per-sample Nyquist cutoff — skip the term entirely
|
||||
// (hard truncate), but still advance amp so later octaves (there
|
||||
// are none finer in these const arrays, but the rule is general)
|
||||
// keep their intended relative weight.
|
||||
amp *= 0.5 + 0.35 * rug;
|
||||
continue;
|
||||
}
|
||||
let mut n = value_noise(
|
||||
seed.wrapping_add((i as u64).wrapping_mul(0x1000)),
|
||||
wx,
|
||||
@@ -170,6 +235,9 @@ fn enveloped_fbm(
|
||||
// them toward a single gentle swell.
|
||||
amp *= 0.5 + 0.35 * rug;
|
||||
}
|
||||
if norm == 0.0 {
|
||||
return 0.0; // every octave cut by the cutoff → no invented relief left
|
||||
}
|
||||
let fbm = sum / norm; // ≈ [-1, 1]
|
||||
|
||||
// Envelope caps the amplitude; within the cap, ruggedness scales how much of
|
||||
@@ -183,8 +251,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn deterministic() {
|
||||
let a = terrain_detail(42, 12_345.0, -6_789.0, 0.6, 0.5);
|
||||
let b = terrain_detail(42, 12_345.0, -6_789.0, 0.6, 0.5);
|
||||
let a = terrain_detail(42, 12_345.0, -6_789.0, 0.6, 0.5, 0.0);
|
||||
let b = terrain_detail(42, 12_345.0, -6_789.0, 0.6, 0.5, 0.0);
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
@@ -193,7 +261,7 @@ mod tests {
|
||||
// envelope = 0 → the authored heightmap is flat here → no relief (the
|
||||
// envelope rule: never sprout terrain on an authored plain).
|
||||
for &rug in &[0.0, 0.5, 1.0] {
|
||||
assert_eq!(terrain_detail(7, 1000.0, 2000.0, 0.0, rug), 0.0);
|
||||
assert_eq!(terrain_detail(7, 1000.0, 2000.0, 0.0, rug, 0.0), 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,7 +271,7 @@ mod tests {
|
||||
for i in 0..400 {
|
||||
let wx = (i as f64) * 137.0;
|
||||
let wy = (i as f64) * -91.0;
|
||||
let v = terrain_detail(99, wx, wy, 0.5, 1.0);
|
||||
let v = terrain_detail(99, wx, wy, 0.5, 1.0, 0.0);
|
||||
assert!(v.abs() <= 0.5 + 1e-9, "v={v} exceeded envelope at {i}");
|
||||
}
|
||||
}
|
||||
@@ -215,7 +283,7 @@ mod tests {
|
||||
let mean_abs = |rug: f64| -> f64 {
|
||||
let n = 500;
|
||||
(0..n)
|
||||
.map(|i| terrain_detail(3, i as f64 * 53.0, i as f64 * 71.0, 0.7, rug).abs())
|
||||
.map(|i| terrain_detail(3, i as f64 * 53.0, i as f64 * 71.0, 0.7, rug, 0.0).abs())
|
||||
.sum::<f64>()
|
||||
/ n as f64
|
||||
};
|
||||
@@ -229,8 +297,8 @@ mod tests {
|
||||
fn continuous_no_creases() {
|
||||
// Small position steps produce small output changes (C¹ value noise) — no
|
||||
// lattice creases that would read as grid artifacts.
|
||||
let base = terrain_detail(11, 5_000.0, 5_000.0, 0.8, 0.6);
|
||||
let near = terrain_detail(11, 5_000.5, 5_000.0, 0.8, 0.6);
|
||||
let base = terrain_detail(11, 5_000.0, 5_000.0, 0.8, 0.6, 0.0);
|
||||
let near = terrain_detail(11, 5_000.5, 5_000.0, 0.8, 0.6, 0.0);
|
||||
assert!(
|
||||
(base - near).abs() < 0.05,
|
||||
"0.5 m step jumped by {}",
|
||||
@@ -238,17 +306,59 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── min_wavelength_m cutoff (T-1149, zoom ladder §2) ──────────────────────
|
||||
|
||||
#[test]
|
||||
fn cutoff_zero_matches_pre_t1149_behavior() {
|
||||
// 0.0 = no cutoff = every octave — this is the compatibility contract
|
||||
// every existing caller (derive_district's default) relies on.
|
||||
for i in 0..200 {
|
||||
let wx = i as f64 * 91.0;
|
||||
let wy = i as f64 * -53.0;
|
||||
let with_explicit_zero = terrain_detail(21, wx, wy, 0.6, 0.5, 0.0);
|
||||
// The finest OCTAVE_WAVELENGTHS_M entry is 4_096.0 — a cutoff below
|
||||
// that admits every octave too, and must agree exactly.
|
||||
let with_below_finest = terrain_detail(21, wx, wy, 0.6, 0.5, 1.0);
|
||||
assert_eq!(with_explicit_zero, with_below_finest);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cutoff_truncates_octaves_below_it() {
|
||||
// A cutoff placed above the coarsest OCTAVE_WAVELENGTHS_M entry
|
||||
// (32_768.0) must skip every octave and fall back to 0.0 (the
|
||||
// norm==0.0 empty-sum guard), same as the flat_envelope_invents_nothing
|
||||
// envelope==0 case but reached via the cutoff instead.
|
||||
assert_eq!(
|
||||
terrain_detail(5, 1_000.0, 2_000.0, 0.6, 0.5, 100_000.0),
|
||||
0.0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cutoff_changes_output_relative_to_uncut() {
|
||||
// A mid-band cutoff (drops the two finest octaves: 8_192.0, 4_096.0)
|
||||
// must produce DIFFERENT output than the uncut derive at the same
|
||||
// position — otherwise the cutoff parameter would be a no-op.
|
||||
let uncut = terrain_detail(17, 12_000.0, 9_000.0, 0.7, 0.6, 0.0);
|
||||
let cut = terrain_detail(17, 12_000.0, 9_000.0, 0.7, 0.6, 8_193.0);
|
||||
assert_ne!(
|
||||
uncut, cut,
|
||||
"a mid-band cutoff must change the derived output"
|
||||
);
|
||||
}
|
||||
|
||||
// ── voxel_relief (T-1081): same contract, sub-district band ──────────────
|
||||
|
||||
#[test]
|
||||
fn voxel_relief_deterministic_and_bounded() {
|
||||
let a = voxel_relief(42, 12_345.0, -6_789.0, 0.6, 0.5);
|
||||
let b = voxel_relief(42, 12_345.0, -6_789.0, 0.6, 0.5);
|
||||
let a = voxel_relief(42, 12_345.0, -6_789.0, 0.6, 0.5, 0.0);
|
||||
let b = voxel_relief(42, 12_345.0, -6_789.0, 0.6, 0.5, 0.0);
|
||||
assert_eq!(a, b);
|
||||
// Same envelope rule + amplitude ceiling as terrain_detail.
|
||||
assert_eq!(voxel_relief(7, 1_000.0, 2_000.0, 0.0, 1.0), 0.0);
|
||||
assert_eq!(voxel_relief(7, 1_000.0, 2_000.0, 0.0, 1.0, 0.0), 0.0);
|
||||
for i in 0..400 {
|
||||
let v = voxel_relief(99, i as f64 * 137.0, i as f64 * -91.0, 0.5, 1.0);
|
||||
let v = voxel_relief(99, i as f64 * 137.0, i as f64 * -91.0, 0.5, 1.0, 0.0);
|
||||
assert!(v.abs() <= 0.5 + 1e-9, "v={v} exceeded envelope at {i}");
|
||||
}
|
||||
}
|
||||
@@ -261,7 +371,7 @@ mod tests {
|
||||
// are deliberately non-aligned with the octave wavelengths to avoid aliasing.
|
||||
let seed = 1234;
|
||||
let vals: Vec<f64> = (0..16)
|
||||
.map(|i| voxel_relief(seed, i as f64 * 137.0, i as f64 * 89.0, 0.7, 0.6))
|
||||
.map(|i| voxel_relief(seed, i as f64 * 137.0, i as f64 * 89.0, 0.7, 0.6, 0.0))
|
||||
.collect();
|
||||
let min = vals.iter().cloned().fold(f64::INFINITY, f64::min);
|
||||
let max = vals.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
|
||||
|
||||
@@ -1088,6 +1088,10 @@ struct InventedPrimitives {
|
||||
/// low-relief coasts); `ridge` carries fjord/tectonic sharpness.
|
||||
///
|
||||
/// `body_params` must already carry the district's `latitude_deg`.
|
||||
///
|
||||
/// `min_wavelength_m` (T-1149, zoom ladder §2): threaded straight to the
|
||||
/// `terrain_detail` scatter call — octaves finer than this are truncated.
|
||||
/// `0.0` = no cutoff = today's behavior.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn invent_primitives(
|
||||
seed: SeedChain,
|
||||
@@ -1099,6 +1103,7 @@ fn invent_primitives(
|
||||
world_x_m: f64,
|
||||
world_y_m: f64,
|
||||
region_baseline_c: Option<f32>,
|
||||
min_wavelength_m: f64,
|
||||
) -> InventedPrimitives {
|
||||
// ── 1. Driver tier: UNWARPED raw-bilinear climate (one-step-stale). ─────
|
||||
let raw_elev_q =
|
||||
@@ -1147,6 +1152,7 @@ fn invent_primitives(
|
||||
world_y_m,
|
||||
env_amp,
|
||||
ruggedness,
|
||||
min_wavelength_m,
|
||||
);
|
||||
|
||||
// Shoreline carving (T-1125): glacial / tectonically-young SHORES are cut
|
||||
@@ -1272,6 +1278,7 @@ pub fn derive_district_profile(
|
||||
world_x_m,
|
||||
world_y_m,
|
||||
region_baseline_c,
|
||||
0.0, // batch path — no octave cutoff, matches derive_district's default
|
||||
);
|
||||
|
||||
build_district_profile(
|
||||
@@ -1421,15 +1428,55 @@ pub fn derive_district(
|
||||
climate: &ClimateConstants,
|
||||
) -> DistrictProfile {
|
||||
let (dx, dy) = district_pos;
|
||||
let dm = scale::DISTRICT_M as f64;
|
||||
// Thin wrapper (T-1149): quantize DistrictPos -> world metres, then hand off
|
||||
// to the metres-addressable interior. `min_wavelength_m = 0.0` = no octave
|
||||
// cutoff, preserving this function's output byte-for-byte.
|
||||
derive_at_metres(
|
||||
seed,
|
||||
body_id,
|
||||
body_params,
|
||||
ta,
|
||||
dx as f64 * dm,
|
||||
dy as f64 * dm,
|
||||
climate,
|
||||
0.0,
|
||||
)
|
||||
}
|
||||
|
||||
// District → fractional heightmap pixel + world-metre coordinate + latitude.
|
||||
/// The metres-addressable derivation interior (T-1149, zoom ladder keystone,
|
||||
/// design doc §2/§8 step 1) — `derive_district`'s former inline body, extracted
|
||||
/// so a fractional-metres position (not just an integer [`DistrictPos`]) can be
|
||||
/// classified. This is what makes the quarter rung (512 m spacing, T-1150)
|
||||
/// possible without a second derivation pipeline: same function, finer step.
|
||||
///
|
||||
/// `wx`/`wy` are absolute world metres — NOT required to fall on a district-grid
|
||||
/// multiple of [`scale::DISTRICT_M`]; any fractional position is legal.
|
||||
///
|
||||
/// `min_wavelength_m` (§2): forwarded to the `terrain_detail` octave sum inside
|
||||
/// [`invent_primitives`] — octaves finer than this cutoff are truncated. `0.0`
|
||||
/// = no cutoff = [`derive_district`]'s existing behavior.
|
||||
///
|
||||
/// `body_id` is required for the D-243 §4 climate edge-fuzz warp domain separation.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn derive_at_metres(
|
||||
seed: SeedChain,
|
||||
body_id: &str,
|
||||
body_params: &BodyParams,
|
||||
ta: &TerrainAnalysis,
|
||||
wx: f64,
|
||||
wy: f64,
|
||||
climate: &ClimateConstants,
|
||||
min_wavelength_m: f64,
|
||||
) -> DistrictProfile {
|
||||
// World metres -> fractional heightmap pixel + latitude. Mirrors
|
||||
// `derive_district`'s former inline mapping exactly, just keyed on
|
||||
// fractional (wx, wy) instead of an integer DistrictPos scaled up first.
|
||||
let (px, py, world_x_m, world_y_m, lat_deg) = match body_params.body_radius_km {
|
||||
Some(r_km) if r_km > 0.0 => {
|
||||
let circumference_m = std::f64::consts::TAU * r_km * 1000.0;
|
||||
let meridian_m = std::f64::consts::PI * r_km * 1000.0;
|
||||
let wx = dx as f64 * scale::DISTRICT_M as f64;
|
||||
let wy = dy as f64 * scale::DISTRICT_M as f64;
|
||||
// Longitude wraps; district (0,0) sits at lon 0 / the equator.
|
||||
// Longitude wraps; (0,0) sits at lon 0 / the equator.
|
||||
let px = (wx / circumference_m).rem_euclid(1.0) * ta.w as f64;
|
||||
// Latitude: equator at py = h/2, clamped at the poles.
|
||||
let lat_frac = (wy / meridian_m).clamp(-0.5, 0.5); // −0.5 = N pole, +0.5 = S
|
||||
@@ -1437,15 +1484,17 @@ pub fn derive_district(
|
||||
(px, py, wx, wy, -lat_frac * 180.0)
|
||||
}
|
||||
_ => {
|
||||
// No radius: the district grid IS the heightmap grid (tiny test bodies).
|
||||
let px = (dx as f64).clamp(0.0, ta.w.saturating_sub(1) as f64);
|
||||
let py = (dy as f64).clamp(0.0, ta.h.saturating_sub(1) as f64);
|
||||
// No radius: the working grid IS the metre grid (tiny test bodies),
|
||||
// 1 DISTRICT_M = 1 heightmap pixel — the inverse of
|
||||
// `pixel_to_world_m`'s own no-radius convention.
|
||||
let dm = scale::DISTRICT_M as f64;
|
||||
let px = (wx / dm).clamp(0.0, ta.w.saturating_sub(1) as f64);
|
||||
let py = (wy / dm).clamp(0.0, ta.h.saturating_sub(1) as f64);
|
||||
let lat_deg = if ta.h > 1 {
|
||||
90.0 - (py / (ta.h - 1) as f64) * 180.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let dm = scale::DISTRICT_M as f64;
|
||||
(px, py, px * dm, py * dm, lat_deg)
|
||||
}
|
||||
};
|
||||
@@ -1456,11 +1505,24 @@ pub fn derive_district(
|
||||
};
|
||||
|
||||
// D-243 §3/§4: compute the edge-fuzz-blended region baseline on-the-fly for
|
||||
// this district. No pre-built cache here — the on-demand path derives the four
|
||||
// surrounding region baselines directly. Pure, deterministic, cheap.
|
||||
// this position. No pre-built cache here — the on-demand path derives the
|
||||
// four surrounding region baselines directly. Pure, deterministic, cheap.
|
||||
// `seed.seed()` (the body-scoped seed value) ensures body-unique warp separation.
|
||||
// Hoisted above the primitives (T-1125): the invention's driver tier needs
|
||||
// the baseline for its one-step-stale climate estimate.
|
||||
//
|
||||
// `region_baseline_at_district` keys on the CONTAINING DistrictPos (via
|
||||
// `rem_euclid` inside `region_profile.rs`), not on fractional metres — so a
|
||||
// sub-district sample (e.g. a quarter, T-1150) floor-divides down to its
|
||||
// containing district here. This is D-243's design intent (climate is a
|
||||
// district-tier field, R2/zoom-ladder-design-doc §9): temperature is a hard
|
||||
// step at every district boundary at every rung, by construction — it does
|
||||
// not refine continuously the way elevation/slope do under a finer
|
||||
// min_wavelength_m.
|
||||
let district_pos: DistrictPos = (
|
||||
(wx / scale::DISTRICT_M as f64).floor() as i32,
|
||||
(wy / scale::DISTRICT_M as f64).floor() as i32,
|
||||
);
|
||||
let region_baseline_c = region_profile::region_baseline_at_district(
|
||||
seed.seed(),
|
||||
body_id,
|
||||
@@ -1484,15 +1546,17 @@ pub fn derive_district(
|
||||
world_x_m,
|
||||
world_y_m,
|
||||
region_baseline_c,
|
||||
min_wavelength_m,
|
||||
);
|
||||
|
||||
// derive_district is the on-demand path (arbitrary DistrictPos, no L1 working
|
||||
// grid). basin_direction is an ACCEPTED LIMITATION here: it defaults to North
|
||||
// (a fallback, not a computed value) because the D8 thalweg is only available
|
||||
// from the L1 fdir grid the batch path holds. Production voxel generation runs
|
||||
// through the batch path (derive_all_districts), which threads the true D8
|
||||
// direction from L1; this on-demand path is the fallback for districts derived
|
||||
// outside that pass, where a meaningful basin_direction isn't available.
|
||||
// derive_at_metres is the on-demand path (arbitrary world position, no L1
|
||||
// working grid). basin_direction is an ACCEPTED LIMITATION here: it
|
||||
// defaults to North (a fallback, not a computed value) because the D8
|
||||
// thalweg is only available from the L1 fdir grid the batch path holds.
|
||||
// Production voxel generation runs through the batch path
|
||||
// (derive_all_districts), which threads the true D8 direction from L1;
|
||||
// this on-demand path is the fallback for positions derived outside that
|
||||
// pass, where a meaningful basin_direction isn't available.
|
||||
build_district_profile(
|
||||
seed,
|
||||
¶ms,
|
||||
@@ -1857,6 +1921,151 @@ mod tests {
|
||||
assert!((0..=100).contains(&a.elev_q) && (0..=100).contains(&a.slope_q));
|
||||
}
|
||||
|
||||
// --- derive_at_metres (T-1149 keystone extraction) -------------------------
|
||||
|
||||
/// `derive_district` is a thin wrapper: at an exact district-aligned metre
|
||||
/// position, with `min_wavelength_m = 0.0`, it must be BIT-IDENTICAL to
|
||||
/// calling `derive_at_metres` directly (the acceptance criterion the
|
||||
/// ticket names explicitly — existing callers see byte-identical output).
|
||||
#[test]
|
||||
fn derive_at_metres_matches_derive_district_at_aligned_position_zero_cutoff() {
|
||||
let hm = test_hm();
|
||||
let ta = test_ta(&hm);
|
||||
let climate = ClimateConstants::default();
|
||||
let p = earth_params();
|
||||
let dp = (1234, -567);
|
||||
let dm = scale::DISTRICT_M as f64;
|
||||
|
||||
let via_wrapper = derive_district(test_seed(), "test_body", &p, &ta, dp, &climate);
|
||||
let via_metres = derive_at_metres(
|
||||
test_seed(),
|
||||
"test_body",
|
||||
&p,
|
||||
&ta,
|
||||
dp.0 as f64 * dm,
|
||||
dp.1 as f64 * dm,
|
||||
&climate,
|
||||
0.0,
|
||||
);
|
||||
assert_district_profiles_eq(&via_wrapper, &via_metres);
|
||||
}
|
||||
|
||||
/// Same equivalence check on the no-radius (tiny test body) branch — the
|
||||
/// two derivation paths diverge internally (fractional-pixel clamp vs.
|
||||
/// direct district indexing) and must be checked independently.
|
||||
#[test]
|
||||
fn derive_at_metres_matches_derive_district_no_radius() {
|
||||
let hm = test_hm();
|
||||
let ta = test_ta(&hm);
|
||||
let climate = ClimateConstants::default();
|
||||
let p = BodyParams {
|
||||
planet_class: Some("temperate".into()),
|
||||
atmosphere: Some("breathable".into()),
|
||||
..Default::default() // body_radius_km: None
|
||||
};
|
||||
let dp = (20, 10);
|
||||
let dm = scale::DISTRICT_M as f64;
|
||||
|
||||
let via_wrapper = derive_district(test_seed(), "test_body", &p, &ta, dp, &climate);
|
||||
let via_metres = derive_at_metres(
|
||||
test_seed(),
|
||||
"test_body",
|
||||
&p,
|
||||
&ta,
|
||||
dp.0 as f64 * dm,
|
||||
dp.1 as f64 * dm,
|
||||
&climate,
|
||||
0.0,
|
||||
);
|
||||
assert_district_profiles_eq(&via_wrapper, &via_metres);
|
||||
}
|
||||
|
||||
/// Field-by-field `DistrictProfile` equality — the struct has no
|
||||
/// `PartialEq` derive (production type, not test-only), so the
|
||||
/// bit-identical acceptance checks above compare fields directly instead
|
||||
/// of adding a derive to non-test code for test convenience.
|
||||
fn assert_district_profiles_eq(a: &DistrictProfile, b: &DistrictProfile) {
|
||||
assert_eq!(a.morphology_zone as u8, b.morphology_zone as u8);
|
||||
assert_eq!(a.tectonic_class as u8, b.tectonic_class as u8);
|
||||
assert_eq!(a.glaciation_grade as u8, b.glaciation_grade as u8);
|
||||
assert_eq!(a.precipitation_class as u8, b.precipitation_class as u8);
|
||||
assert_eq!(a.slope_q, b.slope_q);
|
||||
assert_eq!(a.elev_q, b.elev_q);
|
||||
assert_eq!(a.ocean_fraction_q, b.ocean_fraction_q);
|
||||
assert_eq!(a.river_threshold, b.river_threshold);
|
||||
assert_eq!(a.temperature_c, b.temperature_c);
|
||||
assert_eq!(a.moisture_q, b.moisture_q);
|
||||
assert_eq!(a.vegetation_class as u8, b.vegetation_class as u8);
|
||||
assert_eq!(a.basin_direction as u8, b.basin_direction as u8);
|
||||
}
|
||||
|
||||
/// A non-district-aligned fractional metre position (e.g. a quarter-grid
|
||||
/// sample, T-1150) must derive without panicking and stay within the same
|
||||
/// value ranges as the district-aligned case — the whole point of the
|
||||
/// extraction is that ANY fractional world position is now legal input,
|
||||
/// not just integer DistrictPos multiples.
|
||||
#[test]
|
||||
fn derive_at_metres_accepts_fractional_sub_district_position() {
|
||||
let hm = test_hm();
|
||||
let ta = test_ta(&hm);
|
||||
let climate = ClimateConstants::default();
|
||||
let p = earth_params();
|
||||
let dm = scale::DISTRICT_M as f64;
|
||||
|
||||
// A quarter-grid offset (512 m, D-243) inside district (1234, -567).
|
||||
let prof = derive_at_metres(
|
||||
test_seed(),
|
||||
"test_body",
|
||||
&p,
|
||||
&ta,
|
||||
1234.0 * dm + 512.0,
|
||||
-567.0 * dm + 512.0,
|
||||
&climate,
|
||||
512.0,
|
||||
);
|
||||
assert!((0..=100).contains(&prof.elev_q));
|
||||
assert!((0..=100).contains(&prof.slope_q));
|
||||
}
|
||||
|
||||
/// A `min_wavelength_m` cutoff must actually change the invented terrain
|
||||
/// primitives relative to the uncut (0.0) derive at the SAME position —
|
||||
/// otherwise the parameter would be silently inert at this layer (the
|
||||
/// enveloped_fbm-level test already covers the raw scatter function; this
|
||||
/// confirms the wiring survives through invent_primitives/derive_at_metres).
|
||||
#[test]
|
||||
fn derive_at_metres_cutoff_changes_invented_primitives() {
|
||||
let hm = test_hm();
|
||||
let ta = test_ta(&hm);
|
||||
let climate = ClimateConstants::default();
|
||||
let p = earth_params();
|
||||
let dm = scale::DISTRICT_M as f64;
|
||||
|
||||
let mut any_differs = false;
|
||||
for i in 0..20 {
|
||||
let wx = (100 + i * 37) as f64 * dm;
|
||||
let wy = (100 + i * 53) as f64 * dm;
|
||||
let uncut = derive_at_metres(test_seed(), "test_body", &p, &ta, wx, wy, &climate, 0.0);
|
||||
let cut = derive_at_metres(
|
||||
test_seed(),
|
||||
"test_body",
|
||||
&p,
|
||||
&ta,
|
||||
wx,
|
||||
wy,
|
||||
&climate,
|
||||
8_193.0, // above the two finest OCTAVE_WAVELENGTHS_M entries
|
||||
);
|
||||
if uncut.elev_q != cut.elev_q || uncut.slope_q != cut.slope_q {
|
||||
any_differs = true;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
any_differs,
|
||||
"a mid-band min_wavelength_m cutoff must change invented terrain \
|
||||
at at least one sampled position"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_district_profile_is_deterministic() {
|
||||
let hm = test_hm();
|
||||
|
||||
+133
-13
@@ -203,10 +203,17 @@ pub enum GenWorkItem {
|
||||
/// reason `AnalyzeBody.body_params` is boxed.
|
||||
body_params: Box<BodyParams>,
|
||||
/// Window centre + side length in districts. `n` is ALREADY clamped to
|
||||
/// `[1, DISTRICT_WINDOW_MAX_N]` by the caller (`handle_atlas_request`)
|
||||
/// `[1, DISTRICT_WINDOW_MAX_N]` AND the granularity-aware
|
||||
/// `WIRE_CAP_CELLS` ceiling by the caller (`handle_atlas_request`)
|
||||
/// before this item is built — never trusted from the wire again here.
|
||||
center: DistrictPos,
|
||||
n: u32,
|
||||
/// Derivation granularity (T-1150) — `WINDOW_GRANULARITY_DISTRICT` (1)
|
||||
/// or `WINDOW_GRANULARITY_QUARTER` (4). Already resolved via
|
||||
/// `resolve_window_granularity` by the caller.
|
||||
granularity: u32,
|
||||
/// Octave cutoff in whole metres (T-1149/T-1150), `0` = no cutoff.
|
||||
min_wl_m: u32,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -218,14 +225,23 @@ impl GenWorkItem {
|
||||
}
|
||||
}
|
||||
|
||||
/// Coalescing key for `DeriveWindow` items only — `(connection, body)`.
|
||||
/// Coalescing key for `DeriveWindow` items only — `(connection, body,
|
||||
/// granularity)` (T-1150, design doc §3 [SOFT] recommendation, extending
|
||||
/// T-1137's `(connection, body)`). `granularity` is part of the key so an
|
||||
/// in-flight district-spacing (granularity 1) pan-burst is never
|
||||
/// superseded by an unrelated quarter-spacing (granularity 4) request for
|
||||
/// the same connection+body, and vice versa — the two rungs are separate
|
||||
/// in-flight derives, not competing updates to the same one.
|
||||
/// `None` for every other variant (they don't coalesce this way).
|
||||
pub fn window_supersede_key(&self) -> Option<(ConnectionId, &str)> {
|
||||
pub fn window_supersede_key(&self) -> Option<(ConnectionId, &str, u32)> {
|
||||
if let GenWorkItem::DeriveWindow {
|
||||
body_id, conn_id, ..
|
||||
body_id,
|
||||
conn_id,
|
||||
granularity,
|
||||
..
|
||||
} = self
|
||||
{
|
||||
Some((*conn_id, body_id))
|
||||
Some((*conn_id, body_id, *granularity))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -403,11 +419,15 @@ impl GenerationQueue {
|
||||
}
|
||||
|
||||
/// Submit a `DeriveWindow` item with per-connection coalescing (D-226
|
||||
/// T-1124 amendment §1, "recommended"): if a `DeriveWindow` item for the
|
||||
/// SAME `(connection, body)` is still sitting in the pending queue
|
||||
/// (not yet dispatched to a Rayon worker), it is replaced in place by the
|
||||
/// new one — a pan-burst that queues several window requests for the same
|
||||
/// connection+body before the first is dispatched collapses to one derive.
|
||||
/// T-1124 amendment §1, "recommended"; extended T-1150 to key on
|
||||
/// granularity too): if a `DeriveWindow` item for the SAME `(connection,
|
||||
/// body, granularity)` is still sitting in the pending queue (not yet
|
||||
/// dispatched to a Rayon worker), it is replaced in place by the new one
|
||||
/// — a pan-burst that queues several window requests for the same
|
||||
/// connection+body+granularity before the first is dispatched collapses
|
||||
/// to one derive. A district-spacing and quarter-spacing request for the
|
||||
/// same connection+body do NOT coalesce with each other — they're
|
||||
/// separate in-flight derives, not competing updates to the same rung.
|
||||
///
|
||||
/// Deliberately does **not** attempt to cancel an item already dispatched
|
||||
/// to a Rayon worker (no cancellation channel exists, and the amendment
|
||||
@@ -419,12 +439,12 @@ impl GenerationQueue {
|
||||
/// `window_supersede_key()` returns `None`).
|
||||
pub fn submit_window(&self, item: GenWorkItem, priority: GenPriority) {
|
||||
if let Some(key) = item.window_supersede_key() {
|
||||
let key = (key.0, key.1.to_string());
|
||||
let key = (key.0, key.1.to_string(), key.2);
|
||||
let mut pending = self.pending.lock().unwrap();
|
||||
pending.retain(|q| {
|
||||
q.item
|
||||
.window_supersede_key()
|
||||
.map(|k| (k.0, k.1.to_string()) != key)
|
||||
.map(|k| (k.0, k.1.to_string(), k.2) != key)
|
||||
.unwrap_or(true)
|
||||
});
|
||||
let pos = pending
|
||||
@@ -747,6 +767,8 @@ fn run_work_item(
|
||||
body_params,
|
||||
center,
|
||||
n,
|
||||
granularity,
|
||||
min_wl_m,
|
||||
} => match load_heightmap_png(heightmap_path, body_id, *sea_level) {
|
||||
Ok(hm) => {
|
||||
// Same GRID_W×GRID_H downsample AnalyzeBody applies (D-202) — the
|
||||
@@ -780,6 +802,8 @@ fn run_work_item(
|
||||
*center,
|
||||
*n,
|
||||
&climate,
|
||||
*granularity,
|
||||
*min_wl_m,
|
||||
);
|
||||
GenCompletion::WindowDerived {
|
||||
body_id: body_id.clone(),
|
||||
@@ -1120,8 +1144,26 @@ mod tests {
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
/// Build a `DeriveWindow` work item pointing at a tiny test heightmap,
|
||||
/// mirroring `analyze()`'s fixture shape.
|
||||
/// mirroring `analyze()`'s fixture shape. `granularity` defaults to
|
||||
/// district (matching every pre-T-1150 call site) via
|
||||
/// `derive_window_at()` below — extended (PR #191 review, Hoshe 2) so
|
||||
/// coalescing tests can exercise the granularity axis of
|
||||
/// `window_supersede_key()` without a second near-duplicate helper.
|
||||
fn derive_window(body_id: &str, conn_id: ConnectionId, center: DistrictPos) -> GenWorkItem {
|
||||
derive_window_at(
|
||||
body_id,
|
||||
conn_id,
|
||||
center,
|
||||
crate::atlas::layer_proxy::WINDOW_GRANULARITY_DISTRICT,
|
||||
)
|
||||
}
|
||||
|
||||
fn derive_window_at(
|
||||
body_id: &str,
|
||||
conn_id: ConnectionId,
|
||||
center: DistrictPos,
|
||||
granularity: u32,
|
||||
) -> GenWorkItem {
|
||||
GenWorkItem::DeriveWindow {
|
||||
body_id: body_id.to_string(),
|
||||
conn_id,
|
||||
@@ -1137,6 +1179,8 @@ mod tests {
|
||||
}),
|
||||
center,
|
||||
n: 4,
|
||||
granularity,
|
||||
min_wl_m: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1256,6 +1300,82 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// **PR #191 review, Hoshe 2 — zero coverage before this test.**
|
||||
/// `window_supersede_key()`'s doc claims district and quarter requests
|
||||
/// for the SAME `(connection, body)` are separate in-flight slots (the
|
||||
/// key is `(conn_id, body_id, granularity)`, not `(conn_id, body_id)`).
|
||||
/// Two submissions for the same connection+body but DIFFERENT
|
||||
/// granularity must NOT coalesce — both survive as independent pending
|
||||
/// items.
|
||||
#[test]
|
||||
fn submit_window_does_not_coalesce_different_granularity() {
|
||||
let q = GenerationQueue::with_threads(1);
|
||||
// See `submit_window_coalesces_same_connection_and_body`'s comment on
|
||||
// why the occupier must be `analyze()`, not `FillChunk`.
|
||||
q.submit(analyze("Occupier3"), GenPriority::Low);
|
||||
|
||||
let conn = ConnectionId(9);
|
||||
q.submit_window(
|
||||
derive_window_at(
|
||||
"GranBody",
|
||||
conn,
|
||||
(0, 0),
|
||||
crate::atlas::layer_proxy::WINDOW_GRANULARITY_DISTRICT,
|
||||
),
|
||||
GenPriority::Immediate,
|
||||
);
|
||||
q.submit_window(
|
||||
derive_window_at(
|
||||
"GranBody",
|
||||
conn,
|
||||
(0, 0),
|
||||
crate::atlas::layer_proxy::WINDOW_GRANULARITY_QUARTER,
|
||||
),
|
||||
GenPriority::Immediate,
|
||||
);
|
||||
assert_eq!(
|
||||
q.pending_count(),
|
||||
2,
|
||||
"same (connection, body) but DIFFERENT granularity must NOT coalesce — \
|
||||
district and quarter are separate in-flight slots"
|
||||
);
|
||||
}
|
||||
|
||||
/// The coalescing-DOES-happen counterpart to the test above: two
|
||||
/// submissions for the SAME `(connection, body, granularity)` still
|
||||
/// collapse to one pending item — confirms the granularity axis didn't
|
||||
/// accidentally loosen the existing same-key coalescing behavior.
|
||||
#[test]
|
||||
fn submit_window_coalesces_same_connection_body_and_granularity() {
|
||||
let q = GenerationQueue::with_threads(1);
|
||||
q.submit(analyze("Occupier4"), GenPriority::Low);
|
||||
|
||||
let conn = ConnectionId(11);
|
||||
q.submit_window(
|
||||
derive_window_at(
|
||||
"SameGranBody",
|
||||
conn,
|
||||
(0, 0),
|
||||
crate::atlas::layer_proxy::WINDOW_GRANULARITY_QUARTER,
|
||||
),
|
||||
GenPriority::Immediate,
|
||||
);
|
||||
q.submit_window(
|
||||
derive_window_at(
|
||||
"SameGranBody",
|
||||
conn,
|
||||
(5, 5),
|
||||
crate::atlas::layer_proxy::WINDOW_GRANULARITY_QUARTER,
|
||||
),
|
||||
GenPriority::Immediate,
|
||||
);
|
||||
assert_eq!(
|
||||
q.pending_count(),
|
||||
1,
|
||||
"same (connection, body, granularity) must still coalesce to one pending item"
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// TerrainAnalysisCache (T-1137, PR #187 review — Tyre C1)
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
+1081
-68
File diff suppressed because it is too large
Load Diff
@@ -413,7 +413,16 @@ fn drain_generation_completions(
|
||||
// NEXT poll (the existing D-225 re-request loop) hits
|
||||
// `handle_atlas_request`'s window branch, which finds this
|
||||
// entry via `DistrictWindowCache::get` and serves it.
|
||||
window_cache.insert((body_id, layer.center, layer.n), *layer);
|
||||
window_cache.insert(
|
||||
(
|
||||
body_id,
|
||||
layer.center,
|
||||
layer.n,
|
||||
layer.granularity,
|
||||
layer.min_wl_m,
|
||||
),
|
||||
*layer,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -904,6 +913,8 @@ mod tests {
|
||||
up_to: CascadeLayer::Topography,
|
||||
window_center: None,
|
||||
window_n: 0,
|
||||
window_granularity: 0,
|
||||
window_min_wl_m: 0,
|
||||
},
|
||||
)]));
|
||||
world.insert_resource(AtlasResponseBuffer::default());
|
||||
|
||||
@@ -511,6 +511,7 @@ pub fn derive_voxel_column(
|
||||
voxel_y as f64,
|
||||
signal,
|
||||
signal,
|
||||
0.0, // voxel fill path — no octave cutoff (T-1149's cutoff is Atlas-serving only)
|
||||
);
|
||||
let relief_m = (relief * VOXEL_RELIEF_SPAN_M as f64) as i32; // truncate (D-010)
|
||||
column.elevation_m = (column.elevation_m + relief_m).max(0);
|
||||
|
||||
@@ -1201,6 +1201,8 @@ mod inbound_tests {
|
||||
up_to: CascadeLayer::Topography,
|
||||
window_center: None,
|
||||
window_n: 0,
|
||||
window_granularity: 0,
|
||||
window_min_wl_m: 0,
|
||||
};
|
||||
let frame = rmp_serde::to_vec_named(&req).unwrap();
|
||||
assert!(
|
||||
@@ -1246,6 +1248,8 @@ mod inbound_tests {
|
||||
up_to: CascadeLayer::Topography,
|
||||
window_center: None,
|
||||
window_n: 0,
|
||||
window_granularity: 0,
|
||||
window_min_wl_m: 0,
|
||||
})
|
||||
.unwrap();
|
||||
let star_map_frame = rmp_serde::to_vec_named(&StarMapRequest { star_map: true }).unwrap();
|
||||
|
||||
@@ -371,6 +371,8 @@ fn single_tick_drains_all_ready_inbound_frames() {
|
||||
up_to: CascadeLayer::Topography,
|
||||
window_center: None,
|
||||
window_n: 0,
|
||||
window_granularity: 0,
|
||||
window_min_wl_m: 0,
|
||||
};
|
||||
let payload = rmp_serde::to_vec_named(&req).expect("failed to serialize");
|
||||
write_framed(&mut stream, &payload).expect("write atlas frame");
|
||||
|
||||
@@ -7,6 +7,7 @@ use settled_reach_server::atlas::layer_proxy::{
|
||||
AtlasLayerResponse, AtlasLayerStatus, DistrictWindowLayer, QuarterFootprintEntry,
|
||||
QuarterFootprintLayer, RegionGridLayer, RoadGraphEdge, RoadGraphLayer, RoadGraphNode,
|
||||
SettlementEntry, SettlementLayer, SettlementSizeClass, REGION_TEMP_NONE_DC,
|
||||
WINDOW_GRANULARITY_DISTRICT,
|
||||
};
|
||||
use settled_reach_server::atlas::region_profile::{SeasonPhase, WeatherState};
|
||||
use settled_reach_server::atlas::road_graph::RoadNodeKind;
|
||||
@@ -725,6 +726,8 @@ fn generate_atlas_layer_response_fixtures() {
|
||||
let window = DistrictWindowLayer {
|
||||
center: (10, -5),
|
||||
n: 2,
|
||||
granularity: WINDOW_GRANULARITY_DISTRICT,
|
||||
min_wl_m: 0,
|
||||
morphology: vec![0, 8, 14, 16], // OpenOcean, AlluvialPlain, Alpine, Wetland
|
||||
elev_q: vec![0, 45, 98, 60],
|
||||
temp_dc: vec![205, 150, REGION_TEMP_NONE_DC, 80], // 20.5°C, 15.0°C, airless sentinel, 8.0°C
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
//! Zoom-ladder derivation benchmarks (T-1149, design doc §2/§7/§8 step 1).
|
||||
//!
|
||||
//! Measures `derive_at_metres` per-cell cost at district spacing (2,048 m) and
|
||||
//! quarter spacing (512 m), with and without a `min_wavelength_m` octave
|
||||
//! cutoff — the exact numbers the design doc flags as UNBUILT/UNMEASURED
|
||||
//! (§7: "Octave-cutoff derive (min_wavelength_m-bearing) ... not measured").
|
||||
//!
|
||||
//! Manual `Instant`-based timing, matching every other bench in this repo
|
||||
//! (`shadowcast_bench.rs`, `perf_bench.rs`) and the same technique
|
||||
//! `aliveness_probe --render` used to produce the ~1.2–1.4 µs/district
|
||||
//! release figure the design doc cites — no criterion dependency exists here.
|
||||
//!
|
||||
//! Run: `cargo test --release --test zoom_ladder_bench -- --ignored --nocapture`
|
||||
//! (debug numbers are ~5x slower and not representative of the design doc's
|
||||
//! release-build figures; run `--release` for numbers worth recording).
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use settled_reach_server::atlas::district_profile::{
|
||||
derive_at_metres, BodyParams, ClimateConstants,
|
||||
};
|
||||
use settled_reach_server::atlas::drainage;
|
||||
use settled_reach_server::atlas::features::TerrainAnalysis;
|
||||
use settled_reach_server::atlas::heightmap::BodyHeightmap;
|
||||
use settled_reach_server::atlas::scale;
|
||||
use settled_reach_server::seed::{SeedChain, SeedDomain};
|
||||
|
||||
fn bench_hm() -> BodyHeightmap {
|
||||
// Same shape as district_profile.rs's own test_hm/window_test_hm fixtures
|
||||
// — a smooth gradient, deterministic, no PNG I/O.
|
||||
let (w, h) = (128u32, 64u32);
|
||||
let n = (w * h) as usize;
|
||||
let data = (0..n)
|
||||
.map(|i| {
|
||||
let r = (i / w as usize) as f32 / h as f32;
|
||||
let c = (i % w as usize) as f32 / w as f32;
|
||||
(r * 0.6 + c * 0.4).min(1.0)
|
||||
})
|
||||
.collect();
|
||||
BodyHeightmap {
|
||||
body_id: "bench".into(),
|
||||
width: w,
|
||||
height: h,
|
||||
data,
|
||||
sea_level: 0.3,
|
||||
}
|
||||
}
|
||||
|
||||
fn bench_ta(hm: &BodyHeightmap) -> TerrainAnalysis {
|
||||
let dr = drainage::analyze(&hm.data, hm.width, hm.height, hm.sea_level);
|
||||
TerrainAnalysis::analyze(hm, &dr)
|
||||
}
|
||||
|
||||
fn bench_params() -> BodyParams {
|
||||
BodyParams {
|
||||
hydrosphere: Some("ocean".into()),
|
||||
atmosphere: Some("breathable".into()),
|
||||
planet_class: Some("temperate".into()),
|
||||
body_radius_km: Some(6371.0),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Time `n_cells` sequential `derive_at_metres` calls on a spacing-`step_m`
|
||||
/// grid starting at world origin, with the given octave cutoff. Returns
|
||||
/// (total_elapsed, per_cell_ns).
|
||||
fn time_derive_sweep(
|
||||
seed: SeedChain,
|
||||
body_id: &str,
|
||||
params: &BodyParams,
|
||||
ta: &TerrainAnalysis,
|
||||
climate: &ClimateConstants,
|
||||
grid_side: u32,
|
||||
step_m: f64,
|
||||
min_wavelength_m: f64,
|
||||
) -> (std::time::Duration, f64) {
|
||||
let n_cells = (grid_side * grid_side) as u64;
|
||||
let t0 = Instant::now();
|
||||
for row in 0..grid_side {
|
||||
for col in 0..grid_side {
|
||||
let wx = col as f64 * step_m;
|
||||
let wy = row as f64 * step_m;
|
||||
let prof =
|
||||
derive_at_metres(seed, body_id, params, ta, wx, wy, climate, min_wavelength_m);
|
||||
// Prevent the optimizer from hoisting the call out of the loop.
|
||||
std::hint::black_box(prof.elev_q);
|
||||
}
|
||||
}
|
||||
let elapsed = t0.elapsed();
|
||||
let per_cell_ns = elapsed.as_secs_f64() * 1e9 / n_cells as f64;
|
||||
(elapsed, per_cell_ns)
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn bench_derive_at_metres_district_and_quarter_spacing() {
|
||||
let hm = bench_hm();
|
||||
let ta = bench_ta(&hm);
|
||||
let params = bench_params();
|
||||
let climate = ClimateConstants::default();
|
||||
let seed = SeedChain::root(99).derive(SeedDomain::Body, 1);
|
||||
let grid_side = 64u32; // 4,096 cells per sweep — matches the D-226 window cap
|
||||
|
||||
println!("\n=== T-1149 zoom-ladder derive_at_metres benchmark ===");
|
||||
println!(
|
||||
"grid: {grid_side}x{grid_side} = {} cells/sweep\n",
|
||||
grid_side * grid_side
|
||||
);
|
||||
|
||||
let district_m = scale::DISTRICT_M as f64;
|
||||
let quarter_m = scale::QUARTER_M as f64;
|
||||
|
||||
// District spacing (2,048 m), cutoff 0 — today's uncut behavior.
|
||||
let (elapsed, per_cell_ns) = time_derive_sweep(
|
||||
seed, "bench", ¶ms, &ta, &climate, grid_side, district_m, 0.0,
|
||||
);
|
||||
println!(
|
||||
"district spacing, cutoff=0: {:>8.2} ms total, {:>7.1} ns/cell ({:.3} µs/cell)",
|
||||
elapsed.as_secs_f64() * 1000.0,
|
||||
per_cell_ns,
|
||||
per_cell_ns / 1000.0
|
||||
);
|
||||
|
||||
// District spacing, cutoff 2,048 m — truncates every OCTAVE_WAVELENGTHS_M
|
||||
// entry below the district's own spacing (finest is 4,096 m, so this
|
||||
// cutoff is BELOW that — confirms the cutoff plumbing at district scale
|
||||
// without changing which octaves survive, since 2,048 < 4,096 admits all
|
||||
// of them; recorded for the design doc's requested (district, cutoff
|
||||
// 2048) combination regardless).
|
||||
let (elapsed, per_cell_ns) = time_derive_sweep(
|
||||
seed, "bench", ¶ms, &ta, &climate, grid_side, district_m, 2_048.0,
|
||||
);
|
||||
println!(
|
||||
"district spacing, cutoff=2048m: {:>8.2} ms total, {:>7.1} ns/cell ({:.3} µs/cell)",
|
||||
elapsed.as_secs_f64() * 1000.0,
|
||||
per_cell_ns,
|
||||
per_cell_ns / 1000.0
|
||||
);
|
||||
|
||||
// Quarter spacing (512 m), cutoff 512 m — the T-1150 Option B rung: full
|
||||
// reclassification at quarter spacing with the matching octave cutoff.
|
||||
let (elapsed, per_cell_ns) = time_derive_sweep(
|
||||
seed, "bench", ¶ms, &ta, &climate, grid_side, quarter_m, 512.0,
|
||||
);
|
||||
println!(
|
||||
"quarter spacing, cutoff=512m: {:>8.2} ms total, {:>7.1} ns/cell ({:.3} µs/cell)",
|
||||
elapsed.as_secs_f64() * 1000.0,
|
||||
per_cell_ns,
|
||||
per_cell_ns / 1000.0
|
||||
);
|
||||
|
||||
println!();
|
||||
}
|
||||
Reference in New Issue
Block a user