← Blog

Twice the Pixels, Zero Forks: PocketJS on PS Vita

PocketJS 0.4.0's headline: the same apps, unchanged, as native PS Vita bubbles. The Vita port is now verified on real hardware: the 480×272 logical world renders at a true 960×544 through a target-owned raster density, while a pocket.json contract system makes ad hoc platform hacks fail the build. Plus front touch as deterministic input, 44 byte-exact Vita goldens, and release VPKs installed and run on a physical Vita.

Written by Yifeng "Evan" Wang

PocketJS demos and OpenStrike running natively on a real PS Vita — release VPKs, installed through VitaShell and rendered through vita2d/GXM.

The PocketJS Vita port is now verified on real PS Vita hardware. The release VPKs were installed as named LiveArea bubbles, then several framework demos and OpenStrike were launched and exercised using the production renderer. The deterministic Vita3K suite remains our repeatable pixel oracle; the real-device pass closes the deployment and on-hardware presentation gap.

The emulator development loop remains one command:

bun play vita gallery --fullscreen

That builds a native VPK, installs it into the Vita3K emulator, and launches the same Gallery demo we ship on PSP. The same build pipeline emits the release VPK installed on hardware. There is no Vita entry file, no Vita component tree, and no if (vita) anywhere in the application. If you are new here: PocketJS runs real Solid and Vue Vapor components on a 2004 Sony PSP — 333 MHz, 32 MB of RAM — at a locked 60 FPS. The Vita is the second PlayStation the runtime targets, and the first whose screen has more pixels than the apps were drawn for.

The PocketJS Gallery demo running at 960 by 544 for PS Vita: an EVERGREEN page of six procedural texture tiles named FERN, MOSS, PINE, JADE, TIDE and GROVE, with crisp labels and a controller-hint status bar

A committed 960×544 Vita golden of the Gallery demo — frame 132 of a deterministic input tape. Like every Vita screenshot in this post, it is produced by the capture build's CPU renderer inside the guest, consuming the same DrawList the production vita2d/GXM pass draws; GPU texture residency is asserted separately.

The interesting part is not that we taught a second Sony handheld to draw a rectangle. It is that the port refused to become a fork — and this post is about the two decisions that kept it one codebase. First, the 2× screen is a rendering contract, not a scale factor: the Vita renders the same 480×272 logical world at a native 960×544, so text and vectors gain real detail instead of stretched pixels. Second, platform differences live in one build-time contract — a manifest, a profile, and a resolver — so an ad hoc if (vita) has nowhere to hide. Everything here lands in PocketJS 0.4.0.

The port that refused to become a fork

PocketJS applications do not render pixels themselves. Solid or Vue Vapor updates a native tree through HostOps; pocketjs-core lays that tree out and emits a DrawList; one last host-specific layer submits those drawing commands to the machine.

app.tsx + app.pak one product bundle · Solid or Vue Vapor · one manifest QuickJS guest → HostOps buttons · analog nub · touch snapshots · baked glyphs pocketjs-core layout · clip · paint transforms · DrawList PSP host sceGu · fixed-function GE 480×272 · density 1 · HostOps ABI 1 PS Vita host vita2d · GXM · front touch 960×544 · density 2 · HostOps ABI 2 the fork is one native submission layer, not the application

On PSP that last layer translates the DrawList to sceGu, Sony's fixed-function Graphics Engine API. On Vita, a Rust host embeds QuickJS, feeds the same pak into the same core, and translates the same DrawList through vita2d to GXM. The VPK contains the native host, compiled JavaScript, styles, font atlases, and images as one installable bubble.

This boundary is the whole ballgame. A rounded corner is not a Vita feature; clipping, masks, and paint-only transforms are PocketJS semantics implemented above either GPU. The backend is allowed to be completely different — the visible contract is not.

Twice the pixels, not twice the app

The PSP screen is 480×272. The Vita screen is 960×544 — exactly twice the width and twice the height. The first cut of the port used that relationship the obvious way: draw the 480×272 frame, stretch it ×2. Layout survived perfectly. So did every jagged edge, now four times the area.

Here is that difference on real committed goldens — the same frame of the same input tape, once as the stretched density-1 frame, once as the Vita actually renders it:

