Files
xteink-web-emulator/web/app.js
T
evan b22934fe0d Replace vvfat SD with browser-built raw FAT32 from OPFS
Browser SD files are persisted in OPFS (web/sdstore.js) and edited in the
Files tab. At boot the browser builds a deterministic 64 MiB FAT32 image
(web/fat32.js) with nested directories and VFAT long names, then mounts
it as a raw block device, bypassing QEMU's broken populated-vvfat WASM
coroutine path. Guest writes mutate only the ephemeral image.

Exposes a hidden #frame-generation indicator that increments on every
e-ink flush, so automation can wait on real display updates instead of
fixed sleeps. Adds tests/ with a Makefile runner: 'make test' for JS +
FAT32/mtools checks, 'make browser-test' for a headless-Firefox boot of
the official CrossPoint 1.4.1 firmware that navigates to
Test/example.txt. Bumps third_party/qemu to the SPI-SD/CMD13 fixes and
updates README/ROADMAP/ISSUES for the new architecture.
2026-07-21 07:43:07 -04:00

512 lines
17 KiB
JavaScript

import {
listSdFiles, readSdFile, writeSdFile, deleteSdFile, readAllSdFiles,
seedDefaultsIfEmpty, saveBootState, loadBootState,
} 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());
}
const LOREM_IPSUM = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.';
export const EXAMPLE_SD_TEXT = Array(10).fill(LOREM_IPSUM).join('\n\n');
export const DEFAULT_SD_FILES = new Map([['Test/example.txt', EXAMPLE_SD_TEXT]]);
// 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;
const indicator = document.querySelector('#frame-generation');
indicator.value = String(generation);
document.dispatchEvent(new CustomEvent('xteink-frame', { detail: { generation } }));
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;
let persistLog = false;
const logBuffer = [];
const LOG_MAX_LINES = 1000;
const DEBUG_LOG_KEY = 'xteink-debug-log';
let activeModule = null;
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;
function isTextPath(path) {
return TEXT_EXTENSIONS.has(path.split('.').pop().toLowerCase());
}
async function selectSdFile(path) {
selectedSdPath = path;
const editor = document.querySelector('#file-editor');
const save = document.querySelector('#file-save');
const del = document.querySelector('#file-delete');
del.hidden = false;
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;
}
for (const button of document.querySelectorAll('#file-list button')) {
button.classList.toggle('selected', button.dataset.path === path);
}
}
async function renderFiles() {
const list = document.querySelector('#file-list');
const editor = document.querySelector('#file-editor');
const entries = await listSdFiles();
list.replaceChildren();
let firstPath = null;
for (const entry of entries) {
if (entry.directory) {
const directory = document.createElement('div');
directory.className = 'file-directory';
directory.textContent = entry.path;
list.append(directory);
continue;
}
const button = document.createElement('button');
button.type = 'button';
button.dataset.path = entry.path;
const path = document.createElement('span');
path.textContent = entry.path;
const size = document.createElement('small');
size.textContent = formatBytes(entry.size);
button.append(path, size);
button.addEventListener('click', () => selectSdFile(entry.path));
list.append(button);
firstPath ||= entry.path;
}
const stillPresent = entries.some(entry => entry.path === selectedSdPath);
const target = stillPresent ? selectedSdPath : firstPath;
if (target) {
await selectSdFile(target);
} else {
selectedSdPath = null;
editor.value = 'The SD card is empty.';
editor.readOnly = true;
document.querySelector('#file-save').hidden = true;
document.querySelector('#file-delete').hidden = true;
}
}
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();
seedDefaultsIfEmpty(DEFAULT_SD_FILES).then(renderFiles);
renderDebug();
setInterval(renderDebug, 1000);
}
function wireFileControls() {
document.querySelector('#file-add').addEventListener('change', async event => {
for (const file of event.target.files) {
await writeSdFile(file.name, new Uint8Array(await file.arrayBuffer()));
}
event.target.value = '';
await renderFiles();
});
document.querySelector('#file-new').addEventListener('click', async () => {
const name = prompt('New file path (e.g. Notes/todo.txt):');
if (!name) {
return;
}
await writeSdFile(name, '');
selectedSdPath = name;
await renderFiles();
});
document.querySelector('#file-save').addEventListener('click', async () => {
if (!selectedSdPath) {
return;
}
await writeSdFile(selectedSdPath, document.querySelector('#file-editor').value);
await renderFiles();
});
document.querySelector('#file-delete').addEventListener('click', async () => {
if (!selectedSdPath || !confirm(`Delete ${selectedSdPath}?`)) {
return;
}
await deleteSdFile(selectedSdPath);
selectedSdPath = null;
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)),
]);
await seedDefaultsIfEmpty(DEFAULT_SD_FILES);
const sdFiles = await readAllSdFiles();
setStatus(`Building SD card image from ${sdFiles.length} browser file(s)…`);
const sdImage = buildFat32Image(sdFiles);
const sdMessage = `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 qemuLogFlags = new URL(location.href).searchParams.has('trace')
? 'guest_errors,unimp,in_asm'
: 'guest_errors,unimp';
const options = {
arguments: [
'-machine', `xteink,variant=${variant}`,
'-accel', 'tcg,tb-size=16',
'-icount', 'shift=auto,align=off,sleep=off',
'-L', '/bios',
'-display', 'none',
'-serial', 'stdio',
'-nic', 'none',
'-d', qemuLogFlags,
'-drive', 'file=/flash.bin,if=mtd,format=raw',
'-drive', '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);
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 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;
}
await saveBootState(file, variantInput.value);
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(async blob => {
const variant = params.get('variant') || 'x3';
const firmware = new File([blob], 'firmware.bin');
await saveBootState(firmware, variant);
await start(firmware, variant);
})
.catch(error => setStatus(error.message || String(error), true));
} else {
loadBootState()
.then(saved => {
if (saved) {
variantInput.value = saved.variant;
start(saved.firmware, saved.variant);
}
})
.catch(error => setStatus(error.message || String(error), true));
}
}
if (typeof document !== 'undefined') {
initialize();
}