const FLASH_SIZE = 16 * 1024 * 1024; const BOOTLOADER_OFFSET = 0x0; const PARTITIONS_OFFSET = 0x8000; const APP_OFFSET = 0x10000; const ESP_IMAGE_MAGIC = 0xe9; const RELEASED_ADC = 4095; export const BUTTONS = { back: { type: 'adc', channel: 1, value: 3512 }, confirm: { type: 'adc', channel: 1, value: 2694 }, left: { type: 'adc', channel: 1, value: 1493 }, right: { type: 'adc', channel: 1, value: 5 }, up: { type: 'adc', channel: 2, value: 2242 }, down: { type: 'adc', channel: 2, value: 5 }, power: { type: 'gpio', pin: 3 }, }; // Assemble Flash Image - m25p80 requires a backing file exactly the chip size (16 MB), so a bare app image is placed into a 0xFF-erased 16 MB flash alongside the bootloader and partition table. export function mergeFirmware(app, bootloader, partitions) { if (app[0] !== ESP_IMAGE_MAGIC) { throw new Error('Not an ESP32 app image (missing 0xE9 magic byte).'); } if (APP_OFFSET + app.length > FLASH_SIZE) { throw new Error(`App image is too large for the ${FLASH_SIZE / 1024 / 1024} MB flash.`); } const flash = new Uint8Array(FLASH_SIZE).fill(0xff); flash.set(bootloader, BOOTLOADER_OFFSET); flash.set(partitions, PARTITIONS_OFFSET); flash.set(app, APP_OFFSET); return flash; } async function fetchBin(url) { const response = await fetch(url); if (!response.ok) { throw new Error(`Could not load ${url.pathname} (${response.status}).`); } return new Uint8Array(await response.arrayBuffer()); } // A full 16 MB image is used as-is; anything else is treated as a CrossPoint app image and merged with the bundled bootloader and partition table. async function prepareFirmware(bytes, assetRoot) { if (bytes.length === FLASH_SIZE) { return bytes; } const [bootloader, partitions] = await Promise.all([ fetchBin(new URL('assets/esp32c3-bootloader.bin', assetRoot)), fetchBin(new URL('assets/crosspoint-partitions.bin', assetRoot)), ]); return mergeFirmware(bytes, bootloader, partitions); } export function setButton(module, name, pressed) { const button = BUTTONS[name]; if (!button) { throw new Error(`Unknown button: ${name}`); } if (button.type === 'adc') { module._xteink_wasm_set_adc(button.channel, pressed ? button.value : RELEASED_ADC); } else { module._xteink_wasm_set_gpio(button.pin, pressed ? 0 : 1); } } function drawFrame(module, canvas) { const width = module._xteink_wasm_width(); const height = module._xteink_wasm_height(); const stride = module._xteink_wasm_stride(); const pointer = module._xteink_wasm_framebuffer(); if (!width || !height || !stride || !pointer) { return false; } if (canvas.width !== width || canvas.height !== height) { canvas.width = width; canvas.height = height; } const source = module.HEAPU8; const image = new ImageData(width, height); for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const sourceOffset = pointer + y * stride + x * 4; const targetOffset = (y * width + x) * 4; image.data[targetOffset] = source[sourceOffset]; image.data[targetOffset + 1] = source[sourceOffset + 1]; image.data[targetOffset + 2] = source[sourceOffset + 2]; image.data[targetOffset + 3] = 255; } } canvas.getContext('2d').putImageData(image, 0, 0); return true; } function startDisplayLoop(module, canvas, setStatus, onReady) { let generation = -1; let ready = false; const update = () => { const nextGeneration = module._xteink_wasm_frame_generation(); if (nextGeneration !== generation && drawFrame(module, canvas)) { generation = nextGeneration; setStatus('Running.'); if (!ready) { ready = true; onReady(); } } requestAnimationFrame(update); }; requestAnimationFrame(update); } function enableControls(module) { for (const element of document.querySelectorAll('[data-button]')) { element.disabled = false; let pressed = false; const press = () => { if (!pressed) { pressed = true; element.classList.add('active'); setButton(module, element.dataset.button, true); } }; const release = () => { if (pressed) { pressed = false; element.classList.remove('active'); setButton(module, element.dataset.button, false); } }; element.addEventListener('pointerdown', event => { element.setPointerCapture(event.pointerId); press(); }); element.addEventListener('pointerup', release); element.addEventListener('pointercancel', release); element.addEventListener('lostpointercapture', release); element.addEventListener('keydown', event => { if (!event.repeat && (event.key === ' ' || event.key === 'Enter')) { event.preventDefault(); press(); } }); element.addEventListener('keyup', event => { if (event.key === ' ' || event.key === 'Enter') { event.preventDefault(); release(); } }); element.addEventListener('blur', release); } } let logEl = null; const logBuffer = []; const LOG_MAX_LINES = 1000; function logLine(message) { logBuffer.push(message); if (logBuffer.length > LOG_MAX_LINES) { logBuffer.splice(0, logBuffer.length - LOG_MAX_LINES); } if (logEl) { const atBottom = logEl.scrollTop + logEl.clientHeight >= logEl.scrollHeight - 4; logEl.textContent = logBuffer.join('\n'); if (atBottom) { logEl.scrollTop = logEl.scrollHeight; } } } async function boot(file, variant, setStatus) { if (!crossOriginIsolated) { throw new Error('This emulator requires COOP/COEP headers. Start it with `make web`.'); } setStatus('Loading firmware…'); const firmware = await prepareFirmware(new Uint8Array(await file.arrayBuffer()), import.meta.url); const artifactRoot = new URL('../dist/wasm/', import.meta.url); const qemuUrl = new URL('qemu-system-riscv32.js', artifactRoot); const rom = new Uint8Array(await fetch(new URL('esp32c3-rom.bin', artifactRoot)).then(response => { if (!response.ok) { throw new Error(`Could not load ESP32-C3 ROM (${response.status}).`); } return response.arrayBuffer(); })); const { default: createQemu } = await import(qemuUrl); const options = { arguments: [ '-machine', `xteink,variant=${variant}`, '-L', '/bios', '-display', 'none', '-serial', 'stdio', '-nic', 'none', '-d', 'guest_errors,unimp', '-drive', 'file=/flash.bin,if=mtd,format=raw', ], locateFile: path => new URL(path, artifactRoot).href, mainScriptUrlOrBlob: qemuUrl.href, print: message => { console.log(message); logLine(message); }, printErr: message => { console.error(message); logLine(message); }, stdin: () => null, }; options.preRun = [() => { options.FS.mkdir('/bios'); options.FS.writeFile('/bios/esp32c3-rom.bin', rom); options.FS.writeFile('/flash.bin', firmware); }]; setStatus('Starting emulator…'); return createQemu(options); } function initialize() { const form = document.querySelector('#boot-form'); const firmwareInput = document.querySelector('#firmware'); const variantInput = document.querySelector('#variant'); const bootButton = document.querySelector('#boot'); const status = document.querySelector('#status'); const canvas = document.querySelector('#screen'); logEl = document.querySelector('#log'); document.querySelector('#log-clear').addEventListener('click', () => { logBuffer.length = 0; logEl.textContent = ''; }); const setStatus = (message, error = false) => { status.textContent = message; status.classList.toggle('error', error); }; form.addEventListener('submit', async event => { event.preventDefault(); const file = firmwareInput.files[0]; if (!file) { setStatus('Choose a firmware image first.', true); return; } bootButton.disabled = true; firmwareInput.disabled = true; variantInput.disabled = true; try { const module = await boot(file, variantInput.value, setStatus); startDisplayLoop(module, canvas, setStatus, () => enableControls(module)); } catch (error) { console.error(error); setStatus(error.message || String(error), true); bootButton.disabled = false; firmwareInput.disabled = false; variantInput.disabled = false; } }); } if (typeof document !== 'undefined') { initialize(); }