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