Files
game_engine/docs/superpowers/plans/2026-03-25-phase2-jpg-decoder.md
2026-03-25 19:58:56 +09:00

31 KiB

JPG Decoder Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Self-contained Baseline JPEG decoder returning RGBA pixel data, matching existing parse_png API pattern.

Architecture: JFIF marker-based parser → Huffman bit-stream decoder → inverse quantization → 8x8 IDCT → YCbCr→RGB → MCU assembly. Single file jpg.rs with helper structs, registered in lib.rs.

Tech Stack: Pure Rust, no external dependencies. Result<(Vec<u8>, u32, u32), String> error pattern.


Task 1: JFIF Marker Parser + Skeleton

Files:

  • Create: crates/voltex_renderer/src/jpg.rs

  • Modify: crates/voltex_renderer/src/lib.rs

  • Step 1: Write failing test for marker parsing

// In crates/voltex_renderer/src/jpg.rs
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_invalid_signature() {
        let data = [0u8; 10];
        assert!(parse_jpg(&data).is_err());
    }

    #[test]
    fn test_empty_data() {
        assert!(parse_jpg(&[]).is_err());
    }

    #[test]
    fn test_soi_only() {
        let data = [0xFF, 0xD8]; // SOI only, no SOF/SOS
        assert!(parse_jpg(&data).is_err());
    }
}
  • Step 2: Run test to verify it fails

Run: cargo test --package voltex_renderer -- jpg::tests -v Expected: FAIL — module jpg not found

  • Step 3: Implement marker parser skeleton
// crates/voltex_renderer/src/jpg.rs

/// Baseline JPEG decoder. Supports SOF0 (sequential DCT, Huffman).
/// Returns RGBA pixel data like parse_png.

pub fn parse_jpg(data: &[u8]) -> Result<(Vec<u8>, u32, u32), String> {
    if data.len() < 2 || data[0] != 0xFF || data[1] != 0xD8 {
        return Err("Invalid JPEG: missing SOI marker".into());
    }

    let mut pos = 2;
    let mut width: u16 = 0;
    let mut height: u16 = 0;
    let mut num_components: u8 = 0;
    let mut components: Vec<JpegComponent> = Vec::new();
    let mut qt_tables: [[u16; 64]; 4] = [[0; 64]; 4];
    let mut dc_tables: [Option<HuffTable>; 4] = [None, None, None, None];
    let mut ac_tables: [Option<HuffTable>; 4] = [None, None, None, None];
    let mut found_sof = false;

    while pos + 1 < data.len() {
        if data[pos] != 0xFF {
            return Err(format!("Expected marker at position {}", pos));
        }
        // Skip padding 0xFF bytes
        while pos + 1 < data.len() && data[pos + 1] == 0xFF {
            pos += 1;
        }
        if pos + 1 >= data.len() {
            return Err("Unexpected end of data".into());
        }
        let marker = data[pos + 1];
        pos += 2;

        match marker {
            0xD8 => {} // SOI (already handled)
            0xD9 => break, // EOI
            0xDA => {
                // SOS — Start of Scan
                if !found_sof {
                    return Err("SOS before SOF".into());
                }
                let (rgb, scan_end) = decode_scan(
                    data, pos, width, height, num_components,
                    &components, &qt_tables, &dc_tables, &ac_tables,
                )?;
                pos = scan_end;
                // Convert RGB to RGBA
                let w = width as u32;
                let h = height as u32;
                let mut rgba = Vec::with_capacity((w * h * 4) as usize);
                for pixel in rgb.chunks_exact(3) {
                    rgba.push(pixel[0]);
                    rgba.push(pixel[1]);
                    rgba.push(pixel[2]);
                    rgba.push(255);
                }
                return Ok((rgba, w, h));
            }
            0xC0 => {
                // SOF0 — Baseline DCT
                let (sof, len) = parse_sof(data, pos)?;
                width = sof.width;
                height = sof.height;
                num_components = sof.num_components;
                components = sof.components;
                found_sof = true;
                pos += len;
            }
            0xC4 => {
                // DHT — Define Huffman Table
                let len = parse_dht(data, pos, &mut dc_tables, &mut ac_tables)?;
                pos += len;
            }
            0xDB => {
                // DQT — Define Quantization Table
                let len = parse_dqt(data, pos, &mut qt_tables)?;
                pos += len;
            }
            0xDD => {
                // DRI — Define Restart Interval
                if pos + 4 > data.len() { return Err("DRI too short".into()); }
                // Skip restart interval (2 byte length + 2 byte interval)
                let seg_len = u16::from_be_bytes([data[pos], data[pos + 1]]) as usize;
                pos += seg_len;
            }
            0xD0..=0xD7 => {
                // RST markers — handled inside scan decoder
            }
            0xE0..=0xEF | 0xFE => {
                // APP0-APP15, COM — skip
                if pos + 2 > data.len() { return Err("Segment too short".into()); }
                let seg_len = u16::from_be_bytes([data[pos], data[pos + 1]]) as usize;
                pos += seg_len;
            }
            _ => {
                // Unknown marker with length — skip
                if pos + 2 > data.len() { return Err(format!("Unknown marker 0x{:02X}", marker)); }
                let seg_len = u16::from_be_bytes([data[pos], data[pos + 1]]) as usize;
                pos += seg_len;
            }
        }
    }

    Err("No image data found (missing SOS)".into())
}

