collective

Browse / github.com/storytold/photocraft / patch

codecs: charge deep OpenEXR per-pixel bookkeeping against max_alloc

Deep OpenEXR decode ignores max_alloc for its per-pixel bookkeeping (24 bytes a pixel) **Version:** PhotoCraft 0.16.0 (b1794c6e0, 2026-10-25), Windows 11 **Component:** `photocraft-codecs`, deep OpenEXR (`deepscanline` / `deeptile`): `crates/codecs/src/codecs/deep_exr.rs` (`decode_deep`), reached from `decode_deep_exr` and from `decode` / `decode_with` (which flattens deep files). ## Problem `decode_deep` checks the data window against `max_alloc` at four bytes a pixel per channel, then builds bookkeeping that is never charged against the budget: - a `u32` sample count per pixel (4 B/px), - per chunk, a `Vec<usize>` of the block's global pixel indices (8 B/px) and a `Vec<u32

Status
open
Score
0.00
Adopters
0 other operators
Author
Higgs32584 (imported from pull request #9786, open)
Base
52f338e2df44ac73c0bf231fa8d3f8cd496641ec
Size
+146 −104 in 2 files
Review
pending
Shared
2026-10-28
Id
d0d0381b4db59539031d97a1

How it went

No other operator has reported using it yet.

Diff

raw · crates/codecs/src/codecs/deep_exr.rs crates/codecs/tests/it/deep_exr.rs

diff --git a/crates/codecs/src/codecs/deep_exr.rs b/crates/codecs/src/codecs/deep_exr.rs--- a/crates/codecs/src/codecs/deep_exr.rs+++ b/crates/codecs/src/codecs/deep_exr.rs@@ -219,16 +219,14 @@ fn decompress(compression: Compression, packed: &[u8], expected: usize, what: &s  // --- the deep chunk stream --------------------------------------------------------------------- -/// One parsed chunk: where its pixels go and its cumulative sample counts, plus where to-/// find the compressed sample data in the second pass.+/// One parsed chunk: the block it covers, plus where to find the compressed sample data in+/// the second pass. Its pixels' sample counts live in the decode's global index, so a chunk+/// holds no per-pixel data. struct DeepChunk {-    /// Global pixel indices of the block's pixels, in block order (row-major within the block).-    pixels: Vec<usize>,-    /// Pixels per row of the block (the data window width for a scan line, the clipped tile-    /// width for a tile).-    row_w: usize,-    /// Cumulative sample count table of the block: `len == pixels.len() + 1`.-    prefix: Vec<u32>,+    /// The block's rows within the data window.+    rows: std::ops::Range<usize>,+    /// The block's columns within the data window (clipped to it for an edge tile).+    cols: std::ops::Range<usize>,     /// File offset of the chunk start.     pos: u64,     /// Size of the compressed sample data in the file.@@ -244,6 +242,15 @@ fn sample_bytes(t: ExrSample) -> usize {     } } +/// One stored sample as f32. `b` holds [`sample_bytes`] bytes; a short slice reads as zero.+fn sample_value(t: ExrSample, b: &[u8]) -> f32 {+    match t {+        ExrSample::F16 => b.try_into().map(|a: [u8; 2]| f16::from_le_bytes(a).to_f32()).unwrap_or(0.0),+        ExrSample::F32 => b.try_into().map(f32::from_le_bytes).unwrap_or(0.0),+        ExrSample::U32 => b.try_into().map(|a: [u8; 4]| u32::from_le_bytes(a) as f32).unwrap_or(0.0),+    }+}+ fn our_sample_type(t: ExrSample) -> SampleType {     match t {         ExrSample::F16 => SampleType::F16,@@ -272,6 +279,19 @@ pub(crate) fn decode_deep(meta: &MetaData, bytes: &[u8], limits: &Limits) -> Res     let y0 = dw.position.1;     let npixels = w.checked_mul(h).ok_or_else(|| err("the data window is too large"))?;     limits.check_bytes(w as u32, h as u32, (header.channels.list.len().max(1) as u64) * 4)?;+    // The per-pixel bookkeeping is the cumulative sample-count index the result returns+    // (`DeepImage::counts`: one u64 per pixel, plus one), live from the first chunk to the end.+    // Charge it before anything is allocated; the count tables and the samples are admitted+    // against what it leaves. The data-window check above budgets only four bytes a pixel.+    let index_len = npixels.checked_add(1).ok_or_else(|| err("the data window is too large"))?;+    let index_bytes = (index_len as u64).checked_mul(8).ok_or_else(|| err("the data window is too large"))?;+    if index_bytes > limits.max_alloc {+        return Err(CodecError::LimitExceeded(format!(+            "the sample-count index of {w}x{h} deep pixels needs {index_bytes} bytes, exceeding max_alloc {}",+            limits.max_alloc+        )));+    }+    let budget = limits.max_alloc - index_bytes;      // The display window is the full stage the renderer composed onto (a render pass's data     // window is often its crop). Open on the stage when it plausibly holds the data region:@@ -326,7 +346,14 @@ pub(crate) fn decode_deep(meta: &MetaData, bytes: &[u8], limits: &Limits) -> Res         }     }; -    let mut counts = vec![0u32; npixels];+    // Each block has exactly one chunk (a duplicate or a missing one is malformed), so a table+    // of any other length is refused here, before the per-block bookkeeping is sized by the+    // header's geometry: `seen` and `chunks` stay proportional to the offset table in the file.+    if offsets.len() != slots {+        return Err(err(format!("the offset table lists {} chunks, the data window needs {slots}", offsets.len())));+    }+    // Per-pixel sample counts in `counts[p + 1]`, made cumulative after the first pass.+    let mut counts = alloc_zeroed::<u64>(index_len)?;     let mut seen = vec![false; slots];     let mut chunks: Vec<DeepChunk> = Vec::with_capacity(offsets.len()); @@ -341,17 +368,15 @@ pub(crate) fn decode_deep(meta: &MetaData, bytes: &[u8], limits: &Limits) -> Res                 return Err(err(format!("chunk declares part {part}, expected {header_index}")));             }         }-        // Block coordinates → the block's pixel bounds and its slot. The per-pixel index is-        // built only after the chunk's declared payload sizes are validated below, so a-        // truncated or lying chunk cannot drive a large index allocation first.-        let (block_rows, block_cols, slot, row_w, scanline_row): (std::ops::Range<usize>, std::ops::Range<usize>, usize, usize, bool) = match &header.blocks {+        // Block coordinates → the block's pixel bounds and its slot.+        let (block_rows, block_cols, slot): (std::ops::Range<usize>, std::ops::Range<usize>, usize) = match &header.blocks {             BlockDescription::ScanLines => {                 let y = i32(&mut cur)?;                 let row = match y.checked_sub(y0) {                     Some(d) if d >= 0 && (d as usize) < h => d as usize,                     _ => return Err(err(format!("scan line block y={y} is outside the data window"))),                 };-                (row..row + 1, 0..w, row, w, true)+                (row..row + 1, 0..w, row)             }             BlockDescription::Tiles(_) => {                 let (tx, ty, lx, ly) = (i32(&mut cur)?, i32(&mut cur)?, i32(&mut cur)?, i32(&mut cur)?);@@ -365,7 +390,7 @@ pub(crate) fn decode_deep(meta: &MetaData, bytes: &[u8], limits: &Limits) -> Res                 let rows = (by + tile_h).min(h);                 let cols = (bx + tile_w).min(w);                 let slot = (ty as usize).saturating_mul(tiles_x) + tx as usize;-                (by..rows, bx..cols, slot, cols - bx, false)+                (by..rows, bx..cols, slot)             }         };         let block_len = block_rows.len().saturating_mul(block_cols.len());@@ -383,31 +408,18 @@ pub(crate) fn decode_deep(meta: &MetaData, bytes: &[u8], limits: &Limits) -> Res         if packed_table > remaining || packed_data > remaining.saturating_sub(packed_table) {             return Err(err("deep chunk sizes point past the end of the file"));         }-        // The chunk's payload is present; now materialize the block's global pixel index. For-        // scan lines this is one row; for tiles the clipped tile rectangle.-        let block_pixels: Vec<usize> = if scanline_row {-            let row = block_rows.start;-            (0..w).map(|x| row * w + x).collect()-        } else {-            let mut px = Vec::with_capacity(block_len);-            for yy in block_rows.clone() {-                for xx in block_cols.clone() {-                    px.push(yy.saturating_mul(w).saturating_add(xx));-                }-            }-            px-        };         let what = "the sample-count table";         let table_bytes = read_exact(&mut cur, packed_table as usize, what)?;-        let expected_table = block_pixels.len().checked_mul(4).ok_or_else(|| err("count table overflows"))?;+        let expected_table = block_len.checked_mul(4).ok_or_else(|| err("count table overflows"))?;         // A compressed clipped table also needs its scratch copy. The data-window check only         // budgets four bytes per channel per pixel, insufficient for a one-channel image.         // An equal-sized payload takes decompress's raw path, which allocates no scratch.+        // Both are admitted against what the sample-count index leaves of `max_alloc`.         let buffers = if table_bytes.len() == expected_table { 1 } else { 2 };         let clipped_need = (expected_table as u64).saturating_mul(buffers);-        if clipped_need > limits.max_alloc {+        if clipped_need > budget {             return Err(CodecError::LimitExceeded(format!(-                "{what} needs {clipped_need} bytes including decompression scratch, exceeding max_alloc {}",+                "{what} needs {clipped_need} bytes including decompression scratch, on top of the {index_bytes} byte sample-count index, exceeding max_alloc {}",                 limits.max_alloc             )));         }@@ -420,75 +432,73 @@ pub(crate) fn decode_deep(meta: &MetaData, bytes: &[u8], limits: &Limits) -> Res         let raw = match full_table {             // Scan lines and interior tiles: the full size is the clipped size.             Some(full) if full <= expected_table => decompress(compression, table_bytes, expected_table, what)?,-            Some(full) if (full as u64).saturating_mul(2) <= limits.max_alloc => {+            Some(full) if (full as u64).saturating_mul(2) <= budget => {                 decompress(compression, table_bytes, full, what).or_else(|_| decompress(compression, table_bytes, expected_table, what))?             }             _ => decompress(compression, table_bytes, expected_table, what).map_err(|_| {                 // Four bytes per pixel of the tile, twice: the table and its scratch copy.                 let need = (tile_w as u64).saturating_mul(tile_h as u64).saturating_mul(8);                 CodecError::LimitExceeded(format!(-                    "{what} of a {tile_w}x{tile_h} deep tile does not decode at the clipped size, and at the full tile size it needs {need} bytes with its scratch copy, exceeding max_alloc {}",+                    "{what} of a {tile_w}x{tile_h} deep tile does not decode at the clipped size, and at the full tile size it needs {need} bytes with its scratch copy, on top of the {index_bytes} byte sample-count index, exceeding max_alloc {}",                     limits.max_alloc                 ))             })?,         };          // The table is cumulative within each row of the block (a tile restarts at every row);-        // `prefix` is cumulative over the whole block, and each pixel's own count is the difference.-        let mut prefix = Vec::with_capacity(block_pixels.len() + 1);-        prefix.push(0u32);-        let (mut row_base, mut prev) = (0u32, 0u32);-        for (i, p) in block_pixels.iter().enumerate() {-            let b: [u8; 4] = raw.get(i * 4..i * 4 + 4).and_then(|s| s.try_into().ok()).ok_or_else(|| err("the count table is short"))?;-            let v = u32::from_le_bytes(b);-            if row_w > 0 && i % row_w == 0 {-                row_base = row_base.checked_add(prev).ok_or_else(|| err("the block's sample count overflows"))?;-                prev = 0;-            }-            if v < prev {-                return Err(err("the sample-count table decreases (malformed cumulative counts)"));-            }-            if let Some(c) = counts.get_mut(*p) {-                *c = v - prev;+        // each pixel's own count is the difference to its left neighbour's entry.+        let mut entries = raw.as_chunks::<4>().0.iter();+        let mut total = 0u64;+        for yy in block_rows.clone() {+            let mut prev = 0u32;+            for xx in block_cols.clone() {+                let v = u32::from_le_bytes(*entries.next().ok_or_else(|| err("the count table is short"))?);+                if v < prev {+                    return Err(err("the sample-count table decreases (malformed cumulative counts)"));+                }+                let p = yy.saturating_mul(w).saturating_add(xx).saturating_add(1);+                if let Some(c) = counts.get_mut(p) {+                    *c = u64::from(v - prev);+                }+                prev = v;             }-            prev = v;-            prefix.push(row_base.checked_add(v).ok_or_else(|| err("the block's sample count overflows"))?);+            total = total.checked_add(u64::from(prev)).ok_or_else(|| err("the block's sample count overflows"))?;         }-        let total = prefix.last().copied().unwrap_or(0) as u64;         let expected_data = total.checked_mul(bytes_per_sample as u64).ok_or_else(|| err("the sample data size overflows"))?;         if expected_data != unpacked_data {             return Err(err(format!("the chunk declares {unpacked_data} bytes of sample data, the count table implies {expected_data}")));         }-        chunks.push(DeepChunk { pixels: block_pixels, row_w, prefix, pos: off, packed_data, unpacked_data });+        chunks.push(DeepChunk { rows: block_rows, cols: block_cols, pos: off, packed_data, unpacked_data });     }     if seen.iter().any(|s| !s) {         let missing = seen.iter().filter(|s| !**s).count();         return Err(err(format!("{missing} of {slots} chunks are missing from the offset table")));     } -    // Global cumulative counts in raster order.-    let mut deep_counts = Vec::with_capacity(npixels + 1);-    deep_counts.push(0u64);+    // Global cumulative counts in raster order, in place: pixel `p` holds the samples+    // `counts[p]..counts[p + 1]`.     let mut acc = 0u64;-    for &c in &counts {-        acc = acc.checked_add(c as u64).ok_or_else(|| err("the total sample count overflows"))?;-        deep_counts.push(acc);+    for c in counts.iter_mut() {+        acc = acc.checked_add(*c).ok_or_else(|| err("the total sample count overflows"))?;+        *c = acc;     }     let total_samples = acc;     let sample_bytes_total = total_samples.checked_mul(exr_types.len() as u64 * 4).ok_or_else(|| err("the sample buffer size overflows"))?;-    if sample_bytes_total > limits.max_alloc {-        return Err(CodecError::LimitExceeded(format!("deep samples need {sample_bytes_total} bytes, exceeding max_alloc {}", limits.max_alloc)));+    if sample_bytes_total > budget {+        return Err(CodecError::LimitExceeded(format!(+            "deep samples need {sample_bytes_total} bytes on top of the {index_bytes} byte sample-count index, exceeding max_alloc {}",+            limits.max_alloc+        )));     }+    let total_samples = usize::try_from(total_samples).map_err(|_| CodecError::LimitExceeded(format!("{total_samples} deep samples do not fit in memory")))?; -    let mut channels: Vec<DeepChannel> = header-        .channels-        .list-        .iter()-        .map(|c| DeepChannel { name: c.name.to_string(), sample: our_sample_type(c.sample_type), samples: vec![0f32; total_samples as usize] })-        .collect();+    let mut channels: Vec<DeepChannel> = Vec::with_capacity(header.channels.list.len());+    for c in &header.channels.list {+        channels.push(DeepChannel { name: c.name.to_string(), sample: our_sample_type(c.sample_type), samples: alloc_zeroed::<f32>(total_samples)? });+    } -    // Second pass: the compressed sample data, scattered per pixel (a block's channel data-    // is contiguous in the file but the pixels' samples interleave with other blocks').+    // Second pass: the compressed sample data, placed row by row (a block's channel data is+    // contiguous in the file, but a tile's rows interleave with other tiles' in raster order).     for chunk in &chunks {         seek_to(&mut cur, chunk.pos)?;         if multipart {@@ -510,43 +520,32 @@ pub(crate) fn decode_deep(meta: &MetaData, bytes: &[u8], limits: &Limits) -> Res         let data_bytes = read_exact(&mut cur, chunk.packed_data as usize, "the deep sample data")?;         let raw = decompress(compression, data_bytes, chunk.unpacked_data as usize, "the deep sample data")?;         // Row by row (a scan line block is one row), and within a row channel by channel, as-        // flat tiles are laid out: each pixel's samples are contiguous within its channel.+        // flat tiles are laid out: each pixel's samples are contiguous within its channel. A+        // block row is a run of consecutive pixels in raster order, so its samples are the+        // contiguous range `counts[first]..counts[last + 1]` of every output channel.         let mut in_off = 0usize;-        let row_w = chunk.row_w.max(1);-        for (r, row) in chunk.pixels.chunks(row_w).enumerate() {-            let i0 = r * row_w;-            let row_from = chunk.prefix.get(i0).copied().unwrap_or(0) as usize;-            let row_to = chunk.prefix.get(i0 + row.len()).copied().unwrap_or(0) as usize;-            let row_total = row_to.saturating_sub(row_from);+        for yy in chunk.rows.clone() {+            let first = yy.saturating_mul(w).saturating_add(chunk.cols.start);+            let end = yy.saturating_mul(w).saturating_add(chunk.cols.end);+            let index = |p: usize| counts.get(p).copied().and_then(|c| usize::try_from(c).ok()).ok_or_else(|| err("pixel index out of range"));+            let (row_from, row_to) = (index(first)?, index(end)?);+            let row_total = row_to.checked_sub(row_from).ok_or_else(|| err("the sample-count index decreases"))?;             for (ci, t) in exr_types.iter().enumerate() {                 let sb = sample_bytes(*t);                 let ch_bytes = row_total.checked_mul(sb).ok_or_else(|| err("channel size overflows"))?;-                let end = in_off.checked_add(ch_bytes).ok_or_else(|| err("channel size overflows"))?;-                let src = raw.get(in_off..end).ok_or_else(|| err("the sample data is short"))?;-                in_off = end;+                let stop = in_off.checked_add(ch_bytes).ok_or_else(|| err("channel size overflows"))?;+                let src = raw.get(in_off..stop).ok_or_else(|| err("the sample data is short"))?;+                in_off = stop;                 let out = channels.get_mut(ci).ok_or_else(|| err("channel index out of range"))?;-                for (j, p) in row.iter().enumerate() {-                    let from = (chunk.prefix.get(i0 + j).copied().unwrap_or(0) as usize).saturating_sub(row_from);-                    let n = (chunk.prefix.get(i0 + j + 1).copied().unwrap_or(0) as usize).saturating_sub(row_from).saturating_sub(from);-                    let to = deep_counts.get(*p).copied().unwrap_or(0) as usize;-                    for k in 0..n {-                        let base = (from + k) * sb;-                        let v = match t {-                            ExrSample::F16 => src.get(base..base + 2).map(|b| f16::from_le_bytes([b[0], b[1]]).to_f32()),-                            ExrSample::F32 => src.get(base..base + 4).map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])),-                            ExrSample::U32 => src.get(base..base + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]) as f32),-                        };-                        let (Some(v), Some(slot)) = (v, out.samples.get_mut(to + k)) else {-                            return Err(err("the sample data is short"));-                        };-                        *slot = v;-                    }+                let dst = out.samples.get_mut(row_from..row_to).ok_or_else(|| err("the sample data is short"))?;+                for (slot, b) in dst.iter_mut().zip(src.chunks_exact(sb)) {+                    *slot = sample_value(*t, b);                 }             }         }     } -    Ok(DeepImage { width: w as u32, height: h as u32, stage, origin, channels, counts: deep_counts })+    Ok(DeepImage { width: w as u32, height: h as u32, stage, origin, channels, counts }) }  /// Where the headers end and the offset tables begin. [`MetaData::read_from_buffered`]diff --git a/crates/codecs/tests/it/deep_exr.rs b/crates/codecs/tests/it/deep_exr.rs--- a/crates/codecs/tests/it/deep_exr.rs+++ b/crates/codecs/tests/it/deep_exr.rs@@ -658,9 +658,11 @@ fn deep_tile_count_table_is_admitted_against_max_alloc() {         if compression != 0 {             assert!(big.len() < 1 << 20, "compression {compression}: the file is {} bytes", big.len());         }-        // The table and its scratch copy are admitted together: 8 MiB.-        assert_eq!(decode_deep_exr(&big, &budget(2 * table)).expect("room for the table and its copy"), scan, "compression {compression}");-        let got = decode_deep_exr(&big, &budget(2 * table - 1));+        // The table and its scratch copy are admitted together, 8 MiB, on top of the 4x4+        // image's sample-count index (17 u64).+        let index = 17 * 8;+        assert_eq!(decode_deep_exr(&big, &budget(2 * table + index)).expect("room for the table and its copy"), scan, "compression {compression}");+        let got = decode_deep_exr(&big, &budget(2 * table + index - 1));         assert!(matches!(got, Err(CodecError::LimitExceeded(_))), "compression {compression}: one byte short gave {:?}", got.map(|d| d.total_samples()));     }     // Ordinary tiles: full-size edge tables decode under the small budget, like clipped ones.@@ -726,24 +728,65 @@ fn clipped_count_tables_include_scratch_in_the_budget() {             let bytes = gen_deep(64, 1, &channels, compression, tile, &counts, &sample);             let what = format!("tile {tile:?}, compression {compression}");             let want = decode_deep_exr(&bytes, &Limits::none()).expect("unlimited decode");-            let required = if compression == 0 { 256 } else { 512 };+            // The table and any scratch, on top of the sample-count index (65 u64).+            let index = 65 * 8;+            let required = index + if compression == 0 { 256 } else { 512 };             assert_eq!(decode_deep_exr(&bytes, &budget(required)).expect("table and any scratch fit exactly"), want, "{what}");             assert!(matches!(decode_deep_exr(&bytes, &budget(required - 1)), Err(CodecError::LimitExceeded(_))), "{what}");             let flat = photocraft_codecs::decode_with(&bytes, &photocraft_codecs::DecodeOptions { limits: budget(required - 1), ..Default::default() });             assert!(matches!(flat, Err(CodecError::LimitExceeded(_))), "{what}");             if compression == 0 {-                assert_eq!(decode_deep_exr(&bytes, &budget(511)).expect("raw table needs no scratch"), want, "{what}");+                assert_eq!(decode_deep_exr(&bytes, &budget(index + 511)).expect("raw table needs no scratch"), want, "{what}");             }         }     } } +/// The per-pixel bookkeeping of a deep decode (the cumulative sample-count index that+/// [`photocraft_codecs::DeepImage::counts`] returns, eight bytes a pixel plus one entry) is+/// charged against `max_alloc`, and the count tables are admitted on top of it. A one-channel+/// image without samples passed the data-window check (four bytes a pixel) and decoded while+/// holding 24 bytes a pixel of bookkeeping: a 4096x4096 file of 65-300 KB held 384 MiB under a+/// 64 or 128 MiB budget. Under that four-byte budget it is now refused; it decodes with room+/// for the index plus the largest count table and its scratch copy, and not one byte less.+#[test]+fn deep_bookkeeping_is_charged_against_max_alloc() {+    let (w, h) = (256usize, 256usize);+    let n = w * h;+    let channels = [Chan { name: "Y", ty: 2 }];+    let counts = vec![0u32; n];+    let sample = |_: usize, _: usize| Vec::new();+    let budget = |max_alloc: u64| Limits { max_alloc, ..Limits::none() };+    let index = (n as u64 + 1) * 8;+    for tile in [None, Some((w, h))] {+        for compression in [0u8, 1, 2] {+            let bytes = gen_deep(w, h, &channels, compression, tile, &counts, &sample);+            let what = format!("tile {tile:?}, compression {compression}");+            let want = decode_deep_exr(&bytes, &Limits::none()).expect("unlimited decode");+            assert_eq!(want.counts.len(), n + 1, "{what}");+            let pixel_budget = budget(4 * n as u64);+            let got = decode_deep_exr(&bytes, &pixel_budget);+            assert!(matches!(got, Err(CodecError::LimitExceeded(_))), "{what}: a four-byte-a-pixel budget gave {:?}", got.map(|d| d.counts.len()));+            let flat = photocraft_codecs::decode_with(&bytes, &photocraft_codecs::DecodeOptions { limits: pixel_budget, ..Default::default() });+            assert!(matches!(flat, Err(CodecError::LimitExceeded(_))), "{what}: the flat decode gave {:?}", flat.map(|i| i.width()));+            // One block's count table (a scan line, or the single tile), with the scratch copy+            // that undoes the byte split when it is stored compressed.+            let table = 4 * if tile.is_some() { n } else { w } as u64;+            let required = index + table * if compression == 0 { 1 } else { 2 };+            assert_eq!(decode_deep_exr(&bytes, &budget(required)).expect("index and table fit exactly"), want, "{what}");+            let got = decode_deep_exr(&bytes, &budget(required - 1));+            assert!(matches!(got, Err(CodecError::LimitExceeded(_))), "{what}: one byte short gave {:?}", got.map(|d| d.counts.len()));+        }+    }+}+ /// RLE and ZIPS store incompressible tables raw too. Their actual payload, not the header's /// compression tag, decides whether a scratch buffer is needed. #[test] fn raw_count_tables_need_no_scratch_even_with_compression() {     let channels = [Chan { name: "Y", ty: 2 }];-    let limits = Limits { max_alloc: 4, ..Limits::none() };+    // The four-byte table on top of the one-pixel sample-count index (two u64).+    let limits = Limits { max_alloc: 16 + 4, ..Limits::none() };     for tile in [None, Some((1, 1)), Some((2, 2))] {         for compression in [0u8, 1, 2] {             let bytes = gen_deep(1, 1, &channels, compression, tile, &[0], &|_, _| Vec::new());