import { ASSETS } from './assets.js'; const $ = (id) => document.getElementById(id); const worker = new Worker(new URL('./js/worker.js', import.meta.url), { type: 'module' }); let ready = false, running = false, paused = false; let files = []; // {name, data:Uint8Array, addr:number} let curExample = null; // ---------- helpers ---------- async function gunzipB64(b64) { const bin = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); const ds = new DecompressionStream('gzip'); const stream = new Blob([bin]).stream().pipeThrough(ds); return new Uint8Array(await new Response(stream).arrayBuffer()); } const hex = (n) => '0x' + n.toString(16).toUpperCase(); function setStat(t) { $('stat').textContent = t; } // ---------- boot ---------- async function init() { setStat('載入 ROM…'); const [pro, app, wasm] = await Promise.all([gunzipB64(ASSETS.rom_pro), gunzipB64(ASSETS.rom_app), gunzipB64(ASSETS.wasm)]); worker.postMessage({ type: 'roms', pro: pro.buffer, app: app.buffer, wasm: wasm.buffer }, [pro.buffer, app.buffer, wasm.buffer]); const sel = $('example'); for (const ex of ASSETS.examples) { const o = document.createElement('option'); o.value = ex.id; o.textContent = ex.title; sel.appendChild(o); } buildKbd(); buildGpio(); buildBoard(); } worker.onmessage = (e) => { const m = e.data; switch (m.type) { case 'ready': ready = true; setStat('就緒 — 按「載入並執行」'); if (!window.__noAuto) runExample($('example').value || ASSETS.examples[0].id); break; case 'booted': running = true; setStat('執行中…'); $('btnPause').textContent = '⏸ 暫停'; paused = false; break; case 'frame': drawFrame(m); break; case 'uart': if (m.n === 0) appendSerial(m.data); break; case 'status': onStatus(m); break; case 'gpio': updateGpio(m); break; case 'flash': { const a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([m.data])); a.download = 'esp32-flash.bin'; a.click(); break; } case 'error': setStat('錯誤: ' + m.text); break; } }; function buildFlash(parts, sizeMB) { const size = sizeMB * 1024 * 1024; const img = new Uint8Array(size).fill(0xFF); for (const p of parts) { if (p.addr + p.data.length > size) throw new Error(p.name + ' 超出 flash 大小'); img.set(p.data, p.addr); } return img; } function startBoot(flash, psram) { $('serial').textContent = ''; worker.postMessage({ type: 'boot', flash: flash.buffer, psram, cpi: 1 }, [flash.buffer]); $('noSignal').style.display = 'flex'; } async function runExample(id) { const ex = ASSETS.examples.find((x) => x.id === id); if (!ex) return; curExample = ex; setStat('解壓縮 ' + ex.title + '…'); const flash = await gunzipB64(ex.flash); $('chkPsram').checked = !!ex.psram; $('selFlash').value = String(Math.max(2, Math.min(16, flash.length / 1048576))); files = [{ name: ex.id + '.flash.bin', data: flash.slice(), addr: 0, fromExample: true }]; renderFiles(); startBoot(flash, !!ex.psram); } $('btnRun').onclick = () => { if (ready) runExample($('example').value); }; $('example').onchange = () => { if (ready) runExample($('example').value); }; $('btnReset').onclick = () => worker.postMessage({ type: 'reset' }); $('btnPause').onclick = () => { paused = !paused; worker.postMessage({ type: 'pause', value: paused }); $('btnPause').textContent = paused ? '▶ 繼續' : '⏸ 暫停'; }; $('chkTurbo').onchange = (e) => worker.postMessage({ type: 'turbo', value: e.target.checked }); $('selSpeed').onchange = (e) => worker.postMessage({ type: 'speed', value: parseFloat(e.target.value) }); $('btnSave').onclick = () => worker.postMessage({ type: 'getFlash' }); $('btnFiles').onclick = () => { $('filesBox').open = true; $('fileIn').click(); }; // ---------- flash file table ---------- function guessAddr(name, data) { const n = name.toLowerCase(); if (data.length >= 0x100000 && (data.length & (data.length - 1)) === 0) return 0; if (n.includes('bootloader')) return 0x1000; if (n.includes('partition')) return 0x8000; if (n.includes('boot_app0') || n.includes('otadata')) return 0xE000; if (data[0] === 0xE9) return 0x10000; if (n.endsWith('.img') || n.includes('disk') || n.includes('fat')) return 0x200000; return 0x10000; } function renderFiles() { const tb = $('fileTbl').querySelector('tbody'); tb.innerHTML = ''; files.forEach((f, i) => { const tr = document.createElement('tr'); tr.innerHTML = `${f.name}${f.data.length}`; tr.querySelector('input').onchange = (e) => { const v = parseInt(e.target.value, 16) || parseInt(e.target.value, 10) || 0; f.addr = v; e.target.value = hex(v); }; tr.querySelector('button').onclick = () => { files.splice(i, 1); renderFiles(); }; tb.appendChild(tr); }); } async function addFiles(list) { $('filesBox').open = true; for (const file of list) { const data = new Uint8Array(await file.arrayBuffer()); files = files.filter((f) => !f.fromExample); files.push({ name: file.name, data, addr: guessAddr(file.name, data) }); } // single app image without boot files -> add defaults if (!files.some((f) => f.addr === 0) && !files.some((f) => f.addr === 0x1000)) addDefaults(); renderFiles(); } async function addDefaults() { const defs = [['bootloader.bin', ASSETS.bootloader, 0x1000], ['partitions.bin', ASSETS.partitions, 0x8000], ['boot_app0.bin', ASSETS.boot_app0, 0xE000]]; for (const [name, b64, addr] of defs) if (!files.some((f) => f.addr === addr)) files.push({ name, data: await gunzipB64(b64), addr }); renderFiles(); } $('fileIn').onchange = (e) => { addFiles(e.target.files); e.target.value = ''; }; $('btnAddDefaults').onclick = addDefaults; $('btnBoot').onclick = () => { try { const mb = parseInt($('selFlash').value); const full = files.find((f) => f.addr === 0 && f.data.length >= 0x100000); const flash = buildFlash(files, full ? Math.max(mb, Math.ceil(full.data.length / 1048576)) : mb); startBoot(flash, $('chkPsram').checked); } catch (err) { setStat('錯誤: ' + err.message); } }; document.addEventListener('dragover', (e) => e.preventDefault()); document.addEventListener('drop', (e) => { e.preventDefault(); if (e.dataTransfer.files.length) { $('filesBox').open = true; addFiles(e.dataTransfer.files); } }); // ---------- display ---------- const canvas = $('screen'), ctx = canvas.getContext('2d'); let lastW = 0, lastH = 0, frames = 0; let pendingFrame = null, raf = 0; function drawFrame(m) { if (pendingFrame) worker.postMessage({ type: 'frameAck' }); // dropped frame pendingFrame = m; if (!raf) raf = requestAnimationFrame(paint); } function paint() { raf = 0; const m = pendingFrame; if (!m) return; pendingFrame = null; if (m.w !== lastW || m.h !== lastH) { canvas.width = m.w; canvas.height = m.h; lastW = m.w; lastH = m.h; $('vgaInfo').textContent = `VGA ${m.w}×${m.h}`; } ctx.putImageData(new ImageData(new Uint8ClampedArray(m.data.buffer), m.w, m.h), 0, 0); $('noSignal').style.display = 'none'; frames++; worker.postMessage({ type: 'frameAck' }); } $('chkStretch').onchange = (e) => canvas.classList.toggle('native', !e.target.checked); $('btnFull').onclick = () => { const w = $('screenWrap'); if (document.fullscreenElement) document.exitFullscreen(); else w.requestFullscreen?.(); }; // ---------- serial ---------- const dec = new TextDecoder('utf-8'); function appendSerial(bytes) { const el = $('serial'); const atEnd = el.scrollTop + el.clientHeight >= el.scrollHeight - 8; el.textContent += dec.decode(bytes, { stream: true }).replace(/\r/g, ''); if (el.textContent.length > 200000) el.textContent = el.textContent.slice(-100000); if (atEnd) el.scrollTop = el.scrollHeight; } $('serialIn').onkeydown = (e) => { if (e.key === 'Enter') { const t = e.target.value + '\n'; worker.postMessage({ type: 'uartRx', n: 0, data: new TextEncoder().encode(t) }); e.target.value = ''; } }; $('btnSerialClr').onclick = () => { $('serial').textContent = ''; }; // ---------- status ---------- function onStatus(m) { const s = m.virtMs / 1000; setStat(`虛擬時間 ${s.toFixed(1)}s · CPU ${m.cpuMHz.toFixed(0)}MHz · ${m.mips.toFixed(0)} MIPS · 速度 ${(m.ratio * 100).toFixed(0)}% · ${m.engine === 'wasm' ? 'WASM' : 'JS'} 核心`); } // ---------- GPIO panel ---------- const ROLE = {}; [[22, 'R1'], [21, 'R0'], [19, 'G1'], [18, 'G0'], [5, 'B1'], [4, 'B0'], [23, 'HS'], [15, 'VS'], [33, 'KCLK'], [32, 'KDAT'], [26, 'MCLK'], [27, 'MDAT'], [25, 'AUD'], [13, 'SDCS'], [12, 'MOSI'], [2, 'MISO'], [14, 'SCK'], [1, 'TX0'], [3, 'RX0']].forEach(([p, r]) => { ROLE[p] = r; }); function buildGpio() { const g = $('gpioGrid'); g.innerHTML = ''; for (let i = 0; i < 40; i++) { if ([20, 24, 28, 29, 30, 31].includes(i)) continue; const d = document.createElement('div'); d.className = 'pin'; d.id = 'pin' + i; d.textContent = i + (ROLE[i] ? '\n' + ROLE[i] : ''); d.style.whiteSpace = 'pre'; d.title = 'GPIO' + i + (ROLE[i] ? ' (' + ROLE[i] + ')' : ''); g.appendChild(d); } } function buildBoard() { const svg = $('board'); const NS = 'http://www.w3.org/2000/svg'; const add = (tag, attrs, text) => { const e = document.createElementNS(NS, tag); for (const k in attrs) e.setAttribute(k, attrs[k]); if (text) e.textContent = text; svg.appendChild(e); return e; }; add('rect', { x: 4, y: 4, width: 352, height: 142, rx: 8, fill: '#0f2a1a', stroke: '#2f7a4c' }); add('rect', { x: 130, y: 34, width: 100, height: 72, rx: 4, fill: '#14202c', stroke: '#6aa' }); add('text', { x: 180, y: 66, 'text-anchor': 'middle', fill: '#9cd', 'font-size': 11 }, 'ESP32'); add('text', { x: 180, y: 80, 'text-anchor': 'middle', fill: '#789', 'font-size': 8 }, 'WROVER · 4MB PSRAM'); const pins = (title, x, y, list) => { add('text', { x, y: y - 6, fill: '#7fd', 'font-size': 8 }, title); list.forEach(([g, name], i) => { add('circle', { id: 'ind' + g, cx: x + 4, cy: y + 7 + i * 12, r: 3.5, fill: '#234', stroke: '#6a8' }); add('text', { x: x + 11, y: y + 10 + i * 12, fill: '#bde', 'font-size': 8 }, name + ' G' + g); }); }; pins('VGA (DB15)', 12, 22, [[22, 'R1'], [21, 'R0'], [19, 'G1'], [18, 'G0'], [5, 'B1'], [4, 'B0'], [23, 'HS'], [15, 'VS']]); pins('PS/2 KBD', 250, 22, [[33, 'CLK'], [32, 'DATA']]); pins('PS/2 MOUSE', 250, 58, [[26, 'CLK'], [27, 'DATA']]); pins('SD (SPI)', 250, 96, [[13, 'CS'], [12, 'MOSI'], [2, 'MISO'], [14, 'SCK']]); pins('AUDIO', 135, 118, [[25, 'DAC']]); pins('UART0', 135, 20, []); } function updateGpio(m) { for (const g of [22, 21, 19, 18, 5, 4, 23, 15, 33, 32, 26, 27, 13, 12, 2, 14, 25]) { const e = document.getElementById('ind' + g); if (e) e.setAttribute('fill', m.lv[g] ? (m.drv[g] >= 0 ? '#3ddc84' : '#2a9d5c') : '#234'); } for (let i = 0; i < 40; i++) { const d = $('pin' + i); if (!d) continue; d.classList.toggle('hi', !!m.lv[i]); d.classList.toggle('drv', m.drv[i] >= 0); } } // ---------- virtual PS/2 keyboard ---------- const KB = [ [['Esc', 'Escape'], ['F1', 'F1'], ['F2', 'F2'], ['F3', 'F3'], ['F4', 'F4'], ['F5', 'F5'], ['F6', 'F6'], ['F7', 'F7'], ['F8', 'F8'], ['F9', 'F9'], ['F10', 'F10'], ['F11', 'F11'], ['F12', 'F12']], [['`', 'Backquote'], ['1', 'Digit1'], ['2', 'Digit2'], ['3', 'Digit3'], ['4', 'Digit4'], ['5', 'Digit5'], ['6', 'Digit6'], ['7', 'Digit7'], ['8', 'Digit8'], ['9', 'Digit9'], ['0', 'Digit0'], ['-', 'Minus'], ['=', 'Equal'], ['⌫', 'Backspace', 2]], [['Tab', 'Tab', 1.5], ['Q', 'KeyQ'], ['W', 'KeyW'], ['E', 'KeyE'], ['R', 'KeyR'], ['T', 'KeyT'], ['Y', 'KeyY'], ['U', 'KeyU'], ['I', 'KeyI'], ['O', 'KeyO'], ['P', 'KeyP'], ['[', 'BracketLeft'], [']', 'BracketRight'], ['\\', 'Backslash', 1.5]], [['Caps', 'CapsLock', 1.8], ['A', 'KeyA'], ['S', 'KeyS'], ['D', 'KeyD'], ['F', 'KeyF'], ['G', 'KeyG'], ['H', 'KeyH'], ['J', 'KeyJ'], ['K', 'KeyK'], ['L', 'KeyL'], [';', 'Semicolon'], ["'", 'Quote'], ['Enter', 'Enter', 2.2]], [['Shift', 'ShiftLeft', 2.3, 1], ['Z', 'KeyZ'], ['X', 'KeyX'], ['C', 'KeyC'], ['V', 'KeyV'], ['B', 'KeyB'], ['N', 'KeyN'], ['M', 'KeyM'], [',', 'Comma'], ['.', 'Period'], ['/', 'Slash'], ['Shift', 'ShiftRight', 2.7, 1]], [['Ctrl', 'ControlLeft', 1.6, 1], ['Alt', 'AltLeft', 1.6, 1], ['Space', 'Space', 7], ['Alt', 'AltRight', 1.4, 1], ['Ctrl', 'ControlRight', 1.6, 1], ['←', 'ArrowLeft'], ['↑', 'ArrowUp'], ['↓', 'ArrowDown'], ['→', 'ArrowRight']], [['Ins', 'Insert'], ['Del', 'Delete'], ['Home', 'Home'], ['End', 'End'], ['PgUp', 'PageUp'], ['PgDn', 'PageDown'], ['Num', 'NumLock'], ['Scr', 'ScrollLock'], ['PrtSc', 'PrintScreen']], ]; const latched = new Set(); function sendKey(code, down) { worker.postMessage({ type: 'key', code, down }); } function tap(code) { sendKey(code, true); setTimeout(() => sendKey(code, false), 55); } function buildKbd() { const root = $('vkbd'); root.innerHTML = ''; for (const row of KB) { const r = document.createElement('div'); r.className = 'krow'; for (const [label, code, w = 1, mod] of row) { const b = document.createElement('button'); b.className = 'key' + (mod ? ' mod' : ''); b.textContent = label; b.style.flexGrow = w; b.dataset.code = code; const press = (e) => { e.preventDefault(); if (mod) { if (latched.has(code)) { latched.delete(code); b.classList.remove('latched'); sendKey(code, false); } else { latched.add(code); b.classList.add('latched'); sendKey(code, true); } } else { sendKey(code, true); b.classList.add('down'); const up = () => { sendKey(code, false); b.classList.remove('down'); for (const c of [...latched]) { latched.delete(c); sendKey(c, false); } root.querySelectorAll('.latched').forEach((x) => x.classList.remove('latched')); window.removeEventListener('pointerup', up); b.removeEventListener('pointerleave', up); }; window.addEventListener('pointerup', up); b.addEventListener('pointerleave', up); } }; b.addEventListener('pointerdown', press); r.appendChild(b); } root.appendChild(r); } } // ---------- physical keyboard capture ---------- let capture = false; const wrap = $('screenWrap'); function setCapture(v) { capture = v; wrap.classList.toggle('focus', v); if (v) wrap.focus(); } wrap.addEventListener('click', () => setCapture(true)); document.addEventListener('keydown', (e) => { if (!capture) return; if (e.target && /INPUT|SELECT|TEXTAREA/.test(e.target.tagName)) return; if (e.code === 'Escape' && e.shiftKey) { setCapture(false); return; } e.preventDefault(); if (!e.repeat) sendKey(e.code, true); else sendKey(e.code, true); }, true); document.addEventListener('keyup', (e) => { if (!capture) return; if (e.target && /INPUT|SELECT|TEXTAREA/.test(e.target.tagName)) return; e.preventDefault(); sendKey(e.code, false); }, true); document.addEventListener('click', (e) => { if (!wrap.contains(e.target) && !$('vkbd').contains(e.target)) setCapture(false); }, true); // ---------- PS/2 mouse over the screen (hold button + move) ---------- let mdown = 0, lastMx = 0, lastMy = 0; canvas.addEventListener('pointerdown', (e) => { mdown = e.buttons; lastMx = e.clientX; lastMy = e.clientY; }); canvas.addEventListener('pointermove', (e) => { if (!e.buttons && !mdown) return; const sx = (canvas.width / canvas.getBoundingClientRect().width), sy = (canvas.height / canvas.getBoundingClientRect().height); worker.postMessage({ type: 'mouse', dx: Math.round((e.clientX - lastMx) * sx), dy: -Math.round((e.clientY - lastMy) * sy), buttons: e.buttons }); lastMx = e.clientX; lastMy = e.clientY; }); window.addEventListener('pointerup', () => { if (mdown) { mdown = 0; worker.postMessage({ type: 'mouse', dx: 0, dy: 0, buttons: 0 }); } }); init();