feat(simulation): step-canvas serving — tagged envelope (D-255, T-1181)

The D-225-discharging wire migration per D-255(c): a new
server/src/atlas/step_canvas.rs subsystem (six-rung StepCanvasRung
ladder — Global variable-extent rung 0 through Chunk 64m — the
StepCanvasRequest/StepCanvasResponse tagged envelope extending the
proven ShapeProbe discriminated-shape pattern, PNG-per-field dense +
MessagePack-native sparse encoding per the T-1179 measured table, and
both server cache tiers: the structurally keep-always GlobalTierCache
and the dual-axis StepCanvasCache with storage TTLs per rung and
SIM_STATE_TTL clock-bucket staleness per the D-227 amendment (1)
formula). Wired through bridge/{mod,tcp,local}.rs (sixth demux shape,
send_step_canvas_response mirroring the five existing senders),
gen_queue.rs (DeriveStepCanvas work item with per-connection-per-rung
coalescing, reusing the shared TerrainAnalysisCache), and plugin.rs
(serve/complete systems, lazy D-206 rung-0 population).

Acceptance gate (mandatory per D-227 amendment (3)):
tests/step_canvas_acceptance_gate.rs — cache-hit == cache-miss
byte-identical for every rung, lossless encode round-trips, cache
round-trips vs fresh derive, distinct-center sanity. 5/5 pass.

Station-spacing cap ADOPTED: course stations floored to District
spacing (2,048 m) at finer rungs — the S2-measured +38-87% chunk/block
course cost had zero display benefit at the same station density
(COURSE_STATION_SPACING_FLOOR_M).

Documented honest gaps, not shortcuts: settlement_id is a proximity
approximation (no footprint polygons exist yet); glaciation/flooded_q
sim-state planes are wire-shape-ready D-253 stubs; rung-0 uses signed
equator-anchored rows (the canonical D-256 core convention — T-1186's
wrong-latitude behavior applies unchanged and unfixed here, by
instruction).

