Skip to content

feat(package): .pocket v1 — one app, every target, one file#139

Merged
doodlewind merged 19 commits into
mainfrom
meteor-shovel
Jul 22, 2026
Merged

feat(package): .pocket v1 — one app, every target, one file#139
doodlewind merged 19 commits into
mainfrom
meteor-shovel

Conversation

@doodlewind

Copy link
Copy Markdown
Collaborator

What

The .pocket distribution container from PLATFORM.md L2 — designed from the start to model Pocket's whole target spread (desktop widget / vita / psp and whatever comes next), not a psp+web pair.

Format (spec/pocket-package.ts + core/src/package.rs)

manifest × target variants. Dist bundles are target-flavored (PSP bakes density-1 rasters, Vita density-2, a desktop widget resolves a live viewport), so "the app" is one target-neutral pocket.json plus one variant per target: its resolved plan (admission evidence), js (NUL-included so devices eval zero-copy from .rodata), pak, and cover — behind a typed, append-only section table (unknown kinds are skipped: the op-table discipline as a file format).

  • Per-variant FNV-1a64: thin extracts a device subset from a universal file without changing any variant's identity — store holds the universal file, the memory stick carries the thinned one, same format.
  • Footer hash over the whole file: torn copies and stale syncs self-announce (the stale-embed tripwire, now a container).
  • Two readers, one contract: the TS encoder/decoder and the no_std zero-copy Rust reader are both pinned to one committed synthetic fixture — they cannot drift apart silently.

Proven with a universal hero.pocket: psp variant (density-1 pak 464 KB) and vita variant (density-2 pak 1.8 MB) side by side in one file; thin --target psp → 579 KB device file, verify re-admits the embedded manifest per variant.

