onedrop_engine/
engine.rs

1//! Main Milkdrop engine implementation.
2//!
3//! The `update()` orchestrator lives here; the three heavyweight per-frame
4//! phases that used to dominate the file are split into sibling modules,
5//! each adding its own `impl MilkEngine` block:
6//!
7//! - [`wave_phase`] — `evaluate_custom_waves`
8//! - [`shape_phase`] — `evaluate_custom_shapes`
9//! - [`state_sync`] — `update_render_state_from_evaluator`
10//!
11//! That split keeps `engine.rs` focused on the lifecycle (`new`, `load_preset`,
12//! `update`, public getters) and supports preset transitions, where two engine
13//! instances drive the wave/shape paths in parallel.
14
15mod morph;
16mod preset_slot;
17mod shape_phase;
18mod state_sync;
19mod transition_state;
20mod wave_phase;
21
22use crate::audio::AudioAnalyzer;
23use crate::beat_detection::{BeatDetectionMode, BeatDetector, PresetChange};
24use crate::error::{EngineError, Result};
25use crate::fft::FFTAnalyzer;
26use onedrop_eval::{MilkEvaluator, Node};
27use onedrop_parser::{MilkPreset, parse_preset};
28use onedrop_renderer::{GpuContext, MilkRenderer, RenderConfig, RenderState};
29use preset_slot::{CompiledShape, CompiledWave, PresetSlot, preset_id_for, run_block_with_logger};
30use std::fs;
31use std::path::Path;
32use std::sync::Arc;
33use std::time::{Duration, Instant};
34use transition_state::TransitionState;
35use wgpu;
36
37/// Per-phase wall-clock breakdown for one `MilkEngine::update` call.
38/// Populated when `EngineConfig::profile` is `true`; exposed by
39/// `MilkEngine::last_profile()` for the bench tool and any future overlay.
40#[derive(Debug, Default, Clone)]
41pub struct FrameProfile {
42    pub audio_analyze: Duration,
43    pub spectrum_fft: Duration,
44    pub per_frame_eval: Duration,
45    pub state_sync: Duration,
46    pub warp_compute: Duration,
47    pub waveform_samples: Duration,
48    pub custom_waves: Duration,
49    pub custom_shapes: Duration,
50    pub gpu_render: Duration,
51    pub total: Duration,
52}
53
54/// FFT window size used to expose `value1`/`value2` spectrum bins to
55/// `wavecode_N` per-point equations when `b_spectrum` is set. Power of 2,
56/// large enough to give a reasonable bin density without ballooning the
57/// analyzer cost. Matches the standard MD2 FFT size.
58const SPECTRUM_FFT_SIZE: usize = 512;
59
60/// Advance one slot's per-frame state: push the shared audio levels +
61/// time + frame into its evaluator context, then run its per-frame
62/// equations. Called once for `current` and (during transitions) once
63/// for `fading_out` each frame.
64///
65/// `enable_per_frame == false` skips the equation eval but still updates
66/// the context — keeps `time` / `audio` correct in case a downstream
67/// path (warp executor, custom waves) reads them.
68fn tick_slot_evaluator(
69    slot: &mut PresetSlot,
70    audio: onedrop_renderer::AudioLevels,
71    delta_time: f32,
72    enable_per_frame: bool,
73    display_duration_s: f32,
74    track_progress_override: Option<f32>,
75) {
76    slot.state.time += delta_time;
77    slot.state.audio = audio;
78    // MD2 `progress`: when an MPRIS player exposes a track position
79    // (`ProgressSource::MprisAutoTrack` + a non-zero `mpris:length`),
80    // the override replaces the local-window computation. Otherwise we
81    // fall back to the per-preset display window: `display_duration_s
82    // == 0` keeps `progress = 0` (window disabled); the per-preset
83    // anchor in `slot.preset_loaded_time` recovers elapsed-since-load.
84    slot.state.progress = match track_progress_override {
85        Some(p) => p.clamp(0.0, 1.0),
86        None if display_duration_s > 0.0 => {
87            ((slot.state.time - slot.preset_loaded_time) / display_duration_s).clamp(0.0, 1.0)
88        }
89        None => 0.0,
90    };
91
92    let ctx = slot.evaluator.context_mut();
93    ctx.set_time(slot.state.time as f64);
94    ctx.set_frame(slot.state.frame as f64);
95    // Expose `progress` to the per-frame evaluator context. MD2 preset
96    // equations reference it directly (e.g., `wave_a = progress * 0.5`).
97    ctx.set("progress", slot.state.progress as f64);
98    ctx.set_audio(audio.bass as f64, audio.mid as f64, audio.treb as f64);
99    ctx.set("bass_att", audio.bass_att as f64);
100    ctx.set("mid_att", audio.mid_att as f64);
101    ctx.set("treb_att", audio.treb_att as f64);
102
103    if enable_per_frame {
104        // Run the pre-compiled `per_frame` block. CompiledBlock prefers
105        // the bytecode VM when its nodes lowered cleanly (~80 % of the
106        // corpus), otherwise it falls back to the evalexpr Node walk —
107        // either way we skip the regex-based reparse that the legacy
108        // `eval_per_frame(&[String])` path was doing every call.
109        // Populated at `load_preset`; empty (no-op) when no preset is
110        // active.
111        run_block_with_logger(&mut slot.evaluator, &slot.compiled_per_frame, |idx, e| {
112            log::warn!("per_frame equation[{}] failed: {}", idx, e)
113        });
114    }
115    // Frame counter is bumped by the engine after `renderer.render()` so
116    // `comp_uniforms` for this frame still see the pre-tick value (matches
117    // pre-V.3 ordering).
118}
119
120/// Main Milkdrop visualization engine.
121pub struct MilkEngine {
122    /// Renderer
123    renderer: MilkRenderer,
124
125    /// Active preset bundle: preset + evaluator + warp executor + state +
126    /// compiled wave/shape equations.
127    current: PresetSlot,
128
129    /// Outgoing preset's bundle, alive only between a `load_preset` call
130    /// and the moment the configured transition duration elapses. Driven
131    /// each frame in lock-step with `current` so its warp / wave / shape
132    /// outputs land on the renderer's secondary chain for the blend pass.
133    fading_out: Option<PresetSlot>,
134
135    /// Delta-time tracker for the in-flight transition. `None` outside
136    /// transitions. Cleared in the same frame as `fading_out` once it
137    /// reaches the configured duration.
138    transition: Option<TransitionState>,
139
140    /// Audio analyzer
141    audio_analyzer: AudioAnalyzer,
142
143    /// Beat detector for automatic preset changes
144    beat_detector: BeatDetector,
145
146    /// Engine configuration
147    config: EngineConfig,
148
149    /// Frequency-domain analyzer feeding `value1`/`value2` for
150    /// `wavecode_N` blocks with `b_spectrum = 1`. Always populated each
151    /// frame so the per-point loop pays no cost when no preset is active.
152    fft: FFTAnalyzer,
153    /// Spectrum magnitudes (size `SPECTRUM_FFT_SIZE / 2`) of the most
154    /// recent audio buffer. Reused across waves within a frame.
155    spectrum_bins: Vec<f32>,
156
157    /// Wall-clock breakdown of the most recent `update` call. `None` when
158    /// `EngineConfig::profile` is `false` (default). The bench tool flips
159    /// it on; production builds leave it off to save the `Instant::now`
160    /// reads (`Instant::elapsed` measured at ~20 ns × 9 phases = ~180 ns,
161    /// negligible but trivially avoidable).
162    last_profile: Option<FrameProfile>,
163
164    /// User-configured baseline warp mesh size, captured at engine
165    /// init. The MD1 per-pixel auto-upgrade (`md1_mesh_override`)
166    /// densifies the mesh for MD1 presets; loading an MD2+ preset
167    /// reverts to this baseline so the user's mesh-quality choice
168    /// stays honoured on the common case. Manual `set_mesh_size`
169    /// calls (e.g., GUI quality slider) update this baseline too.
170    baseline_mesh: (u32, u32),
171
172    /// MPRIS2 progress reader, when [`EngineConfig::progress_source`]
173    /// requests it and the `mpris` feature is enabled. `None` means
174    /// `progress` falls back to the local-window computation each
175    /// frame (the historical behaviour).
176    #[cfg(feature = "mpris")]
177    mpris: Option<crate::mpris::MprisPoller>,
178
179    /// Sprite (`MILK_IMG.INI`) manager: holds the parsed defs, the
180    /// active sprite list, and runs per-frame equations against each
181    /// active sprite's own evaluator. The result list is forwarded to
182    /// the renderer's `update_sprites` each frame.
183    sprites: crate::sprites::SpriteManager,
184
185    /// Message (`MILK_MSG.INI`) overlay manager. Drives the text
186    /// pipeline + the MPRIS auto-title path: when the MPRIS poller
187    /// reports a new track title, the engine spawns a transient
188    /// "Now playing — …" message.
189    messages: crate::messages::MessageManager,
190
191    /// Last MPRIS title observed — used to suppress duplicate
192    /// `show_transient` spawns on idle polls that report the same
193    /// metadata. `None` until the first non-empty title is seen.
194    #[cfg(feature = "mpris")]
195    mpris_last_title: Option<String>,
196}
197
198/// Engine configuration.
199#[derive(Debug, Clone)]
200pub struct EngineConfig {
201    /// Render configuration
202    pub render_config: RenderConfig,
203
204    /// Audio sample rate
205    pub sample_rate: f32,
206
207    /// Enable per-frame equations
208    pub enable_per_frame: bool,
209
210    /// Enable per-pixel equations
211    pub enable_per_pixel: bool,
212
213    /// Duration of the crossfade between two presets. `0.0` disables
214    /// transitions (instant cut). Default mirrors MD2's
215    /// `f_transition_time = 2.7 s`.
216    pub transition_duration_s: f32,
217
218    /// Length of a preset's display window in seconds. Drives MD2's
219    /// `progress` uniform: `progress = (time - preset_loaded_time)
220    /// / preset_display_duration_s`, clamped to `[0, 1]`. Presets
221    /// read `progress` for fade-ins, build-ups, and once-per-window
222    /// effects. Default mirrors MD2's `nPresetDisplayLengthSeconds
223    /// = 16`. Set `0.0` to disable (clamps `progress` to `0`).
224    pub preset_display_duration_s: f32,
225
226    /// Record per-phase wall-clock breakdown of every `update` call into
227    /// [`MilkEngine::last_profile`]. Used by the bench tool. Off in the
228    /// real GUI/CLI path.
229    pub profile: bool,
230
231    /// Waveform L/R top-vs-bottom split. When `true`, the static
232    /// waveform pass renders the LEFT channel on the upper screen
233    /// half and the RIGHT channel on the lower half (two dispatches
234    /// with a `±0.25` `wave_y` offset). Default `false` (single
235    /// mono trace). Surfaced at engine level rather than preset
236    /// level — MD2 had a player-side option for this, not a preset
237    /// variable.
238    pub waveform_split_lr: bool,
239
240    /// MD1-style per-pixel warp mode: when the loaded preset's version
241    /// is `< 200` (MilkDrop 1), auto-upgrade the warp mesh to this
242    /// resolution. MD1 presets routinely encode high-frequency warp
243    /// formulas (`dx = sin(40*y)`, …) that MD2's mesh-based approach
244    /// smooths over at coarse resolutions; densifying the mesh for the
245    /// lifetime of the preset restores the "looks evaluated per pixel"
246    /// feel without an actual full-resolution CPU eval (which would
247    /// run EEL2 millions of times per frame).
248    ///
249    /// MD2+ presets revert to the user's baseline
250    /// `RenderConfig::mesh_cols`/`mesh_rows` on the next load. `None`
251    /// disables the override (every preset uses the baseline).
252    ///
253    /// Default `Some((96, 72))` — a sweet spot for a true per-pixel
254    /// feel on classic MD1 presets without thrashing the warp
255    /// executor's per-vertex evaluator. Cap is `WarpMesh::new`'s
256    /// 192×96; lower if MD1 presets feel choppier than expected on
257    /// underpowered hardware.
258    pub md1_mesh_override: Option<(u32, u32)>,
259
260    /// Source for MD2's `progress` uniform. Default
261    /// [`ProgressSource::LocalWindow`] derives `progress` from
262    /// `preset_display_duration_s` (the per-preset slot timer).
263    /// [`ProgressSource::MprisAutoTrack`] asks the engine to query
264    /// the freedesktop MPRIS2 bus on a background thread and use
265    /// `Position / mpris:length` of the active player instead — so
266    /// presets that fade with `progress` track the song rather than
267    /// the slot. When the `mpris` cargo feature is off, the variant
268    /// is still accepted but the engine silently falls back to
269    /// `LocalWindow`.
270    pub progress_source: ProgressSource,
271}
272
273/// Source for the MD2 `progress` uniform.
274///
275/// `LocalWindow` is the historical behaviour — `progress` ramps from
276/// `0` at preset load to `1` after [`EngineConfig::preset_display_duration_s`]
277/// seconds. `MprisAutoTrack` instead reads the active MPRIS2 player's
278/// track position and clamps it to `[0, 1]`; presets that authoring
279/// against `progress` (build-ups, fade-ins) sync to the song. When no
280/// player is reachable or the metadata is incomplete, the variant
281/// transparently falls back to `LocalWindow` per frame.
282#[derive(Debug, Clone, Copy, PartialEq, Eq)]
283pub enum ProgressSource {
284    /// Derive `progress` from `preset_display_duration_s`.
285    LocalWindow,
286    /// Read `progress` from the active MPRIS2 player's track position;
287    /// fall back to `LocalWindow` when no player is reachable.
288    MprisAutoTrack,
289}
290
291/// Quality preset for engine configuration.
292#[derive(Debug, Clone, Copy, PartialEq, Eq)]
293pub enum QualityPreset {
294    /// Low quality - per-pixel disabled, optimized for performance
295    Low,
296    /// Medium quality - balanced settings (default)
297    Medium,
298    /// High quality - all features enabled
299    High,
300}
301
302impl EngineConfig {
303    /// Create configuration from a quality preset.
304    pub fn from_preset(preset: QualityPreset) -> Self {
305        match preset {
306            QualityPreset::Low => Self {
307                render_config: RenderConfig::default(),
308                sample_rate: 44100.0,
309                enable_per_frame: true,
310                enable_per_pixel: false,
311                transition_duration_s: 2.7,
312                preset_display_duration_s: 16.0,
313                profile: false,
314                md1_mesh_override: Some((96, 72)),
315                waveform_split_lr: false,
316                progress_source: ProgressSource::LocalWindow,
317            },
318            QualityPreset::Medium => Self {
319                render_config: RenderConfig::default(),
320                sample_rate: 44100.0,
321                enable_per_frame: true,
322                enable_per_pixel: false,
323                transition_duration_s: 2.7,
324                preset_display_duration_s: 16.0,
325                profile: false,
326                md1_mesh_override: Some((96, 72)),
327                waveform_split_lr: false,
328                progress_source: ProgressSource::LocalWindow,
329            },
330            QualityPreset::High => Self {
331                render_config: RenderConfig::default(),
332                sample_rate: 44100.0,
333                enable_per_frame: true,
334                enable_per_pixel: true,
335                transition_duration_s: 2.7,
336                preset_display_duration_s: 16.0,
337                profile: false,
338                md1_mesh_override: Some((96, 72)),
339                waveform_split_lr: false,
340                progress_source: ProgressSource::LocalWindow,
341            },
342        }
343    }
344}
345
346impl Default for EngineConfig {
347    fn default() -> Self {
348        Self::from_preset(QualityPreset::Medium)
349    }
350}
351
352impl MilkEngine {
353    /// Create a new engine.
354    pub async fn new(config: EngineConfig) -> Result<Self> {
355        let renderer = MilkRenderer::new(config.render_config.clone()).await?;
356        Self::from_renderer(renderer, config)
357    }
358
359    /// Create an engine from an existing device and queue.
360    /// This is useful when sharing a GPU context with a GUI.
361    pub fn from_device(
362        device: Arc<wgpu::Device>,
363        queue: Arc<wgpu::Queue>,
364        config: EngineConfig,
365    ) -> Result<Self> {
366        let gpu = GpuContext::from_device(device, queue, config.render_config.clone());
367        let renderer = MilkRenderer::from_gpu_context(gpu)?;
368        Self::from_renderer(renderer, config)
369    }
370
371    /// Create an engine from an existing renderer.
372    fn from_renderer(mut renderer: MilkRenderer, config: EngineConfig) -> Result<Self> {
373        let audio_analyzer = AudioAnalyzer::new(config.sample_rate);
374
375        // Populate the user-texture pool from the XDG /
376        // `~/Music/milkdrop/textures` candidate dirs. Missing dirs are
377        // silently skipped; presets that reference unknown textures fall
378        // back to the comp pipeline's 1×1 white. Engine consumers that
379        // want a custom dir (`--textures /foo`) can call
380        // `renderer.set_texture_pool` directly afterwards.
381        {
382            let device = renderer.gpu_device();
383            let queue = renderer.gpu_queue();
384            let dirs = onedrop_renderer::default_texture_dirs();
385            let pool = onedrop_renderer::TexturePool::from_dirs(&device, &queue, &dirs);
386            if !pool.is_empty() {
387                log::info!("loaded {} user textures from XDG dirs", pool.len());
388            }
389            renderer.set_texture_pool(pool);
390        }
391
392        let fft = FFTAnalyzer::new_or_default(SPECTRUM_FFT_SIZE, config.sample_rate);
393        let baseline_mesh = renderer.mesh_size();
394
395        // MPRIS poller: only spawned when feature on AND config opts in.
396        // A failure to reach the session bus (headless CI, sandboxed
397        // runtime) collapses to `None` so the engine transparently
398        // falls back to the local-window `progress`.
399        #[cfg(feature = "mpris")]
400        let mpris = match config.progress_source {
401            ProgressSource::MprisAutoTrack => crate::mpris::MprisPoller::spawn(),
402            ProgressSource::LocalWindow => None,
403        };
404
405        Ok(Self {
406            renderer,
407            current: PresetSlot::default(),
408            fading_out: None,
409            transition: None,
410            audio_analyzer,
411            beat_detector: BeatDetector::new(),
412            config,
413            fft,
414            spectrum_bins: vec![0.0; SPECTRUM_FFT_SIZE / 2],
415            last_profile: None,
416            baseline_mesh,
417            #[cfg(feature = "mpris")]
418            mpris,
419            sprites: crate::sprites::SpriteManager::new(),
420            messages: crate::messages::MessageManager::new(),
421            #[cfg(feature = "mpris")]
422            mpris_last_title: None,
423        })
424    }
425
426    /// Load a preset from file.
427    pub fn load_preset<P: AsRef<Path>>(&mut self, path: P) -> Result<()> {
428        const MAX_PRESET_SIZE: u64 = 10 * 1024 * 1024; // 10MB limit
429
430        let path_ref = path.as_ref();
431        log::info!("Loading preset: {}", path_ref.display());
432
433        // Validate file size before loading
434        let metadata = fs::metadata(path_ref).map_err(|e| {
435            log::error!("Failed to read file metadata {}: {}", path_ref.display(), e);
436            EngineError::PresetLoadFailed(format!("Cannot read file metadata: {}", e))
437        })?;
438
439        if metadata.len() > MAX_PRESET_SIZE {
440            log::error!(
441                "Preset file too large: {} bytes (max {})",
442                metadata.len(),
443                MAX_PRESET_SIZE
444            );
445            return Err(EngineError::PresetLoadFailed(format!(
446                "File too large: {} bytes (max {} bytes)",
447                metadata.len(),
448                MAX_PRESET_SIZE
449            )));
450        }
451
452        // Read file (UTF-8 lossy: presets in the wild routinely contain
453        // legacy cp1252 bytes in track names / comments — fail-open).
454        let bytes = fs::read(path_ref).map_err(|e| {
455            log::error!("Failed to read preset file {}: {}", path_ref.display(), e);
456            EngineError::PresetLoadFailed(format!("Cannot read file: {}", e))
457        })?;
458        let content = String::from_utf8_lossy(&bytes).into_owned();
459
460        // Parse preset
461        let preset = parse_preset(&content).map_err(|e| {
462            log::error!("Failed to parse preset {}: {}", path_ref.display(), e);
463            e
464        })?;
465
466        // Validate preset
467        if preset.per_frame_equations.is_empty() && preset.per_pixel_equations.is_empty() {
468            log::warn!(
469                "Preset {} has no equations, using default parameters",
470                path_ref.display()
471            );
472        }
473
474        let result = self.load_preset_from_data(preset);
475
476        // `MILK_IMG.INI` / `MILK_MSG.INI` sibling autoload — MD2
477        // looked them up in the preset directory; we do the same so
478        // user-authored sprite + message packs Just Work without an
479        // explicit API call. Failures are silent (logged) since most
480        // presets don't ship these files.
481        if let Some(dir) = path_ref.parent() {
482            self.autoload_overlay_inis(dir);
483        }
484        result
485    }
486
487    /// Look for `MILK_IMG.INI` and `MILK_MSG.INI` siblings of a preset
488    /// file and load them into the sprite + message managers.
489    /// Best-effort: missing / unparseable files log a warning and
490    /// leave the previous load untouched.
491    fn autoload_overlay_inis(&mut self, dir: &Path) {
492        // Sprites.
493        let img_path = dir.join("MILK_IMG.INI");
494        if img_path.is_file() {
495            match fs::read_to_string(&img_path) {
496                Ok(content) => match onedrop_parser::parse_milk_img_ini(&content) {
497                    Ok(defs) => {
498                        log::info!(
499                            "MILK_IMG.INI: loaded {} sprite defs from {}",
500                            defs.len(),
501                            img_path.display()
502                        );
503                        self.load_sprite_defs(defs, dir);
504                    }
505                    Err(e) => {
506                        log::warn!("MILK_IMG.INI parse failed at {}: {}", img_path.display(), e)
507                    }
508                },
509                Err(e) => log::warn!("MILK_IMG.INI read failed at {}: {}", img_path.display(), e),
510            }
511        }
512        // Messages.
513        let msg_path = dir.join("MILK_MSG.INI");
514        if msg_path.is_file() {
515            match fs::read_to_string(&msg_path) {
516                Ok(content) => match onedrop_parser::parse_milk_msg_ini(&content) {
517                    Ok(defs) => {
518                        log::info!(
519                            "MILK_MSG.INI: loaded {} message defs from {}",
520                            defs.len(),
521                            msg_path.display()
522                        );
523                        self.load_message_defs(defs);
524                    }
525                    Err(e) => {
526                        log::warn!("MILK_MSG.INI parse failed at {}: {}", msg_path.display(), e)
527                    }
528                },
529                Err(e) => log::warn!("MILK_MSG.INI read failed at {}: {}", msg_path.display(), e),
530            }
531        }
532    }
533
534    /// Load the default preset.
535    /// This is useful as a fallback when no preset is available or loading fails.
536    pub fn load_default_preset(&mut self) -> Result<()> {
537        log::info!("Loading default preset");
538        let preset = crate::default_preset::default_preset();
539        self.load_preset_from_data(preset)
540    }
541
542    /// Load a preset from parsed data.
543    ///
544    /// When `config.transition_duration_s > 0` and a preset is already
545    /// loaded, archives the current slot into `fading_out`, asks the
546    /// renderer to allocate a secondary chain for it, and starts the
547    /// transition timer. The next `update()` will tick both slots and
548    /// drive the renderer's blend pass through the eased crossfade.
549    pub fn load_preset_from_data(&mut self, preset: MilkPreset) -> Result<()> {
550        log::info!("Loading preset version {}", preset.version);
551
552        // Start a transition when there's a previous preset and the
553        // configured duration is non-zero. Drop any pending transition
554        // (rare: load called mid-fade) — the new outgoing preset is
555        // simply the current one.
556        let start_transition =
557            self.config.transition_duration_s > 0.0 && self.current.preset.is_some();
558        if start_transition {
559            self.renderer.start_transition()?;
560            let outgoing = std::mem::take(&mut self.current);
561            // Carry the global frame counter + time across preset changes
562            // so external observers (HUD, tests, user shaders sampling
563            // `time` / `frame`) see a continuous monotonic stream. Each
564            // slot still owns the rest of its `RenderState` (motion,
565            // wave, q snapshot, …) independently — only the global
566            // counters bleed across.
567            self.current = PresetSlot::default();
568            self.current.state.frame = outgoing.state.frame;
569            self.current.state.time = outgoing.state.time;
570            // `progress` resets each preset load; anchor at the
571            // current global time so `progress = (now - anchor) /
572            // duration` starts at 0 for the freshly-loaded preset.
573            self.current.preset_loaded_time = outgoing.state.time;
574            self.fading_out = Some(outgoing);
575            self.transition = Some(TransitionState::new(self.config.transition_duration_s));
576            self.renderer.set_transition_progress(0.0);
577        } else {
578            // Cut: drop any stale fading_out from a previous in-flight
579            // transition and reset the renderer's secondary chain.
580            self.fading_out = None;
581            self.transition = None;
582            self.renderer.clear_secondary_chain();
583            // Anchor `progress` to the current global time so the new
584            // preset starts at `progress = 0`.
585            self.current.preset_loaded_time = self.current.state.time;
586        }
587
588        // MD1-style per-pixel warp: densify the mesh for MilkDrop 1
589        // presets (version < 200) so high-frequency warp formulas
590        // don't get smoothed out by mesh interpolation. MD2+ presets
591        // revert to the user's baseline mesh size so the user's
592        // explicit quality preference is honoured on the common path.
593        match self.config.md1_mesh_override {
594            Some((cols, rows)) if preset.version < 200 => {
595                let cur = self.renderer.mesh_size();
596                if cur != (cols, rows) {
597                    log::info!(
598                        "MD1 preset detected (version {}); upgrading warp mesh to {}×{}",
599                        preset.version,
600                        cols,
601                        rows
602                    );
603                    self.renderer.set_mesh_size(cols, rows);
604                }
605            }
606            _ => {
607                let baseline = self.baseline_mesh;
608                if self.renderer.mesh_size() != baseline {
609                    self.renderer.set_mesh_size(baseline.0, baseline.1);
610                }
611            }
612        }
613
614        // Initialize evaluator context with preset parameters
615        self.init_evaluator_from_preset(&preset);
616
617        // Pre-compile per-vertex equations once; subsequent frames just
618        // execute the compiled `Node`s against a scratch context.
619        self.current
620            .warp_executor
621            .set_equations(&mut self.current.evaluator, &preset.per_pixel_equations);
622
623        // Translate + compile the preset's user comp shader and either
624        // swap it into the comp pass or fall back to the gamma-only
625        // default. Compile failures are non-fatal: the renderer keeps
626        // rendering, just without the user-authored composite. This
627        // matches MilkDrop's classic behaviour where presets with broken
628        // shaders are still navigable.
629        //
630        // The `preset_id` argument seeds `sampler_rand0X` deterministically.
631        // No filename is available here (`load_preset_from_data` accepts
632        // pre-parsed data) so we derive it from the comp shader content —
633        // identical comp_shaders → identical rand selection, distinct
634        // ones diverge. Real `load_preset(path)` callers get the filename
635        // path threaded into this via the `current_preset` it stores.
636        let preset_id = preset_id_for(&preset);
637        match self
638            .renderer
639            .set_user_comp_shader_for_preset(preset.comp_shader.as_deref(), &preset_id)
640        {
641            Ok(true) => {
642                if preset.comp_shader.is_some() {
643                    log::info!("user comp shader compiled and active");
644                }
645            }
646            Ok(false) => {
647                log::info!("user comp shader fell back to default — see prior warning");
648            }
649            Err(e) => return Err(EngineError::from(e)),
650        }
651
652        // Same path for the warp shader: if the preset ships one,
653        // translate + wrap + validate, then swap the warp pass over to
654        // the user pipeline. Failure falls back to the default
655        // hand-written `warp.wgsl` (decay + flags), so the render loop
656        // stays alive on any preset.
657        match self
658            .renderer
659            .set_user_warp_shader_for_preset(preset.warp_shader.as_deref(), &preset_id)
660        {
661            Ok(true) => {
662                if preset.warp_shader.is_some() {
663                    log::info!("user warp shader compiled and active");
664                }
665            }
666            Ok(false) => {
667                log::info!("user warp shader fell back to default — see prior warning");
668            }
669            Err(e) => return Err(EngineError::from(e)),
670        }
671
672        // Compile each enabled wavecode_N block's init/per_frame/per_point
673        // equations once. Eval runs cached `Node`s every frame, skipping
674        // the regex-based preprocess.
675        // Failures inside a single phase are non-fatal: the equation is
676        // dropped from the compiled list (logged) and the wave keeps
677        // running with whatever did compile. This matches MD2's
678        // permissive load behaviour.
679        self.current.compiled_waves.clear();
680        self.current.waves_need_init.clear();
681        for w in &preset.waves {
682            // init / per_frame go through CompiledBlock so they pick up
683            // the bytecode VM transparently when their nodes lower
684            // cleanly (most do — see CompiledBlock::from_nodes).
685            //
686            // Each `from_nodes` call needs `&mut evaluator.context` to
687            // intern cold-var names into the slab; structure the calls
688            // as two steps (compile_phase, then from_nodes) so the two
689            // mutable borrows of `evaluator` happen sequentially rather
690            // than nested.
691            let init_nodes =
692                Self::compile_phase(&mut self.current.evaluator, &w.per_frame_init_equations);
693            let init = onedrop_eval::CompiledBlock::from_nodes(
694                init_nodes,
695                self.current.evaluator.context_mut(),
696            );
697            let pf_nodes = Self::compile_phase(&mut self.current.evaluator, &w.per_frame_equations);
698            let per_frame = onedrop_eval::CompiledBlock::from_nodes(
699                pf_nodes,
700                self.current.evaluator.context_mut(),
701            );
702            let per_point =
703                Self::compile_phase(&mut self.current.evaluator, &w.per_point_equations);
704            // Cache the parallelism verdict for the per-point block. The
705            // analysis is a single AST walk — running it once at preset
706            // load amortises it across thousands of frames.
707            let per_point_parallelism = onedrop_eval::analyse_per_point(&per_point);
708            // Try to lower per_point into the stack-VM bytecode. Any
709            // operator/function the VM doesn't support (no opcode for
710            // it) returns Err and we fall back to evalexpr Nodes for
711            // that wave. Logged at debug — bench-only, not actionable.
712            // Kept as a separate Option (rather than CompiledBlock) so
713            // the parallel-samples branch in `compute_one_wave` can
714            // distinguish bytecode vs Nodes per worker.
715            let per_point_bytecode = match onedrop_eval::CompiledBytecode::try_compile(
716                &per_point,
717                self.current.evaluator.context_mut(),
718            ) {
719                Ok(bc) => Some(bc),
720                Err(e) => {
721                    log::debug!(
722                        "wave[{}] per_point stays on evalexpr (reason: {})",
723                        w.index,
724                        e
725                    );
726                    None
727                }
728            };
729            self.current.compiled_waves.push(CompiledWave {
730                init,
731                per_frame,
732                per_point,
733                per_point_parallelism,
734                per_point_bytecode,
735            });
736            self.current.waves_need_init.push(true);
737        }
738
739        // Same compile pattern for shapecode_N. Shapes have no per_point
740        // block (the per_frame block runs once per instance), so only two
741        // phases.
742        self.current.compiled_shapes.clear();
743        self.current.shapes_need_init.clear();
744        for s in &preset.shapes {
745            let init_nodes =
746                Self::compile_phase(&mut self.current.evaluator, &s.per_frame_init_equations);
747            let init = onedrop_eval::CompiledBlock::from_nodes(
748                init_nodes,
749                self.current.evaluator.context_mut(),
750            );
751            let pf_nodes = Self::compile_phase(&mut self.current.evaluator, &s.per_frame_equations);
752            let per_frame = onedrop_eval::CompiledBlock::from_nodes(
753                pf_nodes,
754                self.current.evaluator.context_mut(),
755            );
756            self.current
757                .compiled_shapes
758                .push(CompiledShape { init, per_frame });
759            self.current.shapes_need_init.push(true);
760        }
761
762        // Pre-compile the main-preset `per_frame_init` + `per_frame`
763        // blocks. Until this landed both lists were re-parsed every
764        // frame through `MilkEvaluator::eval_per_frame(&[String])`,
765        // even though the source strings never change between frames.
766        // CompiledBlock now lifts them into bytecode (with evalexpr
767        // fallback) so the per-frame eval phase shares the same fast
768        // path as wave/shape per_frame.
769        let pf_init_nodes = Self::compile_phase(
770            &mut self.current.evaluator,
771            &preset.per_frame_init_equations,
772        );
773        self.current.compiled_per_frame_init = onedrop_eval::CompiledBlock::from_nodes(
774            pf_init_nodes,
775            self.current.evaluator.context_mut(),
776        );
777        let pf_nodes =
778            Self::compile_phase(&mut self.current.evaluator, &preset.per_frame_equations);
779        self.current.compiled_per_frame =
780            onedrop_eval::CompiledBlock::from_nodes(pf_nodes, self.current.evaluator.context_mut());
781
782        // `per_frame_init` runs once at preset load — same semantics as
783        // the per-wave/per-shape init blocks.
784        let init_block = std::mem::replace(
785            &mut self.current.compiled_per_frame_init,
786            onedrop_eval::CompiledBlock::empty(),
787        );
788        run_block_with_logger(&mut self.current.evaluator, &init_block, |idx, e| {
789            log::warn!("preset per_frame_init equation[{}] failed: {}", idx, e);
790        });
791        self.current.compiled_per_frame_init = init_block;
792
793        self.current.preset = Some(preset);
794
795        Ok(())
796    }
797
798    /// Compile a single equation-phase batch, logging individual
799    /// failures and returning the surviving Nodes. Public-by-convention
800    /// for testability inside the module.
801    fn compile_phase(evaluator: &mut MilkEvaluator, equations: &[String]) -> Vec<Node> {
802        let mut nodes = Vec::with_capacity(equations.len());
803        for eq in equations {
804            match evaluator.compile(eq) {
805                Ok(n) => nodes.push(n),
806                Err(e) => log::warn!("wavecode equation compile failed (`{}`): {}", eq, e),
807            }
808        }
809        nodes
810    }
811
812    /// Initialize evaluator context from preset parameters.
813    fn init_evaluator_from_preset(&mut self, preset: &MilkPreset) {
814        let ctx = self.current.evaluator.context_mut();
815        let params = &preset.parameters;
816
817        // Set motion parameters
818        ctx.set_var("zoom", params.zoom as f64);
819        ctx.set_var("zoomexp", params.zoomexp() as f64);
820        ctx.set_var("rot", params.rot as f64);
821        ctx.set_var("warp", params.warp as f64);
822        ctx.set_var("cx", params.cx as f64);
823        ctx.set_var("cy", params.cy as f64);
824        ctx.set_var("dx", params.dx as f64);
825        ctx.set_var("dy", params.dy as f64);
826        ctx.set_var("sx", params.sx as f64);
827        ctx.set_var("sy", params.sy as f64);
828
829        // Set wave parameters
830        ctx.set_var("wave_r", params.wave_r as f64);
831        ctx.set_var("wave_g", params.wave_g as f64);
832        ctx.set_var("wave_b", params.wave_b as f64);
833        ctx.set_var("wave_a", params.wave_a() as f64);
834        ctx.set_var("wave_x", params.wave_x as f64);
835        ctx.set_var("wave_y", params.wave_y as f64);
836        ctx.set_var("wave_mode", params.wave_mode() as f64);
837
838        // Full MD2 wave cluster threaded through the evaluator so
839        // per-frame eqs can flip any of these on the fly.
840        ctx.set_var("wave_scale", params.f_wave_scale as f64);
841        ctx.set_var("wave_param", params.f_wave_param as f64);
842        ctx.set_var("wave_smoothing", params.f_wave_smoothing as f64);
843        ctx.set_var("wave_thick", if params.b_wave_thick { 1.0 } else { 0.0 });
844        ctx.set_var("wave_dots", if params.b_wave_dots { 1.0 } else { 0.0 });
845        ctx.set_var(
846            "additivewave",
847            if params.b_additive_waves { 1.0 } else { 0.0 },
848        );
849        ctx.set_var(
850            "wave_brighten",
851            if params.b_maximize_wave_color {
852                1.0
853            } else {
854                0.0
855            },
856        );
857        ctx.set_var(
858            "modwavealphabyvolume",
859            if params.b_mod_wave_alpha_by_volume {
860                1.0
861            } else {
862                0.0
863            },
864        );
865        ctx.set_var("modwavealphastart", params.f_mod_wave_alpha_start as f64);
866        ctx.set_var("modwavealphaend", params.f_mod_wave_alpha_end as f64);
867
868        // Set other parameters
869        ctx.set_var("decay", params.decay() as f64);
870        ctx.set_var("gamma", params.gamma() as f64);
871        ctx.set_var("echo_zoom", params.echo_zoom() as f64);
872        ctx.set_var("echo_alpha", params.echo_alpha() as f64);
873        // Seed `echo_orient` so presets shipping `nVideoEchoOrientation`
874        // without a per-frame eq still flip the echo sample. Two aliases are
875        // seeded for symmetry with the read path in
876        // `update_render_state_from_evaluator`.
877        ctx.set_var(
878            "echo_orient",
879            params.n_video_echo_orientation.clamp(0, 3) as f64,
880        );
881        ctx.set_var(
882            "video_echo_orientation",
883            params.n_video_echo_orientation.clamp(0, 3) as f64,
884        );
885        ctx.set_var(
886            "darken_center",
887            if params.darken_center() { 1.0 } else { 0.0 },
888        );
889        ctx.set_var(
890            "red_blue_stereo",
891            if params.b_red_blue_stereo { 1.0 } else { 0.0 },
892        );
893        ctx.set_var("wrap", if params.wrap() { 1.0 } else { 0.0 });
894        ctx.set_var("invert", if params.invert() { 1.0 } else { 0.0 });
895        ctx.set_var("brighten", if params.brighten() { 1.0 } else { 0.0 });
896        ctx.set_var("darken", if params.darken() { 1.0 } else { 0.0 });
897        ctx.set_var("solarize", if params.solarize() { 1.0 } else { 0.0 });
898
899        // Outer + inner border + motion-vector grid. Seed the evaluator
900        // with the preset's scalars so per-frame eqs can animate them
901        // (e.g. flashing `ob_a` on a beat). Motion-vector grid size falls
902        // back to the MD2 default of 12×9 when the preset omits
903        // `nMotionVectorsX/Y` (they parse as 0.0).
904        ctx.set_var("ob_size", params.ob_size as f64);
905        ctx.set_var("ob_r", params.ob_r as f64);
906        ctx.set_var("ob_g", params.ob_g as f64);
907        ctx.set_var("ob_b", params.ob_b as f64);
908        ctx.set_var("ob_a", params.ob_a as f64);
909        ctx.set_var("ib_size", params.ib_size as f64);
910        ctx.set_var("ib_r", params.ib_r as f64);
911        ctx.set_var("ib_g", params.ib_g as f64);
912        ctx.set_var("ib_b", params.ib_b as f64);
913        ctx.set_var("ib_a", params.ib_a as f64);
914        let nmv_x = if params.n_motion_vectors_x > 0.0 {
915            params.n_motion_vectors_x
916        } else {
917            12.0
918        };
919        let nmv_y = if params.n_motion_vectors_y > 0.0 {
920            params.n_motion_vectors_y
921        } else {
922            9.0
923        };
924        ctx.set_var("nMotionVectorsX", nmv_x as f64);
925        ctx.set_var("nMotionVectorsY", nmv_y as f64);
926        ctx.set_var("mv_dx", params.mv_dx as f64);
927        ctx.set_var("mv_dy", params.mv_dy as f64);
928        ctx.set_var("mv_l", params.mv_l as f64);
929        ctx.set_var("mv_r", params.mv_r as f64);
930        ctx.set_var("mv_g", params.mv_g as f64);
931        ctx.set_var("mv_b", params.mv_b as f64);
932        ctx.set_var("mv_a", params.mv_a as f64);
933    }
934
935    /// Update engine with mono audio + render a frame. Convenience
936    /// wrapper around [`Self::update_stereo`] that feeds the same buffer
937    /// for both channels — handy for callers that only have a downmixed
938    /// stream (CLI render path, test fixtures, headless smoke tests).
939    /// Custom waves' `value2` will mirror `value1`; real stereo content
940    /// requires [`Self::update_stereo`].
941    ///
942    /// Returns `Some(PresetChange)` if beat detection triggered a preset
943    /// change.
944    pub fn update(
945        &mut self,
946        audio_samples: &[f32],
947        delta_time: f32,
948    ) -> Result<Option<PresetChange>> {
949        self.update_stereo(audio_samples, audio_samples, delta_time)
950    }
951
952    /// Update engine with split L/R audio + render a frame. The two
953    /// buffers must be the same length; MD2 custom waves'
954    /// `value1` reads from `left`, `value2` from `right`. Audio
955    /// analysis (band levels, beat detection, FFT spectrum) runs on a
956    /// downmix of the two channels — the perceptual loudness those
957    /// metrics report is the listener's, not one channel's.
958    ///
959    /// `delta_time` is in seconds; the engine multiplies it into every
960    /// per-frame time step.
961    pub fn update_stereo(
962        &mut self,
963        left: &[f32],
964        right: &[f32],
965        delta_time: f32,
966    ) -> Result<Option<PresetChange>> {
967        // Downmix for the engine-wide audio analyzers (bass/mid/treb +
968        // beat detector + FFT spectrum). Perceptual loudness lives at
969        // the listener's ear, not in one channel — and historically MD2
970        // band levels were computed on the mixed input anyway. Sharing
971        // a single buffer alloc per frame keeps the cost negligible.
972        // When `left.as_ptr() == right.as_ptr()` (the mono case from
973        // `update()` above), `Cow::Borrowed` lets us skip the alloc.
974        let downmix: std::borrow::Cow<'_, [f32]> = if std::ptr::eq(left, right) {
975            std::borrow::Cow::Borrowed(left)
976        } else {
977            let n = left.len().min(right.len());
978            std::borrow::Cow::Owned((0..n).map(|i| 0.5 * (left[i] + right[i])).collect())
979        };
980        let audio_samples: &[f32] = &downmix;
981        let profile_enabled = self.config.profile;
982        let frame_start = profile_enabled.then(Instant::now);
983        let mut profile = FrameProfile::default();
984        // Helper closures: capture `&mut profile` and `profile_enabled`.
985        // Returning a Duration is cheap; `Instant::elapsed` is ~20 ns.
986        macro_rules! phase {
987            ($field:ident, $body:expr) => {{
988                if profile_enabled {
989                    let t = Instant::now();
990                    let r = $body;
991                    profile.$field += t.elapsed();
992                    r
993                } else {
994                    $body
995                }
996            }};
997        }
998
999        // Analyze audio + run beat detection — both are engine-global and
1000        // feed every active slot.
1001        let audio_levels = phase!(audio_analyze, self.audio_analyzer.analyze(audio_samples));
1002        let preset_change = self.beat_detector.should_change_preset(
1003            audio_levels.bass,
1004            audio_levels.mid,
1005            audio_levels.treb,
1006        );
1007
1008        // Refresh the FFT spectrum once per frame; shared between slots
1009        // (both audio bins and analyzer are engine-wide).
1010        phase!(spectrum_fft, self.update_spectrum(audio_samples));
1011
1012        // --- Per-slot evaluation ---
1013        //
1014        // First tick the primary (current) slot. Then, if a transition is
1015        // in flight, tick the outgoing (fading_out) slot identically — same
1016        // audio, same delta — so both presets get a coherent per-frame
1017        // step. The two slots own independent evaluator contexts and
1018        // RenderStates so they don't trample each other.
1019
1020        // Read the MPRIS snapshot once per frame and feed the same value
1021        // to both slots so `progress` stays coherent across a transition.
1022        // `None` means "no usable track position" → fall back to the
1023        // local-window computation inside `tick_slot_evaluator`.
1024        let track_progress = self.track_progress_override();
1025
1026        phase!(
1027            per_frame_eval,
1028            tick_slot_evaluator(
1029                &mut self.current,
1030                audio_levels,
1031                delta_time,
1032                self.config.enable_per_frame,
1033                self.config.preset_display_duration_s,
1034                track_progress,
1035            )
1036        );
1037
1038        if let Some(slot) = self.fading_out.as_mut() {
1039            phase!(
1040                per_frame_eval,
1041                tick_slot_evaluator(
1042                    slot,
1043                    audio_levels,
1044                    delta_time,
1045                    self.config.enable_per_frame,
1046                    self.config.preset_display_duration_s,
1047                    track_progress,
1048                )
1049            );
1050        }
1051
1052        // Smart boolean interpolation of motion params + flags between
1053        // the two slots, applied *before* the state sync so both chains'
1054        // `state.motion` and downstream warp inputs see the morphed
1055        // values. Split-borrow the disjoint fields so we can hold mutable
1056        // refs to both slots and an immutable ref to the timer.
1057        {
1058            let fading = &mut self.fading_out;
1059            let current = &mut self.current;
1060            let transition = &self.transition;
1061            if let (Some(slot), Some(t)) = (fading.as_mut(), transition.as_ref()) {
1062                morph::apply_smart_boolean_interpolation(slot, current, t.linear());
1063            }
1064        }
1065
1066        phase!(state_sync, state_sync::sync_render_state(&mut self.current));
1067        if let Some(slot) = self.fading_out.as_mut() {
1068            phase!(state_sync, state_sync::sync_render_state(slot));
1069        }
1070
1071        // `state.wave.split_lr` is a player-side toggle, not a preset
1072        // variable — `sync_render_state` defaults it to `false`; we
1073        // overlay the engine config here so both slots see it during
1074        // a transition.
1075        self.current.state.wave.split_lr = self.config.waveform_split_lr;
1076        if let Some(slot) = self.fading_out.as_mut() {
1077            slot.state.wave.split_lr = self.config.waveform_split_lr;
1078        }
1079
1080        // --- Primary chain: per-vertex warp + waveform + custom waves/shapes ---
1081        let mesh = self.renderer.warp_mesh();
1082        let primary_warp = phase!(
1083            warp_compute,
1084            self.current.warp_executor.compute(
1085                mesh,
1086                &self.current.evaluator,
1087                self.current.state.time,
1088            )
1089        );
1090        self.renderer.update_warp_vertices(&primary_warp);
1091        self.renderer.update_state(self.current.state);
1092        let vol = (audio_levels.bass + audio_levels.mid + audio_levels.treb) / 3.0;
1093        phase!(
1094            waveform_samples,
1095            self.renderer.update_waveform_samples_lr(left, right, vol)
1096        );
1097
1098        let (waves_v, waves_b) = phase!(
1099            custom_waves,
1100            wave_phase::compute_custom_waves(&mut self.current, left, right, &self.spectrum_bins,)
1101        );
1102        self.renderer.update_custom_waves(&waves_v, &waves_b);
1103
1104        let (shapes_i, shapes_b) = phase!(
1105            custom_shapes,
1106            shape_phase::compute_custom_shapes(&mut self.current)
1107        );
1108        self.renderer.update_custom_shapes(&shapes_i, &shapes_b);
1109
1110        // --- Sprites (§5): tick each active sprite against the current
1111        // q snapshot and forward the resulting frames to the renderer.
1112        // Sprites are a global overlay, not per-preset — only the
1113        // primary slot's q values feed the per-frame eval (matches
1114        // MD2's behaviour: `MILK_IMG.INI` lives outside the preset).
1115        let q_snap = self.current.state.q_snapshot;
1116        let sprite_instances = self.sprites.tick(self.current.state.time, &q_snap);
1117        let sprite_frames: Vec<onedrop_renderer::SpriteFrame> = sprite_instances
1118            .iter()
1119            .map(|s| onedrop_renderer::SpriteFrame {
1120                texture_index: s.texture_index,
1121                x: s.x,
1122                y: s.y,
1123                sx: s.sx,
1124                sy: s.sy,
1125                rot: s.rot,
1126                rgba: s.rgba,
1127                blend: match s.blend {
1128                    crate::sprites::SpriteBlendMode::Alpha => {
1129                        onedrop_renderer::SpriteBlendKind::Alpha
1130                    }
1131                    crate::sprites::SpriteBlendMode::Additive => {
1132                        onedrop_renderer::SpriteBlendKind::Additive
1133                    }
1134                },
1135                burn: s.burn,
1136            })
1137            .collect();
1138        self.renderer.update_sprites(&sprite_frames);
1139
1140        // --- MPRIS auto-title (§6): when the active MPRIS player
1141        // reports a new title, spawn a transient "now playing"
1142        // message. Cheap — runs only when the feature is built and
1143        // the snapshot's title actually changed.
1144        #[cfg(feature = "mpris")]
1145        if let Some(poller) = self.mpris.as_ref() {
1146            let snap = poller.snapshot();
1147            if let Some(title) = snap.title.as_ref() {
1148                let changed = self
1149                    .mpris_last_title
1150                    .as_deref()
1151                    .map(|t| t != title.as_str())
1152                    .unwrap_or(true);
1153                if changed && !title.is_empty() {
1154                    self.messages
1155                        .show_transient(format!("♪ {}", title), self.current.state.time);
1156                    self.mpris_last_title = Some(title.clone());
1157                }
1158            }
1159        }
1160
1161        // --- Messages (§6): tick fade timing + forward render
1162        // instances to the renderer's text pipeline.
1163        let message_instances = self.messages.tick(self.current.state.time);
1164        let text_frames: Vec<onedrop_renderer::TextFrame> = message_instances
1165            .iter()
1166            .map(|m| onedrop_renderer::TextFrame {
1167                text: m.text.clone(),
1168                font: m.font,
1169                size_px: m.size_px,
1170                x: m.x,
1171                y: m.y,
1172                rgba: m.rgba,
1173            })
1174            .collect();
1175        self.renderer.update_text_frames(&text_frames);
1176
1177        // --- Secondary chain (if transitioning): same flow, different slot ---
1178        if let Some(slot) = self.fading_out.as_mut() {
1179            let sec_warp = slot.warp_executor.compute(
1180                self.renderer.warp_mesh(),
1181                &slot.evaluator,
1182                slot.state.time,
1183            );
1184            self.renderer.update_secondary_warp_vertices(&sec_warp);
1185            self.renderer.update_secondary_state(Some(slot.state));
1186            self.renderer
1187                .update_secondary_waveform_samples_lr(left, right, vol, slot.state.wave);
1188
1189            let (waves_v, waves_b) =
1190                wave_phase::compute_custom_waves(slot, left, right, &self.spectrum_bins);
1191            self.renderer
1192                .update_secondary_custom_waves(&waves_v, &waves_b);
1193
1194            let (shapes_i, shapes_b) = shape_phase::compute_custom_shapes(slot);
1195            self.renderer
1196                .update_secondary_custom_shapes(&shapes_i, &shapes_b);
1197        } else {
1198            self.renderer.update_secondary_state(None);
1199        }
1200
1201        // --- Transition timer ---
1202        if let Some(t) = self.transition.as_mut() {
1203            t.tick(delta_time);
1204            self.renderer.set_transition_progress(t.eased());
1205            if t.is_complete() {
1206                self.fading_out = None;
1207                self.transition = None;
1208                self.renderer.clear_secondary_chain();
1209            }
1210        }
1211
1212        // Render frame
1213        phase!(gpu_render, self.renderer.render()?);
1214
1215        // Bump frame counters AFTER the render so `comp_uniforms` saw the
1216        // pre-tick frame (matches pre-V.3 ordering). Both slots advance in
1217        // lock-step during a transition.
1218        self.current.state.frame += 1;
1219        if let Some(slot) = self.fading_out.as_mut() {
1220            slot.state.frame += 1;
1221        }
1222
1223        if let Some(t) = frame_start {
1224            profile.total = t.elapsed();
1225            self.last_profile = Some(profile);
1226        }
1227
1228        Ok(preset_change)
1229    }
1230
1231    /// Per-phase wall-clock breakdown of the most recent `update` call.
1232    /// Returns `None` unless `EngineConfig::profile` was set. Cleared on
1233    /// engine reset.
1234    pub fn last_profile(&self) -> Option<&FrameProfile> {
1235        self.last_profile.as_ref()
1236    }
1237
1238    /// Return the current transition's eased progress in `[0, 1]`, or
1239    /// `None` when no transition is in flight. Useful for HUD overlays
1240    /// and tests.
1241    pub fn transition_progress(&self) -> Option<f32> {
1242        self.transition.as_ref().map(|t| t.eased())
1243    }
1244
1245    /// `true` while a preset transition is animating. Equivalent to
1246    /// `transition_progress().is_some()`.
1247    pub fn is_transitioning(&self) -> bool {
1248        self.transition.is_some()
1249    }
1250
1251    /// Refresh the FFT magnitude spectrum from the current audio frame.
1252    /// Used by `b_spectrum = 1` `wavecode_N` blocks to populate
1253    /// `value1`/`value2`. Always runs (even with no preset loaded) so
1254    /// the buffer stays warm across preset hops.
1255    fn update_spectrum(&mut self, audio_samples: &[f32]) {
1256        let bins = self.fft.analyze(audio_samples);
1257        let n = bins.len().min(self.spectrum_bins.len());
1258        self.spectrum_bins[..n].copy_from_slice(&bins[..n]);
1259        for slot in self.spectrum_bins.iter_mut().skip(n) {
1260            *slot = 0.0;
1261        }
1262    }
1263
1264    /// Get the current render texture.
1265    pub fn render_texture(&self) -> &wgpu::Texture {
1266        self.renderer.render_texture()
1267    }
1268
1269    /// Get current state.
1270    pub fn state(&self) -> &RenderState {
1271        &self.current.state
1272    }
1273
1274    /// Get current preset.
1275    pub fn current_preset(&self) -> Option<&MilkPreset> {
1276        self.current.preset.as_ref()
1277    }
1278
1279    /// Snapshot of `q1..q32` after the most recent `update`.
1280    ///
1281    /// Returned as `f32` so a future GPU pipeline can copy it directly into a
1282    /// `q: array<vec4<f32>, 8>` uniform (MilkDrop 2's standard q-channel
1283    /// layout). The CPU-side bridge — per-frame eqs write `q1..q32`,
1284    /// per-vertex eqs read them, identical state restored across vertices —
1285    /// is already in place via `WarpExecutor`; this getter is the GPU-side
1286    /// hand-off point for upcoming comp/warp user shaders (sprint B).
1287    pub fn q_snapshot(&self) -> [f32; 32] {
1288        let ctx = self.current.evaluator.context();
1289        let mut out = [0.0f32; 32];
1290        for (i, slot) in out.iter_mut().enumerate() {
1291            // q1..q32 live in MilkContext's array-backed q_vars; index
1292            // straight into the slot rather than rebuilding the
1293            // `"qN"` name string and going through the trait `get`
1294            // path.
1295            *slot = ctx.q_get_idx(i) as f32;
1296        }
1297        out
1298    }
1299
1300    /// Get the beat detector.
1301    pub fn beat_detector(&self) -> &BeatDetector {
1302        &self.beat_detector
1303    }
1304
1305    /// Get the beat detector mutably.
1306    pub fn beat_detector_mut(&mut self) -> &mut BeatDetector {
1307        &mut self.beat_detector
1308    }
1309
1310    /// Set beat detection mode.
1311    pub fn set_beat_detection_mode(&mut self, mode: BeatDetectionMode) {
1312        self.beat_detector.set_mode(mode);
1313    }
1314
1315    /// Toggle beat detection to next mode.
1316    pub fn next_beat_detection_mode(&mut self) {
1317        self.beat_detector.next_mode();
1318    }
1319
1320    /// Enable beat detection.
1321    pub fn enable_beat_detection(&mut self) {
1322        self.beat_detector.enable();
1323    }
1324
1325    /// Disable beat detection.
1326    pub fn disable_beat_detection(&mut self) {
1327        self.beat_detector.disable();
1328    }
1329
1330    /// Load a set of parsed `MILK_IMG.INI` sprite definitions and pre-
1331    /// resolve their image files against `dir`. Missing files fall
1332    /// back to a 1×1 transparent texture inside the renderer's sprite
1333    /// pool — the engine's `texture_index` mapping stays dense.
1334    pub fn load_sprite_defs(
1335        &mut self,
1336        defs: Vec<onedrop_parser::SpriteDef>,
1337        dir: &std::path::Path,
1338    ) {
1339        let imgs: Vec<String> = defs.iter().map(|d| d.img.clone()).collect();
1340        self.renderer.load_sprite_defs(dir, &imgs);
1341        self.sprites.load_defs(defs);
1342    }
1343
1344    /// Spawn the sprite at INI slot `slot` (1-based, matching the
1345    /// `[img01]` section header). Returns `false` if no def is
1346    /// loaded for that slot. Multiple spawns of the same slot stack
1347    /// — that matches MD2 + lets `K`-hammering build up effects.
1348    pub fn spawn_sprite_slot(&mut self, slot: u32) -> bool {
1349        self.sprites.spawn_slot(slot)
1350    }
1351
1352    /// Cycle to the next sprite slot (`K` keybind). Returns the slot
1353    /// that was spawned (or `None` if no sprites are loaded).
1354    pub fn cycle_next_sprite(&mut self) -> Option<u32> {
1355        self.sprites.cycle_next()
1356    }
1357
1358    /// Spawn a random sprite (`Shift+K` keybind). The caller supplies
1359    /// the RNG seed (typically `state.frame as u64 ^ time bits`) so
1360    /// tests stay deterministic.
1361    pub fn spawn_random_sprite(&mut self, seed: u64) -> Option<u32> {
1362        self.sprites.spawn_random(seed)
1363    }
1364
1365    /// Drop every active sprite (`Ctrl+T` keybind).
1366    pub fn clear_sprites(&mut self) {
1367        self.sprites.clear();
1368    }
1369
1370    /// Remove the most recently spawned sprite (`Delete` keybind).
1371    /// Returns `true` when something was popped — the GUI suppresses
1372    /// the keystroke on an empty list.
1373    pub fn pop_most_recent_sprite(&mut self) -> bool {
1374        self.sprites.pop_most_recent()
1375    }
1376
1377    /// Number of loaded sprite definitions (size of the texture pool).
1378    pub fn sprite_def_count(&self) -> usize {
1379        self.sprites.def_count()
1380    }
1381
1382    /// Number of sprites being drawn this frame.
1383    pub fn sprite_active_count(&self) -> usize {
1384        self.sprites.active_count()
1385    }
1386
1387    /// Load a set of parsed `MILK_MSG.INI` message definitions.
1388    /// Replaces the previous load.
1389    pub fn load_message_defs(&mut self, defs: Vec<onedrop_parser::MessageDef>) {
1390        self.messages.load_defs(defs);
1391    }
1392
1393    /// Spawn the message at INI slot `slot`. Returns `false` if no
1394    /// def matches that slot.
1395    pub fn spawn_message_slot(&mut self, slot: u32) -> bool {
1396        self.messages.spawn_slot(slot, self.current.state.time)
1397    }
1398
1399    /// Cycle through the loaded message slots (`T` keybind). Returns
1400    /// the slot that was spawned, or `None` if no messages are
1401    /// loaded.
1402    pub fn cycle_next_message(&mut self) -> Option<u32> {
1403        self.messages.cycle_next(self.current.state.time)
1404    }
1405
1406    /// Drop every active message (`Ctrl+Y` keybind).
1407    pub fn clear_messages(&mut self) {
1408        self.messages.clear();
1409    }
1410
1411    /// Show an ad-hoc transient message. Used by the MPRIS-driven
1412    /// auto-title path; surfaced publicly so the GUI can post its
1413    /// own transient toasts ("preset loaded", "audio source: …", …).
1414    pub fn show_transient_message(&mut self, text: impl Into<String>) {
1415        self.messages.show_transient(text, self.current.state.time);
1416    }
1417
1418    pub fn message_def_count(&self) -> usize {
1419        self.messages.def_count()
1420    }
1421
1422    pub fn message_active_count(&self) -> usize {
1423        self.messages.active_count()
1424    }
1425
1426    /// Get a reference to the renderer.
1427    pub fn renderer(&self) -> &MilkRenderer {
1428        &self.renderer
1429    }
1430
1431    /// Get a mutable reference to the renderer.
1432    pub fn renderer_mut(&mut self) -> &mut MilkRenderer {
1433        &mut self.renderer
1434    }
1435
1436    /// Borrow the engine config — used by the GUI's Options panel to
1437    /// reflect current values in widgets at startup.
1438    pub fn config(&self) -> &EngineConfig {
1439        &self.config
1440    }
1441
1442    /// Mutate hot-toggleable config fields (`enable_per_frame`,
1443    /// `transition_duration_s`, `profile`). The GUI's Options panel
1444    /// calls this when the user moves a widget. Fields that aren't
1445    /// safely hot-swappable (render_config, sample_rate) should not
1446    /// be poked here — change them via dedicated APIs that handle
1447    /// the side effects (pipeline rebuild, audio stream restart).
1448    pub fn config_mut(&mut self) -> &mut EngineConfig {
1449        &mut self.config
1450    }
1451
1452    /// Reset engine state.
1453    pub fn reset(&mut self) {
1454        self.current.state = RenderState::default();
1455        self.current.evaluator.reset();
1456        self.audio_analyzer.reset();
1457    }
1458
1459    /// Resize the renderer.
1460    pub fn resize(&mut self, width: u32, height: u32) {
1461        self.renderer.resize(width, height);
1462    }
1463
1464    /// Resolve the MD2 `progress` override for the current frame.
1465    ///
1466    /// Returns `Some(p)` when [`EngineConfig::progress_source`] is
1467    /// [`ProgressSource::MprisAutoTrack`], the `mpris` feature is
1468    /// enabled, *and* an MPRIS player is reachable with a non-zero
1469    /// `mpris:length`. Returns `None` otherwise so the slot tick
1470    /// falls back to the local-window computation.
1471    fn track_progress_override(&self) -> Option<f32> {
1472        #[cfg(feature = "mpris")]
1473        {
1474            if matches!(self.config.progress_source, ProgressSource::MprisAutoTrack) {
1475                return self.mpris.as_ref().and_then(|m| m.snapshot().progress);
1476            }
1477        }
1478        None
1479    }
1480
1481    /// Current MPRIS snapshot, when the `mpris` feature is enabled and
1482    /// a poller is active. Exposed so a HUD/overlay can render the
1483    /// track title without opening its own D-Bus connection.
1484    #[cfg(feature = "mpris")]
1485    pub fn mpris_snapshot(&self) -> Option<crate::mpris::MprisSnapshot> {
1486        self.mpris.as_ref().map(|m| m.snapshot())
1487    }
1488}
1489
1490#[cfg(test)]
1491mod tests {
1492    use super::*;
1493
1494    /// `tick_slot_evaluator` must use the override verbatim (clamped)
1495    /// when one is supplied — the local-window timer is ignored, so a
1496    /// freshly-loaded preset whose anchor would otherwise give
1497    /// `progress ≈ 0` instead reports the MPRIS-fed mid-track value.
1498    #[test]
1499    fn track_progress_override_replaces_local_window() {
1500        let mut slot = PresetSlot::default();
1501        slot.state.time = 0.0;
1502        slot.preset_loaded_time = 0.0;
1503        let audio = onedrop_renderer::AudioLevels::default();
1504        // Local-window would yield progress = (1.0 / 16.0) ≈ 0.0625.
1505        tick_slot_evaluator(&mut slot, audio, 1.0, false, 16.0, Some(0.75));
1506        assert!((slot.state.progress - 0.75).abs() < 1e-6);
1507    }
1508
1509    /// Override values outside `[0, 1]` get clamped — guards against
1510    /// MPRIS players reporting a position past the published length
1511    /// (Spotify ads, malformed metadata).
1512    #[test]
1513    fn track_progress_override_is_clamped() {
1514        let mut slot = PresetSlot::default();
1515        let audio = onedrop_renderer::AudioLevels::default();
1516        tick_slot_evaluator(&mut slot, audio, 0.016, false, 16.0, Some(1.42));
1517        assert!((slot.state.progress - 1.0).abs() < 1e-6);
1518        tick_slot_evaluator(&mut slot, audio, 0.016, false, 16.0, Some(-0.3));
1519        assert!((slot.state.progress - 0.0).abs() < 1e-6);
1520    }
1521
1522    /// When no override is supplied, behaviour must match the
1523    /// historical local-window formula (regression guard for the
1524    /// `progress_ramps_across_display_window` integration test).
1525    #[test]
1526    fn no_override_falls_back_to_local_window() {
1527        let mut slot = PresetSlot::default();
1528        slot.state.time = 0.0;
1529        slot.preset_loaded_time = 0.0;
1530        let audio = onedrop_renderer::AudioLevels::default();
1531        tick_slot_evaluator(&mut slot, audio, 2.0, false, 4.0, None);
1532        // time = 2 s, duration = 4 s → progress = 0.5.
1533        assert!((slot.state.progress - 0.5).abs() < 1e-6);
1534    }
1535}