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 Choose a merged 16 MB firmware image and boot it locally in your browser. Choose a CrossPoint firmware xteink X3 / X4
- .bin (the ~5 MB release app image, or a full 16 MB flash) and boot it locally in your browser.