Design: Netlist-grounded board simulation for SIL¶
Status: design accepted (2026-07-11, revised after adversarial review); Phase 0 implemented
Scope: MaD-side view — system architecture, decision record, integration and phasing. The generic engine design lives upstream in SIL/embsim/BOARD_ENGINE.md.
Motivation¶
Today the SIL machine is hand-assembled in SIL/MaDSim/src/wiring.rs: Rust model
objects soldered together with callbacks and file descriptors. That wiring encodes
what we meant the hardware to be, not what the PCBs are — so an entire class of
real-world failures is invisible to simulation by construction.
The July 2026 force-gauge bring-up made that cost concrete. Every blocking bug was found on the bench with a multimeter and a logic analyzer; the table below shows what a netlist-grounded simulation reproduces, with the specific engine mechanism that fires (each row was adversarially traced through the engine spec):
| Real bring-up bug | Hand-wired SIL | Netlist-grounded SIL — mechanism |
|---|---|---|
ADS122U04 ~RESET floating (net has exactly one pin) → chip never responds |
invisible | caught: build-time resolution pass reports FloatingSense to the chip model, whose datasheet behavior is silence |
| AVDD unpowered (analog domain isolated on PCB) → POR never releases | invisible | caught: VDDA net has no PowerOut source → PowerNetUnsourced; model's PowerIn sees domain down |
| TX/RX crossed in the harness | invisible | caught: stream routing finds producer-facing-producer through the collapsed 47 Ω series Rs → StreamMismatch (+ Contention on the fought net; mid-rail voltage available from the Thevenin solve) |
| JP1/JP2 solder jumpers open → ADC inputs floating, wild readings | invisible | caught: source-free analog cluster solves to Floating; ADC model applies its declared floating-input noise policy (deterministic signature the regression asserts) |
| PGA common-mode range at gain 128 | invisible | caught: load-cell transducer contributes parameterized bridge-leg resistors to the cluster; MNA yields absolute AIN voltages; range check lives in the ADC model, reading gain/mux from its own firmware-written registers |
| UART auto-stream byte rotation | found on bench; protocol model then extended (RDATA/manual mode) and now regression-covers the fix | same — the byte-drop trigger is out of scope at byte-pipe fidelity in both columns; stream_drop fault injection exercises loss-handling paths |
The bug class that burns us is connectivity and power-domain truth — exactly what the KiCad schematics already encode.
Decision record: SPICE vs netlist + chip models¶
Decision: netlist-structural simulation with behavioral chip models and a lightweight quasi-static analog solver. No SPICE engine in the core.
The deciding observations:
- The dichotomy is partly false. Vendors do not publish SPICE models for digital-heavy mixed-signal parts (there is no SPICE model of the ADS122U04's UART state machine). Behavioral Rust chip models must be written either way; SPICE could only ever cover the passives.
- Our passives don't need SPICE. The analog content of these boards is a Wheatstone bridge (resistor algebra — the bridge itself is the off-board load cell, contributed to the solver as parameterized legs), RC filters (closed-form single-pole), dividers and pull-ups (algebra). Quasi-static nodal analysis recomputed on input change answers every question these circuits pose, deterministically, orders of magnitude faster than transient SPICE.
- SIL runs near-real-time. Playwright drives a real UI build (currently the
frozen legacy Electron app; the shipped WASM app has its own emulator harness
in
Software/MaDWasmControl/e2e/) over a PTY while firmware makes ~1 kHz ADC round-trips against the virtual clock. Lockstep SPICE transient across seconds of virtual time is a 10–1000× wall-clock cost with no additional bug-catching power for this hardware. - Escape hatch preserved at zero cost. The analog cluster solver sits behind a trait; a SPICE backend is a future additional implementation, not a rewrite. We revisit only if a bench bug appears that quasi-static + closed-form-RC math cannot express. Until then, no ngspice dependency, no cluster-marking syntax.
Architecture: component-centric SIL¶
The architectural inversion: the firmware is not the center of the simulation. The P2 is one component among peers. A simulated system is:
System
├── Board "EdgeBoard" netlist → parts: P2Component(firmware.a), inverters,
│ isolators, transceivers, regulators, LEDs, connectors…
├── Board "DS2Addon" netlist → parts: ADS122U04, series Rs, decoupling Cs,
│ solder jumpers, connectors (the bridge itself is the
│ off-board load cell, modeled as a transducer plant)
├── Harness "machine" EdgeBoard J? ↔ DS2Addon J1, pin-to-pin (or "bench")
└── Plants gantry, sample, load-cell physics — attached at
transducer components (the only part a netlist
cannot express)
wiring.rs shrinks to: the system description (which boards, which harness,
which scenario overrides) plus the physics plant models.
The P2 as a component¶
The P2 component's boundary is its pins; its internals are:
- The firmware image —
libfirmware.abuilt bypio run -e native_emulator, entered viamad_begin()on a component-owned thread. - The silicon peripherals — embsim's serial/GPIO/pulse/encoder/i2c
emulations, today process-global channel tables, becoming fields of the P2
component instance (they live inside the silicon on the real part). This
dissolves the emulator-side half of the single-instance constraint; the
firmware's own C statics still limit one P2-running-a-given-image per
process. The trampoline-routing consequences are an embsim
CONTRACT.mdrevision — see the engine doc. - The pin facade — the HAL channel → physical pin map, read from the firmware's own compiled HAL config tables (see Phase 0), so the component's pinout is derived from the same data that runs on hardware. A pin-config vs PCB mismatch becomes a visible SIL failure, exactly as it would be on the bench.
Behavioral granularity stays at the HAL level (a serial channel is a byte pipe attached to pins — with routing through the board's series resistors derived from the netlist — not a bit-timed shift register). If a bug class ever demands bit-level timing, that is a peripheral-internal upgrade behind the same pin interface.
The P2 Edge Module gap¶
The EdgeBoard netlist contains the socket (P2_EDGE_MODULE_SOCKET), not the
P2 Edge Module that plugs into it — the module is a separate Parallax product
(its own PCB: P2 chip, boot flash, LDOs, pull-ups, and on the EC32MB variant,
PSRAM). The baseline plan folds the module into the P2 component (chip pin ≡
edge finger, module internals behavioral). That assumption is known to hide
real failure classes we have already hit: the boot-flash/SD bus sharing on
P58–61 is module-internal wiring (it cost us the silent flash-boot failure in
bring-up), and the EC32MB variant keeps P40–P57 for its PSRAM and brings none
of them to its card edge, so the RPI serial pins (P53/P55) the debug build used
until the Edge v0.3 pin move (below) had no path to the chip. Module choice is a pin-validity question the
simulation should enforce.
Parallax publishes the module schematics (PDF for both variants; their EDA source is DipTrace, not KiCad). Upgrade path, in increasing fidelity:
- Pin-map + reservation table (cheap, phase 3): the module as a behavioral component with an authoritative finger↔chip-pin map and per-variant reservation list transcribed from the product guide. Catches the reserved-pin and shared-bus conflict class.
- Module-as-board (full): transcribe the module schematic (~a few dozen parts) into a committed netlist, diff-checked against the published PDF revision; the P2 chip becomes the true MCU component boundary, flash/LDOs/ pull-ups become registry parts, and socket↔module mating is a generated 1:1 80-finger harness. This makes flash-bus contention and module power domains simulable truths.
Sourcing for (2), decided: export a netlist from Parallax's DipTrace sources
once (DipTrace free tier + a small importer for its netlist dialect). Vendor
module netlists are one-time committed artifacts pinned to the module revision —
exempt from the CI-regeneration rule, which applies to boards we author in
Hardware/. Re-export only if the machine's module is ever revised.
The machine uses the EC32MB variant, and auditing the EdgeBoard netlist
against its reservation table (P40–P57 = PSRAM) already produced a real
finding before any engine exists: the pre-v0.3 EdgeBoard netlist wired reserved
pins — isolator IC2's outputs drove P52/P53 and its inputs took P54/P55 (the RPI serial path,
matching HW_PIN_RPI_RX/TX = 53/55 as they then were), and header J24 fanned
out P40–P47. None of
these lines reached the chip: the EC32MB has no card-edge finger for P40–P57
(fingers 55, 56, 59–67 and 69–77 are no-connects; read: the header of embsim's
boards/netlists/p2_ec32mb.net, transcribed from Parallax's Rev B schematic).
So a Pi on J4 could not talk to the firmware, and the debug build's MAIN channel
on P53/P55 ran on the PSRAM's own pins: a host on J4 received nothing
(measured on #278's first round, before the route below). The phase-3 tier-1
gate is that the reservation table reproduces exactly this finding.
The route, decided (2026-10-21). The user's decision: the app reaches the
firmware over the carrier's debug header J1 (P62/P63, RESn on J1.3), with the
production build (pio env P2Eval), through a Prop Plug. The app
flashes over J1 (Software/Control/README.md, "Firmware flashing", which
covers flashing only). That build's MAIN channel receives on P63 and
transmits on P62 at 2,000,000 baud (HW_pins.h, HAL_serial_config.c),
and J1's P62/P63 go straight to the
socket's fingers 50 and 49 (mad_edge.net). SIL/mad.toml and the other
project files put the host there, the production image in the module's flash, and
nothing on J4; SIL/mad-catalog/tests/mad.rs holds the protocol round trip
over J1. The debug build stays where a run needs its log on P62 or its MAIN
channel on a bare chip's P30/P32 (P53/P55 before the move below; the
p2core/p2iss suites, mad-catalog/tests/boot.rs).
Two other ways out were not taken then: a plain P2-EC module in J3 (the
socket symbol names it; embsim has no p2-ec kind or netlist), and moving
the debug build's MAIN channel, which needs a carrier change too, since J4's
RPI_RX/RPI_TX reach only IC2.
The debug build's MAIN moved (2026-10-25, MaD #309). The Edge carrier
v0.3 puts IC2's carrier side on P29–P32, pins the EC32MB brings to fingers
11–8 (read: p2_ec32mb.net), and the debug build's MAIN channel follows:
P30 receives from IC2's OUTC (pin 5) and P32 drives its INA (pin 3) (read:
the v0.3 netlist exported from Hardware/EdgeBoard/KiCad; HW_pins.h).
The p2core/p2iss suites put the host on P30/P32. The
SIL's mad_edge.net was re-exported from the v0.3 files on 2026-11-01
(KiCad 9's kicad-cli), so the project files' board has IC2 on P29–P32 as
well; what this section finds about J4 above held for the carrier before
v0.3. The production build's MAIN is unchanged, on P62/P63.
Was open: which image the machine runs (resolved below). The app as shipped flashes a
release's MaDEdge image by default, the MaDEdge build
(Software/Control/src/firmware/release.ts, pickFirmwareAsset; the release
job in .github/workflows/ci.yml), whose protocol is on P53/P55 in every
release so far, which the EC32MB cannot reach (above; P30/P32 since the move
above); the single-port production build is the
release's -P2Eval.bin, which the app flashes only as a local file. The
bench has an FT232R attached, USB 0403:6001, beside the P2-EVAL's FT231X
(measured: ioreg, 2026-10-21; what the FT232R is wired to is not known);
0403:6001 is the id the retired mad-emulator gave its host. If the machine runs the
MaDEdge image, the route needs revisiting: a released image's protocol reaches
the chip only on a module with fingers for P53/P55, such as the plain P2-EC
(inferred), and one built since the move reaches it on a v0.3 carrier's J4. If it runs the production image, the app's default asset would
need changing to match.
One firmware, two ports (2026-11-01). Superseding the two routes above:
the P2Eval and MaDEdge envs are gone, replaced by one, MaD
(pio run -e MaD; make p2image). Its MAIN channel is always on P30/P32 at
2,000,000 baud, and P62/P63 (J1) is for programming and the debug log only,
because opening J1 toggled DTR and rebooted the machine on every connect. Each
release carries the one image, MaD-Firmware-<version>.bin, which the app
flashes over J1 and then talks to over the control port. The flash-boot
project files (SIL/mad.toml, mad-cosim.toml, mad-playground.toml,
mad-hard-stop-cosim.toml) put the host on J4, through IC2 to P30/P32 (USB
0403:6001); the serial-boot files (mad-serial-boot*.toml) keep it on J1
(0403:6015) to flash. make p2image-production and
MAD_P2_PRODUCTION_IMAGE are gone. The real machine moves over when its v0.3
carrier is built and stays on firmware 0.14 until then; the P2-EVAL bench
moves its FTDI adapter (A5069RR4) from P53/P55 to P30 (FTDI TX) and P32 (FTDI
RX).
IC14, the servo isolator. IC14 (STEP, DIR, ENA out to the drive, SRDY
back) had its secondary ground pins 9 and 15 on an unnamed net with only C26
pin 2 on it, not on EN_GND (read: the pre-v0.3 mad_edge.net,
Net-(IC14-GND2_1); MaD #285): nothing IC14 drove reached the drive, and
its output to P5 gave its default high whatever SRDY said. The drawing now
grounds them on EN_GND (MaD #299), and the SIL netlist follows it since its
2026-11-01 re-export: at rest SC_ENA reads low and P5 follows the drive's
ready line, and the firmware jogs the carriage (SIL/mad-catalog/tests/mad.rs).
The project files model the current drawing, not the boards as built (the
user's decision, 2026-11-01). IC14 is drawn as ISO7741FDWR; its symbol's
part name places embsim's ISO6741DWR model (same pinout).
Part classification (no separate "type file")¶
Every KiCad netlist component carries a libsource (lib + part). The part
name reliably encodes function class; the lib name is best-effort (real
exports from our boards contain empty lib names and *-rescue libs), so
classification keys on the part name with rescue-mangling normalized. Three
tiers (full rules in the engine doc):
- Auto-assigned:
R*/C*/L*/LED/D_*passives (values parsed:47R,0.1uF,4k7; two-terminal classes are pin-count-validated),Conn*boundaries,Jumper*stateful shorts (default from the name:_NO/_Openopen,_NCclosed — our boards useJumper_NO_Smallvia a rescue lib andJumper_2_Open/Jumper_3_Open), mechanical ignored. - Registry lookup for everything else — active parts come from custom
and standard libraries alike. Measured on the real netlists: the DS2Addon
needs one registry entry (
ADS122U04→ the existing protocol model, given rescue-lib-tolerant jumper matching); the EdgeBoard (168 components) needs ~17 part types, several as explicit stubs: theSN74LVC1G14Schmitt inverter, seven isolator/optocoupler types (VO2631,6N137,ISO6721B,ISO6731,ISO6740F,ISO6741,ISO6742), theAM26LS31/AM26LV32RS-422 transceivers,XL1509/UCC12040regulators as power-domain sources, theNSI50010LED driver,APM4953and2N3904discretes,SW_Push, andP2_EDGE_MODULE_SOCKET. Most are one-line behaviors or stubs; the socket is the load-bearing one. - Hard error on unknown parts. A missing model fails system construction, with an explicit per-board stub list for genuinely don't-care parts. Never silently ignore.
DNP policy: this repo marks do-not-populate parts with value = "X"; the
engine treats those (and KiCad's native dnp attribute, when present) as
absent. Solder jumpers are present but stateful, overridable per scenario.
Harness files — the only hand-maintained wiring¶
Three links connect firmware to a peripheral chip; two are derived:
| Link | Source of truth |
|---|---|
| HAL channel → P2 pin | firmware HAL config tables (Phase 0), read from libfirmware.a |
| P2 pin → board net | EdgeBoard netlist (P2_EDGE_MODULE_SOCKET pin map) |
| board ↔ board | harness file — no schematic describes the cable loom |
The harness file is small data (TOML), one per physical setup. Bench-rig endpoints that source power declare it, which is how a strap makes a power domain live:
# harness/bench.toml — P2-EVAL bring-up rig
[[connect]]
from = "P2EVAL.P0" # bare MCU-pin endpoints allowed for bench rigs
to = "DS2Addon.J1.4" # silk "TX"
[[connect]]
from = "P2EVAL.3V3"
kind = "power(3.3V)" # sources the domain — this is the AVDD strap
to = "DS2Addon.J2.1"
# …
Harness errors are a real-world failure mode (the crossed TX/RX cost a bench
day); deliberately broken harnesses are checked in as regression fixtures, with
the engine's StreamMismatch/Contention/FloatingSense findings as the
assertion targets. Grounds are connected explicitly — the engine never merges
same-named nets across boards.
Phasing¶
Phase 0 — HAL config/implementation split (firmware-only, standalone value)
Extract the per-channel config tables (HAL_serial_channelConfig, GPIO,
encoder, pulse-out) from src/HAL/P2/*.c into data-only translation units
(src/HAL/Config/*.c — no HAL function definitions, no P2 intrinsics, so they
compile natively without colliding with the emulator's Rust HAL trampolines),
with external linkage, unique HAL_-prefixed names, and
__attribute__((used)); struct definitions to HAL/Include; the units added
to the base build_src_filter so both targets compile them. The P2
implementations consume them unchanged. The emulator reads the table values
from libfirmware.a itself (symbol + DWARF layout over the archive's data
sections — the linker never extracts data-only archive members into the
process image, so the archive is the readable source of truth), with a
build-time check that the table symbols are present in the archive. Side effect: the
emulator's default pacing comes from the firmware's own table instead of an
independent guess — MAD_SIM_BAUD remains as an explicit test override (the
low-baud session-regression test and instant-TX playground mode depend on
overriding the firmware's 2 Mbaud).
Gate: both P2 and native targets build; SIL boots and the force path runs;
CI table-presence check green.
Phase 1 — embsim-board + DS2Addon pilot
Netlist parser (version-gated), Component trait, single-writer net engine
with Thevenin drive resolution, cluster MNA v1, stream routing, part registry;
minimal two-node system: P2 component + DS2Addon board + bench harness.
Gate: the force path works as today (happy-path Playwright equivalence),
and three bench-bug scenarios reproduce as SIL regression tests:
floating-~RESET (via pin_detach on the pull-up once the board rev adds R10,
or stock netlist for pre-R10 boards), AVDD-unstrapped (harness without the
power connect), and crossed TX/RX (swapped harness fixture).
Phase 2 — net semantics hardening + peripheral ownership
Power-domain volts everywhere, contention/ambiguous-level diagnostics
completeness, jumper/DNP scenario overrides, fault algebra
(pin_detach/pin_short/net_stuck/value_override/stream_drop); embsim
peripherals move from process globals into the P2 component instance
(CONTRACT.md revision upstream).
Phase 3 — EdgeBoard + machine harness
Hierarchical-sheet netlist ingestion, the ~17-entry EdgeBoard registry (mostly
stubs), machine harness file; wiring.rs reduced to system description + plant
models. CI regenerates netlists from Hardware/ (committed, diff-checked) so
simulation cannot drift from the schematics.
Testing strategy¶
- Engine unit tests (upstream, embsim): netlist parsing fixtures per KiCad major, net-state truth tables including impedance-escalation boundaries, MNA hand-checks (bridge, dividers, source-free singular clusters), stream-routing cases.
- Scenario regression tests (MaD): each real bring-up bug becomes a named
scenario asserting the specific engine finding and the model-level failure
signature (silent ADC, POR-held chip,
StreamMismatch). - Equivalence gate (migration safety): phase 1 must keep force-path behavior equivalent to the current hand-wired path for the happy-path Playwright suite.
Upstream split (embsim is a standalone repo)¶
| Lands upstream (embsim) | Lands in MaD |
|---|---|
embsim-board crate: netlist parser, Component trait, net engine, cluster solver, stream routing, registry + fault-algebra mechanisms |
Part registry entries (ADS122U04 → model, EdgeBoard actives/stubs) |
| Auto-classification of standard part classes | Board netlist exports (Hardware/ → committed artifacts) |
| MCU-as-component pattern + CONTRACT.md revision | Harness files (bench, machine, broken-fixture variants) |
| Peripheral de-globalization | Plant models + system description (wiring.rs successor) |
| SymbolResolver value-read path in memory-inspect | HAL/Config data-only TUs (Phase 0, firmware) |
Per repo policy: engine changes are committed and pushed upstream first (embsim CI gates), then MaD bumps the submodule pin.
Decisions (were open questions)¶
- Netlist artifacts: regenerate in CI + diff-check against committed
exports for boards authored in
Hardware/— schematic changes appear as reviewable netlist diffs in PRs. Vendor netlists (P2 Edge Module) are one-time committed artifacts pinned to a module revision. - Fault injection is test-only — no trace-viewer surface initially.
- (still open) Bench-rig boards (P2-EVAL): trivial board definition vs raw MCU-pin harness endpoints — the design allows both; pick when phase 1 lands.
- EdgeBoard actives get real behavioral models only where they add bug-catching value at negligible runtime cost; the default is a gated-wire stub. Latency rule of thumb: nothing on a net-resolution path may block or add per-event work beyond O(cluster) — a model that needs its own thread or timers must justify itself with a concrete failure class it catches.
- The machine uses the P2 Edge Module EC32MB. Consequences: the reservation table (P40–P57) is mandatory in phase 3 tier 1 and already surfaces a real EdgeBoard conflict (RPI serial via IC2 + J24 on reserved pins — see "The P2 Edge Module gap"); the module netlist comes from a one-time DipTrace export.