Skip to content

The ISS board: what is modelled, what is not

Tracking list for running the real P2 firmware image on p2core/p2iss with every part of the PCBA present as a node on the net.

The rule this list is measured against: components interact only through TheveninDrive levels or periodic (pulse) drives. No byte routes, no stream routes, no shared tables. A signal that cannot be expressed that way is called out explicitly rather than quietly special-cased.

Status keys: done · partial · todo · broken


Pin map — settled, and previously contradictory

Three sources disagreed. Resolved against the hardware and the toolchain:

Pin Flash (boot) microSD Source
P58 DO (MISO) DAT0 / MISO P2 Edge module guide; MaD_Edge.kicad_sch labels it P58/FLASH_MISO
P59 DI (MOSI) CMD / MOSI as above
P60 CLK DAT3 / CS as above
P61 CS CLK as above

Flash and microSD share four pins and swap CLK/CS between them. That is why the three maps disagreed:

  • p2core/src/board.rs (DO=58, DI=59, CS=60, CLK=61) — correct for SD, and independently confirmed: FlexC's _vfs_open_sdcard() is _vfs_open_sdcardx(pclk=61, pss=60, pdi=59, pdo=58) (toolchain-flexcc/include/filesys/fatfs/fatfs_vfs.c:82).
  • The P2 boot ROM uses P61=#CS, P60=CLK, P59=DI, P58=DO — correct for flash, and the opposite assignment.
  • Firmware/MaDCore/src/HAL/Include/HW_pins.h (SD_CLK=61, SD_MOSI=60, SD_MISO=59, SD_CS=58) — wrong, and dead: nothing reads it for SD, because the firmware calls _vfs_open_sdcard() which carries its own pins. Misleading; should be corrected or deleted. See Open items.

Components