Side by side comparison of the same Gallery frame: on the left the 480 by 272 frame stretched two times with visibly blocky text edges on the words deep forest floor and EVERGREEN and a chunky FERN tile; on the right the same frame rendered at raster density 2 with smooth crisp glyphs and a clean rounded tile

The same logical crop of Gallery frame 132, magnified so one logical pixel is 4 image pixels. Left: the 480×272 density-1 golden every earlier target renders, upscaled the way a plain integer-fit port would show it. Right: the committed 960×544 Vita golden. Same layout, same wrapping, same focus ring — the glyphs, the rounded-corner mask, and even the procedural tile art are sampled at twice the resolution.

The finished contract separates two facts the first cut had fused. The logical viewport is the world the app is written against: 480×272, the truth for layout, text wrapping, focus, hit targets, and animation. The raster density is how many physical samples each logical pixel deserves: 1 on PSP, 2 on Vita. Layout stays portable; pixels stop being nostalgic.

logical world · 480×272 layout text wrapping focus + hit targets animation what the app is written against identical on PSP + Vita rasterDensity = 2 font atlas v3 — metrics logical, glyph coverage stored ×2 SVGs + rounded-corner masks ×2 images prefer an @2x sibling, dimensions checked at build time dynamic textures read platform.pixelRatio geometry + gradients sampled directly on the 960×544 target physical raster 960×544 every pixel earned none stretched integer-fit · exact ×2 no relayout · no crop · no letterbox · no target branch

The rules in the middle column are the whole implementation, and none of them lives in application code. Font atlas v3 keeps advances, baselines, and line heights in logical pixels while storing density-scaled glyph coverage — so text lays out byte-identically on both machines and only looks better on one. SVGs and PocketJS's own rounded-corner masks bake at the same density. PNG and sprite assets prefer an @2x sibling, with the build failing loudly if its dimensions lie. Anything that generates pixels at runtime reads platform.pixelRatio from @pocketjs/framework/platform. And geometry, gradients, and triangles are rasterized directly on the 960×544 target rather than rendered small and copied large.

Just as important is who consumes the answer. The resolved target profile owns physical size, presentation, and raster density — and the PSP GE backend, the Vita GXM backend, the WASM test renderer, the capture build, and even the low-level demo builds all read those same resolved values. We did not hide a scale = 2 in the JavaScript compiler, and no component ever rediscovers a scale factor. When one late bug turned out to be a demo build constructing its own density-unaware raster path, the fix was to delete the private assumption, not to add a Vita case.

An app asks; a host answers

The 2× contract begged a larger question: what should an application say about platforms when it may run on more than one handheld?

Not this:

{ "psp": true, "vita": true, "scale": 2 }

Those are host facts, and the moment an app states them, every future host inherits an archaeology project. In pocket.json — the manifest every PocketJS app ships — the app writes only the public APIs it needs and the logical world it was designed for:

{
  "engine": {
    "capabilities": {
      "requires": ["text.glyphs.baked", "input.buttons"],
      "enhances": ["input.analog.left", "input.touch"]
    }
  },
  "app": {
    "entry": "app/main.tsx",
    "framework": "solid",
    "viewport": {
      "logical": [480, 272],
      "presentation": "integer-fit"
    }
  }
}

PocketJS owns the other half. Each stock target registers a small profile stating what its host has actually implemented and tested — the real Vita entry, verbatim from spec/platforms.ts:

vita: {
  hostAbi: 2,
  display: {
    physicalViewport: [960, 544],
    logicalViewports: [[480, 272]],
    presentations: ["integer-fit"],
    rasterDensity: 2,
  },
  capabilities: [
    "input.analog.left",
    "input.buttons",
    "input.touch",
    "text.glyphs.baked",
  ],
},

A resolver combines manifest and profile exactly once, and everything downstream consumes the resolved answer:

pocket.json — app intent requires · enhances logical viewport · presentation target profile — host facts HostOps ABI · physical display raster density · tested capabilities resolve runs exactly once per build unmet requires → build fails here, not on a player's screen ResolvedBuildPlan plan.json · small · checksummed JS compiler hasFeature("…") → true/false dead branches removed native backend Cargo build · target + ABI verified again at runtime packager EBOOT.PBP for PSP · .vpk for Vita exact app.output artifact names no later stage asks if (target === "vita") — compatibility is a build result, not an allowlist

