commit f972fbe78bf6be34fe7e118dbd557ed3044a38ea Author: Evan Reichard Date: Sun Jul 19 19:21:43 2026 -0400 initial commit diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..df23cb1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +dist/ +firmware.bin +firmware.elf +flash16.bin +flash.bin +*.png +*.ppm +result +_scratch/ +sd.img diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 0000000..be124d4 --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,290 @@ +# 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 + +- The `xteink-x3` QEMU machine boots **unmodified canonical firmware** to the + home screen, mounts a FAT32 SD image, and takes button input. Verified with + QMP `screendump`. +- All Phase-1 device models live in **one patch**: + `qemu/patches/0001-xteink-x3-machine.patch`, applied onto espressif/qemu tag + `esp-develop-9.2.2-20260417` by `scripts/build-qemu.sh`. +- Next: X4 sibling machine → compile to WASM (qemu-wasm) → static browser UI. + +--- + +## 1. Environment / hardware realities (READ FIRST) + +- **The dev box is ~15 yr old, 4 GB RAM.** A full QEMU build is ~1660 objects + and takes a long time. **Always `-j2`** (more OOMs the linker) and use + **very long timeouts (I used 7200 s)** for any `ninja`/`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; never + `rm -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: + ```sh + SP=$(nix build nixpkgs#util-linux.bin --no-link --print-out-paths)/bin/setpriv + "$SP" --inh-caps=-all --ambient-caps=-all nix develop --command + ``` + 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). + +--- + +## 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 has `inputsFrom = [ pkgs.qemu ]`). Getting glib/ + pixman/meson/ninja right by hand is painful; borrow qemu's. +- **slirp**: the fork *unconditionally compiles* `net/slirp.c` even with + `--disable-slirp` (build-system bug), so you must **enable** it. nixpkgs + provides libslirp; `--enable-slirp` + libslirp on `PKG_CONFIG_PATH` works, or + it falls back to the bundled subproject. +- **libgcrypt**: the ESP32-C3 crypto devices (`esp32c3_aes.c`, rsa, ds, xts) are + gated on `gcrypt.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`**: exporting `PKG_CONFIG_PATH` after the + first `configure` is ignored on reconfigure. Use + `meson 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/qsrc` is where I built it this session and is EPHEMERAL** (lost on + reboot). The patch is the durable artifact; `make qemu` reclones+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 from `ESP32C3_IO_START_ADDR` (== `DR_REG_UART_BASE` == + `0x60000000`) for `0xd1000` bytes. **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: + ```sh + grep 'Unsupported read' log | sed -E 's/.*\$//' | sort | uniq -c | sort -nr | head + ``` + The hottest address is almost always the next thing to model. Leave it `0` in + 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's `S0/FP = + 0x60040000` literally pointed at the ADC base. +- Model (`hw/adc/esp32c3_adc.c`): `INT_RAW` always 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 props `adci[*]`, default `0xfff`. +- `adc_oneshot_ll_get_raw_result` reads `adc1_data & 0xfff`. + +### SPI2 (display + SD) — `DR_REG_SPI2_BASE = 0x60024000` +- **Completely different register map from SPI1** (`hw/ssi/esp32c3_spi.c` is + flash-specific — do NOT reuse it). GP-SPI2 layout: `CMD 0x00` (USR = bit24, + UPDATE = bit23), `MS_DLEN 0x1c` (bits[17:0] = bitlen-1), data buffer + `W0..W15` at `0x98..0xd4`. +- Model (`hw/ssi/esp32c3_spi2.c`): on `CMD` write with USR set, clock + `(MS_DLEN/8)+1` bytes out of W0.. onto an `SSIBus` via `ssi_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_enable` bit 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.c` has a **bug**: CMD58 (read OCR) hardcodes the R1 byte to `0x01` + (idle) even after ACMD41 reported ready → SdFat rejects the card → firmware + shows "SD card error". Fix: track `idle` state, 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_PERIPHERAL` subclass **must set `ssi->realize`** + even if empty — the base class calls it unconditionally and a null pointer + segfaults at machine init. Also set `cs_polarity = SSI_CS_LOW` and + `user_creatable = false`. + +--- + +## 6. Machine wiring (`xteink_x3_machine_init`) + +`xteink-x3` is a subclass of the `esp32c3` machine that, after the base init: +- creates the panel on `spi2.bus`, wires GPIO out 21→CS, 4→DC, 5→RST, and panel + BUSY→GPIO in 6; +- creates `ssi-sd` at CS index 1 on `spi2.bus`, wires GPIO out 12→its CS, and a + `sd-card-spi` backed by `drive_get(IF_SD, 0, 0)` (QEMU block layer — the SD + "interface" you asked for; local `-drive` now, browser blockdev later); +- **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). +- **QEMU models no I²C**, so detection currently returns X4 by default. The + **crosspoint-reader-lua** firmware never calls `setDisplayX3()` at all → it is + **X4-only in emulation** and drives SSD1677 commands (`0x18/0x0c/0x44/0x45…`), + which our UC8253 panel ignores (→ blank screen). Don't chase that as a bug; + it's the wrong firmware for an X3 machine. +- To test the X3 display this session I built the **canonical** firmware with a + **one-line temporary hack**: `detectDeviceTypeWithFingerprint()` in + `/tmp/crosspoint-reader-main/lib/hal/HalGPIO.cpp` returns `X3` at the top. + **THIS EDIT IS IN /tmp AND WILL BE LOST ON REBOOT.** The merged test image is + `/tmp/crosspoint-main-x3-flash.bin` (also ephemeral). +- **Production plan (no custom firmware):** the `xteink-x3` machine should expose + the three X3 I²C fingerprint chips (needs an esp32c3 I²C controller model + + trivial BQ27220/DS3231/QMI8658 stubs that return plausible WHO_AM_I/values), + OR the emulator seeds the NVS `dev_ovr` key. Either makes stock firmware + auto-select X3. The `xteink-x4` machine simply omits the fingerprint. This is + how launch-time X3/X4 selection stays firmware-agnostic. + +--- + +## 8. Reproducibility model + +- **Durable = the patch** (`qemu/patches/0001-xteink-x3-machine.patch`), verified + to `git apply` cleanly onto a pristine checkout of the pinned tag. When you + edit `/tmp/qsrc`, **regenerate it** and re-verify: + ```sh + cd && git add -N && git diff --binary > /qemu/patches/0001-xteink-x3-machine.patch + git apply --reverse --check /qemu/patches/0001-xteink-x3-machine.patch # must succeed + ``` + (Reverse-check on the dirty tree proves patch == pristine→current diff, which + is equivalent to forward-applying to pristine.) +- Firmware/SD/flash images and `/tmp/qsrc` are all disposable; `make qemu` + + `make firmware sdimage` rebuild them. + +--- + +## 9. Debugging workflow cheat-sheet + +- **Headless run + capture** (canonical loop this session): + ```sh + qemu-system-riscv32 -machine xteink-x3 -L /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; hold-from-boot gives no + press edge): `scripts/qmp-qom.py /tmp/q.sock set /machine/adc "adci[1]" 2760` + then `… 4095` to release. Ladder-1 midpoints: Back 3512 / Confirm 2694 / + Left 1493 / Right ~5; ladder-2: Up 2242 / Down ~5. **>3900 = no button.** +- **SD protocol trace**: flip `//#define DEBUG_SSI_SD 1` in `hw/sd/ssi-sd.c`. +- **Panel trace**: add an `info_report` in `xteink_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. Next steps (in priority order) + +1. **X3 fingerprint or NVS seeding** (§7) so stock firmware auto-selects X3 with + no source edit. Smallest path to a genuinely firmware-agnostic X3 machine. + Needs a minimal esp32c3 I²C controller + 3 tiny I²C target stubs, OR NVS + pre-seed. This is the last thing standing between "works with a hacked build" + and "works with any downloaded binary." +2. **`xteink-x4` machine**: SSD1677 panel (different command set — `0x18` set-RAM, + `0x44/0x45` window, `0x4e/0x4f` cursor, `0x24` write, `0x22/0x20` update; + BUSY active-HIGH), X4 battery via ADC (not I²C gauge), 792×528? confirm X4 + geometry from `BoardConfig.h XTEINK_X4`. Sibling machine + a second panel + device; reuse ADC/SPI2/GPIO/SD unchanged. +3. **Compile to WASM**: combine this patched fork with `ktock/qemu-wasm`'s WASM + TCG/TCI backend + Emscripten. Expect toolchain friction; the device models + themselves are portable C and shouldn't need changes. De-risk by first + building an *unpatched* esp32c3 target under qemu-wasm, then apply our patch. +4. **Static browser front-end**: `` fed from the display surface; DOM + buttons → the ADC/GPIO QOM injection (same calls as `qmp-qom.py`); file input + → emulated flash; preloaded/uploaded SD image → block backend; X3/X4 chosen + before boot. Must run fully from static assets (airgapped). + +--- + +## 11. Key paths + +| What | Where | +|------|-------| +| Emulator repo | `/home/evanreichard/Development/git/personal/xteink-web-emulator` | +| The patch | `qemu/patches/0001-xteink-x3-machine.patch` | +| QEMU source (ephemeral) | `_scratch/qemu-src` (via `make qemu`); was `/tmp/qsrc` this session | +| Canonical firmware (X3 hack in /tmp, ephemeral) | `/tmp/crosspoint-reader-main` (`lib/hal/HalGPIO.cpp` forced X3) | +| Lua firmware (X4-only in emu) | `/home/evanreichard/Development/git/personal/crosspoint-reader-lua` | +| Upstream firmware refs | `crosspoint-reader/crosspoint-reader` (canonical, cleaner) + its `freeink-sdk` submodule | +| espressif/qemu tag | `esp-develop-9.2.2-20260417` | +| qemu-wasm | `github.com/ktock/qemu-wasm` | diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2ba4e11 --- /dev/null +++ b/Makefile @@ -0,0 +1,41 @@ +# PlatformIO artifacts to merge into a 16 MB ESP32-C3 flash image. +# Canonical firmware: https://github.com/crosspoint-reader/crosspoint-reader +FIRMWARE_DIR ?= ../crosspoint-reader/.pio/build/default +SD_SRC ?= ../crosspoint-reader/sdcard +QEMU_SRC ?= _scratch/qemu-src +QEMU_BIN = $(QEMU_SRC)/build/qemu-system-riscv32 + +.PHONY: firmware sdimage qemu run run-upstream chip clean + +firmware: + esptool --chip esp32c3 merge-bin -o flash.bin \ + 0x0 $(FIRMWARE_DIR)/bootloader.bin \ + 0x8000 $(FIRMWARE_DIR)/partitions.bin \ + 0x10000 $(FIRMWARE_DIR)/firmware.bin + truncate -s 16M flash.bin + cp $(FIRMWARE_DIR)/firmware.elf firmware.elf + +# Local-disk implementation of the SD block interface. +sdimage: + scripts/mksd.sh $(SD_SRC) sd.img + +qemu: + QEMU_SRC=$(QEMU_SRC) scripts/build-qemu.sh + +# Patched xteink X3 machine. Add `-display none` for a headless run. +run: qemu + $(QEMU_BIN) -machine xteink-x3 -L $(QEMU_SRC)/pc-bios \ + -serial stdio -drive file=flash.bin,if=mtd,format=raw \ + $(if $(wildcard sd.img),-drive file=sd.img,if=sd,format=raw) + +# Unpatched release binary retained as the fast generic ESP32-C3 baseline. +run-upstream: + nix run .#run-upstream -- flash.bin + +# Legacy Wokwi protocol model; retained as a small host-tested reference. +chip: + $(MAKE) -C chip + +clean: + $(MAKE) -C chip clean + rm -f flash.bin flash16.bin firmware.elf diff --git a/README.md b/README.md new file mode 100644 index 0000000..9fd805c --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# xteink-web-emulator + +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:** native peripheral models done for the X3. Unmodified canonical +firmware boots to the home screen, mounts a FAT32 SD image, lists its files, and +responds to button presses under a patched espressif-qemu. The browser (WASM) +port is next. See [ROADMAP.md](./ROADMAP.md) for architecture and plan. + +## Quick start (native QEMU) + +```sh +nix develop +make qemu # clone espressif/qemu @ pinned tag, apply patch, build (JOBS=2) +make firmware sdimage # 16 MB flash.bin + FAT32 sd.img from a PlatformIO build +make run # boot the xteink-x3 machine (add `-display none` for headless) +``` + +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. + +## Contents + +- `qemu/patches/0001-xteink-x3-machine.patch` — the `xteink-x3` machine and its + device models (ADC, GPIO, SPI2, UC8253 e-ink, SD-over-SPI fix), applied onto + the pinned espressif/qemu tag by `scripts/build-qemu.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`. +- `scripts/mksd.sh` — build the FAT32 SD backing image (local-disk impl of the + SD block interface). +- `chip/eink-x3.chip.c` — the reverse-engineered X3 e-ink protocol, first written + as a Wokwi chip and now the reference for the QEMU display device. + `make -C chip test` runs its host self-check. + +## Why not Wokwi + +Wokwi runs any bin in a browser and we had the e-ink chip working — but its +simulation engine is proprietary and gated behind a license/service, so it can't +be self-hosted or run airgapped. QEMU is the open path; the e-ink protocol work +carried straight over. diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..f70d18d --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,107 @@ +# xteink-web-emulator — architecture & roadmap + +**Goal:** an offline, static-asset web page where you upload an xteink X3/X4 +firmware `.bin` and it runs *client-side* — real ESP32-C3 machine code, with the +e-ink screen on a canvas, on-screen buttons, and a virtual SD card. No server, +no proprietary engine. + +## Why QEMU (and why not the alternatives) + +| Approach | Runs any `.bin` | Open source | Offline in browser | +|----------|:---:|:---:|:---:| +| **Wokwi** | ✅ | ❌ (closed engine, licensed) | ❌ | +| **Recompile firmware → WASM** (crosspoint-simulator + Emscripten) | ❌ source only | ✅ | ✅ | +| **QEMU (espressif fork) → WASM** | ✅ | ✅ | ✅ | + +Only QEMU satisfies all three. `espressif/qemu` models the ESP32-C3 SoC and +executes real firmware; `ktock/qemu-wasm` shows QEMU compiling to WASM and +running in-browser from static files. The endgame merges those two. + +## Status + +**Phase 1 (native peripheral models) is done for the X3.** The unmodified +canonical firmware (`crosspoint-reader/crosspoint-reader`, X3-selected) boots to +the home screen, mounts a FAT32 SD image, lists its contents, and responds to +button presses — all under native espressif-qemu with our patch. Reproduce: + +```sh +make qemu # clone espressif/qemu @ pinned tag, apply patch, build +make firmware sdimage # 16 MB flash.bin + FAT32 sd.img from a pio build +make run # boot the xteink-x3 machine (add `-display none` for headless) +``` + +Screenshots (via QMP `screendump`) confirmed: boot → home → Browse Files. + +### Device selection (X3 vs X4) + +X3 and X4 share one ESP32-C3 binary and pick a profile at runtime via an I²C +fingerprint (BQ27220 gauge + DS3231 RTC + QMI8658 IMU on SDA20/SCL0), with an +NVS override (`cphw/dev_ovr`: 1=X4, 2=X3). **The emulator picks X3 vs X4 at +launch** by starting the matching machine (`xteink-x3`, later `xteink-x4`); the +X3 machine will expose the X3 fingerprint so stock dual firmware auto-detects. +No recompiled/custom firmware — the current test build forces X3 only because +the fingerprint I²C devices aren't modelled yet. + +## The interfaces (native now → browser later) + +Each peripheral is modelled behind a QEMU-native seam that the WASM front-end +reuses unchanged — no bespoke abstraction layer: + +| Peripheral | Native seam | Browser binding (phase 3) | +|------------|-------------|---------------------------| +| e-ink panel | `xteink-x3-eink` SSI device → QEMU graphical console (528×792, physical orientation) | `` from the same display surface | +| SD card | `ssi-sd` + `sd-card-spi` backed by QEMU block layer (`-drive if=sd`) | browser-supplied blockdev (File/OPFS) | +| Buttons + battery | `esp32c3.adc` QOM props `adci[1]`/`adci[2]`; power button = GPIO3 input | DOM buttons → `qom-set` equivalent | +| Firmware image | flash via `-drive if=mtd` | uploaded `.bin` written to emulated flash | + +## Phase 1 device models (in `qemu/patches/0001-xteink-x3-machine.patch`) + +- **`esp32c3.adc`** — SAR ADC: oneshot completes immediately; per-channel value + is settable live via QOM (`adci[*]`). Feeds battery and the two resistor-ladder + button groups. Replaced the original always-done register shim. +- **`esp32_gpio`** — real OUT/ENABLE/IN registers with 32 named input + output + lines. Outputs read high while a pin is input-configured (matches the board's + pull-ups; without this, display CS floated low during SD probing). +- **`ssi.esp32c3.spi2`** — the general-purpose SPI2 controller (CPU-buffer + transactions on an SSI bus). DMA path deliberately unimplemented until needed. +- **`xteink-x3-eink`** — UC8253 panel: decodes DTM1/DTM2 planes (52 272 B each), + drives BUSY, renders to a QEMU console. Protocol from `chip/eink-x3.chip.c`. +- **`ssi-sd` fix** — track card idle state so CMD58 reports ready after ACMD41 + (the 9.2 fork hardcoded idle, which SdFat rejects). +- **`xteink-x3` machine** — subclass of `esp32c3` wiring panel CS21/DC4/RST5/ + BUSY6 and SD CS12 onto SPI2, and the SD card onto QEMU's block layer. + +## Remaining phases + +1. **X4 machine** — SSD1677 panel + X4 battery/ADC profile as a sibling machine. + The X3 command trace (`0x18/0x0c/0x44/0x45…`) is already captured. +2. **Compile the patched fork to WASM** — merge Espressif's SoC models with + qemu-wasm's WASM TCG/TCI backend; build `qemu-system-riscv32` with Emscripten. + Output: static `.wasm` + `.js` + packaged BIOS/ROM. +3. **Browser front-end (static)** — upload `.bin` → emulated flash; `` + from the display surface; DOM buttons → ADC/GPIO injection; preloaded SD + image; launch-time X3/X4 selection. Fully airgapped. + +## Repo layout + +| Path | Role | +|------|------| +| `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/` | the xteink X3 machine + device models (applied onto the pinned tag) | +| `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 | + +## Debugging notes + +- Headless capture: run with `-qmp unix:…` and use `scripts/qmp-screendump.py`; + the console is 528×792 (physical portrait), rotate not needed. +- Inject a button: `scripts/qmp-qom.py set /machine/adc "adci[1]" 2760` + (ladder-1 midpoints: Back 3512 / Confirm 2694 / Left 1493 / Right ~5; ladder-2 + Up 2242 / Down ~5). Value > 3900 = no button. +- Symbolize a panic PC: `llvm-addr2line -f -C -e firmware.elf 0x4210d286`. +- Flash must be 16 MB — the partition table spans 16 MB even though the app fits + under 4 MB. Espressif QEMU only accepts 2/4/8/16 MB mtd images. +- Trace SD/panel: flip `DEBUG_SSI_SD` in `hw/sd/ssi-sd.c` or add an + `info_report` in `xteink_x3_eink.c` (both off by default in the patch). diff --git a/chip/Makefile b/chip/Makefile new file mode 100644 index 0000000..46b8984 --- /dev/null +++ b/chip/Makefile @@ -0,0 +1,23 @@ +TARGET = dist/eink-x3.chip.wasm +SOURCES = eink-x3.chip.c + +.PHONY: all clean test +all: $(TARGET) dist/eink-x3.chip.json + +test: dist + $(CC) -O2 -Wall -o dist/test test/test.c + ./dist/test + +dist: + mkdir -p dist + +$(TARGET): dist $(SOURCES) wokwi-api.h + clang --target=wasm32 -nostdlib -O2 -Wall -Werror -Wno-unused-function \ + -Wl,--no-entry -Wl,--import-memory -Wl,--export-table -Wl,--export-dynamic \ + -o $(TARGET) $(SOURCES) + +dist/eink-x3.chip.json: dist eink-x3.chip.json + cp eink-x3.chip.json dist/ + +clean: + rm -rf dist diff --git a/chip/eink-x3.chip.c b/chip/eink-x3.chip.c new file mode 100644 index 0000000..a1d41e4 --- /dev/null +++ b/chip/eink-x3.chip.c @@ -0,0 +1,157 @@ +// xteink X3 e-ink panel model for Wokwi. +// +// The X3 firmware drives a UC8179-style controller (see EInkDisplay.cpp +// setDisplayX3 / displayBuffer). This chip reconstructs the intended image +// from the SPI stream and paints it to a Wokwi framebuffer. +// +// Protocol facts this relies on (from the driver, not a datasheet): +// - SPI mode 0, MSB first. DC low = command byte, DC high = data byte. +// - Pixel planes are streamed after command 0x13 (new frame), 99 bytes/row, +// bottom-to-top (row i on the wire = framebuffer row H-1-i). +// - Bit = 1 -> white, bit = 0 -> black (0xFF = white byte). +// - Full-sync writes the plane inverted and flags it with 0x50 payload 0xA9; +// fast refresh writes it upright with 0x50 payload 0x29. +// - Command 0x12 triggers the refresh; BUSY is active LOW (idle HIGH). +// +// Commands 0x20-0x24 are LUT registers here (not RAM/activation), so they are +// ignored. Only 0x13 (plane), 0x50 (polarity), and 0x12/0x04/0x02 (refresh/ +// power -> BUSY pulse) matter for rendering. + +#include "wokwi-api.h" + +#define WIDTH 792 +#define HEIGHT 528 +#define WIDTH_BYTES (WIDTH / 8) // 99 +#define PLANE_SIZE (WIDTH_BYTES * HEIGHT) // 52272 + +#define CMD_NEW_PLANE 0x13 +#define CMD_POLARITY 0x50 +#define CMD_REFRESH 0x12 +#define CMD_POWER_ON 0x04 +#define CMD_POWER_OFF 0x02 + +#define BUSY_PULSE_US 5000 +#define SPI_CHUNK 4096 + +typedef struct { + pin_t cs, dc, busy; + spi_dev_t spi; + buffer_t fb; + timer_t busy_timer; + uint8_t spi_buf[SPI_CHUNK]; + + bool span_is_command; // this CS-low span began with DC low + uint32_t span_pos; // byte index within the current span + uint8_t cmd; // last command byte + uint32_t plane_pos; // write cursor into plane[] + bool invert; // recovered from the 0x50 polarity selector + uint8_t plane[PLANE_SIZE]; +} chip_state_t; + +static chip_state_t chip; + +static void render(void) { + static uint8_t row_rgba[WIDTH * 4]; + for (uint32_t i = 0; i < HEIGHT; i++) { + const uint32_t y = HEIGHT - 1 - i; // un-mirror: wire is bottom-to-top + const uint8_t *src = &chip.plane[i * WIDTH_BYTES]; + uint32_t o = 0; + for (uint32_t bx = 0; bx < WIDTH_BYTES; bx++) { + uint8_t v = src[bx]; + if (chip.invert) v = (uint8_t)~v; + for (int bit = 0; bit < 8; bit++) { + const uint8_t px = (v & (0x80 >> bit)) ? 0xFF : 0x00; // 1=white + row_rgba[o++] = px; // R + row_rgba[o++] = px; // G + row_rgba[o++] = px; // B + row_rgba[o++] = 0xFF; // A + } + } + buffer_write(chip.fb, y * WIDTH * 4, row_rgba, WIDTH * 4); + } +} + +static void pulse_busy(void) { + pin_write(chip.busy, LOW); + timer_start(chip.busy_timer, BUSY_PULSE_US, false); +} + +static void on_busy_timer(void *user_data) { + (void)user_data; + pin_write(chip.busy, HIGH); +} + +static void handle_command(uint8_t b) { + chip.cmd = b; + chip.span_pos = 1; + if (b == CMD_NEW_PLANE) chip.plane_pos = 0; + if (b == CMD_REFRESH) render(); + if (b == CMD_REFRESH || b == CMD_POWER_ON || b == CMD_POWER_OFF) pulse_busy(); +} + +static void handle_data(uint8_t b) { + const uint32_t data_index = chip.span_pos - (chip.span_is_command ? 1 : 0); + if (chip.cmd == CMD_NEW_PLANE) { + if (chip.plane_pos < PLANE_SIZE) chip.plane[chip.plane_pos++] = b; + } else if (chip.cmd == CMD_POLARITY && data_index == 0) { + chip.invert = (b == 0xA9); + } + chip.span_pos++; +} + +static void on_spi_done(void *user_data, uint8_t *buffer, uint32_t count) { + (void)user_data; + if (count == 0) return; // spi_stop with no data + + if (chip.span_is_command) { + const uint8_t b = buffer[0]; + if (chip.span_pos == 0) + handle_command(b); + else + handle_data(b); + if (pin_read(chip.cs) == LOW) spi_start(chip.spi, chip.spi_buf, 1); + } else { + for (uint32_t i = 0; i < count; i++) handle_data(buffer[i]); + if (pin_read(chip.cs) == LOW) spi_start(chip.spi, chip.spi_buf, SPI_CHUNK); + } +} + +static void on_cs_change(void *user_data, pin_t pin, uint32_t value) { + (void)user_data; + (void)pin; + if (value == LOW) { + chip.span_is_command = (pin_read(chip.dc) == LOW); + chip.span_pos = 0; + spi_start(chip.spi, chip.spi_buf, chip.span_is_command ? 1 : SPI_CHUNK); + } else { + spi_stop(chip.spi); + } +} + +void chip_init(void) { + const pin_t sck = pin_init("SCLK", INPUT); + const pin_t mosi = pin_init("MOSI", INPUT); + chip.cs = pin_init("CS", INPUT_PULLUP); + chip.dc = pin_init("DC", INPUT); + pin_init("RST", INPUT); + chip.busy = pin_init("BUSY", OUTPUT_HIGH); + + uint32_t w = 0, h = 0; + chip.fb = framebuffer_init(&w, &h); + + const spi_config_t spi_cfg = { + .sck = sck, + .mosi = mosi, + .miso = NO_PIN, + .mode = 0, + .done = on_spi_done, + .user_data = &chip, + }; + chip.spi = spi_init(&spi_cfg); + + const timer_config_t timer_cfg = {.callback = on_busy_timer, .user_data = &chip}; + chip.busy_timer = timer_init(&timer_cfg); + + const pin_watch_config_t cs_watch = {.edge = BOTH, .pin_change = on_cs_change, .user_data = &chip}; + pin_watch(chip.cs, &cs_watch); +} diff --git a/chip/eink-x3.chip.json b/chip/eink-x3.chip.json new file mode 100644 index 0000000..7dffc6a --- /dev/null +++ b/chip/eink-x3.chip.json @@ -0,0 +1,10 @@ +{ + "name": "xteink X3 e-ink", + "author": "xteink-web-emulator", + "license": "MIT", + "pins": ["SCLK", "MOSI", "CS", "DC", "RST", "BUSY", "VCC", "GND"], + "display": { + "width": 792, + "height": 528 + } +} diff --git a/chip/test/test.c b/chip/test/test.c new file mode 100644 index 0000000..90f000a --- /dev/null +++ b/chip/test/test.c @@ -0,0 +1,77 @@ +// Host self-check for the plane reconstruction (mirror + invert + bit unpack). +// Drives the chip's command/data handlers with a full-sync stream built the way +// EInkDisplay::displayBuffer emits it, then asserts the framebuffer matches the +// source image. Run: make -C chip test + +#include +#include +#include +#include +#include + +#define W 792 +#define H 528 +#define WB (W / 8) + +static uint8_t g_fb[W * H * 4]; + +// Minimal Wokwi API mocks (chip only calls these at runtime). +typedef int32_t pin_t; +typedef uint32_t spi_dev_t; +typedef uint32_t timer_t; +typedef uint32_t buffer_t; +typedef struct { void *user_data; uint32_t edge; void (*pin_change)(void *, pin_t, uint32_t); } pin_watch_config_t; +typedef struct { void *user_data; pin_t sck, mosi, miso; uint32_t mode; void (*done)(void *, uint8_t *, uint32_t); uint32_t reserved[8]; } spi_config_t; +typedef struct { void *user_data; void (*callback)(void *); uint32_t reserved[8]; } timer_config_t; +enum { LOW = 0, HIGH = 1, INPUT = 0, OUTPUT = 1, INPUT_PULLUP = 2, OUTPUT_HIGH = 17, BOTH = 3 }; +#define NO_PIN ((pin_t)-1) + +static pin_t pin_init(const char *n, uint32_t m) { (void)n; (void)m; return 0; } +static uint32_t pin_read(pin_t p) { (void)p; return HIGH; } +static void pin_write(pin_t p, uint32_t v) { (void)p; (void)v; } +static int pin_watch(pin_t p, const pin_watch_config_t *c) { (void)p; (void)c; return 1; } +static spi_dev_t spi_init(const spi_config_t *c) { (void)c; return 0; } +static void spi_start(spi_dev_t s, uint8_t *b, uint32_t n) { (void)s; (void)b; (void)n; } +static void spi_stop(spi_dev_t s) { (void)s; } +static timer_t timer_init(const timer_config_t *c) { (void)c; return 0; } +static void timer_start(timer_t t, uint32_t us, int r) { (void)t; (void)us; (void)r; } +static buffer_t framebuffer_init(uint32_t *w, uint32_t *h) { *w = W; *h = H; return 1; } +static void buffer_write(buffer_t b, uint32_t off, uint8_t *d, uint32_t n) { (void)b; memcpy(g_fb + off, d, n); } + +#define WOKWI_API_H +#include "../eink-x3.chip.c" + +static void feed_command(uint8_t c) { chip.span_is_command = true; handle_command(c); } +static void feed_data(uint8_t d) { handle_data(d); } + +int main(void) { + chip.fb = framebuffer_init(&(uint32_t){0}, &(uint32_t){0}); + + // Source image: white, with a few black pixels at known coordinates. + static uint8_t src[WB * H]; + memset(src, 0xFF, sizeof(src)); + const int bx[] = {16, 0, 791, 400}; + const int by[] = {3, 0, 527, 260}; + for (int k = 0; k < 4; k++) + src[by[k] * WB + bx[k] / 8] &= (uint8_t)~(0x80 >> (bx[k] % 8)); + + // Full-sync stream: 0x13 + inverted, bottom-to-top plane; polarity 0xA9; 0x12. + feed_command(0x13); + for (int i = 0; i < H; i++) { + const int srcY = H - 1 - i; + for (int x = 0; x < WB; x++) feed_data((uint8_t)~src[srcY * WB + x]); + } + feed_command(0x50); + feed_data(0xA9); + feed_data(0x07); + feed_command(0x12); + + for (int y = 0; y < H; y++) + for (int x = 0; x < W; x++) { + const int black = !(src[y * WB + x / 8] & (0x80 >> (x % 8))); + const uint8_t got = g_fb[(y * W + x) * 4]; + assert(got == (black ? 0x00 : 0xFF)); + } + printf("ok: %dx%d reconstructed, mirror+invert correct\n", W, H); + return 0; +} diff --git a/chip/wokwi-api.h b/chip/wokwi-api.h new file mode 100644 index 0000000..0b9560c --- /dev/null +++ b/chip/wokwi-api.h @@ -0,0 +1,138 @@ +#include +#include + +#ifndef WOKWI_API_H +#define WOKWI_API_H + +enum pin_value { + LOW = 0, + HIGH = 1 +}; + +enum pin_mode { + INPUT = 0, + OUTPUT = 1, + INPUT_PULLUP = 2, + INPUT_PULLDOWN = 3, + ANALOG = 4, + + OUTPUT_LOW = 16, + OUTPUT_HIGH = 17, +}; + +enum edge { + RISING = 1, + FALLING = 2, + BOTH = 3, +}; + +int __attribute__((export_name("__wokwi_api_version_1"))) __attribute__((weak)) __wokwi_api_version_1(void) { return 1; } + +#ifdef __cplusplus +extern "C" { +#endif + +typedef int32_t pin_t; +#define NO_PIN ((pin_t)-1) + +typedef struct { + void *user_data; + uint32_t edge; + void (*pin_change)(void *user_data, pin_t pin, uint32_t value); +} pin_watch_config_t; + +extern __attribute__((export_name("chipInit"))) void chip_init(void); + +extern __attribute__((import_name("pinInit"))) pin_t pin_init(const char *name, uint32_t mode); + +extern __attribute__((import_name("pinRead"))) uint32_t pin_read(pin_t pin); +extern __attribute__((import_name("pinWrite"))) void pin_write(pin_t pin, uint32_t value); +extern __attribute__((import_name("pinWatch"))) bool pin_watch(pin_t pin, const pin_watch_config_t *config); +extern __attribute__((import_name("pinWatchStop"))) void pin_watch_stop(pin_t pin); +extern __attribute__((import_name("pinMode"))) void pin_mode(pin_t pin, uint32_t value); +extern __attribute__((import_name("pinADCRead"))) float pin_adc_read(pin_t pin); +extern __attribute__((import_name("pinDACWrite"))) float pin_dac_write(pin_t pin, float voltage); + +extern __attribute__((import_name("attrInit"))) uint32_t attr_init(const char *name, uint32_t default_value); +extern __attribute__((import_name("attrInit"))) uint32_t attr_init_float(const char *name, float default_value); +extern __attribute__((import_name("attrRead"))) uint32_t attr_read(uint32_t attr_id); +extern __attribute__((import_name("attrReadFloat"))) float attr_read_float(uint32_t attr_id); + +typedef struct { + void *user_data; + uint32_t address; + pin_t scl; + pin_t sda; + bool (*connect)(void *user_data, uint32_t address, bool connect); + uint8_t (*read)(void *user_data); + bool (*write)(void *user_data, uint8_t data); + void (*disconnect)(void *user_data); + uint32_t reserved[8]; +} i2c_config_t; + +typedef uint32_t i2c_dev_t; + +extern __attribute__((import_name("i2cInit"))) i2c_dev_t i2c_init(const i2c_config_t *config); + +typedef struct { + void *user_data; + pin_t rx; + pin_t tx; + uint32_t baud_rate; + void (*rx_data)(void *user_data, uint8_t byte); + void (*write_done)(void *user_data); + uint32_t reserved[8]; +} uart_config_t; + +typedef uint32_t uart_dev_t; + +extern __attribute__((import_name("uartInit"))) uart_dev_t uart_init(const uart_config_t *config); +extern __attribute__((import_name("uartWrite"))) bool uart_write(uart_dev_t uart, uint8_t *buffer, uint32_t count); + +typedef struct { + void *user_data; + pin_t sck; + pin_t mosi; + pin_t miso; + uint32_t mode; + void (*done)(void *user_data, uint8_t *buffer, uint32_t count); + uint32_t reserved[8]; +} spi_config_t; +typedef uint32_t spi_dev_t; + +extern __attribute__((import_name("spiInit"))) spi_dev_t spi_init(const spi_config_t *spi_config); +extern __attribute__((import_name("spiStart"))) void spi_start(const spi_dev_t spi, uint8_t *buffer, uint32_t count); +extern __attribute__((import_name("spiStop"))) void spi_stop(const spi_dev_t spi); + +typedef struct { + void *user_data; + void (*callback)(void *user_data); + uint32_t reserved[8]; +} timer_config_t; + +typedef uint32_t timer_t; + +extern __attribute__((import_name("timerInit"))) timer_t timer_init(const timer_config_t *config); +extern __attribute__((import_name("timerStart"))) void timer_start(const timer_t timer, uint32_t micros, bool repeat); +extern __attribute__((import_name("timerStartNanos"))) void timer_start_ns_d(const timer_t timer, double nanos, bool repeat); +static void timer_start_ns(const timer_t timer, uint64_t nanos, bool repeat) { + timer_start_ns_d(timer, (double)nanos, repeat); +} +extern __attribute__((import_name("timerStop"))) void timer_stop(const timer_t timer); + +extern __attribute__((import_name("getSimNanos"))) double get_sim_nanos_d(void); + +static uint64_t get_sim_nanos(void) { + return (uint64_t)get_sim_nanos_d(); +} + +typedef uint32_t buffer_t; +extern __attribute__((import_name("framebufferInit"))) buffer_t framebuffer_init(uint32_t *pixel_width, uint32_t *pixel_height); +extern __attribute__((import_name("bufferRead"))) void buffer_read(buffer_t buffer, uint32_t offset, uint8_t *data, uint32_t data_len); +extern __attribute__((import_name("bufferWrite"))) void buffer_write(buffer_t buffer, uint32_t offset, uint8_t *data, uint32_t data_len); + +#ifdef __cplusplus +} +#endif + +#endif /* WOKWI_API_H */ diff --git a/diagram.json b/diagram.json new file mode 100644 index 0000000..f928aa9 --- /dev/null +++ b/diagram.json @@ -0,0 +1,20 @@ +{ + "version": 1, + "author": "xteink-web-emulator", + "editor": "wokwi", + "parts": [ + { "type": "board-esp32-c3-devkitm-1", "id": "esp", "top": 0, "left": 0, "attrs": { "flashSize": "16" } }, + { "type": "chip-eink-x3", "id": "disp", "top": -140, "left": 200, "attrs": {} } + ], + "connections": [ + ["esp:8", "disp:SCLK", "green", []], + ["esp:10", "disp:MOSI", "blue", []], + ["esp:TX", "disp:CS", "orange", []], + ["esp:4", "disp:DC", "purple", []], + ["esp:5", "disp:RST", "gray", []], + ["esp:6", "disp:BUSY", "yellow", []], + ["esp:3V3.1", "disp:VCC", "red", []], + ["esp:GND.1", "disp:GND", "black", []] + ], + "dependencies": {} +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..03827ea --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1784356753, + "narHash": "sha256-12KrbMiWLcf8m7pCvAtZh1ZrgF85ZXDXvfR/fWTKy84=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "61b7c44c4073f0b827768aff0049561b5110ea5a", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..fcc54aa --- /dev/null +++ b/flake.nix @@ -0,0 +1,75 @@ +{ + description = "Browser emulator for the xteink X3 (ESP32-C3) — QEMU-based, runs any firmware .bin"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = + { self + , nixpkgs + , flake-utils + , + }: + flake-utils.lib.eachDefaultSystem ( + system: + let + pkgs = nixpkgs.legacyPackages.${system}; + + # Espressif's QEMU fork (ESP32-C3 SoC). Prebuilt generic-linux binary, + # made runnable on NixOS with autoPatchelfHook (no FHS/bwrap needed). + qemu-esp32c3 = pkgs.stdenv.mkDerivation { + pname = "qemu-esp32c3"; + version = "esp-develop-9.2.2-20260417"; + src = pkgs.fetchurl { + url = "https://github.com/espressif/qemu/releases/download/esp-develop-9.2.2-20260417/qemu-riscv32-softmmu-esp_develop_9.2.2_20260417-x86_64-linux-gnu.tar.xz"; + hash = "sha256-VH8D4EcBqSy7aZ9/fQFa3B9bXvk8u5TA3ZtxB+LYTnc="; + }; + sourceRoot = "qemu"; + nativeBuildInputs = [ pkgs.autoPatchelfHook ]; + buildInputs = with pkgs; [ pixman libgcrypt SDL2 zlib libslirp stdenv.cc.cc.lib ]; + installPhase = '' + mkdir -p $out + cp -r bin share $out/ + ''; + }; + + # `nix run .#run-upstream -- flash.bin [extra qemu args]` + runScript = pkgs.writeShellScriptBin "xteink-qemu-upstream" '' + flash="''${1:?usage: run [qemu args...]}"; shift || true + exec ${qemu-esp32c3}/bin/qemu-system-riscv32 \ + -machine esp32c3 -L ${qemu-esp32c3}/share/qemu \ + -nographic -drive file="$flash",if=mtd,format=raw "$@" + ''; + in + { + packages.qemu-esp32c3 = qemu-esp32c3; + packages.default = qemu-esp32c3; + + apps.run-upstream = { + type = "app"; + program = "${runScript}/bin/xteink-qemu-upstream"; + }; + + devShells.default = pkgs.mkShell { + # Reuse nixpkgs' complete QEMU build environment; Espressif's crypto + # devices additionally require libgcrypt. + inputsFrom = [ pkgs.qemu ]; + packages = with pkgs; [ + qemu-esp32c3 + esptool + gnumake + git + libgcrypt + libslirp + dosfstools + mtools + llvmPackages.clang-unwrapped + lld + gcc + ]; + }; + } + ); +} diff --git a/qemu/patches/0001-xteink-x3-machine.patch b/qemu/patches/0001-xteink-x3-machine.patch new file mode 100644 index 0000000..65af824 --- /dev/null +++ b/qemu/patches/0001-xteink-x3-machine.patch @@ -0,0 +1,868 @@ +diff --git i/hw/adc/esp32c3_adc.c w/hw/adc/esp32c3_adc.c +new file mode 100644 +index 0000000..8908742 +--- /dev/null ++++ w/hw/adc/esp32c3_adc.c +@@ -0,0 +1,101 @@ ++/* ++ * ESP32-C3 SAR ADC ++ * ++ * SPDX-License-Identifier: GPL-2.0-or-later ++ */ ++ ++#include "qemu/osdep.h" ++#include "qemu/module.h" ++#include "hw/adc/esp32c3_adc.h" ++ ++#define ADC_ONETIME_SAMPLE 0x020 ++#define ADC_DATA1 0x02c ++#define ADC_DATA2 0x030 ++#define ADC_INT_RAW 0x044 ++ ++#define ADC_CHANNEL_SHIFT 25 ++#define ADC_CHANNEL_MASK 0xf ++#define ADC_DONE (BIT(31) | BIT(30)) ++#define ADC_MAX 0xfff ++ ++static uint64_t esp32c3_adc_read(void *opaque, hwaddr addr, unsigned size) ++{ ++ ESP32C3AdcState *s = ESP32C3_ADC(opaque); ++ ++ if (addr == ADC_INT_RAW) { ++ return ADC_DONE; ++ } ++ if (addr == ADC_DATA1 || addr == ADC_DATA2) { ++ return s->channel < ESP32C3_ADC_CHANNELS ++ ? MIN(s->input[s->channel], ADC_MAX) ++ : ADC_MAX; ++ } ++ return s->regs[addr / 4]; ++} ++ ++static void esp32c3_adc_write(void *opaque, hwaddr addr, uint64_t value, ++ unsigned size) ++{ ++ ESP32C3AdcState *s = ESP32C3_ADC(opaque); ++ s->regs[addr / 4] = value; ++ ++ if (addr == ADC_ONETIME_SAMPLE) { ++ s->channel = (value >> ADC_CHANNEL_SHIFT) & ADC_CHANNEL_MASK; ++ } ++} ++ ++static const MemoryRegionOps esp32c3_adc_ops = { ++ .read = esp32c3_adc_read, ++ .write = esp32c3_adc_write, ++ .endianness = DEVICE_LITTLE_ENDIAN, ++ .valid = { ++ .min_access_size = 4, ++ .max_access_size = 4, ++ }, ++}; ++ ++static void esp32c3_adc_reset_hold(Object *obj, ResetType type) ++{ ++ ESP32C3AdcState *s = ESP32C3_ADC(obj); ++ memset(s->regs, 0, sizeof(s->regs)); ++ for (int i = 0; i < ESP32C3_ADC_CHANNELS; i++) { ++ s->input[i] = ADC_MAX; ++ } ++ s->channel = 0; ++} ++ ++static void esp32c3_adc_init(Object *obj) ++{ ++ ESP32C3AdcState *s = ESP32C3_ADC(obj); ++ SysBusDevice *sbd = SYS_BUS_DEVICE(obj); ++ ++ memory_region_init_io(&s->iomem, obj, &esp32c3_adc_ops, s, ++ TYPE_ESP32C3_ADC, ESP32C3_ADC_IO_SIZE); ++ sysbus_init_mmio(sbd, &s->iomem); ++ for (int i = 0; i < ESP32C3_ADC_CHANNELS; i++) { ++ object_property_add_uint32_ptr(obj, "adci[*]", &s->input[i], ++ OBJ_PROP_FLAG_READWRITE); ++ } ++ esp32c3_adc_reset_hold(obj, RESET_TYPE_COLD); ++} ++ ++static void esp32c3_adc_class_init(ObjectClass *klass, void *data) ++{ ++ ResettableClass *rc = RESETTABLE_CLASS(klass); ++ rc->phases.hold = esp32c3_adc_reset_hold; ++} ++ ++static const TypeInfo esp32c3_adc_info = { ++ .name = TYPE_ESP32C3_ADC, ++ .parent = TYPE_SYS_BUS_DEVICE, ++ .instance_size = sizeof(ESP32C3AdcState), ++ .instance_init = esp32c3_adc_init, ++ .class_init = esp32c3_adc_class_init, ++}; ++ ++static void esp32c3_adc_register_types(void) ++{ ++ type_register_static(&esp32c3_adc_info); ++} ++ ++type_init(esp32c3_adc_register_types) +diff --git i/hw/adc/meson.build w/hw/adc/meson.build +index 7f7acc1..541d5d3 100644 +--- i/hw/adc/meson.build ++++ w/hw/adc/meson.build +@@ -1,4 +1,5 @@ + system_ss.add(when: 'CONFIG_STM32F2XX_ADC', if_true: files('stm32f2xx_adc.c')) ++system_ss.add(when: 'CONFIG_RISCV_ESP32C3', if_true: files('esp32c3_adc.c')) + system_ss.add(when: 'CONFIG_ASPEED_SOC', if_true: files('aspeed_adc.c')) + system_ss.add(when: 'CONFIG_NPCM7XX', if_true: files('npcm7xx_adc.c')) + system_ss.add(when: 'CONFIG_ZYNQ', if_true: files('zynq-xadc.c')) +diff --git i/hw/display/meson.build w/hw/display/meson.build +index 36a9ace..c73f313 100644 +--- i/hw/display/meson.build ++++ w/hw/display/meson.build +@@ -32,6 +32,7 @@ system_ss.add(when: 'CONFIG_CG3', if_true: files('cg3.c')) + system_ss.add(when: 'CONFIG_MACFB', if_true: files('macfb.c')) + system_ss.add(when: 'CONFIG_NEXTCUBE', if_true: files('next-fb.c')) + system_ss.add(when: 'CONFIG_ESP_RGB', if_true: files('esp_rgb.c')) ++system_ss.add(when: 'CONFIG_RISCV_ESP32C3', if_true: files('xteink_x3_eink.c')) + + system_ss.add(when: 'CONFIG_VGA', if_true: files('vga.c')) + system_ss.add(when: 'CONFIG_VIRTIO', if_true: files('virtio-dmabuf.c')) +diff --git i/hw/display/xteink_x3_eink.c w/hw/display/xteink_x3_eink.c +new file mode 100644 +index 0000000..bc9a18c +--- /dev/null ++++ w/hw/display/xteink_x3_eink.c +@@ -0,0 +1,165 @@ ++/* ++ * xteink X3 UC8253 e-ink panel ++ * ++ * SPDX-License-Identifier: GPL-2.0-or-later ++ */ ++ ++#include "qemu/osdep.h" ++#include "qemu/module.h" ++#include "hw/irq.h" ++#include "hw/display/xteink_x3_eink.h" ++ ++#define CMD_POWER_OFF 0x02 ++#define CMD_POWER_ON 0x04 ++#define CMD_DTM1 0x10 ++#define CMD_REFRESH 0x12 ++#define CMD_DTM2 0x13 ++ ++#define BUSY_TIME_NS (5 * SCALE_MS) ++ ++static void xteink_x3_eink_render(XteinkX3EinkState *s) ++{ ++ DisplaySurface *surface = qemu_console_surface(s->console); ++ uint32_t *pixels = (uint32_t *)surface_data(surface); ++ ++ for (unsigned y = 0; y < XTEINK_X3_HEIGHT; y++) { ++ const uint8_t *row = &s->new_plane[(XTEINK_X3_HEIGHT - 1 - y) * ++ XTEINK_X3_WIDTH_BYTES]; ++ for (unsigned x = 0; x < XTEINK_X3_WIDTH; x++) { ++ unsigned screen_x = XTEINK_X3_HEIGHT - 1 - y; ++ unsigned screen_y = x; ++ pixels[screen_y * XTEINK_X3_HEIGHT + screen_x] = ++ (row[x / 8] & (0x80 >> (x % 8))) ? 0x00ffffff : 0; ++ } ++ } ++ ++ dpy_gfx_update(s->console, 0, 0, XTEINK_X3_HEIGHT, XTEINK_X3_WIDTH); ++} ++ ++static void xteink_x3_eink_busy_done(void *opaque) ++{ ++ XteinkX3EinkState *s = XTEINK_X3_EINK(opaque); ++ qemu_set_irq(s->busy, 1); ++} ++ ++static void xteink_x3_eink_pulse_busy(XteinkX3EinkState *s) ++{ ++ qemu_set_irq(s->busy, 0); ++ timer_mod(s->busy_timer, ++ qemu_clock_get_ns(QEMU_CLOCK_VIRTUAL) + BUSY_TIME_NS); ++} ++ ++static void xteink_x3_eink_reset(DeviceState *dev) ++{ ++ XteinkX3EinkState *s = XTEINK_X3_EINK(dev); ++ ++ s->command = 0; ++ s->plane_pos = 0; ++ memset(s->old_plane, 0xff, sizeof(s->old_plane)); ++ memset(s->new_plane, 0xff, sizeof(s->new_plane)); ++ qemu_set_irq(s->busy, 1); ++ xteink_x3_eink_render(s); ++} ++ ++static void xteink_x3_eink_set_dc(void *opaque, int n, int level) ++{ ++ XteinkX3EinkState *s = XTEINK_X3_EINK(opaque); ++ s->dc = level; ++} ++ ++static void xteink_x3_eink_set_reset(void *opaque, int n, int level) ++{ ++ if (!level) { ++ xteink_x3_eink_reset(DEVICE(opaque)); ++ } ++} ++ ++static uint32_t xteink_x3_eink_transfer(SSIPeripheral *peripheral, ++ uint32_t value) ++{ ++ XteinkX3EinkState *s = XTEINK_X3_EINK(peripheral); ++ uint8_t byte = value; ++ ++ if (!s->dc) { ++ s->command = byte; ++ if (byte == CMD_DTM1 || byte == CMD_DTM2) { ++ s->plane_pos = 0; ++ } else if (byte == CMD_REFRESH) { ++ xteink_x3_eink_render(s); ++ xteink_x3_eink_pulse_busy(s); ++ } else if (byte == CMD_POWER_ON || byte == CMD_POWER_OFF) { ++ xteink_x3_eink_pulse_busy(s); ++ } ++ } else if (s->plane_pos < XTEINK_X3_PLANE_SIZE) { ++ if (s->command == CMD_DTM1) { ++ s->old_plane[s->plane_pos++] = byte; ++ } else if (s->command == CMD_DTM2) { ++ s->new_plane[s->plane_pos++] = byte; ++ } ++ } ++ ++ return 0xff; ++} ++ ++static void xteink_x3_eink_invalidate(void *opaque) ++{ ++ xteink_x3_eink_render(XTEINK_X3_EINK(opaque)); ++} ++ ++static void xteink_x3_eink_update(void *opaque) ++{ ++} ++ ++static const GraphicHwOps xteink_x3_eink_graphics_ops = { ++ .invalidate = xteink_x3_eink_invalidate, ++ .gfx_update = xteink_x3_eink_update, ++}; ++ ++static void xteink_x3_eink_init(Object *obj) ++{ ++ XteinkX3EinkState *s = XTEINK_X3_EINK(obj); ++ ++ s->console = graphic_console_init(DEVICE(s), 0, ++ &xteink_x3_eink_graphics_ops, s); ++ dpy_gfx_replace_surface(s->console, ++ qemu_create_displaysurface(XTEINK_X3_HEIGHT, XTEINK_X3_WIDTH)); ++ s->busy_timer = timer_new_ns(QEMU_CLOCK_VIRTUAL, ++ xteink_x3_eink_busy_done, s); ++ qdev_init_gpio_in_named(DEVICE(s), xteink_x3_eink_set_dc, ++ XTEINK_X3_EINK_DC, 1); ++ qdev_init_gpio_in_named(DEVICE(s), xteink_x3_eink_set_reset, ++ XTEINK_X3_EINK_RESET, 1); ++ qdev_init_gpio_out_named(DEVICE(s), &s->busy, ++ XTEINK_X3_EINK_BUSY, 1); ++} ++ ++static void xteink_x3_eink_realize(SSIPeripheral *peripheral, Error **errp) ++{ ++} ++ ++static void xteink_x3_eink_class_init(ObjectClass *klass, void *data) ++{ ++ DeviceClass *dc = DEVICE_CLASS(klass); ++ SSIPeripheralClass *ssi = SSI_PERIPHERAL_CLASS(klass); ++ ++ ssi->realize = xteink_x3_eink_realize; ++ ssi->transfer = xteink_x3_eink_transfer; ++ ssi->cs_polarity = SSI_CS_LOW; ++ device_class_set_legacy_reset(dc, xteink_x3_eink_reset); ++ dc->user_creatable = false; ++} ++ ++static const TypeInfo xteink_x3_eink_info = { ++ .name = TYPE_XTEINK_X3_EINK, ++ .parent = TYPE_SSI_PERIPHERAL, ++ .instance_size = sizeof(XteinkX3EinkState), ++ .instance_init = xteink_x3_eink_init, ++ .class_init = xteink_x3_eink_class_init, ++}; ++ ++static void xteink_x3_eink_register_types(void) ++{ ++ type_register_static(&xteink_x3_eink_info); ++} ++ ++type_init(xteink_x3_eink_register_types) +diff --git i/hw/gpio/esp32_gpio.c w/hw/gpio/esp32_gpio.c +index 4fea1f5..55cac08 100644 +--- i/hw/gpio/esp32_gpio.c ++++ w/hw/gpio/esp32_gpio.c +@@ -19,26 +19,77 @@ + #include "hw/qdev-properties.h" + #include "hw/gpio/esp32_gpio.h" + ++#define GPIO_OUT 0x04 ++#define GPIO_OUT_W1TS 0x08 ++#define GPIO_OUT_W1TC 0x0c ++#define GPIO_ENABLE 0x20 ++#define GPIO_ENABLE_W1TS 0x24 ++#define GPIO_ENABLE_W1TC 0x28 ++#define GPIO_IN 0x3c + ++static void esp32_gpio_drive_outputs(Esp32GpioState *s) ++{ ++ for (int i = 0; i < ESP32_GPIO_COUNT; i++) { ++ int level = (s->output_enable & BIT(i)) ++ ? !!(s->output_level & BIT(i)) ++ : 1; ++ qemu_set_irq(s->output_lines[i], level); ++ } ++} ++ ++static void esp32_gpio_set_input(void *opaque, int pin, int level) ++{ ++ Esp32GpioState *s = ESP32_GPIO(opaque); ++ s->input_level = deposit32(s->input_level, pin, 1, !!level); ++} + + static uint64_t esp32_gpio_read(void *opaque, hwaddr addr, unsigned int size) + { + Esp32GpioState *s = ESP32_GPIO(opaque); +- uint64_t r = 0; +- switch (addr) { +- case A_GPIO_STRAP: +- r = s->strap_mode; +- break; + ++ switch (addr) { ++ case GPIO_OUT: ++ return s->output_level; ++ case GPIO_ENABLE: ++ return s->output_enable; ++ case A_GPIO_STRAP: ++ return s->strap_mode; ++ case GPIO_IN: ++ return s->input_level; + default: +- break; ++ return 0; + } +- return r; + } + + static void esp32_gpio_write(void *opaque, hwaddr addr, +- uint64_t value, unsigned int size) ++ uint64_t value, unsigned int size) + { ++ Esp32GpioState *s = ESP32_GPIO(opaque); ++ ++ switch (addr) { ++ case GPIO_OUT: ++ s->output_level = value; ++ break; ++ case GPIO_OUT_W1TS: ++ s->output_level |= value; ++ break; ++ case GPIO_OUT_W1TC: ++ s->output_level &= ~value; ++ break; ++ case GPIO_ENABLE: ++ s->output_enable = value; ++ break; ++ case GPIO_ENABLE_W1TS: ++ s->output_enable |= value; ++ break; ++ case GPIO_ENABLE_W1TC: ++ s->output_enable &= ~value; ++ break; ++ default: ++ return; ++ } ++ ++ esp32_gpio_drive_outputs(s); + } + + static const MemoryRegionOps uart_ops = { +@@ -49,6 +100,11 @@ static const MemoryRegionOps uart_ops = { + + static void esp32_gpio_reset_hold(Object *obj, ResetType type) + { ++ Esp32GpioState *s = ESP32_GPIO(obj); ++ s->input_level = UINT32_MAX; ++ s->output_level = 0; ++ s->output_enable = 0; ++ esp32_gpio_drive_outputs(s); + } + + static void esp32_gpio_realize(DeviceState *dev, Error **errp) +@@ -67,6 +123,12 @@ static void esp32_gpio_init(Object *obj) + TYPE_ESP32_GPIO, 0x1000); + sysbus_init_mmio(sbd, &s->iomem); + sysbus_init_irq(sbd, &s->irq); ++ qdev_init_gpio_in_named(DEVICE(obj), esp32_gpio_set_input, ++ ESP32_GPIO_INPUT, ESP32_GPIO_COUNT); ++ qdev_init_gpio_out_named(DEVICE(obj), s->output_lines, ++ ESP32_GPIO_OUTPUT, ESP32_GPIO_COUNT); ++ ++ esp32_gpio_reset_hold(obj, RESET_TYPE_COLD); + } + + static Property esp32_gpio_properties[] = { +diff --git i/hw/riscv/esp32c3.c w/hw/riscv/esp32c3.c +index ed698fb..a49eae6 100644 +--- i/hw/riscv/esp32c3.c ++++ w/hw/riscv/esp32c3.c +@@ -22,6 +22,7 @@ + #include "hw/riscv/boot.h" + #include "hw/riscv/numa.h" + #include "sysemu/device_tree.h" ++#include "sysemu/blockdev.h" + #include "sysemu/sysemu.h" + #include "sysemu/kvm.h" + #include "sysemu/runstate.h" +@@ -40,6 +41,8 @@ + #include "hw/timer/esp32c3_timg.h" + #include "hw/timer/esp32c3_systimer.h" + #include "hw/ssi/esp32c3_spi.h" ++#include "hw/ssi/esp32c3_spi2.h" ++#include "hw/adc/esp32c3_adc.h" + #include "hw/misc/esp32c3_rtc_cntl.h" + #include "hw/misc/esp32c3_aes.h" + #include "hw/misc/esp32c3_rsa.h" +@@ -49,6 +52,8 @@ + #include "hw/misc/esp32c3_jtag.h" + #include "hw/dma/esp32c3_gdma.h" + #include "hw/display/esp_rgb.h" ++#include "hw/display/xteink_x3_eink.h" ++#include "hw/sd/sd.h" + #include "hw/net/can/esp32c3_twai.h" + + #define ESP32C3_IO_WARNING 0 +@@ -66,6 +71,7 @@ struct Esp32C3MachineState { + EspRISCVCPU soc; + BusState periph_bus; + MemoryRegion iomem; ++ bool xteink_x3; + + qemu_irq cpu_reset; + +@@ -86,6 +92,8 @@ struct Esp32C3MachineState { + ESP32C3TimgState timg[2]; + ESP32C3SysTimerState systimer; + ESP32C3SpiState spi1; ++ ESP32C3Spi2State spi2; ++ ESP32C3AdcState adc; + ESP32C3RtcCntlState rtccntl; + ESP32C3UsbJtagState jtag; + ESPRgbState rgb; +@@ -420,9 +428,13 @@ static void esp32c3_machine_init(MachineState *machine) + object_initialize_child(OBJECT(machine), "timg1", &ms->timg[1], TYPE_ESP32C3_TIMG); + object_initialize_child(OBJECT(machine), "systimer", &ms->systimer, TYPE_ESP32C3_SYSTIMER); + object_initialize_child(OBJECT(machine), "spi1", &ms->spi1, TYPE_ESP32C3_SPI); ++ object_initialize_child(OBJECT(machine), "spi2", &ms->spi2, TYPE_ESP32C3_SPI2); ++ object_initialize_child(OBJECT(machine), "adc", &ms->adc, TYPE_ESP32C3_ADC); + object_initialize_child(OBJECT(machine), "rtccntl", &ms->rtccntl, TYPE_ESP32C3_RTC_CNTL); + object_initialize_child(OBJECT(machine), "jtag", &ms->jtag, TYPE_ESP32C3_JTAG); +- object_initialize_child(OBJECT(machine), "rgb", &ms->rgb, TYPE_ESP_RGB); ++ if (!ms->xteink_x3) { ++ object_initialize_child(OBJECT(machine), "rgb", &ms->rgb, TYPE_ESP_RGB); ++ } + object_initialize_child(OBJECT(machine), "twai", &ms->twai, TYPE_ESP32C3_TWAI); + + /* Realize all the I/O peripherals we depend on */ +@@ -476,6 +488,20 @@ static void esp32c3_machine_init(MachineState *machine) + } + } + ++ /* SPI2 controller (display and SD card) */ ++ { ++ sysbus_realize(SYS_BUS_DEVICE(&ms->spi2), &error_fatal); ++ MemoryRegion *mr = sysbus_mmio_get_region(SYS_BUS_DEVICE(&ms->spi2), 0); ++ memory_region_add_subregion_overlap(sys_mem, DR_REG_SPI2_BASE, mr, 0); ++ } ++ ++ /* SAR ADC (battery and resistor-ladder buttons) */ ++ { ++ sysbus_realize(SYS_BUS_DEVICE(&ms->adc), &error_fatal); ++ MemoryRegion *mr = sysbus_mmio_get_region(SYS_BUS_DEVICE(&ms->adc), 0); ++ memory_region_add_subregion_overlap(sys_mem, DR_REG_APB_SARADC_BASE, mr, 0); ++ } ++ + for (int i = 0; i < ESP32C3_UART_COUNT; ++i) { + const hwaddr uart_base[] = { DR_REG_UART_BASE, DR_REG_UART1_BASE }; + sysbus_realize(SYS_BUS_DEVICE(&ms->uart[i]), &error_fatal); +@@ -641,7 +667,7 @@ static void esp32c3_machine_init(MachineState *machine) + } + + /* RGB display realization */ +- { ++ if (!ms->xteink_x3) { + /* Give the internal RAM memory region to the display */ + ms->rgb.intram = dram; + sysbus_realize(SYS_BUS_DEVICE(&ms->rgb), &error_fatal); +@@ -659,6 +685,42 @@ static void esp32c3_machine_init(MachineState *machine) + } + + ++static void xteink_x3_machine_init(MachineState *machine) ++{ ++ Esp32C3MachineState *ms = ESP32C3_MACHINE(machine); ++ ms->xteink_x3 = true; ++ esp32c3_machine_init(machine); ++ ++ DeviceState *panel = ssi_create_peripheral(ms->spi2.bus, ++ TYPE_XTEINK_X3_EINK); ++ qdev_connect_gpio_out_named(DEVICE(&ms->gpio), ESP32_GPIO_OUTPUT, 21, ++ qdev_get_gpio_in_named(panel, SSI_GPIO_CS, 0)); ++ qdev_connect_gpio_out_named(DEVICE(&ms->gpio), ESP32_GPIO_OUTPUT, 4, ++ qdev_get_gpio_in_named(panel, XTEINK_X3_EINK_DC, 0)); ++ qdev_connect_gpio_out_named(DEVICE(&ms->gpio), ESP32_GPIO_OUTPUT, 5, ++ qdev_get_gpio_in_named(panel, XTEINK_X3_EINK_RESET, 0)); ++ qdev_connect_gpio_out_named(panel, XTEINK_X3_EINK_BUSY, 0, ++ qdev_get_gpio_in_named(DEVICE(&ms->gpio), ESP32_GPIO_INPUT, 6)); ++ device_cold_reset(panel); ++ qemu_set_irq(qdev_get_gpio_in_named(panel, SSI_GPIO_CS, 0), 1); ++ ++ DeviceState *sd_adapter = qdev_new("ssi-sd"); ++ qdev_prop_set_uint8(sd_adapter, "cs", 1); ++ qdev_realize_and_unref(sd_adapter, BUS(ms->spi2.bus), &error_fatal); ++ qdev_connect_gpio_out_named(DEVICE(&ms->gpio), ESP32_GPIO_OUTPUT, 12, ++ qdev_get_gpio_in_named(sd_adapter, SSI_GPIO_CS, 0)); ++ qemu_set_irq(qdev_get_gpio_in_named(sd_adapter, SSI_GPIO_CS, 0), 1); ++ ++ DriveInfo *dinfo = drive_get(IF_SD, 0, 0); ++ DeviceState *sd_card = qdev_new(TYPE_SD_CARD_SPI); ++ qdev_prop_set_drive_err(sd_card, "drive", ++ dinfo ? blk_by_legacy_dinfo(dinfo) : NULL, ++ &error_fatal); ++ qdev_realize_and_unref(sd_card, ++ qdev_get_child_bus(sd_adapter, "sd-bus"), ++ &error_fatal); ++} ++ + /* Initialize machine type */ + static void esp32c3_machine_class_init(ObjectClass *oc, void *data) + { +@@ -683,9 +745,23 @@ static const TypeInfo esp32c3_info = { + .class_init = esp32c3_machine_class_init, + }; + ++static void xteink_x3_machine_class_init(ObjectClass *oc, void *data) ++{ ++ MachineClass *mc = MACHINE_CLASS(oc); ++ mc->desc = "xteink X3 (ESP32-C3, UC8253 e-ink)"; ++ mc->init = xteink_x3_machine_init; ++} ++ ++static const TypeInfo xteink_x3_info = { ++ .name = MACHINE_TYPE_NAME("xteink-x3"), ++ .parent = TYPE_ESP32C3_MACHINE, ++ .class_init = xteink_x3_machine_class_init, ++}; ++ + static void esp32c3_machine_type_init(void) + { + type_register_static(&esp32c3_info); ++ type_register_static(&xteink_x3_info); + } + + type_init(esp32c3_machine_type_init); +diff --git i/hw/sd/ssi-sd.c w/hw/sd/ssi-sd.c +index 1594051..552dc2c 100644 +--- i/hw/sd/ssi-sd.c ++++ w/hw/sd/ssi-sd.c +@@ -64,6 +64,7 @@ struct ssi_sd_state { + int32_t arglen; + int32_t response_pos; + int32_t stopping; ++ bool idle; + SDBus sdbus; + }; + +@@ -180,7 +181,7 @@ static uint32_t ssi_sd_transfer(SSIPeripheral *dev, uint32_t val) + /* CMD8/CMD58 returns R3/R7 response */ + DPRINTF("Returned R3/R7\n"); + s->arglen = 5; +- s->response[0] = 1; ++ s->response[0] = s->cmd == 58 ? s->idle : 1; + memcpy(&s->response[1], longresp, 4); + } else if (s->arglen != 4) { + BADF("Unexpected response to cmd %d\n", s->cmd); +@@ -236,6 +237,11 @@ static uint32_t ssi_sd_transfer(SSIPeripheral *dev, uint32_t val) + status |= SSI_SDR_PARAMETER_ERROR; + s->response[0] = status >> 8; + s->response[1] = status; ++ if (s->cmd == 0) { ++ s->idle = true; ++ } else if (s->cmd == 41 && s->response[0] == 0) { ++ s->idle = false; ++ } + DPRINTF("Card status 0x%02x\n", status); + } + s->mode = SSI_SD_PREP_RESP; +@@ -329,6 +335,9 @@ static int ssi_sd_post_load(void *opaque, int version_id) + { + ssi_sd_state *s = (ssi_sd_state *)opaque; + ++ if (version_id < 8) { ++ s->idle = false; ++ } + if (s->mode > SSI_SD_SKIP_CRC16) { + return -EINVAL; + } +@@ -347,7 +356,7 @@ static int ssi_sd_post_load(void *opaque, int version_id) + + static const VMStateDescription vmstate_ssi_sd = { + .name = "ssi_sd", +- .version_id = 7, ++ .version_id = 8, + .minimum_version_id = 7, + .post_load = ssi_sd_post_load, + .fields = (const VMStateField []) { +@@ -361,6 +370,7 @@ static const VMStateDescription vmstate_ssi_sd = { + VMSTATE_INT32(arglen, ssi_sd_state), + VMSTATE_INT32(response_pos, ssi_sd_state), + VMSTATE_INT32(stopping, ssi_sd_state), ++ VMSTATE_BOOL_V(idle, ssi_sd_state, 8), + VMSTATE_SSI_PERIPHERAL(ssidev, ssi_sd_state), + VMSTATE_END_OF_LIST() + } +@@ -387,6 +397,7 @@ static void ssi_sd_reset(DeviceState *dev) + s->arglen = 0; + s->response_pos = 0; + s->stopping = 0; ++ s->idle = true; + } + + static void ssi_sd_class_init(ObjectClass *klass, void *data) +diff --git i/hw/ssi/esp32c3_spi2.c w/hw/ssi/esp32c3_spi2.c +new file mode 100644 +index 0000000..28fcc9d +--- /dev/null ++++ w/hw/ssi/esp32c3_spi2.c +@@ -0,0 +1,101 @@ ++/* ++ * ESP32-C3 general-purpose SPI2 controller ++ * ++ * SPDX-License-Identifier: GPL-2.0-or-later ++ */ ++ ++#include "qemu/osdep.h" ++#include "qemu/module.h" ++#include "hw/ssi/esp32c3_spi2.h" ++ ++#define SPI2_CMD 0x000 ++#define SPI2_MS_DLEN 0x01c ++#define SPI2_W0 0x098 ++ ++#define SPI2_CMD_USR BIT(24) ++#define SPI2_DLEN_MASK 0x3ffff ++#define SPI2_BUFFER_SIZE 64 ++ ++static void esp32c3_spi2_transfer(ESP32C3Spi2State *s) ++{ ++ size_t bytes = (s->regs[SPI2_MS_DLEN / 4] & SPI2_DLEN_MASK) / 8 + 1; ++ bytes = MIN(bytes, SPI2_BUFFER_SIZE); ++ ++ for (size_t i = 0; i < bytes; i++) { ++ uint32_t *word = &s->regs[SPI2_W0 / 4 + i / 4]; ++ unsigned shift = (i % 4) * 8; ++ uint8_t tx = *word >> shift; ++ uint8_t rx = ssi_transfer(s->bus, tx); ++ *word = (*word & ~(0xffu << shift)) | ((uint32_t)rx << shift); ++ } ++} ++ ++static uint64_t esp32c3_spi2_read(void *opaque, hwaddr addr, unsigned size) ++{ ++ ESP32C3Spi2State *s = ESP32C3_SPI2(opaque); ++ return s->regs[addr / 4]; ++} ++ ++static void esp32c3_spi2_write(void *opaque, hwaddr addr, uint64_t value, ++ unsigned size) ++{ ++ ESP32C3Spi2State *s = ESP32C3_SPI2(opaque); ++ ++ if (addr == SPI2_CMD) { ++ if (value & SPI2_CMD_USR) { ++ esp32c3_spi2_transfer(s); ++ } ++ s->regs[SPI2_CMD / 4] = 0; ++ return; ++ } ++ ++ s->regs[addr / 4] = value; ++} ++ ++static const MemoryRegionOps esp32c3_spi2_ops = { ++ .read = esp32c3_spi2_read, ++ .write = esp32c3_spi2_write, ++ .endianness = DEVICE_LITTLE_ENDIAN, ++ .valid = { ++ .min_access_size = 4, ++ .max_access_size = 4, ++ }, ++}; ++ ++static void esp32c3_spi2_reset_hold(Object *obj, ResetType type) ++{ ++ ESP32C3Spi2State *s = ESP32C3_SPI2(obj); ++ memset(s->regs, 0, sizeof(s->regs)); ++} ++ ++static void esp32c3_spi2_init(Object *obj) ++{ ++ ESP32C3Spi2State *s = ESP32C3_SPI2(obj); ++ SysBusDevice *sbd = SYS_BUS_DEVICE(obj); ++ ++ memory_region_init_io(&s->iomem, obj, &esp32c3_spi2_ops, s, ++ TYPE_ESP32C3_SPI2, ESP32C3_SPI2_IO_SIZE); ++ sysbus_init_mmio(sbd, &s->iomem); ++ s->bus = ssi_create_bus(DEVICE(s), "spi"); ++} ++ ++static void esp32c3_spi2_class_init(ObjectClass *klass, void *data) ++{ ++ ResettableClass *rc = RESETTABLE_CLASS(klass); ++ rc->phases.hold = esp32c3_spi2_reset_hold; ++} ++ ++static const TypeInfo esp32c3_spi2_info = { ++ .name = TYPE_ESP32C3_SPI2, ++ .parent = TYPE_SYS_BUS_DEVICE, ++ .instance_size = sizeof(ESP32C3Spi2State), ++ .instance_init = esp32c3_spi2_init, ++ .class_init = esp32c3_spi2_class_init, ++}; ++ ++static void esp32c3_spi2_register_types(void) ++{ ++ type_register_static(&esp32c3_spi2_info); ++} ++ ++type_init(esp32c3_spi2_register_types) +diff --git i/hw/ssi/meson.build w/hw/ssi/meson.build +index 30f1a87..5524459 100644 +--- i/hw/ssi/meson.build ++++ w/hw/ssi/meson.build +@@ -11,7 +11,7 @@ system_ss.add(when: 'CONFIG_XILINX_SPIPS', if_true: files('xilinx_spips.c')) + system_ss.add(when: 'CONFIG_XLNX_VERSAL', if_true: files('xlnx-versal-ospi.c')) + system_ss.add(when: 'CONFIG_IMX', if_true: files('imx_spi.c')) + system_ss.add(when: 'CONFIG_XTENSA_ESP32', if_true: files('esp32_spi.c')) +-system_ss.add(when: 'CONFIG_RISCV_ESP32C3', if_true: files('esp32c3_spi.c')) ++system_ss.add(when: 'CONFIG_RISCV_ESP32C3', if_true: files('esp32c3_spi.c', 'esp32c3_spi2.c')) + system_ss.add(when: 'CONFIG_XTENSA_ESP32S3', if_true: files('esp32s3_spi.c')) + system_ss.add(when: 'CONFIG_IBEX', if_true: files('ibex_spi_host.c')) + system_ss.add(when: 'CONFIG_BCM2835_SPI', if_true: files('bcm2835_spi.c')) +diff --git i/include/hw/adc/esp32c3_adc.h w/include/hw/adc/esp32c3_adc.h +new file mode 100644 +index 0000000..1b914e1 +--- /dev/null ++++ w/include/hw/adc/esp32c3_adc.h +@@ -0,0 +1,17 @@ ++#pragma once ++ ++#include "hw/sysbus.h" ++ ++#define TYPE_ESP32C3_ADC "esp32c3.adc" ++OBJECT_DECLARE_SIMPLE_TYPE(ESP32C3AdcState, ESP32C3_ADC) ++ ++#define ESP32C3_ADC_CHANNELS 10 ++#define ESP32C3_ADC_IO_SIZE 0x1000 ++ ++struct ESP32C3AdcState { ++ SysBusDevice parent_obj; ++ MemoryRegion iomem; ++ uint32_t regs[ESP32C3_ADC_IO_SIZE / sizeof(uint32_t)]; ++ uint32_t input[ESP32C3_ADC_CHANNELS]; ++ uint8_t channel; ++}; +diff --git i/include/hw/display/xteink_x3_eink.h w/include/hw/display/xteink_x3_eink.h +new file mode 100644 +index 0000000..64de018 +--- /dev/null ++++ w/include/hw/display/xteink_x3_eink.h +@@ -0,0 +1,29 @@ ++#pragma once ++ ++#include "hw/ssi/ssi.h" ++#include "qemu/timer.h" ++#include "ui/console.h" ++ ++#define TYPE_XTEINK_X3_EINK "xteink-x3-eink" ++OBJECT_DECLARE_SIMPLE_TYPE(XteinkX3EinkState, XTEINK_X3_EINK) ++ ++#define XTEINK_X3_EINK_DC "dc" ++#define XTEINK_X3_EINK_RESET "reset" ++#define XTEINK_X3_EINK_BUSY "busy" ++ ++#define XTEINK_X3_WIDTH 792 ++#define XTEINK_X3_HEIGHT 528 ++#define XTEINK_X3_WIDTH_BYTES (XTEINK_X3_WIDTH / 8) ++#define XTEINK_X3_PLANE_SIZE (XTEINK_X3_WIDTH_BYTES * XTEINK_X3_HEIGHT) ++ ++struct XteinkX3EinkState { ++ SSIPeripheral parent_obj; ++ QemuConsole *console; ++ QEMUTimer *busy_timer; ++ qemu_irq busy; ++ bool dc; ++ uint8_t command; ++ uint32_t plane_pos; ++ uint8_t old_plane[XTEINK_X3_PLANE_SIZE]; ++ uint8_t new_plane[XTEINK_X3_PLANE_SIZE]; ++}; +diff --git i/include/hw/gpio/esp32_gpio.h w/include/hw/gpio/esp32_gpio.h +index 163b41a..e2f52ad 100644 +--- i/include/hw/gpio/esp32_gpio.h ++++ w/include/hw/gpio/esp32_gpio.h +@@ -14,12 +14,20 @@ REG32(GPIO_STRAP, 0x0038) + #define ESP32_STRAP_MODE_FLASH_BOOT 0x12 + #define ESP32_STRAP_MODE_UART_BOOT 0x0f + ++#define ESP32_GPIO_COUNT 32 ++#define ESP32_GPIO_INPUT "gpio-in" ++#define ESP32_GPIO_OUTPUT "gpio-out" ++ + typedef struct Esp32GpioState { + SysBusDevice parent_obj; + + MemoryRegion iomem; + qemu_irq irq; ++ qemu_irq output_lines[ESP32_GPIO_COUNT]; + uint32_t strap_mode; ++ uint32_t input_level; ++ uint32_t output_level; ++ uint32_t output_enable; + } Esp32GpioState; + + typedef struct Esp32GpioClass { +diff --git i/include/hw/ssi/esp32c3_spi2.h w/include/hw/ssi/esp32c3_spi2.h +new file mode 100644 +index 0000000..2155c36 +--- /dev/null ++++ w/include/hw/ssi/esp32c3_spi2.h +@@ -0,0 +1,17 @@ ++#pragma once ++ ++#include "hw/ssi/ssi.h" ++#include "hw/sysbus.h" ++ ++#define TYPE_ESP32C3_SPI2 "ssi.esp32c3.spi2" ++OBJECT_DECLARE_SIMPLE_TYPE(ESP32C3Spi2State, ESP32C3_SPI2) ++ ++#define ESP32C3_SPI2_IO_SIZE 0x1000 ++#define ESP32C3_SPI2_REG_WORDS (ESP32C3_SPI2_IO_SIZE / sizeof(uint32_t)) ++ ++struct ESP32C3Spi2State { ++ SysBusDevice parent_obj; ++ MemoryRegion iomem; ++ SSIBus *bus; ++ uint32_t regs[ESP32C3_SPI2_REG_WORDS]; ++}; diff --git a/scripts/build-qemu.sh b/scripts/build-qemu.sh new file mode 100755 index 0000000..a5c108e --- /dev/null +++ b/scripts/build-qemu.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +SRC="${QEMU_SRC:-$ROOT/_scratch/qemu-src}" +TAG="esp-develop-9.2.2-20260417" +PATCH="$ROOT/qemu/patches/0001-xteink-x3-machine.patch" +JOBS="${JOBS:-2}" + +if [ ! -d "$SRC/.git" ]; then + git clone --depth 1 --branch "$TAG" https://github.com/espressif/qemu "$SRC" +fi + +cd "$SRC" +if git apply --check "$PATCH" 2>/dev/null; then + git apply "$PATCH" +elif ! git apply --reverse --check "$PATCH" 2>/dev/null; then + echo "QEMU source has changes that conflict with $PATCH" >&2 + exit 1 +fi + +if [ ! -f build/build.ninja ]; then + ./configure \ + --target-list=riscv32-softmmu \ + --enable-gcrypt \ + --enable-slirp \ + --disable-werror \ + --disable-docs \ + --disable-tools +fi + +ninja -C build -j"$JOBS" qemu-system-riscv32 +printf '%s\n' "$SRC/build/qemu-system-riscv32" diff --git a/scripts/mksd.sh b/scripts/mksd.sh new file mode 100755 index 0000000..76768bb --- /dev/null +++ b/scripts/mksd.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Build a FAT32 SD-card image from a local directory — the local-disk backing +# for the emulated SD. QEMU's block layer is the interface: this same image is +# swapped for a browser-supplied blockdev (File/OPFS) in the WASM build. +# +# scripts/mksd.sh [src-dir] [out-img] (SIZE_MB env overrides size) +set -euo pipefail + +SRC="${1:-../crosspoint-reader-lua/sdcard}" +OUT="${2:-sd.img}" +SIZE_MB="${SIZE_MB:-64}" + +[ -d "$SRC" ] || { echo "no such dir: $SRC" >&2; exit 1; } +OUT_ABS="$(realpath -m "$OUT")" + +rm -f "$OUT_ABS" +truncate -s "${SIZE_MB}M" "$OUT_ABS" +mkfs.vfat -F 32 -n XTEINK "$OUT_ABS" >/dev/null + +# mcopy the tree in (recursive, no per-file prompts). +( cd "$SRC" && for e in *; do [ -e "$e" ] && mcopy -i "$OUT_ABS" -s -Q -o "$e" ::; done ) + +echo "wrote $OUT (${SIZE_MB}M FAT32) from $SRC" +mdir -i "$OUT_ABS" :: | sed 's/^/ /' diff --git a/scripts/qmp-qom.py b/scripts/qmp-qom.py new file mode 100755 index 0000000..9f91411 --- /dev/null +++ b/scripts/qmp-qom.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +# Exercise the button/ADC QOM interface: qmp-qom.py get|set [value] +import json +import socket +import sys + +sock = socket.socket(socket.AF_UNIX) +sock.connect(sys.argv[1]) +f = sock.makefile("rw") +json.loads(f.readline()) + + +def cmd(command): + f.write(json.dumps(command) + "\n") + f.flush() + return json.loads(f.readline()) + + +cmd({"execute": "qmp_capabilities"}) +op, path, prop = sys.argv[2], sys.argv[3], sys.argv[4] +if op == "set": + print(cmd({"execute": "qom-set", "arguments": { + "path": path, "property": prop, "value": int(sys.argv[5])}})) +else: + print(cmd({"execute": "qom-get", "arguments": { + "path": path, "property": prop}})) diff --git a/scripts/qmp-screendump.py b/scripts/qmp-screendump.py new file mode 100755 index 0000000..fea52f9 --- /dev/null +++ b/scripts/qmp-screendump.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python3 +import json +import socket +import sys + +sock = socket.socket(socket.AF_UNIX) +sock.connect(sys.argv[1]) +f = sock.makefile("rw") +json.loads(f.readline()) +for command in ( + {"execute": "qmp_capabilities"}, + {"execute": "screendump", "arguments": {"filename": sys.argv[2]}}, +): + f.write(json.dumps(command) + "\n") + f.flush() + response = json.loads(f.readline()) + if "error" in response: + raise RuntimeError(response["error"]) diff --git a/wokwi.toml b/wokwi.toml new file mode 100644 index 0000000..bad0964 --- /dev/null +++ b/wokwi.toml @@ -0,0 +1,10 @@ +# Point `firmware`/`elf` at any xteink-X3 build. `make firmware` produces +# flash.bin + firmware.elf from a local crosspoint-reader-lua PlatformIO build. +[wokwi] +version = 1 +firmware = 'flash.bin' +elf = 'firmware.elf' + +[[chip]] +name = 'eink-x3' +binary = 'chip/dist/eink-x3.chip.wasm'