#[derive(Clone)]
struct JpegComponent {
    id: u8,
    h_sample: u8,  // horizontal sampling factor
    v_sample: u8,  // vertical sampling factor
    qt_id: u8,     // quantization table ID
    dc_table: u8,  // DC Huffman table ID
    ac_table: u8,  // AC Huffman table ID
}

struct SofData {
    width: u16,
    height: u16,
    num_components: u8,
    components: Vec<JpegComponent>,
}

struct HuffTable {
    // lookup[code_length - 1] = Vec of (code, symbol)
    symbols: Vec<u8>,     // symbols in code-length order
    offsets: [u16; 17],   // offset into symbols for each code length
    maxcode: [i32; 17],   // max code value for each length (-1 if unused)
    mincode: [u16; 17],   // min code value for each length
}

Register module in lib.rs:

pub mod jpg;
pub use jpg::parse_jpg;
  • Step 4: Run tests to verify they pass

Run: cargo test --package voltex_renderer -- jpg::tests -v Expected: 3 tests PASS

  • Step 5: Commit
git add crates/voltex_renderer/src/jpg.rs crates/voltex_renderer/src/lib.rs
git commit -m "feat(renderer): add JPEG decoder skeleton with marker parser"

Task 2: Quantization Table (DQT) + Huffman Table (DHT) Parsers

Files:

  • Modify: crates/voltex_renderer/src/jpg.rs

  • Step 1: Write tests for DQT and DHT parsing

#[test]
fn test_parse_dqt_8bit() {
    // DQT segment: length=67, precision=0(8-bit), table_id=0, 64 bytes of quantization values
    let mut seg = Vec::new();
    seg.extend_from_slice(&67u16.to_be_bytes()); // segment length
    seg.push(0x00); // precision=0 (8-bit), table_id=0
    for i in 0..64u8 {
        seg.push(i + 1); // quant values 1..64
    }
    let mut qt_tables = [[0u16; 64]; 4];
    let len = parse_dqt(&seg, 0, &mut qt_tables).unwrap();
    assert_eq!(len, 67);
    assert_eq!(qt_tables[0][0], 1);
    assert_eq!(qt_tables[0][63], 64);
}

#[test]
fn test_parse_dht() {
    // DHT segment: DC table, class=0, id=0
    // Simple table: 1 symbol of length 1 (symbol = 0x05)
    let mut seg = Vec::new();
    let mut body = Vec::new();
    body.push(0x00); // class=0 (DC), id=0
    // counts: 1 symbol at length 1, 0 for lengths 2-16
    body.push(1);
    for _ in 1..16 { body.push(0); }
    body.push(0x05); // the symbol

    seg.extend_from_slice(&((body.len() + 2) as u16).to_be_bytes());
    seg.extend_from_slice(&body);

    let mut dc_tables: [Option<HuffTable>; 4] = [None, None, None, None];
    let mut ac_tables: [Option<HuffTable>; 4] = [None, None, None, None];
    let len = parse_dht(&seg, 0, &mut dc_tables, &mut ac_tables).unwrap();
    assert!(dc_tables[0].is_some());
    let table = dc_tables[0].as_ref().unwrap();
    assert_eq!(table.symbols[0], 0x05);
}
  • Step 2: Run tests to verify they fail

Run: cargo test --package voltex_renderer -- jpg::tests::test_parse_dqt -v Expected: FAIL — parse_dqt and parse_dht not found / not implemented

  • Step 3: Implement DQT and DHT parsers
