feat(web): add initial browser emulator interface
This commit is contained in:
+14
-5
@@ -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) |
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+8
-5
@@ -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 | `<canvas>` 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
|
||||
|
||||
|
||||
@@ -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 <emscripten/emscripten.h>
|
||||
+
|
||||
+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 <emscripten/emscripten.h>
|
||||
+#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 <lwp.h>
|
||||
#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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
@@ -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 <artifact-directory>');
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -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],
|
||||
]);
|
||||
+204
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>xteink web emulator</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<header>
|
||||
<p class="eyebrow">ESP32-C3 emulator</p>
|
||||
<h1>xteink X3 / X4</h1>
|
||||
<p>Choose a merged 16 MB firmware image and boot it locally in your browser.</p>
|
||||
</header>
|
||||
|
||||
<form id="boot-form" class="boot-panel">
|
||||
<label>
|
||||
Firmware (.bin)
|
||||
<input id="firmware" name="firmware" type="file" accept=".bin,application/octet-stream" required>
|
||||
</label>
|
||||
<label>
|
||||
Device
|
||||
<select id="variant" name="variant">
|
||||
<option value="x3">X3</option>
|
||||
<option value="x4">X4 (display stub)</option>
|
||||
</select>
|
||||
</label>
|
||||
<button id="boot" type="submit">Boot firmware</button>
|
||||
</form>
|
||||
|
||||
<p id="status" class="status" role="status">Waiting for firmware.</p>
|
||||
|
||||
<section class="device" aria-label="Emulated xteink device">
|
||||
<div class="screen-shell">
|
||||
<canvas id="screen" width="528" height="792" aria-label="E-ink display"></canvas>
|
||||
</div>
|
||||
|
||||
<div id="controls" class="controls" aria-label="Device controls">
|
||||
<button type="button" data-button="back" disabled>Back</button>
|
||||
<div class="dpad">
|
||||
<button type="button" class="up" data-button="up" disabled aria-label="Up">▲</button>
|
||||
<button type="button" class="left" data-button="left" disabled aria-label="Left">◀</button>
|
||||
<button type="button" class="confirm" data-button="confirm" disabled>OK</button>
|
||||
<button type="button" class="right" data-button="right" disabled aria-label="Right">▶</button>
|
||||
<button type="button" class="down" data-button="down" disabled aria-label="Down">▼</button>
|
||||
</div>
|
||||
<button type="button" class="power" data-button="power" disabled>Power</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script type="module" src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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; }
|
||||
}
|
||||
Reference in New Issue
Block a user