diff --git a/site/assets/blog/symbian-e7-figma-components.png b/site/assets/blog/symbian-e7-figma-components.png new file mode 100644 index 00000000..04af1a78 Binary files /dev/null and b/site/assets/blog/symbian-e7-figma-components.png differ diff --git a/site/assets/blog/symbian-e7-figma-fit.png b/site/assets/blog/symbian-e7-figma-fit.png new file mode 100644 index 00000000..9f581f40 Binary files /dev/null and b/site/assets/blog/symbian-e7-figma-fit.png differ diff --git a/site/assets/blog/symbian-e7-figma-zoom.png b/site/assets/blog/symbian-e7-figma-zoom.png new file mode 100644 index 00000000..5dc124ce Binary files /dev/null and b/site/assets/blog/symbian-e7-figma-zoom.png differ diff --git a/site/assets/blog/symbian-e7-hero-landscape.png b/site/assets/blog/symbian-e7-hero-landscape.png new file mode 100644 index 00000000..0f147064 Binary files /dev/null and b/site/assets/blog/symbian-e7-hero-landscape.png differ diff --git a/site/assets/blog/symbian-e7-hero-portrait.png b/site/assets/blog/symbian-e7-hero-portrait.png new file mode 100644 index 00000000..debd450f Binary files /dev/null and b/site/assets/blog/symbian-e7-hero-portrait.png differ diff --git a/site/assets/blog/symbian-e7-launcher-portrait.png b/site/assets/blog/symbian-e7-launcher-portrait.png new file mode 100644 index 00000000..9da0648d Binary files /dev/null and b/site/assets/blog/symbian-e7-launcher-portrait.png differ diff --git a/site/assets/blog/symbian-e7-launcher.png b/site/assets/blog/symbian-e7-launcher.png new file mode 100644 index 00000000..38b799b3 Binary files /dev/null and b/site/assets/blog/symbian-e7-launcher.png differ diff --git a/site/build.ts b/site/build.ts index 19a97d7d..d1544603 100644 --- a/site/build.ts +++ b/site/build.ts @@ -514,7 +514,7 @@ type Highlight = (text: string, rawLang: string) => string; async function setupMarkdown(): Promise { const highlighter = await createHighlighter({ themes: ["one-dark-pro"], - langs: ["tsx", "typescript", "jsx", "javascript", "json", "bash", "rust", "toml", "html", "css", "diff"], + langs: ["tsx", "typescript", "jsx", "javascript", "json", "bash", "rust", "toml", "html", "css", "diff", "c", "cpp"], }); const LANG_ALIAS: Record = { ts: "typescript", js: "javascript", sh: "bash", shell: "bash", console: "bash", jsonc: "json", rs: "rust", text: "text", txt: "text" }; const loaded = new Set(highlighter.getLoadedLanguages()); diff --git a/site/content/blog/pocketjs-on-symbian.md b/site/content/blog/pocketjs-on-symbian.md new file mode 100644 index 00000000..1d6ec67e --- /dev/null +++ b/site/content/blog/pocketjs-on-symbian.md @@ -0,0 +1,644 @@ +Symbian has no threads to wait in. Its concurrency primitive is the **active object**: a class with one method, one word of shared status, and a cooperative scheduler that freezes the entire phone if your callback takes too long. + +A UI runtime whose whole design is one frame function that must return turns out to be exactly the shape that operating system wants — which is why [PocketJS](/blog/introducing-pocketjs/) now runs on a 2011 Nokia E7, natively, as installed Symbian applications with their own UIDs, drawn by the phone's GPU, and why that took two days rather than two months. + +The PocketJS Hero demo at 640 by 360 on the Nokia E7: a PocketJS header with FPS, NODES and DRAWS counters, the headline 'JSX at 60 FPS.', a gradient underline, body text reading 'Flexbox, springs and baked type — running on a 2005 handheld', a blue 'Press Circle' button, and 'Count: 6' + +

The Hero demo at the E7's native 640×360. This frame — and every application screenshot in this post — is the real Symbian guest bundle, resolved against the E7 build profile, booted at the E7's exact viewport, and rasterized by the same wasm core our byte-exact pixel goldens run on. Photographs of a 4-inch AMOLED would tell you less.

+ +If you have not met PocketJS: it runs real [Solid](https://www.solidjs.com/) and Vue Vapor components — JSX, reactivity, flexbox, Tailwind classes, springs — on hardware with no browser and no JIT, starting with a 2004 Sony PSP at 333 MHz. The runtime is a Rust core plus [QuickJS](https://bellard.org/quickjs/), and applications are ordinary TypeScript. + +Symbian is the newest machine family it targets, and the first that is a *phone* — the first with an operating system that owns the screen, the keys, and the scheduler, and expects to be asked. This post is the field guide: what Symbian's programming model actually is, in full (it is genuinely unlike anything you use today), what a 2011 toolchain looks like when you pin it in 2026, and — the part we actually care about — **which parts of a UI runtime have to change when the target changes, and which parts must not.** + +The whole port is six merged PRs across three repositories. + +## The machine + + + MACHINE + CPU + RAM + SCREEN + GPU + + Sony PSP-1000 · 2004 + 333 MHz MIPS, 1 core + 32 MB + 480×272 + fixed-function GE + Sony PS Vita · 2011 + 4× ARM Cortex-A9 + 512 MB + 960×544 + GXM (vita2d) + + Nokia E7-00 · 2011 + 680 MHz ARM11 + 256 MB + 640×360 ⇄ 360×640 + OpenGL ES 2.0 + + The E7 has ~8× the PSP's memory and ~2× its clock — and 1.76× the pixels to fill, on a CPU with no NEON. + It is also the first PocketJS target whose screen rotates, the first with a keyboard, and the first + whose OS wants to be asked politely before it lets you have the framebuffer. + The exact unit: E7-00, Nokia type RM-626 · Belle Refresh, firmware 111.040.1514 · 4″ AMOLED · slide-out QWERTY + Symbian was the best-selling smartphone OS in the world as recently as 2010. Nokia announced its retirement in + February 2011 — the year this phone shipped. The E7 is a flagship from an OS that knew it was cancelled. + + +The E7 is not a weak machine by PocketJS standards. On paper it should be the *easiest* target we have taken on: eight times the PSP's memory, twice its clock, a programmable GPU, and a real operating system with a filesystem and a process model. + +What makes it hard is that everything about how you talk to that operating system is unfamiliar. So let us start there, because Symbian's model is genuinely interesting, and it explains three later decisions in this port. + +## Interlude: how Symbian programs actually work + +Symbian was designed in the 1990s for devices with a few megabytes of RAM, no MMU-backed virtual memory to spare, and a battery that had to last a week. Every major design decision follows from those constraints, and the result is an idiom that looks alien if you grew up on POSIX threads or an event loop with callbacks. + +**Threads exist, but they are not how you wait.** A Symbian thread has a stack you must size up front, and context switching costs power you do not have. So the primitive you actually reach for is not a thread — it is the **active object** from the first paragraph. Here it is properly. + +An active object is a C++ class deriving from `CActive`. It owns a `TRequestStatus` (a single word of shared memory), and it implements one method: `RunL()`. You start an asynchronous operation by handing your `TRequestStatus` to a *service* — a timer, a socket, the file server, the window server — and then calling `SetActive()`. The service is usually a separate server process, or the kernel. When it finishes, it writes a completion code into your `TRequestStatus` and signals your thread. + +Meanwhile your thread is inside `CActiveScheduler::Start()`, which is a loop around `User::WaitForAnyRequest()` — a kernel call that blocks until *any* of that thread's outstanding requests completes. When one does, the scheduler walks its list of active objects, finds the highest-priority one whose status is no longer pending, and calls its `RunL()`. + +That is abstract until you see it. Here is a complete, ordinary Symbian asynchronous operation — a cursor that blinks every 500 ms: + +```cpp +class CBlinker : public CActive { +public: + static CBlinker* NewL(); + ~CBlinker(); + void Blink(TTimeIntervalMicroSeconds32 aDelay); +private: + CBlinker(); + void ConstructL(); + void RunL(); // the completion callback — the whole point + void DoCancel(); // you MUST be able to retract the request + RTimer iTimer; // an R class: a handle to a kernel object +}; + +CBlinker* CBlinker::NewL() { + CBlinker* self = new (ELeave) CBlinker(); // cannot leave + CleanupStack::PushL(self); // …so this is safe + self->ConstructL(); // this one CAN leave + CleanupStack::Pop(self); + return self; +} + +CBlinker::CBlinker() : CActive(EPriorityStandard) { + CActiveScheduler::Add(this); // join this thread's scheduler +} + +void CBlinker::ConstructL() { + User::LeaveIfError(iTimer.CreateLocal()); // no exceptions: leave +} + +void CBlinker::Blink(TTimeIntervalMicroSeconds32 aDelay) { + Cancel(); // at most ONE request outstanding + iTimer.After(iStatus, aDelay); // hand the kernel our status word + SetActive(); // "scheduler: watch iStatus" +} + +void CBlinker::RunL() { // runs when iStatus completes + if (iStatus != KErrNone) return; + ToggleTheCursor(); + Blink(TTimeIntervalMicroSeconds32(500000)); // re-arm; there is no loop +} + +void CBlinker::DoCancel() { + iTimer.Cancel(); // completes iStatus with KErrCancel +} + +CBlinker::~CBlinker() { + Cancel(); // CActive::Cancel() → DoCancel() + iTimer.Close(); +} +``` + +Forty lines. Here is the same program today: + +```ts +async function blink(signal: AbortSignal) { + while (!signal.aborted) { + await sleep(500, signal); + toggleTheCursor(); + } +} +``` + +These are not different approaches. They are **the same state machine**, and the second one is the first one with a compiler doing the transcription: + +| Symbian, by hand | Modern, generated | +| --- | --- | +| `iStatus` — one word the service writes into | the promise's resolution slot | +| `SetActive()` | reaching an `await` and registering the continuation | +| `RunL()` | the code *after* the `await` | +| `CActiveScheduler` | the event loop | +| `EPriorityStandard` | nothing — microtasks have no priorities | +| `DoCancel()`, mandatory | `AbortSignal`, optional and frequently forgotten | +| `RunError()` | the `catch` block | +| `NewL()` + `CleanupStack` | nothing — you have a garbage collector | +| the `L` suffix | nothing — every function may throw | + +`await` did not invent anything here. It *automated* this, and the automation is why a modern async function can be read top to bottom while `CBlinker` has to be read by chasing four methods that never call each other. + +What Symbian bought with that cost is worth naming, because it is the reason the model survived on 16 MB phones: every state machine is a named object with a known size, allocated when you say so; the wait is one kernel call for the entire thread, not one per task; there is no hidden queue growing behind your back; and priorities are explicit, so a redraw can outrank a network reply. It is `async/await` with the allocations made visible and the scheduler made yours. + +And it comes with one non-negotiable rule, which is the reason any of this matters to a UI runtime: **`RunL()` runs to completion and nothing preempts it.** No other thread is keeping the phone's UI alive while you are inside it. + + + + one thread + no second stack, no mutex, no preemption + + CActiveScheduler::Start() + loop { WaitForAnyRequest(); dispatch RunL(); } + + CTimer + iStatus ● + + RSocket::Read + iStatus ○ + + RWsSession + iStatus ○ + ● complete → RunL() runs to completion, then the loop + waits again. Nothing preempts it. A slow RunL() is + a frozen UI — this is the whole contract. + + + + server processes + kernel + the async half lives outside your process + + Window Server — input, redraw, rotation + + File Server, Socket Server, Font Server… + + kernel timers, DMA, power management + Each completion writes one word into your + TRequestStatus and signals the thread. + + …and Qt 4.7 for Symbian is a shim over exactly this + QEventDispatcherSymbian owns a CActiveScheduler. QTimer is implemented by a CActive subclass. + PocketJS's host is a QGLWidget with a 30 Hz timer — its timerEvent() is an active object's RunL(). + We never write CActive by hand, but we inherit its contract: the frame function may not block, ever, + because there is no other thread to keep the phone's UI alive while it runs. + + +The rest of the idiom follows from the same austerity: + +- **No C++ exceptions.** Symbian predates usable exception support on ARM, so it invented its own: `User::Leave()` unwinds to the nearest `TRAP` macro. Any function that can leave is named with a trailing `L` — `ConstructL()`, `NewL()`, `RunL()`. The `L` is part of the type system, enforced by convention and code review. +- **A cleanup stack.** Because a leave is a `longjmp`-shaped unwind, destructors of stack objects do not run for heap pointers you are holding. So you push them onto a `CleanupStack` and pop them on success. This is why every Symbian class is constructed in *two phases*: `new (ELeave) CFoo` first (which cannot leave), then `ConstructL()` (which can), with the cleanup stack covering the gap. +- **Hungarian-flavored type prefixes that mean something.** `T` types are plain values with no destructor, `C` types are heap classes derived from `CBase`, `R` types are handles to server-owned resources, `M` types are pure interfaces. You can read a Symbian header and know each object's ownership and lifetime from its name. +- **Descriptors instead of strings.** `TDesC`/`TBuf`/`HBufC` carry their length and their maximum length; there is no NUL terminator and no unbounded copy. +- **Capabilities in the binary.** An executable's E32 header carries a capability set (`NetworkServices`, `ReadUserData`, `AllFiles`…). The loader enforces them, and anything above the lowest tier needs a signature from Symbian Signed. Our runtime declares `CAPABILITY NONE` so a self-signed development certificate is enough to install it. +- **UIDs, not bundle identifiers.** Every application has a 32-bit UID3 that the installer, the app menu, and the private data directory all key on. Two apps with the same UID are the *same app*; installing one replaces the other. + +That last bullet turns into a real architectural requirement later, so hold onto it. + +The relevant conclusion for a UI runtime: **Symbian gives you exactly one thread to be interactive in, and hands you the display through a server you must not stall.** + +PocketJS never writes `CActive` by hand — Qt already did, and our host is an ordinary `QGLWidget` with a 30 Hz timer. But look at what that timer callback actually is, once you know the machinery underneath it: + +```cpp +void PocketJsRuntime::timerEvent(QTimerEvent *event) // ← a CActive::RunL() +{ + if (event->timerId() == timer_.timerId()) { + if (failed_) return; + queueViewport(size()); // Qt delivered a rotation? take it + // …context checks… + if (!applyPendingViewport()) { recoverGuestFailure(currentApp_); return; } + runFrame(); // guest → core ticks → GLES2 present + if (failed_) recoverGuestFailure(currentApp_); + return; + } + QGLWidget::timerEvent(event); +} +``` + +`QTimer` is implemented by a `CActive` subclass, so `timerEvent()` is dispatched by the same `CActiveScheduler` loop as `CBlinker::RunL()` above, under the same rule: return, or the phone stops responding. A runtime whose entire contract is already *one frame function, called once, that must return* did not need to be taught that. It is the first reason this port took days rather than months. + +## What "porting PocketJS" actually means + +PocketJS applications do not draw pixels. A Solid or Vue Vapor component tree mutates a *native* tree through a small set of host operations (`createNode`, `setStyle`, `setText`, …). The Rust core, `pocketjs-core`, owns that retained tree: layout, styles, text measurement, animation, focus, hit testing. Once per frame it emits a **DrawList** — a flat, deterministic buffer of drawing commands. Something host-specific submits that DrawList to the machine. + +That last sentence is the entire port surface. + + + + app.tsx → app.js + app.pak + Solid / Vue Vapor — identical bytes on every target + styles, fonts and images are baked into the .pak + + QuickJS + HostOps + ~30 calls: createNode · setStyle · setText · animate … + + pocketjs-core — layout, text, animation + no_std Rust · the same crate on every target + + DrawList — the portable seam + + + PSP · sceGu + 480×272, fixed function + 2 MB VRAM + + Vita · GXM + 960×544, density 2 + + ESP32-P4 · PPA + RGB565 strips + + Symbian · GLES2 + 640×360 ⇄ 360×640 + new in this port + + the port added + a Qt/QuickJS host · an ARMv6 no_std build + a GLES2 backend · a live viewport + physical-key input · SIS packaging, per-app UIDs + a native extension ABI for 3D apps + + the port did not change + the DrawList format · the core's semantics + one line of framework code + the production target registry + 49 pixel goldens · 180 frame hashes + + +That last line is the honest measure of whether an architecture scales to a new machine. If adding Symbian had needed one pixel of change in the PSP's goldens, the seam would have been in the wrong place. + +## Part 1: the toolchain is archaeology + +Before any of that could run, we needed a compiler. This turned out to be the least glamorous and most fragile part of the whole project, and it is worth describing because "get a 2011 toolchain to behave like a 2026 one" is a genuinely recurring problem. + +The inputs are historical artifacts: + +- **GCCE 4.6.3** — the GCC targeting Symbian's ARM EABI. Shipped as a 32-bit **i686 Linux** binary. +- **Belle SDK (`SymbianSR1Qt474`)** — headers, import libraries, and the E32 tools. +- **Qt 4.7.4 source**, used to build a native `qmake` — the phone's ROM has Qt 4.8, which happily runs an application built against 4.7.4. +- **GnuPoc** — [mstorsjo's](https://github.com/mstorsjo/gnupoc-package) native reimplementations of `elf2e32`, `petran`, and the SIS packaging and signing tools, so we don't need Windows. +- **QuickJS**, pinned to a revision with a Symbian/GCCE patch. + +The 32-bit-Intel-binary detail decides the architecture: the build runs in a `linux/amd64` container with i386 multiarch enabled. On an Apple Silicon Mac that means the toolchain executes under two layers of emulation, which is fine, because it is a build, not a game. + +Then repeatability. Pinning the *downloads* by SHA-256 is easy. The trap is that a Dockerfile which runs `apt-get install` is not pinned at all — rebuild it in six months and you get different native tools under the same image tag, still labelled "reproducible". So the container installs from exact [Debian snapshot](https://snapshot.debian.org/) timestamps, and the resolved package set is itself hashed into the toolchain's implementation digest. + + + + pinned inputs + Belle SDK · Qt 4.7.4 + GCCE 4.6.3 · i686 + Qt 4.7.4 → qmake + GnuPoc E32 / SIS + QuickJS @ 0fc946fb + all SHA-256 verified + + + + linux/amd64 container · no network + Debian snapshot imports · i386 multiarch + 1 · two-pass app build → app.js + app.pak + 2 · cargo build-std → armv6-symbian-eabi .a + 3 · GCCE 4.6.3, C++98 → Qt host objects + 4 · link host + core + QuickJS (--no-undefined) + 5 · elf2e32 → E32 image, UID3, CAPABILITY NONE + 6 · makesis + signsis → one installable .sis + repo mounted read-only · one writable output dir + signing key in its own volume, never rotated + + + + outputs + app.sis — installable + app.receipt.json — + hashes of JS, pak, plan, + core, data, QuickJS rev + + to the phone + USB MTP upload, then + read the object back and + compare SHA-256 before + claiming delivery + + and one small piece of luck: CODA + Qt SDK 1.2.1 shipped an on-device agent speaking TCF over USB. We reimplemented enough of Qt Creator's + transport to run `coda usb launch App.exe` from a Mac — no driver, no IP. Reply: “CODA process: p2399”. + + +Two things about that diagram are worth calling out for anyone doing similar work. + +**The signing identity is infrastructure, not a build artifact.** Symbian will only upgrade an installed package if the new one is signed by the same certificate and carries a higher version. A `docker volume prune` that takes out your development key means every installed app is permanently stuck — you cannot upgrade them, only uninstall. So the key lives in its own named volume, the setup step refuses to rotate a valid identity, and `doctor` prints its fingerprint. + +**Byte delivery deserves a proof.** MTP over USB to a phone is not a reliable channel in the "either it worked or it errored" sense. The deploy command uploads once, reads the object back by the ID the phone returned, and compares SHA-256 — and then explicitly *does not* claim the app is installed, because on Symbian installation is a human tapping through a self-signed-certificate warning. + +The one genuinely funny obstacle: CODA's original SIS is signed by a Nokia certificate that expired on 2016-01-02. The documented workaround is to disconnect the phone from the network, turn off automatic time, set the date to 2015, install, and set the clock back. + +## Part 2: Rust, no_std, on a phone from 2011 + +The core is a `no_std` Rust crate. Getting it onto Symbian needed a custom target JSON (`armv6-symbian-eabi`), a pinned nightly, and `-Z build-std`, since no prebuilt `core` exists for a target Rust has never heard of. Two details are worth passing on: + +**The allocator has to be honest about alignment.** The core's global allocator forwards to Symbian's C `malloc`, which guarantees 8-byte alignment. Rust's `GlobalAlloc` may ask for more. The first version silently ignored that; the fixed version returns null for any alignment above 8, in both `alloc` and `realloc`, so an impossible request fails loudly instead of producing a misaligned pointer that works until it doesn't. + +**No weak symbols.** Applications that need their own native code (we will get to OpenStrike) export an extension table the host looks up. The obvious C idiom is a weak symbol that defaults to null — but Symbian's E32 conversion tools cannot safely consume ELF weak relocations. So the stock core exports an explicit *null provider* and application cores disable that Cargo feature and export the real one. It is uglier than a weak symbol and it survives `elf2e32`. + +## Part 3: a screen that rotates + +Every previous PocketJS target had one resolution, forever. The PSP is 480×272. The Vita renders the same logical 480×272 world at 960×544. Both are compile-time facts. + +The E7 is 640×360 in landscape, and 360×640 the moment you slide the keyboard shut and turn it. Both are the *native* viewport — there is no letterbox, no PSP-shaped island in the middle of the screen. + +The first plan was to pick one orientation and pillarbox the other, and it did not survive being said out loud: writing a resolution into an application is exactly the class of thing this runtime exists to make impossible. So the E7 got the real version. + +
+ Hero demo in landscape at 640 by 360: a single row with the headline, body copy, and the Press Circle button beside the counter + The same Hero demo in portrait at 360 by 640: the header statistics wrap under the logo, the body copy breaks across two lines, and the button and counter stack +
+ +

The same app, the same build, the same running instance — 640×360 and 360×640. The count is preserved across rotation because nothing remounts.

+ +The mechanism has three parts: + +1. **The manifest declares a dynamic viewport.** `pocket.json` gains a `dynamic` block — `default: [640,360]`, `min: [360,360]`, `max: [640,640]` — alongside the existing `fixed` PSP/Vita contract. An app that has not declared one is *rejected* by the E7 build, rather than silently stretched. +2. **The host resizes the core first, then tells the framework.** Qt's automatic orientation delivers a resize event; the host calls `ui_set_viewport()` and then a live-viewport hook shared by both frameworks. +3. **Solid and Vue Vapor update their roots in place.** No unmount, no remount. Application state, focus ownership, timers, and animation phase all survive rotation, because from the framework's perspective nothing happened except two numbers changing. + +That third point is where a UI runtime earns its keep. Rotation on this phone is *not* an app restart, and it is not a media query either — it is the same reactive graph observing a different viewport. + +## Part 4: many apps, one runtime + +Symbian's UID model, from the interlude, has a consequence: if every PocketJS app shipped as "the PocketJS runtime", installing the second one would replace the first. + +So the packager derives a stable private-range UID from each app's Pocket id, along with a collision-resistant executable name, its own app-menu caption, and its own SIS and receipt filenames. Pocket Figma is `0xEEB7A533`; OpenStrike is `0xE86B9226`. They are separate applications on the phone's menu, installable and removable independently, that happen to share a runtime the way two Electron apps share Chromium. + +Then there is the other direction: the **Pocket Launcher**, which packs many `.pocket` app bundles into *one* SIS and switches between them. + +The PocketJS Cover Flow launcher at 640 by 360 on the E7: a perspective carousel of app covers receding to both sides with mirrored reflections below, the centred card showing a retro Chrome-style window, and the caption 'Chrome — 3 / 18 — dev.pocket-stack.chrome' + +

The Cover Flow launcher at 640×360 — real perspective, not a scaled sprite strip. The core projects and depth-sorts the cards, then subdivides each into textured triangles; the E7 backend decodes those triangles straight into GLES2 draw calls. (The "this host cannot switch apps" hint is the wasm oracle telling on itself — the capability is a host feature, and on the phone this line reads differently.)

+ +The launcher holds exactly one live guest. Choosing a card captures a frozen shot of the outgoing frame as a texture, tears down that guest's realm and core, and cold-boots the next package behind the still image. On a machine with 256 MB and one thread, "keep them all warm" is not an option, so the switch is honest about being a switch. + +Home or Backspace summons the launcher back. The whole flow is the same Cover Flow launcher that shipped on PSP and Vita, retargeted — the app-switching machinery is host-level, so it came along with the host. + +The same launcher rotated to portrait at 360 by 640: the carousel occupies the upper half of the taller screen with the caption below + +

And in portrait, because the launcher is an app too, and the live viewport contract does not have exceptions.

+ +## Part 5: the day it ran at less than one frame per second + +Here is the part of the story where the architecture paid off, after first sending the bill. + +The first working host presented like this: the Rust core rasterized the DrawList on the CPU into an ARGB32 buffer, wrapped it in a `QImage`, and blitted it with `QPainter`. That is exactly how the PSP and the ESP32 work, minus their hardware blitters, and it is the reason the port booted so quickly — the same software rasterizer that produces our pixel goldens produced the phone's first frame. + +Then we installed Pocket Figma and the launcher on the device. My note from that session, in full, was: *figma 和 launcher 真机实测非常卡顿 < 1fps* — under one frame per second, on hardware. + +The diagnosis was not subtle: **the phone's GPU was not involved at all.** We were asking a 680 MHz in-order ARM11 with no NEON to software-rasterize 230,400 pixels — 1.76× the PSP's frame — including a perspective-warped Cover Flow and full-screen image tiles, and then to memcpy the result through Qt's painter into the window server. The PSP survives this workload because it has a GPU doing the rasterizing; here we had simply not used the one in the phone. + +So the fix was to write the fourth DrawList backend. + + + BEFORE — PR #176 + + DrawList + from the core + + + software raster → ARGB32 + 230,400 px/frame on the CPU + + + QImage → QPainter + blit to window server + + Broadcom GPU + idle — never asked + measured on the device: under 1 fps for Pocket Figma and the launcher + + AFTER — PR #183 + + DrawList + unchanged, byte-exact + + + GLES2 backend · Rust + 8 ops → VBOs + textures + + + QGLWidget · EGL + synchronous updateGL() + + + Broadcom GPU + rasterize · blend + + what the backend owns — and what it deliberately does not + Owns: vertex assembly, texture upload and format conversion (5650 / 4444 / 8888 / palettized T8 → RGBA), + scissor in GL's bottom-left space, straight-alpha blending, and a per-texture revision so an in-place + update re-uploads one texture. Does not own: geometry, clipping, layout, z-order — the core decides those. + + +Note what did *not* move. The GPU did not get to decide geometry. The core still emits the same CPU-clipped, depth-sorted DrawList it emits for the PSP's fixed-function GE, and the perspective Cover Flow still arrives as pre-subdivided textured triangles rather than as a projection matrix. That is a deliberate constraint: it is what keeps one deterministic frame definition across a fixed-function GPU from 2004, a programmable one from 2011, `wgpu` on a laptop, and a software rasterizer in a test. + +The device verdict after the GLES2 backend landed: the launcher was smooth, Pocket Figma went from unusable to acceptable. + +### The text turned black + +Then a regression appeared that is a perfect small illustration of "portable format, per-target contract". + +Text on the E7 rendered *black*, everywhere, regardless of the color the app asked for. Not missing, not garbled — black. + +The DrawList's color encoding was fine. The bug was in the new backend's glyph atlas: it uploaded font coverage as a `GL_ALPHA` texture, and the shared fragment shader does `texture2D(...) * v_color`. OpenGL ES 2.0 specifies that sampling a `GL_ALPHA` texture yields `(0, 0, 0, coverage)` — the RGB channels are *defined* to be zero. So every text color, whatever it was, got multiplied by black. + +This is not a driver quirk; it is the spec, and every other backend had quietly avoided it in a different way. The software rasterizer, the PSP, and `wgpu` use coverage only to scale alpha. The Vita uploads white RGB alongside coverage alpha. The fix was to match them at half the memory cost of RGBA: upload the atlas as `GL_LUMINANCE_ALPHA`, two bytes per texel, `[255, coverage]`. + +A "portable" intermediate format does not mean every backend can be written without reading the target's spec. It means the *contract* is written down in one place, so a backend that violates it produces a bug you can name in one sentence. + +## Part 6: Pocket Figma, and the cost of a copy + +[Pocket Figma](/blog/pocket-figma/) opens real `.fig` design files on handhelds. Its content is a pyramid of pre-baked image tiles — the same idea as a slippy map — streamed as you pan and zoom. + +Pocket Figma at 640 by 360 on the E7 showing the Welcome page of a design file fitted to the screen: a Paper Kit wireframe cover on the left and a grid of white artboards with illustrations, with a status bar reading 'Welcome — T/S page  Q/E zoom  Esc fit — 10%' + +

A real design file at 10% fit on the E7's 640×360 — and, in the status bar, the discoverability fix: T/S page, Q/E zoom, Esc fit. Keyboard-driven controls are useless if nothing on screen says they exist.

+ +Even after the GLES2 backend, Figma was the last app to feel right. The reason was on the *other* side of the JS boundary. + +The tile path went: the guest reads the pack from its `__pak` ArrayBuffer, slices out the tile it needs, and hands the bytes to `ui.uploadTexture()`. On a 6 MB pack, in a QuickJS heap living inside a Symbian process, every pan that crosses a tile boundary and every mip change means JavaScript-side slicing and copying of image data — and the copies are large enough that the process heap notices. + +So the host grew one more operation: `ui.loadTileTexture(name, index)`. The guest names a tile; the *host* looks the entry up in the pack it already owns, and hands the bytes straight to Rust for upload. No JS-side slice, no second copy, no decompression in the interpreter. + +Pocket Figma zoomed to 29 percent on the E7, showing crisp artboard content: illustrations of people, a 'Downloads' artboard with credit lines, and a small navigation menu + +

Zoomed to 29%. The tiles the viewport needs are uploaded by the host straight from the pack; the guest only ever names them.

+ +Pocket Figma showing a different page of the same file, 'Components', at 4 percent zoom: a long horizontal row of dozens of small artboards spread across the canvas + +

A second page of the same file at 4% — the `T`/`S` page switch. It was implemented before it was discoverable; the status bar came later, after the obvious feedback that nobody can press a key they don't know about.

+ +There is a small governance point hidden in `loadTileTexture`. It would have been easy to expose "read arbitrary bytes from the pack" to JavaScript. Instead the operation is narrow — name a tile, get a texture handle — because the pack bytes are host-owned and the guest gets a separate writable buffer. Script code cannot mutate storage that native code is borrowing. + +The user's verdict after the final build reached the phone: **实测流畅** — fluid in real use. + +## Part 7: OpenStrike, and how an app brings its own engine + +[OpenStrike](/blog/shipping-openstrike/) is our Counter-Strike-shaped FPS: BSP maps, bots, a Solid JSX HUD, holding 60 FPS on a real PSP. Getting it onto the E7 was the point at which "PocketJS is a 2D UI runtime" had to stop being true. + +OpenStrike running on a PSP: the sunlit dust2 courtyard with cliffs, green ammo crates, a rifle viewmodel, and a JSX HUD showing HP 100, ammo 30/90 and HOSTILES 3/3 + +

This frame is the PSP build, from OpenStrike's launch post — we do not have a device capture of the E7 running it. The E7 runs the same openstrike-core simulation and the same cooked .p3d maps, through a GLES2 renderer instead of the PSP's fixed-function GE, at 640×360 instead of 480×272. All eight maps ship in one 22.8 MB installable.

+ +An FPS cannot be expressed in host operations. It needs its own renderer, its own map loader, its own simulation running at a fixed step. But forking the Qt/QuickJS host per application would have been the end of the port as a *platform*. + +So the host grew a **versioned native extension ABI**: an application supplies a prebuilt static library implementing the ordinary `ui_*` core surface, which may *also* export a table of callbacks — `boot`, `before_guest`, `after_guest`, `resize`, `render`, `shutdown` — plus a flag requesting a depth buffer. + + + ONE HOST FRAME · 30 Hz · QTimer → timerEvent() → an active object's RunL() + + + sample input + buttons + native keys + + before_guest() + its sim steps here + + frame(buttons, touches) + one call into QuickJS + + drain jobs + promise queue + + after_guest() + drain commands + + ui_tick() × 2 + the core advances at a fixed 60 Hz + + extension render() — the 3D world + depth-tested, owns the color buffer + + ui_gl_render_over() — the HUD + composites without clearing + The JavaScript frame is consulted exactly once and can never stall the simulation — the same + one-crossing-per-frame discipline as every other target, which on this OS also keeps the phone alive. + + PocketJS owns + QuickJS, lifecycle, the GLES2 context, packaging, + physical-key sampling, the app UID and SIS, the + JSX overlay, rotation, and the receipt that pins + every byte that went into the package. + unchanged for every other app on the platform + + the application extension owns + the OpenStrike simulation, BSP map loading, and + Pocket3D's GLES2 world renderer — actors, + viewmodels, progressive textures, effects. + Maps live outside app.pak, in the app's private + mass-memory directory, hash-pinned in the receipt. + + +That last line solves a problem specific to this class of device. Eight cooked CS maps do not belong inside `app.pak`: the pack is embedded in the executable's read-only data and mirrored into the guest, so a 20 MB pack would be duplicated inside a process heap that does not have room for it. So custom cores can declare a `--mass-storage-data-root` — an ordinary directory tree, staged into `E:\private\\data\`, with every path, byte count, and SHA-256 recorded in the build receipt and re-validated inside the offline container. + +The path validation is deliberately paranoid: no symlinks, no special files, no unsafe relative paths, no case-insensitive collisions (Symbian's filesystem does not distinguish `Dust2.p3d` from `dust2.p3d`), no overlap with the build payload. A packaging step that silently follows a symlink out of the source tree is a supply-chain bug with a friendly face. + +## Part 8: the W key does not report W + +OpenStrike ran. Dust2 rendered. Looking around was smooth. And you could not walk. + +This is the part of the port that cost the most wall-clock time for the least code, and it ends on a fact about this phone that is worth carrying away: **the Nokia E7's `W` key does not report `W`. It reports scan code `2`.** + +The debugging went in three rounds, and they are worth listing because each one *looked* like a complete answer: + +**Round 1 — nothing works.** The host matched `Qt::Key_W`, which is the uppercase ASCII value `0x57`. Qt 4.7's Symbian backend forwards ordinary character keysyms straight through, so an unshifted physical W arrives as lowercase `'w'`. Special keys — Enter, Backspace, arrows — go through Qt's own translation and worked fine, which is exactly the kind of partial success that makes you trust the wrong hypothesis. Fix: normalize ASCII letters before mapping. Shipped as PR #187. + +**Round 2 — A, S and D work; W and R do not.** This is the report that makes no sense. A single normalization bug does not spare four letters and hit two. + +The answer is in the hardware. The E7's keyboard is a 4-row slide-out QWERTY with no dedicated number row: digits live on the top letter row, reached through `Fn`. And at the driver level, that top row identifies itself by the *digits*. The physical `Q` key reports native scan code `'1'`, `W` reports `'2'`, `E` reports `'3'`, `R` reports `'4'` — right across to `P` reporting `'0'`. The other letters report their own alphabetic scan codes, which is why A, S and D behaved and W and R did not. + +Above that sits the **FEP** — Symbian's Front-End Processor, the input-method layer that turns key events into characters. What Qt hands you as a "key" is the FEP's *interpretation*, which depends on the input mode, the `Fn` state, and the active editor. It is the right abstraction for a text field and the wrong one for a game controller. + + + WHAT THE E7 KEYBOARD REPORTS + + Qscan '1' + Wscan '2' + Escan '3' + Rscan '4' + T Y U I Oscan '5'…'9' + Pscan '0' + A S D F … Zscan 'A'…'Z' — as expected + + The top row has no dedicated digits: numbers are Fn-layer characters, so the matrix names those keys by digit. + + + QKeyEvent::key() — the logical key + produced by the FEP: depends on input mode, + Fn state, Shift, and the focused editor. + W could arrive as 'w', as 'W', or as '2'. + + QKeyEvent::nativeScanCode() — the switch + the physical matrix position, independent of + every input-method decision above it. + W is always scan '2' — on this device. + + the resolution order the host settled on + 1 · map the whole target-specific physical matrix from the scan code — '1'…'0' → Q…P, 'A'…'Z' → themselves + 2 · fall back to the normalized logical key only when no scan code is recognized — never reinterpret a digit + 3 · same function for press and release · stay out of Qt's text-input mode · clear held keys on focus loss + + +**Round 3 — D works, W still doesn't, and R never reloads.** The remaining piece is not a mapping problem at all: it is a *sampling* problem. The host runs its frame at 30 Hz while the window server can deliver a press and its matching release between two samples. A quick tap became a key that was never held on any frame the guest saw. The fix is a latch — a press sets both the held bitset and a "pressed this frame" bitset, and the frame function ORs them, so every pulse survives for exactly one frame. Held and latched state is cleared across focus, rotation, viewport, and guest boundaries, so a rotation can never leave a key stuck down. + +After that landed, the device acceptance pass was clean end to end: WASD movement with W and D pointing the right way, Enter to fire, R to reload, Backspace to the map menu, smooth first-person look, and all eight maps selectable and loadable. No sticky keys. + +The generalizable lesson is not "check scan codes". It is that **an input abstraction has a domain**, and text input is not the same domain as a game controller. Symbian's FEP is a good design for the thing it was designed for. The moment we wanted "which physical switch is closed", we had to step under it — and, importantly, we had to do so in a *target-specific* table, because "scan code `2` means W" is a fact about the Nokia E7's keyboard matrix and nothing else. + +## What the port cost, and what it didn't + + + THE WHOLE PORT + + + pocketjs #176toolchain · Qt/QuickJS host · orientation · CODA over USB+7,796 + pocketjs #183per-app UIDs · launcher catalog · GLES2 renderer · tile bridge+5,884 + pocketjs #185native extension ABI · Pocket3D GLES2 backend+3,203 + pocketjs #186private mass-storage data, hash-pinned in the receipt+643 + pocketjs #187normalize lowercase letter keysyms+81 + pocketjs #188resolve controls from physical scan codes · one-frame latch+179 + open-strike #14full 3D game · 8 maps · E7 controls+2,848 + pocket-figma #4/#5E7 build path · live viewport · discoverable controls+609 + + + ≈21,000 lines · three repos · ~2 days · target registry unchanged · goldens updated: 0 · framework lines: 0 + + +The shape of that table is the argument. The two PRs that fixed the most user-visible problems — text you can read, controls that work — are 81 and 179 lines. The large ones are a *toolchain* and a *backend*: things a new machine genuinely needs, in the two places the architecture reserved for machine-specific code. + +Everything else held: the DrawList did not gain a Symbian case, the framework did not gain a platform check, the target registry did not gain an entry, and the 49 pixel goldens and 180 frame hashes that pin PSP behaviour never moved. + +## The honest boundary + +Symbian is **not** a production PocketJS target. It builds through a private `symbian-e7-dev` profile that lives in the tools directory, deliberately outside the production registry, and it stays there until it earns its way out: + +- Installation, launch, rotation, GLES2 output, keyboard input, app switching, and three real applications are confirmed on **one** physical E7. That is a manual pass, not repeatable validation. There are no Symbian pixel goldens yet. +- Touch works on the device and is exercised by Pocket Figma, but stays outside the published contract during the private period. +- CODA gives us remote launch, not source-level debugging. Run control, breakpoints, memory and registers are all in the protocol; the CODA-to-GDB adapter, a Symbian GDB, and matching symbol artifacts are not built. +- Signed SIS packages carry a timestamp, so a build is repeatable but not byte-for-byte reproducible. + +None of the historical SDK inputs are redistributed. If you want to reproduce this, you supply your own copies under their original terms — the toolchain verifies their hashes and does the rest. + +## Why bother + +The E7 is not a market. Nobody is shipping a Symbian app in 2026, and this port will never have users in the ordinary sense. + +It is a *test*, and specifically the test we could not run any other way. PocketJS's claim is that a UI runtime can be structured so that supporting a new machine means writing a submission backend and a host, not forking a framework. Two Sony handhelds and an ESP32 do not prove that — they are all machines where you own the hardware outright, boot into your own code, and never negotiate with an operating system. + +Symbian negotiates. It has a window server that owns your framebuffer, an installer that owns your identity, an input-method layer between you and the keys, a capability system that owns your privileges, and a cooperative scheduler that will freeze the phone if your frame function takes too long. It is much closer to a modern OS than a PSP is — it is, in a real sense, the *oldest* device here and the most *normal* one. + +The runtime went onto it in two days, and the parts that had to change were exactly the parts we had designed to be changeable. The parts that did not change are the evidence. + +Also, a slide-out QWERTY is a genuinely great way to play Counter-Strike, and it took a physical scan code table to find that out. + +--- + +*PocketJS is open source at [pocket-stack/pocketjs](https://github.com/pocket-stack/pocketjs). The Symbian workflow is documented in [`docs/SYMBIAN_E7.md`](https://github.com/pocket-stack/pocketjs/blob/main/docs/SYMBIAN_E7.md); the port is PRs [#176](https://github.com/pocket-stack/pocketjs/pull/176), [#183](https://github.com/pocket-stack/pocketjs/pull/183), [#185](https://github.com/pocket-stack/pocketjs/pull/185), [#186](https://github.com/pocket-stack/pocketjs/pull/186), [#187](https://github.com/pocket-stack/pocketjs/pull/187) and [#188](https://github.com/pocket-stack/pocketjs/pull/188), with [open-strike#14](https://github.com/pocket-stack/open-strike/pull/14) and [pocket-figma#4](https://github.com/pocket-stack/pocket-figma/pull/4) downstream.* diff --git a/site/home.html b/site/home.html index 110597a8..09b6440c 100644 --- a/site/home.html +++ b/site/home.html @@ -44,9 +44,9 @@
- + New - Pocket Character: a desktop digital human at 4% of one core + Symbian Wanted a Frame Function: PocketJS on a Nokia E7

Bare MetalModern Web

@@ -335,6 +335,7 @@

One core, many devices

  • Sony PSP
  • PS Vita
  • macOS
  • +
  • Symbian
  • iOS
  • Nintendo 3DS
  • Steam Deck
  • diff --git a/site/nav.ts b/site/nav.ts index e6ad3c74..51fede54 100644 --- a/site/nav.ts +++ b/site/nav.ts @@ -54,6 +54,14 @@ export interface BlogPost { } export const BLOG_POSTS: BlogPost[] = [ + { + slug: "pocketjs-on-symbian", + title: "Symbian Wanted a Frame Function: PocketJS on a Nokia E7", + date: "2026-07-26", + description: + "Solid components, a Cover Flow launcher, a real Figma file and a 3D FPS, running natively on Symbian Belle. What the active-object model is, and what a new machine is allowed to change.", + author: { name: 'Yifeng "Evan" Wang', url: "https://github.com/doodlewind" }, + }, { slug: "pocket-vapor", title: "Pocket Vapor: Vue, Compiled All the Way Down",