1#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
56
57use thiserror::Error;
58
59pub const MAGIC: &[u8; 8] = b"AAPL\r\n\x1a\n";
62
63const ASTC_BLOCK_BYTES: usize = 16;
65const ASTC_BLOCK_WIDTH: u32 = 4;
67const ASTC_BLOCK_HEIGHT: u32 = 4;
69const MAX_IMAGE_PIXELS: u64 = 100_000_000;
74const MACRO_BLOCKS: u32 = 32;
76const MACRO_TILE_PX: u32 = MACRO_BLOCKS * ASTC_BLOCK_WIDTH;
78
79#[derive(Debug, Error)]
86pub enum AtxError {
87 #[error("not an ATX file: expected AAPL magic, found {found:02x?}")]
89 NotAtx {
90 found: Vec<u8>,
92 },
93 #[error("ATX has no HEAD chunk")]
95 NoHead,
96 #[error("ATX has no texture payload chunk (astc/ASTC/LZFS)")]
98 NoPayload,
99 #[error("unsupported ATX pixel format {pixel_format:?} (not a known ASTC 4x4 discriminator)")]
102 UnsupportedPixelFormat {
103 pixel_format: (u32, u32),
105 },
106 #[error("invalid ATX dimensions: {width}x{height}")]
108 InvalidDimensions {
109 width: u32,
111 height: u32,
113 },
114 #[error("ATX texture payload too small: got {got} bytes, expected at least {expected}")]
116 PayloadTooSmall {
117 got: usize,
119 expected: usize,
121 },
122 #[error("LZFSE decompression failed: {0}")]
124 Decompress(String),
125 #[error("ASTC decode failed: {0}")]
127 AstcDecode(String),
128}
129
130pub mod fourcc {
132 pub const HEAD: &[u8; 4] = b"HEAD";
134 pub const FILL: &[u8; 4] = b"FILL";
136 pub const ASTC_LOWER: &[u8; 4] = b"astc";
138 pub const ASTC_UPPER: &[u8; 4] = b"ASTC";
140 pub const LZFS: &[u8; 4] = b"LZFS";
142}
143
144#[derive(Debug, Clone, PartialEq, Eq)]
146pub struct ChunkRef {
147 pub tag: [u8; 4],
149 pub offset: usize,
151 pub size: u32,
153 pub payload_offset: usize,
155}
156
157#[derive(Debug, Clone, Default, PartialEq, Eq)]
159pub struct Head {
160 pub flags: u32,
162 pub width: u32,
164 pub height: u32,
166 pub depth: u32,
168 pub array_layers: u32,
170 pub mipmaps: u32,
172 pub texture_uuid: [u8; 16],
174 pub pixel_format: (u32, u32),
176}
177
178#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct Payload {
181 pub tag: [u8; 4],
183 pub declared_size: u32,
185 pub data_offset: usize,
187 pub data_len: usize,
189 pub compressed: bool,
191}
192
193#[derive(Debug, Clone, Copy, PartialEq, Eq)]
196pub enum FormatConfidence {
197 Confirmed,
199 Inferred,
202}
203
204#[must_use]
208pub fn astc4x4_confidence(discriminator: (u32, u32)) -> Option<FormatConfidence> {
209 match discriminator {
210 (3, 5) => Some(FormatConfidence::Confirmed),
211 (1, 1) | (3, 1) => Some(FormatConfidence::Inferred),
212 _ => None,
213 }
214}
215
216#[must_use]
218pub fn is_atx(bytes: &[u8]) -> bool {
219 bytes.get(..MAGIC.len()) == Some(MAGIC.as_slice())
220}
221
222#[derive(Debug, Clone, Default, PartialEq, Eq)]
225pub struct Atx {
226 pub chunks: Vec<ChunkRef>,
228 pub head: Option<Head>,
230 pub payload: Option<Payload>,
232 pub warnings: Vec<String>,
234}
235
236pub fn parse(bytes: &[u8]) -> Result<Atx, AtxError> {
240 if !is_atx(bytes) {
241 return Err(AtxError::NotAtx {
242 found: bytes.get(..MAGIC.len()).unwrap_or(bytes).to_vec(),
243 });
244 }
245 let mut warnings = Vec::new();
246 let chunks = walk_chunks(bytes, &mut warnings);
247 let head = parse_head(bytes, &chunks, &mut warnings);
248 let payload = parse_payload(bytes, &chunks, &mut warnings);
249 Ok(Atx {
250 chunks,
251 head,
252 payload,
253 warnings,
254 })
255}
256
257fn u32_le(bytes: &[u8], off: usize) -> Option<u32> {
259 bytes
260 .get(off..off + 4)?
261 .try_into()
262 .ok()
263 .map(u32::from_le_bytes)
264}
265
266fn is_payload_tag(tag: [u8; 4]) -> bool {
268 &tag == fourcc::ASTC_LOWER || &tag == fourcc::ASTC_UPPER || &tag == fourcc::LZFS
269}
270
271fn walk_chunks(bytes: &[u8], warnings: &mut Vec<String>) -> Vec<ChunkRef> {
274 let mut out = Vec::new();
275 let mut offset = MAGIC.len();
276 while offset + 8 <= bytes.len() {
277 let Some(size) = u32_le(bytes, offset) else {
278 break; };
280 let Some(tag_slice) = bytes.get(offset + 4..offset + 8) else {
281 break; };
283 let Ok(tag) = <[u8; 4]>::try_from(tag_slice) else {
284 break; };
286 let payload_offset = offset + 8;
287 let end = payload_offset + size as usize;
288 if end > bytes.len() {
289 warnings.push(format!(
290 "Chunk {} at offset {offset} extends beyond EOF",
291 String::from_utf8_lossy(&tag)
292 ));
293 return out;
294 }
295 out.push(ChunkRef {
296 tag,
297 offset,
298 size,
299 payload_offset,
300 });
301 offset = end;
302 }
303 if offset != bytes.len() {
304 warnings.push(format!(
305 "{} trailing byte(s) after last complete chunk",
306 bytes.len() - offset
307 ));
308 }
309 out
310}
311
312fn parse_head(bytes: &[u8], chunks: &[ChunkRef], warnings: &mut Vec<String>) -> Option<Head> {
315 let Some(head) = chunks.iter().find(|c| &c.tag == fourcc::HEAD) else {
316 warnings.push("No HEAD chunk found".to_string());
317 return None;
318 };
319 if (head.size as usize) < 0x54 {
320 warnings.push(format!(
321 "HEAD chunk too small for documented ATX header: {} bytes",
322 head.size
323 ));
324 return None;
325 }
326 let p = bytes.get(head.payload_offset..head.payload_offset + head.size as usize)?;
327 let texture_uuid: [u8; 16] = p.get(0x3C..0x4C)?.try_into().ok()?;
328 Some(Head {
329 flags: u32_le(p, 0x00)?,
330 width: u32_le(p, 0x18)?,
331 height: u32_le(p, 0x1C)?,
332 depth: u32_le(p, 0x20)?,
333 array_layers: u32_le(p, 0x28)?,
334 mipmaps: u32_le(p, 0x2C)?,
335 texture_uuid,
336 pixel_format: (u32_le(p, 0x4C)?, u32_le(p, 0x50)?),
337 })
338}
339
340fn parse_payload(bytes: &[u8], chunks: &[ChunkRef], warnings: &mut Vec<String>) -> Option<Payload> {
343 let Some(chunk) = chunks.iter().find(|c| is_payload_tag(c.tag)) else {
344 warnings.push("No astc, ASTC, or LZFS texture payload chunk found".to_string());
345 return None;
346 };
347 if chunk.size < 4 {
348 warnings.push(format!(
349 "{} chunk too small to include an inner size",
350 String::from_utf8_lossy(&chunk.tag)
351 ));
352 return None;
353 }
354 let declared_size = u32_le(bytes, chunk.payload_offset)?;
355 Some(Payload {
356 tag: chunk.tag,
357 declared_size,
358 data_offset: chunk.payload_offset + 4,
359 data_len: chunk.size as usize - 4,
360 compressed: &chunk.tag == fourcc::LZFS,
361 })
362}
363
364#[derive(Debug, Clone)]
366pub struct DecodedImage {
367 pub width: u32,
369 pub height: u32,
371 pub rgba: Vec<u8>,
373 pub confidence: FormatConfidence,
375}
376
377pub fn decode(bytes: &[u8]) -> Result<DecodedImage, AtxError> {
382 let atx = parse(bytes)?;
383 let head = atx.head.ok_or(AtxError::NoHead)?;
384 let payload = atx.payload.ok_or(AtxError::NoPayload)?;
385
386 if head.width == 0
387 || head.height == 0
388 || u64::from(head.width) * u64::from(head.height) > MAX_IMAGE_PIXELS
389 {
390 return Err(AtxError::InvalidDimensions {
391 width: head.width,
392 height: head.height,
393 });
394 }
395 let confidence =
396 astc4x4_confidence(head.pixel_format).ok_or(AtxError::UnsupportedPixelFormat {
397 pixel_format: head.pixel_format,
398 })?;
399
400 let data = bytes
401 .get(payload.data_offset..payload.data_offset + payload.data_len)
402 .ok_or_else(|| AtxError::Decompress("payload slice out of bounds".to_string()))?;
403
404 let rgba = if payload.compressed {
405 decode_lzfs(data, head.width, head.height)?
406 } else {
407 decode_macro_tiled(data, head.width, head.height)?
408 };
409
410 Ok(DecodedImage {
411 width: head.width,
412 height: head.height,
413 rgba,
414 confidence,
415 })
416}
417
418fn round_up(value: u32, multiple: u32) -> u32 {
420 value.div_ceil(multiple).saturating_mul(multiple)
424}
425
426fn astc_byte_count(width: u32, height: u32) -> usize {
428 let blocks_w = round_up(width, ASTC_BLOCK_WIDTH) / ASTC_BLOCK_WIDTH;
429 let blocks_h = round_up(height, ASTC_BLOCK_HEIGHT) / ASTC_BLOCK_HEIGHT;
430 (blocks_w as usize)
431 .saturating_mul(blocks_h as usize)
432 .saturating_mul(ASTC_BLOCK_BYTES)
433}
434
435fn decode_lzfs(data: &[u8], width: u32, height: u32) -> Result<Vec<u8>, AtxError> {
437 let mut astc = Vec::new();
438 lzfse_rust::decode_bytes(data, &mut astc).map_err(|e| AtxError::Decompress(e.to_string()))?;
439 let padded_w = round_up(width, ASTC_BLOCK_WIDTH);
440 let padded_h = round_up(height, ASTC_BLOCK_HEIGHT);
441 let expected = astc_byte_count(padded_w, padded_h);
442 let astc = astc.get(..expected).ok_or(AtxError::PayloadTooSmall {
443 got: astc.len(),
444 expected,
445 })?;
446 let rgba = astc_to_rgba(astc, padded_w, padded_h)?;
447 Ok(crop_rgba(&rgba, padded_w, padded_h, width, height))
448}
449
450fn decode_macro_tiled(data: &[u8], width: u32, height: u32) -> Result<Vec<u8>, AtxError> {
454 let padded_w = round_up(width, MACRO_TILE_PX);
455 let padded_h = round_up(height, MACRO_TILE_PX);
456 let blocks_w = padded_w / ASTC_BLOCK_WIDTH;
457 let blocks_h = padded_h / ASTC_BLOCK_HEIGHT;
458 let expected = blocks_w as usize * blocks_h as usize * ASTC_BLOCK_BYTES;
459 if data.len() < expected {
460 return Err(AtxError::PayloadTooSmall {
461 got: data.len(),
462 expected,
463 });
464 }
465
466 let mut best: Option<(f64, Vec<u8>)> = None;
467 for swap_xy in [false, true] {
468 let linear = detile_blocks(data, blocks_w, blocks_h, swap_xy);
469 let padded_rgba = astc_to_rgba(&linear, padded_w, padded_h)?;
470 let cropped = crop_rgba(&padded_rgba, padded_w, padded_h, width, height);
471 let score = grid_seam_score(&cropped, width, height);
472 let replace = match &best {
473 Some((best_score, _)) => score < *best_score,
474 None => true,
475 };
476 if replace {
477 best = Some((score, cropped));
478 }
479 }
480 best.map(|(_, rgba)| rgba)
481 .ok_or_else(|| AtxError::AstcDecode("no de-tile candidate produced".to_string()))
482}
483
484fn detile_blocks(src: &[u8], blocks_w: u32, blocks_h: u32, swap_xy: bool) -> Vec<u8> {
486 let mut linear = vec![0u8; blocks_w as usize * blocks_h as usize * ASTC_BLOCK_BYTES];
487 let mut src_off = 0usize;
488 let mut macro_y = 0;
489 while macro_y < blocks_h {
490 let mut macro_x = 0;
491 while macro_x < blocks_w {
492 for morton in 0..MACRO_BLOCKS * MACRO_BLOCKS {
493 let (mut local_x, mut local_y) = morton_5bit(morton);
494 if swap_xy {
495 core::mem::swap(&mut local_x, &mut local_y);
496 }
497 let block_x = macro_x + local_x;
498 let block_y = macro_y + local_y;
499 let dst = (block_y * blocks_w + block_x) as usize * ASTC_BLOCK_BYTES;
500 if let (Some(d), Some(s)) = (
501 linear.get_mut(dst..dst + ASTC_BLOCK_BYTES),
502 src.get(src_off..src_off + ASTC_BLOCK_BYTES),
503 ) {
504 d.copy_from_slice(s);
505 }
506 src_off += ASTC_BLOCK_BYTES;
507 }
508 macro_x += MACRO_BLOCKS;
509 }
510 macro_y += MACRO_BLOCKS;
511 }
512 linear
513}
514
515fn astc_to_rgba(astc: &[u8], width: u32, height: u32) -> Result<Vec<u8>, AtxError> {
517 let row = width as usize;
518 let mut rgba = vec![0u8; row * height as usize * 4];
519 astc_decode::astc_decode(
520 astc,
521 width,
522 height,
523 astc_decode::Footprint::ASTC_4X4,
524 |x, y, color| {
525 let idx = (y as usize * row + x as usize) * 4;
526 if let Some(px) = rgba.get_mut(idx..idx + 4) {
527 px.copy_from_slice(&color);
528 }
529 },
530 )
531 .map_err(|e| AtxError::AstcDecode(e.to_string()))?;
532 Ok(rgba)
533}
534
535fn crop_rgba(src: &[u8], src_w: u32, _src_h: u32, dst_w: u32, dst_h: u32) -> Vec<u8> {
537 let (sw, dw, dh) = (src_w as usize, dst_w as usize, dst_h as usize);
538 let mut out = vec![0u8; dw * dh * 4];
539 for y in 0..dh {
540 let src_row = y * sw * 4;
541 let dst_row = y * dw * 4;
542 if let (Some(s), Some(d)) = (
543 src.get(src_row..src_row + dw * 4),
544 out.get_mut(dst_row..dst_row + dw * 4),
545 ) {
546 d.copy_from_slice(s);
547 }
548 }
549 out
550}
551
552fn grid_seam_score(rgba: &[u8], width: u32, height: u32) -> f64 {
556 let (w, h) = (width as usize, height as usize);
557 let luma = |x: usize, y: usize| -> i32 {
558 let i = (y * w + x) * 4;
559 match rgba.get(i..i + 3) {
560 Some(p) => {
561 (i32::from(p[0]) * 299 + i32::from(p[1]) * 587 + i32::from(p[2]) * 114) / 1000
562 }
563 None => 0, }
565 };
566 let step = MACRO_TILE_PX as usize;
567 let mut total = 0.0f64;
568 let mut count = 0u32;
569 let mut x = step;
570 while x < w {
571 for y in 0..h {
572 total += f64::from((luma(x, y) - luma(x - 1, y)).unsigned_abs());
573 count += 1;
574 }
575 x += step;
576 }
577 let mut y = step;
578 while y < h {
579 for x in 0..w {
580 total += f64::from((luma(x, y) - luma(x, y - 1)).unsigned_abs());
581 count += 1;
582 }
583 y += step;
584 }
585 if count == 0 {
586 0.0
587 } else {
588 total / f64::from(count)
589 }
590}
591
592#[must_use]
595pub fn morton_5bit(index: u32) -> (u32, u32) {
596 let mut x = 0;
597 let mut y = 0;
598 for bit in 0..5 {
599 x |= ((index >> (bit * 2)) & 1) << bit;
600 y |= ((index >> (bit * 2 + 1)) & 1) << bit;
601 }
602 (x, y)
603}
604
605#[cfg(test)]
606mod tests {
607 use super::*;
608
609 fn container(chunks: &[(&[u8; 4], Vec<u8>)]) -> Vec<u8> {
612 let mut out = MAGIC.to_vec();
613 for (tag, payload) in chunks {
614 out.extend_from_slice(&(payload.len() as u32).to_le_bytes());
615 out.extend_from_slice(tag.as_slice());
616 out.extend_from_slice(payload);
617 }
618 out
619 }
620
621 fn head_payload(width: u32, height: u32, pixel_format: (u32, u32)) -> Vec<u8> {
623 let mut p = vec![0u8; 0x54];
624 let put = |p: &mut [u8], off: usize, v: u32| {
625 p[off..off + 4].copy_from_slice(&v.to_le_bytes());
626 };
627 put(&mut p, 0x00, 0xABCD); put(&mut p, 0x18, width);
629 put(&mut p, 0x1C, height);
630 put(&mut p, 0x20, 1); put(&mut p, 0x28, 1); put(&mut p, 0x2C, 1); for (i, b) in (0..16u8).enumerate() {
634 p[0x3C + i] = b; }
636 put(&mut p, 0x4C, pixel_format.0);
637 put(&mut p, 0x50, pixel_format.1);
638 p
639 }
640
641 fn payload_body(declared_size: u32, data: &[u8]) -> Vec<u8> {
643 let mut p = declared_size.to_le_bytes().to_vec();
644 p.extend_from_slice(data);
645 p
646 }
647
648 #[test]
649 fn magic_gates_atx_on_full_8_bytes() {
650 assert!(is_atx(b"AAPL\r\n\x1a\nrest"));
651 assert!(
652 !is_atx(b"AAPL\x00\x01\x02\x03"),
653 "4-byte AAPL prefix is not ATX"
654 );
655 assert!(!is_atx(b"AAPL\r\n\x1a"), "7 bytes is too short");
656 assert!(!is_atx(b"PK\x03\x04"));
657 }
658
659 #[test]
660 fn parse_rejects_non_atx_loudly_with_the_bytes() {
661 let err = parse(b"\x89PNG\r\n\x1a\n").unwrap_err();
662 match err {
663 AtxError::NotAtx { found } => assert_eq!(found, b"\x89PNG\r\n\x1a\n"),
664 other => panic!("expected NotAtx, got {other:?}"),
665 }
666 }
667
668 #[test]
669 fn framed_walk_locates_chunks_with_size_and_payload_offset() {
670 let buf = container(&[
671 (fourcc::HEAD, vec![0u8; 0x54]),
672 (fourcc::ASTC_LOWER, payload_body(16, &[0u8; 16])),
673 ]);
674 let atx = parse(&buf).unwrap();
675 assert_eq!(atx.chunks.len(), 2);
676 let head = &atx.chunks[0];
677 assert_eq!(&head.tag, fourcc::HEAD);
678 assert_eq!(head.offset, MAGIC.len());
679 assert_eq!(head.size, 0x54);
680 assert_eq!(head.payload_offset, MAGIC.len() + 8);
681 let astc = &atx.chunks[1];
682 assert_eq!(&astc.tag, fourcc::ASTC_LOWER);
683 assert_eq!(astc.size, 20); assert!(atx.warnings.is_empty());
685 }
686
687 #[test]
688 fn framed_walk_warns_on_chunk_past_eof() {
689 let mut buf = MAGIC.to_vec();
691 buf.extend_from_slice(&999u32.to_le_bytes());
692 buf.extend_from_slice(fourcc::HEAD);
693 buf.extend_from_slice(&[0u8; 4]);
694 let atx = parse(&buf).unwrap();
695 assert!(atx.chunks.is_empty());
696 assert!(atx.warnings.iter().any(|w| w.contains("EOF")));
697 }
698
699 #[test]
700 fn head_parses_fields_at_documented_offsets() {
701 let buf = container(&[(fourcc::HEAD, head_payload(390, 844, (3, 5)))]);
702 let head = parse(&buf).unwrap().head.expect("HEAD should parse");
703 assert_eq!(head.width, 390);
704 assert_eq!(head.height, 844);
705 assert_eq!(head.depth, 1);
706 assert_eq!(head.array_layers, 1);
707 assert_eq!(head.mipmaps, 1);
708 assert_eq!(head.flags, 0xABCD);
709 assert_eq!(head.pixel_format, (3, 5));
710 assert_eq!(
711 head.texture_uuid,
712 [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
713 );
714 }
715
716 #[test]
717 fn head_too_small_degrades_to_warning_not_panic() {
718 let buf = container(&[(fourcc::HEAD, vec![0u8; 8])]); let atx = parse(&buf).unwrap();
720 assert!(atx.head.is_none());
721 assert!(atx.warnings.iter().any(|w| w.contains("HEAD")));
722 }
723
724 #[test]
725 fn payload_locates_inner_size_and_data() {
726 let data = vec![0xAAu8; 32];
727 let buf = container(&[
728 (fourcc::HEAD, head_payload(4, 4, (3, 5))),
729 (fourcc::LZFS, payload_body(64, &data)),
730 ]);
731 let payload = parse(&buf).unwrap().payload.expect("payload");
732 assert_eq!(&payload.tag, fourcc::LZFS);
733 assert_eq!(payload.declared_size, 64);
734 assert_eq!(payload.data_len, 32);
735 assert!(payload.compressed);
736 assert_eq!(
737 &buf[payload.data_offset..payload.data_offset + payload.data_len],
738 &data[..]
739 );
740 }
741
742 #[test]
743 fn oversized_dimensions_error_not_overflow_panic() {
744 for (w, h) in [(u32::MAX, u32::MAX), (u32::MAX, 1), (100_000, 100_000)] {
749 let buf = container(&[
750 (fourcc::HEAD, head_payload(w, h, (3, 5))),
751 (fourcc::ASTC_LOWER, payload_body(64, &[0u8; 64])),
752 ]);
753 assert!(
754 matches!(decode(&buf), Err(AtxError::InvalidDimensions { .. })),
755 "{w}x{h} must error loudly, not panic/overflow"
756 );
757 }
758 }
759
760 #[test]
761 fn pixel_format_confidence_is_honest() {
762 assert_eq!(
763 astc4x4_confidence((3, 5)),
764 Some(FormatConfidence::Confirmed)
765 );
766 assert_eq!(astc4x4_confidence((1, 1)), Some(FormatConfidence::Inferred));
767 assert_eq!(astc4x4_confidence((3, 1)), Some(FormatConfidence::Inferred));
768 assert_eq!(
769 astc4x4_confidence((9, 9)),
770 None,
771 "unknown pair: surface raw, never guess"
772 );
773 }
774
775 #[test]
776 fn morton_5bit_matches_hand_derived_values() {
777 assert_eq!(morton_5bit(0b0), (0, 0));
779 assert_eq!(morton_5bit(0b1), (1, 0)); assert_eq!(morton_5bit(0b10), (0, 1)); assert_eq!(morton_5bit(0b11), (1, 1));
782 assert_eq!(morton_5bit(0b100), (2, 0)); assert_eq!(morton_5bit(1023), (31, 31));
785 }
786
787 #[test]
788 fn detile_permutation_matches_ileapp_oracle() {
789 let mut payload = Vec::new();
795 for i in 0..32 * 32u32 {
796 payload.extend_from_slice(&[(i & 0xFF) as u8; 16]);
797 }
798 let cases: [(bool, [u8; 16]); 2] = [
799 (
800 false,
801 [0, 1, 4, 5, 16, 17, 20, 21, 64, 65, 68, 69, 80, 81, 84, 85],
802 ),
803 (
804 true,
805 [
806 0, 2, 8, 10, 32, 34, 40, 42, 128, 130, 136, 138, 160, 162, 168, 170,
807 ],
808 ),
809 ];
810 for (swap, expected) in cases {
811 let linear = detile_blocks(&payload, 32, 32, swap);
812 let got: Vec<u8> = (0..16).map(|l| linear[l * ASTC_BLOCK_BYTES]).collect();
813 assert_eq!(
814 got, expected,
815 "swap={swap} de-tile diverges from iLEAPP oracle"
816 );
817 }
818 }
819
820 #[test]
821 fn decode_rejects_non_atx() {
822 assert!(matches!(decode(b"nope"), Err(AtxError::NotAtx { .. })));
823 }
824
825 #[test]
826 fn decode_surfaces_unsupported_pixel_format_with_bytes() {
827 let buf = container(&[
828 (fourcc::HEAD, head_payload(4, 4, (9, 9))),
829 (fourcc::ASTC_LOWER, payload_body(16, &[0u8; 16])),
830 ]);
831 match decode(&buf) {
832 Err(AtxError::UnsupportedPixelFormat { pixel_format }) => {
833 assert_eq!(pixel_format, (9, 9));
834 }
835 other => panic!("expected UnsupportedPixelFormat, got {other:?}"),
836 }
837 }
838
839 #[test]
840 fn decode_lzfs_path_is_structurally_wired() {
841 let astc_block = [0u8; 16]; let mut compressed = Vec::new();
846 lzfse_rust::encode_bytes(&astc_block, &mut compressed).unwrap();
847 let buf = container(&[
848 (fourcc::HEAD, head_payload(4, 4, (3, 5))),
849 (
850 fourcc::LZFS,
851 payload_body(astc_block.len() as u32, &compressed),
852 ),
853 ]);
854 let img = decode(&buf).unwrap();
855 assert_eq!((img.width, img.height), (4, 4));
856 assert_eq!(img.rgba.len(), 4 * 4 * 4);
857 assert_eq!(img.confidence, FormatConfidence::Confirmed);
858 }
859
860 #[test]
861 fn decode_raw_astc_path_is_structurally_wired() {
862 let blocks = 32 * 32;
866 let payload = payload_body(0, &vec![0u8; blocks * 16]);
867 let buf = container(&[
868 (fourcc::HEAD, head_payload(4, 4, (1, 1))),
869 (fourcc::ASTC_UPPER, payload),
870 ]);
871 let img = decode(&buf).unwrap();
872 assert_eq!((img.width, img.height), (4, 4));
873 assert_eq!(img.rgba.len(), 4 * 4 * 4);
874 assert_eq!(img.confidence, FormatConfidence::Inferred);
875 }
876
877 #[test]
878 fn trailing_bytes_after_last_chunk_warn() {
879 let mut buf = container(&[(fourcc::HEAD, head_payload(4, 4, (3, 5)))]);
880 buf.extend_from_slice(&[0xAA, 0xBB, 0xCC]); let atx = parse(&buf).unwrap();
882 assert!(atx.warnings.iter().any(|w| w.contains("trailing byte")));
883 }
884
885 #[test]
886 fn payload_chunk_too_small_for_inner_size_warns() {
887 let buf = container(&[(fourcc::LZFS, vec![0u8, 1])]);
889 let atx = parse(&buf).unwrap();
890 assert!(atx.payload.is_none());
891 assert!(atx
892 .warnings
893 .iter()
894 .any(|w| w.contains("too small to include an inner size")));
895 }
896
897 #[test]
898 fn macro_tiled_payload_too_small_errors() {
899 let buf = container(&[
902 (fourcc::HEAD, head_payload(4, 4, (1, 1))),
903 (fourcc::ASTC_LOWER, payload_body(0, &[0u8; 100])),
904 ]);
905 assert!(matches!(
906 decode(&buf),
907 Err(AtxError::PayloadTooSmall { .. })
908 ));
909 }
910
911 #[test]
912 fn macro_tiled_large_image_exercises_seam_score() {
913 let blocks = 64 * 64;
917 let buf = container(&[
918 (fourcc::HEAD, head_payload(200, 200, (3, 5))),
919 (fourcc::ASTC_UPPER, payload_body(0, &vec![0u8; blocks * 16])),
920 ]);
921 let img = decode(&buf).unwrap();
922 assert_eq!((img.width, img.height), (200, 200));
923 assert_eq!(img.rgba.len(), 200 * 200 * 4);
924 }
925}