fn parse_dqt(data: &[u8], pos: usize, qt_tables: &mut [[u16; 64]; 4]) -> Result<usize, String> {
    if pos + 2 > data.len() { return Err("DQT too short".into()); }
    let seg_len = u16::from_be_bytes([data[pos], data[pos + 1]]) as usize;
    if pos + seg_len > data.len() { return Err("DQT segment extends past data".into()); }

    let mut off = pos + 2;
    let seg_end = pos + seg_len;
    while off < seg_end {
        let pq_tq = data[off];
        let precision = pq_tq >> 4; // 0=8-bit, 1=16-bit
        let table_id = (pq_tq & 0x0F) as usize;
        off += 1;
        if table_id >= 4 { return Err(format!("DQT table id {} out of range", table_id)); }

        if precision == 0 {
            // 8-bit values
            if off + 64 > seg_end { return Err("DQT 8-bit data too short".into()); }
            for i in 0..64 {
                qt_tables[table_id][i] = data[off + i] as u16;
            }
            off += 64;
        } else {
            // 16-bit values
            if off + 128 > seg_end { return Err("DQT 16-bit data too short".into()); }
            for i in 0..64 {
                qt_tables[table_id][i] = u16::from_be_bytes([data[off + i * 2], data[off + i * 2 + 1]]);
            }
            off += 128;
        }
    }
    Ok(seg_len)
}

fn parse_sof(data: &[u8], pos: usize) -> Result<(SofData, usize), String> {
    if pos + 2 > data.len() { return Err("SOF too short".into()); }
    let seg_len = u16::from_be_bytes([data[pos], data[pos + 1]]) as usize;
    if pos + seg_len > data.len() { return Err("SOF segment extends past data".into()); }

    let precision = data[pos + 2];
    if precision != 8 { return Err(format!("Unsupported sample precision: {}", precision)); }

    let height = u16::from_be_bytes([data[pos + 3], data[pos + 4]]);
    let width = u16::from_be_bytes([data[pos + 5], data[pos + 6]]);
    let num_comp = data[pos + 7];

    let mut components = Vec::new();
    let mut off = pos + 8;
    for _ in 0..num_comp {
        if off + 3 > pos + seg_len { return Err("SOF component data too short".into()); }
        let id = data[off];
        let sampling = data[off + 1];
        let h_sample = sampling >> 4;
        let v_sample = sampling & 0x0F;
        let qt_id = data[off + 2];
        components.push(JpegComponent {
            id, h_sample, v_sample, qt_id,
            dc_table: 0, ac_table: 0,
        });
        off += 3;
    }

    Ok((SofData { width, height, num_components: num_comp, components }, seg_len))
}

fn parse_dht(
    data: &[u8], pos: usize,
    dc_tables: &mut [Option<HuffTable>; 4],
    ac_tables: &mut [Option<HuffTable>; 4],
) -> Result<usize, String> {
    if pos + 2 > data.len() { return Err("DHT too short".into()); }
    let seg_len = u16::from_be_bytes([data[pos], data[pos + 1]]) as usize;
    if pos + seg_len > data.len() { return Err("DHT segment extends past data".into()); }

    let mut off = pos + 2;
    let seg_end = pos + seg_len;
    while off < seg_end {
        let tc_th = data[off];
        let table_class = tc_th >> 4; // 0=DC, 1=AC
        let table_id = (tc_th & 0x0F) as usize;
        off += 1;
        if table_id >= 4 { return Err(format!("DHT table id {} out of range", table_id)); }

        // Read 16 counts (number of symbols for each code length 1..16)
        if off + 16 > seg_end { return Err("DHT counts too short".into()); }
        let mut counts = [0u8; 16];
        counts.copy_from_slice(&data[off..off + 16]);
        off += 16;

        let total_symbols: usize = counts.iter().map(|&c| c as usize).sum();
        if off + total_symbols > seg_end { return Err("DHT symbols too short".into()); }

        let symbols: Vec<u8> = data[off..off + total_symbols].to_vec();
        off += total_symbols;

        // Build lookup tables
        let mut offsets = [0u16; 17];
        let mut maxcode = [-1i32; 17];
        let mut mincode = [0u16; 17];
        let mut code: u16 = 0;
        let mut sym_offset: u16 = 0;

        for i in 0..16 {
            offsets[i] = sym_offset;
            if counts[i] > 0 {
                mincode[i] = code;
                maxcode[i] = (code + counts[i] as u16 - 1) as i32;
                sym_offset += counts[i] as u16;
            }
            code = (code + counts[i] as u16) << 1;
        }
        offsets[16] = sym_offset;

        let table = HuffTable { symbols, offsets, maxcode, mincode };
        if table_class == 0 {
            dc_tables[table_id] = Some(table);
        } else {
            ac_tables[table_id] = Some(table);
        }
    }
    Ok(seg_len)
}
  • Step 4: Run tests to verify they pass

