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)