Skip to main content

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;