Skip to main content

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}