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}
132
133/// `render_sky`'s [`crate::Grid::new`] default, for
134/// `#[serde(default)]` on self-describing formats.
135fn default_render_sky() -> bool {
136    true
137}
138
139/// Errors from [`Scene::from_snapshot`]. Wraps the per-chunk
140/// [`ParseError`] with a tag identifying which grid + chunk
141/// failed.
142#[derive(Debug)]
143pub enum FromSnapshotError {
144    /// One chunk's bytes failed to round-trip through
145    /// [`roxlap_formats::vxl::parse`].
146    ChunkParse {
147        /// The grid whose chunk failed to parse.
148        grid: GridId,
149        /// The failing chunk's index within `grid`.
150        chunk: IVec3,
151        /// The underlying `.vxl` parse failure.
152        source: ParseError,
153    },
154}
155
156impl std::fmt::Display for FromSnapshotError {
157    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158        match self {
159            Self::ChunkParse {
160                grid,
161                chunk,
162                source,
163            } => {
164                write!(
165                    f,
166                    "scene snapshot: grid {} chunk {chunk:?} parse failed: {source:?}",
167                    grid.raw()
168                )
169            }
170        }
171    }
172}
173
174impl std::error::Error for FromSnapshotError {}
175
176impl Scene {
177    /// Capture the scene's full state as a serde-friendly value.
178    /// Each chunk is encoded via
179    /// [`roxlap_formats::vxl::serialize`]; the rest is plain field
180    /// data.
181    ///
182    /// Grid iteration order in the produced snapshot is sorted by
183    /// [`GridId`] so two snapshots of the same scene produce
184    /// byte-identical output (the live `HashMap` iteration order
185    /// would be non-deterministic).
186    #[must_use]
187    pub fn to_snapshot(&self) -> SceneSnapshot {
188        let mut grid_ids: Vec<GridId> = self.grids.keys().copied().collect();
189        grid_ids.sort_unstable();
190
191        let mut grids = Vec::with_capacity(grid_ids.len());
192        for id in grid_ids {
193            let grid = &self.grids[&id];
194            let mut chunk_addrs: Vec<IVec3> = grid.chunks.keys().copied().collect();
195            chunk_addrs.sort_unstable_by_key(|a| (a.x, a.y, a.z));
196            let chunks = chunk_addrs
197                .into_iter()
198                .map(|addr| (addr, compact_serialize_chunk(&grid.chunks[&addr])))
199                .collect();
200            // S7.2: emit chunk_versions sorted by chunk idx so the
201            // snapshot bytes stay deterministic (HashMap iter order
202            // is not). Zero entries are dropped on the assumption
203            // "missing == 0" — the snapshot stays compact for grids
204            // whose live counters are dense in 1+.
205            let mut version_addrs: Vec<IVec3> = grid
206                .chunk_versions
207                .iter()
208                .filter_map(|(a, v)| if *v != 0 { Some(*a) } else { None })
209                .collect();
210            version_addrs.sort_unstable_by_key(|a| (a.x, a.y, a.z));
211            let chunk_versions = version_addrs
212                .into_iter()
213                .map(|addr| (addr, grid.chunk_versions[&addr]))
214                .collect();
215            grids.push((
216                id,
217                GridSnapshot {
218                    transform: grid.transform,
219                    chunks,
220                    chunk_versions,
221                    name: grid.name.clone(),
222                    render_sky: grid.render_sky,
223                    mip_levels_override: grid.mip_levels_override,
224                    lod_thresholds: grid.lod_thresholds,
225                    stream_radius: grid.stream_radius,
226                },
227            ));
228        }
229        SceneSnapshot {
230            next_grid_id: self.next_grid_id,
231            grids,
232        }
233    }
234
235    /// Restore a [`Scene`] from a snapshot. Each chunk's bytes are
236    /// re-parsed via [`roxlap_formats::vxl::parse`] and re-armed
237    /// for edits via [`roxlap_formats::vxl::Vxl::reserve_edit_capacity`].
238    ///
239    /// # Errors
240    ///
241    /// Returns [`FromSnapshotError::ChunkParse`] tagged with the
242    /// owning grid + chunk index if any chunk's bytes fail to
243    /// parse. The partial scene is dropped — restoration is
244    /// all-or-nothing.
245    pub fn from_snapshot(snap: &SceneSnapshot) -> Result<Self, FromSnapshotError> {
246        let mut scene = Self::new();
247        scene.next_grid_id = snap.next_grid_id;
248        for (id, gsnap) in &snap.grids {
249            let mut grid = Grid::new(gsnap.transform);
250            for (addr, bytes) in &gsnap.chunks {
251                let mut vxl =
252                    vxl::parse(bytes).map_err(|source| FromSnapshotError::ChunkParse {
253                        grid: *id,
254                        chunk: *addr,
255                        source,
256                    })?;
257                let n_cols = (vxl.vsid as usize) * (vxl.vsid as usize);
258                vxl.reserve_edit_capacity(n_cols * RESTORE_EDIT_HEADROOM_PER_COLUMN);
259                grid.chunks.insert(*addr, vxl);
260                grid.note_chunk_set_changed();
261            }
262            // S7.2: restore per-chunk versions. Pre-S7.2 snapshots
263            // carry an empty Vec (via #[serde(default)]) → no
264            // bumps applied, every chunk reads as version 0.
265            for (addr, ver) in &gsnap.chunk_versions {
266                grid.restore_chunk_version(*addr, *ver);
267            }
268            // QE.5b — restore the grid's runtime configuration (the
269            // `generator` / `store` hooks are host code: rebind them
270            // after loading, keyed on `name`).
271            grid.name.clone_from(&gsnap.name);
272            grid.render_sky = gsnap.render_sky;
273            grid.mip_levels_override = gsnap.mip_levels_override;
274            grid.lod_thresholds = gsnap.lod_thresholds;
275            grid.stream_radius = gsnap.stream_radius;
276            scene.grids.insert(*id, grid);
277        }
278        Ok(scene)
279    }
280}
281
282/// The QE.5b wire envelope's magic — identifies a byte blob as a
283/// roxlap scene snapshot before any deserialisation runs.
284pub const SNAPSHOT_MAGIC: [u8; 4] = *b"RXSS";
285
286/// Current snapshot wire version. Bumped whenever the
287/// [`SceneSnapshot`] payload shape changes; [`Scene::load_snapshot`]
288/// dispatches on it, so an old save either loads correctly or fails
289/// with [`SnapshotLoadError::UnsupportedVersion`] — never a silent
290/// misparse (the pre-QE.5 failure mode of bare positional bincode).
291pub const SNAPSHOT_VERSION: u32 = 1;
292
293/// Errors from [`Scene::load_snapshot`].
294#[derive(Debug)]
295pub enum SnapshotLoadError {
296    /// The bytes don't start with [`SNAPSHOT_MAGIC`] — not a roxlap
297    /// snapshot (or a pre-QE.5 bare-bincode save; see the
298    /// [`Scene::load_snapshot`] migration note).
299    BadMagic,
300    /// A snapshot from a newer (or unknown) wire version.
301    UnsupportedVersion(u32),
302    /// The payload failed to decode (truncated / corrupted bytes).
303    Decode(String),
304    /// The payload decoded but a chunk failed to restore.
305    Restore(FromSnapshotError),
306}
307
308impl std::fmt::Display for SnapshotLoadError {
309    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
310        match self {
311            Self::BadMagic => write!(f, "not a roxlap scene snapshot (bad magic)"),
312            Self::UnsupportedVersion(v) => {
313                write!(
314                    f,
315                    "unsupported snapshot version {v} (this build reads <= {SNAPSHOT_VERSION})"
316                )
317            }
318            Self::Decode(msg) => write!(f, "snapshot payload decode failed: {msg}"),
319            Self::Restore(e) => write!(f, "snapshot restore failed: {e}"),
320        }
321    }
322}
323
324impl std::error::Error for SnapshotLoadError {
325    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
326        match self {
327            Self::Restore(e) => Some(e),
328            _ => None,
329        }
330    }
331}
332
333impl Scene {
334    /// Serialise the scene to the versioned snapshot **wire format**
335    /// (QE.5b): [`SNAPSHOT_MAGIC`] + little-endian
336    /// [`SNAPSHOT_VERSION`] + a bincode [`SceneSnapshot`] payload.
337    /// This is the save-file API — a blob written today stays
338    /// loadable by future engine versions ([`Self::load_snapshot`]
339    /// dispatches on the version), which bare serde values can't
340    /// promise under positional codecs.
341    ///
342    /// Hosts that want a different payload codec (JSON for debugging,
343    /// postcard for size) can still serialise
344    /// [`Self::to_snapshot`]'s value themselves — and then own their
345    /// format's evolution story.
346    #[must_use]
347    pub fn save_snapshot(&self) -> Vec<u8> {
348        let mut out = Vec::with_capacity(64);
349        out.extend_from_slice(&SNAPSHOT_MAGIC);
350        out.extend_from_slice(&SNAPSHOT_VERSION.to_le_bytes());
351        bincode::serialize_into(&mut out, &self.to_snapshot())
352            .expect("bincode into Vec<u8> cannot fail");
353        out
354    }
355
356    /// Load a scene from [`Self::save_snapshot`] bytes, dispatching
357    /// on the embedded wire version.
358    ///
359    /// Migration note: saves written **before** QE.5b (bare bincode
360    /// of a [`SceneSnapshot`], no envelope) fail with
361    /// [`SnapshotLoadError::BadMagic`]; decode those with the engine
362    /// version that wrote them and re-save.
363    ///
364    /// # Errors
365    /// [`SnapshotLoadError`] — bad magic, unknown version, corrupted
366    /// payload, or a failing chunk restore.
367    pub fn load_snapshot(bytes: &[u8]) -> Result<Self, SnapshotLoadError> {
368        let (Some(magic), Some(version)) = (bytes.get(..4), bytes.get(4..8)) else {
369            return Err(SnapshotLoadError::BadMagic);
370        };
371        if magic != SNAPSHOT_MAGIC {
372            return Err(SnapshotLoadError::BadMagic);
373        }
374        let version = u32::from_le_bytes(version.try_into().expect("4-byte slice"));
375        if version != 1 {
376            return Err(SnapshotLoadError::UnsupportedVersion(version));
377        }
378        let snap: SceneSnapshot = bincode::deserialize(&bytes[8..])
379            .map_err(|e| SnapshotLoadError::Decode(e.to_string()))?;
380        Self::from_snapshot(&snap).map_err(SnapshotLoadError::Restore)
381    }
382}
383
384#[cfg(test)]
385#[allow(clippy::cast_possible_wrap, clippy::type_complexity)]
386mod tests {
387    use super::*;
388    use crate::chunks::tests::voxel_is_solid;
389    use crate::CHUNK_SIZE_XY;
390    use glam::DVec3;
391    use roxlap_formats::color::VoxColor;
392
393    impl GridId {
394        pub(crate) fn from_raw_for_test(raw: u32) -> Self {
395            Self(raw)
396        }
397    }
398
399    /// A 2-grid scene with ~100 chunks total — the validation
400    /// criterion in PORTING-SCENE.md S2. Builds a deterministic
401    /// pattern (one voxel per chunk, colour derived from chunk
402    /// index) so the round-trip can verify each chunk byte-by-byte
403    /// without relying on edit ordering.
404    fn build_two_grid_scene() -> (Scene, Vec<(GridId, IVec3, u32, u32, u32, VoxColor)>) {
405        // Returns (scene, expected_voxels) where each expected entry
406        // is (grid, chunk_idx, voxel_x, voxel_y, voxel_z, color) for
407        // post-restore verification.
408        let mut scene = Scene::new();
409        let g0 = scene.add_grid(GridTransform::at(DVec3::new(0.0, 0.0, 0.0)));
410        let g1 = scene.add_grid(GridTransform::at(DVec3::new(1000.0, 0.0, 0.0)));
411        let mut expected = Vec::new();
412        // Grid 0: 5×5×2 = 50 chunks across (chx, chy, chz) ∈
413        // ([0..5], [0..5], [0..2]). One voxel per chunk at local
414        // (5, 6, 7) with chunk-derived colour.
415        for chz in 0..2 {
416            for chy in 0..5 {
417                for chx in 0..5 {
418                    let chunk_idx = IVec3::new(chx, chy, chz);
419                    #[allow(clippy::cast_sign_loss)]
420                    let color = VoxColor(
421                        0x80_00_00_00 | ((chx as u32) << 16) | ((chy as u32) << 8) | (chz as u32),
422                    );
423                    let global_voxel = chunk_idx
424                        * IVec3::new(
425                            CHUNK_SIZE_XY as i32,
426                            CHUNK_SIZE_XY as i32,
427                            crate::CHUNK_SIZE_Z as i32,
428                        )
429                        + IVec3::new(5, 6, 7);
430                    scene
431                        .grid_mut(g0)
432                        .unwrap()
433                        .set_voxel(global_voxel, Some(color));
434                    expected.push((g0, chunk_idx, 5, 6, 7, color));
435                }
436            }
437        }
438        // Grid 1: 5×5×2 = 50 chunks, similar pattern but offset
439        // colour space + different voxel coord.
440        for chz in 0..2 {
441            for chy in 0..5 {
442                for chx in 0..5 {
443                    let chunk_idx = IVec3::new(chx, chy, chz);
444                    #[allow(clippy::cast_sign_loss)]
445                    let color = VoxColor(
446                        0x80_ff_00_00 | ((chx as u32) << 16) | ((chy as u32) << 8) | (chz as u32),
447                    );
448                    let global_voxel = chunk_idx
449                        * IVec3::new(
450                            CHUNK_SIZE_XY as i32,
451                            CHUNK_SIZE_XY as i32,
452                            crate::CHUNK_SIZE_Z as i32,
453                        )
454                        + IVec3::new(10, 11, 12);
455                    scene
456                        .grid_mut(g1)
457                        .unwrap()
458                        .set_voxel(global_voxel, Some(color));
459                    expected.push((g1, chunk_idx, 10, 11, 12, color));
460                }
461            }
462        }
463        (scene, expected)
464    }
465
466    fn assert_voxels_match(scene: &Scene, expected: &[(GridId, IVec3, u32, u32, u32, VoxColor)]) {
467        for &(grid_id, chunk_idx, vx, vy, vz, _color) in expected {
468            let grid = scene.grid(grid_id).expect("grid present");
469            let chunk = grid.chunk(chunk_idx).expect("chunk present");
470            assert!(
471                voxel_is_solid(chunk, vx, vy, vz),
472                "voxel ({vx},{vy},{vz}) in grid={} chunk={chunk_idx:?} not solid post-restore",
473                grid_id.raw()
474            );
475        }
476    }
477
478    #[test]
479    fn snapshot_round_trip_preserves_two_grid_100_chunk_scene() {
480        let (scene, expected) = build_two_grid_scene();
481        assert_eq!(scene.grid_count(), 2);
482        let total_chunks: usize = scene.grids().map(|(_, g)| g.chunks.len()).sum();
483        assert_eq!(total_chunks, 100, "test setup should produce 100 chunks");
484
485        // Round-trip via in-memory bincode.
486        let snap = scene.to_snapshot();
487        let bytes = bincode::serialize(&snap).expect("bincode serialize");
488        let snap_back: SceneSnapshot = bincode::deserialize(&bytes).expect("bincode deserialize");
489        let restored = Scene::from_snapshot(&snap_back).expect("restore");
490
491        // Same shape.
492        assert_eq!(restored.grid_count(), 2);
493        let total_restored: usize = restored.grids().map(|(_, g)| g.chunks.len()).sum();
494        assert_eq!(total_restored, 100);
495
496        // Same voxels.
497        assert_voxels_match(&restored, &expected);
498    }
499
500    #[test]
501    fn snapshot_preserves_next_grid_id_and_transforms() {
502        let mut scene = Scene::new();
503        let g0 = scene.add_grid(GridTransform::at(DVec3::new(10.0, 20.0, 30.0)));
504        let _g1 = scene.add_grid(GridTransform::at(DVec3::new(40.0, 50.0, 60.0)));
505        scene.remove_grid(g0); // bumps the gap
506        let _g2 = scene.add_grid(GridTransform::at(DVec3::new(70.0, 80.0, 90.0)));
507        // next_grid_id should be 3 now (g0=0, g1=1, g2=2).
508        let snap = scene.to_snapshot();
509        assert_eq!(snap.next_grid_id, 3);
510
511        let restored = Scene::from_snapshot(&snap).expect("restore");
512        assert_eq!(restored.grid_count(), 2);
513        // A new grid added to the restored scene should get id 3,
514        // not reuse the dropped id 0.
515        let mut restored_mut = restored;
516        let new_id = restored_mut.add_grid(GridTransform::identity());
517        assert_eq!(new_id.raw(), 3);
518    }
519
520    #[test]
521    fn restored_scene_is_editable() {
522        // The "/ mutate" half of "round-trip serialize / deserialize
523        // / mutate" — verify that a restored scene's chunks have
524        // edit capacity reserved so subsequent `set_voxel` doesn't
525        // panic.
526        let (scene, _) = build_two_grid_scene();
527        let snap = scene.to_snapshot();
528        let mut restored = Scene::from_snapshot(&snap).expect("restore");
529
530        let g0 = GridId::from_raw_for_test(0);
531        let new_voxel = IVec3::new(50, 51, 52);
532        restored
533            .grid_mut(g0)
534            .expect("grid 0 present")
535            .set_voxel(new_voxel, Some(VoxColor(0x80_de_ad_be)));
536        let chunk = restored
537            .grid(g0)
538            .unwrap()
539            .chunk(IVec3::ZERO)
540            .expect("chunk created");
541        assert!(voxel_is_solid(chunk, 50, 51, 52));
542    }
543
544    // ---- S7.2: chunk_versions round-trip ----
545
546    #[test]
547    fn snapshot_round_trip_preserves_chunk_versions() {
548        // Build a scene whose chunk_versions are non-trivial (multi-
549        // edit on the same chunk + edits across multiple chunks),
550        // round-trip, and verify every version survives.
551        let mut scene = Scene::new();
552        let id = scene.add_grid(GridTransform::identity());
553        let g = scene.grid_mut(id).unwrap();
554        // Three edits on chunk (0,0,0) → version 3.
555        g.set_voxel(IVec3::new(0, 0, 0), Some(VoxColor(0x80_aa_bb_cc)));
556        g.set_voxel(IVec3::new(1, 1, 1), Some(VoxColor(0x80_dd_ee_ff)));
557        g.set_voxel(IVec3::new(2, 2, 2), Some(VoxColor(0x80_11_22_33)));
558        // One edit on chunk (1,0,0) → version 1.
559        g.set_voxel(IVec3::new(128, 0, 0), Some(VoxColor(0x80_44_55_66)));
560        assert_eq!(g.chunk_version(IVec3::ZERO), 3);
561        assert_eq!(g.chunk_version(IVec3::new(1, 0, 0)), 1);
562
563        let snap = scene.to_snapshot();
564        let bytes = bincode::serialize(&snap).expect("bincode serialize");
565        let snap_back: SceneSnapshot = bincode::deserialize(&bytes).expect("bincode deserialize");
566        let restored = Scene::from_snapshot(&snap_back).expect("restore");
567
568        let g = restored.grid(id).expect("grid present");
569        assert_eq!(g.chunk_version(IVec3::ZERO), 3);
570        assert_eq!(g.chunk_version(IVec3::new(1, 0, 0)), 1);
571        assert_eq!(g.chunk_versions.len(), 2);
572    }
573
574    #[test]
575    fn snapshot_chunk_versions_zero_entries_are_dropped_from_wire() {
576        // Implementation detail worth pinning: we don't waste bytes
577        // on chunks whose live counter is 0 (== absent semantically).
578        let mut scene = Scene::new();
579        let id = scene.add_grid(GridTransform::identity());
580        let g = scene.grid_mut(id).unwrap();
581        // Manually inject a zero entry — we don't have a public API
582        // to do this; reach into chunk_versions to verify the
583        // serialise-side filter behaves.
584        g.chunk_versions.insert(IVec3::ZERO, 0);
585        let snap = scene.to_snapshot();
586        let g_snap = &snap.grids[0].1;
587        assert!(g_snap.chunk_versions.is_empty(), "zero entries dropped");
588    }
589
590    #[test]
591    fn snapshot_is_deterministic() {
592        let (scene, _) = build_two_grid_scene();
593        let s1 = bincode::serialize(&scene.to_snapshot()).unwrap();
594        let s2 = bincode::serialize(&scene.to_snapshot()).unwrap();
595        assert_eq!(s1, s2, "snapshot bytes should be deterministic");
596    }
597}