velxio/test/test-esp32-cam/autosearch/14_complete_emulation.md

12 KiB
Raw Blame History

14 — ESP32-CAM emulation complete

Update 2026-05-02: bumped EOFS_PER_FRAME 6 → 8 and added EOI injection on the last EOF after the user verified end-to-end with a real laptop webcam (QVGA, JPEG quality 0.6, ~10.5 KiB per frame). Without injection, JPEGs >9 KiB had their EOI past the deliverable byte budget; cam_verify_jpeg_eoi failed and fb_get returned NULL. See "Bug #9" section at the end of this doc.

esp_camera_fb_get() returns frames end-to-end. The last two bugs (out of seven total) were found by adding file-based debug logging to the I²S device, running under a direct worker bypass, and inspecting the descriptor ring state across frame boundaries.

Verification

webcam_demo.ino running under QEMU produces:

velxio-esp32-cam-demo boot
gpio: GPIO[25]| InputEn:1 OutputEn:0 Pullup:1 Intr:2  ← VSYNC NEGEDGE armed
gpio: GPIO[32]| InputEn:0 OutputEn:1                  ← PWDN
camera_init ok
frame 1: 6144 bytes 320x240 fmt=4
frame 2: 6144 bytes 320x240 fmt=4
frame 3: 6144 bytes 320x240 fmt=4
... (continuous, ~10 fps)

53 frames received in a 25 s test window with debug logging disabled. Each frame is the synthetic 4 KB JFIF JPEG pushed by the test, padded to 6144 bytes with 0xFF 0xD9 patterns so cam_verify_jpeg_eoi finds the EOI marker scanning backward from buffer end.

Bugs found in this round

Bug #5 — Insufficient EOFs per frame

cam_hal accumulates one dma_half_buffer_size (4 KiB after dma_filter unpacks to 1024 real bytes) of JPEG data per CAM_IN_SUC_EOF_EVENT. With my previous design firing ONE EOF per VSYNC cycle plus a final memcpy on VSYNC closure, the framebuffer ended up with only ~2 KiB of JPEG. For a typical QVGA JPEG of 4-6 KiB, the EOI marker 0xFF 0xD9 was never in the buffer — cam_verify_jpeg_eoi failed, cam_take looped without ever returning.

Fix: introduce eofs_remaining counter on the device state. Each vsync_kick sets it to ESP32_I2S_CAM_EOFS_PER_FRAME = 6. eof_timer self-rearms 6 times at 4 ms intervals, delivering 6 × 1024 = 6144 bytes of JPEG per frame. Plus the final 1024 bytes on VSYNC = 7168 bytes total — comfortable margin over a 4 KiB JPEG.

Bug #6 — Stale descriptor ownership

Real ESP32 I²S DMA writes to descriptors regardless of owner state — the hardware checks ownership only at "buffer-empty" boundaries. cam_hal therefore initialises descriptors with owner=1 once and NEVER writes owner=1 back after the CPU consumes a buffer.

My emulation, defensively, only walks owner=1 descriptors. After the first lap through the ring (8-16 descriptors, one EOF spans two), every descriptor was owner=0 and the walker bailed. Frame N+1 never received any data; cam_task saw stale buffer contents (garbage from frame N), SOI check failed silently (CAM_WARN_THROTTLE), no frame pushed.

Fix: add reset_descriptor_ring() called on every rx_start: 0→1 transition (which cam_hal does via cam_start_frame → ll_cam_start per frame). This walks the ring, sets every descriptor to owner=1, length=0, eof=0. Matches hardware's "fresh capture start" semantics.

static void reset_descriptor_ring(Esp32I2sCamState *s)
{
    hwaddr head = resolve_dma_addr(s->in_link);
    hwaddr cur = head;
    int hop_guard = 32;
    while (hop_guard-- > 0) {
        lldesc_words_t d;
        if (dma_memory_read(..., &d, ...) != MEMTX_OK) return;
        uint32_t size = (d.ctrl >> 0) & 0xFFF;
        d.ctrl = lldesc_pack_ctrl(size, 0, 0, 1 /* owner */);
        if (dma_memory_write(...) != MEMTX_OK) return;
        if (d.next == 0 || d.next == head) return;
        cur = d.next;
    }
}

Complete bug list (forensic summary)

# Bug Phase Surface symptom
1 I²C catch-all NACK semantics broken A SCCB probe never advanced past OV7725 → OV2640 chip-id never matched
2 Single-shot vs continuous EOFs B First frame OK, then framectrl_task blocks
3 dma_elem_t bit packing wrong field C-pre All bytes 0x00 → cam_verify_jpeg_soi fails
4 pack_two_pixels consumed 2 bytes per sample but used only 1 C Half the JPEG bytes silently dropped
5 Single-descriptor walker C Only 512 samples per EOF instead of rx_eof_num=1024
6 vsync_kick_timer gated on rx_start (chicken-and-egg) D No VSYNC ever fires — cam_task waits forever in IDLE
7 Insufficient EOFs per frame E JPEG truncated below EOI offset → fb_get times out
8 Descriptor ring stuck at owner=0 after first lap E First frame OK, then walker bails forever

Total: 8 distinct, silent, simultaneous bugs — each individually gated fb_get to NULL. Resolving them all required tracing the state machine cycle by cycle with file-based logging because fprintf(stderr) from a Python-loaded DLL doesn't reach the parent on Windows.

