Skip to content
Open
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
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ apps/launcher/covers/
hosts/web/pocketjs.wasm
# generated per-demo XMB metadata (tools/psp.ts, from apps/<app>/psp/Psp.toml)
hosts/psp/Psp.toml
# generated Switch host inputs and devkitPro outputs
hosts/switch/romfs/
hosts/switch/build/
hosts/switch/pocketjs-switch.elf
hosts/switch/pocketjs-switch.map
hosts/switch/pocketjs-switch.nacp
hosts/switch/pocketjs-switch.nro
# Obsolete checkout-local SDK location; bootstrap uses the shared cache.
mipsel-sony-psp/
# golden-mismatch dumps written by tests/golden.ts
Expand Down
28 changes: 20 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,13 @@ animation under an 8 MB memory budget. Write Solid JSX, Vue Vapor JSX, or Vue
single-file components, run them on QuickJS, and let PocketJS move layout,
styling, text and animation into a tiny `no_std` Rust core.

It runs on real PSP and PS Vita hardware, PPSSPP, Vita3K, the browser (WASM),
native macOS windows (wgpu) and headless Bun. Full design + contracts:
[docs/DESIGN.md](./DESIGN.md). PocketJS is growing into a family of specialized
It runs on real PSP and PS Vita hardware, Nintendo Switch homebrew in Ryujinx,
PPSSPP, Vita3K, the browser (WASM), native macOS windows (wgpu), and headless
Bun. Full design + contracts:
[docs/DESIGN.md](./docs/DESIGN.md). PocketJS is growing into a family of specialized
runtimes — Rust cores, spec-pinned surfaces, one QuickJS guest — documented
in [docs/RUNTIMES.md](./RUNTIMES.md); the 3D base lives in
[engine/pocket3d/](./pocket3d/), and its first game runtime is
in [docs/RUNTIMES.md](./docs/RUNTIMES.md); the 3D base lives in
[engine/pocket3d/](./engine/pocket3d/), and its first game runtime is
[OpenStrike](https://github.com/pocket-stack/open-strike).

## Screenshots
Expand Down Expand Up @@ -51,9 +52,9 @@ Or drive everything through the [`pocket` CLI](https://www.npmjs.com/package/@po
`npm i -g @pocketjs/cli`, then `pocket doctor` checks the Bun / Rust / PSP
toolchain (`pocket setup` runs the same pinned bootstrap), `pocket create <name>`
scaffolds a format-2 manifest, and `pocket check|compile|build --target
psp|vita` delegate to the canonical resolver. Low-level host-development
commands such as `pocket dev`, `pocket psp`, `pocket vita`, and `pocket play`
remain available.
psp|vita|switch` delegate to the canonical resolver. Low-level host-development
commands such as `pocket dev`, `pocket psp`, `pocket vita`, `pocket switch`, and
`pocket play` remain available.

The build is two-pass: pass 1 transforms every module reachable from the entry
(framework-specific JSX + TypeScript, or Vue SFC compiled directly to Vapor;
Expand Down Expand Up @@ -146,16 +147,20 @@ required router package.
bun run bootstrap # idempotent PSP toolchain setup
bun play vita hero # build, install and launch in Vita3K
bun play vita gallery --fullscreen # stretch to the host's full screen
bun play switch hero # build and launch the NRO in Ryujinx
bun play --help # list every runnable demo
bun run test # spec contract + tailwind parser tests
bun pocket check --target psp # validate pocket.json + resolved target contract
bun pocket compile --target psp # typecheck and compile, for custom native hosts
bun pocket build --target psp # typecheck, compile, and package the target
pocket build --target vita -- --release
pocket build --target switch -- --release
pocket play vita hero # build, install and launch in Vita3K
pocket play switch hero # build and launch in Ryujinx
bun tools/build.ts <app> [--framework=solid|vue-vapor] [--extra-chars=…]
bun run psp <app> # low-level PSP demo build
bun run vita <app> # low-level Vita demo build
bun run switch <app> --release # low-level Nintendo Switch NRO build
bun run dev [app] # browser dev host
bun run wasm # rebuild the wasm core
bun run e2e:vita # Vita3K, native-density 960x544 golden E2E
Expand Down Expand Up @@ -241,6 +246,13 @@ fonts, vectors and core masks at Vita's native 960x544 density. Physical
controls, left-analog input, and front-panel multi-touch snapshots are supported;
PSP builds retain their controller-only fallback.

The Nintendo Switch homebrew host is documented in
[hosts/switch/README.md](./hosts/switch/README.md). It runs the same QuickJS
Solid and Vue Vapor guests, renders the density-2 surface into a centered
960x544 framebuffer region, and packages manifest metadata plus RomFS assets as
an NRO. Its devkitPro, Rust, QuickJS/newlib, and Ryujinx requirements live with
the host rather than in the general framework setup.

## The Pocket Launcher (on-device app switching)

One PSP EBOOT or Vita VPK can embed every app admitted by that target plus a
Expand Down
18 changes: 18 additions & 0 deletions contracts/spec/platforms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ export type PocketCapabilityId = CapabilityId<typeof POCKET_CAPABILITIES>;
export const POCKET_TARGETS = defineTargetRegistry<PocketCapabilityId, {
readonly psp: TargetProfile<PocketCapabilityId>;
readonly vita: TargetProfile<PocketCapabilityId>;
readonly switch: TargetProfile<PocketCapabilityId>;
readonly pocketbook: TargetProfile<PocketCapabilityId>;
readonly "macos-widget": TargetProfile<PocketCapabilityId>;
}>({
Expand Down Expand Up @@ -202,6 +203,23 @@ export const POCKET_TARGETS = defineTargetRegistry<PocketCapabilityId, {
"text.glyphs.baked",
],
},
switch: {
hostAbi: 4,
platform: "switch",
form: "takeover",
display: {
physicalViewport: [1280, 720],
logicalViewports: [[480, 272]],
presentations: ["integer-fit"],
rasterDensity: 2,
},
capabilities: [
"input.analog.left",
"input.buttons",
"input.cursor",
"text.glyphs.baked",
],
},
// PocketBook e-readers (inkview): hosts/pocketbook reuses the backend-
// agnostic ui surface + the core software rasterizer. Same logical viewport
// and density as vita (480×272 @2x → a 960×544 render). physicalViewport is
Expand Down
1 change: 1 addition & 0 deletions docs/RUNTIMES.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ The grammar is implemented once, as infrastructure every runtime reuses:
| `pocketjs-psp` (lib) | Guest hosting + `ui` surface, PSP edition: the arena allocator, the QuickJS embedding, the DrawList GE backend (with an overlay mode for 3D compositing), pak feeding, and the DevTools mailbox — everything the 2D EBOOT proved, linkable by game EBOOTs. |
| `pocket3d-vita` | The 3D substrate, Vita edition: CPU projection and six-plane clipping into vita2d/GXM at 960x544, painter-sorted so a PocketJS HUD can share the same scene. |
| `pocketjs-vita` (lib) | Guest hosting + `ui` surface, Vita edition: QuickJS, density-2 pak/font resources, controller/dual-analog input, logical-coordinate front-panel contacts and a native-density 960x544 vita2d backend over the portable 480x272 logical layout. |
| `pocketjs-switch` (lib + libnx shell) | Guest hosting + `ui` surface, Nintendo Switch homebrew edition: a libnx shell owns RomFS, controller input and the 1280x720 framebuffer; a Rust static library owns QuickJS, the shared native `HostOps`, pak feeding and the deterministic density-2 software rasterizer, centered as a 960x544 surface. |

A specialized runtime is then a thin composition. OpenStrike is:

Expand Down
12 changes: 8 additions & 4 deletions docs/STRUCTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ pocketjs/
├─ hosts/ Surfaces: every embedding of the cores
│ ├─ psp/ QuickJS + rust-psp EBOOT host
│ ├─ vita/ Vita host
│ ├─ switch/ QuickJS + libnx/Rust NRO host
│ ├─ esp32p4/ reusable ESP-IDF PPA adapter + component smoke build
│ ├─ pocketbook/ PocketBook e-reader host (inkview, standalone lone-bin crate)
│ ├─ symbian/ Nokia E7 Qt/QuickJS runtime + visible toolchain probe
Expand All @@ -28,7 +29,7 @@ pocketjs/
├─ framework/ Guest: @pocketjs/framework
│ ├─ src/ the TS runtime (Solid + Vue Vapor renderers, components, input, osk…)
│ └─ compiler/ the interpreted-path build pipeline (jsx-plugin, tailwind, pak)
├─ vapor/ Pocket Vapor: the AOT compiler family (Vue Vapor subset → GBA/GB/NES)
├─ vapor/ Pocket Vapor: the AOT compiler family (Vue Vapor subset → GBA/GB/NES/ESP32)
├─ contracts/ single sources of truth binding the layers
│ ├─ spec/ op contract, platform contracts, manifest + package spec, gen-rust
│ └─ schema/ published JSON schemas (pocket-2.json)
Expand All @@ -52,8 +53,11 @@ New things go where the axis says — never invent a top-level directory:
- **A new Rust simulation core** → `engine/` (workspace member if it builds on
desktop; excluded standalone crate if it needs a console toolchain).
- **A new platform embedding** (ESP32, 3DS, …) → `hosts/<platform>/`.
- **A new AOT backend** (Vapor gains a console) → `vapor/runtime/<console>/`;
the vapor compiler grows a target entry, the top level does not change.
- **A new AOT backend** (Pocket Vapor gains a device) →
`vapor/runtime/<device>/`; the vapor compiler grows a target entry, the top
level does not change. A QuickJS guest host such as `hosts/switch/` does not
imply an AOT backend: Vue Vapor guests already use the shared framework
runtime and `HostOps`.
- **A new demo** → `apps/<name>/` with a `pocket.json`. Standalone products
keep the `pocket-<name>` separate-repo convention and do not move in.
- **A new command** → `tools/<name>.ts`. No single-file top-level directories.
Expand All @@ -67,6 +71,6 @@ New things go where the axis says — never invent a top-level directory:
change; the `exports`/`files` maps in package.json absorb internal moves.
- **Cargo stays non-workspace where toolchains demand it**: `engine/core`,
`engine/wasm`, `engine/symbian`, `engine/backends/esp32p4-ppa`, `hosts/psp`,
`hosts/vita`, `hosts/pocketbook`, and the gu/vita 3D crates each stand alone
`hosts/vita`, `hosts/switch`, `hosts/pocketbook`, and the gu/vita 3D crates each stand alone
with their own lockfiles. `engine/Cargo.toml` is the one desktop workspace.
- **Moves are `git mv`** — history stays traceable.
7 changes: 6 additions & 1 deletion framework/src/manifest/host-build-inputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { verifyPlanHash, type ResolvedBuildPlan } from "./plan.ts";
/** Stable subset of the internal build plan consumed by custom native hosts. */
export interface HostBuildInputs {
readonly appOutput: string;
readonly appTitle: string;
readonly appVersion: string;
readonly target: string;
readonly hostAbi: number;
readonly viewport: {
Expand Down Expand Up @@ -42,7 +44,8 @@ function hasHostInputShape(input: unknown): input is ResolvedBuildPlan {
if (!isRecord(input.viewport) || !isRecord(input.features)) return false;
if (
typeof input.app.id !== "string" || input.app.id.length === 0 ||
typeof input.app.title !== "string" || input.app.title.length === 0
typeof input.app.title !== "string" || input.app.title.length === 0 ||
typeof input.app.version !== "string" || input.app.version.length === 0
) return false;
if (typeof input.app.output !== "string" || input.app.output.length === 0) return false;
if (typeof input.target.id !== "string" || input.target.id.length === 0) return false;
Expand Down Expand Up @@ -91,6 +94,8 @@ export function extractHostBuildInputs(
}
return {
appOutput: plan.app.output,
appTitle: plan.app.title,
appVersion: plan.app.version,
target: plan.target.id,
hostAbi: plan.target.hostAbi,
viewport: {
Expand Down
2 changes: 1 addition & 1 deletion framework/src/manifest/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { PocketManifestV2 } from "../../../contracts/spec/pocket-manifest.t
import type { PresentationMode, Viewport } from "../../../contracts/spec/platforms.ts";

export interface ResolvedBuildPlanContent {
readonly app: Pick<PocketManifestV2, "id" | "title"> &
readonly app: Pick<PocketManifestV2, "id" | "title" | "version"> &
Pick<PocketManifestV2["app"], "entry" | "framework"> & {
readonly output: string;
};
Expand Down
10 changes: 6 additions & 4 deletions framework/src/manifest/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,13 +157,14 @@ function resolveViewport(
ok = false;
}
if (presentation === "integer-fit") {
const x = physicalViewport[0] / logical[0];
const y = physicalViewport[1] / logical[1];
if (!Number.isInteger(x) || x < 1 || x !== y) {
const scale = Math.floor(
Math.min(physicalViewport[0] / logical[0], physicalViewport[1] / logical[1]),
);
if (scale < 1) {
diagnostics.push({
code: "viewport.integerFitMismatch",
path: fixedPath,
message: "integer-fit requires one positive integer scale on both axes",
message: "integer-fit requires the logical viewport to fit at a positive integer scale",
});
ok = false;
}
Expand Down Expand Up @@ -348,6 +349,7 @@ export function resolveBuildPlan(
app: {
id: manifest.id,
title: manifest.title,
version: manifest.version,
entry: manifest.app.entry,
output,
framework: manifest.app.framework,
Expand Down
19 changes: 10 additions & 9 deletions hosts/vita/src/ffi.rs → hosts/native/ffi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ static mut UI: Option<Ui> = None;
///
/// # Safety
///
/// Call once per guest on the Vita render thread, with no outstanding reference
/// Call once per guest on the host render thread, with no outstanding reference
/// to the process-global UI.
pub unsafe fn init_ui() -> &'static mut Ui {
let mut instance = Ui::new_with_raster_density(crate::graphics::RASTER_DENSITY);
Expand All @@ -45,7 +45,7 @@ pub unsafe fn init_ui() -> &'static mut Ui {
///
/// # Safety
///
/// The caller must stay on the Vita render thread and must not create another
/// The caller must stay on the host render thread and must not create another
/// live mutable reference to the global UI.
pub unsafe fn ui() -> &'static mut Ui {
UI.as_mut().expect("ffi::init_ui not called")
Expand Down Expand Up @@ -612,8 +612,8 @@ unsafe extern "C" fn js_debug_step(
JS_UNDEFINED
}

/// ui.__dbgActive() -> bool: whether the PSPLINK/memstick mailbox was found
/// at boot (dbg::init). The JS shim only builds a transport when true.
/// ui.__dbgActive() -> bool: whether the debug mailbox was found at boot
/// (dbg::init). The JS shim only builds a transport when true.
unsafe extern "C" fn js_dbg_active(
ctx: *mut JSContext,
_this: JSValue,
Expand All @@ -625,7 +625,7 @@ unsafe extern "C" fn js_dbg_active(

/// ui.__dbgPoll() -> string | undefined: new complete JSON lines from the
/// bridge (may batch several). The shim rate-limits calls to ~every 10
/// frames; each call is a few sceIo round trips over usbhostfs.
/// frames; each call is a few round trips over the debug bridge.
unsafe extern "C" fn js_dbg_poll(
ctx: *mut JSContext,
_this: JSValue,
Expand Down Expand Up @@ -797,17 +797,18 @@ pub unsafe fn register(
add_fn(ctx, ui_obj, b"__dbgPoll\0", js_dbg_poll, 0);
add_fn(ctx, ui_obj, b"__dbgSend\0", js_dbg_send, 1);
add_fn(ctx, ui_obj, b"__dbgShot\0", js_dbg_shot, 0);
// Optional launcher surface. Single-app VPKs omit these ops and preserve
// Optional launcher surface. Single-app builds omit these ops and preserve
// the framework's documented degraded behavior.
if crate::switch::multi() {
add_fn(ctx, ui_obj, b"appTable\0", js_app_table, 0);
add_fn(ctx, ui_obj, b"appLaunch\0", js_app_launch, 1);
add_fn(ctx, ui_obj, b"appShot\0", js_app_shot, 0);
}

// Framework-owned host identity. The bundle rejects a VPK assembled for a
// different target or HostOps ABI before app code mounts. planHash is a
// build-time checksum and intentionally does not enter runtime handshake.
// Framework-owned host identity. The bundle rejects a package assembled
// for a different target or HostOps ABI before app code mounts. planHash
// is a build-time checksum and intentionally does not enter runtime
// handshake.
let target = env!("POCKETJS_TARGET");
JS_SetPropertyStr(
ctx,
Expand Down
8 changes: 4 additions & 4 deletions hosts/vita/src/pak.rs → hosts/native/pak.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,11 +132,11 @@ pub fn feed(ui: &mut Ui, pak: &[u8]) -> (Vec<(String, i32)>, Vec<SpriteReg>) {
};
if key == "ui:styles" {
if !ui.load_styles(blob) {
crate::vita_log(format_args!("[PocketJS pak] bad styles.bin"));
crate::host_log(format_args!("[PocketJS pak] bad styles.bin"));
}
} else if key.starts_with("ui:font.") {
if !ui.load_font_atlas(blob) {
crate::vita_log(format_args!("[PocketJS pak] bad font atlas {}", key));
crate::host_log(format_args!("[PocketJS pak] bad font atlas {}", key));
} else {
let slot = blob.get(12).copied().unwrap_or(0);
if let Some(atlas) = ui.font_atlas(slot) {
Expand All @@ -162,7 +162,7 @@ pub fn feed(ui: &mut Ui, pak: &[u8]) -> (Vec<(String, i32)>, Vec<SpriteReg>) {
crate::graphics::register_texture(ui, handle);
textures.push((String::from(name), handle));
} else {
crate::vita_log(format_args!(
crate::host_log(format_args!(
"[PocketJS pak] bad image {} ({}x{} psm {})",
key, w, h, psm
));
Expand Down Expand Up @@ -196,7 +196,7 @@ pub fn feed(ui: &mut Ui, pak: &[u8]) -> (Vec<(String, i32)>, Vec<SpriteReg>) {
step,
});
} else {
crate::vita_log(format_args!(
crate::host_log(format_args!(
"[PocketJS pak] bad sprite {} ({}x{} psm {})",
key, w, h, psm
));
Expand Down
Loading