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}