Symbian Wanted a Frame Function: PocketJS on a Nokia E7
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.
Written by Yifeng "Evan" Wang
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 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 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 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, 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
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:
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:
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.
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 nearestTRAPmacro. Any function that can leave is named with a trailingL—ConstructL(),NewL(),RunL(). TheLis 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 aCleanupStackand pop them on success. This is why every Symbian class is constructed in two phases:new (ELeave) CFoofirst (which cannot leave), thenConstructL()(which can), with the cleanup stack covering the gap. - Hungarian-flavored type prefixes that mean something.
Ttypes are plain values with no destructor,Ctypes are heap classes derived fromCBase,Rtypes are handles to server-owned resources,Mtypes 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/HBufCcarry 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 declaresCAPABILITY NONEso 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:
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.
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 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 timestamps, and the resolved package set is itself hashed into the toolchain's implementation digest.
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.
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:
- The manifest declares a dynamic viewport.
pocket.jsongains adynamicblock —default: [640,360],min: [360,360],max: [640,640]— alongside the existingfixedPSP/Vita contract. An app that has not declared one is rejected by the E7 build, rather than silently stretched. - 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. - 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 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.

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.
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 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.

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.

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

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 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.

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.
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\<UID>\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.
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 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. The Symbian workflow is documented in docs/SYMBIAN_E7.md; the port is PRs #176, #183, #185, #186, #187 and #188, with open-strike#14 and pocket-figma#4 downstream.