Skip to main content

rlx_core/render/scenes/
mod.rs

1//! Built-in scenes and the thin trait the renderer cycles through.
2//!
3//! Per ADR-0002 this stays crate-internal and minimal: it is the vocabulary
4//! the future preset engine will drive, not a public extension point — no
5//! plugin registration, no dynamic dispatch beyond what cycling needs.
6
7// Hot-path panic-denial pragma (Plan 0002 Phase 2, extended to scenes by Plan
8// 0003 Phase 0). Scene update/render run every displayed frame; a panic here
9// is a visible crash mid-show.
10#![deny(
11    clippy::unwrap_used,
12    clippy::expect_used,
13    clippy::indexing_slicing,
14    clippy::panic,
15    clippy::unreachable
16)]
17
18pub(crate) mod common;
19pub mod emitter;
20pub mod fragment_field;
21pub mod lines;
22/// The shared mark-silhouette vocabulary the two particle scenes draw through
23/// (ADR-0084). Crate-internal: it is arithmetic and a roster, not a scene.
24pub(crate) mod marks;
25pub mod particles;
26pub mod reaction_diffusion;
27pub mod shape_collage;
28pub mod shape_field;
29pub mod swarm;
30pub mod warp_mesh;
31
32use std::cell::RefCell;
33use std::rc::Rc;
34
35use crate::dsp::AnalysisFrame;
36use crate::preset::SystemKind;
37use crate::render::palette::Palette;
38
39/// The `dt` (seconds) the C ABI's legacy `rlx_render` and the headless capture
40/// primitives inject when a caller has no real elapsed time to supply — the
41/// former fixed scene step, now demoted to a fallback (Plan 0014 Phase 2, ADR-0012).
42/// The live frontends measure and inject real `dt` instead, so animation is
43/// frame-rate-independent; capture uses this fixed value so a render is a pure
44/// function of its inputs.
45pub(crate) const FALLBACK_DT: f32 = 1.0 / 60.0;
46
47/// What a parameter is **for** (ADR-0180 rule 2): whether its value carries an
48/// integer meaning, which decides two things and nothing else — whether the
49/// engine quantizes it before the scene sees it, and which of the generated
50/// reference's two groups it prints under.
51///
52/// Orthogonal to `[hold]`: a hold reaches any bindable parameter whatever its
53/// kind, and a kind quantizes whether or not the binding is held.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
55pub enum ParamKind {
56    /// Continuous — every value in the range means something, and the scene
57    /// reads the fraction. The default, and what every parameter was before
58    /// kinds existed.
59    #[default]
60    Modal,
61    /// Integer meaning: a mode number, a rule index, a count, a family. The
62    /// engine rounds the post-smoothing value **once**, CPU-side, before
63    /// `set_param`, so the scene is never handed 6.4 petals.
64    Structural,
65}
66
67impl ParamKind {
68    /// The name the generated reference and the exported schema print. Two
69    /// readers, one spelling.
70    pub fn as_str(self) -> &'static str {
71        match self {
72            ParamKind::Modal => "modal",
73            ParamKind::Structural => "structural",
74        }
75    }
76
77    /// `value` as the scene should receive it: rounded for a
78    /// [`Structural`](Self::Structural) parameter, untouched for a
79    /// [`Modal`](Self::Modal) one.
80    ///
81    /// **The last step before `set_param`**, after the hold has chosen which
82    /// frame's value stands and the smoother has eased toward it — which is
83    /// why a structural parameter that is *also* smoothed steps through the
84    /// intervening integers rather than landing fractionally. An author who
85    /// wants a clean jump leaves it out of `[smoothing]`.
86    ///
87    /// A non-finite value is passed through rather than rounded: `f32::round`
88    /// leaves `NaN` alone anyway, and the scenes already guard their own
89    /// inputs.
90    pub fn quantize(self, value: f32) -> f32 {
91        match self {
92            ParamKind::Modal => value,
93            ParamKind::Structural => value.round(),
94        }
95    }
96}
97
98/// What one named parameter is, as the engine declares it (ADR-0170).
99///
100/// A scene and an engine stage each declare their parameters as a `&[ParamSpec]`
101/// rather than as a bare `&[&str]`. Three things read the same declaration: the
102/// load-time check that a preset binding names something real, the constant a
103/// scene applies at reset, and the generated reference table in
104/// `presets/README.md`. Before this they were three copies, and only the names
105/// were held together.
106///
107/// **The doc line is the definition; the roster's essay is the discussion.**
108/// One sentence, saying what the parameter does — not restating its name.
109#[derive(Debug, Clone, Copy, PartialEq)]
110pub struct ParamSpec {
111    /// The name a preset binds, exactly as it is spelled in a `.toml`.
112    pub name: &'static str,
113    /// The value `reset_params` applies, and the one the reference prints.
114    ///
115    /// Read back out with [`default_of`], which resolves at compile time — so
116    /// the scene's `DEFAULT_*` constants and this field are one number, not two.
117    pub default: f32,
118    /// The range that **reads**, for the table.
119    ///
120    /// Not a clamp and not a validation bound: it is what an author can expect
121    /// to see a difference across. `None` where the parameter is unbounded or
122    /// world-space, in which case the frame is the bound — inventing a number
123    /// there would be a claim nothing holds.
124    pub range: Option<[f32; 2]>,
125    /// One sentence: what the parameter does.
126    pub doc: &'static str,
127    /// Whether the value carries an integer meaning (ADR-0180 rule 2).
128    ///
129    /// Read back out with [`kind_of`], and enforced by
130    /// `declared_params_match_set_param` in `core/tests/preset.rs`, which
131    /// compares this against a hand-kept roster — a field nothing checks is a
132    /// field that drifts.
133    pub kind: ParamKind,
134}
135
136/// Byte-wise `str` equality, usable in a `const fn`.
137///
138/// `PartialEq` for `str` is not const, and this is only ever called at compile
139/// time over rosters of a few dozen entries.
140#[allow(
141    clippy::indexing_slicing,
142    reason = "compile-time only; a const fn cannot call `slice::get`, the bound is checked above each index, and a violation is a compile error"
143)]
144const fn str_eq(a: &str, b: &str) -> bool {
145    let (a, b) = (a.as_bytes(), b.as_bytes());
146    if a.len() != b.len() {
147        return false;
148    }
149    let mut i = 0;
150    while i < a.len() {
151        if a[i] != b[i] {
152            return false;
153        }
154        i += 1;
155    }
156    true
157}
158
159/// The default `name` is declared with, resolved **at compile time**.
160///
161/// This is what makes a scene's `DEFAULT_*` constant and its `ParamSpec` one
162/// number rather than two copies of one: the constant is defined as a call to
163/// this, so a default changed in the spec changes what `reset_params` applies,
164/// and the generated table cannot state a value the engine does not use.
165///
166/// The linear scan costs nothing — it never runs at runtime — and a name with no
167/// spec is a **compile error**, because a const-eval panic is.
168#[allow(
169    clippy::indexing_slicing,
170    clippy::panic,
171    reason = "compile-time only; the panic is the point, since a name no spec declares is then a compile error rather than anything a frame reaches"
172)]
173pub const fn default_of(specs: &[ParamSpec], name: &str) -> f32 {
174    let mut i = 0;
175    while i < specs.len() {
176        if str_eq(specs[i].name, name) {
177            return specs[i].default;
178        }
179        i += 1;
180    }
181    panic!("default_of: no ParamSpec declares that name");
182}
183
184/// The names in a spec roster, for the places that still want `&[&str]`.
185///
186/// Allocates, so it is for load-time validation and tests rather than a frame.
187pub fn spec_names(specs: &[ParamSpec]) -> Vec<&'static str> {
188    specs.iter().map(|spec| spec.name).collect()
189}
190
191/// Whether `name` is declared in `specs`. The load-time membership test.
192pub fn declares(specs: &[ParamSpec], name: &str) -> bool {
193    specs.iter().any(|spec| spec.name == name)
194}
195
196/// The [`ParamKind`] `name` is declared with in `specs`, or `None` where no
197/// spec here declares it.
198///
199/// Load-time, like [`declares`]: the loader folds the answer onto the binding
200/// so nothing per frame searches a roster by name (`[smoothing]`'s rule, for
201/// its reason). A name no roster declares is already an ADR-0020 warning, and
202/// an unclaimed binding reaches no scene, so its kind never matters.
203pub fn kind_of(specs: &[ParamSpec], name: &str) -> Option<ParamKind> {
204    specs
205        .iter()
206        .find(|spec| spec.name == name)
207        .map(|spec| spec.kind)
208}
209
210/// One integrated animation phase — the only way a bindable rate advances
211/// anything in this engine (ADR-0135, finishing the rule ADR-0132 stated).
212///
213/// **A rate multiplier has to be integrated to be a rate at all.** A phase
214/// computed as `time * rate` lets a rate bound to audio retroactively rescale
215/// *all* elapsed time on every frame: at t = 100 s a swing from `1.0` to `1.5`
216/// moves the phase by fifty seconds in a single frame — the figure snaps to a
217/// new position rather than accelerating toward it, on a lane whose whole
218/// method is binding parameters to audio. Integrated, the same swing bends the
219/// motion.
220///
221/// [`step`](Self::step) is the **only** mutator, and there is deliberately no
222/// `Add`/`AddAssign`/`Deref`/`DerefMut` impl. The constraint is the value: with
223/// one, a scene could write `phase + self.rate * self.time` and compile, which
224/// is exactly the door this type exists to close.
225///
226/// # No constant scale is folded into the accumulation
227///
228/// A scene carrying its own fixed rate — the attractor's `SPIN_RATE` — applies
229/// it where the phase is **read**, never inside the sum. The accumulator is then
230/// `Σ (rate · dt)` with `rate` at its `1.0` default, i.e. `Σ dt` term for term:
231/// bit-for-bit the same summation the renderer performs for its own clock, so
232/// the integrated form reproduces the multiply it replaced *exactly* and no
233/// golden baseline moves. Folding a `0.18` in would sum `0.18 · dt` instead and
234/// drift in the last bits of every capture.
235///
236/// # It steps in `update`, never in `advance`
237///
238/// The per-frame order is `set_time` → `advance` → `reset_params` → `set_param`
239/// → `update` (`core/src/render/mod.rs`), so a scene stores the `dt` that
240/// [`Scene::advance`] hands it and integrates in [`Scene::update`], where *this*
241/// frame's rate has landed. Integrating in `advance` would use the previous
242/// frame's.
243///
244/// The type is arithmetic with no device in it, which is what keeps every rate
245/// in the engine testable on the CPU without rendering anything.
246#[derive(Clone, Copy, Default, Debug, PartialEq)]
247pub(crate) struct Phase(f32);
248
249impl Phase {
250    /// One frame's integration, at *this* frame's rate.
251    pub(crate) fn step(&mut self, rate: f32, dt: f32) {
252        self.0 += rate * dt;
253    }
254
255    /// The accumulated phase, in rate-scaled seconds. A scene with its own
256    /// constant scale applies it here, on the read.
257    pub(crate) fn get(self) -> f32 {
258        self.0
259    }
260}
261
262/// Declarative structural config a scene consumes once at preset load
263/// (ADR-0007): **not** expressions — the family / grammar / tiling the sampler
264/// or generator builds from. Delivered through the optional
265/// `Scene::configure` hook, off the hot path. This is
266/// the shared structural-config enum for every scene that has one: the line
267/// scenes' curve/L-system/star variants, plus the compute-particle attractor
268/// family (Plan 0016) — it lives here rather than in `lines/` so `lines` never has to name a
269/// `particles` type (Plan 0031 Phase 6).
270#[derive(Debug, Clone)]
271pub enum GeneratorConfig {
272    /// A parametric curve: which family to sample.
273    Curve {
274        /// The curve family (Maurer rose, ...).
275        family: lines::CurveFamily,
276    },
277    /// An L-system: a grammar the generator expands and turtle-walks at load,
278    /// caching one segment buffer per depth.
279    LSystem {
280        /// The starting string.
281        axiom: String,
282        /// Production rules `(predecessor, successor)`.
283        rules: Vec<(char, String)>,
284        /// Turn angle in degrees for `+`/`-`.
285        angle_deg: f32,
286        /// Iterations to precompute (`1..=max_depth`), clamped to
287        /// [`lines::MAX_LSYSTEM_DEPTH`] at load.
288        max_depth: u32,
289        /// Reserved seed for future stochastic rules; deterministic today.
290        seed: u64,
291    },
292    /// A Hankin star pattern: an `n`-fold star rosette built at load, with a few
293    /// contact-angle variants a beat can switch between — and, since ADR-0079,
294    /// an optional ring ornament drawn inside it.
295    Star {
296        /// Star order `n` (from the tiling), e.g. 6 or 12. **`0` means no
297        /// interlace at all** (`tiling = "none"`), which the loader accepts only
298        /// alongside a non-empty `rings` roster — the ornament drawn alone.
299        order: u32,
300        /// Contact angle in degrees; variants are precomputed around it.
301        contact_angle_deg: f32,
302        /// The `[generator] rings` roster (ADR-0079): concentric rings of
303        /// repeated motifs filling the interior the rosette leaves hollow. Empty
304        /// — the default, and what an absent `rings` key means — is exactly the
305        /// pre-Plan-0065 scene.
306        rings: Vec<lines::star::RingSpec>,
307    },
308    /// A GPU compute-particle attractor (Plan 0016): which strange-attractor map
309    /// the compute step iterates. Not a line scene — reuses this shared enum so
310    /// the family rides the existing `configure` hook (no new trait method).
311    Particles {
312        /// The attractor family (De Jong, Clifford, Thomas, Lorenz, or one of
313        /// the IFS figures).
314        family: particles::AttractorFamily,
315        /// The figure the bindable `morph` param travels **towards** (ADR-0075),
316        /// from `[particles] morph_to`. `None` — the default — pins the figure,
317        /// so `morph` is inert.
318        ///
319        /// IFS-only, and validated as such at load: `morph_to` on a map family
320        /// is a load error rather than a silent no-op, because the author asked
321        /// for something the engine cannot do.
322        morph_to: Option<particles::ifs::IfsFigure>,
323        /// Fraction of the tier's particle budget actually drawn (ADR-0069),
324        /// validated at load into
325        /// [`MIN_PARTICLE_DENSITY`](particles::MIN_PARTICLE_DENSITY)`..=1.0`.
326        /// Structural, not bindable: an eased integer count would re-decide the
327        /// picture every frame. `1.0` is the whole budget and the default.
328        density: f32,
329        /// The **tuple path** the bindable `morph` param walks along on a map
330        /// family (ADR-0093), as `(from, to)` roster indices from
331        /// `[particles] tuple_from` / `tuple_to`. `None` — the default — means
332        /// there is no path and `morph` is inert, exactly as `morph_to` does for
333        /// the IFS.
334        ///
335        /// **Both ends are structural on purpose.** The walk's framing is
336        /// measured across it at load, which is thousands of map iterations; a
337        /// path whose near end came from the per-frame `tuple` param would have
338        /// to re-measure inside the frame loop every time that param moved.
339        ///
340        /// Map-family-only, and validated as such at load — the IFS reaches its
341        /// own figure-to-figure travel through `morph_to` instead, and a tuple
342        /// path on an IFS is a load error rather than a silent no-op.
343        tuple_path: Option<(u32, u32)>,
344    },
345    /// The spectrum readout's `[spectrum]` table (Plan 0034 / ADR-0036): how many
346    /// elements the frequency axis is divided into, how they are laid out, and how
347    /// fast each one follows its band. All three are structure rather than
348    /// expression — they are fixed for as long as the preset is loaded — so they
349    /// ride the existing `configure` hook like every other declarative config.
350    Spectrum {
351        /// Element count, validated at load into
352        /// `2..=`[`SPECTRUM_BINS`](crate::dsp::SPECTRUM_BINS).
353        elements: usize,
354        /// Which figure the elements form.
355        layout: lines::SpectrumLayout,
356        /// Per-element temporal easing in **seconds**, applied on the injected
357        /// real `dt` — the same [`Easing`](crate::preset::Easing) the `[smoothing]`
358        /// table uses, deliberately reused rather than a second vocabulary
359        /// (ADR-0035).
360        easing: crate::preset::Easing,
361    },
362    /// The warp mesh's `[mesh]` table (Plan 0100 / ADR-0113): the grid, in
363    /// cells, that the per-vertex program is evaluated over.
364    ///
365    /// Structural for `[curve] family`'s reason — the vertex and index buffers
366    /// are built from it, so an eased grid would rebuild them mid-frame — and
367    /// **clamped to the tier at both consumers** rather than at load, since the
368    /// loader does not know which tier will render the preset
369    /// ([`warp_mesh::clamp_grid`]).
370    WarpMesh {
371        /// Requested cells, `(x, y)`, validated at load into
372        /// [`MIN_MESH`](warp_mesh::MIN_MESH)`..=`[`MAX_MESH`](warp_mesh::MAX_MESH).
373        mesh: (u32, u32),
374        /// The compiled EEL2 programs a **converted** preset carries, from a
375        /// `[milk]` table (Plan 0100 Phase 2 / ADR-0113). `None` — a
376        /// hand-authored `warp_mesh` preset — drives the mesh from the ordinary
377        /// `[params]` and `[per_vertex]` bindings instead, and executes no VM at
378        /// all.
379        ///
380        /// Boxed because it is much the largest thing this enum carries and every
381        /// other variant would pay for it by value.
382        milk: Option<Box<crate::milk::MilkBundle>>,
383        /// The salt the bundle's `rand()` draws under (ADR-0051).
384        ///
385        /// **The preset's `pinned_salt`, always** — its declared numeric seed, or
386        /// `0` where it declared `seed = "random"`. So a bundle is a pure
387        /// function of its inputs in the live app as well as in the harness,
388        /// which is stronger than ADR-0051 requires and costs nothing: per-run
389        /// variety is opt-in through `seed = "random"`, and no *converted* preset
390        /// declares one. A hand-written bundle that does gets the pinned
391        /// behaviour, and that is stated rather than discovered.
392        salt: u32,
393    },
394    /// The shape field's `[path]` table (ADR-0107): an authored silhouette, as a
395    /// closed contour parsed once at load from inline SVG path data.
396    ///
397    /// The one variant here carrying **geometry** rather than a selector or a
398    /// size. It rides `configure` for the reason every other structural table
399    /// does — it is fixed for as long as the preset is loaded — and a
400    /// `shape_field` preset declaring no table gets `None` and draws the closed
401    /// `marks` roster exactly as it did before paths existed.
402    Path {
403        /// The contour, normalized into `[-1, 1]` and resampled to the arity
404        /// `[path] samples` asked for. `None` — a `shape_field` preset that
405        /// declares no table — is what makes this config **always `Some`**: it
406        /// is handed over on every preset switch precisely so `configure` runs
407        /// and clears the outgoing preset's contour, the same reason the
408        /// attractor's and the spectrum's configs are unconditional.
409        shape: Option<crate::preset::path::PathShape>,
410        /// The silhouette the bindable `morph` param travels **towards**, from
411        /// `[path] morph_to`. `None` — the default — pins the figure, so `morph`
412        /// is inert, exactly as the attractor's own `morph_to` does.
413        ///
414        /// Already **aligned** to `shape` at load: same arity, same winding, and
415        /// the cyclic start that minimises total displacement (ADR-0107). The
416        /// render layer interpolates the two point lists and re-derives none of
417        /// that, which is what keeps an `O(N^2)` search off the frame.
418        morph_to: Option<crate::preset::path::PathShape>,
419    },
420}
421
422impl GeneratorConfig {
423    /// How many elements a per-element binding should be evaluated for under this
424    /// config, or `0` when the system has no per-element surface (Plan 0034 Phase
425    /// 4). Read once at preset load to size the render layer's scratch.
426    ///
427    /// The count lives here rather than on `Scene` because it is **preset data**:
428    /// it comes off the `[spectrum]` table, not out of the scene's state, and the
429    /// renderer already holds the preset.
430    pub fn element_count(&self) -> usize {
431        match self {
432            GeneratorConfig::Spectrum { elements, .. } => *elements,
433            GeneratorConfig::Curve { .. }
434            | GeneratorConfig::LSystem { .. }
435            | GeneratorConfig::Star { .. }
436            | GeneratorConfig::Particles { .. }
437            | GeneratorConfig::WarpMesh { .. }
438            | GeneratorConfig::Path { .. } => 0,
439        }
440    }
441}
442
443/// Which construction hit the segment cap, for the surfaced message.
444///
445/// An enum rather than a `String` because one of the two producers is **per
446/// frame**: with a `String`, an audio-driven `mirror_order` sitting over the cap
447/// builds a fresh `format!("mirror x{order}")` on every single frame for as long
448/// as it stayed there — a heap allocation on the hot path (Plan 0031 Phase 4).
449/// The formatting now happens only in [`Display`](std::fmt::Display), i.e. only
450/// when something actually prints it.
451#[derive(Debug, Clone, Copy, PartialEq, Eq)]
452pub enum OverflowContext {
453    /// An N-fold geometry mirror replicated past the cap — per frame, from the
454    /// `mirror_order` param (Plan 0018 Phase 4).
455    Mirror(u32),
456    /// An L-system depth expanded past the cap — once, at preset load.
457    Depth(u32),
458}
459
460impl std::fmt::Display for OverflowContext {
461    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
462        // These two renderings are the user-visible text ADR-0007 requires stay
463        // informative; the shell prints them verbatim. Do not reword them
464        // without meaning to change what an operator sees.
465        match self {
466            OverflowContext::Mirror(order) => write!(f, "mirror x{order}"),
467            OverflowContext::Depth(depth) => write!(f, "depth {depth}"),
468        }
469    }
470}
471
472/// Reported when building a line scene's geometry hit the segment cap and
473/// truncated. The cap must never be a silent cut (ADR-0007 Risks), so it travels
474/// to the frontend two ways: out of `Scene::configure` at preset load, and off
475/// `Scene::mirror_overflow` for the per-frame mirror. `None` is the normal case
476/// where geometry fit.
477#[derive(Debug, Clone, Copy, PartialEq, Eq)]
478pub struct CapOverflow {
479    /// How many draw segments were dropped at the cap.
480    pub dropped: usize,
481    /// Where the drop happened, for the surfaced message.
482    pub context: OverflowContext,
483    /// **The cap that bit**, carried rather than read from a constant: it is a
484    /// tier value now (Plan 0044), so the same preset overflows at 20 000
485    /// segments on the floor and not at all at 60 000 on rich. A message naming a
486    /// cap the run was not using would be worse than no message.
487    pub cap: usize,
488}
489
490impl std::fmt::Display for CapOverflow {
491    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
492        write!(
493            f,
494            "geometry exceeded the {}-segment cap at {} (dropped {} segment(s)); \
495             reduce the structure or its depth",
496            self.cap, self.context, self.dropped
497        )
498    }
499}
500
501/// One visual. `update` advances state from the analysis frame; `render` draws
502/// with the state it has.
503///
504/// Both built-in systems (fragment field, swarm) are preset-driven and
505/// implement the named-parameter surface — `set_time`, `reset_params`,
506/// `set_param` — that the preset layer evaluates into per frame (ADR-0002). The
507/// trait carries no-op defaults so a future non-parametric scene need not.
508pub(crate) trait Scene {
509    fn name(&self) -> &'static str;
510    fn update(&mut self, frame: &AnalysisFrame);
511    fn render(
512        &mut self,
513        queue: &wgpu::Queue,
514        encoder: &mut wgpu::CommandEncoder,
515        view: &wgpu::TextureView,
516        aspect: f32,
517    );
518
519    /// The pixel size of the target **this scene renders into this frame**
520    /// (ADR-0030). That is *not* always the surface: the composite chain routes
521    /// the scene into the first active post stage's input, which is a fixed
522    /// internal grid for the trails and kaleidoscope stages and the surface
523    /// otherwise, so the only correct value is the one the chain reports back
524    /// (`PostChain::begin`).
525    /// A scene that accumulates into an internal offscreen field sizes that field
526    /// from here, so it matches its target instead of upscaling from a fixed grid
527    /// or supersampling into a smaller offscreen; every other scene ignores it.
528    ///
529    /// **Called unconditionally every frame**, immediately before
530    /// [`render`](Self::render) — it is named for what it carries, not for an
531    /// event, and there is no resize event behind it. So ADR-0030 condition 2
532    /// binds every implementor: **compare against what you already built and do
533    /// nothing when unchanged**, and never allocate or build GPU resources here.
534    /// The attractor records the requested grid and lets the next `render` notice
535    /// the difference.
536    ///
537    /// Default no-op, in the same spirit as [`advance`](Self::advance): the
538    /// renderer already holds the size in `draw_frame`, and `Scene` is a `dyn`
539    /// trait, so this is the only channel that reaches a scene with it (Plan 0027
540    /// Phase 2, the third and first hot-path widening — ADR-0030).
541    fn set_target_size(&mut self, _width: u32, _height: u32) {}
542
543    /// How much of this scene's coverage the **backdrop** resolves against, for
544    /// the frame it is about to render (ADR-0085). `1.0` is coverage-as-occlusion
545    /// — what every frame did before `occlude` existed — and `0.0` is light that
546    /// adds without covering.
547    ///
548    /// **Called unconditionally every frame**, immediately before
549    /// [`render`](Self::render), in the same spirit as
550    /// [`set_target_size`](Self::set_target_size). The renderer hands a literal
551    /// `1.0` whenever a post stage is active, because then the scene draws into a
552    /// scratch offscreen with no backdrop under it and the chain's last stage owns
553    /// the seam instead — a scene must never apply this twice.
554    ///
555    /// Only a scene that **presents premultiplied over the backdrop** (ADR-0026 —
556    /// the reaction-diffusion, attractor and fragment-field presents) has anything
557    /// to do here. The additive families draw through
558    /// [`gpu::ADDITIVE_LIGHT_SATURATING_COVERAGE`](crate::render::gpu::ADDITIVE_LIGHT_SATURATING_COVERAGE),
559    /// whose colour destination factor is `One`: with no stage active their light
560    /// already adds to the backdrop rather than replacing it, so there is no
561    /// occlusion at that seam for this to scale. Default no-op.
562    fn set_occlude(&mut self, _occlude: f32) {}
563
564    /// The scene's feedback field, for a probe that needs the value **before**
565    /// this scene's present pass. `None` for every scene without one, which is
566    /// every scene but the warp mesh.
567    ///
568    /// **`#[cfg(test)]`, and that gate is the whole justification.** ADR-0002
569    /// keeps this trait thin and a real widening of it is ADR-worthy; this method
570    /// does not exist in a shipped build, so the extension seam is unchanged. It
571    /// exists because Plan 0111 Phase 2's bisect requires its five seams to be
572    /// read from **one run** — same signal, same hop, same size, same adapter —
573    /// and a `Box<dyn Scene>` cannot otherwise be asked for the one quantity that
574    /// sits upstream of everything the bisect covers. Measuring seam A on a
575    /// separately-driven scene would satisfy the arithmetic and quietly break
576    /// that requirement.
577    #[cfg(test)]
578    fn feedback_field(&self) -> Option<&wgpu::Texture> {
579        None
580    }
581
582    /// Advance simulation state by `dt` real seconds (Plan 0014 Phase 2). The
583    /// renderer injects the elapsed time each frame; a feedback scene steps its
584    /// fixed-timestep accumulator here and a CPU-integrated scene (the swarm)
585    /// scales its motion by `dt`, so both look identical over wall-clock time on
586    /// any refresh rate. Stateless, purely `time`-driven scenes ignore it.
587    ///
588    /// **`dt` is finite and strictly positive.** The renderer guarantees it,
589    /// substituting [`FALLBACK_DT`] for a degenerate delta before this is called
590    /// (ADR-0152), so an implementor may store it, integrate it, or divide by it
591    /// without checking. **Do not re-check it per scene**: the guarantee is
592    /// invisible from inside a scene, and this sentence is the whole of what
593    /// stands between a shell's raw delta and every accumulator behind this
594    /// trait — a second copy at one site makes it a rule enforced by a list of
595    /// sites again, which is the failure the seam exists to end.
596    fn advance(&mut self, _dt: f32) {}
597
598    /// Set the shared scene clock (seconds). The renderer owns the single clock
599    /// so an expression's `time` and the system's animation never diverge.
600    fn set_time(&mut self, _time: f32) {}
601    /// Reset every named parameter to its default (called each frame before the
602    /// active preset's bindings are applied, so unbound params don't leak).
603    fn reset_params(&mut self) {}
604    /// Apply one named parameter; unknown names are ignored.
605    fn set_param(&mut self, _name: &str, _value: f32) {}
606
607    /// Apply one named parameter as a **per-element series** (Plan 0034 Phase 4,
608    /// ADR-0036): `values` holds one evaluation of the binding per element, in
609    /// element order. Reached only for a binding whose expression names `index`.
610    ///
611    /// **This is the whole channel, and it is deliberately this narrow.** It
612    /// carries `(name, &[f32])` in one direction and returns nothing. A scene
613    /// cannot ask the preset layer for anything, cannot see the expression, and
614    /// cannot learn which preset is loaded — so this is `set_param` with a slice,
615    /// not an inversion in which scenes read presets. The slice borrows the
616    /// renderer's scratch, which is sized at preset load, so nothing here
617    /// allocates.
618    ///
619    /// The default takes the **first** value and routes it through
620    /// [`set_param`](Self::set_param) — exactly the `index = 0` reading a binding
621    /// gets outside a per-element evaluation. So a scene with no per-element
622    /// surface degrades a series to a scalar instead of dropping it, and a scene
623    /// that never opts in behaves byte-for-byte as before. Only the spectrum
624    /// readout overrides this.
625    fn set_param_series(&mut self, name: &str, values: &[f32]) {
626        if let Some(&first) = values.first() {
627            self.set_param(name, first);
628        }
629    }
630
631    /// Apply one named parameter as a **per-vertex series** (Plan 0100 Phase 1,
632    /// ADR-0113): `values` holds one evaluation of the binding per mesh vertex,
633    /// in row-major order from the top-left, `(meshx + 1) * (meshy + 1) ` long.
634    ///
635    /// The per-element channel one axis up, and deliberately just as narrow: it
636    /// carries `(name, &[f32])` in one direction and returns nothing. Reached
637    /// only for a binding in a `[per_vertex]` table, so a scene that never opts
638    /// in is never called.
639    ///
640    /// Unlike [`set_param_series`](Self::set_param_series) the default does
641    /// **nothing** rather than degrading to the first value. A per-vertex series
642    /// varies over space and its first element is the top-left corner, which is
643    /// not a sensible whole-scene reading of anything; the loader already warns
644    /// that a `[per_vertex]` table on another system is inert.
645    ///
646    /// The slice borrows the renderer's scratch, sized at preset load from the
647    /// same [`clamp_grid`](warp_mesh::clamp_grid) the scene uses, so nothing here
648    /// allocates.
649    fn set_per_vertex(&mut self, _name: &str, _values: &[f32]) {}
650
651    /// Consume a preset's declarative structural config (ADR-0007). Invoked
652    /// **once at preset load, off the hot path** — a generator builds and caches
653    /// its geometry here; a parametric scene records its family. Default no-op,
654    /// so non-line scenes (fragment field, swarm) never implement it. The one
655    /// optional widening of this trait ADR-0007 sanctions — keep it to this.
656    ///
657    /// Returns [`Some`](lines::CapOverflow) when building the geometry hit the
658    /// segment cap and truncated, so the frontend can surface it — the cap is
659    /// never a silent cut (ADR-0007 Risks). `None` means it fit (the norm).
660    fn configure(&mut self, _cfg: &lines::GeneratorConfig) -> Option<lines::CapOverflow> {
661        None
662    }
663
664    /// Consume a preset's baked color [`Palette`] (ADR-0021). Invoked **once at
665    /// preset load, off the hot path** — a shader-colored scene stores the baked
666    /// LUT and uploads it to its 256×1 texture (or samples it on the CPU) on the
667    /// next frame; a non-colored scene (the line scenes) ignores it. Default
668    /// no-op. The second and last thin off-hot-path widening of this trait after
669    /// ADR-0007's [`configure`](Scene::configure).
670    fn set_palette(&mut self, _palette: &Palette) {}
671
672    /// Consume a preset's `[feedback]` structural table (ADR-0048). Invoked
673    /// **once at preset load, off the hot path**, like
674    /// [`configure`](Scene::configure) and [`set_palette`](Scene::set_palette),
675    /// and load-time for `configure`'s reason: a warp kind is a shader path, not
676    /// a scalar. Default no-op — the third and last thin off-hot-path widening of
677    /// this trait.
678    ///
679    /// # One vocabulary, two buffers
680    ///
681    /// **This is the routing contract, and it is worth stating plainly because it
682    /// will surprise someone.** The `fb_*` params and this table are consumed by
683    /// *two* sinks: the engine [`Trails`](crate::render::trails::Trails) stage,
684    /// which transforms the accumulation every scene composites through, and the
685    /// attractor scene's own internal trail field, which is what reaches here.
686    /// A preset may have **both** active at once — an attractor with `trails` on —
687    /// and then a single `fb_rotate` turns *both* accumulations, each about its
688    /// own buffer. Neither transforms the other's, and neither transforms the
689    /// present deposit: the transform applies to the past.
690    ///
691    /// That is a deliberate design (ADR-0048's Alternative D was to give the
692    /// engine stage the vocabulary and leave the attractor out), and the reason it
693    /// is safe is that the two answer the same param names with the same
694    /// arithmetic — [`feedback::Transform`](crate::render::feedback::Transform)
695    /// and one shared WGSL snippet, not two implementations that must agree.
696    fn set_feedback(&mut self, _cfg: crate::render::feedback::FeedbackConfig) {}
697
698    /// The per-frame geometry-mirror cap overflow (Plan 0018 Phase 4), if this
699    /// frame's N-fold replication exceeded the segment cap and truncated. Reuses
700    /// the ADR-0007 [`CapOverflow`](lines::CapOverflow) so the frontend surfaces
701    /// it — the cap is never a silent cut. Default `None`: only the line scenes
702    /// mirror, and only when `mirror_order` pushes past the cap.
703    fn mirror_overflow(&self) -> Option<&lines::CapOverflow> {
704        None
705    }
706
707    /// The scene's **resolved sample budget** for the target it was last given,
708    /// where it has one (ADR-0140) — the attractor, and nothing else today.
709    ///
710    /// A hook rather than a field on the roster because it is a scene's own
711    /// arithmetic: only a scene knows what its budget resolved to. `None` is the
712    /// honest answer for every scene whose count is a flat capacity, and for the
713    /// attractor before a target size has reached it.
714    ///
715    /// **The seam exists so the factory's choice of ceiling is readable off the
716    /// built scene**, which is what makes that wiring testable at all — a test
717    /// that recomputed the law would pass with both modes handed the same
718    /// number. It is not the reporting path: `shot --render` prints its budget
719    /// from [`TierConfig::attractor_budget_offline`] instead, because the header
720    /// is written before the renderer is constructed and this would answer
721    /// `None` there.
722    ///
723    /// `#[cfg(test)]` because that leaves nothing but the test: no shipped path
724    /// asks a scene what its budget resolved to, and a trait method the engine
725    /// never calls is a widened seam pretending to be an API.
726    #[cfg(test)]
727    fn sample_budget(&self) -> Option<u32> {
728        None
729    }
730}
731
732/// The registry: every built-in scene, **keyed by the [`SystemKind`] it drives**,
733/// in [`SystemKind::ALL`] order. All scenes are created up front so switching
734/// mid-show is a lookup, never a hitch.
735///
736/// The keying is the point: the renderer addresses a scene by the kind its preset
737/// names, so a scene cannot silently end up in the wrong slot. Nothing here is
738/// positional — reordering [`SystemKind::ALL`] reorders construction and nothing
739/// else.
740pub(crate) fn create_all(
741    device: &wgpu::Device,
742    surface_format: wgpu::TextureFormat,
743    tier: &crate::render::TierConfig,
744    budget: crate::render::SampleBudget,
745) -> Vec<(SystemKind, Box<dyn Scene>)> {
746    // One shared line renderer for every line scene (ADR-0007: "one line
747    // renderer"). A single instanced-quad pipeline + segment buffer, borrowed by
748    // whichever line scene is active — only one draws per frame. (Two separate
749    // line pipelines with byte-identical vertex layouts also mis-render on the
750    // DX12 WARP software adapter the capture tests use; one renderer avoids it.)
751    // `new_with_arcs`, not `new`: `star_pattern`'s circular motifs are one arc
752    // instance each (ADR-0098), and the arc buffer holds
753    // `max_segments` because the two kinds share **one** budget — everything
754    // that passes `build_rings`'s cap check must reach the GPU, or a cap would
755    // be silently cutting geometry, which ADR-0007 forbids.
756    // `new_split_with_arcs`: any of the four line systems may ask for the
757    // opacity-preserving seam through `stroke_blend` (ADR-0138), and the
758    // pipelines are built here rather than when a preset first selects one —
759    // building a GPU resource mid-run changes what a later pass resolves to on
760    // the DX12 software adapter.
761    let line_renderer = Rc::new(RefCell::new(lines::LineRenderer::new_split_with_arcs(
762        device,
763        surface_format,
764        tier.max_segments,
765        tier.max_segments,
766        "lines",
767    )));
768    SystemKind::ALL
769        .iter()
770        .map(|&kind| {
771            (
772                kind,
773                create(
774                    kind,
775                    device,
776                    surface_format,
777                    &mut || line_renderer.clone(),
778                    tier,
779                    budget,
780                ),
781            )
782        })
783        .collect()
784}
785
786/// A scene constructed **for one preset's `[layer]`** (ADR-0090 point 4, Plan
787/// 0076 Phase 2), never taken from the roster — which is what makes same-system
788/// pairs legal and keeps two dissolving sides' layers from sharing anything.
789/// The stateful families duplicate their GPU state by construction: their
790/// constructors are already self-contained (a second reaction-diffusion
791/// ping-pong field, a second particle buffer), so this is the same exhaustive
792/// [`create`] the roster uses, differing only in where a line scene gets its
793/// renderer.
794///
795/// # The `LineRenderer` answer, recorded (the Phase 2 discovery duty)
796///
797/// **A layer line scene gets its own `LineRenderer`; the shared one is not
798/// shareable between two live line draws in one frame.** `LineRenderer::draw`
799/// uploads its instance and uniform buffers through `Queue::write_buffer`, and
800/// queued writes are applied before the submission's passes execute — so two
801/// draws through one renderer in one frame would both rasterize the *second*
802/// draw's segments under the second draw's uniforms. Making it shareable would
803/// need a partitioned instance buffer and per-draw uniform slots — a redesign
804/// of the idiom, not constructor plumbing — so duplication is the answer, at
805/// one pipeline plus one `max_segments` instance buffer per layered line
806/// preset.
807///
808/// The duplicate is built **only when the layer is a line system**: a
809/// fragment/swarm/particle layer pays no line pipeline, and WARP's documented
810/// sensitivity to coexisting identical pipeline layouts (ADR-0058 / Plan 0053)
811/// is only ever exercised by a preset that actually declares a line layer.
812pub(crate) fn create_layer_scene(
813    kind: SystemKind,
814    device: &wgpu::Device,
815    surface_format: wgpu::TextureFormat,
816    tier: &crate::render::TierConfig,
817    budget: crate::render::SampleBudget,
818) -> Box<dyn Scene> {
819    create(
820        kind,
821        device,
822        surface_format,
823        &mut || {
824            // Arcs too — a `[layer]` may be a `star_pattern`, and a layer that
825            // could not draw them would render a mandala with its circles
826            // missing rather than fail.
827            Rc::new(RefCell::new(lines::LineRenderer::new_split_with_arcs(
828                device,
829                surface_format,
830                tier.max_segments,
831                tier.max_segments,
832                "layer-lines",
833            )))
834        },
835        tier,
836        budget,
837    )
838}
839
840/// Whether two systems' scenes share mutable GPU state, so **one frame must not
841/// render both**.
842///
843/// Two facts make this true, and only one of them is obvious. The roster is keyed
844/// by kind, so the same kind is literally the same `Box<dyn Scene>`. Less
845/// obviously, the three **line** scenes deliberately share one `LineRenderer` —
846/// "borrowed by whichever line scene is active, only one draws per frame" (see
847/// [`create_all`]) — so two *different* line kinds are just as unrenderable in one
848/// frame as one kind twice.
849///
850/// Plan 0023's dual-live dissolve is the first caller: it composites two presets
851/// in a single frame, which is exactly what this forbids. A pair that shares
852/// resources falls back to the frozen snapshot.
853///
854/// **This is a statement about the roster's instances only.** A `[layer]`
855/// scene ([`create_layer_scene`], Plan 0076 Phase 2) is constructed per preset
856/// and shares nothing with the roster or with another preset's layer by
857/// construction — so a preset's own main-plus-layer pair never consults this,
858/// whatever the two systems are.
859pub(crate) fn shares_resources(a: SystemKind, b: SystemKind) -> bool {
860    a == b || (draws_through_shared_line_renderer(a) && draws_through_shared_line_renderer(b))
861}
862
863/// Whether a system draws through the shared `LineRenderer`. **Exhaustive** with
864/// no wildcard arm, like [`create`] itself: a new scene fails to compile here
865/// until someone says which side of the sharing it is on.
866fn draws_through_shared_line_renderer(kind: SystemKind) -> bool {
867    match kind {
868        SystemKind::ParametricCurve
869        | SystemKind::LSystem
870        | SystemKind::StarPattern
871        | SystemKind::Spectrum => true,
872        SystemKind::FragmentField
873        | SystemKind::Swarm
874        | SystemKind::ReactionDiffusion
875        | SystemKind::Attractor
876        | SystemKind::Emitter
877        | SystemKind::ShapeField
878        | SystemKind::WarpMesh
879        | SystemKind::ShapeCollage => false,
880    }
881}
882
883/// Build the scene a [`SystemKind`] drives.
884///
885/// An **exhaustive** `match` with no wildcard arm — the same guard the golden
886/// drift fixtures use: adding a variant fails to compile here until its scene is
887/// constructed, so a new system cannot ship unbuilt or wired to the wrong scene.
888///
889/// `line_renderer` is a **source**, called only by the line arms: the roster
890/// hands out clones of its one shared renderer, a layer construction builds a
891/// fresh one on demand ([`create_layer_scene`]) — and a non-line kind builds
892/// none at all.
893fn create(
894    kind: SystemKind,
895    device: &wgpu::Device,
896    surface_format: wgpu::TextureFormat,
897    line_renderer: &mut dyn FnMut() -> Rc<RefCell<lines::LineRenderer>>,
898    tier: &crate::render::TierConfig,
899    budget: crate::render::SampleBudget,
900) -> Box<dyn Scene> {
901    match kind {
902        SystemKind::FragmentField => Box::new(fragment_field::FragmentFieldScene::new(
903            device,
904            surface_format,
905        )),
906        SystemKind::Swarm => Box::new(swarm::SwarmScene::new(
907            device,
908            surface_format,
909            tier.swarm_particles,
910        )),
911        SystemKind::ParametricCurve => Box::new(lines::ParametricCurveScene::new(
912            line_renderer(),
913            tier.max_segments,
914        )),
915        SystemKind::LSystem => {
916            Box::new(lines::LSystemScene::new(line_renderer(), tier.max_segments))
917        }
918        SystemKind::StarPattern => Box::new(lines::StarPatternScene::new(
919            line_renderer(),
920            tier.max_segments,
921        )),
922        SystemKind::ReactionDiffusion => Box::new(reaction_diffusion::ReactionDiffusionScene::new(
923            device,
924            surface_format,
925        )),
926        SystemKind::Attractor => Box::new(particles::AttractorScene::new(
927            device,
928            surface_format,
929            tier.attractor_particles,
930            match budget {
931                crate::render::SampleBudget::Live => tier.attractor_particles_live_ceiling,
932                crate::render::SampleBudget::Offline => tier.attractor_particles_offline_ceiling,
933            },
934            tier.attractor_trail_cap,
935        )),
936        SystemKind::Spectrum => Box::new(lines::SpectrumScene::new(
937            line_renderer(),
938            tier.max_segments,
939        )),
940        SystemKind::Emitter => Box::new(emitter::EmitterScene::new(
941            device,
942            surface_format,
943            tier.emitter_objects,
944        )),
945        SystemKind::ShapeField => {
946            Box::new(shape_field::ShapeFieldScene::new(device, surface_format))
947        }
948        SystemKind::WarpMesh => Box::new(warp_mesh::WarpMeshScene::new(
949            device,
950            surface_format,
951            tier.mesh_grid,
952            tier.max_segments,
953        )),
954        SystemKind::ShapeCollage => Box::new(shape_collage::ShapeCollageScene::new(
955            device,
956            surface_format,
957            tier.collage_elements,
958        )),
959    }
960}
961
962/// Tiny deterministic RNG (splitmix64) so visual randomness is explicitly
963/// seeded (NFR 6) without pulling a rand crate.
964pub(crate) struct SeededRng(u64);
965
966impl SeededRng {
967    pub(crate) fn new(seed: u64) -> Self {
968        Self(seed)
969    }
970
971    fn next_u64(&mut self) -> u64 {
972        self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
973        let mut z = self.0;
974        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
975        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
976        z ^ (z >> 31)
977    }
978
979    /// Uniform in [0, 1).
980    pub(crate) fn next_f32(&mut self) -> f32 {
981        (self.next_u64() >> 40) as f32 / (1u64 << 24) as f32
982    }
983
984    /// Uniform in [lo, hi).
985    pub(crate) fn range(&mut self, lo: f32, hi: f32) -> f32 {
986        lo + (hi - lo) * self.next_f32()
987    }
988}
989
990#[cfg(test)]
991mod tests {
992    //! The scene-keying contract (Plan 0030 Phase 3), and the parameter-kind
993    //! quantizer declared beside it. Test asserts panic freely; this is not the
994    //! render path.
995    #![allow(clippy::panic)]
996
997    use super::{ParamKind, create_all};
998    use crate::preset::SystemKind;
999    use crate::render::context::{RenderContext, RenderError};
1000
1001    /// `Structural` rounds and `Modal` does not — the whole of what a kind
1002    /// changes about a value.
1003    ///
1004    /// **Nothing else can see this work or fail.** The engine's `Structural`
1005    /// roster is confined to parameters whose scene already clamps and rounds
1006    /// the value itself, so `quantize` composes to the identity everywhere it
1007    /// currently runs and no rendered assertion distinguishes it from being
1008    /// absent (design-backlog 0197). This covers it directly instead.
1009    #[test]
1010    fn a_structural_kind_rounds_and_a_modal_one_hands_the_value_through() {
1011        // Rust rounds a half AWAY FROM ZERO, and a scene indexing a closed
1012        // roster with the result depends on which way 6.5 goes, so the
1013        // direction is pinned here rather than assumed.
1014        for (value, rounded) in [
1015            (6.4_f32, 6.0_f32),
1016            (6.5, 7.0),
1017            (6.6, 7.0),
1018            (-6.4, -6.0),
1019            (-6.5, -7.0),
1020            (3.0, 3.0),
1021            (0.0, 0.0),
1022        ] {
1023            assert_eq!(
1024                ParamKind::Structural.quantize(value),
1025                rounded,
1026                "Structural must round {value}"
1027            );
1028            assert_eq!(
1029                ParamKind::Modal.quantize(value),
1030                value,
1031                "Modal must hand {value} through untouched"
1032            );
1033        }
1034
1035        // The property behind the table, over a sweep that lands on no integer
1036        // by construction: a Structural value equals its own round, and
1037        // quantizing it again moves nothing.
1038        for i in -400..400 {
1039            let value = i as f32 * 0.0137;
1040            let q = ParamKind::Structural.quantize(value);
1041            assert_eq!(q, q.round(), "Structural produced the non-integral {q}");
1042            assert_eq!(
1043                ParamKind::Structural.quantize(q),
1044                q,
1045                "quantizing an already-quantized {q} moved it"
1046            );
1047            assert_eq!(
1048                ParamKind::Modal.quantize(value),
1049                value,
1050                "Modal moved {value}"
1051            );
1052        }
1053
1054        // A non-finite value survives, which is what the type's own doc claims:
1055        // `f32::round` has no special case for one and neither does this.
1056        assert!(ParamKind::Structural.quantize(f32::NAN).is_nan());
1057        assert_eq!(ParamKind::Structural.quantize(f32::INFINITY), f32::INFINITY);
1058        assert_eq!(
1059            ParamKind::Structural.quantize(f32::NEG_INFINITY),
1060            f32::NEG_INFINITY
1061        );
1062    }
1063
1064    /// The scene each system is *supposed* to drive, written independently of the
1065    /// factory so the two can disagree. This is the mapping the old magic-index
1066    /// `system_slot` lookup could never assert: it named a position, and nothing
1067    /// checked the position held the right scene.
1068    fn expected_scene_name(system: SystemKind) -> &'static str {
1069        match system {
1070            SystemKind::FragmentField => "fragment field",
1071            SystemKind::ShapeField => "shape field",
1072            SystemKind::Swarm => "swarm",
1073            SystemKind::ParametricCurve => "parametric curve",
1074            SystemKind::LSystem => "l-system",
1075            SystemKind::StarPattern => "star pattern",
1076            SystemKind::ReactionDiffusion => "reaction diffusion",
1077            SystemKind::Attractor => "attractor",
1078            SystemKind::Spectrum => "spectrum",
1079            SystemKind::Emitter => "emitter",
1080            SystemKind::WarpMesh => "warp mesh",
1081            SystemKind::ShapeCollage => "shape collage",
1082        }
1083    }
1084
1085    /// **The factory is what chooses the ceiling**, and the two choices resolve
1086    /// different budgets at the same target (ADR-0140).
1087    ///
1088    /// Read off the scene rather than recomputed: this is the wiring under test,
1089    /// so a recomputation of the law here would pass with the factory handing
1090    /// both modes the same number. 1920x1080 is the size the whole plan is
1091    /// about — nine times the reference — and it is past the live ceiling and
1092    /// under the offline one, which is what makes the two answers differ.
1093    ///
1094    /// No frame is rendered: `set_target_size` is CPU arithmetic, so this costs
1095    /// one scene build on WARP and nothing else.
1096    #[test]
1097    fn the_render_path_resolves_a_larger_budget_than_a_window_does() {
1098        use crate::render::{SampleBudget, TierConfig};
1099
1100        let ctx = match RenderContext::new_headless(64, 64, true) {
1101            Ok(ctx) => ctx,
1102            Err(RenderError::RequestAdapter(_)) => {
1103                eprintln!("skipped: no GPU adapter on this runner (ADR-0016)");
1104                return;
1105            }
1106            Err(e) => panic!("headless context build failed: {e}"),
1107        };
1108
1109        let resolved = |budget: SampleBudget, tier: &TierConfig, w: u32, h: u32| -> Option<u32> {
1110            let mut scenes = create_all(&ctx.device, ctx.surface_format(), tier, budget);
1111            let (_, scene) = scenes
1112                .iter_mut()
1113                .find(|(kind, _)| *kind == SystemKind::Attractor)?;
1114            // Before a target size reaches it, a scene has no resolved budget to
1115            // report - which is the distinction the hook's `None` carries.
1116            assert_eq!(scene.sample_budget(), None);
1117            scene.set_target_size(w, h);
1118            scene.sample_budget()
1119        };
1120
1121        let rich = TierConfig::RICH;
1122        assert_eq!(
1123            resolved(SampleBudget::Offline, &rich, 1920, 1080),
1124            Some(1_350_000),
1125            "a render at 1080p must reach the law's own value, not the live cap"
1126        );
1127        assert_eq!(
1128            resolved(SampleBudget::Live, &rich, 1920, 1080),
1129            Some(rich.attractor_particles_live_ceiling),
1130            "a window at 1080p is held at the live ceiling"
1131        );
1132
1133        // Deposits per output pixel, stated as sample arithmetic (an image
1134        // statistic would be the wrong instrument - the ones this repo has are
1135        // themselves resolution-bound). The render reaches the 640x360
1136        // reference density exactly; today's flat count delivers a ninth of it.
1137        const PX: u32 = 1920 * 1080;
1138        let render = 1_350_000_f64 / f64::from(PX);
1139        let reference =
1140            f64::from(rich.attractor_particles) / f64::from(crate::render::REFERENCE_PX);
1141        assert!(
1142            (render - reference).abs() < 1e-9,
1143            "a 1080p render deposits {render} samples per pixel against the reference {reference}"
1144        );
1145        assert_eq!(1_350_000 / rich.attractor_particles, 9);
1146
1147        // And the small captures are untouched at BOTH ceilings, which is the
1148        // half that keeps every baseline where it is.
1149        for tier in [TierConfig::FLOOR, TierConfig::RICH] {
1150            for budget in [SampleBudget::Live, SampleBudget::Offline] {
1151                assert_eq!(
1152                    resolved(budget, &tier, 128, 128),
1153                    Some(tier.attractor_particles),
1154                    "{:?}/{budget:?} moved the golden suite's count",
1155                    tier.tier
1156                );
1157            }
1158        }
1159    }
1160
1161    /// Every `SystemKind::ALL` entry builds the scene that kind is supposed to
1162    /// drive, and the roster covers exactly the roster — so transposing two
1163    /// factory arms — which silently points every preset of one system at
1164    /// another's scene — fails here.
1165    ///
1166    /// Needs a GPU adapter to build the scenes, so it skips on runners without
1167    /// one (ADR-0016).
1168    #[test]
1169    fn every_kind_builds_the_scene_it_drives() {
1170        let ctx = match RenderContext::new_headless(64, 64, true) {
1171            Ok(ctx) => ctx,
1172            Err(RenderError::RequestAdapter(_)) => {
1173                eprintln!("skipped: no GPU adapter on this runner (ADR-0016)");
1174                return;
1175            }
1176            Err(e) => panic!("headless context build failed: {e}"),
1177        };
1178
1179        let scenes = create_all(
1180            &ctx.device,
1181            ctx.surface_format(),
1182            &crate::render::TierConfig::FLOOR,
1183            crate::render::SampleBudget::Live,
1184        );
1185
1186        let kinds: Vec<SystemKind> = scenes.iter().map(|(kind, _)| *kind).collect();
1187        assert_eq!(
1188            kinds,
1189            SystemKind::ALL.to_vec(),
1190            "the roster is exactly SystemKind::ALL, in its order"
1191        );
1192
1193        for (kind, scene) in &scenes {
1194            assert_eq!(
1195                scene.name(),
1196                expected_scene_name(*kind),
1197                "system {} must drive its own scene",
1198                kind.as_str()
1199            );
1200        }
1201    }
1202
1203    /// The **freeze veto** a dual-live dissolve rests on (Plan 0023 Phase 4): a
1204    /// pair of systems that would have to render one mutable object twice in a
1205    /// frame must report shared resources, so the governor never upgrades it.
1206    ///
1207    /// GPU-free — this is the mapping, not the rendering. It closes the half the
1208    /// governor's own test has to assume: `dual_live_eligible` is asserted to
1209    /// refuse a shared pair, and here is what makes a pair shared.
1210    #[test]
1211    fn a_pair_that_cannot_render_twice_reports_shared_resources() {
1212        // Same kind is the same `Box<dyn Scene>` — the same-scene case that must
1213        // always freeze, whatever the frame budget says.
1214        for kind in SystemKind::ALL {
1215            assert!(
1216                super::shares_resources(kind, kind),
1217                "{} against itself is one scene object",
1218                kind.as_str()
1219            );
1220        }
1221
1222        // Two *different* line systems are just as unrenderable together: they
1223        // borrow one shared `LineRenderer` (see `create_all`).
1224        let lines = [
1225            SystemKind::ParametricCurve,
1226            SystemKind::LSystem,
1227            SystemKind::StarPattern,
1228            SystemKind::Spectrum,
1229        ];
1230        for a in lines {
1231            for b in lines {
1232                assert!(
1233                    super::shares_resources(a, b),
1234                    "{} and {} share the line renderer",
1235                    a.as_str(),
1236                    b.as_str()
1237                );
1238            }
1239        }
1240
1241        // Everything else holds independent state, so a dissolve between them may
1242        // run both sides live.
1243        let independent = [
1244            SystemKind::FragmentField,
1245            SystemKind::Swarm,
1246            SystemKind::ReactionDiffusion,
1247            SystemKind::Attractor,
1248            SystemKind::Emitter,
1249            SystemKind::ShapeField,
1250            SystemKind::WarpMesh,
1251            SystemKind::ShapeCollage,
1252        ];
1253        for (i, a) in independent.iter().enumerate() {
1254            for b in independent.iter().skip(i + 1).chain(lines.iter()) {
1255                assert!(
1256                    !super::shares_resources(*a, *b),
1257                    "{} and {} hold independent GPU state",
1258                    a.as_str(),
1259                    b.as_str()
1260                );
1261            }
1262        }
1263    }
1264}