1use anyhow::{Context, Result};
4use clap::{Subcommand, ValueEnum};
5use image::{ImageFormat, ImageReader, imageops::FilterType};
6use std::path::{Path, PathBuf};
7use wow_blp::{
8 convert::{
9 AlphaBits, Blp2Format, BlpOldFormat, BlpTarget, DxtAlgorithm, blp_to_image, image_to_blp,
10 },
11 encode::save_blp,
12 parser::load_blp,
13 types::BlpContent,
14};
15
16#[derive(Subcommand)]
17pub enum BlpCommands {
18 Info {
20 file: PathBuf,
22
23 #[arg(long)]
25 mipmaps: bool,
26
27 #[arg(long)]
29 raw: bool,
30
31 #[arg(long)]
33 compression: bool,
34
35 #[arg(long)]
37 size: bool,
38
39 #[arg(long)]
41 best_mipmap_for: Option<u32>,
42
43 #[arg(long)]
45 all: bool,
46 },
47
48 Validate {
50 file: PathBuf,
52
53 #[arg(long)]
55 strict: bool,
56 },
57
58 Convert {
60 input: PathBuf,
62
63 output: PathBuf,
65
66 #[arg(short = 'i', long)]
68 input_format: Option<InputFormat>,
69
70 #[arg(short = 'o', long)]
72 output_format: Option<OutputFormat>,
73
74 #[arg(long, default_value = "blp1")]
76 blp_version: BlpVersionCli,
77
78 #[arg(long, default_value = "jpeg")]
80 blp_format: BlpFormat,
81
82 #[arg(long)]
84 alpha_bits: Option<u8>,
85
86 #[arg(long, default_value = "0")]
88 mipmap_level: usize,
89
90 #[arg(long)]
92 no_mipmaps: bool,
93
94 #[arg(long, default_value = "lanczos3")]
96 mipmap_filter: MipmapFilter,
97
98 #[arg(long, default_value = "medium")]
100 dxt_compression: DxtCompression,
101 },
102}
103
104#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
105pub enum InputFormat {
106 Blp,
107 Png,
108 Jpeg,
109 Gif,
110 Bmp,
111 Ico,
112 Tiff,
113 Webp,
114 Pnm,
115 Dds,
116 Tga,
117 #[value(name = "openexr")]
118 OpenExr,
119 Farbfeld,
120}
121
122#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
123pub enum OutputFormat {
124 Blp,
125 Png,
126 Jpeg,
127 Gif,
128 Bmp,
129 Ico,
130 Tiff,
131 Pnm,
132 Tga,
133 #[value(name = "openexr")]
134 OpenExr,
135 Farbfeld,
136}
137
138#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
139pub enum BlpVersionCli {
140 Blp0,
141 Blp1,
142 Blp2,
143}
144
145impl From<BlpVersionCli> for wow_blp::types::BlpVersion {
146 fn from(value: BlpVersionCli) -> Self {
147 match value {
148 BlpVersionCli::Blp0 => wow_blp::types::BlpVersion::Blp0,
149 BlpVersionCli::Blp1 => wow_blp::types::BlpVersion::Blp1,
150 BlpVersionCli::Blp2 => wow_blp::types::BlpVersion::Blp2,
151 }
152 }
153}
154
155#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
156pub enum BlpFormat {
157 Raw1,
158 Raw3,
159 Jpeg,
160 Dxt1,
161 Dxt3,
162 Dxt5,
163}
164
165#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
166pub enum MipmapFilter {
167 Nearest,
169 Triangle,
171 CatmullRom,
173 Gaussian,
175 Lanczos3,
177}
178
179#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
180pub enum DxtCompression {
181 Fastest,
183 Medium,
185 Finest,
187}
188
189impl From<MipmapFilter> for FilterType {
192 fn from(value: MipmapFilter) -> FilterType {
193 match value {
194 MipmapFilter::Nearest => FilterType::Nearest,
195 MipmapFilter::Triangle => FilterType::Triangle,
196 MipmapFilter::CatmullRom => FilterType::CatmullRom,
197 MipmapFilter::Gaussian => FilterType::Gaussian,
198 MipmapFilter::Lanczos3 => FilterType::Lanczos3,
199 }
200 }
201}
202
203impl From<DxtCompression> for DxtAlgorithm {
204 fn from(value: DxtCompression) -> DxtAlgorithm {
205 match value {
206 DxtCompression::Fastest => DxtAlgorithm::RangeFit,
207 DxtCompression::Medium => DxtAlgorithm::ClusterFit,
208 DxtCompression::Finest => DxtAlgorithm::IterativeClusterFit,
209 }
210 }
211}
212
213impl TryFrom<OutputFormat> for ImageFormat {
214 type Error = anyhow::Error;
215
216 fn try_from(val: OutputFormat) -> Result<ImageFormat> {
217 match val {
218 OutputFormat::Blp => anyhow::bail!("BLP format handled separately"),
219 OutputFormat::Png => Ok(ImageFormat::Png),
220 OutputFormat::Jpeg => Ok(ImageFormat::Jpeg),
221 OutputFormat::Gif => Ok(ImageFormat::Gif),
222 OutputFormat::Bmp => Ok(ImageFormat::Bmp),
223 OutputFormat::Ico => Ok(ImageFormat::Ico),
224 OutputFormat::Tiff => Ok(ImageFormat::Tiff),
225 OutputFormat::Pnm => Ok(ImageFormat::Pnm),
226 OutputFormat::Tga => Ok(ImageFormat::Tga),
227 OutputFormat::OpenExr => Ok(ImageFormat::OpenExr),
228 OutputFormat::Farbfeld => Ok(ImageFormat::Farbfeld),
229 }
230 }
231}
232
233fn guess_input_format(path: &Path) -> Option<InputFormat> {
234 let ext = path.extension()?.to_str()?.to_lowercase();
235 match ext.as_str() {
236 "blp" => Some(InputFormat::Blp),
237 "png" => Some(InputFormat::Png),
238 "jpg" | "jpeg" => Some(InputFormat::Jpeg),
239 "gif" => Some(InputFormat::Gif),
240 "bmp" => Some(InputFormat::Bmp),
241 "ico" => Some(InputFormat::Ico),
242 "tiff" | "tif" => Some(InputFormat::Tiff),
243 "webp" => Some(InputFormat::Webp),
244 "pnm" | "pbm" | "pgm" | "ppm" | "pam" => Some(InputFormat::Pnm),
245 "dds" => Some(InputFormat::Dds),
246 "tga" => Some(InputFormat::Tga),
247 "exr" => Some(InputFormat::OpenExr),
248 "ff" | "farbfeld" => Some(InputFormat::Farbfeld),
249 _ => None,
250 }
251}
252
253fn guess_output_format(path: &Path) -> Option<OutputFormat> {
254 let ext = path.extension()?.to_str()?.to_lowercase();
255 match ext.as_str() {
256 "blp" => Some(OutputFormat::Blp),
257 "png" => Some(OutputFormat::Png),
258 "jpg" | "jpeg" => Some(OutputFormat::Jpeg),
259 "gif" => Some(OutputFormat::Gif),
260 "bmp" => Some(OutputFormat::Bmp),
261 "ico" => Some(OutputFormat::Ico),
262 "tiff" | "tif" => Some(OutputFormat::Tiff),
263 "pnm" | "pbm" | "pgm" | "ppm" | "pam" => Some(OutputFormat::Pnm),
264 "tga" => Some(OutputFormat::Tga),
265 "exr" => Some(OutputFormat::OpenExr),
266 "ff" | "farbfeld" => Some(OutputFormat::Farbfeld),
267 _ => None,
268 }
269}
270
271fn make_blp_target(
272 version: BlpVersionCli,
273 format: BlpFormat,
274 alpha_bits: u8,
275 dxt_algo: DxtCompression,
276) -> Result<BlpTarget> {
277 use wow_blp::types::BlpVersion;
278 let version: BlpVersion = version.into();
279 match version {
280 BlpVersion::Blp0 => match format {
281 BlpFormat::Raw1 => {
282 let alpha_bits = match alpha_bits {
283 0 => AlphaBits::NoAlpha,
284 1 => AlphaBits::Bit1,
285 4 => AlphaBits::Bit4,
286 8 => AlphaBits::Bit8,
287 _ => anyhow::bail!("Invalid alpha bits {} for BLP0 Raw1 format", alpha_bits),
288 };
289 Ok(BlpTarget::Blp0(BlpOldFormat::Raw1 { alpha_bits }))
290 }
291 BlpFormat::Jpeg => {
292 let has_alpha = match alpha_bits {
293 0 => false,
294 8 => true,
295 _ => anyhow::bail!(
296 "Invalid alpha bits {} for BLP0 JPEG format (only 0 or 8 supported)",
297 alpha_bits
298 ),
299 };
300 Ok(BlpTarget::Blp0(BlpOldFormat::Jpeg { has_alpha }))
301 }
302 _ => anyhow::bail!("BLP0 only supports Raw1 and JPEG formats"),
303 },
304 BlpVersion::Blp1 => match format {
305 BlpFormat::Raw1 => {
306 let alpha_bits = match alpha_bits {
307 0 => AlphaBits::NoAlpha,
308 1 => AlphaBits::Bit1,
309 4 => AlphaBits::Bit4,
310 8 => AlphaBits::Bit8,
311 _ => anyhow::bail!("Invalid alpha bits {} for BLP1 Raw1 format", alpha_bits),
312 };
313 Ok(BlpTarget::Blp1(BlpOldFormat::Raw1 { alpha_bits }))
314 }
315 BlpFormat::Jpeg => {
316 let has_alpha = match alpha_bits {
317 0 => false,
318 8 => true,
319 _ => anyhow::bail!(
320 "Invalid alpha bits {} for BLP1 JPEG format (only 0 or 8 supported)",
321 alpha_bits
322 ),
323 };
324 Ok(BlpTarget::Blp1(BlpOldFormat::Jpeg { has_alpha }))
325 }
326 _ => anyhow::bail!("BLP1 only supports Raw1 and JPEG formats"),
327 },
328 BlpVersion::Blp2 => match format {
329 BlpFormat::Raw1 => {
330 let alpha_bits = match alpha_bits {
331 0 => AlphaBits::NoAlpha,
332 1 => AlphaBits::Bit1,
333 4 => AlphaBits::Bit4,
334 8 => AlphaBits::Bit8,
335 _ => anyhow::bail!("Invalid alpha bits {} for BLP2 Raw1 format", alpha_bits),
336 };
337 Ok(BlpTarget::Blp2(Blp2Format::Raw1 { alpha_bits }))
338 }
339 BlpFormat::Raw3 => Ok(BlpTarget::Blp2(Blp2Format::Raw3)),
340 BlpFormat::Jpeg => {
341 let has_alpha = match alpha_bits {
342 0 => false,
343 8 => true,
344 _ => anyhow::bail!(
345 "Invalid alpha bits {} for BLP2 JPEG format (only 0 or 8 supported)",
346 alpha_bits
347 ),
348 };
349 Ok(BlpTarget::Blp2(Blp2Format::Jpeg { has_alpha }))
350 }
351 BlpFormat::Dxt1 => {
352 let has_alpha = match alpha_bits {
353 0 => false,
354 1 => true,
355 _ => anyhow::bail!(
356 "Invalid alpha bits {} for BLP2 DXT1 format (only 0 or 1 supported)",
357 alpha_bits
358 ),
359 };
360 Ok(BlpTarget::Blp2(Blp2Format::Dxt1 {
361 has_alpha,
362 compress_algorithm: dxt_algo.into(),
363 }))
364 }
365 BlpFormat::Dxt3 => {
366 let has_alpha = match alpha_bits {
367 0 => false,
368 8 => true,
369 _ => anyhow::bail!(
370 "Invalid alpha bits {} for BLP2 DXT3 format (only 0 or 8 supported)",
371 alpha_bits
372 ),
373 };
374 Ok(BlpTarget::Blp2(Blp2Format::Dxt3 {
375 has_alpha,
376 compress_algorithm: dxt_algo.into(),
377 }))
378 }
379 BlpFormat::Dxt5 => {
380 let has_alpha = match alpha_bits {
381 0 => false,
382 8 => true,
383 _ => anyhow::bail!(
384 "Invalid alpha bits {} for BLP2 DXT5 format (only 0 or 8 supported)",
385 alpha_bits
386 ),
387 };
388 Ok(BlpTarget::Blp2(Blp2Format::Dxt5 {
389 has_alpha,
390 compress_algorithm: dxt_algo.into(),
391 }))
392 }
393 },
394 }
395}
396
397fn image_has_alpha(image: &image::DynamicImage) -> bool {
399 use image::DynamicImage;
400 matches!(
401 image,
402 DynamicImage::ImageLumaA8(_)
403 | DynamicImage::ImageLumaA16(_)
404 | DynamicImage::ImageRgba8(_)
405 | DynamicImage::ImageRgba16(_)
406 | DynamicImage::ImageRgba32F(_)
407 )
408}
409
410fn determine_alpha_bits(
412 blp_format: BlpFormat,
413 has_alpha: bool,
414 user_specified: Option<u8>,
415) -> Result<u8> {
416 if let Some(bits) = user_specified {
418 return Ok(bits);
419 }
420
421 Ok(match blp_format {
423 BlpFormat::Dxt1 => {
424 if has_alpha { 1 } else { 0 }
426 }
427 BlpFormat::Dxt3 | BlpFormat::Dxt5 | BlpFormat::Jpeg => {
428 if has_alpha { 8 } else { 0 }
430 }
431 BlpFormat::Raw1 | BlpFormat::Raw3 => {
432 if has_alpha { 8 } else { 0 }
434 }
435 })
436}
437
438fn convert_blp(args: ConvertArgs) -> Result<()> {
439 let input_format = args
441 .input_format
442 .or_else(|| guess_input_format(&args.input))
443 .context("Failed to determine input format. Please specify with --input-format")?;
444
445 let output_format = args
447 .output_format
448 .or_else(|| guess_output_format(&args.output))
449 .context("Failed to determine output format. Please specify with --output-format")?;
450
451 log::info!("Converting from {input_format:?} to {output_format:?}");
452
453 let input_image = if input_format == InputFormat::Blp {
455 let blp_image = load_blp(&args.input)
456 .with_context(|| format!("Failed to load BLP file: {}", args.input.display()))?;
457
458 blp_to_image(&blp_image, args.mipmap_level)
459 .with_context(|| format!("Failed to convert BLP mipmap level {}", args.mipmap_level))?
460 } else {
461 ImageReader::open(&args.input)
462 .with_context(|| format!("Failed to open image file: {}", args.input.display()))?
463 .decode()
464 .with_context(|| format!("Failed to decode image: {}", args.input.display()))?
465 };
466
467 match output_format {
469 OutputFormat::Blp => {
470 let has_alpha = image_has_alpha(&input_image);
471
472 let alpha_bits = determine_alpha_bits(args.blp_format, has_alpha, args.alpha_bits)?;
474
475 if args.blp_format == BlpFormat::Jpeg && has_alpha && alpha_bits > 0 {
477 log::info!(
478 "Input image has alpha channel. JPEG-compressed BLP will store \
479 alpha separately (not in the JPEG stream)."
480 );
481 }
482
483 log::info!(
484 "Using {} alpha bits (input has alpha: {})",
485 alpha_bits,
486 has_alpha
487 );
488
489 let target = make_blp_target(
490 args.blp_version,
491 args.blp_format,
492 alpha_bits,
493 args.dxt_compression,
494 )?;
495 let blp = image_to_blp(
496 input_image,
497 !args.no_mipmaps,
498 target,
499 args.mipmap_filter.into(),
500 )
501 .context("Failed to convert image to BLP")?;
502
503 save_blp(&blp, &args.output)
504 .with_context(|| format!("Failed to save BLP file: {}", args.output.display()))?;
505 }
506 _ => {
507 let img_format = output_format.try_into()?;
508 input_image
509 .save_with_format(&args.output, img_format)
510 .with_context(|| format!("Failed to save image: {}", args.output.display()))?;
511 }
512 }
513
514 println!(
515 "✓ Converted {} to {}",
516 args.input.display(),
517 args.output.display()
518 );
519 Ok(())
520}
521
522fn show_blp_info(
523 file: PathBuf,
524 show_mipmaps: bool,
525 show_raw: bool,
526 show_compression: bool,
527 show_size: bool,
528 best_mipmap_for: Option<u32>,
529 show_all: bool,
530) -> Result<()> {
531 let blp =
532 load_blp(&file).with_context(|| format!("Failed to load BLP file: {}", file.display()))?;
533
534 let show_mipmaps = show_mipmaps || show_all;
536 let show_compression = show_compression || show_all;
537 let show_size = show_size || show_all;
538
539 println!("BLP File Information: {}", file.display());
540 println!("=====================================");
541
542 println!("Version: {:?}", blp.header.version);
544 println!("Dimensions: {}x{}", blp.header.width, blp.header.height);
545 println!("Content Type: {:?}", blp.header.content);
546 println!("Compression: {:?}", blp.compression_type());
547 println!("Alpha Bits: {}", blp.alpha_bit_depth());
548 println!("Has Mipmaps: {}", blp.header.has_mipmaps());
549 println!("Image Count: {}", blp.image_count());
550
551 match &blp.content {
553 BlpContent::Jpeg(jpeg) => {
554 println!("JPEG Header Size: {} bytes", jpeg.header.len());
555 }
556 BlpContent::Raw1(raw) => {
557 println!("Palette Colors: {}", raw.cmap.len());
558 }
559 BlpContent::Raw3(_) => {
560 println!("Format Details: Uncompressed BGRA");
561 }
562 BlpContent::Dxt1(_) => {
563 println!("Format Details: S3TC BC1 (4 bpp)");
564 }
565 BlpContent::Dxt3(_) => {
566 println!("Format Details: S3TC BC2 (8 bpp, explicit alpha)");
567 }
568 BlpContent::Dxt5(_) => {
569 println!("Format Details: S3TC BC3 (8 bpp, interpolated alpha)");
570 }
571 }
572
573 if show_compression {
575 println!("\nCompression Statistics:");
576 println!("----------------------");
577 let compression_ratio = blp.compression_ratio();
578 println!("Compression Ratio: {compression_ratio:.2}:1");
579 println!(
580 "Compression Efficiency: {:.1}%",
581 (1.0 - 1.0 / compression_ratio) * 100.0
582 );
583 }
584
585 if show_size {
587 println!("\nFile Size Information:");
588 println!("---------------------");
589 let estimated_size = blp.estimated_file_size();
590 println!(
591 "Estimated File Size: {} bytes ({:.2} KB)",
592 estimated_size,
593 estimated_size as f32 / 1024.0
594 );
595
596 let mipmap_info = blp.mipmap_info();
597 let total_uncompressed = mipmap_info
598 .iter()
599 .map(|info| info.width * info.height * 4)
600 .sum::<u32>();
601 println!(
602 "Uncompressed Size: {} bytes ({:.2} KB)",
603 total_uncompressed,
604 total_uncompressed as f32 / 1024.0
605 );
606 }
607
608 if let Some(target_size) = best_mipmap_for {
610 println!("\nBest Mipmap for {target_size}x{target_size} target:");
611 println!("-------------------------------");
612 let best_level = blp.best_mipmap_for_size(target_size);
613 let (width, height) = blp.header.mipmap_size(best_level);
614 println!("Best Level: {best_level} ({width}x{height})");
615 }
616
617 if show_mipmaps {
619 println!("\nMipmap Information:");
620 println!("-------------------");
621 let mipmap_info = blp.mipmap_info();
622 for info in &mipmap_info {
623 println!(
624 " Level {}: {}x{} ({} bytes, {} pixels)",
625 info.level, info.width, info.height, info.data_size, info.pixel_count
626 );
627 }
628 }
629
630 if show_raw {
632 println!("\nRaw Header Data:");
633 println!("----------------");
634 println!(" Version: {:?}", blp.header.version);
635 println!(" Content Tag: {:?}", blp.header.content);
636 println!(" Flags: {:?}", blp.header.flags);
637 println!(" Width: {}", blp.header.width);
638 println!(" Height: {}", blp.header.height);
639 println!(" Mipmap Locator: {:?}", blp.header.mipmap_locator);
640 }
641
642 Ok(())
643}
644
645fn validate_blp(file: PathBuf, strict: bool) -> Result<()> {
646 println!("Validating BLP file: {}", file.display());
647
648 let blp = match load_blp(&file) {
650 Ok(blp) => blp,
651 Err(e) => {
652 println!("✗ Failed to load BLP file: {e}");
653 return Err(e.into());
654 }
655 };
656
657 let mut errors: Vec<String> = Vec::new();
658 let mut warnings: Vec<String> = Vec::new();
659
660 log::debug!("BLP version: {:?}", blp.header.version);
662
663 if blp.header.width == 0 || blp.header.height == 0 {
665 errors.push("Invalid dimensions (0 width or height)".to_string());
666 }
667
668 if !blp.header.width.is_power_of_two() || !blp.header.height.is_power_of_two() {
669 if strict {
670 errors.push("Dimensions are not powers of 2".to_string());
671 } else {
672 warnings.push("Dimensions are not powers of 2 (non-standard but may work)".to_string());
673 }
674 }
675
676 if blp.header.has_mipmaps() {
678 let expected_levels = (blp.header.width.max(blp.header.height) as f32).log2() as usize + 1;
679 let actual_levels = blp.image_count();
680
681 if actual_levels < expected_levels {
682 warnings.push(format!(
683 "Incomplete mipmap chain: expected {expected_levels} levels, got {actual_levels}"
684 ));
685 }
686 }
687
688 match &blp.content {
690 BlpContent::Jpeg(jpeg) => {
691 if jpeg.header.is_empty() {
693 errors.push("JPEG header is empty".to_string());
694 }
695 }
696 BlpContent::Dxt1(_) | BlpContent::Dxt3(_) | BlpContent::Dxt5(_) => {
697 if blp.header.width % 4 != 0 || blp.header.height % 4 != 0 {
699 errors.push("DXT format requires dimensions to be multiples of 4".to_string());
700 }
701 }
702 _ => {}
703 }
704
705 if errors.is_empty() && warnings.is_empty() {
707 println!("✓ BLP file is valid");
708 Ok(())
709 } else {
710 if !errors.is_empty() {
711 println!("\nErrors:");
712 for error in &errors {
713 println!(" ✗ {error}");
714 }
715 }
716
717 if !warnings.is_empty() {
718 println!("\nWarnings:");
719 for warning in &warnings {
720 println!(" ⚠ {warning}");
721 }
722 }
723
724 if errors.is_empty() {
725 println!("\n✓ BLP file is valid with warnings");
726 Ok(())
727 } else {
728 anyhow::bail!("BLP file validation failed with {} error(s)", errors.len())
729 }
730 }
731}
732
733struct ConvertArgs {
735 input: PathBuf,
736 output: PathBuf,
737 input_format: Option<InputFormat>,
738 output_format: Option<OutputFormat>,
739 blp_version: BlpVersionCli,
740 blp_format: BlpFormat,
741 alpha_bits: Option<u8>,
742 mipmap_level: usize,
743 no_mipmaps: bool,
744 mipmap_filter: MipmapFilter,
745 dxt_compression: DxtCompression,
746}
747
748pub fn execute(command: BlpCommands) -> Result<()> {
749 match command {
750 BlpCommands::Convert {
751 input,
752 output,
753 input_format,
754 output_format,
755 blp_version,
756 blp_format,
757 alpha_bits,
758 mipmap_level,
759 no_mipmaps,
760 mipmap_filter,
761 dxt_compression,
762 } => convert_blp(ConvertArgs {
763 input,
764 output,
765 input_format,
766 output_format,
767 blp_version,
768 blp_format,
769 alpha_bits,
770 mipmap_level,
771 no_mipmaps,
772 mipmap_filter,
773 dxt_compression,
774 }),
775 BlpCommands::Info {
776 file,
777 mipmaps,
778 raw,
779 compression,
780 size,
781 best_mipmap_for,
782 all,
783 } => show_blp_info(file, mipmaps, raw, compression, size, best_mipmap_for, all),
784 BlpCommands::Validate { file, strict } => validate_blp(file, strict),
785 }
786}