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.
This commit is contained in:
2026-07-20 08:21:38 -04:00
parent bd4aa68032
commit 90981ad154
6 changed files with 55 additions and 11 deletions
+3 -1
View File
@@ -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.
+13 -3
View File
@@ -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 = {
+38 -6
View File
@@ -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 => {
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -11,7 +11,7 @@
<header>
<p class="eyebrow">ESP32-C3 emulator</p>
<h1>xteink X3 / X4</h1>
<p>Choose a merged 16 MB firmware image and boot it locally in your browser.</p>
<p>Choose a CrossPoint firmware <code>.bin</code> (the ~5 MB release app image, or a full 16 MB flash) and boot it locally in your browser.</p>
</header>
<form id="boot-form" class="boot-panel">