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