1use anyhow::{Context, Result};
4use clap::{Parser, Subcommand};
5use onedrop_engine::{EngineConfig, MeshQuality, MilkEngine, RenderConfig};
6use std::path::PathBuf;
7
8fn 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 #[arg(short, long, global = true)]
46 verbose: bool,
47}
48
49#[derive(Subcommand)]
50enum Commands {
51 Info {
53 preset: PathBuf,
55 },
56
57 Validate {
59 preset: PathBuf,
62
63 #[arg(long)]
68 strict: bool,
69
70 #[arg(short, long)]
74 output: Option<PathBuf>,
75 },
76
77 Render {
79 preset: PathBuf,
81
82 #[arg(short, long, default_value = "60")]
84 frames: u32,
85
86 #[arg(short, long, default_value = "output")]
88 output: PathBuf,
89
90 #[arg(short, long, default_value = "1280")]
92 width: u32,
93
94 #[arg(short = 'H', long, default_value = "720")]
96 height: u32,
97
98 #[arg(short = 'm', long, default_value = "medium", value_parser = parse_mesh_size)]
102 mesh_size: (u32, u32),
103 },
104
105 List {
107 directory: PathBuf,
109 },
110
111 CompileShaders {
115 directory: PathBuf,
117
118 #[arg(short, long)]
120 output: Option<PathBuf>,
121 },
122
123 ListAudio,
135
136 Benchmark {
141 preset: PathBuf,
143
144 #[arg(short = 'n', long, default_value = "1000")]
148 frames: u32,
149
150 #[arg(short, long, default_value = "1280")]
152 width: u32,
153
154 #[arg(short = 'H', long, default_value = "720")]
156 height: u32,
157
158 #[arg(short = 'm', long, default_value = "medium", value_parser = parse_mesh_size)]
160 mesh_size: (u32, u32),
161 },
162
163 DumpShader {
167 preset: PathBuf,
169
170 #[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 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#[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 comp_error_kind: String,
406 comp_error: String,
407 parse_error: String,
408}
409
410fn 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 {
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
492fn 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
695fn 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
718fn 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 std::fs::create_dir_all(&output_dir).context("Failed to create output directory")?;
790
791 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 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 for frame in 0..frames {
817 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 engine
827 .update(&audio_samples, 0.016)
828 .context("Failed to update engine")?;
829
830 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 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
895fn 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 s.replace('"', "'").replace(',', ";")
1032}
1033
1034fn first_line(s: &str) -> String {
1035 s.lines().next().unwrap_or("").chars().take(140).collect()
1036}
1037
1038fn 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; frame_us.push(dt);
1085 }
1086
1087 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
1123fn 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}