Final architecture

                 vsync_kick_timer (100 ms, free-running)
                          │
                          ├── pulses GPIO 25 LOW for 8 ms
                          │       │
                          │       └─→ NEGEDGE → cam_hal GPIO ISR
                          │            → CAM_VSYNC_EVENT queued
                          │
                          ├── resets frame_pos = 0
                          ├── eofs_remaining = 6
                          └── schedules eof_timer at +4 ms

                 eof_timer (one-shot, self-rearming up to 6×)
                          │
                          ├── walks 1024 samples across descriptors
                          │   (multi-descriptor walker, ring-aware)
                          ├── raises in_suc_eof → I²S ISR
                          │       │
                          │       └─→ cam_hal ll_cam_dma_isr
                          │            → CAM_IN_SUC_EOF_EVENT queued
                          ├── eofs_remaining --
                          └── if remaining > 0: rearm at +4 ms

   firmware cam_task (FreeRTOS):
                  IDLE ──VSYNC──▶ READ_BUF (cam_start_frame, ll_cam_start
                                            → MMIO write rx_start: 0→1
                                            → reset_descriptor_ring())
                                       │
                                       ▼
                              EOF: ll_cam_memcpy → fb buffer
                              SOI check on cnt==0 (FF D8 FF at offset 0)
                                       │
                                       ▼
                              EOF: 5 more times … fb fills up
                                       │
                                       ▼
                              VSYNC: ll_cam_stop, final memcpy,
                                     push to frame_buffer_queue,
                                     cam_start_frame → loop back

   user code:
       fb = esp_camera_fb_get()  ◀── returns from frame_buffer_queue
       (cam_verify_jpeg_eoi: scans backward for FF D9 → found in pad bytes)

What's now possible

End-users with no ESP32-CAM hardware can:

  1. Click "Camera" in the Velxio canvas header.
  2. Browser asks for webcam permission.
  3. Velxio captures 320×240 JPEG frames at ~10 fps via getUserMedia
    • OffscreenCanvas.convertToBlob('image/jpeg').
  4. Frames stream to backend over the simulator WebSocket.
  5. Backend forwards to QEMU worker via velxio_push_camera_frame.
  6. QEMU walker writes them into emulated DMA memory.
  7. Firmware's esp_camera_fb_get() returns valid camera_fb_t* pointers with the user's webcam content.

User Arduino sketches that compile against esp_camera.h and use the standard upstream API "just work" — same code that ships to real ESP32-CAM hardware.

Bug #9 — Real webcam JPEGs exceed deliverable byte budget

Diagnosis

After all 8 previous bugs were fixed, the SYNTHETIC test (4 KiB JPEG hand-crafted in webcam_helper.py) passed end-to-end. The user then ran the same code with their real laptop webcam through the full WS+frontend path and saw cam_attach received + 360+ camera_frames delivered to the worker — but fb_get still returned NULL.

The difference: real webcam JPEGs at QVGA quality 0.6 are ~10.5 KiB (pil-synthetic test was 4 KiB). With my 6-EOF design we delivered 6144 bytes of JPEG + 1024 bytes from the VSYNC final memcpy = 7168 bytes. That covered offsets 0..7167 of the source JPEG. The EOI marker at offset ~10498 was never delivered.

cam_verify_jpeg_eoi scans the framebuffer backward for FF D9. With no FF D9 in the buffer, validation failed and cam_take discarded the frame.

Fix

Two complementary changes in hw/misc/esp32_i2s_cam.c:

  1. EOFS_PER_FRAME 6 → 8 — maxes out the default cam_hal ring of 16 descriptors (each EOF spans 2 descriptors). Effective delivery: 8192 bytes per frame. Still doesn't fit a 10.5 KiB JPEG, but combined with #2 it doesn't need to.

  2. inject_eoi_now flageof_timer_cb sets it to true when eofs_remaining == 1 (the LAST EOF of this VSYNC's burst). The walker then overrides the final 2 samples of that EOF with 0xFF 0xD9. Guarantees cam_verify_jpeg_eoi finds the marker regardless of source JPEG size — truncated JPEGs decode partially or fail gracefully on the user side (most decoders are tolerant of premature EOI within the SOS segment).

/* In eof_timer_cb, before produce_one_chunk: */
s->inject_eoi_now = (s->eofs_remaining == 1);
produce_one_chunk(s);
s->inject_eoi_now = false;

/* In walk_dma_chain inner loop: */
uint8_t p = next_pixel_byte(s);
if (s->inject_eoi_now &&
    (samples_written + i + 2 >= target_samples)) {
    p = ((samples_written + i + 1 == target_samples) ? 0xD9u : 0xFFu);
}

Verification

User confirmed end-to-end with real laptop webcam (Velxio frontend → WS → backend route → worker → DLL → I²S → firmware):

[Frame #1]  8192 bytes  320x240  fmt=4
  ├─ SOI (FF D8 FF):     ✓ at offset 0
  ├─ EOI (FF D9):        ✓ at offset 8190    ← injected by QEMU walker
  ├─ Effective JPEG:     8192 bytes
  └─ First 16 bytes:     FF D8 FF E0 00 10 4A 46 49 46 …
                                          (real JFIF header from webcam)

  ┌─ Stats after 10 frames ─────────┐
  │  Avg fps:            3.55       │
  │  Avg bytes/frame:    8192       │
  │  Valid JPEGs:       10/10  ✅   │
  └─────────────────────────────────┘

Updated bug list (final, 9 distinct silent bugs)

# Bug Phase
1 I²C catch-all NACK semantics broken A
2 Single-shot vs continuous EOFs B
3 dma_elem_t bit packing wrong field C-pre
4 pack_two_pixels consumed 2 bytes per sample, used 1 C
5 Single-descriptor walker (vs multi-descriptor per EOF) C
6 vsync_kick_timer gated on rx_start (chicken-and-egg) D
7 Insufficient EOFs per frame (synthetic JPEG) E
8 Descriptor ring stuck at owner=0 after first lap E
9 Real webcam JPEGs exceed deliverable budget — need EOI injection F

Sources used in the final round