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();