Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions rules/irix/indycam-cdmc-register-map.md
Original file line number Diff line number Diff line change
@@ -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
245 changes: 192 additions & 53 deletions src/cdmc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 ────────────────────────────────────────────────────────
Expand All @@ -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,
Expand Down Expand Up @@ -210,34 +239,57 @@ 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;
let y = c[1] as f32;
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;
Expand All @@ -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<u8> = (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, &regs);
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, &regs);
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, &regs);
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, &regs);
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);
}
}
Loading