diff --git a/rules/irix/indycam-cdmc-register-map.md b/rules/irix/indycam-cdmc-register-map.md new file mode 100644 index 0000000..60696fc --- /dev/null +++ b/rules/irix/indycam-cdmc-register-map.md @@ -0,0 +1,72 @@ +# IndyCam CDMC register map and power-on defaults + +**Keywords:** indycam, cdmc, vino, camera, green, colour cast, white balance, +register map, i2c 0x56 +**Category:** vino, irix +**Status:** Fixed in `src/cdmc.rs`. + +## Symptom + +Live camera capture (`[vino] source = "camera"`) produced a recognisable but +uniformly **green** image — luma and geometry correct, red and blue crushed to +near zero. Reproduced on IRIX 6.5.22 with `vidtomem -f /tmp/cap -v 0`. + +## Cause + +Two compounding bugs in the emulated CDMC (the IndyCam's I2C controller at +slave address 0x56/0x57): + +1. **The register map was wrong.** Every subaddress except VERSION (0x0E) was + assigned to the wrong control. The real map — same silicon the Linux + `indycam` driver drives — is: + + | Sub | Register | Access | Default | + |-----|----------|--------|---------| + | 0x00 | CONTROL (AGCENA, AWBCTL, EVNFLD) | rw | AGCENA | + | 0x01 | SHUTTER | rw | 0xFF | + | 0x02 | GAIN | rw | 0x80 | + | 0x03 | BRIGHTNESS | r | 0x80 | + | 0x04 | RED_BALANCE | rw | 0x18 | + | 0x05 | BLUE_BALANCE | rw | 0xA4 | + | 0x06 | RED_SATURATION | rw | 0x80 | + | 0x07 | BLUE_SATURATION | rw | 0xC0 | + | 0x08 | GAMMA | rw | 0x80 | + | 0x0E | VERSION | r | 0x10 (IndyCam v1.0) | + | 0x0F | RESET | w | — | + +2. **The register file powered up at zero while the pixel maths treated 0x80 as + neutral.** Every unprogrammed control therefore sat at its extreme. The + balance and saturation terms pulled Cb and Cr from 128 down to 96, which + through BT.601 is R−51, G+38, B−64 per pixel — exactly the observed green + cast. Note the defaults are *not* all 0x80: red balance is 0x18 and blue + saturation 0xC0, so "0x80 is neutral" is wrong even with a correct map. + +## Fix + +`apply_uyvy_field` is now anchored at the power-on values: with the defaults in +place every term is unity and the field passes through byte-identical. Controls +respond proportionally as the guest moves them away from default. AGCENA and +AWBCTL suppress the gain and balance terms respectively, which matches a host +webcam running its own auto-exposure and auto-white-balance. + +`power_on_defaults_pass_the_field_through_untouched` in `src/cdmc.rs` locks the +pass-through property in. + +## Debugging technique worth reusing + +Isolate host capture from the guest before touching emulator code. A short +example binary that opens the camera through the same `nokhwa` path, prints the +negotiated format and raw buffer length, and writes a PPM proved in one run that +macOS AVFoundation delivers correct 1920×1080 packed YUYV (Cb at byte 1, Cr at +byte 3) — clearing `src/camera.rs` and pointing at the guest side. + +Then replay a captured host frame through `Cdmc::apply_uyvy_field` offline with +candidate register states and compare against the guest's screenshot. The +"gain programmed, everything else zero" state reproduced the reported image +exactly, which pinned the fault to the CDMC stage without a single boot. + +## See also + +- [indycam-end-to-end-capture.md](indycam-end-to-end-capture.md) +- [vino-capture-on-6.5-progress.md](vino-capture-on-6.5-progress.md) +- Linux `drivers/media/video/indycam.h` — authoritative register map/defaults diff --git a/src/cdmc.rs b/src/cdmc.rs index 58238ac..4afec50 100644 --- a/src/cdmc.rs +++ b/src/cdmc.rs @@ -29,23 +29,26 @@ use crate::devlog::LogModule; // ─── Register subaddresses ──────────────────────────────────────────────────── // -// Layout follows the IRIX indycam driver: low half is identification, the -// rest is image-control registers (brightness, hue, saturation, gamma, etc.). +// Layout and power-on values follow the IndyCam ("Guinness camera") register +// map as documented by the Linux `indycam` driver (drivers/media/video/ +// indycam.h, Ladislav Michl / Mikael Nousiainen) — same silicon the IRIX vino +// driver talks to. Low addresses are the exposure/colour controls, VERSION +// sits at 0x0E, and RESET at 0x0F. pub mod reg { - pub const VERSION: u8 = 0x00; // r Version / ID byte (used by 6.5 inventory) - pub const GAIN: u8 = 0x01; // rw Analog gain - pub const BLUE_BAL: u8 = 0x02; // rw Blue balance - pub const RED_BAL: u8 = 0x03; // rw Red balance - pub const RED_SAT: u8 = 0x04; // rw Red saturation - pub const BLUE_SAT: u8 = 0x05; // rw Blue saturation - pub const SHUTTER_HI: u8 = 0x06; // rw Shutter speed high byte - pub const SHUTTER_LO: u8 = 0x07; // rw Shutter speed low byte - pub const CONTROL: u8 = 0x08; // rw Control bits (AGC, AEC, AWB, etc.) + pub const CONTROL: u8 = 0x00; // rw AGC / AWB enables + EVNFLD status + pub const SHUTTER: u8 = 0x01; // rw Shutter speed + pub const GAIN: u8 = 0x02; // rw Analog gain + pub const BRIGHTNESS: u8 = 0x03; // r Measured scene brightness + pub const RED_BAL: u8 = 0x04; // rw Red balance + pub const BLUE_BAL: u8 = 0x05; // rw Blue balance + pub const RED_SAT: u8 = 0x06; // rw Red saturation + pub const BLUE_SAT: u8 = 0x07; // rw Blue saturation + pub const GAMMA: u8 = 0x08; // rw Gamma // 0x09–0x0D unused (silently ignored) - pub const CAMERA_ID: u8 = 0x0E; // r Model/presence ID byte + pub const VERSION: u8 = 0x0E; // r Camera model/version byte // // The IRIX 5.3 vino driver's `vinoCameraAttached()` reads this byte // and considers the camera "present" iff the value is exactly 0x10. @@ -63,16 +66,34 @@ pub mod reg { // start frame capture even though videod / vlinfo have already // enumerated the device. - // Total register slots — 0x00..=0x0E inclusive = 15. - pub const COUNT: usize = 0x0F; + pub const RESET: u8 = 0x0F; // w Write triggers a device reset - /// Identification value returned at subaddress 0x00. Concrete IndyCam - /// units returned 0x12; pick a value that lets driver probes succeed. - pub const VERSION_VAL: u8 = 0x12; + // Total register slots — 0x00..=0x0F inclusive = 16. + pub const COUNT: usize = 0x10; - /// Value returned at CAMERA_ID (0x0E). Must be exactly 0x10 for the - /// IRIX 5.3 vino driver's `vinoCameraAttached()` check to pass. - pub const CAMERA_ID_VAL: u8 = 0x10; + // ── CONTROL bits ── + pub const CONTROL_AGCENA: u8 = 1 << 0; // automatic gain control + pub const CONTROL_AWBCTL: u8 = 1 << 1; // automatic white balance + pub const CONTROL_EVNFLD: u8 = 1 << 4; // read-only: current field is even + + // ── Power-on values ── + // + // `apply_uyvy_field` treats exactly this set as "no adjustment", so a + // guest that leaves the camera at its defaults gets the host frame + // through unaltered. + pub const CONTROL_DEFAULT: u8 = CONTROL_AGCENA; + pub const SHUTTER_DEFAULT: u8 = 0xFF; + pub const GAIN_DEFAULT: u8 = 0x80; + pub const BRIGHTNESS_DEFAULT: u8 = 0x80; + pub const RED_BAL_DEFAULT: u8 = 0x18; + pub const BLUE_BAL_DEFAULT: u8 = 0xA4; + pub const RED_SAT_DEFAULT: u8 = 0x80; + pub const BLUE_SAT_DEFAULT: u8 = 0xC0; + pub const GAMMA_DEFAULT: u8 = 0x80; + + /// Value returned at VERSION (0x0E). Must be exactly 0x10 (IndyCam v1.0) + /// for the IRIX 5.3 vino driver's `vinoCameraAttached()` check to pass. + pub const VERSION_VAL: u8 = 0x10; } // ─── I2C state machine ──────────────────────────────────────────────────────── @@ -98,8 +119,16 @@ struct CdmcState { impl Default for CdmcState { fn default() -> Self { let mut regs = [0u8; reg::COUNT]; - regs[reg::VERSION as usize] = reg::VERSION_VAL; - regs[reg::CAMERA_ID as usize] = reg::CAMERA_ID_VAL; + regs[reg::CONTROL as usize] = reg::CONTROL_DEFAULT; + regs[reg::SHUTTER as usize] = reg::SHUTTER_DEFAULT; + regs[reg::GAIN as usize] = reg::GAIN_DEFAULT; + regs[reg::BRIGHTNESS as usize] = reg::BRIGHTNESS_DEFAULT; + regs[reg::RED_BAL as usize] = reg::RED_BAL_DEFAULT; + regs[reg::BLUE_BAL as usize] = reg::BLUE_BAL_DEFAULT; + regs[reg::RED_SAT as usize] = reg::RED_SAT_DEFAULT; + regs[reg::BLUE_SAT as usize] = reg::BLUE_SAT_DEFAULT; + regs[reg::GAMMA as usize] = reg::GAMMA_DEFAULT; + regs[reg::VERSION as usize] = reg::VERSION_VAL; Self { regs, i2c_write_addr: 0x56, @@ -210,23 +239,46 @@ impl Cdmc { self.state.lock().regs } - /// Apply CDMC brightness / colour balance to a packed UYVY field in-place. + /// Apply CDMC exposure / colour balance to a packed UYVY field in-place. + /// + /// The host camera already delivers an exposed, white-balanced frame, so + /// the transfer functions here are anchored at the IndyCam's power-on + /// register values: with the defaults in place every term is unity and the + /// field passes through untouched. A guest that moves a control away from + /// its default (the video panel's sliders) gets a proportional, bounded + /// change in that direction — the response curve is an approximation of + /// the real camera's, not a measured match. pub fn apply_uyvy_field(pixels: &mut [u8], regs: &[u8; reg::COUNT]) { - // GAIN 0x00..=0xFF maps roughly to 0.5×..1.5× luma; 0x80 = unity. - let gain = regs[reg::GAIN as usize] as i32; - let luma_scale = (gain as f32 - 128.0) / 128.0 + 1.0; - let red_bias = (regs[reg::RED_BAL as usize] as i32 - 128) as f32 / 128.0; - let blue_bias = (regs[reg::BLUE_BAL as usize] as i32 - 128) as f32 / 128.0; - let red_sat = (regs[reg::RED_SAT as usize] as i32 - 128) as f32 / 256.0; - let blue_sat = (regs[reg::BLUE_SAT as usize] as i32 - 128) as f32 / 256.0; - let shutter = ((regs[reg::SHUTTER_HI as usize] as u16) << 8) - | regs[reg::SHUTTER_LO as usize] as u16; - // Shutter 0 = auto; non-zero scales exposure down (preview approximation). - let exposure = if shutter == 0 { - 1.0f32 + /// Deviation of a control from its default, as a multiplier centred on + /// 1.0 and bounded to roughly 0.5×..1.5× across the full 0x00..0xFF range. + fn factor(val: u8, default: u8) -> f32 { + 1.0 + (val as f32 - default as f32) / 255.0 + } + + let control = regs[reg::CONTROL as usize]; + + // With automatic gain control enabled the camera runs its own exposure + // loop and the GAIN/SHUTTER registers don't drive the picture, which + // matches the host camera doing its own auto-exposure. + let luma_scale = if control & reg::CONTROL_AGCENA != 0 { + 1.0 } else { - (65535.0 / shutter as f32).clamp(0.25, 2.0) + factor(regs[reg::GAIN as usize], reg::GAIN_DEFAULT) + * factor(regs[reg::SHUTTER as usize], reg::SHUTTER_DEFAULT) + }; + + // Balance shifts a colour-difference channel's white point; saturation + // scales its amplitude about neutral. Automatic white balance takes the + // balance registers out of the picture the same way AGC does for gain. + let awb = control & reg::CONTROL_AWBCTL != 0; + let red_shift = if awb { 0.0 } else { + (regs[reg::RED_BAL as usize] as f32 - reg::RED_BAL_DEFAULT as f32) / 4.0 }; + let blue_shift = if awb { 0.0 } else { + (regs[reg::BLUE_BAL as usize] as f32 - reg::BLUE_BAL_DEFAULT as f32) / 4.0 + }; + let red_scale = factor(regs[reg::RED_SAT as usize], reg::RED_SAT_DEFAULT); + let blue_scale = factor(regs[reg::BLUE_SAT as usize], reg::BLUE_SAT_DEFAULT); for c in pixels.chunks_mut(4) { let u = c[0] as f32; @@ -234,10 +286,10 @@ impl Cdmc { let v = c[2] as f32; let y2 = c[3] as f32; - let y_adj = ((y - 16.0) * luma_scale * exposure + 16.0).clamp(0.0, 235.0); - let y2_adj = ((y2 - 16.0) * luma_scale * exposure + 16.0).clamp(0.0, 235.0); - let u_adj = (u + blue_bias * 32.0 + blue_sat * (u - 128.0)).clamp(0.0, 255.0); - let v_adj = (v + red_bias * 32.0 + red_sat * (v - 128.0)).clamp(0.0, 255.0); + let y_adj = ((y - 16.0) * luma_scale + 16.0).clamp(0.0, 235.0); + let y2_adj = ((y2 - 16.0) * luma_scale + 16.0).clamp(0.0, 235.0); + let u_adj = (128.0 + (u - 128.0) * blue_scale + blue_shift).clamp(0.0, 255.0); + let v_adj = (128.0 + (v - 128.0) * red_scale + red_shift).clamp(0.0, 255.0); c[0] = u_adj as u8; c[1] = y_adj as u8; @@ -249,29 +301,116 @@ impl Cdmc { // ── Register write ──────────────────────────────────────────────────── fn reg_w(st: &mut CdmcState, data: u8) { - let sub = st.i2c_subaddr as usize; - if sub < reg::COUNT { - // VERSION is read-only; everything else accepts writes. - if st.i2c_subaddr != reg::VERSION { - st.regs[sub] = data; - } + let sub = st.i2c_subaddr; + match sub { + // A write to RESET returns every control to its power-on value. + // The I2C transaction itself carries on. + reg::RESET => st.regs = CdmcState::default().regs, + // BRIGHTNESS reports the measured scene level and VERSION identifies + // the camera; both are read-only. + reg::BRIGHTNESS | reg::VERSION => {} + _ if (sub as usize) < reg::COUNT => st.regs[sub as usize] = data, + _ => {} } - let name = Self::reg_name(st.i2c_subaddr); - dlog_dev!(LogModule::Vino, "CDMC: write reg {:#04x} ({}) = {:#04x}", st.i2c_subaddr, name, data); + let name = Self::reg_name(sub); + dlog_dev!(LogModule::Vino, "CDMC: write reg {:#04x} ({}) = {:#04x}", sub, name, data); } fn reg_name(subaddr: u8) -> &'static str { match subaddr { - reg::VERSION => "VERSION", + reg::CONTROL => "CONTROL", + reg::SHUTTER => "SHUTTER", reg::GAIN => "GAIN", - reg::BLUE_BAL => "BLUE_BAL", + reg::BRIGHTNESS => "BRIGHTNESS", reg::RED_BAL => "RED_BAL", + reg::BLUE_BAL => "BLUE_BAL", reg::RED_SAT => "RED_SAT", reg::BLUE_SAT => "BLUE_SAT", - reg::SHUTTER_HI => "SHUTTER_HI", - reg::SHUTTER_LO => "SHUTTER_LO", - reg::CONTROL => "CONTROL", + reg::GAMMA => "GAMMA", + reg::VERSION => "VERSION", + reg::RESET => "RESET", _ => "(unknown)", } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// A guest that never touches the camera controls gets the host frame + /// through unaltered — any drift between the power-on register values and + /// the transfer functions' neutral point shows up as a colour cast on + /// every captured frame. + #[test] + fn power_on_defaults_pass_the_field_through_untouched() { + let regs = Cdmc::new().regs_copy(); + // Luma spans the legal 16..=235 range; chroma spans the full byte. + let original: Vec = (0..64) + .flat_map(|i| { + let luma = 16 + (i * 219 / 63) as u8; + [(i * 4) as u8, luma, (i * 4 + 2) as u8, luma] + }) + .collect(); + let mut pixels = original.clone(); + Cdmc::apply_uyvy_field(&mut pixels, ®s); + assert_eq!(pixels, original); + } + + #[test] + fn neutral_chroma_survives_the_defaults() { + let regs = Cdmc::new().regs_copy(); + let mut pixels = vec![128, 100, 128, 120]; + Cdmc::apply_uyvy_field(&mut pixels, ®s); + assert_eq!(pixels, vec![128, 100, 128, 120]); + } + + #[test] + fn red_balance_above_default_shifts_cr_up() { + let mut regs = Cdmc::new().regs_copy(); + regs[reg::RED_BAL as usize] = reg::RED_BAL_DEFAULT + 0x40; + let mut pixels = vec![128, 100, 128, 100]; + Cdmc::apply_uyvy_field(&mut pixels, ®s); + assert_eq!(pixels[0], 128, "blue-difference channel untouched"); + assert!(pixels[2] > 128, "Cr shifted up, got {}", pixels[2]); + } + + /// Automatic white balance is the camera's own loop; the balance registers + /// stop driving the picture while it is enabled. + #[test] + fn awb_suppresses_the_balance_registers() { + let mut regs = Cdmc::new().regs_copy(); + regs[reg::CONTROL as usize] |= reg::CONTROL_AWBCTL; + regs[reg::RED_BAL as usize] = 0xFF; + let mut pixels = vec![128, 100, 128, 100]; + Cdmc::apply_uyvy_field(&mut pixels, ®s); + assert_eq!(pixels, vec![128, 100, 128, 100]); + } + + #[test] + fn version_is_read_only_and_identifies_an_indycam() { + let cdmc = Cdmc::new(); + assert_eq!(cdmc.regs_copy()[reg::VERSION as usize], reg::VERSION_VAL); + cdmc.i2c_write(0x56); + cdmc.i2c_write(reg::VERSION); + cdmc.i2c_write(0x00); + cdmc.i2c_stop(); + assert_eq!(cdmc.regs_copy()[reg::VERSION as usize], reg::VERSION_VAL); + } + + #[test] + fn reset_restores_the_power_on_values() { + let cdmc = Cdmc::new(); + cdmc.i2c_write(0x56); + cdmc.i2c_write(reg::RED_BAL); + cdmc.i2c_write(0xFF); + cdmc.i2c_stop(); + assert_eq!(cdmc.regs_copy()[reg::RED_BAL as usize], 0xFF); + + cdmc.i2c_write(0x56); + cdmc.i2c_write(reg::RESET); + cdmc.i2c_write(0x01); + cdmc.i2c_stop(); + assert_eq!(cdmc.regs_copy()[reg::RED_BAL as usize], reg::RED_BAL_DEFAULT); + } +}