From 42c96a31a20636b803b8447c5f6ec9397b9fce96 Mon Sep 17 00:00:00 2001 From: Evan Reichard Date: Mon, 20 Jul 2026 16:34:19 -0400 Subject: [PATCH] more --- HANDOFF.md | 40 ++-- Makefile | 8 +- README.md | 13 +- ROADMAP.md | 11 +- qemu/patches/0002-qemu-wasm.patch | 323 +++++++++++++++++++++++++++++- scripts/build-qemu-wasm.sh | 4 +- scripts/smoke-browser-bridge.mjs | 12 +- scripts/test-web.mjs | 15 +- web/app.js | 177 ++++++++++++++-- web/index.html | 34 +++- web/styles.css | 21 +- 11 files changed, 572 insertions(+), 86 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index 55f8861..f5b89c0 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -16,15 +16,16 @@ touching QEMU internals. Pairs with `ROADMAP.md` (the plan) — this is the `0002-qemu-wasm.patch` ports qemu-wasm's host backend onto the same pinned Espressif QEMU commit `40edccac415693c5130f91c01d84176ae6008566`. - `make qemu-wasm` produces a smoke-tested ES module, WASM binary, and pthread - worker under `dist/wasm/`. Next: static browser UI. Full X4 rendering waits. + worker under `dist/wasm/`; the static browser UI boots firmware, renders X3, + drives buttons, and exposes SD files/debug metrics. Full X4 rendering waits. --- ## 1. Environment / hardware realities (READ FIRST) -- **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`. +- A full QEMU build is large and can take several minutes. Build parallelism + defaults to `nproc`; set `JOBS` lower if memory pressure requires it and use + long timeouts 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 @@ -174,8 +175,8 @@ Base addresses from ESP-IDF `soc/esp32c3` headers; all confirmed live. - creates the selected panel on `spi2.bus`, wires GPIO out 21→CS, 4→DC, 5→RST, and panel BUSY→GPIO in 6 (X3 UC8253 renders; X4 SSD1677 is a blank stub); - creates `ssi-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); + `sd-card-spi` backed by `drive_get(IF_SD, 0, 0)`. Native uses a raw image; + WASM uses QEMU `vvfat` over the JS-created MEMFS `/sdcard` directory; - **defaults both CS lines high** at init (`qemu_set_irq(cs, 1)`) so nothing is selected before the firmware drives GPIO. @@ -283,21 +284,26 @@ ADC ladders on GPIO1 & GPIO2; power button GPIO3; UC8253 @ 16 MHz. - 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. +- Firmware and ROM are written to MEMFS before QEMU starts. Browser SD uses + QEMU `vvfat` to expose `/sdcard` as a writable 32 GiB FAT32 volume; memory + scales with actual directory files plus about 5 MiB of FAT/write bookkeeping, + not logical capacity. The initial JS provider creates `Test/example.txt`. +- The inspector has Logs, Files, and Debug tabs. Debug reports committed WASM + heap (176.3 MiB after a 90-second validation run), visible MEMFS bytes, SD file + bytes, and browser JS heap when supported. +- A second boot needs a page reload because QEMU machine selection and global + state are process-wide. Next: -0. **Boot stall**: firmware freezes after X3 panel init (display generation stays - at 2). Compare against native `make run` (which reaches the home screen); - suspect timers/interrupts or just slow TCG. `_scratch/dump-fb3.mjs` reproduces. -1. **Browser SD storage**: add an uploaded or OPFS-backed SD block device. +1. **Browser SD editing/persistence**: add file controls and optionally OPFS. + Live host-side changes to a mounted FAT volume are intentionally accepted as + unsafe; editing is not implemented yet. -> Patch workflow: `scripts/build-qemu-wasm.sh` only re-applies -> `qemu/patches/0002-qemu-wasm.patch` when the WASM source tree lacks the xteink -> files. After editing the patch, `rm -rf _scratch/qemu-wasm-src` so `make -> qemu-wasm` re-clones and re-applies it. The patch is regenerated from the -> `_scratch/qemu-fix-src` worktree via `git diff --binary 610f8c69..HEAD`. +> Patch workflow: `scripts/build-qemu-wasm.sh` resets/reapplies patches when +> either patch is newer than `.xteink-patch-stamp`. Regenerate patch 0002 from +> `_scratch/qemu-fix-src` with `git diff --binary 610f8c69 > +> qemu/patches/0002-qemu-wasm.patch`. 2. **Full X4 rendering later**: SSD1677 is 800×480 controller / 480×800 portrait, BUSY active-high. Implement its RAM/window/update commands when needed. diff --git a/Makefile b/Makefile index 22433e6..294fe58 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 blank-sd-asset qemu qemu-wasm web web-test 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 \ @@ -23,12 +23,6 @@ firmware: sdimage: scripts/mksd.sh $(SD_SRC) sd.img -blank-sd-asset: - @tmpdir=$$(mktemp -d); trap 'rm -rf "$$tmpdir"' EXIT; \ - mkdir "$$tmpdir/empty"; \ - SIZE_MB=64 scripts/mksd.sh "$$tmpdir/empty" "$$tmpdir/sd.img"; \ - gzip -n -9 -c "$$tmpdir/sd.img" > web/assets/blank-sd.img.gz - qemu: QEMU_SRC=$(QEMU_SRC) scripts/build-qemu.sh diff --git a/README.md b/README.md index 778bfbc..d282920 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ buttons. See [ROADMAP.md](./ROADMAP.md) for architecture and plan. git clone --recursive https://github.com/crosspoint-reader/crosspoint-reader ../crosspoint-reader # Build ../crosspoint-reader's PlatformIO `default` environment first. nix develop -make qemu # clone espressif/qemu @ pinned commit, apply patch, build (JOBS=2) +make qemu # clone espressif/qemu @ pinned commit, apply patch, build make firmware sdimage # 16 MB flash.bin + FAT32 sd.img from the PlatformIO build make run # X3 (default) make run VARIANT=x4 # X4 detection + blank SSD1677 stub @@ -54,11 +54,12 @@ 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 now boots in WASM: it runs past the earlier allocator and -function-pointer signature crashes and initializes the X3 panel (528×792). It -still stalls before painting a full home screen (the display generation counter -freezes after early init), so end-to-end boot is not complete. X4 remains a -white display stub, and browser SD storage is not implemented yet. +Real firmware boots in WASM and drives the X3 panel (528×792). The browser SD +uses QEMU's directory-backed FAT provider: the guest sees a writable, ephemeral +32 GiB FAT32 card while memory use scales with actual files instead of logical +capacity. It starts with `Test/example.txt`; inspect it in the Files tab. The +Debug tab reports committed WASM heap, visible MEMFS data, SD file bytes, and +browser JS heap where supported. X4 remains a white display stub. ## Contents diff --git a/ROADMAP.md b/ROADMAP.md index 5fa15d3..0205048 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -38,9 +38,8 @@ 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/`; 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 now boots past the allocator -and function-pointer crashes and initializes the X3 panel, but still stalls -before painting a full home screen. +UI for firmware, canvas, buttons, logs, SD files, and debug metrics. Real +firmware boots in WASM and drives the X3 panel. ### Device selection (X3 vs X4) @@ -59,7 +58,7 @@ reuses unchanged — no bespoke abstraction layer: | 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) | +| SD card | `ssi-sd` + `sd-card-spi` backed by QEMU block layer (`-drive if=sd`) | QEMU `vvfat` over a JS-created MEMFS directory; future persistence can use OPFS | | Buttons + battery | ADC QOM props `adci[1]`/`adci[2]`; GPIO QOM `input-level[3]` for Power | DOM buttons → `qom-set` equivalent | | Firmware image | flash via `-drive if=mtd` | uploaded `.bin` written to emulated flash | @@ -86,8 +85,8 @@ reuses unchanged — no bespoke abstraction layer: ## Remaining phases -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. +1. **Browser SD editing/persistence** — add file controls and optionally OPFS; + the current 32 GiB logical card is writable and ephemeral. 2. **Full X4 panel** — implement SSD1677 RAM/window/update commands when visible X4 rendering is needed; the selection, dimensions, wiring, and stub exist. diff --git a/qemu/patches/0002-qemu-wasm.patch b/qemu/patches/0002-qemu-wasm.patch index fc27fcd..325c7c6 100644 --- a/qemu/patches/0002-qemu-wasm.patch +++ b/qemu/patches/0002-qemu-wasm.patch @@ -224,6 +224,30 @@ index 49814ec4af..0715610d37 100644 assert(tcg_enabled()); g_assert(!icount_enabled()); +diff --git c/accel/tcg/tcg-accel-ops-rr.c w/accel/tcg/tcg-accel-ops-rr.c +index 8ebadf8e9e..1e38df86e8 100644 +--- c/accel/tcg/tcg-accel-ops-rr.c ++++ w/accel/tcg/tcg-accel-ops-rr.c +@@ -177,11 +177,19 @@ static int rr_cpu_count(void) + * elsewhere. + */ + ++#if defined(EMSCRIPTEN) && !defined(CONFIG_TCG_INTERPRETER) ++#include "../../tcg/wasm32.h" ++#endif ++ + static void *rr_cpu_thread_fn(void *arg) + { + Notifier force_rcu; + CPUState *cpu = arg; + ++#if defined(EMSCRIPTEN) && !defined(CONFIG_TCG_INTERPRETER) ++ init_wasm32(); ++#endif ++ + assert(tcg_enabled()); + rcu_register_thread(); + force_rcu.notify = rr_force_rcu; diff --git c/backends/meson.build w/backends/meson.build index da714b93d1..9b88d22685 100644 --- c/backends/meson.build @@ -278,6 +302,223 @@ index 90fa54352c..0fa9cc31b6 100644 /* * parse_zone - Fill a zone descriptor +diff --git c/block/vvfat.c w/block/vvfat.c +index 8ffe8b3b9b..4cf02d9a55 100644 +--- c/block/vvfat.c ++++ w/block/vvfat.c +@@ -749,6 +749,7 @@ static int read_directory(BDRVVVFATState* s, int mapping_index) + const char* dirname = mapping->path; + int first_cluster = mapping->begin; + int parent_index = mapping->info.dir.parent_mapping_index; ++ bool is_root = parent_index < 0; + mapping_t* parent_mapping = (mapping_t*) + (parent_index >= 0 ? array_get(&(s->mapping), parent_index) : NULL); + int first_cluster_of_parent = parent_mapping ? parent_mapping->begin : -1; +@@ -765,9 +766,9 @@ static int read_directory(BDRVVVFATState* s, int mapping_index) + } + + i = mapping->info.dir.first_dir_index = +- first_cluster == 0 ? 0 : s->directory.next; ++ is_root ? 0 : s->directory.next; + +- if (first_cluster != 0) { ++ if (!is_root) { + /* create the top entries of a subdirectory */ + (void)create_short_and_long_name(s, i, ".", 1); + (void)create_short_and_long_name(s, i, "..", 1); +@@ -781,13 +782,14 @@ static int read_directory(BDRVVVFATState* s, int mapping_index) + int is_dot=!strcmp(entry->d_name,"."); + int is_dotdot=!strcmp(entry->d_name,".."); + +- if (first_cluster == 0 && s->directory.next >= s->root_entries - 1) { ++ if (is_root && s->fat_type != 32 && ++ s->directory.next >= s->root_entries - 1) { + fprintf(stderr, "Too many entries in root directory\n"); + closedir(dir); + return -2; + } + +- if(first_cluster == 0 && (is_dotdot || is_dot)) ++ if(is_root && (is_dotdot || is_dot)) + continue; + + buffer = g_malloc(length); +@@ -860,8 +862,7 @@ static int read_directory(BDRVVVFATState* s, int mapping_index) + memset(direntry,0,sizeof(direntry_t)); + } + +- if (s->fat_type != 32 && +- mapping_index == 0 && ++ if (s->fat_type != 32 && is_root && + s->directory.next < s->root_entries) { + /* root directory */ + int cur = s->directory.next; +@@ -877,20 +878,24 @@ static int read_directory(BDRVVVFATState* s, int mapping_index) + * 0x20 / s->cluster_size; + mapping->end = first_cluster; + +- direntry = array_get(&(s->directory), mapping->dir_index); +- set_begin_of_direntry(direntry, mapping->begin); ++ if (!is_root) { ++ direntry = array_get(&(s->directory), mapping->dir_index); ++ set_begin_of_direntry(direntry, mapping->begin); ++ } + + return 0; + } + + static inline int32_t sector2cluster(BDRVVVFATState* s,off_t sector_num) + { +- return (sector_num - s->offset_to_root_dir) / s->sectors_per_cluster; ++ return (sector_num - s->offset_to_root_dir) / s->sectors_per_cluster ++ + (s->fat_type == 32 ? 2 : 0); + } + + static inline off_t cluster2sector(BDRVVVFATState* s, uint32_t cluster_num) + { +- return s->offset_to_root_dir + s->sectors_per_cluster * cluster_num; ++ return s->offset_to_root_dir + s->sectors_per_cluster ++ * (cluster_num - (s->fat_type == 32 ? 2 : 0)); + } + + static int init_directories(BDRVVVFATState* s, +@@ -934,11 +939,12 @@ static int init_directories(BDRVVVFATState* s, + init_fat(s); + + /* TODO: if there are more entries, bootsector has to be adjusted! */ +- s->root_entries = 0x02 * 0x10 * s->sectors_per_cluster; ++ s->root_entries = s->fat_type == 32 ? 0 : ++ 0x02 * 0x10 * s->sectors_per_cluster; + s->cluster_count=sector2cluster(s, s->sector_count); + + mapping = array_get_next(&(s->mapping)); +- mapping->begin = 0; ++ mapping->begin = s->fat_type == 32 ? 2 : 0; + mapping->dir_index = 0; + mapping->info.dir.parent_mapping_index = -1; + mapping->first_mapping_index = -1; +@@ -950,11 +956,10 @@ static int init_directories(BDRVVVFATState* s, + mapping->read_only = 0; + s->path = mapping->path; + +- for (i = 0, cluster = 0; i < s->mapping.next; i++) { +- /* MS-DOS expects the FAT to be 0 for the root directory +- * (except for the media byte). */ +- /* LATER TODO: still true for FAT32? */ +- int fix_fat = (i != 0); ++ for (i = 0, cluster = s->fat_type == 32 ? 2 : 0; ++ i < s->mapping.next; i++) { ++ /* FAT12/16 stores the root directory outside the cluster chain. */ ++ int fix_fat = (i != 0 || s->fat_type == 32); + mapping = array_get(&(s->mapping), i); + + if (mapping->mode & MODE_DIRECTORY) { +@@ -1026,22 +1031,35 @@ static int init_directories(BDRVVVFATState* s, + /* media descriptor: hard disk=0xf8, floppy=0xf0 */ + bootsector->media_type = (s->offset_to_bootsector > 0 ? 0xf8 : 0xf0); + s->fat.pointer[0] = bootsector->media_type; +- bootsector->sectors_per_fat=cpu_to_le16(s->sectors_per_fat); ++ bootsector->sectors_per_fat = s->fat_type == 32 ? 0 : ++ cpu_to_le16(s->sectors_per_fat); + bootsector->sectors_per_track = cpu_to_le16(secs); + bootsector->number_of_heads = cpu_to_le16(heads); + bootsector->hidden_sectors = cpu_to_le32(s->offset_to_bootsector); + bootsector->total_sectors=cpu_to_le32(s->sector_count>0xffff?s->sector_count:0); + +- /* LATER TODO: if FAT32, this is wrong */ +- /* drive_number: fda=0, hda=0x80 */ +- bootsector->u.fat16.drive_number = s->offset_to_bootsector == 0 ? 0 : 0x80; +- bootsector->u.fat16.signature=0x29; +- bootsector->u.fat16.id=cpu_to_le32(0xfabe1afd); +- +- memcpy(bootsector->u.fat16.volume_label, s->volume_label, +- sizeof(bootsector->u.fat16.volume_label)); +- memcpy(bootsector->u.fat16.fat_type, +- s->fat_type == 12 ? "FAT12 " : "FAT16 ", 8); ++ if (s->fat_type == 32) { ++ bootsector->u.fat32.sectors_per_fat = cpu_to_le32(s->sectors_per_fat); ++ bootsector->u.fat32.first_cluster_of_root_dir = cpu_to_le32(2); ++ bootsector->u.fat32.info_sector = cpu_to_le16(0xffff); ++ bootsector->u.fat32.backup_boot_sector = cpu_to_le16(0xffff); ++ bootsector->u.fat32.drive_number = 0x80; ++ bootsector->u.fat32.signature = 0x29; ++ bootsector->u.fat32.id = cpu_to_le32(0xfabe1afd); ++ memcpy(bootsector->u.fat32.volume_label, s->volume_label, ++ sizeof(bootsector->u.fat32.volume_label)); ++ memcpy(bootsector->u.fat32.fat_type, "FAT32 ", 8); ++ } else { ++ /* drive_number: fda=0, hda=0x80 */ ++ bootsector->u.fat16.drive_number = ++ s->offset_to_bootsector == 0 ? 0 : 0x80; ++ bootsector->u.fat16.signature=0x29; ++ bootsector->u.fat16.id=cpu_to_le32(0xfabe1afd); ++ memcpy(bootsector->u.fat16.volume_label, s->volume_label, ++ sizeof(bootsector->u.fat16.volume_label)); ++ memcpy(bootsector->u.fat16.fat_type, ++ s->fat_type == 12 ? "FAT12 " : "FAT16 ", 8); ++ } + bootsector->magic[0]=0x55; bootsector->magic[1]=0xaa; + + return 0; +@@ -1139,6 +1157,7 @@ static int vvfat_open(BlockDriverState *bs, QDict *options, int flags, + { + BDRVVVFATState *s = bs->opaque; + int cyls, heads, secs; ++ uint64_t total_sectors; + bool floppy; + const char *dirname, *label; + QemuOpts *opts; +@@ -1205,7 +1224,9 @@ static int vvfat_open(BlockDriverState *bs, QDict *options, int flags, + + switch (s->fat_type) { + case 32: ++#ifndef EMSCRIPTEN + warn_report("FAT32 has not been tested. You are welcome to do so!"); ++#endif + break; + case 16: + case 12: +@@ -1219,8 +1240,17 @@ static int vvfat_open(BlockDriverState *bs, QDict *options, int flags, + + s->bs = bs; + +- /* LATER TODO: if FAT32, adjust */ +- s->sectors_per_cluster=0x10; ++#ifdef EMSCRIPTEN ++ if (!floppy && s->fat_type == 32) { ++ /* Browser SD Geometry - A larger cluster keeps the synthesized 32 GiB FAT near 4 MiB. */ ++ s->sectors_per_cluster = 0x40; ++ total_sectors = 32ULL * 1024 * 1024 * 1024 / BDRV_SECTOR_SIZE; ++ } else ++#endif ++ { ++ s->sectors_per_cluster = 0x10; ++ total_sectors = (uint64_t)cyls * heads * secs; ++ } + + s->current_cluster=0xffffffff; + +@@ -1232,8 +1262,8 @@ static int vvfat_open(BlockDriverState *bs, QDict *options, int flags, + DLOG(fprintf(stderr, "vvfat %s chs %d,%d,%d\n", + dirname, cyls, heads, secs)); + +- s->sector_count = cyls * heads * secs - s->offset_to_bootsector; +- bs->total_sectors = cyls * heads * secs; ++ s->sector_count = total_sectors - s->offset_to_bootsector; ++ bs->total_sectors = total_sectors; + + if (qemu_opt_get_bool(opts, "rw", false)) { + if (!bdrv_is_read_only(bs)) { +@@ -1523,7 +1553,8 @@ vvfat_read(BlockDriverState *bs, int64_t sector_num, uint8_t *buf, int nb_sector + } else { + uint32_t sector = sector_num - s->offset_to_root_dir, + sector_offset_in_cluster=(sector%s->sectors_per_cluster), +- cluster_num=sector/s->sectors_per_cluster; ++ cluster_num=sector/s->sectors_per_cluster ++ + (s->fat_type == 32 ? 2 : 0); + if(cluster_num > s->cluster_count || read_cluster(s, cluster_num) != 0) { + /* LATER TODO: strict: return -1; */ + memset(buf+i*0x200,0,0x200); diff --git c/configs/devices/riscv32-softmmu/xteink.mak w/configs/devices/riscv32-softmmu/xteink.mak new file mode 100644 index 0000000000..a9179b4119 @@ -613,6 +854,39 @@ index 1879b5c2df..e3f09cc51b 100644 } static void esp32c3_cache_init(Object *obj) +diff --git c/hw/misc/esp32c3_jtag.c w/hw/misc/esp32c3_jtag.c +index 82ee69717b..64a971f183 100644 +--- c/hw/misc/esp32c3_jtag.c ++++ w/hw/misc/esp32c3_jtag.c +@@ -16,10 +16,18 @@ + #include "hw/misc/esp32c3_jtag.h" + + ++/* USB Serial JTAG Console Tap - The firmware routes ESP-IDF console output (incl. panic dumps) through this peripheral. EP1 is the byte FIFO; EP1_CONF bit1 (SERIAL_IN_EP_DATA_FREE) must read set so the driver believes the TX FIFO can always accept a byte. */ ++#define ESP32C3_JTAG_EP1_REG 0x00 ++#define ESP32C3_JTAG_EP1_CONF_REG 0x04 ++#define ESP32C3_JTAG_IN_EP_DATA_FREE (1u << 1) ++ + static uint64_t esp32c3_jtag_read(void *opaque, hwaddr addr, unsigned int size) + { + ESP32C3UsbJtagState *s = ESP32C3_JTAG(opaque); + (void) s; ++ if (addr == ESP32C3_JTAG_EP1_CONF_REG) { ++ return ESP32C3_JTAG_IN_EP_DATA_FREE; ++ } + return 0; + } + +@@ -27,6 +35,9 @@ static void esp32c3_jtag_write(void *opaque, hwaddr addr, uint64_t value, unsign + { + ESP32C3UsbJtagState *s = ESP32C3_JTAG(opaque); + (void) s; ++ if (addr == ESP32C3_JTAG_EP1_REG) { ++ fputc((int)(value & 0xff), stderr); ++ } + } + + static const MemoryRegionOps esp32c3_jtag_ops = { diff --git c/hw/misc/esp_sha.c w/hw/misc/esp_sha.c index c9fa2e610a..cc186595eb 100644 --- c/hw/misc/esp_sha.c @@ -707,7 +981,7 @@ index c9fa2e610a..cc186595eb 100644 }, }; diff --git c/hw/riscv/esp32c3.c w/hw/riscv/esp32c3.c -index 2f1ccbfbb0..febc96bfe3 100644 +index 2f1ccbfbb0..af8bb86c97 100644 --- c/hw/riscv/esp32c3.c +++ w/hw/riscv/esp32c3.c @@ -57,6 +57,10 @@ @@ -721,7 +995,7 @@ index 2f1ccbfbb0..febc96bfe3 100644 #define ESP32C3_IO_WARNING 0 #define ESP32C3_RESET_ADDRESS 0x40000000 -@@ -103,6 +107,27 @@ struct Esp32C3MachineState { +@@ -103,6 +107,29 @@ struct Esp32C3MachineState { Esp32C3TWAIState twai; }; @@ -732,6 +1006,7 @@ index 2f1ccbfbb0..febc96bfe3 100644 +{ + if (xteink_wasm_machine && channel >= 0 && + channel < ESP32C3_ADC_CHANNELS) { ++ fprintf(stderr, "[button] adc channel=%d value=%u\n", channel, value); + qatomic_set(&xteink_wasm_machine->adc.input[channel], + MIN(value, UINT32_C(0xfff))); + } @@ -740,6 +1015,7 @@ index 2f1ccbfbb0..febc96bfe3 100644 +EMSCRIPTEN_KEEPALIVE void xteink_wasm_set_gpio(int pin, int level) +{ + if (xteink_wasm_machine && pin >= 0 && pin < ESP32_GPIO_COUNT) { ++ fprintf(stderr, "[button] gpio pin=%d level=%d\n", pin, level); + esp32_gpio_set_input_level(&xteink_wasm_machine->gpio.parent, + pin, level); + } @@ -749,7 +1025,7 @@ index 2f1ccbfbb0..febc96bfe3 100644 /* 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) +@@ -421,12 +448,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); @@ -768,7 +1044,7 @@ index 2f1ccbfbb0..febc96bfe3 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 +515,9 @@ static void esp32c3_machine_init(MachineState *machine) +@@ -484,7 +517,9 @@ static void esp32c3_machine_init(MachineState *machine) /* SPI1 controller (SPI Flash) */ { @@ -778,7 +1054,7 @@ index 2f1ccbfbb0..febc96bfe3 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 +572,9 @@ static void esp32c3_machine_init(MachineState *machine) +@@ -539,7 +574,9 @@ static void esp32c3_machine_init(MachineState *machine) if (blk) { ms->cache.flash_blk = blk; } @@ -788,7 +1064,7 @@ index 2f1ccbfbb0..febc96bfe3 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 +670,7 @@ static void esp32c3_machine_init(MachineState *machine) +@@ -635,6 +672,7 @@ static void esp32c3_machine_init(MachineState *machine) qdev_get_gpio_in(intmatrix_dev, ETS_SHA_INTR_SOURCE)); } @@ -796,7 +1072,7 @@ index 2f1ccbfbb0..febc96bfe3 100644 /* AES realization */ { ms->aes.parent.gdma = ESP_GDMA(&ms->gdma); -@@ -654,6 +690,8 @@ static void esp32c3_machine_init(MachineState *machine) +@@ -654,6 +692,8 @@ static void esp32c3_machine_init(MachineState *machine) qdev_get_gpio_in(intmatrix_dev, ETS_RSA_INTR_SOURCE)); } @@ -805,7 +1081,7 @@ index 2f1ccbfbb0..febc96bfe3 100644 /* HMAC realization */ { ms->hmac.parent.efuse = ESP_EFUSE(&ms->efuse); -@@ -662,6 +700,7 @@ static void esp32c3_machine_init(MachineState *machine) +@@ -662,6 +702,7 @@ static void esp32c3_machine_init(MachineState *machine) memory_region_add_subregion_overlap(sys_mem, DR_REG_HMAC_BASE, mr, 0); } @@ -813,7 +1089,7 @@ index 2f1ccbfbb0..febc96bfe3 100644 /* Digital Signature realization */ { ms->ds.parent.hmac = ESP_HMAC(&ms->hmac); -@@ -682,6 +721,8 @@ static void esp32c3_machine_init(MachineState *machine) +@@ -682,6 +723,8 @@ static void esp32c3_machine_init(MachineState *machine) memory_region_add_subregion_overlap(sys_mem, DR_REG_AES_XTS_BASE, mr, 0); } @@ -822,7 +1098,7 @@ index 2f1ccbfbb0..febc96bfe3 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) +@@ -706,6 +749,9 @@ static void xteink_machine_init(MachineState *machine) Esp32C3MachineState *ms = ESP32C3_MACHINE(machine); ms->xteink = true; esp32c3_machine_init(machine); @@ -832,6 +1108,33 @@ index 2f1ccbfbb0..febc96bfe3 100644 /* 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 c/hw/riscv/esp32c3_intmatrix.c w/hw/riscv/esp32c3_intmatrix.c +index c1376b7299..1275808dc0 100644 +--- c/hw/riscv/esp32c3_intmatrix.c ++++ w/hw/riscv/esp32c3_intmatrix.c +@@ -25,9 +25,10 @@ + #define INTMATRIX_DEBUG 0 + #define INTMATRIX_WARNING 0 + +-#define BIT_SET(reg, bit) ((reg) & BIT(bit)) +-#define CLEAR_BIT(reg, bit) do { (reg) &= ~BIT(bit); } while(0) +-#define SET_BIT(reg, bit) do { (reg) |= BIT(bit); } while(0) ++/* 64-bit Shifts For irq_levels - There are up to 62 interrupt sources, so bit ops on the uint64_t level mask must shift in 64-bit. QEMU's BIT() is `1UL << nr`, which is only 32-bit on wasm32 and silently aliases source N to N-32. */ ++#define BIT_SET(reg, bit) ((reg) & (1ULL << (bit))) ++#define CLEAR_BIT(reg, bit) do { (reg) &= ~(1ULL << (bit)); } while(0) ++#define SET_BIT(reg, bit) do { (reg) |= (1ULL << (bit)); } while(0) + + + static int esp32c3_get_output_line_level(ESP32C3IntMatrixState *s, int line) +@@ -36,7 +37,7 @@ static int esp32c3_get_output_line_level(ESP32C3IntMatrixState *s, int line) + + for (int i = 0; level_shared == 0 && i < ESP32C3_INT_MATRIX_INPUTS; i++) { + const uint_fast8_t mapped = s->irq_map[i]; +- if (mapped == line && (s->irq_levels & BIT(i))) ++ if (mapped == line && (s->irq_levels & (1ULL << i))) + { + level_shared |= 1; + } diff --git c/include/exec/exec-all.h w/include/exec/exec-all.h index 2e4c4cc4b4..99e6da08c9 100644 --- c/include/exec/exec-all.h diff --git a/scripts/build-qemu-wasm.sh b/scripts/build-qemu-wasm.sh index f05b858..ab11aeb 100755 --- a/scripts/build-qemu-wasm.sh +++ b/scripts/build-qemu-wasm.sh @@ -47,7 +47,7 @@ printf 'Building Emscripten environment (qemu-wasm %s)\n' "$QEMU_WASM_COMMIT" FLAGS="-O3 -Wno-error=unused-command-line-argument -matomics -mbulk-memory -DNDEBUG -DG_DISABLE_ASSERT -D_GNU_SOURCE -sASYNCIFY=1 -pthread -sPROXY_TO_PTHREAD=1 -sFORCE_FILESYSTEM -sALLOW_TABLE_GROWTH -sINITIAL_MEMORY=64MB -sALLOW_MEMORY_GROWTH=1 -sMAXIMUM_MEMORY=1GB -sSTACK_SIZE=1048576 -sWASM_BIGINT -sMALLOC=mimalloc -sEXPORT_ES6=1 -sASYNCIFY_IMPORTS=ffi_call_js" -if [ ! -f build/build.ninja ]; then +if [ ! -f build/build.ninja ] || ! grep -q 'block/vvfat.c' build/build.ninja; then "$ENGINE" run --rm -v "$SRC:/qemu" "$IMAGE" bash -lc " cd /qemu emconfigure ./configure \\ @@ -59,6 +59,8 @@ if [ ! -f build/build.ninja ]; then --without-default-devices \\ --with-devices-riscv32=xteink \\ --enable-system \\ + --enable-qcow1 \\ + --enable-vvfat \\ --with-coroutine=fiber \\ --disable-werror \\ --disable-docs \\ diff --git a/scripts/smoke-browser-bridge.mjs b/scripts/smoke-browser-bridge.mjs index 2402705..d27bb28 100644 --- a/scripts/smoke-browser-bridge.mjs +++ b/scripts/smoke-browser-bridge.mjs @@ -1,7 +1,6 @@ import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import { pathToFileURL } from 'node:url'; -import { gunzipSync } from 'node:zlib'; const artifactRoot = process.argv[2]; if (!artifactRoot) { @@ -10,22 +9,23 @@ if (!artifactRoot) { const moduleUrl = pathToFileURL(`${artifactRoot}/qemu-system-riscv32.js`); const rom = new Uint8Array(await readFile(`${artifactRoot}/esp32c3-rom.bin`)); -const sdCard = gunzipSync(await readFile('web/assets/blank-sd.img.gz')); const { default: createQemu } = await import(moduleUrl); const options = { arguments: [ '-S', '-machine', 'xteink,variant=x3', '-accel', 'tcg,tb-size=16', '-L', '/bios', '-display', 'none', '-serial', 'null', '-nic', 'none', '-drive', 'file=/flash.bin,if=mtd,format=raw', - '-drive', 'file=/sd.img,if=sd,format=raw', + '-drive', 'file=fat:32:rw:/sdcard,if=sd,format=raw', ], mainScriptUrlOrBlob: moduleUrl.href, }; options.preRun = [() => { - options.FS.mkdir('/bios'); + options.FS.mkdirTree('/bios'); + options.FS.mkdirTree('/var/tmp'); + options.FS.mkdirTree('/sdcard/Test'); options.FS.writeFile('/bios/esp32c3-rom.bin', rom); options.FS.writeFile('/flash.bin', new Uint8Array(16 * 1024 * 1024)); - options.FS.writeFile('/sd.img', sdCard); + options.FS.writeFile('/sdcard/Test/example.txt', 'Lorem ipsum'); }]; const module = await createQemu(options); @@ -35,6 +35,8 @@ const timer = setInterval(() => { clearInterval(timer); assert.equal(module._xteink_wasm_height(), 792); assert.ok(module._xteink_wasm_framebuffer()); + assert.ok(module.FS.readdir('/sdcard/Test').includes('example.txt')); + assert.equal(module.FS.readFile('/sdcard/Test/example.txt', { encoding: 'utf8' }), 'Lorem ipsum'); module._xteink_wasm_set_adc(1, 2694); module._xteink_wasm_set_gpio(3, 0); process.exit(0); diff --git a/scripts/test-web.mjs b/scripts/test-web.mjs index 7865907..19ebe75 100644 --- a/scripts/test-web.mjs +++ b/scripts/test-web.mjs @@ -1,6 +1,5 @@ import assert from 'node:assert/strict'; -import { readFile } from 'node:fs/promises'; -import { BUTTONS, setButton, mergeFirmware, loadEphemeralSdCard } from '../web/app.js'; +import { BUTTONS, DEFAULT_SD_FILES, EXAMPLE_SD_TEXT, formatBytes, setButton, mergeFirmware } from '../web/app.js'; // Merge - app image is placed at 0x10000 in a 0xFF-erased 16 MB flash with bootloader and partitions. const bootloader = new Uint8Array([1, 2, 3]); @@ -15,14 +14,10 @@ assert.equal(flash[0x10000], 0xe9); assert.equal(flash[0x20000], 0xff); assert.throws(() => mergeFirmware(new Uint8Array([0x00]), bootloader, partitions), /0xE9/); -const compressedSd = await readFile('web/assets/blank-sd.img.gz'); -const fetchSd = async () => new Response(compressedSd); -const firstSd = await loadEphemeralSdCard(import.meta.url, fetchSd); -const secondSd = await loadEphemeralSdCard(import.meta.url, fetchSd); -assert.equal(firstSd.length, 64 * 1024 * 1024); -assert.deepEqual(firstSd.slice(510, 512), new Uint8Array([0x55, 0xaa])); -firstSd[0] ^= 0xff; -assert.notEqual(firstSd[0], secondSd[0]); +assert.equal(DEFAULT_SD_FILES.get('Test/example.txt'), EXAMPLE_SD_TEXT); +assert.equal(EXAMPLE_SD_TEXT.split('Lorem ipsum').length - 1, 10); +assert.equal(formatBytes(512), '512 B'); +assert.equal(formatBytes(64 * 1024 * 1024), '64.0 MiB'); const calls = []; const module = { diff --git a/web/app.js b/web/app.js index 3eadb2d..bd9517e 100644 --- a/web/app.js +++ b/web/app.js @@ -38,16 +38,18 @@ async function fetchBin(url) { return new Uint8Array(await response.arrayBuffer()); } -export async function loadEphemeralSdCard(assetRoot = import.meta.url, fetchFn = fetch) { - const response = await fetchFn(new URL('assets/blank-sd.img.gz', assetRoot)); - if (!response.ok) { - throw new Error(`Could not load blank SD card (${response.status}).`); +const LOREM_IPSUM = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.'; +export const EXAMPLE_SD_TEXT = Array(10).fill(LOREM_IPSUM).join('\n\n'); +export const DEFAULT_SD_FILES = new Map([['Test/example.txt', EXAMPLE_SD_TEXT]]); + +function mountEphemeralSdCard(fs) { + for (const [path, contents] of DEFAULT_SD_FILES) { + const parts = path.split('/'); + const filename = parts.pop(); + const directory = `/sdcard/${parts.join('/')}`; + fs.mkdirTree(directory); + fs.writeFile(`${directory}/${filename}`, contents); } - if (!response.body || typeof DecompressionStream === 'undefined') { - throw new Error('This browser cannot decompress the blank SD card image.'); - } - const stream = response.body.pipeThrough(new DecompressionStream('gzip')); - return new Uint8Array(await new Response(stream).arrayBuffer()); } // A full 16 MB image is used as-is; anything else is treated as a CrossPoint app image and merged with the bundled bootloader and partition table. @@ -170,6 +172,145 @@ let persistLog = false; const logBuffer = []; const LOG_MAX_LINES = 1000; const DEBUG_LOG_KEY = 'xteink-debug-log'; +let activeModule = null; + +export function formatBytes(bytes) { + if (bytes < 1024) { + return `${bytes} B`; + } + const units = ['KiB', 'MiB', 'GiB']; + let value = bytes; + let unit = -1; + do { + value /= 1024; + unit++; + } while (value >= 1024 && unit < units.length - 1); + return `${value.toFixed(value >= 10 ? 1 : 2)} ${units[unit]}`; +} + +function sdEntries(module = activeModule) { + if (!module) { + return [ + { path: 'Test/', directory: true }, + ...[...DEFAULT_SD_FILES].map(([path, contents]) => ({ + path, + size: new TextEncoder().encode(contents).length, + read: () => contents, + })), + ]; + } + + const entries = []; + const visit = (directory, relative = '') => { + for (const name of module.FS.readdir(directory).filter(name => name !== '.' && name !== '..').sort()) { + const absolute = `${directory}/${name}`; + const path = relative ? `${relative}/${name}` : name; + const stat = module.FS.stat(absolute); + if (module.FS.isDir(stat.mode)) { + entries.push({ path: `${path}/`, directory: true }); + visit(absolute, path); + } else { + entries.push({ + path, + size: stat.size, + read: () => module.FS.readFile(absolute, { encoding: 'utf8' }), + }); + } + } + }; + visit('/sdcard'); + return entries; +} + +function renderFiles() { + const list = document.querySelector('#file-list'); + const preview = document.querySelector('#file-preview'); + const entries = sdEntries(); + list.replaceChildren(); + let firstFile = null; + + for (const entry of entries) { + if (entry.directory) { + const directory = document.createElement('div'); + directory.className = 'file-directory'; + directory.textContent = entry.path; + list.append(directory); + continue; + } + const button = document.createElement('button'); + button.type = 'button'; + const path = document.createElement('span'); + path.textContent = entry.path; + const size = document.createElement('small'); + size.textContent = formatBytes(entry.size); + button.append(path, size); + button.addEventListener('click', () => { + try { + preview.textContent = entry.read(); + } catch (error) { + preview.textContent = `Could not read ${entry.path}: ${error.message || error}`; + } + }); + list.append(button); + firstFile ||= button; + } + + if (firstFile) { + firstFile.click(); + } else { + preview.textContent = 'The SD card is empty.'; + } +} + +function pathBytes(fs, path) { + try { + const stat = fs.stat(path); + if (!fs.isDir(stat.mode)) { + return { bytes: stat.size, files: 1 }; + } + return fs.readdir(path) + .filter(name => name !== '.' && name !== '..') + .map(name => pathBytes(fs, `${path}/${name}`)) + .reduce((total, item) => ({ bytes: total.bytes + item.bytes, files: total.files + item.files }), { bytes: 0, files: 0 }); + } catch { + return { bytes: 0, files: 0 }; + } +} + +function renderDebug() { + const jsHeap = performance.memory?.usedJSHeapSize; + document.querySelector('#debug-js-heap').textContent = jsHeap ? formatBytes(jsHeap) : 'Unavailable in this browser'; + if (!activeModule) { + return; + } + const visible = ['/flash.bin', '/bios', '/sdcard', '/var/tmp'] + .map(path => pathBytes(activeModule.FS, path)) + .reduce((total, item) => ({ bytes: total.bytes + item.bytes, files: total.files + item.files }), { bytes: 0, files: 0 }); + const sd = pathBytes(activeModule.FS, '/sdcard'); + document.querySelector('#debug-wasm-heap').textContent = formatBytes(activeModule.HEAPU8.byteLength); + document.querySelector('#debug-memfs').textContent = `${formatBytes(visible.bytes)} in ${visible.files} files`; + document.querySelector('#debug-sd-files').textContent = `${formatBytes(sd.bytes)} in ${sd.files} files`; +} + +function initializeInspector() { + for (const tab of document.querySelectorAll('[role="tab"]')) { + tab.addEventListener('click', () => { + for (const candidate of document.querySelectorAll('[role="tab"]')) { + const selected = candidate === tab; + candidate.setAttribute('aria-selected', selected); + document.querySelector(`#${candidate.getAttribute('aria-controls')}`).hidden = !selected; + } + if (tab.dataset.tab === 'files') { + renderFiles(); + } else if (tab.dataset.tab === 'debug') { + renderDebug(); + } + }); + } + renderFiles(); + renderDebug(); + setInterval(renderDebug, 1000); +} function logLine(message) { logBuffer.push(message); @@ -188,7 +329,7 @@ function logLine(message) { } } -async function boot(file, variant, setStatus, sdCardProvider = loadEphemeralSdCard) { +async function boot(file, variant, setStatus) { if (!crossOriginIsolated) { throw new Error('This emulator requires COOP/COEP headers. Start it with `make web`.'); } @@ -196,12 +337,11 @@ async function boot(file, variant, setStatus, sdCardProvider = loadEphemeralSdCa const artifactRoot = new URL('../dist/wasm/', import.meta.url); const qemuUrl = new URL('qemu-system-riscv32.js', artifactRoot); - const [firmware, rom, sdCard] = await Promise.all([ + const [firmware, rom] = await Promise.all([ prepareFirmware(new Uint8Array(await file.arrayBuffer()), import.meta.url), fetchBin(new URL('esp32c3-rom.bin', artifactRoot)), - sdCardProvider(import.meta.url), ]); - const sdMessage = 'Using a blank ephemeral SD card; writes will be discarded when the emulator restarts.'; + const sdMessage = 'Using a 32 GiB ephemeral directory-backed SD card; writes will be discarded when the emulator restarts.'; console.log(sdMessage); logLine(sdMessage); const { default: createQemu } = await import(qemuUrl); @@ -220,7 +360,7 @@ async function boot(file, variant, setStatus, sdCardProvider = loadEphemeralSdCa '-nic', 'none', '-d', qemuLogFlags, '-drive', 'file=/flash.bin,if=mtd,format=raw', - '-drive', 'file=/sd.img,if=sd,format=raw', + '-drive', 'file=fat:32:rw:/sdcard,if=sd,format=raw', ], locateFile: path => new URL(path, artifactRoot).href, mainScriptUrlOrBlob: qemuUrl.href, @@ -229,10 +369,11 @@ async function boot(file, variant, setStatus, sdCardProvider = loadEphemeralSdCa stdin: () => null, }; options.preRun = [() => { - options.FS.mkdir('/bios'); + options.FS.mkdirTree('/bios'); + options.FS.mkdirTree('/var/tmp'); options.FS.writeFile('/bios/esp32c3-rom.bin', rom); options.FS.writeFile('/flash.bin', firmware); - options.FS.writeFile('/sd.img', sdCard); + mountEphemeralSdCard(options.FS); }]; setStatus('Starting emulator…'); @@ -258,6 +399,7 @@ function initialize() { sessionStorage.removeItem(DEBUG_LOG_KEY); logEl.textContent = ''; }); + initializeInspector(); const setStatus = (message, error = false) => { status.textContent = message; @@ -270,6 +412,9 @@ function initialize() { variantInput.disabled = true; try { const module = await boot(file, variant, setStatus); + activeModule = module; + renderFiles(); + renderDebug(); if (persistLog) { window.__xteinkModule = module; } diff --git a/web/index.html b/web/index.html index 0522a3e..cc0439b 100644 --- a/web/index.html +++ b/web/index.html @@ -49,12 +49,36 @@ -
-
- QEMU log - +
+
+ + + +
+ +
+
+ QEMU log + +
+