Run: cargo test --package voltex_renderer -- jpg::tests -v Expected: All PASS

  • Step 5: Commit
git add crates/voltex_renderer/src/jpg.rs
git commit -m "feat(renderer): add JPEG DQT/DHT/SOF marker parsers"

Task 3: Huffman Bit-Stream Reader

Files:

  • Modify: crates/voltex_renderer/src/jpg.rs

  • Step 1: Write tests for bit reader

#[test]
fn test_bit_reader_basic() {
    // 0xA5 = 10100101
    let data = [0xA5];
    let mut reader = BitReader::new(&data, 0);
    assert_eq!(reader.read_bits(1).unwrap(), 1); // 1
    assert_eq!(reader.read_bits(1).unwrap(), 0); // 0
    assert_eq!(reader.read_bits(3).unwrap(), 0b100); // 100
    assert_eq!(reader.read_bits(3).unwrap(), 0b101); // 101
}

#[test]
fn test_bit_reader_byte_stuffing() {
    // JPEG byte stuffing: 0xFF 0x00 → single 0xFF byte
    let data = [0xFF, 0x00, 0x80];
    let mut reader = BitReader::new(&data, 0);
    let val = reader.read_bits(8).unwrap();
    assert_eq!(val, 0xFF);
    let val2 = reader.read_bits(1).unwrap();
    assert_eq!(val2, 1); // 0x80 = 10000000
}
  • Step 2: Run tests to verify they fail

  • Step 3: Implement BitReader

struct BitReader<'a> {
    data: &'a [u8],
    pos: usize,
    bit_pos: u8,    // 0-7, MSB first
    current: u8,
}

impl<'a> BitReader<'a> {
    fn new(data: &'a [u8], start: usize) -> Self {
        Self { data, pos: start, bit_pos: 0, current: 0 }
    }

    fn read_byte(&mut self) -> Result<u8, String> {
        if self.pos >= self.data.len() {
            return Err("Unexpected end of scan data".into());
        }
        let byte = self.data[self.pos];
        self.pos += 1;
        // Handle byte stuffing: 0xFF 0x00 → 0xFF
        if byte == 0xFF {
            if self.pos >= self.data.len() {
                return Err("Unexpected end after 0xFF".into());
            }
            let next = self.data[self.pos];
            if next == 0x00 {
                self.pos += 1; // skip stuffed 0x00
            } else if next >= 0xD0 && next <= 0xD7 {
                // RST marker — skip and read next byte
                self.pos += 1;
                return self.read_byte();
            } else {
                // Marker found — end of scan
                return Err("Marker found in scan data".into());
            }
        }
        Ok(byte)
    }

    fn ensure_bits(&mut self) -> Result<(), String> {
        if self.bit_pos == 0 {
            self.current = self.read_byte()?;
            self.bit_pos = 8;
        }
        Ok(())
    }

    fn read_bit(&mut self) -> Result<u8, String> {
        self.ensure_bits()?;
        self.bit_pos -= 1;
        Ok((self.current >> self.bit_pos) & 1)
    }

    fn read_bits(&mut self, count: u8) -> Result<u16, String> {
        let mut val: u16 = 0;
        for _ in 0..count {
            val = (val << 1) | self.read_bit()? as u16;
        }
        Ok(val)
    }

    /// Decode one Huffman symbol using the given table
    fn decode_huffman(&mut self, table: &HuffTable) -> Result<u8, String> {
        let mut code: u16 = 0;
        for len in 0..16 {
            code = (code << 1) | self.read_bit()? as u16;
            if table.maxcode[len] >= 0 && code as i32 <= table.maxcode[len] {
                let idx = table.offsets[len] as usize + (code - table.mincode[len]) as usize;
                return Ok(table.symbols[idx]);
            }
        }
        Err("Invalid Huffman code".into())
    }

    fn scan_end_pos(&self) -> usize {
        self.pos
    }
}
  • Step 4: Run tests

