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