stm32f4-emu 0.1.0

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.
@@ -0,0 +1,885 @@
1
+ // Universal STM32F407 emulator factory — runs in Node and the browser.
2
+ // No imports: the caller supplies the bindings (wasm-bindgen module),
3
+ // the Unicorn factory (MUnicorn), the SVD XML, and the firmware bytes.
4
+ //
5
+ // Default eth_* constants match eth_http/eth_http.elf (nm-verified):
6
+ // irq_flag 0x20000620, rx_frame_idx 0x20000628, rx_frame_len 0x2000062c,
7
+ // tx_desc 0x20000610, rx_desc 0x20000630, tx_pkt 0x20000008, rx_buf 0x20000660
8
+
9
+ export async function createEmulator(opts) {
10
+ const {
11
+ firmware,
12
+ bindings, // wasm-bindgen module (web or nodejs build)
13
+ unicorn, // async factory -> Unicorn module
14
+ svdXml, // SVD XML string for init_svd
15
+ wasmInit, // optional: wasm bytes for bindings.default() (Node)
16
+ flash_size = 0x100000,
17
+ ram_size = 0x20000,
18
+ vector_table = 0x08000000,
19
+ tickEvery = 5000, // tick_n batching cadence
20
+ pollEvery = 1000, // DMA/TX-poll check cadence
21
+ // Skip the per-instruction poll/tick wasm calls entirely (no ETH/DMA/
22
+ // timers/watchdog needed — the doom firmware). InstCount++ alone keeps
23
+ // the instruction accounting; the guest's own clock paces the loop.
24
+ minimalPolls = false,
25
+ // doom fast path: HOOK_BLOCK instead of HOOK_CODE (size/2 inst per
26
+ // block). Only valid with minimalPolls (no tick/poll logic to run).
27
+ blockCounting = false,
28
+ // Fastest path: register NO counting hook at all. Every basic block
29
+ // otherwise crosses the WASM->JS boundary just to bump a counter,
30
+ // which is pure overhead for a firmware (like doom) that paces off
31
+ // its own frame counter rather than instCount. `instCount` then
32
+ // tracks the emu_start budget issued per step() — which is actually
33
+ // MORE accurate than block counting (that over-reports ~1.39x, see
34
+ // AGENTS §16). Requires minimalPolls; overrides blockCounting.
35
+ noCountHook = false,
36
+ // Escape hatch: force the old per-INSTRUCTION HOOK_CODE path. The
37
+ // default is now a per-BLOCK hook doing the same accounting (~16%
38
+ // faster — the crossing, not the work, is what costs). Set this if a
39
+ // firmware genuinely needs instruction-exact instCount or stops.
40
+ perInstHook = false,
41
+ maxBatch = 100000, // emu_start instruction budget per step()
42
+ onTx = null, // (frame: Uint8Array, meta) called per TX capture
43
+ eth = {}, // firmware-specific SRAM addresses (defaults above)
44
+ ext_devices = {},
45
+ extra_ram = [], // [{addr, size}] plain RAM regions mapped before
46
+ // extra_mem preload (e.g. FSMC SDRAM, WAD ROM at
47
+ // 0xB8000000 for the doom firmware)
48
+ extra_mem = [], // [{addr, data}] preloaded into mapped memory (ELF RAM segments)
49
+ uart_addr = 0x40011000, // USART base for uart_rx_byte injection (UART4 = 0x40004C00)
50
+ // Run guest IRQ handlers (USART RXNE etc.) between batches. OFF by
51
+ // default: the ETH firmware is driven by writing irq_flag in SRAM and
52
+ // is corrupted if the guest ETH_IRQHandler also runs (it re-reads
53
+ // DMASR and re-scans rx_desc, stomping rx_frame_idx/len). Enable only
54
+ // for interrupt-driven firmware (rx_interrupt_test, rx_crypto_test).
55
+ enable_irqs = false,
56
+ // Interrupt-driven ETH firmware: the guest ETH_IRQHandler (run by the
57
+ // pump) reads DMASR and scans rx_desc itself, so the driver must NOT
58
+ // write the SRAM irq_flag / rx_frame_idx / rx_frame_len globals that
59
+ // the polling ETH firmware (eth_http) expects. Requires enable_irqs.
60
+ irq_eth = false,
61
+ } = opts;
62
+
63
+ const {
64
+ periph_read, periph_write, tick, tick_n, get_uart_output,
65
+ dma_get_pending_count, dma_get_pending, dma_set_completed,
66
+ dma_periph_read, dma_periph_write,
67
+ is_watchdog_reset_requested, add_spi_flash, add_i2c_eeprom, init_svd,
68
+ eth_is_tx_poll, eth_get_tx_desc_addr, eth_clear_tx_poll,
69
+ eth_is_rx_poll, eth_clear_rx_poll, eth_tx_done, eth_rx_done,
70
+ get_next_pending_interrupt, uart_rx_byte,
71
+ flash_is_programming, flash_take_erase, flash_erase_applied,
72
+ spi_tap, spi_take_events, spi_push_miso,
73
+ fsmc_tap, fsmc_take_events, fsmc_push_data,
74
+ dcmi_feed_frame, dcmi_clear,
75
+ i2c_register_slave, i2c_take_events, i2c_push_rx,
76
+ i2c_register_regfile, i2c_regfile_get, i2c_regfile_set,
77
+ audio_take_capture,
78
+ gpio_read_output, gpio_read_input, gpio_set_input,
79
+ adc_set_channel_value, adc_clear_channel_value,
80
+ } = bindings;
81
+
82
+ const E = {
83
+ txDesc: 0x20000610, rxDesc: 0x20000630, txPkt: 0x20000008,
84
+ rxBuf: 0x20000660, rxStride: 1536, irqFlag: 0x20000620,
85
+ rxFrameIdx: 0x20000628, rxFrameLen: 0x2000062c, // SRAM addrs of globals
86
+ rxInjectIdx: 0, // desc index to inject at
87
+ rxDescs: 4, // number of RX descriptors
88
+ ...eth,
89
+ };
90
+
91
+ const Module = await unicorn();
92
+ if (typeof bindings.default === 'function') {
93
+ if (wasmInit) await bindings.default({ module_or_path: wasmInit });
94
+ else await bindings.default();
95
+ }
96
+ // Clear process-lifetime wasm globals before registering THIS instance's
97
+ // devices. ExtDevices is a module-level list and the peripheral
98
+ // constructors bind by first-match, so without this a second
99
+ // createEmulator() in the same process silently attaches to the FIRST
100
+ // instance's devices (measured: a regfile seeded 0x22 read back 0x11).
101
+ // Older wasm bundles may not export it, hence the guard.
102
+ if (typeof bindings.reset_state === 'function') bindings.reset_state();
103
+ for (const cfg of (ext_devices.spi_flash || [])) {
104
+ add_spi_flash(cfg.peripheral, cfg.jedec_id, cfg.data, cfg.cs ?? null);
105
+ }
106
+ for (const cfg of (ext_devices.i2c_eeprom || [])) {
107
+ add_i2c_eeprom(cfg.peripheral, cfg.address, cfg.data);
108
+ }
109
+ // Register-file I2C devices (DS3231 RTC). init is a &[u8] snapshot of the
110
+ // register file: BCD time regs 0x00-0x06, temp MSB/LSB 0x11/0x12. The
111
+ // `rtc` device config is shorthand for a regfile at 0x68 that also
112
+ // enables the emu.rtc time/temp decoder.
113
+ const regfileCfg = [
114
+ ...(ext_devices.regfile || []),
115
+ ...(ext_devices.rtc ? [{
116
+ peripheral: ext_devices.rtc.i2c || 'I2C1',
117
+ address: ext_devices.rtc.addr || 0x68,
118
+ size: ext_devices.rtc.size || 20,
119
+ init: ext_devices.rtc.init || [],
120
+ }] : []),
121
+ ];
122
+ for (const cfg of regfileCfg) {
123
+ i2c_register_regfile(
124
+ cfg.peripheral || 'I2C1', cfg.address || 0x68, cfg.size || 20,
125
+ new Uint8Array(cfg.init || []));
126
+ }
127
+ // Virtual peripheral devices (JS hardware layer). Each is a real device
128
+ // protocol implemented in JS on top of the bus taps, driven by the
129
+ // firmware through the modeled I2C/SPI/TIM/I2S registers.
130
+ if (ext_devices.oled) {
131
+ i2c_register_slave(ext_devices.oled.i2c || 'I2C1', ext_devices.oled.addr || 0x3C);
132
+ }
133
+ if (ext_devices.tft) {
134
+ spi_tap(ext_devices.tft.spi || 'SPI2', ext_devices.tft.cs || null, ext_devices.tft.dc || null);
135
+ }
136
+ // Custom SPI/I2C devices (site/components.js-style embedder devices):
137
+ // { peripheral, cs?, dc?, handler(events, push) } / { peripheral, address, handler }.
138
+ // spi_tap/i2c_register_slave must run before init() below — the Spi/I2c
139
+ // peripheral objects snapshot their attached-device list once at
140
+ // construction and never rescan it (see spi.rs Spi::new).
141
+ const spiDevices = [];
142
+ for (const cfg of (ext_devices.spiDevices || [])) {
143
+ spi_tap(cfg.peripheral, cfg.cs ?? null, cfg.dc ?? null);
144
+ spiDevices.push({ peripheral: cfg.peripheral, handler: cfg.handler });
145
+ }
146
+ const i2cDevices = [];
147
+ for (const cfg of (ext_devices.i2cDevices || [])) {
148
+ i2c_register_slave(cfg.peripheral, cfg.address);
149
+ i2cDevices.push({ peripheral: cfg.peripheral, handler: cfg.handler });
150
+ }
151
+ // Memory-mapped FSMC devices: { bank, handler(events, push) }. `bank` is
152
+ // 0-based (0 = BANK1 @ 0x60000000). Events arrive as PAIRS of words —
153
+ // header then value — because an FSMC access carries an address as well
154
+ // as a value; see the fsmc_tap doc comment in lib.rs. Same before-init()
155
+ // rule as spi_tap: Fsmc binds its banks' devices once at construction.
156
+ const fsmcDevices = [];
157
+ for (const cfg of (ext_devices.fsmcDevices || [])) {
158
+ const bank = cfg.bank ?? 0;
159
+ fsmc_tap(bank);
160
+ fsmcDevices.push({ bank, handler: cfg.handler });
161
+ }
162
+ // Exactly ONE of these. They both install a fresh WasmSystem and the last
163
+ // one wins, so calling init() after init_svd() would replace the
164
+ // SVD-derived peripheral map with the hardcoded one. (It used to be a
165
+ // harmless no-op only because SYS was a OnceLock that ignored the second
166
+ // call — see the SYS comment in stm32-periph-wasm/src/lib.rs.)
167
+ if (svdXml) init_svd(svdXml);
168
+ else bindings.init();
169
+
170
+ const uc = new Module.Unicorn(
171
+ Module.ARCH_ARM,
172
+ Module.MODE_THUMB | Module.MODE_LITTLE_ENDIAN
173
+ );
174
+
175
+ const mmap = (addr, size, perms, label) => {
176
+ try {
177
+ uc.mem_map(BigInt(addr), size, perms);
178
+ } catch (e) {
179
+ let regions = '?';
180
+ try {
181
+ regions = JSON.stringify(uc.mem_regions());
182
+ } catch (e2) {
183
+ regions = 'regions-err: ' + e2;
184
+ }
185
+ throw new Error(`mem_map ${label} 0x${addr.toString(16)}+0x${size.toString(16)}: ${e} | regions=${regions}`);
186
+ }
187
+ };
188
+ mmap(vector_table & ~0x1FFFF, flash_size, Module.PROT_ALL, 'flash');
189
+ uc.mem_write(BigInt(vector_table), firmware);
190
+ mmap(0x20000000, ram_size, Module.PROT_ALL, 'ram');
191
+ // NOTE: this Unicorn WASM build's mem_write transports data via a
192
+ // stackAlloc copy; subsequent mem_map mallocs can reuse stack-touched
193
+ // heap pages, so "fresh" guest RAM can be seeded with leftovers from the
194
+ // firmware transfer (the 288KB doom.bin is big enough to trigger it).
195
+ // Zero SRAM after mapping so the guest never reads phantom pre-boot data.
196
+ uc.mem_write(BigInt(0x20000000), new Uint8Array(ram_size));
197
+ for (const r of extra_ram) {
198
+ mmap(r.addr, r.size, Module.PROT_ALL, 'extra_ram');
199
+ // same leftover-seeding hazard as SRAM: extra_ram can host .data/.bss
200
+ // (doom's 0xC0000000) — zero it too so bss globals start clean.
201
+ uc.mem_write(BigInt(r.addr), new Uint8Array(r.size));
202
+ }
203
+ for (const seg of extra_mem) {
204
+ uc.mem_write(BigInt(seg.addr), seg.data);
205
+ }
206
+
207
+ const periphRanges = [
208
+ [0x40000000, 0xB0000000],
209
+ [0xE0000000, 0xE1000000],
210
+ ];
211
+ for (const [start, end] of periphRanges) {
212
+ mmap(start, end - start, Module.PROT_READ | Module.PROT_WRITE, 'periph');
213
+ }
214
+
215
+ const read32 = (addr) => {
216
+ const b = uc.mem_read(BigInt(addr), 4);
217
+ const v = new DataView(b.buffer, b.byteOffset, b.byteLength);
218
+ return v.getUint32(0, true);
219
+ };
220
+ const write32 = (addr, val) => {
221
+ const b = new Uint8Array(4);
222
+ new DataView(b.buffer).setUint32(0, val >>> 0, true);
223
+ uc.mem_write(BigInt(addr), b);
224
+ };
225
+
226
+ // Scratch buffers reused across MMIO reads. This hook fires on EVERY
227
+ // peripheral read, and polling firmware (ETH spinning on DMASR, USART on
228
+ // TXE) hits it constantly — allocating a fresh Uint8Array each time was
229
+ // pure GC churn. Safe to reuse: uc.mem_write copies the bytes into guest
230
+ // memory synchronously before returning, so nothing retains the buffer.
231
+ const rdScratch = [];
232
+ const memReadHook = (handle, type, address, size, value, user_data) => {
233
+ const val = periph_read(Number(address), size) >>> 0;
234
+ let bytes = rdScratch[size];
235
+ if (bytes === undefined) bytes = rdScratch[size] = new Uint8Array(size);
236
+ for (let i = 0; i < size; i++) bytes[i] = (val >> (i * 8)) & 0xFF;
237
+ uc.mem_write(address, bytes);
238
+ };
239
+ const memWriteHook = (handle, type, address, size, value, user_data) => {
240
+ const a = Number(address);
241
+ periph_write(a, size, Number(value));
242
+ if (a >= 0x40023C00 && a <= 0x40023C18) syncFlashProtection(); // FLASH CR writes flip pg
243
+ };
244
+ for (const [start, end] of periphRanges) {
245
+ uc.hook_add(Module.HOOK_MEM_READ, memReadHook, null, start, end);
246
+ uc.hook_add(Module.HOOK_MEM_WRITE, memWriteHook, null, start, end);
247
+ }
248
+
249
+ // FLASH regions: guest stores are hardware-gated by programming mode.
250
+ // While the model says programming is active (unlocked + PG + !BSY) the
251
+ // region is writable so stores land directly; otherwise it is read/exec
252
+ // only — the guest's useless stores fault with UC_ERR_WRITE_PROT and the
253
+ // driver skips the instruction (Unicorn 2.1.4 WASM fires HOOK_MEM_WRITE
254
+ // before the store and cannot block it; WRITE_PROT is the only gate).
255
+ const FLASH_GUEST_START = 0x08000000n;
256
+ const FLASH_GUEST_LEN = 0x100000;
257
+ let flashWritable = false;
258
+ const syncFlashProtection = () => {
259
+ const pg = flash_is_programming();
260
+ if (pg && !flashWritable) {
261
+ uc.mem_protect(FLASH_GUEST_START, FLASH_GUEST_LEN, Module.PROT_ALL);
262
+ flashWritable = true;
263
+ } else if (!pg && flashWritable) {
264
+ uc.mem_protect(FLASH_GUEST_START, FLASH_GUEST_LEN, Module.PROT_READ | Module.PROT_EXEC);
265
+ flashWritable = false;
266
+ }
267
+ };
268
+ uc.mem_protect(FLASH_GUEST_START, FLASH_GUEST_LEN, Module.PROT_READ | Module.PROT_EXEC);
269
+
270
+ const stepThroughFlashFault = () => {
271
+ const pc = uc.reg_read_i32(Module.ARM_REG_PC) >>> 0;
272
+ const b = uc.mem_read(BigInt(pc & ~1), 2);
273
+ const h = new DataView(b.buffer, b.byteOffset, b.byteLength).getUint16(0, true);
274
+ const size = (h & 0xE000) === 0xE000 ? 4 : 2; // Thumb-2 wide vs halfword
275
+ uc.reg_write_i32(Module.ARM_REG_PC, (pc + size) | 1);
276
+ };
277
+
278
+ const applyFlashErase = () => {
279
+ const er = flash_take_erase();
280
+ if (er.length === 2) {
281
+ const [start, len] = er;
282
+ const ff = new Uint8Array(4096).fill(0xFF);
283
+ for (let off = 0; off < len; off += 4096) {
284
+ const chunk = Math.min(4096, len - off);
285
+ uc.mem_write(BigInt(start + off), ff.subarray(0, chunk));
286
+ }
287
+ flash_erase_applied();
288
+ }
289
+ };
290
+
291
+ const sp_init = read32(vector_table);
292
+ const pc_init = read32(vector_table + 4);
293
+ uc.reg_write_i32(Module.ARM_REG_SP, sp_init);
294
+ uc.reg_write_i32(Module.ARM_REG_PC, pc_init | 1);
295
+
296
+ let stopRequested = false;
297
+ let instCount = 0;
298
+ let tickAcc = 0;
299
+ let pollAcc = 0;
300
+ const rxQueue = [];
301
+
302
+ const codeHook = (handle, address, size, user_data) => {
303
+ instCount++;
304
+ if (rxQueue.length > 0 && eth_is_rx_poll()) {
305
+ uc.emu_stop();
306
+ return;
307
+ }
308
+ if (minimalPolls) return;
309
+ tickAcc++;
310
+ if (tickAcc >= tickEvery) {
311
+ tickAcc = 0;
312
+ tick_n(tickEvery);
313
+ if (is_watchdog_reset_requested()) {
314
+ stopRequested = true;
315
+ uc.emu_stop();
316
+ return;
317
+ }
318
+ }
319
+ pollAcc++;
320
+ if (pollAcc >= pollEvery) {
321
+ pollAcc = 0;
322
+ applyFlashErase(); // model holds BSY until the erase is applied
323
+ if (dma_get_pending_count() > 0 || eth_is_tx_poll()) {
324
+ uc.emu_stop();
325
+ return;
326
+ }
327
+ }
328
+ };
329
+ // Count-only block hook (doom's old fast path): no tick/poll logic, so it
330
+ // is ONLY valid alongside minimalPolls.
331
+ const blockHook = (handle, address, size, user_data) => {
332
+ instCount += Number(size) / 2; // Thumb-2: block size in bytes
333
+ };
334
+ // Full block hook — same work as codeHook, but once per basic block
335
+ // instead of once per instruction.
336
+ //
337
+ // Measured: the WASM->JS crossing itself costs ~16% of throughput, while
338
+ // the callback BODY costs ~0 (a hook whose body returns immediately is
339
+ // just as slow as one that does the full accounting). Blocks average
340
+ // roughly 5-15 Thumb-2 instructions, so this removes ~80-90% of the
341
+ // crossings and hands that ~16% to EVERY firmware, not just doom.
342
+ //
343
+ // Why this is not a behaviour change: the tick/poll thresholds are 5000
344
+ // and 1000 instructions, so accumulating per block still trips them
345
+ // within one block (~10 inst) of where per-instruction counting would —
346
+ // far finer than the thresholds themselves. The rx-poll stop likewise
347
+ // lands at most a block late, and the driver just services RX and
348
+ // resumes. The one real cost is that instCount becomes approximate (a
349
+ // block that exits early still counts its full size), which affects the
350
+ // MIPS readout but nothing that gates on emu_start's own budget.
351
+ const blockHookFull = (handle, address, size, user_data) => {
352
+ const n = Number(size) / 2;
353
+ instCount += n;
354
+ if (rxQueue.length > 0 && eth_is_rx_poll()) {
355
+ uc.emu_stop();
356
+ return;
357
+ }
358
+ tickAcc += n;
359
+ if (tickAcc >= tickEvery) {
360
+ tick_n(tickAcc);
361
+ tickAcc = 0;
362
+ if (is_watchdog_reset_requested()) {
363
+ stopRequested = true;
364
+ uc.emu_stop();
365
+ return;
366
+ }
367
+ }
368
+ pollAcc += n;
369
+ if (pollAcc >= pollEvery) {
370
+ pollAcc = 0;
371
+ applyFlashErase(); // model holds BSY until the erase is applied
372
+ if (dma_get_pending_count() > 0 || eth_is_tx_poll()) {
373
+ uc.emu_stop();
374
+ return;
375
+ }
376
+ }
377
+ };
378
+ if (noCountHook) { /* no per-block/per-inst hook: step() accounts the budget */ }
379
+ else if (blockCounting) uc.hook_add(Module.HOOK_BLOCK, blockHook, null);
380
+ else if (minimalPolls) uc.hook_add(Module.HOOK_CODE, codeHook, null);
381
+ else if (perInstHook) uc.hook_add(Module.HOOK_CODE, codeHook, null);
382
+ else uc.hook_add(Module.HOOK_BLOCK, blockHookFull, null);
383
+
384
+ // ── virtual peripheral devices (JS hardware layer) ────────────────────
385
+ // Device protocols implemented in JS on top of the bus taps:
386
+ // - oled: SSD1306 128x64 over I2C (page-addressed framebuffer)
387
+ // - tft: ILI9341 240x320 RGB565 over SPI with a DC line
388
+ // - buzzer: TIM PWM frequency/duty read from the modeled timer regs
389
+ // - speaker:I2S TX samples drained from the model's capture FIFO
390
+ // Enabled per-firmware via ext_devices.{oled,tft,buzzer,speaker}.
391
+
392
+ const oled = ext_devices.oled ? {
393
+ w: 128, h: 64, col: 0, page: 0, cmdArg: 0, cmdLeft: 0,
394
+ inData: false, needControl: false,
395
+ fb: new Uint8Array(128 * 64), frame: 0,
396
+ } : null;
397
+ const oledI2C = ext_devices.oled ? (ext_devices.oled.i2c || 'I2C1') : null;
398
+ const OLED_ARG_CMDS = { 0x20: 1, 0x21: 1, 0x22: 1, 0x81: 1, 0x8D: 1, 0xA8: 1, 0xD3: 1, 0xD5: 1, 0xD9: 1, 0xDA: 1, 0xDB: 1 };
399
+ const processOled = () => {
400
+ if (!oled) return;
401
+ const events = i2c_take_events(oledI2C);
402
+ for (const ev of events) {
403
+ if (ev & 0x80000000) {
404
+ // START: the next byte is a control byte (0x00 = command
405
+ // group, 0x40 = data group). STOP: group ends here.
406
+ oled.inData = false;
407
+ if (ev & 0x40000000) oled.needControl = true;
408
+ continue;
409
+ }
410
+ const b = ev & 0xFF;
411
+ if (oled.needControl) {
412
+ oled.needControl = false;
413
+ if (b === 0x40) oled.inData = true;
414
+ continue;
415
+ }
416
+ if (oled.inData) {
417
+ for (let bit = 0; bit < 8; bit++) {
418
+ oled.fb[(oled.page * 8 + bit) * oled.w + oled.col] = (b >> bit) & 1;
419
+ }
420
+ oled.col = (oled.col + 1) % oled.w;
421
+ continue;
422
+ }
423
+ if (oled.cmdLeft > 0) { oled.cmdLeft--; continue; }
424
+ if (b >= 0xB0 && b <= 0xB7) { oled.page = b & 0x07; continue; }
425
+ if (b >= 0x00 && b <= 0x0F) { oled.col = (oled.col & 0xF0) | b; continue; }
426
+ if (b >= 0x10 && b <= 0x1F) { oled.col = ((b & 0x0F) << 4) | (oled.col & 0x0F); continue; }
427
+ const n = OLED_ARG_CMDS[b];
428
+ if (n !== undefined) { oled.cmdLeft = n; continue; }
429
+ // single-byte commands (0xAE/0xAF/0x40/0xA4/0xA6/...) — no action
430
+ }
431
+ if (events.length) oled.frame++;
432
+ };
433
+
434
+ const tft = ext_devices.tft ? {
435
+ w: 240, h: 320, x0: 0, y0: 0, x1: 239, y1: 319, x: 0, y: 0,
436
+ mode: 'idle', cmdArg: 0, cmdLeft: 0, argBuf: [],
437
+ fb: new Uint8Array(240 * 320 * 2), frame: 0, pixels: 0,
438
+ } : null;
439
+ const tftSpi = ext_devices.tft ? (ext_devices.tft.spi || 'SPI2') : null;
440
+ const TFT_ARG_CMDS = { 0x2A: 4, 0x2B: 4, 0x36: 1, 0x3A: 1, 0xC0: 2, 0xC1: 1, 0xC5: 2, 0xC7: 1, 0xE0: 15, 0xE1: 15, 0xF6: 3, 0x35: 1, 0x53: 1 };
441
+ const processTft = () => {
442
+ if (!tft) return;
443
+ const events = spi_take_events(tftSpi);
444
+ for (const ev of events) {
445
+ if (ev & 0x80000000) {
446
+ if (ev & 0x40000000) { // CS asserted: fresh transaction
447
+ tft.mode = 'idle';
448
+ tft.cmdLeft = 0;
449
+ }
450
+ continue;
451
+ }
452
+ const dc = (ev >> 29) & 1;
453
+ const b = ev & 0xFF;
454
+ if (tft.mode === 'write') {
455
+ tft.argBuf.push(b);
456
+ if (tft.argBuf.length === 2) {
457
+ const px = (tft.argBuf[0] << 8) | tft.argBuf[1];
458
+ const off = (tft.y * tft.w + tft.x) * 2;
459
+ tft.fb[off] = tft.argBuf[0];
460
+ tft.fb[off + 1] = tft.argBuf[1];
461
+ tft.x++;
462
+ tft.pixels++;
463
+ if (tft.x > tft.x1) {
464
+ tft.x = tft.x0;
465
+ tft.y++;
466
+ tft.frame++;
467
+ }
468
+ if (tft.y > tft.y1) { tft.y = tft.y0; tft.mode = 'idle'; }
469
+ tft.argBuf.length = 0;
470
+ }
471
+ continue;
472
+ }
473
+ if (!dc) { // command byte
474
+ if (b === 0x2C) {
475
+ tft.mode = 'write';
476
+ tft.x = tft.x0;
477
+ tft.y = tft.y0;
478
+ tft.argBuf.length = 0;
479
+ continue;
480
+ }
481
+ const n = TFT_ARG_CMDS[b];
482
+ if (n !== undefined) { tft.mode = 'args'; tft.cmdArg = b; tft.cmdLeft = n; tft.argBuf.length = 0; }
483
+ continue;
484
+ }
485
+ // data byte in args mode
486
+ if (tft.mode === 'args') {
487
+ tft.argBuf.push(b);
488
+ tft.cmdLeft--;
489
+ if (tft.cmdLeft === 0) {
490
+ if (tft.cmdArg === 0x2A && tft.argBuf.length === 4) {
491
+ tft.x0 = (tft.argBuf[0] << 8) | tft.argBuf[1];
492
+ tft.x1 = (tft.argBuf[2] << 8) | tft.argBuf[3];
493
+ } else if (tft.cmdArg === 0x2B && tft.argBuf.length === 4) {
494
+ tft.y0 = (tft.argBuf[0] << 8) | tft.argBuf[1];
495
+ tft.y1 = (tft.argBuf[2] << 8) | tft.argBuf[3];
496
+ }
497
+ tft.mode = 'idle';
498
+ }
499
+ }
500
+ }
501
+ };
502
+
503
+ const buzzer = ext_devices.buzzer ? {
504
+ base: ext_devices.buzzer.tim === 'TIM3' ? 0x40000400
505
+ : ext_devices.buzzer.tim === 'TIM4' ? 0x40000800
506
+ : ext_devices.buzzer.tim === 'TIM5' ? 0x40000C00
507
+ : 0x40000000, // TIM2 default
508
+ fclk: 84e6, freq: 0, duty: 0, change: 0,
509
+ } : null;
510
+ const processBuzzer = () => {
511
+ if (!buzzer) return;
512
+ const cr1 = read32(buzzer.base + 0x00);
513
+ const ccer = read32(buzzer.base + 0x20);
514
+ const psc = read32(buzzer.base + 0x28);
515
+ const arr = read32(buzzer.base + 0x2C);
516
+ const ccr = read32(buzzer.base + 0x34);
517
+ let freq = 0, duty = 0;
518
+ if ((cr1 & 1) && (ccer & 1) && ccr > 0 && arr > 0 && arr < 0xFFFFFF) {
519
+ const div = (psc + 1) * (arr + 1);
520
+ if (div > 0) { freq = buzzer.fclk / div; duty = ccr / (arr + 1); }
521
+ }
522
+ if (freq !== buzzer.freq || duty !== buzzer.duty) {
523
+ buzzer.freq = freq; buzzer.duty = duty; buzzer.change++;
524
+ }
525
+ };
526
+
527
+ const speaker = ext_devices.speaker ? { ring: [], total: 0, samples: 0 } : null;
528
+ const processSpeaker = () => {
529
+ if (!speaker) return;
530
+ const s = audio_take_capture();
531
+ if (!s || s.length === 0) return;
532
+ const f = new Float32Array(s.length);
533
+ for (let i = 0; i < s.length; i++) f[i] = (s[i] > 0x7FFF ? s[i] - 0x10000 : s[i]) / 32768;
534
+ speaker.ring.push(f);
535
+ speaker.total += f.length;
536
+ if (speaker.ring.length > 64) speaker.ring.shift();
537
+ };
538
+ const takeSpeakerSamples = () => {
539
+ if (!speaker) return new Float32Array(0);
540
+ let n = 0;
541
+ for (const f of speaker.ring) n += f.length;
542
+ const out = new Float32Array(n);
543
+ let off = 0;
544
+ for (const f of speaker.ring) { out.set(f, off); off += f.length; }
545
+ speaker.ring.length = 0;
546
+ return out;
547
+ };
548
+
549
+ // DS3231 RTC: register file behind the tap (BCD time regs 0x00-0x06,
550
+ // temp MSB/LSB 0x11/0x12). Read live from the modeled registers; the
551
+ // JS driver seeds the register file (init) and the guest writes it.
552
+ const rtc = ext_devices.rtc ? {
553
+ peri: ext_devices.rtc.i2c || 'I2C1', change: 0, time: null, temp: null,
554
+ lastKey: '',
555
+ } : null;
556
+ const bcd2n = (v) => ((v >> 4) * 10) + (v & 0x0F);
557
+ const processRtc = () => {
558
+ if (!rtc) return;
559
+ const r = (o) => i2c_regfile_get(rtc.peri, o);
560
+ const time = {
561
+ sec: bcd2n(r(0x00)), min: bcd2n(r(0x01)), hour: bcd2n(r(0x02)),
562
+ dow: bcd2n(r(0x03)), day: bcd2n(r(0x04)), mon: bcd2n(r(0x05)),
563
+ year: bcd2n(r(0x06)),
564
+ };
565
+ const tmsb = r(0x11), tlsb = r(0x12);
566
+ const temp = (tmsb & 0x80 ? tmsb - 0x100 : tmsb) + (tlsb >> 6) * 0.25;
567
+ rtc.time = time;
568
+ rtc.temp = temp;
569
+ const key = `${time.sec}:${time.min}:${time.hour}:${time.day}:${time.mon}:${time.year}:${temp}`;
570
+ if (key !== rtc.lastKey) { rtc.lastKey = key; rtc.change++; }
571
+ };
572
+
573
+ // ── Public component-attachment API (LED/Button/custom SPI/I2C
574
+ // devices) — built on the same GPIO shims and bus taps the oled/tft
575
+ // blocks above use internally, but reusable by embedder code without
576
+ // editing this file. See site/components.js and docs/components.md.
577
+ const portIndex = (port) => typeof port === 'string' ? port.toUpperCase().charCodeAt(0) - 65 : port;
578
+
579
+ const pin = (port, num) => {
580
+ const p = portIndex(port);
581
+ return {
582
+ read: () => gpio_read_output(p, num),
583
+ readInput: () => gpio_read_input(p, num),
584
+ write: (level) => gpio_set_input(p, num, !!level),
585
+ };
586
+ };
587
+
588
+ const i2cRegfile = (peripheral) => ({
589
+ get: (offset) => i2c_regfile_get(peripheral, offset),
590
+ set: (offset, value) => i2c_regfile_set(peripheral, offset, value & 0xFF),
591
+ });
592
+
593
+ const setAdcChannel = (peripheral, channel, value) => adc_set_channel_value(peripheral, channel, value);
594
+ const clearAdcChannel = (peripheral, channel) => adc_clear_channel_value(peripheral, channel);
595
+
596
+ const gpioWatchers = [];
597
+ const watchPin = (port, num, callback) => {
598
+ const p = portIndex(port);
599
+ const w = { p, num, last: gpio_read_output(p, num), callback };
600
+ gpioWatchers.push(w);
601
+ return () => {
602
+ const i = gpioWatchers.indexOf(w);
603
+ if (i >= 0) gpioWatchers.splice(i, 1);
604
+ };
605
+ };
606
+ const processGpioWatchers = () => {
607
+ for (const w of gpioWatchers) {
608
+ const v = gpio_read_output(w.p, w.num);
609
+ if (v !== w.last) { w.last = v; w.callback(v); }
610
+ }
611
+ };
612
+
613
+ const processSpiDevices = () => {
614
+ for (const d of spiDevices) {
615
+ const events = spi_take_events(d.peripheral);
616
+ if (events.length) d.handler(events, (bytes) => spi_push_miso(d.peripheral, bytes));
617
+ }
618
+ };
619
+
620
+ const processI2cDevices = () => {
621
+ for (const d of i2cDevices) {
622
+ const events = i2c_take_events(d.peripheral);
623
+ if (events.length) d.handler(events, (bytes) => i2c_push_rx(d.peripheral, bytes));
624
+ }
625
+ };
626
+
627
+ // ── DCMI camera sensor (JS pixel source) ──
628
+ // The DCMI model consumes one fed frame with real VSYNC/LINE/FRAME/OVR
629
+ // semantics and drops it when fully read, so a live camera just has to
630
+ // keep supplying frames. Unlike the bus taps this needs no registration
631
+ // before init() — the frame slot is a global the model polls.
632
+ const camera = ext_devices.camera || null;
633
+ let cameraFrames = 0;
634
+ let cameraRunning = true;
635
+ const processCamera = () => {
636
+ if (!camera || !cameraRunning) return;
637
+ // `frame(n)` returns the next frame's pixels, or null/undefined to
638
+ // leave the current one in place (a sensor running slower than the
639
+ // step loop, which is the normal case).
640
+ const px = camera.frame ? camera.frame(cameraFrames) : camera.pixels;
641
+ if (!px) return;
642
+ dcmi_feed_frame(camera.width, camera.height,
643
+ px instanceof Uint8Array ? px : Uint8Array.from(px));
644
+ cameraFrames++;
645
+ };
646
+
647
+ const processFsmcDevices = () => {
648
+ for (const d of fsmcDevices) {
649
+ const events = fsmc_take_events(d.bank);
650
+ if (events.length) d.handler(events, (values) => fsmc_push_data(d.bank, Uint32Array.from(values)));
651
+ }
652
+ };
653
+
654
+ const processDevices = () => {
655
+ processOled();
656
+ processTft();
657
+ processBuzzer();
658
+ processSpeaker();
659
+ processRtc();
660
+ processGpioWatchers();
661
+ processSpiDevices();
662
+ processI2cDevices();
663
+ processFsmcDevices();
664
+ processCamera();
665
+ };
666
+
667
+ // ── ETH TX capture + RX injection ──
668
+ const processEth = () => {
669
+ if (eth_is_tx_poll()) {
670
+ const descAddr = eth_get_tx_desc_addr();
671
+ if (descAddr !== 0) {
672
+ const desc = uc.mem_read(BigInt(descAddr), 8);
673
+ const dv = new DataView(desc.buffer, desc.byteOffset, desc.byteLength);
674
+ const tdes0 = dv.getUint32(0, true);
675
+ const tdes1 = dv.getUint32(4, true);
676
+ if (tdes0 & 0x80000000) {
677
+ const bufAddr = tdes1 & 0xFFFFFFFC;
678
+ const bufSize = tdes0 & 0x3FFF;
679
+ if (bufAddr !== 0 && bufSize > 0 && bufSize <= 2000) {
680
+ const pkt = new Uint8Array(uc.mem_read(BigInt(bufAddr), bufSize));
681
+ if (onTx) onTx(pkt, { bufAddr, len: bufSize });
682
+ }
683
+ const wb = new Uint8Array(4);
684
+ new DataView(wb.buffer).setUint32(0, (tdes0 & ~0x80000000) | 0x20000000, true);
685
+ uc.mem_write(BigInt(descAddr), wb);
686
+ }
687
+ }
688
+ eth_clear_tx_poll();
689
+ eth_tx_done();
690
+ if (!irq_eth) write32(E.irqFlag, read32(E.irqFlag) | 1);
691
+ }
692
+
693
+ if (eth_is_rx_poll() && rxQueue.length > 0) {
694
+ const idx = E.rxInjectIdx;
695
+ E.rxInjectIdx = (E.rxInjectIdx + 1) % E.rxDescs;
696
+ const descAddr = E.rxDesc + idx * 8;
697
+ const bufAddr = E.rxBuf + idx * E.rxStride;
698
+ const frame = rxQueue.shift();
699
+ const len = Math.min(frame.length, E.rxStride);
700
+ uc.mem_write(BigInt(bufAddr), frame.subarray(0, len));
701
+ const wb = new Uint8Array(4);
702
+ // Real F407: RDES0 high word = frame length [29:16], OWN cleared
703
+ // for CPU ownership; FS/LS live in the low status word (set by
704
+ // the DMA, not the driver). Writing FS/LS marker bits at 28/27
705
+ // would corrupt the length read by the guest ISR.
706
+ new DataView(wb.buffer).setUint32(0, len << 16, true);
707
+ uc.mem_write(BigInt(descAddr), wb);
708
+ if (!irq_eth) {
709
+ write32(E.rxFrameIdx, idx);
710
+ write32(E.rxFrameLen, len);
711
+ write32(E.irqFlag, read32(E.irqFlag) | 2);
712
+ }
713
+ eth_clear_rx_poll();
714
+ eth_rx_done();
715
+ }
716
+ };
717
+
718
+ const isPeriphAddr = (a) => (a >= 0x40000000 && a < 0xB0000000) || (a >= 0xE0000000 && a < 0xE1000000);
719
+ const processDma = () => {
720
+ const count = dma_get_pending_count();
721
+ for (let i = 0; i < count; i++) {
722
+ const pending = dma_get_pending(0);
723
+ if (pending.length < 5) continue;
724
+ const dir = pending[0], stream = pending[1], src = pending[2], dst = pending[3], size = pending[4];
725
+ const peri_addr = pending[5] || 0;
726
+ const peripheral = pending[6] || 0;
727
+ try {
728
+ if (dir === 2 || !peripheral || !isPeriphAddr(peri_addr)) {
729
+ uc.mem_write(BigInt(dst), uc.mem_read(BigInt(src), size));
730
+ } else if (dir === 0) { // read: peri -> RAM
731
+ const data = dma_periph_read(peri_addr, size, (pending[7] || 0) === 1, pending[8] || 4);
732
+ uc.mem_write(BigInt(dst), data);
733
+ } else { // dir === 1: write: RAM -> peri
734
+ dma_periph_write(peri_addr, uc.mem_read(BigInt(src), size));
735
+ }
736
+ } catch (e) { /* ignore */ }
737
+ dma_set_completed(stream, true);
738
+ }
739
+ };
740
+
741
+ // ── interrupt pump (runs guest IRQ handlers between batches) ───────────
742
+ // Port of cli.mjs processInterrupts: pushes an exception frame, runs the
743
+ // handler until it aborts on bx lr (EXC_RETURN unsupported), then restores
744
+ // the full context incl. XPSR (condition flags!).
745
+ // Enabled ONLY for interrupt-driven firmware (enable_irqs) — the ETH
746
+ // firmware must NOT use it: the driver signals TX/RX done via SRAM
747
+ // irq_flag + model DMASR, and a guest ETH_IRQHandler run on top re-scans
748
+ // rx_desc and corrupts rx_frame_idx/len (observed garbage response body).
749
+ const processInterrupts = () => {
750
+ while (!stopRequested) {
751
+ const irq = get_next_pending_interrupt();
752
+ if (irq <= -100) break;
753
+ const savedAt = uc.reg_read_i32(Module.ARM_REG_SP);
754
+ const frame = new Uint8Array(32);
755
+ const sv = new DataView(frame.buffer);
756
+ sv.setUint32(0, uc.reg_read_i32(Module.ARM_REG_XPSR), true);
757
+ sv.setUint32(4, uc.reg_read_i32(Module.ARM_REG_PC), true);
758
+ sv.setUint32(8, uc.reg_read_i32(Module.ARM_REG_LR), true);
759
+ sv.setUint32(12, uc.reg_read_i32(Module.ARM_REG_R12), true);
760
+ sv.setUint32(16, uc.reg_read_i32(Module.ARM_REG_R3), true);
761
+ sv.setUint32(20, uc.reg_read_i32(Module.ARM_REG_R2), true);
762
+ sv.setUint32(24, uc.reg_read_i32(Module.ARM_REG_R1), true);
763
+ sv.setUint32(28, uc.reg_read_i32(Module.ARM_REG_R0), true);
764
+ uc.mem_write(BigInt(savedAt - 32), frame);
765
+ uc.reg_write_i32(Module.ARM_REG_SP, savedAt - 32);
766
+ const handler_pc = read32(vector_table + 4 * (16 + irq));
767
+ uc.reg_write_i32(Module.ARM_REG_LR, 0xFFFFFFF9);
768
+ uc.reg_write_i32(Module.ARM_REG_PC, handler_pc);
769
+ try {
770
+ uc.emu_start(BigInt(handler_pc), 0n, 0n, 100000);
771
+ } catch (e) {
772
+ // handler aborted on bx lr (EXC_RETURN not supported) — expected
773
+ }
774
+ const savedFrame = uc.mem_read(BigInt(savedAt - 32), 32);
775
+ const savedSv = new DataView(savedFrame.buffer, savedFrame.byteOffset, savedFrame.byteLength);
776
+ uc.reg_write_i32(Module.ARM_REG_XPSR, savedSv.getUint32(0, true));
777
+ uc.reg_write_i32(Module.ARM_REG_R0, savedSv.getUint32(28, true));
778
+ uc.reg_write_i32(Module.ARM_REG_R1, savedSv.getUint32(24, true));
779
+ uc.reg_write_i32(Module.ARM_REG_R2, savedSv.getUint32(20, true));
780
+ uc.reg_write_i32(Module.ARM_REG_R3, savedSv.getUint32(16, true));
781
+ uc.reg_write_i32(Module.ARM_REG_R12, savedSv.getUint32(12, true));
782
+ uc.reg_write_i32(Module.ARM_REG_LR, savedSv.getUint32(8, true));
783
+ uc.reg_write_i32(Module.ARM_REG_PC, savedSv.getUint32(4, true) | 1);
784
+ uc.reg_write_i32(Module.ARM_REG_SP, savedAt);
785
+ processDma();
786
+ processEth();
787
+ }
788
+ };
789
+
790
+ const step = (max_inst = maxBatch) => {
791
+ processDma();
792
+ processEth();
793
+ syncFlashProtection();
794
+ const current_pc = uc.reg_read_i32(Module.ARM_REG_PC);
795
+ try {
796
+ uc.emu_start(BigInt(current_pc | 1), 0n, 0n, max_inst);
797
+ } catch (e) {
798
+ if (String(e).includes('WRITE_PROT')) {
799
+ if (flash_is_programming()) {
800
+ // Region toggled late: pg is active, so re-enable writes
801
+ // and re-execute the store from the same PC.
802
+ uc.mem_protect(FLASH_GUEST_START, FLASH_GUEST_LEN, Module.PROT_ALL);
803
+ flashWritable = true;
804
+ uc.emu_start(BigInt(uc.reg_read_i32(Module.ARM_REG_PC) | 1), 0n, 0n, max_inst);
805
+ } else {
806
+ stepThroughFlashFault();
807
+ }
808
+ } else throw e;
809
+ }
810
+ // With no counting hook the budget issued IS the instruction account
811
+ // (emu_start runs to the budget unless something stops it early).
812
+ if (noCountHook) instCount += max_inst;
813
+ tick();
814
+ applyFlashErase();
815
+ syncFlashProtection();
816
+ processDma();
817
+ processEth();
818
+ processDevices();
819
+ if (enable_irqs) processInterrupts();
820
+ const stopped = stopRequested || is_watchdog_reset_requested();
821
+ return { pc: uc.reg_read_i32(Module.ARM_REG_PC), stopped, instCount };
822
+ };
823
+
824
+ const run = (max_instructions = 0) => {
825
+ stopRequested = false;
826
+ let totalSteps = 0;
827
+ while (!stopRequested) {
828
+ step();
829
+ totalSteps++;
830
+ if (max_instructions > 0 && instCount >= max_instructions) break;
831
+ }
832
+ return { totalSteps, instCount };
833
+ };
834
+
835
+ return {
836
+ uc, Module, read32, write32,
837
+ step, run,
838
+ drainUart() { return get_uart_output(); },
839
+ injectFrame(frame) { rxQueue.push(frame); },
840
+ sendUartByte(b) { return uart_rx_byte(uart_addr, b & 0xFF); },
841
+ sendUart(bytes) {
842
+ for (const b of bytes) uart_rx_byte(uart_addr, b & 0xFF);
843
+ },
844
+ rxQueue,
845
+ pin, watchPin, i2cRegfile, setAdcChannel, clearAdcChannel,
846
+ oled: oled ? { fb: oled.fb, frame: () => oled.frame } : null,
847
+ tft: tft ? { fb: tft.fb, w: tft.w, h: tft.h, frame: () => tft.frame } : null,
848
+ buzzer: buzzer ? { get freq() { return buzzer.freq; }, get duty() { return buzzer.duty; }, get change() { return buzzer.change; } } : null,
849
+ takeSpeakerSamples,
850
+ rtc: rtc ? { get time() { return rtc.time; }, get temp() { return rtc.temp; }, get change() { return rtc.change; } } : null,
851
+ // DCMI pixel source. Usable with or without `ext_devices.camera`:
852
+ // feed() hands the model one frame directly, stop() empties the slot.
853
+ camera: {
854
+ feed(w, h, pixels) {
855
+ dcmi_feed_frame(w, h, pixels instanceof Uint8Array ? pixels : Uint8Array.from(pixels));
856
+ },
857
+ // Unplug the sensor: drops the pending frame AND stops any
858
+ // `ext_devices.camera` source from feeding more, so a later
859
+ // feed() is not overwritten on the next step.
860
+ stop() { cameraRunning = false; dcmi_clear(); },
861
+ start() { cameraRunning = true; },
862
+ get frames() { return cameraFrames; },
863
+ },
864
+ // Push values a memory-mapped FSMC device answers on bank reads,
865
+ // for embedders driving a bank without a `fsmcDevices` handler.
866
+ pushFsmcData(bank, values) { fsmc_push_data(bank, Uint32Array.from(values)); },
867
+ takeFsmcEvents(bank) { return fsmc_take_events(bank); },
868
+ stop() {
869
+ stopRequested = true;
870
+ try { uc.emu_stop(); } catch (e) {}
871
+ },
872
+ getRegisters() {
873
+ const out = {};
874
+ for (const n of ['R0','R1','R2','R3','R4','R5','R6','R7','R8','R9','R10','R11','R12']) {
875
+ out[n] = uc.reg_read_i32(Module['ARM_REG_' + n]);
876
+ }
877
+ out.SP = uc.reg_read_i32(Module.ARM_REG_SP);
878
+ out.LR = uc.reg_read_i32(Module.ARM_REG_LR);
879
+ out.PC = uc.reg_read_i32(Module.ARM_REG_PC);
880
+ out.XPSR = uc.reg_read_i32(Module.ARM_REG_XPSR);
881
+ return out;
882
+ },
883
+ close() { try { uc.close(); } catch (e) {} },
884
+ };
885
+ }