From 6b0c5ea881035edcd7a5310351234cf0df0b9226 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hans=20H=C3=BCbner?= Date: Wed, 22 Jul 2026 13:20:52 +0200 Subject: [PATCH] cdmc: correct the IndyCam register map and power-on defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live camera capture came out uniformly green: luma and geometry correct, red and blue crushed to near zero. Every CDMC subaddress except VERSION (0x0E) was mapped to the wrong control. The IndyCam register map is CONTROL 0x00, SHUTTER 0x01, GAIN 0x02, BRIGHTNESS 0x03 (read-only), RED_BALANCE 0x04, BLUE_BALANCE 0x05, RED_SATURATION 0x06, BLUE_SATURATION 0x07, GAMMA 0x08, VERSION 0x0E (read-only), RESET 0x0F. Mapping VERSION to 0x00 also made every CONTROL write get dropped as a write to a read-only register. The register file powered up at all-zero while apply_uyvy_field treated 0x80 as each control's neutral point, so every unprogrammed control 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 — the green cast. The hardware defaults are not uniformly 0x80: red balance is 0x18 and blue saturation 0xC0. apply_uyvy_field is now anchored at the power-on values, so a guest that leaves the camera alone gets the host frame through byte-identical, and controls respond proportionally as the guest moves them. AGCENA and AWBCTL suppress the gain and balance terms, matching a host camera running its own auto-exposure and auto-white-balance. Co-Authored-By: Claude Opus 4.8 (1M context) --- rules/irix/indycam-cdmc-register-map.md | 72 +++++++ src/cdmc.rs | 245 +++++++++++++++++++----- 2 files changed, 264 insertions(+), 53 deletions(-) create mode 100644 rules/irix/indycam-cdmc-register-map.md 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); + } +}