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:
@@ -258,6 +258,48 @@ pub enum GenWorkItem {
|
||||
/// special-cased away.
|
||||
min_wl_m: u32,
|
||||
},
|
||||
/// Derive a D-255(a) step canvas (T-1181, the tagged-envelope migration).
|
||||
///
|
||||
/// **Binding serving model — same as `DeriveWindow`:** step canvases ride
|
||||
/// this SAME Rayon queue, never inline on the `PreInput` drain (D-255(d):
|
||||
/// "never inline here"). Reuses the SAME `TerrainAnalysisCache` LRU
|
||||
/// `DeriveWindow` already populates — a step-canvas request and a legacy
|
||||
/// `district_window` request for the same body share one
|
||||
/// `run_layer1`-derived `(Layer1Output, TerrainAnalysis)` cache entry
|
||||
/// (keyed on `body_id` alone), so a player already panning the legacy
|
||||
/// window viewer and then opening the new stepped map (or vice versa)
|
||||
/// pays the ~45 ms `run_layer1` cost at most once per body, not once per
|
||||
/// carrier.
|
||||
DeriveStepCanvas {
|
||||
body_id: String,
|
||||
/// Coalescing/routing key — NOT used by `run_work_item` itself (the
|
||||
/// derive is connection-agnostic), only by
|
||||
/// `GenerationQueue::submit_step_canvas` to decide which still-pending
|
||||
/// item a new one for the same connection+body+rung supersedes.
|
||||
conn_id: ConnectionId,
|
||||
heightmap_path: PathBuf,
|
||||
sea_level: f32,
|
||||
body_seed: SeedChain,
|
||||
body_params: Box<BodyParams>,
|
||||
/// The body's placed settlements, pre-resolved at dispatch time from
|
||||
/// `BodyWorldState.placements` if already cached (empty otherwise —
|
||||
/// `settlement_id` coverage is simply all-zero until the body's own
|
||||
/// `AnalyzeBody` cascade has placed settlements; see
|
||||
/// `step_canvas::serve_step_canvas_request`'s doc).
|
||||
placements: Vec<crate::atlas::attractor_matching::CityPlacement>,
|
||||
/// The D-255(a) rung this canvas targets.
|
||||
rung: crate::atlas::step_canvas::StepCanvasRung,
|
||||
/// World-metre centre (ignored for `StepCanvasRung::Global`, whose
|
||||
/// canvas is whole-body/origin-anchored).
|
||||
center: (i64, i64),
|
||||
/// Canvas pixel budget for every FIXED rung (ignored for `Global`,
|
||||
/// whose extent is the body's own region grid).
|
||||
extent: (u32, u32),
|
||||
/// Octave cutoff in whole metres, already quantized by the caller
|
||||
/// (`step_canvas::quantize_min_wl_m_for_rung`) — never a raw wire
|
||||
/// value.
|
||||
min_wl_m: u32,
|
||||
},
|
||||
}
|
||||
|
||||
impl GenWorkItem {
|
||||
@@ -293,6 +335,29 @@ impl GenWorkItem {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Coalescing key for `DeriveStepCanvas` items only — `(connection, body,
|
||||
/// rung)` (T-1181, mirroring `window_supersede_key`'s exact reasoning):
|
||||
/// a pan-burst that queues several step-canvas requests for the same
|
||||
/// connection+body+rung before the first is dispatched collapses to one
|
||||
/// derive. A different rung for the same connection+body does NOT
|
||||
/// coalesce — every rung is a separate in-flight derive, not a competing
|
||||
/// update to the same one. `None` for every other variant.
|
||||
pub fn step_canvas_supersede_key(
|
||||
&self,
|
||||
) -> Option<(ConnectionId, &str, crate::atlas::step_canvas::StepCanvasRung)> {
|
||||
if let GenWorkItem::DeriveStepCanvas {
|
||||
body_id,
|
||||
conn_id,
|
||||
rung,
|
||||
..
|
||||
} = self
|
||||
{
|
||||
Some((*conn_id, body_id, *rung))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -352,6 +417,22 @@ pub enum GenCompletion {
|
||||
/// boxing rationale).
|
||||
layer: Box<DistrictWindowLayer>,
|
||||
},
|
||||
/// A D-255(a) step canvas finished deriving (T-1181). The main thread
|
||||
/// inserts `canvas` into `GlobalTierCache` (rung `Global`) or
|
||||
/// `StepCanvasCache` (every other rung), keyed as
|
||||
/// `serve_step_canvas_request` documents — NOT pushed into any in-flight
|
||||
/// response, same re-poll-and-hit-cache model as `WindowDerived`.
|
||||
StepCanvasDerived {
|
||||
body_id: String,
|
||||
rung: crate::atlas::step_canvas::StepCanvasRung,
|
||||
center: (i64, i64),
|
||||
extent: (u32, u32),
|
||||
min_wl_m: u32,
|
||||
/// Boxed to keep `GenCompletion` variant sizes balanced — an
|
||||
/// `EncodedStepCanvas` carries eight PNG/msgpack-encoded fields,
|
||||
/// comparable to `WindowDerived`'s own boxing rationale.
|
||||
canvas: Box<crate::atlas::step_canvas::EncodedStepCanvas>,
|
||||
},
|
||||
/// Work item failed — body_id or city_id for logging.
|
||||
Failed { item: GenWorkItem, reason: String },
|
||||
}
|
||||
@@ -523,6 +604,36 @@ impl GenerationQueue {
|
||||
}
|
||||
}
|
||||
|
||||
/// Submit a `DeriveStepCanvas` item with per-connection-per-rung
|
||||
/// coalescing (T-1181, mirroring `submit_window`'s exact discipline): if
|
||||
/// an item for the SAME `(connection, body, rung)` is still pending
|
||||
/// (not yet dispatched), it is replaced in place — a step-cross burst
|
||||
/// that queues several requests for the same connection+body+rung before
|
||||
/// the first is dispatched collapses to one derive. `item` MUST be a
|
||||
/// `DeriveStepCanvas` variant; any other variant falls through to plain
|
||||
/// `submit` with no coalescing.
|
||||
pub fn submit_step_canvas(&self, item: GenWorkItem, priority: GenPriority) {
|
||||
if let Some(key) = item.step_canvas_supersede_key() {
|
||||
let key = (key.0, key.1.to_string(), key.2);
|
||||
let mut pending = self.pending.lock().unwrap();
|
||||
pending.retain(|q| {
|
||||
q.item
|
||||
.step_canvas_supersede_key()
|
||||
.map(|k| (k.0, k.1.to_string(), k.2) != key)
|
||||
.unwrap_or(true)
|
||||
});
|
||||
let pos = pending
|
||||
.iter()
|
||||
.position(|q| q.priority > priority)
|
||||
.unwrap_or(pending.len());
|
||||
pending.insert(pos, QueuedWork { priority, item });
|
||||
drop(pending);
|
||||
self.dispatch_next();
|
||||
} else {
|
||||
self.submit(item, priority);
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain all completed items from the channel and dispatch pending work.
|
||||
///
|
||||
/// Call once per tick from the main thread. Returns all completions
|
||||
@@ -988,6 +1099,66 @@ fn run_work_item(
|
||||
reason: format!("heightmap load failed: {e}"),
|
||||
},
|
||||
},
|
||||
GenWorkItem::DeriveStepCanvas {
|
||||
body_id,
|
||||
conn_id: _, // routing-only (queue-level coalescing); the derive itself is connection-agnostic
|
||||
heightmap_path,
|
||||
sea_level,
|
||||
body_seed,
|
||||
body_params,
|
||||
placements,
|
||||
rung,
|
||||
center,
|
||||
extent,
|
||||
min_wl_m,
|
||||
} => match load_heightmap_png(heightmap_path, body_id, *sea_level) {
|
||||
Ok(hm) => {
|
||||
// Same GRID_W×GRID_H downsample AnalyzeBody/DeriveWindow apply
|
||||
// (D-202) — the true DistrictPos<->world-metres mapping must
|
||||
// stay consistent across every derive path.
|
||||
let working = if hm.width > GRID_W || hm.height > GRID_H {
|
||||
hm.downsample(GRID_W, GRID_H)
|
||||
} else {
|
||||
hm
|
||||
};
|
||||
// Same per-body TerrainAnalysisCache LRU DeriveWindow already
|
||||
// populates (T-1137 binding decision, extended here) — keyed
|
||||
// on body_id alone, so a step-canvas request and a legacy
|
||||
// district_window request for the same body share one
|
||||
// ~45 ms run_layer1 re-derive, never pay it twice.
|
||||
let (l1, ta) = terrain_cache
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get_or_derive(body_id, &working);
|
||||
let climate = ClimateConstants::default();
|
||||
let raw = crate::atlas::step_canvas::build_step_canvas(
|
||||
*body_seed,
|
||||
body_id,
|
||||
body_params,
|
||||
&ta,
|
||||
&l1.river_network,
|
||||
placements,
|
||||
*rung,
|
||||
*center,
|
||||
*extent,
|
||||
&climate,
|
||||
*min_wl_m,
|
||||
);
|
||||
let canvas = crate::atlas::step_canvas::encode_step_canvas(&raw);
|
||||
GenCompletion::StepCanvasDerived {
|
||||
body_id: body_id.clone(),
|
||||
rung: *rung,
|
||||
center: *center,
|
||||
extent: *extent,
|
||||
min_wl_m: *min_wl_m,
|
||||
canvas: Box::new(canvas),
|
||||
}
|
||||
}
|
||||
Err(e) => GenCompletion::Failed {
|
||||
item: item.clone(),
|
||||
reason: format!("heightmap load failed: {e}"),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ use crate::simulation::generator::{AttractorType, DistrictType, MaintenanceAutho
|
||||
|
||||
/// Fallback sea level when the heightmap PNG carries no `sea_level` tEXt chunk
|
||||
/// (the loader prefers the chunk; this is only the floor).
|
||||
const DEFAULT_SEA_LEVEL: f32 = 0.3;
|
||||
pub(crate) const DEFAULT_SEA_LEVEL: f32 = 0.3;
|
||||
|
||||
/// Hard server-side clamp on [`AtlasLayerRequest::window_n`] (D-226 T-1124
|
||||
/// amendment §4, binding numbers). 64×64 districts ≈ 131 km per side — the
|
||||
@@ -484,7 +484,7 @@ const _: () =
|
||||
/// to the coarser/lower band, i.e. `<=` on the running best distance) keeps
|
||||
/// the mapping total and deterministic for any `u32` input, including values
|
||||
/// far outside the octave range (e.g. `u32::MAX` snaps to the coarsest band).
|
||||
fn quantize_min_wl_m(raw: u32) -> u32 {
|
||||
pub(crate) fn quantize_min_wl_m(raw: u32) -> u32 {
|
||||
let raw_f = raw as f64;
|
||||
let mut best = MIN_WL_BANDS_M[0];
|
||||
let mut best_dist = (raw_f - best).abs();
|
||||
|
||||
@@ -35,6 +35,7 @@ pub mod scale;
|
||||
pub mod shell;
|
||||
pub mod skeleton_gen;
|
||||
pub mod source_resolver;
|
||||
pub mod step_canvas;
|
||||
pub mod subbiome;
|
||||
pub mod tile_condition;
|
||||
pub mod trait_catalog_reader;
|
||||
|
||||
+101
-1
@@ -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 {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ use super::{decode_inbound, BridgeError, Inbound, ObserverSnapshot, SimBridge};
|
||||
use crate::atlas::atlas_data_proxy::{CityNamesResponse, StarMapResponse};
|
||||
use crate::atlas::browse_proxy::BrowseResponse;
|
||||
use crate::atlas::layer_proxy::AtlasLayerResponse;
|
||||
use crate::atlas::step_canvas::StepCanvasResponse;
|
||||
use crate::bridge::framing::{read_framed, write_framed};
|
||||
use std::fs;
|
||||
use std::io::{BufReader, BufWriter};
|
||||
@@ -177,6 +178,16 @@ impl SimBridge for LocalBridge {
|
||||
write_framed(writer.get_mut(), &payload)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn send_step_canvas_response(&self, resp: &StepCanvasResponse) -> Result<(), BridgeError> {
|
||||
let payload = rmp_serde::to_vec_named(resp)?;
|
||||
let mut writer = self
|
||||
.writer
|
||||
.lock()
|
||||
.map_err(|e| BridgeError::MutexPoisoned(format!("writer: {}", e)))?;
|
||||
write_framed(writer.get_mut(), &payload)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for LocalBridge {
|
||||
|
||||
+106
-19
@@ -11,6 +11,7 @@ use crate::atlas::atlas_data_proxy::{
|
||||
};
|
||||
use crate::atlas::browse_proxy::{BrowseRequest, BrowseResponse};
|
||||
use crate::atlas::layer_proxy::{AtlasLayerRequest, AtlasLayerResponse};
|
||||
use crate::atlas::step_canvas::{StepCanvasRequest, StepCanvasResponse};
|
||||
use crate::bridge::tcp::TcpBridge;
|
||||
|
||||
pub mod debug;
|
||||
@@ -64,15 +65,22 @@ pub enum BridgeError {
|
||||
/// frames that carry more than one shape's discriminators outright (PR #176
|
||||
/// review H1). `AtlasLayerRequest` itself is untouched byte-for-byte.
|
||||
///
|
||||
/// **Ceiling (D-225 trajectory):** [`BrowseRequest`] (T-1131) is the FIFTH
|
||||
/// map shape and, per the ceiling this doc already called at four, the last
|
||||
/// one this hand-rolled scheme should ever carry — it stays at five only
|
||||
/// because six entity kinds x two forms were folded into ONE new shape
|
||||
/// (`browse`'s own internal `kind`/`query` enums pick the sub-behavior,
|
||||
/// exactly as `AtlasLayerRequest.up_to: CascadeLayer` already does) rather
|
||||
/// than added as twelve more top-level shapes. The next genuinely NEW
|
||||
/// inbound shape (a sixth) must migrate the channel to the tagged-envelope
|
||||
/// framing D-225 deferred — do not add a sixth probe.
|
||||
/// **Ceiling (D-225 trajectory, DISCHARGED by D-255(c)):** [`BrowseRequest`]
|
||||
/// (T-1131) was the FIFTH map shape and, per the ceiling this doc used to
|
||||
/// call at four, the last one this hand-rolled scheme was meant to carry.
|
||||
/// [`StepCanvasRequest`] (T-1181) is that predicted SIXTH shape — the
|
||||
/// step-canvas payload is genuinely new (D-255(c): 21×-563× the legacy
|
||||
/// `district_window` carrier's ~30 KB reference, a cell-count gap no
|
||||
/// encoding closes), so per this doc's own prior instruction it extends the
|
||||
/// SAME tagged-marker pattern one more time (`step_canvas: bool`) rather
|
||||
/// than inventing a parallel envelope format — this IS the tagged-envelope
|
||||
/// migration D-225's 2026-06-12 amendment deferred, now executed. The
|
||||
/// pattern generalizes cleanly because every one of the five prior shapes
|
||||
/// already carries (or, for `AtlasLayerRequest`, is disambiguated against)
|
||||
/// exactly this kind of required marker field — there is no natural ceiling
|
||||
/// on the DEMUX mechanism itself, only a discipline reminder that a new
|
||||
/// shape should justify why it can't ride an existing one (as
|
||||
/// `StepCanvasRequest` does, D-255(c)).
|
||||
#[derive(Debug)]
|
||||
pub enum Inbound {
|
||||
/// A batch of player inputs (the gameplay path).
|
||||
@@ -86,6 +94,9 @@ pub enum Inbound {
|
||||
/// A data-browser request — one of the six D-254 §4 v1 entity kinds
|
||||
/// (T-1131).
|
||||
BrowseRequest(BrowseRequest),
|
||||
/// A D-255(a) step-canvas data-canvas request (T-1181, the D-225
|
||||
/// tagged-envelope migration, executed).
|
||||
StepCanvasRequest(StepCanvasRequest),
|
||||
}
|
||||
|
||||
/// Key-presence probe for the defensive multi-shape check in
|
||||
@@ -99,13 +110,15 @@ struct ShapeProbe {
|
||||
star_map: Option<serde::de::IgnoredAny>,
|
||||
city_names: Option<serde::de::IgnoredAny>,
|
||||
browse: Option<serde::de::IgnoredAny>,
|
||||
step_canvas: Option<serde::de::IgnoredAny>,
|
||||
}
|
||||
|
||||
/// Demux a received frame payload into an [`Inbound`] (D-225, T-949, T-1131).
|
||||
/// Tries, in order: `Vec<PlayerInput>` (array) → `AtlasLayerRequest` (map,
|
||||
/// `body_id`+`up_to`) → `StarMapRequest` (map, `star_map` discriminator) →
|
||||
/// `CityNamesRequest` (map, `city_names` discriminator + `body_id`) →
|
||||
/// `BrowseRequest` (map, `browse` discriminator).
|
||||
/// Demux a received frame payload into an [`Inbound`] (D-225, T-949, T-1131,
|
||||
/// T-1181). Tries, in order: `Vec<PlayerInput>` (array) → `AtlasLayerRequest`
|
||||
/// (map, `body_id`+`up_to`) → `StarMapRequest` (map, `star_map`
|
||||
/// discriminator) → `CityNamesRequest` (map, `city_names` discriminator +
|
||||
/// `body_id`) → `BrowseRequest` (map, `browse` discriminator) →
|
||||
/// `StepCanvasRequest` (map, `step_canvas` discriminator).
|
||||
///
|
||||
/// Mutual exclusivity is enforced, not assumed: no minimal well-formed
|
||||
/// instance of one shape satisfies another (see the [`Inbound`] doc), and a
|
||||
@@ -113,7 +126,7 @@ struct ShapeProbe {
|
||||
/// more than one shape — e.g. a buggy encoder emitting
|
||||
/// `{"star_map": true, "city_names": true, ...}` — instead of silently
|
||||
/// routing it to whichever shape is tried first (PR #176 review H1). A frame
|
||||
/// satisfying none of the five shapes is a genuinely malformed input frame.
|
||||
/// satisfying none of the six shapes is a genuinely malformed input frame.
|
||||
pub fn decode_inbound(payload: &[u8]) -> Result<Inbound, BridgeError> {
|
||||
if let Ok(inputs) = rmp_serde::from_slice::<Vec<PlayerInput>>(payload) {
|
||||
return Ok(Inbound::Inputs(inputs));
|
||||
@@ -127,19 +140,22 @@ pub fn decode_inbound(payload: &[u8]) -> Result<Inbound, BridgeError> {
|
||||
let star_map = probe.star_map.is_some();
|
||||
let city_names = probe.city_names.is_some();
|
||||
let browse = probe.browse.is_some();
|
||||
let step_canvas = probe.step_canvas.is_some();
|
||||
let shapes = usize::from(atlas)
|
||||
+ usize::from(star_map)
|
||||
+ usize::from(city_names)
|
||||
+ usize::from(browse);
|
||||
+ usize::from(browse)
|
||||
+ usize::from(step_canvas);
|
||||
if shapes > 1 {
|
||||
let dump_len = payload.len().min(256);
|
||||
tracing::error!(
|
||||
"inbound frame matches {} request shapes at once (atlas={}, star_map={}, city_names={}, browse={}) — rejecting ambiguous frame. Raw ({} of {} bytes): {:02x?}",
|
||||
"inbound frame matches {} request shapes at once (atlas={}, star_map={}, city_names={}, browse={}, step_canvas={}) — rejecting ambiguous frame. Raw ({} of {} bytes): {:02x?}",
|
||||
shapes,
|
||||
atlas,
|
||||
star_map,
|
||||
city_names,
|
||||
browse,
|
||||
step_canvas,
|
||||
dump_len,
|
||||
payload.len(),
|
||||
&payload[..dump_len]
|
||||
@@ -159,8 +175,11 @@ pub fn decode_inbound(payload: &[u8]) -> Result<Inbound, BridgeError> {
|
||||
if let Ok(req) = rmp_serde::from_slice::<CityNamesRequest>(payload) {
|
||||
return Ok(Inbound::CityNamesRequest(req));
|
||||
}
|
||||
match rmp_serde::from_slice::<BrowseRequest>(payload) {
|
||||
Ok(req) => Ok(Inbound::BrowseRequest(req)),
|
||||
if let Ok(req) = rmp_serde::from_slice::<BrowseRequest>(payload) {
|
||||
return Ok(Inbound::BrowseRequest(req));
|
||||
}
|
||||
match rmp_serde::from_slice::<StepCanvasRequest>(payload) {
|
||||
Ok(req) => Ok(Inbound::StepCanvasRequest(req)),
|
||||
Err(e) => {
|
||||
let dump_len = payload.len().min(256);
|
||||
tracing::error!(
|
||||
@@ -212,6 +231,9 @@ pub trait SimBridge: Send + Sync {
|
||||
|
||||
/// Send a browse response to the client (T-1131).
|
||||
fn send_browse_response(&self, resp: &BrowseResponse) -> Result<(), BridgeError>;
|
||||
|
||||
/// Send a step-canvas response to the client (T-1181, D-255(c)).
|
||||
fn send_step_canvas_response(&self, resp: &StepCanvasResponse) -> Result<(), BridgeError>;
|
||||
}
|
||||
|
||||
/// Identifies one connection for response-tagging and role-lookup purposes
|
||||
@@ -477,6 +499,27 @@ impl BridgeResource {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a step-canvas response to exactly the connection that requested
|
||||
/// it (T-1181 — same per-connection routing D-254 §2 established for
|
||||
/// atlas/star-map/city-names/browse).
|
||||
pub fn send_step_canvas_response_to(
|
||||
&self,
|
||||
id: ConnectionId,
|
||||
resp: &StepCanvasResponse,
|
||||
) -> Result<(), BridgeError> {
|
||||
match self.connection(id) {
|
||||
Some(c) => c.bridge.send_step_canvas_response(resp),
|
||||
None => {
|
||||
tracing::debug!(
|
||||
"step canvas response for {} dropped — connection {:?} no longer present",
|
||||
resp.body_id,
|
||||
id
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tracks whether the protocol handshake has been sent (#555).
|
||||
@@ -529,6 +572,7 @@ pub fn receive_bridge_inputs(
|
||||
mut star_map_requests: ResMut<StarMapRequestBuffer>,
|
||||
mut city_names_requests: ResMut<CityNamesRequestBuffer>,
|
||||
mut browse_requests: ResMut<BrowseRequestBuffer>,
|
||||
mut step_canvas_requests: ResMut<StepCanvasRequestBuffer>,
|
||||
time: Option<Res<crate::simulation::time::SimulationTime>>,
|
||||
) {
|
||||
let Some(mut bridge) = bridge else { return };
|
||||
@@ -574,6 +618,9 @@ pub fn receive_bridge_inputs(
|
||||
Ok(Some(Inbound::BrowseRequest(req))) => {
|
||||
browse_requests.0.push((player_id, req));
|
||||
}
|
||||
Ok(Some(Inbound::StepCanvasRequest(req))) => {
|
||||
step_canvas_requests.0.push((player_id, req));
|
||||
}
|
||||
// No complete frame ready — the backlog is drained.
|
||||
Ok(None) => break,
|
||||
Err(BridgeError::Disconnected) => {
|
||||
@@ -688,6 +735,9 @@ pub fn receive_bridge_inputs(
|
||||
Ok(Some(Inbound::BrowseRequest(req))) => {
|
||||
browse_requests.0.push((reader_id, req));
|
||||
}
|
||||
Ok(Some(Inbound::StepCanvasRequest(req))) => {
|
||||
step_canvas_requests.0.push((reader_id, req));
|
||||
}
|
||||
Ok(None) => break,
|
||||
Err(BridgeError::Disconnected) => {
|
||||
tracing::info!("Reader connection {:?} disconnected", reader_id);
|
||||
@@ -932,6 +982,37 @@ pub fn send_browse_responses(
|
||||
}
|
||||
}
|
||||
|
||||
/// Inbound step-canvas requests routed off the bridge (T-1181), drained by
|
||||
/// the proxy serve system in `PreInput`. Connection-tagged (D-254 §2).
|
||||
#[derive(Resource, Default)]
|
||||
pub struct StepCanvasRequestBuffer(pub Vec<(ConnectionId, StepCanvasRequest)>);
|
||||
|
||||
/// Outbound step-canvas responses, filled by the proxy serve system and
|
||||
/// flushed to the client in `PostSnapshot` (T-1181). Connection-tagged
|
||||
/// (D-254 §2).
|
||||
#[derive(Resource, Default)]
|
||||
pub struct StepCanvasResponseBuffer(pub Vec<(ConnectionId, StepCanvasResponse)>);
|
||||
|
||||
/// Flush buffered step-canvas responses to their requesting connections
|
||||
/// (T-1181 — same per-connection routing D-254 §2 established for
|
||||
/// atlas/star-map/city-names/browse). A failed send is logged but not fatal.
|
||||
pub fn send_step_canvas_responses(
|
||||
bridge: Option<Res<BridgeResource>>,
|
||||
mut buffer: ResMut<StepCanvasResponseBuffer>,
|
||||
) {
|
||||
let Some(bridge) = bridge else { return };
|
||||
for (id, resp) in buffer.0.drain(..) {
|
||||
if let Err(e) = bridge.send_step_canvas_response_to(id, &resp) {
|
||||
tracing::warn!(
|
||||
"failed to send step canvas response for {} to {:?}: {}",
|
||||
resp.body_id,
|
||||
id,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Holds the server's TCP listener for accepting connections AFTER the
|
||||
/// first Player connection (D-254 §2, T-1130).
|
||||
///
|
||||
@@ -1091,6 +1172,8 @@ impl Plugin for BridgePlugin {
|
||||
.init_resource::<CityNamesResponseBuffer>()
|
||||
.init_resource::<BrowseRequestBuffer>()
|
||||
.init_resource::<BrowseResponseBuffer>()
|
||||
.init_resource::<StepCanvasRequestBuffer>()
|
||||
.init_resource::<StepCanvasResponseBuffer>()
|
||||
.init_resource::<ConnectionListener>()
|
||||
.init_resource::<PendingConnections>()
|
||||
// Multi-connection accept-loop (D-254 §2, T-1130) — must run
|
||||
@@ -1119,6 +1202,10 @@ impl Plugin for BridgePlugin {
|
||||
Update,
|
||||
send_browse_responses.in_set(TickPhase::PostSnapshot),
|
||||
)
|
||||
.add_systems(
|
||||
Update,
|
||||
send_step_canvas_responses.in_set(TickPhase::PostSnapshot),
|
||||
)
|
||||
// Debug commands — Snapshot phase
|
||||
.add_systems(
|
||||
Update,
|
||||
|
||||
@@ -7,6 +7,7 @@ use super::{decode_inbound, BridgeError, Inbound, ObserverSnapshot, SimBridge};
|
||||
use crate::atlas::atlas_data_proxy::{CityNamesResponse, StarMapResponse};
|
||||
use crate::atlas::browse_proxy::BrowseResponse;
|
||||
use crate::atlas::layer_proxy::AtlasLayerResponse;
|
||||
use crate::atlas::step_canvas::StepCanvasResponse;
|
||||
use crate::bridge::framing::{read_framed, write_framed, FrameAccumulator};
|
||||
use std::io::BufWriter;
|
||||
use std::net::{SocketAddr, TcpListener, TcpStream};
|
||||
@@ -328,6 +329,20 @@ impl SimBridge for TcpBridge {
|
||||
result?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn send_step_canvas_response(&self, resp: &StepCanvasResponse) -> Result<(), BridgeError> {
|
||||
let payload = rmp_serde::to_vec_named(resp)?;
|
||||
let mut writer = self
|
||||
.writer
|
||||
.lock()
|
||||
.map_err(|e| BridgeError::MutexPoisoned(format!("writer: {}", e)))?;
|
||||
let stream = writer.get_mut();
|
||||
stream.set_nonblocking(false).map_err(BridgeError::Io)?;
|
||||
let result = write_framed(stream, &payload);
|
||||
stream.set_nonblocking(true).map_err(BridgeError::Io)?;
|
||||
result?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A connection that has been TCP-accepted but has not yet completed the
|
||||
|
||||
Reference in New Issue
Block a user