Consumers wired in this PR

  • PSP EBOOT: embeds .pocket files verbatim; the core reader extracts js/pak zero-copy at boot. Build identity = hash of the package files (bundle-hash twin + devtools bridge updated). Single-app EBOOTs keep the classic inline embed, byte-identical.
  • pocketjs.dev stage: /stage/apps/*.pocket served as-is; the wasm host decodes the psp variant in the browser.
  • Tooling: pocket:pack build|inspect|thin|verify (each target compiles into its own outdir — the stale-dist lesson, institutionalized), launcher.ts pack for the EBOOT/site chain.

Verification

  • bun run test full chain green (+ new test/pocket-package.test.ts; two stale site assertions from the previous stage-copy change fixed).
  • cargo test package green (Rust reader vs the shared fixture).
  • bun run e2e:launcher: byte-identical switch signature (217/220, same gaps) with the EBOOT now booting guests out of packages.
  • Headless-Chrome probe: full launch/summon/resume protocol over fetched .pocket files, green.
  • Real PSP: package-mode EBOOT reloaded over PSPLINK + reinstalled to ms0, boot trace clean.

🤖 Generated with Claude Code

doodlewind and others added 18 commits July 21, 2026 11:49
One EBOOT now embeds every PSP-admissible app plus a Cover Flow deck
(LAUNCHER.md). SELECT inside any app summons the deck over a frozen shot
of the interrupted app; picking a card swaps the whole guest (fresh
QuickJS realm + Ui core per app). Resume = relaunch — no suspend
anywhere in the protocol.

- spec ops 39..41 (appTable/appLaunch/appShot, append-only) +
  @pocketjs/framework/launcher wrappers with degraded no-op mode
- native: build.rs multi-bundle embed (registry TSV -> generated APPS
  table), switch.rs (pending switch, SELECT edge latch, framebuffer
  capture + bilinear 256x128 shot with forced alpha), run_guest loop
  with full teardown (JS runtime free, Ui swap, vid/svc/font resets);
  broken guests fall back to the launcher instead of halting
- host-sim: launcher.ts drives the same protocol over bootWorld for
  deterministic switch-flow tests; shot.ts is the downscale twin
- demos/launcher: perspective-pipeline Cover Flow (spring-driven
  translateX/translateZ/rotateY cards, sim-rendered covers, frozen-shot
  scrim overlay, INTERRUPTED badge, browse-only fallback)
- scripts/launcher.ts: computed admission registry (15 in, ipod-nano +
  note out), deterministic sim covers, one-command EBOOT build
- tests: launcher-sim (protocol, latching, determinism-by-hash,
  registry freshness), e2e-launcher-ppsspp (3-swap journey, exact
  217/220 capture signature); no launcher pixel goldens by design
- single-app EBOOTs byte-identical: full PPSSPP e2e suite passes
  byte-exact after the main loop restructure

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rowsing

First run on a real PSP over PSPLINK surfaced three issues:

- mashing the d-pad outran the deck: springs let the selection race
  ahead of the animation, so CROSS launched a card the eye had not
  reached (picking "motions" landed on zoomlab). Browse transforms are
  now 140 ms ease-out tweens retargeted per press — visual center lags
  the selection by at most one hop. Reproduced + verified in sim.
- heavy aliasing on tilted cards: the IMG flags byte existed in the pak
  layout but no cook path ever set it. encodeImageEntry gains a flags
  param, scripts/build.ts reads an optional per-app images.json, and
  every cover bakes IMG_FLAG_LINEAR; a 2 px alpha fade ring on each
  cover lets bilinear soften the polygon edges too. The frozen shot
  uploads linear as well (it stretches 256x128 -> 480x272).
- everything ran at PSPLINK's 222 MHz default: the host never set the
  clock. run() now pins 333/166 MHz at boot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eats

Hold LTRIGGER/RTRIGGER to flow through cards at 10/s (first step on the
press frame), hold a d-pad direction to key-repeat after 250 ms. Every
step retargets the same 140 ms tweens, so the deck glides continuously
instead of stepping — and selection still moves one hop per step, so the
visual center never trails what CROSS launches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Held-flow no longer steps card-to-card with decelerating tweens (it
visibly paused at each card). The deck position is now a fractional
scrub advanced every frame while the input is held, cards are placed
with the new jump() primitive (direct set — the core kills any running
tween on the prop), and release tweens home from the exact fraction.
Deck geometry became one continuous function whose integer samples are
the unchanged discrete targets. jump() is exported from
@pocketjs/framework/animation as the general scrub primitive.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- CIRCLE launches the centered card (console convention); CROSS and
  SELECT back out to the interrupted app. This also closes the
  real-hardware report of every pick landing in the last app: users
  confirmed with O, which was bound to resume. A regression test now
  pins summon -> browse -> CIRCLE to the browsed card.
- The deck stands on a baked stage: scripts/launcher.ts renders a
  256x128 Aqua-era gradient (black floor, cool center glow, faint card
  sheen) stretched full-screen with bilinear, like the frozen shot.
- Trigger flow 10 -> 14 cards/s, held d-pad 7.5 -> 9.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A single-frame tap advances the scrub by only a fraction of a card;
Math.round on release sucked it back to the origin. A flow that ends
displaced from where it began now settles at least one card in the
displacement's direction (the deck wall is the only exception).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- All four browse inputs are one flow mechanism now: d-pad LEFT/RIGHT
  scrubs exactly like the L/R triggers (18 cards/s held, tap = one card
  via the no-round-back rule). The discrete step handlers and the
  key-repeat delay are gone.
- The multi-app EBOOT gets an XMB identity: demos/launcher/psp/Psp.toml
  with a committed ICON0 (a settled deck render resized to 144x80) and
  PIC1 (the stage gradient at 480x272), regenerated by the build's new
  art step via the sim host.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Card textures double to 256x256: cover on top, a vertically mirrored
copy below with a baked alpha falloff (~30% at the seam, gone 60% down).
One 192x192 quad per card carries both, so the reflection rides the
card's rotation for free and the fade lives in texture alpha — the 3D
pipeline has no per-quad gradient, the stage simply shows through. (The
first attempt used a second flipped Image per card and was dropped for
clutter; baking is both cheaper and correct.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two real-hardware finds from the reflection feature:

- The single 256x256 card quad put the cover/reflection seam mid-quad,
  where the GE's screen-space affine sampling bends texture lines at the
  triangle diagonal on tilted cards (a two-segment polyline where the
  card's bottom edge should be). Cover and reflection are now TWO quads
  stacked in the rotating container — the seam is a shared geometric
  edge and projects straight; interior affine warp stays, as the era
  intended.
- Full-res reflection textures tipped the PSP arena over (~2 MB extra
  texture heap) and boot parked on the OOM handler. Reflections now
  ship quarter-res PSM_4444 (16 KB a card) — invisible at 30% alpha,
  and the sim never noticed either issue.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PSM_4444 gave the 0.3->0 reflection ramp only ~5 alpha steps: visible
horizontal bands every few rows on hardware. Quarter-res stays (32 KB a
card keeps the arena comfortable); the 8-bit alpha ramp is what the
fade actually needs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PLATFORM.md lands the platform roadmap (layers, .pocket package draft,
dynamic install admission, bytecode plan, memory governance, the
sim -> PPSSPP -> hardware DX pipeline), and the veil is its first piece:

- Every guest swap now plays ~24 host-drawn vblanks before the blocking
  eval: the outgoing frame's shot dims (every switch captures one now;
  guests still only see it on summon), the Pocket mark fades in
  centered, and an additive highlight sweeps across it — vertex-alpha
  strips over the mark's own texture, no second asset. The settled
  frame holds through the eval; the incoming guest's first present
  replaces it.
- The mark rasterizes at build time from the brand SVG's exact geometry
  (SDF + supersample — the PNGs carry an opaque plate and the SVG's
  stroked shapes are outside bake-svg's subset), 128x128 RGBA embedded.
- Real-GE find: the ui path leaves blending disabled between frames —
  the veil must sceGuEnable(Blend) itself or texture alpha is ignored.
- Contract-invisible by construction: no op, no capability, frames
  present outside the input-indexed loop. The e2e switch signature is
  byte-for-byte unchanged (217/220, same gaps); capture builds dump
  veil frames separately as vNNNN.raw for inspection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three real-hardware reports, one root cause plus one GE truth:

- The shot's 2:1 center crop deformed everything downstream: the veil's
  "dimming screen" stretched 13% vertically, covers lost their top and
  bottom 16 px. The downscale now takes the FULL 480x272 frame into the
  pow2 256x128 texture (stored slightly squeezed); every consumer draws
  at screen aspect — fullscreen 480x272, cards at 192x109 — which
  undoes the squeeze exactly. No crop, no net deformation.
- The veil's left-to-right sweep read as a marching band of seams: GE
  sprites do not interpolate color across a quad, so the strip-built
  gradient was 12 flat steps. Replaced with a uniform-per-frame
  brightness swell on the mark — spatially constant by construction,
  nothing to tear.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The launcher's tilted cards still showed interior texture lines kinking
at the quad diagonal — TEX_TRI sampling is affine per triangle, and the
earlier cover/reflection split only straightened the SEAM, not content
lines inside each quad. collect_3d now emits foreshortened image quads
as a grid of cells sized by the perspective variation per axis
(projectively correct UVs at every cell corner; residual per-cell
affine error shrinks quadratically). Flat or affine mappings keep the
single-quad path — all 49 pixel goldens pass byte-exact, and the e2e
switch signature is unchanged.

Also drops the launcher's corner texts (POCKET LAUNCHER / 15 APPS) —
the deck speaks for itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ched

The pocketjs.dev hero's 3D PSP now runs the SAME multi-app deck the
EBOOT ships, on the wasm core:

- PocketHost gains enableAppSwitching() — the browser twin of
  native/src/switch.rs + host-sim/launcher.ts: the three app* ops
  overlay every guest's namespace, SELECT is stripped from non-launcher
  guests with a host-tracked summon edge, and a switch is the same
  whole-guest swap runIIFE always was (frozen shot downscaled via the
  shared host-sim/shot.ts filter and re-uploaded into the next world).
  A broken guest falls back into the launcher, like the native rule.
- The site build packages /stage/apps/ — launcher + all 15 admitted
  bundles + apps.json from the registry — replacing the single
  settings-main screen app; bundles prefetch idle-priority after the
  hero is up so launches swap instantly.
- The stage model's SELECT button (and Shift on the keyboard) summons,
  exactly like the hardware chord.
- site/verify.ts: dedicated Chrome profile dir (new-Chrome CDP
  requirement) + SwiftShader flags; the switch protocol is verified
  headlessly by driving PocketHost directly (WebGL-independent probe):
  boot -> launch Café -> SELECT summon with shot + resume, all green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
scripts/site-build.ts (the deploy workflow's single entry) now runs the
launcher artifact chain — registry scan, per-app bundles, deterministic
covers, launcher compile — before site/build.ts, which requires them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The distribution container from PLATFORM.md, built to model the whole
target spread (desktop widget / vita / psp), not a psp-web pair:

- spec/pocket-package.ts: manifest × TARGET VARIANTS. Dist bundles are
  target-flavored (density-split rasters), so each variant carries its
  own resolved plan, js (NUL-included for zero-copy device eval), pak
  and cover behind a typed APPEND-ONLY section table (unknown kinds
  skip — the op-table discipline as a file format). Per-variant
  FNV-1a64 makes `thin` (universal -> device subset) identity-stable;
  a footer hash makes torn copies self-announcing.
- core/src/package.rs: no_std zero-copy reader. Both implementations
  pin to ONE committed synthetic fixture — two readers, one contract.
- scripts/pocket-pack.ts: build (per-target outdirs — the stale-dist
  lesson institutionalized) / inspect / thin / verify (footer +
  re-admission of the embedded manifest per variant). Proven with a
  universal hero.pocket: psp (density-1 pak 464KB) + vita (density-2
  pak 1.8MB) side by side.
- The launcher chain packs psp packages; the EBOOT now embeds .pocket
  files VERBATIM and boots guests straight out of them (build identity
  = hash of the package files); the site serves the same files to the
  wasm host, which decodes the psp variant in the browser.
- e2e switch signature byte-identical (217/220), full test chain green
  (two stale site assertions from the stage copy change fixed along
  the way), core tests green, real-PSP reload + ms0 install verified.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The hardware report after the .pocket switch (log stream fighting the
veil, hang on summon) traced to a deployment slip, not the format: the
shipped release EBOOT had POCKETJS_TRACE baked in — debug-screen prints
interleaving with the veil's presents, on the XMB where host0: does not
even exist. PPSSPP's package-mode e2e runs the identical summon path
byte-exact, and the traceless rebuild is the fix.

Hardening from the same look:
- scripts/psp.ts now warns LOUDLY when trace is baked into a release
  build — this exact slip shipped once.
- Embedded .pocket files are anchored behind psp::Align16: their
  payloads are 16-aligned relative to the file start, include_bytes!
  guarantees nothing, and MIPS faults on unaligned word loads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@doodlewind
doodlewind marked this pull request as ready for review July 22, 2026 06:18
…port work)

Conflicts were squash-merge echoes of the launcher PR plus the ESP32
viewport refactor: kept the .pocket package side everywhere it owned the
hunk, took main's join()-style paths and SimViewportOptions, and merged
the test chain as the union of both additions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@doodlewind
doodlewind merged commit 11ba428 into main Jul 22, 2026
@doodlewind
doodlewind deleted the meteor-shovel branch July 22, 2026 06:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant