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