onedrop/
main.rs

1//! OneDrop CLI - Command-line interface for MilkDrop visualizations
2
3use anyhow::{Context, Result};
4use clap::{Parser, Subcommand};
5use onedrop_engine::{EngineConfig, MeshQuality, MilkEngine, RenderConfig};
6use std::path::PathBuf;
7
8/// Parse `--mesh-size` arg: accepts named presets (`low`, `medium`,
9/// `high`, `ultra`) or an explicit `COLSxROWS` form (e.g. `64x48`).
10/// Returns the resolved `(cols, rows)`. Values are clamped to MD2's
11/// `[2, 192] × [2, 96]` window downstream by `MilkRenderer::set_mesh_size`.
12fn parse_mesh_size(s: &str) -> Result<(u32, u32), String> {
13    let trimmed = s.trim();
14    match trimmed.to_ascii_lowercase().as_str() {
15        "low" => return Ok((32, 24)),
16        "medium" | "med" => return Ok((48, 36)),
17        "high" => return Ok((64, 48)),
18        "ultra" => return Ok((96, 72)),
19        _ => {}
20    }
21    let (c, r) = trimmed
22        .split_once(['x', 'X', '×'])
23        .ok_or_else(|| format!("expected COLSxROWS or a preset name, got {trimmed:?}"))?;
24    let cols: u32 = c
25        .trim()
26        .parse()
27        .map_err(|e| format!("invalid cols {c:?}: {e}"))?;
28    let rows: u32 = r
29        .trim()
30        .parse()
31        .map_err(|e| format!("invalid rows {r:?}: {e}"))?;
32    Ok((cols, rows))
33}
34
35#[derive(Parser)]
36#[command(name = "onedrop")]
37#[command(author = "all3f0r1")]
38#[command(version = "0.1.0")]
39#[command(about = "OneDrop — pure-Rust MilkDrop 2.0d visualizer", long_about = None)]
40struct Cli {
41    #[command(subcommand)]
42    command: Commands,
43
44    /// Enable verbose logging
45    #[arg(short, long, global = true)]
46    verbose: bool,
47}
48
49#[derive(Subcommand)]
50enum Commands {
51    /// Show information about a preset
52    Info {
53        /// Path to the .milk preset file
54        preset: PathBuf,
55    },
56
57    /// Validate a preset file (or, with `--strict`, a directory of presets).
58    Validate {
59        /// Path to a `.milk` preset OR a directory containing `.milk`
60        /// files. A directory is only accepted in `--strict` mode.
61        preset: PathBuf,
62
63        /// Run the full pipeline (parse + eval + warp HLSL→WGSL + comp
64        /// HLSL→WGSL→naga-validate) and classify every failure by
65        /// stage / kind. Emits a CSV alongside the input when run on
66        /// a directory.
67        #[arg(long)]
68        strict: bool,
69
70        /// CSV output path. Only used in `--strict` mode against a
71        /// directory; defaults to `<dir>.validate_strict.csv` next to
72        /// the input.
73        #[arg(short, long)]
74        output: Option<PathBuf>,
75    },
76
77    /// Render a preset to images
78    Render {
79        /// Path to the .milk preset file
80        preset: PathBuf,
81
82        /// Number of frames to render
83        #[arg(short, long, default_value = "60")]
84        frames: u32,
85
86        /// Output directory for frames
87        #[arg(short, long, default_value = "output")]
88        output: PathBuf,
89
90        /// Width of output
91        #[arg(short, long, default_value = "1280")]
92        width: u32,
93
94        /// Height of output
95        #[arg(short = 'H', long, default_value = "720")]
96        height: u32,
97
98        /// Warp mesh size. Accepts a preset name (`low`, `medium`,
99        /// `high`, `ultra`) or an explicit `COLSxROWS` (e.g. `64x48`).
100        /// Defaults to `medium` (48×36). MD2 caps at 192×96.
101        #[arg(short = 'm', long, default_value = "medium", value_parser = parse_mesh_size)]
102        mesh_size: (u32, u32),
103    },
104
105    /// List all presets in a directory
106    List {
107        /// Directory containing .milk files
108        directory: PathBuf,
109    },
110
111    /// Try to compile every preset's comp shader through the user-shader
112    /// pipeline (HLSL translate → WGSL wrap → naga validate). Reports
113    /// success/fail counts and writes a CSV next to the directory.
114    CompileShaders {
115        /// Directory containing .milk files
116        directory: PathBuf,
117
118        /// Output CSV path (default: alongside the input dir).
119        #[arg(short, long)]
120        output: Option<PathBuf>,
121    },
122
123    /// List capture sources the active cpal host can see, with
124    /// monitor / microphone / other classification. The autodetect
125    /// pick (the monitor of the host's default output sink, when
126    /// present) is flagged with an arrow — that's what
127    /// `AudioInput::with_device(None)` opens at boot.
128    ///
129    /// If no monitor source shows up on a PipeWire system, load
130    /// `module-loopback` (`pactl load-module module-loopback
131    /// latency_msec=1`) so the sink monitor is exposed to ALSA /
132    /// cpal — some distros ship a minimal PipeWire config that
133    /// doesn't enable it by default.
134    ListAudio,
135
136    /// Render N frames through the full engine and report per-frame
137    /// timing statistics (mean, std-dev, min, max, p50, p99). Output
138    /// is on stdout — no PNGs are written, the engine runs purely
139    /// for timing.
140    Benchmark {
141        /// Path to the .milk preset file
142        preset: PathBuf,
143
144        /// Number of frames to render. Default 1000 (roughly 16 s at
145        /// 60 fps), which is enough to amortise compile / first-frame
146        /// upload costs and still complete in seconds on a workstation.
147        #[arg(short = 'n', long, default_value = "1000")]
148        frames: u32,
149
150        /// Render-target width.
151        #[arg(short, long, default_value = "1280")]
152        width: u32,
153
154        /// Render-target height.
155        #[arg(short = 'H', long, default_value = "720")]
156        height: u32,
157
158        /// Warp mesh size (see `render`'s `--mesh-size`).
159        #[arg(short = 'm', long, default_value = "medium", value_parser = parse_mesh_size)]
160        mesh_size: (u32, u32),
161    },
162
163    /// Emit the translated WGSL for a preset's warp and/or comp
164    /// shader on stdout. Useful for debugging HLSL→WGSL translator
165    /// output without standing up the renderer.
166    DumpShader {
167        /// Path to the .milk preset file
168        preset: PathBuf,
169
170        /// Which shader(s) to dump. `both` (default) prints warp then
171        /// comp; `warp` or `comp` print only one.
172        #[arg(short, long, default_value = "both", value_parser = parse_dump_kind)]
173        kind: DumpKind,
174    },
175}
176
177#[derive(Debug, Clone, Copy)]
178enum DumpKind {
179    Warp,
180    Comp,
181    Both,
182}
183
184fn parse_dump_kind(s: &str) -> Result<DumpKind, String> {
185    match s.trim().to_ascii_lowercase().as_str() {
186        "warp" => Ok(DumpKind::Warp),
187        "comp" | "composite" => Ok(DumpKind::Comp),
188        "both" | "all" => Ok(DumpKind::Both),
189        other => Err(format!("expected warp|comp|both, got {other:?}")),
190    }
191}
192
193fn main() -> Result<()> {
194    let cli = Cli::parse();
195
196    // Initialize logger
197    if cli.verbose {
198        env_logger::Builder::from_default_env()
199            .filter_level(log::LevelFilter::Debug)
200            .init();
201    } else {
202        env_logger::Builder::from_default_env()
203            .filter_level(log::LevelFilter::Info)
204            .init();
205    }
206
207    match cli.command {
208        Commands::Info { preset } => cmd_info(preset),
209        Commands::Validate {
210            preset,
211            strict,
212            output,
213        } => cmd_validate(preset, strict, output),
214        Commands::Render {
215            preset,
216            frames,
217            output,
218            width,
219            height,
220            mesh_size,
221        } => cmd_render(preset, frames, output, width, height, mesh_size),
222        Commands::List { directory } => cmd_list(directory),
223        Commands::CompileShaders { directory, output } => cmd_compile_shaders(directory, output),
224        Commands::ListAudio => cmd_list_audio(),
225        Commands::Benchmark {
226            preset,
227            frames,
228            width,
229            height,
230            mesh_size,
231        } => cmd_benchmark(preset, frames, width, height, mesh_size),
232        Commands::DumpShader { preset, kind } => cmd_dump_shader(preset, kind),
233    }
234}
235
236fn cmd_list_audio() -> Result<()> {
237    use onedrop_engine::{SourceKind, list_sources};
238
239    let sources = list_sources();
240    if sources.is_empty() {
241        println!("No input devices reported by the active cpal host.");
242        println!();
243        println!("On PipeWire, the sink monitor may not be exposed by default.");
244        println!("Try: pactl load-module module-loopback latency_msec=1");
245        return Ok(());
246    }
247
248    println!("\n=== Audio capture sources ===\n");
249    println!("  Arrow column legend: → autodetect pick (opened by `with_device(None)`)");
250    println!("                       * host's default input (cpal-reported)");
251    println!();
252
253    let mut autodetect_seen = false;
254    for s in &sources {
255        let marker = match (s.is_autodetect_pick, s.is_host_default) {
256            (true, _) => {
257                autodetect_seen = true;
258                "→"
259            }
260            (false, true) => "*",
261            _ => " ",
262        };
263        let kind = match s.kind {
264            SourceKind::Monitor => "monitor",
265            SourceKind::Microphone => "mic    ",
266            SourceKind::Other => "other  ",
267        };
268        println!("  {marker} [{kind}] {}", s.name);
269    }
270
271    if !autodetect_seen {
272        println!();
273        println!("No monitor source matched the default output sink. Autodetect will fall back to");
274        println!("the first available `.monitor` device, then to the host's default input (`*`).");
275        println!("On PipeWire, try `pactl load-module module-loopback latency_msec=1` to expose");
276        println!("the sink monitor.");
277    }
278
279    Ok(())
280}
281
282fn cmd_info(preset_path: PathBuf) -> Result<()> {
283    log::info!("Loading preset: {}", preset_path.display());
284
285    let bytes = std::fs::read(&preset_path).context("Failed to read preset file")?;
286    let content = String::from_utf8_lossy(&bytes).into_owned();
287
288    let preset = onedrop_parser::parse_preset(&content).context("Failed to parse preset")?;
289
290    println!("\n=== Preset Information ===\n");
291    println!("Version: {}", preset.version);
292    println!("Warp shader version: {}", preset.ps_version_warp);
293    println!("Composite shader version: {}", preset.ps_version_comp);
294
295    println!("\n--- Parameters ---");
296    println!("Zoom: {}", preset.parameters.zoom);
297    println!("Rotation: {}", preset.parameters.rot);
298    println!("Decay: {}", preset.parameters.decay());
299    println!(
300        "Wave color: R={}, G={}, B={}",
301        preset.parameters.wave_r, preset.parameters.wave_g, preset.parameters.wave_b
302    );
303
304    println!("\n--- Equations ---");
305    println!("Per-frame equations: {}", preset.per_frame_equations.len());
306    println!("Per-pixel equations: {}", preset.per_pixel_equations.len());
307
308    if !preset.per_frame_equations.is_empty() {
309        println!("\nPer-frame equations:");
310        for (i, eq) in preset.per_frame_equations.iter().enumerate().take(5) {
311            println!("  {}: {}", i + 1, eq);
312        }
313        if preset.per_frame_equations.len() > 5 {
314            println!("  ... and {} more", preset.per_frame_equations.len() - 5);
315        }
316    }
317
318    println!("\n--- Custom Elements ---");
319    println!("Waves: {}", preset.waves.len());
320    println!("Shapes: {}", preset.shapes.len());
321
322    println!("\n--- Shaders ---");
323    println!(
324        "Warp shader: {}",
325        if preset.warp_shader.is_some() {
326            "Yes"
327        } else {
328            "No"
329        }
330    );
331    println!(
332        "Composite shader: {}",
333        if preset.comp_shader.is_some() {
334            "Yes"
335        } else {
336            "No"
337        }
338    );
339
340    Ok(())
341}
342
343fn cmd_validate(preset_path: PathBuf, strict: bool, output: Option<PathBuf>) -> Result<()> {
344    if preset_path.is_dir() {
345        if !strict {
346            anyhow::bail!(
347                "validate <dir> requires --strict (the directory mode emits a CSV report)"
348            );
349        }
350        return cmd_validate_strict_dir(preset_path, output);
351    }
352
353    log::info!("Validating preset: {}", preset_path.display());
354
355    let bytes = std::fs::read(&preset_path).context("Failed to read preset file")?;
356    let content = String::from_utf8_lossy(&bytes).into_owned();
357
358    let preset = match onedrop_parser::parse_preset(&content) {
359        Ok(p) => p,
360        Err(e) => {
361            println!("✗ Preset is invalid!");
362            println!("  Error: {}", e);
363            return Err(e.into());
364        }
365    };
366
367    println!("✓ Preset is valid!");
368    println!("  Version: {}", preset.version);
369    println!(
370        "  Per-frame equations: {}",
371        preset.per_frame_equations.len()
372    );
373    println!(
374        "  Per-pixel equations: {}",
375        preset.per_pixel_equations.len()
376    );
377
378    if strict {
379        let r = run_strict_pipeline(&preset_path, &content);
380        println!("\n--- strict pipeline ---");
381        report_strict_row_human(&r);
382    }
383
384    Ok(())
385}
386
387/// One row of the `validate --strict` CSV. Mirrors the fields of
388/// `tools/src/sample.rs::PresetResult` so the long-tail triage workflow
389/// works the same way whether you reach for the CLI or the bench tool.
390#[derive(Debug, Default)]
391struct StrictResult {
392    name: String,
393    parse_ok: bool,
394    has_warp: bool,
395    has_comp: bool,
396    eval_ok: bool,
397    eval_total: usize,
398    eval_failed: usize,
399    eval_first_error: String,
400    warp_parse_ok: bool,
401    warp_translate_ok: bool,
402    warp_error: String,
403    comp_compile_ok: bool,
404    /// `parse` / `validate` / `translate` / `other` / "" when ok.
405    comp_error_kind: String,
406    comp_error: String,
407    parse_error: String,
408}
409
410/// Run the full pipeline against one preset, collecting per-stage status.
411fn run_strict_pipeline(path: &std::path::Path, content: &str) -> StrictResult {
412    let mut r = StrictResult {
413        name: path
414            .file_name()
415            .and_then(|s| s.to_str())
416            .unwrap_or("?")
417            .to_string(),
418        ..Default::default()
419    };
420
421    let preset = match onedrop_parser::parse_preset(content) {
422        Ok(p) => {
423            r.parse_ok = true;
424            p
425        }
426        Err(e) => {
427            r.parse_error = strict_first_line(&format!("{e}"));
428            return r;
429        }
430    };
431
432    r.has_warp = preset.warp_shader.is_some();
433    r.has_comp = preset.comp_shader.is_some();
434
435    // Eval — use the paren-balance-aware list entry point so a
436    // multi-line `loop(N, …; …; …)` counts as one block, matching the
437    // metric used by the corpus bench.
438    {
439        let mut ev = onedrop_eval::MilkEvaluator::new();
440        let mut all: Vec<String> = Vec::with_capacity(
441            preset.per_frame_init_equations.len()
442                + preset.per_frame_equations.len()
443                + preset.per_pixel_equations.len(),
444        );
445        all.extend(preset.per_frame_init_equations.iter().cloned());
446        all.extend(preset.per_frame_equations.iter().cloned());
447        all.extend(preset.per_pixel_equations.iter().cloned());
448        let stats = ev.eval_equation_list(&all);
449        r.eval_total = stats.total;
450        r.eval_failed = stats.failed;
451        r.eval_ok = stats.failed == 0;
452        r.eval_first_error = strict_first_line(&stats.first_error);
453    }
454
455    if let Some(hlsl) = preset.warp_shader.as_deref() {
456        match onedrop_hlsl::parse::parse_hlsl(hlsl) {
457            Ok(_) => {
458                r.warp_parse_ok = true;
459                match onedrop_hlsl::translate_shader(hlsl) {
460                    Ok(_) => r.warp_translate_ok = true,
461                    Err(e) => r.warp_error = strict_first_line(&format!("translate: {e}")),
462                }
463            }
464            Err(e) => r.warp_error = strict_first_line(&format!("parse: {}", e.message)),
465        }
466    }
467
468    if let Some(hlsl) = preset.comp_shader.as_deref() {
469        let mut compiler = onedrop_codegen::ShaderCompiler::new();
470        match compiler.compile_user_comp_shader(hlsl) {
471            Ok(_) => r.comp_compile_ok = true,
472            Err(e) => {
473                let s = e.to_string();
474                let kind = if s.contains("WGSL parse") {
475                    "parse"
476                } else if s.contains("Validation") {
477                    "validate"
478                } else if s.contains("translate") || s.contains("HLSL→WGSL") {
479                    "translate"
480                } else {
481                    "other"
482                };
483                r.comp_error_kind = kind.to_string();
484                r.comp_error = strict_first_line(&s);
485            }
486        }
487    }
488
489    r
490}
491
492/// Strict mode against a directory: walk every `.milk` file, run the
493/// pipeline, emit a CSV report, and print a stage-by-stage summary so
494/// the long tail can be triaged without re-reading the CSV.
495fn cmd_validate_strict_dir(directory: PathBuf, output: Option<PathBuf>) -> Result<()> {
496    use std::io::Write;
497
498    let mut presets: Vec<PathBuf> = std::fs::read_dir(&directory)
499        .with_context(|| format!("Failed to read directory {}", directory.display()))?
500        .filter_map(|e| e.ok().map(|e| e.path()))
501        .filter(|p| p.extension().and_then(|s| s.to_str()) == Some("milk"))
502        .collect();
503    presets.sort();
504
505    if presets.is_empty() {
506        println!("No .milk presets found in {}", directory.display());
507        return Ok(());
508    }
509
510    let csv_path = output.unwrap_or_else(|| {
511        let mut p = directory.clone();
512        let stem = p
513            .file_name()
514            .and_then(|s| s.to_str())
515            .map(|s| s.to_string())
516            .unwrap_or_else(|| "presets".into());
517        p.pop();
518        p.push(format!("{stem}.validate_strict.csv"));
519        p
520    });
521
522    let mut csv = std::fs::File::create(&csv_path)
523        .with_context(|| format!("Failed to create CSV at {}", csv_path.display()))?;
524    writeln!(
525        csv,
526        "name,parse_ok,has_warp,has_comp,eval_ok,eval_total,eval_failed,\
527         warp_parse_ok,warp_translate_ok,comp_compile_ok,comp_error_kind,\
528         parse_error,eval_first_error,warp_error,comp_error"
529    )?;
530
531    let total = presets.len();
532    let mut results: Vec<StrictResult> = Vec::with_capacity(total);
533
534    for (i, path) in presets.iter().enumerate() {
535        let content = match std::fs::read(path) {
536            Ok(b) => String::from_utf8_lossy(&b).into_owned(),
537            Err(e) => {
538                let mut r = StrictResult {
539                    name: path
540                        .file_name()
541                        .and_then(|s| s.to_str())
542                        .unwrap_or("?")
543                        .to_string(),
544                    ..Default::default()
545                };
546                r.parse_error = format!("read error: {e}");
547                results.push(r);
548                continue;
549            }
550        };
551        let r = run_strict_pipeline(path, &content);
552        results.push(r);
553
554        if (i + 1).is_multiple_of(50) || i + 1 == total {
555            log::info!("[{:>5}/{:>5}] strict-validating presets", i + 1, total);
556        }
557    }
558
559    for r in &results {
560        writeln!(
561            csv,
562            "\"{}\",{},{},{},{},{},{},{},{},{},{},\"{}\",\"{}\",\"{}\",\"{}\"",
563            r.name.replace('"', "'"),
564            r.parse_ok,
565            r.has_warp,
566            r.has_comp,
567            r.eval_ok,
568            r.eval_total,
569            r.eval_failed,
570            r.warp_parse_ok,
571            r.warp_translate_ok,
572            r.comp_compile_ok,
573            r.comp_error_kind,
574            csv_escape(&r.parse_error),
575            csv_escape(&r.eval_first_error),
576            csv_escape(&r.warp_error),
577            csv_escape(&r.comp_error),
578        )?;
579    }
580
581    print_strict_summary(&results, total);
582    println!("\nCSV written: {}", csv_path.display());
583    Ok(())
584}
585
586fn print_strict_summary(results: &[StrictResult], total: usize) {
587    let pct = |n: usize, d: usize| -> f64 {
588        if d == 0 {
589            0.0
590        } else {
591            100.0 * n as f64 / d as f64
592        }
593    };
594
595    let parse_ok = results.iter().filter(|r| r.parse_ok).count();
596    let has_warp = results.iter().filter(|r| r.has_warp).count();
597    let has_comp = results.iter().filter(|r| r.has_comp).count();
598    let eval_ok = results.iter().filter(|r| r.parse_ok && r.eval_ok).count();
599    let eval_partial = results
600        .iter()
601        .filter(|r| r.parse_ok && r.eval_failed > 0)
602        .count();
603    let warp_translate_ok = results
604        .iter()
605        .filter(|r| r.has_warp && r.warp_translate_ok)
606        .count();
607    let comp_compile_ok = results
608        .iter()
609        .filter(|r| r.has_comp && r.comp_compile_ok)
610        .count();
611    let all_green = results
612        .iter()
613        .filter(|r| {
614            r.parse_ok
615                && r.eval_ok
616                && (!r.has_warp || r.warp_translate_ok)
617                && (!r.has_comp || r.comp_compile_ok)
618        })
619        .count();
620
621    println!("\n=== validate --strict summary ===");
622    println!("sample size:       {}", total);
623    println!(
624        "parse OK:          {} ({:.1}%)",
625        parse_ok,
626        pct(parse_ok, total)
627    );
628    println!(
629        "has warp:          {} ({:.1}%)",
630        has_warp,
631        pct(has_warp, total)
632    );
633    println!(
634        "has comp:          {} ({:.1}%)",
635        has_comp,
636        pct(has_comp, total)
637    );
638    println!(
639        "eval all-eqs OK:   {} ({:.1}% of parsed)  partial-fail: {}",
640        eval_ok,
641        pct(eval_ok, parse_ok),
642        eval_partial
643    );
644    println!(
645        "warp HLSL→WGSL OK: {} ({:.1}% of has_warp)",
646        warp_translate_ok,
647        pct(warp_translate_ok, has_warp)
648    );
649    println!(
650        "comp compile OK:   {} ({:.1}% of has_comp)",
651        comp_compile_ok,
652        pct(comp_compile_ok, has_comp)
653    );
654    println!(
655        "ALL-GREEN:         {} ({:.1}%) — parse+eval+warp+comp end-to-end",
656        all_green,
657        pct(all_green, total)
658    );
659
660    let mut comp_kinds: std::collections::BTreeMap<String, usize> = Default::default();
661    for r in results {
662        if r.has_comp && !r.comp_compile_ok {
663            *comp_kinds.entry(r.comp_error_kind.clone()).or_default() += 1;
664        }
665    }
666    if !comp_kinds.is_empty() {
667        println!("\n--- comp failure stage breakdown ---");
668        for (k, v) in &comp_kinds {
669            println!("  {:<10} {}", k, v);
670        }
671    }
672
673    let mut eval_buckets: std::collections::BTreeMap<String, usize> = Default::default();
674    for r in results {
675        if r.parse_ok && !r.eval_ok {
676            *eval_buckets
677                .entry(bucket_eval_error(&r.eval_first_error))
678                .or_default() += 1;
679        }
680    }
681    if !eval_buckets.is_empty() {
682        println!("\n--- top eval error buckets ---");
683        print_top_buckets(&eval_buckets, 12);
684    }
685}
686
687fn print_top_buckets(m: &std::collections::BTreeMap<String, usize>, n: usize) {
688    let mut v: Vec<(&String, &usize)> = m.iter().collect();
689    v.sort_by(|a, b| b.1.cmp(a.1));
690    for (k, c) in v.iter().take(n) {
691        println!("  {:>4}  {}", c, k);
692    }
693}
694
695/// Coarse-grained classification of an eval error message. Matches the
696/// buckets used by `tools/src/sample.rs` so a CLI run and a bench run
697/// produce comparable stats.
698fn bucket_eval_error(s: &str) -> String {
699    let low = s.to_lowercase();
700    if low.contains("undefined") || low.contains("unknown") || low.contains("not found") {
701        "undefined identifier".into()
702    } else if low.contains("syntax") || low.contains("parse") {
703        "syntax/parse".into()
704    } else if low.contains("type") {
705        "type error".into()
706    } else if low.contains("division") || low.contains("divide") {
707        "division".into()
708    } else {
709        let cut = s
710            .char_indices()
711            .find(|(i, c)| *i >= 24 && (*c == ':' || *c == ';' || *c == '@'))
712            .map(|(i, _)| i)
713            .unwrap_or_else(|| s.len().min(120));
714        s[..cut].trim().to_string()
715    }
716}
717
718/// Single-preset strict report renderer for `validate --strict <file>`.
719fn report_strict_row_human(r: &StrictResult) {
720    println!("  parse:    {}", if r.parse_ok { "ok" } else { "FAIL" });
721    if !r.parse_error.is_empty() {
722        println!("    error:  {}", r.parse_error);
723    }
724    println!(
725        "  eval:     {}{}",
726        if r.eval_ok { "ok" } else { "FAIL" },
727        if r.eval_total > 0 {
728            format!(" ({} eqs, {} failed)", r.eval_total, r.eval_failed)
729        } else {
730            String::new()
731        }
732    );
733    if !r.eval_first_error.is_empty() {
734        println!("    error:  {}", r.eval_first_error);
735    }
736    if r.has_warp {
737        println!(
738            "  warp:     parse={} translate={}",
739            if r.warp_parse_ok { "ok" } else { "FAIL" },
740            if r.warp_translate_ok { "ok" } else { "FAIL" }
741        );
742        if !r.warp_error.is_empty() {
743            println!("    error:  {}", r.warp_error);
744        }
745    }
746    if r.has_comp {
747        println!(
748            "  comp:     compile={}{}",
749            if r.comp_compile_ok { "ok" } else { "FAIL" },
750            if r.comp_error_kind.is_empty() {
751                String::new()
752            } else {
753                format!(" (kind={})", r.comp_error_kind)
754            }
755        );
756        if !r.comp_error.is_empty() {
757            println!("    error:  {}", r.comp_error);
758        }
759    }
760}
761
762fn strict_first_line(s: &str) -> String {
763    s.lines()
764        .next()
765        .unwrap_or("")
766        .chars()
767        .take(240)
768        .collect::<String>()
769        .replace('"', "'")
770}
771
772fn cmd_render(
773    preset_path: PathBuf,
774    frames: u32,
775    output_dir: PathBuf,
776    width: u32,
777    height: u32,
778    mesh_size: (u32, u32),
779) -> Result<()> {
780    log::info!("Rendering preset: {}", preset_path.display());
781    log::info!("Output: {} frames to {}", frames, output_dir.display());
782    log::info!("Resolution: {}x{}", width, height);
783    let mesh_label = MeshQuality::from_size(mesh_size.0, mesh_size.1)
784        .map(|q| q.label().to_string())
785        .unwrap_or_else(|| format!("Custom ({}×{})", mesh_size.0, mesh_size.1));
786    log::info!("Mesh: {}", mesh_label);
787
788    // Create output directory
789    std::fs::create_dir_all(&output_dir).context("Failed to create output directory")?;
790
791    // Create engine
792    let config = EngineConfig {
793        render_config: RenderConfig {
794            width,
795            height,
796            mesh_cols: mesh_size.0,
797            mesh_rows: mesh_size.1,
798            ..Default::default()
799        },
800        ..Default::default()
801    };
802
803    let mut engine =
804        pollster::block_on(MilkEngine::new(config)).context("Failed to create engine")?;
805
806    // Load preset
807    engine
808        .load_preset(&preset_path)
809        .context("Failed to load preset")?;
810
811    println!("Rendering {} frames...", frames);
812
813    let (rt_w, rt_h) = engine.renderer().render_size();
814
815    // Render frames
816    for frame in 0..frames {
817        // Generate some audio (sine wave for demo)
818        let audio_samples: Vec<f32> = (0..1024)
819            .map(|i| {
820                let t = (frame * 1024 + i) as f32 * 0.001;
821                (t * 2.0 * std::f32::consts::PI * 60.0).sin() * 0.5
822            })
823            .collect();
824
825        // Update engine
826        engine
827            .update(&audio_samples, 0.016)
828            .context("Failed to update engine")?;
829
830        // Read back the display texture (always RGBA8) and write a PNG.
831        // The renderer-side helper is a synchronous CPU stall — fine
832        // for offline render, catastrophic in the live path.
833        let bytes = engine
834            .renderer()
835            .read_render_texture()
836            .context("Failed to read back display texture")?;
837        let path = output_dir.join(format!("frame_{frame:05}.png"));
838        image::save_buffer(&path, &bytes, rt_w, rt_h, image::ExtendedColorType::Rgba8)
839            .with_context(|| format!("Failed to write {}", path.display()))?;
840
841        // Progress indicator
842        if frame % 10 == 0 || frame == frames - 1 {
843            println!(
844                "  Frame {}/{} ({:.1}%)",
845                frame + 1,
846                frames,
847                (frame + 1) as f32 / frames as f32 * 100.0
848            );
849        }
850    }
851
852    println!("\n✓ Rendering complete!");
853    println!("  Output: {}", output_dir.display());
854
855    Ok(())
856}
857
858fn cmd_list(directory: PathBuf) -> Result<()> {
859    log::info!("Listing presets in: {}", directory.display());
860
861    let entries = std::fs::read_dir(&directory).context("Failed to read directory")?;
862
863    let mut presets = Vec::new();
864
865    for entry in entries {
866        let entry = entry?;
867        let path = entry.path();
868
869        if path.extension().and_then(|s| s.to_str()) == Some("milk") {
870            presets.push(path);
871        }
872    }
873
874    if presets.is_empty() {
875        println!("No .milk presets found in {}", directory.display());
876        return Ok(());
877    }
878
879    presets.sort();
880
881    println!("\n=== Presets in {} ===\n", directory.display());
882    println!("Found {} preset(s):\n", presets.len());
883
884    for (i, preset) in presets.iter().enumerate() {
885        println!(
886            "  {}. {}",
887            i + 1,
888            preset.file_name().unwrap().to_string_lossy()
889        );
890    }
891
892    Ok(())
893}
894
895/// Iterate `.milk` files in `directory`, attempt to compile each preset's
896/// `comp_shader` HLSL through the user-shader pipeline, and write a CSV
897/// report (`name,has_comp_shader,parse_ok,compile_ok,error_kind,error_excerpt`).
898///
899/// Acceptance criterion: with the post-naga AST translator, ≥30% of
900/// in-the-wild presets should compile. With the current regex-based
901/// translator the rate is much lower; this command makes the gap
902/// quantifiable and locks a baseline.
903fn cmd_compile_shaders(directory: PathBuf, output: Option<PathBuf>) -> Result<()> {
904    use onedrop_codegen::ShaderCompiler;
905    use std::io::Write;
906
907    let mut presets: Vec<PathBuf> = std::fs::read_dir(&directory)?
908        .filter_map(|e| e.ok().map(|e| e.path()))
909        .filter(|p| p.extension().and_then(|s| s.to_str()) == Some("milk"))
910        .collect();
911    presets.sort();
912
913    if presets.is_empty() {
914        println!("No .milk presets found in {}", directory.display());
915        return Ok(());
916    }
917
918    let csv_path = output.unwrap_or_else(|| {
919        let mut p = directory.clone();
920        p.set_extension("compile_report.csv");
921        p
922    });
923
924    let mut csv = std::fs::File::create(&csv_path)
925        .with_context(|| format!("Failed to create CSV at {}", csv_path.display()))?;
926    writeln!(
927        csv,
928        "name,has_comp_shader,parse_ok,compile_ok,error_kind,error_excerpt"
929    )?;
930
931    let mut compiler = ShaderCompiler::new();
932    let total = presets.len();
933    let mut parse_ok = 0;
934    let mut has_comp = 0;
935    let mut compile_ok = 0;
936
937    for path in &presets {
938        let name = path
939            .file_name()
940            .and_then(|s| s.to_str())
941            .unwrap_or("?")
942            .to_string();
943        let raw = match std::fs::read(path) {
944            Ok(b) => String::from_utf8_lossy(&b).into_owned(),
945            Err(e) => {
946                writeln!(
947                    csv,
948                    "\"{name}\",unknown,false,false,read_error,\"{}\"",
949                    csv_escape(&e.to_string())
950                )?;
951                continue;
952            }
953        };
954        let preset = match onedrop_parser::parse_preset(&raw) {
955            Ok(p) => {
956                parse_ok += 1;
957                p
958            }
959            Err(e) => {
960                writeln!(
961                    csv,
962                    "\"{name}\",unknown,false,false,parse_error,\"{}\"",
963                    csv_escape(&e.to_string())
964                )?;
965                continue;
966            }
967        };
968
969        let Some(comp_hlsl) = preset.comp_shader.as_deref() else {
970            writeln!(csv, "\"{name}\",false,true,false,no_comp_shader,")?;
971            continue;
972        };
973        has_comp += 1;
974
975        match compiler.compile_user_comp_shader(comp_hlsl) {
976            Ok(_) => {
977                compile_ok += 1;
978                writeln!(csv, "\"{name}\",true,true,true,,")?;
979            }
980            Err(e) => {
981                let s = e.to_string();
982                let (kind, excerpt) = if s.contains("WGSL parse") {
983                    ("parse", first_line(&s))
984                } else if s.contains("Validation") {
985                    ("validate", first_line(&s))
986                } else {
987                    ("translate", first_line(&s))
988                };
989                writeln!(
990                    csv,
991                    "\"{name}\",true,true,false,{kind},\"{}\"",
992                    csv_escape(&excerpt)
993                )?;
994            }
995        }
996    }
997
998    println!("\n=== Comp shader compile report ===");
999    println!("Directory:           {}", directory.display());
1000    println!("Presets scanned:     {total}");
1001    println!("Parse OK:            {parse_ok}");
1002    println!(
1003        "Has comp_shader:     {has_comp} ({:.1}%)",
1004        if total > 0 {
1005            100.0 * has_comp as f64 / total as f64
1006        } else {
1007            0.0
1008        }
1009    );
1010    println!(
1011        "Compile OK:          {compile_ok} ({:.1}% of total, {:.1}% of those with shader)",
1012        if total > 0 {
1013            100.0 * compile_ok as f64 / total as f64
1014        } else {
1015            0.0
1016        },
1017        if has_comp > 0 {
1018            100.0 * compile_ok as f64 / has_comp as f64
1019        } else {
1020            0.0
1021        }
1022    );
1023    println!("CSV written to:      {}", csv_path.display());
1024
1025    Ok(())
1026}
1027
1028fn csv_escape(s: &str) -> String {
1029    // Replace double quotes (terminator) and commas (separator) with safe
1030    // single-character substitutes so the field stays in one CSV cell.
1031    s.replace('"', "'").replace(',', ";")
1032}
1033
1034fn first_line(s: &str) -> String {
1035    s.lines().next().unwrap_or("").chars().take(140).collect()
1036}
1037
1038/// Run `frames` frames through the engine and print per-frame timing
1039/// statistics. The first frame is excluded from the summary (compile +
1040/// upload one-shot costs would skew the mean).
1041fn cmd_benchmark(
1042    preset_path: PathBuf,
1043    frames: u32,
1044    width: u32,
1045    height: u32,
1046    mesh_size: (u32, u32),
1047) -> Result<()> {
1048    log::info!("Benchmarking preset: {}", preset_path.display());
1049    log::info!("Frames: {}, resolution: {}x{}", frames, width, height);
1050
1051    let config = EngineConfig {
1052        render_config: RenderConfig {
1053            width,
1054            height,
1055            mesh_cols: mesh_size.0,
1056            mesh_rows: mesh_size.1,
1057            ..Default::default()
1058        },
1059        ..Default::default()
1060    };
1061    let mut engine =
1062        pollster::block_on(MilkEngine::new(config)).context("Failed to create engine")?;
1063    engine
1064        .load_preset(&preset_path)
1065        .context("Failed to load preset")?;
1066
1067    let frame_count = frames.max(2) as usize;
1068    let mut frame_us: Vec<f64> = Vec::with_capacity(frame_count);
1069
1070    let audio_samples: Vec<f32> = (0..1024)
1071        .map(|i| {
1072            let t = i as f32 * 0.001;
1073            (t * 2.0 * std::f32::consts::PI * 60.0).sin() * 0.5
1074        })
1075        .collect();
1076
1077    println!("Running {frame_count} frames...");
1078    for _ in 0..frame_count {
1079        let t0 = std::time::Instant::now();
1080        engine
1081            .update(&audio_samples, 1.0 / 60.0)
1082            .context("Failed to update engine")?;
1083        let dt = t0.elapsed().as_secs_f64() * 1_000.0; // ms
1084        frame_us.push(dt);
1085    }
1086
1087    // Skip the first frame from the summary — pipeline / cache warm-up
1088    // dominates it on every run and biases the mean upward.
1089    let warmed: Vec<f64> = frame_us.iter().skip(1).copied().collect();
1090    let n = warmed.len() as f64;
1091    let mean = warmed.iter().sum::<f64>() / n;
1092    let var = warmed.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / n;
1093    let stddev = var.sqrt();
1094    let min = warmed.iter().cloned().fold(f64::INFINITY, f64::min);
1095    let max = warmed.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
1096
1097    let mut sorted = warmed.clone();
1098    sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
1099    let p = |q: f64| -> f64 {
1100        let idx = ((sorted.len() as f64 - 1.0) * q).round() as usize;
1101        sorted[idx.min(sorted.len() - 1)]
1102    };
1103
1104    let warmup_ms = frame_us[0];
1105    let total_s: f64 = warmed.iter().sum::<f64>() / 1_000.0;
1106    let avg_fps = if mean > 0.0 { 1_000.0 / mean } else { 0.0 };
1107
1108    println!();
1109    println!("=== Benchmark summary ===");
1110    println!("  Preset:       {}", preset_path.display());
1111    println!("  Frames:       {} (1 warm-up excluded)", frame_count);
1112    println!("  Warm-up:      {:>8.3} ms (frame 0)", warmup_ms);
1113    println!("  Mean:         {:>8.3} ms  ({:.1} fps avg)", mean, avg_fps);
1114    println!("  Std-dev:      {:>8.3} ms", stddev);
1115    println!("  Min:          {:>8.3} ms", min);
1116    println!("  p50:          {:>8.3} ms", p(0.50));
1117    println!("  p99:          {:>8.3} ms", p(0.99));
1118    println!("  Max:          {:>8.3} ms", max);
1119    println!("  Wall time:    {:>8.3} s", total_s);
1120    Ok(())
1121}
1122
1123/// Run a preset's `warp_shader` / `comp_shader` HLSL through the
1124/// translator and print the resulting WGSL on stdout. Failures print
1125/// the translator's error to stderr and continue (so `--kind=both`
1126/// still emits the other shader). Exit code is non-zero only when
1127/// nothing at all could be emitted.
1128fn cmd_dump_shader(preset_path: PathBuf, kind: DumpKind) -> Result<()> {
1129    let content = std::fs::read_to_string(&preset_path)
1130        .with_context(|| format!("Failed to read {}", preset_path.display()))?;
1131    let preset = onedrop_parser::parse_preset(&content).context("Failed to parse preset")?;
1132
1133    let mut compiler = onedrop_codegen::ShaderCompiler::new();
1134    let mut emitted_any = false;
1135
1136    if matches!(kind, DumpKind::Warp | DumpKind::Both) {
1137        if let Some(hlsl) = preset.warp_shader.as_deref() {
1138            println!("// ===== WARP SHADER =====");
1139            match compiler.compile_user_warp_shader(hlsl) {
1140                Ok(c) => {
1141                    println!("{}", c.source);
1142                    emitted_any = true;
1143                }
1144                Err(e) => {
1145                    eprintln!("warp: translation failed: {e}");
1146                }
1147            }
1148        } else if matches!(kind, DumpKind::Warp) {
1149            eprintln!("warp: preset has no `warp_shader` block");
1150        }
1151    }
1152
1153    if matches!(kind, DumpKind::Comp | DumpKind::Both) {
1154        if let Some(hlsl) = preset.comp_shader.as_deref() {
1155            if matches!(kind, DumpKind::Both) {
1156                println!();
1157            }
1158            println!("// ===== COMP SHADER =====");
1159            match compiler.compile_user_comp_shader(hlsl) {
1160                Ok(c) => {
1161                    println!("{}", c.source);
1162                    emitted_any = true;
1163                }
1164                Err(e) => {
1165                    eprintln!("comp: translation failed: {e}");
1166                }
1167            }
1168        } else if matches!(kind, DumpKind::Comp) {
1169            eprintln!("comp: preset has no `comp_shader` block");
1170        }
1171    }
1172
1173    if !emitted_any {
1174        anyhow::bail!("nothing emitted — preset has no shader of the requested kind");
1175    }
1176    Ok(())
1177}