summaryrefslogtreecommitdiff
path: root/src/net
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-25 12:40:53 -0300
committerGabriel Schneider <[email protected]>2026-08-25 12:46:51 -0300
commitf5f8068fac59b4f16046c2022c2fc7c7e447ef4c (patch)
tree2731a3ed4e51cae09e184e25778eded5fc37d1f5 /src/net
downloadesp32p4-f5f8068fac59b4f16046c2022c2fc7c7e447ef4c.tar.gz
esp32p4-f5f8068fac59b4f16046c2022c2fc7c7e447ef4c.zip
zig-p4: pure-Zig ESP32-P4 toolchain
build.zig generates the linker script and drives Zig's own LLD; tools/image.zig turns the ELF into a flashable image and tools/{rom,serial}.zig speak the mask ROM loader over the UART. No CMake, ninja, idf.py, esptool, or external linker. src/soc.zig is a comptime register model over ESP-IDF's own *_reg.h headers; src/hal/ adds peripheral sequences; src/io/ implements std.Io for the chip; src/oracle/ diffs this HAL against ESP-IDF's on the die.
Diffstat (limited to 'src/net')
-rw-r--r--src/net/all.zig156
-rw-r--r--src/net/heap.zig673
-rw-r--r--src/net/hosted/abi_assert.c78
-rw-r--r--src/net/hosted/include_dirs.txt171
-rw-r--r--src/net/hosted/pin_assert.c56
-rw-r--r--src/net/hosted/sdkconfig.h139
-rw-r--r--src/net/hosted/sdkconfig_idf.h1473
-rw-r--r--src/net/hosted/wifi_shim.c200
-rw-r--r--src/net/hosted_glue.zig320
-rw-r--r--src/net/hosted_os.zig890
-rw-r--r--src/net/ip.zig2903
-rw-r--r--src/net/ip_test.zig3029
-rw-r--r--src/net/libc.zig457
-rw-r--r--src/net/link.zig552
-rw-r--r--src/net/port.zig2194
15 files changed, 13291 insertions, 0 deletions
diff --git a/src/net/all.zig b/src/net/all.zig
new file mode 100644
index 0000000..3fc6dba
--- /dev/null
+++ b/src/net/all.zig
@@ -0,0 +1,156 @@
+//! Everything the radio path needs, in one translation unit.
+//!
+//! This file exists for the same reason src/appdesc.zig is a separate object: the files it names
+//! define `export`ed C symbols that ESP-Hosted's C calls, and nothing in the application source
+//! mentions them. An ordinary `@import` would be analysed lazily under ReleaseSmall, the exports
+//! would never be emitted, and the link would fail with a list of missing `_h_*` symbols that looks
+//! like the port table was never written.
+//!
+//! Referencing each import in a `comptime` block forces analysis, which forces the exports.
+//!
+//! The layers, bottom up:
+//!
+//! src/hal/sdmmc.zig the P4's SDMMC peripheral as an SDIO host
+//! src/io/p4.zig std.Io for this chip - the cooperative runtime everything above uses
+//! src/net/libc.zig the libc symbols ESP-Hosted's C reaches for
+//! src/net/port.zig `g_h`, the table ESP-Hosted reaches the machine through
+//! src/net/hosted_glue.zig logging, event bases, and loud stubs for layers not yet run
+//! src/net/ip.zig IPv4/ARP/ICMP/UDP/DHCP/TCP/HTTP, replacing lwIP
+//! src/net/link.zig ESP-Hosted's station channel, bridged to that stack
+//!
+//! ESP-Hosted's transport C sits on top of `port.zig` and is compiled by `hostedC` in build.zig.
+
+const std = @import("std");
+
+pub const libc = @import("libc.zig");
+pub const glue = @import("hosted_glue.zig");
+pub const port = @import("port.zig");
+pub const heap = @import("heap.zig");
+pub const os = @import("hosted_os.zig");
+pub const ip = @import("ip.zig");
+/// The station data path. Separate from `init` on purpose: bringing the transport up and putting an
+/// IP stack on the station interface are two decisions, and an application may want the first
+/// without the second (examples/radio.zig does). Call `link.open()` once `hosted_wifi_sta_start`
+/// has returned.
+pub const link = @import("link.zig");
+/// The std.Io implementation. A module rather than a path: Zig confines a module's imports to its
+/// own root directory, so src/net/ cannot reach ../io/ by file. build.zig wires it as `io`.
+pub const runtime = @import("io");
+
+comptime {
+ _ = libc;
+ _ = glue;
+ _ = port;
+ _ = heap;
+ _ = os;
+ _ = ip;
+ _ = link;
+ _ = runtime;
+}
+
+/// ESP-Hosted's transport entry point, from
+/// host/drivers/transport/transport_drv.h:131. Returns an `esp_err_t`, so 0 is success. The
+/// callback fires once the slave has answered and the transport has reached its "active" state,
+/// which is the moment the radio becomes usable.
+extern fn setup_transport(up_cb: ?*const fn () callconv(.c) void) c_int;
+
+/// Populate the transport configuration from Kconfig - SDIO slot, bus width, clock, the pin map and
+/// the C6 reset pin. From host/api/src/esp_hosted_transport_config.c:25.
+///
+/// This is not optional and skipping it does not fail loudly. `esp_hosted_sdio_get_config` hands
+/// back a pointer to static storage which starts out all zeroes, so a transport started without
+/// this reads slot 0, width 0, 0 kHz, every pin GPIO0 and queue sizes of zero. The first run of
+/// examples/radio.zig did exactly that: the only hint was ESP-Hosted warning "provided sdio tx queue
+/// size is zero! Setting to 20", and then nothing ever came up. ESP-Hosted's own esp_hosted_init
+/// calls this at esp_hosted_api.c:151; this file calls the same function for the same reason.
+extern fn esp_hosted_set_default_config() c_int;
+
+/// True if a configuration has already been set, so `init` can be called twice without clobbering
+/// a configuration an application deliberately overrode.
+extern fn esp_hosted_is_config_valid() bool;
+
+/// Attempt the connection to the coprocessor: release its reset, bring the SDIO card up, and run
+/// the capability handshake. From host/drivers/transport/transport_drv.h:133.
+///
+/// `setup_transport` does NOT do this - it only calls transport_drv_init (bus and threads) and
+/// stores the up-callback (transport_drv.c:170-177). ESP-Hosted's own API splits the two the same
+/// way: esp_hosted_init calls setup_transport, and esp_hosted_connect_to_slave calls this
+/// (esp_hosted_api.c:184). Calling only the first is a transport that exists and never speaks; that
+/// is exactly what the third run of examples/radio.zig showed - threads created, nothing allocated,
+/// no SDIO traffic, and silence for ten seconds.
+extern fn transport_drv_reconfigure() c_int;
+
+/// Bring the RPC layer up and register its event callbacks. From
+/// host/drivers/rpc/wrap/rpc_wrap.h:45,50.
+///
+/// Separate from the transport on purpose: the transport is the pipe, RPC is the language spoken
+/// over it. esp_hosted_init calls setup_transport and then these two (esp_hosted_api.c:154-156).
+/// Skipping them leaves a transport that is genuinely up and a control path that answers every
+/// request with "RPC not initialized or transport down, failing fast" - which is what the first
+/// Wi-Fi call on this board printed.
+///
+/// These must run BEFORE `transport_drv_reconfigure`, and the reason is a single line in
+/// ESP-Hosted: `rpc_core_init` ends with `set_rpc_lib_state(RPC_LIB_STATE_INIT)`
+/// (rpc_core.c:1164), and the *only* thing that ever raises that state to READY is `rpc_start`,
+/// called from `transport_delayed_init` (transport_drv.c:802) on the transport's own RX thread the
+/// moment the slave's INIT event is parsed. Call `rpc_init` after the transport is up and
+/// `rpc_core_init` stamps INIT over the READY that already happened, with no second writer: both
+/// `rpc_rx_thread` and `rpc_tx_thread` then sit in `if (!is_rpc_lib_ready()) _h_sleep(1)`
+/// (rpc_core.c:482-485, :543-547) forever. `rpc_send_req` still succeeds - it only enqueues
+/// (rpc_core.c:1019) - so every synchronous request is accepted, never transmitted, and returns
+/// "Timeout waiting for Resp" ten seconds later. That is exactly the Req_WifiInit failure.
+extern fn rpc_init() c_int;
+extern fn rpc_register_event_callbacks() c_int;
+
+/// Set once the C reports the transport up. Read through `isUp`.
+var transport_up: bool = false;
+
+fn onTransportUp() callconv(.c) void {
+ transport_up = true;
+}
+
+/// True once the C6 has answered and ESP-Hosted's transport has reached its active state.
+pub fn isUp() bool {
+ return transport_up;
+}
+
+pub const Error = error{ ConfigFailed, TransportSetupFailed, SlaveConnectFailed, RpcInitFailed };
+
+/// Bring the radio path up, in the one order that works.
+///
+/// Each step depends on the one before it:
+/// 1. the libc allocator must exist before ESP-Hosted allocates anything, and its very first act
+/// is to allocate,
+/// 2. the port table must be installed before the transport starts, because the transport reaches
+/// the SDIO bus, the clock and its own threads through that table,
+/// 3. the transport must be *set up* - bus, queues, threads - before RPC, because `rpc_core_init`
+/// opens a serial endpoint on it,
+/// 4. RPC must be initialised before the coprocessor is spoken to, because the handshake's own
+/// `rpc_start` is what takes the RPC lib from INIT to READY and `rpc_core_init` would
+/// overwrite it. See `rpc_init` above,
+/// 5. only then is the C6 reset released and the capability handshake run, through our driver.
+///
+/// Blocks for the handshake: step 5 is `transport_drv_reconfigure`, which polls the slave every
+/// 200 ms (transport_drv.c:219-234). It runs on whatever task calls this, so that task must not be
+/// the one printing progress.
+pub fn init(io_impl: std.Io, gpa: std.mem.Allocator) Error!void {
+ libc.install(gpa);
+ port.install(io_impl, gpa);
+ // The configuration must exist before the transport reads it. Only set defaults if an
+ // application has not already provided its own, which is the order esp_hosted_init uses.
+ if (!esp_hosted_is_config_valid()) {
+ if (esp_hosted_set_default_config() != 0) return error.ConfigFailed;
+ }
+ if (setup_transport(&onTransportUp) != 0) return error.TransportSetupFailed;
+
+ // The control path, before the pipe is opened. This is ESP-Hosted's own order
+ // (esp_hosted_api.c:154-156 init, then esp_hosted_api.c:184 connect) and it is load-bearing,
+ // not cosmetic: see the comment on `rpc_init` above. With RPC initialised first, `rpc_start`
+ // from the handshake is the last writer of the RPC lib state, and the reader and writer threads
+ // leave their not-ready loop for good.
+ if (rpc_init() != 0) return error.RpcInitFailed;
+ if (rpc_register_event_callbacks() != 0) return error.RpcInitFailed;
+
+ // And now actually talk to the coprocessor. Blocks until the slave answers.
+ if (transport_drv_reconfigure() != 0) return error.SlaveConnectFailed;
+}
diff --git a/src/net/heap.zig b/src/net/heap.zig
new file mode 100644
index 0000000..73ebcf2
--- /dev/null
+++ b/src/net/heap.zig
@@ -0,0 +1,673 @@
+//! Two allocators, because ESP-Hosted needs two different things and `std` supplies neither.
+//!
+//! **`Heap`** is a general-purpose allocator over a caller-supplied static buffer, exposed through
+//! the ordinary `std.mem.Allocator` vtable. Writing one was not the first choice. `std.heap` was
+//! read first, and nothing in it fits this workload:
+//!
+//! * `FixedBufferAllocator` can only free the *most recent* allocation. ESP-Hosted frees
+//! per-packet buffers in whatever order the radio finishes with them.
+//! * `ArenaAllocator` cannot reclaim at all until the whole arena dies, and this arena never dies.
+//! * `BrkAllocator` (`std/heap/BrkAllocator.zig:38`) rounds its backing store to
+//! `@max(64 * 1024, page_size_max)` per *size class*. One 64 KiB page per class does not fit in
+//! a 128 KiB L2MEM, let alone the ~48 KiB this heap is meant to occupy.
+//! * `DebugAllocator` is page-granular and carries per-page metadata for a debugging feature set
+//! nothing here wants.
+//! * `SmpAllocator`, `PageAllocator`, `c_allocator` all need an OS.
+//!
+//! So `Heap` is a K&R-style allocator: one address-sorted free list, coalescing on free, first fit.
+//! The workload it is sized for is the one the parent measured - `mempool.c` recycling fixed-size
+//! buffers - which is exactly the pattern that keeps a coalescing free list short and its first fit
+//! O(1) in practice: freed blocks of one size are handed straight back out.
+//!
+//! **`CHeap`** is the part that has nothing to do with which allocator is underneath. C's `free`
+//! takes a bare pointer and no length, and `std.mem.Allocator.rawFree` requires both the exact
+//! length and the original alignment. `CHeap` recovers them from an eight-byte header stored
+//! immediately below every pointer it hands out. That header is the price of the C ABI and it is
+//! paid per allocation:
+//!
+//! _h_malloc(n) -> 8 bytes overhead
+//! _h_malloc_align(n, 64) -> 64 bytes overhead (the header plus the alignment slack)
+//!
+//! `CHeap` is written against `std.mem.Allocator`, not against `Heap`, so `install()` can be handed
+//! any allocator at all and the C side does not change.
+
+const std = @import("std");
+const assert = std.debug.assert;
+const Allocator = std.mem.Allocator;
+const Alignment = std.mem.Alignment;
+
+// ---------------------------------------------------------------------------------------- Heap
+
+/// A coalescing free-list allocator over one contiguous buffer.
+///
+/// Not thread-safe, and deliberately so: this runs under a cooperative single-core scheduler where
+/// no task can be preempted between two instructions, and every entry point here runs to completion
+/// without yielding. An interrupt handler must never allocate - see `port.zig`, which never does.
+pub const Heap = struct {
+ base: [*]align(granule) u8,
+ /// Arena length in bytes, always a multiple of `granule`.
+ len: u32,
+ /// Offset of the first free block's header, or `null_off`.
+ free_head: u32,
+
+ /// Every block header and every payload is 8-byte aligned. Eight is `_Alignof(max_align_t)` on
+ /// rv32 (`long long` and `double` are 8-byte aligned), which is the weakest guarantee C's
+ /// `malloc` is allowed to make, so it is also the strongest one a caller may assume.
+ pub const granule = 8;
+
+ /// Free blocks store their list link in the payload, so a block must hold a header plus one
+ /// link. Any split that would leave less than this is absorbed into the neighbour instead.
+ const min_block = @sizeOf(Block) + granule;
+ const null_off: u32 = std.math.maxInt(u32);
+
+ /// Header of every block, allocated or free.
+ ///
+ /// `next` is only meaningful while the block is on the free list; in an allocated block it
+ /// holds `alloc_magic`, which turns a double free or a wild pointer into an assertion instead
+ /// of a corrupted list.
+ const Block = extern struct {
+ /// Total bytes of this block *including* the header. Always a multiple of `granule`.
+ size: u32,
+ next: u32,
+
+ const alloc_magic: u32 = 0xA110_C8ED;
+ };
+
+ comptime {
+ assert(@sizeOf(Block) == granule);
+ assert(@alignOf(Block) <= granule);
+ }
+
+ /// Take ownership of `buffer`. The whole buffer becomes one free block; nothing else is stored
+ /// outside it, so the heap's own footprint is `@sizeOf(Heap)` (12 bytes) plus the buffer.
+ pub fn init(buffer: []align(granule) u8) Heap {
+ const usable: u32 = @intCast(buffer.len & ~@as(usize, granule - 1));
+ assert(usable >= min_block);
+ var h: Heap = .{ .base = buffer.ptr, .len = usable, .free_head = 0 };
+ const first = h.blockAt(0);
+ first.* = .{ .size = usable, .next = null_off };
+ return h;
+ }
+
+ pub fn allocator(h: *Heap) Allocator {
+ return .{ .ptr = h, .vtable = &.{
+ .alloc = alloc,
+ .resize = resize,
+ .remap = remap,
+ .free = freeFn,
+ } };
+ }
+
+ inline fn blockAt(h: *Heap, off: u32) *Block {
+ assert(off + @sizeOf(Block) <= h.len);
+ return @ptrCast(@alignCast(h.base + off));
+ }
+
+ inline fn payloadOf(h: *Heap, off: u32) [*]u8 {
+ return h.base + off + @sizeOf(Block);
+ }
+
+ fn alloc(ctx: *anyopaque, len: usize, alignment: Alignment, ret_addr: usize) ?[*]u8 {
+ _ = ret_addr;
+ const h: *Heap = @ptrCast(@alignCast(ctx));
+
+ // A zero-length allocation still needs a distinct address with a valid header, because it
+ // will be handed back to `free` with its length and must be findable.
+ const want: u32 = std.math.cast(u32, std.mem.alignForward(usize, @max(len, granule), granule)) orelse return null;
+ const a: u32 = @intCast(@max(granule, alignment.toByteUnits()));
+
+ var prev: u32 = null_off;
+ var cur: u32 = h.free_head;
+ while (cur != null_off) {
+ const blk = h.blockAt(cur);
+
+ // Where the payload would land if the block were used as-is, and how far it has to
+ // move to satisfy `a`. A gap smaller than `min_block` cannot become its own free
+ // block, so step one whole alignment further - the block was sized for that case.
+ const natural = @intFromPtr(h.payloadOf(cur));
+ var gap: u32 = @intCast(std.mem.alignForward(usize, natural, a) - natural);
+ if (gap != 0 and gap < min_block) gap += a;
+
+ const need = gap + @sizeOf(Block) + want;
+ if (blk.size < need) {
+ prev = cur;
+ cur = blk.next;
+ continue;
+ }
+
+ // The block that will be handed out starts `gap` bytes into the free block. When
+ // `gap` is zero that is the free block itself, which then leaves the list.
+ const alloc_off = cur + gap;
+ var alloc_size = blk.size - gap;
+ const tail_off = alloc_off + @sizeOf(Block) + want;
+ const tail_size = alloc_size - @sizeOf(Block) - want;
+
+ if (gap == 0) {
+ h.unlink(prev, cur);
+ } else {
+ // The leading gap stays a free block at the same address, so the list order is
+ // unchanged and no relinking is needed.
+ blk.size = gap;
+ }
+
+ if (tail_size >= min_block) {
+ alloc_size -= tail_size;
+ const tail = h.blockAt(tail_off);
+ tail.* = .{ .size = tail_size, .next = undefined };
+ h.insert(tail_off);
+ }
+
+ const out = h.blockAt(alloc_off);
+ out.* = .{ .size = alloc_size, .next = Block.alloc_magic };
+ const payload = h.payloadOf(alloc_off);
+ assert(@intFromPtr(payload) % a == 0);
+ return payload;
+ }
+ return null;
+ }
+
+ fn resize(ctx: *anyopaque, memory: []u8, alignment: Alignment, new_len: usize, ret_addr: usize) bool {
+ _ = alignment;
+ _ = ret_addr;
+ const h: *Heap = @ptrCast(@alignCast(ctx));
+ const off = h.offsetOfPayload(memory.ptr);
+ const blk = h.blockAt(off);
+ assert(blk.next == Block.alloc_magic);
+
+ const want: u32 = std.math.cast(u32, std.mem.alignForward(usize, @max(new_len, granule), granule)) orelse return false;
+ const have = blk.size - @sizeOf(Block);
+ if (want <= have) {
+ // Shrink: give the tail back if it is big enough to be a block of its own.
+ const tail_size = have - want;
+ if (tail_size >= min_block) {
+ blk.size -= tail_size;
+ const tail_off = off + @sizeOf(Block) + want;
+ h.blockAt(tail_off).* = .{ .size = tail_size, .next = undefined };
+ h.insert(tail_off);
+ }
+ return true;
+ }
+
+ // Grow in place only by swallowing the physically adjacent free block, which is the case
+ // that matters: `serial_ll_if.c:282` reassembles a fragmented RPC response by repeatedly
+ // reallocating the same buffer upward with nothing allocated after it.
+ const next_off = off + blk.size;
+ if (next_off >= h.len) return false;
+ const prev_link = h.findFreePredecessor(next_off) orelse return false;
+ const next = h.blockAt(next_off);
+ if (blk.size + next.size < @sizeOf(Block) + want) return false;
+
+ h.unlink(prev_link, next_off);
+ blk.size += next.size;
+ const tail_size = blk.size - @sizeOf(Block) - want;
+ if (tail_size >= min_block) {
+ blk.size -= tail_size;
+ const tail_off = off + @sizeOf(Block) + want;
+ h.blockAt(tail_off).* = .{ .size = tail_size, .next = undefined };
+ h.insert(tail_off);
+ }
+ return true;
+ }
+
+ fn remap(ctx: *anyopaque, memory: []u8, alignment: Alignment, new_len: usize, ret_addr: usize) ?[*]u8 {
+ // Relocation is never cheaper here than the caller's own alloc/copy/free, because this
+ // allocator cannot move a block without copying it either.
+ return if (resize(ctx, memory, alignment, new_len, ret_addr)) memory.ptr else null;
+ }
+
+ fn freeFn(ctx: *anyopaque, memory: []u8, alignment: Alignment, ret_addr: usize) void {
+ _ = alignment;
+ _ = ret_addr;
+ const h: *Heap = @ptrCast(@alignCast(ctx));
+ const off = h.offsetOfPayload(memory.ptr);
+ // A double free lands here with `next` already holding a list offset rather than the
+ // magic, and would otherwise splice the block into the free list twice.
+ assert(h.blockAt(off).next == Block.alloc_magic);
+ h.insert(off);
+ }
+
+ fn offsetOfPayload(h: *Heap, p: [*]u8) u32 {
+ const delta = @intFromPtr(p) - @intFromPtr(h.base);
+ assert(delta >= @sizeOf(Block) and delta < h.len);
+ return @intCast(delta - @sizeOf(Block));
+ }
+
+ /// Splice `off` out of the free list. `prev` is its predecessor, or `null_off` if it is head.
+ fn unlink(h: *Heap, prev: u32, off: u32) void {
+ const nxt = h.blockAt(off).next;
+ if (prev == null_off) h.free_head = nxt else h.blockAt(prev).next = nxt;
+ }
+
+ /// The free-list predecessor of `off`, or null if `off` is not on the free list at all.
+ /// `null_off` is returned when `off` is the head, mirroring `unlink`'s convention.
+ fn findFreePredecessor(h: *Heap, off: u32) ?u32 {
+ var prev: u32 = null_off;
+ var cur = h.free_head;
+ while (cur != null_off) : ({
+ prev = cur;
+ cur = h.blockAt(cur).next;
+ }) {
+ if (cur == off) return prev;
+ if (cur > off) return null;
+ }
+ return null;
+ }
+
+ /// Insert a block into the address-sorted free list, coalescing with either neighbour it
+ /// physically touches. Address order is what makes coalescing a pointer comparison rather than
+ /// a search, and it is why the list is sorted at all.
+ fn insert(h: *Heap, off: u32) void {
+ var prev: u32 = null_off;
+ var cur = h.free_head;
+ while (cur != null_off and cur < off) : ({
+ prev = cur;
+ cur = h.blockAt(cur).next;
+ }) {}
+
+ const blk = h.blockAt(off);
+ blk.next = cur;
+ if (prev == null_off) h.free_head = off else h.blockAt(prev).next = off;
+
+ if (cur != null_off and off + blk.size == cur) {
+ const nxt = h.blockAt(cur);
+ blk.size += nxt.size;
+ blk.next = nxt.next;
+ }
+ if (prev != null_off) {
+ const p = h.blockAt(prev);
+ if (prev + p.size == off) {
+ p.size += blk.size;
+ p.next = blk.next;
+ }
+ }
+ }
+
+ pub const Stats = struct {
+ /// Bytes in the arena, header overhead included.
+ total: u32,
+ /// Bytes on the free list, header overhead included.
+ free: u32,
+ /// Largest single free block, which is the largest allocation that can still succeed
+ /// (less one header, less alignment slack).
+ largest_free: u32,
+ free_blocks: u32,
+ };
+
+ pub fn stats(h: *Heap) Stats {
+ var s: Stats = .{ .total = h.len, .free = 0, .largest_free = 0, .free_blocks = 0 };
+ var cur = h.free_head;
+ while (cur != null_off) : (cur = h.blockAt(cur).next) {
+ const size = h.blockAt(cur).size;
+ s.free += size;
+ s.free_blocks += 1;
+ if (size > s.largest_free) s.largest_free = size;
+ }
+ return s;
+ }
+
+ /// Walk the free list and assert every invariant. Used by the tests; also usable from a
+ /// hardware self-test, where a corrupted list is otherwise invisible until it is fatal.
+ pub fn check(h: *Heap) void {
+ var cur = h.free_head;
+ var prev: u32 = null_off;
+ while (cur != null_off) {
+ const blk = h.blockAt(cur);
+ assert(blk.size >= min_block);
+ assert(blk.size % granule == 0);
+ assert(cur % granule == 0);
+ assert(cur + blk.size <= h.len);
+ if (prev != null_off) {
+ // Sorted, and never two free blocks that touch: `insert` would have merged them.
+ assert(prev < cur);
+ assert(prev + h.blockAt(prev).size < cur);
+ }
+ prev = cur;
+ cur = blk.next;
+ }
+ }
+};
+
+// --------------------------------------------------------------------------------------- CHeap
+
+/// C `malloc`/`free`/`realloc` semantics on top of any `std.mem.Allocator`.
+///
+/// The whole reason this type exists is that `free(p)` carries no size and `rawFree` demands one.
+/// Every pointer handed to C therefore has a `Header` in the eight bytes below it, holding what
+/// `rawFree` needs: the exact length that was allocated, and the distance back to the base pointer.
+///
+/// The alignment passed to the backing allocator is always `granule` (8). Stronger alignments are
+/// satisfied *inside* the allocation by over-allocating and moving the payload up, rather than by
+/// asking the backing allocator for them - which keeps the header immediately below the payload in
+/// every case, and means `Heap` only ever sees one alignment.
+pub const CHeap = struct {
+ gpa: Allocator,
+
+ /// Live bytes as seen by C, i.e. what was asked for, not what was consumed. `bytes_reserved`
+ /// is the honest number.
+ bytes_live: usize = 0,
+ bytes_reserved: usize = 0,
+ peak_reserved: usize = 0,
+ blocks_live: usize = 0,
+ /// Allocations that returned NULL. Nonzero means the heap is too small; ESP-Hosted logs and
+ /// limps on rather than failing loudly, so this counter is the only durable evidence.
+ failures: usize = 0,
+
+ pub const granule = Heap.granule;
+
+ const Header = extern struct {
+ /// Bytes passed to `rawAlloc`, and therefore the length `rawFree` must be given.
+ total: u32,
+ /// `payload - base`. Between `granule` and the requested alignment, inclusive.
+ offset: u16,
+ /// `log2` of the alignment C asked for. Kept for `realloc`, which must preserve it.
+ log2_align: u8,
+ magic: u8,
+
+ const value: u8 = 0x48; // 'H'
+ };
+
+ comptime {
+ assert(@sizeOf(Header) == granule);
+ assert(@alignOf(Header) <= granule);
+ }
+
+ /// The strongest alignment expressible in `Header.offset`. ESP-Hosted asks for at most 64
+ /// (`HOSTED_MEM_ALIGNMENT_64`, port_esp_hosted_host_os.h:95).
+ pub const max_alignment = 1 << 15;
+
+ pub fn malloc(c: *CHeap, size: usize) ?[*]u8 {
+ return c.mallocAligned(size, granule);
+ }
+
+ /// `size` is rounded up to a multiple of `alignment` before allocating, which is what
+ /// `heap_caps_aligned_alloc` does and therefore what `_h_malloc_align`'s callers get today.
+ /// It matters for DMA: a buffer whose *end* is not aligned shares its last cache line with
+ /// whatever follows it.
+ pub fn mallocAligned(c: *CHeap, size: usize, alignment: usize) ?[*]u8 {
+ assert(std.math.isPowerOfTwo(alignment));
+ assert(alignment <= max_alignment);
+ const a = @max(granule, alignment);
+
+ const payload = std.mem.alignForward(usize, @max(size, 1), a);
+ // `a` bytes of slack is always enough: the base is `granule`-aligned, the header needs
+ // `granule` of that slack, and moving up to the next `a` boundary costs at most `a -
+ // granule` more.
+ const total = std.math.add(usize, payload, a) catch {
+ c.failures += 1;
+ return null;
+ };
+
+ const base = c.gpa.rawAlloc(total, .fromByteUnits(granule), @returnAddress()) orelse {
+ c.failures += 1;
+ return null;
+ };
+ const user_addr = std.mem.alignForward(usize, @intFromPtr(base) + @sizeOf(Header), a);
+ const offset = user_addr - @intFromPtr(base);
+ assert(offset >= @sizeOf(Header) and offset <= a);
+ assert(offset + payload <= total);
+
+ const user: [*]u8 = @ptrFromInt(user_addr);
+ headerOf(user).* = .{
+ .total = @intCast(total),
+ .offset = @intCast(offset),
+ .log2_align = @intCast(std.math.log2_int(usize, a)),
+ .magic = Header.value,
+ };
+
+ c.bytes_live += size;
+ c.bytes_reserved += total;
+ c.blocks_live += 1;
+ if (c.bytes_reserved > c.peak_reserved) c.peak_reserved = c.bytes_reserved;
+ return user;
+ }
+
+ pub fn calloc(c: *CHeap, count: usize, size: usize) ?[*]u8 {
+ const n = std.math.mul(usize, count, size) catch {
+ c.failures += 1;
+ return null;
+ };
+ const p = c.malloc(n) orelse return null;
+ @memset(p[0..n], 0);
+ return p;
+ }
+
+ pub fn free(c: *CHeap, ptr: ?[*]u8) void {
+ const user = ptr orelse return;
+ const h = headerOf(user).*;
+ assert(h.magic == Header.value);
+ const base: [*]u8 = @ptrFromInt(@intFromPtr(user) - h.offset);
+ // Poison the magic so a second free asserts here rather than corrupting the backing
+ // allocator's own bookkeeping several calls later.
+ headerOf(user).magic = 0;
+
+ c.bytes_live -|= usableLen(h);
+ c.bytes_reserved -= h.total;
+ c.blocks_live -= 1;
+ c.gpa.rawFree(base[0..h.total], .fromByteUnits(granule), @returnAddress());
+ }
+
+ /// C `realloc`: null pointer means allocate, zero size means free, and the old contents are
+ /// preserved up to the smaller of the two sizes.
+ ///
+ /// Growth in place is attempted first. `serial_ll_if.c:282` reassembles a fragmented RPC
+ /// response by calling this in a loop on the same buffer, so a `realloc` that always copies
+ /// turns an n-fragment response into O(n^2) bytes moved.
+ pub fn realloc(c: *CHeap, ptr: ?[*]u8, new_size: usize) ?[*]u8 {
+ const user = ptr orelse return c.malloc(new_size);
+ if (new_size == 0) {
+ c.free(user);
+ return null;
+ }
+
+ const h = headerOf(user).*;
+ assert(h.magic == Header.value);
+ const a = @as(usize, 1) << @intCast(h.log2_align);
+ const old_usable = usableLen(h);
+ if (new_size <= old_usable) return user;
+
+ const base: [*]u8 = @ptrFromInt(@intFromPtr(user) - h.offset);
+ const new_total = std.math.add(usize, std.mem.alignForward(usize, new_size, a), a) catch {
+ c.failures += 1;
+ return null;
+ };
+ if (c.gpa.rawResize(base[0..h.total], .fromByteUnits(granule), new_total, @returnAddress())) {
+ c.bytes_live += new_size - old_usable;
+ c.bytes_reserved += new_total - h.total;
+ if (c.bytes_reserved > c.peak_reserved) c.peak_reserved = c.bytes_reserved;
+ headerOf(user).total = @intCast(new_total);
+ return user;
+ }
+
+ const fresh = c.mallocAligned(new_size, a) orelse return null;
+ @memcpy(fresh[0..old_usable], user[0..old_usable]);
+ c.free(user);
+ return fresh;
+ }
+
+ /// Bytes the caller may legitimately touch. Larger than what was asked for whenever the
+ /// request was rounded up to the alignment.
+ fn usableLen(h: Header) usize {
+ return h.total - h.offset;
+ }
+
+ inline fn headerOf(user: [*]u8) *Header {
+ return @ptrFromInt(@intFromPtr(user) - @sizeOf(Header));
+ }
+};
+
+// ---------------------------------------------------------------------------------------- tests
+
+const testing = std.testing;
+
+fn testHeap(comptime bytes: usize) struct { buf: []align(Heap.granule) u8, heap: Heap } {
+ const buf = testing.allocator.alignedAlloc(u8, .fromByteUnits(Heap.granule), bytes) catch unreachable;
+ return .{ .buf = buf, .heap = Heap.init(buf) };
+}
+
+test "Heap: alloc, free, and reuse of a hole in the middle" {
+ var t = testHeap(4096);
+ defer testing.allocator.free(t.buf);
+ const a = t.heap.allocator();
+
+ const p0 = try a.alloc(u8, 64);
+ const p1 = try a.alloc(u8, 64);
+ const p2 = try a.alloc(u8, 64);
+ t.heap.check();
+
+ // Free the middle one. An arena or a FixedBufferAllocator cannot give this back; the whole
+ // point of this allocator is that the next 64-byte request lands right here.
+ a.free(p1);
+ t.heap.check();
+ const p3 = try a.alloc(u8, 64);
+ try testing.expectEqual(p1.ptr, p3.ptr);
+
+ a.free(p0);
+ a.free(p2);
+ a.free(p3);
+ t.heap.check();
+ // Everything coalesced back into one block.
+ const s = t.heap.stats();
+ try testing.expectEqual(@as(u32, 1), s.free_blocks);
+ try testing.expectEqual(s.total, s.free);
+}
+
+test "Heap: the malloc/free/realloc churn that defeats an arena" {
+ var t = testHeap(16 * 1024);
+ defer testing.allocator.free(t.buf);
+ const a = t.heap.allocator();
+
+ // mempool.c's pattern: allocate a batch of same-size buffers, release them in a scrambled
+ // order, allocate the same batch again. An arena's high-water mark would double each round;
+ // this must not grow at all.
+ var live: [16][]u8 = undefined;
+ const order = [_]usize{ 7, 0, 15, 3, 11, 1, 9, 4, 13, 2, 8, 6, 14, 5, 12, 10 };
+
+ for (&live) |*slot| slot.* = try a.alloc(u8, 200);
+ const after_first_round = t.heap.stats().free;
+
+ for (0..8) |_| {
+ for (order) |i| a.free(live[i]);
+ t.heap.check();
+ for (&live) |*slot| slot.* = try a.alloc(u8, 200);
+ t.heap.check();
+ try testing.expectEqual(after_first_round, t.heap.stats().free);
+ }
+ for (live) |slot| a.free(slot);
+
+ // Interleave reallocs that grow past their block, which is the serial reassembly path.
+ var grow = try a.alloc(u8, 32);
+ @memset(grow, 0xAB);
+ var n: usize = 64;
+ while (n <= 2048) : (n *= 2) {
+ const old_len = grow.len;
+ grow = try a.realloc(grow, n);
+ try testing.expect(std.mem.allEqual(u8, grow[0..old_len], 0xAB));
+ @memset(grow[old_len..], 0xAB);
+ t.heap.check();
+ }
+ a.free(grow);
+ t.heap.check();
+ try testing.expectEqual(t.heap.stats().total, t.heap.stats().free);
+}
+
+test "Heap: strong alignment splits the leading gap back into the free list" {
+ var t = testHeap(8192);
+ defer testing.allocator.free(t.buf);
+ const a = t.heap.allocator();
+
+ // 64-byte alignment is what _h_malloc_align asks for on the SDIO data path.
+ var blocks: [8][]align(64) u8 = undefined;
+ for (&blocks, 0..) |*b, i| {
+ b.* = try a.alignedAlloc(u8, .@"64", 100 + i);
+ try testing.expectEqual(@as(usize, 0), @intFromPtr(b.ptr) % 64);
+ }
+ t.heap.check();
+ for (blocks) |b| a.free(b);
+ t.heap.check();
+ try testing.expectEqual(t.heap.stats().total, t.heap.stats().free);
+}
+
+test "Heap: exhaustion returns null rather than trampling the arena" {
+ var t = testHeap(1024);
+ defer testing.allocator.free(t.buf);
+ const a = t.heap.allocator();
+
+ var held: [64][]u8 = undefined;
+ var n: usize = 0;
+ while (n < held.len) : (n += 1) {
+ held[n] = a.alloc(u8, 64) catch break;
+ }
+ try testing.expect(n > 0 and n < held.len);
+ try testing.expectError(error.OutOfMemory, a.alloc(u8, 64));
+ t.heap.check();
+ for (held[0..n]) |b| a.free(b);
+ t.heap.check();
+ try testing.expectEqual(t.heap.stats().total, t.heap.stats().free);
+}
+
+test "CHeap: malloc/free/realloc against the C ABI, over the Heap" {
+ var t = testHeap(16 * 1024);
+ defer testing.allocator.free(t.buf);
+ var c: CHeap = .{ .gpa = t.heap.allocator() };
+
+ const p = c.malloc(100).?;
+ @memset(p[0..100], 0x5A);
+ try testing.expectEqual(@as(usize, 0), @intFromPtr(p) % CHeap.granule);
+ try testing.expectEqual(@as(usize, 1), c.blocks_live);
+
+ // realloc must preserve contents across a move.
+ const q = c.realloc(p, 4000).?;
+ try testing.expect(std.mem.allEqual(u8, q[0..100], 0x5A));
+ // Shrinking inside the same block returns the same pointer, as C permits.
+ try testing.expectEqual(q, c.realloc(q, 8).?);
+ c.free(q);
+ try testing.expectEqual(@as(usize, 0), c.blocks_live);
+ try testing.expectEqual(@as(usize, 0), c.bytes_reserved);
+
+ // calloc zeroes.
+ const z = c.calloc(10, 16).?;
+ try testing.expect(std.mem.allEqual(u8, z[0..160], 0));
+ c.free(z);
+
+ // free(NULL) is a no-op, and realloc(NULL, n) is malloc.
+ c.free(null);
+ const r = c.realloc(null, 32).?;
+ // realloc(p, 0) frees and yields NULL.
+ try testing.expectEqual(@as(?[*]u8, null), c.realloc(r, 0));
+ try testing.expectEqual(@as(usize, 0), c.blocks_live);
+
+ t.heap.check();
+ try testing.expectEqual(t.heap.stats().total, t.heap.stats().free);
+}
+
+test "CHeap: _h_malloc_align(n, 64) is 64-aligned at both ends and frees exactly" {
+ // 12 x (1536 rounded to 64, plus 64 of header and slack) = 19,200 bytes, plus block headers.
+ var t = testHeap(24 * 1024);
+ defer testing.allocator.free(t.buf);
+ var c: CHeap = .{ .gpa = t.heap.allocator() };
+
+ var held: [12][*]u8 = undefined;
+ for (&held, 0..) |*slot, i| {
+ slot.* = c.mallocAligned(1536 - i, 64).?;
+ try testing.expectEqual(@as(usize, 0), @intFromPtr(slot.*) % 64);
+ }
+ // 64-byte alignment costs exactly 64 bytes of overhead per buffer: the eight-byte header plus
+ // the slack that moves the payload onto the boundary.
+ try testing.expectEqual(@as(usize, 12), c.blocks_live);
+ for (held) |slot| c.free(slot);
+ try testing.expectEqual(@as(usize, 0), c.bytes_reserved);
+ t.heap.check();
+ try testing.expectEqual(t.heap.stats().total, t.heap.stats().free);
+}
+
+test "CHeap: allocation failure is reported, not fatal" {
+ var t = testHeap(1024);
+ defer testing.allocator.free(t.buf);
+ var c: CHeap = .{ .gpa = t.heap.allocator() };
+
+ try testing.expectEqual(@as(?[*]u8, null), c.malloc(100_000));
+ try testing.expectEqual(@as(usize, 1), c.failures);
+ // The heap is untouched by the failure.
+ t.heap.check();
+ try testing.expectEqual(t.heap.stats().total, t.heap.stats().free);
+}
diff --git a/src/net/hosted/abi_assert.c b/src/net/hosted/abi_assert.c
new file mode 100644
index 0000000..8815e89
--- /dev/null
+++ b/src/net/hosted/abi_assert.c
@@ -0,0 +1,78 @@
+/*
+ * The one ABI check that stands between this build and a silent hang.
+ *
+ * `hosted_osi_funcs_t` (host/esp_hosted_os_abstraction.h) is the function-pointer table ESP-Hosted
+ * reaches everything through - memory, sync, threads, GPIO, SDIO. src/net/port.zig defines it in
+ * Zig. Zig can assert its own layout; it cannot assert C's. This file asserts C's, so the two are
+ * checked against each other at build time.
+ *
+ * Why this is not paranoia. Four of the table's entries are guarded:
+ *
+ * #ifdef H_USE_MEMPOOL <- host/esp_hosted_os_abstraction.h:64-69
+ * void *(*_h_get_mempool)(...);
+ * ...
+ * #endif
+ *
+ * `#ifdef`, not `#if`. H_USE_MEMPOOL is defined by
+ * host/port/esp/freertos/include/port_esp_hosted_host_config.h:127-131 - to 1 or to 0, but always
+ * DEFINED. So the four pointers are present in any translation unit that saw that header, and
+ * absent in any that did not, and every entry after them shifts by four pointers.
+ *
+ * That is reachable, not theoretical: host/esp_hosted.h:14 and
+ * host/drivers/transport/transport_util.h:10 both include esp_hosted_os_abstraction.h as their
+ * FIRST include, so a TU reaching the struct through either of those - before any port header -
+ * gets the short layout. Under IDF's CMake the ordering happens to work out. Under our flags it
+ * would be luck.
+ *
+ * Measured with our exact flags, both ways:
+ *
+ * sizeof _h_config_gpio _h_event_post
+ * without the force-include 268 132 264
+ * with the force-include 284 148 280
+ *
+ * Sixteen bytes. A TU with the short layout calling _h_config_gpio jumps through a mempool
+ * pointer instead - which is a jump to the wrong function, on a board with no debugger, and the
+ * symptom would look exactly like the SDIO bus failing to come up.
+ *
+ * build.zig therefore force-includes port_esp_hosted_host_config.h into every ESP-Hosted
+ * translation unit, and compiles this file to assert that it worked. The numbers below are the
+ * long (correct) layout.
+ */
+
+#include "esp_hosted_os_abstraction.h"
+#include <stddef.h>
+
+/* The guard must be visible here, or this file is asserting the wrong layout and proving nothing. */
+#ifndef H_USE_MEMPOOL
+#error "H_USE_MEMPOOL is not visible: the force-include of port_esp_hosted_host_config.h is missing."
+#endif
+
+_Static_assert(
+ sizeof(hosted_osi_funcs_t) == 284,
+ "hosted_osi_funcs_t is not the 284-byte layout. Either the force-include of "
+ "port_esp_hosted_host_config.h was lost (short layout, 268), or ESP-Hosted changed the table. "
+ "Compare against the struct in src/net/port.zig before touching this number.");
+
+/* Two offsets, chosen because they sit on either side of the mempool block: the first entry after
+ * it, and one near the end. If the block appears or disappears, both move. */
+_Static_assert(
+ offsetof(hosted_osi_funcs_t, _h_config_gpio) == 148,
+ "_h_config_gpio moved. It is the first entry after the #ifdef H_USE_MEMPOOL block, so this is "
+ "what the short layout breaks first: 132 instead of 148.");
+
+_Static_assert(
+ offsetof(hosted_osi_funcs_t, _h_event_post) == 280,
+ "_h_event_post moved. Together with the _h_config_gpio assertion this pins both ends of the "
+ "table.");
+
+/* Field count, checked through the size. src/net/port.zig asserts its Zig struct has 71 fields;
+ * every entry is a pointer, so 71 * 4 must be the size on this 32-bit target. A size check alone
+ * would not catch a field deleted in one place and duplicated in another - the length survives and
+ * the two sides silently disagree about which pointer is which. */
+_Static_assert(
+ sizeof(hosted_osi_funcs_t) == 71 * sizeof(void (*)(void)),
+ "hosted_osi_funcs_t is not 71 function pointers. Compare field by field against the struct in "
+ "src/net/port.zig - a count mismatch means one side has an entry the other does not, and every "
+ "entry after it calls the wrong function.");
+
+const int esp_hosted_abi_assertions_hold = 1;
diff --git a/src/net/hosted/include_dirs.txt b/src/net/hosted/include_dirs.txt
new file mode 100644
index 0000000..75ee2d3
--- /dev/null
+++ b/src/net/hosted/include_dirs.txt
@@ -0,0 +1,171 @@
+# Include directories ESP-Hosted's C needs, in order.
+#
+# Provenance: extracted from the -I flags ESP-IDF v6.0.2 used to compile
+# host/drivers/transport/transport_drv.c in 02-esp32p4-m3-radio/build/compile_commands.json -
+# the build that worked on this board. Order is IDF's and matters: esp_wifi_remote's
+# idf_v6.0/include/injected must precede components/esp_wifi/include, because a host with no
+# radio needs the injected Wi-Fi types rather than the real ones.
+#
+# IDF/ -> relative to the ESP-IDF checkout
+# MC/ -> relative to 02-esp32p4-m3-radio (the managed_components tree)
+#
+# The one path dropped from IDF's list is build/config, the generated Kconfig header. This
+# project supplies its own copy as src/net/hosted/sdkconfig.h.
+MC/managed_components/espressif__esp_hosted/host
+MC/managed_components/espressif__esp_hosted/host/api/include
+MC/managed_components/espressif__esp_hosted/host/drivers/transport
+MC/managed_components/espressif__esp_hosted/host/drivers/transport/spi
+MC/managed_components/espressif__esp_hosted/host/drivers/transport/sdio
+MC/managed_components/espressif__esp_hosted/host/drivers/serial
+MC/managed_components/espressif__esp_hosted/host/utils
+MC/managed_components/espressif__esp_hosted/host/api/priv
+MC/managed_components/espressif__esp_hosted/host/drivers/rpc/core
+MC/managed_components/espressif__esp_hosted/host/drivers/rpc/slaveif
+MC/managed_components/espressif__esp_hosted/host/drivers/rpc/wrap
+MC/managed_components/espressif__esp_hosted/host/drivers/virtual_serial_if
+MC/managed_components/espressif__esp_hosted/common
+MC/managed_components/espressif__esp_hosted/common/log
+MC/managed_components/espressif__esp_hosted/common/rpc
+MC/managed_components/espressif__esp_hosted/common/transport
+MC/managed_components/espressif__esp_hosted/common/protobuf-c
+MC/managed_components/espressif__esp_hosted/common/proto
+MC/managed_components/espressif__esp_hosted/common/mempool/include
+MC/managed_components/espressif__esp_hosted/common/utils
+MC/managed_components/espressif__esp_hosted/host/drivers/bt
+MC/managed_components/espressif__esp_hosted/host/drivers/power_save
+MC/managed_components/espressif__esp_hosted/host/port/esp/freertos/include
+IDF/components/esp_libc/platform_include
+IDF/components/freertos/config/include
+IDF/components/freertos/config/include/freertos
+IDF/components/freertos/config/riscv/include
+IDF/components/freertos/FreeRTOS-Kernel/include
+IDF/components/freertos/FreeRTOS-Kernel/portable/riscv/include
+IDF/components/freertos/FreeRTOS-Kernel/portable/riscv/include/freertos
+IDF/components/freertos/esp_additions/include
+IDF/components/esp_hw_support/include
+IDF/components/esp_hw_support/include/soc
+IDF/components/esp_hw_support/ldo/include
+IDF/components/esp_hw_support/debug_probe/include
+IDF/components/esp_hw_support/etm/include
+IDF/components/esp_hw_support/mspi_timing_tuning/include
+IDF/components/esp_hw_support/mspi_timing_tuning/tuning_scheme_impl/include
+IDF/components/esp_hw_support/power_supply/include
+IDF/components/esp_hw_support/modem/include
+IDF/components/esp_hw_support/port/esp32p4/.
+IDF/components/esp_hw_support/port/esp32p4/include
+IDF/components/esp_hw_support/port/esp32p4/private_include
+IDF/components/esp_hw_support/mspi_timing_tuning/port/esp32p4/.
+IDF/components/heap/include
+IDF/components/heap/tlsf
+IDF/components/log/include
+IDF/components/soc/include
+IDF/components/soc/esp32p4
+IDF/components/soc/esp32p4/include
+IDF/components/soc/esp32p4/register/hw_ver1
+IDF/components/hal/platform_port/include
+IDF/components/hal/esp32p4/include
+IDF/components/hal/include
+IDF/components/esp_rom/include
+IDF/components/esp_rom/esp32p4/include
+IDF/components/esp_rom/esp32p4/include/esp32p4
+IDF/components/esp_rom/esp32p4
+IDF/components/esp_common/include
+IDF/components/esp_system/include
+IDF/components/esp_system/port/soc
+IDF/components/esp_system/port/include/riscv
+IDF/components/esp_system/port/include/private
+IDF/components/esp_stdio/include
+IDF/components/riscv/include
+IDF/components/esp_hal_gpio/include
+IDF/components/esp_hal_gpio/esp32p4/include
+IDF/components/esp_hal_usb/include
+IDF/components/esp_hal_usb/esp32p4/include
+IDF/components/esp_hal_pmu/include
+IDF/components/esp_hal_pmu/esp32p4/include
+IDF/components/esp_hal_ana_conv/include
+IDF/components/esp_hal_ana_conv/esp32p4/include
+IDF/components/esp_hal_dma/include
+IDF/components/esp_hal_dma/esp32p4/include
+IDF/components/lwip/include
+IDF/components/lwip/include/apps
+IDF/components/lwip/lwip/src/include
+IDF/components/lwip/port/include
+IDF/components/lwip/port/freertos/include
+IDF/components/lwip/port/esp32xx/include
+IDF/components/lwip/port/esp32xx/include/arch
+IDF/components/lwip/port/esp32xx/include/sys
+IDF/components/esp_driver_sdmmc/include
+IDF/components/esp_driver_sdmmc/legacy/include
+IDF/components/esp_driver_sd_intf/include
+IDF/components/sdmmc/include
+IDF/components/esp_blockdev/include
+IDF/components/esp_hal_sd/include
+IDF/components/esp_hal_sd/esp32p4/include
+IDF/components/esp_driver_spi/include
+IDF/components/esp_pm/include
+IDF/components/esp_hal_gpspi/include
+IDF/components/esp_hal_gpspi/esp32p4/include
+IDF/components/esp_driver_dma/include
+IDF/components/esp_driver_uart/include
+IDF/components/esp_hal_uart/include
+IDF/components/esp_hal_uart/esp32p4/include
+IDF/components/vfs/include
+IDF/components/esp_driver_gpio/include
+IDF/components/esp_event/include
+IDF/components/esp_netif/include
+IDF/components/esp_timer/include
+IDF/components/driver/i2c/include
+IDF/components/driver/touch_sensor/include
+IDF/components/driver/twai/include
+IDF/components/esp_hal_i2c/esp32p4/include
+IDF/components/esp_hal_i2c/include
+IDF/components/esp_hal_twai/include
+IDF/components/esp_hal_twai/esp32p4/include
+IDF/components/esp_hal_touch_sens/esp32p4/include
+IDF/components/esp_hal_touch_sens/include
+MC/managed_components/espressif__esp_wifi_remote/idf_v6.0/include/injected
+IDF/components/esp_wifi/include
+IDF/components/esp_wifi/wifi_apps/nan_app/include
+MC/managed_components/espressif__esp_wifi_remote/include
+MC/managed_components/espressif__esp_wifi_remote/idf_v6.0/include
+IDF/components/bt/common/osi/include
+IDF/components/bt/common/api/include/api
+IDF/components/bt/common/btc/profile/esp/blufi/include
+IDF/components/bt/common/btc/profile/esp/include
+IDF/components/bt/common/hci_log/include
+IDF/components/bt/common/ble_log/include
+IDF/components/bt/common/ble_log/deprecated/include
+IDF/components/bt/common/tinycrypt/include
+IDF/components/bt/common/tinycrypt/port
+IDF/components/bt/host/nimble/nimble/nimble/host/include
+IDF/components/bt/host/nimble/nimble/nimble/include
+IDF/components/bt/host/nimble/nimble/nimble/host/services/ans/include
+IDF/components/bt/host/nimble/nimble/nimble/host/services/bas/include
+IDF/components/bt/host/nimble/nimble/nimble/host/services/dis/include
+IDF/components/bt/host/nimble/nimble/nimble/host/services/gap/include
+IDF/components/bt/host/nimble/nimble/nimble/host/services/gatt/include
+IDF/components/bt/host/nimble/nimble/nimble/host/services/hr/include
+IDF/components/bt/host/nimble/nimble/nimble/host/services/htp/include
+IDF/components/bt/host/nimble/nimble/nimble/host/services/ias/include
+IDF/components/bt/host/nimble/nimble/nimble/host/services/ipss/include
+IDF/components/bt/host/nimble/nimble/nimble/host/services/lls/include
+IDF/components/bt/host/nimble/nimble/nimble/host/services/prox/include
+IDF/components/bt/host/nimble/nimble/nimble/host/services/cts/include
+IDF/components/bt/host/nimble/nimble/nimble/host/services/tps/include
+IDF/components/bt/host/nimble/nimble/nimble/host/services/hid/include
+IDF/components/bt/host/nimble/nimble/nimble/host/services/sps/include
+IDF/components/bt/host/nimble/nimble/nimble/host/services/cte/include
+IDF/components/bt/host/nimble/nimble/nimble/host/util/include
+IDF/components/bt/host/nimble/nimble/nimble/host/store/ram/include
+IDF/components/bt/host/nimble/nimble/nimble/host/store/config/include
+IDF/components/bt/host/nimble/nimble/nimble/host/services/ras/include
+IDF/components/bt/host/nimble/nimble/porting/nimble/include
+IDF/components/bt/host/nimble/port/include
+IDF/components/bt/host/nimble/nimble/nimble/transport/include
+IDF/components/bt/host/nimble/nimble/nimble/transport/common/hci_h4/include
+IDF/components/bt/porting/include
+IDF/components/bt/host/nimble/nimble/porting/npl/freertos/include
+IDF/components/esp_http_client/include
+IDF/components/console
+IDF/components/wpa_supplicant/esp_supplicant/include
+IDF/components/esp_driver_usb_serial_jtag/include
diff --git a/src/net/hosted/pin_assert.c b/src/net/hosted/pin_assert.c
new file mode 100644
index 0000000..8704c7b
--- /dev/null
+++ b/src/net/hosted/pin_assert.c
@@ -0,0 +1,56 @@
+/*
+ * The board, asserted at compile time.
+ *
+ * ESP-Hosted derives its SDIO pin map, bus width, clock and the C6 reset pin from Kconfig, and this
+ * project checks in that Kconfig verbatim as src/net/hosted/sdkconfig.h. That makes the wiring a
+ * build input rather than something written in Zig - which is the right choice, because the real
+ * `struct esp_hosted_sdio_config` interleaves `gpio_pin_t {void *port; int pin;}` pairs and
+ * transcribing it into Zig invites a silent wrong-pin bug.
+ *
+ * The cost of that choice is that the wiring is now several files away from the board. This file
+ * pays it back: every value is asserted against what was measured on the die. If a Kconfig symbol
+ * ever drifts, the build fails naming the pin, instead of the C6 quietly never answering - which
+ * is the same symptom as a dead radio, a wrong clock, or a held reset, and takes an afternoon to
+ * tell apart.
+ *
+ * Measurements: Guition JC-ESP32P4-M3-DEV schematic sheet 5 (schematics/5_ESP32-C6.png in the
+ * unofficial board repo), confirmed against the boot log of the ESP-IDF build in
+ * 02-esp32p4-m3-radio that reached esp_hosted transport state "active" on this die.
+ */
+
+#include "port_esp_hosted_host_config.h"
+
+/* SDIO slot and bus. Slot 1 is the only one routed to the C6 on this board. */
+_Static_assert(H_SDMMC_HOST_SLOT == 1, "SDIO slot: board routes the C6 to slot 1");
+_Static_assert(H_SDIO_BUS_WIDTH == 4, "SDIO bus width: all four data lines are wired");
+_Static_assert(H_SDIO_CLOCK_FREQ_KHZ == 40000, "SDIO clock: 40 MHz was measured working");
+
+/* Pin map. D1 doubles as the slave interrupt line, which is why it must be a real data pin and
+ * not left unconfigured. */
+_Static_assert(H_SDIO_PIN_CLK == 18, "SDIO CLK is GPIO18");
+_Static_assert(H_SDIO_PIN_CMD == 19, "SDIO CMD is GPIO19");
+_Static_assert(H_SDIO_PIN_D0 == 14, "SDIO D0 is GPIO14");
+_Static_assert(H_SDIO_PIN_D1 == 15, "SDIO D1 is GPIO15, and doubles as the slave interrupt");
+_Static_assert(H_SDIO_PIN_D2 == 16, "SDIO D2 is GPIO16");
+_Static_assert(H_SDIO_PIN_D3 == 17, "SDIO D3 is GPIO17");
+
+/* The C6 reset. Active low with an external pull-up: it must be RELEASED, never driven high.
+ * Driving it the other way holds the radio in reset forever while looking like a config detail. */
+_Static_assert(H_GPIO_PIN_RESET == 54, "C6 reset is GPIO54");
+
+/* The coprocessor. H_SLAVE_TARGET_ESP32C6 is what
+ * host/port/esp/freertos/include/port_esp_hosted_host_config.h:65-95 derives from
+ * CONFIG_ESP_HOSTED_CP_TARGET_ESP32C6, and it gates wire-format decisions further up. */
+#ifndef H_SLAVE_TARGET_ESP32C6
+#error "Slave target is not ESP32-C6. The coprocessor on this board is an ESP32-C6-MINI."
+#endif
+
+/* H_USE_MEMPOOL must be DEFINED - its value is a real choice (see sdkconfig.h override 3), but
+ * whether the name exists at all is what decides the length of hosted_osi_funcs_t, because the
+ * struct guards four members with #ifdef. abi_assert.c checks the resulting size directly. */
+#ifndef H_USE_MEMPOOL
+#error "H_USE_MEMPOOL is not defined: the force-include of port_esp_hosted_host_config.h is missing."
+#endif
+
+/* A definition, so the translation unit is not empty. */
+const int esp_hosted_pin_assertions_hold = 1;
diff --git a/src/net/hosted/sdkconfig.h b/src/net/hosted/sdkconfig.h
new file mode 100644
index 0000000..63e77e9
--- /dev/null
+++ b/src/net/hosted/sdkconfig.h
@@ -0,0 +1,139 @@
+/*
+ * The Kconfig surface ESP-Hosted's C compiles against in this project.
+ *
+ * Two parts, deliberately separated:
+ *
+ * sdkconfig_idf.h ESP-IDF v6.0.2's generated header, verbatim, from the build in
+ * 02-esp32p4-m3-radio that reached transport state "active" on this die.
+ * Unedited, so its provenance is checkable.
+ *
+ * this file that header, plus a short list of overrides. Each one states what it changes
+ * and why, so the delta from the proven configuration is reviewable rather than
+ * buried in 1,400 generated lines.
+ *
+ * The generated header is used rather than a hand-picked subset because ESP-Hosted's headers derive
+ * struct layouts and the slave target from these symbols: CONFIG_ESP_HOSTED_CP_TARGET_ESP32C6 is
+ * what defines H_SLAVE_TARGET_ESP32C6, and CONFIG_ESP_HOSTED_USE_MEMPOOL is what decides the length
+ * of hosted_osi_funcs_t (see abi_assert.c). Hand-picking would be a second, unproven configuration.
+ *
+ * Nothing here starts a FreeRTOS kernel or an IDF component. The CONFIG_FREERTOS_* values only
+ * shape type declarations; src/net/port.zig and src/io/p4.zig supply the runtime.
+ */
+
+#pragma once
+
+#include "sdkconfig_idf.h"
+
+/* ------------------------------------------------------------------------------------------------
+ * Override 1: SDIO queue depths, 20 -> 4 each.
+ *
+ * IDF's build ran with 20 TX and 20 RX descriptors. That is a reasonable number when the heap is
+ * PSRAM-backed; here it is not. Forty in-flight buffers at MAX_SDIO_BUFFER_SIZE (1536 B) reserve
+ * ~60 KB before a single task stack exists, and this image has ~128 KB of L2MEM in total with
+ * nothing initialising the 32 MB of PSRAM.
+ *
+ * Four each was the first attempt and it was measured wrong. Once the board associated, the AP's
+ * ordinary broadcast traffic filled a four-deep queue immediately: the console filled with
+ * "task still writing Rx data to queue!", the receive counter froze at 8 frames, and the board
+ * stopped answering ARP - so it took a DHCP lease and then went silent, which looked like a bug in
+ * the IP stack rather than a queue two sizes too small.
+ *
+ * Sixteen each was then too many, for the reason that makes this setting awkward: with the mempool
+ * off (override 3) every frame is a fresh `_h_malloc_align(MAX_TRANSPORT_BUFFER_SIZE, 64)` from our
+ * heap, so the depths bound peak heap demand at (tx + rx) x 1536 bytes. At sixteen each that is
+ * ~49 KB of a 56 KB heap, and the board duly ran out: "mempool OOM start (RX)" at 11 s, then
+ * "STA TX: mempool_alloc failed, dropping pkt", after which nothing moved in either direction.
+ *
+ * Eight each: ~24.5 KB peak, against a heap sized well above it in examples/http.zig. Deep enough
+ * that ordinary broadcast traffic does not fill the queue between two `tick`s, shallow enough that a
+ * burst cannot exhaust the heap and stop the transmit path as collateral damage. That second
+ * property is the one worth protecting: a receive queue that overflows drops a frame, but a heap
+ * that empties takes the whole radio down.
+ * ---------------------------------------------------------------------------------------------- */
+#undef CONFIG_ESP_HOSTED_SDIO_TX_Q_SIZE
+#define CONFIG_ESP_HOSTED_SDIO_TX_Q_SIZE 8
+#undef CONFIG_ESP_HOSTED_SDIO_RX_Q_SIZE
+#define CONFIG_ESP_HOSTED_SDIO_RX_Q_SIZE 8
+
+/* These two are aliases the transport reads; they must follow the values above rather than the
+ * originals, or the queues and the descriptors disagree about their own depth. */
+#undef CONFIG_ESP_SDIO_TX_Q_SIZE
+#define CONFIG_ESP_SDIO_TX_Q_SIZE CONFIG_ESP_HOSTED_SDIO_TX_Q_SIZE
+#undef CONFIG_ESP_SDIO_RX_Q_SIZE
+#define CONFIG_ESP_SDIO_RX_Q_SIZE CONFIG_ESP_HOSTED_SDIO_RX_Q_SIZE
+
+/* ------------------------------------------------------------------------------------------------
+ * Override 2: Bluetooth off.
+ *
+ * The IDF build this configuration came from used BLE through the C6, so it enabled NimBLE and the
+ * VHCI transport. This project does not do Bluetooth, and leaving it on is not free: ESP-Hosted's
+ * transport calls hci_drv_init() unconditionally (transport_drv.c:126), and with NimBLE enabled that
+ * pulls in the real vhci_drv.c and the whole NimBLE host - ble_transport_*, os_mbuf_*,
+ * ble_hs_mbuf_to_flat - which is another stack this image has no reason to carry.
+ *
+ * With these off, ESP-Hosted's own host/drivers/bt/hci_stub_drv.c compiles to a no-op hci_drv_init
+ * and a drop-everything hci_rx_handler. That file is in the source list in build.zig, which is why
+ * this is a configuration change rather than a Zig stub: the C already ships the right answer for a
+ * host without Bluetooth, and using it keeps one fewer thing for us to get wrong.
+ *
+ * The C6 still reports HCI capability in its capability byte (0x0d on this board). That is the
+ * coprocessor saying what it can do, not a request; declining is the host's decision.
+ * ---------------------------------------------------------------------------------------------- */
+#undef CONFIG_ESP_HOSTED_ENABLE_BT_NIMBLE
+#undef CONFIG_ESP_HOSTED_NIMBLE_HCI_VHCI
+#undef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID
+#undef CONFIG_ESP_HOSTED_BLUEDROID_HCI_VHCI
+#undef CONFIG_BT_ENABLED
+#undef CONFIG_BT_NIMBLE_ENABLED
+
+/* ------------------------------------------------------------------------------------------------
+ * Override 3: mempool off.
+ *
+ * ESP-Hosted's mempool recycles fixed-size packet buffers instead of going to malloc each time. It
+ * needs a backend, supplied by `os_mempool_get_ops()`, and on ESP-IDF that comes from FreeRTOS's own
+ * pool implementation. This image has no FreeRTOS, and mempool.c treats a null ops table as a hard
+ * failure rather than a fallback (mempool.c:63-65, "hosted mempool init failed: no mempool ops") -
+ * which is what the second run of examples/radio.zig printed.
+ *
+ * The choice is to write a pool backend or to switch the optimisation off. Off, for now: the
+ * allocator behind _h_malloc (src/net/heap.zig) is a coalescing free list over a static buffer, so
+ * the same-size churn mempool exists to avoid is already cheap and cannot fragment the way a
+ * general-purpose heap would. If profiling later says otherwise, the backend is a small job and this
+ * is the one line to flip back.
+ *
+ * Layout note, because this looks dangerous and is not: turning this off does NOT change
+ * hosted_osi_funcs_t. port_esp_hosted_host_config.h:127-131 defines H_USE_MEMPOOL to 1 or to 0, and
+ * the struct's four mempool members are guarded by `#ifdef`, which only asks whether the name is
+ * defined. Both ways the struct is 284 bytes. src/net/hosted/abi_assert.c asserts that directly.
+ * ---------------------------------------------------------------------------------------------- */
+#undef CONFIG_ESP_HOSTED_USE_MEMPOOL
+#define CONFIG_ESP_HOSTED_USE_MEMPOOL 0
+
+/* ------------------------------------------------------------------------------------------------
+ * Override 4: the ESP-Hosted CLI off.
+ *
+ * A console command set for poking the transport at runtime. It needs IDF's `console` component -
+ * esp_console_cmd_register, a line editor, and a UART driver - none of which exists in this image,
+ * and none of which this project wants: the serial line here is a log, not a shell.
+ *
+ * `H_ESP_HOSTED_CLI_ENABLED` is an `#ifdef` on the *value* of this symbol being defined
+ * (transport_drv.c:804), so it must be #undef'd rather than defined to 0.
+ * ---------------------------------------------------------------------------------------------- */
+#undef CONFIG_ESP_HOSTED_CLI_ENABLED
+
+/* ------------------------------------------------------------------------------------------------
+ * Override 5: compile DEBUG-level logging in.
+ *
+ * The generated configuration stops at CONFIG_LOG_MAXIMUM_LEVEL 3 (INFO), which compiles ESP_LOGD
+ * away entirely. That hides exactly the lines needed to tell a stalled receive path apart from a
+ * silent slave: sdio_drv.c:1190 logs "--- Wait for SDIO intr ---" at DEBUG on every pass of
+ * sdio_read_task, so its presence or absence answers "is the read task still looping?" directly.
+ *
+ * 4, not 5: VERBOSE adds a per-interrupt line that floods a 115200 baud console and changes the
+ * timing of the thing being measured.
+ *
+ * The runtime filter in src/net/hosted_glue.zig is separate and independent - this only decides what
+ * exists in the image to be filtered.
+ * ---------------------------------------------------------------------------------------------- */
+#undef CONFIG_LOG_MAXIMUM_LEVEL
+#define CONFIG_LOG_MAXIMUM_LEVEL 4
diff --git a/src/net/hosted/sdkconfig_idf.h b/src/net/hosted/sdkconfig_idf.h
new file mode 100644
index 0000000..1457dcf
--- /dev/null
+++ b/src/net/hosted/sdkconfig_idf.h
@@ -0,0 +1,1473 @@
+/*
+ * ESP-IDF v6.0.2's generated Kconfig header, verbatim.
+ *
+ * Provenance: 00-projects/0x4200.cafe/02-esp32p4-m3-radio/build/config/sdkconfig.h, produced by the
+ * IDF build that brought this P4 onto Wi-Fi through the onboard ESP32-C6 over SDIO. Not edited -
+ * not one line. Deviations belong in sdkconfig.h, which includes this file and then overrides
+ * individual symbols with a reason attached.
+ *
+ * Do not include this directly. Include "sdkconfig.h", which is what ESP-IDF's own headers ask for.
+ */
+/*
+ * Automatically generated file. DO NOT EDIT.
+ * Espressif IoT Development Framework (ESP-IDF) 6.0.2 Configuration Header
+ */
+#pragma once
+#define CONFIG_SOC_ADC_SUPPORTED 1
+#define CONFIG_SOC_ANA_CMPR_SUPPORTED 1
+#define CONFIG_SOC_DEDICATED_GPIO_SUPPORTED 1
+#define CONFIG_SOC_UART_SUPPORTED 1
+#define CONFIG_SOC_GDMA_SUPPORTED 1
+#define CONFIG_SOC_UHCI_SUPPORTED 1
+#define CONFIG_SOC_AHB_GDMA_SUPPORTED 1
+#define CONFIG_SOC_AXI_GDMA_SUPPORTED 1
+#define CONFIG_SOC_DW_GDMA_SUPPORTED 1
+#define CONFIG_SOC_DMA2D_SUPPORTED 1
+#define CONFIG_SOC_GPTIMER_SUPPORTED 1
+#define CONFIG_SOC_PCNT_SUPPORTED 1
+#define CONFIG_SOC_LCDCAM_CAM_SUPPORTED 1
+#define CONFIG_SOC_LCDCAM_I80_LCD_SUPPORTED 1
+#define CONFIG_SOC_LCDCAM_RGB_LCD_SUPPORTED 1
+#define CONFIG_SOC_LCD_I80_SUPPORTED 1
+#define CONFIG_SOC_LCD_RGB_SUPPORTED 1
+#define CONFIG_SOC_MIPI_CSI_SUPPORTED 1
+#define CONFIG_SOC_MIPI_DSI_SUPPORTED 1
+#define CONFIG_SOC_MCPWM_SUPPORTED 1
+#define CONFIG_SOC_TWAI_SUPPORTED 1
+#define CONFIG_SOC_ETM_SUPPORTED 1
+#define CONFIG_SOC_PARLIO_SUPPORTED 1
+#define CONFIG_SOC_PARLIO_LCD_SUPPORTED 1
+#define CONFIG_SOC_ASYNC_MEMCPY_SUPPORTED 1
+#define CONFIG_SOC_EMAC_SUPPORTED 1
+#define CONFIG_SOC_USB_OTG_SUPPORTED 1
+#define CONFIG_SOC_WIRELESS_HOST_SUPPORTED 1
+#define CONFIG_SOC_USB_SERIAL_JTAG_SUPPORTED 1
+#define CONFIG_SOC_TEMP_SENSOR_SUPPORTED 1
+#define CONFIG_SOC_SUPPORTS_SECURE_DL_MODE 1
+#define CONFIG_SOC_ULP_SUPPORTED 1
+#define CONFIG_SOC_LP_CORE_SUPPORTED 1
+#define CONFIG_SOC_EFUSE_KEY_PURPOSE_FIELD 1
+#define CONFIG_SOC_EFUSE_SUPPORTED 1
+#define CONFIG_SOC_RTC_FAST_MEM_SUPPORTED 1
+#define CONFIG_SOC_RTC_MEM_SUPPORTED 1
+#define CONFIG_SOC_RMT_SUPPORTED 1
+#define CONFIG_SOC_I2S_SUPPORTED 1
+#define CONFIG_SOC_SDM_SUPPORTED 1
+#define CONFIG_SOC_GPSPI_SUPPORTED 1
+#define CONFIG_SOC_LEDC_SUPPORTED 1
+#define CONFIG_SOC_ISP_SUPPORTED 1
+#define CONFIG_SOC_I2C_SUPPORTED 1
+#define CONFIG_SOC_SYSTIMER_SUPPORTED 1
+#define CONFIG_SOC_AES_SUPPORTED 1
+#define CONFIG_SOC_MPI_SUPPORTED 1
+#define CONFIG_SOC_SHA_SUPPORTED 1
+#define CONFIG_SOC_HMAC_SUPPORTED 1
+#define CONFIG_SOC_DIG_SIGN_SUPPORTED 1
+#define CONFIG_SOC_ECC_SUPPORTED 1
+#define CONFIG_SOC_ECC_EXTENDED_MODES_SUPPORTED 1
+#define CONFIG_SOC_ECDSA_SUPPORTED 1
+#define CONFIG_SOC_KEY_MANAGER_SUPPORTED 1
+#define CONFIG_SOC_HUK_SUPPORTED 1
+#define CONFIG_SOC_FLASH_ENC_SUPPORTED 1
+#define CONFIG_SOC_SECURE_BOOT_SUPPORTED 1
+#define CONFIG_SOC_BOD_SUPPORTED 1
+#define CONFIG_SOC_VBAT_SUPPORTED 1
+#define CONFIG_SOC_APM_SUPPORTED 1
+#define CONFIG_SOC_PMU_SUPPORTED 1
+#define CONFIG_SOC_PMU_PVT_SUPPORTED 1
+#define CONFIG_SOC_PVT_EN_WITH_SLEEP 1
+#define CONFIG_SOC_PVT_RETENTION_BY_REGDMA 1
+#define CONFIG_SOC_DCDC_SUPPORTED 1
+#define CONFIG_SOC_PAU_SUPPORTED 1
+#define CONFIG_SOC_RTC_TIMER_V2_SUPPORTED 1
+#define CONFIG_SOC_ULP_LP_UART_SUPPORTED 1
+#define CONFIG_SOC_LP_GPIO_MATRIX_SUPPORTED 1
+#define CONFIG_SOC_LP_PERIPHERALS_SUPPORTED 1
+#define CONFIG_SOC_LP_I2C_SUPPORTED 1
+#define CONFIG_SOC_LP_I2S_SUPPORTED 1
+#define CONFIG_SOC_LP_SPI_SUPPORTED 1
+#define CONFIG_SOC_LP_ADC_SUPPORTED 1
+#define CONFIG_SOC_LP_VAD_SUPPORTED 1
+#define CONFIG_SOC_LP_MAILBOX_SUPPORTED 1
+#define CONFIG_SOC_SPIRAM_SUPPORTED 1
+#define CONFIG_SOC_PSRAM_DMA_CAPABLE 1
+#define CONFIG_SOC_SDMMC_HOST_SUPPORTED 1
+#define CONFIG_SOC_CLK_TREE_SUPPORTED 1
+#define CONFIG_SOC_ASSIST_DEBUG_SUPPORTED 1
+#define CONFIG_SOC_DEBUG_PROBE_SUPPORTED 1
+#define CONFIG_SOC_WDT_SUPPORTED 1
+#define CONFIG_SOC_SPI_FLASH_SUPPORTED 1
+#define CONFIG_SOC_TOUCH_SENSOR_SUPPORTED 1
+#define CONFIG_SOC_RNG_SUPPORTED 1
+#define CONFIG_SOC_GP_LDO_SUPPORTED 1
+#define CONFIG_SOC_PPA_SUPPORTED 1
+#define CONFIG_SOC_LIGHT_SLEEP_SUPPORTED 1
+#define CONFIG_SOC_DEEP_SLEEP_SUPPORTED 1
+#define CONFIG_SOC_PM_SUPPORTED 1
+#define CONFIG_SOC_BITSCRAMBLER_SUPPORTED 1
+#define CONFIG_SOC_SIMD_INSTRUCTION_SUPPORTED 1
+#define CONFIG_SOC_I3C_MASTER_SUPPORTED 1
+#define CONFIG_SOC_XTAL_SUPPORT_40M 1
+#define CONFIG_SOC_AES_SUPPORT_DMA 1
+#define CONFIG_SOC_AES_SUPPORT_GCM 1
+#define CONFIG_SOC_AES_GDMA 1
+#define CONFIG_SOC_AES_SUPPORT_AES_128 1
+#define CONFIG_SOC_AES_SUPPORT_AES_256 1
+#define CONFIG_SOC_AES_SUPPORT_PSEUDO_ROUND_FUNCTION 1
+#define CONFIG_SOC_ADC_RTC_CTRL_SUPPORTED 1
+#define CONFIG_SOC_ADC_DIG_CTRL_SUPPORTED 1
+#define CONFIG_SOC_ADC_DMA_SUPPORTED 1
+#define CONFIG_SOC_ADC_PERIPH_NUM 2
+#define CONFIG_SOC_ADC_MAX_CHANNEL_NUM 8
+#define CONFIG_SOC_ADC_ATTEN_NUM 4
+#define CONFIG_SOC_ADC_DIGI_CONTROLLER_NUM 2
+#define CONFIG_SOC_ADC_PATT_LEN_MAX 16
+#define CONFIG_SOC_ADC_DIGI_MAX_BITWIDTH 12
+#define CONFIG_SOC_ADC_DIGI_MIN_BITWIDTH 12
+#define CONFIG_SOC_ADC_DIGI_IIR_FILTER_NUM 2
+#define CONFIG_SOC_ADC_DIGI_MONITOR_NUM 2
+#define CONFIG_SOC_ADC_DIGI_RESULT_BYTES 4
+#define CONFIG_SOC_ADC_DIGI_DATA_BYTES_PER_CONV 4
+#define CONFIG_SOC_ADC_SAMPLE_FREQ_THRES_HIGH 83333
+#define CONFIG_SOC_ADC_SAMPLE_FREQ_THRES_LOW 611
+#define CONFIG_SOC_ADC_RTC_MIN_BITWIDTH 12
+#define CONFIG_SOC_ADC_RTC_MAX_BITWIDTH 12
+#define CONFIG_SOC_ADC_CALIBRATION_V1_SUPPORTED 1
+#define CONFIG_SOC_ADC_SELF_HW_CALI_SUPPORTED 1
+#define CONFIG_SOC_ADC_CALIB_CHAN_COMPENS_SUPPORTED 1
+#define CONFIG_SOC_ADC_SHARED_POWER 1
+#define CONFIG_SOC_BROWNOUT_RESET_SUPPORTED 1
+#define CONFIG_SOC_SHARED_IDCACHE_SUPPORTED 1
+#define CONFIG_SOC_CACHE_WRITEBACK_SUPPORTED 1
+#define CONFIG_SOC_CACHE_FREEZE_SUPPORTED 1
+#define CONFIG_SOC_CACHE_INTERNAL_MEM_VIA_L1CACHE 1
+#define CONFIG_SOC_CPU_CORES_NUM 2
+#define CONFIG_SOC_CPU_INTR_NUM 32
+#define CONFIG_SOC_CPU_HAS_FLEXIBLE_INTC 1
+#define CONFIG_SOC_INT_CLIC_SUPPORTED 1
+#define CONFIG_SOC_INT_HW_NESTED_SUPPORTED 1
+#define CONFIG_SOC_BRANCH_PREDICTOR_SUPPORTED 1
+#define CONFIG_SOC_CPU_COPROC_NUM 3
+#define CONFIG_SOC_CPU_HAS_FPU 1
+#define CONFIG_SOC_CPU_HAS_FPU_EXT_ILL_BUG 1
+#define CONFIG_SOC_CPU_HAS_HWLOOP 1
+#define CONFIG_SOC_CPU_HAS_HWLOOP_STATE_BUG 1
+#define CONFIG_SOC_CPU_HAS_PIE 1
+#define CONFIG_SOC_HP_CPU_HAS_MULTIPLE_CORES 1
+#define CONFIG_SOC_CPU_BREAKPOINTS_NUM 3
+#define CONFIG_SOC_CPU_WATCHPOINTS_NUM 3
+#define CONFIG_SOC_CPU_WATCHPOINT_MAX_REGION_SIZE 0x100
+#define CONFIG_SOC_CPU_HAS_PMA 1
+#define CONFIG_SOC_CPU_IDRAM_SPLIT_USING_PMP 1
+#define CONFIG_SOC_CPU_PMP_REGION_GRANULARITY 128
+#define CONFIG_SOC_CPU_HAS_LOCKUP_RESET 1
+#define CONFIG_SOC_CPU_HAS_ZC_EXTENSIONS 1
+#define CONFIG_SOC_CPU_ZCMP_WORKAROUND 1
+#define CONFIG_SOC_CPU_ZCMP_PUSH_REVERSED 1
+#define CONFIG_SOC_CPU_ZCMP_POPRET_ISSUE 1
+#define CONFIG_SOC_SIMD_PREFERRED_DATA_ALIGNMENT 16
+#define CONFIG_SOC_DS_SIGNATURE_MAX_BIT_LEN 4096
+#define CONFIG_SOC_DS_KEY_PARAM_MD_IV_LENGTH 16
+#define CONFIG_SOC_DS_KEY_CHECK_MAX_WAIT_US 1100
+#define CONFIG_SOC_DMA_CAN_ACCESS_FLASH 1
+#define CONFIG_SOC_AHB_GDMA_VERSION 2
+#define CONFIG_SOC_GDMA_SUPPORT_CRC 1
+#define CONFIG_SOC_GDMA_SUPPORT_ETM 1
+#define CONFIG_SOC_GDMA_SUPPORT_SLEEP_RETENTION 1
+#define CONFIG_SOC_GDMA_EXT_MEM_ENC_ALIGNMENT 16
+#define CONFIG_SOC_GPIO_PORT 1
+#define CONFIG_SOC_GPIO_PIN_COUNT 55
+#define CONFIG_SOC_GPIO_SUPPORT_PIN_GLITCH_FILTER 1
+#define CONFIG_SOC_GPIO_FLEX_GLITCH_FILTER_NUM 8
+#define CONFIG_SOC_GPIO_SUPPORT_PIN_HYS_FILTER 1
+#define CONFIG_SOC_GPIO_SUPPORT_ETM 1
+#define CONFIG_SOC_GPIO_SUPPORT_HP_PERIPH_PD_SLEEP_WAKEUP 1
+#define CONFIG_SOC_LP_IO_HAS_INDEPENDENT_WAKEUP_SOURCE 1
+#define CONFIG_SOC_LP_IO_CLOCK_IS_INDEPENDENT 1
+#define CONFIG_SOC_GPIO_VALID_GPIO_MASK 0x007FFFFFFFFFFFFF
+#define CONFIG_SOC_GPIO_IN_RANGE_MAX 54
+#define CONFIG_SOC_GPIO_OUT_RANGE_MAX 54
+#define CONFIG_SOC_GPIO_HP_PERIPH_PD_SLEEP_WAKEABLE_MASK 0
+#define CONFIG_SOC_GPIO_HP_PERIPH_PD_SLEEP_WAKEABLE_PIN_CNT 16
+#define CONFIG_SOC_GPIO_VALID_DIGITAL_IO_PAD_MASK 0x007FFFFFFFFF0000
+#define CONFIG_SOC_GPIO_SUPPORT_FORCE_HOLD 1
+#define CONFIG_SOC_GPIO_SUPPORT_HOLD_SINGLE_IO_IN_DSLP 1
+#define CONFIG_SOC_GPIO_CLOCKOUT_BY_GPIO_MATRIX 1
+#define CONFIG_SOC_GPIO_CLOCKOUT_CHANNEL_NUM 2
+#define CONFIG_SOC_CLOCKOUT_SUPPORT_CHANNEL_DIVIDER 1
+#define CONFIG_SOC_DEBUG_PROBE_NUM_UNIT 1
+#define CONFIG_SOC_DEBUG_PROBE_MAX_OUTPUT_WIDTH 16
+#define CONFIG_SOC_RTCIO_PIN_COUNT 16
+#define CONFIG_SOC_RTCIO_INPUT_OUTPUT_SUPPORTED 1
+#define CONFIG_SOC_RTCIO_HOLD_SUPPORTED 1
+#define CONFIG_SOC_RTCIO_WAKE_SUPPORTED 1
+#define CONFIG_SOC_SDM_SUPPORT_SLEEP_RETENTION 1
+#define CONFIG_SOC_ETM_SUPPORT_SLEEP_RETENTION 1
+#define CONFIG_SOC_ANA_CMPR_NUM 2
+#define CONFIG_SOC_ANA_CMPR_CAN_DISTINGUISH_EDGE 1
+#define CONFIG_SOC_ANA_CMPR_SUPPORT_ETM 1
+#define CONFIG_SOC_I2C_NUM 3
+#define CONFIG_SOC_HP_I2C_NUM 2
+#define CONFIG_SOC_LP_I2C_NUM 1
+#define CONFIG_SOC_I2C_SUPPORT_XTAL 1
+#define CONFIG_SOC_I2C_SUPPORT_RTC 1
+#define CONFIG_SOC_I2C_SUPPORT_10BIT_ADDR 1
+#define CONFIG_SOC_I2C_SUPPORT_SLAVE 1
+#define CONFIG_SOC_I2C_SLAVE_SUPPORT_BROADCAST 1
+#define CONFIG_SOC_I2C_SLAVE_CAN_GET_STRETCH_CAUSE 1
+#define CONFIG_SOC_I2C_SUPPORT_SLEEP_RETENTION 1
+#define CONFIG_SOC_I2S_HW_VERSION_2 1
+#define CONFIG_SOC_I2S_SUPPORTS_ETM 1
+#define CONFIG_SOC_I2S_SUPPORTS_APLL 1
+#define CONFIG_SOC_I2S_SUPPORTS_PCM 1
+#define CONFIG_SOC_I2S_SUPPORTS_PDM 1
+#define CONFIG_SOC_I2S_SUPPORTS_PDM_TX 1
+#define CONFIG_SOC_I2S_SUPPORTS_PCM2PDM 1
+#define CONFIG_SOC_I2S_SUPPORTS_PDM_RX 1
+#define CONFIG_SOC_I2S_SUPPORTS_PDM2PCM 1
+#define CONFIG_SOC_I2S_SUPPORTS_PDM_RX_HP_FILTER 1
+#define CONFIG_SOC_I2S_SUPPORTS_TX_SYNC_CNT 1
+#define CONFIG_SOC_I2S_SUPPORTS_TDM 1
+#define CONFIG_SOC_I2S_PDM_MAX_TX_LINES 2
+#define CONFIG_SOC_I2S_PDM_MAX_RX_LINES 4
+#define CONFIG_SOC_LP_I2S_NUM 1
+#define CONFIG_SOC_ISP_BF_SUPPORTED 1
+#define CONFIG_SOC_ISP_BLC_SUPPORTED 1
+#define CONFIG_SOC_ISP_CCM_SUPPORTED 1
+#define CONFIG_SOC_ISP_COLOR_SUPPORTED 1
+#define CONFIG_SOC_ISP_CROP_SUPPORTED 1
+#define CONFIG_SOC_ISP_DEMOSAIC_SUPPORTED 1
+#define CONFIG_SOC_ISP_DVP_SUPPORTED 1
+#define CONFIG_SOC_ISP_LSC_SUPPORTED 1
+#define CONFIG_SOC_ISP_SHARPEN_SUPPORTED 1
+#define CONFIG_SOC_ISP_WBG_SUPPORTED 1
+#define CONFIG_SOC_ISP_SHARE_CSI_BRG 1
+#define CONFIG_SOC_ISP_AE_BLOCK_X_NUMS 5
+#define CONFIG_SOC_ISP_AE_BLOCK_Y_NUMS 5
+#define CONFIG_SOC_ISP_AF_WINDOW_NUMS 3
+#define CONFIG_SOC_ISP_AWB_WINDOW_X_NUMS 5
+#define CONFIG_SOC_ISP_AWB_WINDOW_Y_NUMS 5
+#define CONFIG_SOC_ISP_BF_TEMPLATE_X_NUMS 3
+#define CONFIG_SOC_ISP_BF_TEMPLATE_Y_NUMS 3
+#define CONFIG_SOC_ISP_CCM_DIMENSION 3
+#define CONFIG_SOC_ISP_DEMOSAIC_GRAD_RATIO_INT_BITS 2
+#define CONFIG_SOC_ISP_DEMOSAIC_GRAD_RATIO_DEC_BITS 4
+#define CONFIG_SOC_ISP_DEMOSAIC_GRAD_RATIO_RES_BITS 26
+#define CONFIG_SOC_ISP_SHARPEN_TEMPLATE_X_NUMS 3
+#define CONFIG_SOC_ISP_SHARPEN_TEMPLATE_Y_NUMS 3
+#define CONFIG_SOC_ISP_SHARPEN_H_FREQ_COEF_INT_BITS 3
+#define CONFIG_SOC_ISP_SHARPEN_H_FREQ_COEF_DEC_BITS 5
+#define CONFIG_SOC_ISP_SHARPEN_H_FREQ_COEF_RES_BITS 24
+#define CONFIG_SOC_ISP_SHARPEN_M_FREQ_COEF_INT_BITS 3
+#define CONFIG_SOC_ISP_SHARPEN_M_FREQ_COEF_DEC_BITS 5
+#define CONFIG_SOC_ISP_SHARPEN_M_FREQ_COEF_RES_BITS 24
+#define CONFIG_SOC_ISP_HIST_BLOCK_X_NUMS 5
+#define CONFIG_SOC_ISP_HIST_BLOCK_Y_NUMS 5
+#define CONFIG_SOC_ISP_HIST_SEGMENT_NUMS 16
+#define CONFIG_SOC_ISP_HIST_INTERVAL_NUMS 15
+#define CONFIG_SOC_ISP_LSC_GRAD_RATIO_INT_BITS 2
+#define CONFIG_SOC_ISP_LSC_GRAD_RATIO_DEC_BITS 8
+#define CONFIG_SOC_ISP_LSC_GRAD_RATIO_RES_BITS 22
+#define CONFIG_SOC_LEDC_SUPPORT_PLL_DIV_CLOCK 1
+#define CONFIG_SOC_LEDC_SUPPORT_XTAL_CLOCK 1
+#define CONFIG_SOC_LEDC_TIMER_NUM 4
+#define CONFIG_SOC_LEDC_CHANNEL_NUM 8
+#define CONFIG_SOC_LEDC_TIMER_BIT_WIDTH 20
+#define CONFIG_SOC_LEDC_GAMMA_CURVE_FADE_SUPPORTED 1
+#define CONFIG_SOC_LEDC_GAMMA_CURVE_FADE_RANGE_MAX 16
+#define CONFIG_SOC_LEDC_SUPPORT_FADE_STOP 1
+#define CONFIG_SOC_LEDC_FADE_PARAMS_BIT_WIDTH 10
+#define CONFIG_SOC_LEDC_SUPPORT_SLEEP_RETENTION 1
+#define CONFIG_SOC_LEDC_SUPPORT_ETM 1
+#define CONFIG_SOC_MMU_PERIPH_NUM 2
+#define CONFIG_SOC_MMU_LINEAR_ADDRESS_REGION_NUM 2
+#define CONFIG_SOC_MMU_DI_VADDR_SHARED 1
+#define CONFIG_SOC_MMU_PER_EXT_MEM_TARGET 1
+#define CONFIG_SOC_MPU_MIN_REGION_SIZE 0x20000000
+#define CONFIG_SOC_MPU_REGIONS_MAX_NUM 8
+#define CONFIG_SOC_PCNT_SUPPORT_RUNTIME_THRES_UPDATE 1
+#define CONFIG_SOC_PCNT_SUPPORT_CLEAR_SIGNAL 1
+#define CONFIG_SOC_RMT_MEM_WORDS_PER_CHANNEL 48
+#define CONFIG_SOC_RMT_SUPPORT_RX_PINGPONG 1
+#define CONFIG_SOC_RMT_SUPPORT_TX_LOOP_COUNT 1
+#define CONFIG_SOC_RMT_SUPPORT_TX_LOOP_AUTO_STOP 1
+#define CONFIG_SOC_RMT_SUPPORT_DMA 1
+#define CONFIG_SOC_RMT_SUPPORT_SLEEP_RETENTION 1
+#define CONFIG_SOC_MCPWM_SWSYNC_CAN_PROPAGATE 1
+#define CONFIG_SOC_MCPWM_SUPPORT_ETM 1
+#define CONFIG_SOC_MCPWM_SUPPORT_EVENT_COMPARATOR 1
+#define CONFIG_SOC_MCPWM_CAPTURE_CLK_FROM_GROUP 1
+#define CONFIG_SOC_MCPWM_SUPPORT_SLEEP_RETENTION 1
+#define CONFIG_SOC_USB_OTG_PERIPH_NUM 2
+#define CONFIG_SOC_USB_FSLS_PHY_NUM 1
+#define CONFIG_SOC_USB_UTMI_PHY_NUM 1
+#define CONFIG_SOC_USB_UTMI_PHY_NO_POWER_OFF_ISO 1
+#define CONFIG_SOC_PARLIO_TX_UNIT_MAX_DATA_WIDTH 16
+#define CONFIG_SOC_PARLIO_RX_UNIT_MAX_DATA_WIDTH 16
+#define CONFIG_SOC_PARLIO_TX_CLK_SUPPORT_GATING 1
+#define CONFIG_SOC_PARLIO_RX_CLK_SUPPORT_GATING 1
+#define CONFIG_SOC_PARLIO_TX_SUPPORT_LOOP_TRANSMISSION 1
+#define CONFIG_SOC_PARLIO_SUPPORT_SLEEP_RETENTION 1
+#define CONFIG_SOC_PARLIO_SUPPORT_I80_LCD 1
+#define CONFIG_SOC_MPI_MEM_BLOCKS_NUM 4
+#define CONFIG_SOC_MPI_OPERATIONS_NUM 3
+#define CONFIG_SOC_RSA_MAX_BIT_LEN 4096
+#define CONFIG_SOC_SDMMC_USE_IOMUX 1
+#define CONFIG_SOC_SDMMC_USE_GPIO_MATRIX 1
+#define CONFIG_SOC_SDMMC_NUM_SLOTS 2
+#define CONFIG_SOC_SDMMC_DATA_WIDTH_MAX 8
+#define CONFIG_SOC_SDMMC_DELAY_PHASE_NUM 8
+#define CONFIG_SOC_SDMMC_IO_POWER_EXTERNAL 1
+#define CONFIG_SOC_SDMMC_PSRAM_DMA_CAPABLE 1
+#define CONFIG_SOC_SDMMC_UHS_I_SUPPORTED 1
+#define CONFIG_SOC_SHA_DMA_MAX_BUFFER_SIZE 3968
+#define CONFIG_SOC_SHA_SUPPORT_DMA 1
+#define CONFIG_SOC_SHA_SUPPORT_RESUME 1
+#define CONFIG_SOC_SHA_GDMA 1
+#define CONFIG_SOC_SHA_SUPPORT_SHA1 1
+#define CONFIG_SOC_SHA_SUPPORT_SHA224 1
+#define CONFIG_SOC_SHA_SUPPORT_SHA256 1
+#define CONFIG_SOC_SHA_SUPPORT_SHA384 1
+#define CONFIG_SOC_SHA_SUPPORT_SHA512 1
+#define CONFIG_SOC_SHA_SUPPORT_SHA512_224 1
+#define CONFIG_SOC_SHA_SUPPORT_SHA512_256 1
+#define CONFIG_SOC_SHA_SUPPORT_SHA512_T 1
+#define CONFIG_SOC_ECC_CONSTANT_TIME_POINT_MUL 1
+#define CONFIG_SOC_ECC_SUPPORT_CURVE_P384 1
+#define CONFIG_SOC_ECDSA_SUPPORT_EXPORT_PUBKEY 1
+#define CONFIG_SOC_ECDSA_SUPPORT_DETERMINISTIC_MODE 1
+#define CONFIG_SOC_ECDSA_SUPPORT_HW_DETERMINISTIC_LOOP 1
+#define CONFIG_SOC_ECDSA_USES_MPI 1
+#define CONFIG_SOC_ECDSA_SUPPORT_CURVE_P384 1
+#define CONFIG_SOC_ECDSA_SUPPORT_CURVE_SPECIFIC_KEY_PURPOSES 1
+#define CONFIG_SOC_SPI_PERIPH_NUM 3
+#define CONFIG_SOC_SPI_MAX_CS_NUM 6
+#define CONFIG_SOC_SPI_MAXIMUM_BUFFER_SIZE 64
+#define CONFIG_SOC_SPI_SUPPORT_SLEEP_RETENTION 1
+#define CONFIG_SOC_SPI_SUPPORT_SLAVE_HD_VER2 1
+#define CONFIG_SOC_SPI_SLAVE_SUPPORT_SEG_TRANS 1
+#define CONFIG_SOC_SPI_SUPPORT_DDRCLK 1
+#define CONFIG_SOC_SPI_SUPPORT_CD_SIG 1
+#define CONFIG_SOC_SPI_SUPPORT_OCT 1
+#define CONFIG_SOC_SPI_SUPPORT_CLK_XTAL 1
+#define CONFIG_SOC_SPI_SUPPORT_CLK_RC_FAST 1
+#define CONFIG_SOC_MSPI_HAS_INDEPENT_IOMUX 1
+#define CONFIG_SOC_MEMSPI_IS_INDEPENDENT 1
+#define CONFIG_SOC_SPI_MAX_PRE_DIVIDER 16
+#define CONFIG_SOC_LP_SPI_MAXIMUM_BUFFER_SIZE 64
+#define CONFIG_SOC_SPIRAM_XIP_SUPPORTED 1
+#define CONFIG_SOC_SPI_MEM_SUPPORT_AUTO_WAIT_IDLE 1
+#define CONFIG_SOC_SPI_MEM_SUPPORT_AUTO_SUSPEND 1
+#define CONFIG_SOC_SPI_MEM_SUPPORT_AUTO_RESUME 1
+#define CONFIG_SOC_SPI_MEM_SUPPORT_IDLE_INTR 1
+#define CONFIG_SOC_SPI_MEM_SUPPORT_SW_SUSPEND 1
+#define CONFIG_SOC_SPI_MEM_SUPPORT_CHECK_SUS 1
+#define CONFIG_SOC_SPI_MEM_SUPPORT_TIMING_TUNING 1
+#define CONFIG_SOC_MEMSPI_TIMING_TUNING_BY_DQS 1
+#define CONFIG_SOC_MEMSPI_TIMING_TUNING_BY_FLASH_DELAY 1
+#define CONFIG_SOC_SPI_MEM_SUPPORT_CACHE_32BIT_ADDR_MAP 1
+#define CONFIG_SOC_SPI_MEM_SUPPORT_TSUS_TRES_SEPERATE_CTR 1
+#define CONFIG_SOC_SPI_PERIPH_SUPPORT_CONTROL_DUMMY_OUT 1
+#define CONFIG_SOC_SPI_MEM_FLASH_SUPPORT_HPM 1
+#define CONFIG_SOC_MEMSPI_ENCRYPTION_ALIGNMENT 16
+#define CONFIG_SOC_SYSTIMER_COUNTER_NUM 2
+#define CONFIG_SOC_SYSTIMER_ALARM_NUM 3
+#define CONFIG_SOC_SYSTIMER_BIT_WIDTH_LO 32
+#define CONFIG_SOC_SYSTIMER_BIT_WIDTH_HI 20
+#define CONFIG_SOC_SYSTIMER_FIXED_DIVIDER 1
+#define CONFIG_SOC_SYSTIMER_SUPPORT_RC_FAST 1
+#define CONFIG_SOC_SYSTIMER_INT_LEVEL 1
+#define CONFIG_SOC_SYSTIMER_ALARM_MISS_COMPENSATE 1
+#define CONFIG_SOC_SYSTIMER_SUPPORT_ETM 1
+#define CONFIG_SOC_LP_TIMER_BIT_WIDTH_LO 32
+#define CONFIG_SOC_LP_TIMER_BIT_WIDTH_HI 16
+#define CONFIG_SOC_TIMER_SUPPORT_ETM 1
+#define CONFIG_SOC_TIMER_SUPPORT_SLEEP_RETENTION 1
+#define CONFIG_SOC_MWDT_SUPPORT_XTAL 1
+#define CONFIG_SOC_MWDT_SUPPORT_SLEEP_RETENTION 1
+#define CONFIG_SOC_TOUCH_SENSOR_VERSION 3
+#define CONFIG_SOC_TOUCH_MIN_CHAN_ID 1
+#define CONFIG_SOC_TOUCH_MAX_CHAN_ID 14
+#define CONFIG_SOC_TOUCH_SUPPORT_SLEEP_WAKEUP 1
+#define CONFIG_SOC_TOUCH_SUPPORT_BENCHMARK 1
+#define CONFIG_SOC_TOUCH_SUPPORT_WATERPROOF 1
+#define CONFIG_SOC_TOUCH_SUPPORT_PROX_SENSING 1
+#define CONFIG_SOC_TOUCH_PROXIMITY_CHANNEL_NUM 3
+#define CONFIG_SOC_TOUCH_SAMPLE_CFG_NUM 3
+#define CONFIG_SOC_TWAI_CONTROLLER_NUM 3
+#define CONFIG_SOC_TWAI_MASK_FILTER_NUM 1
+#define CONFIG_SOC_TWAI_SUPPORT_SLEEP_RETENTION 1
+#define CONFIG_SOC_EFUSE_DIS_PAD_JTAG 1
+#define CONFIG_SOC_EFUSE_DIS_USB_JTAG 1
+#define CONFIG_SOC_EFUSE_DIS_DIRECT_BOOT 1
+#define CONFIG_SOC_EFUSE_SOFT_DIS_JTAG 1
+#define CONFIG_SOC_EFUSE_DIS_DOWNLOAD_MSPI 1
+#define CONFIG_SOC_EFUSE_ECDSA_KEY 1
+#define CONFIG_SOC_EFUSE_XTS_AES_KEY_128 1
+#define CONFIG_SOC_EFUSE_XTS_AES_KEY_256 1
+#define CONFIG_SOC_EFUSE_ECDSA_KEY_P192 1
+#define CONFIG_SOC_EFUSE_ECDSA_KEY_P384 1
+#define CONFIG_SOC_KEY_MANAGER_SUPPORT_KEY_DEPLOYMENT 1
+#define CONFIG_SOC_KEY_MANAGER_ECDSA_KEY_DEPLOY 1
+#define CONFIG_SOC_KEY_MANAGER_FE_KEY_DEPLOY 1
+#define CONFIG_SOC_KEY_MANAGER_FE_KEY_DEPLOY_XTS_AES_128 1
+#define CONFIG_SOC_KEY_MANAGER_FE_KEY_DEPLOY_XTS_AES_256 1
+#define CONFIG_SOC_KEY_MANAGER_HMAC_KEY_DEPLOY 1
+#define CONFIG_SOC_KEY_MANAGER_DS_KEY_DEPLOY 1
+#define CONFIG_SOC_SECURE_BOOT_V2_RSA 1
+#define CONFIG_SOC_SECURE_BOOT_V2_ECC 1
+#define CONFIG_SOC_EFUSE_SECURE_BOOT_KEY_DIGESTS 3
+#define CONFIG_SOC_EFUSE_REVOKE_BOOT_KEY_DIGESTS 1
+#define CONFIG_SOC_SUPPORT_SECURE_BOOT_REVOKE_KEY 1
+#define CONFIG_SOC_FLASH_ENCRYPTED_XTS_AES_BLOCK_MAX 64
+#define CONFIG_SOC_FLASH_ENCRYPTION_XTS_AES 1
+#define CONFIG_SOC_FLASH_ENCRYPTION_XTS_AES_OPTIONS 1
+#define CONFIG_SOC_FLASH_ENCRYPTION_XTS_AES_128 1
+#define CONFIG_SOC_FLASH_ENCRYPTION_XTS_AES_256 1
+#define CONFIG_SOC_FLASH_ENCRYPTION_XTS_AES_SUPPORT_PSEUDO_ROUND 1
+#define CONFIG_SOC_FLASH_ENCRYPTION_PAGE_CONFIGURABLE 1
+#define CONFIG_SOC_PSRAM_ENCRYPTION_SEPARATE_KEY 1
+#define CONFIG_SOC_PSRAM_ENCRYPTION_PAGE_CONFIGURABLE 1
+#define CONFIG_SOC_RECOVERY_BOOTLOADER_SUPPORTED 1
+#define CONFIG_SOC_UART_NUM 6
+#define CONFIG_SOC_UART_HP_NUM 5
+#define CONFIG_SOC_UART_LP_NUM 1
+#define CONFIG_SOC_UART_FIFO_LEN 128
+#define CONFIG_SOC_LP_UART_FIFO_LEN 16
+#define CONFIG_SOC_UART_BITRATE_MAX 5000000
+#define CONFIG_SOC_UART_SUPPORT_RTC_CLK 1
+#define CONFIG_SOC_UART_SUPPORT_XTAL_CLK 1
+#define CONFIG_SOC_UART_SUPPORT_WAKEUP_INT 1
+#define CONFIG_SOC_UART_HAS_LP_UART 1
+#define CONFIG_SOC_UART_SUPPORT_SLEEP_RETENTION 1
+#define CONFIG_SOC_UART_WAKEUP_CHARS_SEQ_MAX_LEN 5
+#define CONFIG_SOC_UART_WAKEUP_SUPPORT_ACTIVE_THRESH_MODE 1
+#define CONFIG_SOC_UART_WAKEUP_SUPPORT_FIFO_THRESH_MODE 1
+#define CONFIG_SOC_UART_WAKEUP_SUPPORT_START_BIT_MODE 1
+#define CONFIG_SOC_UART_WAKEUP_SUPPORT_CHAR_SEQ_MODE 1
+#define CONFIG_SOC_LP_I2S_SUPPORT_VAD 1
+#define CONFIG_SOC_UHCI_NUM 1
+#define CONFIG_SOC_COEX_HW_PTI 1
+#define CONFIG_SOC_PHY_DIG_REGS_MEM_SIZE 21
+#define CONFIG_SOC_WIFI_LIGHT_SLEEP_CLK_WIDTH 12
+#define CONFIG_SOC_PM_SUPPORT_EXT1_WAKEUP 1
+#define CONFIG_SOC_PM_SUPPORT_EXT1_WAKEUP_MODE_PER_PIN 1
+#define CONFIG_SOC_PM_EXT1_WAKEUP_BY_PMU 1
+#define CONFIG_SOC_PM_SUPPORT_WIFI_WAKEUP 1
+#define CONFIG_SOC_PM_SUPPORT_TOUCH_SENSOR_WAKEUP 1
+#define CONFIG_SOC_PM_SUPPORT_LP_UART_WAKEUP 1
+#define CONFIG_SOC_PM_SUPPORT_CPU_PD 1
+#define CONFIG_SOC_PM_SUPPORT_XTAL32K_PD 1
+#define CONFIG_SOC_PM_SUPPORT_RC32K_PD 1
+#define CONFIG_SOC_PM_SUPPORT_RC_FAST_PD 1
+#define CONFIG_SOC_PM_SUPPORT_VDDSDIO_PD 1
+#define CONFIG_SOC_PM_SUPPORT_TOP_PD 1
+#define CONFIG_SOC_PM_SUPPORT_CNNT_PD 1
+#define CONFIG_SOC_PM_SUPPORT_RTC_PERIPH_PD 1
+#define CONFIG_SOC_PM_SUPPORT_DEEPSLEEP_CHECK_STUB_ONLY 1
+#define CONFIG_SOC_PM_CPU_RETENTION_BY_SW 1
+#define CONFIG_SOC_PM_FPU_RETENTION_BY_SW 1
+#define CONFIG_SOC_PM_CACHE_RETENTION_BY_PAU 1
+#define CONFIG_SOC_PM_PAU_LINK_NUM 4
+#define CONFIG_SOC_PM_PAU_REGDMA_LINK_MULTI_ADDR 1
+#define CONFIG_SOC_PAU_IN_TOP_DOMAIN 1
+#define CONFIG_SOC_PM_PAU_REGDMA_UPDATE_CACHE_BEFORE_WAIT_COMPARE 1
+#define CONFIG_SOC_SLEEP_SYSTIMER_STALL_WORKAROUND 1
+#define CONFIG_SOC_SLEEP_TGWDT_STOP_WORKAROUND 1
+#define CONFIG_SOC_PM_RETENTION_MODULE_NUM 64
+#define CONFIG_SOC_CLK_RC_FAST_SUPPORT_CALIBRATION 1
+#define CONFIG_SOC_CLK_APLL_SUPPORTED 1
+#define CONFIG_SOC_CLK_MPLL_SUPPORTED 1
+#define CONFIG_SOC_CLK_XTAL32K_SUPPORTED 1
+#define CONFIG_SOC_CLK_RC32K_SUPPORTED 1
+#define CONFIG_SOC_CLK_LP_FAST_SUPPORT_LP_PLL 1
+#define CONFIG_SOC_CLK_LP_FAST_SUPPORT_XTAL 1
+#define CONFIG_SOC_PERIPH_CLK_CTRL_SHARED 1
+#define CONFIG_SOC_TEMPERATURE_SENSOR_INTR_SUPPORT 1
+#define CONFIG_SOC_TSENS_IS_INDEPENDENT_FROM_ADC 1
+#define CONFIG_SOC_TEMPERATURE_SENSOR_SUPPORT_ETM 1
+#define CONFIG_SOC_TEMPERATURE_SENSOR_SUPPORT_SLEEP_RETENTION 1
+#define CONFIG_SOC_MEM_SPM_SUPPORTED 1
+#define CONFIG_SOC_ASYNCHRONOUS_BUS_ERROR_MODE 1
+#define CONFIG_SOC_EMAC_IEEE1588V2_SUPPORTED 1
+#define CONFIG_SOC_EMAC_USE_MULTI_IO_MUX 1
+#define CONFIG_SOC_EMAC_MII_USE_GPIO_MATRIX 1
+#define CONFIG_SOC_EMAC_SUPPORT_SLEEP_RETENTION 1
+#define CONFIG_SOC_JPEG_CODEC_SUPPORTED 1
+#define CONFIG_SOC_JPEG_DECODE_SUPPORTED 1
+#define CONFIG_SOC_JPEG_ENCODE_SUPPORTED 1
+#define CONFIG_SOC_H264_ENCODER_SUPPORTED 1
+#define CONFIG_SOC_LCDCAM_CAM_SUPPORT_RGB_YUV_CONV 1
+#define CONFIG_SOC_LCDCAM_LCD_SUPPORT_SLEEP_RETENTION 1
+#define CONFIG_SOC_I3C_MASTER_PERIPH_NUM 1
+#define CONFIG_SOC_I3C_MASTER_ADDRESS_TABLE_NUM 12
+#define CONFIG_SOC_I3C_MASTER_COMMAND_TABLE_NUM 12
+#define CONFIG_SOC_LP_CORE_SUPPORT_ETM 1
+#define CONFIG_SOC_LP_CORE_SUPPORT_LP_ADC 1
+#define CONFIG_SOC_LP_CORE_SUPPORT_STORE_LOAD_EXCEPTIONS 1
+#define CONFIG_IDF_CMAKE 1
+#define CONFIG_IDF_TOOLCHAIN "gcc"
+#define CONFIG_IDF_TOOLCHAIN_GCC 1
+#define CONFIG_IDF_TARGET_ARCH_RISCV 1
+#define CONFIG_IDF_TARGET_ARCH "riscv"
+#define CONFIG_IDF_TARGET "esp32p4"
+#define CONFIG_IDF_INIT_VERSION "6.0.2"
+#define CONFIG_IDF_TARGET_ESP32P4 1
+#define CONFIG_IDF_FIRMWARE_CHIP_ID 0x0012
+#define CONFIG_APP_BUILD_TYPE_APP_2NDBOOT 1
+#define CONFIG_APP_BUILD_GENERATE_BINARIES 1
+#define CONFIG_APP_BUILD_BOOTLOADER 1
+#define CONFIG_APP_BUILD_USE_FLASH_SECTIONS 1
+#define CONFIG_BOOTLOADER_COMPILE_TIME_DATE 1
+#define CONFIG_BOOTLOADER_PROJECT_VER 1
+#define CONFIG_BOOTLOADER_OFFSET_IN_FLASH 0x2000
+#define CONFIG_BOOTLOADER_COMPILER_OPTIMIZATION_SIZE 1
+#define CONFIG_BOOTLOADER_LOG_VERSION_1 1
+#define CONFIG_BOOTLOADER_LOG_VERSION 1
+#define CONFIG_BOOTLOADER_LOG_LEVEL_INFO 1
+#define CONFIG_BOOTLOADER_LOG_LEVEL 3
+#define CONFIG_BOOTLOADER_LOG_TIMESTAMP_SOURCE_CPU_TICKS 1
+#define CONFIG_BOOTLOADER_LOG_MODE_TEXT_EN 1
+#define CONFIG_BOOTLOADER_LOG_MODE_TEXT 1
+#define CONFIG_BOOTLOADER_CPU_CLK_FREQ_MHZ 90
+#define CONFIG_BOOTLOADER_FLASH_XMC_SUPPORT 1
+#define CONFIG_BOOTLOADER_REGION_PROTECTION_ENABLE 1
+#define CONFIG_BOOTLOADER_WDT_ENABLE 1
+#define CONFIG_BOOTLOADER_WDT_TIME_MS 9000
+#define CONFIG_BOOTLOADER_RESERVE_RTC_SIZE 0x0
+#define CONFIG_SECURE_BOOT_V2_RSA_SUPPORTED 1
+#define CONFIG_SECURE_BOOT_V2_ECC_SUPPORTED 1
+#define CONFIG_SECURE_BOOT_V2_ECDSA_INSECURE 1
+#define CONFIG_SECURE_BOOT_V2_PREFERRED 1
+#define CONFIG_SECURE_ROM_DL_MODE_ENABLED 1
+#define CONFIG_APP_COMPILE_TIME_DATE 1
+#define CONFIG_APP_RETRIEVE_LEN_ELF_SHA 9
+#define CONFIG_ESP_ROM_HAS_CRC_LE 1
+#define CONFIG_ESP_ROM_HAS_CRC_BE 1
+#define CONFIG_ESP_ROM_UART_CLK_IS_XTAL 1
+#define CONFIG_ESP_ROM_USB_SERIAL_DEVICE_NUM 6
+#define CONFIG_ESP_ROM_USB_OTG_NUM 5
+#define CONFIG_ESP_ROM_HAS_RETARGETABLE_LOCKING 1
+#define CONFIG_ESP_ROM_GET_CLK_FREQ 1
+#define CONFIG_ESP_ROM_HAS_RVFPLIB 1
+#define CONFIG_ESP_ROM_HAS_HAL_WDT 1
+#define CONFIG_ESP_ROM_HAS_HAL_SYSTIMER 1
+#define CONFIG_ESP_ROM_SYSTIMER_INIT_PATCH 1
+#define CONFIG_ESP_ROM_HAS_LAYOUT_TABLE 1
+#define CONFIG_ESP_ROM_WDT_INIT_PATCH 1
+#define CONFIG_ESP_ROM_HAS_LP_ROM 1
+#define CONFIG_ESP_ROM_WITHOUT_REGI2C 1
+#define CONFIG_ESP_ROM_HAS_NEWLIB 1
+#define CONFIG_ESP_ROM_HAS_NEWLIB_NANO_FORMAT 1
+#define CONFIG_ESP_ROM_HAS_NEWLIB_NANO_PRINTF_FLOAT_BUG 1
+#define CONFIG_ESP_ROM_HAS_VERSION 1
+#define CONFIG_ESP_ROM_CLIC_INT_TYPE_PATCH 1
+#define CONFIG_ESP_ROM_HAS_OUTPUT_PUTC_FUNC 1
+#define CONFIG_ESP_ROM_HAS_SUBOPTIMAL_NEWLIB_ON_MISALIGNED_MEMORY 1
+#define CONFIG_ESP_ROM_ECDSA_VERIFY_PATCH 1
+#define CONFIG_ESP_ROM_BOOTLOADER_OFFSET_FLASH 0x2000
+#define CONFIG_ESP_ROM_CACHE_WRITEBACK_NEEDS_SYNC_TWICE_MAP 1
+#define CONFIG_BOOT_ROM_LOG_ALWAYS_ON 1
+#define CONFIG_ESPTOOLPY_FLASHMODE_DIO 1
+#define CONFIG_ESPTOOLPY_FLASH_SAMPLE_MODE_STR 1
+#define CONFIG_ESPTOOLPY_FLASHMODE "dio"
+#define CONFIG_ESPTOOLPY_FLASHFREQ_80M 1
+#define CONFIG_ESPTOOLPY_FLASHFREQ_VAL 80
+#define CONFIG_ESPTOOLPY_FLASHFREQ "80m"
+#define CONFIG_ESPTOOLPY_FLASHSIZE_16MB 1
+#define CONFIG_ESPTOOLPY_FLASHSIZE "16MB"
+#define CONFIG_ESPTOOLPY_BEFORE_RESET 1
+#define CONFIG_ESPTOOLPY_BEFORE "default-reset"
+#define CONFIG_ESPTOOLPY_AFTER_RESET 1
+#define CONFIG_ESPTOOLPY_AFTER "hard-reset"
+#define CONFIG_ESPTOOLPY_MONITOR_BAUD 115200
+#define CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE 1
+#define CONFIG_PARTITION_TABLE_CUSTOM_FILENAME "partitions.csv"
+#define CONFIG_PARTITION_TABLE_FILENAME "partitions_singleapp_large.csv"
+#define CONFIG_PARTITION_TABLE_OFFSET 0x8000
+#define CONFIG_PARTITION_TABLE_MD5 1
+#define CONFIG_COMPILER_OPTIMIZATION_DEBUG 1
+#define CONFIG_COMPILER_OPTIMIZATION_ASSERTIONS_ENABLE 1
+#define CONFIG_COMPILER_FLOAT_LIB_FROM_RVFPLIB 1
+#define CONFIG_COMPILER_OPTIMIZATION_ASSERTION_LEVEL 2
+#define CONFIG_COMPILER_HIDE_PATHS_MACROS 1
+#define CONFIG_COMPILER_STACK_CHECK_MODE_NONE 1
+#define CONFIG_COMPILER_RT_LIB_GCCLIB 1
+#define CONFIG_COMPILER_RT_LIB_NAME "gcc"
+#define CONFIG_COMPILER_ORPHAN_SECTIONS_ERROR 1
+#define CONFIG_COMPILER_CXX_GLIBCXX_CONSTEXPR_NO_CHANGE 1
+#define CONFIG_BT_ENABLED 1
+#define CONFIG_BT_NIMBLE_ENABLED 1
+#define CONFIG_BT_CONTROLLER_DISABLED 1
+#define CONFIG_BT_ALARM_MAX_NUM 50
+#define CONFIG_BT_SMP_CRYPTO_STACK_TINYCRYPT 1
+#define CONFIG_BT_NIMBLE_MEM_ALLOC_MODE_INTERNAL 1
+#define CONFIG_BT_NIMBLE_PINNED_TO_CORE 0
+#define CONFIG_BT_NIMBLE_PINNED_TO_CORE_0 1
+#define CONFIG_BT_NIMBLE_HOST_TASK_STACK_SIZE 4096
+#define CONFIG_BT_NIMBLE_ROLE_PERIPHERAL 1
+#define CONFIG_BT_NIMBLE_ROLE_BROADCASTER 1
+#define CONFIG_BT_NIMBLE_ROLE_OBSERVER 1
+#define CONFIG_BT_NIMBLE_GATT_SERVER 1
+#define CONFIG_BT_NIMBLE_SECURITY_ENABLE 1
+#define CONFIG_BT_NIMBLE_SM_LEGACY 1
+#define CONFIG_BT_NIMBLE_SM_SC 1
+#define CONFIG_BT_NIMBLE_LL_CFG_FEAT_LE_ENCRYPTION 1
+#define CONFIG_BT_NIMBLE_SM_LVL 0
+#define CONFIG_BT_NIMBLE_SM_SC_ONLY 0
+#define CONFIG_BT_NIMBLE_MAX_BONDS 3
+#define CONFIG_BT_NIMBLE_RPA_TIMEOUT 900
+#define CONFIG_BT_NIMBLE_WHITELIST_SIZE 12
+#define CONFIG_BT_NIMBLE_HS_PVCY 1
+#define CONFIG_BT_NIMBLE_MAX_CONNECTIONS 3
+#define CONFIG_BT_NIMBLE_MAX_CCCDS 8
+#define CONFIG_BT_NIMBLE_HS_STOP_TIMEOUT_MS 2000
+#define CONFIG_BT_NIMBLE_USE_ESP_TIMER 1
+#define CONFIG_BT_NIMBLE_ATT_PREFERRED_MTU 256
+#define CONFIG_BT_NIMBLE_ATT_MAX_PREP_ENTRIES 64
+#define CONFIG_BT_NIMBLE_GATT_MAX_PROCS 4
+#define CONFIG_BT_NIMBLE_L2CAP_COC_MAX_NUM 0
+#define CONFIG_BT_NIMBLE_MSYS_1_BLOCK_COUNT 12
+#define CONFIG_BT_NIMBLE_MSYS_1_BLOCK_SIZE 256
+#define CONFIG_BT_NIMBLE_MSYS_2_BLOCK_COUNT 24
+#define CONFIG_BT_NIMBLE_MSYS_2_BLOCK_SIZE 320
+#define CONFIG_BT_NIMBLE_TRANSPORT_ACL_FROM_LL_COUNT 24
+#define CONFIG_BT_NIMBLE_TRANSPORT_ACL_SIZE 255
+#define CONFIG_BT_NIMBLE_TRANSPORT_EVT_SIZE 70
+#define CONFIG_BT_NIMBLE_TRANSPORT_EVT_COUNT 30
+#define CONFIG_BT_NIMBLE_TRANSPORT_EVT_DISCARD_COUNT 8
+#define CONFIG_BT_NIMBLE_L2CAP_COC_SDU_BUFF_COUNT 1
+#define CONFIG_BT_NIMBLE_PROX_SERVICE 1
+#define CONFIG_BT_NIMBLE_ANS_SERVICE 1
+#define CONFIG_BT_NIMBLE_CTS_SERVICE 1
+#define CONFIG_BT_NIMBLE_HTP_SERVICE 1
+#define CONFIG_BT_NIMBLE_IPSS_SERVICE 1
+#define CONFIG_BT_NIMBLE_TPS_SERVICE 1
+#define CONFIG_BT_NIMBLE_IAS_SERVICE 1
+#define CONFIG_BT_NIMBLE_LLS_SERVICE 1
+#define CONFIG_BT_NIMBLE_SPS_SERVICE 1
+#define CONFIG_BT_NIMBLE_HR_SERVICE 1
+#define CONFIG_BT_NIMBLE_BAS_SERVICE 1
+#define CONFIG_BT_NIMBLE_DIS_SERVICE 1
+#define CONFIG_BT_NIMBLE_GAP_SERVICE 1
+#define CONFIG_BT_NIMBLE_SVC_GAP_DEVICE_NAME "nimble"
+#define CONFIG_BT_NIMBLE_GAP_DEVICE_NAME_MAX_LEN 31
+#define CONFIG_BT_NIMBLE_SVC_GAP_APPEARANCE 0x0
+#define CONFIG_BT_NIMBLE_SVC_GAP_NAME_WRITE_PERM 0
+#define CONFIG_BT_NIMBLE_SVC_GAP_NAME_WRITE_PERM_ENC 0
+#define CONFIG_BT_NIMBLE_SVC_GAP_NAME_WRITE_PERM_AUTHEN 0
+#define CONFIG_BT_NIMBLE_SVC_GAP_NAME_WRITE_PERM_AUTHOR 0
+#define CONFIG_BT_NIMBLE_SVC_GAP_CAR_CHAR_NOT_SUPP 1
+#define CONFIG_BT_NIMBLE_SVC_GAP_CENT_ADDR_RESOLUTION -1
+#define CONFIG_BT_NIMBLE_SVC_GAP_APPEAR_WRITE_PERM 0
+#define CONFIG_BT_NIMBLE_SVC_GAP_APPEAR_WRITE_PERM_ENC 0
+#define CONFIG_BT_NIMBLE_SVC_GAP_APPEAR_WRITE_PERM_ATHN 0
+#define CONFIG_BT_NIMBLE_SVC_GAP_APPEAR_WRITE_PERM_ATHR 0
+#define CONFIG_BT_NIMBLE_SVC_GAP_PPCP_MAX_CONN_INTERVAL 0
+#define CONFIG_BT_NIMBLE_SVC_GAP_PPCP_MIN_CONN_INTERVAL 0
+#define CONFIG_BT_NIMBLE_SVC_GAP_PPCP_SLAVE_LATENCY 0
+#define CONFIG_BT_NIMBLE_SVC_GAP_PPCP_SUPERVISION_TMO 0
+#define CONFIG_BT_NIMBLE_EATT_CHAN_NUM 0
+#define CONFIG_BT_NIMBLE_DTM_MODE_TEST 1
+#define CONFIG_BT_NIMBLE_MEM_OPTIMIZATION 1
+#define CONFIG_BT_NIMBLE_STATIC_TO_DYNAMIC 1
+#define CONFIG_BT_NIMBLE_SM_SIGN_CNT 1
+#define CONFIG_BT_NIMBLE_CPFD_CAFD 1
+#define CONFIG_BT_NIMBLE_RECONFIG_MTU 1
+#define CONFIG_UART_HW_FLOWCTRL_DISABLE 1
+#define CONFIG_BT_NIMBLE_HCI_UART_FLOW_CTRL 0
+#define CONFIG_BT_NIMBLE_HCI_UART_RTS_PIN 19
+#define CONFIG_BT_NIMBLE_HCI_UART_CTS_PIN 23
+#define CONFIG_BT_NIMBLE_LOG_LEVEL_INFO 1
+#define CONFIG_BT_NIMBLE_LOG_LEVEL 1
+#define CONFIG_BT_NIMBLE_PRINT_ERR_NAME 1
+#define CONFIG_BT_NIMBLE_CHK_HOST_STATUS 1
+#define CONFIG_BT_NIMBLE_UTIL_API 1
+#define CONFIG_BT_NIMBLE_EXTRA_ADV_FIELDS 1
+#define CONFIG_EFUSE_MAX_BLK_LEN 256
+#define CONFIG_ESP_TLS_USING_MBEDTLS 1
+#define CONFIG_ESP_TLS_USE_DS_PERIPHERAL 1
+#define CONFIG_ESP_TLS_DYN_BUF_STRATEGY_SUPPORTED 1
+#define CONFIG_ESP_ERR_TO_NAME_LOOKUP 1
+#define CONFIG_ANA_CMPR_ISR_HANDLER_IN_IRAM 1
+#define CONFIG_ANA_CMPR_OBJ_CACHE_SAFE 1
+#define CONFIG_GDMA_CTRL_FUNC_IN_IRAM 1
+#define CONFIG_GDMA_ISR_HANDLER_IN_IRAM 1
+#define CONFIG_GDMA_OBJ_DRAM_SAFE 1
+#define CONFIG_GPTIMER_ISR_HANDLER_IN_IRAM 1
+#define CONFIG_GPTIMER_OBJ_CACHE_SAFE 1
+#define CONFIG_I2C_MASTER_ISR_HANDLER_IN_IRAM 1
+#define CONFIG_MCPWM_ISR_HANDLER_IN_IRAM 1
+#define CONFIG_MCPWM_OBJ_CACHE_SAFE 1
+#define CONFIG_PARLIO_TX_ISR_HANDLER_IN_IRAM 1
+#define CONFIG_PARLIO_RX_ISR_HANDLER_IN_IRAM 1
+#define CONFIG_PARLIO_OBJ_CACHE_SAFE 1
+#define CONFIG_RMT_ENCODER_FUNC_IN_IRAM 1
+#define CONFIG_RMT_TX_ISR_HANDLER_IN_IRAM 1
+#define CONFIG_RMT_RX_ISR_HANDLER_IN_IRAM 1
+#define CONFIG_RMT_OBJ_CACHE_SAFE 1
+#define CONFIG_SPI_MASTER_ISR_IN_IRAM 1
+#define CONFIG_SPI_SLAVE_ISR_IN_IRAM 1
+#define CONFIG_USJ_ENABLE_USB_SERIAL_JTAG 1
+#define CONFIG_ETH_ENABLED 1
+#define CONFIG_ETH_USE_ESP32_EMAC 1
+#define CONFIG_ETH_DMA_BUFFER_SIZE 512
+#define CONFIG_ETH_DMA_RX_BUFFER_NUM 20
+#define CONFIG_ETH_DMA_TX_BUFFER_NUM 10
+#define CONFIG_ETH_USE_SPI_ETHERNET 1
+#define CONFIG_ESP_EVENT_POST_FROM_ISR 1
+#define CONFIG_ESP_EVENT_POST_FROM_IRAM_ISR 1
+#define CONFIG_ESP_GDBSTUB_ENABLED 1
+#define CONFIG_ESP_GDBSTUB_SUPPORT_TASKS 1
+#define CONFIG_ESP_GDBSTUB_MAX_TASKS 32
+#define CONFIG_ESPHID_TASK_SIZE_BT 2048
+#define CONFIG_ESPHID_TASK_SIZE_BLE 4096
+#define CONFIG_ESP_HTTP_CLIENT_ENABLE_HTTPS 1
+#define CONFIG_ESP_HTTP_CLIENT_EVENT_POST_TIMEOUT 2000
+#define CONFIG_HTTPD_MAX_REQ_HDR_LEN 1024
+#define CONFIG_HTTPD_MAX_URI_LEN 512
+#define CONFIG_HTTPD_ERR_RESP_NO_DELAY 1
+#define CONFIG_HTTPD_PURGE_BUF_LEN 32
+#define CONFIG_HTTPD_SERVER_EVENT_POST_TIMEOUT 2000
+#define CONFIG_ESP_HTTPS_OTA_EVENT_POST_TIMEOUT 2000
+#define CONFIG_ESP_HTTPS_SERVER_EVENT_POST_TIMEOUT 2000
+#define CONFIG_ESP_HW_SUPPORT_FUNC_IN_IRAM 1
+#define CONFIG_ESP32P4_SELECTS_REV_LESS_V3 1
+#define CONFIG_ESP32P4_REV_MIN_100 1
+#define CONFIG_ESP32P4_REV_MIN_FULL 100
+#define CONFIG_ESP_REV_MIN_FULL 100
+#define CONFIG_ESP32P4_REV_MAX_FULL 199
+#define CONFIG_ESP_REV_MAX_FULL 199
+#define CONFIG_ESP_EFUSE_BLOCK_REV_MIN_FULL 0
+#define CONFIG_ESP_EFUSE_BLOCK_REV_MAX_FULL 199
+#define CONFIG_ESP_MAC_ADDR_UNIVERSE_ETH 1
+#define CONFIG_ESP_MAC_UNIVERSAL_MAC_ADDRESSES_ONE 1
+#define CONFIG_ESP_MAC_UNIVERSAL_MAC_ADDRESSES 1
+#define CONFIG_ESP32P4_UNIVERSAL_MAC_ADDRESSES_ONE 1
+#define CONFIG_ESP32P4_UNIVERSAL_MAC_ADDRESSES 1
+#define CONFIG_ESP_SLEEP_FLASH_LEAKAGE_WORKAROUND 1
+#define CONFIG_ESP_SLEEP_PSRAM_LEAKAGE_WORKAROUND 1
+#define CONFIG_ESP_SLEEP_GPIO_RESET_WORKAROUND 1
+#define CONFIG_ESP_SLEEP_WAIT_FLASH_READY_EXTRA_DELAY 0
+#define CONFIG_ESP_SLEEP_GPIO_ENABLE_INTERNAL_RESISTORS 1
+#define CONFIG_RTC_CLK_SRC_INT_RC 1
+#define CONFIG_RTC_CLK_CAL_CYCLES 1024
+#define CONFIG_RTC_FAST_CLK_SRC_RC_FAST 1
+#define CONFIG_RTC_CLK_FUNC_IN_IRAM 1
+#define CONFIG_RTC_TIME_FUNC_IN_IRAM 1
+#define CONFIG_ESP_PERIPH_CTRL_FUNC_IN_IRAM 1
+#define CONFIG_ESP_REGI2C_CTRL_FUNC_IN_IRAM 1
+#define CONFIG_XTAL_FREQ_40 1
+#define CONFIG_XTAL_FREQ 40
+#define CONFIG_ESP_SLEEP_DCM_VSET_VAL_IN_SLEEP 14
+#define CONFIG_ESP_LDO_RESERVE_SPI_NOR_FLASH 1
+#define CONFIG_ESP_LDO_CHAN_SPI_NOR_FLASH_DOMAIN 1
+#define CONFIG_ESP_LDO_VOLTAGE_SPI_NOR_FLASH_3300_MV 1
+#define CONFIG_ESP_LDO_VOLTAGE_SPI_NOR_FLASH_DOMAIN 3300
+#define CONFIG_ESP_LDO_RESERVE_PSRAM 1
+#define CONFIG_ESP_LDO_CHAN_PSRAM_DOMAIN 2
+#define CONFIG_ESP_LDO_VOLTAGE_PSRAM_1800_MV 1
+#define CONFIG_ESP_LDO_VOLTAGE_PSRAM_DOMAIN 1800
+#define CONFIG_ESP_BROWNOUT_DET 1
+#define CONFIG_ESP_BROWNOUT_DET_LVL_SEL_7 1
+#define CONFIG_ESP_BROWNOUT_DET_LVL 7
+#define CONFIG_ESP_BROWNOUT_USE_INTR 1
+#define CONFIG_ESP_SPI_BUS_LOCK_ISR_FUNCS_IN_IRAM 1
+#define CONFIG_ESP_ENABLE_PVT 1
+#define CONFIG_ESP_INTR_IN_IRAM 1
+#define CONFIG_P4_REV3_MSPI_WORKAROUND_SIZE 0x0
+#define CONFIG_LCD_DSI_ISR_HANDLER_IN_IRAM 1
+#define CONFIG_LCD_DSI_OBJ_FORCE_INTERNAL 1
+#define CONFIG_LIBC_PICOLIBC 1
+#define CONFIG_LIBC_PICOLIBC_NEWLIB_COMPATIBILITY 1
+#define CONFIG_LIBC_MISC_IN_IRAM 1
+#define CONFIG_LIBC_LOCKS_PLACE_IN_IRAM 1
+#define CONFIG_LIBC_STDOUT_LINE_ENDING_CRLF 1
+#define CONFIG_LIBC_STDIN_LINE_ENDING_CR 1
+#define CONFIG_LIBC_TIME_SYSCALL_USE_RTC_HRT 1
+#define CONFIG_LIBC_OPTIMIZED_MISALIGNED_ACCESS 1
+#define CONFIG_LIBC_ASSERT_BUFFER_SIZE 200
+#define CONFIG_ESP_NETIF_LOST_IP_TIMER_ENABLE 1
+#define CONFIG_ESP_NETIF_IP_LOST_TIMER_INTERVAL 120
+#define CONFIG_ESP_NETIF_TCPIP_LWIP 1
+#define CONFIG_ESP_NETIF_USES_TCPIP_WITH_BSD_API 1
+#define CONFIG_ESP_NETIF_REPORT_DATA_TRAFFIC 1
+#define CONFIG_ESP_NETIF_RECEIVE_REPORT_ERRORS 1
+#define CONFIG_PM_SLEEP_FUNC_IN_IRAM 1
+#define CONFIG_PM_SLP_IRAM_OPT 1
+#define CONFIG_PM_SLP_DEFAULT_PARAMS_OPT 1
+#define CONFIG_SPIRAM 1
+#define CONFIG_SPIRAM_MODE_HEX 1
+#define CONFIG_SPIRAM_SPEED_200M 1
+#define CONFIG_SPIRAM_SPEED 200
+#define CONFIG_SPIRAM_BOOT_HW_INIT 1
+#define CONFIG_SPIRAM_BOOT_INIT 1
+#define CONFIG_SPIRAM_PRE_CONFIGURE_MEMORY_PROTECTION 1
+#define CONFIG_SPIRAM_USE_MALLOC 1
+#define CONFIG_SPIRAM_MEMTEST 1
+#define CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL 16384
+#define CONFIG_SPIRAM_MALLOC_RESERVE_INTERNAL 32768
+#define CONFIG_ESP_ROM_PRINT_IN_IRAM 1
+#define CONFIG_ESP_CONSOLE_UART_DEFAULT 1
+#define CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG 1
+#define CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG_ENABLED 1
+#define CONFIG_ESP_CONSOLE_UART 1
+#define CONFIG_ESP_CONSOLE_UART_NUM 0
+#define CONFIG_ESP_CONSOLE_ROM_SERIAL_PORT_NUM 0
+#define CONFIG_ESP_CONSOLE_UART_BAUDRATE 115200
+#define CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_360 1
+#define CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ 360
+#define CONFIG_CACHE_L2_CACHE_128KB 1
+#define CONFIG_CACHE_L2_CACHE_SIZE 0x20000
+#define CONFIG_CACHE_L2_CACHE_LINE_64B 1
+#define CONFIG_CACHE_L2_CACHE_LINE_SIZE 64
+#define CONFIG_CACHE_L1_CACHE_LINE_SIZE 64
+#define CONFIG_ESP_SYSTEM_IN_IRAM 1
+#define CONFIG_ESP_SYSTEM_PANIC_PRINT_REBOOT 1
+#define CONFIG_ESP_SYSTEM_PANIC_REBOOT_DELAY_SECONDS 0
+#define CONFIG_ESP_SYSTEM_RTC_FAST_MEM_AS_HEAP_DEPCHECK 1
+#define CONFIG_ESP_SYSTEM_ALLOW_RTC_FAST_MEM_AS_HEAP 1
+#define CONFIG_ESP_SYSTEM_NO_BACKTRACE 1
+#define CONFIG_ESP_SYSTEM_MEMPROT 1
+#define CONFIG_ESP_SYSTEM_MEMPROT_PMP 1
+#define CONFIG_ESP_SYSTEM_EVENT_QUEUE_SIZE 32
+#define CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE 2304
+#define CONFIG_ESP_MAIN_TASK_STACK_SIZE 6144
+#define CONFIG_ESP_MAIN_TASK_AFFINITY_CPU0 1
+#define CONFIG_ESP_MAIN_TASK_AFFINITY 0x0
+#define CONFIG_ESP_MINIMAL_SHARED_STACK_SIZE 2048
+#define CONFIG_ESP_INT_WDT 1
+#define CONFIG_ESP_INT_WDT_TIMEOUT_MS 300
+#define CONFIG_ESP_INT_WDT_CHECK_CPU1 1
+#define CONFIG_ESP_TASK_WDT_EN 1
+#define CONFIG_ESP_TASK_WDT_INIT 1
+#define CONFIG_ESP_TASK_WDT_TIMEOUT_S 5
+#define CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0 1
+#define CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1 1
+#define CONFIG_ESP_DEBUG_OCDAWARE 1
+#define CONFIG_ESP_SYSTEM_CHECK_INT_LEVEL_4 1
+#define CONFIG_ESP_SYSTEM_HW_STACK_GUARD 1
+#define CONFIG_ESP_SYSTEM_HW_PC_RECORD 1
+#define CONFIG_ESP_IPC_ENABLE 1
+#define CONFIG_ESP_IPC_TASK_STACK_SIZE 1024
+#define CONFIG_ESP_IPC_USES_CALLERS_PRIORITY 1
+#define CONFIG_ESP_IPC_ISR_ENABLE 1
+#define CONFIG_ESP_TIMER_IN_IRAM 1
+#define CONFIG_ESP_TIME_FUNCS_USE_RTC_TIMER 1
+#define CONFIG_ESP_TIME_FUNCS_USE_ESP_TIMER 1
+#define CONFIG_ESP_TIMER_TASK_STACK_SIZE 3584
+#define CONFIG_ESP_TIMER_INTERRUPT_LEVEL 1
+#define CONFIG_ESP_TIMER_TASK_AFFINITY 0x0
+#define CONFIG_ESP_TIMER_TASK_AFFINITY_CPU0 1
+#define CONFIG_ESP_TIMER_ISR_AFFINITY_CPU0 1
+#define CONFIG_ESP_TIMER_IMPL_SYSTIMER 1
+#define CONFIG_ESP_TRACE_LIB_NONE 1
+#define CONFIG_ESP_TRACE_LIB_NAME "none"
+#define CONFIG_ESP_TRACE_TRANSPORT_NONE 1
+#define CONFIG_ESP_TRACE_TRANSPORT_NAME "none"
+#define CONFIG_ESP_WIFI_STATIC_RX_BUFFER_NUM 10
+#define CONFIG_ESP_WIFI_DYNAMIC_RX_BUFFER_NUM 32
+#define CONFIG_ESP_WIFI_TX_BUFFER_TYPE 1
+#define CONFIG_ESP_WIFI_DYNAMIC_TX_BUFFER_NUM 32
+#define CONFIG_ESP_WIFI_DYNAMIC_RX_MGMT_BUF 0
+#define CONFIG_ESP_WIFI_RX_MGMT_BUF_NUM_DEF 5
+#define CONFIG_ESP_WIFI_AMPDU_TX_ENABLED 1
+#define CONFIG_ESP_WIFI_TX_BA_WIN 6
+#define CONFIG_ESP_WIFI_AMPDU_RX_ENABLED 1
+#define CONFIG_ESP_WIFI_RX_BA_WIN 6
+#define CONFIG_ESP_WIFI_NVS_ENABLED 1
+#define CONFIG_ESP_WIFI_SOFTAP_BEACON_MAX_LEN 752
+#define CONFIG_ESP_WIFI_MGMT_SBUF_NUM 32
+#define CONFIG_ESP_WIFI_IRAM_OPT 1
+#define CONFIG_ESP_WIFI_EXTRA_IRAM_OPT 1
+#define CONFIG_ESP_WIFI_RX_IRAM_OPT 1
+#define CONFIG_ESP_WIFI_ENABLE_WPA3_SAE 1
+#define CONFIG_ESP_WIFI_ENABLE_SAE_H2E 1
+#define CONFIG_ESP_WIFI_ENABLE_SAE_PK 1
+#define CONFIG_ESP_WIFI_SOFTAP_SAE_SUPPORT 1
+#define CONFIG_ESP_WIFI_ENABLE_WPA3_OWE_STA 1
+#define CONFIG_ESP_WIFI_WPA3_COMPATIBLE_SUPPORT 1
+#define CONFIG_ESP_WIFI_SLP_IRAM_OPT 1
+#define CONFIG_ESP_WIFI_SLP_DEFAULT_MIN_ACTIVE_TIME 50
+#define CONFIG_ESP_WIFI_BSS_MAX_IDLE_SUPPORT 1
+#define CONFIG_ESP_WIFI_SLP_DEFAULT_MAX_ACTIVE_TIME 10
+#define CONFIG_ESP_WIFI_SLP_DEFAULT_WAIT_BROADCAST_DATA_TIME 15
+#define CONFIG_ESP_WIFI_STA_DISCONNECTED_PM_ENABLE 1
+#define CONFIG_ESP_WIFI_GMAC_SUPPORT 1
+#define CONFIG_ESP_WIFI_SOFTAP_SUPPORT 1
+#define CONFIG_ESP_WIFI_ESPNOW_MAX_ENCRYPT_NUM 7
+#define CONFIG_ESP_WIFI_MBEDTLS_CRYPTO 1
+#define CONFIG_ESP_WIFI_MBEDTLS_TLS_CLIENT 1
+#define CONFIG_ESP_WIFI_TX_HETB_QUEUE_NUM 3
+#define CONFIG_ESP_WIFI_ENTERPRISE_SUPPORT 1
+#define CONFIG_ESP_COREDUMP_ENABLE_TO_NONE 1
+#define CONFIG_FATFS_VOLUME_COUNT 2
+#define CONFIG_FATFS_LFN_HEAP 1
+#define CONFIG_FATFS_SECTOR_4096 1
+#define CONFIG_FATFS_CODEPAGE_437 1
+#define CONFIG_FATFS_CODEPAGE 437
+#define CONFIG_FATFS_MAX_LFN 255
+#define CONFIG_FATFS_API_ENCODING_ANSI_OEM 1
+#define CONFIG_FATFS_FS_LOCK 0
+#define CONFIG_FATFS_TIMEOUT_MS 10000
+#define CONFIG_FATFS_PER_FILE_CACHE 1
+#define CONFIG_FATFS_ALLOC_PREFER_EXTRAM 1
+#define CONFIG_FATFS_USE_STRFUNC_NONE 1
+#define CONFIG_FATFS_VFS_FSTAT_BLKSIZE 0
+#define CONFIG_FATFS_LINK_LOCK 1
+#define CONFIG_FATFS_USE_DYN_BUFFERS 1
+#define CONFIG_FATFS_DONT_TRUST_FREE_CLUSTER_CNT 0
+#define CONFIG_FATFS_DONT_TRUST_LAST_ALLOC 0
+#define CONFIG_FREERTOS_HZ 1000
+#define CONFIG_FREERTOS_CHECK_STACKOVERFLOW_CANARY 1
+#define CONFIG_FREERTOS_THREAD_LOCAL_STORAGE_POINTERS 1
+#define CONFIG_FREERTOS_IDLE_TASK_STACKSIZE 1536
+#define CONFIG_FREERTOS_MAX_TASK_NAME_LEN 16
+#define CONFIG_FREERTOS_USE_TIMERS 1
+#define CONFIG_FREERTOS_TIMER_SERVICE_TASK_NAME "Tmr Svc"
+#define CONFIG_FREERTOS_TIMER_TASK_NO_AFFINITY 1
+#define CONFIG_FREERTOS_TIMER_SERVICE_TASK_CORE_AFFINITY 0x7FFFFFFF
+#define CONFIG_FREERTOS_TIMER_TASK_PRIORITY 1
+#define CONFIG_FREERTOS_TIMER_TASK_STACK_DEPTH 2048
+#define CONFIG_FREERTOS_TIMER_QUEUE_LENGTH 10
+#define CONFIG_FREERTOS_QUEUE_REGISTRY_SIZE 0
+#define CONFIG_FREERTOS_TASK_NOTIFICATION_ARRAY_ENTRIES 1
+#define CONFIG_FREERTOS_TASK_FUNCTION_WRAPPER 1
+#define CONFIG_FREERTOS_TLSP_DELETION_CALLBACKS 1
+#define CONFIG_FREERTOS_CHECK_MUTEX_GIVEN_BY_OWNER 1
+#define CONFIG_FREERTOS_ISR_STACKSIZE 1536
+#define CONFIG_FREERTOS_INTERRUPT_BACKTRACE 1
+#define CONFIG_FREERTOS_TICK_SUPPORT_SYSTIMER 1
+#define CONFIG_FREERTOS_CORETIMER_SYSTIMER_LVL1 1
+#define CONFIG_FREERTOS_SYSTICK_USES_SYSTIMER 1
+#define CONFIG_FREERTOS_TASK_CREATE_ALLOW_EXT_MEM 1
+#define CONFIG_FREERTOS_PORT 1
+#define CONFIG_FREERTOS_NO_AFFINITY 0x7FFFFFFF
+#define CONFIG_FREERTOS_SUPPORT_STATIC_ALLOCATION 1
+#define CONFIG_FREERTOS_DEBUG_OCDAWARE 1
+#define CONFIG_FREERTOS_NUMBER_OF_CORES 2
+#define CONFIG_HAL_ASSERTION_EQUALS_SYSTEM 1
+#define CONFIG_HAL_DEFAULT_ASSERTION_LEVEL 2
+#define CONFIG_HAL_SYSTIMER_USE_ROM_IMPL 1
+#define CONFIG_HAL_WDT_USE_ROM_IMPL 1
+#define CONFIG_HAL_GPIO_USE_ROM_IMPL 1
+#define CONFIG_HEAP_POISONING_DISABLED 1
+#define CONFIG_HEAP_TRACING_OFF 1
+#define CONFIG_LOG_VERSION_1 1
+#define CONFIG_LOG_VERSION 1
+#define CONFIG_LOG_DEFAULT_LEVEL_INFO 1
+#define CONFIG_LOG_DEFAULT_LEVEL 3
+#define CONFIG_LOG_MAXIMUM_EQUALS_DEFAULT 1
+#define CONFIG_LOG_MAXIMUM_LEVEL 3
+#define CONFIG_LOG_DYNAMIC_LEVEL_CONTROL 1
+#define CONFIG_LOG_TAG_LEVEL_IMPL_CACHE_AND_LINKED_LIST 1
+#define CONFIG_LOG_TAG_LEVEL_CACHE_BINARY_MIN_HEAP 1
+#define CONFIG_LOG_TAG_LEVEL_IMPL_CACHE_SIZE 31
+#define CONFIG_LOG_TIMESTAMP_SOURCE_RTOS 1
+#define CONFIG_LOG_MODE_TEXT_EN 1
+#define CONFIG_LOG_MODE_TEXT 1
+#define CONFIG_LOG_IN_IRAM 1
+#define CONFIG_LWIP_ENABLE 1
+#define CONFIG_LWIP_LOCAL_HOSTNAME "espressif"
+#define CONFIG_LWIP_TCPIP_TASK_PRIO 18
+#define CONFIG_LWIP_DNS_SUPPORT_MDNS_QUERIES 1
+#define CONFIG_LWIP_TIMERS_ONDEMAND 1
+#define CONFIG_LWIP_ND6 1
+#define CONFIG_LWIP_MAX_SOCKETS 10
+#define CONFIG_LWIP_SO_REUSE 1
+#define CONFIG_LWIP_SO_REUSE_RXTOALL 1
+#define CONFIG_LWIP_IP_DEFAULT_TTL 64
+#define CONFIG_LWIP_IP4_FRAG 1
+#define CONFIG_LWIP_IP6_FRAG 1
+#define CONFIG_LWIP_IP_REASS_MAX_PBUFS 10
+#define CONFIG_LWIP_IPV6_DUP_DETECT_ATTEMPTS 1
+#define CONFIG_LWIP_ESP_GRATUITOUS_ARP 1
+#define CONFIG_LWIP_GARP_TMR_INTERVAL 60
+#define CONFIG_LWIP_ESP_MLDV6_REPORT 1
+#define CONFIG_LWIP_MLDV6_TMR_INTERVAL 40
+#define CONFIG_LWIP_TCPIP_RECVMBOX_SIZE 32
+#define CONFIG_LWIP_DHCP_DOES_ARP_CHECK 1
+#define CONFIG_LWIP_DHCP_DISABLE_VENDOR_CLASS_ID 1
+#define CONFIG_LWIP_DHCP_OPTIONS_LEN 69
+#define CONFIG_LWIP_NUM_NETIF_CLIENT_DATA 0
+#define CONFIG_LWIP_DHCP_COARSE_TIMER_SECS 1
+#define CONFIG_LWIP_DHCPS 1
+#define CONFIG_LWIP_DHCPS_REPORT_CLIENT_HOSTNAME 1
+#define CONFIG_LWIP_DHCPS_LEASE_UNIT 60
+#define CONFIG_LWIP_DHCPS_MAX_STATION_NUM 8
+#define CONFIG_LWIP_DHCPS_MAX_HOSTNAME_LEN 64
+#define CONFIG_LWIP_DHCPS_STATIC_ENTRIES 1
+#define CONFIG_LWIP_IPV4 1
+#define CONFIG_LWIP_IPV6 1
+#define CONFIG_LWIP_IPV6_NUM_ADDRESSES 3
+#define CONFIG_LWIP_NETIF_LOOPBACK 1
+#define CONFIG_LWIP_LOOPBACK_MAX_PBUFS 8
+#define CONFIG_LWIP_MAX_ACTIVE_TCP 16
+#define CONFIG_LWIP_MAX_LISTENING_TCP 16
+#define CONFIG_LWIP_TCP_HIGH_SPEED_RETRANSMISSION 1
+#define CONFIG_LWIP_TCP_MAXRTX 12
+#define CONFIG_LWIP_TCP_SYNMAXRTX 12
+#define CONFIG_LWIP_TCP_MSS 1440
+#define CONFIG_LWIP_TCP_TMR_INTERVAL 250
+#define CONFIG_LWIP_TCP_MSL 60000
+#define CONFIG_LWIP_TCP_FIN_WAIT_TIMEOUT 20000
+#define CONFIG_LWIP_TCP_SND_BUF_DEFAULT 5760
+#define CONFIG_LWIP_TCP_WND_DEFAULT 5760
+#define CONFIG_LWIP_TCP_RECVMBOX_SIZE 6
+#define CONFIG_LWIP_TCP_ACCEPTMBOX_SIZE 6
+#define CONFIG_LWIP_TCP_QUEUE_OOSEQ 1
+#define CONFIG_LWIP_TCP_OOSEQ_TIMEOUT 6
+#define CONFIG_LWIP_TCP_OOSEQ_MAX_PBUFS 4
+#define CONFIG_LWIP_TCP_OVERSIZE_MSS 1
+#define CONFIG_LWIP_TCP_RTO_TIME 1500
+#define CONFIG_LWIP_MAX_UDP_PCBS 16
+#define CONFIG_LWIP_UDP_RECVMBOX_SIZE 6
+#define CONFIG_LWIP_CHECKSUM_CHECK_ICMP 1
+#define CONFIG_LWIP_TCPIP_TASK_STACK_SIZE 3072
+#define CONFIG_LWIP_TCPIP_TASK_AFFINITY_NO_AFFINITY 1
+#define CONFIG_LWIP_TCPIP_TASK_AFFINITY 0x7FFFFFFF
+#define CONFIG_LWIP_IPV6_MEMP_NUM_ND6_QUEUE 3
+#define CONFIG_LWIP_IPV6_ND6_NUM_NEIGHBORS 5
+#define CONFIG_LWIP_IPV6_ND6_NUM_PREFIXES 5
+#define CONFIG_LWIP_IPV6_ND6_NUM_ROUTERS 3
+#define CONFIG_LWIP_IPV6_ND6_NUM_DESTINATIONS 10
+#define CONFIG_LWIP_ICMP 1
+#define CONFIG_LWIP_MAX_RAW_PCBS 16
+#define CONFIG_LWIP_SNTP_MAX_SERVERS 1
+#define CONFIG_LWIP_SNTP_UPDATE_DELAY 3600000
+#define CONFIG_LWIP_SNTP_STARTUP_DELAY 1
+#define CONFIG_LWIP_SNTP_MAXIMUM_STARTUP_DELAY 5000
+#define CONFIG_LWIP_DNS_MAX_HOST_IP 1
+#define CONFIG_LWIP_DNS_MAX_SERVERS 3
+#define CONFIG_LWIP_BRIDGEIF_MAX_PORTS 7
+#define CONFIG_LWIP_ESP_LWIP_ASSERT 1
+#define CONFIG_LWIP_HOOK_TCP_ISN_DEFAULT 1
+#define CONFIG_LWIP_HOOK_IP6_ROUTE_NONE 1
+#define CONFIG_LWIP_HOOK_ND6_GET_GW_NONE 1
+#define CONFIG_LWIP_HOOK_IP6_SELECT_SRC_ADDR_NONE 1
+#define CONFIG_LWIP_HOOK_DHCP_EXTRA_OPTION_NONE 1
+#define CONFIG_LWIP_HOOK_NETCONN_EXT_RESOLVE_NONE 1
+#define CONFIG_LWIP_HOOK_DNS_EXT_RESOLVE_NONE 1
+#define CONFIG_LWIP_HOOK_IP6_INPUT_DEFAULT 1
+#define CONFIG_MBEDTLS_VER_4_X_SUPPORT 1
+#define CONFIG_MBEDTLS_COMPILER_OPTIMIZATION_SIZE 1
+#define CONFIG_MBEDTLS_FS_IO 1
+#define CONFIG_MBEDTLS_THREADING_C 1
+#define CONFIG_MBEDTLS_THREADING_PTHREAD 1
+#define CONFIG_MBEDTLS_ERROR_STRINGS 1
+#define CONFIG_MBEDTLS_VERSION_C 1
+#define CONFIG_MBEDTLS_HAVE_TIME 1
+#define CONFIG_MBEDTLS_INTERNAL_MEM_ALLOC 1
+#define CONFIG_MBEDTLS_ASYMMETRIC_CONTENT_LEN 1
+#define CONFIG_MBEDTLS_SSL_IN_CONTENT_LEN 16384
+#define CONFIG_MBEDTLS_SSL_OUT_CONTENT_LEN 4096
+#define CONFIG_MBEDTLS_SELF_TEST 1
+#define CONFIG_MBEDTLS_X509_USE_C 1
+#define CONFIG_MBEDTLS_PEM_PARSE_C 1
+#define CONFIG_MBEDTLS_PEM_WRITE_C 1
+#define CONFIG_MBEDTLS_PK_C 1
+#define CONFIG_MBEDTLS_PK_PARSE_C 1
+#define CONFIG_MBEDTLS_PK_WRITE_C 1
+#define CONFIG_MBEDTLS_X509_CRL_PARSE_C 1
+#define CONFIG_MBEDTLS_X509_CRT_PARSE_C 1
+#define CONFIG_MBEDTLS_X509_CSR_PARSE_C 1
+#define CONFIG_MBEDTLS_X509_RSASSA_PSS_SUPPORT 1
+#define CONFIG_MBEDTLS_ASN1_PARSE_C 1
+#define CONFIG_MBEDTLS_ASN1_WRITE_C 1
+#define CONFIG_MBEDTLS_CERTIFICATE_BUNDLE 1
+#define CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_FULL 1
+#define CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_MAX_CERTS 200
+#define CONFIG_MBEDTLS_TLS_ENABLED 1
+#define CONFIG_MBEDTLS_SSL_PROTO_TLS1_2 1
+#define CONFIG_MBEDTLS_TLS_SERVER 1
+#define CONFIG_MBEDTLS_TLS_CLIENT 1
+#define CONFIG_MBEDTLS_TLS_SERVER_AND_CLIENT 1
+#define CONFIG_MBEDTLS_SSL_CACHE_C 1
+#define CONFIG_MBEDTLS_SSL_ALL_ALERT_MESSAGES 1
+#define CONFIG_MBEDTLS_KEY_EXCHANGE_RSA 1
+#define CONFIG_MBEDTLS_KEY_EXCHANGE_ELLIPTIC_CURVE 1
+#define CONFIG_MBEDTLS_KEY_EXCHANGE_ECDHE_RSA 1
+#define CONFIG_MBEDTLS_KEY_EXCHANGE_ECDHE_ECDSA 1
+#define CONFIG_MBEDTLS_SSL_SERVER_NAME_INDICATION 1
+#define CONFIG_MBEDTLS_SSL_ALPN 1
+#define CONFIG_MBEDTLS_SSL_MAX_FRAGMENT_LENGTH 1
+#define CONFIG_MBEDTLS_SSL_RENEGOTIATION 1
+#define CONFIG_MBEDTLS_CLIENT_SSL_SESSION_TICKETS 1
+#define CONFIG_MBEDTLS_SERVER_SSL_SESSION_TICKETS 1
+#define CONFIG_MBEDTLS_AES_C 1
+#define CONFIG_MBEDTLS_CCM_C 1
+#define CONFIG_MBEDTLS_CIPHER_MODE_CBC 1
+#define CONFIG_MBEDTLS_CIPHER_MODE_CFB 1
+#define CONFIG_MBEDTLS_CIPHER_MODE_CTR 1
+#define CONFIG_MBEDTLS_CIPHER_MODE_OFB 1
+#define CONFIG_MBEDTLS_CIPHER_MODE_XTS 1
+#define CONFIG_MBEDTLS_GCM_C 1
+#define CONFIG_MBEDTLS_AES_ROM_TABLES 1
+#define CONFIG_MBEDTLS_CMAC_C 1
+#define CONFIG_MBEDTLS_RSA_C 1
+#define CONFIG_MBEDTLS_ECP_C 1
+#define CONFIG_MBEDTLS_ECP_DP_SECP256R1_ENABLED 1
+#define CONFIG_MBEDTLS_ECP_DP_SECP384R1_ENABLED 1
+#define CONFIG_MBEDTLS_ECP_DP_SECP521R1_ENABLED 1
+#define CONFIG_MBEDTLS_ECP_DP_SECP256K1_ENABLED 1
+#define CONFIG_MBEDTLS_ECP_DP_BP256R1_ENABLED 1
+#define CONFIG_MBEDTLS_ECP_DP_BP384R1_ENABLED 1
+#define CONFIG_MBEDTLS_ECP_DP_BP512R1_ENABLED 1
+#define CONFIG_MBEDTLS_ECP_DP_CURVE25519_ENABLED 1
+#define CONFIG_MBEDTLS_ECP_NIST_OPTIM 1
+#define CONFIG_MBEDTLS_ECDH_C 1
+#define CONFIG_MBEDTLS_ECDSA_C 1
+#define CONFIG_MBEDTLS_PK_PARSE_EC_EXTENDED 1
+#define CONFIG_MBEDTLS_PK_PARSE_EC_COMPRESSED 1
+#define CONFIG_MBEDTLS_ECDSA_DETERMINISTIC 1
+#define CONFIG_MBEDTLS_MD_C 1
+#define CONFIG_MBEDTLS_MD5_C 1
+#define CONFIG_MBEDTLS_SHA1_C 1
+#define CONFIG_MBEDTLS_SHA256_C 1
+#define CONFIG_MBEDTLS_SHA384_C 1
+#define CONFIG_MBEDTLS_SHA512_C 1
+#define CONFIG_MBEDTLS_ROM_MD5 1
+#define CONFIG_MBEDTLS_HARDWARE_ECDSA_VERIFY 1
+#define CONFIG_MBEDTLS_HARDWARE_ECC 1
+#define CONFIG_MBEDTLS_ECC_OTHER_CURVES_SOFT_FALLBACK 1
+#define CONFIG_MBEDTLS_HARDWARE_SHA 1
+#define CONFIG_MBEDTLS_HARDWARE_MPI 1
+#define CONFIG_MBEDTLS_LARGE_KEY_SOFTWARE_MPI 1
+#define CONFIG_MBEDTLS_MPI_USE_INTERRUPT 1
+#define CONFIG_MBEDTLS_MPI_INTERRUPT_LEVEL 0
+#define CONFIG_MBEDTLS_HARDWARE_AES 1
+#define CONFIG_MBEDTLS_HARDWARE_GCM 1
+#define CONFIG_MBEDTLS_GCM_SUPPORT_NON_AES_CIPHER 1
+#define CONFIG_MBEDTLS_AES_USE_INTERRUPT 1
+#define CONFIG_MBEDTLS_AES_INTERRUPT_LEVEL 0
+#define CONFIG_MBEDTLS_AES_HW_SMALL_DATA_LEN_OPTIM 1
+#define CONFIG_MBEDTLS_HARDWARE_RSA_DS_PERIPHERAL 1
+#define CONFIG_MBEDTLS_CTR_DRBG_C 1
+#define CONFIG_MBEDTLS_HMAC_DRBG_C 1
+#define CONFIG_MBEDTLS_BASE64_C 1
+#define CONFIG_MBEDTLS_PKCS5_C 1
+#define CONFIG_MBEDTLS_PKCS7_C 1
+#define CONFIG_MBEDTLS_PKCS1_V15 1
+#define CONFIG_MBEDTLS_PKCS1_V21 1
+#define CONFIG_ESP_PROTOCOMM_SUPPORT_SECURITY_VERSION_2 1
+#define CONFIG_ESP_PROTOCOMM_SUPPORT_SECURITY_PATCH_VERSION 1
+#define CONFIG_PTHREAD_TASK_PRIO_DEFAULT 5
+#define CONFIG_PTHREAD_TASK_STACK_SIZE_DEFAULT 3072
+#define CONFIG_PTHREAD_STACK_MIN 768
+#define CONFIG_PTHREAD_DEFAULT_CORE_NO_AFFINITY 1
+#define CONFIG_PTHREAD_TASK_CORE_DEFAULT -1
+#define CONFIG_PTHREAD_TASK_NAME_DEFAULT "pthread"
+#define CONFIG_SD_ENABLE_SDIO_SUPPORT 1
+#define CONFIG_MMU_PAGE_SIZE_64KB 1
+#define CONFIG_MMU_PAGE_MODE "64KB"
+#define CONFIG_MMU_PAGE_SIZE 0x10000
+#define CONFIG_SPI_FLASH_BROWNOUT_RESET_XMC 1
+#define CONFIG_SPI_FLASH_BROWNOUT_RESET 1
+#define CONFIG_SPI_FLASH_HPM_AUTO 1
+#define CONFIG_SPI_FLASH_HPM_ON 1
+#define CONFIG_SPI_FLASH_HPM_DC_AUTO 1
+#define CONFIG_SPI_FLASH_SUSPEND_TSUS_VAL_US 50
+#define CONFIG_SPI_FLASH_PLACE_FUNCTIONS_IN_IRAM 1
+#define CONFIG_SPI_FLASH_DANGEROUS_WRITE_ABORTS 1
+#define CONFIG_SPI_FLASH_YIELD_DURING_ERASE 1
+#define CONFIG_SPI_FLASH_ERASE_YIELD_DURATION_MS 20
+#define CONFIG_SPI_FLASH_ERASE_YIELD_TICKS 1
+#define CONFIG_SPI_FLASH_WRITE_CHUNK_SIZE 8192
+#define CONFIG_SPI_FLASH_VENDOR_XMC_SUPPORT_ENABLED 1
+#define CONFIG_SPI_FLASH_VENDOR_GD_SUPPORT_ENABLED 1
+#define CONFIG_SPI_FLASH_SUPPORT_GD_CHIP 1
+#define CONFIG_SPI_FLASH_SUPPORT_BOYA_CHIP 1
+#define CONFIG_SPI_FLASH_ENABLE_ENCRYPTED_READ_WRITE 1
+#define CONFIG_SPIFFS_MAX_PARTITIONS 3
+#define CONFIG_SPIFFS_CACHE 1
+#define CONFIG_SPIFFS_CACHE_WR 1
+#define CONFIG_SPIFFS_PAGE_CHECK 1
+#define CONFIG_SPIFFS_GC_MAX_RUNS 10
+#define CONFIG_SPIFFS_PAGE_SIZE 256
+#define CONFIG_SPIFFS_OBJ_NAME_LEN 32
+#define CONFIG_SPIFFS_USE_MAGIC 1
+#define CONFIG_SPIFFS_USE_MAGIC_LENGTH 1
+#define CONFIG_SPIFFS_META_LENGTH 4
+#define CONFIG_SPIFFS_USE_MTIME 1
+#define CONFIG_WS_TRANSPORT 1
+#define CONFIG_WS_BUFFER_SIZE 1024
+#define CONFIG_UNITY_ENABLE_FLOAT 1
+#define CONFIG_UNITY_ENABLE_DOUBLE 1
+#define CONFIG_UNITY_ENABLE_IDF_TEST_RUNNER 1
+#define CONFIG_VFS_SUPPORT_IO 1
+#define CONFIG_VFS_SUPPORT_DIR 1
+#define CONFIG_VFS_SUPPORT_SELECT 1
+#define CONFIG_VFS_SUPPRESS_SELECT_DEBUG_OUTPUT 1
+#define CONFIG_VFS_MAX_COUNT 8
+#define CONFIG_VFS_SEMIHOSTFS_MAX_MOUNT_POINTS 1
+#define CONFIG_VFS_INITIALIZE_DEV_NULL 1
+#define CONFIG_WL_SECTOR_SIZE_4096 1
+#define CONFIG_WL_SECTOR_SIZE 4096
+#define CONFIG_EPPP_LINK_DEVICE_UART 1
+#define CONFIG_EPPP_LINK_CONN_MAX_RETRY 6
+#define CONFIG_ESP_HOSTED_ENABLED 1
+#define CONFIG_ESP_HOSTED_CP_TARGET_ESP32C6 1
+#define CONFIG_ESP_HOSTED_PRIV_ENABLE_WIFI_OPTIONS 1
+#define CONFIG_ESP_HOSTED_IDF_SLAVE_TARGET "esp32c6"
+#define CONFIG_ESP_HOSTED_P4_DEV_BOARD_NONE 1
+#define CONFIG_ESP_HOSTED_PRIV_SDIO_OPTION 1
+#define CONFIG_ESP_HOSTED_PRIV_SPI_HD_OPTION 1
+#define CONFIG_ESP_HOSTED_SDIO_HOST_INTERFACE 1
+#define CONFIG_ESP_HOSTED_SDIO_RESET_ACTIVE_HIGH 1
+#define CONFIG_ESP_HOSTED_SDIO_OPTIMIZATION_RX_STREAMING_MODE 1
+#define CONFIG_ESP_HOSTED_SDIO_SLOT_1 1
+#define CONFIG_ESP_HOSTED_SDIO_SLOT 1
+#define CONFIG_ESP_HOSTED_SDIO_4_BIT_BUS 1
+#define CONFIG_ESP_HOSTED_SDIO_BUS_WIDTH 4
+#define CONFIG_ESP_HOSTED_SDIO_CLOCK_FREQ_KHZ 40000
+#define CONFIG_ESP_HOSTED_SDIO_CMD_GPIO_RANGE_MIN 0
+#define CONFIG_ESP_HOSTED_SDIO_CMD_GPIO_RANGE_MAX 100
+#define CONFIG_ESP_HOSTED_SDIO_CLK_GPIO_RANGE_MIN 0
+#define CONFIG_ESP_HOSTED_SDIO_CLK_GPIO_RANGE_MAX 100
+#define CONFIG_ESP_HOSTED_SDIO_D0_GPIO_RANGE_MIN 0
+#define CONFIG_ESP_HOSTED_SDIO_D0_GPIO_RANGE_MAX 100
+#define CONFIG_ESP_HOSTED_SDIO_D1_GPIO_RANGE_MIN 0
+#define CONFIG_ESP_HOSTED_SDIO_D1_GPIO_RANGE_MAX 100
+#define CONFIG_ESP_HOSTED_SDIO_D2_GPIO_RANGE_MIN 0
+#define CONFIG_ESP_HOSTED_SDIO_D2_GPIO_RANGE_MAX 100
+#define CONFIG_ESP_HOSTED_SDIO_D3_GPIO_RANGE_MIN 0
+#define CONFIG_ESP_HOSTED_SDIO_D3_GPIO_RANGE_MAX 100
+#define CONFIG_ESP_HOSTED_SDIO_RESET_SLAVE_GPIO_MIN 0
+#define CONFIG_ESP_HOSTED_SDIO_RESET_SLAVE_GPIO_MAX 100
+#define CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_CMD_SLOT_1 19
+#define CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_CLK_SLOT_1 18
+#define CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_D0_SLOT_1 14
+#define CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_D1_4BIT_BUS_SLOT_1 15
+#define CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_D2_4BIT_BUS_SLOT_1 16
+#define CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_D3_4BIT_BUS_SLOT_1 17
+#define CONFIG_ESP_HOSTED_SDIO_GPIO_RESET_SLAVE 54
+#define CONFIG_ESP_HOSTED_SDIO_PIN_CMD 19
+#define CONFIG_ESP_HOSTED_SDIO_PIN_CLK 18
+#define CONFIG_ESP_HOSTED_SDIO_PIN_D0 14
+#define CONFIG_ESP_HOSTED_SDIO_PRIV_PIN_D1_4BIT_BUS 15
+#define CONFIG_ESP_HOSTED_SDIO_PIN_D2 16
+#define CONFIG_ESP_HOSTED_SDIO_PIN_D3 17
+#define CONFIG_ESP_HOSTED_SDIO_PIN_D1 15
+#define CONFIG_ESP_HOSTED_SDIO_TX_Q_SIZE 20
+#define CONFIG_ESP_HOSTED_SDIO_RX_Q_SIZE 20
+#define CONFIG_ESP_HOSTED_SDIO_RESET_DELAY_MS 1500
+#define CONFIG_ESP_HOSTED_SLAVE_RESET_ON_EVERY_HOST_BOOTUP 1
+#define CONFIG_ESP_HOSTED_GPIO_SLAVE_RESET_SLAVE 54
+#define CONFIG_ESP_HOSTED_ENABLE_BT_NIMBLE 1
+#define CONFIG_ESP_HOSTED_NIMBLE_HCI_VHCI 1
+#define CONFIG_ESP_HOSTED_RPC_TASK_STACK 4096
+#define CONFIG_ESP_HOSTED_DFLT_TASK_STACK 3072
+#define CONFIG_ESP_HOSTED_TRANSPORT_RESTART_ON_FAILURE 1
+#define CONFIG_ESP_HOSTED_MEM_MONITOR 1
+#define CONFIG_ESP_HOSTED_ENABLE_ITWT 1
+#define CONFIG_ESP_HOSTED_USE_MEMPOOL 1
+#define CONFIG_ESP_HOSTED_MAX_SIMULTANEOUS_SYNC_RPC_REQUESTS 5
+#define CONFIG_ESP_HOSTED_MAX_SIMULTANEOUS_ASYNC_RPC_REQUESTS 5
+#define CONFIG_ESP_HOSTED_CLI_ENABLED 1
+#define CONFIG_ESP_HOSTED_HOST_TO_ESP_WIFI_DATA_THROTTLE 1
+#define CONFIG_ESP_HOSTED_PRIV_WIFI_TX_SDIO_HIGH_THRESHOLD 80
+#define CONFIG_ESP_HOSTED_TO_WIFI_DATA_THROTTLE_HIGH_THRESHOLD 80
+#define CONFIG_ESP_HOSTED_TO_WIFI_DATA_THROTTLE_LOW_THRESHOLD 60
+#define CONFIG_ESP_HOSTED_ENABLE_PEER_DATA_TRANSFER 1
+#define CONFIG_ESP_HOSTED_MAX_CUSTOM_MSG_HANDLERS 3
+#define CONFIG_ESP_WIFI_REMOTE_ENABLED 1
+#define CONFIG_ESP_WIFI_REMOTE_IDF_SPECIFIC_ADDED 1
+#define CONFIG_SLAVE_IDF_TARGET_ESP32C6 1
+#define CONFIG_SLAVE_SOC_WIFI_SUPPORTED 1
+#define CONFIG_SLAVE_SOC_WIFI_WAPI_SUPPORT 1
+#define CONFIG_SLAVE_SOC_WIFI_CSI_SUPPORT 1
+#define CONFIG_SLAVE_SOC_WIFI_MESH_SUPPORT 1
+#define CONFIG_SLAVE_SOC_WIFI_LIGHT_SLEEP_CLK_WIDTH 12
+#define CONFIG_SLAVE_SOC_WIFI_HW_TSF 1
+#define CONFIG_SLAVE_SOC_WIFI_FTM_SUPPORT 1
+#define CONFIG_SLAVE_FREERTOS_UNICORE 1
+#define CONFIG_SLAVE_SOC_WIFI_GCMP_SUPPORT 1
+#define CONFIG_SLAVE_SOC_WIFI_TXOP_SUPPORT 1
+#define CONFIG_SLAVE_IDF_TARGET_ARCH_RISCV 1
+#define CONFIG_SLAVE_SOC_WIFI_HE_SUPPORT 1
+#define CONFIG_SLAVE_SOC_WIFI_MAC_VERSION_NUM 2
+#define CONFIG_WIFI_RMT_STATIC_RX_BUFFER_NUM 10
+#define CONFIG_WIFI_RMT_DYNAMIC_RX_BUFFER_NUM 32
+#define CONFIG_WIFI_RMT_DYNAMIC_TX_BUFFER 1
+#define CONFIG_WIFI_RMT_TX_BUFFER_TYPE 1
+#define CONFIG_WIFI_RMT_DYNAMIC_TX_BUFFER_NUM 32
+#define CONFIG_WIFI_RMT_STATIC_RX_MGMT_BUFFER 1
+#define CONFIG_WIFI_RMT_DYNAMIC_RX_MGMT_BUF 0
+#define CONFIG_WIFI_RMT_RX_MGMT_BUF_NUM_DEF 5
+#define CONFIG_WIFI_RMT_AMPDU_TX_ENABLED 1
+#define CONFIG_WIFI_RMT_TX_BA_WIN 6
+#define CONFIG_WIFI_RMT_AMPDU_RX_ENABLED 1
+#define CONFIG_WIFI_RMT_RX_BA_WIN 6
+#define CONFIG_WIFI_RMT_NVS_ENABLED 1
+#define CONFIG_WIFI_RMT_SOFTAP_BEACON_MAX_LEN 752
+#define CONFIG_WIFI_RMT_MGMT_SBUF_NUM 32
+#define CONFIG_WIFI_RMT_IRAM_OPT 1
+#define CONFIG_WIFI_RMT_EXTRA_IRAM_OPT 1
+#define CONFIG_WIFI_RMT_RX_IRAM_OPT 1
+#define CONFIG_WIFI_RMT_ENABLE_WPA3_SAE 1
+#define CONFIG_WIFI_RMT_ENABLE_SAE_H2E 1
+#define CONFIG_WIFI_RMT_ENABLE_SAE_PK 1
+#define CONFIG_WIFI_RMT_SOFTAP_SAE_SUPPORT 1
+#define CONFIG_WIFI_RMT_ENABLE_WPA3_OWE_STA 1
+#define CONFIG_WIFI_RMT_WPA3_COMPATIBLE_SUPPORT 1
+#define CONFIG_WIFI_RMT_SLP_IRAM_OPT 1
+#define CONFIG_WIFI_RMT_SLP_DEFAULT_MIN_ACTIVE_TIME 50
+#define CONFIG_WIFI_RMT_BSS_MAX_IDLE_SUPPORT 1
+#define CONFIG_WIFI_RMT_SLP_DEFAULT_MAX_ACTIVE_TIME 10
+#define CONFIG_WIFI_RMT_SLP_DEFAULT_WAIT_BROADCAST_DATA_TIME 15
+#define CONFIG_WIFI_RMT_STA_DISCONNECTED_PM_ENABLE 1
+#define CONFIG_WIFI_RMT_GMAC_SUPPORT 1
+#define CONFIG_WIFI_RMT_SOFTAP_SUPPORT 1
+#define CONFIG_WIFI_RMT_ESPNOW_MAX_ENCRYPT_NUM 7
+#define CONFIG_WIFI_RMT_MBEDTLS_CRYPTO 1
+#define CONFIG_WIFI_RMT_MBEDTLS_TLS_CLIENT 1
+#define CONFIG_WIFI_RMT_TX_HETB_QUEUE_NUM 3
+#define CONFIG_WIFI_RMT_ENTERPRISE_SUPPORT 1
+#define CONFIG_ESP_WIFI_REMOTE_LIBRARY_HOSTED 1
+#define CONFIG_ESP_WIFI_REMOTE_EAP_ENABLED 1
+
+/* List of deprecated options */
+#define CONFIG_BROWNOUT_DET CONFIG_ESP_BROWNOUT_DET
+#define CONFIG_BROWNOUT_DET_LVL CONFIG_ESP_BROWNOUT_DET_LVL
+#define CONFIG_BROWNOUT_DET_LVL_SEL_7 CONFIG_ESP_BROWNOUT_DET_LVL_SEL_7
+#define CONFIG_BT_NIMBLE_ACL_BUF_COUNT CONFIG_BT_NIMBLE_TRANSPORT_ACL_FROM_LL_COUNT
+#define CONFIG_BT_NIMBLE_ACL_BUF_SIZE CONFIG_BT_NIMBLE_TRANSPORT_ACL_SIZE
+#define CONFIG_BT_NIMBLE_HCI_EVT_BUF_SIZE CONFIG_BT_NIMBLE_TRANSPORT_EVT_SIZE
+#define CONFIG_BT_NIMBLE_HCI_EVT_HI_BUF_COUNT CONFIG_BT_NIMBLE_TRANSPORT_EVT_COUNT
+#define CONFIG_BT_NIMBLE_HCI_EVT_LO_BUF_COUNT CONFIG_BT_NIMBLE_TRANSPORT_EVT_DISCARD_COUNT
+#define CONFIG_BT_NIMBLE_MSYS1_BLOCK_COUNT CONFIG_BT_NIMBLE_MSYS_1_BLOCK_COUNT
+#define CONFIG_BT_NIMBLE_SM_SC_LVL CONFIG_BT_NIMBLE_SM_LVL
+#define CONFIG_BT_NIMBLE_TASK_STACK_SIZE CONFIG_BT_NIMBLE_HOST_TASK_STACK_SIZE
+#define CONFIG_COMPILER_OPTIMIZATION_DEFAULT CONFIG_COMPILER_OPTIMIZATION_DEBUG
+#define CONFIG_COMPILER_OPTIMIZATION_LEVEL_DEBUG CONFIG_COMPILER_OPTIMIZATION_DEBUG
+#define CONFIG_CONSOLE_UART CONFIG_ESP_CONSOLE_UART
+#define CONFIG_CONSOLE_UART_BAUDRATE CONFIG_ESP_CONSOLE_UART_BAUDRATE
+#define CONFIG_CONSOLE_UART_DEFAULT CONFIG_ESP_CONSOLE_UART_DEFAULT
+#define CONFIG_CONSOLE_UART_NUM CONFIG_ESP_CONSOLE_UART_NUM
+#define CONFIG_ESP32_DEFAULT_PTHREAD_CORE_NO_AFFINITY CONFIG_PTHREAD_DEFAULT_CORE_NO_AFFINITY
+#define CONFIG_ESP32_ENABLE_COREDUMP_TO_NONE CONFIG_ESP_COREDUMP_ENABLE_TO_NONE
+#define CONFIG_ESP32_PTHREAD_STACK_MIN CONFIG_PTHREAD_STACK_MIN
+#define CONFIG_ESP32_PTHREAD_TASK_CORE_DEFAULT CONFIG_PTHREAD_TASK_CORE_DEFAULT
+#define CONFIG_ESP32_PTHREAD_TASK_NAME_DEFAULT CONFIG_PTHREAD_TASK_NAME_DEFAULT
+#define CONFIG_ESP32_PTHREAD_TASK_PRIO_DEFAULT CONFIG_PTHREAD_TASK_PRIO_DEFAULT
+#define CONFIG_ESP32_PTHREAD_TASK_STACK_SIZE_DEFAULT CONFIG_PTHREAD_TASK_STACK_SIZE_DEFAULT
+#define CONFIG_ESP32_WIFI_AMPDU_RX_ENABLED CONFIG_ESP_WIFI_AMPDU_RX_ENABLED
+#define CONFIG_ESP32_WIFI_AMPDU_TX_ENABLED CONFIG_ESP_WIFI_AMPDU_TX_ENABLED
+#define CONFIG_ESP32_WIFI_DYNAMIC_RX_BUFFER_NUM CONFIG_ESP_WIFI_DYNAMIC_RX_BUFFER_NUM
+#define CONFIG_ESP32_WIFI_DYNAMIC_TX_BUFFER_NUM CONFIG_ESP_WIFI_DYNAMIC_TX_BUFFER_NUM
+#define CONFIG_ESP32_WIFI_ENABLE_WPA3_OWE_STA CONFIG_ESP_WIFI_ENABLE_WPA3_OWE_STA
+#define CONFIG_ESP32_WIFI_ENABLE_WPA3_SAE CONFIG_ESP_WIFI_ENABLE_WPA3_SAE
+#define CONFIG_ESP32_WIFI_IRAM_OPT CONFIG_ESP_WIFI_IRAM_OPT
+#define CONFIG_ESP32_WIFI_MGMT_SBUF_NUM CONFIG_ESP_WIFI_MGMT_SBUF_NUM
+#define CONFIG_ESP32_WIFI_NVS_ENABLED CONFIG_ESP_WIFI_NVS_ENABLED
+#define CONFIG_ESP32_WIFI_RX_BA_WIN CONFIG_ESP_WIFI_RX_BA_WIN
+#define CONFIG_ESP32_WIFI_RX_IRAM_OPT CONFIG_ESP_WIFI_RX_IRAM_OPT
+#define CONFIG_ESP32_WIFI_SOFTAP_BEACON_MAX_LEN CONFIG_ESP_WIFI_SOFTAP_BEACON_MAX_LEN
+#define CONFIG_ESP32_WIFI_STATIC_RX_BUFFER_NUM CONFIG_ESP_WIFI_STATIC_RX_BUFFER_NUM
+#define CONFIG_ESP32_WIFI_TX_BA_WIN CONFIG_ESP_WIFI_TX_BA_WIN
+#define CONFIG_ESP32_WIFI_TX_BUFFER_TYPE CONFIG_ESP_WIFI_TX_BUFFER_TYPE
+#define CONFIG_ESP_DFLT_TASK_STACK CONFIG_ESP_HOSTED_DFLT_TASK_STACK
+#define CONFIG_ESP_ENABLE_BT_NIMBLE CONFIG_ESP_HOSTED_ENABLE_BT_NIMBLE
+#define CONFIG_ESP_GPIO_SLAVE_RESET_SLAVE CONFIG_ESP_HOSTED_GPIO_SLAVE_RESET_SLAVE
+#define CONFIG_ESP_GRATUITOUS_ARP CONFIG_LWIP_ESP_GRATUITOUS_ARP
+#define CONFIG_ESP_MAX_SIMULTANEOUS_ASYNC_RPC_REQUESTS CONFIG_ESP_HOSTED_MAX_SIMULTANEOUS_ASYNC_RPC_REQUESTS
+#define CONFIG_ESP_MAX_SIMULTANEOUS_SYNC_RPC_REQUESTS CONFIG_ESP_HOSTED_MAX_SIMULTANEOUS_SYNC_RPC_REQUESTS
+#define CONFIG_ESP_NIMBLE_HCI_VHCI CONFIG_ESP_HOSTED_NIMBLE_HCI_VHCI
+#define CONFIG_ESP_RPC_TASK_STACK CONFIG_ESP_HOSTED_RPC_TASK_STACK
+#define CONFIG_ESP_SDIO_4_BIT_BUS CONFIG_ESP_HOSTED_SDIO_4_BIT_BUS
+#define CONFIG_ESP_SDIO_BUS_WIDTH CONFIG_ESP_HOSTED_SDIO_BUS_WIDTH
+#define CONFIG_ESP_SDIO_CLOCK_FREQ_KHZ CONFIG_ESP_HOSTED_SDIO_CLOCK_FREQ_KHZ
+#define CONFIG_ESP_SDIO_GPIO_RESET_SLAVE CONFIG_ESP_HOSTED_SDIO_GPIO_RESET_SLAVE
+#define CONFIG_ESP_SDIO_HOST_INTERFACE CONFIG_ESP_HOSTED_SDIO_HOST_INTERFACE
+#define CONFIG_ESP_SDIO_OPTIMIZATION_RX_STREAMING_MODE CONFIG_ESP_HOSTED_SDIO_OPTIMIZATION_RX_STREAMING_MODE
+#define CONFIG_ESP_SDIO_PIN_CLK CONFIG_ESP_HOSTED_SDIO_PIN_CLK
+#define CONFIG_ESP_SDIO_PIN_CMD CONFIG_ESP_HOSTED_SDIO_PIN_CMD
+#define CONFIG_ESP_SDIO_PIN_D0 CONFIG_ESP_HOSTED_SDIO_PIN_D0
+#define CONFIG_ESP_SDIO_PIN_D1 CONFIG_ESP_HOSTED_SDIO_PIN_D1
+#define CONFIG_ESP_SDIO_PIN_D2 CONFIG_ESP_HOSTED_SDIO_PIN_D2
+#define CONFIG_ESP_SDIO_PIN_D3 CONFIG_ESP_HOSTED_SDIO_PIN_D3
+#define CONFIG_ESP_SDIO_RX_Q_SIZE CONFIG_ESP_HOSTED_SDIO_RX_Q_SIZE
+#define CONFIG_ESP_SDIO_TX_Q_SIZE CONFIG_ESP_HOSTED_SDIO_TX_Q_SIZE
+#define CONFIG_ESP_SYSTEM_BROWNOUT_INTR CONFIG_ESP_BROWNOUT_USE_INTR
+#define CONFIG_ESP_SYSTEM_MEMPROT_FEATURE CONFIG_ESP_SYSTEM_MEMPROT
+#define CONFIG_ESP_SYSTEM_MEMPROT_FEATURE_VIA_TEE CONFIG_ESP_SYSTEM_MEMPROT
+#define CONFIG_ESP_SYSTEM_PMP_IDRAM_SPLIT CONFIG_ESP_SYSTEM_MEMPROT
+#define CONFIG_ESP_TASK_WDT CONFIG_ESP_TASK_WDT_INIT
+#define CONFIG_ESP_USE_MEMPOOL CONFIG_ESP_HOSTED_USE_MEMPOOL
+#define CONFIG_FLASHMODE_DIO CONFIG_ESPTOOLPY_FLASHMODE_DIO
+#define CONFIG_GARP_TMR_INTERVAL CONFIG_LWIP_GARP_TMR_INTERVAL
+#define CONFIG_GDBSTUB_MAX_TASKS CONFIG_ESP_GDBSTUB_MAX_TASKS
+#define CONFIG_GDBSTUB_SUPPORT_TASKS CONFIG_ESP_GDBSTUB_SUPPORT_TASKS
+#define CONFIG_HOST_TO_ESP_WIFI_DATA_THROTTLE CONFIG_ESP_HOSTED_HOST_TO_ESP_WIFI_DATA_THROTTLE
+#define CONFIG_IDF_SLAVE_TARGET CONFIG_ESP_HOSTED_IDF_SLAVE_TARGET
+#define CONFIG_INT_WDT CONFIG_ESP_INT_WDT
+#define CONFIG_INT_WDT_CHECK_CPU1 CONFIG_ESP_INT_WDT_CHECK_CPU1
+#define CONFIG_INT_WDT_TIMEOUT_MS CONFIG_ESP_INT_WDT_TIMEOUT_MS
+#define CONFIG_IPC_TASK_STACK_SIZE CONFIG_ESP_IPC_TASK_STACK_SIZE
+#define CONFIG_LOG_BOOTLOADER_LEVEL CONFIG_BOOTLOADER_LOG_LEVEL
+#define CONFIG_LOG_BOOTLOADER_LEVEL_INFO CONFIG_BOOTLOADER_LOG_LEVEL_INFO
+#define CONFIG_MAIN_TASK_STACK_SIZE CONFIG_ESP_MAIN_TASK_STACK_SIZE
+#define CONFIG_MONITOR_BAUD CONFIG_ESPTOOLPY_MONITOR_BAUD
+#define CONFIG_NEWLIB_STDIN_LINE_ENDING_CR CONFIG_LIBC_STDIN_LINE_ENDING_CR
+#define CONFIG_NEWLIB_STDOUT_LINE_ENDING_CRLF CONFIG_LIBC_STDOUT_LINE_ENDING_CRLF
+#define CONFIG_NEWLIB_TIME_SYSCALL_USE_RTC_HRT CONFIG_LIBC_TIME_SYSCALL_USE_RTC_HRT
+#define CONFIG_NIMBLE_ATT_PREFERRED_MTU CONFIG_BT_NIMBLE_ATT_PREFERRED_MTU
+#define CONFIG_NIMBLE_ENABLED CONFIG_BT_NIMBLE_ENABLED
+#define CONFIG_NIMBLE_GAP_DEVICE_NAME_MAX_LEN CONFIG_BT_NIMBLE_GAP_DEVICE_NAME_MAX_LEN
+#define CONFIG_NIMBLE_L2CAP_COC_MAX_NUM CONFIG_BT_NIMBLE_L2CAP_COC_MAX_NUM
+#define CONFIG_NIMBLE_MAX_BONDS CONFIG_BT_NIMBLE_MAX_BONDS
+#define CONFIG_NIMBLE_MAX_CCCDS CONFIG_BT_NIMBLE_MAX_CCCDS
+#define CONFIG_NIMBLE_MAX_CONNECTIONS CONFIG_BT_NIMBLE_MAX_CONNECTIONS
+#define CONFIG_NIMBLE_MEM_ALLOC_MODE_INTERNAL CONFIG_BT_NIMBLE_MEM_ALLOC_MODE_INTERNAL
+#define CONFIG_NIMBLE_PINNED_TO_CORE CONFIG_BT_NIMBLE_PINNED_TO_CORE
+#define CONFIG_NIMBLE_PINNED_TO_CORE_0 CONFIG_BT_NIMBLE_PINNED_TO_CORE_0
+#define CONFIG_NIMBLE_ROLE_BROADCASTER CONFIG_BT_NIMBLE_ROLE_BROADCASTER
+#define CONFIG_NIMBLE_ROLE_OBSERVER CONFIG_BT_NIMBLE_ROLE_OBSERVER
+#define CONFIG_NIMBLE_ROLE_PERIPHERAL CONFIG_BT_NIMBLE_ROLE_PERIPHERAL
+#define CONFIG_NIMBLE_RPA_TIMEOUT CONFIG_BT_NIMBLE_RPA_TIMEOUT
+#define CONFIG_NIMBLE_SM_LEGACY CONFIG_BT_NIMBLE_SM_LEGACY
+#define CONFIG_NIMBLE_SM_SC CONFIG_BT_NIMBLE_SM_SC
+#define CONFIG_NIMBLE_SVC_GAP_APPEARANCE CONFIG_BT_NIMBLE_SVC_GAP_APPEARANCE
+#define CONFIG_NIMBLE_SVC_GAP_DEVICE_NAME CONFIG_BT_NIMBLE_SVC_GAP_DEVICE_NAME
+#define CONFIG_NIMBLE_TASK_STACK_SIZE CONFIG_BT_NIMBLE_HOST_TASK_STACK_SIZE
+#define CONFIG_OPTIMIZATION_ASSERTIONS_ENABLED CONFIG_COMPILER_OPTIMIZATION_ASSERTIONS_ENABLE
+#define CONFIG_OPTIMIZATION_ASSERTION_LEVEL CONFIG_COMPILER_OPTIMIZATION_ASSERTION_LEVEL
+#define CONFIG_OPTIMIZATION_LEVEL_DEBUG CONFIG_COMPILER_OPTIMIZATION_DEBUG
+#define CONFIG_PERIPH_CTRL_FUNC_IN_IRAM CONFIG_ESP_PERIPH_CTRL_FUNC_IN_IRAM
+#define CONFIG_POST_EVENTS_FROM_IRAM_ISR CONFIG_ESP_EVENT_POST_FROM_IRAM_ISR
+#define CONFIG_POST_EVENTS_FROM_ISR CONFIG_ESP_EVENT_POST_FROM_ISR
+#define CONFIG_PRIV_WIFI_TX_SDIO_HIGH_THRESHOLD CONFIG_ESP_HOSTED_PRIV_WIFI_TX_SDIO_HIGH_THRESHOLD
+#define CONFIG_SDIO_RESET_ACTIVE_HIGH CONFIG_ESP_HOSTED_SDIO_RESET_ACTIVE_HIGH
+#define CONFIG_SEMIHOSTFS_MAX_MOUNT_POINTS CONFIG_VFS_SEMIHOSTFS_MAX_MOUNT_POINTS
+#define CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY CONFIG_FREERTOS_TASK_CREATE_ALLOW_EXT_MEM
+#define CONFIG_SPI_FLASH_WRITING_DANGEROUS_REGIONS_ABORTS CONFIG_SPI_FLASH_DANGEROUS_WRITE_ABORTS
+#define CONFIG_STACK_CHECK_NONE CONFIG_COMPILER_STACK_CHECK_MODE_NONE
+#define CONFIG_SUPPRESS_SELECT_DEBUG_OUTPUT CONFIG_VFS_SUPPRESS_SELECT_DEBUG_OUTPUT
+#define CONFIG_SYSTEM_EVENT_QUEUE_SIZE CONFIG_ESP_SYSTEM_EVENT_QUEUE_SIZE
+#define CONFIG_SYSTEM_EVENT_TASK_STACK_SIZE CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE
+#define CONFIG_TASK_WDT CONFIG_ESP_TASK_WDT_INIT
+#define CONFIG_TASK_WDT_CHECK_IDLE_TASK_CPU0 CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0
+#define CONFIG_TASK_WDT_CHECK_IDLE_TASK_CPU1 CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1
+#define CONFIG_TASK_WDT_TIMEOUT_S CONFIG_ESP_TASK_WDT_TIMEOUT_S
+#define CONFIG_TCPIP_RECVMBOX_SIZE CONFIG_LWIP_TCPIP_RECVMBOX_SIZE
+#define CONFIG_TCPIP_TASK_AFFINITY CONFIG_LWIP_TCPIP_TASK_AFFINITY
+#define CONFIG_TCPIP_TASK_AFFINITY_NO_AFFINITY CONFIG_LWIP_TCPIP_TASK_AFFINITY_NO_AFFINITY
+#define CONFIG_TCPIP_TASK_STACK_SIZE CONFIG_LWIP_TCPIP_TASK_STACK_SIZE
+#define CONFIG_TCP_MAXRTX CONFIG_LWIP_TCP_MAXRTX
+#define CONFIG_TCP_MSL CONFIG_LWIP_TCP_MSL
+#define CONFIG_TCP_MSS CONFIG_LWIP_TCP_MSS
+#define CONFIG_TCP_OVERSIZE_MSS CONFIG_LWIP_TCP_OVERSIZE_MSS
+#define CONFIG_TCP_QUEUE_OOSEQ CONFIG_LWIP_TCP_QUEUE_OOSEQ
+#define CONFIG_TCP_RECVMBOX_SIZE CONFIG_LWIP_TCP_RECVMBOX_SIZE
+#define CONFIG_TCP_SND_BUF_DEFAULT CONFIG_LWIP_TCP_SND_BUF_DEFAULT
+#define CONFIG_TCP_SYNMAXRTX CONFIG_LWIP_TCP_SYNMAXRTX
+#define CONFIG_TCP_WND_DEFAULT CONFIG_LWIP_TCP_WND_DEFAULT
+#define CONFIG_TIMER_QUEUE_LENGTH CONFIG_FREERTOS_TIMER_QUEUE_LENGTH
+#define CONFIG_TIMER_TASK_PRIORITY CONFIG_FREERTOS_TIMER_TASK_PRIORITY
+#define CONFIG_TIMER_TASK_STACK_DEPTH CONFIG_FREERTOS_TIMER_TASK_STACK_DEPTH
+#define CONFIG_TIMER_TASK_STACK_SIZE CONFIG_ESP_TIMER_TASK_STACK_SIZE
+#define CONFIG_TO_WIFI_DATA_THROTTLE_HIGH_THRESHOLD CONFIG_ESP_HOSTED_TO_WIFI_DATA_THROTTLE_HIGH_THRESHOLD
+#define CONFIG_TO_WIFI_DATA_THROTTLE_LOW_THRESHOLD CONFIG_ESP_HOSTED_TO_WIFI_DATA_THROTTLE_LOW_THRESHOLD
+#define CONFIG_UDP_RECVMBOX_SIZE CONFIG_LWIP_UDP_RECVMBOX_SIZE
+#define CONFIG_WPA_MBEDTLS_CRYPTO CONFIG_ESP_WIFI_MBEDTLS_CRYPTO
+#define CONFIG_WPA_MBEDTLS_TLS_CLIENT CONFIG_ESP_WIFI_MBEDTLS_TLS_CLIENT
diff --git a/src/net/hosted/wifi_shim.c b/src/net/hosted/wifi_shim.c
new file mode 100644
index 0000000..67bdcc6
--- /dev/null
+++ b/src/net/hosted/wifi_shim.c
@@ -0,0 +1,200 @@
+/*
+ * A narrow C surface over ESP-Hosted's Wi-Fi RPC, so Zig never transcribes an IDF struct.
+ *
+ * `rpc_wifi_init` takes a `wifi_init_config_t`, `rpc_wifi_set_config` takes a `wifi_config_t`, and
+ * scanning hands back `wifi_ap_record_t`. Those are large, versioned structs full of bitfields, and
+ * IDF builds them with macros - WIFI_INIT_CONFIG_DEFAULT() alone sets over twenty fields
+ * (esp_wifi.h:316). Writing Zig `extern struct`s to match would be a transcription that compiles
+ * happily and goes wrong on the next IDF release, exactly the mistake that
+ * `esp_hosted_sdio_get_config` taught this project once already.
+ *
+ * So the structs stay on the C side, built by IDF's own macros, and Zig gets plain scalars and byte
+ * buffers. Everything here is a thin forwarder; the interesting code is all in ESP-Hosted's RPC
+ * layer, which this does not duplicate.
+ */
+
+#include <string.h>
+
+#include "esp_wifi_types.h"
+#include "esp_wifi.h"
+#include "rpc_wrap.h"
+
+/* ESP-Hosted's RPC entry points (host/drivers/rpc/wrap/rpc_wrap.c). Declared here rather than
+ * relying on the header, so a signature change is a compile error in this file. */
+int rpc_wifi_init(const wifi_init_config_t *arg);
+int rpc_wifi_set_mode(wifi_mode_t mode);
+int rpc_wifi_set_config(wifi_interface_t interface, wifi_config_t *conf);
+int rpc_wifi_connect(void);
+int rpc_wifi_scan_start(const wifi_scan_config_t *config, bool block);
+int rpc_wifi_scan_get_ap_num(uint16_t *number);
+int rpc_wifi_scan_get_ap_records(uint16_t *number, wifi_ap_record_t *ap_records);
+int rpc_wifi_start(void);
+int rpc_wifi_get_mac(wifi_interface_t mode, uint8_t mac[6]);
+int rpc_wifi_set_ps(wifi_ps_type_t type);
+
+/* Initialise the coprocessor's Wi-Fi and put it in station mode, started.
+ *
+ * The order is IDF's own and is not negotiable: init, set_mode, start. `esp_wifi_start` is what
+ * actually brings the radio up on the C6; a config set before it is accepted and a connect before
+ * it is not. */
+int hosted_wifi_sta_start(void)
+{
+ /* IDF's WIFI_INIT_CONFIG_DEFAULT() is deliberately NOT used, and this is not a shortcut.
+ *
+ * That macro's first two fields are `.osi_funcs = &g_wifi_osi_funcs` and
+ * `.wpa_crypto_funcs = g_wifi_default_wpa_crypto_funcs` (esp_wifi.h:317-318) - the local Wi-Fi
+ * driver's OS adapter and crypto tables. This chip has no Wi-Fi driver: the C6 does, and it uses
+ * its own. Referencing them here pulls in symbols that cannot exist in this image, which is
+ * exactly the link error that led to this comment.
+ *
+ * They are also provably unnecessary. rpc_req.c:182-215 packs the request field by field, and
+ * every field it packs is a scalar; neither function pointer is ever serialised. So the struct
+ * only has to carry the scalars, and those come from the same Kconfig-derived macros the real
+ * default uses - via the checked-in sdkconfig, so they are this project's configuration and not
+ * a second set of numbers.
+ *
+ * `magic` is load-bearing: the coprocessor validates it (esp_wifi.h's own note says it must
+ * always be WIFI_INIT_CONFIG_MAGIC), so a zeroed struct is rejected. */
+ wifi_init_config_t cfg = { 0 };
+ cfg.static_rx_buf_num = CONFIG_ESP_WIFI_STATIC_RX_BUFFER_NUM;
+ cfg.dynamic_rx_buf_num = CONFIG_ESP_WIFI_DYNAMIC_RX_BUFFER_NUM;
+ cfg.tx_buf_type = CONFIG_ESP_WIFI_TX_BUFFER_TYPE;
+ cfg.static_tx_buf_num = WIFI_STATIC_TX_BUFFER_NUM;
+ cfg.dynamic_tx_buf_num = WIFI_DYNAMIC_TX_BUFFER_NUM;
+ cfg.rx_mgmt_buf_type = CONFIG_ESP_WIFI_DYNAMIC_RX_MGMT_BUF;
+ cfg.rx_mgmt_buf_num = WIFI_RX_MGMT_BUF_NUM_DEF;
+ cfg.cache_tx_buf_num = WIFI_CACHE_TX_BUFFER_NUM;
+ cfg.csi_enable = WIFI_CSI_ENABLED;
+ cfg.ampdu_rx_enable = WIFI_AMPDU_RX_ENABLED;
+ cfg.ampdu_tx_enable = WIFI_AMPDU_TX_ENABLED;
+ cfg.amsdu_tx_enable = WIFI_AMSDU_TX_ENABLED;
+ cfg.nvs_enable = WIFI_NVS_ENABLED;
+ cfg.nano_enable = WIFI_NANO_FORMAT_ENABLED;
+ cfg.rx_ba_win = WIFI_DEFAULT_RX_BA_WIN;
+ cfg.wifi_task_core_id = WIFI_TASK_CORE_ID;
+ cfg.beacon_max_len = WIFI_SOFTAP_BEACON_MAX_LEN;
+ cfg.mgmt_sbuf_num = WIFI_MGMT_SBUF_NUM;
+ cfg.feature_caps = WIFI_FEATURE_CAPS;
+ cfg.sta_disconnected_pm = WIFI_STA_DISCONNECTED_PM_ENABLED;
+ cfg.espnow_max_encrypt_num = CONFIG_ESP_WIFI_ESPNOW_MAX_ENCRYPT_NUM;
+ cfg.tx_hetb_queue_num = WIFI_TX_HETB_QUEUE_NUM;
+ cfg.dump_hesigb_enable = WIFI_DUMP_HESIGB_ENABLED;
+ cfg.magic = WIFI_INIT_CONFIG_MAGIC;
+
+ int err = rpc_wifi_init(&cfg);
+ if (err) {
+ return err;
+ }
+ err = rpc_wifi_set_mode(WIFI_MODE_STA);
+ if (err) {
+ return err;
+ }
+ err = rpc_wifi_start();
+ if (err) {
+ return err;
+ }
+
+ /* Power save OFF, and this is not a performance tweak - it decides whether the board is
+ * reachable at all.
+ *
+ * ESP-IDF's default is WIFI_PS_MIN_MODEM (esp_wifi_types_generic.h:376): the station sleeps and
+ * only wakes for a beacon every DTIM period. A sleeping station misses frames the AP does not
+ * buffer for it, and broadcast ARP is exactly that. The observed symptom on this board was
+ * precise and misleading: DHCP completed - because the host speaks first and the reply arrives
+ * inside the wake window - the board took a real lease, and then it answered no ARP and no ping,
+ * with the frame counter advancing about once per ten seconds. It looked like a broken receive
+ * path rather than a radio that was asleep.
+ *
+ * A device that exists to answer requests cannot sleep between them. WIFI_PS_NONE. */
+ return rpc_wifi_set_ps(WIFI_PS_NONE);
+}
+
+/* The station's MAC. Needed by the IP stack: ARP and Ethernet framing are built around it, and it
+ * belongs to the C6's radio, not to this chip. */
+int hosted_wifi_get_mac(uint8_t out[6])
+{
+ return rpc_wifi_get_mac(WIFI_IF_STA, out);
+}
+
+/* Scan every channel and report how many networks were seen.
+ *
+ * Blocking: the RPC layer waits for the coprocessor to finish, which takes a couple of seconds
+ * across all channels. A scan needs no credentials, which makes it the cheapest end-to-end proof
+ * that the RPC path and the radio both work. */
+int hosted_wifi_scan(uint16_t *found)
+{
+ wifi_scan_config_t scan = { 0 };
+ scan.show_hidden = true;
+ int err = rpc_wifi_scan_start(&scan, true);
+ if (err) {
+ return err;
+ }
+ return rpc_wifi_scan_get_ap_num(found);
+}
+
+/* One scan result, flattened to scalars.
+ *
+ * `ssid_out` must have room for 33 bytes; the SSID is copied NUL-terminated. Returns the number of
+ * records actually written into the caller's view, which is `min(*count, what the slave has)`.
+ */
+int hosted_wifi_scan_record(uint16_t index, char *ssid_out, int8_t *rssi_out,
+ uint8_t *channel_out, uint8_t *authmode_out)
+{
+ /* One record at a time, into a local, so the caller never sees a wifi_ap_record_t. Asking the
+ * slave for a single record by index is not part of the RPC, so this fetches the run up to
+ * `index` and keeps the last - fine for the small numbers a diagnostic prints, and stated here
+ * rather than hidden because it is O(n^2) if someone loops it over hundreds of networks. */
+ static wifi_ap_record_t records[16];
+ uint16_t want = index + 1;
+ if (want > 16) {
+ return -1;
+ }
+ int err = rpc_wifi_scan_get_ap_records(&want, records);
+ if (err) {
+ return err;
+ }
+ if (index >= want) {
+ return -1;
+ }
+
+ const wifi_ap_record_t *r = &records[index];
+ size_t n = strnlen((const char *)r->ssid, 32);
+ memcpy(ssid_out, r->ssid, n);
+ ssid_out[n] = 0;
+ *rssi_out = r->rssi;
+ *channel_out = r->primary;
+ *authmode_out = (uint8_t)r->authmode;
+ return 0;
+}
+
+/* Join a network.
+ *
+ * `ssid` and `psk` are NUL-terminated. The PSK is copied into the request and never stored here;
+ * it arrives from a build option so it is not in the source, and this function keeps no copy after
+ * the RPC returns.
+ *
+ * `threshold.authmode` is deliberately left at 0 (WIFI_AUTH_OPEN) rather than forced to WPA2: it is
+ * a *minimum* acceptable security level, and pinning it too high refuses networks that would
+ * otherwise work while pinning it low refuses nothing. The AP's actual authmode is what gets used.
+ */
+int hosted_wifi_connect(const char *ssid, const char *psk)
+{
+ wifi_config_t conf = { 0 };
+
+ size_t ssid_len = strnlen(ssid, sizeof(conf.sta.ssid) - 1);
+ memcpy(conf.sta.ssid, ssid, ssid_len);
+
+ size_t psk_len = strnlen(psk, sizeof(conf.sta.password) - 1);
+ memcpy(conf.sta.password, psk, psk_len);
+
+ /* Scan all channels and pick the strongest match rather than the first: this network has both a
+ * 2.4 GHz and a 5 GHz radio on the same SSID family, and the C6 is 2.4 GHz only. */
+ conf.sta.scan_method = WIFI_ALL_CHANNEL_SCAN;
+ conf.sta.sort_method = WIFI_CONNECT_AP_BY_SIGNAL;
+
+ int err = rpc_wifi_set_config(WIFI_IF_STA, &conf);
+ if (err) {
+ return err;
+ }
+ return rpc_wifi_connect();
+}
diff --git a/src/net/hosted_glue.zig b/src/net/hosted_glue.zig
new file mode 100644
index 0000000..40bac2e
--- /dev/null
+++ b/src/net/hosted_glue.zig
@@ -0,0 +1,320 @@
+//! The symbols ESP-Hosted's transport needs that are neither libc nor the `g_h` port table:
+//! this board's transport configuration, a logging sink, and honest stubs for the layers above
+//! the transport that milestone 1 does not run.
+//!
+//! Measured, not guessed. Linking transport_drv.o + transport_util.o + sdio_drv.o + mempool.o
+//! leaves 26 undefined symbols. src/net/libc.zig covers the libc ones, src/net/port.zig covers
+//! `g_h`, and everything else is here.
+//!
+//! The distinction that matters in this file: a *configuration* symbol returns real values for this
+//! board, and a *stub* prints its own name and parks. Nothing here silently returns success. On a
+//! board with no debugger, a function that quietly does nothing is indistinguishable from a
+//! function that worked, and that is the failure mode this project keeps paying for.
+
+const std = @import("std");
+const soc = @import("soc");
+const hal = @import("hal");
+
+// ---------------------------------------------------------------------------------------------
+// Board configuration is NOT here, deliberately.
+//
+// An earlier version of this file hand-wrote `esp_hosted_sdio_get_config` and
+// `esp_hosted_transport_get_reset_config` in Zig, with a Zig `extern struct` mirroring
+// `struct esp_hosted_sdio_config`. That was wrong twice over: the real signature takes a
+// `struct esp_hosted_sdio_config **` and hands back a pointer to static storage rather than
+// filling a caller's struct (host/api/include/esp_hosted_transport_config.h:151), and the real
+// struct interleaves `gpio_pin_t {void *port; int pin;}` pairs rather than plain ints
+// (same header, lines 22-45). A transcription of that layout is a silent wrong-pin bug waiting
+// to happen.
+//
+// ESP-Hosted already ships both getters, deriving every value from Kconfig:
+// host/api/src/esp_hosted_transport_config.c
+// host/port/esp/freertos/src/port_esp_hosted_host_transport_defaults.c
+// Together they compile clean under our flags and need only esp_log, esp_log_timestamp and the
+// ROM's mem* - so the build compiles them instead. Nothing is transcribed and nothing can drift.
+//
+// What guarantees they produce THIS board's wiring is a compile-time check, not a comment:
+// src/net/hosted/pin_assert.c static-asserts the Kconfig macros against the measured pin map
+// (slot 1, 4-bit, 40 MHz, CLK 18, CMD 19, D0-D3 = 14/15/16/17, C6 reset 54) and is compiled as
+// part of the hosted build. If a Kconfig value ever drifts from the board, the build fails with
+// the name of the pin instead of the radio silently not answering.
+
+// ---------------------------------------------------------------------------------------------
+// Logging
+//
+// ESP-Hosted logs through IDF's `esp_log`. Routing it to the ROM UART printer keeps the transport's
+// own diagnostics - which are good, and are how we will see the handshake progress - without
+// linking esp_log_write, its lock, its timestamp source or its level filtering.
+// ---------------------------------------------------------------------------------------------
+
+/// IDF's log levels, from esp_log_level_t.
+pub const Level = enum(c_int) { none = 0, err = 1, warn = 2, info = 3, debug = 4, verbose = 5 };
+
+/// Everything at or below this prints. `.debug` while bringing the transport up: its per-packet
+/// logging is the only view into the handshake before the IP stack exists.
+var level: Level = .debug;
+
+pub fn setLevel(l: Level) void {
+ level = l;
+}
+
+export fn esp_log_timestamp() callconv(.c) u32 {
+ // Milliseconds since boot, from the 16 MHz systimer - the only trustworthy timebase on this
+ // die, since the CPU runs at the bootloader's 90 MHz and nothing reconfigures the PLL.
+ //
+ // `read` is optional because the counter has a latch-then-read handshake that can fail to
+ // complete (see src/hal/systimer.zig, and the differential case that exists because of it).
+ // A failed read yields 0 rather than propagating: this is a log timestamp, and a logging call
+ // that panics would destroy exactly the diagnostics being printed.
+ const ticks = hal.systimer.read(.unit0) orelse return 0;
+ return @intCast(ticks / 16_000);
+}
+
+export fn esp_log_level_get(_: ?[*:0]const u8) callconv(.c) c_int {
+ return @intFromEnum(level);
+}
+
+export fn esp_log_level_set(_: ?[*:0]const u8, _: c_int) callconv(.c) void {
+ // Deliberately ignored: this build has one global level, set from Zig. Silently accepting the
+ // call is right here - a caller lowering a tag's verbosity is not load-bearing - and it is the
+ // only silent no-op in this file.
+}
+
+export fn esp_log_default_level() callconv(.c) c_int {
+ return @intFromEnum(level);
+}
+
+/// IDF v6's variadic log entry point. ESP-Hosted's ESP_LOG* macros land here.
+export fn esp_log(
+ cfg: u32,
+ // Unused: the tag is already inside `fmt`, put there by ESP-Hosted's own logging macros. Kept
+ // in the signature because this is a C ABI entry point and the argument is really passed.
+ _: ?[*:0]const u8,
+ fmt: ?[*:0]const u8,
+ ...,
+) callconv(.c) void {
+ // esp_log_config_t packs the level into the low bits; IDF's esp_log_level_t ordering means a
+ // numerically higher value is more verbose.
+ // The level lives in the low 3 bits: esp_log_config_t's `log_level` field is declared
+ // `esp_log_level_t log_level: ESP_LOG_LEVEL_LEN` (esp_log_config.h:122) with ESP_LOG_LEVEL_LEN
+ // = 3 (esp_log_level.h:30). Verified on the die by printing the raw word: info lines arrive as
+ // cfg=0x00000003 and warnings as cfg=0x00000002.
+ const msg_level: c_int = @intCast(cfg & 0x7);
+ if (msg_level > @intFromEnum(level)) return;
+
+ var ap = @cVaStart();
+ defer @cVaEnd(&ap);
+ emit(fmt orelse "", &ap);
+}
+
+/// The other entry point in IDF v6; same job, already-started va_list.
+export fn esp_log_writev(
+ msg_level: c_int,
+ _: ?[*:0]const u8,
+ fmt: ?[*:0]const u8,
+ ap: *std.builtin.VaList,
+) callconv(.c) void {
+ if (msg_level > @intFromEnum(level)) return;
+ emit(fmt orelse "", ap);
+}
+
+/// Format one already-level-filtered log line and put it on the wire.
+///
+/// Takes neither the level nor the tag, and that is the point: ESP-Hosted's logging macros
+/// (esp_hosted_log.h) bake the level letter, the timestamp and the tag into the format string they
+/// hand us. An earlier version of this function added its own prefix as well, and every line came
+/// out doubled:
+///
+/// W (136) H_SDIO_DRV: W (136) H_SDIO_DRV: provided sdio tx queue size is zero!
+///
+/// The level still matters - `esp_log` and `esp_log_writev` filter on it before calling here - it
+/// just has no business in the output a second time.
+fn emit(fmt: [*:0]const u8, ap: *std.builtin.VaList) void {
+ var line: [256]u8 = undefined;
+ const n = vsnprintf(&line, line.len, fmt, ap);
+ if (n <= 0) return;
+ const len = @min(@as(usize, @intCast(n)), line.len - 1);
+ // Print with plain `%s`, never `%.*s`: the ROM's `ets_printf` does not implement `.*`
+ // precision and prints the specifier literally, which is how the first run of this code
+ // produced "W (141) H_SDIO_DRV: %*0s" instead of a message.
+ line[len] = 0;
+ soc.rom.print("%s", .{@as([*:0]const u8, @ptrCast(&line))});
+ // ESP-Hosted's own lines already end in \n; anything else gets a terminator so the next line
+ // does not run into it.
+ if (line[len - 1] != '\n') soc.rom.print("\r\n", .{});
+}
+
+extern fn vsnprintf(buf: [*]u8, size: usize, fmt: [*:0]const u8, ap: *std.builtin.VaList) c_int;
+
+/// Reached by protobuf-c's error paths. Lives here rather than in src/net/libc.zig because it needs
+/// the ROM printer, and libc.zig is deliberately free of chip imports so it can be host-tested.
+export fn printf(fmt: [*:0]const u8, ...) callconv(.c) c_int {
+ var ap = @cVaStart();
+ defer @cVaEnd(&ap);
+ var line: [256]u8 = undefined;
+ const n = vsnprintf(&line, line.len, fmt, &ap);
+ if (n > 0) soc.rom.print("%s", .{@as([*:0]const u8, @ptrCast(&line))});
+ return n;
+}
+
+/// IDF's hex dump, referenced by ESP-Hosted's ESP_HEXLOG* macros once DEBUG-level logging is
+/// compiled in (sdkconfig.h override 5). A real implementation, because a hexdump that prints
+/// nothing is worse than none at all when the thing being debugged is a wire format - but bounded to
+/// 64 bytes a call, since the point is to identify a packet rather than to transcribe it.
+export fn esp_log_buffer_hexdump_internal(
+ tag: ?[*:0]const u8,
+ buffer: ?*const anyopaque,
+ buff_len: u16,
+ msg_level: c_int,
+) callconv(.c) void {
+ if (msg_level > @intFromEnum(level)) return;
+ const bytes: [*]const u8 = @ptrCast(buffer orelse return);
+ const n = @min(buff_len, 64);
+ soc.rom.print("%s: %u bytes:", .{ @as([*:0]const u8, tag orelse "hex"), @as(u32, buff_len) });
+ for (0..n) |i| soc.rom.print(" %02x", .{@as(u32, bytes[i])});
+ if (n < buff_len) soc.rom.print(" ...", .{});
+ soc.rom.print("\r\n", .{});
+}
+
+export fn esp_rom_printf(fmt: [*:0]const u8, ...) callconv(.c) c_int {
+ // The ROM printer is what this is named after; hand it straight over.
+ var ap = @cVaStart();
+ defer @cVaEnd(&ap);
+ var line: [256]u8 = undefined;
+ const n = vsnprintf(&line, line.len, fmt, &ap);
+ if (n > 0) soc.rom.print("%s", .{@as([*:0]const u8, @ptrCast(&line))});
+ return n;
+}
+
+// ---------------------------------------------------------------------------------------------
+// Event base
+//
+// `ESP_HOSTED_EVENT` and `WIFI_EVENT` are esp_event base symbols - opaque pointers whose *address*
+// is the identity. Nothing dereferences them, so a byte of storage each is a complete
+// implementation, and `_h_event_post` in port.zig is what actually routes events.
+// ---------------------------------------------------------------------------------------------
+
+export const ESP_HOSTED_EVENT: u8 = 0;
+export const WIFI_EVENT: u8 = 0;
+
+// ---------------------------------------------------------------------------------------------
+// Stubs for the layers milestone 1 does not run.
+//
+// Each prints its own name and parks. That is the whole point: reaching one of these means the
+// transport got further than expected and the next layer is now needed, which is information. A
+// stub that returned 0 would turn that into a hang with no console output.
+// ---------------------------------------------------------------------------------------------
+
+fn unimplemented(comptime name: []const u8) noreturn {
+ soc.rom.print("\r\n=== esp_hosted reached " ++ name ++ ", which this build does not implement.\r\n", .{});
+ soc.rom.print("=== The transport got further than milestone 1. Implement it in src/net/.\r\n", .{});
+ while (true) {}
+}
+
+// rpc_start and serial_ll_rx_handler are no longer stubbed here: build.zig compiles ESP-Hosted's
+// own RPC layer (host/drivers/rpc/**, plus protobuf-c and the generated descriptors), which defines
+// both. The loud stub did its job first - it is what turned "the radio hangs" into a console line
+// naming rpc_start as the next thing to build.
+
+// Bluetooth is not stubbed here. ESP-Hosted ships host/drivers/bt/hci_stub_drv.c, which is the
+// vendor's own no-op hci_drv_init and a drop-everything hci_rx_handler for a host without BT, and
+// build.zig compiles it. Defining them here as well is a duplicate-symbol link error - which is how
+// this comment came to exist. BT is switched off in src/net/hosted/sdkconfig.h so that file does not
+// pull NimBLE in.
+
+/// ESP-Hosted's console commands. There is no console component in this image.
+export fn esp_hosted_cli_start() callconv(.c) c_int {
+ unimplemented("esp_hosted_cli_start");
+}
+
+export fn esp_hosted_cli_stop() callconv(.c) c_int {
+ unimplemented("esp_hosted_cli_stop");
+}
+
+// create_debugging_tasks is ESP-Hosted's own (host/utils/stats.c), compiled by build.zig. With the
+// stats Kconfig options off it spawns nothing.
+
+/// IDF's internal Wi-Fi receive-callback registration, and it stays a loud stub deliberately: the
+/// station frame path does not go through it, and it has never been reached.
+///
+/// It was expected to be the seam. It is not. In the file set build.zig compiles, the only caller
+/// is `transport_drv_remove_channel` (transport_drv.c:252), which unregisters on teardown - and
+/// nothing in this project tears a channel down. `transport_drv_add_channel`, the registration
+/// half, never mentions it (transport_drv.c:440-502): it stores the callback in `chan_arr[if_type]`
+/// and `sdio_process_rx_task` calls it from there (sdio_drv.c:1394-1408). That channel callback is
+/// the whole story, and src/net/link.zig is what registers it.
+///
+/// This function exists because ESP-Hosted's C references the symbol and the link needs it. If it
+/// is ever reached, the console line it prints is real information - something began tearing the
+/// station channel down - and that is worth more than a silent zero.
+export fn esp_wifi_internal_reg_rxcb(_: c_int, _: ?*anyopaque) callconv(.c) c_int {
+ unimplemented("esp_wifi_internal_reg_rxcb");
+}
+
+/// Host power-save. Not used: this board is mains-powered and the path adds a wakeup protocol
+/// between the P4 and the C6 that nothing here needs.
+export fn stop_host_power_save() callconv(.c) c_int {
+ unimplemented("stop_host_power_save");
+}
+
+export fn esp_hosted_woke_from_power_save() callconv(.c) bool {
+ // Answering this one honestly is better than parking: it is called on the normal boot path,
+ // and the truthful answer on a board that never sleeps is "no".
+ return false;
+}
+
+export fn release_slave_reset_gpio_post_wakeup() callconv(.c) void {
+ // Same reasoning: only meaningful after a power-save wakeup, which cannot have happened.
+}
+
+// ---------------------------------------------------------------------------------------------
+// esp_netif, declined.
+//
+// ESP-Hosted's RPC layer asks IDF's network-interface layer whether an interface exists and whether
+// it is up, before handing it a received frame. This project does not use esp_netif or lwIP - the
+// whole point of src/net/ip.zig is to replace them - so there is no handle to give it and no
+// interface it would recognise.
+//
+// Answering "no interface" is the truthful answer and it is safe, and this is now observed rather
+// than hoped for: station frames reach this project through the transport's own channel callback,
+// which src/net/link.zig registers with `transport_drv_add_channel` and which sdio_drv.c:1394-1408
+// dispatches to. That path does not consult esp_netif at all. These two stay as they are.
+// ---------------------------------------------------------------------------------------------
+
+export fn esp_netif_get_handle_from_ifkey(_: ?[*:0]const u8) callconv(.c) ?*anyopaque {
+ return null;
+}
+
+export fn esp_netif_is_netif_up(_: ?*anyopaque) callconv(.c) bool {
+ return false;
+}
+
+/// mempool.c's pluggable backend. ESP-Hosted's own static pool is used, so the ops table is null;
+/// mempool.c checks for null and falls back.
+export fn os_mempool_get_ops() callconv(.c) ?*anyopaque {
+ return null;
+}
+
+// There are no tests in this file, and that is a deliberate answer rather than an omission.
+//
+// Everything here ends in the ROM UART printer (`ets_printf`, a mask-ROM address) or in
+// `vsnprintf` from src/net/libc.zig, so a standalone host build compiles but cannot link. What is
+// worth checking is the level *ordering* - and that is checkable at compile time, on every build,
+// which is strictly better than a test that only runs when someone asks:
+
+comptime {
+ // IDF's esp_log_level_t numbers levels so that a HIGHER value is MORE verbose. The filter in
+ // `esp_log` above is therefore `msg_level > level -> drop`. Inverting that inequality would
+ // silently discard exactly the transport diagnostics that bring-up depends on, and the code
+ // would look right. These assertions pin the ordering the filter assumes.
+ std.debug.assert(@intFromEnum(Level.none) < @intFromEnum(Level.err));
+ std.debug.assert(@intFromEnum(Level.err) < @intFromEnum(Level.warn));
+ std.debug.assert(@intFromEnum(Level.warn) < @intFromEnum(Level.info));
+ std.debug.assert(@intFromEnum(Level.info) < @intFromEnum(Level.debug));
+ std.debug.assert(@intFromEnum(Level.debug) < @intFromEnum(Level.verbose));
+
+ // The values must be IDF's own, not merely ordered: ESP-Hosted's C passes esp_log_level_t
+ // integers across the ABI, so a shifted enum would misclassify every line.
+ std.debug.assert(@intFromEnum(Level.err) == 1);
+ std.debug.assert(@intFromEnum(Level.verbose) == 5);
+}
diff --git a/src/net/hosted_os.zig b/src/net/hosted_os.zig
new file mode 100644
index 0000000..d844177
--- /dev/null
+++ b/src/net/hosted_os.zig
@@ -0,0 +1,890 @@
+//! ESP-Hosted's OS objects - mutex, counting semaphore, fixed-capacity queue, thread, software
+//! timer - expressed in `std.Io`, with FreeRTOS's exact observable behaviour.
+//!
+//! Nothing here reimplements a synchronisation primitive. `std.Io.Mutex`, `std.Io.Semaphore` and
+//! `std.Io.TypeErasedQueue` do the blocking; this file supplies only the three things ESP-Hosted
+//! needs that they do not have:
+//!
+//! 1. **The timeout dialect.** `_h_lock_mutex`, `_h_get_semaphore` and `_h_dequeue_item` all take
+//! an `int`, where 0 means "do not block", a negative value means "block forever", and a
+//! positive value means a bounded wait. The unit of that positive value is *not* the same in
+//! all three - see `Wait`.
+//! 2. **The return codes.** `RET_OK`/`RET_FAIL`/`RET_INVALID`/`RET_FAIL_TIMEOUT` from
+//! `port_esp_hosted_host_os.h:86-91`, which the C caller branches on.
+//! 3. **The initial state.** A FreeRTOS semaphore created by
+//! `hosted_create_semaphore` (`port_esp_hosted_host_os.c:523-547`) is given *once* before it
+//! is returned, so it starts with one permit, and callers rely on that: `sdio_drv.c:1504`,
+//! `:1508` and `:1540` each take that permit back immediately after creating the semaphore. A
+//! semaphore that started at zero would leave every count in the transport off by one.
+//!
+//! This file is deliberately free of hardware and of the C ABI, so it runs on the host under
+//! `std.Io.Threaded` and the tests below are real tests.
+
+const std = @import("std");
+const assert = std.debug.assert;
+const Io = std.Io;
+const Allocator = std.mem.Allocator;
+
+/// `port_esp_hosted_host_os.h:86-91`.
+pub const ret = struct {
+ pub const ok: c_int = 0;
+ pub const fail: c_int = -1;
+ pub const invalid: c_int = -2;
+ pub const fail_mem: c_int = -3;
+ pub const fail4: c_int = -4;
+ pub const fail_timeout: c_int = -5;
+};
+
+/// The clock everything here measures against. `.awake` is `std.Io`'s monotonic clock; on this
+/// board it is `hal.systimer`'s fixed 16 MHz, which does not move when the CPU clock does.
+pub const clock: Io.Clock = .awake;
+
+/// How often a bounded wait re-checks.
+///
+/// `std.Io.Semaphore` and `std.Io.TypeErasedQueue` have no timed acquire, and neither does
+/// `std.Io.Mutex`; only `futexWaitTimeout` does, and reaching for it would mean rebuilding those
+/// three primitives instead of using them. So a *bounded* wait polls, and an unbounded one - which
+/// is what every hot path in ESP-Hosted actually uses - blocks properly with no polling at all.
+///
+/// The cost is bounded and small: a bounded wait is used in exactly one place in the tree,
+/// `rpc_core.c:844`, the synchronous-RPC response wait, whose timeout is measured in seconds. One
+/// millisecond of added latency on a request that is allowed to take five seconds is not worth a
+/// hand-rolled futex semaphore.
+pub const poll_interval_ms: u32 = 1;
+
+/// The three shapes an ESP-Hosted timeout argument can take.
+pub const Wait = union(enum) {
+ /// `0` - try, do not block.
+ immediate,
+ /// Negative, i.e. `HOSTED_BLOCKING` (-1) or `HOSTED_BLOCK_MAX` (`portMAX_DELAY`, which reaches
+ /// an `int` parameter as -1).
+ forever,
+ /// A bounded wait, in milliseconds.
+ bounded_ms: u32,
+
+ /// The dialect used by `_h_lock_mutex` and `_h_get_semaphore`: a positive value is
+ /// milliseconds (`port_esp_hosted_host_os.c:452`, `:573`).
+ pub fn fromMillis(timeout: c_int) Wait {
+ if (timeout == 0) return .immediate;
+ if (timeout < 0) return .forever;
+ return .{ .bounded_ms = @intCast(timeout) };
+ }
+
+ /// The dialect used by `_h_dequeue_item`: a positive value is *seconds*, because the
+ /// implementation converts it with `SEC_TO_MILLISEC` before `pdMS_TO_TICKS`
+ /// (`port_esp_hosted_host_os.c:336`). The asymmetry with `fromMillis` is not a mistake in this
+ /// file; it is a mistake in ESP-Hosted that this file has to reproduce. No caller in the tree
+ /// passes a positive value to a queue, so nothing depends on it today.
+ pub fn fromQueueTimeout(timeout: c_int) Wait {
+ if (timeout == 0) return .immediate;
+ if (timeout < 0) return .forever;
+ return .{ .bounded_ms = @as(u32, @intCast(timeout)) *| 1000 };
+ }
+};
+
+/// Milliseconds on `clock`, which is what `_h_get_time_ms` returns.
+///
+/// The narrowing to `u64` before the division is not cosmetic. `Io.Timestamp.nanoseconds` is `i96`,
+/// and `@divFloor` on an `i96` compiles to a call to compiler_rt's `__divti3` - a 128-bit software
+/// division, on every call, on a 90 MHz in-order core. Narrowing first turns that into
+/// `__udivdi3`, a 64-bit one. Both were read out of the object's undefined-symbol list rather than
+/// guessed; ReleaseSmall declines to strength-reduce even a constant 64-bit divisor, so the
+/// libcall stays, but it is now half the width. The range given up is imaginary: 2^64 nanoseconds
+/// is 584 years, and this clock starts at boot.
+pub fn nowMs(io: Io) u64 {
+ const ns = clock.now(io).nanoseconds;
+ if (ns <= 0) return 0;
+ const ns64: u64 = @intCast(ns);
+ return ns64 / std.time.ns_per_ms;
+}
+
+/// Sleep one poll interval. Reports cancelation so bounded waits abandon promptly rather than
+/// spinning out the full timeout after the task has been asked to stop.
+fn pollSleep(io: Io) error{Canceled}!void {
+ return io.sleep(.fromMilliseconds(poll_interval_ms), clock);
+}
+
+// --------------------------------------------------------------------------------------- Mutex
+
+/// FreeRTOS gives ESP-Hosted a *recursive-capable* mutex handle but ESP-Hosted never recurses on
+/// one: every use is a bracketed `SDIO_LOCK`/`SDIO_UNLOCK` or equivalent, and every one of the ten
+/// call sites in the tree passes `HOSTED_BLOCK_MAX`. So a plain `std.Io.Mutex` is the whole
+/// requirement.
+pub const Mutex = struct {
+ inner: Io.Mutex = .init,
+
+ pub fn lock(m: *Mutex, io: Io, w: Wait) c_int {
+ switch (w) {
+ .immediate => return if (m.inner.tryLock()) ret.ok else ret.fail,
+ .forever => {
+ m.inner.lockUncancelable(io);
+ return ret.ok;
+ },
+ .bounded_ms => |ms| {
+ const deadline = nowMs(io) + ms;
+ while (true) {
+ if (m.inner.tryLock()) return ret.ok;
+ if (nowMs(io) >= deadline) return ret.fail;
+ pollSleep(io) catch return ret.fail;
+ }
+ },
+ }
+ }
+
+ pub fn unlock(m: *Mutex, io: Io) c_int {
+ m.inner.unlock(io);
+ return ret.ok;
+ }
+};
+
+// ----------------------------------------------------------------------------------- Semaphore
+
+/// A counting semaphore with FreeRTOS's cap and FreeRTOS's initial count.
+///
+/// The blocking path is `std.Io.Semaphore` untouched. What is added around it:
+///
+/// * a **maximum count**, because `xSemaphoreCreateCounting(maxCount, 0)` refuses a give past
+/// `maxCount` and `std.Io.Semaphore` has no ceiling. `sdio_drv.c:1502` sizes
+/// `sem_to_slave_queue` at `tx_queue_size * MAX_PRIORITY_QUEUES` precisely so that the
+/// semaphore saturates when the queues do.
+/// * a **non-blocking take**, which `std.Io.Semaphore` does not expose. It is the tail of
+/// `Semaphore.wait` (`std/Io/Semaphore.zig:18-24`) with the `cond.wait` loop removed, using
+/// the same public fields, so it takes and releases the same mutex in the same order.
+/// * an **ISR-deferred post**; see `postFromIsr`.
+pub const Semaphore = struct {
+ inner: Io.Semaphore,
+ max: u32,
+ /// Posts an interrupt handler could not deliver directly. Folded in by the next task-side
+ /// operation on this semaphore.
+ isr_posts: std.atomic.Value(u32) = .init(0),
+
+ /// `maxCount` as ESP-Hosted passes it: `<= 1` means a binary semaphore.
+ ///
+ /// Starts with one permit, matching `port_esp_hosted_host_os.c:544` - see the file header.
+ pub fn init(max_count: u32) Semaphore {
+ return .{ .inner = .{ .permits = 1 }, .max = @max(max_count, 1) };
+ }
+
+ pub fn post(s: *Semaphore, io: Io) c_int {
+ // Fold in anything an interrupt deferred, so every task-side entry point closes that
+ // window and not just the waiting ones. Two instructions when nothing is pending.
+ s.drainIsrPosts(io);
+ return if (s.add(io, 1) == 1) ret.ok else ret.fail;
+ }
+
+ /// `_h_post_semaphore_from_isr`, and the one entry in the whole table whose FreeRTOS meaning
+ /// does not survive the move to a cooperative scheduler intact.
+ ///
+ /// FreeRTOS has `xSemaphoreGiveFromISR`, which manipulates the semaphore inside a port-level
+ /// critical section and then asks for a context switch on return from the interrupt. Neither
+ /// half exists here. `std.Io.Semaphore.post` takes the semaphore's own `Io.Mutex`, and an
+ /// interrupt that blocked on a mutex held by the task it interrupted would deadlock the core -
+ /// there is no other task to run and no preemption to run it.
+ ///
+ /// What is safe on this runtime, confirmed with the runtime's author: `io.futexWake` runs
+ /// inside a critical section that clears `mstatus.MIE`, touches only the run queue, and never
+ /// takes a task-held lock. `Io.Mutex.tryLock` is a single compare-exchange. So:
+ ///
+ /// * if the mutex is free, the post happens inline and completely. On a single core with
+ /// interrupts already masked, no task can observe the intermediate state.
+ /// * if the mutex is held, the interrupted task is *running* and holds it - `Io.Condition`
+ /// releases the mutex before it blocks (`std/Io.zig:1689`), so nobody ever sleeps holding
+ /// it. The post is recorded in `isr_posts` and folded in by that task's next operation on
+ /// this semaphore, which is a few instructions away.
+ ///
+ /// The residual hole: if the interrupt lands in that few-instruction window *and* the only
+ /// other participant is already blocked in `wait`, the deferred post sits until someone else
+ /// touches the semaphore. `drainIsrPosts` exists so an application can close it from an
+ /// interrupt epilogue. On the SDIO transport this is moot: `_h_post_semaphore_from_isr` has
+ /// exactly two callers in the tree, `spi_drv.c:181` and `:190`, plus `spi_hd_drv.c:174`, and
+ /// none of them is compiled for SDIO.
+ ///
+ /// Returns `RET_OK` if the post was delivered or deferred, never fails: an interrupt has
+ /// nowhere to report a failure to.
+ pub fn postFromIsr(s: *Semaphore, io: Io) c_int {
+ if (s.inner.mutex.tryLock()) {
+ defer s.inner.mutex.unlock(io);
+ if (s.inner.permits < s.max) {
+ s.inner.permits += 1;
+ s.inner.cond.signal(io);
+ }
+ return ret.ok;
+ }
+ _ = s.isr_posts.fetchAdd(1, .release);
+ return ret.ok;
+ }
+
+ /// Fold any interrupt-deferred posts into the semaphore. Safe and cheap to call from a task at
+ /// any time; a no-op when nothing is pending.
+ pub fn drainIsrPosts(s: *Semaphore, io: Io) void {
+ const pending = s.isr_posts.swap(0, .acquire);
+ if (pending != 0) _ = s.add(io, pending);
+ }
+
+ /// Add `n` permits, saturating at `max`. Returns how many were actually added.
+ fn add(s: *Semaphore, io: Io, n: u32) u32 {
+ s.inner.mutex.lockUncancelable(io);
+ defer s.inner.mutex.unlock(io);
+ const room = s.max -| @as(u32, @intCast(s.inner.permits));
+ const added = @min(room, n);
+ if (added == 0) return 0;
+ s.inner.permits += added;
+ // One signal per permit: `Io.Condition.signal` releases exactly one waiter.
+ for (0..added) |_| s.inner.cond.signal(io);
+ return added;
+ }
+
+ /// `_h_get_semaphore`. Returns 0 on success and `RET_FAIL_TIMEOUT` otherwise, which is what
+ /// `port_esp_hosted_host_os.c:577-579` returns and what `rpc_core.c:844` tests.
+ pub fn wait(s: *Semaphore, io: Io, w: Wait) c_int {
+ switch (w) {
+ .immediate => return if (s.tryTake(io)) ret.ok else ret.fail_timeout,
+ .forever => {
+ s.drainIsrPosts(io);
+ // Cancelation is reported as RET_FAIL_TIMEOUT, which is the only failure code
+ // `hosted_get_semaphore` ever returns (port_esp_hosted_host_os.c:579) and
+ // therefore the only one callers test for.
+ s.inner.wait(io) catch return ret.fail_timeout;
+ return ret.ok;
+ },
+ .bounded_ms => |ms| {
+ const deadline = nowMs(io) + ms;
+ while (true) {
+ if (s.tryTake(io)) return ret.ok;
+ if (nowMs(io) >= deadline) return ret.fail_timeout;
+ pollSleep(io) catch return ret.fail;
+ }
+ },
+ }
+ }
+
+ /// Take a permit if one is available. The body is `Semaphore.wait`
+ /// (`std/Io/Semaphore.zig:18-24`) minus its `cond.wait` loop.
+ pub fn tryTake(s: *Semaphore, io: Io) bool {
+ s.drainIsrPosts(io);
+ s.inner.mutex.lockUncancelable(io);
+ defer s.inner.mutex.unlock(io);
+ if (s.inner.permits == 0) return false;
+ s.inner.permits -= 1;
+ if (s.inner.permits > 0) s.inner.cond.signal(io);
+ return true;
+ }
+
+ pub fn count(s: *Semaphore, io: Io) u32 {
+ s.inner.mutex.lockUncancelable(io);
+ defer s.inner.mutex.unlock(io);
+ return @intCast(s.inner.permits);
+ }
+};
+
+// --------------------------------------------------------------------------------------- Queue
+
+/// A fixed-capacity queue of runtime-sized items.
+///
+/// `_h_create_queue(qnum_elem, qitem_size)` fixes the element size at *run* time, so
+/// `std.Io.Queue(Elem)` - which needs the type at compile time - cannot be used, but
+/// `std.Io.TypeErasedQueue` can: it is a byte ring with `min`-byte put and get, which is exactly a
+/// queue of fixed-size records once every operation moves `item_size` bytes.
+///
+/// That the ring only ever moves whole items is what makes the non-blocking forms exact. The
+/// buffer is `count * item_size` bytes and every transfer is `item_size`, so the occupied length is
+/// always a multiple of `item_size`; a `min = 0` put therefore either fits the whole item or moves
+/// nothing at all, and can never leave half a record in the ring.
+pub const Queue = struct {
+ inner: Io.TypeErasedQueue,
+ item_size: u32,
+ /// Owned; freed by `destroy`.
+ buffer: []u8,
+
+ pub fn create(gpa: Allocator, count: u32, item_size: u32) ?*Queue {
+ assert(item_size > 0);
+ const q = gpa.create(Queue) catch return null;
+ const buf = gpa.alloc(u8, @as(usize, count) * item_size) catch {
+ gpa.destroy(q);
+ return null;
+ };
+ q.* = .{ .inner = .init(buf), .item_size = item_size, .buffer = buf };
+ return q;
+ }
+
+ pub fn destroy(q: *Queue, io: Io, gpa: Allocator) void {
+ q.inner.close(io);
+ gpa.free(q.buffer);
+ gpa.destroy(q);
+ }
+
+ /// `_h_queue_item`. `item` points at one `item_size` record, which is copied into the queue -
+ /// FreeRTOS's `xQueueSendToBack` copies too, which is why every caller passes `&handle` rather
+ /// than a heap pointer.
+ pub fn send(q: *Queue, io: Io, item: [*]const u8, w: Wait) c_int {
+ const n = q.item_size;
+ const slice = item[0..n];
+ switch (w) {
+ .immediate => {
+ const put = q.inner.put(io, slice, 0) catch return ret.fail;
+ return if (put == n) ret.ok else ret.fail;
+ },
+ .forever => {
+ // Uncancelable, deliberately. A cancelable blocking put can be interrupted
+ // *after* it has copied part of a record into the ring, and since the ring's
+ // occupied length is what makes the non-blocking forms exact, a half record
+ // desynchronises every subsequent transfer. FreeRTOS's portMAX_DELAY does not
+ // return early either. The cost is that a canceled task blocked here stays
+ // blocked - which it would anyway: `Future.cancel` signals only the *next*
+ // cancelation point, and ESP-Hosted's task bodies loop straight back into the
+ // queue. See `Thread.cancel`.
+ const put = q.inner.putUncancelable(io, slice, n) catch return ret.fail;
+ return if (put == n) ret.ok else ret.fail;
+ },
+ .bounded_ms => |ms| {
+ const deadline = nowMs(io) + ms;
+ while (true) {
+ const put = q.inner.put(io, slice, 0) catch return ret.fail;
+ if (put == n) return ret.ok;
+ assert(put == 0); // a partial record would corrupt the ring
+ if (nowMs(io) >= deadline) return ret.fail;
+ pollSleep(io) catch return ret.fail;
+ }
+ },
+ }
+ }
+
+ /// `_h_dequeue_item`. Returns 0 on success, `RET_FAIL` on timeout - note the asymmetry with
+ /// `Semaphore.wait`, which returns `RET_FAIL_TIMEOUT`; `port_esp_hosted_host_os.c:342` really
+ /// does return the plain failure code here.
+ pub fn receive(q: *Queue, io: Io, out: [*]u8, w: Wait) c_int {
+ const n = q.item_size;
+ const slice = out[0..n];
+ switch (w) {
+ .immediate => {
+ const got = q.inner.get(io, slice, 0) catch return ret.fail;
+ return if (got == n) ret.ok else ret.fail;
+ },
+ .forever => {
+ // Uncancelable for the same reason as `send`.
+ const got = q.inner.getUncancelable(io, slice, n) catch return ret.fail;
+ return if (got == n) ret.ok else ret.fail;
+ },
+ .bounded_ms => |ms| {
+ const deadline = nowMs(io) + ms;
+ while (true) {
+ const got = q.inner.get(io, slice, 0) catch return ret.fail;
+ if (got == n) return ret.ok;
+ assert(got == 0);
+ if (nowMs(io) >= deadline) return ret.fail;
+ pollSleep(io) catch return ret.fail;
+ }
+ },
+ }
+ }
+
+ /// `_h_queue_msg_waiting` = `uxQueueMessagesWaiting`, which counts *buffered* items only and
+ /// not producers blocked with an item in hand.
+ pub fn waiting(q: *Queue, io: Io) c_int {
+ q.inner.mutex.lockUncancelable(io);
+ defer q.inner.mutex.unlock(io);
+ return @intCast(q.inner.len / q.item_size);
+ }
+
+ /// `_h_reset_queue` = `xQueueReset`: discard everything buffered. Blocked producers and
+ /// consumers are left alone, which is also what FreeRTOS does for waiting *receivers*; it
+ /// differs in that FreeRTOS re-evaluates blocked senders. No caller in the tree uses this.
+ pub fn reset(q: *Queue, io: Io) c_int {
+ q.inner.mutex.lockUncancelable(io);
+ defer q.inner.mutex.unlock(io);
+ q.inner.start = 0;
+ q.inner.len = 0;
+ return ret.ok;
+ }
+};
+
+// -------------------------------------------------------------------------------------- Thread
+
+/// ESP-Hosted's task entry point: `void (*)(void const *)`, called once and never expected to
+/// return (`port_esp_hosted_host_os.c:163`, and every body in the tree is a `while (1)` loop).
+pub const StartRoutine = *const fn (?*const anyopaque) callconv(.c) void;
+
+pub const Thread = struct {
+ future: Io.Future(void),
+ name: [*:0]const u8,
+
+ fn trampoline(start: StartRoutine, arg: ?*const anyopaque) void {
+ start(arg);
+ }
+
+ /// `io.concurrent`, not `io.async`, and the difference is the whole point of the entry.
+ ///
+ /// `xTaskCreate` returns a task that exists and will run whatever its creator does next.
+ /// `io.async` promises less: the implementation is allowed to run the body inline before
+ /// returning, which for an ESP-Hosted task body - an unconditional `while (1)` - would never
+ /// return and would deadlock initialisation on the spot. `io.concurrent` forbids exactly that
+ /// (`std/Io.zig:2358-2364`) and reports `error.ConcurrencyUnavailable` when no unit of
+ /// concurrency is free.
+ ///
+ /// Turning that error into NULL is right: `_h_thread_create` is documented to return NULL on
+ /// failure and its callers check (`rpc_core.c:582`, `sdio_drv.c:1543`). A task pool one slot
+ /// too small then produces a legible "thread creation failed" instead of a hang.
+ pub fn create(io: Io, gpa: Allocator, name: [*:0]const u8, start: StartRoutine, arg: ?*const anyopaque) ?*Thread {
+ const t = gpa.create(Thread) catch return null;
+ t.* = .{
+ .future = io.concurrent(trampoline, .{ start, arg }) catch {
+ gpa.destroy(t);
+ return null;
+ },
+ .name = name,
+ };
+ return t;
+ }
+
+ /// `_h_thread_cancel` maps to `Future.cancel`, and this is the second place FreeRTOS's model
+ /// does not fit.
+ ///
+ /// `vTaskDelete` destroys a task from outside, wherever it happens to be. `std.Io`'s cancel is
+ /// cooperative: it asks, then *waits for the task body to return*. ESP-Hosted's task bodies
+ /// never return - `sdio_read_task`, `rpc_rx_thread` and the rest are unconditional loops - so
+ /// this call completes only if the body happens to exit, and otherwise blocks.
+ ///
+ /// That is survivable because of where it is called from: `cancel_rpc_threads`
+ /// (`rpc_core.c`) and the transport teardown paths, both of which run only when the host is
+ /// about to restart the slave. It is not survivable as a routine operation, and if a teardown
+ /// path becomes routine the fix is a `killTask` on the runtime that reclaims the slot without
+ /// unwinding, not a change here: there is no way to unwind a C frame from Zig.
+ pub fn cancel(t: *Thread, io: Io, gpa: Allocator) c_int {
+ t.future.cancel(io);
+ gpa.destroy(t);
+ return ret.ok;
+ }
+};
+
+// ------------------------------------------------------------------------------- software timers
+
+pub const TimerHandler = *const fn (?*anyopaque) callconv(.c) void;
+
+pub const TimerKind = enum(c_int) {
+ /// `H_TIMER_TYPE_ONESHOT`, port_esp_hosted_host_os.h:39.
+ oneshot = 0,
+ /// `H_TIMER_TYPE_PERIODIC`.
+ periodic = 1,
+};
+
+pub const Timer = struct {
+ handler: TimerHandler = undefined,
+ arg: ?*anyopaque = null,
+ /// Absolute deadline on `clock`, in milliseconds.
+ deadline_ms: u64 = 0,
+ /// 0 for a one-shot.
+ period_ms: u32 = 0,
+ in_use: bool = false,
+};
+
+/// One task servicing every software timer, rather than one task per timer.
+///
+/// ESP-IDF backs `_h_timer_start` with `esp_timer`, which has its own dedicated task. Doing the
+/// obvious thing here - `io.async` per timer - would cost one whole task slot and one whole static
+/// stack per timer, and ESP-Hosted starts up to three concurrently: the slave-unresponsive timer
+/// (`transport_drv.c:188`), the per-request asynchronous RPC timeout (`rpc_core.c:215`), and the
+/// power-save timer. At the stack sizes this runtime needs that is 15 KB to run three sleeps.
+///
+/// So: one task, an array of slots, and a futex word that a `start` or `stop` bumps to make the
+/// service task recompute its next deadline. Static footprint is `@sizeOf(Timer)` (24 bytes on
+/// rv32) per slot plus one task stack.
+///
+/// Handlers run on the service task, not in an interrupt, so they may block. `init_timeout_cb`
+/// (`transport_drv.c`) calls `_h_restart_host`, which never returns, and that is fine here.
+pub fn TimerService(comptime slot_count: usize) type {
+ return struct {
+ const Self = @This();
+
+ slots: [slot_count]Timer = @splat(.{}),
+ /// Bumped whenever a slot is armed or disarmed; the service task waits on it.
+ epoch: std.atomic.Value(u32) = .init(0),
+ /// Guards `slots`. A plain `Io.Mutex`: every critical section here is a few dozen
+ /// instructions and never blocks.
+ mutex: Io.Mutex = .init,
+ task: ?*Thread = null,
+ stopping: bool = false,
+
+ pub fn start(self: *Self, io: Io, gpa: Allocator) bool {
+ if (self.task != null) return true;
+ const t = gpa.create(Thread) catch return false;
+ // Concurrent for the same reason as `Thread.create`: the service loop never returns,
+ // so an implementation permitted to run it inline would never return from `start`.
+ t.* = .{
+ .future = io.concurrent(service, .{ self, io }) catch {
+ gpa.destroy(t);
+ return false;
+ },
+ .name = "hosted_timers",
+ };
+ self.task = t;
+ return true;
+ }
+
+ /// Arm a slot. Returns its index, or null when every slot is in use.
+ pub fn arm(self: *Self, io: Io, ms: u32, kind: TimerKind, handler: TimerHandler, arg: ?*anyopaque) ?usize {
+ self.mutex.lockUncancelable(io);
+ const idx = blk: {
+ for (&self.slots, 0..) |*s, i| if (!s.in_use) break :blk i;
+ self.mutex.unlock(io);
+ return null;
+ };
+ self.slots[idx] = .{
+ .handler = handler,
+ .arg = arg,
+ .deadline_ms = nowMs(io) + ms,
+ .period_ms = if (kind == .periodic) ms else 0,
+ .in_use = true,
+ };
+ self.mutex.unlock(io);
+ self.kick(io);
+ return idx;
+ }
+
+ pub fn disarm(self: *Self, io: Io, idx: usize) c_int {
+ if (idx >= slot_count) return ret.invalid;
+ self.mutex.lockUncancelable(io);
+ const was = self.slots[idx].in_use;
+ self.slots[idx].in_use = false;
+ self.mutex.unlock(io);
+ self.kick(io);
+ return if (was) ret.ok else ret.fail;
+ }
+
+ fn kick(self: *Self, io: Io) void {
+ _ = self.epoch.fetchAdd(1, .release);
+ io.futexWake(u32, &self.epoch.raw, 1);
+ }
+
+ fn service(self: *Self, io: Io) void {
+ while (!self.stopping) {
+ const seen = self.epoch.load(.acquire);
+ const now = nowMs(io);
+
+ // Fire everything due, collecting the handlers first so none of them runs while
+ // the slot table is locked: a handler may arm or disarm a timer.
+ var due: [slot_count]struct { h: TimerHandler, a: ?*anyopaque } = undefined;
+ var due_len: usize = 0;
+ var next_deadline: ?u64 = null;
+
+ self.mutex.lockUncancelable(io);
+ for (&self.slots) |*s| {
+ if (!s.in_use) continue;
+ if (s.deadline_ms <= now) {
+ due[due_len] = .{ .h = s.handler, .a = s.arg };
+ due_len += 1;
+ if (s.period_ms == 0) {
+ s.in_use = false;
+ } else {
+ s.deadline_ms = now + s.period_ms;
+ }
+ }
+ if (s.in_use) {
+ if (next_deadline == null or s.deadline_ms < next_deadline.?)
+ next_deadline = s.deadline_ms;
+ }
+ }
+ self.mutex.unlock(io);
+
+ for (due[0..due_len]) |d| d.h(d.a);
+ if (due_len != 0) continue;
+
+ if (next_deadline) |dl| {
+ const remaining = dl -| nowMs(io);
+ io.futexWaitTimeout(u32, &self.epoch.raw, seen, .{
+ .duration = .{ .clock = clock, .raw = .fromMilliseconds(@intCast(remaining)) },
+ }) catch return;
+ } else {
+ io.futexWait(u32, &self.epoch.raw, seen) catch return;
+ }
+ }
+ }
+
+ pub fn stop(self: *Self, io: Io, gpa: Allocator) void {
+ const t = self.task orelse return;
+ self.stopping = true;
+ self.kick(io);
+ t.future.cancel(io);
+ gpa.destroy(t);
+ self.task = null;
+ }
+ };
+}
+
+// ---------------------------------------------------------------------------------------- tests
+
+const testing = std.testing;
+
+fn hostIo() struct { threaded: *Io.Threaded, io: Io } {
+ const t = testing.allocator.create(Io.Threaded) catch unreachable;
+ t.* = .init(testing.allocator, .{});
+ return .{ .threaded = t, .io = t.io() };
+}
+
+test "Semaphore starts with one permit, as FreeRTOS's create+give does" {
+ var h = hostIo();
+ defer {
+ h.threaded.deinit();
+ testing.allocator.destroy(h.threaded);
+ }
+ const io = h.io;
+
+ // sdio_drv.c:1502-1504 creates a counting semaphore and immediately takes the permit that
+ // hosted_create_semaphore left behind. If the count started at zero this take would fail and
+ // every subsequent count would be one too high.
+ var s = Semaphore.init(60);
+ try testing.expectEqual(@as(u32, 1), s.count(io));
+ try testing.expectEqual(ret.ok, s.wait(io, .immediate));
+ try testing.expectEqual(@as(u32, 0), s.count(io));
+
+ // Empty: a non-blocking take reports RET_FAIL_TIMEOUT, which is the code rpc_core.c:844 tests.
+ try testing.expectEqual(ret.fail_timeout, s.wait(io, .immediate));
+}
+
+test "Semaphore counts, saturates at max, and times out" {
+ var h = hostIo();
+ defer {
+ h.threaded.deinit();
+ testing.allocator.destroy(h.threaded);
+ }
+ const io = h.io;
+
+ var s = Semaphore.init(3);
+ // Starts at 1; two more posts reach the cap.
+ try testing.expectEqual(ret.ok, s.post(io));
+ try testing.expectEqual(ret.ok, s.post(io));
+ try testing.expectEqual(@as(u32, 3), s.count(io));
+ // FreeRTOS's xSemaphoreGive returns pdFALSE past maxCount, and so does this.
+ try testing.expectEqual(ret.fail, s.post(io));
+ try testing.expectEqual(@as(u32, 3), s.count(io));
+
+ for (0..3) |_| try testing.expectEqual(ret.ok, s.wait(io, .immediate));
+
+ // A bounded wait on an empty semaphore returns RET_FAIL_TIMEOUT, and takes at least as long as
+ // it was asked to.
+ const before = nowMs(io);
+ try testing.expectEqual(ret.fail_timeout, s.wait(io, .{ .bounded_ms = 25 }));
+ try testing.expect(nowMs(io) - before >= 25);
+}
+
+test "Semaphore: a blocked waiter is released by a post from another task" {
+ var h = hostIo();
+ defer {
+ h.threaded.deinit();
+ testing.allocator.destroy(h.threaded);
+ }
+ const io = h.io;
+
+ var s = Semaphore.init(4);
+ try testing.expectEqual(ret.ok, s.wait(io, .immediate)); // drain the initial permit
+
+ const Worker = struct {
+ fn run(sem: *Semaphore, i: Io) c_int {
+ return sem.wait(i, .forever);
+ }
+ };
+ var f = io.async(Worker.run, .{ &s, io });
+ // Give the waiter time to actually block, then release it.
+ try io.sleep(.fromMilliseconds(20), clock);
+ try testing.expectEqual(ret.ok, s.post(io));
+ try testing.expectEqual(ret.ok, f.await(io));
+ try testing.expectEqual(@as(u32, 0), s.count(io));
+}
+
+test "Semaphore: an interrupt-deferred post is folded in by the next task-side operation" {
+ var h = hostIo();
+ defer {
+ h.threaded.deinit();
+ testing.allocator.destroy(h.threaded);
+ }
+ const io = h.io;
+
+ var s = Semaphore.init(4);
+ try testing.expectEqual(ret.ok, s.wait(io, .immediate));
+
+ // Simulate the contended case: hold the semaphore's mutex, so postFromIsr cannot deliver
+ // inline and must defer. This is the exact window described on `postFromIsr`.
+ s.inner.mutex.lockUncancelable(io);
+ try testing.expectEqual(ret.ok, s.postFromIsr(io));
+ try testing.expectEqual(@as(u32, 1), s.isr_posts.load(.acquire));
+ s.inner.mutex.unlock(io);
+
+ // The next task-side touch delivers it.
+ try testing.expectEqual(ret.ok, s.wait(io, .immediate));
+ try testing.expectEqual(@as(u32, 0), s.isr_posts.load(.acquire));
+
+ // Uncontended, it lands directly.
+ try testing.expectEqual(ret.ok, s.postFromIsr(io));
+ try testing.expectEqual(@as(u32, 0), s.isr_posts.load(.acquire));
+ try testing.expectEqual(@as(u32, 1), s.count(io));
+}
+
+test "Queue: fixed-capacity records, non-blocking edges, and message count" {
+ var h = hostIo();
+ defer {
+ h.threaded.deinit();
+ testing.allocator.destroy(h.threaded);
+ }
+ const io = h.io;
+ const gpa = testing.allocator;
+
+ // 24 bytes is sizeof(interface_buffer_handle_t) on rv32, which is what every transport queue
+ // in ESP-Hosted carries.
+ const item_size = 24;
+ const q = Queue.create(gpa, 4, item_size).?;
+ defer q.destroy(io, gpa);
+
+ try testing.expectEqual(@as(c_int, 0), q.waiting(io));
+ // Empty, non-blocking: RET_FAIL, and note it is RET_FAIL and not RET_FAIL_TIMEOUT.
+ var out: [item_size]u8 = undefined;
+ try testing.expectEqual(ret.fail, q.receive(io, &out, .immediate));
+
+ var item: [item_size]u8 = undefined;
+ for (0..4) |i| {
+ @memset(&item, @intCast(i));
+ try testing.expectEqual(ret.ok, q.send(io, &item, .immediate));
+ try testing.expectEqual(@as(c_int, @intCast(i + 1)), q.waiting(io));
+ }
+ // Full: a non-blocking send fails and leaves no partial record behind.
+ @memset(&item, 0xFF);
+ try testing.expectEqual(ret.fail, q.send(io, &item, .immediate));
+ try testing.expectEqual(@as(c_int, 4), q.waiting(io));
+
+ // FIFO order, whole records.
+ for (0..4) |i| {
+ try testing.expectEqual(ret.ok, q.receive(io, &out, .forever));
+ try testing.expect(std.mem.allEqual(u8, &out, @intCast(i)));
+ }
+ try testing.expectEqual(@as(c_int, 0), q.waiting(io));
+
+ // A bounded receive on an empty queue waits and then fails.
+ const before = nowMs(io);
+ try testing.expectEqual(ret.fail, q.receive(io, &out, .{ .bounded_ms = 25 }));
+ try testing.expect(nowMs(io) - before >= 25);
+}
+
+test "Queue: blocking receive is woken by a producer, and reset discards" {
+ var h = hostIo();
+ defer {
+ h.threaded.deinit();
+ testing.allocator.destroy(h.threaded);
+ }
+ const io = h.io;
+ const gpa = testing.allocator;
+
+ const q = Queue.create(gpa, 2, 4).?;
+ defer q.destroy(io, gpa);
+
+ const Consumer = struct {
+ fn run(queue: *Queue, i: Io) u32 {
+ var buf: [4]u8 = undefined;
+ if (queue.receive(i, &buf, .forever) != ret.ok) return 0xDEAD;
+ return std.mem.readInt(u32, &buf, .little);
+ }
+ };
+ var f = io.async(Consumer.run, .{ q, io });
+ try io.sleep(.fromMilliseconds(20), clock);
+
+ var word: [4]u8 = undefined;
+ std.mem.writeInt(u32, &word, 0xC0FFEE, .little);
+ try testing.expectEqual(ret.ok, q.send(io, &word, .forever));
+ try testing.expectEqual(@as(u32, 0xC0FFEE), f.await(io));
+
+ // reset drops buffered records.
+ try testing.expectEqual(ret.ok, q.send(io, &word, .immediate));
+ try testing.expectEqual(ret.ok, q.send(io, &word, .immediate));
+ try testing.expectEqual(@as(c_int, 2), q.waiting(io));
+ _ = q.reset(io);
+ try testing.expectEqual(@as(c_int, 0), q.waiting(io));
+}
+
+test "Mutex: the three timeout dialects" {
+ var h = hostIo();
+ defer {
+ h.threaded.deinit();
+ testing.allocator.destroy(h.threaded);
+ }
+ const io = h.io;
+
+ var m: Mutex = .{};
+ try testing.expectEqual(ret.ok, m.lock(io, .forever));
+ // Held: a non-blocking lock fails rather than deadlocking.
+ try testing.expectEqual(ret.fail, m.lock(io, .immediate));
+ const before = nowMs(io);
+ try testing.expectEqual(ret.fail, m.lock(io, .{ .bounded_ms = 25 }));
+ try testing.expect(nowMs(io) - before >= 25);
+ try testing.expectEqual(ret.ok, m.unlock(io));
+ try testing.expectEqual(ret.ok, m.lock(io, .immediate));
+ try testing.expectEqual(ret.ok, m.unlock(io));
+}
+
+test "Wait: the two timeout dialects ESP-Hosted uses" {
+ // _h_lock_mutex and _h_get_semaphore: positive means milliseconds.
+ try testing.expectEqual(Wait.immediate, Wait.fromMillis(0));
+ try testing.expectEqual(Wait.forever, Wait.fromMillis(-1));
+ // HOSTED_BLOCK_MAX is portMAX_DELAY, 0xFFFFFFFF, which reaches an `int` parameter as -1.
+ try testing.expectEqual(Wait.forever, Wait.fromMillis(@bitCast(@as(u32, 0xFFFF_FFFF))));
+ try testing.expectEqual(Wait{ .bounded_ms = 5000 }, Wait.fromMillis(5000));
+
+ // _h_dequeue_item: positive means seconds. port_esp_hosted_host_os.c:336.
+ try testing.expectEqual(Wait{ .bounded_ms = 5000 }, Wait.fromQueueTimeout(5));
+ try testing.expectEqual(Wait.forever, Wait.fromQueueTimeout(-1));
+}
+
+test "TimerService: one-shot fires once, periodic repeats, stop cancels" {
+ var h = hostIo();
+ defer {
+ h.threaded.deinit();
+ testing.allocator.destroy(h.threaded);
+ }
+ const io = h.io;
+ const gpa = testing.allocator;
+
+ const Counter = struct {
+ var oneshot: u32 = 0;
+ var periodic: u32 = 0;
+ fn bumpOneshot(_: ?*anyopaque) callconv(.c) void {
+ oneshot += 1;
+ }
+ fn bumpPeriodic(_: ?*anyopaque) callconv(.c) void {
+ periodic += 1;
+ }
+ };
+ Counter.oneshot = 0;
+ Counter.periodic = 0;
+
+ var svc: TimerService(4) = .{};
+ try testing.expect(svc.start(io, gpa));
+ defer svc.stop(io, gpa);
+
+ _ = svc.arm(io, 10, .oneshot, Counter.bumpOneshot, null).?;
+ const p = svc.arm(io, 10, .periodic, Counter.bumpPeriodic, null).?;
+
+ try io.sleep(.fromMilliseconds(120), clock);
+ try testing.expectEqual(@as(u32, 1), Counter.oneshot);
+ try testing.expect(Counter.periodic >= 3);
+
+ // Disarming stops it; the count must not move afterwards.
+ try testing.expectEqual(ret.ok, svc.disarm(io, p));
+ const frozen = Counter.periodic;
+ try io.sleep(.fromMilliseconds(60), clock);
+ try testing.expectEqual(frozen, Counter.periodic);
+ // Disarming an already-disarmed slot reports failure, as esp_timer_stop does.
+ try testing.expectEqual(ret.fail, svc.disarm(io, p));
+}
+
+test "TimerService: slot exhaustion is reported, not fatal" {
+ var h = hostIo();
+ defer {
+ h.threaded.deinit();
+ testing.allocator.destroy(h.threaded);
+ }
+ const io = h.io;
+
+ const Nop = struct {
+ fn f(_: ?*anyopaque) callconv(.c) void {}
+ };
+ var svc: TimerService(2) = .{};
+ _ = svc.arm(io, 10_000, .oneshot, Nop.f, null).?;
+ _ = svc.arm(io, 10_000, .oneshot, Nop.f, null).?;
+ try testing.expectEqual(@as(?usize, null), svc.arm(io, 10_000, .oneshot, Nop.f, null));
+}
diff --git a/src/net/ip.zig b/src/net/ip.zig
new file mode 100644
index 0000000..2cc4301
--- /dev/null
+++ b/src/net/ip.zig
@@ -0,0 +1,2903 @@
+//! A minimal IPv4 stack: Ethernet, ARP, IPv4, ICMP echo, UDP, a DHCP client, one TCP client and
+//! HTTP GET. This is what replaces lwIP.
+//!
+//! Two functions drive everything and nothing else touches the outside world:
+//!
+//! stack.onFrame(frame) a received Ethernet frame, headers and all
+//! stack.tick(now_ms) time passing, in milliseconds, from anywhere the caller likes
+//!
+//! and one callback carries frames out (`send`, supplied to `init`). There is no `std.Io`, no
+//! allocator, no clock read and no hidden thread. That is not minimalism for its own sake: it is
+//! what makes the whole stack testable on the host, where a "network" is a test function that hands
+//! `onFrame` bytes it wrote by hand and reads back whatever `send` was given. Every protocol
+//! behaviour in this file is exercised that way in `ip_test.zig`, including retransmission - which
+//! on a real timer would be a flaky test and here is two calls to `tick`.
+//!
+//! **Everything is statically sized.** `Stack` is one struct with fixed buffers inside it; there is
+//! no allocator, not even a `FixedBufferAllocator`, because nothing here has a lifetime that an
+//! arena would model better than a field does. `@sizeOf(Stack)` is asserted at compile time below
+//! (`footprint`) so the number cannot drift silently against the ~128 KB of L2MEM the image has.
+//!
+//! Wire formats are matched field by field against the lwIP this replaces, and every one is cited:
+//! ESP-IDF v6.0.2 carries lwIP at `components/lwip/lwip/src/`, and the packed structs in
+//! `include/lwip/prot/*.h` are the reference for offsets, and its `.c` files for behaviour. Where
+//! this stack deliberately differs from lwIP, the comment says so and why.
+//!
+//! ## What this is not
+//!
+//! * No IPv6, no TCP listen/accept, no IP fragmentation or reassembly, no TLS. Out of scope.
+//! * No congestion control. TCP sends at most one unacknowledged segment at a time (see `Tcp`),
+//! which is a fixed window of one and therefore needs no congestion window, no slow start and
+//! no fast recovery. It is also slow. For an HTTP GET of a few kilobytes over Wi-Fi that is the
+//! right trade; for bulk transfer it is not, and nothing here pretends otherwise.
+//! * No VLAN tags, no 802.1Q. A tagged frame is dropped as an unknown ethertype.
+//! * No transfer coding but `identity` and `chunked`. Anything else - `gzip`, `deflate`, a
+//! stack of them - is rejected with `error.UnsupportedTransferEncoding` rather than handed
+//! back with its framing bytes still in it.
+//! * DNS resolves A records only, one query at a time, over the UDP already here, with no
+//! cache. `resolve` follows `httpGet`'s protocol exactly: start, `error.WouldBlock`, the
+//! caller drives `tick`/`onFrame`, call again with the same name.
+
+const std = @import("std");
+const assert = std.debug.assert;
+
+// =============================================================================== sizing
+//
+// The whole static footprint, in one place. Every buffer in `Stack` is one of these.
+
+/// Ethernet MTU: the largest IP datagram that fits in one frame.
+pub const mtu: usize = 1500;
+/// Ethernet header: 6 destination + 6 source + 2 ethertype. lwIP `prot/ethernet.h:89`
+/// (`SIZEOF_ETH_HDR`, with its optional `ETH_PAD_SIZE` at zero).
+pub const eth_hlen: usize = 14;
+/// The largest frame this stack will build or accept, excluding the FCS the MAC appends.
+pub const frame_max: usize = eth_hlen + mtu;
+
+/// ARP cache entries. Four is enough for the gateway, one peer, and two strangers, which is the
+/// whole population a single-connection HTTP client on a home /24 ever needs to address.
+pub const arp_cache_len: usize = 4;
+
+/// Bytes of HTTP response head (status line plus headers) that may be buffered while waiting for
+/// the blank line. Exceeding this fails the request rather than truncating silently.
+///
+/// 2048, raised from 1024 against a measurement rather than a guess. A real response from the site
+/// this stack was pointed at - Cloudflare in front of GitHub Pages - carries **1043 bytes** of head:
+/// 26 header lines, of which `Report-To` alone is 254 bytes and `Nel`, `X-Fastly-Request-ID`,
+/// `X-GitHub-Request-Id` and `alt-svc` are another 200 between them. At 1024 the request failed with
+/// `HttpHeadersTooLong` after the body had already been negotiated, 19 bytes short.
+///
+/// Modern CDN responses simply have large heads, and 1 KB is not a realistic ceiling for one. 2 KB
+/// leaves about a kilobyte of margin over the measured case; the failure remains a named error
+/// rather than truncation, so a head that exceeds even this is still diagnosable rather than silently
+/// wrong.
+pub const http_head_max: usize = 2048;
+
+/// Bytes of chunked *framing* - one chunk's extension parameters, or the whole trailer section -
+/// tolerated before the response is failed. Framing is skipped rather than stored, so this bounds
+/// work and not memory: without it a peer that streams `;a=b` forever, or trailer lines forever,
+/// is a request that never ends and never errors. 512 is generous; a real trailer section is one
+/// or two short lines.
+pub const http_framing_max: usize = 512;
+
+/// The longest host name `resolve` will encode into a DNS question, in dotted text. RFC 1035 2.3.4
+/// allows 255; this stack holds the encoded question in `Stack` for the duration of the query, and
+/// 64 covers every name a device that fetches one URL will ever ask for. A longer one is
+/// `error.NameTooLong`, never a silently truncated question.
+pub const dns_name_max: usize = 64;
+
+/// The encoded question that `dns_name_max` produces. Encoding turns `a.b` into `1a1b0`: one
+/// length byte per label plus the root label, which for a name with no trailing dot is exactly
+/// two bytes more than the text. RFC 1035 4.1.2.
+pub const dns_qname_max: usize = dns_name_max + 2;
+
+/// Bytes of outbound TCP payload held for retransmission. This is sized for one HTTP request line
+/// plus headers; there is no streaming send, so it is also the hard limit on request size.
+pub const tcp_tx_max: usize = 512;
+
+/// The receive window this stack advertises, in bytes, when it has that much room to consume into.
+/// One MSS: a peer that fills the window gets a segment acknowledged before it may send another.
+pub const tcp_window: u16 = 1460;
+
+/// TCP MSS offered in the SYN. 1500 - 20 (IP) - 20 (TCP).
+pub const tcp_mss: u16 = 1460;
+
+/// RFC 1122 4.2.2.6: a peer that sends no MSS option is assumed to accept 536.
+pub const tcp_default_mss: u16 = 536;
+
+/// Initial retransmission timeout. RFC 6298 2.1 specifies 1 s for a connection with no RTT sample,
+/// and this stack never takes an RTT sample - see `Tcp.rto_ms`.
+pub const tcp_rto_initial_ms: u32 = 1000;
+/// Retransmission timeout ceiling. RFC 6298 5.7 allows any value at or above 60 s; 16 s is chosen
+/// against a device whose whole reason to exist is one short request.
+pub const tcp_rto_max_ms: u32 = 16_000;
+/// Retransmissions of the same segment before the connection is abandoned with `error.TimedOut`.
+/// With the backoff above that is 1+2+4+8+16+16 = 47 s of trying.
+pub const tcp_max_retries: u8 = 6;
+/// TIME_WAIT duration. RFC 793 says 2*MSL, conventionally 240 s. Two seconds is what this uses:
+/// holding a connection block for four minutes on a part with 128 KB of RAM to protect a
+/// port number that this stack increments on every connect is the wrong trade. The risk it drops is
+/// a late duplicate segment from the *previous* incarnation of the same 4-tuple being accepted into
+/// a new one, and incrementing the local port already makes a repeat 4-tuple require 16,384
+/// connections first.
+pub const tcp_time_wait_ms: u32 = 2000;
+/// How long a half-closed connection waits for the peer's FIN before the block is released. RFC
+/// 793 has no such timer and a connection may legitimately sit in FIN-WAIT-2 forever; Linux uses
+/// 60 s for the same reason this uses 10 s - a peer that has our FIN and never answers is a peer
+/// that is gone, and the one connection block here is not worth holding for it.
+pub const tcp_fin_wait2_ms: u32 = 10_000;
+
+/// DHCP retransmission backoff, in milliseconds, indexed by attempt. RFC 2131 4.1 asks for
+/// randomised exponential backoff starting at 4 s; this starts at 2 s because the first DHCP
+/// exchange is on the critical path of every boot, and does not randomise because there is one
+/// client on this board and the collision RFC 2131 is avoiding is between many.
+const dhcp_backoff_ms = [_]u32{ 2_000, 4_000, 8_000, 16_000, 32_000, 64_000 };
+
+/// Minimum length of the BOOTP/DHCP message this stack transmits, in UDP payload bytes. RFC 951
+/// fixed BOOTP messages at 300 bytes and relay agents in the field still expect at least that
+/// much; lwIP pads the same way through its fixed-size `struct dhcp_msg`
+/// (`prot/dhcp.h:63-91`: 236 + 4 cookie + `DHCP_OPTIONS_LEN` 68 = 308).
+const dhcp_min_msg_len: usize = 300;
+
+/// DNS retransmission backoff, in milliseconds, indexed by attempt. RFC 1035 4.2.1 leaves the
+/// timer to the implementation; this is BIND's classic 1 s doubling, and the array length is the
+/// try count, so the whole exchange is bounded at 1+2+4 = 7 s and then `error.TimedOut`. The
+/// transaction id is *not* redrawn between tries: a slow first answer must still be accepted.
+const dns_backoff_ms = [_]u32{ 1_000, 2_000, 4_000 };
+
+/// Compression pointers followed while skipping one name (RFC 1035 4.1.4). This is the bound that
+/// makes a hostile message terminate: see `dnsSkipName`, where the argument is written out.
+const dns_max_jumps: u8 = 16;
+
+// =============================================================== addresses and enumerations
+
+pub const Mac = [6]u8;
+pub const Ip4 = [4]u8;
+
+pub const mac_broadcast: Mac = @splat(0xff);
+pub const ip_any: Ip4 = @splat(0x00);
+pub const ip_broadcast: Ip4 = @splat(0xff);
+
+/// Ethernet type field values. lwIP `prot/ieee.h:52-85` (`enum lwip_ieee_eth_type`).
+pub const EtherType = enum(u16) {
+ ip4 = 0x0800,
+ arp = 0x0806,
+ vlan = 0x8100,
+ ip6 = 0x86dd,
+ _,
+};
+
+/// IP header protocol numbers. lwIP `prot/ip.h:46-50`.
+pub const Protocol = enum(u8) {
+ icmp = 1,
+ tcp = 6,
+ udp = 17,
+ _,
+};
+
+// =============================================================================== checksum
+//
+// One implementation for IPv4, ICMP, UDP and TCP. The last two prepend a pseudo-header, which is
+// the only difference between them: the arithmetic is identical, so it is written once.
+
+/// The Internet checksum of RFC 1071: the one's complement of the one's complement sum of the
+/// data taken as 16-bit big-endian words, with a zero byte appended if the length is odd.
+///
+/// Incremental, because TCP and UDP checksum a pseudo-header, a header and a payload that are
+/// three separate buffers and never adjacent in memory. Feeding them in sequence must give the
+/// same answer as checksumming the concatenation, which is why `half` exists: a chunk of odd
+/// length leaves the high byte of a word owed, and the next chunk's first byte completes it.
+/// Getting that wrong is invisible until a payload happens to have odd length, which for HTTP is
+/// most of the time.
+pub const Checksum = struct {
+ /// Accumulated 16-bit words. Deferring the end-around carry is safe for any length this
+ /// stack can produce: 32 bits absorbs 65,536 words, and the largest thing checksummed here is
+ /// 1,500 bytes.
+ sum: u32 = 0,
+ /// High byte of a 16-bit word whose low byte has not arrived yet.
+ half: ?u8 = null,
+
+ pub fn update(self: *Checksum, bytes: []const u8) void {
+ var b = bytes;
+ if (self.half) |hi| {
+ if (b.len == 0) return;
+ self.sum += (@as(u32, hi) << 8) | b[0];
+ self.half = null;
+ b = b[1..];
+ }
+ var i: usize = 0;
+ while (i + 1 < b.len) : (i += 2) self.sum += std.mem.readInt(u16, b[i..][0..2], .big);
+ if (i < b.len) self.half = b[i];
+ }
+
+ /// Feed a big-endian 16-bit value, for the pseudo-header fields that are not in any buffer.
+ pub fn update16(self: *Checksum, v: u16) void {
+ var tmp: [2]u8 = undefined;
+ std.mem.writeInt(u16, &tmp, v, .big);
+ self.update(&tmp);
+ }
+
+ /// The checksum as it goes on the wire. RFC 1071 1: an odd-length buffer is padded with a
+ /// zero byte, which the fold below does implicitly by shifting the owed byte up.
+ pub fn final(self: Checksum) u16 {
+ var s = self.sum;
+ if (self.half) |hi| s += @as(u32, hi) << 8;
+ while (s >> 16 != 0) s = (s & 0xffff) + (s >> 16);
+ return ~@as(u16, @truncate(s));
+ }
+};
+
+/// The Internet checksum of one contiguous buffer.
+pub fn checksum(bytes: []const u8) u16 {
+ var c: Checksum = .{};
+ c.update(bytes);
+ return c.final();
+}
+
+/// The TCP/UDP pseudo-header of RFC 793 3.1: source address, destination address, a zero byte, the
+/// protocol number and the transport length. Not transmitted; only checksummed.
+fn pseudoHeader(c: *Checksum, src: Ip4, dst: Ip4, proto: Protocol, len: u16) void {
+ c.update(&src);
+ c.update(&dst);
+ c.update16(@intFromEnum(proto)); // the zero byte and the protocol byte, as one word
+ c.update16(len);
+}
+
+/// A checksum for a UDP or TCP segment: pseudo-header, then the segment with its own checksum
+/// field already zeroed.
+fn transportChecksum(src: Ip4, dst: Ip4, proto: Protocol, segment: []const u8) u16 {
+ var c: Checksum = .{};
+ pseudoHeader(&c, src, dst, proto, @intCast(segment.len));
+ c.update(segment);
+ return c.final();
+}
+
+/// Verify a received transport checksum. A UDP datagram may carry zero, meaning "not computed"
+/// (RFC 768); TCP may not.
+fn transportChecksumOk(src: Ip4, dst: Ip4, proto: Protocol, segment: []const u8, field: u16) bool {
+ if (proto == .udp and field == 0) return true;
+ // Summing a segment that already contains its own checksum yields 0 (or, equivalently, the
+ // sum before complementing is 0xffff). RFC 1071 1.
+ return transportChecksum(src, dst, proto, segment) == 0;
+}
+
+/// A transmitted UDP checksum of zero would be read as "not computed", so RFC 768 requires it be
+/// sent as the equivalent 0xffff instead. TCP has no such rule and no such ambiguity.
+pub fn udpChecksumOnWire(c: u16) u16 {
+ return if (c == 0) 0xffff else c;
+}
+
+// ============================================================== unaligned big-endian access
+//
+// `std.mem.readInt`/`writeInt` with an explicit endianness at every single field. Never a shift and
+// an or: a network header written by hand is where byte order goes wrong, and it goes wrong
+// silently, on one field, in a way that looks like a hardware problem.
+
+inline fn rd16(b: []const u8, off: usize) u16 {
+ return std.mem.readInt(u16, b[off..][0..2], .big);
+}
+inline fn rd32(b: []const u8, off: usize) u32 {
+ return std.mem.readInt(u32, b[off..][0..4], .big);
+}
+inline fn wr16(b: []u8, off: usize, v: u16) void {
+ std.mem.writeInt(u16, b[off..][0..2], v, .big);
+}
+inline fn wr32(b: []u8, off: usize, v: u32) void {
+ std.mem.writeInt(u32, b[off..][0..4], v, .big);
+}
+inline fn rdIp(b: []const u8, off: usize) Ip4 {
+ return b[off..][0..4].*;
+}
+inline fn wrIp(b: []u8, off: usize, v: Ip4) void {
+ b[off..][0..4].* = v;
+}
+inline fn rdMac(b: []const u8, off: usize) Mac {
+ return b[off..][0..6].*;
+}
+inline fn wrMac(b: []u8, off: usize, v: Mac) void {
+ b[off..][0..6].* = v;
+}
+
+// ============================================================================ header offsets
+//
+// Byte offsets rather than packed structs. `extern struct` would need `align(1)` on every field and
+// a byte-swap on every access on this little-endian part, and the offsets are what the RFCs and
+// lwIP's headers actually state, so this is the form that can be checked against them by eye.
+
+/// lwIP `prot/ethernet.h:76-83` (`struct eth_hdr`).
+const eth = struct {
+ const dst = 0;
+ const src = 6;
+ const ethertype = 12;
+};
+
+/// lwIP `prot/etharp.h:86-96` (`struct etharp_hdr`), `SIZEOF_ETHARP_HDR` 28 at `:102`.
+const arp = struct {
+ const hwtype = 0;
+ const proto = 2;
+ const hwlen = 4;
+ const protolen = 5;
+ const opcode = 6;
+ const sha = 8; // sender hardware address
+ const spa = 14; // sender protocol address
+ const tha = 18; // target hardware address
+ const tpa = 24; // target protocol address
+ const len = 28;
+
+ /// lwIP `prot/iana.h:54` (`LWIP_IANA_HWTYPE_ETHERNET`).
+ const hwtype_ethernet: u16 = 1;
+ /// lwIP `prot/etharp.h:105-108` (`enum etharp_opcode`).
+ const op_request: u16 = 1;
+ const op_reply: u16 = 2;
+};
+
+/// lwIP `prot/ip4.h:73-97` (`struct ip_hdr`), `IP_HLEN` 20 at `:64`.
+const ip4 = struct {
+ const v_hl = 0;
+ const tos = 1;
+ const total_len = 2;
+ const id = 4;
+ const frag = 6;
+ const ttl = 8;
+ const proto = 9;
+ const chksum = 10;
+ const src = 12;
+ const dst = 16;
+ const hlen = 20;
+
+ /// lwIP `prot/ip4.h:84-87`.
+ const flag_df: u16 = 0x4000;
+ const flag_mf: u16 = 0x2000;
+ const offset_mask: u16 = 0x1fff;
+};
+
+/// lwIP `prot/icmp.h:89-95` (`struct icmp_echo_hdr`).
+const icmp = struct {
+ const type_ = 0;
+ const code = 1;
+ const chksum = 2;
+ const id = 4;
+ const seq = 6;
+ const hlen = 8;
+
+ /// lwIP `prot/icmp.h:46,50`.
+ const echo_reply: u8 = 0;
+ const echo_request: u8 = 8;
+};
+
+/// lwIP `prot/udp.h:53-58` (`struct udp_hdr`), `UDP_HLEN` 8 at `:46`.
+const udp = struct {
+ const src_port = 0;
+ const dst_port = 2;
+ const len = 4;
+ const chksum = 6;
+ const hlen = 8;
+};
+
+/// lwIP `prot/tcp.h:56-65` (`struct tcp_hdr`), `TCP_HLEN` 20 at `:47`.
+const tcp = struct {
+ const src_port = 0;
+ const dst_port = 2;
+ const seq = 4;
+ const ack = 8;
+ /// Top four bits are the header length in 32-bit words; the low six are the flags.
+ /// lwIP `prot/tcp.h:85-87`.
+ const hdrlen_flags = 12;
+ const window = 14;
+ const chksum = 16;
+ const urgent = 18;
+ const hlen = 20;
+
+ /// lwIP `prot/tcp.h:72-81`.
+ const fin: u8 = 0x01;
+ const syn: u8 = 0x02;
+ const rst: u8 = 0x04;
+ const psh: u8 = 0x08;
+ const ack_f: u8 = 0x10;
+ const urg: u8 = 0x20;
+
+ /// RFC 793 3.1: kind 2, length 4, then the 16-bit MSS.
+ const opt_end: u8 = 0;
+ const opt_nop: u8 = 1;
+ const opt_mss: u8 = 2;
+};
+
+/// lwIP `prot/dhcp.h:50-91` (`struct dhcp_msg`) and `:51-56` for the offsets named there.
+const dhcp = struct {
+ const op = 0;
+ const htype = 1;
+ const hlen = 2;
+ const hops = 3;
+ const xid = 4;
+ const secs = 8;
+ const flags = 10;
+ const ciaddr = 12;
+ const yiaddr = 16;
+ const siaddr = 20;
+ const giaddr = 24;
+ const chaddr = 28;
+ const sname = 44; // DHCP_SNAME_OFS
+ const file = 108; // DHCP_FILE_OFS
+ const cookie = 236; // DHCP_MSG_LEN
+ const options = 240; // DHCP_OPTIONS_OFS = DHCP_MSG_LEN + 4
+
+ /// lwIP `prot/dhcp.h:116-117`.
+ const bootrequest: u8 = 1;
+ const bootreply: u8 = 2;
+ /// lwIP `prot/dhcp.h:120-127`.
+ const discover: u8 = 1;
+ const offer: u8 = 2;
+ const request: u8 = 3;
+ const ack: u8 = 5;
+ const nak: u8 = 6;
+ /// lwIP `prot/dhcp.h:129`.
+ const magic_cookie: u32 = 0x63825363;
+ /// RFC 2131 figure 2: the top bit of `flags` asks the server to broadcast its reply.
+ const flag_broadcast: u16 = 0x8000;
+
+ /// lwIP `prot/dhcp.h:134-165`. Only the ones this client uses.
+ const opt_pad: u8 = 0;
+ const opt_subnet_mask: u8 = 1;
+ const opt_router: u8 = 3;
+ const opt_dns: u8 = 6;
+ const opt_hostname: u8 = 12;
+ const opt_requested_ip: u8 = 50;
+ const opt_lease_time: u8 = 51;
+ const opt_overload: u8 = 52;
+ const opt_msg_type: u8 = 53;
+ const opt_server_id: u8 = 54;
+ const opt_param_list: u8 = 55;
+ const opt_max_msg_size: u8 = 57;
+ const opt_t1: u8 = 58;
+ const opt_t2: u8 = 59;
+ const opt_end: u8 = 255;
+
+ /// lwIP `prot/iana.h:66-68`.
+ const server_port: u16 = 67;
+ const client_port: u16 = 68;
+};
+
+/// RFC 1035 4.1. There is no lwIP reference for this one: lwIP's resolver is `core/dns.c`, which
+/// builds the same header out of its own `struct dns_hdr` (`core/dns.c:180-190`) - the offsets
+/// below are the RFC's, and `dns.c` is only a cross-check.
+const dns = struct {
+ // 4.1.1 header, six 16-bit fields.
+ const id = 0;
+ const flags = 2;
+ const qdcount = 4;
+ const ancount = 6;
+ const nscount = 8;
+ const arcount = 10;
+ const hlen = 12;
+
+ /// 4.1.1: QR is the top bit of `flags`, RD is bit 8, RCODE the bottom four bits.
+ const flag_qr: u16 = 0x8000;
+ const flag_rd: u16 = 0x0100;
+ const rcode_mask: u16 = 0x000f;
+ /// RCODE 3, "name error": the name authoritatively does not exist. RFC 1035 4.1.1.
+ const rcode_name_error: u16 = 3;
+
+ /// 4.1.4: the two top bits of a length byte set means the rest is a 14-bit offset.
+ const ptr_mask: u8 = 0xc0;
+ /// 2.3.4: a label is at most 63 bytes, which is also why 0x40 and 0x80 are free to be flags.
+ const label_max: u8 = 63;
+
+ /// 3.2.2 TYPE and 3.2.4 CLASS. Only the two this stack looks at, plus CNAME, which is not
+ /// followed but must be stepped over: a name behind a CNAME chain answers with the chain and
+ /// the A record together, and a resolver that stops at the first record finds the CNAME.
+ const type_a: u16 = 1;
+ const type_cname: u16 = 5;
+ const class_in: u16 = 1;
+
+ /// 3.2.1: TYPE(2) CLASS(2) TTL(4) RDLENGTH(2) after the name.
+ const rr_fixed = 10;
+
+ /// lwIP `prot/iana.h:64` (`LWIP_IANA_PORT_DNS`).
+ const port: u16 = 53;
+};
+
+// ============================================================================== ARP cache
+
+const ArpEntry = struct {
+ ip: Ip4 = ip_any,
+ mac: Mac = @splat(0),
+ /// `tick`'s clock at the last hit or update. Zero means the entry is empty.
+ stamp_ms: u64 = 0,
+
+ inline fn valid(self: ArpEntry) bool {
+ return self.stamp_ms != 0;
+ }
+};
+
+/// Entries older than this are treated as absent and re-resolved. lwIP's default is 300 s
+/// (`ARP_TMR_INTERVAL` 1000 ms x `ARP_MAXAGE` 300, `core/ipv4/etharp.c`); the same here.
+const arp_max_age_ms: u64 = 300_000;
+/// How often an unanswered ARP request is repeated while `httpGet` waits for a MAC address.
+const arp_retry_ms: u64 = 1000;
+/// ARP requests sent for one destination before `httpGet` gives up with `error.HostUnreachable`.
+const arp_max_tries: u8 = 5;
+
+// ================================================================================ DHCP state
+
+pub const DhcpState = enum {
+ /// `dhcpStart` has not been called, or `setStatic` has taken over.
+ off,
+ /// DISCOVER sent, waiting for an OFFER.
+ selecting,
+ /// REQUEST sent, waiting for an ACK.
+ requesting,
+ /// Bound, lease held, T1 not yet reached.
+ bound,
+ /// Past T1: unicast REQUEST to the server that granted the lease.
+ renewing,
+ /// Past T2: broadcast REQUEST to any server.
+ rebinding,
+};
+
+const Dhcp = struct {
+ state: DhcpState = .off,
+ xid: u32 = 0,
+ /// The address the server offered, held between OFFER and ACK.
+ offered: Ip4 = ip_any,
+ /// Option 54 from the OFFER, echoed in the REQUEST and unicast to when renewing.
+ server: Ip4 = ip_any,
+ /// Option 51, seconds. `0xffff_ffff` is an infinite lease (RFC 2131 3.3).
+ lease_s: u32 = 0,
+ /// Absolute deadlines derived from the lease at bind time, in `tick`'s milliseconds.
+ t1_ms: u64 = 0,
+ t2_ms: u64 = 0,
+ expire_ms: u64 = 0,
+ /// When the next DISCOVER/REQUEST retransmission is due, and how many have gone out.
+ retry_ms: u64 = 0,
+ tries: u8 = 0,
+ /// `tick`'s clock when acquisition began, for the `secs` field.
+ started_ms: u64 = 0,
+};
+
+// ================================================================================ TCP state
+
+pub const TcpState = enum {
+ closed,
+ /// Waiting for the peer's MAC address before the SYN can be built.
+ arp_wait,
+ syn_sent,
+ established,
+ /// Our FIN is sent; the peer has not FINed.
+ fin_wait_1,
+ fin_wait_2,
+ /// The peer FINed first and we have replied with our own FIN.
+ last_ack,
+ time_wait,
+};
+
+const Tcp = struct {
+ state: TcpState = .closed,
+
+ peer_ip: Ip4 = ip_any,
+ peer_port: u16 = 0,
+ local_port: u16 = 0,
+
+ /// Initial send sequence number. The SYN occupies `iss`; request data occupies
+ /// `iss+1 .. iss+1+tx_len`; a FIN occupies `iss+1+tx_len`. Every offset in this struct is
+ /// derived from that one layout, which is why there is no separate "unacked offset".
+ iss: u32 = 0,
+ /// Oldest sequence number not yet acknowledged by the peer.
+ snd_una: u32 = 0,
+ /// Next sequence number to send.
+ snd_nxt: u32 = 0,
+ /// The peer's advertised window.
+ snd_wnd: u32 = 0,
+ /// The peer's MSS, from its SYN's option or RFC 1122's default.
+ snd_mss: u16 = tcp_default_mss,
+ /// Set once a FIN has been queued behind the request data.
+ fin_queued: bool = false,
+ /// Set once the peer's FIN has been received in order. Receiving it does not by itself move
+ /// `state`, so that `tcpSendData` stays the only thing that changes it.
+ peer_fin: bool = false,
+
+ /// Next sequence number expected from the peer.
+ rcv_nxt: u32 = 0,
+
+ /// Retransmission deadline in `tick`'s milliseconds, and the current timeout. `rto_ms` doubles
+ /// on every retransmission and is never reduced by an RTT measurement, because this stack
+ /// takes none: with a single segment in flight and a fixed backoff there is nothing an RTT
+ /// estimator would change except the first timeout, and 1 s is already RFC 6298's answer for
+ /// that case.
+ rto_deadline_ms: u64 = 0,
+ rto_ms: u32 = tcp_rto_initial_ms,
+ retries: u8 = 0,
+ /// When TIME_WAIT ends.
+ close_deadline_ms: u64 = 0,
+
+ /// The request bytes, held for retransmission until acknowledged.
+ tx: [tcp_tx_max]u8 = undefined,
+ tx_len: usize = 0,
+
+ /// Sequence number of the first byte of `tx`.
+ inline fn dataStart(self: Tcp) u32 {
+ return self.iss +% 1;
+ }
+ /// Sequence number one past the last byte of `tx`.
+ inline fn dataEnd(self: Tcp) u32 {
+ return self.iss +% 1 +% @as(u32, @intCast(self.tx_len));
+ }
+};
+
+/// Sequence-number comparison. TCP sequence numbers wrap, so they are compared by the sign of the
+/// difference and never by `<`. RFC 1982 serial arithmetic; the classic bug this avoids is a
+/// connection that stalls forever once the sequence space crosses 2^32.
+inline fn seqLt(a: u32, b: u32) bool {
+ return @as(i32, @bitCast(a -% b)) < 0;
+}
+inline fn seqLe(a: u32, b: u32) bool {
+ return @as(i32, @bitCast(a -% b)) <= 0;
+}
+inline fn seqGt(a: u32, b: u32) bool {
+ return seqLt(b, a);
+}
+inline fn seqGe(a: u32, b: u32) bool {
+ return seqLe(b, a);
+}
+
+// =============================================================================== HTTP state
+
+pub const HttpError = error{
+ /// The request is in flight. Call `tick`, feed frames to `onFrame`, and call `httpGet` again
+ /// with the same arguments. This is the only "error" a healthy request returns.
+ WouldBlock,
+ /// `httpGet` was called with different arguments while a request was in flight.
+ Busy,
+ /// The peer's MAC address could not be resolved.
+ HostUnreachable,
+ /// The peer sent RST.
+ ConnectionReset,
+ /// The peer FINed or vanished before the body was complete.
+ ConnectionClosed,
+ /// Retransmissions exhausted.
+ TimedOut,
+ /// The status line, the headers, or a chunked body's trailer section exceeded its budget
+ /// (`http_head_max`, `http_framing_max`).
+ HttpHeadersTooLong,
+ /// The status line was not `HTTP/1.x SSS`.
+ HttpMalformed,
+ /// A chunked body's framing was not RFC 7230 4.1: a size with no hex digits, a size that
+ /// overflows `usize`, or a CRLF that was not where the grammar puts it. Distinct from
+ /// `HttpMalformed` because the two point at different halves of the response, and on a board
+ /// with one UART the error name is the whole diagnosis.
+ HttpChunkMalformed,
+ /// `Transfer-Encoding` was present and was neither `identity` nor `chunked`.
+ UnsupportedTransferEncoding,
+ /// The body did not fit in the caller's `out` buffer.
+ StreamTooLong,
+ /// The request line and headers did not fit in `tcp_tx_max`, or `path` is unusable.
+ RequestTooLong,
+ /// `httpGet` was called before the stack had an address.
+ NoAddress,
+};
+
+const HttpPhase = enum { idle, head, body, complete, failed };
+
+const Http = struct {
+ phase: HttpPhase = .idle,
+ /// Valid when `phase == .failed`.
+ err: HttpError = error.WouldBlock,
+
+ /// The caller's output buffer, borrowed for the duration of the request. Recorded rather than
+ /// copied, so the caller must not move or resize it between `httpGet` calls; the identity check
+ /// in `httpGet` catches the common way of getting that wrong.
+ out: []u8 = &.{},
+ out_len: usize = 0,
+
+ /// The request being served, kept so a re-entrant `httpGet` can be told apart from a new one.
+ /// The hash covers the path *and* the `Host:` name, which is what makes two requests to the
+ /// same address for the same path but different virtual hosts distinguishable - and they must
+ /// be, or the second silently rides on the first's connection. A hash rather than the strings
+ /// themselves because `Stack` has a 4 KiB budget and the strings are the caller's, alive for
+ /// the duration of the call only.
+ req_host: Ip4 = ip_any,
+ req_port: u16 = 0,
+ req_hash: u64 = 0,
+
+ /// Status line and headers, accumulated until the blank line.
+ head: [http_head_max]u8 = undefined,
+ head_len: usize = 0,
+
+ status: u16 = 0,
+ /// `null` means the response had no usable `Content-Length`, so the body ends at the peer's
+ /// FIN - or, when `chunked`, at the zero-length chunk.
+ content_length: ?usize = null,
+
+ // ------------------------------------------------------- RFC 7230 4.1 chunked decoding
+ //
+ // Four fields hold the whole position in the chunked grammar, because a segment boundary may
+ // fall between any two bytes of it and the decoder has to resume from exactly here.
+
+ /// `Transfer-Encoding: chunked` was in force on this response.
+ chunked: bool = false,
+ chunk: ChunkState = .size,
+ /// In `.size`, the hexadecimal size accumulated so far; in `.data`, the bytes of this chunk
+ /// still to come. The two are the same number, which is why one field serves both: the size
+ /// read is the count remaining the instant the header ends.
+ chunk_left: usize = 0,
+ /// At least one hex digit has been seen in the size being read. RFC 7230 4.1 is `1*HEXDIG`,
+ /// so an empty size is malformed - and without this flag a stray CRLF reads as a chunk of
+ /// length zero, which is the terminator, which ends the body early and looks like success.
+ chunk_digit: bool = false,
+ /// Framing bytes consumed in the extension or trailer section now being skipped, against
+ /// `http_framing_max`.
+ chunk_skip: u16 = 0,
+};
+
+/// Where the chunked decoder is in RFC 7230 4.1's grammar:
+///
+/// chunked-body = *chunk last-chunk trailer-part CRLF
+/// chunk = chunk-size [ chunk-ext ] CRLF chunk-data CRLF
+/// last-chunk = 1*("0") [ chunk-ext ] CRLF
+///
+/// Every terminal in that grammar that can be split by a segment boundary is a state, including
+/// the two halves of each CRLF. That is not pedantry: a 1,460-byte segment ends wherever the
+/// server's writes happen to end, and "the CR arrived and the LF did not" is a case that happens.
+const ChunkState = enum {
+ /// Reading hex digits of `chunk-size`.
+ size,
+ /// A `;` was seen: skipping `chunk-ext` to the CR that ends the header.
+ ext,
+ /// The CR of the chunk header is in; its LF must follow.
+ size_lf,
+ /// Copying `chunk_left` more bytes of `chunk-data` into the caller's `out`.
+ data,
+ /// The data is in; the CR of the CRLF that closes the chunk must follow.
+ data_cr,
+ /// ...and its LF.
+ data_lf,
+ /// At the first byte of a trailer line - or of the CRLF that ends the whole body.
+ trailer,
+ /// Inside a trailer line, skipping to its CR.
+ trailer_line,
+ /// The LF of a trailer line.
+ trailer_lf,
+ /// The LF of the final empty line. The response is complete after it, and not before.
+ end_lf,
+};
+
+// ================================================================================ DNS state
+
+pub const DnsError = error{
+ /// The query is in flight. Call `tick`, feed frames to `onFrame`, and call `resolve` again
+ /// with the same name. This is the only "error" a healthy query returns.
+ WouldBlock,
+ /// `resolve` was called with a different name while a query was in flight. One query is
+ /// outstanding at a time; the caller must finish or abandon the first.
+ Busy,
+ /// `resolve` was called before the stack had an address of its own to send from.
+ NoAddress,
+ /// No resolver: DHCP supplied none and `setDnsServer` was not called.
+ NoDnsServer,
+ /// The name was empty, had an empty label, or had a label over 63 bytes. RFC 1035 2.3.4.
+ NameInvalid,
+ /// The name was longer than `dns_name_max`.
+ NameTooLong,
+ /// `dns_backoff_ms.len` queries went out and nothing came back.
+ TimedOut,
+ /// The server said the name does not exist (RCODE 3), or answered with no A record in it -
+ /// a CNAME chain leading nowhere, or an AAAA-only name. Both mean the same thing to a stack
+ /// that speaks IPv4 only.
+ NameNotFound,
+ /// The server answered with a non-zero RCODE other than name-error: SERVFAIL, REFUSED.
+ DnsRefused,
+ /// A response that matched the id and the question could not be parsed: a name that runs off
+ /// the end, a compression pointer that goes forward or loops, an RDLENGTH past the message.
+ /// Responses that do *not* match the id and question are ignored rather than reported, so
+ /// this is the server or an attacker who already guessed both, never stray traffic.
+ DnsMalformed,
+};
+
+const DnsPhase = enum { idle, waiting, done, failed };
+
+const DnsQuery = struct {
+ phase: DnsPhase = .idle,
+ /// Valid when `phase == .failed`.
+ err: DnsError = error.WouldBlock,
+
+ /// The question, in RFC 1035 4.1.2 wire form, root label included. Held rather than
+ /// re-encoded because it is needed in three places: to build each retransmission from `tick`,
+ /// to compare against the question echoed in a response, and to tell a re-entrant `resolve`
+ /// from a new one. Comparing the encoded form is what makes the last two exact.
+ qname: [dns_qname_max]u8 = undefined,
+ qname_len: u8 = 0,
+
+ /// The transaction id, held across retransmissions so a slow first answer still matches.
+ id: u16 = 0,
+ /// The ephemeral source port, redrawn per query. Together with `id` that is 32 bits an
+ /// off-path spoofer has to guess, which is the whole of what plain DNS offers.
+ local_port: u16 = 0,
+
+ tries: u8 = 0,
+ retry_ms: u64 = 0,
+
+ /// Valid when `phase == .done`.
+ result: Ip4 = ip_any,
+};
+
+// ================================================================================= counters
+//
+// Not statistics for their own sake: the first hardware bring-up of this stack will be a board that
+// either answers a ping or does not, with no debugger and one UART. These are what turns "nothing
+// happens" into "1,204 frames arrived, 1,204 were dropped, and the checksum counter is zero", which
+// says the frames are not for us rather than that the checksum code is broken.
+
+pub const Counters = struct {
+ rx_frames: u32 = 0,
+ rx_dropped: u32 = 0,
+ tx_frames: u32 = 0,
+ tx_dropped: u32 = 0,
+ arp_rx: u32 = 0,
+ arp_tx: u32 = 0,
+ icmp_echo: u32 = 0,
+ udp_rx: u32 = 0,
+ dhcp_rx: u32 = 0,
+ dhcp_tx: u32 = 0,
+ tcp_rx: u32 = 0,
+ tcp_tx: u32 = 0,
+ tcp_retx: u32 = 0,
+ tcp_rst_rx: u32 = 0,
+ /// Queries sent, retransmissions among them, and responses that matched the outstanding
+ /// query's id and question. `dns_tx > dns_rx` with `dns_retx` climbing is a resolver that is
+ /// not answering; `dns_rx == 0` with `udp_rx` climbing is an answer arriving and being
+ /// rejected, which is a different bug in a different place.
+ dns_tx: u32 = 0,
+ dns_retx: u32 = 0,
+ dns_rx: u32 = 0,
+ /// Frames discarded because a checksum did not verify. A non-zero value here with a working
+ /// link means a bug in this file or a broken SDIO transfer, not a network problem.
+ checksum_bad: u32 = 0,
+};
+
+// ==================================================================================== Stack
+
+pub const Stack = struct {
+ // ------------------------------------------------------------------ identity and route
+ mac: Mac,
+ /// `null` until DHCP binds or `setStatic` is called.
+ addr: ?Ip4 = null,
+ mask: Ip4 = ip_any,
+ gw: Ip4 = ip_any,
+ /// The resolver, from DHCP option 6 or `setDnsServer`. `null` means nothing to ask.
+ dns: ?Ip4 = null,
+
+ /// Where frames go. Called synchronously from `onFrame`, `tick` and `httpGet`; the slice is
+ /// borrowed for the duration of the call and must be copied if the transport needs it later.
+ ///
+ /// Note the absence of a context pointer: the interface this slice implements specifies
+ /// `*const fn ([]const u8) void`, so a callee needing state has to reach it some other way.
+ send: *const fn (frame: []const u8) void,
+
+ // ------------------------------------------------------------------------------ clock
+ /// The last value handed to `tick`. `onFrame` needs a timestamp for the ARP cache and takes it
+ /// from here rather than reading a clock, which is what keeps this file free of any hardware
+ /// dependency at all.
+ now_ms: u64 = 0,
+
+ // ------------------------------------------------------------------------------ state
+ arp_cache: [arp_cache_len]ArpEntry = @splat(.{}),
+ /// Pending ARP resolution for the TCP peer: deadline, tries.
+ arp_retry_ms: u64 = 0,
+ arp_tries: u8 = 0,
+
+ dhcp: Dhcp = .{},
+ tcp: Tcp = .{},
+ http: Http = .{},
+ /// The one outstanding DNS query. Named `query` and not `dns`, which is the server's address.
+ query: DnsQuery = .{},
+ counters: Counters = .{},
+
+ /// IPv4 identification field. Incremented per datagram. Nothing here fragments, so this only
+ /// has to be non-constant for the benefit of middleboxes and packet captures.
+ ip_id: u16 = 0,
+ /// Mixed into transaction ids, initial sequence numbers and ephemeral ports. There is no
+ /// hardware RNG in this file's reach, so this is seeded from the MAC and stirred by every
+ /// `tick` value observed - which for the two uses here (not colliding with a previous
+ /// incarnation of the same connection, and not matching a stale DHCP reply) is sufficient.
+ /// It is emphatically *not* a source of security-relevant randomness.
+ entropy: u64,
+
+ /// The single transmit staging buffer. Every frame this stack sends is built here and handed to
+ /// `send` before the next one starts, so one is enough - and `send` is documented as borrowing.
+ tx: [frame_max]u8 = undefined,
+
+ /// The total static footprint of one `Stack`, asserted so the number in the report cannot rot.
+ pub const footprint = @sizeOf(Stack);
+
+ // =========================================================================== lifecycle
+
+ /// A single struct-literal return, deliberately: result-location semantics then construct the
+ /// buffers in the caller's storage instead of memcpy-ing several kilobytes off a stack that is
+ /// 8 KB by default on this target.
+ pub fn init(mac: [6]u8, send: *const fn (frame: []const u8) void) Stack {
+ return .{
+ .mac = mac,
+ .send = send,
+ .entropy = std.hash.Wyhash.hash(0x4200_cafe, &mac),
+ };
+ }
+
+ /// Stir and draw. Not random; see `entropy`.
+ fn draw(self: *Stack) u32 {
+ self.entropy = self.entropy *% 6364136223846793005 +% 1442695040888963407;
+ return @truncate(self.entropy >> 32);
+ }
+
+ // ============================================================================= address
+
+ /// The configured address, or `null` if there is none yet.
+ pub fn ip(self: *Stack) ?[4]u8 {
+ return self.addr;
+ }
+
+ pub fn netmask(self: *Stack) Ip4 {
+ return self.mask;
+ }
+
+ pub fn gateway(self: *Stack) Ip4 {
+ return self.gw;
+ }
+
+ /// The resolver `resolve` will ask: the first server DHCP offered (option 6), or whatever
+ /// `setDnsServer` last set. `null` means `resolve` will answer `error.NoDnsServer`.
+ pub fn dnsServer(self: *Stack) ?Ip4 {
+ return self.dns;
+ }
+
+ /// Override the resolver. Only needed on a network whose DHCP server offers none, or when
+ /// configuring statically: the ordinary path is a lease that carries option 6, which
+ /// `dhcpBind` already stores, and a caller that does nothing gets that.
+ ///
+ /// Any query in flight is abandoned: it was addressed to the old server and its answer would
+ /// now be rejected as coming from the wrong source.
+ pub fn setDnsServer(self: *Stack, addr: Ip4) void {
+ self.dns = addr;
+ self.query.phase = .idle;
+ }
+
+ pub fn dhcpState(self: *Stack) DhcpState {
+ return self.dhcp.state;
+ }
+
+ pub fn tcpState(self: *Stack) TcpState {
+ return self.tcp.state;
+ }
+
+ /// The status code of the last response whose head was parsed. Zero before that.
+ pub fn httpStatus(self: *Stack) u16 {
+ return self.http.status;
+ }
+
+ /// Configure statically and stop any DHCP activity. This is the path the first hardware test
+ /// takes: it makes the board reachable without a working DHCP client, so an ARP or ping
+ /// failure means the SDIO transport or the association is wrong rather than this file.
+ pub fn setStatic(self: *Stack, addr: [4]u8, mask: [4]u8, gw: [4]u8) void {
+ self.dhcp = .{};
+ self.addr = addr;
+ self.mask = mask;
+ self.gw = gw;
+ // Any query in flight was sent from the old address, so its answer is addressed to a
+ // station that no longer exists. `dns` itself is left alone: a resolver learnt from a
+ // previous lease is still the right one to ask on the same wire.
+ self.query.phase = .idle;
+ self.announce();
+ }
+
+ /// Is this address ours, or one everybody on the wire is meant to hear?
+ fn forUs(self: *Stack, dst: Ip4) bool {
+ if (std.mem.eql(u8, &dst, &ip_broadcast)) return true;
+ const a = self.addr orelse return false;
+ if (std.mem.eql(u8, &dst, &a)) return true;
+ // Subnet broadcast: host bits all ones.
+ var i: usize = 0;
+ while (i < 4) : (i += 1) {
+ if (dst[i] | self.mask[i] != 0xff) return false;
+ if (dst[i] & self.mask[i] != a[i] & self.mask[i]) return false;
+ }
+ return true;
+ }
+
+ fn onLink(self: *Stack, dst: Ip4) bool {
+ const a = self.addr orelse return true; // unconfigured: everything is a direct neighbour
+ var i: usize = 0;
+ while (i < 4) : (i += 1) {
+ if ((dst[i] ^ a[i]) & self.mask[i] != 0) return false;
+ }
+ return true;
+ }
+
+ // ================================================================== frame construction
+
+ fn emitFrame(self: *Stack, len: usize) void {
+ if (len > frame_max) {
+ self.counters.tx_dropped += 1;
+ return;
+ }
+ // Ethernet's 60-byte minimum (64 with FCS) is padded by the MAC, and ESP-Hosted's slave
+ // hands the frame to the C6's Wi-Fi MAC, which does the same. Nothing is padded here.
+ self.counters.tx_frames += 1;
+ self.send(self.tx[0..len]);
+ }
+
+ fn ethHeader(self: *Stack, dst: Mac, ethertype: EtherType) void {
+ wrMac(&self.tx, eth.dst, dst);
+ wrMac(&self.tx, eth.src, self.mac);
+ wr16(&self.tx, eth.ethertype, @intFromEnum(ethertype));
+ }
+
+ /// Build and send an IPv4 datagram whose payload the caller has already written to
+ /// `self.tx[eth_hlen + ip4.hlen ..]`. Returns false if the destination's MAC is unknown, in
+ /// which case an ARP request has been sent and the datagram is dropped.
+ ///
+ /// Dropping rather than queueing is lwIP's `ETHARP_SUPPORT_STATIC_ENTRIES`-less behaviour minus
+ /// its one-packet queue (`core/ipv4/etharp.c`, `etharp_query`). Nothing here needs the queue:
+ /// DHCP is broadcast, ICMP replies go to a peer whose MAC just arrived in the request, and TCP
+ /// resolves the peer before the SYN is built (`TcpState.arp_wait`).
+ fn emitIp(self: *Stack, src: Ip4, dst: Ip4, proto: Protocol, payload_len: usize) bool {
+ assert(payload_len <= mtu - ip4.hlen);
+ const total: u16 = @intCast(ip4.hlen + payload_len);
+
+ const dst_mac = self.routeMac(dst) orelse {
+ self.counters.tx_dropped += 1;
+ return false;
+ };
+ self.ethHeader(dst_mac, .ip4);
+
+ const h = self.tx[eth_hlen..][0..ip4.hlen];
+ h[ip4.v_hl] = 0x45; // IPv4, 5 words of header, no options
+ h[ip4.tos] = 0;
+ wr16(h, ip4.total_len, total);
+ wr16(h, ip4.id, self.ip_id);
+ self.ip_id +%= 1;
+ // DF set: this stack neither fragments what it sends nor reassembles what it receives, so
+ // saying so is more useful than letting a router fragment a datagram we cannot rebuild.
+ wr16(h, ip4.frag, ip4.flag_df);
+ h[ip4.ttl] = 64; // RFC 1122 3.2.1.7 recommends 64
+ h[ip4.proto] = @intFromEnum(proto);
+ wr16(h, ip4.chksum, 0);
+ wrIp(h, ip4.src, src);
+ wrIp(h, ip4.dst, dst);
+ wr16(h, ip4.chksum, checksum(h));
+
+ self.emitFrame(eth_hlen + total);
+ return true;
+ }
+
+ /// The MAC a datagram for `dst` must be sent to: broadcast for a broadcast address, the peer
+ /// itself if it is on-link, otherwise the gateway. `null` means unresolved, and an ARP request
+ /// has been sent.
+ fn routeMac(self: *Stack, dst: Ip4) ?Mac {
+ if (std.mem.eql(u8, &dst, &ip_broadcast)) return mac_broadcast;
+ if (self.addr != null) {
+ // Subnet broadcast.
+ var all_ones = true;
+ var i: usize = 0;
+ while (i < 4) : (i += 1) {
+ if (dst[i] | self.mask[i] != 0xff) all_ones = false;
+ }
+ if (all_ones and self.onLink(dst)) return mac_broadcast;
+ }
+ const next = if (self.onLink(dst)) dst else self.gw;
+ if (self.arpLookup(next)) |m| return m;
+ self.arpRequest(next);
+ return null;
+ }
+
+ // ================================================================================= ARP
+
+ /// An entry's timestamp is *not* refreshed by a lookup, only by an ARP packet from that host.
+ /// Refreshing on use looks like a cheap optimisation and is a real bug: an entry kept alive by
+ /// our own traffic is never re-resolved, so a gateway whose MAC changes - VRRP failover, a
+ /// replaced router, a roam to a different AP with a different BSSID-derived address - is never
+ /// noticed, and every frame goes to a MAC that no longer answers. Ageing out after
+ /// `arp_max_age_ms` of no ARP traffic from that host costs one dropped segment and a
+ /// retransmission; getting it wrong costs the connection.
+ fn arpLookup(self: *Stack, target: Ip4) ?Mac {
+ for (&self.arp_cache) |*e| {
+ if (!e.valid()) continue;
+ if (self.now_ms -% e.stamp_ms > arp_max_age_ms) {
+ e.stamp_ms = 0;
+ continue;
+ }
+ if (std.mem.eql(u8, &e.ip, &target)) return e.mac;
+ }
+ return null;
+ }
+
+ /// Insert or refresh. `insert` false means "update only if already known", which is how a
+ /// four-entry cache survives a busy /24: every ARP request on the segment is a broadcast, and a
+ /// cache that admitted all of them would evict the gateway within seconds. lwIP draws the same
+ /// line with `ETHARP_FLAG_TRY_HARD` (`core/ipv4/etharp.c`, `etharp_update_arp_entry`).
+ fn arpStore(self: *Stack, target: Ip4, hw: Mac, insert: bool) void {
+ if (std.mem.eql(u8, &target, &ip_any)) return;
+ if (std.mem.eql(u8, &target, &ip_broadcast)) return;
+ for (&self.arp_cache) |*e| {
+ if (e.valid() and std.mem.eql(u8, &e.ip, &target)) {
+ e.mac = hw;
+ e.stamp_ms = self.now_ms;
+ return;
+ }
+ }
+ if (!insert) return;
+ // Free slot, else the least recently used.
+ var victim: *ArpEntry = &self.arp_cache[0];
+ for (&self.arp_cache) |*e| {
+ if (!e.valid()) {
+ victim = e;
+ break;
+ }
+ if (e.stamp_ms < victim.stamp_ms) victim = e;
+ }
+ victim.* = .{ .ip = target, .mac = hw, .stamp_ms = self.now_ms };
+ }
+
+ fn arpEmit(self: *Stack, opcode: u16, target_ip: Ip4, target_mac: Mac, dst_mac: Mac, spa: Ip4) void {
+ self.ethHeader(dst_mac, .arp);
+ const h = self.tx[eth_hlen..][0..arp.len];
+ wr16(h, arp.hwtype, arp.hwtype_ethernet);
+ wr16(h, arp.proto, @intFromEnum(EtherType.ip4));
+ h[arp.hwlen] = 6;
+ h[arp.protolen] = 4;
+ wr16(h, arp.opcode, opcode);
+ wrMac(h, arp.sha, self.mac);
+ wrIp(h, arp.spa, spa);
+ wrMac(h, arp.tha, target_mac);
+ wrIp(h, arp.tpa, target_ip);
+ self.counters.arp_tx += 1;
+ self.emitFrame(eth_hlen + arp.len);
+ }
+
+ fn arpRequest(self: *Stack, target: Ip4) void {
+ // RFC 826: the target hardware address of a request is "don't care"; zero is conventional.
+ self.arpEmit(arp.op_request, target, @splat(0), mac_broadcast, self.addr orelse ip_any);
+ }
+
+ /// Gratuitous ARP: a broadcast request for our own address, which every listener treats as
+ /// "this MAC now owns this IP". Sent when an address is acquired, so the gateway and the AP
+ /// learn us without waiting to need us. RFC 5227 2.3.
+ fn announce(self: *Stack) void {
+ const a = self.addr orelse return;
+ self.arpEmit(arp.op_request, a, @splat(0), mac_broadcast, a);
+ }
+
+ fn arpInput(self: *Stack, body: []const u8) void {
+ if (body.len < arp.len) {
+ self.counters.rx_dropped += 1;
+ return;
+ }
+ // RFC 826 "Packet Reception", exactly the four checks lwIP makes at
+ // `core/ipv4/etharp.c:656-659`.
+ if (rd16(body, arp.hwtype) != arp.hwtype_ethernet or
+ rd16(body, arp.proto) != @intFromEnum(EtherType.ip4) or
+ body[arp.hwlen] != 6 or body[arp.protolen] != 4)
+ {
+ self.counters.rx_dropped += 1;
+ return;
+ }
+ self.counters.arp_rx += 1;
+
+ const spa = rdIp(body, arp.spa);
+ const sha = rdMac(body, arp.sha);
+ const tpa = rdIp(body, arp.tpa);
+ const for_us = if (self.addr) |a| std.mem.eql(u8, &tpa, &a) else false;
+
+ // Learn the sender. Admitted to a free slot only when the packet was addressed to us -
+ // either a request we must answer or the reply to a request we sent.
+ self.arpStore(spa, sha, for_us);
+
+ if (rd16(body, arp.opcode) == arp.op_request and for_us) {
+ // A reply goes back to the requester, not to the broadcast address.
+ self.arpEmit(arp.op_reply, spa, sha, sha, self.addr.?);
+ }
+ }
+
+ // =============================================================================== input
+
+ /// A received Ethernet frame. Everything this stack does in response happens before this
+ /// returns, including any frame it sends.
+ pub fn onFrame(self: *Stack, frame: []const u8) void {
+ self.counters.rx_frames += 1;
+ if (frame.len < eth_hlen or frame.len > frame_max) {
+ self.counters.rx_dropped += 1;
+ return;
+ }
+ const dst = rdMac(frame, eth.dst);
+ // The C6's MAC filter should already have done this, but a promiscuous or misconfigured
+ // transport would otherwise have this stack answering ARP for other stations.
+ if (!std.mem.eql(u8, &dst, &self.mac) and !std.mem.eql(u8, &dst, &mac_broadcast)) {
+ self.counters.rx_dropped += 1;
+ return;
+ }
+ const body = frame[eth_hlen..];
+ switch (@as(EtherType, @enumFromInt(rd16(frame, eth.ethertype)))) {
+ .arp => self.arpInput(body),
+ .ip4 => self.ip4Input(body),
+ // .vlan lands here: an 802.1Q tag would need the 4-byte shim skipped and the real
+ // ethertype read from behind it. Nothing on this board tags frames, so it is dropped
+ // rather than half-handled.
+ else => self.counters.rx_dropped += 1,
+ }
+ }
+
+ fn ip4Input(self: *Stack, body: []const u8) void {
+ if (body.len < ip4.hlen) {
+ self.counters.rx_dropped += 1;
+ return;
+ }
+ if (body[ip4.v_hl] >> 4 != 4) {
+ self.counters.rx_dropped += 1;
+ return;
+ }
+ const hlen = @as(usize, body[ip4.v_hl] & 0x0f) * 4;
+ if (hlen < ip4.hlen or hlen > body.len) {
+ self.counters.rx_dropped += 1;
+ return;
+ }
+ if (checksum(body[0..hlen]) != 0) {
+ self.counters.checksum_bad += 1;
+ return;
+ }
+ const total = rd16(body, ip4.total_len);
+ if (total < hlen or total > body.len) {
+ // Shorter than claimed: truncated. Longer than claimed happens legitimately - a
+ // 60-byte minimum-length Ethernet frame padding a 28-byte datagram - and is handled by
+ // trusting `total` below, but a frame shorter than its own IP header claims is junk.
+ self.counters.rx_dropped += 1;
+ return;
+ }
+ const frag = rd16(body, ip4.frag);
+ if (frag & (ip4.flag_mf | ip4.offset_mask) != 0) {
+ // A fragment. Reassembly is out of scope, and accepting the first fragment as a whole
+ // datagram would be worse than dropping it.
+ self.counters.rx_dropped += 1;
+ return;
+ }
+
+ const src = rdIp(body, ip4.src);
+ const dst = rdIp(body, ip4.dst);
+ const proto: Protocol = @enumFromInt(body[ip4.proto]);
+ const payload = body[hlen..total];
+
+ if (!self.forUs(dst)) {
+ // One exception, and it is the reason DHCP works at all: a server may unicast its
+ // OFFER or ACK to the address it is about to grant, which is not yet ours, at a MAC
+ // that is. RFC 2131 4.1 permits exactly this. So while unbound, UDP is let through to
+ // the demultiplexer, which will only match the DHCP client port.
+ const dhcp_pending = self.addr == null and self.dhcp.state != .off;
+ if (!(dhcp_pending and proto == .udp)) {
+ self.counters.rx_dropped += 1;
+ return;
+ }
+ }
+
+ switch (proto) {
+ .icmp => self.icmpInput(src, dst, payload),
+ .udp => self.udpInput(src, dst, payload),
+ .tcp => self.tcpInput(src, dst, payload),
+ else => self.counters.rx_dropped += 1,
+ }
+ }
+
+ // ================================================================================ ICMP
+
+ fn icmpInput(self: *Stack, src: Ip4, dst: Ip4, payload: []const u8) void {
+ if (payload.len < icmp.hlen) {
+ self.counters.rx_dropped += 1;
+ return;
+ }
+ // ICMP has no pseudo-header (RFC 792): the checksum covers the message alone.
+ if (checksum(payload) != 0) {
+ self.counters.checksum_bad += 1;
+ return;
+ }
+ if (payload[icmp.type_] != icmp.echo_request) {
+ // Destination-unreachable and time-exceeded carry useful information that nothing here
+ // consumes; a stack with no routing decisions to revise has nothing to do with them.
+ self.counters.rx_dropped += 1;
+ return;
+ }
+ // A request addressed to the broadcast address is answered from our own address only; a
+ // reply sourced from a broadcast address is malformed and some hosts treat it as an attack.
+ if (self.addr == null) return;
+ if (payload.len > mtu - ip4.hlen) {
+ // Would need fragmenting to answer. `ping -s 1473` from the development host lands
+ // here as a fragmented request and is already dropped above; this covers the rest.
+ self.counters.rx_dropped += 1;
+ return;
+ }
+ _ = dst;
+
+ const out = self.tx[eth_hlen + ip4.hlen ..][0..payload.len];
+ @memcpy(out, payload);
+ out[icmp.type_] = icmp.echo_reply;
+ out[icmp.code] = 0;
+ wr16(out, icmp.chksum, 0);
+ wr16(out, icmp.chksum, checksum(out));
+ self.counters.icmp_echo += 1;
+ _ = self.emitIp(self.addr.?, src, .icmp, payload.len);
+ }
+
+ // ================================================================================= UDP
+
+ fn udpInput(self: *Stack, src: Ip4, dst: Ip4, payload: []const u8) void {
+ if (payload.len < udp.hlen) {
+ self.counters.rx_dropped += 1;
+ return;
+ }
+ const ulen = rd16(payload, udp.len);
+ if (ulen < udp.hlen or ulen > payload.len) {
+ self.counters.rx_dropped += 1;
+ return;
+ }
+ const datagram = payload[0..ulen];
+ if (!transportChecksumOk(src, dst, .udp, datagram, rd16(datagram, udp.chksum))) {
+ self.counters.checksum_bad += 1;
+ return;
+ }
+ self.counters.udp_rx += 1;
+
+ const sport = rd16(datagram, udp.src_port);
+ const dport = rd16(datagram, udp.dst_port);
+ const data = datagram[udp.hlen..];
+ if (dport == dhcp.client_port) {
+ self.dhcpInput(src, data);
+ } else if (self.query.phase == .waiting and dport == self.query.local_port) {
+ self.dnsInput(src, sport, data);
+ } else {
+ // No sockets, so nothing else has a port. A real stack would answer with ICMP port
+ // unreachable; announcing which ports are closed is of no use to this device.
+ self.counters.rx_dropped += 1;
+ }
+ }
+
+ /// Send a UDP datagram. `src` may be `0.0.0.0`, which DHCP needs before it has an address.
+ fn emitUdp(self: *Stack, src: Ip4, sport: u16, dst: Ip4, dport: u16, data_len: usize) bool {
+ const seg_len = udp.hlen + data_len;
+ assert(seg_len <= mtu - ip4.hlen);
+ const seg = self.tx[eth_hlen + ip4.hlen ..][0..seg_len];
+ wr16(seg, udp.src_port, sport);
+ wr16(seg, udp.dst_port, dport);
+ wr16(seg, udp.len, @intCast(seg_len));
+ wr16(seg, udp.chksum, 0);
+ wr16(seg, udp.chksum, udpChecksumOnWire(transportChecksum(src, dst, .udp, seg)));
+ return self.emitIp(src, dst, .udp, seg_len);
+ }
+
+ // ================================================================================ DHCP
+
+ /// Begin acquiring an address. Idempotent while an acquisition is in progress; a call while
+ /// bound restarts from DISCOVER.
+ pub fn dhcpStart(self: *Stack) void {
+ self.addr = null;
+ self.mask = ip_any;
+ self.gw = ip_any;
+ self.dns = null;
+ // The resolver is gone with the lease, so anything in flight to it is abandoned rather
+ // than left to time out against a server this stack no longer believes in.
+ self.query.phase = .idle;
+ self.dhcp = .{
+ .state = .selecting,
+ .xid = self.draw(),
+ .started_ms = self.now_ms,
+ };
+ self.dhcpSend(dhcp.discover);
+ self.dhcp.tries = 1;
+ self.dhcp.retry_ms = self.now_ms + dhcp_backoff_ms[0];
+ }
+
+ /// Options are appended through this so a length byte can never be written by hand.
+ const OptWriter = struct {
+ buf: []u8,
+ i: usize = 0,
+
+ fn raw(self: *OptWriter, code: u8, value: []const u8) void {
+ assert(value.len <= 255);
+ assert(self.i + 2 + value.len <= self.buf.len);
+ self.buf[self.i] = code;
+ self.buf[self.i + 1] = @intCast(value.len);
+ @memcpy(self.buf[self.i + 2 ..][0..value.len], value);
+ self.i += 2 + value.len;
+ }
+ fn byte(self: *OptWriter, code: u8, v: u8) void {
+ self.raw(code, &[_]u8{v});
+ }
+ fn word(self: *OptWriter, code: u8, v: u16) void {
+ var t: [2]u8 = undefined;
+ std.mem.writeInt(u16, &t, v, .big);
+ self.raw(code, &t);
+ }
+ fn address(self: *OptWriter, code: u8, v: Ip4) void {
+ self.raw(code, &v);
+ }
+ fn end(self: *OptWriter) void {
+ assert(self.i < self.buf.len);
+ self.buf[self.i] = dhcp.opt_end;
+ self.i += 1;
+ }
+ };
+
+ /// Build and send one DHCP message. The RFC 2131 4.3.6 table is what decides which fields are
+ /// set: it is the part of DHCP that servers actually enforce, and getting `ciaddr` or the
+ /// server identifier wrong produces a NAK rather than an error message.
+ fn dhcpSend(self: *Stack, kind: u8) void {
+ const msg = self.tx[eth_hlen + ip4.hlen + udp.hlen ..][0..dhcp_min_msg_len];
+ @memset(msg, 0);
+
+ const renewing = self.dhcp.state == .renewing;
+ const rebinding = self.dhcp.state == .rebinding;
+ // RENEWING and REBINDING carry the bound address in `ciaddr` and no requested-IP option;
+ // SELECTING and REQUESTING carry zero and use option 50. lwIP makes the same distinction
+ // at `core/ipv4/dhcp.c:2026-2030`.
+ const use_ciaddr = renewing or rebinding;
+
+ msg[dhcp.op] = dhcp.bootrequest;
+ msg[dhcp.htype] = @intCast(arp.hwtype_ethernet);
+ msg[dhcp.hlen] = 6;
+ msg[dhcp.hops] = 0;
+ wr32(msg, dhcp.xid, self.dhcp.xid);
+ wr16(msg, dhcp.secs, @intCast(@min(0xffff, (self.now_ms -% self.dhcp.started_ms) / 1000)));
+ // Ask the server to broadcast its reply. lwIP clears this flag
+ // (`core/ipv4/dhcp.c:2024-2025`: "we don't need the broadcast flag since we can receive
+ // unicast traffic before being fully configured"), and so can this stack - `ip4Input` has
+ // the explicit exemption for it. The flag is set anyway because a broadcast reply is the
+ // path with the fewest ways to fail on first bring-up: it needs no ARP entry at the server,
+ // no unicast-to-unconfigured-host handling in the AP, and no exemption in this file.
+ wr16(msg, dhcp.flags, dhcp.flag_broadcast);
+ if (use_ciaddr) wrIp(msg, dhcp.ciaddr, self.addr orelse ip_any);
+ @memcpy(msg[dhcp.chaddr..][0..6], &self.mac);
+ wr32(msg, dhcp.cookie, dhcp.magic_cookie);
+
+ var o: OptWriter = .{ .buf = msg[dhcp.options..] };
+ o.byte(dhcp.opt_msg_type, kind);
+ // RFC 2131 3.5: the maximum message size we can reassemble. One MTU minus the headers,
+ // which for this stack is also the largest datagram it can receive at all.
+ o.word(dhcp.opt_max_msg_size, @intCast(mtu - ip4.hlen - udp.hlen));
+ if (kind == dhcp.request and !use_ciaddr) {
+ o.address(dhcp.opt_requested_ip, self.dhcp.offered);
+ o.address(dhcp.opt_server_id, self.dhcp.server);
+ }
+ // RFC 2131 4.3.6: a REQUEST in RENEWING/REBINDING must not carry a server identifier.
+ o.raw(dhcp.opt_param_list, &[_]u8{
+ dhcp.opt_subnet_mask,
+ dhcp.opt_router,
+ dhcp.opt_dns,
+ dhcp.opt_lease_time,
+ dhcp.opt_t1,
+ dhcp.opt_t2,
+ });
+ o.raw(dhcp.opt_hostname, "esp32p4");
+ o.end();
+ // Everything past the END option stays zero: RFC 2131 4.1 pads with option 0.
+
+ const src = if (use_ciaddr) (self.addr orelse ip_any) else ip_any;
+ // RENEWING unicasts to the server that granted the lease; every other message is broadcast
+ // (RFC 2131 4.3.6, 4.4.5).
+ const dst = if (renewing) self.dhcp.server else ip_broadcast;
+ self.counters.dhcp_tx += 1;
+ _ = self.emitUdp(src, dhcp.client_port, dst, dhcp.server_port, dhcp_min_msg_len);
+ }
+
+ /// One parsed option, or the end of the list.
+ const Opt = struct { code: u8, value: []const u8 };
+
+ /// Walk a DHCP option list. Stops at END, at a truncated option, or at the end of the buffer -
+ /// a malformed length must not walk off the datagram, which is the classic DHCP parser bug.
+ fn dhcpOption(body: []const u8, want: u8) ?[]const u8 {
+ if (body.len <= dhcp.options) return null;
+ var i: usize = dhcp.options;
+ while (i < body.len) {
+ const code = body[i];
+ if (code == dhcp.opt_end) return null;
+ if (code == dhcp.opt_pad) {
+ i += 1;
+ continue;
+ }
+ if (i + 2 > body.len) return null;
+ const len = body[i + 1];
+ if (i + 2 + len > body.len) return null;
+ if (code == want) return body[i + 2 ..][0..len];
+ i += 2 + len;
+ }
+ return null;
+ }
+
+ fn dhcpOptionIp(body: []const u8, want: u8) ?Ip4 {
+ const v = dhcpOption(body, want) orelse return null;
+ if (v.len < 4) return null;
+ return v[0..4].*;
+ }
+
+ fn dhcpOptionU32(body: []const u8, want: u8) ?u32 {
+ const v = dhcpOption(body, want) orelse return null;
+ if (v.len != 4) return null;
+ return std.mem.readInt(u32, v[0..4], .big);
+ }
+
+ fn dhcpInput(self: *Stack, src: Ip4, body: []const u8) void {
+ if (self.dhcp.state == .off) return;
+ if (body.len < dhcp.options) {
+ self.counters.rx_dropped += 1;
+ return;
+ }
+ if (body[dhcp.op] != dhcp.bootreply) return;
+ if (rd32(body, dhcp.cookie) != dhcp.magic_cookie) return;
+ if (rd32(body, dhcp.xid) != self.dhcp.xid) return;
+ // The reply must be about our hardware address, not a relayed one for someone else.
+ if (body[dhcp.hlen] != 6 or !std.mem.eql(u8, body[dhcp.chaddr..][0..6], &self.mac)) return;
+
+ const kind_opt = dhcpOption(body, dhcp.opt_msg_type) orelse return;
+ if (kind_opt.len != 1) return;
+ self.counters.dhcp_rx += 1;
+
+ switch (kind_opt[0]) {
+ dhcp.offer => {
+ if (self.dhcp.state != .selecting) return;
+ self.dhcp.offered = rdIp(body, dhcp.yiaddr);
+ if (std.mem.eql(u8, &self.dhcp.offered, &ip_any)) return;
+ // Option 54 is how the REQUEST names which offer it accepts. A server that omits
+ // it is out of spec; `siaddr` is the best fallback, and the sender is the last.
+ self.dhcp.server = dhcpOptionIp(body, dhcp.opt_server_id) orelse blk: {
+ const s = rdIp(body, dhcp.siaddr);
+ break :blk if (std.mem.eql(u8, &s, &ip_any)) src else s;
+ };
+ self.dhcp.state = .requesting;
+ self.dhcpSend(dhcp.request);
+ self.dhcp.tries = 1;
+ self.dhcp.retry_ms = self.now_ms + dhcp_backoff_ms[0];
+ },
+ dhcp.ack => {
+ switch (self.dhcp.state) {
+ .requesting, .renewing, .rebinding => {},
+ else => return,
+ }
+ const granted = rdIp(body, dhcp.yiaddr);
+ if (std.mem.eql(u8, &granted, &ip_any)) return;
+ self.dhcpBind(body, granted, src);
+ },
+ dhcp.nak => {
+ switch (self.dhcp.state) {
+ .requesting, .renewing, .rebinding => {},
+ else => return,
+ }
+ // RFC 2131 4.4.5: a NAK sends the client back to INIT. The lease is gone, so the
+ // address goes with it - continuing to use it would be squatting.
+ self.dhcpStart();
+ },
+ else => {},
+ }
+ }
+
+ fn dhcpBind(self: *Stack, body: []const u8, granted: Ip4, src: Ip4) void {
+ self.addr = granted;
+ self.mask = dhcpOptionIp(body, dhcp.opt_subnet_mask) orelse .{ 255, 255, 255, 0 };
+ self.gw = dhcpOptionIp(body, dhcp.opt_router) orelse ip_any;
+ self.dns = dhcpOptionIp(body, dhcp.opt_dns);
+ if (dhcpOptionIp(body, dhcp.opt_server_id)) |s| self.dhcp.server = s else if (std.mem.eql(u8, &self.dhcp.server, &ip_any)) {
+ self.dhcp.server = src;
+ }
+
+ // RFC 2131 3.3. A server that sends no lease time is out of spec; an hour is a safe
+ // assumption, being short enough that a wrong guess self-corrects.
+ const lease = dhcpOptionU32(body, dhcp.opt_lease_time) orelse 3600;
+ self.dhcp.lease_s = lease;
+ if (lease == 0xffff_ffff) {
+ // Infinite lease: never renew.
+ self.dhcp.t1_ms = std.math.maxInt(u64);
+ self.dhcp.t2_ms = std.math.maxInt(u64);
+ self.dhcp.expire_ms = std.math.maxInt(u64);
+ } else {
+ // The server may state T1 and T2 itself; otherwise lwIP's derivation, which is RFC
+ // 2131 4.4.5's: half the lease, and seven eighths of it
+ // (`core/ipv4/dhcp.c:757` and `:766`).
+ const t1 = dhcpOptionU32(body, dhcp.opt_t1) orelse lease / 2;
+ const t2 = dhcpOptionU32(body, dhcp.opt_t2) orelse (lease / 8) * 7;
+ const base = self.now_ms;
+ self.dhcp.t1_ms = base + @as(u64, @min(t1, lease)) * 1000;
+ self.dhcp.t2_ms = base + @as(u64, @min(t2, lease)) * 1000;
+ self.dhcp.expire_ms = base + @as(u64, lease) * 1000;
+ }
+ self.dhcp.state = .bound;
+ self.dhcp.tries = 0;
+ self.dhcp.retry_ms = 0;
+ self.announce();
+ }
+
+ fn dhcpTick(self: *Stack) void {
+ // T1 while bound: start renewing. RFC 2131 4.4.5 requires a fresh transaction id, and the
+ // first REQUEST goes out on this same tick rather than one backoff later - a state change
+ // that transmits nothing is how a lease quietly expires while the client thinks it is
+ // renewing.
+ if (self.dhcp.state == .bound and self.now_ms >= self.dhcp.t1_ms) {
+ self.dhcp.state = .renewing;
+ self.dhcp.xid = self.draw();
+ self.dhcp.started_ms = self.now_ms;
+ self.dhcp.tries = 0;
+ self.dhcp.retry_ms = 0;
+ }
+ switch (self.dhcp.state) {
+ .off, .bound => return,
+ .selecting, .requesting, .renewing, .rebinding => {},
+ }
+ if (self.dhcp.state == .renewing and self.now_ms >= self.dhcp.t2_ms) {
+ // T2: the granting server is not answering. Ask anyone.
+ self.dhcp.state = .rebinding;
+ self.dhcp.tries = 0;
+ self.dhcp.retry_ms = 0;
+ }
+ if ((self.dhcp.state == .renewing or self.dhcp.state == .rebinding) and
+ self.now_ms >= self.dhcp.expire_ms)
+ {
+ // The lease is over. Give up the address before asking again: keeping it would mean
+ // using an address the server may already have given away.
+ self.dhcpStart();
+ return;
+ }
+ if (self.dhcp.retry_ms != 0 and self.now_ms < self.dhcp.retry_ms) return;
+ const kind: u8 = if (self.dhcp.state == .selecting) dhcp.discover else dhcp.request;
+ self.dhcpSend(kind);
+ const idx = @min(self.dhcp.tries, dhcp_backoff_ms.len - 1);
+ self.dhcp.retry_ms = self.now_ms + dhcp_backoff_ms[idx];
+ if (self.dhcp.tries < 255) self.dhcp.tries += 1;
+ }
+
+ // ================================================================================= DNS
+ //
+ // One question, QTYPE=A, QCLASS=IN, recursion desired, over the UDP above. No cache, no
+ // search list, no NS or SOA handling, no TCP fallback on a truncated answer: this resolves
+ // the one name a device that fetches one URL has to resolve, and says so with a named error
+ // when it cannot.
+ //
+ // The hard part of DNS parsing is not the header, it is that a name in a resource record may
+ // be a compression pointer into anywhere earlier in the message (RFC 1035 4.1.4). A parser
+ // that follows those without a bound hangs on a message that points at itself, and such a
+ // message costs an attacker two bytes. `dnsSkipName` is where that is dealt with.
+
+ /// Encode a dotted name into RFC 1035 4.1.2 wire form: each label prefixed with its length,
+ /// terminated by the zero-length root label. Returns the encoded length.
+ ///
+ /// `out` must be at least `dns_qname_max`, which the length check below makes sufficient: a
+ /// name of `n` text bytes with no trailing dot encodes to exactly `n + 2`.
+ fn dnsEncodeName(name: []const u8, out: []u8) DnsError!usize {
+ assert(out.len >= dns_qname_max);
+ if (name.len > dns_name_max) return error.NameTooLong;
+ // A trailing dot is the root label written out, and `example.com.` names the same node as
+ // `example.com`. Everything after it - an empty final label - is not.
+ var rest = name;
+ if (rest.len != 0 and rest[rest.len - 1] == '.') rest = rest[0 .. rest.len - 1];
+ if (rest.len == 0) return error.NameInvalid;
+
+ var o: usize = 0;
+ var labels = std.mem.splitScalar(u8, rest, '.');
+ while (labels.next()) |label| {
+ // An empty label inside a name (`a..b`, or a leading dot) is not a name.
+ if (label.len == 0 or label.len > dns.label_max) return error.NameInvalid;
+ out[o] = @intCast(label.len);
+ @memcpy(out[o + 1 ..][0..label.len], label);
+ o += 1 + label.len;
+ }
+ out[o] = 0;
+ return o + 1;
+ }
+
+ /// Compare an encoded name against the question we asked, ASCII-case-insensitively. RFC 4343:
+ /// label comparison ignores case, and a resolver is entitled to answer `0X4200.CAFE` to a
+ /// question about `0x4200.cafe`. Length bytes are 0-63 and so are never touched by the fold.
+ fn dnsQNameEql(a: []const u8, b: []const u8) bool {
+ if (a.len != b.len) return false;
+ for (a, b) |x, y| if (std.ascii.toLower(x) != std.ascii.toLower(y)) return false;
+ return true;
+ }
+
+ /// Step over the name at `start` and return the offset of the byte after it - which for a
+ /// name that ends in a compression pointer is two bytes after the pointer, *not* wherever the
+ /// pointer led. `null` means the name is unparseable and the message is to be rejected.
+ ///
+ /// **Why this terminates.** Two independent bounds, because one of them is not enough:
+ ///
+ /// * A pointer must point strictly backwards (`target < here`). That alone is the check
+ /// most implementations stop at, and it is *not* sufficient: after jumping back the walk
+ /// moves forward again over labels, so a pointer at offset 12 to offset 10 and a label at
+ /// 10 that is two bytes long lands back at 12, and the pair loops forever with every
+ /// individual jump going backwards.
+ /// * So the jumps themselves are counted, and `dns_max_jumps` of them ends the name. That
+ /// is the bound that actually holds: the loop below does at most `dns_max_jumps` jumps
+ /// and, between them, walks labels whose lengths are positive, so it visits at most
+ /// `dns_max_jumps * msg.len` bytes and stops. A legitimate answer uses one jump per name.
+ fn dnsSkipName(msg: []const u8, start: usize) ?usize {
+ var i = start;
+ var jumps: u8 = 0;
+ // The offset after the name in the *message*, fixed by the first pointer taken.
+ var after: ?usize = null;
+ while (true) {
+ if (i >= msg.len) return null;
+ const len = msg[i];
+ if (len & dns.ptr_mask == dns.ptr_mask) {
+ if (i + 1 >= msg.len) return null;
+ const target = (@as(usize, len & 0x3f) << 8) | msg[i + 1];
+ if (after == null) after = i + 2;
+ if (target >= i) return null;
+ jumps += 1;
+ if (jumps > dns_max_jumps) return null;
+ i = target;
+ continue;
+ }
+ // 0x40 and 0x80 are the reserved label types of RFC 1035 4.1.4 / RFC 6891; neither is
+ // something this stack can skip a known number of bytes past, so neither is accepted.
+ if (len & dns.ptr_mask != 0) return null;
+ if (len == 0) return after orelse i + 1;
+ i += 1 + @as(usize, len);
+ if (i > msg.len) return null;
+ }
+ }
+
+ /// Build and send the query held in `self.query`. Called for the first transmission and for
+ /// every retransmission, from the same fields, so the two cannot drift apart.
+ fn dnsSend(self: *Stack) void {
+ const src = self.addr orelse return;
+ const server = self.dns orelse return;
+ const qn_len: usize = self.query.qname_len;
+ const msg_len = dns.hlen + qn_len + 4;
+ const msg = self.tx[eth_hlen + ip4.hlen + udp.hlen ..][0..msg_len];
+ wr16(msg, dns.id, self.query.id);
+ // RD only. Not AD, not CD, not EDNS0: this asks a recursive resolver for one A record and
+ // has nothing to validate with.
+ wr16(msg, dns.flags, dns.flag_rd);
+ wr16(msg, dns.qdcount, 1);
+ wr16(msg, dns.ancount, 0);
+ wr16(msg, dns.nscount, 0);
+ wr16(msg, dns.arcount, 0);
+ @memcpy(msg[dns.hlen..][0..qn_len], self.query.qname[0..qn_len]);
+ wr16(msg, dns.hlen + qn_len, dns.type_a);
+ wr16(msg, dns.hlen + qn_len + 2, dns.class_in);
+ self.counters.dns_tx += 1;
+ _ = self.emitUdp(src, self.query.local_port, server, dns.port, msg_len);
+ }
+
+ fn dnsFail(self: *Stack, e: DnsError) void {
+ self.query.phase = .failed;
+ self.query.err = e;
+ }
+
+ /// A datagram to the port the outstanding query was sent from. Called from inside `onFrame`.
+ ///
+ /// Everything that does not match the query is *ignored*, not failed: on a real network the
+ /// port this query owns will collect late answers to previous queries, scans, and whatever
+ /// else is loose on the segment, and any of those failing the query would be a denial of
+ /// service that costs one packet. Only a response that matches the source, the id and the
+ /// question can decide the query - and then it decides it either way.
+ fn dnsInput(self: *Stack, src: Ip4, sport: u16, msg: []const u8) void {
+ const server = self.dns orelse return;
+ if (!std.mem.eql(u8, &src, &server)) return;
+ if (sport != dns.port) return;
+ if (msg.len < dns.hlen) return;
+ if (rd16(msg, dns.id) != self.query.id) return;
+
+ const flags = rd16(msg, dns.flags);
+ if (flags & dns.flag_qr == 0) return; // a query, not a response
+ if (rd16(msg, dns.qdcount) != 1) return;
+
+ // The question, echoed. A server that answers a different question - or an attacker who
+ // guessed the id and the port but not the name - is not answering this.
+ const qn = self.query.qname[0..self.query.qname_len];
+ var off = dns.hlen + qn.len + 4;
+ if (msg.len < off) return;
+ if (!dnsQNameEql(msg[dns.hlen..][0..qn.len], qn)) return;
+ if (rd16(msg, dns.hlen + qn.len) != dns.type_a) return;
+ if (rd16(msg, dns.hlen + qn.len + 2) != dns.class_in) return;
+
+ self.counters.dns_rx += 1;
+
+ const rcode = flags & dns.rcode_mask;
+ if (rcode != 0) {
+ self.dnsFail(if (rcode == dns.rcode_name_error) error.NameNotFound else error.DnsRefused);
+ return;
+ }
+
+ // Walk the answer section and take the first A record. Walking rather than reading the
+ // first record is what makes a CNAME chain work: `0x4200.cafe` may answer with the CNAME
+ // and the A together, in that order, and a resolver that reads answer[0] gets a name.
+ var left = rd16(msg, dns.ancount);
+ while (left != 0) : (left -= 1) {
+ off = dnsSkipName(msg, off) orelse {
+ self.dnsFail(error.DnsMalformed);
+ return;
+ };
+ if (off + dns.rr_fixed > msg.len) {
+ self.dnsFail(error.DnsMalformed);
+ return;
+ }
+ const rtype = rd16(msg, off);
+ const rclass = rd16(msg, off + 2);
+ const rdlen: usize = rd16(msg, off + 8);
+ off += dns.rr_fixed;
+ if (off + rdlen > msg.len) {
+ self.dnsFail(error.DnsMalformed);
+ return;
+ }
+ if (rtype == dns.type_a and rclass == dns.class_in and rdlen == 4) {
+ self.query.result = rdIp(msg, off);
+ self.query.phase = .done;
+ return;
+ }
+ off += rdlen;
+ }
+ // A well-formed answer with no A record in it: NODATA, or a CNAME chain this stack will
+ // not chase a second query down.
+ self.dnsFail(error.NameNotFound);
+ }
+
+ fn dnsTick(self: *Stack) void {
+ if (self.query.phase != .waiting) return;
+ if (self.now_ms < self.query.retry_ms) return;
+ if (self.query.tries >= dns_backoff_ms.len) {
+ self.dnsFail(error.TimedOut);
+ return;
+ }
+ self.query.retry_ms = self.now_ms + dns_backoff_ms[self.query.tries];
+ self.query.tries += 1;
+ self.counters.dns_retx += 1;
+ self.dnsSend();
+ }
+
+ /// Resolve `name` to an IPv4 address.
+ ///
+ /// **The protocol is `httpGet`'s, deliberately.** There is no clock and no `std.Io` here, so
+ /// there is nothing for a blocking call to block on: the first call sends the query and
+ /// returns `error.WouldBlock`, and the caller drives `tick` and `onFrame` and calls again
+ /// with the same name until an address or a real error comes back.
+ ///
+ /// const addr = while (true) {
+ /// stack.tick(hal.systimer.millis());
+ /// while (transport.next()) |frame| stack.onFrame(frame);
+ /// if (stack.resolve("0x4200.cafe")) |a| break a
+ /// else |e| if (e != error.WouldBlock) return e;
+ /// };
+ ///
+ /// The wait is bounded whether or not the caller bounds it: `dns_backoff_ms` retransmits
+ /// three times over 7 s and then answers `error.TimedOut`. Nothing here waits forever, and
+ /// the retransmissions happen in `tick`, so a caller that ticks and polls rarely still gets
+ /// them on time.
+ ///
+ /// One query is outstanding at a time. A call naming something else while one is in flight is
+ /// `error.Busy`; a call naming something else after one has finished starts a new query,
+ /// which is what makes the loop above safe to write for two names in a row.
+ pub fn resolve(self: *Stack, name: []const u8) DnsError!Ip4 {
+ // Encoded first, and compared in encoded form: `0x4200.cafe`, `0x4200.cafe.` and
+ // `0X4200.CAFE` are one name, and a caller that spells it differently between two polls
+ // of the same loop must not get `error.Busy` for it.
+ var wire: [dns_qname_max]u8 = undefined;
+ const wire_len = try dnsEncodeName(name, &wire);
+ const same = self.query.qname_len == wire_len and
+ dnsQNameEql(self.query.qname[0..wire_len], wire[0..wire_len]);
+
+ switch (self.query.phase) {
+ .idle => {},
+ .waiting => {
+ if (!same) return error.Busy;
+ return error.WouldBlock;
+ },
+ // A finished query for this name is collected and the slot released. A finished query
+ // for a different name falls through and is replaced.
+ .done => if (same) {
+ self.query.phase = .idle;
+ return self.query.result;
+ },
+ .failed => if (same) {
+ self.query.phase = .idle;
+ return self.query.err;
+ },
+ }
+
+ if (self.addr == null) return error.NoAddress;
+ if (self.dns == null) return error.NoDnsServer;
+
+ @memcpy(self.query.qname[0..wire_len], wire[0..wire_len]);
+ self.query.qname_len = @intCast(wire_len);
+ self.query.id = @truncate(self.draw());
+ // RFC 6335's dynamic range, as `tcpConnect` uses. Redrawn per query so a late answer to
+ // the previous one cannot be mistaken for this one even if the id happens to repeat.
+ self.query.local_port = @intCast(49152 + self.draw() % (65535 - 49152 + 1));
+ self.query.result = ip_any;
+ self.query.err = error.WouldBlock;
+ self.query.phase = .waiting;
+ self.query.tries = 1;
+ self.query.retry_ms = self.now_ms + dns_backoff_ms[0];
+ self.dnsSend();
+ return error.WouldBlock;
+ }
+
+ // ================================================================================= TCP
+ //
+ // One connection, client side only, one unacknowledged segment at a time. The send side is a
+ // single static buffer holding the whole request, so "retransmission" is always "send from
+ // `snd_una` again" and there is no retransmission queue. The receive side has no reassembly
+ // buffer at all: a segment that is not the next one expected is answered with a duplicate ACK
+ // and dropped. Three of those is a fast-retransmit signal to any modern peer, so the common
+ // case of one lost segment costs a round trip rather than an RTO - but a reordered segment
+ // costs a retransmission that a reassembly buffer would have avoided. That is the price of not
+ // having one, and on a Wi-Fi link where reordering is rare it is the right price.
+
+ fn emitTcp(self: *Stack, flags: u8, seq: u32, data: []const u8, mss_opt: bool) bool {
+ const src = self.addr orelse return false;
+ const opt_len: usize = if (mss_opt) 4 else 0;
+ const seg_len = tcp.hlen + opt_len + data.len;
+ assert(seg_len <= mtu - ip4.hlen);
+ const seg = self.tx[eth_hlen + ip4.hlen ..][0..seg_len];
+
+ wr16(seg, tcp.src_port, self.tcp.local_port);
+ wr16(seg, tcp.dst_port, self.tcp.peer_port);
+ wr32(seg, tcp.seq, seq);
+ wr32(seg, tcp.ack, self.tcp.rcv_nxt);
+ const words: u16 = @intCast((tcp.hlen + opt_len) / 4);
+ wr16(seg, tcp.hdrlen_flags, (words << 12) | flags);
+ wr16(seg, tcp.window, self.rcvWindow());
+ wr16(seg, tcp.chksum, 0);
+ wr16(seg, tcp.urgent, 0);
+ if (mss_opt) {
+ seg[tcp.hlen] = tcp.opt_mss;
+ seg[tcp.hlen + 1] = 4;
+ wr16(seg, tcp.hlen + 2, tcp_mss);
+ }
+ if (data.len != 0) @memcpy(seg[tcp.hlen + opt_len ..], data);
+ wr16(seg, tcp.chksum, transportChecksum(src, self.tcp.peer_ip, .tcp, seg));
+
+ self.counters.tcp_tx += 1;
+ return self.emitIp(src, self.tcp.peer_ip, .tcp, seg_len);
+ }
+
+ /// The window to advertise: real back-pressure, not a constant. Everything accepted is consumed
+ /// synchronously into the HTTP head buffer or the caller's `out`, so the window is whatever
+ /// room is left there, capped at one MSS. Advertising a fixed window while the consumer had no
+ /// room left would turn "the response is bigger than your buffer" into a silently dropped
+ /// segment and an RTO storm.
+ fn rcvWindow(self: *Stack) u16 {
+ const room: usize = switch (self.http.phase) {
+ .head => (http_head_max - self.http.head_len) + self.http.out.len,
+ // Chunked framing - the CRLF closing each chunk, the zero-length chunk, the trailer
+ // section and the final CRLF - is consumed and discarded rather than delivered, so it
+ // needs window that `out` does not account for. Without this a body that exactly
+ // fills `out` closes the window before its own terminator can arrive, and the request
+ // stalls until the RTO gives up on a peer that is behaving perfectly.
+ .body => (self.http.out.len - self.http.out_len) +
+ @as(usize, if (self.http.chunked) http_framing_max + 16 else 0),
+ else => tcp_window,
+ };
+ return @intCast(@min(room, tcp_window));
+ }
+
+ /// Reset the connection and fail the request. RST is sent unless the peer sent one.
+ fn tcpAbort(self: *Stack, err: HttpError, send_rst: bool) void {
+ if (send_rst and self.tcp.state != .closed and self.tcp.state != .arp_wait) {
+ _ = self.emitTcp(tcp.rst | tcp.ack_f, self.tcp.snd_nxt, &.{}, false);
+ }
+ self.tcp.state = .closed;
+ if (self.http.phase == .head or self.http.phase == .body) {
+ self.http.phase = .failed;
+ self.http.err = err;
+ }
+ }
+
+ /// Set up the connection block for a fresh connect. Written field by field on purpose: the
+ /// obvious `self.tcp = .{ ... }` would assign `tx` from the struct's `undefined` default, which
+ /// in a safe build overwrites the request bytes with 0xAA, and in a release build memsets half
+ /// a kilobyte for nothing.
+ fn tcpConnect(self: *Stack, peer: Ip4, port: u16) void {
+ // A fresh ephemeral port every time. RFC 6335's dynamic range is 49152-65535, and moving
+ // through it is what makes the short TIME_WAIT above safe.
+ const span: u32 = 65535 - 49152 + 1;
+ const iss = self.draw();
+ self.tcp.state = .arp_wait;
+ self.tcp.peer_ip = peer;
+ self.tcp.peer_port = port;
+ self.tcp.local_port = @intCast(49152 + self.draw() % span);
+ self.tcp.iss = iss;
+ self.tcp.snd_una = iss;
+ self.tcp.snd_nxt = iss;
+ self.tcp.snd_wnd = 0;
+ self.tcp.snd_mss = tcp_default_mss;
+ self.tcp.fin_queued = false;
+ self.tcp.peer_fin = false;
+ self.tcp.rcv_nxt = 0;
+ self.tcp.rto_deadline_ms = 0;
+ self.tcp.rto_ms = tcp_rto_initial_ms;
+ self.tcp.retries = 0;
+ self.tcp.close_deadline_ms = 0;
+ self.tcp.tx_len = 0;
+ self.arp_tries = 0;
+ self.arp_retry_ms = 0;
+ }
+
+ fn tcpSendSyn(self: *Stack) void {
+ self.tcp.state = .syn_sent;
+ self.tcp.snd_nxt = self.tcp.iss +% 1;
+ _ = self.emitTcp(tcp.syn, self.tcp.iss, &.{}, true);
+ self.armRto();
+ }
+
+ fn armRto(self: *Stack) void {
+ self.tcp.rto_deadline_ms = self.now_ms + self.tcp.rto_ms;
+ }
+
+ /// Send as much of the request as the peer's window and MSS allow, then the FIN if the whole
+ /// request has gone out. One segment in flight, so this sends at most one segment per call.
+ ///
+ /// Only `established` sends: receiving the peer's FIN does not move the state, it sets
+ /// `peer_fin`, so this stays the single place that decides what goes on the wire and the state
+ /// only ever changes when a FIN of ours actually leaves.
+ fn tcpSendData(self: *Stack) void {
+ if (self.tcp.state != .established) return;
+ // Nothing outstanding is the precondition for sending: this is the fixed window of one.
+ if (seqLt(self.tcp.snd_una, self.tcp.snd_nxt)) return;
+
+ const end = self.tcp.dataEnd();
+ if (seqLt(self.tcp.snd_nxt, end)) {
+ const off: usize = self.tcp.snd_nxt -% self.tcp.dataStart();
+ const remaining = self.tcp.tx_len - off;
+ const window: usize = self.tcp.snd_una +% self.tcp.snd_wnd -% self.tcp.snd_nxt;
+ const n = @min(@min(remaining, self.tcp.snd_mss), @max(window, 1));
+ // PSH on the last segment of the request: the peer's application should see it without
+ // waiting for more. RFC 793 has no requirement here; every HTTP server expects it.
+ const last = off + n == self.tcp.tx_len;
+ const flags: u8 = tcp.ack_f | (if (last) tcp.psh else 0);
+ const seq = self.tcp.snd_nxt;
+ self.tcp.snd_nxt = seq +% @as(u32, @intCast(n));
+ _ = self.emitTcp(flags, seq, self.tcp.tx[off..][0..n], false);
+ self.armRto();
+ return;
+ }
+ if (self.tcp.fin_queued and self.tcp.snd_nxt == end) {
+ const seq = self.tcp.snd_nxt;
+ self.tcp.snd_nxt = seq +% 1;
+ _ = self.emitTcp(tcp.fin | tcp.ack_f, seq, &.{}, false);
+ self.armRto();
+ // RFC 793's FIN-WAIT-1 if we closed first, its CLOSING/LAST-ACK if the peer did. Both
+ // of the latter are `last_ack` here: they differ only in which ACK is still owed, and
+ // `closeCheck` settles that from the sequence numbers.
+ self.tcp.state = if (self.tcp.peer_fin) .last_ack else .fin_wait_1;
+ }
+ }
+
+ /// Half-close: everything we mean to send has been sent, so send FIN once the data is out.
+ fn tcpFinish(self: *Stack) void {
+ if (self.tcp.fin_queued) return;
+ self.tcp.fin_queued = true;
+ self.tcpSendData();
+ }
+
+ /// Both directions closed and our FIN acknowledged: nothing is left in flight, so the
+ /// connection block can be released after TIME_WAIT. Called once at the end of every segment,
+ /// which covers both orders of arrival - the peer's FIN then its ACK, or the reverse.
+ fn closeCheck(self: *Stack) void {
+ switch (self.tcp.state) {
+ .fin_wait_1, .fin_wait_2, .last_ack => {},
+ else => return,
+ }
+ if (!self.tcp.peer_fin) return;
+ if (self.tcp.snd_una != self.tcp.snd_nxt) return;
+ self.tcp.state = .time_wait;
+ self.tcp.rto_deadline_ms = 0;
+ self.tcp.close_deadline_ms = self.now_ms + tcp_time_wait_ms;
+ }
+
+ fn tcpInput(self: *Stack, src: Ip4, dst: Ip4, seg: []const u8) void {
+ if (seg.len < tcp.hlen) {
+ self.counters.rx_dropped += 1;
+ return;
+ }
+ const hf = rd16(seg, tcp.hdrlen_flags);
+ const hlen = @as(usize, hf >> 12) * 4;
+ if (hlen < tcp.hlen or hlen > seg.len) {
+ self.counters.rx_dropped += 1;
+ return;
+ }
+ if (!transportChecksumOk(src, dst, .tcp, seg, rd16(seg, tcp.chksum))) {
+ self.counters.checksum_bad += 1;
+ return;
+ }
+ const flags: u8 = @truncate(hf & 0x3f);
+ const sport = rd16(seg, tcp.src_port);
+ const dport = rd16(seg, tcp.dst_port);
+
+ if (self.tcp.state == .closed or
+ dport != self.tcp.local_port or
+ sport != self.tcp.peer_port or
+ !std.mem.eql(u8, &src, &self.tcp.peer_ip))
+ {
+ // Not for our one connection. A real stack would RST; a client with no listening port
+ // gains nothing by telling a scanner it is there.
+ self.counters.rx_dropped += 1;
+ return;
+ }
+ self.counters.tcp_rx += 1;
+
+ const seq = rd32(seg, tcp.seq);
+ const ackno = rd32(seg, tcp.ack);
+ const data = seg[hlen..];
+
+ if (flags & tcp.rst != 0) {
+ self.counters.tcp_rst_rx += 1;
+ // RFC 5961 3: only a RST whose sequence number is the next one expected may tear the
+ // connection down. Anything else gets a challenge ACK, which is also what stops a
+ // blind off-path reset.
+ if (self.tcp.state == .syn_sent) {
+ // In SYN-SENT the RST is validated by its ACK instead: there is no rcv_nxt yet.
+ if (flags & tcp.ack_f != 0 and ackno == self.tcp.snd_nxt) self.tcpAbort(error.ConnectionReset, false);
+ return;
+ }
+ if (seq == self.tcp.rcv_nxt) {
+ self.tcpAbort(error.ConnectionReset, false);
+ } else {
+ _ = self.emitTcp(tcp.ack_f, self.tcp.snd_nxt, &.{}, false);
+ }
+ return;
+ }
+
+ if (self.tcp.state == .syn_sent) {
+ if (flags & tcp.syn == 0) {
+ self.counters.rx_dropped += 1;
+ return;
+ }
+ if (flags & tcp.ack_f == 0) {
+ // A simultaneous open. Nothing on the other end of an HTTP GET does this.
+ self.counters.rx_dropped += 1;
+ return;
+ }
+ if (ackno != self.tcp.iss +% 1) {
+ // Not acknowledging our SYN: an old duplicate. RFC 793 says reset it.
+ _ = self.emitTcp(tcp.rst, ackno, &.{}, false);
+ return;
+ }
+ self.tcp.rcv_nxt = seq +% 1;
+ self.tcp.snd_una = ackno;
+ self.tcp.snd_wnd = rd16(seg, tcp.window);
+ self.tcp.snd_mss = parseMss(seg[tcp.hlen..hlen]) orelse tcp_default_mss;
+ self.tcp.state = .established;
+ self.tcp.rto_ms = tcp_rto_initial_ms;
+ self.tcp.retries = 0;
+ // The ACK completing the handshake carries the first data segment, which is one frame
+ // saved and what every other stack does.
+ self.tcp.rto_deadline_ms = 0;
+ self.tcpSendData();
+ if (self.tcp.snd_nxt == self.tcp.snd_una) {
+ // Nothing to send yet; the handshake still needs acknowledging.
+ _ = self.emitTcp(tcp.ack_f, self.tcp.snd_nxt, &.{}, false);
+ }
+ return;
+ }
+
+ // A duplicate SYN in an established connection is either a retransmitted SYN whose ACK was
+ // lost - answer with an ACK - or an attack. Never a reason to re-open.
+ if (flags & tcp.syn != 0 and seqLt(seq, self.tcp.rcv_nxt)) {
+ _ = self.emitTcp(tcp.ack_f, self.tcp.snd_nxt, &.{}, false);
+ return;
+ }
+
+ if (flags & tcp.ack_f != 0) self.tcpAck(ackno, rd16(seg, tcp.window));
+
+ // ---- receive side
+ var payload = data;
+ var accept = false;
+ if (payload.len != 0) {
+ if (seqLe(seq, self.tcp.rcv_nxt) and seqGt(seq +% @as(u32, @intCast(payload.len)), self.tcp.rcv_nxt)) {
+ // Overlaps what we already have: trim the duplicate prefix. A retransmission after
+ // a lost ACK arrives exactly like this, and rejecting it would deadlock.
+ const skip: usize = self.tcp.rcv_nxt -% seq;
+ payload = payload[skip..];
+ accept = true;
+ } else if (seqLe(seq +% @as(u32, @intCast(payload.len)), self.tcp.rcv_nxt)) {
+ // Wholly old. Re-acknowledge so the peer stops.
+ _ = self.emitTcp(tcp.ack_f, self.tcp.snd_nxt, &.{}, false);
+ return;
+ } else {
+ // Out of order, and there is nowhere to keep it. The duplicate ACK below is the
+ // signal that makes the peer resend.
+ _ = self.emitTcp(tcp.ack_f, self.tcp.snd_nxt, &.{}, false);
+ return;
+ }
+ }
+
+ if (accept) {
+ // Never accept more than the window we advertised.
+ const room = self.rcvWindow();
+ if (payload.len > room) payload = payload[0..room];
+ self.tcp.rcv_nxt +%= @intCast(payload.len);
+ // Consuming the data may itself put a segment on the wire - completing the body sends
+ // our FIN - and every segment carries `rcv_nxt`, so a separate ACK would be a wasted
+ // frame. Counting is the honest way to know: anything emitted has already acknowledged
+ // this data, and nothing emitted means we still owe an ACK.
+ const tx_before = self.counters.tcp_tx;
+ self.httpOnData(payload);
+ if (self.tcp.state == .closed) return; // httpOnData failed and aborted
+ if (self.counters.tcp_tx == tx_before) {
+ _ = self.emitTcp(tcp.ack_f, self.tcp.snd_nxt, &.{}, false);
+ }
+ }
+
+ // ---- FIN, in order only. An out-of-order FIN names a sequence number beyond data we have
+ // not seen, and honouring it would close the connection over a hole.
+ if (flags & tcp.fin != 0) {
+ const fin_seq = seq +% @as(u32, @intCast(data.len));
+ const in_order = fin_seq == self.tcp.rcv_nxt;
+ // A FIN we have already consumed, arriving again because our ACK was lost. It must be
+ // re-acknowledged or the peer retransmits until it gives up and resets.
+ const duplicate = self.tcp.peer_fin and fin_seq +% 1 == self.tcp.rcv_nxt;
+ if (in_order and !self.tcp.peer_fin) {
+ self.tcp.rcv_nxt +%= 1;
+ self.tcp.peer_fin = true;
+ self.httpOnEof();
+ }
+ if (in_order or duplicate) {
+ // Our own FIN, if it has not gone yet, acknowledges the peer's on the way out.
+ const tx_before = self.counters.tcp_tx;
+ self.tcpFinish();
+ if (self.counters.tcp_tx == tx_before) {
+ _ = self.emitTcp(tcp.ack_f, self.tcp.snd_nxt, &.{}, false);
+ }
+ }
+ }
+
+ self.closeCheck();
+ }
+
+ fn tcpAck(self: *Stack, ackno: u32, window: u16) void {
+ // An ACK ahead of what we sent is invalid; an old one is a duplicate.
+ if (seqGt(ackno, self.tcp.snd_nxt)) return;
+ self.tcp.snd_wnd = window;
+ if (seqLe(ackno, self.tcp.snd_una)) {
+ // Duplicate ACK. With one segment in flight there is nothing to fast-retransmit.
+ return;
+ }
+ self.tcp.snd_una = ackno;
+ self.tcp.retries = 0;
+ self.tcp.rto_ms = tcp_rto_initial_ms;
+ if (self.tcp.snd_una == self.tcp.snd_nxt) {
+ self.tcp.rto_deadline_ms = 0; // nothing outstanding
+ } else {
+ self.armRto();
+ }
+ if (self.tcp.state == .fin_wait_1 and self.tcp.snd_una == self.tcp.snd_nxt) {
+ self.tcp.state = .fin_wait_2;
+ self.tcp.close_deadline_ms = self.now_ms + tcp_fin_wait2_ms;
+ }
+ // Window opened or data acknowledged: there may be more to send.
+ self.tcpSendData();
+ }
+
+ /// RFC 793 3.1 option format: kind, then for kinds above 1 a length byte covering both.
+ fn parseMss(opts: []const u8) ?u16 {
+ var i: usize = 0;
+ while (i < opts.len) {
+ const kind = opts[i];
+ if (kind == tcp.opt_end) return null;
+ if (kind == tcp.opt_nop) {
+ i += 1;
+ continue;
+ }
+ if (i + 2 > opts.len) return null;
+ const len = opts[i + 1];
+ if (len < 2 or i + len > opts.len) return null;
+ if (kind == tcp.opt_mss and len == 4) {
+ const v = rd16(opts, i + 2);
+ // Below RFC 1122's floor a peer's MSS is not believable; above our MTU it cannot
+ // be honoured anyway.
+ return @min(@max(v, 64), tcp_mss);
+ }
+ i += len;
+ }
+ return null;
+ }
+
+ fn tcpTick(self: *Stack) void {
+ switch (self.tcp.state) {
+ .closed => {},
+ .arp_wait => {
+ if (self.arpLookup(self.tcpNextHop())) |_| {
+ self.tcpSendSyn();
+ return;
+ }
+ if (self.arp_retry_ms != 0 and self.now_ms < self.arp_retry_ms) return;
+ if (self.arp_tries >= arp_max_tries) {
+ self.tcpAbort(error.HostUnreachable, false);
+ return;
+ }
+ self.arpRequest(self.tcpNextHop());
+ self.arp_tries += 1;
+ self.arp_retry_ms = self.now_ms + arp_retry_ms;
+ },
+ .fin_wait_2 => {
+ // Our FIN is acknowledged and nothing is outstanding, so there is no RTO to run:
+ // the only thing left is the peer's FIN, and this is how long we wait for it.
+ if (self.now_ms >= self.tcp.close_deadline_ms) self.tcp.state = .closed;
+ },
+ .time_wait => {
+ if (self.now_ms >= self.tcp.close_deadline_ms) self.tcp.state = .closed;
+ },
+ else => {
+ if (self.tcp.rto_deadline_ms == 0) return;
+ if (self.now_ms < self.tcp.rto_deadline_ms) return;
+ if (self.tcp.retries >= tcp_max_retries) {
+ self.tcpAbort(error.TimedOut, true);
+ return;
+ }
+ self.tcp.retries += 1;
+ self.counters.tcp_retx += 1;
+ // Exponential backoff, RFC 6298 5.5.
+ self.tcp.rto_ms = @min(self.tcp.rto_ms * 2, tcp_rto_max_ms);
+ self.tcpRetransmit();
+ },
+ }
+ }
+
+ fn tcpNextHop(self: *Stack) Ip4 {
+ return if (self.onLink(self.tcp.peer_ip)) self.tcp.peer_ip else self.gw;
+ }
+
+ /// Go back to `snd_una` and send again. With one segment in flight this is the whole of
+ /// retransmission: there is no queue to walk and no partial-ACK case to handle.
+ fn tcpRetransmit(self: *Stack) void {
+ const una = self.tcp.snd_una;
+ if (una == self.tcp.iss) {
+ // The SYN. Its MSS option must be repeated: a peer that only ever sees the
+ // retransmission would otherwise assume 536.
+ self.tcp.snd_nxt = self.tcp.iss;
+ self.tcpSendSyn();
+ return;
+ }
+ const end = self.tcp.dataEnd();
+ if (seqLt(una, end)) {
+ self.tcp.snd_nxt = una;
+ self.tcpSendData();
+ return;
+ }
+ if (self.tcp.fin_queued and una == end) {
+ self.tcp.snd_nxt = una;
+ // `tcpSendData` re-sends the FIN and re-arms, but it refuses to run in FIN_WAIT_1
+ // (which is where a lost FIN leaves us), so the segment is emitted directly.
+ _ = self.emitTcp(tcp.fin | tcp.ack_f, una, &.{}, false);
+ self.tcp.snd_nxt = una +% 1;
+ self.armRto();
+ return;
+ }
+ // Nothing identifiable outstanding: a bare ACK, which costs one frame and cannot hurt.
+ _ = self.emitTcp(tcp.ack_f, self.tcp.snd_nxt, &.{}, false);
+ self.armRto();
+ }
+
+ // ================================================================================ HTTP
+
+ /// Fetch `path` from `host:port` over HTTP/1.1 and write the body to `out`, sending the
+ /// address literal as the `Host:` header. Exactly `httpGetHost(host, null, ...)`; see there
+ /// for the protocol, which is the whole of how this is used.
+ pub fn httpGet(self: *Stack, host: [4]u8, port: u16, path: []const u8, out: []u8) HttpError!usize {
+ return self.httpGetHost(host, null, port, path, out);
+ }
+
+ /// Fetch `path` from `host:port` over HTTP/1.1 and write the body to `out`.
+ ///
+ /// `name` is the `Host:` header. `null` sends the address literal - `Host: 192.168.1.90` -
+ /// which is right for a bare address and is what `httpGet` does. A name is what a
+ /// name-based virtual host requires: one address behind a CDN serves thousands of sites and
+ /// picks between them on this header alone, so `Host: 104.21.46.8` gets the CDN's own error
+ /// page and never the site. The address is still where the connection goes; the name only
+ /// ever appears in the header, and nothing here resolves it - `resolve` does that, and the
+ /// two are separate because a caller may have the address already.
+ ///
+ /// The port is appended as `:port` only when it is not 80, name or no name. RFC 7230 5.4.
+ ///
+ /// **This does not block, and it is not a one-shot call.** There is no `std.Io` here and no
+ /// clock, so there is nothing for a blocking call to block on: the frames that carry the
+ /// response arrive through `onFrame` and time advances through `tick`, both of which are the
+ /// caller's to drive. So the first call starts the request and returns `error.WouldBlock`, and
+ /// the caller keeps driving and keeps calling with the same arguments until it returns a length:
+ ///
+ /// while (true) {
+ /// stack.tick(hal.systimer.millis());
+ /// while (transport.next()) |frame| stack.onFrame(frame);
+ /// if (stack.httpGetHost(addr, "0x4200.cafe", 80, "/", &buf)) |n| break :done buf[0..n]
+ /// else |e| if (e != error.WouldBlock) return e;
+ /// }
+ ///
+ /// `out` is borrowed until the request completes: it is written to from inside `onFrame` as the
+ /// body arrives, so it must not move or be reused meanwhile. Calling with different arguments
+ /// while a request is in flight returns `error.Busy` rather than quietly abandoning the first,
+ /// and `name` is one of those arguments: two requests to one address for one path but
+ /// different virtual hosts are different requests.
+ ///
+ /// `Content-Length` is honoured, and so is `Transfer-Encoding: chunked` - the body handed back
+ /// is decoded, with no framing bytes in it. A response with neither ends at the peer's FIN,
+ /// which is why the request says `Connection: close`.
+ pub fn httpGetHost(
+ self: *Stack,
+ host: [4]u8,
+ name: ?[]const u8,
+ port: u16,
+ path: []const u8,
+ out: []u8,
+ ) HttpError!usize {
+ // The name is folded into the path hash rather than given a field of its own: `Stack` has
+ // a 4 KiB budget, and what this has to distinguish is "the same call again" from "a
+ // different call", which a hash does exactly. Seeding with the name's hash rather than
+ // concatenating keeps `null` (seed 0) distinct from any name, including the empty one.
+ const req_hash = std.hash.Wyhash.hash(
+ if (name) |nm| std.hash.Wyhash.hash(0x486f_7374, nm) else 0,
+ path,
+ );
+ switch (self.http.phase) {
+ .idle => {},
+ .head, .body => {
+ if (!std.mem.eql(u8, &self.http.req_host, &host) or
+ self.http.req_port != port or
+ self.http.req_hash != req_hash or
+ self.http.out.ptr != out.ptr or
+ self.http.out.len != out.len) return error.Busy;
+ return error.WouldBlock;
+ },
+ .complete => {
+ const n = self.http.out_len;
+ self.http.phase = .idle;
+ return n;
+ },
+ .failed => {
+ const e = self.http.err;
+ self.http.phase = .idle;
+ return e;
+ },
+ }
+
+ if (self.addr == null) return error.NoAddress;
+
+ // The request, built once into the TCP send buffer where it stays until acknowledged.
+ var w: RequestWriter = .{ .buf = &self.tcp.tx };
+ w.str("GET ");
+ w.str(if (path.len == 0) "/" else path);
+ w.str(" HTTP/1.1\r\nHost: ");
+ if (name) |nm| w.str(nm) else w.ipv4(host);
+ if (port != 80) {
+ w.str(":");
+ w.dec(port);
+ }
+ // Connection: close is not politeness, it is the framing: it is what makes a response with
+ // no Content-Length terminable, and it is what makes the peer's FIN the end of the body.
+ w.str("\r\nUser-Agent: zig-p4/0.1\r\nAccept: */*\r\nConnection: close\r\n\r\n");
+ if (w.overflow) return error.RequestTooLong;
+
+ self.http = .{
+ .phase = .head,
+ .out = out,
+ .req_host = host,
+ .req_port = port,
+ .req_hash = req_hash,
+ };
+ self.tcpConnect(host, port);
+ self.tcp.tx_len = w.i;
+ self.tcp.fin_queued = false;
+ // A MAC address may already be known, in which case the SYN goes out now rather than one
+ // `tick` later.
+ if (self.arpLookup(self.tcpNextHop()) != null) {
+ self.tcpSendSyn();
+ } else {
+ self.arpRequest(self.tcpNextHop());
+ self.arp_tries = 1;
+ self.arp_retry_ms = self.now_ms + arp_retry_ms;
+ }
+ return error.WouldBlock;
+ }
+
+ /// A bounds-checked append into a fixed buffer. Overflow is recorded, not asserted: a caller's
+ /// long path is a request error, not a bug in this file.
+ const RequestWriter = struct {
+ buf: []u8,
+ i: usize = 0,
+ overflow: bool = false,
+
+ fn str(self: *RequestWriter, s: []const u8) void {
+ if (self.overflow or self.i + s.len > self.buf.len) {
+ self.overflow = true;
+ return;
+ }
+ @memcpy(self.buf[self.i..][0..s.len], s);
+ self.i += s.len;
+ }
+ fn dec(self: *RequestWriter, v: u32) void {
+ var tmp: [10]u8 = undefined;
+ var n: usize = 0;
+ var x = v;
+ while (true) {
+ tmp[n] = '0' + @as(u8, @intCast(x % 10));
+ n += 1;
+ x /= 10;
+ if (x == 0) break;
+ }
+ while (n > 0) {
+ n -= 1;
+ self.str(tmp[n .. n + 1]);
+ }
+ }
+ fn ipv4(self: *RequestWriter, a: Ip4) void {
+ for (a, 0..) |b, k| {
+ if (k != 0) self.str(".");
+ self.dec(b);
+ }
+ }
+ };
+
+ /// Fail the request and reset the connection. The phase is set before `tcpAbort`, which would
+ /// otherwise overwrite `err` with its own argument on the way past.
+ fn httpFail(self: *Stack, e: HttpError) void {
+ self.http.phase = .failed;
+ self.http.err = e;
+ self.tcpAbort(e, true);
+ }
+
+ /// In-order TCP payload. Called from inside `onFrame`.
+ fn httpOnData(self: *Stack, bytes: []const u8) void {
+ var rest = bytes;
+ if (self.http.phase == .head) {
+ const room = http_head_max - self.http.head_len;
+ const n = @min(room, rest.len);
+ @memcpy(self.http.head[self.http.head_len..][0..n], rest[0..n]);
+ const scan_from = self.http.head_len -| 3;
+ self.http.head_len += n;
+ rest = rest[n..];
+
+ const blank = std.mem.indexOfPos(u8, self.http.head[0..self.http.head_len], scan_from, "\r\n\r\n") orelse {
+ if (self.http.head_len == http_head_max) self.httpFail(error.HttpHeadersTooLong);
+ return;
+ };
+ const head_end = blank + 4;
+ // Anything the head buffer swallowed past the blank line is body. This is the case a
+ // test has to cover deliberately, because it only happens when a segment boundary does
+ // not coincide with the end of the headers - which on a real server is most of the time.
+ const spill = self.http.head[head_end..self.http.head_len];
+ self.parseHead(self.http.head[0..blank]) catch |e| {
+ self.httpFail(e);
+ return;
+ };
+ self.http.phase = .body;
+ // `spill` aliases `self.http.head`, and `httpBody` only ever writes to `self.http.out`,
+ // so passing it through is safe. Copy first if that ever stops being true.
+ //
+ // It is called unconditionally, even when `spill` is empty: that is what completes a
+ // `Content-Length: 0` response, whose body is over the moment its headers are.
+ self.httpBody(spill);
+ if (self.http.phase != .body) return;
+ }
+ if (rest.len != 0) self.httpBody(rest);
+ }
+
+ /// Status line and headers, without the terminating blank line.
+ fn parseHead(self: *Stack, head: []const u8) HttpError!void {
+ var lines = std.mem.splitSequence(u8, head, "\r\n");
+ const status_line = lines.next() orelse return error.HttpMalformed;
+ // "HTTP/1.1 200 OK": version, space, three digits.
+ if (status_line.len < 12) return error.HttpMalformed;
+ if (!std.mem.startsWith(u8, status_line, "HTTP/1.")) return error.HttpMalformed;
+ if (status_line[8] != ' ') return error.HttpMalformed;
+ var code: u16 = 0;
+ for (status_line[9..12]) |c| {
+ if (c < '0' or c > '9') return error.HttpMalformed;
+ code = code * 10 + (c - '0');
+ }
+ self.http.status = code;
+ self.http.content_length = null;
+ self.http.chunked = false;
+
+ while (lines.next()) |line| {
+ if (line.len == 0) continue;
+ const colon = std.mem.indexOfScalar(u8, line, ':') orelse continue;
+ const name = line[0..colon];
+ const value = std.mem.trim(u8, line[colon + 1 ..], " \t");
+ // RFC 7230 3.2: field names are case-insensitive. Servers vary, and a stack that
+ // compares them exactly works against nginx and fails against something else.
+ if (std.ascii.eqlIgnoreCase(name, "content-length")) {
+ self.http.content_length = std.fmt.parseInt(usize, value, 10) catch
+ return error.HttpMalformed;
+ } else if (std.ascii.eqlIgnoreCase(name, "transfer-encoding")) {
+ // RFC 7230 3.3.1: the final coding decides the framing. Exactly two are
+ // understood - `chunked`, which frames the body, and `identity`, which does not -
+ // and a list, or a coding that transforms the bytes, is refused. Guessing at
+ // `gzip` would hand the caller compressed data and call it a body.
+ if (std.ascii.eqlIgnoreCase(value, "chunked")) {
+ self.http.chunked = true;
+ } else if (!std.ascii.eqlIgnoreCase(value, "identity")) {
+ return error.UnsupportedTransferEncoding;
+ }
+ }
+ }
+ if (self.http.chunked) {
+ // RFC 7230 3.3.3 case 3: when both are present the chunked framing wins and
+ // `Content-Length` must be ignored - it is the classic request-smuggling
+ // disagreement, and a response that carries both is not to be believed twice.
+ self.http.content_length = null;
+ self.http.chunk = .size;
+ self.http.chunk_left = 0;
+ self.http.chunk_digit = false;
+ self.http.chunk_skip = 0;
+ }
+ // A response whose body cannot possibly fit is refused now rather than after copying most
+ // of it: the caller gets a clean error instead of a truncated buffer. A chunked response
+ // announces no total, so its equivalent check is per chunk, in `httpChunkedBody`.
+ if (self.http.content_length) |len| {
+ if (len > self.http.out.len) return error.StreamTooLong;
+ }
+ }
+
+ fn httpBody(self: *Stack, bytes: []const u8) void {
+ if (self.http.chunked) return self.httpChunkedBody(bytes);
+ var b = bytes;
+ if (self.http.content_length) |len| {
+ const want = len - self.http.out_len;
+ if (b.len > want) b = b[0..want];
+ }
+ if (self.http.out_len + b.len > self.http.out.len) {
+ self.httpFail(error.StreamTooLong);
+ return;
+ }
+ @memcpy(self.http.out[self.http.out_len..][0..b.len], b);
+ self.http.out_len += b.len;
+ if (self.http.content_length) |len| {
+ if (self.http.out_len >= len) self.httpComplete();
+ }
+ }
+
+ /// Charge `n` bytes against the framing budget. False means the request has been failed and
+ /// the decoder must stop.
+ fn chunkSkip(self: *Stack, n: usize) bool {
+ const total = @as(usize, self.http.chunk_skip) + n;
+ if (total > http_framing_max) {
+ self.httpFail(error.HttpHeadersTooLong);
+ return false;
+ }
+ self.http.chunk_skip = @intCast(total);
+ return true;
+ }
+
+ /// RFC 7230 4.1 chunked decoding, resumable between any two bytes.
+ ///
+ /// The decoder's whole position lives in `http.chunk`, `chunk_left`, `chunk_digit` and
+ /// `chunk_skip`, and `bytes` is whatever the last segment happened to carry. Nothing is
+ /// buffered and nothing is looked ahead at: a size split across two segments accumulates a
+ /// digit at a time, a CRLF split across two segments is two states, and a chunk's data is
+ /// copied out as it arrives however it is cut up. That is not a hypothetical - a 1,460-byte
+ /// segment ends where the server's writes ended, which is nowhere in particular.
+ ///
+ /// `out` receives decoded data only. No size, no extension, no CRLF and no trailer byte is
+ /// ever copied into it, and every failure is a named error rather than a short body.
+ fn httpChunkedBody(self: *Stack, bytes: []const u8) void {
+ var b = bytes;
+ while (b.len != 0) {
+ switch (self.http.chunk) {
+ .size => {
+ const c = b[0];
+ const digit: ?u8 = switch (c) {
+ '0'...'9' => c - '0',
+ 'a'...'f' => c - 'a' + 10,
+ 'A'...'F' => c - 'A' + 10,
+ else => null,
+ };
+ b = b[1..];
+ if (digit) |d| {
+ // Checked, not truncated: a size that does not fit `usize` is a malformed
+ // message, and wrapping it would turn a hostile header into a short read
+ // that looks like a complete body.
+ if (self.http.chunk_left > (std.math.maxInt(usize) - @as(usize, d)) / 16) {
+ self.httpFail(error.HttpChunkMalformed);
+ return;
+ }
+ self.http.chunk_left = self.http.chunk_left * 16 + d;
+ self.http.chunk_digit = true;
+ continue;
+ }
+ // RFC 7230 4.1 is `1*HEXDIG`. Without this an empty line reads as a chunk of
+ // size zero, which is the terminator, which ends the body early.
+ if (!self.http.chunk_digit) {
+ self.httpFail(error.HttpChunkMalformed);
+ return;
+ }
+ self.http.chunk_skip = 0;
+ switch (c) {
+ ';' => self.http.chunk = .ext,
+ '\r' => self.http.chunk = .size_lf,
+ else => {
+ self.httpFail(error.HttpChunkMalformed);
+ return;
+ },
+ }
+ },
+ .ext => {
+ // chunk-ext is skipped whole: nothing here depends on one, so the only thing
+ // that matters is finding the CR that ends the header - possibly not in this
+ // segment at all.
+ const cr = std.mem.indexOfScalar(u8, b, '\r');
+ const n = cr orelse b.len;
+ if (!self.chunkSkip(n)) return;
+ b = b[n..];
+ if (cr != null) {
+ b = b[1..];
+ self.http.chunk = .size_lf;
+ }
+ },
+ .size_lf => {
+ if (b[0] != '\n') {
+ self.httpFail(error.HttpChunkMalformed);
+ return;
+ }
+ b = b[1..];
+ if (self.http.chunk_left == 0) {
+ // The zero-length chunk. What follows is the trailer section, and the
+ // body is not complete until its final CRLF.
+ self.http.chunk_skip = 0;
+ self.http.chunk = .trailer;
+ } else {
+ // Refused on the header rather than part-way through the copy, which is
+ // what `Content-Length` gets: the caller sees the error before the buffer
+ // has been half filled with a body it will never be given.
+ if (self.http.chunk_left > self.http.out.len - self.http.out_len) {
+ self.httpFail(error.StreamTooLong);
+ return;
+ }
+ self.http.chunk = .data;
+ }
+ },
+ .data => {
+ // In bounds by construction: `.size_lf` refused any chunk larger than the room
+ // left, and this only ever takes `chunk_left` of it.
+ const n = @min(self.http.chunk_left, b.len);
+ @memcpy(self.http.out[self.http.out_len..][0..n], b[0..n]);
+ self.http.out_len += n;
+ self.http.chunk_left -= n;
+ b = b[n..];
+ if (self.http.chunk_left == 0) self.http.chunk = .data_cr;
+ },
+ .data_cr => {
+ if (b[0] != '\r') {
+ self.httpFail(error.HttpChunkMalformed);
+ return;
+ }
+ b = b[1..];
+ self.http.chunk = .data_lf;
+ },
+ .data_lf => {
+ if (b[0] != '\n') {
+ self.httpFail(error.HttpChunkMalformed);
+ return;
+ }
+ b = b[1..];
+ // `.data` is only ever left with the chunk exhausted, so the accumulator the
+ // next size builds in already reads zero and is not re-zeroed here. Asserted
+ // rather than assumed: re-zeroing would be dead code that hides the day the
+ // invariant stops holding, and a stale count would be silent.
+ assert(self.http.chunk_left == 0);
+ self.http.chunk_digit = false;
+ self.http.chunk = .size;
+ },
+ .trailer => {
+ if (!self.chunkSkip(1)) return;
+ const cr = b[0] == '\r';
+ b = b[1..];
+ self.http.chunk = if (cr) .end_lf else .trailer_line;
+ },
+ .trailer_line => {
+ const cr = std.mem.indexOfScalar(u8, b, '\r');
+ const n = cr orelse b.len;
+ if (!self.chunkSkip(n)) return;
+ b = b[n..];
+ if (cr != null) {
+ b = b[1..];
+ self.http.chunk = .trailer_lf;
+ }
+ },
+ .trailer_lf => {
+ if (b[0] != '\n') {
+ self.httpFail(error.HttpChunkMalformed);
+ return;
+ }
+ b = b[1..];
+ self.http.chunk = .trailer;
+ },
+ .end_lf => {
+ if (b[0] != '\n') {
+ self.httpFail(error.HttpChunkMalformed);
+ return;
+ }
+ self.httpComplete();
+ // Anything after the final CRLF belongs to a response this connection will
+ // never ask for: `Connection: close` was sent, and the FIN follows.
+ return;
+ },
+ }
+ }
+ }
+
+ fn httpComplete(self: *Stack) void {
+ self.http.phase = .complete;
+ // The body is in hand; close our half. Reading further would only cost frames.
+ self.tcpFinish();
+ }
+
+ /// The peer closed. Whether that completes the response depends on the framing.
+ fn httpOnEof(self: *Stack) void {
+ switch (self.http.phase) {
+ .body => {
+ if (self.http.chunked) {
+ // The zero-length chunk and its trailer never arrived. RFC 7230 4.1 makes
+ // them the framing, so a close before them is a truncated body, however many
+ // whole chunks came first - reporting what did arrive would be reporting a
+ // prefix as the whole.
+ self.http.phase = .failed;
+ self.http.err = error.ConnectionClosed;
+ } else if (self.http.content_length) |len| {
+ if (self.http.out_len >= len) {
+ self.http.phase = .complete;
+ } else {
+ // Fewer body bytes than Content-Length promised.
+ self.http.phase = .failed;
+ self.http.err = error.ConnectionClosed;
+ }
+ } else {
+ // No Content-Length: the FIN *is* the framing (RFC 7230 3.3.3 case 7).
+ self.http.phase = .complete;
+ }
+ },
+ .head => {
+ self.http.phase = .failed;
+ self.http.err = error.ConnectionClosed;
+ },
+ else => {},
+ }
+ }
+
+ // ================================================================================ tick
+
+ /// Advance time. Drives DHCP retransmission and renewal, ARP resolution and TCP
+ /// retransmission. `now_ms` must be monotonic; it need not start at zero and it need not be
+ /// called at any particular rate, but nothing times out between calls, so a 47-second TCP
+ /// deadline needs ticks more often than every 47 seconds to be observed on time.
+ pub fn tick(self: *Stack, now_ms: u64) void {
+ self.now_ms = now_ms;
+ // Stir. The MAC alone would make every boot draw the same transaction ids, initial sequence
+ // numbers and ephemeral ports, which is how two runs of the same firmware end up accepting
+ // each other's stale DHCP replies. `now_ms` is the only outside input this file has, and a
+ // caller that ticks a real timer before starting DHCP therefore gets a different sequence
+ // every boot. Still not a source of security-relevant randomness - see `entropy`.
+ self.entropy ^= now_ms *% 0x9e37_79b9_7f4a_7c15;
+ self.dhcpTick();
+ self.dnsTick();
+ self.tcpTick();
+ }
+};
+
+// The footprint claim, enforced at compile time, so a buffer that grows fails the build rather than
+// the board.
+//
+// 4 KiB is the budget and it is measured, not guessed: the image has ~128 KB of L2MEM, nothing
+// initialises the 32 MB of PSRAM, and ESP-Hosted's queues and its seven task stacks are competing
+// for the same space. `Stack` is 3,576 bytes today. The failure this prevents is a stack overflow
+// on a part with no debugger, which is indistinguishable from the SDIO bus not coming up.
+comptime {
+ // 6 KiB, raised from 4 KiB when `http_head_max` went from 1024 to 2048 to fit a real CDN
+ // response head (1043 bytes measured). This is a regression alarm, not a budget: it exists so a
+ // buffer cannot grow unnoticed, and moving it is a decision to be justified at the buffer that
+ // caused it - which the comment on `http_head_max` does. The image's real constraint is the
+ // ~128 KB of L2MEM, and the heap in examples/http.zig was reduced by the same amount to pay for
+ // this.
+ if (Stack.footprint > 6 * 1024) @compileError(std.fmt.comptimePrint(
+ "ip.Stack is {d} bytes, over the 4 KiB budget",
+ .{Stack.footprint},
+ ));
+}
+
+// The host tests live in `ip_test.zig` - 117 cases, and they are the correctness argument for this
+// slice, since it is the one part of the P4 bring-up that can be proven without the board. They are
+// in their own file because they are longer than the stack, and because the tests deliberately
+// re-derive every header offset from the RFCs rather than importing the tables above: a test that
+// shares the constant it is checking passes on a consistent misreading.
+//
+// This reference is what makes `zig build test` find them: build.zig runs `src/net/ip.zig` as a
+// test root, and Zig only collects tests from files the root actually references.
+test {
+ _ = @import("ip_test.zig");
+}
diff --git a/src/net/ip_test.zig b/src/net/ip_test.zig
new file mode 100644
index 0000000..3e4c1f7
--- /dev/null
+++ b/src/net/ip_test.zig
@@ -0,0 +1,3029 @@
+//! Host tests for the IPv4 stack.
+//!
+//! This is the one slice of the P4 bring-up that can be *proven* without the board, and this file is
+//! the proof. The stack takes frames through `onFrame` and time through `tick`, so a network here is
+//! a function that writes bytes by hand and reads back whatever the stack handed to its `send`
+//! callback. Nothing is mocked, nothing is stubbed: the code under test is the code that will run on
+//! the die, byte for byte.
+//!
+//! Two rules keep this honest:
+//!
+//! * **The headers are re-derived here.** These tests do not import `ip.zig`'s offset tables; they
+//! write literal offsets taken from the RFCs and from lwIP's packed structs. A test that shared
+//! the constant it was checking would pass on a consistent misreading of the RFC, which is
+//! exactly the failure mode this stack has to avoid. Where the two transcriptions disagree, one
+//! of them is wrong and the test says so.
+//! * **Every checksum is verified, never merely computed.** A checksum built by the same helper
+//! the stack uses would prove nothing. `verify` below sums the received bytes independently and
+//! asserts the fold is zero, which is the property a peer's kernel will check.
+//!
+//! Run with: zig build test
+
+const std = @import("std");
+const testing = std.testing;
+const ip = @import("ip.zig");
+
+// ============================================================================ capture rig
+//
+// `Stack.init` takes `*const fn ([]const u8) void` - no context pointer - so the captured frames
+// have to live somewhere a plain function can reach. That is a wart in the interface, not in the
+// stack, and the cost is this file-scope buffer.
+
+const cap_max = 32;
+var cap_bytes: [cap_max][ip.frame_max]u8 = undefined;
+var cap_lens: [cap_max]usize = undefined;
+var cap_n: usize = 0;
+var cap_over: usize = 0;
+
+fn capture(frame: []const u8) void {
+ if (cap_n == cap_max) {
+ cap_over += 1;
+ return;
+ }
+ @memcpy(cap_bytes[cap_n][0..frame.len], frame);
+ cap_lens[cap_n] = frame.len;
+ cap_n += 1;
+}
+
+fn clearCapture() void {
+ cap_n = 0;
+ cap_over = 0;
+}
+
+fn sent(i: usize) []const u8 {
+ return cap_bytes[i][0..cap_lens[i]];
+}
+
+fn lastSent() []const u8 {
+ return sent(cap_n - 1);
+}
+
+/// A stack with a MAC and an empty capture log. Every test starts here.
+fn newStack() ip.Stack {
+ clearCapture();
+ return .init(our_mac, capture);
+}
+
+const our_mac: ip.Mac = .{ 0x40, 0x4c, 0xca, 0xfe, 0x00, 0x01 };
+const gw_mac: ip.Mac = .{ 0x02, 0x00, 0x00, 0x11, 0x22, 0x33 };
+const peer_mac: ip.Mac = .{ 0x02, 0x00, 0x00, 0xaa, 0xbb, 0xcc };
+const our_ip: ip.Ip4 = .{ 192, 168, 1, 42 };
+const gw_ip: ip.Ip4 = .{ 192, 168, 1, 1 };
+const mask24: ip.Ip4 = .{ 255, 255, 255, 0 };
+const peer_ip: ip.Ip4 = .{ 192, 168, 1, 90 };
+const off_net_ip: ip.Ip4 = .{ 93, 184, 216, 34 };
+const bcast_mac: ip.Mac = .{ 0xff, 0xff, 0xff, 0xff, 0xff, 0xff };
+/// RFC 826: the target hardware address of a request is "don't care".
+const zero_mac: ip.Mac = .{ 0, 0, 0, 0, 0, 0 };
+
+// ================================================================== independent primitives
+//
+// Header offsets written out again, from the RFCs. See the note at the top of the file.
+
+/// RFC 1071. Written differently from `ip.Checksum` on purpose: a `u32` accumulator over
+/// `readInt`-free manual pairing, so a mistake in one is not a mistake in both.
+fn sum16(bytes: []const u8) u32 {
+ var s: u32 = 0;
+ var i: usize = 0;
+ while (i + 1 < bytes.len) : (i += 2) {
+ s += (@as(u32, bytes[i]) << 8) | bytes[i + 1];
+ }
+ if (i < bytes.len) s += @as(u32, bytes[i]) << 8;
+ while (s >> 16 != 0) s = (s & 0xffff) + (s >> 16);
+ return s;
+}
+
+/// The property every receiver relies on: a buffer that already contains its own checksum sums to
+/// 0xffff, so the complement is zero.
+fn verify(bytes: []const u8) !void {
+ try testing.expectEqual(@as(u32, 0xffff), sum16(bytes));
+}
+
+fn verifyTransport(src: ip.Ip4, dst: ip.Ip4, proto: u8, seg: []const u8) !void {
+ var ph: [12]u8 = undefined;
+ @memcpy(ph[0..4], &src);
+ @memcpy(ph[4..8], &dst);
+ ph[8] = 0;
+ ph[9] = proto;
+ std.mem.writeInt(u16, ph[10..12], @intCast(seg.len), .big);
+ var s = sum16(&ph) + sum16(seg);
+ while (s >> 16 != 0) s = (s & 0xffff) + (s >> 16);
+ try testing.expectEqual(@as(u32, 0xffff), s);
+}
+
+fn be16(b: []const u8, off: usize) u16 {
+ return std.mem.readInt(u16, b[off..][0..2], .big);
+}
+fn be32(b: []const u8, off: usize) u32 {
+ return std.mem.readInt(u32, b[off..][0..4], .big);
+}
+fn put16(b: []u8, off: usize, v: u16) void {
+ std.mem.writeInt(u16, b[off..][0..2], v, .big);
+}
+fn put32(b: []u8, off: usize, v: u32) void {
+ std.mem.writeInt(u32, b[off..][0..4], v, .big);
+}
+
+/// A scratch frame under construction. `len` is the total frame length.
+const Frame = struct {
+ buf: [ip.frame_max]u8 = undefined,
+ len: usize = 0,
+
+ /// Ethernet II: 6 destination, 6 source, 2 ethertype. RFC 894 / lwIP `prot/ethernet.h:76-83`.
+ fn eth(self: *Frame, dst: ip.Mac, src: ip.Mac, ethertype: u16) void {
+ @memcpy(self.buf[0..6], &dst);
+ @memcpy(self.buf[6..12], &src);
+ put16(&self.buf, 12, ethertype);
+ self.len = 14;
+ }
+
+ /// RFC 791 3.1. Fills the header and returns the payload slice to be written; the caller then
+ /// calls `sealIp`.
+ fn ip4(self: *Frame, src: ip.Ip4, dst: ip.Ip4, proto: u8, payload_len: usize) []u8 {
+ const h = self.buf[14..][0..20];
+ h[0] = 0x45;
+ h[1] = 0;
+ put16(h, 2, @intCast(20 + payload_len));
+ put16(h, 4, 0x1234);
+ put16(h, 6, 0);
+ h[8] = 64;
+ h[9] = proto;
+ put16(h, 10, 0);
+ @memcpy(h[12..16], &src);
+ @memcpy(h[16..20], &dst);
+ self.len = 14 + 20 + payload_len;
+ return self.buf[34 .. 34 + payload_len];
+ }
+
+ fn sealIp(self: *Frame) void {
+ const h = self.buf[14..][0..20];
+ put16(h, 10, 0);
+ put16(h, 10, ~@as(u16, @truncate(sum16(h))));
+ }
+
+ /// Fill in a UDP or TCP checksum over the pseudo-header plus the segment.
+ fn sealTransport(self: *Frame, chksum_off: usize) void {
+ const h = self.buf[14..][0..20];
+ const proto = h[9];
+ const seg = self.buf[34..self.len];
+ var ph: [12]u8 = undefined;
+ @memcpy(ph[0..4], h[12..16]);
+ @memcpy(ph[4..8], h[16..20]);
+ ph[8] = 0;
+ ph[9] = proto;
+ std.mem.writeInt(u16, ph[10..12], @intCast(seg.len), .big);
+ put16(seg, chksum_off, 0);
+ var s = sum16(&ph) + sum16(seg);
+ while (s >> 16 != 0) s = (s & 0xffff) + (s >> 16);
+ put16(seg, chksum_off, ~@as(u16, @truncate(s)));
+ self.sealIp();
+ }
+
+ fn bytes(self: *const Frame) []const u8 {
+ return self.buf[0..self.len];
+ }
+};
+
+/// RFC 826 packet format, 28 bytes. lwIP `prot/etharp.h:86-96`.
+fn arpFrame(opcode: u16, sha: ip.Mac, spa: ip.Ip4, tha: ip.Mac, tpa: ip.Ip4, eth_dst: ip.Mac) Frame {
+ var f: Frame = .{};
+ f.eth(eth_dst, sha, 0x0806);
+ const a = f.buf[14..][0..28];
+ put16(a, 0, 1); // hwtype: Ethernet
+ put16(a, 2, 0x0800); // proto: IPv4
+ a[4] = 6;
+ a[5] = 4;
+ put16(a, 6, opcode);
+ @memcpy(a[8..14], &sha);
+ @memcpy(a[14..18], &spa);
+ @memcpy(a[18..24], &tha);
+ @memcpy(a[24..28], &tpa);
+ f.len = 14 + 28;
+ return f;
+}
+
+/// RFC 792 echo. `payload` is the data after the 8-byte header.
+fn icmpEchoFrame(src: ip.Ip4, dst: ip.Ip4, id: u16, seq: u16, payload: []const u8) Frame {
+ var f: Frame = .{};
+ f.eth(our_mac, peer_mac, 0x0800);
+ const p = f.ip4(src, dst, 1, 8 + payload.len);
+ p[0] = 8; // echo request
+ p[1] = 0;
+ put16(p, 2, 0);
+ put16(p, 4, id);
+ put16(p, 6, seq);
+ @memcpy(p[8..], payload);
+ // ICMP has no pseudo-header (RFC 792): the checksum covers the message alone.
+ put16(p, 2, ~@as(u16, @truncate(sum16(p))));
+ f.sealIp();
+ return f;
+}
+
+// ================================================================================= checksum
+
+test "RFC 1071 worked example" {
+ // RFC 1071 section 3, the byte sequence spelled out in the document's own figure:
+ // 00 01 f2 03 f4 f5 f6 f7 -> sum ddf2, checksum 220d
+ const data = [_]u8{ 0x00, 0x01, 0xf2, 0x03, 0xf4, 0xf5, 0xf6, 0xf7 };
+ try testing.expectEqual(@as(u32, 0xddf2), sum16(&data));
+ try testing.expectEqual(@as(u16, 0x220d), ip.checksum(&data));
+}
+
+test "checksum: incremental feeding matches contiguous, including at odd boundaries" {
+ // The bug this catches is a chunk of odd length leaving the high byte of a word unaccounted
+ // for. Splitting at every possible offset is cheap and total.
+ const data = [_]u8{ 0x45, 0x00, 0x00, 0x54, 0xab, 0xcd, 0x40, 0x00, 0x40, 0x01, 0x00, 0x00, 0xc0, 0xa8, 0x01, 0x2a, 0xc0, 0xa8, 0x01, 0x01, 0x7f };
+ const want = ip.checksum(&data);
+ var split: usize = 0;
+ while (split <= data.len) : (split += 1) {
+ var c: ip.Checksum = .{};
+ c.update(data[0..split]);
+ c.update(data[split..]);
+ try testing.expectEqual(want, c.final());
+ }
+ // Three-way split too, so two consecutive odd chunks are exercised.
+ var i: usize = 0;
+ while (i < data.len) : (i += 1) {
+ var j: usize = i;
+ while (j < data.len) : (j += 1) {
+ var c: ip.Checksum = .{};
+ c.update(data[0..i]);
+ c.update(data[i..j]);
+ c.update(data[j..]);
+ try testing.expectEqual(want, c.final());
+ }
+ }
+}
+
+test "checksum: an odd-length buffer is padded with a zero byte, not with the previous byte" {
+ // RFC 1071 section 1. A three-byte buffer must checksum as if it were four with a trailing 0.
+ const odd = [_]u8{ 0xde, 0xad, 0xbe };
+ const padded = [_]u8{ 0xde, 0xad, 0xbe, 0x00 };
+ try testing.expectEqual(ip.checksum(&padded), ip.checksum(&odd));
+}
+
+test "checksum: an all-zero buffer checksums to 0xffff, never to 0x0000" {
+ // A transmitted zero means "no checksum" in UDP, so the distinction is load-bearing.
+ const zeros: [20]u8 = @splat(0);
+ try testing.expectEqual(@as(u16, 0xffff), ip.checksum(&zeros));
+}
+
+test "checksum: RFC 768's transmitted zero is sent as 0xffff" {
+ // A UDP checksum field of zero means "not computed", so a datagram whose checksum genuinely
+ // works out to zero must transmit the arithmetically equivalent 0xffff instead. Tested on the
+ // helper because the case cannot be provoked by choosing DHCP option bytes: it depends on the
+ // whole datagram, headers included, summing to exactly 0xffff.
+ try testing.expectEqual(@as(u16, 0xffff), ip.udpChecksumOnWire(0));
+ try testing.expectEqual(@as(u16, 0xffff), ip.udpChecksumOnWire(0xffff));
+ try testing.expectEqual(@as(u16, 0x1234), ip.udpChecksumOnWire(0x1234));
+}
+
+test "checksum: a real IPv4 header verifies to zero once its own checksum is in place" {
+ var h = [_]u8{ 0x45, 0x00, 0x00, 0x3c, 0x1c, 0x46, 0x40, 0x00, 0x40, 0x06, 0x00, 0x00, 0xac, 0x10, 0x0a, 0x63, 0xac, 0x10, 0x0a, 0x0c };
+ const c = ip.checksum(&h);
+ put16(&h, 10, c);
+ try verify(&h);
+ // And the classic published value for this header, from the Wikipedia/Comer worked example.
+ try testing.expectEqual(@as(u16, 0xb1e6), c);
+}
+
+// ====================================================================================== ARP
+
+test "ARP: a request for our address is answered, and the reply is well formed" {
+ var s = newStack();
+ s.tick(1000);
+ s.setStatic(our_ip, mask24, gw_ip);
+ // setStatic announces; drop that so the reply is the only frame under test.
+ clearCapture();
+
+ var req = arpFrame(1, peer_mac, peer_ip, zero_mac, our_ip, bcast_mac);
+ s.onFrame(req.bytes());
+
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ const r = lastSent();
+ try testing.expectEqual(@as(usize, 42), r.len);
+ // Unicast back to the requester, not broadcast: a broadcast reply is legal but wasteful, and
+ // every stack on the segment would have to parse it.
+ try testing.expectEqualSlices(u8, &peer_mac, r[0..6]);
+ try testing.expectEqualSlices(u8, &our_mac, r[6..12]);
+ try testing.expectEqual(@as(u16, 0x0806), be16(r, 12));
+
+ const a = r[14..42];
+ try testing.expectEqual(@as(u16, 1), be16(a, 0)); // hwtype Ethernet
+ try testing.expectEqual(@as(u16, 0x0800), be16(a, 2)); // proto IPv4
+ try testing.expectEqual(@as(u8, 6), a[4]);
+ try testing.expectEqual(@as(u8, 4), a[5]);
+ try testing.expectEqual(@as(u16, 2), be16(a, 6)); // reply
+ try testing.expectEqualSlices(u8, &our_mac, a[8..14]); // sender hw = us
+ try testing.expectEqualSlices(u8, &our_ip, a[14..18]); // sender proto = us
+ try testing.expectEqualSlices(u8, &peer_mac, a[18..24]); // target hw = requester
+ try testing.expectEqualSlices(u8, &peer_ip, a[24..28]);
+}
+
+test "ARP: a request for somebody else's address is ignored" {
+ var s = newStack();
+ s.setStatic(our_ip, mask24, gw_ip);
+ clearCapture();
+ var req = arpFrame(1, peer_mac, peer_ip, zero_mac, .{ 192, 168, 1, 77 }, bcast_mac);
+ s.onFrame(req.bytes());
+ try testing.expectEqual(@as(usize, 0), cap_n);
+}
+
+test "ARP: a malformed header is rejected on all four RFC 826 reception checks" {
+ const bad_fields = [_]struct { off: usize, val: u8 }{
+ .{ .off = 1, .val = 2 }, // hwtype 2, not Ethernet
+ .{ .off = 3, .val = 0x06 }, // proto 0x0806, not IPv4
+ .{ .off = 4, .val = 8 }, // hwlen 8
+ .{ .off = 5, .val = 16 }, // protolen 16
+ };
+ for (bad_fields) |bad| {
+ var s = newStack();
+ s.setStatic(our_ip, mask24, gw_ip);
+ clearCapture();
+ var req = arpFrame(1, peer_mac, peer_ip, zero_mac, our_ip, bcast_mac);
+ req.buf[14 + bad.off] = bad.val;
+ s.onFrame(req.bytes());
+ try testing.expectEqual(@as(usize, 0), cap_n);
+ }
+}
+
+test "ARP: setStatic announces the address gratuitously" {
+ var s = newStack();
+ s.tick(500);
+ s.setStatic(our_ip, mask24, gw_ip);
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ const g = lastSent();
+ try testing.expectEqualSlices(u8, &bcast_mac, g[0..6]);
+ try testing.expectEqual(@as(u16, 0x0806), be16(g, 12));
+ const a = g[14..42];
+ try testing.expectEqual(@as(u16, 1), be16(a, 6)); // a request...
+ try testing.expectEqualSlices(u8, &our_ip, a[14..18]); // ...whose sender...
+ try testing.expectEqualSlices(u8, &our_ip, a[24..28]); // ...and target are both us
+}
+
+test "ARP: a four-entry cache is not thrashed by unrelated broadcast traffic" {
+ var s = newStack();
+ s.tick(1000);
+ s.setStatic(our_ip, mask24, gw_ip);
+
+ // Learn the gateway the legitimate way: it ARPs for us, we reply, and it goes in the cache.
+ var probe = arpFrame(1, gw_mac, gw_ip, zero_mac, our_ip, bcast_mac);
+ s.onFrame(probe.bytes());
+
+ // Now flood the segment with ARP between other hosts. None of it is addressed to us, so none
+ // of it may evict the gateway.
+ var k: u8 = 0;
+ while (k < 20) : (k += 1) {
+ var noise = arpFrame(
+ 1,
+ .{ 0x02, 0, 0, 0, 0, k },
+ .{ 192, 168, 1, 100 + k },
+ zero_mac,
+ .{ 192, 168, 1, 200 },
+ bcast_mac,
+ );
+ s.onFrame(noise.bytes());
+ }
+ clearCapture();
+
+ // If the gateway survived, a datagram to an off-net address goes straight out to `gw_mac`
+ // instead of provoking an ARP request.
+ var echo = icmpEchoFrame(gw_ip, our_ip, 1, 1, "x");
+ s.onFrame(echo.bytes());
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ try testing.expectEqual(@as(u16, 0x0800), be16(lastSent(), 12)); // IPv4, not an ARP request
+ try testing.expectEqualSlices(u8, &gw_mac, lastSent()[0..6]);
+}
+
+test "ARP: a cache entry ages out even while it is being used" {
+ // The bug this pins: refreshing an entry's timestamp on every lookup. It looks harmless and it
+ // means an entry kept alive by our own traffic is never re-resolved, so a gateway whose MAC
+ // changes is never noticed.
+ var s = newStack();
+ s.tick(1000);
+ s.setStatic(our_ip, mask24, gw_ip);
+ var probe = arpFrame(1, peer_mac, peer_ip, zero_mac, our_ip, bcast_mac);
+ s.onFrame(probe.bytes());
+
+ // Keep using the entry, all the way past the 300 s age limit.
+ var now: u64 = 1000;
+ while (now < 400_000) : (now += 10_000) {
+ s.tick(now);
+ clearCapture();
+ var echo = icmpEchoFrame(peer_ip, our_ip, 1, 1, "x");
+ s.onFrame(echo.bytes());
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ }
+ // Past the limit the entry is gone: the reply is dropped and an ARP request goes in its place.
+ try testing.expectEqual(@as(u16, 0x0806), be16(lastSent(), 12));
+ try testing.expectEqualSlices(u8, &peer_ip, lastSent()[14 + 24 ..][0..4]);
+}
+
+test "ARP: a host that changes its MAC is followed" {
+ var s = newStack();
+ s.tick(1000);
+ s.setStatic(our_ip, mask24, gw_ip);
+ var probe = arpFrame(1, peer_mac, peer_ip, zero_mac, our_ip, bcast_mac);
+ s.onFrame(probe.bytes());
+
+ // Same address, new hardware: a replaced router, or a VRRP failover.
+ const new_mac: ip.Mac = .{ 0x02, 0x00, 0x00, 0xde, 0xad, 0x01 };
+ var again = arpFrame(1, new_mac, peer_ip, zero_mac, our_ip, bcast_mac);
+ s.onFrame(again.bytes());
+ clearCapture();
+
+ var echo = icmpEchoFrame(peer_ip, our_ip, 1, 1, "x");
+ s.onFrame(echo.bytes());
+ try testing.expectEqualSlices(u8, &new_mac, lastSent()[0..6]);
+}
+
+test "IPv4: a received header carrying options is parsed by its own length field" {
+ // `ping -R` and any router-alert path produce these. A parser that assumes 20 bytes reads the
+ // options as the ICMP header and answers nonsense - or, worse, answers with the checksum
+ // covering the wrong bytes.
+ var s = newStack();
+ s.tick(1000);
+ s.setStatic(our_ip, mask24, gw_ip);
+ var probe = arpFrame(1, peer_mac, peer_ip, zero_mac, our_ip, bcast_mac);
+ s.onFrame(probe.bytes());
+ clearCapture();
+
+ // 24-byte header: 20 plus a 4-byte NOP,NOP,NOP,END option block.
+ var f: Frame = .{};
+ f.eth(our_mac, peer_mac, 0x0800);
+ const total = 24 + 8 + 4;
+ const h = f.buf[14..][0..24];
+ h[0] = 0x46; // IPv4, 6 words of header
+ h[1] = 0;
+ put16(h, 2, total);
+ put16(h, 4, 0x1234);
+ put16(h, 6, 0);
+ h[8] = 64;
+ h[9] = 1; // ICMP
+ put16(h, 10, 0);
+ @memcpy(h[12..16], &peer_ip);
+ @memcpy(h[16..20], &our_ip);
+ h[20] = 1; // NOP
+ h[21] = 1;
+ h[22] = 1;
+ h[23] = 0; // END
+ put16(h, 10, ~@as(u16, @truncate(sum16(h))));
+ const m = f.buf[14 + 24 ..][0 .. 8 + 4];
+ m[0] = 8;
+ m[1] = 0;
+ put16(m, 2, 0);
+ put16(m, 4, 0x0102);
+ put16(m, 6, 0x0304);
+ @memcpy(m[8..], "wxyz");
+ put16(m, 2, ~@as(u16, @truncate(sum16(m))));
+ f.len = 14 + total;
+ s.onFrame(f.bytes());
+
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ const r = lastSent();
+ // The reply is emitted with a plain 20-byte header - nothing here generates options - and the
+ // echoed id, sequence and data prove the request's payload was found at the right offset.
+ try testing.expectEqual(@as(u8, 0x45), r[14]);
+ try verify(r[14..34]);
+ const e = r[34..];
+ try testing.expectEqual(@as(u8, 0), e[0]);
+ try testing.expectEqual(@as(u16, 0x0102), be16(e, 4));
+ try testing.expectEqual(@as(u16, 0x0304), be16(e, 6));
+ try testing.expectEqualStrings("wxyz", e[8..12]);
+ try verify(e);
+}
+
+// ===================================================================================== ICMP
+
+test "ICMP: an echo request is answered with a correct echo reply" {
+ var s = newStack();
+ s.tick(1000);
+ s.setStatic(our_ip, mask24, gw_ip);
+ clearCapture();
+ // Teach the stack the peer's MAC by having it ARP for us first.
+ var probe = arpFrame(1, peer_mac, peer_ip, zero_mac, our_ip, bcast_mac);
+ s.onFrame(probe.bytes());
+ clearCapture();
+
+ // The payload `ping` sends: 56 bytes, a timestamp then a counting pattern.
+ var payload: [56]u8 = undefined;
+ for (&payload, 0..) |*b, i| b.* = @intCast(i);
+ var req = icmpEchoFrame(peer_ip, our_ip, 0xbeef, 7, &payload);
+ s.onFrame(req.bytes());
+
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ const r = lastSent();
+ try testing.expectEqual(@as(usize, 14 + 20 + 8 + 56), r.len);
+ try testing.expectEqualSlices(u8, &peer_mac, r[0..6]);
+ try testing.expectEqual(@as(u16, 0x0800), be16(r, 12));
+
+ const h = r[14..34];
+ try testing.expectEqual(@as(u8, 0x45), h[0]);
+ try testing.expectEqual(@as(u16, 20 + 8 + 56), be16(h, 2));
+ try testing.expectEqual(@as(u8, 1), h[9]); // ICMP
+ // RFC 1122 3.2.1.7 recommends 64. A TTL of 1 is the failure that works on the bench and dies
+ // at the first router, which is the worst possible time to find out.
+ try testing.expectEqual(@as(u8, 64), h[8]);
+ // Don't Fragment: this stack neither fragments nor reassembles, so a router must not fragment
+ // what it cannot rebuild.
+ try testing.expectEqual(@as(u16, 0x4000), be16(h, 6));
+ try testing.expectEqualSlices(u8, &our_ip, h[12..16]); // src and dst swapped
+ try testing.expectEqualSlices(u8, &peer_ip, h[16..20]);
+ try verify(h); // the IP header checksum, checked independently
+
+ const m = r[34..];
+ try testing.expectEqual(@as(u8, 0), m[0]); // echo reply
+ try testing.expectEqual(@as(u8, 0), m[1]);
+ try testing.expectEqual(@as(u16, 0xbeef), be16(m, 4)); // id echoed
+ try testing.expectEqual(@as(u16, 7), be16(m, 6)); // sequence echoed
+ try testing.expectEqualSlices(u8, &payload, m[8..]);
+ try verify(m); // and the ICMP checksum
+}
+
+test "ICMP: a request with a bad IP header checksum is dropped and counted" {
+ var s = newStack();
+ s.tick(1000);
+ s.setStatic(our_ip, mask24, gw_ip);
+ var probe = arpFrame(1, peer_mac, peer_ip, zero_mac, our_ip, bcast_mac);
+ s.onFrame(probe.bytes());
+ clearCapture();
+
+ var req = icmpEchoFrame(peer_ip, our_ip, 1, 1, "abcd");
+ req.buf[14 + 10] ^= 0xff; // corrupt the IP header checksum
+ s.onFrame(req.bytes());
+ try testing.expectEqual(@as(usize, 0), cap_n);
+ try testing.expectEqual(@as(u32, 1), s.counters.checksum_bad);
+}
+
+test "ICMP: a request with a bad ICMP checksum is dropped and counted" {
+ var s = newStack();
+ s.tick(1000);
+ s.setStatic(our_ip, mask24, gw_ip);
+ var probe = arpFrame(1, peer_mac, peer_ip, zero_mac, our_ip, bcast_mac);
+ s.onFrame(probe.bytes());
+ clearCapture();
+
+ var req = icmpEchoFrame(peer_ip, our_ip, 1, 1, "abcd");
+ req.buf[34 + 2] ^= 0xff; // corrupt the ICMP checksum
+ s.onFrame(req.bytes());
+ try testing.expectEqual(@as(usize, 0), cap_n);
+ try testing.expectEqual(@as(u32, 1), s.counters.checksum_bad);
+}
+
+test "ICMP: a fragment is dropped rather than answered as a whole datagram" {
+ var s = newStack();
+ s.tick(1000);
+ s.setStatic(our_ip, mask24, gw_ip);
+ var probe = arpFrame(1, peer_mac, peer_ip, zero_mac, our_ip, bcast_mac);
+ s.onFrame(probe.bytes());
+ clearCapture();
+
+ var req = icmpEchoFrame(peer_ip, our_ip, 1, 1, "abcd");
+ put16(&req.buf, 14 + 6, 0x2000); // MF set
+ req.sealIp();
+ s.onFrame(req.bytes());
+ try testing.expectEqual(@as(usize, 0), cap_n);
+}
+
+test "a frame addressed to another station is dropped" {
+ var s = newStack();
+ s.tick(1000);
+ s.setStatic(our_ip, mask24, gw_ip);
+ clearCapture();
+ var req = icmpEchoFrame(peer_ip, our_ip, 1, 1, "abcd");
+ req.buf[0] = 0x02; // not our MAC, not broadcast
+ s.onFrame(req.bytes());
+ try testing.expectEqual(@as(usize, 0), cap_n);
+ try testing.expect(s.counters.rx_dropped >= 1);
+}
+
+// ===================================================================================== DHCP
+//
+// RFC 2131. The synthetic server below is what a real one does with the fields that matter, and
+// nothing else: no relay agent, no overload, no vendor options.
+
+/// Offsets into the BOOTP message, from RFC 2131 figure 1 / lwIP `prot/dhcp.h:50-91`.
+const d = struct {
+ const op = 0;
+ const htype = 1;
+ const hlen = 2;
+ const xid = 4;
+ const secs = 8;
+ const flags = 10;
+ const ciaddr = 12;
+ const yiaddr = 16;
+ const siaddr = 20;
+ const chaddr = 28;
+ const cookie = 236;
+ const options = 240;
+};
+
+fn dhcpReply(kind: u8, xid: u32, yiaddr: ip.Ip4, server: ip.Ip4, opts: []const u8, dst_ip: ip.Ip4, dst_mac: ip.Mac) Frame {
+ var f: Frame = .{};
+ f.eth(dst_mac, gw_mac, 0x0800);
+ const payload_len = 8 + d.options + 3 + opts.len + 1;
+ const p = f.ip4(server, dst_ip, 17, payload_len);
+ put16(p, 0, 67); // source port: DHCP server
+ put16(p, 2, 68); // destination port: DHCP client
+ put16(p, 4, @intCast(payload_len));
+ put16(p, 6, 0);
+ const m = p[8..];
+ @memset(m, 0);
+ m[d.op] = 2; // BOOTREPLY
+ m[d.htype] = 1;
+ m[d.hlen] = 6;
+ put32(m, d.xid, xid);
+ @memcpy(m[d.yiaddr..][0..4], &yiaddr);
+ @memcpy(m[d.siaddr..][0..4], &server);
+ @memcpy(m[d.chaddr..][0..6], &our_mac);
+ put32(m, d.cookie, 0x63825363);
+ m[d.options] = 53; // message type
+ m[d.options + 1] = 1;
+ m[d.options + 2] = kind;
+ @memcpy(m[d.options + 3 ..][0..opts.len], opts);
+ m[d.options + 3 + opts.len] = 255; // END
+ f.sealTransport(6);
+ return f;
+}
+
+/// Option 1 (mask), 3 (router), 6 (DNS), 51 (lease), 54 (server id) for the network in the brief.
+const standard_opts = [_]u8{
+ 1, 4, 255, 255, 255, 0, // subnet mask /24
+ 3, 4, 192, 168, 1, 1, // router
+ 6, 4, 192, 168, 1, 1, // DNS
+ 51, 4, 0, 0, 0x1c, 0x20, // lease 7200 s
+ 54, 4, 192, 168, 1, 1, // server identifier
+};
+
+fn findOption(msg: []const u8, want: u8) ?[]const u8 {
+ var i: usize = d.options;
+ while (i < msg.len) {
+ if (msg[i] == 255) return null;
+ if (msg[i] == 0) {
+ i += 1;
+ continue;
+ }
+ if (i + 2 > msg.len) return null;
+ const len = msg[i + 1];
+ if (i + 2 + len > msg.len) return null;
+ if (msg[i] == want) return msg[i + 2 ..][0..len];
+ i += 2 + len;
+ }
+ return null;
+}
+
+/// The DHCP message inside a captured frame, and a few sanity checks that apply to all of them.
+fn dhcpOut(frame: []const u8) ![]const u8 {
+ try testing.expectEqual(@as(u16, 0x0800), be16(frame, 12));
+ const h = frame[14..34];
+ try testing.expectEqual(@as(u8, 17), h[9]); // UDP
+ try verify(h);
+ const seg = frame[34..];
+ try testing.expectEqual(@as(u16, 68), be16(seg, 0)); // from the client port
+ try testing.expectEqual(@as(u16, 67), be16(seg, 2)); // to the server port
+ try testing.expectEqual(@as(u16, @intCast(seg.len)), be16(seg, 4));
+ try verifyTransport(h[12..16].*, h[16..20].*, 17, seg);
+ const msg = seg[8..];
+ try testing.expectEqual(@as(u8, 1), msg[d.op]); // BOOTREQUEST
+ try testing.expectEqual(@as(u8, 1), msg[d.htype]); // Ethernet
+ try testing.expectEqual(@as(u8, 6), msg[d.hlen]);
+ try testing.expectEqual(@as(u32, 0x63825363), be32(msg, d.cookie));
+ try testing.expectEqualSlices(u8, &our_mac, msg[d.chaddr..][0..6]);
+ // RFC 951: a BOOTP message is at least 300 bytes.
+ try testing.expect(msg.len >= 300);
+ return msg;
+}
+
+test "DHCP: a full DISCOVER / OFFER / REQUEST / ACK exchange binds the address" {
+ var s = newStack();
+ s.tick(10_000);
+ s.dhcpStart();
+ try testing.expectEqual(ip.DhcpState.selecting, s.dhcpState());
+
+ // ---- DISCOVER
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ const disc_frame = sent(0);
+ // Broadcast at both layers: no address yet, so nothing else could work.
+ try testing.expectEqualSlices(u8, &bcast_mac, disc_frame[0..6]);
+ try testing.expectEqualSlices(u8, &.{ 0, 0, 0, 0 }, disc_frame[14 + 12 ..][0..4]);
+ try testing.expectEqualSlices(u8, &.{ 255, 255, 255, 255 }, disc_frame[14 + 16 ..][0..4]);
+ const disc = try dhcpOut(disc_frame);
+ try testing.expectEqual(@as(u16, 0x8000), be16(disc, d.flags)); // ask for a broadcast reply
+ try testing.expectEqualSlices(u8, &.{ 0, 0, 0, 0 }, disc[d.ciaddr..][0..4]);
+ try testing.expectEqualSlices(u8, &.{1}, findOption(disc, 53).?); // DHCPDISCOVER
+ try testing.expect(findOption(disc, 55) != null); // parameter request list
+ try testing.expect(findOption(disc, 57) != null); // maximum message size
+ // A DISCOVER must not claim an address or name a server.
+ try testing.expect(findOption(disc, 50) == null);
+ try testing.expect(findOption(disc, 54) == null);
+ const xid = be32(disc, d.xid);
+
+ // ---- OFFER, unicast to the address about to be granted (RFC 2131 4.1 permits this, and it is
+ // the case that only works because `ip4Input` lets UDP through while unbound).
+ clearCapture();
+ var offer = dhcpReply(2, xid, our_ip, gw_ip, &standard_opts, our_ip, our_mac);
+ s.onFrame(offer.bytes());
+ try testing.expectEqual(ip.DhcpState.requesting, s.dhcpState());
+
+ // ---- REQUEST
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ const req = try dhcpOut(sent(0));
+ try testing.expectEqual(xid, be32(req, d.xid)); // same transaction
+ try testing.expectEqualSlices(u8, &.{3}, findOption(req, 53).?); // DHCPREQUEST
+ // RFC 2131 4.3.2: SELECTING carries the offered address in option 50 and the server it is
+ // accepting in option 54, and `ciaddr` stays zero.
+ try testing.expectEqualSlices(u8, &our_ip, findOption(req, 50).?);
+ try testing.expectEqualSlices(u8, &gw_ip, findOption(req, 54).?);
+ try testing.expectEqualSlices(u8, &.{ 0, 0, 0, 0 }, req[d.ciaddr..][0..4]);
+
+ // ---- ACK
+ clearCapture();
+ var ack = dhcpReply(5, xid, our_ip, gw_ip, &standard_opts, our_ip, our_mac);
+ s.onFrame(ack.bytes());
+
+ try testing.expectEqual(ip.DhcpState.bound, s.dhcpState());
+ try testing.expectEqual(our_ip, s.ip().?);
+ try testing.expectEqual(mask24, s.netmask());
+ try testing.expectEqual(gw_ip, s.gateway());
+ try testing.expectEqual(gw_ip, s.dnsServer().?);
+ // Binding announces the new address.
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ try testing.expectEqual(@as(u16, 0x0806), be16(lastSent(), 12));
+ try testing.expectEqualSlices(u8, &our_ip, lastSent()[14 + 14 ..][0..4]);
+}
+
+test "DHCP: a reply with the wrong transaction id is ignored" {
+ var s = newStack();
+ s.tick(10_000);
+ s.dhcpStart();
+ const xid = be32(sent(0)[42..], d.xid);
+ clearCapture();
+ var offer = dhcpReply(2, xid ^ 0xffff_ffff, our_ip, gw_ip, &standard_opts, our_ip, our_mac);
+ s.onFrame(offer.bytes());
+ try testing.expectEqual(ip.DhcpState.selecting, s.dhcpState());
+ try testing.expectEqual(@as(usize, 0), cap_n);
+}
+
+test "DHCP: a reply for another station's hardware address is ignored" {
+ var s = newStack();
+ s.tick(10_000);
+ s.dhcpStart();
+ const xid = be32(sent(0)[42..], d.xid);
+ clearCapture();
+ var offer = dhcpReply(2, xid, our_ip, gw_ip, &standard_opts, our_ip, our_mac);
+ offer.buf[34 + 8 + d.chaddr + 5] ^= 0xff; // a different chaddr
+ offer.sealTransport(6);
+ s.onFrame(offer.bytes());
+ try testing.expectEqual(ip.DhcpState.selecting, s.dhcpState());
+ try testing.expectEqual(@as(usize, 0), cap_n);
+}
+
+test "DHCP: DISCOVER is retransmitted with a growing backoff and the same transaction id" {
+ var s = newStack();
+ s.tick(0);
+ s.dhcpStart();
+ const xid = be32(sent(0)[42..], d.xid);
+ clearCapture();
+
+ // Nothing before the first backoff expires.
+ s.tick(1_999);
+ try testing.expectEqual(@as(usize, 0), cap_n);
+ s.tick(2_000);
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ try testing.expectEqual(xid, be32(sent(0)[42..], d.xid));
+
+ // The next interval is longer: nothing at +2 s, a frame at +4 s.
+ s.tick(5_999);
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ s.tick(6_000);
+ try testing.expectEqual(@as(usize, 2), cap_n);
+
+ // And the `secs` field tracks how long acquisition has been going.
+ try testing.expectEqual(@as(u16, 6), be16(sent(1)[42..], d.secs));
+}
+
+test "DHCP: a NAK surrenders the address and restarts from DISCOVER" {
+ var s = newStack();
+ s.tick(10_000);
+ s.dhcpStart();
+ const xid = be32(sent(0)[42..], d.xid);
+ var offer = dhcpReply(2, xid, our_ip, gw_ip, &standard_opts, our_ip, our_mac);
+ s.onFrame(offer.bytes());
+ clearCapture();
+
+ var nak = dhcpReply(6, xid, .{ 0, 0, 0, 0 }, gw_ip, &.{}, ip.ip_broadcast, bcast_mac);
+ s.onFrame(nak.bytes());
+ try testing.expectEqual(ip.DhcpState.selecting, s.dhcpState());
+ try testing.expect(s.ip() == null);
+ // And a fresh DISCOVER went out immediately.
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ try testing.expectEqualSlices(u8, &.{1}, findOption(try dhcpOut(sent(0)), 53).?);
+}
+
+test "DHCP: at T1 the lease is renewed by unicast REQUEST with ciaddr set" {
+ var s = newStack();
+ s.tick(0);
+ s.dhcpStart();
+ const xid0 = be32(sent(0)[42..], d.xid);
+ var offer = dhcpReply(2, xid0, our_ip, gw_ip, &standard_opts, our_ip, our_mac);
+ s.onFrame(offer.bytes());
+ var ack = dhcpReply(5, xid0, our_ip, gw_ip, &standard_opts, our_ip, our_mac);
+ s.onFrame(ack.bytes());
+ try testing.expectEqual(ip.DhcpState.bound, s.dhcpState());
+
+ // Lease 7200 s, so T1 = 3600 s (lwIP `core/ipv4/dhcp.c:757`: half the lease).
+ clearCapture();
+ s.tick(3_599_000);
+ try testing.expectEqual(@as(usize, 0), cap_n);
+ try testing.expectEqual(ip.DhcpState.bound, s.dhcpState());
+
+ // T1. The REQUEST is unicast to the server, so it needs the server's MAC first: with the cache
+ // empty, the datagram is dropped and an ARP request goes out in its place.
+ s.tick(3_600_000);
+ try testing.expectEqual(ip.DhcpState.renewing, s.dhcpState());
+ try testing.expectEqual(@as(u16, 0x0806), be16(sent(0), 12));
+ try testing.expectEqualSlices(u8, &gw_ip, sent(0)[14 + 24 ..][0..4]); // ARP for the server
+
+ // The server answers by ARPing for us, which is enough to populate the cache.
+ var probe = arpFrame(1, gw_mac, gw_ip, zero_mac, our_ip, bcast_mac);
+ s.onFrame(probe.bytes());
+ clearCapture();
+
+ // The next retransmission now has a route.
+ s.tick(3_602_000);
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ const r = lastSent();
+ try testing.expectEqualSlices(u8, &gw_mac, r[0..6]); // unicast to the server
+ try testing.expectEqualSlices(u8, &gw_ip, r[14 + 16 ..][0..4]);
+ const msg = try dhcpOut(r);
+ try testing.expectEqualSlices(u8, &.{3}, findOption(msg, 53).?); // DHCPREQUEST
+ // RFC 2131 4.3.6, the RENEWING column: ciaddr carries the bound address, and there is no
+ // requested-IP option and no server identifier.
+ try testing.expectEqualSlices(u8, &our_ip, msg[d.ciaddr..][0..4]);
+ try testing.expect(findOption(msg, 50) == null);
+ try testing.expect(findOption(msg, 54) == null);
+ // A fresh transaction id for the new exchange (RFC 2131 4.4.5).
+ try testing.expect(be32(msg, d.xid) != xid0);
+
+ // The server ACKs and the lease is extended from now.
+ const xid1 = be32(msg, d.xid);
+ clearCapture();
+ var ack2 = dhcpReply(5, xid1, our_ip, gw_ip, &standard_opts, our_ip, our_mac);
+ s.onFrame(ack2.bytes());
+ try testing.expectEqual(ip.DhcpState.bound, s.dhcpState());
+ try testing.expectEqual(our_ip, s.ip().?);
+}
+
+test "DHCP: at T2 renewal becomes a broadcast rebind, and an expired lease is surrendered" {
+ var s = newStack();
+ s.tick(0);
+ s.dhcpStart();
+ const xid0 = be32(sent(0)[42..], d.xid);
+ var offer = dhcpReply(2, xid0, our_ip, gw_ip, &standard_opts, our_ip, our_mac);
+ s.onFrame(offer.bytes());
+ var ack = dhcpReply(5, xid0, our_ip, gw_ip, &standard_opts, our_ip, our_mac);
+ s.onFrame(ack.bytes());
+
+ // Give the stack the server's MAC so the renewal is not blocked on ARP.
+ var probe = arpFrame(1, gw_mac, gw_ip, zero_mac, our_ip, bcast_mac);
+ s.onFrame(probe.bytes());
+
+ s.tick(3_600_000); // T1
+ try testing.expectEqual(ip.DhcpState.renewing, s.dhcpState());
+
+ // T2 = 7/8 of 7200 s = 6300 s (lwIP `core/ipv4/dhcp.c:766`).
+ clearCapture();
+ s.tick(6_300_000);
+ try testing.expectEqual(ip.DhcpState.rebinding, s.dhcpState());
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ // Rebinding is broadcast: the granting server is not answering, so ask anybody.
+ try testing.expectEqualSlices(u8, &bcast_mac, lastSent()[0..6]);
+ const msg = try dhcpOut(lastSent());
+ try testing.expectEqualSlices(u8, &our_ip, msg[d.ciaddr..][0..4]);
+ try testing.expect(findOption(msg, 54) == null);
+
+ // Lease expiry: the address must go, because the server may already have handed it out.
+ clearCapture();
+ s.tick(7_200_000);
+ try testing.expect(s.ip() == null);
+ try testing.expectEqual(ip.DhcpState.selecting, s.dhcpState());
+}
+
+test "DHCP: an option whose length runs past the datagram does not read off the end" {
+ var s = newStack();
+ s.tick(10_000);
+ s.dhcpStart();
+ const xid = be32(sent(0)[42..], d.xid);
+ clearCapture();
+ // Option 54 - the server identifier, which the OFFER handler actually looks for - claiming 200
+ // bytes of a message with three left. Unchecked, that is a 200-byte read past the end of the
+ // frame, which is the classic DHCP parser bug and is reachable by any host on the segment.
+ var offer = dhcpReply(2, xid, our_ip, gw_ip, &[_]u8{ 54, 200, 192, 168 }, our_ip, our_mac);
+ offer.sealTransport(6);
+ s.onFrame(offer.bytes());
+ // The option did not resolve, so the handler fell back to `siaddr` - and the exchange carried
+ // on rather than crashing.
+ try testing.expectEqual(ip.DhcpState.requesting, s.dhcpState());
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ const req = try dhcpOut(sent(0));
+ try testing.expectEqualSlices(u8, &gw_ip, findOption(req, 54).?); // from siaddr
+}
+
+test "DHCP: an option truncated by one byte does not read off the end" {
+ var s = newStack();
+ s.tick(10_000);
+ s.dhcpStart();
+ const xid = be32(sent(0)[42..], d.xid);
+ clearCapture();
+ // Length 4 with only three bytes of message left after it, counting the END marker.
+ var offer = dhcpReply(2, xid, our_ip, gw_ip, &[_]u8{ 54, 4, 192, 168 }, our_ip, our_mac);
+ offer.sealTransport(6);
+ s.onFrame(offer.bytes());
+ try testing.expectEqual(ip.DhcpState.requesting, s.dhcpState());
+}
+
+test "DHCP: a bogus option before a good one does not hide it" {
+ var s = newStack();
+ s.tick(10_000);
+ s.dhcpStart();
+ const xid = be32(sent(0)[42..], d.xid);
+ clearCapture();
+ // A zero-length option, then a pad, then the real server identifier.
+ var offer = dhcpReply(2, xid, our_ip, gw_ip, &[_]u8{ 12, 0, 0, 54, 4, 192, 168, 1, 1 }, our_ip, our_mac);
+ offer.sealTransport(6);
+ s.onFrame(offer.bytes());
+ const req = try dhcpOut(sent(0));
+ try testing.expectEqualSlices(u8, &gw_ip, findOption(req, 54).?);
+}
+
+/// Cut `drop` bytes off the end of a UDP datagram and re-seal, so the last byte of the options is
+/// wherever the caller wants it. `dhcpReply` always writes an END marker, and END is what stops a
+/// well-behaved option walk - so the only way to test what happens when the walk reaches the end of
+/// the buffer instead is to take the marker away.
+fn truncateUdp(f: *Frame, drop: usize) void {
+ f.len -= drop;
+ const h = f.buf[14..][0..20];
+ put16(h, 2, @intCast(f.len - 14));
+ const seg = f.buf[34..f.len];
+ put16(seg, 4, @intCast(seg.len));
+ f.sealTransport(6);
+}
+
+test "DHCP: an option code in the last byte, with no length byte after it, is not read past" {
+ var s = newStack();
+ s.tick(10_000);
+ s.dhcpStart();
+ const xid = be32(sent(0)[42..], d.xid);
+ clearCapture();
+ // A hostname option, then a bare code 3 where a length byte should be. The END marker that
+ // `dhcpReply` appends is cut off, so the walk runs into the end of the datagram - and no
+ // option 54 is present, so the handler's search for the server identifier walks the whole
+ // list and reaches that last byte. Unchecked, reading its length byte is one past the frame.
+ var offer = dhcpReply(2, xid, our_ip, gw_ip, &[_]u8{ 12, 1, 'x', 3 }, our_ip, our_mac);
+ truncateUdp(&offer, 1);
+ s.onFrame(offer.bytes());
+ // It read what it could and stopped, and fell back to `siaddr` for the server identifier.
+ try testing.expectEqual(ip.DhcpState.requesting, s.dhcpState());
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ try testing.expectEqualSlices(u8, &gw_ip, findOption(try dhcpOut(sent(0)), 54).?);
+}
+
+test "DHCP: a reply without the magic cookie is not a DHCP message" {
+ // RFC 2131 3: the four-byte cookie is what distinguishes a DHCP message from plain BOOTP.
+ // Without the check, any BOOTP reply - or any UDP datagram to port 68 that happens to have the
+ // right xid in the right place - is parsed as options.
+ var s = newStack();
+ s.tick(10_000);
+ s.dhcpStart();
+ const xid = be32(sent(0)[42..], d.xid);
+ clearCapture();
+ var offer = dhcpReply(2, xid, our_ip, gw_ip, &standard_opts, our_ip, our_mac);
+ put32(&offer.buf, 34 + 8 + d.cookie, 0x63825364); // one off
+ offer.sealTransport(6);
+ s.onFrame(offer.bytes());
+ try testing.expectEqual(ip.DhcpState.selecting, s.dhcpState());
+ try testing.expectEqual(@as(usize, 0), cap_n);
+}
+
+test "DHCP: a BOOTREQUEST is not mistaken for a reply" {
+ // Every DISCOVER on the segment is a broadcast, including our own. A client that does not check
+ // the `op` field parses its own request - or another client's - as an offer, and RFC 2131 gives
+ // it a `yiaddr` of zero to work with.
+ var s = newStack();
+ s.tick(10_000);
+ s.dhcpStart();
+ const xid = be32(sent(0)[42..], d.xid);
+ clearCapture();
+ var offer = dhcpReply(2, xid, our_ip, gw_ip, &standard_opts, our_ip, our_mac);
+ offer.buf[34 + 8 + d.op] = 1; // BOOTREQUEST
+ offer.sealTransport(6);
+ s.onFrame(offer.bytes());
+ try testing.expectEqual(ip.DhcpState.selecting, s.dhcpState());
+ try testing.expectEqual(@as(usize, 0), cap_n);
+}
+
+// ====================================================================================== TCP
+//
+// The synthetic peer. Sequence numbers here are the *peer's*; the stack's are read out of what it
+// sends, because its ISN is not something a test may assume.
+
+/// Offsets into the TCP header, RFC 793 3.1 / lwIP `prot/tcp.h:56-65`.
+const t = struct {
+ const src = 0;
+ const dst = 2;
+ const seq = 4;
+ const ack = 8;
+ const hdrlen_flags = 12;
+ const window = 14;
+ const chksum = 16;
+
+ const fin: u8 = 0x01;
+ const syn: u8 = 0x02;
+ const rst: u8 = 0x04;
+ const psh: u8 = 0x08;
+ const ack_f: u8 = 0x10;
+};
+
+const Peer = struct {
+ ip: ip.Ip4,
+ port: u16,
+ mac: ip.Mac,
+ /// Our own sequence space, as the peer.
+ seq: u32 = 0x1000_0000,
+ /// The stack's ports and sequence numbers, learnt from its SYN.
+ stack_port: u16 = 0,
+ window: u16 = 8192,
+ /// With an MSS option in our SYN-ACK, or without.
+ mss: ?u16 = 1460,
+
+ fn segment(self: *Peer, flags: u8, ackno: u32, data: []const u8, with_mss: bool) Frame {
+ var f: Frame = .{};
+ f.eth(our_mac, self.mac, 0x0800);
+ const opt_len: usize = if (with_mss) 4 else 0;
+ const p = f.ip4(self.ip, our_ip, 6, 20 + opt_len + data.len);
+ put16(p, t.src, self.port);
+ put16(p, t.dst, self.stack_port);
+ put32(p, t.seq, self.seq);
+ put32(p, t.ack, ackno);
+ put16(p, t.hdrlen_flags, (@as(u16, @intCast((20 + opt_len) / 4)) << 12) | flags);
+ put16(p, t.window, self.window);
+ put16(p, t.chksum, 0);
+ put16(p, 18, 0);
+ if (with_mss) {
+ p[20] = 2;
+ p[21] = 4;
+ put16(p, 22, self.mss.?);
+ }
+ if (data.len != 0) @memcpy(p[20 + opt_len ..], data);
+ f.sealTransport(t.chksum);
+ return f;
+ }
+};
+
+/// A captured TCP segment, decoded, with its checksums verified independently.
+const Seg = struct {
+ src_port: u16,
+ dst_port: u16,
+ seq: u32,
+ ack: u32,
+ flags: u8,
+ window: u16,
+ data: []const u8,
+ mss: ?u16,
+};
+
+fn decode(frame: []const u8) !Seg {
+ try testing.expectEqual(@as(u16, 0x0800), be16(frame, 12));
+ const h = frame[14..34];
+ try testing.expectEqual(@as(u8, 6), h[9]);
+ try verify(h);
+ const total = be16(h, 2);
+ const seg = frame[34 .. 14 + total];
+ try verifyTransport(h[12..16].*, h[16..20].*, 6, seg);
+ const hf = be16(seg, t.hdrlen_flags);
+ const hlen = @as(usize, hf >> 12) * 4;
+ var mss: ?u16 = null;
+ var i: usize = 20;
+ while (i + 1 < hlen) {
+ if (seg[i] == 0) break;
+ if (seg[i] == 1) {
+ i += 1;
+ continue;
+ }
+ const olen = seg[i + 1];
+ if (olen < 2 or i + olen > hlen) break;
+ if (seg[i] == 2 and olen == 4) mss = be16(seg, i + 2);
+ i += olen;
+ }
+ return .{
+ .src_port = be16(seg, t.src),
+ .dst_port = be16(seg, t.dst),
+ .seq = be32(seg, t.seq),
+ .ack = be32(seg, t.ack),
+ .flags = @truncate(hf & 0x3f),
+ .window = be16(seg, t.window),
+ .data = seg[hlen..],
+ .mss = mss,
+ };
+}
+
+/// Bring a stack up statically with the peer's MAC already in the ARP cache, then start a GET.
+/// Returns the peer and the SYN the stack sent.
+fn startGet(s: *ip.Stack, peer: *Peer, path: []const u8, out: []u8) !Seg {
+ s.tick(1000);
+ s.setStatic(our_ip, mask24, gw_ip);
+ var probe = arpFrame(1, peer.mac, peer.ip, zero_mac, our_ip, bcast_mac);
+ s.onFrame(probe.bytes());
+ clearCapture();
+
+ try testing.expectError(error.WouldBlock, s.httpGet(peer.ip, peer.port, path, out));
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ const syn = try decode(sent(0));
+ peer.stack_port = syn.src_port;
+ return syn;
+}
+
+/// Complete the handshake: deliver the SYN-ACK and return the sequence number that acknowledges the
+/// whole request. Afterwards `sent(0)` is the request segment - the capture log is cleared first, so
+/// tests never have to remember whether the SYN is still in it. That off-by-one is exactly the kind
+/// of thing a test helper exists to remove.
+fn handshake(s: *ip.Stack, peer: *Peer, iss: u32) !u32 {
+ clearCapture();
+ var synack = peer.segment(t.syn | t.ack_f, iss +% 1, &.{}, true);
+ s.onFrame(synack.bytes());
+ peer.seq +%= 1;
+ const req = try decode(sent(0));
+ try testing.expect(req.data.len > 0);
+ return iss +% 1 +% @as(u32, @intCast(req.data.len));
+}
+
+test "TCP: the SYN offers an MSS, uses an ephemeral port and advertises a window" {
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [4096]u8 = undefined;
+ const syn = try startGet(&s, &peer, "/", &out);
+
+ try testing.expectEqual(t.syn, syn.flags);
+ try testing.expectEqual(@as(u16, 80), syn.dst_port);
+ try testing.expect(syn.src_port >= 49152); // RFC 6335 dynamic range
+ try testing.expectEqual(@as(?u16, 1460), syn.mss);
+ try testing.expect(syn.window > 0);
+ try testing.expectEqual(@as(usize, 0), syn.data.len);
+ try testing.expectEqual(ip.TcpState.syn_sent, s.tcpState());
+}
+
+test "TCP: a handshake, the request, a response and a clean teardown" {
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [4096]u8 = undefined;
+ const syn = try startGet(&s, &peer, "/index.html", &out);
+ const iss = syn.seq;
+
+ // ---- SYN-ACK
+ _ = try handshake(&s, &peer, iss);
+ try testing.expectEqual(ip.TcpState.established, s.tcpState());
+
+ // The handshake's ACK carries the request: one frame, not two.
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ const req = try decode(sent(0));
+ try testing.expectEqual(t.ack_f | t.psh, req.flags);
+ try testing.expectEqual(iss +% 1, req.seq);
+ try testing.expectEqual(peer.seq, req.ack);
+ try testing.expect(std.mem.startsWith(u8, req.data, "GET /index.html HTTP/1.1\r\n"));
+ // The Host header is the address literal - there is no DNS here - and port 80 is elided.
+ try testing.expect(std.mem.indexOf(u8, req.data, "\r\nHost: 192.168.1.90\r\n") != null);
+ // Connection: close is the framing for a body with no Content-Length.
+ try testing.expect(std.mem.indexOf(u8, req.data, "\r\nConnection: close\r\n") != null);
+ try testing.expect(std.mem.endsWith(u8, req.data, "\r\n\r\n"));
+ const req_len = req.data.len;
+
+ // ---- the peer acknowledges the request and sends the whole response in one segment
+ clearCapture();
+ const body = "hello, world";
+ const response = "HTTP/1.1 200 OK\r\nServer: test\r\nContent-Length: 12\r\n\r\n" ++ body;
+ var resp = peer.segment(t.ack_f | t.psh, iss +% 1 +% @as(u32, @intCast(req_len)), response, false);
+ s.onFrame(resp.bytes());
+ peer.seq +%= @intCast(response.len);
+
+ // The body is complete, so the stack half-closes: the FIN is the acknowledgement too.
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ const fin = try decode(sent(0));
+ try testing.expectEqual(t.fin | t.ack_f, fin.flags);
+ try testing.expectEqual(peer.seq, fin.ack);
+ try testing.expectEqual(ip.TcpState.fin_wait_1, s.tcpState());
+
+ // ---- the peer acknowledges our FIN and sends its own
+ clearCapture();
+ var peer_fin = peer.segment(t.fin | t.ack_f, fin.seq +% 1, &.{}, false);
+ s.onFrame(peer_fin.bytes());
+ peer.seq +%= 1;
+ const last = try decode(lastSent());
+ try testing.expectEqual(t.ack_f, last.flags);
+ try testing.expectEqual(peer.seq, last.ack);
+ try testing.expectEqual(ip.TcpState.time_wait, s.tcpState());
+
+ // ---- and the body comes out
+ const n = try s.httpGet(peer.ip, peer.port, "/index.html", &out);
+ try testing.expectEqual(@as(usize, 12), n);
+ try testing.expectEqualStrings(body, out[0..n]);
+ try testing.expectEqual(@as(u16, 200), s.httpStatus());
+
+ // TIME_WAIT is short by design; it ends on the clock, not on a frame.
+ s.tick(1_000_000);
+ try testing.expectEqual(ip.TcpState.closed, s.tcpState());
+}
+
+test "TCP: the SYN is retransmitted with its MSS option, on a doubling timer" {
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [4096]u8 = undefined;
+ const syn = try startGet(&s, &peer, "/", &out);
+ clearCapture();
+
+ // Nothing before the RTO.
+ s.tick(1_999);
+ try testing.expectEqual(@as(usize, 0), cap_n);
+ s.tick(2_000);
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ const again = try decode(sent(0));
+ try testing.expectEqual(t.syn, again.flags);
+ try testing.expectEqual(syn.seq, again.seq);
+ // The MSS option must be repeated: a peer that only ever sees the retransmission would
+ // otherwise fall back to 536.
+ try testing.expectEqual(@as(?u16, 1460), again.mss);
+
+ // The next timeout is twice as long: 2 s, not 1 s.
+ s.tick(3_999);
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ s.tick(4_000);
+ try testing.expectEqual(@as(usize, 2), cap_n);
+ try testing.expectEqual(@as(u32, 2), s.counters.tcp_retx);
+}
+
+test "TCP: retransmission after a dropped data segment resends the identical bytes" {
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [4096]u8 = undefined;
+ const syn = try startGet(&s, &peer, "/drop", &out);
+ const iss = syn.seq;
+
+ _ = try handshake(&s, &peer, iss);
+ const first = try decode(sent(0));
+ try testing.expect(first.data.len > 0);
+
+ // Pretend the segment was lost: never acknowledge it, just let time pass.
+ clearCapture();
+ s.tick(1_500);
+ try testing.expectEqual(@as(usize, 0), cap_n); // handshake completed at t=1000, RTO at t=2000
+ s.tick(2_000);
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ try testing.expectEqual(@as(u32, 1), s.counters.tcp_retx);
+
+ const again = try decode(sent(0));
+ try testing.expectEqual(first.seq, again.seq);
+ try testing.expectEqualSlices(u8, first.data, again.data);
+ try testing.expectEqual(first.flags, again.flags);
+
+ // Now it gets through, and the connection carries on from the same place.
+ clearCapture();
+ const response = "HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n";
+ var resp = peer.segment(t.ack_f, iss +% 1 +% @as(u32, @intCast(first.data.len)), response, false);
+ s.onFrame(resp.bytes());
+ try testing.expectEqual(@as(usize, 0), try s.httpGet(peer.ip, peer.port, "/drop", &out));
+ try testing.expectEqual(@as(u16, 204), s.httpStatus());
+}
+
+test "TCP: retransmission eventually gives up with TimedOut" {
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [4096]u8 = undefined;
+ _ = try startGet(&s, &peer, "/", &out);
+
+ // Six retransmissions with a doubling, capped backoff, then failure. Ticking well past every
+ // deadline in one step is enough: the deadline is absolute.
+ var now: u64 = 1000;
+ var k: usize = 0;
+ while (k < 8) : (k += 1) {
+ now += 60_000;
+ s.tick(now);
+ }
+ try testing.expectEqual(ip.TcpState.closed, s.tcpState());
+ try testing.expectError(error.TimedOut, s.httpGet(peer.ip, peer.port, "/", &out));
+ try testing.expectEqual(@as(u32, 6), s.counters.tcp_retx);
+}
+
+test "TCP: an out-of-order segment is not accepted, and provokes a duplicate ACK" {
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [4096]u8 = undefined;
+ const syn = try startGet(&s, &peer, "/", &out);
+ const iss = syn.seq;
+ const our_next = try handshake(&s, &peer, iss);
+ const in_order_seq = peer.seq;
+
+ // The second half of the response arrives first.
+ const head = "HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\n";
+ clearCapture();
+ peer.seq = in_order_seq +% @as(u32, @intCast(head.len));
+ var late = peer.segment(t.ack_f, our_next, "abcd", false);
+ s.onFrame(late.bytes());
+
+ // A duplicate ACK for what we are still waiting for, and nothing consumed.
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ const dup = try decode(sent(0));
+ try testing.expectEqual(t.ack_f, dup.flags);
+ try testing.expectEqual(in_order_seq, dup.ack);
+ try testing.expectError(error.WouldBlock, s.httpGet(peer.ip, peer.port, "/", &out));
+
+ // The missing piece arrives.
+ clearCapture();
+ peer.seq = in_order_seq;
+ var missing = peer.segment(t.ack_f, our_next, head, false);
+ s.onFrame(missing.bytes());
+ try testing.expectError(error.WouldBlock, s.httpGet(peer.ip, peer.port, "/", &out));
+ try testing.expectEqual(@as(u16, 200), s.httpStatus());
+
+ // And the retransmission of the tail completes it.
+ peer.seq = in_order_seq +% @as(u32, @intCast(head.len));
+ var tail = peer.segment(t.ack_f, our_next, "abcd", false);
+ s.onFrame(tail.bytes());
+ try testing.expectEqual(@as(usize, 4), try s.httpGet(peer.ip, peer.port, "/", &out));
+ try testing.expectEqualStrings("abcd", out[0..4]);
+}
+
+test "TCP: a retransmission overlapping data already received is trimmed, not rejected" {
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [4096]u8 = undefined;
+ const syn = try startGet(&s, &peer, "/", &out);
+ const iss = syn.seq;
+ const our_next = try handshake(&s, &peer, iss);
+
+ // Headers first, so the overlap lands squarely in the body where duplicated bytes cannot hide
+ // in a header line the parser would have skipped anyway.
+ const head = "HTTP/1.1 200 OK\r\nContent-Length: 16\r\n\r\n";
+ var h = peer.segment(t.ack_f, our_next, head, false);
+ s.onFrame(h.bytes());
+ peer.seq +%= @intCast(head.len);
+ const base = peer.seq;
+
+ // Ten body bytes.
+ var a = peer.segment(t.ack_f, our_next, "0123456789", false);
+ s.onFrame(a.bytes());
+
+ // Then a retransmission that starts four bytes before what we now expect and carries six new
+ // bytes past it. Without trimming, `6789` is written twice, `rcv_nxt` runs four ahead of the
+ // truth, and the final six bytes are then rejected as old - so the request never completes.
+ peer.seq = base +% 6;
+ var b = peer.segment(t.ack_f, our_next, "6789abcdef", false);
+ s.onFrame(b.bytes());
+
+ try testing.expectEqual(@as(usize, 16), try s.httpGet(peer.ip, peer.port, "/", &out));
+ try testing.expectEqualStrings("0123456789abcdef", out[0..16]);
+}
+
+test "TCP: a SYN-ACK that does not acknowledge our SYN is reset, not accepted" {
+ // RFC 793 3.4: an old duplicate SYN-ACK, or one aimed at a previous incarnation of this
+ // 4-tuple, is answered with a reset. Accepting it would establish a connection whose sequence
+ // space the peer does not agree with, and every subsequent segment would be discarded.
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [4096]u8 = undefined;
+ const syn = try startGet(&s, &peer, "/", &out);
+ clearCapture();
+
+ var wrong = peer.segment(t.syn | t.ack_f, syn.seq +% 999, &.{}, true);
+ s.onFrame(wrong.bytes());
+ try testing.expectEqual(ip.TcpState.syn_sent, s.tcpState());
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ const rst = try decode(sent(0));
+ try testing.expectEqual(t.rst, rst.flags);
+ try testing.expectEqual(syn.seq +% 999, rst.seq); // RST carries the offending ACK number
+
+ // The right one still works.
+ clearCapture();
+ var right = peer.segment(t.syn | t.ack_f, syn.seq +% 1, &.{}, true);
+ s.onFrame(right.bytes());
+ try testing.expectEqual(ip.TcpState.established, s.tcpState());
+}
+
+test "TCP: a FIN ahead of the data we have is not honoured" {
+ // A FIN whose sequence number is past `rcv_nxt` closes the connection over a hole. Honouring it
+ // would report a complete body that is missing its middle.
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [4096]u8 = undefined;
+ const syn = try startGet(&s, &peer, "/", &out);
+ const iss = syn.seq;
+ const our_next = try handshake(&s, &peer, iss);
+ const base = peer.seq;
+
+ const head = "HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\n";
+ var h = peer.segment(t.ack_f, our_next, head, false);
+ s.onFrame(h.bytes());
+ peer.seq +%= @intCast(head.len);
+
+ // A FIN 100 bytes into the future, as though a segment we never saw preceded it.
+ clearCapture();
+ peer.seq = base +% @as(u32, @intCast(head.len)) +% 100;
+ var early = peer.segment(t.fin | t.ack_f, our_next, &.{}, false);
+ s.onFrame(early.bytes());
+ // Not closed, not completed: the body is still outstanding.
+ try testing.expectEqual(ip.TcpState.established, s.tcpState());
+ try testing.expectError(error.WouldBlock, s.httpGet(peer.ip, peer.port, "/", &out));
+
+ // The real body arrives and completes it.
+ peer.seq = base +% @as(u32, @intCast(head.len));
+ var body = peer.segment(t.ack_f, our_next, "wxyz", false);
+ s.onFrame(body.bytes());
+ try testing.expectEqual(@as(usize, 4), try s.httpGet(peer.ip, peer.port, "/", &out));
+ try testing.expectEqualStrings("wxyz", out[0..4]);
+}
+
+test "TCP: an in-window RST tears the connection down; an out-of-window one does not" {
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [4096]u8 = undefined;
+ const syn = try startGet(&s, &peer, "/", &out);
+ const iss = syn.seq;
+ _ = try handshake(&s, &peer, iss);
+
+ // RFC 5961 3: a RST whose sequence number is not the next one expected gets a challenge ACK
+ // and is otherwise ignored. This is what stops a blind off-path reset.
+ clearCapture();
+ const good_seq = peer.seq;
+ peer.seq = good_seq +% 5000;
+ var bogus = peer.segment(t.rst, 0, &.{}, false);
+ s.onFrame(bogus.bytes());
+ try testing.expectEqual(ip.TcpState.established, s.tcpState());
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ try testing.expectEqual(t.ack_f, (try decode(sent(0))).flags);
+
+ // The real thing.
+ peer.seq = good_seq;
+ var reset = peer.segment(t.rst, 0, &.{}, false);
+ s.onFrame(reset.bytes());
+ try testing.expectEqual(ip.TcpState.closed, s.tcpState());
+ try testing.expectError(error.ConnectionReset, s.httpGet(peer.ip, peer.port, "/", &out));
+ try testing.expectEqual(@as(u32, 2), s.counters.tcp_rst_rx);
+}
+
+test "TCP: a segment for a different port is not mistaken for this connection" {
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [4096]u8 = undefined;
+ const syn = try startGet(&s, &peer, "/", &out);
+ clearCapture();
+ const real_port = peer.stack_port;
+ peer.stack_port = real_port ^ 1;
+ var stray = peer.segment(t.syn | t.ack_f, syn.seq +% 1, &.{}, true);
+ s.onFrame(stray.bytes());
+ try testing.expectEqual(ip.TcpState.syn_sent, s.tcpState());
+ try testing.expectEqual(@as(usize, 0), cap_n);
+}
+
+test "TCP: a segment from a different host is not mistaken for this connection" {
+ // The whole 4-tuple has to match, not just the ports. A stack that checks only the ports can
+ // have its connection completed - or reset - by any host on the segment that guesses a
+ // 16-bit number.
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [4096]u8 = undefined;
+ const syn = try startGet(&s, &peer, "/", &out);
+ clearCapture();
+
+ // Same ports, different source address.
+ var impostor: Peer = .{ .ip = gw_ip, .port = 80, .mac = gw_mac, .seq = 0x7000_0000 };
+ impostor.stack_port = peer.stack_port;
+ var stray = impostor.segment(t.syn | t.ack_f, syn.seq +% 1, &.{}, true);
+ s.onFrame(stray.bytes());
+ try testing.expectEqual(ip.TcpState.syn_sent, s.tcpState());
+ try testing.expectEqual(@as(usize, 0), cap_n);
+
+ // And a reset from the same impostor is ignored too.
+ var reset = impostor.segment(t.rst, 0, &.{}, false);
+ s.onFrame(reset.bytes());
+ try testing.expectEqual(ip.TcpState.syn_sent, s.tcpState());
+ try testing.expectEqual(@as(u32, 0), s.counters.tcp_rst_rx);
+}
+
+test "TCP: the peer's MSS is honoured, and the request is split across segments" {
+ // The MSS option only matters when the request is bigger than it, which for a GET means a long
+ // path. A stack that ignores the option sends one oversized segment that a peer with a small
+ // MSS - a tunnel, a PPPoE link, anything with encapsulation overhead - drops silently.
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac, .mss = 100 };
+ var out: [64]u8 = undefined;
+ const path: [300]u8 = @splat('q');
+ var full_path: [301]u8 = undefined;
+ full_path[0] = '/';
+ @memcpy(full_path[1..], &path);
+
+ const syn = try startGet(&s, &peer, &full_path, &out);
+ const iss = syn.seq;
+ clearCapture();
+ var synack = peer.segment(t.syn | t.ack_f, iss +% 1, &.{}, true);
+ s.onFrame(synack.bytes());
+ peer.seq +%= 1;
+
+ // Reassemble the request from however many segments it takes, acknowledging each one: with a
+ // window of one segment, nothing more is sent until the previous is acknowledged.
+ var assembled: [512]u8 = undefined;
+ var got: usize = 0;
+ var rounds: usize = 0;
+ while (true) : (rounds += 1) {
+ try testing.expect(rounds < 16); // termination, so a stall fails rather than hangs
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ const seg = try decode(sent(0));
+ try testing.expect(seg.data.len <= 100); // the peer's MSS, honoured
+ try testing.expectEqual(iss +% 1 +% @as(u32, @intCast(got)), seg.seq);
+ @memcpy(assembled[got..][0..seg.data.len], seg.data);
+ got += seg.data.len;
+ if (seg.flags & t.fin != 0) break;
+ clearCapture();
+ var ack = peer.segment(t.ack_f, seg.seq +% @as(u32, @intCast(seg.data.len)), &.{}, false);
+ s.onFrame(ack.bytes());
+ if (cap_n == 0) break; // request fully sent and acknowledged
+ }
+ try testing.expect(rounds >= 3); // 400-odd bytes at 100 per segment
+ try testing.expect(std.mem.startsWith(u8, assembled[0..got], "GET /qqq"));
+ try testing.expect(std.mem.endsWith(u8, assembled[0..got], "\r\n\r\n"));
+ try testing.expect(std.mem.indexOf(u8, assembled[0..got], &path) != null);
+}
+
+test "TCP: sequence numbers wrap across 2^32 without stalling" {
+ var s = newStack();
+ // A peer whose ISN is chosen so its data crosses the wrap. This is the case a `<` comparison
+ // instead of RFC 1982 serial arithmetic breaks, and it breaks by hanging forever.
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac, .seq = 0xffff_ffe0 };
+ var out: [4096]u8 = undefined;
+ const syn = try startGet(&s, &peer, "/", &out);
+ const iss = syn.seq;
+ const our_next = try handshake(&s, &peer, iss);
+
+ const head = "HTTP/1.1 200 OK\r\nContent-Length: 8\r\n\r\n";
+ clearCapture();
+ var a = peer.segment(t.ack_f, our_next, head, false); // 38 bytes: crosses the wrap
+ s.onFrame(a.bytes());
+ peer.seq +%= @intCast(head.len);
+ try testing.expect(peer.seq < 0x1000); // we really did wrap
+
+ var b = peer.segment(t.ack_f, our_next, "12345678", false);
+ s.onFrame(b.bytes());
+ try testing.expectEqual(@as(usize, 8), try s.httpGet(peer.ip, peer.port, "/", &out));
+ try testing.expectEqualStrings("12345678", out[0..8]);
+}
+
+test "TCP: an unresolvable peer fails with HostUnreachable after ARP gives up" {
+ var s = newStack();
+ s.tick(0);
+ s.setStatic(our_ip, mask24, gw_ip);
+ var out: [64]u8 = undefined;
+ // Nothing in the cache, and nothing ever answers.
+ try testing.expectError(error.WouldBlock, s.httpGet(peer_ip, 80, "/", &out));
+ try testing.expectEqual(ip.TcpState.arp_wait, s.tcpState());
+ var now: u64 = 0;
+ var k: usize = 0;
+ while (k < 8) : (k += 1) {
+ now += 1000;
+ s.tick(now);
+ }
+ try testing.expectError(error.HostUnreachable, s.httpGet(peer_ip, 80, "/", &out));
+ // Every attempt was a broadcast ARP request for the peer.
+ try testing.expect(s.counters.arp_tx >= 5);
+}
+
+test "TCP: an off-net destination is sent to the gateway's MAC" {
+ var s = newStack();
+ s.tick(1000);
+ s.setStatic(our_ip, mask24, gw_ip);
+ var probe = arpFrame(1, gw_mac, gw_ip, zero_mac, our_ip, bcast_mac);
+ s.onFrame(probe.bytes());
+ clearCapture();
+ var out: [64]u8 = undefined;
+ try testing.expectError(error.WouldBlock, s.httpGet(off_net_ip, 80, "/", &out));
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ const syn = lastSent();
+ try testing.expectEqualSlices(u8, &gw_mac, syn[0..6]); // to the gateway...
+ try testing.expectEqualSlices(u8, &off_net_ip, syn[14 + 16 ..][0..4]); // ...for the peer
+}
+
+// ===================================================================================== HTTP
+
+/// Handshake, then feed the response in the given pieces, one segment each.
+fn runResponse(s: *ip.Stack, peer: *Peer, path: []const u8, out: []u8, pieces: []const []const u8) !void {
+ const syn = try startGet(s, peer, path, out);
+ const iss = syn.seq;
+ const our_next = try handshake(s, peer, iss);
+ for (pieces) |piece| {
+ clearCapture();
+ var seg = peer.segment(t.ack_f, our_next, piece, false);
+ s.onFrame(seg.bytes());
+ peer.seq +%= @intCast(piece.len);
+ }
+}
+
+test "HTTP: headers split across two segments" {
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [4096]u8 = undefined;
+ // The split falls inside the `Content-Length` field name, and the second piece carries the
+ // blank line and the start of the body. This is the ordinary case on a real server, and it is
+ // the one a parser that assumes headers arrive whole gets wrong.
+ try runResponse(&s, &peer, "/split", &out, &.{
+ "HTTP/1.1 200 OK\r\nServer: nginx\r\nContent-Len",
+ "gth: 11\r\nETag: \"x\"\r\n\r\nhello wor",
+ "ld",
+ });
+ const n = try s.httpGet(peer.ip, peer.port, "/split", &out);
+ try testing.expectEqual(@as(usize, 11), n);
+ try testing.expectEqualStrings("hello world", out[0..n]);
+ try testing.expectEqual(@as(u16, 200), s.httpStatus());
+}
+
+test "HTTP: the status line and blank line split one byte at a time" {
+ // The pathological segmentation: every byte its own segment. If any offset in the parser is
+ // off by one, one of these iterations lands on it.
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [64]u8 = undefined;
+ const response = "HTTP/1.1 201 Created\r\nContent-Length: 3\r\nX: y\r\n\r\nabc";
+ var pieces: [response.len][]const u8 = undefined;
+ for (&pieces, 0..) |*p, i| p.* = response[i .. i + 1];
+ try runResponse(&s, &peer, "/bytes", &out, &pieces);
+ try testing.expectEqual(@as(usize, 3), try s.httpGet(peer.ip, peer.port, "/bytes", &out));
+ try testing.expectEqualStrings("abc", out[0..3]);
+ try testing.expectEqual(@as(u16, 201), s.httpStatus());
+}
+
+test "HTTP: a header name's case is not significant" {
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [64]u8 = undefined;
+ try runResponse(&s, &peer, "/case", &out, &.{
+ "HTTP/1.0 200 OK\r\ncOnTeNt-LeNgTh: 7 \r\n\r\n1234567",
+ });
+ try testing.expectEqual(@as(usize, 7), try s.httpGet(peer.ip, peer.port, "/case", &out));
+ try testing.expectEqualStrings("1234567", out[0..7]);
+}
+
+test "HTTP: a body with no Content-Length is terminated by the peer's FIN" {
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [4096]u8 = undefined;
+ const syn = try startGet(&s, &peer, "/stream", &out);
+ const iss = syn.seq;
+ const our_next = try handshake(&s, &peer, iss);
+
+ var a = peer.segment(t.ack_f, our_next, "HTTP/1.1 200 OK\r\nServer: x\r\n\r\npart one ", false);
+ s.onFrame(a.bytes());
+ peer.seq +%= 39;
+ try testing.expectError(error.WouldBlock, s.httpGet(peer.ip, peer.port, "/stream", &out));
+
+ var b = peer.segment(t.ack_f, our_next, "part two", false);
+ s.onFrame(b.bytes());
+ peer.seq +%= 8;
+ try testing.expectError(error.WouldBlock, s.httpGet(peer.ip, peer.port, "/stream", &out));
+
+ // RFC 7230 3.3.3 case 7: with no Content-Length and no chunking, the connection close is the
+ // framing. That is why the request said `Connection: close`.
+ clearCapture();
+ var fin = peer.segment(t.fin | t.ack_f, our_next, &.{}, false);
+ s.onFrame(fin.bytes());
+ const n = try s.httpGet(peer.ip, peer.port, "/stream", &out);
+ try testing.expectEqualStrings("part one part two", out[0..n]);
+
+ // The peer closed first, so this is RFC 793's CLOSE-WAIT -> LAST-ACK: our FIN goes out
+ // acknowledging theirs, and the connection is not finished until that FIN is acknowledged.
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ const ours = try decode(sent(0));
+ try testing.expectEqual(t.fin | t.ack_f, ours.flags);
+ try testing.expectEqual(peer.seq +% 1, ours.ack); // their FIN consumed one sequence number
+ try testing.expectEqual(ip.TcpState.last_ack, s.tcpState());
+
+ // Their ACK of our FIN finishes it.
+ clearCapture();
+ peer.seq +%= 1;
+ var final = peer.segment(t.ack_f, ours.seq +% 1, &.{}, false);
+ s.onFrame(final.bytes());
+ try testing.expectEqual(ip.TcpState.time_wait, s.tcpState());
+ try testing.expectEqual(@as(usize, 0), cap_n); // a bare ACK needs no answer
+
+ // The peer's FIN again, because our ACK of it was lost. It has already been consumed, so it is
+ // "old" by one sequence number - and a stack that only accepts an exactly-in-order FIN answers
+ // nothing, leaving the peer retransmitting until it gives up and resets.
+ clearCapture();
+ var again: Peer = peer;
+ again.seq = peer.seq -% 1; // the sequence number their FIN actually carried
+ var dup = again.segment(t.fin | t.ack_f, ours.seq +% 1, &.{}, false);
+ s.onFrame(dup.bytes());
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ const reack = try decode(sent(0));
+ try testing.expectEqual(t.ack_f, reack.flags);
+ try testing.expectEqual(peer.seq, reack.ack); // still the sequence number past their FIN
+ try testing.expectEqual(ip.TcpState.time_wait, s.tcpState());
+}
+
+// ============================================================================= HTTP chunked
+//
+// RFC 7230 4.1. The framing is a size in hex, CRLF, that many bytes, CRLF, repeated, ended by a
+// zero size, an optional trailer section and one more CRLF. Two things make it worth this many
+// cases: the caller must see the decoded bytes and none of the framing, and a segment boundary
+// may fall anywhere - including inside a size, inside a CRLF, and inside a chunk whose *data*
+// contains CRLFs of its own.
+
+/// The example from RFC 7230's own appendix, by way of the one everybody quotes. Its third chunk
+/// carries `\r\n\r\n` as data, which is the trap: a decoder that scans for a delimiter instead of
+/// counting the size it was given loses the rest of the body here, and reports success.
+const chunked_head = "HTTP/1.1 200 OK\r\nServer: cloudflare\r\nTransfer-Encoding: chunked\r\n\r\n";
+const chunked_wire = "4\r\nWiki\r\n5\r\npedia\r\nE\r\n in\r\n\r\nchunks.\r\n0\r\n\r\n";
+const chunked_want = "Wikipedia in\r\n\r\nchunks.";
+
+/// Drive a response through a fresh connection, cut into `pieces`, and return the decoded body.
+fn decodeChunked(out: []u8, pieces: []const []const u8) ![]const u8 {
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ try runResponse(&s, &peer, "/c", out, pieces);
+ const n = try s.httpGet(peer.ip, peer.port, "/c", out);
+ return out[0..n];
+}
+
+/// The same, expecting a named failure rather than a body.
+fn expectChunkedError(want: anyerror, out: []u8, pieces: []const []const u8) !void {
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ try runResponse(&s, &peer, "/c", out, pieces);
+ try testing.expectError(want, s.httpGet(peer.ip, peer.port, "/c", out));
+}
+
+test "HTTP chunked: a whole response in one segment decodes, framing bytes and all removed" {
+ var out: [256]u8 = undefined;
+ const got = try decodeChunked(&out, &.{chunked_head ++ chunked_wire});
+ try testing.expectEqualStrings(chunked_want, got);
+ // Said the other way round, because it is the property that matters: no size, no CRLF and no
+ // terminator reached the caller.
+ try testing.expect(std.mem.indexOf(u8, got, "\r\nE\r\n") == null);
+ try testing.expect(std.mem.indexOf(u8, got, "0\r\n") == null);
+}
+
+test "HTTP chunked: the response split at every single offset, two segments" {
+ // The decoder has to resume from wherever the cut landed: mid-size, between the CR and the LF
+ // of a chunk header, mid-data, mid-terminator. This walks every one of those positions.
+ const response = chunked_head ++ chunked_wire;
+ var split: usize = 1;
+ while (split < response.len) : (split += 1) {
+ var out: [256]u8 = undefined;
+ const got = try decodeChunked(&out, &.{ response[0..split], response[split..] });
+ try testing.expectEqualStrings(chunked_want, got);
+ }
+}
+
+test "HTTP chunked: the response split one byte at a time" {
+ // The pathological segmentation. Every state in the machine is entered with an empty input
+ // and re-entered with one byte, which is where a decoder that peeks at `b[1]` dies.
+ const response = chunked_head ++ chunked_wire;
+ var pieces: [response.len][]const u8 = undefined;
+ for (&pieces, 0..) |*p, i| p.* = response[i .. i + 1];
+ var out: [256]u8 = undefined;
+ const got = try decodeChunked(&out, &pieces);
+ try testing.expectEqualStrings(chunked_want, got);
+}
+
+test "HTTP chunked: the body arrives across three segments cut inside one chunk's data" {
+ var out: [256]u8 = undefined;
+ const got = try decodeChunked(&out, &.{
+ chunked_head ++ "4\r\nWi",
+ "ki\r\n5\r\npe",
+ "dia\r\nE\r\n in\r\n\r\nchunks.\r\n0\r\n\r\n",
+ });
+ try testing.expectEqualStrings(chunked_want, got);
+}
+
+test "HTTP chunked: chunk extensions are skipped, not delivered" {
+ var out: [64]u8 = undefined;
+ const got = try decodeChunked(&out, &.{
+ "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n" ++
+ "5;name=value;flag\r\nhello\r\n0;last\r\n\r\n",
+ });
+ try testing.expectEqualStrings("hello", got);
+}
+
+test "HTTP chunked: an extension split across segments is still skipped" {
+ var out: [64]u8 = undefined;
+ const got = try decodeChunked(&out, &.{
+ "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5;na",
+ "me=val",
+ "ue\r\nhello\r\n0\r\n\r\n",
+ });
+ try testing.expectEqualStrings("hello", got);
+}
+
+test "HTTP chunked: a trailer section is skipped and only its final CRLF completes the body" {
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [64]u8 = undefined;
+ const syn = try startGet(&s, &peer, "/c", &out);
+ const our_next = try handshake(&s, &peer, syn.seq);
+
+ // Everything up to but not including the CRLF that ends the trailer section.
+ const piece =
+ "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n0\r\nExpires: now\r\n";
+ var a = peer.segment(t.ack_f, our_next, piece, false);
+ s.onFrame(a.bytes());
+ peer.seq +%= @intCast(piece.len);
+
+ // The zero chunk is in and every body byte is here, and it is still not complete: the trailer
+ // section is part of the message, and a decoder that finished at the zero chunk would hand
+ // the caller a body while leaving the connection mid-message.
+ try testing.expectError(error.WouldBlock, s.httpGet(peer.ip, peer.port, "/c", &out));
+
+ var b = peer.segment(t.ack_f, our_next, "\r\n", false);
+ s.onFrame(b.bytes());
+ peer.seq +%= 2;
+ try testing.expectEqual(@as(usize, 5), try s.httpGet(peer.ip, peer.port, "/c", &out));
+ try testing.expectEqualStrings("hello", out[0..5]);
+}
+
+test "HTTP chunked: sizes in upper case hex, and with leading zeros" {
+ var out: [64]u8 = undefined;
+ const got = try decodeChunked(&out, &.{
+ "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n" ++
+ "00000A\r\n0123456789\r\nB\r\nabcdefghijk\r\n000\r\n\r\n",
+ });
+ try testing.expectEqualStrings("0123456789abcdefghijk", got);
+}
+
+test "HTTP chunked: an empty body is the terminator alone" {
+ var out: [64]u8 = undefined;
+ const got = try decodeChunked(&out, &.{
+ "HTTP/1.1 204 No Content\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n",
+ });
+ try testing.expectEqual(@as(usize, 0), got.len);
+}
+
+test "HTTP chunked: Content-Length beside chunked is ignored, not obeyed" {
+ // RFC 7230 3.3.3 case 3. A response carrying both is the request-smuggling disagreement, and
+ // the framing that wins is the chunked one. Obeying the length here would stop after 2 bytes
+ // and report success on a fifth of the body.
+ var out: [64]u8 = undefined;
+ const got = try decodeChunked(&out, &.{
+ "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nTransfer-Encoding: chunked\r\n\r\n" ++
+ "5\r\nhello\r\n0\r\n\r\n",
+ });
+ try testing.expectEqualStrings("hello", got);
+}
+
+test "HTTP chunked: the header order does not decide which framing wins" {
+ var out: [64]u8 = undefined;
+ const got = try decodeChunked(&out, &.{
+ "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nContent-Length: 2\r\n\r\n" ++
+ "5\r\nhello\r\n0\r\n\r\n",
+ });
+ try testing.expectEqualStrings("hello", got);
+}
+
+test "HTTP chunked: a size with no hex digits is refused, never read as the terminator" {
+ // The dangerous misparse: a stray CRLF where a size belongs is a zero-length chunk to a
+ // decoder with no `1*HEXDIG` check, and a zero-length chunk ends the body. That is a
+ // truncated response reported as a complete one.
+ var out: [64]u8 = undefined;
+ try expectChunkedError(error.HttpChunkMalformed, &out, &.{
+ "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n\r\nhello\r\n0\r\n\r\n",
+ });
+ try expectChunkedError(error.HttpChunkMalformed, &out, &.{
+ "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\nxyz\r\nhello\r\n0\r\n\r\n",
+ });
+}
+
+test "HTTP chunked: a chunk not followed by CRLF is refused" {
+ var out: [64]u8 = undefined;
+ // Data, then a bare LF where the CRLF belongs.
+ try expectChunkedError(error.HttpChunkMalformed, &out, &.{
+ "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\n0\r\n\r\n",
+ });
+ // A chunk header whose CR is not followed by LF.
+ try expectChunkedError(error.HttpChunkMalformed, &out, &.{
+ "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\rhello\r\n0\r\n\r\n",
+ });
+ // The final CRLF of the message, mangled.
+ try expectChunkedError(error.HttpChunkMalformed, &out, &.{
+ "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n0\r\n\rx",
+ });
+}
+
+test "HTTP chunked: each half of each CRLF is required in its own position" {
+ // The three cases above are all refused by a decoder that merely skips *two* bytes wherever a
+ // CRLF belongs; these are not. Each one is a well-framed message to such a decoder - it
+ // returns `hello` and reports success - and a malformed one to this stack. That is the
+ // difference between checking the delimiter and counting past it.
+ var out: [64]u8 = undefined;
+ // LF where the chunk's closing CR belongs, and the real LF behind it.
+ try expectChunkedError(error.HttpChunkMalformed, &out, &.{
+ "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\n\n0\r\n\r\n",
+ });
+ // CR in place, then a byte that is not the LF.
+ try expectChunkedError(error.HttpChunkMalformed, &out, &.{
+ "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\rZ0\r\n\r\n",
+ });
+ // And in the chunk header: CR in place, junk where the LF belongs.
+ try expectChunkedError(error.HttpChunkMalformed, &out, &.{
+ "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\rZhello\r\n0\r\n\r\n",
+ });
+}
+
+test "HTTP chunked: a second chunk with an empty size is refused, not read as the terminator" {
+ // The first chunk's size sets the "a digit was seen" flag, and it has to be cleared for the
+ // next one. Left set, the CRLF below reads as a zero-length chunk - the terminator - and the
+ // response ends silently five bytes in.
+ var out: [64]u8 = undefined;
+ try expectChunkedError(error.HttpChunkMalformed, &out, &.{
+ "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n\r\nmore\r\n0\r\n\r\n",
+ });
+}
+
+test "HTTP chunked: an impossible Content-Length beside chunked does not fail the request" {
+ // The other half of "chunked wins": the length is not merely unused for framing, it is not
+ // consulted at all - including by the check that refuses a body too big for `out`. A server
+ // that sends both is already not to be believed about the length.
+ var out: [64]u8 = undefined;
+ const got = try decodeChunked(&out, &.{
+ "HTTP/1.1 200 OK\r\nContent-Length: 100000\r\nTransfer-Encoding: chunked\r\n\r\n" ++
+ "5\r\nhello\r\n0\r\n\r\n",
+ });
+ try testing.expectEqualStrings("hello", got);
+}
+
+test "HTTP chunked: a body that exactly fills out still leaves window for its terminator" {
+ // The deadlock this pins: the advertised window is the room left in `out`, and chunked
+ // framing is consumed without going there. A body that fills `out` to the last byte closes
+ // the window, the terminator can never be accepted, and the request stalls against a peer
+ // that is behaving perfectly - until the RTO calls it a timeout.
+ var out: [5]u8 = undefined;
+ const got = try decodeChunked(&out, &.{
+ "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n",
+ "0\r\n\r\n",
+ });
+ try testing.expectEqualStrings("hello", got);
+}
+
+test "HTTP chunked: a size that overflows usize is refused, not wrapped" {
+ // Seventeen f's. Wrapped, this is a small number and the response looks well framed.
+ var out: [64]u8 = undefined;
+ try expectChunkedError(error.HttpChunkMalformed, &out, &.{
+ "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\nfffffffffffffffff\r\n",
+ });
+}
+
+test "HTTP chunked: a chunk larger than the caller's buffer fails on the header, before any copy" {
+ var out: [8]u8 = undefined;
+ try expectChunkedError(error.StreamTooLong, &out, &.{
+ "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n64\r\n",
+ });
+}
+
+test "HTTP chunked: chunks that together outgrow the buffer fail, and do not truncate" {
+ var out: [8]u8 = undefined;
+ try expectChunkedError(error.StreamTooLong, &out, &.{
+ "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n5\r\nworld\r\n0\r\n\r\n",
+ });
+}
+
+test "HTTP chunked: an endless chunk extension is bounded" {
+ const pad: [http_framing_over]u8 = @splat('x');
+ var out: [4096]u8 = undefined;
+ try expectChunkedError(error.HttpHeadersTooLong, &out, &.{
+ "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5;",
+ &pad,
+ });
+}
+
+test "HTTP chunked: an endless trailer section is bounded" {
+ const pad: [http_framing_over]u8 = @splat('x');
+ var out: [4096]u8 = undefined;
+ try expectChunkedError(error.HttpHeadersTooLong, &out, &.{
+ "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n0\r\nX: ",
+ &pad,
+ });
+}
+
+/// One byte past the framing budget, so the bound is tested at the bound and not far above it.
+const http_framing_over = ip.http_framing_max + 1;
+
+test "HTTP chunked: a close before the terminator is an error, not the body that did arrive" {
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [64]u8 = undefined;
+ const syn = try startGet(&s, &peer, "/c", &out);
+ const our_next = try handshake(&s, &peer, syn.seq);
+
+ const piece = "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n";
+ var a = peer.segment(t.ack_f, our_next, piece, false);
+ s.onFrame(a.bytes());
+ peer.seq +%= @intCast(piece.len);
+
+ var fin = peer.segment(t.fin | t.ack_f, our_next, &.{}, false);
+ s.onFrame(fin.bytes());
+ // Five bytes of body are sitting in `out`, and they are not the answer: chunked framing says
+ // the message ends at the zero chunk, so a close before it truncated the response.
+ try testing.expectError(error.ConnectionClosed, s.httpGet(peer.ip, peer.port, "/c", &out));
+}
+
+test "HTTP: a transfer coding that is neither identity nor chunked is still refused" {
+ for ([_][]const u8{ "gzip", "deflate", "chunked, gzip", "gzip, chunked" }) |coding| {
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [64]u8 = undefined;
+ var head: [128]u8 = undefined;
+ const resp = try std.fmt.bufPrint(
+ &head,
+ "HTTP/1.1 200 OK\r\nTransfer-Encoding: {s}\r\n\r\n5\r\nhello\r\n0\r\n\r\n",
+ .{coding},
+ );
+ try runResponse(&s, &peer, "/tc", &out, &.{resp});
+ try testing.expectError(
+ error.UnsupportedTransferEncoding,
+ s.httpGet(peer.ip, peer.port, "/tc", &out),
+ );
+ try testing.expectEqual(ip.TcpState.closed, s.tcpState());
+ }
+}
+
+test "HTTP: Transfer-Encoding: identity is accepted" {
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [64]u8 = undefined;
+ try runResponse(&s, &peer, "/id", &out, &.{
+ "HTTP/1.1 200 OK\r\nTransfer-Encoding: identity\r\nContent-Length: 2\r\n\r\nok",
+ });
+ try testing.expectEqual(@as(usize, 2), try s.httpGet(peer.ip, peer.port, "/id", &out));
+}
+
+test "HTTP: a malformed status line is refused" {
+ for ([_][]const u8{
+ "ICY 200 OK\r\nContent-Length: 0\r\n\r\n",
+ "HTTP/1.1 200 OK\r\n\r\n",
+ "HTTP/1.1 2xx OK\r\n\r\n",
+ // The right shape, the wrong protocol. HTTP/2 has no textual status line at all, so a
+ // server answering this over a cleartext HTTP/1.1 request is not something to guess at.
+ "HTTP/2.0 200 OK\r\nContent-Length: 0\r\n\r\n",
+ "ICE/1.0 200 OK\r\nContent-Length: 0\r\n\r\n",
+ "HTTP/1.1\r\n\r\n",
+ }) |bad| {
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [64]u8 = undefined;
+ try runResponse(&s, &peer, "/bad", &out, &.{bad});
+ try testing.expectError(error.HttpMalformed, s.httpGet(peer.ip, peer.port, "/bad", &out));
+ }
+}
+
+test "HTTP: a Content-Length larger than the caller's buffer fails before any body is copied" {
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [8]u8 = undefined;
+ try runResponse(&s, &peer, "/big", &out, &.{
+ "HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n0123456789",
+ });
+ try testing.expectError(error.StreamTooLong, s.httpGet(peer.ip, peer.port, "/big", &out));
+}
+
+test "HTTP: an impossible Content-Length fails at once, not after a partial body" {
+ // 100 promised bytes into an 8-byte buffer, and only five of them ever arrive. The request is
+ // already impossible when the headers are parsed, and saying so then is the difference between
+ // an immediate error and a request that hangs until the peer closes.
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [8]u8 = undefined;
+ try runResponse(&s, &peer, "/early", &out, &.{
+ "HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n01234",
+ });
+ try testing.expectError(error.StreamTooLong, s.httpGet(peer.ip, peer.port, "/early", &out));
+ try testing.expectEqual(ip.TcpState.closed, s.tcpState());
+}
+
+test "HTTP: a body longer than the caller's buffer with no Content-Length fails" {
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [4]u8 = undefined;
+ try runResponse(&s, &peer, "/big2", &out, &.{
+ "HTTP/1.1 200 OK\r\n\r\n0123456789",
+ });
+ try testing.expectError(error.StreamTooLong, s.httpGet(peer.ip, peer.port, "/big2", &out));
+}
+
+test "HTTP: an oversized header block fails rather than truncating" {
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [64]u8 = undefined;
+ // One header line per segment until the head buffer is full. No blank line ever arrives.
+ var pieces: [40][]const u8 = undefined;
+ for (&pieces) |*p| p.* = "X-Padding: 0123456789012345678901234567890123456789\r\n";
+ var first: [2][]const u8 = .{ "HTTP/1.1 200 OK\r\n", pieces[0] };
+ _ = &first;
+ try runResponse(&s, &peer, "/hdr", &out, &pieces);
+ try testing.expectError(error.HttpHeadersTooLong, s.httpGet(peer.ip, peer.port, "/hdr", &out));
+}
+
+test "HTTP: a Content-Length: 0 response completes on the headers alone" {
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [64]u8 = undefined;
+ try runResponse(&s, &peer, "/empty", &out, &.{
+ "HTTP/1.1 304 Not Modified\r\nContent-Length: 0\r\n\r\n",
+ });
+ try testing.expectEqual(@as(usize, 0), try s.httpGet(peer.ip, peer.port, "/empty", &out));
+ try testing.expectEqual(@as(u16, 304), s.httpStatus());
+ // Completing the body half-closes, whatever the length was.
+ try testing.expect(s.tcpState() != .established);
+}
+
+test "HTTP: a truncated body - FIN before Content-Length is met - is an error, not a short read" {
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [64]u8 = undefined;
+ const syn = try startGet(&s, &peer, "/trunc", &out);
+ const iss = syn.seq;
+ const our_next = try handshake(&s, &peer, iss);
+
+ const piece = "HTTP/1.1 200 OK\r\nContent-Length: 20\r\n\r\nshort";
+ var a = peer.segment(t.ack_f, our_next, piece, false);
+ s.onFrame(a.bytes());
+ peer.seq +%= @intCast(piece.len);
+ var fin = peer.segment(t.fin | t.ack_f, our_next, &.{}, false);
+ s.onFrame(fin.bytes());
+ try testing.expectError(error.ConnectionClosed, s.httpGet(peer.ip, peer.port, "/trunc", &out));
+}
+
+test "HTTP: a non-default port appears in the Host header" {
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 8080, .mac = peer_mac };
+ var out: [64]u8 = undefined;
+ const syn = try startGet(&s, &peer, "/", &out);
+ var synack = peer.segment(t.syn | t.ack_f, syn.seq +% 1, &.{}, true);
+ clearCapture();
+ s.onFrame(synack.bytes());
+ const req = try decode(sent(0));
+ try testing.expect(std.mem.indexOf(u8, req.data, "\r\nHost: 192.168.1.90:8080\r\n") != null);
+}
+
+// ========================================================================= the Host: header
+//
+// A name-based virtual host - which is what everything behind a CDN is - chooses the site from
+// this header alone. `Host: 104.21.46.8` reaches Cloudflare and gets Cloudflare's error page; the
+// site is only reachable by name. But a bare address in a lab is only reachable by address, so
+// both spellings have to be exactly right.
+
+/// Start a request, complete the handshake, and return the request segment the stack sent.
+fn requestFor(s: *ip.Stack, peer: *Peer, name: ?[]const u8, path: []const u8, out: []u8) !Seg {
+ s.tick(1000);
+ s.setStatic(our_ip, mask24, gw_ip);
+ var probe = arpFrame(1, peer.mac, peer.ip, zero_mac, our_ip, bcast_mac);
+ s.onFrame(probe.bytes());
+ clearCapture();
+
+ try testing.expectError(error.WouldBlock, s.httpGetHost(peer.ip, name, peer.port, path, out));
+ const syn = try decode(sent(0));
+ peer.stack_port = syn.src_port;
+ clearCapture();
+ var synack = peer.segment(t.syn | t.ack_f, syn.seq +% 1, &.{}, true);
+ s.onFrame(synack.bytes());
+ return try decode(sent(0));
+}
+
+test "HTTP Host: a supplied name is sent instead of the address" {
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [64]u8 = undefined;
+ const req = try requestFor(&s, &peer, "0x4200.cafe", "/", &out);
+ try testing.expect(std.mem.indexOf(u8, req.data, "\r\nHost: 0x4200.cafe\r\n") != null);
+ // The address is still where the connection went; the name is only ever a header.
+ try testing.expect(std.mem.indexOf(u8, req.data, "192.168.1.90") == null);
+}
+
+test "HTTP Host: a name keeps the rule that only a non-default port is appended" {
+ var s80 = newStack();
+ var peer80: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out80: [64]u8 = undefined;
+ const req80 = try requestFor(&s80, &peer80, "0x4200.cafe", "/", &out80);
+ try testing.expect(std.mem.indexOf(u8, req80.data, "\r\nHost: 0x4200.cafe\r\n") != null);
+
+ var s8080 = newStack();
+ var peer8080: Peer = .{ .ip = peer_ip, .port = 8080, .mac = peer_mac };
+ var out8080: [64]u8 = undefined;
+ const req8080 = try requestFor(&s8080, &peer8080, "0x4200.cafe", "/", &out8080);
+ try testing.expect(std.mem.indexOf(u8, req8080.data, "\r\nHost: 0x4200.cafe:8080\r\n") != null);
+}
+
+test "HTTP Host: no name is byte for byte what httpGet has always sent" {
+ // The working test against a bare address depends on this, so it is asserted on the bytes and
+ // not on a substring: two stacks with the same MAC and the same tick draw the same ephemeral
+ // port and the same ISN, so the two requests must be identical octet for octet.
+ var a = newStack();
+ var peer_a: Peer = .{ .ip = peer_ip, .port = 8080, .mac = peer_mac };
+ var out_a: [64]u8 = undefined;
+ const req_a = try requestFor(&a, &peer_a, null, "/index.html", &out_a);
+ var kept: [512]u8 = undefined;
+ @memcpy(kept[0..req_a.data.len], req_a.data);
+ const first = kept[0..req_a.data.len];
+
+ var b = newStack();
+ var peer_b: Peer = .{ .ip = peer_ip, .port = 8080, .mac = peer_mac };
+ var out_b: [64]u8 = undefined;
+ b.tick(1000);
+ b.setStatic(our_ip, mask24, gw_ip);
+ var probe = arpFrame(1, peer_b.mac, peer_b.ip, zero_mac, our_ip, bcast_mac);
+ b.onFrame(probe.bytes());
+ clearCapture();
+ try testing.expectError(error.WouldBlock, b.httpGet(peer_b.ip, peer_b.port, "/index.html", &out_b));
+ const syn = try decode(sent(0));
+ peer_b.stack_port = syn.src_port;
+ clearCapture();
+ var synack = peer_b.segment(t.syn | t.ack_f, syn.seq +% 1, &.{}, true);
+ b.onFrame(synack.bytes());
+ const req_b = try decode(sent(0));
+
+ try testing.expectEqualSlices(u8, first, req_b.data);
+ try testing.expect(std.mem.indexOf(u8, req_b.data, "\r\nHost: 192.168.1.90:8080\r\n") != null);
+}
+
+test "HTTP Host: the name is part of the request's identity, so changing it is Busy" {
+ var s = newStack();
+ const peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [64]u8 = undefined;
+ s.tick(1000);
+ s.setStatic(our_ip, mask24, gw_ip);
+ var probe = arpFrame(1, peer.mac, peer.ip, zero_mac, our_ip, bcast_mac);
+ s.onFrame(probe.bytes());
+
+ try testing.expectError(error.WouldBlock, s.httpGetHost(peer.ip, "0x4200.cafe", 80, "/", &out));
+ // The same call again is the protocol.
+ try testing.expectError(error.WouldBlock, s.httpGetHost(peer.ip, "0x4200.cafe", 80, "/", &out));
+ // A different virtual host on the same address for the same path is a different request, and
+ // riding on this connection would fetch the wrong site under the right name.
+ try testing.expectError(error.Busy, s.httpGetHost(peer.ip, "example.com", 80, "/", &out));
+ // And "no name" is not the same request as any name.
+ try testing.expectError(error.Busy, s.httpGetHost(peer.ip, null, 80, "/", &out));
+ try testing.expectError(error.Busy, s.httpGet(peer.ip, 80, "/", &out));
+}
+
+test "HTTP: httpGet before an address exists is refused" {
+ var s = newStack();
+ var out: [64]u8 = undefined;
+ try testing.expectError(error.NoAddress, s.httpGet(peer_ip, 80, "/", &out));
+}
+
+test "HTTP: re-entering with different arguments is refused rather than silently switching" {
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [64]u8 = undefined;
+ var other: [64]u8 = undefined;
+ _ = try startGet(&s, &peer, "/one", &out);
+ try testing.expectError(error.WouldBlock, s.httpGet(peer.ip, 80, "/one", &out));
+ try testing.expectError(error.Busy, s.httpGet(peer.ip, 80, "/two", &out));
+ try testing.expectError(error.Busy, s.httpGet(peer.ip, 81, "/one", &out));
+ try testing.expectError(error.Busy, s.httpGet(gw_ip, 80, "/one", &out));
+ // A different output buffer is the dangerous one: the body is written as it arrives, so the
+ // stack is holding a pointer into the first.
+ try testing.expectError(error.Busy, s.httpGet(peer.ip, 80, "/one", &other));
+ // Same buffer, shorter: `Content-Length` was already checked against the original length, and
+ // the body is written through the original slice, so a shrunk view is just as wrong.
+ try testing.expectError(error.Busy, s.httpGet(peer.ip, 80, "/one", out[0..32]));
+ try testing.expectError(error.Busy, s.httpGet(peer.ip, 80, "/one", out[1..]));
+ // The original arguments still work.
+ try testing.expectError(error.WouldBlock, s.httpGet(peer.ip, 80, "/one", &out));
+}
+
+test "HTTP: a path longer than the request buffer is refused" {
+ var s = newStack();
+ s.tick(1000);
+ s.setStatic(our_ip, mask24, gw_ip);
+ var out: [64]u8 = undefined;
+ const long: [600]u8 = @splat('a');
+ try testing.expectError(error.RequestTooLong, s.httpGet(peer_ip, 80, &long, &out));
+}
+
+test "HTTP: two requests in sequence use different ephemeral ports" {
+ var s = newStack();
+ var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ var out: [64]u8 = undefined;
+ try runResponse(&s, &peer, "/a", &out, &.{"HTTP/1.1 200 OK\r\nContent-Length: 1\r\na\r\n\r\na"});
+ _ = try s.httpGet(peer.ip, peer.port, "/a", &out);
+ const first_port = peer.stack_port;
+
+ const peer2: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac };
+ clearCapture();
+ try testing.expectError(error.WouldBlock, s.httpGet(peer2.ip, peer2.port, "/b", &out));
+ const syn = try decode(sent(0));
+ try testing.expect(syn.src_port != first_port);
+}
+
+// ====================================================================================== DNS
+//
+// RFC 1035. The header offsets and the name encoding below are written out again from the RFC,
+// like every other wire format in this file. The parts that need testing are not the header -
+// six 16-bit fields - but the two that are easy to get wrong and impossible to see when they are:
+// matching the *question* as well as the id, and following compression pointers under a bound.
+
+/// RFC 1035 4.1.1, re-derived.
+const q = struct {
+ const id = 0;
+ const flags = 2;
+ const qdcount = 4;
+ const ancount = 6;
+ const nscount = 8;
+ const arcount = 10;
+ const hlen = 12;
+};
+
+/// The resolver this network's DHCP server hands out: the gateway itself.
+const dns_ip: ip.Ip4 = .{ 192, 168, 1, 1 };
+
+/// RFC 1035 4.1.2 name encoding. No validation, deliberately: a test that shared the encoder's
+/// checks could not write a malformed name to see the stack reject it.
+fn wireName(buf: []u8, name: []const u8) usize {
+ var o: usize = 0;
+ var labels = std.mem.splitScalar(u8, name, '.');
+ while (labels.next()) |label| {
+ buf[o] = @intCast(label.len);
+ @memcpy(buf[o + 1 ..][0..label.len], label);
+ o += 1 + label.len;
+ }
+ buf[o] = 0;
+ return o + 1;
+}
+
+/// A DNS message under construction.
+const Msg = struct {
+ buf: [512]u8 = @splat(0),
+ len: usize = 0,
+
+ fn header(self: *Msg, id: u16, flags: u16, qd: u16, an: u16) void {
+ put16(&self.buf, q.id, id);
+ put16(&self.buf, q.flags, flags);
+ put16(&self.buf, q.qdcount, qd);
+ put16(&self.buf, q.ancount, an);
+ put16(&self.buf, q.nscount, 0);
+ put16(&self.buf, q.arcount, 0);
+ self.len = q.hlen;
+ }
+
+ fn question(self: *Msg, name: []const u8, qtype: u16, qclass: u16) void {
+ self.len += wireName(self.buf[self.len..], name);
+ self.be(qtype);
+ self.be(qclass);
+ }
+
+ /// Append one big-endian 16-bit field.
+ fn be(self: *Msg, v: u16) void {
+ put16(&self.buf, self.len, v);
+ self.len += 2;
+ }
+
+ fn bytes(self: *Msg, b: []const u8) void {
+ @memcpy(self.buf[self.len..][0..b.len], b);
+ self.len += b.len;
+ }
+
+ /// A resource record whose owner name is a compression pointer to `name_off`, which is what a
+ /// real server emits for every record after the first: the question's name is at offset 12,
+ /// and every answer points at it.
+ fn rr(self: *Msg, name_off: u16, rtype: u16, rclass: u16, rdata: []const u8) void {
+ self.be(0xc000 | name_off);
+ self.be(rtype);
+ self.be(rclass);
+ put32(&self.buf, self.len, 300); // TTL
+ self.len += 4;
+ self.be(@intCast(rdata.len));
+ self.bytes(rdata);
+ }
+
+ fn slice(self: *const Msg) []const u8 {
+ return self.buf[0..self.len];
+ }
+};
+
+/// A UDP datagram from `src`:`sport` to our address at `dport`.
+fn udpFrame(src: ip.Ip4, sport: u16, dport: u16, payload: []const u8) Frame {
+ var f: Frame = .{};
+ f.eth(our_mac, gw_mac, 0x0800);
+ const seg_len = 8 + payload.len;
+ const p = f.ip4(src, our_ip, 17, seg_len);
+ put16(p, 0, sport);
+ put16(p, 2, dport);
+ put16(p, 4, @intCast(seg_len));
+ put16(p, 6, 0);
+ @memcpy(p[8..], payload);
+ f.sealTransport(6);
+ return f;
+}
+
+/// A stack with an address, a resolver, and the resolver's MAC already learnt.
+fn newResolverStack() ip.Stack {
+ var s = newStack();
+ s.tick(1000);
+ s.setStatic(our_ip, mask24, gw_ip);
+ s.setDnsServer(dns_ip);
+ var probe = arpFrame(1, gw_mac, dns_ip, zero_mac, our_ip, bcast_mac);
+ s.onFrame(probe.bytes());
+ clearCapture();
+ return s;
+}
+
+/// The DNS payload of a captured query, with both checksums verified independently. Also returns
+/// the source port, which is the other half of what an off-path spoofer has to guess.
+fn queryOut(frame: []const u8) !struct { msg: []const u8, sport: u16 } {
+ try testing.expectEqual(@as(u16, 0x0800), be16(frame, 12));
+ const h = frame[14..34];
+ try testing.expectEqual(@as(u8, 17), h[9]); // UDP
+ try verify(h);
+ try testing.expectEqualSlices(u8, &dns_ip, h[16..20]);
+ const total = be16(h, 2);
+ const seg = frame[34 .. 14 + total];
+ try testing.expectEqual(@as(u16, 53), be16(seg, 2));
+ try testing.expectEqual(@as(u16, @intCast(seg.len)), be16(seg, 4));
+ try verifyTransport(h[12..16].*, h[16..20].*, 17, seg);
+ return .{ .msg = seg[8..], .sport = be16(seg, 0) };
+}
+
+/// Answer the outstanding query with `an` answer records built by `fill`, and return the address
+/// `resolve` then produces - or the error it produces.
+fn answerWith(s: *ip.Stack, name: []const u8, m: *Msg) !ip.Ip4 {
+ var f = udpFrame(dns_ip, 53, dns_query_port, m.slice());
+ s.onFrame(f.bytes());
+ return s.resolve(name);
+}
+
+/// The source port of the query most recently captured, filled in by `startResolve`.
+var dns_query_port: u16 = 0;
+
+/// Start a query and record its id and source port.
+fn startResolve(s: *ip.Stack, name: []const u8) !u16 {
+ try testing.expectError(error.WouldBlock, s.resolve(name));
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ const out = try queryOut(sent(0));
+ dns_query_port = out.sport;
+ clearCapture();
+ return be16(out.msg, q.id);
+}
+
+test "DNS: the query is one A/IN question, recursion desired, from an ephemeral port" {
+ var s = newResolverStack();
+ try testing.expectError(error.WouldBlock, s.resolve("0x4200.cafe"));
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ const out = try queryOut(sent(0));
+ const msg = out.msg;
+
+ try testing.expect(out.sport >= 49152); // RFC 6335 dynamic range
+ // QR=0, OPCODE=0, RD=1, and nothing else. RFC 1035 4.1.1.
+ try testing.expectEqual(@as(u16, 0x0100), be16(msg, q.flags));
+ try testing.expectEqual(@as(u16, 1), be16(msg, q.qdcount));
+ try testing.expectEqual(@as(u16, 0), be16(msg, q.ancount));
+ try testing.expectEqual(@as(u16, 0), be16(msg, q.nscount));
+ try testing.expectEqual(@as(u16, 0), be16(msg, q.arcount));
+
+ // The question: `6 0x4200 4 cafe 0`, then QTYPE=A, QCLASS=IN. Written out literally, because
+ // the length-prefixed encoding is the thing being checked.
+ const want = [_]u8{ 6, '0', 'x', '4', '2', '0', '0', 4, 'c', 'a', 'f', 'e', 0 };
+ try testing.expectEqualSlices(u8, &want, msg[q.hlen..][0..want.len]);
+ try testing.expectEqual(@as(u16, 1), be16(msg, q.hlen + want.len)); // QTYPE=A
+ try testing.expectEqual(@as(u16, 1), be16(msg, q.hlen + want.len + 2)); // QCLASS=IN
+ try testing.expectEqual(@as(usize, q.hlen + want.len + 4), msg.len);
+ try testing.expectEqual(@as(u32, 1), s.counters.dns_tx);
+}
+
+test "DNS: an answer resolves the name, and the query slot is released" {
+ var s = newResolverStack();
+ const id = try startResolve(&s, "0x4200.cafe");
+
+ var m: Msg = .{};
+ m.header(id, 0x8180, 1, 1); // QR, RD, RA, RCODE 0
+ m.question("0x4200.cafe", 1, 1);
+ m.rr(q.hlen, 1, 1, &[_]u8{ 104, 21, 46, 8 });
+
+ const got = try answerWith(&s, "0x4200.cafe", &m);
+ try testing.expectEqualSlices(u8, &[_]u8{ 104, 21, 46, 8 }, &got);
+ try testing.expectEqual(@as(u32, 1), s.counters.dns_rx);
+ // The slot is free again: a second name resolves without an intervening reset.
+ try testing.expectError(error.WouldBlock, s.resolve("example.com"));
+}
+
+test "DNS: a CNAME ahead of the A record is stepped over, not read as an address" {
+ // This is the shape a CDN answers with, and a resolver that reads answer[0] gets a name where
+ // it wanted four octets. RDLENGTH would even be 4 for a short enough label.
+ var s = newResolverStack();
+ const id = try startResolve(&s, "0x4200.cafe");
+
+ var cname: [32]u8 = undefined;
+ const cname_len = wireName(&cname, "edge.example");
+
+ var m: Msg = .{};
+ m.header(id, 0x8180, 1, 3);
+ m.question("0x4200.cafe", 1, 1);
+ m.rr(q.hlen, 5, 1, cname[0..cname_len]); // CNAME
+ m.rr(q.hlen, 28, 1, &[_]u8{0} ** 16); // AAAA - also not an address this stack can use
+ m.rr(q.hlen, 1, 1, &[_]u8{ 172, 67, 221, 247 }); // and finally the A
+
+ const got = try answerWith(&s, "0x4200.cafe", &m);
+ try testing.expectEqualSlices(u8, &[_]u8{ 172, 67, 221, 247 }, &got);
+}
+
+test "DNS: an owner name written out in full, not compressed, is skipped correctly" {
+ var s = newResolverStack();
+ const id = try startResolve(&s, "0x4200.cafe");
+
+ var m: Msg = .{};
+ m.header(id, 0x8180, 1, 1);
+ m.question("0x4200.cafe", 1, 1);
+ var full: [32]u8 = undefined;
+ m.bytes(full[0..wireName(&full, "0x4200.cafe")]);
+ m.be(1); // A
+ m.be(1); // IN
+ m.bytes(&[_]u8{ 0, 0, 1, 44 }); // TTL
+ m.be(4);
+ m.bytes(&[_]u8{ 104, 21, 46, 8 });
+
+ const got = try answerWith(&s, "0x4200.cafe", &m);
+ try testing.expectEqualSlices(u8, &[_]u8{ 104, 21, 46, 8 }, &got);
+}
+
+test "DNS: a compression pointer that loops is bounded, not followed forever" {
+ // The gadget: at the start of the answer section, a one-byte label followed by a pointer back
+ // to that label. Every jump goes strictly backwards - so the "pointers must point backwards"
+ // check that most parsers stop at passes it - and the walk still never ends, because stepping
+ // over the label moves forward again. Only counting the jumps terminates this.
+ //
+ // If this test hangs, it has failed. That is the whole point of it.
+ var s = newResolverStack();
+ const id = try startResolve(&s, "0x4200.cafe");
+
+ var m: Msg = .{};
+ m.header(id, 0x8180, 1, 1);
+ m.question("0x4200.cafe", 1, 1);
+ const gadget: u16 = @intCast(m.len);
+ m.bytes(&[_]u8{ 1, 'x' }); // a label...
+ m.be(0xc000 | gadget); // ...and a pointer back to it
+
+ try testing.expectError(error.DnsMalformed, answerWith(&s, "0x4200.cafe", &m));
+}
+
+test "DNS: a compression pointer that points forward is rejected" {
+ var s = newResolverStack();
+ const id = try startResolve(&s, "0x4200.cafe");
+
+ var m: Msg = .{};
+ m.header(id, 0x8180, 1, 1);
+ m.question("0x4200.cafe", 1, 1);
+ // A forward pointer that a parser without the backwards rule would happily follow: it lands
+ // on a root label placed at the very end of this message, so the name resolves, the record
+ // behind it parses, and an address comes out. RFC 1035 4.1.4 only ever compresses against a
+ // *prior* occurrence, and the rule is what keeps `dnsSkipName`'s jumps monotone.
+ m.be(0xc000 | 0x002d); // -> offset 45, the root label appended below
+ m.be(1); // A
+ m.be(1); // IN
+ m.bytes(&[_]u8{ 0, 0, 1, 44 }); // TTL
+ m.be(4);
+ m.bytes(&[_]u8{ 6, 6, 6, 6 });
+ try testing.expectEqual(@as(usize, 45), m.len);
+ m.bytes(&[_]u8{0}); // the root label the pointer aims at
+ try testing.expectError(error.DnsMalformed, answerWith(&s, "0x4200.cafe", &m));
+
+ // And one aimed past the end of the message entirely.
+ var s2 = newResolverStack();
+ const id2 = try startResolve(&s2, "0x4200.cafe");
+ var far: Msg = .{};
+ far.header(id2, 0x8180, 1, 1);
+ far.question("0x4200.cafe", 1, 1);
+ far.be(0xc000 | 0x00fa);
+ try testing.expectError(error.DnsMalformed, answerWith(&s2, "0x4200.cafe", &far));
+}
+
+test "DNS: a reserved label type is refused rather than guessed past" {
+ // RFC 1035 4.1.4 defines the two top bits of a length byte: 00 is a label, 11 is a pointer,
+ // 01 and 10 are reserved. A parser that treats 0x40 as "a label of 64 bytes" walks somewhere
+ // arbitrary and then keeps going - here, straight onto a well-formed A record.
+ var s = newResolverStack();
+ const id = try startResolve(&s, "0x4200.cafe");
+
+ var m: Msg = .{};
+ m.header(id, 0x8180, 1, 1);
+ m.question("0x4200.cafe", 1, 1);
+ m.bytes(&[_]u8{0x40}); // reserved type, low bits zero
+ m.bytes(&([_]u8{'z'} ** 64)); // what a 0x40-as-length parser would skip
+ m.bytes(&[_]u8{0}); // ...landing on a root label, so the name "parses"
+ m.be(1);
+ m.be(1);
+ m.bytes(&[_]u8{ 0, 0, 1, 44 });
+ m.be(4);
+ m.bytes(&[_]u8{ 6, 6, 6, 6 });
+ try testing.expectError(error.DnsMalformed, answerWith(&s, "0x4200.cafe", &m));
+}
+
+test "DNS: a pointer to a self-referential offset in the question is bounded too" {
+ var s = newResolverStack();
+ const id = try startResolve(&s, "0x4200.cafe");
+
+ var m: Msg = .{};
+ m.header(id, 0x8180, 1, 1);
+ m.question("0x4200.cafe", 1, 1);
+ const here: u16 = @intCast(m.len);
+ // A pointer to itself: rejected by the backwards check alone, since the target is not less
+ // than the pointer's own offset.
+ m.be(0xc000 | here);
+ try testing.expectError(error.DnsMalformed, answerWith(&s, "0x4200.cafe", &m));
+}
+
+test "DNS: a response with the wrong transaction id is ignored, and the query stays live" {
+ var s = newResolverStack();
+ const id = try startResolve(&s, "0x4200.cafe");
+
+ var m: Msg = .{};
+ m.header(id +% 1, 0x8180, 1, 1);
+ m.question("0x4200.cafe", 1, 1);
+ m.rr(q.hlen, 1, 1, &[_]u8{ 1, 2, 3, 4 });
+ try testing.expectError(error.WouldBlock, answerWith(&s, "0x4200.cafe", &m));
+ try testing.expectEqual(@as(u32, 0), s.counters.dns_rx);
+}
+
+test "DNS: a response echoing a different question is ignored" {
+ // The id alone is 16 bits. A resolver that checks only the id accepts an answer for any name
+ // an attacker likes, which is the entire cache-poisoning family.
+ var s = newResolverStack();
+ const id = try startResolve(&s, "0x4200.cafe");
+
+ var m: Msg = .{};
+ m.header(id, 0x8180, 1, 1);
+ m.question("evil.example", 1, 1);
+ m.rr(q.hlen, 1, 1, &[_]u8{ 6, 6, 6, 6 });
+ try testing.expectError(error.WouldBlock, answerWith(&s, "0x4200.cafe", &m));
+
+ // The one that matters, and the one a length-blind check misses: a different name of exactly
+ // the same encoded length, so QTYPE and QCLASS still land where they are expected and every
+ // check but the name's own passes. `kafe` for `cafe`.
+ var lookalike: Msg = .{};
+ lookalike.header(id, 0x8180, 1, 1);
+ lookalike.question("0x4200.kafe", 1, 1);
+ lookalike.rr(q.hlen, 1, 1, &[_]u8{ 6, 6, 6, 6 });
+ // The same encoded length as the question we actually asked, so nothing after the name moves.
+ var ours: Msg = .{};
+ ours.header(id, 0x8180, 1, 1);
+ ours.question("0x4200.cafe", 1, 1);
+ ours.rr(q.hlen, 1, 1, &[_]u8{ 6, 6, 6, 6 });
+ try testing.expectEqual(ours.len, lookalike.len);
+ try testing.expectError(error.WouldBlock, answerWith(&s, "0x4200.cafe", &lookalike));
+
+ // Nor a right name asked as the wrong type or class.
+ var wrong_type: Msg = .{};
+ wrong_type.header(id, 0x8180, 1, 1);
+ wrong_type.question("0x4200.cafe", 28, 1); // AAAA
+ wrong_type.rr(q.hlen, 1, 1, &[_]u8{ 6, 6, 6, 6 });
+ try testing.expectError(error.WouldBlock, answerWith(&s, "0x4200.cafe", &wrong_type));
+
+ var wrong_class: Msg = .{};
+ wrong_class.header(id, 0x8180, 1, 1);
+ wrong_class.question("0x4200.cafe", 1, 3); // CH
+ wrong_class.rr(q.hlen, 1, 1, &[_]u8{ 6, 6, 6, 6 });
+ try testing.expectError(error.WouldBlock, answerWith(&s, "0x4200.cafe", &wrong_class));
+}
+
+test "DNS: the echoed question is matched case-insensitively, as RFC 4343 requires" {
+ var s = newResolverStack();
+ const id = try startResolve(&s, "0x4200.cafe");
+ var m: Msg = .{};
+ m.header(id, 0x8180, 1, 1);
+ m.question("0X4200.CAFE", 1, 1);
+ m.rr(q.hlen, 1, 1, &[_]u8{ 104, 21, 46, 8 });
+ const got = try answerWith(&s, "0x4200.cafe", &m);
+ try testing.expectEqualSlices(u8, &[_]u8{ 104, 21, 46, 8 }, &got);
+}
+
+test "DNS: a response from the wrong source, or the wrong port, is ignored" {
+ var s = newResolverStack();
+ const id = try startResolve(&s, "0x4200.cafe");
+
+ var m: Msg = .{};
+ m.header(id, 0x8180, 1, 1);
+ m.question("0x4200.cafe", 1, 1);
+ m.rr(q.hlen, 1, 1, &[_]u8{ 6, 6, 6, 6 });
+
+ var wrong_src = udpFrame(.{ 192, 168, 1, 250 }, 53, dns_query_port, m.slice());
+ s.onFrame(wrong_src.bytes());
+ try testing.expectError(error.WouldBlock, s.resolve("0x4200.cafe"));
+
+ var wrong_port = udpFrame(dns_ip, 5353, dns_query_port, m.slice());
+ s.onFrame(wrong_port.bytes());
+ try testing.expectError(error.WouldBlock, s.resolve("0x4200.cafe"));
+
+ // And to a port that is not the one this query was sent from.
+ var wrong_dport = udpFrame(dns_ip, 53, dns_query_port +% 1, m.slice());
+ s.onFrame(wrong_dport.bytes());
+ try testing.expectError(error.WouldBlock, s.resolve("0x4200.cafe"));
+
+ // The right one still works, so the three rejections above are not rejecting everything.
+ const got = try answerWith(&s, "0x4200.cafe", &m);
+ try testing.expectEqualSlices(u8, &[_]u8{ 6, 6, 6, 6 }, &got);
+}
+
+test "DNS: a query, not a response, on the right port is ignored" {
+ var s = newResolverStack();
+ const id = try startResolve(&s, "0x4200.cafe");
+ var m: Msg = .{};
+ m.header(id, 0x0100, 1, 1); // QR clear
+ m.question("0x4200.cafe", 1, 1);
+ m.rr(q.hlen, 1, 1, &[_]u8{ 6, 6, 6, 6 });
+ try testing.expectError(error.WouldBlock, answerWith(&s, "0x4200.cafe", &m));
+}
+
+test "DNS: NXDOMAIN and a refusal are distinct named errors" {
+ var s = newResolverStack();
+ const id = try startResolve(&s, "0x4200.cafe");
+ var nx: Msg = .{};
+ nx.header(id, 0x8183, 1, 0); // RCODE 3
+ nx.question("0x4200.cafe", 1, 1);
+ try testing.expectError(error.NameNotFound, answerWith(&s, "0x4200.cafe", &nx));
+
+ var s2 = newResolverStack();
+ const id2 = try startResolve(&s2, "0x4200.cafe");
+ var refused: Msg = .{};
+ refused.header(id2, 0x8185, 1, 0); // RCODE 5, REFUSED
+ refused.question("0x4200.cafe", 1, 1);
+ try testing.expectError(error.DnsRefused, answerWith(&s2, "0x4200.cafe", &refused));
+}
+
+test "DNS: an answer with no A record in it is NameNotFound, not a hang" {
+ var s = newResolverStack();
+ const id = try startResolve(&s, "0x4200.cafe");
+ var m: Msg = .{};
+ m.header(id, 0x8180, 1, 1);
+ m.question("0x4200.cafe", 1, 1);
+ m.rr(q.hlen, 28, 1, &[_]u8{0} ** 16); // AAAA only
+ try testing.expectError(error.NameNotFound, answerWith(&s, "0x4200.cafe", &m));
+}
+
+test "DNS: an A record with the wrong RDLENGTH is not read as an address" {
+ var s = newResolverStack();
+ const id = try startResolve(&s, "0x4200.cafe");
+ var m: Msg = .{};
+ m.header(id, 0x8180, 1, 2);
+ m.question("0x4200.cafe", 1, 1);
+ m.rr(q.hlen, 1, 1, &[_]u8{ 1, 2, 3 }); // an A record three bytes long
+ m.rr(q.hlen, 1, 1, &[_]u8{ 104, 21, 46, 8 }); // the real one, behind it
+ const got = try answerWith(&s, "0x4200.cafe", &m);
+ try testing.expectEqualSlices(u8, &[_]u8{ 104, 21, 46, 8 }, &got);
+}
+
+test "DNS: an RDLENGTH that runs past the end of the message is refused, not read" {
+ var s = newResolverStack();
+ const id = try startResolve(&s, "0x4200.cafe");
+ var m: Msg = .{};
+ m.header(id, 0x8180, 1, 1);
+ m.question("0x4200.cafe", 1, 1);
+ m.be(0xc000 | q.hlen);
+ m.be(1);
+ m.be(1);
+ m.bytes(&[_]u8{ 0, 0, 1, 44 });
+ m.be(400); // RDLENGTH far past what follows
+ m.bytes(&[_]u8{ 104, 21, 46, 8 });
+ try testing.expectError(error.DnsMalformed, answerWith(&s, "0x4200.cafe", &m));
+}
+
+test "DNS: every truncation of a good response is refused, and none is read off the end" {
+ // Every prefix of a well-formed answer, each against a *fresh* query - which is the part that
+ // matters. Feeding them all to one query would stop testing after the first prefix that
+ // decided it, because a decided query stops listening, and the prefixes that cut inside the
+ // resource record - exactly the ones whose bounds are worth checking - come last.
+ var cut: usize = 0;
+ while (cut < 45) : (cut += 1) {
+ var s = newResolverStack();
+ const id = try startResolve(&s, "0x4200.cafe");
+ var m: Msg = .{};
+ m.header(id, 0x8180, 1, 1);
+ m.question("0x4200.cafe", 1, 1);
+ m.rr(q.hlen, 1, 1, &[_]u8{ 104, 21, 46, 8 });
+ try testing.expectEqual(@as(usize, 45), m.len);
+
+ var f = udpFrame(dns_ip, 53, dns_query_port, m.buf[0..cut]);
+ s.onFrame(f.bytes());
+ // Ignored or refused, but never resolved: a prefix of the truth is not the truth.
+ if (s.resolve("0x4200.cafe")) |_| return error.TestUnexpectedResult else |_| {}
+ }
+ // ...and the whole thing does resolve, so the loop above is rejecting truncation and not
+ // simply rejecting everything.
+ var s = newResolverStack();
+ const id = try startResolve(&s, "0x4200.cafe");
+ var m: Msg = .{};
+ m.header(id, 0x8180, 1, 1);
+ m.question("0x4200.cafe", 1, 1);
+ m.rr(q.hlen, 1, 1, &[_]u8{ 104, 21, 46, 8 });
+ const got = try answerWith(&s, "0x4200.cafe", &m);
+ try testing.expectEqualSlices(u8, &[_]u8{ 104, 21, 46, 8 }, &got);
+}
+
+test "DNS: two queries in sequence use different source ports" {
+ // The id is 16 bits and the port is the other 16. Reusing one port halves what an off-path
+ // spoofer has to guess, and makes a late answer to the previous query land on the live one.
+ var s = newResolverStack();
+ const id = try startResolve(&s, "0x4200.cafe");
+ const first_port = dns_query_port;
+
+ var m: Msg = .{};
+ m.header(id, 0x8180, 1, 1);
+ m.question("0x4200.cafe", 1, 1);
+ m.rr(q.hlen, 1, 1, &[_]u8{ 104, 21, 46, 8 });
+ _ = try answerWith(&s, "0x4200.cafe", &m);
+
+ _ = try startResolve(&s, "example.com");
+ try testing.expect(dns_query_port != first_port);
+}
+
+test "DNS: an answer count larger than the answers present does not walk off the end" {
+ var s = newResolverStack();
+ const id = try startResolve(&s, "0x4200.cafe");
+ var m: Msg = .{};
+ m.header(id, 0x8180, 1, 0xffff); // 65,535 answers promised, none delivered
+ m.question("0x4200.cafe", 1, 1);
+ try testing.expectError(error.DnsMalformed, answerWith(&s, "0x4200.cafe", &m));
+}
+
+test "DNS: the query is retransmitted on a doubling timer and then times out" {
+ var s = newResolverStack();
+ const id = try startResolve(&s, "0x4200.cafe");
+
+ // Nothing before the first deadline. The query went out at t=1000 with a 1 s timer.
+ s.tick(1_999);
+ try testing.expectEqual(@as(usize, 0), cap_n);
+
+ s.tick(2_000);
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ const re = try queryOut(sent(0));
+ // The same id, so an answer to the first attempt still counts. Redrawing it is how a slow
+ // resolver turns into a timeout on a network that was working.
+ try testing.expectEqual(id, be16(re.msg, q.id));
+ clearCapture();
+
+ s.tick(3_999);
+ try testing.expectEqual(@as(usize, 0), cap_n);
+ s.tick(4_000);
+ try testing.expectEqual(@as(usize, 1), cap_n);
+ clearCapture();
+
+ try testing.expectError(error.WouldBlock, s.resolve("0x4200.cafe"));
+ s.tick(8_000);
+ try testing.expectError(error.TimedOut, s.resolve("0x4200.cafe"));
+ try testing.expectEqual(@as(u32, 3), s.counters.dns_tx);
+ try testing.expectEqual(@as(u32, 2), s.counters.dns_retx);
+
+ // And the slot is free: the next call starts a new query rather than returning the old error.
+ try testing.expectError(error.WouldBlock, s.resolve("0x4200.cafe"));
+}
+
+test "DNS: a late answer to an abandoned query does not resolve a new one" {
+ var s = newResolverStack();
+ const first_id = try startResolve(&s, "0x4200.cafe");
+ const first_port = dns_query_port;
+ // The whole schedule: 1 s, 2 s, 4 s, then out of tries.
+ s.tick(2_000);
+ s.tick(4_000);
+ s.tick(8_000);
+ clearCapture();
+ try testing.expectError(error.TimedOut, s.resolve("0x4200.cafe"));
+ _ = try startResolve(&s, "0x4200.cafe");
+
+ var m: Msg = .{};
+ m.header(first_id, 0x8180, 1, 1);
+ m.question("0x4200.cafe", 1, 1);
+ m.rr(q.hlen, 1, 1, &[_]u8{ 9, 9, 9, 9 });
+ var f = udpFrame(dns_ip, 53, first_port, m.slice());
+ s.onFrame(f.bytes());
+ try testing.expectError(error.WouldBlock, s.resolve("0x4200.cafe"));
+}
+
+test "DNS: a second name while a query is in flight is Busy, and the first is untouched" {
+ var s = newResolverStack();
+ const id = try startResolve(&s, "0x4200.cafe");
+ try testing.expectError(error.Busy, s.resolve("example.com"));
+ // The same name, spelled with a trailing root dot and in a different case, is the same query.
+ try testing.expectError(error.WouldBlock, s.resolve("0X4200.CAFE."));
+ try testing.expectError(error.WouldBlock, s.resolve("0x4200.cafe"));
+
+ var m: Msg = .{};
+ m.header(id, 0x8180, 1, 1);
+ m.question("0x4200.cafe", 1, 1);
+ m.rr(q.hlen, 1, 1, &[_]u8{ 104, 21, 46, 8 });
+ const got = try answerWith(&s, "0x4200.cafe.", &m);
+ try testing.expectEqualSlices(u8, &[_]u8{ 104, 21, 46, 8 }, &got);
+}
+
+test "DNS: with no resolver and no address, resolve says which one is missing" {
+ var no_server = newStack();
+ no_server.tick(1000);
+ no_server.setStatic(our_ip, mask24, gw_ip);
+ clearCapture();
+ try testing.expectError(error.NoDnsServer, no_server.resolve("0x4200.cafe"));
+ try testing.expectEqual(@as(usize, 0), cap_n);
+
+ var no_addr = newStack();
+ no_addr.tick(1000);
+ no_addr.setDnsServer(dns_ip);
+ clearCapture();
+ try testing.expectError(error.NoAddress, no_addr.resolve("0x4200.cafe"));
+ try testing.expectEqual(@as(usize, 0), cap_n);
+}
+
+test "DNS: an unusable name is refused before a byte leaves, and says which way it was unusable" {
+ var s = newResolverStack();
+ const long: [ip.dns_name_max + 1]u8 = @splat('a');
+ try testing.expectError(error.NameTooLong, s.resolve(&long));
+ // A label over 63 bytes, inside a name that is itself short enough - so this is the label
+ // rule and not the name rule that rejects it.
+ const long_label = "b" ** 64;
+ for ([_][]const u8{ "", ".", "..", ".a", "a..b", long_label }) |bad| {
+ try testing.expectError(error.NameInvalid, s.resolve(bad));
+ }
+ try testing.expectEqual(@as(usize, 0), cap_n);
+ // A name of exactly the maximum is fine, and is what proves the limit is off by nothing:
+ // 31 + 1 + 32 = 64 text bytes, encoding to 66 - which is `dns_qname_max` exactly.
+ const ok = "a" ** 31 ++ "." ++ "b" ** 32;
+ try testing.expectEqual(@as(usize, ip.dns_name_max), ok.len);
+ try testing.expectError(error.WouldBlock, s.resolve(ok));
+}
+
+test "DNS: the resolver DHCP supplied is the one resolve asks, with nothing configured" {
+ // The default path on this network: the lease carries option 6 and the caller does nothing.
+ var s = newStack();
+ s.tick(10_000);
+ s.dhcpStart();
+ const discover = try dhcpOut(sent(0));
+ const xid = be32(discover, d.xid);
+
+ var offer = dhcpReply(2, xid, our_ip, gw_ip, &standard_opts, our_ip, our_mac);
+ s.onFrame(offer.bytes());
+ var ack = dhcpReply(5, xid, our_ip, gw_ip, &standard_opts, our_ip, our_mac);
+ s.onFrame(ack.bytes());
+ try testing.expectEqual(ip.DhcpState.bound, s.dhcpState());
+ try testing.expectEqualSlices(u8, &dns_ip, &(s.dnsServer().?));
+
+ // The resolver's MAC, so the query can actually be addressed.
+ var probe = arpFrame(1, gw_mac, dns_ip, zero_mac, our_ip, bcast_mac);
+ s.onFrame(probe.bytes());
+ clearCapture();
+
+ const id = try startResolve(&s, "0x4200.cafe");
+ var m: Msg = .{};
+ m.header(id, 0x8180, 1, 1);
+ m.question("0x4200.cafe", 1, 1);
+ m.rr(q.hlen, 1, 1, &[_]u8{ 104, 21, 46, 8 });
+ const got = try answerWith(&s, "0x4200.cafe", &m);
+ try testing.expectEqualSlices(u8, &[_]u8{ 104, 21, 46, 8 }, &got);
+}
+
+test "DNS: a new lease abandons a query in flight rather than leaving it to time out" {
+ var s = newResolverStack();
+ _ = try startResolve(&s, "0x4200.cafe");
+ s.dhcpStart();
+ // No address and no resolver now, and the query is gone with them - so this is the error that
+ // names what is missing, not `Busy` from a query nobody can answer.
+ try testing.expectError(error.NoAddress, s.resolve("0x4200.cafe"));
+}
+
+test "identity: the clock stirs the transaction ids, so two boots do not collide" {
+ // Same MAC, same firmware, different moment of first tick. If `tick` did not mix `now_ms` into
+ // the entropy, both would draw identical DHCP transaction ids and identical initial sequence
+ // numbers, and a reboot would happily accept a reply meant for its previous incarnation.
+ var a = newStack();
+ a.tick(1234);
+ a.dhcpStart();
+ const xid_a = be32(sent(0)[42..], d.xid);
+
+ var b = newStack();
+ b.tick(9_876_543);
+ b.dhcpStart();
+ const xid_b = be32(sent(0)[42..], d.xid);
+
+ try testing.expect(xid_a != xid_b);
+}
+
+// ================================================================================ footprint
+
+test "footprint: the static cost of one Stack" {
+ // No printing. The test runner speaks a binary protocol over its own stdio under
+ // `zig build test`, and a diagnostic in the middle of it costs the whole suite's results for
+ // the sake of a number that an assertion states better anyway.
+ //
+ // 6 KiB is the ceiling, and it is not arbitrary: the image has ~128 KB of L2MEM, nothing
+ // initialises the 32 MB of PSRAM, and ESP-Hosted's queues and its task stacks compete for the
+ // same space. The stack is ~4,600 bytes today: 3,472 before chunked decoding and the resolver
+ // (104 bytes between them, mostly the encoded question), then 1,024 more when `http_head_max`
+ // went 1024 -> 2048 to fit a real CDN response head - measured at 1,043 bytes from the site this
+ // was pointed at, which failed the request by 19 bytes at the old size.
+ //
+ // A regression to 30 KiB would not announce itself any other way; it would show up as a stack
+ // overflow on the die. The heap in examples/http.zig was reduced by the same 2 KB this raise
+ // cost, so the image's total is unchanged.
+ const n = ip.Stack.footprint;
+ try testing.expect(n <= 6 * 1024);
+ // And a floor, so the ceiling cannot be met by quietly shrinking a buffer that the protocol
+ // needs: one full frame to build in, the request held for retransmission, the response head
+ // held while waiting for the blank line, and the DNS question held for the retransmissions
+ // and for the comparison against what the server echoes back.
+ try testing.expect(n >= ip.frame_max + ip.tcp_tx_max + ip.http_head_max + ip.dns_qname_max);
+}
diff --git a/src/net/libc.zig b/src/net/libc.zig
new file mode 100644
index 0000000..38eab6d
--- /dev/null
+++ b/src/net/libc.zig
@@ -0,0 +1,457 @@
+//! The libc symbols ESP-Hosted's C reaches for, and nothing more.
+//!
+//! This is not a libc. It is the exact set measured by linking the transport, and each entry is here
+//! because a specific call site needs it:
+//!
+//! nm on the milestone-1 objects (transport_drv.o transport_util.o sdio_drv.o mempool.o) leaves
+//! 26 undefined symbols. These are the libc ones: memcpy memset strcpy snprintf __errno_location
+//! htole16 le16toh, plus malloc/free/realloc once mempool.c is included.
+//!
+//! Two sources cover them:
+//!
+//! 1. compiler_rt, which Zig links automatically. It provides the memory primitives - memcpy,
+//! memset, memcmp, memmove - and the integer helpers clang emits for 64-bit division on a
+//! 32-bit target, __udivdi3 and __divdi3. It provides no `str*` functions at all.
+//! 2. This file, for everything else.
+//!
+//! The P4 mask ROM is a third possibility that this project deliberately does not use yet.
+//! `components/esp_rom/esp32p4/ld/esp32p4.rom.newlib.ld` exports 32 newlib symbols - strlen,
+//! strlcpy, strchr, strstr, memset, qsort, atoi and friends - as absolute addresses, which would
+//! cost no code in the image and would be the same implementation IDF links. It is not wired in
+//! because that file assigns those names unconditionally rather than with PROVIDE, so it would
+//! collide with compiler_rt's own memset and memcpy definitions. Trading a real duplicate-symbol
+//! hazard for a few hundred bytes is not worth it while the image is 2 KB.
+//!
+//! Deliberately absent: stdio beyond snprintf, locale, floating-point formatting beyond what
+//! std.fmt gives, and anything reentrant. If a link error names a symbol not here, the honest move
+//! is to add it here with a comment saying which call site wanted it - not to link a real libc.
+
+const std = @import("std");
+
+/// Set by `install`. ESP-Hosted allocates per-packet buffers and frees them, so this cannot be an
+/// arena; see the allocator discussion in src/net/port.zig.
+var gpa: ?std.mem.Allocator = null;
+
+pub fn install(allocator: std.mem.Allocator) void {
+ gpa = allocator;
+}
+
+// ---------------------------------------------------------------------------------------------
+// malloc family
+//
+// C's `free` carries no size, but Zig's Allocator.free needs one. The classic fix is a header word
+// in front of every block holding the length. It costs 8 bytes per allocation (the word plus
+// padding to keep the payload 8-aligned, which the SDIO IDMAC path needs anyway) and it is the only
+// way to bridge the two contracts without a side table.
+// ---------------------------------------------------------------------------------------------
+
+/// Payload alignment. 8 rather than 4 because DMA descriptors on this chip want 8-byte alignment,
+/// and buffers handed to CMD53 come from here.
+const malloc_align: std.mem.Alignment = .@"8";
+const header_size = malloc_align.toByteUnits();
+
+comptime {
+ // The header must not push the payload out of alignment.
+ std.debug.assert(header_size >= @sizeOf(usize));
+ std.debug.assert(header_size % malloc_align.toByteUnits() == 0);
+}
+
+fn allocBlock(total_payload: usize) ?[*]u8 {
+ const a = gpa orelse @panic("libc malloc before install()");
+ const raw = a.rawAlloc(header_size + total_payload, malloc_align, @returnAddress()) orelse
+ return null;
+ // Record the payload length in the word directly before the payload.
+ const payload = raw + header_size;
+ @as(*usize, @ptrCast(@alignCast(raw))).* = total_payload;
+ return payload;
+}
+
+fn payloadLen(payload: [*]u8) usize {
+ return @as(*const usize, @ptrCast(@alignCast(payload - header_size))).*;
+}
+
+fn freeBlock(payload: [*]u8) void {
+ const a = gpa orelse @panic("libc free before install()");
+ const len = payloadLen(payload);
+ a.rawFree((payload - header_size)[0 .. header_size + len], malloc_align, @returnAddress());
+}
+
+export fn malloc(size: usize) callconv(.c) ?*anyopaque {
+ if (size == 0) return null;
+ return @ptrCast(allocBlock(size));
+}
+
+export fn calloc(n: usize, size: usize) callconv(.c) ?*anyopaque {
+ const total = std.math.mul(usize, n, size) catch return null;
+ if (total == 0) return null;
+ const p = allocBlock(total) orelse return null;
+ @memset(p[0..total], 0);
+ return @ptrCast(p);
+}
+
+export fn free(ptr: ?*anyopaque) callconv(.c) void {
+ const p = ptr orelse return;
+ freeBlock(@ptrCast(p));
+}
+
+export fn realloc(ptr: ?*anyopaque, size: usize) callconv(.c) ?*anyopaque {
+ const p = ptr orelse return malloc(size);
+ if (size == 0) {
+ freeBlock(@ptrCast(p));
+ return null;
+ }
+ const old: [*]u8 = @ptrCast(p);
+ const old_len = payloadLen(old);
+ if (old_len == size) return ptr;
+
+ // Try to grow or shrink in place first; the allocator may well be able to, and mempool.c
+ // reallocs the same buffer repeatedly.
+ const a = gpa orelse @panic("libc realloc before install()");
+ const whole = (old - header_size)[0 .. header_size + old_len];
+ if (a.rawResize(whole, malloc_align, header_size + size, @returnAddress())) {
+ @as(*usize, @ptrCast(@alignCast(old - header_size))).* = size;
+ return ptr;
+ }
+
+ const new = allocBlock(size) orelse return null;
+ @memcpy(new[0..@min(old_len, size)], old[0..@min(old_len, size)]);
+ freeBlock(old);
+ return @ptrCast(new);
+}
+
+/// ESP-Hosted's `_h_malloc_align` path and IDF's `heap_caps_aligned_alloc` both land here. The
+/// header trick still works as long as the requested alignment is not stricter than ours; anything
+/// stricter would need the payload moved and the header written at a computed offset, and nothing
+/// in the measured surface asks for that. Assert rather than silently misalign a DMA buffer.
+export fn aligned_alloc(alignment: usize, size: usize) callconv(.c) ?*anyopaque {
+ // A stricter alignment would need the payload moved and the header written at a computed
+ // offset. Nothing in the measured surface asks for it, so this asserts rather than silently
+ // handing back a misaligned DMA buffer - which would corrupt a packet, not fail a call.
+ if (alignment > malloc_align.toByteUnits()) @panic("aligned_alloc: alignment stricter than 8");
+ return malloc(size);
+}
+
+// ---------------------------------------------------------------------------------------------
+// string
+//
+// compiler_rt covers `mem*` and nothing else, so every `str*` ESP-Hosted references is here. The
+// list is exactly what the link demanded - measured, not anticipated.
+// ---------------------------------------------------------------------------------------------
+
+export fn strlen(s: [*:0]const u8) callconv(.c) usize {
+ // std.mem.len is the same loop; going through it keeps this honest about being a wrapper rather
+ // than a hand-optimised copy of something the standard library already has.
+ return std.mem.len(s);
+}
+
+export fn strcpy(dst: [*]u8, src: [*:0]const u8) callconv(.c) [*]u8 {
+ var i: usize = 0;
+ while (src[i] != 0) : (i += 1) dst[i] = src[i];
+ dst[i] = 0;
+ return dst;
+}
+
+export fn strnlen(s: [*]const u8, max: usize) callconv(.c) usize {
+ var i: usize = 0;
+ while (i < max and s[i] != 0) : (i += 1) {}
+ return i;
+}
+
+export fn strcmp(a: [*:0]const u8, b: [*:0]const u8) callconv(.c) c_int {
+ var i: usize = 0;
+ while (a[i] != 0 and a[i] == b[i]) : (i += 1) {}
+ return @as(c_int, a[i]) - @as(c_int, b[i]);
+}
+
+export fn strncmp(a: [*]const u8, b: [*]const u8, n: usize) callconv(.c) c_int {
+ var i: usize = 0;
+ while (i < n) : (i += 1) {
+ if (a[i] != b[i]) return @as(c_int, a[i]) - @as(c_int, b[i]);
+ if (a[i] == 0) break;
+ }
+ return 0;
+}
+
+// ---------------------------------------------------------------------------------------------
+// endian helpers
+//
+// These are macros in musl's <endian.h>, but ESP-Hosted takes their address in a couple of places,
+// so clang emits calls and the linker wants real symbols. riscv32 is little-endian, so both are
+// identity - which is exactly why getting them wrong would be invisible here and corrupt on a
+// big-endian host. Written as byte-order conversions rather than `return x` to say so.
+// ---------------------------------------------------------------------------------------------
+
+export fn htole16(x: u16) callconv(.c) u16 {
+ return std.mem.nativeToLittle(u16, x);
+}
+
+export fn le16toh(x: u16) callconv(.c) u16 {
+ return std.mem.littleToNative(u16, x);
+}
+
+export fn htole32(x: u32) callconv(.c) u32 {
+ return std.mem.nativeToLittle(u32, x);
+}
+
+export fn le32toh(x: u32) callconv(.c) u32 {
+ return std.mem.littleToNative(u32, x);
+}
+
+// ---------------------------------------------------------------------------------------------
+// errno
+//
+// ESP-Hosted reads errno after its own calls fail. There are no threads competing for it in a
+// cooperative runtime, so one global is correct here; it would need to be per-task the moment a
+// preemptive scheduler appeared.
+// ---------------------------------------------------------------------------------------------
+
+var errno_storage: c_int = 0;
+
+export fn __errno_location() callconv(.c) *c_int {
+ return &errno_storage;
+}
+
+// ---------------------------------------------------------------------------------------------
+// snprintf
+//
+// The one genuinely non-trivial entry. ESP-Hosted uses it for log lines and for formatting MAC
+// addresses and transport state, so the conversions that matter are %d %u %x %s %c %p and width /
+// zero-pad on the integer ones. std.fmt does the formatting; this only parses the C format string.
+//
+// Unsupported conversions print `%!` followed by the specifier rather than being skipped, so a
+// format this does not handle is visible in the log instead of silently dropping its argument.
+// ---------------------------------------------------------------------------------------------
+
+export fn snprintf(buf: [*]u8, size: usize, fmt: [*:0]const u8, ...) callconv(.c) c_int {
+ var ap = @cVaStart();
+ defer @cVaEnd(&ap);
+ return vsnprintfImpl(buf, size, fmt, &ap);
+}
+
+export fn vsnprintf(
+ buf: [*]u8,
+ size: usize,
+ fmt: [*:0]const u8,
+ ap: *std.builtin.VaList,
+) callconv(.c) c_int {
+ return vsnprintfImpl(buf, size, fmt, ap);
+}
+
+/// `callconv(.c)` is required, not stylistic: `@cVaArg` is only available in a function using the C
+/// calling convention, and Zig rejects it in an `auto` one.
+fn vsnprintfImpl(
+ buf: [*]u8,
+ size: usize,
+ fmt: [*:0]const u8,
+ ap: *std.builtin.VaList,
+) callconv(.c) c_int {
+ // Writes into the caller's buffer, tracking how many bytes *would* have been written, because
+ // that is what snprintf returns and callers use it to size a second call.
+ var out: Counting = .{ .buf = if (size == 0) &.{} else buf[0 .. size - 1] };
+
+ var i: usize = 0;
+ while (fmt[i] != 0) : (i += 1) {
+ if (fmt[i] != '%') {
+ out.byte(fmt[i]);
+ continue;
+ }
+ i += 1;
+ if (fmt[i] == '%') {
+ out.byte('%');
+ continue;
+ }
+
+ // flags and width: only the subset ESP-Hosted uses
+ var zero_pad = false;
+ var width: usize = 0;
+ while (fmt[i] == '0' or fmt[i] == '-' or fmt[i] == '+' or fmt[i] == ' ') : (i += 1) {
+ if (fmt[i] == '0') zero_pad = true;
+ }
+ while (fmt[i] >= '1' and fmt[i] <= '9') : (i += 1) {
+ width = width * 10 + (fmt[i] - '0');
+ }
+ // length modifiers: consumed, and `ll`/`z` widen the fetch below
+ var long_long = false;
+ while (true) : (i += 1) {
+ switch (fmt[i]) {
+ 'l' => if (fmt[i + 1] == 'l') {
+ long_long = true;
+ } else {},
+ 'h', 'z', 't', 'j' => {},
+ else => break,
+ }
+ }
+
+ switch (fmt[i]) {
+ 'd', 'i' => {
+ if (long_long) {
+ out.int(@cVaArg(ap, i64), 10, false, width, zero_pad);
+ } else {
+ out.int(@cVaArg(ap, c_int), 10, false, width, zero_pad);
+ }
+ },
+ 'u' => {
+ if (long_long) {
+ out.int(@cVaArg(ap, u64), 10, false, width, zero_pad);
+ } else {
+ out.int(@cVaArg(ap, c_uint), 10, false, width, zero_pad);
+ }
+ },
+ 'x' => out.int(@cVaArg(ap, c_uint), 16, false, width, zero_pad),
+ 'X' => out.int(@cVaArg(ap, c_uint), 16, true, width, zero_pad),
+ 'c' => out.byte(@truncate(@as(c_uint, @bitCast(@cVaArg(ap, c_int))))),
+ 's' => {
+ const s = @cVaArg(ap, ?[*:0]const u8) orelse "(null)";
+ var n: usize = 0;
+ while (s[n] != 0) : (n += 1) {}
+ out.pad(width, n, ' ');
+ out.slice(s[0..n]);
+ },
+ 'p' => {
+ out.slice("0x");
+ out.int(@intFromPtr(@cVaArg(ap, ?*anyopaque)), 16, false, 8, true);
+ },
+ 0 => break,
+ else => {
+ // Unsupported: say so in the output rather than desynchronising silently. The
+ // argument is deliberately not consumed - there is no way to know its width.
+ out.slice("%!");
+ out.byte(fmt[i]);
+ },
+ }
+ }
+
+ if (size != 0) buf[@min(out.written, size - 1)] = 0;
+ return @intCast(out.would);
+}
+
+/// A writer that stops filling at the end of the buffer but keeps counting, which is what
+/// snprintf's return value means.
+const Counting = struct {
+ buf: []u8,
+ written: usize = 0,
+ would: usize = 0,
+
+ fn byte(self: *Counting, c: u8) void {
+ if (self.written < self.buf.len) {
+ self.buf[self.written] = c;
+ self.written += 1;
+ }
+ self.would += 1;
+ }
+
+ fn slice(self: *Counting, s: []const u8) void {
+ for (s) |c| self.byte(c);
+ }
+
+ fn pad(self: *Counting, width: usize, len: usize, fill: u8) void {
+ if (width > len) for (0..width - len) |_| self.byte(fill);
+ }
+
+ fn int(
+ self: *Counting,
+ value: anytype,
+ base: u8,
+ upper: bool,
+ width: usize,
+ zero_pad: bool,
+ ) void {
+ var tmp: [24]u8 = undefined;
+ const end = std.fmt.printInt(&tmp, value, base, if (upper) .upper else .lower, .{});
+ const s = tmp[0..end];
+ self.pad(width, s.len, if (zero_pad) '0' else ' ');
+ self.slice(s);
+ }
+};
+
+// ---------------------------------------------------------------------------------------------
+// Tests. These run on the host, where a wrong snprintf is cheap to find; on the die it would be a
+// garbled log line at best and a buffer overrun at worst.
+// ---------------------------------------------------------------------------------------------
+
+test "snprintf: the conversions esp_hosted actually uses" {
+ var buf: [64]u8 = undefined;
+ const n = snprintf(&buf, buf.len, "state %d port %u flags 0x%x %s", @as(c_int, -3), @as(c_uint, 7), @as(c_uint, 0xbeef), "ok");
+ try std.testing.expectEqualStrings("state -3 port 7 flags 0xbeef ok", buf[0..@intCast(n)]);
+}
+
+test "snprintf: return value is the length that would have been written" {
+ var buf: [8]u8 = undefined;
+ const n = snprintf(&buf, buf.len, "%s", "0123456789");
+ // Truncated to 7 chars plus NUL, but reports the full 10 so a caller can size a second call.
+ try std.testing.expectEqual(@as(c_int, 10), n);
+ try std.testing.expectEqualStrings("0123456", buf[0..7]);
+ try std.testing.expectEqual(@as(u8, 0), buf[7]);
+}
+
+test "snprintf: zero-padded width, as used for MAC bytes" {
+ var buf: [32]u8 = undefined;
+ const n = snprintf(&buf, buf.len, "%02x:%02x", @as(c_uint, 0x0a), @as(c_uint, 0xf1));
+ try std.testing.expectEqualStrings("0a:f1", buf[0..@intCast(n)]);
+}
+
+test "snprintf: size 0 writes nothing at all" {
+ var buf = [_]u8{0xAA} ** 4;
+ const n = snprintf(&buf, 0, "hello");
+ try std.testing.expectEqual(@as(c_int, 5), n);
+ try std.testing.expectEqual(@as(u8, 0xAA), buf[0]);
+}
+
+test "snprintf: an unsupported conversion is visible, not silent" {
+ var buf: [32]u8 = undefined;
+ const n = snprintf(&buf, buf.len, "f=%f", @as(f64, 1.5));
+ try std.testing.expectEqualStrings("f=%!f", buf[0..@intCast(n)]);
+}
+
+test "malloc/free/realloc survive the churn mempool.c generates" {
+ var backing: [4096]u8 = undefined;
+ var fba = std.heap.FixedBufferAllocator.init(&backing);
+ install(fba.allocator());
+ defer gpa = null;
+
+ // Same-size alloc/free churn: the case an arena cannot serve.
+ var i: usize = 0;
+ while (i < 8) : (i += 1) {
+ const p = malloc(64) orelse return error.OutOfMemory;
+ free(p);
+ }
+
+ const a = malloc(32) orelse return error.OutOfMemory;
+ @memset(@as([*]u8, @ptrCast(a))[0..32], 0x5A);
+ const b = realloc(a, 64) orelse return error.OutOfMemory;
+ // Contents must survive the grow.
+ try std.testing.expectEqual(@as(u8, 0x5A), @as([*]u8, @ptrCast(b))[31]);
+ free(b);
+}
+
+test "calloc zeroes, and rejects overflow rather than under-allocating" {
+ var backing: [1024]u8 = undefined;
+ var fba = std.heap.FixedBufferAllocator.init(&backing);
+ install(fba.allocator());
+ defer gpa = null;
+
+ const p = calloc(16, 4) orelse return error.OutOfMemory;
+ for (@as([*]u8, @ptrCast(p))[0..64]) |byte| try std.testing.expectEqual(@as(u8, 0), byte);
+ free(p);
+
+ try std.testing.expect(calloc(std.math.maxInt(usize), 2) == null);
+}
+
+test "strlen, strcpy, strcmp and strncmp agree with std" {
+ try std.testing.expectEqual(@as(usize, 0), strlen(""));
+ try std.testing.expectEqual(@as(usize, 3), strlen("abc"));
+ // strnlen stops at the bound, which is the whole reason the shim uses it on wire data.
+ try std.testing.expectEqual(@as(usize, 3), strnlen("abc", 8));
+ try std.testing.expectEqual(@as(usize, 2), strnlen("abc", 2));
+ try std.testing.expectEqual(@as(usize, 0), strnlen("abc", 0));
+
+ var dst: [8]u8 = undefined;
+ _ = strcpy(&dst, "abc");
+ try std.testing.expectEqualStrings("abc", dst[0..3]);
+ try std.testing.expectEqual(@as(u8, 0), dst[3]);
+
+ try std.testing.expect(strcmp("abc", "abc") == 0);
+ try std.testing.expect(strcmp("abc", "abd") < 0);
+ try std.testing.expect(strncmp("abcX", "abcY", 3) == 0);
+ try std.testing.expect(strncmp("abcX", "abcY", 4) != 0);
+}
diff --git a/src/net/link.zig b/src/net/link.zig
new file mode 100644
index 0000000..4277595
--- /dev/null
+++ b/src/net/link.zig
@@ -0,0 +1,552 @@
+//! The seam: ESP-Hosted's station data channel, bridged to `src/net/ip.zig`.
+//!
+//! Everything below this file is proven - the SDIO host driver, the runtime, the port table, the
+//! RPC layer, the association. Everything above it is proven too: `ip.zig` has 117 host tests and a
+//! mutation sweep. This file is the twenty lines of pointer handling in between, and it is the one
+//! part of the path that no host test can check, because both of its neighbours are C.
+//!
+//! So every decision here is cited rather than inferred.
+//!
+//! ------------------------------------------------------------------------------------------
+//! 1. Where the received frame starts: at `buffer`, offset zero.
+//!
+//! This is the single most expensive thing to get wrong. A frame shifted by the 12-byte
+//! `esp_payload_header` parses as garbage - the ethertype lands in the middle of a MAC address -
+//! and every one of ip.zig's tests would still pass. The RX convention is established by the
+//! producer and confirmed by the vendor's own consumer:
+//!
+//! * sdio_drv.c:830 rejects any packet whose header `offset` field is not
+//! `sizeof(struct esp_payload_header)`, so the payload always begins exactly one header in.
+//! * sdio_drv.c:887 `buf_handle.payload = rxbuff + offset` - `payload` already points past the
+//! header. `priv_buffer_handle` (:882) is what still points at the header.
+//! * sdio_drv.c:1396-1400 allocates `copy_payload = _h_malloc(buf_handle->payload_len)` and
+//! memcpy's `payload_len` bytes from `buf_handle->payload` into it, then frees the original
+//! buffer at :1401. So the copy is exactly the payload, nothing more.
+//! * sdio_drv.c:1407-1408 `rx(api_chan, copy_payload, copy_payload, payload_len)` - `buffer`
+//! and `buff_to_free` are the same pointer, and it is the start of the frame.
+//! * The vendor's own consumer agrees: esp_wifi_remote_net2.c:40-51 passes `buffer` straight to
+//! the netif receive function as the frame and `buff_to_free` only as the free handle.
+//!
+//! `H_ESP_PAYLOAD_HEADER_OFFSET` appears on the *transmit* side only (transport_drv.c:381), where
+//! ESP-Hosted is *building* a buffer and has to leave room for the header it is about to write.
+//! Adding it on receive would be applying the same correction twice, in the wrong direction.
+//!
+//! ------------------------------------------------------------------------------------------
+//! 2. Who frees, and with what.
+//!
+//! `copy_payload` came from `_h_malloc` (sdio_drv.c:1396), so it is freed with `_h_free` - which
+//! is exactly what `HOSTED_FREE` expands to (port_esp_hosted_host_os.h:139) and what
+//! `transport_sta_free_cb` reaches through `MEMPOOL_FREE` with the pool disabled
+//! (transport_util.h:29-31). `onRxFrame` below frees it through `g_h.funcs->_h_free`, once, on
+//! every path including the error paths, and always returns `ESP_OK`.
+//!
+//! Returning `ESP_OK` unconditionally is not laziness, it is the only value that is safe under
+//! both of sdio_drv.c's ownership rules. With `ESP_WIFI_REMOTE_VERSION` >= 1.3.1 the callee always
+//! owns the buffer and the caller never frees (:1418). Below that, and when the macro is undefined,
+//! the caller frees the buffer *if the callee returned non-zero* (:1411-1416). A non-zero return
+//! from a callback that has already freed is therefore a double free under one rule and a leak
+//! under neither - so this file frees and returns zero, which is one free under both.
+//!
+//! ------------------------------------------------------------------------------------------
+//! 3. `api_chan` must not be null.
+//!
+//! `transport_drv_sta_tx` opens with `assert(h && h == chan_arr[ESP_STA_IF]->api_chan)`
+//! (transport_drv.c:369), and the vendor's reference RX callback opens with `assert(h)`
+//! (esp_wifi_remote_net2.c:41). ESP-Hosted's own registration honours that: it allocates a cookie
+//! and passes it in (esp_hosted_api.c:200-203). This build compiles the C at -O2 with `-DNDEBUG`
+//! (Zig adds it for every non-Debug optimize mode), so those asserts are compiled out today and a
+//! null cookie would merely be an unchecked contract violation rather than a crash - which is a
+//! worse outcome, not a better one. `channel_cookie` below is that non-null cookie, and it is
+//! handed back to `tx` on every transmit so the identity check holds.
+//!
+//! ------------------------------------------------------------------------------------------
+//! 4. The transmitted frame need not outlive the call.
+//!
+//! `transport_drv_sta_tx` allocates its own buffer and copies into it before queueing:
+//! `mempool_alloc(..., MAX_TRANSPORT_BUFFER_SIZE, true)` at transport_drv.c:372 - with the pool
+//! disabled that is `_h_malloc_align(1536, 64)` (transport_util.h:21-27) - then
+//! `_h_memcpy(copy_buff + H_ESP_PAYLOAD_HEADER_OFFSET, buffer, len)` at :381, and only then
+//! `esp_hosted_tx(..., copy_buff, ...)` at :383. Nothing retains `buffer`. That is what makes
+//! `ip.Stack`'s "the slice is borrowed for the duration of the call" contract satisfiable, and it
+//! is why `sendFrame` may hand over a pointer into the stack's single transmit staging buffer.
+//!
+//! ------------------------------------------------------------------------------------------
+//! 5. Why there is a re-entrancy guard.
+//!
+//! This is the one hazard the task description does not mention and it is real.
+//!
+//! `ip.Stack` is a single-threaded state machine: `onFrame` may send (an ARP reply, an ICMP echo
+//! reply, a TCP ACK) before it returns, and `tick` and `httpGet` may too. Sending ends in
+//! `esp_hosted_tx`, whose last act is
+//! `_h_queue_item(to_slave_queue[prio], &buf_handle, HOSTED_BLOCK_MAX)` (sdio_drv.c:1607). That
+//! queue holds four items (`CONFIG_ESP_HOSTED_SDIO_TX_Q_SIZE 4`, src/net/hosted/sdkconfig.h:41)
+//! and `_h_queue_item` with `HOSTED_BLOCK_MAX` is a *blocking* send: port.zig:734-740 forwards it
+//! to `os.Queue.send`, which suspends the calling task until there is room.
+//!
+//! So a full transmit queue suspends whoever is inside the stack. `onRxFrame` runs on ESP-Hosted's
+//! `sdio_process_rx_task`; `tick` and `httpGet` run on the application's task. Without a guard,
+//! either one can be suspended mid-mutation and the other walk straight into the same `Stack`.
+//! On a cooperative scheduler that is not a torn read, it is two interleaved state machines
+//! sharing one transmit buffer, one TCP sequence space and one `http.out` slice.
+//!
+//! The guard makes that impossible, and every way it can fire has a correct answer already:
+//!
+//! * a frame arriving while the stack is busy is dropped, which is what a real NIC does when its
+//! transmit queue is full. DHCP, ARP and TCP all retransmit.
+//! * a `tick` skipped is a `tick` deferred: `ip.zig`'s timers are absolute deadlines compared
+//! against `now_ms` (`dhcpTick`, `tcpTick`), not increments, so nothing is lost.
+//! * `httpGet` returns `error.WouldBlock`, which is precisely the answer its protocol already
+//! requires the caller to handle by calling again with identical arguments.
+//!
+//! Each of those is counted, so a log can say which one happened rather than leaving a stall
+//! unexplained.
+
+const std = @import("std");
+
+const ip = @import("ip.zig");
+const port = @import("port.zig");
+
+// ================================================================= ESP-Hosted's C surface
+
+/// `esp_hosted_if_type_t`, common/esp_hosted_interface.h:14-24.
+///
+/// Note the value. The enumeration opens with `ESP_INVALID_IF`, so the station interface is **1**,
+/// not 0. Registering channel 0 would fall through `transport_drv_add_channel`'s switch to
+/// `default:` (transport_drv.c:481-484), which logs "Not yet supported" and returns NULL after
+/// having already installed a half-built channel - and `chan_arr[ESP_STA_IF]` would stay NULL, so
+/// sdio_drv.c:1394 would go on discarding every station frame in silence.
+const esp_sta_if: c_uint = 1;
+
+/// `transport_channel_tx_fn_t`, transport_drv.h:118. Returns `esp_err_t`; 0 is `ESP_OK`.
+const TxFn = *const fn (h: ?*anyopaque, buffer: ?*anyopaque, len: usize) callconv(.c) c_int;
+
+/// `transport_channel_rx_fn_t`, transport_drv.h:119.
+const RxFn = *const fn (
+ h: ?*anyopaque,
+ buffer: ?*anyopaque,
+ buff_to_free: ?*anyopaque,
+ len: usize,
+) callconv(.c) c_int;
+
+/// transport_drv.h:134-136. `tx` is an out-parameter: the transport writes the interface's own
+/// transmit function into it (transport_drv.c:469-471) and that is the only way to obtain it.
+///
+/// This is compiled in - `transport_drv.c` is on build.zig's source list - but nothing calls it,
+/// because the file that normally does (`esp_hosted_api.c`'s `add_esp_wifi_remote_channels`) is
+/// not compiled: this project calls `setup_transport`, `rpc_init` and `transport_drv_reconfigure`
+/// directly from `src/net/all.zig`. Registering the station channel is therefore ours to do.
+extern fn transport_drv_add_channel(
+ api_chan: ?*anyopaque,
+ if_type: c_uint,
+ secure: u8,
+ tx: *?TxFn,
+ rx: RxFn,
+) ?*anyopaque;
+
+/// The station's MAC, through the C shim (src/net/hosted/wifi_shim.c:96). It belongs to the C6's
+/// radio, not to this chip, and ARP and Ethernet framing are built on it. Valid only after
+/// `hosted_wifi_sta_start`, because that is what brings the radio up on the coprocessor.
+extern fn hosted_wifi_get_mac(out: *[6]u8) c_int;
+
+// ============================================================================== module state
+
+/// The one IPv4 stack. A module-level variable rather than something the caller owns, because
+/// `ip.Stack.send` is `*const fn ([]const u8) void` with no context pointer: the transmit callback
+/// has to reach the transport some other way, and a file-scope binding is the honest version of
+/// "some other way". 3,576 bytes of .bss - see `footprint`.
+var sta: ip.Stack = undefined;
+
+/// The `api_chan` cookie. Its address is what ESP-Hosted stores and compares; its contents are
+/// never read by anyone. See note 3 in the header for why it may not be null.
+var channel_cookie: u32 = 0x5354_4100; // 'STA\0', so a memory dump names it
+
+/// The transport's station transmit function, from `transport_drv_add_channel`'s out-parameter.
+var tx_fn: ?TxFn = null;
+
+/// Set once the channel is registered and the stack is live.
+var opened: bool = false;
+
+/// The re-entrancy guard. See note 5 in the header.
+var in_stack: bool = false;
+
+pub const Stats = struct {
+ /// Frames handed to us by sdio_drv.c, before any filtering.
+ rx_frames: u32 = 0,
+ /// Frames whose `h` was not our cookie. Non-zero means another channel's traffic reached this
+ /// callback, which would be an ESP-Hosted bug and not something to paper over.
+ rx_wrong_channel: u32 = 0,
+ /// `buffer` was null, or `len` was zero or larger than an Ethernet frame.
+ rx_bad: u32 = 0,
+ /// Frames dropped because the stack was already entered. See note 5.
+ rx_reentrant: u32 = 0,
+ /// Frames actually delivered to `ip.Stack.onFrame`.
+ rx_delivered: u32 = 0,
+ /// `tick` calls that found the stack entered and did nothing.
+ tick_skipped: u32 = 0,
+ /// `httpGet`/`httpGetHost` calls answered `WouldBlock` by the guard rather than by the stack.
+ http_deferred: u32 = 0,
+ /// `resolve` calls answered `WouldBlock` by the guard rather than by the stack. The query's
+ /// own timer runs in `tick`, so these cost a poll and never a retransmission.
+ dns_deferred: u32 = 0,
+ /// Frames handed to the transport.
+ tx_frames: u32 = 0,
+ /// Transmits the transport rejected: not ready, throttled, or out of buffers.
+ tx_failed: u32 = 0,
+ /// Transmits attempted before the channel existed. Should be zero.
+ tx_no_channel: u32 = 0,
+ /// Frames the stack asked to send, accepted into the deferred ring. The difference between this
+ /// and `tx_frames` is what is still waiting for the next `tick`.
+ tx_queued: u32 = 0,
+ /// Frames dropped because the deferred ring was full when the stack tried to send. Non-zero
+ /// means `tick` is not keeping up with the offered load; every protocol above this retransmits,
+ /// so it costs latency rather than correctness.
+ tx_ring_full: u32 = 0,
+ /// Frames the stack offered with an impossible length. Should be zero; a non-zero value points
+ /// at ip.zig rather than at the transport.
+ tx_bad: u32 = 0,
+};
+
+var counters: Stats = .{};
+
+/// Everything this file adds to .bss, so the number in a report cannot rot. The stack dominates it.
+pub const footprint: usize =
+ @sizeOf(@TypeOf(sta)) +
+ @sizeOf(@TypeOf(channel_cookie)) +
+ @sizeOf(@TypeOf(tx_fn)) +
+ @sizeOf(@TypeOf(opened)) +
+ @sizeOf(@TypeOf(in_stack)) +
+ @sizeOf(@TypeOf(counters)) +
+ @sizeOf(@TypeOf(tx_ring));
+
+// ================================================================================= transmit
+
+/// `ip.Stack.send`. The slice is borrowed for the duration of this call only, which is exactly what
+/// the transport needs - see note 4 in the header.
+fn sendFrame(frame: []const u8) void {
+ if (tx_fn == null) {
+ counters.tx_no_channel += 1;
+ return;
+ }
+ if (frame.len == 0 or frame.len > ip.frame_max) {
+ counters.tx_bad += 1;
+ return;
+ }
+ // Queued, never transmitted from here. See `flushTx`.
+ const next = (tx_ring.head + 1) % tx_ring_slots;
+ if (next == tx_ring.tail) {
+ counters.tx_ring_full += 1;
+ return;
+ }
+ @memcpy(tx_ring.slot[tx_ring.head][0..frame.len], frame);
+ tx_ring.len[tx_ring.head] = @intCast(frame.len);
+ tx_ring.head = next;
+ counters.tx_queued += 1;
+}
+
+/// Hand every queued frame to ESP-Hosted. MUST be called only from a task that may block.
+///
+/// This indirection is the fix for a deadlock the board demonstrated, and it is worth stating
+/// exactly because the shape of it is not obvious.
+///
+/// `ip.Stack.onFrame` answers things: an ARP request gets a reply, an ICMP echo gets an echo, a TCP
+/// segment gets an ACK. So a received frame turns into a transmitted frame inside `onFrame`. But
+/// `onFrame` runs on ESP-Hosted's `sdio_process_rx_task`, and transmitting ends in
+/// `_h_queue_item(to_slave_queue, HOSTED_BLOCK_MAX)` (sdio_drv.c:1607), which SUSPENDS the caller
+/// when the queue is full. Suspend the RX task and it stops draining the receive queue; the receive
+/// queue fills; ESP-Hosted logs "task still writing Rx data to queue!" and stops delivering.
+/// Everything then looks like a dead IP stack.
+///
+/// Measured on the board before this change: frames received froze at 17 and never advanced again,
+/// no ping was ever answered, and the HTTP GET failed with HostUnreachable because the ARP reply it
+/// needed was never sent. Raising the SDIO queue depth from 4 to 16 only moved the number.
+///
+/// So the receive path now only ever copies into this ring, which cannot block, and the application
+/// task drains it from `tick`. The cost is one copy and `tx_ring_slots * frame_max` of .bss.
+fn flushTx() void {
+ const tx = tx_fn orelse return;
+ while (tx_ring.tail != tx_ring.head) {
+ const i = tx_ring.tail;
+ const n = tx_ring.len[i];
+ counters.tx_frames += 1;
+ // The const cast is sound and it is load-bearing that it is: `transport_drv_sta_tx` reads
+ // `buffer` exactly once, as the source of a memcpy into its own aligned buffer
+ // (transport_drv.c:381), and neither writes through it nor retains it. ESP-Hosted's
+ // signature is simply not const-correct.
+ const rc = tx(@ptrCast(&channel_cookie), @ptrCast(&tx_ring.slot[i]), n);
+ if (rc != 0) counters.tx_failed += 1;
+ // Advance only after the call returns, so a frame is never handed out twice.
+ tx_ring.tail = (i + 1) % tx_ring_slots;
+ }
+}
+
+/// Outgoing frames waiting for a task that may block.
+///
+/// Four slots, at `ip.frame_max` each. Enough that the replies one pass of received frames can
+/// generate - an ARP answer, an ICMP echo, a TCP ACK - all fit, since the whole ring is drained on
+/// the very next `tick`. A full ring drops the newest frame and counts it, which is what a real
+/// network interface does under load, and every protocol above this retransmits.
+///
+/// Deliberately small: this is .bss competing with the heap ESP-Hosted allocates every received
+/// frame from, and eight slots cost 12 KB that the transport needs more than this ring does.
+const tx_ring_slots = 4;
+
+var tx_ring: struct {
+ slot: [tx_ring_slots][ip.frame_max]u8 = undefined,
+ len: [tx_ring_slots]u16 = @splat(0),
+ head: usize = 0,
+ tail: usize = 0,
+} = .{};
+
+// ================================================================================== receive
+
+/// `transport_channel_rx_fn_t`. Called from ESP-Hosted's `sdio_process_rx_task`
+/// (sdio_drv.c:1407), which is one of the tasks `port.zig` spawned on this project's own runtime.
+///
+/// The buffer is ours the moment this is entered, and it is freed on every path. See notes 1 and 2.
+fn onRxFrame(
+ h: ?*anyopaque,
+ buffer: ?*anyopaque,
+ buff_to_free: ?*anyopaque,
+ len: usize,
+) callconv(.c) c_int {
+ // `HOSTED_FREE(buff)` is `g_h.funcs->_h_free(buff)` (port_esp_hosted_host_os.h:139), and this
+ // is that call. First statement in the function so that no early return can miss it: the
+ // failure mode of a missed free here is not a leak that shows up in a heap report, it is the
+ // 32 KiB heap exhausted in a few seconds of the AP's broadcast traffic.
+ defer port.g_h.funcs.free(buff_to_free);
+
+ counters.rx_frames += 1;
+
+ if (h != @as(?*anyopaque, @ptrCast(&channel_cookie))) {
+ counters.rx_wrong_channel += 1;
+ return 0;
+ }
+ const bytes: [*]const u8 = @ptrCast(buffer orelse {
+ counters.rx_bad += 1;
+ return 0;
+ });
+ if (!opened or len == 0 or len > ip.frame_max) {
+ counters.rx_bad += 1;
+ return 0;
+ }
+ if (in_stack) {
+ counters.rx_reentrant += 1;
+ return 0;
+ }
+
+ in_stack = true;
+ defer in_stack = false;
+ counters.rx_delivered += 1;
+ sta.onFrame(bytes[0..len]);
+ return 0;
+}
+
+// ================================================================================ lifecycle
+
+pub const Error = error{
+ /// `hosted_wifi_get_mac` failed, or answered with the all-zero MAC that means "no radio yet".
+ /// The usual cause is calling this before `hosted_wifi_sta_start`.
+ MacUnavailable,
+ /// `transport_drv_add_channel` refused, or accepted without filling in the transmit function.
+ ChannelRegisterFailed,
+ AlreadyOpen,
+};
+
+/// Register the station channel and bring the IP stack up behind it.
+///
+/// Call after `net.init` and after `hosted_wifi_sta_start`; association may follow or may already
+/// have happened, it makes no difference to this. Registering *before* associating is the tidier
+/// order, because `chan_arr[ESP_STA_IF]` becoming non-null is the moment sdio_drv.c stops
+/// discarding station frames, and until then a live association fills ESP-Hosted's receive queue
+/// and logs "task still writing Rx data to queue!".
+///
+/// The order inside matters: the stack is constructed *before* the channel is registered. The
+/// instant `transport_drv_add_channel` returns, `sdio_process_rx_task` may call `onRxFrame`, and
+/// that must not find `sta` uninitialised.
+pub fn open() Error!void {
+ if (opened) return error.AlreadyOpen;
+
+ var mac_bytes: [6]u8 = @splat(0);
+ if (hosted_wifi_get_mac(&mac_bytes) != 0) return error.MacUnavailable;
+ // An all-zero MAC is not a MAC. It is what the shim hands back if the coprocessor answered
+ // without having a station interface, and building an ARP cache on it would produce a stack
+ // that transmits frames no switch will ever route back.
+ if (std.mem.allEqual(u8, &mac_bytes, 0)) return error.MacUnavailable;
+
+ sta = .init(mac_bytes, &sendFrame);
+
+ var tx: ?TxFn = null;
+ const channel = transport_drv_add_channel(
+ @ptrCast(&channel_cookie),
+ esp_sta_if,
+ 0, // secure=0: plain text, as ESP-Hosted itself uses for the two Wi-Fi interfaces
+ // (esp_hosted_api.c:105-107). The secure path is the RPC channel's, and RPC has
+ // its own already.
+ &tx,
+ &onRxFrame,
+ );
+ if (channel == null) return error.ChannelRegisterFailed;
+ // Belt and braces: the switch at transport_drv.c:467-485 is the only writer of `*tx`, and the
+ // one branch that leaves it untouched also returns NULL. Checking both means a future
+ // ESP-Hosted that separates those cannot leave us with a live channel and no way to transmit.
+ tx_fn = tx orelse return error.ChannelRegisterFailed;
+
+ opened = true;
+}
+
+/// True once `open` has succeeded.
+pub fn isOpen() bool {
+ return opened;
+}
+
+// ============================================================ the guarded entry points
+//
+// Every function that can mutate the stack goes through `in_stack`. Every function that only reads
+// it does not, because a read cannot suspend and the worst it can observe is a value one frame out
+// of date.
+
+/// Advance the stack's clock. Returns false if the stack was busy and the tick was skipped, which
+/// is harmless - see note 5 - but worth being able to see.
+pub fn tick(now_ms: u64) bool {
+ if (in_stack) {
+ counters.tick_skipped += 1;
+ return false;
+ }
+ in_stack = true;
+ sta.tick(now_ms);
+ in_stack = false;
+ // Outside the guard, and last: draining may block, and `in_stack` must not be held across a
+ // suspension or the receive path would drop every frame that arrived while we waited.
+ flushTx();
+ return true;
+}
+
+/// Begin DHCP. Call `tick` at least once first: `dhcpStart` stamps the acquisition's start time
+/// from the stack's idea of now, which only `tick` sets. Returns false if the stack was busy.
+pub fn dhcpStart() bool {
+ if (in_stack) return false;
+ in_stack = true;
+ defer in_stack = false;
+ sta.dhcpStart();
+ return true;
+}
+
+/// Configure statically instead of asking a server.
+pub fn setStatic(addr: [4]u8, mask: [4]u8, gw: [4]u8) bool {
+ if (in_stack) return false;
+ in_stack = true;
+ defer in_stack = false;
+ sta.setStatic(addr, mask, gw);
+ return true;
+}
+
+/// Override the resolver `resolve` asks. Not needed on a network whose DHCP server offers one -
+/// `dhcpBind` stores option 6 and `resolve` uses it with no configuration at all. Returns false if
+/// the stack was busy.
+pub fn setDnsServer(addr: [4]u8) bool {
+ if (in_stack) return false;
+ in_stack = true;
+ defer in_stack = false;
+ sta.setDnsServer(addr);
+ return true;
+}
+
+/// One HTTP GET, with the address literal as the `Host:` header. `ip.Stack.httpGet`'s protocol,
+/// unchanged: this returns `error.WouldBlock` until the body is complete, and the caller must keep
+/// calling with *identical* arguments while driving `tick`. `out` is borrowed until a length comes
+/// back.
+pub fn httpGet(host: [4]u8, remote_port: u16, path: []const u8, out: []u8) ip.HttpError!usize {
+ return httpGetHost(host, null, remote_port, path, out);
+}
+
+/// The same, with an explicit `Host:` name for a name-based virtual host. See
+/// `ip.Stack.httpGetHost`; `name` is part of the request's identity, so it must not change between
+/// calls any more than `path` may.
+pub fn httpGetHost(
+ host: [4]u8,
+ name: ?[]const u8,
+ remote_port: u16,
+ path: []const u8,
+ out: []u8,
+) ip.HttpError!usize {
+ if (in_stack) {
+ // Answering the caller's own protocol back at it. The alternative - waiting - would be a
+ // second place in this file that can block, and the guard exists to have exactly none.
+ counters.http_deferred += 1;
+ return error.WouldBlock;
+ }
+ in_stack = true;
+ defer in_stack = false;
+ return sta.httpGetHost(host, name, remote_port, path, out);
+}
+
+/// Resolve a name to an address. `ip.Stack.resolve`'s protocol, which is `httpGet`'s: this returns
+/// `error.WouldBlock` until an address or a real error comes back, and the caller keeps calling
+/// with the same name while driving `tick`.
+///
+/// The guard's answer is the same `error.WouldBlock`, for the same reason it is in `httpGetHost`:
+/// the query's own retransmissions run in `sta.tick`, so a deferred poll costs nothing and the 7 s
+/// bound still holds. Frames the query sends go through `sendFrame` into the deferred ring like
+/// every other frame here - nothing on this path touches the transport's tx function directly.
+pub fn resolve(name: []const u8) ip.DnsError!ip.Ip4 {
+ if (in_stack) {
+ counters.dns_deferred += 1;
+ return error.WouldBlock;
+ }
+ in_stack = true;
+ defer in_stack = false;
+ return sta.resolve(name);
+}
+
+// ==================================================================== read-only accessors
+
+/// The station MAC the stack was built on.
+pub fn mac() [6]u8 {
+ return sta.mac;
+}
+
+/// The configured address, or null if there is none yet.
+pub fn address() ?[4]u8 {
+ return sta.addr;
+}
+
+pub fn netmask() [4]u8 {
+ return sta.mask;
+}
+
+pub fn gateway() [4]u8 {
+ return sta.gw;
+}
+
+pub fn dnsServer() ?[4]u8 {
+ return sta.dns;
+}
+
+pub fn dhcpState() ip.DhcpState {
+ return sta.dhcp.state;
+}
+
+pub fn tcpState() ip.TcpState {
+ return sta.tcp.state;
+}
+
+pub fn httpStatus() u16 {
+ return sta.http.status;
+}
+
+/// The IP stack's own counters: frames in, frames dropped, echoes answered, checksums rejected.
+pub fn ipCounters() ip.Counters {
+ return sta.counters;
+}
+
+/// This file's counters: the transport boundary, and every way the guard fired.
+pub fn stats() Stats {
+ return counters;
+}
+
+// There are no tests here, and that is an answer rather than an omission. Two of the three things
+// this file does are calls into ESP-Hosted's C - `transport_drv_add_channel` and the transmit
+// function it hands back - and the third is a callback that C invokes. A host test could only
+// exercise it against a mock of the very code whose conventions are the thing in doubt, and it
+// would pass just as happily against a mock that put the frame one header too late. The evidence
+// that matters is the citations in this file's header and a board that answers a ping.
diff --git a/src/net/port.zig b/src/net/port.zig
new file mode 100644
index 0000000..fafbf4e
--- /dev/null
+++ b/src/net/port.zig
@@ -0,0 +1,2194 @@
+//! ESP-Hosted's `g_h.funcs` port table, in Zig.
+//!
+//! This is the seam. Above it sit ~13,000 lines of ESP-Hosted C - the SDIO transport state machine,
+//! the RPC protocol, the protobuf codec - which are already correct and which this project has no
+//! intention of rewriting. Below it sit `std.Io`, `std.mem.Allocator` and `src/hal`. Everything
+//! ESP-Hosted asks of an operating system passes through the 71 function pointers defined here, so
+//! this file is the entire dependency of that C on FreeRTOS and ESP-IDF, and replacing it replaces
+//! both.
+//!
+//! # The struct, and why its layout is the dangerous part
+//!
+//! `hosted_osi_funcs_t` is declared at `host/esp_hosted_os_abstraction.h:13-117`. Every member is a
+//! function pointer, so on rv32 the struct is 71 words and **there is nothing in the type system,
+//! on either side, that notices a field in the wrong place**. A mis-ordered pointer is a call to
+//! the wrong function with the wrong arguments, which on this board is a hang with no console
+//! output.
+//!
+//! Worse, the C struct is not one layout. Four mempool members are guarded by
+//! `#ifdef H_USE_MEMPOOL` (`:64-69`), and `H_USE_MEMPOOL` is *always defined* - to 1 or to 0 - by
+//! `host/port/esp/freertos/include/port_esp_hosted_host_config.h:127-131`, which `#ifdef` does not
+//! care about. A translation unit that reaches the struct without having seen that header first
+//! gets a struct 16 bytes shorter, with everything from `_h_config_gpio` onward displaced by four
+//! pointers. That is reachable in the real tree: `host/esp_hosted.h:14` and
+//! `host/drivers/transport/transport_util.h:10` both include the abstraction header as their first
+//! include. Measured with our own flags:
+//!
+//! without -include port_esp_hosted_host_config.h: sizeof=268 _h_config_gpio=132 _h_event_post=264
+//! with -include port_esp_hosted_host_config.h: sizeof=284 _h_config_gpio=148 _h_event_post=280
+//!
+//! This file targets the long layout, and `layout_check` below asserts the three numbers on the
+//! right. The build force-includes that header into every ESP-Hosted translation unit and compares
+//! C's `offsetof` against these assertions, so an include-order change fails the build instead of
+//! the board.
+//!
+//! # What is real, what is a loud stub
+//!
+//! Real: memory, sync, threads, timers, time, GPIO, SDIO, events, mempool locks. That is every
+//! entry the SDIO transport and the RPC layer touch, established by grepping the tree for each
+//! `_h_` name rather than by guessing.
+//!
+//! Loud stubs: the SPI, SPI-HD and UART transports (a different bus), power-save (needs
+//! `esp_sleep`), `_h_do_bus_transfer` (SPI-only; ESP-IDF leaves it null under SDIO), and
+//! `_h_printf`. Each prints its own name through `ets_printf` and returns a failure code, so an
+//! unimplemented path announces itself on the console instead of jumping through a null pointer.
+//! `stub_calls` counts them.
+//!
+//! # Where ESP-Hosted's assumptions do not fit a cooperative single-core runtime
+//!
+//! Four places, all documented at the point of impact:
+//!
+//! * `_h_post_semaphore_from_isr` - FreeRTOS manipulates the semaphore inside a critical section
+//! and requests a context switch on return. See `hosted_os.Semaphore.postFromIsr`.
+//! * `_h_thread_cancel` - `vTaskDelete` kills a task where it stands; `std.Io`'s cancel asks and
+//! waits, and ESP-Hosted's task bodies never return. See `hosted_os.Thread.cancel`.
+//! * `_h_blocking_delay` - a deliberate busy-wait, which on a cooperative scheduler starves
+//! every other task for its duration. Unused in the tree; kept honest.
+//! * bounded waits - `std.Io` has no timed acquire for a mutex, semaphore or queue, so those
+//! poll. See `hosted_os.poll_interval_ms`. Unbounded waits, which is what every hot path uses,
+//! block properly.
+
+const std = @import("std");
+const assert = std.debug.assert;
+const Io = std.Io;
+const Allocator = std.mem.Allocator;
+
+const hal = @import("hal");
+const hheap = @import("heap.zig");
+const os = @import("hosted_os.zig");
+
+const ret = os.ret;
+
+/// `ets_printf` from the mask ROM. Declared here rather than imported from `soc` so this file's
+/// only module dependency is `hal`; the symbol comes from
+/// `components/esp_rom/esp32p4/ld/esp32p4.rom.ld`.
+extern fn ets_printf(fmt: [*:0]const u8, ...) c_int;
+
+fn note(comptime fmt: [*:0]const u8, args: anytype) void {
+ _ = @call(.auto, ets_printf, .{fmt} ++ args);
+}
+
+// ============================================================================ the struct
+
+/// `void (*start_routine)(void const *)`, `esp_hosted_os_abstraction.h:25`.
+pub const StartRoutine = *const fn (?*const anyopaque) callconv(.c) void;
+/// `void (*timeout_handler)(void *)`, `:60`.
+pub const TimerHandler = *const fn (?*anyopaque) callconv(.c) void;
+/// `void (*gpio_isr_handler)(void* arg)`, `:73`.
+pub const IsrHandler = *const fn (?*anyopaque) callconv(.c) void;
+/// `esp_event_base_t`, which is `const char *`.
+pub const EventBase = [*:0]const u8;
+
+/// `hosted_osi_funcs_t`, `host/esp_hosted_os_abstraction.h:13-117`, in the layout that
+/// `H_USE_MEMPOOL` being defined produces. Field order is the C declaration order exactly; the
+/// line number beside each is its declaration in that header.
+pub const HostedOsiFuncs = extern struct {
+ // ---- Memory, :15-22
+ /// :15 `void* (*)(void* dest, const void* src, uint32_t size)`
+ memcpy: *const fn (?*anyopaque, ?*const anyopaque, u32) callconv(.c) ?*anyopaque,
+ /// :16 `void* (*)(void* buf, int val, size_t len)`
+ memset: *const fn (?*anyopaque, c_int, usize) callconv(.c) ?*anyopaque,
+ /// :17 `void* (*)(size_t size)`
+ malloc: *const fn (usize) callconv(.c) ?*anyopaque,
+ /// :18 `void* (*)(size_t blk_no, size_t size)`
+ calloc: *const fn (usize, usize) callconv(.c) ?*anyopaque,
+ /// :19 `void (*)(void* ptr)`
+ free: *const fn (?*anyopaque) callconv(.c) void,
+ /// :20 `void* (*)(void *mem, size_t newsize)`
+ realloc: *const fn (?*anyopaque, usize) callconv(.c) ?*anyopaque,
+ /// :21 `void* (*)(size_t size, size_t align)`
+ malloc_align: *const fn (usize, usize) callconv(.c) ?*anyopaque,
+ /// :22 `void (*)(void* ptr)`
+ free_align: *const fn (?*anyopaque) callconv(.c) void,
+
+ // ---- Thread, :25-27
+ /// :25 `void* (*)(const char *tname, uint32_t tprio, uint32_t tstack_size, void (*start_routine)(void const *), void *sr_arg)`
+ thread_create: *const fn ([*:0]const u8, u32, u32, StartRoutine, ?*anyopaque) callconv(.c) ?*anyopaque,
+ /// :26 `int (*)(void *thread_handle)`
+ thread_cancel: *const fn (?*anyopaque) callconv(.c) c_int,
+ /// :27 `void (*)(void)`
+ thread_yield: *const fn () callconv(.c) void,
+
+ // ---- Sleeps, :30-32
+ /// :30 `unsigned int (*)(unsigned int mseconds)`
+ msleep: *const fn (c_uint) callconv(.c) c_uint,
+ /// :31 `unsigned int (*)(unsigned int useconds)`
+ usleep: *const fn (c_uint) callconv(.c) c_uint,
+ /// :32 `unsigned int (*)(unsigned int seconds)`
+ sleep: *const fn (c_uint) callconv(.c) c_uint,
+
+ // ---- Blocking non-sleepable delay, :35
+ /// :35 `unsigned int (*)(unsigned int number)`
+ blocking_delay: *const fn (c_uint) callconv(.c) c_uint,
+
+ // ---- Queue, :38-43
+ /// :38 `int (*)(void * queue_handle, void *item, int timeout)`
+ queue_item: *const fn (?*anyopaque, ?*const anyopaque, c_int) callconv(.c) c_int,
+ /// :39 `void* (*)(uint32_t qnum_elem, uint32_t qitem_size)`
+ create_queue: *const fn (u32, u32) callconv(.c) ?*anyopaque,
+ /// :40 `int (*)(void * queue_handle, void *item, int timeout)`
+ dequeue_item: *const fn (?*anyopaque, ?*anyopaque, c_int) callconv(.c) c_int,
+ /// :41 `int (*)(void * queue_handle)`
+ queue_msg_waiting: *const fn (?*anyopaque) callconv(.c) c_int,
+ /// :42 `int (*)(void * queue_handle)`
+ destroy_queue: *const fn (?*anyopaque) callconv(.c) c_int,
+ /// :43 `int (*)(void * queue_handle)`
+ reset_queue: *const fn (?*anyopaque) callconv(.c) c_int,
+
+ // ---- Mutex, :46-49. Note that unlock comes *first*.
+ /// :46 `int (*)(void * mutex_handle)`
+ unlock_mutex: *const fn (?*anyopaque) callconv(.c) c_int,
+ /// :47 `void* (*)(void)`
+ create_mutex: *const fn () callconv(.c) ?*anyopaque,
+ /// :48 `int (*)(void * mutex_handle, int timeout_ms)`
+ lock_mutex: *const fn (?*anyopaque, c_int) callconv(.c) c_int,
+ /// :49 `int (*)(void * mutex_handle)`
+ destroy_mutex: *const fn (?*anyopaque) callconv(.c) c_int,
+
+ // ---- Semaphore, :52-56. `post` precedes `create`, as with the mutex.
+ /// :52 `int (*)(void * semaphore_handle)`
+ post_semaphore: *const fn (?*anyopaque) callconv(.c) c_int,
+ /// :53 `int (*)(void * semaphore_handle)`
+ post_semaphore_from_isr: *const fn (?*anyopaque) callconv(.c) c_int,
+ /// :54 `void* (*)(int maxCount)`
+ create_semaphore: *const fn (c_int) callconv(.c) ?*anyopaque,
+ /// :55 `int (*)(void * semaphore_handle, int timeout_ms)`
+ get_semaphore: *const fn (?*anyopaque, c_int) callconv(.c) c_int,
+ /// :56 `int (*)(void * semaphore_handle)`
+ destroy_semaphore: *const fn (?*anyopaque) callconv(.c) c_int,
+
+ // ---- Timer, :59-61. `stop` precedes `start`.
+ /// :59 `int (*)(void *timer_handle)`
+ timer_stop: *const fn (?*anyopaque) callconv(.c) c_int,
+ /// :60 `void* (*)(const char *name, int duration_ms, int type, void (*timeout_handler)(void *), void *arg)`
+ timer_start: *const fn ([*:0]const u8, c_int, c_int, TimerHandler, ?*anyopaque) callconv(.c) ?*anyopaque,
+ /// :61 `uint64_t (*)(void)`
+ get_time_ms: *const fn () callconv(.c) u64,
+
+ // ---- Mempool, :65-68, present because H_USE_MEMPOOL is defined. See the file header.
+ /// :65 `void* (*)(void)`
+ create_lock_mempool: *const fn () callconv(.c) ?*anyopaque,
+ /// :66 `void (*)(void *lock_handle)`
+ lock_mempool: *const fn (?*anyopaque) callconv(.c) void,
+ /// :67 `void (*)(void *lock_handle)`
+ unlock_mempool: *const fn (?*anyopaque) callconv(.c) void,
+ /// :68 `void (*)(void *lock_handle)`
+ destroy_lock_mempool: *const fn (?*anyopaque) callconv(.c) void,
+
+ // ---- GPIO, :72-79
+ /// :72 `int (*)(void* gpio_port, uint32_t gpio_num, uint32_t mode)`
+ config_gpio: *const fn (?*anyopaque, u32, u32) callconv(.c) c_int,
+ /// :73 `int (*)(void* gpio_port, uint32_t gpio_num, uint32_t intr_type, void (*gpio_isr_handler)(void* arg), void *arg)`
+ config_gpio_as_interrupt: *const fn (?*anyopaque, u32, u32, IsrHandler, ?*anyopaque) callconv(.c) c_int,
+ /// :74 `int (*)(void* gpio_port, uint32_t gpio_num)`
+ teardown_gpio_interrupt: *const fn (?*anyopaque, u32) callconv(.c) c_int,
+ /// :75 `int (*)(void* gpio_port, uint32_t gpio_num)`
+ read_gpio: *const fn (?*anyopaque, u32) callconv(.c) c_int,
+ /// :76 `int (*)(void* gpio_port, uint32_t gpio_num, uint32_t value)`
+ write_gpio: *const fn (?*anyopaque, u32, u32) callconv(.c) c_int,
+ /// :77 `int (*)(void* gpio_port, uint32_t gpio_num, uint32_t pull_value, uint32_t enable)`
+ pull_gpio: *const fn (?*anyopaque, u32, u32, u32) callconv(.c) c_int,
+ /// :78 `int (*)(void* gpio_port, uint32_t gpio_num, uint32_t hold_value)`
+ hold_gpio: *const fn (?*anyopaque, u32, u32) callconv(.c) c_int,
+ /// :79 `int (*)(void)`
+ get_host_wakeup_or_reboot_reason: *const fn () callconv(.c) c_int,
+
+ // ---- All transports, :81-82
+ /// :81 `void * (*)(void)`
+ bus_init: *const fn () callconv(.c) ?*anyopaque,
+ /// :82 `int (*)(void*)`
+ bus_deinit: *const fn (?*anyopaque) callconv(.c) c_int,
+
+ // ---- :84-88
+ /// :84 `int (*)(void *transfer_context)` - SPI only; ESP-IDF leaves this null under SDIO.
+ do_bus_transfer: *const fn (?*anyopaque) callconv(.c) c_int,
+ /// :85 `int (*)(int32_t event_id, void* event_data, size_t event_data_size, uint32_t ticks_to_wait)`
+ event_wifi_post: *const fn (i32, ?*anyopaque, usize, u32) callconv(.c) c_int,
+ /// :87 `void (*)(int level, const char *tag, const char *format, ...)`
+ printf: *const fn (c_int, [*:0]const u8, [*:0]const u8, ...) callconv(.c) void,
+ /// :88 `void (*)(void)`
+ hosted_init_hook: *const fn () callconv(.c) void,
+
+ // ---- Transport - SDIO, :91-97
+ /// :91 `int (*)(void *ctx, bool show_config)`
+ sdio_card_init: *const fn (?*anyopaque, bool) callconv(.c) c_int,
+ /// :92 `int (*)(void*ctx)`
+ sdio_card_deinit: *const fn (?*anyopaque) callconv(.c) c_int,
+ /// :93 `int (*)(void *ctx, uint32_t reg, uint8_t *data, uint16_t size, bool lock_required)`
+ sdio_read_reg: *const fn (?*anyopaque, u32, [*]u8, u16, bool) callconv(.c) c_int,
+ /// :94 same
+ sdio_write_reg: *const fn (?*anyopaque, u32, [*]u8, u16, bool) callconv(.c) c_int,
+ /// :95 same
+ sdio_read_block: *const fn (?*anyopaque, u32, [*]u8, u16, bool) callconv(.c) c_int,
+ /// :96 same
+ sdio_write_block: *const fn (?*anyopaque, u32, [*]u8, u16, bool) callconv(.c) c_int,
+ /// :97 `int (*)(void *ctx, uint32_t ticks_to_wait)`
+ sdio_wait_slave_intr: *const fn (?*anyopaque, u32) callconv(.c) c_int,
+
+ // ---- Transport - SPI HD, :100-105
+ /// :100 `int (*)(uint32_t reg, uint32_t *data, int poll, bool lock_required)`
+ spi_hd_read_reg: *const fn (u32, *u32, c_int, bool) callconv(.c) c_int,
+ /// :101 `int (*)(uint32_t reg, uint32_t *data, bool lock_required)`
+ spi_hd_write_reg: *const fn (u32, *u32, bool) callconv(.c) c_int,
+ /// :102 `int (*)(uint8_t *data, uint16_t size, bool lock_required)`
+ spi_hd_read_dma: *const fn ([*]u8, u16, bool) callconv(.c) c_int,
+ /// :103 same
+ spi_hd_write_dma: *const fn ([*]u8, u16, bool) callconv(.c) c_int,
+ /// :104 `int (*)(uint32_t data_lines)`
+ spi_hd_set_data_lines: *const fn (u32) callconv(.c) c_int,
+ /// :105 `int (*)(void)`
+ spi_hd_send_cmd9: *const fn () callconv(.c) c_int,
+
+ // ---- Transport - UART, :108-110
+ /// :108 `int (*)(void *ctx, uint8_t *data, uint16_t size)`
+ uart_read: *const fn (?*anyopaque, [*]u8, u16) callconv(.c) c_int,
+ /// :109 same
+ uart_write: *const fn (?*anyopaque, [*]u8, u16) callconv(.c) c_int,
+ /// :110 `int (*)(void *ctx)`
+ uart_flush_input: *const fn (?*anyopaque) callconv(.c) c_int,
+
+ /// :112 `int (*)(void)`
+ restart_host: *const fn () callconv(.c) c_int,
+
+ /// :114 `int (*)(uint32_t power_save_type, void* gpio_port, uint32_t gpio_num, int level)`
+ config_host_power_save_hal_impl: *const fn (u32, ?*anyopaque, u32, c_int) callconv(.c) c_int,
+ /// :115 `int (*)(uint32_t power_save_type)`
+ start_host_power_save_hal_impl: *const fn (u32) callconv(.c) c_int,
+ /// :116 `int (*)(esp_event_base_t event_base, int32_t event_id, void* event_data, size_t event_data_size, uint32_t ticks_to_wait)`
+ event_post: *const fn (EventBase, i32, ?*anyopaque, usize, u32) callconv(.c) c_int,
+};
+
+/// `struct hosted_config_t`, `esp_hosted_os_abstraction.h:119-121`.
+pub const HostedConfig = extern struct {
+ funcs: *const HostedOsiFuncs,
+};
+
+/// The three numbers the C side must agree on. Measured from C with the force-include in place;
+/// the build re-measures and compares, so this is a contract and not a comment.
+pub const layout_check = struct {
+ pub const sizeof: usize = 284;
+ pub const offset_config_gpio: usize = 148;
+ pub const offset_event_post: usize = 280;
+};
+
+comptime {
+ if (@sizeOf(usize) != 4) @compileError(
+ "this layout is rv32-specific: 71 pointers at 4 bytes each. Re-measure offsetof on any other target.",
+ );
+ assert(@sizeOf(HostedOsiFuncs) == layout_check.sizeof);
+ assert(@offsetOf(HostedOsiFuncs, "config_gpio") == layout_check.offset_config_gpio);
+ assert(@offsetOf(HostedOsiFuncs, "event_post") == layout_check.offset_event_post);
+ // Every member is one pointer, so the count is derivable and worth asserting: a field
+ // accidentally deleted or duplicated changes this even when the size happens to survive.
+ assert(std.meta.fields(HostedOsiFuncs).len == 71);
+ assert(@sizeOf(HostedOsiFuncs) == 71 * @sizeOf(usize));
+}
+
+// ============================================================================ the exported table
+
+/// The table itself. `HOSTED_CONFIG_INIT_DEFAULT` points `g_h.funcs` here
+/// (`esp_hosted_os_abstraction.h:125-127`), and `port_esp_hosted_host_os.c:938` is the definition
+/// this replaces.
+pub export const g_hosted_osi_funcs: HostedOsiFuncs = .{
+ .memcpy = hostedMemcpy,
+ .memset = hostedMemset,
+ .malloc = hostedMalloc,
+ .calloc = hostedCalloc,
+ .free = hostedFree,
+ .realloc = hostedRealloc,
+ .malloc_align = hostedMallocAlign,
+ .free_align = hostedFreeAlign,
+
+ .thread_create = hostedThreadCreate,
+ .thread_cancel = hostedThreadCancel,
+ .thread_yield = hostedThreadYield,
+
+ .msleep = hostedMsleep,
+ .usleep = hostedUsleep,
+ .sleep = hostedSleep,
+ .blocking_delay = hostedBlockingDelay,
+
+ .queue_item = hostedQueueItem,
+ .create_queue = hostedCreateQueue,
+ .dequeue_item = hostedDequeueItem,
+ .queue_msg_waiting = hostedQueueMsgWaiting,
+ .destroy_queue = hostedDestroyQueue,
+ .reset_queue = hostedResetQueue,
+
+ .unlock_mutex = hostedUnlockMutex,
+ .create_mutex = hostedCreateMutex,
+ .lock_mutex = hostedLockMutex,
+ .destroy_mutex = hostedDestroyMutex,
+
+ .post_semaphore = hostedPostSemaphore,
+ .post_semaphore_from_isr = hostedPostSemaphoreFromIsr,
+ .create_semaphore = hostedCreateSemaphore,
+ .get_semaphore = hostedGetSemaphore,
+ .destroy_semaphore = hostedDestroySemaphore,
+
+ .timer_stop = hostedTimerStop,
+ .timer_start = hostedTimerStart,
+ .get_time_ms = hostedGetTimeMs,
+
+ .create_lock_mempool = hostedCreateLockMempool,
+ .lock_mempool = hostedLockMempool,
+ .unlock_mempool = hostedUnlockMempool,
+ .destroy_lock_mempool = hostedDestroyLockMempool,
+
+ .config_gpio = hostedConfigGpio,
+ .config_gpio_as_interrupt = hostedConfigGpioAsInterrupt,
+ .teardown_gpio_interrupt = hostedTeardownGpioInterrupt,
+ .read_gpio = hostedReadGpio,
+ .write_gpio = hostedWriteGpio,
+ .pull_gpio = hostedPullGpio,
+ .hold_gpio = hostedHoldGpio,
+ .get_host_wakeup_or_reboot_reason = hostedGetWakeupReason,
+
+ .bus_init = hostedBusInit,
+ .bus_deinit = hostedBusDeinit,
+
+ .do_bus_transfer = stubDoBusTransfer,
+ .event_wifi_post = hostedEventWifiPost,
+ .printf = stubPrintf,
+ .hosted_init_hook = hostedInitHook,
+
+ .sdio_card_init = hostedSdioCardInit,
+ .sdio_card_deinit = hostedSdioCardDeinit,
+ .sdio_read_reg = hostedSdioReadReg,
+ .sdio_write_reg = hostedSdioWriteReg,
+ .sdio_read_block = hostedSdioReadBlock,
+ .sdio_write_block = hostedSdioWriteBlock,
+ .sdio_wait_slave_intr = hostedSdioWaitSlaveIntr,
+
+ .spi_hd_read_reg = stubSpiHdReadReg,
+ .spi_hd_write_reg = stubSpiHdWriteReg,
+ .spi_hd_read_dma = stubSpiHdReadDma,
+ .spi_hd_write_dma = stubSpiHdWriteDma,
+ .spi_hd_set_data_lines = stubSpiHdSetDataLines,
+ .spi_hd_send_cmd9 = stubSpiHdSendCmd9,
+
+ .uart_read = stubUartRead,
+ .uart_write = stubUartWrite,
+ .uart_flush_input = stubUartFlushInput,
+
+ .restart_host = hostedRestartHost,
+
+ .config_host_power_save_hal_impl = stubConfigHostPowerSave,
+ .start_host_power_save_hal_impl = stubStartHostPowerSave,
+ .event_post = hostedEventPost,
+};
+
+/// `extern struct hosted_config_t g_h;` (`esp_hosted_os_abstraction.h:129`). Statically
+/// initialised, because C reads `g_h.funcs->...` and nothing guarantees `install` ran first - it is
+/// the *state* behind the functions that needs installing, not the pointer to them.
+pub export var g_h: HostedConfig = .{ .funcs = &g_hosted_osi_funcs };
+
+// ============================================================================ installed state
+
+/// Board wiring and sizing. Compile-time so the static footprint is a build-time number.
+pub const Config = struct {
+ /// The C6's reset/enable pin. GPIO54 on this board (`sdkconfig:4557`,
+ /// `CONFIG_ESP_HOSTED_GPIO_SLAVE_RESET_SLAVE=54`).
+ ///
+ /// It has an external pull-up, so the *released* state is the one the pull-up wins. ESP-Hosted
+ /// drives `H_RESET_VAL_ACTIVE` last (`sdio_drv.c:1651-1657`), and with
+ /// `CONFIG_ESP_HOSTED_RESET_GPIO_ACTIVE_LOW` unset - which is how the working IDF build on this
+ /// board was configured - `H_RESET_VAL_ACTIVE` is `H_GPIO_HIGH`
+ /// (`port_esp_hosted_host_config.h:445-451`). So the sequence is high, low, high: a reset pulse
+ /// that ends released. Invert that and the radio stays in reset for ever.
+ reset_pin: u8 = 54,
+
+ /// CLIC external line for the GPIO interrupt aggregate (`hal.intr.Source.gpio_intr0`).
+ gpio_clic_line: u5 = 20,
+ /// CLIC external line for the SDMMC host, which is where the C6's D1 slave interrupt arrives.
+ sdio_clic_line: u5 = 21,
+
+ /// Software timer slots. ESP-Hosted arms at most three at once: the slave-unresponsive timer
+ /// (`transport_drv.c:188`), a per-request asynchronous RPC timeout (`rpc_core.c:215`), and the
+ /// power-save timer. Four leaves one spare and costs 96 bytes.
+ timer_slots: usize = 4,
+
+ /// Pads whose interrupt can be registered at once. The SDIO transport registers none; SPI
+ /// registers two. Four is generous and costs 48 bytes.
+ gpio_isr_slots: usize = 4,
+
+ /// Wait for the C6's D1 slave interrupt through the CLIC, or poll for it.
+ ///
+ /// `true`. The reason it was `false` is worth keeping written down, because it was a
+ /// misdiagnosis rather than a hardware limit.
+ ///
+ /// Every attempt printed `MARK PORT_SDIO_LAPSE ... intmask=0x00000000`, and that was read as
+ /// "the unmask does not stick". It never said that: the print happens *after*
+ /// `disarmSdioLine()`, which had just written that zero on purpose, and the other witness -
+ /// `hal.sdmmc.interruptDiagnostics` - runs on the application task, which is never inside an
+ /// arming window. `hal.sdmmc.armSlaveInterrupt` now reads INTMASK back inside the same masked
+ /// region as the store, so the claim is finally testable: `stuck=` on `MARK PORT_SDIO_ARM`.
+ ///
+ /// What was really missing is `takeInterruptControl`. Nothing in this build had ever called
+ /// `hal.intr.init()` - `examples/intrcheck.zig` and `examples/portcheck.zig` do,
+ /// `examples/http.zig` and `examples/radio.zig` do not, and nothing under `src/` did either -
+ /// so mtvec still belonged to the bootloader, the threshold was never opened, and mstatus.MIE
+ /// was never this image's decision. A CLIC line enabled in that state either cannot be
+ /// delivered at all, which is a LAPSE every window for ever, or is delivered *outside this
+ /// image*, which is the "board goes silent right after Open data path at slave" that was
+ /// blamed on a storm.
+ ///
+ /// Not verified on hardware by the author of this change. Two nets remain under it: the
+ /// bounded re-look (`sdio_relook_ms`) carries the transport through any window the interrupt
+ /// misses, and `sdio_foreign_limit` consecutive unexplained handler entries abandon the line
+ /// for `sdioPoll` permanently. Set this to `false` to isolate a regression against the proven
+ /// polling path; nothing else has to change, and with it false the CLIC is not touched at all.
+ sdio_use_interrupt: bool = true,
+};
+
+pub const config: Config = .{};
+
+const State = struct {
+ io: Io = undefined,
+ /// The allocator handed to `install`. Used directly for OS-object handles, and wrapped by
+ /// `cheap` for everything C allocates.
+ gpa: Allocator = undefined,
+ cheap: hheap.CHeap = undefined,
+ timers: os.TimerService(config.timer_slots) = .{},
+ installed: bool = false,
+
+ /// The bus context `_h_bus_init` hands to C and C hands back to every `_h_sdio_*` call. Its
+ /// *identity* is all that matters - ESP-IDF returns `&context`, a file-static - so this is a
+ /// single static object and a null `ctx` from C is a real error rather than a second bus.
+ bus: BusContext = .{},
+
+ /// Called with every event ESP-Hosted posts. Association and DHCP-relevant events arrive here.
+ on_event: ?*const fn (Event) void = null,
+
+ /// Deferred wake word for the SDIO slave interrupt. The ISR bumps it and wakes; the waiter
+ /// futex-waits on it.
+ sdio_intr_epoch: std.atomic.Value(u32) = .init(0),
+
+ /// RINTSTS and IDSTS as `sdioDispatch` saw them at entry. Both are sticky, so reading them
+ /// after the handler has disarmed loses nothing. INTMASK is *not* sticky and is deliberately
+ /// absent here: the disarm has just rewritten it, so a handler-entry read of it could only ever
+ /// return the disarmed value. What the mask really was is `sdio_armed_intmask`.
+ sdio_intr_rintsts: std.atomic.Value(u32) = .init(0),
+ sdio_intr_idsts: std.atomic.Value(u32) = .init(0),
+
+ /// INTMASK and MINTSTS as `hal.sdmmc.armSlaveInterrupt` read them back, inside the same masked
+ /// region as the store that armed them. Written and read only by the waiting task, so plain
+ /// words rather than atomics.
+ sdio_armed_intmask: u32 = 0,
+ sdio_armed_mintsts: u32 = 0,
+
+ /// Consecutive handler entries whose cause was not the card interrupt. Reset by any real one.
+ /// At `sdio_foreign_limit` the wait stops using the interrupt at all.
+ sdio_intr_foreign: u32 = 0,
+
+ /// Arming windows that lapsed with no handler entry. **At idle this is the normal state and
+ /// says nothing is wrong**: the C6 has nothing to report, so no interrupt arrives inside
+ /// `sdio_relook_ms`, the re-look finds nothing either, and the wait goes round again. It is
+ /// counted and printed because a *rising* count with frames flowing is how the re-look
+ /// carrying the transport announces itself.
+ sdio_intr_lapses: u32 = 0,
+
+ /// Consecutive lapsed windows in which the re-look then found the card *already calling* -
+ /// the pad low or the latch set. That is the failure that matters, and it is the only reading
+ /// that separates "the interrupt is not being delivered" from "the card is quiet": an idle
+ /// card lapses for free, a calling card whose interrupt did not arrive costs a real frame up
+ /// to `sdio_relook_ms` of latency.
+ ///
+ /// Reset by any wake the handler really delivered. At `sdio_missed_limit` the wait gives the
+ /// line up for `sdioPoll` permanently, which is what keeps the interrupt path from being
+ /// strictly worse than the 1 ms poll it replaces.
+ sdio_intr_missed: u32 = 0,
+
+ /// MINTSTS and RINTSTS as `sdioDispatch` read them, *before* it disarmed. MINTSTS is
+ /// `RINTSTS & INTMASK` and the disarm zeroes it, so this is the only place its value at the
+ /// moment of delivery survives - and it is the direct answer to "does MINTSTS ever show this
+ /// slot's bit".
+ sdio_intr_mintsts: std.atomic.Value(u32) = .init(0),
+
+ /// Remaining diagnostic lines, one budget per failure mode. See `sdioMark`.
+ sdio_foreign_marks: u32 = 0,
+ sdio_lapse_marks: u32 = 0,
+ sdio_missed_marks: u32 = 0,
+ sdio_arm_marks: u32 = 0,
+ sdio_wake_marks: u32 = 0,
+
+ /// Arming windows completed, for the periodic tally. Every budgeted MARK above eventually goes
+ /// quiet; this one does not, because "is the interrupt or the re-look carrying the transport"
+ /// is a question that stays interesting for the whole run.
+ sdio_windows: u32 = 0,
+
+ /// GPIO ISR registrations, indexed arbitrarily.
+ gpio_isrs: [config.gpio_isr_slots]GpioIsr = @splat(.{}),
+
+ /// `_h_sleep` calls. In the file set build.zig compiles this counts exactly one thing: the
+ /// two `if (!is_rpc_lib_ready()) _h_sleep(1)` loops at the head of `rpc_rx_thread` and
+ /// `rpc_tx_thread` (rpc_core.c:482-485, :543-547). The tree's only other `_h_sleep` callers
+ /// are transport_drv.c:693, which is followed by `assert(0!=0)`, and stats.c:115 in
+ /// `raw_tp_tx_task`, which is never created with TEST_RAW_TP off.
+ ///
+ /// So a count that keeps *growing* while a synchronous RPC request is outstanding means the
+ /// RPC lib state is not READY and the request will never be transmitted - the failure that
+ /// otherwise looks exactly like a coprocessor that does not answer. Two per second while
+ /// stuck, and it costs one add.
+ hosted_sleep_calls: u32 = 0,
+
+ /// Loud-stub call count. Nonzero after a run means a path nobody implemented was taken.
+ stub_calls: u32 = 0,
+};
+
+const GpioIsr = struct {
+ pin: u8 = 0xFF,
+ handler: ?IsrHandler = null,
+ arg: ?*anyopaque = null,
+};
+
+const BusContext = struct {
+ /// `hosted_sdio_init` creates this and every `SDIO_LOCK` takes it
+ /// (`port_esp_hosted_host_sdio.c:36-42, 395`).
+ lock: os.Mutex = .{},
+ up: bool = false,
+};
+
+var state: State = .{};
+
+/// An event ESP-Hosted posted. `base` distinguishes `WIFI_EVENT` (via `_h_event_wifi_post`) from
+/// `ESP_HOSTED_EVENT` and anything else (via `_h_event_post`).
+pub const Event = struct {
+ pub const Base = union(enum) {
+ wifi,
+ /// The `esp_event_base_t` string C passed, which is a pointer to a string literal owned by
+ /// the C side and valid for the lifetime of the program.
+ named: EventBase,
+ };
+ base: Base,
+ id: i32,
+ /// Borrowed for the duration of the callback only. ESP-IDF's `esp_event_post` copies;
+ /// this does not, so a handler that needs the data past its return must copy it.
+ data: ?[]const u8,
+};
+
+/// Bring the table's state up. Idempotent.
+///
+/// After this returns, C may call anything in `g_h.funcs`. Note what it does *not* do: it does not
+/// start a scheduler and it does not touch the radio. ESP-Hosted's own `esp_hosted_init` does that,
+/// and the tasks it spawns through `_h_thread_create` first execute when the calling context next
+/// blocks - `io.async` assigns a slot and marks it ready, it does not preempt. A caller that
+/// installs, initialises ESP-Hosted and then never blocks will see nothing happen.
+pub fn install(io: Io, gpa: Allocator) void {
+ state.io = io;
+ state.gpa = gpa;
+ state.cheap = .{ .gpa = gpa };
+ state.installed = true;
+ // The timer service owns one task; start it eagerly so `_h_timer_start` cannot fail for want
+ // of a scheduler.
+ if (!state.timers.start(io, gpa)) note("MARK PORT_TIMER_SERVICE_FAIL\r\n", .{});
+}
+
+/// Register the application's event sink. Association, disconnection and the slave's own lifecycle
+/// events arrive here; this is not a reimplementation of `esp_event`, it is one callback.
+pub fn setEventHandler(handler: ?*const fn (Event) void) void {
+ state.on_event = handler;
+}
+
+/// Diagnostics for a hardware self-test: heap use, whether any loud stub was reached, and whether
+/// ESP-Hosted's RPC threads are stuck in their not-ready loop. See `State.hosted_sleep_calls`.
+pub fn stats() struct {
+ bytes_live: usize,
+ bytes_reserved: usize,
+ peak_reserved: usize,
+ blocks_live: usize,
+ alloc_failures: usize,
+ stub_calls: u32,
+ hosted_sleep_calls: u32,
+} {
+ return .{
+ .bytes_live = state.cheap.bytes_live,
+ .bytes_reserved = state.cheap.bytes_reserved,
+ .peak_reserved = state.cheap.peak_reserved,
+ .blocks_live = state.cheap.blocks_live,
+ .alloc_failures = state.cheap.failures,
+ .stub_calls = state.stub_calls,
+ .hosted_sleep_calls = state.hosted_sleep_calls,
+ };
+}
+
+/// The SDIO card-interrupt path's counters, for a heartbeat that wants to say whether the radio is
+/// being woken or polled. Every field is a running total, none is reset by anything here.
+///
+/// `epoch` is handler entries. `foreign` is *consecutive* entries whose cause was not the card
+/// interrupt - at `sdio_foreign_limit` the wait abandons the interrupt for `sdioPoll`, so a
+/// non-zero `foreign` with a growing `epoch` means the line is being taken for the wrong reason.
+/// `lapses` is arming windows that produced no entry at all; at idle that is the resting state and
+/// costs nothing. `missed` is the subset of those whose re-look then found the card already
+/// calling, which is the one that matters - at `sdio_missed_limit` the wait abandons the interrupt
+/// too. `rintsts`/`idsts` are what the last handler entry saw; `armed_intmask` is what INTMASK read
+/// back at the last arm, which is the only reading of that register that means anything.
+pub fn sdioStats() struct {
+ epoch: u32,
+ foreign: u32,
+ lapses: u32,
+ missed: u32,
+ rintsts: u32,
+ idsts: u32,
+ armed_intmask: u32,
+} {
+ return .{
+ .epoch = state.sdio_intr_epoch.load(.acquire),
+ .foreign = state.sdio_intr_foreign,
+ .lapses = state.sdio_intr_lapses,
+ .missed = state.sdio_intr_missed,
+ .rintsts = state.sdio_intr_rintsts.load(.acquire),
+ .idsts = state.sdio_intr_idsts.load(.acquire),
+ .armed_intmask = state.sdio_armed_intmask,
+ };
+}
+
+inline fn currentIo() Io {
+ assert(state.installed);
+ return state.io;
+}
+
+// ============================================================================ 1. memory
+
+fn hostedMemcpy(dest: ?*anyopaque, src: ?*const anyopaque, size: u32) callconv(.c) ?*anyopaque {
+ // ESP-IDF asserts on a null pointer with a nonzero size (port_esp_hosted_host_os.c:67-76); the
+ // same condition, as a Zig assertion.
+ if (size == 0) return dest;
+ const d: [*]u8 = @ptrCast(dest.?);
+ const s: [*]const u8 = @ptrCast(src.?);
+ @memcpy(d[0..size], s[0..size]);
+ return dest;
+}
+
+fn hostedMemset(buf: ?*anyopaque, val: c_int, len: usize) callconv(.c) ?*anyopaque {
+ if (len == 0) return buf;
+ const b: [*]u8 = @ptrCast(buf.?);
+ @memset(b[0..len], @truncate(@as(c_uint, @bitCast(val))));
+ return buf;
+}
+
+fn hostedMalloc(size: usize) callconv(.c) ?*anyopaque {
+ assert(state.installed);
+ return @ptrCast(state.cheap.malloc(size));
+}
+
+fn hostedCalloc(blk_no: usize, size: usize) callconv(.c) ?*anyopaque {
+ assert(state.installed);
+ return @ptrCast(state.cheap.calloc(blk_no, size));
+}
+
+fn hostedFree(ptr: ?*anyopaque) callconv(.c) void {
+ assert(state.installed);
+ state.cheap.free(@ptrCast(ptr));
+}
+
+fn hostedRealloc(mem: ?*anyopaque, newsize: usize) callconv(.c) ?*anyopaque {
+ assert(state.installed);
+ return @ptrCast(state.cheap.realloc(@ptrCast(mem), newsize));
+}
+
+/// `_h_malloc_align(size, align)`. ESP-IDF routes this to `heap_caps_aligned_alloc` with
+/// DMA-capable caps (`port_esp_hosted_host_os.c:128-143`) because IDF's SDMMC driver DMAs straight
+/// out of the caller's buffer.
+///
+/// Ours does not: `hal.sdmmc` bounces every CMD53 through its own 64-byte-aligned buffer reached
+/// through the non-cacheable alias, and memcpy's to and from the caller's slice. So the alignment
+/// is honoured - it costs 64 bytes a buffer and callers may reasonably rely on it - but nothing
+/// downstream needs it, and `_h_malloc` would do.
+fn hostedMallocAlign(size: usize, alignment: usize) callconv(.c) ?*anyopaque {
+ assert(state.installed);
+ // ESP-Hosted only ever asks for 4, 32 or 64 (HOSTED_MEM_ALIGNMENT_*,
+ // port_esp_hosted_host_os.h:93-95). A non-power-of-two would silently corrupt the header
+ // arithmetic, so refuse it.
+ if (alignment == 0 or !std.math.isPowerOfTwo(alignment) or alignment > hheap.CHeap.max_alignment) {
+ note("MARK PORT_BAD_ALIGN %u\r\n", .{@as(u32, @intCast(alignment))});
+ return null;
+ }
+ return @ptrCast(state.cheap.mallocAligned(size, alignment));
+}
+
+/// One header format for both `_h_free` and `_h_free_align`, because ESP-IDF has one too: its
+/// `hosted_free_align` is a plain `free` (`port_esp_hosted_host_os.c:145-148`), and mixing the two
+/// is legal in the tree - `sdio_drv.c:353` frees with `_h_free_align` a buffer that
+/// `transport_util.c:14` allocated with `_h_malloc_align`, while `HOSTED_FREE` uses `_h_free`
+/// throughout.
+fn hostedFreeAlign(ptr: ?*anyopaque) callconv(.c) void {
+ assert(state.installed);
+ state.cheap.free(@ptrCast(ptr));
+}
+
+// ============================================================================ 2. sync
+
+fn hostedCreateMutex() callconv(.c) ?*anyopaque {
+ assert(state.installed);
+ const m = state.gpa.create(os.Mutex) catch return null;
+ m.* = .{};
+ return @ptrCast(m);
+}
+
+fn hostedLockMutex(handle: ?*anyopaque, timeout_ms: c_int) callconv(.c) c_int {
+ const m: *os.Mutex = @ptrCast(@alignCast(handle orelse return ret.invalid));
+ return m.lock(currentIo(), .fromMillis(timeout_ms));
+}
+
+fn hostedUnlockMutex(handle: ?*anyopaque) callconv(.c) c_int {
+ const m: *os.Mutex = @ptrCast(@alignCast(handle orelse return ret.invalid));
+ return m.unlock(currentIo());
+}
+
+fn hostedDestroyMutex(handle: ?*anyopaque) callconv(.c) c_int {
+ const m: *os.Mutex = @ptrCast(@alignCast(handle orelse return ret.invalid));
+ state.gpa.destroy(m);
+ return ret.ok;
+}
+
+fn hostedCreateSemaphore(max_count: c_int) callconv(.c) ?*anyopaque {
+ assert(state.installed);
+ const s = state.gpa.create(os.Semaphore) catch return null;
+ s.* = .init(if (max_count > 0) @intCast(max_count) else 1);
+ return @ptrCast(s);
+}
+
+fn hostedPostSemaphore(handle: ?*anyopaque) callconv(.c) c_int {
+ const s: *os.Semaphore = @ptrCast(@alignCast(handle orelse return ret.invalid));
+ return s.post(currentIo());
+}
+
+/// See `hosted_os.Semaphore.postFromIsr` for what "from ISR" can and cannot mean here.
+fn hostedPostSemaphoreFromIsr(handle: ?*anyopaque) callconv(.c) c_int {
+ const s: *os.Semaphore = @ptrCast(@alignCast(handle orelse return ret.invalid));
+ return s.postFromIsr(state.io);
+}
+
+fn hostedGetSemaphore(handle: ?*anyopaque, timeout_ms: c_int) callconv(.c) c_int {
+ const s: *os.Semaphore = @ptrCast(@alignCast(handle orelse return ret.invalid));
+ return s.wait(currentIo(), .fromMillis(timeout_ms));
+}
+
+fn hostedDestroySemaphore(handle: ?*anyopaque) callconv(.c) c_int {
+ const s: *os.Semaphore = @ptrCast(@alignCast(handle orelse return ret.invalid));
+ state.gpa.destroy(s);
+ return ret.ok;
+}
+
+fn hostedCreateQueue(qnum_elem: u32, qitem_size: u32) callconv(.c) ?*anyopaque {
+ assert(state.installed);
+ if (qnum_elem == 0 or qitem_size == 0) return null;
+ return @ptrCast(os.Queue.create(state.gpa, qnum_elem, qitem_size));
+}
+
+fn hostedQueueItem(handle: ?*anyopaque, item: ?*const anyopaque, timeout: c_int) callconv(.c) c_int {
+ const q: *os.Queue = @ptrCast(@alignCast(handle orelse return ret.invalid));
+ const p: [*]const u8 = @ptrCast(item orelse return ret.invalid);
+ // `_h_queue_item`'s timeout reaches xQueueSendToBack unconverted, so its units are ticks; every
+ // caller passes HOSTED_BLOCK_MAX or 0, both of which mean the same thing in either dialect.
+ return q.send(currentIo(), p, .fromMillis(timeout));
+}
+
+fn hostedDequeueItem(handle: ?*anyopaque, item: ?*anyopaque, timeout: c_int) callconv(.c) c_int {
+ const q: *os.Queue = @ptrCast(@alignCast(handle orelse return ret.invalid));
+ const p: [*]u8 = @ptrCast(item orelse return ret.invalid);
+ // Seconds, not milliseconds, on the positive branch. See `hosted_os.Wait.fromQueueTimeout`.
+ return q.receive(currentIo(), p, .fromQueueTimeout(timeout));
+}
+
+fn hostedQueueMsgWaiting(handle: ?*anyopaque) callconv(.c) c_int {
+ const q: *os.Queue = @ptrCast(@alignCast(handle orelse return ret.invalid));
+ return q.waiting(currentIo());
+}
+
+fn hostedDestroyQueue(handle: ?*anyopaque) callconv(.c) c_int {
+ const q: *os.Queue = @ptrCast(@alignCast(handle orelse return ret.invalid));
+ q.destroy(currentIo(), state.gpa);
+ return ret.ok;
+}
+
+fn hostedResetQueue(handle: ?*anyopaque) callconv(.c) c_int {
+ const q: *os.Queue = @ptrCast(@alignCast(handle orelse return ret.invalid));
+ return q.reset(currentIo());
+}
+
+/// The mempool lock. `H_USE_MEMPOOL` is 1 in this board's configuration, so these four must not be
+/// null even though the version of `common/mempool/mempool.c` in this tree does not call them.
+///
+/// ESP-IDF uses a `portMUX_TYPE` spinlock and `portENTER_CRITICAL`
+/// (`port_esp_hosted_host_os.c:602-643`), which on a multi-core preemptive kernel means "take the
+/// spinlock and disable interrupts". On one core with a cooperative scheduler the spinlock half is
+/// vacuous - there is no other core to contend with - and the interrupt half is the whole content.
+/// So the handle is `hal.intr`'s nesting mask guard, and the critical section is exactly as long as
+/// interrupts are off.
+const MempoolLock = struct {
+ guard: hal.clkrst.Guard = undefined,
+ held: bool = false,
+};
+
+fn hostedCreateLockMempool() callconv(.c) ?*anyopaque {
+ assert(state.installed);
+ const l = state.gpa.create(MempoolLock) catch return null;
+ l.* = .{};
+ return @ptrCast(l);
+}
+
+fn hostedLockMempool(handle: ?*anyopaque) callconv(.c) void {
+ const l: *MempoolLock = @ptrCast(@alignCast(handle orelse return));
+ l.guard = hal.intr.mask();
+ l.held = true;
+}
+
+fn hostedUnlockMempool(handle: ?*anyopaque) callconv(.c) void {
+ const l: *MempoolLock = @ptrCast(@alignCast(handle orelse return));
+ if (!l.held) return;
+ l.held = false;
+ l.guard.release();
+}
+
+fn hostedDestroyLockMempool(handle: ?*anyopaque) callconv(.c) void {
+ const l: *MempoolLock = @ptrCast(@alignCast(handle orelse return));
+ state.gpa.destroy(l);
+}
+
+// ============================================================================ 3. threads
+
+/// ESP-Hosted spawns **seven** tasks on the SDIO transport, and their requested stacks are the
+/// single largest memory claim in the whole port:
+///
+/// sdio_rx_buf RX_BUF_TASK_STACK_SIZE sdio_drv.c:1542 (= CONFIG_ESP_HOSTED_DFLT_TASK_STACK)
+/// sdio_read DFLT_TASK_STACK_SIZE sdio_drv.c:1545
+/// sdio_process_rx DFLT_TASK_STACK_SIZE sdio_drv.c:1548
+/// sdio_write DFLT_TASK_STACK_SIZE sdio_drv.c:1551
+/// rpc_rx RPC_TASK_STACK_SIZE rpc_core.c:578
+/// rpc_tx RPC_TASK_STACK_SIZE rpc_core.c:580
+/// rpc_supp_cb RPC_TASK_STACK_SIZE rpc_wrap.c:2398
+///
+/// `DFLT_TASK_STACK_SIZE` and `RPC_TASK_STACK_SIZE` are both `5*1024`
+/// (`port_esp_hosted_host_os.h:64-67`), and ESP-IDF's `xTaskCreate` takes bytes, so the ask is
+/// 35 KB. Plus this port's timer service task, plus the main context, that is nine slots.
+///
+/// The requested size is **ignored**, and that is not laziness: `std.Io.async` has no stack-size
+/// parameter, and the runtime takes the first free slot from a pool whose slots are all declared at
+/// one size. The number to declare is therefore the worst case over all seven, which is what the
+/// caller of `install` decides when it builds its `Runtime`. 5 KB is FreeRTOS's number for tasks
+/// that call `printf`; these bodies do not, and the honest way to size the pool is a painted-stack
+/// watermark on the die, not this constant.
+pub const thread_count = 7;
+pub const requested_stack_bytes = 5 * 1024;
+
+fn hostedThreadCreate(
+ tname: [*:0]const u8,
+ tprio: u32,
+ tstack_size: u32,
+ start_routine: StartRoutine,
+ sr_arg: ?*anyopaque,
+) callconv(.c) ?*anyopaque {
+ assert(state.installed);
+ // Priority is meaningless on a cooperative scheduler: a task runs until it blocks, and
+ // ESP-Hosted gives all seven the same priority anyway (RPC_TASK_PRIO and DFLT_TASK_PRIO are
+ // both 23, port_esp_hosted_host_os.h:65-68).
+ _ = tprio;
+ _ = tstack_size;
+ return @ptrCast(os.Thread.create(currentIo(), state.gpa, tname, start_routine, sr_arg));
+}
+
+fn hostedThreadCancel(handle: ?*anyopaque) callconv(.c) c_int {
+ const t: *os.Thread = @ptrCast(@alignCast(handle orelse return ret.invalid));
+ return t.cancel(currentIo(), state.gpa);
+}
+
+fn hostedThreadYield() callconv(.c) void {
+ // A zero-duration sleep is the portable yield, and on this runtime it is a documented one
+ // trip round the run queue rather than a no-op. Cancelation is swallowed because the C caller
+ // (`spi_hd_drv.c:568`, the only one in the tree) has nowhere to report it.
+ currentIo().sleep(.zero, os.clock) catch {};
+}
+
+// ============================================================================ 4. time
+
+fn hostedMsleep(mseconds: c_uint) callconv(.c) c_uint {
+ currentIo().sleep(.fromMilliseconds(mseconds), os.clock) catch {};
+ return 0;
+}
+
+fn hostedUsleep(useconds: c_uint) callconv(.c) c_uint {
+ currentIo().sleep(.fromMicroseconds(useconds), os.clock) catch {};
+ return 0;
+}
+
+/// Counted, because in this build every call is one turn of an ESP-Hosted RPC thread's not-ready
+/// spin. See `State.hosted_sleep_calls`.
+fn hostedSleep(seconds: c_uint) callconv(.c) c_uint {
+ state.hosted_sleep_calls += 1;
+ return hostedMsleep(seconds *| 1000);
+}
+
+/// `_h_blocking_delay` is documented in ESP-Hosted as a "non sleepable delay - BLOCKING dead wait"
+/// and implemented as `for (idx = 0; idx < 100*number; idx++)` on a `volatile`
+/// (`port_esp_hosted_host_os.c:261-267`). That is a loop count, not a duration, and its wall-clock
+/// meaning depends on the compiler and the CPU clock.
+///
+/// It is reproduced as a real busy-wait rather than a sleep, because a caller reaching for this
+/// specifically wants not to yield - and reproduced against `hal.systimer` rather than a loop
+/// count, so the delay is at least defined. ESP-IDF's version at 360 MHz takes roughly 0.3 us per
+/// unit; at this board's measured 90 MHz it would be about 1.1 us, and 1 us is the round number in
+/// range. **Nothing in the tree calls this**, verified by grep, so no behaviour depends on the
+/// choice.
+///
+/// On a cooperative scheduler this starves every other task for the duration. That is inherent to
+/// what the entry means, not a defect of this implementation.
+fn hostedBlockingDelay(number: c_uint) callconv(.c) c_uint {
+ hal.systimer.delayMicros(number);
+ return 0;
+}
+
+fn hostedGetTimeMs() callconv(.c) u64 {
+ return os.nowMs(currentIo());
+}
+
+// ============================================================================ timers
+
+/// A timer handle as C sees it. ESP-IDF hands back a heap pointer
+/// (`port_esp_hosted_host_os.c:697`); this hands back a pointer to one, so `_h_timer_stop` can find
+/// the slot and free the handle exactly as ESP-IDF's does.
+const TimerHandle = struct {
+ slot: usize,
+};
+
+fn hostedTimerStart(
+ name: [*:0]const u8,
+ duration_ms: c_int,
+ kind: c_int,
+ handler: TimerHandler,
+ arg: ?*anyopaque,
+) callconv(.c) ?*anyopaque {
+ assert(state.installed);
+ if (duration_ms < 0) return null;
+ const k: os.TimerKind = switch (kind) {
+ 0 => .oneshot,
+ 1 => .periodic,
+ else => {
+ // ESP-IDF logs "Unsupported timer type" and returns NULL (:720-725).
+ note("MARK PORT_TIMER_BAD_TYPE %s %d\r\n", .{ name, kind });
+ return null;
+ },
+ };
+ const slot = state.timers.arm(currentIo(), @intCast(duration_ms), k, handler, arg) orelse {
+ note("MARK PORT_TIMER_SLOTS_FULL %s\r\n", .{name});
+ return null;
+ };
+ const h = state.gpa.create(TimerHandle) catch {
+ _ = state.timers.disarm(currentIo(), slot);
+ return null;
+ };
+ h.* = .{ .slot = slot };
+ return @ptrCast(h);
+}
+
+fn hostedTimerStop(handle: ?*anyopaque) callconv(.c) c_int {
+ const h: *TimerHandle = @ptrCast(@alignCast(handle orelse return ret.fail));
+ const r = state.timers.disarm(currentIo(), h.slot);
+ state.gpa.destroy(h);
+ return r;
+}
+
+// ============================================================================ 5. GPIO
+
+/// `H_GPIO_MODE_DEF_*`, `port_esp_hosted_host_os.h:71-73`: bit 0 input, bit 1 output, bit 2
+/// open-drain.
+const gpio_mode_input: u32 = 1 << 0;
+const gpio_mode_output: u32 = 1 << 1;
+const gpio_mode_open_drain: u32 = 1 << 2;
+
+/// `H_GPIO_PULL_UP` is 1 and `H_GPIO_PULL_DOWN` is 0 (`port_esp_hosted_host_os.h:83-84`) - note
+/// that this is a *direction* selector and not a boolean, and the separate `enable` argument says
+/// whether to turn that resistor on or off.
+const gpio_pull_up: u32 = 1;
+
+/// `_h_config_gpio`. The `gpio_port` argument is always `H_GPIO_PORT_DEFAULT` / NULL on this chip
+/// (`port_esp_hosted_host_config.h:435`); ESP-IDF ignores it too.
+///
+/// ESP-IDF's version goes through `gpio_config`, which also clears both pulls
+/// (`port_esp_hosted_host_os.c:746-758`). Reproduced, because the reset pin depends on it: GPIO54
+/// has an external pull-up and an internal pull-down fighting it would be a weak, marginal high.
+fn hostedConfigGpio(gpio_port: ?*anyopaque, gpio_num: u32, mode: u32) callconv(.c) c_int {
+ _ = gpio_port;
+ if (gpio_num > hal.gpio.max_pin) return ret.invalid;
+ const pin: u8 = @intCast(gpio_num);
+
+ hal.gpio.setFunction(pin, .gpio);
+ hal.gpio.setPull(pin, .none);
+ hal.gpio.setOpenDrain(pin, mode & gpio_mode_open_drain != 0);
+ hal.gpio.setInputEnable(pin, mode & gpio_mode_input != 0);
+ if (mode & gpio_mode_output != 0) {
+ // Point the matrix at the GPIO peripheral before enabling the driver, so the pad never
+ // spends an instant driven by whatever signal the matrix happened to hold.
+ hal.gpio.matrixOut(pin, hal.gpio.matrix_gpio_signal);
+ hal.gpio.outputEnable(pin);
+ } else {
+ hal.gpio.outputDisable(pin);
+ }
+ return ret.ok;
+}
+
+fn hostedReadGpio(gpio_port: ?*anyopaque, gpio_num: u32) callconv(.c) c_int {
+ _ = gpio_port;
+ if (gpio_num > hal.gpio.max_pin) return ret.invalid;
+ return hal.gpio.getLevel(@intCast(gpio_num));
+}
+
+fn hostedWriteGpio(gpio_port: ?*anyopaque, gpio_num: u32, value: u32) callconv(.c) c_int {
+ _ = gpio_port;
+ if (gpio_num > hal.gpio.max_pin) return ret.invalid;
+ hal.gpio.setLevel(@intCast(gpio_num), if (value != 0) 1 else 0);
+ return ret.ok;
+}
+
+/// `_h_pull_gpio(port, pin, pull_value, enable)`.
+///
+/// The four-argument shape does not map onto one register field: the P4 has one pull-up bit and one
+/// pull-down bit, and `hal.gpio.setPull` writes both in one store precisely so a pad can never end
+/// up with two resistors fighting. Disabling one pull therefore means "leave the *other* alone",
+/// which is read back rather than assumed.
+fn hostedPullGpio(gpio_port: ?*anyopaque, gpio_num: u32, pull_value: u32, enable: u32) callconv(.c) c_int {
+ _ = gpio_port;
+ if (gpio_num > hal.gpio.max_pin) return ret.invalid;
+ const pin: u8 = @intCast(gpio_num);
+ const up = pull_value == gpio_pull_up;
+ if (enable != 0) {
+ hal.gpio.setPull(pin, if (up) .up else .down);
+ } else {
+ // gpio_pullup_dis / gpio_pulldown_dis clear one bit only. If the other pull is not set
+ // either, the pad ends up floating, which is what ESP-IDF leaves behind too.
+ const current = hal.gpio.getPull(pin);
+ const target: hal.gpio.Pull = if (up)
+ (if (current == .down) .down else .none)
+ else
+ (if (current == .up) .up else .none);
+ hal.gpio.setPull(pin, target);
+ }
+ return ret.ok;
+}
+
+/// `_h_hold_gpio`. ESP-IDF calls `gpio_hold_en`, which latches a pad's output through a sleep or a
+/// domain power-down so the slave is not reset by the host napping.
+///
+/// This image never sleeps and never powers a domain down: `_h_config_host_power_save_hal_impl` and
+/// `_h_start_host_power_save_hal_impl` are both loud stubs, and the only callers of this entry are
+/// in `power_save_drv.c:210,230`, which those stubs make unreachable. Holding a pad against a sleep
+/// that cannot happen is not a no-op worth pretending to - the P4's hold bit lives in
+/// `LP_AON`/`HP_SYS` registers the HAL does not model, and writing them blind is how a pad gets
+/// stuck. So this reports failure loudly instead.
+fn hostedHoldGpio(gpio_port: ?*anyopaque, gpio_num: u32, hold_value: u32) callconv(.c) c_int {
+ _ = gpio_port;
+ state.stub_calls += 1;
+ note("MARK PORT_STUB _h_hold_gpio pin=%u hold=%u (no sleep support; nothing should reach this)\r\n", .{ gpio_num, hold_value });
+ return ret.fail;
+}
+
+/// `H_GPIO_INTR_*`, `port_esp_hosted_host_config.h:56-62`. The values coincide exactly with the
+/// P4's `GPIO_PINn_INT_TYPE` encoding (`gpio_reg.h:377-381`), which is not a coincidence: the
+/// enum was written from it.
+fn intrTypeFromHosted(intr_type: u32) ?hal.gpio.IntrType {
+ return switch (intr_type) {
+ 0 => .disable,
+ 1 => .posedge,
+ 2 => .negedge,
+ 3 => .anyedge,
+ 4 => .low_level,
+ 5 => .high_level,
+ else => null,
+ };
+}
+
+/// `_h_config_gpio_as_interrupt`.
+///
+/// ESP-IDF's version (`port_esp_hosted_host_os.c:760-797`) configures the pad as an input with a
+/// pull that opposes the edge being detected, installs IDF's shared GPIO ISR service, adds a
+/// per-pin handler, then sets the trigger type and enables. Same five steps here, with `hal.gpio`
+/// and `hal.intr` in place of the driver:
+///
+/// 1. pad as input, pull opposing the edge - a floating pad on an edge-triggered interrupt is a
+/// free-running interrupt source.
+/// 2. record (pin, handler, arg) in `state.gpio_isrs`.
+/// 3. arm the pad on GPIO interrupt line 0, which is the line ESP-IDF uses.
+/// 4. route `gpio_intr0` to a CLIC line and give it `gpioDispatch`, once.
+/// 5. enable.
+///
+/// The CLIC trigger is **level**, not edge: the GPIO peripheral holds its line asserted while any
+/// status bit is set, and the handler clears the status. An edge-triggered CLIC line here would
+/// lose a second pad's event that arrived while the first was being serviced.
+///
+/// Nothing in the SDIO transport calls this. Its callers are `spi_drv.c:625,628`,
+/// `spi_hd_drv.c:548` and `power_save_drv.c:68`. It is implemented rather than stubbed because it
+/// costs little and because a host-wakeup pin is the obvious next use.
+fn hostedConfigGpioAsInterrupt(
+ gpio_port: ?*anyopaque,
+ gpio_num: u32,
+ intr_type: u32,
+ handler: IsrHandler,
+ arg: ?*anyopaque,
+) callconv(.c) c_int {
+ _ = gpio_port;
+ if (gpio_num > hal.gpio.max_pin) return ret.invalid;
+ const pin: u8 = @intCast(gpio_num);
+ const t = intrTypeFromHosted(intr_type) orelse {
+ note("MARK PORT_GPIO_BAD_INTR_TYPE %u\r\n", .{intr_type});
+ return ret.invalid;
+ };
+
+ // ESP-IDF pulls up for a falling edge and down for anything else (:771-775).
+ hal.gpio.configureInput(pin, .{ .pull = if (t == .negedge) .up else .down });
+
+ const slot = blk: {
+ for (&state.gpio_isrs) |*s| if (s.pin == pin) break :blk s;
+ for (&state.gpio_isrs) |*s| if (s.handler == null) break :blk s;
+ note("MARK PORT_GPIO_ISR_SLOTS_FULL pin=%u\r\n", .{gpio_num});
+ return ret.fail;
+ };
+ slot.* = .{ .pin = pin, .handler = handler, .arg = arg };
+
+ if (!gpio_line_attached) {
+ gpio_line_attached = true;
+ // mtvec, MTVT, the threshold and MIE, before a line that `configureLine` enables as its
+ // last act can be delivered anywhere. See `takeInterruptControl`.
+ takeInterruptControl();
+ hal.intr.routeId(@intFromEnum(hal.intr.Source.gpio_intr0), config.gpio_clic_line);
+ hal.intr.configureLine(config.gpio_clic_line, .{
+ .handler = gpioDispatch,
+ .trigger = .level,
+ });
+ }
+ hal.gpio.setInterrupt(pin, t, .line0);
+ return ret.ok;
+}
+
+fn hostedTeardownGpioInterrupt(gpio_port: ?*anyopaque, gpio_num: u32) callconv(.c) c_int {
+ _ = gpio_port;
+ if (gpio_num > hal.gpio.max_pin) return ret.invalid;
+ const pin: u8 = @intCast(gpio_num);
+ hal.gpio.disableInterrupt(pin);
+ hal.gpio.clearInterrupt(pin);
+ for (&state.gpio_isrs) |*s| {
+ if (s.pin == pin) s.* = .{};
+ }
+ return ret.ok;
+}
+
+var gpio_line_attached: bool = false;
+
+/// The one CLIC handler behind every registered pad. Reads the whole pending mask once, clears it
+/// once, then dispatches - so an event on a second pad arriving mid-dispatch is caught by the next
+/// interrupt rather than lost.
+///
+/// The status is cleared *before* the handlers run. For an edge-triggered pad that is the correct
+/// order: clearing after the handler would drop an edge that arrived during it.
+fn gpioDispatch(line: u5) void {
+ _ = line;
+ const pending = hal.gpio.pendingMask(.line0);
+ hal.gpio.clearInterrupts(pending.low, pending.high);
+ for (&state.gpio_isrs) |*s| {
+ const h = s.handler orelse continue;
+ const bit: u32 = @as(u32, 1) << @intCast(if (s.pin < 32) s.pin else s.pin - 32);
+ const hit = if (s.pin < 32) pending.low & bit else pending.high & bit;
+ if (hit != 0) h(s.arg);
+ }
+}
+
+// ============================================================================ 6. SDIO
+
+/// `ESP_ADDRESS_MASK`, `host/drivers/transport/sdio/sdio_reg.h:87`. Slave scratch registers live in
+/// the low 10 bits of function 1's address space, and ESP-Hosted masks every register address with
+/// this before the transfer (`port_esp_hosted_host_sdio.c:500,523`). Block transfers are *not*
+/// masked, which is why `ESP_SLAVE_CMD53_END_ADDR - data_left` works.
+const esp_address_mask: u32 = 0x3FF;
+/// `ESP_BLOCK_SIZE`, `sdio_reg.h:39`.
+const esp_block_size: u32 = 512;
+/// The SDIO function ESP-Hosted talks to. `SDIO_FUNC_1`.
+const sdio_func: u3 = 1;
+
+/// `ESP_OK` / `ESP_FAIL` as `esp_err_t`, which is what the `_h_sdio_*` entries return and what
+/// `sdio_drv.c` tests against zero.
+const esp_ok: c_int = 0;
+const esp_fail: c_int = -1;
+
+fn busCtx(ctx: ?*anyopaque) ?*BusContext {
+ const p = ctx orelse return null;
+ const b: *BusContext = @ptrCast(@alignCast(p));
+ // ESP-IDF returns a pointer to one file-static context; anything else is a bug, and a wild
+ // pointer here would be a wild bus.
+ if (b != &state.bus) return null;
+ return b;
+}
+
+/// `_h_bus_init` = `hosted_sdio_init` (`port_esp_hosted_host_sdio.c:317-399`): bring the SDMMC host
+/// and slot up, create the bus mutex, return the context. Guarded against a second call, as the
+/// original is (`:322-326`).
+///
+/// The slot, width and clock are `hal.sdmmc`'s defaults, which are this board's measured working
+/// configuration: slot 1, 4-bit, 40 MHz, CLK 18 / CMD 19 / D0-D3 14-17.
+fn hostedBusInit() callconv(.c) ?*anyopaque {
+ assert(state.installed);
+ if (state.bus.up) {
+ note("MARK PORT_SDIO_ALREADY_UP\r\n", .{});
+ return @ptrCast(&state.bus);
+ }
+ hal.sdmmc.init(.{}) catch |e| {
+ note("MARK PORT_SDIO_INIT_FAIL %s\r\n", .{@errorName(e).ptr});
+ return null;
+ };
+ state.bus = .{ .lock = .{}, .up = true };
+ return @ptrCast(&state.bus);
+}
+
+fn hostedBusDeinit(ctx: ?*anyopaque) callconv(.c) c_int {
+ const b = busCtx(ctx) orelse return esp_fail;
+ b.up = false;
+ return esp_ok;
+}
+
+/// `_h_sdio_card_init` = `hosted_sdio_card_init` + `hosted_sdio_card_fn_init`
+/// (`port_esp_hosted_host_sdio.c:141-217, 401-471`).
+///
+/// `hal.sdmmc.cardInit` does the SD/SDIO card identification and programmes the host's block size.
+/// What is left is the part that is ESP-Hosted's protocol rather than the bus's: enable function 1,
+/// wait for it to report ready, enable its interrupt, and set the CCCR block size for functions 0
+/// and 1. Those writes are idempotent and the read-back is the check; the sequence is reproduced
+/// in ESP-IDF's order because that order is what this board was observed to come up with.
+///
+/// Failure returns `ESP_FAIL` rather than asserting, because the caller retries: `sdio_drv.c:1638`
+/// loops up to `CARD_INIT_TIMEOUT_MS`, and the first register reads after a reset legitimately
+/// fail while the C6 is still booting (`:150-153`).
+fn hostedSdioCardInit(ctx: ?*anyopaque, show_config: bool) callconv(.c) c_int {
+ const b = busCtx(ctx) orelse return esp_fail;
+ _ = b;
+ hal.sdmmc.cardInit() catch |e| {
+ note("MARK PORT_SDIO_CARD_INIT_FAIL %s\r\n", .{@errorName(e).ptr});
+ return esp_fail;
+ };
+ if (show_config) {
+ note("MARK PORT_SDIO slot=1 width=4 khz=40000 clk=18 cmd=19 d0-3=14,15,16,17 reset=%u\r\n", .{
+ @as(u32, config.reset_pin),
+ });
+ }
+ return sdioFunctionInit();
+}
+
+// CCCR and FBR offsets, `esp-idf/components/sdmmc/include/sd_protocol_defs.h:511-533`.
+const cccr_fn_enable: u17 = 0x02;
+const cccr_fn_ready: u17 = 0x03;
+const cccr_int_enable: u17 = 0x04;
+const cccr_bus_width: u17 = 0x07;
+const cccr_blksize_l: u17 = 0x10;
+const cccr_blksize_h: u17 = 0x11;
+const fbr_start: u17 = 0x100;
+/// `FUNC1_EN_MASK`, `port_esp_hosted_host_sdio.c:29`.
+const func1_en_mask: u8 = 1 << 1;
+/// `SDIO_INIT_MAX_RETRY`, `:30`.
+const sdio_init_max_retry = 10;
+
+fn sdioFunctionInit() c_int {
+ // Function 0 is the CCCR; every access here is CMD52 on function 0.
+ var ioe = cmd52(0, cccr_fn_enable) orelse return esp_fail;
+ cmd52w(0, cccr_fn_enable, ioe | func1_en_mask) orelse return esp_fail;
+
+ // Poll IOR until function 1 reports ready. 10 tries, 10 ms apart (:180-192).
+ var tries: u32 = 0;
+ while (tries < sdio_init_max_retry) : (tries += 1) {
+ const ior = cmd52(0, cccr_fn_ready) orelse return esp_fail;
+ if (ior & func1_en_mask != 0) break;
+ _ = hostedMsleep(10);
+ }
+ if (tries >= sdio_init_max_retry) {
+ note("MARK PORT_SDIO_FN1_NOT_READY\r\n", .{});
+ return esp_fail;
+ }
+
+ // Master interrupt enable (bit 0) plus function 1's own (:196-198).
+ const ie = cmd52(0, cccr_int_enable) orelse return esp_fail;
+ cmd52w(0, cccr_int_enable, ie | 1 | func1_en_mask) orelse return esp_fail;
+
+ const bus_width = cmd52(0, cccr_bus_width) orelse return esp_fail;
+
+ // CCCR block size for function 0, then function 1 through its FBR (:120-137, 208-214).
+ if (setBlockSize(0, esp_block_size) != esp_ok) return esp_fail;
+ if (setBlockSize(1, esp_block_size) != esp_ok) return esp_fail;
+
+ ioe = cmd52(0, cccr_fn_enable) orelse return esp_fail;
+ note("MARK PORT_SDIO_FN1 ioe=0x%02x ie=0x%02x bus_width=0x%02x\r\n", .{
+ @as(u32, ioe), @as(u32, ie | 1 | func1_en_mask), @as(u32, bus_width),
+ });
+ return esp_ok;
+}
+
+fn setBlockSize(func: u3, value: u16) c_int {
+ const offset: u17 = fbr_start * @as(u17, func);
+ const lo: u8 = @truncate(value);
+ const hi: u8 = @truncate(value >> 8);
+ cmd52w(0, offset + cccr_blksize_l, lo) orelse return esp_fail;
+ cmd52w(0, offset + cccr_blksize_h, hi) orelse return esp_fail;
+ const rb_lo = cmd52(0, offset + cccr_blksize_l) orelse return esp_fail;
+ const rb_hi = cmd52(0, offset + cccr_blksize_h) orelse return esp_fail;
+ const rb = @as(u16, rb_hi) << 8 | rb_lo;
+ return if (rb == value) esp_ok else esp_fail;
+}
+
+fn cmd52(func: u3, addr: u17) ?u8 {
+ return hal.sdmmc.cmd52Read(func, addr) catch null;
+}
+
+fn cmd52w(func: u3, addr: u17, value: u8) ?void {
+ hal.sdmmc.cmd52Write(func, addr, value) catch return null;
+ return {};
+}
+
+/// `_h_sdio_card_deinit` frees IDF's DMA bounce buffer (`port_esp_hosted_host_sdio.c:473-487`).
+/// `hal.sdmmc` owns its bounce buffer statically, so there is nothing to free.
+fn hostedSdioCardDeinit(ctx: ?*anyopaque) callconv(.c) c_int {
+ _ = busCtx(ctx) orelse return esp_fail;
+ return esp_ok;
+}
+
+/// `lock_required` exists because ESP-IDF's SDMMC driver is shared: `sdio_drv.c` reaches the bus
+/// from four tasks, and a CMD53 that interleaves with another CMD53 is a corrupt transfer. Some
+/// call sites already hold the bus lock (`SDIO_DRV_LOCK`) and pass false to avoid taking it twice;
+/// the rest pass true.
+///
+/// **It is still required here**, and this is the one place where a cooperative scheduler does not
+/// let a lock go. Cooperative means no task is preempted between two *instructions*; it does not
+/// mean a task cannot yield in the middle of a transfer, and `hal.sdmmc`'s CMD53 path does exactly
+/// that if it waits on the SDMMC host's interrupt. A second task entering `cmd53Read` while the
+/// first is parked inside one would reprogramme the descriptor under it. The lock is what makes
+/// "one transfer at a time" true, and it is cheap: `Io.Mutex.tryLock` is one compare-exchange when
+/// uncontended, which is every call on the fast path.
+fn sdioLock(b: *BusContext, required: bool) void {
+ if (required) _ = b.lock.lock(state.io, .forever);
+}
+
+fn sdioUnlock(b: *BusContext, required: bool) void {
+ if (required) _ = b.lock.unlock(state.io);
+}
+
+/// `_h_sdio_read_reg`: function 1, address masked, CMD52 for one byte and CMD53 byte mode with an
+/// incrementing address for more (`port_esp_hosted_host_sdio.c:489-511`).
+fn hostedSdioReadReg(ctx: ?*anyopaque, reg: u32, data: [*]u8, size: u16, lock_required: bool) callconv(.c) c_int {
+ const b = busCtx(ctx) orelse return esp_fail;
+ const addr: u17 = @intCast(reg & esp_address_mask);
+ sdioLock(b, lock_required);
+ defer sdioUnlock(b, lock_required);
+ if (size <= 1) {
+ data[0] = hal.sdmmc.cmd52Read(sdio_func, addr) catch return esp_fail;
+ return esp_ok;
+ }
+ hal.sdmmc.cmd53Read(sdio_func, addr, data[0..size], true) catch return esp_fail;
+ return esp_ok;
+}
+
+fn hostedSdioWriteReg(ctx: ?*anyopaque, reg: u32, data: [*]u8, size: u16, lock_required: bool) callconv(.c) c_int {
+ const b = busCtx(ctx) orelse return esp_fail;
+ const addr: u17 = @intCast(reg & esp_address_mask);
+ sdioLock(b, lock_required);
+ defer sdioUnlock(b, lock_required);
+ if (size <= 1) {
+ hal.sdmmc.cmd52Write(sdio_func, addr, data[0]) catch return esp_fail;
+ return esp_ok;
+ }
+ hal.sdmmc.cmd53Write(sdio_func, addr, data[0..size], true) catch return esp_fail;
+ return esp_ok;
+}
+
+/// `_h_sdio_read_block` / `_h_sdio_write_block`, `port_esp_hosted_host_sdio.c:536-576`, with the
+/// splitting from `sdio_read_fromio`/`sdio_write_toio` (`:221-292`):
+///
+/// * the length is first rounded **up** to a multiple of four (`H_SDIO_TX_LEN_TO_TRANSFER`,
+/// `port_esp_hosted_host_config.h:274-275`), because the slave's FIFO is word-wide;
+/// * while 512 bytes or more remain, transfer whole 512-byte blocks;
+/// * transfer the remainder in byte mode;
+/// * the address advances by every chunk, and is **not** masked - block transfers address the
+/// slave's data window, not its scratch registers.
+///
+/// Rounding up means reading or writing past `size`. That is ESP-Hosted's design, not an accident:
+/// its buffers come from `_h_malloc_align(len, 64)`, so there are always at least 64 usable bytes
+/// at the end - and this port's `_h_malloc_align` rounds the *allocation* up to the alignment for
+/// exactly this reason. A caller that hands a tightly-sized buffer to a block transfer would have
+/// the same bug under ESP-IDF.
+fn hostedSdioReadBlock(ctx: ?*anyopaque, reg: u32, data: [*]u8, size: u16, lock_required: bool) callconv(.c) c_int {
+ const b = busCtx(ctx) orelse return esp_fail;
+ sdioLock(b, lock_required);
+ defer sdioUnlock(b, lock_required);
+ if (size <= 1) {
+ // Unmasked, unlike the `_reg` entries: `hosted_sdio_read_block` has no
+ // `reg &= ESP_ADDRESS_MASK` (port_esp_hosted_host_sdio.c:536-555). Masking here would
+ // fold `ESP_SLAVE_CMD53_END_ADDR - data_left` (sdio_drv.c:756) onto a scratch register.
+ data[0] = hal.sdmmc.cmd52Read(sdio_func, @intCast(reg)) catch return esp_fail;
+ return esp_ok;
+ }
+ return blockTransfer(.read, reg, data, size);
+}
+
+fn hostedSdioWriteBlock(ctx: ?*anyopaque, reg: u32, data: [*]u8, size: u16, lock_required: bool) callconv(.c) c_int {
+ const b = busCtx(ctx) orelse return esp_fail;
+ sdioLock(b, lock_required);
+ defer sdioUnlock(b, lock_required);
+ if (size <= 1) {
+ // Unmasked; see `hostedSdioReadBlock`.
+ hal.sdmmc.cmd52Write(sdio_func, @intCast(reg), data[0]) catch return esp_fail;
+ return esp_ok;
+ }
+ return blockTransfer(.write, reg, data, size);
+}
+
+fn blockTransfer(comptime dir: enum { read, write }, reg: u32, data: [*]u8, size: u16) c_int {
+ // H_SDIO_{TX,RX}_LEN_TO_TRANSFER: (x + 3) & ~3.
+ const total: u32 = (@as(u32, size) + 3) & ~@as(u32, 3);
+ var remaining: u32 = total;
+ var addr: u32 = reg;
+ var at: u32 = 0;
+
+ while (remaining >= esp_block_size) {
+ // H_SDIO_{TX,RX}_BLOCKS_TO_TRANSFER: all whole blocks in one command unless the build
+ // forces one block at a time (port_esp_hosted_host_config.h:297-308).
+ const chunk = (remaining / esp_block_size) * esp_block_size;
+ const slice = data[at .. at + chunk];
+ switch (dir) {
+ .read => hal.sdmmc.cmd53Read(sdio_func, @intCast(addr), slice, true) catch return esp_fail,
+ .write => hal.sdmmc.cmd53Write(sdio_func, @intCast(addr), slice, true) catch return esp_fail,
+ }
+ remaining -= chunk;
+ at += chunk;
+ addr += chunk;
+ }
+ if (remaining > 0) {
+ const slice = data[at .. at + remaining];
+ switch (dir) {
+ .read => hal.sdmmc.cmd53Read(sdio_func, @intCast(addr), slice, true) catch return esp_fail,
+ .write => hal.sdmmc.cmd53Write(sdio_func, @intCast(addr), slice, true) catch return esp_fail,
+ }
+ }
+ return esp_ok;
+}
+
+/// `_h_sdio_wait_slave_intr`: block until the C6 asserts its SDIO interrupt on D1.
+///
+/// The arming order is IDF's, from `sd_host_sdmmc.c:396-426`: mask the card interrupt, drop the
+/// previous wake's latch, look once at what is pending, and only then unmask and sleep. The look
+/// is not optional - the capture is negedge-triggered, so an edge that arrived while this task was
+/// awake is not going to arrive again.
+///
+/// ### The storm this function used to cause
+///
+/// Measured on the die: the first call here killed the machine. Every task starved, including one
+/// that does nothing but sleep and print a heartbeat, from the instant `configureLine` set the
+/// line's IE bit. On a cooperative scheduler nothing that *blocks* can do that. It was an
+/// interrupt storm.
+///
+/// The controller drives a single line into the CLIC and asserts it whenever `RINTSTS & INTMASK`
+/// (or the IDMAC's `IDSTS & IDINTEN`) is non-zero - not just for the card interrupt this function
+/// waits on. Two separate causes were holding it high permanently: `INTMASK` carried
+/// `Event.default`, whose card-detect bit no command path ever clears, and `initDma` had unmasked
+/// the IDMAC's three completion interrupts with nothing ever clearing `IDSTS` after a transfer.
+/// Either one is enough.
+///
+/// A level-triggered line whose source is still asserting re-enters the moment the handler
+/// `mret`s. The old `sdioDispatch` tested `slaveInterruptPending()` *first* and took an early
+/// return when the cause was not the card interrupt - without masking or clearing anything. So
+/// the line stayed high, the core re-entered, and it never came back. `hal.intr`'s module comment
+/// describes this precise failure for lines the ROM left armed (`intr.zig:512-518`); this was the
+/// same bug, self-inflicted.
+///
+/// Three invariants fix it, none of which depends on guessing which bit was set:
+///
+/// * **the handler deasserts on every path**, before it reads anything at all;
+/// * **only this function arms.** `hal.intr.configureLine` enables the line as its last act,
+/// which is exactly what must not happen at configuration time, so the line is configured
+/// with the individual setters and left disabled;
+/// * **the controller is silent unless armed** - `hal.sdmmc`'s half of the fix, which reduces
+/// the set of possible causes to one.
+///
+/// ### Level, not edge, and why the answer is not "either works"
+///
+/// Two different trigger behaviours meet on this path, and conflating them sends you tuning the
+/// wrong knob. **Card to controller is an edge**: D1's negedge is captured once into RINTSTS,
+/// which is why step 3 below reads D1's *pad* rather than the latch before sleeping.
+/// **Controller to CLIC is a level**: RINTSTS is a sticky write-1-to-clear latch and MINTSTS is
+/// `RINTSTS & INTMASK`, so the controller's single output stays asserted until software masks or
+/// clears the bit that raised it. The CLIC trigger describes that second stage and only that one,
+/// so it is `.level`.
+///
+/// `.edge` would be wrong three times over, and the third is the one that bites. It would need an
+/// `edgeAck` this handler does not do. It would drop a re-assert that arrived while the line was
+/// still high, because there is no second rising edge to capture. And it would *hide* a handler
+/// that fails to deassert - the re-entry would stop, the storm would go away, and the bug would
+/// still be there, waiting for the day something else holds MINTSTS non-zero. A level trigger
+/// makes that failure loud and local, which is worth more than a trigger type that works by luck.
+///
+/// ### The precondition that was missing, and was read as a mask that would not stick
+///
+/// The line was configured, routed and armed - and nothing in this image had taken ownership of
+/// the interrupt controller. `takeInterruptControl` is that step and its comment has the detail;
+/// the short form is that `hal.intr.setHandler` files a handler in a table the core does not
+/// consult until `hal.intr.init()` has written mtvec and MTVT, and that the threshold and
+/// mstatus.MIE are equally this image's job and were nobody's. Neither diagnostic that reported
+/// `intmask=0` could have shown anything else, because both read INTMASK after a deliberate
+/// disarm; `MARK PORT_SDIO_ARM` carries the read-back that can.
+///
+/// `ticks_to_wait` is FreeRTOS ticks. The only caller (`sdio_drv.c:1191`) passes
+/// `HOSTED_BLOCK_MAX`, so the bounded branch exists for completeness; at ESP-Hosted's recommended
+/// tick rate one tick is one millisecond.
+fn hostedSdioWaitSlaveIntr(ctx: ?*anyopaque, ticks_to_wait: u32) callconv(.c) c_int {
+ if (busCtx(ctx) == null) return esp_fail;
+
+ // One unconditional trip round the run queue, before anything else.
+ //
+ // Every other path out of this function can return without ever having slept: the pad read at
+ // step 3, the latch read after it, and `sdioPoll`'s fast path all answer "yes, now". That is
+ // correct - and it means a card holding D1 low that the C declines to drain (no NEW_PACKET
+ // bit, `sdio_drv.c:1247-1251`) turns `sdio_read_task`'s `for (;;)` into a loop with no
+ // yield in it anywhere, because the C has none of its own either. A blocking entry point that
+ // can return without blocking has to supply the scheduling point itself; the alternative is
+ // the same total starvation as the interrupt storm, reached by a different road.
+ state.io.sleep(.zero, os.clock) catch {};
+
+ // Configured off by default on this board: see `Config.sdio_use_interrupt`. Checked before the
+ // line is ever configured, so with polling selected the CLIC is not touched at all.
+ if (!config.sdio_use_interrupt) return sdioPoll(ticks_to_wait);
+
+ // Enough foreign handler entries, or enough calls the interrupt failed to deliver, and this
+ // line is not usable on this board whatever the mask says. Poll instead: slower per look, but
+ // bounded, proven, and faster than a 20 ms re-look that is carrying the transport on its own.
+ if (state.sdio_intr_foreign >= sdio_foreign_limit) return sdioPoll(ticks_to_wait);
+ if (state.sdio_intr_missed >= sdio_missed_limit) return sdioPoll(ticks_to_wait);
+
+ if (!sdio_line_configured) {
+ sdio_line_configured = true;
+ // First, and the step whose absence produced every LAPSE this board has reported: mtvec,
+ // MTVT, the threshold and mstatus.MIE.
+ takeInterruptControl();
+ hal.intr.route(hal.sdmmc.interrupt_source, config.sdio_clic_line);
+ // `hal.intr.configureLine` in its documented order, minus the `setEnabled(line, true)` it
+ // finishes with. See the storm note: enabling here is the bug.
+ hal.intr.setHandler(config.sdio_clic_line, sdioDispatch);
+ hal.intr.setTrigger(config.sdio_clic_line, .level);
+ hal.intr.setPriority(config.sdio_clic_line, sdio_clic_priority);
+ hal.intr.setVectored(config.sdio_clic_line, false);
+ hal.intr.setEnabled(config.sdio_clic_line, false);
+
+ // The whole delivery chain above the controller, once, before the first sleep. Each field
+ // is a distinct way for the line to exist and never arrive, and each has a different fix:
+ // `routed=99` is a matrix write that missed, `routed` unequal to `line` is two owners of
+ // one line, `thresh >= prio` masks it however armed it is (the comparison is inclusive),
+ // `mie=0` masks everything, and `mtvec` unequal to `want_mtvec` means the handler the core
+ // would reach is not this image's.
+ note("MARK PORT_SDIO_CLIC line=%u source=%u routed=%u prio=%u trig=%u thresh=%u mie=%u mtvec=0x%08x want_mtvec=0x%08x\r\n", .{
+ @as(u32, config.sdio_clic_line),
+ @as(u32, @intFromEnum(hal.sdmmc.interrupt_source)),
+ @as(u32, hal.intr.routedLine(hal.sdmmc.interrupt_source) orelse 99),
+ @as(u32, hal.intr.getPriority(config.sdio_clic_line)),
+ @as(u32, @intFromEnum(hal.intr.getTrigger(config.sdio_clic_line))),
+ @as(u32, hal.intr.getThreshold()),
+ @as(u32, @intFromBool(hal.intr.globalEnabled())),
+ hal.intr.readMtvec(),
+ hal.intr.trapEntryAddress() | hal.intr.mtvec_mode_clic,
+ });
+ }
+
+ // A bounded wait that loops, rather than the unbounded one the caller asked for.
+ //
+ // The lost-edge case that used to need this is now handled properly at step 3 of the arming
+ // sequence below, so this is no longer the mechanism - it is the net under it. It stays
+ // because an unbounded futex wait is precisely the shape of failure that cost an afternoon:
+ // silent, indistinguishable from a card that never called, and impossible to report on. A
+ // 20 ms re-look turns "the radio is dead" into `MARK PORT_SDIO_LAPSE` with the registers
+ // attached, and costs that latency only on beats where the interrupt did not arrive.
+ //
+ // `sdio_drv.c:1188` is right that a finite wait is unusable *for the caller*, so the loop, not
+ // the wait, is what honours `HOSTED_BLOCK_MAX`: this function still only returns when there is
+ // something to report. The property gained is that no path through it can be silent for ever.
+ const bounded = ticks_to_wait != std.math.maxInt(u32);
+ const deadline = os.nowMs(state.io) + ticks_to_wait;
+
+ while (true) {
+ // Read before arming, so an interrupt taken between here and the futex wait cannot be
+ // lost: `futexWaitTimeout` returns immediately on a value that no longer matches.
+ const seen = state.sdio_intr_epoch.load(.acquire);
+
+ // Steps 1-4 of `sd_host_sdmmc.c:404-426`, in that order, as written out on
+ // `hal.sdmmc.setSlaveInterruptEnabled`. Getting the order wrong loses wakeups; getting
+ // step 3 wrong loses them permanently.
+ hal.sdmmc.setSlaveInterruptEnabled(false);
+ hal.sdmmc.clearSlaveInterrupt();
+
+ // Step 3, and the one that cannot be done with the controller's registers alone. RINTSTS
+ // is a latch: it says "a negedge was captured", and step 2 has just thrown that away. D1's
+ // pad is a level: it says "the card is holding the line low *now*". A C6 that is still
+ // waiting to be drained is exactly the second without the first, and sleeping on it waits
+ // for an edge that has already happened. The latch is tested too, for the window between
+ // the clear above and this read.
+ //
+ // This is not a window that lapsed - nothing has been armed and nothing has slept - so it
+ // leaves `sdio_intr_missed` alone.
+ if (hal.sdmmc.slaveInterruptAsserted() or hal.sdmmc.slaveInterruptPending()) return esp_ok;
+
+ // Source first, CLIC last: the line must not be deliverable while the only cause it is
+ // allowed to have is still masked. The unmask reads INTMASK back inside its own masked
+ // region, which is the only reading of that register that can answer "did it stick".
+ const armed = hal.sdmmc.armSlaveInterrupt();
+ state.sdio_armed_intmask = armed.intmask;
+ state.sdio_armed_mintsts = armed.mintsts;
+ hal.intr.setEnabled(config.sdio_clic_line, true);
+
+ // `stuck=1` retires the "the unmask does not stick" hypothesis; `stuck=0` confirms it, with
+ // the word that was wanted printed beside the word the register returned. Budgeted,
+ // because it is a property of the configuration rather than of the beat.
+ sdioMark(&state.sdio_arm_marks, "MARK PORT_SDIO_ARM stuck=%u want=0x%08x intmask=0x%08x mintsts=0x%08x rintsts=0x%08x ie=%u\r\n", .{
+ @as(u32, @intFromBool(armed.stuck())),
+ armed.want,
+ armed.intmask,
+ armed.mintsts,
+ armed.rintsts,
+ @as(u32, @intFromBool(hal.intr.isEnabled(config.sdio_clic_line))),
+ });
+
+ // Timeout and cancelation are indistinguishable here and neither is a result; the epoch is
+ // the only thing that says whether the handler ran.
+ state.io.futexWaitTimeout(u32, &state.sdio_intr_epoch.raw, seen, .{
+ .duration = .{ .clock = os.clock, .raw = .fromMilliseconds(sdio_relook_ms) },
+ }) catch {};
+
+ // The CLIC's own pending bit, read *before* the disarm, because it is the discriminator a
+ // lapse otherwise has no way to report: `pend=1` with no handler entry means the CLIC
+ // latched this line and the core never took it, so the fault is mtvec, the threshold or
+ // MIE rather than the controller or the C6.
+ const clic_pending = hal.intr.isPending(config.sdio_clic_line);
+
+ // Idempotent: on a real wake the handler already did both. On a lapse it did not, and an
+ // armed line with nobody waiting is how a storm gets its second chance.
+ disarmSdioLine();
+
+ if (state.sdio_intr_epoch.load(.acquire) != seen) {
+ // The handler ran. It deliberately does not clear the latched SDIO bit - clearing it
+ // while D1 is still low would drop the next wakeup - so the bit still being set is
+ // what distinguishes "the C6 called" from "something else held the controller's line
+ // high and the handler is who noticed".
+ if (hal.sdmmc.slaveInterruptPending()) {
+ state.sdio_intr_foreign = 0;
+ state.sdio_intr_missed = 0;
+ // **The line that says the interrupt works.** Until now a successful delivery was
+ // the only outcome that printed nothing at all, so a console showing idle lapses
+ // and no wakes was indistinguishable from a console showing a dead CLIC - which is
+ // exactly the ambiguity that made the last flash inconclusive. `mintsts` is the
+ // word the controller's output follows, captured at handler entry before the
+ // disarm zeroed it; this slot's bit set in it is delivery proven end to end.
+ sdioMark(&state.sdio_wake_marks, "MARK PORT_SDIO_WAKE n=%u mintsts=0x%08x rintsts=0x%08x idsts=0x%08x\r\n", .{
+ state.sdio_intr_epoch.load(.acquire),
+ state.sdio_intr_mintsts.load(.acquire),
+ state.sdio_intr_rintsts.load(.acquire),
+ state.sdio_intr_idsts.load(.acquire),
+ });
+ return esp_ok;
+ }
+ state.sdio_intr_foreign += 1;
+ sdioMark(&state.sdio_foreign_marks, "MARK PORT_SDIO_FOREIGN n=%u mintsts=0x%08x rintsts=0x%08x idsts=0x%08x armed_intmask=0x%08x\r\n", .{
+ state.sdio_intr_foreign,
+ state.sdio_intr_mintsts.load(.acquire),
+ state.sdio_intr_rintsts.load(.acquire),
+ state.sdio_intr_idsts.load(.acquire),
+ state.sdio_armed_intmask,
+ });
+ if (state.sdio_intr_foreign >= sdio_foreign_limit) {
+ note("MARK PORT_SDIO_POLLING abandoning CLIC line %u\r\n", .{
+ @as(u32, config.sdio_clic_line),
+ });
+ return sdioPoll(ticks_to_wait);
+ }
+ } else {
+ // Nobody entered the handler. Ask both ends directly before calling it a lapse - the
+ // pad for a card asserting now, the latch for an edge captured while the CLIC was
+ // being taken down.
+ //
+ // This is the one reading that separates the two things a lapse can mean, and it is
+ // why `sdio_intr_lapses` alone is not a fault signal. **The card is calling and the
+ // interrupt did not deliver it**: a real frame has just paid up to `sdio_relook_ms` of
+ // latency, the re-look is doing the interrupt's job, and four of those in a row is a
+ // configuration that will not fix itself - so the line goes back to the poll, which is
+ // twenty times quicker at exactly this.
+ if (hal.sdmmc.slaveInterruptAsserted() or hal.sdmmc.slaveInterruptPending()) {
+ state.sdio_intr_missed += 1;
+ sdioMark(&state.sdio_missed_marks, "MARK PORT_SDIO_MISSED n=%u pend=%u armed_intmask=0x%08x armed_mintsts=0x%08x rintsts=0x%08x\r\n", .{
+ state.sdio_intr_missed,
+ @as(u32, @intFromBool(clic_pending)),
+ state.sdio_armed_intmask,
+ state.sdio_armed_mintsts,
+ hal.sdmmc.interruptStatusRaw(),
+ });
+ if (state.sdio_intr_missed >= sdio_missed_limit) {
+ note("MARK PORT_SDIO_POLLING abandoning CLIC line %u after %u undelivered calls\r\n", .{
+ @as(u32, config.sdio_clic_line),
+ state.sdio_intr_missed,
+ });
+ return sdioPoll(ticks_to_wait);
+ }
+ return esp_ok;
+ }
+
+ // The other meaning: the C6 had nothing to say. Free, and the resting state of an idle
+ // link - which is the whole point of waiting on an interrupt instead of polling.
+ state.sdio_intr_lapses +%= 1;
+ // `armed_*` is what the mask was during the window that lapsed; `now_*` is the
+ // disarmed state. Both are printed so the two can no longer be mistaken for each
+ // other: `now_intmask=0` is expected here, and always was.
+ sdioMark(&state.sdio_lapse_marks, "MARK PORT_SDIO_LAPSE n=%u armed_intmask=0x%08x armed_mintsts=0x%08x pend=%u now_rintsts=0x%08x now_intmask=0x%08x\r\n", .{
+ state.sdio_intr_lapses,
+ state.sdio_armed_intmask,
+ state.sdio_armed_mintsts,
+ @as(u32, @intFromBool(clic_pending)),
+ hal.sdmmc.interruptStatusRaw(),
+ hal.sdmmc.interruptMaskRaw(),
+ });
+ }
+
+ // The one diagnostic with no budget, because the ratio it reports is the whole question and
+ // it stays interesting after every other line has gone quiet. `wakes` is handler entries:
+ // rising with `lapses` means the interrupt is carrying the transport and the re-look is
+ // only covering the idle gaps, flat at zero means the CLIC is not delivering and the
+ // re-look is doing all of it.
+ state.sdio_windows +%= 1;
+ if (state.sdio_windows % sdio_tally_every == 0) {
+ note("MARK PORT_SDIO_TALLY windows=%u wakes=%u lapses=%u missed=%u foreign=%u\r\n", .{
+ state.sdio_windows,
+ state.sdio_intr_epoch.load(.acquire),
+ state.sdio_intr_lapses,
+ state.sdio_intr_missed,
+ state.sdio_intr_foreign,
+ });
+ }
+
+ if (bounded and os.nowMs(state.io) >= deadline) return esp_fail;
+ }
+}
+
+/// Consecutive foreign handler entries after which the interrupt is abandoned for polling. Four,
+/// because one can be a race and four in a row is a configuration that will not fix itself.
+const sdio_foreign_limit: u32 = 4;
+
+/// Consecutive undelivered calls - lapsed windows whose re-look found the card already asserting -
+/// after which the interrupt is abandoned for polling.
+///
+/// Four, for the same reason as `sdio_foreign_limit`: one can be a race against the CLIC being
+/// taken down, four in a row is a configuration. At `sdio_relook_ms` each that is 80 ms of
+/// degraded latency before the line is given up, well inside one of the transport's own 200 ms
+/// retry turns (`transport_drv.c:233`).
+///
+/// This bound is what makes flipping `sdio_use_interrupt` to `true` an experiment rather than a
+/// bet. Without it, a board where delivery is still broken would give every received frame 20 ms
+/// instead of the poll's 1 ms, for ever, with eight budgeted MARK lines to say so. Note that it
+/// counts *undelivered calls* and not lapses: an idle card lapses every window by construction,
+/// and penalising that would trade the interrupt away 160 ms after boot on a link that was
+/// working perfectly.
+const sdio_missed_limit: u32 = 4;
+
+/// Priority for the SDIO CLIC line. `hal.intr.init` leaves the threshold at 0 and the comparison
+/// is inclusive, so 1 is the lowest value that can ever be taken. Nothing higher would win against
+/// anything: `port.zig` is the only owner of a CLIC line in this image.
+const sdio_clic_priority: u3 = 1;
+
+/// Lines each distinct diagnostic may print. A wait that gives up has to be able to say why; it
+/// does not have to say so ten thousand times.
+const sdio_mark_budget: u32 = 8;
+
+/// Arming windows between `MARK PORT_SDIO_TALLY` lines. 64 windows is at most 1.3 s of idle link
+/// at `sdio_relook_ms`, and far less when frames are flowing, so the ratio is visible within a
+/// couple of seconds of boot and costs one `ets_printf` per 64 windows.
+const sdio_tally_every: u32 = 64;
+
+/// Cadence of the polling fallback. Both the pad and the latch are single register reads, so this
+/// is a latency budget rather than a cost.
+const sdio_poll_ms: u32 = 1;
+
+/// How long one arming window sleeps before looking at the pad and the latch itself.
+///
+/// The number is a latency budget, not a timeout: an interrupt that arrives is delivered at once,
+/// and this only bounds how long a *lost* negedge can go unnoticed. 20 ms is two orders of
+/// magnitude below anything the transport's own retries care about (`transport_drv.c:233` sleeps
+/// 200 ms per turn) and two orders above the cost of the register reads it gates.
+const sdio_relook_ms: u32 = 20;
+
+fn sdioMark(budget: *u32, comptime fmt: [*:0]const u8, args: anytype) void {
+ if (budget.* >= sdio_mark_budget) return;
+ budget.* += 1;
+ note(fmt, args);
+}
+
+/// The interrupt-free path. `sdio_drv.c:1188` insists a finite wait is unusable here, so an
+/// unbounded `ticks_to_wait` blocks until the card really does call - it just yields between
+/// checks instead of sleeping on a futex.
+///
+/// The clear before returning is load-bearing. `sdio_clear_intr` writes the *slave's*
+/// `ESP_SLAVE_INT_CLR_REG` (`sdio_drv.c:423-427`); nothing in the C touches this controller's
+/// RINTSTS, so a latched bit left set here makes the next call return immediately, and
+/// `sdio_read_task`'s loop contains no other yield. That is the same total starvation the
+/// interrupt storm caused, reached the slow way - and it is why the interrupt path clears at the
+/// top of every arm rather than on the way out.
+fn sdioPoll(ticks_to_wait: u32) c_int {
+ _ = ticks_to_wait;
+
+ // Fast path: if either controller-side signal says the card is calling, say so at once. Both
+ // are real when they do fire, and they cost two register reads.
+ if (hal.sdmmc.slaveInterruptAsserted() or hal.sdmmc.slaveInterruptPending()) {
+ hal.sdmmc.clearSlaveInterrupt();
+ return esp_ok;
+ }
+
+ // Otherwise sleep briefly and report "look again" - deliberately, and this is the whole point of
+ // this function.
+ //
+ // Neither controller-side signal is a trustworthy answer to "does the slave have a packet":
+ //
+ // - `slaveInterruptPending` reads RINTSTS bit 16+slot, which LATCHES an edge. The card asserts
+ // once per packet; clear that latch while the card still has data queued and the edge is
+ // gone, with nothing to re-create it until the *next* packet arrives.
+ // - `slaveInterruptAsserted` reads D1's pad level, and D1 is a DATA line. The SDMMC controller
+ // owns that pad throughout every CMD53, and the SDIO interrupt is only meaningful in defined
+ // windows between blocks. ESP-IDF never reads it for this: `sdmmc_host_io_int_wait` consults
+ // the controller's own status word instead.
+ //
+ // Measured consequence of trusting them: the receive counter reached somewhere between 6 and 18
+ // frames and then froze for ever, while transmits kept working. The board took a real DHCP lease
+ // - the host speaks first there - and then answered no ARP and no ping.
+ //
+ // The authority on "is there a packet" is the slave's own ESP_SLAVE_INT_RAW_REG, and
+ // `sdio_read_task` already reads it on every pass and tests BIT(SDIO_INT_NEW_PACKET) itself
+ // (sdio_drv.c:1204, :1247). examples/sdiocheck.zig proved that register answers reliably over
+ // CMD53. So when the cheap signals say nothing, the right move is not to guess - it is to yield
+ // and let the caller ask the slave. `HOSTED_BLOCK_MAX` is honoured in the sense that matters:
+ // this returns only when the caller has something to do, and "read your registers again" always
+ // is.
+ //
+ // The cost is one register read per `sdio_poll_ms` while the link is idle. The benefit is that a
+ // lost edge can no longer strand a packet.
+ state.io.sleep(.fromMilliseconds(sdio_poll_ms), os.clock) catch return esp_fail;
+ return esp_ok;
+}
+
+/// Take ownership of the interrupt controller, once, before any line this file configures can be
+/// delivered.
+///
+/// **This is the step whose absence made the interrupt path look like an INTMASK write that would
+/// not stick.** `hal.intr.init()` is not decoration; it is what makes an interrupt reach *this
+/// image* at all, and nothing in the `-Dapp=examples/http.zig` build had ever called it.
+/// `examples/intrcheck.zig` and `examples/portcheck.zig` do; `examples/http.zig`,
+/// `examples/radio.zig` and everything under `src/` did not. So when
+/// `hal.intr.setEnabled(sdio_clic_line, true)` ran on this board, four separate preconditions were
+/// missing:
+///
+/// * **mtvec still belonged to the bootloader.** `hal.intr.init` fills the vector table, writes
+/// MTVT and writes `mtvec = trapEntry | 3` (`intr.zig:558-577`). Without it, the CLIC vectors
+/// wherever the ROM left mtvec pointing, `hal.intr.setHandler` files `sdioDispatch` in a table
+/// the core never consults, and the core leaves this image and does not come back. That is the
+/// reported "whole board going silent right after Open data path at slave": not a storm, an
+/// exit.
+/// * **whatever the ROM armed was still armed** (`intr.zig:512-518`), so the first MIE could
+/// also deliver somebody else's level-triggered source into the same nowhere.
+/// * **the threshold was never opened.** The comparison is inclusive and this line runs at
+/// priority 1, so a threshold the ROM left at 1 or above masks it for ever - which is a LAPSE
+/// every window with no handler entry and nothing else wrong anywhere.
+/// * **mstatus.MIE.** `hal.intr.init` deliberately leaves it clear and says that turning it on
+/// is the caller's decision (`intr.zig:531`). Nothing in this build was that caller.
+///
+/// Enabling MIE here is safe *because* `init()` ran first: it has just detached all 128 sources
+/// and cleared all 48 enables, so the only lines that can be delivered afterwards are the ones
+/// this file enables itself.
+///
+/// Idempotent, and the test is the fact that matters rather than a flag of our own - if mtvec
+/// already points at this image's trap entry then somebody has already done this, and re-running
+/// `init()` would destroy `hal.intr.boot_state`, the only record of what the bootloader handed
+/// over. Both call sites (here and `hostedConfigGpioAsInterrupt`) run it before they touch a line,
+/// so whichever is first does the work and the other finds it done - which matters, because
+/// `init()` detaches every source and would otherwise silence a line the other had just armed.
+fn takeInterruptControl() void {
+ if (hal.intr.readMtvec() != (hal.intr.trapEntryAddress() | hal.intr.mtvec_mode_clic)) {
+ hal.intr.init();
+ // A fault is the one failure on this path that cannot report itself: `hal.intr` parks the
+ // core with the numbers recorded and no way to print them. Only installed if the
+ // application has not claimed the hook.
+ if (hal.intr.on_fault == null) hal.intr.on_fault = reportFault;
+ note("MARK PORT_INTR_OWN mtvec=0x%08x want=0x%08x mtvt=0x%08x thresh=%u boot_mie=%u rom_lines=0x%08x rom_sources=%u\r\n", .{
+ hal.intr.readMtvec(),
+ hal.intr.trapEntryAddress() | hal.intr.mtvec_mode_clic,
+ hal.intr.readMtvt(),
+ @as(u32, hal.intr.getThreshold()),
+ @as(u32, @intFromBool(hal.intr.boot_state.mie)),
+ hal.intr.boot_state.enabled_lines,
+ hal.intr.boot_state.routed_sources,
+ });
+ }
+ if (!hal.intr.globalEnabled()) hal.intr.globalEnable();
+}
+
+/// Last words. `hal.intr.intrFault` has already recorded the fault and will park the core after
+/// this returns, so this is the only chance the numbers get to leave the board.
+fn reportFault(f: hal.intr.Fault) void {
+ note("MARK PORT_INTR_FAULT mcause=0x%08x mepc=0x%08x mtval=0x%08x taken=%u last_id=%u spurious=%u\r\n", .{
+ f.mcause,
+ f.mepc,
+ f.mtval,
+ hal.intr.taken,
+ hal.intr.last_clic_id,
+ hal.intr.spurious,
+ });
+}
+
+/// Deassert and disable, in that order. The guarantee the handler needs: after this the line
+/// cannot be taken again until somebody arms it.
+fn disarmSdioLine() void {
+ hal.sdmmc.setSlaveInterruptEnabled(false);
+ hal.intr.setEnabled(config.sdio_clic_line, false);
+}
+
+var sdio_line_configured: bool = false;
+
+/// The CLIC handler. Runs with `mstatus.MIE` clear on the interrupted stack
+/// (`hal.intr.Handler`), so what follows cannot itself be interrupted - and after the first
+/// statement it cannot be re-entered either.
+fn sdioDispatch(line: u5) void {
+ _ = line;
+ // One load, before the disarm, and it is safe for a reason worth stating rather than assuming.
+ //
+ // The invariant is "no path returns from this handler with the line still asserted", because a
+ // level line re-enters the instant the handler `mret`s and that hangs the core. What breaks the
+ // invariant is a *branch* - any test that can return early. A read cannot return, so a load
+ // placed here costs the invariant nothing.
+ //
+ // It has to be here, though: MINTSTS is `RINTSTS & INTMASK`, so the disarm below zeroes it and
+ // reading it afterwards would report 0 on every entry - the same mistake the old INTMASK read
+ // made one line lower. This is the register the controller's output actually follows, so its
+ // value at the moment of delivery is the direct answer to "did the card interrupt reach the
+ // CLIC, or did something else".
+ const mintsts_at_entry = hal.sdmmc.interruptStatusMasked();
+
+ // Unconditional, and first among the *stores*. A level-triggered line does not deassert because
+ // the handler returned; masking the source and dropping the CLIC's enable are the only two
+ // things that stop it, and this handler does not know which status bit is holding the line up.
+ // Every test placed before this point is a chance to return with the line still asserted, which
+ // is not a missed interrupt - it is a hang of the whole core.
+ disarmSdioLine();
+
+ // The rest of what the line looked like at entry, and the reason
+ // `hal.sdmmc.interruptStatusRaw` and `hal.sdmmc.dmaStatusRaw` exist. Both of these registers
+ // are sticky, so reading them after the disarm loses nothing.
+ //
+ // INTMASK is deliberately *not* read here. It is not sticky, the disarm has just rewritten it,
+ // and a `MARK PORT_SDIO_FOREIGN` carrying that value only ever said that the disarm worked. The
+ // mask that was actually in force is `state.sdio_armed_intmask`, read back by the arm inside its
+ // own masked region.
+ state.sdio_intr_mintsts.store(mintsts_at_entry, .release);
+ state.sdio_intr_rintsts.store(hal.sdmmc.interruptStatusRaw(), .release);
+ state.sdio_intr_idsts.store(hal.sdmmc.dmaStatusRaw(), .release);
+
+ // Wake unconditionally too. The waiter can tell a real card interrupt from a foreign one, and
+ // a waiter that is told is a waiter that can report; returning silently is how the old handler
+ // turned a misconfigured mask into a wait that never ended.
+ _ = state.sdio_intr_epoch.fetchAdd(1, .release);
+ state.io.futexWake(u32, &state.sdio_intr_epoch.raw, 1);
+}
+
+// ============================================================================ 7. events
+
+fn hostedEventWifiPost(event_id: i32, event_data: ?*anyopaque, event_data_size: usize, ticks_to_wait: u32) callconv(.c) c_int {
+ _ = ticks_to_wait;
+ deliver(.{
+ .base = .wifi,
+ .id = event_id,
+ .data = sliceOf(event_data, event_data_size),
+ });
+ return esp_ok;
+}
+
+fn hostedEventPost(event_base: EventBase, event_id: i32, event_data: ?*anyopaque, event_data_size: usize, ticks_to_wait: u32) callconv(.c) c_int {
+ _ = ticks_to_wait;
+ deliver(.{
+ .base = .{ .named = event_base },
+ .id = event_id,
+ .data = sliceOf(event_data, event_data_size),
+ });
+ return esp_ok;
+}
+
+fn sliceOf(p: ?*anyopaque, len: usize) ?[]const u8 {
+ const q = p orelse return null;
+ if (len == 0) return null;
+ const b: [*]const u8 = @ptrCast(q);
+ return b[0..len];
+}
+
+/// `ticks_to_wait` is dropped, and that is a real difference. `esp_event_post` copies the payload
+/// into a queue and can block when that queue is full, which is what the argument is for. This
+/// calls the application straight through, on the posting task, so there is no queue to fill and
+/// nothing to wait for - but it also means a slow handler stalls the transport task that posted the
+/// event. The application is expected to copy what it needs and return.
+fn deliver(e: Event) void {
+ const h = state.on_event orelse {
+ // Silent by default would hide association and disconnection reasons, which is exactly
+ // what a bring-up needs to see.
+ switch (e.base) {
+ .wifi => note("MARK PORT_EVENT wifi id=%d len=%u (no handler)\r\n", .{ e.id, @as(u32, @intCast(if (e.data) |d| d.len else 0)) }),
+ .named => |n| note("MARK PORT_EVENT %s id=%d len=%u (no handler)\r\n", .{ n, e.id, @as(u32, @intCast(if (e.data) |d| d.len else 0)) }),
+ }
+ return;
+ };
+ h(e);
+}
+
+// ============================================================================ misc real entries
+
+/// `hosted_init_hook` warns if `CONFIG_FREERTOS_HZ` is below ESP-Hosted's recommendation
+/// (`port_esp_hosted_host_os.c:150-158`). There is no tick here at all - `std.Io`'s timebase is
+/// `hal.systimer`'s 16 MHz counter and sleeps are absolute deadlines, not tick counts - so the
+/// jitter that warning is about does not exist. Announce the port instead, which is the one line
+/// that proves this table is the one being called.
+fn hostedInitHook() callconv(.c) void {
+ note("MARK PORT_HOOK zig port installed=%u timers=%u\r\n", .{
+ @as(u32, @intFromBool(state.installed)),
+ @as(u32, config.timer_slots),
+ });
+}
+
+/// `_h_restart_host` reboots the host when the slave has stopped answering
+/// (`transport_drv.c:70`, `sdio_drv.c:578`, and the init-timeout callback).
+///
+/// ESP-IDF calls `esp_restart`. There is no `esp_restart` here and, more to the point, a bring-up
+/// that silently reboots is a bring-up you cannot debug: the interesting state is the state at the
+/// moment the slave went quiet. So this reports and parks, with interrupts left on so the console
+/// still works and a debugger can still attach.
+fn hostedRestartHost() callconv(.c) c_int {
+ const s = stats();
+ note("MARK PORT_RESTART_HOST requested; parking. heap live=%u reserved=%u peak=%u blocks=%u fail=%u stubs=%u\r\n", .{
+ @as(u32, @intCast(s.bytes_live)),
+ @as(u32, @intCast(s.bytes_reserved)),
+ @as(u32, @intCast(s.peak_reserved)),
+ @as(u32, @intCast(s.blocks_live)),
+ @as(u32, @intCast(s.alloc_failures)),
+ s.stub_calls,
+ });
+ while (true) {}
+}
+
+/// `_h_get_host_wakeup_or_reboot_reason`. `HOSTED_WAKEUP_NORMAL_REBOOT` is what ESP-IDF returns
+/// when power-save is not compiled in (`port_esp_hosted_host_os.c:932-934`), and it is the truth
+/// here: this image has no sleep support, so every boot is a normal one.
+fn hostedGetWakeupReason() callconv(.c) c_int {
+ return 0; // HOSTED_WAKEUP_NORMAL_REBOOT
+}
+
+// ============================================================================ 8. loud stubs
+
+/// Every stub prints its own name and returns a failure code. The two properties that matter: a
+/// path nobody implemented is *visible* on the console rather than a hang, and the pointer is never
+/// null, so a call through it cannot be a jump to address zero.
+fn stub(comptime name: []const u8) void {
+ state.stub_calls += 1;
+ note("MARK PORT_STUB " ++ name ++ "\r\n", .{});
+}
+
+/// SPI only. ESP-IDF assigns this just once, under `H_TRANSPORT_IN_USE == H_TRANSPORT_SPI`
+/// (`port_esp_hosted_host_os.c:991`), leaving it **null** for SDIO - so under IDF, reaching this on
+/// an SDIO build is a jump to zero. Here it is a message.
+fn stubDoBusTransfer(_: ?*anyopaque) callconv(.c) c_int {
+ stub("_h_do_bus_transfer (SPI transport)");
+ return esp_fail;
+}
+
+/// `_h_printf` routes ESP-Hosted's logging through the port table. Nothing in the tree calls it -
+/// every `ESP_LOG*` goes to `esp_log_writev` directly, which is the parent's symbol - so this is
+/// unreachable in practice, and implementing it would mean either a printf formatter in Zig or a
+/// `va_list` handed across an ABI boundary that has not been validated on rv32. The tag and the
+/// unexpanded format string are printed, which is enough to identify the call site if it ever
+/// happens.
+fn stubPrintf(level: c_int, tag: [*:0]const u8, format: [*:0]const u8, ...) callconv(.c) void {
+ state.stub_calls += 1;
+ note("MARK PORT_STUB _h_printf level=%d tag=%s fmt=%s (varargs not expanded)\r\n", .{ level, tag, format });
+}
+
+fn stubSpiHdReadReg(_: u32, _: *u32, _: c_int, _: bool) callconv(.c) c_int {
+ stub("_h_spi_hd_read_reg");
+ return esp_fail;
+}
+fn stubSpiHdWriteReg(_: u32, _: *u32, _: bool) callconv(.c) c_int {
+ stub("_h_spi_hd_write_reg");
+ return esp_fail;
+}
+fn stubSpiHdReadDma(_: [*]u8, _: u16, _: bool) callconv(.c) c_int {
+ stub("_h_spi_hd_read_dma");
+ return esp_fail;
+}
+fn stubSpiHdWriteDma(_: [*]u8, _: u16, _: bool) callconv(.c) c_int {
+ stub("_h_spi_hd_write_dma");
+ return esp_fail;
+}
+fn stubSpiHdSetDataLines(_: u32) callconv(.c) c_int {
+ stub("_h_spi_hd_set_data_lines");
+ return esp_fail;
+}
+fn stubSpiHdSendCmd9() callconv(.c) c_int {
+ stub("_h_spi_hd_send_cmd9");
+ return esp_fail;
+}
+
+fn stubUartRead(_: ?*anyopaque, _: [*]u8, _: u16) callconv(.c) c_int {
+ stub("_h_uart_read");
+ return esp_fail;
+}
+fn stubUartWrite(_: ?*anyopaque, _: [*]u8, _: u16) callconv(.c) c_int {
+ stub("_h_uart_write");
+ return esp_fail;
+}
+fn stubUartFlushInput(_: ?*anyopaque) callconv(.c) c_int {
+ stub("_h_uart_flush_input");
+ return esp_fail;
+}
+
+/// Power save needs `esp_sleep`, a wakeup GPIO in the LP domain, and a hold latch this HAL does not
+/// model. ESP-IDF's own version returns -1 unless `H_HOST_PS_ALLOWED`
+/// (`port_esp_hosted_host_os.c:876-891`), so -1 is also the configured-off answer.
+fn stubConfigHostPowerSave(_: u32, _: ?*anyopaque, _: u32, _: c_int) callconv(.c) c_int {
+ stub("_h_config_host_power_save_hal_impl");
+ return -1;
+}
+fn stubStartHostPowerSave(_: u32) callconv(.c) c_int {
+ stub("_h_start_host_power_save_hal_impl");
+ return -1;
+}
+
+// ============================================================================ compile-time census
+
+/// A compile-time list of which entries are real and which are loud stubs, so the census in the
+/// module header cannot drift from the table. `port.stubbed` is what a self-test prints.
+pub const stubbed = [_][]const u8{
+ "_h_do_bus_transfer",
+ "_h_printf",
+ "_h_hold_gpio",
+ "_h_spi_hd_read_reg",
+ "_h_spi_hd_write_reg",
+ "_h_spi_hd_read_dma",
+ "_h_spi_hd_write_dma",
+ "_h_spi_hd_set_data_lines",
+ "_h_spi_hd_send_cmd9",
+ "_h_uart_read",
+ "_h_uart_write",
+ "_h_uart_flush_input",
+ "_h_config_host_power_save_hal_impl",
+ "_h_start_host_power_save_hal_impl",
+};
+
+comptime {
+ // 71 entries, 14 stubbed, 57 real.
+ assert(stubbed.len == 14);
+ assert(std.meta.fields(HostedOsiFuncs).len - stubbed.len == 57);
+}