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 mod analytic_field;
19pub mod cellular;
20pub(crate) mod common;
21pub mod emitter;
22pub mod fragment_field;
23pub mod lines;
24/// The shared mark-silhouette vocabulary the two particle scenes draw through
25/// (ADR-0084). Crate-internal: it is arithmetic and a roster, not a scene.
26pub(crate) mod marks;
27pub mod particles;
28pub mod plexus;
29pub mod reaction_diffusion;
30pub mod shape_collage;
31pub mod shape_field;
32pub mod swarm;
33pub mod voxel;
34pub mod warp_mesh;
35
36use std::cell::RefCell;
37use std::rc::Rc;
38
39use crate::dsp::AnalysisFrame;
40use crate::preset::SystemKind;
41use crate::render::palette::Palette;
42
43/// The `dt` (seconds) the C ABI's legacy `rlx_render` and the headless capture
44/// primitives inject when a caller has no real elapsed time to supply — the
45/// former fixed scene step, now demoted to a fallback (Plan 0014 Phase 2, ADR-0012).
46/// The live frontends measure and inject real `dt` instead, so animation is
47/// frame-rate-independent; capture uses this fixed value so a render is a pure
48/// function of its inputs.
49pub(crate) const FALLBACK_DT: f32 = 1.0 / 60.0;
50
51/// What a parameter is **for** (ADR-0180 rule 2): whether its value carries an
52/// integer meaning, which decides two things and nothing else — whether the
53/// engine quantizes it before the scene sees it, and which of the generated
54/// reference's two groups it prints under.
55///
56/// Orthogonal to `[hold]`: a hold reaches any bindable parameter whatever its
57/// kind, and a kind quantizes whether or not the binding is held.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
59pub enum ParamKind {
60    /// Continuous — every value in the range means something, and the scene
61    /// reads the fraction. The default, and what every parameter was before
62    /// kinds existed.
63    #[default]
64    Modal,
65    /// Integer meaning: a mode number, a rule index, a count, a family. The
66    /// engine rounds the post-smoothing value **once**, CPU-side, before
67    /// `set_param`, so the scene is never handed 6.4 petals.
68    Structural,
69}
70
71impl ParamKind {
72    /// The name the generated reference and the exported schema print. Two
73    /// readers, one spelling.
74    pub fn as_str(self) -> &'static str {
75        match self {
76            ParamKind::Modal => "modal",
77            ParamKind::Structural => "structural",
78        }
79    }
80
81    /// `value` as the scene should receive it: rounded for a
82    /// [`Structural`](Self::Structural) parameter, untouched for a
83    /// [`Modal`](Self::Modal) one.
84    ///
85    /// **The last step before `set_param`**, after the hold has chosen which
86    /// frame's value stands and the smoother has eased toward it — which is
87    /// why a structural parameter that is *also* smoothed steps through the
88    /// intervening integers rather than landing fractionally. An author who
89    /// wants a clean jump leaves it out of `[smoothing]`.
90    ///
91    /// A non-finite value is passed through rather than rounded: `f32::round`
92    /// leaves `NaN` alone anyway, and the scenes already guard their own
93    /// inputs.
94    pub fn quantize(self, value: f32) -> f32 {
95        match self {
96            ParamKind::Modal => value,
97            ParamKind::Structural => value.round(),
98        }
99    }
100}
101
102/// What one named parameter is, as the engine declares it (ADR-0170).
103///
104/// A scene and an engine stage each declare their parameters as a `&[ParamSpec]`
105/// rather than as a bare `&[&str]`. Three things read the same declaration: the
106/// load-time check that a preset binding names something real, the constant a
107/// scene applies at reset, and the generated reference table in
108/// `presets/README.md`. Before this they were three copies, and only the names
109/// were held together.
110///
111/// **The doc line is the definition; the roster's essay is the discussion.**
112/// One sentence, saying what the parameter does — not restating its name.
113#[derive(Debug, Clone, Copy, PartialEq)]
114pub struct ParamSpec {
115    /// The name a preset binds, exactly as it is spelled in a `.toml`.
116    pub name: &'static str,
117    /// The value `reset_params` applies, and the one the reference prints.
118    ///
119    /// Read back out with [`default_of`], which resolves at compile time — so
120    /// the scene's `DEFAULT_*` constants and this field are one number, not two.
121    pub default: f32,
122    /// The range that **reads**, for the table.
123    ///
124    /// Not a clamp and not a validation bound: it is what an author can expect
125    /// to see a difference across. `None` where the parameter is unbounded or
126    /// world-space, in which case the frame is the bound — inventing a number
127    /// there would be a claim nothing holds.
128    pub range: Option<[f32; 2]>,
129    /// One sentence: what the parameter does.
130    pub doc: &'static str,
131    /// Whether the value carries an integer meaning (ADR-0180 rule 2).
132    ///
133    /// Read back out with [`kind_of`], and enforced by
134    /// `declared_params_match_set_param` in `core/tests/suite/preset.rs`, which
135    /// compares this against a hand-kept roster — a field nothing checks is a
136    /// field that drifts.
137    pub kind: ParamKind,
138    /// Which of the five groups an editor files the parameter under (ADR-0256).
139    pub group: ParamGroup,
140    /// Whether the parameter is one of the few that most decide its system's
141    /// look — listed first when its group is opened. One flag per declaration,
142    /// not per preset: a parameter central to one preset is shown by the
143    /// preset binding it.
144    pub main: bool,
145}
146
147/// The group a parameter is filed under in an editor (ADR-0256), in the order
148/// an editor lists them.
149///
150/// The engine-wide stages declare [`Post`](Self::Post), except the overall
151/// exposure and the backdrop's brightness, which are [`Light`](Self::Light).
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153pub enum ParamGroup {
154    /// What is drawn: form, count, mode, extent, framing.
155    Shape,
156    /// How it moves: speed, spin, drift, flow, anything over time.
157    Motion,
158    /// Hue, saturation, the palette and how the picture reads it.
159    Colour,
160    /// Brightness, glow, fade and the other amounts of light.
161    Light,
162    /// A pass over the finished frame: the backdrop, trails, mirroring,
163    /// bloom, the ink remap.
164    Post,
165}
166
167impl ParamGroup {
168    /// Every group, in listing order.
169    pub const ALL: [ParamGroup; 5] = [
170        ParamGroup::Shape,
171        ParamGroup::Motion,
172        ParamGroup::Colour,
173        ParamGroup::Light,
174        ParamGroup::Post,
175    ];
176
177    /// The name the generated reference and the exported schema print.
178    pub fn as_str(self) -> &'static str {
179        match self {
180            ParamGroup::Shape => "shape",
181            ParamGroup::Motion => "motion",
182            ParamGroup::Colour => "colour",
183            ParamGroup::Light => "light",
184            ParamGroup::Post => "post",
185        }
186    }
187}
188
189/// Byte-wise `str` equality, usable in a `const fn`.
190///
191/// `PartialEq` for `str` is not const, and this is only ever called at compile
192/// time over rosters of a few dozen entries.
193#[allow(
194    clippy::indexing_slicing,
195    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"
196)]
197const fn str_eq(a: &str, b: &str) -> bool {
198    let (a, b) = (a.as_bytes(), b.as_bytes());
199    if a.len() != b.len() {
200        return false;
201    }
202    let mut i = 0;
203    while i < a.len() {
204        if a[i] != b[i] {
205            return false;
206        }
207        i += 1;
208    }
209    true
210}
211
212/// The default `name` is declared with, resolved **at compile time**.
213///
214/// This is what makes a scene's `DEFAULT_*` constant and its `ParamSpec` one
215/// number rather than two copies of one: the constant is defined as a call to
216/// this, so a default changed in the spec changes what `reset_params` applies,
217/// and the generated table cannot state a value the engine does not use.
218///
219/// The linear scan costs nothing — it never runs at runtime — and a name with no
220/// spec is a **compile error**, because a const-eval panic is.
221#[allow(
222    clippy::indexing_slicing,
223    clippy::panic,
224    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"
225)]
226pub const fn default_of(specs: &[ParamSpec], name: &str) -> f32 {
227    let mut i = 0;
228    while i < specs.len() {
229        if str_eq(specs[i].name, name) {
230            return specs[i].default;
231        }
232        i += 1;
233    }
234    panic!("default_of: no ParamSpec declares that name");
235}
236
237/// The names in a spec roster, for the places that still want `&[&str]`.
238///
239/// Allocates, so it is for load-time validation and tests rather than a frame.
240pub fn spec_names(specs: &[ParamSpec]) -> Vec<&'static str> {
241    specs.iter().map(|spec| spec.name).collect()
242}
243
244/// Whether `name` is declared in `specs`. The load-time membership test.
245pub fn declares(specs: &[ParamSpec], name: &str) -> bool {
246    specs.iter().any(|spec| spec.name == name)
247}
248
249/// The [`ParamKind`] `name` is declared with in `specs`, or `None` where no
250/// spec here declares it.
251///
252/// Load-time, like [`declares`]: the loader folds the answer onto the binding
253/// so nothing per frame searches a roster by name (`[smoothing]`'s rule, for
254/// its reason). A name no roster declares is already an ADR-0020 warning, and
255/// an unclaimed binding reaches no scene, so its kind never matters.
256pub fn kind_of(specs: &[ParamSpec], name: &str) -> Option<ParamKind> {
257    specs
258        .iter()
259        .find(|spec| spec.name == name)
260        .map(|spec| spec.kind)
261}
262
263/// Where one family-dependent parameter reads, on one family of a
264/// family-bearing system (ADR-0180 rule 4).
265#[derive(Debug, Clone, Copy, PartialEq)]
266pub struct FamilyRange {
267    /// The family, spelled as a preset names it (`[curve] family = "..."`).
268    pub family: &'static str,
269    /// The range that reads on this family, in [`ParamSpec::range`]'s sense.
270    /// `None` where the family **does not read the parameter at all** — the
271    /// reference prints it as inert there rather than leaving it to be found.
272    pub range: Option<[f32; 2]>,
273}
274
275/// A parameter whose meaning — and so whose reading range — depends on the
276/// family its system draws.
277///
278/// The generated reference prints [`ranges`](Self::ranges) in place of the
279/// single [`ParamSpec::range`], because one pair would be a claim nothing holds:
280/// `parametric_curve`'s `n` reads `1..24` on a rose and `-8..8` on a
281/// hypotrochoid. The spec's own range stays declared and is the one a family in
282/// this list reads — the exported schema still carries one pair per parameter.
283#[derive(Debug, Clone, Copy, PartialEq)]
284pub struct FamilyParam {
285    /// The parameter, as its [`ParamSpec`] names it.
286    pub name: &'static str,
287    /// One entry per family of the system, in the system's roster order.
288    pub ranges: &'static [FamilyRange],
289}
290
291/// The family-dependent parameters of the roster `label` names — as
292/// `export::param_rosters` labels it — or nothing, for a system whose
293/// parameters read the same on every family it draws.
294pub fn family_params(label: &str) -> &'static [FamilyParam] {
295    match label {
296        "parametric_curve" => lines::parametric::FAMILY_PARAMS,
297        "lsystem" => lines::lsystem::FAMILY_PARAMS,
298        "analytic_field" => analytic_field::FAMILY_PARAMS,
299        "cellular" => cellular::FAMILY_PARAMS,
300        "plexus" => plexus::FAMILY_PARAMS,
301        "attractor" => particles::FAMILY_PARAMS,
302        "voxel" => voxel::FAMILY_PARAMS,
303        _ => &[],
304    }
305}
306
307/// One integrated animation phase — the only way a bindable rate advances
308/// anything in this engine (ADR-0135, finishing the rule ADR-0132 stated).
309///
310/// **A rate multiplier has to be integrated to be a rate at all.** A phase
311/// computed as `time * rate` lets a rate bound to audio retroactively rescale
312/// *all* elapsed time on every frame: at t = 100 s a swing from `1.0` to `1.5`
313/// moves the phase by fifty seconds in a single frame — the figure snaps to a
314/// new position rather than accelerating toward it, on a lane whose whole
315/// method is binding parameters to audio. Integrated, the same swing bends the
316/// motion.
317///
318/// [`step`](Self::step) is the **only** mutator, and there is deliberately no
319/// `Add`/`AddAssign`/`Deref`/`DerefMut` impl. The constraint is the value: with
320/// one, a scene could write `phase + self.rate * self.time` and compile, which
321/// is exactly the door this type exists to close.
322///
323/// # No constant scale is folded into the accumulation
324///
325/// A scene carrying its own fixed rate — the attractor's `SPIN_RATE` — applies
326/// it where the phase is **read**, never inside the sum. The accumulator is then
327/// `Σ (rate · dt)` with `rate` at its `1.0` default, i.e. `Σ dt` term for term:
328/// bit-for-bit the same summation the renderer performs for its own clock, so
329/// the integrated form reproduces the multiply it replaced *exactly* and no
330/// golden baseline moves. Folding a `0.18` in would sum `0.18 · dt` instead and
331/// drift in the last bits of every capture.
332///
333/// # It steps where this frame's rate has landed
334///
335/// The per-frame order is `reset_params` → `set_param` → `set_time` → `advance`
336/// → `update` (`core/src/render/evaluate.rs`), so by the time either
337/// [`Scene::advance`] or [`Scene::update`] runs, *this* frame's rate is the one
338/// the scene holds. A scene may step a phase in either; the roster's rate
339/// scenes store the `dt` `advance` hands them and step in `update`. The trap is
340/// the sequence itself: moving `advance` back ahead of the bindings would make a
341/// step taken there integrate the last frame's rate.
342///
343/// The type is arithmetic with no device in it, which is what keeps every rate
344/// in the engine testable on the CPU without rendering anything.
345#[derive(Clone, Copy, Default, Debug, PartialEq)]
346pub(crate) struct Phase(f32);
347
348impl Phase {
349    /// One frame's integration, at *this* frame's rate.
350    pub(crate) fn step(&mut self, rate: f32, dt: f32) {
351        self.0 += rate * dt;
352    }
353
354    /// The accumulated phase, in rate-scaled seconds. A scene with its own
355    /// constant scale applies it here, on the read.
356    pub(crate) fn get(self) -> f32 {
357        self.0
358    }
359}
360
361/// Declarative structural config a scene consumes once at preset load
362/// (ADR-0007): **not** expressions — the family / grammar / tiling the sampler
363/// or generator builds from. Delivered through the optional
364/// `Scene::configure` hook, off the hot path. This is
365/// the shared structural-config enum for every scene that has one: the line
366/// scenes' curve/L-system/star variants, plus the compute-particle attractor
367/// family (Plan 0016) — it lives here rather than in `lines/` so `lines` never has to name a
368/// `particles` type (Plan 0031 Phase 6).
369#[derive(Debug, Clone)]
370pub enum GeneratorConfig {
371    /// A parametric curve: which family to sample.
372    Curve {
373        /// The curve family (Maurer rose, ...).
374        family: lines::CurveFamily,
375    },
376    /// An L-system: a grammar the generator expands and turtle-walks at load,
377    /// caching one segment buffer per depth.
378    LSystem {
379        /// The starting string.
380        axiom: String,
381        /// Production rules `(predecessor, successor)`.
382        rules: Vec<(char, String)>,
383        /// Turn angle in degrees for `+`/`-`.
384        angle_deg: f32,
385        /// Iterations to precompute (`1..=max_depth`), clamped to
386        /// [`lines::MAX_LSYSTEM_DEPTH`] at load.
387        max_depth: u32,
388        /// Reserved seed for future stochastic rules; deterministic today.
389        seed: u64,
390        /// Which turtle walks the expansion, from `[generator] turtle`
391        /// (ADR-0258). `Flat` is the default and the plane walk.
392        turtle: lines::turtle::TurtleMode,
393        /// How the figure grows, from `[generator] growth`: `Fixed`, the
394        /// default, caches every depth; `Endless` walks a stream into a ring.
395        growth: lines::lsystem::Growth,
396        /// How many segments an endless figure keeps, from `[generator]
397        /// trail`, validated at load against the stream's length and held to
398        /// the tier's cap by the scene. Inert on a fixed figure.
399        trail: u32,
400        /// How many of an endless figure's newest segments its follow point is
401        /// the centroid of, from `[generator] follow_window`. Inert on a fixed
402        /// figure.
403        follow_window: u32,
404    },
405    /// A Hankin star pattern: an `n`-fold star rosette built at load, with a few
406    /// contact-angle variants a beat can switch between — and, since ADR-0079,
407    /// an optional ring ornament drawn inside it.
408    Star {
409        /// Star order `n` (from the tiling), e.g. 6 or 12. **`0` means no
410        /// interlace at all** (`tiling = "none"`), which the loader accepts only
411        /// alongside a non-empty `rings` roster — the ornament drawn alone.
412        order: u32,
413        /// Contact angle in degrees; variants are precomputed around it.
414        contact_angle_deg: f32,
415        /// The `[generator] rings` roster (ADR-0079): concentric rings of
416        /// repeated motifs filling the interior the rosette leaves hollow. Empty
417        /// — the default, and what an absent `rings` key means — is exactly the
418        /// pre-Plan-0065 scene.
419        rings: Vec<lines::star::RingSpec>,
420    },
421    /// A GPU compute-particle attractor (Plan 0016): which strange-attractor map
422    /// the compute step iterates. Not a line scene — reuses this shared enum so
423    /// the family rides the existing `configure` hook (no new trait method).
424    Particles {
425        /// The attractor family (De Jong, Clifford, Thomas, Lorenz, or one of
426        /// the IFS figures).
427        family: particles::AttractorFamily,
428        /// The figure the bindable `morph` param travels **towards** (ADR-0075),
429        /// from `[particles] morph_to`. `None` — the default — pins the figure,
430        /// so `morph` is inert.
431        ///
432        /// IFS-only, and validated as such at load: `morph_to` on a map family
433        /// is a load error rather than a silent no-op, because the author asked
434        /// for something the engine cannot do.
435        morph_to: Option<particles::ifs::IfsFigure>,
436        /// How much of the tier's particle budget is actually drawn (ADR-0069)
437        /// — a count against the tier's anchor for a trace and a fraction of
438        /// the target-scaled budget for a cloud, the band between them resolved
439        /// by `active_particles` (ADR-0195) —
440        /// validated at load into
441        /// [`MIN_PARTICLE_DENSITY`](particles::MIN_PARTICLE_DENSITY)`..=1.0`.
442        /// Structural, not bindable: an eased integer count would re-decide the
443        /// picture every frame. `1.0` is the whole budget and the default.
444        density: f32,
445        /// The **tuple path** the bindable `morph` param walks along on a map
446        /// family (ADR-0093), as `(from, to)` roster indices from
447        /// `[particles] tuple_from` / `tuple_to`. `None` — the default — means
448        /// there is no path and `morph` is inert, exactly as `morph_to` does for
449        /// the IFS.
450        ///
451        /// **Both ends are structural on purpose.** The walk's framing is
452        /// measured across it at load, which is thousands of map iterations; a
453        /// path whose near end came from the per-frame `tuple` param would have
454        /// to re-measure inside the frame loop every time that param moved.
455        ///
456        /// Map-family-only, and validated as such at load — the IFS reaches its
457        /// own figure-to-figure travel through `morph_to` instead, and a tuple
458        /// path on an IFS is a load error rather than a silent no-op.
459        tuple_path: Option<(u32, u32)>,
460    },
461    /// The spectrum readout's `[spectrum]` table (Plan 0034 / ADR-0036): how many
462    /// elements the frequency axis is divided into, how they are laid out, and how
463    /// fast each one follows its band. All three are structure rather than
464    /// expression — they are fixed for as long as the preset is loaded — so they
465    /// ride the existing `configure` hook like every other declarative config.
466    Spectrum {
467        /// Element count, validated at load into
468        /// `2..=`[`SPECTRUM_BINS`](crate::dsp::SPECTRUM_BINS).
469        elements: usize,
470        /// Which figure the elements form.
471        layout: lines::SpectrumLayout,
472        /// Per-element temporal easing in **seconds**, applied on the injected
473        /// real `dt` — the same [`Easing`](crate::preset::Easing) the `[smoothing]`
474        /// table uses, deliberately reused rather than a second vocabulary
475        /// (ADR-0035).
476        easing: crate::preset::Easing,
477    },
478    /// The warp mesh's `[mesh]` table (Plan 0100 / ADR-0113): the grid, in
479    /// cells, that the per-vertex program is evaluated over.
480    ///
481    /// Structural for `[curve] family`'s reason — the vertex and index buffers
482    /// are built from it, so an eased grid would rebuild them mid-frame — and
483    /// **clamped to the tier at both consumers** rather than at load, since the
484    /// loader does not know which tier will render the preset
485    /// ([`warp_mesh::clamp_grid`]).
486    WarpMesh {
487        /// Requested cells, `(x, y)`, validated at load into
488        /// [`MIN_MESH`](warp_mesh::MIN_MESH)`..=`[`MAX_MESH`](warp_mesh::MAX_MESH).
489        mesh: (u32, u32),
490        /// The compiled EEL2 programs a **converted** preset carries, from a
491        /// `[milk]` table (Plan 0100 Phase 2 / ADR-0113). `None` — a
492        /// hand-authored `warp_mesh` preset — drives the mesh from the ordinary
493        /// `[params]` and `[per_vertex]` bindings instead, and executes no VM at
494        /// all.
495        ///
496        /// Boxed because it is much the largest thing this enum carries and every
497        /// other variant would pay for it by value.
498        milk: Option<Box<crate::milk::MilkBundle>>,
499        /// The salt the bundle's `rand()` draws under (ADR-0051).
500        ///
501        /// **The preset's `pinned_salt`, always** — its declared numeric seed, or
502        /// `0` where it declared `seed = "random"`. So a bundle is a pure
503        /// function of its inputs in the live app as well as in the harness,
504        /// which is stronger than ADR-0051 requires and costs nothing: per-run
505        /// variety is opt-in through `seed = "random"`, and no *converted* preset
506        /// declares one. A hand-written bundle that does gets the pinned
507        /// behaviour, and that is stated rather than discovered.
508        salt: u32,
509    },
510    /// The shape field's `[path]` table (ADR-0107): an authored silhouette, as a
511    /// closed contour parsed once at load from inline SVG path data.
512    ///
513    /// The one variant here carrying **geometry** rather than a selector or a
514    /// size. It rides `configure` for the reason every other structural table
515    /// does — it is fixed for as long as the preset is loaded — and a
516    /// `shape_field` preset declaring no table gets `None` and draws the closed
517    /// `marks` roster exactly as it did before paths existed.
518    Path {
519        /// The contour, normalized into `[-1, 1]` and resampled to the arity
520        /// `[path] samples` asked for. `None` — a `shape_field` preset that
521        /// declares no table — is what makes this config **always `Some`**: it
522        /// is handed over on every preset switch precisely so `configure` runs
523        /// and clears the outgoing preset's contour, the same reason the
524        /// attractor's and the spectrum's configs are unconditional.
525        shape: Option<crate::preset::path::PathShape>,
526        /// The silhouette the bindable `morph` param travels **towards**, from
527        /// `[path] morph_to`. `None` — the default — pins the figure, so `morph`
528        /// is inert, exactly as the attractor's own `morph_to` does.
529        ///
530        /// Already **aligned** to `shape` at load: same arity, same winding, and
531        /// the cyclic start that minimises total displacement (ADR-0107). The
532        /// render layer interpolates the two point lists and re-derives none of
533        /// that, which is what keeps an `O(N^2)` search off the frame.
534        morph_to: Option<crate::preset::path::PathShape>,
535    },
536    /// The analytic field's `[field]` table (ADR-0180 rule 1): which closed-form
537    /// family the pass draws. Always `Some` for that system, so `configure` runs
538    /// on every preset switch and the outgoing preset's family never survives
539    /// into the incoming one.
540    Field(analytic_field::FieldConfig),
541    /// The cellular system's `[cellular]` table (ADR-0180 rule 1): which
542    /// automaton runs, on how large a grid, and the salt its seeding draws from.
543    /// Always `Some` for that system, so `configure` runs on every preset switch
544    /// and the incoming preset starts from its own seed.
545    Cellular(cellular::CellularConfig),
546    /// The plexus system's `[plexus]` table (ADR-0257): the layout, the point
547    /// count and the seed. Always `Some` for that system, so `configure` runs on
548    /// every preset switch and the incoming preset starts from its own points.
549    Plexus(plexus::PlexusConfig),
550    /// The waterfall's `[waterfall]` table (ADR-0180 rule 1): the band count,
551    /// the row count, the push period and the per-band easing. Always `Some`
552    /// for that system, so `configure` runs on every preset switch and the
553    /// incoming preset starts from an empty ring.
554    Waterfall(lines::waterfall::WaterfallConfig),
555    /// The voxel system's `[voxel]` table (ADR-0268): the grid, the rule list,
556    /// the seed ball and the edge rule, with the salt its seeding draws from.
557    /// Always `Some` for that system, so `configure` runs on every preset
558    /// switch and the incoming preset starts from its own seed.
559    Voxel(voxel::VoxelConfig),
560}
561
562impl GeneratorConfig {
563    /// How many elements a per-element binding should be evaluated for under this
564    /// config, or `0` when the system has no per-element surface (Plan 0034 Phase
565    /// 4). Read once at preset load to size the render layer's scratch.
566    ///
567    /// The count lives here rather than on `Scene` because it is **preset data**:
568    /// it comes off the `[spectrum]` table, not out of the scene's state, and the
569    /// renderer already holds the preset.
570    pub fn element_count(&self) -> usize {
571        match self {
572            GeneratorConfig::Spectrum { elements, .. } => *elements,
573            GeneratorConfig::Curve { .. }
574            | GeneratorConfig::LSystem { .. }
575            | GeneratorConfig::Star { .. }
576            | GeneratorConfig::Particles { .. }
577            | GeneratorConfig::WarpMesh { .. }
578            | GeneratorConfig::Path { .. }
579            | GeneratorConfig::Field(_)
580            | GeneratorConfig::Cellular(_)
581            | GeneratorConfig::Plexus(_)
582            | GeneratorConfig::Waterfall(_)
583            | GeneratorConfig::Voxel(_) => 0,
584        }
585    }
586}
587
588/// Which construction hit the segment cap, for the surfaced message.
589///
590/// An enum rather than a `String` because one of the two producers is **per
591/// frame**: with a `String`, an audio-driven `mirror_order` sitting over the cap
592/// builds a fresh `format!("mirror x{order}")` on every single frame for as long
593/// as it stayed there — a heap allocation on the hot path (Plan 0031 Phase 4).
594/// The formatting now happens only in [`Display`](std::fmt::Display), i.e. only
595/// when something actually prints it.
596#[derive(Debug, Clone, Copy, PartialEq, Eq)]
597pub enum OverflowContext {
598    /// An N-fold geometry mirror replicated past the cap — per frame, from the
599    /// `mirror_order` param (Plan 0018 Phase 4).
600    Mirror(u32),
601    /// An L-system depth expanded past the cap — once, at preset load.
602    Depth(u32),
603    /// An escape-time `iterations` budget asked for past the tier's
604    /// [`field_iterations`](crate::render::TierConfig::field_iterations) —
605    /// per frame, since the budget is bindable. Carries what was asked; the
606    /// [`CapOverflow`] carries the cap it was clamped to.
607    ///
608    /// Not a truncation of geometry but a clamp of a structural parameter, and
609    /// the one case where the tier changes a preset's picture rather than its
610    /// density (ADR-0045) — which is exactly why it must not be silent.
611    Iterations(u32),
612    /// A cellular `[cellular] grid` asked for past the tier's
613    /// [`cellular_grid`](crate::render::TierConfig::cellular_grid) — once, at
614    /// preset load, since the grid is structural. Carries what was asked.
615    ///
616    /// A clamp of content, like [`Iterations`](Self::Iterations): a pattern is
617    /// a fixed number of cells, so a smaller grid draws every pattern larger.
618    Grid(u32),
619    /// A `larger_than_life` `radius` asked for past the tier's
620    /// [`cellular_radius`](crate::render::TierConfig::cellular_radius) — per
621    /// frame, since the radius is bindable. Carries what was asked.
622    Radius(u32),
623    /// A `[plexus] points` asked for past the tier's
624    /// [`plexus_points`](crate::render::TierConfig::plexus_points) — at preset
625    /// load, since the count is structural. Carries what was asked. A clamp of
626    /// content: fewer points is a sparser network.
627    Points(u32),
628    /// A plexus graph that linked more pairs than the tier's
629    /// [`plexus_edges`](crate::render::TierConfig::plexus_edges) — per frame,
630    /// since `link_distance` is bindable. Carries how many linked; the surplus
631    /// is dropped in index order.
632    Edges(u32),
633    /// An `aperture` asked for past the tier's
634    /// [`max_coc_px`](crate::render::TierConfig::max_coc_px) — per frame, since
635    /// `aperture` is bindable. Carries the aperture, in whole pixels: the blur
636    /// of the far field, which the drawn blur stops short of.
637    ///
638    /// **Not the widest blur the lens draws.** In front of the focal plane the
639    /// circle of confusion grows without bound as depth shrinks, so a close
640    /// camera saturates the cap on every tier with any aperture at all; that
641    /// saturation is the lens's ceiling, not an overflow, and is never
642    /// announced (ADR-0257).
643    Blur(u32),
644    /// A space curve's `samples` asked for past the tier's
645    /// [`seg3d_segments`](crate::render::TierConfig::seg3d_segments) — per
646    /// frame, since `samples` is bindable. Carries what was asked. A clamp of
647    /// content: the curve is walked from fewer points, so it is coarser.
648    Samples(u32),
649    /// A `[waterfall] rows` asked for past what the tier's
650    /// [`seg3d_segments`](crate::render::TierConfig::seg3d_segments) draws at the
651    /// preset's band count — at preset load, since the count is structural.
652    /// Carries the rows asked and the segments one row costs (`elements - 1`);
653    /// the [`CapOverflow`] carries the segment cap. A clamp of content: the
654    /// landscape keeps less of the music.
655    Rows(u32, u32),
656    /// An endless L-system's `[generator] trail` asked for past the tier's
657    /// cap for its turtle — [`seg3d_segments`](crate::render::TierConfig::seg3d_segments)
658    /// in space, [`max_segments`](crate::render::TierConfig::max_segments) on
659    /// the plane — at preset load, since the trail is structural. A clamp of
660    /// content: the vine keeps fewer segments behind its tip.
661    Trail {
662        /// The trail asked for.
663        asked: u32,
664        /// Whether the figure walks in space, which names the cap that bit.
665        space: bool,
666    },
667    /// A `[voxel] grid` asked for past the tier's
668    /// [`voxel_grid`](crate::render::TierConfig::voxel_grid) — once, at preset
669    /// load, since the grid is structural. Carries what was asked. A clamp of
670    /// content: a structure is a fixed number of cells, so a smaller cube draws
671    /// every structure larger.
672    Voxels(u32),
673}
674
675impl std::fmt::Display for OverflowContext {
676    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
677        // These renderings are the user-visible text ADR-0007 requires stay
678        // informative; the shell prints them verbatim. Do not reword them
679        // without meaning to change what an operator sees.
680        match self {
681            OverflowContext::Mirror(order) => write!(f, "mirror x{order}"),
682            OverflowContext::Depth(depth) => write!(f, "depth {depth}"),
683            OverflowContext::Iterations(asked) => write!(f, "iterations {asked}"),
684            OverflowContext::Grid(asked) => write!(f, "grid {asked}"),
685            OverflowContext::Radius(asked) => write!(f, "radius {asked}"),
686            OverflowContext::Points(asked) => write!(f, "points {asked}"),
687            OverflowContext::Edges(linked) => write!(f, "{linked} links"),
688            OverflowContext::Blur(asked) => write!(f, "an aperture of {asked} px"),
689            OverflowContext::Samples(asked) => write!(f, "samples {asked}"),
690            OverflowContext::Rows(asked, _) => write!(f, "rows {asked}"),
691            OverflowContext::Trail { asked, .. } => write!(f, "trail {asked}"),
692            OverflowContext::Voxels(asked) => write!(f, "grid {asked}"),
693        }
694    }
695}
696
697/// Reported when building a line scene's geometry hit the segment cap and
698/// truncated. The cap must never be a silent cut (ADR-0007 Risks), so it travels
699/// to the frontend two ways: out of `Scene::configure` at preset load, and off
700/// `Scene::mirror_overflow` for the per-frame mirror. `None` is the normal case
701/// where geometry fit.
702#[derive(Debug, Clone, Copy, PartialEq, Eq)]
703pub struct CapOverflow {
704    /// How many draw segments were dropped at the cap.
705    pub dropped: usize,
706    /// Where the drop happened, for the surfaced message.
707    pub context: OverflowContext,
708    /// **The cap that bit**, carried rather than read from a constant: it is a
709    /// tier value now (Plan 0044), so the same preset overflows at 20 000
710    /// segments on the floor and not at all at 60 000 on rich. A message naming a
711    /// cap the run was not using would be worse than no message.
712    pub cap: usize,
713}
714
715impl std::fmt::Display for CapOverflow {
716    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
717        // Pinning the top tier is offered only where it would lift the cap: on
718        // Rich it is the cap that bit, and a remedy naming the tier the run is
719        // already on sends the operator nowhere.
720        let pin = if self.top_tier_lifts() {
721            ", or pin --tier rich"
722        } else {
723            ""
724        };
725        match self.context {
726            // A clamp, not a cut: nothing is dropped, the picture is drawn at
727            // the cap, and the operator is told which lever gets it back.
728            OverflowContext::Iterations(_) => write!(
729                f,
730                "{} is past this quality tier's cap of {}; drawn at {} instead, so the \
731                 set's boundary resolves less detail than the preset asked for \
732                 (ask for {} or fewer{pin})",
733                self.context, self.cap, self.cap, self.cap
734            ),
735            OverflowContext::Grid(_) => write!(
736                f,
737                "{} is past this quality tier's cap of {}; the automaton runs on a {}-cell \
738                 grid instead, so every pattern draws larger than the preset asked \
739                 (ask for {} or fewer{pin})",
740                self.context, self.cap, self.cap, self.cap
741            ),
742            OverflowContext::Radius(_) => write!(
743                f,
744                "{} is past this quality tier's cap of {}; the neighbourhood is drawn at {} \
745                 instead, which runs a different rule than the preset asked \
746                 (ask for {} or fewer{pin})",
747                self.context, self.cap, self.cap, self.cap
748            ),
749            OverflowContext::Points(_) => write!(
750                f,
751                "{} is past this quality tier's cap of {}; the network is drawn with {} \
752                 points instead, so it is sparser than the preset asked \
753                 (ask for {} or fewer{pin})",
754                self.context, self.cap, self.cap, self.cap
755            ),
756            OverflowContext::Edges(_) => write!(
757                f,
758                "{} exceeded this quality tier's {}-link cap (dropped {}); lower \
759                 link_distance or the point count{pin}",
760                self.context, self.cap, self.dropped
761            ),
762            OverflowContext::Blur(_) => write!(
763                f,
764                "{} is past this quality tier's cap of {} px; drawn at {} px instead, so \
765                 the background is sharper than the preset asked \
766                 (ask for {} or fewer{pin})",
767                self.context, self.cap, self.cap, self.cap
768            ),
769            OverflowContext::Samples(_) => write!(
770                f,
771                "{} is past this quality tier's cap of {}; the space curve is walked from {} \
772                 points instead, so it is coarser than the preset asked \
773                 (ask for {} or fewer{pin})",
774                self.context, self.cap, self.cap, self.cap
775            ),
776            // `cap` is in segments; the rows kept are the whole rows it holds.
777            OverflowContext::Rows(_, per_row) => {
778                let kept = self.cap / per_row.max(1) as usize;
779                write!(
780                    f,
781                    "{} is past this quality tier's {}-segment cap at this band count; the \
782                     landscape keeps {kept} rows instead, so it shows less of the music than \
783                     the preset asked (ask for {kept} or fewer, or fewer elements{pin})",
784                    self.context, self.cap
785                )
786            }
787            OverflowContext::Trail { .. } => write!(
788                f,
789                "{} is past this quality tier's cap of {}; the vine keeps {} segments \
790                 instead, so less of it trails behind its tip than the preset asked \
791                 (ask for {} or fewer{pin})",
792                self.context, self.cap, self.cap, self.cap
793            ),
794            OverflowContext::Voxels(_) => write!(
795                f,
796                "{} is past this quality tier's cap of {}; the automaton runs in a {}-cell \
797                 cube instead, so every structure draws larger than the preset asked \
798                 (ask for {} or fewer{pin})",
799                self.context, self.cap, self.cap, self.cap
800            ),
801            OverflowContext::Mirror(_) | OverflowContext::Depth(_) => write!(
802                f,
803                "geometry exceeded the {}-segment cap at {} (dropped {} segment(s)); \
804                 reduce the structure or its depth",
805                self.cap, self.context, self.dropped
806            ),
807        }
808    }
809}
810
811impl CapOverflow {
812    /// Whether the top tier's cap for this context is above the one that bit,
813    /// so that pinning it would draw more of what the preset asked.
814    ///
815    /// Read off [`TierConfig::RICH`](crate::render::TierConfig::RICH) rather
816    /// than carried, so no producer has to know which tier it runs on: a cap
817    /// below Rich's can only have come from a lower tier, and a cap equal to it
818    /// is the top tier's own.
819    pub fn top_tier_lifts(&self) -> bool {
820        let rich = crate::render::TierConfig::RICH;
821        let top = match self.context {
822            OverflowContext::Mirror(_) | OverflowContext::Depth(_) => rich.max_segments,
823            OverflowContext::Iterations(_) => rich.field_iterations as usize,
824            OverflowContext::Grid(_) => rich.cellular_grid as usize,
825            OverflowContext::Radius(_) => rich.cellular_radius as usize,
826            OverflowContext::Points(_) => rich.plexus_points as usize,
827            OverflowContext::Edges(_) => rich.plexus_edges as usize,
828            OverflowContext::Blur(_) => rich.max_coc_px as usize,
829            OverflowContext::Samples(_) | OverflowContext::Rows(..) => rich.seg3d_segments as usize,
830            OverflowContext::Trail { space: true, .. } => rich.seg3d_segments as usize,
831            OverflowContext::Trail { space: false, .. } => rich.max_segments,
832            OverflowContext::Voxels(_) => rich.voxel_grid as usize,
833        };
834        self.cap < top
835    }
836
837    /// The clearing of this overflow, worded in the same terms its onset was.
838    ///
839    /// Three of the five contexts clamp a structural parameter rather than
840    /// cutting geometry, so one sentence cannot speak for all five: what came
841    /// back is an iteration budget, a grid or a neighbourhood, not geometry and
842    /// not segments. The shell holds the overflow that last bit and renders this
843    /// off it, which keeps every word of the pair in this file.
844    pub fn recovered(&self) -> Recovered<'_> {
845        Recovered(self)
846    }
847}
848
849/// The recovery sentence for a [`CapOverflow`], as a [`Display`](std::fmt::Display)
850/// adapter rather than a `String` — the same reason [`OverflowContext`] is an
851/// enum: formatting happens only where something prints.
852pub struct Recovered<'a>(&'a CapOverflow);
853
854impl std::fmt::Display for Recovered<'_> {
855    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
856        // User-visible text the shell prints verbatim, like the onset above.
857        // No wildcard arm: a sixth context must choose its own sentence rather
858        // than inherit one that does not describe it.
859        let cap = self.0.cap;
860        match self.0.context {
861            OverflowContext::Mirror(_) | OverflowContext::Depth(_) => {
862                write!(f, "geometry is back within this tier's {cap}-segment cap")
863            }
864            OverflowContext::Iterations(_) => write!(
865                f,
866                "the iteration budget is back within this tier's cap of {cap}"
867            ),
868            OverflowContext::Grid(_) => {
869                write!(f, "the grid is back within this tier's cap of {cap}")
870            }
871            OverflowContext::Radius(_) => {
872                write!(
873                    f,
874                    "the neighbourhood is back within this tier's cap of {cap}"
875                )
876            }
877            OverflowContext::Points(_) => {
878                write!(f, "the point count is back within this tier's cap of {cap}")
879            }
880            OverflowContext::Edges(_) => {
881                write!(f, "the links are back within this tier's cap of {cap}")
882            }
883            OverflowContext::Blur(_) => {
884                write!(f, "the aperture is back within this tier's cap of {cap} px")
885            }
886            OverflowContext::Samples(_) => {
887                write!(
888                    f,
889                    "the sample count is back within this tier's cap of {cap}"
890                )
891            }
892            OverflowContext::Rows(_, per_row) => {
893                let kept = cap / per_row.max(1) as usize;
894                write!(
895                    f,
896                    "the row count is back within this tier's cap of {kept} rows"
897                )
898            }
899            OverflowContext::Trail { .. } => {
900                write!(f, "the trail is back within this tier's cap of {cap}")
901            }
902            OverflowContext::Voxels(_) => {
903                write!(f, "the grid is back within this tier's cap of {cap}")
904            }
905        }
906    }
907}
908
909/// A scene that binds a parameter **per mesh vertex** (Plan 0100 Phase 1,
910/// ADR-0113).
911///
912/// Reached through [`Scene::as_per_vertex_bound`], whose default is `None`
913/// (ADR-0238): a `[per_vertex]` table aimed at a scene that has no vertices is
914/// an absence the caller can act on, rather than a no-op body that returns and
915/// leaves no trace. The loader already warns that such a table is inert.
916pub(crate) trait PerVertexBound {
917    /// Apply one named parameter as a per-vertex series: `values` holds one
918    /// evaluation of the binding per mesh vertex, in row-major order from the
919    /// top-left, `(meshx + 1) * (meshy + 1)` long.
920    ///
921    /// The per-element channel one axis up, and deliberately just as narrow: it
922    /// carries `(name, &[f32])` in one direction and returns nothing.
923    ///
924    /// The slice borrows the renderer's scratch, sized at preset load from the
925    /// same [`clamp_grid`](warp_mesh::clamp_grid) the scene uses, so nothing
926    /// here allocates.
927    fn set_per_vertex(&mut self, name: &str, values: &[f32]);
928}
929
930/// A scene that binds a parameter **per element** (Plan 0034 Phase 4,
931/// ADR-0036).
932///
933/// Reached through [`Scene::as_series_bound`], whose default is `None`
934/// (ADR-0238). A scene without a per-element surface does not silently swallow
935/// the series: the caller takes element 0 through
936/// [`Scene::set_param`](Scene::set_param) instead, which is the `index = 0`
937/// reading the binding would have got outside a per-element evaluation.
938pub(crate) trait SeriesBound {
939    /// Apply one named parameter as a per-element series, in element order.
940    /// Reached only for a binding whose expression names `index`.
941    ///
942    /// **This is the whole channel, and it is deliberately this narrow.** It
943    /// carries `(name, &[f32])` in one direction and returns nothing. A scene
944    /// cannot ask the preset layer for anything, cannot see the expression, and
945    /// cannot learn which preset is loaded — so this is `set_param` with a
946    /// slice, not an inversion in which scenes read presets. The slice borrows
947    /// the renderer's scratch, which is sized at preset load, so nothing here
948    /// allocates.
949    ///
950    /// A name this scene has no per-element row for is the implementor's own
951    /// fallback, not the caller's: only the scene knows which of its parameters
952    /// vary across elements.
953    fn set_param_series(&mut self, name: &str, values: &[f32]);
954}
955
956/// A scene keeping a feedback field a probe can read **before** that scene's
957/// present pass.
958///
959/// **`#[cfg(test)]`, and that gate is the whole justification.** ADR-0002 keeps
960/// the scene seam thin and a real widening of it is ADR-worthy; this does not
961/// exist in a shipped build, so the extension seam is unchanged. It exists
962/// because Plan 0111 Phase 2's bisect requires its five seams to be read from
963/// **one run** — same signal, same hop, same size, same adapter — and a
964/// `Box<dyn Scene>` cannot otherwise be asked for the one quantity that sits
965/// upstream of everything the bisect covers. Measuring seam A on a
966/// separately-driven scene would satisfy the arithmetic and quietly break that
967/// requirement.
968#[cfg(test)]
969pub(crate) trait FeedbackSource {
970    /// The field, or `None` when this scene's GPU resources have not been built
971    /// yet — so the outer [`Option`] says "no such capability" and the inner one
972    /// says "not yet".
973    fn feedback_field(&self) -> Option<&wgpu::Texture>;
974}
975
976/// A scene with a feedback accumulation of **its own**, which takes a preset's
977/// `[feedback]` table (ADR-0048).
978///
979/// # One vocabulary, two buffers
980///
981/// **This is the routing contract, and it is worth stating plainly because it
982/// will surprise someone.** The `fb_*` params and that table are consumed by
983/// *two* sinks: the engine [`Trails`](crate::render::trails::Trails) stage,
984/// which transforms the accumulation every scene composites through, and the
985/// attractor scene's own internal trail field, which is what reaches here. A
986/// preset may have **both** active at once — an attractor with `trails` on —
987/// and then a single `fb_rotate` turns *both* accumulations, each about its own
988/// buffer. Neither transforms the other's, and neither transforms the present
989/// deposit: the transform applies to the past.
990///
991/// That is a deliberate design (ADR-0048's Alternative D was to give the engine
992/// stage the vocabulary and leave the attractor out), and the reason it is safe
993/// is that the two answer the same param names with the same arithmetic —
994/// [`feedback::Transform`](crate::render::feedback::Transform) and one shared
995/// WGSL snippet, not two implementations that must agree.
996pub(crate) trait FeedbackSink {
997    /// Take the active preset's `[feedback]` table. Invoked **once at preset
998    /// load, off the hot path**, like [`Scene::configure`] and
999    /// [`Scene::set_palette`]: a warp kind is a shader path, not a scalar.
1000    fn set_feedback(&mut self, cfg: crate::render::feedback::FeedbackConfig);
1001}
1002
1003/// One visual. `update` advances state from the analysis frame; `render` draws
1004/// with the state it has.
1005///
1006/// Both built-in systems (fragment field, swarm) are preset-driven and
1007/// implement the named-parameter surface — `set_time`, `reset_params`,
1008/// `set_param` — that the preset layer evaluates into per frame (ADR-0002). The
1009/// trait carries no-op defaults so a future non-parametric scene need not.
1010///
1011/// # Capabilities are declared, not defaulted
1012///
1013/// A surface only some scenes have is a narrow trait of its own, reached
1014/// through an `as_*` accessor here whose default is `None` (ADR-0238). The
1015/// difference from a defaulted method is what the seam can *say*: a caller
1016/// asking a scene for a capability it lacks gets an absence it can act on
1017/// rather than a body that returns having done nothing.
1018pub(crate) trait Scene {
1019    fn name(&self) -> &'static str;
1020    fn update(&mut self, frame: &AnalysisFrame);
1021    fn render(
1022        &mut self,
1023        queue: &wgpu::Queue,
1024        encoder: &mut wgpu::CommandEncoder,
1025        view: &wgpu::TextureView,
1026        aspect: f32,
1027    );
1028
1029    /// The pixel size of the target **this scene renders into this frame**
1030    /// (ADR-0030). That is *not* always the surface: the composite chain routes
1031    /// the scene into the first active post stage's input, which is a fixed
1032    /// internal grid for the trails and kaleidoscope stages and the surface
1033    /// otherwise, so the only correct value is the one the chain reports back
1034    /// (`PostChain::begin`).
1035    /// A scene that accumulates into an internal offscreen field sizes that field
1036    /// from here, so it matches its target instead of upscaling from a fixed grid
1037    /// or supersampling into a smaller offscreen; every other scene ignores it.
1038    ///
1039    /// **Called unconditionally every frame**, immediately before
1040    /// [`render`](Self::render) — it is named for what it carries, not for an
1041    /// event, and there is no resize event behind it. So ADR-0030 condition 2
1042    /// binds every implementor: **compare against what you already built and do
1043    /// nothing when unchanged**, and never allocate or build GPU resources here.
1044    /// The attractor records the requested grid and lets the next `render` notice
1045    /// the difference.
1046    ///
1047    /// Default no-op, in the same spirit as [`advance`](Self::advance): the
1048    /// renderer already holds the size in `draw_frame`, and `Scene` is a `dyn`
1049    /// trait, so this is the only channel that reaches a scene with it (Plan 0027
1050    /// Phase 2, the third and first hot-path widening — ADR-0030).
1051    fn set_target_size(&mut self, _width: u32, _height: u32) {}
1052
1053    /// How much of the renderer's grid scale (ADR-0245) the size handed to
1054    /// [`set_target_size`](Self::set_target_size) does **not** already carry —
1055    /// the fraction a scene sizing an internal field of its own still applies.
1056    ///
1057    /// The value depends on the frame's route, which is why it cannot ride
1058    /// `configure`: into a post stage the target is that stage's grid, already
1059    /// scaled, and this is `1.0`; straight into the destination the target is
1060    /// the display's own size and this is the whole scale. A field sized from the
1061    /// target alone would therefore be drawn at the square of the scale whenever
1062    /// a stage is active.
1063    ///
1064    /// **Called unconditionally every frame**, immediately before
1065    /// `set_target_size`, and under the same ADR-0030 obligations: record the
1066    /// value and compare, never build or allocate here. Default no-op — only the
1067    /// attractor has a field to size.
1068    fn set_grid_scale(&mut self, _scale: crate::render::tier::GridScale) {}
1069
1070    /// How much of this scene's coverage the **backdrop** resolves against, for
1071    /// the frame it is about to render (ADR-0085). `1.0` is coverage-as-occlusion
1072    /// — what every frame did before `occlude` existed — and `0.0` is light that
1073    /// adds without covering.
1074    ///
1075    /// **Called unconditionally every frame**, immediately before
1076    /// [`render`](Self::render), in the same spirit as
1077    /// [`set_target_size`](Self::set_target_size). The renderer hands a literal
1078    /// `1.0` whenever a post stage is active, because then the scene draws into a
1079    /// scratch offscreen with no backdrop under it and the chain's last stage owns
1080    /// the seam instead — a scene must never apply this twice.
1081    ///
1082    /// Only a scene that **presents premultiplied over the backdrop** (ADR-0026 —
1083    /// the reaction-diffusion, cellular, attractor and warp-mesh presents, and the
1084    /// fragment-field, analytic-field, shape-field and shape-collage fullscreen
1085    /// fields) has anything to do here. Such a scene writes `occlude` into its
1086    /// alpha and its present pipeline blends `PREMULTIPLIED_ALPHA_BLENDING`, so
1087    /// the backdrop resolves as `scene + bg * (1 - occlude)`; at the literal `1.0`
1088    /// that blend is exactly a replace. The additive families draw through
1089    /// [`gpu::ADDITIVE_LIGHT_SATURATING_COVERAGE`](crate::render::gpu::ADDITIVE_LIGHT_SATURATING_COVERAGE),
1090    /// whose colour destination factor is `One`: with no stage active their light
1091    /// already adds to the backdrop rather than replacing it, so there is no
1092    /// occlusion at that seam for this to scale. Default no-op.
1093    fn set_occlude(&mut self, _occlude: f32) {}
1094
1095    /// This scene as a [`FeedbackSource`], or `None` — which is every scene but
1096    /// the warp mesh.
1097    #[cfg(test)]
1098    fn as_feedback_source(&self) -> Option<&dyn FeedbackSource> {
1099        None
1100    }
1101
1102    /// Advance simulation state by `dt` real seconds (Plan 0014 Phase 2). The
1103    /// renderer injects the elapsed time each frame; a feedback scene steps its
1104    /// fixed-timestep accumulator here and a CPU-integrated scene (the swarm)
1105    /// scales its motion by `dt`, so both look identical over wall-clock time on
1106    /// any refresh rate. Stateless, purely `time`-driven scenes ignore it.
1107    ///
1108    /// **Called after this frame's parameters are applied** — after
1109    /// [`reset_params`](Self::reset_params), every binding, the live overrides
1110    /// and the per-vertex table, and after [`set_time`](Self::set_time) —
1111    /// and immediately before [`update`](Self::update). A value a scene reads
1112    /// here is the one this frame bound, so a rate may be integrated in
1113    /// `advance` or in `update` and both are correct (ADR-0198).
1114    ///
1115    /// **`dt` is finite and strictly positive.** The renderer guarantees it,
1116    /// substituting [`FALLBACK_DT`] for a degenerate delta before this is called
1117    /// (ADR-0152), so an implementor may store it, integrate it, or divide by it
1118    /// without checking. **Do not re-check it per scene**: the guarantee is
1119    /// invisible from inside a scene, and this sentence is the whole of what
1120    /// stands between a shell's raw delta and every accumulator behind this
1121    /// trait — a second copy at one site makes it a rule enforced by a list of
1122    /// sites again, which is the failure the seam exists to end.
1123    fn advance(&mut self, _dt: f32) {}
1124
1125    /// Set the shared scene clock (seconds). The renderer owns the single clock
1126    /// so an expression's `time` and the system's animation never diverge.
1127    fn set_time(&mut self, _time: f32) {}
1128    /// Reset every named parameter to its default (called each frame before the
1129    /// active preset's bindings are applied, so unbound params don't leak).
1130    fn reset_params(&mut self) {}
1131    /// Apply one named parameter; unknown names are ignored.
1132    fn set_param(&mut self, _name: &str, _value: f32) {}
1133
1134    /// This scene as a [`SeriesBound`], or `None` — which is every scene but the
1135    /// spectrum readout.
1136    ///
1137    /// The caller degrades a series to element 0 on `None`, so a scene that
1138    /// never opts in behaves byte-for-byte as it did when that fallback was a
1139    /// default body here.
1140    fn as_series_bound(&mut self) -> Option<&mut dyn SeriesBound> {
1141        None
1142    }
1143
1144    /// This scene as a [`PerVertexBound`], or `None` — which is every scene but
1145    /// the warp mesh.
1146    ///
1147    /// Unlike [`as_series_bound`](Self::as_series_bound) there is no degrading
1148    /// on `None`: a per-vertex series varies over space and its first element is
1149    /// the top-left corner, which is not a sensible whole-scene reading of
1150    /// anything. The caller stops evaluating the table instead.
1151    fn as_per_vertex_bound(&mut self) -> Option<&mut dyn PerVertexBound> {
1152        None
1153    }
1154
1155    /// Consume a preset's declarative structural config (ADR-0007). Invoked
1156    /// **once at preset load, off the hot path** — a generator builds and caches
1157    /// its geometry here; a parametric scene records its family. Default no-op,
1158    /// so non-line scenes (fragment field, swarm) never implement it. The one
1159    /// optional widening of this trait ADR-0007 sanctions — keep it to this.
1160    ///
1161    /// Returns [`Some`](lines::CapOverflow) when building the geometry hit the
1162    /// segment cap and truncated, so the frontend can surface it — the cap is
1163    /// never a silent cut (ADR-0007 Risks). `None` means it fit (the norm).
1164    fn configure(&mut self, _cfg: &lines::GeneratorConfig) -> Option<lines::CapOverflow> {
1165        None
1166    }
1167
1168    /// Consume a preset's baked color [`Palette`] (ADR-0021). Invoked **once at
1169    /// preset load, off the hot path** — a shader-colored scene stores the baked
1170    /// LUT and uploads it to its 256×1 texture (or samples it on the CPU) on the
1171    /// next frame; a non-colored scene (the line scenes) ignores it. Default
1172    /// no-op. The second and last thin off-hot-path widening of this trait after
1173    /// ADR-0007's [`configure`](Scene::configure).
1174    fn set_palette(&mut self, _palette: &Palette) {}
1175
1176    /// This scene as a [`FeedbackSink`], or `None` — which is every scene but
1177    /// the attractor.
1178    fn as_feedback_sink(&mut self) -> Option<&mut dyn FeedbackSink> {
1179        None
1180    }
1181
1182    /// The per-frame cap overflow, if this frame hit one: the line scenes'
1183    /// geometry mirror (Plan 0018 Phase 4) when its N-fold replication exceeded
1184    /// the segment cap and truncated, the analytic field's escape-time budget
1185    /// when a bound `iterations` passed the tier's cap and was clamped, or the
1186    /// cellular system's `radius` likewise. Reuses the ADR-0007
1187    /// [`CapOverflow`](lines::CapOverflow) so the frontend surfaces any of them
1188    /// — a cap is never silent. Default `None`.
1189    fn mirror_overflow(&self) -> Option<&lines::CapOverflow> {
1190        None
1191    }
1192}
1193
1194/// The registry: every built-in scene, **keyed by the [`SystemKind`] it drives**,
1195/// in [`SystemKind::ALL`] order. All scenes are created up front so switching
1196/// mid-show is a lookup, never a hitch.
1197///
1198/// The keying is the point: the renderer addresses a scene by the kind its preset
1199/// names, so a scene cannot silently end up in the wrong slot. Nothing here is
1200/// positional — reordering [`SystemKind::ALL`] reorders construction and nothing
1201/// else.
1202pub(crate) fn create_all(
1203    device: &wgpu::Device,
1204    surface_format: wgpu::TextureFormat,
1205    tier: &crate::render::TierConfig,
1206    budget: crate::render::SampleBudget,
1207) -> Vec<(SystemKind, Box<dyn Scene>)> {
1208    // One shared line renderer for every line scene (ADR-0007: "one line
1209    // renderer"). A single instanced-quad pipeline + segment buffer, borrowed by
1210    // whichever line scene is active — only one draws per frame. (Two separate
1211    // line pipelines with byte-identical vertex layouts also mis-render on the
1212    // DX12 WARP software adapter, a WARP reading unverified on lavapipe as of
1213    // 2026-10-06; one renderer avoids it.)
1214    // `new_with_arcs`, not `new`: `star_pattern`'s circular motifs are one arc
1215    // instance each (ADR-0098), and the arc buffer holds
1216    // `max_segments` because the two kinds share **one** budget — everything
1217    // that passes `build_rings`'s cap check must reach the GPU, or a cap would
1218    // be silently cutting geometry, which ADR-0007 forbids.
1219    // `new_split_with_arcs`: any of the four line systems may ask for the
1220    // opacity-preserving seam through `stroke_blend` (ADR-0138), and the
1221    // pipelines are built here rather than when a preset first selects one —
1222    // building a GPU resource mid-run changes what a later pass resolves to on
1223    // the DX12 software adapter (a WARP reading, unverified on lavapipe as of
1224    // 2026-10-06).
1225    let line_renderer = Rc::new(RefCell::new(lines::LineRenderer::new_split_with_arcs(
1226        device,
1227        surface_format,
1228        tier.max_segments,
1229        tier.max_segments,
1230        "lines",
1231    )));
1232    SystemKind::ALL
1233        .iter()
1234        .map(|&kind| {
1235            (
1236                kind,
1237                create(
1238                    kind,
1239                    device,
1240                    surface_format,
1241                    &mut || line_renderer.clone(),
1242                    tier,
1243                    budget,
1244                ),
1245            )
1246        })
1247        .collect()
1248}
1249
1250/// A scene constructed **for one preset's `[layer]`** (ADR-0090 point 4, Plan
1251/// 0076 Phase 2), never taken from the roster — which is what makes same-system
1252/// pairs legal and keeps two dissolving sides' layers from sharing anything.
1253/// The stateful families duplicate their GPU state by construction: their
1254/// constructors are already self-contained (a second reaction-diffusion
1255/// ping-pong field, a second particle buffer), so this is the same exhaustive
1256/// [`create`] the roster uses, differing only in where a line scene gets its
1257/// renderer.
1258///
1259/// # The `LineRenderer` answer, recorded (the Phase 2 discovery duty)
1260///
1261/// **A layer line scene gets its own `LineRenderer`; the shared one is not
1262/// shareable between two live line draws in one frame.** `LineRenderer::draw`
1263/// uploads its instance and uniform buffers through `Queue::write_buffer`, and
1264/// queued writes are applied before the submission's passes execute — so two
1265/// draws through one renderer in one frame would both rasterize the *second*
1266/// draw's segments under the second draw's uniforms. Making it shareable would
1267/// need a partitioned instance buffer and per-draw uniform slots — a redesign
1268/// of the idiom, not constructor plumbing — so duplication is the answer, at
1269/// one pipeline plus one `max_segments` instance buffer per layered line
1270/// preset.
1271///
1272/// The duplicate is built **only when the layer is a line system**: a
1273/// fragment/swarm/particle layer pays no line pipeline, and WARP's documented
1274/// sensitivity to coexisting identical pipeline layouts (ADR-0058 / Plan 0053)
1275/// is only ever exercised by a preset that actually declares a line layer.
1276/// (That sensitivity is a WARP reading, unverified on lavapipe as of
1277/// 2026-10-06.)
1278pub(crate) fn create_layer_scene(
1279    kind: SystemKind,
1280    device: &wgpu::Device,
1281    surface_format: wgpu::TextureFormat,
1282    tier: &crate::render::TierConfig,
1283    budget: crate::render::SampleBudget,
1284) -> Box<dyn Scene> {
1285    create(
1286        kind,
1287        device,
1288        surface_format,
1289        &mut || {
1290            // Arcs too — a `[layer]` may be a `star_pattern`, and a layer that
1291            // could not draw them would render a mandala with its circles
1292            // missing rather than fail.
1293            Rc::new(RefCell::new(lines::LineRenderer::new_split_with_arcs(
1294                device,
1295                surface_format,
1296                tier.max_segments,
1297                tier.max_segments,
1298                "layer-lines",
1299            )))
1300        },
1301        tier,
1302        budget,
1303    )
1304}
1305
1306/// Whether two systems' scenes share mutable GPU state, so **one frame must not
1307/// render both**.
1308///
1309/// Two facts make this true, and only one of them is obvious. The roster is keyed
1310/// by kind, so the same kind is literally the same `Box<dyn Scene>`. Less
1311/// obviously, the three **line** scenes deliberately share one `LineRenderer` —
1312/// "borrowed by whichever line scene is active, only one draws per frame" (see
1313/// [`create_all`]) — so two *different* line kinds are just as unrenderable in one
1314/// frame as one kind twice.
1315///
1316/// Plan 0023's dual-live dissolve is the first caller: it composites two presets
1317/// in a single frame, which is exactly what this forbids. A pair that shares
1318/// resources falls back to the frozen snapshot.
1319///
1320/// **This is a statement about the roster's instances only.** A `[layer]`
1321/// scene ([`create_layer_scene`], Plan 0076 Phase 2) is constructed per preset
1322/// and shares nothing with the roster or with another preset's layer by
1323/// construction — so a preset's own main-plus-layer pair never consults this,
1324/// whatever the two systems are.
1325pub(crate) fn shares_resources(a: SystemKind, b: SystemKind) -> bool {
1326    a == b || (kind_info(a).shares_line_renderer && kind_info(b).shares_line_renderer)
1327}
1328
1329/// The render-side static facts about a [`SystemKind`] — what is true of the
1330/// *kind*, as opposed to what a built scene can be asked (ADR-0238 part 3).
1331///
1332/// These stay kind facts because they are asked where no scene exists:
1333/// [`shares_resources`] is consulted about a **roster preset** whose scene may
1334/// never have been constructed, which is why no accessor on `Scene` can replace
1335/// it. They stay in `render/` because each is a render implementation detail
1336/// and none belongs in the preset schema.
1337pub(crate) struct SceneKindInfo {
1338    /// Whether this kind draws through the roster's one shared
1339    /// [`LineRenderer`](lines::LineRenderer) — "borrowed by whichever line scene
1340    /// is active, only one draws per frame" (see [`create_all`]). So two
1341    /// *different* line kinds are as unrenderable in one frame as one kind
1342    /// twice.
1343    pub(crate) shares_line_renderer: bool,
1344}
1345
1346/// The static facts about `kind`, in **one exhaustive table** with no wildcard
1347/// arm, like [`create`] itself: a new system fails to compile here until
1348/// someone says which side of every fact it is on.
1349///
1350/// The next such fact is a **field on [`SceneKindInfo`]**, not a fifteenth
1351/// fourteen-arm match of its own — that accumulation is what ADR-0238 part 3
1352/// ended.
1353pub(crate) fn kind_info(kind: SystemKind) -> SceneKindInfo {
1354    // `waterfall` is a line system that answers `false`: it draws through a
1355    // `seg3d` renderer of its own and never borrows the shared 2D one.
1356    let shares_line_renderer = match kind {
1357        SystemKind::ParametricCurve
1358        | SystemKind::LSystem
1359        | SystemKind::StarPattern
1360        | SystemKind::Spectrum => true,
1361        SystemKind::FragmentField
1362        | SystemKind::Swarm
1363        | SystemKind::ReactionDiffusion
1364        | SystemKind::Attractor
1365        | SystemKind::Emitter
1366        | SystemKind::ShapeField
1367        | SystemKind::WarpMesh
1368        | SystemKind::ShapeCollage
1369        | SystemKind::AnalyticField
1370        | SystemKind::Cellular
1371        | SystemKind::Plexus
1372        | SystemKind::Waterfall
1373        | SystemKind::Voxel => false,
1374    };
1375    SceneKindInfo {
1376        shares_line_renderer,
1377    }
1378}
1379
1380/// Build the scene a [`SystemKind`] drives.
1381///
1382/// An **exhaustive** `match` with no wildcard arm — the same guard the golden
1383/// drift fixtures use: adding a variant fails to compile here until its scene is
1384/// constructed, so a new system cannot ship unbuilt or wired to the wrong scene.
1385///
1386/// `line_renderer` is a **source**, called only by the line arms: the roster
1387/// hands out clones of its one shared renderer, a layer construction builds a
1388/// fresh one on demand ([`create_layer_scene`]) — and a non-line kind builds
1389/// none at all.
1390fn create(
1391    kind: SystemKind,
1392    device: &wgpu::Device,
1393    surface_format: wgpu::TextureFormat,
1394    line_renderer: &mut dyn FnMut() -> Rc<RefCell<lines::LineRenderer>>,
1395    tier: &crate::render::TierConfig,
1396    budget: crate::render::SampleBudget,
1397) -> Box<dyn Scene> {
1398    match kind {
1399        SystemKind::FragmentField => Box::new(fragment_field::FragmentFieldScene::new(
1400            device,
1401            surface_format,
1402        )),
1403        SystemKind::Swarm => Box::new(swarm::SwarmScene::new(
1404            device,
1405            surface_format,
1406            tier.swarm_particles,
1407            tier.swarm_max_coc_px.min(tier.max_coc_px) as f32,
1408        )),
1409        SystemKind::ParametricCurve => Box::new(lines::ParametricCurveScene::new(
1410            line_renderer(),
1411            tier.max_segments,
1412            device,
1413            surface_format,
1414            tier.seg3d_segments as usize,
1415            tier.max_coc_px as f32,
1416        )),
1417        SystemKind::LSystem => Box::new(lines::LSystemScene::new(
1418            line_renderer(),
1419            tier.max_segments,
1420            device,
1421            surface_format,
1422            tier.seg3d_segments as usize,
1423            tier.max_coc_px as f32,
1424        )),
1425        SystemKind::StarPattern => Box::new(lines::StarPatternScene::new(
1426            line_renderer(),
1427            tier.max_segments,
1428        )),
1429        SystemKind::ReactionDiffusion => Box::new(reaction_diffusion::ReactionDiffusionScene::new(
1430            device,
1431            surface_format,
1432        )),
1433        SystemKind::Attractor => Box::new(create_attractor(device, surface_format, tier, budget)),
1434        SystemKind::Spectrum => Box::new(lines::SpectrumScene::new(
1435            line_renderer(),
1436            tier.max_segments,
1437        )),
1438        SystemKind::Emitter => Box::new(emitter::EmitterScene::new(
1439            device,
1440            surface_format,
1441            tier.emitter_objects,
1442        )),
1443        SystemKind::ShapeField => {
1444            Box::new(shape_field::ShapeFieldScene::new(device, surface_format))
1445        }
1446        SystemKind::WarpMesh => Box::new(warp_mesh::WarpMeshScene::new(
1447            device,
1448            surface_format,
1449            tier.mesh_grid,
1450            tier.max_segments,
1451        )),
1452        SystemKind::ShapeCollage => Box::new(shape_collage::ShapeCollageScene::new(
1453            device,
1454            surface_format,
1455            tier.collage_elements,
1456        )),
1457        SystemKind::AnalyticField => Box::new(analytic_field::AnalyticFieldScene::new(
1458            device,
1459            surface_format,
1460            tier.field_iterations,
1461        )),
1462        SystemKind::Cellular => Box::new(cellular::CellularScene::new(
1463            device,
1464            surface_format,
1465            tier.cellular_radius,
1466            tier.cellular_grid,
1467            tier.cellular_route_rate,
1468        )),
1469        SystemKind::Plexus => Box::new(plexus::PlexusScene::new(
1470            device,
1471            surface_format,
1472            tier.plexus_points as usize,
1473            tier.plexus_edges as usize,
1474            tier.max_coc_px as f32,
1475        )),
1476        SystemKind::Waterfall => Box::new(lines::WaterfallScene::new(
1477            device,
1478            surface_format,
1479            tier.seg3d_segments as usize,
1480            tier.max_coc_px as f32,
1481        )),
1482        SystemKind::Voxel => Box::new(voxel::VoxelScene::new(
1483            device,
1484            surface_format,
1485            tier.voxel_grid,
1486            tier.voxel_march_scale,
1487            tier.voxel_march_cap,
1488        )),
1489    }
1490}
1491
1492/// The attractor scene [`create`] builds, as its concrete type.
1493///
1494/// **The ceiling choice lives here and nowhere else** (ADR-0140): a window gets
1495/// the tier's live cap, a headless render its offline one. Named rather than
1496/// inlined into the factory arm so a test can build exactly what the factory
1497/// builds and read the resolved budget off it, without restating the choice it
1498/// is asserting about.
1499fn create_attractor(
1500    device: &wgpu::Device,
1501    surface_format: wgpu::TextureFormat,
1502    tier: &crate::render::TierConfig,
1503    budget: crate::render::SampleBudget,
1504) -> particles::AttractorScene {
1505    particles::AttractorScene::new(
1506        device,
1507        surface_format,
1508        tier.attractor_particles,
1509        match budget {
1510            crate::render::SampleBudget::Live => tier.attractor_particles_live_ceiling,
1511            crate::render::SampleBudget::Offline => tier.attractor_particles_offline_ceiling,
1512        },
1513        tier.attractor_trail_cap,
1514        tier.max_coc_px as f32,
1515    )
1516}
1517
1518/// Tiny deterministic RNG (splitmix64) so visual randomness is explicitly
1519/// seeded (NFR 6) without pulling a rand crate.
1520pub(crate) struct SeededRng(u64);
1521
1522impl SeededRng {
1523    pub(crate) fn new(seed: u64) -> Self {
1524        Self(seed)
1525    }
1526
1527    fn next_u64(&mut self) -> u64 {
1528        self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
1529        let mut z = self.0;
1530        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
1531        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
1532        z ^ (z >> 31)
1533    }
1534
1535    /// Uniform in [0, 1).
1536    pub(crate) fn next_f32(&mut self) -> f32 {
1537        (self.next_u64() >> 40) as f32 / (1u64 << 24) as f32
1538    }
1539
1540    /// Uniform in [lo, hi).
1541    pub(crate) fn range(&mut self, lo: f32, hi: f32) -> f32 {
1542        lo + (hi - lo) * self.next_f32()
1543    }
1544}
1545
1546#[cfg(test)]
1547mod tests {
1548    //! The scene-keying contract (Plan 0030 Phase 3), and the parameter-kind
1549    //! quantizer declared beside it. Test asserts panic freely; this is not the
1550    //! render path.
1551    #![allow(clippy::panic, clippy::expect_used)]
1552
1553    use super::{CapOverflow, OverflowContext, ParamKind, Scene, create_all, create_attractor};
1554    use crate::preset::SystemKind;
1555    use crate::render::context::{RenderContext, RenderError};
1556
1557    /// **`pin --tier rich` is offered only where Rich would lift the cap.** At
1558    /// a cap below the top tier's the remedy names it; at the top tier's own
1559    /// cap it does not, in every context that offers it.
1560    #[test]
1561    fn the_tier_remedy_is_offered_only_below_the_top_tier() {
1562        let rich = crate::render::TierConfig::RICH;
1563        for (context, top) in [
1564            (
1565                OverflowContext::Iterations(5000),
1566                rich.field_iterations as usize,
1567            ),
1568            (OverflowContext::Grid(4096), rich.cellular_grid as usize),
1569            (OverflowContext::Radius(40), rich.cellular_radius as usize),
1570            (OverflowContext::Points(9000), rich.plexus_points as usize),
1571            (OverflowContext::Edges(90_000), rich.plexus_edges as usize),
1572            (OverflowContext::Blur(30), rich.max_coc_px as usize),
1573            (
1574                OverflowContext::Samples(90_000),
1575                rich.seg3d_segments as usize,
1576            ),
1577            (OverflowContext::Rows(900, 63), rich.seg3d_segments as usize),
1578            (
1579                OverflowContext::Trail {
1580                    asked: 90_000,
1581                    space: true,
1582                },
1583                rich.seg3d_segments as usize,
1584            ),
1585            (
1586                OverflowContext::Trail {
1587                    asked: 90_000,
1588                    space: false,
1589                },
1590                rich.max_segments,
1591            ),
1592            (OverflowContext::Voxels(512), rich.voxel_grid as usize),
1593        ] {
1594            let below = CapOverflow {
1595                dropped: 1,
1596                context,
1597                cap: top / 2,
1598            };
1599            assert!(
1600                below.to_string().contains("pin --tier rich"),
1601                "{context} under a lower tier's cap: {below}"
1602            );
1603            let at_top = CapOverflow {
1604                dropped: 1,
1605                context,
1606                cap: top,
1607            };
1608            assert!(
1609                !at_top.to_string().contains("--tier"),
1610                "{context} at the top tier's cap: {at_top}"
1611            );
1612        }
1613    }
1614
1615    /// **Each context's recovery is worded in that context's own terms.** Three
1616    /// of the five clamp a structural parameter rather than cutting geometry, so
1617    /// a line telling an operator that "geometry is back within the segment cap"
1618    /// after an iteration, grid or radius clamp names something that never
1619    /// overflowed.
1620    #[test]
1621    fn each_overflow_context_recovers_in_its_own_words() {
1622        for (context, word) in [
1623            (OverflowContext::Mirror(8), "geometry"),
1624            (OverflowContext::Depth(9), "geometry"),
1625            (OverflowContext::Iterations(600), "iteration budget"),
1626            (OverflowContext::Grid(512), "grid"),
1627            (OverflowContext::Radius(7), "neighbourhood"),
1628            (OverflowContext::Points(900), "point count"),
1629            (OverflowContext::Edges(30_000), "links"),
1630            (OverflowContext::Blur(30), "aperture"),
1631            (OverflowContext::Samples(30_000), "sample count"),
1632            // One segment a row, so the rows kept are the 20000 asserted below.
1633            (OverflowContext::Rows(30_000, 1), "row count"),
1634            (
1635                OverflowContext::Trail {
1636                    asked: 30_000,
1637                    space: true,
1638                },
1639                "trail",
1640            ),
1641            (OverflowContext::Voxels(512), "grid"),
1642        ] {
1643            let overflow = CapOverflow {
1644                dropped: 0,
1645                context,
1646                cap: 20_000,
1647            };
1648            let text = overflow.recovered().to_string();
1649            assert!(
1650                text.contains(word),
1651                "{context}'s recovery does not name {word}: {text}"
1652            );
1653            assert!(
1654                text.contains("20000"),
1655                "{context}'s recovery does not carry the cap that bit: {text}"
1656            );
1657            let structural = matches!(
1658                context,
1659                OverflowContext::Iterations(_)
1660                    | OverflowContext::Grid(_)
1661                    | OverflowContext::Radius(_)
1662                    | OverflowContext::Points(_)
1663                    | OverflowContext::Edges(_)
1664                    | OverflowContext::Blur(_)
1665                    | OverflowContext::Samples(_)
1666                    | OverflowContext::Rows(..)
1667                    | OverflowContext::Trail { .. }
1668                    | OverflowContext::Voxels(_)
1669            );
1670            if structural {
1671                assert!(
1672                    !text.contains("segment") && !text.contains("geometry"),
1673                    "{context} is a clamp of a structural parameter, and its recovery \
1674                     still speaks of geometry: {text}"
1675                );
1676            }
1677        }
1678    }
1679
1680    /// `Structural` rounds and `Modal` does not — the whole of what a kind
1681    /// changes about a value.
1682    ///
1683    /// **Nothing else can see this work or fail.** The engine's `Structural`
1684    /// roster is confined to parameters whose scene already clamps and rounds
1685    /// the value itself, so `quantize` composes to the identity everywhere it
1686    /// currently runs and no rendered assertion distinguishes it from being
1687    /// absent (design-backlog 0197). This covers it directly instead.
1688    #[test]
1689    fn a_structural_kind_rounds_and_a_modal_one_hands_the_value_through() {
1690        // Rust rounds a half AWAY FROM ZERO, and a scene indexing a closed
1691        // roster with the result depends on which way 6.5 goes, so the
1692        // direction is pinned here rather than assumed.
1693        for (value, rounded) in [
1694            (6.4_f32, 6.0_f32),
1695            (6.5, 7.0),
1696            (6.6, 7.0),
1697            (-6.4, -6.0),
1698            (-6.5, -7.0),
1699            (3.0, 3.0),
1700            (0.0, 0.0),
1701        ] {
1702            assert_eq!(
1703                ParamKind::Structural.quantize(value),
1704                rounded,
1705                "Structural must round {value}"
1706            );
1707            assert_eq!(
1708                ParamKind::Modal.quantize(value),
1709                value,
1710                "Modal must hand {value} through untouched"
1711            );
1712        }
1713
1714        // The property behind the table, over a sweep that lands on no integer
1715        // by construction: a Structural value equals its own round, and
1716        // quantizing it again moves nothing.
1717        for i in -400..400 {
1718            let value = i as f32 * 0.0137;
1719            let q = ParamKind::Structural.quantize(value);
1720            assert_eq!(q, q.round(), "Structural produced the non-integral {q}");
1721            assert_eq!(
1722                ParamKind::Structural.quantize(q),
1723                q,
1724                "quantizing an already-quantized {q} moved it"
1725            );
1726            assert_eq!(
1727                ParamKind::Modal.quantize(value),
1728                value,
1729                "Modal moved {value}"
1730            );
1731        }
1732
1733        // A non-finite value survives, which is what the type's own doc claims:
1734        // `f32::round` has no special case for one and neither does this.
1735        assert!(ParamKind::Structural.quantize(f32::NAN).is_nan());
1736        assert_eq!(ParamKind::Structural.quantize(f32::INFINITY), f32::INFINITY);
1737        assert_eq!(
1738            ParamKind::Structural.quantize(f32::NEG_INFINITY),
1739            f32::NEG_INFINITY
1740        );
1741    }
1742
1743    /// The scene each system is *supposed* to drive, written independently of the
1744    /// factory so the two can disagree. This is the mapping the old magic-index
1745    /// `system_slot` lookup could never assert: it named a position, and nothing
1746    /// checked the position held the right scene.
1747    fn expected_scene_name(system: SystemKind) -> &'static str {
1748        match system {
1749            SystemKind::FragmentField => "fragment field",
1750            SystemKind::ShapeField => "shape field",
1751            SystemKind::Swarm => "swarm",
1752            SystemKind::ParametricCurve => "parametric curve",
1753            SystemKind::LSystem => "l-system",
1754            SystemKind::StarPattern => "star pattern",
1755            SystemKind::ReactionDiffusion => "reaction diffusion",
1756            SystemKind::Attractor => "attractor",
1757            SystemKind::Spectrum => "spectrum",
1758            SystemKind::Emitter => "emitter",
1759            SystemKind::WarpMesh => "warp mesh",
1760            SystemKind::ShapeCollage => "shape collage",
1761            SystemKind::AnalyticField => "analytic field",
1762            SystemKind::Cellular => "cellular",
1763            SystemKind::Plexus => "plexus",
1764            SystemKind::Waterfall => "waterfall",
1765            SystemKind::Voxel => "voxel",
1766        }
1767    }
1768
1769    /// **The factory is what chooses the ceiling**, and the two choices resolve
1770    /// different budgets at the same target (ADR-0140).
1771    ///
1772    /// Read off the scene rather than recomputed: this is the wiring under test,
1773    /// so a recomputation of the law here would pass with the factory handing
1774    /// both modes the same number. [`create_attractor`] is the factory's own
1775    /// ceiling choice, reached for its concrete type because the budget is a
1776    /// property of this scene and not of the `Scene` seam (ADR-0238).
1777    /// 1920x1080 is the size the whole plan is about — nine times the
1778    /// reference — and it is past the live ceiling and under the offline one,
1779    /// which is what makes the two answers differ.
1780    ///
1781    /// No frame is rendered: `set_target_size` is CPU arithmetic, so this costs
1782    /// one scene build on the software adapter and nothing else.
1783    #[test]
1784    fn the_render_path_resolves_a_larger_budget_than_a_window_does() {
1785        use crate::render::{SampleBudget, TierConfig};
1786
1787        let ctx = match RenderContext::new_headless(64, 64, true) {
1788            Ok(ctx) => ctx,
1789            Err(RenderError::RequestAdapter(_)) => {
1790                eprintln!("skipped: no GPU adapter on this runner (ADR-0016)");
1791                return;
1792            }
1793            Err(e) => panic!("headless context build failed: {e}"),
1794        };
1795
1796        let resolved = |budget: SampleBudget, tier: &TierConfig, w: u32, h: u32| -> Option<u32> {
1797            let mut scene = create_attractor(&ctx.device, ctx.surface_format(), tier, budget);
1798            // Before a target size reaches it, a scene has no resolved budget to
1799            // report - which is the distinction the `None` carries.
1800            assert_eq!(scene.sample_budget(), None);
1801            scene.set_target_size(w, h);
1802            scene.sample_budget()
1803        };
1804
1805        let rich = TierConfig::RICH;
1806        assert_eq!(
1807            resolved(SampleBudget::Offline, &rich, 1920, 1080),
1808            Some(1_350_000),
1809            "a render at 1080p must reach the law's own value, not the live cap"
1810        );
1811        assert_eq!(
1812            resolved(SampleBudget::Live, &rich, 1920, 1080),
1813            Some(rich.attractor_particles_live_ceiling),
1814            "a window at 1080p is held at the live ceiling"
1815        );
1816
1817        // Deposits per output pixel, stated as sample arithmetic (an image
1818        // statistic would be the wrong instrument - the ones this repo has are
1819        // themselves resolution-bound). The render reaches the 640x360
1820        // reference density exactly; today's flat count delivers a ninth of it.
1821        const PX: u32 = 1920 * 1080;
1822        let render = 1_350_000_f64 / f64::from(PX);
1823        let reference =
1824            f64::from(rich.attractor_particles) / f64::from(crate::render::REFERENCE_PX);
1825        assert!(
1826            (render - reference).abs() < 1e-9,
1827            "a 1080p render deposits {render} samples per pixel against the reference {reference}"
1828        );
1829        assert_eq!(1_350_000 / rich.attractor_particles, 9);
1830
1831        // And the small captures are untouched at BOTH ceilings, which is the
1832        // half that keeps every baseline where it is.
1833        for tier in [TierConfig::FLOOR, TierConfig::RICH] {
1834            for budget in [SampleBudget::Live, SampleBudget::Offline] {
1835                assert_eq!(
1836                    resolved(budget, &tier, 128, 128),
1837                    Some(tier.attractor_particles),
1838                    "{:?}/{budget:?} moved the golden suite's count",
1839                    tier.tier
1840                );
1841            }
1842        }
1843    }
1844
1845    /// **At a full grid scale the budget is today's at every size this suite
1846    /// names** (ADR-0245), and a fraction is what moves it.
1847    ///
1848    /// "Before" is the density law against the target alone —
1849    /// [`attractor_budget`](crate::render::attractor_budget) of `w * h`, the
1850    /// expression the scene evaluated before the grid scale existed — and
1851    /// "after" is read off the factory's scene with the scale handed to it the
1852    /// way the composite hands it. Every tier and both ceilings, because the
1853    /// floor's live ceiling is its anchor and would pass whatever the law did:
1854    /// the rich rows are where a count could move.
1855    ///
1856    /// The half-scale row is the non-vacuity: a 1080p field at 0.5 holds a
1857    /// quarter of the target's texels, so the rich live budget falls from its
1858    /// ceiling to 2.25x the anchor.
1859    #[test]
1860    fn a_full_grid_scale_resolves_todays_budget_at_every_suite_size() {
1861        use crate::render::tier::GridScale;
1862        use crate::render::{SampleBudget, TierConfig, attractor_budget};
1863
1864        let ctx = match RenderContext::new_headless(64, 64, true) {
1865            Ok(ctx) => ctx,
1866            Err(RenderError::RequestAdapter(_)) => {
1867                eprintln!("skipped: no GPU adapter on this runner (ADR-0016)");
1868                return;
1869            }
1870            Err(e) => panic!("headless context build failed: {e}"),
1871        };
1872
1873        for tier in [TierConfig::FLOOR, TierConfig::RICH] {
1874            for budget in [SampleBudget::Live, SampleBudget::Offline] {
1875                let ceiling = match budget {
1876                    SampleBudget::Live => tier.attractor_particles_live_ceiling,
1877                    SampleBudget::Offline => tier.attractor_particles_offline_ceiling,
1878                };
1879                for (w, h) in [(128, 128), (640, 360), (1920, 1080)] {
1880                    let before = attractor_budget(tier.attractor_particles, w * h, ceiling);
1881                    let mut scene =
1882                        create_attractor(&ctx.device, ctx.surface_format(), &tier, budget);
1883                    scene.set_grid_scale(GridScale::FULL);
1884                    scene.set_target_size(w, h);
1885                    assert_eq!(
1886                        scene.sample_budget(),
1887                        Some(before),
1888                        "{:?}/{budget:?} at {w}x{h}: the full-scale budget moved",
1889                        tier.tier
1890                    );
1891                }
1892            }
1893        }
1894
1895        let rich = TierConfig::RICH;
1896        let mut scene =
1897            create_attractor(&ctx.device, ctx.surface_format(), &rich, SampleBudget::Live);
1898        scene.set_grid_scale(GridScale::new(0.5).expect("in range"));
1899        scene.set_target_size(1920, 1080);
1900        assert_eq!(
1901            scene.sample_budget(),
1902            Some(337_500),
1903            "a half-scale 1080p field holds 518 400 texels, 2.25x the reference"
1904        );
1905    }
1906
1907    /// **The built scene draws a trace's anchor count out of an unchanged
1908    /// budget** (ADR-0195), at 1920x1080 under both ceilings.
1909    ///
1910    /// The sibling above pins the budget; this pins what is drawn out of it, and
1911    /// the pair is the statement that only the drawn count moved. Read off the
1912    /// scene for that test's reason — a recomputation here would pass with
1913    /// `configure` never reaching `active_count` at all, which is one of the two
1914    /// call sites that has to pass the anchor.
1915    ///
1916    /// No frame is rendered: both accessors are CPU arithmetic.
1917    #[test]
1918    fn a_trace_preset_draws_its_anchor_count_at_1080p() {
1919        use crate::render::{SampleBudget, TierConfig};
1920
1921        let ctx = match RenderContext::new_headless(64, 64, true) {
1922            Ok(ctx) => ctx,
1923            Err(RenderError::RequestAdapter(_)) => {
1924                eprintln!("skipped: no GPU adapter on this runner (ADR-0016)");
1925                return;
1926            }
1927            Err(e) => panic!("headless context build failed: {e}"),
1928        };
1929
1930        // `density = 0.02` is the shipped trace value — `attractor_lorenzknot`
1931        // and `fragment_sumi`'s layer among them.
1932        const DENSITY: f32 = 0.02;
1933        let rich = TierConfig::RICH;
1934
1935        for (budget, expected) in [
1936            (SampleBudget::Live, rich.attractor_particles_live_ceiling),
1937            (SampleBudget::Offline, 1_350_000),
1938        ] {
1939            let mut scene = create_attractor(&ctx.device, ctx.surface_format(), &rich, budget);
1940            // The preset switch order: `configure` carries the density, and the
1941            // first frame's `set_target_size` carries the target.
1942            scene.configure(&super::lines::GeneratorConfig::Particles {
1943                family: crate::render::scenes::particles::AttractorFamily::Thomas,
1944                density: DENSITY,
1945                morph_to: None,
1946                tuple_path: None,
1947            });
1948            scene.set_target_size(1920, 1080);
1949
1950            assert_eq!(
1951                scene.active_sample_count(),
1952                Some(3_000),
1953                "{budget:?} at 1080p Rich draws the anchor's count for a trace"
1954            );
1955            assert_eq!(
1956                scene.sample_budget(),
1957                Some(expected),
1958                "{budget:?} at 1080p Rich resolves an unchanged budget"
1959            );
1960            // Non-vacuity: the budget is what the old expression took the
1961            // fraction of, and it is nine times (or four times) the count above.
1962            assert_ne!((expected as f32 * DENSITY).round() as u32, 3_000);
1963        }
1964    }
1965
1966    /// Every `SystemKind::ALL` entry builds the scene that kind is supposed to
1967    /// drive, and the roster covers exactly the roster — so transposing two
1968    /// factory arms — which silently points every preset of one system at
1969    /// another's scene — fails here.
1970    ///
1971    /// Needs a GPU adapter to build the scenes, so it skips on runners without
1972    /// one (ADR-0016).
1973    #[test]
1974    fn every_kind_builds_the_scene_it_drives() {
1975        let ctx = match RenderContext::new_headless(64, 64, true) {
1976            Ok(ctx) => ctx,
1977            Err(RenderError::RequestAdapter(_)) => {
1978                eprintln!("skipped: no GPU adapter on this runner (ADR-0016)");
1979                return;
1980            }
1981            Err(e) => panic!("headless context build failed: {e}"),
1982        };
1983
1984        let scenes = create_all(
1985            &ctx.device,
1986            ctx.surface_format(),
1987            &crate::render::TierConfig::FLOOR,
1988            crate::render::SampleBudget::Live,
1989        );
1990
1991        let kinds: Vec<SystemKind> = scenes.iter().map(|(kind, _)| *kind).collect();
1992        assert_eq!(
1993            kinds,
1994            SystemKind::ALL.to_vec(),
1995            "the roster is exactly SystemKind::ALL, in its order"
1996        );
1997
1998        for (kind, scene) in &scenes {
1999            assert_eq!(
2000                scene.name(),
2001                expected_scene_name(*kind),
2002                "system {} must drive its own scene",
2003                kind.as_str()
2004            );
2005        }
2006    }
2007
2008    /// The **freeze veto** a dual-live dissolve rests on (Plan 0023 Phase 4): a
2009    /// pair of systems that would have to render one mutable object twice in a
2010    /// frame must report shared resources, so the governor never upgrades it.
2011    ///
2012    /// GPU-free — this is the mapping, not the rendering. It closes the half the
2013    /// governor's own test has to assume: `dual_live_eligible` is asserted to
2014    /// refuse a shared pair, and here is what makes a pair shared.
2015    #[test]
2016    fn a_pair_that_cannot_render_twice_reports_shared_resources() {
2017        // Same kind is the same `Box<dyn Scene>` — the same-scene case that must
2018        // always freeze, whatever the frame budget says.
2019        for kind in SystemKind::ALL {
2020            assert!(
2021                super::shares_resources(kind, kind),
2022                "{} against itself is one scene object",
2023                kind.as_str()
2024            );
2025        }
2026
2027        // Two *different* line systems are just as unrenderable together: they
2028        // borrow one shared `LineRenderer` (see `create_all`).
2029        let lines = [
2030            SystemKind::ParametricCurve,
2031            SystemKind::LSystem,
2032            SystemKind::StarPattern,
2033            SystemKind::Spectrum,
2034        ];
2035        for a in lines {
2036            for b in lines {
2037                assert!(
2038                    super::shares_resources(a, b),
2039                    "{} and {} share the line renderer",
2040                    a.as_str(),
2041                    b.as_str()
2042                );
2043            }
2044        }
2045
2046        // Everything else holds independent state, so a dissolve between them may
2047        // run both sides live.
2048        let independent = [
2049            SystemKind::FragmentField,
2050            SystemKind::Swarm,
2051            SystemKind::ReactionDiffusion,
2052            SystemKind::Attractor,
2053            SystemKind::Emitter,
2054            SystemKind::ShapeField,
2055            SystemKind::WarpMesh,
2056            SystemKind::ShapeCollage,
2057            SystemKind::AnalyticField,
2058            SystemKind::Cellular,
2059            SystemKind::Plexus,
2060            SystemKind::Waterfall,
2061        ];
2062        for (i, a) in independent.iter().enumerate() {
2063            for b in independent.iter().skip(i + 1).chain(lines.iter()) {
2064                assert!(
2065                    !super::shares_resources(*a, *b),
2066                    "{} and {} hold independent GPU state",
2067                    a.as_str(),
2068                    b.as_str()
2069                );
2070            }
2071        }
2072    }
2073}