Run: cargo test --package voltex_renderer -- jpg::tests -v Expected: All PASS

  • Step 5: Commit
git add crates/voltex_renderer/src/jpg.rs
git commit -m "feat(renderer): add JPEG Huffman bit-stream reader with byte stuffing"

Task 4: 8x8 IDCT

Files:

  • Modify: crates/voltex_renderer/src/jpg.rs

  • Step 1: Write IDCT test

#[test]
fn test_idct_dc_only() {
    // DC-only block: only coefficient [0] is set
    let mut block = [0i32; 64];
    block[0] = 800; // after dequantization
    let result = idct(&block);
    // All 64 values should be the same: 800/8 = 100
    let expected = 100;
    for &v in &result {
        assert!((v - expected).abs() <= 1, "DC-only IDCT: expected ~{}, got {}", expected, v);
    }
}

#[test]
fn test_idct_known_values() {
    // A simple known block
    let mut block = [0i32; 64];
    block[0] = 640;
    block[1] = 100;
    let result = idct(&block);
    // DC component should dominate: average ~80
    let avg: i32 = result.iter().sum::<i32>() / 64;
    assert!((avg - 80).abs() <= 2);
}
  • Step 2: Run to verify failure

  • Step 3: Implement IDCT

/// Zig-zag order for 8x8 block
const ZIGZAG: [usize; 64] = [
     0,  1,  8, 16,  9,  2,  3, 10,
    17, 24, 32, 25, 18, 11,  4,  5,
    12, 19, 26, 33, 40, 48, 41, 34,
    27, 20, 13,  6,  7, 14, 21, 28,
    35, 42, 49, 56, 57, 50, 43, 36,
    29, 22, 15, 23, 30, 37, 44, 51,
    58, 59, 52, 45, 38, 31, 39, 46,
    53, 60, 61, 54, 47, 55, 62, 63,
];

fn idct(coeffs: &[i32; 64]) -> [i32; 64] {
    // AAN-based float IDCT
    let mut workspace = [0.0f64; 64];

    // Arrange from zigzag to row-major
    let mut block = [0.0f64; 64];
    for i in 0..64 {
        block[ZIGZAG[i]] = coeffs[i] as f64;
    }

    // 1D IDCT on rows
    for row in 0..8 {
        let off = row * 8;
        idct_1d(&mut block, off);
    }

    // Transpose
    for r in 0..8 {
        for c in 0..8 {
            workspace[c * 8 + r] = block[r * 8 + c];
        }
    }

    // 1D IDCT on columns (now rows after transpose)
    for row in 0..8 {
        let off = row * 8;
        idct_1d(&mut workspace, off);
    }

    // Transpose back and round
    let mut result = [0i32; 64];
    for r in 0..8 {
        for c in 0..8 {
            result[r * 8 + c] = (workspace[c * 8 + r] / 8.0).round() as i32;
        }
    }
    result
}

fn idct_1d(data: &mut [f64], off: usize) {
    use std::f64::consts::PI;
    let mut tmp = [0.0f64; 8];
    for x in 0..8 {
        let mut sum = 0.0;
        for u in 0..8 {
            let cu = if u == 0 { 1.0 / 2.0f64.sqrt() } else { 1.0 };
            sum += cu * data[off + u] * ((2.0 * x as f64 + 1.0) * u as f64 * PI / 16.0).cos();
        }
        tmp[x] = sum / 2.0;
    }
    data[off..off + 8].copy_from_slice(&tmp);
}
  • Step 4: Run tests

Run: cargo test --package voltex_renderer -- jpg::tests::test_idct -v Expected: All PASS

  • Step 5: Commit
git add crates/voltex_renderer/src/jpg.rs
git commit -m "feat(renderer): add JPEG 8x8 IDCT with zig-zag reordering"

Task 5: Scan Decoder (MCU Decoding + YCbCr→RGB)

Files:

  • Modify: crates/voltex_renderer/src/jpg.rs

  • Step 1: Write integration test with synthetic minimal JPEG

#[test]
fn test_decode_synthetic_1x1_gray_like() {
    // Test the MCU decode + color conversion path
    // We'll create a minimal 8x8 JPEG by hand
    // This is complex — test via the full parse_jpg path with a known-good JPEG
    let jpg_data = build_minimal_jpeg_8x8();
    let result = parse_jpg(&jpg_data);
    assert!(result.is_ok(), "Failed to decode minimal JPEG: {:?}", result.err());
    let (rgba, w, h) = result.unwrap();
    assert_eq!(w, 8);
    assert_eq!(h, 8);
    assert_eq!(rgba.len(), 8 * 8 * 4);
}
  • Step 2: Run test to verify failure

  • Step 3: Implement scan decoder

