Skip to main content

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    /// CA.0 (v4) — the grid's cutaway clip ([`crate::Grid::z_clip`]).
146    /// Same trailing-field discipline: v1..v3 blobs decode through
147    /// their frozen shadow shapes and restore unclipped (`None`).
148    #[serde(default)]
149    pub z_clip: Option<i32>,
150}
151
152/// `voxel_world_size`'s default (`1.0`) for `#[serde(default)]` on
153/// self-describing formats — an unscaled grid.
154fn one_f64() -> f64 {
155    1.0
156}
157
158/// SC.snap — the **frozen v1** wire shape of [`SceneSnapshot`], used only to
159/// deserialize version-1 blobs (which predate the persisted scale). bincode
160/// is positional, so this must mirror v1's fields exactly — it has NO
161/// `voxel_world_size` (grids restore at `1.0` via [`From`]).
162#[derive(Deserialize)]
163struct SceneSnapshotV1 {
164    next_grid_id: u32,
165    grids: Vec<(GridId, GridSnapshotV1)>,
166}
167
168/// SC.snap — the frozen v1 [`GridSnapshot`] shape (no `voxel_world_size`).
169/// The field list + `#[serde(default)]`s must byte-match what v1
170/// `save_snapshot` wrote (the checked-in fixture is the gate).
171#[derive(Deserialize)]
172struct GridSnapshotV1 {
173    transform: GridTransform,
174    chunks: Vec<(IVec3, Vec<u8>)>,
175    #[serde(default)]
176    chunk_versions: Vec<(IVec3, u64)>,
177    #[serde(default)]
178    name: Option<String>,
179    #[serde(default = "default_render_sky")]
180    render_sky: bool,
181    #[serde(default)]
182    mip_levels_override: Option<u32>,
183    #[serde(default = "LodThresholds::always_near")]
184    lod_thresholds: LodThresholds,
185    #[serde(default)]
186    stream_radius: StreamRadius,
187}
188
189impl From<SceneSnapshotV1> for SceneSnapshot {
190    fn from(v1: SceneSnapshotV1) -> Self {
191        Self {
192            next_grid_id: v1.next_grid_id,
193            grids: v1
194                .grids
195                .into_iter()
196                // Chain through the frozen v2 + v3 shapes (see the
197                // GridSnapshotV1 → V2 → V3 impls).
198                .map(|(id, g)| (id, GridSnapshotV3::from(GridSnapshotV2::from(g)).into()))
199                .collect(),
200        }
201    }
202}
203
204impl From<GridSnapshotV1> for GridSnapshotV2 {
205    // FROZEN (WT.0): v1 → v2 adds only the persisted scale. Version
206    // bumps chain shadow shapes (V1 → V2 → … → live), so old links
207    // like this one never change again — only the final
208    // shadow-to-live impl is rewritten per bump.
209    fn from(g: GridSnapshotV1) -> Self {
210        Self {
211            transform: g.transform,
212            chunks: g.chunks,
213            chunk_versions: g.chunk_versions,
214            name: g.name,
215            render_sky: g.render_sky,
216            mip_levels_override: g.mip_levels_override,
217            lod_thresholds: g.lod_thresholds,
218            stream_radius: g.stream_radius,
219            // v1 predates persisted scale — an unscaled grid.
220            voxel_world_size: 1.0,
221        }
222    }
223}
224
225/// WT.0 — the **frozen v2** wire shape of [`SceneSnapshot`], used only to
226/// deserialize version-2 blobs (which predate water volumes). Same
227/// positional-bincode rationale as [`SceneSnapshotV1`].
228#[derive(Deserialize)]
229struct SceneSnapshotV2 {
230    next_grid_id: u32,
231    grids: Vec<(GridId, GridSnapshotV2)>,
232}
233
234/// WT.0 — the frozen v2 [`GridSnapshot`] shape (no `water_volumes`).
235/// The field list must byte-match what v2 `save_snapshot` wrote (the
236/// checked-in v2 fixture is the gate).
237#[derive(Deserialize)]
238struct GridSnapshotV2 {
239    transform: GridTransform,
240    chunks: Vec<(IVec3, Vec<u8>)>,
241    #[serde(default)]
242    chunk_versions: Vec<(IVec3, u64)>,
243    #[serde(default)]
244    name: Option<String>,
245    #[serde(default = "default_render_sky")]
246    render_sky: bool,
247    #[serde(default)]
248    mip_levels_override: Option<u32>,
249    #[serde(default = "LodThresholds::always_near")]
250    lod_thresholds: LodThresholds,
251    #[serde(default)]
252    stream_radius: StreamRadius,
253    #[serde(default = "one_f64")]
254    voxel_world_size: f64,
255}
256
257impl From<SceneSnapshotV2> for SceneSnapshot {
258    fn from(v2: SceneSnapshotV2) -> Self {
259        Self {
260            next_grid_id: v2.next_grid_id,
261            grids: v2
262                .grids
263                .into_iter()
264                .map(|(id, g)| (id, GridSnapshotV3::from(g).into()))
265                .collect(),
266        }
267    }
268}
269
270impl From<GridSnapshotV2> for GridSnapshotV3 {
271    // FROZEN (CA.0): v2 → v3 adds only the water volumes. Version
272    // bumps chain shadow shapes (V1 → V2 → … → live), so old links
273    // like this one never change again — only the final
274    // shadow-to-live impl is rewritten per bump.
275    fn from(g: GridSnapshotV2) -> Self {
276        Self {
277            transform: g.transform,
278            chunks: g.chunks,
279            chunk_versions: g.chunk_versions,
280            name: g.name,
281            render_sky: g.render_sky,
282            mip_levels_override: g.mip_levels_override,
283            lod_thresholds: g.lod_thresholds,
284            stream_radius: g.stream_radius,
285            voxel_world_size: g.voxel_world_size,
286            // v2 predates water volumes — a dry grid.
287            water_volumes: Vec::new(),
288        }
289    }
290}
291
292/// CA.0 — the **frozen v3** wire shape of [`SceneSnapshot`], used only to
293/// deserialize version-3 blobs (which predate the cutaway clip). Same
294/// positional-bincode rationale as [`SceneSnapshotV1`].
295#[derive(Deserialize)]
296struct SceneSnapshotV3 {
297    next_grid_id: u32,
298    grids: Vec<(GridId, GridSnapshotV3)>,
299}
300
301/// CA.0 — the frozen v3 [`GridSnapshot`] shape (no `z_clip`). The field
302/// list must byte-match what v3 `save_snapshot` wrote (the checked-in
303/// v3 fixture is the gate).
304#[derive(Deserialize)]
305struct GridSnapshotV3 {
306    transform: GridTransform,
307    chunks: Vec<(IVec3, Vec<u8>)>,
308    #[serde(default)]
309    chunk_versions: Vec<(IVec3, u64)>,
310    #[serde(default)]
311    name: Option<String>,
312    #[serde(default = "default_render_sky")]
313    render_sky: bool,
314    #[serde(default)]
315    mip_levels_override: Option<u32>,
316    #[serde(default = "LodThresholds::always_near")]
317    lod_thresholds: LodThresholds,
318    #[serde(default)]
319    stream_radius: StreamRadius,
320    #[serde(default = "one_f64")]
321    voxel_world_size: f64,
322    #[serde(default)]
323    water_volumes: Vec<crate::WaterVolume>,
324}
325
326impl From<SceneSnapshotV3> for SceneSnapshot {
327    fn from(v3: SceneSnapshotV3) -> Self {
328        Self {
329            next_grid_id: v3.next_grid_id,
330            grids: v3.grids.into_iter().map(|(id, g)| (id, g.into())).collect(),
331        }
332    }
333}
334
335impl From<GridSnapshotV3> for GridSnapshot {
336    fn from(g: GridSnapshotV3) -> Self {
337        Self {
338            transform: g.transform,
339            chunks: g.chunks,
340            chunk_versions: g.chunk_versions,
341            name: g.name,
342            render_sky: g.render_sky,
343            mip_levels_override: g.mip_levels_override,
344            lod_thresholds: g.lod_thresholds,
345            stream_radius: g.stream_radius,
346            voxel_world_size: g.voxel_world_size,
347            water_volumes: g.water_volumes,
348            // v3 predates the cutaway clip — an unclipped grid.
349            z_clip: None,
350        }
351    }
352}
353
354/// `render_sky`'s [`crate::Grid::new`] default, for
355/// `#[serde(default)]` on self-describing formats.
356fn default_render_sky() -> bool {
357    true
358}
359
360/// Errors from [`Scene::from_snapshot`]. Wraps the per-chunk
361/// [`ParseError`] with a tag identifying which grid + chunk
362/// failed.
363#[derive(Debug)]
364pub enum FromSnapshotError {
365    /// One chunk's bytes failed to round-trip through
366    /// [`roxlap_formats::vxl::parse`].
367    ChunkParse {
368        /// The grid whose chunk failed to parse.
369        grid: GridId,
370        /// The failing chunk's index within `grid`.
371        chunk: IVec3,
372        /// The underlying `.vxl` parse failure.
373        source: ParseError,
374    },
375}
376
377impl std::fmt::Display for FromSnapshotError {
378    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
379        match self {
380            Self::ChunkParse {
381                grid,
382                chunk,
383                source,
384            } => {
385                write!(
386                    f,
387                    "scene snapshot: grid {} chunk {chunk:?} parse failed: {source:?}",
388                    grid.raw()
389                )
390            }
391        }
392    }
393}
394
395impl std::error::Error for FromSnapshotError {}
396
397impl Scene {
398    /// Capture the scene's full state as a serde-friendly value.
399    /// Each chunk is encoded via
400    /// [`roxlap_formats::vxl::serialize`]; the rest is plain field
401    /// data.
402    ///
403    /// Grid iteration order in the produced snapshot is sorted by
404    /// [`GridId`] so two snapshots of the same scene produce
405    /// byte-identical output (the live `HashMap` iteration order
406    /// would be non-deterministic).
407    #[must_use]
408    pub fn to_snapshot(&self) -> SceneSnapshot {
409        // FW.1 — a presentation-only twin is DERIVED visual state
410        // (rebuilt from the real grid + a `FogOfWar`), not authoritative
411        // scene content: exclude it (`query_grids` shares the predicate
412        // with the rest of the crate). This keeps the wire format
413        // unchanged (no new fields) — after a load the host re-arms
414        // fog-of-war, which re-creates the twin. The real grid's
415        // `render_excluded` flag is likewise transient and defaults off
416        // on load (fog is off until re-armed — [`crate::FowTwin::sync`]
417        // signals a lost twin so a host can detect the re-arm).
418        let mut grid_ids: Vec<GridId> = self.query_grids().map(|(id, _)| id).collect();
419        grid_ids.sort_unstable();
420
421        let mut grids = Vec::with_capacity(grid_ids.len());
422        for id in grid_ids {
423            let grid = &self.grids[&id];
424            let mut chunk_addrs: Vec<IVec3> = grid.chunks.keys().copied().collect();
425            chunk_addrs.sort_unstable_by_key(|a| (a.x, a.y, a.z));
426            let chunks = chunk_addrs
427                .into_iter()
428                .map(|addr| (addr, compact_serialize_chunk(&grid.chunks[&addr])))
429                .collect();
430            // S7.2: emit chunk_versions sorted by chunk idx so the
431            // snapshot bytes stay deterministic (HashMap iter order
432            // is not). Zero entries are dropped on the assumption
433            // "missing == 0" — the snapshot stays compact for grids
434            // whose live counters are dense in 1+.
435            let mut version_addrs: Vec<IVec3> = grid
436                .chunk_versions
437                .iter()
438                .filter_map(|(a, v)| if *v != 0 { Some(*a) } else { None })
439                .collect();
440            version_addrs.sort_unstable_by_key(|a| (a.x, a.y, a.z));
441            let chunk_versions = version_addrs
442                .into_iter()
443                .map(|addr| (addr, grid.chunk_versions[&addr]))
444                .collect();
445            grids.push((
446                id,
447                GridSnapshot {
448                    transform: grid.transform,
449                    chunks,
450                    chunk_versions,
451                    name: grid.name.clone(),
452                    render_sky: grid.render_sky,
453                    mip_levels_override: grid.mip_levels_override,
454                    lod_thresholds: grid.lod_thresholds,
455                    stream_radius: grid.stream_radius,
456                    // SC.snap — persist the grid's scale (transform's own wire
457                    // form omits it via #[serde(skip)]).
458                    voxel_world_size: grid.transform.voxel_world_size,
459                    // WT.0 (v3) — persist the water volumes verbatim
460                    // (host-authored Vec order is already deterministic).
461                    water_volumes: grid.water_volumes.clone(),
462                    // CA.0 (v4) — persist the cutaway clip.
463                    z_clip: grid.z_clip,
464                },
465            ));
466        }
467        SceneSnapshot {
468            next_grid_id: self.next_grid_id,
469            grids,
470        }
471    }
472
473    /// Restore a [`Scene`] from a snapshot. Each chunk's bytes are
474    /// re-parsed via [`roxlap_formats::vxl::parse`] and re-armed
475    /// for edits via [`roxlap_formats::vxl::Vxl::reserve_edit_capacity`].
476    ///
477    /// # Errors
478    ///
479    /// Returns [`FromSnapshotError::ChunkParse`] tagged with the
480    /// owning grid + chunk index if any chunk's bytes fail to
481    /// parse. The partial scene is dropped — restoration is
482    /// all-or-nothing.
483    pub fn from_snapshot(snap: &SceneSnapshot) -> Result<Self, FromSnapshotError> {
484        let mut scene = Self::new();
485        scene.next_grid_id = snap.next_grid_id;
486        for (id, gsnap) in &snap.grids {
487            // SC.snap — `transform`'s wire form omits `voxel_world_size`
488            // (`#[serde(skip)]`); the persisted value rides in the sibling
489            // `gsnap.voxel_world_size`. Fold it back into the transform.
490            // Guard untrusted bytes to a sane range: not just ≤0 / non-finite
491            // (which break the marcher — GPU `chunk_dim = 0` → NaN) but also
492            // subnormal-near-zero (1e-300 > 0 yet `vsid · 1e-300 ≈ 0`) and
493            // absurdly-huge finite values. `[1e-6, 1e6]` spans any sane
494            // planet↔grain ratio with margin; out-of-range → fall back to 1.0.
495            let mut transform = gsnap.transform;
496            let vws = gsnap.voxel_world_size;
497            transform.voxel_world_size = if vws.is_finite() && (1e-6..=1e6).contains(&vws) {
498                vws
499            } else {
500                log::warn!(
501                    "load_snapshot: grid {id:?} has out-of-range \
502                         voxel_world_size {vws} — restoring at 1.0"
503                );
504                1.0
505            };
506            let mut grid = Grid::new(transform);
507            for (addr, bytes) in &gsnap.chunks {
508                let mut vxl =
509                    vxl::parse(bytes).map_err(|source| FromSnapshotError::ChunkParse {
510                        grid: *id,
511                        chunk: *addr,
512                        source,
513                    })?;
514                let n_cols = (vxl.vsid as usize) * (vxl.vsid as usize);
515                vxl.reserve_edit_capacity(n_cols * RESTORE_EDIT_HEADROOM_PER_COLUMN);
516                grid.chunks.insert(*addr, vxl);
517                grid.note_chunk_set_changed();
518            }
519            // S7.2: restore per-chunk versions. Pre-S7.2 snapshots
520            // carry an empty Vec (via #[serde(default)]) → no
521            // bumps applied, every chunk reads as version 0.
522            for (addr, ver) in &gsnap.chunk_versions {
523                grid.restore_chunk_version(*addr, *ver);
524            }
525            // QE.5b — restore the grid's runtime configuration (the
526            // `generator` / `store` hooks are host code: rebind them
527            // after loading, keyed on `name`).
528            grid.name.clone_from(&gsnap.name);
529            grid.render_sky = gsnap.render_sky;
530            grid.mip_levels_override = gsnap.mip_levels_override;
531            grid.lod_thresholds = gsnap.lod_thresholds;
532            grid.stream_radius = gsnap.stream_radius;
533            // WT.0 — restore the water volumes VERBATIM. No corner
534            // re-normalisation: `WaterVolume::depth_local` accepts
535            // either corner order, so normalising here would make a
536            // volume behave differently before and after a round-trip
537            // (a live scene must equal its restored self).
538            grid.water_volumes.clone_from(&gsnap.water_volumes);
539            // CA.0 — restore the cutaway clip (pre-v4 saves carry
540            // `None` via the shadow-shape chain → unclipped).
541            grid.z_clip = gsnap.z_clip;
542            scene.grids.insert(*id, grid);
543        }
544        Ok(scene)
545    }
546}
547
548/// The QE.5b wire envelope's magic — identifies a byte blob as a
549/// roxlap scene snapshot before any deserialisation runs.
550pub const SNAPSHOT_MAGIC: [u8; 4] = *b"RXSS";
551
552/// Current snapshot wire version. Bumped whenever the
553/// [`SceneSnapshot`] payload shape changes in a way `#[serde(default)]`
554/// trailing fields can't cover; [`Scene::load_snapshot`] dispatches on
555/// it, so an old save either loads correctly or fails with
556/// [`SnapshotLoadError::UnsupportedVersion`] — never a silent misparse
557/// (the pre-QE.5 failure mode of bare positional bincode).
558///
559/// CA.0 — **v4** appends per-grid `z_clip` (v3 = WT.0's
560/// `water_volumes`; v2 = SC.snap's `voxel_world_size`; v1 = the
561/// original shape). bincode is strictly positional, so every
562/// trailing-field addition bumps the version and freezes the previous
563/// shape as a private shadow struct: [`Scene::load_snapshot`]
564/// dispatches — v4 blobs decode [`GridSnapshot`] directly; v3/v2/v1
565/// blobs decode `GridSnapshotV3` / `GridSnapshotV2` / `GridSnapshotV1`
566/// and restore unclipped (and, for v2/v1, with no water; for v1,
567/// `voxel_world_size = 1.0`). `GridTransform`'s own wire form stays
568/// frozen (`#[serde(skip)]`) in ALL versions, so the forever-loadable
569/// v1/v2/v3 fixtures keep loading unchanged.
570pub const SNAPSHOT_VERSION: u32 = 4;
571
572/// Errors from [`Scene::load_snapshot`].
573#[derive(Debug)]
574pub enum SnapshotLoadError {
575    /// The bytes don't start with [`SNAPSHOT_MAGIC`] — not a roxlap
576    /// snapshot (or a pre-QE.5 bare-bincode save; see the
577    /// [`Scene::load_snapshot`] migration note).
578    BadMagic,
579    /// A snapshot from a newer (or unknown) wire version.
580    UnsupportedVersion(u32),
581    /// The payload failed to decode (truncated / corrupted bytes).
582    Decode(String),
583    /// The payload decoded but a chunk failed to restore.
584    Restore(FromSnapshotError),
585}
586
587impl std::fmt::Display for SnapshotLoadError {
588    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
589        match self {
590            Self::BadMagic => write!(f, "not a roxlap scene snapshot (bad magic)"),
591            Self::UnsupportedVersion(v) => {
592                write!(
593                    f,
594                    "unsupported snapshot version {v} (this build reads <= {SNAPSHOT_VERSION})"
595                )
596            }
597            Self::Decode(msg) => write!(f, "snapshot payload decode failed: {msg}"),
598            Self::Restore(e) => write!(f, "snapshot restore failed: {e}"),
599        }
600    }
601}
602
603impl std::error::Error for SnapshotLoadError {
604    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
605        match self {
606            Self::Restore(e) => Some(e),
607            _ => None,
608        }
609    }
610}
611
612impl Scene {
613    /// Serialise the scene to the versioned snapshot **wire format**
614    /// (QE.5b): [`SNAPSHOT_MAGIC`] + little-endian
615    /// [`SNAPSHOT_VERSION`] + a bincode [`SceneSnapshot`] payload.
616    /// This is the save-file API — a blob written today stays
617    /// loadable by future engine versions ([`Self::load_snapshot`]
618    /// dispatches on the version), which bare serde values can't
619    /// promise under positional codecs.
620    ///
621    /// Hosts that want a different payload codec (JSON for debugging,
622    /// postcard for size) can still serialise
623    /// [`Self::to_snapshot`]'s value themselves — and then own their
624    /// format's evolution story.
625    #[must_use]
626    pub fn save_snapshot(&self) -> Vec<u8> {
627        // SC.snap — v2 persists per-grid `voxel_world_size` (see
628        // [`SNAPSHOT_VERSION`]); no scale is lost across a round-trip.
629        let mut out = Vec::with_capacity(64);
630        out.extend_from_slice(&SNAPSHOT_MAGIC);
631        out.extend_from_slice(&SNAPSHOT_VERSION.to_le_bytes());
632        bincode::serialize_into(&mut out, &self.to_snapshot())
633            .expect("bincode into Vec<u8> cannot fail");
634        out
635    }
636
637    /// Load a scene from [`Self::save_snapshot`] bytes, dispatching
638    /// on the embedded wire version.
639    ///
640    /// Migration note: saves written **before** QE.5b (bare bincode
641    /// of a [`SceneSnapshot`], no envelope) fail with
642    /// [`SnapshotLoadError::BadMagic`]; decode those with the engine
643    /// version that wrote them and re-save.
644    ///
645    /// # Errors
646    /// [`SnapshotLoadError`] — bad magic, unknown version, corrupted
647    /// payload, or a failing chunk restore.
648    pub fn load_snapshot(bytes: &[u8]) -> Result<Self, SnapshotLoadError> {
649        let (Some(magic), Some(version)) = (bytes.get(..4), bytes.get(4..8)) else {
650            return Err(SnapshotLoadError::BadMagic);
651        };
652        if magic != SNAPSHOT_MAGIC {
653            return Err(SnapshotLoadError::BadMagic);
654        }
655        let version = u32::from_le_bytes(version.try_into().expect("4-byte slice"));
656        let payload = &bytes[8..];
657        // Dispatch on the wire version: older blobs decode their frozen
658        // shadow shapes (v1: no scale, no water → 1.0 + dry; v2 (SC.snap):
659        // no water → dry; v3 (WT.0): no clip → unclipped); v4 blobs decode
660        // `SceneSnapshot` directly. All funnel through `from_snapshot`.
661        let snap: SceneSnapshot = match version {
662            1 => bincode::deserialize::<SceneSnapshotV1>(payload)
663                .map_err(|e| SnapshotLoadError::Decode(e.to_string()))?
664                .into(),
665            2 => bincode::deserialize::<SceneSnapshotV2>(payload)
666                .map_err(|e| SnapshotLoadError::Decode(e.to_string()))?
667                .into(),
668            3 => bincode::deserialize::<SceneSnapshotV3>(payload)
669                .map_err(|e| SnapshotLoadError::Decode(e.to_string()))?
670                .into(),
671            4 => bincode::deserialize(payload)
672                .map_err(|e| SnapshotLoadError::Decode(e.to_string()))?,
673            v => return Err(SnapshotLoadError::UnsupportedVersion(v)),
674        };
675        Self::from_snapshot(&snap).map_err(SnapshotLoadError::Restore)
676    }
677}
678
679#[cfg(test)]
680#[allow(clippy::cast_possible_wrap, clippy::type_complexity)]
681mod tests {
682    use super::*;
683    use crate::chunks::tests::voxel_is_solid;
684    use crate::CHUNK_SIZE_XY;
685    use glam::DVec3;
686    use roxlap_formats::color::VoxColor;
687
688    impl GridId {
689        pub(crate) fn from_raw_for_test(raw: u32) -> Self {
690            Self(raw)
691        }
692    }
693
694    #[test]
695    fn sc_snap_scaled_grid_survives_round_trip() {
696        // SC.snap — v2 persists per-grid voxel_world_size. A vws=0.25 grid must
697        // restore at 0.25, not the pre-SC.snap 1.0 (transform's own wire form
698        // still omits it via #[serde(skip)] — the value rides the sibling
699        // GridSnapshot field).
700        let mut scene = Scene::new();
701        let id = scene.add_grid(GridTransform::at_scale(DVec3::new(5.0, -3.0, 0.0), 0.25));
702        scene
703            .grid_mut(id)
704            .unwrap()
705            .set_voxel(IVec3::new(1, 2, 100), Some(VoxColor(0x8011_2233)));
706
707        let bytes = scene.save_snapshot();
708        // LITERAL on purpose (WT.0 review): this is the one assert that
709        // pins the envelope version as a NUMBER. Comparing against
710        // SNAPSHOT_VERSION would be a tautology (save_snapshot writes
711        // that same constant) and an accidental bump would sail through
712        // green while old engines stop reading new saves. Bumping the
713        // version deliberately? Update this literal alongside the new
714        // shadow shape + fixture.
715        assert_eq!(&bytes[4..8], &4u32.to_le_bytes(), "expected v4 wire");
716        let restored = Scene::load_snapshot(&bytes).expect("round trip");
717
718        let (_, g) = restored.grids().next().expect("one grid");
719        assert!(
720            (g.transform.voxel_world_size - 0.25).abs() < 1e-12,
721            "scale must survive: got {}",
722            g.transform.voxel_world_size
723        );
724        assert_eq!(g.transform.origin, DVec3::new(5.0, -3.0, 0.0));
725        assert!(g.voxel_solid(IVec3::new(1, 2, 100)));
726    }
727
728    #[test]
729    fn sc_snap_out_of_range_scale_restores_at_one() {
730        // The load-side guard rejects corrupt/absurd persisted scale — not
731        // just ≤0 / non-finite but subnormal-near-zero and huge finite values,
732        // any of which collapse the marcher's chunk_dim toward 0 (→ NaN on
733        // GPU). All fall back to 1.0.
734        for bad in [0.0, -2.0, f64::NAN, f64::INFINITY, 1e-300, 1e300] {
735            let snap = SceneSnapshot {
736                next_grid_id: 1,
737                grids: vec![(
738                    GridId::from_raw_for_test(0),
739                    GridSnapshot {
740                        transform: GridTransform::identity(),
741                        chunks: vec![],
742                        chunk_versions: vec![],
743                        name: None,
744                        render_sky: true,
745                        mip_levels_override: None,
746                        lod_thresholds: LodThresholds::always_near(),
747                        stream_radius: StreamRadius::default(),
748                        voxel_world_size: bad,
749                        water_volumes: vec![],
750                        z_clip: None,
751                    },
752                )],
753            };
754            let scene = Scene::from_snapshot(&snap).expect("restore");
755            let (_, g) = scene.grids().next().expect("one grid");
756            assert_eq!(
757                g.transform.voxel_world_size, 1.0,
758                "out-of-range vws {bad} must restore at 1.0"
759            );
760        }
761        // A sane extreme within [1e-6, 1e6] is preserved verbatim.
762        let ok = SceneSnapshot {
763            next_grid_id: 1,
764            grids: vec![(
765                GridId::from_raw_for_test(0),
766                GridSnapshot {
767                    transform: GridTransform::identity(),
768                    chunks: vec![],
769                    chunk_versions: vec![],
770                    name: None,
771                    render_sky: true,
772                    mip_levels_override: None,
773                    lod_thresholds: LodThresholds::always_near(),
774                    stream_radius: StreamRadius::default(),
775                    voxel_world_size: 1000.0,
776                    water_volumes: vec![],
777                    z_clip: None,
778                },
779            )],
780        };
781        let scene = Scene::from_snapshot(&ok).expect("restore");
782        assert_eq!(
783            scene.grids().next().unwrap().1.transform.voxel_world_size,
784            1000.0
785        );
786    }
787
788    /// A 2-grid scene with ~100 chunks total — the validation
789    /// criterion in PORTING-SCENE.md S2. Builds a deterministic
790    /// pattern (one voxel per chunk, colour derived from chunk
791    /// index) so the round-trip can verify each chunk byte-by-byte
792    /// without relying on edit ordering.
793    fn build_two_grid_scene() -> (Scene, Vec<(GridId, IVec3, u32, u32, u32, VoxColor)>) {
794        // Returns (scene, expected_voxels) where each expected entry
795        // is (grid, chunk_idx, voxel_x, voxel_y, voxel_z, color) for
796        // post-restore verification.
797        let mut scene = Scene::new();
798        let g0 = scene.add_grid(GridTransform::at(DVec3::new(0.0, 0.0, 0.0)));
799        let g1 = scene.add_grid(GridTransform::at(DVec3::new(1000.0, 0.0, 0.0)));
800        let mut expected = Vec::new();
801        // Grid 0: 5×5×2 = 50 chunks across (chx, chy, chz) ∈
802        // ([0..5], [0..5], [0..2]). One voxel per chunk at local
803        // (5, 6, 7) with chunk-derived colour.
804        for chz in 0..2 {
805            for chy in 0..5 {
806                for chx in 0..5 {
807                    let chunk_idx = IVec3::new(chx, chy, chz);
808                    #[allow(clippy::cast_sign_loss)]
809                    let color = VoxColor(
810                        0x80_00_00_00 | ((chx as u32) << 16) | ((chy as u32) << 8) | (chz as u32),
811                    );
812                    let global_voxel = chunk_idx
813                        * IVec3::new(
814                            CHUNK_SIZE_XY as i32,
815                            CHUNK_SIZE_XY as i32,
816                            crate::CHUNK_SIZE_Z as i32,
817                        )
818                        + IVec3::new(5, 6, 7);
819                    scene
820                        .grid_mut(g0)
821                        .unwrap()
822                        .set_voxel(global_voxel, Some(color));
823                    expected.push((g0, chunk_idx, 5, 6, 7, color));
824                }
825            }
826        }
827        // Grid 1: 5×5×2 = 50 chunks, similar pattern but offset
828        // colour space + different voxel coord.
829        for chz in 0..2 {
830            for chy in 0..5 {
831                for chx in 0..5 {
832                    let chunk_idx = IVec3::new(chx, chy, chz);
833                    #[allow(clippy::cast_sign_loss)]
834                    let color = VoxColor(
835                        0x80_ff_00_00 | ((chx as u32) << 16) | ((chy as u32) << 8) | (chz as u32),
836                    );
837                    let global_voxel = chunk_idx
838                        * IVec3::new(
839                            CHUNK_SIZE_XY as i32,
840                            CHUNK_SIZE_XY as i32,
841                            crate::CHUNK_SIZE_Z as i32,
842                        )
843                        + IVec3::new(10, 11, 12);
844                    scene
845                        .grid_mut(g1)
846                        .unwrap()
847                        .set_voxel(global_voxel, Some(color));
848                    expected.push((g1, chunk_idx, 10, 11, 12, color));
849                }
850            }
851        }
852        (scene, expected)
853    }
854
855    fn assert_voxels_match(scene: &Scene, expected: &[(GridId, IVec3, u32, u32, u32, VoxColor)]) {
856        for &(grid_id, chunk_idx, vx, vy, vz, _color) in expected {
857            let grid = scene.grid(grid_id).expect("grid present");
858            let chunk = grid.chunk(chunk_idx).expect("chunk present");
859            assert!(
860                voxel_is_solid(chunk, vx, vy, vz),
861                "voxel ({vx},{vy},{vz}) in grid={} chunk={chunk_idx:?} not solid post-restore",
862                grid_id.raw()
863            );
864        }
865    }
866
867    #[test]
868    fn snapshot_round_trip_preserves_two_grid_100_chunk_scene() {
869        let (scene, expected) = build_two_grid_scene();
870        assert_eq!(scene.grid_count(), 2);
871        let total_chunks: usize = scene.grids().map(|(_, g)| g.chunks.len()).sum();
872        assert_eq!(total_chunks, 100, "test setup should produce 100 chunks");
873
874        // Round-trip via in-memory bincode.
875        let snap = scene.to_snapshot();
876        let bytes = bincode::serialize(&snap).expect("bincode serialize");
877        let snap_back: SceneSnapshot = bincode::deserialize(&bytes).expect("bincode deserialize");
878        let restored = Scene::from_snapshot(&snap_back).expect("restore");
879
880        // Same shape.
881        assert_eq!(restored.grid_count(), 2);
882        let total_restored: usize = restored.grids().map(|(_, g)| g.chunks.len()).sum();
883        assert_eq!(total_restored, 100);
884
885        // Same voxels.
886        assert_voxels_match(&restored, &expected);
887    }
888
889    #[test]
890    fn snapshot_preserves_next_grid_id_and_transforms() {
891        let mut scene = Scene::new();
892        let g0 = scene.add_grid(GridTransform::at(DVec3::new(10.0, 20.0, 30.0)));
893        let _g1 = scene.add_grid(GridTransform::at(DVec3::new(40.0, 50.0, 60.0)));
894        scene.remove_grid(g0); // bumps the gap
895        let _g2 = scene.add_grid(GridTransform::at(DVec3::new(70.0, 80.0, 90.0)));
896        // next_grid_id should be 3 now (g0=0, g1=1, g2=2).
897        let snap = scene.to_snapshot();
898        assert_eq!(snap.next_grid_id, 3);
899
900        let restored = Scene::from_snapshot(&snap).expect("restore");
901        assert_eq!(restored.grid_count(), 2);
902        // A new grid added to the restored scene should get id 3,
903        // not reuse the dropped id 0.
904        let mut restored_mut = restored;
905        let new_id = restored_mut.add_grid(GridTransform::identity());
906        assert_eq!(new_id.raw(), 3);
907    }
908
909    #[test]
910    fn restored_scene_is_editable() {
911        // The "/ mutate" half of "round-trip serialize / deserialize
912        // / mutate" — verify that a restored scene's chunks have
913        // edit capacity reserved so subsequent `set_voxel` doesn't
914        // panic.
915        let (scene, _) = build_two_grid_scene();
916        let snap = scene.to_snapshot();
917        let mut restored = Scene::from_snapshot(&snap).expect("restore");
918
919        let g0 = GridId::from_raw_for_test(0);
920        let new_voxel = IVec3::new(50, 51, 52);
921        restored
922            .grid_mut(g0)
923            .expect("grid 0 present")
924            .set_voxel(new_voxel, Some(VoxColor(0x80_de_ad_be)));
925        let chunk = restored
926            .grid(g0)
927            .unwrap()
928            .chunk(IVec3::ZERO)
929            .expect("chunk created");
930        assert!(voxel_is_solid(chunk, 50, 51, 52));
931    }
932
933    // ---- S7.2: chunk_versions round-trip ----
934
935    #[test]
936    fn snapshot_round_trip_preserves_chunk_versions() {
937        // Build a scene whose chunk_versions are non-trivial (multi-
938        // edit on the same chunk + edits across multiple chunks),
939        // round-trip, and verify every version survives.
940        let mut scene = Scene::new();
941        let id = scene.add_grid(GridTransform::identity());
942        let g = scene.grid_mut(id).unwrap();
943        // Three edits on chunk (0,0,0) → version 3.
944        g.set_voxel(IVec3::new(0, 0, 0), Some(VoxColor(0x80_aa_bb_cc)));
945        g.set_voxel(IVec3::new(1, 1, 1), Some(VoxColor(0x80_dd_ee_ff)));
946        g.set_voxel(IVec3::new(2, 2, 2), Some(VoxColor(0x80_11_22_33)));
947        // One edit on chunk (1,0,0) → version 1.
948        g.set_voxel(IVec3::new(128, 0, 0), Some(VoxColor(0x80_44_55_66)));
949        assert_eq!(g.chunk_version(IVec3::ZERO), 3);
950        assert_eq!(g.chunk_version(IVec3::new(1, 0, 0)), 1);
951
952        let snap = scene.to_snapshot();
953        let bytes = bincode::serialize(&snap).expect("bincode serialize");
954        let snap_back: SceneSnapshot = bincode::deserialize(&bytes).expect("bincode deserialize");
955        let restored = Scene::from_snapshot(&snap_back).expect("restore");
956
957        let g = restored.grid(id).expect("grid present");
958        assert_eq!(g.chunk_version(IVec3::ZERO), 3);
959        assert_eq!(g.chunk_version(IVec3::new(1, 0, 0)), 1);
960        assert_eq!(g.chunk_versions.len(), 2);
961    }
962
963    #[test]
964    fn snapshot_chunk_versions_zero_entries_are_dropped_from_wire() {
965        // Implementation detail worth pinning: we don't waste bytes
966        // on chunks whose live counter is 0 (== absent semantically).
967        let mut scene = Scene::new();
968        let id = scene.add_grid(GridTransform::identity());
969        let g = scene.grid_mut(id).unwrap();
970        // Manually inject a zero entry — we don't have a public API
971        // to do this; reach into chunk_versions to verify the
972        // serialise-side filter behaves.
973        g.chunk_versions.insert(IVec3::ZERO, 0);
974        let snap = scene.to_snapshot();
975        let g_snap = &snap.grids[0].1;
976        assert!(g_snap.chunk_versions.is_empty(), "zero entries dropped");
977    }
978
979    #[test]
980    fn snapshot_is_deterministic() {
981        let (scene, _) = build_two_grid_scene();
982        let s1 = bincode::serialize(&scene.to_snapshot()).unwrap();
983        let s2 = bincode::serialize(&scene.to_snapshot()).unwrap();
984        assert_eq!(s1, s2, "snapshot bytes should be deterministic");
985    }
986}