stm32f4-emu 1.0.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,7 +7,8 @@
7
7
  [![Pages](https://github.com/danish9661/stm32F4-emulator/actions/workflows/pages.yml/badge.svg)](https://github.com/danish9661/stm32F4-emulator/actions/workflows/pages.yml)
8
8
 
9
9
  An STM32F407 microcontroller emulator that runs real Cortex-M4 firmware. It
10
- combines a **Unicorn CPU core** (QEMU-derived, compiled to WASM) with a
10
+ combines a **Rust CPU core** (a WASM-native Thumb-2 interpreter with exact
11
+ Cortex-M exception entry/return, including the VFPv4-SP FPU) with a
11
12
  **Rust peripheral model** (RCC, USART, GPIO, DMA, ETH, TIM, NVIC, ...) also
12
13
  compiled to WASM — so the whole machine runs headless in **Node.js or a
13
14
  browser tab**, with no SDL, no native deps, no hardware.
@@ -32,18 +33,18 @@ scripted network (netsim) as fallback, a live UART terminal (with **UART
32
33
  RX input** — type into the console and the firmware reads it; newline
33
34
  characters excluded per HTML spec, see AGENTS.md §11), GPIO pin readout
34
35
  for banks A–E, and key peripheral registers. Interrupt-driven firmware
35
- (`rx_interrupt_test`, `rx_crypto_test`) is serviced by an opt-in guest-IRQ
36
- pump; polling firmware (the ETH demos) never uses it. For automation, a
36
+ (`rx_interrupt_test`, `rx_crypto_test`) gets inline guest-IRQ delivery;
37
+ polling firmware (the ETH demos) never uses it. For automation, a
37
38
  preset can auto-boot via the URL: `?fw=eth_http`, `?fw=blinky`, `?fw=crypto_test`, …
38
39
 
39
40
  ## DOOM (in the browser)
40
41
 
41
42
  **[`site/doom.html`](site/doom.html)** runs DOOM 1 shareware
42
- (doomgeneric, ported to the emulated F407) at ~25 FPS in a
43
- headless-Chrome-verified browser page — playable, but below DOOM's native
44
- 35 fps: at ~918k guest instructions per rendered frame, 35 fps would need
45
- ~32 MIPS and the Unicorn WASM core tops out near 20-24 (details and the
46
- measurements in AGENTS.md §16). Because the guest mixes one frame of audio
43
+ (doomgeneric, ported to the emulated F407) at the full 35 fps in a
44
+ headless-Chrome-verified browser page — playable: at ~1M guest instructions
45
+ per rendered frame, 35 fps needs ~32 MIPS and the Rust core delivers ~65
46
+ (details and the measurements in AGENTS.md §16/§22). Because the guest mixes
47
+ one frame of audio
47
48
  per rendered frame, sound below 35 fps plays slightly slow and pitched-down
48
49
  rather than breaking up — the worklet rate-matches instead of inserting
49
50
  gaps, and the stats line reports it (`audio 0.72x`). The page: 320×200
@@ -182,9 +183,9 @@ watchdog timeouts close to spec) will diverge — see docs/progress-and-future.m
182
183
  ## Architecture
183
184
 
184
185
  ```
185
- firmware .bin ──► Unicorn WASM CPU ──► memory hooks
186
+ firmware .bin ──► Rust WASM CPU (Thumb-2 + NVIC/exceptions)
186
187
  │
187
- periph_read/write ──► Rust peripheral model (WASM)
188
+ peripheral MMIO ──► Rust peripheral model (WASM)
188
189
  RCC USART GPIO DMA ETH TIM NVIC
189
190
  │
190
191
  UART out / ETH TX frames ──► driver (cli.mjs / site/emulator.js)
@@ -192,9 +193,10 @@ firmware .bin ──► Unicorn WASM CPU ──► memory hooks
192
193
  RX frames injected (netsim, or real gVisor gateway)
193
194
  ```
194
195
 
195
- - **CPU**: Unicorn 2.1.4 compiled to WASM executes Thumb-2 code; every
196
- read/write to a hooked MMIO range is routed into the Rust model, which
197
- answers by writing the modeled register value back into guest memory.
196
+ - **CPU**: a pure-Rust Cortex-M4 Thumb-2 interpreter compiled to WASM;
197
+ peripheral accesses call straight into the Rust model, and guest IRQs
198
+ (SysTick, ETH, USART, SVC/PendSV) are delivered inline with exact
199
+ exception stacking — no native deps, no JIT, no hooks.
198
200
  - **Peripherals**: a `wasm-bindgen` crate (`stm32-periph-wasm/`); registers
199
201
  and bit fields come from the vendor SVD (`monox/stm32f407.svd`).
200
202
  - **Ethernet**: TX is captured from the DMA descriptors; RX frames are
@@ -223,8 +225,8 @@ firmware .bin ──► Unicorn WASM CPU ──► memory hooks
223
225
  │ ├── test_blinky.mjs Node blinky GPIO test (npm test)
224
226
  │ ├── test_rx_interrupt.mjs Node UART-interrupt test (npm test)
225
227
  │ ├── test_component_*.mjs Component-API tests, one firmware each (npm test)
226
- │ ├── test_doom.mjs Node DOOM boot/menu/gameplay/save test
227
- │ └── vendor/ Browser WASM build, SVD, Unicorn
228
+ │ ├── test_doom_wasm.mjs Node DOOM boot/menu/gameplay/save test
229
+ │ └── vendor/ Browser WASM build (CPU + peripherals), SVD
228
230
  ├── index.mjs, package.json npm package entry (stm32f4-emu)
229
231
  ├── mcp/ MCP server (drive the emulator from an AI agent)
230
232
  ├── .github/workflows/ CI (Linux/Windows/macOS test matrix) + Pages deploy
@@ -296,6 +298,5 @@ rebuild — delete it so the vendor assets stay tracked/committed.
296
298
  ## License & Credits
297
299
 
298
300
  - **License**: GPL-3.0-only. See [LICENSE](LICENSE).
299
- - **Unicorn CPU Core**: Powered by [Unicorn.js](https://github.com/AlexAltea/unicorn.js) by [Alex Altea](https://github.com/AlexAltea) (WASM/JS port of the [Unicorn Engine](https://www.unicorn-engine.org/) CPU emulator, derived from QEMU, licensed under GPLv2).
300
301
  - **Heritage**: Fork and continuation of [nviennot/stm32-emulator](https://github.com/nviennot/stm32-emulator) (native SDL 3D printer emulator by Nicolas Viennot). The headless WASM peripheral model, networking stack, browser demo, virtual components API, MCP server, and npm package are new work built on that base.
301
302
  - **DOOM**: Ported using [doomgeneric](https://github.com/ozkl/doomgeneric) by Ozkan Sezgin.
package/cli.mjs CHANGED
@@ -10,15 +10,12 @@
10
10
  // emulator advances the virtual RTC until a wakeup source (e.g. an RTC alarm)
11
11
  // fires — so firmware that enters STOP (e.g. deep_sleep_demo.bin) runs.
12
12
  import { readFileSync } from 'node:fs';
13
- import { createRequire } from 'node:module';
14
13
  import { fileURLToPath } from 'node:url';
15
14
  import { dirname, resolve } from 'node:path';
16
15
  import * as bindings from './site/vendor/stm32_periph_wasm.js';
17
16
  import { createEmulator } from './site/emulator.js';
18
17
  import { parseIntelHex, parseElf } from './site/loaders.js';
19
18
 
20
- const require = createRequire(import.meta.url);
21
- const unicornFactory = require('./site/vendor/unicorn_arm.cjs');
22
19
  const __dirname = dirname(fileURLToPath(import.meta.url));
23
20
  const svdXml = readFileSync(resolve(__dirname, 'site/vendor/stm32f407.svd'), 'utf8');
24
21
  const wasmBytes = new Uint8Array(readFileSync(resolve(__dirname, 'site/vendor/stm32_periph_wasm_bg.wasm')));
@@ -35,7 +32,7 @@ Arguments:
35
32
  Options:
36
33
  -n, --inst <N> instruction budget to run (default 20000000)
37
34
  -f, --format <fmt> firmware format: auto|bin|hex|elf (default auto)
38
- -v, --verbose trace peripheral register reads/writes to stderr
35
+ -v, --verbose trace guest PCs to stderr (capped per step)
39
36
  -l, --lowpower halt on WFI/WFE and advance the virtual RTC until wakeup
40
37
  -h, --help show this help
41
38
  -V, --version show version
@@ -119,8 +116,8 @@ async function main() {
119
116
  let emu;
120
117
  try {
121
118
  emu = await createEmulator({
122
- firmware, bindings, unicorn: unicornFactory, svdXml, wasmInit: wasmBytes,
123
- extra_mem, verbose, lowpower,
119
+ firmware, bindings, svdXml, wasmInit: wasmBytes,
120
+ extra_mem, lowpower,
124
121
  });
125
122
  } catch (e) {
126
123
  fail(`emulator failed to load firmware: ${e.message}`);
@@ -132,7 +129,15 @@ async function main() {
132
129
  try {
133
130
  while (remaining > 0) {
134
131
  const take = Math.min(STEP, remaining);
132
+ if (verbose) { try { emu.traceStart(); } catch {} }
135
133
  const r = emu.step(take);
134
+ if (verbose) {
135
+ try {
136
+ const pcs = emu.takeTrace() || [];
137
+ const tail = pcs.slice(-8).map((p) => `0x${(p >>> 0).toString(16)}`).join(' ');
138
+ if (tail) process.stderr.write(`[trace]${tail}\n`);
139
+ } catch {} finally { try { emu.traceStop(); } catch {} }
140
+ }
136
141
  remaining -= (r.instCount - lastInst);
137
142
  lastInst = r.instCount;
138
143
  const u = emu.drainUart();
package/index.d.ts ADDED
@@ -0,0 +1,48 @@
1
+ /// <reference types="node" />
2
+
3
+ import { createEmulator, CreateEmulatorOpts, EmulatorHandle } from './site/emulator.js';
4
+
5
+ export * from './site/emulator.js';
6
+
7
+ export interface CreateSTM32F407Opts extends CreateEmulatorOpts {
8
+ /** A Uint8Array, or a key into the bundled FIRMWARES table. */
9
+ firmware: Uint8Array | string;
10
+ }
11
+
12
+ export function createSTM32F407(opts?: CreateSTM32F407Opts): Promise<EmulatorHandle>;
13
+ export function decodeFirmware(key: string): Uint8Array;
14
+ export function createNetSim(opts?: any): any;
15
+
16
+ export const FIRMWARES: Record<string, { bytes: string; [key: string]: unknown }>;
17
+ export const bindings: any;
18
+ export const unicornFactory: any;
19
+ export const svdXml: string;
20
+
21
+ // Component-attachment API (attach devices to an emulator handle).
22
+ export class LED {
23
+ constructor(emu: any, port: string, num: number, opts?: { activeLow?: boolean });
24
+ on(): void;
25
+ off(): void;
26
+ toggle(): void;
27
+ read(): boolean;
28
+ onChange(cb: (on: boolean) => void): void;
29
+ }
30
+ export class Button {
31
+ constructor(emu: any, port: string, num: number, opts?: { activeLow?: boolean });
32
+ press(): void;
33
+ release(): void;
34
+ read(): boolean;
35
+ on(evt: 'down' | 'up', cb: () => void): void;
36
+ }
37
+ export class Pwm {
38
+ constructor(emu: any, timer: string, channel?: number, opts?: { clockHz?: number });
39
+ setDuty(percent: number): void;
40
+ read(): number;
41
+ }
42
+ export class Potentiometer {
43
+ constructor(emu: any, peripheral: string, channel?: number, opts?: { min?: number; max?: number });
44
+ set(value: number): void;
45
+ }
46
+ export class I2cRegisterDevice {
47
+ constructor(emu: any, peripheral: string, opts?: any);
48
+ }
package/index.mjs CHANGED
@@ -1,8 +1,7 @@
1
1
  // stm32f4-emu — Node API entry.
2
2
  // Wraps the browser/Node-universal emulator.js with the bundled assets
3
- // (SVD, wasm bindings, Unicorn) so Node consumers get a one-call setup.
3
+ // (SVD, wasm bindings) so Node consumers get a one-call setup.
4
4
  import { readFileSync } from 'node:fs';
5
- import { createRequire } from 'node:module';
6
5
  import * as bindings from './site/vendor/stm32_periph_wasm.js';
7
6
  import { createEmulator } from './site/emulator.js';
8
7
  import { createNetSim } from './site/netsim.js';
@@ -14,23 +13,19 @@ import { STM32F4, GPIOPin, USART, DMAStream } from './site/stm32f4.js';
14
13
  // `firmware` option is optional (defer to loadBin/loadHex/loadELF after
15
14
  // create), matching the rp2040js / avr8js ergonomics.
16
15
  STM32F4.create = (opts = {}) => STM32F4._create({
17
- bindings, unicorn: unicornFactory, svdXml, wasmInit: wasmBytes, ...opts,
16
+ bindings, svdXml, wasmInit: wasmBytes, ...opts,
18
17
  });
19
18
 
20
- const require = createRequire(import.meta.url);
21
- const unicornFactory = require('./site/vendor/unicorn_arm.cjs');
22
-
23
19
  const svdXml = readFileSync(new URL('./site/vendor/stm32f407.svd', import.meta.url), 'utf8');
24
20
  const wasmBytes = new Uint8Array(readFileSync(new URL('./site/vendor/stm32_periph_wasm_bg.wasm', import.meta.url)));
25
21
 
26
22
  // Decode a base64-encoded firmware from FIRMWARES.
23
+ // Uses single-pass Uint8Array.from for fewer allocations than manual loop.
27
24
  export function decodeFirmware(key) {
28
25
  const fw = FIRMWARES[key];
29
26
  if (!fw) throw new Error(`unknown firmware '${key}' (have: ${Object.keys(FIRMWARES).join(', ')})`);
30
27
  const bin = atob(fw.bytes);
31
- const out = new Uint8Array(bin.length);
32
- for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
33
- return out;
28
+ return Uint8Array.from(bin, c => c.charCodeAt(0));
34
29
  }
35
30
 
36
31
  // Convenience: create an emulator with the bundled STM32F407 assets.
@@ -41,7 +36,7 @@ export async function createSTM32F407(opts = {}) {
41
36
  const { firmware } = opts;
42
37
  const bin = typeof firmware === 'string' ? decodeFirmware(firmware) : firmware;
43
38
  if (!bin) throw new Error('createSTM32F407 requires `firmware` (Uint8Array or a FIRMWARES key)');
44
- return createEmulator({ ...opts, firmware: bin, bindings, unicorn: unicornFactory, svdXml, wasmInit: wasmBytes });
39
+ return createEmulator({ ...opts, firmware: bin, bindings, svdXml, wasmInit: wasmBytes });
45
40
  }
46
41
 
47
- export { createEmulator, createNetSim, FIRMWARES, bindings, unicornFactory, svdXml, LED, Button, Pwm, I2cRegisterDevice, Potentiometer, STM32F4, GPIOPin, USART, DMAStream };
42
+ export { createEmulator, createNetSim, FIRMWARES, bindings, svdXml, LED, Button, Pwm, I2cRegisterDevice, Potentiometer, STM32F4, GPIOPin, USART, DMAStream };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "stm32f4-emu",
3
- "version": "1.0.0",
4
- "description": "STM32F407 emulator: Unicorn (WASM) CPU + Rust peripheral model. Runs real Cortex-M4 firmware in Node or the browser.",
3
+ "version": "1.1.1",
4
+ "description": "STM32F407 emulator: Rust CPU + peripheral model (WASM). Runs real Cortex-M4 firmware in Node or the browser.",
5
5
  "type": "module",
6
6
  "main": "index.mjs",
7
7
  "bin": {
@@ -9,6 +9,7 @@
9
9
  "stm32f4-mcp": "./mcp/server.mjs",
10
10
  "stm32f4-bridge": "./site/ws-bridge.mjs"
11
11
  },
12
+ "types": "index.d.ts",
12
13
  "exports": {
13
14
  ".": "./index.mjs",
14
15
  "./components": "./site/components.js",
@@ -18,26 +19,29 @@
18
19
  "./loaders": "./site/loaders.js",
19
20
  "./stm32f4": "./site/stm32f4.js",
20
21
  "./vendor": "./site/vendor/stm32_periph_wasm.js",
21
- "./vendor/unicorn": "./site/vendor/unicorn_arm.cjs",
22
22
  "./remote": "./site/remote-emu.js",
23
- "./ws-bridge": "./site/ws-bridge.mjs",
24
- "./site": "./site/index.html"
23
+ "./ws-bridge": "./site/ws-bridge.mjs"
25
24
  },
26
25
  "files": [
27
26
  "index.mjs",
27
+ "index.d.ts",
28
28
  "cli.mjs",
29
29
  "README.md",
30
+ "LICENSE",
30
31
  "site/emulator.js",
32
+ "site/emulator.d.ts",
33
+ "site/stm32f4.js",
31
34
  "site/components.js",
32
- "mcp/server.mjs",
33
35
  "site/netsim.js",
34
36
  "site/firmware.js",
35
37
  "site/loaders.js",
36
- "site/index.html",
37
- "site/app.js",
38
38
  "site/remote-emu.js",
39
39
  "site/ws-bridge.mjs",
40
- "site/vendor/"
40
+ "site/app.js",
41
+ "site/index.html",
42
+ "site/vendor/",
43
+ "mcp/server.mjs",
44
+ "tools/make_firmware.mjs"
41
45
  ],
42
46
  "keywords": [
43
47
  "stm32",
@@ -45,7 +49,6 @@
45
49
  "emulator",
46
50
  "arm",
47
51
  "cortex-m4",
48
- "unicorn",
49
52
  "wasm",
50
53
  "embedded",
51
54
  "firmware"
@@ -63,7 +66,7 @@
63
66
  "node": ">=20"
64
67
  },
65
68
  "scripts": {
66
- "test": "node site/test_stm32f4_api.mjs && node site/test_stm32f4_periph.mjs && node site/test_ws_bridge.mjs && node site/test_flow.mjs && node site/test_blinky.mjs && node site/test_rx_interrupt.mjs && node site/test_component_led.mjs && node site/test_component_button.mjs && node site/test_component_pwm.mjs && node site/test_component_i2cregfile.mjs && node site/test_component_adc.mjs && node site/test_multi_instance.mjs && node site/test_fsmc_dcmi.mjs && node site/test_fsmc.mjs && node site/test_dcmi.mjs && node site/probe_freertos.mjs && node site/test_candemo.mjs && node site/test_lowpower.mjs && node site/test_edge_cases.mjs && node site/test_can_inject.mjs && node site/test_watchdog.mjs && node site/test_wwdg.mjs && node site/test_wwdg_window.mjs && node site/test_tim_capture.mjs && node site/test_qspi.mjs && node site/test_qspi_cdp.mjs && node site/test_browser.mjs",
69
+ "test": "node site/test_stm32f4_api.mjs && node site/test_stm32f4_periph.mjs && node site/test_ws_bridge.mjs && node site/test_flow.mjs && node site/test_blinky.mjs && node site/test_rx_interrupt.mjs && node site/test_component_led.mjs && node site/test_component_button.mjs && node site/test_component_pwm.mjs && node site/test_component_i2cregfile.mjs && node site/test_component_adc.mjs && node site/test_multi_instance.mjs && node site/test_fsmc_dcmi.mjs && node site/test_fsmc.mjs && node site/test_dcmi.mjs && node site/probe_freertos.mjs && node site/test_candemo.mjs && node site/test_lowpower.mjs && node site/test_edge_cases.mjs && node site/test_can_inject.mjs && node site/test_watchdog.mjs && node site/test_wwdg.mjs && node site/test_wwdg_window.mjs && node site/test_tim_capture.mjs && node site/test_qspi.mjs && node site/test_fpu.mjs && node site/test_fpu_irq.mjs && node site/test_mpu.mjs && node site/test_usb.mjs && node site/test_qspi_cdp.mjs && node site/test_browser.mjs",
67
70
  "test:unit": "node site/test_stm32f4_api.mjs && node site/test_stm32f4_periph.mjs && node site/test_ws_bridge.mjs && node site/test_flow.mjs && node site/test_blinky.mjs && node site/test_rx_interrupt.mjs && node site/test_component_led.mjs && node site/test_component_button.mjs && node site/test_component_pwm.mjs && node site/test_component_i2cregfile.mjs && node site/test_component_adc.mjs && node site/test_multi_instance.mjs && node site/test_fsmc_dcmi.mjs && node site/test_fsmc.mjs && node site/test_dcmi.mjs && node site/probe_freertos.mjs && node site/test_candemo.mjs && node site/test_lowpower.mjs && node site/test_edge_cases.mjs && node site/test_can_inject.mjs && node site/test_watchdog.mjs && node site/test_wwdg.mjs && node site/test_wwdg_window.mjs && node site/test_tim_capture.mjs && node site/test_qspi.mjs",
68
71
  "test:bridge": "node site/test_ws_bridge.mjs",
69
72
  "test:bridge:browser": "node site/test_bridge_cdp.mjs",
@@ -79,6 +82,9 @@
79
82
  "test:timcap": "node site/test_tim_capture.mjs",
80
83
  "test:lowpower": "node site/test_lowpower.mjs",
81
84
  "test:qspi": "node site/test_qspi.mjs",
85
+ "test:fpu": "node site/test_fpu.mjs",
86
+ "test:fpuirq": "node site/test_fpu_irq.mjs",
87
+ "test:mpu": "node site/test_mpu.mjs",
82
88
  "test:qspi:browser": "node site/test_qspi_cdp.mjs",
83
89
  "test:browser": "node site/test_browser.mjs",
84
90
  "test:edge": "node site/test_edge_cases.mjs",
@@ -89,7 +95,15 @@
89
95
  "bridge": "node site/ws-bridge.mjs",
90
96
  "firmwares": "node tools/make_firmware.mjs",
91
97
  "serve": "python3 -m http.server 8123 --directory site",
92
- "prepack": "node tools/make_firmware.mjs"
98
+ "prepack": "node tools/make_firmware.mjs",
99
+ "typecheck": "tsc --noEmit",
100
+ "lint": "eslint site mcp tools index.mjs cli.mjs",
101
+ "format": "prettier --check .",
102
+ "format:write": "prettier --write .",
103
+ "build:wasm": "wasm-pack build stm32-periph-wasm --target web --out-dir ../site/vendor && rm -f site/vendor/.gitignore && cp monox/stm32f407.svd site/vendor/ 2>/dev/null || true; command -v wasm-opt >/dev/null 2>&1 && wasm-opt -O3 site/vendor/stm32_periph_wasm_bg.wasm -o site/vendor/stm32_periph_wasm_bg.wasm || echo 'wasm-opt not found, skipping'",
104
+ "build:wasm:node": "wasm-pack build stm32-periph-wasm --target nodejs --out-dir pkg",
105
+ "build:wasm:opt": "wasm-opt -O3 site/vendor/stm32_periph_wasm_bg.wasm -o site/vendor/stm32_periph_wasm_bg.wasm",
106
+ "test:wasm": "node site/test_wasm_cpu.mjs && node site/test_flow_wasm.mjs && node site/test_audio_wasm.mjs && node site/test_wasm_multi.mjs && node site/test_lowpower_wasm.mjs && node site/probe_freertos.mjs && node site/test_doom_wasm.mjs"
93
107
  },
94
108
  "dependencies": {
95
109
  "ws": "^8.21.3"
@@ -109,6 +123,9 @@
109
123
  "devDependencies": {
110
124
  "@modelcontextprotocol/sdk": "^1.30.0",
111
125
  "playwright": "^1.62.1",
112
- "zod": "^3.25 || ^4.0"
126
+ "zod": "^3.25 || ^4.0",
127
+ "typescript": "^5.6.0",
128
+ "eslint": "^9.0.0",
129
+ "prettier": "^3.0.0"
113
130
  }
114
131
  }
package/site/app.js CHANGED
@@ -2,10 +2,11 @@
2
2
  // Preset + custom (.bin/.hex/.elf/.map) firmware loading, Run/Stop/Reset,
3
3
  // an optional WebSocket gateway (real network stack) with a netsim fallback,
4
4
  // live UART terminal, GPIO/peripheral register readout, and packet viewer.
5
- import * as bindings from './vendor/stm32_periph_wasm.js';
5
+ import * as bindings from './vendor/stm32_periph_wasm.js?v=13';
6
6
  import { createEmulator } from './emulator.js';
7
7
  import { createNetSim } from './netsim.js';
8
- import { FIRMWARES } from './firmware.js';
8
+ import { createUsbHost } from './usbhost.js';
9
+ import { FIRMWARES } from './firmware.js?v=9';
9
10
  import { parseIntelHex, parseElf, parseMap } from './loaders.js';
10
11
  import { createRemoteEmulator } from './remote-emu.js';
11
12
 
@@ -13,22 +14,19 @@ const $ = (id) => document.getElementById(id);
13
14
  const uartEl = $('uart'), statusEl = $('statusText'), dotEl = $('dot');
14
15
  const framesEl = $('frames');
15
16
 
16
- const decodeB64 = (b64) => {
17
- const bin = atob(b64);
18
- const u = new Uint8Array(bin.length);
19
- for (let i = 0; i < bin.length; i++) u[i] = bin.charCodeAt(i);
20
- return u;
21
- };
17
+ const decodeB64 = (b64) => Uint8Array.from(atob(b64), c => c.charCodeAt(0));
18
+ const HEX_TABLE = Array.from({ length: 256 }, (_, i) => i.toString(16).padStart(2, '0'));
22
19
  const hex = (arr, n = 32) => {
23
20
  let s = '';
24
- for (let i = 0; i < Math.min(arr.length, n); i++) s += arr[i].toString(16).padStart(2, '0') + ' ';
21
+ const len = Math.min(arr.length, n);
22
+ for (let i = 0; i < len; i++) s += HEX_TABLE[arr[i]] + ' ';
25
23
  if (arr.length > n) s += '…';
26
24
  return s.trim();
27
25
  };
28
26
  const hex32 = (v) => '0x' + (v >>> 0).toString(16).padStart(8, '0');
29
27
 
30
28
  let session = 0;
31
- let emu = null, netsim = null, running = false;
29
+ let emu = null, netsim = null, usbhost = null, running = false;
32
30
  let uartBuf = '', totalInst = 0, t0 = performance.now(), lastInst = 0, lastT = t0;
33
31
  let stepsDone = 0;
34
32
  let image = null; // { flash, ram, extraMem, entry, symbols, name, uartAddr }
@@ -46,12 +44,12 @@ const ETH_RX_MAP = {
46
44
  // Interrupt-driven firmware: the emulator pumps guest IRQ handlers (USART RXNE
47
45
  // etc.). OFF for ETH firmware — the driver signals completion via SRAM
48
46
  // irq_flag and the guest ETH_IRQHandler would double-process DMASR/rx_desc.
49
- const IRQ_FIRMWARES = new Set(['rx_interrupt_test', 'rx_crypto_test', 'comprehensive_test', 'eth_irq_test']);
47
+ const IRQ_FIRMWARES = new Set(['rx_interrupt_test', 'rx_crypto_test', 'comprehensive_test', 'eth_irq_test', 'edge_test', 'periph_test', 'fpu_irq_test', 'mpu_test']);
50
48
 
51
49
  // Interrupt-driven ETH firmware: the guest ETH_IRQHandler (run by the pump)
52
50
  // reads DMASR and scans rx_desc itself, so the driver must not write the
53
51
  // SRAM irq_flag/rx_frame_idx globals (irq_eth mode in emulator.js).
54
- const IRQ_ETH_FIRMWARES = new Set(['eth_irq_test']);
52
+ const IRQ_ETH_FIRMWARES = new Set(['eth_irq_test', 'eth_dhcp', 'eth_test']);
55
53
 
56
54
  // Firmwares that exercise the WFI/STOP low-power path: the emulator halts the
57
55
  // core on WFI and advances the virtual RTC until an alarm/interrupt wakes it.
@@ -75,7 +73,38 @@ const DEVICE_FIRMWARES = {
75
73
  audio_play_test: { speaker: true },
76
74
  rtc_test: { rtc: { i2c: 'I2C1', addr: 0x68, init: RTC_INIT } },
77
75
  qspi_test: { qspi: [{ peripheral: 'QUADSPI', size: 256 }] },
78
- };
76
+ // new_periph_test exercises DCMI polled capture (DR increments + FNE),
77
+ // which needs a sensor frame — same idea as the audio_test WAV seed.
78
+ // Ramp pixels so consecutive DR reads differ.
79
+ new_periph_test: { camera: { width: 64, height: 48, pixels: Uint8Array.from({ length: 64 * 48 }, (_, i) => i & 0xFF) } },
80
+ deep_periph_test: { camera: { width: 64, height: 48, pixels: Uint8Array.from({ length: 64 * 48 }, (_, i) => i & 0xFF) } },
81
+ // periph_test talks to an I2C EEPROM at 0x50 (write/readback + blank
82
+ // check at addr 0). Blank image; the model handles the R/W protocol.
83
+ periph_test: { i2c_eeprom: [{ peripheral: 'I2C1', address: 0x50, data: new Uint8Array(256).fill(0xFF) }] },
84
+ edge_test: { i2c_eeprom: [{ peripheral: 'I2C1', address: 0x50, data: new Uint8Array(256).fill(0xFF) }], spi_flash: [{ peripheral: 'SPI3', jedec_id: 0xEF4016, size: 0x200000, cs: null, data: new Uint8Array(0x200000).fill(0xFF) }] },
85
+ // spi_tft_test talks to an SPI NOR flash on SPI3 (software CS PB12).
86
+ // Without it JEDEC/status reads return 0xFF and the checks FAIL.
87
+ // Blank 2 MB image, same shape as site/test_spi_flash.mjs.
88
+ spi_tft_test: { spi_flash: [{ peripheral: 'SPI3', jedec_id: 0xEF4015, size: 0x200000, cs: 'PB12', data: new Uint8Array(0x200000).fill(0xFF) }] },
89
+ };
90
+
91
+ // audio_test needs the same 64-sample PCM16 WAV the node harness
92
+ // (site/test_audio.mjs) loads via audio_load_wav — without it the I2S DMA
93
+ // reads fall back to generated audio and the firmware's checksum FAILs.
94
+ function makeAudioTestWav() {
95
+ const N = 64, dataLen = N * 2;
96
+ const w = new Uint8Array(44 + dataLen);
97
+ const dv = new DataView(w.buffer);
98
+ const wstr = (o, s) => { for (let i = 0; i < s.length; i++) w[o + i] = s.charCodeAt(i); };
99
+ wstr(0, 'RIFF'); dv.setUint32(4, 36 + dataLen, true); wstr(8, 'WAVE');
100
+ wstr(12, 'fmt '); dv.setUint32(16, 16, true);
101
+ dv.setUint16(20, 1, true); dv.setUint16(22, 1, true);
102
+ dv.setUint32(24, 44100, true); dv.setUint32(28, 44100 * 2, true);
103
+ dv.setUint16(32, 2, true); dv.setUint16(34, 16, true);
104
+ wstr(36, 'data'); dv.setUint32(40, dataLen, true);
105
+ for (let i = 0; i < N; i++) dv.setInt16(44 + i * 2, (i * 300 + 7) & 0xFFFF, true);
106
+ return w;
107
+ }
79
108
 
80
109
  // ── status + UART ──────────────────────────────────────────────────────────
81
110
  const setStatus = (text, cls) => {
@@ -83,10 +112,25 @@ const setStatus = (text, cls) => {
83
112
  dotEl.className = 'dot ' + cls;
84
113
  };
85
114
 
115
+ let uartChunks = [], uartLen = 0;
86
116
  const appendUart = (chunk) => {
87
117
  if (!chunk) return;
88
- uartBuf += chunk;
89
- if (uartBuf.length > 200000) uartBuf = uartBuf.slice(-200000);
118
+ uartChunks.push(chunk);
119
+ uartLen += chunk.length;
120
+ // Keep last 200k chars without repeatedly copying 200k string (old: uartBuf+=chunk; slice)
121
+ while (uartLen > 200000) {
122
+ const first = uartChunks[0];
123
+ const excess = uartLen - 200000;
124
+ if (first.length <= excess) {
125
+ uartLen -= first.length;
126
+ uartChunks.shift();
127
+ } else {
128
+ uartChunks[0] = first.slice(excess);
129
+ uartLen = 200000;
130
+ break;
131
+ }
132
+ }
133
+ uartBuf = uartChunks.join('');
90
134
  const wasAtBottom = uartEl.scrollTop + uartEl.clientHeight >= uartEl.scrollHeight - 8;
91
135
  uartEl.textContent = uartBuf;
92
136
  if (wasAtBottom && $('chkAuto').checked) uartEl.scrollTop = uartEl.scrollHeight;
@@ -202,7 +246,7 @@ $('btnBoot').addEventListener('click', async () => {
202
246
  setBusy(false);
203
247
  $('btnRun').textContent = 'Run';
204
248
  setStatus('loading firmware…', 'stop');
205
- uartEl.textContent = uartBuf = '';
249
+ uartEl.textContent = uartBuf = ''; uartChunks = []; uartLen = 0;
206
250
  framesEl.textContent = '';
207
251
  totalInst = 0; stepsDone = 0;
208
252
  t0 = lastT = performance.now(); lastInst = 0;
@@ -273,7 +317,7 @@ const boot = async () => {
273
317
  if (audioCtx) { try { audioCtx.close(); } catch (e) {} audioCtx = null; audioQueued = 0; }
274
318
 
275
319
  const fw = image.flash;
276
- uartEl.textContent = uartBuf = '';
320
+ uartEl.textContent = uartBuf = ''; uartChunks = []; uartLen = 0;
277
321
  framesEl.textContent = '';
278
322
  totalInst = 0; stepsDone = 0;
279
323
  t0 = lastT = performance.now(); lastInst = 0;
@@ -282,6 +326,7 @@ const boot = async () => {
282
326
  if (bridgeUrl) {
283
327
  // ── remote mode: Node runs the emulator, browser is a thin client ──
284
328
  netsim = null;
329
+ usbhost = null;
285
330
  gw.tx = 0; gw.rx = 0;
286
331
  try {
287
332
  emu = await createRemoteEmulator(bridgeUrl, {
@@ -323,17 +368,21 @@ const boot = async () => {
323
368
  }
324
369
  } else {
325
370
  // ── local mode: WASM runs in the browser (default) ──
326
- const svdXml = await fetch('vendor/stm32f407.svd').then((r) => r.text());
371
+ // NOTE (VENDOR_V): vendor asset versions (?v=13) must be bumped together
372
+ // after every wasm-pack rebuild, or browsers keep the stale model.
373
+ const svdXml = await fetch('vendor/stm32f407.svd?v=13').then((r) => r.text());
327
374
  if (id !== session) return;
328
375
 
329
376
  netsim = gw.connected ? null : createNetSim();
377
+ // Scripted USB host when the demo preset boots locally.
378
+ usbhost = (!bridgeUrl && image.name === 'usb_cdc_test') ? createUsbHost(bindings) : null;
330
379
  gw.tx = 0; gw.rx = 0;
331
380
  if (gw.connected) setGwStatus(true, gwLabel());
332
381
  emu = await createEmulator({
333
382
  firmware: fw,
334
383
  bindings,
335
- unicorn: MUnicorn,
336
384
  svdXml,
385
+ wasmUrl: 'vendor/stm32_periph_wasm_bg.wasm?v=13',
337
386
  extra_mem: image.extraMem,
338
387
  uart_addr: image.uartAddr,
339
388
  enable_irqs: IRQ_FIRMWARES.has(image.name),
@@ -356,9 +405,25 @@ const boot = async () => {
356
405
  },
357
406
  });
358
407
  if (id !== session) { emu.close(); return; }
408
+ // Seed the model's audio source for audio_test (see makeAudioTestWav).
409
+ if (image.name === 'audio_test' && bindings.audio_load_wav) {
410
+ try { bindings.audio_load_wav(makeAudioTestWav()); } catch (e) {}
411
+ }
412
+ // Pre-feed one camera frame so DCMI capture has sensor data from the
413
+ // very first step (the firmware reads DR immediately after setting
414
+ // CAPTURE, before any per-step feed runs).
415
+ if ((image.name === 'new_periph_test' || image.name === 'deep_periph_test') && emu.camera) {
416
+ try {
417
+ const cam = DEVICE_FIRMWARES[image.name].camera;
418
+ emu.camera.feed(cam.width, cam.height, cam.pixels);
419
+ } catch (e) {}
420
+ }
359
421
  }
360
422
 
361
- appendUart(`── booted ${image.name} ${bridgeUrl ? '(bridge ' + bridgeUrl + ')' : gw.connected ? '(gateway)' : '(netsim)'} ──\r\n`);
423
+ const bootTags = bridgeUrl
424
+ ? `(bridge ${bridgeUrl})`
425
+ : (gw.connected ? '(gateway)' : '(netsim)');
426
+ appendUart(`── booted ${image.name} ${bootTags} ──\r\n`);
362
427
  window.__emu = emu; // debug handle (CDP smoke tests)
363
428
  window.__bindings = bindings;
364
429
  running = true;
@@ -382,8 +447,13 @@ const loop = async (id) => {
382
447
  return;
383
448
  }
384
449
  appendUart(emu.drainUart());
385
- refreshStats();
386
- renderLtdc();
450
+ // Scripted USB host for the usb_cdc_test preset (local mode only —
451
+ // USB has no gateway backend; this is the netsim equivalent).
452
+ try {
453
+ if (usbhost && !usbhost.done) usbhost.frame(uartBuf);
454
+ } catch (e) {}
455
+ await refreshStats();
456
+ await renderLtdc();
387
457
  renderDevices();
388
458
  await raf();
389
459
  }
@@ -414,7 +484,7 @@ $('btnReset').addEventListener('click', () => {
414
484
  }
415
485
  if (image) boot();
416
486
  });
417
- $('btnClear').addEventListener('click', () => { uartEl.textContent = uartBuf = ''; });
487
+ $('btnClear').addEventListener('click', () => { uartEl.textContent = uartBuf = ''; uartChunks = []; uartLen = 0; });
418
488
  $('btnGw').addEventListener('click', connectGateway);
419
489
 
420
490
  const sendRx = (term) => {
@@ -584,18 +654,22 @@ let ltdcCtx = ltdcCanvas.getContext('2d');
584
654
  let ltdcCacheKey = '';
585
655
  let ltdcOff = document.createElement('canvas');
586
656
 
587
- const renderLtdc = () => {
657
+ const renderLtdc = async () => {
588
658
  if (!emu || !ltdcCanvas.width) return;
589
659
  try {
590
- const gcr = emu.read32(LTDC + 0x18);
591
- const l1cr = emu.read32(LTDC + 0x84);
592
- if (!(gcr & 1) || !(l1cr & 1)) { ltdcInfo.textContent = 'scanout idle'; return; }
593
- const pf = emu.read32(LTDC + 0x94) & 7;
594
- const cfbar = emu.read32(LTDC + 0xAC) >>> 0;
595
- const cfblr = emu.read32(LTDC + 0xB0) >>> 0;
596
- const cfblnr = emu.read32(LTDC + 0xB4) & 0x7FF;
597
- const whpcr = emu.read32(LTDC + 0x88) >>> 0;
598
- const wvpcr = emu.read32(LTDC + 0x8C) >>> 0;
660
+ const [gcr, l1cr, pfRaw, cfbarRaw, cfblrRaw, cfblnrRaw, whpcrRaw, wvpcrRaw] = await Promise.all([
661
+ emu.read32(LTDC + 0x18), emu.read32(LTDC + 0x84), emu.read32(LTDC + 0x94),
662
+ emu.read32(LTDC + 0xAC), emu.read32(LTDC + 0xB0), emu.read32(LTDC + 0xB4),
663
+ emu.read32(LTDC + 0x88), emu.read32(LTDC + 0x8C),
664
+ ]);
665
+ const gcrV = gcr >>> 0, l1crV = l1cr >>> 0;
666
+ if (!(gcrV & 1) || !(l1crV & 1)) { ltdcInfo.textContent = 'scanout idle'; return; }
667
+ const pf = pfRaw & 7;
668
+ const cfbar = cfbarRaw >>> 0;
669
+ const cfblr = cfblrRaw >>> 0;
670
+ const cfblnr = cfblnrRaw & 0x7FF;
671
+ const whpcr = whpcrRaw >>> 0;
672
+ const wvpcr = wvpcrRaw >>> 0;
599
673
  const bpp = pf === 2 ? 2 : 4;
600
674
  const lineBytes = Math.min(cfblr & 0x1FFF, 640 * bpp);
601
675
  const w = Math.min((((whpcr & 0xFFF) + 1) || (lineBytes / bpp)) | 0, 640);
@@ -609,6 +683,11 @@ const renderLtdc = () => {
609
683
  const key = pf + ':' + w + 'x' + lines + ':' + cfbar + ':' + pitch + ':' + (bindings.ltdc_get_frame_count ? bindings.ltdc_get_frame_count() : 0);
610
684
  if (key === ltdcCacheKey) return;
611
685
  ltdcCacheKey = key;
686
+ // Bridge mode has no direct uc.mem_read — skip framebuffer blit
687
+ if (!emu.uc || typeof emu.uc.mem_read !== 'function') {
688
+ ltdcInfo.textContent = `layer0 ${w}×${lines} ${pf === 2 ? 'RGB565' : 'ARGB8888'} @ ${hex32(cfbar)} (bridge — no fb read)`;
689
+ return;
690
+ }
612
691
  // Read (only the used width bytes per line — the model guest RAM is
613
692
  // byte-exact, so no opacity/color-key handling beyond the format).
614
693
  const rowBytes = Math.min(w * bpp, pitch || w * bpp);