Component Transport Status
Host protocol link (P32/P30: on P2Iss directly; on the project files through the carrier's J4 and IC2) UART framed on Thevenin levels, rate derived from the guest's own WXPIN divisor done
Debug console (P62) Byte-level inside p2core::Board, forwarded to the emulator log partial — not a node
DS2 piggy board (netlist) + ADS122U04 UART on levels; analog inputs via MNA cluster solve done — see F1
Load cell bridge Two Thevenin drives into AIN0/AIN1 done
Servo drive — STEP Timed level transitions (p2iss::pulse::PulseDriver), both P_TRANSITION and P_NCO_FREQ done
Servo drive — DIR, ENA Thevenin levels done
Servo RDY Sensed level done
Quadrature encoder (A/B/Z) Encoder model drives levels; p2core decodes Gray phases with slip counting done
End stops (upper, lower) EndSwitch dry contacts + weak pull-downs done
Door end stop Weak pull only — no model partial
ESD lines (upper, lower, switch) Sensed levels, idle high (activeLow in HAL_GPIO_config.c) done
ESD power, charge pump Driven levels done
microSD card On nets as a decoupled component; the ISS reads its smart-pin roles from the mode words (p2core::smartbus) and holds no SPI knowledge; firmware mounts it done — see S1, S2, G4
SPI boot flash On the net (p2iss::flashnode), bit-banged edge-by-edge done — see B1
P2 ROM bootloader Executes the real Parallax ROM and boots from the net flash done — see B2

Open items

F1 — ADS122U04 never answers — FIXED

Root cause was not in the ADC model or the wiring: the shared UART deframer required a full nominal frame time before it would accept the next start bit.

A P2 smart pin asked for 115'200 baud at clkfreq = 160 MHz actually clocks 115'273 — 0.06 % fast, a rate any real UART accepts without noticing. Integer bit periods make the sender's frame 86'750 ns against the receiver's 86'800, so the second byte's start bit landed 50 ns before the deadline, was absorbed into the frame in progress, and the stream desynchronized permanently. The model read the protocol's 0x55 sync byte as register data and never recovered.

Fix (embsim, board/src/uart.rs): close a frame at stop_sampled_ns() — the midpoint of the stop bit, which is where real hardware samples it — rather than at end-of-frame. That buys the standard half-bit of tolerance in both directions. Regression tests a_marginally_fast_sender_still_frames_back_to_back_bytes and its slow counterpart; the fast one fails without the fix.

Also fixed in p2iss: every transmit pin now idles high from attach, so a far end has a mark level to detect a start bit against before the guest has configured any rate.

This is an upstream embsim change and needs its own PR.

Consequence worth noting: with the force gauge actually streaming, the ISS changed the speed figure of the day (0.74x → 0.35x), but both numbers predate the G3 time-model fix and were artifacts of it; see G3 for the honest measurement.

S1 — microSD as a node — DONE

The framing gap is closed. Root cause: AKPIN assembles as WRPIN #1,S (confirmed against flexspin's listing), so the acknowledge rcvr_mmc opens with never reached the bit-level receiver; the stale word it had latched during the preceding transmit satisfied TESTP immediately, the guest ran a byte ahead of the bus and deselected mid-frame. Fixed by catching the WRPIN #1 acknowledge in the SPI path, and restarting the receiver's bit counter on WXPIN. The full SDv2 initialisation now decodes off the wire, and the firmware mounts the card (see S2).

Original detail follows.

S1 (original) — microSD as a node — built, one gap left

The bus is real. p2core::SpiShift is a bit-level shifter pair (p2core/src/spi.rs), p2iss::spi_bus::SpiSequencer drives the clock and MOSI as TheveninDrive levels one transition at a time, and p2iss::sdnode::SdCardNode is a component that senses CLK/CS/MOSI and drives MISO. p2iss::P2Iss::with_spi puts the guest's four SPI pins on nets.

Working and asserted (p2iss/tests/sd_node.rs): clock edges cross a net, chip select gates them (a deselected card ignores the 64 dummy init clocks), bytes assemble out of bits, and the card decodes CMD0 — frame, argument and CRC — off the wire.

Four hardware behaviours had to be modelled to get there, each found by reading sdmm.cc rather than guessing:

  1. SPI mode 3, not mode 0. The driver sets spm_ck = P_PULSE | P_OE | P_INVERT_OUTPUT and calls it CPOL = 1. The clock idles high, leads with a falling edge, drives data on the leading edge and samples on the trailing one.
  2. DIRL/DIRH on the transmit pin are reset/liven, not direction. WYPIN loads the shifter while reset and the buffer once livened.
  3. The transmitter is two registers deep. xmit_mmc's continuous-mode loop queues the next longword and spins on TESTP PIN_DI for the buffer to empty. A one-register model loses every word but the last.
  4. The receiver holds one word, not a queue. A word completing before the last was read overwrites it. Queueing them made TESTP read true when nothing had arrived, so the driver stopped waiting for the bus and fired burst after burst into a transfer that had not happened.

The remaining gap: every command after CMD0 arrives misframed, currently by four bit times — the card reads 0xF4 where 0x48 was sent, which is four idle ones followed by the first four bits of CMD8. The byte boundary slips somewhere between the first response and the second frame, a region containing a deselect, a reselect and wait_ready's idle bytes. It is a count of clock edges going astray, not a wire-order or polarity error — either of those would have broken CMD0 too.

Captured as the_card_completes_the_full_initialisation_sequence, #[ignore]d rather than weakened, to be un-ignored when it passes.

Cost, measured (p2core/examples/sd_cost.rs): a three-second boot clocks 194 SPI bytes ≈ 4 656 engine events; a sustained 512-byte block is ~12 k. Affordable — an earlier estimate against this was wrong.

S2 — FlexC mount() — DONE

The mount succeeds. There were two defects between the card and a working mount, both fixed:

  1. A p2core BITH span bug. flexspin encodes a method-pointer tag as obj | (index << 20), which compiles to BITH obj, #20 ADDBITS 4 — a five-bit span, not one bit. p2core's BITH/BITL/BITNOT set a single bit, turning method tag 31 into tag 1, so mount()'s (*v->init)(name) dispatched into the cog manager's task table instead of the filesystem's v_init and returned 1. Fixed with a span implementation and a regression test (bit_instructions_cover_their_addbits_span).
  2. A toolchain heap-reuse bug in fatfs_vfs.c: the FFS block (which carries ff.c's FatFs[] statics as members) was not zeroed, so f_mount read a garbage "old volume" and ran disk_deinitialize on it — closing the block handle that had just been opened. Fixed by zeroing the two managed blocks in the toolchain source (a local patch; see Toolchain patches).

With both fixed the firmware reads block 0 (boot sector) then block 129 (root directory) over the wire and advances past the mount. Tests p2iss/tests/sd_mount.rs (formatted card mounts, blank card is probed and rejected) and sd_node.rs (the full init sequence completes).

S2 (original) — FlexC mount() returns -1 — narrowed

dev_nvram.c:218: failed to mount sd card: /sd, so the firmware boots on failsafe records. The image is a valid FAT16 volume — fsck_msdos reads it clean, and p2iss::sdimage's own tests walk the directory tree and cluster chains — so the fault is not in the image.

Narrowed with p2iss/examples/sd_mount_probe.rs: against the byte-routed card the guest issues CMD0, CMD0, CMD8, CMD55, ACMD41, CMD58 — a complete, correct SDv2 initialisation — and then never reads a single block (reads: []). FatFs cannot have started mounting, so the failure is inside _sdmm_open (fatfs_vfs.c:30): either disk_setpins/pin reservation or disk_initialize returning STA_NOINIT. The byte-routed model cannot get further because it cannot represent what disk_initialize does next — the 15 kΩ pull-up on the receive pin, or the smart-pin reconfiguration (_wxpin(PIN_CLK, ck_div), _wrpin(PIN_DI, spm_tx)) at its tail.

Likely subsumed by S1. A faithful bit-level bus represents all of it; this should be re-tested once S1's framing gap closes rather than chased separately.

B1 — SPI boot flash — DONE (on the net)

p2iss::flashnode::FlashNode is a first-class component on real nets. It wraps p2core::SpiFlash (the shared protocol: $66/$99 reset, $04 write-disable, $05 status→$00, $03 read-data), senses CLK/CS/MOSI and drives MISO only while the part sends data, status or its ID (SpiFlash::drives_miso), releasing it otherwise. That is what a microSD on the same four Edge-module pins needs, with CLK/CS swapped (flash CLK=P60, CS=P61): the flash's CS is the card's clock, so every low half of a card clock pulse selects it. No test puts a card beside this node yet; rom_boot_net.rs has the flash alone.

It works because the ISS yields at every net-pin event — the mechanism that answers "why can't embsim handle a bit-banged device". See The yield mechanism below. p2iss/tests/rom_boot_net.rs boots the real ROM from a net flash: the flash sees ~17k clock edges cross the net, and the payload runs.

p2core::SpiFlash also has a Board-resident host (Board::with_flash) used by p2core/tests/rom_boot_chain.rs — a fast, instant-pin path for the CPU-level boot test, with no net. Same protocol logic, two mounts (exactly as the SD card is both byte-routed in Board and net-routed via SdCardNode).

B1 (original) — SPI boot flash as a node

The P2 Edge carries a serial flash on P58–P61 with CLK = 60, CS = 61 — the same four wires as the microSD, with those two exchanged. Nothing models it.

The infrastructure now exists: SpiPins::FLASH is defined alongside SpiPins::SD, and a flash component would sense the same bus behind its own chip select, exactly as the hardware distinguishes them. Blocked on S1's framing gap — there is no point adding a second device to a bus whose byte boundaries drift.

B2 — P2 ROM bootloader — DONE

The real Parallax boot ROM executes end to end. p2core::Machine::with_boot_rom loads the 16 KB ROM into the top of hub RAM and starts COG 0 at $FC000 (wrapping to $7C000), exactly as silicon does. The ROM seeds its RNG, loads its own cog/LUT images, walks the pull-up decision tree on P59/P60/P61, and — finding the flash strap on P61 — bit-bangs the SPI flash, loads its first kilobyte, verifies the 256 longs sum to "Prop", copies them to cog RAM and jumps.

That kilobyte is p2iss/rom/stage1.spin2, this repository's stage-1 loader: it reads the application length at flash $400, streams the application into hub 0, and relaunches the cog on it — the same entry convention the ROM used on stage-1. p2iss::flashimage::boot_flash builds the layout.

Getting the ROM to run needed a batch of instructions the MaD firmware never uses, each added to p2core with its behaviour read off the ROM source or flexspin's output: RCL/RCR, the hub FIFO (WRFAST/RDFAST/WF*/RF*), SETD/SETS, SETQ2→LUT block loads, DRVC/DRVNC/DRVZ/DRVNZ/DRVNOT, BITC/BITNC/BITZ/BITNZ, the SETSE/POLLSE/SETINT event ops (inert), and the full J{n}CT/SE/INT/ATN/PAT/… jump-on-event family. Two structural fixes came with them: the 20-bit PC now wraps rather than trapping (so $FC000 hub-exec works), and an augmented S is a 32-bit literal, never a PTRA/PTRB expression — without that the ROM's own self-load read from the wrong place. Test: p2core/tests/rom_boot_chain.rs.

Board time on a ROM boot is the clock the chip runs on (p2core::Machine:: time_map): RCFAST's nominal 20 MHz out of reset, then whatever each HUBSET clock word selects — RCSLOW, or for the crystal and PLL the rate the program recorded at hub $14 — carried on from the instant of the switch. Hub $14 is the ROM's scratch until a program is loaded, so the ROM's serial window (1.8e9 clocks, a minute on the fastest 30 MHz part) lasts 90 s of board time, as it would at nominal. Tests: p2core/tests/rom_time_base.rs, p2iss/tests/rom_serial_window.rs. A P2IssCore keeps its own map from the clock word and the crystal on XI.

Reference: p2iss/rom/rom_booter_v33k.spin2 (trimmed Parallax source; see p2iss/rom/README.md).

B2 (original) — P2 ROM bootloader

Real silicon loads 16 KB of boot code from an internal ROM into the top of hub RAM; COG 0 runs it, sets RC-FAST, samples pull-ups/downs on P59/P60/P61 to pick a boot source, and loads the application from serial, SPI flash, or SD. The ISS skips all of it and writes the image into hub RAM directly.

What the ROM does, from the Parallax documentation: on reset the P2 loads 16 KB of boot code from an internal serial ROM into the top of hub RAM; COG 0 runs it, sets RC-FAST, and samples pull-ups/pull-downs on P59/P60/P61 to choose between serial, SPI flash and SD as the boot source.

Reference: ROM_Booter_v33k.spin2 in parallaxinc/propeller (resources/FPGA Examples/) — permissively available, worth reading before implementing rather than inferring the decision tree.

Depends on B1: there is nothing to boot from until the flash is a device.

B3 — the ISS as the P2 package's core, booting off the P2-EC32MB's flash — DONE

p2iss::P2IssCore (p2iss/src/package.rs; embsim MIGRATING-MAD.md step 2) is the same p2core machine and the same net bus as P2Iss, as an embsim_boards::p2::P2Core: it fills the P2-EC32MB's processor slot U100 inside embsim's P2Package, which holds it until the module's reset has released and the datasheet's 3 ms restart delay has run out (5.5 ms from the module's fingers), then starts it on the engine thread. It takes all 64 pads and reads every one; no pin list says what a pin is:

  • a pad drives from the guest's DIR, OUT and mode word, through BankSupplies::pad_drive — high at its bank's supply, at the strength the word's drive field sets (fast, 1.5 k, 15 k, 150 k, float);
  • a P_ASYNC_TX pad marks its line from the moment it is enabled and frames bytes at the rate its bit period and the guest's clock give, and takes one byte at a time: it reads busy until the byte's frame is out, in the guest's clocks, so a sender waits a frame time a byte, as on silicon; a P_ASYNC_RX pad gets a deframer at its own rate and holds one byte, which the next overwrites: since the guest runs ahead, a byte lands at its own instant or the guest's present, whichever is later, and no closer to an unread byte before it than the wire put them (package.rs, "Receive"), so an overwritten byte is the firmware's own read gap (p2iss/tests/package_rx_overrun.rs); a pad made a receive pin before its rate is written holds its line's transitions for the deframer the rate gives it; a P_TRANSITION or P_NCO_FREQ pad is a step train (p2iss::pulse), and a WRPIN to any other mode banks its count and hands the pad back to the word, so _pinclear releases it and the firmware's software step pulses (HAL_pulseOut.c, below about 1.22 kHz) reach the net; the sync-serial pins are the smart bus (G4), clocked on the guest's own clock, so the module's flash and card share P58–P61, and a bus pin with DIR clear or its word cleared is released;
  • a pad whose published drive is strong reads its OUT, every other pad its net (p2core::Board::sensed, the strong-mask rule);
  • the guest's clock is the one its HUBSET word selects (reported to the package, whose XI reading depends on it) and the crystal the package reads on XI gives: RCFAST from START, then the PLL's 160 MHz from the module's 20 MHz TCXO. Hub $14 plays no part;
  • the guest leads, and every change waits for its own instant (embsim DESIGN.md rule 3). A wake runs the cogs in clock order, at most one slice past the engine; an instruction that does something outside — a pad, a mode word, a byte for a transmit pin, a train, the clock word — ends the pass, and what it did is carried out by the wake at the instant it ran at (its cog's clock after it, through the clock map), which then wakes again at that instant so the engine resolves the change, and delivers any answer, before the guest's next instruction. The bus clock's edges are clock counts the pass stops at, carried out the same way. Two wakes a change;
  • nothing straps a pin: S301 does.

Known gaps, written up for embsim and the serial-boot step: the pads ignore P_INVERT_OUTPUT in a logic-mode word (BankSupplies::pad_drive reads only the drive fields; QEMU's core has the same gap), so the 15 kΩ pull-up flexspin's sdmm.cc sets on the card's data-out pin P58 (OUT low, inverted) presents as a pull-down; TESTP on P0/P1 reads high once P63 has carried anything since it became a receive pin (a byte waiting, or, before the guest has given P63 a bit period, its line moving, held for the deframer that bit period gives it) for the ROM's autobaud, a shortcut no net or mode word gives, with RDPIN on them p2core::Board's fixed 2 000 000 baud measurement; a receive pin takes bytes whatever its DIR, where silicon's starts at the ROM's dirh after the autobaud > (the ROM drops each > and its parser scrolls past the rest); and stage-1's relaunch (setq #$FF / rdlong 0, #0 / jmp #0 at cog $019) works on the ISS only because the reloaded $019 happens to be harmless: p2core executes the reloaded long where silicon runs the jmp #0 it had already fetched.

p2iss/tests/ec32mb_flash_boot.rs, stepped, builds the module from its vendor netlist, powers it from J203's fingers, closes S301 positions 2 (FLASH) and 4 (the P59 pull-down), and holds that the ROM reads stage-1 at 0 and stage-1 the 324 512-byte MaDEdge program (the debug build of the time, now the one MaD build) at $400, that the core starts at 5.5 ms, and that a terminal at 230 400 baud on J203.50 (P62/PRG_DBG_TXD) reads Starting MaD Board at 1.254 s of board time, with the firmware on 160 MHz from the crystal. p2iss/tests/package_pads.rs, stepped, boots small programs the same way with a scope on one pad: the boot's 18 373 flash clock edges each on a nanosecond of its own, every one on a tick of the 20 MHz RC clock from START, 100 ns apart at the closest; a step train's pin released by _pinclear and then driven as a plain pin; and the sync-serial clock's transitions half its period apart from the WYPIN, the pad released once the pin is cleared. P2Iss keeps its Component impl and pin lists for the p2iss tests that still use them. mad-emulator, their last consumer outside those tests, has retired (MaD #362); they go in step 11's follow-up, which MaD #298 tracks until it lands.

p2iss/tests/ec32mb_sd_mount.rs, stepped, is the same module with the card make sd-image writes in J301: the firmware's boot-time mount and its logging core's mount both succeed, and the card is read at block 0 and then at its root directory, block 129. It needs embsim with the W25Q128JV's DO released until it has data to shift out (RileyMcCarthy/embsim#141). Before that the flash, whose /CS is the card's clock P61 with S301 FLASH closed, drove P58 high through every low half of the card's mode-3 clock at 25 Ω against the card's 265 Ω (R304), and was deselected on each rising edge. The failure took a second departure from silicon, in p2core: the smart bus latches MISO from the level held just before the rising edge, while sdmm.cc asks for the sample after it (wxpin #7 | 32) and p2core drops that bit (RileyMcCarthy/MaD#269). So every byte read $FF, the card heard only CMD0, and the mount failed with errno 12. Just after each edge P58 already carried the card's bit (measured in #268's review), so a p2core that sampled after the edge would have read the card even beside the old flash (inferred). The flash's fix is needed regardless: the part leaves DO at high impedance until it has data to send (W25Q128JV Revision F §8.2.6, Figure 14, p.28). One gap remains on that net: before the card has had a command, embsim's card drives MISO whenever P60, the flash's clock, selects it, so the ROM's and stage-1's flash reads fight it (the flash wins, at ten times the strength, and the boot is unaffected; RileyMcCarthy/embsim#142).

G1 — Debug console is not a node

Inside the package (B3) it is: P62 is an async transmit pad on the module's net, and a terminal on J203.50 reads it. For P2Iss: P62/P63 are still byte-level inside p2core::Board. They should be a SerialLink like the protocol and force-gauge links.

G2 — HW_pins.h SD block is wrong and unused

Correct it to the real microSD map (CLK=61, CS=60, MOSI=59, MISO=58) or delete it, and note that _vfs_open_sdcard() carries the pins.

G3 — ISS runs below real time — measured, and mostly the firmware's polling

The old "0.74×" was an artifact. system_clocks() returned the maximum cog clock and step_until round-robined by turn, so one cog's waitx/waitct1 dragged the machine's "now" forward and froze every other cog for the length of the wait: the protocol cog answered ~2.5 s late, and the phantom Scheduling overrun (19994/1000 us) came from the same jump. Time is now the frontier — the minimum over running cogs; GETCT/WAITCT1 use the cog's own clock; a coginit'd cog inherits its starter's clock. Request→reply is 50–100 ms of wall at --speed 1.

Honest speed after that: ~0.1× on an M2, ~22–29 M guest instructions/s of wall. A profile (sample, 8 s, steady state) puts ~61% in p2core interpretation, ~23% in the pacer's nanosleep (genuine guest idle — unpaced reaches 0.10× against 0.09×), and ~7% in the embsim net engine. A transaction-level bus would trim that 7%, not the interpreter; see docs/dev scope notes before reopening the byte route (embsim c254a64 deleted it on purpose).

Where the guest instructions go, on the Board model: the firmware's own free-running polls. After the G5 fix below, HAL_serial_recieveBytes's 1024-iteration idle spin (SERIAL cog) is ~85% of all executed instructions, the force-gauge _getus timeout loop most of the rest. waitx/waitct1 are free clock jumps in the ISS (and cheaper on silicon), so a short backoff in those idle paths would make the ISS nearly real-time without touching the interpreter. The existing idle-poll fast-forward (PollState) only skips pure register-only self-loops; it saves nothing while any other cog is busy, because step_until still steps every cog under the deadline once per pass.

Cheap interpreter wins measured on a copy, not yet landed: precompute the per-pass deadline in clocks instead of now_us() + a mul/div per cog (+5.5%), hoist the std::env lookups out of the hot path and a 4-byte rd_long fast path (+4.5%); a decode cache is worth only ~2%.

G5 — SETQ + WRLONG #imm is a block fill — FIXED

flexcc lowers memset(p, 0, len) to setq #len/4-1 / wrlong #0, p (31 sites in this image; every module's _init). p2core executed the immediate form as the register form — a block copy from cog register 0 upward — so each _init sprayed cog 0's FCACHE contents over its own data. The visible chain: app_testManagement_data.request.pendingManualMoveCount received the bytes of a jmp word (0x5D9000), the CONTROL cog drained six million phantom moves under the CONTROL-channel lock forever (lib_staticQueue_push: data is FULL on every one), and MONITOR spent 100% of its instructions in locktry on that lock — "live readout never populated" in the app e2e. Found by tracing lock-table transitions per instruction, then watching the corrupted hub long for writers (the writer was _app_testManagement_init+0x18, i.e. the memset itself). Symbols came from re-assembling the flexcc .p2asm with flexspin -2 -l, which the PlatformIO build does not produce.

After the fix: zero queue-full prints, guest demand 172 M → 75 M instructions per virtual second, app e2e smoke 2/20 → 8/20. Regression: p2core/tests/block_fill.rs.

What the corrected ISS then surfaced — firmware, not ISS, and not yet acted on: - MONITOR stack overflow. 1024-byte stack, 1000 bytes (97%) peak; the upper canary trips at the first test start (Stack underflow detected on channel 0) → channel ERROR → APP_CONTROL_FAULT_COG → every later WRITE_MOTION_ENABLE NACKed. The debug image's %0.3f DEBUG_* formatting on that cog is the likely straw. - NVRAM save unmounts the LOGGER's volume. A runtime profile save (dev_nvram_run: 4 -> 3) mounts and umounts the single FatFs volume on MAIN while LOGGER owns it; LOGGER's next opens fail with errno 12 and WRITE_TEST_RUN is NACKed (invalidatedPartialUpload). dev_nvram.c:464 already works around the boot-time version of the same race. - LOGGER overruns of 1.4–5.3 ms on file open/close (FatFs directory work inside a 1 kHz tick) — real, noisy, not fatal. - p2core's LOCKTRY lets the owning cog re-acquire (reentrant); CLAUDE.md documents HAL locks as non-reentrant. Verify against the silicon doc — the firmware's shared CONTROL-channel lock (app_motion / app_testManagement / app_control) has nested-acquire paths that only work if silicon agrees.

U1 — the UART deframer fix must go upstream

embsim/board/src/uart.rs gained UartFraming::stop_sampled_ns and two regression tests (see F1). The submodule is on the unmerged branch feat/delete-byte-routes; this needs its own PR to embsim before the MaD pin bump.

G4 — the SD smart-pin path is generalized — DONE

The SD card is now as decoupled from the CPU as the flash. p2core::smartbus models the P2's synchronous-serial smart pins as CPU hardware: it reads the clock/TX/RX roles from the mode words the firmware programs (P_PULSE clock, P_SYNC_TX/P_SYNC_RX with the clock-pin offset in bits 26:24 — the same field the quadrature decoder reads), shifts and samples on the clock's edges, and exposes the driven pin levels. The ISS no longer declares an "SPI bus" or knows any pin roles: it lifts the four SD pins onto nets generically (with_sync_serial enables the board's smart-pin model), pumps the clock pin's edges over virtual time, and moves levels. SdCardNode is a plain net-sensing component.

Two bugs were found completing this, both worth recording: - WYPIN y on a pulse clock is y pulses = 2y transitions — the old sequencer knew this (transitions = pulses * 2); the first cut clocked half. - One edge per distinct virtual instant. The yield re-wakes and the periodic slice all call the pump at the same instant; firing an edge on each burst several transitions into one net resolution, which collapse on the wire and make the device miss edges. The pump now gates on a scheduled edge time (NetPins::next_edge_ns) — the same discipline the flash bit-bang needs, made explicit for the smart-pin clock.

p2core::Board keeps its byte-routed SD for standalone runs and unit tests (off unless enable_smart_bus), exactly as it keeps the byte path beside the net one for other peripherals.

G6 — the SD write path had never been exercised — FIXED

Every failure that looked like a filesystem or firmware fault on the ISS — fopen(..., "wb") returning errno 12, WRITE_TEST_RUN NACKed, "New Test started" never appearing — was the card model. errno 12 is EIO in FlexC's non-standard errno.h (ENOMEM is 7, EMFILE 11), and _set_dos_error maps it from FR_DISK_ERR; the guest was reporting a disk fault, accurately. Four defects, all in p2core, none in the firmware:

  • CMD24 could not receive its block. The command arm set Phase::ReceivingBlock, then xfer_inner's Command arm overwrote it with Responding because a response was now queued, and draining that response returned the card to Command. ReceivingBlock was unreachable. The host read $FF where xmit_datablock wants the $05 data-accepted token, and the 512 payload bytes went to the command collector, which latches on any %01xxxxxx byte and manufactured commands out of file data — a directory entry's "BIN " decoded as CMD2. Fixed with pending_write + after_responding().
  • CMD18/CMD25 were not implemented at all. sdmm.cc picks the multi-block form for any count > 1, which is most of what FatFs asks for, and the model answered R1_ILLEGAL — send_cmd() == 0 reads that as failure. Single-sector metadata writes worked, so a file would be created and then fail on its contents, which reads as an intermittent fault.
  • Releasing CS did not abandon a transfer. xmit_datablock starts with wait_ready(), and on timeout returns without sending the token: the card is left waiting for a block that never comes. Only a deselect frees it, and the net-side node stops exchanging bytes entirely while CS is high — so a card that only noticed the release during an exchange never noticed at all. It stayed in ReceivingBlock and swallowed every later command frame as payload; the captured wire shows the host re-sending 58 00 00 00 a5 01 into a card answering $FF, with the bus dead for the rest of the run. Fixed by making it SdCard::set_selected, called from the CS sense.
  • Idle-poll fast-forward starved the bus. See G7.

Regression tests: p2core/tests/sd_block_write.rs drives the card exactly as sdmm.cc does (command frame, R1 poll, token, payload, CRC, data response) for single block, multi-block read and write, a deselect mid-block, and the "command accepted but no block sent" stall.

G7 — fast-forward must not skip a spin on a pin mid-transfer — FIXED

testp changes nothing and passes every purity test, so the idle-poll detector counted testp / jmp #$-1 as a confirmed idle loop and jumped the cog's clock to the end of the slice. But step_until is called with the engine's current instant, so a fast-forwarded cog executes about one iteration per wake — while sdmm.cc times its own transfers out in guest clocks (500 ms in wait_ready, 125 ms for a data token). The transfer crawled, the timeout expired, and select() failed with no command ever reaching the card: 31 writes per run became zero. A/B with P2CORE_NO_FF=1 was decisive.

The rule is now: a loop that reads a pin (TESTP/TESTPN/RDPIN/RQPIN, or INA/INB as a source) may still be fast-forwarded when nothing is clocking — a level can then only change on a net wake, which ends the slice anyway — but never while PinBus::external_transfer_busy() reports a burst in flight. The blanket version of this rule cost 4x throughput (0.10x → 0.03x); gated on an in-flight transfer it costs nothing measurable.

Firmware findings the corrected ISS surfaced — FIXED

  • MONITOR overflowed its stack. 1024 bytes against a measured 1032-byte deepest path (app_monitor_run → processLogging → IO_SDCard_push → lib_staticQueue_push → DEBUG_ERROR → fprintf → __dofmt → __fmtfloat; the %0.3f timestamp in DEBUG_* is most of it). The canary tripped at the first test start, the channel went to ERROR, APP_CONTROL_FAULT_COG latched, and every later motion-enable was NACKed — six e2e scenarios waiting on "Motion: enabled". Now 2048.
  • The two canary messages were swapped. The stack grows up from stack[0], so corrupting the lower canary is an underflow and running off the top is the overflow; the strings said the opposite.

G8 — the guest slept a full slice after every SD byte — FIXED

Measured, not guessed: a burst-timing probe over a whole run put a byte's wire time at a median 0.40 us and the gap after it at a median 93.6 us, so 99.9% of SD virtual time was dead gap and a sector cost ~50 ms of guest clock instead of ~0.2 ms. service_smart_clock armed nothing when a burst finished, so the guest waited for the next periodic SLICE_NS wake before it could clock the next byte. sdmm.cc times its own transfers out in guest clocks, so this alone can fail a transfer the card answered perfectly.

The fix arms a wake at the next microsecond, which is the finest step step_until can act on. It must be computed from now_ns, not from the edge's own due: due can already be in the past when the wake fires, and arming there makes the wheel refire at an instant virtual time cannot leave — that deadlocked the engine and was the reason the first attempt was reverted. After the fix the median gap is 0.60 us (156x better) with idle throughput unchanged at 0.10-0.12x.

G9 — DIR/OUT are per-cog; the pad sees the OR — FIXED

This was the lost data token, and the cause was nowhere near the SD model.

Board mirrored DIRA/DIRB/OUTA/OUTB into one global pair, but they are per-cog registers on the P2 and the pad sees the OR across all eight — the firmware says so itself at dev_nvram.c:462 ("The P2 ORs pin DIR/OUT across all 8 cogs"). So any cog's write erased every other cog's. The debug console (P62) and the SD card (P58..P61) share DIRB, and a DEBUG_* print issues about 327,000 DIRB writes in 30 seconds, every one of them with the SD bits clear. Each made dir_of(P59) read low, and Board::dir_out_changed forwards that to the smart bus as set_tx_livened(false) — which is a reset of the sync-serial transmit shifter, not a direction change.

When one landed between sdmm.cc's dirh PIN_DI and its clock burst, the loaded $FE data token was wiped and the card was clocked $FF instead. The card then sat in ReceivingBlock { got: 0 } discarding all 512 payload bytes, xmit_datablock failed, and FatFs never persisted the file's directory entry or FAT — so the G-code file could not be re-opened and no test could start. It was intermittent precisely because it depended on a print landing inside that window.

Fixed by tracking dir/out per cog and OR-ing them at the pad, with the cog index threaded through PinBus::dir_out_changed. Regression tests in p2core/tests/per_cog_dir.rs cover the console-versus-SD case directly, that a pin is released only once every cog has released it, and that OUT is OR-ed the same way. After the fix an isolated run completes the whole sequence for the first time: G-code written, closed, re-opened for read, sample file opened, moves processed, test started.

Note the class of bug: any peripheral sharing a DIR bank with a pin another cog drives was exposed to this. The SD was simply the first to notice, because its transmit path treats DIR as a reset.

G10 — with the SD actually working, the engine is the bottleneck

A profile taken during SD traffic inverts the idle picture completely:

idle during SD traffic
p2core interpretation ~61% 83 samples
net engine (EngineCore::run) ~7% 3727 samples

The hot spots are per-edge overhead — the wake heap, mutex traffic, publish_levels, and malloc/free from PinHandle churn — not CPU emulation. publish_levels has since been made allocation-free (fixed arrays instead of HashMaps, and the component facade is no longer cloned per call), which removed the malloc traffic but did not move the throughput needle: the cost is in the engine's per-edge resolve, at roughly 24 engine events per SD byte.

This corrects the earlier answer in G3. The claim that a transaction-level SPI/I2C bus "would trim 7%, not the interpreter" was measured with the SD idle, when the interpreter genuinely dominated. Now that writes work, an SD-heavy phase drops the ISS to ~0.00-0.01x and the engine dominates. For the SD path specifically a transaction-level hand-off is the right lever after all — which is what the fidelity-first design (a shared edge-to-transaction decoder plus batched drive sequences, with the capability-gated word hand-off held in reserve) was scoped to deliver.

Still open — SD throughput

The suite is unchanged at 8/20, and the binding constraint has moved: the SD is now correct but slow enough that the app's 15 s waits expire during a test run. Speed alternates between 0.10-0.11x when idle and 0.00x whenever the SD is busy. The remaining work is the throughput programme above, not another correctness hunt.

G11 — the boot off the flash, and every pad on a net — measured

The two costs embsim's NODES.md §13 review item 10 left open (embsim MIGRATING-MAD.md step 3), measured by p2iss/examples/iss_speed.rs with P2IssCore in the P2-EC32MB's package (B3) against P2Iss on no board. Every mode runs stepped, so the run's host time is its cost. Release build, Apple M2 (8 CPUs) shared with other work, 2026-10-19, two runs each. The host was loaded (its load average is printed with each run), which inflates the wall figures by up to three times; the CPU figures (user and system, every thread) are the comparable ones:

cd SIL && make p2image && cargo build --release -p p2iss --examples
./target/release/examples/iss_speed ec32mb 2   # P2IssCore, the module, its flash
./target/release/examples/iss_speed            # P2Iss, the image in hub RAM, the link's 2 pins on nets
./target/release/examples/iss_speed levels     # the same with 14 GPIO pins on nets as levels
Route Phase Board time Host s per board s, wall (CPU) Load average
ec32mb boot: START to the firmware's first line 1.247 s 32.4 (10.7); 21.1 (10.6) 28.3; 23.4
ec32mb running, every pad on a net 2.0 s 23.7 (11.0); 19.3 (11.1) 28.3; 23.4
bare running, the link on nets 2.0 s 39.5 (10.5); 21.5 (10.1) 32.3; 27.0
levels running, the link and 14 pins on nets 2.0 s 27.2 (10.4); 11.2 (9.3) 34.4; 24.8
  • The boot off the flash is 1.247 s of board time from START (5.5 ms after the fingers are powered), all but 0.3 ms of it the ROM and stage-1 on the 20 MHz RC oscillator: stage-1 bit-bangs the 324 512-byte program, about 5.2 million pad changes, each a stop of the guest, a wake at the change's own instant and one more after the net has resolved (about 2.5 µs of host CPU each). In the dev profile cargo test builds, ec32mb_flash_boot.rs took 165 s on CI's runner and 264–596 s here (a host at a load of 20–100), both measured before the stamping below.
  • What stamping each change at its own instant costs (embsim DESIGN.md rule 3), against the same core stamping changes at the wake's instant, alternating runs on the same host at a load of 23–31: the boot 9.3 and 8.9 CPU s per board s before, 10.7 and 10.6 after, about 17 % more; running 10.0 and 9.9 before, 11.0 and 11.1 after, about 11 % more. The second wake per change is most of it; publishing only the pads a write moved took the boot's overhead from 31 % to 17 %.
  • Every pad on a net costs about what today's routes cost: 11.0–11.1 CPU s per board s against 9.3–10.5, the spread between runs nearly as large as the difference. All 64 pads read their nets; only the pads the guest changes are published.
  • A fast load is not called for by these numbers. The boot is a fixed 1.25 board s per board, at the same host cost per board second as the firmware running, so it weighs only where boards are started often (the e2e's board per scenario: about 13 s of host CPU each, release). Its host cost is per pad change, so what would shrink it is fewer clock edges — a stage-1 that reads the flash two bits a clock, as Parallax's own loader does with Fast Read Dual Output (p2iss/rom/stage1.spin2) — not a faster guest clock, and not a load that skips the chain.

The project files: the host on J4, the firmware in the flash

Since 2026-11-01 there is one firmware build, MaD (pio run -e MaD, make p2image), and its MAIN channel is always on P30/P32. The flash-boot project files (SIL/mad.toml, mad-cosim.toml, mad-playground.toml, mad-hard-stop-cosim.toml) put the host on the carrier's control port J4, through IC2 to P30/P32 (USB 0403:6001), with that image in the module's flash (make flash-image). The serial-boot files (mad-serial-boot*.toml) keep the host on the debug header J1 (P62, P63, GND; USB 0403:6015), where the machine's Prop Plug sits, to flash. P62 carries the debug log. make p2image-production and MAD_P2_PRODUCTION_IMAGE are gone.

What follows records the route before that change (2026-10-21): the host on J1 and the module's flash holding the single-port production build (pio run -e P2Eval), whose MAIN channel received on P63 and transmitted on P62 at 2,000,000 baud, with the FlexC console's own smart pins (HAL_SERIAL_TYPE_BUILTIN). The debug build (MaDEdge) had its MAIN channel on P30/P32, and the project files left J4 empty (board design). The measurements below are of those images.

mad-catalog/tests/mad.rs boots the production image in P2IssCore off the module's flash and sends ProtoEmb frames on J1: the firmware answers the version read and the state read on P62, at 2,000,000 baud by the rate the core derives, and acknowledges a request to enable motion. Two things differ from the debug image there:

  • the production image still prints Starting MaD Board on P62 (main.c) at the FlexC console's default rate (230,400 baud, inferred) before its serial core sets the pin to 2,000,000, so a host at the protocol's rate reads that line as framing errors (27 of them, measured), and the core's report keeps P62's bytes as the console: it says the boot line, then the protocol's bytes as text (measured);
  • a request that lands before the serial core has armed its receive interrupt would go unanswered (inferred), so the case asks again every 20 ms of board time. In the runs here the first request, sent at 1.226 s of board time, was answered at 1.235 s (measured).

The machine's faults on the project files

Read off the ISS's hub RAM (dev_cogManager's channel table and app_control's fault flags, at the addresses the build's listing gives) every 20 ms of board time, both images in mad.toml (measured, 2026-10-21):

  • A core fault while the channels come up. The cog manager moves a channel one state per pass of the main cog's 100 ms loop (Main/MaD.c): the first pass launches its cog, the second sees the launch and reports the channel running (dev_cogManager.c), and the firmware reports a core fault until all seven have (app_control.c, processFaults). The production image's first state read, at 1.244 s, 65 ms after its boot line, falls in that window and answers the core fault; by 1.308 s every channel is running, its lock allocated (ids 2 to 8) and its stack canaries intact, and the core fault is gone. It is the boot's own window, on both images, and not a fault that stands.
  • The emergency-stop circuit's power. With nothing on the carrier's J17 (GPI_3), every read after that window answered ESD_POWER, to 3.1 s of board time, on both images. P22 reads J17's loop through IC12 and U8 with R8's pull-up to VIO_16_23 (read: mad_edge.net): with no current in the loop it rests high, and the firmware reads P22 high as the emergency-stop circuit without power (HAL_GPIO_config.c, active high), which faults the machine and refuses motion. The retired mad-emulator held P22 low with a bench pull (SIL/MaDSim/src/iss_description.rs at 4bf392614, IDLE_PULLS), which is why motion enabled there. The project files close J17's loop from the carrier's 5V_IO to GND_IO, as the switch loops take them, standing for a powered circuit (inferred); with it the machine reads unfaulted from 1.314 s and enabling motion is acknowledged (measured). What the machine has on J17 is not confirmed: with nothing there, the firmware on the machine would fault the same way (inferred from the netlist and the firmware).

The core fault is first in the firmware's order and the reported reason is the first fault set, so during the window it also hides the power fault (app_control_private_processFaults). A case that reads the same command twice takes only the answer to its own request: the first answer stays in what the host received, and a parser started at an earlier request finds it again (mad.rs, HostPort::answer).

The p2core/p2iss suites and mad-catalog/tests/boot.rs kept the debug image (now the one MaD image): they put the host on the bare chip's P30/P32, or read the log on P62. boot.rs's console case is ec32mb_flash_boot.rs's boot said by a project file. It passes with the production image too (measured, 2026-10-21): no request reaches the firmware there, and the summary keeps P62 at the console's rate.

The host: Chrome on the board's clock, or a PTY

The ISS interprets every instruction, so a run's virtual time falls far behind the wall clock (a few percent of real time with the app attached, a fraction of that while the firmware works its SD card), and MaD Control's 2 s protocol budget is counted on the browser's clock. A browser on the host's own clock, behind the emulator's PTY, times out a live board: worthless for asserting, and misleading for looking.

So the browser runs on the board's clock. A project file puts embsim's chrome-cdp node where the PTY host would sit, on the host's pins with its rail wired. The node launches the host's Chrome at the board's first slice and holds every page and dedicated worker to the board's time over DevTools, a 1 ms quantum at a time; every page's navigator.serial is its shim, whose one port is this line. It is built through embsim's catalog (embsim_cdp::catalog, the chrome-cdp kind), and a failure it reports (a grant that sticks, a page that crashes, a Chrome that exits) stops the run. The run prints Chrome's DevTools URL in its "reached" line; a suite attaches there (SIL testing). Until MaD #298 retired it, mad-emulator --chrome put the same node on the bare chip's P30/P32.

The e2e runs on the machine's project (step 10): make e2e-emulator is embsim run mad-cosim.toml, whose HOST is that kind on the carrier's control port J4 (USB 0403:6001, DevTools on 9222, the port granted to every origin; on J1, USB 0403:6015, with the production image, until 2026-11-01), the module booting the firmware off its flash. Measured on an Apple M2, 2026-10-21 (on J1), one board per scenario (BOARD_PER_SCENARIO=1): the 23 scenarios that run there (14 on a board, and 9 that need none) passed in 753 s of host time, boards included, the per-PR ten in 512 s, and BUNDLE-live on the bundle in 36 s; each board boots the firmware off the flash first, about 1.3 s of board time in 24 s of host, and the board then ran at about 0.1x with the app attached. On J1 the production image's boot line went out on P62 at the console's rate, which the node's receiver at 2,000,000 baud read as framing errors (27 in a run, measured); on J4 the host sees only the protocol. The scenarios that move the carriage run on the same file (make e2e-motion), per PR G2+G3+H2+I and nightly the whole set: since the 2026-11-01 re-export of mad_edge.net (Edge v0.3), IC14's drive-side grounds are on EN_GND (MaD #299) and the carriage moves. The suite skips SERVO-stall there, since nothing stops the carriage (run-all.mjs, PROJECT_CANNOT), and runs it on that file with a hard stop 3 mm above the carriage's start, mad-hard-stop-cosim.toml (make e2e-stall, per PR).

Target Host
make playground, playground-iss mad-playground.toml (mad-cosim.toml, headed): Chrome, a window on the app, on J4 (USB 0403:6001), DevTools on 9222
make playground-rom mad-serial-boot-playground.toml: Chrome, a window on the app's flasher, the ROM's serial boot on J1 (USB 0403:6015)
make e2e-emulator, make e2e, make e2e-bundle mad-cosim.toml: Chrome, headless, on J4 (USB 0403:6001), DevTools on 9222
make e2e-rom mad-serial-boot-cosim.toml: Chrome, headless, on J1 (USB 0403:6015), the mask ROM's serial boot, for FW-ISS
make e2e-motion mad-cosim.toml: Chrome, headless, on J4, DevTools on 9222, for the scenarios that move the carriage
make e2e-stall mad-hard-stop-cosim.toml (a hard stop on the carriage): Chrome, headless, on J4, DevTools on 9222, for SERVO-stall alone
make playground-pty mad.toml: a PTY on J4 at /tmp/tty.iss, for a serial console
embsim run mad.toml --pty PATH mad.toml: a PTY on J4 (mad-catalog/tests/pty_protocol.rs)