rlx_core/preset/schema/mod.rs
1//! TOML preset schema: which built-in system a preset drives and the
2//! expression bound to each of its named parameters.
3//!
4//! Parsing happens once at load: the raw TOML is deserialized, each parameter
5//! expression is compiled (a malformed one is rejected with a surfaced error),
6//! and the result is an in-memory [`Preset`] whose bindings are ready to
7//! evaluate. A bad preset returns `Err` — it never panics, so the caller can
8//! degrade to the last good preset (ADR-0002 / NFR 10).
9
10use std::collections::BTreeMap;
11use std::fmt;
12use std::path::PathBuf;
13
14use serde::Deserialize;
15
16use super::expr::{self, Expr, ExprError};
17use crate::render::feedback::{Deposit, FeedbackConfig, Warp};
18use crate::render::palette::{NamedPalette, PaletteConfig};
19use crate::render::scenes::ParamKind;
20use crate::render::scenes::analytic_field::{EscapeMap, FieldConfig, FieldFamily, TrapShape};
21use crate::render::scenes::cellular::{CellularConfig, CellularFamily};
22use crate::render::scenes::lines::star::{DEFAULT_RING_SCALE, MAX_RING_COUNT, Motif, RingSpec};
23use crate::render::scenes::lines::{
24 CurveFamily, GeneratorConfig, MAX_LSYSTEM_DEPTH, SpectrumLayout, hankin,
25};
26use crate::render::scenes::particles::AttractorFamily;
27use crate::render::scenes::particles::ifs::IfsFigure;
28use crate::render::scenes::plexus::{PlexusConfig, PlexusLayout};
29
30// The six concerns this file holds apart. `system` is the roster of built-in
31// systems, `easing` the attack/release pair, `hold` the musical edge a binding
32// re-samples on, `raw` the on-disk tables, `load` the TOML-to-`Preset` path,
33// `error` the failure enum. What stays here is the compiled shape a preset
34// becomes.
35mod easing;
36mod error;
37pub mod export;
38mod hold;
39mod load;
40mod raw;
41mod system;
42
43pub use easing::Easing;
44pub use error::PresetError;
45pub use export::{KeyDesc, KeyKind, Roster, TableDesc};
46pub use hold::HoldEdge;
47pub use system::{GLOBAL_PARAMS, SystemKind, is_known_param, kind_of_param};
48
49use raw::*;
50
51/// Where a preset's second scene joins the composite (ADR-0090): before the
52/// post chain, sharing every stage with the main scene, or between the
53/// kaleidoscope and bloom in its own offscreen (Phase 3).
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
55pub enum LayerJoin {
56 /// The layer draws into the same scene target as the main scene, before the
57 /// chain — one substance, shared trails/fold/bloom. The default.
58 #[default]
59 Under,
60 /// The layer renders into its own offscreen and blends into the chain
61 /// between the kaleidoscope and bloom — crisp geometry, shared glow.
62 Over,
63}
64
65impl LayerJoin {
66 /// Both join points, for the load error's "expected one of" listing and for
67 /// the schema export, which renders this rather than restating it.
68 pub const ALL: [LayerJoin; 2] = [LayerJoin::Under, LayerJoin::Over];
69
70 /// Parse the canonical `join = "..."` value, or `None` if unknown.
71 pub fn from_name(name: &str) -> Option<Self> {
72 Some(match name {
73 "under" => LayerJoin::Under,
74 "over" => LayerJoin::Over,
75 _ => return None,
76 })
77 }
78
79 /// The canonical name — [`from_name`](Self::from_name)'s inverse.
80 pub fn as_str(self) -> &'static str {
81 match self {
82 LayerJoin::Under => "under",
83 LayerJoin::Over => "over",
84 }
85 }
86}
87
88/// How an `over` layer blends into the chain (ADR-0090): fixed at load, like
89/// every structural key, and applied in linear light within the layer's
90/// premultiplied-alpha footprint. Parsed now; consumed by the blend pass
91/// (Plan 0076 Phase 3).
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
93pub enum LayerBlend {
94 /// Linear-light addition — the engine's native compositing idiom.
95 Add,
96 /// `1 - (1-a)(1-b)`: bounded brightening. The default — ADR-0090's
97 /// illustrative mode, and the one that cannot blow out.
98 #[default]
99 Screen,
100 /// Darkens where the layer has coverage.
101 Multiply,
102 /// Multiply below mid-grey, screen above.
103 Overlay,
104}
105
106impl LayerBlend {
107 /// Every mode, for the load error's "expected one of" listing.
108 pub const ALL: [LayerBlend; 4] = [
109 LayerBlend::Add,
110 LayerBlend::Screen,
111 LayerBlend::Multiply,
112 LayerBlend::Overlay,
113 ];
114
115 /// Parse the canonical `blend = "..."` value, or `None` if unknown.
116 pub fn from_name(name: &str) -> Option<Self> {
117 Some(match name {
118 "add" => LayerBlend::Add,
119 "screen" => LayerBlend::Screen,
120 "multiply" => LayerBlend::Multiply,
121 "overlay" => LayerBlend::Overlay,
122 _ => return None,
123 })
124 }
125
126 /// The canonical name — [`from_name`](Self::from_name)'s inverse.
127 pub fn as_str(self) -> &'static str {
128 match self {
129 LayerBlend::Add => "add",
130 LayerBlend::Screen => "screen",
131 LayerBlend::Multiply => "multiply",
132 LayerBlend::Overlay => "overlay",
133 }
134 }
135}
136
137/// The optional second scene layer (ADR-0090 / Plan 0076): a full authoring
138/// surface — its own system, params, bindings, `[layer.smoothing]` and
139/// structural tables — joined to the composite at [`LayerJoin`]. The preset's
140/// single `[palette]` serves both layers (one colour language, one baked LUT),
141/// and layer params are **namespaced to the layer**: they reach the layer's
142/// scene only, never the main scene's first-owner-wins routing and never the
143/// compositing stages, which belong to the preset as a whole.
144#[derive(Debug)]
145pub struct Layer {
146 /// The built-in system this layer drives.
147 pub system: SystemKind,
148 /// Where the layer joins the composite.
149 pub join: LayerJoin,
150 /// How an `over` layer blends in (ignored, with a load warning, on an
151 /// `under` join — there is no junction for it to apply at).
152 pub blend: LayerBlend,
153 /// The bindable mix amount at the `over` join (ADR-0090): how much of the
154 /// layer the blend applies, evaluated per frame like any binding so audio
155 /// can surge the second layer. `None` — the default — is full strength.
156 pub mix: Option<Binding>,
157 /// The layer's parameter bindings, name-sorted like the preset's own.
158 pub params: Vec<Binding>,
159 /// The layer's `[layer.per_vertex]` bindings — see
160 /// [`Preset::per_vertex`](Preset::per_vertex).
161 pub per_vertex: Vec<Binding>,
162 /// The layer's declarative structural config (ADR-0007), from its own
163 /// `[layer.curve]` / `[layer.generator]` / `[layer.particles]` /
164 /// `[layer.spectrum]` tables — validated by the same per-system rules as
165 /// the top level.
166 pub config: Option<GeneratorConfig>,
167}
168
169/// A named parameter bound to a compiled expression.
170#[derive(Debug)]
171pub struct Binding {
172 /// The system parameter this drives (e.g. `warp`, `hue`).
173 pub name: String,
174 /// The compiled expression producing its per-frame value.
175 pub expr: Expr,
176 /// This binding's easing constants (ADR-0019 / ADR-0035), read out of the
177 /// preset's `[smoothing]` table **once, here at load**.
178 /// [`Easing::INSTANT`] — the default for an unlisted param — means no
179 /// smoothing. Resolved at parse time rather than looked up per binding per
180 /// frame (Plan 0031 Phase 3); it is a fact about the preset, and the preset
181 /// does not change while it renders.
182 pub tau: Easing,
183 /// The musical edge this binding re-samples on (ADR-0180 rule 2), read out
184 /// of the preset's `[hold]` table **once, here at load**, for `tau`'s
185 /// reason and at `tau`'s boundary.
186 ///
187 /// `None` -- the default for an unlisted param, and what every binding in
188 /// the shipped set carried before holds existed -- means the scene sees
189 /// every frame's value. `Some` means it sees the value taken at the last
190 /// edge, and the render layer holds that value; nothing here does.
191 pub hold: Option<HoldEdge>,
192 /// What the parameter this binding drives is **for** (ADR-0180 rule 2),
193 /// read off its [`ParamSpec`](crate::render::scenes::ParamSpec) here at
194 /// load — `tau`'s boundary, for `tau`'s reason. A
195 /// [`Structural`](ParamKind::Structural) value is rounded once, after the
196 /// hold and after the smoother, before the scene sees it.
197 ///
198 /// Folded rather than searched per frame: which kind a name carries is a
199 /// fact about the *engine*, and the engine does not change while it runs.
200 pub kind: ParamKind,
201}
202
203/// One `[latch]` entry, compiled (ADR-0137): a gate armed on one condition and
204/// fired by the first rising edge of another inside the arming window.
205///
206/// Its **slot is its position in [`Preset::latches`]**, and that is the only
207/// place the mapping exists: the loader resolved the author's name onto a
208/// reserved variable slot while compiling the bindings, so nothing per-frame
209/// looks a latch up by name. The same reasoning that keeps `[smoothing]` off
210/// [`Preset`] as a table — a fact about the preset, resolved once, and the
211/// preset does not change while it renders.
212///
213/// The two expressions are compiled **without** any latch name in scope, so a
214/// latch cannot read a latch. That is not a restriction waiting to be lifted: it
215/// is what makes "evaluate every latch, then the params that read them" a
216/// complete order rather than one with a dependency graph inside it.
217#[derive(Debug)]
218pub struct Latch {
219 /// The author's name for it, which its bindings reference.
220 pub name: String,
221 /// While this holds (`> 0.5`), the latch is armed. Its fall re-arms.
222 pub arm: Expr,
223 /// The rising edge that fires an armed latch.
224 pub fire: Expr,
225 /// How long the fired latch reads `1.0`, in seconds. `0` is one frame.
226 pub hold: f32,
227}
228
229/// A loaded, ready-to-evaluate preset.
230#[derive(Debug)]
231pub struct Preset {
232 /// Human-readable name (defaults to the system name if omitted).
233 pub name: String,
234 /// Which built-in system this preset drives.
235 pub system: SystemKind,
236 /// The absolute path this preset was read from, when it came from a
237 /// directory. `None` for the embedded set, which has no file on disk, and
238 /// for anything compiled straight from a string.
239 ///
240 /// Set by [`crate::preset::load_dir`] rather than here: `from_toml_str` is
241 /// handed source text and has no way to know where it came from, and a
242 /// caller that does know is the one that can say. A consumer that offers to
243 /// edit a preset needs this to be able to distinguish "not editable" from
244 /// "the write failed" (ADR-0184).
245 pub source: Option<PathBuf>,
246 /// Parameter bindings, sorted by name for deterministic iteration.
247 pub params: Vec<Binding>,
248 /// The `[per_vertex]` table's bindings (Plan 0100 Phase 1): the warp mesh's
249 /// per-vertex program, evaluated once **per mesh vertex** per frame with
250 /// `x`/`y`/`rad`/`ang` bound to that vertex's position.
251 ///
252 /// A separate table rather than a naming convention inside `[params]`,
253 /// because the cost is categorically different: one of these is `N`
254 /// evaluations where an ordinary binding is one, and an author has to be able
255 /// to see which of their bindings they are paying `N` for. Empty for every
256 /// system but the warp mesh, and for a warp-mesh preset that accepts the
257 /// identity transform.
258 ///
259 /// Never eased: like a per-element binding, a per-vertex one has no single
260 /// value for the smoother to hold. A `[smoothing]` entry naming one is a
261 /// load warning.
262 pub per_vertex: Vec<Binding>,
263 /// The `[latch]` table's entries (ADR-0137), in slot order — the one part of
264 /// the preset surface whose value depends on frame history.
265 ///
266 /// Empty for a preset declaring no table, which is the overwhelmingly common
267 /// case and costs exactly what it cost before latches existed: the render
268 /// layer's bank advances nothing and every reserved slot stays at its rest
269 /// value of `0.0`.
270 pub latches: Vec<Latch>,
271 /// Declarative structural config for a line scene (ADR-0007), applied once
272 /// at preset load via `Scene::configure`. `None` for the fragment/swarm
273 /// systems and for curve presets that accept the family default.
274 pub config: Option<GeneratorConfig>,
275 // The `[smoothing]` table itself is deliberately **not** kept: it is validated
276 // at load and folded into each binding's `tau` there (Plan 0031 Phase 3), so
277 // there is nothing left for a frame to look up. An entry naming a param this
278 // preset does not bind was inert before and is inert now.
279 /// Optional color palette selection (ADR-0021 / Plan 0020), from a `[palette]`
280 /// table — a built-in `name` or custom `stops`, validated and baked-ready at
281 /// this boundary. `None` means the default `spectrum` (the exact current
282 /// cosine), so a preset without `[palette]` is visually unchanged. The
283 /// renderer bakes it into a LUT and hands it to the active scene via
284 /// `Scene::set_palette` on each preset switch.
285 pub palette: Option<PaletteConfig>,
286 /// The `[feedback]` structural table (ADR-0048): which curated warp the
287 /// accumulation buffers resample their past through, and how this frame's
288 /// light is deposited onto it.
289 ///
290 /// Not an `Option`: the absent table and the all-defaults table mean the same
291 /// thing, and a plain value is what lets the renderer hand it over on **every**
292 /// preset switch — so the outgoing preset's warp can never survive into the
293 /// incoming one. Load-time by `[curve] family`'s reasoning; the strength that
294 /// rides on it is the bindable `fb_warp`.
295 pub feedback: FeedbackConfig,
296 /// Optional **second** palette (ADR-0021 / Plan 0020 Phase 4), from a
297 /// `[palette_b]` table. When present, the renderer bakes an A/B pair and a
298 /// bindable `palette_mix` param crossfades between them per frame. `None`
299 /// means no crossfade (palette A only).
300 pub palette_b: Option<PaletteConfig>,
301 /// The salt this preset's `hash()`/`noise()` calls mix into their argument
302 /// **in the live app** (ADR-0051): folded at load from the `[generator] seed`
303 /// key (Plan 0010 reserved it, Plan 0047 gave it meaning), or drawn once from
304 /// OS entropy where the preset declares `seed = "random"`. `0` when it
305 /// declares nothing — a perfectly good salt, and the one the whole shipped
306 /// library used before any preset asked for another.
307 ///
308 /// A load-time constant. Nothing per-frame recomputes it, and no expression
309 /// can read it except through the two functions it salts.
310 pub salt: u32,
311 /// The salt every **capture** path uses in place of [`salt`](Self::salt):
312 /// the declared number, or `0` for `seed = "random"`.
313 ///
314 /// Equal to `salt` unless the preset opted into per-run variety — the whole
315 /// point of the pair (ADR-0051, following ADR-0045's tier pinning). The live
316 /// app varies and the harness pins, so `shot`, the goldens, `--report` and
317 /// the behavioral gates stay pure functions of their inputs while a preset
318 /// can still be different every time the user starts the app.
319 ///
320 /// It is the *renderer* that chooses between the two, not the loader, and
321 /// deliberately: `default_presets()` feeds both the live C-ABI path and the
322 /// capture gates, so a decision taken at load would be wrong for one of them.
323 pub pinned_salt: u32,
324 /// Parameters whose `clamp()` bounds are **meant** to pin, from an
325 /// `[occupancy] exempt = [...]` table (ADR-0062). Sorted and deduplicated at
326 /// load.
327 ///
328 /// A safety rail exists to bind at peak, and the saturation gate would
329 /// otherwise convict it of the defect it was written to prevent. The
330 /// exemption silences `core/tests/suite/saturation.rs`, and **only** that: the
331 /// binding still appears in `--report`'s `occ` count and `SAT` lines,
332 /// because an exemption is a place to hide and the one mitigation available
333 /// is that it stays visible.
334 ///
335 /// A preset-level table naming params rather than a per-expression
336 /// annotation, deliberately: the grammar stays a pure expression language
337 /// (ADR-0020), and this is metadata *about* a binding rather than part of
338 /// it. Harness-only — nothing per-frame reads it.
339 pub occupancy_exempt: Vec<String>,
340 /// Whether this preset is one of its family's **representatives** — the
341 /// sample the `dev` lane's per-phase test tier renders (ADR-0157).
342 ///
343 /// Absent means `false`. Harness-only, like `occupancy_exempt`: nothing
344 /// per-frame reads it, and it changes nothing about how the preset looks or
345 /// what the close and CI render, which is the whole library either way. It
346 /// is **declared, not derived** — a first-N or hash-rotation rule would
347 /// either never sample a newly landed preset or make the same tree gate
348 /// differently on different commits.
349 ///
350 /// A floor is enforced in `core/tests/suite/preset.rs`: every family carries at
351 /// least two. That catches a sample decayed to nothing; it cannot catch two
352 /// representatives that have stopped representing a family that grew around
353 /// them, which is a curation duty with no gate behind it.
354 pub representative: bool,
355 /// The optional second scene layer (ADR-0090 / Plan 0076), from a `[layer]`
356 /// table. `None` — the overwhelmingly common case — takes exactly the code
357 /// path a preset took before layers existed: no new pass, no new target.
358 pub layer: Option<Layer>,
359 /// Non-fatal problems found while loading — bindings naming a parameter
360 /// this system does not consume (ADR-0020), resting values in a dead zone,
361 /// inert table entries. The preset loaded and its good bindings apply; these
362 /// are surfaced so a typo stops failing silently. Empty for a clean preset.
363 /// Load-time only — never read per frame.
364 pub warnings: Vec<PresetWarning>,
365}
366
367/// One non-fatal problem found while loading a preset, and the binding it is
368/// about when it is about one (ADR-0192).
369///
370/// `param` is spelled as [`PresetError::param`] spells an expression error's
371/// label — `glow`, `[layer] glow`, `[per_vertex] x`, `[layer] [per_vertex] x` —
372/// so a consumer that places an error by its label places a warning by the same
373/// route. It is `None` for a warning about no single binding: a structural key,
374/// a table that is inert as a whole, or a table entry naming a binding the
375/// preset does not have (there is no binding line to point at).
376///
377/// Derefs to the message, so a warning reads as the text it always was; the
378/// label is the added structure, not a change to what is said.
379#[derive(Debug, Clone, PartialEq, Eq)]
380pub struct PresetWarning {
381 /// The sentence a person reads.
382 pub message: String,
383 /// The label of the binding the warning is about, or `None`.
384 pub param: Option<String>,
385}
386
387impl PresetWarning {
388 /// A warning about the binding labelled `param`.
389 pub fn about(param: impl Into<String>, message: impl Into<String>) -> Self {
390 Self {
391 message: message.into(),
392 param: Some(param.into()),
393 }
394 }
395
396 /// A warning about no single binding.
397 pub fn unanchored(message: impl Into<String>) -> Self {
398 Self {
399 message: message.into(),
400 param: None,
401 }
402 }
403}
404
405impl fmt::Display for PresetWarning {
406 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
407 f.write_str(&self.message)
408 }
409}
410
411impl std::ops::Deref for PresetWarning {
412 type Target = str;
413
414 fn deref(&self) -> &str {
415 &self.message
416 }
417}
418
419#[cfg(test)]
420mod tests;