Implement decode_scan function:

  • Parse SOS header (component → Huffman table mapping)
  • For each MCU: decode DC (differential) + AC (run-length) for each component
  • Dequantize coefficients using DQT table
  • IDCT each 8x8 block
  • Handle chroma subsampling (assemble MCU from Y/Cb/Cr blocks)
  • YCbCr → RGB conversion: R = Y + 1.402*(Cr-128), G = Y - 0.344136*(Cb-128) - 0.714136*(Cr-128), B = Y + 1.772*(Cb-128)
  • Clamp to 0..255
  • Handle restart markers (DRI/RST): reset DC predictors
fn decode_scan(
    data: &[u8], pos: usize,
    width: u16, height: u16, num_components: u8,
    components: &[JpegComponent],
    qt_tables: &[[u16; 64]; 4],
    dc_tables: &[Option<HuffTable>; 4],
    ac_tables: &[Option<HuffTable>; 4],
) -> Result<(Vec<u8>, usize), String> {
    // Parse SOS header
    if pos + 2 > data.len() { return Err("SOS too short".into()); }
    let seg_len = u16::from_be_bytes([data[pos], data[pos + 1]]) as usize;
    let ns = data[pos + 2] as usize;
    let mut scan_components = components.to_vec();
    let mut off = pos + 3;
    for i in 0..ns {
        let _cs = data[off]; // component selector
        let td_ta = data[off + 1];
        scan_components[i].dc_table = td_ta >> 4;
        scan_components[i].ac_table = td_ta & 0x0F;
        off += 2;
    }
    // Skip spectral selection and successive approximation bytes
    let scan_data_start = pos + seg_len;

    let mut reader = BitReader::new(data, scan_data_start);

    // Calculate MCU dimensions
    let max_h = scan_components.iter().map(|c| c.h_sample).max().unwrap_or(1);
    let max_v = scan_components.iter().map(|c| c.v_sample).max().unwrap_or(1);
    let mcu_width = (max_h as u16) * 8;
    let mcu_height = (max_v as u16) * 8;
    let mcus_x = (width + mcu_width - 1) / mcu_width;
    let mcus_y = (height + mcu_height - 1) / mcu_height;

    let mut dc_pred = vec![0i32; num_components as usize];
    let mut rgb = vec![0u8; (width as usize) * (height as usize) * 3];

    for mcu_row in 0..mcus_y {
        for mcu_col in 0..mcus_x {
            // Decode blocks for each component in this MCU
            let mut mcu_blocks: Vec<Vec<[i32; 64]>> = Vec::new();

            for (ci, comp) in scan_components.iter().enumerate().take(num_components as usize) {
                let blocks_h = comp.h_sample as usize;
                let blocks_v = comp.v_sample as usize;
                let mut blocks = Vec::with_capacity(blocks_h * blocks_v);

                for _ in 0..(blocks_h * blocks_v) {
                    let block = decode_block(
                        &mut reader,
                        dc_tables[comp.dc_table as usize].as_ref()
                            .ok_or("Missing DC Huffman table")?,
                        ac_tables[comp.ac_table as usize].as_ref()
                            .ok_or("Missing AC Huffman table")?,
                        &mut dc_pred[ci],
                        &qt_tables[comp.qt_id as usize],
                    )?;
                    blocks.push(block);
                }
                mcu_blocks.push(blocks);
            }

            // Assemble MCU pixels to RGB
            assemble_mcu(
                &mcu_blocks, &scan_components, num_components,
                max_h, max_v, mcu_col as usize, mcu_row as usize,
                width as usize, height as usize, &mut rgb,
            );
        }
    }

    Ok((rgb, reader.scan_end_pos()))
}

