18 KiB
Handoff — xteink-web-emulator
Everything below is hard-won context from the session that built the native X3
emulator. If it isn't in this repo or this doc, it's gone. Read this before
touching QEMU internals. Pairs with ROADMAP.md (the plan) — this is the
"how it actually works and what will bite you" file.
0. TL;DR of current state
- One
xteinkQEMU machine selectsvariant=x3|x4at 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. - Phase-1 device models live in
qemu/patches/0001-xteink-machine.patch;0002-qemu-wasm.patchports qemu-wasm's host backend onto the same pinned Espressif QEMU commit40edccac415693c5130f91c01d84176ae6008566. make qemu-wasmproduces a smoke-tested ES module, WASM binary, and pthread worker underdist/wasm/; the static browser UI boots firmware, renders X3, drives buttons, and exposes SD files/debug metrics. Full X4 rendering waits.
1. Environment / hardware realities (READ FIRST)
- A full QEMU build is large and can take several minutes. Build parallelism
defaults to
nproc; setJOBSlower if memory pressure requires it and use long timeouts for anyninja/pio/nix build. Do not treat a long-running build as hung. - First full QEMU build ≈ many minutes; incremental after a patch edit is
seconds (only the touched
.c+ relink). Iterate incrementally; neverrm -rf build. - Nix sandbox capability leak: running FHS/bwrap-wrapped tools (PlatformIO,
and the prebuilt release QEMU) fails with
bwrap: Unexpected capabilities but not setuid, old file caps config?. Workaround — clear ambient caps:Our source-built QEMU is a normal nix-linked ELF and does NOT need this (that's a bonus reason we build from source, not just to patch).SP=$(nix build nixpkgs#util-linux.bin --no-link --print-out-paths)/bin/setpriv "$SP" --inh-caps=-all --ambient-caps=-all nix develop <flake> --command <cmd>
2. Building espressif-qemu from source (the fiddly part)
scripts/build-qemu.sh encodes all of this, but here's why:
- Reuse nixpkgs' QEMU build env:
nix develop nixpkgs#qemu --command …(or the repo devShell, which hasinputsFrom = [ pkgs.qemu ]). Getting glib/ pixman/meson/ninja right by hand is painful; borrow qemu's. - slirp: the fork unconditionally compiles
net/slirp.ceven with--disable-slirp(build-system bug), so you must enable it. nixpkgs provides libslirp;--enable-slirp+ libslirp onPKG_CONFIG_PATHworks, or it falls back to the bundled subproject. - libgcrypt: the ESP32-C3 crypto devices (
esp32c3_aes.c, rsa, ds, xts) are gated ongcrypt.found(). QEMU prefers gnutls and skips gcrypt unless you pass--enable-gcrypt. Without it the build succeeds but the machine fails at runtime:unknown type 'misc.esp32c3.aes'. - Meson caches
pkg_config_path: exportingPKG_CONFIG_PATHafter the firstconfigureis ignored on reconfigure. Usemeson configure build -Dpkg_config_path=…or pass it to./configure. - Config that works:
--target-list=riscv32-softmmu --enable-gcrypt --enable-slirp --disable-werror --disable-docs --disable-tools. /tmp/qsrcis where I built it this session and is EPHEMERAL (lost on reboot). The patch is the durable artifact;make qemureclones+rebuilds.
3. How espressif-qemu's esp32c3 machine is structured
- File:
hw/riscv/esp32c3.c. It hand-builds the SoC (no device tree). - Catch-all MMIO:
esp32c3_io_ops(esp32c3_io_read/write) covers the whole peripheral window fromESP32C3_IO_START_ADDR(==DR_REG_UART_BASE==0x60000000) for0xd1000bytes. Any peripheral without a dedicated device falls here and reads 0 / ignores writes. That's why unmodeled peripherals silently misbehave instead of faulting. - Turn on
#define ESP32C3_IO_WARNING 1(top of the file) to log every unsupported access as[ESP32-C3] Unsupported read/write to $ADDR. This is the single most useful debugging lever. It's noisy (megabytes/sec), so run ~12 s and bucket by address:The hottest address is almost always the next thing to model. Leave itgrep 'Unsupported read' log | sed -E 's/.*\$//' | sort | uniq -c | sort -nr | head0in committed code. - Devices that already exist in the fork: UART, GPIO (barebones — we fleshed
it out), SPI1 (flash only), GDMA, AES/SHA/RSA/HMAC/DS/XTS, RTC_CNTL, systimer,
timg, efuse, intmatrix, cache, JTAG, TWAI,
esp_rgb(a QEMU-specific virtual framebuffer, NOT real X3 hardware — we disable it on the xteink machine). - Devices that did NOT exist and we added: SAR ADC, SPI2, the UC8253 panel, and real GPIO register behavior. There is no I²C controller model — this matters for X3/X4 detection (see §7).
4. Peripheral register facts (discovered, verified against firmware)
Base addresses from ESP-IDF soc/esp32c3 headers; all confirmed live.
SAR ADC — DR_REG_APB_SARADC_BASE = 0x60040000
- The original boot blocker: firmware polls
INT_RAW(+0x44) bit 31 (ADC1_ONESHOT_DONE) after starting a conversion; the unmodeled reg returned 0 forever → busy-loop with interrupts off → interrupt-WDT panic (Guru Meditation … Interrupt wdt timeout). The panic dump'sS0/FP = 0x60040000literally pointed at the ADC base. - Model (
hw/adc/esp32c3_adc.c):INT_RAWalways returns done bits 31|30;ONETIME_SAMPLE(+0x20) bits [28:25] select the channel;1_DATA_STATUS(+0x2c) /2_DATA_STATUS(+0x30) return the selected channel's 12-bit value. Per-channel values are QOM propsadci[*], default0xfff. adc_oneshot_ll_get_raw_resultreadsadc1_data & 0xfff.
SPI2 (display + SD) — DR_REG_SPI2_BASE = 0x60024000
- Completely different register map from SPI1 (
hw/ssi/esp32c3_spi.cis flash-specific — do NOT reuse it). GP-SPI2 layout:CMD 0x00(USR = bit24, UPDATE = bit23),MS_DLEN 0x1c(bits[17:0] = bitlen-1), data bufferW0..W15at0x98..0xd4. - Model (
hw/ssi/esp32c3_spi2.c): onCMDwrite with USR set, clock(MS_DLEN/8)+1bytes out of W0.. onto anSSIBusviassi_transfer, writing RX back into the same words. DMA path is intentionally unimplemented — the firmware's CPU-buffer path is enough; add DMA only if a future firmware needs it (it'll show up as SPI2 traffic that the CPU-buffer model can't explain).
GPIO — DR_REG_GPIO_BASE = 0x60004000
- The stock model only returned the strap register. We added real
OUT 0x04/OUT_W1TS 0x08/OUT_W1TC 0x0c/ENABLE 0x20(+W1TS/W1TC) /IN 0x3c, with 32 named input + output qemu_irq lines (ESP32_GPIO_INPUT/ESP32_GPIO_OUTPUT). - CRITICAL SUBTLETY: an output line must read HIGH when the pin is
configured as input (
output_enablebit clear), not follow the (zero) OUT latch. Real boards have pull-ups; the X3 display CS is pulled high while SD is being probed. Without this, CS floated LOW during SD init and the panel greedily consumed the SD command bytes (FF … 40 00 … 95) as garbage commands. This one bit cost real debugging time.
SD-over-SPI — QEMU's ssi-sd + sd-card-spi
- These exist upstream; we attach them to SPI2 at CS12. But the 9.2 fork's
ssi-sd.chas a bug: CMD58 (read OCR) hardcodes the R1 byte to0x01(idle) even after ACMD41 reported ready → SdFat rejects the card → firmware shows "SD card error". Fix: trackidlestate, clear it when ACMD41 returns 0, return it for CMD58. (Upstream qemu master rewrote this path entirely; we did the minimal equivalent + a vmstate version bump to 8.)
5. The e-ink panel model (hw/display/xteink_x3_eink.c)
- X3 uses a UC8253 controller, 792×528, 99 bytes/row, 52 272 bytes/plane.
- Protocol (from
chip/eink-x3.chip.c, the old Wokwi model — keep it as the reference): DC low = command, DC high = data.0x10=DTM1 (old plane),0x13=DTM2 (new plane),0x12=refresh (render + pulse BUSY),0x04/0x02= power on/off (pulse BUSY). Bit 1 = white, 0 = black. Rows arrive bottom-to-top (wire row i → framebuffer row H-1-i). - BUSY is a GPIO output from the panel back into GPIO6 (active low; we pulse it low for 5 ms of virtual time via a QEMUTimer, then high).
- Rendered to a QEMU graphical console (not a custom file callback) so the
WASM front-end reuses QEMU's display surface. Capture headless with QMP
screendump→ PPM. - Orientation: the controller framebuffer (792×528 landscape) is rotated
90° clockwise to the physical portrait panel (528×792). We bake that
into the render loop (
screen_x = H-1-y,screen_y = x) so the console is already physical-orientation. Verified by eye: text reads upright at 528×792. - SSI gotcha: a
TYPE_SSI_PERIPHERALsubclass must setssi->realizeeven if empty — the base class calls it unconditionally and a null pointer segfaults at machine init. Also setcs_polarity = SSI_CS_LOWanduser_creatable = false.
6. Machine wiring (xteink_machine_init)
xteink is a subclass of the esp32c3 machine. variant=x3 is the default;
variant=x4 selects the sibling wiring before boot. After the base init it:
- creates the selected panel on
spi2.bus, wires GPIO out 21→CS, 4→DC, 5→RST, and panel BUSY→GPIO in 6 (X3 UC8253 renders; X4 SSD1677 is a blank stub); - creates
ssi-sdat CS index 1 onspi2.bus, wires GPIO out 12→its CS, and asd-card-spibacked bydrive_get(IF_SD, 0, 0). Native uses a raw image; WASM uses QEMUvvfatover the JS-created MEMFS/sdcarddirectory; - defaults both CS lines high at init (
qemu_set_irq(cs, 1)) so nothing is selected before the firmware drives GPIO.
Pin facts (X3, from freeink-sdk BoardConfig.h XTEINK_X3): display
SCLK8/MOSI10/CS21/DC4/RST5/BUSY6; SD shares SCLK8/MOSI10, MISO7, CS12; button
ADC ladders on GPIO1 & GPIO2; power button GPIO3; UC8253 @ 16 MHz.
7. X3 vs X4 detection — the thing that wasted time
- Both X3 and X4 ship in ONE ESP32-C3 binary. Which panel/battery profile is
used is decided at runtime by an I²C fingerprint on SDA20/SCL0: presence of
BQ27220 gauge (0x55), DS3231 RTC (0x68), QMI8658 IMU (0x6B). ≥2 of 3 on two
passes ⇒ X3; 0 ⇒ X4. Result is cached in NVS (
cphw/dev_det) and can be overridden (cphw/dev_ovr: 1=X4, 2=X3). esp32.i2cfrom the Espressif fork now runs on C3. The register layout matches, but C3 command opcodes differ (RESTART=6, WRITE=1, READ=3, STOP=2, END=4); ac3controller property normalizes them without changing xtensa.variant=x3attaches minimal BQ27220/DS3231/QMI8658 targets returning exactly the probe signatures.variant=x4leaves the bus empty, so reads NAK and the firmware selects X4. The resulting Arduino errors are noisy but harmless.- Validation used upstream commit
556b8aewith the old forced-X3 line removed. X3 rendered the home screen (~16,054 black pixels); X4 emitted SSD1677 bytes12 18 80 0c ..., proving the guest selected its X4 driver. - Detection persists
cphw/dev_det; use a clean flash or QEMU-snapshotwhen testing both variants so one run's NVS cache cannot affect the next.
8. Reproducibility model
- Durable = the patches: apply
0001-xteink-machine.patch, then0002-qemu-wasm.patchfor the browser build. Both target exact QEMU commit40edccac415693c5130f91c01d84176ae6008566(tagesp-develop-9.2.2-20260417). When editing_scratch/qemu-src, regenerate:(Reverse-check on the dirty tree proves patch == pristine→current diff, which is equivalent to forward-applying to pristine.)cd <qemu-src> && git add -N <new files> && git diff --binary > <repo>/qemu/patches/0001-xteink-machine.patch git apply --reverse --check <repo>/qemu/patches/0001-xteink-machine.patch - Firmware/SD/flash images and
/tmp/qsrcare all disposable;make qemu+make firmware sdimagerebuild them.
9. Debugging workflow cheat-sheet
- Headless run + capture (canonical loop this session):
qemu-system-riscv32 -machine xteink,variant=x3 -L <src>/pc-bios -display none -serial null \ -qmp unix:/tmp/q.sock,server=on,wait=off \ -drive file=flash.bin,if=mtd,format=raw -drive file=sd.img,if=sd,format=raw & sleep 200 # this CPU is slow — give boot real time scripts/qmp-screendump.py /tmp/q.sock out.ppm - Inspect the screen: convert with
nix shell nixpkgs#imagemagick -c magick out.ppm out.png; the black-pixel count is a cheap "did the screen change" signal (home ≈ 15914, browse ≈ 11962, boot-white = 962). - Inject a button (proper edge = set then clear):
scripts/qmp-qom.py /tmp/q.sock set /machine/adc "adci[1]" 2760, then4095to release. Ladder-1: Back 3512 / Confirm 2694 / Left 1493 / Right ~5; ladder-2: Up 2242 / Down ~5. >3900 = no button. Power is active-low:qmp-qom.py ... set /machine/gpio "input-level[3]" falsethentrue. - Cold-boot power behavior: stock firmware expects Power held after a POWERON reset. On this ~24× slow host, hold GPIO3 low ~90 real seconds. For X4 battery boot also set GPIO20 low; high means USB and causes intentional sleep.
- SD protocol trace: flip
//#define DEBUG_SSI_SD 1inhw/sd/ssi-sd.c. - Panel trace: add an
info_reportinxteink_x3_eink_transfer(bounded with a static counter — it's hot). - Symbolize a panic PC:
llvm-addr2line -f -C -e firmware.elf 0xADDR(ignore DWARF "premature terminator" warnings). - Flash images must be 2/4/8/16 MB. The partition table spans 16 MB (app is
<4 MB), so always pad to 16 MB (
truncate -s 16M); a mismatched size errors with "only 2, 4, 8, and 16MB images are supported".
10. WASM build and next steps
make qemu-wasmuses Podman or Docker and the pinned Emscripten 3.1.50 image inqemu/wasm/Dockerfile. It builds onlyriscv32-softmmuwith the xteink device graph; unrelated boards, networking, audio, virtfs, tools, and docs are disabled. Outputs go todist/wasm/.- qemu-wasm commit
0ef7b4e2814b231705d8371dd7997f5b72e70bafis based on QEMU 8.2;0002-qemu-wasm.patchis the resolved port to Espressif QEMU 9.2.-sEMULATE_FUNCTION_POINTER_CASTS=1is 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.
- Real firmware now boots past the earlier crashes and initializes the X3 panel. It stalls before painting a full home screen (display generation freezes at 2 after early init) — the next investigation, likely timer/interrupt or slow TCG.
- Function-pointer signature fixes (required after dropping
EMULATE_FUNCTION_POINTER_CASTS, which broke the wasm32 TCGffi_call_jspath):object_class_cmp→GCompareFunc,esp_shahash-callback wrappers, and anxts_aes != NULLguard in the ESP32-C3 cache (XTS is gcrypt-gated and absent in WASM; unencrypted flash is unaffected). - Emscripten pthread startup has a rare transient trap at proxied entry
(
invokeEntryPoint→dynCall_ii); the build retries the bridge smoke once. - The upstream Emscripten RAM allocator fix is backported: anonymous guest RAM
uses
qemu_memaligninstead of the broken partial-unmapmmapworkaround. - Emscripten pthreads require cross-origin isolation (COOP/COEP) in the browser.
make webruns the stdlib server with those headers and serves/web/plus the siblingdist/wasm/artifacts. - The narrow browser bridge exports panel surface metadata/pixels and atomic ADC/GPIO setters. JS polls a frame generation counter and forces alpha to 255; X3's native surface stores RGB with a zero alpha byte.
- Firmware and ROM are written to MEMFS before QEMU starts. Browser SD uses
QEMU
vvfatto expose/sdcardas a writable 32 GiB FAT32 volume; memory scales with actual directory files plus about 5 MiB of FAT/write bookkeeping, not logical capacity. The initial JS provider createsTest/example.txt. - The inspector has Logs, Files, and Debug tabs. Debug reports committed WASM heap (176.3 MiB after a 90-second validation run), visible MEMFS bytes, SD file bytes, and browser JS heap when supported.
- A second boot needs a page reload because QEMU machine selection and global state are process-wide.
Next:
- Browser SD editing/persistence: add file controls and optionally OPFS. Live host-side changes to a mounted FAT volume are intentionally accepted as unsafe; editing is not implemented yet.
Patch workflow:
scripts/build-qemu-wasm.shresets/reapplies patches when either patch is newer than.xteink-patch-stamp. Regenerate patch 0002 from_scratch/qemu-fix-srcwithgit diff --binary 610f8c69 > qemu/patches/0002-qemu-wasm.patch.
- Full X4 rendering later: SSD1677 is 800×480 controller / 480×800 portrait, BUSY active-high. Implement its RAM/window/update commands when needed.
11. Key paths
| What | Where |
|---|---|
| Emulator repo | /home/evanreichard/Development/git/personal/xteink-web-emulator |
| 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/ |
| Browser UI | web/; run with make web |
| 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) |
| espressif/qemu commit | 40edccac415693c5130f91c01d84176ae6008566 |
| espressif/qemu tag | esp-develop-9.2.2-20260417 |
| qemu-wasm | github.com/ktock/qemu-wasm |