Skip to main content

roxlap_scene/
streaming.rs

1//! Streaming + procedural-generation hooks.
2//!
3//! S7 of the scene-graph port (see `PORTING-SCENE.md` § S7). This
4//! module lands incrementally:
5//!
6//! - **S7.0** (this commit): the [`ChunkGenerator`] trait and the
7//!   synchronous [`Grid::ensure_chunk_generated`](crate::Grid::ensure_chunk_generated) helper. Generators
8//!   are plain `Box<dyn ChunkGenerator>` — no rayon, no channels,
9//!   no async dispatch yet.
10//! - S7.1: per-grid `StreamRadius { r_active, r_evict }` policy and
11//!   `Scene::pump_streaming_sync(camera)`.
12//! - S7.2: per-chunk version counter for the edit-vs-generate race.
13//! - S7.3: async dispatch through a dedicated rayon pool +
14//!   `crossbeam_channel`.
15//! - S7.4: render integration (pending-chunk reads, billboard cache
16//!   invalidation on stream-in).
17//! - S7.5: `roxlap-cavegen` adapter as the first concrete generator.
18//! - S7.6: streaming demo.
19//!
20//! The `Send + Sync` bound on [`ChunkGenerator`] is needed by S7.3
21//! but is cheap to require now — generators are typically stateless
22//! noise configs that already satisfy it.
23
24use std::fmt;
25
26use glam::{DVec3, IVec3};
27use roxlap_formats::vxl::Vxl;
28
29use crate::{CHUNK_SIZE_XY, CHUNK_SIZE_Z};
30
31/// Pluggable per-chunk procedural generator.
32///
33/// `Grid` instances optionally carry a `Box<dyn ChunkGenerator>`.
34/// When the streaming layer (or a direct
35/// [`Grid::ensure_chunk_generated`](crate::Grid::ensure_chunk_generated)
36/// call) needs a chunk that is not yet materialised, it asks the
37/// generator to produce one. The returned [`Vxl`] is moved into the
38/// grid's sparse chunk map at the requested index.
39///
40/// Generators are expected to be deterministic functions of
41/// `chunk_idx` plus their own configuration: calling `generate` with
42/// the same index twice should return equivalent chunks. This is
43/// what makes "evict + re-stream" sound under [`crate::Grid`]'s
44/// no-persistence default (see S7 scope brief, decision 5).
45///
46/// `Send + Sync` is required so S7.3 can dispatch generation onto a
47/// background rayon pool without per-call locking. `Debug` is
48/// required so [`crate::Grid`] can derive `Debug` while holding a
49/// `Box<dyn ChunkGenerator>`.
50pub trait ChunkGenerator: fmt::Debug + Send + Sync {
51    /// Produce the chunk at `chunk_idx`. Implementations should not
52    /// allocate or touch any state outside their own configuration
53    /// — running this from a background thread must be safe.
54    fn generate(&self, chunk_idx: IVec3) -> Vxl;
55
56    /// Per-chunk filter consulted by [`crate::Scene::pump_streaming`]
57    /// (+ the synchronous [`crate::Grid::ensure_chunk_generated`]
58    /// helper) before dispatching `generate`. Returning `false`
59    /// skips the chunk entirely — it never enters the grid's chunk
60    /// map and `origin_chunk_z` (etc.) reflect only the indices the
61    /// generator actually materialises.
62    ///
63    /// Used to avoid creating placeholder bedrock-only chunks for
64    /// layers the generator has no real content for (e.g. the
65    /// streaming-hills demo's `HillsChunkGenerator` declines
66    /// `chunk_idx.z != 0` so the camera can fly above the world
67    /// without triggering the S4B.6.j cross-chunk look-down
68    /// limitation).
69    ///
70    /// Default returns `true` — pre-fix behaviour, every dispatched
71    /// chunk gets generated.
72    fn should_generate(&self, _chunk_idx: IVec3) -> bool {
73        true
74    }
75}
76
77/// Pluggable persistence for **edited** streamed chunks (QE.5a).
78///
79/// The [`ChunkGenerator`] contract makes evict + re-stream sound for
80/// *pristine* chunks (generation is deterministic), but an edited
81/// chunk (`chunk_version != 0` — the player dug, built, or a bake
82/// wrote into it) would silently revert to generator output when the
83/// camera walks away and comes back. A `ChunkStore` closes that hole:
84///
85/// - **Eviction** ([`crate::Scene::pump_streaming`] /
86///   [`pump_streaming_sync`](crate::Scene::pump_streaming_sync)):
87///   every evicted chunk whose version is non-zero is handed to
88///   [`store`](Self::store) *before* it is dropped.
89/// - **Stream-in**: before running the generator for a missing chunk,
90///   the streaming layer asks [`load`](Self::load); a hit installs
91///   the stored chunk (with its persisted edit version) and the
92///   generator is not called. A stored chunk wins even where
93///   [`ChunkGenerator::should_generate`] declines the index (edits
94///   can materialise chunks the generator never would).
95///
96/// Implementations own the actual storage — an in-memory map, a
97/// save-file region, a database. `load` runs on the streaming pool's
98/// background threads under [`crate::Scene::pump_streaming`] (so
99/// blocking IO is fine there) but **inline** under the synchronous
100/// paths ([`pump_streaming_sync`](crate::Scene::pump_streaming_sync)
101/// / [`Grid::ensure_chunk_generated`](crate::Grid::ensure_chunk_generated));
102/// `store` always runs inline during the eviction pass — keep it
103/// cheap (e.g. queue bytes for a writer thread).
104///
105/// Note [`crate::Scene::to_snapshot`] serialises only *materialised*
106/// chunks — chunks living solely in the store are the host's to save
107/// alongside the snapshot.
108pub trait ChunkStore: fmt::Debug + Send + Sync {
109    /// Persist an edited chunk that is about to be evicted. `version`
110    /// is its [`crate::Grid::chunk_version`] (always non-zero here).
111    fn store(&self, chunk_idx: IVec3, vxl: &Vxl, version: u64);
112
113    /// Load a previously stored chunk. `Some((vxl, version))` installs
114    /// it (restoring `version` as the chunk's edit version) instead of
115    /// generating; `None` falls through to the generator.
116    fn load(&self, chunk_idx: IVec3) -> Option<(Vxl, u64)>;
117}
118
119/// Per-grid streaming activity / eviction radii (S7.1).
120///
121/// Both values are in **grid-local voxel units** — the same scale
122/// as a `GridLocalPos::voxel` coordinate. The math falls out
123/// cleanly from there: chunks span fixed integer voxel extents and
124/// the camera's grid-local position is also expressed in voxels.
125///
126/// Semantics inside [`crate::Scene::pump_streaming_sync`]:
127///
128/// - A chunk whose AABB-to-camera distance is `≤ r_active` MUST be
129///   loaded; if absent + a generator is attached, it gets streamed
130///   in via [`crate::Grid::ensure_chunk_generated`].
131/// - A chunk whose AABB-to-camera distance is `> r_evict` is
132///   dropped from the chunk map.
133/// - Chunks in the hysteresis band `(r_active, r_evict]` are
134///   neither streamed in nor evicted — they're left as-is. The
135///   gap prevents a camera oscillating near a boundary from
136///   thrashing generation + eviction.
137///
138/// The [`Default`] / [`Self::DISABLED`] value is `r_active = 0`,
139/// `r_evict = ∞`: [`crate::Scene::pump_streaming_sync`] is a no-op.
140/// Existing grids keep the pre-S7 "absent stays absent, present
141/// stays present" behaviour until a caller opts in.
142#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
143pub struct StreamRadius {
144    /// Chunks closer than this (grid-local voxels, AABB distance)
145    /// are streamed in.
146    pub r_active: f64,
147    /// Chunks farther than this (grid-local voxels, AABB distance)
148    /// are evicted. Must be `≥ r_active`.
149    pub r_evict: f64,
150}
151
152impl StreamRadius {
153    /// `r_active = 0`, `r_evict = ∞` — `pump_streaming_sync`
154    /// never streams a chunk in or evicts one. The default for
155    /// pre-S7.1 grids.
156    pub const DISABLED: Self = Self {
157        r_active: 0.0,
158        r_evict: f64::INFINITY,
159    };
160
161    /// New radius pair. Requires `r_evict >= r_active` so the
162    /// hysteresis band is well-formed (or empty when `==`).
163    ///
164    /// # Panics
165    ///
166    /// Panics if `r_evict < r_active`, if `r_active` is `NaN` or
167    /// negative, or if `r_evict` is negative. NaN and negative
168    /// radii are policy bugs — failing loud at construction beats
169    /// silently degenerating chunk-AABB tests later.
170    #[must_use]
171    pub fn new(r_active: f64, r_evict: f64) -> Self {
172        assert!(
173            r_evict >= r_active,
174            "StreamRadius: r_evict ({r_evict}) must be >= r_active ({r_active})"
175        );
176        assert!(
177            r_active.is_finite() && r_active >= 0.0,
178            "StreamRadius: r_active must be finite and >= 0, got {r_active}"
179        );
180        assert!(
181            r_evict >= 0.0,
182            "StreamRadius: r_evict must be >= 0, got {r_evict}"
183        );
184        Self { r_active, r_evict }
185    }
186
187    /// `true` for the [`Self::DISABLED`] sentinel pair. Lets
188    /// `pump_streaming_sync` skip the per-grid pass cheaply when
189    /// streaming is off.
190    #[must_use]
191    pub fn is_disabled(self) -> bool {
192        self.r_active == 0.0 && self.r_evict == f64::INFINITY
193    }
194}
195
196impl Default for StreamRadius {
197    fn default() -> Self {
198        Self::DISABLED
199    }
200}
201
202/// Squared distance from `p_local` (grid-local f64) to the AABB of
203/// the chunk at `chunk_idx`, also in grid-local voxel units.
204///
205/// The chunk at `(chx, chy, chz)` covers voxel-space
206/// `[chx*XY, (chx+1)*XY) × [chy*XY, (chy+1)*XY) × [chz*Z, (chz+1)*Z)`.
207/// Standard "clamp point to AABB then subtract" gives the closest
208/// point on the box; squared length avoids a sqrt per chunk in the
209/// streaming inner loop.
210///
211/// Returns `0.0` if `p_local` is inside the chunk.
212#[must_use]
213pub(crate) fn chunk_aabb_dist_sq(p_local: DVec3, chunk_idx: IVec3) -> f64 {
214    let sxy = f64::from(CHUNK_SIZE_XY);
215    let sz = f64::from(CHUNK_SIZE_Z);
216    let lo = DVec3::new(
217        f64::from(chunk_idx.x) * sxy,
218        f64::from(chunk_idx.y) * sxy,
219        f64::from(chunk_idx.z) * sz,
220    );
221    let hi = DVec3::new(lo.x + sxy, lo.y + sxy, lo.z + sz);
222    let dx = (lo.x - p_local.x).max(0.0).max(p_local.x - hi.x);
223    let dy = (lo.y - p_local.y).max(0.0).max(p_local.y - hi.y);
224    let dz = (lo.z - p_local.z).max(0.0).max(p_local.z - hi.z);
225    dx * dx + dy * dy + dz * dz
226}
227
228/// World-to-grid-local f64 transform — the same inverse-rotation
229/// path as [`crate::addr::world_to_grid_local`], but skipping the
230/// chunk + voxel + fract decomposition. Used by
231/// [`crate::Scene::pump_streaming_sync`] which only needs the
232/// continuous grid-local position to test chunk-AABB distances.
233#[must_use]
234pub(crate) fn world_to_grid_local_pos(world_pos: DVec3, transform: &crate::GridTransform) -> DVec3 {
235    transform.rotation.inverse() * (world_pos - transform.origin)
236}
237
238// ===========================================================
239// S7.3 async dispatch — native only.
240// ===========================================================
241//
242// On wasm32 the streaming pool / channel infrastructure is
243// cfg'd out and `Scene::pump_streaming` short-circuits to
244// `pump_streaming_sync` — there's no rayon `ThreadPool::build`
245// on `wasm32-unknown-unknown` without the wasm-bindgen-rayon
246// adapter, which is a binary-side concern that the scene crate
247// doesn't pull in.
248
249#[cfg(not(target_arch = "wasm32"))]
250pub(crate) use native::{ChunkResult, StreamingState};
251
252#[cfg(not(target_arch = "wasm32"))]
253mod native {
254    use super::*;
255    use crate::GridId;
256
257    /// One generator result carried back over the channel from a
258    /// rayon worker to the main thread (S7.3). Kept `pub(crate)`
259    /// — callers don't construct or inspect these directly.
260    pub(crate) struct ChunkResult {
261        pub grid_id: GridId,
262        pub chunk_idx: IVec3,
263        /// `Grid::chunk_version(chunk_idx)` at dispatch time. The
264        /// main thread compares against the live counter on
265        /// install; mismatch ⇒ an edit happened mid-generation,
266        /// discard the result.
267        pub version_at_dispatch: u64,
268        /// The produced chunk, or `None` when the task had nothing
269        /// to install (QE.5a: store miss + generator declined the
270        /// index) — the drain then just clears the pending entry.
271        pub vxl: Option<Vxl>,
272        /// QE.5a — set when `vxl` came from the [`ChunkStore`]: the
273        /// persisted edit version to restore on install.
274        pub restored_version: Option<u64>,
275    }
276
277    /// Per-scene streaming state: dedicated rayon `ThreadPool` plus
278    /// the `crossbeam_channel` inbox the background tasks send
279    /// results into. One `StreamingState` lives on each
280    /// [`crate::Scene`].
281    ///
282    /// The pool is built lazily on first dispatch — a scene that
283    /// only ever uses [`crate::Scene::pump_streaming_sync`] pays
284    /// no thread-pool overhead.
285    pub(crate) struct StreamingState {
286        pub thread_count: usize,
287        pub pool: Option<rayon::ThreadPool>,
288        pub tx: crossbeam_channel::Sender<ChunkResult>,
289        pub rx: crossbeam_channel::Receiver<ChunkResult>,
290    }
291
292    impl Default for StreamingState {
293        fn default() -> Self {
294            // Unbounded so a slow drain (e.g. a frame stall in
295            // pump_streaming) doesn't block rayon workers on send.
296            // Inbox lifetime is bounded by pending_gen — there
297            // can't be more in-flight messages than there are
298            // chunks in pending_gen sets across all grids.
299            let (tx, rx) = crossbeam_channel::unbounded();
300            Self {
301                thread_count: 2,
302                pool: None,
303                tx,
304                rx,
305            }
306        }
307    }
308
309    impl std::fmt::Debug for StreamingState {
310        // Intentionally elides the channel + pool internals — they
311        // print noisily and add nothing over the summary booleans.
312        // `finish_non_exhaustive` signals that omission to clippy.
313        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
314            f.debug_struct("StreamingState")
315                .field("thread_count", &self.thread_count)
316                .field("pool_built", &self.pool.is_some())
317                .finish_non_exhaustive()
318        }
319    }
320
321    impl StreamingState {
322        /// Lazily construct the pool with the current
323        /// `thread_count`. Idempotent — second call is a no-op
324        /// when the pool already exists.
325        ///
326        /// # Panics
327        /// Panics if rayon fails to build the pool (typically OS
328        /// thread-allocation failure).
329        pub fn ensure_pool(&mut self) {
330            if self.pool.is_none() {
331                let pool = rayon::ThreadPoolBuilder::new()
332                    .num_threads(self.thread_count)
333                    .thread_name(|i| format!("roxlap-stream-{i}"))
334                    .build()
335                    .expect("rayon ThreadPoolBuilder");
336                self.pool = Some(pool);
337            }
338        }
339
340        /// Update the desired thread count. If the pool was already
341        /// built, drops the old pool (which blocks until in-flight
342        /// tasks finish — rayon's standard contract) and rebuilds
343        /// with the new count on the next [`Self::ensure_pool`]
344        /// call. The channel survives the rebuild so any results
345        /// the old pool's tasks managed to send before drop are
346        /// still drained by the next pump.
347        ///
348        /// No-op when the new count matches the current one.
349        ///
350        /// # Panics
351        /// Panics if `n == 0`.
352        pub fn set_thread_count(&mut self, n: usize) {
353            assert!(n > 0, "streaming thread count must be >= 1");
354            if self.thread_count == n {
355                return;
356            }
357            self.thread_count = n;
358            self.pool = None;
359        }
360    }
361}
362
363#[cfg(test)]
364pub(crate) mod tests {
365    use super::*;
366    use crate::chunks::tests::voxel_is_solid;
367    use crate::{Grid, GridTransform, Scene, CHUNK_SIZE_XY, CHUNK_SIZE_Z};
368    use glam::DQuat;
369    use roxlap_formats::edit::{set_spans, Vspan};
370    use std::sync::atomic::{AtomicUsize, Ordering};
371    use std::sync::Arc;
372
373    /// Test-only generator that stamps a chunk-idx-derived solid pad
374    /// (one voxel at local origin) into an otherwise air chunk, and
375    /// counts how many times `generate` was called.
376    ///
377    /// The count lets us assert idempotency: `ensure_chunk_generated`
378    /// must not invoke the generator a second time once the chunk is
379    /// materialised.
380    #[derive(Debug)]
381    pub(crate) struct StubGenerator {
382        pub call_count: Arc<AtomicUsize>,
383    }
384
385    impl StubGenerator {
386        pub(crate) fn new() -> Self {
387            Self {
388                call_count: Arc::new(AtomicUsize::new(0)),
389            }
390        }
391    }
392
393    impl ChunkGenerator for StubGenerator {
394        fn generate(&self, chunk_idx: IVec3) -> Vxl {
395            self.call_count.fetch_add(1, Ordering::Relaxed);
396            // Build a fresh all-air chunk by stamping one voxel via
397            // the same path as `chunks::empty_chunk_vxl`, then
398            // carving everything except `(0, 0, chunk_idx.x as u8)`
399            // — gives us a chunk-idx-distinguishable signature
400            // without duplicating the empty-chunk builder.
401            let mut g = Grid::new(GridTransform::identity());
402            let mark_z = (chunk_idx.x.rem_euclid(200) as u32) % CHUNK_SIZE_Z;
403            // ensure_chunk creates a stock all-air chunk; we then
404            // stamp one voxel and detach the chunk.
405            g.ensure_chunk(IVec3::ZERO);
406            let vxl = g.chunks.remove(&IVec3::ZERO).expect("just inserted");
407            let mut vxl = vxl;
408            // Stamp one voxel at (0, 0, mark_z) so each chunk has a
409            // unique geometric fingerprint.
410            set_spans(
411                &mut vxl,
412                &[Vspan {
413                    x: 0,
414                    y: 0,
415                    z0: u8::try_from(mark_z).unwrap_or(0),
416                    z1: u8::try_from(mark_z).unwrap_or(0),
417                }],
418                Some(0x80_aa_bb_cc),
419            );
420            vxl
421        }
422    }
423
424    #[test]
425    fn stub_generator_emits_distinguishable_chunks() {
426        // Direct sanity check on the generator before we test the
427        // helper. Two different chunk indices must produce
428        // distinguishable voxel content.
429        let gen = StubGenerator::new();
430        let a = gen.generate(IVec3::new(0, 0, 0));
431        let b = gen.generate(IVec3::new(7, 0, 0));
432        assert_eq!(a.vsid, CHUNK_SIZE_XY);
433        assert_eq!(b.vsid, CHUNK_SIZE_XY);
434        assert!(voxel_is_solid(&a, 0, 0, 0), "chunk_idx.x=0 marks z=0");
435        assert!(voxel_is_solid(&b, 0, 0, 7), "chunk_idx.x=7 marks z=7");
436        assert_eq!(gen.call_count.load(Ordering::Relaxed), 2);
437    }
438
439    #[test]
440    fn ensure_chunk_generated_populates_via_generator() {
441        let mut g = Grid::new(GridTransform::identity());
442        let gen = StubGenerator::new();
443        let counter = Arc::clone(&gen.call_count);
444        g.set_generator(Some(Arc::new(gen)));
445
446        assert_eq!(g.chunk_count(), 0);
447        let idx = IVec3::new(3, 0, 0);
448        let produced = g.ensure_chunk_generated(idx);
449        assert!(
450            produced,
451            "ensure_chunk_generated returns true when it generates"
452        );
453        assert_eq!(g.chunk_count(), 1);
454        let chunk = g.chunk(idx).expect("chunk now present");
455        assert!(
456            voxel_is_solid(chunk, 0, 0, 3),
457            "stub generator's mark voxel for chunk_idx.x=3 missing"
458        );
459        assert_eq!(counter.load(Ordering::Relaxed), 1);
460    }
461
462    #[test]
463    fn ensure_chunk_generated_is_idempotent() {
464        // Re-calling on an already-materialised chunk must not invoke
465        // the generator again — the chunk's existing content stays,
466        // and the call count stays at 1.
467        let mut g = Grid::new(GridTransform::identity());
468        let gen = StubGenerator::new();
469        let counter = Arc::clone(&gen.call_count);
470        g.set_generator(Some(Arc::new(gen)));
471
472        let idx = IVec3::new(5, -2, 0);
473        assert!(g.ensure_chunk_generated(idx));
474        assert!(!g.ensure_chunk_generated(idx), "second call no-ops");
475        assert!(!g.ensure_chunk_generated(idx), "third call still no-ops");
476        assert_eq!(g.chunk_count(), 1);
477        assert_eq!(counter.load(Ordering::Relaxed), 1);
478    }
479
480    #[test]
481    fn ensure_chunk_generated_without_generator_is_noop() {
482        // A grid with no generator must leave a missing chunk
483        // missing — no implicit empty-chunk allocation, since that
484        // would conflict with the "implicit air" interpretation of
485        // absent chunk-map entries.
486        let mut g = Grid::new(GridTransform::identity());
487        let idx = IVec3::new(0, 0, 0);
488        assert!(g.generator.is_none());
489        let produced = g.ensure_chunk_generated(idx);
490        assert!(!produced, "no generator → no chunk generated");
491        assert_eq!(g.chunk_count(), 0);
492        assert!(g.chunk(idx).is_none());
493    }
494
495    #[test]
496    fn ensure_chunk_generated_on_already_present_chunk_skips_generator() {
497        // If the chunk was created via the edit API (ensure_chunk +
498        // set_voxel) before the generator was attached, a later
499        // ensure_chunk_generated call must not overwrite it with
500        // procedurally-generated content.
501        let mut g = Grid::new(GridTransform::identity());
502        let idx = IVec3::new(0, 0, 0);
503        // Stamp a manual voxel at chunk-local (10, 10, 10).
504        g.set_voxel(IVec3::new(10, 10, 10), Some(0x80_11_22_33));
505        assert_eq!(g.chunk_count(), 1);
506
507        let gen = StubGenerator::new();
508        let counter = Arc::clone(&gen.call_count);
509        g.set_generator(Some(Arc::new(gen)));
510
511        let produced = g.ensure_chunk_generated(idx);
512        assert!(!produced, "existing chunk not regenerated");
513        assert_eq!(counter.load(Ordering::Relaxed), 0);
514        // Manual voxel still there; stub's signature voxel absent.
515        let chunk = g.chunk(idx).expect("manual chunk present");
516        assert!(voxel_is_solid(chunk, 10, 10, 10), "manual voxel survived");
517        assert!(
518            !voxel_is_solid(chunk, 0, 0, 0),
519            "generator's mark voxel must NOT appear"
520        );
521    }
522
523    // ---- S7.1: StreamRadius + Scene::pump_streaming_sync ----
524
525    #[test]
526    fn stream_radius_disabled_is_truly_zero_infty() {
527        let r = StreamRadius::DISABLED;
528        assert_eq!(r.r_active, 0.0);
529        assert!(r.r_evict.is_infinite() && r.r_evict.is_sign_positive());
530        assert!(r.is_disabled());
531        assert!(StreamRadius::default().is_disabled());
532    }
533
534    #[test]
535    fn stream_radius_new_rejects_evict_below_active() {
536        // Guard against accidental "r_evict < r_active" configs that
537        // would evict eagerly + re-stream the same chunk every pump.
538        let result = std::panic::catch_unwind(|| StreamRadius::new(200.0, 100.0));
539        assert!(result.is_err(), "r_evict < r_active must panic");
540    }
541
542    #[test]
543    fn chunk_aabb_dist_sq_inside_chunk_is_zero() {
544        // Camera at (10, 20, 30) — well inside chunk (0, 0, 0)
545        // which covers x,y in [0, 128) and z in [0, 256).
546        let d = chunk_aabb_dist_sq(DVec3::new(10.0, 20.0, 30.0), IVec3::new(0, 0, 0));
547        assert_eq!(d, 0.0);
548    }
549
550    #[test]
551    fn chunk_aabb_dist_sq_axis_aligned() {
552        // Camera at (0, 0, 0). Chunk (1, 0, 0) starts at x=128; nearest
553        // point on its AABB is (128, 0, 0); squared distance 128² = 16384.
554        let d = chunk_aabb_dist_sq(DVec3::ZERO, IVec3::new(1, 0, 0));
555        let expected = 128.0_f64.powi(2);
556        assert!((d - expected).abs() < 1e-9, "got {d}, want {expected}");
557        // Chunk (0, 0, 1) — nearest face at z=256.
558        let d = chunk_aabb_dist_sq(DVec3::ZERO, IVec3::new(0, 0, 1));
559        let expected = 256.0_f64.powi(2);
560        assert!((d - expected).abs() < 1e-9, "got {d}, want {expected}");
561    }
562
563    #[test]
564    fn pump_streaming_sync_with_disabled_radius_is_noop() {
565        // Disabled (default) → never generates, never evicts. This is
566        // the byte-stability guarantee for any pre-S7.1 caller.
567        let mut scene = Scene::new();
568        let id = scene.add_grid(GridTransform::identity());
569        let gen = StubGenerator::new();
570        let counter = Arc::clone(&gen.call_count);
571        scene
572            .grid_mut(id)
573            .unwrap()
574            .set_generator(Some(Arc::new(gen)));
575        // Stamp a far-away chunk that would be evicted under any
576        // finite r_evict.
577        scene
578            .grid_mut(id)
579            .unwrap()
580            .set_voxel(IVec3::new(10_000, 0, 0), Some(0x80_11_22_33));
581        let baseline_chunks = scene.grid(id).unwrap().chunk_count();
582        scene.pump_streaming_sync(DVec3::ZERO);
583        assert_eq!(scene.grid(id).unwrap().chunk_count(), baseline_chunks);
584        assert_eq!(counter.load(Ordering::Relaxed), 0);
585    }
586
587    #[test]
588    fn pump_streaming_sync_streams_in_chunks_within_r_active() {
589        // r_active = 200 voxels covers chunk (0,0,0) (origin) plus the
590        // ring of XY neighbours whose nearest face lies within 200 of
591        // origin. Chunks at chx ±1 are 128 voxels away (within); chunks
592        // at chx ±2 are 256 voxels away (just outside).
593        let mut scene = Scene::new();
594        let id = scene.add_grid(GridTransform::identity());
595        let gen = StubGenerator::new();
596        let counter = Arc::clone(&gen.call_count);
597        let g = scene.grid_mut(id).unwrap();
598        g.set_generator(Some(Arc::new(gen)));
599        g.stream_radius = StreamRadius::new(200.0, 400.0);
600        scene.pump_streaming_sync(DVec3::ZERO);
601
602        // Camera at world origin → grid-local (0, 0, 0). chz coverage:
603        // chunk (0,0,0) Z-AABB is [0, 256); chunk (0,0,-1) Z-AABB is
604        // [-256, 0). Both touch the camera point → both must stream
605        // in. (0,0,1) is at z=256, more than 200 away → must NOT.
606        let g = scene.grid(id).unwrap();
607        let must_have = [
608            IVec3::new(0, 0, 0),
609            IVec3::new(1, 0, 0),
610            IVec3::new(-1, 0, 0),
611            IVec3::new(0, 1, 0),
612            IVec3::new(0, -1, 0),
613            IVec3::new(0, 0, -1),
614        ];
615        for idx in must_have {
616            assert!(
617                g.chunks.contains_key(&idx),
618                "chunk {idx:?} missing from streamed set"
619            );
620        }
621        // Diagonals at chunk (1,1,0): AABB nearest = (128,128,0);
622        // dist = sqrt(128² + 128²) ≈ 181.0 < 200 → must be streamed.
623        assert!(g.chunks.contains_key(&IVec3::new(1, 1, 0)));
624        // Chunk (2, 0, 0): nearest face at x=256, dist=256 > 200.
625        assert!(!g.chunks.contains_key(&IVec3::new(2, 0, 0)));
626        // Camera chz coverage: (0,0,1) at z=256 > 200 → out.
627        assert!(!g.chunks.contains_key(&IVec3::new(0, 0, 1)));
628
629        // Counter equals number of streamed chunks.
630        let streamed = g.chunk_count();
631        assert_eq!(counter.load(Ordering::Relaxed), streamed);
632    }
633
634    #[test]
635    fn pump_streaming_sync_idempotent_under_stationary_camera() {
636        // Second pump at the same position must NOT regenerate any
637        // already-loaded chunk — counter stays at the post-first-pump
638        // value.
639        let mut scene = Scene::new();
640        let id = scene.add_grid(GridTransform::identity());
641        let gen = StubGenerator::new();
642        let counter = Arc::clone(&gen.call_count);
643        let g = scene.grid_mut(id).unwrap();
644        g.set_generator(Some(Arc::new(gen)));
645        g.stream_radius = StreamRadius::new(180.0, 400.0);
646
647        scene.pump_streaming_sync(DVec3::ZERO);
648        let after_first = counter.load(Ordering::Relaxed);
649        scene.pump_streaming_sync(DVec3::ZERO);
650        let after_second = counter.load(Ordering::Relaxed);
651        assert_eq!(after_first, after_second, "second pump regenerated chunks");
652    }
653
654    #[test]
655    fn pump_streaming_sync_evicts_chunks_beyond_r_evict() {
656        // Stream chunks within r_active=200; then move the camera far
657        // away and verify r_evict trims the now-distant set.
658        let mut scene = Scene::new();
659        let id = scene.add_grid(GridTransform::identity());
660        let gen = StubGenerator::new();
661        let g = scene.grid_mut(id).unwrap();
662        g.set_generator(Some(Arc::new(gen)));
663        g.stream_radius = StreamRadius::new(200.0, 400.0);
664        scene.pump_streaming_sync(DVec3::ZERO);
665        let initial = scene.grid(id).unwrap().chunk_count();
666        assert!(initial > 0, "expected chunks streamed in around origin");
667
668        // Teleport camera ~10_000 voxels along +x; every chunk near
669        // the old origin is now > r_evict (400) away.
670        scene.pump_streaming_sync(DVec3::new(10_000.0, 0.0, 0.0));
671        let g = scene.grid(id).unwrap();
672        for idx in [
673            IVec3::new(0, 0, 0),
674            IVec3::new(1, 0, 0),
675            IVec3::new(-1, 0, 0),
676        ] {
677            assert!(
678                !g.chunks.contains_key(&idx),
679                "chunk {idx:?} survived eviction after far teleport"
680            );
681        }
682        // New chunks around the new camera position must exist.
683        // Camera at x=10_000 → chunk chx = 10_000 / 128 ≈ 78.
684        let cam_chx = 10_000_i32 / i32::try_from(CHUNK_SIZE_XY).unwrap();
685        assert!(g.chunks.contains_key(&IVec3::new(cam_chx, 0, 0)));
686    }
687
688    #[test]
689    fn pump_streaming_sync_hysteresis_band_retains_chunks() {
690        // r_active = 200, r_evict = 600. A chunk that's currently
691        // present and lives in the band (200 < d <= 600) is neither
692        // streamed in (it's already present) nor evicted (within
693        // r_evict). Move just past r_active and check the chunks that
694        // were inside the now-shrunk active set stay loaded.
695        let mut scene = Scene::new();
696        let id = scene.add_grid(GridTransform::identity());
697        let gen = StubGenerator::new();
698        let g = scene.grid_mut(id).unwrap();
699        g.set_generator(Some(Arc::new(gen)));
700        g.stream_radius = StreamRadius::new(200.0, 600.0);
701        scene.pump_streaming_sync(DVec3::ZERO);
702        let g = scene.grid(id).unwrap();
703        // A chunk at (1, 0, 0) is 128 voxels away — well inside
704        // r_active. After bumping the camera to (300, 0, 0), nearest
705        // face of (1, 0, 0) is x=256 → dist = max(0, 300-256) = 44 <
706        // r_active, still inside r_active so it would stream in again
707        // anyway. We need a chunk we KNOW will fall in the
708        // hysteresis band. (-2, 0, 0): AABB nearest face x=-128;
709        // initial dist at cam (0,0,0) = 128 (inside r_active). After
710        // pump #1, present. After cam → (300, 0, 0): dist = 300 - (-128)
711        // = 428 → in band (200, 600]. Must stay.
712        let band_idx = IVec3::new(-2, 0, 0);
713        // First, confirm (-2, 0, 0) was actually streamed in (its dist
714        // from origin is min(128, 256) = 128 along x → 128 < 200).
715        assert!(
716            g.chunks.contains_key(&band_idx),
717            "(-2, 0, 0) should be streamed at origin"
718        );
719
720        scene.pump_streaming_sync(DVec3::new(300.0, 0.0, 0.0));
721        let g = scene.grid(id).unwrap();
722        assert!(
723            g.chunks.contains_key(&band_idx),
724            "(-2, 0, 0) should remain in the hysteresis band"
725        );
726    }
727
728    #[test]
729    fn pump_streaming_sync_with_no_generator_does_not_panic() {
730        // r_active > 0 but no generator: pump must skip the stream-in
731        // pass cleanly and still run the evict pass. Use a manually-
732        // edited chunk that's far away to verify eviction still works.
733        let mut scene = Scene::new();
734        let id = scene.add_grid(GridTransform::identity());
735        let g = scene.grid_mut(id).unwrap();
736        g.stream_radius = StreamRadius::new(200.0, 400.0);
737        // Manual chunk at (50, 0, 0): grid-local x=50*128 = 6400, far
738        // outside r_evict from origin.
739        g.set_voxel(IVec3::new(50 * 128, 0, 0), Some(0x80_aa_bb_cc));
740        assert_eq!(scene.grid(id).unwrap().chunk_count(), 1);
741
742        scene.pump_streaming_sync(DVec3::ZERO);
743        let g = scene.grid(id).unwrap();
744        // Stream-in pass was a no-op (no generator); evict pass
745        // dropped the far chunk.
746        assert_eq!(g.chunk_count(), 0);
747    }
748
749    #[test]
750    fn pump_streaming_sync_respects_grid_rotation() {
751        // Place a grid rotated 180° around Z. World camera at
752        // (+10, 0, 0) maps to grid-local (-10, 0, 0). The streamed
753        // chunk set must reflect that — chunk (-1, 0, 0) must be
754        // present (grid-local x=-10 falls inside (-128, 0]).
755        let transform = GridTransform {
756            origin: DVec3::ZERO,
757            rotation: DQuat::from_axis_angle(DVec3::Z, std::f64::consts::PI),
758        };
759        let mut scene = Scene::new();
760        let id = scene.add_grid(transform);
761        let gen = StubGenerator::new();
762        let g = scene.grid_mut(id).unwrap();
763        g.set_generator(Some(Arc::new(gen)));
764        g.stream_radius = StreamRadius::new(50.0, 200.0);
765
766        // World camera at (+10, 0, 0). After inverse 180°-Z, grid-local
767        // is (-10, 0, 0).
768        scene.pump_streaming_sync(DVec3::new(10.0, 0.0, 0.0));
769        let g = scene.grid(id).unwrap();
770        // Camera grid-local x = -10 → camera chunk chx = floor(-10/128) = -1.
771        // Chunk (-1, 0, 0) AABB nearest x lies in [-128, 0); camera in
772        // it → dist 0 → must be streamed.
773        assert!(
774            g.chunks.contains_key(&IVec3::new(-1, 0, 0)),
775            "rotation not applied — camera should map to chunk (-1, 0, 0)"
776        );
777        // Chunk (0, 0, 0) starts at x=0; camera at x=-10 → dist=10 <
778        // r_active → also streamed.
779        assert!(g.chunks.contains_key(&IVec3::new(0, 0, 0)));
780    }
781
782    // ---- S7.2: chunk_version interplay with streaming ----
783
784    #[test]
785    fn ensure_chunk_generated_does_not_bump_version() {
786        // A freshly-generated chunk has no user edits → version 0.
787        let mut g = Grid::new(GridTransform::identity());
788        let gen = StubGenerator::new();
789        g.set_generator(Some(Arc::new(gen)));
790        let idx = IVec3::new(2, 3, 0);
791        assert!(g.ensure_chunk_generated(idx));
792        assert_eq!(g.chunk_version(idx), 0);
793        // chunk_versions doesn't grow either.
794        assert!(g.chunk_versions.is_empty());
795    }
796
797    #[test]
798    fn ensure_chunk_generated_then_edit_starts_at_version_one() {
799        // Generator install + a single edit → version 1 (not 2).
800        let mut g = Grid::new(GridTransform::identity());
801        let gen = StubGenerator::new();
802        g.set_generator(Some(Arc::new(gen)));
803        let idx = IVec3::ZERO;
804        g.ensure_chunk_generated(idx);
805        g.set_voxel(IVec3::new(10, 10, 10), Some(0x80_aa_bb_cc));
806        assert_eq!(g.chunk_version(idx), 1);
807    }
808
809    #[test]
810    fn pump_streaming_sync_eviction_drops_chunk_version_entry() {
811        // Edit a chunk (version bumps to 1), then evict it via the
812        // streaming pump. The chunk_versions map entry must also be
813        // dropped so the map stays bounded.
814        let mut scene = Scene::new();
815        let id = scene.add_grid(GridTransform::identity());
816        let g = scene.grid_mut(id).unwrap();
817        g.set_voxel(IVec3::new(0, 0, 0), Some(0x80_aa_bb_cc));
818        assert_eq!(g.chunk_version(IVec3::ZERO), 1);
819        g.stream_radius = StreamRadius::new(10.0, 50.0);
820
821        scene.pump_streaming_sync(DVec3::new(10_000.0, 0.0, 0.0));
822        let g = scene.grid(id).unwrap();
823        assert_eq!(g.chunk_count(), 0, "chunk should have been evicted");
824        assert_eq!(
825            g.chunk_version(IVec3::ZERO),
826            0,
827            "version entry should be cleared on eviction"
828        );
829        assert!(g.chunk_versions.is_empty(), "map should be empty");
830    }
831
832    // ---- S7.3: async pump_streaming ----
833
834    /// Test-only generator that pauses inside `generate` until the
835    /// test explicitly releases it. Two-channel design:
836    ///
837    /// - `arrival_tx`: each task signals "I'm running" with its
838    ///   chunk_idx before it blocks. Lets the test wait for the
839    ///   dispatcher to have actually scheduled work without
840    ///   sleeping.
841    /// - `release_rx`: each task blocks on `recv()` here until the
842    ///   test sends a `()` (or drops the matching `release_tx`,
843    ///   which unblocks all pending tasks via `Err` return — the
844    ///   safety net so a panicking test doesn't deadlock on
845    ///   `Scene::drop` waiting for blocked tasks to finish).
846    #[derive(Debug)]
847    #[cfg(not(target_arch = "wasm32"))]
848    struct BlockingGenerator {
849        arrival_tx: crossbeam_channel::Sender<IVec3>,
850        release_rx: crossbeam_channel::Receiver<()>,
851        call_count: Arc<AtomicUsize>,
852    }
853
854    #[cfg(not(target_arch = "wasm32"))]
855    impl ChunkGenerator for BlockingGenerator {
856        fn generate(&self, chunk_idx: IVec3) -> Vxl {
857            self.call_count.fetch_add(1, Ordering::Relaxed);
858            let _ = self.arrival_tx.send(chunk_idx);
859            // recv returns Err if the matching Sender was dropped
860            // (e.g. test panicked / ended); still produce a chunk
861            // so the rayon pool's drop doesn't hang.
862            let _ = self.release_rx.recv();
863            StubGenerator::new().generate(chunk_idx)
864        }
865    }
866
867    /// Convenience: pump_streaming in a spin-loop until `grid`'s
868    /// `pending_gen` is empty, releasing all gates as we go.
869    /// Panics on a 5-second timeout — generation tasks shouldn't
870    /// take that long even on the slowest CI.
871    #[cfg(not(target_arch = "wasm32"))]
872    fn pump_until_idle(
873        scene: &mut Scene,
874        cam: DVec3,
875        grid_id: crate::GridId,
876        release_tx: Option<&crossbeam_channel::Sender<()>>,
877    ) {
878        use std::time::{Duration, Instant};
879        let deadline = Instant::now() + Duration::from_secs(5);
880        loop {
881            scene.pump_streaming(cam);
882            let idle = scene
883                .grid(grid_id)
884                .map_or(true, |g| g.pending_gen.is_empty());
885            if idle {
886                return;
887            }
888            if Instant::now() > deadline {
889                panic!("pump_until_idle: timeout with pending tasks");
890            }
891            // Release any blocked tasks so they can complete.
892            if let Some(tx) = release_tx {
893                let _ = tx.try_send(());
894            }
895            std::thread::sleep(Duration::from_millis(1));
896        }
897    }
898
899    // ---- S7.4: stream-in clears billboards cache ----
900
901    #[test]
902    fn ensure_chunk_generated_invalidates_billboard_cache() {
903        // Sync stream-in: a populated cache must be cleared when
904        // a generator installs a new chunk — the bounding sphere
905        // may have grown.
906        let mut g = Grid::new(GridTransform::identity());
907        let gen = StubGenerator::new();
908        g.set_generator(Some(Arc::new(gen)));
909        g.billboards = Some(crate::BillboardCache::new_empty(32));
910
911        let installed = g.ensure_chunk_generated(IVec3::new(2, 0, 0));
912        assert!(installed, "generator should have installed the chunk");
913        assert!(
914            g.billboards.is_none(),
915            "ensure_chunk_generated must clear billboards on install"
916        );
917    }
918
919    #[test]
920    fn ensure_chunk_generated_noop_preserves_billboard_cache() {
921        // No-install paths (no generator, already-present) must
922        // NOT clear the cache — there was no bounding-sphere
923        // change to invalidate.
924        let mut g = Grid::new(GridTransform::identity());
925        g.set_voxel(IVec3::new(0, 0, 0), Some(0x80_aa_bb_cc));
926        g.billboards = Some(crate::BillboardCache::new_empty(32));
927        // No generator → no install → cache stays.
928        let installed = g.ensure_chunk_generated(IVec3::new(5, 5, 0));
929        assert!(!installed);
930        assert!(
931            g.billboards.is_some(),
932            "no-generator no-op must not clear billboards"
933        );
934        // Already-present chunk → no install → cache stays.
935        let installed = g.ensure_chunk_generated(IVec3::ZERO);
936        assert!(!installed);
937        assert!(
938            g.billboards.is_some(),
939            "already-present chunk must not clear billboards"
940        );
941    }
942
943    #[test]
944    #[cfg(not(target_arch = "wasm32"))]
945    fn pump_streaming_async_install_invalidates_billboard_cache() {
946        // Async path: pump_streaming installs chunks via the drain;
947        // each install must clear the cache so the next Far render
948        // rebuilds with the new chunk set.
949        let mut scene = Scene::new();
950        let id = scene.add_grid(GridTransform::identity());
951        let gen = StubGenerator::new();
952        let g = scene.grid_mut(id).unwrap();
953        g.set_generator(Some(Arc::new(gen)));
954        g.stream_radius = StreamRadius::new(10.0, 200.0);
955        // Stamp a sentinel cache before pumping.
956        g.billboards = Some(crate::BillboardCache::new_empty(32));
957        let cam = DVec3::new(64.0, 64.0, 128.0);
958
959        pump_until_idle(&mut scene, cam, id, None);
960
961        let g = scene.grid(id).unwrap();
962        assert!(g.chunks.contains_key(&IVec3::ZERO), "chunk installed");
963        assert!(
964            g.billboards.is_none(),
965            "async install must clear billboards"
966        );
967    }
968
969    #[test]
970    #[cfg(not(target_arch = "wasm32"))]
971    fn pump_streaming_no_install_preserves_billboard_cache() {
972        // Pump with no missing chunks (all already present) must
973        // NOT clear billboards. Set up: stream chunks in sync,
974        // populate cache, pump again with same camera + same
975        // r_active → drain has nothing → cache survives.
976        let mut scene = Scene::new();
977        let id = scene.add_grid(GridTransform::identity());
978        let g = scene.grid_mut(id).unwrap();
979        let gen = StubGenerator::new();
980        g.set_generator(Some(Arc::new(gen)));
981        g.stream_radius = StreamRadius::new(10.0, 200.0);
982        let cam = DVec3::new(64.0, 64.0, 128.0);
983        // First pump: streams in chunk(s).
984        pump_until_idle(&mut scene, cam, id, None);
985        // Stamp cache.
986        scene.grid_mut(id).unwrap().billboards = Some(crate::BillboardCache::new_empty(32));
987        // Second pump at same camera: no new chunks installed.
988        scene.pump_streaming(cam);
989        let g = scene.grid(id).unwrap();
990        assert!(
991            g.billboards.is_some(),
992            "pump with no install should not clear billboards"
993        );
994    }
995
996    #[test]
997    #[cfg(not(target_arch = "wasm32"))]
998    fn pump_streaming_dispatches_and_installs_via_async_path() {
999        // Happy path: set up a fast (non-blocking) generator,
1000        // configure r_active, call pump_streaming, spin until
1001        // idle, verify chunks installed.
1002        let mut scene = Scene::new();
1003        let id = scene.add_grid(GridTransform::identity());
1004        let gen = StubGenerator::new();
1005        let counter = Arc::clone(&gen.call_count);
1006        let g = scene.grid_mut(id).unwrap();
1007        g.set_generator(Some(Arc::new(gen)));
1008        // Camera inside chunk (0,0,0) far from edges so only one
1009        // chunk hits the r_active=10 ball.
1010        g.stream_radius = StreamRadius::new(10.0, 200.0);
1011        let cam = DVec3::new(64.0, 64.0, 128.0);
1012
1013        pump_until_idle(&mut scene, cam, id, None);
1014
1015        let g = scene.grid(id).unwrap();
1016        assert!(g.chunks.contains_key(&IVec3::ZERO), "chunk installed");
1017        assert_eq!(counter.load(Ordering::Relaxed), 1, "generator called once");
1018        assert!(g.pending_gen.is_empty(), "no leftover pending");
1019    }
1020
1021    #[test]
1022    #[cfg(not(target_arch = "wasm32"))]
1023    fn pump_streaming_tracks_in_flight_chunks_in_pending_gen() {
1024        // Verify pending_gen reflects in-flight async tasks: after
1025        // dispatch, pending_gen contains the chunk; after release
1026        // + drain, it's empty.
1027        let (arrival_tx, arrival_rx) = crossbeam_channel::unbounded();
1028        let (release_tx, release_rx) = crossbeam_channel::unbounded();
1029        let counter = Arc::new(AtomicUsize::new(0));
1030        let gen = BlockingGenerator {
1031            arrival_tx,
1032            release_rx,
1033            call_count: Arc::clone(&counter),
1034        };
1035
1036        let mut scene = Scene::new();
1037        let id = scene.add_grid(GridTransform::identity());
1038        let g = scene.grid_mut(id).unwrap();
1039        g.set_generator(Some(Arc::new(gen)));
1040        g.stream_radius = StreamRadius::new(10.0, 200.0);
1041        let cam = DVec3::new(64.0, 64.0, 128.0);
1042
1043        scene.pump_streaming(cam);
1044
1045        // Wait for the task to actually start.
1046        let arrived = arrival_rx
1047            .recv_timeout(std::time::Duration::from_secs(2))
1048            .expect("task didn't start");
1049        assert_eq!(arrived, IVec3::ZERO);
1050
1051        // Right now the task is blocked inside `generate`.
1052        // pending_gen must reflect that.
1053        assert!(scene.grid(id).unwrap().pending_gen.contains(&IVec3::ZERO));
1054        assert!(scene.grid(id).unwrap().chunks.is_empty());
1055
1056        // Release and drain.
1057        release_tx.send(()).unwrap();
1058        pump_until_idle(&mut scene, cam, id, Some(&release_tx));
1059
1060        let g = scene.grid(id).unwrap();
1061        assert!(g.chunks.contains_key(&IVec3::ZERO));
1062        assert!(!g.pending_gen.contains(&IVec3::ZERO));
1063        assert_eq!(counter.load(Ordering::Relaxed), 1);
1064    }
1065
1066    #[test]
1067    #[cfg(not(target_arch = "wasm32"))]
1068    fn pump_streaming_does_not_redispatch_in_flight_chunks() {
1069        // While chunk X is in pending_gen, repeated pump calls
1070        // must NOT enqueue another generate for X. Verified by
1071        // call_count staying at 1 across multiple pumps.
1072        let (arrival_tx, arrival_rx) = crossbeam_channel::unbounded();
1073        let (release_tx, release_rx) = crossbeam_channel::unbounded();
1074        let counter = Arc::new(AtomicUsize::new(0));
1075        let gen = BlockingGenerator {
1076            arrival_tx,
1077            release_rx,
1078            call_count: Arc::clone(&counter),
1079        };
1080
1081        let mut scene = Scene::new();
1082        let id = scene.add_grid(GridTransform::identity());
1083        let g = scene.grid_mut(id).unwrap();
1084        g.set_generator(Some(Arc::new(gen)));
1085        g.stream_radius = StreamRadius::new(10.0, 200.0);
1086        let cam = DVec3::new(64.0, 64.0, 128.0);
1087
1088        scene.pump_streaming(cam);
1089        let _ = arrival_rx
1090            .recv_timeout(std::time::Duration::from_secs(2))
1091            .expect("task didn't start");
1092
1093        // Pump several more times while task is blocked.
1094        for _ in 0..5 {
1095            scene.pump_streaming(cam);
1096        }
1097        assert_eq!(
1098            counter.load(Ordering::Relaxed),
1099            1,
1100            "in-flight chunk re-dispatched"
1101        );
1102
1103        release_tx.send(()).unwrap();
1104        pump_until_idle(&mut scene, cam, id, Some(&release_tx));
1105        // Final post-drain assertion: still just one generate call.
1106        assert_eq!(counter.load(Ordering::Relaxed), 1);
1107    }
1108
1109    #[test]
1110    #[cfg(not(target_arch = "wasm32"))]
1111    fn pump_streaming_discards_stale_result_when_chunk_edited_during_gen() {
1112        // Race: dispatch a chunk; while task is blocked, edit the
1113        // chunk via set_voxel (creates a real chunk + bumps
1114        // version to 1). Release. The result arrives with
1115        // version_at_dispatch=0 vs current=1 → must discard. The
1116        // chunk keeps the user edit; doesn't get overwritten by
1117        // generator output.
1118        let (arrival_tx, arrival_rx) = crossbeam_channel::unbounded();
1119        let (release_tx, release_rx) = crossbeam_channel::unbounded();
1120        let counter = Arc::new(AtomicUsize::new(0));
1121        let gen = BlockingGenerator {
1122            arrival_tx,
1123            release_rx,
1124            call_count: Arc::clone(&counter),
1125        };
1126
1127        let mut scene = Scene::new();
1128        let id = scene.add_grid(GridTransform::identity());
1129        let g = scene.grid_mut(id).unwrap();
1130        g.set_generator(Some(Arc::new(gen)));
1131        g.stream_radius = StreamRadius::new(10.0, 200.0);
1132        let cam = DVec3::new(64.0, 64.0, 128.0);
1133
1134        scene.pump_streaming(cam);
1135        let _ = arrival_rx
1136            .recv_timeout(std::time::Duration::from_secs(2))
1137            .expect("task didn't start");
1138
1139        // Edit while the task is blocked.
1140        let g = scene.grid_mut(id).unwrap();
1141        // A user voxel at (10, 11, 12) inside chunk (0,0,0).
1142        g.set_voxel(IVec3::new(10, 11, 12), Some(0x80_de_ad_be));
1143        assert_eq!(g.chunk_version(IVec3::ZERO), 1);
1144        let chunk = g.chunk(IVec3::ZERO).unwrap();
1145        assert!(voxel_is_solid(chunk, 10, 11, 12));
1146        // Stub's signature voxel for chunk_idx.x=0 lives at
1147        // (0, 0, 0). After the user edit, before release, that
1148        // voxel is NOT solid (manual edit only set (10,11,12)).
1149        assert!(!voxel_is_solid(chunk, 0, 0, 0));
1150
1151        release_tx.send(()).unwrap();
1152        pump_until_idle(&mut scene, cam, id, Some(&release_tx));
1153
1154        // Chunk has the user voxel and NOT the generator signature.
1155        let g = scene.grid(id).unwrap();
1156        let chunk = g.chunk(IVec3::ZERO).unwrap();
1157        assert!(voxel_is_solid(chunk, 10, 11, 12), "user edit survived");
1158        assert!(
1159            !voxel_is_solid(chunk, 0, 0, 0),
1160            "stale generator output must not have overwritten the chunk"
1161        );
1162        // Generator ran exactly once before we discarded its result.
1163        assert_eq!(counter.load(Ordering::Relaxed), 1);
1164    }
1165
1166    #[test]
1167    #[cfg(not(target_arch = "wasm32"))]
1168    fn pump_streaming_eviction_drops_pending_gen_entry() {
1169        // Dispatch a chunk; while task is blocked, move the camera
1170        // far enough that the chunk is past r_evict. After the
1171        // next pump, the chunk's pending_gen entry must be gone
1172        // (the eviction half of pump removes it). When the task
1173        // finally completes, the drain discards the result via
1174        // "was_pending = false".
1175        let (arrival_tx, arrival_rx) = crossbeam_channel::unbounded();
1176        let (release_tx, release_rx) = crossbeam_channel::unbounded();
1177        let counter = Arc::new(AtomicUsize::new(0));
1178        let gen = BlockingGenerator {
1179            arrival_tx,
1180            release_rx,
1181            call_count: Arc::clone(&counter),
1182        };
1183
1184        let mut scene = Scene::new();
1185        let id = scene.add_grid(GridTransform::identity());
1186        let g = scene.grid_mut(id).unwrap();
1187        g.set_generator(Some(Arc::new(gen)));
1188        g.stream_radius = StreamRadius::new(10.0, 50.0);
1189        let near_cam = DVec3::new(64.0, 64.0, 128.0);
1190        scene.pump_streaming(near_cam);
1191        let _ = arrival_rx
1192            .recv_timeout(std::time::Duration::from_secs(2))
1193            .expect("task didn't start");
1194        assert!(scene.grid(id).unwrap().pending_gen.contains(&IVec3::ZERO));
1195
1196        // Teleport the camera 10_000 voxels along +x. Chunk
1197        // (0,0,0)'s nearest face at x=128 is now ~9872 away —
1198        // well past r_evict.
1199        let far_cam = DVec3::new(10_000.0, 64.0, 128.0);
1200        scene.pump_streaming(far_cam);
1201        assert!(
1202            !scene.grid(id).unwrap().pending_gen.contains(&IVec3::ZERO),
1203            "eviction should have cleared the pending entry"
1204        );
1205
1206        // Now release the blocked task. Its result arrives with
1207        // was_pending = false → silently dropped.
1208        release_tx.send(()).unwrap();
1209        // Drain at the far camera; chunk (0,0,0) is not in
1210        // r_active there, so no re-dispatch.
1211        pump_until_idle(&mut scene, far_cam, id, Some(&release_tx));
1212        let g = scene.grid(id).unwrap();
1213        assert!(
1214            !g.chunks.contains_key(&IVec3::ZERO),
1215            "evicted chunk must not be re-installed by the stale result"
1216        );
1217    }
1218
1219    #[test]
1220    #[cfg(not(target_arch = "wasm32"))]
1221    fn pump_streaming_with_disabled_radius_is_noop() {
1222        // Like the sync pump's disabled-noop test, but going
1223        // through the async path. No dispatch, no drain, no
1224        // panic.
1225        let mut scene = Scene::new();
1226        let id = scene.add_grid(GridTransform::identity());
1227        let gen = StubGenerator::new();
1228        let counter = Arc::clone(&gen.call_count);
1229        scene
1230            .grid_mut(id)
1231            .unwrap()
1232            .set_generator(Some(Arc::new(gen)));
1233        // stream_radius defaults to DISABLED.
1234        scene.pump_streaming(DVec3::ZERO);
1235        let g = scene.grid(id).unwrap();
1236        assert!(g.chunks.is_empty());
1237        assert!(g.pending_gen.is_empty());
1238        assert_eq!(counter.load(Ordering::Relaxed), 0);
1239    }
1240
1241    #[test]
1242    #[cfg(not(target_arch = "wasm32"))]
1243    fn set_streaming_threads_zero_panics() {
1244        let mut scene = Scene::new();
1245        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1246            scene.set_streaming_threads(0);
1247        }));
1248        assert!(result.is_err(), "zero threads must panic");
1249    }
1250
1251    #[test]
1252    #[cfg(not(target_arch = "wasm32"))]
1253    fn set_streaming_threads_lazily_applied_before_first_pump() {
1254        // Set thread count before any pump → pool is built with
1255        // the new count on next pump. Verified by a successful
1256        // round-trip with thread_count = 1.
1257        let mut scene = Scene::new();
1258        scene.set_streaming_threads(1);
1259        let id = scene.add_grid(GridTransform::identity());
1260        let gen = StubGenerator::new();
1261        let g = scene.grid_mut(id).unwrap();
1262        g.set_generator(Some(Arc::new(gen)));
1263        g.stream_radius = StreamRadius::new(10.0, 200.0);
1264        let cam = DVec3::new(64.0, 64.0, 128.0);
1265        pump_until_idle(&mut scene, cam, id, None);
1266        assert!(scene.grid(id).unwrap().chunks.contains_key(&IVec3::ZERO));
1267    }
1268
1269    #[test]
1270    fn pump_streaming_sync_eviction_clears_billboard_cache() {
1271        // S7.4 will hand off invalidation more carefully; for S7.1
1272        // we just pin that eviction nukes the cache so a future Far
1273        // render rebuilds it. (Cache stays untouched when nothing
1274        // gets evicted.)
1275        use crate::BillboardCache;
1276        let mut scene = Scene::new();
1277        let id = scene.add_grid(GridTransform::identity());
1278        let g = scene.grid_mut(id).unwrap();
1279        // Seed a single chunk to evict and a placeholder cache.
1280        g.set_voxel(IVec3::new(0, 0, 0), Some(0x80_aa_bb_cc));
1281        g.billboards = Some(BillboardCache::new_empty(64));
1282        g.stream_radius = StreamRadius::new(10.0, 50.0);
1283
1284        // Camera far enough that the chunk's nearest face > r_evict.
1285        scene.pump_streaming_sync(DVec3::new(10_000.0, 0.0, 0.0));
1286        let g = scene.grid(id).unwrap();
1287        assert_eq!(g.chunk_count(), 0, "chunk should have been evicted");
1288        assert!(g.billboards.is_none(), "billboard cache should be cleared");
1289    }
1290
1291    // ---- QE.5a: ChunkStore persistence across evict + re-stream ----
1292
1293    /// In-memory [`ChunkStore`] for the persistence tests.
1294    #[derive(Debug, Default)]
1295    struct MemStore {
1296        slots: std::sync::Mutex<std::collections::HashMap<IVec3, (Vxl, u64)>>,
1297    }
1298
1299    impl ChunkStore for MemStore {
1300        fn store(&self, chunk_idx: IVec3, vxl: &Vxl, version: u64) {
1301            self.slots
1302                .lock()
1303                .unwrap()
1304                .insert(chunk_idx, (vxl.clone(), version));
1305        }
1306        fn load(&self, chunk_idx: IVec3) -> Option<(Vxl, u64)> {
1307            self.slots
1308                .lock()
1309                .unwrap()
1310                .get(&chunk_idx)
1311                .map(|(vxl, version)| (vxl.clone(), *version))
1312        }
1313    }
1314
1315    #[test]
1316    fn chunk_store_round_trips_edits_across_evict_and_restream() {
1317        let mut scene = Scene::new();
1318        let id = scene.add_grid(GridTransform::identity());
1319        let store = Arc::new(MemStore::default());
1320        {
1321            let g = scene.grid_mut(id).unwrap();
1322            g.set_generator(Some(Arc::new(StubGenerator::new())));
1323            g.set_chunk_store(Some(store.clone()));
1324            g.stream_radius = StreamRadius::new(50.0, 200.0);
1325        }
1326
1327        // Stream in around the origin, then edit chunk (0, 0, 0).
1328        scene.pump_streaming_sync(DVec3::ZERO);
1329        let edited_voxel = IVec3::new(1, 1, 40);
1330        let edited_version = {
1331            let g = scene.grid_mut(id).unwrap();
1332            assert!(g.chunks.contains_key(&IVec3::ZERO), "chunk streamed in");
1333            g.set_voxel(edited_voxel, Some(0x8011_2233));
1334            assert!(g.voxel_solid(edited_voxel));
1335            let v = g.chunk_version(IVec3::ZERO);
1336            assert!(v > 0, "edit bumps the version");
1337            v
1338        };
1339
1340        // Walk far away: the edited chunk must land in the store; a
1341        // pristine (version 0) neighbour must NOT be stored.
1342        scene.pump_streaming_sync(DVec3::new(10_000.0, 10_000.0, 0.0));
1343        assert!(
1344            !scene.grid(id).unwrap().chunks.contains_key(&IVec3::ZERO),
1345            "edited chunk evicted"
1346        );
1347        let stored = store.load(IVec3::ZERO).expect("edited chunk persisted");
1348        assert_eq!(stored.1, edited_version, "persisted edit version");
1349        assert!(
1350            store.load(IVec3::new(1, 0, 0)).is_none(),
1351            "pristine generator chunks are regenerable and skip the store"
1352        );
1353
1354        // Walk back: the chunk restores from the store — edits intact,
1355        // version restored (not regenerated at version 0).
1356        scene.pump_streaming_sync(DVec3::ZERO);
1357        let g = scene.grid(id).unwrap();
1358        assert!(
1359            g.voxel_solid(edited_voxel),
1360            "player edit survived evict + re-stream"
1361        );
1362        assert_eq!(
1363            g.chunk_version(IVec3::ZERO),
1364            edited_version,
1365            "persisted edit version restored"
1366        );
1367    }
1368
1369    #[test]
1370    fn chunk_store_alone_restores_without_generator() {
1371        // A grid with ONLY a store (no generator) still restores
1372        // persisted chunks — edits can outlive their generator.
1373        let store = Arc::new(MemStore::default());
1374        {
1375            // Seed the store from a throwaway grid.
1376            let mut g = Grid::new(GridTransform::identity());
1377            g.set_voxel(IVec3::new(2, 2, 50), Some(0x8044_5566));
1378            let vxl = g.chunks.get(&IVec3::ZERO).expect("materialised");
1379            store.store(IVec3::ZERO, vxl, 7);
1380        }
1381
1382        let mut scene = Scene::new();
1383        let id = scene.add_grid(GridTransform::identity());
1384        {
1385            let g = scene.grid_mut(id).unwrap();
1386            g.set_chunk_store(Some(store));
1387            g.stream_radius = StreamRadius::new(50.0, 200.0);
1388        }
1389        scene.pump_streaming_sync(DVec3::ZERO);
1390        let g = scene.grid(id).unwrap();
1391        assert!(g.voxel_solid(IVec3::new(2, 2, 50)));
1392        assert_eq!(g.chunk_version(IVec3::ZERO), 7);
1393    }
1394}