onedrop_renderer/
renderer.rs

1//! Main renderer assembling the per-frame Milkdrop pipeline.
2//!
3//! Current pipeline (MVP):
4//! 1. **Warp pass** — sample `prev_texture` at the per-vertex warped UV
5//!    (computed CPU-side by [`onedrop-engine::warp_eval`]) and write to
6//!    `render_texture`, applying decay + feedback filters in the fragment
7//!    shader.
8//! 2. **Copy-to-prev** — `render_texture` → `prev_texture` so next frame's
9//!    warp pass can sample the previous frame's content.
10//! 3. **Comp pass** — sample `render_texture`, apply display-only filters
11//!    (currently just `gamma_adj`; user comp shaders later in sprint B2),
12//!    write to `final_texture`. Filters here intentionally don't feed back
13//!    into the warp loop.
14//!
15//! `final_texture` is what `render_texture()` exposes to the GUI/CLI.
16//!
17//! Future passes (waveforms, shapes, motion vectors, sprites, blur)
18//! compose on top of this foundation.
19
20use onedrop_codegen::{ShaderCompiler, ShaderUniforms, USER_TEXTURE_SLOTS};
21use onedrop_hlsl::{TextureBindingPlan, UserSamplerRef, scan_user_samplers};
22
23use crate::config::{RenderConfig, RenderState};
24use crate::custom_shape::{CustomShapeBatch, CustomShapeInstance};
25use crate::custom_wave::{CustomWaveBatch, CustomWaveVertex};
26use crate::error::Result;
27use crate::final_blend::FinalBlendPipeline;
28use crate::gpu_context::GpuContext;
29use crate::render_chain::RenderChain;
30use crate::sprite_pipeline::{SpriteFrame, SpritePipeline, SpritePool};
31use crate::text_pipeline::{TextFrame, TextPipeline};
32use crate::texture_pool::TexturePool;
33use crate::warp_mesh::WarpMesh;
34use crate::warp_pipeline::WarpVertex;
35
36use std::collections::hash_map::DefaultHasher;
37use std::hash::{Hash, Hasher};
38
39/// Fallback warp mesh resolution. Used by call-sites that bypass
40/// `RenderConfig` (tests, benches). The real default comes from
41/// `RenderConfig::mesh_cols`/`mesh_rows`, which `MeshQuality::Medium`
42/// resolves to 48×36. MD2 ships 32×24 → 192×96.
43pub const DEFAULT_MESH_COLS: u32 = 48;
44pub const DEFAULT_MESH_ROWS: u32 = 36;
45
46/// Main Milkdrop renderer.
47///
48/// Orchestrates one or two [`RenderChain`]s plus the final-blend pipeline
49/// that composites their `final_texture` outputs into a single display
50/// target. During a preset transition, the secondary chain is `Some` and
51/// holds the outgoing preset's full pipeline; outside transitions it is
52/// `None` and the blend pass passes the primary chain straight through.
53pub struct MilkRenderer {
54    gpu: GpuContext,
55    /// Primary rendering chain — the currently active preset.
56    chain: RenderChain,
57    /// Secondary chain, active only during a preset transition. Holds the
58    /// **outgoing** preset's state so it can keep evolving while the new
59    /// preset takes over the primary slot.
60    secondary: Option<RenderChain>,
61    /// Final blend pass: lerps primary↔secondary `final_texture` into the
62    /// display target by the transition progress. Passthrough of primary
63    /// when no secondary chain is active.
64    final_blend: FinalBlendPipeline,
65    warp_mesh: WarpMesh,
66    /// Primary chain's per-frame state (motion, wave, decay, gamma, audio,
67    /// q1..q32, …). Pushed by the engine via `update_state`.
68    state: RenderState,
69    /// Secondary chain's per-frame state. Populated by the engine during
70    /// preset transitions; `None` outside transitions. Each chain has its own
71    /// `gamma_adj` / `echo_alpha` / audio / time — they advance independently
72    /// while the blend pass crossfades their `final_texture` outputs.
73    secondary_state: Option<RenderState>,
74    /// Caches translated/validated user comp shaders by HLSL source. Letting
75    /// the renderer own it keeps the codegen surface inside the crate that
76    /// already depends on it; the engine just hands HLSL bytes over.
77    shader_compiler: ShaderCompiler,
78    /// User textures loaded from disk. Empty by default; the engine layer
79    /// (GUI / CLI) populates it via `set_texture_pool`. The fallback 1×1
80    /// white inside the pool is bound into any unfilled user-texture slot
81    /// so the comp pass produces correct output for presets whose textures
82    /// we don't have on disk.
83    texture_pool: TexturePool,
84    /// Sprite GPU pipeline (`MILK_IMG.INI` overlay). Shared by both
85    /// chains during transitions — the pipeline is stateless past
86    /// init, and the sprite frames the engine pushes are global, not
87    /// per-preset.
88    sprite_pipeline: SpritePipeline,
89    /// Sprite texture pool: one texture per loaded `MILK_IMG.INI`
90    /// entry, indexed by [`SpriteFrame::texture_index`].
91    sprite_pool: SpritePool,
92    /// Sprite render instances the engine pushed via
93    /// [`Self::update_sprites`] for the next frame. Cleared on every
94    /// `render` so a stale list doesn't outlive the engine tick.
95    sprite_frames: Vec<SpriteFrame>,
96    /// Text overlay (`MILK_MSG.INI` + MPRIS auto-title) pipeline.
97    /// Renders on top of the final-blend output so messages never
98    /// participate in the warp feedback loop.
99    text_pipeline: TextPipeline,
100    /// Text frames the engine pushed via [`Self::update_text_frames`]
101    /// for the next render call.
102    text_frames: Vec<TextFrame>,
103}
104
105impl MilkRenderer {
106    /// Create a new renderer that owns its GPU device.
107    pub async fn new(config: RenderConfig) -> Result<Self> {
108        let gpu = GpuContext::new(config).await?;
109        Self::from_gpu_context(gpu)
110    }
111
112    /// Create a renderer atop a shared GPU context.
113    pub fn from_gpu_context(gpu: GpuContext) -> Result<Self> {
114        let aspect = gpu.aspect_ratio();
115        let cols = gpu.config.mesh_cols.max(2);
116        let rows = gpu.config.mesh_rows.max(2);
117        let warp_mesh = WarpMesh::new(cols, rows, aspect);
118        let chain = RenderChain::new(&gpu, &warp_mesh)?;
119        let final_blend = FinalBlendPipeline::new(
120            &gpu.device,
121            &gpu.config,
122            &chain.textures().final_texture_view,
123        )?;
124        let texture_pool = TexturePool::new(&gpu.device, &gpu.queue);
125        let sprite_pipeline = SpritePipeline::new(&gpu.device, gpu.config.texture_format.to_wgpu());
126        let sprite_pool = SpritePool::new(&gpu.device, &gpu.queue);
127        let text_pipeline =
128            TextPipeline::new(&gpu.device, &gpu.queue, gpu.config.texture_format.to_wgpu());
129        Ok(Self {
130            gpu,
131            chain,
132            secondary: None,
133            final_blend,
134            warp_mesh,
135            state: RenderState::default(),
136            secondary_state: None,
137            shader_compiler: ShaderCompiler::new(),
138            texture_pool,
139            sprite_pipeline,
140            sprite_pool,
141            sprite_frames: Vec::new(),
142            text_pipeline,
143            text_frames: Vec::new(),
144        })
145    }
146
147    /// Swap in a populated [`TexturePool`]. The engine layer (GUI / CLI)
148    /// calls this after constructing the renderer if the user has a
149    /// texture directory configured. Pool population is best-effort
150    /// (missing dirs are silently empty); see [`TexturePool::from_dirs`].
151    pub fn set_texture_pool(&mut self, pool: TexturePool) {
152        self.texture_pool = pool;
153    }
154
155    /// Borrow the texture pool. Used by the GUI's HUD to display "X user
156    /// textures loaded" and by tests to assert pool wiring.
157    pub fn texture_pool(&self) -> &TexturePool {
158        &self.texture_pool
159    }
160
161    /// Borrow the underlying GPU device. Exposed so the engine layer can
162    /// build a [`TexturePool`] before swapping it into the renderer.
163    pub fn gpu_device(&self) -> std::sync::Arc<wgpu::Device> {
164        self.gpu.device.clone()
165    }
166
167    /// Borrow the underlying GPU queue. Mirror of `gpu_device` for the
168    /// pool-construction path.
169    pub fn gpu_queue(&self) -> std::sync::Arc<wgpu::Queue> {
170        self.gpu.queue.clone()
171    }
172
173    /// Format of the display texture that [`read_render_texture`] returns.
174    /// Offline callers (CLI render-to-PNG, snapshot tests) use this to
175    /// decide whether to swizzle BGRA → RGBA before encoding.
176    pub fn render_format(&self) -> wgpu::TextureFormat {
177        self.gpu.config.texture_format.to_wgpu()
178    }
179
180    /// Render-target width × height in pixels. Mirrors `RenderConfig`.
181    pub fn render_size(&self) -> (u32, u32) {
182        (self.gpu.config.width, self.gpu.config.height)
183    }
184
185    /// Install (or remove) the user comp shader for the active preset.
186    ///
187    /// Pass `Some(hlsl)` from the preset's `comp_shader` block to swap the
188    /// comp pass into the user-authored pipeline. Pass `None` (or an empty
189    /// `&str` after trimming) to revert to the engine's gamma-only default
190    /// — the fallback for presets without a custom composite or for
191    /// preset loads where the translation/validation chain failed.
192    ///
193    /// Return value:
194    /// - `Ok(true)` — user shader compiled and is now active.
195    /// - `Ok(false)` — translation or validation failed; the comp pipeline
196    ///   is now on the default fallback. **Not** an error from the
197    ///   renderer's perspective; the caller can surface a log line.
198    /// - `Err(_)` — the wgpu device rejected the validated shader. Rare;
199    ///   indicates either a wgpu/naga version skew or a
200    ///   pipeline-construction bug.
201    pub fn set_user_comp_shader(&mut self, hlsl: Option<&str>) -> Result<bool> {
202        self.set_user_comp_shader_for_preset(hlsl, "")
203    }
204
205    /// Same as [`Self::set_user_comp_shader`], but lets the engine pass a
206    /// `preset_id` that seeds the deterministic `sampler_rand0X` pick for
207    /// this preset. Use the preset filename (`"Geiss - Aerial.milk"`) or
208    /// any string stable for the preset's lifetime — same string → same
209    /// rand selection.
210    pub fn set_user_comp_shader_for_preset(
211        &mut self,
212        hlsl: Option<&str>,
213        preset_id: &str,
214    ) -> Result<bool> {
215        let hlsl = match hlsl.map(str::trim).filter(|s| !s.is_empty()) {
216            Some(s) => s,
217            None => {
218                self.chain
219                    .comp_pipeline_mut()
220                    .reset_to_default(&self.gpu.device);
221                return Ok(true);
222            }
223        };
224
225        // Scan for `sampler sampler_X;` declarations, resolve each through
226        // the texture pool (literal names + `sampler_rand0X`), and build the
227        // per-preset TextureBindingPlan before translating.
228        let user_refs = scan_user_samplers(hlsl);
229        let (plan, slot_views) = self.build_texture_plan(&user_refs, preset_id);
230
231        match self
232            .shader_compiler
233            .compile_user_comp_shader_with_plan(hlsl, &plan)
234        {
235            Ok(compiled) => {
236                self.chain.comp_pipeline_mut().set_user_shader_with_plan(
237                    &self.gpu.device,
238                    &compiled.source,
239                    slot_views,
240                )?;
241                Ok(true)
242            }
243            Err(e) => {
244                log::warn!("user comp shader failed to compile, falling back to default: {e}");
245                self.chain
246                    .comp_pipeline_mut()
247                    .reset_to_default(&self.gpu.device);
248                Ok(false)
249            }
250        }
251    }
252
253    /// Resolve a list of `sampler sampler_X;` references against the
254    /// texture pool into a [`TextureBindingPlan`] + a parallel array of
255    /// `wgpu::TextureView` slot bindings for the comp pipeline.
256    ///
257    /// - Literal logical names (`"clouds"`, `"worms"`) are looked up by
258    ///   exact pool match.
259    /// - `sampler_rand0X` (with optional name-prefix suffix) is resolved
260    ///   to a deterministic-per-preset pick from the pool, optionally
261    ///   filtered by the suffix.
262    /// - Unresolvable names fall back to the 1×1 white texture.
263    fn build_texture_plan(
264        &self,
265        refs: &[UserSamplerRef],
266        preset_id: &str,
267    ) -> (
268        TextureBindingPlan,
269        [Option<wgpu::TextureView>; USER_TEXTURE_SLOTS],
270    ) {
271        let mut plan = TextureBindingPlan::empty();
272        let mut views: [Option<wgpu::TextureView>; USER_TEXTURE_SLOTS] = Default::default();
273
274        // Group references by logical name so multi-alias references
275        // (`sampler_clouds` + `sampler_fw_clouds`) collapse to one slot.
276        let mut by_logical: std::collections::BTreeMap<&str, Vec<&UserSamplerRef>> =
277            std::collections::BTreeMap::new();
278        for r in refs {
279            by_logical
280                .entry(r.logical_name.as_str())
281                .or_default()
282                .push(r);
283        }
284
285        for (logical, aliases) in by_logical {
286            let resolved = self.resolve_logical_texture(logical, preset_id);
287            let pool_name = resolved.as_ref().map(|t| t.name.clone());
288            let texsize = resolved
289                .as_ref()
290                .map(|t| t.texsize())
291                .unwrap_or([1.0, 1.0, 1.0, 1.0]);
292
293            let alias_specs: Vec<(String, &'static str)> = aliases
294                .iter()
295                .map(|a| (a.full_name.clone(), a.sampler_kind))
296                .collect();
297            let Some(slot) = plan.add_slot(pool_name, texsize, &alias_specs) else {
298                continue; // cap exceeded — logged by add_slot
299            };
300
301            views[slot] = resolved.map(|t| t.view.clone());
302        }
303
304        (plan, views)
305    }
306
307    /// Resolve one logical texture name (e.g. `"clouds"`, `"rand02_smalltiled"`)
308    /// to a concrete [`crate::UserTexture`] in the pool. `sampler_rand0X[_prefix]`
309    /// patterns become deterministic picks seeded by the preset id; literal
310    /// names are exact pool lookups.
311    fn resolve_logical_texture(
312        &self,
313        logical: &str,
314        preset_id: &str,
315    ) -> Option<std::sync::Arc<crate::UserTexture>> {
316        // `rand00..rand15` — optionally followed by `_<prefix>` to filter
317        // the pool's candidate set.
318        if let Some(rest) = logical.strip_prefix("rand") {
319            // Two-digit slot suffix; treat any leading digits as the slot
320            // index even when fewer than 2 are written.
321            let mut slot_str = String::new();
322            let mut prefix_part = "";
323            for (i, c) in rest.char_indices() {
324                if c.is_ascii_digit() {
325                    slot_str.push(c);
326                } else {
327                    prefix_part = &rest[i..];
328                    break;
329                }
330            }
331            if !slot_str.is_empty() {
332                let slot_index: u64 = slot_str.parse().unwrap_or(0);
333                let prefix = prefix_part.trim_start_matches('_');
334                let mut hasher = DefaultHasher::new();
335                preset_id.hash(&mut hasher);
336                slot_index.hash(&mut hasher);
337                let seed = hasher.finish();
338                return self
339                    .texture_pool
340                    .pick_rand(
341                        seed,
342                        if prefix.is_empty() {
343                            None
344                        } else {
345                            Some(prefix)
346                        },
347                    )
348                    .cloned();
349            }
350        }
351        self.texture_pool.get(logical).cloned()
352    }
353
354    /// Whether the comp pass is currently driven by a user-authored
355    /// shader (vs. the engine's gamma-only fallback). Surfaced for tests
356    /// and the GUI's debug overlay.
357    pub fn has_user_comp_shader(&self) -> bool {
358        self.chain.comp_pipeline().has_user_shader()
359    }
360
361    /// Swap in a user-translated warp shader. Mirror of
362    /// [`Self::set_user_comp_shader_for_preset`] but for the warp pass.
363    /// The translation goes through the same `TextureBindingPlan`
364    /// scan + texture-pool resolution as the comp side.
365    pub fn set_user_warp_shader(&mut self, hlsl: Option<&str>) -> Result<bool> {
366        self.set_user_warp_shader_for_preset(hlsl, "")
367    }
368
369    /// Same as [`Self::set_user_warp_shader`], with a `preset_id` that
370    /// seeds the deterministic `sampler_rand0X` picks (filename works).
371    pub fn set_user_warp_shader_for_preset(
372        &mut self,
373        hlsl: Option<&str>,
374        preset_id: &str,
375    ) -> Result<bool> {
376        let hlsl = match hlsl.map(str::trim).filter(|s| !s.is_empty()) {
377            Some(s) => s,
378            None => {
379                self.chain.warp_pipeline_mut().reset_to_default();
380                return Ok(true);
381            }
382        };
383
384        let user_refs = scan_user_samplers(hlsl);
385        let (plan, slot_views) = self.build_texture_plan(&user_refs, preset_id);
386
387        match self
388            .shader_compiler
389            .compile_user_warp_shader_with_plan(hlsl, &plan)
390        {
391            Ok(compiled) => {
392                // Clone every view we need into owned handles *before*
393                // borrowing `self.chain` mutably — the comp pipeline's
394                // aux view ref would otherwise hold an immutable borrow
395                // of `self.chain` for the whole call. Warp uses the
396                // *previous* frame's blur pyramid (this frame's blur
397                // doesn't exist yet at warp time); the noise pack is
398                // shared with the comp pipeline.
399                let textures = self.chain.textures();
400                let prev_view = textures.prev_texture_view.clone();
401                let prev_blur1 = textures.prev_blur1_texture_view.clone();
402                let prev_blur2 = textures.prev_blur2_texture_view.clone();
403                let prev_blur3 = textures.prev_blur3_texture_view.clone();
404                let aux_owned = {
405                    let aux = self.chain.comp_pipeline().comp_aux_views();
406                    [
407                        aux.noise_lq.clone(),
408                        aux.noise_mq.clone(),
409                        aux.noise_hq.clone(),
410                        aux.noisevol_lq.clone(),
411                        aux.noisevol_hq.clone(),
412                    ]
413                };
414                let aux = crate::user_warp_pipeline::WarpAuxViews {
415                    prev_blur1: &prev_blur1,
416                    prev_blur2: &prev_blur2,
417                    prev_blur3: &prev_blur3,
418                    noise_lq: &aux_owned[0],
419                    noise_mq: &aux_owned[1],
420                    noise_hq: &aux_owned[2],
421                    noisevol_lq: &aux_owned[3],
422                    noisevol_hq: &aux_owned[4],
423                };
424                self.chain.warp_pipeline_mut().set_user_shader_with_plan(
425                    &self.gpu.device,
426                    &self.gpu.queue,
427                    &compiled.source,
428                    &prev_view,
429                    &aux,
430                    slot_views,
431                )?;
432                Ok(true)
433            }
434            Err(e) => {
435                log::warn!("user warp shader failed to compile, falling back to default: {e}");
436                self.chain.warp_pipeline_mut().reset_to_default();
437                Ok(false)
438            }
439        }
440    }
441
442    /// Whether the warp pass is currently driven by a user-authored
443    /// shader.
444    pub fn has_user_warp_shader(&self) -> bool {
445        self.chain.warp_pipeline().has_user_shader()
446    }
447
448    /// Update render state.
449    pub fn update_state(&mut self, state: RenderState) {
450        self.state = state;
451    }
452
453    /// Borrow the warp mesh for per-vertex equation evaluation.
454    pub fn warp_mesh(&self) -> &WarpMesh {
455        &self.warp_mesh
456    }
457
458    /// Rebuild the warp mesh at a different `cols × rows`. Reallocates
459    /// the warp pipeline's vertex/index buffers on both the primary and
460    /// (if active) secondary chains. The per-vertex equation executor in
461    /// the engine reads `warp_mesh()` every frame and allocates its
462    /// output vector fresh, so it adapts without further plumbing.
463    ///
464    /// `cols` and `rows` are clamped to `>= 2` to match `WarpMesh::new`.
465    /// MD2 caps `nMeshSize` at 192×96; we mirror that ceiling so a
466    /// runaway slider can't allocate gigabytes of index buffer.
467    pub fn set_mesh_size(&mut self, cols: u32, rows: u32) {
468        let cols = cols.clamp(2, 192);
469        let rows = rows.clamp(2, 96);
470        if cols == self.warp_mesh.cols && rows == self.warp_mesh.rows {
471            return;
472        }
473        let aspect = self.gpu.aspect_ratio();
474        self.warp_mesh = WarpMesh::new(cols, rows, aspect);
475        self.chain
476            .rebuild_warp_mesh(&self.gpu.device, &self.warp_mesh);
477        if let Some(secondary) = self.secondary.as_mut() {
478            secondary.rebuild_warp_mesh(&self.gpu.device, &self.warp_mesh);
479        }
480        self.gpu.config.mesh_cols = cols;
481        self.gpu.config.mesh_rows = rows;
482    }
483
484    /// Current warp mesh size as `(cols, rows)`. Used by the GUI's
485    /// options panel to seed its widget state.
486    pub fn mesh_size(&self) -> (u32, u32) {
487        (self.warp_mesh.cols, self.warp_mesh.rows)
488    }
489
490    /// Push freshly computed warp UVs to the GPU. Also caches them on
491    /// the chain so the motion-vector pass can build anisotropic
492    /// segments aligned with the local warp displacement.
493    pub fn update_warp_vertices(&mut self, vertices: &[WarpVertex]) {
494        let cols = self.warp_mesh.cols;
495        let rows = self.warp_mesh.rows;
496        self.chain
497            .update_warp_vertices(&self.gpu.queue, cols, rows, vertices);
498    }
499
500    /// Push a fresh mono audio sample window for the waveform pass.
501    ///
502    /// The renderer applies `wave_smoothing` (single-pole IIR) on the
503    /// CPU side before upload — pass the raw samples; smoothing follows
504    /// `RenderState::wave.smoothing`. The buffer is clamped to
505    /// `NUM_WAVE_SAMPLES`; shorter inputs are zero-padded.
506    ///
507    /// `instantaneous_volume` is the frame-mean abs value used by
508    /// `b_mod_wave_alpha_by_volume` to scale the wave alpha — pass
509    /// `(|bass| + |mid| + |treb|) / 3` or any equivalent volume proxy
510    /// in `[0, 1]`.
511    pub fn update_waveform_samples(&mut self, samples: &[f32], instantaneous_volume: f32) {
512        self.chain.update_waveform_samples(
513            &self.gpu.queue,
514            samples,
515            instantaneous_volume,
516            self.state.wave,
517        );
518    }
519
520    /// Stereo variant of [`Self::update_waveform_samples`] — uploads
521    /// distinct left and right channels so the waveform pass can lay
522    /// them on the upper and lower screen halves when
523    /// `WaveParams::split_lr` is `true`.
524    pub fn update_waveform_samples_lr(
525        &mut self,
526        left: &[f32],
527        right: &[f32],
528        instantaneous_volume: f32,
529    ) {
530        self.chain.update_waveform_samples_lr(
531            &self.gpu.queue,
532            left,
533            right,
534            instantaneous_volume,
535            self.state.wave,
536        );
537    }
538
539    /// Borrow the current waveform sample buffer. Mostly useful for
540    /// tests that want to assert the smoothing applied or the latest
541    /// upload's length.
542    pub fn wave_samples(&self) -> &[f32] {
543        self.chain.wave_samples()
544    }
545
546    /// Upload the per-frame custom-wave vertex stream.
547    pub fn update_custom_waves(
548        &mut self,
549        vertices: &[CustomWaveVertex],
550        batches: &[CustomWaveBatch],
551    ) {
552        self.chain
553            .update_custom_waves(&self.gpu.queue, vertices, batches);
554    }
555
556    pub fn custom_wave_vertex_count(&self) -> u32 {
557        self.chain.custom_wave_vertex_count()
558    }
559
560    pub fn custom_wave_batch_count(&self) -> usize {
561        self.chain.custom_wave_batch_count()
562    }
563
564    /// Upload the per-frame custom-shape instance stream.
565    pub fn update_custom_shapes(
566        &mut self,
567        instances: &[CustomShapeInstance],
568        batches: &[CustomShapeBatch],
569    ) {
570        let aspect = self.gpu.aspect_ratio();
571        self.chain
572            .update_custom_shapes(&self.gpu.queue, instances, batches, aspect);
573    }
574
575    pub fn custom_shape_instance_count(&self) -> u32 {
576        self.chain.custom_shape_instance_count()
577    }
578
579    pub fn custom_shape_batch_count(&self) -> usize {
580        self.chain.custom_shape_batch_count()
581    }
582
583    pub fn border_batch_count(&self) -> usize {
584        self.chain.border_batch_count()
585    }
586
587    pub fn motion_vector_segment_count(&self) -> u32 {
588        self.chain.motion_vector_segment_count()
589    }
590
591    /// Push the sprite frame list for the next [`Self::render`] call.
592    /// The engine ticks its `SpriteManager` and feeds the resulting
593    /// [`SpriteFrame`] list here. Empty slice = no sprite pass recorded
594    /// next frame.
595    pub fn update_sprites(&mut self, frames: &[SpriteFrame]) {
596        self.sprite_frames.clear();
597        self.sprite_frames.extend_from_slice(frames);
598    }
599
600    /// Number of sprite frames queued for the next render call.
601    /// Exposed for tests + the GUI's HUD.
602    pub fn sprite_frame_count(&self) -> usize {
603        self.sprite_frames.len()
604    }
605
606    /// Reload sprite textures from a directory, in the order of the
607    /// parsed `MILK_IMG.INI` entries. `def_imgs` is the parallel list
608    /// of `img=` filenames the engine pulled from the parser. Missing
609    /// files are silently substituted with a 1×1 transparent fallback;
610    /// the engine's `texture_index` mapping stays dense.
611    pub fn load_sprite_defs(&mut self, dir: &std::path::Path, def_imgs: &[String]) {
612        self.sprite_pool
613            .load_from_defs(&self.gpu.device, &self.gpu.queue, dir, def_imgs);
614    }
615
616    /// Borrow the sprite pool. Used by tests + GUI HUD.
617    pub fn sprite_pool(&self) -> &SpritePool {
618        &self.sprite_pool
619    }
620
621    /// Push the text-overlay frame list for the next [`Self::render`]
622    /// call. The engine ticks its `MessageManager` (+ optional MPRIS
623    /// auto-title) and forwards the resulting [`TextFrame`] list.
624    /// Empty slice = no text pass.
625    pub fn update_text_frames(&mut self, frames: &[TextFrame]) {
626        self.text_frames.clear();
627        self.text_frames.extend_from_slice(frames);
628    }
629
630    /// Number of text frames queued for next render.
631    pub fn text_frame_count(&self) -> usize {
632        self.text_frames.len()
633    }
634
635    /// Number of glyphs currently cached in the text atlas. Test
636    /// surface — grows on the first frame each glyph is requested.
637    pub fn text_atlas_glyph_count(&self) -> usize {
638        self.text_pipeline.atlas().glyph_count()
639    }
640
641    /// Render a frame: record every per-chain pass (primary + optional
642    /// secondary) plus the final blend, copy warp outputs into their
643    /// feedback buffers, and submit. When no secondary chain is active the
644    /// blend pass acts as a passthrough of the primary chain's
645    /// `final_texture`.
646    pub fn render(&mut self) -> Result<()> {
647        let primary_uniforms = build_shader_uniforms_from(&self.state, &self.gpu);
648
649        let mut encoder = self
650            .gpu
651            .device
652            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
653                label: Some("MilkRenderer Encoder"),
654            });
655
656        self.chain.record_passes(
657            &self.gpu,
658            &mut encoder,
659            &self.state,
660            &primary_uniforms,
661            &self.sprite_pipeline,
662            &self.sprite_pool,
663            &self.sprite_frames,
664        );
665
666        // V.3 — drive the secondary chain with its own state (outgoing
667        // preset). Falls back to the primary state when the engine hasn't
668        // pushed a secondary state yet (transition just started).
669        // Sprites are global (not per-preset), so the secondary chain
670        // sees the *same* frames as the primary; the blend pass merges
671        // them at the comp output.
672        if let Some(secondary) = self.secondary.as_mut() {
673            let sec_state = self.secondary_state.as_ref().unwrap_or(&self.state);
674            let sec_uniforms = build_shader_uniforms_from(sec_state, &self.gpu);
675            secondary.record_passes(
676                &self.gpu,
677                &mut encoder,
678                sec_state,
679                &sec_uniforms,
680                &self.sprite_pipeline,
681                &self.sprite_pool,
682                &self.sprite_frames,
683            );
684        }
685
686        // Final blend: lerp primary↔secondary into the display texture.
687        // Passthrough of primary when `secondary` is `None`.
688        self.final_blend.record_pass(&mut encoder);
689
690        // Text overlay: draws messages on top of the blended output.
691        // Lives AFTER `final_blend` so it never feeds back through
692        // the warp loop (MD2's message overlay sits at the same
693        // stage — display-only, no smearing).
694        if !self.text_frames.is_empty() {
695            self.text_pipeline.record(
696                &mut encoder,
697                &self.gpu.queue,
698                &self.gpu.device,
699                self.final_blend.display_texture_view(),
700                &self.text_frames,
701                self.gpu.config.width,
702                self.gpu.config.height,
703            );
704        }
705
706        // Preserve each chain's warp output for next frame's feedback AFTER
707        // the comp pass has consumed `prev_texture`. The comp pass sees
708        // frame N-1's warp output for echo; gamma still stays out of the
709        // feedback loop because copy_to_prev sources `render_texture`
710        // (untouched by comp).
711        self.chain.copy_to_prev(&mut encoder, &self.gpu);
712        if let Some(secondary) = self.secondary.as_ref() {
713            secondary.copy_to_prev(&mut encoder, &self.gpu);
714        }
715
716        self.gpu.queue.submit(std::iter::once(encoder.finish()));
717
718        self.state.frame += 1;
719        if let Some(s) = self.secondary_state.as_mut() {
720            s.frame += 1;
721        }
722        Ok(())
723    }
724
725    /// Begin a preset transition: swap the current primary chain into the
726    /// secondary slot (where it keeps fading out the outgoing preset) and
727    /// allocate a fresh primary chain for the incoming preset.
728    ///
729    /// The blend pass is rebound so the **secondary** view points at the
730    /// outgoing preset (the old primary) and the **primary** view at the
731    /// new chain. `progress` starts at `0` (full secondary visible) and
732    /// the engine ramps it to `1` over the transition duration.
733    ///
734    /// Returns `Err` only if the new primary chain fails to allocate (rare
735    /// — a wgpu error).
736    pub fn start_transition(&mut self) -> Result<()> {
737        // If a transition is already in flight, drop the previous outgoing
738        // chain. The engine should normally not call this twice without
739        // letting the first transition finish; this is a safety net.
740        self.secondary = None;
741
742        let new_primary = RenderChain::new(&self.gpu, &self.warp_mesh)?;
743        let outgoing = std::mem::replace(&mut self.chain, new_primary);
744        self.install_secondary_chain(outgoing);
745        Ok(())
746    }
747
748    /// Install a caller-built secondary chain at the start of a preset
749    /// transition. The renderer's blend pass starts sourcing from it
750    /// immediately; the caller drives `set_transition_progress` each
751    /// frame until completion. Most callers want
752    /// [`Self::start_transition`] instead — this overload exists for
753    /// tests and for reclaiming a previous chain's textures.
754    pub fn install_secondary_chain(&mut self, chain: RenderChain) {
755        self.final_blend.set_inputs(
756            &self.gpu.device,
757            &self.chain.textures().final_texture_view,
758            Some(&chain.textures().final_texture_view),
759        );
760        self.final_blend.update_progress(&self.gpu.queue, 0.0, true);
761        self.secondary = Some(chain);
762    }
763
764    /// Drop the secondary chain at transition end and rebind the blend
765    /// pass to passthrough of the primary chain.
766    pub fn clear_secondary_chain(&mut self) -> Option<RenderChain> {
767        self.final_blend.set_inputs(
768            &self.gpu.device,
769            &self.chain.textures().final_texture_view,
770            None,
771        );
772        self.final_blend
773            .update_progress(&self.gpu.queue, 0.0, false);
774        self.secondary.take()
775    }
776
777    /// Push the current transition progress (in `[0, 1]`) into the blend
778    /// pass uniform. `0` = full secondary (outgoing preset); `1` = full
779    /// primary (incoming preset). Caller is expected to feed the eased
780    /// value from the engine's `TransitionManager`.
781    pub fn set_transition_progress(&self, progress: f32) {
782        self.final_blend
783            .update_progress(&self.gpu.queue, progress, self.secondary.is_some());
784    }
785
786    /// Borrow the secondary chain mutably — used by the engine to push
787    /// the outgoing preset's warp vertices / audio samples / etc. while
788    /// the transition is in flight. `None` outside transitions.
789    pub fn secondary_chain_mut(&mut self) -> Option<&mut RenderChain> {
790        self.secondary.as_mut()
791    }
792
793    /// Whether a secondary chain is currently active (a transition is in
794    /// flight).
795    pub fn is_transitioning(&self) -> bool {
796        self.secondary.is_some()
797    }
798
799    /// Get the displayable texture: the final-blend pass output that
800    /// composites primary + optional secondary chains. The GUI/CLI sample
801    /// from this for presentation.
802    ///
803    /// The name is kept for backwards compatibility with the pre-comp-pass
804    /// API (the GUI's render path imports it as
805    /// `engine.renderer().render_texture()`); renaming would be a breaking
806    /// change for consumers.
807    #[allow(clippy::misnamed_getters)]
808    pub fn render_texture(&self) -> &wgpu::Texture {
809        self.final_blend.display_texture()
810    }
811
812    /// Get the warp pass output before comp filtering. Useful for tests that
813    /// want to assert on the feedback chain content directly, bypassing
814    /// `gamma_adj` and any future display-only adjustments.
815    pub fn warp_output_texture(&self) -> &wgpu::Texture {
816        &self.chain.textures().render_texture
817    }
818
819    /// Read the current `final_texture` back as tightly-packed **RGBA8**
820    /// bytes (`width * height * 4`). Reflects what the screen shows
821    /// (post-comp), matching `render_texture()`. The byte order is
822    /// always R, G, B, A regardless of the surface format — BGRA
823    /// surfaces (the desktop default) are swizzled on the CPU before
824    /// the buffer returns so callers never have to branch on format.
825    ///
826    /// This is a synchronous CPU stall — intended for tests and offline
827    /// snapshots, not the hot rendering path. Wgpu requires the per-row
828    /// staging stride to be 256-byte aligned, so the helper copies into a
829    /// padded staging buffer and unpads it before returning.
830    pub fn read_render_texture(&self) -> Result<Vec<u8>> {
831        let width = self.gpu.config.width;
832        let height = self.gpu.config.height;
833        let bytes_per_pixel: u32 = 4;
834        let unpadded_row = width * bytes_per_pixel;
835        let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
836        let padded_row = unpadded_row.div_ceil(align) * align;
837
838        let staging = self.gpu.device.create_buffer(&wgpu::BufferDescriptor {
839            label: Some("Render Texture Readback Staging"),
840            size: (padded_row * height) as u64,
841            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
842            mapped_at_creation: false,
843        });
844
845        let mut encoder = self
846            .gpu
847            .device
848            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
849                label: Some("Readback Encoder"),
850            });
851        encoder.copy_texture_to_buffer(
852            wgpu::TexelCopyTextureInfo {
853                texture: self.final_blend.display_texture(),
854                mip_level: 0,
855                origin: wgpu::Origin3d::ZERO,
856                aspect: wgpu::TextureAspect::All,
857            },
858            wgpu::TexelCopyBufferInfo {
859                buffer: &staging,
860                layout: wgpu::TexelCopyBufferLayout {
861                    offset: 0,
862                    bytes_per_row: Some(padded_row),
863                    rows_per_image: Some(height),
864                },
865            },
866            wgpu::Extent3d {
867                width,
868                height,
869                depth_or_array_layers: 1,
870            },
871        );
872        self.gpu.queue.submit(std::iter::once(encoder.finish()));
873
874        let slice = staging.slice(..);
875        let (tx, rx) = std::sync::mpsc::channel();
876        slice.map_async(wgpu::MapMode::Read, move |res| {
877            let _ = tx.send(res);
878        });
879        self.gpu
880            .device
881            .poll(wgpu::PollType::wait_indefinitely())
882            .ok();
883        rx.recv()
884            .map_err(|e| crate::error::RenderError::RenderFailed(e.to_string()))?
885            .map_err(|e| crate::error::RenderError::RenderFailed(format!("{e:?}")))?;
886
887        let view = slice.get_mapped_range();
888        let mut out = Vec::with_capacity((unpadded_row * height) as usize);
889        for row in 0..height {
890            let start = (row * padded_row) as usize;
891            let end = start + unpadded_row as usize;
892            out.extend_from_slice(&view[start..end]);
893        }
894        drop(view);
895        staging.unmap();
896
897        // Normalise to RGBA8 byte order. BGRA8 (desktop default on most
898        // backends) gets its R/B channels swapped in place.
899        let fmt = self.gpu.config.texture_format.to_wgpu();
900        if matches!(
901            fmt,
902            wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb
903        ) {
904            for px in out.chunks_exact_mut(4) {
905                px.swap(0, 2);
906            }
907        }
908
909        Ok(out)
910    }
911
912    pub fn state(&self) -> &RenderState {
913        &self.state
914    }
915
916    /// Push the outgoing preset's `RenderState` for the secondary chain.
917    /// Called by the engine each frame during a transition; cleared by
918    /// passing `None` once the transition completes.
919    pub fn update_secondary_state(&mut self, state: Option<RenderState>) {
920        self.secondary_state = state;
921    }
922
923    /// Push the outgoing preset's per-vertex warp UVs to the secondary
924    /// chain. No-op when no secondary chain is active. Mirrors
925    /// `update_warp_vertices` for the primary chain.
926    pub fn update_secondary_warp_vertices(&mut self, vertices: &[WarpVertex]) {
927        let cols = self.warp_mesh.cols;
928        let rows = self.warp_mesh.rows;
929        if let Some(sec) = self.secondary.as_mut() {
930            sec.update_warp_vertices(&self.gpu.queue, cols, rows, vertices);
931        }
932    }
933
934    /// Push the outgoing preset's audio samples to the secondary chain's
935    /// waveform pass. No-op when no secondary chain is active. The
936    /// `wave_params` argument carries the smoothing factor for the IIR.
937    pub fn update_secondary_waveform_samples(
938        &mut self,
939        samples: &[f32],
940        instantaneous_volume: f32,
941        wave_params: crate::config::WaveParams,
942    ) {
943        if let Some(sec) = self.secondary.as_mut() {
944            sec.update_waveform_samples(
945                &self.gpu.queue,
946                samples,
947                instantaneous_volume,
948                wave_params,
949            );
950        }
951    }
952
953    /// Stereo variant of `update_secondary_waveform_samples` for the
954    /// outgoing preset during a transition. Mirrors the primary
955    /// chain's `update_waveform_samples_lr`.
956    pub fn update_secondary_waveform_samples_lr(
957        &mut self,
958        left: &[f32],
959        right: &[f32],
960        instantaneous_volume: f32,
961        wave_params: crate::config::WaveParams,
962    ) {
963        if let Some(sec) = self.secondary.as_mut() {
964            sec.update_waveform_samples_lr(
965                &self.gpu.queue,
966                left,
967                right,
968                instantaneous_volume,
969                wave_params,
970            );
971        }
972    }
973
974    /// Push the outgoing preset's custom-wave vertex stream to the secondary
975    /// chain. No-op when no secondary chain is active.
976    pub fn update_secondary_custom_waves(
977        &mut self,
978        vertices: &[CustomWaveVertex],
979        batches: &[CustomWaveBatch],
980    ) {
981        if let Some(sec) = self.secondary.as_mut() {
982            sec.update_custom_waves(&self.gpu.queue, vertices, batches);
983        }
984    }
985
986    /// Push the outgoing preset's custom-shape instance stream to the
987    /// secondary chain. No-op when no secondary chain is active.
988    pub fn update_secondary_custom_shapes(
989        &mut self,
990        instances: &[CustomShapeInstance],
991        batches: &[CustomShapeBatch],
992    ) {
993        let aspect = self.gpu.aspect_ratio();
994        if let Some(sec) = self.secondary.as_mut() {
995            sec.update_custom_shapes(&self.gpu.queue, instances, batches, aspect);
996        }
997    }
998
999    /// Resize render targets. Re-allocates per-chain textures, the blend
1000    /// display target, and rebinds the blend pass to the new primary view.
1001    pub fn resize(&mut self, width: u32, height: u32) {
1002        self.gpu.set_resolution(width, height);
1003        self.warp_mesh = WarpMesh::new(
1004            self.warp_mesh.cols,
1005            self.warp_mesh.rows,
1006            self.gpu.aspect_ratio(),
1007        );
1008        self.chain.resize(&self.gpu);
1009        if let Some(secondary) = self.secondary.as_mut() {
1010            secondary.resize(&self.gpu);
1011        }
1012        self.final_blend.resize(&self.gpu.device, &self.gpu.config);
1013        let secondary_view = self
1014            .secondary
1015            .as_ref()
1016            .map(|s| s.textures().final_texture_view.clone());
1017        self.final_blend.set_inputs(
1018            &self.gpu.device,
1019            &self.chain.textures().final_texture_view,
1020            secondary_view.as_ref(),
1021        );
1022    }
1023}
1024
1025/// Build the `ShaderUniforms` payload from any `RenderState` + GPU context.
1026/// Shared between the primary and secondary render paths so each chain can
1027/// run with its own `gamma_adj` / `echo_alpha` / audio / q snapshot.
1028fn build_shader_uniforms_from(state: &RenderState, gpu: &GpuContext) -> ShaderUniforms {
1029    let aspect = gpu.aspect_ratio();
1030    let aspect_x = if aspect > 1.0 { 1.0 / aspect } else { 1.0 };
1031    let aspect_y = if aspect < 1.0 { aspect } else { 1.0 };
1032    let w = gpu.config.width as f32;
1033    let h = gpu.config.height as f32;
1034    let audio = &state.audio;
1035    let mut u = ShaderUniforms {
1036        time: state.time,
1037        frame: state.frame as f32,
1038        progress: state.progress,
1039        bass: audio.bass,
1040        mid: audio.mid,
1041        treb: audio.treb,
1042        vol: (audio.bass + audio.mid + audio.treb) / 3.0,
1043        bass_att: audio.bass_att,
1044        mid_att: audio.mid_att,
1045        treb_att: audio.treb_att,
1046        vol_att: (audio.bass_att + audio.mid_att + audio.treb_att) / 3.0,
1047        gamma_adj: state.gamma_adj,
1048        echo_zoom: state.echo_zoom,
1049        echo_alpha: state.echo_alpha,
1050        echo_orient: state.echo_orient as f32,
1051        red_blue_stereo: if state.red_blue_stereo { 1.0 } else { 0.0 },
1052        aspect: [aspect_x, aspect_y, 1.0 / aspect_x, 1.0 / aspect_y],
1053        texsize: [w, h, 1.0 / w, 1.0 / h],
1054        hue_shader: hue_shader_for(state.time),
1055        ..ShaderUniforms::default()
1056    };
1057    u.set_q_channels(&state.q_snapshot);
1058    u
1059}
1060
1061/// Time-driven RGB tint MD2 cycles through each frame. Three oscillators
1062/// with mutually-prime periods produce a slow rainbow rotation that
1063/// never repeats (rationals on the unit circle); presets that multiply
1064/// `ret * hue_shader` get the rainbow rotation MD2 is tuned for, while
1065/// presets that ignore it keep their authored colour palette because
1066/// the per-channel output stays in `[0, 1]` and floats around `~0.5`.
1067/// `.w` is reserved for a future per-corner mix factor.
1068fn hue_shader_for(time: f32) -> [f32; 4] {
1069    let r = 0.5 + 0.5 * (time * 0.34 + 1.0).cos();
1070    let g = 0.5 + 0.5 * (time * 0.47 + 2.0).cos();
1071    let b = 0.5 + 0.5 * (time * 0.27 + 3.0).cos();
1072    [r, g, b, 0.0]
1073}
1074
1075#[cfg(test)]
1076mod tests {
1077    use super::*;
1078    use crate::waveform::NUM_WAVE_SAMPLES;
1079
1080    #[test]
1081    fn test_renderer_creation() {
1082        let config = RenderConfig::default();
1083        let renderer = pollster::block_on(MilkRenderer::new(config));
1084        assert!(renderer.is_ok());
1085    }
1086
1087    #[test]
1088    fn test_render_frame() {
1089        let config = RenderConfig::default();
1090        let mut renderer = pollster::block_on(MilkRenderer::new(config)).unwrap();
1091        let result = renderer.render();
1092        assert!(result.is_ok());
1093    }
1094
1095    #[test]
1096    fn test_render_texture() {
1097        let config = RenderConfig::default();
1098        let width = config.width;
1099        let height = config.height;
1100        let renderer = pollster::block_on(MilkRenderer::new(config)).unwrap();
1101        let texture = renderer.render_texture();
1102        assert_eq!(texture.width(), width);
1103        assert_eq!(texture.height(), height);
1104    }
1105
1106    #[test]
1107    fn test_multiple_renders() {
1108        let config = RenderConfig::default();
1109        let mut renderer = pollster::block_on(MilkRenderer::new(config)).unwrap();
1110        for _ in 0..10 {
1111            assert!(renderer.render().is_ok());
1112        }
1113        assert_eq!(renderer.state().frame, 10);
1114    }
1115
1116    /// `set_user_comp_shader` end-to-end: a real, simple MD2-style HLSL body
1117    /// must translate, validate, and end up driving the comp pass. The
1118    /// `has_user_comp_shader()` flag is the visible test surface; the
1119    /// pipeline-level swap is exercised by the comp_pipeline tests.
1120    #[test]
1121    fn user_comp_shader_swap_via_renderer_api() {
1122        let mut r = pollster::block_on(MilkRenderer::new(RenderConfig::default())).unwrap();
1123        // The single-line MD2-style body the translator handles cleanly:
1124        // brace-stripped wrapper, `tex2D`/`saturate` already covered, no
1125        // implicit-broadcast traps.
1126        let hlsl = "shader_body { ret = tex2D(sampler_main, uv).xyz; ret *= 1.5; }";
1127        let ok = r
1128            .set_user_comp_shader(Some(hlsl))
1129            .expect("compile path must not error on valid HLSL");
1130        assert!(ok, "expected compile success, got fallback");
1131        assert!(r.has_user_comp_shader());
1132
1133        // None reverts to default.
1134        let _ = r.set_user_comp_shader(None);
1135        assert!(!r.has_user_comp_shader());
1136    }
1137
1138    /// Mirror of `user_comp_shader_swap_via_renderer_api` for the warp
1139    /// pipeline. Same MD2-style body shape; the renderer's API flips the
1140    /// warp pass from the engine's hand-written `warp.wgsl` to a
1141    /// translated user pipeline. The reset path (`None`) returns to the
1142    /// default warp.
1143    #[test]
1144    fn user_warp_shader_swap_via_renderer_api() {
1145        let mut r = pollster::block_on(MilkRenderer::new(RenderConfig::default())).unwrap();
1146        let hlsl = "shader_body { ret = tex2D(sampler_main, uv).xyz * 0.9; ret -= 0.01; }";
1147        let ok = r
1148            .set_user_warp_shader(Some(hlsl))
1149            .expect("compile path must not error on valid HLSL");
1150        assert!(ok, "expected warp compile success, got fallback");
1151        assert!(r.has_user_warp_shader());
1152
1153        // The render loop must keep working — a frame draws with the
1154        // user pipeline active.
1155        assert!(r.render().is_ok());
1156
1157        // Reset reverts to the default warp.
1158        let _ = r.set_user_warp_shader(None);
1159        assert!(!r.has_user_warp_shader());
1160        assert!(r.render().is_ok());
1161    }
1162
1163    /// Invalid warp HLSL must NOT propagate as an error — same contract
1164    /// the comp side honours. The pass falls back to the default
1165    /// `warp.wgsl`, the renderer reports `Ok(false)`, and the frame
1166    /// still draws.
1167    #[test]
1168    fn user_warp_shader_invalid_hlsl_falls_back_gracefully() {
1169        let mut r = pollster::block_on(MilkRenderer::new(RenderConfig::default())).unwrap();
1170        let ok = r
1171            .set_user_warp_shader(Some("not HLSL @@@ {{{"))
1172            .expect("invalid input must not bubble up as renderer error");
1173        assert!(!ok);
1174        assert!(!r.has_user_warp_shader());
1175        assert!(r.render().is_ok());
1176    }
1177
1178    /// Garbage HLSL must NOT propagate as an error from the renderer — the
1179    /// comp pass falls back to the default and reports `Ok(false)`. This is
1180    /// the contract the engine relies on to keep navigation usable when a
1181    /// preset's shader can't be translated.
1182    #[test]
1183    fn user_comp_shader_invalid_hlsl_falls_back_gracefully() {
1184        let mut r = pollster::block_on(MilkRenderer::new(RenderConfig::default())).unwrap();
1185        let ok = r
1186            .set_user_comp_shader(Some(
1187                "this is not even close to HLSL — squiggly braces { everywhere",
1188            ))
1189            .expect("invalid input must not bubble up as renderer error");
1190        assert!(!ok, "expected fallback, got success");
1191        assert!(!r.has_user_comp_shader());
1192        // Renderer still renders cleanly with the default shader.
1193        assert!(r.render().is_ok());
1194    }
1195
1196    /// Noise sampling end-to-end: a user comp shader that samples the
1197    /// procedural noise pack must translate, validate, and drive the comp
1198    /// pass. This covers the noise sampler bindings (6–10) and the
1199    /// sampler variants (`sampler_pw_main` → `sampler_pw`): the translator
1200    /// emits distinct bindings, the wrapper declares them, and the
1201    /// renderer binds them.
1202    #[test]
1203    fn noise_sampling_user_shader_compiles_through_renderer() {
1204        let mut r = pollster::block_on(MilkRenderer::new(RenderConfig::default())).unwrap();
1205        let hlsl = "shader_body {\n\
1206            ret = tex2D(sampler_noise_lq, uv).xyz;\n\
1207            ret += tex2D(sampler_pw_noise_hq, uv * 4).xyz * 0.25;\n\
1208            ret += tex3D(sampler_noisevol_hq, vec3<f32>(uv, time)).xyz * 0.5;\n\
1209            ret *= tex2D(sampler_pw_main, uv).xyz + 0.25;\n\
1210        }";
1211        let ok = r
1212            .set_user_comp_shader(Some(hlsl))
1213            .expect("compile path must not error");
1214        assert!(
1215            ok,
1216            "noise-sampling shader should compile through noise bindings"
1217        );
1218        assert!(r.has_user_comp_shader());
1219        // Renderer can drive a frame with this shader bound — covers
1220        // pipeline draw with the full 15-binding bind group.
1221        assert!(r.render().is_ok());
1222    }
1223
1224    /// Empty / whitespace-only HLSL is treated as "no user shader" — same
1225    /// path as `None`. Avoids a needless compile attempt for presets that
1226    /// declare `comp_shader=` with no content.
1227    #[test]
1228    fn user_comp_shader_empty_input_resets_to_default() {
1229        let mut r = pollster::block_on(MilkRenderer::new(RenderConfig::default())).unwrap();
1230        // Install a real one first…
1231        let _ = r
1232            .set_user_comp_shader(Some("shader_body { ret = vec3<f32>(1, 0, 0); }"))
1233            .unwrap();
1234        assert!(r.has_user_comp_shader());
1235        // …then reset via empty.
1236        let ok = r.set_user_comp_shader(Some("   \n\t  ")).unwrap();
1237        assert!(ok);
1238        assert!(!r.has_user_comp_shader());
1239    }
1240
1241    #[test]
1242    fn warp_mesh_exposed() {
1243        let cfg = RenderConfig::default();
1244        let r = pollster::block_on(MilkRenderer::new(cfg)).unwrap();
1245        let m = r.warp_mesh();
1246        assert_eq!(m.cols, DEFAULT_MESH_COLS);
1247        assert_eq!(m.rows, DEFAULT_MESH_ROWS);
1248        assert_eq!(
1249            m.vertex_count(),
1250            (DEFAULT_MESH_COLS * DEFAULT_MESH_ROWS) as usize
1251        );
1252    }
1253
1254    // -----------------------------------------------------------------
1255    // User-loaded textures + sampler_rand0X end-to-end
1256    // -----------------------------------------------------------------
1257
1258    /// Helper: write a 4×4 solid-colour PNG so a test can synthesise a pool
1259    /// without checking fixtures into the repo. Returns the tempdir handle
1260    /// so the file lives as long as the binding.
1261    fn make_texture_pool(
1262        gpu_device: &wgpu::Device,
1263        gpu_queue: &wgpu::Queue,
1264        names: &[&str],
1265    ) -> (tempfile::TempDir, crate::TexturePool) {
1266        let dir = tempfile::tempdir().unwrap();
1267        for n in names {
1268            let path = dir.path().join(format!("{n}.png"));
1269            let pixels: Vec<u8> = (0..16).flat_map(|_| [10u8, 20, 30, 255]).collect();
1270            let img = image::RgbaImage::from_raw(4, 4, pixels).unwrap();
1271            img.save_with_format(&path, image::ImageFormat::Png)
1272                .unwrap();
1273        }
1274        let pool =
1275            crate::TexturePool::from_dirs(gpu_device, gpu_queue, &[dir.path().to_path_buf()]);
1276        (dir, pool)
1277    }
1278
1279    /// A preset that samples a disk-loaded `sampler_clouds` must compile
1280    /// successfully when the renderer's pool has that texture loaded.
1281    /// The translator lands the sampler on its user-texture binding
1282    /// instead of falling back to `sampler_main` with a
1283    /// `/*was: sampler_clouds*/` debug comment.
1284    #[test]
1285    fn user_texture_drives_user_shader_compile() {
1286        let mut r = pollster::block_on(MilkRenderer::new(RenderConfig::default())).unwrap();
1287        let (_keepalive, pool) =
1288            make_texture_pool(r.gpu.device.as_ref(), r.gpu.queue.as_ref(), &["clouds"]);
1289        r.set_texture_pool(pool);
1290        assert_eq!(r.texture_pool().len(), 1);
1291
1292        let hlsl = "sampler sampler_clouds;\n\
1293                    shader_body { ret = tex2D(sampler_clouds, uv).xyz; }";
1294        let ok = r
1295            .set_user_comp_shader(Some(hlsl))
1296            .expect("plan-driven compile must not error");
1297        assert!(ok, "expected user-texture compile to succeed");
1298        assert!(r.has_user_comp_shader());
1299        assert!(r.render().is_ok());
1300    }
1301
1302    /// `sampler_rand02` resolves to a pool texture deterministically per
1303    /// preset id. Same id → same texture binding; different ids → may
1304    /// differ. The test asserts the deterministic-per-id contract.
1305    #[test]
1306    fn sampler_rand0x_resolves_deterministically_per_preset() {
1307        let mut r = pollster::block_on(MilkRenderer::new(RenderConfig::default())).unwrap();
1308        let (_keepalive, pool) = make_texture_pool(
1309            r.gpu.device.as_ref(),
1310            r.gpu.queue.as_ref(),
1311            &["alpha", "beta", "gamma"],
1312        );
1313        r.set_texture_pool(pool);
1314
1315        let hlsl = "sampler sampler_rand02;\n\
1316                    shader_body { ret = tex2D(sampler_rand02, uv).xyz; }";
1317
1318        // Same preset id twice → same compilation result.
1319        let ok_a = r
1320            .set_user_comp_shader_for_preset(Some(hlsl), "Geiss - Aerial.milk")
1321            .unwrap();
1322        assert!(ok_a);
1323        let ok_b = r
1324            .set_user_comp_shader_for_preset(Some(hlsl), "Geiss - Aerial.milk")
1325            .unwrap();
1326        assert!(ok_b);
1327        // Different preset id is *allowed* to pick the same texture
1328        // (small pools have collisions) — we only test the no-error and
1329        // no-fallback path, not that the choice differs.
1330        let ok_c = r
1331            .set_user_comp_shader_for_preset(Some(hlsl), "Krash - Mandala.milk")
1332            .unwrap();
1333        assert!(ok_c);
1334    }
1335
1336    /// A user shader referencing a missing pool texture must still compile
1337    /// (binding routed through the user slot, but bound to the fallback
1338    /// 1×1 white texture). Sampling produces `vec4(1, 1, 1, 1)` so the
1339    /// preset's math still terminates in a valid output.
1340    #[test]
1341    fn missing_user_texture_uses_fallback_not_error() {
1342        let mut r = pollster::block_on(MilkRenderer::new(RenderConfig::default())).unwrap();
1343        // No pool populated — only the fallback.
1344        let hlsl = "sampler sampler_clouds;\n\
1345                    shader_body { ret = tex2D(sampler_clouds, uv).xyz; }";
1346        let ok = r
1347            .set_user_comp_shader(Some(hlsl))
1348            .expect("missing texture must not surface as renderer error");
1349        assert!(ok);
1350        assert!(r.has_user_comp_shader());
1351        assert!(r.render().is_ok());
1352    }
1353
1354    // -----------------------------------------------------------------
1355    // Waveform pass dispatched end-to-end
1356    // -----------------------------------------------------------------
1357
1358    /// Every MD2 wave mode (0..7) must render without panicking through
1359    /// the dispatched wave pass. We seed deterministic sample data, set
1360    /// the mode, and read back the final framebuffer — no specific
1361    /// pattern assertion (mode geometry would tie the test to the
1362    /// shader), just "ran clean and produced non-zero output".
1363    #[test]
1364    fn waveform_dispatch_runs_for_every_md2_mode() {
1365        for mode in 0..=7 {
1366            let mut r = pollster::block_on(MilkRenderer::new(RenderConfig::default())).unwrap();
1367            // Set a recognisable wave colour so the pass actually writes
1368            // something we can detect in the framebuffer.
1369            let mut s = RenderState::default();
1370            s.wave.mode = mode;
1371            s.wave.r = 1.0;
1372            s.wave.g = 0.5;
1373            s.wave.b = 0.0;
1374            s.wave.a = 1.0;
1375            s.wave.scale = 0.5;
1376            s.wave.thick = mode % 2 == 0;
1377            s.wave.additive = mode == 3;
1378            s.wave.dots = mode == 5;
1379            r.update_state(s);
1380            // Deterministic sine wave so the renderer has signal to draw.
1381            let samples: Vec<f32> = (0..NUM_WAVE_SAMPLES)
1382                .map(|i| (i as f32 * 0.05).sin() * 0.6)
1383                .collect();
1384            r.update_waveform_samples(&samples, 0.5);
1385            r.render().unwrap();
1386            let frame = r.read_render_texture().unwrap();
1387            assert!(
1388                frame.iter().any(|&b| b != 0),
1389                "mode {} produced an all-zero framebuffer",
1390                mode
1391            );
1392        }
1393    }
1394
1395    /// `update_waveform_samples` must always end up with exactly
1396    /// `NUM_WAVE_SAMPLES` entries (zero-padded if shorter) so the GPU
1397    /// storage upload size matches the pipeline expectation.
1398    #[test]
1399    fn waveform_samples_buffer_is_canonicalised() {
1400        use crate::waveform::NUM_WAVE_SAMPLES;
1401        let mut r = pollster::block_on(MilkRenderer::new(RenderConfig::default())).unwrap();
1402        r.update_waveform_samples(&[0.5, -0.5, 0.25], 0.1);
1403        assert_eq!(r.wave_samples().len(), NUM_WAVE_SAMPLES);
1404        // Padding tail is zero.
1405        assert!(r.wave_samples().iter().skip(3).all(|&v| v.abs() < 1e-6));
1406    }
1407
1408    /// Smoothing applied to the sample buffer attenuates spike-y input.
1409    /// Drives the smoothing path that runs before each GPU upload.
1410    #[test]
1411    fn waveform_smoothing_path_attenuates() {
1412        let mut r = pollster::block_on(MilkRenderer::new(RenderConfig::default())).unwrap();
1413        let mut s = RenderState::default();
1414        s.wave.smoothing = 0.9;
1415        r.update_state(s);
1416        let spiky: Vec<f32> = (0..NUM_WAVE_SAMPLES)
1417            .map(|i| if i % 2 == 0 { 1.0 } else { -1.0 })
1418            .collect();
1419        r.update_waveform_samples(&spiky, 0.0);
1420        let max = r.wave_samples().iter().cloned().fold(f32::MIN, f32::max);
1421        let min = r.wave_samples().iter().cloned().fold(f32::MAX, f32::min);
1422        assert!(
1423            (max - min) < 1.5,
1424            "smoothing did not attenuate spiky input (peak-to-peak {})",
1425            max - min
1426        );
1427    }
1428
1429    /// `additive` and `dots` flags propagate through `RenderState` and
1430    /// drive the pipeline-selection branch in `render`. The visible
1431    /// test surface is "render() succeeds for every combination" — the
1432    /// exact pixel output is the shader's job, covered by other tests.
1433    #[test]
1434    fn waveform_blend_and_dots_flags_dispatch() {
1435        for &(thick, dots, additive) in &[
1436            (false, false, false),
1437            (true, false, false),
1438            (false, true, false),
1439            (false, false, true),
1440            (true, true, true),
1441        ] {
1442            let mut r = pollster::block_on(MilkRenderer::new(RenderConfig::default())).unwrap();
1443            let mut s = RenderState::default();
1444            s.wave.mode = 0;
1445            s.wave.thick = thick;
1446            s.wave.dots = dots;
1447            s.wave.additive = additive;
1448            r.update_state(s);
1449            r.update_waveform_samples(&[0.3_f32; NUM_WAVE_SAMPLES], 0.4);
1450            r.render().expect("render must succeed for any flag combo");
1451        }
1452    }
1453
1454    /// `b_mod_wave_alpha_by_volume` should make the wave dim at silence
1455    /// and brighter at high volume. We exercise both ends and check the
1456    /// renderer doesn't reject either — the geometry/colour test for the
1457    /// envelope itself is the `build_uniforms_mod_alpha_*` unit test.
1458    #[test]
1459    fn waveform_mod_alpha_by_volume_drives_dispatch() {
1460        let mut r = pollster::block_on(MilkRenderer::new(RenderConfig::default())).unwrap();
1461        let mut s = RenderState::default();
1462        s.wave.mod_alpha_by_volume = true;
1463        s.wave.mod_alpha_start = 0.0;
1464        s.wave.mod_alpha_end = 1.0;
1465        r.update_state(s);
1466
1467        r.update_waveform_samples(&[0.2_f32; NUM_WAVE_SAMPLES], 0.0);
1468        r.render().unwrap();
1469        r.update_waveform_samples(&[0.2_f32; NUM_WAVE_SAMPLES], 1.0);
1470        r.render().unwrap();
1471    }
1472
1473    #[test]
1474    fn rendering_is_deterministic_within_session() {
1475        // Two independent renderer instances driven through the same warp UV
1476        // sequence must produce the same final framebuffer. We seed the warp
1477        // UVs by hand (no audio, no presets) so the test does not depend on
1478        // any other engine state.
1479        let make = || {
1480            let mut r = pollster::block_on(MilkRenderer::new(RenderConfig::default())).unwrap();
1481            for frame in 0..5 {
1482                let mesh = r.warp_mesh();
1483                // Identity warp + slight time-dependent shift so consecutive
1484                // frames diverge from a single "all-zeroes" output.
1485                let verts: Vec<crate::warp_pipeline::WarpVertex> = mesh
1486                    .vertices
1487                    .iter()
1488                    .map(|v| {
1489                        let shift = (frame as f32) * 0.01;
1490                        crate::warp_pipeline::WarpVertex {
1491                            pos_clip: v.pos_clip,
1492                            uv_warp: [v.uv_orig[0] + shift, v.uv_orig[1] + shift],
1493                        }
1494                    })
1495                    .collect();
1496                r.update_warp_vertices(&verts);
1497                r.render().unwrap();
1498            }
1499            r.read_render_texture().unwrap()
1500        };
1501
1502        let a = make();
1503        let b = make();
1504        assert_eq!(a.len(), b.len(), "readback length mismatch");
1505        assert_eq!(a, b, "non-deterministic rendering: byte-level mismatch");
1506        // Sanity: not all zeroes — confirms the test actually exercised the
1507        // pipeline rather than reading an untouched texture.
1508        assert!(
1509            a.iter().any(|&b| b != 0),
1510            "framebuffer is all-zero, test did not actually render"
1511        );
1512    }
1513
1514    // -----------------------------------------------------------------
1515    // Borders + motion vectors dispatched end-to-end
1516    // -----------------------------------------------------------------
1517
1518    /// `RenderState::default` has zero alpha on both border rings and
1519    /// the motion-vector grid, so the renderer must take the no-op
1520    /// path: zero draw calls, zero segments.
1521    #[test]
1522    fn borders_and_motion_vectors_default_to_noop() {
1523        let mut r = pollster::block_on(MilkRenderer::new(RenderConfig::default())).unwrap();
1524        r.update_state(RenderState::default());
1525        r.render().unwrap();
1526        assert_eq!(r.border_batch_count(), 0);
1527        assert_eq!(r.motion_vector_segment_count(), 0);
1528    }
1529
1530    /// Enabling either ring should produce exactly one batch; enabling
1531    /// both must produce two. Mirrors the typical preset case of "outer
1532    /// border off, inner on" → one ring drawn.
1533    #[test]
1534    fn borders_batch_count_tracks_visibility() {
1535        let mut r = pollster::block_on(MilkRenderer::new(RenderConfig::default())).unwrap();
1536        let mut s = RenderState::default();
1537        s.borders.outer_size = 0.02;
1538        s.borders.outer_color = [1.0, 0.5, 0.0, 1.0];
1539        r.update_state(s);
1540        r.render().unwrap();
1541        assert_eq!(r.border_batch_count(), 1);
1542
1543        s.borders.inner_size = 0.01;
1544        s.borders.inner_color = [0.2, 0.4, 0.6, 0.8];
1545        r.update_state(s);
1546        r.render().unwrap();
1547        assert_eq!(r.border_batch_count(), 2);
1548    }
1549
1550    /// Motion-vector grid produces `nx * ny` segments when enabled and
1551    /// clamps at the MD2 cap of 64×48 regardless of the requested size.
1552    #[test]
1553    fn motion_vector_segments_grid_and_cap() {
1554        let mut r = pollster::block_on(MilkRenderer::new(RenderConfig::default())).unwrap();
1555        let mut s = RenderState::default();
1556        s.motion_vectors.grid_x = 8;
1557        s.motion_vectors.grid_y = 6;
1558        s.motion_vectors.color = [1.0, 1.0, 1.0, 0.7];
1559        r.update_state(s);
1560        r.render().unwrap();
1561        assert_eq!(r.motion_vector_segment_count(), 8 * 6);
1562
1563        // Way past the MD2 cap: must clamp to 64 × 48.
1564        s.motion_vectors.grid_x = 200;
1565        s.motion_vectors.grid_y = 100;
1566        r.update_state(s);
1567        r.render().unwrap();
1568        assert_eq!(r.motion_vector_segment_count(), 64 * 48);
1569    }
1570
1571    /// Setting either alpha to zero on a previously visible pass must
1572    /// flip the renderer back to the no-op path (no batches / no
1573    /// segments). Per-frame eqs in MD2 can toggle these mid-preset.
1574    #[test]
1575    fn alpha_zero_disables_pass_again() {
1576        let mut r = pollster::block_on(MilkRenderer::new(RenderConfig::default())).unwrap();
1577        let mut s = RenderState::default();
1578        s.borders.outer_size = 0.02;
1579        s.borders.outer_color = [1.0, 0.0, 0.0, 1.0];
1580        s.motion_vectors.grid_x = 4;
1581        s.motion_vectors.grid_y = 3;
1582        s.motion_vectors.color = [1.0, 1.0, 1.0, 1.0];
1583        r.update_state(s);
1584        r.render().unwrap();
1585        assert_eq!(r.border_batch_count(), 1);
1586        assert_eq!(r.motion_vector_segment_count(), 12);
1587
1588        s.borders.outer_color[3] = 0.0;
1589        s.motion_vectors.color[3] = 0.0;
1590        r.update_state(s);
1591        r.render().unwrap();
1592        assert_eq!(r.border_batch_count(), 0);
1593        assert_eq!(r.motion_vector_segment_count(), 0);
1594    }
1595}