diff --git a/README.md b/README.md index 6584046..778bfbc 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,9 @@ make web # Open http://127.0.0.1:8000/web/ ``` -Choose a merged 16 MB firmware `.bin`, select X3 or X4, and boot. The controls +Choose a CrossPoint firmware `.bin` — either the ~5 MB release app image (it is +merged in-browser with the bundled bootloader and partition table into a 16 MB +flash) or a full 16 MB image — select X3 or X4, and boot. The controls map to the device's ADC button ladders and active-low Power GPIO. `make web` serves the required COOP/COEP headers for Emscripten pthreads; a generic static server without those headers will not work. diff --git a/scripts/test-web.mjs b/scripts/test-web.mjs index 5abb821..31e1499 100644 --- a/scripts/test-web.mjs +++ b/scripts/test-web.mjs @@ -1,8 +1,18 @@ import assert from 'node:assert/strict'; -import { BUTTONS, setButton, validateFirmwareSize } from '../web/app.js'; +import { BUTTONS, setButton, mergeFirmware } from '../web/app.js'; -validateFirmwareSize(16 * 1024 * 1024); -assert.throws(() => validateFirmwareSize(4 * 1024 * 1024), /exactly 16 MB/); +// Merge - app image is placed at 0x10000 in a 0xFF-erased 16 MB flash with bootloader and partitions. +const bootloader = new Uint8Array([1, 2, 3]); +const partitions = new Uint8Array([4, 5, 6]); +const app = new Uint8Array(1024); +app[0] = 0xe9; +const flash = mergeFirmware(app, bootloader, partitions); +assert.equal(flash.length, 16 * 1024 * 1024); +assert.deepEqual(flash.slice(0, 3), bootloader); +assert.deepEqual(flash.slice(0x8000, 0x8003), partitions); +assert.equal(flash[0x10000], 0xe9); +assert.equal(flash[0x20000], 0xff); +assert.throws(() => mergeFirmware(new Uint8Array([0x00]), bootloader, partitions), /0xE9/); const calls = []; const module = { diff --git a/web/app.js b/web/app.js index b5f20fe..f4d9c66 100644 --- a/web/app.js +++ b/web/app.js @@ -1,4 +1,8 @@ -const FIRMWARE_SIZE = 16 * 1024 * 1024; +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 = { @@ -11,10 +15,39 @@ export const BUTTONS = { power: { type: 'gpio', pin: 3 }, }; -export function validateFirmwareSize(size) { - if (size !== FIRMWARE_SIZE) { - throw new Error(`Firmware must be exactly 16 MB; this file is ${(size / 1024 / 1024).toFixed(2)} MB.`); +// 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) { @@ -121,13 +154,12 @@ function enableControls(module) { } async function boot(file, variant, setStatus) { - validateFirmwareSize(file.size); if (!crossOriginIsolated) { throw new Error('This emulator requires COOP/COEP headers. Start it with `make web`.'); } setStatus('Loading firmware…'); - const firmware = new Uint8Array(await file.arrayBuffer()); + 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 => { diff --git a/web/assets/crosspoint-partitions.bin b/web/assets/crosspoint-partitions.bin new file mode 100644 index 0000000..71dc012 Binary files /dev/null and b/web/assets/crosspoint-partitions.bin differ diff --git a/web/assets/esp32c3-bootloader.bin b/web/assets/esp32c3-bootloader.bin new file mode 100644 index 0000000..b4fc27d Binary files /dev/null and b/web/assets/esp32c3-bootloader.bin differ diff --git a/web/index.html b/web/index.html index e271d0a..5d504a6 100644 --- a/web/index.html +++ b/web/index.html @@ -11,7 +11,7 @@

ESP32-C3 emulator

xteink X3 / X4

-

Choose a merged 16 MB firmware image and boot it locally in your browser.

+

Choose a CrossPoint firmware .bin (the ~5 MB release app image, or a full 16 MB flash) and boot it locally in your browser.