Files
xteink-web-emulator/web/app.js
T
evan 90981ad154 feat(web): auto-merge CrossPoint release app image into a 16 MB flash
Accept the ~5 MB release firmware.bin directly: place it at 0x10000 in a
0xFF-erased 16 MB image alongside the bundled ESP32-C3 bootloader (0x0) and
CrossPoint partition table (0x8000). m25p80 requires a full chip-size backing,
so the 16 MB image is still built, but the user no longer pre-merges. Full
16 MB images are still accepted as-is.
2026-07-20 08:21:38 -04:00

237 lines
7.6 KiB
JavaScript

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 = new Uint8Array(module.wasmMemory.buffer);
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);
}
}
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', 'null',
'-nic', 'none',
'-drive', 'file=/flash.bin,if=mtd,format=raw',
],
locateFile: path => new URL(path, artifactRoot).href,
mainScriptUrlOrBlob: qemuUrl.href,
print: message => console.log(message),
printErr: message => console.error(message),
};
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');
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();
}