Skip to content

ESP32-C3 WiFi MAC ↔ SimNet bridge (design + RE notes)

Update (TCP/HTTP landed). The virtual AP (peripherals/esp32c3/virtual_wifi.rs) now terminates TCP to an HTTP port in addition to DHCP/ARP/UDP: a station's SYN gets a SYN-ACK, its HTTP request is reassembled and handed to the shared L4 [HttpServer] (network/sim.rs, reusing its router + HTTP/1.1 encoder — one source of truth), and the response is segmented back followed by a FIN. It serves the /v1/public-stats snapshot (the LBC3.1 device's target). This closes the "subsequent TCP payloads relay to the existing L4 SimNet servers" item below. Covered by unit tests (tcp_http_get_roundtrip, tcp_http_unknown_path_404) that drive the exact segment exchange lwIP performs, with checksum verification. Still owed: end-to-end validation against a real booted C3 running esp_http_client (needs a connecting-app firmware fixture; the current probe idles / only does UDP).

Status: bidirectional + associated. An unmodified C3 IDF binary boots, brings WiFi up, and associates with a virtual AP over the real MAC (no wifi_thunks): init → auth → assoc → run → STA CONNECTED. The bridge injects RX frames (beacon/auth-resp/assoc-resp) and captures the MAC's TX (the STA's probe/auth/assoc requests and a real broadcast IPv4/UDP DHCP-discover). Remaining: an IP/DHCP responder that turns captured TX into injected RX replies so the STA gets an IP and exchanges app traffic.

Bidirectional summary (implemented in esp32c3::wifi_mac + cli bridge)

  • RX inject (queue_rx_frame): write [48-byte rx-control header][802.11 frame] into an owner-held lldesc RX descriptor, set the lldesc (owner=0, eof=1, length=total), set RX event 0x01004000, raise MAC IRQ (matrix src 0). Header must have word@0 bit28 (matched-vif), word@4 = 0x08000000 | len<<8, byte44=rssi, byte45=rate.
  • TX capture (take_tx_frames): on a write of 0xC000_0000 to per-AC PLCP0 0x60033D08-AC*8, follow the low-20-bit pointer → TX lldesc → word1 = frame buffer (DRAM), read the 802.11 frame, then signal TX-complete (per-queue done state 0xCB0 + event bit 0x80) since TX is fire-and-forget.
  • Virtual AP (cli build_open_beacon/build_auth_resp/build_assoc_resp, BSSID 02:00:00:00:00:01, STA 00:00:00:00:00:00, OPEN auth).

The remaining DHCP/IP final mile

The captured DHCP-discover (802.11 data, to-DS, LLC/SNAP ethertype 0x0800, IPv4/UDP) must be answered: parse it, build a DHCP-offer then DHCP-ack (assign e.g. 192.168.4.2, gw 192.168.4.1), wrap in UDP/IPv4 (IPv4 header checksum required; UDP checksum may be 0)/LLC-SNAP/802.11-data (from-DS: addr1=STA, addr2=BSSID, addr3=server), and queue_rx_frame it. Then the STA reaches IP_EVENT_STA_GOT_IP; subsequent TCP/UDP payloads relay to the existing L4 SimNet servers. (Original phase notes below.)

Why this is its own phase (the impedance mismatch)

  • The real C3 MAC operates on raw 802.11 frames in hardware DMA rings. The running firmware (lmac/pp/wdev + the libnet80211 driver) does real scan → auth → assoc → data, programming the MAC registers and DMA descriptors.
  • The existing SimNet (crates/core/src/network/sim.rs) is an L4 socket simulation (TCP connect/send/recv, HTTP/echo servers, VirtualAp.associate). The S3 bridged to it by thunking at the lwIP socket layer (esp32s3::wifi_thunks) and faking WL_CONNECTED — i.e. it never ran esp_wifi/MAC at all. That is the thunk we are explicitly removing.

Bridging the real MAC to SimNet therefore needs a frame-level layer in between (802.11 ↔ Ethernet ↔ the existing L4 SimNet), not a socket shim.

