Skip to main content

atx_core/
lib.rs

1//! `atx-core` — reader/decoder for Apple **ATX** (`AAPL`) texture-image containers.
2//!
3//! ATX files are Apple "AAPL" texture containers found throughout iOS UI image
4//! caches: PosterBoard / runtime **snapshots**, **wallpapers**, **contact
5//! posters**, **avatars / Animoji**. Forensically they are *what was on screen*.
6//!
7//! ## Container layout (clean-room from the iLEAPP reference)
8//!
9//! The byte-level framing below is reimplemented clean-room from
10//! [abrignoni/iLEAPP](https://github.com/abrignoni/iLEAPP)'s
11//! `leapp_functions/parsers/apple_atx.py` (MIT, @JamesHabben, 2026-06-25), the
12//! authoritative reverse-engineered reference cited by the source write-up
13//! ([James Habben, 2026-06-26](https://leapps.org/blog-post?post=2026-06-26-decoding-apple-atx-images)).
14//!
15//! ```text
16//! AAPL\r\n\x1a\n      8-byte signature (PNG-style)
17//! [size u32 LE][tag] chunk header (size = payload bytes, NOT incl. the 8-byte header)
18//!   payload[size]
19//! ...                repeated to EOF
20//! ```
21//!
22//! Chunk tags: `HEAD` (metadata), `FILL`, `astc`/`ASTC` (raw ASTC payload),
23//! `LZFS` (LZFSE-compressed ASTC).
24//!
25//! ## Decode pipeline
26//!
27//! - **`LZFS`** payload: LZFSE-decompress (`lzfse_rust`) → a *linear* ASTC 4x4
28//!   stream (padded to the 4x4 block grid) → `astc-decode` → RGBA8. No macro
29//!   de-tiling — the compressed stream is already linear.
30//! - **raw `astc`/`ASTC`** payload: the ASTC blocks are **macro-tiled** in 32x32
31//!   block (128 px) tiles, Morton-ordered within each tile. De-tile to a linear
32//!   block stream, then `astc-decode` → RGBA8. The Morton X/Y interpretation is
33//!   not flagged by the format, so both orientations are decoded and the one with
34//!   the smaller brightness jump across the 128-px macro-tile seams is kept (the
35//!   reference's grid-seam heuristic).
36//!
37//! Codecs are REUSED, never reinvented: `lzfse_rust` (the fleet's LZFSE) and
38//! `astc-decode`. The new value-add is the AAPL container parse, the HEAD field
39//! layout, and the Morton de-tiling.
40//!
41//! ## Validation status (Doer-Checker) — tier-1
42//!
43//! In-crate unit tests cover the container parse and Morton math against the
44//! documented byte layout. Beyond that, the end-to-end decode is **tier-1
45//! validated on real device textures**: 108 real `.atx` files from a public
46//! iPhone 11 / iOS 17.3 full-file-system image decode to RGBA matching the
47//! independent iLEAPP reference (a different author *and* a different ASTC
48//! decoder) to within one LSB per channel on every file, across both the
49//! LZFSE-compressed and raw macro-tiled paths. See `docs/validation.md` for the
50//! corpus, oracle, and per-path results.
51//!
52//! **Epistemics**: report the pixel format as *confirmed* vs *inferred*; never
53//! claim a file is the *active* wallpaper just because of its path. State what the
54//! container holds, not what it means.
55#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
56
57use thiserror::Error;
58
59/// Every ATX file begins with this 8-byte signature (`AAPL` + a PNG-style
60/// `\r\n\x1a\n` guard). Confirmed from the iLEAPP reference (`AAPL_MAGIC`).
61pub const MAGIC: &[u8; 8] = b"AAPL\r\n\x1a\n";
62
63/// Bytes per ASTC block (all block footprints are 128-bit).
64const ASTC_BLOCK_BYTES: usize = 16;
65/// ASTC 4x4 block width in pixels.
66const ASTC_BLOCK_WIDTH: u32 = 4;
67/// ASTC 4x4 block height in pixels.
68const ASTC_BLOCK_HEIGHT: u32 = 4;
69/// Upper bound on declared geometry (width x height). A crafted HEAD can carry
70/// arbitrary `u32` dimensions; reject implausibly large ones loudly before any
71/// padding/byte-count math so the decode cannot overflow or attempt a wild
72/// allocation. Mirrors the iLEAPP reference's `MAX_IMAGE_PIXELS` guard.
73const MAX_IMAGE_PIXELS: u64 = 100_000_000;
74/// Macro-tile edge in ASTC blocks (the de-tiling tile is 32x32 blocks).
75const MACRO_BLOCKS: u32 = 32;
76/// Macro-tile edge in pixels (32 blocks x 4 px = 128) — also the grid-seam step.
77const MACRO_TILE_PX: u32 = MACRO_BLOCKS * ASTC_BLOCK_WIDTH;
78
79/// Errors from reading or decoding an ATX container.
80///
81/// Bad magic is a *bootstrap* failure (the buffer is not an ATX container at all)
82/// and fails loud. Per-artifact misses after a validated magic — a missing HEAD,
83/// a truncated chunk — degrade to [`Atx::warnings`] rather than an error, so the
84/// chunk inventory and any metadata still reach the caller.
85#[derive(Debug, Error)]
86pub enum AtxError {
87    /// The buffer does not begin with the 8-byte `AAPL` magic.
88    #[error("not an ATX file: expected AAPL magic, found {found:02x?}")]
89    NotAtx {
90        /// The leading bytes actually present (up to 8).
91        found: Vec<u8>,
92    },
93    /// No `HEAD` chunk was present, so no metadata could be parsed.
94    #[error("ATX has no HEAD chunk")]
95    NoHead,
96    /// No `astc`/`ASTC`/`LZFS` texture payload chunk was present.
97    #[error("ATX has no texture payload chunk (astc/ASTC/LZFS)")]
98    NoPayload,
99    /// The pixel-format discriminator is not a recognized ASTC 4x4 mapping. The
100    /// raw pair is surfaced so the analyst can identify it.
101    #[error("unsupported ATX pixel format {pixel_format:?} (not a known ASTC 4x4 discriminator)")]
102    UnsupportedPixelFormat {
103        /// The raw `(a, b)` discriminator pair from HEAD.
104        pixel_format: (u32, u32),
105    },
106    /// HEAD declared dimensions that cannot form an image.
107    #[error("invalid ATX dimensions: {width}x{height}")]
108    InvalidDimensions {
109        /// Declared width.
110        width: u32,
111        /// Declared height.
112        height: u32,
113    },
114    /// The texture payload was smaller than the declared geometry requires.
115    #[error("ATX texture payload too small: got {got} bytes, expected at least {expected}")]
116    PayloadTooSmall {
117        /// Bytes actually present.
118        got: usize,
119        /// Bytes the padded ASTC geometry requires.
120        expected: usize,
121    },
122    /// LZFSE decompression of an `LZFS` payload failed.
123    #[error("LZFSE decompression failed: {0}")]
124    Decompress(String),
125    /// The ASTC decoder failed to consume the (de-tiled) block stream.
126    #[error("ASTC decode failed: {0}")]
127    AstcDecode(String),
128}
129
130/// The chunk FourCC tags ATX containers carry.
131pub mod fourcc {
132    /// Metadata chunk.
133    pub const HEAD: &[u8; 4] = b"HEAD";
134    /// Fill chunk.
135    pub const FILL: &[u8; 4] = b"FILL";
136    /// Raw ASTC payload (lowercase form).
137    pub const ASTC_LOWER: &[u8; 4] = b"astc";
138    /// Raw ASTC payload (uppercase form).
139    pub const ASTC_UPPER: &[u8; 4] = b"ASTC";
140    /// LZFSE-compressed ASTC payload.
141    pub const LZFS: &[u8; 4] = b"LZFS";
142}
143
144/// A located chunk within the container, from the framed `[size][tag]` walk.
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub struct ChunkRef {
147    /// The 4-byte tag.
148    pub tag: [u8; 4],
149    /// Byte offset of the chunk header (its `size` field) within the container.
150    pub offset: usize,
151    /// Declared payload size in bytes (excludes the 8-byte chunk header).
152    pub size: u32,
153    /// Byte offset of the chunk payload (`offset + 8`).
154    pub payload_offset: usize,
155}
156
157/// `HEAD` metadata, parsed at the byte offsets confirmed from the iLEAPP reference.
158#[derive(Debug, Clone, Default, PartialEq, Eq)]
159pub struct Head {
160    /// HEAD flags word (offset `0x00`).
161    pub flags: u32,
162    /// Texture width in pixels (offset `0x18`).
163    pub width: u32,
164    /// Texture height in pixels (offset `0x1C`).
165    pub height: u32,
166    /// Texture depth (offset `0x20`).
167    pub depth: u32,
168    /// Array layer count (offset `0x28`).
169    pub array_layers: u32,
170    /// Mipmap level count (offset `0x2C`).
171    pub mipmaps: u32,
172    /// 16-byte texture UUID (offset `0x3C`).
173    pub texture_uuid: [u8; 16],
174    /// The pixel-format discriminator pair (offsets `0x4C`, `0x50`).
175    pub pixel_format: (u32, u32),
176}
177
178/// A located texture payload chunk and its inner framing.
179#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct Payload {
181    /// The payload chunk tag (`astc`, `ASTC`, or `LZFS`).
182    pub tag: [u8; 4],
183    /// The 4-byte inner declared size that prefixes the payload data.
184    pub declared_size: u32,
185    /// Byte offset of the payload data within the container (after the inner size).
186    pub data_offset: usize,
187    /// Length of the payload data in bytes.
188    pub data_len: usize,
189    /// Whether the payload is LZFSE-compressed (the `LZFS` tag).
190    pub compressed: bool,
191}
192
193/// How confidently the pixel format is known — the source research's honest
194/// distinction (never present an inference as a confirmed fact).
195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
196pub enum FormatConfidence {
197    /// Discriminator `(3, 5)`: confirmed ASTC 4x4.
198    Confirmed,
199    /// Discriminator `(1, 1)` or `(3, 1)`: inferred ASTC 4x4 from a matching
200    /// payload type + size (decoded successfully, but not asserted by the format).
201    Inferred,
202}
203
204/// Map a pixel-format discriminator pair to an ASTC-4x4 confidence, per the
205/// iLEAPP findings. Returns `None` for an unrecognized pair — surface the raw
206/// pair to the analyst rather than guessing a format.
207#[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/// Whether `bytes` begins with the full 8-byte ATX `AAPL` magic.
217#[must_use]
218pub fn is_atx(bytes: &[u8]) -> bool {
219    bytes.get(..MAGIC.len()) == Some(MAGIC.as_slice())
220}
221
222/// A parsed ATX container: its chunk inventory, optional HEAD metadata, optional
223/// texture payload, and any non-fatal parse warnings (truncation, trailing bytes).
224#[derive(Debug, Clone, Default, PartialEq, Eq)]
225pub struct Atx {
226    /// Every chunk located by the framed walk, in file order.
227    pub chunks: Vec<ChunkRef>,
228    /// The parsed `HEAD` metadata, if a well-formed HEAD chunk was present.
229    pub head: Option<Head>,
230    /// The located texture payload, if an `astc`/`ASTC`/`LZFS` chunk was present.
231    pub payload: Option<Payload>,
232    /// Non-fatal anomalies surfaced during the walk (fail-loud, never silent).
233    pub warnings: Vec<String>,
234}
235
236/// Parse an ATX container: validate the magic (loud on failure), walk the framed
237/// chunk list, and parse HEAD + locate the texture payload. Malformed chunks
238/// after a valid magic degrade to [`Atx::warnings`].
239pub 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
257/// Read a little-endian `u32` at `off`, bounds-checked (no panic on truncation).
258fn 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
266/// Whether a tag is one of the texture payload chunks.
267fn is_payload_tag(tag: [u8; 4]) -> bool {
268    &tag == fourcc::ASTC_LOWER || &tag == fourcc::ASTC_UPPER || &tag == fourcc::LZFS
269}
270
271/// Walk the framed `[size u32 LE][tag][payload]` chunk list from after the magic.
272/// Stops (with a warning) at the first chunk that would extend past EOF.
273fn 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; // cov:unreachable: offset+8 <= len keeps the u32 in range
279        };
280        let Some(tag_slice) = bytes.get(offset + 4..offset + 8) else {
281            break; // cov:unreachable: offset+8 <= len keeps the tag in range
282        };
283        let Ok(tag) = <[u8; 4]>::try_from(tag_slice) else {
284            break; // cov:unreachable: slice is exactly 4 bytes
285        };
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
312/// Parse the `HEAD` chunk's fields at the documented offsets. Degrades to a
313/// warning (returning `None`) if HEAD is absent or too small.
314fn 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
340/// Locate the first texture payload chunk and read its inner `[declared_size][data]`
341/// framing. Degrades to a warning (returning `None`) if absent or too small.
342fn 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/// A decoded ATX image (RGBA8).
365#[derive(Debug, Clone)]
366pub struct DecodedImage {
367    /// Width in pixels.
368    pub width: u32,
369    /// Height in pixels.
370    pub height: u32,
371    /// RGBA8 pixels, row-major, `width * height * 4` bytes.
372    pub rgba: Vec<u8>,
373    /// How confidently the source pixel format was identified.
374    pub confidence: FormatConfidence,
375}
376
377/// Decode the texture payload to RGBA8.
378///
379/// Tier-1 validated: output matches the independent iLEAPP oracle to within one
380/// LSB per channel on 108 real iOS 17.3 device textures (see `docs/validation.md`).
381pub 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
418/// Round `value` up to the next multiple of `multiple`.
419fn round_up(value: u32, multiple: u32) -> u32 {
420    // Saturating so a pathological `value` can never overflow-panic; callers gate
421    // real geometry on `MAX_IMAGE_PIXELS`, and a saturated size is caught by the
422    // downstream payload-length check rather than aborting.
423    value.div_ceil(multiple).saturating_mul(multiple)
424}
425
426/// Bytes a padded `width` x `height` ASTC 4x4 texture occupies.
427fn 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
435/// `LZFS` path: LZFSE-decompress to a *linear* ASTC 4x4 stream, decode, crop.
436fn 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
450/// Raw `astc`/`ASTC` path: the blocks are macro-tiled (32x32-block, Morton order).
451/// The X/Y interpretation is unflagged, so decode both orientations and keep the
452/// one with the smaller brightness jump across the 128-px macro-tile seams.
453fn 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
484/// Scatter macro-tiled, Morton-ordered ASTC blocks into linear raster block order.
485fn 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
515/// Decode a linear ASTC 4x4 block stream to an RGBA8 buffer of `width` x `height`.
516fn 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
535/// Crop the top-left `dst_w` x `dst_h` region out of a `src_w`-wide RGBA8 buffer.
536fn 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
552/// Mean absolute luma step across the 128-px macro-tile seams of an RGBA8 image.
553/// Lower means smoother seams — the reference's tie-breaker between the two
554/// Morton X/Y orientations. Luma uses the ITU-R 601-2 weights PIL's "L" mode uses.
555fn 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, // cov:unreachable: luma is called with x<w,y<h over a w*h*4 buffer
564        }
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/// Decode an index into a 5-bit Morton (Z-order) `(x, y)` pair: even bits form
593/// `x`, odd bits form `y`. Pure function — the de-tiling primitive.
594#[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    /// Build a framed ATX container: 8-byte magic + `[size u32 LE][tag][payload]`
610    /// per chunk, matching the documented layout (tier-2 fixture construction).
611    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    /// An 0x54-byte HEAD payload with fields at the documented offsets.
622    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); // flags
628        put(&mut p, 0x18, width);
629        put(&mut p, 0x1C, height);
630        put(&mut p, 0x20, 1); // depth
631        put(&mut p, 0x28, 1); // array_layers
632        put(&mut p, 0x2C, 1); // mipmaps
633        for (i, b) in (0..16u8).enumerate() {
634            p[0x3C + i] = b; // recognizable UUID bytes
635        }
636        put(&mut p, 0x4C, pixel_format.0);
637        put(&mut p, 0x50, pixel_format.1);
638        p
639    }
640
641    /// A payload chunk body: `[declared_size u32 LE][data]`.
642    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); // 4-byte declared size + 16 data
684        assert!(atx.warnings.is_empty());
685    }
686
687    #[test]
688    fn framed_walk_warns_on_chunk_past_eof() {
689        // size claims 999 bytes but only a few follow.
690        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])]); // < 0x54
719        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        // A crafted HEAD carries arbitrary u32 dimensions. cargo-fuzz found that
745        // u32::MAX dims overflowed the padding math (`round_up`); decode must fail
746        // loud with InvalidDimensions, never panic. Regression for the
747        // parse_decode fuzz crash.
748        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        // even bits -> x, odd bits -> y.
778        assert_eq!(morton_5bit(0b0), (0, 0));
779        assert_eq!(morton_5bit(0b1), (1, 0)); // bit0 -> x bit0
780        assert_eq!(morton_5bit(0b10), (0, 1)); // bit1 -> y bit0
781        assert_eq!(morton_5bit(0b11), (1, 1));
782        assert_eq!(morton_5bit(0b100), (2, 0)); // bit2 -> x bit1
783                                                // index 1023 = all 10 bits set -> x=31, y=31
784        assert_eq!(morton_5bit(1023), (31, 31));
785    }
786
787    #[test]
788    fn detile_permutation_matches_ileapp_oracle() {
789        // Oracle: iLEAPP `_macro_tiled_payload` on a single 128x128 macro tile
790        // (32x32 blocks) where source block i carries byte value (i & 0xFF). The
791        // value landing at linear block L is the cross-checked permutation. These
792        // golden arrays were produced by the reference (tier-2 independent oracle),
793        // not chosen by us. See tests/oracle in the build-out notes.
794        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        // Tier-2 STRUCTURAL test: validates pipeline plumbing (decompress, decode,
842        // crop, rgba size, confidence) on a 4x4 image — NOT visual correctness,
843        // which needs a real sample + iLEAPP oracle (HANDOFF §5).
844        let astc_block = [0u8; 16]; // one 4x4 ASTC block
845        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        // Tier-2 STRUCTURAL test: a 4x4 image pads to a 128x128 macro tile
863        // (32x32 blocks = 1024 ASTC blocks). Validates de-tile + decode + crop
864        // plumbing; not visual correctness.
865        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]); // < 8 trailing bytes: walk exits with offset < len
881        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        // An LZFS chunk whose whole payload is < 4 bytes cannot hold the inner size.
888        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        // A 4x4 image pads to a 128x128 macro tile (16384 bytes); a tiny payload
900        // must fail loud, not read out of bounds.
901        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        // 200x200 pads to a 256x256 macro region (64x64 = 4096 ASTC blocks). The
914        // 128-px seam loops in grid_seam_score only execute when each dimension
915        // exceeds one macro tile, so this covers the orientation tie-break path.
916        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}