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