Progress and future work
Status as of 2026-08-09. The authoritative living log is AGENTS.md; this document is the readable summary.
What works today
Emulator core
- Full Cortex-M4 Thumb-2 execution via Unicorn 2.1.4 (WASM) — the same firmware binaries run here and on real silicon, for logic; timing is instruction-count driven, not wall-clock (see Known limitations #4).
- 33 peripheral modules, 28 detailed (see peripherals.md).
- Deterministic instruction-count clock (timers, ADC, RNG, RTC, watchdogs).
- NVIC interrupt model with an opt-in guest-IRQ pump for interrupt-driven firmware (UART RX + crypto firmwares verified in Node and in a browser).
- Register map from the vendor SVD; bit-banding support.
Networking (the flagship feature)
- Three real bare-metal network firmwares:
eth_http— DHCP + TCP client + HTTP GET, prints the responseeth_dhcp— DHCP loop, printsDHCP SUCCESSeth_test— raw ETH TX/RX self-test
- Two network peers: canned
netsim(deterministic) and a real gVisor stack viaopenhw-local-gateway(Go). The firmware does real DHCP and real TCP against a real kernel network stack over a WebSocket. - Multi-round soaks: 200M-instruction runs, 1000+ consecutive TCP connections with 0 failures (see benchmarks.md).
Browser demo + npm package
- Single-page console (site/): UART terminal with bidirectional RX,
31 firmware presets, custom
.bin/.hex/.elf/.mapupload, gateway connection to real gVisor networking, live GPIO grid, peripheral register readout, packet viewer. Deployed to GitHub Pages. - DOOM runs in the browser (
site/doom.html): the doomgeneric F407 port boots, plays the shareware WAD at ~25 FPS (realtime-locked guest clock, I2S mixer → AudioWorklet), and saves/loads games tolocalStorage(F2/F6 save, F3/F9 load — firmware stages savegames in EXTRAM at 0xC0080000, 2 slots × 256 KB, mirrored by doom.js). Below DOOM's native 35 fps: ~918k guest instructions/frame means 35 fps needs ~32 MIPS against a ~20-24 MIPS core ceiling (measured 2026-08-14, see AGENTS.md §16 —emu.step()size and-O2ruled out; dropping the per-block counting hook did help, 22 → 25 fps). Audio is mixed per rendered frame, so below 35 fps the worklet rate-matches and plays slightly slow/pitched-down rather than breaking up. Verified:node site/test_doom.mjs(boot → menu → E1M1 →SAVE ok slot=0) and a headless-Chrome CDP smoke (save → reload-less F9 quick-load →LOAD ok, audio continuous). stm32f4-emulatornpm package (packed, not published) with a cleancreateSTM32F407({firmware})API.- CI runs the Node test suite on every push; Pages deploys the demo.
Recently fixed (2026-08-09)
- DOOM save/load (2026-08-14): firmware save shim routes
doomsavN.dsgfile ops to a 2×256 KB EXTRAM staging area through a private fd (0x7f00), commits via the newlibrename=_link+_unlinkchain, and busy-waits for the driver on load — with the driver (doom.js) mirroring the blob tolocalStorage. Also fixed the browser's F-key codes (engineKEY_F1..F12 = 0xBB..0xC6; the old 0x80..0x8B mapping made F2/F3/F6/F9 dead keys) and passed raw ASCII letters through for the save-name entry and 'y' confirms. Details + gotchas in AGENTS.md §16.- Unicorn 40k-instruction wedge: oneemu_startrunning ~40k+ instructions without a stop condition permanently wedges this WASM build (broken timeout path,qemu_thread_create: Not supported). Fix:maxBatchcapped at 20000 (MAX_BATCHenv). After the cap: 604 TCP connected / 0 fail / 0 timeouts in 47.9 s (~12.6 rounds/s). - UART RX buffer cap: 16-byte model buffer silently dropped the 16th
byte (a trailing
\n), breaking browser RX smoke tests with exactly-16- byte sends. Cap raised to 64; verified 3-byte, 16-byte, and split sends in headless Chrome. - XPSR restore in the interrupt pump (condition flags must survive the
fake-ISR abort) — eliminated a ~1-in-2
TCP failflake. - HTTP 000b bug: consecutive RX injections landed in the same
rx_buf[0], so a queued second frame was parsed with a stale length. Fix: rotate the injection index across RX descriptor slots. - Per-round gateway restart now opt-in (
GW_RESTART=1); default mode runs consecutive rounds at full speed with no restart. - SPI NOR flash write path (full): real-MISO timing (a dummy byte
shifts out while the command/address bytes clock in —
dummy_pendinginSpiFlash), WriteEnable-gated PageProgram/SectorErase4k that commit on CS deassert (program ANDs bits, erase writes 0xFF, WEL auto-clears on completion like real W25Q). Program/erase buffering lives inself.cmdargs so data bytes aren't mis-parsed as new opcodes. CS deassert edges reach the flash via GPIO write callbacks (register_cs_callbacks, wired in bothfrom_svdandnew_wasm—from_svdwas missing the wiring, which silently killed commit for the SVD/init_svdpath).spi_flash_testfirmware: 9/9 PASS (JEDEC, WEL set/cleared, readback, second program, erase). Native integration testperipherals::spi::tests::firmware_flow_via_gpio_csexercises the same flow through real MMIO + GPIO edges. - EXTI GPIO edge path (new):
Exti::scan_linesruns per tick, reads the GPIO line level of the SYSCFG-selected port, and pends IRQs on RTSR/FTSR edges;exti_testfirmware: 3/3 PASS (fired once, PR cleared by handler, fired on 2nd edge). Test-driver note: an edge must be observed low by a tick before re-raising — injecting low+high in one JS iteration is invisible to the model. - FLASH program/erase (new):
spi_flash_test's flash region now programs (PG) and sector-erases (SER) the backing buffer via the JS flash-command driver;flash_test11/11 PASS. - Hardware-accurate NVIC (new):
set_intr_pendingno longer auto-enables IRQs. Pending is set regardless of ISER; delivery (pump) happens only when the firmware sets the enable bit — a disabled pending IRQ stays pending until taken or cleared via ICPR, exactly like real hardware.has_pending/get_pending_vectornow report only deliverable pending, and ICSR VECTPENDING returns the exception vector number (was returning the 0-based IRQ number). All interrupt-driven firmwares (rx_interrupt, rx_crypto, exti, eth_http/dhcp/test) set ISER explicitly and pass unchanged; 20M-inst soak: 121 TCP connected, 0 TCP fail.
Known limitations
Unicorn WASM wedge— resolved 2026-08-10: does not reproduce on Node 22.22 (V8); fresh characterization with count-basedemu_startat n=1000..1,000,000, and 150M-instruction cli.mjs soaks atMAX_BATCH=500000, ran wedge-free (AGENTS.md §7). Ruling: the original wedge was build/environment-specific to an older Node/V8 WASM engine, not the vendored Unicorn build itself.cli.mjs'smaxBatch(default 200000,MAX_BATCHenv override) remains, but as a throughput/ responsiveness knob — the driver still needs periodicemu_startreturns to service DMA/ETH polling and interrupts regardless of any wedge — not a workaround for a bug. One unrelated, still-reproducible instance killer: passing thetimeoutargument toemu_startaborts the instance (qemu_thread_create: Not supported); nothing in this repo passes it.- Guest-IRQ pump vs ETH firmware: the pump must stay disabled for ETH
firmware (an emulated ETH_IRQHandler re-scans
rx_descand stomps the driver's frame bookkeeping). - Hardware paths not modeled: DCMI has no pixel source (CAN now has a real two-node bus with arbitration; I2S/SAI have a WAV-backed DMA capture path; LTDC has real scanout + a browser sink). USB OTG is explicitly out of scope, not a to-do — see roadmap note below.
- Timers are instruction-count driven, not wall-clock driven — a
delay_ms(100)is ~2.4M emulated instructions, so real-time blink rates don't hold (documented in AGENTS.md §11).
Roadmap / future implementation
Priority 1 — emulator robustness
- Unicorn WASM wedge: not reproducible on Node 22.22 (2026-08-10 —
count-based
emu_startreturns cleanly at any budget, 500k batches × thousands of rounds; the vendored build IS the current unicorn.js v2.1.4 arm release).cli.mjsdefaultMAX_BATCHraised 20k → 200k (~2.96–3.2 MIPS vs ~2.1). Remaining known landmine: thetimeoutargument toemu_startaborts the instance (qemu_thread_create: Not supported) — nothing in the repo passes it; a native Node addon or V8-upgrade would fully retire this. - Hardware-accurate NVIC: don't auto-enable IRQs in
set_intr_pending; make the pump deliver pending interrupts only when ISER bits are set by firmware. - EXTI ↔ GPIO edge-trigger wiring (GPIO config drives EXTI pends).
- FLASH program/erase emulation (write to the flash backing buffer), needed for DFU-style and bootloader firmwares.
- DMA peripheral-side copies chunked in Rust (
dma_periph_read/dma_periph_write): one WASM call per transfer instead of size/4 per-chunk calls from JS; also fixed M2P which previously wrote peripheral bytes back into guest RAM. RAM-to-RAM copies stay in JS (Unicorn owns guest memory).
Priority 2 — peripheral depth
- DOOM audio "only crackling" — FIXED 2026-08-14. The root cause was
an inverted resample ratio, not the fps shortfall.
site/audio-worklet.jsadvanced its 11025 Hz read cursor bysampleRate/11025(≈4.35) per output sample instead of11025/sampleRate(≈0.23) — consuming input ~19x too fast, so every sound played far above audible pitch and starved instantly. It passed every test because the tests only counted samples the guest PRODUCED; nothing verified playback consumed them at the right rate. Also replaced the underrun policy (which emitted silence AND flushed the queue, discarding good audio) with a proportional rate controller that matches playback to production and never flushes. Measured on the page over 8 s of play: starved output samples 364476 → 0. Below 35 fps audio now plays slow/pitched-down (~0.72x at 25 fps) but continuous — matching the slow-motion game. Note for future tuning: the rate floor must stay belowfps/35, and the?v=onaddModule('audio-worklet.js?v=N')MUST be bumped on every edit or you are testing a cached worklet. - LTDC scanout + display sink: the model advances a scanline/scanframe
(2 px per tick from the real SSCR/BPCR/AWCR geometry), fires LIF at
LIPCR and the frame-end F flag, and pends LTDC IRQ 88; exports
ltdc_get_scanline/ltdc_get_frame_count.ltdc_testfirmware paints an ARGB8888 gradient layer, and the browser console renders layer0's framebuffer into a canvas panel live (?fw=ltdc_test; ARGB8888 + RGB565 handled). - I2S/SAI real audio (WAV-backed DMA):
audio_load_wavparses a RIFF/WAVE PCM16 file into the model source; I2S/SAI DR reads consume it (falling back to the synthetic generator), DR writes push into a capture FIFO (audio_take_capture). The shared SPI block routes DR to audio when I2SMOD is set (real silicon shares the register block).audio_testfirmware runs a full DMA1 PERIPH->MEM transfer from I2S1_DR and checks the sample checksum — this drove DMA fixes below. - DMA PINC + PSIZE-aware peripheral reads: transfers now carry PINC and
the peripheral width, so a fixed-address 16-bit DR FIFO yields
contiguous sample streams (
pinc=0re-reads the same register inpsizechunks) instead of zero-padded 4-byte groups. Completion flags (TCIF/HTIF) are no longer set at EN-write: they latch from the JS driver'sdma_set_completed(viadma_check_completionin the LISR/HISR read path) and stay set until the guest clears them through IFCR — real-w1c semantics. - DCMI real pixel source.
- USB OTG — deferred, not planned: none of this repo's firmware targets or intended use cases (networking demos, DOOM, breadboard- style peripheral simulation) exercise USB, and a browser sandbox has no real USB host to enumerate against without WebUSB passthrough to physical hardware — which isn't emulation. Revisit only if a concrete firmware need appears (a lighter USB CDC echo sample is a much smaller ask, see Priority 3).
- EtherCAT / timers in PWM servo mode for the printer heritage firmwares.
- CAN bus peer / arbitration between CAN1 and CAN2 on a shared bus:
TX requests stage frames; each tick arbitrates (lowest arbitration
ID wins, ties by node then mailbox), the winner's mailbox completes
(TSR TXOK|TME|RQCP) and the frame broadcasts to every node's RX that
passes its filter banks (winning transmitter receives its own frame
like real CAN); losers stay staged for the next free round. Real
filter semantics: 28 global banks (CAN2 = 14..27), mask/list modes,
32/16-bit scale, FFA1R FIFO assignment; 3 mailboxes per FIFO at the
real addresses, FMP/FULL/FOVR and RFOM release. BTR LBKM loopback
delivers only to the sender.
can_testfirmware: loopback echo (id/payload verified) + two-node arbitration (both nodes end with both frames); 4 Rust unit tests cover arbitration, loopback, filter gating, FIFO overflow/release. Also fixed the minimal (no-SVD) register list — it was missing CAN1/CAN2 entirely, so CAN firmware silently did nothing in browser/test builds.
Priority 3 — product / ecosystem
- Component-attachment API (2026-08-14, rp2040js-style): public
emu.pin()/watchPin()/i2cRegfile()/setAdcChannel()plusLED/Button/Pwm/Potentiometer/I2cRegisterDeviceinsite/components.js, andext_devices.spiDevices/i2cDevicesfor embedder-defined bus protocols on the existing SPI/I2C taps. Each class is verified against real firmware in its own process (site/test_component_{led,button,pwm,i2cregfile,adc}.mjs). ADC injection needed the one Rust change (a global override table insystem.rs+adc_set_channel_value/adc_clear_channel_valueexports) since channels previously only produced LCG noise. See components.md. - MCP server (2026-08-14):
mcp/server.mjsexposes the emulator as 14 MCP tools (load/step/UART/pins/ADC/registers/memory/components) over stdio via the official@modelcontextprotocol/sdk— the project's first runtime npm dependency. Protocol round-trip smoke test:npm run test:mcp. See mcp.md. - Windows/macOS CI (2026-08-14):
ci.ymlruns the suite onubuntu-latest/windows-latest/macos-latest. No source changes were needed — the test path was already portable (plainnodeinvocations,new URL(..., import.meta.url)paths, nochild_process/native addons, prebuilt WASM needs no Rust toolchain). - Publish
stm32f4-emulatorto npm (README documentsnpm packflow already; anpm publish+ consumer verification remains). - GitHub Pages: serve the demo over https (gateway mode currently
needs http:// for plain
ws://; document a WSS gateway or a local-proxy flow). - VS Code extension / devcontainer with the full toolchain (wasm-pack, arduino-cli, go). (An MCP server now covers part of the "drive the emulator from your editor" use case — see above.)
- Waveform/DMA trace view in the browser console.
- Interrupt-driven ETH driver (
eth_irq_test): NVIC ETH IRQ 61 + DMAIER, the pump runsETH_IRQHandlerwhich reads DMASR TS/RS, scans/re-arms RX descriptors; the driver only signals the model (irq_ethmode — no SRAM flag writes). Also fixed the RX descriptor format: FS/LS marker bits at 28/27 corrupted the frame-length window [29:16] (len<<16only, like real F407). - FreeRTOS port (
freertos_test) — verifies the interrupt pump's context-switch path end-to-end: TIM3 ISR →xSemaphoreGiveFromISR(xTimSem)→portYIELD_FROM_ISR(PendSV) → scheduler context-switches to the higher-priorityvHighTask, which pends on the semaphore.vHighTaskarms TIM3 itself (self-contained). Wired asprobe_freertos.mjsregression test (wired intonpm test); the probe is intentionally quiet (final summary +PROBE PASS/PROBE FAIL). This was the regression test that caught (and now guards) the mid-strexception-return PC bug fixed insite/emulator.jsprocessInterrupts(see AGENTS.md §9).- Deeper FreeRTOS coverage — DEFERRED (not needed). The probe already
exercises all three yield types (task / ISR / SysTick) plus preemption
and a binary-semaphore give-from-ISR, which fully guards the
emulator-specific defect. Inter-task queues, mutex/priority-inheritance,
task deletion, and a second concurrent live ISR (e.g. UART RX) would
mostly test guest FreeRTOS library code, not new emulator behavior,
and add maintenance cost for marginal protection. Revisit only if
processInterruptsis reworked or a real FreeRTOS app using those primitives is targeted.
- Deeper FreeRTOS coverage — DEFERRED (not needed). The probe already
exercises all three yield types (task / ISR / SysTick) plus preemption
and a binary-semaphore give-from-ISR, which fully guards the
emulator-specific defect. Inter-task queues, mutex/priority-inheritance,
task deletion, and a second concurrent live ISR (e.g. UART RX) would
mostly test guest FreeRTOS library code, not new emulator behavior,
and add maintenance cost for marginal protection. Revisit only if
- USB CDC echo.
Delivered 2026-08-22 (CLI / DX pass)
-
stm32f4-emuheadless CLI (cli.mjs, registered as thestm32f4-emubin): loads.bin/.elf/.hex, boots it, streams guest UART to stdout; flags--inst,--format,--verbose,--help,--version. -
--verboseregister trace insite/emulator.js(opts.verbose), traces peripheral MMIO R/W to stderr, capped at 5000 accesses. - Actionable firmware-load errors: empty/too-small image and zero
reset-vector throws with guidance;
loaders.jsELF/HEX parse failures name the expected format and likely cause. -
stm32f4-mcp --help/--versionfor the MCP server bin. -
CHANGELOG.mdtracking releases/features. - TypeScript declarations (
index.d.ts,site/emulator.d.ts) for the public Node API (createEmulator / createSTM32F407 / decodeFirmware / components) so TypeScript consumers get types.
Deferred / low-priority (tracked, not scheduled)
- More demo firmwares — a CAN-bus demo (exercising the two-node
arbitration model already in
can.rs) and a deep-sleep / low-power (STOP/WFI + RTC wakeup) demo. Useful for showcasing, but the peripheral models they need are already verified by existingcan_test/rtc_test; the marginal emulator value is a new firmware + test harness each. Revisit when a showcase gap is identified. - Wider edge-case test coverage ("236/236" style) — the current suite already guards every emulator-specific defect (FreeRTOS context switch, ETH RX/TX, DMA, I2C/SPI taps, LTDC, audio, RTC). Extra cases would mostly re-test guest library code. Low value relative to maintenance cost; add only when a new bug class appears.
- Website / docs polish — landing-page copy, diagrams, more in-page help. Cosmetic; do alongside the next public-facing push.
- Performance work — the MIPS ceiling is the Unicorn 2.1.4 WASM core (≈20–23 MIPS headless; DOOM runs ~22–24 fps). Already optimized (per-block hook, noCountHook path, minimalPolls). No further easy headroom without a different CPU core; revisit only if a faster Unicorn build or a native (non-WASM) binding becomes available.
Verification checklist (regression)
npm test # flow + blinky + rx-interrupt + 5 component tests
npm run test:mcp # MCP protocol round-trip (needs npm install)
scripts/verify_ethernet.sh 10000000 # 3 firmwares through gateway
node site/probe_firmwares.mjs # every preset boots to a banner
node site/test_rx_interrupt.mjs # interrupt-driven UART/CRC
node site/test_flash.mjs # FLASH program/erase (11/11)
node site/test_spi_flash.mjs # SPI NOR write path (9/9)
node site/test_exti.mjs # EXTI GPIO edges (3/3)
node site/test_can.mjs # CAN loopback + 2-node arbitration
node site/test_audio.mjs # I2S DMA WAV replay + TX capture
node site/test_ltdc.mjs # LTDC scanout + framebuffer pixels
(cd stm32-periph-wasm && cargo test --lib) # native unit + integration tests
SOAK_STATS=1 node cli.mjs ../eth_http/eth_http.bin 200000000 \
--gateway --config=../../eth_http/config.yaml # long soak (≈15 min)