MAC interrupt / event anatomy (RE'd from wifi_probe.elf + ROM)

  • MAC interrupt event register: hal_mac_interrupt_get_event reads 0x6003_3C3C; hal_mac_interrupt_clr_event writes 0x6003_3C40 (W1C).
  • ISR wDev_ProcessFiq (0x4038_34A4) reads the event word and dispatches: | event mask | handler | meaning | |---|---|---| | 0x0100_4000 | lmacProcessRxSucData (ROM 0x4000_1614) | RX frame received | | 0x80 | lmacPostTxComplete (ROM 0x4000_1608) | TX complete | | 0x100 | lmacProcessCollisions(ROM 0x4000_1610) | TX collision | | 0x1E | wdev_process_tbtt | beacon timing | | 0x1E0 | wdev_process_tsf_timer | TSF |
  • RX is a descriptor linked list (wdev_record_rx_linked_list, wdev_dump_rx_linked_list); lmacProcessRxSucData walks it.
  • MAC interrupt = interrupt-matrix source 0 (MAC_INTR_MAP @ offset 0 in interrupt_core0.yaml), routed to a CPU line by the C3 interrupt matrix we already model — so raising it delivers to wDev_ProcessFiq via the normal trap path.

MAC DMA registers (RE'd from the live connect run, LABWIRED_MAC_TRACE)

Captured by tracing writes to the 0x6003_3000 MAC window while the real driver brings WiFi up and starts a scan:

  • RX descriptor ring base: 0x6003_3088 ← a DRAM pointer (e.g. 0x3fca4904).
  • RX descriptor format (linked list, 3 words each): | word | meaning | |---|---| | 0 | flags/len — 0x8064_0640: bit31 = owner (HW may fill), low 16 = buffer size (0x640 = 1600 = the "static rx buffer" size) | | 1 | buffer pointer (DRAM, the 1600-byte frame buffer) | | 2 | next-descriptor pointer (ring is a singly-linked list) |
  • Trigger / handshake: 0x6003_3084 bit31 (written 0x8000_0000 to kick; the prior session's "handshake" scratch bit).
  • Other config seen: 0x6003_3c60/c64/c6c (a second ring/EOF pointer at 0x6003_3c640x3fc00000, zeroed), 0x6003_3d04, 0x6003_3084.

RX descriptor is an ESP lldesc_t (CONFIRMED by tracing the driver's reads of an injected descriptor): word0 = size[11:0] | length[23:12] | offset[28:24] | sosf[29] | eof[30] | owner[31]. Empty/HW-owned reads 0x80640640 (owner=1, length=size=1600). On RX completion HW writes owner=0, eof=1, length=actual-rx-bytes, size preserved (e.g. 0x40140640 for a 320-byte frame). VALIDATED end-to-end: with that writeback, the real driver's RX callback follows word1 (buffer ptr) and reads the injected frame bytes out of the buffer, then recycles the descriptor (owner re-set to 0xc0140640). The RX inject path (queue → DMA → lldesc → MAC IRQ → wDev_ProcessFiqlmacProcessRxSucData → driver reads frame) works against the real firmware. The 802.11 frame starts at buffer offset 0 (no rx-control prefix in the buffer).

TX ring (still to RE): the scan probe-request TX path hadn't queued a TX descriptor within the traced window; needs a longer trace / break on the lmac TX path to find the TX-kick register + descriptor.

RX-inject mechanism (target design)

  1. Place the received 802.11 frame into the next free RX DMA descriptor's buffer (RX ring base register: TODO — finish RE'ing where the driver programs it in mac_txrx_init / ppRxPkt).
  2. Set the RX-success bits in the event register 0x6003_3C3C (0x0100_4000).
  3. Assert MAC interrupt source 0 → matrix → CPU line → trap → wDev_ProcessFiqlmacProcessRxSucData consumes the descriptor and hands the frame up.

TX-capture mechanism (target design)

The driver fills a TX descriptor and writes a TX-kick register; the model reads the frame out of the descriptor buffer and hands it to the frame-level AP, then sets the TX-complete event bit (0x80) + raises the MAC interrupt so lmacPostTxComplete runs. TODO — RE the TX-kick register + descriptor format.

Remaining build (sequence)

  1. MAC DMA model (esp32c3::wifi_mac, behavioral, replacing the declarative window but preserving the bring-up register-backing + MAC-ready bit): event register + interrupt raise + RX descriptor inject + TX descriptor capture. Finish the RX-ring-base / TX-kick RE first.
  2. Frame-level VirtualAp: handle the 802.11 management the driver sends (probe/auth/assoc) so it associates, and relay data-frame payloads to/from the existing L4 SimNet (de/encapsulate 802.11 ↔ Ethernet ↔ IP).
  3. A connecting C3 app: the current wifi_probe fixture brings WiFi up and idles ("idling for trace") — it never scans/connects, so it generates no MAC traffic. A minimal esp_wifi_connect + socket app (C3 IDF build) is needed to exercise and validate the bridge end-to-end.

The natural first milestone is association over the real MAC (driver TX of probe/auth/assoc via the real DMA ring → frame-level AP responds via RX inject → driver reaches connected) — the first true "real MAC, no thunks" comms.