Legacy district_window carrier byte-unchanged: window_derivation_golden
6/6 byte-identical, all district_window suites pass unmodified. Full
suite at implementation time: 2127 passed across 45 binaries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 03:13:08 +02:00
co-authored by Claude Fable 5
parent 5151c9010f
commit cd2f342421
12 changed files with 2699 additions and 23 deletions
+101 -1
View File
@@ -36,6 +36,10 @@ use crate::atlas::road_graph::{RoadGraph, RoadNode};
use crate::atlas::scale;
use crate::atlas::skeleton_gen::derive_complexity;
use crate::atlas::source_resolver::BodySourceResolverResource;
use crate::atlas::step_canvas::{
serve_step_canvas_request, GlobalTierCache, StepCanvasCache, StepCanvasResponse,
StepCanvasStatus, STEP_CANVAS_CACHE_CAPACITY,
};
use crate::atlas::trait_catalog_reader::{
ExteriorCatalog, TraitBias, TraitCatalogReaderResource, TraitTemplate,
};
@@ -49,6 +53,7 @@ use crate::atlas::trait_swerve::{
use crate::bridge::{
AtlasRequestBuffer, AtlasResponseBuffer, BrowseRequestBuffer, BrowseResponseBuffer,
CityNamesRequestBuffer, CityNamesResponseBuffer, StarMapRequestBuffer, StarMapResponseBuffer,
StepCanvasRequestBuffer, StepCanvasResponseBuffer,
};
use crate::seed::{SeedChain, SeedDomain};
use crate::simulation::generator::{
@@ -66,6 +71,8 @@ impl Plugin for GenerationPlugin {
app.insert_resource(GenerationQueue::new())
.insert_resource(BodyWorldStateCache::new(CACHE_CAPACITY))
.insert_resource(DistrictWindowCache::new(DISTRICT_WINDOW_CACHE_CAPACITY))
.insert_resource(GlobalTierCache::new())
.insert_resource(StepCanvasCache::new(STEP_CANVAS_CACHE_CAPACITY))
.add_systems(
Update,
drain_generation_completions.in_set(TickPhase::PreInput),
@@ -76,7 +83,11 @@ impl Plugin for GenerationPlugin {
Update,
serve_city_names_requests.in_set(TickPhase::PreInput),
)
.add_systems(Update, serve_browse_requests.in_set(TickPhase::PreInput));
.add_systems(Update, serve_browse_requests.in_set(TickPhase::PreInput))
.add_systems(
Update,
serve_step_canvas_requests.in_set(TickPhase::PreInput),
);
}
}
@@ -218,6 +229,68 @@ pub fn serve_browse_requests(
}
}
/// Drain inbound step-canvas requests and serve each through the proxy
/// (T-1181, D-255(c)/(d)): Global rung → the always-keep `GlobalTierCache`;
/// every fixed rung → the dual-axis-evicted `StepCanvasCache`. Cache hit →
/// Ready, miss → resolve + enqueue (`GenWorkItem::DeriveStepCanvas`) +
/// Pending — same D-225 poll/cache/enqueue model `serve_atlas_requests`
/// already uses for `district_window`.
fn serve_step_canvas_requests(
mut requests: ResMut<StepCanvasRequestBuffer>,
mut responses: ResMut<StepCanvasResponseBuffer>,
mut global_cache: ResMut<GlobalTierCache>,
mut canvas_cache: ResMut<StepCanvasCache>,
body_state_cache: Res<BodyWorldStateCache>,
queue: Res<GenerationQueue>,
resolver: Option<Res<BodySourceResolverResource>>,
body_params_reader: Option<Res<BodyParamsReaderResource>>,
rng: Option<Res<SimRng>>,
time: Option<Res<SimulationTime>>,
) {
if requests.0.is_empty() {
return;
}
let world_seed = rng.as_ref().map(|r| r.seed()).unwrap_or(0);
let tick = time.as_ref().map(|t| t.tick).unwrap_or(0);
let params_reader = body_params_reader.as_ref().map(|r| &r.0);
let pending: Vec<_> = requests.0.drain(..).collect();
for (conn_id, req) in pending {
// Read-only lookup (peek, no LRU bump — this proxy is not the
// canonical "this body was visited" signal, serve_atlas_requests'
// own cache.get already owns that) for settlement_id coverage
// (step_canvas::serve_step_canvas_request's doc). Empty when the
// body isn't cached yet or has no placements — settlement_id then
// reads all-zero on the derived canvas, not an error.
let placements: &[crate::atlas::attractor_matching::CityPlacement] = body_state_cache
.peek(&req.body_id)
.map(|s| s.placements.as_slice())
.unwrap_or(&[]);
let resp = match resolver.as_ref() {
Some(r) => serve_step_canvas_request(
&req,
&mut global_cache.as_mut(),
&mut canvas_cache.as_mut(),
&queue,
&r.0,
params_reader,
placements,
world_seed,
tick,
conn_id,
),
None => StepCanvasResponse {
body_id: req.body_id.clone(),
rung: req.rung,
center: req.center,
min_wl_m: req.min_wl_m,
status: StepCanvasStatus::Error("no body source resolver".to_string()),
canvas: None,
},
};
responses.0.push((conn_id, resp));
}
}
/// Drain finished background work each tick and apply it to the cache (D-206).
///
/// Runs in `PreInput` (off the Rayon workers, on the main thread): a cheap
@@ -226,10 +299,13 @@ fn drain_generation_completions(
queue: Res<GenerationQueue>,
mut cache: ResMut<BodyWorldStateCache>,
mut window_cache: ResMut<DistrictWindowCache>,
mut global_tier_cache: ResMut<GlobalTierCache>,
mut step_canvas_cache: ResMut<StepCanvasCache>,
city_reader: Option<Res<CityContextReaderResource>>,
trait_catalog: Option<Res<TraitCatalogReaderResource>>,
body_params_reader: Option<Res<BodyParamsReaderResource>>,
rng: Option<Res<SimRng>>,
time: Option<Res<SimulationTime>>,
) {
for completion in queue.drain_completions() {
match completion {
@@ -470,6 +546,28 @@ fn drain_generation_completions(
*layer,
);
}
GenCompletion::StepCanvasDerived {
body_id,
rung,
center,
extent,
min_wl_m,
canvas,
} => {
// T-1181, D-255(d): cache the completed canvas — NOT pushed
// into any in-flight response (same re-poll-and-hit-cache
// model WindowDerived above uses). Global (rung 0) goes to
// the always-keep GlobalTierCache; every fixed rung goes to
// the dual-axis-evicted StepCanvasCache.
if rung.is_global() {
global_tier_cache.as_mut().insert(body_id, *canvas);
} else {
let tick = time.as_ref().map(|t| t.tick).unwrap_or(0);
step_canvas_cache
.as_mut()
.insert((body_id, rung, center, extent, min_wl_m), *canvas, tick);
}
}
}
}
}
@@ -926,6 +1024,8 @@ mod tests {
world.insert_resource(GenerationQueue::new());
world.insert_resource(BodyWorldStateCache::new(CACHE_CAPACITY));
world.insert_resource(DistrictWindowCache::new(DISTRICT_WINDOW_CACHE_CAPACITY));
world.insert_resource(GlobalTierCache::new());
world.insert_resource(StepCanvasCache::new(STEP_CANVAS_CACHE_CAPACITY));
world.resource::<GenerationQueue>().submit(
GenWorkItem::AnalyzeBody {