Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
18a21c6
tools/qemu-wasm: add browser emulator for the wasm QEMU build
Aug 21, 2026
5e9f89d
tools/qemu-wasm: single-file emulator page and minimal QEMU build
Aug 22, 2026
8cee325
tools/qemu-wasm: add prebuilt site bundle for Pages deployment
Aug 22, 2026
a172d0f
tools/qemu-wasm: TCI performance patches
Aug 22, 2026
5ae2732
tools/qemu-wasm: TCG-to-wasm JIT overlay, 17 fps
Aug 22, 2026
827f5bd
tools/qemu-wasm: coerce pointer exports to Number for MEMORY64 builds
Aug 22, 2026
16e8de1
tools/qemu-wasm: import the runtime as qemu-system-arm.js
Aug 22, 2026
a76db26
tools/qemu-wasm: refresh site bundle with the JIT build
Aug 22, 2026
0a3fc0c
qemu-wasm: bridge UART1 to the browser via shared-memory rings
Aug 23, 2026
1a4c026
qemu-wasm: install apps from the browser (store link or .pbw)
Aug 23, 2026
8b1172a
qemu-wasm: refresh site bundle with app-install build
Aug 23, 2026
6f08ee5
qemu-wasm: add an Upload .pbw button to the install panel
Aug 23, 2026
a5aaba6
qemu-wasm: run PebbleKit JS apps in the browser
Aug 23, 2026
d5b5595
qemu-wasm: add the PebbleKit JS CORS proxy worker
Aug 23, 2026
18dcb62
qemu-wasm: ship PebbleKit JS in the site bundle
Aug 23, 2026
dc0677a
qemu-wasm: carry the pkjs-proxy worker into site deploys
Aug 23, 2026
fe8f51a
qemu-wasm: default to the deployed pkjs-proxy worker
Aug 23, 2026
1731f39
qemu-wasm: render Clay data: config pages in an in-page modal
Aug 23, 2026
c7391d3
qemu-wasm: surface pkjs logs in the on-page console
Aug 23, 2026
58add8d
qemu-wasm: log geolocation and fetch outcomes from app JS
Aug 23, 2026
ad14a94
qemu-wasm: robust geolocation for JS apps
Aug 23, 2026
13c978a
qemu-wasm: wire up the touchscreen and audio output
Aug 23, 2026
cde681f
qemu-wasm: persistence, autoboot, console input, sensors, gabbro
Aug 23, 2026
3835de5
qemu-wasm: readable console, notifications, voice dictation
Aug 23, 2026
3a2438c
qemu-wasm: keep the board selector active while running
Aug 23, 2026
9242b68
qemu-wasm: stop form fields from driving the watch buttons
Aug 23, 2026
9d4caa6
qemu-wasm: fix deploy bundle missing the new page modules
Aug 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions tools/qemu-wasm/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
web/qemu-system-arm.js
web/qemu-system-arm.wasm
web/qemu-system-arm.worker.js
web/firmware/
web/qemu-system-arm.mjs
52 changes: 52 additions & 0 deletions tools/qemu-wasm/NOTES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Emscripten link-flag notes

The reference build (coredevices/qemu `build-wasm/`, emsdk 3.1.50) links
`qemu-system-arm.js` with, among others:

```
-pthread -sASYNCIFY=1 -sPROXY_TO_PTHREAD=1 -sFORCE_FILESYSTEM
-sALLOW_TABLE_GROWTH -sTOTAL_MEMORY=2GB -sWASM_BIGINT -sEXPORT_ES6=1
-sASYNCIFY_IMPORTS=ffi_call_js
-sEXPORTED_RUNTIME_METHODS=addFunction,removeFunction,TTY,FS,HEAPU8,HEAPU32,callMain
-sPTHREAD_POOL_SIZE=4 -sUSE_SDL=2
```

(from `configs/meson/emscripten.txt` in the qemu tree plus the wasm-deps
LDFLAGS; `-sEXPORT_ES6=1` implies MODULARIZE, hence the dynamic `import()`
in `web/index.html` and `smoke-test.mjs`, and `"type": "module"` in
`web/package.json`.)

## What the shell needs

