Skip to main content

rlx_core/preset/schema/
export.rs

1//! What a preset may contain, as a machine-readable document (Plan 0158
2//! Phase 4).
3//!
4//! A studio building a parameter panel, an expression editor or a table form has
5//! to know two things the engine already knows: every **parameter** a system and
6//! the engine stages accept, and every **key** a structural table accepts. Both
7//! are declared in the engine and read by the loader; this renders them.
8//!
9//! ## The parameter half is generated, not restated
10//!
11//! It walks the same [`ParamSpec`] declarations the generated block in
12//! `presets/README.md` is rendered from (ADR-0170) — the same rosters, in the
13//! same order — so the published table and this document cannot disagree about a
14//! name, a default or a range. `core/tests/preset.rs` renders both from one walk
15//! and asserts they agree.
16//!
17//! ## The structural half is declared beside its serde struct
18//!
19//! Every `[table]` a preset may write carries a [`TableDesc`] next to the `Raw*`
20//! struct the loader deserializes it into. That is a second statement of the
21//! table's shape and it is one on purpose: serde carries a field's *name* and
22//! *type*, and none of what an editor needs — the closed roster a string is
23//! drawn from, the default the loader substitutes, the sentence that says what
24//! the key does. What stops the two drifting is a test, not a construction:
25//! `core/src/preset/schema/tests.rs` reads the field roster serde derived
26//! (through a `Deserializer` that answers nothing and records what it was asked
27//! for) and asserts it is exactly what the descriptor names.
28//!
29//! **No enumeration is written here.** A `KeyKind::Roster` names the type that
30//! owns the closed set, and [`Roster::values`] asks it — so `[feedback] warp`'s
31//! roster is `Warp::ALL` and there is nothing here to fall out of step with it.
32//!
33//! ## The hash
34//!
35//! [`hash`] is FNV-1a over the document **body** — everything the export
36//! declares, without the hash field itself, which would otherwise be hashing its
37//! own output. A studio compares it against the one a player reports in `hello`
38//! to know whether the schema it built its panels from is the schema the player
39//! is running.
40//!
41//! No JSON crate: the writer below is thirty lines and NFR section 4's
42//! dependency gate asks for a justification longer than that.
43
44// A continuation of one module split across several files, so it needs the
45// names `preset/schema/mod.rs` has in scope.
46use super::*;
47
48use crate::render::scenes::ParamSpec;
49
50/// The document format's own version, carried as `v` so a consumer can refuse a
51/// shape it does not know. Independent of the workspace version and of the
52/// event stream's `v`: this one moves when the *document* changes shape, which
53/// is rarer than either.
54pub const SCHEMA_VERSION: u32 = 1;
55
56// ---------------------------------------------------------------------------
57// The descriptor vocabulary
58// ---------------------------------------------------------------------------
59
60/// A closed roster of accepted spellings, named by the type that owns it.
61///
62/// The whole point of the indirection: a `KeyKind::Roster(Roster::Warp)` renders
63/// `Warp::ALL`, so the export cannot list a warp the loader rejects or omit one
64/// it accepts. Writing the strings here instead would be a second copy of every
65/// roster in the engine.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum Roster {
68    /// `system` — the built-in scenes.
69    System,
70    /// `[curve] family`.
71    CurveFamily,
72    /// `[particles] family` — the four maps plus every IFS figure.
73    AttractorFamily,
74    /// `[particles] morph_to` — the IFS figures alone.
75    IfsFigure,
76    /// `[spectrum] layout`.
77    SpectrumLayout,
78    /// `[feedback] warp`.
79    Warp,
80    /// `[feedback] blend`.
81    Deposit,
82    /// `[palette] name`.
83    Palette,
84    /// `[layer] join`.
85    LayerJoin,
86    /// `[layer] blend`.
87    LayerBlend,
88    /// `[generator] rings` motif.
89    Motif,
90    /// `[generator] tiling`.
91    Tiling,
92}
93
94impl Roster {
95    /// The accepted spellings, asked of the type that owns the roster.
96    pub fn values(self) -> Vec<&'static str> {
97        use crate::render::feedback::{Deposit, Warp};
98        use crate::render::palette::NamedPalette;
99        use crate::render::scenes::lines::star::Motif;
100        use crate::render::scenes::lines::{CurveFamily, SpectrumLayout};
101        use crate::render::scenes::particles::AttractorFamily;
102        use crate::render::scenes::particles::ifs::IfsFigure;
103        match self {
104            Roster::System => SystemKind::ALL.iter().map(|k| k.as_str()).collect(),
105            Roster::CurveFamily => CurveFamily::ALL.iter().map(|f| f.as_str()).collect(),
106            Roster::AttractorFamily => AttractorFamily::MAPS
107                .iter()
108                .map(|f| f.as_str())
109                .chain(IfsFigure::ALL.iter().map(|f| f.name()))
110                .collect(),
111            Roster::IfsFigure => IfsFigure::ALL.iter().map(|f| f.name()).collect(),
112            Roster::SpectrumLayout => SpectrumLayout::NAMES.to_vec(),
113            Roster::Warp => Warp::ALL.iter().map(|w| w.as_str()).collect(),
114            Roster::Deposit => Deposit::ALL.iter().map(|d| d.as_str()).collect(),
115            Roster::Palette => NamedPalette::ALL.iter().map(|p| p.as_str()).collect(),
116            Roster::LayerJoin => LayerJoin::ALL.iter().map(|j| j.as_str()).collect(),
117            Roster::LayerBlend => LayerBlend::ALL.iter().map(|b| b.as_str()).collect(),
118            Roster::Motif => Motif::ALL.iter().map(|m| m.name()).collect(),
119            Roster::Tiling => crate::render::scenes::lines::hankin::TILINGS.to_vec(),
120        }
121    }
122
123    /// Whether `name` is in this roster — the membership test the round-trip
124    /// test uses against each type's own `from_name`.
125    pub fn accepts(self, name: &str) -> bool {
126        self.values().contains(&name)
127    }
128}
129
130/// What one structural key accepts.
131///
132/// Deliberately coarser than serde's type: an editor needs to know that
133/// `[path] d` is *free text* and `[curve] family` is *one of a closed set*, and
134/// serde reports both as `String`.
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136pub enum KeyKind {
137    /// `true` / `false`.
138    Bool,
139    /// A whole number.
140    Int,
141    /// A real number.
142    Float,
143    /// Free text with no roster behind it — a path's `d`, an axiom, a shader
144    /// body.
145    Text,
146    /// An expression in the preset grammar.
147    Expr,
148    /// One of a closed set.
149    Roster(Roster),
150    /// An easing constant: seconds as a number, or `{ attack, release }`.
151    Easing,
152    /// A salt: a number, or the string `"random"` (ADR-0051).
153    Seed,
154    /// A hold edge: `"beat"`, `"bar"`, or a positive number of seconds
155    /// (ADR-0180 rule 2). Not a `Roster`, because the number is not one of a
156    /// closed set and an editor offering only the two words would reject a
157    /// legal entry.
158    Hold,
159    /// A colour: `"#rrggbb"` or `[r, g, b]` in `0..=1`.
160    Colour,
161    /// A table of author-chosen names to values of this kind.
162    Map(&'static KeyKind),
163    /// A list of values of this kind.
164    List(&'static KeyKind),
165    /// A nested table, named in [`TABLES`].
166    Table(&'static str),
167}
168
169impl KeyKind {
170    /// The document's spelling for this kind.
171    fn tag(&self) -> &'static str {
172        match self {
173            KeyKind::Bool => "bool",
174            KeyKind::Int => "int",
175            KeyKind::Float => "float",
176            KeyKind::Text => "text",
177            KeyKind::Expr => "expr",
178            KeyKind::Roster(_) => "enum",
179            KeyKind::Easing => "easing",
180            KeyKind::Seed => "seed",
181            KeyKind::Hold => "hold",
182            KeyKind::Colour => "colour",
183            KeyKind::Map(_) => "map",
184            KeyKind::List(_) => "list",
185            KeyKind::Table(_) => "table",
186        }
187    }
188}
189
190/// One key of one structural table.
191#[derive(Debug, Clone, Copy)]
192pub struct KeyDesc {
193    /// The key as it is written in the file.
194    pub name: &'static str,
195    /// What it accepts.
196    pub kind: KeyKind,
197    /// What the loader uses when the key is absent, written as it would appear
198    /// in TOML. Empty where absence means something other than a value — a
199    /// required key, or one whose absence turns a feature off rather than
200    /// selecting a value.
201    pub default: &'static str,
202    /// One line: what it does.
203    pub doc: &'static str,
204}
205
206/// One structural table a preset may write.
207#[derive(Debug, Clone, Copy)]
208pub struct TableDesc {
209    /// The table's name — its TOML header, or the name a [`KeyKind::Table`]
210    /// refers to it by. `preset` is the document root.
211    pub name: &'static str,
212    /// One line: what the table is for.
213    pub doc: &'static str,
214    /// Its keys, in declaration order.
215    pub keys: &'static [KeyDesc],
216}
217
218/// Every table, root first. A [`KeyKind::Table`] names one of these.
219///
220/// Flat with references rather than nested inline, because `[layer]` carries
221/// most of the root's tables and inlining would print each of them twice — and
222/// a consumer building a form wants one definition per table, not one per site.
223pub const TABLES: &[&TableDesc] = &[
224    &super::raw::PRESET,
225    &super::raw::LAYER,
226    &super::raw::LATCH,
227    &super::raw::CURVE,
228    &super::raw::GENERATOR,
229    &super::raw::RING,
230    &super::raw::PARTICLES,
231    &super::raw::PATH,
232    &super::raw::SPECTRUM,
233    &super::raw::MESH,
234    &super::raw::MILK,
235    &super::raw::MILK_ELEMENT,
236    &super::raw::FEEDBACK,
237    &super::raw::OCCUPANCY,
238    &super::raw::PALETTE,
239    &super::raw::STOP,
240];
241
242/// The table named `name`, or `None`.
243pub fn table(name: &str) -> Option<&'static TableDesc> {
244    TABLES.iter().copied().find(|t| t.name == name)
245}
246
247// ---------------------------------------------------------------------------
248// The parameter half
249// ---------------------------------------------------------------------------
250
251/// The engine-wide stages, labelled as a reader meets them rather than as the
252/// modules are named.
253///
254/// Zipped against [`GLOBAL_PARAMS`] rather than naming each module, because that
255/// array is already the one statement of which stages a preset may bind whatever
256/// its system. The labels are in its order, and
257/// [`param_rosters`]'s length check is what holds them there.
258pub const STAGE_LABELS: [&str; 7] = [
259    "background",
260    "trails",
261    "kaleidoscope",
262    "bloom",
263    "composite",
264    "tonemap",
265    "ink",
266];
267
268/// Every parameter roster a preset may bind, labelled: one per system, then one
269/// per engine stage.
270///
271/// **The one walk.** The generated reference in `presets/README.md` and the
272/// document below are both rendered from this, so the two cannot state different
273/// defaults for a name (ADR-0170).
274pub fn param_rosters() -> Vec<(&'static str, &'static [ParamSpec])> {
275    let mut out: Vec<(&'static str, &'static [ParamSpec])> = SystemKind::ALL
276        .iter()
277        .map(|kind| (kind.as_str(), kind.param_specs()))
278        .collect();
279    // A stage that joined `GLOBAL_PARAMS` without a label here would be printed
280    // under its neighbour's heading, so it is dropped rather than mislabelled —
281    // and `the_stage_labels_cover_every_global_roster` fails on it.
282    out.extend(STAGE_LABELS.iter().copied().zip(GLOBAL_PARAMS));
283    out
284}
285
286/// How many of the rosters are engine stages rather than systems — the tail of
287/// [`param_rosters`].
288pub fn stage_count() -> usize {
289    STAGE_LABELS.len().min(GLOBAL_PARAMS.len())
290}
291
292// ---------------------------------------------------------------------------
293// The document
294// ---------------------------------------------------------------------------
295
296/// The whole schema as JSON, hash included.
297///
298/// One line of output; a consumer parses it, and a human reading it reaches for
299/// a formatter. Deterministic: every roster it walks is a `const` array in
300/// declaration order, so two runs on one build produce identical bytes.
301pub fn document() -> String {
302    let body = body();
303    format!(
304        "{{\"v\":{SCHEMA_VERSION},\"hash\":\"{:016x}\",{body}",
305        hash_str(&body)
306    )
307}
308
309/// The document's stable hash.
310///
311/// FNV-1a over the **body** — the document without its own `v` and `hash` — so
312/// the hash is a function of what the engine declares rather than of itself. It
313/// changes when and only when that declaration changes.
314pub fn hash() -> u64 {
315    hash_str(&body())
316}
317
318/// The hash as the sixteen hex characters `document` prints and `hello` reports.
319pub fn hash_hex() -> String {
320    format!("{:016x}", hash())
321}
322
323/// Everything the export declares, as the tail of a JSON object — from
324/// `"systems"` through the closing brace.
325fn body() -> String {
326    let rosters = param_rosters();
327    let stages = stage_count();
328    let split = rosters.len().saturating_sub(stages);
329    let mut out = String::with_capacity(64 * 1024);
330
331    out.push_str("\"systems\":[");
332    for (i, (label, specs)) in rosters.iter().take(split).enumerate() {
333        if i > 0 {
334            out.push(',');
335        }
336        push_roster(&mut out, label, specs);
337    }
338    out.push_str("],\"stages\":[");
339    for (i, (label, specs)) in rosters.iter().skip(split).enumerate() {
340        if i > 0 {
341            out.push(',');
342        }
343        push_roster(&mut out, label, specs);
344    }
345    out.push_str("],\"tables\":[");
346    for (i, table) in TABLES.iter().enumerate() {
347        if i > 0 {
348            out.push(',');
349        }
350        push_table(&mut out, table);
351    }
352    out.push_str("],\"grammar\":");
353    push_grammar(&mut out);
354    out.push('}');
355    out
356}
357
358/// The expression grammar's three identifier rosters.
359///
360/// **Generated by walking the engine's own declarations**, the same discipline
361/// the parameter half is held to (ADR-0170): `variables` is what the parser's
362/// identifier lookup accepts out of `VAR_NAMES`, `functions` is the roster
363/// `Func::from_name` resolves through, and `constants` is the table `constant`
364/// resolves through. Nothing here is a second copy, so an editor colouring these
365/// colours exactly what the engine knows.
366///
367/// The reserved `[latch]` placeholders are **absent** from `variables`, because
368/// the parser refuses them by name: an author reaches a latch through the name
369/// they declared for it, and offering `_latch0` would be offering a spelling
370/// that does not compile. A preset's own latch names are in the preset, not in
371/// the schema.
372fn push_grammar(out: &mut String) {
373    out.push_str("{\"variables\":");
374    push_names(out, expr::variable_names());
375    out.push_str(",\"functions\":");
376    push_names(out, expr::function_names());
377    out.push_str(",\"constants\":");
378    push_names(out, expr::constant_names());
379    out.push('}');
380}
381
382/// A JSON array of strings.
383fn push_names(out: &mut String, names: impl Iterator<Item = &'static str>) {
384    out.push('[');
385    for (i, name) in names.enumerate() {
386        if i > 0 {
387            out.push(',');
388        }
389        push_string(out, name);
390    }
391    out.push(']');
392}
393
394/// One labelled parameter roster.
395fn push_roster(out: &mut String, label: &str, specs: &[ParamSpec]) {
396    out.push_str("{\"name\":");
397    push_string(out, label);
398    out.push_str(",\"params\":[");
399    for (i, spec) in specs.iter().enumerate() {
400        if i > 0 {
401            out.push(',');
402        }
403        out.push_str("{\"name\":");
404        push_string(out, spec.name);
405        out.push_str(",\"default\":");
406        push_number(out, spec.default);
407        out.push_str(",\"range\":");
408        match spec.range {
409            Some([lo, hi]) => {
410                out.push('[');
411                push_number(out, lo);
412                out.push(',');
413                push_number(out, hi);
414                out.push(']');
415            }
416            None => out.push_str("null"),
417        }
418        out.push_str(",\"doc\":");
419        push_string(out, spec.doc);
420        // ADR-0180 rule 4's distinction, so a studio can group its panel the
421        // way the reference groups its tables. Additive: a consumer that does
422        // not know the field ignores it, which is why `SCHEMA_VERSION` does
423        // not move — the body hash does, and that is the staleness signal a
424        // studio already compares.
425        out.push_str(",\"kind\":");
426        push_string(out, spec.kind.as_str());
427        out.push('}');
428    }
429    out.push_str("]}");
430}
431
432/// One structural table.
433fn push_table(out: &mut String, table: &TableDesc) {
434    out.push_str("{\"name\":");
435    push_string(out, table.name);
436    out.push_str(",\"doc\":");
437    push_string(out, table.doc);
438    out.push_str(",\"keys\":[");
439    for (i, key) in table.keys.iter().enumerate() {
440        if i > 0 {
441            out.push(',');
442        }
443        out.push_str("{\"name\":");
444        push_string(out, key.name);
445        out.push_str(",\"default\":");
446        push_string(out, key.default);
447        out.push_str(",\"doc\":");
448        push_string(out, key.doc);
449        out.push(',');
450        push_kind(out, &key.kind);
451        out.push('}');
452    }
453    out.push_str("]}");
454}
455
456/// One key's kind, as the fields it contributes to that key's object.
457fn push_kind(out: &mut String, kind: &KeyKind) {
458    out.push_str("\"kind\":");
459    push_string(out, kind.tag());
460    match kind {
461        KeyKind::Roster(roster) => {
462            out.push_str(",\"values\":[");
463            for (i, value) in roster.values().iter().enumerate() {
464                if i > 0 {
465                    out.push(',');
466                }
467                push_string(out, value);
468            }
469            out.push(']');
470        }
471        KeyKind::Map(of) | KeyKind::List(of) => {
472            out.push_str(",\"of\":{");
473            push_kind(out, of);
474            out.push('}');
475        }
476        KeyKind::Table(name) => {
477            out.push_str(",\"table\":");
478            push_string(out, name);
479        }
480        _ => {}
481    }
482}
483
484/// A JSON string, quotes included.
485///
486/// Escapes what RFC 8259 requires and nothing else: the quote, the backslash,
487/// and every control character below `0x20`. Doc lines are hand-written ASCII
488/// prose, so the `\u` arm is a guard against a future one rather than a live
489/// case.
490fn push_string(out: &mut String, text: &str) {
491    out.push('"');
492    for ch in text.chars() {
493        match ch {
494            '"' => out.push_str("\\\""),
495            '\\' => out.push_str("\\\\"),
496            '\n' => out.push_str("\\n"),
497            '\r' => out.push_str("\\r"),
498            '\t' => out.push_str("\\t"),
499            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
500            c => out.push(c),
501        }
502    }
503    out.push('"');
504}
505
506/// A JSON number.
507///
508/// `{:?}` on an `f32` prints the shortest decimal that round-trips, which keeps
509/// `0.5` as `0.5` rather than `0.5000` and keeps the hash stable across the
510/// values a spec can hold. A non-finite default cannot be written as JSON, so it
511/// is emitted as `null` — the loader would refuse such a spec long before this,
512/// and printing `NaN` would produce a document nothing can parse.
513fn push_number(out: &mut String, value: f32) {
514    if value.is_finite() {
515        out.push_str(&format!("{value:?}"));
516    } else {
517        out.push_str("null");
518    }
519}
520
521/// FNV-1a, 64-bit, over `text`'s bytes — the function [`hash`] uses, exposed so
522/// a test can hash a deliberately perturbed copy of the document and assert the
523/// hash moved with it.
524///
525/// Not cryptographic and not asked to be: the question it answers is "is the
526/// studio's copy of this document the one this player is running", where the
527/// adversary is a stale file rather than a person. Written out rather than taken
528/// from `DefaultHasher`, whose output std explicitly does not promise to be
529/// stable across releases — a hash a studio caches has to survive a toolchain
530/// bump.
531pub fn hash_str(text: &str) -> u64 {
532    const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
533    const PRIME: u64 = 0x0000_0100_0000_01b3;
534    let mut hash = OFFSET;
535    for byte in text.as_bytes() {
536        hash ^= u64::from(*byte);
537        hash = hash.wrapping_mul(PRIME);
538    }
539    hash
540}