fn decode_block(
    reader: &mut BitReader,
    dc_table: &HuffTable,
    ac_table: &HuffTable,
    dc_pred: &mut i32,
    qt: &[u16; 64],
) -> Result<[i32; 64], String> {
    let mut coeffs = [0i32; 64];

    // DC coefficient
    let dc_len = reader.decode_huffman(dc_table)?;
    let dc_val = if dc_len > 0 {
        let bits = reader.read_bits(dc_len)? as i32;
        // Extend sign
        if bits < (1 << (dc_len - 1)) {
            bits - (1 << dc_len) + 1
        } else {
            bits
        }
    } else { 0 };
    *dc_pred += dc_val;
    coeffs[0] = *dc_pred * qt[0] as i32;

    // AC coefficients
    let mut k = 1;
    while k < 64 {
        let rs = reader.decode_huffman(ac_table)?;
        let run = (rs >> 4) as usize;     // zero run length
        let size = (rs & 0x0F) as u8;     // value bit length

        if size == 0 {
            if run == 0 { break; }         // EOB
            if run == 15 { k += 16; continue; } // ZRL (16 zeros)
            break;
        }

        k += run;
        if k >= 64 { break; }

        let bits = reader.read_bits(size)? as i32;
        let val = if bits < (1 << (size - 1)) {
            bits - (1 << size) + 1
        } else {
            bits
        };
        coeffs[k] = val * qt[k] as i32;
        k += 1;
    }

    Ok(idct(&coeffs))
}

fn assemble_mcu(
    mcu_blocks: &[Vec<[i32; 64]>],
    components: &[JpegComponent],
    num_components: u8,
    max_h: u8, max_v: u8,
    mcu_col: usize, mcu_row: usize,
    img_width: usize, img_height: usize,
    rgb: &mut [u8],
) {
    let mcu_px = mcu_col * max_h as usize * 8;
    let mcu_py = mcu_row * max_v as usize * 8;

    for py in 0..(max_v as usize * 8) {
        for px in 0..(max_h as usize * 8) {
            let x = mcu_px + px;
            let y = mcu_py + py;
            if x >= img_width || y >= img_height { continue; }

            if num_components == 1 {
                // Grayscale
                let val = sample_component(&mcu_blocks[0], &components[0], max_h, max_v, px, py);
                let clamped = val.clamp(0, 255) as u8;
                let offset = (y * img_width + x) * 3;
                rgb[offset] = clamped;
                rgb[offset + 1] = clamped;
                rgb[offset + 2] = clamped;
            } else {
                // YCbCr → RGB
                let yy = sample_component(&mcu_blocks[0], &components[0], max_h, max_v, px, py) as f32 + 128.0;
                let cb = sample_component(&mcu_blocks[1], &components[1], max_h, max_v, px, py) as f32;
                let cr = sample_component(&mcu_blocks[2], &components[2], max_h, max_v, px, py) as f32;

                let r = (yy + 1.402 * cr).round().clamp(0.0, 255.0) as u8;
                let g = (yy - 0.344136 * cb - 0.714136 * cr).round().clamp(0.0, 255.0) as u8;
                let b = (yy + 1.772 * cb).round().clamp(0.0, 255.0) as u8;

                let offset = (y * img_width + x) * 3;
                rgb[offset] = r;
                rgb[offset + 1] = g;
                rgb[offset + 2] = b;
            }
        }
    }
}

fn sample_component(
    blocks: &[[i32; 64]],
    comp: &JpegComponent,
    max_h: u8, max_v: u8,
    px: usize, py: usize,
) -> i32 {
    // Map pixel position to block + pixel within block
    let scale_x = comp.h_sample as usize;
    let scale_y = comp.v_sample as usize;
    let cx = px * scale_x / (max_h as usize * 8);
    let cy = py * scale_y / (max_v as usize * 8);
    let bx = (px * scale_x / (max_h as usize)) % 8;
    let by = (py * scale_y / (max_v as usize)) % 8;
    let block_idx = cy * scale_x + cx;
    if block_idx < blocks.len() {
        blocks[block_idx][by * 8 + bx]
    } else {
        0
    }
}

