diff --git a/.gitignore b/.gitignore
index 1f5ff1d7..d350d13e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -31,3 +31,4 @@ tests/goldens/web/*.actual.png
tests/goldens/psp/*.actual.png
# irecovery readline history, written when the iPhone 2G runbook drives iBoot
.irecovery
+engine/apple/dist/
diff --git a/apps/nsengine/app.tsx b/apps/nsengine/app.tsx
new file mode 100644
index 00000000..37ce9888
--- /dev/null
+++ b/apps/nsengine/app.tsx
@@ -0,0 +1,90 @@
+// @title NS Engine — pocketjs guest talking to a NativeScript host
+import { createSignal, onMount } from "solid-js";
+import { Image, Screen, Text, View } from "@pocketjs/framework/components";
+import { runEffect } from "@pocketjs/framework/effects";
+import { createSpriteAnimation, onFrame } from "@pocketjs/framework/lifecycle";
+import { pumpHostLines } from "./channel.ts";
+
+const SPINNER_FRAMES = [
+ "spinner-00.svg",
+ "spinner-01.svg",
+ "spinner-02.svg",
+ "spinner-03.svg",
+ "spinner-04.svg",
+ "spinner-05.svg",
+ "spinner-06.svg",
+ "spinner-07.svg",
+];
+
+// Bakes the glyphs dynamic host strings may use (digits, punctuation).
+const GLYPH_SEED = "0123456789 #:{}\"pong hello from NativeScript,.!?-_iOS via sandboxed realm";
+
+// In a sidecar realm (Direction A) no platform globals exist; when the
+// NativeScript runtime is the guest engine (Direction B), the whole iOS
+// surface is one identifier away.
+declare const UIDevice: { currentDevice: { systemVersion: string } } | undefined;
+const platformReach = typeof UIDevice !== "undefined"
+ ? `iOS ${UIDevice!.currentDevice.systemVersion} via NativeScript`
+ : "sandboxed realm";
+
+function Stat(props: { label: string; value: string; valueClass: string }) {
+ return (
+
+ {props.label}
+ {props.value}
+
+ );
+}
+
+export default function App() {
+ const [reply, setReply] = createSignal("waiting");
+ const [hostEvent, setHostEvent] = createSignal("none yet");
+ const [count, setCount] = createSignal(0);
+ const spinnerSrc = createSpriteAnimation(SPINNER_FRAMES, { frameStep: 5 });
+
+ onFrame(() => pumpHostLines((message) => {
+ setHostEvent(String(message["msg"] ?? JSON.stringify(message)));
+ }));
+
+ const ping = () => {
+ const n = count() + 1;
+ setCount(n);
+ runEffect("ns.ping", { n }, (result) => setReply(String(result)));
+ };
+
+ // Fire one round trip unprompted so the channel proves itself on boot.
+ onMount(() => ping());
+
+ return (
+
+
+
+
+
+
+ PocketJS × NativeScript
+
+
+ one Rust core · two JS worlds
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Ping host · {count()}
+
+
+
+ {GLYPH_SEED}
+
+ );
+}
diff --git a/apps/nsengine/channel.ts b/apps/nsengine/channel.ts
new file mode 100644
index 00000000..4ee3c02a
--- /dev/null
+++ b/apps/nsengine/channel.ts
@@ -0,0 +1,57 @@
+// Effect driver over the ui.svc* host-service channel: commands go out as
+// JSON lines (svcSend), results and host-initiated events come back through
+// the per-frame poll pump (svcPoll). Protocol:
+// guest -> host {t:"cmd", id, kind, payload}
+// host -> guest {t:"result", id, result} | {t:"event", ...anything}
+
+import { getOps } from "@pocketjs/framework";
+import { installEffectDriver } from "@pocketjs/framework/effects";
+
+type SvcOps = {
+ svcSend?: (line: string) => void;
+ svcPoll?: () => string | null;
+};
+
+const pendingDeliver = new Map void>();
+
+export function installSvcEffectDriver(): void {
+ installEffectDriver((cmd, deliver) => {
+ const ops = getOps() as SvcOps;
+ if (typeof ops.svcSend !== "function") {
+ return; // host without a service channel: commands drop, app stays pure
+ }
+ pendingDeliver.set(cmd.id, deliver);
+ ops.svcSend(JSON.stringify({ t: "cmd", id: cmd.id, kind: cmd.kind, payload: cmd.payload }));
+ });
+}
+
+/** Run once per frame: matches results to pending effects, forwards events. */
+export function pumpHostLines(onEvent: (event: Record) => void): void {
+ const ops = getOps() as SvcOps;
+ if (typeof ops.svcPoll !== "function") {
+ return;
+ }
+ const batch = ops.svcPoll();
+ if (!batch) {
+ return;
+ }
+ for (const line of batch.split("\n")) {
+ if (!line) {
+ continue;
+ }
+ let message: Record;
+ try {
+ message = JSON.parse(line) as Record;
+ } catch {
+ continue;
+ }
+ const id = message["id"];
+ if (message["t"] === "result" && typeof id === "number" && pendingDeliver.has(id)) {
+ const deliver = pendingDeliver.get(id)!;
+ pendingDeliver.delete(id);
+ deliver(message["result"]);
+ } else {
+ onEvent(message);
+ }
+ }
+}
diff --git a/apps/nsengine/main.tsx b/apps/nsengine/main.tsx
new file mode 100644
index 00000000..606418f0
--- /dev/null
+++ b/apps/nsengine/main.tsx
@@ -0,0 +1,7 @@
+// @title NS Engine
+import { mount } from "@pocketjs/framework/solid";
+import App from "./app.tsx";
+import { installSvcEffectDriver } from "./channel.ts";
+
+installSvcEffectDriver();
+mount(() => );
diff --git a/engine/Cargo.lock b/engine/Cargo.lock
index 3ce5f614..df0e64cb 100644
--- a/engine/Cargo.lock
+++ b/engine/Cargo.lock
@@ -193,6 +193,26 @@ version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
+[[package]]
+name = "bindgen"
+version = "0.72.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
+dependencies = [
+ "bitflags 2.13.0",
+ "cexpr",
+ "clang-sys",
+ "itertools",
+ "log",
+ "prettyplease",
+ "proc-macro2",
+ "quote",
+ "regex",
+ "rustc-hash 2.1.3",
+ "shlex 1.3.0",
+ "syn",
+]
+
[[package]]
name = "bit-set"
version = "0.8.0"
@@ -317,7 +337,16 @@ dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
- "shlex",
+ "shlex 2.0.1",
+]
+
+[[package]]
+name = "cexpr"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
+dependencies = [
+ "nom",
]
[[package]]
@@ -332,6 +361,17 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
+[[package]]
+name = "clang-sys"
+version = "1.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4"
+dependencies = [
+ "glob",
+ "libc",
+ "libloading",
+]
+
[[package]]
name = "codespan-reporting"
version = "0.12.0"
@@ -368,6 +408,15 @@ dependencies = [
"crossbeam-utils",
]
+[[package]]
+name = "convert_case"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49"
+dependencies = [
+ "unicode-segmentation",
+]
+
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -502,6 +551,12 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
+[[package]]
+name = "either"
+version = "1.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
+
[[package]]
name = "env_filter"
version = "2.0.0"
@@ -578,6 +633,12 @@ dependencies = [
"miniz_oxide",
]
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
[[package]]
name = "foldhash"
version = "0.1.5"
@@ -689,6 +750,12 @@ dependencies = [
"libm",
]
+[[package]]
+name = "glob"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
+
[[package]]
name = "glow"
version = "0.16.0"
@@ -874,6 +941,12 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df"
+[[package]]
+name = "ident_case"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
+
[[package]]
name = "image"
version = "0.25.10"
@@ -911,6 +984,15 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+[[package]]
+name = "itertools"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
+dependencies = [
+ "either",
+]
+
[[package]]
name = "itoa"
version = "1.0.18"
@@ -1161,6 +1243,12 @@ dependencies = [
"paste",
]
+[[package]]
+name = "minimal-lexical"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
+
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -1199,7 +1287,7 @@ dependencies = [
"log",
"num-traits",
"once_cell",
- "rustc-hash",
+ "rustc-hash 1.1.0",
"spirv",
"strum",
"thiserror 2.0.18",
@@ -1245,6 +1333,16 @@ dependencies = [
"jni-sys 0.3.1",
]
+[[package]]
+name = "nom"
+version = "7.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
+dependencies = [
+ "memchr",
+ "minimal-lexical",
+]
+
[[package]]
name = "note-widget"
version = "0.1.0"
@@ -1634,6 +1732,17 @@ dependencies = [
"miniz_oxide",
]
+[[package]]
+name = "pocket-apple"
+version = "0.1.0"
+dependencies = [
+ "log",
+ "pocket-mod",
+ "pocket-ui-surface",
+ "pocketjs-core",
+ "rquickjs",
+]
+
[[package]]
name = "pocket-db"
version = "0.1.0"
@@ -1830,6 +1939,16 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa"
+[[package]]
+name = "prettyplease"
+version = "0.2.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
+dependencies = [
+ "proc-macro2",
+ "syn",
+]
+
[[package]]
name = "proc-macro-crate"
version = "3.5.0"
@@ -1974,6 +2093,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0688f8b0192998cca685adefdfad3483da295fa40a0ec406b4c14ecd729e858"
dependencies = [
"rquickjs-core",
+ "rquickjs-macro",
]
[[package]]
@@ -1987,12 +2107,30 @@ dependencies = [
"rquickjs-sys",
]
+[[package]]
+name = "rquickjs-macro"
+version = "0.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04e6cf4b6e695b526cb430a6f445d3ccb9908696b99f1c1f8a1480af38bed5e6"
+dependencies = [
+ "convert_case",
+ "fnv",
+ "ident_case",
+ "indexmap",
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "rquickjs-core",
+ "syn",
+]
+
[[package]]
name = "rquickjs-sys"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "698077537c286a169de8693b216672bcef148bf2e2e112ebf50758c68e9afa09"
dependencies = [
+ "bindgen",
"cc",
]
@@ -2027,6 +2165,12 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
+[[package]]
+name = "rustc-hash"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
+
[[package]]
name = "rustc_version"
version = "0.4.1"
@@ -2151,6 +2295,12 @@ dependencies = [
"zmij",
]
+[[package]]
+name = "shlex"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
+
[[package]]
name = "shlex"
version = "2.0.1"
@@ -2749,7 +2899,7 @@ dependencies = [
"portable-atomic",
"profiling",
"raw-window-handle",
- "rustc-hash",
+ "rustc-hash 1.1.0",
"smallvec",
"thiserror 2.0.18",
"wgpu-core-deps-apple",
diff --git a/engine/Cargo.toml b/engine/Cargo.toml
index 38eb5f5e..7a8c0d88 100644
--- a/engine/Cargo.toml
+++ b/engine/Cargo.toml
@@ -11,6 +11,7 @@
[workspace]
resolver = "2"
members = [
+ "apple",
"crates/pocket-db",
"crates/pocket-fs",
"crates/pocket-mod",
diff --git a/engine/apple/Cargo.toml b/engine/apple/Cargo.toml
new file mode 100644
index 00000000..ca3fb4b8
--- /dev/null
+++ b/engine/apple/Cargo.toml
@@ -0,0 +1,28 @@
+# pocket-apple — the PocketJS Apple host core: one pocket-mod guest realm,
+# the pocket-ui-surface `ui` mount, and pocketjs-core's software rasterizer
+# behind a small C ABI consumed by PocketSurfaceView (UIKit).
+
+[package]
+name = "pocket-apple"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+description = "The PocketJS `ui` surface for Apple platforms: pocket-mod guest, pak feeding, software raster DrawList behind a C ABI"
+
+[lib]
+crate-type = ["staticlib", "rlib"]
+
+[dependencies]
+pocket-mod = { workspace = true }
+pocket-ui-surface = { workspace = true }
+pocketjs-core = { workspace = true }
+log = { workspace = true }
+
+# rquickjs ships pre-generated FFI bindings for common targets but NOT the
+# aarch64-apple-ios family, so generate them at build time (bindgen; requires
+# libclang) for iOS builds only. Desktop builds — including the examples and
+# `cargo test --workspace` in CI — keep the pre-generated bindings, so cargo's
+# feature unification never drags libclang into the other workspace members.
+[target.'cfg(target_os = "ios")'.dependencies]
+rquickjs = { workspace = true, features = ["bindgen"] }
diff --git a/engine/apple/apple/PocketSurfaceView.h b/engine/apple/apple/PocketSurfaceView.h
new file mode 100644
index 00000000..76aa3b12
--- /dev/null
+++ b/engine/apple/apple/PocketSurfaceView.h
@@ -0,0 +1,84 @@
+// PocketSurfaceView — a UIKit view that hosts one PocketJS guest: display-link
+// driven ticks, packed touch input, and damage-gated compositing of the
+// software-rasterized ARGB framebuffer. Main thread only.
+
+#import
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface PocketSurfaceView : UIView
+
+// density is the raster scale (1..4; use 2 or 3 to match screen scale).
+- (instancetype)initWithFrame:(CGRect)frame
+ logicalWidth:(uint32_t)logicalWidth
+ logicalHeight:(uint32_t)logicalHeight
+ density:(uint32_t)density;
+
+// Struct-free convenience for bridged callers; frame starts at zero and is
+// laid out by the parent view system.
++ (instancetype)surfaceWithLogicalWidth:(uint32_t)logicalWidth
+ logicalHeight:(uint32_t)logicalHeight
+ density:(uint32_t)density;
+
+// External-guest mode: no embedded QuickJS realm — the embedding runtime
+// (e.g. NativeScript) owns the guest, mounts globalThis.ui over the ui*
+// methods below, and receives onTick to run globalThis.frame each display
+// tick. evalBundle is invalid in this mode; loadPak feeds the core directly.
++ (instancetype)externalSurfaceWithLogicalWidth:(uint32_t)logicalWidth
+ logicalHeight:(uint32_t)logicalHeight
+ density:(uint32_t)density;
+
+// External mode only: runs before the core tick; call globalThis.frame here.
+@property(nonatomic, copy, nullable) void (^onTick)
+ (uint32_t buttons, uint32_t analog, NSArray *touches);
+
+// ---- external-guest ui.* ops --------------------------------------------
+- (int32_t)uiCreateNode:(int32_t)nodeType;
+- (void)uiDestroyNode:(int32_t)nodeId;
+- (void)uiInsertBefore:(int32_t)parent child:(int32_t)child anchor:(int32_t)anchor;
+- (void)uiRemoveChild:(int32_t)parent child:(int32_t)child;
+- (void)uiSetStyle:(int32_t)nodeId style:(int32_t)styleId;
+- (void)uiSetProp:(int32_t)nodeId prop:(int32_t)prop value:(double)value;
+- (void)uiSetText:(int32_t)nodeId text:(NSString *)text;
+- (void)uiReplaceText:(int32_t)nodeId text:(NSString *)text;
+- (float)uiMeasureText:(NSString *)text fontSlot:(int32_t)fontSlot;
+- (int32_t)uiUploadTexture:(NSData *)pixels width:(uint32_t)width height:(uint32_t)height psm:(uint32_t)psm;
+- (void)uiSetImage:(int32_t)nodeId texture:(int32_t)texture;
+- (void)uiSetSprite:(int32_t)nodeId atlas:(int32_t)atlas frames:(int32_t)frames cols:(int32_t)cols step:(int32_t)step;
+- (int32_t)uiAnimate:(int32_t)nodeId prop:(int32_t)prop to:(double)to dur:(int32_t)durationMs easing:(int32_t)easing delay:(int32_t)delayMs;
+- (void)uiCancelAnim:(int32_t)animId;
+- (void)uiSetFocus:(int32_t)nodeId;
+- (void)uiSetActive:(int32_t)nodeId active:(int32_t)active;
+- (int32_t)uiHitTestBounds:(float)x y:(float)y;
+- (NSDictionary *)uiTextures;
+- (NSArray *> *)uiSprites;
+- (void)uiSvcSend:(NSString *)line;
+- (NSString *_Nullable)uiSvcPoll;
+- (BOOL)uiSvcOpen:(NSString *)name;
+
+// Feed assets before start. Returns NO with `lastError` set on failure.
+- (BOOL)loadPak:(NSData *)pak;
+- (BOOL)evalBundle:(NSString *)source label:(nullable NSString *)label;
+
+// Convenience: reads .js and .pak from a directory.
+- (BOOL)loadAppNamed:(NSString *)name fromDirectory:(NSString *)directory;
+
+// Starts/stops the CADisplayLink. start after evalBundle succeeds.
+- (void)start;
+- (void)stop;
+
+// Guest -> host effect lines (JSON by convention), delivered on the main
+// thread during the display tick.
+@property(nonatomic, copy, nullable) void (^onEffect)(NSString *line);
+
+// Host -> guest: queued for the guest's next poll (frame-boundary delivery).
+- (void)postEvent:(NSString *)line;
+
+@property(nonatomic, readonly) uint32_t logicalWidth;
+@property(nonatomic, readonly) uint32_t logicalHeight;
+@property(nonatomic, readonly, nullable) NSString *lastError;
+@property(nonatomic, copy, nullable) void (^onError)(NSString *message);
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/engine/apple/apple/PocketSurfaceView.m b/engine/apple/apple/PocketSurfaceView.m
new file mode 100644
index 00000000..b0225613
--- /dev/null
+++ b/engine/apple/apple/PocketSurfaceView.m
@@ -0,0 +1,521 @@
+#import "PocketSurfaceView.h"
+
+#import
+
+#include "pocket_apple.h"
+
+// The guest sees at most 8 contacts; slots map UITouch identity to the packed
+// word's id bits for the touch's lifetime.
+#define POCKET_MAX_TOUCHES 8
+
+typedef struct {
+ __weak UITouch *touch;
+ CGPoint point;
+ BOOL live;
+ BOOL reported;
+ BOOL used;
+} PocketTouchSlot;
+
+@implementation PocketSurfaceView {
+ PocketApple *_handle;
+ PocketAppleCore *_coreHandle;
+ CADisplayLink *_displayLink;
+ CGColorSpaceRef _colorSpace;
+ PocketTouchSlot _touchSlots[POCKET_MAX_TOUCHES];
+ uint32_t _density;
+ BOOL _running;
+}
+
++ (instancetype)surfaceWithLogicalWidth:(uint32_t)logicalWidth
+ logicalHeight:(uint32_t)logicalHeight
+ density:(uint32_t)density {
+ return [[self alloc] initWithFrame:CGRectZero
+ logicalWidth:logicalWidth
+ logicalHeight:logicalHeight
+ density:density];
+}
+
++ (instancetype)externalSurfaceWithLogicalWidth:(uint32_t)logicalWidth
+ logicalHeight:(uint32_t)logicalHeight
+ density:(uint32_t)density {
+ PocketSurfaceView *view = [[self alloc] initWithFrame:CGRectZero
+ logicalWidth:logicalWidth
+ logicalHeight:logicalHeight
+ density:density];
+ if (view != nil && view->_handle != NULL) {
+ pocket_apple_destroy(view->_handle);
+ view->_handle = NULL;
+ view->_coreHandle = pocket_apple_core_create(density, logicalWidth, logicalHeight);
+ if (view->_coreHandle == NULL) {
+ [view captureError];
+ }
+ }
+ return view;
+}
+
+- (instancetype)initWithFrame:(CGRect)frame
+ logicalWidth:(uint32_t)logicalWidth
+ logicalHeight:(uint32_t)logicalHeight
+ density:(uint32_t)density {
+ self = [super initWithFrame:frame];
+ if (self) {
+ _logicalWidth = logicalWidth;
+ _logicalHeight = logicalHeight;
+ _density = density;
+ _handle = pocket_apple_create(density, logicalWidth, logicalHeight);
+ if (_handle == NULL) {
+ [self captureError];
+ }
+ _colorSpace = CGColorSpaceCreateDeviceRGB();
+ self.multipleTouchEnabled = YES;
+ self.layer.contentsGravity = kCAGravityResizeAspect;
+ self.backgroundColor = [UIColor blackColor];
+
+ [[NSNotificationCenter defaultCenter] addObserver:self
+ selector:@selector(appDidEnterBackground)
+ name:UIApplicationDidEnterBackgroundNotification
+ object:nil];
+ [[NSNotificationCenter defaultCenter] addObserver:self
+ selector:@selector(appWillEnterForeground)
+ name:UIApplicationWillEnterForegroundNotification
+ object:nil];
+ }
+ return self;
+}
+
+- (void)dealloc {
+ [[NSNotificationCenter defaultCenter] removeObserver:self];
+ [_displayLink invalidate];
+ if (_handle != NULL) {
+ pocket_apple_destroy(_handle);
+ _handle = NULL;
+ }
+ if (_coreHandle != NULL) {
+ pocket_apple_core_destroy(_coreHandle);
+ _coreHandle = NULL;
+ }
+ if (_colorSpace != NULL) {
+ CGColorSpaceRelease(_colorSpace);
+ }
+}
+
+- (void)captureError {
+ const char *message = pocket_apple_last_error();
+ _lastError = message != NULL ? @(message) : @"unknown pocket-apple error";
+ if (self.onError != nil) {
+ self.onError(_lastError);
+ }
+}
+
+static void PocketSurfaceEffectTrampoline(const char *line, void *context) {
+ PocketSurfaceView *view = (__bridge PocketSurfaceView *)context;
+ if (view.onEffect != nil && line != NULL) {
+ view.onEffect(@(line));
+ }
+}
+
+- (void)setOnEffect:(void (^)(NSString *))onEffect {
+ _onEffect = [onEffect copy];
+ if (_handle != NULL) {
+ // The handle is destroyed in dealloc, so the unretained self reference
+ // can never outlive the registration.
+ pocket_apple_set_effect_callback(
+ _handle, onEffect != nil ? PocketSurfaceEffectTrampoline : NULL,
+ (__bridge void *)self);
+ }
+}
+
+- (void)postEvent:(NSString *)line {
+ if (line.length == 0) {
+ return;
+ }
+ if (_coreHandle != NULL) {
+ pocket_apple_core_post_event(_coreHandle, line.UTF8String);
+ } else if (_handle != NULL) {
+ pocket_apple_post_event(_handle, line.UTF8String);
+ }
+}
+
+- (BOOL)loadPak:(NSData *)pak {
+ if (pak.length == 0) {
+ return NO;
+ }
+ if (_coreHandle != NULL) {
+ if (pocket_apple_core_load_pak(_coreHandle, pak.bytes, pak.length) != 0) {
+ [self captureError];
+ return NO;
+ }
+ return YES;
+ }
+ if (_handle == NULL) {
+ return NO;
+ }
+ if (pocket_apple_load_pak(_handle, pak.bytes, pak.length) != 0) {
+ [self captureError];
+ return NO;
+ }
+ return YES;
+}
+
+- (BOOL)evalBundle:(NSString *)source label:(NSString *)label {
+ if (_handle == NULL || source.length == 0) {
+ return NO;
+ }
+ NSData *utf8 = [source dataUsingEncoding:NSUTF8StringEncoding];
+ if (pocket_apple_eval_bundle(_handle, utf8.bytes, utf8.length,
+ label != nil ? label.UTF8String : NULL) != 0) {
+ [self captureError];
+ return NO;
+ }
+ return YES;
+}
+
+- (BOOL)loadAppNamed:(NSString *)name fromDirectory:(NSString *)directory {
+ NSString *jsPath = [directory stringByAppendingPathComponent:
+ [name stringByAppendingPathExtension:@"js"]];
+ NSString *pakPath = [directory stringByAppendingPathComponent:
+ [name stringByAppendingPathExtension:@"pak"]];
+ NSData *pak = [NSData dataWithContentsOfFile:pakPath];
+ NSString *bundle = [NSString stringWithContentsOfFile:jsPath
+ encoding:NSUTF8StringEncoding
+ error:nil];
+ if (pak == nil || bundle == nil) {
+ _lastError = [NSString stringWithFormat:@"missing app assets: %@ / %@", jsPath, pakPath];
+ if (self.onError != nil) {
+ self.onError(_lastError);
+ }
+ return NO;
+ }
+ return [self loadPak:pak] && [self evalBundle:bundle label:name];
+}
+
+- (void)start {
+ if (_running || (_handle == NULL && _coreHandle == NULL)) {
+ return;
+ }
+ _running = YES;
+ _displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(handleDisplayTick:)];
+ if (@available(iOS 15.0, *)) {
+ // The core advances in exact 1/60 s steps; cap the link to match.
+ _displayLink.preferredFrameRateRange = CAFrameRateRangeMake(60, 60, 60);
+ }
+ [_displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
+}
+
+- (void)stop {
+ _running = NO;
+ [_displayLink invalidate];
+ _displayLink = nil;
+}
+
+- (void)appDidEnterBackground {
+ _displayLink.paused = YES;
+}
+
+- (void)appWillEnterForeground {
+ if (_running) {
+ _displayLink.paused = NO;
+ }
+}
+
+// The layer letterboxes with resizeAspect; touches must invert the same fit.
+- (CGRect)fittedContentRect {
+ CGSize bounds = self.bounds.size;
+ if (bounds.width <= 0 || bounds.height <= 0 || _logicalWidth == 0 || _logicalHeight == 0) {
+ return CGRectZero;
+ }
+ CGFloat scale = MIN(bounds.width / _logicalWidth, bounds.height / _logicalHeight);
+ CGFloat width = _logicalWidth * scale;
+ CGFloat height = _logicalHeight * scale;
+ return CGRectMake((bounds.width - width) / 2, (bounds.height - height) / 2, width, height);
+}
+
+- (BOOL)logicalPointForPoint:(CGPoint)point outX:(uint32_t *)outX outY:(uint32_t *)outY {
+ CGRect content = [self fittedContentRect];
+ if (CGRectIsEmpty(content)) {
+ return NO;
+ }
+ CGFloat x = (point.x - content.origin.x) / content.size.width * _logicalWidth;
+ CGFloat y = (point.y - content.origin.y) / content.size.height * _logicalHeight;
+ if (x < 0 || y < 0 || x >= _logicalWidth || y >= _logicalHeight) {
+ return NO;
+ }
+ // Packed coordinates carry 9 bits per axis.
+ *outX = (uint32_t)MIN(x, 511.0);
+ *outY = (uint32_t)MIN(y, 511.0);
+ return YES;
+}
+
+// Contacts latch until the display tick has reported them at least once:
+// a down+up that lands between two ticks still reaches the guest as one
+// present frame followed by an absent one (the contract's release edge).
+- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
+ for (UITouch *touch in touches) {
+ for (int slot = 0; slot < POCKET_MAX_TOUCHES; slot++) {
+ if (!_touchSlots[slot].used) {
+ _touchSlots[slot].touch = touch;
+ _touchSlots[slot].point = [touch locationInView:self];
+ _touchSlots[slot].live = YES;
+ _touchSlots[slot].reported = NO;
+ _touchSlots[slot].used = YES;
+ break;
+ }
+ }
+ }
+}
+
+- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event {
+ for (UITouch *touch in touches) {
+ for (int slot = 0; slot < POCKET_MAX_TOUCHES; slot++) {
+ if (_touchSlots[slot].used && _touchSlots[slot].touch == touch) {
+ _touchSlots[slot].point = [touch locationInView:self];
+ }
+ }
+ }
+}
+
+- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event {
+ [self releaseTouches:touches];
+}
+
+- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event {
+ [self releaseTouches:touches];
+}
+
+- (void)releaseTouches:(NSSet *)touches {
+ for (UITouch *touch in touches) {
+ for (int slot = 0; slot < POCKET_MAX_TOUCHES; slot++) {
+ if (_touchSlots[slot].used && _touchSlots[slot].touch == touch) {
+ _touchSlots[slot].live = NO;
+ if (_touchSlots[slot].reported) {
+ _touchSlots[slot].used = NO;
+ }
+ }
+ }
+ }
+}
+
+- (size_t)collectTouchWords:(uint32_t[POCKET_MAX_TOUCHES])words {
+ size_t count = 0;
+ for (int slot = 0; slot < POCKET_MAX_TOUCHES; slot++) {
+ if (!_touchSlots[slot].used) {
+ continue;
+ }
+ if (_touchSlots[slot].live && _touchSlots[slot].touch != nil) {
+ _touchSlots[slot].point = [_touchSlots[slot].touch locationInView:self];
+ }
+ uint32_t x = 0;
+ uint32_t y = 0;
+ if ([self logicalPointForPoint:_touchSlots[slot].point outX:&x outY:&y]) {
+ words[count++] = ((uint32_t)(slot & 0xff) << 18) | ((y & 0x1ff) << 9) | (x & 0x1ff);
+ }
+ _touchSlots[slot].reported = YES;
+ if (!_touchSlots[slot].live) {
+ _touchSlots[slot].used = NO;
+ }
+ }
+ return count;
+}
+
+- (void)presentFrame:(const PocketAppleFrame *)frame {
+ if (frame->region_count == 0 && self.layer.contents != nil) {
+ return;
+ }
+ size_t length = (size_t)frame->stride_bytes * frame->height_px;
+ CFDataRef data = CFDataCreate(NULL, frame->pixels, (CFIndex)length);
+ if (data == NULL) {
+ return;
+ }
+ CGDataProviderRef provider = CGDataProviderCreateWithCFData(data);
+ CGImageRef image = CGImageCreate(
+ frame->width_px, frame->height_px, 8, 32, frame->stride_bytes, _colorSpace,
+ (CGBitmapInfo)kCGImageAlphaNoneSkipFirst | kCGBitmapByteOrder32Little, provider, NULL,
+ false, kCGRenderingIntentDefault);
+ if (image != NULL) {
+ self.layer.contents = (__bridge id)image;
+ CGImageRelease(image);
+ }
+ CGDataProviderRelease(provider);
+ CFRelease(data);
+}
+
+- (void)handleDisplayTick:(CADisplayLink *)link {
+ uint32_t words[POCKET_MAX_TOUCHES];
+ size_t count = [self collectTouchWords:words];
+
+ if (_coreHandle != NULL) {
+ if (self.onTick != nil) {
+ NSMutableArray *touches = [NSMutableArray arrayWithCapacity:count];
+ for (size_t i = 0; i < count; i++) {
+ [touches addObject:@(words[i])];
+ }
+ self.onTick(0, 0x8080, touches);
+ }
+ pocket_apple_core_tick(_coreHandle);
+ if (self.onEffect != nil) {
+ pocket_apple_core_drain_effects(_coreHandle, PocketSurfaceEffectTrampoline,
+ (__bridge void *)self);
+ }
+ PocketAppleFrame frame;
+ if (pocket_apple_core_render(_coreHandle, &frame) != 0) {
+ [self captureError];
+ [self stop];
+ return;
+ }
+ [self presentFrame:&frame];
+ return;
+ }
+
+ if (_handle == NULL) {
+ return;
+ }
+ if (pocket_apple_frame(_handle, 0, 0, count > 0 ? words : NULL, count) != 0) {
+ [self captureError];
+ [self stop];
+ return;
+ }
+ PocketAppleFrame frame;
+ if (pocket_apple_render(_handle, &frame) != 0) {
+ [self captureError];
+ [self stop];
+ return;
+ }
+ [self presentFrame:&frame];
+}
+
+// ---- external-guest ui.* ops --------------------------------------------
+
+- (int32_t)uiCreateNode:(int32_t)nodeType {
+ return _coreHandle != NULL ? pocket_apple_core_create_node(_coreHandle, (uint32_t)nodeType) : 0;
+}
+
+- (void)uiDestroyNode:(int32_t)nodeId {
+ if (_coreHandle != NULL) pocket_apple_core_destroy_node(_coreHandle, nodeId);
+}
+
+- (void)uiInsertBefore:(int32_t)parent child:(int32_t)child anchor:(int32_t)anchor {
+ if (_coreHandle != NULL) pocket_apple_core_insert_before(_coreHandle, parent, child, anchor);
+}
+
+- (void)uiRemoveChild:(int32_t)parent child:(int32_t)child {
+ if (_coreHandle != NULL) pocket_apple_core_remove_child(_coreHandle, parent, child);
+}
+
+- (void)uiSetStyle:(int32_t)nodeId style:(int32_t)styleId {
+ if (_coreHandle != NULL) pocket_apple_core_set_style(_coreHandle, nodeId, styleId);
+}
+
+- (void)uiSetProp:(int32_t)nodeId prop:(int32_t)prop value:(double)value {
+ if (_coreHandle != NULL) pocket_apple_core_set_prop(_coreHandle, nodeId, (uint32_t)prop, value);
+}
+
+- (void)uiSetText:(int32_t)nodeId text:(NSString *)text {
+ if (_coreHandle == NULL) return;
+ NSData *utf8 = [text dataUsingEncoding:NSUTF8StringEncoding];
+ pocket_apple_core_set_text(_coreHandle, nodeId, utf8.bytes, utf8.length);
+}
+
+- (void)uiReplaceText:(int32_t)nodeId text:(NSString *)text {
+ if (_coreHandle == NULL) return;
+ NSData *utf8 = [text dataUsingEncoding:NSUTF8StringEncoding];
+ pocket_apple_core_replace_text(_coreHandle, nodeId, utf8.bytes, utf8.length);
+}
+
+- (float)uiMeasureText:(NSString *)text fontSlot:(int32_t)fontSlot {
+ if (_coreHandle == NULL) return 0;
+ NSData *utf8 = [text dataUsingEncoding:NSUTF8StringEncoding];
+ return pocket_apple_core_measure_text(_coreHandle, utf8.bytes, utf8.length, (uint32_t)fontSlot);
+}
+
+- (int32_t)uiUploadTexture:(NSData *)pixels width:(uint32_t)width height:(uint32_t)height psm:(uint32_t)psm {
+ if (_coreHandle == NULL || pixels.length == 0) return -1;
+ return pocket_apple_core_upload_texture(_coreHandle, pixels.bytes, pixels.length, width, height, psm);
+}
+
+- (void)uiSetImage:(int32_t)nodeId texture:(int32_t)texture {
+ if (_coreHandle != NULL) pocket_apple_core_set_image(_coreHandle, nodeId, texture);
+}
+
+- (void)uiSetSprite:(int32_t)nodeId atlas:(int32_t)atlas frames:(int32_t)frames cols:(int32_t)cols step:(int32_t)step {
+ if (_coreHandle != NULL) {
+ pocket_apple_core_set_sprite(_coreHandle, nodeId, atlas, (uint32_t)frames, (uint32_t)cols,
+ (uint32_t)step);
+ }
+}
+
+- (int32_t)uiAnimate:(int32_t)nodeId prop:(int32_t)prop to:(double)to dur:(int32_t)durationMs easing:(int32_t)easing delay:(int32_t)delayMs {
+ if (_coreHandle == NULL) return -1;
+ return pocket_apple_core_animate(_coreHandle, nodeId, (uint32_t)prop, to, (uint32_t)durationMs,
+ (uint32_t)easing, (uint32_t)delayMs);
+}
+
+- (void)uiCancelAnim:(int32_t)animId {
+ if (_coreHandle != NULL) pocket_apple_core_cancel_anim(_coreHandle, animId);
+}
+
+- (void)uiSetFocus:(int32_t)nodeId {
+ if (_coreHandle != NULL) pocket_apple_core_set_focus(_coreHandle, nodeId);
+}
+
+- (void)uiSetActive:(int32_t)nodeId active:(int32_t)active {
+ if (_coreHandle != NULL) pocket_apple_core_set_active(_coreHandle, nodeId, active);
+}
+
+- (int32_t)uiHitTestBounds:(float)x y:(float)y {
+ if (_coreHandle != NULL) return pocket_apple_core_hit_test_bounds(_coreHandle, x, y);
+ if (_handle != NULL) return pocket_apple_hit_test_bounds(_handle, x, y);
+ return 0;
+}
+
+- (NSDictionary *)uiTextures {
+ NSMutableDictionary *table = [NSMutableDictionary dictionary];
+ if (_coreHandle != NULL) {
+ uint32_t count = pocket_apple_core_texture_count(_coreHandle);
+ for (uint32_t i = 0; i < count; i++) {
+ const char *name = pocket_apple_core_texture_name(_coreHandle, i);
+ if (name != NULL) {
+ table[@(name)] = @(pocket_apple_core_texture_handle(_coreHandle, i));
+ }
+ }
+ }
+ return table;
+}
+
+- (NSArray *> *)uiSprites {
+ NSMutableArray *sprites = [NSMutableArray array];
+ if (_coreHandle != NULL) {
+ uint32_t count = pocket_apple_core_sprite_count(_coreHandle);
+ for (uint32_t i = 0; i < count; i++) {
+ const char *name = pocket_apple_core_sprite_name(_coreHandle, i);
+ int32_t info[4] = {0};
+ if (name != NULL && pocket_apple_core_sprite_info(_coreHandle, i, info) == 0) {
+ [sprites addObject:@{
+ @"name" : @(name),
+ @"handle" : @(info[0]),
+ @"frames" : @(info[1]),
+ @"cols" : @(info[2]),
+ @"step" : @(info[3]),
+ }];
+ }
+ }
+ }
+ return sprites;
+}
+
+- (void)uiSvcSend:(NSString *)line {
+ if (_coreHandle == NULL) return;
+ NSData *utf8 = [line dataUsingEncoding:NSUTF8StringEncoding];
+ pocket_apple_core_svc_send(_coreHandle, utf8.bytes, utf8.length);
+}
+
+- (NSString *)uiSvcPoll {
+ if (_coreHandle == NULL) return nil;
+ const char *batch = pocket_apple_core_svc_poll(_coreHandle);
+ return batch != NULL ? @(batch) : nil;
+}
+
+- (BOOL)uiSvcOpen:(NSString *)name {
+ return _coreHandle != NULL;
+}
+
+@end
diff --git a/engine/apple/build-xcframework.sh b/engine/apple/build-xcframework.sh
new file mode 100755
index 00000000..b49683f9
--- /dev/null
+++ b/engine/apple/build-xcframework.sh
@@ -0,0 +1,77 @@
+#!/bin/bash
+# Builds PocketApple.xcframework: the pocket-apple Rust staticlib plus the
+# compiled PocketSurfaceView, packaged as a dynamic framework per slice
+# (device arm64 + simulator arm64). Output: engine/apple/dist/.
+set -euo pipefail
+
+cd "$(dirname "$0")"
+APPLE_DIR="$PWD"
+ENGINE_DIR="$(cd .. && pwd)"
+DIST="$APPLE_DIR/dist"
+MIN_IOS="16.0"
+
+rm -rf "$DIST"
+mkdir -p "$DIST"
+
+build_slice() {
+ local rust_target="$1" sdk="$2" clang_target="$3" slice="$4"
+
+ (cd "$ENGINE_DIR" && IPHONEOS_DEPLOYMENT_TARGET="$MIN_IOS" cargo build -p pocket-apple --release --target "$rust_target")
+
+ local fw="$DIST/$slice/PocketApple.framework"
+ mkdir -p "$fw/Headers" "$fw/Modules"
+
+ cp "$APPLE_DIR/include/pocket_apple.h" "$fw/Headers/"
+ cp "$APPLE_DIR/apple/PocketSurfaceView.h" "$fw/Headers/"
+ cat > "$fw/Headers/PocketApple.h" <<'EOF'
+#import
+#include
+EOF
+ cat > "$fw/Modules/module.modulemap" <<'EOF'
+framework module PocketApple {
+ umbrella header "PocketApple.h"
+ export *
+ module * { export * }
+}
+EOF
+ cat > "$fw/Info.plist" <
+
+
+
+ CFBundleDevelopmentRegionen
+ CFBundleExecutablePocketApple
+ CFBundleIdentifierdev.pocketjs.PocketApple
+ CFBundleInfoDictionaryVersion6.0
+ CFBundleNamePocketApple
+ CFBundlePackageTypeFMWK
+ CFBundleShortVersionString0.1.0
+ CFBundleVersion1
+ MinimumOSVersion$MIN_IOS
+
+
+EOF
+
+ xcrun -sdk "$sdk" clang \
+ -target "$clang_target" \
+ -fobjc-arc -fapplication-extension \
+ -dynamiclib \
+ -install_name "@rpath/PocketApple.framework/PocketApple" \
+ -I "$APPLE_DIR/include" \
+ "$APPLE_DIR/apple/PocketSurfaceView.m" \
+ "$ENGINE_DIR/target/$rust_target/release/libpocket_apple.a" \
+ -framework Foundation -framework UIKit -framework QuartzCore -framework CoreGraphics \
+ -dead_strip \
+ -o "$fw/PocketApple"
+}
+
+build_slice aarch64-apple-ios iphoneos "arm64-apple-ios$MIN_IOS" ios-arm64
+build_slice aarch64-apple-ios-sim iphonesimulator "arm64-apple-ios$MIN_IOS-simulator" ios-arm64-simulator
+
+rm -rf "$DIST/PocketApple.xcframework"
+xcodebuild -create-xcframework \
+ -framework "$DIST/ios-arm64/PocketApple.framework" \
+ -framework "$DIST/ios-arm64-simulator/PocketApple.framework" \
+ -output "$DIST/PocketApple.xcframework"
+
+echo "OK: $DIST/PocketApple.xcframework"
diff --git a/engine/apple/examples/render_hero.rs b/engine/apple/examples/render_hero.rs
new file mode 100644
index 00000000..e8fc16cc
--- /dev/null
+++ b/engine/apple/examples/render_hero.rs
@@ -0,0 +1,118 @@
+//! Renders a guest bundle through the pocket-apple C ABI and writes PPM
+//! snapshots. Build the MOUNTED demo entry first — the bare app name builds a
+//! component-only bundle that installs no frame() and cannot boot here:
+//! bun tools/build.ts hero-main
+//! cargo run -p pocket-apple --example render_hero -- ../dist/hero-main.js ../dist/hero-main.pak /tmp/hero
+//! Exit is nonzero if two independent instances disagree on the final frame
+//! (determinism check) or the frame is blank.
+
+use std::ffi::CString;
+
+use pocket_apple::{
+ pocket_apple_create, pocket_apple_destroy, pocket_apple_eval_bundle, pocket_apple_frame,
+ pocket_apple_last_error, pocket_apple_load_pak, pocket_apple_render, PocketAppleFrame,
+};
+
+const WIDTH: u32 = 480;
+const HEIGHT: u32 = 272;
+const DENSITY: u32 = 2;
+const FRAMES: u32 = 180;
+
+fn last_error() -> String {
+ unsafe {
+ std::ffi::CStr::from_ptr(pocket_apple_last_error())
+ .to_string_lossy()
+ .into_owned()
+ }
+}
+
+fn run_instance(bundle: &[u8], pak: &[u8]) -> (Vec, u32, u32, u64) {
+ let handle = pocket_apple_create(DENSITY, WIDTH, HEIGHT);
+ assert!(!handle.is_null(), "create failed: {}", last_error());
+ assert_eq!(
+ pocket_apple_load_pak(handle, pak.as_ptr(), pak.len()),
+ 0,
+ "load_pak failed: {}",
+ last_error()
+ );
+ let label = CString::new("hero").unwrap();
+ assert_eq!(
+ pocket_apple_eval_bundle(handle, bundle.as_ptr(), bundle.len(), label.as_ptr()),
+ 0,
+ "eval failed: {}",
+ last_error()
+ );
+
+ let mut frame = unsafe { std::mem::zeroed::() };
+ let mut damage_total: u64 = 0;
+ for tick in 0..FRAMES {
+ assert_eq!(
+ pocket_apple_frame(handle, 0, 0, std::ptr::null(), 0),
+ 0,
+ "frame {tick} failed: {}",
+ last_error()
+ );
+ assert_eq!(
+ pocket_apple_render(handle, &mut frame),
+ 0,
+ "render {tick} failed: {}",
+ last_error()
+ );
+ for region in frame.regions.iter().take(frame.region_count as usize) {
+ damage_total += (region[2] as u64) * (region[3] as u64);
+ }
+ }
+ let len = (frame.stride_bytes * frame.height_px) as usize;
+ let pixels = unsafe { std::slice::from_raw_parts(frame.pixels, len) }.to_vec();
+ let (w, h) = (frame.width_px, frame.height_px);
+ pocket_apple_destroy(handle);
+ (pixels, w, h, damage_total)
+}
+
+fn write_ppm(path: &str, argb: &[u8], width: u32, height: u32) {
+ let mut out = format!("P6\n{width} {height}\n255\n").into_bytes();
+ for chunk in argb.chunks_exact(4) {
+ // ARGB32 little-endian in memory: B, G, R, A.
+ out.extend_from_slice(&[chunk[2], chunk[1], chunk[0]]);
+ }
+ std::fs::write(path, out).expect("write ppm");
+}
+
+struct StderrLogger;
+
+impl log::Log for StderrLogger {
+ fn enabled(&self, _: &log::Metadata) -> bool {
+ true
+ }
+ fn log(&self, record: &log::Record) {
+ eprintln!("[{}] {}", record.target(), record.args());
+ }
+ fn flush(&self) {}
+}
+
+static LOGGER: StderrLogger = StderrLogger;
+
+fn main() {
+ let _ = log::set_logger(&LOGGER).map(|_| log::set_max_level(log::LevelFilter::Debug));
+ let args: Vec = std::env::args().collect();
+ let bundle_path = args.get(1).map(String::as_str).unwrap_or("../dist/hero-main.js");
+ let pak_path = args.get(2).map(String::as_str).unwrap_or("../dist/hero-main.pak");
+ let out_base = args.get(3).map(String::as_str).unwrap_or("/tmp/hero");
+
+ let bundle = std::fs::read(bundle_path).expect("read bundle");
+ let pak = std::fs::read(pak_path).expect("read pak");
+
+ let (first, w, h, damage_a) = run_instance(&bundle, &pak);
+ let (second, _, _, damage_b) = run_instance(&bundle, &pak);
+
+ let non_blank = first.chunks_exact(4).any(|px| px[0] != 0 || px[1] != 0 || px[2] != 0);
+ let deterministic = first == second;
+
+ write_ppm(&format!("{out_base}-frame{FRAMES}.ppm"), &first, w, h);
+ println!(
+ "rendered {FRAMES} frames at {w}x{h} (density {DENSITY}) | non_blank={non_blank} deterministic={deterministic} damage_px_a={damage_a} damage_px_b={damage_b}"
+ );
+ if !non_blank || !deterministic {
+ std::process::exit(1);
+ }
+}
diff --git a/engine/apple/include/pocket_apple.h b/engine/apple/include/pocket_apple.h
new file mode 100644
index 00000000..f9d64e04
--- /dev/null
+++ b/engine/apple/include/pocket_apple.h
@@ -0,0 +1,148 @@
+// pocket-apple C ABI — the PocketJS guest + ui surface + software rasterizer
+// (engine/apple/src/lib.rs). Single-threaded: create, drive, and destroy a
+// handle from one thread (in practice the main thread, with CADisplayLink).
+//
+// Call order per handle:
+// create -> load_pak* -> [set_identity] -> eval_bundle
+// -> per tick: frame, render -> destroy
+// load_pak/set_identity are rejected after eval_bundle: the surface publishes
+// both to the guest when `ui` is mounted.
+
+#ifndef POCKET_APPLE_H
+#define POCKET_APPLE_H
+
+#include
+#include
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+#define POCKET_APPLE_MAX_DAMAGE_REGIONS 8
+
+typedef struct PocketApple PocketApple;
+
+// One rendered frame. `pixels` is ARGB32 words — BGRA byte order in memory on
+// little-endian, i.e. kCGBitmapByteOrder32Little | kCGImageAlphaNoneSkipFirst.
+// The pointer stays valid until the next render/destroy on the same handle.
+typedef struct PocketAppleFrame {
+ const uint8_t *pixels;
+ uint32_t width_px;
+ uint32_t height_px;
+ uint32_t stride_bytes;
+ // Repaint rects in pixel coordinates as x, y, w, h. region_count == 0 means
+ // nothing changed this frame; previous contents are still current.
+ int32_t regions[POCKET_APPLE_MAX_DAMAGE_REGIONS][4];
+ uint32_t region_count;
+ int32_t full_redraw;
+} PocketAppleFrame;
+
+uint32_t pocket_apple_abi_version(void);
+
+// Last failure message for this thread; valid until the next failing call.
+const char *pocket_apple_last_error(void);
+
+// density is the raster scale (1..4); logical viewport is in pocketjs units
+// (hero is 480x272). Returns NULL on failure.
+PocketApple *pocket_apple_create(uint32_t density, uint32_t logical_width,
+ uint32_t logical_height);
+
+int32_t pocket_apple_set_identity(PocketApple *handle, const char *host_id,
+ uint32_t host_abi);
+
+int32_t pocket_apple_load_pak(PocketApple *handle, const uint8_t *bytes,
+ size_t length);
+
+// Mounts `ui` on first call, evaluates the bundle, and requires it to install
+// globalThis.frame. `label` may be NULL ("app").
+int32_t pocket_apple_eval_bundle(PocketApple *handle, const uint8_t *source,
+ size_t length, const char *label);
+
+// touches: up to 8 packed words, (id & 0xff) << 18 | (y & 0x1ff) << 9 |
+// (x & 0x1ff), logical coordinates; a contact present this tick means
+// down/move, absent means released. analog 0 means centered (0x8080).
+int32_t pocket_apple_frame(PocketApple *handle, uint32_t buttons,
+ uint32_t analog, const uint32_t *touches,
+ size_t touch_count);
+
+int32_t pocket_apple_render(PocketApple *handle, PocketAppleFrame *out);
+
+// Logical coordinates; returns the hit node id or 0.
+int32_t pocket_apple_hit_test_bounds(PocketApple *handle, float x, float y);
+
+// Guest -> host effect sink: lines the guest's effect driver svcSend()s
+// (JSON by convention), delivered synchronously during pocket_apple_frame on
+// the calling thread. context must outlive the registration.
+typedef void (*PocketAppleEffectCallback)(const char *line, void *context);
+int32_t pocket_apple_set_effect_callback(PocketApple *handle,
+ PocketAppleEffectCallback callback,
+ void *context);
+
+// Host -> guest: queue one line for the guest's next svcPoll (delivered at a
+// frame boundary, never mid-tick).
+int32_t pocket_apple_post_event(PocketApple *handle, const char *line);
+
+void pocket_apple_destroy(PocketApple *handle);
+
+// ---- external-guest mode ---------------------------------------------------
+// The embedding runtime owns the JS guest; this side owns only the core, the
+// pak feed, the raster pipeline, and the svc queues. Mount globalThis.ui in
+// the embedding engine over these ops. Same single-thread rules.
+
+typedef struct PocketAppleCore PocketAppleCore;
+
+PocketAppleCore *pocket_apple_core_create(uint32_t density, uint32_t logical_width,
+ uint32_t logical_height);
+int32_t pocket_apple_core_load_pak(PocketAppleCore *handle, const uint8_t *bytes,
+ size_t length);
+
+int32_t pocket_apple_core_create_node(PocketAppleCore *handle, uint32_t node_type);
+void pocket_apple_core_destroy_node(PocketAppleCore *handle, int32_t id);
+void pocket_apple_core_insert_before(PocketAppleCore *handle, int32_t parent, int32_t child,
+ int32_t anchor);
+void pocket_apple_core_remove_child(PocketAppleCore *handle, int32_t parent, int32_t child);
+void pocket_apple_core_set_style(PocketAppleCore *handle, int32_t id, int32_t style);
+void pocket_apple_core_set_prop(PocketAppleCore *handle, int32_t id, uint32_t prop, double value);
+void pocket_apple_core_set_text(PocketAppleCore *handle, int32_t id, const uint8_t *text,
+ size_t length);
+void pocket_apple_core_replace_text(PocketAppleCore *handle, int32_t id, const uint8_t *text,
+ size_t length);
+float pocket_apple_core_measure_text(PocketAppleCore *handle, const uint8_t *text, size_t length,
+ uint32_t font_slot);
+int32_t pocket_apple_core_upload_texture(PocketAppleCore *handle, const uint8_t *bytes,
+ size_t length, uint32_t width, uint32_t height,
+ uint32_t psm);
+void pocket_apple_core_set_image(PocketAppleCore *handle, int32_t id, int32_t texture);
+void pocket_apple_core_set_sprite(PocketAppleCore *handle, int32_t id, int32_t atlas,
+ uint32_t frames, uint32_t cols, uint32_t step);
+int32_t pocket_apple_core_animate(PocketAppleCore *handle, int32_t id, uint32_t prop, double to,
+ uint32_t duration_ms, uint32_t easing, uint32_t delay_ms);
+void pocket_apple_core_cancel_anim(PocketAppleCore *handle, int32_t anim_id);
+void pocket_apple_core_set_focus(PocketAppleCore *handle, int32_t id);
+void pocket_apple_core_set_active(PocketAppleCore *handle, int32_t id, int32_t active);
+int32_t pocket_apple_core_hit_test_bounds(PocketAppleCore *handle, float x, float y);
+
+uint32_t pocket_apple_core_texture_count(PocketAppleCore *handle);
+const char *pocket_apple_core_texture_name(PocketAppleCore *handle, uint32_t index);
+int32_t pocket_apple_core_texture_handle(PocketAppleCore *handle, uint32_t index);
+uint32_t pocket_apple_core_sprite_count(PocketAppleCore *handle);
+const char *pocket_apple_core_sprite_name(PocketAppleCore *handle, uint32_t index);
+// out must hold 4 int32: handle, frames, cols, step.
+int32_t pocket_apple_core_sprite_info(PocketAppleCore *handle, uint32_t index, int32_t *out);
+
+void pocket_apple_core_svc_send(PocketAppleCore *handle, const uint8_t *text, size_t length);
+// Newline-joined batch or NULL; valid until the next poll on this handle.
+const char *pocket_apple_core_svc_poll(PocketAppleCore *handle);
+int32_t pocket_apple_core_post_event(PocketAppleCore *handle, const char *line);
+void pocket_apple_core_drain_effects(PocketAppleCore *handle, PocketAppleEffectCallback callback,
+ void *context);
+
+void pocket_apple_core_tick(PocketAppleCore *handle);
+int32_t pocket_apple_core_render(PocketAppleCore *handle, PocketAppleFrame *out);
+void pocket_apple_core_destroy(PocketAppleCore *handle);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif // POCKET_APPLE_H
diff --git a/engine/apple/src/core_host.rs b/engine/apple/src/core_host.rs
new file mode 100644
index 00000000..82bd80e1
--- /dev/null
+++ b/engine/apple/src/core_host.rs
@@ -0,0 +1,559 @@
+//! External-guest mode: the JS engine lives elsewhere (a NativeScript
+//! runtime), so this side owns only `pocketjs_core::Ui`, the pak feed, the
+//! raster pipeline, and the svc queues. The host mounts `globalThis.ui` in
+//! its own engine and delegates each op to the `pocket_apple_core_*` C ABI.
+//! Same single-thread rules as the guest-owning mode.
+
+use std::collections::VecDeque;
+use std::ffi::{c_char, CString};
+use std::panic::{catch_unwind, AssertUnwindSafe};
+use std::slice;
+
+use pocket_ui_surface::walk_pak;
+use pocketjs_core::damage::{DamagePolicy, DamageTracker};
+use pocketjs_core::raster;
+use pocketjs_core::Ui;
+
+use crate::{set_last_error, PocketAppleFrame, POCKET_APPLE_MAX_DAMAGE_REGIONS};
+
+const OK: i32 = 0;
+const ERR_BAD_ARGUMENT: i32 = -1;
+const ERR_PANIC: i32 = -4;
+
+pub struct SpriteReg {
+ pub name: CString,
+ pub handle: i32,
+ pub frames: u16,
+ pub cols: u16,
+ pub step: u16,
+}
+
+pub struct PocketAppleCore {
+ ui: Ui,
+ framebuffer: Vec,
+ tracker: DamageTracker,
+ density: u32,
+ logical_width: u32,
+ logical_height: u32,
+ textures: Vec<(CString, i32)>,
+ sprites: Vec,
+ svc_in: VecDeque,
+ svc_out: VecDeque,
+ svc_poll_batch: CString,
+}
+
+fn with_core(
+ handle: *mut PocketAppleCore,
+ default: R,
+ f: impl FnOnce(&mut PocketAppleCore) -> R,
+) -> R {
+ if handle.is_null() {
+ set_last_error("null core handle");
+ return default;
+ }
+ let state = unsafe { &mut *handle };
+ match catch_unwind(AssertUnwindSafe(|| f(state))) {
+ Ok(value) => value,
+ Err(_) => {
+ set_last_error("panic inside pocket-apple core");
+ default
+ }
+ }
+}
+
+fn str_arg<'a>(bytes: *const u8, length: usize) -> Option<&'a str> {
+ if bytes.is_null() {
+ return Some("");
+ }
+ std::str::from_utf8(unsafe { slice::from_raw_parts(bytes, length) }).ok()
+}
+
+fn rd_u16(b: &[u8], off: usize) -> Option {
+ Some(u16::from_le_bytes([*b.get(off)?, *b.get(off + 1)?]))
+}
+
+fn decode_pix_header(blob: &[u8], pixels_off: usize) -> Option<(u32, u32, u32, &[u8])> {
+ let w = rd_u16(blob, 0)? as u32;
+ let h = rd_u16(blob, 2)? as u32;
+ let psm = *blob.get(4)? as u32;
+ let pixels = blob.get(pixels_off..)?;
+ Some((w, h, psm, pixels))
+}
+
+#[unsafe(no_mangle)]
+pub extern "C" fn pocket_apple_core_create(
+ density: u32,
+ logical_width: u32,
+ logical_height: u32,
+) -> *mut PocketAppleCore {
+ let result = catch_unwind(|| {
+ if density == 0
+ || density > raster::MAX_RENDER_SCALE
+ || logical_width == 0
+ || logical_height == 0
+ {
+ set_last_error("invalid density or viewport");
+ return std::ptr::null_mut();
+ }
+ let mut ui = Ui::new_with_raster_density(density);
+ ui.set_viewport(logical_width as f32, logical_height as f32);
+ let pixel_len =
+ (logical_width * density) as usize * (logical_height * density) as usize * 4;
+ Box::into_raw(Box::new(PocketAppleCore {
+ ui,
+ framebuffer: vec![0; pixel_len],
+ tracker: DamageTracker::default(),
+ density,
+ logical_width,
+ logical_height,
+ textures: Vec::new(),
+ sprites: Vec::new(),
+ svc_in: VecDeque::new(),
+ svc_out: VecDeque::new(),
+ svc_poll_batch: CString::default(),
+ }))
+ });
+ result.unwrap_or(std::ptr::null_mut())
+}
+
+/// Mirrors `UiSurface::feed_pak`: styles and font atlases feed the core,
+/// images and sprite atlases upload as textures and land in the name tables
+/// the host publishes as `ui.__textures` / `ui.__sprites`.
+#[unsafe(no_mangle)]
+pub extern "C" fn pocket_apple_core_load_pak(
+ handle: *mut PocketAppleCore,
+ bytes: *const u8,
+ length: usize,
+) -> i32 {
+ with_core(handle, ERR_PANIC, |state| {
+ if bytes.is_null() || length == 0 {
+ return ERR_BAD_ARGUMENT;
+ }
+ let pak = unsafe { slice::from_raw_parts(bytes, length) };
+ for entry in walk_pak(pak) {
+ if entry.key == "ui:styles" {
+ if !state.ui.load_styles(entry.blob) {
+ log::warn!("pocket-apple: bad styles.bin in pak");
+ }
+ } else if entry.key.starts_with("ui:font.") {
+ if !state.ui.load_font_atlas(entry.blob) {
+ log::warn!("pocket-apple: bad font atlas {}", entry.key);
+ }
+ } else if let Some(name) = entry.key.strip_prefix("ui:img.") {
+ let Some((w, h, psm, pixels)) = decode_pix_header(entry.blob, 8) else {
+ continue;
+ };
+ let texture = state.ui.upload_texture(pixels, w, h, psm);
+ if texture >= 0 {
+ if let Ok(name) = CString::new(name) {
+ state.textures.push((name, texture));
+ }
+ }
+ } else if let Some(name) = entry.key.strip_prefix("ui:sprite.") {
+ let Some((w, h, psm, pixels)) = decode_pix_header(entry.blob, 16) else {
+ continue;
+ };
+ let (Some(frames), Some(cols), Some(step)) = (
+ rd_u16(entry.blob, 6),
+ rd_u16(entry.blob, 8),
+ rd_u16(entry.blob, 10),
+ ) else {
+ continue;
+ };
+ let texture = state.ui.upload_texture(pixels, w, h, psm);
+ if texture >= 0 {
+ if let Ok(name) = CString::new(name) {
+ state.sprites.push(SpriteReg {
+ name,
+ handle: texture,
+ frames,
+ cols,
+ step,
+ });
+ }
+ }
+ }
+ }
+ OK
+ })
+}
+
+// ---- ui.* ops ------------------------------------------------------------
+
+#[unsafe(no_mangle)]
+pub extern "C" fn pocket_apple_core_create_node(handle: *mut PocketAppleCore, node_type: u32) -> i32 {
+ with_core(handle, 0, |state| state.ui.create_node(node_type as u8))
+}
+
+#[unsafe(no_mangle)]
+pub extern "C" fn pocket_apple_core_destroy_node(handle: *mut PocketAppleCore, id: i32) {
+ with_core(handle, (), |state| state.ui.destroy_node(id));
+}
+
+#[unsafe(no_mangle)]
+pub extern "C" fn pocket_apple_core_insert_before(
+ handle: *mut PocketAppleCore,
+ parent: i32,
+ child: i32,
+ anchor: i32,
+) {
+ with_core(handle, (), |state| state.ui.insert_before(parent, child, anchor));
+}
+
+#[unsafe(no_mangle)]
+pub extern "C" fn pocket_apple_core_remove_child(
+ handle: *mut PocketAppleCore,
+ parent: i32,
+ child: i32,
+) {
+ with_core(handle, (), |state| state.ui.remove_child(parent, child));
+}
+
+#[unsafe(no_mangle)]
+pub extern "C" fn pocket_apple_core_set_style(handle: *mut PocketAppleCore, id: i32, style: i32) {
+ with_core(handle, (), |state| state.ui.set_style(id, style));
+}
+
+#[unsafe(no_mangle)]
+pub extern "C" fn pocket_apple_core_set_prop(
+ handle: *mut PocketAppleCore,
+ id: i32,
+ prop: u32,
+ value: f64,
+) {
+ with_core(handle, (), |state| state.ui.set_prop(id, prop as u8, value));
+}
+
+#[unsafe(no_mangle)]
+pub extern "C" fn pocket_apple_core_set_text(
+ handle: *mut PocketAppleCore,
+ id: i32,
+ text: *const u8,
+ length: usize,
+) {
+ with_core(handle, (), |state| {
+ if let Some(text) = str_arg(text, length) {
+ state.ui.set_text(id, text);
+ }
+ });
+}
+
+#[unsafe(no_mangle)]
+pub extern "C" fn pocket_apple_core_replace_text(
+ handle: *mut PocketAppleCore,
+ id: i32,
+ text: *const u8,
+ length: usize,
+) {
+ with_core(handle, (), |state| {
+ if let Some(text) = str_arg(text, length) {
+ state.ui.replace_text(id, text);
+ }
+ });
+}
+
+#[unsafe(no_mangle)]
+pub extern "C" fn pocket_apple_core_measure_text(
+ handle: *mut PocketAppleCore,
+ text: *const u8,
+ length: usize,
+ font_slot: u32,
+) -> f32 {
+ with_core(handle, 0.0, |state| {
+ str_arg(text, length)
+ .map(|text| state.ui.measure_text(text, font_slot as u8))
+ .unwrap_or(0.0)
+ })
+}
+
+#[unsafe(no_mangle)]
+pub extern "C" fn pocket_apple_core_upload_texture(
+ handle: *mut PocketAppleCore,
+ bytes: *const u8,
+ length: usize,
+ width: u32,
+ height: u32,
+ psm: u32,
+) -> i32 {
+ with_core(handle, -1, |state| {
+ if bytes.is_null() || length == 0 {
+ return -1;
+ }
+ let data = unsafe { slice::from_raw_parts(bytes, length) };
+ state.ui.upload_texture(data, width, height, psm)
+ })
+}
+
+#[unsafe(no_mangle)]
+pub extern "C" fn pocket_apple_core_set_image(handle: *mut PocketAppleCore, id: i32, texture: i32) {
+ with_core(handle, (), |state| state.ui.set_image(id, texture));
+}
+
+#[unsafe(no_mangle)]
+pub extern "C" fn pocket_apple_core_set_sprite(
+ handle: *mut PocketAppleCore,
+ id: i32,
+ atlas: i32,
+ frames: u32,
+ cols: u32,
+ step: u32,
+) {
+ with_core(handle, (), |state| state.ui.set_sprite(id, atlas, frames, cols, step));
+}
+
+#[unsafe(no_mangle)]
+pub extern "C" fn pocket_apple_core_animate(
+ handle: *mut PocketAppleCore,
+ id: i32,
+ prop: u32,
+ to: f64,
+ duration_ms: u32,
+ easing: u32,
+ delay_ms: u32,
+) -> i32 {
+ with_core(handle, -1, |state| {
+ state
+ .ui
+ .animate(id, prop as u8, to, duration_ms, easing as u8, delay_ms)
+ })
+}
+
+#[unsafe(no_mangle)]
+pub extern "C" fn pocket_apple_core_cancel_anim(handle: *mut PocketAppleCore, anim_id: i32) {
+ with_core(handle, (), |state| state.ui.cancel_anim(anim_id));
+}
+
+#[unsafe(no_mangle)]
+pub extern "C" fn pocket_apple_core_set_focus(handle: *mut PocketAppleCore, id: i32) {
+ with_core(handle, (), |state| state.ui.set_focus(id));
+}
+
+#[unsafe(no_mangle)]
+pub extern "C" fn pocket_apple_core_set_active(handle: *mut PocketAppleCore, id: i32, active: i32) {
+ with_core(handle, (), |state| state.ui.set_active(id, active != 0));
+}
+
+#[unsafe(no_mangle)]
+pub extern "C" fn pocket_apple_core_hit_test_bounds(
+ handle: *mut PocketAppleCore,
+ x: f32,
+ y: f32,
+) -> i32 {
+ with_core(handle, 0, |state| state.ui.hit_test_bounds(x, y))
+}
+
+// ---- texture / sprite tables (published as ui.__textures / __sprites) ----
+
+#[unsafe(no_mangle)]
+pub extern "C" fn pocket_apple_core_texture_count(handle: *mut PocketAppleCore) -> u32 {
+ with_core(handle, 0, |state| state.textures.len() as u32)
+}
+
+#[unsafe(no_mangle)]
+pub extern "C" fn pocket_apple_core_texture_name(
+ handle: *mut PocketAppleCore,
+ index: u32,
+) -> *const c_char {
+ with_core(handle, std::ptr::null(), |state| {
+ state
+ .textures
+ .get(index as usize)
+ .map(|(name, _)| name.as_ptr())
+ .unwrap_or(std::ptr::null())
+ })
+}
+
+#[unsafe(no_mangle)]
+pub extern "C" fn pocket_apple_core_texture_handle(
+ handle: *mut PocketAppleCore,
+ index: u32,
+) -> i32 {
+ with_core(handle, -1, |state| {
+ state
+ .textures
+ .get(index as usize)
+ .map(|(_, texture)| *texture)
+ .unwrap_or(-1)
+ })
+}
+
+#[unsafe(no_mangle)]
+pub extern "C" fn pocket_apple_core_sprite_count(handle: *mut PocketAppleCore) -> u32 {
+ with_core(handle, 0, |state| state.sprites.len() as u32)
+}
+
+#[unsafe(no_mangle)]
+pub extern "C" fn pocket_apple_core_sprite_name(
+ handle: *mut PocketAppleCore,
+ index: u32,
+) -> *const c_char {
+ with_core(handle, std::ptr::null(), |state| {
+ state
+ .sprites
+ .get(index as usize)
+ .map(|sprite| sprite.name.as_ptr())
+ .unwrap_or(std::ptr::null())
+ })
+}
+
+/// Packs handle plus atlas geometry: [handle, frames, cols, step].
+#[unsafe(no_mangle)]
+pub extern "C" fn pocket_apple_core_sprite_info(
+ handle: *mut PocketAppleCore,
+ index: u32,
+ out: *mut i32,
+) -> i32 {
+ with_core(handle, ERR_BAD_ARGUMENT, |state| {
+ if out.is_null() {
+ return ERR_BAD_ARGUMENT;
+ }
+ let Some(sprite) = state.sprites.get(index as usize) else {
+ return ERR_BAD_ARGUMENT;
+ };
+ let slots = unsafe { slice::from_raw_parts_mut(out, 4) };
+ slots[0] = sprite.handle;
+ slots[1] = sprite.frames as i32;
+ slots[2] = sprite.cols as i32;
+ slots[3] = sprite.step as i32;
+ OK
+ })
+}
+
+// ---- svc channel ----------------------------------------------------------
+
+#[unsafe(no_mangle)]
+pub extern "C" fn pocket_apple_core_svc_send(
+ handle: *mut PocketAppleCore,
+ text: *const u8,
+ length: usize,
+) {
+ with_core(handle, (), |state| {
+ if let Some(line) = str_arg(text, length) {
+ state.svc_out.push_back(line.to_string());
+ }
+ });
+}
+
+/// Newline-joined batch of queued host lines, or NULL when empty. The
+/// returned pointer stays valid until the next poll on the same handle.
+#[unsafe(no_mangle)]
+pub extern "C" fn pocket_apple_core_svc_poll(handle: *mut PocketAppleCore) -> *const c_char {
+ with_core(handle, std::ptr::null(), |state| {
+ if state.svc_in.is_empty() {
+ return std::ptr::null();
+ }
+ let mut batch = String::new();
+ for line in state.svc_in.drain(..) {
+ batch.push_str(&line);
+ batch.push('\n');
+ }
+ state.svc_poll_batch = CString::new(batch).unwrap_or_default();
+ state.svc_poll_batch.as_ptr()
+ })
+}
+
+#[unsafe(no_mangle)]
+pub extern "C" fn pocket_apple_core_post_event(
+ handle: *mut PocketAppleCore,
+ line: *const c_char,
+) -> i32 {
+ with_core(handle, ERR_PANIC, |state| {
+ if line.is_null() {
+ return ERR_BAD_ARGUMENT;
+ }
+ match unsafe { std::ffi::CStr::from_ptr(line) }.to_str() {
+ Ok(text) => {
+ state.svc_in.push_back(text.to_string());
+ OK
+ }
+ Err(_) => ERR_BAD_ARGUMENT,
+ }
+ })
+}
+
+/// Drains guest svcSend lines into `callback` (guest -> host effects).
+#[unsafe(no_mangle)]
+pub extern "C" fn pocket_apple_core_drain_effects(
+ handle: *mut PocketAppleCore,
+ callback: Option,
+ context: *mut std::ffi::c_void,
+) {
+ with_core(handle, (), |state| {
+ let Some(callback) = callback else { return };
+ while let Some(line) = state.svc_out.pop_front() {
+ if let Ok(line) = CString::new(line) {
+ callback(line.as_ptr(), context);
+ }
+ }
+ });
+}
+
+// ---- frame ----------------------------------------------------------------
+
+#[unsafe(no_mangle)]
+pub extern "C" fn pocket_apple_core_tick(handle: *mut PocketAppleCore) {
+ with_core(handle, (), |state| state.ui.tick());
+}
+
+#[unsafe(no_mangle)]
+pub extern "C" fn pocket_apple_core_render(
+ handle: *mut PocketAppleCore,
+ out: *mut PocketAppleFrame,
+) -> i32 {
+ with_core(handle, ERR_PANIC, |state| {
+ if out.is_null() {
+ return ERR_BAD_ARGUMENT;
+ }
+ let words = state.ui.draw().words.clone();
+ let plan = match raster::render_scaled_argb_incremental(
+ &state.ui,
+ &words,
+ &mut state.framebuffer,
+ state.density,
+ &mut state.tracker,
+ DamagePolicy::default(),
+ ) {
+ Ok(plan) => plan,
+ Err(_) => {
+ raster::render_scaled_argb(&state.ui, &words, &mut state.framebuffer, state.density);
+ state.tracker.invalidate();
+ pocketjs_core::damage::DamagePlan::full(pocketjs_core::damage::DamageRect::new(
+ 0,
+ 0,
+ state.logical_width as i32,
+ state.logical_height as i32,
+ ))
+ }
+ };
+
+ let width_px = state.logical_width * state.density;
+ let frame = unsafe { &mut *out };
+ frame.pixels = state.framebuffer.as_ptr();
+ frame.width_px = width_px;
+ frame.height_px = state.logical_height * state.density;
+ frame.stride_bytes = width_px * 4;
+ frame.full_redraw = i32::from(plan.is_full_redraw());
+ frame.region_count = plan.region_count().min(POCKET_APPLE_MAX_DAMAGE_REGIONS) as u32;
+ frame.regions = [[0; 4]; POCKET_APPLE_MAX_DAMAGE_REGIONS];
+ for (slot, rect) in frame.regions.iter_mut().zip(plan.regions()) {
+ let scale = state.density as i32;
+ *slot = [
+ rect.x0.max(0) * scale,
+ rect.y0.max(0) * scale,
+ (rect.x1 - rect.x0).max(0) * scale,
+ (rect.y1 - rect.y0).max(0) * scale,
+ ];
+ }
+ OK
+ })
+}
+
+#[unsafe(no_mangle)]
+pub extern "C" fn pocket_apple_core_destroy(handle: *mut PocketAppleCore) {
+ if handle.is_null() {
+ return;
+ }
+ let _ = catch_unwind(AssertUnwindSafe(|| unsafe {
+ drop(Box::from_raw(handle));
+ }));
+}
diff --git a/engine/apple/src/lib.rs b/engine/apple/src/lib.rs
new file mode 100644
index 00000000..7841c215
--- /dev/null
+++ b/engine/apple/src/lib.rs
@@ -0,0 +1,402 @@
+//! pocket-apple — the PocketJS Apple host core behind a C ABI.
+//!
+//! Composition mirrors `hosts/pocketbook`: one `pocket_mod::Guest` (QuickJS
+//! realm), one `pocket_ui_surface::UiSurface` (`globalThis.ui` + pak feeding),
+//! and `pocketjs_core::raster` driven incrementally through a `DamageTracker`.
+//! The framebuffer is ARGB32 words — BGRA byte order in memory on
+//! little-endian, i.e. `kCGBitmapByteOrder32Little | kCGImageAlphaNoneSkipFirst`
+//! for CoreGraphics without any swizzling.
+//!
+//! Threading: everything here is single-threaded by construction (`UiSurface`
+//! is `Rc>`). Create, drive, and destroy a handle from one thread —
+//! in practice the main thread, alongside CADisplayLink.
+//!
+//! Call order per handle: `create` → `load_pak`* → `eval_bundle` → per tick
+//! `frame` then `render` → `destroy`. `load_pak` and `set_identity` are
+//! rejected after `eval_bundle` because the surface publishes both to the
+//! guest at mount time.
+
+use std::cell::RefCell;
+use std::ffi::{c_char, CString};
+use std::panic::{catch_unwind, AssertUnwindSafe};
+use std::slice;
+
+pub mod core_host;
+
+use pocket_mod::Guest;
+use pocket_ui_surface::UiSurface;
+use pocketjs_core::damage::{DamagePolicy, DamageTracker, DEFAULT_DAMAGE_REGIONS};
+use pocketjs_core::raster;
+use pocketjs_core::spec;
+
+pub const POCKET_APPLE_ABI_VERSION: u32 = 1;
+pub const POCKET_APPLE_MAX_DAMAGE_REGIONS: usize = DEFAULT_DAMAGE_REGIONS;
+
+const OK: i32 = 0;
+const ERR_BAD_ARGUMENT: i32 = -1;
+const ERR_BAD_STATE: i32 = -2;
+const ERR_GUEST: i32 = -3;
+const ERR_PANIC: i32 = -4;
+
+thread_local! {
+ static LAST_ERROR: RefCell = RefCell::new(CString::new("").unwrap());
+}
+
+pub(crate) fn set_last_error(message: impl AsRef) {
+ let sanitized = message.as_ref().replace('\0', " ");
+ LAST_ERROR.with(|slot| {
+ *slot.borrow_mut() = CString::new(sanitized).unwrap_or_default();
+ });
+}
+
+pub type PocketAppleEffectCallback =
+ extern "C" fn(line: *const c_char, context: *mut std::ffi::c_void);
+
+pub struct PocketApple {
+ guest: Guest,
+ surface: UiSurface,
+ framebuffer: Vec,
+ tracker: DamageTracker,
+ density: u32,
+ logical_width: u32,
+ logical_height: u32,
+ mounted: bool,
+ effect_callback: Option<(PocketAppleEffectCallback, *mut std::ffi::c_void)>,
+}
+
+/// One rendered frame. `pixels` stays valid until the next `render`, a
+/// `destroy`, or any other call that mutates the handle.
+#[repr(C)]
+pub struct PocketAppleFrame {
+ pub pixels: *const u8,
+ pub width_px: u32,
+ pub height_px: u32,
+ pub stride_bytes: u32,
+ /// Repaint rects in pixel coordinates as x, y, w, h. `region_count == 0`
+ /// means nothing changed this frame; the previous contents are current.
+ pub regions: [[i32; 4]; POCKET_APPLE_MAX_DAMAGE_REGIONS],
+ pub region_count: u32,
+ pub full_redraw: i32,
+}
+
+fn with_handle(
+ handle: *mut PocketApple,
+ default: R,
+ f: impl FnOnce(&mut PocketApple) -> R,
+) -> R {
+ if handle.is_null() {
+ set_last_error("null handle");
+ return default;
+ }
+ let state = unsafe { &mut *handle };
+ match catch_unwind(AssertUnwindSafe(|| f(state))) {
+ Ok(value) => value,
+ Err(_) => {
+ set_last_error("panic inside pocket-apple");
+ default
+ }
+ }
+}
+
+#[unsafe(no_mangle)]
+pub extern "C" fn pocket_apple_abi_version() -> u32 {
+ POCKET_APPLE_ABI_VERSION
+}
+
+#[unsafe(no_mangle)]
+pub extern "C" fn pocket_apple_last_error() -> *const c_char {
+ LAST_ERROR.with(|slot| slot.borrow().as_ptr())
+}
+
+#[unsafe(no_mangle)]
+pub extern "C" fn pocket_apple_create(
+ density: u32,
+ logical_width: u32,
+ logical_height: u32,
+) -> *mut PocketApple {
+ let result = catch_unwind(|| {
+ if density == 0
+ || density > raster::MAX_RENDER_SCALE
+ || logical_width == 0
+ || logical_height == 0
+ {
+ set_last_error("invalid density or viewport");
+ return std::ptr::null_mut();
+ }
+ let guest = match Guest::new() {
+ Ok(guest) => guest,
+ Err(error) => {
+ set_last_error(format!("guest create failed: {error}"));
+ return std::ptr::null_mut();
+ }
+ };
+ let surface = UiSurface::new_with_density(
+ (logical_width as f32, logical_height as f32),
+ density,
+ );
+ let pixel_len =
+ (logical_width * density) as usize * (logical_height * density) as usize * 4;
+ Box::into_raw(Box::new(PocketApple {
+ guest,
+ surface,
+ framebuffer: vec![0; pixel_len],
+ tracker: DamageTracker::default(),
+ density,
+ logical_width,
+ logical_height,
+ mounted: false,
+ effect_callback: None,
+ }))
+ });
+ result.unwrap_or(std::ptr::null_mut())
+}
+
+#[unsafe(no_mangle)]
+pub extern "C" fn pocket_apple_set_identity(
+ handle: *mut PocketApple,
+ host_id: *const c_char,
+ host_abi: u32,
+) -> i32 {
+ with_handle(handle, ERR_PANIC, |state| {
+ if state.mounted {
+ set_last_error("identity must be set before eval_bundle");
+ return ERR_BAD_STATE;
+ }
+ if host_id.is_null() {
+ return ERR_BAD_ARGUMENT;
+ }
+ let id = unsafe { std::ffi::CStr::from_ptr(host_id) };
+ match id.to_str() {
+ Ok(id) => {
+ state.surface.set_identity(id, host_abi);
+ OK
+ }
+ Err(_) => ERR_BAD_ARGUMENT,
+ }
+ })
+}
+
+#[unsafe(no_mangle)]
+pub extern "C" fn pocket_apple_load_pak(
+ handle: *mut PocketApple,
+ bytes: *const u8,
+ length: usize,
+) -> i32 {
+ with_handle(handle, ERR_PANIC, |state| {
+ if state.mounted {
+ set_last_error("pak must be fed before eval_bundle");
+ return ERR_BAD_STATE;
+ }
+ if bytes.is_null() || length == 0 {
+ return ERR_BAD_ARGUMENT;
+ }
+ let pak = unsafe { slice::from_raw_parts(bytes, length) };
+ state.surface.feed_pak(pak);
+ OK
+ })
+}
+
+#[unsafe(no_mangle)]
+pub extern "C" fn pocket_apple_eval_bundle(
+ handle: *mut PocketApple,
+ source: *const u8,
+ length: usize,
+ label: *const c_char,
+) -> i32 {
+ with_handle(handle, ERR_PANIC, |state| {
+ if source.is_null() || length == 0 {
+ return ERR_BAD_ARGUMENT;
+ }
+ let bytes = unsafe { slice::from_raw_parts(source, length) };
+ let bundle = match std::str::from_utf8(bytes) {
+ Ok(text) => text,
+ Err(_) => {
+ set_last_error("bundle is not UTF-8");
+ return ERR_BAD_ARGUMENT;
+ }
+ };
+ let label = if label.is_null() {
+ "app"
+ } else {
+ unsafe { std::ffi::CStr::from_ptr(label) }
+ .to_str()
+ .unwrap_or("app")
+ };
+ if !state.mounted {
+ if let Err(error) = state.surface.mount(&state.guest) {
+ set_last_error(format!("ui mount failed: {error}"));
+ return ERR_GUEST;
+ }
+ state.mounted = true;
+ }
+ if let Err(error) = state.guest.eval(label, bundle) {
+ set_last_error(format!("bundle eval failed: {error}"));
+ return ERR_GUEST;
+ }
+ if !state.guest.has_frame() {
+ set_last_error("bundle installed no frame() — is this a PocketJS app?");
+ return ERR_GUEST;
+ }
+ OK
+ })
+}
+
+/// `touches`: up to 8 packed words, `(id & 0xff) << 18 | (y & 0x1ff) << 9 |
+/// (x & 0x1ff)` in logical coordinates. Pass `analog = 0x8080` when centered.
+#[unsafe(no_mangle)]
+pub extern "C" fn pocket_apple_frame(
+ handle: *mut PocketApple,
+ buttons: u32,
+ analog: u32,
+ touches: *const u32,
+ touch_count: usize,
+) -> i32 {
+ with_handle(handle, ERR_PANIC, |state| {
+ if !state.mounted {
+ set_last_error("frame before eval_bundle");
+ return ERR_BAD_STATE;
+ }
+ let touch_words: &[u32] = if touches.is_null() || touch_count == 0 {
+ &[]
+ } else {
+ unsafe { slice::from_raw_parts(touches, touch_count.min(8)) }
+ };
+ let analog = if analog == 0 { spec::ANALOG_CENTER } else { analog };
+ if let Err(error) = state.guest.frame_with_touches(buttons, analog, touch_words) {
+ set_last_error(format!("guest frame failed: {error}"));
+ return ERR_GUEST;
+ }
+ state.surface.tick();
+ if let Some((callback, context)) = state.effect_callback {
+ for line in state.surface.svc_drain() {
+ if let Ok(line) = CString::new(line) {
+ callback(line.as_ptr(), context);
+ }
+ }
+ }
+ OK
+ })
+}
+
+/// Register the guest -> host effect sink. Lines are whatever the guest's
+/// effect driver `svcSend`s (JSON by convention), delivered synchronously
+/// during `pocket_apple_frame` on the calling thread. `context` must stay
+/// valid until the callback is replaced or the handle destroyed.
+#[unsafe(no_mangle)]
+pub extern "C" fn pocket_apple_set_effect_callback(
+ handle: *mut PocketApple,
+ callback: Option,
+ context: *mut std::ffi::c_void,
+) -> i32 {
+ with_handle(handle, ERR_PANIC, |state| {
+ state.effect_callback = callback.map(|cb| (cb, context));
+ OK
+ })
+}
+
+/// Queue one line for the guest's next `svcPoll` — host -> guest facts land
+/// at a frame boundary, per the "no mid-tick callbacks" law.
+#[unsafe(no_mangle)]
+pub extern "C" fn pocket_apple_post_event(
+ handle: *mut PocketApple,
+ line: *const c_char,
+) -> i32 {
+ with_handle(handle, ERR_PANIC, |state| {
+ if line.is_null() {
+ return ERR_BAD_ARGUMENT;
+ }
+ match unsafe { std::ffi::CStr::from_ptr(line) }.to_str() {
+ Ok(text) => {
+ state.surface.svc_push(text);
+ OK
+ }
+ Err(_) => ERR_BAD_ARGUMENT,
+ }
+ })
+}
+
+#[unsafe(no_mangle)]
+pub extern "C" fn pocket_apple_render(
+ handle: *mut PocketApple,
+ out: *mut PocketAppleFrame,
+) -> i32 {
+ with_handle(handle, ERR_PANIC, |state| {
+ if out.is_null() {
+ return ERR_BAD_ARGUMENT;
+ }
+ if !state.mounted {
+ set_last_error("render before eval_bundle");
+ return ERR_BAD_STATE;
+ }
+ let density = state.density;
+ let framebuffer = &mut state.framebuffer;
+ let tracker = &mut state.tracker;
+ let plan = state.surface.with_ui(|ui| {
+ let words = ui.draw().words.clone();
+ match raster::render_scaled_argb_incremental(
+ ui,
+ &words,
+ framebuffer,
+ density,
+ tracker,
+ DamagePolicy::default(),
+ ) {
+ Ok(plan) => plan,
+ Err(_) => {
+ raster::render_scaled_argb(ui, &words, framebuffer, density);
+ tracker.invalidate();
+ pocketjs_core::damage::DamagePlan::full(
+ pocketjs_core::damage::DamageRect::new(
+ 0,
+ 0,
+ state.logical_width as i32,
+ state.logical_height as i32,
+ ),
+ )
+ }
+ }
+ });
+
+ let width_px = state.logical_width * density;
+ let height_px = state.logical_height * density;
+ let frame = unsafe { &mut *out };
+ frame.pixels = state.framebuffer.as_ptr();
+ frame.width_px = width_px;
+ frame.height_px = height_px;
+ frame.stride_bytes = width_px * 4;
+ frame.full_redraw = i32::from(plan.is_full_redraw());
+ frame.region_count = plan.region_count().min(POCKET_APPLE_MAX_DAMAGE_REGIONS) as u32;
+ frame.regions = [[0; 4]; POCKET_APPLE_MAX_DAMAGE_REGIONS];
+ for (slot, rect) in frame.regions.iter_mut().zip(plan.regions()) {
+ let scale = density as i32;
+ let x = rect.x0.max(0) * scale;
+ let y = rect.y0.max(0) * scale;
+ let w = (rect.x1 - rect.x0).max(0) * scale;
+ let h = (rect.y1 - rect.y0).max(0) * scale;
+ *slot = [x, y, w, h];
+ }
+ OK
+ })
+}
+
+/// Hit test in logical coordinates. Returns the focusable node id or 0.
+#[unsafe(no_mangle)]
+pub extern "C" fn pocket_apple_hit_test_bounds(
+ handle: *mut PocketApple,
+ x: f32,
+ y: f32,
+) -> i32 {
+ with_handle(handle, 0, |state| {
+ state.surface.with_ui(|ui| ui.hit_test_bounds(x, y))
+ })
+}
+
+#[unsafe(no_mangle)]
+pub extern "C" fn pocket_apple_destroy(handle: *mut PocketApple) {
+ if handle.is_null() {
+ return;
+ }
+ let _ = catch_unwind(AssertUnwindSafe(|| unsafe {
+ drop(Box::from_raw(handle));
+ }));
+}
diff --git a/engine/crates/pocket-ui-surface/src/surface.rs b/engine/crates/pocket-ui-surface/src/surface.rs
index ce5c21ae..3c75dda3 100644
--- a/engine/crates/pocket-ui-surface/src/surface.rs
+++ b/engine/crates/pocket-ui-surface/src/surface.rs
@@ -348,6 +348,13 @@ impl UiSurface {
ui.borrow_mut().ui.hit_test(x as f32, y as f32)
});
+ // Touch-path hit authority (spec op 42): the gesture layer
+ // prefers the bounds hit over the ink-claiming hitTest above.
+ let ui = self.inner.clone();
+ op!("hitTestBounds", move |x: f64, y: f64| {
+ ui.borrow_mut().ui.hit_test_bounds(x as f32, y as f32)
+ });
+
let ui = self.inner.clone();
op!("setCursor", move |tex: i32, hot_x: f64, hot_y: f64, w: f64, h: f64| {
ui.borrow_mut().ui.set_cursor(tex, hot_x as f32, hot_y as f32, w as f32, h as f32)