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, Scene};
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.
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct GridSnapshot {
95    pub transform: GridTransform,
96    /// Chunks as `(chunk_idx, vxl_bytes)`. `vxl_bytes` is
97    /// [`roxlap_formats::vxl::serialize`] output — re-parseable
98    /// via [`roxlap_formats::vxl::parse`].
99    pub chunks: Vec<(IVec3, Vec<u8>)>,
100    /// S7.2: per-chunk edit version counters, sorted by chunk
101    /// index. Chunks absent from this list restore as version 0 —
102    /// the same as a freshly generated or pre-S7.2 snapshot. The
103    /// `#[serde(default)]` attribute lets a pre-S7.2 snapshot
104    /// deserialise into this newer struct shape cleanly.
105    #[serde(default)]
106    pub chunk_versions: Vec<(IVec3, u64)>,
107}
108
109/// Errors from [`Scene::from_snapshot`]. Wraps the per-chunk
110/// [`ParseError`] with a tag identifying which grid + chunk
111/// failed.
112#[derive(Debug)]
113pub enum FromSnapshotError {
114    /// One chunk's bytes failed to round-trip through
115    /// [`roxlap_formats::vxl::parse`].
116    ChunkParse {
117        grid: GridId,
118        chunk: IVec3,
119        source: ParseError,
120    },
121}
122
123impl std::fmt::Display for FromSnapshotError {
124    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125        match self {
126            Self::ChunkParse {
127                grid,
128                chunk,
129                source,
130            } => {
131                write!(
132                    f,
133                    "scene snapshot: grid {} chunk {chunk:?} parse failed: {source:?}",
134                    grid.raw()
135                )
136            }
137        }
138    }
139}
140
141impl std::error::Error for FromSnapshotError {}
142
143impl Scene {
144    /// Capture the scene's full state as a serde-friendly value.
145    /// Each chunk is encoded via
146    /// [`roxlap_formats::vxl::serialize`]; the rest is plain field
147    /// data.
148    ///
149    /// Grid iteration order in the produced snapshot is sorted by
150    /// [`GridId`] so two snapshots of the same scene produce
151    /// byte-identical output (the live `HashMap` iteration order
152    /// would be non-deterministic).
153    #[must_use]
154    pub fn to_snapshot(&self) -> SceneSnapshot {
155        let mut grid_ids: Vec<GridId> = self.grids.keys().copied().collect();
156        grid_ids.sort_unstable();
157
158        let mut grids = Vec::with_capacity(grid_ids.len());
159        for id in grid_ids {
160            let grid = &self.grids[&id];
161            let mut chunk_addrs: Vec<IVec3> = grid.chunks.keys().copied().collect();
162            chunk_addrs.sort_unstable_by_key(|a| (a.x, a.y, a.z));
163            let chunks = chunk_addrs
164                .into_iter()
165                .map(|addr| (addr, compact_serialize_chunk(&grid.chunks[&addr])))
166                .collect();
167            // S7.2: emit chunk_versions sorted by chunk idx so the
168            // snapshot bytes stay deterministic (HashMap iter order
169            // is not). Zero entries are dropped on the assumption
170            // "missing == 0" — the snapshot stays compact for grids
171            // whose live counters are dense in 1+.
172            let mut version_addrs: Vec<IVec3> = grid
173                .chunk_versions
174                .iter()
175                .filter_map(|(a, v)| if *v != 0 { Some(*a) } else { None })
176                .collect();
177            version_addrs.sort_unstable_by_key(|a| (a.x, a.y, a.z));
178            let chunk_versions = version_addrs
179                .into_iter()
180                .map(|addr| (addr, grid.chunk_versions[&addr]))
181                .collect();
182            grids.push((
183                id,
184                GridSnapshot {
185                    transform: grid.transform,
186                    chunks,
187                    chunk_versions,
188                },
189            ));
190        }
191        SceneSnapshot {
192            next_grid_id: self.next_grid_id,
193            grids,
194        }
195    }
196
197    /// Restore a [`Scene`] from a snapshot. Each chunk's bytes are
198    /// re-parsed via [`roxlap_formats::vxl::parse`] and re-armed
199    /// for edits via [`roxlap_formats::vxl::Vxl::reserve_edit_capacity`].
200    ///
201    /// # Errors
202    ///
203    /// Returns [`FromSnapshotError::ChunkParse`] tagged with the
204    /// owning grid + chunk index if any chunk's bytes fail to
205    /// parse. The partial scene is dropped — restoration is
206    /// all-or-nothing.
207    pub fn from_snapshot(snap: &SceneSnapshot) -> Result<Self, FromSnapshotError> {
208        let mut scene = Self::new();
209        scene.next_grid_id = snap.next_grid_id;
210        for (id, gsnap) in &snap.grids {
211            let mut grid = Grid::new(gsnap.transform);
212            for (addr, bytes) in &gsnap.chunks {
213                let mut vxl =
214                    vxl::parse(bytes).map_err(|source| FromSnapshotError::ChunkParse {
215                        grid: *id,
216                        chunk: *addr,
217                        source,
218                    })?;
219                let n_cols = (vxl.vsid as usize) * (vxl.vsid as usize);
220                vxl.reserve_edit_capacity(n_cols * RESTORE_EDIT_HEADROOM_PER_COLUMN);
221                grid.chunks.insert(*addr, vxl);
222            }
223            // S7.2: restore per-chunk versions. Pre-S7.2 snapshots
224            // carry an empty Vec (via #[serde(default)]) → no
225            // bumps applied, every chunk reads as version 0.
226            for (addr, ver) in &gsnap.chunk_versions {
227                grid.chunk_versions.insert(*addr, *ver);
228            }
229            scene.grids.insert(*id, grid);
230        }
231        Ok(scene)
232    }
233}
234
235#[cfg(test)]
236#[allow(clippy::cast_possible_wrap, clippy::type_complexity)]
237mod tests {
238    use super::*;
239    use crate::chunks::tests::voxel_is_solid;
240    use crate::CHUNK_SIZE_XY;
241    use glam::DVec3;
242
243    impl GridId {
244        pub(crate) fn from_raw_for_test(raw: u32) -> Self {
245            Self(raw)
246        }
247    }
248
249    /// A 2-grid scene with ~100 chunks total — the validation
250    /// criterion in PORTING-SCENE.md S2. Builds a deterministic
251    /// pattern (one voxel per chunk, colour derived from chunk
252    /// index) so the round-trip can verify each chunk byte-by-byte
253    /// without relying on edit ordering.
254    fn build_two_grid_scene() -> (Scene, Vec<(GridId, IVec3, u32, u32, u32, u32)>) {
255        // Returns (scene, expected_voxels) where each expected entry
256        // is (grid, chunk_idx, voxel_x, voxel_y, voxel_z, color) for
257        // post-restore verification.
258        let mut scene = Scene::new();
259        let g0 = scene.add_grid(GridTransform::at(DVec3::new(0.0, 0.0, 0.0)));
260        let g1 = scene.add_grid(GridTransform::at(DVec3::new(1000.0, 0.0, 0.0)));
261        let mut expected = Vec::new();
262        // Grid 0: 5×5×2 = 50 chunks across (chx, chy, chz) ∈
263        // ([0..5], [0..5], [0..2]). One voxel per chunk at local
264        // (5, 6, 7) with chunk-derived colour.
265        for chz in 0..2 {
266            for chy in 0..5 {
267                for chx in 0..5 {
268                    let chunk_idx = IVec3::new(chx, chy, chz);
269                    #[allow(clippy::cast_sign_loss)]
270                    let color =
271                        0x80_00_00_00 | ((chx as u32) << 16) | ((chy as u32) << 8) | (chz as u32);
272                    let global_voxel = chunk_idx
273                        * IVec3::new(
274                            CHUNK_SIZE_XY as i32,
275                            CHUNK_SIZE_XY as i32,
276                            crate::CHUNK_SIZE_Z as i32,
277                        )
278                        + IVec3::new(5, 6, 7);
279                    scene
280                        .grid_mut(g0)
281                        .unwrap()
282                        .set_voxel(global_voxel, Some(color));
283                    expected.push((g0, chunk_idx, 5, 6, 7, color));
284                }
285            }
286        }
287        // Grid 1: 5×5×2 = 50 chunks, similar pattern but offset
288        // colour space + different voxel coord.
289        for chz in 0..2 {
290            for chy in 0..5 {
291                for chx in 0..5 {
292                    let chunk_idx = IVec3::new(chx, chy, chz);
293                    #[allow(clippy::cast_sign_loss)]
294                    let color =
295                        0x80_ff_00_00 | ((chx as u32) << 16) | ((chy as u32) << 8) | (chz as u32);
296                    let global_voxel = chunk_idx
297                        * IVec3::new(
298                            CHUNK_SIZE_XY as i32,
299                            CHUNK_SIZE_XY as i32,
300                            crate::CHUNK_SIZE_Z as i32,
301                        )
302                        + IVec3::new(10, 11, 12);
303                    scene
304                        .grid_mut(g1)
305                        .unwrap()
306                        .set_voxel(global_voxel, Some(color));
307                    expected.push((g1, chunk_idx, 10, 11, 12, color));
308                }
309            }
310        }
311        (scene, expected)
312    }
313
314    fn assert_voxels_match(scene: &Scene, expected: &[(GridId, IVec3, u32, u32, u32, u32)]) {
315        for &(grid_id, chunk_idx, vx, vy, vz, _color) in expected {
316            let grid = scene.grid(grid_id).expect("grid present");
317            let chunk = grid.chunk(chunk_idx).expect("chunk present");
318            assert!(
319                voxel_is_solid(chunk, vx, vy, vz),
320                "voxel ({vx},{vy},{vz}) in grid={} chunk={chunk_idx:?} not solid post-restore",
321                grid_id.raw()
322            );
323        }
324    }
325
326    #[test]
327    fn snapshot_round_trip_preserves_two_grid_100_chunk_scene() {
328        let (scene, expected) = build_two_grid_scene();
329        assert_eq!(scene.grid_count(), 2);
330        let total_chunks: usize = scene.grids().map(|(_, g)| g.chunks.len()).sum();
331        assert_eq!(total_chunks, 100, "test setup should produce 100 chunks");
332
333        // Round-trip via in-memory bincode.
334        let snap = scene.to_snapshot();
335        let bytes = bincode::serialize(&snap).expect("bincode serialize");
336        let snap_back: SceneSnapshot = bincode::deserialize(&bytes).expect("bincode deserialize");
337        let restored = Scene::from_snapshot(&snap_back).expect("restore");
338
339        // Same shape.
340        assert_eq!(restored.grid_count(), 2);
341        let total_restored: usize = restored.grids().map(|(_, g)| g.chunks.len()).sum();
342        assert_eq!(total_restored, 100);
343
344        // Same voxels.
345        assert_voxels_match(&restored, &expected);
346    }
347
348    #[test]
349    fn snapshot_preserves_next_grid_id_and_transforms() {
350        let mut scene = Scene::new();
351        let g0 = scene.add_grid(GridTransform::at(DVec3::new(10.0, 20.0, 30.0)));
352        let _g1 = scene.add_grid(GridTransform::at(DVec3::new(40.0, 50.0, 60.0)));
353        scene.remove_grid(g0); // bumps the gap
354        let _g2 = scene.add_grid(GridTransform::at(DVec3::new(70.0, 80.0, 90.0)));
355        // next_grid_id should be 3 now (g0=0, g1=1, g2=2).
356        let snap = scene.to_snapshot();
357        assert_eq!(snap.next_grid_id, 3);
358
359        let restored = Scene::from_snapshot(&snap).expect("restore");
360        assert_eq!(restored.grid_count(), 2);
361        // A new grid added to the restored scene should get id 3,
362        // not reuse the dropped id 0.
363        let mut restored_mut = restored;
364        let new_id = restored_mut.add_grid(GridTransform::identity());
365        assert_eq!(new_id.raw(), 3);
366    }
367
368    #[test]
369    fn restored_scene_is_editable() {
370        // The "/ mutate" half of "round-trip serialize / deserialize
371        // / mutate" — verify that a restored scene's chunks have
372        // edit capacity reserved so subsequent `set_voxel` doesn't
373        // panic.
374        let (scene, _) = build_two_grid_scene();
375        let snap = scene.to_snapshot();
376        let mut restored = Scene::from_snapshot(&snap).expect("restore");
377
378        let g0 = GridId::from_raw_for_test(0);
379        let new_voxel = IVec3::new(50, 51, 52);
380        restored
381            .grid_mut(g0)
382            .expect("grid 0 present")
383            .set_voxel(new_voxel, Some(0x80_de_ad_be));
384        let chunk = restored
385            .grid(g0)
386            .unwrap()
387            .chunk(IVec3::ZERO)
388            .expect("chunk created");
389        assert!(voxel_is_solid(chunk, 50, 51, 52));
390    }
391
392    // ---- S7.2: chunk_versions round-trip ----
393
394    #[test]
395    fn snapshot_round_trip_preserves_chunk_versions() {
396        // Build a scene whose chunk_versions are non-trivial (multi-
397        // edit on the same chunk + edits across multiple chunks),
398        // round-trip, and verify every version survives.
399        let mut scene = Scene::new();
400        let id = scene.add_grid(GridTransform::identity());
401        let g = scene.grid_mut(id).unwrap();
402        // Three edits on chunk (0,0,0) → version 3.
403        g.set_voxel(IVec3::new(0, 0, 0), Some(0x80_aa_bb_cc));
404        g.set_voxel(IVec3::new(1, 1, 1), Some(0x80_dd_ee_ff));
405        g.set_voxel(IVec3::new(2, 2, 2), Some(0x80_11_22_33));
406        // One edit on chunk (1,0,0) → version 1.
407        g.set_voxel(IVec3::new(128, 0, 0), Some(0x80_44_55_66));
408        assert_eq!(g.chunk_version(IVec3::ZERO), 3);
409        assert_eq!(g.chunk_version(IVec3::new(1, 0, 0)), 1);
410
411        let snap = scene.to_snapshot();
412        let bytes = bincode::serialize(&snap).expect("bincode serialize");
413        let snap_back: SceneSnapshot = bincode::deserialize(&bytes).expect("bincode deserialize");
414        let restored = Scene::from_snapshot(&snap_back).expect("restore");
415
416        let g = restored.grid(id).expect("grid present");
417        assert_eq!(g.chunk_version(IVec3::ZERO), 3);
418        assert_eq!(g.chunk_version(IVec3::new(1, 0, 0)), 1);
419        assert_eq!(g.chunk_versions.len(), 2);
420    }
421
422    #[test]
423    fn snapshot_chunk_versions_zero_entries_are_dropped_from_wire() {
424        // Implementation detail worth pinning: we don't waste bytes
425        // on chunks whose live counter is 0 (== absent semantically).
426        let mut scene = Scene::new();
427        let id = scene.add_grid(GridTransform::identity());
428        let g = scene.grid_mut(id).unwrap();
429        // Manually inject a zero entry — we don't have a public API
430        // to do this; reach into chunk_versions to verify the
431        // serialise-side filter behaves.
432        g.chunk_versions.insert(IVec3::ZERO, 0);
433        let snap = scene.to_snapshot();
434        let g_snap = &snap.grids[0].1;
435        assert!(g_snap.chunk_versions.is_empty(), "zero entries dropped");
436    }
437
438    #[test]
439    fn snapshot_is_deterministic() {
440        let (scene, _) = build_two_grid_scene();
441        let s1 = bincode::serialize(&scene.to_snapshot()).unwrap();
442        let s2 = bincode::serialize(&scene.to_snapshot()).unwrap();
443        assert_eq!(s1, s2, "snapshot bytes should be deterministic");
444    }
445}