From 6e0e65218a7a015d6183284d8c70eed45861a066 Mon Sep 17 00:00:00 2001 From: Evan Reichard Date: Mon, 27 Jul 2026 12:39:55 -0400 Subject: [PATCH] feat(xteink): report and dump guest RAM usage Adds a 'memory' subcommand and matching web tab that show how much of each ESP32-C3 RAM window the guest has written, plus raw region dumps via QMP pmemsave. --- README.xteink.md | 4 +++- scripts/xteink/cli.py | 20 ++++++++++++++++++ scripts/xteink/emu.py | 31 ++++++++++++++++++++++++++++ scripts/xteink/web/__init__.py | 14 +++++++++++++ scripts/xteink/web/index.html | 37 ++++++++++++++++++++++++++++++++++ 5 files changed, 105 insertions(+), 1 deletion(-) diff --git a/README.xteink.md b/README.xteink.md index cecf6ec3ea..a62e9c0a12 100644 --- a/README.xteink.md +++ b/README.xteink.md @@ -41,11 +41,13 @@ nix run .#xteink-emu -- interactive --firmware firmware.bin --sdcard sdcard.img nix run .#xteink-emu -- wait --timeout 30 --match 'ready' nix run .#xteink-emu -- button bottom-2 --hold-ms 100 nix run .#xteink-emu -- screenshot --output screen.png +nix run .#xteink-emu -- memory +nix run .#xteink-emu -- memory --region iram --dump iram.bin nix run .#xteink-emu -- stop nix run .#xteink-emu -- web --host 127.0.0.1 --port 8080 ``` -The code lives in `scripts/xteink/` (`emu.py` drives QEMU, `cli.py` is the command line, `web/` is the browser UI); `scripts/xteink-emu.py` is the entry point. `web` serves a page that uploads firmware and an SD image, boots them, drives the buttons, streams the panel and serial log, and browses or edits the SD card through mtools. Card edits stop the emulator first, since QEMU caches the image; use *Apply & Reboot* to bring it back. The screen redraws off the panel's `frame-generation` counter, so it updates on each real e-ink refresh rather than on a timer. +The code lives in `scripts/xteink/` (`emu.py` drives QEMU, `cli.py` is the command line, `web/` is the browser UI); `scripts/xteink-emu.py` is the entry point. `web` serves a page that uploads firmware and an SD image, boots them, drives the buttons, streams the panel and serial log, and browses or edits the SD card through mtools. Card edits stop the emulator first, since QEMU caches the image; use *Apply & Reboot* to bring it back. `memory` reports how much of each RAM window the guest has written and dumps a region to a file; the web UI shows the same table under its *Memory* tab. QEMU zeroes RAM at reset, so the number is a high-water mark, not live heap — the firmware's own heap logging is authoritative for that. The screen redraws off the panel's `frame-generation` counter, so it updates on each real e-ink refresh rather than on a timer. State defaults to `/tmp/xteink-emu-$UID`; pass `--state-dir` to any subcommand for parallel instances. `interactive` opens the display and maps Left/Right, 1–4, and P to the matching device buttons. Hold P for a long power press and close the window to stop. `wait` advances a persistent log cursor after each match. The emulator exposes the open Wi-Fi network `qemu`; guest traffic uses QEMU's user-mode NAT. diff --git a/scripts/xteink/cli.py b/scripts/xteink/cli.py index 27f84fa0ff..1a384d9a38 100644 --- a/scripts/xteink/cli.py +++ b/scripts/xteink/cli.py @@ -81,6 +81,20 @@ def screenshot(args): print(emu.screendump(state, args.output, args.settle_ms)) +def memory(args): + state = state_dir(args) + emu.require_running(state) + if args.dump: + print(emu.dump_memory(state, args.region, args.dump)) + return + for row in emu.memory_usage(state): + percent = 100 * row["touched"] / row["size"] + print( + f"{row['region']:<7}{row['base']:#010x} {row['size'] // 1024:>4} KiB total" + f" {row['touched'] // 1024:>4} KiB touched ({percent:.0f}%)" + ) + + def web(args): serve(state_dir(args), args.host, args.port, args.log_bytes) @@ -134,6 +148,12 @@ def parser(): command.add_argument("--settle-ms", type=int, default=1500, help="wait for the panel to refresh before capturing") command.set_defaults(handler=screenshot) + command = commands.add_parser("memory", help="RAM high-water marks, or dump a region with --dump") + add_state(command) + command.add_argument("--region", choices=emu.MEMORY_REGIONS, default="dram") + command.add_argument("--dump", help="write the raw region to this file") + command.set_defaults(handler=memory) + command = commands.add_parser("web") add_state(command) command.add_argument("--host", default="127.0.0.1") diff --git a/scripts/xteink/emu.py b/scripts/xteink/emu.py index 0ce07bb7d9..e0b4fe5f99 100755 --- a/scripts/xteink/emu.py +++ b/scripts/xteink/emu.py @@ -31,6 +31,12 @@ BUTTONS = { "power": ("/machine/gpio", "input-level[3]", False, True), } VARIANTS = ("x3", "x4") +# ESP32-C3 RAM windows. dram and iram alias the same internal SRAM, so their numbers overlap. +MEMORY_REGIONS = { + "dram": (0x3FC80000, 0x60000), + "iram": (0x4037C000, 0x60000 + 16 * 1024), + "rtcram": (0x50000000, 0x2000), +} def default_state_dir(): @@ -302,6 +308,31 @@ def press(state, name, samples=4, timeout=90, hold_ms=500): await_samples(state, prop, samples, timeout) +def dump_memory(state, region, output): + base, size = MEMORY_REGIONS[region] + output = Path(output).expanduser().resolve() + output.parent.mkdir(parents=True, exist_ok=True) + qmp_command(state, "pmemsave", {"val": base, "size": size, "filename": str(output)}, timeout=30) + return output + + +def memory_usage(state): + """Bytes the guest has written per RAM region. + + QEMU zeroes RAM at reset, so a non-zero byte is one the firmware touched. That makes this a + high-water mark rather than live heap usage: freed blocks keep their old contents. + """ + scratch = state / "memory.bin" + rows = [] + try: + for region, (base, size) in MEMORY_REGIONS.items(): + data = dump_memory(state, region, scratch).read_bytes() + rows.append({"region": region, "base": base, "size": size, "touched": len(data) - data.count(0)}) + finally: + scratch.unlink(missing_ok=True) + return rows + + def screendump(state, output, settle_ms=0): # The e-ink panel needs a full refresh cycle before a capture shows the latest frame. if settle_ms: diff --git a/scripts/xteink/web/__init__.py b/scripts/xteink/web/__init__.py index 83aef1a8d4..f4752187bc 100644 --- a/scripts/xteink/web/__init__.py +++ b/scripts/xteink/web/__init__.py @@ -118,6 +118,20 @@ def serve(state, host, port, log_bytes): except UnicodeDecodeError: return self.reply_json({"binary": True, "size": len(data)}) return self.reply_json({"binary": False, "text": text}) + if route == "/memory": + emu.require_running(state) + return self.reply_json(emu.memory_usage(state)) + if route == "/memory/dump": + emu.require_running(state) + region = query.get("region", ["dram"])[0] + if region not in emu.MEMORY_REGIONS: + return self.send_error(404) + dump = emu.dump_memory(state, region, state / "web-memory.bin") + return self.reply( + dump.read_bytes(), + "application/octet-stream", + headers=[("Content-Disposition", f'attachment; filename="{region}.bin"')], + ) if route == "/log": # Tail only; a long-lived emulator log outgrows what the page can hold. return self.reply((state / "serial.log").read_bytes()[-log_bytes:]) diff --git a/scripts/xteink/web/index.html b/scripts/xteink/web/index.html index 5c2e2a6694..a158c936b1 100644 --- a/scripts/xteink/web/index.html +++ b/scripts/xteink/web/index.html @@ -120,6 +120,7 @@ img { display: block; width: 100%; height: auto; aspect-ratio: 528 / 792; backgr
+
@@ -130,6 +131,14 @@ img { display: block; width: 100%; height: auto; aspect-ratio: 528 / 792; backgr

       
+ +