import { listSdFiles, readSdFile, writeSdFile, mkdirSd, deleteSdFile, clearSdFiles, readAllSdFiles, } from './sdstore.js'; import { buildFat32Image } from './fat32.js'; 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 powerHeld = false; let heldFrames = 0; let ready = false; const update = () => { const nextGeneration = module._xteink_wasm_frame_generation(); if (nextGeneration !== generation && drawFrame(module, canvas)) { generation = nextGeneration; const indicator = document.querySelector('#frame-generation'); indicator.value = String(generation); document.dispatchEvent(new CustomEvent('xteink-frame', { detail: { generation } })); if (!powerHeld) { powerHeld = true; setButton(module, 'power', true); setStatus('Holding power for cold boot…'); } else if (!ready && ++heldFrames >= 2) { setButton(module, 'power', false); ready = true; setStatus('Running.'); 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; let persistLog = false; const logBuffer = []; const LOG_MAX_LINES = 1000; const DEBUG_LOG_KEY = 'xteink-debug-log'; let activeModule = null; if (typeof window !== 'undefined') { window.__xteinkRuntimeErrors = []; } export function formatBytes(bytes) { if (bytes < 1024) { return `${bytes} B`; } const units = ['KiB', 'MiB', 'GiB']; let value = bytes; let unit = -1; do { value /= 1024; unit++; } while (value >= 1024 && unit < units.length - 1); return `${value.toFixed(value >= 10 ? 1 : 2)} ${units[unit]}`; } const TEXT_EXTENSIONS = new Set(['txt', 'md', 'json', 'cfg', 'csv', 'log', 'html', 'xml', 'ini', 'yml', 'yaml']); let selectedSdPath = null; let currentSdDirectory = ''; function isTextPath(path) { return TEXT_EXTENSIONS.has(path.split('.').pop().toLowerCase()); } function showEditor(show) { document.querySelector('#files-browse').hidden = show; document.querySelector('#files-edit').hidden = !show; } function closeEditor() { selectedSdPath = null; showEditor(false); } async function selectSdFile(path) { selectedSdPath = path; const editor = document.querySelector('#file-editor'); const save = document.querySelector('#file-save'); document.querySelector('#file-edit-name').textContent = path.split('/').pop(); try { const bytes = await readSdFile(path); if (isTextPath(path)) { editor.value = new TextDecoder().decode(bytes); editor.readOnly = false; save.hidden = false; } else { editor.value = `(binary file — ${formatBytes(bytes.length)}; not editable here)`; editor.readOnly = true; save.hidden = true; } } catch (error) { editor.value = `Could not read ${path}: ${error.message || error}`; editor.readOnly = true; save.hidden = true; } showEditor(true); } async function renderFiles() { const list = document.querySelector('#file-list'); const entries = await listSdFiles(); if (currentSdDirectory && !entries.some(entry => entry.directory && entry.path === `${currentSdDirectory}/`)) { currentSdDirectory = ''; } const prefix = currentSdDirectory ? `${currentSdDirectory}/` : ''; const visibleEntries = entries.filter(entry => { if (!entry.path.startsWith(prefix)) { return false; } const relativePath = entry.path.slice(prefix.length).replace(/\/$/, ''); return relativePath && !relativePath.includes('/'); }); document.querySelector('#file-path').textContent = `/sdcard${currentSdDirectory ? `/${currentSdDirectory}` : ''}`; document.querySelector('#file-back').disabled = !currentSdDirectory; const body = list.querySelector('tbody'); body.replaceChildren(); if (!visibleEntries.length) { const row = body.insertRow(); const cell = row.insertCell(); cell.colSpan = 4; cell.className = 'file-empty'; cell.textContent = 'This folder is empty'; return; } const childCount = dirPrefix => entries.filter(e => { const rest = e.path.slice(dirPrefix.length).replace(/\/$/, ''); return e.path.startsWith(dirPrefix) && rest && !rest.includes('/'); }).length; for (const entry of visibleEntries) { const name = entry.path.slice(prefix.length).replace(/\/$/, ''); const row = body.insertRow(); row.className = entry.directory ? 'directory' : 'file'; row.insertCell().textContent = entry.directory ? '\u{1F4C1}' : '\u{1F4C4}'; row.insertCell().textContent = name; if (entry.directory) { const n = childCount(entry.path); row.insertCell().textContent = `${n} item${n === 1 ? '' : 's'}`; } else { row.insertCell().textContent = formatBytes(entry.size); } const del = document.createElement('button'); del.type = 'button'; del.className = 'file-del'; del.textContent = '\u2715'; del.title = `Delete ${name}`; del.addEventListener('click', async event => { event.stopPropagation(); if (!confirm(`Delete ${name}?`)) { return; } await deleteSdFile(entry.path); await renderFiles(); }); row.insertCell().append(del); row.addEventListener('click', () => { if (entry.directory) { currentSdDirectory = entry.path.replace(/\/$/, ''); selectedSdPath = null; renderFiles(); } else { selectSdFile(entry.path); } }); } } function pathBytes(fs, path) { try { const stat = fs.stat(path); if (!fs.isDir(stat.mode)) { return { bytes: stat.size, files: 1 }; } return fs.readdir(path) .filter(name => name !== '.' && name !== '..') .map(name => pathBytes(fs, `${path}/${name}`)) .reduce((total, item) => ({ bytes: total.bytes + item.bytes, files: total.files + item.files }), { bytes: 0, files: 0 }); } catch { return { bytes: 0, files: 0 }; } } function renderDebug() { const jsHeap = performance.memory?.usedJSHeapSize; document.querySelector('#debug-js-heap').textContent = jsHeap ? formatBytes(jsHeap) : 'Unavailable in this browser'; if (!activeModule) { return; } const visible = ['/flash.bin', '/bios', '/sd.img', '/var/tmp'] .map(path => pathBytes(activeModule.FS, path)) .reduce((total, item) => ({ bytes: total.bytes + item.bytes, files: total.files + item.files }), { bytes: 0, files: 0 }); document.querySelector('#debug-wasm-heap').textContent = formatBytes(activeModule.HEAPU8.byteLength); document.querySelector('#debug-memfs').textContent = `${formatBytes(visible.bytes)} in ${visible.files} files`; document.querySelector('#debug-sd-files').textContent = '64.0 MiB raw image (OPFS source)'; } function initializeInspector() { for (const tab of document.querySelectorAll('[role="tab"]')) { tab.addEventListener('click', () => { for (const candidate of document.querySelectorAll('[role="tab"]')) { const selected = candidate === tab; candidate.setAttribute('aria-selected', selected); document.querySelector(`#${candidate.getAttribute('aria-controls')}`).hidden = !selected; } if (tab.dataset.tab === 'files') { renderFiles(); } else if (tab.dataset.tab === 'debug') { renderDebug(); } }); } wireFileControls(); renderFiles(); renderDebug(); setInterval(renderDebug, 1000); } function wireFileControls() { const inDir = name => (currentSdDirectory ? `${currentSdDirectory}/${name}` : name); document.querySelector('#file-add').addEventListener('change', async event => { for (const file of event.target.files) { await writeSdFile(inDir(file.name), new Uint8Array(await file.arrayBuffer())); } event.target.value = ''; await renderFiles(); }); document.querySelector('#file-new').addEventListener('click', async () => { const name = prompt('New file name:')?.replace(/^\/+|\/+$/g, ''); if (!name) { return; } const path = inDir(name); await writeSdFile(path, ''); await renderFiles(); await selectSdFile(path); }); document.querySelector('#file-new-folder').addEventListener('click', async () => { const name = prompt('New folder name:')?.replace(/^\/+|\/+$/g, ''); if (!name) { return; } await mkdirSd(inDir(name)); await renderFiles(); }); document.querySelector('#file-close').addEventListener('click', () => { closeEditor(); renderFiles(); }); document.querySelector('#file-back').addEventListener('click', async () => { currentSdDirectory = currentSdDirectory.split('/').slice(0, -1).join('/'); selectedSdPath = null; await renderFiles(); }); document.querySelector('#file-reset').addEventListener('click', async () => { if (!confirm('Delete all files from the SD card?')) { return; } await clearSdFiles(); currentSdDirectory = ''; selectedSdPath = null; await renderFiles(); }); document.querySelector('#file-save').addEventListener('click', async () => { if (!selectedSdPath) { return; } await writeSdFile(selectedSdPath, document.querySelector('#file-editor').value); }); document.querySelector('#file-delete').addEventListener('click', async () => { if (!selectedSdPath || !confirm(`Delete ${selectedSdPath}?`)) { return; } await deleteSdFile(selectedSdPath); closeEditor(); await renderFiles(); }); document.querySelector('#file-reboot').addEventListener('click', () => location.reload()); } function logLine(message) { logBuffer.push(message); if (logBuffer.length > LOG_MAX_LINES) { logBuffer.splice(0, logBuffer.length - LOG_MAX_LINES); } if (persistLog) { sessionStorage.setItem(DEBUG_LOG_KEY, logBuffer.join('\n')); } 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 and ephemeral SD card…'); const artifactRoot = new URL('../dist/wasm/', import.meta.url); const qemuUrl = new URL('qemu-system-riscv32.js', artifactRoot); const [firmware, rom] = await Promise.all([ prepareFirmware(new Uint8Array(await file.arrayBuffer()), import.meta.url), fetchBin(new URL('esp32c3-rom.bin', artifactRoot)), ]); const sdFiles = await readAllSdFiles(); const useVvfat = new URL(location.href).searchParams.has('vvfat'); setStatus(`${useVvfat ? 'Staging' : 'Building SD card image from'} ${sdFiles.length} browser file(s)…`); const sdImage = useVvfat ? null : buildFat32Image(sdFiles); const sdMessage = useVvfat ? `Using experimental vvfat backed by ${sdFiles.length} browser file(s); guest writes are discarded on reboot.` : `Using a 64 MiB FAT32 image built from ${sdFiles.length} browser file(s); guest writes are discarded on reboot.`; console.log(sdMessage); logLine(sdMessage); const { default: createQemu } = await import(qemuUrl); const params = new URL(location.href).searchParams; const qemuLogFlags = params.has('trace') ? 'guest_errors,unimp,in_asm' : 'guest_errors,unimp'; // icount throttles TCG ~4.7x here and starves the guest watchdog; off by default. ?icount restores it. const icount = params.has('icount') ? ['-icount', 'shift=auto,align=off,sleep=off'] : []; const options = { arguments: [ '-machine', `xteink,variant=${variant}`, '-accel', 'tcg,tb-size=16', ...icount, '-L', '/bios', '-display', 'none', '-serial', 'stdio', '-nic', 'none', '-d', qemuLogFlags, '-drive', 'file=/flash.bin,if=mtd,format=raw', '-drive', useVvfat ? 'file=fat:16:rw:/sdcard,if=sd,format=raw' : 'file=/sd.img,if=sd,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.mkdirTree('/bios'); options.FS.mkdirTree('/var/tmp'); options.FS.writeFile('/bios/esp32c3-rom.bin', rom); options.FS.writeFile('/flash.bin', firmware); if (useVvfat) { for (const { path, bytes } of sdFiles) { const slash = path.lastIndexOf('/'); const directory = slash < 0 ? '/sdcard' : `/sdcard/${path.slice(0, slash)}`; options.FS.mkdirTree(directory); options.FS.writeFile(`/sdcard/${path}`, bytes); } } else { options.FS.writeFile('/sd.img', sdImage); } }]; 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'); const params = new URL(location.href).searchParams; persistLog = params.has('firmware'); if (persistLog) { logBuffer.push(...(sessionStorage.getItem(DEBUG_LOG_KEY) || '').split('\n').filter(Boolean)); logEl.textContent = logBuffer.join('\n'); } document.querySelector('#log-clear').addEventListener('click', () => { logBuffer.length = 0; sessionStorage.removeItem(DEBUG_LOG_KEY); logEl.textContent = ''; }); initializeInspector(); const setStatus = (message, error = false) => { status.textContent = message; status.classList.toggle('error', error); }; const recordRuntimeError = value => { const message = value instanceof Error ? value.stack || value.message : String(value); window.__xteinkRuntimeErrors.push(message); logLine(`[runtime] ${message}`); setStatus(`Runtime error: ${message}`, true); }; window.addEventListener('error', event => recordRuntimeError(event.message || event.error || 'Unknown worker error')); window.addEventListener('unhandledrejection', event => recordRuntimeError(event.reason || 'Unhandled rejection')); const start = async (file, variant = variantInput.value) => { bootButton.disabled = true; firmwareInput.disabled = true; variantInput.disabled = true; try { const module = await boot(file, variant, setStatus); activeModule = module; renderFiles(); renderDebug(); if (persistLog) { window.__xteinkModule = module; } 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; } }; form.addEventListener('submit', async event => { event.preventDefault(); const file = firmwareInput.files[0]; if (!file) { setStatus('Choose a firmware image first.', true); return; } start(file); }); const firmwareUrl = params.get('firmware'); if (firmwareUrl) { fetch(firmwareUrl) .then(response => { if (!response.ok) { throw new Error(`Could not load debug firmware (${response.status}).`); } return response.blob(); }) .then(blob => start(new File([blob], 'firmware.bin'), params.get('variant') || 'x3')) .catch(error => setStatus(error.message || String(error), true)); } } if (typeof document !== 'undefined') { initialize(); }