A PSP-shaped application therefore resolves for Vita unchanged when the Vita profile satisfies its requirements and accepts its 480×272 logical viewport — which is exactly how every stock demo, Pocket Figma, and OpenStrike became Vita apps without editing a component. And an app that requires input.touch fails to resolve for PSP at build time, with a named capability in the error instead of a black screen on a Memory Stick.

What keeps this honest over time is an admission rule, written at the top of the capability registry: a capability id names stable public behavior an application can observe — never hardware, never a permission, never an internal wire format. The registry today is four strings: input.buttons, input.analog.left, input.touch, text.glyphs.baked. During review we deleted a fifth, ui.drawlist, because DrawList is how the core talks to its own renderers — no app can observe it, so no app may request it. And if a future host offers the same API at a meaningfully different execution level, it gets a new id rather than a footnote; a capability that silently means "but slow here" is how cross-platform frameworks rot.

Optional capabilities stay honest the same way. hasFeature is an ordinary import:

import { hasFeature } from "@pocketjs/framework/platform";

if (hasFeature("input.analog.left")) {
  installAnalogNavigation();
} else {
  installButtonNavigation();
}

For manifest builds the compiler proves the binding is the framework import and the argument is a string literal, then folds the call to true or false — and Bun removes the dead branch even with minification off. A PSP package carries no Vita-only code merely because the source contains a fallback pair. The transform cache keys include the sorted feature map, so a PSP build can never reuse a Vita bundle's specialization. Alias imports fold; dynamic feature names and shadowed locals named hasFeature deliberately stay runtime lookups. It is a small build contract, not a capability-token language — TypeScript still checks the app like any other code.

The touchscreen kept the promise

The first Vita runtime shipped with the touch panel dark — deliberately. The hardware was sitting right there, but PocketJS had no public touch API, no HostOps delivery, and no tests, so the profile did not advertise input.touch. Profiles may not claim what the stock host has not implemented and tested; the port's own docs promised touch would arrive "as framework behavior with manifests, types, native delivery, fallback rules, and tests together — not as if (vita) patches."

That is the promise this release keeps. Touch landed as one vertical slice: a public touches() snapshot in @pocketjs/framework/input, native delivery from the Vita front panel, a HostOps ABI bump from 1 to 2, the input.touch registry entry, the profile advertisement, and the tests — in the same change.

import { touches } from "@pocketjs/framework/input";
import { hasFeature } from "@pocketjs/framework/platform";

if (hasFeature("input.touch")) {
  // Vita: real contacts. PSP builds drop this whole branch.
  for (const contact of touches()) {
    inkAt(contact.x, contact.y); // logical 480×272 coordinates
  }
}

The shape of the API is where the density contract pays off twice:

front panel · physical (600, 300) 960×544 sampling grid ÷ density logical snapshot · 480×272 { id: 3, x: 300, y: 150 } one u32 per contact: x:9 bits · y:9 bits · id:8 bits ≤ 8 contacts · immutable delivered at frame start touches() app code DeepZoom anchored pan + pinch no ÷2 in sight the PSP row profile has no input.touch → hasFeature("input.touch") folds to false → the controller fallback is the only code left in the EBOOT applications never see the panel's grid, the density, or the target name — only logical contacts

Contacts arrive as an immutable snapshot at the start of each host frame, in logical viewport pixels — the host divides by the raster density before the app ever looks, so application code never compensates for the Vita's panel grid. (A 480-wide logical space also happens to fit in 9 bits, which is why a whole contact — position and id — travels as one u32.) Because the snapshot is frame-aligned data rather than an event stream, it obeys the determinism rules like every other input: tapes record it, replays reproduce it, and a run with no fingers down costs zero bytes.

The first consumer is <DeepZoom>, which now takes anchored gestures: one-finger pan continues with measured inertia after release, and a two-finger pinch zooms around the midpoint of the contacts instead of the screen center. Pocket Figma's Vita test spreads two synthetic fingers to twice their starting distance and asserts the document scale is exactly 2.0 around a fixed document center — while the PSP build of the same component keeps its trigger-and-nub controls, minus the touch branch it never shipped.

Proof beats vibes

