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}