Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions apps/cards/psp/octane/Psp.toml
Original file line number Diff line number Diff line change
@@ -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"
Binary file added apps/cards/psp/octane/icon0.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/cards/psp/octane/pic1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 7 additions & 0 deletions apps/gallery/psp/octane/Psp.toml
Original file line number Diff line number Diff line change
@@ -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"
Binary file added apps/gallery/psp/octane/icon0.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/gallery/psp/octane/pic1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
55 changes: 37 additions & 18 deletions apps/hero/app.octane.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => {
Expand All @@ -20,8 +20,35 @@ const Spinner = () => {
return <Sprite class="w-10 h-10" sprite="spinner-atlas.svg" />;
};

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 (
<View class="flex-row items-center gap-4">
<View
class="px-4 py-2 rounded-xl shadow-md bg-blue-600 border-blue-500 focus:bg-blue-500 active:bg-blue-700 transition-colors duration-150"
focusable
onPress={() => {
const next = count + 1;
setCount(next);
props.onCount(next);
}}
>
<Text class="text-base text-white font-bold">Press Circle</Text>
</View>
<Text class="text-sm text-slate-600">{`Count: ${count}`}</Text>
{count > 3 ? (
<Text class="text-sm text-emerald-600">Reactive on real hardware.</Text>
) : null}
</View>
);
};

export default function Hero() {
const underline = useRef<NodeMirror | null>(null);

useLayoutEffect(() => {
Expand Down Expand Up @@ -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 }}
/>
<Text class="text-sm text-slate-600">
Flexbox, springs and baked type - running through Octane.
</Text>
</View>

<View class="flex-row items-center gap-4">
<View
class="px-4 py-2 rounded-xl shadow-md bg-blue-600 border-blue-500 focus:bg-blue-500 active:bg-blue-700 transition-colors duration-150"
focusable
onPress={() => {
setCount(count + 1);
}}
>
<Text class="text-base text-white font-bold">Press Circle</Text>
</View>
<Text class="text-sm text-slate-600">{`Count: ${count}`}</Text>
{count > 3 ? (
<Text class="text-sm text-emerald-600">Reactive on real hardware.</Text>
) : null}
</View>
<CounterRow
onCount={(next: number) => {
if (underline.current) jump(underline.current, "translateX", next * 2);
}}
/>
</View>
);
}
7 changes: 7 additions & 0 deletions apps/hero/psp/octane/Psp.toml
Original file line number Diff line number Diff line change
@@ -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"
Binary file added apps/hero/psp/octane/icon0.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/hero/psp/octane/pic1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 7 additions & 0 deletions apps/library/psp/octane/Psp.toml
Original file line number Diff line number Diff line change
@@ -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"
Binary file added apps/library/psp/octane/icon0.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/library/psp/octane/pic1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 7 additions & 0 deletions apps/music/psp/octane/Psp.toml
Original file line number Diff line number Diff line change
@@ -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"
Binary file added apps/music/psp/octane/icon0.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/music/psp/octane/pic1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
71 changes: 36 additions & 35 deletions apps/notifications/app.octane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,55 +70,56 @@ function NoticeRow(props: NoticeRowProps) {

export default function Notifications() {
const [items, setItems] = useState<Notice[]>([...INITIAL]);
const [dismissingId, setDismissingId] = useState<string | null>(null);
const [riseOffsets, setRiseOffsets] = useState<Record<string, number>>({});
const rowRefs = useRef(new Map<string, NodeMirror>());
// 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<string[]>([]);
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<string | null>(null);
const risingIds = useRef<string[]>([]);
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" });
Expand All @@ -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);
}}
Expand Down
7 changes: 7 additions & 0 deletions apps/notifications/psp/octane/Psp.toml
Original file line number Diff line number Diff line change
@@ -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"
Binary file added apps/notifications/psp/octane/icon0.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/notifications/psp/octane/pic1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
17 changes: 10 additions & 7 deletions apps/settings/app.octane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<NodeMirror | null>(null);
const initialized = useRef(false);
const palette = themeByName(props.themeName);
Expand All @@ -138,7 +142,6 @@ const Toggle = (
focusable
onPress={() => {
setCurrent(!current);
props.onToggle();
}}
>
<Text class={palette.rowLabelCls}>{props.label}</Text>
Expand Down Expand Up @@ -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<ThemeName>("indigo");
const currentTheme = themeByName(theme);

Expand All @@ -252,8 +255,8 @@ export default function Settings() {
</View>

<View class="flex-col gap-2">
<Toggle label="SOUND EFFECTS" value={sfx} themeName={theme} onToggle={() => setSfx(!sfx)} />
<Toggle label="VIBRATION" value={vibration} themeName={theme} onToggle={() => setVibration(!vibration)} />
<Toggle label="SOUND EFFECTS" initial={true} themeName={theme} />
<Toggle label="VIBRATION" initial={false} themeName={theme} />
<Brightness themeName={theme} />
<ThemeRow value={theme} themeName={theme} onPick={(next: ThemeName) => setTheme(next)} />
</View>
Expand Down
7 changes: 7 additions & 0 deletions apps/settings/psp/octane/Psp.toml
Original file line number Diff line number Diff line change
@@ -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"
Binary file added apps/settings/psp/octane/icon0.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/settings/psp/octane/pic1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 7 additions & 0 deletions apps/stats/psp/octane/Psp.toml
Original file line number Diff line number Diff line change
@@ -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"
Binary file added apps/stats/psp/octane/icon0.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/stats/psp/octane/pic1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 5 additions & 3 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading