Skip to main content

djvu_iw44/
lib.rs

1//! IW44 wavelet image decoder — pure-Rust clean-room implementation (phase 2c).
2//!
3//! Implements the IW44 progressive wavelet codec used by DjVu BG44, FG44, and
4//! TH44 chunks.  Each BG44 chunk may carry one or more *slices*; the ZP coder
5//! state persists across all chunks so that progressive refinement works correctly.
6//!
7//! ## Key public types
8//!
9//! - `Iw44Image` — progressive decoder; call `Iw44Image::decode_chunk` for
10//!   each BG44/FG44/TH44 chunk, then `Iw44Image::to_rgb` to obtain an RGB
11//!   pixmap.
12//! - `Iw44Error` — typed error enum (re-exported from
13//!   this crate).
14//!
15//! ## Architecture
16//!
17//! YCbCr planes are kept separate (`y: Vec<i16>`, `cb: Vec<i16>`, `cr: Vec<i16>`)
18//! until `to_rgb()` is called.  This allows future SIMD processing on each plane
19//! independently.  No interleaved buffers exist inside this module.
20
21#![cfg_attr(not(feature = "std"), no_std)]
22#![deny(unsafe_code)]
23
24#[cfg(not(feature = "std"))]
25extern crate alloc;
26
27#[cfg(not(feature = "std"))]
28use alloc::{vec, vec::Vec};
29#[cfg(feature = "std")]
30use std::{vec, vec::Vec};
31
32use djvu_pixmap::{GrayPixmap, Pixmap, PixmapError};
33use djvu_zp::ZpDecoder;
34
35/// IW44 wavelet image encoder — produces BG44/FG44/TH44 chunk payloads (std-only).
36///
37/// Shares the band/quant/state-flag/zigzag spec data with the decoder (this
38/// module) instead of re-declaring it. Requires `std` for the ZP encoder and the
39/// SIMD forward-transform paths.
40#[cfg(feature = "std")]
41pub mod encode;
42
43/// IW44 wavelet image decoding errors.
44#[derive(Debug, thiserror::Error, PartialEq, Eq)]
45#[non_exhaustive]
46pub enum Iw44Error {
47    /// Input ended before the IW44 stream was complete.
48    #[error("IW44 stream is truncated")]
49    Truncated,
50
51    /// The IW44 stream contains invalid data.
52    #[error("IW44 stream contains invalid data")]
53    Invalid,
54
55    /// A BG44/FG44/TH44 chunk is too short (fewer than 2 bytes).
56    #[error("IW44 chunk is too short")]
57    ChunkTooShort,
58
59    /// The first chunk header is too short (needs at least 9 bytes).
60    #[error("IW44 first chunk header too short (need ≥ 9 bytes)")]
61    HeaderTooShort,
62
63    /// Image width or height is zero.
64    #[error("IW44 image has zero dimension")]
65    ZeroDimension,
66
67    /// Image dimensions exceed the safety limit.
68    #[error("IW44 image dimensions too large")]
69    ImageTooLarge,
70
71    /// A subsequent chunk was encountered before the first chunk.
72    #[error("IW44 subsequent chunk received before first chunk")]
73    MissingFirstChunk,
74
75    /// The subsample parameter must be >= 1.
76    #[error("IW44 subsample must be >= 1")]
77    InvalidSubsample,
78
79    /// No codec has been initialized (no chunks decoded yet).
80    #[error("IW44 codec not yet initialized")]
81    MissingCodec,
82
83    /// The ZP arithmetic coder stream is too short.
84    #[error("IW44 ZP coder stream too short")]
85    ZpTooShort,
86
87    /// A chunk's serial number does not match the expected next value
88    /// (0, 1, 2, … in document order). Mirrors DjVuLibre's
89    /// `IW44Image.wrong_serial`/`wrong_serial2` check in
90    /// `IWBitmap::decode_chunk`/`IWPixmap::decode_chunk` — a corrupted or
91    /// desynced chunk sequence (e.g. from a bit-flip landing on the serial
92    /// byte itself, or a dropped/duplicated chunk) is rejected instead of
93    /// silently decoded into the wrong refinement slot.
94    #[error("IW44 chunk does not bear expected serial number")]
95    UnexpectedSerial,
96}
97
98/// The decoder's output pixmap is bounded like its input planes.
99///
100/// [`Pixmap::try_new`] refuses more than [`Pixmap::MAX_PIXELS`]; the decoder
101/// already rejects an image that large at the header, so this is the same
102/// limit reported from the other end.
103impl From<PixmapError> for Iw44Error {
104    fn from(_: PixmapError) -> Self {
105        Iw44Error::ImageTooLarge
106    }
107}
108
109// ---- Band-bucket mapping: 10 bands, each mapped to a range of buckets --------
110//
111// The band/quant/state-flag/zigzag definitions below are the IW44 *spec* — one
112// source of truth shared between the decoder (this module) and the std-only
113// [`encode`] module, which is why they are `pub(crate)` rather than private.
114
115/// `BAND_BUCKETS[band]` = `(first_bucket, last_bucket)` inclusive.
116pub(crate) const BAND_BUCKETS: [(usize, usize); 10] = [
117    (0, 0),
118    (1, 1),
119    (2, 2),
120    (3, 3),
121    (4, 7),
122    (8, 11),
123    (12, 15),
124    (16, 31),
125    (32, 47),
126    (48, 63),
127];
128
129/// Initial quantization step table for the low-frequency band (band 0).
130pub(crate) const QUANT_LO_INIT: [u32; 16] = [
131    0x004000, 0x008000, 0x008000, 0x010000, 0x010000, 0x010000, 0x010000, 0x010000, 0x010000,
132    0x010000, 0x010000, 0x010000, 0x020000, 0x020000, 0x020000, 0x020000,
133];
134
135/// Initial quantization step table for high-frequency bands (bands 1–9).
136pub(crate) const QUANT_HI_INIT: [u32; 10] = [
137    0, 0x020000, 0x020000, 0x040000, 0x040000, 0x040000, 0x080000, 0x040000, 0x040000, 0x080000,
138];
139
140// ---- Coefficient state flags -------------------------------------------------
141
142pub(crate) const ZERO: u8 = 1;
143pub(crate) const ACTIVE: u8 = 2;
144pub(crate) const NEW: u8 = 4;
145pub(crate) const UNK: u8 = 8;
146
147// ---- Zigzag scan tables ------------------------------------------------------
148//
149// Each coefficient index `i` (0..1024) maps to a `(row, col)` within the 32×32
150// block via bit-interleaving: even bits → column, odd bits → row.
151
152pub(crate) const fn zigzag_row(i: usize) -> u8 {
153    let b1 = ((i >> 1) & 1) as u8;
154    let b3 = ((i >> 3) & 1) as u8;
155    let b5 = ((i >> 5) & 1) as u8;
156    let b7 = ((i >> 7) & 1) as u8;
157    let b9 = ((i >> 9) & 1) as u8;
158    b1 * 16 + b3 * 8 + b5 * 4 + b7 * 2 + b9
159}
160
161pub(crate) const fn zigzag_col(i: usize) -> u8 {
162    let b0 = (i & 1) as u8;
163    let b2 = ((i >> 2) & 1) as u8;
164    let b4 = ((i >> 4) & 1) as u8;
165    let b6 = ((i >> 6) & 1) as u8;
166    let b8 = ((i >> 8) & 1) as u8;
167    b0 * 16 + b2 * 8 + b4 * 4 + b6 * 2 + b8
168}
169
170/// Inverse zigzag: `ZIGZAG_INV[row * 32 + col]` is the index `i` such that
171/// `zigzag_row(i) == row as u8 && zigzag_col(i) == col as u8`.
172///
173/// Enables row-major scatter (sequential writes to the plane) at the cost of
174/// gathering block coefficients in zigzag order (2 KB block fits in L1).
175static ZIGZAG_INV: [u16; 1024] = {
176    let mut table = [0u16; 1024];
177    let mut i = 0usize;
178    while i < 1024 {
179        let r = zigzag_row(i) as usize;
180        let c = zigzag_col(i) as usize;
181        table[r * 32 + c] = i as u16;
182        i += 1;
183    }
184    table
185};
186
187/// Compact inverse zigzag for sub=2 (16×16 sub-block, 256 entries).
188/// `ZIGZAG_INV_SUB2[row * 16 + col]` = index `i` in 0..256 such that
189/// `zigzag_row(i) >> 1 == row && zigzag_col(i) >> 1 == col`.
190static ZIGZAG_INV_SUB2: [u8; 256] = {
191    let mut table = [0u8; 256];
192    let mut i = 0usize;
193    while i < 256 {
194        let r = (zigzag_row(i) >> 1) as usize;
195        let c = (zigzag_col(i) >> 1) as usize;
196        table[r * 16 + c] = i as u8;
197        i += 1;
198    }
199    table
200};
201
202/// Compact inverse zigzag for sub=4 (8×8 sub-block, 64 entries).
203/// `ZIGZAG_INV_SUB4[row * 8 + col]` = index `i` in 0..64.
204static ZIGZAG_INV_SUB4: [u8; 64] = {
205    let mut table = [0u8; 64];
206    let mut i = 0usize;
207    while i < 64 {
208        let r = (zigzag_row(i) >> 2) as usize;
209        let c = (zigzag_col(i) >> 2) as usize;
210        table[r * 8 + c] = i as u8;
211        i += 1;
212    }
213    table
214};
215
216/// Compact inverse zigzag for sub=8 (4×4 sub-block, 16 entries).
217/// `ZIGZAG_INV_SUB8[row * 4 + col]` = index `i` in 0..16.
218static ZIGZAG_INV_SUB8: [u8; 16] = {
219    let mut table = [0u8; 16];
220    let mut i = 0usize;
221    while i < 16 {
222        let r = (zigzag_row(i) >> 3) as usize;
223        let c = (zigzag_col(i) >> 3) as usize;
224        table[r * 4 + c] = i as u8;
225        i += 1;
226    }
227    table
228};
229
230// ---- Normalization -----------------------------------------------------------
231
232/// Map a raw wavelet coefficient to a signed pixel offset in `[-128, 127]`.
233#[inline]
234fn normalize(val: i16) -> i32 {
235    let v = ((val as i32) + 32) >> 6;
236    v.clamp(-128, 127)
237}
238
239// ---- SIMD YCbCr→RGBA row conversion -----------------------------------------
240//
241// Processes 8 pixels per iteration using `wide::i32x8` (maps to AVX2 on x86_64,
242// NEON on ARM64, or scalar on other targets — all in safe Rust).
243
244/// Convert one row of pre-normalized YCbCr values to RGBA using SIMD.
245///
246/// `y_row`, `cb_row`, `cr_row` are normalized i32 values in `[-128, 127]`.
247/// `out` must hold exactly `y_row.len() * 4` bytes (RGBA).
248///
249/// DjVu YCbCr→RGB formula (LeCun 1998):
250/// ```text
251/// t2    = Cr + (Cr >> 1)
252/// t3    = Y  + 128 - (Cb >> 2)
253/// R     = clamp(Y  + 128 + t2,      0, 255)
254/// G     = clamp(t3 - (t2 >> 1),     0, 255)
255/// B     = clamp(t3 + (Cb << 1),     0, 255)
256/// ```
257pub(crate) fn ycbcr_row_to_rgba(y_row: &[i32], cb_row: &[i32], cr_row: &[i32], out: &mut [u8]) {
258    debug_assert_eq!(y_row.len(), cb_row.len());
259    debug_assert_eq!(y_row.len(), cr_row.len());
260    debug_assert_eq!(out.len(), y_row.len() * 4);
261
262    let w = y_row.len();
263
264    #[cfg(target_arch = "aarch64")]
265    {
266        #[allow(unsafe_code)]
267        unsafe {
268            ycbcr_neon(
269                y_row.as_ptr(),
270                cb_row.as_ptr(),
271                cr_row.as_ptr(),
272                out.as_mut_ptr(),
273                w,
274            )
275        };
276        return;
277    }
278
279    // Portable path: as_chunks eliminates per-element bounds checks.
280    #[allow(unreachable_code)]
281    ycbcr_portable(y_row, cb_row, cr_row, out, w);
282}
283
284/// Convert raw i16 plane row data to RGBA, fusing normalize + YCbCr in one pass.
285///
286/// Uses `ycbcr_neon_raw` on AArch64 (avoids three intermediate i32 buffers and
287/// the separate normalize loops).  Falls back to two-pass on other targets.
288///
289/// `y`, `cb`, `cr` must all have the same length `w`; `out` must hold `w * 4` bytes.
290#[inline]
291fn ycbcr_row_from_i16(y: &[i16], cb: &[i16], cr: &[i16], out: &mut [u8]) {
292    let w = y.len();
293    debug_assert_eq!(cb.len(), w);
294    debug_assert_eq!(cr.len(), w);
295    debug_assert_eq!(out.len(), w * 4);
296    #[cfg(target_arch = "aarch64")]
297    {
298        #[allow(unsafe_code)]
299        unsafe {
300            ycbcr_neon_raw(y.as_ptr(), cb.as_ptr(), cr.as_ptr(), out.as_mut_ptr(), w);
301        }
302        return;
303    }
304    // Runtime AVX2 detection requires `std` (`is_x86_feature_detected!`).
305    #[cfg(all(target_arch = "x86_64", feature = "std"))]
306    {
307        if std::is_x86_feature_detected!("avx2") {
308            #[allow(unsafe_code)]
309            unsafe {
310                ycbcr_avx2_raw(y.as_ptr(), cb.as_ptr(), cr.as_ptr(), out.as_mut_ptr(), w);
311            }
312            return;
313        }
314    }
315    // WASM simd128 is compile-time only; no runtime detection.
316    #[cfg(all(target_arch = "wasm32", target_feature = "simd128"))]
317    {
318        #[allow(unsafe_code)]
319        unsafe {
320            ycbcr_simd128_raw(y.as_ptr(), cb.as_ptr(), cr.as_ptr(), out.as_mut_ptr(), w);
321        }
322        return;
323    }
324    #[allow(unreachable_code)]
325    {
326        let mut y_norm = vec![0i32; w];
327        let mut cb_norm = vec![0i32; w];
328        let mut cr_norm = vec![0i32; w];
329        for (col, v) in y_norm.iter_mut().enumerate() {
330            *v = normalize(y[col]);
331        }
332        for col in 0..w {
333            cb_norm[col] = normalize(cb[col]);
334            cr_norm[col] = normalize(cr[col]);
335        }
336        ycbcr_row_to_rgba(&y_norm, &cb_norm, &cr_norm, out);
337    }
338}
339
340/// Convert raw i16 plane row data to RGBA with chroma at half horizontal resolution.
341///
342/// `y` has length ≥ `w`; `cb_half`/`cr_half` have length ≥ `(w+1)/2`.  Each
343/// chroma sample is nearest-neighbour upsampled to two adjacent output pixels.
344/// Uses `ycbcr_neon_raw_half` on AArch64; two-pass fallback elsewhere.
345///
346/// Superseded by bilinear chroma upsampling (#422): `to_rgb_subsample` now
347/// builds full-resolution chroma rows and uses `ycbcr_row_from_i16`. Retained
348/// (with its SIMD kernels and their tests) for reference and possible reuse.
349#[inline]
350#[allow(dead_code)]
351fn ycbcr_row_from_i16_half(y: &[i16], cb_half: &[i16], cr_half: &[i16], out: &mut [u8], w: usize) {
352    debug_assert!(y.len() >= w);
353    debug_assert_eq!(out.len(), w * 4);
354    #[cfg(target_arch = "aarch64")]
355    {
356        #[allow(unsafe_code)]
357        unsafe {
358            ycbcr_neon_raw_half(
359                y.as_ptr(),
360                cb_half.as_ptr(),
361                cr_half.as_ptr(),
362                out.as_mut_ptr(),
363                w,
364            );
365        }
366        return;
367    }
368    #[cfg(all(target_arch = "x86_64", feature = "std"))]
369    {
370        if std::is_x86_feature_detected!("avx2") {
371            #[allow(unsafe_code)]
372            unsafe {
373                ycbcr_avx2_raw_half(
374                    y.as_ptr(),
375                    cb_half.as_ptr(),
376                    cr_half.as_ptr(),
377                    out.as_mut_ptr(),
378                    w,
379                );
380            }
381            return;
382        }
383    }
384    #[cfg(all(target_arch = "wasm32", target_feature = "simd128"))]
385    {
386        #[allow(unsafe_code)]
387        unsafe {
388            ycbcr_simd128_raw_half(
389                y.as_ptr(),
390                cb_half.as_ptr(),
391                cr_half.as_ptr(),
392                out.as_mut_ptr(),
393                w,
394            );
395        }
396        return;
397    }
398    #[allow(unreachable_code)]
399    {
400        let mut y_norm = vec![0i32; w];
401        let mut cb_norm = vec![0i32; w];
402        let mut cr_norm = vec![0i32; w];
403        for (col, v) in y_norm.iter_mut().enumerate() {
404            *v = normalize(y[col]);
405        }
406        for col in 0..w {
407            cb_norm[col] = normalize(cb_half[col / 2]);
408            cr_norm[col] = normalize(cr_half[col / 2]);
409        }
410        ycbcr_row_to_rgba(&y_norm, &cb_norm, &cr_norm, out);
411    }
412}
413
414/// #422: bilinearly upsample one full-resolution chroma row (length `out.len()`)
415/// from two adjacent half-resolution rows.
416///
417/// `half0`/`half1` are the chroma rows at `row/2` and `row/2+1`; when `v_blend`
418/// (the luma row is odd) they are averaged for the vertical tap, otherwise
419/// `half0` is used directly. Horizontally, even output columns take chroma
420/// `c/2` and odd columns average `c/2` and `c/2+1` (clamped to `cw-1`). This
421/// replaces the previous 2×2 nearest-neighbour replication, removing the colour
422/// stairstepping at sharp chroma transitions. Works on the raw i16 plane values
423/// (normalisation happens later in `ycbcr_row_from_i16`).
424fn upsample_chroma_row_bilinear(
425    half0: &[i16],
426    half1: &[i16],
427    v_blend: bool,
428    out: &mut [i16],
429    cw: usize,
430) {
431    let vsamp = |hc: usize| -> i32 {
432        if v_blend {
433            (half0[hc] as i32 + half1[hc] as i32 + 1) >> 1
434        } else {
435            half0[hc] as i32
436        }
437    };
438    for (c, o) in out.iter_mut().enumerate() {
439        let hc = c >> 1;
440        let v0 = vsamp(hc);
441        *o = if c & 1 == 0 {
442            v0 as i16
443        } else {
444            let v1 = vsamp((hc + 1).min(cw - 1));
445            ((v0 + v1 + 1) >> 1) as i16
446        };
447    }
448}
449
450/// Portable YCbCr→RGBA using as_chunks so LLVM sees exact 8-element slices.
451#[inline(always)]
452fn ycbcr_portable(y_row: &[i32], cb_row: &[i32], cr_row: &[i32], out: &mut [u8], w: usize) {
453    use wide::i32x8;
454    let c128 = i32x8::splat(128);
455    let c0 = i32x8::splat(0);
456    let c255 = i32x8::splat(255);
457
458    let full8 = w / 8;
459    for (((yc, cbc), crc), outc) in y_row[..full8 * 8]
460        .as_chunks::<8>()
461        .0
462        .iter()
463        .zip(cb_row[..full8 * 8].as_chunks::<8>().0)
464        .zip(cr_row[..full8 * 8].as_chunks::<8>().0)
465        .zip(out[..full8 * 32].as_chunks_mut::<32>().0)
466    {
467        let ys = i32x8::from([yc[0], yc[1], yc[2], yc[3], yc[4], yc[5], yc[6], yc[7]]);
468        let bs = i32x8::from([
469            cbc[0], cbc[1], cbc[2], cbc[3], cbc[4], cbc[5], cbc[6], cbc[7],
470        ]);
471        let rs = i32x8::from([
472            crc[0], crc[1], crc[2], crc[3], crc[4], crc[5], crc[6], crc[7],
473        ]);
474        let t2 = rs + (rs >> 1_i32);
475        let t3 = ys + c128 - (bs >> 2_i32);
476        let red = (ys + c128 + t2).max(c0).min(c255).to_array();
477        let grn = (t3 - (t2 >> 1_i32)).max(c0).min(c255).to_array();
478        let blu = (t3 + (bs << 1_i32)).max(c0).min(c255).to_array();
479        for i in 0..8 {
480            outc[i * 4] = red[i] as u8;
481            outc[i * 4 + 1] = grn[i] as u8;
482            outc[i * 4 + 2] = blu[i] as u8;
483            outc[i * 4 + 3] = 255;
484        }
485    }
486    for col in (full8 * 8)..w {
487        let y = y_row[col];
488        let b = cb_row[col];
489        let r = cr_row[col];
490        let t2 = r + (r >> 1);
491        let t3 = y + 128 - (b >> 2);
492        out[col * 4] = (y + 128 + t2).clamp(0, 255) as u8;
493        out[col * 4 + 1] = (t3 - (t2 >> 1)).clamp(0, 255) as u8;
494        out[col * 4 + 2] = (t3 + (b << 1)).clamp(0, 255) as u8;
495        out[col * 4 + 3] = 255;
496    }
497}
498
499/// AArch64 NEON fused normalize + YCbCr→RGBA from raw i16 plane data (non-chroma-half).
500///
501/// Loads 8 i16 per channel, applies `normalize()` inline using `vrshrq_n_s16`
502/// (rounding-shift by 6, i.e. `(v+32)>>6`) and clamps to `[-128,127]`, then
503/// runs the YCbCr→RGBA formula.  Eliminates the separate normalize pass and the
504/// three intermediate i32 buffers.
505///
506/// `cbp` and `crp` must point to `w` values each (same stride as `yp`).
507#[cfg(target_arch = "aarch64")]
508#[allow(unsafe_code, unsafe_op_in_unsafe_fn)]
509#[target_feature(enable = "neon")]
510unsafe fn ycbcr_neon_raw(
511    yp: *const i16,
512    cbp: *const i16,
513    crp: *const i16,
514    outp: *mut u8,
515    w: usize,
516) {
517    use core::arch::aarch64::*;
518    // After normalize+clamp all values ∈ [-128, 127].  The YCbCr arithmetic
519    // intermediates all fit in i16 (proof: y128∈[0,255], t2∈[-192,190],
520    // t3∈[-31,287], r16∈[-192,445], g16∈[-126,383], b16∈[-287,541]).
521    // vqmovun_s16 saturates signed i16 → unsigned u8, clamping to [0,255]
522    // in one instruction — no separate min/max clamp ops needed.
523    let n_min = vdupq_n_s16(-128);
524    let n_max = vdupq_n_s16(127);
525    let c128 = vdupq_n_s16(128);
526    let alpha = vdup_n_u8(255);
527
528    let full8 = w / 8;
529    for i in 0..full8 {
530        let off = i * 8;
531        // Load + normalize (rounded right-shift by 6) + clamp to [-128, 127] at i16
532        let yc = vmaxq_s16(
533            vminq_s16(vrshrq_n_s16::<6>(vld1q_s16(yp.add(off))), n_max),
534            n_min,
535        );
536        let cbc = vmaxq_s16(
537            vminq_s16(vrshrq_n_s16::<6>(vld1q_s16(cbp.add(off))), n_max),
538            n_min,
539        );
540        let crc = vmaxq_s16(
541            vminq_s16(vrshrq_n_s16::<6>(vld1q_s16(crp.add(off))), n_max),
542            n_min,
543        );
544        // All arithmetic stays at i16 — no widening to i32 needed.
545        // y128 = y + 128, range [0, 255]
546        let y128 = vaddq_s16(yc, c128);
547        // t2 = cr + (cr >> 1) = 1.5·cr, range [-192, 190]
548        let t2 = vaddq_s16(crc, vshrq_n_s16::<1>(crc));
549        // t3 = y128 - (cb >> 2), range [-31, 287]
550        let t3 = vsubq_s16(y128, vshrq_n_s16::<2>(cbc));
551        // R = y128 + t2, range [-192, 445]
552        let r16 = vaddq_s16(y128, t2);
553        // G = t3 - (t2 >> 1), range [-126, 383]
554        let g16 = vsubq_s16(t3, vshrq_n_s16::<1>(t2));
555        // B = t3 + 2·cb, range [-287, 541]
556        let b16 = vaddq_s16(t3, vshlq_n_s16::<1>(cbc));
557        // Saturating narrow signed i16 → unsigned u8 (clamps to [0, 255])
558        let r8 = vqmovun_s16(r16);
559        let g8 = vqmovun_s16(g16);
560        let b8 = vqmovun_s16(b16);
561        vst4_u8(outp.add(off * 4), uint8x8x4_t(r8, g8, b8, alpha));
562    }
563    // Scalar tail
564    for col in (full8 * 8)..w {
565        let y = normalize(*yp.add(col));
566        let b = normalize(*cbp.add(col));
567        let r = normalize(*crp.add(col));
568        let t2 = r + (r >> 1);
569        let t3 = y + 128 - (b >> 2);
570        *outp.add(col * 4) = (y + 128 + t2).clamp(0, 255) as u8;
571        *outp.add(col * 4 + 1) = (t3 - (t2 >> 1)).clamp(0, 255) as u8;
572        *outp.add(col * 4 + 2) = (t3 + (b << 1)).clamp(0, 255) as u8;
573        *outp.add(col * 4 + 3) = 255;
574    }
575}
576
577/// AArch64 NEON fused normalize + YCbCr→RGBA from raw i16 plane data (chroma-half).
578///
579/// `cbp` and `crp` point to chroma planes at half the horizontal resolution.
580/// Each chroma sample is nearest-neighbour upsampled to two luma columns.
581/// 8 output pixels are produced per iteration, consuming 8 Y samples and 4 Cb/Cr samples.
582#[cfg(target_arch = "aarch64")]
583#[allow(unsafe_code, unsafe_op_in_unsafe_fn)]
584#[target_feature(enable = "neon")]
585unsafe fn ycbcr_neon_raw_half(
586    yp: *const i16,
587    cbp: *const i16,
588    crp: *const i16,
589    outp: *mut u8,
590    w: usize,
591) {
592    use core::arch::aarch64::*;
593    // Same i16 arithmetic as ycbcr_neon_raw — all intermediates fit in i16.
594    let n_min = vdupq_n_s16(-128);
595    let n_max = vdupq_n_s16(127);
596    let c128 = vdupq_n_s16(128);
597    let alpha = vdup_n_u8(255);
598
599    let full8 = w / 8;
600    for i in 0..full8 {
601        let off = i * 8;
602        let c_off = i * 4;
603        // Load + normalize Y (8 consecutive)
604        let yc = vmaxq_s16(
605            vminq_s16(vrshrq_n_s16::<6>(vld1q_s16(yp.add(off))), n_max),
606            n_min,
607        );
608        // Load 4 chroma values, normalize at i16 level, then upsample 4→8 by
609        // duplicating each value: [a,b,c,d] → [a,a,b,b,c,c,d,d] via vzip1q
610        let cb4 = vmaxq_s16(
611            vminq_s16(
612                vrshrq_n_s16::<6>(vcombine_s16(vld1_s16(cbp.add(c_off)), vdup_n_s16(0))),
613                n_max,
614            ),
615            n_min,
616        );
617        let cr4 = vmaxq_s16(
618            vminq_s16(
619                vrshrq_n_s16::<6>(vcombine_s16(vld1_s16(crp.add(c_off)), vdup_n_s16(0))),
620                n_max,
621            ),
622            n_min,
623        );
624        // Upsample: interleave low 4 lanes with themselves → [a,a,b,b,c,c,d,d]
625        let cbc = vzip1q_s16(cb4, cb4);
626        let crc = vzip1q_s16(cr4, cr4);
627        // All arithmetic at i16 level (same ranges as non-half path after upsample)
628        let y128 = vaddq_s16(yc, c128);
629        let t2 = vaddq_s16(crc, vshrq_n_s16::<1>(crc));
630        let t3 = vsubq_s16(y128, vshrq_n_s16::<2>(cbc));
631        let r16 = vaddq_s16(y128, t2);
632        let g16 = vsubq_s16(t3, vshrq_n_s16::<1>(t2));
633        let b16 = vaddq_s16(t3, vshlq_n_s16::<1>(cbc));
634        let r8 = vqmovun_s16(r16);
635        let g8 = vqmovun_s16(g16);
636        let b8 = vqmovun_s16(b16);
637        vst4_u8(outp.add(off * 4), uint8x8x4_t(r8, g8, b8, alpha));
638    }
639    // Scalar tail
640    for col in (full8 * 8)..w {
641        let y = normalize(*yp.add(col));
642        let b = normalize(*cbp.add(col / 2));
643        let r = normalize(*crp.add(col / 2));
644        let t2 = r + (r >> 1);
645        let t3 = y + 128 - (b >> 2);
646        *outp.add(col * 4) = (y + 128 + t2).clamp(0, 255) as u8;
647        *outp.add(col * 4 + 1) = (t3 - (t2 >> 1)).clamp(0, 255) as u8;
648        *outp.add(col * 4 + 2) = (t3 + (b << 1)).clamp(0, 255) as u8;
649        *outp.add(col * 4 + 3) = 255;
650    }
651}
652
653/// x86_64 AVX2 fused normalize + YCbCr→RGBA from raw i16 plane data (non-chroma-half).
654///
655/// 16 pixels per iteration (vs NEON's 8): __m256i holds 16 i16. Pack-down to u8
656/// is done via SSE `_mm_packus_epi16` on the two 128-bit halves followed by an
657/// SSE byte-interleave to materialise R/G/B/A → RGBA bytes.
658///
659/// `cbp` and `crp` must point to `w` values each (same stride as `yp`).
660#[cfg(all(target_arch = "x86_64", feature = "std"))]
661#[allow(unsafe_code, unsafe_op_in_unsafe_fn)]
662#[target_feature(enable = "avx2")]
663unsafe fn ycbcr_avx2_raw(
664    yp: *const i16,
665    cbp: *const i16,
666    crp: *const i16,
667    outp: *mut u8,
668    w: usize,
669) {
670    use core::arch::x86_64::*;
671    let n_min = _mm256_set1_epi16(-128);
672    let n_max = _mm256_set1_epi16(127);
673    let c128 = _mm256_set1_epi16(128);
674    let one = _mm256_set1_epi16(1);
675
676    let full16 = w / 16;
677    for i in 0..full16 {
678        let off = i * 16;
679        // Rounding right shift by 6 + clamp to [-128, 127].
680        // Equivalent to scalar `((v as i32 + 32) >> 6).clamp(-128, 127)` and to NEON
681        // `vrshrq_n_s16::<6>` followed by clamp.  We compute it at i16 width without
682        // overflow as `(v >> 6) + ((v as u16 >> 5) & 1)` — the bit-5 logical-shifted
683        // term is the round-half-away-from-zero correction and matches the wider
684        // intermediate that NEON / scalar use.
685        let load_norm_clamp = |p: *const i16| -> __m256i {
686            let v = _mm256_loadu_si256(p as *const __m256i);
687            let high = _mm256_srai_epi16::<6>(v);
688            let bit5 = _mm256_and_si256(_mm256_srli_epi16::<5>(v), one);
689            let n = _mm256_add_epi16(high, bit5);
690            _mm256_max_epi16(_mm256_min_epi16(n, n_max), n_min)
691        };
692        let yc = load_norm_clamp(yp.add(off));
693        let cbc = load_norm_clamp(cbp.add(off));
694        let crc = load_norm_clamp(crp.add(off));
695
696        // Same i16 arithmetic as NEON path; ranges fit in i16 → no widening.
697        let y128 = _mm256_add_epi16(yc, c128);
698        let t2 = _mm256_add_epi16(crc, _mm256_srai_epi16::<1>(crc));
699        let t3 = _mm256_sub_epi16(y128, _mm256_srai_epi16::<2>(cbc));
700        let r16 = _mm256_add_epi16(y128, t2);
701        let g16 = _mm256_sub_epi16(t3, _mm256_srai_epi16::<1>(t2));
702        let b16 = _mm256_add_epi16(t3, _mm256_slli_epi16::<1>(cbc));
703
704        // Saturating narrow signed i16 → unsigned u8 in halves (clamps to [0, 255])
705        let r_pack = _mm_packus_epi16(
706            _mm256_castsi256_si128(r16),
707            _mm256_extracti128_si256::<1>(r16),
708        );
709        let g_pack = _mm_packus_epi16(
710            _mm256_castsi256_si128(g16),
711            _mm256_extracti128_si256::<1>(g16),
712        );
713        let b_pack = _mm_packus_epi16(
714            _mm256_castsi256_si128(b16),
715            _mm256_extracti128_si256::<1>(b16),
716        );
717        let a_pack = _mm_set1_epi8(-1i8);
718
719        // Interleave R/G and B/A into pairs, then unpack i16 to materialise RGBA.
720        let rg_lo = _mm_unpacklo_epi8(r_pack, g_pack);
721        let rg_hi = _mm_unpackhi_epi8(r_pack, g_pack);
722        let ba_lo = _mm_unpacklo_epi8(b_pack, a_pack);
723        let ba_hi = _mm_unpackhi_epi8(b_pack, a_pack);
724
725        let rgba0 = _mm_unpacklo_epi16(rg_lo, ba_lo);
726        let rgba1 = _mm_unpackhi_epi16(rg_lo, ba_lo);
727        let rgba2 = _mm_unpacklo_epi16(rg_hi, ba_hi);
728        let rgba3 = _mm_unpackhi_epi16(rg_hi, ba_hi);
729
730        let dst = outp.add(off * 4) as *mut __m128i;
731        _mm_storeu_si128(dst, rgba0);
732        _mm_storeu_si128(dst.add(1), rgba1);
733        _mm_storeu_si128(dst.add(2), rgba2);
734        _mm_storeu_si128(dst.add(3), rgba3);
735    }
736    // Scalar tail
737    for col in (full16 * 16)..w {
738        let y = normalize(*yp.add(col));
739        let b = normalize(*cbp.add(col));
740        let r = normalize(*crp.add(col));
741        let t2 = r + (r >> 1);
742        let t3 = y + 128 - (b >> 2);
743        *outp.add(col * 4) = (y + 128 + t2).clamp(0, 255) as u8;
744        *outp.add(col * 4 + 1) = (t3 - (t2 >> 1)).clamp(0, 255) as u8;
745        *outp.add(col * 4 + 2) = (t3 + (b << 1)).clamp(0, 255) as u8;
746        *outp.add(col * 4 + 3) = 255;
747    }
748}
749
750/// x86_64 AVX2 fused normalize + YCbCr→RGBA from raw i16 plane data (chroma-half).
751///
752/// 16 Y / 8 chroma per iteration. Chroma upsample uses `_mm256_permute4x64_epi64`
753/// to place chromas 0-3 in the low 128-bit lane low half and chromas 4-7 in the
754/// high 128-bit lane low half, then `_mm256_unpacklo_epi16(v, v)` duplicates each
755/// chroma into two adjacent i16 lanes per 128-bit half.
756#[cfg(all(target_arch = "x86_64", feature = "std"))]
757#[allow(unsafe_code, unsafe_op_in_unsafe_fn)]
758#[target_feature(enable = "avx2")]
759unsafe fn ycbcr_avx2_raw_half(
760    yp: *const i16,
761    cbp: *const i16,
762    crp: *const i16,
763    outp: *mut u8,
764    w: usize,
765) {
766    use core::arch::x86_64::*;
767    let n_min = _mm256_set1_epi16(-128);
768    let n_max = _mm256_set1_epi16(127);
769    let c128 = _mm256_set1_epi16(128);
770    let one = _mm256_set1_epi16(1);
771
772    // Overflow-safe rounding right shift by 6 + clamp to [-128, 127];
773    // see `ycbcr_avx2_raw` for the equivalence proof.
774    let norm_clamp = |v: __m256i| -> __m256i {
775        let high = _mm256_srai_epi16::<6>(v);
776        let bit5 = _mm256_and_si256(_mm256_srli_epi16::<5>(v), one);
777        let n = _mm256_add_epi16(high, bit5);
778        _mm256_max_epi16(_mm256_min_epi16(n, n_max), n_min)
779    };
780
781    let full16 = w / 16;
782    for i in 0..full16 {
783        let off = i * 16;
784        let c_off = i * 8;
785
786        // Load + normalize 16 Y samples
787        let yv = _mm256_loadu_si256(yp.add(off) as *const __m256i);
788        let yc = norm_clamp(yv);
789
790        // Load 8 chroma i16 (one __m128i), upsample to 16 by duplicating each.
791        let upsample = |p: *const i16| -> __m256i {
792            let v8 = _mm_loadu_si128(p as *const __m128i);
793            // Place i16s 0-3 into i64-lane 0 (already there), i16s 4-7 into i64-lane 2.
794            // permute4x64 mask 0b00_01_00_00: out0←src0, out1←src0, out2←src1, out3←src0.
795            let spread = _mm256_permute4x64_epi64::<0b00_01_00_00>(_mm256_castsi128_si256(v8));
796            // Per-128-bit-lane interleave with itself: duplicates each i16 lane.
797            _mm256_unpacklo_epi16(spread, spread)
798        };
799        let cbc = norm_clamp(upsample(cbp.add(c_off)));
800        let crc = norm_clamp(upsample(crp.add(c_off)));
801
802        let y128 = _mm256_add_epi16(yc, c128);
803        let t2 = _mm256_add_epi16(crc, _mm256_srai_epi16::<1>(crc));
804        let t3 = _mm256_sub_epi16(y128, _mm256_srai_epi16::<2>(cbc));
805        let r16 = _mm256_add_epi16(y128, t2);
806        let g16 = _mm256_sub_epi16(t3, _mm256_srai_epi16::<1>(t2));
807        let b16 = _mm256_add_epi16(t3, _mm256_slli_epi16::<1>(cbc));
808
809        let r_pack = _mm_packus_epi16(
810            _mm256_castsi256_si128(r16),
811            _mm256_extracti128_si256::<1>(r16),
812        );
813        let g_pack = _mm_packus_epi16(
814            _mm256_castsi256_si128(g16),
815            _mm256_extracti128_si256::<1>(g16),
816        );
817        let b_pack = _mm_packus_epi16(
818            _mm256_castsi256_si128(b16),
819            _mm256_extracti128_si256::<1>(b16),
820        );
821        let a_pack = _mm_set1_epi8(-1i8);
822
823        let rg_lo = _mm_unpacklo_epi8(r_pack, g_pack);
824        let rg_hi = _mm_unpackhi_epi8(r_pack, g_pack);
825        let ba_lo = _mm_unpacklo_epi8(b_pack, a_pack);
826        let ba_hi = _mm_unpackhi_epi8(b_pack, a_pack);
827
828        let rgba0 = _mm_unpacklo_epi16(rg_lo, ba_lo);
829        let rgba1 = _mm_unpackhi_epi16(rg_lo, ba_lo);
830        let rgba2 = _mm_unpacklo_epi16(rg_hi, ba_hi);
831        let rgba3 = _mm_unpackhi_epi16(rg_hi, ba_hi);
832
833        let dst = outp.add(off * 4) as *mut __m128i;
834        _mm_storeu_si128(dst, rgba0);
835        _mm_storeu_si128(dst.add(1), rgba1);
836        _mm_storeu_si128(dst.add(2), rgba2);
837        _mm_storeu_si128(dst.add(3), rgba3);
838    }
839    // Scalar tail
840    for col in (full16 * 16)..w {
841        let y = normalize(*yp.add(col));
842        let b = normalize(*cbp.add(col / 2));
843        let r = normalize(*crp.add(col / 2));
844        let t2 = r + (r >> 1);
845        let t3 = y + 128 - (b >> 2);
846        *outp.add(col * 4) = (y + 128 + t2).clamp(0, 255) as u8;
847        *outp.add(col * 4 + 1) = (t3 - (t2 >> 1)).clamp(0, 255) as u8;
848        *outp.add(col * 4 + 2) = (t3 + (b << 1)).clamp(0, 255) as u8;
849        *outp.add(col * 4 + 3) = 255;
850    }
851}
852
853/// WASM simd128 fused normalize + YCbCr→RGBA from raw i16 plane data (non-chroma-half).
854///
855/// 8 pixels per iteration, mirroring the AArch64 NEON kernel byte-for-byte.
856/// `v128` is 128 bits → 8×i16, same width as NEON's `int16x8_t`. Saturating
857/// signed-i16 → unsigned-u8 narrow is one instruction (`u8x16_narrow_i16x8`),
858/// equivalent to NEON `vqmovun_s16`.
859///
860/// RGBA byte-interleave is materialised via two `i8x16_shuffle` calls
861/// (constant-mask shuffle, 16 lanes each, picking from {r/g pack, b/alpha pack}).
862/// WASM has no `vst4`-equivalent; the shuffle pair is the simd128 idiom.
863///
864/// `cbp` and `crp` must point to `w` values each (same stride as `yp`).
865#[cfg(target_arch = "wasm32")]
866#[allow(unsafe_code, unsafe_op_in_unsafe_fn, dead_code)]
867#[target_feature(enable = "simd128")]
868unsafe fn ycbcr_simd128_raw(
869    yp: *const i16,
870    cbp: *const i16,
871    crp: *const i16,
872    outp: *mut u8,
873    w: usize,
874) {
875    use core::arch::wasm32::*;
876    let n_min = i16x8_splat(-128);
877    let n_max = i16x8_splat(127);
878    let c128 = i16x8_splat(128);
879    let one = i16x8_splat(1);
880    // Saturating-narrow input ≥ 255 → 255, so any sentinel ≥ 255 produces the
881    // alpha byte without a separate splat-store path.
882    let alpha_src = i16x8_splat(255);
883
884    let full8 = w / 8;
885    for i in 0..full8 {
886        let off = i * 8;
887        // Rounding right shift by 6 + clamp to [-128, 127].
888        // Same overflow-safe form as the AVX2 path: `(v >> 6) + ((v as u16 >> 5) & 1)`.
889        // Avoids the i16-overflow that would happen with `(v + 32) >> 6` for v near
890        // `i16::MAX` and matches the wider intermediate that NEON `vrshrq_n_s16` uses.
891        let load_norm_clamp = |p: *const i16| -> v128 {
892            let v = v128_load(p as *const v128);
893            let high = i16x8_shr(v, 6);
894            let bit5 = v128_and(u16x8_shr(v, 5), one);
895            let n = i16x8_add(high, bit5);
896            i16x8_max(i16x8_min(n, n_max), n_min)
897        };
898        let yc = load_norm_clamp(yp.add(off));
899        let cbc = load_norm_clamp(cbp.add(off));
900        let crc = load_norm_clamp(crp.add(off));
901
902        // Same i16 arithmetic as NEON / AVX2 — all intermediates fit in i16.
903        let y128 = i16x8_add(yc, c128);
904        let t2 = i16x8_add(crc, i16x8_shr(crc, 1));
905        let t3 = i16x8_sub(y128, i16x8_shr(cbc, 2));
906        let r16 = i16x8_add(y128, t2);
907        let g16 = i16x8_sub(t3, i16x8_shr(t2, 1));
908        let b16 = i16x8_add(t3, i16x8_shl(cbc, 1));
909
910        // Saturating signed→unsigned narrow: i16x8 → u8x16 (clamps to [0, 255]).
911        // Pack two i16x8 vectors into one u8x16 in a single op — exactly NEON's
912        // `vqmovun_s16` semantics, just in the wider 16-lane form.
913        let v_rg = u8x16_narrow_i16x8(r16, g16);
914        let v_ba = u8x16_narrow_i16x8(b16, alpha_src);
915
916        // Interleave to RGBA: pixel n = (r_n, g_n, b_n, a_n).
917        // v_rg lanes: 0..7 = r, 8..15 = g. v_ba lanes: 0..7 = b, 8..15 = 255.
918        // Constant byte-shuffle picks {r_n=v_rg[n], g_n=v_rg[n+8], b_n=v_ba[n+0], a_n=v_ba[n+8]}.
919        let out0 =
920            i8x16_shuffle::<0, 8, 16, 24, 1, 9, 17, 25, 2, 10, 18, 26, 3, 11, 19, 27>(v_rg, v_ba);
921        let out1 =
922            i8x16_shuffle::<4, 12, 20, 28, 5, 13, 21, 29, 6, 14, 22, 30, 7, 15, 23, 31>(v_rg, v_ba);
923
924        v128_store(outp.add(off * 4) as *mut v128, out0);
925        v128_store(outp.add(off * 4 + 16) as *mut v128, out1);
926    }
927    // Scalar tail
928    for col in (full8 * 8)..w {
929        let y = normalize(*yp.add(col));
930        let b = normalize(*cbp.add(col));
931        let r = normalize(*crp.add(col));
932        let t2 = r + (r >> 1);
933        let t3 = y + 128 - (b >> 2);
934        *outp.add(col * 4) = (y + 128 + t2).clamp(0, 255) as u8;
935        *outp.add(col * 4 + 1) = (t3 - (t2 >> 1)).clamp(0, 255) as u8;
936        *outp.add(col * 4 + 2) = (t3 + (b << 1)).clamp(0, 255) as u8;
937        *outp.add(col * 4 + 3) = 255;
938    }
939}
940
941/// WASM simd128 fused normalize + YCbCr→RGBA, chroma-half variant.
942///
943/// 8 luma + 4 chroma per iteration. Chroma is loaded as 8 bytes via
944/// `v128_load64_zero` (low half = 4 i16, high half = 0), normalized at
945/// i16 width across all 8 lanes (high lanes normalize to 0, unused), and
946/// nearest-neighbour upsampled to 8 lanes via a constant byte shuffle that
947/// duplicates each of the low 4 i16 lanes (`[a,b,c,d,_,_,_,_]` → `[a,a,b,b,c,c,d,d]`).
948#[cfg(target_arch = "wasm32")]
949#[allow(unsafe_code, unsafe_op_in_unsafe_fn, dead_code)]
950#[target_feature(enable = "simd128")]
951unsafe fn ycbcr_simd128_raw_half(
952    yp: *const i16,
953    cbp: *const i16,
954    crp: *const i16,
955    outp: *mut u8,
956    w: usize,
957) {
958    use core::arch::wasm32::*;
959    let n_min = i16x8_splat(-128);
960    let n_max = i16x8_splat(127);
961    let c128 = i16x8_splat(128);
962    let one = i16x8_splat(1);
963    let alpha_src = i16x8_splat(255);
964
965    let full8 = w / 8;
966    for i in 0..full8 {
967        let off = i * 8;
968        let c_off = i * 4;
969        // Y: full 8-lane load + normalize (same as non-half path).
970        let load_norm_clamp = |p: *const i16| -> v128 {
971            let v = v128_load(p as *const v128);
972            let high = i16x8_shr(v, 6);
973            let bit5 = v128_and(u16x8_shr(v, 5), one);
974            let n = i16x8_add(high, bit5);
975            i16x8_max(i16x8_min(n, n_max), n_min)
976        };
977        let yc = load_norm_clamp(yp.add(off));
978
979        // Chroma: load 4 i16 = 8 bytes into low half of v128, zero upper half.
980        // Normalize on the full vector (upper 4 lanes normalize to 0, harmless).
981        let load_norm_chroma_4 = |p: *const i16| -> v128 {
982            let v = v128_load64_zero(p as *const u64);
983            let high = i16x8_shr(v, 6);
984            let bit5 = v128_and(u16x8_shr(v, 5), one);
985            let n = i16x8_add(high, bit5);
986            i16x8_max(i16x8_min(n, n_max), n_min)
987        };
988        let cb4 = load_norm_chroma_4(cbp.add(c_off));
989        let cr4 = load_norm_chroma_4(crp.add(c_off));
990
991        // Upsample each i16 lane into a pair (`zip-low` of self+self).
992        // Byte-level shuffle: bytes 0,1 → 0,1,2,3 ; 2,3 → 4,5,6,7 ; etc.
993        let cbc = i8x16_shuffle::<0, 1, 0, 1, 2, 3, 2, 3, 4, 5, 4, 5, 6, 7, 6, 7>(cb4, cb4);
994        let crc = i8x16_shuffle::<0, 1, 0, 1, 2, 3, 2, 3, 4, 5, 4, 5, 6, 7, 6, 7>(cr4, cr4);
995
996        let y128 = i16x8_add(yc, c128);
997        let t2 = i16x8_add(crc, i16x8_shr(crc, 1));
998        let t3 = i16x8_sub(y128, i16x8_shr(cbc, 2));
999        let r16 = i16x8_add(y128, t2);
1000        let g16 = i16x8_sub(t3, i16x8_shr(t2, 1));
1001        let b16 = i16x8_add(t3, i16x8_shl(cbc, 1));
1002
1003        let v_rg = u8x16_narrow_i16x8(r16, g16);
1004        let v_ba = u8x16_narrow_i16x8(b16, alpha_src);
1005        let out0 =
1006            i8x16_shuffle::<0, 8, 16, 24, 1, 9, 17, 25, 2, 10, 18, 26, 3, 11, 19, 27>(v_rg, v_ba);
1007        let out1 =
1008            i8x16_shuffle::<4, 12, 20, 28, 5, 13, 21, 29, 6, 14, 22, 30, 7, 15, 23, 31>(v_rg, v_ba);
1009        v128_store(outp.add(off * 4) as *mut v128, out0);
1010        v128_store(outp.add(off * 4 + 16) as *mut v128, out1);
1011    }
1012    for col in (full8 * 8)..w {
1013        let y = normalize(*yp.add(col));
1014        let b = normalize(*cbp.add(col / 2));
1015        let r = normalize(*crp.add(col / 2));
1016        let t2 = r + (r >> 1);
1017        let t3 = y + 128 - (b >> 2);
1018        *outp.add(col * 4) = (y + 128 + t2).clamp(0, 255) as u8;
1019        *outp.add(col * 4 + 1) = (t3 - (t2 >> 1)).clamp(0, 255) as u8;
1020        *outp.add(col * 4 + 2) = (t3 + (b << 1)).clamp(0, 255) as u8;
1021        *outp.add(col * 4 + 3) = 255;
1022    }
1023}
1024
1025/// AArch64 NEON: 6× vld1q_s32 + SIMD arithmetic + vst4_u8 per 8 pixels.
1026/// Replaces 80+ bounds-check branches per 8 pixels in the LLVM-generated portable code.
1027#[cfg(target_arch = "aarch64")]
1028#[allow(unsafe_code, unsafe_op_in_unsafe_fn)]
1029#[target_feature(enable = "neon")]
1030unsafe fn ycbcr_neon(yp: *const i32, cbp: *const i32, crp: *const i32, outp: *mut u8, w: usize) {
1031    use core::arch::aarch64::*;
1032    let c128 = vdupq_n_s32(128);
1033    let c0 = vdupq_n_s32(0);
1034    let c255 = vdupq_n_s32(255);
1035    let alpha = vdup_n_u8(255);
1036
1037    let full8 = w / 8;
1038    for i in 0..full8 {
1039        let off = i * 8;
1040        // Load 8 × i32 from each channel (2 × vld1q_s32 = one cache line per channel)
1041        let y_lo = vld1q_s32(yp.add(off));
1042        let y_hi = vld1q_s32(yp.add(off + 4));
1043        let cb_lo = vld1q_s32(cbp.add(off));
1044        let cb_hi = vld1q_s32(cbp.add(off + 4));
1045        let cr_lo = vld1q_s32(crp.add(off));
1046        let cr_hi = vld1q_s32(crp.add(off + 4));
1047
1048        // t2 = cr + (cr >> 1)
1049        let t2_lo = vaddq_s32(cr_lo, vshrq_n_s32::<1>(cr_lo));
1050        let t2_hi = vaddq_s32(cr_hi, vshrq_n_s32::<1>(cr_hi));
1051        // t3 = y + 128 - (cb >> 2)
1052        let t3_lo = vsubq_s32(vaddq_s32(y_lo, c128), vshrq_n_s32::<2>(cb_lo));
1053        let t3_hi = vsubq_s32(vaddq_s32(y_hi, c128), vshrq_n_s32::<2>(cb_hi));
1054
1055        // red = clamp(y + 128 + t2)
1056        let r_lo = vminq_s32(vmaxq_s32(vaddq_s32(vaddq_s32(y_lo, c128), t2_lo), c0), c255);
1057        let r_hi = vminq_s32(vmaxq_s32(vaddq_s32(vaddq_s32(y_hi, c128), t2_hi), c0), c255);
1058        // green = clamp(t3 - (t2 >> 1))
1059        let g_lo = vminq_s32(
1060            vmaxq_s32(vsubq_s32(t3_lo, vshrq_n_s32::<1>(t2_lo)), c0),
1061            c255,
1062        );
1063        let g_hi = vminq_s32(
1064            vmaxq_s32(vsubq_s32(t3_hi, vshrq_n_s32::<1>(t2_hi)), c0),
1065            c255,
1066        );
1067        // blue = clamp(t3 + (cb << 1))
1068        let b_lo = vminq_s32(
1069            vmaxq_s32(vaddq_s32(t3_lo, vshlq_n_s32::<1>(cb_lo)), c0),
1070            c255,
1071        );
1072        let b_hi = vminq_s32(
1073            vmaxq_s32(vaddq_s32(t3_hi, vshlq_n_s32::<1>(cb_hi)), c0),
1074            c255,
1075        );
1076
1077        // Narrow i32×4 → i16×4 → u8×8 for each channel
1078        let r8 = vqmovun_s16(vcombine_s16(vmovn_s32(r_lo), vmovn_s32(r_hi)));
1079        let g8 = vqmovun_s16(vcombine_s16(vmovn_s32(g_lo), vmovn_s32(g_hi)));
1080        let b8 = vqmovun_s16(vcombine_s16(vmovn_s32(b_lo), vmovn_s32(b_hi)));
1081
1082        // Store 8 RGBA pixels (32 bytes) interleaved via vst4_u8
1083        vst4_u8(outp.add(off * 4), uint8x8x4_t(r8, g8, b8, alpha));
1084    }
1085
1086    // Scalar tail
1087    for col in (full8 * 8)..w {
1088        let y = *yp.add(col);
1089        let b = *cbp.add(col);
1090        let r = *crp.add(col);
1091        let t2 = r + (r >> 1);
1092        let t3 = y + 128 - (b >> 2);
1093        *outp.add(col * 4) = (y + 128 + t2).clamp(0, 255) as u8;
1094        *outp.add(col * 4 + 1) = (t3 - (t2 >> 1)).clamp(0, 255) as u8;
1095        *outp.add(col * 4 + 2) = (t3 + (b << 1)).clamp(0, 255) as u8;
1096        *outp.add(col * 4 + 3) = 255;
1097    }
1098}
1099
1100// ---- Per-channel wavelet decoder --------------------------------------------
1101
1102/// State for a single YCbCr plane wavelet decoder.
1103///
1104/// Holds 32×32 block coefficients and the ZP context tables that persist
1105/// across progressive slices.
1106#[derive(Clone, Debug)]
1107struct PlaneDecoder {
1108    width: usize,
1109    height: usize,
1110    block_cols: usize,
1111    /// Row-major array of 32×32 blocks. A block addresses 1024 i16
1112    /// coefficients in zigzag-scan order but stores only the buckets it really
1113    /// uses — see [`CoefBlock`].
1114    blocks: Vec<CoefBlock>,
1115    /// Running total of the `CoefBlock::hi` lengths, so [`PlaneDecoder::heap_bytes`]
1116    /// stays O(1) instead of walking every block on each cache-budget query.
1117    hi_len: usize,
1118    quant_lo: [u32; 16],
1119    quant_hi: [u32; 10],
1120    /// Current band index (0..10, wraps around).
1121    curband: usize,
1122    // ZP context bytes — persistent across slices and chunks.
1123    ctx_decode_bucket: [u8; 1],
1124    ctx_decode_coef: [u8; 80],
1125    ctx_activate_coef: [u8; 16],
1126    ctx_increase_coef: [u8; 1],
1127    // Per-block temporary decode state (re-used each block, not persisted).
1128    coeffstate: [[u8; 16]; 16],
1129    bucketstate: [u8; 16],
1130    bbstate: u8,
1131}
1132
1133/// Map one bucket's 16 i16 coefficients to UNK/ACTIVE flags, store in `bucket`,
1134/// and return the OR of all flag bytes (bstatetmp).
1135///
1136/// Dispatches to NEON on aarch64, AVX2 on x86_64 when available, else scalar.
1137#[allow(unsafe_code)]
1138#[inline(always)]
1139fn prelim_flags_bucket(coefs: &[i16; 16], bucket: &mut [u8; 16]) -> u8 {
1140    #[cfg(target_arch = "aarch64")]
1141    // SAFETY: NEON is mandatory on aarch64; `coefs` is exactly 16 i16 wide.
1142    return unsafe { prelim_flags_bucket_neon(coefs, bucket) };
1143
1144    #[cfg(all(target_arch = "x86_64", feature = "std"))]
1145    {
1146        if std::is_x86_feature_detected!("avx2") {
1147            // SAFETY: AVX2 was just feature-detected; `coefs` is exactly 16 i16 wide.
1148            return unsafe { prelim_flags_bucket_avx2(coefs, bucket) };
1149        }
1150    }
1151
1152    #[cfg_attr(target_arch = "aarch64", allow(unreachable_code))]
1153    {
1154        let mut bstate = 0u8;
1155        for k in 0..16 {
1156            let f = if coefs[k] == 0 { UNK } else { ACTIVE };
1157            bucket[k] = f;
1158            bstate |= f;
1159        }
1160        bstate
1161    }
1162}
1163
1164/// NEON-vectorized version of `prelim_flags_bucket` for aarch64.
1165///
1166/// Loads 16 i16 values, compares to zero with NEON, narrows to u8 flags
1167/// (UNK=8 for zero, ACTIVE=2 for non-zero), stores, and OR-reduces to bstatetmp.
1168#[cfg(target_arch = "aarch64")]
1169#[allow(unsafe_code, unsafe_op_in_unsafe_fn)]
1170#[target_feature(enable = "neon")]
1171unsafe fn prelim_flags_bucket_neon(coefs: &[i16; 16], bucket: &mut [u8; 16]) -> u8 {
1172    use core::arch::aarch64::*;
1173    let ptr = coefs.as_ptr();
1174    // Load as u16 — zero-comparison is the same for signed and unsigned 16-bit.
1175    let c0 = vreinterpretq_u16_s16(vld1q_s16(ptr));
1176    let c1 = vreinterpretq_u16_s16(vld1q_s16(ptr.add(8)));
1177    // nz: 0xFFFF where coef != 0, 0x0000 where coef == 0
1178    let zero = vdupq_n_u16(0);
1179    let nz0 = vmvnq_u16(vceqq_u16(c0, zero));
1180    let nz1 = vmvnq_u16(vceqq_u16(c1, zero));
1181    // result = UNK ^ ((UNK ^ ACTIVE) & nz)  ⟹  UNK(8) if zero, ACTIVE(2) if nonzero
1182    // UNK ^ ACTIVE = 8 ^ 2 = 10
1183    let xv = vdupq_n_u16(10);
1184    let uv = vdupq_n_u16(8);
1185    let r0 = veorq_u16(uv, vandq_u16(xv, nz0));
1186    let r1 = veorq_u16(uv, vandq_u16(xv, nz1));
1187    // Narrow u16 → u8 (values 2 and 8 both fit; high byte of each lane is 0)
1188    let out = vcombine_u8(vmovn_u16(r0), vmovn_u16(r1));
1189    vst1q_u8(bucket.as_mut_ptr(), out);
1190    // Horizontal OR: fold 16 u8 lanes to 1
1191    let lo = vget_low_u8(out);
1192    let hi = vget_high_u8(out);
1193    let v4 = vorr_u8(lo, hi);
1194    let v2 = vorr_u8(v4, vext_u8::<4>(v4, v4));
1195    let v1 = vorr_u8(v2, vext_u8::<2>(v2, v2));
1196    let v0 = vorr_u8(v1, vext_u8::<1>(v1, v1));
1197    vget_lane_u8::<0>(v0)
1198}
1199
1200/// AVX2-vectorized version of `prelim_flags_bucket` for x86_64.
1201///
1202/// Loads 16 i16 in one `__m256i`, compares to zero with `_mm256_cmpeq_epi16`,
1203/// builds UNK/ACTIVE flags via `uv ^ (xv & nz)` where UNK=8 and XV=10
1204/// (= UNK ^ ACTIVE), narrows to 16 u8 with `_mm_packus_epi16` (saturating but
1205/// values 2/8 fit), stores via `_mm_storeu_si128`, and horizontally OR-reduces
1206/// the 16 bytes to one byte via shift+OR.
1207///
1208/// Mirror of `prelim_flags_bucket_neon` — same operations, AVX2 lanes.
1209#[cfg(all(target_arch = "x86_64", feature = "std"))]
1210#[allow(unsafe_code, unsafe_op_in_unsafe_fn)]
1211#[target_feature(enable = "avx2")]
1212unsafe fn prelim_flags_bucket_avx2(coefs: &[i16; 16], bucket: &mut [u8; 16]) -> u8 {
1213    use core::arch::x86_64::*;
1214    // Load the bucket's 16 contiguous i16 (32 bytes).
1215    let coefs = _mm256_loadu_si256(coefs.as_ptr() as *const __m256i);
1216    // eq: 0xFFFF where coef == 0, 0x0000 where != 0.
1217    let zero = _mm256_setzero_si256();
1218    let eq = _mm256_cmpeq_epi16(coefs, zero);
1219    // nz = !eq.  cmpeq(x, x) == all-ones.
1220    let all_ones = _mm256_cmpeq_epi16(zero, zero);
1221    let nz = _mm256_xor_si256(eq, all_ones);
1222    // result = UNK ^ ((UNK ^ ACTIVE) & nz)  ⟹  UNK(8) if zero, ACTIVE(2) if nonzero.
1223    let xv = _mm256_set1_epi16(10);
1224    let uv = _mm256_set1_epi16(8);
1225    let r16 = _mm256_xor_si256(uv, _mm256_and_si256(xv, nz));
1226    // Narrow u16 → u8: pack the two 128-bit halves.  `_mm_packus_epi16` saturates
1227    // to [0, 255] but our values are 2 or 8 — equivalent to truncation here.
1228    let r_lo = _mm256_castsi256_si128(r16);
1229    let r_hi = _mm256_extracti128_si256::<1>(r16);
1230    let packed = _mm_packus_epi16(r_lo, r_hi);
1231    _mm_storeu_si128(bucket.as_mut_ptr() as *mut __m128i, packed);
1232    // Horizontal OR of 16 u8 lanes → 1 byte via successive shift+OR.
1233    let or64 = _mm_or_si128(packed, _mm_unpackhi_epi64(packed, packed));
1234    let or32 = _mm_or_si128(or64, _mm_srli_si128::<4>(or64));
1235    let or16_red = _mm_or_si128(or32, _mm_srli_si128::<2>(or32));
1236    let or8 = _mm_or_si128(or16_red, _mm_srli_si128::<1>(or16_red));
1237    _mm_extract_epi8::<0>(or8) as u8
1238}
1239
1240/// NEON-vectorized band-0 path of `preliminary_flag_computation`.
1241///
1242/// Band 0 differs from bands 1-9: only update entries where `old_flags[k] != ZERO (1)`.
1243/// Uses `vbslq_u8` to blend new flags (UNK/ACTIVE from coef) with old flags (keep ZERO).
1244#[cfg(target_arch = "aarch64")]
1245#[allow(unsafe_code, unsafe_op_in_unsafe_fn)]
1246#[target_feature(enable = "neon")]
1247unsafe fn prelim_flags_band0_neon(block: &[i16; 16], old_flags: &mut [u8; 16]) -> u8 {
1248    use core::arch::aarch64::*;
1249    // Load old coeffstate[0] (u8 flags: ZERO=1, UNK=8, ACTIVE=2).
1250    let old_u8 = vld1q_u8(old_flags.as_ptr());
1251    // should_update mask: 0xFF where old_flags[k] != ZERO(1), 0x00 where == ZERO
1252    let one_u8 = vdupq_n_u8(1);
1253    let is_zero_state = vceqq_u8(old_u8, one_u8); // 0xFF where ZERO, 0x00 elsewhere
1254    let should_update = vmvnq_u8(is_zero_state); // 0xFF where not-ZERO
1255    // Compute new flags from first 16 coefs (same as prelim_flags_bucket_neon with base=0).
1256    let ptr = block.as_ptr();
1257    let c0 = vreinterpretq_u16_s16(vld1q_s16(ptr));
1258    let c1 = vreinterpretq_u16_s16(vld1q_s16(ptr.add(8)));
1259    let zero16 = vdupq_n_u16(0);
1260    let nz0 = vmvnq_u16(vceqq_u16(c0, zero16));
1261    let nz1 = vmvnq_u16(vceqq_u16(c1, zero16));
1262    let xv = vdupq_n_u16(10); // UNK ^ ACTIVE = 10
1263    let uv = vdupq_n_u16(8); // UNK = 8
1264    let r0 = veorq_u16(uv, vandq_u16(xv, nz0));
1265    let r1 = veorq_u16(uv, vandq_u16(xv, nz1));
1266    let new_flags = vcombine_u8(vmovn_u16(r0), vmovn_u16(r1));
1267    // Blend: where should_update, take new_flags; where ZERO state, keep old.
1268    let result = vbslq_u8(should_update, new_flags, old_u8);
1269    vst1q_u8(old_flags.as_mut_ptr(), result);
1270    // Horizontal OR of final flags for bstatetmp.
1271    let lo = vget_low_u8(result);
1272    let hi = vget_high_u8(result);
1273    let v4 = vorr_u8(lo, hi);
1274    let v2 = vorr_u8(v4, vext_u8::<4>(v4, v4));
1275    let v1 = vorr_u8(v2, vext_u8::<2>(v2, v2));
1276    let v0 = vorr_u8(v1, vext_u8::<1>(v1, v1));
1277    vget_lane_u8::<0>(v0)
1278}
1279
1280/// AVX2-vectorized band-0 path of `preliminary_flag_computation` for x86_64.
1281///
1282/// Mirror of `prelim_flags_band0_neon`: only updates entries where
1283/// `old_flags[k] != ZERO(1)`; uses an SSE2 blend (`(new & m) | (old & ~m)`)
1284/// for the conditional-write step that NEON does with `vbslq_u8`.
1285#[cfg(all(target_arch = "x86_64", feature = "std"))]
1286#[allow(unsafe_code, unsafe_op_in_unsafe_fn)]
1287#[target_feature(enable = "avx2")]
1288unsafe fn prelim_flags_band0_avx2(block: &[i16; 16], old_flags: &mut [u8; 16]) -> u8 {
1289    use core::arch::x86_64::*;
1290    // Load old coeffstate[0] (16 u8 flags: ZERO=1, UNK=8, ACTIVE=2).
1291    let old_u8 = _mm_loadu_si128(old_flags.as_ptr() as *const __m128i);
1292    // should_update mask: 0xFF where old_flags[k] != ZERO(1), 0x00 where == ZERO.
1293    let one_u8 = _mm_set1_epi8(1);
1294    let is_zero_state = _mm_cmpeq_epi8(old_u8, one_u8);
1295    let all_ones_128 = _mm_cmpeq_epi8(old_u8, old_u8);
1296    let should_update = _mm_xor_si128(is_zero_state, all_ones_128);
1297
1298    // Compute new flags from first 16 coefs (same recipe as prelim_flags_bucket_avx2 with base=0).
1299    let coefs = _mm256_loadu_si256(block.as_ptr() as *const __m256i);
1300    let zero = _mm256_setzero_si256();
1301    let eq = _mm256_cmpeq_epi16(coefs, zero);
1302    let all_ones_256 = _mm256_cmpeq_epi16(zero, zero);
1303    let nz = _mm256_xor_si256(eq, all_ones_256);
1304    let xv = _mm256_set1_epi16(10);
1305    let uv = _mm256_set1_epi16(8);
1306    let r16 = _mm256_xor_si256(uv, _mm256_and_si256(xv, nz));
1307    let r_lo = _mm256_castsi256_si128(r16);
1308    let r_hi = _mm256_extracti128_si256::<1>(r16);
1309    let new_flags = _mm_packus_epi16(r_lo, r_hi);
1310
1311    // Blend: (new & should_update) | (old & ~should_update).
1312    let blended = _mm_or_si128(
1313        _mm_and_si128(should_update, new_flags),
1314        _mm_andnot_si128(should_update, old_u8),
1315    );
1316    _mm_storeu_si128(old_flags.as_mut_ptr() as *mut __m128i, blended);
1317
1318    // Horizontal OR of 16 u8 lanes → 1 byte (same reduction as the bucket path).
1319    let or64 = _mm_or_si128(blended, _mm_unpackhi_epi64(blended, blended));
1320    let or32 = _mm_or_si128(or64, _mm_srli_si128::<4>(or64));
1321    let or16_red = _mm_or_si128(or32, _mm_srli_si128::<2>(or32));
1322    let or8 = _mm_or_si128(or16_red, _mm_srli_si128::<1>(or16_red));
1323    _mm_extract_epi8::<0>(or8) as u8
1324}
1325
1326/// Dispatcher for the band-0 path of `preliminary_flag_computation`.
1327///
1328/// Picks NEON on aarch64, AVX2 on x86_64 when available, scalar otherwise.
1329#[allow(unsafe_code)]
1330#[inline(always)]
1331fn band0_dispatch(block: &[i16; 16], old_flags: &mut [u8; 16]) -> u8 {
1332    #[cfg(target_arch = "aarch64")]
1333    // SAFETY: NEON always available on aarch64; block[0..16] valid by construction.
1334    return unsafe { prelim_flags_band0_neon(block, old_flags) };
1335
1336    #[cfg(all(target_arch = "x86_64", feature = "std"))]
1337    {
1338        if std::is_x86_feature_detected!("avx2") {
1339            // SAFETY: AVX2 was just feature-detected; block[0..16] valid by construction.
1340            return unsafe { prelim_flags_band0_avx2(block, old_flags) };
1341        }
1342    }
1343
1344    #[cfg_attr(target_arch = "aarch64", allow(unreachable_code))]
1345    {
1346        let mut b = 0u8;
1347        for k in 0..16 {
1348            if old_flags[k] != ZERO {
1349                old_flags[k] = if block[k] == 0 { UNK } else { ACTIVE };
1350            }
1351            b |= old_flags[k];
1352        }
1353        b
1354    }
1355}
1356
1357/// A bucket that was never written. Reading an absent bucket yields this —
1358/// exactly what a zero-filled one held before (see [`CoefBlock`]).
1359const ZERO_BUCKET: [i16; 16] = [0; 16];
1360
1361/// One 32x32 IW44 coefficient block, stored as the prefix of buckets that
1362/// actually holds data.
1363///
1364/// A block is 1024 coefficients in zigzag order, grouped into 64 buckets of 16.
1365/// Storing all of them costs a flat 2 KB per block whatever the image really
1366/// contains: 124 MB for one plane of a 6780x9148 page, and three such planes
1367/// made a single-page thumbnail peak at 383 MB. Almost all of it is zeros —
1368/// measured on that page, a complete four-chunk decode leaves **9.3 %** of the
1369/// luma buckets non-zero and **1.6 %** of the chroma ones; a first-chunk
1370/// preview leaves 3.5 % (PERF_EXPERIMENTS.md IW44_SPARSE_BLOCKS).
1371///
1372/// A block only ever needs a *prefix* of its buckets, so `lo` holds bucket 0
1373/// inline — every block has one — and `hi` covers buckets `1..=n`, growing on
1374/// the first write above bucket 0. An absent bucket reads as zero, which is
1375/// what a never-written bucket already held, so the decoder's behaviour is
1376/// unchanged: the UNK/ACTIVE flags it derives depend only on whether a
1377/// coefficient is zero.
1378#[derive(Clone, Debug, Default)]
1379pub(crate) struct CoefBlock {
1380    /// Bucket 0 — coefficients 0..16. Always present.
1381    lo: [i16; 16],
1382    /// Buckets 1..=n — coefficients 16..16*(n+1). Empty until a coefficient
1383    /// above bucket 0 is written; `len()` is always a multiple of 16.
1384    hi: Vec<i16>,
1385}
1386
1387impl CoefBlock {
1388    /// Coefficient `i` in zigzag order; zero when its bucket is absent.
1389    #[inline(always)]
1390    pub(crate) fn coef(&self, i: usize) -> i16 {
1391        if i < 16 {
1392            self.lo[i]
1393        } else {
1394            self.hi.get(i - 16).copied().unwrap_or(0)
1395        }
1396    }
1397
1398    /// Bucket `b`'s 16 coefficients, or [`ZERO_BUCKET`] when absent.
1399    #[inline(always)]
1400    pub(crate) fn bucket(&self, b: usize) -> &[i16; 16] {
1401        if b == 0 {
1402            return &self.lo;
1403        }
1404        let off = (b - 1) * 16;
1405        match self.hi.get(off..off + 16) {
1406            // `expect` cannot fire: the slice is 16 long by construction.
1407            Some(s) => s.try_into().expect("16-wide bucket slice"),
1408            None => &ZERO_BUCKET,
1409        }
1410    }
1411
1412    /// Make buckets `0..=top` exist, and report how many coefficients that added.
1413    ///
1414    /// Growth is per band, not per bucket: a block is walked bucket by bucket in
1415    /// band order, so growing to the bucket asked for meant up to 64
1416    /// reallocations per block and cost 10 % on `iw44_decode_first_chunk`.
1417    #[inline]
1418    pub(crate) fn grow_through(&mut self, top: usize) -> usize {
1419        let need = top * 16;
1420        if self.hi.len() >= need {
1421            return 0;
1422        }
1423        let grow = need - self.hi.len();
1424        self.hi.reserve_exact(grow);
1425        self.hi.resize(need, 0);
1426        grow
1427    }
1428
1429    /// Bucket `b`, which must already exist (see [`grow_through`](Self::grow_through)).
1430    #[inline]
1431    pub(crate) fn bucket_mut(&mut self, b: usize) -> &mut [i16; 16] {
1432        if b == 0 {
1433            return &mut self.lo;
1434        }
1435        let off = (b - 1) * 16;
1436        // `expect` cannot fire: the slice is 16 long by construction.
1437        <&mut [i16; 16]>::try_from(&mut self.hi[off..off + 16]).expect("16-wide bucket slice")
1438    }
1439
1440    /// Bucket `b` when it exists, else `None` — for a caller that knows an
1441    /// absent bucket has nothing to do.
1442    #[inline]
1443    pub(crate) fn bucket_mut_if_present(&mut self, b: usize) -> Option<&mut [i16; 16]> {
1444        if b == 0 {
1445            return Some(&mut self.lo);
1446        }
1447        let off = (b - 1) * 16;
1448        let s = self.hi.get_mut(off..off + 16)?;
1449        Some(<&mut [i16; 16]>::try_from(s).expect("16-wide bucket slice"))
1450    }
1451
1452    /// Copy this block's coefficients `0..n` into `out`, zero-filling the rest.
1453    ///
1454    /// `reconstruct` scatters coefficients by zigzag index in a tight loop, so
1455    /// it materialises the prefix it needs once per block rather than paying
1456    /// [`coef`](Self::coef)'s bounds test per coefficient.
1457    #[inline]
1458    fn materialize(&self, out: &mut [i16]) {
1459        let n = out.len();
1460        let lo = n.min(16);
1461        out[..lo].copy_from_slice(&self.lo[..lo]);
1462        if n > 16 {
1463            let hi = self.hi.len().min(n - 16);
1464            out[16..16 + hi].copy_from_slice(&self.hi[..hi]);
1465            out[16 + hi..].fill(0);
1466        }
1467    }
1468}
1469
1470impl PlaneDecoder {
1471    /// Heap bytes held by this plane's coefficient array: the block index plus
1472    /// the buckets that were actually written (see [`CoefBlock`]).
1473    fn heap_bytes(&self) -> usize {
1474        self.blocks.capacity() * core::mem::size_of::<CoefBlock>()
1475            + self.hi_len * core::mem::size_of::<i16>()
1476    }
1477
1478    /// Bucket `b` of block `block_idx`, growing the block to reach it.
1479    ///
1480    /// Every write above bucket 0 goes through here so `hi_len` stays exact.
1481    #[inline]
1482    fn bucket_mut(&mut self, block_idx: usize, b: usize) -> &mut [i16; 16] {
1483        // Grow to the end of the band `b` belongs to, not to `b` itself. A
1484        // block is walked bucket by bucket in band order, so growing per bucket
1485        // meant up to 64 reallocations per block and cost 10 % on
1486        // `iw44_decode_first_chunk`; per band there are at most 10. The band is
1487        // also the natural unit: the passes that follow read every bucket in it.
1488        let band_top = BAND_BUCKETS[self.curband].1;
1489        debug_assert!(
1490            b >= BAND_BUCKETS[self.curband].0 && b <= band_top,
1491            "bucket {b} is outside band {}",
1492            self.curband
1493        );
1494        let block = &mut self.blocks[block_idx];
1495        self.hi_len += block.grow_through(band_top);
1496        block.bucket_mut(b)
1497    }
1498
1499    fn new(width: usize, height: usize) -> Self {
1500        let block_cols = width.div_ceil(32);
1501        let block_rows = height.div_ceil(32);
1502        let block_count = block_cols * block_rows;
1503        PlaneDecoder {
1504            width,
1505            height,
1506            block_cols,
1507            blocks: vec![CoefBlock::default(); block_count],
1508            hi_len: 0,
1509            quant_lo: QUANT_LO_INIT,
1510            quant_hi: QUANT_HI_INIT,
1511            curband: 0,
1512            ctx_decode_bucket: [0; 1],
1513            ctx_decode_coef: [0; 80],
1514            ctx_activate_coef: [0; 16],
1515            ctx_increase_coef: [0; 1],
1516            coeffstate: [[0; 16]; 16],
1517            bucketstate: [0; 16],
1518            bbstate: 0,
1519        }
1520    }
1521
1522    /// Decode one slice (one band across all blocks) from `zp`.
1523    fn decode_slice(&mut self, zp: &mut ZpDecoder<'_>) {
1524        if !self.is_null_slice() {
1525            for block_idx in 0..self.blocks.len() {
1526                self.preliminary_flag_computation(block_idx);
1527                if self.block_band_decoding_pass(zp) && self.bucket_decoding_pass(zp, block_idx) {
1528                    self.newly_active_coefficient_decoding_pass(zp, block_idx);
1529                }
1530                // Skip the inner loop entirely when no ACTIVE coefficients exist
1531                // (avoids function call + zp register flush for fresh/sparse blocks).
1532                if (self.bbstate & ACTIVE) != 0 {
1533                    self.previously_active_coefficient_decoding_pass(zp, block_idx);
1534                }
1535            }
1536        }
1537        self.finish_slice();
1538    }
1539
1540    fn is_null_slice(&mut self) -> bool {
1541        if self.curband == 0 {
1542            let mut is_null = true;
1543            for i in 0..16 {
1544                let threshold = self.quant_lo[i];
1545                self.coeffstate[0][i] = ZERO;
1546                if threshold > 0 && threshold < 0x8000 {
1547                    self.coeffstate[0][i] = UNK;
1548                    is_null = false;
1549                }
1550            }
1551            is_null
1552        } else {
1553            let threshold = self.quant_hi[self.curband];
1554            !(threshold > 0 && threshold < 0x8000)
1555        }
1556    }
1557
1558    fn preliminary_flag_computation(&mut self, block_idx: usize) {
1559        self.bbstate = 0;
1560        let (from, to) = BAND_BUCKETS[self.curband];
1561
1562        if self.curband != 0 {
1563            // The band's buckets are consecutive in the block's tail, so resolve
1564            // the block and its tail length once instead of per bucket: this
1565            // loop runs for every band of every block and the indexing showed up
1566            // as a few percent on `iw44_decode_first_chunk`.
1567            let hi = &self.blocks[block_idx].hi[..];
1568            for (boff, j) in (from..=to).enumerate() {
1569                let off = (j - 1) * 16;
1570                let coefs = match hi.get(off..off + 16) {
1571                    Some(s) => <&[i16; 16]>::try_from(s).expect("16-wide bucket slice"),
1572                    None => &ZERO_BUCKET,
1573                };
1574                let bstatetmp = prelim_flags_bucket(coefs, &mut self.coeffstate[boff]);
1575                self.bucketstate[boff] = bstatetmp;
1576                self.bbstate |= bstatetmp;
1577            }
1578        } else {
1579            let bstatetmp =
1580                band0_dispatch(self.blocks[block_idx].bucket(0), &mut self.coeffstate[0]);
1581            self.bucketstate[0] = bstatetmp;
1582            self.bbstate |= bstatetmp;
1583        }
1584    }
1585
1586    fn block_band_decoding_pass(&mut self, zp: &mut ZpDecoder<'_>) -> bool {
1587        let (from, to) = BAND_BUCKETS[self.curband];
1588        let bcount = to - from + 1;
1589        let should_mark_new = bcount < 16
1590            || (self.bbstate & ACTIVE) != 0
1591            || ((self.bbstate & UNK) != 0 && zp.decode_bit(&mut self.ctx_decode_bucket[0]));
1592        if should_mark_new {
1593            self.bbstate |= NEW;
1594        }
1595        (self.bbstate & NEW) != 0
1596    }
1597
1598    /// Returns `true` if any bucket was newly marked active (NEW bit set).
1599    fn bucket_decoding_pass(&mut self, zp: &mut ZpDecoder<'_>, block_idx: usize) -> bool {
1600        let (from, to) = BAND_BUCKETS[self.curband];
1601        let mut any_new = false;
1602        for (boff, i) in (from..=to).enumerate() {
1603            if (self.bucketstate[boff] & UNK) == 0 {
1604                continue;
1605            }
1606            let mut n: usize = 0;
1607            if self.curband != 0 {
1608                let t = 4 * i;
1609                for j in t..t + 4 {
1610                    if self.blocks[block_idx].coef(j) != 0 {
1611                        n += 1;
1612                    }
1613                }
1614                if n == 4 {
1615                    n = 3;
1616                }
1617            }
1618            if (self.bbstate & ACTIVE) != 0 {
1619                n |= 4;
1620            }
1621            if zp.decode_bit(&mut self.ctx_decode_coef[n + self.curband * 8]) {
1622                self.bucketstate[boff] |= NEW;
1623                any_new = true;
1624            }
1625        }
1626        any_new
1627    }
1628
1629    fn newly_active_coefficient_decoding_pass(&mut self, zp: &mut ZpDecoder<'_>, block_idx: usize) {
1630        let (from, to) = BAND_BUCKETS[self.curband];
1631        let mut step = self.quant_hi[self.curband];
1632        for (boff, i) in (from..=to).enumerate() {
1633            if (self.bucketstate[boff] & NEW) != 0 {
1634                let shift: usize = if (self.bucketstate[boff] & ACTIVE) != 0 {
1635                    8
1636                } else {
1637                    0
1638                };
1639                let mut np: usize = 0;
1640                for j in 0..16 {
1641                    if (self.coeffstate[boff][j] & UNK) != 0 {
1642                        np += 1;
1643                    }
1644                }
1645                for j in 0..16 {
1646                    if (self.coeffstate[boff][j] & UNK) != 0 {
1647                        let ip = np.min(7);
1648                        if zp.decode_bit(&mut self.ctx_activate_coef[shift + ip]) {
1649                            let sign = if zp.decode_passthrough_iw44() {
1650                                -1i32
1651                            } else {
1652                                1i32
1653                            };
1654                            np = 0;
1655                            if self.curband == 0 {
1656                                step = self.quant_lo[j];
1657                            }
1658                            let s = step as i32;
1659                            let val = sign * (s + (s >> 1) - (s >> 3));
1660                            self.bucket_mut(block_idx, i)[j] = val as i16;
1661                        }
1662                        np = np.saturating_sub(1);
1663                    }
1664                }
1665            }
1666        }
1667    }
1668
1669    /// Hot inner loop for refining already-active coefficients.
1670    ///
1671    /// Uses local copies of all ZP state fields so LLVM can keep them in
1672    /// registers for the duration of the double-loop, avoiding struct-pointer
1673    /// round-trips on every `decode_bit` / `decode_passthrough_iw44` call.
1674    #[inline(never)]
1675    fn previously_active_coefficient_decoding_pass(
1676        &mut self,
1677        zp: &mut ZpDecoder<'_>,
1678        block_idx: usize,
1679    ) {
1680        use djvu_zp::tables::{LPS_NEXT, MPS_NEXT, PROB, THRESHOLD};
1681
1682        // Extract ZP state to true stack-locals — LLVM keeps these in registers.
1683        let mut a = zp.a;
1684        let mut c = zp.c;
1685        let mut fence = zp.fence;
1686        let mut bit_buf = zp.bit_buf;
1687        let mut bit_count = zp.bit_count;
1688        let data = zp.data;
1689        let mut pos = zp.pos;
1690
1691        macro_rules! read_byte {
1692            () => {{
1693                let b = if pos < data.len() { data[pos] } else { 0xff };
1694                pos = pos.wrapping_add(1);
1695                b as u32
1696            }};
1697        }
1698        macro_rules! refill {
1699            () => {
1700                while bit_count <= 24 {
1701                    bit_buf = (bit_buf << 8) | read_byte!();
1702                    bit_count += 8;
1703                }
1704            };
1705        }
1706        macro_rules! renorm {
1707            () => {{
1708                let shift = (a as u16).leading_ones();
1709                bit_count -= shift as i32;
1710                a = (a << shift) & 0xffff;
1711                let mask = (1u32 << (shift & 31)).wrapping_sub(1);
1712                c = ((c << shift) | (bit_buf >> (bit_count as u32 & 31)) & mask) & 0xffff;
1713                if bit_count < 16 {
1714                    refill!();
1715                }
1716                fence = c.min(0x7fff);
1717            }};
1718        }
1719        // Decode one bit using an adaptive context byte.
1720        macro_rules! decode_bit_ctx {
1721            ($ctx:expr) => {{
1722                let state = ($ctx) as usize;
1723                let mps_bit = state & 1;
1724                let z = a + PROB[state] as u32;
1725                if z <= fence {
1726                    a = z;
1727                    mps_bit != 0
1728                } else {
1729                    let boundary = 0x6000u32 + ((a + z) >> 2);
1730                    let z_clamped = z.min(boundary);
1731                    if z_clamped > c {
1732                        let complement = 0x10000u32 - z_clamped;
1733                        a = (a + complement) & 0xffff;
1734                        c = (c + complement) & 0xffff;
1735                        $ctx = LPS_NEXT[state];
1736                        renorm!();
1737                        (1 - mps_bit) != 0
1738                    } else {
1739                        if a >= THRESHOLD[state] as u32 {
1740                            $ctx = MPS_NEXT[state];
1741                        }
1742                        bit_count -= 1;
1743                        a = (z_clamped << 1) & 0xffff;
1744                        c = ((c << 1) | (bit_buf >> (bit_count as u32 & 31)) & 1) & 0xffff;
1745                        if bit_count < 16 {
1746                            refill!();
1747                        }
1748                        fence = c.min(0x7fff);
1749                        mps_bit != 0
1750                    }
1751                }
1752            }};
1753        }
1754        // Decode one bit in IW44 passthrough mode (threshold = 0x8000 + 3a/8).
1755        macro_rules! decode_passthrough_iw44 {
1756            () => {{
1757                let z = (0x8000u32 + (3u32 * a) / 8) as u16;
1758                if z as u32 > c {
1759                    let complement = 0x10000u32 - z as u32;
1760                    a = (a + complement) & 0xffff;
1761                    c = (c + complement) & 0xffff;
1762                    renorm!();
1763                    true
1764                } else {
1765                    bit_count -= 1;
1766                    a = (z as u32 * 2) & 0xffff;
1767                    c = (c << 1 | (bit_buf >> (bit_count as u32 & 31)) & 1) & 0xffff;
1768                    if bit_count < 16 {
1769                        refill!();
1770                    }
1771                    fence = c.min(0x7fff);
1772                    false
1773                }
1774            }};
1775        }
1776
1777        let (from, to) = BAND_BUCKETS[self.curband];
1778        let mut step = self.quant_hi[self.curband];
1779        for (boff, i) in (from..=to).enumerate() {
1780            // An ACTIVE coefficient is by definition non-zero, so its bucket
1781            // was written and `bucket_mut` never grows the block here. Skipping
1782            // buckets with none also skips that call entirely.
1783            // An ACTIVE coefficient is by definition non-zero, so its bucket
1784            // was written. An absent bucket therefore has nothing to refine.
1785            let bucket = match self.blocks[block_idx].bucket_mut_if_present(i) {
1786                Some(b) => b,
1787                None => continue,
1788            };
1789            let flags = &self.coeffstate[boff];
1790            for (j, slot) in bucket.iter_mut().enumerate() {
1791                if (flags[j] & ACTIVE) != 0 {
1792                    if self.curband == 0 {
1793                        step = self.quant_lo[j];
1794                    }
1795                    let coef = *slot;
1796                    let mut abs_coef = coef.unsigned_abs() as i32;
1797                    let s = step as i32;
1798                    let des = if abs_coef <= 3 * s {
1799                        let d = decode_bit_ctx!(self.ctx_increase_coef[0]);
1800                        abs_coef += s >> 2;
1801                        d
1802                    } else {
1803                        decode_passthrough_iw44!()
1804                    };
1805                    if des {
1806                        abs_coef += s >> 1;
1807                    } else {
1808                        abs_coef += -s + (s >> 1);
1809                    }
1810                    *slot = if coef < 0 {
1811                        -abs_coef as i16
1812                    } else {
1813                        abs_coef as i16
1814                    };
1815                }
1816            }
1817        }
1818
1819        // Write back ZP state so subsequent calls see the updated arithmetic.
1820        zp.a = a;
1821        zp.c = c;
1822        zp.fence = fence;
1823        zp.bit_buf = bit_buf;
1824        zp.bit_count = bit_count;
1825        zp.pos = pos;
1826    }
1827
1828    /// Advance quantization step and band counter after one slice.
1829    fn finish_slice(&mut self) {
1830        self.quant_hi[self.curband] >>= 1;
1831        if self.curband == 0 {
1832            for i in 0..16 {
1833                self.quant_lo[i] >>= 1;
1834            }
1835        }
1836        self.curband += 1;
1837        if self.curband == 10 {
1838            self.curband = 0;
1839        }
1840    }
1841
1842    /// Apply the inverse wavelet transform and return a flat `i16` array.
1843    ///
1844    /// The returned vector is row-major, with stride = `width.div_ceil(32)*32`.
1845    /// `subsample` ≥ 1 controls the resolution (1 = full, 2 = half, etc.).
1846    fn reconstruct(&self, subsample: usize) -> FlatPlane {
1847        // ── Fast path for sub≥2: compact plane ────────────────────────────────
1848        //
1849        // For subsample=2 the wavelet only ever reads/writes (even_row, even_col)
1850        // positions — those with zigzag index i < 256 (see zigzag_row/col: both
1851        // are even iff bits 8 and 9 of i are 0).  We can therefore:
1852        //   1. Allocate a 4× smaller plane  (ceil(w/2) × ceil(h/2))
1853        //   2. Scatter only the sub_block² low-frequency coefficients per block
1854        //      (zigzag indices 0..sub_block² map to even multiples of sub)
1855        //   3. Run the full wavelet (sub=1) on the compact plane, which now
1856        //      includes the SIMD s=1 pass.
1857        //
1858        // This is equivalent to running the wavelet at sub=2 on the full plane
1859        // and sampling every other position: each compact[k][c] equals the value
1860        // that full[k·sub][c·sub] would hold after the sub=2 wavelet.
1861        //
1862        // The same logic holds for sub=4 (8×8 sub-block) and sub=8 (4×4 sub-block).
1863        if (2..=8).contains(&subsample) && subsample.is_power_of_two() {
1864            let sub = subsample;
1865
1866            // Block structure: the compact plane inherits the same block grid but
1867            // each 32×32 block contributes a (32/sub)×(32/sub) sub-block.
1868            let block_rows = self.height.div_ceil(32);
1869            let sub_block = 32 / sub; // 16 for sub=2, 8 for sub=4, 4 for sub=8
1870
1871            // Compact plane dimensions, aligned to the sub-block width.
1872            let compact_stride = self.block_cols * sub_block;
1873            let compact_rows = block_rows * sub_block;
1874            // Logical image dimensions at the target resolution.
1875            let compact_w = self.width.div_ceil(sub);
1876            let compact_h = self.height.div_ceil(sub);
1877
1878            // Safety: zigzag_row(i)/sub × zigzag_col(i)/sub for i in 0..sub_block²
1879            // is a bijection over [0..sub_block) × [0..sub_block) (bits 8/9 of i are
1880            // 0 → both zigzag values are even; dividing by sub tiles all sub_block²
1881            // positions per block → every element is written before the wavelet reads).
1882            #[allow(unsafe_code)]
1883            let mut plane = FlatPlane {
1884                data: unsafe { uninit_i16_vec(compact_stride * compact_rows) },
1885                stride: compact_stride,
1886            };
1887
1888            // Row-major scatter via compact inverse zigzag tables: write
1889            // sub_block consecutive i16 per row before advancing, maximising
1890            // write-combine efficiency (one cache line per row for sub=2).
1891            // Safety invariants for get_unchecked below:
1892            //   inv: inv_base+col = row*sub_block+col, row,col ∈ 0..sub_block → < sub_block²
1893            //        = compact_inv.len(); block[i]: compact_inv values < sub_block² ≤ 256
1894            //        < 1024 = block.len(); plane[dst_base+col]: sequential within
1895            //        (base_row+row)*compact_stride+base_col+[0,sub_block) — all in bounds.
1896            let compact_inv: &[u8] = match sub {
1897                2 => &ZIGZAG_INV_SUB2,
1898                4 => &ZIGZAG_INV_SUB4,
1899                _ => &ZIGZAG_INV_SUB8, // sub=8
1900            };
1901            // The compact tables only ever name zigzag indices < sub_block², so
1902            // each block is materialised into that prefix once (see
1903            // `CoefBlock::materialize`) and the scatter below stays a tight
1904            // read of a contiguous array.
1905            let mut prefix = [0i16; 256];
1906            #[allow(unsafe_code)]
1907            for r in 0..block_rows {
1908                for c in 0..self.block_cols {
1909                    let block = &self.blocks[r * self.block_cols + c];
1910                    block.materialize(&mut prefix[..sub_block * sub_block]);
1911                    let base_row = r * sub_block;
1912                    let base_col = c * sub_block;
1913                    for row in 0..sub_block {
1914                        let dst_base = (base_row + row) * compact_stride + base_col;
1915                        let inv_base = row * sub_block;
1916                        for col in 0..sub_block {
1917                            // Safety: see invariants above.
1918                            let i = unsafe { *compact_inv.get_unchecked(inv_base + col) } as usize;
1919                            unsafe {
1920                                *plane.data.get_unchecked_mut(dst_base + col) =
1921                                    *prefix.get_unchecked(i);
1922                            }
1923                        }
1924                    }
1925                }
1926            }
1927
1928            // Run the wavelet on the compact plane starting at scale 16/sub.
1929            // compact s=k ↔ full s=k·sub, so the coarsest valid pass is
1930            // s = 16/sub (e.g. s=8 for sub=2).  Starting at s=16 would add a
1931            // spurious pass with no coefficients and introduce rounding noise.
1932            let start_scale = 16 / sub;
1933            inverse_wavelet_transform_from(&mut plane, compact_w, compact_h, 1, start_scale);
1934            return plane;
1935        }
1936
1937        // ── Default path (sub=1, or non-power-of-two sub) ─────────────────────
1938        let full_width = self.width.div_ceil(32) * 32;
1939        let full_height = self.height.div_ceil(32) * 32;
1940        let block_rows = self.height.div_ceil(32);
1941        // Safety: ZIGZAG_ROW/COL for i in 0..1024 is a bijection over [0..32)×[0..32)
1942        // (odd-indexed bits → row, even-indexed bits → col, non-overlapping). The
1943        // scatter below writes every element before the wavelet reads any of them.
1944        #[allow(unsafe_code)]
1945        let mut plane = FlatPlane {
1946            data: unsafe { uninit_i16_vec(full_width * full_height) },
1947            stride: full_width,
1948        };
1949
1950        // Row-major scatter via ZIGZAG_INV: write 32 consecutive i16 per row
1951        // (= 1 cache line) before advancing, maximising write-combine efficiency.
1952        // block[ZIGZAG_INV[row*32+col]] is a gathered read from a 2 KB array
1953        // that fits in L1, so the scatter cost is minimal.
1954        let mut full = [0i16; 1024];
1955        for r in 0..block_rows {
1956            for c in 0..self.block_cols {
1957                self.blocks[r * self.block_cols + c].materialize(&mut full);
1958                let row_base = r << 5;
1959                let col_base = c << 5;
1960                for row in 0..32usize {
1961                    let dst_base = (row_base + row) * full_width + col_base;
1962                    let inv_base = row * 32;
1963                    for col in 0..32usize {
1964                        let i = ZIGZAG_INV[inv_base + col] as usize;
1965                        plane.data[dst_base + col] = full[i];
1966                    }
1967                }
1968            }
1969        }
1970
1971        inverse_wavelet_transform(&mut plane, self.width, self.height, subsample);
1972        plane
1973    }
1974
1975    /// Reconstruct a horizontal band of the full-resolution plane.
1976    ///
1977    /// The band covers block rows `[first_block, last_block)`; the returned
1978    /// plane's row 0 is the image's absolute row `first_block * 32`, and its
1979    /// stride is the same as `reconstruct(1)` would give.
1980    ///
1981    /// The inverse wavelet couples rows: a pass at scale `s` reads three
1982    /// samples either side, so an output row depends on rows up to
1983    /// `3 * (16 + 8 + 4 + 2 + 1) = 93` away, doubled to `186` by the two
1984    /// lifting stages of each pass. A caller therefore asks for more block rows
1985    /// than it keeps — see [`BAND_HALO_BLOCKS`] — and uses only the interior.
1986    /// The edges of the band carry the transform's own boundary handling, which
1987    /// is correct only where the band edge is the image edge.
1988    ///
1989    /// Callers must keep `first_block` on a block boundary, which is what makes
1990    /// the band's row coordinates agree with the full plane's on every scale:
1991    /// 32 is a multiple of the coarsest pass's 16.
1992    fn reconstruct_band(&self, first_block: usize, last_block: usize) -> FlatPlane {
1993        debug_assert!(first_block < last_block);
1994        let block_rows = self.height.div_ceil(32);
1995        let last_block = last_block.min(block_rows);
1996        let full_width = self.width.div_ceil(32) * 32;
1997        let band_rows = (last_block - first_block) * 32;
1998
1999        // Safety: as in `reconstruct` — the scatter below writes every element
2000        // before the wavelet reads any of them.
2001        #[allow(unsafe_code)]
2002        let mut plane = FlatPlane {
2003            data: unsafe { uninit_i16_vec(full_width * band_rows) },
2004            stride: full_width,
2005        };
2006
2007        let mut full = [0i16; 1024];
2008        for r in first_block..last_block {
2009            for c in 0..self.block_cols {
2010                self.blocks[r * self.block_cols + c].materialize(&mut full);
2011                let row_base = (r - first_block) << 5;
2012                let col_base = c << 5;
2013                for row in 0..32usize {
2014                    let dst_base = (row_base + row) * full_width + col_base;
2015                    let inv_base = row * 32;
2016                    for col in 0..32usize {
2017                        let i = ZIGZAG_INV[inv_base + col] as usize;
2018                        plane.data[dst_base + col] = full[i];
2019                    }
2020                }
2021            }
2022        }
2023
2024        // The logical height decides where the transform applies its boundary
2025        // handling. A band that reaches the bottom of the image must report the
2026        // image's own remaining height, so that boundary is the real one.
2027        let logical = if last_block == block_rows {
2028            self.height - first_block * 32
2029        } else {
2030            band_rows
2031        };
2032        inverse_wavelet_transform(&mut plane, self.width, logical, 1);
2033        plane
2034    }
2035}
2036
2037/// Reconstruct the three colour planes.
2038///
2039/// With the `parallel` feature the three independent inverse wavelet
2040/// transforms run concurrently on separate rayon threads, cutting the
2041/// reconstruction wall time from Y+Cb+Cr sequential to max(Y, Cb, Cr) —
2042/// roughly 1.5-2x faster on large pages, where Y dominates.
2043fn reconstruct_planes(
2044    y_dec: &PlaneDecoder,
2045    cb_dec: &PlaneDecoder,
2046    cr_dec: &PlaneDecoder,
2047    sub: usize,
2048    chroma_sub: usize,
2049) -> (FlatPlane, FlatPlane, FlatPlane) {
2050    #[cfg(feature = "parallel")]
2051    {
2052        let (y, (cb, cr)) = rayon::join(
2053            || y_dec.reconstruct(sub),
2054            || {
2055                rayon::join(
2056                    || cb_dec.reconstruct(chroma_sub),
2057                    || cr_dec.reconstruct(chroma_sub),
2058                )
2059            },
2060        );
2061        (y, cb, cr)
2062    }
2063    #[cfg(not(feature = "parallel"))]
2064    {
2065        (
2066            y_dec.reconstruct(sub),
2067            cb_dec.reconstruct(chroma_sub),
2068            cr_dec.reconstruct(chroma_sub),
2069        )
2070    }
2071}
2072
2073/// Write image rows `rows` of a full-resolution colour page into `out`.
2074///
2075/// `out` holds exactly those rows as RGBA, top to bottom. The planes need not
2076/// cover the whole image: `y_row0` and `c_row0` say which image row each
2077/// plane's row 0 holds, which is what lets a banded caller pass a slice of the
2078/// page. DjVu stores rows bottom-to-top, so image row `r` is the output row
2079/// `ph - 1 - r` of the whole picture, and the first row of `out`.
2080#[allow(clippy::too_many_arguments)]
2081fn convert_rgb_rows(
2082    chroma_half: bool,
2083    y: &FlatPlane,
2084    y_row0: usize,
2085    cb: &FlatPlane,
2086    cr: &FlatPlane,
2087    c_row0: usize,
2088    rows: core::ops::Range<usize>,
2089    pw: usize,
2090    ph: usize,
2091    out: &mut [u8],
2092) {
2093    // #422: half-resolution chroma dimensions, for bilinear upsampling.
2094    let cw = pw.div_ceil(2);
2095    let ch = ph.div_ceil(2);
2096    let out_lo = ph - rows.end;
2097    debug_assert_eq!(out.len(), rows.len() * pw * 4);
2098
2099    #[cfg(feature = "parallel")]
2100    {
2101        use rayon::prelude::*;
2102        out.par_chunks_mut(pw * 4)
2103            .enumerate()
2104            .for_each(|(i, row_data)| {
2105                let row = ph - 1 - (out_lo + i); // DjVu rows are bottom-to-top
2106                let y_off = (row - y_row0) * y.stride;
2107                if chroma_half {
2108                    let c0 = row / 2;
2109                    let c1 = (c0 + 1).min(ch - 1);
2110                    let v_blend = row & 1 == 1;
2111                    let mut cb_full = vec![0i16; pw];
2112                    let mut cr_full = vec![0i16; pw];
2113                    upsample_chroma_row_bilinear(
2114                        &cb.data[(c0 - c_row0) * cb.stride..],
2115                        &cb.data[(c1 - c_row0) * cb.stride..],
2116                        v_blend,
2117                        &mut cb_full,
2118                        cw,
2119                    );
2120                    upsample_chroma_row_bilinear(
2121                        &cr.data[(c0 - c_row0) * cr.stride..],
2122                        &cr.data[(c1 - c_row0) * cr.stride..],
2123                        v_blend,
2124                        &mut cr_full,
2125                        cw,
2126                    );
2127                    ycbcr_row_from_i16(&y.data[y_off..y_off + pw], &cb_full, &cr_full, row_data);
2128                } else {
2129                    let c_off = (row - c_row0) * cb.stride;
2130                    ycbcr_row_from_i16(
2131                        &y.data[y_off..y_off + pw],
2132                        &cb.data[c_off..c_off + pw],
2133                        &cr.data[c_off..c_off + pw],
2134                        row_data,
2135                    );
2136                }
2137            });
2138    }
2139    #[cfg(not(feature = "parallel"))]
2140    {
2141        let mut cb_full = vec![0i16; pw];
2142        let mut cr_full = vec![0i16; pw];
2143        for (i, row_data) in out.chunks_mut(pw * 4).enumerate() {
2144            let row = ph - 1 - (out_lo + i); // DjVu rows are bottom-to-top
2145            let y_off = (row - y_row0) * y.stride;
2146            if chroma_half {
2147                let c0 = row / 2;
2148                let c1 = (c0 + 1).min(ch - 1);
2149                let v_blend = row & 1 == 1;
2150                upsample_chroma_row_bilinear(
2151                    &cb.data[(c0 - c_row0) * cb.stride..],
2152                    &cb.data[(c1 - c_row0) * cb.stride..],
2153                    v_blend,
2154                    &mut cb_full,
2155                    cw,
2156                );
2157                upsample_chroma_row_bilinear(
2158                    &cr.data[(c0 - c_row0) * cr.stride..],
2159                    &cr.data[(c1 - c_row0) * cr.stride..],
2160                    v_blend,
2161                    &mut cr_full,
2162                    cw,
2163                );
2164                ycbcr_row_from_i16(&y.data[y_off..y_off + pw], &cb_full, &cr_full, row_data);
2165            } else {
2166                let c_off = (row - c_row0) * cb.stride;
2167                ycbcr_row_from_i16(
2168                    &y.data[y_off..y_off + pw],
2169                    &cb.data[c_off..c_off + pw],
2170                    &cr.data[c_off..c_off + pw],
2171                    row_data,
2172                );
2173            }
2174        }
2175    }
2176}
2177
2178/// Planes smaller than this reconstruct whole: banding would cost work and
2179/// save memory nobody is short of. 128 MiB is about a 4600x4600 colour page.
2180const BAND_MIN_PLANE_BYTES: usize = 128 * 1024 * 1024;
2181
2182/// What one band of planes may cost. A band holds its kept rows plus a halo on
2183/// each side, so this is the real working set, not the kept part.
2184const BAND_BUDGET_BYTES: usize = 128 * 1024 * 1024;
2185
2186/// The smallest band worth keeping: four halos, so the doubled halo work is at
2187/// most half of the band's own.
2188const BAND_MIN_KEEP_BLOCKS: usize = 4 * BAND_HALO_BLOCKS;
2189
2190/// How many block rows one band keeps, or `None` to reconstruct whole planes.
2191///
2192/// Banding trades work for memory. The halo rows are transformed twice, so a
2193/// band keeping `k` block rows does `(k + 2 * BAND_HALO_BLOCKS) / k` of the
2194/// whole-plane work. Only a page whose planes are genuinely large is worth
2195/// that; under [`BAND_MIN_PLANE_BYTES`] the whole-plane path runs exactly as
2196/// it did before.
2197///
2198/// `out_bytes_per_px` is what the caller keeps per kept pixel beside the
2199/// planes: 0 when the RGB goes into a picture that exists anyway, 4 when the
2200/// caller holds one band of RGB rows and nothing else (#811). Those bytes come
2201/// out of the same budget, so such a band keeps fewer block rows.
2202fn band_keep_blocks(
2203    y_dec: &PlaneDecoder,
2204    chroma_half: bool,
2205    out_bytes_per_px: usize,
2206) -> Option<usize> {
2207    let stride = y_dec.width.div_ceil(32) * 32;
2208    // Bytes the three planes hold per luma row. Luma is two bytes a pixel; the
2209    // two chroma planes add two more each, or one more together when chroma is
2210    // stored at half resolution in both directions.
2211    let per_row = if chroma_half { stride * 3 } else { stride * 6 };
2212    let block_rows = y_dec.height.div_ceil(32);
2213    if per_row.saturating_mul(block_rows * 32) <= BAND_MIN_PLANE_BYTES {
2214        return None;
2215    }
2216    let per_block_row = per_row * 32;
2217    // The halos are pure plane rows; every kept block row also carries the
2218    // caller's output bytes.
2219    let halo_bytes = 2 * BAND_HALO_BLOCKS * per_block_row;
2220    let per_kept_block_row = per_block_row + stride * 32 * out_bytes_per_px;
2221    let affordable = BAND_BUDGET_BYTES.saturating_sub(halo_bytes) / per_kept_block_row;
2222    let keep = affordable.max(BAND_MIN_KEEP_BLOCKS);
2223    // Band only when a band really is a part of the page. A `keep` just under
2224    // `block_rows` would split the page into two bands that each carry almost
2225    // all of it: the halo work doubles and the memory saving is nearly zero.
2226    // Half the page is the point where the saving pays for the extra pass.
2227    (keep * 2 <= block_rows).then_some(keep)
2228}
2229
2230/// Block rows of overlap a band needs on each side before its interior is
2231/// exact. The transform's vertical reach is 186 rows (see
2232/// [`PlaneDecoder::reconstruct_band`]); 8 block rows is 256, the next block
2233/// multiple above it with margin to spare.
2234const BAND_HALO_BLOCKS: usize = 8;
2235
2236// ---- Flat plane helper -------------------------------------------------------
2237
2238/// Allocate `n` uninitialized `i16` elements.
2239///
2240/// Uses `Vec<MaybeUninit<i16>>` (the clippy-blessed pattern) and reinterprets
2241/// as `Vec<i16>`.
2242///
2243/// # Safety
2244/// Caller must write every element before reading it.
2245#[allow(unsafe_code)]
2246unsafe fn uninit_i16_vec(n: usize) -> Vec<i16> {
2247    use core::mem::MaybeUninit;
2248    let mut v: Vec<MaybeUninit<i16>> = Vec::with_capacity(n);
2249    // Safety: MaybeUninit<i16> requires no initialization; len will equal capacity.
2250    unsafe { v.set_len(n) };
2251    let mut md = core::mem::ManuallyDrop::new(v);
2252    // Safety: MaybeUninit<i16> and i16 have identical layout; capacity unchanged.
2253    unsafe { Vec::from_raw_parts(md.as_mut_ptr().cast::<i16>(), md.len(), md.capacity()) }
2254}
2255
2256struct FlatPlane {
2257    data: Vec<i16>,
2258    stride: usize,
2259}
2260
2261// ---- Inverse Dubuc-Deslauriers-Lemire (4,4) wavelet transform ---------------
2262//
2263// Two passes per resolution level:
2264//   1. Column pass (lifting + prediction along rows of subsampled columns)
2265//   2. Row pass (lifting + prediction along columns of subsampled rows)
2266//
2267// The column pass is transposed for cache efficiency.
2268//
2269// When `s == 1` (the final, highest-resolution level) the column indices are
2270// contiguous, so we can process 8 columns per iteration using `wide::i32x8`.
2271
2272use wide::i32x8;
2273
2274/// Load 8 `i16` values at stride `s` starting at `slice[phys_off]`.
2275///
2276/// Reads `slice[phys_off + j*s]` for j = 0..7. For s=1 this is identical to
2277/// [`load8`]. For s=2 and s=4 the AArch64 path uses `ld2`/`ld4` to deinterleave
2278/// in a single instruction; other targets use scalar loads that LLVM may
2279/// auto-vectorize.
2280#[inline(always)]
2281fn load8s(slice: &[i16], phys_off: usize, s: usize) -> i32x8 {
2282    // s=1 fast path: single contiguous load + sign-extend.  Checked FIRST so that
2283    // the s=1 branch is a single cmp+b (not taken on s≠1) rather than a 5-branch
2284    // dispatch chain inside load8s_neon.
2285    if s == 1 {
2286        // x86_64 + AVX2 enabled at compile time: `vpmovsxwd ymm, [mem]` is one
2287        // instruction (movdqu + vpmovsxwd, fused on most µarchs). Compile-time
2288        // gating keeps the hot loop branch-free; runtime detection in this loop
2289        // would dominate the kernel.
2290        #[cfg(all(target_arch = "x86_64", target_feature = "avx2"))]
2291        {
2292            #[allow(unsafe_code)]
2293            return unsafe { load8s_s1_avx2(slice, phys_off) };
2294        }
2295        // WASM simd128 compile-time path: `i32x4.extend_low/high_i16x8_s` sign-extends
2296        // 8×i16 → 8×i32 in two 128-bit ops, avoiding 8 scalar cast+store pairs.
2297        // On WASM, `wide::i32x8` is `{a: i32x4, b: i32x4}` where each `i32x4` is
2298        // `repr(transparent)` over `v128`, so [lo, hi]: [v128; 2] transmutes cleanly.
2299        #[cfg(all(target_arch = "wasm32", target_feature = "simd128"))]
2300        {
2301            #[allow(unsafe_code)]
2302            return unsafe { load8s_s1_simd128(slice, phys_off) };
2303        }
2304        #[allow(unsafe_code, unreachable_code)]
2305        return unsafe {
2306            // SAFETY: caller ensures phys_off+7 < slice.len().
2307            let arr: [i16; 8] = core::ptr::read(slice.as_ptr().add(phys_off) as *const [i16; 8]);
2308            i32x8::from([
2309                arr[0] as i32,
2310                arr[1] as i32,
2311                arr[2] as i32,
2312                arr[3] as i32,
2313                arr[4] as i32,
2314                arr[5] as i32,
2315                arr[6] as i32,
2316                arr[7] as i32,
2317            ])
2318        };
2319    }
2320    #[cfg(target_arch = "aarch64")]
2321    if s == 2 || s == 4 {
2322        #[allow(unsafe_code)]
2323        return unsafe { load8s_neon(slice, phys_off, s) };
2324    }
2325    i32x8::from([
2326        slice[phys_off] as i32,
2327        slice[phys_off + s] as i32,
2328        slice[phys_off + 2 * s] as i32,
2329        slice[phys_off + 3 * s] as i32,
2330        slice[phys_off + 4 * s] as i32,
2331        slice[phys_off + 5 * s] as i32,
2332        slice[phys_off + 6 * s] as i32,
2333        slice[phys_off + 7 * s] as i32,
2334    ])
2335}
2336
2337/// Store 8 `i32x8` values (truncated to `i16`) at stride `s` starting at `slice[phys_off]`.
2338///
2339/// Writes `slice[phys_off + j*s] = v[j] as i16` for j = 0..7. Interleaved positions
2340/// (those not at multiples of `s`) are left unchanged.
2341#[inline(always)]
2342fn store8s(slice: &mut [i16], phys_off: usize, s: usize, v: i32x8) {
2343    // s=1 fast path: narrow and store contiguously.  Same reasoning as load8s.
2344    if s == 1 {
2345        #[cfg(all(target_arch = "x86_64", target_feature = "avx2"))]
2346        {
2347            #[allow(unsafe_code)]
2348            return unsafe { store8s_s1_avx2(slice, phys_off, v) };
2349        }
2350        // WASM simd128: byte-shuffle to pack the low halfword of each i32 lane into
2351        // a contiguous i16x8.  Indices 0,1,4,5,8,9,12,13 pick bytes 0-1 of each 4-byte
2352        // i32 from the low half (lo), and indices 16,17,20,21,24,25,28,29 do the same
2353        // for the high half (hi).  This matches the truncating `as i16` semantics
2354        // (not saturating narrow) and mirrors the AVX2 byte-shuffle approach.
2355        #[cfg(all(target_arch = "wasm32", target_feature = "simd128"))]
2356        {
2357            #[allow(unsafe_code)]
2358            return unsafe { store8s_s1_simd128(slice, phys_off, v) };
2359        }
2360        #[allow(unsafe_code, unreachable_code)]
2361        return unsafe {
2362            // SAFETY: caller ensures phys_off+7 < slice.len().
2363            let a = v.to_array();
2364            let narrow: [i16; 8] = [
2365                a[0] as i16,
2366                a[1] as i16,
2367                a[2] as i16,
2368                a[3] as i16,
2369                a[4] as i16,
2370                a[5] as i16,
2371                a[6] as i16,
2372                a[7] as i16,
2373            ];
2374            core::ptr::write(slice.as_mut_ptr().add(phys_off) as *mut [i16; 8], narrow);
2375        };
2376    }
2377    #[cfg(target_arch = "aarch64")]
2378    if s == 2 || s == 4 {
2379        #[allow(unsafe_code)]
2380        return unsafe { store8s_neon(slice, phys_off, s, v) };
2381    }
2382    let a = v.to_array();
2383    for j in 0..8 {
2384        slice[phys_off + j * s] = a[j] as i16;
2385    }
2386}
2387
2388// ---- AArch64 NEON stride load/store -----------------------------------------
2389//
2390// ld2 deinterleaves 16 consecutive i16s into two vectors (even, odd).
2391// ld4 deinterleaves 32 consecutive i16s into four vectors.
2392// After widening the target lane to i32, `lifting_even` / `predict_inner`
2393// run on i32x8 exactly as for s=1.
2394// On store, we re-interleave the updated even lane with the unchanged odd lanes.
2395
2396#[cfg(target_arch = "aarch64")]
2397// s=1 is now handled directly in load8s/store8s (single ldr/str q without dispatch).
2398// This function only needs to handle s=2 and s=4.
2399#[allow(unsafe_code, unsafe_op_in_unsafe_fn)]
2400#[target_feature(enable = "neon")]
2401unsafe fn load8s_neon(slice: &[i16], phys_off: usize, s: usize) -> i32x8 {
2402    use core::arch::aarch64::*;
2403    let ptr = slice.as_ptr().add(phys_off);
2404    let target: int16x8_t = if s == 2 {
2405        vld2q_s16(ptr).0
2406    } else {
2407        // s == 4
2408        vld4q_s16(ptr).0
2409    };
2410    // Widen i16x8 → two i32x4, then reinterpret as [i32;8] → i32x8
2411    let lo = vmovl_s16(vget_low_s16(target));
2412    let hi = vmovl_high_s16(target);
2413    let arr = core::mem::transmute::<[int32x4_t; 2], [i32; 8]>([lo, hi]);
2414    i32x8::from(arr)
2415}
2416
2417// ---- x86_64 AVX2 stride-1 load/store ---------------------------------------
2418//
2419// `vpmovsxwd ymm, [mem]` sign-extends 8×i16 → 8×i32 in one fused load+convert.
2420// Truncating narrow i32x8 → i16x8 has no native AVX2 instruction (the only
2421// pack ops saturate); we emulate it with a per-lane byte shuffle that gathers
2422// the low halfword of each i32 lane, then a 64-bit lane permute to combine
2423// the two 128-bit halves.
2424//
2425// `i32x8` ↔ `__m256i` are layout-compatible on x86_64 with AVX2 enabled
2426// (`wide` uses `__m256i` internally), and the existing `C16: i32x8 = transmute([16i32; 8])`
2427// pattern at line ~1639 already relies on this. Both are 32 bytes.
2428
2429#[cfg(all(target_arch = "x86_64", feature = "std"))]
2430#[allow(unsafe_code, unsafe_op_in_unsafe_fn, dead_code)]
2431#[target_feature(enable = "avx2")]
2432#[inline]
2433unsafe fn load8s_s1_avx2(slice: &[i16], phys_off: usize) -> i32x8 {
2434    use core::arch::x86_64::*;
2435    let ptr = slice.as_ptr().add(phys_off) as *const __m128i;
2436    let v16 = _mm_loadu_si128(ptr);
2437    let v32 = _mm256_cvtepi16_epi32(v16);
2438    let arr: [i32; 8] = core::mem::transmute(v32);
2439    i32x8::from(arr)
2440}
2441
2442#[cfg(all(target_arch = "x86_64", feature = "std"))]
2443#[allow(unsafe_code, unsafe_op_in_unsafe_fn, dead_code)]
2444#[target_feature(enable = "avx2")]
2445#[inline]
2446unsafe fn store8s_s1_avx2(slice: &mut [i16], phys_off: usize, v: i32x8) {
2447    use core::arch::x86_64::*;
2448    let arr: [i32; 8] = v.to_array();
2449    let v32: __m256i = core::mem::transmute(arr);
2450    // Per-lane byte shuffle: pack low halfwords of each i32 into the low 64 bits
2451    // of each 128-bit lane. _mm256_shuffle_epi8 is per-128-bit-lane, so the same
2452    // 16-byte mask applies to both halves.
2453    let shuf = _mm256_setr_epi8(
2454        0, 1, 4, 5, 8, 9, 12, 13, -1, -1, -1, -1, -1, -1, -1, -1, 0, 1, 4, 5, 8, 9, 12, 13, -1, -1,
2455        -1, -1, -1, -1, -1, -1,
2456    );
2457    let shuffled = _mm256_shuffle_epi8(v32, shuf);
2458    // 64-bit lanes after shuffle: [lo_packed | zeros | hi_packed | zeros].
2459    // Permute to bring [lo_packed | hi_packed] into the low 128 bits.
2460    // Imm 0b00_00_10_00 = lane 0 → 0 (lo_packed), lane 1 → 2 (hi_packed).
2461    let permuted = _mm256_permute4x64_epi64::<0b00_00_10_00>(shuffled);
2462    let result = _mm256_castsi256_si128(permuted);
2463    let ptr = slice.as_mut_ptr().add(phys_off) as *mut __m128i;
2464    _mm_storeu_si128(ptr, result);
2465}
2466
2467#[cfg(target_arch = "aarch64")]
2468#[allow(unsafe_code, unsafe_op_in_unsafe_fn)]
2469#[target_feature(enable = "neon")]
2470unsafe fn store8s_neon(slice: &mut [i16], phys_off: usize, s: usize, v: i32x8) {
2471    use core::arch::aarch64::*;
2472    let ptr = slice.as_mut_ptr().add(phys_off);
2473    // Narrow v (i32x8) back to i16x8 via vmovn (truncate low 16 bits)
2474    let v_arr = core::mem::transmute::<[i32; 8], [int32x4_t; 2]>(v.to_array());
2475    let new_vals = vcombine_s16(vmovn_s32(v_arr[0]), vmovn_s32(v_arr[1]));
2476    // For s=2,4: scatter-store 8 i16s to stride-s positions.
2477    // Using 8 individual str h avoids the extra vld2/vld4 that would be needed
2478    // to preserve interleaved odd lanes before a vst2/vst4.
2479    // Each str h targets the same ~16-byte cache region (already hot from load8s).
2480    let a: [i16; 8] = core::mem::transmute(new_vals);
2481    for (j, &val) in a.iter().enumerate() {
2482        *ptr.add(j * s) = val;
2483    }
2484}
2485
2486// ---- WASM simd128 stride-1 load/store ----------------------------------------
2487//
2488// On WASM simd128, `wide::i32x8` compiles to `{a: i32x4, b: i32x4}` where each
2489// `i32x4` is `repr(transparent)` over `v128`.  The struct is `repr(C, align(32))`
2490// so it is memory-compatible with `[v128; 2]` (two consecutive 128-bit values).
2491//
2492// Load: `i32x4.extend_low_i16x8_s` / `i32x4.extend_high_i16x8_s` each produce one
2493// `v128` of 4×i32 from the low/high 4 lanes of an i16x8, sign-extending in a single
2494// WASM instruction (equivalent to `_mm256_cvtepi16_epi32` on AVX2 but in two 128-bit
2495// ops).
2496//
2497// Store: `i8x16_shuffle` with constant mask picks bytes 0,1,4,5,8,9,12,13 from the
2498// low half and 0,1,4,5,8,9,12,13 from the high half (as indices 16..31 into the
2499// second operand), packing the low 2 bytes of each 4-byte i32 lane into a contiguous
2500// 16-byte i16x8.  This is the truncating `as i16` cast (not saturating), matching
2501// the scalar fallback and the AVX2 byte-shuffle approach.
2502
2503#[cfg(all(target_arch = "wasm32", target_feature = "simd128"))]
2504#[allow(unsafe_code, unsafe_op_in_unsafe_fn, dead_code)]
2505#[target_feature(enable = "simd128")]
2506#[inline]
2507unsafe fn load8s_s1_simd128(slice: &[i16], phys_off: usize) -> i32x8 {
2508    use core::arch::wasm32::*;
2509    // Load 8 consecutive i16 (16 bytes) as a v128.
2510    let v16 = v128_load(slice.as_ptr().add(phys_off) as *const v128);
2511    // Sign-extend lower 4 i16 → i32x4 and upper 4 i16 → i32x4.
2512    let lo = i32x4_extend_low_i16x8(v16);
2513    let hi = i32x4_extend_high_i16x8(v16);
2514    // Transmute [v128; 2] → i32x8.  On WASM simd128, i32x8 is {a: i32x4(v128), b: i32x4(v128)}
2515    // (repr(C, align(32))), layout-compatible with two consecutive v128 values.
2516    core::mem::transmute::<[v128; 2], i32x8>([lo, hi])
2517}
2518
2519#[cfg(all(target_arch = "wasm32", target_feature = "simd128"))]
2520#[allow(unsafe_code, unsafe_op_in_unsafe_fn, dead_code)]
2521#[target_feature(enable = "simd128")]
2522#[inline]
2523unsafe fn store8s_s1_simd128(slice: &mut [i16], phys_off: usize, v: i32x8) {
2524    use core::arch::wasm32::*;
2525    // Transmute i32x8 → [v128; 2] (lo = lower 4 lanes, hi = upper 4 lanes).
2526    let [lo, hi]: [v128; 2] = core::mem::transmute(v);
2527    // Pack low halfwords of each i32 lane via constant byte-shuffle.
2528    // Indices 0,1,4,5,8,9,12,13 select bytes 0-1 of lanes 0-3 from `lo` (first operand).
2529    // Indices 16,17,20,21,24,25,28,29 select bytes 0-1 of lanes 0-3 from `hi` (second operand).
2530    // Result is 8 consecutive i16 values, truncating i32→i16 (low 16 bits only).
2531    let out = i8x16_shuffle::<0, 1, 4, 5, 8, 9, 12, 13, 16, 17, 20, 21, 24, 25, 28, 29>(lo, hi);
2532    v128_store(slice.as_mut_ptr().add(phys_off) as *mut v128, out);
2533}
2534
2535/// Load 8 contiguous `i32` values from `slice[off..]` into an `i32x8`.
2536///
2537/// # Safety
2538/// Caller must ensure `off + 7 < slice.len()`.
2539#[inline(always)]
2540#[allow(unsafe_code)]
2541fn load8_i32(slice: &[i32], off: usize) -> i32x8 {
2542    // SAFETY: caller guarantees off+7 is in bounds.
2543    unsafe {
2544        i32x8::from([
2545            *slice.get_unchecked(off),
2546            *slice.get_unchecked(off + 1),
2547            *slice.get_unchecked(off + 2),
2548            *slice.get_unchecked(off + 3),
2549            *slice.get_unchecked(off + 4),
2550            *slice.get_unchecked(off + 5),
2551            *slice.get_unchecked(off + 6),
2552            *slice.get_unchecked(off + 7),
2553        ])
2554    }
2555}
2556
2557/// Store 8 values from an `i32x8` into contiguous `i32` slots at `slice[off..]`.
2558///
2559/// # Safety
2560/// Caller must ensure `off + 7 < slice.len()`.
2561#[inline(always)]
2562#[allow(unsafe_code)]
2563fn store8_i32(slice: &mut [i32], off: usize, v: i32x8) {
2564    let a = v.to_array();
2565    // SAFETY: caller guarantees off+7 is in bounds.
2566    unsafe {
2567        *slice.get_unchecked_mut(off) = a[0];
2568        *slice.get_unchecked_mut(off + 1) = a[1];
2569        *slice.get_unchecked_mut(off + 2) = a[2];
2570        *slice.get_unchecked_mut(off + 3) = a[3];
2571        *slice.get_unchecked_mut(off + 4) = a[4];
2572        *slice.get_unchecked_mut(off + 5) = a[5];
2573        *slice.get_unchecked_mut(off + 6) = a[6];
2574        *slice.get_unchecked_mut(off + 7) = a[7];
2575    }
2576}
2577
2578/// Gather one `i16` value from each of 8 consecutive rows at column index `k`.
2579///
2580/// `offs[i]` is the start offset `row_i * stride` for row `i`.
2581///
2582/// # Safety
2583/// Caller must ensure `offs[i] + k < data.len()` for all `i in 0..8`.
2584#[inline(always)]
2585#[allow(unsafe_code)]
2586fn load_rows8(data: &[i16], offs: &[usize; 8], k: usize) -> i32x8 {
2587    // SAFETY: caller guarantees offs[i]+k is in bounds for all i.
2588    unsafe {
2589        i32x8::from([
2590            *data.get_unchecked(offs[0] + k) as i32,
2591            *data.get_unchecked(offs[1] + k) as i32,
2592            *data.get_unchecked(offs[2] + k) as i32,
2593            *data.get_unchecked(offs[3] + k) as i32,
2594            *data.get_unchecked(offs[4] + k) as i32,
2595            *data.get_unchecked(offs[5] + k) as i32,
2596            *data.get_unchecked(offs[6] + k) as i32,
2597            *data.get_unchecked(offs[7] + k) as i32,
2598        ])
2599    }
2600}
2601
2602/// Scatter one value from `v` to each of 8 consecutive rows at column index `k`.
2603///
2604/// # Safety
2605/// Caller must ensure `offs[i] + k < data.len()` for all `i in 0..8`.
2606#[inline(always)]
2607#[allow(unsafe_code)]
2608fn store_rows8(data: &mut [i16], offs: &[usize; 8], k: usize, v: i32x8) {
2609    let a = v.to_array();
2610    // SAFETY: caller guarantees offs[i]+k is in bounds for all i.
2611    unsafe {
2612        *data.get_unchecked_mut(offs[0] + k) = a[0] as i16;
2613        *data.get_unchecked_mut(offs[1] + k) = a[1] as i16;
2614        *data.get_unchecked_mut(offs[2] + k) = a[2] as i16;
2615        *data.get_unchecked_mut(offs[3] + k) = a[3] as i16;
2616        *data.get_unchecked_mut(offs[4] + k) = a[4] as i16;
2617        *data.get_unchecked_mut(offs[5] + k) = a[5] as i16;
2618        *data.get_unchecked_mut(offs[6] + k) = a[6] as i16;
2619        *data.get_unchecked_mut(offs[7] + k) = a[7] as i16;
2620    }
2621}
2622
2623// Compile-time rounding constants — avoids the `memcpy` call that
2624// `i32x8::splat(N)` generates on AArch64 (LLVM doesn't hoist splat to movi.4s).
2625// SAFETY: [i32; 8] and i32x8 have identical representations (8 × 4-byte i32,
2626// 32-byte size); the transmute is value-preserving.
2627#[allow(unsafe_code)]
2628const C16: i32x8 = unsafe { core::mem::transmute([16i32; 8]) };
2629#[allow(unsafe_code)]
2630const C8: i32x8 = unsafe { core::mem::transmute([8i32; 8]) };
2631#[allow(unsafe_code)]
2632const C1: i32x8 = unsafe { core::mem::transmute([1i32; 8]) };
2633
2634/// Lifting filter: `data[idx] -= ((9*(p1+n1) - (p3+n3) + 16) >> 5)`
2635#[inline(always)]
2636fn lifting_even(cur: i32x8, p1: i32x8, n1: i32x8, p3: i32x8, n3: i32x8) -> i32x8 {
2637    let a = p1 + n1;
2638    let c = p3 + n3;
2639    cur - (((a << 3) + a - c + C16) >> 5)
2640}
2641
2642/// Prediction filter (inner): `data[idx] += ((9*(p1+n1) - (p3+n3) + 8) >> 4)`
2643#[inline(always)]
2644fn predict_inner(cur: i32x8, p1: i32x8, n1: i32x8, p3: i32x8, n3: i32x8) -> i32x8 {
2645    let a = p1 + n1;
2646    cur + (((a << 3) + a - (p3 + n3) + C8) >> 4)
2647}
2648
2649/// Prediction filter (boundary): `data[idx] += ((p + n + 1) >> 1)`
2650#[inline(always)]
2651fn predict_avg(cur: i32x8, p: i32x8, n: i32x8) -> i32x8 {
2652    cur + ((p + n + C1) >> 1)
2653}
2654
2655/// AArch64 NEON horizontal row pass for s=1.
2656///
2657/// Processes each row independently using `vld2q_s16` to deinterleave even/odd
2658/// positions and `vextq_s16` for the 5-tap sliding-window neighbors, eliminating
2659/// the scatter loads (`8×ldrh`) used by the vertical 8-rows-at-a-time path.
2660///
2661/// # Even pass (lifting)
2662/// For each chunk of 8 even positions (`chunk*16 .. chunk*16+15`):
2663/// ```text
2664///   vld2q_s16(chunk*16)     → curr_even[0..8], curr_odd[0..8]
2665///   vld2q_s16((chunk+1)*16) → next_even (for n3)
2666///   p1 = vextq_s16(prev_odd, curr_odd, 7)
2667///   n1 = curr_odd
2668///   p3 = vextq_s16(prev_odd, curr_odd, 6)
2669///   n3 = vextq_s16(curr_odd, next_odd, 1)
2670/// ```
2671///
2672/// # Odd pass (prediction)
2673/// For each chunk of 8 inner odd positions at `3+chunk*16, 5+..., 17+chunk*16`:
2674/// ```text
2675///   pair1 = vld2q_s16(chunk*16)     → p3=.0, odds_lo=.1
2676///   pair2 = vld2q_s16((chunk+1)*16) → next_even=.0, odds_hi=.1
2677///   curr_odds = vextq_s16(odds_lo, odds_hi, 1)
2678///   p1 = vextq_s16(p3, next_even, 1)
2679///   n1 = vextq_s16(p3, next_even, 2)
2680///   n3 = vextq_s16(p3, next_even, 3)
2681/// ```
2682///
2683/// # Safety
2684/// `data[row_off .. row_off+width]` must be valid. `width >= 1`.
2685#[cfg(target_arch = "aarch64")]
2686#[allow(unsafe_code, unsafe_op_in_unsafe_fn)]
2687#[target_feature(enable = "neon")]
2688unsafe fn row_pass_neon_s1_row(data: &mut [i16], row_off: usize, width: usize) {
2689    use core::arch::aarch64::*;
2690
2691    let kmax = width - 1;
2692    let border = kmax.saturating_sub(3);
2693    let ptr = data.as_mut_ptr().add(row_off);
2694
2695    // Number of NEON even chunks: need next chunk fully in bounds for n3.
2696    // Condition: (chunk+1)*16+15 < width  →  chunk < (width-31)/16.
2697    let even_chunks = if width >= 32 { (width - 31) / 16 } else { 0 };
2698
2699    // ── Even pass (lifting) ────────────────────────────────────────────────────
2700
2701    let mut prev_odd = vdupq_n_s16(0i16);
2702
2703    for chunk in 0..even_chunks {
2704        let curr_pair = vld2q_s16(ptr.add(chunk * 16) as *const i16);
2705        let next_pair = vld2q_s16(ptr.add((chunk + 1) * 16) as *const i16);
2706        let curr_even = curr_pair.0;
2707        let curr_odd = curr_pair.1;
2708        let next_odd = next_pair.1;
2709
2710        let p1 = vextq_s16::<7>(prev_odd, curr_odd);
2711        let n1 = curr_odd;
2712        let p3 = vextq_s16::<6>(prev_odd, curr_odd);
2713        let n3 = vextq_s16::<1>(curr_odd, next_odd);
2714
2715        // cur -= ((9*(p1+n1) - (p3+n3) + 16) >> 5)
2716        macro_rules! lift {
2717            ($ce:expr, $p1:expr, $n1:expr, $p3:expr, $n3:expr) => {{
2718                let a = vaddq_s32($p1, $n1);
2719                let c = vaddq_s32($p3, $n3);
2720                let nine_a = vaddq_s32(vshlq_n_s32::<3>(a), a);
2721                let delta = vshrq_n_s32::<5>(vsubq_s32(vaddq_s32(nine_a, vdupq_n_s32(16i32)), c));
2722                vsubq_s32($ce, delta)
2723            }};
2724        }
2725
2726        let new_lo = lift!(
2727            vmovl_s16(vget_low_s16(curr_even)),
2728            vmovl_s16(vget_low_s16(p1)),
2729            vmovl_s16(vget_low_s16(n1)),
2730            vmovl_s16(vget_low_s16(p3)),
2731            vmovl_s16(vget_low_s16(n3))
2732        );
2733        let new_hi = lift!(
2734            vmovl_high_s16(curr_even),
2735            vmovl_high_s16(p1),
2736            vmovl_high_s16(n1),
2737            vmovl_high_s16(p3),
2738            vmovl_high_s16(n3)
2739        );
2740        let new_evens = vcombine_s16(vmovn_s32(new_lo), vmovn_s32(new_hi));
2741
2742        vst2q_s16(ptr.add(chunk * 16), int16x8x2_t(new_evens, curr_odd));
2743
2744        prev_odd = curr_odd;
2745    }
2746
2747    // Scalar even tail: k = even_chunks*16, +2, ... <= kmax.
2748    // State just before the first advance: prev1=prev_odd[6], next1=prev_odd[7], next3=data[k+1].
2749    {
2750        let k_start = even_chunks * 16;
2751        let mut prev1 = if even_chunks > 0 {
2752            vgetq_lane_s16::<6>(prev_odd) as i32
2753        } else {
2754            0
2755        };
2756        let mut next1 = if even_chunks > 0 {
2757            vgetq_lane_s16::<7>(prev_odd) as i32
2758        } else {
2759            0
2760        };
2761        let mut next3 = if k_start < kmax {
2762            *data.get_unchecked(row_off + k_start + 1) as i32
2763        } else {
2764            0
2765        };
2766        let mut k = k_start;
2767        while k <= kmax {
2768            let prev3 = prev1;
2769            prev1 = next1;
2770            next1 = next3;
2771            next3 = if k + 3 <= kmax {
2772                *data.get_unchecked(row_off + k + 3) as i32
2773            } else {
2774                0
2775            };
2776            let a = prev1 + next1;
2777            let c = prev3 + next3;
2778            let idx = row_off + k;
2779            *data.get_unchecked_mut(idx) =
2780                (*data.get_unchecked(idx) as i32 - (((a << 3) + a - c + 16) >> 5)) as i16;
2781            k += 2;
2782        }
2783    }
2784
2785    // ── Odd pass (prediction) ──────────────────────────────────────────────────
2786
2787    if kmax < 1 {
2788        return;
2789    }
2790
2791    // k=1: always predict_avg (or +=prev if k==kmax)
2792    {
2793        let p1 = *data.get_unchecked(row_off) as i32;
2794        let idx1 = row_off + 1;
2795        if 1 < kmax {
2796            let n1 = *data.get_unchecked(row_off + 2) as i32;
2797            *data.get_unchecked_mut(idx1) =
2798                (*data.get_unchecked(idx1) as i32 + ((p1 + n1 + 1) >> 1)) as i16;
2799        } else {
2800            *data.get_unchecked_mut(idx1) = (*data.get_unchecked(idx1) as i32 + p1) as i16;
2801        }
2802    }
2803
2804    // NEON inner odd chunks: predict_inner for k=3,5,...,17+chunk*16.
2805    // Safety: need (chunk+1)*16+15 < width AND 17+chunk*16 <= border (= kmax-3).
2806    // Combined: chunk < (width-31)/16 (same as even_chunks).
2807    // Inner check: 17+chunk*16 <= kmax-3  →  chunk <= (kmax-20)/16.
2808    let odd_chunks = if kmax >= 20 {
2809        even_chunks.min((kmax - 20) / 16 + 1)
2810    } else {
2811        0
2812    };
2813
2814    for chunk in 0..odd_chunks {
2815        // pair1: evens[chunk*8..+7] in .0, odds[chunk*8..+7] in .1
2816        let pair1 = vld2q_s16(ptr.add(chunk * 16) as *const i16);
2817        // pair2: evens[(chunk+1)*8..+7] in .0, odds[(chunk+1)*8..+7] in .1
2818        let pair2 = vld2q_s16(ptr.add((chunk + 1) * 16) as *const i16);
2819
2820        // 8 inner odds at physical positions 3+chunk*16, 5+..., 17+chunk*16
2821        let curr_odds = vextq_s16::<1>(pair1.1, pair2.1);
2822
2823        // Even neighbors for predict_inner:
2824        // p3[i] = even at k_odd-3 = chunk*16+2i → pair1.0[i]
2825        // p1[i] = even at k_odd-1 = chunk*16+2i+2 → vextq(pair1.0, pair2.0, 1)[i]
2826        // n1[i] = even at k_odd+1 = chunk*16+2i+4 → vextq(pair1.0, pair2.0, 2)[i]
2827        // n3[i] = even at k_odd+3 = chunk*16+2i+6 → vextq(pair1.0, pair2.0, 3)[i]
2828        let p3_e = pair1.0;
2829        let p1_e = vextq_s16::<1>(pair1.0, pair2.0); // also = unchanged evens for store
2830        let n1_e = vextq_s16::<2>(pair1.0, pair2.0);
2831        let n3_e = vextq_s16::<3>(pair1.0, pair2.0);
2832
2833        // cur += ((9*(p1+n1) - (p3+n3) + 8) >> 4)
2834        macro_rules! predict {
2835            ($co:expr, $p1:expr, $n1:expr, $p3:expr, $n3:expr) => {{
2836                let a = vaddq_s32($p1, $n1);
2837                let c = vaddq_s32($p3, $n3);
2838                let nine_a = vaddq_s32(vshlq_n_s32::<3>(a), a);
2839                let delta = vshrq_n_s32::<4>(vsubq_s32(vaddq_s32(nine_a, vdupq_n_s32(8i32)), c));
2840                vaddq_s32($co, delta)
2841            }};
2842        }
2843
2844        let new_lo = predict!(
2845            vmovl_s16(vget_low_s16(curr_odds)),
2846            vmovl_s16(vget_low_s16(p1_e)),
2847            vmovl_s16(vget_low_s16(n1_e)),
2848            vmovl_s16(vget_low_s16(p3_e)),
2849            vmovl_s16(vget_low_s16(n3_e))
2850        );
2851        let new_hi = predict!(
2852            vmovl_high_s16(curr_odds),
2853            vmovl_high_s16(p1_e),
2854            vmovl_high_s16(n1_e),
2855            vmovl_high_s16(p3_e),
2856            vmovl_high_s16(n3_e)
2857        );
2858        let new_odds = vcombine_s16(vmovn_s32(new_lo), vmovn_s32(new_hi));
2859
2860        // Store: evens at chunk*16+2,+4,...,+16 unchanged (= p1_e), odds updated.
2861        vst2q_s16(ptr.add(chunk * 16 + 2), int16x8x2_t(p1_e, new_odds));
2862    }
2863
2864    // Scalar odd tail: k = 3+odd_chunks*16, ..., kmax (inner then boundary).
2865    // State before the advance at k_scalar: prev1=data[k-3], next1=data[k-1], next3=data[k+1].
2866    if kmax >= 3 {
2867        let k_scalar = 3 + odd_chunks * 16;
2868        let mut prev1 = *data.get_unchecked(row_off + k_scalar - 3) as i32;
2869        let mut next1 = *data.get_unchecked(row_off + k_scalar - 1) as i32;
2870        let mut next3 = if k_scalar < kmax {
2871            *data.get_unchecked(row_off + k_scalar + 1) as i32
2872        } else {
2873            0
2874        };
2875        let mut k = k_scalar;
2876        while k <= kmax {
2877            let prev3 = prev1;
2878            prev1 = next1;
2879            next1 = next3;
2880            next3 = if k + 3 <= kmax {
2881                *data.get_unchecked(row_off + k + 3) as i32
2882            } else {
2883                0
2884            };
2885            let idx = row_off + k;
2886            if k <= border {
2887                let a = prev1 + next1;
2888                let c = prev3 + next3;
2889                *data.get_unchecked_mut(idx) =
2890                    (*data.get_unchecked(idx) as i32 + (((a << 3) + a - c + 8) >> 4)) as i16;
2891            } else if k < kmax {
2892                *data.get_unchecked_mut(idx) =
2893                    (*data.get_unchecked(idx) as i32 + ((prev1 + next1 + 1) >> 1)) as i16;
2894            } else {
2895                *data.get_unchecked_mut(idx) = (*data.get_unchecked(idx) as i32 + prev1) as i16;
2896            }
2897            k += 2;
2898        }
2899    }
2900}
2901
2902/// Apply the row-direction wavelet pass for one resolution level.
2903///
2904/// When `use_simd` is `true` and `s == 1` (`sd == 0`), on AArch64 the
2905/// horizontal NEON path (`row_pass_neon_s1_row`) is used for each row,
2906/// processing 8 even/odd positions at a time with `vld2q_s16` instead of
2907/// scatter loads. For `s > 1` and non-AArch64, the vertical 8-rows-at-a-time
2908/// `i32x8` path is used. The remaining rows (and all rows when `use_simd` is
2909/// false) use the scalar path.
2910///
2911/// `s` — step between active samples (power of two); `sd = log2(s)`.
2912pub(crate) fn row_pass_inner(
2913    data: &mut [i16],
2914    width: usize,
2915    height: usize,
2916    stride: usize,
2917    s: usize,
2918    sd: usize,
2919    use_simd: bool,
2920) {
2921    // AArch64 horizontal NEON path: at s=1, process each row using vld2q_s16
2922    // (sequential deinterleave) instead of scatter loads across 8 rows.
2923    #[cfg(target_arch = "aarch64")]
2924    if use_simd && s == 1 {
2925        for row in (0..height).step_by(s) {
2926            #[allow(unsafe_code)]
2927            unsafe {
2928                row_pass_neon_s1_row(data, row * stride, width);
2929            }
2930        }
2931        return;
2932    }
2933
2934    let kmax = (width - 1) >> sd;
2935    let border = kmax.saturating_sub(3);
2936
2937    // ── SIMD path: 8 active rows at a time ───────────────────────────────────
2938    //
2939    // At s=1 the 8 rows are consecutive (o[i] = (row_base + i) * stride).
2940    // At s=2 they are spaced by 2  (o[i] = (row_base + i*2) * stride), etc.
2941    // Column accesses use `k << sd` so the logical k loop is unchanged.
2942    let simd_active = if use_simd { height / s / 8 * 8 } else { 0 };
2943    let simd_rows = simd_active * s;
2944
2945    for group in 0..simd_active / 8 {
2946        let row_base = group * 8 * s;
2947        let o: [usize; 8] = core::array::from_fn(|i| (row_base + i * s) * stride);
2948
2949        // — Lifting (even k) ——————————————————————————————————————————————————
2950        let mut prev1v = i32x8::splat(0);
2951        let mut next1v = i32x8::splat(0);
2952        let mut next3v = if kmax >= 1 {
2953            load_rows8(data, &o, 1 << sd)
2954        } else {
2955            i32x8::splat(0)
2956        };
2957        let mut prev3v: i32x8;
2958        let mut k = 0usize;
2959        while k <= kmax {
2960            prev3v = prev1v;
2961            prev1v = next1v;
2962            next1v = next3v;
2963            next3v = if k + 3 <= kmax {
2964                load_rows8(data, &o, (k + 3) << sd)
2965            } else {
2966                i32x8::splat(0)
2967            };
2968            let cur = load_rows8(data, &o, k << sd);
2969            store_rows8(
2970                data,
2971                &o,
2972                k << sd,
2973                lifting_even(cur, prev1v, next1v, prev3v, next3v),
2974            );
2975            k += 2;
2976        }
2977
2978        // — Prediction (odd k) ————————————————————————————————————————————————
2979        if kmax >= 1 {
2980            let mut k = 1usize;
2981            prev1v = load_rows8(data, &o, (k - 1) << sd);
2982            if k < kmax {
2983                next1v = load_rows8(data, &o, (k + 1) << sd);
2984                let cur = load_rows8(data, &o, k << sd);
2985                store_rows8(data, &o, k << sd, predict_avg(cur, prev1v, next1v));
2986            } else {
2987                // k == kmax: boundary — only one odd sample, += prev
2988                let cur = load_rows8(data, &o, k << sd);
2989                store_rows8(data, &o, k << sd, cur + prev1v);
2990                next1v = i32x8::splat(0);
2991            }
2992
2993            next3v = if border >= 3 {
2994                load_rows8(data, &o, (k + 3) << sd)
2995            } else {
2996                i32x8::splat(0)
2997            };
2998
2999            k = 3;
3000            while k <= border {
3001                prev3v = prev1v;
3002                prev1v = next1v;
3003                next1v = next3v;
3004                next3v = load_rows8(data, &o, (k + 3) << sd);
3005                let cur = load_rows8(data, &o, k << sd);
3006                store_rows8(
3007                    data,
3008                    &o,
3009                    k << sd,
3010                    predict_inner(cur, prev1v, next1v, prev3v, next3v),
3011                );
3012                k += 2;
3013            }
3014
3015            while k <= kmax {
3016                prev1v = next1v;
3017                next1v = next3v;
3018                next3v = i32x8::splat(0);
3019                let cur = load_rows8(data, &o, k << sd);
3020                if k < kmax {
3021                    store_rows8(data, &o, k << sd, predict_avg(cur, prev1v, next1v));
3022                } else {
3023                    store_rows8(data, &o, k << sd, cur + prev1v);
3024                }
3025                k += 2;
3026            }
3027        }
3028    }
3029
3030    // ── Scalar path: remaining rows ───────────────────────────────────────────
3031    let scalar_start = simd_rows;
3032    for row in (scalar_start..height).step_by(s) {
3033        let off = row * stride;
3034
3035        // Lifting (even samples)
3036        let mut prev1: i32 = 0;
3037        let mut next1: i32 = 0;
3038        let mut next3: i32 = if kmax >= 1 {
3039            data[off + (1 << sd)] as i32
3040        } else {
3041            0
3042        };
3043        let mut prev3: i32;
3044        let mut k = 0usize;
3045        while k <= kmax {
3046            prev3 = prev1;
3047            prev1 = next1;
3048            next1 = next3;
3049            next3 = if k + 3 <= kmax {
3050                data[off + ((k + 3) << sd)] as i32
3051            } else {
3052                0
3053            };
3054            let a = prev1 + next1;
3055            let c = prev3 + next3;
3056            let idx = off + (k << sd);
3057            data[idx] = (data[idx] as i32 - (((a << 3) + a - c + 16) >> 5)) as i16;
3058            k += 2;
3059        }
3060
3061        // Prediction (odd samples)
3062        if kmax >= 1 {
3063            let mut k = 1usize;
3064            prev1 = data[off + ((k - 1) << sd)] as i32;
3065            if k < kmax {
3066                next1 = data[off + ((k + 1) << sd)] as i32;
3067                let idx = off + (k << sd);
3068                data[idx] = (data[idx] as i32 + ((prev1 + next1 + 1) >> 1)) as i16;
3069            } else {
3070                let idx = off + (k << sd);
3071                data[idx] = (data[idx] as i32 + prev1) as i16;
3072            }
3073
3074            next3 = if border >= 3 {
3075                data[off + ((k + 3) << sd)] as i32
3076            } else {
3077                0
3078            };
3079
3080            k = 3;
3081            while k <= border {
3082                prev3 = prev1;
3083                prev1 = next1;
3084                next1 = next3;
3085                next3 = data[off + ((k + 3) << sd)] as i32;
3086                let a = prev1 + next1;
3087                let idx = off + (k << sd);
3088                data[idx] = (data[idx] as i32 + (((a << 3) + a - (prev3 + next3) + 8) >> 4)) as i16;
3089                k += 2;
3090            }
3091
3092            while k <= kmax {
3093                prev1 = next1;
3094                next1 = next3;
3095                next3 = 0;
3096                let idx = off + (k << sd);
3097                if k < kmax {
3098                    data[idx] = (data[idx] as i32 + ((prev1 + next1 + 1) >> 1)) as i16;
3099                } else {
3100                    data[idx] = (data[idx] as i32 + prev1) as i16;
3101                }
3102                k += 2;
3103            }
3104        }
3105    }
3106}
3107
3108fn inverse_wavelet_transform(plane: &mut FlatPlane, width: usize, height: usize, subsample: usize) {
3109    inverse_wavelet_transform_from(plane, width, height, subsample, 16);
3110}
3111
3112/// Like `inverse_wavelet_transform` but begins at `start_scale` instead of 16.
3113///
3114/// Use `start_scale = 16 / sub` when operating on a compact plane produced by
3115/// subsampling the coefficient scatter by factor `sub`.  For example, the sub=2
3116/// compact plane only contains coefficients up to scale 8, so the s=16 pass
3117/// would be purely spurious.
3118fn inverse_wavelet_transform_from(
3119    plane: &mut FlatPlane,
3120    width: usize,
3121    height: usize,
3122    subsample: usize,
3123    start_scale: usize,
3124) {
3125    let stride = plane.stride;
3126    let data = plane.data.as_mut_slice();
3127    let mut s = start_scale;
3128    let mut s_degree: u32 = start_scale.trailing_zeros();
3129
3130    let mut st0 = vec![0i32; width];
3131    let mut st1 = vec![0i32; width];
3132    let mut st2 = vec![0i32; width];
3133
3134    while s >= subsample {
3135        let sd = s_degree as usize;
3136
3137        // Column pass SIMD: enabled for s=1,2,4 using stride-aware load8s/store8s.
3138        // For s=2 the load uses vld2q_s16 (deinterleave even/odd), for s=4 vld4q_s16.
3139        // The scalar else-branches below are now only reached for s>4 (s=8, s=16).
3140        let use_simd = s <= 4;
3141
3142        // ── Column pass (transposed) ──────────────────────────────────────────
3143        {
3144            let kmax = (height - 1) >> sd;
3145            let border = kmax.saturating_sub(3);
3146            let num_cols = width.div_ceil(s);
3147            let simd_cols = if use_simd { num_cols / 8 * 8 } else { 0 };
3148
3149            // Lifting (even samples)
3150            for v in &mut st0[..num_cols] {
3151                *v = 0;
3152            }
3153            for v in &mut st1[..num_cols] {
3154                *v = 0;
3155            }
3156            if kmax >= 1 {
3157                let off = (1 << sd) * stride;
3158                if use_simd {
3159                    for ci in (0..simd_cols).step_by(8) {
3160                        store8_i32(&mut st2, ci, load8s(data, off + ci * s, s));
3161                    }
3162                    for ci in simd_cols..num_cols {
3163                        st2[ci] = data[off + ci * s] as i32;
3164                    }
3165                } else {
3166                    for (ci, col) in (0..width).step_by(s).enumerate() {
3167                        st2[ci] = data[off + col] as i32;
3168                    }
3169                }
3170            } else {
3171                for v in &mut st2[..num_cols] {
3172                    *v = 0;
3173                }
3174            }
3175
3176            // Split even pass into: main (k+3 <= kmax → n3 always in-bounds) and
3177            // tail (k+3 > kmax → n3 = 0), mirroring the odd pass structure.
3178            // This hoists the `has_n3` branch out of the ci inner loop so that
3179            // the hot path (≥97% of k-iterations) has no runtime conditional.
3180            let mut k = 0usize;
3181            // Main: n3 always available
3182            while k + 3 <= kmax {
3183                let k_off = (k << sd) * stride;
3184                let n3_off = ((k + 3) << sd) * stride;
3185                if use_simd {
3186                    let mut ci = 0usize;
3187                    while ci < simd_cols {
3188                        let vp3 = load8_i32(&st0, ci);
3189                        let vp1 = load8_i32(&st1, ci);
3190                        let vn1 = load8_i32(&st2, ci);
3191                        let vn3 = load8s(data, n3_off + ci * s, s);
3192                        let cur = load8s(data, k_off + ci * s, s);
3193                        store8s(
3194                            data,
3195                            k_off + ci * s,
3196                            s,
3197                            lifting_even(cur, vp1, vn1, vp3, vn3),
3198                        );
3199                        store8_i32(&mut st0, ci, vp1);
3200                        store8_i32(&mut st1, ci, vn1);
3201                        store8_i32(&mut st2, ci, vn3);
3202                        ci += 8;
3203                    }
3204                    while ci < num_cols {
3205                        let p3 = st0[ci];
3206                        let p1 = st1[ci];
3207                        let n1 = st2[ci];
3208                        let n3 = data[n3_off + ci * s] as i32;
3209                        let a = p1 + n1;
3210                        let idx = k_off + ci * s;
3211                        data[idx] =
3212                            (data[idx] as i32 - (((a << 3) + a - (p3 + n3) + 16) >> 5)) as i16;
3213                        st0[ci] = p1;
3214                        st1[ci] = n1;
3215                        st2[ci] = n3;
3216                        ci += 1;
3217                    }
3218                } else {
3219                    for (ci, col) in (0..width).step_by(s).enumerate() {
3220                        let p3 = st0[ci];
3221                        let p1 = st1[ci];
3222                        let n1 = st2[ci];
3223                        let n3 = data[n3_off + col] as i32;
3224                        let a = p1 + n1;
3225                        let c = p3 + n3;
3226                        let idx = k_off + col;
3227                        data[idx] = (data[idx] as i32 - (((a << 3) + a - c + 16) >> 5)) as i16;
3228                        st0[ci] = p1;
3229                        st1[ci] = n1;
3230                        st2[ci] = n3;
3231                    }
3232                }
3233                k += 2;
3234            }
3235            // Tail: k+3 > kmax → n3 = 0
3236            while k <= kmax {
3237                let k_off = (k << sd) * stride;
3238                if use_simd {
3239                    let zero8 = i32x8::splat(0);
3240                    let mut ci = 0usize;
3241                    while ci < simd_cols {
3242                        let vp3 = load8_i32(&st0, ci);
3243                        let vp1 = load8_i32(&st1, ci);
3244                        let vn1 = load8_i32(&st2, ci);
3245                        let cur = load8s(data, k_off + ci * s, s);
3246                        store8s(
3247                            data,
3248                            k_off + ci * s,
3249                            s,
3250                            lifting_even(cur, vp1, vn1, vp3, zero8),
3251                        );
3252                        store8_i32(&mut st0, ci, vp1);
3253                        store8_i32(&mut st1, ci, vn1);
3254                        store8_i32(&mut st2, ci, zero8);
3255                        ci += 8;
3256                    }
3257                    while ci < num_cols {
3258                        let p3 = st0[ci];
3259                        let p1 = st1[ci];
3260                        let n1 = st2[ci];
3261                        let a = p1 + n1;
3262                        let idx = k_off + ci * s;
3263                        data[idx] = (data[idx] as i32 - (((a << 3) + a - p3 + 16) >> 5)) as i16;
3264                        st0[ci] = p1;
3265                        st1[ci] = n1;
3266                        st2[ci] = 0;
3267                        ci += 1;
3268                    }
3269                } else {
3270                    for (ci, col) in (0..width).step_by(s).enumerate() {
3271                        let p3 = st0[ci];
3272                        let p1 = st1[ci];
3273                        let n1 = st2[ci];
3274                        let a = p1 + n1;
3275                        let idx = k_off + col;
3276                        data[idx] = (data[idx] as i32 - (((a << 3) + a - p3 + 16) >> 5)) as i16;
3277                        st0[ci] = p1;
3278                        st1[ci] = n1;
3279                        st2[ci] = 0;
3280                    }
3281                }
3282                k += 2;
3283            }
3284
3285            // Prediction (odd samples)
3286            if kmax >= 1 {
3287                // k = 1
3288                let km1_off = 0;
3289                let k_off = (1 << sd) * stride;
3290
3291                if 2 <= kmax {
3292                    let kp1_off = (2 << sd) * stride;
3293                    if use_simd {
3294                        let mut ci = 0usize;
3295                        while ci < simd_cols {
3296                            let vp = load8s(data, km1_off + ci * s, s);
3297                            let vn = load8s(data, kp1_off + ci * s, s);
3298                            let cur = load8s(data, k_off + ci * s, s);
3299                            store8s(data, k_off + ci * s, s, predict_avg(cur, vp, vn));
3300                            store8_i32(&mut st0, ci, vp);
3301                            store8_i32(&mut st1, ci, vn);
3302                            ci += 8;
3303                        }
3304                        while ci < num_cols {
3305                            let p = data[km1_off + ci * s] as i32;
3306                            let n = data[kp1_off + ci * s] as i32;
3307                            let idx = k_off + ci * s;
3308                            data[idx] = (data[idx] as i32 + ((p + n + 1) >> 1)) as i16;
3309                            st0[ci] = p;
3310                            st1[ci] = n;
3311                            ci += 1;
3312                        }
3313                    } else {
3314                        for (ci, col) in (0..width).step_by(s).enumerate() {
3315                            let p = data[km1_off + col] as i32;
3316                            let n = data[kp1_off + col] as i32;
3317                            let idx = k_off + col;
3318                            data[idx] = (data[idx] as i32 + ((p + n + 1) >> 1)) as i16;
3319                            st0[ci] = p;
3320                            st1[ci] = n;
3321                        }
3322                    }
3323                } else if use_simd {
3324                    let mut ci = 0usize;
3325                    while ci < simd_cols {
3326                        let vp = load8s(data, km1_off + ci * s, s);
3327                        let cur = load8s(data, k_off + ci * s, s);
3328                        store8s(data, k_off + ci * s, s, cur + vp);
3329                        store8_i32(&mut st0, ci, vp);
3330                        ci += 8;
3331                    }
3332                    for v in &mut st1[..num_cols] {
3333                        *v = 0;
3334                    }
3335                    while ci < num_cols {
3336                        let p = data[km1_off + ci * s] as i32;
3337                        let idx = k_off + ci * s;
3338                        data[idx] = (data[idx] as i32 + p) as i16;
3339                        st0[ci] = p;
3340                        st1[ci] = 0;
3341                        ci += 1;
3342                    }
3343                } else {
3344                    for (ci, col) in (0..width).step_by(s).enumerate() {
3345                        let p = data[km1_off + col] as i32;
3346                        let idx = k_off + col;
3347                        data[idx] = (data[idx] as i32 + p) as i16;
3348                        st0[ci] = p;
3349                        st1[ci] = 0;
3350                    }
3351                }
3352
3353                if border >= 3 {
3354                    let off = (4 << sd) * stride;
3355                    if use_simd {
3356                        let mut ci = 0usize;
3357                        while ci < simd_cols {
3358                            store8_i32(&mut st2, ci, load8s(data, off + ci * s, s));
3359                            ci += 8;
3360                        }
3361                        while ci < num_cols {
3362                            st2[ci] = data[off + ci * s] as i32;
3363                            ci += 1;
3364                        }
3365                    } else {
3366                        for (ci, col) in (0..width).step_by(s).enumerate() {
3367                            st2[ci] = data[off + col] as i32;
3368                        }
3369                    }
3370                }
3371
3372                // k = 3, 5, ..., border
3373                let mut k = 3usize;
3374                while k <= border {
3375                    let k_off = (k << sd) * stride;
3376                    let n3_off = ((k + 3) << sd) * stride;
3377
3378                    if use_simd {
3379                        let mut ci = 0usize;
3380                        while ci < simd_cols {
3381                            let vp3 = load8_i32(&st0, ci);
3382                            let vp1 = load8_i32(&st1, ci);
3383                            let vn1 = load8_i32(&st2, ci);
3384                            let vn3 = load8s(data, n3_off + ci * s, s);
3385                            let cur = load8s(data, k_off + ci * s, s);
3386                            store8s(
3387                                data,
3388                                k_off + ci * s,
3389                                s,
3390                                predict_inner(cur, vp1, vn1, vp3, vn3),
3391                            );
3392                            store8_i32(&mut st0, ci, vp1);
3393                            store8_i32(&mut st1, ci, vn1);
3394                            store8_i32(&mut st2, ci, vn3);
3395                            ci += 8;
3396                        }
3397                        while ci < num_cols {
3398                            let p3 = st0[ci];
3399                            let p1 = st1[ci];
3400                            let n1 = st2[ci];
3401                            let n3 = data[n3_off + ci * s] as i32;
3402                            let a = p1 + n1;
3403                            let idx = k_off + ci * s;
3404                            data[idx] =
3405                                (data[idx] as i32 + (((a << 3) + a - (p3 + n3) + 8) >> 4)) as i16;
3406                            st0[ci] = p1;
3407                            st1[ci] = n1;
3408                            st2[ci] = n3;
3409                            ci += 1;
3410                        }
3411                    } else {
3412                        for (ci, col) in (0..width).step_by(s).enumerate() {
3413                            let p3 = st0[ci];
3414                            let p1 = st1[ci];
3415                            let n1 = st2[ci];
3416                            let n3 = data[n3_off + col] as i32;
3417
3418                            let a = p1 + n1;
3419                            let idx = k_off + col;
3420                            data[idx] =
3421                                (data[idx] as i32 + (((a << 3) + a - (p3 + n3) + 8) >> 4)) as i16;
3422
3423                            st0[ci] = p1;
3424                            st1[ci] = n1;
3425                            st2[ci] = n3;
3426                        }
3427                    }
3428                    k += 2;
3429                }
3430
3431                // tail
3432                while k <= kmax {
3433                    let k_off = (k << sd) * stride;
3434
3435                    if k < kmax {
3436                        if use_simd {
3437                            let mut ci = 0usize;
3438                            while ci < simd_cols {
3439                                let vp = load8_i32(&st1, ci);
3440                                let vn = load8_i32(&st2, ci);
3441                                let cur = load8s(data, k_off + ci * s, s);
3442                                store8s(data, k_off + ci * s, s, predict_avg(cur, vp, vn));
3443                                store8_i32(&mut st1, ci, vn);
3444                                store8_i32(&mut st2, ci, i32x8::splat(0));
3445                                ci += 8;
3446                            }
3447                            while ci < num_cols {
3448                                let p = st1[ci];
3449                                let n = st2[ci];
3450                                let idx = k_off + ci * s;
3451                                data[idx] = (data[idx] as i32 + ((p + n + 1) >> 1)) as i16;
3452                                st1[ci] = n;
3453                                st2[ci] = 0;
3454                                ci += 1;
3455                            }
3456                        } else {
3457                            for (ci, col) in (0..width).step_by(s).enumerate() {
3458                                let p = st1[ci];
3459                                let n = st2[ci];
3460                                let idx = k_off + col;
3461                                data[idx] = (data[idx] as i32 + ((p + n + 1) >> 1)) as i16;
3462                                st1[ci] = n;
3463                                st2[ci] = 0;
3464                            }
3465                        }
3466                    } else if use_simd {
3467                        let mut ci = 0usize;
3468                        while ci < simd_cols {
3469                            let vp = load8_i32(&st1, ci);
3470                            let cur = load8s(data, k_off + ci * s, s);
3471                            store8s(data, k_off + ci * s, s, cur + vp);
3472                            store8_i32(&mut st1, ci, load8_i32(&st2, ci));
3473                            store8_i32(&mut st2, ci, i32x8::splat(0));
3474                            ci += 8;
3475                        }
3476                        while ci < num_cols {
3477                            let p = st1[ci];
3478                            let idx = k_off + ci * s;
3479                            data[idx] = (data[idx] as i32 + p) as i16;
3480                            st1[ci] = st2[ci];
3481                            st2[ci] = 0;
3482                            ci += 1;
3483                        }
3484                    } else {
3485                        for (ci, col) in (0..width).step_by(s).enumerate() {
3486                            let p = st1[ci];
3487                            let idx = k_off + col;
3488                            data[idx] = (data[idx] as i32 + p) as i16;
3489                            st1[ci] = st2[ci];
3490                            st2[ci] = 0;
3491                        }
3492                    }
3493                    k += 2;
3494                }
3495            }
3496        }
3497
3498        // ── Row pass ─────────────────────────────────────────────────────────
3499        // Row pass SIMD works for any s — always enable it.
3500        row_pass_inner(data, width, height, stride, s, sd, true);
3501
3502        s >>= 1;
3503        s_degree = s_degree.saturating_sub(1);
3504    }
3505}
3506
3507// ---- Public API -------------------------------------------------------------
3508
3509/// Progressive IW44 wavelet image decoder.
3510///
3511/// Holds three independent planar decoders (Y, Cb, Cr) whose ZP context tables
3512/// persist across chunks, enabling progressive refinement.
3513///
3514/// ## Usage
3515///
3516/// ```no_run
3517/// use djvu_iw44::Iw44Image;
3518///
3519/// let chunk_data: &[u8] = &[]; // BG44 chunk bytes from the DjVu file
3520/// let mut img = Iw44Image::new();
3521/// // Feed each BG44 chunk in document order:
3522/// img.decode_chunk(chunk_data)?;
3523/// // Convert to an RGB pixmap once all desired chunks are decoded:
3524/// let pixmap = img.to_rgb()?;
3525/// # Ok::<(), djvu_iw44::Iw44Error>(())
3526/// ```
3527#[derive(Clone, Debug)]
3528pub struct Iw44Image {
3529    /// Luma plane dimensions (pixels, before subsampling).
3530    pub width: u32,
3531    /// Luma plane dimensions (pixels, before subsampling).
3532    pub height: u32,
3533    /// `true` for color (YCbCr) images, `false` for grayscale.
3534    is_color: bool,
3535    /// Number of Y slices decoded before chroma decoding starts.
3536    delay: u8,
3537    /// `true` if chroma planes are stored at half resolution.
3538    chroma_half: bool,
3539    /// Luma plane decoder.
3540    y: Option<PlaneDecoder>,
3541    /// Blue-difference chroma plane decoder (color images only).
3542    cb: Option<PlaneDecoder>,
3543    /// Red-difference chroma plane decoder (color images only).
3544    cr: Option<PlaneDecoder>,
3545    /// Total slices decoded so far (used to implement the color-delay counter).
3546    cslice: usize,
3547    /// Expected `serial` value of the next chunk passed to [`decode_chunk`](Self::decode_chunk),
3548    /// starting at 0. Mirrors DjVuLibre's `cserial` counter — every chunk must
3549    /// arrive in strict document order (0, 1, 2, …); a mismatch means the
3550    /// chunk sequence is corrupted or desynced (see [`Iw44Error::UnexpectedSerial`]).
3551    next_serial: u32,
3552}
3553
3554impl Default for Iw44Image {
3555    fn default() -> Self {
3556        Self::new()
3557    }
3558}
3559
3560impl Iw44Image {
3561    /// Heap bytes held by this image's decoded coefficient planes.
3562    ///
3563    /// A cache-budget accounting helper: an `Iw44Image` keeps one
3564    /// [`PlaneDecoder`] per colour plane, and each holds `ceil(w/32) *
3565    /// ceil(h/32)` blocks. A block stores its first 16 coefficients inline and
3566    /// grows a heap tail only up to its highest non-zero bucket, so the cost
3567    /// tracks how much detail the chunks actually carried, not `w x h`
3568    /// (PERF_EXPERIMENTS.md IW44_SPARSE_BLOCKS). Both terms are reported: the
3569    /// inline block array and the sum of the heap tails.
3570    ///
3571    /// Call it after every chunk, not once: the tails grow as later chunks
3572    /// refine the image. A colour image also costs more than its luma plane
3573    /// alone — `PageLayers::cached_bytes` in the parent crate once sized a
3574    /// cached image as `w x h x 2` and under-reported colour pages by ~2.6x
3575    /// (PERF_EXPERIMENTS.md DECODE_CACHE_ACCOUNTING).
3576    ///
3577    /// Returns 0 before the first chunk is decoded (no plane is allocated yet).
3578    pub fn heap_bytes(&self) -> usize {
3579        [self.y.as_ref(), self.cb.as_ref(), self.cr.as_ref()]
3580            .into_iter()
3581            .flatten()
3582            .map(PlaneDecoder::heap_bytes)
3583            .sum()
3584    }
3585
3586    /// Create a new, empty decoder.
3587    pub fn new() -> Self {
3588        Iw44Image {
3589            width: 0,
3590            height: 0,
3591            is_color: false,
3592            delay: 0,
3593            chroma_half: false,
3594            y: None,
3595            cb: None,
3596            cr: None,
3597            cslice: 0,
3598            next_serial: 0,
3599        }
3600    }
3601
3602    /// Returns the (width, height) of the Cb chroma plane as allocated.
3603    ///
3604    /// When `chroma_half=true` this should be `(ceil(w/2), ceil(h/2))`.
3605    /// Returns `None` if no color chunks have been decoded yet.
3606    #[cfg(test)]
3607    pub fn chroma_plane_dims(&self) -> Option<(usize, usize)> {
3608        self.cb.as_ref().map(|p| (p.width, p.height))
3609    }
3610
3611    /// Returns `true` if the image is a color (YCbCr) image.
3612    #[cfg(test)]
3613    pub fn is_color(&self) -> bool {
3614        self.is_color
3615    }
3616
3617    /// Returns `true` if chroma planes are stored at half resolution.
3618    #[cfg(test)]
3619    pub fn chroma_half(&self) -> bool {
3620        self.chroma_half
3621    }
3622
3623    /// Decode one BG44/FG44/TH44 chunk.
3624    ///
3625    /// Call this once for each chunk in document order.  The ZP coder state
3626    /// is maintained internally so progressive refinement works automatically.
3627    ///
3628    /// ## Chunk format
3629    ///
3630    /// - First chunk (`serial == 0`): 9-byte header then ZP-coded payload.
3631    /// - Subsequent chunks: 2-byte header (`serial`, `slices`) then ZP payload.
3632    pub fn decode_chunk(&mut self, data: &[u8]) -> Result<(), Iw44Error> {
3633        if data.len() < 2 {
3634            return Err(Iw44Error::ChunkTooShort);
3635        }
3636        let serial = data[0];
3637        let slices = data[1];
3638
3639        // Serial-number continuity check, mirroring DjVuLibre's `cserial`
3640        // counter in `IWBitmap::decode_chunk`/`IWPixmap::decode_chunk`
3641        // (`IW44Image.wrong_serial`/`wrong_serial2`): once a first chunk has
3642        // been decoded, every subsequent chunk must arrive with the exact
3643        // next serial in sequence (0, 1, 2, …). A mismatch — e.g. a bit-flip
3644        // landing on the serial byte itself, a dropped/duplicated chunk, or a
3645        // stray `serial == 0` chunk restarting mid-stream — is a corrupted or
3646        // desynced chunk sequence and must be rejected rather than silently
3647        // decoded into the wrong refinement slot (differential fuzzing
3648        // against `ddjvu` found real corpus mutations that trip this exact
3649        // check on DjVuLibre's side while we decoded on, unnoticed).
3650        //
3651        // The very first chunk fed to a fresh decoder (`self.y` still `None`)
3652        // is exempted here so `MissingFirstChunk` remains the more specific
3653        // diagnostic for "no first chunk decoded yet" when `serial != 0`.
3654        if self.y.is_some() && serial as u32 != self.next_serial {
3655            return Err(Iw44Error::UnexpectedSerial);
3656        }
3657
3658        let payload_start = if serial == 0 {
3659            // First chunk — parse the 9-byte image header.
3660            if data.len() < 9 {
3661                return Err(Iw44Error::HeaderTooShort);
3662            }
3663            let majver = data[2];
3664            let minor = data[3];
3665            let is_grayscale = (majver >> 7) != 0;
3666            let w = u16::from_be_bytes([data[4], data[5]]);
3667            let h = u16::from_be_bytes([data[6], data[7]]);
3668            let delay_byte = data[8];
3669            let delay = if minor >= 2 { delay_byte & 127 } else { 0 };
3670            // IW44 v1.2 streams store full-resolution Cb/Cr planes.  In
3671            // particular, a clear high bit here is not a half-resolution-plane
3672            // signal: DjVuLibre decodes `carte.djvu` with full-resolution
3673            // chroma.  Treating it as one desynchronizes the adaptive ZP
3674            // streams and turns the chroma into plausible-looking noise.
3675            let chroma_half = false;
3676
3677            if w == 0 || h == 0 {
3678                return Err(Iw44Error::ZeroDimension);
3679            }
3680            // Prevent OOM / slow decode on malformed input.
3681            // 64 MP allows real scanned documents (e.g. 6780×9148 ≈ 62 MP at 600 dpi)
3682            // while bounding worst-case decode cost. Measured at the cap
3683            // boundary (2026-08 fuzz slow-unit: color 1023×65535 ≈ 67.0 MP at
3684            // 99.9% of the cap, 241 slices in a 13-byte chunk): ~0.8 s and
3685            // ~400 MB peak (3 planes of 32×32 coefficient blocks) in a native
3686            // release build. The same input needs ~11.5 s under ASan+SanCov
3687            // fuzz instrumentation; that overhead is fuzz-only, not a codec
3688            // gap. See PERF_EXPERIMENTS.md (near-cap slow-unit triage).
3689            let pixels = w as u64 * h as u64;
3690            if pixels > 64 * 1024 * 1024 {
3691                return Err(Iw44Error::ImageTooLarge);
3692            }
3693
3694            self.width = w as u32;
3695            self.height = h as u32;
3696            self.is_color = !is_grayscale;
3697            self.delay = delay;
3698            self.chroma_half = self.is_color && chroma_half;
3699            self.cslice = 0;
3700            self.y = Some(PlaneDecoder::new(w as usize, h as usize));
3701            if self.is_color {
3702                let (cw, ch) = if self.chroma_half {
3703                    ((w as usize).div_ceil(2), (h as usize).div_ceil(2))
3704                } else {
3705                    (w as usize, h as usize)
3706                };
3707                self.cb = Some(PlaneDecoder::new(cw, ch));
3708                self.cr = Some(PlaneDecoder::new(cw, ch));
3709            }
3710            9
3711        } else {
3712            if self.y.is_none() {
3713                return Err(Iw44Error::MissingFirstChunk);
3714            }
3715            2
3716        };
3717
3718        // A refinement chunk's ZP payload may legitimately be shorter than
3719        // `ZpDecoder::new`'s 2-byte minimum — even empty — when the encoder had
3720        // nothing left to encode for these `slices` (observed on a real corpus
3721        // file: a `[serial, slices]` header with a zero-length payload). This is
3722        // not malformed input: `ZpDecoder` already treats reads past the end of
3723        // its buffer as synthetic `0xFF` padding (see `read_byte`), which is
3724        // exactly how a normal chunk's *trailing* padding is already decoded
3725        // (see the slice-loop comment below). Pad up to 2 bytes with `0xFF`
3726        // here so a short/empty payload takes that same, already-relied-upon
3727        // padding path through initialization too, instead of hard-erroring.
3728        let raw_zp_data = &data[payload_start..];
3729        let padded_zp_data;
3730        let zp_data: &[u8] = if raw_zp_data.len() >= 2 {
3731            raw_zp_data
3732        } else {
3733            padded_zp_data = [raw_zp_data.first().copied().unwrap_or(0xff), 0xff];
3734            &padded_zp_data
3735        };
3736        let mut zp = ZpDecoder::new(zp_data).map_err(|_| Iw44Error::ZpTooShort)?;
3737
3738        for _ in 0..slices {
3739            self.cslice += 1;
3740            if let Some(ref mut y) = self.y {
3741                y.decode_slice(&mut zp);
3742            }
3743            if self.is_color && self.cslice > self.delay as usize {
3744                if let Some(ref mut cb) = self.cb {
3745                    cb.decode_slice(&mut zp);
3746                }
3747                if let Some(ref mut cr) = self.cr {
3748                    cr.decode_slice(&mut zp);
3749                }
3750            }
3751            // NOTE: do not early-exit on `zp.is_exhausted()` here. The ZP
3752            // coder is a continuous arithmetic bit stream and `is_exhausted()`
3753            // only reports that the *byte* buffer is drained — it fires several
3754            // bytes before the logical end of the stream (the decoder reads up
3755            // to 24 bits ahead via `refill_buffer`). The remaining slices still
3756            // decode legitimate wavelet refinement from the buffered bits and
3757            // arithmetic registers; skipping them truncates high-frequency
3758            // detail and produces chroma artifacts. The slice loop is already
3759            // bounded by `slices` (a u8, ≤255 per chunk) plus the 64 MP image
3760            // cap, so no early-exit is needed to bound decode time.
3761            // See PERF_EXPERIMENTS.md (#182 and the slice-loop follow-up).
3762        }
3763
3764        self.next_serial = serial as u32 + 1;
3765        Ok(())
3766    }
3767
3768    /// Convert the decoded image to an RGB [`Pixmap`].
3769    ///
3770    /// This is the **only** place where the separate Y, Cb, Cr planes are
3771    /// interleaved into RGB pixels.  DjVu images are stored bottom-to-top;
3772    /// this method flips the output to top-to-bottom.
3773    ///
3774    /// Equivalent to `to_rgb_subsample(1)`.
3775    pub fn to_rgb(&self) -> Result<Pixmap, Iw44Error> {
3776        self.to_rgb_subsample(1)
3777    }
3778
3779    /// Convert to an RGB [`Pixmap`] at reduced resolution.
3780    ///
3781    /// `subsample` must be ≥ 1.  A value of 1 gives full resolution; 2 gives
3782    /// half resolution in each dimension, etc.
3783    pub fn to_rgb_subsample(&self, subsample: u32) -> Result<Pixmap, Iw44Error> {
3784        if subsample == 0 {
3785            return Err(Iw44Error::InvalidSubsample);
3786        }
3787        let y_dec = self.y.as_ref().ok_or(Iw44Error::MissingCodec)?;
3788        let sub = subsample as usize;
3789        let w = (self.width as usize).div_ceil(sub) as u32;
3790        let h = (self.height as usize).div_ceil(sub) as u32;
3791
3792        if self.is_color {
3793            // When chroma_half=true the chroma planes are stored at half luma
3794            // resolution.  Divide the subsample factor by 2 (minimum 1) so that
3795            // reconstruct() operates at the correct scale relative to the smaller
3796            // plane.
3797            let chroma_sub = if self.chroma_half {
3798                sub.div_ceil(2)
3799            } else {
3800                sub
3801            };
3802            let cb_dec = self.cb.as_ref().ok_or(Iw44Error::MissingCodec)?;
3803            let cr_dec = self.cr.as_ref().ok_or(Iw44Error::MissingCodec)?;
3804
3805            let pw = w as usize;
3806            let ph = h as usize;
3807
3808            // Fast path: sub=1 (most common — full-resolution render).
3809            // Pre-normalize Y/Cb/Cr into flat row buffers and apply the
3810            // YCbCr→RGBA formula 8 pixels at a time with SIMD.
3811            if sub == 1 {
3812                let mut pm = Pixmap::try_new(w, h, 0, 0, 0, 255)?;
3813                // A very large page is reconstructed a band at a time. The three
3814                // full-resolution `i16` planes cost 6 bytes per pixel — more
3815                // than the 4-byte output they feed — and are dropped the moment
3816                // the RGB is written. See `band_keep_blocks`.
3817                if let Some(keep) = band_keep_blocks(y_dec, self.chroma_half, 0) {
3818                    self.rgb_sub1_banded(y_dec, cb_dec, cr_dec, keep, pw, ph, &mut pm);
3819                    return Ok(pm);
3820                }
3821                let (y_plane, cb_plane, cr_plane) =
3822                    reconstruct_planes(y_dec, cb_dec, cr_dec, sub, chroma_sub);
3823                convert_rgb_rows(
3824                    self.chroma_half,
3825                    &y_plane,
3826                    0,
3827                    &cb_plane,
3828                    &cr_plane,
3829                    0,
3830                    0..ph,
3831                    pw,
3832                    ph,
3833                    &mut pm.data,
3834                );
3835                return Ok(pm);
3836            }
3837
3838            let (y_plane, cb_plane, cr_plane) =
3839                reconstruct_planes(y_dec, cb_dec, cr_dec, sub, chroma_sub);
3840            let mut pm = Pixmap::try_new(w, h, 0, 0, 0, 255)?;
3841
3842            // Compact path: sub ≥ 2 with power-of-two subsample.
3843            //
3844            // `reconstruct(sub)` now returns a plane that is already at the
3845            // target resolution (ceil(w/sub) × ceil(h/sub)), so we access it
3846            // with sub=1 indexing.  Chroma planes are at the same output size
3847            // (the chroma_half factor is absorbed into chroma_sub), so no
3848            // chroma_half division is needed here.
3849            //
3850            // Uses SIMD via `ycbcr_row_to_rgba` (same as the sub=1 fast path).
3851            if (2..=8).contains(&sub) && sub.is_power_of_two() {
3852                for row in 0..ph {
3853                    let out_row = ph - 1 - row; // DjVu rows are bottom-to-top
3854                    let y_off = row * y_plane.stride;
3855                    let c_off = row * cb_plane.stride;
3856                    let row_start = out_row * pw * 4;
3857                    ycbcr_row_from_i16(
3858                        &y_plane.data[y_off..y_off + pw],
3859                        &cb_plane.data[c_off..c_off + pw],
3860                        &cr_plane.data[c_off..c_off + pw],
3861                        &mut pm.data[row_start..row_start + pw * 4],
3862                    );
3863                }
3864                return Ok(pm);
3865            }
3866
3867            // Fallback scalar path for non-power-of-two or large sub values.
3868            for row in 0..h {
3869                let out_row = h - 1 - row;
3870                for col in 0..w {
3871                    let src_row = row as usize * sub;
3872                    let src_col = col as usize * sub;
3873                    let y_idx = src_row * y_plane.stride + src_col;
3874                    let chroma_row = if self.chroma_half {
3875                        src_row / 2
3876                    } else {
3877                        src_row
3878                    };
3879                    let chroma_col = if self.chroma_half {
3880                        src_col / 2
3881                    } else {
3882                        src_col
3883                    };
3884                    let c_idx = chroma_row * cb_plane.stride + chroma_col;
3885
3886                    let y = normalize(y_plane.data[y_idx]);
3887                    let b = normalize(cb_plane.data[c_idx]);
3888                    let r = normalize(cr_plane.data[c_idx]);
3889
3890                    let t2 = r + (r >> 1);
3891                    let t3 = y + 128 - (b >> 2);
3892
3893                    let red = (y + 128 + t2).clamp(0, 255) as u8;
3894                    let green = (t3 - (t2 >> 1)).clamp(0, 255) as u8;
3895                    let blue = (t3 + (b << 1)).clamp(0, 255) as u8;
3896                    pm.set_rgb(col, out_row, red, green, blue);
3897                }
3898            }
3899            Ok(pm)
3900        } else {
3901            // Grayscale: only the Y plane is needed.
3902            // For sub≥2 the plane is compact (at output resolution); for sub=1 it
3903            // is full-resolution.  Use compact-aware indexing.
3904            let y_plane = y_dec.reconstruct(sub);
3905            let is_compact = (2..=8).contains(&sub) && sub.is_power_of_two();
3906            let mut pm = Pixmap::try_new(w, h, 0, 0, 0, 255)?;
3907            for row in 0..h {
3908                let out_row = h - 1 - row;
3909                for col in 0..w {
3910                    let (src_row, src_col) = if is_compact {
3911                        (row as usize, col as usize)
3912                    } else {
3913                        (row as usize * sub, col as usize * sub)
3914                    };
3915                    let idx = src_row * y_plane.stride + src_col;
3916                    let val = normalize(y_plane.data[idx]);
3917                    // Grayscale: DjVu luma 0 maps to black, −128 → white
3918                    let gray = (127 - val) as u8;
3919                    pm.set_rgb(col, out_row, gray, gray, gray);
3920                }
3921            }
3922            Ok(pm)
3923        }
3924    }
3925
3926    /// Convert a full-resolution colour page into `pm`, one band of block rows
3927    /// at a time, so the `i16` planes never exist whole.
3928    ///
3929    /// `keep` is how many block rows each band contributes to the output; each
3930    /// band reconstructs [`BAND_HALO_BLOCKS`] more on each side and throws them
3931    /// away, because only the interior of a band is exact.
3932    #[allow(clippy::too_many_arguments)]
3933    fn rgb_sub1_banded(
3934        &self,
3935        y_dec: &PlaneDecoder,
3936        cb_dec: &PlaneDecoder,
3937        cr_dec: &PlaneDecoder,
3938        keep: usize,
3939        pw: usize,
3940        ph: usize,
3941        pm: &mut Pixmap,
3942    ) {
3943        let block_rows = (self.height as usize).div_ceil(32);
3944        let mut first = 0usize;
3945        while first < block_rows {
3946            let last = (first + keep).min(block_rows);
3947            let (r0, r1) = (first * 32, (last * 32).min(ph));
3948            if r0 >= r1 {
3949                break;
3950            }
3951            let out = &mut pm.data[(ph - r1) * pw * 4..(ph - r0) * pw * 4];
3952            self.rgb_sub1_band(y_dec, cb_dec, cr_dec, r0, r1, pw, ph, out);
3953            first = last;
3954        }
3955    }
3956
3957    /// Write image rows `r0..r1` of the full-resolution colour picture into
3958    /// `out`, reconstructing only the block rows that cover them plus a halo.
3959    ///
3960    /// `out` holds exactly those rows as RGBA, top to bottom (see
3961    /// [`convert_rgb_rows`]). The band may start on any row: the halo below
3962    /// and above is what makes its rows exact, wherever it starts.
3963    #[allow(clippy::too_many_arguments)]
3964    fn rgb_sub1_band(
3965        &self,
3966        y_dec: &PlaneDecoder,
3967        cb_dec: &PlaneDecoder,
3968        cr_dec: &PlaneDecoder,
3969        r0: usize,
3970        r1: usize,
3971        pw: usize,
3972        ph: usize,
3973        out: &mut [u8],
3974    ) {
3975        let block_rows = (self.height as usize).div_ceil(32);
3976        let c_block_rows = cb_dec.height.div_ceil(32);
3977        let ch = ph.div_ceil(2);
3978        let (first, last) = (r0 / 32, r1.div_ceil(32));
3979        let lo = first.saturating_sub(BAND_HALO_BLOCKS);
3980        let hi = (last + BAND_HALO_BLOCKS).min(block_rows);
3981
3982        // Chroma rows this band reads. At half resolution luma row `r` takes
3983        // chroma rows `r / 2` and `r / 2 + 1`, so the band needs one row past
3984        // its own half — except at the image bottom, where that row is
3985        // clamped away.
3986        let (c_r0, c_r1) = if self.chroma_half {
3987            (r0 / 2, (r1.div_ceil(2) + 1).min(ch))
3988        } else {
3989            (r0, r1)
3990        };
3991        let c_lo = (c_r0 / 32).saturating_sub(BAND_HALO_BLOCKS);
3992        let c_hi = (c_r1.div_ceil(32) + BAND_HALO_BLOCKS).min(c_block_rows);
3993
3994        #[cfg(feature = "parallel")]
3995        let (y_band, cb_band, cr_band) = {
3996            let (y, (cb, cr)) = rayon::join(
3997                || y_dec.reconstruct_band(lo, hi),
3998                || {
3999                    rayon::join(
4000                        || cb_dec.reconstruct_band(c_lo, c_hi),
4001                        || cr_dec.reconstruct_band(c_lo, c_hi),
4002                    )
4003                },
4004            );
4005            (y, cb, cr)
4006        };
4007        #[cfg(not(feature = "parallel"))]
4008        let (y_band, cb_band, cr_band) = (
4009            y_dec.reconstruct_band(lo, hi),
4010            cb_dec.reconstruct_band(c_lo, c_hi),
4011            cr_dec.reconstruct_band(c_lo, c_hi),
4012        );
4013
4014        convert_rgb_rows(
4015            self.chroma_half,
4016            &y_band,
4017            lo * 32,
4018            &cb_band,
4019            &cr_band,
4020            c_lo * 32,
4021            r0..r1,
4022            pw,
4023            ph,
4024            out,
4025        );
4026    }
4027
4028    /// How many rows a caller that composites straight from bands of
4029    /// [`rgb_rows`](Self::rgb_rows) should take at a time, or `None` when the
4030    /// picture is small enough to convert whole with [`to_rgb`](Self::to_rgb).
4031    ///
4032    /// `Some` only for a colour picture whose full-resolution planes are large
4033    /// enough that `to_rgb` itself reconstructs them in bands. Such a caller
4034    /// never holds the whole RGB picture, so its band pays for its own RGB
4035    /// rows out of the same budget and keeps fewer rows than `to_rgb` does.
4036    pub fn rgb_band_rows(&self) -> Option<u32> {
4037        if !self.is_color {
4038            return None;
4039        }
4040        let y_dec = self.y.as_ref()?;
4041        band_keep_blocks(y_dec, self.chroma_half, 4).map(|keep| (keep * 32) as u32)
4042    }
4043
4044    /// Rows `rows` of the full-resolution colour picture, top to bottom, as a
4045    /// pixmap of `self.width` by `rows.len()`.
4046    ///
4047    /// Byte-identical to the same rows of [`to_rgb`](Self::to_rgb), but only
4048    /// the block rows covering `rows` (plus a halo on each side) are
4049    /// reconstructed, so a caller that walks the picture in bands never holds
4050    /// more than one band of planes and one band of RGB. See
4051    /// [`rgb_band_rows`](Self::rgb_band_rows) for the band size that keeps
4052    /// within the reconstruction budget.
4053    ///
4054    /// # Errors
4055    ///
4056    /// [`Iw44Error::MissingCodec`] when the picture is not colour or has no
4057    /// planes yet; [`Iw44Error::Invalid`] when `rows` is not within the
4058    /// picture's height.
4059    pub fn rgb_rows(&self, rows: core::ops::Range<u32>) -> Result<Pixmap, Iw44Error> {
4060        if !self.is_color {
4061            return Err(Iw44Error::MissingCodec);
4062        }
4063        let y_dec = self.y.as_ref().ok_or(Iw44Error::MissingCodec)?;
4064        let cb_dec = self.cb.as_ref().ok_or(Iw44Error::MissingCodec)?;
4065        let cr_dec = self.cr.as_ref().ok_or(Iw44Error::MissingCodec)?;
4066        let (pw, ph) = (self.width as usize, self.height as usize);
4067        let (o0, o1) = (rows.start as usize, rows.end as usize);
4068        if o0 > o1 || o1 > ph {
4069            return Err(Iw44Error::Invalid);
4070        }
4071        let mut pm = Pixmap::try_new(self.width, (o1 - o0) as u32, 0, 0, 0, 255)?;
4072        if o0 == o1 {
4073            return Ok(pm);
4074        }
4075        // Output row `o` is image row `ph - 1 - o`, so output rows `o0..o1`
4076        // are image rows `ph - o1..ph - o0`.
4077        self.rgb_sub1_band(
4078            y_dec,
4079            cb_dec,
4080            cr_dec,
4081            ph - o1,
4082            ph - o0,
4083            pw,
4084            ph,
4085            &mut pm.data,
4086        );
4087        Ok(pm)
4088    }
4089
4090    /// Convert to a grayscale [`GrayPixmap`] at full resolution.
4091    ///
4092    /// See [`to_gray8_subsample`](Self::to_gray8_subsample).
4093    pub fn to_gray8(&self) -> Result<GrayPixmap, Iw44Error> {
4094        self.to_gray8_subsample(1)
4095    }
4096
4097    /// Convert to a grayscale [`GrayPixmap`], decoding **only the Y (luma)
4098    /// plane** and skipping both chroma planes entirely.
4099    ///
4100    /// For a colour image the two chroma inverse-wavelet transforms and the
4101    /// YCbCr→RGBA conversion are the bulk of `to_rgb`'s cost; a grayscale
4102    /// consumer (OCR pre-pass, e-ink viewer, thumbnail grid, `render_gray8`)
4103    /// never needs them. This path reconstructs Y alone and writes one byte per
4104    /// pixel.
4105    ///
4106    /// # Fidelity
4107    ///
4108    /// - **Grayscale images:** byte-identical to `to_rgb_subsample(sub).to_gray8()`
4109    ///   (the R=G=B channels already equal `127 − Y`, and the Rec.601 weights
4110    ///   sum to 1024, so the luma round-trips exactly).
4111    /// - **Colour images:** returns the DjVu luma channel `clamp(Y + 128, 0,
4112    ///   255)`. This is the encoder's own luminance and is *not* bit-identical
4113    ///   to the Rec.601 luma of the reconstructed RGB (`to_gray8`), which mixes
4114    ///   in the chroma-derived R/G/B. The two differ by a few levels at most;
4115    ///   the Y channel is the more faithful luminance.
4116    pub fn to_gray8_subsample(&self, subsample: u32) -> Result<GrayPixmap, Iw44Error> {
4117        if subsample == 0 {
4118            return Err(Iw44Error::InvalidSubsample);
4119        }
4120        let y_dec = self.y.as_ref().ok_or(Iw44Error::MissingCodec)?;
4121        let sub = subsample as usize;
4122        let w = (self.width as usize).div_ceil(sub) as u32;
4123        let h = (self.height as usize).div_ceil(sub) as u32;
4124
4125        // Reconstruct Y only — never touch cb/cr (their reconstruct() + the
4126        // YCbCr math are what this path exists to skip).
4127        let y_plane = y_dec.reconstruct(sub);
4128        let is_compact = (2..=8).contains(&sub) && sub.is_power_of_two();
4129        let is_color = self.is_color;
4130
4131        let pw = w as usize;
4132        let ph = h as usize;
4133        let mut data = vec![0u8; pw * ph];
4134        for row in 0..ph {
4135            let out_row = ph - 1 - row; // DjVu rows are bottom-to-top
4136            let src_row = if is_compact { row } else { row * sub };
4137            let y_off = src_row * y_plane.stride;
4138            let dst = &mut data[out_row * pw..out_row * pw + pw];
4139            if is_color {
4140                // DjVu luma: gray = clamp(Y + 128, 0, 255).
4141                for (col, d) in dst.iter_mut().enumerate() {
4142                    let src_col = if is_compact { col } else { col * sub };
4143                    let val = normalize(y_plane.data[y_off + src_col]);
4144                    *d = (val + 128).clamp(0, 255) as u8;
4145                }
4146            } else {
4147                // Grayscale plane: gray = 127 − Y (matches to_rgb's R channel).
4148                for (col, d) in dst.iter_mut().enumerate() {
4149                    let src_col = if is_compact { col } else { col * sub };
4150                    let val = normalize(y_plane.data[y_off + src_col]);
4151                    *d = (127 - val) as u8;
4152                }
4153            }
4154        }
4155        Ok(GrayPixmap {
4156            width: w,
4157            height: h,
4158            data,
4159        })
4160    }
4161}
4162
4163// ---- Tests ------------------------------------------------------------------
4164
4165#[cfg(test)]
4166mod tests {
4167    use super::*;
4168
4169    /// #815: a refused output pixmap is the decoder's own size limit.
4170    #[test]
4171    fn pixmap_error_maps_to_image_too_large() {
4172        let e = PixmapError::TooLarge {
4173            width: 10000,
4174            height: 10000,
4175            pixels: 100_000_000,
4176            max: Pixmap::MAX_PIXELS,
4177        };
4178        assert_eq!(Iw44Error::from(e), Iw44Error::ImageTooLarge);
4179    }
4180
4181    fn assets_path() -> std::path::PathBuf {
4182        std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
4183            .join("../../references/djvujs/library/assets")
4184    }
4185
4186    fn golden_path() -> std::path::PathBuf {
4187        std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../tests/golden/iw44")
4188    }
4189
4190    /// Extract all BG44 chunk payloads from the first DJVU form in the file.
4191    fn extract_bg44_chunks(file: &djvu_iff::DjvuFile) -> Vec<&[u8]> {
4192        fn collect(chunk: &djvu_iff::Chunk) -> Option<Vec<&[u8]>> {
4193            match chunk {
4194                djvu_iff::Chunk::Form {
4195                    secondary_id,
4196                    children,
4197                    ..
4198                } => {
4199                    if secondary_id == b"DJVU" {
4200                        let v = children
4201                            .iter()
4202                            .filter_map(|c| match c {
4203                                djvu_iff::Chunk::Leaf {
4204                                    id: [b'B', b'G', b'4', b'4'],
4205                                    data,
4206                                } => Some(data.as_slice()),
4207                                _ => None,
4208                            })
4209                            .collect::<Vec<_>>();
4210                        return Some(v);
4211                    }
4212                    for c in children {
4213                        if let Some(v) = collect(c) {
4214                            return Some(v);
4215                        }
4216                    }
4217                    None
4218                }
4219                _ => None,
4220            }
4221        }
4222        collect(&file.root).unwrap_or_default()
4223    }
4224
4225    fn find_ppm_data_start(ppm: &[u8]) -> usize {
4226        let mut newlines = 0;
4227        for (i, &b) in ppm.iter().enumerate() {
4228            if b == b'\n' {
4229                newlines += 1;
4230                if newlines == 3 {
4231                    return i + 1;
4232                }
4233            }
4234        }
4235        0
4236    }
4237
4238    /// Compare `actual_ppm` against a golden file, creating it on first run.
4239    ///
4240    /// If the file doesn't exist it is written (first-time generation).
4241    /// On subsequent runs an exact byte-for-byte comparison is enforced so that
4242    /// any accidental change to the pixel output is caught immediately.
4243    fn assert_or_create_golden(actual_ppm: &[u8], golden_file: &str) {
4244        let path = golden_path().join(golden_file);
4245        if !path.exists() {
4246            std::fs::write(&path, actual_ppm)
4247                .unwrap_or_else(|e| panic!("failed to write golden {golden_file}: {e}"));
4248            return; // golden created — test passes on first run
4249        }
4250        assert_ppm_match(actual_ppm, golden_file);
4251    }
4252
4253    fn assert_ppm_match(actual_ppm: &[u8], golden_file: &str) {
4254        let expected_ppm = std::fs::read(golden_path().join(golden_file))
4255            .unwrap_or_else(|_| panic!("golden file not found: {}", golden_file));
4256        assert_eq!(
4257            actual_ppm.len(),
4258            expected_ppm.len(),
4259            "PPM size mismatch for {}: got {} expected {}",
4260            golden_file,
4261            actual_ppm.len(),
4262            expected_ppm.len()
4263        );
4264        if actual_ppm != expected_ppm {
4265            let header_end = find_ppm_data_start(actual_ppm);
4266            let actual_pixels = &actual_ppm[header_end..];
4267            let expected_pixels = &expected_ppm[header_end..];
4268            let total_pixels = actual_pixels.len() / 3;
4269            let diff_pixels = actual_pixels
4270                .chunks(3)
4271                .zip(expected_pixels.chunks(3))
4272                .filter(|(a, b)| a != b)
4273                .count();
4274            panic!(
4275                "{} pixel mismatch: {}/{} pixels differ ({:.1}%)",
4276                golden_file,
4277                diff_pixels,
4278                total_pixels,
4279                diff_pixels as f64 / total_pixels as f64 * 100.0
4280            );
4281        }
4282    }
4283
4284    /// #422: direct correctness check for bilinear chroma row upsampling, with
4285    /// hand-computed expected values (independent of any fixture).
4286    #[test]
4287    fn upsample_chroma_row_bilinear_values() {
4288        let half0 = [10i16, 20, 30];
4289        // No vertical blend: even cols take half0[c/2], odd cols average
4290        // half0[c/2] and half0[c/2+1] (last clamped).
4291        let mut out = [0i16; 6];
4292        super::upsample_chroma_row_bilinear(&half0, &half0, false, &mut out, 3);
4293        assert_eq!(out, [10, 15, 20, 25, 30, 30]);
4294
4295        // Vertical blend: vsamp(hc) = (half0+half1+1)>>1, then horizontal bilinear.
4296        let half1 = [20i16, 40, 60];
4297        super::upsample_chroma_row_bilinear(&half0, &half1, true, &mut out, 3);
4298        assert_eq!(out, [15, 23, 30, 38, 45, 45]);
4299
4300        // Endpoints stay at the source samples (no overshoot); a flat row is flat.
4301        let flat = [50i16, 50, 50];
4302        super::upsample_chroma_row_bilinear(&flat, &flat, false, &mut out, 3);
4303        assert_eq!(out, [50, 50, 50, 50, 50, 50]);
4304    }
4305
4306    // ---- TDD: failing tests first -------------------------------------------
4307
4308    /// Decode must fail gracefully on empty input.
4309    #[test]
4310    fn iw44_new_rejects_empty_chunk() {
4311        let mut img = Iw44Image::new();
4312        assert!(matches!(
4313            img.decode_chunk(&[]),
4314            Err(Iw44Error::ChunkTooShort)
4315        ));
4316    }
4317
4318    /// Decode must fail gracefully on a truncated first-chunk header.
4319    #[test]
4320    fn iw44_new_rejects_truncated_header() {
4321        let mut img = Iw44Image::new();
4322        // serial=0 but only 5 bytes (need ≥ 9)
4323        assert!(matches!(
4324            img.decode_chunk(&[0x00, 0x01, 0x00, 0x02, 0x00]),
4325            Err(Iw44Error::HeaderTooShort)
4326        ));
4327    }
4328
4329    /// Zero-dimension image must be rejected.
4330    #[test]
4331    fn iw44_new_rejects_zero_dimension() {
4332        let mut img = Iw44Image::new();
4333        // serial=0, slices=1, majver=0, minor=2, w=0, h=100, delay=0
4334        let header = [0x00u8, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x64, 0x00];
4335        assert!(matches!(
4336            img.decode_chunk(&header),
4337            Err(Iw44Error::ZeroDimension)
4338        ));
4339    }
4340
4341    /// A declared width×height above the 64 MP decode-cost cap must be
4342    /// rejected before any pixel buffer is allocated.
4343    #[test]
4344    fn iw44_new_rejects_oversized_image() {
4345        let mut img = Iw44Image::new();
4346        // serial=0, slices=1, majver=0, minor=2, w=65535, h=65535, delay=0
4347        // → 65535×65535 ≈ 4.29 G pixels, far past the 64 MP cap.
4348        let header = [0x00u8, 0x01, 0x00, 0x02, 0xFF, 0xFF, 0xFF, 0xFF, 0x00];
4349        assert!(matches!(
4350            img.decode_chunk(&header),
4351            Err(Iw44Error::ImageTooLarge)
4352        ));
4353    }
4354
4355    /// Subsequent chunk before first chunk must be rejected.
4356    #[test]
4357    fn iw44_new_rejects_subsequent_before_first() {
4358        let mut img = Iw44Image::new();
4359        // serial != 0
4360        assert!(matches!(
4361            img.decode_chunk(&[0x01, 0x01]),
4362            Err(Iw44Error::MissingFirstChunk)
4363        ));
4364    }
4365
4366    /// BUG-ZPSHORT regression: a refinement chunk (`serial != 0`) whose ZP
4367    /// payload is short or entirely empty must decode as a no-op refinement
4368    /// round, not a hard error. Real BG44 streams contain such chunks (e.g.
4369    /// `watchmaker.djvu`'s page-0 chunk 2, a bare 2-byte `[serial, slices]`
4370    /// header) when the encoder had nothing left to encode for that round —
4371    /// `ZpDecoder` already treats reads past a stream's true end as synthetic
4372    /// `0xFF` padding, so a chunk that is *entirely* padding is not malformed.
4373    #[test]
4374    fn iw44_decode_chunk_tolerates_empty_refinement_payload() {
4375        let mut img = Iw44Image::new();
4376        // First chunk: minimal valid grayscale header, 1x1, no slices decoded.
4377        let header = [0x00u8, 0x00, 0x00, 0x02, 0x00, 0x01, 0x00, 0x01, 0x00];
4378        img.decode_chunk(&header).expect("first chunk must decode");
4379
4380        // Refinement chunk: serial=1, slices=4, zero-length ZP payload.
4381        assert!(img.decode_chunk(&[0x01, 0x04]).is_ok());
4382
4383        // Another refinement chunk with a single stray payload byte (still
4384        // short of ZpDecoder's normal 2-byte minimum) must also be tolerated.
4385        assert!(img.decode_chunk(&[0x02, 0x04, 0xab]).is_ok());
4386
4387        // The image must still be usable afterwards (no poisoned state).
4388        assert!(img.to_rgb().is_ok());
4389    }
4390
4391    /// Round 46 (INTEROP_STREAMS finding 2b): a refinement chunk whose
4392    /// `serial` byte skips ahead (or repeats/rewinds) must be rejected rather
4393    /// than silently decoded into the wrong refinement slot. Mirrors
4394    /// DjVuLibre's `cserial` continuity check
4395    /// (`IW44Image.wrong_serial`/`wrong_serial2`) — differential fuzzing
4396    /// against `ddjvu` found real corpus mutations (`watchmaker.djvu`
4397    /// bit-flips landing on a BG44 chunk's serial byte) that trip this exact
4398    /// check on DjVuLibre's side while the pre-fix decoder here decoded on,
4399    /// unnoticed (`fuzz/corpus-regressions/diff_fuzz/watchmaker_00001_our-
4400    /// renders-what-they-reject.*`).
4401    #[test]
4402    fn iw44_decode_chunk_rejects_serial_skip() {
4403        let mut img = Iw44Image::new();
4404        let header = [0x00u8, 0x00, 0x00, 0x02, 0x00, 0x01, 0x00, 0x01, 0x00];
4405        img.decode_chunk(&header).expect("first chunk must decode");
4406
4407        // Expected next serial is 1; a chunk claiming serial=3 (skipped 1, 2)
4408        // must be rejected.
4409        assert!(matches!(
4410            img.decode_chunk(&[0x03, 0x04]),
4411            Err(Iw44Error::UnexpectedSerial)
4412        ));
4413    }
4414
4415    /// Round 46: a refinement chunk that *repeats* an already-consumed
4416    /// serial (instead of skipping ahead) must also be rejected — not just
4417    /// forward gaps.
4418    #[test]
4419    fn iw44_decode_chunk_rejects_serial_repeat() {
4420        let mut img = Iw44Image::new();
4421        let header = [0x00u8, 0x00, 0x00, 0x02, 0x00, 0x01, 0x00, 0x01, 0x00];
4422        img.decode_chunk(&header).expect("first chunk must decode");
4423        img.decode_chunk(&[0x01, 0x04])
4424            .expect("serial=1 refinement must decode");
4425
4426        // Expected next serial is 2; a chunk claiming serial=1 again (a
4427        // duplicated/rewound chunk) must be rejected.
4428        assert!(matches!(
4429            img.decode_chunk(&[0x01, 0x04]),
4430            Err(Iw44Error::UnexpectedSerial)
4431        ));
4432    }
4433
4434    /// Round 46: the BUG-ZPSHORT tolerance (empty/short refinement payloads)
4435    /// must still hold for chunks that *do* arrive in the correct serial
4436    /// order — the new continuity check must not regress it.
4437    #[test]
4438    fn iw44_decode_chunk_serial_check_does_not_regress_zpshort_tolerance() {
4439        let mut img = Iw44Image::new();
4440        let header = [0x00u8, 0x00, 0x00, 0x02, 0x00, 0x01, 0x00, 0x01, 0x00];
4441        img.decode_chunk(&header).expect("first chunk must decode");
4442        // In-order, empty-payload refinement chunks (serial 1, 2, ...) must
4443        // still be tolerated exactly as before.
4444        assert!(img.decode_chunk(&[0x01, 0x04]).is_ok());
4445        assert!(img.decode_chunk(&[0x02, 0x04]).is_ok());
4446        assert!(img.to_rgb().is_ok());
4447    }
4448
4449    /// `to_rgb()` on an uninitialised decoder must return an error.
4450    #[test]
4451    fn iw44_new_to_rgb_without_data_returns_error() {
4452        let img = Iw44Image::new();
4453        assert!(matches!(img.to_rgb(), Err(Iw44Error::MissingCodec)));
4454    }
4455
4456    /// `to_rgb_subsample(0)` must be rejected.
4457    #[test]
4458    fn iw44_new_subsample_zero_rejected() {
4459        let img = Iw44Image::new();
4460        assert!(matches!(
4461            img.to_rgb_subsample(0),
4462            Err(Iw44Error::InvalidSubsample)
4463        ));
4464    }
4465
4466    // ---- Pixel-exact golden tests -------------------------------------------
4467
4468    /// The banded reconstruction must be exact, not close: every interior row
4469    /// of a band must equal the row the whole-plane transform produces.
4470    ///
4471    /// A band carries the transform's own boundary handling at its edges, which
4472    /// is only correct where the band edge is the image edge. `BAND_HALO_BLOCKS`
4473    /// is what buys the interior its correctness, so this test also walks the
4474    /// halo down: the smallest halo that still matches tells a later reader how
4475    /// much margin the constant really has.
4476    #[test]
4477    fn reconstruct_band_matches_the_whole_plane() {
4478        let data = std::fs::read(assets_path().join("carte.djvu")).expect("carte.djvu");
4479        let file = djvu_iff::parse(&data).expect("parse");
4480        let mut img = Iw44Image::new();
4481        for c in &extract_bg44_chunks(&file) {
4482            img.decode_chunk(c).expect("decode_chunk");
4483        }
4484        let y = img.y.as_ref().expect("luma plane");
4485        let block_rows = y.height.div_ceil(32);
4486        assert!(
4487            block_rows >= 2 * BAND_HALO_BLOCKS + 2,
4488            "the fixture must be tall enough to hold an interior band"
4489        );
4490        let full = y.reconstruct(1);
4491
4492        for keep in [1usize, 2, 4] {
4493            let mut first = 0;
4494            while first < block_rows {
4495                let last = (first + keep).min(block_rows);
4496                let lo = first.saturating_sub(BAND_HALO_BLOCKS);
4497                let hi = (last + BAND_HALO_BLOCKS).min(block_rows);
4498                let band = y.reconstruct_band(lo, hi);
4499                for r in first * 32..(last * 32).min(y.height) {
4500                    let a = &full.data[r * full.stride..r * full.stride + y.width];
4501                    let b = &band.data[(r - lo * 32) * band.stride..][..y.width];
4502                    assert_eq!(
4503                        a, b,
4504                        "band [{lo}..{hi}) block rows, keeping [{first}..{last}): \
4505                         image row {r} differs from the whole-plane transform"
4506                    );
4507                }
4508                first = last;
4509            }
4510        }
4511    }
4512
4513    /// End-to-end: the banded colour conversion must produce the same pixels as
4514    /// the whole-plane one, byte for byte, including the chroma upsample that
4515    /// reads one row past each band.
4516    ///
4517    /// `band_keep_blocks` only turns banding on for pages far larger than any
4518    /// fixture, so this calls both paths directly. Every fixture here stores
4519    /// full-resolution chroma, which is all the decoder ever produces today
4520    /// (`chroma_half` is pinned to `false` — see `decode_chunk`); the banded
4521    /// half-resolution arithmetic mirrors the whole-plane branch beside it.
4522    #[test]
4523    fn banded_rgb_matches_whole_plane_rgb() {
4524        for asset in ["carte.djvu", "chicken.djvu", "colorbook.djvu"] {
4525            let data = std::fs::read(assets_path().join(asset)).expect("asset");
4526            let file = djvu_iff::parse(&data).expect("parse");
4527            let chunks = extract_bg44_chunks(&file);
4528            if chunks.is_empty() {
4529                continue;
4530            }
4531            let mut img = Iw44Image::new();
4532            for c in &chunks {
4533                img.decode_chunk(c).expect("decode_chunk");
4534            }
4535            if !img.is_color {
4536                continue;
4537            }
4538            let whole = img.to_rgb().expect("to_rgb");
4539
4540            let y_dec = img.y.as_ref().unwrap();
4541            let cb_dec = img.cb.as_ref().unwrap();
4542            let cr_dec = img.cr.as_ref().unwrap();
4543            let (pw, ph) = (img.width as usize, img.height as usize);
4544            for keep in [1usize, 3, 8] {
4545                let mut banded = Pixmap::try_new(img.width, img.height, 0, 0, 0, 255)
4546                    .expect("fits the pixmap limit");
4547                img.rgb_sub1_banded(y_dec, cb_dec, cr_dec, keep, pw, ph, &mut banded);
4548                assert_eq!(
4549                    banded.data, whole.data,
4550                    "{asset}: banded conversion keeping {keep} block rows per \
4551                     band differs from the whole-plane conversion"
4552                );
4553            }
4554        }
4555    }
4556
4557    /// `rgb_rows` must give the same bytes as the same rows of `to_rgb`, for
4558    /// any row range: a whole band, a band starting mid-block, one row, the
4559    /// first and the last row. This is what lets a renderer composite straight
4560    /// from bands (#811).
4561    #[test]
4562    fn rgb_rows_match_the_whole_picture() {
4563        for asset in ["carte.djvu", "chicken.djvu", "colorbook.djvu"] {
4564            let data = std::fs::read(assets_path().join(asset)).expect("asset");
4565            let file = djvu_iff::parse(&data).expect("parse");
4566            let chunks = extract_bg44_chunks(&file);
4567            if chunks.is_empty() {
4568                continue;
4569            }
4570            let mut img = Iw44Image::new();
4571            for c in &chunks {
4572                img.decode_chunk(c).expect("decode_chunk");
4573            }
4574            if !img.is_color {
4575                continue;
4576            }
4577            let whole = img.to_rgb().expect("to_rgb");
4578            let (w, h) = (img.width, img.height);
4579            let stride = w as usize * 4;
4580            assert!(h > 40, "{asset}: fixture must be taller than one test band");
4581            assert!(
4582                img.rgb_band_rows().is_none(),
4583                "{asset}: a small picture must not ask to be banded"
4584            );
4585
4586            let mut ranges = vec![0..h, 0..1, h - 1..h, 37..h - 5, 3..4];
4587            let mut o = 0;
4588            while o < h {
4589                ranges.push(o..(o + 37).min(h));
4590                o += 37;
4591            }
4592            for r in ranges {
4593                let band = img.rgb_rows(r.clone()).expect("rgb_rows");
4594                assert_eq!((band.width, band.height), (w, r.end - r.start));
4595                assert_eq!(
4596                    band.data,
4597                    &whole.data[r.start as usize * stride..r.end as usize * stride],
4598                    "{asset}: rows {r:?} differ from the whole-picture conversion"
4599                );
4600            }
4601
4602            let empty = img.rgb_rows(5..5).expect("an empty range is fine");
4603            assert_eq!((empty.width, empty.height), (w, 0));
4604            assert!(matches!(img.rgb_rows(0..h + 1), Err(Iw44Error::Invalid)));
4605            assert!(matches!(img.rgb_rows(7..6), Err(Iw44Error::Invalid)));
4606        }
4607    }
4608
4609    #[test]
4610    fn iw44_new_decode_boy_bg() {
4611        let data = std::fs::read(assets_path().join("boy.djvu")).expect("boy.djvu not found");
4612        let file = djvu_iff::parse(&data).expect("failed to parse boy.djvu");
4613        let chunks = extract_bg44_chunks(&file);
4614        assert_eq!(chunks.len(), 1, "expected 1 BG44 chunk in boy.djvu");
4615
4616        let mut img = Iw44Image::new();
4617        for c in &chunks {
4618            img.decode_chunk(c).expect("decode_chunk failed");
4619        }
4620        assert_eq!(img.width, 192);
4621        assert_eq!(img.height, 256);
4622
4623        let pm = img.to_rgb().expect("to_rgb failed");
4624        assert_ppm_match(&pm.to_ppm(), "boy_bg.ppm");
4625    }
4626
4627    #[test]
4628    fn iw44_new_decode_chicken_bg() {
4629        let data =
4630            std::fs::read(assets_path().join("chicken.djvu")).expect("chicken.djvu not found");
4631        let file = djvu_iff::parse(&data).expect("failed to parse chicken.djvu");
4632        let chunks = extract_bg44_chunks(&file);
4633        assert_eq!(chunks.len(), 3, "expected 3 BG44 chunks in chicken.djvu");
4634
4635        let mut img = Iw44Image::new();
4636        for c in &chunks {
4637            img.decode_chunk(c).expect("decode_chunk failed");
4638        }
4639        assert_eq!(img.width, 181);
4640        assert_eq!(img.height, 240);
4641
4642        let pm = img.to_rgb().expect("to_rgb failed");
4643        assert_ppm_match(&pm.to_ppm(), "chicken_bg.ppm");
4644    }
4645
4646    /// Direct gray decode (`to_gray8`) must match the dimensions of `to_rgb`
4647    /// and stay close to its Rec.601 luma, while skipping the chroma planes.
4648    #[test]
4649    fn iw44_to_gray8_matches_rgb_luma_boy() {
4650        let data = std::fs::read(assets_path().join("boy.djvu")).expect("boy.djvu not found");
4651        let file = djvu_iff::parse(&data).expect("failed to parse boy.djvu");
4652        let chunks = extract_bg44_chunks(&file);
4653        let mut img = Iw44Image::new();
4654        for c in &chunks {
4655            img.decode_chunk(c).expect("decode_chunk failed");
4656        }
4657
4658        let rgb = img.to_rgb().expect("to_rgb failed");
4659        let rgb_gray = rgb.to_gray8();
4660        let direct = img.to_gray8().expect("to_gray8 failed");
4661
4662        assert_eq!(direct.width, rgb_gray.width);
4663        assert_eq!(direct.height, rgb_gray.height);
4664        assert_eq!(direct.data.len(), rgb_gray.data.len());
4665
4666        if img.is_color {
4667            // Colour: Y-plane luma vs Rec.601-of-RGB differ by a few levels.
4668            let mut sum = 0u64;
4669            let mut max = 0u8;
4670            for (a, b) in direct.data.iter().zip(rgb_gray.data.iter()) {
4671                let d = a.abs_diff(*b);
4672                sum += d as u64;
4673                max = max.max(d);
4674            }
4675            let mean = sum as f64 / direct.data.len() as f64;
4676            assert!(mean < 4.0, "mean gray diff {mean} too high");
4677            assert!(max <= 24, "max gray diff {max} too high");
4678        } else {
4679            // Grayscale: must be byte-identical to the RGB→luma round-trip.
4680            assert_eq!(direct.data, rgb_gray.data, "gray path must be exact");
4681        }
4682    }
4683
4684    /// `to_gray8_subsample` dimensions must track `to_rgb_subsample` at sub 2/4.
4685    #[test]
4686    fn iw44_to_gray8_subsample_dims() {
4687        let data = std::fs::read(assets_path().join("boy.djvu")).expect("boy.djvu not found");
4688        let file = djvu_iff::parse(&data).expect("failed to parse boy.djvu");
4689        let chunks = extract_bg44_chunks(&file);
4690        let mut img = Iw44Image::new();
4691        for c in &chunks {
4692            img.decode_chunk(c).expect("decode_chunk failed");
4693        }
4694        for sub in [2u32, 4u32] {
4695            let g = img.to_gray8_subsample(sub).expect("gray sub");
4696            let rgb = img.to_rgb_subsample(sub).expect("rgb sub");
4697            assert_eq!((g.width, g.height), (rgb.width, rgb.height));
4698            assert_eq!(g.data.len(), (g.width * g.height) as usize);
4699        }
4700        assert!(matches!(
4701            img.to_gray8_subsample(0),
4702            Err(Iw44Error::InvalidSubsample)
4703        ));
4704    }
4705
4706    /// `to_rgb_subsample(2)` on boy.djvu must produce a pixel-exact result.
4707    ///
4708    /// This golden test guards against any regression in the compact-plane sub=2
4709    /// optimization path.  On first run the golden file is created from the
4710    /// current (correct) output; subsequent runs compare against it.
4711    #[test]
4712    fn iw44_new_decode_boy_sub2() {
4713        let data = std::fs::read(assets_path().join("boy.djvu")).expect("boy.djvu not found");
4714        let file = djvu_iff::parse(&data).expect("failed to parse boy.djvu");
4715        let chunks = extract_bg44_chunks(&file);
4716
4717        let mut img = Iw44Image::new();
4718        for c in &chunks {
4719            img.decode_chunk(c).expect("decode_chunk failed");
4720        }
4721        assert_eq!(img.width, 192);
4722        assert_eq!(img.height, 256);
4723
4724        let pm = img.to_rgb_subsample(2).expect("to_rgb_subsample(2) failed");
4725        assert_eq!(pm.width, 96, "sub=2 width must be ceil(192/2)");
4726        assert_eq!(pm.height, 128, "sub=2 height must be ceil(256/2)");
4727
4728        assert_or_create_golden(&pm.to_ppm(), "boy_bg_sub2.ppm");
4729    }
4730
4731    /// `to_rgb_subsample(2)` on big-scanned-page.djvu (color IW44).
4732    ///
4733    /// Exercises the compact-plane path on a large color document.
4734    #[test]
4735    fn iw44_new_decode_big_scanned_sub2() {
4736        let data = std::fs::read(assets_path().join("big-scanned-page.djvu"))
4737            .expect("big-scanned-page.djvu not found");
4738        let file = djvu_iff::parse(&data).expect("failed to parse big-scanned-page.djvu");
4739        let chunks = extract_bg44_chunks(&file);
4740
4741        let mut img = Iw44Image::new();
4742        for c in &chunks {
4743            img.decode_chunk(c).expect("decode_chunk failed");
4744        }
4745        assert_eq!(img.width, 6780);
4746        assert_eq!(img.height, 9148);
4747
4748        let pm = img.to_rgb_subsample(2).expect("to_rgb_subsample(2) failed");
4749        assert_eq!(pm.width, 3390, "sub=2 width must be ceil(6780/2)");
4750        assert_eq!(pm.height, 4574, "sub=2 height must be ceil(9148/2)");
4751
4752        assert_or_create_golden(&pm.to_ppm(), "big_scanned_sub2.ppm");
4753    }
4754
4755    #[test]
4756    fn iw44_new_decode_big_scanned_sub4() {
4757        let data = std::fs::read(assets_path().join("big-scanned-page.djvu"))
4758            .expect("big-scanned-page.djvu not found");
4759        let file = djvu_iff::parse(&data).expect("failed to parse big-scanned-page.djvu");
4760        let chunks = extract_bg44_chunks(&file);
4761        assert_eq!(chunks.len(), 4, "expected 4 BG44 chunks");
4762
4763        let mut img = Iw44Image::new();
4764        for c in &chunks {
4765            img.decode_chunk(c).expect("decode_chunk failed");
4766        }
4767        assert_eq!(img.width, 6780);
4768        assert_eq!(img.height, 9148);
4769
4770        let pm = img.to_rgb_subsample(4).expect("to_rgb_subsample failed");
4771        assert_ppm_match(&pm.to_ppm(), "big_scanned_sub4.ppm");
4772    }
4773
4774    /// Collect BG44 chunk payloads for every `FORM:DJVU` component in document
4775    /// order. Unlike `extract_bg44_chunks` (which stops at the first DJVU form),
4776    /// this walks the whole DJVM bundle so individual pages can be addressed.
4777    fn extract_bg44_chunks_per_page(file: &djvu_iff::DjvuFile) -> Vec<Vec<&[u8]>> {
4778        fn walk<'a>(chunk: &'a djvu_iff::Chunk, out: &mut Vec<Vec<&'a [u8]>>) {
4779            if let djvu_iff::Chunk::Form {
4780                secondary_id,
4781                children,
4782                ..
4783            } = chunk
4784            {
4785                if secondary_id == b"DJVU" {
4786                    let bg = children
4787                        .iter()
4788                        .filter_map(|c| match c {
4789                            djvu_iff::Chunk::Leaf {
4790                                id: [b'B', b'G', b'4', b'4'],
4791                                data,
4792                            } => Some(data.as_slice()),
4793                            _ => None,
4794                        })
4795                        .collect::<Vec<_>>();
4796                    out.push(bg);
4797                    return;
4798                }
4799                for c in children {
4800                    walk(c, out);
4801                }
4802            }
4803        }
4804        let mut out = Vec::new();
4805        walk(&file.root, &mut out);
4806        out
4807    }
4808
4809    /// Regression for the IW44 slice-loop early-exit bug (treadbear report,
4810    /// 2026-06-09; see PERF_EXPERIMENTS.md).
4811    ///
4812    /// Page 2 of `colorbook.djvu` packs its 97 slices into chunks dense enough
4813    /// that `zp.is_exhausted()` (a *byte*-buffer check) fires several bits
4814    /// before the last slice of a chunk is decoded. The reverted `break` on
4815    /// exhaustion therefore truncated real wavelet refinement, corrupting
4816    /// ~60% of pixels vs DjVuLibre. This golden pins the correct (full-slice)
4817    /// decode so the early-exit cannot be reintroduced.
4818    ///
4819    /// Verified to fail (page-2 background pixels diverge) if the
4820    /// `zp.is_exhausted()` early-exit is restored in `decode_chunk`.
4821    #[test]
4822    fn iw44_colorbook_page2_decodes_all_slices_no_early_exit() {
4823        let data =
4824            std::fs::read(assets_path().join("colorbook.djvu")).expect("colorbook.djvu not found");
4825        let file = djvu_iff::parse(&data).expect("failed to parse colorbook.djvu");
4826        let pages = extract_bg44_chunks_per_page(&file);
4827        let chunks = &pages[2];
4828        assert_eq!(chunks.len(), 4, "colorbook page 2 must have 4 BG44 chunks");
4829
4830        let mut img = Iw44Image::new();
4831        for c in chunks {
4832            img.decode_chunk(c).expect("decode_chunk failed");
4833        }
4834        assert_eq!((img.width, img.height), (739, 1213));
4835
4836        let pm = img.to_rgb().expect("to_rgb failed");
4837        assert_or_create_golden(&pm.to_ppm(), "colorbook_bg_p2.ppm");
4838    }
4839
4840    /// Progressive decode: feeding all chunks at once and feeding them one-by-one
4841    /// must produce identical results.
4842    #[test]
4843    fn iw44_new_progressive_matches_full_decode_chicken() {
4844        let data =
4845            std::fs::read(assets_path().join("chicken.djvu")).expect("chicken.djvu not found");
4846        let file = djvu_iff::parse(&data).expect("failed to parse");
4847        let chunks = extract_bg44_chunks(&file);
4848        assert!(
4849            chunks.len() > 1,
4850            "need multiple chunks for progressive test"
4851        );
4852
4853        // Full decode (all chunks at once via repeated decode_chunk calls)
4854        let mut full = Iw44Image::new();
4855        for c in &chunks {
4856            full.decode_chunk(c).expect("full decode failed");
4857        }
4858        let full_pm = full.to_rgb().expect("full to_rgb failed");
4859
4860        // Progressive decode — same result since ZP state persists
4861        let mut prog = Iw44Image::new();
4862        for c in chunks.iter().take(1) {
4863            prog.decode_chunk(c).expect("progressive decode failed");
4864        }
4865        for c in chunks.iter().skip(1) {
4866            prog.decode_chunk(c).expect("progressive decode failed");
4867        }
4868        let prog_pm = prog.to_rgb().expect("progressive to_rgb failed");
4869
4870        assert_eq!(
4871            full_pm.data, prog_pm.data,
4872            "progressive and full decode must produce identical pixels"
4873        );
4874    }
4875
4876    // ── v1.2 chroma-plane header interpretation ─────────────────────────────
4877
4878    /// IW44 v1.2's delay-byte high bit does not make the Cb/Cr planes half
4879    /// resolution.  `carte.djvu` has that bit clear but DjVuLibre decodes its
4880    /// full-resolution chroma planes; allocating them at half size desynchronizes
4881    /// their ZP streams into chroma noise.
4882    #[test]
4883    fn carte_v12_allocates_full_size_chroma_planes() {
4884        let data = std::fs::read(assets_path().join("carte.djvu")).expect("carte.djvu not found");
4885        let file = djvu_iff::parse(&data).expect("iff parse");
4886        let chunks = extract_bg44_chunks(&file);
4887        assert!(!chunks.is_empty(), "carte.djvu must have BG44 chunks");
4888
4889        let mut img = Iw44Image::new();
4890        img.decode_chunk(chunks[0]).expect("decode_chunk");
4891
4892        assert!(img.is_color(), "carte.djvu must be a color image");
4893        assert!(
4894            !img.chroma_half(),
4895            "v1.2 chroma planes must stay full resolution"
4896        );
4897        let (cw, ch) = img
4898            .chroma_plane_dims()
4899            .expect("chroma plane must be allocated after first color chunk");
4900        let lw = img.width as usize;
4901        let lh = img.height as usize;
4902        let expected_w = lw;
4903        let expected_h = lh;
4904        assert_eq!(
4905            cw, expected_w,
4906            "chroma plane width must equal luma_w={expected_w}, got {cw}"
4907        );
4908        assert_eq!(
4909            ch, expected_h,
4910            "chroma plane height must equal luma_h={expected_h}, got {ch}"
4911        );
4912    }
4913
4914    /// Decode the real v1.2 `carte.djvu` stream with full-resolution chroma.
4915    /// The fixed digest prevents a regression back to the half-plane
4916    /// interpretation that yielded chroma noise.
4917    #[test]
4918    fn iw44_new_decode_carte_bg_full_chroma() {
4919        let data = std::fs::read(assets_path().join("carte.djvu")).expect("carte.djvu not found");
4920        let file = djvu_iff::parse(&data).expect("iff parse");
4921        let chunks = extract_bg44_chunks(&file);
4922
4923        let mut img = Iw44Image::new();
4924        for c in &chunks {
4925            img.decode_chunk(c).expect("decode_chunk failed");
4926        }
4927        assert_eq!(img.width, 1400);
4928        assert_eq!(img.height, 852);
4929
4930        let pm = img.to_rgb().expect("to_rgb failed");
4931        let hash = pm.data.iter().fold(0xcbf29ce484222325u64, |hash, &byte| {
4932            (hash ^ u64::from(byte)).wrapping_mul(0x100000001b3)
4933        });
4934        assert_eq!(
4935            hash, 0x2118_2ba8_f124_2124,
4936            "carte full-chroma pixel digest"
4937        );
4938    }
4939
4940    // ── Error path tests ────────────────────────────────────────────────────
4941
4942    #[test]
4943    fn test_decode_empty_chunk() {
4944        let mut img = Iw44Image::new();
4945        let result = img.decode_chunk(&[]);
4946        assert!(result.is_err());
4947    }
4948
4949    #[test]
4950    fn test_decode_truncated_header() {
4951        let mut img = Iw44Image::new();
4952        // Only 2 bytes — not enough for a header
4953        let result = img.decode_chunk(&[0x00, 0x01]);
4954        assert!(result.is_err());
4955    }
4956
4957    #[test]
4958    fn test_to_rgb_before_decode() {
4959        let img = Iw44Image::new();
4960        // No chunks decoded yet — should fail
4961        let result = img.to_rgb();
4962        assert!(result.is_err());
4963    }
4964
4965    #[test]
4966    fn test_to_rgb_subsample_zero() {
4967        let img = Iw44Image::new();
4968        let result = img.to_rgb_subsample(0);
4969        assert!(result.is_err());
4970    }
4971
4972    // ---- SIMD YCbCr→RGBA tests -----------------------------------------------
4973
4974    /// `ycbcr_row_to_rgba` matches the scalar formula on synthetic data.
4975    #[test]
4976    fn simd_ycbcr_row_matches_scalar() {
4977        // Cover all 8-wide SIMD chunks plus a tail (n=20).
4978        let n = 20usize;
4979        let ys: Vec<i32> = (0..n).map(|i| (i as i32 * 7) % 200 - 100).collect();
4980        let bs: Vec<i32> = (0..n).map(|i| (i as i32 * 13) % 200 - 100).collect();
4981        let rs: Vec<i32> = (0..n).map(|i| (i as i32 * 17) % 200 - 100).collect();
4982
4983        // Scalar reference
4984        let mut expected = vec![0u8; n * 4];
4985        for col in 0..n {
4986            let y = ys[col];
4987            let b = bs[col];
4988            let r = rs[col];
4989            let t2 = r + (r >> 1);
4990            let t3 = y + 128 - (b >> 2);
4991            expected[col * 4] = (y + 128 + t2).clamp(0, 255) as u8;
4992            expected[col * 4 + 1] = (t3 - (t2 >> 1)).clamp(0, 255) as u8;
4993            expected[col * 4 + 2] = (t3 + (b << 1)).clamp(0, 255) as u8;
4994            expected[col * 4 + 3] = 255;
4995        }
4996
4997        // SIMD result
4998        let mut actual = vec![0u8; n * 4];
4999        super::ycbcr_row_to_rgba(&ys, &bs, &rs, &mut actual);
5000
5001        assert_eq!(
5002            expected, actual,
5003            "SIMD must produce identical output to scalar"
5004        );
5005    }
5006
5007    /// `ycbcr_row_to_rgba` handles extreme values (clamping at 0 and 255).
5008    #[test]
5009    fn simd_ycbcr_row_clamps_correctly() {
5010        let n = 8usize;
5011        // Use values that will clamp to 0 and 255 in each channel.
5012        let ys: Vec<i32> = vec![127, -128, 127, -128, 0, 0, 0, 0];
5013        let bs: Vec<i32> = vec![-128, 127, -128, 127, 0, 0, 0, 0];
5014        let rs: Vec<i32> = vec![127, -128, -128, 127, 0, 0, 0, 0];
5015
5016        let mut simd_out = vec![0u8; n * 4];
5017        super::ycbcr_row_to_rgba(&ys, &bs, &rs, &mut simd_out);
5018
5019        // All RGBA values must be in [0, 255] and alpha == 255.
5020        for chunk in simd_out.as_chunks::<4>().0 {
5021            assert_eq!(chunk[3], 255, "alpha must always be 255");
5022        }
5023    }
5024
5025    /// SIMD render of boy.djvu produces identical output to the scalar path.
5026    ///
5027    /// This verifies that the fast path (sub=1) and general path (sub=2, which
5028    /// uses the old scalar code) produce consistent results on a real file.
5029    #[test]
5030    fn simd_render_matches_subsampled_render_dimensions() {
5031        let data = std::fs::read(assets_path().join("boy.djvu")).expect("boy.djvu not found");
5032        let file = djvu_iff::parse(&data).expect("parse failed");
5033        let chunks = extract_bg44_chunks(&file);
5034
5035        let mut img = Iw44Image::new();
5036        for c in &chunks {
5037            img.decode_chunk(c).expect("decode_chunk failed");
5038        }
5039
5040        // Full-resolution render uses SIMD path (sub=1).
5041        let full = img.to_rgb().expect("to_rgb failed");
5042        // sub=2 uses the scalar general path — just check dims match half.
5043        let half = img.to_rgb_subsample(2).expect("subsample(2) failed");
5044
5045        assert_eq!(full.width, img.width);
5046        assert_eq!(full.height, img.height);
5047        assert_eq!(half.width, img.width.div_ceil(2));
5048        assert_eq!(half.height, img.height.div_ceil(2));
5049        // SIMD path must still pass the existing golden test (done in iw44_new_decode_boy_bg).
5050    }
5051
5052    /// SIMD row pass (8 rows at a time) produces identical results to the scalar
5053    /// path on a synthetic 32×16 plane with a deterministic non-trivial pattern.
5054    ///
5055    /// Both paths are exercised by calling `row_pass_inner` with `use_simd=false`
5056    /// (all scalar) and `use_simd=true` (SIMD + scalar tail) on identical copies
5057    /// of the same data.
5058    #[test]
5059    fn simd_row_pass_matches_scalar() {
5060        let width = 32usize;
5061        let height = 16usize;
5062        let stride = width;
5063        let n = stride * height;
5064
5065        // Deterministic non-trivial pattern: values in [-255, 255].
5066        let initial: Vec<i16> = (0..n).map(|i| ((i * 7 + 13) % 511) as i16 - 255).collect();
5067
5068        let mut scalar_data = initial.clone();
5069        // s=1, sd=0, use_simd=false → pure scalar
5070        super::row_pass_inner(&mut scalar_data, width, height, stride, 1, 0, false);
5071
5072        let mut simd_data = initial.clone();
5073        // s=1, sd=0, use_simd=true → SIMD for rows 0..15, scalar tail for remainder
5074        super::row_pass_inner(&mut simd_data, width, height, stride, 1, 0, true);
5075
5076        assert_eq!(
5077            scalar_data, simd_data,
5078            "SIMD row pass must produce identical output to scalar"
5079        );
5080    }
5081
5082    /// Same as `simd_row_pass_matches_scalar` but for s=2 (sd=1).
5083    ///
5084    /// Active rows are every other row; active columns are every other column.
5085    /// The generalised SIMD path (8 active rows at a time with stride s) must
5086    /// produce the same result as the pure scalar path.
5087    #[test]
5088    fn simd_row_pass_s2_matches_scalar() {
5089        let width = 64usize;
5090        let height = 32usize;
5091        let stride = width;
5092        let n = stride * height;
5093        let s = 2usize;
5094        let sd = 1usize;
5095
5096        let initial: Vec<i16> = (0..n).map(|i| ((i * 7 + 13) % 511) as i16 - 255).collect();
5097
5098        let mut scalar_data = initial.clone();
5099        super::row_pass_inner(&mut scalar_data, width, height, stride, s, sd, false);
5100
5101        let mut simd_data = initial.clone();
5102        super::row_pass_inner(&mut simd_data, width, height, stride, s, sd, true);
5103
5104        assert_eq!(
5105            scalar_data, simd_data,
5106            "SIMD row pass (s=2) must produce identical output to scalar"
5107        );
5108    }
5109
5110    /// Reference scalar implementation of the fused-normalize YCbCr→RGBA path.
5111    /// Mirrors `ycbcr_neon_raw` byte-for-byte (same formula, same clamps).
5112    #[cfg(all(target_arch = "x86_64", feature = "std"))]
5113    fn ycbcr_raw_scalar(y: &[i16], cb: &[i16], cr: &[i16], out: &mut [u8]) {
5114        let w = y.len();
5115        for col in 0..w {
5116            let yn = super::normalize(y[col]);
5117            let bn = super::normalize(cb[col]);
5118            let rn = super::normalize(cr[col]);
5119            let t2 = rn + (rn >> 1);
5120            let t3 = yn + 128 - (bn >> 2);
5121            out[col * 4] = (yn + 128 + t2).clamp(0, 255) as u8;
5122            out[col * 4 + 1] = (t3 - (t2 >> 1)).clamp(0, 255) as u8;
5123            out[col * 4 + 2] = (t3 + (bn << 1)).clamp(0, 255) as u8;
5124            out[col * 4 + 3] = 255;
5125        }
5126    }
5127
5128    #[cfg(all(target_arch = "x86_64", feature = "std"))]
5129    fn ycbcr_raw_half_scalar(y: &[i16], cb: &[i16], cr: &[i16], out: &mut [u8]) {
5130        let w = y.len();
5131        for col in 0..w {
5132            let yn = super::normalize(y[col]);
5133            let bn = super::normalize(cb[col / 2]);
5134            let rn = super::normalize(cr[col / 2]);
5135            let t2 = rn + (rn >> 1);
5136            let t3 = yn + 128 - (bn >> 2);
5137            out[col * 4] = (yn + 128 + t2).clamp(0, 255) as u8;
5138            out[col * 4 + 1] = (t3 - (t2 >> 1)).clamp(0, 255) as u8;
5139            out[col * 4 + 2] = (t3 + (bn << 1)).clamp(0, 255) as u8;
5140            out[col * 4 + 3] = 255;
5141        }
5142    }
5143
5144    /// AVX2 fused-normalize YCbCr→RGBA must agree byte-for-byte with the scalar
5145    /// reference across the full i16 input range and all width residues mod 16
5146    /// (covers main loop + scalar tail).
5147    #[cfg(all(target_arch = "x86_64", feature = "std"))]
5148    #[test]
5149    fn ycbcr_avx2_raw_matches_scalar() {
5150        if !std::is_x86_feature_detected!("avx2") {
5151            eprintln!("skipping: AVX2 not available on this host");
5152            return;
5153        }
5154        // Range chosen to exercise normalize + clamp + every arithmetic branch.
5155        let raw_vals: [i16; 8] = [-32768, -8192, -64, -1, 0, 63, 8191, 32767];
5156        for &width in &[1usize, 7, 16, 17, 31, 32, 33, 47, 48, 64, 100] {
5157            let n = width;
5158            let make_seq = |seed: usize| -> Vec<i16> {
5159                (0..n)
5160                    .map(|i| raw_vals[(i + seed) % raw_vals.len()])
5161                    .collect()
5162            };
5163            let y = make_seq(0);
5164            let cb = make_seq(3);
5165            let cr = make_seq(5);
5166
5167            let mut got = vec![0u8; n * 4];
5168            #[allow(unsafe_code)]
5169            unsafe {
5170                super::ycbcr_avx2_raw(y.as_ptr(), cb.as_ptr(), cr.as_ptr(), got.as_mut_ptr(), n);
5171            }
5172
5173            let mut want = vec![0u8; n * 4];
5174            ycbcr_raw_scalar(&y, &cb, &cr, &mut want);
5175
5176            assert_eq!(got, want, "AVX2 raw mismatch at width {}", width);
5177        }
5178    }
5179
5180    /// AVX2 stride-1 load/store must round-trip the full i16 range
5181    /// bit-exactly through an i32x8.
5182    #[cfg(all(target_arch = "x86_64", feature = "std"))]
5183    #[test]
5184    fn load8s_s1_avx2_matches_scalar() {
5185        if !std::is_x86_feature_detected!("avx2") {
5186            eprintln!("skipping: AVX2 not available on this host");
5187            return;
5188        }
5189        let raw_vals: [i16; 8] = [-32768, -8192, -64, -1, 0, 63, 8191, 32767];
5190        let n = 64;
5191        let buf: Vec<i16> = (0..n).map(|i| raw_vals[i % raw_vals.len()]).collect();
5192        for phys_off in 0..(n - 8) {
5193            #[allow(unsafe_code)]
5194            let got = unsafe { super::load8s_s1_avx2(&buf, phys_off) };
5195            let want = super::load8s(&buf, phys_off, 1);
5196            assert_eq!(
5197                got.to_array(),
5198                want.to_array(),
5199                "AVX2 load8s_s1 mismatch at phys_off {}",
5200                phys_off
5201            );
5202        }
5203    }
5204
5205    /// AVX2 stride-1 store must truncate i32→i16 (drop upper 16 bits, no
5206    /// saturation) matching the scalar `as i16` cast for every input.
5207    #[cfg(all(target_arch = "x86_64", feature = "std"))]
5208    #[test]
5209    fn store8s_s1_avx2_matches_scalar() {
5210        if !std::is_x86_feature_detected!("avx2") {
5211            eprintln!("skipping: AVX2 not available on this host");
5212            return;
5213        }
5214        // Inputs that exercise truncation: values that don't fit in i16,
5215        // negative values, and boundaries.
5216        let raw_vals: [i32; 8] = [i32::MIN, -100_000, -32768, -1, 0, 32767, 100_000, i32::MAX];
5217        for offset in 0..8usize {
5218            let mut input = [0i32; 8];
5219            for j in 0..8 {
5220                input[j] = raw_vals[(j + offset) % 8];
5221            }
5222            let v = wide::i32x8::from(input);
5223
5224            // AVX2 store with surrounding sentinel bytes to detect over-write.
5225            let mut buf_avx2 = vec![0xABCDu16 as i16; 32];
5226            #[allow(unsafe_code)]
5227            unsafe {
5228                super::store8s_s1_avx2(&mut buf_avx2, 8, v);
5229            }
5230            // Scalar reference using stride-1 store (which on this host is
5231            // also the AVX2 path; route through stride-2 to force scalar).
5232            let mut buf_scalar = vec![0xABCDu16 as i16; 32];
5233            for j in 0..8 {
5234                buf_scalar[8 + j] = input[j] as i16;
5235            }
5236            assert_eq!(buf_avx2, buf_scalar, "AVX2 store8s_s1 mismatch");
5237        }
5238    }
5239
5240    /// AVX2 `prelim_flags_bucket_avx2` must produce identical bucket bytes
5241    /// and bstatetmp to the scalar fallback for any 16-coef input.
5242    #[cfg(all(target_arch = "x86_64", feature = "std"))]
5243    #[test]
5244    fn prelim_flags_bucket_avx2_matches_scalar() {
5245        if !std::is_x86_feature_detected!("avx2") {
5246            eprintln!("skipping: AVX2 not available on this host");
5247            return;
5248        }
5249        // Inputs that exercise the all-zero, all-nonzero, mixed, and edge-value cases.
5250        let test_vectors: &[[i16; 16]] = &[
5251            [0; 16],
5252            [
5253                1, 0, -1, 0, 100, 0, -200, 0, 0, 1234, 0, -1234, 0, 32767, -32768, 0,
5254            ],
5255            [1; 16],
5256            [-1; 16],
5257            [
5258                32767, -32768, 1, -1, 0, 0, 0, 0, 0, 0, 0, 0, 32767, -32768, 1, -1,
5259            ],
5260        ];
5261        for &coefs in test_vectors {
5262            let mut bucket_avx2 = [0u8; 16];
5263            #[allow(unsafe_code)]
5264            let bstate_avx2 = unsafe { super::prelim_flags_bucket_avx2(&coefs, &mut bucket_avx2) };
5265
5266            let mut bucket_scalar = [0u8; 16];
5267            let mut bstate_scalar = 0u8;
5268            for k in 0..16 {
5269                let f = if coefs[k] == 0 {
5270                    super::UNK
5271                } else {
5272                    super::ACTIVE
5273                };
5274                bucket_scalar[k] = f;
5275                bstate_scalar |= f;
5276            }
5277
5278            assert_eq!(
5279                bucket_avx2, bucket_scalar,
5280                "bucket mismatch for coefs={coefs:?}"
5281            );
5282            assert_eq!(
5283                bstate_avx2, bstate_scalar,
5284                "bstatetmp mismatch for coefs={coefs:?}"
5285            );
5286        }
5287    }
5288
5289    /// AVX2 `prelim_flags_band0_avx2` must mirror the scalar band-0 update:
5290    /// only entries with `old_flags[k] != ZERO` are rewritten; other entries
5291    /// stay (so a ZERO-state lane is preserved across the call).
5292    #[cfg(all(target_arch = "x86_64", feature = "std"))]
5293    #[test]
5294    fn prelim_flags_band0_avx2_matches_scalar() {
5295        if !std::is_x86_feature_detected!("avx2") {
5296            eprintln!("skipping: AVX2 not available on this host");
5297            return;
5298        }
5299        // Old-flag patterns covering the three states and mixed.
5300        let old_patterns: &[[u8; 16]] = &[
5301            [super::ZERO; 16],
5302            [super::UNK; 16],
5303            [super::ACTIVE; 16],
5304            [
5305                super::ZERO,
5306                super::UNK,
5307                super::ACTIVE,
5308                super::ZERO,
5309                super::UNK,
5310                super::ACTIVE,
5311                super::ZERO,
5312                super::UNK,
5313                super::ACTIVE,
5314                super::ZERO,
5315                super::UNK,
5316                super::ACTIVE,
5317                super::ZERO,
5318                super::UNK,
5319                super::ACTIVE,
5320                super::ZERO,
5321            ],
5322        ];
5323        let coef_patterns: &[[i16; 16]] = &[
5324            [0; 16],
5325            [1; 16],
5326            [0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1],
5327            [
5328                -32768, 0, 32767, 0, 100, 0, -100, 0, 0, 1, 0, -1, 0, 5, 0, -5,
5329            ],
5330        ];
5331
5332        for &old in old_patterns {
5333            for &coefs in coef_patterns {
5334                let mut flags_avx2 = old;
5335                #[allow(unsafe_code)]
5336                let bstate_avx2 =
5337                    unsafe { super::prelim_flags_band0_avx2(&coefs, &mut flags_avx2) };
5338
5339                let mut flags_scalar = old;
5340                let mut bstate_scalar = 0u8;
5341                for k in 0..16 {
5342                    if flags_scalar[k] != super::ZERO {
5343                        flags_scalar[k] = if coefs[k] == 0 {
5344                            super::UNK
5345                        } else {
5346                            super::ACTIVE
5347                        };
5348                    }
5349                    bstate_scalar |= flags_scalar[k];
5350                }
5351
5352                assert_eq!(
5353                    flags_avx2, flags_scalar,
5354                    "flags mismatch old={old:?} coefs={coefs:?}"
5355                );
5356                assert_eq!(bstate_avx2, bstate_scalar, "bstatetmp mismatch");
5357            }
5358        }
5359    }
5360
5361    #[cfg(all(target_arch = "x86_64", feature = "std"))]
5362    #[test]
5363    fn ycbcr_avx2_raw_half_matches_scalar() {
5364        if !std::is_x86_feature_detected!("avx2") {
5365            eprintln!("skipping: AVX2 not available on this host");
5366            return;
5367        }
5368        let raw_vals: [i16; 8] = [-32768, -8192, -64, -1, 0, 63, 8191, 32767];
5369        for &width in &[2usize, 8, 16, 18, 30, 32, 34, 48, 64, 96] {
5370            let n = width;
5371            let half = n.div_ceil(2);
5372            let make_seq = |seed: usize, len: usize| -> Vec<i16> {
5373                (0..len)
5374                    .map(|i| raw_vals[(i + seed) % raw_vals.len()])
5375                    .collect()
5376            };
5377            let y = make_seq(0, n);
5378            let cb_half = make_seq(3, half);
5379            let cr_half = make_seq(5, half);
5380
5381            let mut got = vec![0u8; n * 4];
5382            #[allow(unsafe_code)]
5383            unsafe {
5384                super::ycbcr_avx2_raw_half(
5385                    y.as_ptr(),
5386                    cb_half.as_ptr(),
5387                    cr_half.as_ptr(),
5388                    got.as_mut_ptr(),
5389                    n,
5390                );
5391            }
5392
5393            let mut want = vec![0u8; n * 4];
5394            ycbcr_raw_half_scalar(&y, &cb_half, &cr_half, &mut want);
5395
5396            assert_eq!(got, want, "AVX2 raw_half mismatch at width {}", width);
5397        }
5398    }
5399
5400    /// WASM simd128 stride-1 load must sign-extend i16→i32 correctly.
5401    ///
5402    /// Mirrors `load8s_s1_avx2_matches_scalar` but for the simd128 path.
5403    /// Runs only when compiled for wasm32 with +simd128; host tests use the
5404    /// AVX2 or scalar path instead.
5405    #[cfg(all(target_arch = "wasm32", target_feature = "simd128"))]
5406    #[test]
5407    fn load8s_s1_simd128_matches_scalar() {
5408        let raw_vals: [i16; 8] = [-32768, -8192, -64, -1, 0, 63, 8191, 32767];
5409        let n = 64;
5410        let buf: alloc::vec::Vec<i16> = (0..n).map(|i| raw_vals[i % raw_vals.len()]).collect();
5411        for phys_off in 0..(n - 8) {
5412            #[allow(unsafe_code)]
5413            let got = unsafe { super::load8s_s1_simd128(&buf, phys_off) };
5414            let want = super::load8s(&buf, phys_off, 1);
5415            assert_eq!(
5416                got.to_array(),
5417                want.to_array(),
5418                "simd128 load8s_s1 mismatch at phys_off {}",
5419                phys_off
5420            );
5421        }
5422    }
5423
5424    /// WASM simd128 stride-1 store must truncate i32→i16 (drop upper 16 bits, no
5425    /// saturation) matching the scalar `as i16` cast for every input.
5426    ///
5427    /// Mirrors `store8s_s1_avx2_matches_scalar`.
5428    #[cfg(all(target_arch = "wasm32", target_feature = "simd128"))]
5429    #[test]
5430    fn store8s_s1_simd128_matches_scalar() {
5431        let raw_vals: [i32; 8] = [i32::MIN, -100_000, -32768, -1, 0, 32767, 100_000, i32::MAX];
5432        for offset in 0..8usize {
5433            let mut input = [0i32; 8];
5434            for j in 0..8 {
5435                input[j] = raw_vals[(j + offset) % 8];
5436            }
5437            let v = wide::i32x8::from(input);
5438
5439            let mut buf_simd128 = alloc::vec![0xABCDu16 as i16; 32];
5440            #[allow(unsafe_code)]
5441            unsafe {
5442                super::store8s_s1_simd128(&mut buf_simd128, 8, v);
5443            }
5444            let mut buf_scalar = alloc::vec![0xABCDu16 as i16; 32];
5445            for j in 0..8 {
5446                buf_scalar[8 + j] = input[j] as i16;
5447            }
5448            assert_eq!(buf_simd128, buf_scalar, "simd128 store8s_s1 mismatch");
5449        }
5450    }
5451}