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/engine/quickjs-c/pocket_runtime.c b/engine/quickjs-c/pocket_runtime.c index 388acafca..58f62b011 100644 --- a/engine/quickjs-c/pocket_runtime.c +++ b/engine/quickjs-c/pocket_runtime.c @@ -94,6 +94,9 @@ 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; +static const PocketIpcOps *ipc_ops; #ifdef POCKET_SVC_WIRE /* spec SVC_POLL_BUF (8192) + terminator: one svcPoll batch. */ static char svc_poll_buffer[8193]; @@ -468,6 +471,286 @@ 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_ipc_ops(const PocketIpcOps *ops) { + ipc_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; + 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; + 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; +} + +/* One ipc.recv() datagram cap: MAX_PAYLOAD (64 KiB) + frame header. */ +#define HOST_IPC_MAX_DATAGRAM 65552 + +static JSValue host_ipc_connect( + JSContext *ctx, + JSValueConst this_value, + int argc, + JSValueConst *argv +) { + if (ipc_ops == 0) return JS_NewBool(ctx, 0); + const char *path = 0; + size_t path_length = 0; + if (!string_argument(ctx, argc, argv, 0, &path, &path_length)) return JS_EXCEPTION; + int connected = ipc_ops->connect(path); + JS_FreeCString(ctx, path); + return JS_NewBool(ctx, connected >= 0); +} + +static JSValue host_ipc_close( + JSContext *ctx, + JSValueConst this_value, + int argc, + JSValueConst *argv +) { + (void)ctx; + (void)this_value; + (void)argc; + (void)argv; + if (ipc_ops != 0) ipc_ops->close(); + return JS_UNDEFINED; +} + +static JSValue host_ipc_send( + JSContext *ctx, + JSValueConst this_value, + int argc, + JSValueConst *argv +) { + if (ipc_ops == 0) return JS_NewInt32(ctx, -1); + const uint8_t *bytes = 0; + size_t length = 0; + if (!bytes_argument(ctx, argc, argv, 0, &bytes, &length)) return JS_EXCEPTION; + if (length == 0) return JS_NewInt32(ctx, 0); + int written = ipc_ops->send(bytes, length); + return JS_NewInt32(ctx, written < 0 ? -1 : written); +} + +static JSValue host_ipc_recv( + JSContext *ctx, + JSValueConst this_value, + int argc, + JSValueConst *argv +) { + uint8_t buffer[HOST_IPC_MAX_DATAGRAM]; + if (ipc_ops == 0) return JS_NewArrayBufferCopy(ctx, buffer, 0); + int32_t capacity = HOST_IPC_MAX_DATAGRAM; + if (argc > 0 && !int_argument(ctx, argc, argv, 0, &capacity)) return JS_EXCEPTION; + if (capacity < 1) capacity = 1; + if (capacity > HOST_IPC_MAX_DATAGRAM) capacity = HOST_IPC_MAX_DATAGRAM; + int count = ipc_ops->recv(buffer, (size_t)capacity); + if (count < 0) count = 0; + return JS_NewArrayBufferCopy(ctx, buffer, (size_t)count); +} + +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 install_ipc(void) { + JSValue ipc; + if (ipc_ops == 0) return 1; + ipc = JS_NewObject(context); + if (JS_IsException(ipc)) return 0; + if (!add_module_function(context, ipc, "connect", 1, host_ipc_connect) || + !add_module_function(context, ipc, "close", 0, host_ipc_close) || + !add_module_function(context, ipc, "send", 1, host_ipc_send) || + !add_module_function(context, ipc, "recv", 1, host_ipc_recv)) { + JS_FreeValue(context, ipc); + return 0; + } + return JS_SetPropertyStr(context, global, "ipc", ipc) >= 0; +} + static int add_host_operation( JSContext *ctx, JSValueConst object, @@ -630,7 +913,8 @@ 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() || + !install_ipc()) { 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..43d563fbc 100644 --- a/engine/quickjs-c/pocket_runtime.h +++ b/engine/quickjs-c/pocket_runtime.h @@ -27,6 +27,61 @@ 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); + +/* + * IPC ops (host module `globalThis.ipc`): one SOCK_SEQPACKET connection to a + * local daemon (device) or its stream fallback (development hosts). send() + * BORROWS the guest buffer for the duration of the call; recv() copies one + * datagram into the given buffer and returns 0 when idle. Not yet a spec + * module — the method set is pinned here until an IPC spec lands. + */ +typedef struct { + int (*connect)(const char *path); + void (*close)(void); + int (*send)(const uint8_t *data, size_t length); + int (*recv)(uint8_t *buffer, size_t capacity); +} PocketIpcOps; + +void pocket_runtime_set_ipc_ops(const PocketIpcOps *ops); /* `pack` is borrowed by QuickJS and must remain valid until shutdown. */ /* * One guest turn followed by exactly one core tick — the frame contract 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); diff --git a/framework/compiler/subpaths.ts b/framework/compiler/subpaths.ts index 52e4d4d51..30c5edc97 100644 --- a/framework/compiler/subpaths.ts +++ b/framework/compiler/subpaths.ts @@ -67,6 +67,7 @@ export const SUBPATHS: Record = { "resource-view": { file: { solid: "framework/src/resource-view.ts" } }, resource: { file: { solid: "framework/src/resource.ts" } }, audio: { file: "framework/src/audio-api.ts", aliases: TWINS }, + ipc: { file: "framework/src/ipc-api.ts", aliases: TWINS }, media: { file: "framework/src/media.ts", aliases: TWINS }, "media/provider": { file: "tools/media-stream.ts" }, "media/audio": { file: "contracts/spec/media-adpcm.ts" }, diff --git a/framework/src/ipc-api.ts b/framework/src/ipc-api.ts new file mode 100644 index 000000000..768fae9bd --- /dev/null +++ b/framework/src/ipc-api.ts @@ -0,0 +1,30 @@ +// IPC module SDK — the thin guest-side algebra over the `ipc` host module. +// One SOCK_SEQPACKET connection to the local daemon (forkliftd on D211). +// +// ipc.connect(path) -> boolean bind the socket +// ipc.send(bytes) -> number bytes queued (-1: retry next frame) +// ipc.recv(capacity?) -> ArrayBuffer one datagram (empty when idle) +// ipc.close() release the socket +// +// Hosts without the module leave `globalThis.ipc` unset; the accessor below +// returns null and callers fall back to their in-process transport. + +/** The mounted ipc namespace — one method per host op. */ +export interface IpcOps { + /** Bind a SOCK_SEQPACKET socket; false when the path is rejected. */ + connect(path: string): boolean; + /** Release the socket (no-op when closed). */ + close(): void; + /** BORROWED for the call; returns bytes queued (0..length) or -1. */ + send(data: Uint8Array | ArrayBuffer): number; + /** One datagram, at most `capacity`; empty ArrayBuffer when idle. */ + recv(capacity?: number): ArrayBuffer; +} + +/** The ipc module namespace, or null where the host doesn't mount one. + * A live lookup (not cached): hosts install `globalThis.ipc` before eval. */ +export function ipcHost(): IpcOps | null { + const ns = (globalThis as { ipc?: unknown }).ipc; + if (!ns || typeof ns !== "object") return null; + return typeof (ns as IpcOps).connect === "function" ? (ns as IpcOps) : null; +} diff --git a/hosts/d211-linux/README.md b/hosts/d211-linux/README.md new file mode 100644 index 000000000..cbef13f3e --- /dev/null +++ b/hosts/d211-linux/README.md @@ -0,0 +1,131 @@ +# 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 +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 + +`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 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 +``` + +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. + +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/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/d211-hero.png b/hosts/d211-linux/d211-hero.png new file mode 100644 index 000000000..666859a18 Binary files /dev/null and b/hosts/d211-linux/d211-hero.png differ diff --git a/hosts/d211-linux/d211-touch.png b/hosts/d211-linux/d211-touch.png new file mode 100644 index 000000000..77360ba2d Binary files /dev/null and b/hosts/d211-linux/d211-touch.png differ diff --git a/hosts/d211-linux/input.c b/hosts/d211-linux/input.c new file mode 100644 index 000000000..79261db0d --- /dev/null +++ b/hosts/d211-linux/input.c @@ -0,0 +1,202 @@ +#define _GNU_SOURCE + +#include "input.h" + +#include +#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; + 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; + 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; + 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; +} + +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; +} + +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; + + 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 (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 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 (!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 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 down_mask; +} diff --git a/hosts/d211-linux/input.h b/hosts/d211-linux/input.h new file mode 100644 index 000000000..0fcc58f0a --- /dev/null +++ b/hosts/d211-linux/input.h @@ -0,0 +1,60 @@ +#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. 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 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; + +/** 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 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. + */ +unsigned int d211_input_pump(D211Input *input, D211ContactState *state); + +#endif diff --git a/hosts/d211-linux/ipc.c b/hosts/d211-linux/ipc.c new file mode 100644 index 000000000..3281a87b6 --- /dev/null +++ b/hosts/d211-linux/ipc.c @@ -0,0 +1,81 @@ +/* + * d211 ipc host module: SOCK_SEQPACKET client for the local daemon. + * One datagram is one framed message; send/recv are non-blocking so the + * guest's per-frame pump owns the timing. close() releases the socket. + */ + +#include "ipc.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +static int ipc_fd = -1; + +/** 关闭当前连接(未连接时为空操作)。 */ +static void d211_ipc_close(void) { + if (ipc_fd >= 0) { + close(ipc_fd); + ipc_fd = -1; + } +} + +/** 连接 Unix 域套接字(SEQPACKET;一个数据报一帧)。 */ +static int d211_ipc_connect(const char *path) { + if (path == 0) return -1; + d211_ipc_close(); + if (strlen(path) >= sizeof(((struct sockaddr_un *)0)->sun_path)) { + return -1; + } + int fd = socket(AF_UNIX, SOCK_SEQPACKET, 0); + if (fd < 0) return -1; + if (fcntl(fd, F_SETFD, FD_CLOEXEC) != 0) { + close(fd); + return -1; + } + struct sockaddr_un address; + memset(&address, 0, sizeof(address)); + address.sun_family = AF_UNIX; + strncpy(address.sun_path, path, sizeof(address.sun_path) - 1); + if (connect(fd, (struct sockaddr *)&address, sizeof(address)) != 0) { + close(fd); + return -1; + } + ipc_fd = fd; + return 0; +} + +/** 发送一个完整帧;驱动暂满返回 -1,由 guest 下一帧重试。 */ +static int d211_ipc_send(const uint8_t *data, size_t length) { + if (ipc_fd < 0) return -1; + ssize_t count = send(ipc_fd, data, length, MSG_DONTWAIT); + if (count < 0) return -1; + return (int)count; +} + +/** 非阻塞收一个数据报;空返回 0,错误返回 -1。 */ +static int d211_ipc_recv(uint8_t *buffer, size_t capacity) { + if (ipc_fd < 0) return -1; + ssize_t count = recv(ipc_fd, buffer, capacity, MSG_DONTWAIT); + if (count < 0) { + return (errno == EAGAIN || errno == EWOULDBLOCK) ? 0 : -1; + } + return (int)count; +} + +static const PocketIpcOps d211_ipc_ops = { + .connect = d211_ipc_connect, + .close = d211_ipc_close, + .send = d211_ipc_send, + .recv = d211_ipc_recv, +}; + +/** 取 IPC ops 表。 */ +const PocketIpcOps *d211_ipc_ops_table(void) { + return &d211_ipc_ops; +} diff --git a/hosts/d211-linux/ipc.h b/hosts/d211-linux/ipc.h new file mode 100644 index 000000000..c8b8638bc --- /dev/null +++ b/hosts/d211-linux/ipc.h @@ -0,0 +1,14 @@ +/* + * d211 ipc host module: PocketIpcOps table accessor. + * One SOCK_SEQPACKET connection to the local daemon (forkliftd). + */ + +#ifndef D211_IPC_H +#define D211_IPC_H + +#include "pocket_runtime.h" + +/** 取 IPC ops 表(装入运行时前调用 pocket_runtime_set_ipc_ops)。 */ +const PocketIpcOps *d211_ipc_ops_table(void); + +#endif diff --git a/hosts/d211-linux/main.c b/hosts/d211-linux/main.c new file mode 100644 index 000000000..71ba87047 --- /dev/null +++ b/hosts/d211-linux/main.c @@ -0,0 +1,613 @@ +/* + * 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 +#include + +#include "audio.h" +#include "backlight.h" +#include "input.h" +#include "ipc.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; +} + +/* + * 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) { + int descriptor = open(path, O_RDONLY); + if (descriptor < 0) { + fprintf(stderr, "d211: cannot open %s: %s\n", path, strerror(errno)); + return 0; + } + struct stat info; + if (fstat(descriptor, &info) != 0 || info.st_size <= 0) { + close(descriptor); + fprintf(stderr, "d211: %s is empty\n", path); + return 0; + } + 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; + } + close(descriptor); + *out_length = 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) { + release_asset(java_script, java_script_length); + 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)) { + release_asset(java_script, java_script_length); + release_asset(pack, pack_length); + 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 %s\n", + touch.name, + touch.axis_x, + touch.axis_y, + touch.max_x, + touch.max_y, + touch.multi_touch ? "multi" : "single" + ); + } else { + 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()); + pocket_runtime_set_ipc_ops(d211_ipc_ops_table()); + fprintf(stderr, "d211: host modules: audio + backlight + ipc\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); + release_asset(java_script, java_script_length); + release_asset(pack, pack_length); + 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_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(); + uint64_t next_frame = now_ns(); + + 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)); + 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] + ); + } + } + } + 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; + pause.tv_sec = (time_t)(remaining / 1000000000ull); + pause.tv_nsec = (long)(remaining % 1000000000ull); + nanosleep(&pause, 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_contacts(&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); + release_asset(java_script, java_script_length); + release_asset(pack, pack_length); + return 0; +} diff --git a/package.json b/package.json index c10781911..cdba4914c 100644 --- a/package.json +++ b/package.json @@ -162,6 +162,7 @@ "./resource-view": "./framework/src/resource-view.ts", "./resource": "./framework/src/resource.ts", "./audio": "./framework/src/audio-api.ts", + "./ipc": "./framework/src/ipc-api.ts", "./media": "./framework/src/media.ts", "./media/provider": "./tools/media-stream.ts", "./media/audio": "./contracts/spec/media-adpcm.ts", @@ -260,6 +261,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/tests/d211-linux-profile.test.ts b/tests/d211-linux-profile.test.ts new file mode 100644 index 000000000..7fa199e03 --- /dev/null +++ b/tests/d211-linux-profile.test.ts @@ -0,0 +1,162 @@ +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_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"); + }); + + 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('"killall", "-9"'); + 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]+/); + }); +}); 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], 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..866ff3f3c --- /dev/null +++ b/tools/d211-linux.ts @@ -0,0 +1,445 @@ +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(); + /* 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 { + 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..da022e2a8 --- /dev/null +++ b/tools/d211-linux/build-runtime.sh @@ -0,0 +1,176 @@ +#!/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" "${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" "${first_party_flags[@]}" \ + -I"$REPO_ROOT/hosts/d211-linux" \ + -I"$REPO_ROOT/engine/quickjs-c" \ + -c "$REPO_ROOT/hosts/d211-linux/ipc.c" \ + -o "$objects/ipc.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/audio.o" \ + "$objects/backlight.o" \ + "$objects/ipc.o" \ + "$objects/input.o" \ + "$objects/pocket_runtime.o" \ + "$objects/rust_eh_personality.o" \ + "$BUILD_DIR/libquickjs.a" \ + "$RUST_CORE_ARCHIVE" \ + -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" + +ls -l "$staging/pocketjs-d211" 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