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}