wip(picow): CYW43 emulation — chip bring-up works, active(True) returns

Brings the Pico W CYW43439 gSPI emulation from "fails at the first register
read" to "the chip boots fully and MicroPython's network.WLAN().active(True)
returns" — validated end-to-end against the real RPI_PICO_W firmware via a
headless boot harness.

What now works (Phases 1-2):
- PioBusSniffer rewritten to the real cyw43_bus_pio_spi framing
  [out_bits][in_bits][cmd][write_data], skipping the two PIO loop-counter
  words. Self-healing: validates count1 (= tx_length*8-1, 4-aligned, <=2052)
  and skips non-conforming words — re-syncs after the extra word rp2040js
  pushes on large writes AND fast-paths the ~224 KB firmware stream.
- Dual word-order regime: boot 16-bit-LE (swap16x2 / swap16) flips to 32-bit
  big-endian (bswap32) at the SPI_BUS_CONTROL write. Calibrated empirically
  against the firmware. Sniffer reads the mode via setModeProvider().
- Cyw43Emulator: encodeReadWord (per-regime), readBytes-sized backplane reads
  with the value in the last word (response-delay pad), ALP+HT clocks and F2
  always ready, AI core registers (IOCTRL/RESETCTRL), interrupt register
  reports no errors, f1Mem echo store, SDPCM bus-credit granting + initial
  frame.
- RP2040Simulator: serves chip responses on rxFIFO.pull (on-demand) instead of
  racing the async DMA/PIO; passes readBytes through.

Not done yet (Phase 3+): connect() runs but stalls in the power-management /
save-restore phase before any F2/IOCTL traffic; packet transport (Tier 2) and
firmware-clocking perf are open. See project/picow-wifi-emulation/ for the full
research, phases, and findings.

The boot harness (picow-cyw43-boot-harness.investigate.test.ts) is gated behind
CYW43_HARNESS=1 so it stays out of the normal test run.
This commit is contained in:
David Montero 2026-06-12 20:04:06 +02:00
parent 478fd75a7e
commit 9197fcaaf6
5 changed files with 582 additions and 93 deletions

View File

@ -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<string, number>();
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);
});

View File

@ -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);
});
});

View File

@ -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;

View File

@ -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<number, number>();
/** 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<T>(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);
}

View File

@ -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<SnifferEvent> {
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 = [];
}
}
}