diff --git a/frontend/src/__tests__/cyw43-virtualnet.test.ts b/frontend/src/__tests__/cyw43-virtualnet.test.ts new file mode 100644 index 00000000..20c533fe --- /dev/null +++ b/frontend/src/__tests__/cyw43-virtualnet.test.ts @@ -0,0 +1,115 @@ +/** + * cyw43-virtualnet.test.ts + * + * Unit tests for the self-contained virtual network (DHCP + ARP responder) + * that lets a joined Pico W STA reach CYW43_LINK_UP with no backend. + */ + +import { describe, it, expect } from 'vitest'; +import { DEFAULT_VNET, virtualNetReply } from '../simulation/cyw43/virtualNet'; + +const CLIENT_MAC = new Uint8Array([0x28, 0xcd, 0xc1, 0x01, 0x02, 0x03]); + +function ip16(sum: number): number { + while (sum >> 16) sum = (sum & 0xffff) + (sum >>> 16); + return (~sum) & 0xffff; +} + +/** Minimal DHCP DISCOVER/REQUEST the way lwIP frames it. */ +function buildDhcpRequest(msgType: number): Uint8Array { + const ETH = 14, IP = 20, UDP = 8; + const dhcp = new Uint8Array(240 + 7); + dhcp[0] = 1; // BOOTREQUEST + dhcp[1] = 1; dhcp[2] = 6; + dhcp.set([0x11, 0x22, 0x33, 0x44], 4); // xid + dhcp.set(CLIENT_MAC, 28); // chaddr + dhcp.set([0x63, 0x82, 0x53, 0x63], 236); // magic cookie + dhcp.set([53, 1, msgType, 0xff], 240); // option 53 + end + const total = ETH + IP + UDP + dhcp.length; + const buf = new Uint8Array(total); + const dv = new DataView(buf.buffer); + for (let i = 0; i < 6; i++) buf[i] = 0xff; // dst broadcast + buf.set(CLIENT_MAC, 6); + dv.setUint16(12, 0x0800, false); + buf[ETH] = 0x45; + dv.setUint16(ETH + 2, IP + UDP + dhcp.length, false); + buf[ETH + 9] = 17; // UDP + let s = 0; + for (let i = 0; i < IP; i += 2) s += dv.getUint16(ETH + i, false); + dv.setUint16(ETH + 10, ip16(s), false); + dv.setUint16(ETH + IP + 0, 68, false); // src port + dv.setUint16(ETH + IP + 2, 67, false); // dst port (DHCP server) + dv.setUint16(ETH + IP + 4, UDP + dhcp.length, false); + buf.set(dhcp, ETH + IP + UDP); + return buf; +} + +function dhcpReplyType(reply: Uint8Array): number { + // reply = ETH(14)+IP(20)+UDP(8)+DHCP; option 53 is right after the magic cookie. + const dhcp = 14 + 20 + 8; + let i = dhcp + 236 + 4; + while (i < reply.length) { + if (reply[i] === 0xff) break; + if (reply[i] === 0) { i++; continue; } + if (reply[i] === 53) return reply[i + 2]; + i += 2 + reply[i + 1]; + } + return 0; +} + +describe('cyw43 virtual network', () => { + it('answers DHCP DISCOVER with an OFFER carrying the client IP', () => { + const reply = virtualNetReply(DEFAULT_VNET, buildDhcpRequest(1))!; + expect(reply).not.toBeNull(); + expect(dhcpReplyType(reply)).toBe(2); // DHCPOFFER + // yiaddr (offered address) sits at DHCP offset 16. + const yi = reply.subarray(14 + 20 + 8 + 16, 14 + 20 + 8 + 20); + expect(Array.from(yi)).toEqual([...DEFAULT_VNET.clientIp]); + // Unicast back to the requesting STA. + expect(Array.from(reply.subarray(0, 6))).toEqual([...CLIENT_MAC]); + }); + + it('answers DHCP REQUEST with an ACK', () => { + const reply = virtualNetReply(DEFAULT_VNET, buildDhcpRequest(3))!; + expect(dhcpReplyType(reply)).toBe(5); // DHCPACK + }); + + it('answers an ARP who-has the gateway with the AP MAC', () => { + const a = 14; + const req = new Uint8Array(a + 28); + const dv = new DataView(req.buffer); + for (let i = 0; i < 6; i++) req[i] = 0xff; + req.set(CLIENT_MAC, 6); + dv.setUint16(12, 0x0806, false); // ARP + dv.setUint16(a + 0, 1, false); // htype + dv.setUint16(a + 2, 0x0800, false); + req[a + 4] = 6; req[a + 5] = 4; + dv.setUint16(a + 6, 1, false); // request + req.set(CLIENT_MAC, a + 8); + req.set(DEFAULT_VNET.clientIp, a + 14); + req.set(DEFAULT_VNET.serverIp, a + 24); // who-has the gateway + + const reply = virtualNetReply(DEFAULT_VNET, req)!; + expect(reply).not.toBeNull(); + expect(dv.getUint16(12, false)).toBe(0x0806); + const rdv = new DataView(reply.buffer); + expect(rdv.getUint16(a + 6, false)).toBe(2); // ARP reply + // sender HW = AP MAC, sender IP = gateway + expect(Array.from(reply.subarray(a + 8, a + 14))).toEqual([...DEFAULT_VNET.apMac]); + expect(Array.from(reply.subarray(a + 14, a + 18))).toEqual([...DEFAULT_VNET.serverIp]); + }); + + it('ignores non-DHCP UDP and ARP for other addresses', () => { + // ARP for some other IP -> no reply. + const a = 14; + const req = new Uint8Array(a + 28); + const dv = new DataView(req.buffer); + dv.setUint16(12, 0x0806, false); + dv.setUint16(a + 0, 1, false); + dv.setUint16(a + 2, 0x0800, false); + req[a + 4] = 6; req[a + 5] = 4; + dv.setUint16(a + 6, 1, false); + req.set([8, 8, 8, 8], a + 24); // not the gateway + expect(virtualNetReply(DEFAULT_VNET, req)).toBeNull(); + }); +}); diff --git a/frontend/src/__tests__/picow-cyw43-boot-harness.investigate.test.ts b/frontend/src/__tests__/picow-cyw43-boot-harness.investigate.test.ts index d6d4b834..7fed7a39 100644 --- a/frontend/src/__tests__/picow-cyw43-boot-harness.investigate.test.ts +++ b/frontend/src/__tests__/picow-cyw43-boot-harness.investigate.test.ts @@ -124,8 +124,18 @@ describe.skipIf(!process.env.CYW43_HARNESS)('Pico W cyw43 boot harness (investig const funcHist = [0, 0, 0, 0]; // count of decoded gSPI functions F0..F3 const cmdCounts = new Map(); let ledOn = false; + let packetsOut = 0; + const packetLog: string[] = []; chip.onLed((e) => { ledOn = e.on; trace.push(`** LED ${e.on ? 'ON' : 'OFF'} **`); }); chip.onConnect((e) => trace.push(`** WIFI CONNECT ssid=${e.ssid} **`)); + chip.onPacketOut((e) => { + packetsOut++; + if (packetLog.length < 12) { + const et = e.ether.length >= 14 ? ((e.ether[12] << 8) | e.ether[13]) : 0; + packetLog.push(`pkt ${e.ether.length}B ethertype=0x${et.toString(16)}` + + (et === 0x0806 ? ' ARP' : et === 0x0800 ? ' IPv4' : '')); + } + }); const key = (c: Cyw43Cmd) => `${c.write ? 'WR' : 'RD'} F${c.function} 0x${c.address.toString(16)} len=${c.length}`; @@ -412,6 +422,7 @@ describe.skipIf(!process.env.CYW43_HARNESS)('Pico W cyw43 boot harness (investig `===== IOCTL ${chip.debugIoctlStats()} =====\n` + '===== IOCTL SEQUENCE =====\n' + chip.debugIoctlLog().map((s, i) => ` ${i}: ${s}`).join('\n') + '\n' + `===== restartsAtClm=${restartsAtClm} restartsFinal=${restartCount} rxPulled=${rxPulled} =====\n` + + `===== packetsOut=${packetsOut} =====\n` + packetLog.map((s) => ` ${s}`).join('\n') + '\n' + `===== CPU FAULTS faultCount=${faultCount} =====\n` + faultLog.join('\n') + '\n' + '===== HOT PCs (busy-wait localization) =====\n' + [...pcHist.entries()].sort((a, b) => b[1] - a[1]).slice(0, 12) diff --git a/frontend/src/simulation/cyw43/Cyw43Emulator.ts b/frontend/src/simulation/cyw43/Cyw43Emulator.ts index 4eb67702..62b8405e 100644 --- a/frontend/src/simulation/cyw43/Cyw43Emulator.ts +++ b/frontend/src/simulation/cyw43/Cyw43Emulator.ts @@ -55,6 +55,7 @@ import { DEFAULT_STA_MAC, type VirtualAp, } from './virtual-ap'; +import { DEFAULT_VNET, virtualNetReply, type VirtualNetConfig } from './virtualNet'; import type { Cyw43Cmd } from './PioBusSniffer'; // ── Public event surface ──────────────────────────────────────────── @@ -80,6 +81,12 @@ export interface Cyw43EmulatorOptions { staMac?: Uint8Array; /** Optional clock for tests. */ now?: () => number; + /** + * Self-contained virtual network (DHCP/ARP responder). Defaults to on so a + * joined STA gets an IP with no backend. Pass `null` when an external bridge + * owns the network, or an object to override the addressing. + */ + virtualNet?: VirtualNetConfig | null; } type Listener = (ev: T) => void; @@ -130,6 +137,7 @@ export class Cyw43Emulator { private currentSsid = ''; private staMac: Uint8Array; private ap: VirtualAp; + private virtualNet: VirtualNetConfig | null; private eventMask = new Uint8Array(32); // up to 256 event types private inboundEvents: Uint8Array[] = []; // Async events raised WHILE handling an IOCTL are deferred until just after @@ -172,6 +180,7 @@ export class Cyw43Emulator { this.bootMs = this.now(); this.ap = opts.ap ?? DEFAULT_AP; this.staMac = opts.staMac ?? DEFAULT_STA_MAC; + this.virtualNet = opts.virtualNet === undefined ? DEFAULT_VNET : opts.virtualNet; // F2 ready at boot — driver tolerates this being true early. this.f0Regs[F0.F2_INFO >> 2] = 0x01; // Send an initial SDPCM control frame so the host's first F2 poll grants it @@ -263,10 +272,15 @@ export class Cyw43Emulator { * receives bytes destined for the simulated STA's IP address. */ injectPacket(ether: Uint8Array): void { + // Chip -> host DATA frames carry the same 4-byte BDC header as events: the + // driver reads bdc at SDPCM header_length, then the ethernet at bdc + 4 + + // (data_offset<<2). data_offset 0 => ethernet immediately follows. + const bdc = new Uint8Array(4 + ether.length); + bdc.set(ether, 4); const sdpcm = encodeSdpcm({ channel: SdpcmChannel.DATA, sequence: this.chipToHostSeq++ & 0xff, - payload: ether, + payload: bdc, }); this.pushFrame(sdpcm); } @@ -477,11 +491,18 @@ export class Cyw43Emulator { if (channel === SdpcmChannel.CONTROL) { this.handleIoctl(payload); } else if (channel === SdpcmChannel.DATA) { - // Outbound Ethernet frame. Strip BDC header (4 bytes) if present; - // for the test harness we forward raw payload. + // Outbound Ethernet frame. Strip the 4-byte BDC header the driver + // prepends, then forward to any external bridge. const BDC = 4; - const ether = payload.length >= BDC ? payload.subarray(BDC) : payload; + const ether = payload.length >= BDC ? new Uint8Array(payload.subarray(BDC)) : payload; this.firePacketOut(ether); + // Self-contained virtual network: answer DHCP / ARP so a freshly-joined + // STA gets an IP and the link advances NOIP -> UP (isconnected == True). + // Disabled when an external bridge owns the network. + if (this.virtualNet) { + const reply = virtualNetReply(this.virtualNet, ether); + if (reply) this.injectPacket(reply); + } } // Channel 1 (events) is chip → host only. } diff --git a/frontend/src/simulation/cyw43/virtualNet.ts b/frontend/src/simulation/cyw43/virtualNet.ts new file mode 100644 index 00000000..786e555b --- /dev/null +++ b/frontend/src/simulation/cyw43/virtualNet.ts @@ -0,0 +1,218 @@ +/** + * virtualNet.ts + * + * A tiny self-contained network for the CYW43 emulator. The real chip just + * bridges Ethernet frames to/from the air; for a fully local emulation (no + * backend) the virtual AP also has to answer the handful of L2/L3 exchanges a + * freshly-joined STA needs before its `network.WLAN` reports a usable link: + * + * - DHCP (DISCOVER -> OFFER, REQUEST -> ACK) so lwIP gets an IP and the link + * advances CYW43_LINK_NOIP -> CYW43_LINK_UP (isconnected() == True). + * - ARP (who-has the gateway -> is-at) so the stack can resolve the router. + * + * Everything is built by hand (Ethernet + IPv4 + UDP + DHCP/BOOTP, or ARP) with + * a correct IPv4 header checksum; the UDP checksum is left 0 (legal, and what + * most embedded DHCP servers emit). All addresses are link-local to the virtual + * AP and never leave the browser. + */ + +export interface VirtualNetConfig { + serverIp: readonly [number, number, number, number]; // AP / DHCP server / gateway + clientIp: readonly [number, number, number, number]; // address leased to the STA + netmask: readonly [number, number, number, number]; + dnsIp: readonly [number, number, number, number]; + apMac: Uint8Array; // 6 bytes — the virtual AP's MAC + leaseSecs: number; +} + +export const DEFAULT_VNET: VirtualNetConfig = { + serverIp: [192, 168, 4, 1], + clientIp: [192, 168, 4, 2], + netmask: [255, 255, 255, 0], + dnsIp: [192, 168, 4, 1], + apMac: new Uint8Array([0x02, 0x56, 0x45, 0x4c, 0x58, 0x00]), // locally-administered "VELX" + leaseSecs: 86400, +}; + +const ETH_IPV4 = 0x0800; +const ETH_ARP = 0x0806; +const IP_PROTO_UDP = 17; + +function ip16(sum: number): number { + while (sum >> 16) sum = (sum & 0xffff) + (sum >>> 16); + return (~sum) & 0xffff; +} + +/** Build an Ethernet+IPv4+UDP frame carrying `udpPayload` (server -> client). */ +function buildUdp( + cfg: VirtualNetConfig, + dstMac: Uint8Array, + srcPort: number, + dstPort: number, + udpPayload: Uint8Array, +): Uint8Array { + const ETH = 14; + const IP = 20; + const UDP = 8; + const total = ETH + IP + UDP + udpPayload.length; + const buf = new Uint8Array(total); + const dv = new DataView(buf.buffer); + + // Ethernet + buf.set(dstMac.subarray(0, 6), 0); + buf.set(cfg.apMac.subarray(0, 6), 6); + dv.setUint16(12, ETH_IPV4, false); + + // IPv4 + let o = ETH; + buf[o] = 0x45; // version 4, IHL 5 + buf[o + 1] = 0; // DSCP/ECN + dv.setUint16(o + 2, IP + UDP + udpPayload.length, false); // total length + dv.setUint16(o + 4, 0, false); // id + dv.setUint16(o + 6, 0, false); // flags/frag + buf[o + 8] = 64; // TTL + buf[o + 9] = IP_PROTO_UDP; + dv.setUint16(o + 10, 0, false); // checksum (filled below) + buf.set(cfg.serverIp, o + 12); + buf.set(dstMac === BROADCAST_MAC ? [255, 255, 255, 255] : cfg.clientIp, o + 16); + // IPv4 header checksum + let sum = 0; + for (let i = 0; i < IP; i += 2) sum += dv.getUint16(o + i, false); + dv.setUint16(o + 10, ip16(sum), false); + + // UDP (checksum 0 = not computed, legal for IPv4) + o += IP; + dv.setUint16(o + 0, srcPort, false); + dv.setUint16(o + 2, dstPort, false); + dv.setUint16(o + 4, UDP + udpPayload.length, false); + dv.setUint16(o + 6, 0, false); + buf.set(udpPayload, o + UDP); + return buf; +} + +const BROADCAST_MAC = new Uint8Array([0xff, 0xff, 0xff, 0xff, 0xff, 0xff]); +const DHCP_MAGIC = [0x63, 0x82, 0x53, 0x63]; + +interface DhcpReq { + xid: Uint8Array; // 4 bytes + chaddr: Uint8Array; // 6 bytes (client MAC) + msgType: number; // 1 = DISCOVER, 3 = REQUEST + broadcast: boolean; // client wants the reply broadcast +} + +/** Parse a DHCP request out of an Ethernet frame; null if it isn't one. */ +function parseDhcp(ether: Uint8Array): DhcpReq | null { + if (ether.length < 14 + 20 + 8 + 240) return null; + const dv = new DataView(ether.buffer, ether.byteOffset, ether.byteLength); + if (dv.getUint16(12, false) !== ETH_IPV4) return null; + const ihl = (ether[14] & 0x0f) * 4; + if (ether[14 + 9] !== IP_PROTO_UDP) return null; + const udp = 14 + ihl; + const dstPort = dv.getUint16(udp + 2, false); + if (dstPort !== 67) return null; // DHCP server port + const dhcp = udp + 8; + if (ether[dhcp] !== 1) return null; // BOOTREQUEST + const flags = dv.getUint16(dhcp + 10, false); + const xid = ether.slice(dhcp + 4, dhcp + 8); + const chaddr = ether.slice(dhcp + 28, dhcp + 34); + // Verify + skip the magic cookie, then walk options for type 53 (msg type). + const optStart = dhcp + 236; + if ( + ether[optStart] !== DHCP_MAGIC[0] || ether[optStart + 1] !== DHCP_MAGIC[1] || + ether[optStart + 2] !== DHCP_MAGIC[2] || ether[optStart + 3] !== DHCP_MAGIC[3] + ) return null; + let msgType = 0; + let i = optStart + 4; + while (i < ether.length) { + const opt = ether[i]; + if (opt === 0xff) break; // end + if (opt === 0x00) { i++; continue; } // pad + const len = ether[i + 1]; + if (opt === 53 && len >= 1) msgType = ether[i + 2]; + i += 2 + len; + } + if (msgType !== 1 && msgType !== 3) return null; // only DISCOVER / REQUEST + return { xid, chaddr, msgType, broadcast: (flags & 0x8000) !== 0 }; +} + +/** Build the DHCP OFFER/ACK reply Ethernet frame for a parsed request. */ +function buildDhcpReply(cfg: VirtualNetConfig, req: DhcpReq): Uint8Array { + // BOOTP fixed area (236) + magic (4) + options. + const replyType = req.msgType === 1 ? 2 : 5; // DISCOVER->OFFER, REQUEST->ACK + const fixed = new Uint8Array(236); + const dv = new DataView(fixed.buffer); + fixed[0] = 2; // BOOTREPLY + fixed[1] = 1; // htype ethernet + fixed[2] = 6; // hlen + fixed.set(req.xid, 4); + dv.setUint16(10, req.broadcast ? 0x8000 : 0, false); // flags + fixed.set(cfg.clientIp, 16); // yiaddr (leased address) + fixed.set(cfg.serverIp, 20); // siaddr (server) + fixed.set(req.chaddr.subarray(0, 6), 28); // chaddr + + const opts: number[] = [...DHCP_MAGIC]; + opts.push(53, 1, replyType); // DHCP message type + opts.push(54, 4, ...cfg.serverIp); // server identifier + opts.push(51, 4, ...le32be(cfg.leaseSecs)); // lease time + opts.push(1, 4, ...cfg.netmask); // subnet mask + opts.push(3, 4, ...cfg.serverIp); // router (gateway) + opts.push(6, 4, ...cfg.dnsIp); // DNS server + opts.push(0xff); // end + while (opts.length < 60) opts.push(0); // pad to a sane minimum + + const dhcp = new Uint8Array(fixed.length + opts.length); + dhcp.set(fixed, 0); + dhcp.set(opts, fixed.length); + + const dstMac = req.broadcast ? BROADCAST_MAC : req.chaddr; + return buildUdp(cfg, dstMac, 67, 68, dhcp); +} + +function le32be(v: number): number[] { + return [(v >>> 24) & 0xff, (v >>> 16) & 0xff, (v >>> 8) & 0xff, v & 0xff]; +} + +/** Parse an ARP request for the gateway; build the is-at reply, else null. */ +function buildArpReply(cfg: VirtualNetConfig, ether: Uint8Array): Uint8Array | null { + if (ether.length < 14 + 28) return null; + const dv = new DataView(ether.buffer, ether.byteOffset, ether.byteLength); + if (dv.getUint16(12, false) !== ETH_ARP) return null; + const a = 14; + if (dv.getUint16(a + 0, false) !== 1) return null; // htype ethernet + if (dv.getUint16(a + 2, false) !== ETH_IPV4) return null; // ptype IPv4 + if (dv.getUint16(a + 6, false) !== 1) return null; // opcode request + const targetIp = ether.slice(a + 24, a + 28); + // Only answer for the gateway/server address. + if (!ipEq(targetIp, cfg.serverIp)) return null; + const senderMac = ether.slice(a + 8, a + 14); + const senderIp = ether.slice(a + 14, a + 18); + + const out = new Uint8Array(14 + 28); + const odv = new DataView(out.buffer); + out.set(senderMac, 0); // dst = requester + out.set(cfg.apMac.subarray(0, 6), 6); // src = AP + odv.setUint16(12, ETH_ARP, false); + odv.setUint16(a + 0, 1, false); // htype + odv.setUint16(a + 2, ETH_IPV4, false); + out[a + 4] = 6; out[a + 5] = 4; + odv.setUint16(a + 6, 2, false); // opcode reply + out.set(cfg.apMac.subarray(0, 6), a + 8); // sender HW = AP + out.set(cfg.serverIp, a + 14); // sender IP = gateway + out.set(senderMac, a + 18); // target HW = requester + out.set(senderIp, a + 24); // target IP = requester + return out; +} + +function ipEq(a: Uint8Array, b: readonly number[]): boolean { + return a.length >= 4 && a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3]; +} + +/** + * Given an Ethernet frame the STA transmitted, return the AP's reply frame to + * inject back (DHCP OFFER/ACK or ARP is-at), or null if no reply is warranted. + */ +export function virtualNetReply(cfg: VirtualNetConfig, ether: Uint8Array): Uint8Array | null { + const dhcpReq = parseDhcp(ether); + if (dhcpReq) return buildDhcpReply(cfg, dhcpReq); + return buildArpReply(cfg, ether); +}