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