diff --git a/HANDOFF.md b/HANDOFF.md index c4e228e..f9406d7 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -265,15 +265,23 @@ ADC ladders on GPIO1 & GPIO2; power button GPIO3; UC8253 @ 16 MHz. `-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. +- Real firmware currently stops in `ffi_call_js`: Emscripten's + `EMULATE_FUNCTION_POINTER_CASTS` conflicts with libffi/BigInt. The paused + machine and browser bridge smoke tests pass; full browser boot does not yet. +- The upstream Emscripten RAM allocator fix is backported: anonymous guest RAM + uses `qemu_memalign` instead of the broken partial-unmap `mmap` workaround. - Emscripten pthreads require cross-origin isolation (COOP/COEP) in the browser. + `make web` runs the stdlib server with those headers and serves `/web/` plus + the sibling `dist/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. A second boot needs + a page reload because QEMU machine selection and global state are process-wide. 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). +1. **Browser SD storage**: add an uploaded or OPFS-backed SD block device. 2. **Full X4 rendering later**: SSD1677 is 800×480 controller / 480×800 portrait, BUSY active-high. Implement its RAM/window/update commands when needed. @@ -287,6 +295,7 @@ Next: | 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) | diff --git a/Makefile b/Makefile index 335b88d..ea9c53b 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ QEMU_WASM_SRC ?= _scratch/qemu-wasm-src WASM_OUT ?= dist/wasm VARIANT ?= x3 -.PHONY: firmware sdimage qemu qemu-wasm run run-upstream chip clean +.PHONY: firmware sdimage qemu qemu-wasm web web-test run run-upstream chip clean firmware: esptool --chip esp32c3 merge-bin -o flash.bin \ @@ -29,6 +29,12 @@ qemu: qemu-wasm: QEMU_WASM_SRC=$(QEMU_WASM_SRC) WASM_OUT=$(WASM_OUT) JOBS=2 scripts/build-qemu-wasm.sh +web: qemu-wasm + python3 scripts/serve-web.py + +web-test: + node scripts/test-web.mjs + # 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 \ diff --git a/README.md b/README.md index 590a7f5..b4ea0a5 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,9 @@ press buttons, mount a virtual SD — no server, no proprietary engine. 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 static browser -frontend is next. See [ROADMAP.md](./ROADMAP.md) for architecture and plan. +X4 reaches its SSD1677 driver through a blank protocol stub. A basic static +browser UI now loads local firmware, renders the panel, and drives the physical +buttons. See [ROADMAP.md](./ROADMAP.md) for architecture and plan. ## Quick start (native QEMU) @@ -34,12 +35,27 @@ are disabled. ```sh make qemu-wasm -# dist/wasm/qemu-system-riscv32.{js,wasm,worker.js} +# dist/wasm/qemu-system-riscv32.{js,wasm,worker.js} + esp32c3-rom.bin ``` -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. +The build runs Node smoke checks when Node is available. + +## Browser interface + +```sh +make web +# Open http://127.0.0.1:8000/web/ +``` + +Choose a merged 16 MB firmware `.bin`, select X3 or X4, and boot. The controls +map to the device's ADC button ladders and active-low Power GPIO. `make web` +serves the required COOP/COEP headers for Emscripten pthreads; a generic static +server without those headers will not work. + +Real firmware execution currently stops at an Emscripten libffi/function-pointer +signature error. The UI and paused-machine bridge smoke tests work, but browser +boot is not complete. X4 remains a white display stub, and browser SD storage is +not implemented yet. ## Contents diff --git a/ROADMAP.md b/ROADMAP.md index e66c8b2..2523bb6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -36,7 +36,10 @@ 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. +under `dist/wasm/`; Node smoke checks confirm `xteink` is registered and its +browser framebuffer/input bridge initializes. `make web` serves a dependency-free +UI for firmware, canvas, and buttons. Real firmware execution remains blocked by +an Emscripten libffi/function-pointer signature error. ### Device selection (X3 vs X4) @@ -52,7 +55,7 @@ both firmware detection and panel wiring without modifying the firmware. 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) | +| Peripheral | Native seam | Browser binding | |------------|-------------|---------------------------| | e-ink panel | X3 UC8253 (528×792) or blank X4 SSD1677 stub (480×800) → QEMU graphical console | `` 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) | @@ -82,8 +85,8 @@ reuses unchanged — no bespoke abstraction layer: ## Remaining phases -1. **Browser front-end (static)** — uploaded flash, canvas, physical buttons, - SD block backend, and pre-boot X3/X4 picker. Fully airgapped. +1. **Browser SD storage** — add an uploaded or OPFS-backed SD block device. The + basic static frontend already covers firmware, canvas, buttons, and X3/X4. 2. **Full X4 panel** — implement SSD1677 RAM/window/update commands when visible X4 rendering is needed; the selection, dimensions, wiring, and stub exist. @@ -98,7 +101,7 @@ reuses unchanged — no bespoke abstraction layer: | `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 | +| `web/` | Static frontend: firmware upload, canvas, variant, and buttons | ## Debugging notes diff --git a/qemu/patches/0002-qemu-wasm.patch b/qemu/patches/0002-qemu-wasm.patch index aa00f9a..dba4823 100644 --- a/qemu/patches/0002-qemu-wasm.patch +++ b/qemu/patches/0002-qemu-wasm.patch @@ -202,6 +202,23 @@ index 49814ec4af..0715610d37 100644 assert(tcg_enabled()); g_assert(!icount_enabled()); +diff --git a/backends/meson.build b/backends/meson.build +index da714b93d1..9b88d22685 100644 +--- a/backends/meson.build ++++ b/backends/meson.build +@@ -12,8 +12,10 @@ system_ss.add([files( + + if host_os != 'windows' + system_ss.add(files('rng-random.c')) +- system_ss.add(files('hostmem-file.c')) +- system_ss.add([files('hostmem-shm.c'), rt]) ++ if host_os != 'emscripten' ++ system_ss.add(files('hostmem-file.c')) ++ system_ss.add([files('hostmem-shm.c'), rt]) ++ endif + endif + if host_os == 'linux' + system_ss.add(files('hostmem-memfd.c')) diff --git a/block/file-posix.c b/block/file-posix.c index 90fa54352c..0fa9cc31b6 100644 --- a/block/file-posix.c @@ -294,11 +311,189 @@ index 8146fafe80..2ee0605dba 100644 s->info.print_insn = print_insn_tci; #elif defined(__i386__) s->info.mach = bfd_mach_i386_i386; +diff --git a/hw/adc/esp32c3_adc.c b/hw/adc/esp32c3_adc.c +index 89087429c7..4d38b3e13c 100644 +--- a/hw/adc/esp32c3_adc.c ++++ b/hw/adc/esp32c3_adc.c +@@ -27,7 +27,7 @@ static uint64_t esp32c3_adc_read(void *opaque, hwaddr addr, unsigned size) + } + if (addr == ADC_DATA1 || addr == ADC_DATA2) { + return s->channel < ESP32C3_ADC_CHANNELS +- ? MIN(s->input[s->channel], ADC_MAX) ++ ? MIN(qatomic_read(&s->input[s->channel]), ADC_MAX) + : ADC_MAX; + } + return s->regs[addr / 4]; +diff --git a/hw/display/xteink_x3_eink.c b/hw/display/xteink_x3_eink.c +index b699abae4e..8a177e9159 100644 +--- a/hw/display/xteink_x3_eink.c ++++ b/hw/display/xteink_x3_eink.c +@@ -9,6 +9,60 @@ + #include "hw/irq.h" + #include "hw/display/xteink_x3_eink.h" + ++#ifdef __EMSCRIPTEN__ ++#include ++ ++static QemuConsole *xteink_wasm_console; ++static uint32_t xteink_wasm_generation; ++ ++EMSCRIPTEN_KEEPALIVE uintptr_t xteink_wasm_framebuffer(void) ++{ ++ DisplaySurface *surface = xteink_wasm_console ++ ? qemu_console_surface(xteink_wasm_console) ++ : NULL; ++ return surface ? (uintptr_t)surface_data(surface) : 0; ++} ++ ++EMSCRIPTEN_KEEPALIVE int xteink_wasm_width(void) ++{ ++ DisplaySurface *surface = xteink_wasm_console ++ ? qemu_console_surface(xteink_wasm_console) ++ : NULL; ++ return surface ? surface_width(surface) : 0; ++} ++ ++EMSCRIPTEN_KEEPALIVE int xteink_wasm_height(void) ++{ ++ DisplaySurface *surface = xteink_wasm_console ++ ? qemu_console_surface(xteink_wasm_console) ++ : NULL; ++ return surface ? surface_height(surface) : 0; ++} ++ ++EMSCRIPTEN_KEEPALIVE int xteink_wasm_stride(void) ++{ ++ DisplaySurface *surface = xteink_wasm_console ++ ? qemu_console_surface(xteink_wasm_console) ++ : NULL; ++ return surface ? surface_stride(surface) : 0; ++} ++ ++EMSCRIPTEN_KEEPALIVE uint32_t xteink_wasm_frame_generation(void) ++{ ++ return qatomic_read(&xteink_wasm_generation); ++} ++ ++static void xteink_wasm_frame_updated(QemuConsole *console) ++{ ++ xteink_wasm_console = console; ++ qatomic_inc(&xteink_wasm_generation); ++} ++#else ++static void xteink_wasm_frame_updated(QemuConsole *console) ++{ ++} ++#endif ++ + #define CMD_POWER_OFF 0x02 + #define CMD_POWER_ON 0x04 + #define CMD_DTM1 0x10 +@@ -33,6 +87,7 @@ static void xteink_x3_eink_render(XteinkX3EinkState *s) + } + } + ++ xteink_wasm_frame_updated(s->console); + dpy_gfx_update(s->console, 0, 0, XTEINK_X3_HEIGHT, XTEINK_X3_WIDTH); + } + +@@ -171,6 +226,7 @@ static void xteink_x4_eink_render(XteinkX4EinkState *s) + DisplaySurface *surface = qemu_console_surface(s->console); + memset(surface_data(surface), 0xff, + surface_stride(surface) * surface_height(surface)); ++ xteink_wasm_frame_updated(s->console); + dpy_gfx_update(s->console, 0, 0, 480, 800); + } + +diff --git a/hw/gpio/esp32_gpio.c b/hw/gpio/esp32_gpio.c +index 9ec8bd9244..754990968c 100644 +--- a/hw/gpio/esp32_gpio.c ++++ b/hw/gpio/esp32_gpio.c +@@ -38,10 +38,20 @@ static void esp32_gpio_drive_outputs(Esp32GpioState *s) + } + } + ++void esp32_gpio_set_input_level(Esp32GpioState *s, int pin, bool level) ++{ ++ uint32_t old_level; ++ uint32_t new_level; ++ ++ do { ++ old_level = qatomic_read(&s->input_level); ++ new_level = deposit32(old_level, pin, 1, level); ++ } while (qatomic_cmpxchg(&s->input_level, old_level, new_level) != old_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); ++ esp32_gpio_set_input_level(ESP32_GPIO(opaque), pin, level); + } + + static void esp32_gpio_get_input(Object *obj, Visitor *v, const char *name, +@@ -49,7 +59,7 @@ static void esp32_gpio_get_input(Object *obj, Visitor *v, const char *name, + { + Esp32GpioState *s = ESP32_GPIO(obj); + int pin = GPOINTER_TO_INT(opaque); +- bool level = extract32(s->input_level, pin, 1); ++ bool level = extract32(qatomic_read(&s->input_level), pin, 1); + visit_type_bool(v, name, &level, errp); + } + +@@ -78,7 +88,7 @@ static uint64_t esp32_gpio_read(void *opaque, hwaddr addr, unsigned int size) + case A_GPIO_STRAP: + return s->strap_mode; + case GPIO_IN: +- return s->input_level; ++ return qatomic_read(&s->input_level); + default: + return 0; + } diff --git a/hw/riscv/esp32c3.c b/hw/riscv/esp32c3.c -index 2f1ccbfbb0..55ab156533 100644 +index 2f1ccbfbb0..febc96bfe3 100644 --- a/hw/riscv/esp32c3.c +++ b/hw/riscv/esp32c3.c -@@ -421,12 +421,18 @@ static void esp32c3_machine_init(MachineState *machine) +@@ -57,6 +57,10 @@ + #include "hw/sd/sd.h" + #include "hw/net/can/esp32c3_twai.h" + ++#ifdef __EMSCRIPTEN__ ++#include ++#endif ++ + #define ESP32C3_IO_WARNING 0 + + #define ESP32C3_RESET_ADDRESS 0x40000000 +@@ -103,6 +107,27 @@ struct Esp32C3MachineState { + Esp32C3TWAIState twai; + }; + ++#ifdef __EMSCRIPTEN__ ++static struct Esp32C3MachineState *xteink_wasm_machine; ++ ++EMSCRIPTEN_KEEPALIVE void xteink_wasm_set_adc(int channel, uint32_t value) ++{ ++ if (xteink_wasm_machine && channel >= 0 && ++ channel < ESP32C3_ADC_CHANNELS) { ++ qatomic_set(&xteink_wasm_machine->adc.input[channel], ++ MIN(value, UINT32_C(0xfff))); ++ } ++} ++ ++EMSCRIPTEN_KEEPALIVE void xteink_wasm_set_gpio(int pin, int level) ++{ ++ if (xteink_wasm_machine && pin >= 0 && pin < ESP32_GPIO_COUNT) { ++ esp32_gpio_set_input_level(&xteink_wasm_machine->gpio.parent, ++ pin, level); ++ } ++} ++#endif ++ + /* Fake register used by ESP-IDF application to determine whether the code is running on real hardware or on QEMU */ + #define A_SYSCON_ORIGIN_REG 0x3F8 + /* Temporary macro for generating a random value from register SYSCON_RND_DATA_REG */ +@@ -421,12 +446,18 @@ static void esp32c3_machine_init(MachineState *machine) object_initialize_child(OBJECT(machine), "efuse", &ms->efuse, TYPE_ESP32C3_EFUSE); object_initialize_child(OBJECT(machine), "clock", &ms->clock, TYPE_ESP32C3_CLOCK); object_initialize_child(OBJECT(machine), "sha", &ms->sha, TYPE_ESP32C3_SHA); @@ -317,7 +512,7 @@ index 2f1ccbfbb0..55ab156533 100644 object_initialize_child(OBJECT(machine), "timg0", &ms->timg[0], TYPE_ESP32C3_TIMG); object_initialize_child(OBJECT(machine), "timg1", &ms->timg[1], TYPE_ESP32C3_TIMG); object_initialize_child(OBJECT(machine), "systimer", &ms->systimer, TYPE_ESP32C3_SYSTIMER); -@@ -484,7 +490,9 @@ static void esp32c3_machine_init(MachineState *machine) +@@ -484,7 +515,9 @@ static void esp32c3_machine_init(MachineState *machine) /* SPI1 controller (SPI Flash) */ { @@ -327,7 +522,7 @@ index 2f1ccbfbb0..55ab156533 100644 sysbus_realize(SYS_BUS_DEVICE(&ms->spi1), &error_fatal); MemoryRegion *mr = sysbus_mmio_get_region(SYS_BUS_DEVICE(&ms->spi1), 0); memory_region_add_subregion_overlap(sys_mem, DR_REG_SPI1_BASE, mr, 0); -@@ -539,7 +547,9 @@ static void esp32c3_machine_init(MachineState *machine) +@@ -539,7 +572,9 @@ static void esp32c3_machine_init(MachineState *machine) if (blk) { ms->cache.flash_blk = blk; } @@ -337,7 +532,7 @@ index 2f1ccbfbb0..55ab156533 100644 sysbus_realize(SYS_BUS_DEVICE(&ms->cache), &error_fatal); MemoryRegion *mr = sysbus_mmio_get_region(SYS_BUS_DEVICE(&ms->cache), 0); memory_region_add_subregion_overlap(sys_mem, DR_REG_EXTMEM_BASE, mr, 0); -@@ -635,6 +645,7 @@ static void esp32c3_machine_init(MachineState *machine) +@@ -635,6 +670,7 @@ static void esp32c3_machine_init(MachineState *machine) qdev_get_gpio_in(intmatrix_dev, ETS_SHA_INTR_SOURCE)); } @@ -345,7 +540,7 @@ index 2f1ccbfbb0..55ab156533 100644 /* AES realization */ { ms->aes.parent.gdma = ESP_GDMA(&ms->gdma); -@@ -654,6 +665,8 @@ static void esp32c3_machine_init(MachineState *machine) +@@ -654,6 +690,8 @@ static void esp32c3_machine_init(MachineState *machine) qdev_get_gpio_in(intmatrix_dev, ETS_RSA_INTR_SOURCE)); } @@ -354,7 +549,7 @@ index 2f1ccbfbb0..55ab156533 100644 /* HMAC realization */ { ms->hmac.parent.efuse = ESP_EFUSE(&ms->efuse); -@@ -662,6 +675,7 @@ static void esp32c3_machine_init(MachineState *machine) +@@ -662,6 +700,7 @@ static void esp32c3_machine_init(MachineState *machine) memory_region_add_subregion_overlap(sys_mem, DR_REG_HMAC_BASE, mr, 0); } @@ -362,7 +557,7 @@ index 2f1ccbfbb0..55ab156533 100644 /* Digital Signature realization */ { ms->ds.parent.hmac = ESP_HMAC(&ms->hmac); -@@ -682,6 +696,8 @@ static void esp32c3_machine_init(MachineState *machine) +@@ -682,6 +721,8 @@ static void esp32c3_machine_init(MachineState *machine) memory_region_add_subregion_overlap(sys_mem, DR_REG_AES_XTS_BASE, mr, 0); } @@ -371,6 +566,16 @@ index 2f1ccbfbb0..55ab156533 100644 /* RGB display realization */ if (!ms->xteink) { /* Give the internal RAM memory region to the display */ +@@ -706,6 +747,9 @@ static void xteink_machine_init(MachineState *machine) + Esp32C3MachineState *ms = ESP32C3_MACHINE(machine); + ms->xteink = true; + esp32c3_machine_init(machine); ++#ifdef __EMSCRIPTEN__ ++ xteink_wasm_machine = ms; ++#endif + + /* X3 exposes the fingerprint chips; X4 omits them so stock firmware's + * all-NAK probe selects X4. The X4 panel is a blank protocol stub. */ diff --git a/include/exec/exec-all.h b/include/exec/exec-all.h index 2e4c4cc4b4..99e6da08c9 100644 --- a/include/exec/exec-all.h @@ -384,6 +589,16 @@ index 2e4c4cc4b4..99e6da08c9 100644 extern __thread uintptr_t tci_tb_ptr; # define GETPC() tci_tb_ptr #else +diff --git a/include/hw/gpio/esp32_gpio.h b/include/hw/gpio/esp32_gpio.h +index e2f52ad702..721f5de5df 100644 +--- a/include/hw/gpio/esp32_gpio.h ++++ b/include/hw/gpio/esp32_gpio.h +@@ -33,3 +33,5 @@ typedef struct Esp32GpioState { + typedef struct Esp32GpioClass { + SysBusDeviceClass parent_class; + } Esp32GpioClass; ++ ++void esp32_gpio_set_input_level(Esp32GpioState *s, int pin, bool level); diff --git a/include/qemu/atomic.h b/include/qemu/atomic.h index 7a3f2e6576..50dc235043 100644 --- a/include/qemu/atomic.h @@ -720,6 +935,50 @@ index a8066aab03..bd3ae90008 100644 printf "%s\n" ' --with-pkgversion=VALUE use specified string as sub-version of the' printf "%s\n" ' package' printf "%s\n" ' --with-suffix=VALUE Suffix for QEMU data/modules/config directories' +diff --git a/system/memory.c b/system/memory.c +index 85f6834cb3..3958314765 100644 +--- a/system/memory.c ++++ b/system/memory.c +@@ -1636,7 +1636,7 @@ bool memory_region_init_resizeable_ram(MemoryRegion *mr, + return true; + } + +-#ifdef CONFIG_POSIX ++#if defined(CONFIG_POSIX) && !defined(EMSCRIPTEN) + bool memory_region_init_ram_from_file(MemoryRegion *mr, + Object *owner, + const char *name, +diff --git a/system/physmem.c b/system/physmem.c +index 75389064a8..addd5d0c4b 100644 +--- a/system/physmem.c ++++ b/system/physmem.c +@@ -1235,7 +1235,7 @@ long qemu_maxrampagesize(void) + return pagesize; + } + +-#ifdef CONFIG_POSIX ++#if defined(CONFIG_POSIX) && !defined(EMSCRIPTEN) + static int64_t get_file_size(int fd) + { + int64_t size; +@@ -1941,7 +1941,7 @@ out_free: + } + } + +-#ifdef CONFIG_POSIX ++#if defined(CONFIG_POSIX) && !defined(EMSCRIPTEN) + RAMBlock *qemu_ram_alloc_from_fd(ram_addr_t size, MemoryRegion *mr, + uint32_t ram_flags, int fd, off_t offset, + Error **errp) +@@ -2130,7 +2130,7 @@ static void reclaim_ramblock(RAMBlock *block) + ; + } else if (xen_enabled()) { + xen_invalidate_map_cache_entry(block->host); +-#ifndef _WIN32 ++#if !defined(_WIN32) && !defined(EMSCRIPTEN) + } else if (block->fd >= 0) { + qemu_ram_munmap(block->fd, block->host, block->max_length); + close(block->fd); diff --git a/tcg/meson.build b/tcg/meson.build index 69ebb4908a..0894a577c2 100644 --- a/tcg/meson.build @@ -6750,6 +7009,21 @@ index df6c6331bd..c442ff4da8 100644 Int128 int128_divu(Int128 a_s, Int128 b_s) { +diff --git a/util/meson.build b/util/meson.build +index 5d8bef9891..e107fbfb69 100644 +--- a/util/meson.build ++++ b/util/meson.build +@@ -11,7 +11,9 @@ if host_os != 'windows' + endif + util_ss.add(files('compatfd.c')) + util_ss.add(files('event_notifier-posix.c')) +- util_ss.add(files('mmap-alloc.c')) ++ if host_os != 'emscripten' ++ util_ss.add(files('mmap-alloc.c')) ++ endif + freebsd_dep = [] + if host_os == 'freebsd' + freebsd_dep = util diff --git a/util/mmap-alloc.c b/util/mmap-alloc.c index ed14f9c64d..8c8708ca4d 100644 --- a/util/mmap-alloc.c @@ -6774,3 +7048,59 @@ index ed14f9c64d..8c8708ca4d 100644 } void qemu_ram_munmap(int fd, void *ptr, size_t size) +diff --git a/util/oslib-posix.c b/util/oslib-posix.c +index 11b35e48fb..760c3d4f4c 100644 +--- a/util/oslib-posix.c ++++ b/util/oslib-posix.c +@@ -58,6 +58,7 @@ + #include + #endif + ++#include "qemu/memalign.h" + #include "qemu/mmap-alloc.h" + + #define MAX_MEM_PREALLOC_THREAD_COUNT 16 +@@ -192,14 +193,18 @@ fail_close: + void *qemu_anon_ram_alloc(size_t size, uint64_t *alignment, bool shared, + bool noreserve) + { ++ size_t align = QEMU_VMALLOC_ALIGN; ++#ifndef EMSCRIPTEN + const uint32_t qemu_map_flags = (shared ? QEMU_MAP_SHARED : 0) | + (noreserve ? QEMU_MAP_NORESERVE : 0); +- size_t align = QEMU_VMALLOC_ALIGN; + void *ptr = qemu_ram_mmap(-1, size, align, qemu_map_flags, 0); + + if (ptr == MAP_FAILED) { + return NULL; + } ++#else ++ void *ptr = qemu_memalign(align, size); ++#endif + + if (alignment) { + *alignment = align; +@@ -212,7 +217,11 @@ void *qemu_anon_ram_alloc(size_t size, uint64_t *alignment, bool shared, + void qemu_anon_ram_free(void *ptr, size_t size) + { + trace_qemu_anon_ram_free(ptr, size); ++#ifndef EMSCRIPTEN + qemu_ram_munmap(-1, ptr, size); ++#else ++ qemu_vfree(ptr); ++#endif + } + + void qemu_socket_set_block(int fd) +@@ -573,7 +582,11 @@ bool qemu_prealloc_mem(int fd, char *area, size_t sz, int max_threads, + { + static gsize initialized; + int ret; ++#ifndef EMSCRIPTEN + size_t hpagesize = qemu_fd_getpagesize(fd); ++#else ++ size_t hpagesize = qemu_real_host_page_size(); ++#endif + size_t numpages = DIV_ROUND_UP(sz, hpagesize); + bool use_madv_populate_write; + struct sigaction act; diff --git a/scripts/build-qemu-wasm.sh b/scripts/build-qemu-wasm.sh index 4b50465..a3201e3 100755 --- a/scripts/build-qemu-wasm.sh +++ b/scripts/build-qemu-wasm.sh @@ -73,7 +73,9 @@ 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/" +install -m 0644 pc-bios/esp32c3-rom.bin "$OUT/" if command -v node >/dev/null; then "$ROOT/scripts/smoke-qemu-wasm.sh" "$OUT/qemu-system-riscv32.js" + node "$ROOT/scripts/smoke-browser-bridge.mjs" "$OUT" fi printf '%s\n' "$OUT" diff --git a/scripts/serve-web.py b/scripts/serve-web.py new file mode 100644 index 0000000..65a2564 --- /dev/null +++ b/scripts/serve-web.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +import argparse +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer + + +class IsolatedHandler(SimpleHTTPRequestHandler): + def end_headers(self): + self.send_header("Cross-Origin-Opener-Policy", "same-origin") + self.send_header("Cross-Origin-Embedder-Policy", "require-corp") + self.send_header("Cross-Origin-Resource-Policy", "same-origin") + super().end_headers() + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--port", type=int, default=8000) + args = parser.parse_args() + server = ThreadingHTTPServer(("127.0.0.1", args.port), IsolatedHandler) + print(f"Open http://127.0.0.1:{args.port}/web/") + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/scripts/smoke-browser-bridge.mjs b/scripts/smoke-browser-bridge.mjs new file mode 100644 index 0000000..7874b17 --- /dev/null +++ b/scripts/smoke-browser-bridge.mjs @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { pathToFileURL } from 'node:url'; + +const artifactRoot = process.argv[2]; +if (!artifactRoot) { + throw new Error('usage: node scripts/smoke-browser-bridge.mjs '); +} + +const moduleUrl = pathToFileURL(`${artifactRoot}/qemu-system-riscv32.js`); +const rom = new Uint8Array(await readFile(`${artifactRoot}/esp32c3-rom.bin`)); +const { default: createQemu } = await import(moduleUrl); +const options = { + arguments: [ + '-S', '-machine', 'xteink,variant=x3', '-L', '/bios', + '-display', 'none', '-serial', 'null', '-nic', 'none', + '-drive', 'file=/flash.bin,if=mtd,format=raw', + ], + mainScriptUrlOrBlob: moduleUrl.href, +}; +options.preRun = [() => { + options.FS.mkdir('/bios'); + options.FS.writeFile('/bios/esp32c3-rom.bin', rom); + options.FS.writeFile('/flash.bin', new Uint8Array(16 * 1024 * 1024)); +}]; + +const module = await createQemu(options); +const deadline = Date.now() + 15000; +const timer = setInterval(() => { + if (module._xteink_wasm_width() === 528) { + clearInterval(timer); + assert.equal(module._xteink_wasm_height(), 792); + assert.ok(module._xteink_wasm_framebuffer()); + module._xteink_wasm_set_adc(1, 2694); + module._xteink_wasm_set_gpio(3, 0); + process.exit(0); + } + if (Date.now() > deadline) { + clearInterval(timer); + process.exit(1); + } +}, 50); diff --git a/scripts/test-web.mjs b/scripts/test-web.mjs new file mode 100644 index 0000000..5abb821 --- /dev/null +++ b/scripts/test-web.mjs @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict'; +import { BUTTONS, setButton, validateFirmwareSize } from '../web/app.js'; + +validateFirmwareSize(16 * 1024 * 1024); +assert.throws(() => validateFirmwareSize(4 * 1024 * 1024), /exactly 16 MB/); + +const calls = []; +const module = { + _xteink_wasm_set_adc: (...args) => calls.push(['adc', ...args]), + _xteink_wasm_set_gpio: (...args) => calls.push(['gpio', ...args]), +}; +setButton(module, 'confirm', true); +setButton(module, 'confirm', false); +setButton(module, 'power', true); +setButton(module, 'power', false); +assert.deepEqual(calls, [ + ['adc', BUTTONS.confirm.channel, BUTTONS.confirm.value], + ['adc', BUTTONS.confirm.channel, 4095], + ['gpio', 3, 0], + ['gpio', 3, 1], +]); diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..b5f20fe --- /dev/null +++ b/web/app.js @@ -0,0 +1,204 @@ +const FIRMWARE_SIZE = 16 * 1024 * 1024; +const RELEASED_ADC = 4095; + +export const BUTTONS = { + back: { type: 'adc', channel: 1, value: 3512 }, + confirm: { type: 'adc', channel: 1, value: 2694 }, + left: { type: 'adc', channel: 1, value: 1493 }, + right: { type: 'adc', channel: 1, value: 5 }, + up: { type: 'adc', channel: 2, value: 2242 }, + down: { type: 'adc', channel: 2, value: 5 }, + power: { type: 'gpio', pin: 3 }, +}; + +export function validateFirmwareSize(size) { + if (size !== FIRMWARE_SIZE) { + throw new Error(`Firmware must be exactly 16 MB; this file is ${(size / 1024 / 1024).toFixed(2)} MB.`); + } +} + +export function setButton(module, name, pressed) { + const button = BUTTONS[name]; + if (!button) { + throw new Error(`Unknown button: ${name}`); + } + if (button.type === 'adc') { + module._xteink_wasm_set_adc(button.channel, pressed ? button.value : RELEASED_ADC); + } else { + module._xteink_wasm_set_gpio(button.pin, pressed ? 0 : 1); + } +} + +function drawFrame(module, canvas) { + const width = module._xteink_wasm_width(); + const height = module._xteink_wasm_height(); + const stride = module._xteink_wasm_stride(); + const pointer = module._xteink_wasm_framebuffer(); + if (!width || !height || !stride || !pointer) { + return false; + } + + if (canvas.width !== width || canvas.height !== height) { + canvas.width = width; + canvas.height = height; + } + + const source = new Uint8Array(module.wasmMemory.buffer); + const image = new ImageData(width, height); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const sourceOffset = pointer + y * stride + x * 4; + const targetOffset = (y * width + x) * 4; + image.data[targetOffset] = source[sourceOffset]; + image.data[targetOffset + 1] = source[sourceOffset + 1]; + image.data[targetOffset + 2] = source[sourceOffset + 2]; + image.data[targetOffset + 3] = 255; + } + } + canvas.getContext('2d').putImageData(image, 0, 0); + return true; +} + +function startDisplayLoop(module, canvas, setStatus, onReady) { + let generation = -1; + let ready = false; + const update = () => { + const nextGeneration = module._xteink_wasm_frame_generation(); + if (nextGeneration !== generation && drawFrame(module, canvas)) { + generation = nextGeneration; + setStatus('Running.'); + if (!ready) { + ready = true; + onReady(); + } + } + requestAnimationFrame(update); + }; + requestAnimationFrame(update); +} + +function enableControls(module) { + for (const element of document.querySelectorAll('[data-button]')) { + element.disabled = false; + let pressed = false; + + const press = () => { + if (!pressed) { + pressed = true; + element.classList.add('active'); + setButton(module, element.dataset.button, true); + } + }; + const release = () => { + if (pressed) { + pressed = false; + element.classList.remove('active'); + setButton(module, element.dataset.button, false); + } + }; + + element.addEventListener('pointerdown', event => { + element.setPointerCapture(event.pointerId); + press(); + }); + element.addEventListener('pointerup', release); + element.addEventListener('pointercancel', release); + element.addEventListener('lostpointercapture', release); + element.addEventListener('keydown', event => { + if (!event.repeat && (event.key === ' ' || event.key === 'Enter')) { + event.preventDefault(); + press(); + } + }); + element.addEventListener('keyup', event => { + if (event.key === ' ' || event.key === 'Enter') { + event.preventDefault(); + release(); + } + }); + element.addEventListener('blur', release); + } +} + +async function boot(file, variant, setStatus) { + validateFirmwareSize(file.size); + if (!crossOriginIsolated) { + throw new Error('This emulator requires COOP/COEP headers. Start it with `make web`.'); + } + setStatus('Loading firmware…'); + + const firmware = new Uint8Array(await file.arrayBuffer()); + const artifactRoot = new URL('../dist/wasm/', import.meta.url); + const qemuUrl = new URL('qemu-system-riscv32.js', artifactRoot); + const rom = new Uint8Array(await fetch(new URL('esp32c3-rom.bin', artifactRoot)).then(response => { + if (!response.ok) { + throw new Error(`Could not load ESP32-C3 ROM (${response.status}).`); + } + return response.arrayBuffer(); + })); + const { default: createQemu } = await import(qemuUrl); + + const options = { + arguments: [ + '-machine', `xteink,variant=${variant}`, + '-L', '/bios', + '-display', 'none', + '-serial', 'null', + '-nic', 'none', + '-drive', 'file=/flash.bin,if=mtd,format=raw', + ], + locateFile: path => new URL(path, artifactRoot).href, + mainScriptUrlOrBlob: qemuUrl.href, + print: message => console.log(message), + printErr: message => console.error(message), + }; + options.preRun = [() => { + options.FS.mkdir('/bios'); + options.FS.writeFile('/bios/esp32c3-rom.bin', rom); + options.FS.writeFile('/flash.bin', firmware); + }]; + + setStatus('Starting emulator…'); + return createQemu(options); +} + +function initialize() { + const form = document.querySelector('#boot-form'); + const firmwareInput = document.querySelector('#firmware'); + const variantInput = document.querySelector('#variant'); + const bootButton = document.querySelector('#boot'); + const status = document.querySelector('#status'); + const canvas = document.querySelector('#screen'); + + const setStatus = (message, error = false) => { + status.textContent = message; + status.classList.toggle('error', error); + }; + + form.addEventListener('submit', async event => { + event.preventDefault(); + const file = firmwareInput.files[0]; + if (!file) { + setStatus('Choose a firmware image first.', true); + return; + } + + bootButton.disabled = true; + firmwareInput.disabled = true; + variantInput.disabled = true; + try { + const module = await boot(file, variantInput.value, setStatus); + startDisplayLoop(module, canvas, setStatus, () => enableControls(module)); + } catch (error) { + console.error(error); + setStatus(error.message || String(error), true); + bootButton.disabled = false; + firmwareInput.disabled = false; + variantInput.disabled = false; + } + }); +} + +if (typeof document !== 'undefined') { + initialize(); +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..e271d0a --- /dev/null +++ b/web/index.html @@ -0,0 +1,55 @@ + + + + + + xteink web emulator + + + +
+
+

