Skip to main content

concinnity_engine/gfx/streaming_system/
mod.rs

1// src/gfx/streaming_system/mod.rs
2//
3// StreamingSystem: drives the asset-streaming pools (albedo/normal texture,
4// mesh geometry, and infinite voxel-world chunks), and publishes the
5// camera-relative view the draw consumes. Streaming policy (scoring, dispatch,
6// residency) runs here; the GPU effects are recorded into the frame's op
7// queue with owned payloads and replayed at submission, with slot decisions
8// from the engine's `RenderSlots` allocator. An upload the backend refuses
9// comes back one tick later as a `RenderOpFailures` entry and is rolled back
10// at the top of the next step.
11//
12// Scheduled immediately before GraphicsSystem, so a chunk world's view rebase
13// (see `CameraRelativeView`) is ready for this same frame's submit, and any
14// recorded texture / mesh upload lands before the draw. GraphicsSystem's init
15// builds the streamers (world content + backend support) and parks them here
16// as the `StreamingState` resource; each step takes it and puts it back, so
17// the state and the `PipelineContext` are never borrowed together (the same
18// handoff the settings and overlay states use).
19//
20// The streamers themselves (the OS-coupled worker threads + channels) live in
21// `crate::gfx::streaming::{texture, mesh, chunk}`; this module only
22// scores, dispatches, and applies their results each frame.
23
24use crate::components::Camera3D;
25use crate::ecs::asset_id::AssetId;
26use crate::ecs::{PipelineContext, RenderOpFailures, StepResult, System};
27use crate::gfx::backend::ChunkMesh;
28use crate::gfx::ops::{OpFailure, RenderOps};
29use crate::gfx::overlay::OverlayFrame;
30use crate::gfx::render_slots::RenderSlots;
31use crate::gfx::scene_residency::{CHANNEL_MESH, CHANNEL_SHADER, CHANNEL_TEXTURE, SceneResidency};
32
33pub(crate) mod accounting;
34pub(crate) mod pressure;
35pub(crate) mod stats_log;
36
37const IDENTITY4: [[f32; 4]; 4] = crate::gfx::draw_list::IDENTITY4;
38
39// Throttled RSS sampling cadence for the process-RAM back-off valve. RSS is a
40// syscall, so the valve re-evaluates ~2x/second (every 30 frames near 60 fps)
41// off the frame clock rather than every frame.
42const PRESSURE_SAMPLE_INTERVAL: u64 = 30;
43
44// The camera-relative view + position GraphicsSystem hands to `update_view` /
45// `draw_frame`. Published every frame by StreamingSystem: the world's absolute
46// view + camera position when no `VoxelWorld` is streaming, or both rebased
47// onto the chunk render origin when one is (so an unbounded world renders from
48// small coordinates without large-coordinate jitter). GraphicsSystem falls back
49// to the absolute `Camera3D` values if this resource is absent (a unit test
50// driving GraphicsSystem without StreamingSystem).
51#[derive(Debug, Clone, Copy)]
52pub(crate) struct CameraRelativeView {
53    pub view: [[f32; 4]; 4],
54    pub cam_pos: [f32; 3],
55}
56
57// Runtime state for streaming an infinite `VoxelWorld`: the chunk streamer,
58// the resident chunk-to-draw-index map, and the per-chunk render parameters
59// (chunk size for the camera-to-chunk mapping and model placement, plus the
60// shared material every chunk draws with).
61pub(crate) struct ChunkStreamState {
62    pub(crate) streamer: crate::gfx::streaming::chunk::ChunkStreamer,
63    // Maps a resident chunk's coordinate to its `DrawObject` index.
64    pub(crate) draws: std::collections::BTreeMap<crate::gfx::chunk_coord::ChunkCoord, usize>,
65    pub(crate) chunk_w: f32,
66    pub(crate) chunk_d: f32,
67    // Render origin for camera-relative rendering: the chunk every resident
68    // chunk's model matrix is currently placed relative to. It follows the
69    // camera's chunk; when it changes the resident chunks are rebased onto the
70    // new origin.
71    pub(crate) origin_chunk: crate::gfx::chunk_coord::ChunkCoord,
72    pub(crate) texture_slot: usize,
73    pub(crate) normal_map_slot: usize,
74    pub(crate) material: crate::gfx::render_types::MaterialUniforms,
75}
76
77/// `(resident, pending, unloaded)` counts for each streaming pool, or `None`
78/// when that pool is not streaming. Read by the debug server's `streaming`
79/// command for headless verification. Only the `cn debug` binary consumes it,
80/// so it reads as dead code in a plain library build.
81#[derive(Debug, Clone, Default)]
82pub struct StreamingStats {
83    /// `(resident, pending, budget)` texture counts when streaming.
84    pub texture: Option<(usize, usize, usize)>,
85    /// `(resident, pending, budget)` mesh counts when streaming.
86    pub mesh: Option<(usize, usize, usize)>,
87    /// `(resident, pending)` chunk counts when a `VoxelWorld` is streaming.
88    pub chunk: Option<(usize, usize)>,
89    /// `(resident_bytes, byte_budget)` for the texture pool when streaming;
90    /// `byte_budget` is 0 when the pool runs count-only (no byte budget).
91    pub texture_bytes: Option<(u64, u64)>,
92    /// `(resident_bytes, byte_budget)` for the mesh pool when streaming.
93    pub mesh_bytes: Option<(u64, u64)>,
94    /// `(resident_bytes, byte_budget)` for the chunk pool when a VoxelWorld is
95    /// streaming; `byte_budget` is 0 when the GPU reported no memory figure.
96    pub chunk_bytes: Option<(u64, u64)>,
97}
98
99/// Live process-RAM pressure on streaming, published by StreamingSystem on each
100/// throttled sample when a `MemoryBudget` is present. `under_pressure` is true
101/// whenever the back-off valve is engaged (gating loads or evicting). Read by the
102/// debug server's `streaming` command for headless verification; harmless (and
103/// unread) in a plain `cn run`. Absent entirely when no `MemoryBudget` is
104/// published or RSS cannot be queried, in which case the valve is inert.
105#[derive(Debug, Clone, Copy)]
106pub struct StreamingPressure {
107    /// Process resident-set size at the sample.
108    pub rss_bytes: u64,
109    /// The published memory budget.
110    pub budget_bytes: u64,
111    /// Whether the back-off valve is engaged.
112    pub under_pressure: bool,
113}
114
115// The streaming pools GraphicsSystem's init builds and hands off. Held as a
116// parked resource; StreamingSystem takes it each step, drives the pools, and
117// puts it back. `frame_count` is this system's own frame clock, incremented
118// once per step; it stays in lockstep with GraphicsSystem's (both start at 0
119// and tick once per world step), so eviction retire-frames and the LRU scores
120// use the same frame number the draw does.
121pub(crate) struct StreamingState {
122    // Shared albedo + normal-map texture pool streamer. `Some` only when a
123    // `StreamingConfig` was declared.
124    pub(crate) texture_streamer: Option<crate::gfx::streaming::texture::TextureStreamer>,
125    // Mesh-geometry streamer. `Some` under the same condition as above.
126    pub(crate) mesh_streamer: Option<crate::gfx::streaming::mesh::MeshStreamer>,
127    // Maps a streamed mesh's id to its DrawObject index, so completed loads and
128    // evictions are applied to the right draw. Empty when not streaming.
129    pub(crate) mesh_stream_draw_indices: Vec<usize>,
130    // Infinite voxel-world chunk streaming. `Some` only when a `VoxelWorld` was
131    // declared.
132    pub(crate) chunk_stream: Option<ChunkStreamState>,
133    // Deferred shader-bucket pipelines, warmed one per frame as their scene
134    // pins. `Some` only when init deferred at least one bucket.
135    pub(crate) shader_warmup: Option<crate::gfx::streaming::shader::ShaderWarmup>,
136    // Scene-pinned residency over the texture/mesh pools. `Some` only when the
137    // world declares scenes and at least one pool streams; unpinned scenes'
138    // members are blocked on the planners (never load, evict if resident).
139    pub(crate) scene_residency: Option<SceneResidency>,
140    // This system's frame clock (see the struct doc).
141    pub(crate) frame_count: u64,
142    // Frames the backend keeps in flight: an eviction's freed region cannot be
143    // reused until the command buffers that drew it retire, at
144    // `frame_count + frames_in_flight`.
145    pub(crate) frames_in_flight: usize,
146    // Baseline (derived at setup) resident-byte budget for each pool; `None`
147    // when the pool runs count-only (chunks: when no VoxelWorld streams or the
148    // GPU reports no memory). The RAM back-off valve reduces the live budget
149    // below this under deep pressure and restores it exactly on release.
150    pub(crate) texture_baseline_budget: Option<u64>,
151    pub(crate) mesh_baseline_budget: Option<u64>,
152    pub(crate) chunk_baseline_budget: Option<u64>,
153    // Process-RAM back-off valve state (see `pressure`), re-evaluated on the
154    // throttled RSS sample. `pressure_factor` is the byte-budget scale currently
155    // applied to the pools (1.0 = baseline); `last_sampled_rss` feeds the
156    // "still rising" escalation from stage 1 to stage 2.
157    pub(crate) pressure_stage: pressure::StreamPressureStage,
158    pub(crate) pressure_factor: f64,
159    pub(crate) last_sampled_rss: Option<u64>,
160    // Long-session memory drift, folded from the same throttled sample. Purely
161    // reported: it names what grew, and never moves the valve.
162    pub(crate) drift: crate::app::mem_drift::DriftTracker,
163    // Last verdict logged, so a steady session states its reading once rather
164    // than twice a second.
165    pub(crate) last_drift_verdict: Option<crate::app::mem_drift::DriftVerdict>,
166    // Gates the periodic per-pool counter line so a settled pool logs its
167    // counts once rather than every sample.
168    pub(crate) heartbeats: stats_log::PoolHeartbeats,
169}
170
171impl std::fmt::Debug for StreamingState {
172    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
173        f.debug_struct("StreamingState")
174            .field("frame_count", &self.frame_count)
175            .field("texture", &self.texture_streamer.is_some())
176            .field("mesh", &self.mesh_streamer.is_some())
177            .field("chunk", &self.chunk_stream.is_some())
178            .field("pressure", &self.pressure_stage)
179            .finish()
180    }
181}
182
183#[derive(Debug, Default)]
184/// Drives texture / mesh / chunk residency against the streaming budgets.
185pub struct StreamingSystem {
186    // Scene-status scratch reused across frames, compared against the
187    // published `SceneResidencyStatus` before republishing.
188    scene_status_scratch: Vec<(AssetId, crate::gfx::scene_residency::SceneLoadState, f32)>,
189}
190
191impl StreamingSystem {
192    /// A system with empty scratch.
193    pub fn new() -> Self {
194        Self::default()
195    }
196}
197
198impl System for StreamingSystem {
199    fn step(&mut self, ctx: &mut PipelineContext) -> StepResult {
200        // No parked state (graphics init has not succeeded): nothing to drive,
201        // and GraphicsSystem is not drawing either, so no view to publish.
202        if !ctx.resources.contains::<StreamingState>() {
203            return StepResult::Continue;
204        }
205
206        // Everything the drive needs from the wider context, gathered first so
207        // the state below is borrowed in place instead of moved out of its
208        // resource slot (which would free and re-box it every frame).
209        let ram_budget = ctx
210            .resource::<crate::app::budget::MemoryBudget>()
211            .map(|b| b.budget_bytes);
212        // The camera the draw will use (written by the camera controller last
213        // tick) is the absolute fallback when no chunk streaming rebases it.
214        let (view_matrix, cam_pos) = ctx
215            .query::<Camera3D>()
216            .next()
217            .map(|c| (c.view_matrix, c.position))
218            .unwrap_or((IDENTITY4, [0.0; 3]));
219        // Peek (not take) the overlay's world-hidden flag: OverlaySystem
220        // published it first this tick and GraphicsSystem takes it later.
221        // Streaming pauses behind an opaque menu (the world is not drawn),
222        // unless a pinned scene is still loading: a loading screen's opaque
223        // backdrop must not starve the load it reports (see `drive`).
224        let world_hidden = ctx
225            .resource::<OverlayFrame>()
226            .map(|o| o.world_hidden)
227            .unwrap_or(false);
228
229        // Scene pins for streamed-content residency: the active scene, plus a
230        // fade target mid-transition so the destination starts loading before
231        // visibility flips.
232        let pin_pair: Option<([AssetId; 2], usize)> = ctx
233            .resource::<crate::ecs::ActiveSceneFlow>()
234            .and_then(|slot| slot.flow.as_ref())
235            .map(|flow| {
236                let mut pins = [flow.current; 2];
237                let mut len = 1;
238                if let crate::gfx::scene_flow::FadePhase::ToBlack { next, .. } = flow.fade
239                    && next != flow.current
240                {
241                    pins[1] = next;
242                    len = 2;
243                }
244                (pins, len)
245            });
246        let scene_pins: Option<&[AssetId]> = pin_pair.as_ref().map(|(pins, len)| &pins[..*len]);
247        let transient_pool_bytes = ctx.profile.render.transient_pool_bytes;
248
249        // Throttled process-RAM back-off sample (~2x/sec). Reads the world's
250        // `MemoryBudget` ceiling and live RSS; when RSS nears the ceiling the
251        // valve engages (stage 1 gates new loads, stage 2 shrinks residency).
252        // Skipped entirely when no `MemoryBudget` is published or RSS is
253        // unavailable, leaving streaming on its byte-budget policy unchanged.
254        {
255            let mut pressure_sample = None;
256            let mut drift_sample = None;
257            let state = ctx
258                .resources
259                .get_mut::<StreamingState>()
260                .expect("presence checked above");
261            if state.frame_count.is_multiple_of(PRESSURE_SAMPLE_INTERVAL)
262                && let Some(budget) = ram_budget
263            {
264                let rss = crate::app::sysmem::process_resident_bytes();
265                pressure_sample = state.sample_pressure(rss, budget);
266                drift_sample = state.sample_drift(rss, budget);
267            }
268            if let Some(pressure) = pressure_sample {
269                ctx.insert_resource(pressure);
270            }
271            if let Some(drift) = drift_sample {
272                ctx.insert_resource(drift);
273            }
274        }
275
276        // The recording surfaces graphics init published beside this state.
277        // Taken out for the drive so `ctx` stays freely borrowable.
278        let Some(mut queues) = crate::ecs::ActiveRenderQueues::take(ctx.resources) else {
279            // Should not happen (published together with this state): publish
280            // the absolute view so the draw is still driven.
281            ctx.insert_resource(CameraRelativeView {
282                view: view_matrix,
283                cam_pos,
284            });
285            return StepResult::Continue;
286        };
287        let failures = ctx.resources.remove::<RenderOpFailures>();
288
289        let state = ctx
290            .resources
291            .get_mut::<StreamingState>()
292            .expect("presence checked above");
293
294        // Roll back the ops that failed at the previous frame's replay (a
295        // refused streamed-mesh upload, a failed chunk add) before planning,
296        // so this frame's dispatch sees the corrected residency.
297        if let Some(failures) = failures {
298            state.apply_op_failures(&failures.0, &mut queues.slots);
299        }
300
301        let (view, cam_pos) = state.drive(
302            &mut queues.ops,
303            &mut queues.slots,
304            cam_pos,
305            view_matrix,
306            world_hidden,
307            scene_pins,
308        );
309
310        // Refresh each pool's device footprint under the shared tags, so a
311        // readout can name what VRAM is holding.
312        accounting::publish(
313            concinnity_core::memory::ledger(),
314            state.pool_reports(transient_pool_bytes),
315        );
316        // Per-scene load status into the reused scratch; published below once
317        // the state borrow has ended.
318        let have_residency = match state.scene_residency.as_ref() {
319            Some(residency) => {
320                residency.status_into(&mut self.scene_status_scratch);
321                true
322            }
323            None => false,
324        };
325
326        crate::ecs::ActiveRenderQueues::put(ctx.resources, queues);
327        ctx.insert_resource(CameraRelativeView { view, cam_pos });
328        // Republish the per-scene load status when it changed, so menus and
329        // loading screens can read scene progress without touching the pools.
330        if have_residency {
331            match ctx.resource_mut::<crate::ecs::SceneResidencyStatus>() {
332                Some(published) => {
333                    if published.scenes != self.scene_status_scratch {
334                        published.scenes.clone_from(&self.scene_status_scratch);
335                    }
336                }
337                None => {
338                    ctx.insert_resource(crate::ecs::SceneResidencyStatus {
339                        scenes: self.scene_status_scratch.clone(),
340                    });
341                }
342            }
343        }
344        StepResult::Continue
345    }
346}
347
348impl StreamingState {
349    // Apply this frame's pending shader-bucket work: build the pipeline for
350    // one bucket whose scene just pinned, or release one whose scene unpinned.
351    //
352    // A bucket that cannot be installed (unreadable payload, a shader missing
353    // the bindless entry points) is recorded resident anyway after the error:
354    // its draws stay skipped, but the owning scene finishes loading instead of
355    // holding its loading screen open forever on work that will never succeed.
356    fn drive_shader_warmup(&mut self, ops: &mut RenderOps) {
357        let Some((bucket, want_resident)) =
358            self.shader_warmup.as_ref().and_then(|w| w.next_pending())
359        else {
360            return;
361        };
362        let resident = if want_resident {
363            match self.shader_warmup.as_ref().map(|w| w.load(bucket)) {
364                Some(Ok(programs)) => {
365                    // The payload is in hand; the recorded install is what
366                    // ends the deferral. Pipeline creation is device work, so
367                    // it runs (and is timed) at replay beside the draw.
368                    ops.record(move |backend| {
369                        let shader = crate::gfx::backend_init::WorldShader {
370                            programs: Some(&programs),
371                            deferred: false,
372                        };
373                        let started = std::time::Instant::now();
374                        match backend.install_world_shader(bucket, shader) {
375                            // The elapsed time is the frame cost this warmup
376                            // keeps out of gameplay.
377                            Ok(()) => tracing::info!(
378                                "StreamingSystem: shader bucket {} pipeline ready ({:.1} ms)",
379                                bucket,
380                                started.elapsed().as_secs_f32() * 1000.0
381                            ),
382                            Err(e) => tracing::error!(
383                                "StreamingSystem: shader bucket {} pipeline build failed: {}",
384                                bucket,
385                                e
386                            ),
387                        }
388                    });
389                }
390                Some(Err(e)) => tracing::error!(
391                    "StreamingSystem: shader bucket {} payload unreadable: {}",
392                    bucket,
393                    e
394                ),
395                None => {}
396            }
397            true
398        } else {
399            ops.record(move |backend| {
400                backend.evict_world_shader(bucket);
401                tracing::info!(
402                    "StreamingSystem: shader bucket {} pipeline released",
403                    bucket
404                );
405            });
406            false
407        };
408        if let Some(w) = self.shader_warmup.as_mut() {
409            w.note_resident(bucket, resident);
410        }
411        if let Some(residency) = self.scene_residency.as_mut() {
412            residency.note_resident((CHANNEL_SHADER, bucket), resident);
413        }
414    }
415
416    // Roll back the recorded ops that failed at the previous frame's replay:
417    // a refused streamed-mesh upload returns to `Unloaded` (retried once
418    // freed space reclaims), a failed chunk add drops its tracking and frees
419    // its draw slot.
420    pub(crate) fn apply_op_failures(&mut self, failures: &[OpFailure], slots: &mut RenderSlots) {
421        for &failure in failures {
422            match failure {
423                OpFailure::MeshUpload { stream_id } => {
424                    if let Some(streamer) = &mut self.mesh_streamer {
425                        streamer.note_upload_failed(stream_id);
426                    }
427                    if let Some(residency) = &mut self.scene_residency {
428                        residency.note_resident((CHANNEL_MESH, stream_id as u32), false);
429                    }
430                }
431                OpFailure::ChunkAdd { coord } => {
432                    if let Some(cs) = &mut self.chunk_stream
433                        && let Some(draw_idx) = cs.draws.remove(&coord)
434                    {
435                        slots.free_draw(draw_idx);
436                        tracing::warn!(
437                            "StreamingSystem: chunk add ({},{}) rolled back",
438                            coord.x,
439                            coord.z
440                        );
441                    }
442                }
443            }
444        }
445    }
446
447    // Score, dispatch, and apply this frame's streaming for every active pool,
448    // then return the camera-relative view + position the draw should use
449    // (absolute unless a `VoxelWorld` rebases them). Advances the frame clock.
450    fn drive(
451        &mut self,
452        ops: &mut RenderOps,
453        slots: &mut RenderSlots,
454        cam_pos: [f32; 3],
455        view_matrix: [[f32; 4]; 4],
456        world_hidden: bool,
457        scene_pins: Option<&[AssetId]>,
458    ) -> ([[f32; 4]; 4], [f32; 3]) {
459        // Stage 1 of the RAM back-off valve freezes new load dispatch: the pools
460        // keep their current residency but stop growing. Stage 2 keeps
461        // dispatching (under a reduced byte budget) so the planner can evict.
462        let loads_frozen = self.pressure_stage.freezes_loads();
463
464        // Sync scene pins onto the pools: members of a scene leaving the pin
465        // set are blocked (never load, evict next plan), members of a scene
466        // entering it unblock and stream in through the normal planning path.
467        if let (Some(residency), Some(pins)) = (self.scene_residency.as_mut(), scene_pins) {
468            let changes = residency.sync_pins(pins);
469            for (members, blocked) in [(&changes.blocked, true), (&changes.unblocked, false)] {
470                for &(channel, id) in members {
471                    match channel {
472                        CHANNEL_TEXTURE => {
473                            if let Some(s) = &mut self.texture_streamer {
474                                s.set_blocked(id as usize, blocked);
475                            }
476                        }
477                        CHANNEL_MESH => {
478                            if let Some(s) = &mut self.mesh_streamer {
479                                s.set_blocked(id as usize, blocked);
480                            }
481                        }
482                        CHANNEL_SHADER => {
483                            if let Some(w) = &mut self.shader_warmup {
484                                w.set_blocked(id, blocked);
485                            }
486                        }
487                        _ => {}
488                    }
489                }
490            }
491        }
492
493        // Warm (or release) one shader bucket's pipeline per frame, so a
494        // scene owning several shaders spreads the device work over the
495        // frames its loading screen is already up rather than stalling one of
496        // them.
497        self.drive_shader_warmup(ops);
498
499        // A pinned scene mid-load keeps the pools dispatching even while the
500        // world is hidden, so a loading screen's opaque backdrop does not
501        // pause the very load whose progress it shows.
502        let world_hidden = world_hidden
503            && !self
504                .scene_residency
505                .as_ref()
506                .is_some_and(|r| r.any_loading());
507
508        // Drive albedo-texture streaming: re-score every slot by camera
509        // distance, dispatch this frame's background loads within budget, then
510        // apply completed uploads + evictions. Each backend's
511        // update_texture_slot rewrites whichever descriptors / argument-buffers
512        // sample that slot so it takes effect on this same draw_frame.
513        if !world_hidden && let Some(streamer) = &mut self.texture_streamer {
514            streamer.update_scores(cam_pos, self.frame_count);
515            if !loads_frozen {
516                for slot in streamer.plan_and_dispatch() {
517                    ops.record(move |backend| {
518                        if let Err(e) = backend.evict_texture_slot(slot) {
519                            tracing::warn!("StreamingSystem: texture evict slot {}: {}", slot, e);
520                        }
521                    });
522                    if let Some(residency) = self.scene_residency.as_mut() {
523                        residency.note_resident((CHANNEL_TEXTURE, slot as u32), false);
524                    }
525                }
526            }
527            let residency = &mut self.scene_residency;
528            streamer.drain_completed(self.frame_count, |slot, image| {
529                ops.record(move |backend| {
530                    if let Err(e) = backend.update_texture_slot(slot, &image) {
531                        tracing::warn!("StreamingSystem: texture upload slot {}: {}", slot, e);
532                    }
533                });
534                if let Some(residency) = residency.as_mut() {
535                    residency.note_resident((CHANNEL_TEXTURE, slot as u32), true);
536                }
537            });
538            // Surface streaming progress as it moves so a headless run can
539            // confirm textures are coming resident.
540            if let Some((resident, pending, unloaded)) = self
541                .heartbeats
542                .texture
543                .sample(self.frame_count, || streamer.stats())
544            {
545                tracing::info!(
546                    "StreamingSystem: texture streaming -- {} resident, {} pending, {} unloaded",
547                    resident,
548                    pending,
549                    unloaded
550                );
551            }
552        }
553
554        // Drive mesh-geometry streaming: re-score each streamed mesh by camera
555        // distance, dispatch this frame's background loads, then apply completed
556        // geometry uploads + evictions. A mesh is skipped in every pass until
557        // its geometry region is resident.
558        if !world_hidden && let Some(streamer) = &mut self.mesh_streamer {
559            streamer.update_scores(cam_pos, self.frame_count);
560            if !loads_frozen {
561                // A runtime eviction's freed space must not be reused until the
562                // in-flight command buffers that drew it retire.
563                let retire_frame = self.frame_count + self.frames_in_flight as u64;
564                for stream_id in streamer.plan_and_dispatch() {
565                    if let Some(&draw_idx) = self.mesh_stream_draw_indices.get(stream_id) {
566                        ops.record(move |backend| {
567                            if let Err(e) = backend.evict_mesh(draw_idx, retire_frame) {
568                                tracing::warn!(
569                                    "StreamingSystem: mesh evict draw {}: {}",
570                                    draw_idx,
571                                    e
572                                );
573                            }
574                        });
575                    }
576                    if let Some(residency) = self.scene_residency.as_mut() {
577                        residency.note_resident((CHANNEL_MESH, stream_id as u32), false);
578                    }
579                }
580            }
581            let draw_indices = &self.mesh_stream_draw_indices;
582            let frame = self.frame_count;
583            let residency = &mut self.scene_residency;
584            streamer.drain_completed(self.frame_count, |stream_id, verts, idxs| {
585                // The mesh is marked resident on handoff; a transient
586                // seed-full refusal comes back as an op failure and
587                // `apply_op_failures` rolls it back to Unloaded next tick.
588                if let Some(&draw_idx) = draw_indices.get(stream_id) {
589                    ops.record_with(move |backend, out| {
590                        if let Err(e) = backend.upload_mesh(draw_idx, &verts, &idxs, frame) {
591                            tracing::debug!(
592                                "StreamingSystem: mesh upload draw {} deferred: {}",
593                                draw_idx,
594                                e
595                            );
596                            out.memory_pressure |=
597                                matches!(e, crate::gfx::error::RenderError::OutOfDeviceMemory(_));
598                            out.failures.push(OpFailure::MeshUpload { stream_id });
599                        }
600                    });
601                }
602                if let Some(residency) = residency.as_mut() {
603                    residency.note_resident((CHANNEL_MESH, stream_id as u32), true);
604                }
605            });
606            if let Some((resident, pending, unloaded)) = self
607                .heartbeats
608                .mesh
609                .sample(self.frame_count, || streamer.stats())
610            {
611                tracing::info!(
612                    "StreamingSystem: mesh streaming -- {} resident, {} pending, {} unloaded",
613                    resident,
614                    pending,
615                    unloaded
616                );
617            }
618        }
619
620        // Drive infinite-world chunk streaming: generate + upload the chunks
621        // entering the camera's view window and remove those that have left it.
622        // None unless a VoxelWorld was declared.
623        //
624        // Camera-relative rendering: chunk geometry is placed relative to a
625        // render origin that follows the camera's chunk, and the view + camera
626        // position handed to the backend are rebased onto the same origin. The
627        // world transform is unchanged -- it is just evaluated from small
628        // coordinates, so an unbounded world renders without large-coordinate
629        // jitter. The view + camera stay absolute when no VoxelWorld is
630        // streaming, leaving a non-voxel world byte-for-byte unchanged.
631        let mut final_view = view_matrix;
632        let mut final_cam_pos = cam_pos;
633        if let Some(cs) = &mut self.chunk_stream {
634            let camera_chunk = cs.streamer.camera_chunk(cam_pos);
635            let retire_frame = self.frame_count + self.frames_in_flight as u64;
636            // Stage 1 of the RAM valve freezes chunk residency, mirroring the
637            // texture/mesh gate: skipping plan_and_dispatch stops both new
638            // generation and window eviction, so residency holds steady while
639            // the drain below keeps applying in-flight loads. Stage 2 keeps
640            // planning under a reduced byte budget, so the window clamp evicts.
641            if !loads_frozen {
642                for coord in cs.streamer.plan_and_dispatch(camera_chunk) {
643                    if let Some(draw_idx) = cs.draws.remove(&coord) {
644                        slots.free_draw(draw_idx);
645                        ops.record(move |backend| {
646                            if let Err(e) = backend.remove_chunk_mesh(draw_idx, retire_frame) {
647                                tracing::warn!(
648                                    "StreamingSystem: chunk remove ({},{}): {}",
649                                    coord.x,
650                                    coord.z,
651                                    e
652                                );
653                            }
654                        });
655                    }
656                }
657            }
658            // The camera crossed into a new chunk: move the render origin to it
659            // and rebase every resident chunk's model matrix. `prev_draw_models`
660            // is deliberately left alone -- the rebase is exact, so a stationary
661            // chunk shows zero TAA velocity across the shift.
662            if camera_chunk != cs.origin_chunk {
663                for (&coord, &draw_idx) in &cs.draws {
664                    let model = chunk_model_matrix(coord, camera_chunk, cs.chunk_w, cs.chunk_d);
665                    ops.record(move |backend| {
666                        if let Err(e) = backend.set_chunk_model(draw_idx, model) {
667                            tracing::warn!(
668                                "StreamingSystem: chunk rebase ({},{}): {}",
669                                coord.x,
670                                coord.z,
671                                e
672                            );
673                        }
674                    });
675                }
676                cs.origin_chunk = camera_chunk;
677            }
678            let frame = self.frame_count;
679            let (chunk_w, chunk_d) = (cs.chunk_w, cs.chunk_d);
680            let (tex, nm, mat) = (cs.texture_slot, cs.normal_map_slot, cs.material);
681            let mut added: Vec<(crate::gfx::chunk_coord::ChunkCoord, usize)> = Vec::new();
682            cs.streamer.drain_completed(|coord, verts, idxs| {
683                if verts.is_empty() || idxs.is_empty() {
684                    tracing::warn!(
685                        "StreamingSystem: chunk add ({},{}): empty chunk geometry",
686                        coord.x,
687                        coord.z
688                    );
689                    return;
690                }
691                let model = chunk_model_matrix(coord, camera_chunk, chunk_w, chunk_d);
692                let dst = slots.allocate_draw();
693                let draw_idx = match dst {
694                    crate::gfx::draw_slot::SlotAlloc::Reuse(i)
695                    | crate::gfx::draw_slot::SlotAlloc::Append(i) => i,
696                };
697                added.push((coord, draw_idx));
698                // A failed add comes back as an op failure; the rollback drops
699                // the tracking and frees the slot.
700                ops.record_with(move |backend, out| {
701                    let mesh = ChunkMesh {
702                        verts: &verts,
703                        idxs: &idxs,
704                        model,
705                        texture_slot: tex,
706                        normal_map_slot: nm,
707                        material: mat,
708                        frame,
709                    };
710                    if let Err(e) = backend.add_chunk_mesh(mesh, dst) {
711                        tracing::warn!(
712                            "StreamingSystem: chunk add ({},{}): {}",
713                            coord.x,
714                            coord.z,
715                            e
716                        );
717                        out.memory_pressure |=
718                            matches!(e, crate::gfx::error::RenderError::OutOfDeviceMemory(_));
719                        out.failures.push(OpFailure::ChunkAdd { coord });
720                    }
721                });
722            });
723            for (coord, draw_idx) in added {
724                cs.draws.insert(coord, draw_idx);
725            }
726            // Rebase the view + camera onto the render origin so the
727            // origin-relative chunk geometry above transforms exactly.
728            let (ox, oz) = camera_chunk.origin_world(cs.chunk_w, cs.chunk_d);
729            let origin = [ox, 0.0, oz];
730            final_view =
731                crate::gfx::chunk_coord::camera_relative_view(view_matrix, cam_pos, origin);
732            final_cam_pos = [cam_pos[0] - ox, cam_pos[1], cam_pos[2] - oz];
733            if let Some((resident, pending, near, far)) =
734                self.heartbeats.chunk.sample(self.frame_count, || {
735                    let (resident, pending) = cs.streamer.stats();
736                    let (near, far) = cs.streamer.detail_counts();
737                    (resident, pending, near, far)
738                })
739            {
740                tracing::info!(
741                    "StreamingSystem: chunk streaming -- {} resident ({} full, {} impostor), {} pending",
742                    resident,
743                    near,
744                    far,
745                    pending
746                );
747            }
748        }
749
750        self.frame_count += 1;
751        (final_view, final_cam_pos)
752    }
753
754    // Re-evaluate the process-RAM back-off valve from a fresh RSS sample and
755    // apply its decision to the texture + mesh pools. Returns the pressure
756    // reading to publish, or `None` when RSS is unavailable (the valve stays
757    // inert and nothing is published). `budget` is the `MemoryBudget` ceiling.
758    fn sample_pressure(&mut self, rss: Option<u64>, budget: u64) -> Option<StreamingPressure> {
759        let rss = rss?;
760        let rising = self.last_sampled_rss.is_some_and(|prev| rss > prev);
761        let prev_stage = self.pressure_stage;
762        let decision =
763            pressure::step_pressure(rss, budget, rising, prev_stage, self.pressure_factor);
764
765        // Re-apply the pool byte budgets only when the reduced-budget state
766        // actually needs it: while evicting (the factor may have tightened) or
767        // on the transition out of eviction (restore the baseline exactly).
768        // Staying at None/Gate leaves the budgets at their baseline untouched,
769        // so a world never under pressure behaves exactly as before.
770        use pressure::StreamPressureStage::Evict;
771        match (prev_stage, decision.stage) {
772            (_, Evict) => self.apply_byte_factor(decision.budget_factor),
773            (Evict, _) => self.apply_byte_factor(1.0),
774            _ => {}
775        }
776
777        self.pressure_stage = decision.stage;
778        self.pressure_factor = decision.budget_factor;
779        self.last_sampled_rss = Some(rss);
780        Some(StreamingPressure {
781            rss_bytes: rss,
782            budget_bytes: budget,
783            under_pressure: decision.stage != pressure::StreamPressureStage::None,
784        })
785    }
786
787    // Fold the same RSS sample into the long-session drift tracker, stating the
788    // reading whenever it changes. Reports only: every valve stage is decided
789    // by `sample_pressure`, and nothing here moves a byte budget.
790    fn sample_drift(
791        &mut self,
792        rss: Option<u64>,
793        budget: u64,
794    ) -> Option<crate::app::mem_drift::MemoryDrift> {
795        use crate::app::mem_drift::DriftVerdict;
796        let rss = rss?;
797        let heap_live = concinnity_core::memory::stats()?.live_bytes;
798        let drift = self.drift.sample(rss, heap_live, budget)?;
799
800        if self.last_drift_verdict != Some(drift.verdict) {
801            self.last_drift_verdict = Some(drift.verdict);
802            let heap_mib = drift.heap_growth_bytes / (1024 * 1024);
803            let outside_mib = drift.outside_heap_growth_bytes / (1024 * 1024);
804            let minutes = drift.window_secs / 60;
805            let reading = drift.verdict.label();
806            if drift.verdict == DriftVerdict::Settled {
807                tracing::info!(
808                    "memory drift: {reading} -- heap {heap_mib:+} MiB, outside heap {outside_mib:+} MiB over {minutes} min"
809                );
810            } else {
811                tracing::warn!(
812                    "memory drift: {reading} -- heap {heap_mib:+} MiB, outside heap {outside_mib:+} MiB over {minutes} min"
813                );
814            }
815        }
816        Some(drift)
817    }
818
819    // Scale each pool's byte budget to `factor` of its captured baseline. Pools
820    // with no baseline (count-only) are left alone; there is nothing to reduce.
821    fn apply_byte_factor(&mut self, factor: f64) {
822        if let (Some(streamer), Some(baseline)) =
823            (self.texture_streamer.as_mut(), self.texture_baseline_budget)
824        {
825            streamer.set_byte_budget(Some(pressure::scale_budget(baseline, factor)));
826        }
827        if let (Some(streamer), Some(baseline)) =
828            (self.mesh_streamer.as_mut(), self.mesh_baseline_budget)
829        {
830            streamer.set_byte_budget(Some(pressure::scale_budget(baseline, factor)));
831        }
832        // The chunk pool's byte clamp responds to a reduced budget by shrinking
833        // its effective view radius, dropping the far impostor band first.
834        if let (Some(cs), Some(baseline)) = (self.chunk_stream.as_mut(), self.chunk_baseline_budget)
835        {
836            cs.streamer
837                .set_byte_budget(Some(pressure::scale_budget(baseline, factor)));
838        }
839    }
840
841    // What each streaming pool holds in device memory, for the shared ledger.
842    // Only pools that are actually streaming report.
843    //
844    // `transient_pool_bytes` is the render graph's transient pool, which is not a
845    // streaming pool at all: it sits off the device allocator (its slots alias on
846    // purpose, which the general allocator must never do) and so is invisible to
847    // every other accounting path. It rides in under `Textures` because that is
848    // what it holds. Its bytes are added to the budget as well as the usage, so
849    // the streamer's own headroom against its own cap is unchanged -- the pool is
850    // not competing for the streamer's budget, it is reporting alongside it.
851    fn pool_reports(
852        &self,
853        transient_pool_bytes: u64,
854    ) -> impl Iterator<Item = accounting::PoolReport> {
855        let textures = accounting::textures_report(
856            self.texture_streamer
857                .as_ref()
858                .map(|s| (s.resident_bytes(), s.byte_budget())),
859            transient_pool_bytes,
860        );
861        [
862            self.mesh_streamer.as_ref().map(|s| {
863                (
864                    concinnity_core::memory::MemTag::Meshes,
865                    s.resident_bytes(),
866                    s.byte_budget(),
867                )
868            }),
869            self.chunk_stream.as_ref().map(|cs| {
870                (
871                    concinnity_core::memory::MemTag::Chunks,
872                    cs.streamer.resident_bytes(),
873                    cs.streamer.byte_budget(),
874                )
875            }),
876        ]
877        .into_iter()
878        .flatten()
879        .map(
880            |(tag, resident_bytes, byte_budget)| accounting::PoolReport {
881                tag,
882                resident_bytes,
883                byte_budget,
884            },
885        )
886        .chain(textures)
887    }
888
889    // `(resident, pending, unloaded)` counts for each active streaming pool.
890    // Consumed only by the `cn debug` binary's `streaming` command, so it reads
891    // as dead code in a plain library build.
892    pub(crate) fn streaming_stats(&self) -> StreamingStats {
893        StreamingStats {
894            texture: self.texture_streamer.as_ref().map(|s| s.stats()),
895            mesh: self.mesh_streamer.as_ref().map(|s| s.stats()),
896            chunk: self.chunk_stream.as_ref().map(|cs| cs.streamer.stats()),
897            texture_bytes: self
898                .texture_streamer
899                .as_ref()
900                .map(|s| (s.resident_bytes(), s.byte_budget().unwrap_or(0))),
901            mesh_bytes: self
902                .mesh_streamer
903                .as_ref()
904                .map(|s| (s.resident_bytes(), s.byte_budget().unwrap_or(0))),
905            chunk_bytes: self.chunk_stream.as_ref().map(|cs| {
906                (
907                    cs.streamer.resident_bytes(),
908                    cs.streamer.byte_budget().unwrap_or(0),
909                )
910            }),
911        }
912    }
913}
914
915// Model matrix that places chunk `coord`'s origin-local geometry relative to
916// the render origin `origin`, so the on-GPU transform stays exact and small
917// regardless of how far the world origin is. The matching view matrix is
918// rebased onto the same origin by `camera_relative_view`, which keeps an
919// unbounded world's precision intact.
920pub(crate) fn chunk_model_matrix(
921    coord: crate::gfx::chunk_coord::ChunkCoord,
922    origin: crate::gfx::chunk_coord::ChunkCoord,
923    chunk_w: f32,
924    chunk_d: f32,
925) -> [[f32; 4]; 4] {
926    let dx = (coord.x - origin.x) as f32 * chunk_w;
927    let dz = (coord.z - origin.z) as f32 * chunk_d;
928    [
929        [1.0, 0.0, 0.0, 0.0],
930        [0.0, 1.0, 0.0, 0.0],
931        [0.0, 0.0, 1.0, 0.0],
932        [dx, 0.0, dz, 1.0],
933    ]
934}
935
936#[cfg(test)]
937mod tests {
938    use super::*;
939    use crate::blob::BlobData;
940    use crate::ecs::{ComponentStorage, Resources};
941    use crate::gfx::chunk_coord::ChunkCoord;
942    use crate::gfx::chunk_window::ChunkDetail;
943    use crate::gfx::mesh_payload::Vertex;
944    use crate::gfx::mock_backend::{Call, MockBackend, recording_backend};
945    use crate::gfx::profile::FrameProfile;
946    use crate::gfx::streaming::chunk::{ChunkSource, ChunkStreamer};
947    use crate::gfx::streaming::mesh::{DecodedMesh, MeshPayloadSource, MeshStreamer};
948    use crate::gfx::streaming::texture::{DecodedTexture, PayloadSource, TextureStreamer};
949    use pressure::StreamPressureStage;
950    use std::sync::Arc;
951
952    // Upper bound on `drive_until` iterations. The pools decode on their own
953    // worker threads, so a test that waits on one yields rather than sleeps;
954    // the bound turns a regression into a failure instead of a hang.
955    const MAX_DRIVE_SPINS: usize = 100_000;
956
957    fn vtx() -> Vertex {
958        Vertex {
959            pos: [0.0; 3],
960            normal: [0.0, 1.0, 0.0],
961            tangent: [1.0, 0.0, 0.0],
962            color: [1.0; 3],
963            uv: [0.0; 2],
964        }
965    }
966
967    fn tri() -> DecodedMesh {
968        DecodedMesh {
969            vertices: vec![vtx(), vtx(), vtx()],
970            indices: vec![0, 1, 2],
971        }
972    }
973
974    // Sources yielding a fixed payload for any id, so the streamer workers
975    // complete without the build pipeline.
976    struct ConstTexture;
977    impl PayloadSource for ConstTexture {
978        fn fetch(&self, _id: usize) -> Result<DecodedTexture, String> {
979            Ok(DecodedTexture {
980                image: crate::bake::texture::TextureImage::rgba8(1, 1, vec![1, 2, 3, 4]),
981            })
982        }
983    }
984
985    struct ConstMesh;
986    impl MeshPayloadSource for ConstMesh {
987        fn fetch(&self, _id: usize) -> Result<DecodedMesh, String> {
988            Ok(tri())
989        }
990    }
991
992    struct ConstChunk;
993    impl ChunkSource for ConstChunk {
994        fn generate(
995            &self,
996            _coord: ChunkCoord,
997            _detail: ChunkDetail,
998        ) -> Result<DecodedMesh, String> {
999            Ok(tri())
1000        }
1001    }
1002
1003    // A source whose generation always fails, so a chunk is tracked by the
1004    // window but never uploaded: it keeps the resident-draw map under the
1005    // test's control rather than the worker's timing.
1006    struct FailingChunk;
1007    impl ChunkSource for FailingChunk {
1008        fn generate(
1009            &self,
1010            _coord: ChunkCoord,
1011            _detail: ChunkDetail,
1012        ) -> Result<DecodedMesh, String> {
1013            Err("test source".to_string())
1014        }
1015    }
1016
1017    // A view matrix that is a pure translation: enough to tell a rebased view
1018    // apart from the absolute one it was derived from.
1019    fn translation_view(x: f32, y: f32, z: f32) -> [[f32; 4]; 4] {
1020        [
1021            [1.0, 0.0, 0.0, 0.0],
1022            [0.0, 1.0, 0.0, 0.0],
1023            [0.0, 0.0, 1.0, 0.0],
1024            [x, y, z, 1.0],
1025        ]
1026    }
1027
1028    // A bare StreamingState with no pools: enough to exercise the RAM valve's
1029    // sampling + stage machine without standing up the streamer worker threads.
1030    // `apply_byte_factor` is a no-op with no baselines, so the transitions run
1031    // exactly as they would with pools attached.
1032    fn empty_state() -> StreamingState {
1033        StreamingState {
1034            texture_streamer: None,
1035            mesh_streamer: None,
1036            mesh_stream_draw_indices: Vec::new(),
1037            chunk_stream: None,
1038            shader_warmup: None,
1039            scene_residency: None,
1040            frame_count: 0,
1041            frames_in_flight: 2,
1042            texture_baseline_budget: None,
1043            mesh_baseline_budget: None,
1044            chunk_baseline_budget: None,
1045            pressure_stage: StreamPressureStage::None,
1046            pressure_factor: 1.0,
1047            last_sampled_rss: None,
1048            drift: Default::default(),
1049            last_drift_verdict: None,
1050            heartbeats: Default::default(),
1051        }
1052    }
1053
1054    // Texture + mesh pools of two items each: item 0 sits on the camera's
1055    // origin and item 1 far out on +X, so a camera at either end orders the two
1056    // unambiguously. `resident_cap` chooses whether both fit at once (8) or the
1057    // second must displace the first (1). Mesh stream ids map to draw slots
1058    // 10 / 11 so an upload's routing is visible in the call log.
1059    fn pooled_state(resident_cap: usize) -> StreamingState {
1060        let centers = vec![vec![[0.0, 0.0, 0.0]], vec![[100.0, 0.0, 0.0]]];
1061        let mut state = empty_state();
1062        state.texture_streamer = Some(TextureStreamer::new(
1063            Arc::new(ConstTexture),
1064            centers.clone(),
1065            4,
1066            resident_cap,
1067        ));
1068        state.mesh_streamer = Some(MeshStreamer::new(
1069            Arc::new(ConstMesh),
1070            centers,
1071            4,
1072            resident_cap,
1073        ));
1074        state.mesh_stream_draw_indices = vec![10, 11];
1075        state
1076    }
1077
1078    // A chunk pool over 16x16 chunks at the given near / far radii.
1079    fn chunk_state(source: Arc<dyn ChunkSource>, near: i32, far: i32) -> ChunkStreamState {
1080        ChunkStreamState {
1081            streamer: ChunkStreamer::new(source, near, far, 64, 16.0, 16.0),
1082            draws: std::collections::BTreeMap::new(),
1083            chunk_w: 16.0,
1084            chunk_d: 16.0,
1085            origin_chunk: ChunkCoord::new(0, 0),
1086            texture_slot: 0,
1087            normal_map_slot: crate::gfx::render_types::NO_NORMAL_MAP_SLOT,
1088            material: crate::gfx::render_types::MaterialUniforms::DEFAULT,
1089        }
1090    }
1091
1092    // Drive frames until `done` holds. Yields (never sleeps) between frames
1093    // while the pools' workers decode; panics rather than hanging if the state
1094    // is never reached.
1095    // One drive with a throwaway op queue + slot allocator, replaying the
1096    // recorded ops onto the mock backend, for tests that assert per-frame
1097    // behavior rather than the multi-frame pump `drive_until` covers.
1098    fn drive_once(
1099        state: &mut StreamingState,
1100        backend: &mut MockBackend,
1101        cam: [f32; 3],
1102        view: [[f32; 4]; 4],
1103        world_hidden: bool,
1104        pins: Option<&[AssetId]>,
1105    ) -> ([[f32; 4]; 4], [f32; 3]) {
1106        let mut slots = RenderSlots::new(0, true, &[]);
1107        let mut ops = RenderOps::default();
1108        let out = state.drive(&mut ops, &mut slots, cam, view, world_hidden, pins);
1109        let outcome = ops.replay(backend);
1110        state.apply_op_failures(&outcome.failures, &mut slots);
1111        out
1112    }
1113
1114    fn drive_until(
1115        state: &mut StreamingState,
1116        backend: &mut MockBackend,
1117        cam: [f32; 3],
1118        done: impl Fn(&StreamingState) -> bool,
1119    ) {
1120        let mut slots = RenderSlots::new(0, true, &[]);
1121        for _ in 0..MAX_DRIVE_SPINS {
1122            let mut ops = RenderOps::default();
1123            state.drive(&mut ops, &mut slots, cam, IDENTITY4, false, None);
1124            let outcome = ops.replay(backend);
1125            state.apply_op_failures(&outcome.failures, &mut slots);
1126            if done(state) {
1127                return;
1128            }
1129            std::thread::yield_now();
1130        }
1131        panic!("streaming never reached the expected state");
1132    }
1133
1134    // Owns the storage a PipelineContext borrows from, for the `step` tests.
1135    struct StepWorld {
1136        components: ComponentStorage,
1137        blob: BlobData,
1138        profile: FrameProfile,
1139        resources: Resources,
1140        scratch: crate::ecs::Arena,
1141    }
1142
1143    impl StepWorld {
1144        fn new() -> Self {
1145            Self {
1146                components: ComponentStorage::default(),
1147                blob: BlobData::empty(),
1148                profile: FrameProfile::default(),
1149                resources: Resources::new(),
1150                scratch: crate::ecs::Arena::with_capacity(64 * 1024),
1151            }
1152        }
1153
1154        // Place a camera the step will read the absolute view + position from.
1155        fn with_camera(mut self, position: [f32; 3], view_matrix: [[f32; 4]; 4]) -> Self {
1156            self.components.push_typed(Camera3D {
1157                position,
1158                view_matrix,
1159                ..Camera3D::bake(Default::default())
1160            });
1161            self
1162        }
1163
1164        // Publish the op queue + slot allocator the step takes and reparks
1165        // (the pair graphics init publishes in production).
1166        fn park_render_queues(&mut self) {
1167            self.resources.insert(crate::ecs::ActiveRenderQueues(Some(
1168                crate::ecs::RenderQueues {
1169                    ops: Default::default(),
1170                    slots: RenderSlots::new(0, true, &[]),
1171                },
1172            )));
1173        }
1174
1175        fn step(&mut self) -> StepResult {
1176            let mut ctx = PipelineContext {
1177                components: &mut self.components,
1178                blob: &mut self.blob,
1179                profile: &mut self.profile,
1180                resources: &mut self.resources,
1181                frame: crate::ecs::FrameContext::new(&self.scratch),
1182            };
1183            StreamingSystem::new().step(&mut ctx)
1184        }
1185
1186        fn view(&self) -> CameraRelativeView {
1187            *self
1188                .resources
1189                .get::<CameraRelativeView>()
1190                .expect("camera-relative view published")
1191        }
1192
1193        fn parked_state(&self) -> &StreamingState {
1194            self.resources
1195                .get::<StreamingState>()
1196                .expect("state parked again")
1197        }
1198    }
1199
1200    #[test]
1201    fn sample_pressure_engages_and_publishes() {
1202        let mut s = empty_state();
1203        // RSS at 92% of a 1000-byte budget: stage 1 engages.
1204        let p = s.sample_pressure(Some(920), 1000).expect("published");
1205        assert_eq!(s.pressure_stage, StreamPressureStage::Gate);
1206        assert!(p.under_pressure);
1207        assert_eq!(p.rss_bytes, 920);
1208        assert_eq!(p.budget_bytes, 1000);
1209    }
1210
1211    // The drift tracker rides the same sample as the valve: it reports nothing
1212    // until the session settles, and the two terms it then reports always
1213    // account for exactly the RSS movement between them.
1214    #[test]
1215    fn sample_drift_reports_once_settled_and_splits_the_whole_rss_movement() {
1216        const RSS: u64 = 2 * 1024 * 1024 * 1024;
1217        const BUDGET: u64 = 4 * RSS;
1218        let mut s = empty_state();
1219
1220        assert_eq!(s.sample_drift(None, BUDGET), None, "no RSS, no drift");
1221        // Steady samples until the tracker settles and starts reporting; the
1222        // run it needs is the drift module's business, not this system's.
1223        let d = (0..16)
1224            .find_map(|_| s.sample_drift(Some(RSS), BUDGET))
1225            .expect("a steady run settles the baseline");
1226        assert_eq!(d.verdict, crate::app::mem_drift::DriftVerdict::Settled);
1227        // RSS did not move, so whatever the heap did, the outside term is its
1228        // exact complement.
1229        assert_eq!(d.heap_growth_bytes + d.outside_heap_growth_bytes, 0);
1230    }
1231
1232    // Drift is reported, never acted on: the valve's stage and byte-budget
1233    // factor are decided entirely by `sample_pressure`.
1234    #[test]
1235    fn sample_drift_never_moves_the_valve() {
1236        const RSS: u64 = 2 * 1024 * 1024 * 1024;
1237        let mut s = empty_state();
1238        for _ in 0..4 {
1239            s.sample_drift(Some(RSS), 4 * RSS);
1240        }
1241        assert_eq!(s.pressure_stage, StreamPressureStage::None);
1242        assert_eq!(s.pressure_factor, 1.0);
1243        assert_eq!(s.last_sampled_rss, None);
1244    }
1245
1246    #[test]
1247    fn sample_pressure_escalates_when_rss_keeps_rising() {
1248        let mut s = empty_state();
1249        s.sample_pressure(Some(910), 1000);
1250        assert_eq!(s.pressure_stage, StreamPressureStage::Gate);
1251        // Still above engage and climbing: escalate to eviction.
1252        s.sample_pressure(Some(925), 1000);
1253        assert_eq!(s.pressure_stage, StreamPressureStage::Evict);
1254        assert!(s.pressure_factor < 1.0);
1255    }
1256
1257    #[test]
1258    fn sample_pressure_releases_with_hysteresis() {
1259        let mut s = empty_state();
1260        s.sample_pressure(Some(970), 1000); // straight to evict
1261        assert_eq!(s.pressure_stage, StreamPressureStage::Evict);
1262        // In the hysteresis band (85%): still latched.
1263        s.sample_pressure(Some(850), 1000);
1264        assert_eq!(s.pressure_stage, StreamPressureStage::Evict);
1265        // Below the release mark: valve releases and restores the baseline.
1266        let p = s.sample_pressure(Some(700), 1000).expect("published");
1267        assert_eq!(s.pressure_stage, StreamPressureStage::None);
1268        assert_eq!(s.pressure_factor, 1.0);
1269        assert!(!p.under_pressure);
1270    }
1271
1272    #[test]
1273    fn sample_pressure_is_inert_without_rss() {
1274        let mut s = empty_state();
1275        s.sample_pressure(Some(970), 1000);
1276        let stage_before = s.pressure_stage;
1277        // A failed RSS query publishes nothing and leaves the stage untouched.
1278        assert!(s.sample_pressure(None, 1000).is_none());
1279        assert_eq!(s.pressure_stage, stage_before);
1280    }
1281
1282    // The translation column is the integer chunk delta scaled by chunk size;
1283    // the basis stays identity.
1284    #[test]
1285    fn chunk_model_matrix_offsets_by_chunk_delta() {
1286        let m = chunk_model_matrix(ChunkCoord::new(2, -3), ChunkCoord::new(0, 0), 16.0, 10.0);
1287        assert_eq!(m[3], [32.0, 0.0, -30.0, 1.0]);
1288        assert_eq!(m[0], [1.0, 0.0, 0.0, 0.0]);
1289        assert_eq!(m[1], [0.0, 1.0, 0.0, 0.0]);
1290        assert_eq!(m[2], [0.0, 0.0, 1.0, 0.0]);
1291    }
1292
1293    #[test]
1294    fn chunk_model_matrix_origin_chunk_is_untranslated() {
1295        let c = ChunkCoord::new(5, 7);
1296        let m = chunk_model_matrix(c, c, 16.0, 16.0);
1297        assert_eq!(m[3], [0.0, 0.0, 0.0, 1.0]);
1298    }
1299
1300    // Deep pressure scales every pool's budget off its own captured baseline,
1301    // and releasing restores each one exactly (not merely approximately).
1302    #[test]
1303    fn deep_pressure_scales_each_pool_budget_and_release_restores_the_baseline() {
1304        let mut state = pooled_state(8);
1305        state.chunk_stream = Some(chunk_state(Arc::new(ConstChunk), 0, 0));
1306        state.texture_baseline_budget = Some(4000);
1307        state.mesh_baseline_budget = Some(2000);
1308        state.chunk_baseline_budget = Some(1000);
1309        state.apply_byte_factor(1.0);
1310
1311        state.sample_pressure(Some(970), 1000);
1312        assert_eq!(state.pressure_stage, StreamPressureStage::Evict);
1313        let factor = state.pressure_factor;
1314        assert!(factor < 1.0);
1315        let tex = state.texture_streamer.as_ref().unwrap().byte_budget();
1316        let mesh = state.mesh_streamer.as_ref().unwrap().byte_budget();
1317        let chunk = state.chunk_stream.as_ref().unwrap().streamer.byte_budget();
1318        assert_eq!(tex, Some(pressure::scale_budget(4000, factor)));
1319        assert_eq!(mesh, Some(pressure::scale_budget(2000, factor)));
1320        assert_eq!(chunk, Some(pressure::scale_budget(1000, factor)));
1321
1322        state.sample_pressure(Some(100), 1000);
1323        assert_eq!(state.pressure_stage, StreamPressureStage::None);
1324        assert_eq!(
1325            state.texture_streamer.as_ref().unwrap().byte_budget(),
1326            Some(4000)
1327        );
1328        assert_eq!(
1329            state.mesh_streamer.as_ref().unwrap().byte_budget(),
1330            Some(2000)
1331        );
1332        assert_eq!(
1333            state.chunk_stream.as_ref().unwrap().streamer.byte_budget(),
1334            Some(1000)
1335        );
1336    }
1337
1338    // A pool with no captured baseline runs count-only: the valve has nothing
1339    // to scale, so it must not invent a budget for it.
1340    #[test]
1341    fn count_only_pools_gain_no_byte_budget_under_pressure() {
1342        let mut state = pooled_state(8);
1343        state.chunk_stream = Some(chunk_state(Arc::new(ConstChunk), 0, 0));
1344
1345        state.sample_pressure(Some(970), 1000);
1346        assert_eq!(state.pressure_stage, StreamPressureStage::Evict);
1347        assert_eq!(state.texture_streamer.as_ref().unwrap().byte_budget(), None);
1348        assert_eq!(state.mesh_streamer.as_ref().unwrap().byte_budget(), None);
1349        assert_eq!(
1350            state.chunk_stream.as_ref().unwrap().streamer.byte_budget(),
1351            None
1352        );
1353    }
1354
1355    #[test]
1356    fn streaming_stats_reports_every_active_pool() {
1357        let mut state = pooled_state(8);
1358        state.chunk_stream = Some(chunk_state(Arc::new(ConstChunk), 0, 0));
1359        state
1360            .texture_streamer
1361            .as_mut()
1362            .unwrap()
1363            .set_byte_budget(Some(4000));
1364
1365        let stats = state.streaming_stats();
1366        assert_eq!(stats.texture, Some((0, 0, 2)));
1367        assert_eq!(stats.mesh, Some((0, 0, 2)));
1368        assert_eq!(stats.chunk, Some((0, 0)));
1369        assert_eq!(stats.texture_bytes, Some((0, 4000)));
1370        // A count-only pool reports a zero budget rather than dropping the row.
1371        assert_eq!(stats.mesh_bytes, Some((0, 0)));
1372        assert_eq!(stats.chunk_bytes, Some((0, 0)));
1373    }
1374
1375    #[test]
1376    fn streaming_stats_reports_nothing_without_pools() {
1377        let stats = empty_state().streaming_stats();
1378        assert!(stats.texture.is_none());
1379        assert!(stats.mesh.is_none());
1380        assert!(stats.chunk.is_none());
1381        assert!(stats.texture_bytes.is_none());
1382        assert!(stats.mesh_bytes.is_none());
1383        assert!(stats.chunk_bytes.is_none());
1384    }
1385
1386    // The pools have no Debug of their own, so StreamingState's is hand-written
1387    // to report which are active rather than trying to dump them.
1388    #[test]
1389    fn debug_reports_the_active_pools_and_the_frame_clock() {
1390        let mut state = pooled_state(8);
1391        state.frame_count = 7;
1392        state.pressure_stage = StreamPressureStage::Gate;
1393
1394        let s = format!("{state:?}");
1395        assert!(s.contains("frame_count: 7"), "{s}");
1396        assert!(s.contains("texture: true"), "{s}");
1397        assert!(s.contains("mesh: true"), "{s}");
1398        assert!(s.contains("chunk: false"), "{s}");
1399        assert!(s.contains("pressure: Gate"), "{s}");
1400    }
1401
1402    #[test]
1403    fn drive_advances_the_frame_clock() {
1404        let (_recorded, mut backend) = recording_backend();
1405        let mut state = empty_state();
1406        drive_once(&mut state, &mut backend, [0.0; 3], IDENTITY4, false, None);
1407        drive_once(&mut state, &mut backend, [0.0; 3], IDENTITY4, false, None);
1408        assert_eq!(state.frame_count, 2);
1409    }
1410
1411    // Behind an opaque menu the world is not drawn, so no pool dispatches: both
1412    // stay fully unloaded rather than paying for loads nothing will show.
1413    #[test]
1414    fn a_hidden_world_dispatches_no_loads() {
1415        let (_recorded, mut backend) = recording_backend();
1416        let mut state = pooled_state(8);
1417        drive_once(&mut state, &mut backend, [0.0; 3], IDENTITY4, true, None);
1418        assert_eq!(state.texture_streamer.as_ref().unwrap().stats(), (0, 0, 2));
1419        assert_eq!(state.mesh_streamer.as_ref().unwrap().stats(), (0, 0, 2));
1420    }
1421
1422    #[test]
1423    fn a_visible_world_dispatches_texture_and_mesh_loads() {
1424        let (_recorded, mut backend) = recording_backend();
1425        let mut state = pooled_state(8);
1426        drive_once(&mut state, &mut backend, [0.0; 3], IDENTITY4, false, None);
1427        // Dispatch moves an item off Unloaded the same frame it is planned.
1428        assert!(state.texture_streamer.as_ref().unwrap().stats().2 < 2);
1429        assert!(state.mesh_streamer.as_ref().unwrap().stats().2 < 2);
1430    }
1431
1432    // Scene residency over the pools: only the pinned scene's members stream;
1433    // switching the pin set drains the old scene and loads the new one.
1434    #[test]
1435    fn scene_residency_streams_only_the_pinned_scene_and_swaps_on_switch() {
1436        use crate::gfx::scene_residency::SceneLoadState;
1437
1438        let (_recorded, mut backend) = recording_backend();
1439        let mut state = pooled_state(8);
1440        let scene_a = AssetId(70);
1441        let scene_b = AssetId(71);
1442        let residency = SceneResidency::new(vec![
1443            (scene_a, vec![(CHANNEL_TEXTURE, 0), (CHANNEL_MESH, 0)]),
1444            (scene_b, vec![(CHANNEL_TEXTURE, 1), (CHANNEL_MESH, 1)]),
1445        ]);
1446        // Mirror init: every owned member starts blocked.
1447        for (channel, id) in residency.all_members().collect::<Vec<_>>() {
1448            match channel {
1449                CHANNEL_TEXTURE => state
1450                    .texture_streamer
1451                    .as_mut()
1452                    .unwrap()
1453                    .set_blocked(id as usize, true),
1454                _ => state
1455                    .mesh_streamer
1456                    .as_mut()
1457                    .unwrap()
1458                    .set_blocked(id as usize, true),
1459            }
1460        }
1461        state.scene_residency = Some(residency);
1462
1463        // Pin scene A: its members stream in; B's stay blocked out.
1464        let pins_a = [scene_a];
1465        for _ in 0..MAX_DRIVE_SPINS {
1466            drive_once(
1467                &mut state,
1468                &mut backend,
1469                [0.0; 3],
1470                IDENTITY4,
1471                false,
1472                Some(&pins_a),
1473            );
1474            let r = state.scene_residency.as_ref().unwrap();
1475            if r.state(scene_a) == Some(SceneLoadState::Resident) {
1476                break;
1477            }
1478            std::thread::yield_now();
1479        }
1480        let r = state.scene_residency.as_ref().unwrap();
1481        assert_eq!(r.state(scene_a), Some(SceneLoadState::Resident));
1482        assert_eq!(r.state(scene_b), Some(SceneLoadState::Unloaded));
1483        assert_eq!(
1484            state.texture_streamer.as_ref().unwrap().stats().0,
1485            1,
1486            "only A's texture is resident"
1487        );
1488
1489        // Switch the pin to scene B: A drains off the GPU, B streams in.
1490        let pins_b = [scene_b];
1491        for _ in 0..MAX_DRIVE_SPINS {
1492            drive_once(
1493                &mut state,
1494                &mut backend,
1495                [0.0; 3],
1496                IDENTITY4,
1497                false,
1498                Some(&pins_b),
1499            );
1500            let r = state.scene_residency.as_ref().unwrap();
1501            if r.state(scene_b) == Some(SceneLoadState::Resident)
1502                && r.state(scene_a) == Some(SceneLoadState::Unloaded)
1503            {
1504                break;
1505            }
1506            std::thread::yield_now();
1507        }
1508        let r = state.scene_residency.as_ref().unwrap();
1509        assert_eq!(r.state(scene_b), Some(SceneLoadState::Resident));
1510        assert_eq!(r.progress(scene_b), Some(1.0));
1511        assert_eq!(r.state(scene_a), Some(SceneLoadState::Unloaded));
1512        assert_eq!(state.texture_streamer.as_ref().unwrap().stats().0, 1);
1513        assert_eq!(state.mesh_streamer.as_ref().unwrap().stats().0, 1);
1514    }
1515
1516    // Stage 1 of the RAM valve holds residency where it is: no new dispatch.
1517    #[test]
1518    fn the_gate_stage_freezes_new_loads() {
1519        let (_recorded, mut backend) = recording_backend();
1520        let mut state = pooled_state(8);
1521        state.chunk_stream = Some(chunk_state(Arc::new(ConstChunk), 0, 0));
1522        state.pressure_stage = StreamPressureStage::Gate;
1523
1524        drive_once(&mut state, &mut backend, [0.0; 3], IDENTITY4, false, None);
1525        assert_eq!(state.texture_streamer.as_ref().unwrap().stats(), (0, 0, 2));
1526        assert_eq!(state.mesh_streamer.as_ref().unwrap().stats(), (0, 0, 2));
1527        assert_eq!(
1528            state.chunk_stream.as_ref().unwrap().streamer.stats(),
1529            (0, 0)
1530        );
1531    }
1532
1533    // Stage 2 keeps planning under the reduced budget, so the planner can still
1534    // shed residents; freezing it instead would strand the pools over budget.
1535    #[test]
1536    fn the_evict_stage_keeps_planning() {
1537        let (_recorded, mut backend) = recording_backend();
1538        let mut state = pooled_state(8);
1539        state.pressure_stage = StreamPressureStage::Evict;
1540        drive_once(&mut state, &mut backend, [0.0; 3], IDENTITY4, false, None);
1541        assert!(state.texture_streamer.as_ref().unwrap().stats().2 < 2);
1542        assert!(state.mesh_streamer.as_ref().unwrap().stats().2 < 2);
1543    }
1544
1545    // Completed loads route to the backend: a texture by pool slot, a mesh
1546    // through its stream-id -> draw-slot map.
1547    #[test]
1548    fn completed_loads_are_uploaded_to_the_backend() {
1549        let (recorded, mut backend) = recording_backend();
1550        let mut state = pooled_state(8);
1551        drive_until(&mut state, &mut backend, [0.0; 3], |s| {
1552            s.texture_streamer.as_ref().unwrap().stats().0 == 2
1553                && s.mesh_streamer.as_ref().unwrap().stats().0 == 2
1554        });
1555
1556        let s = recorded.lock().unwrap();
1557        assert!(s.saw(&Call::UpdateTextureSlot {
1558            slot: 0,
1559            w: 1,
1560            h: 1
1561        }));
1562        assert!(s.saw(&Call::UpdateTextureSlot {
1563            slot: 1,
1564            w: 1,
1565            h: 1
1566        }));
1567        assert!(s.saw(&Call::UploadMesh {
1568            draw_idx: 10,
1569            vertices: 3,
1570            indices: 3,
1571        }));
1572        assert!(s.saw(&Call::UploadMesh {
1573            draw_idx: 11,
1574            vertices: 3,
1575            indices: 3,
1576        }));
1577    }
1578
1579    // A streamed mesh with no draw slot has nowhere to upload to; the drive
1580    // must swallow it rather than mis-routing the geometry onto another draw.
1581    #[test]
1582    fn a_streamed_mesh_without_a_draw_slot_uploads_nothing() {
1583        let (recorded, mut backend) = recording_backend();
1584        let mut state = pooled_state(8);
1585        state.mesh_stream_draw_indices.clear();
1586        drive_until(&mut state, &mut backend, [0.0; 3], |s| {
1587            s.mesh_streamer.as_ref().unwrap().stats().0 == 2
1588        });
1589        assert!(
1590            !recorded
1591                .lock()
1592                .unwrap()
1593                .calls
1594                .iter()
1595                .any(|c| matches!(c, Call::UploadMesh { .. }))
1596        );
1597    }
1598
1599    // Over the resident cap, the pools shed the item the camera has left
1600    // behind so the nearer one can take its place.
1601    #[test]
1602    fn moving_the_camera_evicts_the_now_distant_item_over_the_cap() {
1603        let (recorded, mut backend) = recording_backend();
1604        // Cap of 1: only one texture / mesh may be resident at a time.
1605        let mut state = pooled_state(1);
1606        drive_until(&mut state, &mut backend, [0.0; 3], |s| {
1607            s.texture_streamer.as_ref().unwrap().stats().0 == 1
1608                && s.mesh_streamer.as_ref().unwrap().stats().0 == 1
1609        });
1610        recorded.lock().unwrap().calls.clear();
1611
1612        // Item 1's center is now the near one, so item 0 is displaced.
1613        drive_once(
1614            &mut state,
1615            &mut backend,
1616            [100.0, 0.0, 0.0],
1617            IDENTITY4,
1618            false,
1619            None,
1620        );
1621        let s = recorded.lock().unwrap();
1622        assert!(s.saw(&Call::EvictTextureSlot(0)), "{:?}", s.calls);
1623        assert!(s.saw(&Call::EvictMesh(10)), "{:?}", s.calls);
1624    }
1625
1626    #[test]
1627    fn without_chunk_streaming_the_view_and_camera_stay_absolute() {
1628        let (_recorded, mut backend) = recording_backend();
1629        let mut state = empty_state();
1630        let view = translation_view(-40.0, -5.0, 40.0);
1631        let cam = [40.0, 5.0, -40.0];
1632        assert_eq!(
1633            drive_once(&mut state, &mut backend, cam, view, false, None),
1634            (view, cam)
1635        );
1636    }
1637
1638    // An unbounded world renders from small coordinates: both the view and the
1639    // camera are rebased onto the camera's own chunk.
1640    #[test]
1641    fn chunk_streaming_rebases_the_view_onto_the_camera_chunk() {
1642        let (_recorded, mut backend) = recording_backend();
1643        let mut state = empty_state();
1644        state.chunk_stream = Some(chunk_state(Arc::new(FailingChunk), 0, 0));
1645        // 16-unit chunks: floor(40/16) = 2, floor(-40/16) = -3.
1646        let cam = [40.0, 5.0, -40.0];
1647        let view = translation_view(-40.0, -5.0, 40.0);
1648        let (out_view, out_cam) = drive_once(&mut state, &mut backend, cam, view, false, None);
1649
1650        let origin = [32.0, 0.0, -48.0];
1651        assert_eq!(out_cam, [8.0, 5.0, 8.0]);
1652        assert_eq!(
1653            out_view,
1654            crate::gfx::chunk_coord::camera_relative_view(view, cam, origin)
1655        );
1656        assert_ne!(out_view, view, "the rebase actually rewrote the view");
1657        assert_eq!(
1658            state.chunk_stream.as_ref().unwrap().origin_chunk,
1659            ChunkCoord::new(2, -3)
1660        );
1661    }
1662
1663    // Crossing a chunk boundary moves the render origin and re-places every
1664    // resident chunk against it; staying put must not re-push identical models.
1665    #[test]
1666    fn crossing_into_a_new_chunk_rebases_resident_chunk_models() {
1667        let (recorded, mut backend) = recording_backend();
1668        let mut state = empty_state();
1669        let mut cs = chunk_state(Arc::new(FailingChunk), 1, 1);
1670        // Stand in for two chunks already uploaded at draw slots 3 and 4.
1671        cs.draws.insert(ChunkCoord::new(0, 0), 3);
1672        cs.draws.insert(ChunkCoord::new(1, 0), 4);
1673        state.chunk_stream = Some(cs);
1674
1675        drive_once(
1676            &mut state,
1677            &mut backend,
1678            [20.0, 0.0, 0.0],
1679            IDENTITY4,
1680            false,
1681            None,
1682        );
1683        {
1684            let s = recorded.lock().unwrap();
1685            assert!(s.saw(&Call::SetChunkModel(3)));
1686            assert!(s.saw(&Call::SetChunkModel(4)));
1687        }
1688        assert_eq!(
1689            state.chunk_stream.as_ref().unwrap().origin_chunk,
1690            ChunkCoord::new(1, 0)
1691        );
1692
1693        recorded.lock().unwrap().calls.clear();
1694        drive_once(
1695            &mut state,
1696            &mut backend,
1697            [21.0, 0.0, 0.0],
1698            IDENTITY4,
1699            false,
1700            None,
1701        );
1702        assert!(
1703            !recorded
1704                .lock()
1705                .unwrap()
1706                .calls
1707                .iter()
1708                .any(|c| matches!(c, Call::SetChunkModel(_)))
1709        );
1710    }
1711
1712    // A chunk that leaves the view window releases its draw slot on the
1713    // backend and stops being tracked as resident.
1714    #[test]
1715    fn chunks_leaving_the_view_window_are_removed_from_the_backend() {
1716        let (recorded, mut backend) = recording_backend();
1717        let mut state = empty_state();
1718        let mut cs = chunk_state(Arc::new(FailingChunk), 0, 0);
1719        // Stand in for chunk (0, 0) already uploaded at draw slot 9.
1720        cs.draws.insert(ChunkCoord::new(0, 0), 9);
1721        state.chunk_stream = Some(cs);
1722
1723        // Frame 1 at the origin puts (0, 0) in the window.
1724        drive_once(&mut state, &mut backend, [0.0; 3], IDENTITY4, false, None);
1725        // Frame 2 far east: (0, 0) is past the evict band.
1726        drive_once(
1727            &mut state,
1728            &mut backend,
1729            [800.0, 0.0, 0.0],
1730            IDENTITY4,
1731            false,
1732            None,
1733        );
1734
1735        assert!(recorded.lock().unwrap().saw(&Call::RemoveChunkMesh(9)));
1736        assert!(
1737            !state
1738                .chunk_stream
1739                .as_ref()
1740                .unwrap()
1741                .draws
1742                .contains_key(&ChunkCoord::new(0, 0))
1743        );
1744    }
1745
1746    // A generated chunk is added to the backend and its returned draw slot
1747    // recorded, so a later rebase or eviction can find it.
1748    #[test]
1749    fn a_generated_chunk_is_added_and_its_draw_slot_tracked() {
1750        let (recorded, mut backend) = recording_backend();
1751        let mut state = empty_state();
1752        state.chunk_stream = Some(chunk_state(Arc::new(ConstChunk), 0, 0));
1753        drive_until(&mut state, &mut backend, [0.0; 3], |s| {
1754            s.chunk_stream.as_ref().unwrap().streamer.stats().0 == 1
1755        });
1756
1757        let cs = state.chunk_stream.as_ref().unwrap();
1758        assert_eq!(cs.draws.get(&ChunkCoord::new(0, 0)), Some(&0));
1759        assert!(recorded.lock().unwrap().saw(&Call::AddChunkMesh));
1760    }
1761
1762    #[test]
1763    fn a_step_without_parked_state_publishes_no_view() {
1764        let mut w = StepWorld::new();
1765        assert_eq!(w.step(), StepResult::Continue);
1766        assert!(w.resources.get::<CameraRelativeView>().is_none());
1767    }
1768
1769    // Init succeeded but the backend is gone: the draw still needs a view, so
1770    // the absolute camera is published and the state stays parked.
1771    #[test]
1772    fn a_step_without_a_backend_still_publishes_the_absolute_view() {
1773        let view = translation_view(-1.0, -2.0, -3.0);
1774        let mut w = StepWorld::new().with_camera([1.0, 2.0, 3.0], view);
1775        w.resources.insert(empty_state());
1776
1777        assert_eq!(w.step(), StepResult::Continue);
1778        assert_eq!(w.view().cam_pos, [1.0, 2.0, 3.0]);
1779        assert_eq!(w.view().view, view);
1780        assert_eq!(w.parked_state().frame_count, 0, "no frame was driven");
1781    }
1782
1783    #[test]
1784    fn a_step_without_a_camera_publishes_the_identity_view() {
1785        let mut w = StepWorld::new();
1786        w.resources.insert(empty_state());
1787        w.park_render_queues();
1788
1789        w.step();
1790        assert_eq!(w.view().view, IDENTITY4);
1791        assert_eq!(w.view().cam_pos, [0.0; 3]);
1792    }
1793
1794    // The op queue + slot allocator are taken for the step and parked again
1795    // for the systems that follow, and the frame clock ticks once per step.
1796    #[test]
1797    fn a_step_drives_the_pools_and_reparks_the_queues() {
1798        let mut w = StepWorld::new().with_camera([0.0; 3], IDENTITY4);
1799        w.resources.insert(pooled_state(8));
1800        w.park_render_queues();
1801
1802        w.step();
1803        assert!(
1804            w.resources
1805                .get::<crate::ecs::ActiveRenderQueues>()
1806                .is_some_and(|slot| slot.0.is_some())
1807        );
1808        assert_eq!(w.parked_state().frame_count, 1);
1809        assert!(
1810            w.parked_state()
1811                .texture_streamer
1812                .as_ref()
1813                .unwrap()
1814                .stats()
1815                .2
1816                < 2,
1817            "the pools were driven"
1818        );
1819    }
1820
1821    // The overlay's flag is peeked, not taken: streaming pauses behind an
1822    // opaque menu, and GraphicsSystem still finds the frame later this tick.
1823    #[test]
1824    fn an_opaque_overlay_suspends_streaming_without_consuming_the_frame() {
1825        let mut w = StepWorld::new().with_camera([0.0; 3], IDENTITY4);
1826        w.resources.insert(pooled_state(8));
1827        w.park_render_queues();
1828        w.resources.insert(OverlayFrame {
1829            world_hidden: true,
1830            ..Default::default()
1831        });
1832
1833        w.step();
1834        assert_eq!(
1835            w.parked_state().texture_streamer.as_ref().unwrap().stats(),
1836            (0, 0, 2)
1837        );
1838        assert!(w.resources.get::<OverlayFrame>().is_some());
1839    }
1840
1841    // RSS is a syscall, so the valve only samples on its throttled cadence.
1842    #[test]
1843    fn ram_pressure_is_sampled_only_on_the_throttled_cadence() {
1844        let mut w = StepWorld::new();
1845        let mut state = empty_state();
1846        state.frame_count = 1;
1847        w.resources.insert(state);
1848        // A 1 MiB ceiling is under any real process RSS, so a sample that ran
1849        // would certainly engage the valve.
1850        w.resources
1851            .insert(crate::app::budget::MemoryBudget::compute(None, 1));
1852
1853        w.step();
1854        assert!(w.resources.get::<StreamingPressure>().is_none());
1855    }
1856
1857    // No published ceiling means no valve: streaming stays on its byte-budget
1858    // policy and nothing is reported.
1859    #[test]
1860    fn ram_pressure_is_not_sampled_without_a_memory_budget() {
1861        let mut w = StepWorld::new();
1862        w.resources.insert(empty_state());
1863        w.step();
1864        assert!(w.resources.get::<StreamingPressure>().is_none());
1865    }
1866
1867    #[test]
1868    fn an_rss_sample_over_the_memory_budget_publishes_engaged_pressure() {
1869        let mut w = StepWorld::new();
1870        w.resources.insert(empty_state());
1871        w.resources
1872            .insert(crate::app::budget::MemoryBudget::compute(None, 1));
1873
1874        w.step();
1875        // The valve is inert (and publishes nothing) where RSS cannot be read.
1876        match crate::app::sysmem::process_resident_bytes() {
1877            Some(_) => {
1878                let p = w.resources.get::<StreamingPressure>().expect("published");
1879                assert!(p.under_pressure);
1880                assert_eq!(p.budget_bytes, 1024 * 1024);
1881                assert_ne!(w.parked_state().pressure_stage, StreamPressureStage::None);
1882            }
1883            None => assert!(w.resources.get::<StreamingPressure>().is_none()),
1884        }
1885    }
1886}