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