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.
package/site/app.js ADDED
@@ -0,0 +1,654 @@
1
+ // In-browser driver for the STM32F407 single-page console.
2
+ // Preset + custom (.bin/.hex/.elf/.map) firmware loading, Run/Stop/Reset,
3
+ // an optional WebSocket gateway (real network stack) with a netsim fallback,
4
+ // live UART terminal, GPIO/peripheral register readout, and packet viewer.
5
+ import * as bindings from './vendor/stm32_periph_wasm.js';
6
+ import { createEmulator } from './emulator.js';
7
+ import { createNetSim } from './netsim.js';
8
+ import { FIRMWARES } from './firmware.js';
9
+ import { parseIntelHex, parseElf, parseMap } from './loaders.js';
10
+
11
+ const $ = (id) => document.getElementById(id);
12
+ const uartEl = $('uart'), statusEl = $('statusText'), dotEl = $('dot');
13
+ const framesEl = $('frames');
14
+
15
+ const decodeB64 = (b64) => {
16
+ const bin = atob(b64);
17
+ const u = new Uint8Array(bin.length);
18
+ for (let i = 0; i < bin.length; i++) u[i] = bin.charCodeAt(i);
19
+ return u;
20
+ };
21
+ const hex = (arr, n = 32) => {
22
+ let s = '';
23
+ for (let i = 0; i < Math.min(arr.length, n); i++) s += arr[i].toString(16).padStart(2, '0') + ' ';
24
+ if (arr.length > n) s += '…';
25
+ return s.trim();
26
+ };
27
+ const hex32 = (v) => '0x' + (v >>> 0).toString(16).padStart(8, '0');
28
+
29
+ let session = 0;
30
+ let emu = null, netsim = null, running = false;
31
+ let uartBuf = '', totalInst = 0, t0 = performance.now(), lastInst = 0, lastT = t0;
32
+ let stepsDone = 0;
33
+ let image = null; // { flash, ram, extraMem, entry, symbols, name, uartAddr }
34
+
35
+ // Firmwares on UART4 (0x40004C00) instead of USART1 (0x40011000).
36
+ const UART4_FIRMWARES = new Set(['echo_test', 'blink_serial']);
37
+ const uartAddrFor = (name) => UART4_FIRMWARES.has(name) ? 0x40004C00 : 0x40011000;
38
+
39
+ // Per-firmware ETH RX descriptor/buffer SRAM addresses (driver injects frames
40
+ // directly into guest memory). Defaults match eth_http (emulator.js E).
41
+ const ETH_RX_MAP = {
42
+ eth_irq_test: { rxDesc: 0x20000050, rxBuf: 0x2000005c },
43
+ };
44
+
45
+ // Interrupt-driven firmware: the emulator pumps guest IRQ handlers (USART RXNE
46
+ // etc.). OFF for ETH firmware — the driver signals completion via SRAM
47
+ // irq_flag and the guest ETH_IRQHandler would double-process DMASR/rx_desc.
48
+ const IRQ_FIRMWARES = new Set(['rx_interrupt_test', 'rx_crypto_test', 'comprehensive_test', 'eth_irq_test']);
49
+
50
+ // Interrupt-driven ETH firmware: the guest ETH_IRQHandler (run by the pump)
51
+ // reads DMASR and scans rx_desc itself, so the driver must not write the
52
+ // SRAM irq_flag/rx_frame_idx globals (irq_eth mode in emulator.js).
53
+ const IRQ_ETH_FIRMWARES = new Set(['eth_irq_test']);
54
+
55
+ // Virtual hardware attached to the emulator per firmware: the JS device
56
+ // layer parses the peripheral traffic and renders it (OLED fb, TFT fb,
57
+ // buzzer freq, speaker samples). Matches emulator.js ext_devices.
58
+ // RTC register-file seed: BCD time 0x00-0x06 (10:45:30 dow3 15/07/26),
59
+ // temp MSB/LSB 0x11/0x12 = 27.50 C. The guest overwrites the time regs.
60
+ const RTC_INIT = (() => {
61
+ const b = new Uint8Array(20);
62
+ b.set([0x30, 0x45, 0x10, 0x03, 0x15, 0x07, 0x26]);
63
+ b[0x11] = 0x1B; b[0x12] = 0x80;
64
+ return b;
65
+ })();
66
+ const DEVICE_FIRMWARES = {
67
+ oled_test: { oled: { i2c: 'I2C1', addr: 0x3C } },
68
+ tft_test: { tft: { spi: 'SPI2', cs: 'PB12', dc: 'PB11' } },
69
+ buzzer_test: { buzzer: { tim: 'TIM2' } },
70
+ audio_play_test: { speaker: true },
71
+ rtc_test: { rtc: { i2c: 'I2C1', addr: 0x68, init: RTC_INIT } },
72
+ };
73
+
74
+ // ── status + UART ──────────────────────────────────────────────────────────
75
+ const setStatus = (text, cls) => {
76
+ statusEl.textContent = text;
77
+ dotEl.className = 'dot ' + cls;
78
+ };
79
+
80
+ const appendUart = (chunk) => {
81
+ if (!chunk) return;
82
+ uartBuf += chunk;
83
+ if (uartBuf.length > 200000) uartBuf = uartBuf.slice(-200000);
84
+ const wasAtBottom = uartEl.scrollTop + uartEl.clientHeight >= uartEl.scrollHeight - 8;
85
+ uartEl.textContent = uartBuf;
86
+ if (wasAtBottom && $('chkAuto').checked) uartEl.scrollTop = uartEl.scrollHeight;
87
+ };
88
+
89
+ // ── packets ────────────────────────────────────────────────────────────────
90
+ const addFrame = (dir, pkt) => {
91
+ const meta = describeFrame(dir, pkt);
92
+ const div = document.createElement('div');
93
+ div.className = 'frame ' + dir;
94
+ div.innerHTML = `<span class="tag">${dir === 'tx' ? 'TX' : 'RX'}</span> ${pkt.length}B ${meta}<pre style="margin:2px 0 0;font-size:11px;color:var(--dim);white-space:pre-wrap;word-break:break-all;">${hex(pkt, 32)}</pre>`;
95
+ framesEl.prepend(div);
96
+ while (framesEl.children.length > 40) framesEl.lastChild.remove();
97
+ };
98
+
99
+ const describeFrame = (dir, pkt) => {
100
+ if (pkt.length < 42) return '';
101
+ const et = (pkt[12] << 8) | pkt[13];
102
+ if (et === 0x0806) return 'ARP';
103
+ if (et !== 0x0800) return '';
104
+ const proto = pkt[23];
105
+ if (proto === 17) {
106
+ const dp = (pkt[36] << 8) | pkt[37];
107
+ if (dp === 67) return 'DHCP';
108
+ if (dp === 68) return 'DHCP → server';
109
+ return 'UDP';
110
+ }
111
+ if (proto === 6) {
112
+ const sp = (pkt[34] << 8) | pkt[35], dp = (pkt[36] << 8) | pkt[37];
113
+ const fl = pkt[47];
114
+ const flS = ((fl & 0x2 ? 'S' : '') + (fl & 0x10 ? 'A' : '') + (fl & 0x08 ? 'P' : '') + (fl & 0x01 ? 'F' : '')).padEnd(3, '·');
115
+ const seq = ((pkt[38] << 24) | (pkt[39] << 16) | (pkt[40] << 8) | pkt[41]) >>> 0;
116
+ const ack = ((pkt[42] << 24) | (pkt[43] << 16) | (pkt[44] << 8) | pkt[45]) >>> 0;
117
+ return `TCP :${sp}→:${dp} fl=0x${fl.toString(16)} ${flS} seq=${seq} ack=${ack}`;
118
+ }
119
+ return `IP proto ${proto}`;
120
+ };
121
+
122
+ // ── gateway (real network stack, optional) ─────────────────────────────────
123
+ const gw = { ws: null, connected: false, tx: 0, rx: 0 };
124
+ const setGwStatus = (connected, text) => {
125
+ gw.connected = connected;
126
+ $('gwDot').className = 'dot ' + (connected ? 'run' : 'stop');
127
+ $('gwStatus').textContent = text;
128
+ $('btnGw').textContent = connected ? 'Disconnect' : 'Connect';
129
+ };
130
+
131
+ const gwLabel = () => `${gw.connected ? 'connected — ' + gw.tx + ' TX / ' + gw.rx + ' RX frames to the real stack' : 'offline — using the scripted network (netsim)'}`;
132
+ const refreshGwLabel = () => { if (gw.connected) $('gwStatus').textContent = gwLabel(); };
133
+
134
+ const connectGateway = () => {
135
+ const url = $('gwUrl').value.trim() || 'ws://127.0.0.1:5099/api/network-gateway';
136
+ if (!/^wss?:\/\//.test(url)) return setGwStatus(false, 'bad URL — expected ws:// or wss://');
137
+ if (gw.ws) {
138
+ try { gw.ws.close(); } catch (e) {}
139
+ gw.ws = null;
140
+ setGwStatus(false, gwLabel());
141
+ return;
142
+ }
143
+ let ws;
144
+ try { ws = new WebSocket(url); } catch (e) { return setGwStatus(false, 'connect failed: ' + e.message); }
145
+ ws.binaryType = 'arraybuffer';
146
+ ws.onopen = () => {
147
+ gw.ws = ws;
148
+ setGwStatus(true, gwLabel());
149
+ appendUart(`── gateway connected (${url}) ──\r\n`);
150
+ };
151
+ ws.onmessage = (ev) => {
152
+ if (typeof ev.data === 'string') return; // control message (e.g. RESET)
153
+ const buf = new Uint8Array(ev.data);
154
+ gw.rx++;
155
+ refreshGwLabel();
156
+ addFrame('rx', buf);
157
+ if (emu) emu.injectFrame(buf);
158
+ };
159
+ ws.onclose = () => {
160
+ if (gw.ws !== ws) return;
161
+ gw.ws = null;
162
+ setGwStatus(false, gwLabel());
163
+ };
164
+ ws.onerror = () => setGwStatus(false, gwLabel().replace('connected', 'connection error'));
165
+ };
166
+
167
+ // ── firmware loading ───────────────────────────────────────────────────────
168
+ const loadFirmwareBytes = (bytes, name) => {
169
+ image = { flash: new Uint8Array(bytes), ram: null, extraMem: [], entry: null, symbols: null, name, uartAddr: uartAddrFor(name) };
170
+ boot();
171
+ };
172
+
173
+ const loadHex = (text, name) => {
174
+ const img = parseIntelHex(text);
175
+ if (!img.flash) throw new Error('no flash data found in HEX file');
176
+ image = { flash: img.flash, ram: img.ram, extraMem: [], entry: img.entry, symbols: null, name, uartAddr: uartAddrFor(name) };
177
+ boot();
178
+ };
179
+
180
+ const loadElf = (bytes, name) => {
181
+ const img = parseElf(bytes);
182
+ if (!img.flash) throw new Error('ELF has no FLASH loadable segment');
183
+ image = { flash: img.flash, ram: img.ram, extraMem: img.extraMem, entry: img.entry, symbols: img.symbols, name, uartAddr: uartAddrFor(name) };
184
+ renderSymbols(img.symbols);
185
+ boot();
186
+ };
187
+
188
+ $('btnBoot').addEventListener('click', () => {
189
+ const fw = $('fwSelect').value;
190
+ image = { flash: decodeB64(FIRMWARES[fw].bytes), ram: null, extraMem: [], entry: null, symbols: null, name: fw, uartAddr: uartAddrFor(fw) };
191
+ renderSymbols(null);
192
+ boot();
193
+ });
194
+
195
+ $('fwFile').addEventListener('change', async (e) => {
196
+ const file = e.target.files[0];
197
+ if (!file) return;
198
+ const ext = file.name.slice(file.name.lastIndexOf('.')).toLowerCase();
199
+ try {
200
+ if (ext === '.bin') {
201
+ loadFirmwareBytes(new Uint8Array(await file.arrayBuffer()), file.name);
202
+ } else if (ext === '.hex') {
203
+ loadHex(await file.text(), file.name);
204
+ } else if (ext === '.elf') {
205
+ loadElf(new Uint8Array(await file.arrayBuffer()), file.name);
206
+ } else if (ext === '.map') {
207
+ const symbols = parseMap(await file.text());
208
+ if (!symbols.length) throw new Error('no symbols found in map file');
209
+ if (image) image.symbols = symbols;
210
+ renderSymbols(symbols);
211
+ appendUart(`── loaded ${symbols.length} symbols from ${file.name} ──\r\n`);
212
+ } else {
213
+ throw new Error('unsupported file type: ' + ext);
214
+ }
215
+ } catch (err) {
216
+ setStatus('load error: ' + err.message, 'err');
217
+ }
218
+ e.target.value = '';
219
+ });
220
+
221
+ // ── boot / run / stop / reset ──────────────────────────────────────────────
222
+ const raf = () => new Promise((r) => requestAnimationFrame(r));
223
+
224
+ const setBusy = (busy) => {
225
+ for (const id of ['btnRun', 'btnStop', 'btnReset']) $(id).disabled = !busy;
226
+ };
227
+
228
+ const boot = async () => {
229
+ const id = ++session;
230
+ running = false;
231
+ setBusy(false);
232
+ $('btnRun').textContent = 'Run';
233
+ setStatus('booting…', 'stop');
234
+ if (emu) { try { emu.close(); } catch (e) {} emu = null; }
235
+ oledCacheKey = ''; tftCacheKey = ''; buzzerCacheKey = ''; rtcCacheKey = '';
236
+ if (audioCtx) { try { audioCtx.close(); } catch (e) {} audioCtx = null; audioQueued = 0; }
237
+
238
+ const fw = image.flash;
239
+ uartEl.textContent = uartBuf = '';
240
+ framesEl.textContent = '';
241
+ totalInst = 0; stepsDone = 0;
242
+ t0 = lastT = performance.now(); lastInst = 0;
243
+ $('stFw').textContent = image.name;
244
+
245
+ const svdXml = await fetch('vendor/stm32f407.svd').then((r) => r.text());
246
+ if (id !== session) return;
247
+
248
+ netsim = gw.connected ? null : createNetSim();
249
+ gw.tx = 0; gw.rx = 0;
250
+ if (gw.connected) setGwStatus(true, gwLabel());
251
+ emu = await createEmulator({
252
+ firmware: fw,
253
+ bindings,
254
+ unicorn: MUnicorn,
255
+ svdXml,
256
+ extra_mem: image.extraMem,
257
+ uart_addr: image.uartAddr,
258
+ enable_irqs: IRQ_FIRMWARES.has(image.name),
259
+ irq_eth: IRQ_ETH_FIRMWARES.has(image.name),
260
+ eth: ETH_RX_MAP[image.name],
261
+ ext_devices: DEVICE_FIRMWARES[image.name],
262
+ onTx: (pkt) => {
263
+ addFrame('tx', pkt);
264
+ if (gw.connected && gw.ws) {
265
+ gw.tx++;
266
+ refreshGwLabel();
267
+ try { gw.ws.send(pkt); } catch (e) {}
268
+ } else if (netsim) {
269
+ for (const reply of netsim.onTx(pkt)) {
270
+ addFrame('rx', reply);
271
+ emu.injectFrame(reply);
272
+ }
273
+ }
274
+ },
275
+ });
276
+ if (id !== session) { emu.close(); return; }
277
+
278
+ appendUart(`── booted ${image.name} ${gw.connected ? '(gateway)' : '(netsim)'} ──\r\n`);
279
+ window.__emu = emu; // debug handle (CDP smoke tests)
280
+ window.__bindings = bindings;
281
+ running = true;
282
+ setBusy(true);
283
+ $('btnRun').textContent = 'Stop';
284
+ setStatus('running', 'run');
285
+ loop(id);
286
+ };
287
+
288
+ const loop = async (id) => {
289
+ while (session === id) {
290
+ if (!running) { await raf(); continue; }
291
+ try {
292
+ const res = emu.step();
293
+ totalInst = res.instCount;
294
+ stepsDone++;
295
+ } catch (e) {
296
+ setStatus('error: ' + e.message, 'err');
297
+ running = false;
298
+ $('btnRun').textContent = 'Run';
299
+ return;
300
+ }
301
+ appendUart(emu.drainUart());
302
+ refreshStats();
303
+ renderLtdc();
304
+ renderDevices();
305
+ await raf();
306
+ }
307
+ };
308
+
309
+ $('btnRun').addEventListener('click', () => {
310
+ if (!emu) return;
311
+ if (running) {
312
+ running = false;
313
+ $('btnRun').textContent = 'Run';
314
+ setStatus('stopped', 'stop');
315
+ } else {
316
+ running = true;
317
+ $('btnRun').textContent = 'Stop';
318
+ setStatus('running', 'run');
319
+ loop(session);
320
+ }
321
+ });
322
+ $('btnStop').addEventListener('click', () => {
323
+ if (!emu) return;
324
+ running = false;
325
+ $('btnRun').textContent = 'Run';
326
+ setStatus('stopped', 'stop');
327
+ });
328
+ $('btnReset').addEventListener('click', () => {
329
+ if (gw.connected && gw.ws) {
330
+ try { gw.ws.send('RESET'); } catch (e) {}
331
+ }
332
+ if (image) boot();
333
+ });
334
+ $('btnClear').addEventListener('click', () => { uartEl.textContent = uartBuf = ''; });
335
+ $('btnGw').addEventListener('click', connectGateway);
336
+
337
+ const sendRx = (term) => {
338
+ const input = $('rxInput');
339
+ const text = input.value;
340
+ const bytes = new Uint8Array(text.length + (term ? 1 : 0));
341
+ for (let i = 0; i < text.length; i++) bytes[i] = text.charCodeAt(i) & 0xFF;
342
+ if (term) bytes[text.length] = term;
343
+ if (!bytes.length || !emu) return;
344
+ appendUart('> ' + text + (term === 0x0D ? '\r' : term === 0x0A ? '\n' : '') + '\r\n');
345
+ emu.sendUart(bytes);
346
+ input.value = '';
347
+ };
348
+ $('btnSend').addEventListener('click', () => sendRx(0x0D));
349
+ $('rxInput').addEventListener('keydown', (e) => {
350
+ if (e.key !== 'Enter') return;
351
+ e.preventDefault();
352
+ sendRx(e.shiftKey ? 0x0A : 0x0D);
353
+ });
354
+ $('btnSaveLog').addEventListener('click', () => {
355
+ if (!uartBuf) return;
356
+ const a = document.createElement('a');
357
+ a.href = URL.createObjectURL(new Blob([uartBuf], { type: 'text/plain' }));
358
+ a.download = 'uart-' + (image ? image.name : 'log') + '.txt';
359
+ a.click();
360
+ URL.revokeObjectURL(a.href);
361
+ });
362
+ window.addEventListener('error', (e) => setStatus('error: ' + e.message, 'err'));
363
+ window.addEventListener('unhandledrejection', (e) => setStatus('boot error: ' + String(e.reason?.message || e.reason).replace(/\s+/g, ' ').slice(0, 300), 'err'));
364
+
365
+ // ── stats ──────────────────────────────────────────────────────────────────
366
+ const refreshStats = () => {
367
+ const regs = emu && emu.getRegisters ? emu.getRegisters() : null;
368
+ const now = performance.now();
369
+ const dt = (now - lastT) / 1000;
370
+ if (dt > 0.5) {
371
+ const mips = ((totalInst - lastInst) / dt / 1e6).toFixed(2);
372
+ lastInst = totalInst; lastT = now;
373
+ $('stMips').textContent = mips;
374
+ }
375
+ $('stInst').textContent = totalInst.toLocaleString();
376
+ $('stSteps').textContent = stepsDone.toLocaleString();
377
+ $('stRounds').textContent = (uartBuf.match(/=== HTTP \d+b ===/g) || []).length;
378
+ $('stPc').textContent = regs ? hex32(regs.PC) : '—';
379
+ $('stSp').textContent = regs ? hex32(regs.SP) : '—';
380
+ $('stXpsr').textContent = regs ? hex32(regs.XPSR) : '—';
381
+ refreshGpio();
382
+ refreshPeriph();
383
+ };
384
+
385
+ // ── LTDC display sink ──────────────────────────────────────────────────────
386
+ // Renders layer-0's framebuffer (ARGB8888 / RGB565) into the aside canvas
387
+ // each rAF once the guest enables the controller + layer. Cache-keyed so we
388
+ // only repaint when the framebuffer content actually changes (the model
389
+ // doesn't signal frame boundaries through a JS-readable counter change alone).
390
+ const LTDC = 0x40016800;
391
+ const ltdcCanvas = $('ltdcCanvas'), ltdcInfo = $('ltdcInfo');
392
+ let ltdcCtx = ltdcCanvas.getContext('2d');
393
+ let ltdcCacheKey = '';
394
+ let ltdcOff = document.createElement('canvas');
395
+
396
+ const renderLtdc = () => {
397
+ if (!emu || !ltdcCanvas.width) return;
398
+ try {
399
+ const gcr = emu.read32(LTDC + 0x18);
400
+ const l1cr = emu.read32(LTDC + 0x84);
401
+ if (!(gcr & 1) || !(l1cr & 1)) { ltdcInfo.textContent = 'scanout idle'; return; }
402
+ const pf = emu.read32(LTDC + 0x94) & 7;
403
+ const cfbar = emu.read32(LTDC + 0xAC) >>> 0;
404
+ const cfblr = emu.read32(LTDC + 0xB0) >>> 0;
405
+ const cfblnr = emu.read32(LTDC + 0xB4) & 0x7FF;
406
+ const whpcr = emu.read32(LTDC + 0x88) >>> 0;
407
+ const wvpcr = emu.read32(LTDC + 0x8C) >>> 0;
408
+ const bpp = pf === 2 ? 2 : 4;
409
+ const lineBytes = Math.min(cfblr & 0x1FFF, 640 * bpp);
410
+ const w = Math.min((((whpcr & 0xFFF) + 1) || (lineBytes / bpp)) | 0, 640);
411
+ const h = Math.min((((whpcr >> 16) & 0xFFF) + 1) | 0, 640);
412
+ const lines = Math.min(cfblnr || h, 640);
413
+ const pitch = Math.min((cfblr >>> 16) || lineBytes, w * bpp + 8);
414
+ if (!w || !lines || cfbar < 0x20000000 || cfbar >= 0x20040000) {
415
+ ltdcInfo.textContent = 'layer enabled, waiting for framebuffer…';
416
+ return;
417
+ }
418
+ const key = pf + ':' + w + 'x' + lines + ':' + cfbar + ':' + pitch + ':' + (bindings.ltdc_get_frame_count ? bindings.ltdc_get_frame_count() : 0);
419
+ if (key === ltdcCacheKey) return;
420
+ ltdcCacheKey = key;
421
+ // Read (only the used width bytes per line — the model guest RAM is
422
+ // byte-exact, so no opacity/color-key handling beyond the format).
423
+ const rowBytes = Math.min(w * bpp, pitch || w * bpp);
424
+ const bytes = new Uint8Array(w * lines * bpp);
425
+ for (let y = 0; y < lines; y++) {
426
+ const row = emu.uc.mem_read(BigInt(cfbar + y * pitch), rowBytes);
427
+ bytes.set(new Uint8Array(row.buffer, row.byteOffset, rowBytes), y * w * bpp);
428
+ }
429
+ ltdcOff.width = w; ltdcOff.height = lines;
430
+ const octx = ltdcOff.getContext('2d');
431
+ const img = octx.createImageData(w, lines);
432
+ const d = img.data;
433
+ for (let i = 0, p = 0; i < w * lines; i++) {
434
+ if (pf === 2) {
435
+ const v = bytes[p] | (bytes[p + 1] << 8); p += 2;
436
+ d[i * 4] = ((v >> 11) & 0x1F) << 3;
437
+ d[i * 4 + 1] = ((v >> 5) & 0x3F) << 2;
438
+ d[i * 4 + 2] = (v & 0x1F) << 3;
439
+ d[i * 4 + 3] = 255;
440
+ } else {
441
+ d[i * 4] = bytes[p + 2]; d[i * 4 + 1] = bytes[p + 1];
442
+ d[i * 4 + 2] = bytes[p]; d[i * 4 + 3] = bytes[p + 3];
443
+ p += 4;
444
+ }
445
+ }
446
+ octx.putImageData(img, 0, 0);
447
+ ltdcCtx.imageSmoothingEnabled = false;
448
+ ltdcCtx.drawImage(ltdcOff, 0, 0, ltdcCanvas.width, ltdcCanvas.height);
449
+ ltdcInfo.textContent = `layer0 ${w}×${lines} ${pf === 2 ? 'RGB565' : 'ARGB8888'} @ ${hex32(cfbar)}`;
450
+ } catch (e) { /* device disabled mid-read — repaint next rAF */ }
451
+ };
452
+
453
+ // ── virtual devices (OLED / TFT / buzzer / speaker) ────────────────────────
454
+ const oledCanvas = $('oledCanvas'), oledInfo = $('oledInfo');
455
+ const oledCtx = oledCanvas.getContext('2d');
456
+ let oledCacheKey = '';
457
+ const renderOled = () => {
458
+ if (!emu || !emu.oled) { oledInfo.textContent = 'no OLED firmware'; return; }
459
+ const key = emu.oled.frame();
460
+ if (key === oledCacheKey) return;
461
+ oledCacheKey = key;
462
+ const fb = emu.oled.fb, img = oledCtx.createImageData(128, 64), d = img.data;
463
+ for (let i = 0; i < 128 * 64; i++) {
464
+ const v = fb[i] ? 255 : 0;
465
+ d[i * 4] = v; d[i * 4 + 1] = v; d[i * 4 + 2] = v; d[i * 4 + 3] = 255;
466
+ }
467
+ oledCtx.imageSmoothingEnabled = false;
468
+ oledCtx.putImageData(img, 0, 0);
469
+ oledInfo.textContent = `SSD1306 128×64 framebuffer — frame ${key}`;
470
+ };
471
+
472
+ const tftCanvas = $('tftCanvas'), tftInfo = $('tftInfo');
473
+ const tftCtx = tftCanvas.getContext('2d');
474
+ let tftCacheKey = '';
475
+ const renderTft = () => {
476
+ if (!emu || !emu.tft) { tftInfo.textContent = 'no TFT firmware'; return; }
477
+ const { w, h, fb } = emu.tft;
478
+ const key = emu.tft.frame() + ':' + w + 'x' + h;
479
+ if (key === tftCacheKey) return;
480
+ tftCacheKey = key;
481
+ const img = tftCtx.createImageData(w, h), d = img.data;
482
+ for (let i = 0, p = 0; i < w * h; i++, p += 2) {
483
+ const v = (fb[p] << 8) | fb[p + 1]; // parser stores RGB565 big-endian
484
+ d[i * 4] = ((v >> 11) & 0x1F) << 3;
485
+ d[i * 4 + 1] = ((v >> 5) & 0x3F) << 2;
486
+ d[i * 4 + 2] = (v & 0x1F) << 3;
487
+ d[i * 4 + 3] = 255;
488
+ }
489
+ tftCtx.imageSmoothingEnabled = false;
490
+ tftCtx.putImageData(img, 0, 0);
491
+ tftInfo.textContent = `ILI9341 ${w}×${h} RGB565 — frame ${emu.tft.frame()}`;
492
+ };
493
+
494
+ const buzzerInfo = $('buzzerInfo');
495
+ let buzzerCacheKey = '';
496
+ const renderBuzzer = () => {
497
+ if (!emu || !emu.buzzer) { buzzerInfo.textContent = 'no buzzer firmware'; return; }
498
+ const f = emu.buzzer.freq, duty = emu.buzzer.duty, ch = emu.buzzer.change;
499
+ const key = f.toFixed(1) + '/' + duty.toFixed(3);
500
+ if (key === buzzerCacheKey) return;
501
+ buzzerCacheKey = key;
502
+ buzzerInfo.textContent = f > 1
503
+ ? `TIM2 CH1 PWM ${f.toFixed(0)} Hz, duty ${(duty * 100).toFixed(0)}% — ${ch} note changes`
504
+ : `TIM2 idle — ${ch} note changes`;
505
+ };
506
+
507
+ const speakerInfo = $('speakerInfo');
508
+ let audioCtx = null, audioNextTime = 0, audioQueued = 0;
509
+ const renderSpeaker = () => {
510
+ if (!emu || !emu.takeSpeakerSamples) { speakerInfo.textContent = 'no audio firmware'; return; }
511
+ const samples = emu.takeSpeakerSamples();
512
+ if (!samples.length) return;
513
+ if (!audioCtx) {
514
+ try { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) {}
515
+ if (!audioCtx) { speakerInfo.textContent = 'WebAudio unavailable'; return; }
516
+ audioNextTime = audioCtx.currentTime + 0.05;
517
+ }
518
+ if (audioCtx.state === 'suspended') audioCtx.resume();
519
+ let off = 0;
520
+ while (off < samples.length) {
521
+ const n = Math.min(4096, samples.length - off);
522
+ const buf = audioCtx.createBuffer(1, n, audioCtx.sampleRate);
523
+ const ch = buf.getChannelData(0);
524
+ for (let i = 0; i < n; i++) ch[i] = samples[off + i];
525
+ const src = audioCtx.createBufferSource();
526
+ src.buffer = buf;
527
+ src.connect(audioCtx.destination);
528
+ src.start(audioNextTime);
529
+ audioNextTime += n / audioCtx.sampleRate;
530
+ off += n;
531
+ audioQueued += n;
532
+ }
533
+ speakerInfo.textContent = `I2S capture playing — ${(audioQueued / audioCtx.sampleRate).toFixed(1)} s queued`;
534
+ };
535
+
536
+ let rtcCacheKey = '';
537
+ const renderRtc = () => {
538
+ if (!emu || !emu.rtc || !emu.rtc.time) {
539
+ if (!emu || !emu.rtc) $('rtcInfo').textContent = 'no RTC firmware';
540
+ return;
541
+ }
542
+ const t = emu.rtc.time, temp = emu.rtc.temp;
543
+ const key = `${t.sec}:${t.min}:${t.hour}:${t.dow}:${t.day}:${t.mon}:${t.year}:${temp}`;
544
+ if (key === rtcCacheKey) return;
545
+ rtcCacheKey = key;
546
+ const pad = (n) => String(n).padStart(2, '0');
547
+ $('rtcInfo').textContent =
548
+ `time ${pad(t.hour)}:${pad(t.min)}:${pad(t.sec)} DOW=${t.dow} ` +
549
+ `${pad(t.day)}/${pad(t.mon)}/${pad(t.year)} temp=${temp.toFixed(2)} C`;
550
+ };
551
+
552
+ const renderDevices = () => { renderOled(); renderTft(); renderBuzzer(); renderSpeaker(); renderRtc(); };
553
+
554
+ // ── GPIO banks A–E ─────────────────────────────────────────────────────────
555
+ const GPIO_BASE = 0x40020000, GPIO_STRIDE = 0x400;
556
+ const BANKS = ['A', 'B', 'C', 'D', 'E'];
557
+ let gpioBuilt = false;
558
+
559
+ const buildGpio = () => {
560
+ if (gpioBuilt) return;
561
+ gpioBuilt = true;
562
+ const el = $('gpio');
563
+ for (const b of BANKS) {
564
+ const div = document.createElement('div');
565
+ div.className = 'bank';
566
+ div.innerHTML = `<span class="bname">P${b}</span><span class="pins">${Array.from({ length: 16 }, (_, i) => `<span class="pin" id="pin${b}${i}"></span>`).join('')}</span>`;
567
+ const regs = document.createElement('div');
568
+ regs.className = 'regs';
569
+ regs.id = 'regs' + b;
570
+ div.appendChild(regs);
571
+ el.appendChild(div);
572
+ }
573
+ };
574
+
575
+ const refreshGpio = () => {
576
+ if (!emu) return;
577
+ buildGpio();
578
+ for (const b of BANKS) {
579
+ const idx = BANKS.indexOf(b);
580
+ const base = GPIO_BASE + idx * GPIO_STRIDE;
581
+ const moder = emu.read32(base + 0x00), odr = emu.read32(base + 0x14), idr = emu.read32(base + 0x10);
582
+ for (let p = 0; p < 16; p++) {
583
+ const mode = (moder >> (p * 2)) & 0x3;
584
+ const out = mode === 1;
585
+ const on = out ? ((odr >> p) & 1) !== 0 : ((idr >> p) & 1) !== 0;
586
+ const pin = $('pin' + b + p);
587
+ pin.className = 'pin ' + (out ? (on ? 'out-on' : 'out-off') : on ? 'in-on' : '');
588
+ }
589
+ $('regs' + b).textContent = `MODER=${hex32(moder)} ODR=${hex32(odr)} IDR=${hex32(idr)}`;
590
+ }
591
+ };
592
+
593
+ // ── peripherals ────────────────────────────────────────────────────────────
594
+ const PERIPH_REGS = [
595
+ ['ETH DMASR', 0x40029014], ['ETH MACCR', 0x40028000],
596
+ ['USART1 SR', 0x40011000], ['USART1 BRR', 0x40011008],
597
+ ['RCC AHB1ENR', 0x40023830], ['GPIOA ODR', 0x40020014],
598
+ ];
599
+ const refreshPeriph = () => {
600
+ if (!emu) return;
601
+ const el = $('periph');
602
+ el.innerHTML = '';
603
+ for (const [name, addr] of PERIPH_REGS) {
604
+ const div = document.createElement('div');
605
+ div.className = 'line';
606
+ div.innerHTML = `<span>${name} (${hex32(addr)})</span><b>${hex32(emu.read32(addr))}</b>`;
607
+ el.appendChild(div);
608
+ }
609
+ };
610
+
611
+ // ── symbols ────────────────────────────────────────────────────────────────
612
+ const renderSymbols = (symbols) => {
613
+ const el = $('symbols');
614
+ el.textContent = '';
615
+ if (!symbols || !symbols.length) {
616
+ const p = document.createElement('p');
617
+ p.className = 'sec';
618
+ p.style.padding = '8px 12px';
619
+ p.textContent = 'Load a .map file or an .elf to list symbols here.';
620
+ el.appendChild(p);
621
+ return;
622
+ }
623
+ const max = 200;
624
+ for (const s of symbols.slice(0, max)) {
625
+ const div = document.createElement('div');
626
+ div.className = 'sym';
627
+ div.innerHTML = `<span>${s.name}</span><span>${hex32(s.addr)}${s.size ? ' (' + s.size + ')' : ''}</span>`;
628
+ el.appendChild(div);
629
+ }
630
+ if (symbols.length > max) {
631
+ const p = document.createElement('p');
632
+ p.className = 'sec';
633
+ p.style.padding = '4px 12px';
634
+ p.textContent = `… ${symbols.length - max} more`;
635
+ el.appendChild(p);
636
+ }
637
+ };
638
+
639
+ // ── idle until the user picks a firmware (auto-boot only via ?fw=) ─────────
640
+ setBusy(false);
641
+ $('btnRun').textContent = 'Run';
642
+ setStatus('idle — select a firmware and press Boot', 'stop');
643
+ // "below" was wrong — the Firmware panel is in the sidebar to the right
644
+ // (and the panel names now match what the sidebar actually says).
645
+ appendUart('STM32F407 console ready. Nothing is running yet.\r\n'
646
+ + 'Pick a firmware under FIRMWARE (right) and press "Boot preset",\r\n'
647
+ + 'or upload your own .bin/.hex/.elf/.map. Firmware output appears here.\r\n');
648
+
649
+ const params = new URLSearchParams(location.search);
650
+ const preset = params.get('fw');
651
+ if (preset && FIRMWARES[preset]) {
652
+ $('fwSelect').value = preset;
653
+ $('btnBoot').click();
654
+ }