458 lines
15 KiB
JavaScript
458 lines
15 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());
|
|
}
|
|
|
|
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]]);
|
|
|
|
function mountEphemeralSdCard(fs) {
|
|
for (const [path, contents] of DEFAULT_SD_FILES) {
|
|
const parts = path.split('/');
|
|
const filename = parts.pop();
|
|
const directory = `/sdcard/${parts.join('/')}`;
|
|
fs.mkdirTree(directory);
|
|
fs.writeFile(`${directory}/${filename}`, contents);
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
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]}`;
|
|
}
|
|
|
|
function sdEntries(module = activeModule) {
|
|
if (!module) {
|
|
return [
|
|
{ path: 'Test/', directory: true },
|
|
...[...DEFAULT_SD_FILES].map(([path, contents]) => ({
|
|
path,
|
|
size: new TextEncoder().encode(contents).length,
|
|
read: () => contents,
|
|
})),
|
|
];
|
|
}
|
|
|
|
const entries = [];
|
|
const visit = (directory, relative = '') => {
|
|
for (const name of module.FS.readdir(directory).filter(name => name !== '.' && name !== '..').sort()) {
|
|
const absolute = `${directory}/${name}`;
|
|
const path = relative ? `${relative}/${name}` : name;
|
|
const stat = module.FS.stat(absolute);
|
|
if (module.FS.isDir(stat.mode)) {
|
|
entries.push({ path: `${path}/`, directory: true });
|
|
visit(absolute, path);
|
|
} else {
|
|
entries.push({
|
|
path,
|
|
size: stat.size,
|
|
read: () => module.FS.readFile(absolute, { encoding: 'utf8' }),
|
|
});
|
|
}
|
|
}
|
|
};
|
|
visit('/sdcard');
|
|
return entries;
|
|
}
|
|
|
|
function renderFiles() {
|
|
const list = document.querySelector('#file-list');
|
|
const preview = document.querySelector('#file-preview');
|
|
const entries = sdEntries();
|
|
list.replaceChildren();
|
|
let firstFile = 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';
|
|
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', () => {
|
|
try {
|
|
preview.textContent = entry.read();
|
|
} catch (error) {
|
|
preview.textContent = `Could not read ${entry.path}: ${error.message || error}`;
|
|
}
|
|
});
|
|
list.append(button);
|
|
firstFile ||= button;
|
|
}
|
|
|
|
if (firstFile) {
|
|
firstFile.click();
|
|
} else {
|
|
preview.textContent = 'The SD card is empty.';
|
|
}
|
|
}
|
|
|
|
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', '/sdcard', '/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 });
|
|
const sd = pathBytes(activeModule.FS, '/sdcard');
|
|
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 = `${formatBytes(sd.bytes)} in ${sd.files} files`;
|
|
}
|
|
|
|
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();
|
|
}
|
|
});
|
|
}
|
|
renderFiles();
|
|
renderDebug();
|
|
setInterval(renderDebug, 1000);
|
|
}
|
|
|
|
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 sdMessage = 'Using a 32 GiB ephemeral directory-backed SD card; writes will be discarded when the emulator restarts.';
|
|
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=fat:32:rw:/sdcard,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);
|
|
mountEphemeralSdCard(options.FS);
|
|
}];
|
|
|
|
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', 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();
|
|
}
|