+      
+ + + + -

     
diff --git a/web/styles.css b/web/styles.css index 2a3bc9a..8b5c12b 100644 --- a/web/styles.css +++ b/web/styles.css @@ -71,7 +71,22 @@ canvas { display: block; width: 100%; height: auto; background: #fff; image-rend .workspace { grid-template-columns: 1fr; } } -.log-panel { min-width: 0; } -.log-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: .5rem; font-size: .85rem; color: #b9b3a4; } +.inspector { min-width: 0; overflow: hidden; border: 1px solid #c8c2b6; border-radius: .75rem; background: #f7f4ed; } +.tabs { display: grid; grid-template-columns: repeat(3, 1fr); border-bottom: 1px solid #c8c2b6; } +.tabs button { border: 0; border-right: 1px solid #c8c2b6; border-radius: 0; color: #56534d; background: transparent; } +.tabs button:last-child { border-right: 0; } +.tabs button[aria-selected="true"] { color: #fff; background: #343532; } +.tab-panel { padding: 1rem; } +.log-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: .5rem; font-size: .85rem; color: #625e55; } .log-header button { min-height: auto; padding: .25rem .75rem; font-size: .8rem; } -.log { margin: 0; max-height: 20rem; overflow: auto; padding: .75rem 1rem; border-radius: .6rem; background: #1c1d1a; color: #d6d0c2; font: .78rem/1.4 ui-monospace, monospace; white-space: pre-wrap; word-break: break-word; } +.log { margin: 0; max-height: 38rem; overflow: auto; padding: .75rem 1rem; border-radius: .6rem; background: #1c1d1a; color: #d6d0c2; font: .78rem/1.4 ui-monospace, monospace; white-space: pre-wrap; word-break: break-word; } +.file-list { display: grid; gap: .4rem; margin-bottom: 1rem; } +.file-directory { padding: .35rem .55rem; color: #625e55; font-weight: 700; } +.file-list button { display: flex; justify-content: space-between; gap: 1rem; width: 100%; min-height: 2.4rem; text-align: left; } +.file-list small { color: #cbc5b9; font-weight: 400; } +.file-preview { min-height: 12rem; max-height: 28rem; overflow: auto; margin: 0; padding: .75rem 1rem; border-radius: .6rem; background: #fff; white-space: pre-wrap; } +.debug-stats { display: grid; gap: .65rem; margin: 0; } +.debug-stats div { display: flex; justify-content: space-between; gap: 1rem; padding-bottom: .65rem; border-bottom: 1px solid #d8d2c7; } +.debug-stats dt { font-weight: 700; } +.debug-stats dd { margin: 0; text-align: right; } +.debug-note { margin: 1rem 0 0; color: #625e55; font-size: .8rem; }