ESP32-C3 emulator

+

xteink X3 / X4

+

Choose a merged 16 MB firmware image and boot it locally in your browser.

+
+ +
+ + + +
+ +

Waiting for firmware.

+ +
+
+ +
+ +
+ +
+ + + + + +
+ +
+
+
+ + + + diff --git a/web/styles.css b/web/styles.css new file mode 100644 index 0000000..28dd336 --- /dev/null +++ b/web/styles.css @@ -0,0 +1,71 @@ +:root { + color-scheme: light; + font-family: Inter, ui-sans-serif, system-ui, sans-serif; + color: #20211f; + background: #e9e5dc; +} + +* { box-sizing: border-box; } + +body { margin: 0; } + +main { + width: min(100% - 2rem, 70rem); + margin: 0 auto; + padding: 2.5rem 0 4rem; +} + +header { margin-bottom: 1.5rem; } +header p { max-width: 42rem; } +h1 { margin: 0; font-size: clamp(2rem, 6vw, 4rem); line-height: 1; } +.eyebrow { margin-bottom: .5rem; font-size: .75rem; font-weight: 800; letter-spacing: .16em; text-transform: uppercase; } + +.boot-panel { + display: flex; + flex-wrap: wrap; + gap: 1rem; + align-items: end; + padding: 1rem; + border: 1px solid #c8c2b6; + border-radius: .75rem; + background: #f7f4ed; +} + +label { display: grid; gap: .4rem; font-size: .85rem; font-weight: 700; } +input, select, button { font: inherit; } +input, select { min-height: 2.6rem; padding: .5rem; border: 1px solid #aaa398; border-radius: .4rem; background: white; } +button { min-height: 2.75rem; padding: .55rem 1rem; border: 1px solid #55534e; border-radius: .5rem; color: #fff; background: #343532; font-weight: 750; cursor: pointer; touch-action: none; } +button:hover:not(:disabled) { background: #11120f; } +button:focus-visible { outline: 3px solid #d2752b; outline-offset: 2px; } +button:disabled { cursor: not-allowed; opacity: .4; } +button.active { transform: translateY(2px); background: #d2752b; } + +.status { min-height: 1.5rem; margin: 1rem 0; } +.status.error { color: #a52d20; font-weight: 700; } + +.device { + display: grid; + grid-template-columns: minmax(18rem, 33rem) minmax(14rem, 20rem); + gap: clamp(1.5rem, 5vw, 4rem); + align-items: center; +} + +.screen-shell { padding: 1.1rem; border-radius: 1.2rem; background: #30312e; box-shadow: 0 1rem 2.5rem #5d584c40; } +canvas { display: block; width: 100%; height: auto; background: #fff; image-rendering: pixelated; } + +.controls { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; align-items: center; } +.controls > button { min-height: 4rem; } +.power { background: #8e3228; } +.dpad { grid-column: 1 / -1; display: grid; grid-template: repeat(3, 4.25rem) / repeat(3, 4.25rem); justify-content: center; } +.dpad button { border-radius: 50%; padding: .25rem; } +.up { grid-area: 1 / 2; } +.left { grid-area: 2 / 1; } +.confirm { grid-area: 2 / 2; } +.right { grid-area: 2 / 3; } +.down { grid-area: 3 / 2; } + +@media (max-width: 48rem) { + main { padding-top: 1.5rem; } + .device { grid-template-columns: 1fr; } + .controls { max-width: 24rem; width: 100%; margin: 0 auto; } +}