From 40bf820a10574c4a2c02982c1a1d5e706344f33b Mon Sep 17 00:00:00 2001 From: boa-z Date: Sat, 12 Sep 2026 08:31:10 +0800 Subject: [PATCH 1/8] fix(ui-cabi): declare scaled software raster entry points ui_render, ui_render_scaled, and ui_render_incremental_scaled are exported C ABI functions the raster implementation already provides; the public header only declared the unscaled incremental form. --- engine/ui-cabi/include/pocket_ui_cabi.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/engine/ui-cabi/include/pocket_ui_cabi.h b/engine/ui-cabi/include/pocket_ui_cabi.h index 6467bfb20..4a188db95 100644 --- a/engine/ui-cabi/include/pocket_ui_cabi.h +++ b/engine/ui-cabi/include/pocket_ui_cabi.h @@ -57,7 +57,10 @@ int32_t ui_debug_rect_xy(void); int32_t ui_debug_rect_wh(void); void ui_debug_pause(int32_t paused); void ui_debug_step(void); +const uint8_t *ui_render(void); +const uint8_t *ui_render_scaled(uint32_t scale); const uint8_t *ui_render_incremental(void); +const uint8_t *ui_render_incremental_scaled(uint32_t scale); uint32_t ui_framebuffer_width(void); uint32_t ui_framebuffer_height(void); uint32_t ui_framebuffer_stride(void); From 8f98b04b999d57d6887eab579881a3234ef3c255 Mon Sep 17 00:00:00 2001 From: boa-z Date: Sat, 12 Sep 2026 08:31:10 +0800 Subject: [PATCH 2/8] feat(d211-linux): add ArtInChip D211 fbdev host and cross-build The D211DBV runs Luban Linux 5.10 on RISC-V with an 800x480 32bpp panel and a GT911 touchscreen. The host presents the software raster surface through /dev/fb0, probes the framebuffer geometry and channel bitfields with FBIOGET_VSCREENINFO/FBIOGET_FSCREENINFO, fixes the visible page with FBIOPAN_DISPLAY, and finds the touch device by capability instead of eventN. The build runs on a canonical Ubuntu builder with the Luban Xuantie GCC wrapper and sysroot; D211_LUBAN_SDK selects the SDK root. The final link uses LLVM LLD from the pinned Rust nightly because binutils 2.35 rejects modern RISC-V ELF attributes. The private d211-linux-dev target (host ABI 11) renders the full 800x480 panel at raster density 1, and tools/d211-linux/remote-build.sh wraps sync, build, and artifact fetch with the builder taken from D211_REMOTE. --- apps/d211-demo/app.tsx | 22 ++ apps/d211-demo/main.tsx | 5 + apps/d211-demo/pocket.json | 24 ++ docs/STRUCTURE.md | 1 + hosts/d211-linux/README.md | 120 ++++++ hosts/d211-linux/d211-hero.png | Bin 0 -> 24866 bytes hosts/d211-linux/input.c | 143 +++++++ hosts/d211-linux/input.h | 37 ++ hosts/d211-linux/main.c | 582 ++++++++++++++++++++++++++++ package.json | 1 + tools/cli/d211-linux-toolchain.json | 31 ++ tools/d211-linux-profile.ts | 53 +++ tools/d211-linux.ts | 444 +++++++++++++++++++++ tools/d211-linux/build-runtime.sh | 155 ++++++++ tools/d211-linux/remote-build.sh | 50 +++ 15 files changed, 1668 insertions(+) create mode 100644 apps/d211-demo/app.tsx create mode 100644 apps/d211-demo/main.tsx create mode 100644 apps/d211-demo/pocket.json create mode 100644 hosts/d211-linux/README.md create mode 100644 hosts/d211-linux/d211-hero.png create mode 100644 hosts/d211-linux/input.c create mode 100644 hosts/d211-linux/input.h create mode 100644 hosts/d211-linux/main.c create mode 100644 tools/cli/d211-linux-toolchain.json create mode 100644 tools/d211-linux-profile.ts create mode 100644 tools/d211-linux.ts create mode 100755 tools/d211-linux/build-runtime.sh create mode 100755 tools/d211-linux/remote-build.sh diff --git a/apps/d211-demo/app.tsx b/apps/d211-demo/app.tsx new file mode 100644 index 000000000..edd406ea5 --- /dev/null +++ b/apps/d211-demo/app.tsx @@ -0,0 +1,22 @@ +import Hero from "../hero/app.tsx"; +import { reportAppAction } from "@pocketjs/framework/host"; + +/** + * First guest on the D211 fbdev host: software raster, QuickJS, on the full + * 800x480 panel. The large layout is the wide-surface variant Hero already + * uses on 480x720 logical targets. + */ +export default function D211Hero() { + return ( + reportAppAction("hero_press", count)} + presentationHz={60} + runtimeLabel="RUST + QUICKJS + SOFTWARE" + spinnerFrameStep={6} + /> + ); +} diff --git a/apps/d211-demo/main.tsx b/apps/d211-demo/main.tsx new file mode 100644 index 000000000..ba10c8a09 --- /dev/null +++ b/apps/d211-demo/main.tsx @@ -0,0 +1,5 @@ +// @title PocketJS: D211 Linux +import { mount } from "@pocketjs/framework/solid"; +import D211Hero from "./app.tsx"; + +mount(() => ); diff --git a/apps/d211-demo/pocket.json b/apps/d211-demo/pocket.json new file mode 100644 index 000000000..205fa171f --- /dev/null +++ b/apps/d211-demo/pocket.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://pocketjs.dev/schema/pocket-2.json", + "pocket": 2, + "id": "dev.pocket-stack.d211-demo", + "name": "pocketjs-d211-demo", + "title": "PocketJS: D211 Linux", + "version": "0.1.0", + "engine": { + "capabilities": { + "requires": ["input.touch", "text.glyphs.baked"] + } + }, + "app": { + "entry": "apps/d211-demo/main.tsx", + "output": "d211-demo-main", + "framework": "solid", + "viewport": { + "fixed": { + "logical": [800, 480], + "presentation": "native" + } + } + } +} diff --git a/docs/STRUCTURE.md b/docs/STRUCTURE.md index 769fc3d7d..19c8af75b 100644 --- a/docs/STRUCTURE.md +++ b/docs/STRUCTURE.md @@ -32,6 +32,7 @@ pocketjs/ │ ├─ blackberry-classic/ input sampling shared by both BlackBerry Classic hosts │ ├─ blackberry-classic-qnx/ BlackBerry 10 Core Native embedding │ ├─ blackberry-classic-android/ BlackBerry 10 Android Runtime embedding +│ ├─ d211-linux/ ArtInChip D211DBV fbdev host (software raster, evdev touch) │ ├─ desktop/ gpui window host — macos-app + linux-app (standalone lone-bin crate) │ ├─ web/ browser dev + Pocket System host (wasm core, isolated iframe Realms) │ └─ sim/ deterministic headless simulation host (docs/DETERMINISM.md) diff --git a/hosts/d211-linux/README.md b/hosts/d211-linux/README.md new file mode 100644 index 000000000..d5a78576b --- /dev/null +++ b/hosts/d211-linux/README.md @@ -0,0 +1,120 @@ +# d211-linux host + +PocketJS on the **ArtInChip D211DBV** running Luban Linux 5.10. +**The host renders with the Rust software rasterizer, presents through +`/dev/fb0`, and reads the GT911 through evdev.** There is no GPU, no EGL, no +DRM, and no window system. + +![PocketJS hero on the full 800×480 panel; Count: 34 after thirty-three taps](d211-hero.png) + +Pipeline for one frame: + +```text +pocket_runtime_tick(PocketRuntimeInput) + │ +ui_render_incremental_scaled(1) 800x480 BGRA, damage bounds in logical px + │ +fb_present() dirty rect × density, row copy to /dev/fb0 + │ +FBIOPAN_DISPLAY(yoffset=0) visible page fixed at startup +``` + +## Display + +The panel is **800×480, 32 bpp, `line_length` 3200**, configured with two +virtual pages (`yres_virtual` 960). **The host reads `fb_var_screeninfo` and +`fb_fix_screeninfo` and verifies the channel bitfields before selecting a blit +path.** When the layout matches red 16:8, green 8:8, blue 0:8 the rows are +copied; any other 32 bpp layout goes through the per-pixel packer. A non-32 bpp +mode is rejected with a message instead of guessed. + +The BSP leaves page 1 visible after another UI has owned the panel. +**`fb_open()` calls `FBIOPAN_DISPLAY` with `yoffset = 0` before the first +frame**, so the host always writes the page the controller scans out. Damage +bounds from `pocket_runtime_damage_bounds()` are logical pixels and are +multiplied by the target raster density for the physical rect. + +The logical viewport is the **full 800×480 panel at raster density 1**, so +layout coordinates map one-to-one onto the framebuffer. + +## Input + +**The touch device is found by capability, not by `eventN`.** `d211_input_open` +scans `/dev/input`, reads `EVIOCGNAME`, and accepts a device with the +`ABS_MT_POSITION_X/Y` pair, falling back to `ABS_X/ABS_Y` plus `BTN_TOUCH`. The +board exposes `goodix-ts` with the MT pair and a 800×480 axis range. + +Raw axis values are scaled into the logical viewport with the `EVIOCGABS` +maximum. **The bounds hit is resolved once at the contact's down edge** with +`pocket_runtime_hit_test_bounds()`, and the same contact id is delivered for +the rest of the press. `POCKET_TOUCH_LOG=1` writes down/up edges with raw +coordinates, logical coordinates, and the resolved hit to stderr. + +## Memory + +`MemTotal` is **54 MB** and the stock system leaves about 20 MB available. +**Run the installed binary from the rootfs, not `/tmp`:** `/tmp` is a tmpfs, +so a resident bundle there consumes RAM directly, and a `/tmp` install +OOM-killed the host under touch input at 11 MB available. `/opt/pocketjs` on +the UBI rootfs keeps the same bytes reclaimable as page cache. With the +bundle on the rootfs the host holds **13 MB RSS and `MemAvailable` stays above +11 MB through repeated touch input**. + +## Build + +The canonical builder is the Ubuntu host with the built Luban SDK; the +Xuantie GCC wrapper and sysroot are x86_64 Linux binaries. **The final link +uses LLVM LLD from the pinned Rust nightly** because Luban binutils 2.35 +cannot parse the `Zaamo`/`Zalrsc` RISC-V ELF attributes that modern LLVM +emits. The wrapper stays the driver, so CRT and glibc come from the Luban +sysroot. + +```sh +# on the builder +bun tools/d211-linux.ts setup # pinned QuickJS checkout + LLD shim +bun tools/d211-linux.ts build # guest bundle, Rust core, host, receipt +bun tools/d211-linux.ts doctor +``` + +From macOS, `tools/d211-linux/remote-build.sh` syncs the checkout to the +builder, runs the build, and fetches `dist/d211-linux/`. **The build root +resolves to `$HOME/d211` unless `D211_LUBAN_SDK` names another SDK, and the +workflow takes the builder from `D211_REMOTE` with an optional +`D211_REMOTE_PORT`.** + +## Deploy + +```sh +# on the machine with the D211 on USB +bun tools/d211-linux.ts stop-ui # test_lvgl owns the panel at boot +bun tools/d211-linux.ts deploy # /opt/pocketjs on the rootfs +bun tools/d211-linux.ts run # foreground, stats on stderr +``` + +The host reads `app.js` and `app.pak` beside its executable; `POCKET_JS` and +`POCKET_PAK` override the paths. `POCKET_FPS` caps the frame loop (default +60), `POCKET_FB` selects a framebuffer other than `/dev/fb0`. + +## Validation on the D211DBV board + +Build `472f7e072276bd38` with PocketJS `d211-linux-dev` (host ABI 11), +2026-09-12, kernel 5.10.44: + +| Measurement | Value | +| ----------- | ----- | +| Boot (init + `app.js` eval) | 353 ms | +| Frame rate | 59.9 fps | +| Tick | avg 1.34 ms | +| Software render | avg 2.17 ms | +| Present | avg 0.04 ms | +| Process RSS | 6,836 kB | +| Damage | 4,080 attempts, 0 failures, 2 full redraws | +| Touch | 33 clean down/up pairs at 800×480, one action per tap | + +The 800×480 framebuffer was captured through `dd if=/dev/fb0` and converted +to PNG; every pixel is non-black, the channel order matches the guest output, +and the screenshot above is the captured frame after thirty-three taps. + +`dist/d211-linux/build-receipt.json` records the PocketJS commit, the Rust +toolchain and target, GCC and LLD versions, the sysroot path, the pinned +QuickJS revision, and the guest, core, and executable digests. diff --git a/hosts/d211-linux/d211-hero.png b/hosts/d211-linux/d211-hero.png new file mode 100644 index 0000000000000000000000000000000000000000..666859a1898aefb8cefab595b6f369659abf0df8 GIT binary patch literal 24866 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 LZ +#include +#include +#include +#include +#include +#include +#include + +static int bit_is_set(const unsigned long *bits, int bit) { + return (int)((bits[bit / (int)(8 * sizeof(unsigned long))] >> + (bit % (int)(8 * sizeof(unsigned long)))) & + 1UL); +} + +static int read_axis_max(int fd, int axis) { + struct input_absinfo info; + if (ioctl(fd, EVIOCGABS(axis), &info) < 0) return 0; + return info.maximum > 0 ? info.maximum : 0; +} + +/* Returns 1 when `fd` is a usable touch device and fills `input`. */ +static int probe_device(int fd, D211Input *input) { + char name[64] = {0}; + if (ioctl(fd, EVIOCGNAME(sizeof(name) - 1), name) < 0) return 0; + + unsigned long abs_bits[(ABS_MAX + 1 + 8 * sizeof(unsigned long) - 1) / + (8 * sizeof(unsigned long))]; + memset(abs_bits, 0, sizeof(abs_bits)); + if (ioctl(fd, EVIOCGBIT(EV_ABS, sizeof(abs_bits)), abs_bits) < 0) return 0; + + unsigned long key_bits[(KEY_MAX + 1 + 8 * sizeof(unsigned long) - 1) / + (8 * sizeof(unsigned long))]; + memset(key_bits, 0, sizeof(key_bits)); + if (ioctl(fd, EVIOCGBIT(EV_KEY, sizeof(key_bits)), key_bits) < 0) return 0; + + int axis_x = -1; + int axis_y = -1; + if (bit_is_set(abs_bits, ABS_MT_POSITION_X) && + bit_is_set(abs_bits, ABS_MT_POSITION_Y)) { + axis_x = ABS_MT_POSITION_X; + axis_y = ABS_MT_POSITION_Y; + } else if (bit_is_set(abs_bits, ABS_X) && bit_is_set(abs_bits, ABS_Y) && + bit_is_set(key_bits, BTN_TOUCH)) { + axis_x = ABS_X; + axis_y = ABS_Y; + } + if (axis_x < 0) return 0; + + int max_x = read_axis_max(fd, axis_x); + int max_y = read_axis_max(fd, axis_y); + if (max_x <= 0 || max_y <= 0) return 0; + + snprintf(input->name, sizeof(input->name), "%s", name); + input->fd = fd; + input->axis_x = axis_x; + input->axis_y = axis_y; + input->max_x = max_x; + input->max_y = max_y; + return 1; +} + +int d211_input_open(D211Input *input) { + memset(input, 0, sizeof(*input)); + input->fd = -1; + + DIR *directory = opendir("/dev/input"); + if (directory == 0) return 0; + + int selected = -1; + struct dirent *entry; + while ((entry = readdir(directory)) != 0) { + if (strncmp(entry->d_name, "event", 5) != 0) continue; + char path[sizeof(entry->d_name) + 16]; + if (snprintf(path, sizeof(path), "/dev/input/%s", entry->d_name) >= + (int)sizeof(path)) { + continue; + } + int fd = open(path, O_RDONLY | O_NONBLOCK); + if (fd < 0) continue; + if (probe_device(fd, input)) { + selected = fd; + break; + } + close(fd); + } + closedir(directory); + return selected >= 0; +} + +void d211_input_close(D211Input *input) { + if (input->fd >= 0) close(input->fd); + input->fd = -1; +} + +int d211_input_pump(D211Input *input) { + if (input->fd < 0) return 0; + + int saw_down = 0; + struct input_event event; + for (;;) { + ssize_t bytes = read(input->fd, &event, sizeof(event)); + if (bytes < 0) { + if (errno == EAGAIN || errno == EWOULDBLOCK) break; + if (errno == EINTR) continue; + break; + } + if (bytes != (ssize_t)sizeof(event)) break; + + if (event.type == EV_ABS) { + if (event.code == input->axis_x) { + input->x = event.value; + input->have_position = 1; + } else if (event.code == input->axis_y) { + input->y = event.value; + input->have_position = 1; + } else if (event.code == ABS_MT_TRACKING_ID) { + if (event.value >= 0) { + if (!input->down) { + input->down = 1; + saw_down = 1; + } + } else { + input->down = 0; + } + } + } else if (event.type == EV_KEY && event.code == BTN_TOUCH) { + if (event.value != 0) { + if (!input->down) { + input->down = 1; + saw_down = 1; + } + } else { + input->down = 0; + } + } + } + return saw_down; +} diff --git a/hosts/d211-linux/input.h b/hosts/d211-linux/input.h new file mode 100644 index 000000000..a89493e98 --- /dev/null +++ b/hosts/d211-linux/input.h @@ -0,0 +1,37 @@ +#ifndef POCKETJS_D211_LINUX_INPUT_H +#define POCKETJS_D211_LINUX_INPUT_H + +/* + * Linux evdev touch sampling for the D211 fbdev host. + * + * The device is found by capability, never by a fixed /dev/input/eventN: the + * host scans /dev/input, reads EVIOCGNAME/EVIOCGBIT(EV_ABS), and prefers a + * multi-touch axis pair over the single-touch one. Raw axis values are scaled + * to the logical viewport by the caller. + */ + +typedef struct { + int fd; + int axis_x; + int axis_y; + int max_x; + int max_y; + int x; + int y; + int down; + int have_position; + char name[64]; +} D211Input; + +/** Opens the first touch-capable evdev device; returns 0 when none exists. */ +int d211_input_open(D211Input *input); + +void d211_input_close(D211Input *input); + +/** + * Drains pending events. Returns 1 when the contact went down in this batch, + * so the caller resolves the bounds hit once at the down edge. + */ +int d211_input_pump(D211Input *input); + +#endif diff --git a/hosts/d211-linux/main.c b/hosts/d211-linux/main.c new file mode 100644 index 000000000..adf25b672 --- /dev/null +++ b/hosts/d211-linux/main.c @@ -0,0 +1,582 @@ +/* + * d211-linux — PocketJS on the ArtInChip D211DBV framebuffer. + * + * The host owns the display and input, and links the PocketJS runtime: + * - /dev/fb0 through FBIOGET_VSCREENINFO / FBIOGET_FSCREENINFO, with the + * pixel layout verified at runtime instead of assumed; + * - the evdev touch device found by capability (never eventN); + * - engine/ui-cabi's software rasterizer at the target raster density; + * - engine/quickjs-c for the guest. + * + * One pocket_runtime_tick per presented frame, per the frame contract. + */ + +#define _GNU_SOURCE + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "input.h" +#include "pocket_runtime.h" +#include "pocket_ui_cabi.h" + +#ifndef POCKET_BUILD_ID +#define POCKET_BUILD_ID "unstaged" +#endif +#ifndef POCKETJS_TARGET_ID +#define POCKETJS_TARGET_ID "d211-linux-dev" +#endif +#ifndef POCKETJS_HOST_ABI +#define POCKETJS_HOST_ABI 11 +#endif +#ifndef POCKET_RASTER_DENSITY +#define POCKET_RASTER_DENSITY 1 +#endif +#ifndef POCKET_LOGICAL_WIDTH +#define POCKET_LOGICAL_WIDTH 800 +#endif +#ifndef POCKET_LOGICAL_HEIGHT +#define POCKET_LOGICAL_HEIGHT 480 +#endif + +#define D211_STAT_SAMPLES 240 + +static volatile sig_atomic_t g_stop = 0; + +static void handle_signal(int signo) { + (void)signo; + g_stop = 1; +} + +static uint64_t now_ns(void) { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (uint64_t)ts.tv_sec * 1000000000ull + (uint64_t)ts.tv_nsec; +} + +static uint8_t *read_asset(const char *path, size_t *out_length) { + FILE *file = fopen(path, "rb"); + if (file == 0) { + fprintf(stderr, "d211: cannot open %s: %s\n", path, strerror(errno)); + return 0; + } + if (fseek(file, 0, SEEK_END) != 0) { + fclose(file); + return 0; + } + long length = ftell(file); + if (length <= 0) { + fclose(file); + fprintf(stderr, "d211: %s is empty\n", path); + return 0; + } + rewind(file); + uint8_t *data = malloc((size_t)length); + if (data == 0) { + fclose(file); + fprintf(stderr, "d211: out of memory reading %s\n", path); + return 0; + } + if (fread(data, 1, (size_t)length, file) != (size_t)length) { + free(data); + fclose(file); + fprintf(stderr, "d211: short read on %s\n", path); + return 0; + } + fclose(file); + *out_length = (size_t)length; + return data; +} + +static int executable_directory(char *buffer, size_t length) { + ssize_t written = readlink("/proc/self/exe", buffer, length - 1); + if (written <= 0) return 0; + buffer[written] = '\0'; + char *slash = strrchr(buffer, '/'); + if (slash == 0) return 0; + *slash = '\0'; + return 1; +} + +static void resolve_asset( + char *out, + size_t out_length, + const char *env_name, + const char *directory, + const char *file_name +) { + const char *override = getenv(env_name); + if (override != 0 && override[0] != '\0') { + snprintf(out, out_length, "%s", override); + } else { + snprintf(out, out_length, "%s/%s", directory, file_name); + } +} + +/* ---- framebuffer --------------------------------------------------------- */ + +typedef struct { + int fd; + uint8_t *memory; + size_t memory_length; + uint32_t width; + uint32_t height; + uint32_t line_length; + struct fb_bitfield red; + struct fb_bitfield green; + struct fb_bitfield blue; + struct fb_bitfield alpha; + int direct_bgra; +} D211Framebuffer; + +static int fb_open(D211Framebuffer *fb, const char *path) { + memset(fb, 0, sizeof(*fb)); + fb->fd = open(path, O_RDWR); + if (fb->fd < 0) { + fprintf(stderr, "d211: cannot open %s: %s\n", path, strerror(errno)); + return 0; + } + + struct fb_var_screeninfo var; + struct fb_fix_screeninfo fix; + if (ioctl(fb->fd, FBIOGET_VSCREENINFO, &var) < 0 || + ioctl(fb->fd, FBIOGET_FSCREENINFO, &fix) < 0) { + fprintf(stderr, "d211: %s is not a framebuffer: %s\n", path, strerror(errno)); + close(fb->fd); + fb->fd = -1; + return 0; + } + + fb->width = var.xres; + fb->height = var.yres; + fb->line_length = fix.line_length; + fb->red = var.red; + fb->green = var.green; + fb->blue = var.blue; + fb->alpha = var.transp; + fb->memory_length = (size_t)fix.line_length * var.yres_virtual; + fb->memory = mmap( + NULL, + fb->memory_length, + PROT_READ | PROT_WRITE, + MAP_SHARED, + fb->fd, + 0 + ); + if (fb->memory == MAP_FAILED) { + fb->memory = 0; + fprintf(stderr, "d211: mmap %s failed: %s\n", path, strerror(errno)); + close(fb->fd); + fb->fd = -1; + return 0; + } + + fb->direct_bgra = + var.bits_per_pixel == 32 && var.red.offset == 16 && var.red.length == 8 && + var.green.offset == 8 && var.green.length == 8 && var.blue.offset == 0 && + var.blue.length == 8; + + /* + * The panel is configured with two virtual pages (yres_virtual = 2 * + * yres). This host owns the display, so force page 0 visible and keep + * writing there; a leftover page from another UI would otherwise stay on + * screen while every frame lands on the hidden page. + */ + if (var.yoffset != 0 || var.xoffset != 0) { + struct fb_var_screeninfo pan = var; + pan.xoffset = 0; + pan.yoffset = 0; + if (ioctl(fb->fd, FBIOPAN_DISPLAY, &pan) < 0) { + fprintf( + stderr, + "d211: cannot force visible page 0 (yoffset=%u): %s\n", + var.yoffset, + strerror(errno) + ); + } else { + fprintf( + stderr, + "d211: visible page forced to yoffset 0 (was %u)\n", + var.yoffset + ); + } + } + + fprintf( + stderr, + "d211: fb %s %ux%u (virtual %ux%u) %ubpp stride=%u r=%u/%u g=%u/%u " + "b=%u/%u a=%u/%u %s\n", + path, + fb->width, + fb->height, + var.xres_virtual, + var.yres_virtual, + var.bits_per_pixel, + fb->line_length, + var.red.offset, + var.red.length, + var.green.offset, + var.green.length, + var.blue.offset, + var.blue.length, + var.transp.offset, + var.transp.length, + fb->direct_bgra ? "direct BGRA" : "converting" + ); + if (var.bits_per_pixel != 32) { + fprintf( + stderr, + "d211: %ubpp is not supported by this host revision\n", + var.bits_per_pixel + ); + return 0; + } + return 1; +} + +static void fb_close(D211Framebuffer *fb) { + if (fb->memory != 0) munmap(fb->memory, fb->memory_length); + if (fb->fd >= 0) close(fb->fd); + fb->memory = 0; + fb->fd = -1; +} + +static uint32_t pack_channel(uint8_t value, const struct fb_bitfield *field) { + if (field->length == 0) return 0; + uint32_t scaled = value; + if (field->length > 8) scaled <<= (field->length - 8); + else if (field->length < 8) scaled >>= (8 - field->length); + return (scaled & ((1u << field->length) - 1u)) << field->offset; +} + +static void fb_present( + D211Framebuffer *fb, + const uint8_t *source, + uint32_t source_stride, + int x0, + int y0, + int x1, + int y1 +) { + if (x0 < 0) x0 = 0; + if (y0 < 0) y0 = 0; + if (x1 > (int)fb->width) x1 = (int)fb->width; + if (y1 > (int)fb->height) y1 = (int)fb->height; + if (x1 <= x0 || y1 <= y0) return; + + for (int y = y0; y < y1; y++) { + const uint8_t *src = source + (size_t)y * source_stride + (size_t)x0 * 4; + uint8_t *dst = fb->memory + (size_t)y * fb->line_length + (size_t)x0 * 4; + if (fb->direct_bgra) { + memcpy(dst, src, (size_t)(x1 - x0) * 4); + continue; + } + for (int x = x0; x < x1; x++) { + uint32_t word = + pack_channel(src[2], &fb->red) | pack_channel(src[1], &fb->green) | + pack_channel(src[0], &fb->blue) | pack_channel(src[3], &fb->alpha); + memcpy(dst, &word, sizeof(word)); + src += 4; + dst += 4; + } + } +} + +/* ---- frame statistics ---------------------------------------------------- */ + +typedef struct { + uint32_t tick_ns[D211_STAT_SAMPLES]; + uint32_t render_ns[D211_STAT_SAMPLES]; + uint32_t present_ns[D211_STAT_SAMPLES]; + unsigned int count; + unsigned int index; +} D211Stats; + +static void stats_add(D211Stats *stats, uint32_t tick, uint32_t render, uint32_t present) { + stats->tick_ns[stats->index] = tick; + stats->render_ns[stats->index] = render; + stats->present_ns[stats->index] = present; + stats->index = (stats->index + 1) % D211_STAT_SAMPLES; + if (stats->count < D211_STAT_SAMPLES) stats->count++; +} + +static int compare_u32(const void *left, const void *right) { + uint32_t a = *(const uint32_t *)left; + uint32_t b = *(const uint32_t *)right; + return (a > b) - (a < b); +} + +static uint32_t percentile_ms(const uint32_t *samples, unsigned int count, double percentile) { + uint32_t copy[D211_STAT_SAMPLES]; + memcpy(copy, samples, count * sizeof(copy[0])); + qsort(copy, count, sizeof(copy[0]), compare_u32); + unsigned int index = (unsigned int)(percentile * (double)(count - 1) + 0.5); + if (index >= count) index = count - 1; + return (copy[index] + 500) / 1000; +} + +static void stats_print(const D211Stats *stats, uint64_t frames, uint64_t elapsed_ns) { + if (stats->count == 0) return; + uint64_t tick_total = 0; + uint64_t render_total = 0; + uint64_t present_total = 0; + for (unsigned int i = 0; i < stats->count; i++) { + tick_total += stats->tick_ns[i]; + render_total += stats->render_ns[i]; + present_total += stats->present_ns[i]; + } + double fps = elapsed_ns > 0 + ? (double)frames * 1e9 / (double)elapsed_ns + : 0.0; + fprintf( + stderr, + "d211: %.1f fps | tick avg=%.2f p95=%u | render avg=%.2f p95=%u | " + "present avg=%.2f p95=%u ms | damage attempts=%lu failures=%lu " + "full=%lu\n", + fps, + (double)tick_total / stats->count / 1e6, + percentile_ms(stats->tick_ns, stats->count, 0.95), + (double)render_total / stats->count / 1e6, + percentile_ms(stats->render_ns, stats->count, 0.95), + (double)present_total / stats->count / 1e6, + percentile_ms(stats->present_ns, stats->count, 0.95), + pocket_runtime_damage_attempts(), + pocket_runtime_damage_failures(), + pocket_runtime_damage_full_redraws() + ); +} + +/* ---- main ---------------------------------------------------------------- */ + +static int scale_axis(int raw, int maximum, int logical) { + if (maximum <= 0) return 0; + int value = (int)(((int64_t)raw * logical) / (maximum + 1)); + if (value < 0) value = 0; + if (value > logical - 1) value = logical - 1; + return value; +} + +int main(void) { + signal(SIGINT, handle_signal); + signal(SIGTERM, handle_signal); + + char directory[512]; + if (!executable_directory(directory, sizeof(directory))) { + snprintf(directory, sizeof(directory), "."); + } + char java_script_path[1024]; + char pack_path[1024]; + resolve_asset(java_script_path, sizeof(java_script_path), "POCKET_JS", directory, "app.js"); + resolve_asset(pack_path, sizeof(pack_path), "POCKET_PAK", directory, "app.pak"); + + size_t java_script_length = 0; + size_t pack_length = 0; + uint8_t *java_script = read_asset(java_script_path, &java_script_length); + if (java_script == 0) return 1; + uint8_t *pack = read_asset(pack_path, &pack_length); + if (pack == 0) { + free(java_script); + return 1; + } + + char framebuffer_path[256]; + const char *framebuffer_override = getenv("POCKET_FB"); + snprintf( + framebuffer_path, + sizeof(framebuffer_path), + "%s", + framebuffer_override != 0 && framebuffer_override[0] != '\0' + ? framebuffer_override + : "/dev/fb0" + ); + D211Framebuffer framebuffer; + if (!fb_open(&framebuffer, framebuffer_path)) { + free(java_script); + free(pack); + return 1; + } + + D211Input touch; + int touch_available = d211_input_open(&touch); + if (touch_available) { + fprintf( + stderr, + "d211: touch %s axes=%d/%d range=%dx%d\n", + touch.name, + touch.axis_x, + touch.axis_y, + touch.max_x, + touch.max_y + ); + } else { + fprintf(stderr, "d211: no touch-capable evdev device found\n"); + } + + uint64_t boot_start = now_ns(); + if (!pocket_runtime_boot( + (const char *)java_script, + java_script_length, + pack, + pack_length, + POCKET_LOGICAL_WIDTH, + POCKET_LOGICAL_HEIGHT + )) { + fprintf(stderr, "d211: boot failed: %s\n", pocket_runtime_error()); + d211_input_close(&touch); + fb_close(&framebuffer); + free(java_script); + free(pack); + return 1; + } + fprintf( + stderr, + "d211: boot %s (%s abi=%d) %dx%d density=%d in %.1f ms\n", + POCKET_BUILD_ID, + POCKETJS_TARGET_ID, + POCKETJS_HOST_ABI, + POCKET_LOGICAL_WIDTH, + POCKET_LOGICAL_HEIGHT, + POCKET_RASTER_DENSITY, + (double)(now_ns() - boot_start) / 1e6 + ); + + long fps = 60; + const char *fps_override = getenv("POCKET_FPS"); + if (fps_override != 0 && fps_override[0] != '\0') { + char *end = 0; + long parsed = strtol(fps_override, &end, 10); + if (end != fps_override && parsed > 0 && parsed <= 240) fps = parsed; + } + const uint64_t frame_period_ns = 1000000000ull / (uint64_t)fps; + const int touch_log = getenv("POCKET_TOUCH_LOG") != 0; + + D211Stats stats; + memset(&stats, 0, sizeof(stats)); + int contact_hit = 0; + int last_down = 0; + int presented_once = 0; + uint64_t frames = 0; + uint64_t stats_start = now_ns(); + uint64_t next_frame = now_ns(); + + while (!g_stop) { + uint64_t frame_start = now_ns(); + + if (touch_available) { + struct pollfd descriptor; + descriptor.fd = touch.fd; + descriptor.events = POLLIN; + uint64_t remaining = next_frame > frame_start ? next_frame - frame_start : 0; + poll(&descriptor, 1, (int)(remaining / 1000000)); + int down_edge = d211_input_pump(&touch); + if (down_edge) { + /* The bounds hit is resolved once, at the contact's down edge. */ + int logical_x = scale_axis(touch.x, touch.max_x, POCKET_LOGICAL_WIDTH); + int logical_y = scale_axis(touch.y, touch.max_y, POCKET_LOGICAL_HEIGHT); + contact_hit = pocket_runtime_hit_test_bounds((float)logical_x, (float)logical_y); + if (touch_log) { + fprintf( + stderr, + "d211: touch down raw=(%d,%d) logical=(%d,%d) hit=%d\n", + touch.x, + touch.y, + logical_x, + logical_y, + contact_hit + ); + } + } else if (touch_log && !touch.down && last_down) { + fprintf(stderr, "d211: touch up\n"); + } + last_down = touch.down; + } else { + uint64_t remaining = next_frame > frame_start ? next_frame - frame_start : 0; + struct timespec pause; + pause.tv_sec = (time_t)(remaining / 1000000000ull); + pause.tv_nsec = (long)(remaining % 1000000000ull); + nanosleep(&pause, 0); + } + + PocketRuntimeInput input; + input.buttons = 0; + input.touch_down = touch_available ? touch.down : 0; + input.touch_x = touch_available + ? scale_axis(touch.x, touch.max_x, POCKET_LOGICAL_WIDTH) + : 0; + input.touch_y = touch_available + ? scale_axis(touch.y, touch.max_y, POCKET_LOGICAL_HEIGHT) + : 0; + input.touch_hit = input.touch_down ? contact_hit : 0; + + uint64_t tick_start = now_ns(); + if (!pocket_runtime_tick(&input)) { + fprintf(stderr, "d211: tick failed: %s\n", pocket_runtime_error()); + break; + } + uint64_t tick_end = now_ns(); + + const uint8_t *pixels = ui_render_incremental_scaled(POCKET_RASTER_DENSITY); + uint64_t render_end = now_ns(); + if (pixels == 0) { + fprintf(stderr, "d211: render returned no surface\n"); + break; + } + + int logical_bounds[4] = {0, 0, 0, 0}; + int has_damage = pocket_runtime_damage_bounds(logical_bounds); + if (has_damage || !presented_once) { + int x0 = has_damage ? logical_bounds[0] * POCKET_RASTER_DENSITY : 0; + int y0 = has_damage ? logical_bounds[1] * POCKET_RASTER_DENSITY : 0; + int x1 = has_damage ? logical_bounds[2] * POCKET_RASTER_DENSITY + : (int)framebuffer.width; + int y1 = has_damage ? logical_bounds[3] * POCKET_RASTER_DENSITY + : (int)framebuffer.height; + fb_present(&framebuffer, pixels, pocket_runtime_stride(), x0, y0, x1, y1); + presented_once = 1; + } + uint64_t present_end = now_ns(); + + stats_add( + &stats, + (uint32_t)(tick_end - tick_start), + (uint32_t)(render_end - tick_end), + (uint32_t)(present_end - render_end) + ); + frames++; + + if (frames % 120 == 0) { + stats_print(&stats, frames, now_ns() - stats_start); + } + + next_frame += frame_period_ns; + uint64_t frame_end = now_ns(); + if (next_frame > frame_end) { + struct timespec pause; + uint64_t remaining = next_frame - frame_end; + pause.tv_sec = (time_t)(remaining / 1000000000ull); + pause.tv_nsec = (long)(remaining % 1000000000ull); + nanosleep(&pause, 0); + } else { + next_frame = frame_end; + } + } + + fprintf(stderr, "d211: shutting down after %llu frames\n", (unsigned long long)frames); + pocket_runtime_shutdown(); + d211_input_close(&touch); + fb_close(&framebuffer); + free(java_script); + free(pack); + return 0; +} diff --git a/package.json b/package.json index c10781911..7f44a7185 100644 --- a/package.json +++ b/package.json @@ -260,6 +260,7 @@ "meizu-m8": "bun tools/meizu-m8.ts", "blackberry-android": "bun tools/blackberry-android.ts", "blackberry-qnx": "bun tools/blackberry-qnx.ts", + "d211-linux": "bun tools/d211-linux.ts", "3ds": "bun tools/3ds.ts", "vita:art": "bun tools/generate-vita-livearea.ts", "vita:art:check": "bun tools/generate-vita-livearea.ts --check", diff --git a/tools/cli/d211-linux-toolchain.json b/tools/cli/d211-linux-toolchain.json new file mode 100644 index 000000000..8f619f90a --- /dev/null +++ b/tools/cli/d211-linux-toolchain.json @@ -0,0 +1,31 @@ +{ + "schemaVersion": 1, + "toolchainVersion": "d211-linux-riscv64-luban-v1", + "cachePath": "d211-linux", + "luban": { + "outputDirectory": "output/d211", + "gccPrefix": "riscv64-unknown-linux-gnu" + }, + "rust": { + "toolchain": "nightly-2026-07-02", + "target": "riscv64gc-unknown-linux-gnu" + }, + "quickjs": { + "version": "2026-06-04", + "repository": "https://github.com/pocket-stack/quickjs-rs.git", + "revision": "ba5bdd0dc013518768e76cd9e05cd30ed53dd35b" + }, + "device": { + "name": "ArtInChip D211DBV (SPI NAND)", + "platform": "Luban Linux 5.10", + "cpu": "riscv64", + "physicalViewport": [800, 480], + "logicalViewport": [800, 480], + "rasterDensity": 1 + }, + "app": { + "manifest": "apps/d211-demo/pocket.json", + "binary": "pocketjs-d211", + "outputDirectory": "dist/d211-linux" + } +} diff --git a/tools/d211-linux-profile.ts b/tools/d211-linux-profile.ts new file mode 100644 index 000000000..250a299c6 --- /dev/null +++ b/tools/d211-linux-profile.ts @@ -0,0 +1,53 @@ +import { + POCKET_CAPABILITIES, + definePlatformContractRegistry, + defineTargetRegistry, +} from "../contracts/spec/platforms.ts"; +import type { ResolvedBuildPlan } from "../framework/src/manifest/plan.ts"; +import { validateAndResolveBuildPlan } from "../framework/src/manifest/resolve.ts"; + +/** + * Development profile for the ArtInChip D211DBV running Luban + * Linux 5.10. + * + * The logical surface is the full 800x480 panel at raster density 1. Host ABI + * 11 reserves the wire generation for this host while it remains private. + */ +export const D211_LINUX_DEV_TARGET_ID = "d211-linux-dev"; +export const D211_LINUX_DEV_HOST_ABI = 11; +export const D211_LINUX_LOGICAL_VIEWPORT = [800, 480] as const; +export const D211_LINUX_PHYSICAL_VIEWPORT = [800, 480] as const; + +export const D211_LINUX_DEV_CONTRACTS = definePlatformContractRegistry( + POCKET_CAPABILITIES, + defineTargetRegistry({ + [D211_LINUX_DEV_TARGET_ID]: { + hostAbi: D211_LINUX_DEV_HOST_ABI, + platform: "linux", + form: "takeover", + display: { + physicalViewport: D211_LINUX_PHYSICAL_VIEWPORT, + logicalViewports: [D211_LINUX_LOGICAL_VIEWPORT], + presentations: ["native"], + rasterDensity: 1, + }, + capabilities: ["input.touch", "text.glyphs.baked"], + }, + }), +); + +export function resolveD211LinuxBuildPlan(input: unknown): ResolvedBuildPlan { + const resolution = validateAndResolveBuildPlan( + input, + { target: D211_LINUX_DEV_TARGET_ID }, + D211_LINUX_DEV_CONTRACTS, + ); + if (!resolution.ok) { + throw new Error( + `pocket d211-linux: manifest did not resolve: ${resolution.diagnostics + .map((diagnostic) => `${diagnostic.path || "/"}: ${diagnostic.message}`) + .join("; ")}`, + ); + } + return resolution.plan; +} diff --git a/tools/d211-linux.ts b/tools/d211-linux.ts new file mode 100644 index 000000000..93e5d5c5a --- /dev/null +++ b/tools/d211-linux.ts @@ -0,0 +1,444 @@ +import { randomBytes } from "node:crypto"; +import { + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { HostBuildInputs } from "../framework/src/manifest/host-build-inputs.ts"; +import { + D211_LINUX_DEV_TARGET_ID, + resolveD211LinuxBuildPlan, +} from "./d211-linux-profile.ts"; +import { + buildGuestBundle, + ensureQuickJsCheckout, + type GuestBundleRequest, + mustRunCommand, + printCheck, + quickJsCheckoutStatus, + readGuestBundle, + runCommand, + sha256File, +} from "./native-host-build.ts"; + +/** + * ArtInChip D211DBV on Luban Linux 5.10. + * + * The cross-build runs on the canonical builder — the Ubuntu host with the + * built Luban SDK — while `deploy` and `run` talk to the D211 attached over + * ADB on the development machine: + * + * Ubuntu: bun tools/d211-linux.ts build + * macOS: bun tools/d211-linux.ts deploy + * + * The host contract is private until the hardware acceptance receipt passes. + */ + +interface D211Toolchain { + readonly toolchainVersion: string; + readonly cachePath: string; + readonly luban: { + readonly outputDirectory: string; + readonly gccPrefix: string; + }; + readonly rust: { + readonly toolchain: string; + readonly target: string; + }; + readonly quickjs: { + readonly version: string; + readonly repository: string; + readonly revision: string; + }; + readonly device: { + readonly name: string; + readonly platform: string; + readonly cpu: string; + readonly physicalViewport: readonly [number, number]; + readonly logicalViewport: readonly [number, number]; + readonly rasterDensity: number; + }; + readonly app: { + readonly manifest: string; + readonly binary: string; + readonly outputDirectory: string; + }; +} + +const LABEL = "PocketJS D211 Linux"; +const repository = fileURLToPath(new URL("..", import.meta.url)); +const command = Bun.argv[2] ?? "doctor"; +const toolchain = JSON.parse( + readFileSync(join(repository, "tools/cli/d211-linux-toolchain.json"), "utf8"), +) as D211Toolchain; + +const cache = join(homedir(), ".cache/pocket-stack", toolchain.cachePath); +const quickJsRoot = join(cache, "sources/quickjs-rs"); +const lldShim = join(cache, "build/lld-shim"); +const rustTargetDirectory = join(repository, ".pocket-build/d211-linux/rust-target"); +const nativeBuild = join(repository, ".pocket-build/d211-linux/runtime"); +const outputDirectory = join(repository, toolchain.app.outputDirectory); +const outputBinary = join(outputDirectory, toolchain.app.binary); +const outputReceipt = join(outputDirectory, "build-receipt.json"); +const lubanSdkRoot = (process.env.D211_LUBAN_SDK ?? "").trim() || join(homedir(), "d211"); +const lubanOutput = join(lubanSdkRoot, toolchain.luban.outputDirectory); +const lubanSysroot = join(lubanOutput, "host/riscv64-linux-gnu/sysroot"); +const lubanGcc = join( + lubanOutput, + `host/bin/${toolchain.luban.gccPrefix}-gcc`, +); +const rustCoreArchive = join( + rustTargetDirectory, + `${toolchain.rust.target}/release/libpocketjs_symbian_core.a`, +); +const guest: GuestBundleRequest = { + label: LABEL, + repository, + target: D211_LINUX_DEV_TARGET_ID, + resolvePlan: (manifest) => resolveD211LinuxBuildPlan(manifest), + manifestPath: join(repository, toolchain.app.manifest), + planPath: join(repository, ".pocket/d211-linux/d211-demo.plan.json"), + outputDirectory: join(repository, "dist/d211-linux/guest"), +}; + +function run(program: string, args: readonly string[]) { + return runCommand(program, args, repository); +} + +/** Doctor tolerates a missing binary instead of throwing on spawn. */ +function probe(program: string, args: readonly string[]) { + try { + return run(program, args); + } catch (error) { + return { exitCode: -1, stdout: "", stderr: String(error) }; + } +} + +function mustRun( + program: string, + args: readonly string[], + cwd = repository, + env: NodeJS.ProcessEnv = process.env, +): string { + return mustRunCommand(LABEL, program, args, cwd, env); +} + +function rustSysroot(): string { + return mustRun("rustup", [ + "run", + toolchain.rust.toolchain, + "rustc", + "--print", + "sysroot", + ]).trim(); +} + +function lldPath(): string { + return join( + rustSysroot(), + "lib/rustlib/x86_64-unknown-linux-gnu/bin/gcc-ld/ld.lld", + ); +} + +/** Symlinks the nightly LLD under the Luban target prefix; never replaces it. */ +function ensureLldShim(): void { + mkdirSync(lldShim, { recursive: true }); + const lld = lldPath(); + if (!existsSync(lld)) { + throw new Error(`${LABEL}: nightly LLD is absent: ${lld}`); + } + for (const name of ["ld.lld", `${toolchain.luban.gccPrefix}-ld.lld`]) { + const link = join(lldShim, name); + if (existsSync(link)) continue; + try { + symlinkSync(lld, link); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + } + } +} + +function targetInstalled(): boolean { + const installed = probe("rustup", [ + "target", + "list", + "--installed", + "--toolchain", + toolchain.rust.toolchain, + ]); + return installed.exitCode === 0 && + installed.stdout.split("\n").includes(toolchain.rust.target); +} + +function doctor(): void { + const gccVersion = probe(lubanGcc, ["--version"]); + const rustcVersion = probe("rustup", [ + "run", + toolchain.rust.toolchain, + "rustc", + "--version", + ]); + const quickjs = quickJsCheckoutStatus(quickJsRoot, toolchain.quickjs); + const checks = [ + printCheck( + "Luban GCC wrapper", + gccVersion.exitCode === 0, + gccVersion.stdout.split("\n")[0] || lubanGcc, + ), + printCheck("Luban sysroot", existsSync(lubanSysroot), lubanSysroot), + printCheck( + "Rust nightly", + rustcVersion.exitCode === 0, + rustcVersion.stdout.trim() || toolchain.rust.toolchain, + ), + printCheck( + `Rust target ${toolchain.rust.target}`, + targetInstalled(), + toolchain.rust.target, + ), + printCheck("nightly LLD", existsSync(lldPath()), lldPath()), + printCheck("pinned QuickJS", quickjs.ok, quickjs.detail), + ]; + if (checks.some((ok) => !ok)) process.exitCode = 1; + else console.log(`[ok] toolchain: ${toolchain.toolchainVersion}`); +} + +function setup(): void { + ensureQuickJsCheckout(LABEL, quickJsRoot, toolchain.quickjs); + ensureLldShim(); + doctor(); +} + +function buildRustCore(): string { + mkdirSync(rustTargetDirectory, { recursive: true }); + mustRun( + "rustup", + [ + "run", + toolchain.rust.toolchain, + "cargo", + "build", + "--release", + "--locked", + "--features", + "bare-platform,software-only", + "--target", + toolchain.rust.target, + ], + join(repository, "engine/ui-cabi"), + { ...process.env, CARGO_TARGET_DIR: rustTargetDirectory }, + ); + if (!existsSync(rustCoreArchive)) { + throw new Error(`${LABEL}: Rust core archive is absent: ${rustCoreArchive}`); + } + return rustCoreArchive; +} + +function writeReceipt(buildId: string, inputs: HostBuildInputs): void { + const src = join(nativeBuild, "staging", toolchain.app.binary); + const elf = existsSync(join(nativeBuild, "pocketjs-d211.readelf.txt")) + ? readFileSync(join(nativeBuild, "pocketjs-d211.readelf.txt"), "utf8") + : ""; + const receipt = { + schemaVersion: 1, + toolchainVersion: toolchain.toolchainVersion, + buildId, + pocketJsCommit: mustRun("git", ["rev-parse", "HEAD"]), + hostContract: inputs, + device: toolchain.device, + rustToolchain: toolchain.rust.toolchain, + rustTarget: toolchain.rust.target, + rustcVersion: mustRun("rustup", [ + "run", + toolchain.rust.toolchain, + "rustc", + "--version", + ]), + gccVersion: mustRun(lubanGcc, ["--version"]).split("\n")[0], + sysroot: lubanSysroot, + lld: lldPath(), + lldVersion: mustRun(lldPath(), ["--version"]).split("\n")[0], + quickJsRevision: toolchain.quickjs.revision, + quickJsVersion: toolchain.quickjs.version, + guestJavaScriptSha256: sha256File(join(outputDirectory, "app.js")), + guestPackSha256: sha256File(join(outputDirectory, "app.pak")), + coreLibrarySha256: sha256File(rustCoreArchive), + executableSha256: sha256File(src), + executableBytes: readFileSync(src).byteLength, + elf, + }; + writeFileSync(outputReceipt, `${JSON.stringify(receipt, null, 2)}\n`); + console.log(`${LABEL}: receipt -> ${outputReceipt}`); +} + +function buildRuntime(): void { + ensureQuickJsCheckout(LABEL, quickJsRoot, toolchain.quickjs); + ensureLldShim(); + const bundle = readGuestBundle(guest); + const coreLibrary = buildRustCore(); + const buildId = randomBytes(8).toString("hex"); + const staging = join(nativeBuild, "staging"); + mkdirSync(staging, { recursive: true }); + mkdirSync(outputDirectory, { recursive: true }); + + copyFileSync(coreLibrary, join(nativeBuild, "libpocketjs_symbian_core.a")); + + mustRun("bash", [join(repository, "tools/d211-linux/build-runtime.sh")], repository, { + ...process.env, + POCKET_BUILD_ID: buildId, + POCKETJS_TARGET_ID: guest.target, + POCKETJS_HOST_ABI: String(bundle.inputs.hostAbi), + POCKET_RASTER_DENSITY: String(bundle.inputs.viewport.rasterDensity), + POCKET_LOGICAL_WIDTH: String(bundle.inputs.viewport.logical[0]), + POCKET_LOGICAL_HEIGHT: String(bundle.inputs.viewport.logical[1]), + REPO_ROOT: repository, + BUILD_DIR: nativeBuild, + LUBAN_OUTPUT_DIR: lubanOutput, + LUBAN_GCC_PREFIX: toolchain.luban.gccPrefix, + LLD_SHIM_DIR: lldShim, + QUICKJS_VERSION: toolchain.quickjs.version, + QUICKJS_DIR: join(quickJsRoot, "libquickjs-sys/embed/quickjs"), + QUICKJS_STATIC_FUNCTIONS: join( + quickJsRoot, + "libquickjs-sys/embed/static-functions.c", + ), + RUST_CORE_ARCHIVE: join(nativeBuild, "libpocketjs_symbian_core.a"), + }); + + const readelf = readFileSync( + join(nativeBuild, "pocketjs-d211.readelf.txt"), + "utf8", + ); + const symbols = readFileSync( + join(nativeBuild, "pocketjs-d211.symbols.txt"), + "utf8", + ); + const expectedElf = ["RISC-V", "RVC", "double-float ABI", "ld-linux-riscv64-lp64d.so.1"]; + for (const marker of expectedElf) { + if (!readelf.includes(marker)) { + throw new Error(`${LABEL}: linked ELF is missing ${marker}`); + } + } + const expectedSymbols = [ + " main", + " pocket_runtime_boot", + " pocket_runtime_tick", + " ui_render_incremental_scaled", + " d211_input_open", + ]; + for (const symbol of expectedSymbols) { + if (!symbols.includes(symbol)) { + throw new Error(`${LABEL}: linked ELF is missing${symbol}`); + } + } + + const staged = join(staging, toolchain.app.binary); + copyFileSync(staged, outputBinary); + copyFileSync(bundle.javaScript, join(outputDirectory, "app.js")); + copyFileSync(bundle.pack, join(outputDirectory, "app.pak")); + + writeReceipt(buildId, bundle.inputs); + console.log(`${LABEL}: ${toolchain.app.binary} -> ${outputBinary}`); + console.log(`SHA-256: ${sha256File(outputBinary)}`); +} + +function build(): void { + buildGuestBundle(guest); + buildRuntime(); +} + +function requireArtifacts(): void { + for (const path of [outputBinary, join(outputDirectory, "app.js"), join(outputDirectory, "app.pak")]) { + if (!existsSync(path)) { + throw new Error(`${LABEL}: ${path} is absent; run \`bun tools/d211-linux.ts build\` on the builder and sync the artifacts`); + } + } +} + +function requireAdb(): void { + if (probe("adb", ["version"]).exitCode !== 0) { + throw new Error(`${LABEL}: adb is not available on this machine`); + } +} + +/** + * The application is installed on the rootfs, not /tmp: /tmp is a tmpfs on + * this board and a resident bundle there costs RAM the 64 MB system cannot + * spare (an install in /tmp OOM-killed the host under touch input). + */ +const DEVICE_DIRECTORY = "/opt/pocketjs"; + +function deploy(): void { + requireAdb(); + requireArtifacts(); + mustRun("adb", ["shell", "mkdir", "-p", DEVICE_DIRECTORY]); + mustRun("adb", ["push", outputBinary, `${DEVICE_DIRECTORY}/pocketjs-d211`]); + mustRun("adb", ["push", join(outputDirectory, "app.js"), `${DEVICE_DIRECTORY}/app.js`]); + mustRun("adb", ["push", join(outputDirectory, "app.pak"), `${DEVICE_DIRECTORY}/app.pak`]); + mustRun("adb", ["shell", "chmod", "+x", `${DEVICE_DIRECTORY}/pocketjs-d211`]); + console.log(`${LABEL}: installed to ${DEVICE_DIRECTORY} (rootfs, not tmpfs)`); +} + +function runOnDevice(): void { + requireAdb(); + const result = run("adb", [ + "shell", + `cd ${DEVICE_DIRECTORY} && ./${toolchain.app.binary}`, + ]); + if (result.stdout) process.stdout.write(result.stdout); + if (result.stderr) process.stderr.write(result.stderr); + if (result.exitCode !== 0) process.exitCode = result.exitCode; +} + +function stopUi(): void { + requireAdb(); + run("adb", ["shell", "killall", "test_lvgl"]); + console.log(`${LABEL}: stopped test_lvgl when present`); +} + +function receipt(): void { + if (!existsSync(outputReceipt)) { + throw new Error(`${LABEL}: no receipt at ${outputReceipt}`); + } + console.log(readFileSync(outputReceipt, "utf8")); +} + +switch (command) { + case "doctor": + doctor(); + break; + case "setup": + setup(); + break; + case "build-demo": + buildGuestBundle(guest); + break; + case "build-runtime": + buildRuntime(); + break; + case "build": + build(); + break; + case "receipt": + receipt(); + break; + case "deploy": + deploy(); + break; + case "run": + runOnDevice(); + break; + case "stop-ui": + stopUi(); + break; + default: + throw new Error( + "usage: bun tools/d211-linux.ts ", + ); +} diff --git a/tools/d211-linux/build-runtime.sh b/tools/d211-linux/build-runtime.sh new file mode 100755 index 000000000..fac35cc68 --- /dev/null +++ b/tools/d211-linux/build-runtime.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash +# +# D211 cross-build: QuickJS + PocketJS runtime + fbdev host, linked with the +# Luban Xuantie GCC wrapper and LLVM LLD. +# +# Runs on the canonical builder (Ubuntu x86_64) with the Luban SDK already +# built. The host object graph is compiled with the SDK wrapper/sysroot; the +# final link uses the LLD shipped with the pinned Rust nightly because Luban's +# binutils 2.35 cannot parse modern RISC-V ELF attributes (Zaamo/Zalrsc). +# +# Environment: +# POCKET_BUILD_ID unique id baked into the binary +# POCKETJS_TARGET_ID verified ResolvedBuildPlan target +# POCKETJS_HOST_ABI verified ResolvedBuildPlan host ABI +# POCKET_RASTER_DENSITY plan raster density +# POCKET_LOGICAL_WIDTH/HEIGHT logical viewport +# REPO_ROOT PocketJS checkout +# BUILD_DIR scratch output directory +# LUBAN_OUTPUT_DIR /output/ +# LUBAN_GCC_PREFIX e.g. riscv64-unknown-linux-gnu +# LLD_SHIM_DIR directory with ld.lld and -ld.lld symlinks +# QUICKJS_VERSION pinned QuickJS version string +# QUICKJS_DIR libquickjs-sys/embed/quickjs +# QUICKJS_STATIC_FUNCTIONS libquickjs-sys/embed/static-functions.c +# RUST_CORE_ARCHIVE libpocketjs_symbian_core.a + +set -euo pipefail + +: "${POCKET_BUILD_ID:?missing POCKET_BUILD_ID}" +: "${POCKETJS_TARGET_ID:?missing POCKETJS_TARGET_ID}" +: "${POCKETJS_HOST_ABI:?missing POCKETJS_HOST_ABI}" +: "${POCKET_RASTER_DENSITY:?missing POCKET_RASTER_DENSITY}" +: "${POCKET_LOGICAL_WIDTH:?missing POCKET_LOGICAL_WIDTH}" +: "${POCKET_LOGICAL_HEIGHT:?missing POCKET_LOGICAL_HEIGHT}" +: "${REPO_ROOT:?missing REPO_ROOT}" +: "${BUILD_DIR:?missing BUILD_DIR}" +: "${LUBAN_OUTPUT_DIR:?missing LUBAN_OUTPUT_DIR}" +: "${LUBAN_GCC_PREFIX:?missing LUBAN_GCC_PREFIX}" +: "${LLD_SHIM_DIR:?missing LLD_SHIM_DIR}" +: "${QUICKJS_VERSION:?missing QUICKJS_VERSION}" +: "${QUICKJS_DIR:?missing QUICKJS_DIR}" +: "${QUICKJS_STATIC_FUNCTIONS:?missing QUICKJS_STATIC_FUNCTIONS}" +: "${RUST_CORE_ARCHIVE:?missing RUST_CORE_ARCHIVE}" + +gcc="$LUBAN_OUTPUT_DIR/host/bin/$LUBAN_GCC_PREFIX-gcc" +ar="$LUBAN_OUTPUT_DIR/host/bin/$LUBAN_GCC_PREFIX-ar" +readelf="$LUBAN_OUTPUT_DIR/host/bin/$LUBAN_GCC_PREFIX-readelf" +nm="$LUBAN_OUTPUT_DIR/host/bin/$LUBAN_GCC_PREFIX-nm" + +for tool in "$gcc" "$ar" "$readelf" "$nm"; do + if [[ ! -x "$tool" ]]; then + echo "d211 build: missing Luban tool $tool" >&2 + exit 1 + fi +done +if [[ ! -e "$LLD_SHIM_DIR/ld.lld" ]]; then + echo "d211 build: no LLD shim in $LLD_SHIM_DIR" >&2 + exit 1 +fi + +objects="$BUILD_DIR/objects" +quickjs_objects="$objects/quickjs" +staging="$BUILD_DIR/staging" +mkdir -p "$quickjs_objects" "$staging" + +quickjs_flags=( + -std=gnu11 + -O2 + -fPIC + -funsigned-char + -fno-strict-aliasing + -ffunction-sections + -fdata-sections + -D_GNU_SOURCE + -DCONFIG_VERSION=\""$QUICKJS_VERSION"\" + -I"$QUICKJS_DIR" + -Wno-unused-parameter +) + +quickjs_object_paths=() +for source in cutils.c dtoa.c libregexp.c libunicode.c quickjs.c; do + object="$quickjs_objects/${source%.c}.o" + "$gcc" "${quickjs_flags[@]}" -c "$QUICKJS_DIR/$source" -o "$object" + quickjs_object_paths+=("$object") +done +static_object="$quickjs_objects/static-functions.o" +"$gcc" "${quickjs_flags[@]}" -c "$QUICKJS_STATIC_FUNCTIONS" -o "$static_object" +quickjs_object_paths+=("$static_object") +"$ar" rcs "$BUILD_DIR/libquickjs.a" "${quickjs_object_paths[@]}" + +first_party_flags=( + -std=gnu11 + -Os + -fPIE + -fno-strict-aliasing + -ffunction-sections + -fdata-sections + -Wall + -Wextra + -Werror + -Wno-unused-parameter +) + +"$gcc" "${first_party_flags[@]}" \ + -DPOCKETJS_TARGET_ID=\""$POCKETJS_TARGET_ID"\" \ + -DPOCKETJS_HOST_ABI="$POCKETJS_HOST_ABI" \ + -DPOCKET_RASTER_DENSITY="$POCKET_RASTER_DENSITY" \ + -I"$REPO_ROOT/engine/quickjs-c" \ + -I"$REPO_ROOT/engine/ui-cabi/include" \ + -I"$REPO_ROOT/contracts/generated" \ + -I"$QUICKJS_DIR" \ + -c "$REPO_ROOT/engine/quickjs-c/pocket_runtime.c" \ + -o "$objects/pocket_runtime.o" + +"$gcc" "${first_party_flags[@]}" \ + -c "$REPO_ROOT/engine/quickjs-c/rust_eh_personality.c" \ + -o "$objects/rust_eh_personality.o" + +"$gcc" "${first_party_flags[@]}" \ + -DPOCKET_BUILD_ID=\""$POCKET_BUILD_ID"\" \ + -DPOCKET_RASTER_DENSITY="$POCKET_RASTER_DENSITY" \ + -DPOCKET_LOGICAL_WIDTH="$POCKET_LOGICAL_WIDTH" \ + -DPOCKET_LOGICAL_HEIGHT="$POCKET_LOGICAL_HEIGHT" \ + -I"$REPO_ROOT/engine/quickjs-c" \ + -I"$REPO_ROOT/engine/ui-cabi/include" \ + -I"$REPO_ROOT/hosts/d211-linux" \ + -c "$REPO_ROOT/hosts/d211-linux/main.c" \ + -o "$objects/main.o" + +"$gcc" "${first_party_flags[@]}" \ + -I"$REPO_ROOT/hosts/d211-linux" \ + -c "$REPO_ROOT/hosts/d211-linux/input.c" \ + -o "$objects/input.o" + +"$gcc" \ + -B"$LLD_SHIM_DIR" \ + -fuse-ld=lld \ + -pie \ + -Wl,-z,relro \ + -Wl,-z,now \ + -Wl,--gc-sections \ + -Wl,--no-undefined \ + -o "$staging/pocketjs-d211" \ + "$objects/main.o" \ + "$objects/input.o" \ + "$objects/pocket_runtime.o" \ + "$objects/rust_eh_personality.o" \ + "$BUILD_DIR/libquickjs.a" \ + "$RUST_CORE_ARCHIVE" \ + -lm + +"$readelf" -h -l -A -d "$staging/pocketjs-d211" > "$BUILD_DIR/pocketjs-d211.readelf.txt" +"$nm" -g "$staging/pocketjs-d211" > "$BUILD_DIR/pocketjs-d211.symbols.txt" + +ls -l "$staging/pocketjs-d211" diff --git a/tools/d211-linux/remote-build.sh b/tools/d211-linux/remote-build.sh new file mode 100755 index 000000000..cb312748e --- /dev/null +++ b/tools/d211-linux/remote-build.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# +# One-command rebuild from macOS: sync the checkout to a canonical builder, +# build there with the Luban toolchain, and bring the artifacts back. +# +# D211_REMOTE=user@host tools/d211-linux/remote-build.sh +# D211_REMOTE=user@host D211_REMOTE_PORT=2222 tools/d211-linux/remote-build.sh +# +# D211_REMOTE is required. D211_REMOTE_PORT is optional and only needed when +# the builder does not listen on the SSH default. GNU rsync is required on +# macOS (`brew install rsync`). The remote tree is the build mirror of this +# checkout; --delete only ever touches that tree (dist/, .pocket/, +# .pocket-build/, node_modules/ are excluded and preserved). + +set -euo pipefail + +: "${D211_REMOTE:?set D211_REMOTE to the builder SSH target (user@host)}" +remote="$D211_REMOTE" +ssh_port="" +if [[ -n "${D211_REMOTE_PORT:-}" ]]; then + ssh_port="-p $D211_REMOTE_PORT" +fi + +# The port flag is intentionally word-split; it is empty or "-p ". +remote_home="$(ssh $ssh_port "$remote" 'printf %s "$HOME"')" +remote_repository="${D211_REMOTE_REPOSITORY:-$remote_home/pocketjs}" + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +echo "d211: syncing $root -> $remote:$remote_repository" +rsync -az --delete \ + --exclude node_modules \ + --exclude .pocket \ + --exclude dist \ + --exclude .pocket-build \ + -e "ssh $ssh_port" \ + "$root/" "$remote:$remote_repository/" + +echo "d211: building on $remote" +ssh $ssh_port "$remote" \ + "bash -lc 'cd $remote_repository && export PATH=\$HOME/.bun/bin:\$PATH && bun tools/d211-linux.ts build'" + +mkdir -p "$root/dist/d211-linux" +echo "d211: fetching artifacts" +rsync -az \ + -e "ssh $ssh_port" \ + "$remote:$remote_repository/dist/d211-linux/" "$root/dist/d211-linux/" + +echo "d211: artifacts in $root/dist/d211-linux" +ls -l "$root/dist/d211-linux" From 951baf538d20883656e380ca3d56e2afd582d74a Mon Sep 17 00:00:00 2001 From: boa-z Date: Sat, 12 Sep 2026 08:31:10 +0800 Subject: [PATCH 3/8] test(d211-linux): cover profile, host probes, and build bridge --- tests/d211-linux-profile.test.ts | 158 +++++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 tests/d211-linux-profile.test.ts diff --git a/tests/d211-linux-profile.test.ts b/tests/d211-linux-profile.test.ts new file mode 100644 index 000000000..d6cbc821c --- /dev/null +++ b/tests/d211-linux-profile.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { POCKET_TARGETS } from "../contracts/spec/platforms.ts"; +import { checkAppTypes } from "../framework/compiler/app-check.ts"; +import { verifyPlanHash } from "../framework/src/manifest/plan.ts"; +import { + D211_LINUX_DEV_CONTRACTS, + D211_LINUX_DEV_HOST_ABI, + D211_LINUX_DEV_TARGET_ID, + D211_LINUX_LOGICAL_VIEWPORT, + D211_LINUX_PHYSICAL_VIEWPORT, + resolveD211LinuxBuildPlan, +} from "../tools/d211-linux-profile.ts"; + +const repository = join(import.meta.dir, ".."); +const manifestPath = join(repository, "apps/d211-demo/pocket.json"); + +function manifest(): Record { + return JSON.parse(readFileSync(manifestPath, "utf8")); +} + +describe("private D211 Linux build profile", () => { + test("uses the fbdev display and touch contract without changing public targets", () => { + expect(POCKET_TARGETS).not.toHaveProperty(D211_LINUX_DEV_TARGET_ID); + expect(D211_LINUX_DEV_CONTRACTS.targets[D211_LINUX_DEV_TARGET_ID]).toEqual({ + hostAbi: D211_LINUX_DEV_HOST_ABI, + platform: "linux", + form: "takeover", + display: { + physicalViewport: D211_LINUX_PHYSICAL_VIEWPORT, + logicalViewports: [D211_LINUX_LOGICAL_VIEWPORT], + presentations: ["native"], + rasterDensity: 1, + }, + capabilities: ["input.touch", "text.glyphs.baked"], + }); + }); + + test("resolves the demo to the exact hardware plan", () => { + const plan = resolveD211LinuxBuildPlan(manifest()); + expect(plan.target).toEqual({ + id: D211_LINUX_DEV_TARGET_ID, + hostAbi: D211_LINUX_DEV_HOST_ABI, + }); + expect(plan.viewport).toEqual({ + logical: D211_LINUX_LOGICAL_VIEWPORT, + physical: D211_LINUX_PHYSICAL_VIEWPORT, + presentation: "native", + rasterDensity: 1, + policy: "fixed", + }); + expect(plan.features).toEqual({ + "input.touch": true, + "text.glyphs.baked": true, + }); + expect(verifyPlanHash(plan)).toBe(true); + }); + + test("rejects unsupported capabilities and a stretched viewport", () => { + const needsButtons = manifest(); + needsButtons.engine.capabilities.requires.push("input.buttons"); + expect(() => resolveD211LinuxBuildPlan(needsButtons)).toThrow("input.buttons"); + + const stretched = manifest(); + stretched.app.viewport.fixed.logical = [400, 240]; + expect(() => resolveD211LinuxBuildPlan(stretched)).toThrow("400x240"); + }); + + test("type-checks explicit PocketJS imports in the Solid demo", () => { + const result = checkAppTypes({ + entry: join(repository, "apps/d211-demo/main.tsx"), + tsconfigPath: join(repository, "tsconfig.json"), + declarationFiles: [join(repository, "framework/src/jsx.d.ts")], + }); + expect( + result.diagnostics + .filter((diagnostic) => diagnostic.category === "error") + .map((diagnostic) => diagnostic.message), + ).toEqual([]); + expect(result.ok).toBe(true); + }); + + test("pins the Luban toolchain, Rust target, and QuickJS revision", () => { + const toolchain = JSON.parse( + readFileSync(join(repository, "tools/cli/d211-linux-toolchain.json"), "utf8"), + ); + expect(toolchain).toMatchObject({ + toolchainVersion: "d211-linux-riscv64-luban-v1", + luban: { + outputDirectory: "output/d211", + gccPrefix: "riscv64-unknown-linux-gnu", + }, + rust: { + toolchain: "nightly-2026-07-02", + target: "riscv64gc-unknown-linux-gnu", + }, + device: { + name: "ArtInChip D211DBV (SPI NAND)", + physicalViewport: [800, 480], + logicalViewport: [800, 480], + rasterDensity: 1, + }, + }); + expect(toolchain.quickjs.revision).toMatch(/^[0-9a-f]{40}$/); + expect(toolchain).not.toHaveProperty("luban.sdkRoot"); + expect(JSON.stringify(toolchain)).not.toContain("/home/"); + }); + + test("the fbdev host probes the framebuffer and never hard-codes the touch node", () => { + const main = readFileSync(join(repository, "hosts/d211-linux/main.c"), "utf8"); + const input = readFileSync(join(repository, "hosts/d211-linux/input.c"), "utf8"); + const header = readFileSync( + join(repository, "engine/ui-cabi/include/pocket_ui_cabi.h"), + "utf8", + ); + expect(main).toContain("FBIOGET_VSCREENINFO"); + expect(main).toContain("FBIOGET_FSCREENINFO"); + expect(main).toContain("FBIOPAN_DISPLAY"); + expect(main).toContain("ui_render_incremental_scaled(POCKET_RASTER_DENSITY)"); + expect(main).toContain("pocket_runtime_damage_bounds"); + expect(main).toContain("pocket_runtime_hit_test_bounds"); + expect(main).toContain("pocket_runtime_tick(&input)"); + expect(main).not.toContain("/dev/input/event0"); + expect(input).toContain("EVIOCGNAME"); + expect(input).toContain("EVIOCGBIT"); + expect(input).toContain("EVIOCGABS"); + expect(input).toContain("ABS_MT_POSITION_X"); + expect(input).not.toContain("/dev/input/event0"); + expect(header).toContain("ui_render_incremental_scaled"); + }); + + test("the build bridges to LLD and installs on the rootfs", () => { + const script = readFileSync( + join(repository, "tools/d211-linux/build-runtime.sh"), + "utf8", + ); + const tooling = readFileSync(join(repository, "tools/d211-linux.ts"), "utf8"); + const workflow = readFileSync( + join(repository, "tools/d211-linux/remote-build.sh"), + "utf8", + ); + expect(script).toContain("-fuse-ld=lld"); + expect(script).toContain("LLD_SHIM_DIR"); + expect(script).toContain("--no-undefined"); + expect(script.split('-DPOCKET_RASTER_DENSITY="$POCKET_RASTER_DENSITY"').length - 1).toBe(2); + expect(script).not.toContain("rm -"); + expect(tooling).toContain('"bare-platform,software-only"'); + expect(tooling).toContain("D211_LUBAN_SDK"); + expect(tooling).toContain('const DEVICE_DIRECTORY = "/opt/pocketjs"'); + expect(tooling).not.toContain('"/tmp/pocketjs-d211"'); + expect(workflow).toContain("D211_REMOTE"); + expect(workflow).toContain("D211_REMOTE_PORT"); + expect(workflow).toContain("bun tools/d211-linux.ts build"); + expect(workflow).toContain("--delete"); + expect(workflow).not.toMatch(/[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/); + }); +}); From b5227099a3f278b608263dde568739526f29aa5f Mon Sep 17 00:00:00 2001 From: boa-z Date: Sat, 12 Sep 2026 08:31:10 +0800 Subject: [PATCH 4/8] test(platform-contracts): register the D211 demo in the admission matrix --- tests/platform-contracts.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/platform-contracts.test.ts b/tests/platform-contracts.test.ts index 707853b2e..36354b8e7 100644 --- a/tests/platform-contracts.test.ts +++ b/tests/platform-contracts.test.ts @@ -521,6 +521,7 @@ describe("semantic resolution", () => { const expected: Record = { "3ds-demo": [false, false, false, false], // requires the private 3ds-dev profile's auxiliary display and touch contracts "blackberry-classic-demo": [false, false, false, true], // built by the private blackberry-{qnx,android}-dev profiles; macos-app also admits its fixed 360x360 buttons+glyphs contract + "d211-demo": [false, false, false, false], // admitted only by the private d211-linux-dev profile (fixed 800x480 fbdev touch surface) cafe: [true, true, false, true], cards: [true, true, false, true], chrome: [true, true, false, true], From 14fedae6fb42b5523da008d64df8b9bde898c242 Mon Sep 17 00:00:00 2001 From: boa-z Date: Sat, 12 Sep 2026 08:46:58 +0800 Subject: [PATCH 5/8] feat(d211-linux): track evdev protocol-B multi-touch The touch backend now runs the protocol-B slot state machine (ABS_MT_SLOT, ABS_MT_TRACKING_ID, per-slot positions) and delivers every live contact through pocket_runtime_tick_contacts(), with the bounds hit resolved once at each contact's own down edge. Single-touch devices still map to one id-0 contact, and the host tracks up to D211_MAX_CONTACTS (8). Validated against the panel with a local probe: five simultaneous contacts, each id keeping its own coordinates and down-edge hit. stop-ui now sends SIGKILL because test_lvgl ignores SIGTERM and keeps the panel. --- hosts/d211-linux/README.md | 25 +++++--- hosts/d211-linux/d211-touch.png | Bin 0 -> 7825 bytes hosts/d211-linux/input.c | 103 ++++++++++++++++++++++++------- hosts/d211-linux/input.h | 45 ++++++++++---- hosts/d211-linux/main.c | 83 +++++++++++++++---------- tests/d211-linux-profile.test.ts | 6 +- tools/d211-linux.ts | 5 +- 7 files changed, 191 insertions(+), 76 deletions(-) create mode 100644 hosts/d211-linux/d211-touch.png diff --git a/hosts/d211-linux/README.md b/hosts/d211-linux/README.md index d5a78576b..cbef13f3e 100644 --- a/hosts/d211-linux/README.md +++ b/hosts/d211-linux/README.md @@ -42,12 +42,16 @@ layout coordinates map one-to-one onto the framebuffer. **The touch device is found by capability, not by `eventN`.** `d211_input_open` scans `/dev/input`, reads `EVIOCGNAME`, and accepts a device with the `ABS_MT_POSITION_X/Y` pair, falling back to `ABS_X/ABS_Y` plus `BTN_TOUCH`. The -board exposes `goodix-ts` with the MT pair and a 800×480 axis range. - -Raw axis values are scaled into the logical viewport with the `EVIOCGABS` -maximum. **The bounds hit is resolved once at the contact's down edge** with -`pocket_runtime_hit_test_bounds()`, and the same contact id is delivered for -the rest of the press. `POCKET_TOUCH_LOG=1` writes down/up edges with raw +panel exposes `goodix-ts` with the MT pair and a 800×480 axis range. + +**Multi-touch is parsed with the protocol-B slot state machine** +(`ABS_MT_SLOT`, `ABS_MT_TRACKING_ID`, per-slot positions) and delivered +through `pocket_runtime_tick_contacts()`; the host tracks up to +`D211_MAX_CONTACTS` (8) simultaneous contacts, and a single-contact device +maps to one id-0 contact. **Each contact's bounds hit is resolved once at its +own down edge** and carried for that contact's lifetime. Raw axis values are +scaled into the logical viewport with the `EVIOCGABS` maximum. +`POCKET_TOUCH_LOG=1` writes per-contact down/up edges with id, raw coordinates, logical coordinates, and the resolved hit to stderr. ## Memory @@ -86,7 +90,7 @@ workflow takes the builder from `D211_REMOTE` with an optional ```sh # on the machine with the D211 on USB -bun tools/d211-linux.ts stop-ui # test_lvgl owns the panel at boot +bun tools/d211-linux.ts stop-ui # test_lvgl ignores SIGTERM; stop-ui sends SIGKILL bun tools/d211-linux.ts deploy # /opt/pocketjs on the rootfs bun tools/d211-linux.ts run # foreground, stats on stderr ``` @@ -115,6 +119,13 @@ The 800×480 framebuffer was captured through `dd if=/dev/fb0` and converted to PNG; every pixel is non-black, the channel order matches the guest output, and the screenshot above is the captured frame after thirty-three taps. +The multi-contact path was validated with a local probe build +(`1a07303606c5edba`) that rendered one marker per contact: **five +simultaneous contacts** from the panel, each id keeping its own coordinates +through its down/up edges and its own down-edge hit. + +![Five simultaneous contacts, one marker per id](d211-touch.png) + `dist/d211-linux/build-receipt.json` records the PocketJS commit, the Rust toolchain and target, GCC and LLD versions, the sysroot path, the pinned QuickJS revision, and the guest, core, and executable digests. diff --git a/hosts/d211-linux/d211-touch.png b/hosts/d211-linux/d211-touch.png new file mode 100644 index 0000000000000000000000000000000000000000..77360ba2dfa475aaf3ad8c1f02fef5d16baf94fb GIT binary patch literal 7825 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 UUZqr5@CkJ8C!I6-YG%Lu7dp&~VgLXD literal 0 HcmV?d00001 diff --git a/hosts/d211-linux/input.c b/hosts/d211-linux/input.c index a4c4573b5..79261db0d 100644 --- a/hosts/d211-linux/input.c +++ b/hosts/d211-linux/input.c @@ -40,10 +40,12 @@ static int probe_device(int fd, D211Input *input) { int axis_x = -1; int axis_y = -1; + int multi_touch = 0; if (bit_is_set(abs_bits, ABS_MT_POSITION_X) && bit_is_set(abs_bits, ABS_MT_POSITION_Y)) { axis_x = ABS_MT_POSITION_X; axis_y = ABS_MT_POSITION_Y; + multi_touch = 1; } else if (bit_is_set(abs_bits, ABS_X) && bit_is_set(abs_bits, ABS_Y) && bit_is_set(key_bits, BTN_TOUCH)) { axis_x = ABS_X; @@ -61,6 +63,10 @@ static int probe_device(int fd, D211Input *input) { input->axis_y = axis_y; input->max_x = max_x; input->max_y = max_y; + input->multi_touch = multi_touch; + input->slot_axis = bit_is_set(abs_bits, ABS_MT_SLOT) ? ABS_MT_SLOT : -1; + input->tracking_axis = + bit_is_set(abs_bits, ABS_MT_TRACKING_ID) ? ABS_MT_TRACKING_ID : -1; return 1; } @@ -97,10 +103,16 @@ void d211_input_close(D211Input *input) { input->fd = -1; } -int d211_input_pump(D211Input *input) { +static int clamp_slot(int slot) { + if (slot < 0) return 0; + if (slot >= D211_MAX_CONTACTS) return D211_MAX_CONTACTS - 1; + return slot; +} + +unsigned int d211_input_pump(D211Input *input, D211ContactState *state) { + state->count = 0; if (input->fd < 0) return 0; - int saw_down = 0; struct input_event event; for (;;) { ssize_t bytes = read(input->fd, &event, sizeof(event)); @@ -112,32 +124,79 @@ int d211_input_pump(D211Input *input) { if (bytes != (ssize_t)sizeof(event)) break; if (event.type == EV_ABS) { - if (event.code == input->axis_x) { - input->x = event.value; - input->have_position = 1; - } else if (event.code == input->axis_y) { - input->y = event.value; - input->have_position = 1; - } else if (event.code == ABS_MT_TRACKING_ID) { - if (event.value >= 0) { - if (!input->down) { - input->down = 1; - saw_down = 1; + if (input->multi_touch) { + int slot = input->current_slot; + if (input->slot_axis >= 0 && event.code == input->slot_axis) { + input->current_slot = clamp_slot(event.value); + continue; + } + if (event.code == input->axis_x) { + input->slot_x[slot] = event.value; + } else if (event.code == input->axis_y) { + input->slot_y[slot] = event.value; + } else if (input->tracking_axis >= 0 && + event.code == input->tracking_axis) { + if (event.value >= 0) { + if (!input->slot_active[slot]) { + input->slot_active[slot] = 1; + input->slot_pending_down[slot] = 1; + } + } else { + input->slot_active[slot] = 0; } - } else { - input->down = 0; } + } else if (event.code == input->axis_x) { + input->single_x = event.value; + } else if (event.code == input->axis_y) { + input->single_y = event.value; } } else if (event.type == EV_KEY && event.code == BTN_TOUCH) { - if (event.value != 0) { - if (!input->down) { - input->down = 1; - saw_down = 1; + if (!input->multi_touch) { + if (event.value != 0) { + if (!input->single_down) { + input->single_down = 1; + input->single_pending_down = 1; + } + } else { + input->single_down = 0; } - } else { - input->down = 0; + } else if (input->tracking_axis < 0) { + /* Slot protocol without tracking ids: BTN_TOUCH owns the slot. */ + int slot = input->current_slot; + if (event.value != 0) { + if (!input->slot_active[slot]) { + input->slot_active[slot] = 1; + input->slot_pending_down[slot] = 1; + } + } else { + input->slot_active[slot] = 0; + } + } + } + } + + unsigned int down_mask = 0; + if (input->multi_touch) { + for (int slot = 0; slot < D211_MAX_CONTACTS; slot++) { + if (!input->slot_active[slot]) continue; + int index = state->count++; + state->contacts[index].id = slot; + state->contacts[index].x = input->slot_x[slot]; + state->contacts[index].y = input->slot_y[slot]; + if (input->slot_pending_down[slot]) { + down_mask |= 1u << index; + input->slot_pending_down[slot] = 0; } } + } else if (input->single_down) { + state->contacts[0].id = 0; + state->contacts[0].x = input->single_x; + state->contacts[0].y = input->single_y; + state->count = 1; + if (input->single_pending_down) { + down_mask = 1u; + input->single_pending_down = 0; + } } - return saw_down; + return down_mask; } diff --git a/hosts/d211-linux/input.h b/hosts/d211-linux/input.h index a89493e98..0fcc58f0a 100644 --- a/hosts/d211-linux/input.h +++ b/hosts/d211-linux/input.h @@ -4,22 +4,44 @@ /* * Linux evdev touch sampling for the D211 fbdev host. * - * The device is found by capability, never by a fixed /dev/input/eventN: the - * host scans /dev/input, reads EVIOCGNAME/EVIOCGBIT(EV_ABS), and prefers a - * multi-touch axis pair over the single-touch one. Raw axis values are scaled - * to the logical viewport by the caller. + * The device is found by capability, never by a fixed /dev/input/eventN. A + * multi-touch axis pair is preferred and parsed with the protocol-B slot + * state machine (ABS_MT_SLOT / ABS_MT_TRACKING_ID); ABS_X/ABS_Y plus + * BTN_TOUCH is the single-contact fallback. Raw axis values are scaled to + * the logical viewport by the caller. */ +#define D211_MAX_CONTACTS 8 + +typedef struct { + int id; /* contact slot, stable while the contact is down */ + int x; /* raw evdev coordinate */ + int y; +} D211Contact; + +typedef struct { + int count; + D211Contact contacts[D211_MAX_CONTACTS]; +} D211ContactState; + typedef struct { int fd; int axis_x; int axis_y; int max_x; int max_y; - int x; - int y; - int down; - int have_position; + int tracking_axis; + int slot_axis; + int multi_touch; + int current_slot; + int slot_active[D211_MAX_CONTACTS]; + int slot_x[D211_MAX_CONTACTS]; + int slot_y[D211_MAX_CONTACTS]; + int slot_pending_down[D211_MAX_CONTACTS]; + int single_down; + int single_x; + int single_y; + int single_pending_down; char name[64]; } D211Input; @@ -29,9 +51,10 @@ int d211_input_open(D211Input *input); void d211_input_close(D211Input *input); /** - * Drains pending events. Returns 1 when the contact went down in this batch, - * so the caller resolves the bounds hit once at the down edge. + * Drains pending events and fills the active-contact snapshot. Returns a + * bitmask over `state->contacts`: bit i is set when entry i went down in this + * batch, so the caller resolves each bounds hit once at its down edge. */ -int d211_input_pump(D211Input *input); +unsigned int d211_input_pump(D211Input *input, D211ContactState *state); #endif diff --git a/hosts/d211-linux/main.c b/hosts/d211-linux/main.c index adf25b672..292a70d93 100644 --- a/hosts/d211-linux/main.c +++ b/hosts/d211-linux/main.c @@ -412,12 +412,13 @@ int main(void) { if (touch_available) { fprintf( stderr, - "d211: touch %s axes=%d/%d range=%dx%d\n", + "d211: touch %s axes=%d/%d range=%dx%d %s\n", touch.name, touch.axis_x, touch.axis_y, touch.max_x, - touch.max_y + touch.max_y, + touch.multi_touch ? "multi" : "single" ); } else { fprintf(stderr, "d211: no touch-capable evdev device found\n"); @@ -463,8 +464,9 @@ int main(void) { D211Stats stats; memset(&stats, 0, sizeof(stats)); - int contact_hit = 0; - int last_down = 0; + int contact_hits[D211_MAX_CONTACTS]; + memset(contact_hits, 0, sizeof(contact_hits)); + unsigned int previous_active_mask = 0; int presented_once = 0; uint64_t frames = 0; uint64_t stats_start = now_ns(); @@ -473,33 +475,48 @@ int main(void) { while (!g_stop) { uint64_t frame_start = now_ns(); + D211ContactState contacts; + memset(&contacts, 0, sizeof(contacts)); if (touch_available) { struct pollfd descriptor; descriptor.fd = touch.fd; descriptor.events = POLLIN; uint64_t remaining = next_frame > frame_start ? next_frame - frame_start : 0; poll(&descriptor, 1, (int)(remaining / 1000000)); - int down_edge = d211_input_pump(&touch); - if (down_edge) { - /* The bounds hit is resolved once, at the contact's down edge. */ - int logical_x = scale_axis(touch.x, touch.max_x, POCKET_LOGICAL_WIDTH); - int logical_y = scale_axis(touch.y, touch.max_y, POCKET_LOGICAL_HEIGHT); - contact_hit = pocket_runtime_hit_test_bounds((float)logical_x, (float)logical_y); - if (touch_log) { - fprintf( - stderr, - "d211: touch down raw=(%d,%d) logical=(%d,%d) hit=%d\n", - touch.x, - touch.y, - logical_x, - logical_y, - contact_hit + unsigned int down_edges = d211_input_pump(&touch, &contacts); + unsigned int active_mask = 0; + for (int index = 0; index < contacts.count; index++) { + const D211Contact *contact = &contacts.contacts[index]; + active_mask |= 1u << contact->id; + int logical_x = scale_axis(contact->x, touch.max_x, POCKET_LOGICAL_WIDTH); + int logical_y = scale_axis(contact->y, touch.max_y, POCKET_LOGICAL_HEIGHT); + if ((down_edges & (1u << index)) != 0) { + /* The bounds hit is resolved once, at the contact's down edge. */ + contact_hits[contact->id] = pocket_runtime_hit_test_bounds( + (float)logical_x, + (float)logical_y ); + if (touch_log) { + fprintf( + stderr, + "d211: touch down id=%d raw=(%d,%d) logical=(%d,%d) hit=%d\n", + contact->id, + contact->x, + contact->y, + logical_x, + logical_y, + contact_hits[contact->id] + ); + } } - } else if (touch_log && !touch.down && last_down) { - fprintf(stderr, "d211: touch up\n"); } - last_down = touch.down; + for (int id = 0; id < D211_MAX_CONTACTS; id++) { + if ((active_mask & (1u << id)) == 0 && (previous_active_mask & (1u << id)) != 0) { + contact_hits[id] = 0; + if (touch_log) fprintf(stderr, "d211: touch up id=%d\n", id); + } + } + previous_active_mask = active_mask; } else { uint64_t remaining = next_frame > frame_start ? next_frame - frame_start : 0; struct timespec pause; @@ -508,19 +525,19 @@ int main(void) { nanosleep(&pause, 0); } - PocketRuntimeInput input; - input.buttons = 0; - input.touch_down = touch_available ? touch.down : 0; - input.touch_x = touch_available - ? scale_axis(touch.x, touch.max_x, POCKET_LOGICAL_WIDTH) - : 0; - input.touch_y = touch_available - ? scale_axis(touch.y, touch.max_y, POCKET_LOGICAL_HEIGHT) - : 0; - input.touch_hit = input.touch_down ? contact_hit : 0; + PocketRuntimeContactsInput input; + memset(&input, 0, sizeof(input)); + for (int index = 0; index < contacts.count; index++) { + const D211Contact *contact = &contacts.contacts[index]; + PocketRuntimeContact *output = &input.contacts[input.contact_count++]; + output->id = contact->id; + output->x = scale_axis(contact->x, touch.max_x, POCKET_LOGICAL_WIDTH); + output->y = scale_axis(contact->y, touch.max_y, POCKET_LOGICAL_HEIGHT); + output->hit = contact_hits[contact->id]; + } uint64_t tick_start = now_ns(); - if (!pocket_runtime_tick(&input)) { + if (!pocket_runtime_tick_contacts(&input)) { fprintf(stderr, "d211: tick failed: %s\n", pocket_runtime_error()); break; } diff --git a/tests/d211-linux-profile.test.ts b/tests/d211-linux-profile.test.ts index d6cbc821c..7fa199e03 100644 --- a/tests/d211-linux-profile.test.ts +++ b/tests/d211-linux-profile.test.ts @@ -120,12 +120,15 @@ describe("private D211 Linux build profile", () => { expect(main).toContain("ui_render_incremental_scaled(POCKET_RASTER_DENSITY)"); expect(main).toContain("pocket_runtime_damage_bounds"); expect(main).toContain("pocket_runtime_hit_test_bounds"); - expect(main).toContain("pocket_runtime_tick(&input)"); + expect(main).toContain("pocket_runtime_tick_contacts(&input)"); + expect(main).toContain("PocketRuntimeContactsInput"); expect(main).not.toContain("/dev/input/event0"); expect(input).toContain("EVIOCGNAME"); expect(input).toContain("EVIOCGBIT"); expect(input).toContain("EVIOCGABS"); expect(input).toContain("ABS_MT_POSITION_X"); + expect(input).toContain("ABS_MT_SLOT"); + expect(input).toContain("ABS_MT_TRACKING_ID"); expect(input).not.toContain("/dev/input/event0"); expect(header).toContain("ui_render_incremental_scaled"); }); @@ -147,6 +150,7 @@ describe("private D211 Linux build profile", () => { expect(script).not.toContain("rm -"); expect(tooling).toContain('"bare-platform,software-only"'); expect(tooling).toContain("D211_LUBAN_SDK"); + expect(tooling).toContain('"killall", "-9"'); expect(tooling).toContain('const DEVICE_DIRECTORY = "/opt/pocketjs"'); expect(tooling).not.toContain('"/tmp/pocketjs-d211"'); expect(workflow).toContain("D211_REMOTE"); diff --git a/tools/d211-linux.ts b/tools/d211-linux.ts index 93e5d5c5a..866ff3f3c 100644 --- a/tools/d211-linux.ts +++ b/tools/d211-linux.ts @@ -398,8 +398,9 @@ function runOnDevice(): void { function stopUi(): void { requireAdb(); - run("adb", ["shell", "killall", "test_lvgl"]); - console.log(`${LABEL}: stopped test_lvgl when present`); + /* test_lvgl ignores SIGTERM; SIGKILL is what actually releases the panel. */ + run("adb", ["shell", "killall", "-9", "test_lvgl"]); + console.log(`${LABEL}: stopped test_lvgl (SIGKILL) when present`); } function receipt(): void { From 73805cc111ab36427627dc1b3da9c7f3ef4c9d9f Mon Sep 17 00:00:00 2001 From: boa-z Date: Sat, 12 Sep 2026 13:08:48 +0800 Subject: [PATCH 6/8] feat(quickjs-c): host audio and backlight module ops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The C runtime could install only the ui surface; native hosts had no way to expose device services to the guest. Add two optional host op tables: - PocketAudioOps installs globalThis.audio with the method set the audio spec already pins (contracts/spec/audio.ts); hosts fill the table before boot and leave it absent otherwise (the framework player stays a no-op). - PocketBacklightOps installs globalThis.backlight { get, set(percent) } — pinned here until a display spec lands. Both follow the existing ui-module installation shape; no wire changes, no behavior change for hosts that pass no tables. --- engine/quickjs-c/pocket_runtime.c | 201 +++++++++++++++++++++++++++++- engine/quickjs-c/pocket_runtime.h | 39 ++++++ 2 files changed, 239 insertions(+), 1 deletion(-) diff --git a/engine/quickjs-c/pocket_runtime.c b/engine/quickjs-c/pocket_runtime.c index 388acafca..0297ae1a6 100644 --- a/engine/quickjs-c/pocket_runtime.c +++ b/engine/quickjs-c/pocket_runtime.c @@ -94,6 +94,8 @@ static char reported_action_name[POCKETJS_ACTION_NAME_CAPACITY]; static int32_t reported_action_value; static unsigned long reported_action_sequence; static int runtime_failed; +static const PocketAudioOps *audio_ops; +static const PocketBacklightOps *backlight_ops; #ifdef POCKET_SVC_WIRE /* spec SVC_POLL_BUF (8192) + terminator: one svcPoll batch. */ static char svc_poll_buffer[8193]; @@ -468,6 +470,203 @@ static JSValue host_operation( return JS_ThrowInternalError(ctx, "unknown PocketJS HostOp"); } +/* ------------------------------------------------------------------ */ +/* Host modules: audio (contracts/spec/audio.ts) and backlight. */ +/* The host fills the op tables before boot; absent tables leave the */ +/* globals unset and the framework player degrades to a no-op. */ + +void pocket_runtime_set_audio_ops(const PocketAudioOps *ops) { + audio_ops = ops; +} + +void pocket_runtime_set_backlight_ops(const PocketBacklightOps *ops) { + backlight_ops = ops; +} + +static JSValue host_audio_create_stream( + JSContext *ctx, + JSValueConst this_value, + int argc, + JSValueConst *argv +) { + uint32_t rate = 0; + uint32_t channels = 0; + if (!uint_argument(ctx, argc, argv, 0, &rate)) return JS_EXCEPTION; + if (!uint_argument(ctx, argc, argv, 1, &channels)) return JS_EXCEPTION; + return JS_NewInt32(ctx, audio_ops->create_stream(rate, channels)); +} + +static JSValue host_audio_destroy_stream( + JSContext *ctx, + JSValueConst this_value, + int argc, + JSValueConst *argv +) { + int32_t handle = 0; + if (!int_argument(ctx, argc, argv, 0, &handle)) return JS_EXCEPTION; + audio_ops->destroy_stream(handle); + return JS_UNDEFINED; +} + +static JSValue host_audio_write_pcm( + JSContext *ctx, + JSValueConst this_value, + int argc, + JSValueConst *argv +) { + int32_t handle = 0; + uint8_t *bytes = 0; + size_t length = 0; + if (!int_argument(ctx, argc, argv, 0, &handle)) return JS_EXCEPTION; + if (!bytes_argument(ctx, argc, argv, 1, &bytes, &length)) return JS_EXCEPTION; + return JS_NewInt32(ctx, audio_ops->write_pcm(handle, bytes, length)); +} + +static JSValue host_audio_play( + JSContext *ctx, + JSValueConst this_value, + int argc, + JSValueConst *argv +) { + int32_t handle = 0; + if (!int_argument(ctx, argc, argv, 0, &handle)) return JS_EXCEPTION; + audio_ops->play(handle); + return JS_UNDEFINED; +} + +static JSValue host_audio_pause( + JSContext *ctx, + JSValueConst this_value, + int argc, + JSValueConst *argv +) { + int32_t handle = 0; + if (!int_argument(ctx, argc, argv, 0, &handle)) return JS_EXCEPTION; + audio_ops->pause(handle); + return JS_UNDEFINED; +} + +static JSValue host_audio_stop( + JSContext *ctx, + JSValueConst this_value, + int argc, + JSValueConst *argv +) { + int32_t handle = 0; + if (!int_argument(ctx, argc, argv, 0, &handle)) return JS_EXCEPTION; + audio_ops->stop(handle); + return JS_UNDEFINED; +} + +static JSValue host_audio_set_volume( + JSContext *ctx, + JSValueConst this_value, + int argc, + JSValueConst *argv +) { + int32_t handle = 0; + double volume = 1.0; + if (!int_argument(ctx, argc, argv, 0, &handle)) return JS_EXCEPTION; + if (argc < 2 || JS_ToFloat64(ctx, &volume, argv[1]) != 0) { + JS_ThrowTypeError(ctx, "missing argument 1"); + return JS_EXCEPTION; + } + if (volume < 0.0) volume = 0.0; + if (volume > 1.0) volume = 1.0; + audio_ops->set_volume(handle, volume); + return JS_UNDEFINED; +} + +static JSValue host_audio_end_stream( + JSContext *ctx, + JSValueConst this_value, + int argc, + JSValueConst *argv +) { + int32_t handle = 0; + if (!int_argument(ctx, argc, argv, 0, &handle)) return JS_EXCEPTION; + audio_ops->end_stream(handle); + return JS_UNDEFINED; +} + +static JSValue host_audio_poll( + JSContext *ctx, + JSValueConst this_value, + int argc, + JSValueConst *argv +) { + const char *line = audio_ops->poll(); + return line == 0 ? JS_UNDEFINED : JS_NewString(ctx, line); +} + +static JSValue host_backlight_get( + JSContext *ctx, + JSValueConst this_value, + int argc, + JSValueConst *argv +) { + return JS_NewInt32(ctx, backlight_ops->get()); +} + +static JSValue host_backlight_set( + JSContext *ctx, + JSValueConst this_value, + int argc, + JSValueConst *argv +) { + int32_t percent = 100; + if (!int_argument(ctx, argc, argv, 0, &percent)) return JS_EXCEPTION; + if (percent < 0) percent = 0; + if (percent > 100) percent = 100; + backlight_ops->set(percent); + return JS_UNDEFINED; +} + +static int add_module_function( + JSContext *ctx, + JSValueConst object, + const char *name, + int arity, + JSCFunction *function +) { + JSValue value = JS_NewCFunction(ctx, function, name, arity); + if (JS_IsException(value)) return 0; + return JS_SetPropertyStr(ctx, object, name, value) >= 0; +} + +static int install_audio(void) { + JSValue audio; + if (audio_ops == 0) return 1; + audio = JS_NewObject(context); + if (JS_IsException(audio)) return 0; + if (!add_module_function(context, audio, "createStream", 2, host_audio_create_stream) || + !add_module_function(context, audio, "destroyStream", 1, host_audio_destroy_stream) || + !add_module_function(context, audio, "writePcm", 2, host_audio_write_pcm) || + !add_module_function(context, audio, "play", 1, host_audio_play) || + !add_module_function(context, audio, "pause", 1, host_audio_pause) || + !add_module_function(context, audio, "stop", 1, host_audio_stop) || + !add_module_function(context, audio, "setVolume", 2, host_audio_set_volume) || + !add_module_function(context, audio, "endStream", 1, host_audio_end_stream) || + !add_module_function(context, audio, "poll", 0, host_audio_poll)) { + JS_FreeValue(context, audio); + return 0; + } + return JS_SetPropertyStr(context, global, "audio", audio) >= 0; +} + +static int install_backlight(void) { + JSValue backlight; + if (backlight_ops == 0) return 1; + backlight = JS_NewObject(context); + if (JS_IsException(backlight)) return 0; + if (!add_module_function(context, backlight, "get", 0, host_backlight_get) || + !add_module_function(context, backlight, "set", 1, host_backlight_set)) { + JS_FreeValue(context, backlight); + return 0; + } + return JS_SetPropertyStr(context, global, "backlight", backlight) >= 0; +} + static int add_host_operation( JSContext *ctx, JSValueConst object, @@ -630,7 +829,7 @@ int pocket_runtime_boot( } REPORT_BOOT_STAGE(5); global = JS_GetGlobalObject(context); - if (!install_host(width, height)) { + if (!install_host(width, height) || !install_audio() || !install_backlight()) { take_exception(context); pocket_runtime_shutdown(); return 0; diff --git a/engine/quickjs-c/pocket_runtime.h b/engine/quickjs-c/pocket_runtime.h index 10b44301e..5b7af91f5 100644 --- a/engine/quickjs-c/pocket_runtime.h +++ b/engine/quickjs-c/pocket_runtime.h @@ -27,6 +27,45 @@ int pocket_runtime_boot( int width, int height ); +/* + * Host module ops (append-only). A host that has device audio fills the table + * and installs it BEFORE pocket_runtime_boot: the runtime then exposes + * `globalThis.audio` with the methods the audio spec pins + * (contracts/spec/audio.ts: createStream/destroyStream/writePcm/play/pause/ + * stop/setVolume/endStream/poll). Hosts without the table leave the global + * absent and the framework's player degrades to a silent no-op. + * + * Frame/tick contract: the host owns the native audio clock and MUST NOT call + * into the guest; consumed frames surface as `credit`/`underrun`/`ended` + * event lines returned one per `poll()` call (JSON, shapes in the spec). + * write_pcm BORROWS the caller's buffer for the duration of the call. + */ +typedef struct { + int (*create_stream)(unsigned int sample_rate, unsigned int channels); + void (*destroy_stream)(int handle); + /* Returns frames accepted; `bytes` is interleaved s16 LE at the stream rate. */ + int (*write_pcm)(int handle, const void *pcm, size_t bytes); + void (*play)(int handle); + void (*pause)(int handle); + void (*stop)(int handle); + void (*set_volume)(int handle, double volume); + void (*end_stream)(int handle); + /* One queued event line, or NULL when the queue is empty. */ + const char *(*poll)(void); +} PocketAudioOps; + +/* + * Backlight ops (host module `globalThis.backlight`): percent 0-100, with the + * host mapping to its native scale. Not yet a spec module — the method set is + * pinned here until a display spec lands. + */ +typedef struct { + int (*get)(void); + void (*set)(int percent); +} PocketBacklightOps; + +void pocket_runtime_set_audio_ops(const PocketAudioOps *ops); +void pocket_runtime_set_backlight_ops(const PocketBacklightOps *ops); /* `pack` is borrowed by QuickJS and must remain valid until shutdown. */ /* * One guest turn followed by exactly one core tick — the frame contract From ae01c718ad19f5b31c9273432519631af603309b Mon Sep 17 00:00:00 2001 From: boa-z Date: Sat, 12 Sep 2026 13:29:59 +0800 Subject: [PATCH 7/8] feat(d211-linux): audio and backlight host modules Wire two host modules into the d211 runtime: - audio: PocketAudioOps on top of one `aplay -t raw` child per stream and a feeder thread over a frame ring; credits follow consumption, starvation is edge-reported as underrun, endStream drains then reports ended; volume is a soft gain. Matches contracts/spec/audio.ts; the framework player now works unchanged (load("btn") -> createStream -> writePcm -> play -> credit). - backlight: PocketBacklightOps over /sys/class/backlight (percent 0-100). main.c sets both tables before boot; build-runtime.sh compiles the two new sources and links -lpthread. The runtime-side hook is the quickjs-c commit below (stacked PR). --- engine/quickjs-c/pocket_runtime.c | 2 +- hosts/d211-linux/audio.c | 435 ++++++++++++++++++++++++++++ hosts/d211-linux/audio.h | 14 + hosts/d211-linux/backlight.c | 84 ++++++ hosts/d211-linux/backlight.h | 14 + hosts/d211-linux/main.c | 70 +++-- tools/d211-linux/build-runtime.sh | 16 +- tools/d211-linux/ge-probe.c | 456 ++++++++++++++++++++++++++++++ 8 files changed, 1060 insertions(+), 31 deletions(-) create mode 100644 hosts/d211-linux/audio.c create mode 100644 hosts/d211-linux/audio.h create mode 100644 hosts/d211-linux/backlight.c create mode 100644 hosts/d211-linux/backlight.h create mode 100644 tools/d211-linux/ge-probe.c diff --git a/engine/quickjs-c/pocket_runtime.c b/engine/quickjs-c/pocket_runtime.c index 0297ae1a6..3363420d0 100644 --- a/engine/quickjs-c/pocket_runtime.c +++ b/engine/quickjs-c/pocket_runtime.c @@ -515,7 +515,7 @@ static JSValue host_audio_write_pcm( JSValueConst *argv ) { int32_t handle = 0; - uint8_t *bytes = 0; + const uint8_t *bytes = 0; size_t length = 0; if (!int_argument(ctx, argc, argv, 0, &handle)) return JS_EXCEPTION; if (!bytes_argument(ctx, argc, argv, 1, &bytes, &length)) return JS_EXCEPTION; diff --git a/hosts/d211-linux/audio.c b/hosts/d211-linux/audio.c new file mode 100644 index 000000000..933cd79d6 --- /dev/null +++ b/hosts/d211-linux/audio.c @@ -0,0 +1,435 @@ +/* + * d211 audio module: PocketAudioOps on top of a native audio clock. + * + * The device has ALSA (aplay) but no audio daemon; short UI sounds are the + * product's whole need, so a stream owns one `aplay -t raw` child and a + * feeder thread that moves PCM from a frame ring into the child's stdin. + * The guest sees the spec's credit discipline: every consumed chunk queues a + * credit line, starvation queues one underrun per episode, and endStream + * drains then queues ended. Volume is a soft gain applied while feeding, so + * no mixer state leaks into the system. + */ + +#include "audio.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define D211_AUDIO_MAX_STREAMS 2 +#define D211_AUDIO_RING_FRAMES 16384 +#define D211_AUDIO_EVENTS 32 +#define D211_AUDIO_FEED_CHUNK 512 + +typedef struct { + int used; + unsigned int rate; + unsigned int channels; + double volume; + int playing; + int ended; + int pid; + int fd; + int16_t *ring; + unsigned int head; + unsigned int tail; + unsigned int count; + pthread_t feeder; + int feeder_running; + int underrun_active; + pthread_mutex_t lock; + pthread_cond_t cond; + char events[D211_AUDIO_EVENTS][64]; + int event_head; + int event_tail; + /* credit 按“占用变化”合并上报:用变化序号而不是占用量本身比较—— + 占用可能在一批内先增后减回到旧值,只比值会丢掉这次变化,guest 的 + 免费帧镜像就再也恢复不了。写与消费都递增序号。 */ + unsigned int change_seq; + unsigned int reported_seq; + /* 设备时钟节拍:feeder 按采样率放行数据,避免把整个剪辑预灌进 aplay + 管道(那样 ring 会瞬间排空,ended 远早于真实播放结束)。 */ + unsigned long long next_write_ns; +} D211AudioStream; + +static D211AudioStream streams[D211_AUDIO_MAX_STREAMS]; + +/** 支持的采样率(与 contracts/spec/audio.ts 的 AUDIO_RATES 一致)。 */ +static int rate_supported(unsigned int rate) { + return rate == 44100 || rate == 22050 || rate == 11025; +} + +/** 入队一条事件(JSON 行,形状见 audio 规范)。 */ +static void queue_event(D211AudioStream *stream, const char *format, int value, unsigned int free_frames) { + int slot = stream->event_tail; + if ((slot + 1) % D211_AUDIO_EVENTS == stream->event_head) return; /* 队列满丢弃 */ + if (strcmp(format, "credit") == 0) { + snprintf(stream->events[slot], sizeof(stream->events[slot]), + "{\"t\":\"credit\",\"h\":%d,\"free\":%u}", value, free_frames); + } else { + snprintf(stream->events[slot], sizeof(stream->events[slot]), "{\"t\":\"%s\",\"h\":%d}", format, value); + } + stream->event_tail = (slot + 1) % D211_AUDIO_EVENTS; +} + +/** 启动 aplay 子进程(raw PCM 从管道读入)。 */ +static int spawn_aplay(D211AudioStream *stream) { + int pipe_fds[2]; + if (pipe(pipe_fds) != 0) return -1; + pid_t pid = fork(); + if (pid < 0) { + close(pipe_fds[0]); + close(pipe_fds[1]); + return -1; + } + if (pid == 0) { + char rate[16]; + char channels[8]; + snprintf(rate, sizeof(rate), "%u", stream->rate); + snprintf(channels, sizeof(channels), "%u", stream->channels); + dup2(pipe_fds[0], STDIN_FILENO); + close(pipe_fds[0]); + close(pipe_fds[1]); + int devnull = open("/dev/null", O_WRONLY); + if (devnull >= 0) { + dup2(devnull, STDOUT_FILENO); + dup2(devnull, STDERR_FILENO); + close(devnull); + } + /* 小缓冲(150ms)让“ring 排空”与真实播放结束对齐;默认缓冲约 1 秒, + 会让 endStream 提前触发,循环语音被截短。 */ + execl("/usr/bin/aplay", "aplay", "-q", "-t", "raw", "-f", "S16_LE", + "-r", rate, "-c", channels, + "--buffer-time=150000", "--period-time=50000", + "-", (char *)0); + _exit(127); + } + close(pipe_fds[0]); + stream->pid = (int)pid; + stream->fd = pipe_fds[1]; + return 0; +} + +/** 停止并回收 aplay 子进程。 */ +static void kill_aplay(D211AudioStream *stream) { + if (stream->fd >= 0) { + close(stream->fd); + stream->fd = -1; + } + if (stream->pid > 0) { + kill(stream->pid, SIGKILL); + waitpid(stream->pid, 0, 0); + stream->pid = -1; + } +} + +/** 喂给 aplay 的块(含软音量)。 */ +static int16_t feed_buffer[D211_AUDIO_FEED_CHUNK * 2]; + +/** feeder 线程:把 ring 里的帧写进 aplay,并按消费量发 credit。 */ +/** 单调时钟(纳秒)。 */ +static unsigned long long monotonic_ns(void) { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (unsigned long long)ts.tv_sec * 1000000000ull + (unsigned long long)ts.tv_nsec; +} + +static void *feeder_main(void *argument) { + D211AudioStream *stream = (D211AudioStream *)argument; + for (;;) { + unsigned int frames; + pthread_mutex_lock(&stream->lock); + while (stream->count == 0 && stream->playing && !stream->ended) { + if (!stream->underrun_active) { + stream->underrun_active = 1; + queue_event(stream, "underrun", (int)(stream - streams), 0); + } + pthread_cond_wait(&stream->cond, &stream->lock); + } + if (!stream->playing && !stream->ended) { + pthread_mutex_unlock(&stream->lock); + break; + } + if (stream->count == 0 && stream->ended) { + stream->playing = 0; + stream->ended = 0; + queue_event(stream, "ended", (int)(stream - streams), 0); + + pthread_mutex_unlock(&stream->lock); + break; + } + frames = stream->count < D211_AUDIO_FEED_CHUNK ? stream->count : D211_AUDIO_FEED_CHUNK; + for (unsigned int index = 0; index < frames * stream->channels; index += 1) { + int16_t sample = stream->ring[stream->head * stream->channels + index]; + if (stream->volume < 1.0) { + sample = (int16_t)((double)sample * stream->volume); + } + feed_buffer[index] = sample; + } + stream->head = (stream->head + frames) % D211_AUDIO_RING_FRAMES; + stream->count -= frames; + stream->change_seq += 1; + stream->underrun_active = 0; + pthread_mutex_unlock(&stream->lock); + + /* 设备时钟节拍:每块按采样率放行(最多提前 2 块),aplay 的缓冲维持在 + 小块级别,ended 与真实播放结束对齐。 */ + unsigned long long now = monotonic_ns(); + if (stream->next_write_ns != 0 && stream->next_write_ns > now + 100000000ull) { + struct timespec until; + until.tv_sec = (time_t)(stream->next_write_ns / 1000000000ull); + until.tv_nsec = (long)(stream->next_write_ns % 1000000000ull); + clock_nanosleep(CLOCK_MONOTONIC, TIMER_ABSTIME, &until, 0); + now = monotonic_ns(); + } + unsigned long long step = (unsigned long long)frames * 1000000000ull / stream->rate; + stream->next_write_ns = (stream->next_write_ns == 0 ? now : stream->next_write_ns) + step; + + size_t bytes = (size_t)frames * stream->channels * sizeof(int16_t); + const uint8_t *cursor = (const uint8_t *)feed_buffer; + while (bytes > 0) { + ssize_t written = write(stream->fd, cursor, bytes); + if (written <= 0) { + if (errno == EINTR) continue; + break; + } + cursor += written; + bytes -= (size_t)written; + } + } + stream->feeder_running = 0; + return 0; +} + +/** 分配流句柄;不合法返回 -1。 */ +static int d211_audio_create_stream(unsigned int sample_rate, unsigned int channels) { + if (!rate_supported(sample_rate) || (channels != 1 && channels != 2)) { + fprintf(stderr, "d211: audio createStream refused rate=%u ch=%u\n", sample_rate, channels); + return -1; + } + for (int index = 0; index < D211_AUDIO_MAX_STREAMS; index += 1) { + D211AudioStream *stream = &streams[index]; + if (stream->used) continue; + stream->used = 1; + stream->rate = sample_rate; + stream->channels = channels; + stream->volume = 1.0; + stream->playing = 0; + stream->ended = 0; + stream->pid = -1; + stream->fd = -1; + stream->head = 0; + stream->tail = 0; + stream->count = 0; + stream->feeder_running = 0; + stream->underrun_active = 0; + stream->event_head = 0; + stream->event_tail = 0; + stream->change_seq = 0; + stream->reported_seq = 0; + pthread_mutex_init(&stream->lock, 0); + pthread_cond_init(&stream->cond, 0); + stream->ring = malloc(sizeof(int16_t) * D211_AUDIO_RING_FRAMES * channels); + if (stream->ring == 0) { + stream->used = 0; + return -1; + } + fprintf(stderr, "d211: audio createStream handle=%d rate=%u ch=%u\n", index, sample_rate, channels); + return index; + } + return -1; +} + +/** 停止输出并释放 ring。 */ +static void d211_audio_destroy_stream(int handle) { + if (handle < 0 || handle >= D211_AUDIO_MAX_STREAMS) return; + + fprintf(stderr, "d211: audio destroy handle=%d\n", handle); + D211AudioStream *stream = &streams[handle]; + if (!stream->used) return; + pthread_mutex_lock(&stream->lock); + stream->playing = 0; + pthread_cond_broadcast(&stream->cond); + pthread_mutex_unlock(&stream->lock); + if (stream->feeder_running) { + pthread_join(stream->feeder, 0); + } + kill_aplay(stream); + free(stream->ring); + stream->ring = 0; + pthread_cond_destroy(&stream->cond); + pthread_mutex_destroy(&stream->lock); + stream->used = 0; +} + +/** 写入 PCM;返回接受的帧数。 */ +static int d211_audio_write_pcm(int handle, const void *pcm, size_t bytes) { + if (handle < 0 || handle >= D211_AUDIO_MAX_STREAMS) return 0; + D211AudioStream *stream = &streams[handle]; + if (!stream->used || stream->ring == 0) return 0; + (void)bytes; + unsigned int frames = (unsigned int)(bytes / (stream->channels * sizeof(int16_t))); + const int16_t *samples = (const int16_t *)pcm; + unsigned int accepted = 0; + pthread_mutex_lock(&stream->lock); + while (accepted < frames && stream->count < D211_AUDIO_RING_FRAMES) { + unsigned int write_index = stream->tail; + for (unsigned int channel = 0; channel < stream->channels; channel += 1) { + stream->ring[write_index * stream->channels + channel] = + samples[accepted * stream->channels + channel]; + } + stream->tail = (stream->tail + 1) % D211_AUDIO_RING_FRAMES; + stream->count += 1; + accepted += 1; + } + if (accepted > 0) stream->change_seq += 1; + pthread_cond_broadcast(&stream->cond); + pthread_mutex_unlock(&stream->lock); + return (int)accepted; +} + +/** 开始输出(必要时拉起 aplay 与 feeder)。 */ +static void d211_audio_play(int handle) { + if (handle < 0 || handle >= D211_AUDIO_MAX_STREAMS) return; + D211AudioStream *stream = &streams[handle]; + if (!stream->used) return; + pthread_mutex_lock(&stream->lock); + if (!stream->playing) { + stream->playing = 1; + stream->ended = 0; + if (stream->pid > 0) { + kill(stream->pid, SIGCONT); + } + } + int needs_feeder = !stream->feeder_running; + pthread_mutex_unlock(&stream->lock); + if (stream->pid < 0 && spawn_aplay(stream) != 0) { + pthread_mutex_lock(&stream->lock); + stream->playing = 0; + pthread_mutex_unlock(&stream->lock); + return; + } + if (needs_feeder) { + stream->feeder_running = 1; + pthread_create(&stream->feeder, 0, feeder_main, stream); + } + fprintf(stderr, "d211: audio play handle=%d rate=%u ch=%u\n", + handle, stream->rate, stream->channels); + pthread_mutex_lock(&stream->lock); + pthread_cond_broadcast(&stream->cond); + pthread_mutex_unlock(&stream->lock); +} + +/** 暂停输出(SIGSTOP 子进程,ring 保留)。 */ +static void d211_audio_pause(int handle) { + if (handle < 0 || handle >= D211_AUDIO_MAX_STREAMS) return; + fprintf(stderr, "d211: audio pause handle=%d\n", handle); + D211AudioStream *stream = &streams[handle]; + if (!stream->used) return; + pthread_mutex_lock(&stream->lock); + stream->playing = 0; + pthread_cond_broadcast(&stream->cond); + pthread_mutex_unlock(&stream->lock); + if (stream->pid > 0) kill(stream->pid, SIGSTOP); +} + +/** 停止并清空 ring。 */ +static void d211_audio_stop(int handle) { + if (handle < 0 || handle >= D211_AUDIO_MAX_STREAMS) return; + fprintf(stderr, "d211: audio stop handle=%d\n", handle); + D211AudioStream *stream = &streams[handle]; + if (!stream->used) return; + pthread_mutex_lock(&stream->lock); + stream->playing = 0; + stream->ended = 0; + stream->count = 0; + stream->head = 0; + stream->tail = 0; + pthread_cond_broadcast(&stream->cond); + pthread_mutex_unlock(&stream->lock); + kill_aplay(stream); +} + +/** 设置软音量(0..1)。 */ +static void d211_audio_set_volume(int handle, double volume) { + if (handle < 0 || handle >= D211_AUDIO_MAX_STREAMS) return; + D211AudioStream *stream = &streams[handle]; + if (!stream->used) return; + fprintf(stderr, "d211: audio setVolume handle=%d volume=%.2f\n", handle, volume); + pthread_mutex_lock(&stream->lock); + stream->volume = volume; + pthread_mutex_unlock(&stream->lock); +} + +/** 标记流结束:ring 排空后自动暂停并上报 ended。 */ +static void d211_audio_end_stream(int handle) { + if (handle < 0 || handle >= D211_AUDIO_MAX_STREAMS) return; + + + D211AudioStream *stream = &streams[handle]; + if (!stream->used) return; + pthread_mutex_lock(&stream->lock); + if (stream->count == 0) { + stream->playing = 0; + queue_event(stream, "ended", handle, 0); + } else { + stream->ended = 1; + } + pthread_cond_broadcast(&stream->cond); + pthread_mutex_unlock(&stream->lock); +} + +/** 取一条事件(NULL 表示队列空)。 */ +static const char *d211_audio_poll(void) { + static char line[64]; + for (int index = 0; index < D211_AUDIO_MAX_STREAMS; index += 1) { + D211AudioStream *stream = &streams[index]; + if (!stream->used) continue; + pthread_mutex_lock(&stream->lock); + /* credit:占用自上次上报以来有变化才上报。用变化序号比较而不是占用 + 数量本身——占用可能在一批内先增后减回到旧值,只比值会漏掉变化, + guest 的免费帧镜像就再也恢复不了。写与消费都递增序号。 */ + if (stream->reported_seq != stream->change_seq) { + stream->reported_seq = stream->change_seq; + snprintf(line, sizeof(line), "{\"t\":\"credit\",\"h\":%d,\"free\":%u}", + index, D211_AUDIO_RING_FRAMES - stream->count); + pthread_mutex_unlock(&stream->lock); + return line; + } + if (stream->event_head != stream->event_tail) { + memcpy(line, stream->events[stream->event_head], sizeof(line)); + line[sizeof(line) - 1] = '\0'; + stream->event_head = (stream->event_head + 1) % D211_AUDIO_EVENTS; + pthread_mutex_unlock(&stream->lock); + return line; + } + pthread_mutex_unlock(&stream->lock); + } + return 0; +} + +/** 宿主音频 ops 表。 */ +static const PocketAudioOps d211_audio_ops = { + .create_stream = d211_audio_create_stream, + .destroy_stream = d211_audio_destroy_stream, + .write_pcm = d211_audio_write_pcm, + .play = d211_audio_play, + .pause = d211_audio_pause, + .stop = d211_audio_stop, + .set_volume = d211_audio_set_volume, + .end_stream = d211_audio_end_stream, + .poll = d211_audio_poll, +}; + +/** 取音频 ops 表。 */ +const PocketAudioOps *d211_audio_ops_table(void) { + return &d211_audio_ops; +} diff --git a/hosts/d211-linux/audio.h b/hosts/d211-linux/audio.h new file mode 100644 index 000000000..3972132b4 --- /dev/null +++ b/hosts/d211-linux/audio.h @@ -0,0 +1,14 @@ +/* + * d211 audio host module: PocketAudioOps table accessor. + * See audio.c for the implementation notes. + */ + +#ifndef D211_AUDIO_H +#define D211_AUDIO_H + +#include "pocket_runtime.h" + +/** 取音频 ops 表(装入运行时前调用 pocket_runtime_set_audio_ops)。 */ +const PocketAudioOps *d211_audio_ops_table(void); + +#endif diff --git a/hosts/d211-linux/backlight.c b/hosts/d211-linux/backlight.c new file mode 100644 index 000000000..ac9d789d0 --- /dev/null +++ b/hosts/d211-linux/backlight.c @@ -0,0 +1,84 @@ +/* + * d211 backlight module: percent <-> /sys/class/backlight//brightness. + * + * The panel exposes a 0..max_brightness scale (10 on this device); the guest + * API is percent 0-100, mapped here. Reading is cached for max_brightness so + * set() is one write. + */ + +#include "backlight.h" + +#include +#include +#include + +static char brightness_path[512]; +static char max_path[512]; +static int max_brightness; +static int discovered; + +/** 发现背光节点与最大亮度(只执行一次)。 */ +static void discover(void) { + DIR *directory; + struct dirent *entry; + if (discovered) return; + discovered = 1; + directory = opendir("/sys/class/backlight"); + if (directory == 0) return; + while ((entry = readdir(directory)) != 0) { + if (entry->d_name[0] == '.') continue; + snprintf(brightness_path, sizeof(brightness_path), + "/sys/class/backlight/%s/brightness", entry->d_name); + snprintf(max_path, sizeof(max_path), + "/sys/class/backlight/%s/max_brightness", entry->d_name); + FILE *file = fopen(max_path, "r"); + if (file == 0) continue; + if (fscanf(file, "%d", &max_brightness) != 1 || max_brightness <= 0) { + max_brightness = 0; + fclose(file); + continue; + } + fclose(file); + break; + } + closedir(directory); +} + +/** 读取当前亮度(0-100);无背光节点返回 -1。 */ +static int d211_backlight_get(void) { + int value = 0; + discover(); + if (max_brightness <= 0) return -1; + FILE *file = fopen(brightness_path, "r"); + if (file == 0) return -1; + if (fscanf(file, "%d", &value) != 1) value = 0; + fclose(file); + int percent = (int)((long)value * 100 / max_brightness); + if (percent < 0) percent = 0; + if (percent > 100) percent = 100; + return percent; +} + +/** 写入百分比亮度(0-100,越界夹取)。 */ +static void d211_backlight_set(int percent) { + discover(); + if (max_brightness <= 0) return; + if (percent < 0) percent = 0; + if (percent > 100) percent = 100; + int value = (int)((long)percent * max_brightness / 100); + FILE *file = fopen(brightness_path, "w"); + if (file == 0) return; + fprintf(file, "%d", value); + fclose(file); +} + +/** 宿主背光 ops 表。 */ +static const PocketBacklightOps d211_backlight_ops = { + .get = d211_backlight_get, + .set = d211_backlight_set, +}; + +/** 取背光 ops 表。 */ +const PocketBacklightOps *d211_backlight_ops_table(void) { + return &d211_backlight_ops; +} diff --git a/hosts/d211-linux/backlight.h b/hosts/d211-linux/backlight.h new file mode 100644 index 000000000..f0e1f1216 --- /dev/null +++ b/hosts/d211-linux/backlight.h @@ -0,0 +1,14 @@ +/* + * d211 backlight host module: PocketBacklightOps table accessor. + * The sysfs node is discovered once (first /sys/class/backlight entry). + */ + +#ifndef D211_BACKLIGHT_H +#define D211_BACKLIGHT_H + +#include "pocket_runtime.h" + +/** 取背光 ops 表(装入运行时前调用 pocket_runtime_set_backlight_ops)。 */ +const PocketBacklightOps *d211_backlight_ops_table(void); + +#endif diff --git a/hosts/d211-linux/main.c b/hosts/d211-linux/main.c index 292a70d93..924b974ba 100644 --- a/hosts/d211-linux/main.c +++ b/hosts/d211-linux/main.c @@ -27,6 +27,11 @@ #include #include +#include +#include + +#include "audio.h" +#include "backlight.h" #include "input.h" #include "pocket_runtime.h" #include "pocket_ui_cabi.h" @@ -65,37 +70,39 @@ static uint64_t now_ns(void) { return (uint64_t)ts.tv_sec * 1000000000ull + (uint64_t)ts.tv_nsec; } +/* + * Assets are mmap'd read-only instead of read into anonymous memory. The + * runtime borrows the pack for the whole app lifetime, so the pages stay + * mapped either way; file-backed pages are reclaimable under memory pressure + * (the 64 MB board OOM-kills the process when app.pak grows past ~8 MB with + * malloc'd assets). + */ +/** 释放 mmap 资源(0 长度或空指针为空操作)。 */ +static void release_asset(uint8_t *data, size_t length) { + if (data != 0 && length > 0) munmap(data, length); +} + static uint8_t *read_asset(const char *path, size_t *out_length) { - FILE *file = fopen(path, "rb"); - if (file == 0) { + int descriptor = open(path, O_RDONLY); + if (descriptor < 0) { fprintf(stderr, "d211: cannot open %s: %s\n", path, strerror(errno)); return 0; } - if (fseek(file, 0, SEEK_END) != 0) { - fclose(file); - return 0; - } - long length = ftell(file); - if (length <= 0) { - fclose(file); + struct stat info; + if (fstat(descriptor, &info) != 0 || info.st_size <= 0) { + close(descriptor); fprintf(stderr, "d211: %s is empty\n", path); return 0; } - rewind(file); - uint8_t *data = malloc((size_t)length); - if (data == 0) { - fclose(file); - fprintf(stderr, "d211: out of memory reading %s\n", path); + size_t length = (size_t)info.st_size; + uint8_t *data = mmap(0, length, PROT_READ, MAP_PRIVATE, descriptor, 0); + if (data == MAP_FAILED) { + close(descriptor); + fprintf(stderr, "d211: cannot mmap %s: %s\n", path, strerror(errno)); return 0; } - if (fread(data, 1, (size_t)length, file) != (size_t)length) { - free(data); - fclose(file); - fprintf(stderr, "d211: short read on %s\n", path); - return 0; - } - fclose(file); - *out_length = (size_t)length; + close(descriptor); + *out_length = length; return data; } @@ -386,7 +393,7 @@ int main(void) { if (java_script == 0) return 1; uint8_t *pack = read_asset(pack_path, &pack_length); if (pack == 0) { - free(java_script); + release_asset(java_script, java_script_length); return 1; } @@ -402,8 +409,8 @@ int main(void) { ); D211Framebuffer framebuffer; if (!fb_open(&framebuffer, framebuffer_path)) { - free(java_script); - free(pack); + release_asset(java_script, java_script_length); + release_asset(pack, pack_length); return 1; } @@ -424,6 +431,11 @@ int main(void) { fprintf(stderr, "d211: no touch-capable evdev device found\n"); } + /* 宿主模块:真实音频输出(aplay)与面板背光(sysfs)。 */ + pocket_runtime_set_audio_ops(d211_audio_ops_table()); + pocket_runtime_set_backlight_ops(d211_backlight_ops_table()); + fprintf(stderr, "d211: host modules: audio + backlight\n"); + uint64_t boot_start = now_ns(); if (!pocket_runtime_boot( (const char *)java_script, @@ -436,8 +448,8 @@ int main(void) { fprintf(stderr, "d211: boot failed: %s\n", pocket_runtime_error()); d211_input_close(&touch); fb_close(&framebuffer); - free(java_script); - free(pack); + release_asset(java_script, java_script_length); + release_asset(pack, pack_length); return 1; } fprintf( @@ -593,7 +605,7 @@ int main(void) { pocket_runtime_shutdown(); d211_input_close(&touch); fb_close(&framebuffer); - free(java_script); - free(pack); + release_asset(java_script, java_script_length); + release_asset(pack, pack_length); return 0; } diff --git a/tools/d211-linux/build-runtime.sh b/tools/d211-linux/build-runtime.sh index fac35cc68..548495003 100755 --- a/tools/d211-linux/build-runtime.sh +++ b/tools/d211-linux/build-runtime.sh @@ -132,6 +132,18 @@ first_party_flags=( -c "$REPO_ROOT/hosts/d211-linux/input.c" \ -o "$objects/input.o" +"$gcc" "${first_party_flags[@]}" \ + -I"$REPO_ROOT/hosts/d211-linux" \ + -I"$REPO_ROOT/engine/quickjs-c" \ + -c "$REPO_ROOT/hosts/d211-linux/audio.c" \ + -o "$objects/audio.o" + +"$gcc" "${first_party_flags[@]}" \ + -I"$REPO_ROOT/hosts/d211-linux" \ + -I"$REPO_ROOT/engine/quickjs-c" \ + -c "$REPO_ROOT/hosts/d211-linux/backlight.c" \ + -o "$objects/backlight.o" + "$gcc" \ -B"$LLD_SHIM_DIR" \ -fuse-ld=lld \ @@ -142,12 +154,14 @@ first_party_flags=( -Wl,--no-undefined \ -o "$staging/pocketjs-d211" \ "$objects/main.o" \ + "$objects/audio.o" \ + "$objects/backlight.o" \ "$objects/input.o" \ "$objects/pocket_runtime.o" \ "$objects/rust_eh_personality.o" \ "$BUILD_DIR/libquickjs.a" \ "$RUST_CORE_ARCHIVE" \ - -lm + -lm -lpthread "$readelf" -h -l -A -d "$staging/pocketjs-d211" > "$BUILD_DIR/pocketjs-d211.readelf.txt" "$nm" -g "$staging/pocketjs-d211" > "$BUILD_DIR/pocketjs-d211.symbols.txt" diff --git a/tools/d211-linux/ge-probe.c b/tools/d211-linux/ge-probe.c new file mode 100644 index 000000000..c1a5ae98c --- /dev/null +++ b/tools/d211-linux/ge-probe.c @@ -0,0 +1,456 @@ +/* + * ge-probe — ArtInChip GE bring-up probe for PocketJS (spike, not shipped). + * + * Talks to /dev/ge directly through the vendor UAPI so the result is the same + * whether or not the MPP wrapper is linked. Exercises the operations a + * PocketJS GE backend would need, verifies pixel order and alpha parity + * against the software rasterizer's integer blend, and times the ioctls. + * + * Build (Luban wrapper, pure C): + * riscv64-unknown-linux-gnu-gcc -O2 ge-probe.c -o ge-probe + * Run on the device: + * ./ge-probe # buffer tests + timings + * ./ge-probe --fb # additionally draws a pattern into /dev/fb0 + */ + +#define _GNU_SOURCE + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include