Extends the over-voltage rule to the two cases the previous slice deferred:
- Boards (ESP32 / Pico / Arduino / ...): a board's supply pins all collapse to
the self-driven vcc_rail net, so an external source on them makes the .op
singular rather than readable. Added a graph-based check (runs before the
solve): if a power source is wired to a board supply pin and its nominal
voltage exceeds that pin's rating, warn. Threaded boardKind into
BoardForSpice so the verifier can look up the board rating.
- Electrolytic capacitors: new `voltage` rating property (select, default 25V,
on capacitor-electrolytic + cap-elec-* presets, via component-overrides +
regenerated metadata). The verifier reads the DC voltage across the +/- pins
and warns on over-voltage (vent/burst) and on reverse polarity (a polarized
cap wired backwards). Defaults to 25V when the property is unset.
Tests: 9V battery -> ESP32 VIN warns, 1.5V doesn't; 24V across a 16V cap warns,
5V across a 25V cap doesn't; reverse-biased cap warns. All real-ngspice.
Adds a non-blocking circuit-verifier rule: a component whose supply pin sees
more than its datasheet absolute-maximum voltage warns ("X V on the VIN pin --
above its Y V maximum; not emulated accurately"). This is the "fed too much
voltage" mistake the operator asked for (a 3.3-5V module wired to a 9V battery).
- New componentRatings.ts: per-PIN abs-max table (SSD1306/ILI9341 displays,
DHT/BMP280/HC-SR04/MPU6050 sensors, NeoPixel, servo). Per-pin thresholds so a
3V3 pin (3.6V) and a VIN pin (6V) are judged separately. Unknown parts are
simply not checked; an unwired or floating supply pin is skipped.
- circuitVerifier reads each rated part's supply-vs-ground voltage from the
solved nets (via pinNetMap) and warns when it exceeds the rating.
- VCC/VDD/3V3/5V pins ride the shared vcc_rail net (NetlistBuilder convention);
VIN is a normal net. Both handled.
- Tests: 9V on a module VIN warns; 5V on VIN does not; a 3.3V pin on a 5V rail
warns.
Boards (esp32/pico/arduino) carry ratings in the table but aren't checked yet
-- BoardForSpice doesn't thread its boardKind; follow-up.
The pre-flight circuit verifier reads branch currents via runNetlist ->
readAllCurrentVectors() (ngSpice_AllVecs enumeration). The production
Web-Worker ngspice WASM build does not surface voltage-source #branch
vectors through that enumeration for an .op plot, so branchCurrents came
back empty and every current rule (short-circuit, LED over-current) read
?? 0 -> no fault. The live solver avoided this by requesting each current
explicitly by name; the Node test build enumerates them, so the gap was
invisible to the suite. Net effect: a 9V battery wired straight to an LED
ran with no warning (reported on project 2840fd12).
- runNetlist: request every V_* source branch current explicitly by name
and merge with the enumeration, so source/LED currents are always present
regardless of the worker WASM's AllVecs behaviour.
- circuitVerifier: non-finite source/LED current -> blocking unstable-solve
fault ("could not solve a stable current - likely a short or a part with
no current limit, e.g. an LED with no series resistor").
- LED runtime (BasicParts): burn out on a non-finite current instead of
falling through to the digital fallback and glowing; raise burnout
threshold 20mA -> 100mA so high-power/RGB channels are not falsely
destroyed; clear the burnt latch on Reset (resetBoard bumps hexEpoch).
Tests: real-data repro, mocked non-finite verifier test, runtime
non-finite / high-power / latch-recovery tests.
AVRSimulator never instantiated avr8js's AVREEPROM peripheral, so any
EEPROM.read/write/update hung the sketch: the Arduino EEPROM library spins
on `while (EECR & (1<<EEPE))` waiting for the write-complete bit to clear,
and with no peripheral driving EECR that bit never cleared (issue #203 —
EEPROM.update(0,123) + EEPROM.read(0) hangs instead of printing 123).
Wire AVREEPROM to the CPU in both loadHex() and reset() via a new
attachEeprom() helper. The EEPROMMemoryBackend is created once per
simulator instance and reused across firmware reloads and resets, so a
value written in one run is still readable on the next boot — matching
real hardware, where re-flashing leaves EEPROM intact. Sizes per variant
(Uno 1024 B, Mega2560 4096 B, ATtiny85 512 B); ATtiny85 gets its own
register map (EECR 0x3C / EEDR 0x3D / EEARL 0x3E / EEARH 0x3F) since
avr8js's default eepromConfig targets the ATmega328P.
Adds eeprom.test.ts: drives the EEPROM register protocol against the
production AVRSimulator (loadHex + step), asserting a byte round-trips,
the EEPE poll terminates (no hang), and contents survive a reset.
The NTC breakout's SPICE topology was inverted relative to the example
sketch's decode formula (rNtc = R_PULL * v / (5 - v)), which assumes a 10k
pull-up from VCC to OUT and the NTC from OUT to GND. The mapper had the NTC
on top (VCC->OUT) and the pull-down on the bottom, so the recovered
temperature ran backwards: dragging the slider to 100C made the sketch
print -25C. Swap the two resistors so V_OUT = 5 * Rntc / (Rntc + Rpull),
matching the sketch and the hand-built reference netlist in
spice-avr-mixed.test.ts (T=0 -> ADC 789, T=25 -> 511, T=50 -> 270).
Also replace the SensorParts linear approximation (2.5 - (t-25)*0.02) with
the same beta-model divider so the non-SPICE ADC injection decodes back to
the slider value, and drop the dead onInput path that treated the element's
value as a raw ADC count.
Reset now restores interactive sensors (temperature/lux/gas sliders) to
their configured defaults: resetBoard re-dispatches each sensor's default
into the running sim and bumps sensorResetNonce so the open
SensorControlPanel remounts and the slider snaps back. Previously a restart
left the NTC frozen at the last dragged temperature.
Updated the examples netlist snapshot for the swapped NTC cards.
The Pico W WiFi showcase examples are a paid-overlay feature now (the WiFi
engine moved to the overlay). Read them through a build-time `@pro` seam so the
SSR prerender + gallery + sitemap include them when built with the overlay, and
OSS gets an empty stub.
- data/examples.ts: import { proExamples } from '@pro/data/proExamples' (static,
build-time) instead of the local examples-picow-wifi.ts; delete that file.
- src/__pro_stub__/data/proExamples.ts: OSS no-op (empty list) for the @pro alias.
- vitest.config.ts: mirror the @pro alias (stub by default / overlay when
VITE_PRO_BUILD) so tests loading examples.ts resolve it.
- scripts/generate-sitemap.mjs: also parse <PRO_OVERLAY_PATH>/data/proExamples.ts
when building with the overlay (the script reads example IDs from source text,
so it can't follow the alias).
- Tests: drop the picow-wifi import/usage from the 5 OSS example tests (they
validate the OSS set now); prune the 4 obsolete picow netlist snapshots. The
overlay's proExamples get their own coverage in pro/.../__tests__/.
The stored snapshot predated the relay-LED netlist emission (current-sense
V-source + LED diode model), so examples-netlist-snapshot failed on a clean
checkout regardless of any source change. Regenerate it to match the current
NetlistBuilder output. Unblocks the deploy test gate.
Move the CYW43439 (Pico W) WiFi emulation out of the open-source tree so it
can ship as a paid feature in a private overlay. OSS keeps a plain Pico W
(no WiFi); the overlay registers the cyw43 protocol + backend network stack
at runtime via generic seams.
Frontend:
- Add simulation/PioPeripheral.ts: a generic "PIO bus peripheral" seam
(feedWord / inDiscardableWriteData / resetFraming / hostWakeLevel /
onHostWake / onSimulationStart). No factory is installed in OSS, so
createPioPeripheral() returns null and a Pico W simulates as a plain Pico.
- RP2040Simulator: keep the fragile PIO-FIFO plumbing (it must re-run after
loadMicroPython swaps the chip) but drive it through PioPeripheral instead
of an inlined cyw43 import (attachCyw43 -> attachPioPeripheral, etc.).
- useSimulatorStore: generic attach/detach + setBoardWifiStatus; drop the
cyw43 bridge map.
- MicroPythonLoader: add registerFirmwareVariant() so an overlay can add the
RPI_PICO_W build; remove the OSS pico-w config + bundled .uf2.
- Delete simulation/cyw43/ (moved to the overlay).
Backend:
- core/hooks.py: add generic register_ws_sim_handler / dispatch_ws_sim_message
and register_gateway_proxy / dispatch_gateway_proxy seams.
- simulation.py: route start_picow / stop_picow / picow_packet_out through the
ws_sim_handler hook (the overlay handles + gates them).
- iot_gateway.py: resolve the Pico W gateway through the gateway_proxy hook.
- Delete services/picow_net/ + picow_net_bridge.py (moved to the overlay).
Tests: move the cyw43/picow suites to the overlay; update RP2040Simulator
mock stubs to attachPioPeripheral.
The served page loads under /api/gateway/<id>/, so absolute fetches like
fetch('/on') hit velxio.dev/on instead of the chip — the LED/relay/servo
controls did nothing. Use relative paths (fetch('on')) so they resolve
under the gateway. The relay example now has real ON/OFF buttons and
wraps its blocking accept loop in try/except so a dropped browser
connection can't kill it.
e2e now fires two sequential requests (first with a browser-sized header)
against a non-resilient blocking server and asserts both are served.
- SerialMonitor linkifies http://10.13.37.x (the Pico W subnet) the same
way it already does http://192.168.4.x for the ESP32, turning the
sketch's printed URL into an 'Open IoT Gateway' link.
- SimulatorCanvas shows the clickable WiFi badge for the Pico W too
(normalizing its 'started' status, which carries the fixed IP, to
got_ip so it reuses the ESP32 badge styling + launcher).
- async-led and servo-web examples print a clickable http://<ip>/ line
so the gateway link appears (relay-web-server already did).
Gated e2e (CYW43_GATEWAY_E2E=1) drives the real emulator + a running
backend and asserts the served page comes back through /api/gateway.
Wi-Fi sketches on the emulated Pico W associate via the chip's built-in
virtual net (DHCP/ARP answered locally), but outbound traffic had no
route, so DNS/MQTT/HTTP failed with OSError -2.
Wire the emulator's outbound DATA path to the backend picow_net bridge:
- Cyw43Emulator forwards every outbound Ethernet frame EXCEPT DHCP/ARP
(still answered locally) to firePacketOut -> the WS bridge, which NATs
DNS/TCP/UDP to the real internet and injects replies back.
- The virtual net stays ON unconditionally and shares the backend's
subnet, gateway and gateway MAC (10.13.37.0/24, gw 10.13.37.1). Nothing
is mutually exclusive, so an absent or flaky bridge can never break the
Wi-Fi association -- it just falls back to no-internet, as before.
- useSimulatorStore opens the bridge (cyw43.connect()) for Wi-Fi sketches.
Validated end to end against a running backend: WiFi connect + DHCP, DNS
resolves example.com, TCP connect + HTTP GET returns 200 OK. Gated e2e in
picow-bridge-e2e.investigate.test.ts (CYW43_BRIDGE_E2E=1).
The CYW43439 F2 (radio frame) channel is word-oriented: the real chip
always drives frames padded up to a 4-byte boundary and the host reads
that word-aligned length, byte-swapping every 32-bit word on the way in.
encodeSdpcm built buffers of exactly 12 + payload bytes, so any frame
whose total length was not a multiple of 4 ended with a partial word.
The emulator's F2 read path (encodeFrameWords) byte-swaps whole words and
copies the leftover tail raw; the host's symmetric per-word swap then
mangles that final word, corrupting the last 1-3 bytes of the frame.
This was invisible for DHCP/ARP (UDP checksum 0 -> lwIP skips the check,
and the damage lands in trailing option padding) but silently dropped
every DNS answer and TCP segment (real checksum -> lwIP discards the
frame), so getaddrinfo()/connect() retried forever.
Pad the backing buffer to a 4-byte boundary while keeping the size header
at the true length, so the driver still parses exactly the real frame and
ignores the pad. Matches real hardware framing.
Headless test that drives the REAL RP2040Simulator (attachCyw43 +
installCyw43PioHooks + lockstep PIO stepping in runFrameForTime), boots
the Pico W firmware, injects a WiFi-connect snippet over the raw REPL, and
asserts isconnected(). Result:
PYBOOT
ACTIVE False (this fw's active() getter reports link status)
CONN_OK 192.168.4.2 (DHCP-leased IP, isconnected() == True)
MAINPY_DONE
Reaches link-up in ~31s wall — the production lockstep PIO stepping is
faster than the harness's setTimeout-cranked PIO.
Also fixes a real production bug: the RP2040 logger was
ConsoleLogger(LogLevel.Error) which THROWS on rp2040js unaligned-read
warnings — lwIP reads the IPv4 header at ethernet offset 14 on every
received packet, so WiFi would have crashed on the first DHCP reply.
Now constructed with throwOnError=false.
Gated behind CYW43_PROD_HARNESS=1 (boots real firmware, ~30s).
The Pico W now connects end to end with NO backend: status reaches
CYW43_LINK_UP (3) and network.WLAN().isconnected() returns True.
After association the STA's lwIP broadcasts DHCP DISCOVER and ARPs the
gateway over the cyw43 DATA channel. A self-contained virtual network
(new virtualNet.ts) answers them:
- DHCP DISCOVER -> OFFER, REQUEST -> ACK (Ethernet+IPv4+UDP+BOOTP, valid
IPv4 header checksum, UDP checksum 0), leasing 192.168.4.2 with gateway
192.168.4.1.
- ARP who-has the gateway -> is-at the AP MAC.
On by default (Cyw43EmulatorOptions.virtualNet); pass null when an
external packet bridge owns the network.
Also fixes injectPacket to prepend the 4-byte BDC header that chip->host
DATA frames need (same as the event-frame fix), so injected packets parse.
Boot harness now reports: STEP_CONNECT_CALLED status=3 / POLL 0 status 3
conn True / HARNESS_DONE.
Known: receiving packets triggers ~30 rp2040js unaligned-read warnings
(lwIP reads the IPv4 header at ethernet offset 14); non-fatal here, but
the production RP2040Simulator must use a non-throwing logger.
The Pico W now joins the virtual AP end to end: active(True) returns,
connect() runs the full WPA/SET_SSID sequence, and the link comes up.
Root causes fixed (each blocked the join):
- mcast_list GET returned empty, so the driver read its own request bytes
as the address count (ASCII 'mcas' ~1.9e9) and looped ~2e9 times,
hanging wifi_on. GETs now return a zero-filled buffer of the asked-for
length (count 0 / status 0), never empty.
- Async event frames lacked the 4-byte BDC header the driver expects at
SDPCM header_length, so it read the broadcast-MAC byte as data_offset
and the payload pointed out of bounds (WRONG_PAYLOAD_TYPE). Prepend BDC.
- WLC_E_LINK signalled link-up via the reason field, but the driver
checks ev->flags & 1. encodeEventFrame now takes a flags arg; LINK uses
flags=1.
- Join needs WIFI_JOIN_STATE_KEYED, which only a WLC_E_PSK_SUP(status=6)
event sets (connect(ssid, "") still configures the WPA supplicant).
Emit it on a successful join.
- Join events were raised synchronously during the SET_SSID ioctl, so the
driver processed them before cyw43_wifi_join set wifi_join_state=ACTIVE,
wiping the bits. Defer events until just after the ioctl reply.
- Event-mask stored 4 bytes misaligned vs queueEvent's read offset.
- SET/GET kind bit is 0x2 (SDPCM_SET), not 0x1.
Remaining for status UP / isconnected: DHCP (needs the packet-transport
bridge or an emulator-side DHCP responder).
Add PioBusSniffer.inDiscardableWriteData(): true while framing a large
non-F2 write (firmware/backplane bulk write the chip discards). The boot
harness drops those data words (keeping ~4 so the PIO raises TXSTALL,
which is all the driver's write path waits for) instead of bit-banging
the full ~224 KB through the PIO. F2/SDPCM IOCTL writes and every
count/command word are retained in full, so the bring-up still completes
the 23-IOCTL wifi_on sequence (F1 framing 3613 -> 97, F2 unchanged).
Also adds IPSR + PC-histogram sampling: confirmed the post-mcast_list
stall is thread-mode (no GPIO IRQ storm) inside MicroPython's host-side
cyw43_cb_tcpip_init (lwIP), above the chip emulation.
Unblocks the full wifi_on IOCTL sequence in the boot harness (clm_load
through the 23-IOCTL bring-up, no crash):
- Drive WL_HOST_WAKE (GPIO24, active-high): the driver gates poll_device
on this pin until its first packet (had_successful_packet), so without
it the first IOCTL response is never read. Emulator now exposes
onHostWake(level) and toggles it with the inbound-frame queue.
- Encode F2/SDPCM frame reads per 32-bit word (encodeFrameWords), same
as register reads: the DMA-in sets channel bswap=true, so an un-encoded
frame landed byte-reversed -> header_length read back as garbage and
the driver dereferenced ioctl_header at an unaligned address (crash).
Guarded to boot mode pass-through (no F2 traffic there; keeps unit tests).
- Fix SET/GET detection: SDPCM_SET is bit 1 (0x2), not 0x1; echo the
kind bit in IOCTL responses.
- Add IOCTL/SDPCM debug counters + sequence log for the harness.
Harness (investigation, CYW43_HARNESS=1 only): non-dropping TX FIFO so
large F2 writes are not truncated, crank PIO steps/tick so the firmware
drains in wall-clock, GPIO24 host-wake wiring, CPU-fault + PC-histogram
+ PIO-state instrumentation.
Remaining: stall after mcast_list (#22) inside cyw43_cb_tcpip_init.
cyw43_spi_transfer calls pio_sm_restart before each transfer's count words, so
hooking restart() to reset the sniffer makes framing deterministic across the
firmware-stream fast-path (no phantom-transfer carryover). Verified: restarts
fire 3625x (once per transfer), F1 phantom count drops, and the CLM IOCTL write
now frames correctly (cmd decodes to F2, 'clmload' payload). Wired into
RP2040Simulator + the harness.
Remaining (next session): the CLM/IOCTL write doesn't complete its payload and
wifi_on still fails (active()=False) — bus_init stalls at/around clm_load with
only 2 STATUS reads and goes idle. Next: trace the CLM write's DMA/PIO drain and
the SDPCM IOCTL response path. See findings.md F-13.
debugInboundCount + STATUS-read tracking show initInbound=1, statusReads=2,
statusReadsWithPkt=2, finalInbound=1: the credit frame IS visible at both STATUS
reads (not a credit tight-loop). The driver reaches clm_load's F2-ready check
(passes) but the F2 IOCTL write never appears on the bus and bus_init returns.
Next: instrument the F2-write path. See findings.md F-13.
Pins the connect blocker: zero F2 transfers (F0=11 F1=97 F2=0), so the host
never sends an IOCTL — it stalls on SDPCM bus credits in clm_load (STATUS shows
no F2_PACKET_AVAILABLE) and times out, so wifi_on fails and active() stays
False. Next: make the credit-granting frame visible in SPI_STATUS during the
stall. See project/picow-wifi-emulation/findings.md F-13.
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.
Follow-up audit after the ESP32 fix: classifyPin() was run for every board
against the protocol pin labels its element actually exposes. One real gap
remained -- Arduino Mega. Its dedicated SDA/SCL pins are only labelled (not
numbered), so I2C links drawn on them came back 'digital' and never bridged.
Map every Mega function label (TX/RX, TX0-3/RX0-3, SDA/SCL) to its pin number.
Audit result for the rest (added as board-protocols-audit.test.ts):
- Arduino Uno/Nano, Pico/Pico-W, STM32 Blue Pill: already OK.
- ESP32 / ESP32-C3: fixed earlier (esp32-uart-pin-classify).
- Raspberry Pi 3/4/5: OK -- the element labels pins by physical number (1..40)
which normalize to BCM, so no function-label gap exists there.
Wiring two ESP32s TX2->RX2 (Serial2) or TX->RX for board-to-board serial
produced no data on the receiver: classifyPin() returned 'digital' for the
UART pins, so the Interconnect never installed the byte-level UART bridge.
Two causes in boardProtocols.ts normalizePinName:
- TX/RX aliases only matched boardKind === 'esp32' exactly, missing every
variant (esp32-devkit-c-v4, esp32-cam, esp32-s3), and TX2/RX2 were not
handled at all. Resolve them via startsWith('esp32') (esp32-c3 kept
separate) and map TX2/RX2 -> GPIO17/16.
- 'GPIO17'-style labels fell into the 'GP' (RP2040) branch first, where
parseInt('IO17') = NaN swallowed them to null. Exclude 'GPIO' from the
'GP' branch so the ESP32 GPIO-prefix handling runs.
Adds esp32-uart-classify.test.ts (6 cases, green).
Revert the earlier approach of widening the existing PWM-callback assertions to
accept the new timeMs arg — that masked a contract change rather than fixing it.
Instead, updatePwm now hands the optional timeMs only to listeners that declare
a 3rd parameter (cb.length >= 3) — i.e. the buzzer, which needs the precise
onset time. Plain (pin, dutyCycle) listeners, and the existing
toHaveBeenCalledWith(pin, dutyCycle) tests, see an unchanged 2-arg call, so the
original PwmCallback contract is preserved.
Add a PinManager test locking the dispatch: a 2-param listener stays 2-arg; a
3-param listener receives timeMs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The sample-accurate scheduling (06526c7) added an optional 3rd `timeMs`
argument to PwmCallback / updatePwm, which broke 9 existing strict
toHaveBeenCalledWith(pin, duty) assertions (PinManager, AVRSimulator,
mega-emulation, attiny85). Match the real signature: PinManager drives
updatePwm directly with no timeMs (assert `undefined`); the AVR OCR-poll path
computes timeMs = cpu.cycles / 16000 (assert `expect.anything()`).
Leaves one pre-existing red — component-to-spice "custom-chip missing fixture"
— which fails on master too and is unrelated to this PR.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A melody / continuous tone (consecutive tone() with no noTone() between) is
back-to-back nonzero-OCR PWM writes with no note-off, so startTone() overwrote
activeOsc without stopping the previous node — oscillators stacked and were
never stopped (reported: created 6, started 6, never stopped 6).
Add a monophonic guard at the top of startTone(): release the live note
(gain ramp + stop) before starting the new one, so a pitch change REPLACES
rather than STACKS. Extract a shared releaseActive(off) helper (also used by
stopTone). Add two melody tests: one asserts starts === stops (no orphans),
monotonic onsets and per-note pitch; one asserts a melody ending without a
trailing noTone() leaves only the final note ringing (stops === starts - 1).
The metronome path is unaffected (each click is an onset→note-off pair, so the
guard never fires there); the three existing metronome tests stay green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The `custom-chip` SPICE mapper emits its sources from getChipDrivenPins()
(the chip's live driven output pins), so a static pin/property fixture can
never exercise it -- it always returns null. The "every mapped metadataId
has a test fixture" check flagged it as missing a fixture, failing the
suite. Exclude it via a RUNTIME_STATE_MAPPERS set; custom-chip SPICE
behaviour is covered by the chip-bus integration tests.
Pre-existing since 4cb5748 (custom-chip first-class circuit nodes).
Add a working microSD card part backed by a FAT16 image, following the
Wokwi storage model: the project's own workspace files are auto-copied
onto the card (free), and an optional "SD Card" panel uploads extra
files (gated as a paid feature by the velxio.dev overlay; OSS default
allows it).
Frontend (in-browser AVR / RP2040):
- ProtocolParts.ts: rewrite the microsd-card part from a handshake stub
into a real SD-over-SPI device (reply-first Ncr timing, SDSC byte
addressing, single/multi-block read+write, CSD/CID, full CMD set).
- utils/fatImage.ts: dependency-free FAT16 super-floppy builder (8.3 + LFN).
- utils/sdCardFiles.ts: assemble the card image from workspace files plus
uploaded files; base64 helpers.
- components/simulator/SdCardPanel.tsx + ComponentPropertyDialog: upload UI.
- DynamicComponent + useSimulatorStore: build and inject the image on run.
- lib/proSdCardGate.ts: overlay-installable gate for the upload action.
- data/examples-storage-microsd.ts: Arduino Uno + ESP32 gallery examples.
Backend (ESP32 via QEMU):
- services/esp32_sd_slave.py: synchronous SD-over-SPI slave (Python port of
the browser part) with a sparse backing store, idle-state R1 tracking and
real CRC16 on data blocks when the host enables CRC (CMD59) -- both
required by ESP-IDF's sdspi driver.
- esp32_worker.py: route SPI bytes to the slave (returns MISO synchronously)
and feed write-only bulk transfers.
- esp32_lib_manager.py + routes/simulation.py: forward the FAT image
(sd_card.image_b64) from the start config into the worker.
Tested:
- frontend: protocol-parts, fat-image, sd-card-gate and microsd-real-firmware
(real Arduino SD.h on avr8js) -- 86 passing.
- backend: test_esp32_sd_slave (10) covering the ESP-IDF init sequence and
CRC16; validated end to end by running a real SD.h sketch in libqemu-xtensa
(mount, directory listing, read and write-readback).
The RP2040 core (125 MHz Cortex-M0) is ~8x heavier to emulate than the
AVR. The run loop used a FIXED per-frame cycle budget, and arduino-pico
delay() busy-waits the timer (no WFI), so a host that cannot sustain
125M instr/s rendered a 1s blink every 4-5s (sim ran in slow motion).
- Derive the frame budget from the MEASURED wall-clock delta (mirrors
AVRSimulator) instead of assuming a perfect 60fps.
- Add IdleSpinDetector: recognise a side-effect-free busy-wait spin and
advance the clock over it (capped at the next timer alarm / scheduled
pin change) instead of executing every idle cycle - the same idea the
WFI fast-path already uses for sleep(). Conservative: a bit-bang loop,
an input-poll that just saw its pin move, or a loop that calls out are
never elided; a false positive only ever advances time up to the
wall-clock budget, never past the next event.
- Bound WFI sleeps to the wall-clock budget so they advance in real
time across frames rather than leaping ahead.
Cuts emulation work for a delay-bound sketch ~1900x (125M -> ~65k
instructions per simulated second) so it tracks wall-time even on hosts
that cannot emulate 125 MHz in real time. Public API unchanged;
step()/stepCycles() untouched.
Adds rp2040-realtime.test.ts: IdleSpinDetector unit tests plus
end-to-end scheduler tests driving a real rp2040js core through a
hand-assembled busy-wait loop (no firmware fixture needed).
Builds on the previous commit; reworks the buzzer audio for glitch-free,
cross-browser playback and adds a metronome quality suite.
- Per-note oscillators with short attack/release ramps, instead of one
long-lived oscillator gated by gain: a fresh fixed frequency per note and no
gain/frequency automation on a persistent node — Firefox in particular clicks
and glitches the pitch otherwise.
- Schedule onsets by their SIMULATED inter-onset spacing (exact, even) with a
light latency hold, instead of a wall-clock average. Turning a control (BPM,
K…) re-locks immediately and the rhythm stays even — no bursts, no overlaps,
no audio drifting away from the display.
- Place each note-off relative to its own onset, preserving the exact click
length from the simulation (the onset scheduler now tracks onsets only).
- Poll PWM every 256 cycles (was 64): finer than any audible pulse, lighter on
the frame loop.
- New src/__tests__/buzzer-metronome.test.ts: drives the buzzer as a metronome
against a controllable audio clock and asserts even spacing, one oscillator
per click with no overlap, correct pitch per metric level, burst absorption,
and a clean re-lock on tempo change.
All simulation-parts + metronome tests pass (57).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A PWM-driven buzzer (analogWrite / Timer tones) was chaotic and unusable as a
metronome. Causes, all on the PWM path:
1. PWM was polled once per animation frame AFTER the cycle loop, so short clicks
that started and ended within one frame were merged or lost, and onsets were
quantised to the frame.
2. The buzzer started the oscillator with `oscillator.start()` (no scheduled
time) — frame-delivery jitter and per-onset oscillator churn.
3. The digital HIGH/LOW path also fired on the ~490Hz PWM carrier edges,
injecting spurious onsets (OCR read as 0 → 20kHz squeaks).
Fix:
- AVRSimulator: poll PWM sub-frame (every 256 cycles) so no pulse is merged or
lost; pass the precise simulated time through updatePwm.
- PinManager: PwmCallback / updatePwm carry an optional timeMs (backward compat).
- Buzzer: one continuous oscillator gated by the gain node, each on/off scheduled
on the AudioContext clock. The schedule predicts the next onset at a smoothed
interval (de-jittering the simulator's bursty per-frame delivery) and holds a
small bounded latency so the click stays aligned with the on-screen playhead
(driven from the same clock) instead of drifting behind it. A `pwmActive` flag
mutes the digital path once hardware PWM drives the pin.
Result: onset jitter for a firmware metronome drops from chaotic (σ ≈ 250ms,
dropped/extra beats, unbounded audio latency) to σ ≈ 15ms at ~30ms latency —
steady and aligned with the display. All 54 simulation-parts tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A board example proving digital and analog coexist in ONE circuit: the Arduino
drives two logic levels, a physical AND gate combines them, and the AND output
switches an NPN 2N2222 transistor that drives the "motor" LED. Verified live: it
compiles, the MCU drives the AND gate (5 V), the transistor conducts and the LED
lights — MCU -> logic gate -> transistor -> load works across the digital and
ngspice motors together.
Known limitation (sim-mixedmode step 2, pending): the ngspice side does not
re-solve on every MCU pin edge, so a fast (1 Hz) blink does not track in real
time — the analog output changes on a slower cadence. User-driven / slow changes
track fine. Snapshot updated for the new example.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The AND Gate Alarm needed BOTH inputs HIGH at once, but it used momentary
pushbuttons buffered through an Arduino — with one mouse you can only hold one
button at a time, so the AND never fired and the alarm could never be
demonstrated.
Rebuilt it as a board-less digital circuit: two SLIDE switches (they latch) feed
a real AND gate that drives the alarm LED. Slide both switches ON and they stay,
so the alarm arms. No MCU / compilation — it runs on the digital gate engine.
Verified live: the LED lights only on 11 (00/01/10 -> off, 11 -> on).
Snapshot updated: the new board-less and-gate-alarm netlist, plus the digital
bucket count label (38 -> 39) from the earlier ripple-counter example.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the first board-less SEQUENTIAL gallery example (digital-ripple-counter-4bit):
four T flip-flops chained into a ripple counter, LEDs showing the binary count,
clocked by a slide switch. Impossible on the SPICE engine (no edge detection at
DC) - it runs on the digital gate engine.
Controller fix (found by testing the counter live): the controller rebuilt the
network on every change, which reset flip-flop state so a counter never counted.
Now the network is built once and KEPT ALIVE; a switch toggle applies
incrementally via setSwitch (preserving sequential state), and a rebuild happens
only on a structural change (components/wires). Correct for combinational AND
sequential circuits.
examples-digital.test.ts: flip-flop examples are digital-engine-only, so they are
exempt from the SPICE-mapping / has-a-gate / netlist checks (the "logic" check
now accepts a gate OR a flip-flop). digitalgate-engine-examples: a correctness
test clocks the real counter example and asserts it counts 1..15,0 in binary.
Verified live (?digitalgates default ON): the counter counts 0..6 on the canvas;
and the complex examples all work - comparator-4bit (A=B correct), decoder-3to8
(perfect one-hot x8), alu-slice-1bit (32 combos deterministic), multiplier-2x2
(3*3=9, 7 distinct products), adder-subtractor-4bit (5+3=8).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Flip-flops are edge-triggered and hold state, which the combinational settle
kernel cannot model alone. buildDigitalNetwork now gives each flip-flop explicit
state + rising-CLK-edge detection (reusing the LogicGateParts sample semantics):
sample the data nets on the edge, drive Q + Qbar. Because a flip-flop only
updates on the clock edge, a Q->D / Q->CLK feedback (counter / shift register)
does not oscillate the settle loop. isAllDigital now accepts a gate OR a
flip-flop, so pure sequential circuits qualify.
Test digitalgate-sequential (4): D (capture + hold), T (toggle), JK
(hold/set/reset/toggle), and a 2-bit ripple counter (FF0.Qbar clocks FF1)
counting 1,2,3,0,1 - impossible on the SPICE path (no edge detection at DC, no
SPICE mapper). The controller already routes all-digital circuits through
buildDigitalNetwork, so a board-less counter/shift-register example would run
live; authoring those gallery examples is the only follow-up. Full digitalgate
+ examples-digital + circuit-simulation-service suites green (127 tests).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
buildMixedNetwork evaluates the gate (digital) side of a MIXED circuit on the
settle kernel and exposes the boundary with the analog (ngspice) domain. Unlike
buildDigitalNetwork it does not bail on non-primitive components - those are the
analog side; their pins mark the nets they touch as boundary. Exposes
boundaryNets, readBoundary(net) (digital->analog: the gate-driven level to seed
an ngspice voltage source) and setBoundaryInput(net, level) (analog->digital:
ngspice's solved+thresholded level, which re-evaluates downstream gates).
Test digitalgate-mixed-boundary (4): the boundary nets are exactly the
digital/analog bridges; both directions track; a digital->analog->digital
coupler loop converges. No ngspice needed - the analog side is supplied by the
test. Wiring the handoff to the live ngspice netlist (0/Vcc sources + threshold
+ settle<->solve iteration) is the remaining step; it needs the running solver
(the node loader is broken by a pre-existing path bug) and a mixed example.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 4 (brought forward before the mixed-mode boundary). digitalgate-sweep
proves the engine handles 38/38 gallery digital examples: every one builds,
resolves every LED, and never oscillates. Tightened isAllDigital to also require
at least one logic gate, so a degenerate analog {source, resistor, LED} circuit
stays on ngspice rather than being claimed by the digital path. Flipped
digitalGatesEnabled() default to ON (override with ?digitalgates=off).
Full frontend suite 2120 pass / 5 fail — the 5 are the same pre-existing
unrelated failures (ngspice node-path, attiny85 arduino-cli, component-to-spice
catalog); the default flip adds no new breakage and examples-digital +
circuit-simulation-service stay green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Board-less digital circuits (logic gates + switches + LEDs) run today as ngspice
analog B-sources, which is fragile for deep logic: a 4-bit ripple adder re-solves
but never lights its result LEDs live. This adds an event-driven digital motor
that reuses the multichip-bus settle kernel, so the same engine that boots a Z80
over a chip bus evaluates a gate network exactly and instantly.
Phases 0-2 (project/digital-gate-engine/), all behind ?digitalgates=on (default
OFF — flag off is byte-for-byte the old behaviour):
- digitalGateEngine.ts: buildDigitalNetwork(components, wires) does union-find
over the wires (merging pass-through resistors), identifies the rail/gnd from
the signal-generator, registers drivers (rail STRONG-1, gnd 0, pull resistors
PULL, slide-switch as a pass-gate) and event-driven gates (reusing the
LogicGateParts boolean semantics), settles on busKernel, and exposes
setSwitch / readLed / netOf. Tolerant of both the raw example `type` and the
store `metadataId`. Returns {ok:false} for any non-primitive, so mixed/analog
circuits stay entirely on ngspice.
- digitalGateController.ts + a SimulatorCanvas useEffect: when the flag is on and
the circuit is all-digital, rebuild from the store on switch-toggle / load
(rAF-coalesced) and paint the wokwi-led DOM. CircuitSimulationService.tick()
skips the SPICE solve for all-digital circuits when the flag is on, so the two
motors never fight over the LEDs.
Tests: digitalgate-kernel (22 — single gates -> half/full adder -> 4-bit
adder/subtractor -> exhaustive ADD 256 -> mux/decoder/comparator/parity/
multiplier) and digitalgate-engine-examples (6 — the real gallery data for
and/or/xor/not + the full adder/subtractor). Verified live: ?digitalgates=on
lights the adder's result LEDs that the SPICE path leaves dark. Full suite
2117 pass / 5 pre-existing unrelated fails.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The gallery example loaded but the Z80 never visibly ran: the screen stayed
frozen on garbage. Two multi-chip async-load races, neither caught by the
existing headless tests (which drive RESET manually and attach the display
before boot):
1. RESET edge-vs-level race. The Z80 only left reset on the RISING edge of
RESET (a pin watch). In the browser the 7 chips instantiate asynchronously,
so the small power-on-reset chip releases RESET before the larger Z80 has
registered its watch -> the edge is lost and the CPU stays in reset forever.
Fix: on_clock samples the RESET level (hardware-accurate; RESET is
level-sensitive) so a missed edge self-corrects. An undriven RESET reads low,
so the CPU safely stays in reset until something drives it high.
Repro/guard: chipbus-galaksija-reset-race (race ordering must still boot).
2. Display-snoop load-order race. galaksija-display was a passive write-snoop;
the ROM paints the screen ONCE at boot then idles, so a display that comes up
late misses every write and shows stale content forever. A snoop cannot
recover writes it never saw. Fix: fold the screen into the RAM chip
(galaksija-ram-display) and render from the ACTUAL video RAM (0x2800-0x2BFF,
internal 0x0800 with A0-A12 wiring) on a ~30 fps timer - correct regardless
of load order, exactly how the real machine scans video RAM.
Repro/guard: chipbus-galaksija-display-snoop-race (late snoop shows nothing)
+ chipbus-galaksija-ram-display (renders even when first paint is post-boot).
The example now has 6 chips (RAM+display merged, gdisp dropped), 76 wires.
Verified live in the browser: boots to "@'READY", shows the ">" prompt, and
pressing A echoes ">A_" through keyboard -> Z80 -> video RAM -> display. The
full chipbus suite is 45/45.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a memory-mapped keyboard so you can type into the Galaksija. Based on
the libretro Galaksija core's scheme (not guessed): reading 0x2000+offset
returns 0xFE when the key at that matrix offset is held, 0xFF otherwise;
the keyMap gives the offset per key ('A'=1 ... Enter=48, Space=31, etc.).
- galaksija-keyboard.c: drives reads of 0x2000-0x203F from a keys[] table and
exports set_key(offset, down) for the host to push key events. Never drives
outside the keyboard range.
- galaksija-ram.c: ram-64k variant that yields reads of 0x2000-0x203F to the
keyboard (writes still go to RAM), so the two never fight for the bus.
- ChipRuntime: ChipInstance.hasKeyboard + setKey() expose the chip's set_key.
- CustomChipPart: bridges browser keydown/keyup (by KeyboardEvent.code, via
GALAKSIJA_KEY_OFFSET) into the chip, ignoring keystrokes while the code
editor or an input is focused so typing code is never hijacked.
- The gallery example gains the keyboard chip (now 7 chips, 99 wires) and uses
galaksija-ram.
Test chipbus-galaksija-keyboard: pressing 'A' (offset 1) makes the BASIC
monitor echo "A" after its ">" prompt and advances the cursor. 41 chipbus
tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ships the full Galaksija (1983 Z80 home computer) as a runnable Retro
gallery example, plus the pieces needed to run a multi-chip bus live in the
browser.
Gallery example (examples-retro-intel.ts, id 'galaksija-z80-computer'):
Z80 + galaksija-rom (public-domain ROM A+B) + ram-64k + inverter (A13
decode) + galaksija-display + a power-on reset chip, wired chip-to-chip
over the bus (76 wires), no board. Click Resume and it boots the real ROM
to the "READY" prompt on the green display. Chip wasm is embedded
(wasmBase64) so it runs without a backend compile.
- ChipRuntime.tickTimers gains a wall-clock budget (CustomChipPart passes
6 ms): a faithful-but-slow event-driven bus can't run a real-time CPU in
one animation frame, so without a cap a Z80 fetching over the settle
kernel froze the tab. With the budget the sim advances slower than real
time (boots over a few seconds) and the UI stays responsive; fast
single-chip examples finish under budget and are unaffected.
- galaksija-display: blits its framebuffer on a ~30 fps timer instead of on
every character write, so a clear-screen burst doesn't flood the canvas.
- reset-gen: power-on reset (pulses RESET high, ties WAIT/BUSREQ/INT/NMI
high) so the machine boots on Resume without a manual reset.
- chipbus flag now defaults ON (override with ?chipbus=off): chip-to-chip
buses are a core capability; single-chip and board nets never take this
path, so the only thing enabled is multi-chip buses, previously broken.
Verified live in the browser: the example boots and renders "@'READY" with
the ">_" prompt, responsive. Full suite 2084 pass (5 pre-existing,
unrelated env failures).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Galaksija stores ASCII codes in its 0x2800 video RAM (verified by snooping
the boot: it writes "@'READY" + ">_" prompt). The original CHRGEN ROM uses
a hardware-specific addressing that does not map char-code*8 to a glyph, so
rendering through it produced garbled output. Render the ASCII codes with
the public-domain IBM/VGA 8x8 font (font8x8 by Daniel Hepper / Marcel
Sondaar) instead -- legible green-on-black phosphor text. The boot screen
now reads "@'READY" with the ">_" input prompt, exactly like a real
Galaksija. Tests updated to check the bright-green channel.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
galaksija-display.c: a 32x16 text video chip that renders the Galaksija
video RAM. It is a passive bus snoop -- watches WR + address + data, and on
a write into the 0x2800 video region stores the character and renders that
cell into a 256x128 framebuffer using the public-domain CHRGEN font (code*8,
bit 0 = lit). It never drives the bus. The host blits the framebuffer to the
chip canvas (vx_framebuffer_init / vx_buffer_write).
Two tests:
- chipbus-galaksija-display: snoop+render smoke test (a write of 'R' to
0x2802 lights its cell; unwritten cells stay blank).
- chipbus-galaksija-computer: the COMPLETE machine over the chip-to-chip bus
(Z80 + galaksija-rom + ram-64k + inverter decode + galaksija-display) boots
the public-domain ROM and renders the monitor's "READY" prompt on screen.
40 chipbus tests across 10 files pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The public-domain Galaksija ROM (Voja Antonic; ROM A monitor + integer
BASIC, ROM B float BASIC, 8 KB) runs on a standalone Z80 + external ROM +
RAM + an inverter for address decode, all chip-to-chip over the shared bus,
no board:
ROM 0x0000-0x1FFF rom.CE = A13
RAM 0x2000-0x3FFF ram.CE = NOT A13 (the inverter chip)
RD -> both OE ; WR -> RAM WE
Pin-level boot proof (mirrors test_intel/test_z80/galaksija.test.js): watch
M1, read the address bus on each opcode fetch, and confirm the Z80 leaves
the reset vector (DI; SUB A; JP 0x03DA), reaches the init routine at 0x03DA,
and runs 1000+ fetches across 50+ distinct ROM addresses -- the real
firmware executing end-to-end through the settle-kernel bus. The on-screen
"READY" prompt is the next milestone (needs the video display chip
rendering the 0x2800 video RAM).
galaksija-rom.c embeds the public-domain ROM A+B image. 38 chipbus tests
across 8 files pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The architectural heart of the retro computer, proven on real chips. A Z80,
a 32K ROM, a 64K RAM and an inverter (address-decode glue) are wired
chip-to-chip over a shared address + data bus, no board:
ROM at 0x0000-0x7FFF rom.CE = A15
RAM at 0x8000-0xFFFF ram.CE = NOT A15 (the inverter chip)
RD -> both OE ; WR -> RAM WE
The ROM program writes 0x5A to RAM at 0x8000, clears A, reads it back, and
HALTs only if the byte survived. HALT going low proves the full core works:
the Z80 runs from ROM, the inverter decodes A15 to select RAM (the settle
kernel drives the combinational glue across hops), and the RAM latches a
write and returns it on a read over the shared tri-state bus, all within
synchronous bus cycles. Adds z80-ram-rom.c (boot image) + ram-64k/inverter
fixtures. 37 chipbus tests across 7 files pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
End-to-end validation of Phases 0-2 on an actual CPU. The real Z80
(examples/intel/z80.c) and a 32K EPROM (z80-boot-rom.c, a rom-32k variant
holding JP 0x0006 / HALT) are wired chip-to-chip over a shared address +
data bus with no board. RD drives the ROM's OE; CE is left enabled.
Booting exercises all three phases at once: the Z80 drives the address ->
the ROM reacts on the shared net key (Phase 0); asserts RD -> the ROM
tri-state-drives the data bus while the Z80 released it (Phase 1); and reads
the data bus in the SAME tickTimers step, getting the settled byte
(Phase 2 settle-before-read). The Z80 fetches C3,06,00, jumps to 0x0006,
fetches 76, and HALTs -> drives HALT low, which the test observes.
z80.wasm is compiled from the committed examples/intel/z80.c; the boot ROM
source + chip.json live in test_custom_chips/sdk/examples. All 36 chipbus
tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>