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/// One-call MNF5 encode (the CLI's pipeline exposed as API): split, encode
477/// the tile stream, compress, and assemble header + payload.
478///
479/// The other formats have one-call fns (`mnf6::encode_mnf7`,
480/// `mnf8::encode_mnf8`); this closes the same convenience gap for MNF5.
481pub fn encode_mnf5(pixels: &[u8], dims: Dims, use_atlas: bool, use_geom: bool) -> Result<Vec<u8>> {
482    let tiles = split_tiles(pixels, dims);
483    let (raw, _stats) = encode_stats(&tiles, use_atlas, use_geom);
484    let payload = compress_payload(&raw);
485    let mut buf = build_header(dims, tiles.len() as u32, payload.len() as u32);
486    buf.extend_from_slice(&payload);
487    Ok(buf)
488}
489
490/// Decode MNF5 with configurable ceilings.
491pub fn decode_with_limits(buf: &[u8], lim: &DecodeLimits) -> Result<DecodeResult> {
492    if buf.len() < HEADER_SIZE {
493        return Err(Error::fmt_at("truncated header", buf.len() as u64));
494    }
495    if &buf[0..4] != b"MNF5" {
496        return Err(Error::fmt_at("bad magic (want MNF5)", 0));
497    }
498    let (w, h) = read_dims(&buf[4..12])?;
499    if w as u64 * h as u64 > lim.max_pixels {
500        return Err(Error::Limits {
501            which: "max_pixels",
502            limit: lim.max_pixels,
503            actual: w as u64 * h as u64,
504        });
505    }
506    let ts = u32::from_le_bytes(buf[12..16].try_into().unwrap());
507    let tile_count = u32::from_le_bytes(buf[16..20].try_into().unwrap());
508    if ts != TILE_SIZE {
509        return Err(Error::fmt_msg(format!("unsupported tile size {ts}")));
510    }
511    if tile_count as u64 > lim.max_tiles as u64 {
512        return Err(Error::Limits {
513            which: "max_tiles",
514            limit: u64::from(lim.max_tiles),
515            actual: tile_count as u64,
516        });
517    }
518    let payload_len = u32::from_le_bytes(buf[20..24].try_into().unwrap()) as usize;
519    let (cols, rows) = tile_grid(Dims {
520        width: w,
521        height: h,
522    });
523    let expected = cols * rows;
524    if tile_count != expected {
525        return Err(Error::fmt_msg(format!(
526            "tile_count {tile_count} != expected {expected} for {w}x{h}"
527        )));
528    }
529    let _rows = rows;
530    if payload_len > MAX_PAYLOAD {
531        return Err(Error::fmt_msg("payload too large"));
532    }
533    if (buf.len() - HEADER_SIZE) < payload_len {
534        return Err(Error::fmt_msg("payload shorter than header-declared len"));
535    }
536    // Reject bytes beyond the declared payload before decompressing: a
537    // well-formed file is header + exactly payload_len bytes.
538    if buf.len() != HEADER_SIZE + payload_len {
539        return Err(Error::fmt_msg(format!(
540            "file is {} bytes but header declares {} + {payload_len} payload",
541            buf.len(),
542            HEADER_SIZE
543        )));
544    }
545    let payload = &buf[HEADER_SIZE..HEADER_SIZE + payload_len];
546
547    // Compression bound (checked math, review fix 1): raw stream <=
548    // 3WH pixel bytes total + per-tile metadata worst case
549    // (1 mode + 4 idx + 1 transform = 6). Reject during decompression
550    // via take(limit+1), not after read_to_end.
551    let px_bytes_total = (w as u64) * (h as u64) * 3;
552    let decomp_limit = px_bytes_total
553        .checked_add(6 * tile_count as u64)
554        .ok_or_else(|| Error::fmt_msg("decompressed size overflow"))?;
555    let mut dz = ZlibDecoder::new(payload).take(decomp_limit + 1);
556    let mut raw = Vec::new();
557    dz.read_to_end(&mut raw)
558        .map_err(|_| Error::Decompress("corrupt zlib stream"))?;
559    if raw.len() as u64 > decomp_limit {
560        return Err(Error::Limits {
561            which: "max_decompressed",
562            limit: decomp_limit,
563            actual: raw.len() as u64,
564        });
565    }
566
567    // Premature-allocation gate (review fix 2): the stream must hold at
568    // least 4 bytes per tile (mode byte + minimum ref metadata) before
569    // any image-sized allocation happens.
570    if raw.len() < 4 * tile_count as usize {
571        return Err(Error::fmt_msg(
572            "decompressed stream shorter than per-tile minimum",
573        ));
574    }
575
576    let total_px = w as usize * h as usize * 3;
577    // decoder memory = pixels buffer + atlas patches (a second full copy
578    // once all tiles are decoded). Enforce the ceiling before allocating.
579    let mem_needed = (total_px as u64) * 2;
580    if mem_needed > lim.max_total_memory {
581        return Err(Error::Limits {
582            which: "max_total_memory",
583            limit: lim.max_total_memory,
584            actual: mem_needed,
585        });
586    }
587    let mut out = vec![0u8; total_px];
588    // no capacity reservation: patches grows as actually decoded
589    let mut patches: Vec<(u32, u32, Vec<u8>)> = Vec::new();
590    let mut cursor = 0usize;
591
592    for t in 0..tile_count {
593        let (tw, th) = tile_dims(
594            Dims {
595                width: w,
596                height: h,
597            },
598            t,
599        );
600        let n = (tw * th * 3) as usize;
601        if cursor + 1 > raw.len() {
602            return Err(Error::fmt_at(
603                format!("tile {t}: truncated mode byte"),
604                u64::from(t),
605            ));
606        }
607        let mode = raw[cursor];
608        cursor += 1;
609        let data: Vec<u8> = match mode {
610            0 => {
611                if cursor + n > raw.len() {
612                    return Err(Error::fmt_at(
613                        format!("tile {t}: truncated literal payload"),
614                        u64::from(t),
615                    ));
616                }
617                let d = raw[cursor..cursor + n].to_vec();
618                cursor += n;
619                d
620            }
621            1 => {
622                if cursor + 4 > raw.len() {
623                    return Err(Error::fmt_at(
624                        format!("tile {t}: truncated reference index"),
625                        u64::from(t),
626                    ));
627                }
628                let idx = u32::from_le_bytes(raw[cursor..cursor + 4].try_into().unwrap());
629                cursor += 4;
630                let (rw, rh, ref_rgb) = patches.get(idx as usize).ok_or_else(|| {
631                    Error::fmt_msg(format!(
632                        "tile {t}: reference to tile {idx} >= {len} decoded",
633                        len = patches.len(),
634                        t = t,
635                        idx = idx
636                    ))
637                })?;
638                if *rw != tw || *rh != th {
639                    return Err(Error::fmt_at(
640                        format!(
641                            "tile {t}: patch {idx} shaped {rw}x{rh}, need {tw}x{th}",
642                            t = t,
643                            idx = idx,
644                            rw = rw,
645                            rh = rh,
646                            tw = tw,
647                            th = th
648                        ),
649                        u64::from(t),
650                    ));
651                }
652                if cursor + n > raw.len() {
653                    return Err(Error::fmt_at(
654                        format!("tile {t}: truncated residual"),
655                        u64::from(t),
656                    ));
657                }
658                let residual = &raw[cursor..cursor + n];
659                let mut d = vec![0u8; n];
660                for i in 0..n {
661                    d[i] = ref_rgb[i].wrapping_add(residual[i]);
662                }
663                cursor += n;
664                d
665            }
666            2 => {
667                if cursor + 5 > raw.len() {
668                    return Err(Error::fmt_at(
669                        format!("tile {t}: truncated geometric reference"),
670                        u64::from(t),
671                    ));
672                }
673                let idx = u32::from_le_bytes(raw[cursor..cursor + 4].try_into().unwrap());
674                let tr = raw[cursor + 4];
675                cursor += 5;
676                if !transform_is_valid_for(tr, tw, th) {
677                    return Err(Error::fmt_at(
678                        format!(
679                            "tile {t}: invalid transform {tr} for {tw}x{th} tile",
680                            t = t,
681                            tr = tr,
682                            tw = tw,
683                            th = th
684                        ),
685                        u64::from(t),
686                    ));
687                }
688                let (rw, rh, ref_rgb) = patches.get(idx as usize).ok_or_else(|| {
689                    Error::fmt_msg(format!(
690                        "tile {t}: reference to tile {idx} >= {len} decoded",
691                        len = patches.len(),
692                        t = t,
693                        idx = idx
694                    ))
695                })?;
696                if *rw != tw || *rh != th {
697                    return Err(Error::fmt_at(
698                        format!(
699                            "tile {t}: patch {idx} shaped {rw}x{rh}, need {tw}x{th}",
700                            t = t,
701                            idx = idx,
702                            rw = rw,
703                            rh = rh,
704                            tw = tw,
705                            th = th
706                        ),
707                        u64::from(t),
708                    ));
709                }
710                if cursor + n > raw.len() {
711                    return Err(Error::fmt_at(
712                        format!("tile {t}: truncated residual"),
713                        u64::from(t),
714                    ));
715                }
716                let residual = &raw[cursor..cursor + n];
717                let transformed = if tr == 0 {
718                    ref_rgb.clone()
719                } else {
720                    apply_transform(tr, tw, th, ref_rgb)
721                };
722                let mut d = vec![0u8; n];
723                for i in 0..n {
724                    d[i] = transformed[i].wrapping_add(residual[i]);
725                }
726                cursor += n;
727                d
728            }
729            m => {
730                return Err(Error::fmt_at(
731                    format!("tile {t}: invalid mode byte {m}"),
732                    u64::from(t),
733                ))
734            }
735        };
736        blit_tile(
737            &mut out,
738            Dims {
739                width: w,
740                height: h,
741            },
742            t,
743            &data,
744            tw,
745            th,
746        );
747        patches.push((tw, th, data));
748    }
749    if cursor != raw.len() {
750        return Err(Error::fmt_msg(format!(
751            "trailing bytes after last tile: {cursor} != {}",
752            raw.len()
753        )));
754    }
755    Ok(DecodeResult {
756        dims: Dims {
757            width: w,
758            height: h,
759        },
760        pixels: out,
761    })
762}
763
764const MAX_PAYLOAD: usize = 1 << 31; // 2 GiB hard stop for untrusted input
765
766fn read_dims(b: &[u8]) -> Result<(u32, u32)> {
767    let w = u32::from_le_bytes(b[0..4].try_into().unwrap());
768    let h = u32::from_le_bytes(b[4..8].try_into().unwrap());
769    if w == 0 || h == 0 {
770        return Err(Error::fmt_msg("zero dimensions"));
771    }
772    // guard oversized allocations: total pixels capped at 2^26 (~192 MiB RGB;
773    // the atlas keeps a second copy of every patch, so budget ~2x while decoding)
774    if (w as u64) * (h as u64) > (1 << 26) {
775        return Err(Error::fmt_msg("image too large"));
776    }
777    Ok((w, h))
778}
779
780/// Split an RGB8 pixel buffer into row-major Tiles (public: CLI uses the same
781/// tiling as encoder/decoder/tests — one definition of truth).
782pub fn split_tiles(pixels: &[u8], d: Dims) -> Vec<Tile> {
783    let (cols, rows) = tile_grid(d);
784    (0..cols * rows)
785        .map(|t| {
786            let (tw, th) = tile_dims(d, t);
787            let mut data = vec![0u8; (tw * th * 3) as usize];
788            let tx = (t % cols) * TILE_SIZE;
789            let ty = (t / cols) * TILE_SIZE;
790            for row in 0..th as usize {
791                for col in 0..tw as usize {
792                    let py = ty as usize + row;
793                    let px = tx as usize + col;
794                    let dst = (row * tw as usize + col) * 3;
795                    let src = (py * d.width as usize + px) * 3;
796                    data[dst..dst + 3].copy_from_slice(&pixels[src..src + 3]);
797                }
798            }
799            Tile {
800                data,
801                width: tw,
802                height: th,
803            }
804        })
805        .collect()
806}
807
808fn blit_tile(out: &mut [u8], dims: Dims, t: u32, data: &[u8], tw: u32, th: u32) {
809    let (cols, _) = tile_grid(dims);
810    let tx = (t % cols) * TILE_SIZE;
811    let ty = (t / cols) * TILE_SIZE;
812    for row in 0..th as usize {
813        let py = ty as usize + row;
814        for col in 0..tw as usize {
815            let px = tx as usize + col;
816            let dst = (py * dims.width as usize + px) * 3;
817            let src = (row * tw as usize + col) * 3;
818            out[dst..dst + 3].copy_from_slice(&data[src..src + 3]);
819        }
820    }
821}
822
823#[cfg(test)]
824mod tests {
825    use super::*;
826
827    fn dims(w: u32, h: u32) -> Dims {
828        Dims {
829            width: w,
830            height: h,
831        }
832    }
833
834    fn roundtrip(d: Dims, pixels: &[u8], use_atlas: bool) -> Vec<u8> {
835        let tiles = split_tiles(pixels, d);
836        let raw = encode_tiles(&tiles, use_atlas, true);
837        let payload = compress_payload(&raw);
838        let header = build_header(d, tiles.len() as u32, payload.len() as u32);
839        let mut buf = header;
840        buf.extend_from_slice(&payload);
841        let res = decode(&buf).expect("decode ok");
842        assert_eq!(res.dims, d);
843        res.pixels
844    }
845
846    fn solid(w: u32, h: u32, c: [u8; 3]) -> Vec<u8> {
847        (0..w * h).flat_map(|_| c).collect()
848    }
849
850    fn gradient(w: u32, h: u32) -> Vec<u8> {
851        (0..w * h)
852            .flat_map(|i| {
853                [
854                    (i % 256) as u8,
855                    ((i / w) % 256) as u8,
856                    ((i / 7) % 256) as u8,
857                ]
858            })
859            .collect()
860    }
861
862    #[test]
863    fn encode_mnf5_one_call_roundtrip() {
864        let (w, h) = (48u32, 36u32); // 3x3 tiles incl. partial edges
865        let mut px = vec![0u8; (w * h * 3) as usize];
866        for (i, b) in px.iter_mut().enumerate() {
867            *b = (i % 251) as u8;
868        }
869        let d = Dims {
870            width: w,
871            height: h,
872        };
873        let buf = encode_mnf5(&px, d, true, true).expect("encode");
874        let res = decode(&buf).expect("decode");
875        assert_eq!(res.pixels, px, "one-call MNF5 exact");
876        assert_eq!((res.dims.width, res.dims.height), (w, h));
877    }
878
879    #[test]
880    fn grid_math() {
881        assert_eq!(tile_grid(dims(16, 16)), (1, 1));
882        assert_eq!(tile_grid(dims(17, 16)), (2, 1));
883        assert_eq!(tile_grid(dims(31, 33)), (2, 3));
884        assert_eq!(tile_dims(dims(31, 33), 1), (15, 16));
885        assert_eq!(tile_dims(dims(31, 33), 2), (16, 16));
886        assert_eq!(tile_dims(dims(31, 33), 5), (15, 1));
887        assert_eq!(div_ceil(0, 16), 0); // 0-sized grid ok for tests only
888    }
889
890    #[test]
891    fn roundtrip_solid_16x16_atlas() {
892        let px = solid(16, 16, [10, 20, 30]);
893        assert_eq!(roundtrip(dims(16, 16), &px, true), px);
894    }
895
896    #[test]
897    fn roundtrip_solid_16x16_no_atlas() {
898        let px = solid(16, 16, [200, 100, 50]);
899        assert_eq!(roundtrip(dims(16, 16), &px, false), px);
900    }
901
902    #[test]
903    fn roundtrip_odd_dims() {
904        for (w, h) in [
905            (31u32, 33u32),
906            (17u32, 1u32),
907            (1u32, 17u32),
908            (1u32, 1u32),
909            (48u32, 47u32),
910        ] {
911            let px = gradient(w, h);
912            let dec = roundtrip(dims(w, h), &px, true);
913            assert_eq!(dec.len(), (w * h * 3) as usize, "len {w}x{h}");
914            assert_eq!(dec, px, "pixels {w}x{h}");
915        }
916    }
917
918    #[test]
919    fn roundtrip_random_pixels() {
920        // deterministic pseudo-random
921        let mut s: u32 = 12345;
922        let mut rnd = || {
923            s = s.wrapping_mul(1103515245).wrapping_add(12345);
924            (s >> 16) as u8
925        };
926        let w = 64;
927        let h = 48;
928        let px: Vec<u8> = (0..w * h * 3).map(|_| rnd()).collect();
929        assert_eq!(roundtrip(dims(w, h), &px, true), px);
930    }
931
932    #[test]
933    fn roundtrip_uniform_atlas_matches_noatlas() {
934        let px = solid(64, 64, [7, 7, 7]);
935        assert_eq!(roundtrip(dims(64, 64), &px, true), px);
936        assert_eq!(roundtrip(dims(64, 64), &px, false), px);
937    }
938
939    #[test]
940    fn decode_rejects_bad_inputs() {
941        assert!(decode(&[]).is_err(), "empty");
942        assert!(decode(&[0u8; 23]).is_err(), "short header");
943        let mut bad_magic = build_header(dims(16, 16), 1, 0);
944        bad_magic[0] = b'X';
945        assert!(decode(&bad_magic).is_err(), "bad magic");
946        let bad_tile_size = {
947            let mut h = build_header(dims(16, 16), 1, 0);
948            h[12] = 15; // tile_size != 16
949            h
950        };
951        assert!(decode(&bad_tile_size).is_err(), "tile size");
952        let bad_count = build_header(dims(16, 16), 5, 0);
953        assert!(decode(&bad_count).is_err(), "tile count");
954        assert!(
955            decode(&build_header(dims(0, 16), 1, 0)).is_err(),
956            "zero dim"
957        );
958        assert!(
959            read_dims(&build_header(dims(0, 16), 1, 0)[4..12]).is_err(),
960            "read_dims rejects zero width"
961        );
962        // truncated payload: valid header, valid zlib, missing trailing tile data
963        let d = dims(32, 16);
964        let tiles = split_tiles(&solid(32, 16, [1, 2, 3]), d);
965        let mut raw = encode_tiles(&tiles, true, false);
966        raw.truncate(raw.len() - 5); // chop half of second tile's data
967        let truncated = {
968            let payload = compress_payload(&raw);
969            let mut b = build_header(d, 2, payload.len() as u32);
970            b.extend_from_slice(&payload);
971            b
972        };
973        assert!(decode(&truncated).is_err(), "truncated tile data");
974        // dangling reference: first tile points at tile 3 which doesn't exist
975        let mut raw_ref = vec![1u8];
976        raw_ref.extend_from_slice(&3u32.to_le_bytes());
977        raw_ref.extend_from_slice(&[0u8; 16 * 16 * 3]);
978        let dang = {
979            let payload = compress_payload(&raw_ref);
980            let mut b = build_header(dims(16, 16), 1, payload.len() as u32);
981            b.extend_from_slice(&payload);
982            b
983        };
984        assert!(decode(&dang).is_err(), "dangling reference");
985    }
986
987    #[test]
988    fn decoder_rejects_oversized_dims() {
989        let h = build_header(
990            Dims {
991                width: 1 << 24,
992                height: 1 << 24,
993            },
994            0,
995            0,
996        );
997        assert!(decode(&h).is_err(), "oversized dims");
998    }
999
1000    #[test]
1001    fn atlas_reference_shape_mismatch_rejected() {
1002        // tile 0 is 16x16, tile 1 is 16x8; mode 1 reference from tile 1 to tile 0
1003        let d = Dims {
1004            width: 16,
1005            height: 24,
1006        };
1007        let mut raw = Vec::new();
1008        raw.push(0u8); // tile 0 literal
1009        raw.extend_from_slice(&[0u8; 16 * 16 * 3]);
1010        raw.push(1u8); // tile 1 reference
1011        raw.extend_from_slice(&0u32.to_le_bytes());
1012        raw.extend_from_slice(&[0u8; 16 * 8 * 3]);
1013        let payload = compress_payload(&raw);
1014        let mut b = build_header(d, 2, payload.len() as u32);
1015        b.extend_from_slice(&payload);
1016        let err = decode(&b).unwrap_err();
1017        assert!(
1018            err.to_string().contains("shaped"),
1019            "want shape mismatch, got: {err}"
1020        );
1021    }
1022
1023    #[test]
1024    fn encoder_picks_reference_when_residual_compresses() {
1025        // two identical NOISY tiles: literal costs ~768 bytes, the exact-match
1026        // residual is all zeros — the atlas must win. (Constant tiles don't
1027        // exercise this: deflate crushes a constant literal as cheaply as a
1028        // zero residual, so the honest cost model prefers the literal.)
1029        let mut s: u32 = 999;
1030        let mut rnd = || {
1031            s = s.wrapping_mul(1103515245).wrapping_add(12345);
1032            (s >> 16) as u8
1033        };
1034        let noisy: Vec<u8> = (0..32 * 16 * 3).map(|_| rnd()).collect();
1035        let tiles = vec![
1036            Tile {
1037                data: noisy.clone(),
1038                width: 32,
1039                height: 16,
1040            },
1041            Tile {
1042                data: noisy.clone(),
1043                width: 32,
1044                height: 16,
1045            },
1046        ];
1047        let raw_tiles = encode_tiles(&tiles, true, false);
1048        let t1 = 1 + 32 * 16 * 3; // second tile's metadata starts after tile 0's data
1049        assert_eq!(raw_tiles[0], 0, "tile 0 literal");
1050        assert_eq!(
1051            raw_tiles[t1], 1,
1052            "tile 1 should be an exact reference (mode 1)"
1053        );
1054        let idx = u32::from_le_bytes(raw_tiles[t1 + 1..t1 + 5].try_into().unwrap());
1055        assert_eq!(idx, 0, "reference points at tile 0");
1056        // then 768 bytes of all-zero residual
1057        assert!(raw_tiles[t1 + 5..].iter().all(|&b| b == 0));
1058    }
1059
1060    #[test]
1061    fn d8_transforms_roundtrip_lossless() {
1062        // Put T_{t0}(original) into the atlas as tile 0 and original as tile 1.
1063        // The encoder must find some exact inverse t with T_t(base) == original
1064        // (t labels depend on composition order; only exactness matters), emit
1065        // mode 2 with a zero residual, and metadata must roundtrip.
1066        for (w, h) in [
1067            (16u32, 16u32),
1068            (30u32, 30u32),
1069            (16u32, 24u32),
1070            (31u32, 33u32),
1071        ] {
1072            let mut s: u32 = 777;
1073            let mut rnd = || {
1074                s = s.wrapping_mul(1103515245).wrapping_add(12345);
1075                (s >> 16) as u8
1076            };
1077            let px: Vec<u8> = (0..w * h * 3).map(|_| rnd()).collect();
1078            for t0 in 0..NUM_TRANSFORMS {
1079                if !transform_is_valid_for(t0, w, h) {
1080                    continue;
1081                }
1082                let base = apply_transform(t0, w, h, &px);
1083                let base_tile = Tile {
1084                    data: base.clone(),
1085                    width: w,
1086                    height: h,
1087                };
1088                let target_tile = Tile {
1089                    data: px.clone(),
1090                    width: w,
1091                    height: h,
1092                };
1093                let raw = encode_tiles(&[base_tile, target_tile], true, true);
1094                let t1 = 1 + (w * h * 3) as usize;
1095                let mode = raw[t1];
1096                let emitted_t = match mode {
1097                    0 => panic!("tile 1 must reference: exact inverse exists (t0={t0}, {w}x{h})"),
1098                    1 => 0u8,
1099                    2 => raw[t1 + 5],
1100                    m => panic!("invalid mode {m}"),
1101                };
1102                assert!(
1103                    transform_is_valid_for(emitted_t, w, h),
1104                    "emitted transform {emitted_t} invalid for {w}x{h} (t0={t0})"
1105                );
1106                // encoder must have found an exact inverse: zero residual
1107                assert_eq!(
1108                    apply_transform(emitted_t, w, h, &base),
1109                    px,
1110                    "T_{emitted_t}(T_{t0}(px)) != px for {w}x{h}"
1111                );
1112            }
1113        }
1114    }
1115
1116    #[test]
1117    fn geom_reference_transform4_nonsquare_rejected() {
1118        // mode 2 with transform 4 on a 16x8 partial tile changes shape -> must error
1119        let d = Dims {
1120            width: 16,
1121            height: 24,
1122        };
1123        let mut raw = Vec::new();
1124        raw.push(0u8); // tile 0 literal 16x16
1125        raw.extend_from_slice(&[0u8; 16 * 16 * 3]);
1126        raw.push(2u8); // tile 1 (16x8): geometric reference
1127        raw.extend_from_slice(&0u32.to_le_bytes());
1128        raw.push(4u8); // invalid: swaps 16x8 -> 8x16
1129        raw.extend_from_slice(&[0u8; 16 * 8 * 3]);
1130        let payload = compress_payload(&raw);
1131        let mut b = build_header(d, 2, payload.len() as u32);
1132        b.extend_from_slice(&payload);
1133        let err = decode(&b).unwrap_err();
1134        assert!(
1135            err.to_string().contains("invalid transform"),
1136            "want transform rejection, got: {err}"
1137        );
1138    }
1139
1140    #[test]
1141    fn nonsquare_partial_tiles_use_shape_keeping_subset() {
1142        // every valid partial-tile transform must roundtrip through the codec
1143        let d = Dims {
1144            width: 31,
1145            height: 33,
1146        }; // has 15x1 and 15x16 partial tiles
1147        let mut s: u32 = 4242;
1148        let mut rnd = || {
1149            s = s.wrapping_mul(1103515245).wrapping_add(12345);
1150            (s >> 16) as u8
1151        };
1152        let px: Vec<u8> = (0..d.width as usize * d.height as usize * 3)
1153            .map(|_| rnd())
1154            .collect();
1155        assert_eq!(roundtrip(d, &px, true), px);
1156    }
1157
1158    #[test]
1159    fn no_atlas_uses_literals() {
1160        let d = Dims {
1161            width: 32,
1162            height: 16,
1163        };
1164        let tiles = split_tiles(&solid(32, 16, [9, 9, 9]), d);
1165        let raw = encode_tiles(&tiles, false, false);
1166        assert_eq!(raw[0], 0);
1167        assert_eq!(
1168            raw[1 + 16 * 16 * 3],
1169            0,
1170            "tile 1 literal when atlas disabled"
1171        );
1172    }
1173
1174    #[test]
1175    fn proxy_cost_monotone() {
1176        assert!(tile_proxy_cost(&[0u8; 16]) <= tile_proxy_cost(&[5u8; 32]));
1177    }
1178}