feat(wasm): add browser QEMU build

This commit is contained in:
2026-07-19 22:31:52 -04:00
parent ca07c02e02
commit 6538cec9f6
10 changed files with 7046 additions and 25 deletions
+28 -13
View File
@@ -12,10 +12,11 @@ touching QEMU internals. Pairs with `ROADMAP.md` (the plan) — this is the
- One `xteink` QEMU machine selects `variant=x3|x4` at launch. **Unmodified
canonical firmware** detects X3 through I²C fingerprints and boots the home
screen; X4 detects through all-NAK probes and reaches a blank SSD1677 stub.
- All Phase-1 device models live in **one patch**:
`qemu/patches/0001-xteink-machine.patch`, applied onto exact espressif/qemu
commit `40edccac415693c5130f91c01d84176ae6008566`.
- Next: compile to WASM (qemu-wasm) → static browser UI. Full X4 rendering waits.
- Phase-1 device models live in `qemu/patches/0001-xteink-machine.patch`;
`0002-qemu-wasm.patch` ports qemu-wasm's host backend onto the same pinned
Espressif QEMU commit `40edccac415693c5130f91c01d84176ae6008566`.
- `make qemu-wasm` produces a smoke-tested ES module, WASM binary, and pthread
worker under `dist/wasm/`. Next: static browser UI. Full X4 rendering waits.
---
@@ -207,8 +208,8 @@ ADC ladders on GPIO1 & GPIO2; power button GPIO3; UC8253 @ 16 MHz.
## 8. Reproducibility model
- **Durable = the patch** (`qemu/patches/0001-xteink-machine.patch`), verified
against exact QEMU commit `40edccac415693c5130f91c01d84176ae6008566` (tag
- **Durable = the patches**: apply `0001-xteink-machine.patch`, then
`0002-qemu-wasm.patch` for the browser build. Both target exact QEMU commit `40edccac415693c5130f91c01d84176ae6008566` (tag
`esp-develop-9.2.2-20260417`). When editing `_scratch/qemu-src`, regenerate:
```sh
cd <qemu-src> && git add -N <new files> && git diff --binary > <repo>/qemu/patches/0001-xteink-machine.patch
@@ -253,15 +254,27 @@ ADC ladders on GPIO1 & GPIO2; power button GPIO3; UC8253 @ 16 MHz.
---
## 10. Next steps (in priority order)
## 10. WASM build and next steps
1. **Compile to WASM**: combine this pinned Espressif fork with
`ktock/qemu-wasm`'s WASM TCG/TCI backend + Emscripten. De-risk by building an
unpatched esp32c3 target under qemu-wasm first, then apply our patch.
2. **Static browser front-end**: display surface → canvas; physical buttons →
- `make qemu-wasm` uses Podman or Docker and the pinned Emscripten 3.1.50 image
in `qemu/wasm/Dockerfile`. It builds only `riscv32-softmmu` with the xteink
device graph; unrelated boards, networking, audio, virtfs, tools, and docs are
disabled. Outputs go to `dist/wasm/`.
- qemu-wasm commit `0ef7b4e2814b231705d8371dd7997f5b72e70baf` is based on
QEMU 8.2; `0002-qemu-wasm.patch` is the resolved port to Espressif QEMU 9.2.
`-sEMULATE_FUNCTION_POINTER_CASTS=1` is required for QEMU's QOM callbacks.
- The WASM dependency image omits libgcrypt. AES/RSA/DS/XTS devices are therefore
not instantiated; their MMIO falls through to the existing zero-return stub.
Canonical firmware boot still needs browser-level validation; add cross-built
libgcrypt only if firmware proves it uses those accelerators.
- Emscripten pthreads require cross-origin isolation (COOP/COEP) in the browser.
Next:
1. **Static browser front-end**: display surface → canvas; physical buttons →
ADC/GPIO controls; uploaded flash; SD block backend; `variant=x3|x4` chosen
before boot. Must run fully from static assets (airgapped).
3. **Full X4 rendering later**: SSD1677 is 800×480 controller / 480×800 portrait,
2. **Full X4 rendering later**: SSD1677 is 800×480 controller / 480×800 portrait,
BUSY active-high. Implement its RAM/window/update commands when needed.
---
@@ -271,7 +284,9 @@ ADC ladders on GPIO1 & GPIO2; power button GPIO3; UC8253 @ 16 MHz.
| What | Where |
|------|-------|
| Emulator repo | `/home/evanreichard/Development/git/personal/xteink-web-emulator` |
| The patch | `qemu/patches/0001-xteink-machine.patch` |
| Native/WASM patches | `qemu/patches/0001-xteink-machine.patch`, `0002-qemu-wasm.patch` |
| WASM environment | `qemu/wasm/Dockerfile` |
| WASM build | `scripts/build-qemu-wasm.sh` → `dist/wasm/` |
| QEMU source (ephemeral) | `_scratch/qemu-src` (via `make qemu`) |
| Canonical firmware | `https://github.com/crosspoint-reader/crosspoint-reader` |
| Tested firmware checkout | `/tmp/crosspoint-reader-main` @ `556b8ae` (unmodified) |
+6 -1
View File
@@ -5,9 +5,11 @@ FIRMWARE_DIR ?= $(FIRMWARE_REPO)/.pio/build/default
SD_SRC ?= $(FIRMWARE_REPO)/sdcard
QEMU_SRC ?= _scratch/qemu-src
QEMU_BIN = $(QEMU_SRC)/build/qemu-system-riscv32
QEMU_WASM_SRC ?= _scratch/qemu-wasm-src
WASM_OUT ?= dist/wasm
VARIANT ?= x3
.PHONY: firmware sdimage qemu run run-upstream chip clean
.PHONY: firmware sdimage qemu qemu-wasm run run-upstream chip clean
firmware:
esptool --chip esp32c3 merge-bin -o flash.bin \
@@ -24,6 +26,9 @@ sdimage:
qemu:
QEMU_SRC=$(QEMU_SRC) scripts/build-qemu.sh
qemu-wasm:
QEMU_WASM_SRC=$(QEMU_WASM_SRC) WASM_OUT=$(WASM_OUT) JOBS=2 scripts/build-qemu-wasm.sh
# Select X4 with `make run VARIANT=x4`. Add `-display none` for headless.
run: qemu
$(QEMU_BIN) -machine xteink,variant=$(VARIANT) -L $(QEMU_SRC)/pc-bios \
+22 -3
View File
@@ -4,11 +4,12 @@ An offline, in-browser emulator for the xteink **X3/X4** (ESP32-C3 + e-ink):
upload a firmware `.bin`, run the real machine code client-side, see the screen,
press buttons, mount a virtual SD — no server, no proprietary engine.
**Status:** one native `xteink` machine selects X3 or X4 at launch. Unmodified
**Status:** native and WebAssembly builds provide one `xteink` machine that
selects X3 or X4 at launch. Unmodified
[canonical firmware](https://github.com/crosspoint-reader/crosspoint-reader)
auto-detects the selected variant: X3 boots with its UC8253 display and SD card;
X4 reaches its SSD1677 driver through a blank protocol stub. The browser (WASM)
port is next. See [ROADMAP.md](./ROADMAP.md) for architecture and plan.
X4 reaches its SSD1677 driver through a blank protocol stub. The static browser
frontend is next. See [ROADMAP.md](./ROADMAP.md) for architecture and plan.
## Quick start (native QEMU)
@@ -25,12 +26,30 @@ make run VARIANT=x4 # X4 detection + blank SSD1677 stub
Point at a firmware build with `make firmware FIRMWARE_DIR=/path/to/.pio/build/gh_release`.
`make qemu` is a long compile on modest hardware — set `JOBS` to taste.
## WebAssembly build
Podman or Docker is required. The build is limited to `riscv32-softmmu` and the
X3/X4 device graph; networking, audio, virtfs, tools, docs, and unrelated boards
are disabled.
```sh
make qemu-wasm
# dist/wasm/qemu-system-riscv32.{js,wasm,worker.js}
```
The build runs a Node smoke check when Node is available. Browser execution
requires cross-origin isolation for Emscripten pthreads; the frontend and its
COOP/COEP serving setup are the next phase.
## Contents
- `qemu/patches/0001-xteink-machine.patch` — the `xteink,variant=x3|x4` machine
and its device models (I²C fingerprints, ADC, GPIO, SPI2, X3 UC8253, X4 panel
stub, SD-over-SPI fix), applied onto the pinned espressif/qemu commit by
`scripts/build-qemu.sh`.
- `qemu/patches/0002-qemu-wasm.patch` and `qemu/wasm/Dockerfile` — the pinned
qemu-wasm host backend port and minimal Emscripten dependency environment,
built by `scripts/build-qemu-wasm.sh`.
- `flake.nix` — the espressif-qemu *release* binary as the fast generic baseline
(`nix run .#run-upstream -- flash.bin`), plus a devShell with QEMU's full build
environment. `Makefile``qemu`, `firmware`, `sdimage`, `run`, `chip`.
+8 -6
View File
@@ -19,7 +19,7 @@ running in-browser from static files. The endgame merges those two.
## Status
**Phase 1 (native foundation) is done for X3 and X4 selection.** Unmodified
**The native foundation and WASM compile phase are done.** Unmodified
[canonical firmware](https://github.com/crosspoint-reader/crosspoint-reader)
auto-detects `xteink,variant=x3` through its real I²C fingerprint and boots the
X3 home screen with SD support. `variant=x4` omits those chips, so the same
@@ -35,6 +35,8 @@ make run VARIANT=x4 # X4 detection + blank panel stub
QMP screenshots confirmed the X3 home screen and 528×792 orientation; captured
X4 SPI traffic confirmed the firmware selected its SSD1677 driver.
`make qemu-wasm` produces the Emscripten module, WASM binary, and pthread worker
under `dist/wasm/`; a Node smoke check confirms `xteink` is registered.
### Device selection (X3 vs X4)
@@ -80,11 +82,9 @@ reuses unchanged — no bespoke abstraction layer:
## Remaining phases
1. **Compile the patched fork to WASM** — combine Espressif's SoC models with
qemu-wasm's WASM TCG/TCI backend; build `qemu-system-riscv32` with Emscripten.
2. **Browser front-end (static)** — uploaded flash, canvas, physical buttons,
1. **Browser front-end (static)** — uploaded flash, canvas, physical buttons,
SD block backend, and pre-boot X3/X4 picker. Fully airgapped.
3. **Full X4 panel** — implement SSD1677 RAM/window/update commands when visible
2. **Full X4 panel** — implement SSD1677 RAM/window/update commands when visible
X4 rendering is needed; the selection, dimensions, wiring, and stub exist.
## Repo layout
@@ -94,7 +94,9 @@ reuses unchanged — no bespoke abstraction layer:
| `flake.nix` | espressif-qemu release pkg (`nix run .#run-upstream`) + full QEMU build devShell |
| `Makefile` | `qemu` (source build), `firmware`, `sdimage`, `run`, `chip` |
| `scripts/build-qemu.sh` | clone pinned espressif/qemu, apply patch, build riscv32-softmmu |
| `qemu/patches/` | xteink machine + device models (applied onto the pinned QEMU commit) |
| `qemu/patches/` | xteink devices plus the qemu-wasm host port, applied onto pinned Espressif QEMU |
| `qemu/wasm/Dockerfile` | Minimal pinned Emscripten dependency environment |
| `scripts/build-qemu-wasm.sh` | X3/X4-only WASM build → `dist/wasm/` |
| `chip/eink-x3.chip.c` | legacy Wokwi model — the reverse-engineered X3 e-ink protocol reference |
| `web/` | (phase 3) static front-end: upload + canvas + buttons |
+7 -2
View File
@@ -564,12 +564,13 @@ index 5f4022f..1477f15 100644
switch (opcode) {
case I2C_OPCODE_RSTART:
i2c_end_transfer(s->bus);
@@ -261,9 +272,16 @@ static void esp32_i2c_init(Object * obj)
@@ -261,9 +272,17 @@ static void esp32_i2c_init(Object * obj)
fifo8_create(&s->rx_fifo, ESP32_I2C_FIFO_LENGTH);
}
+static const Property esp32_i2c_properties[] = {
+ DEFINE_PROP_BOOL("c3", Esp32I2CState, c3, false),
+ DEFINE_PROP_END_OF_LIST(),
+};
+
static void esp32_i2c_class_init(ObjectClass * klass, void * data)
@@ -708,11 +709,15 @@ diff --git i/hw/riscv/Kconfig w/hw/riscv/Kconfig
index 192ab47..293808d 100644
--- i/hw/riscv/Kconfig
+++ w/hw/riscv/Kconfig
@@ -119,4 +119,5 @@ config RISCV_ESP32C3
@@ -119,4 +119,9 @@ config RISCV_ESP32C3
select OPENCORES_ETH
select UNIMP
select ESP_RGB
+ select I2C
+ select SSI
+ select SSI_SD
+ select SSI_M25P80
+ select CAN_SJA1000
diff --git i/hw/riscv/esp32c3.c w/hw/riscv/esp32c3.c
index ed698fb..2f1ccbf 100644
File diff suppressed because it is too large Load Diff
+92
View File
@@ -0,0 +1,92 @@
ARG EMSDK_VERSION=3.1.50
ARG ZLIB_VERSION=1.3.1
ARG GLIB_SERIES=2.75
ARG GLIB_VERSION=2.75.0
ARG FFI_COMMIT=adbcf2b247696dde2667ab552cb93e0c79455c84
FROM emscripten/emsdk:${EMSDK_VERSION} AS base
ENV TARGET=/build/target
ENV CFLAGS="-O2 -matomics -mbulk-memory -DNDEBUG -sWASM_BIGINT -DWASM_BIGINT -pthread -sMALLOC=mimalloc -sASYNCIFY=1"
ENV CXXFLAGS="$CFLAGS"
ENV LDFLAGS="-L$TARGET/lib -O2"
ENV CPATH="$TARGET/include"
ENV PKG_CONFIG_PATH="$TARGET/lib/pkgconfig"
ENV EM_PKG_CONFIG_PATH="$PKG_CONFIG_PATH"
ENV CHOST=wasm32-unknown-linux
ENV MAKEFLAGS=-j2
RUN apt-get update && apt-get install -y \
autoconf build-essential libglib2.0-dev libtool ninja-build pkgconf python3-pip
RUN pip3 install meson==1.5.0 tomli
WORKDIR /build
RUN mkdir -p "$TARGET"
FROM base AS zlib
ARG ZLIB_VERSION
RUN mkdir /zlib \
&& curl -Ls "https://zlib.net/fossils/zlib-${ZLIB_VERSION}.tar.gz" \
| tar xzC /zlib --strip-components=1
WORKDIR /zlib
RUN emconfigure ./configure --prefix="$TARGET" --static && make install
FROM base AS libffi
ARG FFI_COMMIT
RUN git clone https://github.com/libffi/libffi /libffi \
&& git -C /libffi checkout "$FFI_COMMIT"
WORKDIR /libffi
RUN autoreconf -fiv \
&& emconfigure ./configure --host="$CHOST" --prefix="$TARGET" \
--enable-static --disable-shared --disable-dependency-tracking \
--disable-builddir --disable-multi-os-directory --disable-raw-api \
--disable-structs --disable-docs \
&& emmake make install SUBDIRS=include
FROM base AS glib
ARG GLIB_SERIES
ARG GLIB_VERSION
COPY --from=zlib /build/ /build/
COPY --from=libffi /build/ /build/
RUN mkdir /stub \
&& printf '%s\n' '#include <netdb.h>' \
'int res_query(const char *n, int c, int t, unsigned char *d, int l) { h_errno = HOST_NOT_FOUND; return -1; }' \
>/stub/res_query.c \
&& emcc $CFLAGS -c /stub/res_query.c -fPIC -o /stub/libresolv.o \
&& ar rcs "$TARGET/lib/libresolv.a" /stub/libresolv.o
RUN mkdir /glib \
&& curl -Lks "https://download.gnome.org/sources/glib/${GLIB_SERIES}/glib-${GLIB_VERSION}.tar.xz" \
| tar xJC /glib --strip-components=1
WORKDIR /glib
ENV CFLAGS="-Wno-error=incompatible-function-pointer-types -O2 -matomics -mbulk-memory -DNDEBUG -pthread -sWASM_BIGINT -sMALLOC=mimalloc -sASYNCIFY=1"
ENV CXXFLAGS="$CFLAGS"
RUN printf '%s\n' \
'#!/bin/bash' \
'set -euo pipefail' \
'cmd="$1"; shift' \
'args=()' \
'for arg in "$@"; do args+=("${arg/-Werror=unused-command-line-argument/-Wno-error=unused-command-line-argument}"); done' \
'"$cmd" "${args[@]}"' \
>/emcc-meson-wrap.sh && chmod +x /emcc-meson-wrap.sh
RUN printf '%s\n' \
'[host_machine]' \
"system = 'emscripten'" \
"cpu_family = 'wasm32'" \
"cpu = 'wasm32'" \
"endian = 'little'" \
'' \
'[binaries]' \
"c = ['bash', '/emcc-meson-wrap.sh', 'emcc']" \
"cpp = ['bash', '/emcc-meson-wrap.sh', 'em++']" \
"ar = 'emar'" \
"ranlib = 'emranlib'" \
"pkgconfig = ['pkg-config', '--static']" \
>/cross.meson
RUN meson setup _build --prefix="$TARGET" --cross-file=/cross.meson \
--default-library=static --buildtype=release --force-fallback-for=pcre2,gvdb \
-Dselinux=disabled -Dxattr=false -Dlibmount=disabled -Dnls=disabled \
-Dtests=false -Dglib_assert=false -Dglib_checks=false \
&& sed -i -E '/#define HAVE_(CLOSE_RANGE|EPOLL_CREATE|KQUEUE|POSIX_SPAWN|FALLOCATE) 1/d' _build/config.h \
&& meson install -C _build
FROM base
COPY --from=glib /build/ /build/
WORKDIR /build
CMD ["sleep", "infinity"]
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
SRC="${QEMU_WASM_SRC:-$ROOT/_scratch/qemu-wasm-src}"
OUT="${WASM_OUT:-$ROOT/dist/wasm}"
[[ "$SRC" = /* ]] || SRC="$ROOT/$SRC"
[[ "$OUT" = /* ]] || OUT="$ROOT/$OUT"
TAG="esp-develop-9.2.2-20260417"
COMMIT="40edccac415693c5130f91c01d84176ae6008566"
QEMU_WASM_COMMIT="0ef7b4e2814b231705d8371dd7997f5b72e70baf"
IMAGE="xteink-qemu-wasm-build"
JOBS="${JOBS:-2}"
if [ -n "${CONTAINER_ENGINE:-}" ]; then
ENGINE="$CONTAINER_ENGINE"
elif command -v podman >/dev/null; then
ENGINE=podman
else
ENGINE=docker
fi
if [ ! -d "$SRC/.git" ]; then
git clone --depth 1 --branch "$TAG" https://github.com/espressif/qemu "$SRC"
fi
cd "$SRC"
if [ "$(git rev-parse HEAD)" != "$COMMIT" ]; then
echo "QEMU source must be $COMMIT ($TAG)" >&2
exit 1
fi
if [ ! -f hw/display/xteink_x3_eink.c ] || [ ! -f tcg/wasm32.c ]; then
if ! git diff --quiet || ! git diff --cached --quiet; then
echo "QEMU source has changes that conflict with the WASM patches" >&2
exit 1
fi
git apply "$ROOT/qemu/patches/0001-xteink-machine.patch"
git apply --whitespace=nowarn "$ROOT/qemu/patches/0002-qemu-wasm.patch"
fi
printf 'Building Emscripten environment (qemu-wasm %s)\n' "$QEMU_WASM_COMMIT"
"$ENGINE" build -t "$IMAGE" -f "$ROOT/qemu/wasm/Dockerfile" "$ROOT/qemu/wasm"
FLAGS="-O3 -Wno-error=unused-command-line-argument -matomics -mbulk-memory -DNDEBUG -DG_DISABLE_ASSERT -D_GNU_SOURCE -sASYNCIFY=1 -pthread -sPROXY_TO_PTHREAD=1 -sFORCE_FILESYSTEM -sALLOW_TABLE_GROWTH -sEMULATE_FUNCTION_POINTER_CASTS=1 -sINITIAL_MEMORY=512MB -sWASM_BIGINT -sMALLOC=mimalloc -sEXPORT_ES6=1 -sASYNCIFY_IMPORTS=ffi_call_js"
if [ ! -f build/build.ninja ]; then
"$ENGINE" run --rm -v "$SRC:/qemu" "$IMAGE" bash -lc "
cd /qemu
emconfigure ./configure \\
--static \\
--target-list=riscv32-softmmu \\
--cpu=wasm32 \\
--cross-prefix= \\
--without-default-features \\
--without-default-devices \\
--with-devices-riscv32=xteink \\
--enable-system \\
--with-coroutine=fiber \\
--disable-werror \\
--disable-docs \\
--disable-tools \\
--extra-cflags='$FLAGS' \\
--extra-cxxflags='$FLAGS' \\
--extra-ldflags='-sEXPORTED_RUNTIME_METHODS=getTempRet0,setTempRet0,addFunction,removeFunction,TTY,FS'
"
fi
"$ENGINE" run --rm -v "$SRC:/qemu" "$IMAGE" \
bash -lc "cd /qemu && emmake ninja -C build -j$JOBS qemu-system-riscv32.js"
mkdir -p "$OUT"
install -m 0644 build/qemu-system-riscv32.js "$OUT/"
install -m 0644 build/qemu-system-riscv32.wasm "$OUT/"
install -m 0644 build/qemu-system-riscv32.worker.js "$OUT/"
if command -v node >/dev/null; then
"$ROOT/scripts/smoke-qemu-wasm.sh" "$OUT/qemu-system-riscv32.js"
fi
printf '%s\n' "$OUT"
+9
View File
@@ -0,0 +1,9 @@
import { pathToFileURL } from 'node:url';
const modulePath = process.argv[2];
if (!modulePath) {
throw new Error('usage: node scripts/smoke-qemu-wasm.mjs <qemu-system-riscv32.js>');
}
const { default: createQemu } = await import(pathToFileURL(modulePath));
await createQemu({ arguments: ['-machine', 'help'] });
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
MODULE="${1:?usage: scripts/smoke-qemu-wasm.sh <qemu-system-riscv32.js>}"
OUTPUT="$(mktemp)"
trap 'rm -f "$OUTPUT"' EXIT
timeout 15 node "$(dirname "$0")/smoke-qemu-wasm.mjs" "$MODULE" >"$OUTPUT" 2>&1 || status=$?
status="${status:-0}"
if [ "$status" -ne 0 ] && [ "$status" -ne 1 ] && [ "$status" -ne 124 ]; then
cat "$OUTPUT" >&2
exit "$status"
fi
grep -F 'xteink xteink X3/X4 (ESP32-C3); variant=x3|x4' "$OUTPUT" >/dev/null
if grep -Eq 'RuntimeError|table index is out of bounds' "$OUTPUT"; then
cat "$OUTPUT" >&2
exit 1
fi