roxlap_scene/snapshot.rs
1//! Serde-friendly snapshot of a [`Scene`].
2//!
3//! The live [`Scene`] holds [`Vxl`] chunks with allocator state
4//! ([`Vxl::vbit`] / [`Vxl::vbiti`]) that doesn't round-trip
5//! cleanly through serde — the post-edit slab pool is interior
6//! mutable and changes shape under voxalloc-driven scatter.
7//! [`SceneSnapshot`] is a flattened view: per-chunk bytes
8//! produced by [`roxlap_formats::vxl::serialize`], plus the grid
9//! transforms / ids the runtime tracks. It's a value type with
10//! plain serde derives — bincode / postcard / json / cbor all
11//! work.
12//!
13//! Use [`Scene::to_snapshot`] / [`Scene::from_snapshot`] to round
14//! trip. The deserialised scene is editable: each chunk goes back
15//! through [`Vxl::reserve_edit_capacity`] so subsequent
16//! `Grid::set_*` calls don't panic.
17//!
18//! [`Scene`]: crate::Scene
19//! [`Vxl`]: roxlap_formats::vxl::Vxl
20//! [`Vxl::vbit`]: roxlap_formats::vxl::Vxl::vbit
21//! [`Vxl::vbiti`]: roxlap_formats::vxl::Vxl::vbiti
22//! [`Vxl::reserve_edit_capacity`]: roxlap_formats::vxl::Vxl::reserve_edit_capacity
23
24use glam::IVec3;
25use roxlap_formats::vxl::{self, ParseError, Vxl};
26use serde::{Deserialize, Serialize};
27
28use crate::{Grid, GridId, GridTransform, LodThresholds, Scene, StreamRadius};
29
30/// Re-encode a [`Vxl`]'s mip-0 columns into a contiguous bytes
31/// blob that round-trips through [`vxl::parse`].
32///
33/// [`vxl::serialize`] writes the live `vxl.data` array verbatim,
34/// which breaks the round-trip after edits: post-`voxalloc`
35/// scatter, columns may live in the edit pool past the
36/// originally-contiguous prefix, and `vxl::parse` walks columns
37/// linearly from offset 0. This helper builds a temporary
38/// contiguous [`Vxl`] (column index order) and serialises that —
39/// the result is a layout `vxl::parse` accepts even after
40/// arbitrary `set_voxel` / `set_rect` / `set_sphere` edits.
41///
42/// Mip-1+ data isn't preserved (the renderer rebuilds it on
43/// demand). Snapshots are pre-mip; the receiver calls
44/// [`Vxl::generate_mips`] if it wants them.
45fn compact_serialize_chunk(vxl: &Vxl) -> Vec<u8> {
46 let n_cols = (vxl.vsid as usize) * (vxl.vsid as usize);
47 let mut data: Vec<u8> = Vec::new();
48 let mut column_offset: Vec<u32> = Vec::with_capacity(n_cols + 1);
49 for i in 0..n_cols {
50 column_offset.push(u32::try_from(data.len()).expect("offset fits in u32"));
51 data.extend_from_slice(vxl.column_data(i));
52 }
53 column_offset.push(u32::try_from(data.len()).expect("offset fits in u32"));
54
55 let compact = Vxl {
56 vsid: vxl.vsid,
57 ipo: vxl.ipo,
58 ist: vxl.ist,
59 ihe: vxl.ihe,
60 ifo: vxl.ifo,
61 data: data.into_boxed_slice(),
62 column_offset: column_offset.into_boxed_slice(),
63 mip_base_offsets: Box::new([0, n_cols + 1]),
64 vbit: Box::new([]),
65 vbiti: 0,
66 };
67 vxl::serialize(&compact)
68}
69
70/// Bytes of edit-pool headroom re-applied per chunk during
71/// [`Scene::from_snapshot`]. Matches the value chunk creation uses
72/// in [`crate::chunks`] so a snapshot round-trip leaves chunks
73/// equally edit-ready as freshly-created ones.
74const RESTORE_EDIT_HEADROOM_PER_COLUMN: usize = 256;
75
76/// Top-level scene snapshot — full state needed to reconstruct a
77/// [`Scene`] via [`Scene::from_snapshot`].
78///
79/// Grids serialised as a `Vec<(GridId, GridSnapshot)>` rather than
80/// a `HashMap` so the wire form is independent of `HashMap`'s
81/// non-deterministic iteration order — the same scene snapshot
82/// twice in a row produces byte-identical output.
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct SceneSnapshot {
85 /// Next id [`Scene::add_grid`] will hand out. Preserved across
86 /// snapshot round-trips so removed-id non-reuse holds.
87 pub next_grid_id: u32,
88 /// All registered grids paired with their ids.
89 pub grids: Vec<(GridId, GridSnapshot)>,
90}
91
92/// One grid's snapshot: transform + flattened chunks + the grid's
93/// runtime configuration (QE.5b — pre-QE.5 snapshots restored every
94/// config field to its default, so a loaded save silently lost
95/// `render_sky` / LOD / streaming settings).
96///
97/// The `generator` and `store` hooks are host code and cannot be
98/// serialised — rebind them after [`Scene::load_snapshot`], keyed on
99/// [`name`](Self::name).
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct GridSnapshot {
102 /// The grid's world placement (position + rotation).
103 pub transform: GridTransform,
104 /// Chunks as `(chunk_idx, vxl_bytes)`. `vxl_bytes` is
105 /// [`roxlap_formats::vxl::serialize`] output — re-parseable
106 /// via [`roxlap_formats::vxl::parse`].
107 pub chunks: Vec<(IVec3, Vec<u8>)>,
108 /// S7.2: per-chunk edit version counters, sorted by chunk
109 /// index. Chunks absent from this list restore as version 0 —
110 /// the same as a freshly generated or pre-S7.2 snapshot.
111 /// (`#[serde(default)]` covers self-describing formats; the
112 /// wire-format compatibility story is the QE.5b envelope
113 /// version in [`Scene::save_snapshot`].)
114 #[serde(default)]
115 pub chunk_versions: Vec<(IVec3, u64)>,
116 /// QE.5b — the grid's host-assigned rebind tag ([`crate::Grid::name`]).
117 #[serde(default)]
118 pub name: Option<String>,
119 /// QE.5b — [`crate::Grid::render_sky`].
120 #[serde(default = "default_render_sky")]
121 pub render_sky: bool,
122 /// QE.5b — [`crate::Grid::mip_levels_override`].
123 #[serde(default)]
124 pub mip_levels_override: Option<u32>,
125 /// QE.5b — [`crate::Grid::lod_thresholds`].
126 #[serde(default = "LodThresholds::always_near")]
127 pub lod_thresholds: LodThresholds,
128 /// QE.5b — [`crate::Grid::stream_radius`].
129 #[serde(default)]
130 pub stream_radius: StreamRadius,
131 /// SC.snap (v2) — the grid's [`GridTransform::voxel_world_size`]. Stored
132 /// as a sibling field (not inside the transform, whose wire form stays
133 /// frozen with `#[serde(skip)]`). The `#[serde(default)]` covers
134 /// self-describing formats; the bincode positional gap between v1 and v2
135 /// is handled by the private `GridSnapshotV1` shadow shape + the version
136 /// dispatch in [`Scene::load_snapshot`]. v1 blobs restore this at `1.0`.
137 #[serde(default = "one_f64")]
138 pub voxel_world_size: f64,
139 /// WT.0 (v3) — the grid's [`crate::WaterVolume`]s. Same
140 /// trailing-field discipline as `voxel_world_size`: v1/v2 blobs
141 /// decode through their frozen shadow shapes and restore with no
142 /// water.
143 #[serde(default)]
144 pub water_volumes: Vec<crate::WaterVolume>,
145}
146
147/// `voxel_world_size`'s default (`1.0`) for `#[serde(default)]` on
148/// self-describing formats — an unscaled grid.
149fn one_f64() -> f64 {
150 1.0
151}
152
153/// SC.snap — the **frozen v1** wire shape of [`SceneSnapshot`], used only to
154/// deserialize version-1 blobs (which predate the persisted scale). bincode
155/// is positional, so this must mirror v1's fields exactly — it has NO
156/// `voxel_world_size` (grids restore at `1.0` via [`From`]).
157#[derive(Deserialize)]
158struct SceneSnapshotV1 {
159 next_grid_id: u32,
160 grids: Vec<(GridId, GridSnapshotV1)>,
161}
162
163/// SC.snap — the frozen v1 [`GridSnapshot`] shape (no `voxel_world_size`).
164/// The field list + `#[serde(default)]`s must byte-match what v1
165/// `save_snapshot` wrote (the checked-in fixture is the gate).
166#[derive(Deserialize)]
167struct GridSnapshotV1 {
168 transform: GridTransform,
169 chunks: Vec<(IVec3, Vec<u8>)>,
170 #[serde(default)]
171 chunk_versions: Vec<(IVec3, u64)>,
172 #[serde(default)]
173 name: Option<String>,
174 #[serde(default = "default_render_sky")]
175 render_sky: bool,
176 #[serde(default)]
177 mip_levels_override: Option<u32>,
178 #[serde(default = "LodThresholds::always_near")]
179 lod_thresholds: LodThresholds,
180 #[serde(default)]
181 stream_radius: StreamRadius,
182}
183
184impl From<SceneSnapshotV1> for SceneSnapshot {
185 fn from(v1: SceneSnapshotV1) -> Self {
186 Self {
187 next_grid_id: v1.next_grid_id,
188 grids: v1
189 .grids
190 .into_iter()
191 // Chain through the frozen v2 shape (see the
192 // GridSnapshotV1 → GridSnapshotV2 impl).
193 .map(|(id, g)| (id, GridSnapshotV2::from(g).into()))
194 .collect(),
195 }
196 }
197}
198
199impl From<GridSnapshotV1> for GridSnapshotV2 {
200 // FROZEN (WT.0): v1 → v2 adds only the persisted scale. Version
201 // bumps chain shadow shapes (V1 → V2 → … → live), so old links
202 // like this one never change again — only the final
203 // shadow-to-live impl is rewritten per bump.
204 fn from(g: GridSnapshotV1) -> Self {
205 Self {
206 transform: g.transform,
207 chunks: g.chunks,
208 chunk_versions: g.chunk_versions,
209 name: g.name,
210 render_sky: g.render_sky,
211 mip_levels_override: g.mip_levels_override,
212 lod_thresholds: g.lod_thresholds,
213 stream_radius: g.stream_radius,
214 // v1 predates persisted scale — an unscaled grid.
215 voxel_world_size: 1.0,
216 }
217 }
218}
219
220/// WT.0 — the **frozen v2** wire shape of [`SceneSnapshot`], used only to
221/// deserialize version-2 blobs (which predate water volumes). Same
222/// positional-bincode rationale as [`SceneSnapshotV1`].
223#[derive(Deserialize)]
224struct SceneSnapshotV2 {
225 next_grid_id: u32,
226 grids: Vec<(GridId, GridSnapshotV2)>,
227}
228
229/// WT.0 — the frozen v2 [`GridSnapshot`] shape (no `water_volumes`).
230/// The field list must byte-match what v2 `save_snapshot` wrote (the
231/// checked-in v2 fixture is the gate).
232#[derive(Deserialize)]
233struct GridSnapshotV2 {
234 transform: GridTransform,
235 chunks: Vec<(IVec3, Vec<u8>)>,
236 #[serde(default)]
237 chunk_versions: Vec<(IVec3, u64)>,
238 #[serde(default)]
239 name: Option<String>,
240 #[serde(default = "default_render_sky")]
241 render_sky: bool,
242 #[serde(default)]
243 mip_levels_override: Option<u32>,
244 #[serde(default = "LodThresholds::always_near")]
245 lod_thresholds: LodThresholds,
246 #[serde(default)]
247 stream_radius: StreamRadius,
248 #[serde(default = "one_f64")]
249 voxel_world_size: f64,
250}
251
252impl From<SceneSnapshotV2> for SceneSnapshot {
253 fn from(v2: SceneSnapshotV2) -> Self {
254 Self {
255 next_grid_id: v2.next_grid_id,
256 grids: v2.grids.into_iter().map(|(id, g)| (id, g.into())).collect(),
257 }
258 }
259}
260
261impl From<GridSnapshotV2> for GridSnapshot {
262 fn from(g: GridSnapshotV2) -> Self {
263 Self {
264 transform: g.transform,
265 chunks: g.chunks,
266 chunk_versions: g.chunk_versions,
267 name: g.name,
268 render_sky: g.render_sky,
269 mip_levels_override: g.mip_levels_override,
270 lod_thresholds: g.lod_thresholds,
271 stream_radius: g.stream_radius,
272 voxel_world_size: g.voxel_world_size,
273 // v2 predates water volumes — a dry grid.
274 water_volumes: Vec::new(),
275 }
276 }
277}
278
279/// `render_sky`'s [`crate::Grid::new`] default, for
280/// `#[serde(default)]` on self-describing formats.
281fn default_render_sky() -> bool {
282 true
283}
284
285/// Errors from [`Scene::from_snapshot`]. Wraps the per-chunk
286/// [`ParseError`] with a tag identifying which grid + chunk
287/// failed.
288#[derive(Debug)]
289pub enum FromSnapshotError {
290 /// One chunk's bytes failed to round-trip through
291 /// [`roxlap_formats::vxl::parse`].
292 ChunkParse {
293 /// The grid whose chunk failed to parse.
294 grid: GridId,
295 /// The failing chunk's index within `grid`.
296 chunk: IVec3,
297 /// The underlying `.vxl` parse failure.
298 source: ParseError,
299 },
300}
301
302impl std::fmt::Display for FromSnapshotError {
303 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
304 match self {
305 Self::ChunkParse {
306 grid,
307 chunk,
308 source,
309 } => {
310 write!(
311 f,
312 "scene snapshot: grid {} chunk {chunk:?} parse failed: {source:?}",
313 grid.raw()
314 )
315 }
316 }
317 }
318}
319
320impl std::error::Error for FromSnapshotError {}
321
322impl Scene {
323 /// Capture the scene's full state as a serde-friendly value.
324 /// Each chunk is encoded via
325 /// [`roxlap_formats::vxl::serialize`]; the rest is plain field
326 /// data.
327 ///
328 /// Grid iteration order in the produced snapshot is sorted by
329 /// [`GridId`] so two snapshots of the same scene produce
330 /// byte-identical output (the live `HashMap` iteration order
331 /// would be non-deterministic).
332 #[must_use]
333 pub fn to_snapshot(&self) -> SceneSnapshot {
334 let mut grid_ids: Vec<GridId> = self.grids.keys().copied().collect();
335 grid_ids.sort_unstable();
336
337 let mut grids = Vec::with_capacity(grid_ids.len());
338 for id in grid_ids {
339 let grid = &self.grids[&id];
340 let mut chunk_addrs: Vec<IVec3> = grid.chunks.keys().copied().collect();
341 chunk_addrs.sort_unstable_by_key(|a| (a.x, a.y, a.z));
342 let chunks = chunk_addrs
343 .into_iter()
344 .map(|addr| (addr, compact_serialize_chunk(&grid.chunks[&addr])))
345 .collect();
346 // S7.2: emit chunk_versions sorted by chunk idx so the
347 // snapshot bytes stay deterministic (HashMap iter order
348 // is not). Zero entries are dropped on the assumption
349 // "missing == 0" — the snapshot stays compact for grids
350 // whose live counters are dense in 1+.
351 let mut version_addrs: Vec<IVec3> = grid
352 .chunk_versions
353 .iter()
354 .filter_map(|(a, v)| if *v != 0 { Some(*a) } else { None })
355 .collect();
356 version_addrs.sort_unstable_by_key(|a| (a.x, a.y, a.z));
357 let chunk_versions = version_addrs
358 .into_iter()
359 .map(|addr| (addr, grid.chunk_versions[&addr]))
360 .collect();
361 grids.push((
362 id,
363 GridSnapshot {
364 transform: grid.transform,
365 chunks,
366 chunk_versions,
367 name: grid.name.clone(),
368 render_sky: grid.render_sky,
369 mip_levels_override: grid.mip_levels_override,
370 lod_thresholds: grid.lod_thresholds,
371 stream_radius: grid.stream_radius,
372 // SC.snap — persist the grid's scale (transform's own wire
373 // form omits it via #[serde(skip)]).
374 voxel_world_size: grid.transform.voxel_world_size,
375 // WT.0 (v3) — persist the water volumes verbatim
376 // (host-authored Vec order is already deterministic).
377 water_volumes: grid.water_volumes.clone(),
378 },
379 ));
380 }
381 SceneSnapshot {
382 next_grid_id: self.next_grid_id,
383 grids,
384 }
385 }
386
387 /// Restore a [`Scene`] from a snapshot. Each chunk's bytes are
388 /// re-parsed via [`roxlap_formats::vxl::parse`] and re-armed
389 /// for edits via [`roxlap_formats::vxl::Vxl::reserve_edit_capacity`].
390 ///
391 /// # Errors
392 ///
393 /// Returns [`FromSnapshotError::ChunkParse`] tagged with the
394 /// owning grid + chunk index if any chunk's bytes fail to
395 /// parse. The partial scene is dropped — restoration is
396 /// all-or-nothing.
397 pub fn from_snapshot(snap: &SceneSnapshot) -> Result<Self, FromSnapshotError> {
398 let mut scene = Self::new();
399 scene.next_grid_id = snap.next_grid_id;
400 for (id, gsnap) in &snap.grids {
401 // SC.snap — `transform`'s wire form omits `voxel_world_size`
402 // (`#[serde(skip)]`); the persisted value rides in the sibling
403 // `gsnap.voxel_world_size`. Fold it back into the transform.
404 // Guard untrusted bytes to a sane range: not just ≤0 / non-finite
405 // (which break the marcher — GPU `chunk_dim = 0` → NaN) but also
406 // subnormal-near-zero (1e-300 > 0 yet `vsid · 1e-300 ≈ 0`) and
407 // absurdly-huge finite values. `[1e-6, 1e6]` spans any sane
408 // planet↔grain ratio with margin; out-of-range → fall back to 1.0.
409 let mut transform = gsnap.transform;
410 let vws = gsnap.voxel_world_size;
411 transform.voxel_world_size = if vws.is_finite() && (1e-6..=1e6).contains(&vws) {
412 vws
413 } else {
414 log::warn!(
415 "load_snapshot: grid {id:?} has out-of-range \
416 voxel_world_size {vws} — restoring at 1.0"
417 );
418 1.0
419 };
420 let mut grid = Grid::new(transform);
421 for (addr, bytes) in &gsnap.chunks {
422 let mut vxl =
423 vxl::parse(bytes).map_err(|source| FromSnapshotError::ChunkParse {
424 grid: *id,
425 chunk: *addr,
426 source,
427 })?;
428 let n_cols = (vxl.vsid as usize) * (vxl.vsid as usize);
429 vxl.reserve_edit_capacity(n_cols * RESTORE_EDIT_HEADROOM_PER_COLUMN);
430 grid.chunks.insert(*addr, vxl);
431 grid.note_chunk_set_changed();
432 }
433 // S7.2: restore per-chunk versions. Pre-S7.2 snapshots
434 // carry an empty Vec (via #[serde(default)]) → no
435 // bumps applied, every chunk reads as version 0.
436 for (addr, ver) in &gsnap.chunk_versions {
437 grid.restore_chunk_version(*addr, *ver);
438 }
439 // QE.5b — restore the grid's runtime configuration (the
440 // `generator` / `store` hooks are host code: rebind them
441 // after loading, keyed on `name`).
442 grid.name.clone_from(&gsnap.name);
443 grid.render_sky = gsnap.render_sky;
444 grid.mip_levels_override = gsnap.mip_levels_override;
445 grid.lod_thresholds = gsnap.lod_thresholds;
446 grid.stream_radius = gsnap.stream_radius;
447 // WT.0 — restore the water volumes VERBATIM. No corner
448 // re-normalisation: `WaterVolume::depth_local` accepts
449 // either corner order, so normalising here would make a
450 // volume behave differently before and after a round-trip
451 // (a live scene must equal its restored self).
452 grid.water_volumes.clone_from(&gsnap.water_volumes);
453 scene.grids.insert(*id, grid);
454 }
455 Ok(scene)
456 }
457}
458
459/// The QE.5b wire envelope's magic — identifies a byte blob as a
460/// roxlap scene snapshot before any deserialisation runs.
461pub const SNAPSHOT_MAGIC: [u8; 4] = *b"RXSS";
462
463/// Current snapshot wire version. Bumped whenever the
464/// [`SceneSnapshot`] payload shape changes in a way `#[serde(default)]`
465/// trailing fields can't cover; [`Scene::load_snapshot`] dispatches on
466/// it, so an old save either loads correctly or fails with
467/// [`SnapshotLoadError::UnsupportedVersion`] — never a silent misparse
468/// (the pre-QE.5 failure mode of bare positional bincode).
469///
470/// WT.0 — **v3** appends per-grid `water_volumes` (v2 = SC.snap's
471/// `voxel_world_size`; v1 = the original shape). bincode is strictly
472/// positional, so every trailing-field addition bumps the version and
473/// freezes the previous shape as a private shadow struct:
474/// [`Scene::load_snapshot`] dispatches — v3 blobs decode
475/// [`GridSnapshot`] directly; v2/v1 blobs decode `GridSnapshotV2` /
476/// `GridSnapshotV1` and restore with no water (and, for v1,
477/// `voxel_world_size = 1.0`). `GridTransform`'s own wire form stays
478/// frozen (`#[serde(skip)]`) in ALL versions, so the forever-loadable
479/// v1/v2 fixtures keep loading unchanged.
480pub const SNAPSHOT_VERSION: u32 = 3;
481
482/// Errors from [`Scene::load_snapshot`].
483#[derive(Debug)]
484pub enum SnapshotLoadError {
485 /// The bytes don't start with [`SNAPSHOT_MAGIC`] — not a roxlap
486 /// snapshot (or a pre-QE.5 bare-bincode save; see the
487 /// [`Scene::load_snapshot`] migration note).
488 BadMagic,
489 /// A snapshot from a newer (or unknown) wire version.
490 UnsupportedVersion(u32),
491 /// The payload failed to decode (truncated / corrupted bytes).
492 Decode(String),
493 /// The payload decoded but a chunk failed to restore.
494 Restore(FromSnapshotError),
495}
496
497impl std::fmt::Display for SnapshotLoadError {
498 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
499 match self {
500 Self::BadMagic => write!(f, "not a roxlap scene snapshot (bad magic)"),
501 Self::UnsupportedVersion(v) => {
502 write!(
503 f,
504 "unsupported snapshot version {v} (this build reads <= {SNAPSHOT_VERSION})"
505 )
506 }
507 Self::Decode(msg) => write!(f, "snapshot payload decode failed: {msg}"),
508 Self::Restore(e) => write!(f, "snapshot restore failed: {e}"),
509 }
510 }
511}
512
513impl std::error::Error for SnapshotLoadError {
514 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
515 match self {
516 Self::Restore(e) => Some(e),
517 _ => None,
518 }
519 }
520}
521
522impl Scene {
523 /// Serialise the scene to the versioned snapshot **wire format**
524 /// (QE.5b): [`SNAPSHOT_MAGIC`] + little-endian
525 /// [`SNAPSHOT_VERSION`] + a bincode [`SceneSnapshot`] payload.
526 /// This is the save-file API — a blob written today stays
527 /// loadable by future engine versions ([`Self::load_snapshot`]
528 /// dispatches on the version), which bare serde values can't
529 /// promise under positional codecs.
530 ///
531 /// Hosts that want a different payload codec (JSON for debugging,
532 /// postcard for size) can still serialise
533 /// [`Self::to_snapshot`]'s value themselves — and then own their
534 /// format's evolution story.
535 #[must_use]
536 pub fn save_snapshot(&self) -> Vec<u8> {
537 // SC.snap — v2 persists per-grid `voxel_world_size` (see
538 // [`SNAPSHOT_VERSION`]); no scale is lost across a round-trip.
539 let mut out = Vec::with_capacity(64);
540 out.extend_from_slice(&SNAPSHOT_MAGIC);
541 out.extend_from_slice(&SNAPSHOT_VERSION.to_le_bytes());
542 bincode::serialize_into(&mut out, &self.to_snapshot())
543 .expect("bincode into Vec<u8> cannot fail");
544 out
545 }
546
547 /// Load a scene from [`Self::save_snapshot`] bytes, dispatching
548 /// on the embedded wire version.
549 ///
550 /// Migration note: saves written **before** QE.5b (bare bincode
551 /// of a [`SceneSnapshot`], no envelope) fail with
552 /// [`SnapshotLoadError::BadMagic`]; decode those with the engine
553 /// version that wrote them and re-save.
554 ///
555 /// # Errors
556 /// [`SnapshotLoadError`] — bad magic, unknown version, corrupted
557 /// payload, or a failing chunk restore.
558 pub fn load_snapshot(bytes: &[u8]) -> Result<Self, SnapshotLoadError> {
559 let (Some(magic), Some(version)) = (bytes.get(..4), bytes.get(4..8)) else {
560 return Err(SnapshotLoadError::BadMagic);
561 };
562 if magic != SNAPSHOT_MAGIC {
563 return Err(SnapshotLoadError::BadMagic);
564 }
565 let version = u32::from_le_bytes(version.try_into().expect("4-byte slice"));
566 let payload = &bytes[8..];
567 // Dispatch on the wire version: older blobs decode their frozen
568 // shadow shapes (v1: no scale, no water → 1.0 + dry; v2 (SC.snap):
569 // no water → dry); v3 blobs decode `SceneSnapshot` directly. All
570 // funnel through `from_snapshot`.
571 let snap: SceneSnapshot = match version {
572 1 => bincode::deserialize::<SceneSnapshotV1>(payload)
573 .map_err(|e| SnapshotLoadError::Decode(e.to_string()))?
574 .into(),
575 2 => bincode::deserialize::<SceneSnapshotV2>(payload)
576 .map_err(|e| SnapshotLoadError::Decode(e.to_string()))?
577 .into(),
578 3 => bincode::deserialize(payload)
579 .map_err(|e| SnapshotLoadError::Decode(e.to_string()))?,
580 v => return Err(SnapshotLoadError::UnsupportedVersion(v)),
581 };
582 Self::from_snapshot(&snap).map_err(SnapshotLoadError::Restore)
583 }
584}
585
586#[cfg(test)]
587#[allow(clippy::cast_possible_wrap, clippy::type_complexity)]
588mod tests {
589 use super::*;
590 use crate::chunks::tests::voxel_is_solid;
591 use crate::CHUNK_SIZE_XY;
592 use glam::DVec3;
593 use roxlap_formats::color::VoxColor;
594
595 impl GridId {
596 pub(crate) fn from_raw_for_test(raw: u32) -> Self {
597 Self(raw)
598 }
599 }
600
601 #[test]
602 fn sc_snap_scaled_grid_survives_round_trip() {
603 // SC.snap — v2 persists per-grid voxel_world_size. A vws=0.25 grid must
604 // restore at 0.25, not the pre-SC.snap 1.0 (transform's own wire form
605 // still omits it via #[serde(skip)] — the value rides the sibling
606 // GridSnapshot field).
607 let mut scene = Scene::new();
608 let id = scene.add_grid(GridTransform::at_scale(DVec3::new(5.0, -3.0, 0.0), 0.25));
609 scene
610 .grid_mut(id)
611 .unwrap()
612 .set_voxel(IVec3::new(1, 2, 100), Some(VoxColor(0x8011_2233)));
613
614 let bytes = scene.save_snapshot();
615 // LITERAL on purpose (WT.0 review): this is the one assert that
616 // pins the envelope version as a NUMBER. Comparing against
617 // SNAPSHOT_VERSION would be a tautology (save_snapshot writes
618 // that same constant) and an accidental bump would sail through
619 // green while old engines stop reading new saves. Bumping the
620 // version deliberately? Update this literal alongside the new
621 // shadow shape + fixture.
622 assert_eq!(&bytes[4..8], &3u32.to_le_bytes(), "expected v3 wire");
623 let restored = Scene::load_snapshot(&bytes).expect("round trip");
624
625 let (_, g) = restored.grids().next().expect("one grid");
626 assert!(
627 (g.transform.voxel_world_size - 0.25).abs() < 1e-12,
628 "scale must survive: got {}",
629 g.transform.voxel_world_size
630 );
631 assert_eq!(g.transform.origin, DVec3::new(5.0, -3.0, 0.0));
632 assert!(g.voxel_solid(IVec3::new(1, 2, 100)));
633 }
634
635 #[test]
636 fn sc_snap_out_of_range_scale_restores_at_one() {
637 // The load-side guard rejects corrupt/absurd persisted scale — not
638 // just ≤0 / non-finite but subnormal-near-zero and huge finite values,
639 // any of which collapse the marcher's chunk_dim toward 0 (→ NaN on
640 // GPU). All fall back to 1.0.
641 for bad in [0.0, -2.0, f64::NAN, f64::INFINITY, 1e-300, 1e300] {
642 let snap = SceneSnapshot {
643 next_grid_id: 1,
644 grids: vec![(
645 GridId::from_raw_for_test(0),
646 GridSnapshot {
647 transform: GridTransform::identity(),
648 chunks: vec![],
649 chunk_versions: vec![],
650 name: None,
651 render_sky: true,
652 mip_levels_override: None,
653 lod_thresholds: LodThresholds::always_near(),
654 stream_radius: StreamRadius::default(),
655 voxel_world_size: bad,
656 water_volumes: vec![],
657 },
658 )],
659 };
660 let scene = Scene::from_snapshot(&snap).expect("restore");
661 let (_, g) = scene.grids().next().expect("one grid");
662 assert_eq!(
663 g.transform.voxel_world_size, 1.0,
664 "out-of-range vws {bad} must restore at 1.0"
665 );
666 }
667 // A sane extreme within [1e-6, 1e6] is preserved verbatim.
668 let ok = SceneSnapshot {
669 next_grid_id: 1,
670 grids: vec![(
671 GridId::from_raw_for_test(0),
672 GridSnapshot {
673 transform: GridTransform::identity(),
674 chunks: vec![],
675 chunk_versions: vec![],
676 name: None,
677 render_sky: true,
678 mip_levels_override: None,
679 lod_thresholds: LodThresholds::always_near(),
680 stream_radius: StreamRadius::default(),
681 voxel_world_size: 1000.0,
682 water_volumes: vec![],
683 },
684 )],
685 };
686 let scene = Scene::from_snapshot(&ok).expect("restore");
687 assert_eq!(
688 scene.grids().next().unwrap().1.transform.voxel_world_size,
689 1000.0
690 );
691 }
692
693 /// A 2-grid scene with ~100 chunks total — the validation
694 /// criterion in PORTING-SCENE.md S2. Builds a deterministic
695 /// pattern (one voxel per chunk, colour derived from chunk
696 /// index) so the round-trip can verify each chunk byte-by-byte
697 /// without relying on edit ordering.
698 fn build_two_grid_scene() -> (Scene, Vec<(GridId, IVec3, u32, u32, u32, VoxColor)>) {
699 // Returns (scene, expected_voxels) where each expected entry
700 // is (grid, chunk_idx, voxel_x, voxel_y, voxel_z, color) for
701 // post-restore verification.
702 let mut scene = Scene::new();
703 let g0 = scene.add_grid(GridTransform::at(DVec3::new(0.0, 0.0, 0.0)));
704 let g1 = scene.add_grid(GridTransform::at(DVec3::new(1000.0, 0.0, 0.0)));
705 let mut expected = Vec::new();
706 // Grid 0: 5×5×2 = 50 chunks across (chx, chy, chz) ∈
707 // ([0..5], [0..5], [0..2]). One voxel per chunk at local
708 // (5, 6, 7) with chunk-derived colour.
709 for chz in 0..2 {
710 for chy in 0..5 {
711 for chx in 0..5 {
712 let chunk_idx = IVec3::new(chx, chy, chz);
713 #[allow(clippy::cast_sign_loss)]
714 let color = VoxColor(
715 0x80_00_00_00 | ((chx as u32) << 16) | ((chy as u32) << 8) | (chz as u32),
716 );
717 let global_voxel = chunk_idx
718 * IVec3::new(
719 CHUNK_SIZE_XY as i32,
720 CHUNK_SIZE_XY as i32,
721 crate::CHUNK_SIZE_Z as i32,
722 )
723 + IVec3::new(5, 6, 7);
724 scene
725 .grid_mut(g0)
726 .unwrap()
727 .set_voxel(global_voxel, Some(color));
728 expected.push((g0, chunk_idx, 5, 6, 7, color));
729 }
730 }
731 }
732 // Grid 1: 5×5×2 = 50 chunks, similar pattern but offset
733 // colour space + different voxel coord.
734 for chz in 0..2 {
735 for chy in 0..5 {
736 for chx in 0..5 {
737 let chunk_idx = IVec3::new(chx, chy, chz);
738 #[allow(clippy::cast_sign_loss)]
739 let color = VoxColor(
740 0x80_ff_00_00 | ((chx as u32) << 16) | ((chy as u32) << 8) | (chz as u32),
741 );
742 let global_voxel = chunk_idx
743 * IVec3::new(
744 CHUNK_SIZE_XY as i32,
745 CHUNK_SIZE_XY as i32,
746 crate::CHUNK_SIZE_Z as i32,
747 )
748 + IVec3::new(10, 11, 12);
749 scene
750 .grid_mut(g1)
751 .unwrap()
752 .set_voxel(global_voxel, Some(color));
753 expected.push((g1, chunk_idx, 10, 11, 12, color));
754 }
755 }
756 }
757 (scene, expected)
758 }
759
760 fn assert_voxels_match(scene: &Scene, expected: &[(GridId, IVec3, u32, u32, u32, VoxColor)]) {
761 for &(grid_id, chunk_idx, vx, vy, vz, _color) in expected {
762 let grid = scene.grid(grid_id).expect("grid present");
763 let chunk = grid.chunk(chunk_idx).expect("chunk present");
764 assert!(
765 voxel_is_solid(chunk, vx, vy, vz),
766 "voxel ({vx},{vy},{vz}) in grid={} chunk={chunk_idx:?} not solid post-restore",
767 grid_id.raw()
768 );
769 }
770 }
771
772 #[test]
773 fn snapshot_round_trip_preserves_two_grid_100_chunk_scene() {
774 let (scene, expected) = build_two_grid_scene();
775 assert_eq!(scene.grid_count(), 2);
776 let total_chunks: usize = scene.grids().map(|(_, g)| g.chunks.len()).sum();
777 assert_eq!(total_chunks, 100, "test setup should produce 100 chunks");
778
779 // Round-trip via in-memory bincode.
780 let snap = scene.to_snapshot();
781 let bytes = bincode::serialize(&snap).expect("bincode serialize");
782 let snap_back: SceneSnapshot = bincode::deserialize(&bytes).expect("bincode deserialize");
783 let restored = Scene::from_snapshot(&snap_back).expect("restore");
784
785 // Same shape.
786 assert_eq!(restored.grid_count(), 2);
787 let total_restored: usize = restored.grids().map(|(_, g)| g.chunks.len()).sum();
788 assert_eq!(total_restored, 100);
789
790 // Same voxels.
791 assert_voxels_match(&restored, &expected);
792 }
793
794 #[test]
795 fn snapshot_preserves_next_grid_id_and_transforms() {
796 let mut scene = Scene::new();
797 let g0 = scene.add_grid(GridTransform::at(DVec3::new(10.0, 20.0, 30.0)));
798 let _g1 = scene.add_grid(GridTransform::at(DVec3::new(40.0, 50.0, 60.0)));
799 scene.remove_grid(g0); // bumps the gap
800 let _g2 = scene.add_grid(GridTransform::at(DVec3::new(70.0, 80.0, 90.0)));
801 // next_grid_id should be 3 now (g0=0, g1=1, g2=2).
802 let snap = scene.to_snapshot();
803 assert_eq!(snap.next_grid_id, 3);
804
805 let restored = Scene::from_snapshot(&snap).expect("restore");
806 assert_eq!(restored.grid_count(), 2);
807 // A new grid added to the restored scene should get id 3,
808 // not reuse the dropped id 0.
809 let mut restored_mut = restored;
810 let new_id = restored_mut.add_grid(GridTransform::identity());
811 assert_eq!(new_id.raw(), 3);
812 }
813
814 #[test]
815 fn restored_scene_is_editable() {
816 // The "/ mutate" half of "round-trip serialize / deserialize
817 // / mutate" — verify that a restored scene's chunks have
818 // edit capacity reserved so subsequent `set_voxel` doesn't
819 // panic.
820 let (scene, _) = build_two_grid_scene();
821 let snap = scene.to_snapshot();
822 let mut restored = Scene::from_snapshot(&snap).expect("restore");
823
824 let g0 = GridId::from_raw_for_test(0);
825 let new_voxel = IVec3::new(50, 51, 52);
826 restored
827 .grid_mut(g0)
828 .expect("grid 0 present")
829 .set_voxel(new_voxel, Some(VoxColor(0x80_de_ad_be)));
830 let chunk = restored
831 .grid(g0)
832 .unwrap()
833 .chunk(IVec3::ZERO)
834 .expect("chunk created");
835 assert!(voxel_is_solid(chunk, 50, 51, 52));
836 }
837
838 // ---- S7.2: chunk_versions round-trip ----
839
840 #[test]
841 fn snapshot_round_trip_preserves_chunk_versions() {
842 // Build a scene whose chunk_versions are non-trivial (multi-
843 // edit on the same chunk + edits across multiple chunks),
844 // round-trip, and verify every version survives.
845 let mut scene = Scene::new();
846 let id = scene.add_grid(GridTransform::identity());
847 let g = scene.grid_mut(id).unwrap();
848 // Three edits on chunk (0,0,0) → version 3.
849 g.set_voxel(IVec3::new(0, 0, 0), Some(VoxColor(0x80_aa_bb_cc)));
850 g.set_voxel(IVec3::new(1, 1, 1), Some(VoxColor(0x80_dd_ee_ff)));
851 g.set_voxel(IVec3::new(2, 2, 2), Some(VoxColor(0x80_11_22_33)));
852 // One edit on chunk (1,0,0) → version 1.
853 g.set_voxel(IVec3::new(128, 0, 0), Some(VoxColor(0x80_44_55_66)));
854 assert_eq!(g.chunk_version(IVec3::ZERO), 3);
855 assert_eq!(g.chunk_version(IVec3::new(1, 0, 0)), 1);
856
857 let snap = scene.to_snapshot();
858 let bytes = bincode::serialize(&snap).expect("bincode serialize");
859 let snap_back: SceneSnapshot = bincode::deserialize(&bytes).expect("bincode deserialize");
860 let restored = Scene::from_snapshot(&snap_back).expect("restore");
861
862 let g = restored.grid(id).expect("grid present");
863 assert_eq!(g.chunk_version(IVec3::ZERO), 3);
864 assert_eq!(g.chunk_version(IVec3::new(1, 0, 0)), 1);
865 assert_eq!(g.chunk_versions.len(), 2);
866 }
867
868 #[test]
869 fn snapshot_chunk_versions_zero_entries_are_dropped_from_wire() {
870 // Implementation detail worth pinning: we don't waste bytes
871 // on chunks whose live counter is 0 (== absent semantically).
872 let mut scene = Scene::new();
873 let id = scene.add_grid(GridTransform::identity());
874 let g = scene.grid_mut(id).unwrap();
875 // Manually inject a zero entry — we don't have a public API
876 // to do this; reach into chunk_versions to verify the
877 // serialise-side filter behaves.
878 g.chunk_versions.insert(IVec3::ZERO, 0);
879 let snap = scene.to_snapshot();
880 let g_snap = &snap.grids[0].1;
881 assert!(g_snap.chunk_versions.is_empty(), "zero entries dropped");
882 }
883
884 #[test]
885 fn snapshot_is_deterministic() {
886 let (scene, _) = build_two_grid_scene();
887 let s1 = bincode::serialize(&scene.to_snapshot()).unwrap();
888 let s2 = bincode::serialize(&scene.to_snapshot()).unwrap();
889 assert_eq!(s1, s2, "snapshot bytes should be deterministic");
890 }
891}