/// Build a minimal valid 8x8 Baseline JPEG for testing.
/// Encodes a flat mid-gray image (Y=128, Cb=0, Cr=0 → RGB ~128,128,128).
fn build_minimal_jpeg_8x8() -> Vec<u8> {
    // ... (test helper: builds SOI + DQT + SOF0 + DHT + SOS + scan data + EOI)
    // This is a known-good hand-crafted JPEG byte stream
    let mut out = Vec::new();

    // SOI
    out.extend_from_slice(&[0xFF, 0xD8]);

    // DQT — all-ones quantization table (id=0)
    let mut dqt = Vec::new();
    dqt.extend_from_slice(&0x0043u16.to_be_bytes()); // length = 67
    dqt.push(0x00); // 8-bit, table 0
    for _ in 0..64 { dqt.push(1); }
    out.extend_from_slice(&[0xFF, 0xDB]);
    out.extend_from_slice(&dqt);

    // SOF0 — 8x8, 1 component (grayscale for simplicity)
    out.extend_from_slice(&[0xFF, 0xC0]);
    out.extend_from_slice(&0x000Bu16.to_be_bytes()); // length = 11
    out.push(8); // precision
    out.extend_from_slice(&8u16.to_be_bytes()); // height
    out.extend_from_slice(&8u16.to_be_bytes()); // width
    out.push(1); // 1 component
    out.push(1); // component ID
    out.push(0x11); // h_sample=1, v_sample=1
    out.push(0); // qt table 0

    // DHT — DC table (class=0, id=0): symbol 0x04 at length 2 (code=00), symbol 0x00 at length 2 (code=01)
    // Minimal: only need DC length=4 → symbol 0x04 means "4 bits follow"
    out.extend_from_slice(&[0xFF, 0xC4]);
    let mut dht_body = Vec::new();
    dht_body.push(0x00); // DC, id=0
    // Counts for lengths 1-16: 1 symbol at length 1
    dht_body.push(1);
    for _ in 1..16 { dht_body.push(0); }
    dht_body.push(0x00); // symbol: category 0 (DC diff = 0)
    let dht_len = (dht_body.len() + 2) as u16;
    out.extend_from_slice(&dht_len.to_be_bytes());
    out.extend_from_slice(&dht_body);

    // DHT — AC table (class=1, id=0): just EOB symbol
    out.extend_from_slice(&[0xFF, 0xC4]);
    let mut dht_ac = Vec::new();
    dht_ac.push(0x10); // AC, id=0
    dht_ac.push(1); // 1 symbol at length 1
    for _ in 1..16 { dht_ac.push(0); }
    dht_ac.push(0x00); // symbol: 0x00 = EOB
    let dht_ac_len = (dht_ac.len() + 2) as u16;
    out.extend_from_slice(&dht_ac_len.to_be_bytes());
    out.extend_from_slice(&dht_ac);

    // SOS
    out.extend_from_slice(&[0xFF, 0xDA]);
    out.extend_from_slice(&0x0008u16.to_be_bytes()); // length=8
    out.push(1); // 1 component
    out.push(1); // component id=1
    out.push(0x00); // DC table 0, AC table 0
    out.push(0); // Ss
    out.push(63); // Se
    out.push(0); // Ah=0, Al=0

    // Scan data: DC=0 (code=0, 1 bit), AC=EOB (code=0, 1 bit)
    // Bits: 0 (DC diff=0) + 0 (EOB) = 0b00 → padded to byte: 0x00
    out.push(0x00);
    // Pad to byte boundary
    out.push(0x00);

    // EOI
    out.extend_from_slice(&[0xFF, 0xD9]);

    out
}
  • Step 4: Run tests

Run: cargo test --package voltex_renderer -- jpg::tests -v Expected: All PASS

  • Step 5: Commit
git add crates/voltex_renderer/src/jpg.rs
git commit -m "feat(renderer): add JPEG scan decoder with MCU assembly and YCbCr conversion"

Task 6: Subsampling Tests + Full Integration Test

Files:

  • Modify: crates/voltex_renderer/src/jpg.rs

  • Step 1: Add comprehensive tests

#[test]
fn test_grayscale_flat() {
    let jpg_data = build_minimal_jpeg_8x8();
    let (rgba, w, h) = parse_jpg(&jpg_data).unwrap();
    assert_eq!(w, 8);
    assert_eq!(h, 8);
    // Grayscale mid-gray: all pixels should be ~128
    for i in (0..rgba.len()).step_by(4) {
        assert_eq!(rgba[i], rgba[i + 1]); // R == G
        assert_eq!(rgba[i + 1], rgba[i + 2]); // G == B
        assert_eq!(rgba[i + 3], 255); // alpha
    }
}

#[test]
fn test_invalid_marker() {
    let data = [0xFF, 0xD8, 0x00]; // SOI then garbage
    assert!(parse_jpg(&data).is_err());
}
  • Step 2: Run all tests

Run: cargo test --package voltex_renderer -- jpg -v Expected: All PASS

  • Step 3: Run full workspace build

Run: cargo build --workspace Expected: BUILD SUCCESS

  • Step 4: Commit
git add crates/voltex_renderer/src/jpg.rs
git commit -m "feat(renderer): complete JPEG decoder with integration tests"