Skip to main content

rlx_core/preset/schema/
load.rs

1//! Load: raw TOML in, compiled [`Preset`] out.
2//!
3//! [`Preset::from_toml_str`] is the whole entry point. Everything else here is a
4//! step of it -- the latch table, the per-vertex table, the `[layer]` sub-preset
5//! and the structural `[generator]`/`[curve]`/`[particles]` config -- and each
6//! rejects rather than panicking, so one malformed key never takes down a show.
7
8// A continuation of one module split across several files, so it needs the
9// names `preset/schema/mod.rs` has in scope.
10use super::*;
11use crate::render::scenes::declares;
12
13impl Preset {
14    /// Parse and compile a preset from a TOML source string.
15    pub fn from_toml_str(src: &str) -> Result<Self, PresetError> {
16        let raw: RawPreset = toml::from_str(src).map_err(PresetError::Toml)?;
17        let system = SystemKind::from_name(&raw.system)
18            .ok_or_else(|| PresetError::UnknownSystem(raw.system.clone()))?;
19        let name = raw.name.unwrap_or_else(|| raw.system.clone());
20
21        // The `[latch]` table (ADR-0137), resolved **before** the bindings that
22        // may name a latch — the params below compile against these names, so
23        // there is no other order. A preset declaring no table gets an empty
24        // list and every expression below compiles exactly as it did before
25        // latches existed.
26        let latches = build_latches(&raw.latch)?;
27        let latch_names: Vec<String> = latches.iter().map(|l| l.name.clone()).collect();
28
29        let mut warnings = Vec::new();
30        let mut params = compile_bindings(
31            system,
32            raw.params,
33            &latch_names,
34            Surface::Preset,
35            &mut warnings,
36        )?;
37
38        // The salt the grammar's `hash()`/`noise()` mix in (ADR-0051), read from
39        // the long-reserved `[generator] seed`. Read **before** `build_config`
40        // consumes the table, and read for every system: only the L-system and
41        // the star pattern care about the rest of `[generator]`, but any preset
42        // may declare a seed, so a fragment or swarm preset can carry one table
43        // holding nothing else.
44        //
45        // Entropy is drawn here, once per load, and only for `seed = "random"` —
46        // never per frame, never from a clock inside evaluation (ADR-0051
47        // Alternative B). The pinned twin is what the capture paths read.
48        let (salt, pinned_salt) = match raw.generator.as_ref().and_then(|g| g.seed) {
49            Some(RawSeed::Random) => (entropy_salt(), 0),
50            declared => {
51                let salt = salt_from_seed(declared.map_or(0, RawSeed::numeric));
52                (salt, salt)
53            }
54        };
55
56        // Structural config: validated once here (a bad family/grammar -> load
57        // error, the caller keeps the last good preset), then trusted by the
58        // scene. Built per system so each reads the right table.
59        let config = build_config(
60            system,
61            raw.curve,
62            raw.generator,
63            raw.particles,
64            raw.path,
65            raw.spectrum,
66            raw.mesh,
67            raw.field,
68            raw.cellular,
69            raw.plexus,
70            raw.milk,
71            pinned_salt,
72        )?;
73
74        // The `[feedback]` table (ADR-0048): two closed rosters, validated here so
75        // an unknown warp or blend is a surfaced load error rather than a preset
76        // that quietly renders unwarped. Absent means both defaults.
77        let feedback = raw.feedback.unwrap_or_default().into_config()?;
78
79        // Easing time constants (ADR-0019, ADR-0035): validated non-negative +
80        // finite at the load boundary, then folded into the bindings so the frame
81        // loop reads the constants off the binding instead of hashing its name
82        // into a `BTreeMap` once per binding per frame (Plan 0031 Phase 3). A bad
83        // value is a surfaced load error, never a panic.
84        fold_smoothing(&mut params, &raw.smoothing, Surface::Preset, &mut warnings)?;
85
86        // The `[hold]` table (ADR-0180 rule 2), folded at the same boundary and
87        // for the same reason: the edge is a fact about the preset, resolved
88        // once, and the preset does not change while it renders. After the
89        // easing fold, so a preset carrying a bad entry in each table reports
90        // the smoothing one first, as it did before holds existed.
91        fold_hold(params.iter_mut(), &raw.hold, Surface::Preset, &mut warnings)?;
92
93        // A `thickness` resting inside the stroke floor's dead zone (Plan 0087
94        // Phase 1b, design-backlog 0098). Every value below
95        // `MIN_USEFUL_THICKNESS` clamps to the same half-width, so the whole
96        // range renders identically and re-tuning inside it changes nothing —
97        // which is what makes it expensive: the obvious experiment *disproves
98        // the correct hypothesis*. `fragment_vitrail` shipped at 0.016, two
99        // orders below the 1.5-3.2 every other line preset uses, and its Maurer
100        // rose read as scattered dots for its whole shipped life while the
101        // content lane swept chord count and sample count first.
102        //
103        // A warning rather than an error, in ADR-0020's shape and on its
104        // surface: the value is in range and the preset is otherwise good.
105        // Only a binding that *rests* at such a value is reported — see
106        // `Expr::as_const`.
107        if declares(system.param_specs(), "thickness") {
108            for binding in &params {
109                if binding.name != "thickness" {
110                    continue;
111                }
112                let Some(value) = binding.expr.as_const() else {
113                    continue;
114                };
115                if value < crate::render::scenes::lines::MIN_USEFUL_THICKNESS {
116                    warnings.push(PresetWarning::about(
117                        "thickness",
118                        format!(
119                            "parameter 'thickness' rests at {value}, inside the stroke floor's \
120                             dead zone: every value below {:.3} renders the identical hairline \
121                             (about 0.27 px at 1080p), so tuning within that range changes \
122                             nothing. Line presets ship between 1.5 and 3.2",
123                            crate::render::scenes::lines::MIN_USEFUL_THICKNESS
124                        ),
125                    ));
126                }
127            }
128        }
129
130        // The scaled-copy coordinate was asked for on a figure it has no single
131        // value on (Plan 0098 Phase 4 for the `ring`, ADR-0111's one open
132        // behavioural choice; ADR-0179 for the authored contour).
133        //
134        // **One condition, tested on the figure that will be drawn.** `r /
135        // r_boundary` needs a figure every ray from its centre leaves exactly
136        // once. A `ring` is the single arm of the roster that is not — its
137        // centre is in the hole — and an authored contour replaces the roster
138        // arm entirely, so there the question is about the contour's own
139        // geometry rather than about a name. No name betrays it: a figure with
140        // fins, a crescent, or a silhouette whose sinuses put one lobe across
141        // the ray into another all fail, and on every one of them the shader
142        // takes the outermost crossing and the figure collapses to a dot inside
143        // a few huge rays.
144        //
145        // Announcing it is the whole point. On the `ring` the three defensible
146        // answers were rendered before one was chosen, and the outer-edge
147        // definition came out BYTE-IDENTICAL to a `disc`: the coordinate
148        // collapses to `length(p)` and the hole stops existing. A preset would
149        // name one roster entry and be shown another. The silent fallback
150        // renders exactly what this does and only differs in whether anyone is
151        // told.
152        //
153        // A warning rather than an error, in ADR-0020's shape and on the
154        // `thickness` dead-zone surface above: both values are legal, the
155        // preset is otherwise good, and only a binding that *rests* on the
156        // combination can be seen from here.
157        if declares(system.param_specs(), "coord_mode") {
158            let resting = |name: &str| -> Option<f32> {
159                params
160                    .iter()
161                    .find(|b| b.name == name)
162                    .and_then(|b| b.expr.as_const())
163            };
164            let mode = resting("coord_mode");
165            // The ceiling is `shape_field`'s own roster, not a literal `1.0`: a
166            // third coordinate would leave a hardcoded bound quietly testing the
167            // wrong thing, and this quantizes the way the scene does.
168            let max_mode = (crate::render::scenes::shape_field::COORD_MODES.len() - 1) as f32;
169            // The contour, when the preset authored one — and BOTH endpoints of
170            // a morph pair, because both are drawn. The scene decides the
171            // fallback on exactly this, so the warning and the picture cannot
172            // disagree.
173            let contour = match &config {
174                Some(GeneratorConfig::Path { shape, morph_to }) => shape.as_ref().map(|from| {
175                    from.star_shaped()
176                        && morph_to
177                            .as_ref()
178                            .is_none_or(crate::preset::path::PathShape::star_shaped)
179                }),
180                _ => None,
181            };
182            let figure = match contour {
183                Some(false) => Some(
184                    "an authored contour that is not star-shaped about its centre: a ray from \
185                     there crosses the outline more than once",
186                ),
187                Some(true) => None,
188                None => resting("shape")
189                    .map(crate::render::scenes::marks::mark_shape)
190                    .is_some_and(crate::render::scenes::marks::shape_touches_ring)
191                    .then_some(
192                        "a `ring`: an annulus's centre lies in its hole, so a ray from there \
193                         crosses the outline twice",
194                    ),
195            };
196            if let Some(figure) = figure
197                && mode.is_some_and(|m| m.is_finite() && m.clamp(0.0, max_mode).round() >= 1.0)
198            {
199                warnings.push(PresetWarning::about(
200                    "coord_mode",
201                    format!(
202                        "parameter 'coord_mode' is ignored on {figure} and the scaled-copy \
203                         coordinate has no single value there. The figure is drawn with the \
204                         distance instead — a band of constant distance rather than a scaled \
205                         copy of the outline"
206                    ),
207                ));
208            }
209        }
210
211        // A `shape_field` `color_span` narrow enough to starve the gradient
212        // (design-backlog 0099). The scene hands the palette a FIGURE
213        // coordinate whose `0..1` is the interior, so `color_span` is literally
214        // the share of the 256-texel LUT the figure is drawn through: at 0.037
215        // that is nine texels for the whole of it, linear-filtered across
216        // however much of the frame the figure covers, which is an upscaled
217        // gradient and reads as one.
218        //
219        // The reason this is worth a warning and not a note in a document is
220        // that **the symptom names the wrong subsystem**: a soft, crawling
221        // figure reads as a bad silhouette or bad shading, and the value is in
222        // range and the preset is otherwise good. It cost one user look verdict
223        // two misattributions.
224        //
225        // A warning rather than an error, on ADR-0020's surface, and only for a
226        // binding that *rests* — a `color_span` sweeping through the range is a
227        // different claim, and `Expr::as_const` is what separates them.
228        if system == SystemKind::ShapeField {
229            let resting = |name: &str| -> Option<f32> {
230                params
231                    .iter()
232                    .find(|b| b.name == name)
233                    .and_then(|b| b.expr.as_const())
234            };
235            // `palette_steps` snaps the coordinate to a band centre before the
236            // LUT read, so every pixel samples one exact texel and nothing is
237            // interpolated — the trap does not exist there. A band count that
238            // is bound rather than resting is the author working in bands too,
239            // so only a count resting BELOW the quantizer's own activation
240            // threshold leaves this live.
241            let banded = match params.iter().find(|b| b.name == "palette_steps") {
242                Some(binding) => binding.expr.as_const().is_none_or(|steps| {
243                    crate::render::palette::band_steps(steps)
244                        > crate::render::palette::MIN_ACTIVE_STEPS
245                }),
246                None => false,
247            };
248            if let Some(span) = resting("color_span")
249                && !banded
250            {
251                let texels = crate::render::scenes::shape_field::interior_texels(span);
252                if span.is_finite()
253                    && texels < crate::render::scenes::shape_field::MIN_INTERIOR_TEXELS
254                {
255                    warnings.push(PresetWarning::about(
256                        "color_span",
257                        format!(
258                            "parameter 'color_span' rests at {span}, which draws the figure's \
259                             whole interior through about {texels:.0} of the palette's {} \
260                             texels. Below roughly {:.0} the LUT's linear filtering is \
261                             interpolating more than it is reading and the figure comes back \
262                             looking upscaled rather than shaded — the estimate is exact in the \
263                             coordinate and approximate on screen, since how much of the frame \
264                             the figure covers depends on its shape and framing. Bind or set \
265                             `palette_steps` to remove the interpolation entirely",
266                            crate::render::palette::LUT_SIZE,
267                            crate::render::scenes::shape_field::MIN_INTERIOR_TEXELS,
268                        ),
269                    ));
270                }
271            }
272        }
273
274        // Palette selection (ADR-0021): validated at this boundary into a
275        // baked-ready `PaletteConfig`; a bad name/stop list is a surfaced load
276        // error, never a panic. `None` -> the default `spectrum`. `[palette_b]`
277        // (the crossfade target) validates the same way.
278        let palette = raw.palette.map(RawPalette::into_config).transpose()?;
279        let palette_b = raw.palette_b.map(RawPalette::into_config).transpose()?;
280
281        // Saturation exemptions (ADR-0062). A name this preset does not bind is
282        // a warning for the same reason a `[smoothing]` entry naming one is: it
283        // silences nothing, so a typo here would leave the author believing a
284        // gate was exempted while the gate goes on failing on the real name.
285        let mut occupancy_exempt = raw.occupancy.unwrap_or_default().exempt;
286        occupancy_exempt.sort();
287        occupancy_exempt.dedup();
288        for name in &occupancy_exempt {
289            if !params.iter().any(|b| &b.name == name) {
290                // Unanchored: the name is one this preset does not bind, so there
291                // is no binding line to point at.
292                warnings.push(PresetWarning::unanchored(format!(
293                    "[occupancy] exempt entry '{name}' is inert: this preset binds no such \
294                     parameter"
295                )));
296            }
297        }
298
299        // The `[per_vertex]` table (Plan 0100 Phase 1): the warp mesh's
300        // per-vertex program, compiled like any binding and never eased.
301        let per_vertex = build_per_vertex(
302            system,
303            raw.per_vertex,
304            &raw.smoothing,
305            &raw.hold,
306            &latch_names,
307            Surface::Preset,
308            &mut warnings,
309        )?;
310        // A `[params]` binding reaching for a vertex variable reads a flat zero
311        // there — the names are crate-wide because expression slots are
312        // positional, and only a `[per_vertex]` table ever binds them.
313        warn_vertex_use(&params, Surface::Preset, &mut warnings);
314
315        // A latch nothing reads is inert, and inert-and-silent is what this file
316        // exists to prevent — the same warning an `[occupancy] exempt` entry
317        // naming an unbound param gets, for the same reason: the author believes
318        // an event is wired up while nothing consumes it. Every surface that can
319        // name a latch is searched, including the layer's, which is why this sits
320        // after the layer is built.
321        let layer = raw
322            .layer
323            .map(|l| build_layer(l, &latch_names, &mut warnings))
324            .transpose()?;
325        for (slot, latch) in latches.iter().enumerate() {
326            let mut read = params
327                .iter()
328                .chain(&per_vertex)
329                .any(|b| b.expr.uses_latch(slot));
330            if let Some(l) = layer.as_ref() {
331                read = read
332                    || l.params
333                        .iter()
334                        .chain(&l.per_vertex)
335                        .chain(l.mix.as_ref())
336                        .any(|b| b.expr.uses_latch(slot));
337            }
338            if !read {
339                warnings.push(PresetWarning::unanchored(format!(
340                    "[latch] '{}' is inert: no binding in this preset names it",
341                    latch.name
342                )));
343            }
344        }
345
346        Ok(Preset {
347            name,
348            system,
349            // The text this was compiled from does not say where it came from;
350            // `load_dir` fills this in for the presets it read off disk.
351            source: None,
352            params,
353            per_vertex,
354            latches,
355            config,
356            feedback,
357            palette,
358            palette_b,
359            salt,
360            pinned_salt,
361            occupancy_exempt,
362            layer,
363            warnings,
364            representative: raw.representative,
365        })
366    }
367}
368
369/// Compile and validate the `[latch]` table (ADR-0137).
370///
371/// Slot order is the `BTreeMap`'s, so it is the entries' **name order** and not
372/// their order in the file: a preset's latch-to-slot mapping is then a function
373/// of the set of names alone, and re-ordering the table in the TOML cannot move
374/// a latch onto a different slot.
375///
376/// Everything here is a load-time check, in the shape the rest of this file
377/// uses: a bad expression is a [`PresetError::Expr`] naming which key of which
378/// latch, and every other failure is a [`PresetError::Config`]. The two
379/// expressions compile with **no** latch names in scope — see [`Latch`] for why
380/// that is the design rather than an omission.
381pub(super) fn build_latches(raw: &BTreeMap<String, RawLatch>) -> Result<Vec<Latch>, PresetError> {
382    if raw.len() > expr::LATCH_CAP {
383        return Err(PresetError::Config(format!(
384            "[latch] declares {} entries; a preset may hold at most {} (ADR-0137: the \
385             reserved variable block is a fixed size, so this is a wall rather than a \
386             slower path)",
387            raw.len(),
388            expr::LATCH_CAP,
389        )));
390    }
391    let mut out = Vec::with_capacity(raw.len());
392    for (name, entry) in raw {
393        if !expr::is_identifier(name) {
394            return Err(PresetError::Config(format!(
395                "[latch] name '{name}' is not an identifier: a latch is referenced from \
396                 an expression, so its name must start with a letter or underscore and \
397                 hold only letters, digits and underscores"
398            )));
399        }
400        if expr::is_reserved_ident(name) {
401            return Err(PresetError::Config(format!(
402                "[latch] name '{name}' is already a variable, constant or function in the \
403                 expression grammar; a binding naming it would read that instead of the \
404                 latch"
405            )));
406        }
407        check_hold(name, entry.hold)?;
408        let compile = |key: &str, source: &str| {
409            expr::compile(source).map_err(|err| PresetError::Expr {
410                param: format!("[latch] {name}.{key}"),
411                err,
412            })
413        };
414        out.push(Latch {
415            name: name.clone(),
416            arm: compile("arm", &entry.arm)?,
417            fire: compile("fire", &entry.fire)?,
418            hold: entry.hold,
419        });
420    }
421    Ok(out)
422}
423
424/// Validate one `[latch] hold`, in `check_tau`'s shape and at the same boundary:
425/// a non-negative, finite number of seconds, checked once here and trusted by
426/// the render layer's countdown.
427pub(super) fn check_hold(name: &str, seconds: f32) -> Result<(), PresetError> {
428    if seconds.is_finite() && seconds >= 0.0 {
429        return Ok(());
430    }
431    Err(PresetError::Config(format!(
432        "[latch] '{name}' hold must be a non-negative number of seconds, got {seconds}"
433    )))
434}
435
436/// Compile a `[per_vertex]` table into bindings (Plan 0100 Phase 1).
437///
438/// `surface` prefixes the error and warning text and names the tables they
439/// cite, and `smoothing` / `hold` are that surface's own easing and hold
440/// tables — consulted only to reject or warn about an entry naming a
441/// per-vertex binding, which is never eased and never held.
442///
443/// Unknown names warn and keep the binding, exactly like `[params]` (ADR-0020):
444/// one typo must not discard an otherwise-good mesh program. A binding here for
445/// a system that has no per-vertex surface warns too — the table is inert there,
446/// and silently inert is the thing this file exists to prevent.
447pub(super) fn build_per_vertex(
448    system: SystemKind,
449    raw: BTreeMap<String, String>,
450    smoothing: &BTreeMap<String, RawSmoothing>,
451    hold: &BTreeMap<String, RawHold>,
452    latch_names: &[String],
453    surface: Surface,
454    warnings: &mut Vec<PresetWarning>,
455) -> Result<Vec<Binding>, PresetError> {
456    let label = surface.prefix();
457    if raw.is_empty() {
458        return Ok(Vec::new());
459    }
460    if system != SystemKind::WarpMesh {
461        warnings.push(PresetWarning::unanchored(format!(
462            "{label}[per_vertex] is inert for system '{}': only `warp_mesh` evaluates a \
463             per-vertex program (bindings kept, but nothing reads them)",
464            system.as_str()
465        )));
466    }
467    let mut out = Vec::with_capacity(raw.len());
468    for (param, source) in raw {
469        // The label an expression error on this binding carries, and so the one
470        // a warning about it carries too.
471        let labelled = format!("{label}[per_vertex] {param}");
472        let expr =
473            expr::compile_with_latches(&source, latch_names).map_err(|err| PresetError::Expr {
474                param: labelled.clone(),
475                err,
476            })?;
477        if !declares(
478            crate::render::scenes::warp_mesh::PER_VERTEX_PARAMS,
479            param.as_str(),
480        ) {
481            warnings.push(PresetWarning::about(
482                labelled.clone(),
483                format!(
484                    "unknown {label}[per_vertex] parameter '{param}' (expected one of: {}) \
485                     (binding kept, but nothing reads it)",
486                    crate::render::scenes::spec_names(
487                        crate::render::scenes::warp_mesh::PER_VERTEX_PARAMS
488                    )
489                    .join(", ")
490                ),
491            ));
492        }
493        if smoothing.contains_key(&param) {
494            warnings.push(PresetWarning::about(
495                labelled,
496                format!(
497                    "{label}[smoothing] entry '{param}' is ignored: it names a [per_vertex] \
498                     binding, which is evaluated once per mesh vertex and has no single \
499                     value to ease"
500                ),
501            ));
502        }
503        // A load error where the smoothing entry above is a warning: an easing
504        // constant has a degraded form to fall back to and a hold has none.
505        // See `fold_hold`.
506        if hold.contains_key(&param) {
507            return Err(PresetError::Config(format!(
508                "{} entry '{param}' names a {} binding, which is evaluated once per mesh \
509                 vertex and has no single value to hold",
510                surface.table("hold"),
511                surface.table("per_vertex"),
512            )));
513        }
514        out.push(Binding {
515            // Never quantized either: a per-vertex name is drawn from the warp
516            // mesh's own roster, every entry of which is continuous.
517            kind: ParamKind::Modal,
518            name: param,
519            expr,
520            // Never eased and never held — see `Preset::per_vertex`.
521            tau: Easing::INSTANT,
522            hold: None,
523        });
524    }
525    Ok(out)
526}
527
528/// Validate a `[layer]` table (ADR-0090 / Plan 0076). Structural keys —
529/// `system`, `join`, `blend` — follow `[curve] family`'s rule: an unknown value
530/// rejects the preset, because it selects a code path and a silent default
531/// would render a look the author never asked for. Unknown *param* names warn
532/// and keep the binding, exactly like the top level (ADR-0020).
533pub(super) fn build_layer(
534    raw: RawLayer,
535    latch_names: &[String],
536    warnings: &mut Vec<PresetWarning>,
537) -> Result<Layer, PresetError> {
538    let system = SystemKind::from_name(&raw.system)
539        .ok_or_else(|| PresetError::UnknownSystem(raw.system.clone()))?;
540    // Any pair of systems is legal — including the same system twice, and two
541    // line-family systems. The layer's scene is constructed **for the preset**
542    // (`scenes::create_layer_scene`, ADR-0090 point 4 / Plan 0076 Phase 2), so
543    // it shares no GPU state with the roster's instance of the same kind.
544
545    let join = match raw.join.as_deref() {
546        None => LayerJoin::default(),
547        Some(name) => LayerJoin::from_name(name).ok_or_else(|| {
548            PresetError::Config(format!(
549                "unknown [layer] join '{name}' (expected one of: under, over)"
550            ))
551        })?,
552    };
553    let blend = match raw.blend.as_deref() {
554        None => LayerBlend::default(),
555        Some(name) => LayerBlend::from_name(name).ok_or_else(|| {
556            PresetError::Config(format!(
557                "unknown [layer] blend '{name}' (expected one of: {})",
558                LayerBlend::ALL
559                    .iter()
560                    .map(|b| b.as_str())
561                    .collect::<Vec<_>>()
562                    .join(", ")
563            ))
564        })?,
565    };
566    if raw.blend.is_some() && join == LayerJoin::Under {
567        warnings.push(PresetWarning::unanchored(format!(
568            "[layer] blend = '{}' is ignored on an under join: the layer shares the \
569             main scene's composite, so there is no junction for a blend mode to \
570             apply at (blend belongs to join = \"over\")",
571            blend.as_str()
572        )));
573    }
574
575    // The layer's bindings, name-sorted off the BTreeMap like the preset's own.
576    let mut params = compile_bindings(system, raw.params, latch_names, Surface::Layer, warnings)?;
577
578    // `[layer.smoothing]`: the same vocabulary, validation and fold as the top
579    // level (ADR-0019 / ADR-0035), against the layer's own bindings.
580    fold_smoothing(&mut params, &raw.smoothing, Surface::Layer, warnings)?;
581
582    // The bindable mix (ADR-0090): compiled like any binding, eased through
583    // `[layer.smoothing] mix`. Parsed now; the `over` blend consumes it in
584    // Plan 0076 Phase 3.
585    let mut mix = raw
586        .mix
587        .as_deref()
588        .map(|source| {
589            let expr = expr::compile_with_latches(source, latch_names).map_err(|err| {
590                PresetError::Expr {
591                    param: "[layer] mix".into(),
592                    err,
593                }
594            })?;
595            Ok(Binding {
596                name: "mix".into(),
597                expr,
598                tau: raw
599                    .smoothing
600                    .get("mix")
601                    .map_or(Easing::INSTANT, |entry| entry.to_easing()),
602                // Folded below with the layer's own params, which is what lets
603                // `[layer.hold] mix` reach the one layer binding that lives
604                // outside `params`.
605                hold: None,
606                // The junction amount is a fraction, and no scene roster
607                // declares it — it is the composite's, not a scene's.
608                kind: ParamKind::Modal,
609            })
610        })
611        .transpose()?;
612
613    // `[layer.hold]`: the same vocabulary and validation as the top level
614    // (ADR-0180 rule 2), against the layer's own bindings — which are indexed
615    // within the layer, so a layer's hold state cannot collide with the main
616    // preset's. `mix` rides the same walk so an entry naming it is folded
617    // rather than reported inert.
618    fold_hold(
619        params.iter_mut().chain(mix.as_mut()),
620        &raw.hold,
621        Surface::Layer,
622        warnings,
623    )?;
624
625    // `[layer.per_vertex]` — the same surface as the top level's, against the
626    // layer's own system and its own smoothing table.
627    let per_vertex = build_per_vertex(
628        system,
629        raw.per_vertex,
630        &raw.smoothing,
631        &raw.hold,
632        latch_names,
633        Surface::Layer,
634        warnings,
635    )?;
636    warn_vertex_use(&params, Surface::Layer, warnings);
637
638    // The layer's structural config, by the same per-system rules as the top
639    // level (ADR-0007) — a layer L-system still requires its `[layer.generator]`
640    // table, a layer attractor still defaults to De Jong.
641    let config = build_config(
642        system,
643        raw.curve,
644        raw.generator,
645        raw.particles,
646        raw.path,
647        raw.spectrum,
648        raw.mesh,
649        raw.field,
650        raw.cellular,
651        raw.plexus,
652        // A `[layer]` carries no `[milk]` table: a converted preset is a whole
653        // preset, and layering one under another is a composition nothing in the
654        // corpus asks for. A layer warp mesh drives its mesh from `[layer.params]`
655        // and `[layer.per_vertex]` like any hand-authored one.
656        None,
657        0,
658    )?;
659
660    Ok(Layer {
661        system,
662        join,
663        blend,
664        mix,
665        params,
666        per_vertex,
667        config,
668    })
669}
670
671/// Fold a declared 64-bit `[generator] seed` into the 32-bit salt the grammar's
672/// `hash()`/`noise()` mix in (ADR-0051).
673///
674/// XOR-folded rather than truncated, so two seeds differing only in their high
675/// half still salt differently — `seed` is a `u64` in the schema and always has
676/// been, and silently ignoring half of what an author typed is the kind of
677/// surprise this file exists to prevent.
678pub(super) fn salt_from_seed(seed: u64) -> u32 {
679    (seed as u32) ^ ((seed >> 32) as u32)
680}
681
682/// A salt drawn from OS entropy — the `seed = "random"` path (ADR-0051). Called
683/// **once per preset load**, in the live app only: no capture path reaches it,
684/// because a capture reads [`Preset::pinned_salt`] instead.
685///
686/// `RandomState` is the standard library's own entropy: its keys are seeded from
687/// the OS once per process and advance on every `new()`, so two loads of the same
688/// preset — in one run or across two — draw different salts. Using it is what
689/// keeps "sometimes crazy" from costing a dependency (`lightweight is a feature`).
690pub(super) fn entropy_salt() -> u32 {
691    use std::collections::hash_map::RandomState;
692    use std::hash::BuildHasher;
693    salt_from_seed(RandomState::new().hash_one(0u64))
694}
695
696/// Assemble the optional structural config for `system` from the raw tables,
697/// validating at this boundary (ADR-0007). Non-line systems have no config.
698#[allow(clippy::too_many_arguments)]
699pub(super) fn build_config(
700    system: SystemKind,
701    curve: Option<RawCurve>,
702    generator: Option<RawGenerator>,
703    particles: Option<RawParticles>,
704    path: Option<RawPath>,
705    spectrum: Option<RawSpectrum>,
706    mesh: Option<RawMesh>,
707    field: Option<RawField>,
708    cellular: Option<RawCellular>,
709    plexus: Option<RawPlexus>,
710    milk: Option<RawMilk>,
711    salt: u32,
712) -> Result<Option<GeneratorConfig>, PresetError> {
713    match system {
714        // A curve preset without a `[curve]` table accepts the family default.
715        SystemKind::ParametricCurve => curve.map(RawCurve::into_config).transpose(),
716        // A generator preset must declare its `[generator]` table.
717        SystemKind::LSystem => {
718            let g = generator.ok_or_else(|| {
719                PresetError::Config("lsystem requires a [generator] table".into())
720            })?;
721            Ok(Some(g.into_lsystem()?))
722        }
723        SystemKind::StarPattern => {
724            let g = generator.ok_or_else(|| {
725                PresetError::Config("star_pattern requires a [generator] table".into())
726            })?;
727            Ok(Some(g.into_star()?))
728        }
729        // The attractor scene selects its map via an optional `[particles]` table;
730        // absent, it defaults to De Jong. Config is always `Some` so `configure`
731        // runs on every preset switch (resetting the family — never stale).
732        SystemKind::Attractor => {
733            let (family, density, morph_to, tuple_path) = match particles {
734                Some(p) => {
735                    let family = AttractorFamily::from_name(&p.family).ok_or_else(|| {
736                        PresetError::Config(format!("unknown attractor family '{}'", p.family))
737                    })?;
738                    (
739                        family,
740                        p.density()?,
741                        p.morph_to(family)?,
742                        p.tuple_path(family)?,
743                    )
744                }
745                None => (AttractorFamily::DeJong, 1.0, None, None),
746            };
747            Ok(Some(GeneratorConfig::Particles {
748                family,
749                density,
750                morph_to,
751                tuple_path,
752            }))
753        }
754        // The spectrum readout selects its element count, layout and per-element
755        // easing through an optional `[spectrum]` table; absent, it takes the
756        // defaults. Config is always `Some` so `configure` runs on every preset
757        // switch (resizing the element buffer — never stale).
758        SystemKind::Spectrum => Ok(Some(match spectrum {
759            Some(s) => s.into_config()?,
760            None => RawSpectrum::default().into_config()?,
761        })),
762        // The warp mesh's grid is structural for `[curve] family`'s reason: the
763        // vertex and index buffers are built from it, and an eased grid would
764        // rebuild them mid-frame. Config is always `Some` so `configure` runs on
765        // every preset switch (resizing the mesh — never stale).
766        SystemKind::WarpMesh => {
767            let bundle = milk.map(|raw| raw.into_bundle()).transpose()?;
768            Ok(Some(
769                mesh.unwrap_or_default()
770                    .into_config(bundle.map(Box::new), salt)?,
771            ))
772        }
773        // The shape field takes an OPTIONAL `[path]` table (ADR-0107): the
774        // roster is still a closed list selected by the numeric `shape` param
775        // (ADR-0084/ADR-0105), and a preset naming no path draws it exactly as
776        // it did before — the table is an alternative source of a silhouette,
777        // not a replacement for the roster.
778        //
779        // Config is always `Some` so `configure` runs on every preset switch
780        // (clearing the contour — never stale), which is why the absent table is
781        // a `None` INSIDE the variant rather than an absent config.
782        SystemKind::ShapeField => {
783            let parsed = path.map(RawPath::into_parsed).transpose()?;
784            Ok(Some(GeneratorConfig::Path {
785                shape: parsed.as_ref().map(|p| p.shape.clone()),
786                morph_to: parsed.and_then(|p| p.morph_to),
787            }))
788        }
789        // The analytic field's family is structural for `[curve] family`'s
790        // reason — it selects a code path — and an absent table is the default
791        // family. Config is always `Some` so `configure` runs on every preset
792        // switch (resetting the family — never stale).
793        SystemKind::AnalyticField => Ok(Some(GeneratorConfig::Field(match field {
794            Some(f) => f.into_config()?,
795            None => FieldConfig::default(),
796        }))),
797        // The automaton's family, grid and edge rule are structural: the family
798        // selects a rule, and the grid sizes the state textures, which an eased
799        // value would rebuild mid-frame. Config is always `Some` so `configure`
800        // runs on every preset switch and reseeds the field — never stale. The
801        // salt is the pinned one, as the warp mesh's is: the automaton is a pure
802        // function of the preset in the live app as well as in a capture.
803        SystemKind::Cellular => Ok(Some(GeneratorConfig::Cellular(
804            cellular.unwrap_or_default().into_config(salt)?,
805        ))),
806        // The layout, point count and seed are structural: the count sizes the
807        // point set and the seed places it, and an eased value would re-seed it
808        // mid-frame. Config is always `Some` so `configure` runs on every preset
809        // switch and re-seeds the points — never stale. An absent `[plexus]
810        // seed` takes the pinned salt, so a network is a pure function of the
811        // preset in the live app as well as in a capture.
812        SystemKind::Plexus => Ok(Some(GeneratorConfig::Plexus(
813            plexus.unwrap_or_default().into_config(salt)?,
814        ))),
815        // Reaction-diffusion drives its regime through named params (feed/kill/
816        // flow), not a declarative structural table. `shape_collage`'s structure
817        // is an authored element list compiled into the scene, and its seeded
818        // layout grammar is selected by named params too, so that arm is
819        // expected to stay where it is.
820        SystemKind::FragmentField
821        | SystemKind::Swarm
822        | SystemKind::ReactionDiffusion
823        | SystemKind::Emitter
824        | SystemKind::ShapeCollage => Ok(None),
825    }
826}
827
828/// Which surface a binding table belongs to: the preset itself, or its
829/// `[layer]`.
830///
831/// The two compile bindings, validate `[smoothing]` and warn about a stray
832/// vertex variable by identical rules, and differ in exactly three ways -- how a
833/// message names the parameter, which TOML table it names, and whether a
834/// compositing parameter counts as known there. One value carries all three
835/// rather than three flags at every call.
836#[derive(Clone, Copy, PartialEq, Eq)]
837pub(super) enum Surface {
838    Preset,
839    Layer,
840}
841
842impl Surface {
843    /// What every message on this surface prefixes a parameter name with.
844    fn prefix(self) -> &'static str {
845        match self {
846            Surface::Preset => "",
847            Surface::Layer => "[layer] ",
848        }
849    }
850
851    /// `table("smoothing")` is `[smoothing]` at the top level and
852    /// `[layer.smoothing]` inside a layer.
853    fn table(self, name: &str) -> String {
854        match self {
855            Surface::Preset => format!("[{name}]"),
856            Surface::Layer => format!("[layer.{name}]"),
857        }
858    }
859}
860
861/// Compile a `[params]` table into bindings, warning about every name the
862/// surface does not consume.
863///
864/// A name the system does not consume is a warning, not an error: one typo must
865/// not discard the rest of an otherwise-good preset (ADR-0020 / NFR 10). The
866/// binding is kept -- an unconsumed param is harmless at apply time, and
867/// dropping it would turn a surfaced warning back into a silent loss.
868///
869/// `tau` is left [`Easing::INSTANT`] here and filled by [`fold_smoothing`] once
870/// the `[smoothing]` table has been validated, which is what preserves which
871/// error a preset with several problems reports first.
872pub(super) fn compile_bindings(
873    system: SystemKind,
874    params: BTreeMap<String, String>,
875    latch_names: &[String],
876    surface: Surface,
877    warnings: &mut Vec<PresetWarning>,
878) -> Result<Vec<Binding>, PresetError> {
879    let prefix = surface.prefix();
880    // The raw params come from a BTreeMap, so bindings land name-sorted:
881    // evaluation is order-independent, but determinism is cheap to keep.
882    let mut out = Vec::with_capacity(params.len());
883    for (param, source) in params {
884        let labelled = format!("{prefix}{param}");
885        let expr =
886            expr::compile_with_latches(&source, latch_names).map_err(|err| PresetError::Expr {
887                param: labelled.clone(),
888                err,
889            })?;
890        let known = match surface {
891            Surface::Preset => is_known_param(system, &param),
892            // A layer binds its own scene's params only, never the compositing
893            // stages -- so a global here is a *different* mistake from a typo
894            // and says so.
895            Surface::Layer => declares(system.param_specs(), param.as_str()),
896        };
897        if !known {
898            if surface == Surface::Layer
899                && GLOBAL_PARAMS
900                    .iter()
901                    .any(|stage| declares(stage, param.as_str()))
902            {
903                warnings.push(PresetWarning::about(
904                    labelled,
905                    format!(
906                        "[layer] parameter '{param}' is a compositing parameter; a layer \
907                         binds only its own scene's params — bind it at the top level, \
908                         where it drives the whole preset (binding kept, but nothing \
909                         reads it here)"
910                    ),
911                ));
912            } else {
913                warnings.push(PresetWarning::about(
914                    labelled,
915                    format!(
916                        "unknown {prefix}parameter '{param}' for system '{}' (binding kept, but nothing reads it)",
917                        system.as_str()
918                    ),
919                ));
920            }
921        }
922        out.push(Binding {
923            // Read off the engine's declaration once, here, so nothing per
924            // frame searches a roster by name (ADR-0180 rule 2). At the layer
925            // surface the search still walks the global stages and finds
926            // nothing there, because `known` above already rejected them.
927            kind: kind_of_param(system, &param),
928            name: param,
929            expr,
930            tau: Easing::INSTANT,
931            hold: None,
932        });
933    }
934    Ok(out)
935}
936
937/// Validate the `[smoothing]` table, then fold it into the bindings' `tau`.
938///
939/// Validation runs over the whole table first so a preset with two bad entries
940/// reports the same one it always did. An entry naming a per-element binding is
941/// inert -- a series has no single value to ease -- and says so rather than
942/// silently doing nothing.
943pub(super) fn fold_smoothing(
944    params: &mut [Binding],
945    smoothing: &BTreeMap<String, RawSmoothing>,
946    surface: Surface,
947    warnings: &mut Vec<PresetWarning>,
948) -> Result<(), PresetError> {
949    let prefix = surface.prefix();
950    for (param, entry) in smoothing {
951        let named = format!("{prefix}{param}");
952        match *entry {
953            RawSmoothing::Symmetric(seconds) => check_tau(&named, None, seconds)?,
954            RawSmoothing::Asymmetric { attack, release } => {
955                check_tau(&named, Some("attack"), attack)?;
956                check_tau(&named, Some("release"), release)?;
957            }
958        }
959    }
960    for binding in params {
961        binding.tau = smoothing
962            .get(&binding.name)
963            .map_or(Easing::INSTANT, |entry| entry.to_easing());
964        if binding.expr.uses_index() && smoothing.contains_key(&binding.name) {
965            warnings.push(PresetWarning::about(
966                format!("{prefix}{}", binding.name),
967                format!(
968                    "{} entry '{}' is ignored: the binding names `index`, so it is \
969                     evaluated per element and cannot be eased as one value \
970                     (use {} smoothing for the element levels)",
971                    surface.table("smoothing"),
972                    binding.name,
973                    surface.table("spectrum"),
974                ),
975            ));
976            binding.tau = Easing::INSTANT;
977        }
978    }
979    Ok(())
980}
981
982/// Validate the `[hold]` table, then fold it into the bindings' `hold`
983/// (ADR-0180 rule 2).
984///
985/// [`fold_smoothing`]'s shape and its boundary, with one deliberate difference:
986/// a `[hold]` entry naming a **per-element** binding is a load **error**, not a
987/// warning. `[smoothing]` can degrade to instant and still render the preset
988/// the author wrote; a hold cannot degrade to anything -- the binding it names
989/// keeps re-picking its figure every frame, which is the exact defect the table
990/// exists to remove, and a warning would leave that looking like the engine
991/// ignoring a table it accepted. A per-vertex binding is rejected by the same
992/// rule, from `build_per_vertex`, where the per-vertex table is in scope.
993///
994/// An entry naming a parameter this surface does not bind is a **warning**, in
995/// `[occupancy] exempt`'s shape and for its reason: it silences nothing and
996/// holds nothing, and a typo must not discard the rest of a good preset.
997/// `bindings` is every binding this table may reach: a surface's `[params]`,
998/// plus the layer's bindable `mix`, which is a binding living outside `params`
999/// and is eased through `[layer.smoothing] mix` by the same reasoning.
1000pub(super) fn fold_hold<'b>(
1001    bindings: impl Iterator<Item = &'b mut Binding>,
1002    hold: &BTreeMap<String, RawHold>,
1003    surface: Surface,
1004    warnings: &mut Vec<PresetWarning>,
1005) -> Result<(), PresetError> {
1006    if hold.is_empty() {
1007        return Ok(());
1008    }
1009    let prefix = surface.prefix();
1010    // Validated over the whole table first, so a preset with two bad entries
1011    // reports the name-ordered first of them whatever its bindings look like.
1012    let mut edges: BTreeMap<&str, HoldEdge> = BTreeMap::new();
1013    for (param, entry) in hold {
1014        edges.insert(param, entry.to_edge(&format!("{prefix}{param}"))?);
1015    }
1016    let mut folded: Vec<&str> = Vec::new();
1017    for binding in bindings {
1018        let Some((&param, &edge)) = edges.get_key_value(binding.name.as_str()) else {
1019            continue;
1020        };
1021        if binding.expr.uses_index() {
1022            return Err(PresetError::Config(format!(
1023                "{} entry '{param}' names a binding that reads `index`, so it is evaluated \
1024                 once per element and has no single value to hold",
1025                surface.table("hold"),
1026            )));
1027        }
1028        binding.hold = Some(edge);
1029        folded.push(param);
1030    }
1031    for param in edges.keys() {
1032        if !folded.contains(param) {
1033            // Unanchored: the entry names a binding this surface does not have.
1034            warnings.push(PresetWarning::unanchored(format!(
1035                "{} entry '{param}' is inert: this preset binds no such parameter",
1036                surface.table("hold"),
1037            )));
1038        }
1039    }
1040    Ok(())
1041}
1042
1043/// Warn for every binding that reaches for a vertex variable outside a
1044/// `[per_vertex]` table, where it reads a flat zero.
1045///
1046/// The names are crate-wide because expression slots are positional, so nothing
1047/// stops a `[params]` binding from naming one; only a `[per_vertex]` table ever
1048/// binds them.
1049pub(super) fn warn_vertex_use(
1050    params: &[Binding],
1051    surface: Surface,
1052    warnings: &mut Vec<PresetWarning>,
1053) {
1054    let prefix = surface.prefix();
1055    for binding in params {
1056        if binding.expr.uses_vertex() {
1057            warnings.push(PresetWarning::about(
1058                format!("{prefix}{}", binding.name),
1059                format!(
1060                    "{prefix}parameter '{}' names a per-vertex variable (x/y/rad/ang), \
1061                     which reads 0 outside a {} table",
1062                    binding.name,
1063                    surface.table("per_vertex"),
1064                ),
1065            ));
1066        }
1067    }
1068}