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
+11
View File
@@ -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
View File
@@ -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,
+15
View File
@@ -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