rlx_core/render/roster.rs
1//! The loaded presets, the active index, and the per-binding frame state.
2//!
3//! GPU-free on purpose: [`Roster`] is the addressing contract (names in roster
4//! order, in-range select, out-of-range no-op) as a pure type, so it is testable
5//! without a surface, and [`Renderer`]'s preset methods delegate to it 1:1.
6//! [`BindingState`] -- the easing envelope and the sample-and-hold -- sits here
7//! because both are keyed by the same binding index the roster's routes are.
8
9// Hot-path panic-denial pragma (Plan 0002 Phase 2; render/ is scanned by the
10// hygiene guard).
11#![deny(
12 clippy::unwrap_used,
13 clippy::expect_used,
14 clippy::indexing_slicing,
15 clippy::panic,
16 clippy::unreachable
17)]
18
19// A continuation of one module split across several files, so it needs the
20// names `render/mod.rs` has in scope.
21use super::*;
22
23/// The loaded presets plus the active index — the pure, GPU-free part of
24/// selection. Split out of [`Renderer`] so the addressing contract (names in
25/// roster order, in-range select, out-of-range no-op) is unit-testable without a
26/// surface, mirroring how the diagnostics stats are a pure type behind the GPU
27/// [`Renderer`]. [`Renderer`]'s preset methods delegate here 1:1.
28pub(super) struct Roster {
29 pub(super) presets: Vec<Preset>,
30 /// Resolved [`ParamRoute`]s, one inner `Vec` per preset and one entry per that
31 /// preset's bindings, in `Preset::params` order.
32 ///
33 /// Kept here rather than on the active preset alone because a dissolve
34 /// composites **two** presets in one frame (Plan 0023) and both sides want
35 /// their routes; indexing by preset means a side's routes cannot drift out of
36 /// step with the preset it is showing. Resolution is a render-layer concern
37 /// (it names chain positions), which is why it lives on this render-layer type
38 /// and not in `preset/`.
39 pub(super) routes: Vec<Vec<ParamRoute>>,
40 pub(super) active: usize,
41}
42
43impl Roster {
44 pub(super) fn new(presets: Vec<Preset>) -> Self {
45 Self {
46 routes: resolve_routes(&presets),
47 presets,
48 active: 0,
49 }
50 }
51
52 /// Replace the roster; reset `active` to the start if it now points past the
53 /// end. An empty set is ignored — a directory that briefly reads empty or
54 /// all-malformed leaves the last good roster rendering (NFR 10).
55 pub(super) fn set_presets(&mut self, presets: Vec<Preset>) {
56 if presets.is_empty() {
57 return;
58 }
59 self.routes = resolve_routes(&presets);
60 self.presets = presets;
61 if self.active >= self.presets.len() {
62 self.active = 0;
63 }
64 }
65
66 /// Whether `presets` is this roster **rebound** rather than replaced: the
67 /// same presets, in the same order, each still driving the same system, with
68 /// only their expressions and easing constants free to differ.
69 ///
70 /// This is the seam a live editor saves through (ADR-0176). The two things
71 /// it tests are the two that make the carried frame state meaningful — a
72 /// preset that changed system drives a different scene, and a roster that
73 /// changed shape re-points every index — and everything else a save can
74 /// touch is re-handed by [`Renderer::set_presets`] either way.
75 ///
76 /// An empty replacement is never a rebind: [`set_presets`](Self::set_presets)
77 /// ignores it entirely, so the caller must take the path that ignores it.
78 pub(super) fn is_rebind_of(&self, presets: &[Preset]) -> bool {
79 !presets.is_empty()
80 && self.presets.len() == presets.len()
81 && self
82 .presets
83 .iter()
84 .zip(presets)
85 .all(|(held, next)| held.name == next.name && held.system == next.system)
86 }
87
88 /// The resolved routes for the preset at `index`, positionally matching its
89 /// `params`. Empty for an out-of-range index, which pairs with
90 /// `presets.get(index)` returning `None`.
91 pub(super) fn routes_for(&self, index: usize) -> &[ParamRoute] {
92 self.routes.get(index).map_or(&[], Vec::as_slice)
93 }
94
95 /// The active preset's resolved routes.
96 pub(super) fn active_routes(&self) -> &[ParamRoute] {
97 self.routes_for(self.active)
98 }
99
100 /// The index cycling would land on (wrapping), **without** moving there — the
101 /// dissolve controller needs the target before the roster flips, because the
102 /// dissolve's opening frame still composites the outgoing preset. Returns the
103 /// current index on an empty or single-preset roster, which the caller reads as
104 /// "nothing to dissolve to".
105 pub(super) fn next_index(&self) -> usize {
106 if self.presets.is_empty() {
107 return self.active;
108 }
109 (self.active + 1) % self.presets.len()
110 }
111
112 /// Set the active preset **iff** `index` is in range; an out-of-range index
113 /// is a no-op — never a panic, never a wrap.
114 pub(super) fn select(&mut self, index: usize) {
115 if index < self.presets.len() {
116 self.active = index;
117 }
118 }
119
120 /// The active preset, or `None` on an empty roster.
121 pub(super) fn active_preset(&self) -> Option<&Preset> {
122 self.presets.get(self.active)
123 }
124
125 /// The active preset's name, or a placeholder on an empty roster.
126 pub(super) fn name(&self) -> &str {
127 self.active_preset()
128 .map(|p| p.name.as_str())
129 .unwrap_or("no presets")
130 }
131
132 /// The loaded preset names in roster order.
133 pub(super) fn names(&self) -> impl Iterator<Item = &str> {
134 self.presets.iter().map(|p| p.name.as_str())
135 }
136}
137
138/// Resolve every preset's bindings to their destinations, off the hot path — once
139/// per roster load, not once per binding per frame.
140pub(super) fn resolve_routes(presets: &[Preset]) -> Vec<Vec<ParamRoute>> {
141 presets
142 .iter()
143 .map(|preset| {
144 preset
145 .params
146 .iter()
147 .map(|binding| resolve_route(&binding.name, preset.system))
148 .collect()
149 })
150 .collect()
151}
152
153/// Why a live parameter override was refused.
154///
155/// Both arms are *load-time* answers to a question asked over the control
156/// surface, so the sender learns immediately that a name will never move —
157/// rather than sending at slider rate into a value nothing reads. OSC itself has
158/// no reply channel (ADR-0164), which is why this is a `Result` here and a
159/// counter at the listener.
160#[derive(Debug, Clone, PartialEq, Eq)]
161pub enum ParamError {
162 /// The roster is empty, so there is no system whose vocabulary could claim
163 /// any name at all.
164 NoActivePreset,
165 /// No backdrop, post stage, terminal pass or scene on the active preset's
166 /// system answers to this name — the same verdict
167 /// `ParamRoute::Unclaimed` records for a binding, taken at the moment the
168 /// override is set instead of silently at apply time.
169 UnknownParam(String),
170}
171
172impl std::fmt::Display for ParamError {
173 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
174 match self {
175 Self::NoActivePreset => f.write_str("no preset is active"),
176 Self::UnknownParam(name) => {
177 write!(
178 f,
179 "no parameter named `{name}` on the active preset's system"
180 )
181 }
182 }
183 }
184}
185
186impl std::error::Error for ParamError {}
187
188/// Live per-name parameter overrides on the active preset (ADR-0176).
189///
190/// One entry shadows whatever that parameter's binding evaluated to, from the
191/// next frame until it is cleared. This is the seam a control surface drags a
192/// slider through: the alternative — rewriting the preset file — is around
193/// 150 ms late and takes the reload path, which the same ADR is why the reload
194/// path learned to keep its state.
195///
196/// **Keyed by name and carrying its own resolved [`ParamRoute`]**, which is what
197/// lets an override reach a parameter the preset does not bind at all: the value
198/// is applied after the binding walk, against the route
199/// [`resolve_route`] gives the name on the active system. Resolving once at
200/// `set` is sound because the active system cannot change under a live
201/// override — every path that changes it clears the bank.
202///
203/// A `Vec` rather than a map, for [`Roster`]'s reason: a surface drags one or
204/// two parameters at a time, so a linear walk over what is actually held costs
205/// less than hashing a name per frame.
206#[derive(Default)]
207pub(super) struct ParamOverrides {
208 entries: Vec<(String, ParamRoute, f32)>,
209}
210
211impl ParamOverrides {
212 /// Hold `value` on `name`, replacing whatever this name held before.
213 pub(super) fn set(&mut self, name: &str, route: ParamRoute, value: f32) {
214 match self.entries.iter_mut().find(|(held, ..)| held == name) {
215 Some(entry) => {
216 entry.1 = route;
217 entry.2 = value;
218 }
219 None => self.entries.push((name.to_owned(), route, value)),
220 }
221 }
222
223 /// Drop `name`'s override, so its binding resumes on the next frame. Unknown
224 /// names are a no-op — a surface releasing a slider it never held is not an
225 /// error.
226 pub(super) fn clear(&mut self, name: &str) {
227 self.entries.retain(|(held, ..)| held != name);
228 }
229
230 /// Drop every override.
231 pub(super) fn clear_all(&mut self) {
232 self.entries.clear();
233 }
234
235 /// The held overrides, in the order they were first set.
236 pub(super) fn entries(&self) -> &[(String, ParamRoute, f32)] {
237 &self.entries
238 }
239}
240
241/// Render-layer one-pole envelope over evaluated parameter values (ADR-0019 /
242/// Plan 0018 Phase 5, widened by ADR-0035). Each active-preset binding gets
243/// optional exponential smoothing with a per-param [`Easing`] (seconds), applied
244/// on the injected real `dt` **between** `expr.eval` and `set_param`, so band-
245/// and beat-driven motion eases instead of snapping. The evaluator stays pure
246/// and allocation-free — the smoothing state lives here, beside the other
247/// per-frame state the expression path has, [`LatchBank`].
248///
249/// With `attack != release` this is deliberately **not** a linear filter: a
250/// direction-dependent time constant rectifies, so a fast-attack parameter rides
251/// above its input's mean under sustained material. That is the envelope-follower
252/// behavior ADR-0035 exists to provide, not a defect.
253///
254/// State is keyed by binding **index** (the active preset's `params` are a stable
255/// name-sorted `Vec`) and is **reset on every active-preset change** (a switch
256/// snaps to the incoming preset's first value — no cross-preset bleed) and on the
257/// capture scene-rebuild (so a headless capture stays a pure function of its
258/// inputs, NFR 6).
259#[derive(Default)]
260pub(super) struct ParamSmoother {
261 /// Last smoothed value per binding index; grown lazily and seeded with the
262 /// first frame's raw value, so the first frame after a reset snaps rather than
263 /// drifting up from a stale zero. Cleared on reset.
264 ///
265 /// `None` is **no history yet**, which is what the first frame after a reset
266 /// sees and also what [`remap`](Self::remap) leaves in a slot whose parameter
267 /// had no counterpart in the outgoing preset. A sentinel float would have to
268 /// be one `Easing::step` cannot produce, and there is no such float.
269 last: Vec<Option<f32>>,
270}
271
272impl ParamSmoother {
273 /// Forget all state so the next frame snaps to the incoming values.
274 pub(super) fn reset(&mut self) {
275 self.last.clear();
276 }
277
278 /// Re-key the carried state from binding order `from` to binding order `to`,
279 /// **by name**, keeping every eased value whose parameter is still bound.
280 ///
281 /// State is keyed by binding *index* and a preset's `params` are name-sorted,
282 /// so one binding added or removed shifts every index past it. Carrying the
283 /// raw `Vec` across such an edit would hand a parameter its neighbour's eased
284 /// value — visible as a jump on exactly the save that was meant to change
285 /// nothing else. A name with no counterpart lands on `None` and so snaps to
286 /// its own first value, which is what a reset would have given it anyway.
287 ///
288 /// Quadratic in the binding count, and deliberately: this runs once per
289 /// hot-reload over the twenty-odd bindings a preset carries, never per frame.
290 pub(super) fn remap(&mut self, from: &[&str], to: &[&str]) {
291 let carried: Vec<Option<f32>> = to
292 .iter()
293 .map(|name| {
294 from.iter()
295 .position(|prev| prev == name)
296 .and_then(|index| self.last.get(index).copied().flatten())
297 })
298 .collect();
299 self.last = carried;
300 }
301
302 /// The value carried in `index`'s slot, or `None` where nothing has been
303 /// eased into it yet. The state [`remap`](Self::remap) moves, read back so a
304 /// test can assert the move rather than infer it from a rendered frame.
305 #[cfg(test)]
306 pub(super) fn carried(&self, index: usize) -> Option<f32> {
307 self.last.get(index).copied().flatten()
308 }
309
310 /// [`remap`](Self::remap) for a **layer's** smoother, whose slots are the
311 /// layer's params followed by one more for the bindable `mix` (ADR-0090).
312 ///
313 /// That extra slot has no entry in `params`, so it cannot ride the same call:
314 /// it is carried by hand, and only when both sides declare a `mix` — a layer
315 /// that gained or lost one has nothing to carry.
316 pub(super) fn remap_layer(&mut self, from: &Layer, to: &Layer) {
317 let from_names: Vec<&str> = from.params.iter().map(|b| b.name.as_str()).collect();
318 let to_names: Vec<&str> = to.params.iter().map(|b| b.name.as_str()).collect();
319 let mix = self.last.get(from_names.len()).copied().flatten();
320 self.remap(&from_names, &to_names);
321 if from.mix.is_some() && to.mix.is_some() {
322 self.last.resize(to_names.len() + 1, None);
323 if let Some(slot) = self.last.get_mut(to_names.len()) {
324 *slot = mix;
325 }
326 }
327 }
328
329 /// Smooth `raw` for binding `index` toward its previous value over `dt`
330 /// seconds, using whichever of `tau`'s two constants the direction of travel
331 /// selects (ADR-0035). A selected constant of `<= 0` (the default) or
332 /// non-finite, or a non-positive `dt`, passes `raw` through unchanged. The
333 /// first frame after a reset seeds the state with `raw` (a snap).
334 pub(super) fn smooth(&mut self, index: usize, raw: f32, tau: Easing, dt: f32) -> f32 {
335 if self.last.len() <= index {
336 self.last.resize(index + 1, None);
337 }
338 let Some(slot) = self.last.get_mut(index) else {
339 return raw; // unreachable after the resize; never panics on the hot path
340 };
341 // The arithmetic itself lives on `Easing` (Plan 0034 Phase 3), so the
342 // spectrum scene's per-element smoother eases by the same rule rather
343 // than growing a second easing vocabulary beside this one.
344 //
345 // No history in the slot means this parameter has never been eased under
346 // this preset -- the first frame after a reset, or a binding a rebind
347 // brought in -- and it snaps.
348 let next = match *slot {
349 Some(prev) => tau.step(prev, raw, dt),
350 None => raw,
351 };
352 *slot = Some(next);
353 next
354 }
355}
356
357/// One held binding's state between frames.
358#[derive(Clone, Copy, Default)]
359struct HoldSlot {
360 /// The value the scene is being shown. `None` is **never sampled**, which
361 /// is what the first frame under a preset sees and what a
362 /// [`remap`](ParamHold::remap) leaves in a slot whose parameter had no
363 /// counterpart in the outgoing preset.
364 held: Option<f32>,
365 /// What the last re-sample measured its interval in: the analysis frame's
366 /// bar counter for [`HoldEdge::Bar`], the elapsed clock for
367 /// [`HoldEdge::Period`]. Unused for [`HoldEdge::Beat`], whose edge is the
368 /// frame's own one-frame gate rather than an interval.
369 last: Option<f32>,
370}
371
372/// Render-layer sample-and-hold over evaluated parameter values (ADR-0180
373/// rule 2). A binding listed in the preset's `[hold]` table is still evaluated
374/// every frame — the evaluator stays a pure, stateless function of its
375/// [`Variables`] — and this decides which frame's value the scene is shown.
376///
377/// **Beside [`ParamSmoother`] and keyed the same way**, by binding index into
378/// the active preset's name-sorted `params`, because a hold that drifted out of
379/// step with the binding it holds would show one parameter another's held
380/// value. The two are carried together in [`BindingState`] so neither can be
381/// reset, remapped or handed to a dissolve without the other.
382///
383/// Applied **between** `expr.eval` and the smoother: `[smoothing]` then eases
384/// toward the held value exactly as it eases toward any other, so a parameter
385/// that is both held and smoothed travels to each new value rather than
386/// stepping to it.
387///
388/// A preset with no `[hold]` table never reaches the state below — every
389/// binding's edge is `None`, [`hold`](Self::hold) returns the raw value on a
390/// slice length check, and the `Vec` stays empty and unallocated.
391#[derive(Default)]
392pub(super) struct ParamHold {
393 /// Per binding index; grown lazily and only for a binding that declares an
394 /// edge. Cleared on reset.
395 slots: Vec<HoldSlot>,
396}
397
398impl ParamHold {
399 /// Forget every held value so the next frame re-samples.
400 pub(super) fn reset(&mut self) {
401 self.slots.clear();
402 }
403
404 /// Re-key the carried state from binding order `from` to binding order
405 /// `to`, **by name** — [`ParamSmoother::remap`]'s contract, for its reason
406 /// and at its cost. A name with no counterpart lands on a fresh slot and
407 /// so re-samples on its first frame, which is where a reset would have left
408 /// it.
409 pub(super) fn remap(&mut self, from: &[&str], to: &[&str]) {
410 let carried: Vec<HoldSlot> = to
411 .iter()
412 .map(|name| {
413 from.iter()
414 .position(|prev| prev == name)
415 .and_then(|index| self.slots.get(index).copied())
416 .unwrap_or_default()
417 })
418 .collect();
419 self.slots = carried;
420 }
421
422 /// [`remap`](Self::remap) for a **layer's** hold, whose slots are the
423 /// layer's params followed by one more for the bindable `mix` — the same
424 /// shape, and the same hand-carried extra slot, as
425 /// [`ParamSmoother::remap_layer`].
426 pub(super) fn remap_layer(&mut self, from: &Layer, to: &Layer) {
427 let from_names: Vec<&str> = from.params.iter().map(|b| b.name.as_str()).collect();
428 let to_names: Vec<&str> = to.params.iter().map(|b| b.name.as_str()).collect();
429 let mix = self.slots.get(from_names.len()).copied();
430 self.remap(&from_names, &to_names);
431 if from.mix.is_some() && to.mix.is_some() {
432 self.slots.resize(to_names.len() + 1, HoldSlot::default());
433 if let (Some(slot), Some(mix)) = (self.slots.get_mut(to_names.len()), mix) {
434 *slot = mix;
435 }
436 }
437 }
438
439 /// The value carried in `index`'s slot, or `None` where nothing has been
440 /// held in it yet — [`ParamSmoother::carried`]'s counterpart, so a test can
441 /// assert the state rather than infer it from a rendered frame.
442 #[cfg(test)]
443 pub(super) fn carried(&self, index: usize) -> Option<f32> {
444 self.slots.get(index).and_then(|slot| slot.held)
445 }
446
447 /// The value binding `index` shows this frame: `raw` where the binding
448 /// declares no edge or `edge` fires, and the value taken at the last edge
449 /// otherwise.
450 ///
451 /// The **first frame a held binding is seen takes its value** whatever the
452 /// edge says, so a preset never opens on a default it did not ask for. That
453 /// is also what makes a `bar` hold correct on a silent stream, where the
454 /// counter never moves.
455 pub(super) fn hold(
456 &mut self,
457 index: usize,
458 raw: f32,
459 edge: Option<HoldEdge>,
460 frame: &AnalysisFrame,
461 time: f32,
462 ) -> f32 {
463 let Some(edge) = edge else {
464 return raw;
465 };
466 if self.slots.len() <= index {
467 self.slots.resize(index + 1, HoldSlot::default());
468 }
469 let Some(slot) = self.slots.get_mut(index) else {
470 return raw; // unreachable after the resize; never panics on the hot path
471 };
472 // What this edge measures its interval in. `beat` has none: the frame's
473 // gate is already one frame wide (`Analyzer::take_frame` makes a beat
474 // sticky between takes and clears it, so it cannot fire twice for one
475 // hop or fall between two frames).
476 let marker = match edge {
477 HoldEdge::Beat => None,
478 // Exact for every bar count a session can reach: an f32 carries
479 // integers to 2^24, which at four seconds a bar is two years of
480 // continuous play.
481 HoldEdge::Bar => Some(frame.bar_index as f32),
482 HoldEdge::Period(_) => Some(time),
483 };
484 let fired = match (slot.held, edge) {
485 (None, _) => true,
486 (Some(_), HoldEdge::Beat) => frame.beat,
487 // A change, not an increase: `bar_index` steps backward across a
488 // downbeat re-alignment, and a re-sample is the right answer there.
489 (Some(_), HoldEdge::Bar) => slot.last != marker,
490 (Some(_), HoldEdge::Period(seconds)) => {
491 slot.last.is_none_or(|last| time - last >= seconds)
492 }
493 };
494 if fired {
495 slot.held = Some(raw);
496 // The interval restarts from the frame it fired on rather than from
497 // the scheduled edge, so a long frame delays the next one instead of
498 // banking a burst of them.
499 slot.last = marker;
500 }
501 slot.held.unwrap_or(raw)
502 }
503}
504
505/// The per-binding frame state one surface of one preset carries: its easing
506/// envelope and its sample-and-hold.
507///
508/// A bundle rather than two fields wherever a smoother is held, because the two
509/// are keyed by the **same** binding index and every operation on one is an
510/// operation on the other — a reset, a rebind's remap, the hand-over to the
511/// outgoing side of a dissolve. Split across two fields, the way this goes
512/// wrong is that one of the three sites moves only one of them and a parameter
513/// starts reading its neighbour's held value.
514#[derive(Default)]
515pub(super) struct BindingState {
516 pub(super) smoother: ParamSmoother,
517 pub(super) hold: ParamHold,
518}
519
520impl BindingState {
521 /// Forget both halves, so the next frame snaps to the incoming values and
522 /// re-samples every hold.
523 pub(super) fn reset(&mut self) {
524 self.smoother.reset();
525 self.hold.reset();
526 }
527
528 /// Re-key both halves from binding order `from` to binding order `to`.
529 pub(super) fn remap(&mut self, from: &[&str], to: &[&str]) {
530 self.smoother.remap(from, to);
531 self.hold.remap(from, to);
532 }
533
534 /// Re-key both halves across a **layer** rebind.
535 pub(super) fn remap_layer(&mut self, from: &Layer, to: &Layer) {
536 self.smoother.remap_layer(from, to);
537 self.hold.remap_layer(from, to);
538 }
539}
540
541/// The roster-facing half of [`Renderer`]: replacing the preset set, moving the
542/// active index, and applying the incoming preset's structural config to its
543/// scene. An `impl Renderer` continuation for the same reason `tier_governor` is
544/// one -- these read and write `Roster` and nothing else about the device.
545impl Renderer {
546 /// Replace the preset roster (the standalone's hot-reload path). An empty
547 /// set is ignored so a preset directory that briefly reads empty — or whose
548 /// files are all malformed — leaves the last good roster rendering (NFR 10).
549 pub fn set_presets(&mut self, presets: Vec<Preset>) {
550 // The file is the durable channel and the control socket the live one
551 // (ADR-0176). A save is the durable one speaking, so whatever the file
552 // now says wins and the live overrides go with it — including on the
553 // rebind path below, which is the path a live editor's own save takes.
554 self.overrides.clear_all();
555 // A save that only rewrote expressions keeps the show running: the eased
556 // values, the armed latches and the accumulated fields all carry across,
557 // because nothing the scene was built from moved. See `rebind_roster`.
558 if self.roster.is_rebind_of(&presets) {
559 self.rebind_roster(presets);
560 return;
561 }
562 // A dissolve in flight is targeting an index in the *old* roster, which the
563 // replacement may not even have. Cancel it cleanly — the snapshot goes with
564 // it — and land on whatever `set_presets` resolves the active index to.
565 self.cancel_transition();
566 self.reset_transition_rotation();
567 self.roster.set_presets(presets);
568 self.configure_active_scene();
569 }
570
571 /// Swap in a roster that is the current one **rebound** — same presets, same
572 /// order, same systems, new expressions — without taking
573 /// [`configure_active_scene`](Self::configure_active_scene)'s reset path.
574 ///
575 /// The full path exists to stop one preset's state bleeding into another's,
576 /// and here there is no other preset: this is the same show with its
577 /// arithmetic rewritten. So the smoothers keep their eased values, the latch
578 /// bank keeps its armed windows and holds, any dissolve in flight keeps
579 /// running, and every accumulation — the trails buffer, the attractor's own
580 /// field — is simply never touched. What a live editor sees is the parameter
581 /// it changed moving and nothing else moving with it; what the full path gave
582 /// it was the whole frame snapping on every keystroke.
583 ///
584 /// The structural tables are still handed over, because a save may have moved
585 /// one: the palette is re-baked, `[feedback]` re-delivered, and `configure`
586 /// re-run. Those are idempotent for an unchanged table — the attractor
587 /// re-seeds only on a family change, and every other `configure` rebuilds
588 /// geometry that is a pure function of the table. The **layer scene** is the
589 /// one that is not, since it is constructed rather than configured
590 /// (ADR-0090), so it is rebuilt only when the layer's system actually
591 /// changed.
592 fn rebind_roster(&mut self, presets: Vec<Preset>) {
593 let active = self.roster.active;
594 let Self {
595 roster,
596 param_state,
597 layer_state,
598 latches,
599 ..
600 } = self;
601 // Read out of the OUTGOING roster, before the swap: the carried state is
602 // keyed by the index order that roster had.
603 let previous_layer = roster
604 .presets
605 .get(active)
606 .and_then(|preset| preset.layer.as_ref())
607 .map(|layer| layer.system);
608 if let (Some(prev), Some(next)) = (roster.presets.get(active), presets.get(active)) {
609 let from: Vec<&str> = prev.params.iter().map(|b| b.name.as_str()).collect();
610 let to: Vec<&str> = next.params.iter().map(|b| b.name.as_str()).collect();
611 param_state.remap(&from, &to);
612 latches.remap(&prev.latches, &next.latches);
613 match (prev.layer.as_ref(), next.layer.as_ref()) {
614 (Some(from), Some(to)) => layer_state.remap_layer(from, to),
615 // A layer that arrived or left has no state to carry either way.
616 _ => layer_state.reset(),
617 }
618 }
619 self.roster.set_presets(presets);
620 let incoming_layer = self
621 .roster
622 .active_preset()
623 .and_then(|preset| preset.layer.as_ref())
624 .map(|layer| layer.system);
625 self.hand_over_active_preset(previous_layer != incoming_layer);
626 }
627
628 /// Hold `value` on `name` until it is cleared, shadowing whatever the active
629 /// preset's binding for it evaluates to (ADR-0176).
630 ///
631 /// The name does **not** have to be one the preset binds: anything the active
632 /// system's vocabulary claims can be driven, and one it does not is refused
633 /// here rather than dropped silently at apply time. The override survives
634 /// every frame until [`clear_param_override`](Self::clear_param_override),
635 /// and is dropped wholesale by a preset switch and by
636 /// [`set_presets`](Self::set_presets).
637 ///
638 /// Not eased. A sender moving a slider is already producing a continuous
639 /// path, and a smoother between the two would make the picture lag the hand.
640 pub fn set_param_override(&mut self, name: &str, value: f32) -> Result<(), ParamError> {
641 let Some(preset) = self.roster.active_preset() else {
642 return Err(ParamError::NoActivePreset);
643 };
644 match resolve_route(name, preset.system) {
645 ParamRoute::Unclaimed => Err(ParamError::UnknownParam(name.to_owned())),
646 route => {
647 self.overrides.set(name, route, value);
648 Ok(())
649 }
650 }
651 }
652
653 /// Drop `name`'s override; its binding resumes on the next frame, easing from
654 /// wherever the smoother left it rather than from the held value. A name that
655 /// holds no override is a no-op.
656 pub fn clear_param_override(&mut self, name: &str) {
657 self.overrides.clear(name);
658 }
659
660 /// Drop every override at once.
661 pub fn clear_param_overrides(&mut self) {
662 self.overrides.clear_all();
663 }
664
665 /// Switch to the next preset; returns its name. **Dissolves** rather than cuts
666 /// (Plan 0023): the outgoing preset's composite is captured on the next frame
667 /// and blended out over `DEFAULT_DURATION_SECS` while the incoming one
668 /// renders live. Every system is built at startup, so no *scene* is
669 /// constructed here; the dissolve's opening frames do allocate its own
670 /// resources lazily — see `begin_transition`.
671 ///
672 /// The returned name is the **incoming** preset's, immediately — the frontend's
673 /// HUD should name where the show is going, not where it has been.
674 pub fn cycle_preset(&mut self) -> &str {
675 // Settle any dissolve in flight *before* reading the roster: "next" must be
676 // one past where the show is actually going, not one past where it started.
677 // Two switches arriving between two rendered frames therefore advance two
678 // presets, as two switches either side of a frame already did.
679 self.snap_finish_transition();
680 let to = self.roster.next_index();
681 self.begin_transition(to);
682 self.roster.presets.get(to).map_or("no presets", |p| {
683 // Borrowck: the roster is not flipped yet (the capture frame needs the
684 // outgoing preset active), so read the incoming name by index.
685 p.name.as_str()
686 })
687 }
688
689 /// The loaded preset names in roster order — the browse overlay's list
690 /// source (Plan 0008). Selection addresses these by absolute index.
691 pub fn preset_names(&self) -> impl Iterator<Item = &str> {
692 self.roster.names()
693 }
694
695 /// Switch to the preset at `index` (its absolute position in
696 /// [`preset_names`](Self::preset_names)); returns the incoming name. Like
697 /// [`cycle_preset`](Self::cycle_preset) this **dissolves** rather than cuts
698 /// (Plan 0023 Phase 5) — the browse overlay's select is a switch the operator
699 /// watches, so it gets the same treatment as Space. An out-of-range `index` is
700 /// a no-op (never a panic, never a wrap), so a stale index from a shrunk
701 /// hot-reloaded roster is harmless.
702 ///
703 /// Use [`select_preset_now`](Self::select_preset_now) where a blend would be
704 /// wrong rather than merely unwanted.
705 pub fn select_preset(&mut self, index: usize) -> &str {
706 self.begin_transition(index);
707 // A dissolve has not flipped the roster yet — the opening frame still
708 // composites the outgoing preset — so name the incoming one by index, as
709 // `cycle_preset` does. `begin_transition` cuts instantly when the index is
710 // already active, and no-ops when it is out of range; either way the roster
711 // *is* the answer then.
712 match self.transition.as_ref().map(Transition::incoming_index) {
713 Some(to) => self
714 .roster
715 .presets
716 .get(to)
717 .map_or("no presets", |p| &p.name),
718 None => self.preset_name(),
719 }
720 }
721
722 /// Jump to the preset at `index` with **no dissolve** — the instant-cut escape
723 /// for paths where a blend is wrong rather than unwanted: a capture, which must
724 /// stay a pure function of its inputs (NFR §6), or a test placing the roster on
725 /// a known preset before measuring. Returns the now-active name; an
726 /// out-of-range `index` is a no-op.
727 pub fn select_preset_now(&mut self, index: usize) -> &str {
728 self.select_preset_instantly(index);
729 self.preset_name()
730 }
731
732 /// Make the preset named `name` active, returning whether it was found — the
733 /// by-name form of [`select_preset`](Self::select_preset), and like it a
734 /// **dissolve**. An unknown name leaves the active preset unchanged.
735 pub fn select_preset_by_name(&mut self, name: &str) -> bool {
736 let Some(index) = self.preset_names().position(|n| n == name) else {
737 return false;
738 };
739 self.select_preset(index);
740 true
741 }
742
743 /// The instant-cut form of [`select_preset_by_name`](Self::select_preset_by_name),
744 /// used by the capture entry points below.
745 pub(super) fn select_preset_by_name_now(&mut self, name: &str) -> bool {
746 let Some(index) = self.preset_names().position(|n| n == name) else {
747 return false;
748 };
749 self.select_preset_instantly(index);
750 true
751 }
752 /// Apply the active preset's declarative structural config to its scene, if
753 /// it has one (ADR-0007). Called once whenever the active preset changes —
754 /// on select/cycle/hot-reload and after a capture rebuilds the scenes — so a
755 /// generator builds and caches its geometry exactly once, off the hot path.
756 /// A `None` config (fragment/swarm, or a curve on the family default) is a
757 /// no-op via the trait's default `configure`.
758 pub(super) fn configure_active_scene(&mut self) {
759 // Snap the eased params to the incoming preset's first values — no
760 // cross-preset bleed, and determinism across capture rebuilds (ADR-0019).
761 // The latch bank resets on the same beat and for the same two reasons:
762 // an armed window must not cross a preset switch, and a capture has to
763 // stay a pure function of its inputs (NFR 6).
764 self.param_state.reset();
765 self.layer_state.reset();
766 self.latches.reset();
767 // A switch also drops whatever a control surface was holding: an override
768 // names a parameter on the preset it was set against, and the same name
769 // on the incoming preset is a different author's decision (ADR-0176).
770 self.overrides.clear_all();
771 self.hand_over_active_preset(true);
772 }
773
774 /// The hand-over itself, without the resets: the baked palette, the
775 /// `[feedback]` table and the structural `configure`, delivered to the side
776 /// that will draw this preset.
777 ///
778 /// `rebuild_layer` constructs a fresh `[layer]` scene, which is what a preset
779 /// **change** needs (ADR-0090 point 4: a layer is built for the preset, never
780 /// resolved from the roster). A rebind passes `false` where the layer's
781 /// system did not move, and the standing instance is re-handed the same three
782 /// things the main scene is.
783 fn hand_over_active_preset(&mut self, rebuild_layer: bool) {
784 let Self {
785 ctx,
786 scenes,
787 roster,
788 cap_overflow,
789 side,
790 incoming_side,
791 tier,
792 budget,
793 ..
794 } = self;
795 *cap_overflow = None;
796 let Some(preset) = roster.active_preset() else {
797 return;
798 };
799 let Some(scene) = scene_for_mut(scenes, preset.system) else {
800 return;
801 };
802 // Bake the preset's color palette (default `spectrum` if it declares no
803 // `[palette]`) and hand it to the active scene (ADR-0021), off the hot
804 // path. A shader-colored scene stores the LUT and uploads it next frame;
805 // the spectrum readout samples it on the CPU per element (Plan 0034); the
806 // other line scenes ignore it. `spectrum` reproduces the prior cosine, so a
807 // palette-less preset is visually unchanged. A `[palette_b]` bakes an A/B
808 // pair for the bindable `palette_mix` crossfade.
809 let baked = match (preset.palette.as_ref(), preset.palette_b.as_ref()) {
810 (Some(a), Some(b)) => Palette::bake_pair(a, b),
811 (Some(a), None) => Palette::bake(a),
812 (None, Some(b)) => Palette::bake_pair(
813 &crate::render::palette::PaletteConfig::default_spectrum(),
814 b,
815 ),
816 (None, None) => Palette::default_spectrum(),
817 };
818 scene.set_palette(&baked);
819 // The backdrop colours through the same bake (ADR-0086) — one gradient,
820 // two consumers, no second bake and no drift.
821 //
822 // It goes to the side that will actually **draw** this preset. During a
823 // dissolve that is `incoming_side`, which this call precedes by one frame
824 // (the roster flips at the end of the capture frame); `side` is still
825 // painting the outgoing preset's backdrop and keeps the gradient it was
826 // given, until `promote_incoming_side` makes the incoming one *the* side.
827 let live = incoming_side.as_mut().unwrap_or(side);
828 live.background.set_palette(&baked);
829 // The `[feedback]` table (ADR-0048), to the same side and for the same
830 // reason: it is this preset's structural choice, and the outgoing side
831 // keeps the one it is still painting with. Handed over unconditionally —
832 // a preset with no table hands the default, which is what stops the
833 // previous preset's warp surviving a switch.
834 live.chain.set_feedback(preset.feedback);
835 // ...and to the scene, which is the SECOND sink of the same table
836 // (ADR-0048): the attractor's internal trail. Unconditional for the same
837 // reason, and a no-op for every other scene.
838 scene.set_feedback(preset.feedback);
839 // Structural config (ADR-0007), if any: capture segment-cap truncation so
840 // the frontend can surface it (never a silent cut). `None` for the
841 // fit/no-config case.
842 if let Some(cfg) = preset.config.as_ref() {
843 *cap_overflow = scene.configure(cfg);
844 }
845 // The layer's scene is **constructed for the preset** (ADR-0090 point
846 // 4, Plan 0076 Phase 2), never resolved from the one-instance-per-
847 // system roster — same-system pairs are legal, and two dissolving
848 // sides' layers share nothing. It goes to the side that will draw this
849 // preset (`live`, exactly like the palette and feedback hand-offs
850 // above), constructed fresh at every preset change: a switch is off
851 // the hot path, and a fresh deterministic seed is the same contract
852 // the roster scenes get from the capture rebuild. Its load-time
853 // hand-offs mirror the main scene's — the **shared** palette bake (one
854 // gradient, two layers, one world), the default `[feedback]` table (a
855 // layer declares none), and its own structural config, whose cap
856 // overflow surfaces through the same channel when the main scene
857 // produced none (never a silent cut).
858 let build_layer = |layer: &Layer, cap_overflow: &mut Option<CapOverflow>| {
859 let mut layer_scene =
860 // The **same** ceiling the main scene was built against: a
861 // `[layer]` may itself be an attractor, and a layer resolving a
862 // different budget than the preset beside it would be two
863 // densities in one frame.
864 scenes::create_layer_scene(
865 layer.system,
866 &ctx.device,
867 COMPOSITE_FORMAT,
868 tier,
869 *budget,
870 );
871 layer_scene.set_palette(&baked);
872 layer_scene.set_feedback(crate::render::feedback::FeedbackConfig::default());
873 if let Some(cfg) = layer.config.as_ref() {
874 let overflow = layer_scene.configure(cfg);
875 if cap_overflow.is_none() {
876 *cap_overflow = overflow;
877 }
878 }
879 layer_scene
880 };
881 match (rebuild_layer, preset.layer.as_ref(), live.layer.as_mut()) {
882 // The rebind case: the standing scene keeps its state and takes the
883 // same three hand-offs a fresh one would have been built with.
884 (false, Some(layer), Some(layer_scene)) => {
885 layer_scene.set_palette(&baked);
886 layer_scene.set_feedback(crate::render::feedback::FeedbackConfig::default());
887 if let Some(cfg) = layer.config.as_ref() {
888 let overflow = layer_scene.configure(cfg);
889 if cap_overflow.is_none() {
890 *cap_overflow = overflow;
891 }
892 }
893 }
894 _ => {
895 live.layer = preset
896 .layer
897 .as_ref()
898 .map(|layer| build_layer(layer, cap_overflow));
899 }
900 }
901 // The `over` junction's presence and blend mode (ADR-0090 / Plan 0076
902 // Phase 3), handed over unconditionally like the `[feedback]` table
903 // above: a preset with an `under` (or no) layer hands `None`, which
904 // also frees the junction's two full-frame inputs.
905 live.chain.set_layer_join(
906 preset
907 .layer
908 .as_ref()
909 .and_then(|layer| (layer.join == LayerJoin::Over).then_some(layer.blend)),
910 );
911 }
912}