# MAG160C IR Camera Protocol Specification (Recovered via Binary Reverse Engineering) Recovered from: Linux `libmagcore.so.2.1.1` (x86-64 ELF, disassembled with capstone/objdump), Windows `CoreSDKLib.dll` / `ThermalSDK.dll`, and Android `libcoresdk.so` (ARM64, exported `CFunctions`/`CAccumulator`/`CMagDevice` symbols). All offsets/opcodes below were verified in the binaries listed in `analysis/` (`magcore_disasm.txt`, `coresdk_arm64_defined.txt`, etc). ## 1. Device Identity & USB Topology | Item | Value | Evidence | |---|---|---| | VID | `0x833C` | `MAG_LinkCamera` hard-codes `0x833c` (`libmagcore` 0x1d880); link loop compares `vid == 0x833c` (0x184ed) | | PID | `0x0001` (default; PID filter can be overridden) | Hardware Guide; link loop compares pid only when pid!=0 (0x184f6..0x18507) | | Config value | `2` | `libusb_set_configuration(handle, 2)` at 0x1855c | | Interface | `0` | `libusb_claim_interface(handle, 0)` at 0x1857d | | Kernel driver | auto-detach enabled | `libusb_set_auto_detach_kernel_driver(handle, 1)` at 0x18554 | Link sequence (`libmagcore` 0x18440, called from `MAG_LinkCameraEx` 0x186b0): 1. `libusb_get_device_list` 2. For each device: `libusb_get_device_descriptor`; accept when `vid==0x833c` and (pid filter==0 or `pid==filter`) and (optional bus/addr filters match) 3. `libusb_open` (handle stored at dev+0x68) 4. `libusb_set_auto_detach_kernel_driver(handle, 1)` 5. `libusb_set_configuration(handle, 2)` 6. `libusb_claim_interface(handle, 0)` 7. Record timestamp (heartbeat), set open flag dev+0x80=1, pid dev+0x78 ## 2. Endpoints & Bulk Transfer Usage | Endpoint | Direction | Used for | Timeout | Max length | |---|---|---|---|---| | `0x03` | OUT | command packets | 0x1f4 = 500 ms | variable (4..0x3c) | | `0x82` | IN | command responses | 0x7d0 = 2000 ms | 0x1000 = 4096 | | `0x81` | IN | frame stream (realtime) | 0x1f4 = 500 ms | 2 × frame-data length | | `0x84` | IN | bulk/large data reads | 0xea60 = 60000 ms | 0x80000 = 524288 | ## 3. Command / Response Protocol (endpoint pair 0x03 / 0x82) ### 3.1 Command packet shapes All commands are written on EP OUT `0x03`, then the SDK immediately issues one `libusb_bulk_transfer` on EP IN `0x82` reading up to 0x1000 bytes with a 2000 ms timeout. Success requires the read to return more than 3 bytes. Two shapes observed: - **8-byte command**: `{u32 magic, u32 param}` — used by all `0x6bb6b6xx` commands. - **0x3c-byte command**: `{u32 magic, 0x38 bytes payload}` — used by DDT load/store (`0x6bb6b66d` / `0x6bb6b66e`). Send path: `libmagcore` 0x189f0 (raw), 0x18ad0 (u32), 0x18b10 (two u32); response dispatch at 0x187f0. ### 3.2 Command codes (all observed values) | Magic | Purpose | Notes | |---|---|---| | `0x6BB6B66B` | prepare/calibration query 1 | sent back-to-back with 0x66c (0x195af) | | `0x6BB6B66C` | prepare/calibration query 2 | (0x195c1) | | `0x6BB6B66D` | write 0x38-byte payload (e.g. DDT save) | (0x19892) | | `0x6BB6B66E` | read 0x38-byte payload (e.g. DDT load) | (0x198e9) | | `0x6BB6B66F` | camera info query (PID etc) | response → dev+0xb00.. (0x192e0) | | `0x6BB6B670` | prepare/version query | response → dev+0xb70 (0x19a6c) | | `0x6BB6B672` | **trigger FFC** | param passed through (0x19250) | | `0x6BB6B673` | **start streaming/processing** | sent after 50 ms sleep + threads created (0x1909a) | | `0x6BB6B674` | **stop** | (0x18c13) | | `0x6BB6B676` | set parameter A | (0x19280) | | `0x6BB6B677` | set parameter B | (0x192b0) | ### 3.3 Response codes (on EP 0x82) | Magic | Length | Meaning | Disposition | |---|---|---|---| | `0x5BB5B55B` | 0x38 | camera info block | copy 0x38 bytes → dev+0xb00 (0x188e0): `[0]=pid, [0x10]=width, [0x14]=height, ...`; updates per-core name table (`core-%d`), sets dev+0xac0 count | | `0x5BB5B55C` | 0x38 | info block (second slot) | copy 0x38 bytes → dev+0xb38 (0x18880); `[0x30]` also → dev+0xb68 | | `0x5BB5B55D` | 0x38 | info block (third slot) | (0x15396, DDT blocks) | | `0x5BB5B55E` | 0x10 | two qwords | qword0 → dev+0xb70, qword1 → dev+0xb78 (0x18851); used by 0x6bb6b66f/0x6bb6b670 responses | | `0x5BB5B55F` | 0x38 | info block (fourth slot) | (0x154be) | | `0x5BB5B57B` | — | DDT/file header marker | used in saved-file format (0x151db) | Response 0x5BB5B55B payload layout (camera info, 0x38 bytes): ``` +0x00 u32 pid (compared to dev+0x78) +0x04 u32 type +0x08 u64 serial/other +0x10 u32 width (0xa0 = 160) +0x14 u32 height (0x78 = 120) +0x18 u32 ... +0x1c u32 ... +0x20 u32 ... +0x24 u32 ... +0x28 u32 ... +0x2c u32 ... +0x30 u32 ... +0x34 u32 ... ``` ## 4. Frame Stream Protocol (endpoint 0x81) Reader thread (`libmagcore` 0x170a4): - loops while running: `libusb_bulk_transfer(handle, EP 0x81, buf, 2*len, &n, 500ms)`, appends into a `2*len + 0x470` byte ring buffer (dev+0x328). - Searches (4-byte aligned) for marker `0x1BB1B11B`. - When `buffered >= 0x38 + frame_len` and buffer starts with the marker and `*(u32*)(buf+8) == frame_len` and `*(u32*)(buf+0x1c+frame_len) == 0x1BB1B11C`, the frame is accepted and memcpy'd to the frame slot: `memcpy(meta+0x4c, buf+0x1c, frame_len)`. - Signals condvar; dispatcher thread (0x16fb5) copies `frame_len+0x4c` bytes to the user buffer (dev+0x50) and invokes the new-frame callback `cb(frame_index, data)`. ### 4.1 Frame layout (in-buffer) ``` +0x00 u32 0x1BB1B11B leading marker +0x04 u32 frame counter (passed through to callback / stored at dev+8) +0x08 u32 data length (== frame_len; 0x9600 = 38400 for 160x120 raw uint16) +0x0c u32 frame type (0 or 1; must be <= 1) +0x10 u32 period/shutter (a shutter value; SDK stores shutter = frame[0x10] + 0x1f4 when width==160, else frame[0x08]) +0x14 u32 ... +0x18 u32 ... +0x1c ... pixel data (frame_len bytes; for 160x120 = 38400 = 19200 × uint16 LE) +0x1c+frame_len u32 0x1BB1B11C trailing marker +0x20+frame_len ... tail (rest of the 0x38-byte fixed overhead) ``` Total frame size = `0x38 + frame_len`. Width is validated against the expected value: 0xa0 (160) or 0x180 (384). The per-frame metadata block passed to the higher layer is 0x4c bytes: `{0, frame_len, 0, height, -50000, frame[0x10], shutter, 0, 0, 0, 0}` + data. ### 4.2 Frame dispatch / processing threads Start (`MAG_StartProcessImage` → 0x1d8b0 → 0x18f40): 1. allocate `2*frame_len + 0x470` read buffer (dev+0x328) and `frame_len + 0x4c` frame slot (dev+0x330) 2. register user callback (dev+0x48/0x50) 3. create dispatcher thread 0x16fb5 (waits condvar, copies frame, calls callback) 4. create reader thread 0x170a4 (bulk reads EP 0x81, frame parsing) 5. `usleep(50000)` 6. send command `0x6BB6B673` 7. set running flag (dev+0x84 = 1) Stop: send `0x6BB6B674`, join threads, free buffers (0x18c10). ## 5. Start / Stop / FFC / Info API ↔ command mapping | Public API | Command(s) | Implementation | |---|---|---| | `MAG_LinkCamera*` | none (USB setup only) | 0x18440 | | `MAG_PrepareProcessImage` | thread → 0x193f0 (query via 0x6bb6b66f) → 0x19a40 (0x6bb6b670) | 0x19990 | | `MAG_StartProcessImage` | `0x6BB6B673` | 0x18f40 | | `MAG_StopProcessImage` | `0x6BB6B674` | 0x18c10 | | `MAG_TriggerFFC` | `0x6BB6B672` + param (1) | 0x1b850 → 0x19250 | | `MAG_SetFFCMode` | stored; mode affects `0x6bb6b673` behavior | 0x1b810 | | `MAG_GetCamInfo` | cached copy of dev+0x60..0xb0 (0x50 bytes) | 0x1b230 | | `MAG_GetCameraTemperature` | cached dev+0xb38 block | 0x1b030 | | `MAG_GetCurrentCameraInnerTemperature` | cached dev+0x... | 0x1aff0 | | `MAG_GetSenorTemperature` | cached | 0x1b070 | | `MAG_ResetCamera` | `0x6BB6B672`-style reset path | 0x1bff0 | | DDT save/load | `0x6BB6B66D` / `0x6BB6B66E` + 0x38-byte payload | 0x19840 | Error codes (custom errno domain `0xE4AE0001..`): 1 = invalid argument, 2 = no memory, 3 = not ready/already running, 4 = not initialized, 5 = not open/not linked, 6 = not supported, 7 = failed, 8 = ..., 9 = ..., 0x101/0x202/0x205/0x206 = link failures. ## 6. DDT Saved-File Format (`MAG_SaveDDT` / `MAG_LoadBufferedDDT`) Header `0x5BB5B57B` followed by 4-byte-aligned payload (padding with `0x00`), then a sequence of typed blocks, each `{u32 magic, u32 aligned_len, data, padding}`: - `0x5BB5B55B` (0x38-byte info block) - `0x5BB5B55C` - `0x5BB5B55D` - `0x5BB5B55E` - `0x5BB5B55F` ## 7. Temperature Conversion Pipeline (Android `libcoresdk.so`, ARM64) Pipeline per frame (CFunctions object at offset base): ``` PushFrame(frame, srcType) 0x6a4e4 ├─ CAccumulator::PushFrame (baseline) 0x62548 └─ Raw2Temperature(frame) 0x6a398 (mode from this+0x1dc4) ├─ mode==1 && flag(0x1cf4): copy baseline ptr, then Calibration(frame) @ 0x6a41c └─ else: ConvertResponse2Temperature(frame) @ 0x73124 (if flags 0x1200/0x1208 clear → simple path) └─ BlindCompensation(out) 0x72f30 ``` ### 7.1 Calibration (piecewise-linear per-pixel map) — 0x6a41c ``` for p in 0..n-1: n = this+0x76d0 (pixel count, e.g. 19200) diff = (int16)(frame[p] - baseline[p]) >> 1 // baseline from this+0x1ca8 (0 if flag 0x1cc4 clear) i = 0 while i < cnt-1 and diff > thresh[i]: i++ // thresh = this+0x1610 (per-pixel thresholds, int16) v = table[i*n*2 + p*2 + 1] + ((diff * table[i*n*2 + p*2]) >> 12) // table = this+0x1628 v = max(v, 0); v = min(v, 0xffff) out[p] = v // out = this+0x130 then BlindCompensation(out) ``` `cnt = this+0x130c` (band count). Threshold table is per-pixel too (advances `cnt-1` entries per pixel). ### 7.2 ConvertResponse2Temperature (auto-gain) — 0x73124 ``` base = clamp((0xc350 - this+0x76e0) >> (this+0x76e4), 0, 0xffff) // 0xc350 = 50000 coeff = global [0x4028a4] for p: v = base + (response[p]-baseline[p]) * coeff; v = clamp(v,0,0xffff) overflow_count += (v saturated) if overflow_count > 0x101: coeff-- (auto reduce gain, logs "AGAIN") ``` ### 7.3 Probe temperature read — GetTemperature(pos, mode, lock) @ 0x7252c 1. validate pos < pixel count, mode in 1..5 → radius r (1..3) 2. window average over `temp[(y+dy)*width + (x+dx)]` (this+0x140) 3. `ReviseTemperature(avg, pos, 1)` → int 4. if flag this+0x58: `CorrectTemperature(v, emissivity, mode)` → int 5. invalid → `0xE4AE0001` / INT_MIN ### 7.4 ReviseTemperature(value, mode, offset) — 0x6cdb8 (PWL via T2E) ``` if this+0x1638 (calibrated) and emissivity(this+0x1844+ch*0x21c) in (0.01, 0.99): x = (value << this+0x76e4) + this+0x76e0 i = clamp((x + 0xc350) >> 13, 0, 0x111) // index into T2E v = T2E[i] + ((x - i<<13) * (T2E[i+1]-T2E[i]) >> 13) ... sensor-temp float mixing: (v_val - (1-s0)*v_sensor) / s0 binary search T2E for result → idx out = (-0xc350 + idx<<13 - gain + (result-T2E[idx]) * slope[idx] >> 10) >> shift else: simple path 0x6d678: x = (value << shift) + gain (no correction) ``` ### 7.5 CorrectTemperature(value, emissivity, mode) — 0x6d6b0 ``` guard: 0 < this+0xcc <= 1, 0 < emissivity <= 1 v1 = T2E interp(value) v2 = T2E interp(fcvtzs(this+0xb0 * 1000.0)) s = (this+0xcc) * (this+0xd0) * emissivity res = (v1 - (1-s)*v2) / s idx = binary search T2E for res out = idx*8192 - 0xc350 + ((res - T2E[idx]) * slope2[idx] >> 10) clamp out <= 0xfffbd502 k = clamp((out - 15000)/100, 0, 6) // offset segmentation out += (this+0x88 + 4k).off0 + slope*... + fcvtzs(this+0xc4 * out) ``` Units: 0xc350 = 50000, 0x3a98 = 15000 → the integer result is in **millidegrees Celsius × 0.01 fixed scale** (temperature ≈ out/100000 °C × 100 → see `0x3394f4 = 1000.0f`, `0x3394fc = 0.01f`), consistent with invalid temp = -1.0e8 in Java and `GetTemperaturemC` API. ### 7.6 T2E table (`T2E` symbol @ 0x402010) 0x112 monotonic u32 entries, first = 0x3e8 (1000), last = 0xb43bc (737212). Dumped to `analysis/t2e_table.json`. A second runtime-initialized slope table is referenced via GOT 0x401f60 (inverse-map slopes per index). ### 7.7 CAccumulator::PushFrame — 0x62548 (baseline / FFC tracking) State: counter this+0x20, target this+0x28 (mode), pixel count this+0x24 (>>2), buffers this+0x10 (u16 acc), this+0x18. - mode 1: memcpy acc ← frame (2×count bytes) - mode 2: acc[i] = (acc[i]+frame[i]) >> 1 - switch on frame count (3..0x1f states at 0x336220) handles restart; returns 1 when count == target (counter reset) ## 8. Windows parity notes - `CoreSDKLib.dll` imports `libusb0.dll` (usb_bulk_write/usb_bulk_read/...), exports the same `MAG_*` ABI; `ThermalSDK.dll` wraps it. - Windows frame thread has the same 0x1bb1b11b/0x1bb1b11c parser and the same `SendCmd(EP 0x03) + read(EP 0x82)` pattern (strings: "usb error: usb_bulk_read=%d, %s"). - Android `libcoresdk.so` uses the same magic codes via JNI `CMagDevice`; device open uses the native fd from Android UsbManager (bulk endpoints same). ## 9. Remaining unknowns - Exact T2E slope table (GOT 0x401f60) contents — runtime-initialized, likely derived from the loaded DDT/calibration file; structure confirmed, values not in static image. - Exact payload semantics of `0x6BB6B676` / `0x6BB6B677`. - Frame type 0 vs 1 selection criteria and the `frame[0x10]` period/shutter semantics. - Whether camera delivers baseline/DDT over EP 0x84 or via command 0x6BB6B66E. ## 2026-08-10 Live Hardware Verification (supplement to sections 3-4) Measured on a real unit (VID 0x833C PID 0x0001, serial 160043865) by intercepting the official Windows demo (libusb0.dll API shim) and by direct libusb-1.0 probing. These override the earlier Linux-SDK-derived assumptions: 1. **Commands are 4 bytes** (magic only) for 66b/66c/66f/673/674. Only FFC (672) uses an 8-byte {magic, param} packet. The Linux SDK sent 8-byte {magic, param} via SendDword, but the unit only accepts 4-byte for the plain commands (8-byte start is STALLed). 2. **Init sequence**: 66b -> 66c -> 66f (4B each) -> FFC(0) x2 -> sleep 300 ms -> START(73). The official demo sends exactly this. 3. **Info block**: 0x5bb5b55b (from 66b) is the camera info: +0x00 pid, +0x10 width=160, +0x14 height=120, +0x18 fps=15. 0x5bb5b55c (from 66c) is a different block; on this unit its +0x10/+0x14 are 5/4 and must not be used as dimensions. 4. **FFC semantics**: FFC(1) switches stream frames to type=0; FFC(0) switches back to type=1. FFC must be issued after a full frame (header + payload). The demo alternates FFC(0)/FFC(1) about every 10 frames. 5. **Frame stream**: 28-byte header + 38400-byte payload + 28-byte tail, 15 fps. Trailer marker 0x1bb1b11c confirmed. Live capture: 60 frames in 4 s. 6. **Temperature signal**: hand vs background ~1322 counts (FOV-covering hand); ~147 counts/C under a 34C-vs-25C assumption; frame-difference rendering shows the hand clearly. ## 2026-08-10 (evening) - FFC cadence fully decoded (libusb0_trace.txt) Per-line analysis of the 26092-line official trace (frame = 28B header + 38428B data): 1. **Init**: 66b -> 66c -> 66f -> FFC(0) -> START -> [frame 1] -> FFC(0). 2. **FFC(1) after ~frame 10** switches the stream to type=0 (frames 11+). 3. **Sustained cadence**: FFC(0), then exactly 9-10 frames later FFC(1), then a variable gap (34..2680 frames) before the next FFC(0). Each FFC toggles the type; type=1 only appears inside the 9-frame window. 4. Full trace: 10898 frames, 10519 type=0 (96.5%), 194 read errors, stream never stalls. 5. FFC is always issued after a complete frame (header + payload read). Verified reproduction (hardware, 2026-08-10): scheduler with period 400 frames and gap 9 frames (csdk mag160c_ffc_scheduler_t) sustained 1400+ type=0 frames at 15.1 fps with zero stalls, both in the single-threaded demo loop and in the csdk threaded reader (mag160c_ir_start + scheduler). An earlier 44-frame period caused fps degradation and 17.7% type=1 frames.