Skip to main content

manifold/
codec.rs

1// MNF5 codec: encoder + decoder + shared header.
2// Format: 24-byte header ("MNF5", u32 LE width, height, tile=16, tile_count, zlib_len),
3// zlib payload of per-tile [u8 mode][mode 1: u32 idx | mode 2: u32 idx + u8 transform][tw*th*3 bytes].
4
5use crate::error::{Error, Result};
6
7use flate2::read::ZlibDecoder;
8use flate2::write::ZlibEncoder;
9use flate2::Compression;
10use std::io::{Read, Write};
11
12/// Magic + header size (bytes): `MNF5` + 5×`u32` LE.
13pub const HEADER_SIZE: usize = 24;
14/// Tile edge in pixels (both axes).
15pub const TILE_SIZE: u32 = 16;
16/// Atlas cap: max distinct previously-decoded patches tracked.
17pub const ATLAS_CAPACITY: usize = 128;
18/// Best-N references evaluated per tile after signature ranking.
19pub const RANK_CANDIDATES: usize = 8;
20/// D8 geometric transforms considered (identity + rotation/reflection).
21pub const NUM_TRANSFORMS: u8 = 8;
22
23/// Image dimensions in pixels.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub struct Dims {
26    /// Width in pixels.
27    pub width: u32,
28    /// Height in pixels.
29    pub height: u32,
30}
31
32/// Per-tile record kinds: literal or reference (v0.5 mode grammar).
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum Mode {
35    /// Raw RGB bytes follow.
36    Literal,
37    /// Prior tile index; optional transform id follows.
38    Reference(u32),
39    /// Exact reference with a D8 transform applied (index, transform id).
40    GeomReference(u32, u8), // prior tile index, D8 transform id 0..8
41}
42
43impl Mode {
44    /// Encode as the record mode byte.
45    pub fn to_byte(self) -> u8 {
46        match self {
47            Mode::Literal => 0,
48            Mode::Reference(_) => 1,
49            Mode::GeomReference(_, _) => 2,
50        }
51    }
52}
53
54/// A tile: raw RGB data plus its (possibly partial) edge dims.
55#[derive(Debug, Clone)]
56pub struct Tile {
57    /// Row-major RGB8 tile bytes (tw*th*3).
58    pub data: Vec<u8>, // tw*th*3, row-major RGB
59    /// Tile width (partial tiles < 16).
60    pub width: u32,
61    /// Tile height.
62    pub height: u32,
63}
64
65impl Tile {
66    /// Byte length of the tile's raw RGB payload.
67    pub fn nbytes(&self) -> usize {
68        self.width as usize * self.height as usize * 3
69    }
70}
71
72// MSRV (review fix 5): integer `is_multiple_of` is 1.87+, this crate
73// pins 1.73+ (README's div_ceil requirement) — modulo form is deliberate.
74/// Ceiling division.
75#[inline]
76#[allow(clippy::manual_is_multiple_of)]
77pub fn div_ceil(a: u32, b: u32) -> u32 {
78    a / b + u32::from(a % b != 0)
79}
80
81/// (tiles_per_row, tile_rows) for the row-major tile scan.
82pub fn tile_grid(dims: Dims) -> (u32, u32) {
83    (
84        div_ceil(dims.width, TILE_SIZE),
85        div_ceil(dims.height, TILE_SIZE),
86    )
87}
88
89/// (w,h) of tile `t`, respecting partial edge tiles.
90pub fn tile_dims(dims: Dims, t: u32) -> (u32, u32) {
91    let (cols, rows) = tile_grid(dims);
92    let tx = t % cols;
93    let ty = t / cols;
94    debug_assert!(tx < cols && ty < rows, "tile index out of grid");
95    let tw = TILE_SIZE.min(dims.width - tx * TILE_SIZE);
96    let th = TILE_SIZE.min(dims.height - ty * TILE_SIZE);
97    (tw, th)
98}
99
100// ponytail: all patches retained (index space must match decoder); README's
101// "last 128" applies to the ranking window, not storage. If a 20k+-tile photo
102// makes ranking hot, add per-shape buckets + edge signatures (v0.6 backlog).
103/// Atlas: previously decoded patches, ranked lookup by signature.
104#[derive(Default)]
105pub struct Atlas {
106    pub(crate) patches: Vec<(u32, u32, Vec<u8>)>, // (w, h, rgb) of encoded-so-far tiles, in scan order
107    sigs: Vec<Sig>,                               // parallel: structural signature per patch
108}
109
110impl Atlas {
111    /// Empty atlas at the header capacity.
112    pub fn new() -> Self {
113        Atlas::default()
114    }
115
116    /// Insert a decoded tile (encoder-side bookkeeping).
117    pub fn insert(&mut self, tile: &Tile) {
118        let sig = tile_sig(tile.width, tile.height, &tile.data);
119        self.insert_with_sig(tile, sig);
120    }
121
122    /// Insert with a precomputed signature (review fix 5: callers that
123    /// already computed sig for ranking should not pay for it twice).
124    pub fn insert_with_sig(&mut self, tile: &Tile, sig: Sig) {
125        self.sigs.push(sig);
126        self.patches
127            .push((tile.width, tile.height, tile.data.clone()));
128    }
129
130    /// Rank candidate patch indices by structural signature distance (v0.6
131    /// P1: gradient/variance/luma, not mean-RGB-only). Only identically shaped
132    /// patches within the last `ATLAS_CAPACITY` (README: "last 128") are
133    /// eligible. Returns at most `top` indices, best first — encoder
134    /// re-scores each candidate by actual proxy cost anyway.
135    pub fn rank_by_sig(&self, sig: &Sig, w: u32, h: u32, top: usize) -> Vec<u32> {
136        let want = (w, h);
137        let window_start = self.patches.len().saturating_sub(ATLAS_CAPACITY);
138        let mut cands: Vec<(u64, u32)> = self
139            .sigs
140            .iter()
141            .enumerate()
142            .skip(window_start)
143            .filter(|(i, _)| {
144                let (pw, ph, _) = &self.patches[*i];
145                (*pw, *ph) == want
146            })
147            .map(|(i, s)| (sig_dist(s, sig), u32::try_from(i).unwrap_or(u32::MAX)))
148            .collect();
149        cands.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
150        cands.truncate(top);
151        cands.into_iter().map(|(_, i)| i).collect()
152    }
153}
154
155#[inline]
156fn rgb_mean(rgb: &[u8]) -> (i64, i64, i64) {
157    let n = (rgb.len() / 3) as i64;
158    let mut s = (0i64, 0i64, 0i64);
159    for px in rgb.chunks_exact(3) {
160        s.0 += px[0] as i64;
161        s.1 += px[1] as i64;
162        s.2 += px[2] as i64;
163    }
164    (s.0 / n, s.1 / n, s.2 / n)
165}
166
167// ponytail: mean+gradient+variance signature, D8-permutation-aware via the
168// min(dx/dy swap) term. Full edge histograms would rank better if recall
169// ever becomes the bottleneck (v0.7 backlog).
170/// Tile signature: RGB means used for nearest-neighbor ranking.
171#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
172pub struct Sig {
173    mean: (i64, i64, i64),
174    gdx: i64,
175    gdy: i64,
176    luma: i64,
177    var: i64,
178}
179
180fn luma(px: &[u8]) -> i64 {
181    (299 * px[0] as i64 + 587 * px[1] as i64 + 114 * px[2] as i64) / 1000
182}
183
184/// Compute a tile's signature for atlas ranking.
185pub fn tile_sig(w: u32, h: u32, data: &[u8]) -> Sig {
186    let n = (w * h) as i64;
187    let l = data.chunks_exact(3).map(luma).collect::<Vec<i64>>();
188    let (wu, hu) = (w as usize, h as usize);
189    let mut gdx = 0i64;
190    let mut gdy = 0i64;
191    for y in 0..hu {
192        for x in 0..wu {
193            if x + 1 < wu {
194                gdx += (l[y * wu + x] - l[y * wu + x + 1]).abs();
195            }
196            if y + 1 < hu {
197                gdy += (l[y * wu + x] - l[(y + 1) * wu + x]).abs();
198            }
199        }
200    }
201    let mean_l = l.iter().sum::<i64>() / n;
202    let var = l.iter().map(|v| (v - mean_l) * (v - mean_l)).sum::<i64>() / n;
203    Sig {
204        mean: rgb_mean(data),
205        gdx: gdx / n,
206        gdy: gdy / n,
207        luma: mean_l,
208        var,
209    }
210}
211
212fn sig_dist(a: &Sig, b: &Sig) -> u64 {
213    let rgb =
214        (a.mean.0 - b.mean.0).pow(2) + (a.mean.1 - b.mean.1).pow(2) + (a.mean.2 - b.mean.2).pow(2);
215    let grad = (((a.gdx - b.gdx).abs() + (a.gdy - b.gdy).abs()) as u64)
216        .min(((a.gdy - b.gdx).abs() + (a.gdx - b.gdy).abs()) as u64);
217    let lum = (a.luma - b.luma).pow(2) as u64 + (a.var - b.var).pow(2) as u64;
218    rgb as u64 + 4 * grad + lum
219}
220
221// --- D8 reversible transforms (v0.6) -----------------------------------------
222// Pull maps: output pixel (x, y) samples src(sx, sy). Encode and decode both
223// call apply_transform, so any consistent map roundtrips losslessly.
224
225/// Dims of a tile after D8 transform `t` (swapped for rotations).
226pub fn transform_dims(t: u8, w: u32, h: u32) -> (u32, u32) {
227    match t {
228        4..=7 => (h, w),
229        _ => (w, h),
230    }
231}
232
233/// Which transforms keep the tile shape (a reference must reproduce tw x th):
234/// all 8 on square patches, only the identity/hflip/vflip/rot180 subgroup on
235/// non-square partial tiles.
236pub fn transform_is_valid_for(t: u8, w: u32, h: u32) -> bool {
237    if t >= NUM_TRANSFORMS {
238        return false;
239    }
240    w == h || t <= 3
241}
242
243fn transform_source(t: u8, w: u32, h: u32, x: u32, y: u32) -> (u32, u32) {
244    // callers guarantee t <= 4 and x/y within transform_dims output grid
245    match t {
246        0 => (x, y),
247        1 => (w - 1 - x, y),
248        2 => (x, h - 1 - y),
249        3 => (w - 1 - x, h - 1 - y),
250        4 => (y, h - 1 - x),
251        _ => unreachable!("composite transforms are composed in apply_transform"),
252    }
253}
254
255/// Pixel bytes after D8 transform `t` (deterministic integer perm).
256pub fn apply_transform(t: u8, w: u32, h: u32, data: &[u8]) -> Vec<u8> {
257    assert!(t < NUM_TRANSFORMS, "invalid transform {t}");
258    if t >= 5 {
259        let (w2, h2) = transform_dims(4, w, h);
260        let base = apply_transform(4, w, h, data);
261        return match t {
262            5 => apply_transform(1, w2, h2, &base),
263            6 => apply_transform(3, w2, h2, &base),
264            _ => apply_transform(1, w2, h2, &apply_transform(3, w2, h2, &base)),
265        };
266    }
267    let (ow, oh) = transform_dims(t, w, h);
268    let mut out = vec![0u8; (ow as usize) * (oh as usize) * 3];
269    for y in 0..oh {
270        for x in 0..ow {
271            let (sx, sy) = transform_source(t, w, h, x, y);
272            let so = ((sy * w + sx) as usize) * 3;
273            let d = ((y * ow + x) as usize) * 3;
274            out[d..d + 3].copy_from_slice(&data[so..so + 3]);
275        }
276    }
277    out
278}
279
280/// Cost proxy for one tile's contribution to the compressed stream.
281/// ponytail: per-tile zlib call is O(128) small compressions per tile — fine at
282/// benchmark scale; if profiling shows it hot, cache or use a rolling window.
283pub fn tile_proxy_cost(bytes: &[u8]) -> u64 {
284    let mut e = ZlibEncoder::new(Vec::new(), Compression::fast());
285    e.write_all(bytes).ok();
286    e.finish().map(|v| v.len() as u64).unwrap_or(u64::MAX)
287}
288
289/// Encode a tile stream (row-major, tile_dims shapes) with optional atlas matching.
290/// Raw bytes per tile: mode, optional idx, optional transform, body; mode 2 (geometric atlas,
291/// v0.6) writes both index (u32 LE) and transform id (u8) after the mode byte.
292/// `use_geom` extends matching to the D8 group; with `use_atlas` only, exact
293/// prior-patch references (v0.5 behavior) are used.
294pub fn encode_tiles(tiles: &[Tile], use_atlas: bool, use_geom: bool) -> Vec<u8> {
295    encode_stats(tiles, use_atlas, use_geom).0
296}
297
298/// Encode + utilization counters (v0.6 exit metric: reference utilization).
299pub fn encode_stats(tiles: &[Tile], use_atlas: bool, use_geom: bool) -> (Vec<u8>, GeomStats) {
300    let mut out: Vec<u8> =
301        Vec::with_capacity(tiles.len() * (TILE_SIZE as usize * TILE_SIZE as usize * 3 + 6));
302    let mut atlas = Atlas::new();
303    let mut stats = GeomStats::default();
304
305    for tile in tiles {
306        let n = tile.nbytes();
307        let sig = tile_sig(tile.width, tile.height, &tile.data);
308        let mut best_mode = Mode::Literal;
309        let mut best_bytes = tile.data.clone();
310        // literal pays only its own mode byte; reference metadata cost is
311        // counted per-candidate below (review: don't double-charge literals)
312        let lit_cost = 1 + tile_proxy_cost(&tile.data);
313        let mut best_cost = lit_cost;
314
315        if use_atlas {
316            // v0.6 P1: rank by full structural signature instead of mean-RGB-only.
317            let cands = atlas.rank_by_sig(&sig, tile.width, tile.height, RANK_CANDIDATES);
318            // square tiles: all 8 D8 transforms; partial tiles: shape-keeping subgroup.
319            // use_geom=false (atlas ablation) pins t=0 — the v0.5 exact-reference mode.
320            let transforms: &[u8] = if !use_geom {
321                &[0]
322            } else if tile.width == tile.height {
323                &[0, 1, 2, 3, 4, 5, 6, 7]
324            } else {
325                &[0, 1, 2, 3]
326            };
327            for prior in cands {
328                let shape = (tile.width, tile.height);
329                for &t in transforms {
330                    let (_, _, ref_rgb) = &atlas.patches[prior as usize];
331                    let transformed = if t == 0 {
332                        ref_rgb.clone()
333                    } else {
334                        apply_transform(t, shape.0, shape.1, ref_rgb)
335                    };
336                    let mut residual = vec![0u8; n];
337                    for i in 0..n {
338                        residual[i] = tile.data[i].wrapping_sub(transformed[i]);
339                    }
340                    // metadata: mode byte + u32 index + (u8 transform when t != 0)
341                    let meta_cost = 5 + u64::from(t != 0);
342                    let cost = meta_cost + tile_proxy_cost(&residual);
343                    if cost < best_cost {
344                        best_cost = cost;
345                        best_mode = Mode::GeomReference(prior, t);
346                        best_bytes = residual;
347                    }
348                }
349            }
350            // exact mode-1 is a special case of mode 2 with t=0, so the encoder
351            // emits only mode 2 (or mode 0) when matching wins.
352        }
353
354        // exact reference (t=0) is cheaper as mode 1: one byte shorter
355        let emit = match best_mode {
356            Mode::GeomReference(p, 0) => Mode::Reference(p),
357            other => other,
358        };
359        match emit {
360            Mode::Literal => stats.literal += 1,
361            Mode::Reference(_) => stats.ref_exact += 1,
362            Mode::GeomReference(_, t) => {
363                stats.ref_geom += 1;
364                if t != 0 {
365                    stats.geom_nontrivial += 1;
366                }
367            }
368        }
369        stats.modes.push(emit.to_byte());
370        stats.savings.push(lit_cost as i64 - best_cost as i64);
371        stats.refs.push(match emit {
372            Mode::Reference(p) => Some((p, 0)),
373            Mode::GeomReference(p, t) => Some((p, t)),
374            Mode::Literal => None,
375        });
376        out.push(emit.to_byte());
377        match emit {
378            Mode::Reference(prior) => out.extend_from_slice(&prior.to_le_bytes()),
379            Mode::GeomReference(prior, t) => {
380                out.extend_from_slice(&prior.to_le_bytes());
381                out.push(t);
382            }
383            Mode::Literal => {}
384        }
385        out.extend_from_slice(&best_bytes);
386        atlas.insert_with_sig(tile, sig);
387    }
388    (out, stats)
389}
390
391/// zlib-compress the assembled record stream (level 9).
392pub fn compress_payload(raw: &[u8]) -> Vec<u8> {
393    let mut e = ZlibEncoder::new(Vec::new(), Compression::default());
394    e.write_all(raw).expect("zlib encode");
395    e.finish().expect("zlib finish")
396}
397
398// v0.6 exit criterion: how much the atlas is actually used.
399/// Encode statistics (per-tile choices/savings; feeds vis maps).
400#[derive(Debug, Clone, Default)]
401pub struct GeomStats {
402    /// Count of mode-0 literals.
403    pub literal: u32,
404    /// Count of mode-1 exact references.
405    pub ref_exact: u32,
406    /// Count of mode-2 geometric references.
407    pub ref_geom: u32,
408    /// Mode-2 with a nontrivial transform (t≠0).
409    pub geom_nontrivial: u32, // mode 2 with transform != 0
410    /// Per-tile chosen mode, scan order.
411    pub modes: Vec<u8>, // per-tile emit mode (0/1/2), scan order
412    /// Per-tile proxy-cost saving vs literal.
413    pub savings: Vec<i64>, // per-tile literal-proxy-cost minus chosen-cost
414    /// Per-tile (source index, transform).
415    pub refs: Vec<Option<(u32, u8)>>, // per-tile (source index, transform) for references
416}
417
418/// Build the 24-byte `MNF5` header.
419pub fn build_header(dims: Dims, tile_count: u32, payload_len: u32) -> Vec<u8> {
420    let mut h = Vec::with_capacity(HEADER_SIZE);
421    h.extend_from_slice(b"MNF5");
422    h.extend_from_slice(&dims.width.to_le_bytes());
423    h.extend_from_slice(&dims.height.to_le_bytes());
424    h.extend_from_slice(&TILE_SIZE.to_le_bytes());
425    h.extend_from_slice(&tile_count.to_le_bytes());
426    h.extend_from_slice(&payload_len.to_le_bytes());
427    debug_assert_eq!(h.len(), HEADER_SIZE);
428    h
429}
430
431/// Decoded image: dims + RGB8 pixels.
432#[derive(Debug)]
433pub struct DecodeResult {
434    /// Decoded dimensions.
435    pub dims: Dims,
436    /// RGB8 pixels, row-major.
437    pub pixels: Vec<u8>,
438}
439
440/// Configurable decoder resource ceilings (review fix 3). Defaults are the
441/// format-derived maxima; callers decoding untrusted input can tighten any
442/// of them independently.
443#[derive(Debug, Clone, Copy)]
444pub struct DecodeLimits {
445    /// Max total pixels (w*h).
446    pub max_pixels: u64,
447    /// Max tiles.
448    pub max_tiles: u32,
449    /// Max decompressed payload bytes.
450    pub max_decompressed: u64,
451    /// Max decoder memory (pixels + atlas), bytes.
452    pub max_total_memory: u64, // pixels buffer + atlas patches, bytes
453}
454
455impl Default for DecodeLimits {
456    fn default() -> Self {
457        DecodeLimits {
458            max_pixels: 1 << 26,
459            max_tiles: u32::MAX,
460            max_decompressed: u64::MAX, // replaced by checked per-file bound
461            max_total_memory: u64::MAX,
462        }
463    }
464}
465
466/// Decode an MNF5 file (header + zlib payload) back to RGB8 pixels.
467pub fn decode(buf: &[u8]) -> Result<DecodeResult> {
468    decode_with_limits(buf, &DecodeLimits::default())
469}
470
471/// Decode MNF5 with configurable ceilings.
472pub fn decode_with_limits(buf: &[u8], lim: &DecodeLimits) -> Result<DecodeResult> {
473    if buf.len() < HEADER_SIZE {
474        return Err(Error::fmt_at("truncated header", buf.len() as u64));
475    }
476    if &buf[0..4] != b"MNF5" {
477        return Err(Error::fmt_at("bad magic (want MNF5)", 0));
478    }
479    let (w, h) = read_dims(&buf[4..12])?;
480    if w as u64 * h as u64 > lim.max_pixels {
481        return Err(Error::Limits {
482            which: "max_pixels",
483            limit: lim.max_pixels,
484            actual: w as u64 * h as u64,
485        });
486    }
487    let ts = u32::from_le_bytes(buf[12..16].try_into().unwrap());
488    let tile_count = u32::from_le_bytes(buf[16..20].try_into().unwrap());
489    if ts != TILE_SIZE {
490        return Err(Error::fmt_msg(format!("unsupported tile size {ts}")));
491    }
492    if tile_count as u64 > lim.max_tiles as u64 {
493        return Err(Error::Limits {
494            which: "max_tiles",
495            limit: u64::from(lim.max_tiles),
496            actual: tile_count as u64,
497        });
498    }
499    let payload_len = u32::from_le_bytes(buf[20..24].try_into().unwrap()) as usize;
500    let (cols, rows) = tile_grid(Dims {
501        width: w,
502        height: h,
503    });
504    let expected = cols * rows;
505    if tile_count != expected {
506        return Err(Error::fmt_msg(format!(
507            "tile_count {tile_count} != expected {expected} for {w}x{h}"
508        )));
509    }
510    let _rows = rows;
511    if payload_len > MAX_PAYLOAD {
512        return Err(Error::fmt_msg("payload too large"));
513    }
514    if (buf.len() - HEADER_SIZE) < payload_len {
515        return Err(Error::fmt_msg("payload shorter than header-declared len"));
516    }
517    // Reject bytes beyond the declared payload before decompressing: a
518    // well-formed file is header + exactly payload_len bytes.
519    if buf.len() != HEADER_SIZE + payload_len {
520        return Err(Error::fmt_msg(format!(
521            "file is {} bytes but header declares {} + {payload_len} payload",
522            buf.len(),
523            HEADER_SIZE
524        )));
525    }
526    let payload = &buf[HEADER_SIZE..HEADER_SIZE + payload_len];
527
528    // Compression bound (checked math, review fix 1): raw stream <=
529    // 3WH pixel bytes total + per-tile metadata worst case
530    // (1 mode + 4 idx + 1 transform = 6). Reject during decompression
531    // via take(limit+1), not after read_to_end.
532    let px_bytes_total = (w as u64) * (h as u64) * 3;
533    let decomp_limit = px_bytes_total
534        .checked_add(6 * tile_count as u64)
535        .ok_or_else(|| Error::fmt_msg("decompressed size overflow"))?;
536    let mut dz = ZlibDecoder::new(payload).take(decomp_limit + 1);
537    let mut raw = Vec::new();
538    dz.read_to_end(&mut raw)
539        .map_err(|_| Error::Decompress("corrupt zlib stream"))?;
540    if raw.len() as u64 > decomp_limit {
541        return Err(Error::Limits {
542            which: "max_decompressed",
543            limit: decomp_limit,
544            actual: raw.len() as u64,
545        });
546    }
547
548    // Premature-allocation gate (review fix 2): the stream must hold at
549    // least 4 bytes per tile (mode byte + minimum ref metadata) before
550    // any image-sized allocation happens.
551    if raw.len() < 4 * tile_count as usize {
552        return Err(Error::fmt_msg(
553            "decompressed stream shorter than per-tile minimum",
554        ));
555    }
556
557    let total_px = w as usize * h as usize * 3;
558    // decoder memory = pixels buffer + atlas patches (a second full copy
559    // once all tiles are decoded). Enforce the ceiling before allocating.
560    let mem_needed = (total_px as u64) * 2;
561    if mem_needed > lim.max_total_memory {
562        return Err(Error::Limits {
563            which: "max_total_memory",
564            limit: lim.max_total_memory,
565            actual: mem_needed,
566        });
567    }
568    let mut out = vec![0u8; total_px];
569    // no capacity reservation: patches grows as actually decoded
570    let mut patches: Vec<(u32, u32, Vec<u8>)> = Vec::new();
571    let mut cursor = 0usize;
572
573    for t in 0..tile_count {
574        let (tw, th) = tile_dims(
575            Dims {
576                width: w,
577                height: h,
578            },
579            t,
580        );
581        let n = (tw * th * 3) as usize;
582        if cursor + 1 > raw.len() {
583            return Err(Error::fmt_at(
584                format!("tile {t}: truncated mode byte"),
585                u64::from(t),
586            ));
587        }
588        let mode = raw[cursor];
589        cursor += 1;
590        let data: Vec<u8> = match mode {
591            0 => {
592                if cursor + n > raw.len() {
593                    return Err(Error::fmt_at(
594                        format!("tile {t}: truncated literal payload"),
595                        u64::from(t),
596                    ));
597                }
598                let d = raw[cursor..cursor + n].to_vec();
599                cursor += n;
600                d
601            }
602            1 => {
603                if cursor + 4 > raw.len() {
604                    return Err(Error::fmt_at(
605                        format!("tile {t}: truncated reference index"),
606                        u64::from(t),
607                    ));
608                }
609                let idx = u32::from_le_bytes(raw[cursor..cursor + 4].try_into().unwrap());
610                cursor += 4;
611                let (rw, rh, ref_rgb) = patches.get(idx as usize).ok_or_else(|| {
612                    Error::fmt_msg(format!(
613                        "tile {t}: reference to tile {idx} >= {len} decoded",
614                        len = patches.len(),
615                        t = t,
616                        idx = idx
617                    ))
618                })?;
619                if *rw != tw || *rh != th {
620                    return Err(Error::fmt_at(
621                        format!(
622                            "tile {t}: patch {idx} shaped {rw}x{rh}, need {tw}x{th}",
623                            t = t,
624                            idx = idx,
625                            rw = rw,
626                            rh = rh,
627                            tw = tw,
628                            th = th
629                        ),
630                        u64::from(t),
631                    ));
632                }
633                if cursor + n > raw.len() {
634                    return Err(Error::fmt_at(
635                        format!("tile {t}: truncated residual"),
636                        u64::from(t),
637                    ));
638                }
639                let residual = &raw[cursor..cursor + n];
640                let mut d = vec![0u8; n];
641                for i in 0..n {
642                    d[i] = ref_rgb[i].wrapping_add(residual[i]);
643                }
644                cursor += n;
645                d
646            }
647            2 => {
648                if cursor + 5 > raw.len() {
649                    return Err(Error::fmt_at(
650                        format!("tile {t}: truncated geometric reference"),
651                        u64::from(t),
652                    ));
653                }
654                let idx = u32::from_le_bytes(raw[cursor..cursor + 4].try_into().unwrap());
655                let tr = raw[cursor + 4];
656                cursor += 5;
657                if !transform_is_valid_for(tr, tw, th) {
658                    return Err(Error::fmt_at(
659                        format!(
660                            "tile {t}: invalid transform {tr} for {tw}x{th} tile",
661                            t = t,
662                            tr = tr,
663                            tw = tw,
664                            th = th
665                        ),
666                        u64::from(t),
667                    ));
668                }
669                let (rw, rh, ref_rgb) = patches.get(idx as usize).ok_or_else(|| {
670                    Error::fmt_msg(format!(
671                        "tile {t}: reference to tile {idx} >= {len} decoded",
672                        len = patches.len(),
673                        t = t,
674                        idx = idx
675                    ))
676                })?;
677                if *rw != tw || *rh != th {
678                    return Err(Error::fmt_at(
679                        format!(
680                            "tile {t}: patch {idx} shaped {rw}x{rh}, need {tw}x{th}",
681                            t = t,
682                            idx = idx,
683                            rw = rw,
684                            rh = rh,
685                            tw = tw,
686                            th = th
687                        ),
688                        u64::from(t),
689                    ));
690                }
691                if cursor + n > raw.len() {
692                    return Err(Error::fmt_at(
693                        format!("tile {t}: truncated residual"),
694                        u64::from(t),
695                    ));
696                }
697                let residual = &raw[cursor..cursor + n];
698                let transformed = if tr == 0 {
699                    ref_rgb.clone()
700                } else {
701                    apply_transform(tr, tw, th, ref_rgb)
702                };
703                let mut d = vec![0u8; n];
704                for i in 0..n {
705                    d[i] = transformed[i].wrapping_add(residual[i]);
706                }
707                cursor += n;
708                d
709            }
710            m => {
711                return Err(Error::fmt_at(
712                    format!("tile {t}: invalid mode byte {m}"),
713                    u64::from(t),
714                ))
715            }
716        };
717        blit_tile(
718            &mut out,
719            Dims {
720                width: w,
721                height: h,
722            },
723            t,
724            &data,
725            tw,
726            th,
727        );
728        patches.push((tw, th, data));
729    }
730    if cursor != raw.len() {
731        return Err(Error::fmt_msg(format!(
732            "trailing bytes after last tile: {cursor} != {}",
733            raw.len()
734        )));
735    }
736    Ok(DecodeResult {
737        dims: Dims {
738            width: w,
739            height: h,
740        },
741        pixels: out,
742    })
743}
744
745const MAX_PAYLOAD: usize = 1 << 31; // 2 GiB hard stop for untrusted input
746
747fn read_dims(b: &[u8]) -> Result<(u32, u32)> {
748    let w = u32::from_le_bytes(b[0..4].try_into().unwrap());
749    let h = u32::from_le_bytes(b[4..8].try_into().unwrap());
750    if w == 0 || h == 0 {
751        return Err(Error::fmt_msg("zero dimensions"));
752    }
753    // guard oversized allocations: total pixels capped at 2^26 (~192 MiB RGB;
754    // the atlas keeps a second copy of every patch, so budget ~2x while decoding)
755    if (w as u64) * (h as u64) > (1 << 26) {
756        return Err(Error::fmt_msg("image too large"));
757    }
758    Ok((w, h))
759}
760
761/// Split an RGB8 pixel buffer into row-major Tiles (public: CLI uses the same
762/// tiling as encoder/decoder/tests — one definition of truth).
763pub fn split_tiles(pixels: &[u8], d: Dims) -> Vec<Tile> {
764    let (cols, rows) = tile_grid(d);
765    (0..cols * rows)
766        .map(|t| {
767            let (tw, th) = tile_dims(d, t);
768            let mut data = vec![0u8; (tw * th * 3) as usize];
769            let tx = (t % cols) * TILE_SIZE;
770            let ty = (t / cols) * TILE_SIZE;
771            for row in 0..th as usize {
772                for col in 0..tw as usize {
773                    let py = ty as usize + row;
774                    let px = tx as usize + col;
775                    let dst = (row * tw as usize + col) * 3;
776                    let src = (py * d.width as usize + px) * 3;
777                    data[dst..dst + 3].copy_from_slice(&pixels[src..src + 3]);
778                }
779            }
780            Tile {
781                data,
782                width: tw,
783                height: th,
784            }
785        })
786        .collect()
787}
788
789fn blit_tile(out: &mut [u8], dims: Dims, t: u32, data: &[u8], tw: u32, th: u32) {
790    let (cols, _) = tile_grid(dims);
791    let tx = (t % cols) * TILE_SIZE;
792    let ty = (t / cols) * TILE_SIZE;
793    for row in 0..th as usize {
794        let py = ty as usize + row;
795        for col in 0..tw as usize {
796            let px = tx as usize + col;
797            let dst = (py * dims.width as usize + px) * 3;
798            let src = (row * tw as usize + col) * 3;
799            out[dst..dst + 3].copy_from_slice(&data[src..src + 3]);
800        }
801    }
802}
803
804#[cfg(test)]
805mod tests {
806    use super::*;
807
808    fn dims(w: u32, h: u32) -> Dims {
809        Dims {
810            width: w,
811            height: h,
812        }
813    }
814
815    fn roundtrip(d: Dims, pixels: &[u8], use_atlas: bool) -> Vec<u8> {
816        let tiles = split_tiles(pixels, d);
817        let raw = encode_tiles(&tiles, use_atlas, true);
818        let payload = compress_payload(&raw);
819        let header = build_header(d, tiles.len() as u32, payload.len() as u32);
820        let mut buf = header;
821        buf.extend_from_slice(&payload);
822        let res = decode(&buf).expect("decode ok");
823        assert_eq!(res.dims, d);
824        res.pixels
825    }
826
827    fn solid(w: u32, h: u32, c: [u8; 3]) -> Vec<u8> {
828        (0..w * h).flat_map(|_| c).collect()
829    }
830
831    fn gradient(w: u32, h: u32) -> Vec<u8> {
832        (0..w * h)
833            .flat_map(|i| {
834                [
835                    (i % 256) as u8,
836                    ((i / w) % 256) as u8,
837                    ((i / 7) % 256) as u8,
838                ]
839            })
840            .collect()
841    }
842
843    #[test]
844    fn grid_math() {
845        assert_eq!(tile_grid(dims(16, 16)), (1, 1));
846        assert_eq!(tile_grid(dims(17, 16)), (2, 1));
847        assert_eq!(tile_grid(dims(31, 33)), (2, 3));
848        assert_eq!(tile_dims(dims(31, 33), 1), (15, 16));
849        assert_eq!(tile_dims(dims(31, 33), 2), (16, 16));
850        assert_eq!(tile_dims(dims(31, 33), 5), (15, 1));
851        assert_eq!(div_ceil(0, 16), 0); // 0-sized grid ok for tests only
852    }
853
854    #[test]
855    fn roundtrip_solid_16x16_atlas() {
856        let px = solid(16, 16, [10, 20, 30]);
857        assert_eq!(roundtrip(dims(16, 16), &px, true), px);
858    }
859
860    #[test]
861    fn roundtrip_solid_16x16_no_atlas() {
862        let px = solid(16, 16, [200, 100, 50]);
863        assert_eq!(roundtrip(dims(16, 16), &px, false), px);
864    }
865
866    #[test]
867    fn roundtrip_odd_dims() {
868        for (w, h) in [
869            (31u32, 33u32),
870            (17u32, 1u32),
871            (1u32, 17u32),
872            (1u32, 1u32),
873            (48u32, 47u32),
874        ] {
875            let px = gradient(w, h);
876            let dec = roundtrip(dims(w, h), &px, true);
877            assert_eq!(dec.len(), (w * h * 3) as usize, "len {w}x{h}");
878            assert_eq!(dec, px, "pixels {w}x{h}");
879        }
880    }
881
882    #[test]
883    fn roundtrip_random_pixels() {
884        // deterministic pseudo-random
885        let mut s: u32 = 12345;
886        let mut rnd = || {
887            s = s.wrapping_mul(1103515245).wrapping_add(12345);
888            (s >> 16) as u8
889        };
890        let w = 64;
891        let h = 48;
892        let px: Vec<u8> = (0..w * h * 3).map(|_| rnd()).collect();
893        assert_eq!(roundtrip(dims(w, h), &px, true), px);
894    }
895
896    #[test]
897    fn roundtrip_uniform_atlas_matches_noatlas() {
898        let px = solid(64, 64, [7, 7, 7]);
899        assert_eq!(roundtrip(dims(64, 64), &px, true), px);
900        assert_eq!(roundtrip(dims(64, 64), &px, false), px);
901    }
902
903    #[test]
904    fn decode_rejects_bad_inputs() {
905        assert!(decode(&[]).is_err(), "empty");
906        assert!(decode(&[0u8; 23]).is_err(), "short header");
907        let mut bad_magic = build_header(dims(16, 16), 1, 0);
908        bad_magic[0] = b'X';
909        assert!(decode(&bad_magic).is_err(), "bad magic");
910        let bad_tile_size = {
911            let mut h = build_header(dims(16, 16), 1, 0);
912            h[12] = 15; // tile_size != 16
913            h
914        };
915        assert!(decode(&bad_tile_size).is_err(), "tile size");
916        let bad_count = build_header(dims(16, 16), 5, 0);
917        assert!(decode(&bad_count).is_err(), "tile count");
918        assert!(
919            decode(&build_header(dims(0, 16), 1, 0)).is_err(),
920            "zero dim"
921        );
922        assert!(
923            read_dims(&build_header(dims(0, 16), 1, 0)[4..12]).is_err(),
924            "read_dims rejects zero width"
925        );
926        // truncated payload: valid header, valid zlib, missing trailing tile data
927        let d = dims(32, 16);
928        let tiles = split_tiles(&solid(32, 16, [1, 2, 3]), d);
929        let mut raw = encode_tiles(&tiles, true, false);
930        raw.truncate(raw.len() - 5); // chop half of second tile's data
931        let truncated = {
932            let payload = compress_payload(&raw);
933            let mut b = build_header(d, 2, payload.len() as u32);
934            b.extend_from_slice(&payload);
935            b
936        };
937        assert!(decode(&truncated).is_err(), "truncated tile data");
938        // dangling reference: first tile points at tile 3 which doesn't exist
939        let mut raw_ref = vec![1u8];
940        raw_ref.extend_from_slice(&3u32.to_le_bytes());
941        raw_ref.extend_from_slice(&[0u8; 16 * 16 * 3]);
942        let dang = {
943            let payload = compress_payload(&raw_ref);
944            let mut b = build_header(dims(16, 16), 1, payload.len() as u32);
945            b.extend_from_slice(&payload);
946            b
947        };
948        assert!(decode(&dang).is_err(), "dangling reference");
949    }
950
951    #[test]
952    fn decoder_rejects_oversized_dims() {
953        let h = build_header(
954            Dims {
955                width: 1 << 24,
956                height: 1 << 24,
957            },
958            0,
959            0,
960        );
961        assert!(decode(&h).is_err(), "oversized dims");
962    }
963
964    #[test]
965    fn atlas_reference_shape_mismatch_rejected() {
966        // tile 0 is 16x16, tile 1 is 16x8; mode 1 reference from tile 1 to tile 0
967        let d = Dims {
968            width: 16,
969            height: 24,
970        };
971        let mut raw = Vec::new();
972        raw.push(0u8); // tile 0 literal
973        raw.extend_from_slice(&[0u8; 16 * 16 * 3]);
974        raw.push(1u8); // tile 1 reference
975        raw.extend_from_slice(&0u32.to_le_bytes());
976        raw.extend_from_slice(&[0u8; 16 * 8 * 3]);
977        let payload = compress_payload(&raw);
978        let mut b = build_header(d, 2, payload.len() as u32);
979        b.extend_from_slice(&payload);
980        let err = decode(&b).unwrap_err();
981        assert!(
982            err.to_string().contains("shaped"),
983            "want shape mismatch, got: {err}"
984        );
985    }
986
987    #[test]
988    fn encoder_picks_reference_when_residual_compresses() {
989        // two identical NOISY tiles: literal costs ~768 bytes, the exact-match
990        // residual is all zeros — the atlas must win. (Constant tiles don't
991        // exercise this: deflate crushes a constant literal as cheaply as a
992        // zero residual, so the honest cost model prefers the literal.)
993        let mut s: u32 = 999;
994        let mut rnd = || {
995            s = s.wrapping_mul(1103515245).wrapping_add(12345);
996            (s >> 16) as u8
997        };
998        let noisy: Vec<u8> = (0..32 * 16 * 3).map(|_| rnd()).collect();
999        let tiles = vec![
1000            Tile {
1001                data: noisy.clone(),
1002                width: 32,
1003                height: 16,
1004            },
1005            Tile {
1006                data: noisy.clone(),
1007                width: 32,
1008                height: 16,
1009            },
1010        ];
1011        let raw_tiles = encode_tiles(&tiles, true, false);
1012        let t1 = 1 + 32 * 16 * 3; // second tile's metadata starts after tile 0's data
1013        assert_eq!(raw_tiles[0], 0, "tile 0 literal");
1014        assert_eq!(
1015            raw_tiles[t1], 1,
1016            "tile 1 should be an exact reference (mode 1)"
1017        );
1018        let idx = u32::from_le_bytes(raw_tiles[t1 + 1..t1 + 5].try_into().unwrap());
1019        assert_eq!(idx, 0, "reference points at tile 0");
1020        // then 768 bytes of all-zero residual
1021        assert!(raw_tiles[t1 + 5..].iter().all(|&b| b == 0));
1022    }
1023
1024    #[test]
1025    fn d8_transforms_roundtrip_lossless() {
1026        // Put T_{t0}(original) into the atlas as tile 0 and original as tile 1.
1027        // The encoder must find some exact inverse t with T_t(base) == original
1028        // (t labels depend on composition order; only exactness matters), emit
1029        // mode 2 with a zero residual, and metadata must roundtrip.
1030        for (w, h) in [
1031            (16u32, 16u32),
1032            (30u32, 30u32),
1033            (16u32, 24u32),
1034            (31u32, 33u32),
1035        ] {
1036            let mut s: u32 = 777;
1037            let mut rnd = || {
1038                s = s.wrapping_mul(1103515245).wrapping_add(12345);
1039                (s >> 16) as u8
1040            };
1041            let px: Vec<u8> = (0..w * h * 3).map(|_| rnd()).collect();
1042            for t0 in 0..NUM_TRANSFORMS {
1043                if !transform_is_valid_for(t0, w, h) {
1044                    continue;
1045                }
1046                let base = apply_transform(t0, w, h, &px);
1047                let base_tile = Tile {
1048                    data: base.clone(),
1049                    width: w,
1050                    height: h,
1051                };
1052                let target_tile = Tile {
1053                    data: px.clone(),
1054                    width: w,
1055                    height: h,
1056                };
1057                let raw = encode_tiles(&[base_tile, target_tile], true, true);
1058                let t1 = 1 + (w * h * 3) as usize;
1059                let mode = raw[t1];
1060                let emitted_t = match mode {
1061                    0 => panic!("tile 1 must reference: exact inverse exists (t0={t0}, {w}x{h})"),
1062                    1 => 0u8,
1063                    2 => raw[t1 + 5],
1064                    m => panic!("invalid mode {m}"),
1065                };
1066                assert!(
1067                    transform_is_valid_for(emitted_t, w, h),
1068                    "emitted transform {emitted_t} invalid for {w}x{h} (t0={t0})"
1069                );
1070                // encoder must have found an exact inverse: zero residual
1071                assert_eq!(
1072                    apply_transform(emitted_t, w, h, &base),
1073                    px,
1074                    "T_{emitted_t}(T_{t0}(px)) != px for {w}x{h}"
1075                );
1076            }
1077        }
1078    }
1079
1080    #[test]
1081    fn geom_reference_transform4_nonsquare_rejected() {
1082        // mode 2 with transform 4 on a 16x8 partial tile changes shape -> must error
1083        let d = Dims {
1084            width: 16,
1085            height: 24,
1086        };
1087        let mut raw = Vec::new();
1088        raw.push(0u8); // tile 0 literal 16x16
1089        raw.extend_from_slice(&[0u8; 16 * 16 * 3]);
1090        raw.push(2u8); // tile 1 (16x8): geometric reference
1091        raw.extend_from_slice(&0u32.to_le_bytes());
1092        raw.push(4u8); // invalid: swaps 16x8 -> 8x16
1093        raw.extend_from_slice(&[0u8; 16 * 8 * 3]);
1094        let payload = compress_payload(&raw);
1095        let mut b = build_header(d, 2, payload.len() as u32);
1096        b.extend_from_slice(&payload);
1097        let err = decode(&b).unwrap_err();
1098        assert!(
1099            err.to_string().contains("invalid transform"),
1100            "want transform rejection, got: {err}"
1101        );
1102    }
1103
1104    #[test]
1105    fn nonsquare_partial_tiles_use_shape_keeping_subset() {
1106        // every valid partial-tile transform must roundtrip through the codec
1107        let d = Dims {
1108            width: 31,
1109            height: 33,
1110        }; // has 15x1 and 15x16 partial tiles
1111        let mut s: u32 = 4242;
1112        let mut rnd = || {
1113            s = s.wrapping_mul(1103515245).wrapping_add(12345);
1114            (s >> 16) as u8
1115        };
1116        let px: Vec<u8> = (0..d.width as usize * d.height as usize * 3)
1117            .map(|_| rnd())
1118            .collect();
1119        assert_eq!(roundtrip(d, &px, true), px);
1120    }
1121
1122    #[test]
1123    fn no_atlas_uses_literals() {
1124        let d = Dims {
1125            width: 32,
1126            height: 16,
1127        };
1128        let tiles = split_tiles(&solid(32, 16, [9, 9, 9]), d);
1129        let raw = encode_tiles(&tiles, false, false);
1130        assert_eq!(raw[0], 0);
1131        assert_eq!(
1132            raw[1 + 16 * 16 * 3],
1133            0,
1134            "tile 1 literal when atlas disabled"
1135        );
1136    }
1137
1138    #[test]
1139    fn proxy_cost_monotone() {
1140        assert!(tile_proxy_cost(&[0u8; 16]) <= tile_proxy_cost(&[5u8; 32]));
1141    }
1142}