diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 00000000..bd2c9903
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,63 @@
+name: Test suite
+
+# The suite that release.yml already gates a tag on, run per branch instead of
+# only at publish time. Everything here is host-independent: the PSP/Vita
+# golden suites need PPSSPP, Vita3K and the pinned PSP toolchain, so they stay
+# a local step (tests/e2e/) and are not reachable from a runner.
+on:
+ pull_request:
+ push:
+ branches: [main]
+ workflow_dispatch: {}
+
+permissions:
+ contents: read
+
+concurrency:
+ group: test-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ suite:
+ name: JS suite + typecheck + Rust core
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+
+ - uses: dtolnay/rust-toolchain@stable
+ with:
+ targets: wasm32-unknown-unknown
+
+ - name: Cache cargo + wasm target
+ uses: actions/cache@v4
+ with:
+ path: |
+ ~/.cargo/registry
+ ~/.cargo/git
+ engine/wasm/target
+ key: ${{ runner.os }}-wasm-${{ hashFiles('engine/wasm/Cargo.toml', 'engine/core/Cargo.toml', 'engine/wasm/src/**', 'engine/core/src/**') }}
+ restore-keys: ${{ runner.os }}-wasm-
+
+ - name: Install dependencies
+ run: bun install --frozen-lockfile
+
+ # hosts/web/pocketjs.wasm is a build artifact (gitignored); the browser
+ # stages eval it, so it has to exist before the suite runs.
+ - name: Build wasm (core + software rasterizer)
+ run: bun tools/wasm.ts
+
+ - name: Test JavaScript and package contracts
+ run: bun run test
+
+ # The app build at the start of `bun run test` generates the local style
+ # module that TypeScript intentionally imports.
+ - name: Typecheck
+ run: bunx tsc --noEmit
+
+ - name: Test Rust core
+ run: cargo test --locked --manifest-path engine/core/Cargo.toml
diff --git a/apps/cards/psp/octane/Psp.toml b/apps/cards/psp/octane/Psp.toml
new file mode 100644
index 00000000..c023d301
--- /dev/null
+++ b/apps/cards/psp/octane/Psp.toml
@@ -0,0 +1,7 @@
+# XMB metadata for the PocketJS Cards (Octane) EBOOT. tools/psp.ts copies this to
+# hosts/psp/Psp.toml when building this demo with --framework=octane; cargo-psp packs
+# it into PARAM.SFO / ICON0 / PIC1.
+# Regenerate the art with: bun tools/gen-demo-covers.ts --framework=octane cards
+title = "PocketJS Cards (Octane)"
+xmb_icon_png = "icon0.png"
+xmb_background_png = "pic1.png"
diff --git a/apps/cards/psp/octane/icon0.png b/apps/cards/psp/octane/icon0.png
new file mode 100644
index 00000000..f4d7d18b
Binary files /dev/null and b/apps/cards/psp/octane/icon0.png differ
diff --git a/apps/cards/psp/octane/pic1.png b/apps/cards/psp/octane/pic1.png
new file mode 100644
index 00000000..5050e62d
Binary files /dev/null and b/apps/cards/psp/octane/pic1.png differ
diff --git a/apps/gallery/psp/octane/Psp.toml b/apps/gallery/psp/octane/Psp.toml
new file mode 100644
index 00000000..d76eb32a
--- /dev/null
+++ b/apps/gallery/psp/octane/Psp.toml
@@ -0,0 +1,7 @@
+# XMB metadata for the PocketJS Gallery (Octane) EBOOT. tools/psp.ts copies this to
+# hosts/psp/Psp.toml when building this demo with --framework=octane; cargo-psp packs
+# it into PARAM.SFO / ICON0 / PIC1.
+# Regenerate the art with: bun tools/gen-demo-covers.ts --framework=octane gallery
+title = "PocketJS Gallery (Octane)"
+xmb_icon_png = "icon0.png"
+xmb_background_png = "pic1.png"
diff --git a/apps/gallery/psp/octane/icon0.png b/apps/gallery/psp/octane/icon0.png
new file mode 100644
index 00000000..62abe051
Binary files /dev/null and b/apps/gallery/psp/octane/icon0.png differ
diff --git a/apps/gallery/psp/octane/pic1.png b/apps/gallery/psp/octane/pic1.png
new file mode 100644
index 00000000..91405fe7
Binary files /dev/null and b/apps/gallery/psp/octane/pic1.png differ
diff --git a/apps/hero/app.octane.tsx b/apps/hero/app.octane.tsx
index f4b99554..e4ce7ad0 100644
--- a/apps/hero/app.octane.tsx
+++ b/apps/hero/app.octane.tsx
@@ -1,6 +1,6 @@
import { useLayoutEffect, useRef, useState } from "octane";
import { Image, Sprite, Text, View, type NodeMirror } from "@pocketjs/framework/octane/components";
-import { animate } from "@pocketjs/framework/octane/animation";
+import { animate, jump } from "@pocketjs/framework/octane/animation";
import { frameworkName } from "@pocketjs/framework/octane";
const Stat = (props: { label: string; value: string; cls: string }) => {
@@ -20,8 +20,35 @@ const Spinner = () => {
return ;
};
-export default function Hero() {
+// The count lives here rather than in Hero: octane scopes a setState replay to
+// the nearest owner that has a committed range, and the root component has
+// none — root state always replays the whole tree (176.9 -> 37.8 ms per press
+// on PSP for this app). Anything else the count drives has to leave the render
+// path with it, hence the underline moving through jump() below.
+const CounterRow = (props: { onCount: (next: number) => void }) => {
const [count, setCount] = useState(0);
+ return (
+
+ {
+ const next = count + 1;
+ setCount(next);
+ props.onCount(next);
+ }}
+ >
+ Press Circle
+
+ {`Count: ${count}`}
+ {count > 3 ? (
+ Reactive on real hardware.
+ ) : null}
+
+ );
+};
+
+export default function Hero() {
const underline = useRef(null);
useLayoutEffect(() => {
@@ -58,28 +85,20 @@ export default function Hero() {
underline.current = node;
}}
class="h-1 w-0 rounded-full shadow bg-gradient-to-r from-blue-500 to-cyan-500"
- style={{ translateX: count * 2 }}
+ // Constant, never re-applied: it keeps the transform on the node so
+ // jump()'s edge rounding matches the pre-refactor raster exactly.
+ style={{ translateX: 0 }}
/>
Flexbox, springs and baked type - running through Octane.
-
- {
- setCount(count + 1);
- }}
- >
- Press Circle
-
- {`Count: ${count}`}
- {count > 3 ? (
- Reactive on real hardware.
- ) : null}
-
+ {
+ if (underline.current) jump(underline.current, "translateX", next * 2);
+ }}
+ />
);
}
diff --git a/apps/hero/psp/octane/Psp.toml b/apps/hero/psp/octane/Psp.toml
new file mode 100644
index 00000000..ac74dfb9
--- /dev/null
+++ b/apps/hero/psp/octane/Psp.toml
@@ -0,0 +1,7 @@
+# XMB metadata for the PocketJS Hero (Octane) EBOOT. tools/psp.ts copies this to
+# hosts/psp/Psp.toml when building this demo with --framework=octane; cargo-psp packs
+# it into PARAM.SFO / ICON0 / PIC1.
+# Regenerate the art with: bun tools/gen-demo-covers.ts --framework=octane hero
+title = "PocketJS Hero (Octane)"
+xmb_icon_png = "icon0.png"
+xmb_background_png = "pic1.png"
diff --git a/apps/hero/psp/octane/icon0.png b/apps/hero/psp/octane/icon0.png
new file mode 100644
index 00000000..8b5612c4
Binary files /dev/null and b/apps/hero/psp/octane/icon0.png differ
diff --git a/apps/hero/psp/octane/pic1.png b/apps/hero/psp/octane/pic1.png
new file mode 100644
index 00000000..dd8ee28d
Binary files /dev/null and b/apps/hero/psp/octane/pic1.png differ
diff --git a/apps/library/psp/octane/Psp.toml b/apps/library/psp/octane/Psp.toml
new file mode 100644
index 00000000..a395a846
--- /dev/null
+++ b/apps/library/psp/octane/Psp.toml
@@ -0,0 +1,7 @@
+# XMB metadata for the PocketJS Library (Octane) EBOOT. tools/psp.ts copies this to
+# hosts/psp/Psp.toml when building this demo with --framework=octane; cargo-psp packs
+# it into PARAM.SFO / ICON0 / PIC1.
+# Regenerate the art with: bun tools/gen-demo-covers.ts --framework=octane library
+title = "PocketJS Library (Octane)"
+xmb_icon_png = "icon0.png"
+xmb_background_png = "pic1.png"
diff --git a/apps/library/psp/octane/icon0.png b/apps/library/psp/octane/icon0.png
new file mode 100644
index 00000000..88033109
Binary files /dev/null and b/apps/library/psp/octane/icon0.png differ
diff --git a/apps/library/psp/octane/pic1.png b/apps/library/psp/octane/pic1.png
new file mode 100644
index 00000000..f6562687
Binary files /dev/null and b/apps/library/psp/octane/pic1.png differ
diff --git a/apps/music/psp/octane/Psp.toml b/apps/music/psp/octane/Psp.toml
new file mode 100644
index 00000000..bd55e218
--- /dev/null
+++ b/apps/music/psp/octane/Psp.toml
@@ -0,0 +1,7 @@
+# XMB metadata for the PocketJS Music (Octane) EBOOT. tools/psp.ts copies this to
+# hosts/psp/Psp.toml when building this demo with --framework=octane; cargo-psp packs
+# it into PARAM.SFO / ICON0 / PIC1.
+# Regenerate the art with: bun tools/gen-demo-covers.ts --framework=octane music
+title = "PocketJS Music (Octane)"
+xmb_icon_png = "icon0.png"
+xmb_background_png = "pic1.png"
diff --git a/apps/music/psp/octane/icon0.png b/apps/music/psp/octane/icon0.png
new file mode 100644
index 00000000..bad5dae4
Binary files /dev/null and b/apps/music/psp/octane/icon0.png differ
diff --git a/apps/music/psp/octane/pic1.png b/apps/music/psp/octane/pic1.png
new file mode 100644
index 00000000..a942b9f5
Binary files /dev/null and b/apps/music/psp/octane/pic1.png differ
diff --git a/apps/notifications/app.octane.tsx b/apps/notifications/app.octane.tsx
index 769b443b..beb774fe 100644
--- a/apps/notifications/app.octane.tsx
+++ b/apps/notifications/app.octane.tsx
@@ -70,55 +70,56 @@ function NoticeRow(props: NoticeRowProps) {
export default function Notifications() {
const [items, setItems] = useState([...INITIAL]);
- const [dismissingId, setDismissingId] = useState(null);
- const [riseOffsets, setRiseOffsets] = useState>({});
const rowRefs = useRef(new Map());
- // Phase timers live in refs: the motion itself is native animate() tweens,
- // so JS only needs to know when a phase ENDS. Counting in state would
- // replay the whole root every frame of every dismissal on the PSP.
- const riseQueued = useRef([]);
- const riseTick = useRef(0);
+ // The dismissal state machine lives in refs: which row is leaving, which
+ // rows are sliding up, how many frames are left. In state, each phase edge
+ // replays the whole root — three replays of ~35 component bodies per
+ // dismissal, ~200 ms each on the PSP — and only one of the three changes
+ // anything, because only `items` reaches the render tree. `risingIds` is
+ // read during that one replay to seed the survivors' offset, so it must be
+ // assigned before setItems.
+ const dismissingId = useRef(null);
+ const risingIds = useRef([]);
+ const riseFramesLeft = useRef(0);
const dismissTick = useRef(0);
- const hasRise = () => Object.keys(riseOffsets).length > 0 || riseQueued.current.length > 0;
+ const busy = () =>
+ dismissingId.current !== null || risingIds.current.length > 0 || riseFramesLeft.current > 0;
useFrame(() => {
- if (riseQueued.current.length > 0) {
- for (const id of riseQueued.current) {
+ if (risingIds.current.length > 0) {
+ for (const id of risingIds.current) {
const row = rowRefs.current.get(id);
if (row) animate(row, "translateY", 0, { dur: 180, easing: "out" });
}
- riseQueued.current = [];
- riseTick.current = 0;
- } else if (Object.keys(riseOffsets).length > 0) {
- riseTick.current += 1;
- if (riseTick.current >= ROW_RISE_FRAMES) {
- setRiseOffsets({});
- riseTick.current = 0;
- }
+ risingIds.current = [];
+ riseFramesLeft.current = ROW_RISE_FRAMES;
+ } else if (riseFramesLeft.current > 0) {
+ riseFramesLeft.current -= 1;
}
- const id = dismissingId;
+ const id = dismissingId.current;
if (id === null) return;
dismissTick.current += 1;
- if (dismissTick.current >= DISMISS_FRAMES) {
- const before = items;
- const removedIndex = before.findIndex((it) => it.id === id);
- const rising = removedIndex < 0 ? [] : before.slice(removedIndex + 1).map((it) => it.id);
- if (rising.length > 0) {
- setRiseOffsets(Object.fromEntries(rising.map((rid) => [rid, ROW_RISE_PX])));
- riseQueued.current = rising;
- }
- rowRefs.current.delete(id);
- setItems(before.filter((it) => it.id !== id));
- setDismissingId(null);
- dismissTick.current = 0;
- }
+ if (dismissTick.current < DISMISS_FRAMES) return;
+
+ const before = items;
+ const removedIndex = before.findIndex((it) => it.id === id);
+ const rising = removedIndex < 0 ? [] : before.slice(removedIndex + 1).map((it) => it.id);
+ // Set before setItems, because the replay it triggers rebuilds the rows
+ // and reads this to seed their offset. A jump() here would be lost: the
+ // survivors are recreated by that replay, so the node it wrote to is gone
+ // by the time the tween starts.
+ risingIds.current = rising;
+ rowRefs.current.delete(id);
+ setItems(before.filter((it) => it.id !== id));
+ dismissingId.current = null;
+ dismissTick.current = 0;
});
const dismiss = (id: string, el: NodeMirror | undefined) => {
- if (dismissingId !== null || hasRise() || !el) return;
- setDismissingId(id);
+ if (busy() || !el) return;
+ dismissingId.current = id;
dismissTick.current = 0;
animate(el, "opacity", 0, { dur: 200, easing: "out" });
animate(el, "translateX", 24, { dur: 200, easing: "out" });
@@ -140,7 +141,7 @@ export default function Notifications() {
key={item.id}
item={item}
index={i}
- rise={riseOffsets[item.id] ?? 0}
+ rise={risingIds.current.includes(item.id) ? ROW_RISE_PX : 0}
onRowRef={(id: string, row: NodeMirror) => {
rowRefs.current.set(id, row);
}}
diff --git a/apps/notifications/psp/octane/Psp.toml b/apps/notifications/psp/octane/Psp.toml
new file mode 100644
index 00000000..91c65cbe
--- /dev/null
+++ b/apps/notifications/psp/octane/Psp.toml
@@ -0,0 +1,7 @@
+# XMB metadata for the PocketJS Notifications (Octane) EBOOT. tools/psp.ts copies this to
+# hosts/psp/Psp.toml when building this demo with --framework=octane; cargo-psp packs
+# it into PARAM.SFO / ICON0 / PIC1.
+# Regenerate the art with: bun tools/gen-demo-covers.ts --framework=octane notifications
+title = "PocketJS Notifications (Octane)"
+xmb_icon_png = "icon0.png"
+xmb_background_png = "pic1.png"
diff --git a/apps/notifications/psp/octane/icon0.png b/apps/notifications/psp/octane/icon0.png
new file mode 100644
index 00000000..5222dff5
Binary files /dev/null and b/apps/notifications/psp/octane/icon0.png differ
diff --git a/apps/notifications/psp/octane/pic1.png b/apps/notifications/psp/octane/pic1.png
new file mode 100644
index 00000000..88954072
Binary files /dev/null and b/apps/notifications/psp/octane/pic1.png differ
diff --git a/apps/settings/app.octane.tsx b/apps/settings/app.octane.tsx
index cf62e1ac..c618adf5 100644
--- a/apps/settings/app.octane.tsx
+++ b/apps/settings/app.octane.tsx
@@ -112,10 +112,14 @@ function themeByName(name: ThemeName): ThemeOption {
return THEMES.find((t) => t.name === name) ?? THEMES[0];
}
+// The switch owns its value outright. Mirroring it up to Settings would cost
+// a whole-root replay per press — octane can only scope a replay to an owner
+// that holds a committed range, and the root component has none — and nothing
+// up there reads it.
const Toggle = (
- props: { label: string; value: boolean; themeName: ThemeName; onToggle: () => void },
+ props: { label: string; initial: boolean; themeName: ThemeName },
) => {
- const [current, setCurrent] = useState(props.value);
+ const [current, setCurrent] = useState(props.initial);
const knob = useRef(null);
const initialized = useRef(false);
const palette = themeByName(props.themeName);
@@ -138,7 +142,6 @@ const Toggle = (
focusable
onPress={() => {
setCurrent(!current);
- props.onToggle();
}}
>
{props.label}
@@ -236,8 +239,8 @@ const ThemeRow = (
};
export default function Settings() {
- const [sfx, setSfx] = useState(true);
- const [vibration, setVibration] = useState(false);
+ // Only the theme lives up here, and it earns it: every row restyles when it
+ // changes, so that press is a genuine whole-tree render.
const [theme, setTheme] = useState("indigo");
const currentTheme = themeByName(theme);
@@ -252,8 +255,8 @@ export default function Settings() {
- setSfx(!sfx)} />
- setVibration(!vibration)} />
+
+
setTheme(next)} />
diff --git a/apps/settings/psp/octane/Psp.toml b/apps/settings/psp/octane/Psp.toml
new file mode 100644
index 00000000..f3fa7913
--- /dev/null
+++ b/apps/settings/psp/octane/Psp.toml
@@ -0,0 +1,7 @@
+# XMB metadata for the PocketJS Settings (Octane) EBOOT. tools/psp.ts copies this to
+# hosts/psp/Psp.toml when building this demo with --framework=octane; cargo-psp packs
+# it into PARAM.SFO / ICON0 / PIC1.
+# Regenerate the art with: bun tools/gen-demo-covers.ts --framework=octane settings
+title = "PocketJS Settings (Octane)"
+xmb_icon_png = "icon0.png"
+xmb_background_png = "pic1.png"
diff --git a/apps/settings/psp/octane/icon0.png b/apps/settings/psp/octane/icon0.png
new file mode 100644
index 00000000..426b3a14
Binary files /dev/null and b/apps/settings/psp/octane/icon0.png differ
diff --git a/apps/settings/psp/octane/pic1.png b/apps/settings/psp/octane/pic1.png
new file mode 100644
index 00000000..aa28be18
Binary files /dev/null and b/apps/settings/psp/octane/pic1.png differ
diff --git a/apps/stats/psp/octane/Psp.toml b/apps/stats/psp/octane/Psp.toml
new file mode 100644
index 00000000..90f4368f
--- /dev/null
+++ b/apps/stats/psp/octane/Psp.toml
@@ -0,0 +1,7 @@
+# XMB metadata for the PocketJS Stats (Octane) EBOOT. tools/psp.ts copies this to
+# hosts/psp/Psp.toml when building this demo with --framework=octane; cargo-psp packs
+# it into PARAM.SFO / ICON0 / PIC1.
+# Regenerate the art with: bun tools/gen-demo-covers.ts --framework=octane stats
+title = "PocketJS Stats (Octane)"
+xmb_icon_png = "icon0.png"
+xmb_background_png = "pic1.png"
diff --git a/apps/stats/psp/octane/icon0.png b/apps/stats/psp/octane/icon0.png
new file mode 100644
index 00000000..c97fcf35
Binary files /dev/null and b/apps/stats/psp/octane/icon0.png differ
diff --git a/apps/stats/psp/octane/pic1.png b/apps/stats/psp/octane/pic1.png
new file mode 100644
index 00000000..d1677cce
Binary files /dev/null and b/apps/stats/psp/octane/pic1.png differ
diff --git a/bun.lock b/bun.lock
index 96612e21..08fb8cc8 100644
--- a/bun.lock
+++ b/bun.lock
@@ -7,7 +7,7 @@
"dependencies": {
"@vue/compiler-sfc": "3.6.0-rc.1",
"@vue/compiler-vapor": "3.6.0-rc.1",
- "octane": "0.1.18",
+ "octane": "0.1.26",
"solid-js": "^1.9",
"vue": "3.6.0-rc.1",
"vue-jsx-vapor": "3.2.19",
@@ -247,7 +247,7 @@
"@tailwindcss/typography": ["@tailwindcss/typography@0.5.20", "", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || >=4.0.0 || insiders" } }, "sha512-hwbzQuNUfcPvbegQFatVPl/MY/tcM9KLl963hQ5laJKPh81TEZ1+dNG9PirGvcaDBkp+BCshExAyKVPW91dozw=="],
- "@tsrx/core": ["@tsrx/core@0.1.54", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5", "@noble/hashes": "^2.2.0", "@sveltejs/acorn-typescript": "^1.0.11", "@types/estree": "^1.0.8", "@types/estree-jsx": "^1.0.5", "acorn": "^8.17.0", "esrap": "^2.3.0", "is-reference": "^3.0.3", "magic-string": "^0.30.18", "zimmerframe": "^1.1.2" } }, "sha512-DbPskFFyJyako9pIRZDwipM/zIKDrfUcsHsGP8KHK5+Z2yY+tR2PUki+ThgnUA3+LqZo4Khc+07S62tFRX3TeQ=="],
+ "@tsrx/core": ["@tsrx/core@0.1.56", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5", "@noble/hashes": "^2.2.0", "@sveltejs/acorn-typescript": "^1.0.11", "@types/estree": "^1.0.8", "@types/estree-jsx": "^1.0.5", "acorn": "^8.17.0", "esrap": "^2.3.0", "is-reference": "^3.0.3", "magic-string": "^0.30.18", "zimmerframe": "^1.1.2" } }, "sha512-SZ+lani1n0yR4pFqOxclfWvU4Pvd3i9d6aptShicAzC3IYW2yQrsNJoG5ZqoVai9A3ZRikkO4CY8/HK+ksNp1Q=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
@@ -385,6 +385,8 @@
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
+ "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
+
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"esrap": ["esrap@2.3.0", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-GQ/7RN8uOtEfNpzZzBMTzW9JBcX42oaSVtPzdF+6cEL8pqIL094iUpr9jzYGn4O4P/1S60dJ6izyT8F4LYARng=="],
@@ -481,7 +483,7 @@
"node-releases": ["node-releases@2.0.50", "", {}, "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg=="],
- "octane": ["octane@0.1.18", "", { "dependencies": { "@tsrx/core": "^0.1.54", "@types/react": "^19.2.17", "devalue": "^5.8.2", "esrap": "^2.3.0" }, "peerDependencies": { "react": "^19.0.0", "react-dom": "^19.0.0", "vite": "^8.0.16" }, "optionalPeers": ["react", "react-dom", "vite"] }, "sha512-LxqPEXBcjYPbaOPKneb2J9s3wRTKOrNPKOlNsEyN1iJuuabrTIXNFA0xQd6smaOYLCOGWDv/3NpD50NiX94V1g=="],
+ "octane": ["octane@0.1.26", "", { "dependencies": { "@tsrx/core": "^0.1.56", "@types/react": "^19.2.17", "devalue": "^5.8.2", "es-module-lexer": "^1.7.0", "esrap": "^2.3.0" }, "peerDependencies": { "react": "^19.0.0", "react-dom": "^19.0.0", "vite": "^8.0.16" }, "optionalPeers": ["react", "react-dom", "vite"] }, "sha512-StqvnF8qSqIzNQoikPyrYlFBulUgxY7d9An+JptrT/JgZi/sxoygby0iik0EtikNN6RLleeqr/ebS08pxT5NFA=="],
"oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="],
diff --git a/package.json b/package.json
index 438690fe..6a15b57e 100644
--- a/package.json
+++ b/package.json
@@ -172,7 +172,7 @@
"dependencies": {
"@vue/compiler-sfc": "3.6.0-rc.1",
"@vue/compiler-vapor": "3.6.0-rc.1",
- "octane": "0.1.18",
+ "octane": "0.1.26",
"solid-js": "^1.9",
"vue": "3.6.0-rc.1",
"vue-jsx-vapor": "3.2.19"
diff --git a/tools/gen-demo-covers.ts b/tools/gen-demo-covers.ts
index 87aac48d..7a635349 100644
--- a/tools/gen-demo-covers.ts
+++ b/tools/gen-demo-covers.ts
@@ -8,13 +8,21 @@
// stays legible. Writes apps//psp/{Psp.toml,icon0.png,pic1.png};
// tools/psp.ts picks the fragment up for EVERY framework build of the demo.
//
-// bun tools/gen-demo-covers.ts (all demos)
-// bun tools/gen-demo-covers.ts hero music (a subset)
+// --framework= bakes a VARIANT set into apps//psp// instead:
+// same layout, plus the framework's name on the tile, in the PIC1 corner and
+// in the XMB title, so a demo's framework twins are distinguishable on a
+// memory stick that holds several of them. tools/psp.ts prefers the variant
+// directory when building that framework.
+//
+// bun tools/gen-demo-covers.ts (all demos, default fw)
+// bun tools/gen-demo-covers.ts hero music (a subset)
+// bun tools/gen-demo-covers.ts --framework=octane (the Octane twins)
import { createCanvas, GlobalFonts, type SKRSContext2D } from "@napi-rs/canvas";
import { mkdirSync } from "node:fs";
import { runScenario } from "../hosts/sim/sim.ts";
import { BTN } from "../contracts/spec/spec.ts";
+import { FRAMEWORKS, parseFramework } from "../framework/compiler/jsx-plugin.ts";
const ROOT = new URL("../", import.meta.url).pathname;
GlobalFonts.registerFromPath(ROOT + "assets/fonts/Inter-Bold.ttf", "Inter");
@@ -213,12 +221,22 @@ const DEMOS: DemoCover[] = [
},
];
-const only = new Set(Bun.argv.slice(2));
+const args = Bun.argv.slice(2);
+const frameworkArg = args.find((a) => a.startsWith("--framework="));
+// No flag = the shared, framework-neutral cover set the default build uses.
+const framework = frameworkArg
+ ? parseFramework(frameworkArg.slice("--framework=".length), "--framework")
+ : null;
+const frameworkLabel = framework === null ? null : FRAMEWORKS[framework].label;
+const only = new Set(args.filter((a) => !a.startsWith("--")));
const selected = only.size === 0 ? DEMOS : DEMOS.filter((d) => only.has(d.dir));
if (selected.length === 0) throw new Error(`no demos match: ${[...only].join(", ")}`);
for (const demo of selected) {
- const out = `${ROOT}apps/${demo.dir}/psp/`;
+ const out =
+ framework === null
+ ? `${ROOT}apps/${demo.dir}/psp/`
+ : `${ROOT}apps/${demo.dir}/psp/${framework}/`;
mkdirSync(out, { recursive: true });
// ICON0 — 144×80 family tile: mark left, two-line wordmark right, accent rule.
@@ -238,13 +256,19 @@ for (const demo of selected) {
g.fillText(demo.word[1], 66, 55);
g.fillStyle = demo.accent;
g.fillRect(67, 61, 26, 2);
+ if (frameworkLabel !== null) {
+ // The whole point of the variant set: name the framework on the tile so
+ // two builds of one demo are not the same icon in the XMB.
+ g.font = "bold 10px Inter";
+ g.fillText(frameworkLabel.toUpperCase(), 67, 75);
+ }
await Bun.write(out + "icon0.png", c.toBuffer("image/png"));
}
// PIC1 — a real frame of the demo via the sim pump the goldens use.
{
const trace = await runScenario({
- app: demo.bundle,
+ app: framework === null ? demo.bundle : demo.bundle + FRAMEWORKS[framework].outputSuffix,
hz: 60,
seconds: demo.seconds,
script: demo.script,
@@ -261,17 +285,42 @@ for (const demo of selected) {
grad.addColorStop(0.55, "rgba(0,0,0,0)");
g.fillStyle = grad;
g.fillRect(0, 0, 480, 272);
+ if (frameworkLabel !== null) {
+ // Bottom-right, clear of the XMB's left column and its bottom text row.
+ const text = frameworkLabel.toUpperCase();
+ g.font = "bold 13px Inter";
+ const w = g.measureText(text).width;
+ roundRect(g, 480 - 22 - w - 20, 272 - 46, w + 20, 24, 12);
+ g.fillStyle = "rgba(8,14,20,0.72)";
+ g.fill();
+ g.strokeStyle = demo.accent;
+ g.lineWidth = 1;
+ g.stroke();
+ g.fillStyle = demo.accent;
+ g.fillText(text, 480 - 22 - w - 10, 272 - 29);
+ }
await Bun.write(out + "pic1.png", c.toBuffer("image/png"));
}
- const toml = `# XMB metadata for the ${demo.title} EBOOT. tools/psp.ts copies this to
-# hosts/psp/Psp.toml when building this demo (any framework); cargo-psp packs
+ const title = frameworkLabel === null ? demo.title : `${demo.title} (${frameworkLabel})`;
+ const scope =
+ framework === null
+ ? "when building this demo (any framework)"
+ : `when building this demo with --framework=${framework}`;
+ const regen =
+ framework === null
+ ? `bun tools/gen-demo-covers.ts ${demo.dir}`
+ : `bun tools/gen-demo-covers.ts --framework=${framework} ${demo.dir}`;
+ const toml = `# XMB metadata for the ${title} EBOOT. tools/psp.ts copies this to
+# hosts/psp/Psp.toml ${scope}; cargo-psp packs
# it into PARAM.SFO / ICON0 / PIC1.
-# Regenerate the art with: bun tools/gen-demo-covers.ts ${demo.dir}
-title = "${demo.title}"
+# Regenerate the art with: ${regen}
+title = "${title}"
xmb_icon_png = "icon0.png"
xmb_background_png = "pic1.png"
`;
await Bun.write(out + "Psp.toml", toml);
- console.log(`covers: apps/${demo.dir}/psp/ (icon0 144x80, pic1 480x272, "${demo.title}")`);
+ console.log(
+ `covers: ${out.slice(ROOT.length)} (icon0 144x80, pic1 480x272, "${title}")`,
+ );
}
diff --git a/tools/psp.ts b/tools/psp.ts
index 81f4f577..e265579f 100644
--- a/tools/psp.ts
+++ b/tools/psp.ts
@@ -173,9 +173,16 @@ cargoArgs.push("--bin", "pocketjs-psp");
// another app's EBOOT. hosts/psp/Psp.toml is build output (gitignored).
// ---------------------------------------------------------------------------
const GENERATED_MARK = "# GENERATED by tools/psp.ts";
-const fragmentHome = buildPlan
+const fragmentBase = buildPlan
? resolvePath(projectRoot, buildPlan.app.entry, "..", "psp")
: `${pspUiDir}apps/${app.replace(/-main$/, "")}/psp`;
+// A demo built through a non-default framework may ship its own cover set so
+// the XMB tells the twins apart (/psp//); the shared fragment
+// one level up stays the default when there is no such variant.
+const frameworkFragmentHome = `${fragmentBase}/${framework}`;
+const fragmentHome = existsSync(`${frameworkFragmentHome}/Psp.toml`)
+ ? frameworkFragmentHome
+ : fragmentBase;
const xmbFragment = `${fragmentHome}/Psp.toml`;
const xmbFragmentLabel = xmbFragment.startsWith(pspUiDir)
? xmbFragment.slice(pspUiDir.length)