Skip to main content

concinnity_core/render/
chunk_window.rs

1//! Sliding-window streaming policy for an infinite voxel world.
2//!
3//! `gfx::streaming::StreamPlanner` streams a *fixed* pool of items known at
4//! init (textures, build-time meshes). An infinite chunk world is different:
5//! the item set is unbounded and only a bounded *window* around the camera is
6//! ever resident. This module is that policy -- given the camera's chunk and a
7//! view radius it decides which chunks to load (nearest first, budget-limited)
8//! and which to evict (those that have fallen well outside the window).
9//!
10//! Two concentric bands: chunks within `near_radius` stream at full voxel
11//! detail; chunks beyond it but within `far_radius` stream as cheap coarse
12//! "impostors" (a low-poly surface mesh). As the camera moves a chunk crosses
13//! the near/far boundary and is *re-detailed* -- evicted and reloaded at the new
14//! detail. A small detail hysteresis keeps a chunk pacing across the boundary
15//! from thrashing. When `far_radius == near_radius` (the default) the far band
16//! is empty, so the window behaves exactly as the original single-detail one.
17//!
18//! The `std`-side driver (background generation thread, GPU upload) lives in
19//! concinnity-engine's `app::chunk_stream`.
20
21// `BTreeMap` rather than a hash map: `plan` hands back its eviction lists in
22// this map's iteration order, which a hash map would leave unpinned.
23use alloc::collections::BTreeMap;
24use alloc::vec::Vec;
25
26use crate::gfx::chunk_coord::ChunkCoord;
27
28// Extra chunk rings a chunk may drift beyond the view radius before it is
29// evicted. The gap between the load radius and the evict radius is hysteresis:
30// without it a chunk straddling the boundary would load and evict on
31// alternating frames as the camera jitters across a chunk edge.
32const EVICT_HYSTERESIS: i32 = 2;
33
34// Extra rings a currently-full (Near) chunk may drift past `near_radius` before
35// it is downgraded to a Far impostor. Without it a camera pacing back and forth
36// across the near/far boundary would re-detail a chunk every step.
37const DETAIL_HYSTERESIS: i32 = 1;
38
39// Low-water mark (percent of the byte budget) the effective window regrows at.
40// The window shrinks a ring whenever resident bytes exceed the budget and only
41// regrows once they fall back under this fraction of it; the gap is hysteresis
42// that stops the radius oscillating a ring in and out at the boundary.
43const BYTE_BUDGET_LOW_PCT: u64 = 75;
44
45// Residency state of a chunk the window is currently tracking.
46//
47// A chunk not in the window's map is simply unloaded -- there is no explicit
48// `Unloaded` state, since the grid is infinite and tracking every never-seen
49// chunk would be unbounded.
50#[derive(Clone, Copy, PartialEq, Eq, Debug)]
51pub(crate) enum ChunkState {
52    // A background generation+upload has been dispatched but not completed.
53    Pending,
54    // The chunk's mesh is resident on the GPU.
55    Resident,
56}
57
58/// Which representation a chunk is streamed at.
59#[derive(Clone, Copy, PartialEq, Eq, Debug)]
60pub enum ChunkDetail {
61    /// Full voxel geometry for chunks within `near_radius`.
62    Near,
63    /// A coarse distant-impostor surface mesh for chunks beyond `near_radius` but
64    /// within `far_radius`.
65    Far,
66}
67
68#[derive(Clone, Copy, PartialEq, Eq, Debug)]
69struct Slot {
70    state: ChunkState,
71    detail: ChunkDetail,
72    // Resident GPU footprint in bytes, reported by the driver on load
73    // completion. Zero while Pending; only counted toward `resident_bytes`
74    // once the slot is Resident.
75    bytes: u64,
76}
77
78/// The load / evict decisions produced by one [`ChunkWindow::plan`] call.
79#[derive(Debug, Default, PartialEq, Eq)]
80pub struct ChunkPlan {
81    /// Chunks whose background load should be dispatched this frame, nearest
82    /// to the camera first, each tagged with the detail to generate it at.
83    /// Already marked `ChunkState::Pending`.
84    pub to_load: Vec<(ChunkCoord, ChunkDetail)>,
85    /// Chunks removed from the GPU this frame: those that fell outside the
86    /// evict radius, plus those crossing the near/far boundary (which reload at
87    /// the new detail). Already dropped from the window's tracking map.
88    pub to_evict: Vec<ChunkCoord>,
89}
90
91/// Decides which chunks stream in and out of the camera-centred view window,
92/// and at which detail.
93///
94/// The window owns only residency *bookkeeping* -- it never generates a chunk
95/// or touches a GPU resource. Each frame the driver calls [`plan`] with the
96/// camera's current chunk, dispatches the loads, applies the evictions, and
97/// reports completed loads back via [`mark_resident`].
98///
99/// [`plan`]: ChunkWindow::plan
100/// [`mark_resident`]: ChunkWindow::mark_resident
101pub struct ChunkWindow {
102    // Tracked chunks only: a chunk absent from the map is unloaded.
103    states: BTreeMap<ChunkCoord, Slot>,
104    // Chebyshev radius (in chunks) of the full-detail square window.
105    near_radius: i32,
106    // Chebyshev radius of the outer impostor window (>= near_radius).
107    far_radius: i32,
108    // Max chunk loads dispatched per `plan` call.
109    load_budget: usize,
110    // Optional cap on total resident chunk bytes. When `Some(b)`, `plan`
111    // clamps the effective window down (shrinking the far impostor band before
112    // the near full-detail band) whenever resident bytes exceed `b`, evicting
113    // the outermost chunks until they fit. `None` (the default) disables byte
114    // accounting entirely, leaving the pure radius-based window.
115    byte_budget: Option<u64>,
116    // Rings the effective window is currently shrunk by under byte pressure,
117    // in `[0, far_radius]`. Zero (the default, and always so with no byte
118    // budget) means the effective window is exactly the configured one. Each
119    // ring shrinks the far band first, then the near band once the two meet.
120    shrink: i32,
121}
122
123impl ChunkWindow {
124    /// A window with a full-detail radius of `near_radius`, an outer impostor
125    /// radius of `far_radius`, and a per-frame load budget of `load_budget`
126    /// chunks.
127    ///
128    /// `near_radius` is floored at 0 (a lone chunk), `far_radius` at
129    /// `near_radius` (so it never undercuts the full-detail band; equal means
130    /// "no impostors"), and `load_budget` at 1 so a stray 0 cannot wedge
131    /// streaming permanently.
132    pub fn new(near_radius: i32, far_radius: i32, load_budget: usize) -> Self {
133        let near_radius = near_radius.max(0);
134        let far_radius = far_radius.max(near_radius);
135        Self {
136            states: BTreeMap::new(),
137            near_radius,
138            far_radius,
139            load_budget: load_budget.max(1),
140            byte_budget: None,
141            shrink: 0,
142        }
143    }
144
145    /// Set (or clear with `None`) the total resident-chunk-byte budget. `None`
146    /// keeps the pure radius-based window; `Some(b)` additionally clamps the
147    /// effective view radius down until resident bytes fit `b`. Off by default
148    /// so worlds that never set it behave exactly as the radius-only window.
149    pub fn set_byte_budget(&mut self, budget: Option<u64>) {
150        self.byte_budget = budget;
151    }
152
153    /// The active resident-byte budget, or `None` when byte accounting is off
154    /// (the pure radius window). For diagnostics.
155    pub fn byte_budget(&self) -> Option<u64> {
156        self.byte_budget
157    }
158
159    // The detail a chunk should currently be at, given its distance from the
160    // camera, the effective full-detail radius, and (for hysteresis) the detail
161    // it is currently tracked at.
162    fn target_detail(
163        &self,
164        c: ChunkCoord,
165        camera: ChunkCoord,
166        current: Option<ChunkDetail>,
167        near_radius: i32,
168    ) -> ChunkDetail {
169        let d = c.chebyshev_distance(camera);
170        if d <= near_radius {
171            ChunkDetail::Near
172        } else if matches!(current, Some(ChunkDetail::Near)) && d <= near_radius + DETAIL_HYSTERESIS
173        {
174            // A currently-full chunk keeps full detail through the hysteresis
175            // band rather than re-detailing the instant it leaves near_radius.
176            ChunkDetail::Near
177        } else {
178            ChunkDetail::Far
179        }
180    }
181
182    // The effective `(near_radius, far_radius)` after applying the byte-pressure
183    // `shrink`. A shrink first trims the far impostor band down to the near
184    // band, then shrinks the near band (with the far band held equal to it), so
185    // cheap distant impostors are dropped before full-detail near chunks.
186    fn effective_radii(&self) -> (i32, i32) {
187        let far_span = self.far_radius - self.near_radius;
188        if self.shrink <= far_span {
189            (self.near_radius, self.far_radius - self.shrink)
190        } else {
191            let near = (self.near_radius - (self.shrink - far_span)).max(0);
192            (near, near)
193        }
194    }
195
196    // Adjust the byte-pressure `shrink` from resident bytes vs the budget, with
197    // hysteresis so the effective radius does not oscillate at the boundary. A
198    // no-op (shrink pinned to 0) when no byte budget is set.
199    fn adjust_shrink(&mut self) {
200        let Some(budget) = self.byte_budget else {
201            self.shrink = 0;
202            return;
203        };
204        let resident = self.resident_bytes();
205        if resident > budget {
206            // Over budget: shrink one more ring (far impostor band first, then
207            // the near full-detail band), never past a lone camera chunk.
208            self.shrink = (self.shrink + 1).min(self.far_radius);
209            return;
210        }
211        // Under budget: regrow one ring only once resident bytes fall
212        // comfortably below the low-water margin AND no loads are in flight
213        // (a pending load's bytes are not yet counted, so regrowing before it
214        // lands could overshoot and force an immediate re-shrink). The margin
215        // plus the in-flight gate are the hysteresis that keeps the effective
216        // radius from oscillating ring in and out frame to frame.
217        if self.shrink == 0 {
218            return;
219        }
220        let pending = self
221            .states
222            .values()
223            .filter(|slot| slot.state == ChunkState::Pending)
224            .count();
225        if pending == 0 && resident.saturating_mul(100) < budget.saturating_mul(BYTE_BUDGET_LOW_PCT)
226        {
227            self.shrink -= 1;
228        }
229    }
230
231    /// Decide this frame's chunk loads and evictions for a camera in chunk
232    /// `camera`.
233    ///
234    /// First reconciles the effective view radius against the byte budget (a
235    /// no-op when none is set), then evicts every tracked chunk now beyond the
236    /// effective evict radius, re-details any tracked chunk that has crossed the
237    /// near/far boundary (evict + reload at the new detail), and dispatches the
238    /// nearest in-window chunks not yet tracked, up to the load budget, marking
239    /// each `Pending`.
240    pub fn plan(&mut self, camera: ChunkCoord) -> ChunkPlan {
241        // 0. Reconcile the effective window with the byte budget. The clamp only
242        //    ever shrinks below the configured radii, so a world with no budget
243        //    (shrink pinned to 0) plans exactly the configured window.
244        self.adjust_shrink();
245        let (near_radius, far_radius) = self.effective_radii();
246        let evict_radius = far_radius + EVICT_HYSTERESIS;
247
248        let mut to_evict: Vec<ChunkCoord> = Vec::new();
249
250        // 1. Evict chunks that have drifted past the evict radius.
251        let gone: Vec<ChunkCoord> = self
252            .states
253            .keys()
254            .copied()
255            .filter(|c| c.chebyshev_distance(camera) > evict_radius)
256            .collect();
257        for c in &gone {
258            self.states.remove(c);
259        }
260        to_evict.extend_from_slice(&gone);
261
262        // 2. Re-detail: a tracked chunk whose target detail no longer matches
263        //    is dropped + evicted so the candidate scan reloads it at the new
264        //    detail (a near<->far crossing as the camera moves).
265        let redetail: Vec<ChunkCoord> = self
266            .states
267            .iter()
268            .filter(|(c, slot)| {
269                self.target_detail(**c, camera, Some(slot.detail), near_radius) != slot.detail
270            })
271            .map(|(c, _)| *c)
272            .collect();
273        for c in &redetail {
274            self.states.remove(c);
275        }
276        to_evict.extend_from_slice(&redetail);
277
278        // 3. Collect in-window chunks (within far_radius) not yet tracked,
279        //    nearest first.
280        let mut candidates: Vec<ChunkCoord> = Vec::new();
281        for dz in -far_radius..=far_radius {
282            for dx in -far_radius..=far_radius {
283                let c = camera.offset(dx, dz);
284                if !self.states.contains_key(&c) {
285                    candidates.push(c);
286                }
287            }
288        }
289        candidates.sort_unstable_by(|a, b| {
290            a.sq_distance(camera)
291                .cmp(&b.sq_distance(camera))
292                // Stable tiebreak on the coordinate so the plan is deterministic.
293                .then(a.cmp(b))
294        });
295        candidates.truncate(self.load_budget);
296
297        let mut to_load = Vec::with_capacity(candidates.len());
298        for &c in &candidates {
299            let detail = self.target_detail(c, camera, None, near_radius);
300            self.states.insert(
301                c,
302                Slot {
303                    state: ChunkState::Pending,
304                    detail,
305                    bytes: 0,
306                },
307            );
308            to_load.push((c, detail));
309        }
310
311        to_evict.sort_unstable();
312        ChunkPlan { to_load, to_evict }
313    }
314
315    /// Mark a dispatched chunk resident once its mesh is on the GPU, recording
316    /// its GPU footprint in `bytes` (the decoded vertex + index buffer size).
317    /// `bytes` may be 0 when nothing was uploaded (e.g. a generation that
318    /// deterministically failed and is being retired to stop retrying it).
319    ///
320    /// A no-op if the chunk is no longer tracked -- the camera may have moved
321    /// far enough to evict it while its load was still in flight.
322    pub fn mark_resident(&mut self, coord: ChunkCoord, bytes: u64) {
323        if let Some(slot) = self.states.get_mut(&coord) {
324            slot.state = ChunkState::Resident;
325            slot.bytes = bytes;
326        }
327    }
328
329    /// Total bytes of all currently Resident chunks, for diagnostics and the
330    /// byte-budget clamp. Pending chunks (not yet uploaded) are excluded.
331    pub fn resident_bytes(&self) -> u64 {
332        self.states
333            .values()
334            .filter(|slot| slot.state == ChunkState::Resident)
335            .map(|slot| slot.bytes)
336            .sum()
337    }
338
339    /// Drop `coord` from tracking so a later [`plan`](Self::plan) will
340    /// re-dispatch it.
341    ///
342    /// The driver calls this when a dispatch could not be delivered to the
343    /// background worker, so the chunk is retried rather than stuck `Pending`.
344    pub fn forget(&mut self, coord: ChunkCoord) {
345        self.states.remove(&coord);
346    }
347
348    /// Whether the window is still tracking `coord` (pending or resident).
349    ///
350    /// The driver checks this when a background load completes: a chunk
351    /// evicted mid-flight is no longer tracked and its mesh should be dropped.
352    pub fn is_tracked(&self, coord: ChunkCoord) -> bool {
353        self.states.contains_key(&coord)
354    }
355
356    /// `(resident, pending)` chunk counts -- for diagnostics.
357    pub fn counts(&self) -> (usize, usize) {
358        let mut resident = 0;
359        let mut pending = 0;
360        for slot in self.states.values() {
361            match slot.state {
362                ChunkState::Resident => resident += 1,
363                ChunkState::Pending => pending += 1,
364            }
365        }
366        (resident, pending)
367    }
368
369    /// `(near_resident, far_resident)` counts -- resident full chunks vs
370    /// resident impostors, for diagnostics / verifying the far band is active.
371    pub fn counts_by_detail(&self) -> (usize, usize) {
372        let mut near = 0;
373        let mut far = 0;
374        for slot in self.states.values() {
375            if slot.state == ChunkState::Resident {
376                match slot.detail {
377                    ChunkDetail::Near => near += 1,
378                    ChunkDetail::Far => far += 1,
379                }
380            }
381        }
382        (near, far)
383    }
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389
390    use alloc::vec;
391    fn cc(x: i32, z: i32) -> ChunkCoord {
392        ChunkCoord::new(x, z)
393    }
394
395    // Coords in a plan's load list, detail dropped, for set-style assertions.
396    fn load_coords(plan: &ChunkPlan) -> Vec<ChunkCoord> {
397        plan.to_load.iter().map(|(c, _)| *c).collect()
398    }
399
400    // Fill the whole configured window at `camera`, marking every dispatched
401    // chunk resident with `bytes`. Assumes a load budget covering the window.
402    fn fill(w: &mut ChunkWindow, camera: ChunkCoord, bytes: u64) {
403        for (c, _) in w.plan(camera).to_load {
404            w.mark_resident(c, bytes);
405        }
406    }
407
408    // Replan at a stationary `camera`, marking each newly loaded chunk resident,
409    // until the byte clamp reaches a fixed point. Convergence is a streak of
410    // plans that neither load nor evict: a single quiet plan is not enough,
411    // because the effective radius shrinks two frames before the `+2` evict
412    // hysteresis actually drops the outer ring. Panics if it never settles --
413    // which doubles as the assertion that the clamp does not oscillate.
414    fn settle(w: &mut ChunkWindow, camera: ChunkCoord, bytes: u64) {
415        let mut quiet = 0;
416        for _ in 0..200 {
417            let plan = w.plan(camera);
418            for (c, _) in &plan.to_load {
419                w.mark_resident(*c, bytes);
420            }
421            if plan.to_load.is_empty() && plan.to_evict.is_empty() {
422                quiet += 1;
423                if quiet >= 4 {
424                    return;
425                }
426            } else {
427                quiet = 0;
428            }
429        }
430        panic!("byte-budget clamp did not converge (oscillating?)");
431    }
432
433    #[test]
434    fn plan_loads_nearest_in_window_chunks_within_budget() {
435        // near=far=2 -> a 5x5 window of 25 chunks, impostors off; budget 4.
436        let mut w = ChunkWindow::new(2, 2, 4);
437        let plan = w.plan(cc(0, 0));
438        assert!(plan.to_evict.is_empty());
439        assert_eq!(plan.to_load.len(), 4);
440        // The camera's own chunk is distance 0 -- it must be dispatched first.
441        assert_eq!(plan.to_load[0], (cc(0, 0), ChunkDetail::Near));
442        // Every dispatched chunk is within the load radius and full-detail.
443        for (c, detail) in &plan.to_load {
444            assert!(c.chebyshev_distance(cc(0, 0)) <= 2);
445            assert_eq!(*detail, ChunkDetail::Near);
446        }
447    }
448
449    #[test]
450    fn plan_does_not_redispatch_tracked_chunks() {
451        let mut w = ChunkWindow::new(3, 3, 100);
452        let first = w.plan(cc(0, 0));
453        // A generous budget loads the whole 7x7 window at once.
454        assert_eq!(first.to_load.len(), 49);
455        // Nothing left to dispatch on the next frame at the same position.
456        let second = w.plan(cc(0, 0));
457        assert!(second.to_load.is_empty());
458        assert!(second.to_evict.is_empty());
459    }
460
461    #[test]
462    fn plan_evicts_chunks_past_the_hysteresis_band() {
463        let mut w = ChunkWindow::new(2, 2, 100);
464        w.plan(cc(0, 0)); // load the 5x5 window around the origin
465        // Move far enough that the origin chunk is past radius 2 + hysteresis 2.
466        let plan = w.plan(cc(6, 0));
467        assert!(plan.to_evict.contains(&cc(0, 0)));
468    }
469
470    #[test]
471    fn evicted_chunk_can_be_reloaded_after_returning() {
472        let mut w = ChunkWindow::new(1, 1, 100);
473        w.plan(cc(0, 0));
474        w.plan(cc(20, 0)); // evicts the origin window entirely
475        assert!(!w.is_tracked(cc(0, 0)));
476        let plan = w.plan(cc(0, 0));
477        assert!(load_coords(&plan).contains(&cc(0, 0)));
478    }
479
480    #[test]
481    fn mark_resident_promotes_a_pending_chunk() {
482        let mut w = ChunkWindow::new(0, 0, 1);
483        let plan = w.plan(cc(0, 0));
484        assert_eq!(plan.to_load, vec![(cc(0, 0), ChunkDetail::Near)]);
485        assert_eq!(w.counts(), (0, 1));
486        w.mark_resident(cc(0, 0), 0);
487        assert_eq!(w.counts(), (1, 0));
488    }
489
490    #[test]
491    fn mark_resident_of_an_untracked_chunk_is_a_noop() {
492        let mut w = ChunkWindow::new(0, 0, 1);
493        w.mark_resident(cc(9, 9), 0); // never planned -- must not panic or insert
494        assert_eq!(w.counts(), (0, 0));
495        assert!(!w.is_tracked(cc(9, 9)));
496    }
497
498    #[test]
499    fn forget_lets_a_chunk_be_redispatched() {
500        let mut w = ChunkWindow::new(0, 0, 1);
501        w.plan(cc(0, 0));
502        assert!(w.is_tracked(cc(0, 0)));
503        w.forget(cc(0, 0));
504        assert!(!w.is_tracked(cc(0, 0)));
505        let plan = w.plan(cc(0, 0));
506        assert_eq!(plan.to_load, vec![(cc(0, 0), ChunkDetail::Near)]);
507    }
508
509    #[test]
510    fn zero_radius_and_budget_are_floored() {
511        // near floored to 0 (just the camera chunk), far to near, budget to 1.
512        let mut w = ChunkWindow::new(-5, -5, 0);
513        let plan = w.plan(cc(0, 0));
514        assert_eq!(plan.to_load, vec![(cc(0, 0), ChunkDetail::Near)]);
515    }
516
517    #[test]
518    fn far_band_chunks_load_as_impostors() {
519        // near 1, far 3: the 3x3 core is full detail, the surrounding rings are
520        // impostors. A generous budget loads the whole 7x7 window at once.
521        let mut w = ChunkWindow::new(1, 3, 100);
522        let plan = w.plan(cc(0, 0));
523        assert_eq!(plan.to_load.len(), 49);
524        for (c, detail) in &plan.to_load {
525            let d = c.chebyshev_distance(cc(0, 0));
526            let expected = if d <= 1 {
527                ChunkDetail::Near
528            } else {
529                ChunkDetail::Far
530            };
531            assert_eq!(*detail, expected, "chunk {:?} at distance {}", c, d);
532        }
533    }
534
535    #[test]
536    fn crossing_the_boundary_redetails_a_chunk() {
537        let mut w = ChunkWindow::new(1, 3, 100);
538        // Load + resolve the whole window around the origin.
539        let plan = w.plan(cc(0, 0));
540        let crossing = cc(2, 0); // distance 2 -> Far impostor at the origin
541        assert!(plan.to_load.contains(&(crossing, ChunkDetail::Far)));
542        for (c, _) in plan.to_load.clone() {
543            w.mark_resident(c, 0);
544        }
545        let (near0, far0) = w.counts_by_detail();
546        assert!(near0 > 0 && far0 > 0);
547
548        // Step toward `crossing` so it falls inside near_radius: it must be
549        // re-detailed (evicted) and re-dispatched as Near.
550        let plan = w.plan(cc(1, 0));
551        assert!(plan.to_evict.contains(&crossing));
552        assert!(plan.to_load.contains(&(crossing, ChunkDetail::Near)));
553    }
554
555    #[test]
556    fn detail_hysteresis_holds_a_full_chunk_through_the_band() {
557        // near 2, far 5. A chunk at the origin starts Near (camera at origin).
558        let mut w = ChunkWindow::new(2, 5, 200);
559        for (c, _) in w.plan(cc(0, 0)).to_load {
560            w.mark_resident(c, 0);
561        }
562        // Camera steps to (3,0): origin chunk is now chebyshev distance 3 =
563        // near_radius(2) + hysteresis(1), so it stays Near, no re-detail.
564        let plan = w.plan(cc(3, 0));
565        assert!(!plan.to_evict.contains(&cc(0, 0)));
566        // Step once more to (4,0): distance 4 > 2 + 1, so it downgrades to Far.
567        let plan = w.plan(cc(4, 0));
568        assert!(plan.to_evict.contains(&cc(0, 0)));
569        assert!(plan.to_load.contains(&(cc(0, 0), ChunkDetail::Far)));
570    }
571
572    #[test]
573    fn equal_radii_disable_the_far_band() {
574        // far == near -> every in-window chunk is Near, exactly as the original
575        // single-detail window.
576        let mut w = ChunkWindow::new(3, 3, 100);
577        let plan = w.plan(cc(0, 0));
578        assert!(plan.to_load.iter().all(|(_, d)| *d == ChunkDetail::Near));
579        assert_eq!(w.counts_by_detail().1, 0); // never any far chunks
580    }
581
582    #[test]
583    fn resident_bytes_counts_only_resident_chunks() {
584        let mut w = ChunkWindow::new(1, 1, 100); // 3x3 window
585        w.plan(cc(0, 0)); // all 9 dispatched Pending
586        assert_eq!(w.resident_bytes(), 0); // nothing uploaded yet
587        w.mark_resident(cc(0, 0), 500);
588        w.mark_resident(cc(1, 0), 250);
589        assert_eq!(w.resident_bytes(), 750);
590        // The 7 still-pending chunks contribute nothing to the resident total.
591        assert_eq!(w.counts(), (2, 7));
592    }
593
594    #[test]
595    fn byte_budget_accessor_reflects_set_and_clear() {
596        let mut w = ChunkWindow::new(1, 1, 4);
597        assert_eq!(w.byte_budget(), None);
598        w.set_byte_budget(Some(4096));
599        assert_eq!(w.byte_budget(), Some(4096));
600        w.set_byte_budget(None);
601        assert_eq!(w.byte_budget(), None);
602    }
603
604    #[test]
605    fn no_byte_budget_never_shrinks_the_window() {
606        // Absurd resident bytes but no budget: the window stays the configured
607        // radius and a stationary replan neither loads nor evicts -- byte-for-byte
608        // the pure radius-only behavior the existing tests pin.
609        let mut w = ChunkWindow::new(1, 3, 1000);
610        fill(&mut w, cc(0, 0), 10_000_000);
611        assert_eq!(w.counts().0, 49); // full 7x7 window resident
612        for _ in 0..4 {
613            let plan = w.plan(cc(0, 0));
614            assert!(plan.to_load.is_empty());
615            assert!(plan.to_evict.is_empty());
616        }
617        assert_eq!(w.counts().0, 49);
618    }
619
620    #[test]
621    fn byte_budget_evicts_the_far_band_before_the_near_band() {
622        let mut w = ChunkWindow::new(2, 6, 1000);
623        fill(&mut w, cc(0, 0), 100);
624        let (near_full, far_full) = w.counts_by_detail();
625        assert_eq!(near_full, 25); // the 5x5 full-detail core
626        assert!(far_full > 0);
627        // A budget that cannot hold the whole impostor band but comfortably fits
628        // the near core: the far band shrinks first, leaving the core intact.
629        w.set_byte_budget(Some(9000));
630        settle(&mut w, cc(0, 0), 100);
631        let (near_after, far_after) = w.counts_by_detail();
632        assert_eq!(near_after, 25, "full-detail core must survive");
633        assert!(far_after < far_full, "impostor band must shrink");
634        assert!(w.resident_bytes() <= 9000);
635        assert!(w.is_tracked(cc(0, 0)), "camera chunk is never evicted");
636        assert!(!w.is_tracked(cc(6, 0)), "outermost ring evicted");
637    }
638
639    #[test]
640    fn tighter_byte_budget_shrinks_the_window_further() {
641        // A tighter budget must keep strictly fewer chunks resident -- once the
642        // far band is exhausted the clamp shrinks into the near band too.
643        let build = |budget: u64| {
644            let mut w = ChunkWindow::new(2, 6, 1000);
645            fill(&mut w, cc(0, 0), 100);
646            w.set_byte_budget(Some(budget));
647            settle(&mut w, cc(0, 0), 100);
648            w
649        };
650        let loose = build(9000);
651        let tight = build(6000);
652        assert!(loose.resident_bytes() <= 9000);
653        assert!(tight.resident_bytes() <= 6000);
654        assert!(
655            tight.counts().0 < loose.counts().0,
656            "tighter budget must shrink further: tight {} vs loose {}",
657            tight.counts().0,
658            loose.counts().0
659        );
660        assert!(loose.is_tracked(cc(0, 0)) && tight.is_tracked(cc(0, 0)));
661    }
662
663    #[test]
664    fn byte_budget_clamp_settles_without_oscillating() {
665        let mut w = ChunkWindow::new(1, 5, 1000);
666        fill(&mut w, cc(0, 0), 100);
667        w.set_byte_budget(Some(5000));
668        settle(&mut w, cc(0, 0), 100); // panics if it never converges
669        // Past the fixed point a stationary replan is a no-op: the effective
670        // radius neither regrows nor re-shrinks frame to frame.
671        for _ in 0..8 {
672            let plan = w.plan(cc(0, 0));
673            assert!(plan.to_load.is_empty(), "regrew: {:?}", plan.to_load);
674            assert!(plan.to_evict.is_empty(), "evicted: {:?}", plan.to_evict);
675        }
676        assert!(w.resident_bytes() <= 5000);
677    }
678}