This commit is contained in:
2026-07-20 16:34:19 -04:00
parent a87c9576fc
commit 42c96a31a2
11 changed files with 572 additions and 86 deletions
+23 -17
View File
@@ -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.
+1 -7
View File
@@ -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
+7 -6
View File
@@ -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
+5 -6
View File
@@ -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 | `<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) |
| 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.
+313 -10
View File
@@ -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
+3 -1
View File
@@ -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 \\
+7 -5
View File
@@ -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);
+5 -10
View File
@@ -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 = {
+161 -16
View File
@@ -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;
}
+29 -5
View File
@@ -49,12 +49,36 @@
</div>
</section>
<section class="log-panel" aria-label="Emulator logs">
<div class="log-header">
<span>QEMU log</span>
<button id="log-clear" type="button">Clear</button>
<section class="inspector" aria-label="Emulator inspector">
<div class="tabs" role="tablist" aria-label="Emulator information">
<button id="tab-logs" type="button" role="tab" aria-selected="true" aria-controls="panel-logs" data-tab="logs">Logs</button>
<button id="tab-files" type="button" role="tab" aria-selected="false" aria-controls="panel-files" data-tab="files">Files</button>
<button id="tab-debug" type="button" role="tab" aria-selected="false" aria-controls="panel-debug" data-tab="debug">Debug</button>
</div>
<div id="panel-logs" class="tab-panel" role="tabpanel" aria-labelledby="tab-logs">
<div class="log-header">
<span>QEMU log</span>
<button id="log-clear" type="button">Clear</button>
</div>
<pre id="log" class="log" role="log" aria-live="polite"></pre>
</div>
<div id="panel-files" class="tab-panel" role="tabpanel" aria-labelledby="tab-files" hidden>
<div id="file-list" class="file-list" aria-label="SD card files"></div>
<pre id="file-preview" class="file-preview">Select a file to view it.</pre>
</div>
<div id="panel-debug" class="tab-panel" role="tabpanel" aria-labelledby="tab-debug" hidden>
<dl class="debug-stats">
<div><dt>WASM heap committed</dt><dd id="debug-wasm-heap">Not started</dd></div>
<div><dt>Browser JS heap</dt><dd id="debug-js-heap">Unavailable</dd></div>
<div><dt>Visible MEMFS files</dt><dd id="debug-memfs">Not started</dd></div>
<div><dt>SD files stored</dt><dd id="debug-sd-files">1 file</dd></div>
<div><dt>Logical SD capacity</dt><dd>32 GiB</dd></div>
</dl>
<p class="debug-note">Committed heap is allocated WASM memory, not exact live malloc usage.</p>
</div>
<pre id="log" class="log" role="log" aria-live="polite"></pre>
</section>
</div>
</main>
+18 -3
View File
@@ -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; }