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 66f84fd9..7fed7a39 100644 --- a/frontend/src/__tests__/picow-cyw43-boot-harness.investigate.test.ts +++ b/frontend/src/__tests__/picow-cyw43-boot-harness.investigate.test.ts @@ -86,7 +86,21 @@ describe.skipIf(!process.env.CYW43_HARNESS)('Pico W cyw43 boot harness (investig }>((resolve) => { const sim = new Simulator(); sim.rp2040.loadBootrom(bootromB1); - sim.rp2040.logger = new ConsoleLogger(LogLevel.Error); + // Custom non-throwing logger: capture CPU faults (e.g. unaligned reads) + // with the PC so we can locate corruption WITHOUT aborting the run. + void LogLevel; void ConsoleLogger; + let faultCount = 0; + const faultLog: string[] = []; + sim.rp2040.logger = { + debug() {}, info() {}, warn() {}, + error: (_comp: string, msg: string) => { + faultCount++; + if (faultLog.length < 25) { + const pc = ((sim.rp2040 as any).core?.PC ?? 0) >>> 0; + faultLog.push(`PC=0x${pc.toString(16)} ${msg}`); + } + }, + } as any; const fwBlocks = loadUF2(new Uint8Array(readFileSync(FW_PATH)), sim.rp2040.flash); let usbConnected = false; @@ -104,11 +118,24 @@ describe.skipIf(!process.env.CYW43_HARNESS)('Pico W cyw43 boot harness (investig const rawWords: number[] = []; const trace: string[] = []; const f2Log: string[] = []; // F2/IOCTL transfers, NOT subject to the ring + const postF2: string[] = []; // verbatim trace from the first F2 write onward + let f2Seen = false; + let restartsAtClm = -1; // restartCount at the moment clm_load goes out 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}`; @@ -131,6 +158,7 @@ describe.skipIf(!process.env.CYW43_HARNESS)('Pico W cyw43 boot harness (investig if (ev.kind === 'header') { const k = key(ev.cmd); cmdCounts.set(k, (cmdCounts.get(k) ?? 0) + 1); + if (f2Seen && postF2.length < 400) postF2.push(`HDR ${formatCmd(ev.cmd)}`); } else if (ev.kind === 'payload') { // Histogram of decoded functions + capture EVERY F2 transfer. funcHist[ev.cmd.function & 3]++; @@ -140,7 +168,19 @@ describe.skipIf(!process.env.CYW43_HARNESS)('Pico W cyw43 boot harness (investig if (chip.debugInboundCount() > 0) statusReadsWithPkt++; } if (ev.cmd.function === 2 && f2Log.length < 60) { - f2Log.push(`${ev.cmd.write ? 'WR' : 'RD'} F2 a=0x${ev.cmd.address.toString(16)} len=${ev.payload.length || ev.readBytes}`); + const head = Array.from(ev.payload.slice(0, 32)) + .map((b) => b.toString(16).padStart(2, '0')).join(' '); + f2Log.push(`${ev.cmd.write ? 'WR' : 'RD'} F2 a=0x${ev.cmd.address.toString(16)} len=${ev.payload.length || ev.readBytes}` + + (ev.cmd.write ? `\n bytes: ${head}` : '')); + } + if (ev.cmd.function === 2 && ev.cmd.write && !f2Seen) { f2Seen = true; restartsAtClm = restartCount; } + // Once clm_load (first F2 write) goes out, capture EVERYTHING verbatim + // (no ring) so we can see exactly where the driver stalls afterward. + if (f2Seen && postF2.length < 400) { + postF2.push(`${formatCmd(ev.cmd)} rx=${ev.readBytes}` + + (ev.cmd.write && ev.payload.length > 0 && ev.payload.length < 8 + ? ` =0x${((ev.payload[0] | (ev.payload[1] << 8) | (ev.payload[2] << 16) | (ev.payload[3] << 24)) >>> 0).toString(16)}` + : '')); } // Skip the ~3500 firmware-block writes (len>=64) AND the backplane // window-address writes (0x1000a/b/c) that bracket each block — @@ -164,6 +204,11 @@ describe.skipIf(!process.env.CYW43_HARNESS)('Pico W cyw43 boot harness (investig .map((b) => b.toString(16).padStart(2, '0')).join(' '); trace.push(`<- (${reply.length}B) ${hex}${reply.length > 8 ? ' ...' : ''}`); } + if (f2Seen && postF2.length < 400) { + const hex = Array.from(reply.slice(0, 16)) + .map((b) => b.toString(16).padStart(2, '0')).join(' '); + postF2.push(` <- (${reply.length}B) ${hex}${reply.length > 16 ? ' ...' : ''}`); + } } } } @@ -171,17 +216,52 @@ describe.skipIf(!process.env.CYW43_HARNESS)('Pico W cyw43 boot harness (investig let rxPulled = 0; let restartCount = 0; + let txPulls = 0; + let smSteps = 0; for (const pio of (sim.rp2040 as any).pio) { for (const sm of pio.machines) { const tx = sm.txFIFO; if (!tx) continue; - const orig = tx.push.bind(tx); + // Make the TX FIFO NON-DROPPING by swapping its fixed 4-deep backing + // for an unbounded queue. rp2040js's FIFO.push silently drops when + // `used === length` (the DMA outruns the PIO drain on large bursts). + // Real hardware paces the DMA with DREQ so it never drops; the dropped + // words corrupt the 260-word F2 IOCTL writes (clm_load, ioctls), which + // is why no F2 payload ever frames. An unbounded queue retains every + // word so the PIO drains the complete stream — keeping the sniffer + // (hooked on push) and the driver's view identical (no phantom reads). + // Head-pointer queue: pull() is O(1) amortized (NO Array.shift, which is + // O(n) and turned the whole thing O(n^2) — the firmware download stalled). + const q: number[] = []; + let head = 0; + Object.defineProperty(tx, 'full', { get: () => false, configurable: true }); + Object.defineProperty(tx, 'empty', { get: () => head >= q.length, configurable: true }); + Object.defineProperty(tx, 'itemCount', { get: () => q.length - head, configurable: true }); + tx.peek = () => (head < q.length ? q[head] : 0); + tx.reset = () => { q.length = 0; head = 0; }; tx.push = (v: number) => { pushCount++; rawWords.push(v >>> 0); if (rawWords.length > 600) rawWords.splice(0, rawWords.length - 400); // ring + // Firmware/backplane bulk-write data: discard most of it (keep up to + // 4 words so the PIO drains a little then raises TXSTALL, which is all + // the driver's write path waits for). This fast-paths the ~224 KB + // download instead of bit-banging every word through the PIO. F2 + // (IOCTL) writes and all count/command words go through feedWord and + // are retained in full. + if (sniffer.inDiscardableWriteData()) { + if (q.length - head < 4) q.push(v >>> 0); + return; + } feedWord(v, sm); - return orig(v); + q.push(v >>> 0); + }; + tx.pull = () => { + txPulls++; + if (head >= q.length) return 0; + const v = q[head++]; + if (head > 8192 && head * 2 > q.length) { q.splice(0, head); head = 0; } // compact + return v; }; // Reset the sniffer at each transfer boundary: cyw43_spi_transfer // calls pio_sm_restart before pushing the count words, so this keeps @@ -203,8 +283,48 @@ describe.skipIf(!process.env.CYW43_HARNESS)('Pico W cyw43 boot harness (investig }; } } + // Crank the PIO step-rate. rp2040js runs the PIO on its own setTimeout + // loop at only 1000 steps/tick (pio.run) while the CPU's execute() burns + // 1,000,000 instructions/tick — so a CPU tick (~hundreds of ms wall) + // starves the PIO, which only gets 1000 steps between ticks. With a + // non-dropping FIFO the PIO must bit-bang every firmware word, making the + // ~224KB download take 1000x too long. The gSPI PIO runs near the system + // clock (clkdiv ~1-2), so let it do up to 1M steps/tick, breaking early + // once all TX FIFOs are drained to avoid spinning when idle. + if (typeof pio.run === 'function') { + pio.run = () => { + // Cap sized for the largest real transfer (260-word F2 IOCTL write = + // ~16k steps); firmware data is dropped so it never needs deep drain. + // Break out fast once all TX FIFOs are empty (check every 64 steps, + // require a couple of empty checks so the PIO still raises TXSTALL). + let idleChecks = 0; + for (let i = 0; i < 64_000 && !pio.stopped; i++) { + pio.step(); + if ((i & 63) === 0) { + let pending = false; + for (const m of pio.machines) { + if (m.txFIFO && !m.txFIFO.empty) { pending = true; break; } + } + if (pending) { idleChecks = 0; } else if (++idleChecks >= 2) break; + } + } + if (!pio.stopped) pio.runTimer = setTimeout(() => pio.run(), 0); + }; + } } + // Drive the WL_HOST_WAKE line (GPIO24 on Pico W). The driver's + // poll_device gates ALL bus access on this pin being high until it has + // received its first packet (cyw43_ll.c had_successful_packet). Without + // it, the first IOCTL (clm_load) is sent but the driver then polls + // forever getting -1 and never reads the response. Active-high. + let hostWakeHigh = false; + chip.onHostWake((active: boolean) => { + hostWakeHigh = active; + try { (sim.rp2040 as any).gpio[24].setInputValue(active); } catch { /* noop */ } + }); + void hostWakeHigh; + // ── raw REPL injection (mirrors test_micropython_pico.mjs) ── const cdc = new USBCDC(sim.rp2040.usbCtrl); let serial = ''; @@ -253,11 +373,40 @@ describe.skipIf(!process.env.CYW43_HARNESS)('Pico W cyw43 boot harness (investig }; let finished = false; - const deadline = setTimeout(finish, 60_000); + // Sample the CPU PC between sim ticks to find busy-wait loops (the driver + // blocks in active(True) with no bus activity, so a PC histogram localizes + // the stuck loop — DMA wait, TXSTALL wait, or a delay). + const pcHist = new Map(); + const ipsrHist = new Map(); + const pcSampler = setInterval(() => { + const core = (sim.rp2040 as any).core; + const pc = (core?.PC ?? 0) >>> 0; + pcHist.set(pc, (pcHist.get(pc) ?? 0) + 1); + const ipsr = (core?.IPSR ?? 0) & 0x3f; // 0=thread, else exception number + ipsrHist.set(ipsr, (ipsrHist.get(ipsr) ?? 0) + 1); + }, 20); + const deadline = setTimeout(finish, 165_000); function finish() { if (finished) return; finished = true; clearTimeout(deadline); + clearInterval(pcSampler); + // Snapshot the PIO/SM state BEFORE stopping — this is the deadlock state. + let pioState = ''; + try { + let pi = 0; + for (const pio of (sim.rp2040 as any).pio) { + pioState += `PIO${pi} stopped=${pio.stopped} fdebug=0x${(pio.fdebug >>> 0).toString(16)} txStall=0x${(pio.txStall >>> 0).toString(16)}\n`; + let si = 0; + for (const sm of pio.machines) { + const tx = sm.txFIFO; + pioState += ` SM${si} en=${sm.enabled} waiting=${sm.waiting} waitType=${sm.waitType} ` + + `pc=${sm.pc ?? '?'} txDepth=${tx ? tx.itemCount : '?'} rxDepth=${sm.rxFIFO ? sm.rxFIFO.itemCount : '?'}\n`; + si++; + } + pi++; + } + } catch (e) { pioState = `pioState err: ${String(e)}`; } try { sim.stop(); } catch { /* noop */ } const polls = [...cmdCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 12); const report = @@ -268,9 +417,23 @@ describe.skipIf(!process.env.CYW43_HARNESS)('Pico W cyw43 boot harness (investig '===== TOP COMMAND COUNTS (poll loops) =====\n' + polls.map(([k, n]) => ` ${String(n).padStart(6)} ${k}`).join('\n') + '\n\n' + `===== FUNCTION HISTOGRAM F0..F3 = ${funcHist.join(',')} =====\n` + - `===== initInbound=${initInbound} statusReads=${statusReads} statusReadsWithPkt=${statusReadsWithPkt} finalInbound=${chip.debugInboundCount()} restarts=${restartCount} =====\n\n` + + `===== initInbound=${initInbound} statusReads=${statusReads} statusReadsWithPkt=${statusReadsWithPkt} finalInbound=${chip.debugInboundCount()} restarts=${restartCount} =====\n` + + `===== pushCount=${pushCount} txPulls=${txPulls} smSteps=${smSteps} =====\n` + + `===== 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) + .map(([pc, n]) => ` ${String(n).padStart(6)} PC=0x${pc.toString(16)}`).join('\n') + '\n' + + '===== IPSR (0=thread, else exception#) =====\n' + + [...ipsrHist.entries()].sort((a, b) => b[1] - a[1]) + .map(([ipsr, n]) => ` ${String(n).padStart(6)} IPSR=${ipsr}`).join('\n') + '\n\n' + + '===== PIO/SM STATE AT DEADLINE (deadlock) =====\n' + pioState + '\n' + '===== F2/IOCTL TRANSFERS (total seen, non-ring) =====\n' + `count=${f2Log.length}\n` + f2Log.join('\n') + '\n\n' + + '===== POST-CLM_LOAD (verbatim from first F2 write) =====\n' + postF2.join('\n') + '\n\n' + '===== TRACE TAIL (post-firmware) =====\n' + trace.slice(-120).join('\n') + '\n\n' + '===== LAST RAW TX WORDS (hex) — the end of the run =====\n' + rawWords.slice(-70).map((w) => w.toString(16).padStart(8, '0')).join(' ') + '\n'; @@ -297,5 +460,5 @@ describe.skipIf(!process.env.CYW43_HARNESS)('Pico W cyw43 boot harness (investig console.log('\n===== reachedImport=' + result.reachedImport + ' reachedActive=' + result.reachedActive + ' ====='); expect(result.trace.length).toBeGreaterThan(0); - }, 120_000); + }, 185_000); }); diff --git a/frontend/src/__tests__/picow-cyw43-integration.test.ts b/frontend/src/__tests__/picow-cyw43-integration.test.ts index 566b0e7b..d7d24664 100644 --- a/frontend/src/__tests__/picow-cyw43-integration.test.ts +++ b/frontend/src/__tests__/picow-cyw43-integration.test.ts @@ -114,7 +114,9 @@ describe('Cyw43Emulator IOCTL — SET_SSID Velxio-GUEST', () => { const events = pushIoctl(chip, WLC.SET_SSID, payload, 1); expect(chip.getLinkState()).toBe('up'); - expect(events.some((e) => e.type === WLC_E.LINK && e.reason === 1)).toBe(true); + // Link-up is signalled by the LINK event's flags bit 0 (the driver checks + // ev->flags & 1, not the reason), plus a WLC_SUP_KEYED supplicant event. + expect(events.some((e) => e.type === WLC_E.LINK && (e.flags & 1) === 1)).toBe(true); expect(events.some((e) => e.type === WLC_E.SET_SSID && e.status === 0)).toBe(true); }); }); @@ -187,10 +189,10 @@ function pushIoctl( cmd: number, payload: Uint8Array, isSet: number, -): Array<{ type: number; status: number; reason: number; data: Uint8Array }> { +): Array<{ type: number; status: number; reason: number; flags: number; data: Uint8Array }> { const sdpcm = encodeIoctlRequest(0, cmd, isSet, 0, payload); chip.onCommand(makeHdr({ write: true, func: 2, addr: 0, length: sdpcm.length, inc: true }), sdpcm); - const events: Array<{ type: number; status: number; reason: number; data: Uint8Array }> = []; + const events: Array<{ type: number; status: number; reason: number; flags: number; data: Uint8Array }> = []; for (let i = 0; i < 32; i++) { const out = chip.onCommand(makeHdr({ write: false, func: 2, addr: 0, length: 1600, inc: true }), new Uint8Array(0))!; if (!out || out.every((b) => b === 0)) break; @@ -198,7 +200,7 @@ function pushIoctl( if (!f) break; if (f.channel === SdpcmChannel.EVENT) { const ev = decodeEventBody(f.payload); - if (ev) events.push({ type: ev.eventType, status: ev.status, reason: ev.reason, data: ev.data }); + if (ev) events.push({ type: ev.eventType, status: ev.status, reason: ev.reason, flags: ev.flags, data: ev.data }); } else if (f.channel === SdpcmChannel.CONTROL) { decodeCdc(f.payload); // ignore — we just care about events } diff --git a/frontend/src/__tests__/picow-cyw43-prod-sim.investigate.test.ts b/frontend/src/__tests__/picow-cyw43-prod-sim.investigate.test.ts new file mode 100644 index 00000000..47832485 --- /dev/null +++ b/frontend/src/__tests__/picow-cyw43-prod-sim.investigate.test.ts @@ -0,0 +1,103 @@ +/** + * picow-cyw43-prod-sim.investigate.test.ts + * + * PRODUCTION-PATH validation (not CI — run explicitly). + * + * Unlike picow-cyw43-boot-harness.investigate.test.ts (which wires the cyw43 + * emulator to a raw rp2040js Simulator with its own FIFO/PIO plumbing), this + * drives the REAL `RP2040Simulator` — the exact code that runs in the browser: + * attachCyw43() + installCyw43PioHooks() (non-dropping FIFO, firmware fast-path, + * host-wake GPIO24) + the lockstep PIO stepping in runFrameForTime(). It boots + * the real Pico W firmware, injects a WiFi-connect snippet via the raw REPL, and + * asserts the link reaches isconnected()==True. + * + * Run: + * CYW43_PROD_HARNESS=1 npx vitest run \ + * src/__tests__/picow-cyw43-prod-sim.investigate.test.ts + */ + +import { describe, it, expect, vi } from 'vitest'; +import { readFileSync } from 'node:fs'; + +const FW_PATH = + '/home/dave/velxio-prod/velxio/frontend/public/firmware/micropython-rp2040w.uf2'; + +// getFirmware = IndexedDB/fetch (browser only) -> serve from disk. +// loadUserFiles = LittleFS WASM fetch (browser only) -> no-op; we inject the +// WiFi snippet over the raw REPL instead of writing main.py to the filesystem. +vi.mock('../simulation/MicroPythonLoader', async (orig) => { + const actual = (await orig()) as Record; + return { + ...actual, + getFirmware: async () => new Uint8Array(readFileSync(FW_PATH)), + loadUserFiles: async () => { /* skip LittleFS */ }, + }; +}); + +const INJECT_CODE = [ + 'import network, time', + 'print("PYBOOT")', + 'w = network.WLAN(network.STA_IF)', + 'w.active(True)', + 'print("ACTIVE", w.active())', + 'w.connect("Velxio-GUEST", "")', + 'ok = False', + 'for i in range(100):', + ' if w.isconnected():', + ' ok = True', + ' print("CONN_OK", w.ifconfig()[0])', + ' break', + ' time.sleep_ms(150)', + 'if not ok:', + ' print("CONN_TIMEOUT status", w.status())', + 'print("MAINPY_DONE")', +].join('\n'); + +describe.skipIf(!process.env.CYW43_PROD_HARNESS)('Pico W cyw43 — production RP2040Simulator path', () => { + it('boots, connects WiFi, and reaches isconnected()', async () => { + const { RP2040Simulator } = await import('../simulation/RP2040Simulator'); + const { PinManager } = await import('../simulation/PinManager'); + + const sim = new RP2040Simulator(new PinManager()); + let serial = ''; + let buf = ''; + sim.attachCyw43(); // selects Pico W firmware + registers host-wake listener + await sim.loadMicroPython([]); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const cdc = (sim as any).usbCDC as { sendSerialByte: (b: number) => void } | null; + expect(cdc).toBeTruthy(); + const send = (s: string) => { for (const c of s) cdc!.sendSerialByte(c.charCodeAt(0)); }; + + // Raw-REPL injection state machine, fed by the serial stream. + let state: 'idle' | 'prompt' | 'raw' | 'sent' = 'idle'; + sim.onSerialData = (ch: string) => { + serial += ch; buf += ch; + if (serial.length > 60000) serial = serial.slice(-20000); + if (state === 'idle' && buf.includes('>>>')) { + state = 'prompt'; buf = ''; + cdc!.sendSerialByte(0x01); // Ctrl-A -> raw REPL + } else if (state === 'prompt' && buf.includes('raw REPL')) { + state = 'raw'; buf = ''; + send(INJECT_CODE); + cdc!.sendSerialByte(0x04); // Ctrl-D -> execute + state = 'sent'; + } + }; + + const deadline = Date.now() + 175_000; + let frames = 0; + while (Date.now() < deadline) { + for (let i = 0; i < 16; i++) { sim.runFrameForTime(50); frames++; } + if (serial.includes('MAINPY_DONE') || serial.includes('CONN_OK')) break; + await new Promise((r) => setTimeout(r, 0)); + } + try { sim.stop(); } catch { /* noop */ } + + // eslint-disable-next-line no-console + console.log(`\n===== PROD-SIM SERIAL (frames=${frames}, state=${state}) =====\n` + serial.slice(-1500)); + + expect(serial).toContain('PYBOOT'); + expect(serial).toContain('CONN_OK'); + }, 200_000); +}); diff --git a/frontend/src/simulation/RP2040Simulator.ts b/frontend/src/simulation/RP2040Simulator.ts index f554e9d9..3570f179 100644 --- a/frontend/src/simulation/RP2040Simulator.ts +++ b/frontend/src/simulation/RP2040Simulator.ts @@ -291,7 +291,7 @@ export class RP2040Simulator { // 2. Create fresh RP2040 instance this.rp2040 = new RP2040(); - this.rp2040.logger = new ConsoleLogger(LogLevel.Error); + this.rp2040.logger = new ConsoleLogger(LogLevel.Error, false); this.rp2040.loadBootrom(bootromB1); // 3. Load UF2 firmware into flash @@ -416,10 +416,22 @@ export class RP2040Simulator { emu.onPacketOut((ev: PacketOutEvent) => { this.cyw43Bridge?.sendPacket(ev.ether); }); + // Drive WL_HOST_WAKE (GPIO24, active-high). The driver gates poll_device on + // this pin until it has received its first packet, so without it the first + // IOCTL response is never read and wifi_on stalls. + emu.onHostWake((active: boolean) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + try { (this.rp2040 as any)?.gpio?.[24]?.setInputValue(active); } catch { /* noop */ } + }); if (bridge) { bridge.onPacketIn = (p) => emu.injectPacket(p.ether); } + // NOTE: the built-in virtual DHCP/ARP net stays ON. The backend bridge is + // deferred (not validated end to end yet) and is left dormant by the store, + // so the virtual net is the only network responder — the STA associates and + // gets a link-local IP locally (no outbound internet). When the bridge is + // wired and connected, switch with emu.setVirtualNet(null) to cede the net. this.installCyw43PioHooks(); return emu; @@ -453,13 +465,55 @@ export class RP2040Simulator { const tx = sm.txFIFO; const rx = sm.rxFIFO; if (!tx || !rx) continue; + const sniffer = this.cyw43Sniffer; + // Make the TX FIFO NON-DROPPING (head-pointer queue). rp2040js's 4-deep + // FIFO silently drops words once full, which truncates the 260-word F2 + // IOCTL writes (clm_load, the connect ioctls) so the chip never sees a + // complete frame. Real hardware paces the DMA with DREQ and never drops. + // To keep the ~224 KB firmware download cheap we still discard the bulk + // of each firmware/backplane write (inDiscardableWriteData): the PIO + // drains the few kept words, raises TXSTALL, and the driver moves on. + const q: number[] = []; + let head = 0; + const origFull = Object.getOwnPropertyDescriptor(tx, 'full'); + const origEmpty = Object.getOwnPropertyDescriptor(tx, 'empty'); + const origItem = Object.getOwnPropertyDescriptor(tx, 'itemCount'); const origPush: (v: number) => void = tx.push.bind(tx); + const origPull: () => number = tx.pull.bind(tx); + const origPeek = tx.peek?.bind(tx); + const origReset = tx.reset?.bind(tx); + Object.defineProperty(tx, 'full', { get: () => false, configurable: true }); + Object.defineProperty(tx, 'empty', { get: () => head >= q.length, configurable: true }); + Object.defineProperty(tx, 'itemCount', { get: () => q.length - head, configurable: true }); + tx.peek = () => (head < q.length ? q[head] : 0); + tx.reset = () => { q.length = 0; head = 0; }; tx.push = (value: number) => { + if (sniffer.inDiscardableWriteData()) { + if (q.length - head < 4) q.push(value >>> 0); // keep a few so the PIO TXSTALLs + return; + } // Feed the gSPI sniffer; commands that produce a response queue it // for on-demand delivery (see the rxFIFO.pull hook below). this.feedCyw43Word(value); - return origPush(value); + q.push(value >>> 0); }; + tx.pull = () => { + if (head >= q.length) return 0; + const v = q[head++]; + if (head > 8192 && head * 2 > q.length) { q.splice(0, head); head = 0; } // compact + return v; + }; + this.cyw43HookedFifos.push({ + restore: () => { + if (origFull) Object.defineProperty(tx, 'full', origFull); else delete tx.full; + if (origEmpty) Object.defineProperty(tx, 'empty', origEmpty); else delete tx.empty; + if (origItem) Object.defineProperty(tx, 'itemCount', origItem); else delete tx.itemCount; + tx.push = origPush; + tx.pull = origPull; + if (origPeek) tx.peek = origPeek; + if (origReset) tx.reset = origReset; + }, + }); // Reset the gSPI framing at each transfer boundary. cyw43_spi_transfer // does pio_sm_restart before pushing the count words, so this keeps the // sniffer deterministic even across the firmware-stream fast-path. @@ -479,19 +533,25 @@ export class RP2040Simulator { // the data arrived late or was lost; serving on pull keeps it in lock // step with the driver. // eslint-disable-next-line @typescript-eslint/no-explicit-any - const origPull: () => number = (rx as any).pull.bind(rx); + const origRxPull: () => number = (rx as any).pull.bind(rx); // eslint-disable-next-line @typescript-eslint/no-explicit-any (rx as any).pull = () => - this.cyw43RxQueue.length > 0 ? (this.cyw43RxQueue.shift() as number) : origPull(); + this.cyw43RxQueue.length > 0 ? (this.cyw43RxQueue.shift() as number) : origRxPull(); this.cyw43HookedFifos.push({ restore: () => { - tx.push = origPush; // eslint-disable-next-line @typescript-eslint/no-explicit-any - (rx as any).pull = origPull; + (rx as any).pull = origRxPull; }, }); } } + // Re-sync WL_HOST_WAKE: loadMicroPython swaps in a fresh RP2040 (GPIO reset + // to low) while the chip's frame queue — and thus its host-wake level — + // persists. onHostWake only fires on changes, so push the current level now. + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (this.rp2040 as any)?.gpio?.[24]?.setInputValue(this.cyw43.hostWakeLevel()); + } catch { /* noop */ } } private cyw43RxQueue: number[] = []; @@ -540,7 +600,7 @@ export class RP2040Simulator { this.rp2040 = new RP2040(); // Suppress noisy internal logs (only show errors) - this.rp2040.logger = new ConsoleLogger(LogLevel.Error); + this.rp2040.logger = new ConsoleLogger(LogLevel.Error, false); // Load RP2040 B1 bootrom — needed for proper boot sequence this.rp2040.loadBootrom(bootromB1); @@ -921,7 +981,7 @@ export class RP2040Simulator { if (this.micropythonMode) { // In MicroPython mode, restore the full flash snapshot (UF2 + LittleFS) this.rp2040 = new RP2040(); - this.rp2040.logger = new ConsoleLogger(LogLevel.Error); + this.rp2040.logger = new ConsoleLogger(LogLevel.Error, false); this.rp2040.loadBootrom(bootromB1); this.rp2040.flash.set(this.flashCopy); this.rp2040.core.PC = 0x10000000; diff --git a/frontend/src/simulation/cyw43/Cyw43Emulator.ts b/frontend/src/simulation/cyw43/Cyw43Emulator.ts index be3f85e0..7fe2c9f1 100644 --- a/frontend/src/simulation/cyw43/Cyw43Emulator.ts +++ b/frontend/src/simulation/cyw43/Cyw43Emulator.ts @@ -38,6 +38,8 @@ import { WLC, WLC_E, WLC_E_STATUS, + WLC_E_LINK_UP_FLAG, + WLC_SUP, u32le, } from './constants'; import { @@ -53,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 ──────────────────────────────────────────── @@ -78,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; @@ -128,8 +137,31 @@ 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 + // the IOCTL response is queued, mirroring the real chip: it answers the + // ioctl, THEN emits async events. Critical for SET_SSID — the driver only + // sets wifi_join_state=ACTIVE after cyw43_ll_wifi_join returns, so join + // events delivered before that would be wiped out (link never comes up). + private deferEvents = false; + private pendingEvents: Uint8Array[] = []; + + // ── Debug counters (investigation harness only) ───────────────── + private _dbgF2Writes = 0; // F2 write transfers received + private _dbgFramesDecoded = 0; // SDPCM decode succeeded + private _dbgSdpcmFail = 0; // SDPCM decode returned null + private _dbgIoctls = 0; // handleIoctl invoked + private _dbgIoctlFail = 0; // CDC decode returned null + private _dbgLastIoctl = -1; // last cdc.cmd seen + private _dbgIoctlLog: string[] = []; // human-readable IOCTL sequence + debugIoctlStats(): string { + return `f2w=${this._dbgF2Writes} sdpcmOk=${this._dbgFramesDecoded} ` + + `sdpcmFail=${this._dbgSdpcmFail} ioctls=${this._dbgIoctls} ` + + `ioctlFail=${this._dbgIoctlFail} lastCmd=${this._dbgLastIoctl}`; + } + debugIoctlLog(): string[] { return this._dbgIoctlLog; } // ── Listeners ─────────────────────────────────────────────────── private ledListeners: Listener[] = []; @@ -137,12 +169,18 @@ export class Cyw43Emulator { private connectListeners: Listener[] = []; private disconnectListeners: Listener[] = []; private packetOutListeners: Listener[] = []; + // Host-wake (WL_HOST_WAKE / GPIO24) level listeners. The chip drives this + // pin high when it has a frame for the host; the driver gates poll_device on + // it until the first packet is received (cyw43_ll.c had_successful_packet). + private hostWakeListeners: Listener[] = []; + private hostWakeAsserted = false; constructor(opts: Cyw43EmulatorOptions = {}) { this.now = opts.now ?? (() => Date.now()); 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 @@ -166,6 +204,7 @@ export class Cyw43Emulator { } this.inboundEvents.push(sdpcm); this.f0Regs[F0.INTERRUPT >> 2] |= 0x40; + this.updateHostWake(); } // ── Listener registration ─────────────────────────────────────── @@ -174,6 +213,23 @@ export class Cyw43Emulator { onConnect = (cb: Listener) => this.add(this.connectListeners, cb); onDisconnect = (cb: Listener) => this.add(this.disconnectListeners, cb); onPacketOut = (cb: Listener) => this.add(this.packetOutListeners, cb); + /** Fires with the WL_HOST_WAKE pin level (true=high) whenever it changes. */ + onHostWake = (cb: Listener) => { + const off = this.add(this.hostWakeListeners, cb); + cb(this.hostWakeAsserted); // deliver current level on subscribe + return off; + }; + + /** + * Recompute the host-wake (GPIO24) level from the inbound-frame queue and + * notify listeners on a change. Active-high: high while a frame is pending. + */ + private updateHostWake(): void { + const want = this.inboundEvents.length > 0; + if (want === this.hostWakeAsserted) return; + this.hostWakeAsserted = want; + for (const cb of this.hostWakeListeners) cb(want); + } private add(arr: Listener[], cb: Listener): () => void { arr.push(cb); @@ -216,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); } @@ -240,9 +301,38 @@ export class Cyw43Emulator { return this.bigEndian ? bswap32(value >>> 0) : swap16(value >>> 0); } + /** + * Encode an SDPCM frame for an F2 read. The frame rides the SAME byte-swapped + * DMA-in path as register reads (cyw43_spi_transfer sets channel bswap=true), + * so every 32-bit word must be run through encodeReadWord. Without this the + * frame lands byte-reversed in spid_buf: header_length reads back as garbage + * and the driver dereferences ioctl_header at an unaligned address (crash). + */ + private encodeFrameWords(frame: Uint8Array): Uint8Array { + // F2/SDPCM traffic only happens after the bus switches to 32-bit big-endian; + // in the 16-bit boot regime (and the unit tests that exercise IOCTLs there) + // the frame is consumed raw, so pass it through unchanged. + if (!this.bigEndian) return frame; + const out = new Uint8Array(frame.length); + const whole = frame.length & ~3; + for (let i = 0; i < whole; i += 4) { + writeU32LE(out, i, this.encodeReadWord(readU32LE(frame, i))); + } + for (let i = whole; i < frame.length; i++) out[i] = frame[i]; // tail (gSPI is word-aligned) + return out; + } + /** True once the driver has switched the bus to 32-bit big-endian. */ isBigEndian(): boolean { return this.bigEndian; } + /** Current WL_HOST_WAKE level (true=high). Lets the host re-sync GPIO24 + * after the RP2040 (and its GPIO state) is recreated by a firmware reload. */ + hostWakeLevel(): boolean { return this.hostWakeAsserted; } + + /** Enable/replace (or disable with null) the self-contained DHCP/ARP net. + * Disable it when an external packet bridge owns the network. */ + setVirtualNet(cfg: VirtualNetConfig | null): void { this.virtualNet = cfg; } + /** Debug: queued chip→host frame count (for harness instrumentation). */ debugInboundCount(): number { return this.inboundEvents.length; } @@ -378,8 +468,10 @@ export class Cyw43Emulator { private handleF2(cmd: Cyw43Cmd, _payload: Uint8Array, _rxBytes: number): Uint8Array | null { if (cmd.write) { + this._dbgF2Writes++; const frame = decodeSdpcm(_payload); - if (frame) this.handleHostFrame(frame.channel, frame.payload); + if (frame) { this._dbgFramesDecoded++; this.handleHostFrame(frame.channel, frame.payload); } + else this._dbgSdpcmFail++; return null; } @@ -395,7 +487,8 @@ export class Cyw43Emulator { if (this.inboundEvents.length === 0) { this.f0Regs[F0.INTERRUPT >> 2] &= ~0x40; } - return out; + this.updateHostWake(); + return this.encodeFrameWords(out); } // ── Host → chip frame dispatch ────────────────────────────────── @@ -406,11 +499,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. } @@ -418,9 +518,15 @@ export class Cyw43Emulator { // ── IOCTL handler ─────────────────────────────────────────────── private handleIoctl(cdcBytes: Uint8Array): void { + this._dbgIoctls++; const cdc = decodeCdc(cdcBytes); - if (!cdc) return; - const isGet = (cdc.flags & 0x1) === 0; // bit 0 clear = GET + if (!cdc) { this._dbgIoctlFail++; return; } + this._dbgLastIoctl = cdc.cmd; + // Defer any async events the handler raises until after the ioctl reply. + this.deferEvents = true; + // SDPCM_SET is 0x2 (bit 1) in the CDC flags; SDPCM_GET is 0. (cyw43_ll.c) + const ioctlKind = cdc.flags & 0x2; + const isGet = ioctlKind === 0; const data = cdc.payload; // For SET_VAR/GET_VAR, name is a NUL-terminated string at start of payload. let varName = ''; @@ -429,6 +535,9 @@ export class Cyw43Emulator { varName = readCString(data, 0); varOff = varName.length + 1; } + if (this._dbgIoctlLog.length < 60) { + this._dbgIoctlLog.push(`${isGet ? 'GET' : 'SET'} cmd=${cdc.cmd}${varName ? ' ' + varName : ''} dlen=${data.length} outlen=${cdc.outlen}`); + } const reqId = (cdc.flags >>> 16) & 0xffff; let response: Uint8Array = new Uint8Array(0); const status = 0; @@ -510,8 +619,8 @@ export class Cyw43Emulator { dv.setUint32(0, cdc.cmd, true); dv.setUint16(4, response.length, true); dv.setUint16(6, 0, true); - // Mirror the request-id in the upper 16 bits, set bit 0 to mark "response" - dv.setUint32(8, ((reqId & 0xffff) << 16) | (isGet ? 0 : 1), true); + // Mirror the request-id in the upper 16 bits and echo the SET/GET kind bit. + dv.setUint32(8, ((reqId & 0xffff) << 16) | ioctlKind, true); dv.setUint32(12, status >>> 0, true); replyCdc.set(response, CDC_HEADER_LEN); const sdpcm = encodeSdpcm({ @@ -520,6 +629,15 @@ export class Cyw43Emulator { payload: replyCdc, }); this.pushFrame(sdpcm); + + // Now release any deferred async events AFTER the ioctl response, so the + // driver reads the reply (do_ioctl returns) before processing them. + this.deferEvents = false; + if (this.pendingEvents.length > 0) { + const evs = this.pendingEvents; + this.pendingEvents = []; + for (const f of evs) this.pushFrame(f); + } } // ── Concrete IOCTL handlers ───────────────────────────────────── @@ -542,7 +660,15 @@ export class Cyw43Emulator { this.queueEvent(WLC_E.ASSOC, WLC_E_STATUS.SUCCESS, 0); this.queueEvent(WLC_E.SET_SSID, WLC_E_STATUS.SUCCESS, 0, encodeSetSsidPayload(ssid)); - this.queueEvent(WLC_E.LINK, WLC_E_STATUS.SUCCESS, 1 /* link up flag */); + // LINK up: the driver checks ev->flags & 1 (NOT the reason) to mark the + // link up, and only sets WIFI_JOIN_STATE_LINK when that bit is set. + this.queueEvent(WLC_E.LINK, WLC_E_STATUS.SUCCESS, 0, new Uint8Array(0), + WLC_E_LINK_UP_FLAG); + // Supplicant "keyed": for a secured join the driver needs + // WIFI_JOIN_STATE_KEYED before it declares the link up (open networks + // preset it, but MicroPython's connect(ssid, "") still configures the + // WPA supplicant). Emit WLC_SUP_KEYED so the 4-bit join state completes. + this.queueEvent(WLC_E.PSK_SUP, WLC_SUP.KEYED, 0); this.linkState = 'up'; this.fireConnect(ssid); } else { @@ -569,16 +695,20 @@ export class Cyw43Emulator { const val = dv.getUint32(4, true); if (mask & 0x1) this.fireLed((val & 0x1) === 0x1); } - // bsscfg:event_msgs payload: 4-byte cfg index + 16-byte mask - if (name === 'bsscfg:event_msgs' && value.length >= 4 + 16) { - const mask = value.slice(4, 4 + 16); + // bsscfg:event_msgs (and plain event_msgs) payload: 4-byte bsscfg index + // followed by the event bitmask. Store the buffer VERBATIM (index included) + // so the bitmask lines up at offset 4 — exactly where queueEvent and + // hasAnyMaskBitsSet read it (eventMask[4 + (eventType>>3)]). Slicing the + // index off here mis-aligned the mask by 4 bytes, so the join events + // (AUTH/LINK/PSK_SUP) were dropped and the link never came up. + if ((name === 'bsscfg:event_msgs' || name === 'event_msgs') && value.length >= 4 + 4) { this.eventMask = new Uint8Array(32); - this.eventMask.set(mask); + this.eventMask.set(value.subarray(0, Math.min(value.length, 32))); } // sup_wpa_psk / wsec_pmk / passphrase — accept silently. } - private handleGetVar(name: string, _outlen: number): Uint8Array { + private handleGetVar(name: string, outlen: number): Uint8Array { if (name === 'cur_etheraddr') { return new Uint8Array(this.staMac); } @@ -586,7 +716,15 @@ export class Cyw43Emulator { // Synthetic firmware version banner; the driver only uses the prefix. return new TextEncoder().encode('velxio-cyw43-emu 1.0\0'); } - return new Uint8Array(0); + // Every other GET must return a ZERO-FILLED buffer of the size the driver + // asked for — NEVER an empty response. cyw43_do_ioctl only memmoves the + // bytes we return over the driver's request buffer, so an empty reply + // leaves the request bytes in place. Several GETs then parse a leading u32 + // as a count/length and run away: e.g. cyw43_ll_wifi_update_multicast_filter + // reads mcast_list's first word as the address count and loops that many + // times (the ASCII "mcas" => ~1.9e9 iterations, which read out of bounds + // and hang wifi_on). Zeros => count 0 / status 0 (success) everywhere. + return new Uint8Array(Math.max(4, Math.min(outlen, 1536))); } // ── Event queueing ───────────────────────────────────────────── @@ -596,6 +734,7 @@ export class Cyw43Emulator { status: number, reason: number, payload: Uint8Array = new Uint8Array(0), + flags = 0, ): void { // Honour the host's event mask if it's been set; events outside the // mask are dropped on the floor (the chip wouldn't deliver them). @@ -615,8 +754,10 @@ export class Cyw43Emulator { reason, payload, this.staMac, + flags, ); - this.pushFrame(frame); + if (this.deferEvents) this.pendingEvents.push(frame); + else this.pushFrame(frame); } private hasAnyMaskBitsSet(): boolean { diff --git a/frontend/src/simulation/cyw43/PioBusSniffer.ts b/frontend/src/simulation/cyw43/PioBusSniffer.ts index ec49f475..5748f465 100644 --- a/frontend/src/simulation/cyw43/PioBusSniffer.ts +++ b/frontend/src/simulation/cyw43/PioBusSniffer.ts @@ -146,6 +146,20 @@ export class PioBusSniffer { /** Debug hook (tests): fired with each count word's raw value + decode. */ onCount: ((kind: 'out' | 'in', raw: number, bytes: number) => void) | null = null; + /** + * True while the in-flight transfer is a large non-F2 write — i.e. a firmware + * download block (or any backplane bulk write the chip discards). The host + * pushes ~224 KB of these; their data words carry no information the emulator + * needs (firmware lands in chip SRAM we don't model), so a non-dropping FIFO + * forces the PIO to bit-bang every one and the emulation crawls. The harness + * uses this to drop those data words while keeping F2/SDPCM writes (IOCTLs) + * and every command/count word intact. 36 bytes = count(0) + cmd(4) + a few + * config words; firmware blocks are 64-byte payloads, well above it. + */ + inDiscardableWriteData(): boolean { + return this.state === 'writeData' && this.cmd?.function !== 2 && this.outBytes > 36; + } + *feedWord(rawWord: number): Generator { const raw = rawWord >>> 0; diff --git a/frontend/src/simulation/cyw43/constants.ts b/frontend/src/simulation/cyw43/constants.ts index d6bf55e3..7d27eef6 100644 --- a/frontend/src/simulation/cyw43/constants.ts +++ b/frontend/src/simulation/cyw43/constants.ts @@ -101,9 +101,19 @@ export const WLC_E = { SCAN_COMPLETE: 26, JOIN_START: 36, ASSOC_START: 38, + PSK_SUP: 46, // WPA supplicant state; status 6 (WLC_SUP_KEYED) => key exchange done ESCAN_RESULT: 69, } as const; +// WLC_E_PSK_SUP status values (the WPA supplicant state machine). The driver +// only marks WIFI_JOIN_STATE_KEYED when it sees status == KEYED. +export const WLC_SUP = { + KEYED: 6, // WLC_SUP_KEYED — 4-way handshake complete, link may come up +} as const; + +// LINK event flag: bit 0 set means "link up" (cyw43_ll.c checks ev->flags & 1). +export const WLC_E_LINK_UP_FLAG = 1; + // ── Status codes used in event payloads ─────────────────────────── export const WLC_E_STATUS = { SUCCESS: 0, diff --git a/frontend/src/simulation/cyw43/sdpcm.ts b/frontend/src/simulation/cyw43/sdpcm.ts index 069e546d..eab4df51 100644 --- a/frontend/src/simulation/cyw43/sdpcm.ts +++ b/frontend/src/simulation/cyw43/sdpcm.ts @@ -141,28 +141,37 @@ export function encodeEventFrame( reason: number, payload: Uint8Array = new Uint8Array(0), srcMac: Uint8Array = new Uint8Array([0, 0, 0, 0, 0, 0]), + flags = 0, ): Uint8Array { // Event SDPCM payload layout used by Broadcom firmware: + // BDC_HDR (4 bytes) flags, priority, interface, data_offset // ETHER_HDR (14 bytes) dest+src+ethertype // BCMETH_HDR (10 bytes) Broadcom OUI tag // EVENT_HDR (48 bytes) event_type, status, … // + // The driver reads the BDC header at the SDPCM header_length, then takes the + // ethernet payload at bdc + 4 + (data_offset<<2). Without the BDC it reads + // the broadcast-MAC byte as data_offset and the payload points out of bounds. const ETHERTYPE_BRCM = 0x886c; + const BDC_LEN = 4; const ETH_LEN = 14; const BCMETH_LEN = 10; const EVENT_LEN = 48; - const total = ETH_LEN + BCMETH_LEN + EVENT_LEN + payload.length; + const total = BDC_LEN + ETH_LEN + BCMETH_LEN + EVENT_LEN + payload.length; const buf = new Uint8Array(total); const dv = new DataView(buf.buffer); + // BDC header: data_offset = 0 (ethernet immediately follows), interface 0. + // buf[0..3] stay zero. + const eth = BDC_LEN; // Ethernet header: dst = broadcast, src = chip MAC, ethertype = BRCM - for (let i = 0; i < 6; i++) buf[i] = 0xff; - buf.set(srcMac, 6); - dv.setUint16(12, ETHERTYPE_BRCM, false); + for (let i = 0; i < 6; i++) buf[eth + i] = 0xff; + buf.set(srcMac, eth + 6); + dv.setUint16(eth + 12, ETHERTYPE_BRCM, false); // Broadcom Ethernet header (subtype/len/ver/oui/usr_subtype) - let off = ETH_LEN; + let off = eth + ETH_LEN; dv.setUint16(off + 0, 0, false); - dv.setUint16(off + 2, total - ETH_LEN, false); + dv.setUint16(off + 2, total - BDC_LEN - ETH_LEN, false); buf[off + 4] = 0x02; // ver buf[off + 5] = 0x00; // oui[0] buf[off + 6] = 0x10; // oui[1] @@ -172,7 +181,7 @@ export function encodeEventFrame( // EVENT_HDR (big-endian — Broadcom firmware writes this BE) off += BCMETH_LEN; dv.setUint16(off + 0, 1, false); // ver - dv.setUint16(off + 2, 0, false); // flags + dv.setUint16(off + 2, flags & 0xffff, false); // flags (bit 0 = link up for WLC_E_LINK) dv.setUint32(off + 4, eventType >>> 0, false); dv.setUint32(off + 8, status >>> 0, false); dv.setUint32(off + 12, reason >>> 0, false); @@ -191,24 +200,27 @@ export function encodeEventFrame( }); } -/** Decode an event payload — returns the event_type / status / reason. */ +/** Decode an event payload — returns the flags / event_type / status / reason. */ export function decodeEventBody(payload: Uint8Array): { + flags: number; eventType: number; status: number; reason: number; datalen: number; data: Uint8Array; } | null { + const BDC_LEN = 4; const ETH_LEN = 14; const BCMETH_LEN = 10; const EVENT_LEN = 48; - if (payload.length < ETH_LEN + BCMETH_LEN + EVENT_LEN) return null; + if (payload.length < BDC_LEN + ETH_LEN + BCMETH_LEN + EVENT_LEN) return null; const dv = new DataView(payload.buffer, payload.byteOffset, payload.byteLength); - const off = ETH_LEN + BCMETH_LEN; + const off = BDC_LEN + ETH_LEN + BCMETH_LEN; + const flags = dv.getUint16(off + 2, false); const eventType = dv.getUint32(off + 4, false); const status = dv.getUint32(off + 8, false); const reason = dv.getUint32(off + 12, false); const datalen = dv.getUint32(off + 20, false); const data = payload.slice(off + EVENT_LEN, off + EVENT_LEN + datalen); - return { eventType, status, reason, datalen, data }; + return { flags, eventType, status, reason, datalen, data }; } 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); +} diff --git a/frontend/src/store/useSimulatorStore.ts b/frontend/src/store/useSimulatorStore.ts index a60d676f..fa8de92d 100644 --- a/frontend/src/store/useSimulatorStore.ts +++ b/frontend/src/store/useSimulatorStore.ts @@ -1821,8 +1821,15 @@ export const useSimulatorStore = create((set, get) => { /#include\s*[<"]WiFi\.h[>"]/.test(f.content) || /WiFi\.begin\(/.test(f.content), ); - cyw43.wifiEnabled = hasWifi; - cyw43.connect(); + // Backend internet bridge (picow_net: DHCP + NAT to the real + // internet) is deferred until validated end to end. Leaving the + // bridge dormant means the chip emulator's built-in virtual DHCP/ARP + // net handles the association locally: WiFi connects + gets a + // link-local IP (isconnected True), but outbound traffic (MQTT/HTTP) + // has no route yet. Re-enable when the bridge is wired: + // cyw43.wifiEnabled = hasWifi; cyw43.connect(); + void hasWifi; + void cyw43; } } }