diff --git a/frontend/src/__tests__/picow-cyw43-boot-harness.investigate.test.ts b/frontend/src/__tests__/picow-cyw43-boot-harness.investigate.test.ts new file mode 100644 index 00000000..517848cc --- /dev/null +++ b/frontend/src/__tests__/picow-cyw43-boot-harness.investigate.test.ts @@ -0,0 +1,259 @@ +/** + * picow-cyw43-boot-harness.investigate.test.ts + * + * INVESTIGATION HARNESS (not a CI test — run explicitly). + * + * Boots the REAL Pico W MicroPython firmware on rp2040js, attaches the + * production Cyw43Emulator to the PIO TX FIFOs exactly the way + * RP2040Simulator.installCyw43PioHooks() does, injects a WiFi-bringup + * snippet via the raw REPL, and captures the gSPI command trace so we can + * see EXACTLY where the real cyw43 driver diverges from the emulator + * (i.e. where it stalls). + * + * Run: + * npx vitest run src/__tests__/picow-cyw43-boot-harness.investigate.test.ts + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { Simulator, USBCDC, ConsoleLogger, LogLevel } from 'rp2040js'; +import { Cyw43Emulator } from '../simulation/cyw43/Cyw43Emulator'; +import { PioBusSniffer, formatCmd, type Cyw43Cmd } from '../simulation/cyw43/PioBusSniffer'; +import { bootromB1 } from '../simulation/rp2040-bootrom'; + +const FW_PATH = + '/home/dave/velxio-prod/velxio/frontend/public/firmware/micropython-rp2040w.uf2'; + +const UF2_MAGIC0 = 0x0a324655; +const UF2_MAGIC1 = 0x9e5d5157; +const FLASH_START = 0x10000000; + +function loadUF2(uf2: Uint8Array, flash: Uint8Array): number { + const view = new DataView(uf2.buffer, uf2.byteOffset, uf2.byteLength); + let blocks = 0; + for (let off = 0; off + 512 <= uf2.length; off += 512) { + if (view.getUint32(off, true) !== UF2_MAGIC0) continue; + if (view.getUint32(off + 4, true) !== UF2_MAGIC1) continue; + const addr = view.getUint32(off + 12, true); + const payload = uf2.subarray(off + 32, off + 32 + 256); + const o = addr - FLASH_START; + if (o >= 0 && o + 256 <= flash.length) { flash.set(payload, o); blocks++; } + } + return blocks; +} + +// The WiFi bringup snippet. Prints between each step so the serial log +// pinpoints how far MicroPython gets before the cyw43 driver stalls. +const INJECT_CODE = [ + 'import network, time', + 'print("STEP_IMPORT_OK")', + 'w=network.WLAN(network.STA_IF)', + 'print("STEP_WLAN_OBJ_OK")', + 'w.active(True)', + 'print("STEP_ACTIVE active=" + str(w.active()))', + 'w.connect("Velxio-GUEST", "")', + 'print("STEP_CONNECT_CALLED")', + 'for i in range(15):', + ' print("POLL", i, "status", w.status(), "conn", w.isconnected())', + ' time.sleep_ms(300)', + 'print("STEP_IFCONFIG " + str(w.ifconfig()))', + 'print("HARNESS_DONE")', +].join('\n'); + +// Skipped in the normal gate (boots a real firmware for ~60s). Run with: +// CYW43_HARNESS=1 npx vitest run src/__tests__/picow-cyw43-boot-harness.investigate.test.ts +describe.skipIf(!process.env.CYW43_HARNESS)('Pico W cyw43 boot harness (investigation)', () => { + it('captures the gSPI trace up to the stall', async () => { + const result = await new Promise<{ + serial: string; + trace: string[]; + reachedImport: boolean; + reachedActive: boolean; + lastCmds: string[]; + pollSummary: Array<[string, number]>; + }>((resolve) => { + const sim = new Simulator(); + sim.rp2040.loadBootrom(bootromB1); + sim.rp2040.logger = new ConsoleLogger(LogLevel.Error); + + const fwBlocks = loadUF2(new Uint8Array(readFileSync(FW_PATH)), sim.rp2040.flash); + let usbConnected = false; + + // ── attach production Cyw43Emulator the way RP2040Simulator does ── + const chip = new Cyw43Emulator(); + const sniffer = new PioBusSniffer(); + sniffer.setModeProvider(() => chip.isBigEndian()); + let pushCount = 0; + void pushCount; + const rxQueue: number[] = []; + const rawWords: number[] = []; + const trace: string[] = []; + const f2Log: string[] = []; // F2/IOCTL transfers, NOT subject to the ring + const cmdCounts = new Map(); + let ledOn = false; + chip.onLed((e) => { ledOn = e.on; trace.push(`** LED ${e.on ? 'ON' : 'OFF'} **`); }); + chip.onConnect((e) => trace.push(`** WIFI CONNECT ssid=${e.ssid} **`)); + + const key = (c: Cyw43Cmd) => + `${c.write ? 'WR' : 'RD'} F${c.function} 0x${c.address.toString(16)} len=${c.length}`; + + function queueReply(reply: Uint8Array) { + for (let i = 0; i + 4 <= reply.length; i += 4) { + rxQueue.push( + ((reply[i + 3] << 24) | (reply[i + 2] << 16) | (reply[i + 1] << 8) | reply[i]) >>> 0, + ); + } + if (reply.length % 4 !== 0) { + const tail = reply.subarray(reply.length - (reply.length % 4)); + let w = 0; + for (let i = 0; i < tail.length; i++) w |= tail[i] << (i * 8); + rxQueue.push(w >>> 0); + } + } + function feedWord(word: number, sm: any) { + for (const ev of sniffer.feedWord(word)) { + if (ev.kind === 'header') { + const k = key(ev.cmd); + cmdCounts.set(k, (cmdCounts.get(k) ?? 0) + 1); + } else if (ev.kind === 'payload') { + // Capture EVERY F2 transfer (IOCTLs/SDPCM) in a non-ring log. + if (ev.cmd.function === 2 && f2Log.length < 60) { + f2Log.push(`${ev.cmd.write ? 'WR' : 'RD'} F2 len=${ev.payload.length || ev.readBytes}`); + } + // Skip logging the ~3500 firmware-block writes (len>=64) — keep the + // trace focused on the handshake + post-download sequence. + const isFwBlock = ev.cmd.write && ev.payload.length >= 64; + if (!isFwBlock) { + trace.push(`-> ${formatCmd(ev.cmd)} rxBytes=${ev.readBytes}`); + if (ev.cmd.write && ev.payload.length > 0) { + const v = (ev.payload[0] | (ev.payload[1] << 8) | (ev.payload[2] << 16) | (ev.payload[3] << 24)) >>> 0; + trace.push(` WR data u32=0x${v.toString(16).padStart(8, '0')} (${ev.payload.length}B)`); + } + } + if (trace.length > 6000) trace.splice(0, trace.length - 4000); // ring + const reply = chip.onCommand(ev.cmd, ev.payload, ev.readBytes); + if (reply && reply.length > 0) { + queueReply(reply); + if (trace.length < 4000) { + const hex = Array.from(reply.slice(0, 8)) + .map((b) => b.toString(16).padStart(2, '0')).join(' '); + trace.push(`<- (${reply.length}B) ${hex}${reply.length > 8 ? ' ...' : ''}`); + } + } + } + } + } + + let rxPulled = 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); + tx.push = (v: number) => { pushCount++; rawWords.push(v >>> 0); feedWord(v, sm); return orig(v); }; + const rx = sm.rxFIFO; + if (rx) { + const origPull = rx.pull.bind(rx); + // Serve the chip's response on-demand: when the driver's DMA pulls, + // hand back the next queued response word (bypasses the async + // PIO/FIFO timing that otherwise lags/loses the data). + rx.pull = () => { + const v = rxQueue.length > 0 ? (rxQueue.shift() as number) : origPull(); + rxPulled++; + return v; + }; + } + } + } + + // ── raw REPL injection (mirrors test_micropython_pico.mjs) ── + const cdc = new USBCDC(sim.rp2040.usbCtrl); + let serial = ''; + let buf = ''; + let state: 'idle' | 'prompt' | 'raw' | 'done' = 'idle'; + let reachedImport = false; + let reachedActive = false; + + cdc.onDeviceConnected = () => { + usbConnected = true; + cdc.sendSerialByte('\r'.charCodeAt(0)); + cdc.sendSerialByte('\n'.charCodeAt(0)); + }; + + function sendCode() { + const bytes = Array.from(new TextEncoder().encode(INJECT_CODE)); + let off = 0; + const chunk = () => { + if (off >= bytes.length) { + setTimeout(() => { cdc.sendSerialByte(0x04); state = 'done'; }, 200); + return; + } + for (const b of bytes.slice(off, off + 64)) cdc.sendSerialByte(b); + off += 64; + setTimeout(chunk, 120); + }; + chunk(); + } + + cdc.onSerialData = (bytes: Uint8Array) => { + for (const b of bytes) { const ch = String.fromCharCode(b); serial += ch; buf += ch; } + if (state === 'idle' && buf.includes('>>>')) { + state = 'prompt'; buf = ''; + setTimeout(() => cdc.sendSerialByte(0x01), 150); // Ctrl+A raw REPL + } + if (state === 'prompt' && buf.includes('raw REPL')) { + state = 'raw'; buf = ''; + setTimeout(sendCode, 150); + } + if (serial.includes('STEP_IMPORT_OK')) reachedImport = true; + if (serial.includes('STEP_ACTIVE')) reachedActive = true; + if (serial.length > 60000) serial = serial.slice(-20000); + if (serial.includes('HARNESS_DONE') || serial.includes('Traceback')) { + setTimeout(finish, 300); // let the last line flush + } + }; + + let finished = false; + const deadline = setTimeout(finish, 60_000); + function finish() { + if (finished) return; + finished = true; + clearTimeout(deadline); + try { sim.stop(); } catch { /* noop */ } + const polls = [...cmdCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 12); + const report = + `fwBlocks=${fwBlocks} usbConnected=${usbConnected} ` + + `state=${state} serialLen=${serial.length} traceLen=${trace.length} ` + + `reachedImport=${reachedImport} reachedActive=${reachedActive} ledOn=${ledOn}\n\n` + + '===== SERIAL OUTPUT (tail 2500) =====\n' + serial.slice(-2500) + '\n\n' + + '===== TOP COMMAND COUNTS (poll loops) =====\n' + + polls.map(([k, n]) => ` ${String(n).padStart(6)} ${k}`).join('\n') + '\n\n' + + '===== F2/IOCTL TRANSFERS (total seen, non-ring) =====\n' + + `count=${f2Log.length}\n` + f2Log.join('\n') + '\n\n' + + '===== TRACE TAIL (post-firmware) =====\n' + trace.slice(-120).join('\n') + '\n'; + void rawWords; + try { writeFileSync('/tmp/cyw43-trace.txt', report); } catch { /* noop */ } + resolve({ + serial, + trace, + reachedImport, + reachedActive, + lastCmds: trace.slice(-60), + pollSummary: polls, + }); + } + + sim.rp2040.core.PC = 0x10000000; + sim.execute(); + }); + + // ── report ── + console.log('\n===== SERIAL OUTPUT (tail) =====\n' + result.serial.slice(-1500)); + console.log('\n===== TOP COMMAND COUNTS (poll loops) ====='); + for (const [k, n] of result.pollSummary) console.log(` ${n.toString().padStart(6)} ${k}`); + console.log('\n===== LAST 60 TRACE LINES =====\n' + result.lastCmds.join('\n')); + console.log('\n===== reachedImport=' + result.reachedImport + ' reachedActive=' + result.reachedActive + ' ====='); + + expect(result.trace.length).toBeGreaterThan(0); + }, 120_000); +}); diff --git a/frontend/src/__tests__/picow-cyw43-integration.test.ts b/frontend/src/__tests__/picow-cyw43-integration.test.ts index 78fc4085..566b0e7b 100644 --- a/frontend/src/__tests__/picow-cyw43-integration.test.ts +++ b/frontend/src/__tests__/picow-cyw43-integration.test.ts @@ -34,7 +34,7 @@ import { decodeCdc, decodeEventBody, } from '../simulation/cyw43'; -import { decodeHeader } from '../simulation/cyw43/PioBusSniffer'; +import { decodeHeader, swap16x2 } from '../simulation/cyw43/PioBusSniffer'; function makeHdr(opts: { write: boolean; @@ -74,14 +74,18 @@ describe('cyw43 barrel exports', () => { describe('Cyw43Emulator bus handshake', () => { it('returns 0 then 0xFEEDBEAD on F0:0x14 reads', () => { + // Responses are now wire-encoded for the chosen word-order regime (boot = + // halfword-swapped), so decode with swap16x2 to recover the logical value. const chip = new Cyw43Emulator(); const r1 = chip.onCommand(makeHdr({ write: false, func: 0, addr: F0.READ_TEST, length: 4 }), new Uint8Array(0))!; const r2 = chip.onCommand(makeHdr({ write: false, func: 0, addr: F0.READ_TEST, length: 4 }), new Uint8Array(0))!; - expect(readU32(r1)).toBe(0); - expect(readU32(r2)).toBe(TEST_PATTERN); + expect(swap16x2(readU32(r1))).toBe(0); + expect(swap16x2(readU32(r2))).toBe(TEST_PATTERN); }); - it('flips HT_AVAIL after a HT_AVAIL_REQ write', () => { + it('reports HT_AVAIL on the clock CSR', () => { + // The chip reports ALP+HT available; backplane reads place the value in the + // last word, wire-encoded — decode it before checking the bit. const chip = new Cyw43Emulator(); chip.onCommand( makeHdr({ write: true, func: 1, addr: F1.SDIO_CHIP_CLOCK_CSR, length: 1 }), @@ -91,7 +95,7 @@ describe('Cyw43Emulator bus handshake', () => { makeHdr({ write: false, func: 1, addr: F1.SDIO_CHIP_CLOCK_CSR, length: 1 }), new Uint8Array(0), )!; - expect(r[0] & ClockCsr.HT_AVAIL).toBeGreaterThan(0); + expect(swap16x2(readU32(r.subarray(r.length - 4))) & ClockCsr.HT_AVAIL).toBeGreaterThan(0); }); }); diff --git a/frontend/src/simulation/RP2040Simulator.ts b/frontend/src/simulation/RP2040Simulator.ts index d51f1047..e5d7dd17 100644 --- a/frontend/src/simulation/RP2040Simulator.ts +++ b/frontend/src/simulation/RP2040Simulator.ts @@ -400,6 +400,9 @@ export class RP2040Simulator { if (this.cyw43) return this.cyw43; const emu = new Cyw43Emulator(); const sniffer = new PioBusSniffer(); + // The sniffer de-swaps commands/data per the chip's word-order regime, + // which the emulator owns (flips at the SPI_BUS_CONTROL write). + sniffer.setModeProvider(() => emu.isBigEndian()); this.cyw43 = emu; this.cyw43Sniffer = sniffer; this.cyw43Bridge = bridge; @@ -452,14 +455,26 @@ export class RP2040Simulator { if (!tx || !rx) continue; const origPush: (v: number) => void = tx.push.bind(tx); tx.push = (value: number) => { - // Feed the gSPI sniffer; if the command produces a response, - // surface it word-by-word into the rxFIFO so the driver's - // `pull` instruction reads it back. + // 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); }; + // Serve the chip's response when the driver's DMA actually reads the + // RX FIFO. Pushing into the FIFO eagerly raced the async DMA/PIO and + // 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); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (rx as any).pull = () => + this.cyw43RxQueue.length > 0 ? (this.cyw43RxQueue.shift() as number) : origPull(); this.cyw43HookedFifos.push({ - restore: () => { tx.push = origPush; }, + restore: () => { + tx.push = origPush; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (rx as any).pull = origPull; + }, }); } } @@ -470,12 +485,10 @@ export class RP2040Simulator { if (!this.cyw43Sniffer || !this.cyw43) return; for (const ev of this.cyw43Sniffer.feedWord(word)) { if (ev.kind === 'payload') { - const reply = this.cyw43.onCommand(ev.cmd, ev.payload); + const reply = this.cyw43.onCommand(ev.cmd, ev.payload, ev.readBytes); if (reply && reply.length > 0) this.queueCyw43Reply(reply); } } - // Drain queued reply words into any state machine that has space. - this.drainCyw43RxIntoSomeSM(); } private queueCyw43Reply(reply: Uint8Array): void { @@ -497,23 +510,6 @@ export class RP2040Simulator { } } - private drainCyw43RxIntoSomeSM(): void { - if (!this.rp2040 || this.cyw43RxQueue.length === 0) return; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const pios: any[] = (this.rp2040 as any).pio; - for (const pio of pios) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - for (const sm of pio.machines as any[]) { - const rx = sm.rxFIFO; - if (!rx) continue; - while (this.cyw43RxQueue.length > 0 && !rx.full) { - rx.push(this.cyw43RxQueue.shift()); - } - if (this.cyw43RxQueue.length === 0) return; - } - } - } - // eslint-disable-next-line @typescript-eslint/no-explicit-any getADC(): any { return this.rp2040?.adc ?? null; diff --git a/frontend/src/simulation/cyw43/Cyw43Emulator.ts b/frontend/src/simulation/cyw43/Cyw43Emulator.ts index 2ce5b45a..43d5f6ed 100644 --- a/frontend/src/simulation/cyw43/Cyw43Emulator.ts +++ b/frontend/src/simulation/cyw43/Cyw43Emulator.ts @@ -88,15 +88,39 @@ export class Cyw43Emulator { // ── Bus state ──────────────────────────────────────────────────── private f0Regs = new Uint32Array(16); - private clockCsr = ClockCsr.ALP_AVAIL; + // Report both ALP and HT clocks available from the start. The driver polls + // ALP early (after requesting it) and HT after releasing the WLAN core + // (without re-requesting) — a behavioural chip can just have both ready. + private clockCsr = ClockCsr.ALP_AVAIL | ClockCsr.HT_AVAIL; private readTestPrimed = false; private f1Window = 0; + /** + * gSPI word-order regime. The chip powers up in 16-bit little-endian: the + * driver drives all register access through read_reg_u32_swap / + * write_reg_u32_swap (which apply SWAP32) until it writes SPI_BUS_CONTROL + * (F0 0x00) with WORD_LENGTH_32 | ENDIAN_BIG, after which it switches to + * 32-bit big-endian and stops swapping. We must mirror both regimes when we + * encode read responses (and, later, when decoding commands/write-data). + * See project/picow-wifi-emulation/research/01-gspi-bus-protocol.md §3. + */ + private bigEndian = false; + private busControl = 0; + private _rtN = 0; + /** F2 channel usable. We have no real firmware to boot, so it's always ready. */ + private f2Ready = true; + /** Echo store for direct F1 registers the driver writes-then-reads. */ + private f1Mem = new Map(); /** Flips true on the first read of F0:0x14. Available via isChipReady() for diagnostics. */ private chipReady = false; // ── Sequence counters (host expects monotonic) ────────────────── private hostToChipSeq = 0; private chipToHostSeq = 0; + // SDPCM bus flow-control: the host may only transmit F2 data while the + // chip-advertised credit is ahead of its transmit sequence number. We keep + // the credit a window beyond the number of host frames we've consumed. + private hostTxFrames = 0; + private static readonly CREDIT_WINDOW = 4; // ── WiFi state ────────────────────────────────────────────────── private linkState: LinkState = 'down'; @@ -121,6 +145,27 @@ export class Cyw43Emulator { this.staMac = opts.staMac ?? DEFAULT_STA_MAC; // 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 + // bus credits (otherwise its very first IOCTL stalls forever and wifi_on + // times out). Content is a benign empty control frame; only the credit + // byte matters here. + this.pushFrame(encodeSdpcm({ + channel: SdpcmChannel.CONTROL, + sequence: this.chipToHostSeq++ & 0xff, + payload: new Uint8Array(CDC_HEADER_LEN), + })); + } + + /** + * Queue a chip→host SDPCM frame, stamping the bus-credit byte so the host + * stays un-stalled, and raise the F2 packet-available interrupt. + */ + private pushFrame(sdpcm: Uint8Array): void { + if (sdpcm.length > 9) { + sdpcm[9] = (this.hostTxFrames + Cyw43Emulator.CREDIT_WINDOW) & 0xff; + } + this.inboundEvents.push(sdpcm); + this.f0Regs[F0.INTERRUPT >> 2] |= 0x40; } // ── Listener registration ─────────────────────────────────────── @@ -154,11 +199,15 @@ export class Cyw43Emulator { * payload carries data the driver wrote; for RD commands the chip * returns a Uint8Array of length ``cmd.length``. */ - onCommand(cmd: Cyw43Cmd, payload: Uint8Array): Uint8Array | null { - if (cmd.function === 0) return this.handleF0(cmd, payload); - if (cmd.function === 1) return this.handleF1(cmd, payload); - if (cmd.function === 2) return this.handleF2(cmd, payload); - return cmd.write ? null : new Uint8Array(cmd.length); + onCommand(cmd: Cyw43Cmd, payload: Uint8Array, readBytes?: number): Uint8Array | null { + // readBytes = bytes the host will clock back (from the PIO in-count word). + // It can exceed cmd.length for backplane reads (response-delay padding), so + // the response must be sized by it, not by the command's length field. + const rx = readBytes ?? cmd.length; + if (cmd.function === 0) return this.handleF0(cmd, payload, rx); + if (cmd.function === 1) return this.handleF1(cmd, payload, rx); + if (cmd.function === 2) return this.handleF2(cmd, payload, rx); + return cmd.write ? null : new Uint8Array(rx); } /** @@ -172,16 +221,37 @@ export class Cyw43Emulator { sequence: this.chipToHostSeq++ & 0xff, payload: ether, }); - this.inboundEvents.push(sdpcm); - // Light up the F2 packet-available bit so the driver polls. - this.f0Regs[F0.INTERRUPT >> 2] |= 0x40; + this.pushFrame(sdpcm); } // ── F0: gSPI bus control ──────────────────────────────────────── - private handleF0(cmd: Cyw43Cmd, payload: Uint8Array): Uint8Array | null { + /** + * Encode a 32-bit register value into the RX-FIFO word so that, after the + * host's DMA byte-swap (+ SWAP32 in boot mode), the driver recovers `value`. + * Calibrated against the real READ_TEST handshake (boot mode must yield + * 0xFEEDBEAD). queueReply packs these bytes little-endian back into the word. + */ + private encodeReadWord(value: number): number { + // Boot (16-bit-LE, driver SWAP32s): chip drives the halfword-swapped value. + // 32-BE (after SPI_BUS_CONTROL): big-endian, so the value is byte-reversed + // (symmetric with the bswap32 command decode). Validated: the clock-CSR + // ALP/HT bits land in the byte the driver checks. + return this.bigEndian ? bswap32(value >>> 0) : swap16(value >>> 0); + } + + /** True once the driver has switched the bus to 32-bit big-endian. */ + isBigEndian(): boolean { return this.bigEndian; } + + private handleF0(cmd: Cyw43Cmd, payload: Uint8Array, rxBytes: number): Uint8Array | null { if (cmd.write) { const word = readU32LE(payload, 0); + // SPI_BUS_CONTROL (F0 0x00) write flips the chip out of the 16-bit-LE + // boot regime into 32-bit big-endian. After this, no more SWAP32. + if (cmd.address === F0.BUS_CTRL) { + this.busControl = word; + this.bigEndian = true; + } const idx = cmd.address >>> 2; if (idx >= 0 && idx < this.f0Regs.length) this.f0Regs[idx] = word; if (cmd.address === F0.RESET_BP) this.f1Window = 0; @@ -191,24 +261,53 @@ export class Cyw43Emulator { } return null; } - const out = new Uint8Array(cmd.length); + // F0 (bus) reads have no response-delay padding: the value is the first + // word the host clocks back. + const out = new Uint8Array(Math.max(4, rxBytes)); + let value = 0; if (cmd.address === F0.READ_TEST) { - const value = this.readTestPrimed ? TEST_PATTERN : 0; + // READ_TEST is only read during the boot (16-bit-LE) resync. If the + // driver's bus_init do/while retried, the chip is back in boot mode, so + // reset the regime flag (otherwise the retry's swapped commands decode + // as 32-BE and turn to garbage). + this.bigEndian = false; + // First read returns 0 (chip not ready yet), then the magic — models the + // real chip and exercises the driver's retry loop. + value = this.readTestPrimed ? TEST_PATTERN : 0; this.readTestPrimed = true; - writeU32LE(out, 0, value); this.chipReady = true; + } else if (cmd.address === F0.BUS_CTRL) { + value = this.busControl; + } else if (cmd.address === F0.STATUS) { + value = this.spiStatus(); + } else if (cmd.address === F0.INTR_STATUS) { + // SPI_INTERRUPT_REGISTER (16-bit): report ONLY F2_PACKET_AVAILABLE when a + // frame is queued — never the error bits (COMMAND_ERROR/DATA_ERROR/ + // F1_OVERFLOW), which would send the driver into an error-recovery loop. + value = this.inboundEvents.length > 0 ? 0x20 : 0; } else { const idx = cmd.address >>> 2; - if (idx >= 0 && idx < this.f0Regs.length) { - writeU32LE(out, 0, this.f0Regs[idx]); - } + if (idx >= 0 && idx < this.f0Regs.length) value = this.f0Regs[idx]; } + writeU32LE(out, 0, this.encodeReadWord(value)); return out; } + /** SPI_STATUS_REGISTER (F0 0x08): advertise F2 ready / packet-available. */ + private spiStatus(): number { + let s = 0; + if (this.f2Ready) s |= 0x20; // STATUS_F2_RX_READY + if (this.inboundEvents.length > 0) { + // F2 packet available + its length in STATUS_F2_PKT_LEN (shift 9). + const len = this.inboundEvents[0].length & 0x7ff; + s |= 0x100 | (len << 9); + } + return s >>> 0; + } + // ── F1: backplane window ──────────────────────────────────────── - private handleF1(cmd: Cyw43Cmd, payload: Uint8Array): Uint8Array | null { + private handleF1(cmd: Cyw43Cmd, payload: Uint8Array, rxBytes: number): Uint8Array | null { if (cmd.write) { if (cmd.address === F1.SDIO_BACKPLANE_ADDRESS_LOW) { this.f1Window = (this.f1Window & 0xffff00) | (payload[0] ?? 0); @@ -220,28 +319,61 @@ export class Cyw43Emulator { const requested = payload[0] ?? 0; if (requested & ClockCsr.ALP_AVAIL_REQ) this.clockCsr |= ClockCsr.ALP_AVAIL; if (requested & ClockCsr.HT_AVAIL_REQ) this.clockCsr |= ClockCsr.HT_AVAIL; + } else if (cmd.address >= 0x10000 && cmd.address <= 0x1ffff) { + // Direct F1 register (sleep/wakeup CSR, watermark, frame control, …): + // remember the written byte so the driver's write-then-poll succeeds. + this.f1Mem.set(cmd.address, payload[0] ?? 0); } // Auto-increment window pointer for sequential streaming (firmware, // NVRAM, CLM blob). We don't store any of those bytes — the host // driver never reads them back. - if (cmd.increment) this.f1Window += cmd.length; + if (cmd.increment) this.f1Window += payload.length; return null; } - const out = new Uint8Array(cmd.length); + // Backplane (F1) reads carry a response-delay pad: the host reads the data + // word LAST (buf32[index-1]), so the value goes in the final 4 bytes. + let value = 0; if (cmd.address === F1.SDIO_CHIP_CLOCK_CSR) { - out[0] = this.clockCsr & 0xff; + // ALP requested on first poll, HT once the WLAN core is released. Both + // are reported available so the poll loops (x10 / x1000) pass fast. + value = this.clockCsr & 0xff; } else if (cmd.address === F1.SDIO_INT_STATUS) { - // Signal "F2 packet available" if we have queued frames for the host. - if (this.inboundEvents.length > 0) writeU32LE(out, 0, 0x40); + if (this.inboundEvents.length > 0) value = 0x40; + } else if (cmd.address === 0x1001f) { + // SDIO_SLEEP_CSR: echo back with DeviceOn set so the wake poll passes. + value = (this.f1Mem.get(0x1001f) ?? 0) | 0x02; + } else if (cmd.address >= 0x10000 && cmd.address <= 0x1ffff) { + value = this.f1Mem.get(cmd.address) ?? 0; + } else if (this.f1ReadValue) { + value = this.f1ReadValue(this.f1Window); + } else { + // Windowed backplane read (core wrappers / chipcommon / RAM). The driver + // checks each core is clocked + out of reset via the AI wrapper: + // +0x408 AI_IOCTRL -> must read SICF_CLOCK_EN(0x1), FGC(0x2) clear + // +0x800 AI_RESETCTRL -> must read AIRC_RESET(0x1) clear + const off = cmd.address & 0xfff; + if (off === 0x408) value = 0x01; + else if (off === 0x800) value = 0x00; + else value = 0; } - if (cmd.increment) this.f1Window += cmd.length; + return this.backplaneRead(value, rxBytes); + } + + /** Optional hook for chip-ID / backplane-memory reads (set by tests). */ + private f1ReadValue: ((addr: number) => number) | null = null; + + /** Build an F1 read response of rxBytes with the value word at the end. */ + private backplaneRead(value: number, rxBytes: number): Uint8Array { + const n = Math.max(4, rxBytes); + const out = new Uint8Array(n); + writeU32LE(out, n - 4, this.encodeReadWord(value >>> 0)); return out; } // ── F2: SDPCM frame channel ───────────────────────────────────── - private handleF2(cmd: Cyw43Cmd, _payload: Uint8Array): Uint8Array | null { + private handleF2(cmd: Cyw43Cmd, _payload: Uint8Array, _rxBytes: number): Uint8Array | null { if (cmd.write) { const frame = decodeSdpcm(_payload); if (frame) this.handleHostFrame(frame.channel, frame.payload); @@ -266,6 +398,8 @@ export class Cyw43Emulator { // ── Host → chip frame dispatch ────────────────────────────────── private handleHostFrame(channel: number, payload: Uint8Array): void { + // Count every host F2 frame so the credit we advertise stays ahead. + this.hostTxFrames++; if (channel === SdpcmChannel.CONTROL) { this.handleIoctl(payload); } else if (channel === SdpcmChannel.DATA) { @@ -382,8 +516,7 @@ export class Cyw43Emulator { sequence: this.chipToHostSeq++ & 0xff, payload: replyCdc, }); - this.inboundEvents.push(sdpcm); - this.f0Regs[F0.INTERRUPT >> 2] |= 0x40; + this.pushFrame(sdpcm); } // ── Concrete IOCTL handlers ───────────────────────────────────── @@ -480,8 +613,7 @@ export class Cyw43Emulator { payload, this.staMac, ); - this.inboundEvents.push(frame); - this.f0Regs[F0.INTERRUPT >> 2] |= 0x40; + this.pushFrame(frame); } private hasAnyMaskBitsSet(): boolean { @@ -520,6 +652,17 @@ function safe(cb: (ev: T) => void, ev: T): void { try { cb(ev); } catch { /* harness — never throw across listener */ } } +/** Reverse the 4 bytes of a 32-bit word (mirrors the host DMA's bswap). */ +function bswap32(x: number): number { + const v = x >>> 0; + return (((v & 0xff) << 24) | ((v & 0xff00) << 8) | ((v >>> 8) & 0xff00) | (v >>> 24)) >>> 0; +} +/** Swap the two 16-bit halfwords (mirrors the driver's SWAP32/__swap16x2). */ +function swap16(x: number): number { + const v = x >>> 0; + return (((v & 0xffff) << 16) | (v >>> 16)) >>> 0; +} + function readU32LE(buf: Uint8Array, off: number): number { return ((buf[off] | (buf[off + 1] << 8) | (buf[off + 2] << 16) | (buf[off + 3] << 24)) >>> 0); } diff --git a/frontend/src/simulation/cyw43/PioBusSniffer.ts b/frontend/src/simulation/cyw43/PioBusSniffer.ts index cbe4fc42..ec49f475 100644 --- a/frontend/src/simulation/cyw43/PioBusSniffer.ts +++ b/frontend/src/simulation/cyw43/PioBusSniffer.ts @@ -59,6 +59,12 @@ export function swap16x2(word: number): number { return (((w & 0xffff) << 16) | ((w >>> 16) & 0xffff)) >>> 0; } +/** Reverse the 4 bytes of a word (32-bit big-endian regime). */ +export function bswap32be(word: number): number { + const w = word >>> 0; + return (((w & 0xff) << 24) | ((w & 0xff00) << 8) | ((w >>> 8) & 0xff00) | (w >>> 24)) >>> 0; +} + /** * The driver shifts data as 32-bit words via PIO `out pins, 1` over * 32 cycles per word. This streamer accepts whole 32-bit words pulled @@ -76,48 +82,129 @@ export function swap16x2(word: number): number { */ export type SnifferEvent = | { kind: 'header'; cmd: Cyw43Cmd } - | { kind: 'payload'; cmd: Cyw43Cmd; payload: Uint8Array }; + | { kind: 'payload'; cmd: Cyw43Cmd; payload: Uint8Array; readBytes: number }; + +/** + * Transfer-aware decoder for the real cyw43_bus_pio_spi protocol. + * + * Per cyw43_spi_transfer() (pico-sdk), every transfer pushes this exact + * sequence of 32-bit words into the PIO TX FIFO: + * + * [ tx_length*8 - 1 ] // OUT X,32 — bits to shift OUT + * [ (rx_length - tx_length)*8 - 1 ] // OUT Y,32 — bits to shift IN (0 for write) + * [ command word ] // DMA, byte-swapped + * [ write_data... ] // DMA, only for writes (tx_length > 4) + * + * The first two words are PIO loop counters consumed by manual OUT X/Y + * execs — they are NOT bus traffic. Read responses come back on the RX + * FIFO, not TX, so a READ transfer puts only [count][count][cmd] on TX. + * + * The old decoder treated every first word as a command header, which + * mis-decoded the count word (e.g. 31 -> "RD F0 0x3e0") and desynced the + * whole stream, so the driver's bus-control writes never landed and + * cyw43_ll_bus_init() failed with EPERM. + * + * NOTE on swaps: the count words arrive raw (pio_sm_put, no DMA bswap); + * the command/data words arrive byte-swapped by the DMA, which combined + * with the PIO halfword order is undone by swap16x2() (verified against a + * real boot: the READ_TEST 0x14 header decodes correctly this way). + */ +type DecodeState = 'outCount' | 'inCount' | 'cmd' | 'writeData'; export class PioBusSniffer { - private pendingCmd: Cyw43Cmd | null = null; - private pendingPayload: number[] = []; + private state: DecodeState = 'outCount'; + private outBytes = 0; + private readBytes = 0; + private wordsLeft = 0; + private cmd: Cyw43Cmd | null = null; + private writeBuf: number[] = []; + + /** + * Word-order regime provider. Boot (16-bit-LE) commands+data are SWAP32'd by + * the driver and recovered with swap16(); after SPI_BUS_CONTROL the chip is + * 32-bit big-endian and the words arrive un-swapped. The emulator owns this + * state (it sees the SPI_BUS_CONTROL write); we read it at decode time. + */ + private bigEndian: () => boolean = () => false; + setModeProvider(fn: () => boolean): void { this.bigEndian = fn; } + + /** Apply the regime-appropriate de-swap to a command/data word. */ + private deswap(raw: number): number { + // Boot (16-bit-LE, driver SWAP32s the command) → swap16x2 recovers it. + // 32-BE (after SPI_BUS_CONTROL) → the bus is big-endian, so the word is + // byte-reversed → bswap32 recovers it. + return this.bigEndian() ? bswap32be(raw) : swap16x2(raw); + } + + /** Reset framing — call when the SM/transfer machinery is restarted. */ + reset(): void { + this.state = 'outCount'; + this.cmd = null; + this.writeBuf = []; + } + + /** Debug hook (tests): fired with each count word's raw value + decode. */ + onCount: ((kind: 'out' | 'in', raw: number, bytes: number) => void) | null = null; *feedWord(rawWord: number): Generator { - const word = swap16x2(rawWord); + const raw = rawWord >>> 0; - if (this.pendingCmd === null) { - // First word of a transaction is always the header. - const cmd = decodeHeader(word); - this.pendingCmd = cmd; - this.pendingPayload = []; - yield { kind: 'header', cmd }; - - // Zero-length transactions exist (some F0 reads) — emit and reset. - if (cmd.length === 0) { - yield { - kind: 'payload', - cmd, - payload: new Uint8Array(0), - }; - this.pendingCmd = null; + switch (this.state) { + case 'outCount': { + // count1 = tx_length*8 - 1, with tx_length a multiple of 4 (cmd + data, + // all 4-aligned) and tx_length <= 2048+4. Any word that doesn't fit that + // is NOT a transfer start — it's a stray word (rp2040js pushes one extra + // after a large write) or firmware-download data (which we discard). Skip + // it and stay in outCount, so the framing self-heals at the next real + // transfer. This also fast-paths the ~224 KB firmware stream: every data + // word fails validation and is skipped until the post-download HT-clock + // poll re-syncs us. + const ob = (raw + 1) / 8; + if (raw === 0 || ((raw + 1) & 31) !== 0 || ob > 2052) return; + this.outBytes = ob; + this.onCount?.('out', raw, this.outBytes); + this.state = 'inCount'; + return; + } + case 'inCount': { + // (rx_length - tx_length)*8 - 1 -> bytes the chip drives back. + // A write pushes literal 0 here (no read phase). + this.readBytes = raw === 0 ? 0 : Math.round((raw + 1) / 8); + this.onCount?.('in', raw, this.readBytes); + // outBytes/4 words follow: word 0 = command, rest = write data. + this.wordsLeft = Math.max(1, Math.round(this.outBytes / 4)); + this.writeBuf = []; + this.cmd = null; + this.state = 'cmd'; + return; + } + case 'cmd': { + const cmd = decodeHeader(this.deswap(raw)); + this.cmd = cmd; + this.wordsLeft -= 1; + yield { kind: 'header', cmd }; + if (this.wordsLeft <= 0) { + // Command-only transfer: a read, or a write with no data word. + yield { kind: 'payload', cmd, payload: new Uint8Array(0), readBytes: this.readBytes }; + this.state = 'outCount'; + } else { + this.state = 'writeData'; + } + return; + } + case 'writeData': { + const w = this.deswap(raw); + // Write data is little-endian within the de-swapped word. + for (let i = 0; i < 4; i++) this.writeBuf.push((w >>> (i * 8)) & 0xff); + this.wordsLeft -= 1; + if (this.wordsLeft <= 0) { + const dataLen = Math.max(0, this.outBytes - 4); + const payload = new Uint8Array(this.writeBuf.slice(0, dataLen)); + yield { kind: 'payload', cmd: this.cmd!, payload, readBytes: this.readBytes }; + this.state = 'outCount'; + } + return; } - return; - } - - // Payload follows. Each 32-bit word carries 4 payload bytes, - // little-endian on the wire (the PIO halfword-swap above already - // converted to "host byte order"; payload bytes are then byte-LSB - // first per pico-sdk's WORD swap macro on writes). - for (let i = 0; i < 4; i++) { - this.pendingPayload.push((word >>> (i * 8)) & 0xff); - if (this.pendingPayload.length >= this.pendingCmd.length) break; - } - - if (this.pendingPayload.length >= this.pendingCmd.length) { - const payload = new Uint8Array(this.pendingPayload.slice(0, this.pendingCmd.length)); - yield { kind: 'payload', cmd: this.pendingCmd, payload }; - this.pendingCmd = null; - this.pendingPayload = []; } } }