# RE notes: colour, exposure, geometry, lift-off, scan button (Dacuda Scanner Mouse for Mac 2.4) Static analysis only (no device access). Tags: **[V]** verified in disassembly or strings, **[I]** inferred. Addresses are virtual addresses. The main app `Contents/MacOS/Scanner Mouse` and `libSLAMScanSystech.dylib` are **i386 thin** Mach-O. `UVCControl.framework` is fat, and its x86_64 slice was used (addresses with "uvc:"). The app is stripped except for template symbols. I located code through string xrefs and RTTI vtables. ## 0. Scoping facts - **`libSLAMScanSystech.dylib` is NOT our device. [V]** It is the driver for a different, network-attached "Free"/Systech mouse. It contains ffmpeg and RTSP code plus HTTP/XML commands: `cmd/gain`, `cmd/rgbgain` (``), `cmd/exposure` (`Delay_us`, `Duration_us`) and `cmd/laser`. It is used only by `Hal::HalFree`. Our USB/UVC mouse is `Hal::HalLuckyCat` + `Hal::OVMouseManager` (mouse "LuckyCat", OV bridge), with all stitching and registration code inside the main app (`libs/Core`, `libs/Hal/Driver/...`). - **Per-device calibration lives in the mouse's EEPROM (see section 1.4).** The values compiled into the app are only fallbacks, and they are for an LG LSM100 (`productString = EProduct_LG_LSM100`). Anything below tagged "compiled default" is not necessarily what the Hama unit contains. ## 1. Colour / white balance ### 1.1 Sensor and bridge writes (complete list) [V] `OVMouseManager::sccbWrite(id,reg,val)` is at 0x138b020. It has 36 call sites, and all were enumerated. With id=0x42 (image sensor via the bridge proxy BD/BE/B8) the app writes only these registers: | Sensor reg | Written by | Value | |---|---|---| | 0x00 | `setGain(double)` 0x1389160; deferred writer 0x13892f0 | OV gain byte, see below | | 0x01, 0x02, 0x03 | `setWhiteBalance(int a,int b,int c)` 0x13893e0 (writes at 0x1389732/53/76) | reg1=a, reg2=b, reg3=c | | 0x16 and 0x19 | `setImageWindowOffset` / `setYBaseOffset` 0x1389bf0, 0x1389e96, 0x138a7af | window start/stop rows (value derived from `yBaseOffset`) | **The app never writes sensor reg 0x13 (AEC/AGC/AWB) or any other sensor register.** It relies entirely on firmware defaults for AWB and AGC. Bridge-register writes (id=0x88) are D8, ED, EC, 2D, E7, E6, DC, A1..A5 and BD/BE/B8 (proxy). **Gain encoding (reg 0x00, 0x1389214-0x13892af).** The input is `imagerGain`, a double, clamped to [0,16]. The OV format has bits 7..4 as doubling stages and bits 3..0 as the fraction: ``` v = clamp(g,0,16); r = 0 if v>=16: r|=0x80; g/=2 if v>=8 : r|=0x40; g/=2 if v>=4 : r|=0x20; g/=2 if v>=2 : r|=0x10; g/=2 r |= (g<2) ? int((g-1)*16) : 0x0F # g=1.0 -> reg0 = 0x00 ``` **Colour gains.** `HalLuckyCat::open` (0x1331ae0) calls `setWhiteBalance(this+0x2fc, +0x300, +0x304)` at 0x133312f. These three ints come from the config getters at 0x14452f0, 0x1445360 and 0x14453d0. Their property offsets (+0x108, +0x138, +0x168) correspond to `gainR`, `gainG` and `gainB` in the HardwareData config ctor (0x1441300), so: - sensor reg 0x01 = gainR, reg 0x02 = gainG, reg 0x03 = gainB **in the app's naming** [V for the plumbing]. - Which physical channel each register controls is [I]. The standard OV7725/7740 map is 0x01=BLUE, 0x02=RED, 0x03=GREEN. If that applies here, the app's "gainR" really lands in the blue-gain register. Test empirically. **Compiled defaults (LSM100 config ctor 0x1441300) [V]:** | Name | Default | Note | |---|---|---| | `gainR` / `gainG` / `gainB` | 150 (0x96) / 89 (0x59) / 64 (0x40) | the app's reg1 / reg2 / reg3 | | `imagerGain` | 1.0 | reg 0 = 0x00 | | `exposureTimeUs` | 270 | min 100, max 1200 | | `yBaseOffset` | 105 | | | `laserReadOutDelay` | 0.9 | | | `clipOffset` | "8 28 8 12" | ROI margins in px, order not verified [I] | Starting point for a green cast (**untested [I]**): reg0=0x00, reg1=0x96, reg2=0x59, reg3=0x40. If the channels turn out swapped, swap R and B. The proper values are in the EEPROM (1.4). ### 1.2 Software colour correction on decoded frames [V] The preprocessing pipeline (`Core::PreprocessorFactory`) is, in order: `ImageClipper` -> `NurbsColorBalancerPolicy` -> `GammaEnhancerPolicy` -> `Undistorter` (`UndistorterBailey` = lens undistortion) -> FAST corner detector -> `ThresholdController`. - **Colour balancer = `ColorBalancerLinearNurbsScaler`** (`libs/Core/Preprocessor/ColorBalance/...`). It is a per-pixel linear flat-field correction `out = slope*in + intercept`. The maps come either from `/slope.bin /intercept.bin /black.bin /white.bin` or are computed from the per-device NURBS surfaces `nurbsModelBlack` and `nurbsModelWhite` (shading and white reference) in HardwareData ColorParams. This corrects vignetting and colour cast in one step. There are IR and UV variants (`IRBalancer`, `UVBalancer`). Target brightness: `colorDestBrightImage` = **250** [V, default]. `colorDestDarkImage` was not extracted. A simple per-channel white balance alternative: normalise each channel so a white reference maps to 250. - **Gamma enhancer** (`GammaEnhancer.cpp`, `enableGammaEnhancement`) builds a LUT by piecewise-linear interpolation (function 0x14b4eb0). The 17-point LSM100 curve at 0x1dc8480/0x1dc84c4 is: x = 0,4,8,16,32,40,48,56,64,72,80,96,112,144,176,208,255 y = 6,17,27,44,74,86,96,106,115,123,131,145,157,181,202,224,255. A separate 256-entry "LITEARRAY" LUT exists at 0x1dc8508/0x1dc8908. Which LUT our product uses is unknown. The enabling setting is `enableColorEnhancement` / `enableColors` (UI sliders for brightness and saturation). - Hardware-data fields the colour code consumes: `lambda`, `lambdaNormFactor`, `nurbsModelSize`, `nurbsSmoothingFactors`, `nurbsNSmoothingIterations`, `gainR/G/B`, `exposureTimeUs`, `imagerGain`. ### 1.3 Mouse sensor-module variants known to the app [V] "Scarab", "LuckyCat (Largan)", "LuckyCat (Newmax)", "LuckyCat (Kinko)", "(Kinko, restricted)", "(Kinko, r2)", "(Kinko, r2 restricted)". These strings are stored in the HardwareData `info` block. ### 1.4 Per-device calibration in EEPROM (HardwareData) [V for layout, I for blob format details] Read routine `OVMouseManager::readBinaryData` (0x138c4e0); write routine at 0x138cc90. Both use `OVIF_DaEEPROMRead/Write` (uvc: 0x383f/0x380e), which call `-[CameraControlAppDelegate EEPROMRead:withStartAddr:withLength:]` (uvc: 0x1bb9). EEPROM map: | Address | Content | |---|---| | 0x3000 | u16 LE: length N of the blob, valid if 0 < N <= 20000 (0x4E20) | | 0x3002 | 4 bytes: checksum (`DaReadBinaryDataChecksum`) | | 0x3006 | N bytes: blob | | 0x7EB5 | 64 bytes: mouse ID / serial (`ffff...` when erased) | Blob = `boost::archive::binary_iarchive` (`HardwareDataSerializer::fromRawData`, 0x14c3731) holding, in order, `RoiParams`, `SensorParams`, `UndistortionParams`, `ColorParams`, `InfoParams`. There is also an "archiveVersion" check, which produces "Unknown archive version" and "Device contains an old version of UserData" errors. Field names (from the serializer string table 0x1b19c02..): - Sensor: `exposureTimeUs`, `imagerGain`, `laserReadoutDelay`, `metersPerLeftMouseUnit`, `metersPerRightMouseUnit`, `camToLeftMouseAngle`, `camToLeftMouseVec`, `camToRightMouseAngle`, `camToRightMouseVec`, `gyroDegPerStep`, `gyroBias`, `yBaseOffset`. - Roi: `roiX0`, `roiY0`, `roiX1`, `roiY1`. - Undistortion: `clipOffset`, `focalLength`, `principalPointX/Y`, `distModel`, `distCoefficients`, `tiltH`, `pixelSize`. - Color: `gainR/G/B`, `lambda`, `lambdaNormFactor`, `nurbsModelBlack/White`, `nurbsModelSize`, `nurbsSmoothingFactors`, `nurbsNSmoothingIterations`. - Info: `fingerprint`, `calibrationTime`, `avgReprojErrorIntrinsic`, `avgReprojErrorHomography`, `archiveVersion`, mouse-model enum, product/vendor/odm strings. **EEPROM read protocol (uvc: 0x1bb9) [V].** It uses XU selector 3, a 33-byte buffer. "ReadBuf" means SET_CUR sel 3 followed by GET_CUR sel 3 (uvc: 0x25bb). "WriteBuf" means SET_CUR only (uvc: 0x2680). Requests are 0x21/0xA1, wValue 0x0300, wIndex 0x0600, wLength 33: ``` ReadBuf [01, addr_lo, addr_hi, len_lo, len_hi, ...] # set address+length WriteBuf[02, ...] # start repeat ceil(len/32): ReadBuf[03, ...] -> response bytes 1..32 = next 32 data bytes WriteBuf[05, ...] # end ``` The write protocol (0x1a34) is cmd 1 (addr, len), 6, then 7 per 32-byte chunk, and 9 to finish. The `len < 0x7fff` check is in the write path. Do NOT use the write path casually. The EEPROM holds the calibration, and writes are signed (RSA, `:/calib_rsa.pub.txt`). **Strong recommendation:** use the selector-3 read to dump 0x3000..0x3006+N from the user's own mouse. That yields this unit's real gainR/G/B, imagerGain, exposureTimeUs, laser scale and positions, pixelSize, lens model and NURBS white/black surfaces. I did not touch the device, so none of the blob format is confirmed on real data. ## 2. Exposure - Formula [V] (`OVMouseManager::setExposureTime` 0x1389e90): `E6 = floor(ms*49.23 + 0.5)` (the 49.23 is a double constant at 0x1cfaf80), clamped 0..255 by the bridge byte. - The app calls it only from `HalLuckyCat::open` (0x1332e04): `ms = exposureTimeUs * 0.001` from HardwareData. Default 270 us -> E6 = 13; limits 100..1200 us -> E6 5..59. **I found no runtime caller that varies exposure.** I checked the only direct caller and every `call [reg+0x3c]` (the `setExposureTimeMs` vtable slot) in the binary; none is exposure-related. [V for the callers, I for the conclusion "no AEC loop for LuckyCat".] - The PID settings exist but look calibration-only [I]. In the DacudaSettings ctor (0x1470ff0), next to `OpticalModuleValidator`, they are `pidGainP`=1.8, `pidGainI`=0.01, `pidGainD`=0.3, `pidGoal`=240 [V values], alongside `ignoreOmvExposureTime`. The goal 240 is presumably a brightness target, probably a bright-pixel statistic, for the optical-module calibration tool. A separate `ThresholdController` (`gainP`=0.01, `gainI`=0.001, `minThreshold`=20, `maxThreshold`=150) tunes the FAST detector threshold, not exposure. - So for our own AE: the vendor target brightness is ~240 on the bright part, with exposure kept in 100..1200 us (E6 5..59). Our 0..255 range is wider than the vendor ever uses. LED strobe time is the exposure. - Laser read position, bridge reg DC [V] (0x138a1d0): `DC = floor(min(pos,0.9)*128 + 0.5)`, so the default 0.9 gives 0x73 (115). The app refuses it while streaming. ## 3. Stitching geometry Pipeline: record -> `SensorLL::update` (0x12f5c40) -> `LaserCheckerGeometric::check` (0x12fe6b0) -> `PoseEstimatorLL::estimatePose` (0x12f14c0, vtable 0x1d9a208+). - **Record fields used [V]:** `OVMouseData` int fields at +0x74/+0x78 = laser1 dx, dy and +0x80/+0x84 = laser2 dx, dy (cvtsi2sd at 0x12f5d91.. and 0x12f14df..). The Sq fields sit at +0x7c and +0x88 and are not read in `SensorLL::update`. - **Per-laser calibrated vector [V structure]:** `v_i = M_i*(dx,dy)^T + t_i`. `M_i` is a 2x2 matrix at estimator-data +0x84/+0x8c/+0x94/+0x9c (laser 1) and +0xb4/+0xbc/+0xc4/+0xcc (laser 2); `t_i` is at +0xa4 and +0xd4. It is built in the driver factory (0x12ce020) from `camTo{Left,Right}MouseAngle` and `metersPer{Left,Right}MouseUnit` / `laserScaling`. The pose (x, y, phi) is then a 2-point rigid fit: translation from the displacements and rotation from the difference of the two displacement components perpendicular to the laser-laser baseline, divided by the squared baseline length (division by `2*L^2` visible at 0x12f17d9). Laser dy is negated in the checker adapter at 0x12fe620 (`(dx,-dy)` for both lasers). - **Scale [V]:** `laserScalingLuckyCat` = float 0x379f6230 = **1.9e-5 m per laser count** (Driver config ctor 0x1422b40, compiled default). The per-device `metersPerLeftMouseUnit` / `metersPerRightMouseUnit` in EEPROM override it [I]. That is ~1337 counts/inch. - **Laser positions [V, LSM100 compiled default; per-device in EEPROM]:** left (0.025, 0.063) m, right (0.025, -0.014) m, both angles 0. So the lasers are 77 mm apart along y, 25 mm from the camera axis in x. Our empirical values give a baseline of 1.9e-5/4.4e-4 = 43 mm and ~0.057 mm/px, so the Hama unit differs from the LSM100 defaults, as expected. Use the EEPROM values. - **Camera pixel size:** the validator expects 75..79.4 um per pixel (`geomExpectedPixelSizeMin/Max` 7.5e-5..7.94e-5 m), i.e. 320-339 dpi native, for LG modules. `gridUnitInMeters` = 1.058333e-3 (1/24 inch), `subsamplingFactorOM` = 1.2. The authoritative number is `pixelSize` in the EEPROM. - **Orientation:** my empirical result (frames transposed + mirrored: laser dx ~ image y, laser dy ~ image x with sign flip) is consistent with the dy negation and axis-swapped matrices above. The exact image flip in the app is applied by `mirrorConfiguration` (profile int, default 1 / user 2 in WING profile) plus the clip and undistort stages. Not resolved. - **Output DPI:** user setting `scanResolution` (enum index, default 3) -> `Core::setResolution(int dpi)` (0x112fbd0), and `BakerQt` falls back to "native camera resolution" if undefined. The index->dpi table was not found (labels probably in .qm resources). Not resolved. ## 4. Lift-off / frame rejection - **`LaserCheckerGeometric::check(l1, l2)` (0x12fe6b0) [V logic]:** 1. If `0 < |v1| <= 2` and `0 < |v2| <= 2` (tiny motion on both lasers) -> OK. 2. Otherwise, with u = normalised (p_right - p_left) from `camToLeft/RightMouseVec` (setter 0x12fe570), compute `d = dot(v1,u) - dot(v2,u)` and `ratio = |d| / max(|v1|,|v2|)`. If `ratio <= sensitivity` -> OK. Else the result is OK only if `|d| < 4` counts, otherwise **invalid (lifted or slipping)**. The sensitivity is the `liftoffSensitivityLaser` config (default 50; profile `ASD_SLAMLITE` uses 21), passed to the ctor 0x12fe4f0 and stored at +0x6c. A raw ratio can never exceed 2, so the value is probably pre-scaled or the property is another one. The units are unresolved. [I] Also `laserDoubleCheckThreshold`, `laserExpectedRatioMax`, `laserExpectedNumStepsMin` (8000) are calibration-validator settings. - **Second checker (0x12fd330, from `SensorLL::update`) [V structure, I meaning]:** keeps circular buffers of the last laser integer deltas (4 channels), and tests max |delta| against fixed limits (3, 1, 8, 9, 12) with a state flag. It logs "LiftOff1" / "LiftOff2". It looks like a stuck/static-sensor test. - **Surface-quality bytes (record bytes 14/15, i.e. Sq fields) and byte 16 / flag bits:** I found no thresholds applied to them in the app. They are copied into `SensorDataLL` (`laser1Sq`, `laser2Sq`) but unused by the pose estimator or checker I decoded. [V for "not in SensorLL::update", I for "not used elsewhere"]. Lift detection is geometric (laser disagreement) plus Core states: `ScanningState` -> `LiftoffState` ("Stop after lift-off"), image-based `IntensityChecker` (`Utilities/Core/IntensityChecker.cpp`), `BookBorder` match failure, "The mouse was lifted off when starting the scan". Stop reasons in the UI: Buffer Full, lift off, user stop, USB capacity exceeded. - Other thresholds: `minimumBlenderTranslation` 0.0075 m (7.5 mm), `minimumBlenderRotation` 0.0785 rad (4.5 deg), `maximalBlenderConsecutiveDiscard` 15, `minimumBouncerTranslation` 0.0015 m, `minimumBouncerRotation` 0.01 rad, `maximalConsecutiveDiscard` 15, `minimumCoreOverlap` 0.2, `nFeaturesMin/Max` 10/1000, `ratioThresholdFast` 0.6, `ratioThresholdQuality` 0.7. ## 5. Scan button - **Register [V]:** bridge reg 0xBC, bit 0 (active high). `OVIF_GetScanBtnStatus` (uvc: 0x389a) edge-detects against the previous sample: returns **0x11 on a press edge (0->1), 0x10 on a release edge (1->0)**, 0/1 for steady state, -1 for the first sample, 0x99 on read error. - **Polling [V]:** `OVMouseManager` button thread (loop at 0x138b4d0, call at 0x138b79e). Default interval stored at this+0xbc is 0x64 = **100 ms** (store at 0x1390681, so I assume the ctor), changeable with `setScanButtonPollingInterval(int)`. Posts `ScanButtonPressedEvent` / `ScanButtonReleasedEvent`. The loop takes a mutex around the register read, which is shared with video control, so reads are serialised. - **Semantics [I, from strings and config]:** `ControlWindow::scanButtonPress` -> "Start scan, initiated with scan button press (not known yet if single swipe or regular scan)". Press = start. Settings `singleSwipeEnabled`, `singleSwipeTime` = 400 ms: a press held longer than that is "single swipe" (scan runs while held and stops on the release event, "Got ScanButton Released event / Single swipe is active"). A short press is a regular scan that continues after release and stops on the next press ("user stop") or on lift-off, buffer full or USB error. Pressed events are ignored while a modal dialog is open. Also: "Press scan button to begin scanning". - The HID report byte0 bit7 we already see carries the same button state. ## 6. What I could not determine - This unit's real EEPROM contents (gains, scales, laser positions, pixel size, NURBS surfaces) and the boost-binary blob layout. Reading the EEPROM (section 1.4) is the way to get them. - Which of reg 1/2/3 is physically R/G/B on this sensor. Test empirically. - The meaning and scale of `liftoffSensitivityLaser`; how Sq bytes 14/15, byte 16 and the flag bits are used (nothing found). - The scan-resolution index -> dpi table, and the exact final image flip. - Whether the firmware turns on AWB/AEC itself (the app never writes sensor reg 0x13). ## 7. Sensor proxy protocol (SCCB 0x42 via bridge BD/BE/B8) Source: `OVMouseManager::sccbWrite` 0x138b020, low-level proxy write 0x1390a30, proxy read 0x1390860, generic read dispatcher ~0x13904f0. "Bridge reg" = `OVIF_SCCBRead/Write(0x88, reg, ..)`, i.e. XU sel 2 cmd 0x80/0x00 (`UVCRead/WriteUSBReg`). **State flags (V)** in OVMouseManager: +1 = standby (set at 0x138afaf after D8=00 and D8 reads 0xFF; cleared 0x138b459 after D8=E0 enable-mouse), +2 = mouse mode, +3 = scan streaming (set 0x138aa5e after VideoRun, cleared 0x13864b3 in stopVideo). +1 is NOT cleared by startVideo, so it stays 1 during scan. **Gate (V):** - Proxy read 0x1390860 and proxy write 0x1390a30 both start with `if (this[+1]==0) return` (no bridge traffic at all). So **in mouse mode the app silently does nothing for sensor accesses**. Only standby or scan (+1==1) works. - `sccbWrite(0x42)` additionally requires `this[+3] || this[+1]`. **Proxy write sequence (V), 0x1390a30(reg,val):** 1. Poll bridge B8 up to 100 times; break when B8 == 0. Between polls it sleeps via boost `sleep_until(now + 10000 us)` (10 ms, I for the unit being us). (First read happens before any sleep.) 2. If still nonzero after 100 tries: return 1 (error). Otherwise: 3. write BD = reg; write BE = val; write B8 = 1. Return 0. **No poll after the B8=1 write.** **Proxy read sequence (V), 0x1390860(reg):** 1. Same pre-poll: B8 until 0 (100 tries, 10 ms sleeps). On timeout return without reading. 2. write BD = reg; write B8 = 2. 3. Poll B8 until it reads 0 (up to 100 reads, **no sleep** in this loop, tight loop). 4. **Result = bridge reg BE** (single read, the value returned as the sensor register value). No BE write for reads. No F1 write. **Verify and retry in sccbWrite(0x42) (V):** write(reg,val) via 0x1390a30, then read-back via 0x1390860 and compare with val. On mismatch: loop up to 10 times: sleep 10 ms, rewrite, sleep, read back, compare. Returns 0 on verified match, 1 on failure. The app's sensor writes therefore always read back. **Which path for id 0x42 (V):** the proxy only. OVMouseManager calls `OVIF_SCCBRead/Write` exclusively with id 0x88; it never passes 0x42. The UVCControl path (`ReadSCCB/WriteSCCB` uvc: 0x12ce/0x13a6: if id != cached g_sccbID, bridge write F1=id, then XU sel 2 cmd 0x81 / 0x01) exists in the framework but is unused by the app. The app never writes bridge F1 anywhere (no `0xF1` immediate in OVMouseManager or OVInitUVC). **Implication for your observation (I):** your sequence (BD=reg, B8=2, poll B8, read BE) matches the app's read exactly, and the app too works only in standby/scan. Differences to try: (a) the app writes nothing else first, so the zero BE may be real, e.g. the sensor not yet powered/clocked, or reg values genuinely 0 in standby; (b) try the same read while in scan mode (D8=E1 acked 0x99), where the app also uses it (it writes gain/colour gains at open in standby and window regs 0x16/0x19, and verifies each); (c) do the pre-poll (B8==0) before writing BD; (d) as a control, do a write of a known register (e.g. 0x00 gain, B8=1) and read back with the above protocol; (e) an F1=0x42 plus XU cmd 0x81 read is an alternative the firmware supports (UVCControl) but the app never exercises it, so it is untested by the vendor app. I cannot say from static analysis why BE reads 0 in standby.