Skip to content

feat(framework): add Octane as the third first-class framework - #203

Merged
doodlewind merged 7 commits into
mainfrom
grass-responsibility
Jul 30, 2026
Merged

feat(framework): add Octane as the third first-class framework#203
doodlewind merged 7 commits into
mainfrom
grass-responsibility

Conversation

@doodlewind

@doodlewind doodlewind commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Octane (https://github.com/octanejs/octane — React's programming model, compiled; by Dominic Gannaway) joins Solid and Vue Vapor as a first-class PocketJS framework. This is the React-model path PR #6 concluded was impossible with original React: Octane's universal client target compiles JSX + hooks into static host plans + dynamic slot batches executed by a UniversalHostDriver — no DOM, no VDOM — which maps directly onto the native ui.* tree.

What's in here

Adapter (framework/src/*octane*)

  • renderer-octane.ts — the "pocket" universal driver: host command batches (create/update/insert/move/remove/destroy) applied to the shared NodeMirror arena via applyProps; #text hosts through createTextNode/replaceText; portals as a driver capability minting overlay hosts transactionally. Re-exports the host-neutral universal ABI (octane/universal/native — QuickJS-safe).
  • components-octane.tsx — the full 15-component surface (View/Text/Image/Sprite as hand-lowered universal plans; Screen/Focusable/FocusScope/FocusGrid/ActionHandler/Portal/Modal/ActionBar/Grid/Lazy/Gallery as Octane function components).
  • frame-octane.tsx / lifecycle-octane.tsuseFrame, useButtonPress, useSpriteAnimation (use-prefixed: the Octane compiler slot-keys custom hooks by the use[A-Z] call-site convention; non-hook names silently collide their internal slots).
  • index-octane.ts — render/mount; the frame handler drains Octane's microtask renders inside the frame via flushUniversalSync, so state commits land in the same tick as their input.

Compiler/build

  • jsx-plugin.ts: third FRAMEWORKS entry, OCTANE_RENDERER_DESCRIPTOR, octane compile branch (collector pass on pristine source → octane/compiler universal lowering), generalized frameworkVariantPath (app.octane.tsx siblings), cache-key includes the octane version.
  • pocket.json schema ("framework": "octane"), package exports ./octane/*, tsconfig paths.

The memory hunt (the fun part) — octane demos initially died mid-run on PPSSPP (InternalError: out of memory with megabytes of arena free, opaque null exceptions, wedged frame loops). A scratch cargo probe pinned to the exact libquickjs-sys revision (eval the real bundle + stub ui + frame loop + heap histograms + a gc_decref root dump) unpicked a three-layer cause:

  1. Octane's profiler is on by default, and its trackedComponents WeakMap is written even after profiler.stop() — with per-render closures as values.
  2. The pinned QuickJS (2026-06, new GC) marks WeakMap record values strongly — not ephemeron-aware — so those records pinned every render's owner graph (ten-line upstream repro: wm.set(key, {back: key}) never collects; bellard master has the same code).
  3. Even after unpinning, the engine's internal slab allocator (JSMallocArena) amplifies any residual retention 10-20× on a fixed arena: a few live objects pin whole chunks, and the auto-GC threshold (live × 1.5) is far too lazy for a 14.4 MiB heap.

Fixes, in order: framework=octane builds alias octane/profiling to a no-op stub (framework/src/octane-profiling-stub.ts — also removes per-render profiler work from the frame budget); the PSP frame loop gained an arena-pressure GC (JS_RunGC when a frame leaves the bump pointer >256 KiB past the last collection — steady-state Solid/Vapor guests never trigger it); and the Octane ports keep always-animating state in leaf components, committing state on visible steps (music's Equalizer/ProgressLine split, stats' StatTiles). Along the way the PSP host's exception logger learned to print tag + message + stack (a null exception is QuickJS's no-room-for-the-Error-object throw), and one solid golden (hero.boot, a single background pixel ±2) was re-baselined — it predates this branch; this is the first full solid e2e since the #149-era goldens.

Result: all three frameworks pass byte-exact PPSSPP e2e. A real-hardware test then exposed the second act (commits 4e097c0, f0b8d12): hero-octane ran under 1 fps on a physical PSP. Two more root causes fell out — every PSP QuickJS build since 2021 had silently been -O0 (CRATE_CC_NO_DEFAULTS=1 drops the cc crate's opt_level; -O2 now lives in TARGET_CFLAGS, and libquickjs-sys is repinned registry-wide), and an Octane state commit replays the whole root at scope-independent cost, so per-frame state is unaffordable regardless of leaf placement. Continuous motion now rides the native channels (<Sprite> atlases, baked keyframe timelines, animate()/jump(), ref-based phase timers, and a new setTextContent() escape hatch for per-frame text). Per-replay engine residue remains a tracked quickjs-rs follow-up, but replays are now interaction-rate, not frame-rate.

Demos — all 8 Vue Vapor demos got app.octane.tsx twins (hero, cards, stats, library, settings, notifications, music, gallery), sim-verified text/pixel parity against the vapor variants, PPSSPP byte-exact goldens in tests/goldens/psp-octane/ (7 bench-table demos), plus new tests/goldens/psp-vue-vapor/ coverage.

Playground + docs — third framework toggle with in-browser octane compilation (pure-JS compiler, no WASM shim needed), pg/runtime-octane.js bundle, import-map entries; frameworks.md reworked for three frameworks, octane fences added to all 29 :::framework-code blocks, README/DESIGN/RUNTIMES/home/INTEGRATION updated.

Benchmarktools/bench-ppsspp.ts regained the PR #6 cross-engine axis: --frameworks=solid,vue-vapor,octane, per-(app, framework) grouping, geomean-vs-baseline ratios with optional --bootstrap CIs; tools/bench-chart.ts renders the matrix SVG. Full three-framework results below.

Benchmark (PPSSPP software renderer, per PR #6 methodology)

7 samples per (app, framework) cell across the 7 bench demos, deterministic PPSSPP headless (PPSSPP 676724ee5e, base 4e097c0), geomean-vs-Solid ratios with mulberry32-seeded 5000-resample bootstrap 95% CIs. Full report + chart in docs/bench/ (three-frameworks-ppsspp-2026-07-30.{md,json,svg,png}; the pre-fix 07-29 dataset is archived beside it); raw samples reproducible via the specs table in tools/bench-ppsspp.ts, and the report is rebuildable from a raw dump with the new --from-raw=<raw.jsonl> mode.

three-framework chart

metric (geomean vs Solid, lower is better) Vue Vapor Octane
bundle eval 2.91× 2.88×
boot → first frame 2.02× 2.00×
avg frame work 1.11× 1.66× (was 15.58× pre-fix)
host wall clock 1.25× [1.24, 1.25] 1.27× [1.26, 1.28]
bundle size 2.41× 2.96×

Average frame work by app:

app Solid Vue Vapor Octane Octane pre-fix
hero 3.66 ms 3.61 ms 6.53 ms 387.82 ms
cards 4.82 ms 5.14 ms 6.56 ms 9.66 ms
stats 6.84 ms 8.04 ms 9.25 ms 279.91 ms
library 3.68 ms 4.39 ms 4.94 ms 50.76 ms
settings 6.84 ms 7.37 ms 14.53 ms 27.44 ms
notifications 4.52 ms 6.10 ms 13.84 ms 271.21 ms
music 10.44 ms 10.39 ms 12.92 ms 282.71 ms

Every cell of all three frameworks is inside the 16.7 ms 60 fps budget (the -O2 fix also roughly halved Solid's and Vue Vapor's own numbers). The honest remaining gaps: Octane runs 1.2–3.1× Solid per app, and a button press is one root replay ≈ 150–250 ms on the PSP — the replay walk's identity-path re-resolution (13k calls/replay) is the upstream Octane follow-up, alongside the quickjs-rs ephemeron repair.

React status: unchanged from PR #6 — original React still has no viable PSP path; Octane is the React programming model that does run, measured above.

Authoring notes (also in docs/frameworks.md)

  • Mixed static+dynamic text → one template literal (universal lowering trims the static segment's trailing space).
  • Frame-loop counters → functional setState (a same-frame handler write would be clobbered by a stale read).
  • Don't put natively-animated props in a style whose value changes across renders (unchanged values diff away and are safe).
  • Octane entries call mount(App) — JSX in a call-argument arrow is a fail-closed universal-target error.

Validation

  • bunx tsc --noEmit clean
  • bun run test green end-to-end (includes the new tests/octane-smoke.test.ts + hero --framework=octane build in the chain)
  • PPSSPP e2e byte-exact goldens for all three frameworks: tests/goldens/psp/, tests/goldens/psp-vue-vapor/ (new), tests/goldens/psp-octane/ (new); hero.boot re-baselined (single background pixel ±2, pre-dates this branch)
  • Three-framework benchmark: 147 samples (7 per cell × 7 apps × 3 frameworks), deterministic — repeated runs are byte-identical; pre-fix and post-fix reports/charts archived in docs/bench/
  • XMB cover art (ICON0/PIC1/title) for all eight demos via tools/gen-demo-covers.ts
  • bun site/build.ts green — playground ships current pg/runtime.js / pg/runtime-vue-vapor.js / pg/runtime-octane.js
  • Real-hardware PSP first run — caught hero-octane at <1 fps and triggered the performance work in 4e097c0
  • Real-hardware PSP re-test of the post-fix EBOOTs — confirmed smooth on device; it also caught the blank sprite-atlas bug (<g transform> silently dropped by bake-svg) fixed in 569d626

🤖 Generated with Claude Code

doodlewind and others added 4 commits July 29, 2026 16:59
Octane (React's programming model, compiled) joins Solid and Vue Vapor:
universal-driver renderer over the native tree, full 15-component surface,
use-prefixed frame hooks, app.octane.tsx variants for all 8 demos, playground
in-browser compilation, byte-exact PPSSPP goldens, and a three-framework
PPSSPP benchmark (docs/bench/) per the PR #6 methodology. The PSP host gains
an arena-pressure GC; framework=octane builds stub octane/profiling to dodge
a WeakMap pinning pathology in the pinned QuickJS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ly -O0 QuickJS builds

Real-hardware testing showed hero-octane under 1 fps. Two root causes:

1. Every PSP QuickJS build was -O0: CRATE_CC_NO_DEFAULTS=1 drops the cc
   crate's synthesized flags including build.rs opt_level(), and the pin
   predated quickjs-rs PR #6 anyway. -O2 now lives in TARGET_CFLAGS and
   libquickjs-sys is repinned to master (ba5bdd0d) registry-wide
   (psp + vita Cargo, psp/symbian toolchain manifests, pin tests).

2. An Octane state commit replays the whole root regardless of where the
   state lives (~2-4ms desktop, engine-bound on PSP), so per-frame state is
   unaffordable there. Continuous motion now rides the native channels:
   sprite atlases (hero/gallery/library spinners via sprites.json + a new
   spinner-atlas.svg), baked keyframe timelines (music equalizer,
   apps/music/pocket.config.ts), animate()/jump() one-shots (stats systems
   reveal), ref-based phase timers (notifications, library loading), and a
   new setTextContent() escape hatch - the text-shaped sibling of animate() -
   for count-ups and percentages (stats tiles, music progress).

useSpriteAnimation also stops committing state on ticks that do not change
the visible sprite frame.

PPSSPP avg frame work, before -> after (budget 16.7ms):
hero 387.8 -> 6.5ms, stats 279.9 -> 9.2ms, music 282.7 -> 12.9ms,
notifications 271.2 -> 13.8ms, library 50.8 -> 4.9ms, settings 27.4 ->
14.5ms, cards 9.7 -> 6.6ms. All three frameworks' e2e suites pass; only
the six goldens whose pixels legitimately changed were re-baselined.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…+ blog to post-fix numbers

- tools/gen-demo-covers.ts bakes the pocket app-family cover convention for
  every showcase demo: ICON0 as a geometry-drawn family tile, PIC1 as a real
  sim-pumped frame of the app with the XMB legibility dim. apps/<demo>/psp/
  fragments apply to every framework build of that demo.
- docs/bench: archive the 2026-07-30 post-optimization dataset (geomean
  avg_work octane 1.66x vs solid, was 15.58x; all 21 cells inside the 16.7ms
  budget) alongside the pre-fix 07-29 dataset.
- blog: add "The hardware said no" chapter (the -O0-since-2021 discovery,
  replay-scope-independence, the native-channel rule, per-demo before/after),
  refresh the benchmark section to the new dataset with a linear-scale chart,
  and re-count byte-identical goldens (14/23).
- playground compiler-entry merges the music demo's equalizer keyframes into
  the playground-wide animation theme.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
No repo-internal references, leads with the machine and the three findings;
post dated to its publish day.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@doodlewind
doodlewind marked this pull request as ready for review July 30, 2026 01:03
doodlewind and others added 3 commits July 30, 2026 13:00
…pinner atlas

The svg baker scans shapes flat, so the spinner atlas's per-cell
<g transform="translate(...)"> was silently dropped - all eight frames baked
into cell 0 and cells 1-7 shipped blank. On hardware the spinner sat
invisible and "flashed" once per cycle when the frame index wrapped through
the one painted cell; the golden re-baseline had baked the bug in.

The atlas now carries absolute per-cell circle coordinates (no groups), and
bake-svg refuses <g transform> loudly instead of mis-baking. All four
affected goldens re-baked and visually verified: the spinner is present at a
distinct phase in every capture.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
site/home.html: keep main's #199-#202 landing copy for all four conflicted
prose blocks (they intentionally stopped enumerating frameworks); the Octane
counter tab/panel from this branch live in non-conflicting regions and
survive as-is.
…dent

The post-fix builds are verified on the same physical PSP that failed the
first run; the sprite-cycling bullet now carries the honest beat about the
<g transform> atlas bake and the golden re-baseline that hid it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@doodlewind
doodlewind merged commit 7dce434 into main Jul 30, 2026
@doodlewind
doodlewind deleted the grass-responsibility branch July 30, 2026 04:13
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