Covered by the current flags: `FS` (serial log polling, firmware upload in
`preRun`), `HEAPU8`/`HEAPU32` (display blit, button mask writes), and the
`EMSCRIPTEN_KEEPALIVE` browser glue in `hw/display/pebble_display.c`
(`pebble_wasm_display_{width,height,stride,data,frame_count}`) and
`hw/gpio/pebble_gpio.c` (`pebble_wasm_button_state_addr`), which need no
`-sEXPORTED_FUNCTIONS` entry. No extra relink flags are required for the
page as written.

## Possible future flags / glue

- `ENV` in `-sEXPORTED_RUNTIME_METHODS` — set guest environment variables
from JS before `main()` runs (`PEBBLE_QEMU_FIRST_BOOT_LOGIC_ENABLE`,
`PEBBLE_QEMU_START_CONNECTED`, `PEBBLE_QEMU_START_PLUGGED_IN` are read
by `pebble_generic.c`). Without it the defaults (start connected)
apply.
- Touch injection (emery/gabbro): natively `./pbl touch` drives
`hw/misc/pebble_touch.c` through QMP `input-send-event`, which the
browser build doesn't run. Needs a QMP-less glue export in the touch
device (e.g. a shared-memory x/y/pressed record polled on a
virtual-clock timer, like the button mask). Do not fake it from JS.

## SDL display experiment (not wired into the page)

The build also contains QEMU's SDL2 UI compiled against emscripten's SDL2
port (`-sUSE_SDL=2`, `CONFIG_SDL`). In principle `-display sdl` with a
`Module.canvas` renders without any of the `pebble_wasm_*` glue, and would
also route keyboard/pointer events through QEMU's input layer (including
`pebble_touch`). Unverified under `-sPROXY_TO_PTHREAD` (SDL calls are
proxied to the main thread); if it is ever made to work,
`-sOFFSCREENCANVAS_SUPPORT=1` is worth trying to render from the QEMU
pthread directly. The shared-memory path above is the proven, primary
mechanism.
172 changes: 172 additions & 0 deletions tools/qemu-wasm/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
# PebbleOS in the browser (QEMU + WebAssembly)

QEMU compiled to WebAssembly boots real PebbleOS firmware and renders the
watch display to an HTML canvas — no install, no server-side emulation.

## History