"It boots" is not an acceptance criterion for a renderer port. The Vita suite installs a capture VPK into an isolated VitaFS, starts Vita3K, drives the real QuickJS/input/layout loop with deterministic controller tracks, and waits for completion markers written by the guest. Then it checks 44 frames across eleven demos:

  • every capture is exactly 960×544 RGBA;
  • every frame passes a native-detail assertion — physical detail inside logical pixel blocks, proving the render is genuinely density 2 and did not regress to duplicated pixels;
  • every texture and font atlas referenced by the production GXM pass is resident;
  • all 44 frames match their committed goldens byte-exactly.

One caveat stays visible on purpose: Vita3K's macOS Vulkan path does not give back a coherent GXM framebuffer after presentation, so the pixel oracle is a deterministic CPU renderer inside the capture guest, rasterizing the same DrawList at 960×544. The production vita2d/GXM pass still runs and its resource residency is asserted — but these goldens are not GPU framebuffer dumps, and we will not caption them as if they were. Hardware is no longer unobserved: the release VPKs have now been installed through VitaShell and exercised as named LiveArea bubbles on a physical Vita, including the stock demos and OpenStrike. That run validates packaging, on-device launch, controller input, the exercised flows, and the actual vita2d/GXM presentation path; the capture guest remains the byte-exact pixel oracle. They are complementary proofs, not interchangeable captions.

Real applications shook out what framework demos could not. Pocket Figma contributes seven Vita3K journeys — from fit and zoom through touch pan, pinch, and page switching — and page switching found a genuine landmine: destroying live vita2d textures mid-session could fault inside Vita3K's GXM emulation. The durable fix separates the two lifetimes that a naive port fuses — a logical texture handle retires immediately (stale DrawList references keep failing loudly), while its physical GXM allocation drops into a same-size recycler for the next upload to overwrite. Handle generations stay correct, GPU churn stays bounded, and neither layer lies about ownership. OpenStrike adds five golden moments plus live Pocket3D scene counters, proving the 3D world submission is not an empty HUD over a black frame — and its right-stick capture test moves yaw while asserting a centered stick drifts by exactly zero.

Pocket Talk, the PocketJS IM demo, on PS Vita at 960 by 544: a chat with Maya Chen showing message bubbles with timestamps and read ticks, a text field reading q 1 of 140, and a full on-screen keyboard with controller hints

Pocket Talk — the IM demo with an on-screen keyboard and virtual message list — mid-journey on Vita, from the same 44-frame golden suite. Every app in this post installs as its own bubble: Title IDs derive from each manifest's app id, so the demos, Pocket Figma (PFIG00001), and OpenStrike (OPSK00001) coexist on one home screen instead of overwriting a shared test slot.

One pipeline, two acceptance loops

With VitaSDK and Vita3K installed, any bundled demo is a one-liner:

bun play vita hero
bun play vita gallery --fullscreen
bun play --help

The runner resolves the Vita plan, compiles the app, builds and validates the VPK, replaces the installed title, restarts a running Vita3K safely, and launches the new build — the same short loop as a browser preview, except the output is a native console package.

The exact same pipeline emits the artifact used for hardware acceptance:

bun run vita hero --release
# dist/vita/hero-main.vpk — install with VitaShell

The remaining gaps are named, because that is the point of the whole system. Dynamic host-shaped text layout is future work; text remains the deterministic baked-glyph contract both profiles advertise. The vita2d/GXM path has residency checks but no automated framebuffer pixel oracle of its own yet. A physical Vita run is no longer on that list: real hardware now covers install, LiveArea identity, boot, production GXM presentation, controller input, and the exercised interactive flows, while Vita3K capture covers deterministic byte-exact frames. Future capabilities will land the way touch did — contract first, capability id if one is owed, tests in the same change — because the architecture has made the ad hoc version more work than the honest one. That is the property worth porting.

The build contracts landed in #98, the Vita runtime in #92, and native density plus touch in #99, with downstream proofs in Pocket Figma and OpenStrike. If you want the contract rather than the port story, start at Platform contracts. All of it ships in PocketJS 0.4.0.

One app, two PlayStations, one compatibility answer — and the second screen finally spends its own pixels.

Follow @pocket_js for what's next. The pocket keeps getting deeper.