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 passes `raw` through unchanged. `dt` is finite and positive,
333    /// the frame delta `sanitize_frame_dt` hands over (ADR-0191). The first
334    /// frame after a reset seeds the state with `raw` (a snap).
335    pub(super) fn smooth(&mut self, index: usize, raw: f32, tau: Easing, dt: f32) -> f32 {
336        if self.last.len() <= index {
337            self.last.resize(index + 1, None);
338        }
339        let Some(slot) = self.last.get_mut(index) else {
340            return raw; // unreachable after the resize; never panics on the hot path
341        };
342        // The arithmetic itself lives on `Easing` (Plan 0034 Phase 3), so the
343        // spectrum scene's per-element smoother eases by the same rule rather
344        // than growing a second easing vocabulary beside this one.
345        //
346        // No history in the slot means this parameter has never been eased under
347        // this preset -- the first frame after a reset, or a binding a rebind
348        // brought in -- and it snaps.
349        let next = match *slot {
350            Some(prev) => tau.step(prev, raw, dt),
351            None => raw,
352        };
353        *slot = Some(next);
354        next
355    }
356}
357
358/// One held binding's state between frames.
359#[derive(Clone, Copy, Default)]
360struct HoldSlot {
361    /// The value the scene is being shown. `None` is **never sampled**, which
362    /// is what the first frame under a preset sees and what a
363    /// [`remap`](ParamHold::remap) leaves in a slot whose parameter had no
364    /// counterpart in the outgoing preset.
365    held: Option<f32>,
366    /// What the last re-sample measured its interval in: the analysis frame's
367    /// bar counter for [`HoldEdge::Bar`], the elapsed clock for
368    /// [`HoldEdge::Period`]. Unused for [`HoldEdge::Beat`], whose edge is the
369    /// frame's own one-frame gate rather than an interval.
370    last: Option<f32>,
371}
372
373/// Render-layer sample-and-hold over evaluated parameter values (ADR-0180
374/// rule 2). A binding listed in the preset's `[hold]` table is still evaluated
375/// every frame — the evaluator stays a pure, stateless function of its
376/// [`Variables`] — and this decides which frame's value the scene is shown.
377///
378/// **Beside [`ParamSmoother`] and keyed the same way**, by binding index into
379/// the active preset's name-sorted `params`, because a hold that drifted out of
380/// step with the binding it holds would show one parameter another's held
381/// value. The two are carried together in [`BindingState`] so neither can be
382/// reset, remapped or handed to a dissolve without the other.
383///
384/// Applied **between** `expr.eval` and the smoother: `[smoothing]` then eases
385/// toward the held value exactly as it eases toward any other, so a parameter
386/// that is both held and smoothed travels to each new value rather than
387/// stepping to it.
388///
389/// A preset with no `[hold]` table never reaches the state below — every
390/// binding's edge is `None`, [`hold`](Self::hold) returns the raw value on a
391/// slice length check, and the `Vec` stays empty and unallocated.
392#[derive(Default)]
393pub(super) struct ParamHold {
394    /// Per binding index; grown lazily and only for a binding that declares an
395    /// edge. Cleared on reset.
396    slots: Vec<HoldSlot>,
397}
398
399impl ParamHold {
400    /// Forget every held value so the next frame re-samples.
401    pub(super) fn reset(&mut self) {
402        self.slots.clear();
403    }
404
405    /// Re-key the carried state from binding order `from` to binding order
406    /// `to`, **by name** — [`ParamSmoother::remap`]'s contract, for its reason
407    /// and at its cost. A name with no counterpart lands on a fresh slot and
408    /// so re-samples on its first frame, which is where a reset would have left
409    /// it.
410    pub(super) fn remap(&mut self, from: &[&str], to: &[&str]) {
411        let carried: Vec<HoldSlot> = to
412            .iter()
413            .map(|name| {
414                from.iter()
415                    .position(|prev| prev == name)
416                    .and_then(|index| self.slots.get(index).copied())
417                    .unwrap_or_default()
418            })
419            .collect();
420        self.slots = carried;
421    }
422
423    /// [`remap`](Self::remap) for a **layer's** hold, whose slots are the
424    /// layer's params followed by one more for the bindable `mix` — the same
425    /// shape, and the same hand-carried extra slot, as
426    /// [`ParamSmoother::remap_layer`].
427    pub(super) fn remap_layer(&mut self, from: &Layer, to: &Layer) {
428        let from_names: Vec<&str> = from.params.iter().map(|b| b.name.as_str()).collect();
429        let to_names: Vec<&str> = to.params.iter().map(|b| b.name.as_str()).collect();
430        let mix = self.slots.get(from_names.len()).copied();
431        self.remap(&from_names, &to_names);
432        if from.mix.is_some() && to.mix.is_some() {
433            self.slots.resize(to_names.len() + 1, HoldSlot::default());
434            if let (Some(slot), Some(mix)) = (self.slots.get_mut(to_names.len()), mix) {
435                *slot = mix;
436            }
437        }
438    }
439
440    /// The value carried in `index`'s slot, or `None` where nothing has been
441    /// held in it yet — [`ParamSmoother::carried`]'s counterpart, so a test can
442    /// assert the state rather than infer it from a rendered frame.
443    #[cfg(test)]
444    pub(super) fn carried(&self, index: usize) -> Option<f32> {
445        self.slots.get(index).and_then(|slot| slot.held)
446    }
447
448    /// The value binding `index` shows this frame: `raw` where the binding
449    /// declares no edge or `edge` fires, and the value taken at the last edge
450    /// otherwise.
451    ///
452    /// The **first frame a held binding is seen takes its value** whatever the
453    /// edge says, so a preset never opens on a default it did not ask for. That
454    /// is also what makes a `bar` hold correct on a silent stream, where the
455    /// counter never moves.
456    pub(super) fn hold(
457        &mut self,
458        index: usize,
459        raw: f32,
460        edge: Option<HoldEdge>,
461        frame: &AnalysisFrame,
462        time: f32,
463    ) -> f32 {
464        let Some(edge) = edge else {
465            return raw;
466        };
467        if self.slots.len() <= index {
468            self.slots.resize(index + 1, HoldSlot::default());
469        }
470        let Some(slot) = self.slots.get_mut(index) else {
471            return raw; // unreachable after the resize; never panics on the hot path
472        };
473        // What this edge measures its interval in. `beat` has none: the frame's
474        // gate is already one frame wide (`Analyzer::take_frame` makes a beat
475        // sticky between takes and clears it, so it cannot fire twice for one
476        // hop or fall between two frames).
477        let marker = match edge {
478            HoldEdge::Beat => None,
479            // Exact for every bar count a session can reach: an f32 carries
480            // integers to 2^24, which at four seconds a bar is two years of
481            // continuous play.
482            HoldEdge::Bar => Some(frame.bar_index as f32),
483            HoldEdge::Period(_) => Some(time),
484        };
485        let fired = match (slot.held, edge) {
486            (None, _) => true,
487            (Some(_), HoldEdge::Beat) => frame.beat,
488            // A change, not an increase: `bar_index` steps backward across a
489            // downbeat re-alignment, and a re-sample is the right answer there.
490            (Some(_), HoldEdge::Bar) => slot.last != marker,
491            (Some(_), HoldEdge::Period(seconds)) => {
492                slot.last.is_none_or(|last| time - last >= seconds)
493            }
494        };
495        if fired {
496            slot.held = Some(raw);
497            // The interval restarts from the frame it fired on rather than from
498            // the scheduled edge, so a long frame delays the next one instead of
499            // banking a burst of them.
500            slot.last = marker;
501        }
502        slot.held.unwrap_or(raw)
503    }
504}
505
506/// The per-binding frame state one surface of one preset carries: its easing
507/// envelope and its sample-and-hold.
508///
509/// A bundle rather than two fields wherever a smoother is held, because the two
510/// are keyed by the **same** binding index and every operation on one is an
511/// operation on the other — a reset, a rebind's remap, the hand-over to the
512/// outgoing side of a dissolve. Split across two fields, the way this goes
513/// wrong is that one of the three sites moves only one of them and a parameter
514/// starts reading its neighbour's held value.
515#[derive(Default)]
516pub(super) struct BindingState {
517    pub(super) smoother: ParamSmoother,
518    pub(super) hold: ParamHold,
519}
520
521impl BindingState {
522    /// Forget both halves, so the next frame snaps to the incoming values and
523    /// re-samples every hold.
524    pub(super) fn reset(&mut self) {
525        self.smoother.reset();
526        self.hold.reset();
527    }
528
529    /// Re-key both halves from binding order `from` to binding order `to`.
530    pub(super) fn remap(&mut self, from: &[&str], to: &[&str]) {
531        self.smoother.remap(from, to);
532        self.hold.remap(from, to);
533    }
534
535    /// Re-key both halves across a **layer** rebind.
536    pub(super) fn remap_layer(&mut self, from: &Layer, to: &Layer) {
537        self.smoother.remap_layer(from, to);
538        self.hold.remap_layer(from, to);
539    }
540}
541
542/// The roster-facing half of [`Renderer`]: replacing the preset set, moving the
543/// active index, and applying the incoming preset's structural config to its
544/// scene. An `impl Renderer` continuation for the same reason `tier_governor` is
545/// one -- these read and write `Roster` and nothing else about the device.
546impl Renderer {
547    /// Replace the preset roster (the standalone's hot-reload path). An empty
548    /// set is ignored so a preset directory that briefly reads empty — or whose
549    /// files are all malformed — leaves the last good roster rendering (NFR 10).
550    pub fn set_presets(&mut self, presets: Vec<Preset>) {
551        // The file is the durable channel and the control socket the live one
552        // (ADR-0176). A save is the durable one speaking, so whatever the file
553        // now says wins and the live overrides go with it — including on the
554        // rebind path below, which is the path a live editor's own save takes.
555        self.overrides.clear_all();
556        // A save that only rewrote expressions keeps the show running: the eased
557        // values, the armed latches and the accumulated fields all carry across,
558        // because nothing the scene was built from moved. See `rebind_roster`.
559        if self.roster.is_rebind_of(&presets) {
560            self.rebind_roster(presets);
561            return;
562        }
563        // A dissolve in flight is targeting an index in the *old* roster, which the
564        // replacement may not even have. Cancel it cleanly — the snapshot goes with
565        // it — and land on whatever `set_presets` resolves the active index to.
566        self.cancel_transition();
567        self.reset_transition_rotation();
568        self.roster.set_presets(presets);
569        self.configure_active_scene();
570    }
571
572    /// Swap in a roster that is the current one **rebound** — same presets, same
573    /// order, same systems, new expressions — without taking
574    /// [`configure_active_scene`](Self::configure_active_scene)'s reset path.
575    ///
576    /// The full path exists to stop one preset's state bleeding into another's,
577    /// and here there is no other preset: this is the same show with its
578    /// arithmetic rewritten. So the smoothers keep their eased values, the latch
579    /// bank keeps its armed windows and holds, any dissolve in flight keeps
580    /// running, and every accumulation — the trails buffer, the attractor's own
581    /// field — is simply never touched. What a live editor sees is the parameter
582    /// it changed moving and nothing else moving with it; what the full path gave
583    /// it was the whole frame snapping on every keystroke.
584    ///
585    /// The structural tables are still handed over, because a save may have moved
586    /// one: the palette is re-baked, `[feedback]` re-delivered, and `configure`
587    /// re-run. Those are idempotent for an unchanged table — the attractor
588    /// re-seeds only on a family change, and every other `configure` rebuilds
589    /// geometry that is a pure function of the table. The **layer scene** is the
590    /// one that is not, since it is constructed rather than configured
591    /// (ADR-0090), so it is rebuilt only when the layer's system actually
592    /// changed.
593    fn rebind_roster(&mut self, presets: Vec<Preset>) {
594        let active = self.roster.active;
595        let Self {
596            roster,
597            param_state,
598            layer_state,
599            latches,
600            ..
601        } = self;
602        // Read out of the OUTGOING roster, before the swap: the carried state is
603        // keyed by the index order that roster had.
604        let previous_layer = roster
605            .presets
606            .get(active)
607            .and_then(|preset| preset.layer.as_ref())
608            .map(|layer| layer.system);
609        if let (Some(prev), Some(next)) = (roster.presets.get(active), presets.get(active)) {
610            let from: Vec<&str> = prev.params.iter().map(|b| b.name.as_str()).collect();
611            let to: Vec<&str> = next.params.iter().map(|b| b.name.as_str()).collect();
612            param_state.remap(&from, &to);
613            latches.remap(&prev.latches, &next.latches);
614            match (prev.layer.as_ref(), next.layer.as_ref()) {
615                (Some(from), Some(to)) => layer_state.remap_layer(from, to),
616                // A layer that arrived or left has no state to carry either way.
617                _ => layer_state.reset(),
618            }
619        }
620        self.roster.set_presets(presets);
621        let incoming_layer = self
622            .roster
623            .active_preset()
624            .and_then(|preset| preset.layer.as_ref())
625            .map(|layer| layer.system);
626        self.hand_over_active_preset(previous_layer != incoming_layer);
627    }
628
629    /// Hold `value` on `name` until it is cleared, shadowing whatever the active
630    /// preset's binding for it evaluates to (ADR-0176).
631    ///
632    /// The name does **not** have to be one the preset binds: anything the active
633    /// system's vocabulary claims can be driven, and one it does not is refused
634    /// here rather than dropped silently at apply time. The override survives
635    /// every frame until [`clear_param_override`](Self::clear_param_override),
636    /// and is dropped wholesale by a preset switch and by
637    /// [`set_presets`](Self::set_presets).
638    ///
639    /// Not eased. A sender moving a slider is already producing a continuous
640    /// path, and a smoother between the two would make the picture lag the hand.
641    pub fn set_param_override(&mut self, name: &str, value: f32) -> Result<(), ParamError> {
642        let Some(preset) = self.roster.active_preset() else {
643            return Err(ParamError::NoActivePreset);
644        };
645        match resolve_route(name, preset.system) {
646            ParamRoute::Unclaimed => Err(ParamError::UnknownParam(name.to_owned())),
647            route => {
648                self.overrides.set(name, route, value);
649                Ok(())
650            }
651        }
652    }
653
654    /// Drop `name`'s override; its binding resumes on the next frame, easing from
655    /// wherever the smoother left it rather than from the held value. A name that
656    /// holds no override is a no-op.
657    pub fn clear_param_override(&mut self, name: &str) {
658        self.overrides.clear(name);
659    }
660
661    /// Drop every override at once.
662    pub fn clear_param_overrides(&mut self) {
663        self.overrides.clear_all();
664    }
665
666    /// Switch to the next preset; returns its name. **Dissolves** rather than cuts
667    /// (Plan 0023): the outgoing preset's composite is captured on the next frame
668    /// and blended out over `DEFAULT_DURATION_SECS` while the incoming one
669    /// renders live. Every system is built at startup, so no *scene* is
670    /// constructed here; the dissolve's opening frames do allocate its own
671    /// resources lazily — see `begin_transition`.
672    ///
673    /// The returned name is the **incoming** preset's, immediately — the frontend's
674    /// HUD should name where the show is going, not where it has been.
675    pub fn cycle_preset(&mut self) -> &str {
676        // Settle any dissolve in flight *before* reading the roster: "next" must be
677        // one past where the show is actually going, not one past where it started.
678        // Two switches arriving between two rendered frames therefore advance two
679        // presets, as two switches either side of a frame already did.
680        self.snap_finish_transition();
681        let to = self.roster.next_index();
682        self.begin_transition(to);
683        self.roster.presets.get(to).map_or("no presets", |p| {
684            // Borrowck: the roster is not flipped yet (the capture frame needs the
685            // outgoing preset active), so read the incoming name by index.
686            p.name.as_str()
687        })
688    }
689
690    /// The loaded preset names in roster order — the browse overlay's list
691    /// source (Plan 0008). Selection addresses these by absolute index.
692    pub fn preset_names(&self) -> impl Iterator<Item = &str> {
693        self.roster.names()
694    }
695
696    /// Switch to the preset at `index` (its absolute position in
697    /// [`preset_names`](Self::preset_names)); returns the incoming name. Like
698    /// [`cycle_preset`](Self::cycle_preset) this **dissolves** rather than cuts
699    /// (Plan 0023 Phase 5) — the browse overlay's select is a switch the operator
700    /// watches, so it gets the same treatment as Space. An out-of-range `index` is
701    /// a no-op (never a panic, never a wrap), so a stale index from a shrunk
702    /// hot-reloaded roster is harmless.
703    ///
704    /// Use [`select_preset_now`](Self::select_preset_now) where a blend would be
705    /// wrong rather than merely unwanted.
706    pub fn select_preset(&mut self, index: usize) -> &str {
707        self.begin_transition(index);
708        // A dissolve has not flipped the roster yet — the opening frame still
709        // composites the outgoing preset — so name the incoming one by index, as
710        // `cycle_preset` does. `begin_transition` cuts instantly when the index is
711        // already active, and no-ops when it is out of range; either way the roster
712        // *is* the answer then.
713        match self.transition.as_ref().map(Transition::incoming_index) {
714            Some(to) => self
715                .roster
716                .presets
717                .get(to)
718                .map_or("no presets", |p| &p.name),
719            None => self.preset_name(),
720        }
721    }
722
723    /// Jump to the preset at `index` with **no dissolve** — the instant-cut escape
724    /// for paths where a blend is wrong rather than unwanted: a capture, which must
725    /// stay a pure function of its inputs (NFR §6), or a test placing the roster on
726    /// a known preset before measuring. Returns the now-active name; an
727    /// out-of-range `index` is a no-op.
728    pub fn select_preset_now(&mut self, index: usize) -> &str {
729        self.select_preset_instantly(index);
730        self.preset_name()
731    }
732
733    /// Make the preset named `name` active, returning whether it was found — the
734    /// by-name form of [`select_preset`](Self::select_preset), and like it a
735    /// **dissolve**. An unknown name leaves the active preset unchanged.
736    pub fn select_preset_by_name(&mut self, name: &str) -> bool {
737        let Some(index) = self.preset_names().position(|n| n == name) else {
738            return false;
739        };
740        self.select_preset(index);
741        true
742    }
743
744    /// The instant-cut form of [`select_preset_by_name`](Self::select_preset_by_name),
745    /// used by the capture entry points below.
746    pub(super) fn select_preset_by_name_now(&mut self, name: &str) -> bool {
747        let Some(index) = self.preset_names().position(|n| n == name) else {
748            return false;
749        };
750        self.select_preset_instantly(index);
751        true
752    }
753    /// Apply the active preset's declarative structural config to its scene, if
754    /// it has one (ADR-0007). Called once whenever the active preset changes —
755    /// on select/cycle/hot-reload and after a capture rebuilds the scenes — so a
756    /// generator builds and caches its geometry exactly once, off the hot path.
757    /// A `None` config (fragment/swarm, or a curve on the family default) is a
758    /// no-op via the trait's default `configure`.
759    pub(super) fn configure_active_scene(&mut self) {
760        // Snap the eased params to the incoming preset's first values — no
761        // cross-preset bleed, and determinism across capture rebuilds (ADR-0019).
762        // The latch bank resets on the same beat and for the same two reasons:
763        // an armed window must not cross a preset switch, and a capture has to
764        // stay a pure function of its inputs (NFR 6).
765        self.param_state.reset();
766        self.layer_state.reset();
767        self.latches.reset();
768        // A switch also drops whatever a control surface was holding: an override
769        // names a parameter on the preset it was set against, and the same name
770        // on the incoming preset is a different author's decision (ADR-0176).
771        self.overrides.clear_all();
772        self.hand_over_active_preset(true);
773    }
774
775    /// The hand-over itself, without the resets: the baked palette, the
776    /// `[feedback]` table and the structural `configure`, delivered to the side
777    /// that will draw this preset.
778    ///
779    /// `rebuild_layer` constructs a fresh `[layer]` scene, which is what a preset
780    /// **change** needs (ADR-0090 point 4: a layer is built for the preset, never
781    /// resolved from the roster). A rebind passes `false` where the layer's
782    /// system did not move, and the standing instance is re-handed the same three
783    /// things the main scene is.
784    fn hand_over_active_preset(&mut self, rebuild_layer: bool) {
785        let Self {
786            ctx,
787            scenes,
788            roster,
789            cap_overflow,
790            side,
791            incoming_side,
792            tier,
793            budget,
794            ..
795        } = self;
796        *cap_overflow = None;
797        let Some(preset) = roster.active_preset() else {
798            return;
799        };
800        let Some(scene) = scene_for_mut(scenes, preset.system) else {
801            return;
802        };
803        // Bake the preset's color palette (default `spectrum` if it declares no
804        // `[palette]`) and hand it to the active scene (ADR-0021), off the hot
805        // path. A shader-colored scene stores the LUT and uploads it next frame;
806        // the spectrum readout samples it on the CPU per element (Plan 0034); the
807        // other line scenes ignore it. `spectrum` reproduces the prior cosine, so a
808        // palette-less preset is visually unchanged. A `[palette_b]` bakes an A/B
809        // pair for the bindable `palette_mix` crossfade.
810        let baked = match (preset.palette.as_ref(), preset.palette_b.as_ref()) {
811            (Some(a), Some(b)) => Palette::bake_pair(a, b),
812            (Some(a), None) => Palette::bake(a),
813            (None, Some(b)) => Palette::bake_pair(
814                &crate::render::palette::PaletteConfig::default_spectrum(),
815                b,
816            ),
817            (None, None) => Palette::default_spectrum(),
818        };
819        scene.set_palette(&baked);
820        // The backdrop colours through the same bake (ADR-0086) — one gradient,
821        // two consumers, no second bake and no drift.
822        //
823        // It goes to the side that will actually **draw** this preset. During a
824        // dissolve that is `incoming_side`, which this call precedes by one frame
825        // (the roster flips at the end of the capture frame); `side` is still
826        // painting the outgoing preset's backdrop and keeps the gradient it was
827        // given, until `promote_incoming_side` makes the incoming one *the* side.
828        let live = incoming_side.as_mut().unwrap_or(side);
829        live.background.set_palette(&baked);
830        // The `[feedback]` table (ADR-0048), to the same side and for the same
831        // reason: it is this preset's structural choice, and the outgoing side
832        // keeps the one it is still painting with. Handed over unconditionally —
833        // a preset with no table hands the default, which is what stops the
834        // previous preset's warp surviving a switch.
835        live.chain.set_feedback(preset.feedback);
836        // ...and to the scene, which is the SECOND sink of the same table
837        // (ADR-0048): the attractor's internal trail. Unconditional for the same
838        // reason, and skipped outright by a scene that keeps no accumulation of
839        // its own, which it says by answering `None` (ADR-0238).
840        if let Some(sink) = scene.as_feedback_sink() {
841            sink.set_feedback(preset.feedback);
842        }
843        // Structural config (ADR-0007), if any: capture segment-cap truncation so
844        // the frontend can surface it (never a silent cut). `None` for the
845        // fit/no-config case.
846        if let Some(cfg) = preset.config.as_ref() {
847            *cap_overflow = scene.configure(cfg);
848        }
849        // The layer's scene is **constructed for the preset** (ADR-0090 point
850        // 4, Plan 0076 Phase 2), never resolved from the one-instance-per-
851        // system roster — same-system pairs are legal, and two dissolving
852        // sides' layers share nothing. It goes to the side that will draw this
853        // preset (`live`, exactly like the palette and feedback hand-offs
854        // above), constructed fresh at every preset change: a switch is off
855        // the hot path, and a fresh deterministic seed is the same contract
856        // the roster scenes get from the capture rebuild. Its load-time
857        // hand-offs mirror the main scene's — the **shared** palette bake (one
858        // gradient, two layers, one world), the default `[feedback]` table (a
859        // layer declares none), and its own structural config, whose cap
860        // overflow surfaces through the same channel when the main scene
861        // produced none (never a silent cut).
862        let build_layer = |layer: &Layer, cap_overflow: &mut Option<CapOverflow>| {
863            let mut layer_scene =
864                // The **same** ceiling the main scene was built against: a
865                // `[layer]` may itself be an attractor, and a layer resolving a
866                // different budget than the preset beside it would be two
867                // densities in one frame.
868                scenes::create_layer_scene(
869                    layer.system,
870                    &ctx.device,
871                    COMPOSITE_FORMAT,
872                    tier,
873                    *budget,
874                );
875            layer_scene.set_palette(&baked);
876            if let Some(sink) = layer_scene.as_feedback_sink() {
877                sink.set_feedback(crate::render::feedback::FeedbackConfig::default());
878            }
879            if let Some(cfg) = layer.config.as_ref() {
880                let overflow = layer_scene.configure(cfg);
881                if cap_overflow.is_none() {
882                    *cap_overflow = overflow;
883                }
884            }
885            layer_scene
886        };
887        match (rebuild_layer, preset.layer.as_ref(), live.layer.as_mut()) {
888            // The rebind case: the standing scene keeps its state and takes the
889            // same three hand-offs a fresh one would have been built with.
890            (false, Some(layer), Some(layer_scene)) => {
891                layer_scene.set_palette(&baked);
892                if let Some(sink) = layer_scene.as_feedback_sink() {
893                    sink.set_feedback(crate::render::feedback::FeedbackConfig::default());
894                }
895                if let Some(cfg) = layer.config.as_ref() {
896                    let overflow = layer_scene.configure(cfg);
897                    if cap_overflow.is_none() {
898                        *cap_overflow = overflow;
899                    }
900                }
901            }
902            _ => {
903                live.layer = preset
904                    .layer
905                    .as_ref()
906                    .map(|layer| build_layer(layer, cap_overflow));
907            }
908        }
909        // The `over` junction's presence and blend mode (ADR-0090 / Plan 0076
910        // Phase 3), handed over unconditionally like the `[feedback]` table
911        // above: a preset with an `under` (or no) layer hands `None`, which
912        // also frees the junction's two full-frame inputs.
913        live.chain.set_layer_join(
914            preset
915                .layer
916                .as_ref()
917                .and_then(|layer| (layer.join == LayerJoin::Over).then_some(layer.blend)),
918        );
919    }
920}