Pebble's original emulator was a QEMU 2.5 fork with STM32 peripheral models.
[ericmigi/pebble-qemu-wasm](https://github.com/ericmigi/pebble-qemu-wasm)
ported those device models to QEMU 10.1 and solved the browser-integration
problems (emscripten pthreads, main-thread proxying, canvas rendering,
input injection); this shell is derived from it. What changed since: the
[coredevices/qemu](https://github.com/coredevices/qemu) fork replaced the
STM32 models with generic virtual Pebble machines (`pebble-emery`,
`pebble-flint`, `pebble-gabbro` in `hw/arm/pebble_generic.c`) that expose
simple MMIO peripherals — a plain framebuffer display, 4-button GPIO,
touch, UARTs — instead of emulating a specific MCU. That is the same QEMU
`./pbl qemu` uses natively (see `docs/development/qemu.md`), so the browser
build now tracks mainline PebbleOS instead of a firmware fork.

`web/coi-serviceworker.min.js` is
[coi-serviceworker](https://github.com/gzuidhof/coi-serviceworker) v0.1.7
(MIT, Guido Zuidhof and contributors), copied via ericmigi/pebble-qemu-wasm.

## Quick start

```sh
# 1. Build QEMU for wasm32 (one-off, ~30 min)
./build-qemu-wasm.sh ~/coredevices/qemu

# already have a build? just copy the artifacts:
./get-artifacts.sh ~/coredevices/qemu/build-wasm

# 2. Fetch release firmware
./fetch-firmware.sh emery

# 3. Serve and open
./serve.py 8080
open http://localhost:8080
```

Click Boot. The page fetches the firmware images (~34 MB) and the WASM
binary, then boots. The boot logo appears within seconds; a full boot to
the launcher takes about a minute (TCI interpreter, measured under node on
4 cores).

The dev server sends `Cross-Origin-Opener-Policy` /
`Cross-Origin-Embedder-Policy` headers required for `SharedArrayBuffer`
(emscripten pthreads). On static hosts without those headers the bundled
coi-serviceworker provides them after one reload.

URL parameters: `?board=emery|flint|gabbro`, `?auto` (boot immediately),
`?audio` (enable the SDL audio backend).

## Headless smoke test

```sh
node smoke-test.mjs --board emery --seconds 300
```

Boots the WASM build under node with `-display none`, tails the UART2
debug console, and exits 0 once boot markers (ending with
`Ready for communication.`) appear and the exported display surface
reports the board's resolution with an advancing frame counter.

## How it works

- **Machine**: `-machine pebble-emery -kernel qemu_micro_flash.bin -drive
if=mtd,format=raw,file=qemu_spi_flash.bin -display none`. Code flash is
mapped at 0x0, so the raw release image boots directly via `-kernel`;
the 32 MB SPI flash image backs the external-flash device.
- **Display**: firmware writes pixels to framebuffer MMIO at 0x50000000;
`hw/display/pebble_display.c` converts them (ARGB2222 or 1bpp) to a
QemuConsole surface. Under emscripten a virtual-clock timer re-renders
that surface every 33 ms even with `-display none`, and the
`pebble_wasm_display_*` exports hand the page a heap pointer to the
32bpp surface plus a frame counter. The page polls the counter on a
30 ms interval and blits changed frames (BGRX to RGBA) into a canvas
`ImageData` — the same shared-memory scheme the old shell proved out.
- **Input**: `hw/gpio/pebble_gpio.c` exports
`pebble_wasm_button_state_addr()`, the heap address of a button
bitmask (bit 0 Back, 1 Up, 2 Select, 3 Down). Key and pointer events
`Atomics.store` the mask; a 16 ms virtual-clock poll inside the device
applies press and release edges, so held buttons work.
- **Serial**: UART2 (debug console) is routed to a MEMFS file the page
polls. UART1 (pebble-tool control protocol) is bridged to the page
through two ring buffers in wasm memory (`pebble_wasm_serial_ctrl()`
in `pebble_control.c`, JIT overlay): the page writes QemuProtocol
frames into the rx ring with Atomics and drains watch-bound bytes from
the tx ring; a 2 ms virtual-clock timer feeds the existing chardev
receive path.
- **PebbleKit JS**: apps with a `pebble-js-app.js` get their JS run in a
hidden same-origin iframe with a `Pebble` shim (`web/pkjs-runtime.js`),
started/stopped by the watch's app_run_state notifications on endpoint
52. `web/appmessage.js` speaks AppMessage (endpoint 0x30): PUSH with
the app uuid + little-endian tuple dictionary, 2-byte ACK/NACK with
echoed transaction id; sends are serialized and pushes that arrive
during JS startup are queued. The app JS gets real fetch/XHR (with a
CORS-proxy fallback, `pkjs-proxy/`), geolocation, per-app-scoped
localStorage, and config pages via `openURL` + the `return_to`
convention (`web/config-return.html`). Timeline APIs are stubbed.
- **App install**: `web/pebble-transport.js` implements the phone side of
the QEMU serial framing (0xFEED/0xBEEF), Pebble Protocol reassembly,
and the endpoint-17 phone-version handshake (a V3 response that
re-asserts capabilities 0xA3 — the firmware replaces, not ORs, session
capabilities). `web/app-install.js` drives the 4.x install flow:
BlobDB INSERT of a 126-byte AppDBEntry into the app db (retrying on
TRY_LATER), `app_run_state` RUN to trigger the watch's AppFetch
request, then one PutBytes session per object (app binary, resources,
worker) with the legacy STM32 CRC (`legacyDefectiveCrc`, verified
against `tests/fw/util/test_legacy_checksum.c` vectors). `web/pbw.js`
unzips the .pbw with the native DecompressionStream and picks the best
platform directory; `web/store.js` resolves apps.repebble.com /
apps.rebble.io links through the CORS-enabled appstore API. The page
offers a paste-a-link box and drag-drop of .pbw files.

## Controls

| Input | Button |
|-----------------------------|--------|
| Arrow Left, Escape, Backspace | Back |
| Arrow Up / `w` | Up |
| Arrow Right, Enter, `s` | Select |
| Arrow Down / `x` | Down |

On-screen buttons track pointer down/up, so press-and-hold works.

## Known limitations

- TCI interpreter: roughly 1-2 orders of magnitude slower than native
TCG. Expect multi-minute boots and single-digit FPS. (The `jit/` build
the site ships does not have this problem.)
- App install requires the `jit/` build — the serial bridge lives in the
JIT overlay's `pebble_control.c`; the TCI patch set does not carry it
yet.
- No touch yet on emery/gabbro: natively `./pbl touch` injects pointer
events over QMP, which the browser build doesn't run (see NOTES.md).
- flint and gabbro machines exist but are untested in the browser; the
board selector will boot them if firmware is fetched.
- Audio is off by default (`?audio` to try the SDL backend).

For the native QEMU workflow (`./pbl qemu`, buttons via monitor `sendkey`,
touch via QMP, gdb, screenshots) see `docs/development/qemu.md`.

## Files

- `web/index.html` — emulator page
- `web/coi-serviceworker.min.js` — COOP/COEP fallback for static hosts
- `serve.py` — dev server with COOP/COEP headers
- `build-qemu-wasm.sh` — reproducible emsdk + deps + QEMU build
- `get-artifacts.sh` — copy `qemu-system-arm.{js,wasm}` into `web/`
- `fetch-firmware.sh` — download release firmware into `web/firmware/`
- `smoke-test.mjs` — headless boot test under node
- `NOTES.md` — link-flag requirements for the emscripten build

## Performance patches

`patches/0001` also carries the wasm perf work (all `__EMSCRIPTEN__`-gated
or wasm-only): RAM-backed display framebuffer (no MMIO traps on pixel
writes), inline TLB fast path in the TCI interpreter, `cpu_io_recompile`
skip, `-sASYNCIFY_REMOVE` for the interpreter hot path, mimalloc, and
`-Doptimization=3`. Measured on 4 shared cores under node, pebble-emery
v4.35.0, continuous launcher scroll:

| build | boot to ready | boot-anim fps | scroll fps |
|---|---|---|---|
| unpatched TCI | 91 s | 1.3 | 2.1 |
| patched TCI | 38 s | 6.9 | 4.5 |
| wasm JIT (`jit/`) | 15 s | 7.1 | 16.9 |

The interpreter plateaus around 4.5 fps; the TCG-to-wasm JIT build in
`jit/` is the next multiplier and reaches ~17 fps.
24 changes: 24 additions & 0 deletions tools/qemu-wasm/artifact/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Single-file emulator page

Packs the whole emulator — QEMU wasm, runtime JS, pthread worker, and the
release firmware images — into one self-contained HTML file (gzip + base64,
unpacked in the browser with `DecompressionStream`). Useful anywhere only a
single static page can be hosted, e.g. Claude artifacts.

The page needs a host that serves COOP/COEP headers (SharedArrayBuffer);
it probes the environment first and explains what is missing instead of
hanging.

Use the size-reduced QEMU build so the page fits common limits: configure
with `--without-default-devices --with-devices-arm=pebble` (the Pebble
machines only — 25 MB wasm instead of 39 MB, ~13.8 MB page total).

```sh
python3 assemble.py <qemu-build-dir> pebble-emulator.html
node page-test.mjs pebble-emulator.html coi 300 shot.png # boots it headless
node page-test.mjs pebble-emulator.html nocoi 30 shot.png # diagnostics path
```

`assemble.py` also patches one emscripten 3.1.50 line whose
`new URL(..., import.meta.url)` throws when the module is imported from a
blob: URL inside the pthread worker (see comment in the script).
58 changes: 58 additions & 0 deletions tools/qemu-wasm/artifact/assemble.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
#!/usr/bin/env python3
"""Assemble the single-file emulator artifact from template + payloads."""
import base64
import gzip
import sys
from pathlib import Path

HERE = Path(__file__).parent
FW = Path('/home/user/qemu-wasm-firmware')


# In pthread workers Module.locateFile is lost to structured clone, so the
# runtime computes new URL(..., import.meta.url) — which throws for blob:
# bases. The worker receives the compiled wasm module by message and never
# fetches it, so a plain relative name is safe.
MJS_PATCH = (
"wasmBinaryFile = new URL('qemu-system-arm.wasm', import.meta.url).href;",
"wasmBinaryFile = 'qemu-system-arm.wasm';",
)


def pack(path):
raw = Path(path).read_bytes()
if path.name.endswith('.js') and not path.name.endswith('.worker.js'):
text = raw.decode()
assert text.count(MJS_PATCH[0]) == 1, 'mjs patch anchor not found'
raw = text.replace(*MJS_PATCH).encode()
gz = gzip.compress(raw, 9)
return base64.b64encode(gz).decode(), len(raw), len(gz)


def main(build_dir, out_path):
build = Path(build_dir)
tpl = (HERE / 'template.html').read_text()
total_unpacked = 0
sizes = {}
for key, path in [
('__MJS_B64__', build / 'qemu-system-arm.js'),
('__WORKER_B64__', build / 'qemu-system-arm.worker.js'),
('__WASM_B64__', build / 'qemu-system-arm.wasm'),
('__MICRO_B64__', FW / 'qemu_micro_flash.bin'),
('__SPI_B64__', FW / 'qemu_spi_flash.bin'),
]:
b64, raw, gz = pack(path)
tpl = tpl.replace(key, b64)
total_unpacked += raw
sizes[key] = (raw, gz, len(b64))
print(f'{path.name}: raw={raw:,} gz={gz:,} b64={len(b64):,}')
tpl = tpl.replace('__UNPACK_MB__', str(round(total_unpacked / 1e6)))
tpl = tpl.replace('__PAYLOAD_NOTE__',
f'{round(sum(s[2] for s in sizes.values())/1e6, 1)} MB embedded, '
f'{round(total_unpacked/1e6)} MB unpacked in memory')
Path(out_path).write_text(tpl)
print(f'TOTAL page: {len(tpl):,} bytes ({len(tpl)/1e6:.2f} MB; limit 16MB)')


if __name__ == '__main__':
main(sys.argv[1], sys.argv[2])
57 changes: 57 additions & 0 deletions tools/qemu-wasm/artifact/page-test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// Test the assembled single-file emulator page under chromium.
// Usage: node page-test.mjs <html-file> <coi|nocoi> <wait-seconds> <shot.png>
import { chromium } from 'playwright';
import { createServer } from 'node:http';
import { readFileSync } from 'node:fs';

const [file, mode, waitS, shot] = process.argv.slice(2);
const html = readFileSync(file);

const server = createServer((req, res) => {
const headers = { 'Content-Type': 'text/html', 'Cache-Control': 'no-store' };
if (mode === 'coi') {
headers['Cross-Origin-Opener-Policy'] = 'same-origin';
headers['Cross-Origin-Embedder-Policy'] = 'require-corp';
}
res.writeHead(200, headers);
res.end(html);
});
await new Promise((r) => server.listen(8099, r));

const browser = await chromium.launch({ executablePath: '/opt/pw-browsers/chromium', args: ['--no-sandbox'] });
const page = await browser.newPage({ viewport: { width: 760, height: 1100 } });
page.on('pageerror', (e) => console.log('[pageerror]', String(e).slice(0, 300)));
page.on('console', (m) => { const t = m.text(); if (/err|fail|abort|BLOCKED/i.test(t)) console.log('[console]', t.slice(0, 200)); });

await page.goto('http://localhost:8099/?auto', { waitUntil: 'load' });
console.log('loaded, mode=' + mode);

const t0 = Date.now();
let last = '';
while ((Date.now() - t0) / 1000 < Number(waitS)) {
await new Promise((r) => setTimeout(r, 5000));
const state = await page.evaluate(() => ({
status: document.getElementById('status')?.textContent || '',
fps: document.getElementById('fps')?.textContent || '',
checks: [...document.querySelectorAll('.chk')].map((c) => c.textContent).join(' | '),
fail: document.getElementById('fail')?.style.display !== 'none',
nonWhite: (() => {
const c = document.getElementById('screen');
const d = c.getContext('2d').getImageData(0, 0, c.width, c.height).data;
let n = 0;
for (let i = 0; i < d.length; i += 4) if (d[i] < 240 || d[i + 1] < 240 || d[i + 2] < 240) n++;
return n;
})(),
}));
const line = JSON.stringify(state);
if (line !== last) { console.log(`[${Math.round((Date.now() - t0) / 1000)}s]`, line); last = line; }
if (mode === 'nocoi' && state.fail) { console.log('DIAGNOSTIC SHOWN AS EXPECTED'); break; }
if (mode === 'coi' && state.status.includes('Display active') && state.nonWhite > 200 && state.nonWhite < 40000) {
console.log('PIXELS ON CANVAS — BOOT OK'); break;
}
}
await page.screenshot({ path: shot });
console.log('screenshot: ' + shot);
await browser.close();
server.close();
process.exit(0);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Return failure when the browser boot test times out

If the page never reaches the expected display state—for example because QEMU aborts, pixels remain blank, or the loop simply reaches waitS—execution falls through and exits successfully anyway. This makes the documented coi browser test report a green status for broken builds; track whether the success predicate was reached and return nonzero when it was not.

Useful? React with 👍 / 👎.

Loading
Loading