From ec0499e51615967f2bcfd163b295b4c1d1fde34c Mon Sep 17 00:00:00 2001
From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com>
Date: Sat, 25 Jul 2026 14:03:12 +0800
Subject: [PATCH 1/6] feat(symbian): add Nokia E7 compatibility app
---
Cargo.lock | 13 +-
Cargo.toml | 10 +-
README.md | 70 +++-
crates/openstrike-core/Cargo.toml | 2 +-
crates/openstrike-psp/Cargo.toml | 8 +-
crates/openstrike-vita/Cargo.toml | 6 +-
game/openstrike-symbian.tsx | 18 +
game/openstrike.tsx | 23 +-
game/screen.tsx | 17 +
game/sdk.ts | 36 +-
game/symbian-compat-sim.ts | 534 +++++++++++++++++++++++++++
game/symbian-compat.tsx | 300 +++++++++++++++
package.json | 11 +-
pocket.json | 16 +-
scripts/e2e-vita.ts | 2 +-
scripts/ensure-pocketjs-generated.ts | 31 ++
scripts/pocket-contract.ts | 4 +-
scripts/psp.ts | 6 +-
scripts/symbian-compat-smoke.ts | 230 ++++++++++++
scripts/symbian-manifest.ts | 99 +++++
scripts/symbian.ts | 154 ++++++++
scripts/vita.ts | 4 +-
test/openstrike-vita-artwork.test.ts | 2 +-
test/psp-toolchain.test.ts | 6 +-
test/symbian-compat.test.ts | 179 +++++++++
tsconfig.json | 8 +-
vendor/pocketjs | 2 +-
27 files changed, 1708 insertions(+), 83 deletions(-)
create mode 100644 game/openstrike-symbian.tsx
create mode 100644 game/screen.tsx
create mode 100644 game/symbian-compat-sim.ts
create mode 100644 game/symbian-compat.tsx
create mode 100644 scripts/ensure-pocketjs-generated.ts
create mode 100644 scripts/symbian-compat-smoke.ts
create mode 100644 scripts/symbian-manifest.ts
create mode 100644 scripts/symbian.ts
create mode 100644 test/symbian-compat.test.ts
diff --git a/Cargo.lock b/Cargo.lock
index dcb400d..d68371e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1604,13 +1604,22 @@ dependencies = [
]
[[package]]
-name = "pocket-ui-wgpu"
+name = "pocket-ui-surface"
version = "0.1.0"
dependencies = [
"anyhow",
- "bytemuck",
"log",
"pocket-mod",
+ "pocketjs-core",
+]
+
+[[package]]
+name = "pocket-ui-wgpu"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "bytemuck",
+ "pocket-ui-surface",
"pocket3d",
"pocketjs-core",
"wgpu",
diff --git a/Cargo.toml b/Cargo.toml
index 5e6e7c1..791001d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -14,11 +14,11 @@ repository = "https://github.com/pocket-stack/open-strike"
[workspace.dependencies]
# The engine family is vendored as a git submodule (vendor/pocketjs) so the
# Rust crates and the JS framework/build pipeline stay pinned to ONE commit.
-pocket3d = { path = "vendor/pocketjs/pocket3d/crates/pocket3d" }
-pocket3d-bsp = { path = "vendor/pocketjs/pocket3d/crates/pocket3d-bsp" }
-pocket-mod = { path = "vendor/pocketjs/pocket3d/crates/pocket-mod" }
-pocket-ui-wgpu = { path = "vendor/pocketjs/pocket3d/crates/pocket-ui-wgpu" }
-pocketjs-core = { path = "vendor/pocketjs/core", features = ["std"] }
+pocket3d = { path = "vendor/pocketjs/engine/pocket3d/crates/pocket3d" }
+pocket3d-bsp = { path = "vendor/pocketjs/engine/pocket3d/crates/pocket3d-bsp" }
+pocket-mod = { path = "vendor/pocketjs/engine/crates/pocket-mod" }
+pocket-ui-wgpu = { path = "vendor/pocketjs/engine/crates/pocket-ui-wgpu" }
+pocketjs-core = { path = "vendor/pocketjs/engine/core", features = ["std"] }
wgpu = "25"
glam = "0.33"
diff --git a/README.md b/README.md
index a0c4943..aeb6d1b 100644
--- a/README.md
+++ b/README.md
@@ -15,7 +15,10 @@
A CS-like FPS on classic BSP maps — Pocket3D worlds, a PocketJS JSX HUD, gameplay in TypeScript.
-The same game targets desktop (wgpu), PSP (sceGu), and PS Vita (vita2d/GXM); the bottom shot is the real PSP running at a locked 60 fps.
+The full 3D game targets desktop (wgpu), PSP (sceGu), and PS Vita
+(vita2d/GXM); Nokia E7 has a separately labelled playable 2D compatibility
+mode while a Pocket3D Symbian backend is still absent. The bottom shot is the
+real PSP running at a locked 60 fps.
A single-player CS-like FPS built on the **Pocket runtime family**: a Rust
core (Pocket3D) simulates and renders; the *product* — round rules, weapon
@@ -42,7 +45,8 @@ game/ the product bundle (JS/TSX) — runs on every target
├─ sdk.ts `strike` SDK: state snapshots, events, commands
├─ rules.ts the base game as the FIRST MOD: round flow, scoring,
│ weapon + bot tuning
- └─ hud.tsx the HUD — a full PocketJS app (Solid + Tailwind)
+ ├─ hud.tsx the HUD — a full PocketJS app (Solid + Tailwind)
+ └─ symbian-compat* explicitly reduced E7 top-down sim + renderer
vendor/pocketjs the engine, pinned as a git submodule
```
@@ -77,11 +81,14 @@ toolchains. The vendored PocketJS manifest pins the organization-owned
personal-fork checkout is required.
[`pocket.json`](pocket.json) is the portable Pocket application contract. It
-requires the draw list, baked glyphs, buttons and the left analog API at a
-480x272 logical `integer-fit` viewport; it does not claim touch, dynamic text,
-or a stock Pocket3D capability. Pocket3D remains an extension implemented by
-OpenStrike's custom native hosts. Every target build validates that manifest,
-runs the ordinary reachable TypeScript check, writes
+requires baked glyphs and buttons, and uses the left analog API when the
+target provides it. PSP/Vita select its 480x272 fixed `integer-fit` viewport;
+the derived Nokia E7 manifest alone adds `display.viewport.live` and a
+640x360-through-360x640 dynamic viewport. The compatibility app does not
+claim touch, dynamic text, or a stock Pocket3D capability. Pocket3D remains
+an extension implemented by OpenStrike's custom native hosts. Every target
+build validates its manifest, runs the ordinary reachable TypeScript check,
+writes
`.pocket//plan.json`, and delegates compilation to `pocket compile`.
The public `@pocketjs/framework/manifest` helpers verify the build-plan
checksum, project stable `HostBuildInputs`, and generate Cargo's target, host
@@ -90,6 +97,55 @@ ABI and viewport environment. Target artifacts are isolated under
another. At runtime PocketJS compares target and host ABI; the plan checksum
is build-time consistency data, not a runtime trust mechanism.
+## Nokia E7: playable 2D compatibility mode
+
+The generic PocketJS Symbian host has a 2D UI renderer, but it does not yet
+provide OpenStrike's native `strike` surface, BSP loader, collision world, or
+a Pocket3D OpenGL ES renderer. OpenStrike therefore ships an intentionally
+named **E7 2D COMPAT / NO 3D** mode instead of presenting a static mock or
+mislabeling it as the full FPS.
+
+This fallback is entirely inside this repository. The build mechanically
+derives an E7 manifest from canonical `pocket.json` and selects the dedicated
+`game/openstrike-symbian.tsx` entry; there is no second hand-maintained
+contract. On `symbian-e7-dev`—and only when a future native `strike` surface
+is absent—that entry installs a deterministic top-down simulation. The
+ordinary map menu starts a round; the existing `game/rules.ts` still owns
+phase timing, wins, losses and round resets; the existing HUD consumes real
+state snapshots and hit/damage events. Player movement, aiming, automatic
+fire, reloading, pursuing/attacking bots, death and elimination are live
+gameplay. PSP, Vita and desktop continue to build the canonical entry and use
+their native simulation and 3D renderers unchanged.
+
+E7 keyboard controls:
+
+| Key | Action |
+| --- | --- |
+| Arrow up/down | move forward/back |
+| Arrow left/right | turn |
+| E | fire |
+| S | reload |
+| Enter | choose a map / confirm |
+| Backspace or Home | open the return-to-menu dialog |
+
+The pure compatibility simulation is covered by
+`bun run test:symbian-compat`. The pinned `vendor/pocketjs` revision contains
+the Symbian toolchain; `POCKETJS_ROOT` remains available only for testing a
+newer PocketJS checkout. The guest-only command compiles the dedicated bundle
+and automatically exercises the actual HostOps/frame protocol from menu
+selection through a combat hit:
+
+```sh
+bun run test:symbian-bundle
+bun run build:symbian
+```
+
+The full build writes
+`dist/symbian/openstrike-e7-compat.sis`, with a stable private UID derived
+from `dev.pocket-stack.openstrike.e7-compat`, and reruns the same smoke against
+the packaged payload. That harness proves guest startup and gameplay wiring;
+it is not a substitute for installing and accepting the final SIS on an E7.
+
### Map data
Maps and textures are **not** in this repo (they are Valve-copyrighted game
diff --git a/crates/openstrike-core/Cargo.toml b/crates/openstrike-core/Cargo.toml
index 5a2c559..07533c4 100644
--- a/crates/openstrike-core/Cargo.toml
+++ b/crates/openstrike-core/Cargo.toml
@@ -19,6 +19,6 @@ std = ["pocket3d-bsp/std", "glam/std"]
libm = ["pocket3d-bsp/libm", "glam/libm"]
[dependencies]
-pocket3d-bsp = { path = "../../vendor/pocketjs/pocket3d/crates/pocket3d-bsp", default-features = false }
+pocket3d-bsp = { path = "../../vendor/pocketjs/engine/pocket3d/crates/pocket3d-bsp", default-features = false }
glam = { version = "0.33", default-features = false }
libm = "0.2"
diff --git a/crates/openstrike-psp/Cargo.toml b/crates/openstrike-psp/Cargo.toml
index fe00827..c14ae1b 100644
--- a/crates/openstrike-psp/Cargo.toml
+++ b/crates/openstrike-psp/Cargo.toml
@@ -21,10 +21,10 @@ edition = "2021"
[dependencies]
psp = { git = "https://github.com/pocket-stack/rust-psp.git", rev = "2cbaf8c9bc72569c76240a1d9743de10731e5f6b", features = ["external-c-heap", "abort-only", "external-global-alloc"] }
libquickjs-sys = { git = "https://github.com/pocket-stack/quickjs-rs.git", rev = "0fc946fb670c0c29bc0135f510bcb0f595415a61" }
-pocketjs-psp = { path = "../../vendor/pocketjs/native" }
-pocketjs-core = { path = "../../vendor/pocketjs/core" }
-pocket3d-bsp = { path = "../../vendor/pocketjs/pocket3d/crates/pocket3d-bsp", default-features = false, features = ["libm"] }
-pocket3d-gu = { path = "../../vendor/pocketjs/pocket3d/crates/pocket3d-gu" }
+pocketjs-psp = { path = "../../vendor/pocketjs/hosts/psp" }
+pocketjs-core = { path = "../../vendor/pocketjs/engine/core" }
+pocket3d-bsp = { path = "../../vendor/pocketjs/engine/pocket3d/crates/pocket3d-bsp", default-features = false, features = ["libm"] }
+pocket3d-gu = { path = "../../vendor/pocketjs/engine/pocket3d/crates/pocket3d-gu" }
openstrike-core = { path = "../openstrike-core", default-features = false, features = ["libm"] }
glam = { version = "0.33", default-features = false, features = ["libm"] }
libm = "0.2"
diff --git a/crates/openstrike-vita/Cargo.toml b/crates/openstrike-vita/Cargo.toml
index d9e5b87..b72b2f9 100644
--- a/crates/openstrike-vita/Cargo.toml
+++ b/crates/openstrike-vita/Cargo.toml
@@ -17,7 +17,7 @@ path = "src/lib.rs"
[dependencies]
openstrike-core = { path = "../openstrike-core" }
glam = "0.33"
-pocket3d-bsp = { path = "../../vendor/pocketjs/pocket3d/crates/pocket3d-bsp" }
+pocket3d-bsp = { path = "../../vendor/pocketjs/engine/pocket3d/crates/pocket3d-bsp" }
[target.'cfg(target_os = "vita")'.dependencies]
vitasdk-sys = { version = "0.3.3", features = [
@@ -27,8 +27,8 @@ vitasdk-sys = { version = "0.3.3", features = [
"SceLibKernel_stub",
] }
vita2d-sys = "0.1.1"
-pocket3d-vita = { path = "../../vendor/pocketjs/pocket3d/crates/pocket3d-vita" }
-pocketjs-vita = { path = "../../vendor/pocketjs/native-vita" }
+pocket3d-vita = { path = "../../vendor/pocketjs/engine/pocket3d/crates/pocket3d-vita" }
+pocketjs-vita = { path = "../../vendor/pocketjs/hosts/vita" }
libquickjs-sys = { git = "https://github.com/pocket-stack/quickjs-rs.git", rev = "0fc946fb670c0c29bc0135f510bcb0f595415a61" }
[features]
diff --git a/game/openstrike-symbian.tsx b/game/openstrike-symbian.tsx
new file mode 100644
index 0000000..c585750
--- /dev/null
+++ b/game/openstrike-symbian.tsx
@@ -0,0 +1,18 @@
+// @title OpenStrike — E7 2D Compatibility
+//
+// Symbian-specific product entry. It installs the OpenStrike-owned playable
+// 2D fallback before rules/sdk evaluate, then layers the ordinary menu/HUD on
+// top. Native PSP/Vita/desktop entries never import this module or its sim.
+
+import { SymbianCompatibilityLayer } from "./symbian-compat.tsx";
+import "./rules.ts";
+import { mount } from "@pocketjs/framework/solid";
+import { View } from "@pocketjs/framework/components";
+import OpenStrikeScreen from "./screen.tsx";
+
+mount(() => (
+
+
+
+
+));
diff --git a/game/openstrike.tsx b/game/openstrike.tsx
index d88fc17..687fa5b 100644
--- a/game/openstrike.tsx
+++ b/game/openstrike.tsx
@@ -4,24 +4,7 @@
// artifact here.
import "./rules.ts";
-import { createSignal, Show } from "solid-js";
-import { mount } from "@pocketjs/framework";
-import Hud from "./hud.tsx";
-import MainMenu from "./menu.tsx";
-import { strike } from "./sdk.ts";
+import { mount } from "@pocketjs/framework/solid";
+import OpenStrikeScreen from "./screen.tsx";
-// Phase-switched root: handheld menu hosts (PSP EBOOT / Vita VPK) boot into
-// phase "menu";
-// hosts that pre-load a map (desktop --map) never publish it and go
-// straight to the HUD. The menu<->game swap is structural but rare.
-function App() {
- const [inMenu, setInMenu] = createSignal(strike.state().phase === "menu");
- strike.onTick((s) => setInMenu(s.phase === "menu"));
- return (
- }>
-
-
- );
-}
-
-mount(() => );
+mount(() => );
diff --git a/game/screen.tsx b/game/screen.tsx
new file mode 100644
index 0000000..f5f2208
--- /dev/null
+++ b/game/screen.tsx
@@ -0,0 +1,17 @@
+// Shared product screen. Native 3D hosts mount this directly; the explicitly
+// reduced Symbian entry places its compatibility world underneath it.
+
+import { createSignal, Show } from "solid-js";
+import Hud from "./hud.tsx";
+import MainMenu from "./menu.tsx";
+import { strike } from "./sdk.ts";
+
+export default function OpenStrikeScreen() {
+ const [inMenu, setInMenu] = createSignal(strike.state().phase === "menu");
+ strike.onTick((state) => setInMenu(state.phase === "menu"));
+ return (
+ }>
+
+
+ );
+}
diff --git a/game/sdk.ts b/game/sdk.ts
index 67ba7a6..ef926d4 100644
--- a/game/sdk.ts
+++ b/game/sdk.ts
@@ -45,9 +45,11 @@ export interface BotsConfig {
damageMax: number;
}
-interface NativeStrike {
+export interface NativeStrike {
/** Cooked maps available to loadMap (index-aligned), host-injected. */
maps?: string[];
+ /** Compatibility hosts may publish their pre-first-frame state. */
+ __initialState?: StrikeState;
loadMap?(index: number): void;
toMenu?(): void;
setPhase(phase: string): void;
@@ -65,21 +67,23 @@ if (!native) {
throw new Error("openstrike: no `strike` surface — is this running under the game host?");
}
-let current: StrikeState = {
- time: 0,
- phase: "starting",
- hp: 100,
- alive: true,
- ammo: 30,
- reserve: 90,
- reloading: false,
- reloadFrac: 0,
- aliveBots: 0,
- totalBots: 0,
- wins: 0,
- losses: 0,
- speed: 0,
-};
+let current: StrikeState = native.__initialState
+ ? { ...native.__initialState }
+ : {
+ time: 0,
+ phase: "starting",
+ hp: 100,
+ alive: true,
+ ammo: 30,
+ reserve: 90,
+ reloading: false,
+ reloadFrac: 0,
+ aliveBots: 0,
+ totalBots: 0,
+ wins: 0,
+ losses: 0,
+ speed: 0,
+ };
type Handler = (e: StrikeEvent) => void;
type TickHandler = (s: StrikeState) => void;
diff --git a/game/symbian-compat-sim.ts b/game/symbian-compat-sim.ts
new file mode 100644
index 0000000..9bdf85c
--- /dev/null
+++ b/game/symbian-compat-sim.ts
@@ -0,0 +1,534 @@
+// OpenStrike's explicitly reduced Nokia E7 game mode.
+//
+// This is not a replacement for openstrike-core and does not pretend to be
+// the BSP/3D game. The E7 PocketJS host currently has no Pocket3D backend or
+// native `strike` surface, so this small deterministic top-down simulation
+// preserves the real product loop while that native port is still missing:
+// map selection -> rules-driven round phases -> movement/combat -> HUD/events
+// -> win/loss -> reset.
+
+import type {
+ BotsConfig,
+ StrikeEvent,
+ StrikeState,
+ WeaponConfig,
+} from "./sdk.ts";
+
+export const SYMBIAN_COMPAT_MAPS = [
+ "de_dust2",
+ "de_inferno",
+ "de_nuke",
+ "cs_assault",
+ "de_dust",
+ "de_aztec",
+ "de_train",
+ "cs_office",
+] as const;
+
+export interface SymbianCompatInput {
+ /** Forward/backward, from -1 through 1. */
+ forward: number;
+ /** Clockwise/counter-clockwise turn, from -1 through 1. */
+ turn: number;
+ /** Automatic-fire trigger held this step. */
+ fire: boolean;
+ /** Rising-edge reload action. */
+ reload: boolean;
+}
+
+export interface SymbianCompatRect {
+ readonly x: number;
+ readonly y: number;
+ readonly w: number;
+ readonly h: number;
+}
+
+export interface SymbianCompatActor {
+ readonly x: number;
+ readonly y: number;
+ readonly angle: number;
+ readonly alive: boolean;
+}
+
+export interface SymbianCompatScene {
+ readonly mapIndex: number;
+ readonly mapName: string;
+ readonly player: SymbianCompatActor;
+ readonly bots: readonly SymbianCompatActor[];
+ readonly obstacles: readonly SymbianCompatRect[];
+}
+
+interface MapLayout {
+ readonly player: readonly [number, number, number];
+ readonly bots: readonly (readonly [number, number])[];
+ readonly obstacles: readonly SymbianCompatRect[];
+}
+
+interface MutableBot {
+ x: number;
+ y: number;
+ hp: number;
+ attackLeft: number;
+ attacks: number;
+}
+
+type QueuedCommand =
+ | { readonly type: "phase"; readonly phase: StrikeState["phase"] }
+ | { readonly type: "reset" }
+ | { readonly type: "win" }
+ | { readonly type: "loss" }
+ | { readonly type: "bot-count"; readonly count: number }
+ | { readonly type: "weapon"; readonly config: WeaponConfig }
+ | { readonly type: "bots"; readonly config: BotsConfig };
+
+const DEFAULT_WEAPON: WeaponConfig = {
+ magSize: 30,
+ reserve: 90,
+ fireInterval: 0.105,
+ reloadTime: 2.4,
+ damageBody: 34,
+ damageHead: 100,
+};
+
+const DEFAULT_BOTS: BotsConfig = {
+ count: 3,
+ speed: 190,
+ attackInterval: 1.4,
+ damageMin: 8,
+ damageMax: 14,
+};
+
+const LAYOUTS: readonly MapLayout[] = [
+ {
+ player: [0.5, 0.84, -Math.PI / 2],
+ // The default three spawns share the center lane: every round is
+ // completable without waypoint navigation, which this reduced mode does
+ // not claim. Extra modded bots may occupy the outer lanes.
+ bots: [[0.5, 0.16], [0.42, 0.24], [0.58, 0.24], [0.2, 0.62], [0.8, 0.62]],
+ obstacles: [
+ { x: 0.12, y: 0.43, w: 0.27, h: 0.08 },
+ { x: 0.61, y: 0.43, w: 0.27, h: 0.08 },
+ ],
+ },
+ {
+ player: [0.5, 0.84, -Math.PI / 2],
+ bots: [[0.5, 0.16], [0.16, 0.18], [0.84, 0.18], [0.18, 0.7], [0.82, 0.7]],
+ obstacles: [
+ { x: 0.23, y: 0.31, w: 0.12, h: 0.34 },
+ { x: 0.65, y: 0.31, w: 0.12, h: 0.34 },
+ ],
+ },
+ {
+ player: [0.5, 0.84, -Math.PI / 2],
+ bots: [[0.5, 0.16], [0.2, 0.2], [0.8, 0.2], [0.16, 0.5], [0.84, 0.5]],
+ obstacles: [
+ { x: 0.18, y: 0.4, w: 0.22, h: 0.1 },
+ { x: 0.6, y: 0.4, w: 0.22, h: 0.1 },
+ { x: 0.44, y: 0.58, w: 0.12, h: 0.1 },
+ ],
+ },
+ {
+ player: [0.5, 0.84, -Math.PI / 2],
+ bots: [[0.5, 0.16], [0.13, 0.26], [0.87, 0.26], [0.25, 0.58], [0.75, 0.58]],
+ obstacles: [
+ { x: 0.16, y: 0.38, w: 0.2, h: 0.08 },
+ { x: 0.64, y: 0.38, w: 0.2, h: 0.08 },
+ { x: 0.4, y: 0.26, w: 0.2, h: 0.08 },
+ ],
+ },
+];
+
+const clamp = (value: number, min: number, max: number): number =>
+ Math.max(min, Math.min(max, value));
+
+const copyWeapon = (config: WeaponConfig): WeaponConfig => ({
+ magSize: clamp(Math.floor(config.magSize), 1, 999),
+ reserve: clamp(Math.floor(config.reserve), 0, 9999),
+ fireInterval: clamp(config.fireInterval, 0.03, 10),
+ reloadTime: clamp(config.reloadTime, 0.1, 30),
+ damageBody: clamp(Math.floor(config.damageBody), 1, 9999),
+ damageHead: clamp(Math.floor(config.damageHead), 1, 9999),
+});
+
+const copyBots = (config: BotsConfig): BotsConfig => ({
+ count: clamp(Math.floor(config.count), 0, 8),
+ speed: clamp(config.speed, 1, 1000),
+ attackInterval: clamp(config.attackInterval, 0.1, 30),
+ damageMin: clamp(Math.floor(config.damageMin), 0, 999),
+ damageMax: clamp(Math.floor(config.damageMax), 0, 999),
+});
+
+const pointBlocked = (
+ x: number,
+ y: number,
+ radius: number,
+ layout: MapLayout,
+): boolean =>
+ layout.obstacles.some(
+ (rect) =>
+ x + radius > rect.x &&
+ x - radius < rect.x + rect.w &&
+ y + radius > rect.y &&
+ y - radius < rect.y + rect.h,
+ );
+
+/** Liang-Barsky segment/rectangle test, expanded by the actor radius. */
+const segmentBlocked = (
+ x0: number,
+ y0: number,
+ x1: number,
+ y1: number,
+ layout: MapLayout,
+ radius = 0,
+): boolean => {
+ const dx = x1 - x0;
+ const dy = y1 - y0;
+ for (const rect of layout.obstacles) {
+ const minX = rect.x - radius;
+ const maxX = rect.x + rect.w + radius;
+ const minY = rect.y - radius;
+ const maxY = rect.y + rect.h + radius;
+ let near = 0;
+ let far = 1;
+ const clips: readonly (readonly [number, number])[] = [
+ [-dx, x0 - minX],
+ [dx, maxX - x0],
+ [-dy, y0 - minY],
+ [dy, maxY - y0],
+ ];
+ let hit = true;
+ for (const [p, q] of clips) {
+ if (Math.abs(p) < 1e-9) {
+ if (q < 0) hit = false;
+ continue;
+ }
+ const t = q / p;
+ if (p < 0) near = Math.max(near, t);
+ else far = Math.min(far, t);
+ if (near > far) hit = false;
+ }
+ if (hit) return true;
+ }
+ return false;
+};
+
+export class SymbianCompatSimulation {
+ private phase: StrikeState["phase"] = "menu";
+ private time = 0;
+ private hp = 100;
+ private alive = true;
+ private ammo = DEFAULT_WEAPON.magSize;
+ private reserve = DEFAULT_WEAPON.reserve;
+ private reloadLeft = 0;
+ private fireLeft = 0;
+ private wins = 0;
+ private losses = 0;
+ private speed = 0;
+ private mapIndex = 0;
+ private playerX = 0.5;
+ private playerY = 0.84;
+ private playerAngle = -Math.PI / 2;
+ private bots: MutableBot[] = [];
+ private weapon = copyWeapon(DEFAULT_WEAPON);
+ private botConfig = copyBots(DEFAULT_BOTS);
+ private readonly events: StrikeEvent[] = [];
+ private readonly commands: QueuedCommand[] = [];
+
+ initialState(): StrikeState {
+ return this.snapshot();
+ }
+
+ snapshot(): StrikeState {
+ const reloadFrac =
+ this.reloadLeft > 0
+ ? 1 - clamp(this.reloadLeft / this.weapon.reloadTime, 0, 1)
+ : 0;
+ return {
+ time: this.time,
+ phase: this.phase,
+ hp: this.hp,
+ alive: this.alive,
+ ammo: this.ammo,
+ reserve: this.reserve,
+ reloading: this.reloadLeft > 0,
+ reloadFrac,
+ aliveBots: this.bots.filter((bot) => bot.hp > 0).length,
+ totalBots: this.bots.length,
+ wins: this.wins,
+ losses: this.losses,
+ speed: this.speed,
+ };
+ }
+
+ scene(): SymbianCompatScene {
+ const layout = this.layout();
+ return {
+ mapIndex: this.mapIndex,
+ mapName: SYMBIAN_COMPAT_MAPS[this.mapIndex],
+ player: {
+ x: this.playerX,
+ y: this.playerY,
+ angle: this.playerAngle,
+ alive: this.alive,
+ },
+ bots: this.bots.map((bot) => ({
+ x: bot.x,
+ y: bot.y,
+ angle: Math.atan2(this.playerY - bot.y, this.playerX - bot.x),
+ alive: bot.hp > 0,
+ })),
+ obstacles: layout.obstacles,
+ };
+ }
+
+ drainEvents(): StrikeEvent[] {
+ return this.events.splice(0);
+ }
+
+ loadMap(index: number): void {
+ if (!Number.isInteger(index) || index < 0 || index >= SYMBIAN_COMPAT_MAPS.length) return;
+ this.mapIndex = index;
+ this.time = 0;
+ this.resetRound(false);
+ }
+
+ toMenu(): void {
+ this.phase = "menu";
+ this.speed = 0;
+ this.reloadLeft = 0;
+ this.fireLeft = 0;
+ }
+
+ queueSetPhase(phase: StrikeState["phase"]): void {
+ if (phase !== "menu" && phase !== "starting" && phase !== "live" && phase !== "won" && phase !== "lost") {
+ return;
+ }
+ this.commands.push({ type: "phase", phase });
+ }
+
+ queueResetRound(): void {
+ this.commands.push({ type: "reset" });
+ }
+
+ queueAddWin(): void {
+ this.commands.push({ type: "win" });
+ }
+
+ queueAddLoss(): void {
+ this.commands.push({ type: "loss" });
+ }
+
+ queueSetBotCount(count: number): void {
+ this.commands.push({ type: "bot-count", count });
+ }
+
+ queueConfigureWeapon(config: WeaponConfig): void {
+ this.commands.push({ type: "weapon", config: copyWeapon(config) });
+ }
+
+ queueConfigureBots(config: BotsConfig): void {
+ this.commands.push({ type: "bots", config: copyBots(config) });
+ }
+
+ /** Apply guest intents after dispatch, matching the native host ordering. */
+ flushCommands(): void {
+ for (const command of this.commands.splice(0)) {
+ switch (command.type) {
+ case "phase":
+ this.phase = command.phase;
+ break;
+ case "reset":
+ this.resetRound(true);
+ break;
+ case "win":
+ this.wins++;
+ break;
+ case "loss":
+ this.losses++;
+ break;
+ case "bot-count":
+ this.botConfig = {
+ ...this.botConfig,
+ count: clamp(Math.floor(command.count), 0, 8),
+ };
+ break;
+ case "weapon":
+ this.weapon = command.config;
+ if (this.phase === "menu") {
+ this.ammo = this.weapon.magSize;
+ this.reserve = this.weapon.reserve;
+ } else {
+ this.ammo = Math.min(this.ammo, this.weapon.magSize);
+ }
+ break;
+ case "bots":
+ this.botConfig = command.config;
+ break;
+ }
+ }
+ }
+
+ tick(input: SymbianCompatInput, elapsed: number): void {
+ const dt = clamp(Number.isFinite(elapsed) ? elapsed : 0, 0, 0.1);
+ this.time += dt;
+ this.fireLeft = Math.max(0, this.fireLeft - dt);
+
+ if (this.reloadLeft > 0) {
+ this.reloadLeft = Math.max(0, this.reloadLeft - dt);
+ if (this.reloadLeft === 0) this.finishReload();
+ }
+
+ if (this.phase !== "live" || !this.alive) {
+ this.speed = 0;
+ return;
+ }
+
+ const turn = clamp(input.turn, -1, 1);
+ const forward = clamp(input.forward, -1, 1);
+ this.playerAngle += turn * 2.45 * dt;
+ const movement = forward * 0.28 * dt;
+ this.movePlayer(Math.cos(this.playerAngle) * movement, Math.sin(this.playerAngle) * movement);
+ this.speed = Math.abs(forward) * 250;
+
+ if (input.reload) this.startReload();
+ if (input.fire) this.fire();
+
+ this.tickBots(dt);
+ }
+
+ private layout(): MapLayout {
+ return LAYOUTS[this.mapIndex % LAYOUTS.length];
+ }
+
+ private resetRound(emitEvent: boolean): void {
+ const layout = this.layout();
+ this.phase = "starting";
+ this.hp = 100;
+ this.alive = true;
+ this.ammo = this.weapon.magSize;
+ this.reserve = this.weapon.reserve;
+ this.reloadLeft = 0;
+ this.fireLeft = 0;
+ this.speed = 0;
+ [this.playerX, this.playerY, this.playerAngle] = layout.player;
+ this.bots = Array.from({ length: this.botConfig.count }, (_, index) => {
+ const spawn = layout.bots[index % layout.bots.length];
+ return {
+ x: spawn[0],
+ y: spawn[1],
+ hp: Math.max(this.weapon.damageBody * 2, 50),
+ attackLeft: this.botConfig.attackInterval * (0.45 + index * 0.12),
+ attacks: 0,
+ };
+ });
+ if (emitEvent) this.events.push({ type: "roundReset" });
+ }
+
+ private movePlayer(dx: number, dy: number): void {
+ const layout = this.layout();
+ const nextX = clamp(this.playerX + dx, 0.035, 0.965);
+ if (!pointBlocked(nextX, this.playerY, 0.025, layout)) this.playerX = nextX;
+ const nextY = clamp(this.playerY + dy, 0.055, 0.945);
+ if (!pointBlocked(this.playerX, nextY, 0.025, layout)) this.playerY = nextY;
+ }
+
+ private moveBot(bot: MutableBot, dx: number, dy: number): void {
+ const layout = this.layout();
+ const nextX = clamp(bot.x + dx, 0.035, 0.965);
+ if (!pointBlocked(nextX, bot.y, 0.022, layout)) bot.x = nextX;
+ const nextY = clamp(bot.y + dy, 0.055, 0.945);
+ if (!pointBlocked(bot.x, nextY, 0.022, layout)) bot.y = nextY;
+ }
+
+ private startReload(): void {
+ if (
+ this.reloadLeft === 0 &&
+ this.ammo < this.weapon.magSize &&
+ this.reserve > 0
+ ) {
+ this.reloadLeft = this.weapon.reloadTime;
+ }
+ }
+
+ private finishReload(): void {
+ const loaded = Math.min(this.weapon.magSize - this.ammo, this.reserve);
+ this.ammo += loaded;
+ this.reserve -= loaded;
+ }
+
+ private fire(): void {
+ if (this.reloadLeft > 0 || this.fireLeft > 0) return;
+ if (this.ammo <= 0) {
+ this.startReload();
+ return;
+ }
+ this.ammo--;
+ this.fireLeft = this.weapon.fireInterval;
+
+ const dx = Math.cos(this.playerAngle);
+ const dy = Math.sin(this.playerAngle);
+ let bestIndex = -1;
+ let bestProjection = Number.POSITIVE_INFINITY;
+ let bestMiss = Number.POSITIVE_INFINITY;
+ const layout = this.layout();
+ for (let index = 0; index < this.bots.length; index++) {
+ const bot = this.bots[index];
+ if (bot.hp <= 0) continue;
+ const bx = bot.x - this.playerX;
+ const by = bot.y - this.playerY;
+ const projection = bx * dx + by * dy;
+ if (projection <= 0 || projection >= bestProjection) continue;
+ const miss = Math.abs(bx * dy - by * dx);
+ if (miss > 0.05) continue;
+ if (segmentBlocked(this.playerX, this.playerY, bot.x, bot.y, layout, 0.005)) continue;
+ bestIndex = index;
+ bestProjection = projection;
+ bestMiss = miss;
+ }
+ if (bestIndex < 0) return;
+
+ const bot = this.bots[bestIndex];
+ const headshot = bestMiss < 0.012;
+ const damage = headshot ? this.weapon.damageHead : this.weapon.damageBody;
+ bot.hp = Math.max(0, bot.hp - damage);
+ this.events.push({
+ type: "hit",
+ bot: bestIndex,
+ headshot,
+ damage,
+ fatal: bot.hp === 0,
+ });
+ }
+
+ private tickBots(dt: number): void {
+ const layout = this.layout();
+ for (const bot of this.bots) {
+ if (bot.hp <= 0 || !this.alive) continue;
+ const dx = this.playerX - bot.x;
+ const dy = this.playerY - bot.y;
+ const distance = Math.sqrt(dx * dx + dy * dy);
+ const visible = !segmentBlocked(bot.x, bot.y, this.playerX, this.playerY, layout, 0.004);
+ if (distance > 0.28 || !visible) {
+ const step = (this.botConfig.speed / 190) * 0.075 * dt;
+ const inverse = distance > 1e-6 ? 1 / distance : 0;
+ this.moveBot(bot, dx * inverse * step, dy * inverse * step);
+ }
+
+ bot.attackLeft -= dt;
+ if (visible && distance <= 0.38 && bot.attackLeft <= 0) {
+ const span = Math.max(0, this.botConfig.damageMax - this.botConfig.damageMin);
+ const damage =
+ this.botConfig.damageMin +
+ (span > 0 ? (bot.attacks * 3 + this.mapIndex + 1) % (span + 1) : 0);
+ bot.attacks++;
+ bot.attackLeft += this.botConfig.attackInterval;
+ this.hp = Math.max(0, this.hp - damage);
+ this.events.push({ type: "playerDamaged", amount: damage, hp: this.hp });
+ if (this.hp === 0) {
+ this.alive = false;
+ this.events.push({ type: "playerDied" });
+ }
+ }
+ }
+ }
+}
diff --git a/game/symbian-compat.tsx b/game/symbian-compat.tsx
new file mode 100644
index 0000000..e993140
--- /dev/null
+++ b/game/symbian-compat.tsx
@@ -0,0 +1,300 @@
+// Nokia E7 compatibility runtime + renderer.
+//
+// The generic PocketJS Symbian host owns a 2D UI surface, not OpenStrike's
+// native `strike` vocabulary or Pocket3D world renderer. On that one host,
+// and only while no future native `strike` surface exists, install the
+// deterministic top-down compatibility simulation. The on-screen label is
+// intentionally explicit: this is a playable 2D mode, not the BSP/3D port.
+
+import { createSignal, For, Show } from "solid-js";
+import { Text, View } from "@pocketjs/framework/components";
+import * as hot from "@pocketjs/framework/hot";
+import { BTN } from "@pocketjs/framework/input";
+import { onFrame } from "@pocketjs/framework/lifecycle";
+import type { NativeStrike, StrikeState } from "./sdk.ts";
+import {
+ SYMBIAN_COMPAT_MAPS,
+ SymbianCompatSimulation,
+ type SymbianCompatInput,
+} from "./symbian-compat-sim.ts";
+
+const ARENA_W = 320;
+const ARENA_H = 200;
+const ACTOR_SIZE = 10;
+const FRAME_DT = 1 / 30;
+
+type Ref = NonNullable[0]>;
+type HostGlobal = typeof globalThis & {
+ ui?: { __host?: string };
+ strike?: NativeStrike;
+};
+
+const hostGlobal = globalThis as HostGlobal;
+const nativeStrikeExists = hostGlobal.strike !== undefined;
+
+/** True only for the reduced OpenStrike-owned mode, never a native game host. */
+export const symbianCompatibilityActive =
+ hostGlobal.ui?.__host === "symbian-e7-dev" && !nativeStrikeExists;
+
+// Native PSP/Vita/desktop bundles parse the shared guest but allocate none of
+// the compatibility game state.
+const simulation = symbianCompatibilityActive
+ ? new SymbianCompatSimulation()
+ : undefined;
+const compatibilitySurface: NativeStrike | undefined = simulation
+ ? {
+ maps: [...SYMBIAN_COMPAT_MAPS],
+ __initialState: simulation.initialState(),
+ loadMap: (index) => simulation.loadMap(index),
+ toMenu: () => simulation.toMenu(),
+ setPhase: (phase) => {
+ if (
+ phase === "menu" ||
+ phase === "starting" ||
+ phase === "live" ||
+ phase === "won" ||
+ phase === "lost"
+ ) {
+ simulation.queueSetPhase(phase);
+ }
+ },
+ resetRound: () => simulation.queueResetRound(),
+ addWin: () => simulation.queueAddWin(),
+ addLoss: () => simulation.queueAddLoss(),
+ setBotCount: (count) => simulation.queueSetBotCount(count),
+ configureWeapon: (config) => simulation.queueConfigureWeapon(config),
+ configureBots: (config) => simulation.queueConfigureBots(config),
+ }
+ : undefined;
+
+if (compatibilitySurface) hostGlobal.strike = compatibilitySurface;
+
+export function symbianCompatInput(
+ buttons: number,
+ previousButtons: number,
+): SymbianCompatInput {
+ return {
+ forward:
+ (buttons & BTN.UP ? 1 : 0) -
+ (buttons & BTN.DOWN ? 1 : 0),
+ turn:
+ (buttons & BTN.RIGHT ? 1 : 0) -
+ (buttons & BTN.LEFT ? 1 : 0),
+ // E on the E7 keyboard is the Symbian host's right-trigger mapping.
+ fire: (buttons & BTN.RTRIGGER) !== 0,
+ // S is Square. Reload is an edge, like the native hosts.
+ reload:
+ (buttons & BTN.SQUARE) !== 0 &&
+ (previousButtons & BTN.SQUARE) === 0,
+ };
+}
+
+/**
+ * Always-mounted driver plus the reduced world view. Keeping the driver
+ * alive while the menu is visible lets `loadMap()` transition into gameplay
+ * on the next host frame without a native lifecycle channel.
+ */
+export function SymbianCompatibilityLayer() {
+ if (!simulation || !compatibilitySurface) return null;
+
+ const [phase, setPhase] = createSignal("menu");
+ const [mapIndex, setMapIndex] = createSignal(0);
+ let previousButtons = 0;
+ let playerRef: Ref | undefined;
+ let aimRef: Ref | undefined;
+ const botRefs: Array[ = Array.from({ length: 8 });
+
+ const syncScene = () => {
+ const scene = simulation.scene();
+ const playerX = scene.player.x * ARENA_W - ACTOR_SIZE / 2;
+ const playerY = scene.player.y * ARENA_H - ACTOR_SIZE / 2;
+ const angle = (scene.player.angle * 180) / Math.PI;
+ hot.prop(playerRef, "translateX", playerX);
+ hot.prop(playerRef, "translateY", playerY);
+ hot.prop(playerRef, "rotate", angle);
+ hot.prop(playerRef, "opacity", scene.player.alive ? 1 : 0.35);
+ hot.prop(aimRef, "translateX", playerX + ACTOR_SIZE / 2);
+ hot.prop(aimRef, "translateY", playerY + ACTOR_SIZE / 2 - 1);
+ hot.prop(aimRef, "rotate", angle);
+
+ for (let index = 0; index < botRefs.length; index++) {
+ const bot = scene.bots[index];
+ const ref = botRefs[index];
+ hot.prop(ref, "opacity", bot?.alive ? 1 : bot ? 0.18 : 0);
+ if (!bot) continue;
+ hot.prop(ref, "translateX", bot.x * ARENA_W - ACTOR_SIZE / 2);
+ hot.prop(ref, "translateY", bot.y * ARENA_H - ACTOR_SIZE / 2);
+ hot.prop(ref, "rotate", (bot.angle * 180) / Math.PI);
+ }
+ };
+
+ onFrame((buttons) => {
+ simulation.tick(symbianCompatInput(buttons, previousButtons), FRAME_DT);
+ previousButtons = buttons;
+
+ const state = simulation.snapshot();
+ const events = simulation.drainEvents();
+ compatibilitySurface.__dispatch?.(state, events);
+ // Gameplay rules queue intents from __dispatch callbacks. Apply them only
+ // afterwards, matching the native PSP/Vita strike surface.
+ simulation.flushCommands();
+
+ const next = simulation.snapshot();
+ setPhase(next.phase);
+ setMapIndex(simulation.scene().mapIndex);
+ if (next.phase !== "menu") syncScene();
+ });
+
+ const obstacles = () => {
+ mapIndex();
+ return simulation.scene().obstacles;
+ };
+
+ return (
+
+
+
+ {/* Simple tactical floor grid. */}
+ {Array.from({ length: 7 }, (_, index) => (
+
+ ))}
+ {Array.from({ length: 11 }, (_, index) => (
+
+ ))}
+
+
+ {(obstacle) => (
+
+ )}
+
+
+ (aimRef = element)}
+ class="absolute"
+ style={{
+ insetL: 0,
+ insetT: 0,
+ width: 30,
+ height: 2,
+ bgColor: "#b8f34a80",
+ opacity: 1,
+ }}
+ />
+ (playerRef = element)}
+ class="absolute border-[#e8f0f2]"
+ style={{
+ insetL: 0,
+ insetT: 0,
+ width: ACTOR_SIZE,
+ height: ACTOR_SIZE,
+ bgColor: "#b8f34a",
+ }}
+ >
+
+
+
+ {Array.from({ length: 8 }, (_, index) => (
+ (botRefs[index] = element)}
+ class="absolute border-[#fecaca]"
+ style={{
+ insetL: 0,
+ insetT: 0,
+ width: ACTOR_SIZE,
+ height: ACTOR_SIZE,
+ bgColor: "#dc3038",
+ opacity: 0,
+ }}
+ >
+
+
+ ))}
+
+
+
+ E7 2D COMPAT
+
+
+ NO 3D
+
+
+
+ {SYMBIAN_COMPAT_MAPS[mapIndex()].toUpperCase()}
+
+
+
+ ARROWS MOVE / TURN · E FIRE · S RELOAD
+
+
+
+ );
+}
diff --git a/package.json b/package.json
index 7dadcae..a915e67 100644
--- a/package.json
+++ b/package.json
@@ -3,12 +3,15 @@
"private": true,
"type": "module",
"scripts": {
- "bootstrap": "bun vendor/pocketjs/scripts/bootstrap.ts",
- "setup": "cd vendor/pocketjs && bun install && cd ../.. && mkdir -p node_modules/@pocketjs && ln -sfn ../../vendor/pocketjs node_modules/@pocketjs/framework && ln -sfn ../vendor/pocketjs/node_modules/solid-js node_modules/solid-js && ln -sfn ../vendor/pocketjs/node_modules/bun-types node_modules/bun-types",
- "typecheck": "bun vendor/pocketjs/node_modules/typescript/bin/tsc --noEmit",
- "check:platforms": "bun run typecheck && bun vendor/pocketjs/scripts/pocket.ts check --target psp && bun vendor/pocketjs/scripts/pocket.ts check --target vita",
+ "bootstrap": "bun vendor/pocketjs/tools/bootstrap.ts",
+ "setup": "cd vendor/pocketjs && bun install && cd ../.. && mkdir -p node_modules/@pocketjs && ln -sfn ../../vendor/pocketjs node_modules/@pocketjs/framework && ln -sfn ../vendor/pocketjs/node_modules/solid-js node_modules/solid-js && ln -sfn ../vendor/pocketjs/node_modules/bun-types node_modules/bun-types && bun scripts/ensure-pocketjs-generated.ts",
+ "typecheck": "bun scripts/ensure-pocketjs-generated.ts && bun vendor/pocketjs/node_modules/typescript/bin/tsc --noEmit",
+ "check:platforms": "bun run typecheck && bun vendor/pocketjs/tools/pocket.ts check --target psp && bun vendor/pocketjs/tools/pocket.ts check --target vita",
"build:ui": "bun scripts/build-ui.ts --target psp",
+ "build:symbian": "bun scripts/symbian.ts",
"build:vita": "bun scripts/vita.ts",
+ "test:symbian-bundle": "bun scripts/symbian.ts --guest-only",
+ "test:symbian-compat": "bun test test/symbian-compat.test.ts",
"test:vita-package": "bun test test/openstrike-vita-artwork.test.ts",
"test:e2e:vita": "bun scripts/e2e-vita.ts",
"test:psp-toolchain": "bun test test/psp-toolchain.test.ts"
diff --git a/pocket.json b/pocket.json
index 4229f42..e0d74f4 100644
--- a/pocket.json
+++ b/pocket.json
@@ -9,7 +9,9 @@
"capabilities": {
"requires": [
"text.glyphs.baked",
- "input.buttons",
+ "input.buttons"
+ ],
+ "enhances": [
"input.analog.left"
]
}
@@ -19,11 +21,13 @@
"output": "openstrike",
"framework": "solid",
"viewport": {
- "logical": [
- 480,
- 272
- ],
- "presentation": "integer-fit"
+ "fixed": {
+ "logical": [
+ 480,
+ 272
+ ],
+ "presentation": "integer-fit"
+ }
}
}
}
diff --git a/scripts/e2e-vita.ts b/scripts/e2e-vita.ts
index 1e62ceb..849da73 100644
--- a/scripts/e2e-vita.ts
+++ b/scripts/e2e-vita.ts
@@ -23,7 +23,7 @@ import {
DEFAULT_VITA_PACKAGE_ASSETS,
VITA_REQUIRED_SYSTEM_ASSETS,
VITA_SYSTEM_ASSET_PATHS,
-} from "../vendor/pocketjs/scripts/vita-package.ts";
+} from "../vendor/pocketjs/tools/vita-package.ts";
const repo = new URL("..", import.meta.url).pathname;
const home = process.env.HOME ?? "";
diff --git a/scripts/ensure-pocketjs-generated.ts b/scripts/ensure-pocketjs-generated.ts
new file mode 100644
index 0000000..889f22c
--- /dev/null
+++ b/scripts/ensure-pocketjs-generated.ts
@@ -0,0 +1,31 @@
+// A source checkout intentionally omits PocketJS's ignored generated style
+// mirror. TypeScript follows the Solid runtime's relative import before any
+// app build has regenerated it, so seed the smallest valid table for
+// setup/typecheck. Every real PocketJS compile overwrites this file with the
+// app-specific table embedded in the pak.
+
+import { existsSync } from "node:fs";
+import { resolve } from "node:path";
+
+const root = resolve(new URL("..", import.meta.url).pathname);
+const framework = resolve(root, "vendor/pocketjs/framework/src/index.ts");
+const generated = resolve(
+ root,
+ "vendor/pocketjs/framework/src/styles.generated.ts",
+);
+
+if (!existsSync(framework)) {
+ throw new Error(
+ "PocketJS framework sources are missing; initialize vendor/pocketjs first",
+ );
+}
+if (!existsSync(generated)) {
+ await Bun.write(
+ generated,
+ [
+ "// Generated placeholder; PocketJS tools/build.ts replaces this file.",
+ "export const STYLE_IDS: Readonly> = {};",
+ "",
+ ].join("\n"),
+ );
+}
diff --git a/scripts/pocket-contract.ts b/scripts/pocket-contract.ts
index ad5336a..3b7b3a7 100644
--- a/scripts/pocket-contract.ts
+++ b/scripts/pocket-contract.ts
@@ -21,7 +21,7 @@ export function pocketOutputDirectory(target: string): string {
function frameworkRoot(): string {
const root = resolve(`${repo}/vendor/pocketjs`);
- if (!existsSync(`${root}/scripts/pocket.ts`)) {
+ if (!existsSync(`${root}/tools/pocket.ts`)) {
throw new Error(
`PocketJS platform-contract toolchain not found at ${root}; initialize or update vendor/pocketjs`,
);
@@ -36,7 +36,7 @@ export async function compilePocketTarget(target: string): Promise statSync(src).mtimeMs) continue;
console.log(`openstrike-psp: cooking ${stem}`);
await $`cargo run --release -q -p pocket3d-cook -- ${src} --wads ${mapsRoot}/support --subdivide 32 -o ${p3d} --verify`.cwd(
- `${repo}vendor/pocketjs/pocket3d`,
+ `${repo}vendor/pocketjs/engine/pocket3d`,
);
}
@@ -83,7 +83,7 @@ const env = {
`-fno-stack-protector -I${sdk}/psp/include -I${sdk}/psp/sdk/include`,
AR_mipsel_sony_psp: `${llvm}/llvm-ar`,
RANLIB_mipsel_sony_psp: `${llvm}/llvm-ranlib`,
- RUST_PSP_TARGET: `${repo}vendor/pocketjs/native/targets/mipsel-sony-psp.json`,
+ RUST_PSP_TARGET: `${repo}vendor/pocketjs/hosts/psp/targets/mipsel-sony-psp.json`,
RUST_PSP_ABORT_ONLY: "1",
CARGO_PROFILE_DEV_OPT_LEVEL: process.env.CARGO_PROFILE_DEV_OPT_LEVEL ?? "3",
// e2e/bench builds skip the menu and boot straight into --map; interactive
diff --git a/scripts/symbian-compat-smoke.ts b/scripts/symbian-compat-smoke.ts
new file mode 100644
index 0000000..eda9c49
--- /dev/null
+++ b/scripts/symbian-compat-smoke.ts
@@ -0,0 +1,230 @@
+// Execute a compiled symbian-e7-dev guest against a strict in-process model
+// of the PocketJS HostOps/frame contract, then drive a real menu selection and
+// combat input. This catches startup/module-order failures (especially a
+// missing `strike` surface) without claiming to replace hardware acceptance.
+//
+// Usage:
+// bun scripts/symbian-compat-smoke.ts \
+// --js=/path/to/openstrike.js --pak=/path/to/openstrike.pak
+
+import { resolve } from "node:path";
+
+interface StrikeState {
+ phase: "menu" | "starting" | "live" | "won" | "lost";
+ hp: number;
+ ammo: number;
+ aliveBots: number;
+ totalBots: number;
+ wins: number;
+ losses: number;
+}
+
+interface StrikeEvent {
+ type: string;
+}
+
+interface StrikeSurface {
+ __dispatch?: (state: StrikeState, events: StrikeEvent[]) => void;
+}
+
+interface SmokeGlobals {
+ ui?: Record;
+ __pak?: ArrayBuffer;
+ frame?: (buttons: number, analog?: number, touches?: readonly number[]) => void;
+ strike?: StrikeSurface;
+}
+
+const flag = (name: string): string | undefined =>
+ process.argv
+ .slice(2)
+ .find((argument) => argument.startsWith(`${name}=`))
+ ?.slice(name.length + 1);
+
+const jsPath = flag("--js");
+const pakPath = flag("--pak");
+if (!jsPath || !pakPath) {
+ throw new Error(
+ "usage: bun scripts/symbian-compat-smoke.ts --js= --pak=",
+ );
+}
+
+const absoluteJs = resolve(jsPath);
+const absolutePak = resolve(pakPath);
+if (!(await Bun.file(absoluteJs).exists()) || !(await Bun.file(absolutePak).exists())) {
+ throw new Error(`missing compatibility smoke input: ${absoluteJs} or ${absolutePak}`);
+}
+
+let nextNode = 2;
+let nextTexture = 1;
+let nextAnimation = 1;
+let operationCount = 0;
+let focusedNode = 0;
+const op = () => {
+ operationCount++;
+};
+
+const globals = globalThis as typeof globalThis & SmokeGlobals;
+globals.ui = {
+ __host: "symbian-e7-dev",
+ __hostAbi: 4,
+ __viewport: { w: 640, h: 360 },
+ createNode: () => {
+ op();
+ return nextNode++;
+ },
+ destroyNode: op,
+ insertBefore: op,
+ removeChild: op,
+ setStyle: op,
+ setProp: op,
+ setPropBatch: op,
+ setText: op,
+ replaceText: op,
+ uploadTexture: () => {
+ op();
+ return nextTexture++;
+ },
+ uploadImgEntry: () => {
+ op();
+ return nextTexture++;
+ },
+ setImage: op,
+ setSprite: op,
+ animate: () => {
+ op();
+ return nextAnimation++;
+ },
+ cancelAnim: op,
+ setFocus: (node: number) => {
+ focusedNode = node;
+ op();
+ },
+ setActive: op,
+ loadStyles: op,
+ loadFontAtlas: op,
+ measureText: (text: string) => text.length * 7,
+};
+globals.__pak = await Bun.file(absolutePak).arrayBuffer();
+
+// The output is a trusted local PocketJS IIFE generated from this checkout.
+const source = await Bun.file(absoluteJs).text();
+(0, eval)(source);
+// The native host drains pending QuickJS jobs after JS_Eval before starting
+// its frame timer. Mirror that boundary for Solid's mount effects.
+await Promise.resolve();
+await Promise.resolve();
+
+if (typeof globals.frame !== "function") {
+ throw new Error("compatibility bundle did not install the PocketJS frame hook");
+}
+const surface = globals.strike;
+if (!surface?.__dispatch) {
+ throw new Error("compatibility bundle did not install the strike dispatch surface");
+}
+
+let latest: StrikeState | undefined;
+const eventTypes: string[] = [];
+const dispatch = surface.__dispatch;
+surface.__dispatch = (state, events) => {
+ latest = { ...state };
+ eventTypes.push(...events.map((event) => event.type));
+ dispatch(state, events);
+};
+
+const BTN_CIRCLE = 0x2000;
+const BTN_RTRIGGER = 0x0200;
+const BTN_LEFT = 0x0080;
+const BTN_RIGHT = 0x0020;
+const frame = (buttons = 0) => globals.frame!(buttons, 0x8080, []);
+const phase = (): StrikeState["phase"] | undefined => latest?.phase;
+const snapshot = (): StrikeState | undefined => (latest ? { ...latest } : undefined);
+
+// Let onMount establish the menu's first focused map, then activate it using
+// the exact Circle/Enter edge the E7 native host publishes.
+frame();
+if (phase() !== "menu") {
+ throw new Error(`expected menu boot, received ${phase() ?? "no state"}`);
+}
+frame(BTN_CIRCLE);
+frame();
+if (phase() !== "starting") {
+ throw new Error(
+ `map selection did not enter a round: ${phase() ?? "no state"} (focused node ${focusedNode})`,
+ );
+}
+
+for (let index = 0; index < 60 && phase() !== "live"; index++) frame();
+if (phase() !== "live") {
+ throw new Error(`rules did not advance starting -> live: ${phase() ?? "no state"}`);
+}
+if (!latest) throw new Error("live phase was reached without a state snapshot");
+
+const botsBefore = latest.aliveBots;
+frame(BTN_RTRIGGER);
+frame();
+if (!eventTypes.includes("hit") || !latest || latest.aliveBots >= botsBefore) {
+ throw new Error(
+ `combat input did not produce a hit (bots ${botsBefore} -> ${latest?.aliveBots ?? "?"})`,
+ );
+}
+
+const fireBurst = (frames: number) => {
+ for (let index = 0; index < frames; index++) frame(BTN_RTRIGGER);
+ frame();
+};
+
+// Clear the two flanking center-lane bots using only the E7 button protocol.
+// Integer frame turns intentionally allow body hits, exercising automatic
+// fire rather than relying on the initial exact headshot.
+for (let index = 0; index < 2; index++) frame(BTN_LEFT);
+fireBurst(12);
+const leftLaneState = snapshot();
+if (!leftLaneState || leftLaneState.aliveBots !== 1) {
+ throw new Error(
+ `left-lane engagement did not leave one bot: ${leftLaneState?.aliveBots ?? "?"}`,
+ );
+}
+for (let index = 0; index < 4; index++) frame(BTN_RIGHT);
+fireBurst(12);
+for (let index = 0; index < 4 && phase() !== "won"; index++) frame();
+const wonState = snapshot();
+if (!wonState || phase() !== "won" || wonState.aliveBots !== 0 || wonState.wins !== 1) {
+ throw new Error(
+ `elimination did not reach rules-owned win state: ${phase() ?? "?"}, ` +
+ `bots=${wonState?.aliveBots ?? "?"}, wins=${wonState?.wins ?? "?"}`,
+ );
+}
+
+for (let index = 0; index < 130 && phase() !== "starting"; index++) frame();
+const resetState = snapshot();
+if (
+ !resetState ||
+ phase() !== "starting" ||
+ resetState.aliveBots !== resetState.totalBots ||
+ !eventTypes.includes("roundReset")
+) {
+ throw new Error(
+ `rules-owned next round did not reset: ${phase() ?? "?"}, ` +
+ `bots=${resetState?.aliveBots ?? "?"}/${resetState?.totalBots ?? "?"}`,
+ );
+}
+if (operationCount === 0) {
+ throw new Error("compatibility renderer emitted no HostOps");
+}
+
+console.log(
+ JSON.stringify(
+ {
+ result: "ok",
+ mode: "E7 2D compatibility (not the Pocket3D renderer)",
+ phase: latest.phase,
+ bots: `${latest.aliveBots}/${latest.totalBots}`,
+ hp: latest.hp,
+ ammo: latest.ammo,
+ events: eventTypes,
+ hostOperations: operationCount,
+ },
+ null,
+ 2,
+ ),
+);
diff --git a/scripts/symbian-manifest.ts b/scripts/symbian-manifest.ts
new file mode 100644
index 0000000..38e5624
--- /dev/null
+++ b/scripts/symbian-manifest.ts
@@ -0,0 +1,99 @@
+// Derive the explicitly reduced E7 package from the canonical Pocket
+// contract. Keeping this mechanical avoids a second hand-maintained manifest
+// drifting from OpenStrike's version, capabilities, or viewport policy.
+
+interface PocketLikeManifest {
+ $schema?: unknown;
+ pocket?: unknown;
+ id?: unknown;
+ name?: unknown;
+ title?: unknown;
+ version?: unknown;
+ engine?: {
+ capabilities?: {
+ requires?: unknown;
+ enhances?: unknown;
+ [key: string]: unknown;
+ };
+ [key: string]: unknown;
+ };
+ app?: {
+ entry?: unknown;
+ output?: unknown;
+ framework?: unknown;
+ viewport?: unknown;
+ [key: string]: unknown;
+ };
+ [key: string]: unknown;
+}
+
+export const SYMBIAN_COMPAT_ENTRY = "game/openstrike-symbian.tsx";
+export const SYMBIAN_COMPAT_OUTPUT = "openstrike-e7-compat";
+export const SYMBIAN_COMPAT_LIVE_VIEWPORT = "display.viewport.live";
+export const SYMBIAN_COMPAT_DYNAMIC_VIEWPORT = {
+ default: [640, 360],
+ min: [360, 360],
+ max: [640, 640],
+} as const;
+
+export function deriveSymbianCompatManifest(input: unknown): PocketLikeManifest {
+ if (
+ input === null ||
+ typeof input !== "object" ||
+ Array.isArray(input)
+ ) {
+ throw new Error("canonical pocket.json must be an object");
+ }
+ const canonical = input as PocketLikeManifest;
+ const capabilities = canonical.engine?.capabilities;
+ const enhances = capabilities?.enhances;
+ const viewport = canonical.app?.viewport;
+ if (
+ canonical.pocket !== 2 ||
+ typeof canonical.id !== "string" ||
+ typeof canonical.name !== "string" ||
+ typeof canonical.title !== "string" ||
+ typeof canonical.version !== "string" ||
+ canonical.app === undefined ||
+ canonical.app.entry !== "game/openstrike.tsx" ||
+ capabilities === undefined ||
+ !Array.isArray(enhances) ||
+ !enhances.every((capability) => typeof capability === "string") ||
+ viewport === null ||
+ typeof viewport !== "object" ||
+ Array.isArray(viewport) ||
+ !("fixed" in viewport) ||
+ "dynamic" in viewport
+ ) {
+ throw new Error(
+ "canonical pocket.json identity/version/capabilities/fixed viewport is incomplete or unexpected",
+ );
+ }
+
+ const derived = structuredClone(canonical);
+ derived.id = `${canonical.id}.e7-compat`;
+ derived.name = "open-strike-e7-compat";
+ derived.title = "OpenStrike E7 2D Compat";
+ derived.engine = {
+ ...derived.engine!,
+ capabilities: {
+ ...derived.engine!.capabilities!,
+ enhances: [
+ ...enhances,
+ ...(enhances.includes(SYMBIAN_COMPAT_LIVE_VIEWPORT)
+ ? []
+ : [SYMBIAN_COMPAT_LIVE_VIEWPORT]),
+ ],
+ },
+ };
+ derived.app = {
+ ...derived.app!,
+ entry: SYMBIAN_COMPAT_ENTRY,
+ output: SYMBIAN_COMPAT_OUTPUT,
+ viewport: {
+ ...(structuredClone(viewport) as Record),
+ dynamic: structuredClone(SYMBIAN_COMPAT_DYNAMIC_VIEWPORT),
+ },
+ };
+ return derived;
+}
diff --git a/scripts/symbian.ts b/scripts/symbian.ts
new file mode 100644
index 0000000..addfe9c
--- /dev/null
+++ b/scripts/symbian.ts
@@ -0,0 +1,154 @@
+// Build OpenStrike's independently installable Nokia E7 compatibility SIS
+// through the canonical PocketJS Symbian toolchain.
+//
+// The pinned vendor is authoritative. POCKETJS_ROOT/--pocketjs-root remains
+// available only for deliberately testing a newer PocketJS checkout.
+//
+// `--guest-only` stops before Rust/qmake/SIS packaging and runs the HostOps
+// gameplay smoke against dist/pocket/symbian-e7-dev.
+
+import { existsSync, mkdirSync } from "node:fs";
+import { delimiter, dirname, resolve } from "node:path";
+import { pathToFileURL } from "node:url";
+import {
+ deriveSymbianCompatManifest,
+ SYMBIAN_COMPAT_OUTPUT,
+} from "./symbian-manifest.ts";
+
+interface ResolvedPlan {
+ readonly app: { readonly output: string };
+}
+
+interface SymbianProfileModule {
+ resolveSymbianE7BuildPlan(input: unknown): ResolvedPlan;
+}
+
+const repo = resolve(new URL("..", import.meta.url).pathname);
+const args = process.argv.slice(2);
+
+function option(name: string): string | undefined {
+ const equals = args.find((argument) => argument.startsWith(`${name}=`));
+ if (equals) return equals.slice(name.length + 1);
+ const index = args.indexOf(name);
+ if (index >= 0) return args[index + 1];
+ return undefined;
+}
+
+const vendorRoot = resolve(repo, "vendor/pocketjs");
+const pocketjsRoot = resolve(
+ option("--pocketjs-root") ??
+ process.env.POCKETJS_ROOT ??
+ vendorRoot,
+);
+const symbianTool = resolve(pocketjsRoot, "tools/symbian.ts");
+const symbianProfile = resolve(pocketjsRoot, "tools/symbian-profile.ts");
+const pocketBuild = resolve(pocketjsRoot, "tools/build.ts");
+if (
+ !existsSync(symbianTool) ||
+ !existsSync(symbianProfile) ||
+ !existsSync(pocketBuild)
+) {
+ throw new Error(
+ `PocketJS Symbian toolchain is unavailable at ${pocketjsRoot}; ` +
+ "set POCKETJS_ROOT or --pocketjs-root to a current PocketJS checkout",
+ );
+}
+
+const canonicalPath = resolve(repo, "pocket.json");
+const canonical = await Bun.file(canonicalPath).json();
+const manifest = deriveSymbianCompatManifest(canonical);
+const generatedRoot = resolve(repo, ".pocket/symbian-e7-dev");
+const manifestPath = resolve(generatedRoot, "pocket.generated.json");
+mkdirSync(dirname(manifestPath), { recursive: true });
+await Bun.write(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
+
+const profileUrl = pathToFileURL(symbianProfile);
+profileUrl.searchParams.set("mtime", String(Date.now()));
+const profile = await import(profileUrl.href) as SymbianProfileModule;
+const plan = profile.resolveSymbianE7BuildPlan(manifest);
+if (plan.app.output !== SYMBIAN_COMPAT_OUTPUT) {
+ throw new Error(
+ `derived Symbian output mismatch: ${plan.app.output}`,
+ );
+}
+
+const bun = Bun.which("bun");
+if (!bun) throw new Error("Bun is required to build OpenStrike for Symbian");
+const rustup = Bun.which("rustup");
+const subprocessEnv = rustup
+ ? {
+ ...process.env,
+ // PocketJS invokes its pinned toolchain through rustup, but Cargo then
+ // resolves `rustc` through PATH. Keep the rustup proxy ahead of a
+ // Homebrew stable compiler so the upstream nightly selection survives.
+ PATH: [dirname(rustup), process.env.PATH].filter(Boolean).join(delimiter),
+ }
+ : process.env;
+
+async function run(command: readonly string[]): Promise {
+ const child = Bun.spawn({
+ cmd: [...command],
+ cwd: repo,
+ env: subprocessEnv,
+ stdout: "inherit",
+ stderr: "inherit",
+ });
+ const exitCode = await child.exited;
+ if (exitCode !== 0) {
+ throw new Error(`${command.slice(0, 3).join(" ")} failed with exit code ${exitCode}`);
+ }
+}
+
+async function smoke(js: string, pak: string): Promise {
+ await run([
+ bun!,
+ resolve(repo, "scripts/symbian-compat-smoke.ts"),
+ `--js=${js}`,
+ `--pak=${pak}`,
+ ]);
+}
+
+if (args.includes("--guest-only")) {
+ const planPath = resolve(generatedRoot, "plan.json");
+ const outdir = resolve(repo, "dist/pocket/symbian-e7-dev");
+ await Bun.write(planPath, JSON.stringify(plan, null, 2) + "\n");
+ await run([
+ bun,
+ pocketBuild,
+ `--plan=${planPath}`,
+ `--project-root=${repo}`,
+ `--outdir=${outdir}`,
+ ]);
+ await smoke(
+ resolve(outdir, `${SYMBIAN_COMPAT_OUTPUT}.js`),
+ resolve(outdir, `${SYMBIAN_COMPAT_OUTPUT}.pak`),
+ );
+ console.log(`OpenStrike E7 compatibility guest: ${outdir}`);
+} else {
+ const outputRoot = resolve(repo, "dist/symbian");
+ const command = [
+ bun,
+ symbianTool,
+ "build",
+ "app",
+ "--manifest",
+ manifestPath,
+ "--project-root",
+ repo,
+ "--outdir",
+ outputRoot,
+ "--sis-version",
+ option("--sis-version") ?? String(manifest.version),
+ ];
+ const uid = option("--uid");
+ if (uid) command.push("--uid", uid);
+ await run(command);
+
+ const payload = resolve(outputRoot, "build", SYMBIAN_COMPAT_OUTPUT);
+ await smoke(resolve(payload, "app.js"), resolve(payload, "app.pak"));
+ const sis = resolve(outputRoot, `${SYMBIAN_COMPAT_OUTPUT}.sis`);
+ if (!existsSync(sis)) {
+ throw new Error(`Symbian build did not produce ${sis}`);
+ }
+ console.log(`OpenStrike E7 compatibility SIS: ${sis}`);
+}
diff --git a/scripts/vita.ts b/scripts/vita.ts
index 3db548a..2ec5296 100644
--- a/scripts/vita.ts
+++ b/scripts/vita.ts
@@ -17,7 +17,7 @@ import {
statSync,
} from "node:fs";
import { compilePocketTarget, nativePocketContract } from "./pocket-contract.ts";
-import { packageVitaVpk } from "../vendor/pocketjs/scripts/vita-package.ts";
+import { packageVitaVpk } from "../vendor/pocketjs/tools/vita-package.ts";
const repo = new URL("..", import.meta.url).pathname;
const home = process.env.HOME ?? "";
@@ -72,7 +72,7 @@ for (const file of bsps) {
if (existsSync(cooked) && statSync(cooked).mtimeMs > statSync(source).mtimeMs) continue;
console.log(`openstrike-vita: cooking ${stem}`);
await $`cargo run --release -q -p pocket3d-cook -- ${source} --wads ${mapsRoot}/support --subdivide 32 -o ${cooked} --verify`.cwd(
- `${repo}vendor/pocketjs/pocket3d`,
+ `${repo}vendor/pocketjs/engine/pocket3d`,
);
}
diff --git a/test/openstrike-vita-artwork.test.ts b/test/openstrike-vita-artwork.test.ts
index a6fbd46..cbd96b2 100644
--- a/test/openstrike-vita-artwork.test.ts
+++ b/test/openstrike-vita-artwork.test.ts
@@ -4,7 +4,7 @@ import { inflateSync } from "node:zlib";
import {
resolveVitaPackageAssets,
VITA_REQUIRED_SYSTEM_ASSETS,
-} from "../vendor/pocketjs/scripts/vita-package.ts";
+} from "../vendor/pocketjs/tools/vita-package.ts";
const root = new URL("..", import.meta.url).pathname;
const icon = `${root}crates/openstrike-vita/static/sce_sys/icon0.png`;
diff --git a/test/psp-toolchain.test.ts b/test/psp-toolchain.test.ts
index ccfc4c8..d23e770 100644
--- a/test/psp-toolchain.test.ts
+++ b/test/psp-toolchain.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
-import { PSP_TOOLCHAIN } from "../vendor/pocketjs/scripts/psp-toolchain.ts";
+import { PSP_TOOLCHAIN } from "../vendor/pocketjs/tools/psp-toolchain.ts";
const script = await Bun.file(new URL("../scripts/psp.ts", import.meta.url)).text();
const packageJson = await Bun.file(new URL("../package.json", import.meta.url)).json();
@@ -12,8 +12,8 @@ const dependencyContract = (
describe("PSP toolchain contract", () => {
test("delegates bootstrap and builds to PocketJS's pinned authority", () => {
- expect(packageJson.scripts.bootstrap).toBe("bun vendor/pocketjs/scripts/bootstrap.ts");
- expect(script).toContain('from "../vendor/pocketjs/scripts/psp-toolchain.ts"');
+ expect(packageJson.scripts.bootstrap).toBe("bun vendor/pocketjs/tools/bootstrap.ts");
+ expect(script).toContain('from "../vendor/pocketjs/tools/psp-toolchain.ts"');
expect(script).toContain("resolvePspBuildToolchain()");
expect(script).toContain("...toolchain.environment");
expect(script).toContain("toolchain.manifest.rust.toolchain");
diff --git a/test/symbian-compat.test.ts b/test/symbian-compat.test.ts
new file mode 100644
index 0000000..023f105
--- /dev/null
+++ b/test/symbian-compat.test.ts
@@ -0,0 +1,179 @@
+import { describe, expect, test } from "bun:test";
+import {
+ SYMBIAN_COMPAT_MAPS,
+ SymbianCompatSimulation,
+ type SymbianCompatInput,
+} from "../game/symbian-compat-sim.ts";
+import {
+ SYMBIAN_COMPAT_DYNAMIC_VIEWPORT,
+ deriveSymbianCompatManifest,
+ SYMBIAN_COMPAT_ENTRY,
+ SYMBIAN_COMPAT_LIVE_VIEWPORT,
+ SYMBIAN_COMPAT_OUTPUT,
+} from "../scripts/symbian-manifest.ts";
+
+const IDLE: SymbianCompatInput = {
+ forward: 0,
+ turn: 0,
+ fire: false,
+ reload: false,
+};
+
+const LIVE = { ...IDLE, forward: 1 };
+const FIRE = { ...IDLE, fire: true };
+
+function enterLive(simulation: SymbianCompatSimulation, bots = 3): void {
+ simulation.queueConfigureBots({
+ count: bots,
+ speed: 190,
+ attackInterval: 1.4,
+ damageMin: 8,
+ damageMax: 14,
+ });
+ simulation.flushCommands();
+ simulation.loadMap(0);
+ simulation.queueSetPhase("live");
+ simulation.flushCommands();
+}
+
+describe("OpenStrike Nokia E7 2D compatibility simulation", () => {
+ test("derives the compatibility package from canonical pocket.json", async () => {
+ const canonical = await Bun.file(
+ new URL("../pocket.json", import.meta.url),
+ ).json() as Record;
+ const derived = deriveSymbianCompatManifest(canonical) as Record;
+
+ expect(derived).not.toBe(canonical);
+ expect(derived.version).toBe(canonical.version);
+ expect(canonical.app.viewport).not.toHaveProperty("dynamic");
+ expect(derived.app.viewport.fixed).toEqual(canonical.app.viewport.fixed);
+ expect(derived.app.viewport.dynamic).toEqual(SYMBIAN_COMPAT_DYNAMIC_VIEWPORT);
+ expect(canonical.engine.capabilities.enhances).not.toContain(
+ SYMBIAN_COMPAT_LIVE_VIEWPORT,
+ );
+ expect(derived.engine.capabilities.enhances).toEqual([
+ ...canonical.engine.capabilities.enhances,
+ SYMBIAN_COMPAT_LIVE_VIEWPORT,
+ ]);
+ expect(derived.engine.capabilities.enhances).not.toContain("input.touch");
+ expect(derived).toMatchObject({
+ id: `${canonical.id}.e7-compat`,
+ name: "open-strike-e7-compat",
+ title: "OpenStrike E7 2D Compat",
+ app: {
+ entry: SYMBIAN_COMPAT_ENTRY,
+ output: SYMBIAN_COMPAT_OUTPUT,
+ framework: "solid",
+ },
+ });
+ expect(canonical.app.entry).toBe("game/openstrike.tsx");
+ });
+
+ test("keeps the native strike command ordering and completes a combat round", () => {
+ const simulation = new SymbianCompatSimulation();
+ expect(simulation.snapshot().phase).toBe("menu");
+ expect(SYMBIAN_COMPAT_MAPS).toHaveLength(8);
+
+ enterLive(simulation, 1);
+ const before = simulation.scene().player;
+ simulation.tick(LIVE, 0.1);
+ expect(simulation.scene().player.y).toBeLessThan(before.y);
+
+ // The first spawn is centered on the player's initial sightline, so this
+ // is a deterministic precision hit rather than a random test fixture.
+ simulation.tick(FIRE, 0.1);
+ expect(simulation.drainEvents()).toEqual([
+ {
+ type: "hit",
+ bot: 0,
+ headshot: true,
+ damage: 100,
+ fatal: true,
+ },
+ ]);
+ expect(simulation.snapshot().aliveBots).toBe(0);
+
+ simulation.queueAddWin();
+ simulation.queueSetPhase("won");
+ // Guest intents do not mutate facts until the host-side flush.
+ expect(simulation.snapshot().phase).toBe("live");
+ simulation.flushCommands();
+ expect(simulation.snapshot()).toMatchObject({ phase: "won", wins: 1 });
+
+ simulation.queueResetRound();
+ simulation.flushCommands();
+ expect(simulation.snapshot()).toMatchObject({
+ phase: "starting",
+ hp: 100,
+ aliveBots: 1,
+ wins: 1,
+ });
+ expect(simulation.drainEvents()).toEqual([{ type: "roundReset" }]);
+ });
+
+ test("supports magazine depletion and an edge-triggered reload", () => {
+ const simulation = new SymbianCompatSimulation();
+ simulation.queueConfigureWeapon({
+ magSize: 2,
+ reserve: 3,
+ fireInterval: 0.03,
+ reloadTime: 0.2,
+ damageBody: 1,
+ damageHead: 1,
+ });
+ simulation.flushCommands();
+ enterLive(simulation, 0);
+
+ simulation.tick(FIRE, 0.1);
+ simulation.tick(FIRE, 0.1);
+ expect(simulation.snapshot()).toMatchObject({ ammo: 0, reserve: 3 });
+
+ simulation.tick({ ...IDLE, reload: true }, 0.05);
+ expect(simulation.snapshot().reloading).toBe(true);
+ simulation.tick(IDLE, 0.1);
+ simulation.tick(IDLE, 0.1);
+ expect(simulation.snapshot()).toMatchObject({
+ ammo: 2,
+ reserve: 1,
+ reloading: false,
+ });
+ });
+
+ test("bots pursue, attack, and emit the same facts consumed by rules and HUD", () => {
+ const simulation = new SymbianCompatSimulation();
+ enterLive(simulation, 1);
+
+ const events = [];
+ for (let step = 0; step < 120 && simulation.snapshot().hp === 100; step++) {
+ simulation.tick(IDLE, 0.1);
+ events.push(...simulation.drainEvents());
+ }
+ expect(simulation.snapshot().hp).toBeLessThan(100);
+ expect(events.some((event) => event.type === "playerDamaged")).toBe(true);
+ });
+
+ test("replays an input sequence deterministically", () => {
+ const left = new SymbianCompatSimulation();
+ const right = new SymbianCompatSimulation();
+ enterLive(left, 3);
+ enterLive(right, 3);
+
+ const sequence: SymbianCompatInput[] = [
+ LIVE,
+ LIVE,
+ { ...IDLE, turn: -1 },
+ { ...IDLE, turn: -1, fire: true },
+ IDLE,
+ { ...IDLE, reload: true },
+ ];
+ for (let cycle = 0; cycle < 20; cycle++) {
+ const input = sequence[cycle % sequence.length];
+ left.tick(input, 1 / 30);
+ right.tick(input, 1 / 30);
+ }
+
+ expect(right.snapshot()).toEqual(left.snapshot());
+ expect(right.scene()).toEqual(left.scene());
+ expect(right.drainEvents()).toEqual(left.drainEvents());
+ });
+});
diff --git a/tsconfig.json b/tsconfig.json
index b7b1d38..76d83ad 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -10,8 +10,12 @@
"allowImportingTsExtensions": true,
"types": ["bun-types"],
"paths": {
- "@pocketjs/framework": ["./vendor/pocketjs/src/index.ts"],
- "@pocketjs/framework/*": ["./vendor/pocketjs/src/*"]
+ "@pocketjs/framework": ["./vendor/pocketjs/framework/src/index.ts"],
+ "@pocketjs/framework/solid": ["./vendor/pocketjs/framework/src/index.ts"],
+ "@pocketjs/framework/input": ["./vendor/pocketjs/framework/src/input-api.ts"],
+ "@pocketjs/framework/manifest": ["./vendor/pocketjs/framework/src/manifest/index.ts"],
+ "@pocketjs/framework/*": ["./vendor/pocketjs/framework/src/*"],
+ "tools/*": ["./vendor/pocketjs/tools/*"]
}
},
"include": ["game", "scripts"]
diff --git a/vendor/pocketjs b/vendor/pocketjs
index 42d255d..91a0529 160000
--- a/vendor/pocketjs
+++ b/vendor/pocketjs
@@ -1 +1 @@
-Subproject commit 42d255d6de02906c6371112adc1a14d856c0e161
+Subproject commit 91a0529e8fe8a2da7068d0e87b796314835f3748
From 2065078bcebdb990a07eeeccd42528f9f4f0d6e4 Mon Sep 17 00:00:00 2001
From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com>
Date: Sat, 25 Jul 2026 19:33:17 +0800
Subject: [PATCH 2/6] feat(symbian): port full OpenStrike to Nokia E7
---
.gitignore | 2 +
Cargo.toml | 7 +-
README.md | 114 +--
crates/openstrike-symbian/Cargo.lock | 167 ++++
crates/openstrike-symbian/Cargo.toml | 30 +
crates/openstrike-symbian/src/input.rs | 93 +++
crates/openstrike-symbian/src/lib.rs | 457 +++++++++++
crates/openstrike-symbian/src/quickjs.rs | 110 +++
crates/openstrike-vita/src/lib.rs | 1 -
crates/openstrike-vita/src/present_data.rs | 1 +
game/openstrike-symbian.tsx | 18 -
game/screen.tsx | 4 +-
game/sdk.ts | 34 +-
game/symbian-compat-sim.ts | 534 ------------
game/symbian-compat.tsx | 300 -------
package.json | 2 +-
pocket.json | 19 +-
scripts/symbian-compat-smoke.ts | 230 ------
scripts/symbian-manifest.ts | 99 ---
scripts/symbian.ts | 893 ++++++++++++++++++---
test/symbian-compat.test.ts | 179 -----
test/symbian-tooling.test.ts | 278 +++++++
vendor/pocketjs | 2 +-
23 files changed, 2015 insertions(+), 1559 deletions(-)
create mode 100644 crates/openstrike-symbian/Cargo.lock
create mode 100644 crates/openstrike-symbian/Cargo.toml
create mode 100644 crates/openstrike-symbian/src/input.rs
create mode 100644 crates/openstrike-symbian/src/lib.rs
create mode 100644 crates/openstrike-symbian/src/quickjs.rs
delete mode 100644 game/openstrike-symbian.tsx
delete mode 100644 game/symbian-compat-sim.ts
delete mode 100644 game/symbian-compat.tsx
delete mode 100644 scripts/symbian-compat-smoke.ts
delete mode 100644 scripts/symbian-manifest.ts
delete mode 100644 test/symbian-compat.test.ts
create mode 100644 test/symbian-tooling.test.ts
diff --git a/.gitignore b/.gitignore
index 9514dd3..c43293e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,6 +18,8 @@ crates/openstrike-psp/target/
# Vita VPK crate and staged copyrighted map data.
crates/openstrike-vita/target/
crates/openstrike-vita/static/maps/
+# Symbian app-specific static core (standalone nightly build-std crate).
+crates/openstrike-symbian/target/
# script/e2e outputs
/out/
/pocketjs-dbg/
diff --git a/Cargo.toml b/Cargo.toml
index 791001d..6868e28 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -3,7 +3,12 @@ resolver = "2"
members = ["crates/openstrike", "crates/openstrike-core"]
# The vendored engine keeps its own workspaces (Cargo would otherwise try to
# adopt the path-dependency crates into this one and break their inheritance).
-exclude = ["vendor", "crates/openstrike-psp", "crates/openstrike-vita"]
+exclude = [
+ "vendor",
+ "crates/openstrike-psp",
+ "crates/openstrike-vita",
+ "crates/openstrike-symbian",
+]
[workspace.package]
version = "0.1.0"
diff --git a/README.md b/README.md
index aeb6d1b..c561970 100644
--- a/README.md
+++ b/README.md
@@ -15,10 +15,9 @@
]A CS-like FPS on classic BSP maps — Pocket3D worlds, a PocketJS JSX HUD, gameplay in TypeScript.
-The full 3D game targets desktop (wgpu), PSP (sceGu), and PS Vita
-(vita2d/GXM); Nokia E7 has a separately labelled playable 2D compatibility
-mode while a Pocket3D Symbian backend is still absent. The bottom shot is the
-real PSP running at a locked 60 fps.
+The full 3D game targets desktop (wgpu), PSP (sceGu), PS Vita
+(vita2d/GXM), and Nokia E7 (OpenGL ES 2). The bottom shot is the real PSP
+running at a locked 60 fps.
A single-player CS-like FPS built on the **Pocket runtime family**: a Rust
core (Pocket3D) simulates and renders; the *product* — round rules, weapon
@@ -40,13 +39,15 @@ crates/openstrike-psp the PSP build: an EBOOT on pocket3d-gu (sceGu) and
the PocketJS PSP host — same surfaces, same bundle
crates/openstrike-vita the PS Vita build: a native 960×544 VPK on
pocket3d-vita + PocketJS's vita2d/GXM host
+crates/openstrike-symbian
+ the E7 app core: the shared simulation + Pocket3D
+ GLES2, statically linked into PocketJS's Qt host
game/ the product bundle (JS/TSX) — runs on every target
├─ sdk.ts `strike` SDK: state snapshots, events, commands
├─ rules.ts the base game as the FIRST MOD: round flow, scoring,
│ weapon + bot tuning
- ├─ hud.tsx the HUD — a full PocketJS app (Solid + Tailwind)
- └─ symbian-compat* explicitly reduced E7 top-down sim + renderer
+ └─ hud.tsx the HUD — a full PocketJS app (Solid + Tailwind)
vendor/pocketjs the engine, pinned as a git submodule
```
@@ -83,12 +84,11 @@ personal-fork checkout is required.
[`pocket.json`](pocket.json) is the portable Pocket application contract. It
requires baked glyphs and buttons, and uses the left analog API when the
target provides it. PSP/Vita select its 480x272 fixed `integer-fit` viewport;
-the derived Nokia E7 manifest alone adds `display.viewport.live` and a
-640x360-through-360x640 dynamic viewport. The compatibility app does not
-claim touch, dynamic text, or a stock Pocket3D capability. Pocket3D remains
-an extension implemented by OpenStrike's custom native hosts. Every target
-build validates its manifest, runs the ordinary reachable TypeScript check,
-writes
+the Nokia E7 selects the same manifest's `display.viewport.live` enhancement
+and 640x360-through-360x640 dynamic viewport. Pocket3D remains an extension
+implemented by OpenStrike's custom native hosts rather than a private
+manifest channel. Every target build validates its manifest, runs the
+ordinary reachable TypeScript check, writes
`.pocket//plan.json`, and delegates compilation to `pocket compile`.
The public `@pocketjs/framework/manifest` helpers verify the build-plan
checksum, project stable `HostBuildInputs`, and generate Cargo's target, host
@@ -97,54 +97,68 @@ ABI and viewport environment. Target artifacts are isolated under
another. At runtime PocketJS compares target and host ABI; the plan checksum
is build-time consistency data, not a runtime trust mechanism.
-## Nokia E7: playable 2D compatibility mode
-
-The generic PocketJS Symbian host has a 2D UI renderer, but it does not yet
-provide OpenStrike's native `strike` surface, BSP loader, collision world, or
-a Pocket3D OpenGL ES renderer. OpenStrike therefore ships an intentionally
-named **E7 2D COMPAT / NO 3D** mode instead of presenting a static mock or
-mislabeling it as the full FPS.
-
-This fallback is entirely inside this repository. The build mechanically
-derives an E7 manifest from canonical `pocket.json` and selects the dedicated
-`game/openstrike-symbian.tsx` entry; there is no second hand-maintained
-contract. On `symbian-e7-dev`—and only when a future native `strike` surface
-is absent—that entry installs a deterministic top-down simulation. The
-ordinary map menu starts a round; the existing `game/rules.ts` still owns
-phase timing, wins, losses and round resets; the existing HUD consumes real
-state snapshots and hit/damage events. Player movement, aiming, automatic
-fire, reloading, pursuing/attacking bots, death and elimination are live
-gameplay. PSP, Vita and desktop continue to build the canonical entry and use
-their native simulation and 3D renderers unchanged.
+## Nokia E7: full 3D OpenStrike
+
+The E7 build is the real FPS, not the former top-down substitute. Its
+application-specific static core source-shares `openstrike-core`, installs the
+native `strike` surface before the canonical `game/openstrike.tsx` bundle is
+evaluated, reads the cooked Dust2 BSP directly from the PocketJS PAK, and
+renders it through Pocket3D's OpenGL ES 2 backend. The depth-tested world,
+bots, weapon viewmodel, muzzle effects, collision, round logic, and PocketJS
+JSX HUD all run in the same app.
+
+The Qt host owns QuickJS, the GL context, and presentation. The native
+extension renders the 3D scene first; PocketJS composites the retained HUD
+over it. At the E7 host's 30 Hz presentation rate the simulation advances two
+fixed 60 Hz ticks per frame. Rotation updates the camera aspect and live UI
+viewport without rebuilding the simulation, so an active match survives
+landscape/portrait changes.
E7 keyboard controls:
| Key | Action |
| --- | --- |
-| Arrow up/down | move forward/back |
-| Arrow left/right | turn |
+| W / A / S / D | move |
+| Arrow keys | look |
| E | fire |
-| S | reload |
-| Enter | choose a map / confirm |
-| Backspace or Home | open the return-to-menu dialog |
-
-The pure compatibility simulation is covered by
-`bun run test:symbian-compat`. The pinned `vendor/pocketjs` revision contains
-the Symbian toolchain; `POCKETJS_ROOT` remains available only for testing a
-newer PocketJS checkout. The guest-only command compiles the dedicated bundle
-and automatically exercises the actual HostOps/frame protocol from menu
-selection through a combat hit:
+| Space | jump |
+| R | reload |
+| Shift | walk |
+| Backspace or Home | open/close the return-to-menu dialog |
+| Arrow keys + Enter (menus) | select and confirm |
+
+The first E7 package intentionally embeds only `de_dust2`; map and WAD data
+remain user-supplied and are never committed. Touch aiming is not implemented
+yet. The script generates a private Symbian entry and `pak.json` under
+`.pocket/symbian-e7-dev`, so PSP and Vita builds never see or duplicate this
+map. To create or refresh the ignored `dist/maps/de_dust2.p3d`,
+`OPENSTRIKE_MAPS` points to a tree with `maps/de_dust2.bsp` and
+`support/*.wad`; otherwise the local default is
+`~/Downloads/cs-maps-20260705-1836`. An already valid cooked P3D is also a
+complete build input when the copyrighted source tree is not present.
```sh
-bun run test:symbian-bundle
-bun run build:symbian
+# Hermetic manifest/PAK/tooling tests: no Docker, map data, or device needed.
+bun run test:symbian
+
+# Cook only Dust2, build the real JS/PAK, and verify the embedded P3D byte-for-byte.
+OPENSTRIKE_MAPS=~/path/to/cs-maps bun run test:symbian-bundle
+
+# Build the app-specific pinned-nightly core and the independently installable SIS.
+OPENSTRIKE_MAPS=~/path/to/cs-maps bun run build:symbian
```
-The full build writes
-`dist/symbian/openstrike-e7-compat.sis`, with a stable private UID derived
-from `dev.pocket-stack.openstrike.e7-compat`, and reruns the same smoke against
-the packaged payload. That harness proves guest startup and gameplay wiring;
-it is not a substitute for installing and accepting the final SIS on an E7.
+The pinned `vendor/pocketjs` checkout is authoritative.
+`POCKETJS_ROOT=/path/to/pocketjs` (or `--pocketjs-root`) is available only for
+guest-only compatibility checks. A native build rejects any checkout other
+than `vendor/pocketjs`, because the Rust dependency graph and Qt packager must
+use the same pinned ABI. The full command uses PocketJS's exact Symbian
+nightly, 30 Hz host rate, custom target JSON, and
+`build-std=core,alloc,compiler_builtins`, then passes the resulting static
+library through the toolchain's explicit `--core-library` boundary. It writes
+`dist/symbian/openstrike.sis`, validates the cooked map through Pocket3D's
+canonical Rust reader, and revalidates the packaged JS/PAK against that exact
+map; installation and visual acceptance still require a real E7.
### Map data
diff --git a/crates/openstrike-symbian/Cargo.lock b/crates/openstrike-symbian/Cargo.lock
new file mode 100644
index 0000000..f1662fe
--- /dev/null
+++ b/crates/openstrike-symbian/Cargo.lock
@@ -0,0 +1,167 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "arrayvec"
+version = "0.7.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
+
+[[package]]
+name = "glam"
+version = "0.33.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f22fb22f065b308be0d8724e3706c7fa3fc2a6c7d6899df4cad7860e7a75436"
+dependencies = [
+ "libm",
+]
+
+[[package]]
+name = "libm"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
+
+[[package]]
+name = "openstrike-core"
+version = "0.1.0"
+dependencies = [
+ "glam",
+ "libm",
+ "pocket3d-bsp",
+]
+
+[[package]]
+name = "openstrike-symbian"
+version = "0.2.0"
+dependencies = [
+ "glam",
+ "libm",
+ "openstrike-core",
+ "pocket3d-bsp",
+ "pocket3d-gles2",
+ "pocketjs-core",
+ "pocketjs-symbian-core",
+]
+
+[[package]]
+name = "pocket3d-bsp"
+version = "0.1.0"
+dependencies = [
+ "glam",
+]
+
+[[package]]
+name = "pocket3d-gles2"
+version = "0.1.0"
+dependencies = [
+ "glam",
+ "libm",
+ "pocket3d-bsp",
+]
+
+[[package]]
+name = "pocketjs-core"
+version = "0.1.0"
+dependencies = [
+ "taffy",
+]
+
+[[package]]
+name = "pocketjs-symbian-core"
+version = "0.1.0"
+dependencies = [
+ "pocketjs-core",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "slotmap"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038"
+dependencies = [
+ "version_check",
+]
+
+[[package]]
+name = "syn"
+version = "3.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "taffy"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dfde4e2f8595f222ceaae1fb16b4963952e9b33e358869dc4cd6316b0e0790cd"
+dependencies = [
+ "arrayvec",
+ "serde",
+ "slotmap",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
diff --git a/crates/openstrike-symbian/Cargo.toml b/crates/openstrike-symbian/Cargo.toml
new file mode 100644
index 0000000..c5d0534
--- /dev/null
+++ b/crates/openstrike-symbian/Cargo.toml
@@ -0,0 +1,30 @@
+# openstrike-symbian — the app-specific native core linked into the shared
+# PocketJS Qt/Symbian host. It owns OpenStrike simulation and GLES2
+# presentation while reusing PocketJS's QuickJS, retained UI and packaging.
+
+[package]
+name = "openstrike-symbian"
+version = "0.2.0"
+edition = "2021"
+license = "MIT"
+
+[lib]
+crate-type = ["rlib", "staticlib"]
+
+[dependencies]
+openstrike-core = { path = "../openstrike-core", default-features = false, features = ["libm"] }
+pocket3d-bsp = { path = "../../vendor/pocketjs/engine/pocket3d/crates/pocket3d-bsp", default-features = false, features = ["libm"] }
+pocket3d-gles2 = { path = "../../vendor/pocketjs/engine/pocket3d/crates/pocket3d-gles2" }
+pocketjs-core = { path = "../../vendor/pocketjs/engine/core", default-features = false }
+pocketjs-symbian-core = { path = "../../vendor/pocketjs/engine/symbian", default-features = false }
+glam = { version = "0.33", default-features = false, features = ["libm", "scalar-math"] }
+libm = "0.2"
+
+[workspace]
+resolver = "2"
+
+[profile.release]
+panic = "abort"
+opt-level = "s"
+codegen-units = 1
+lto = true
diff --git a/crates/openstrike-symbian/src/input.rs b/crates/openstrike-symbian/src/input.rs
new file mode 100644
index 0000000..761a476
--- /dev/null
+++ b/crates/openstrike-symbian/src/input.rs
@@ -0,0 +1,93 @@
+//! E7 hardware keyboard to deterministic OpenStrike input.
+
+use openstrike_core::sim::{SimInput, MOUSE_SENS};
+use pocketjs_symbian_core::extension::{
+ KEY_FIRE, KEY_JUMP, KEY_LOOK_DOWN, KEY_LOOK_LEFT, KEY_LOOK_RIGHT, KEY_LOOK_UP, KEY_MOVE_BACK,
+ KEY_MOVE_FORWARD, KEY_MOVE_LEFT, KEY_MOVE_RIGHT, KEY_RELOAD, KEY_WALK,
+};
+
+const LOOK_YAW_RATE: f32 = 2.6;
+const LOOK_PITCH_RATE: f32 = 1.7;
+
+pub struct KeyboardInput {
+ previous: u32,
+ look_hold: f32,
+}
+
+pub struct TickInput {
+ pub sim: SimInput,
+ pub look_dx: f32,
+ pub look_dy: f32,
+}
+
+impl KeyboardInput {
+ pub const fn new() -> Self {
+ Self {
+ previous: 0,
+ look_hold: 0.0,
+ }
+ }
+
+ pub fn map(&mut self, keys: u32, dt: f32) -> TickInput {
+ let pressed = keys & !self.previous;
+ self.previous = keys;
+
+ let move_x = f32::from((keys & KEY_MOVE_RIGHT != 0) as u8)
+ - f32::from((keys & KEY_MOVE_LEFT != 0) as u8);
+ let move_y = f32::from((keys & KEY_MOVE_FORWARD != 0) as u8)
+ - f32::from((keys & KEY_MOVE_BACK != 0) as u8);
+ let yaw = f32::from((keys & KEY_LOOK_RIGHT != 0) as u8)
+ - f32::from((keys & KEY_LOOK_LEFT != 0) as u8);
+ let pitch = f32::from((keys & KEY_LOOK_DOWN != 0) as u8)
+ - f32::from((keys & KEY_LOOK_UP != 0) as u8);
+
+ if yaw != 0.0 || pitch != 0.0 {
+ self.look_hold = (self.look_hold + dt).min(0.3);
+ } else {
+ self.look_hold = 0.0;
+ }
+ let acceleration = 0.55 + 0.45 * (self.look_hold / 0.3);
+
+ TickInput {
+ sim: SimInput {
+ move_x,
+ move_y,
+ walk: keys & KEY_WALK != 0,
+ jump: keys & KEY_JUMP != 0,
+ fire: keys & KEY_FIRE != 0,
+ reload: pressed & KEY_RELOAD != 0,
+ },
+ look_dx: yaw * LOOK_YAW_RATE * acceleration * dt / MOUSE_SENS,
+ look_dy: pitch * LOOK_PITCH_RATE * acceleration * dt / MOUSE_SENS,
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn maps_levels_and_reload_edge() {
+ let mut input = KeyboardInput::new();
+ let keys = KEY_MOVE_FORWARD | KEY_MOVE_LEFT | KEY_FIRE | KEY_RELOAD;
+ let first = input.map(keys, 1.0 / 60.0);
+ let held = input.map(keys, 1.0 / 60.0);
+ assert_eq!(first.sim.move_x, -1.0);
+ assert_eq!(first.sim.move_y, 1.0);
+ assert!(first.sim.fire && held.sim.fire);
+ assert!(first.sim.reload);
+ assert!(!held.sim.reload);
+ }
+
+ #[test]
+ fn look_signs_match_apply_look() {
+ let mut input = KeyboardInput::new();
+ let right_down = input.map(KEY_LOOK_RIGHT | KEY_LOOK_DOWN, 1.0 / 60.0);
+ assert!(right_down.look_dx > 0.0);
+ assert!(right_down.look_dy > 0.0);
+ let left_up = input.map(KEY_LOOK_LEFT | KEY_LOOK_UP, 1.0 / 60.0);
+ assert!(left_up.look_dx < 0.0);
+ assert!(left_up.look_dy < 0.0);
+ }
+}
diff --git a/crates/openstrike-symbian/src/lib.rs b/crates/openstrike-symbian/src/lib.rs
new file mode 100644
index 0000000..7cab59e
--- /dev/null
+++ b/crates/openstrike-symbian/src/lib.rs
@@ -0,0 +1,457 @@
+#![no_std]
+#![allow(static_mut_refs)]
+
+extern crate alloc;
+extern crate self as libquickjs_sys;
+extern crate self as pocketjs_psp;
+
+mod input;
+mod quickjs;
+
+pub use quickjs::*;
+
+#[allow(dead_code)]
+#[path = "../../openstrike-vita/src/present_data.rs"]
+mod present_data;
+#[path = "../../openstrike-vita/src/sim_boot.rs"]
+mod sim_boot;
+#[allow(clippy::manual_c_str_literals, clippy::missing_safety_doc)]
+#[path = "../../openstrike-psp/src/strike.rs"]
+mod strike;
+
+use alloc::string::String;
+use alloc::vec::Vec;
+use core::ffi::c_void;
+
+use glam::{Mat4, Vec3};
+use input::KeyboardInput;
+use openstrike_core::sim::Command;
+use openstrike_core::StrikeSim;
+use pocket3d_bsp::cooked::{self, CookedMap};
+use pocket3d_gles2::{
+ BlendMode, Camera3d, DynamicRenderer, FrameOptions, TextureUpload, Viewport, WorldRenderer,
+};
+use pocketjs_symbian_core::extension::{self, ExtensionV1};
+
+pub mod ffi {
+ use super::*;
+
+ pub unsafe fn arg_i32(
+ context: *mut JSContext,
+ argc: i32,
+ argv: *mut JSValue,
+ index: isize,
+ ) -> i32 {
+ if index as i32 >= argc {
+ return 0;
+ }
+ let mut value = 0;
+ JS_ToInt32(context, &mut value, *argv.offset(index));
+ value
+ }
+
+ pub unsafe fn add_fn(
+ context: *mut JSContext,
+ object: JSValue,
+ name: &'static [u8],
+ function: unsafe extern "C" fn(*mut JSContext, JSValue, i32, *mut JSValue) -> JSValue,
+ args: i32,
+ ) {
+ let value = JS_NewCFunction2(
+ context,
+ Some(function),
+ name.as_ptr().cast(),
+ args,
+ JS_CFUNC_generic,
+ 0,
+ );
+ JS_SetPropertyStr(context, object, name.as_ptr().cast(), value);
+ }
+}
+
+const MAP_KEY: &str = "maps/de_dust2.p3d";
+const MAP_NAME: &str = "de_dust2";
+const FIXED_DT: f32 = 1.0 / 60.0;
+const TICKS_PER_HOST_FRAME: usize = 2;
+
+#[used]
+static POCKETJS_CORE_LINK_ROOT: extern "C" fn(u32) = pocketjs_symbian_core::ui_init;
+
+struct Game {
+ sim: StrikeSim,
+ world: WorldRenderer<'static>,
+}
+
+struct State {
+ context: *mut JSContext,
+ global: JSValue,
+ map_bytes: &'static [u8],
+ game: Option,
+ boot_config: Vec,
+ pending_host: Option,
+ input: KeyboardInput,
+ menu_time: f64,
+ viewport_width: u32,
+ viewport_height: u32,
+ dynamic: DynamicRenderer,
+ rifle: Vec,
+ bot_body: Vec,
+ effects: present_data::EffectGeometry,
+}
+
+static mut STATE: Option = None;
+
+const _: () = assert!(
+ core::mem::size_of::()
+ == core::mem::size_of::()
+);
+const _: () = assert!(
+ core::mem::align_of::()
+ == core::mem::align_of::()
+);
+
+fn gles_vertices(vertices: &[present_data::ColorVertex]) -> &[pocket3d_gles2::ColorVertex] {
+ // Both shared presentation vertices and the GLES backend pin the same
+ // repr(C) { ABGR u32, x/y/z f32 } layout above.
+ unsafe { core::slice::from_raw_parts(vertices.as_ptr().cast(), vertices.len()) }
+}
+
+fn valid_extent(value: i32) -> Option {
+ (value > 0 && value <= 640).then_some(value as u32)
+}
+
+fn game_from_map(bytes: &'static [u8], boot_config: &[Command]) -> Result {
+ let map: CookedMap<'static> = cooked::read(bytes).map_err(|_| "invalid cooked map")?;
+ let sim = sim_boot::from_map(&map, boot_config)?;
+ Ok(Game {
+ sim,
+ world: WorldRenderer::new(map),
+ })
+}
+
+unsafe fn drain_commands(state: &mut State) {
+ strike::drain(|command| match &mut state.game {
+ Some(game) => game.sim.apply(command, 0),
+ None => state.boot_config.push(command),
+ });
+ strike::drain_host(|command| state.pending_host = Some(command));
+}
+
+unsafe fn dispatch_tick(state: &mut State, native_keys: u32) -> bool {
+ let tick = state.input.map(native_keys, FIXED_DT);
+ let dispatched = match &mut state.game {
+ Some(game) => {
+ game.sim.apply_look(tick.look_dx, tick.look_dy);
+ game.sim
+ .tick(&game.world.map().collision, FIXED_DT, &tick.sim);
+ strike::dispatch(state.context, state.global, &mut game.sim)
+ }
+ None => {
+ state.menu_time += FIXED_DT as f64;
+ strike::dispatch_menu(state.context, state.global, state.menu_time)
+ }
+ };
+ if dispatched {
+ drain_commands(state);
+ }
+ dispatched
+}
+
+unsafe fn apply_pending_host(state: &mut State) -> Result<(), ()> {
+ match state.pending_host.take() {
+ Some(strike::HostCmd::LoadMap(0)) if state.game.is_none() => {
+ let mut game = game_from_map(state.map_bytes, &state.boot_config).map_err(|_| ())?;
+ game.world.initialize_gpu().map_err(|_| ())?;
+ state.game = Some(game);
+ }
+ Some(strike::HostCmd::ToMenu) => {
+ if let Some(mut game) = state.game.take() {
+ game.world.shutdown_gpu().map_err(|_| ())?;
+ }
+ state.menu_time = 0.0;
+ }
+ _ => {}
+ }
+ Ok(())
+}
+
+fn camera_for(state: &State) -> Camera3d {
+ let mut camera = match &state.game {
+ Some(game) => Camera3d {
+ pos: game.sim.player.eye_interpolated(1.0),
+ yaw: game.sim.player.yaw,
+ pitch: game.sim.player.pitch,
+ fov_y: 74f32.to_radians(),
+ ..Camera3d::default()
+ },
+ None => Camera3d {
+ fov_y: 74f32.to_radians(),
+ ..Camera3d::default()
+ },
+ };
+ camera.set_viewport(state.viewport_width, state.viewport_height);
+ camera
+}
+
+fn sky_color(camera: &Camera3d) -> [f32; 4] {
+ let zenith = Vec3::new(0.34, 0.48, 0.66);
+ let horizon = Vec3::new(0.93, 0.79, 0.62);
+ let amount = libm::powf(
+ libm::sinf(camera.pitch + camera.fov_y * 0.2).clamp(0.0, 1.0),
+ 0.65,
+ );
+ let color = horizon + (zenith - horizon) * amount;
+ [color.x, color.y, color.z, 1.0]
+}
+
+unsafe extern "C" fn boot(
+ context: *mut c_void,
+ pak: *const u8,
+ pak_len: usize,
+ viewport_width: i32,
+ viewport_height: i32,
+) -> i32 {
+ if STATE.is_some() || context.is_null() || pak.is_null() {
+ return 0;
+ }
+ let (Some(width), Some(height)) = (valid_extent(viewport_width), valid_extent(viewport_height))
+ else {
+ return 0;
+ };
+ let pak = core::slice::from_raw_parts(pak, pak_len);
+ let Some(map) = pocketjs_core::pak::find(pak, MAP_KEY) else {
+ return 0;
+ };
+ // The extension ABI guarantees that the host-owned PAK outlives boot and
+ // remains immutable until shutdown. STATE is destroyed before that point.
+ let map: &'static [u8] = core::slice::from_raw_parts(map.as_ptr(), map.len());
+ let context = context.cast::();
+ let global = JS_GetGlobalObject(context);
+
+ // A cold app boot must never inherit intent queued by an earlier guest.
+ strike::drain(drop);
+ strike::drain_host(drop);
+ strike::register(context, global, &[String::from(MAP_NAME)]);
+
+ STATE = Some(State {
+ context,
+ global,
+ map_bytes: map,
+ game: None,
+ boot_config: Vec::new(),
+ pending_host: None,
+ input: KeyboardInput::new(),
+ menu_time: 0.0,
+ viewport_width: width,
+ viewport_height: height,
+ dynamic: DynamicRenderer::new(),
+ rifle: present_data::build_rifle(),
+ bot_body: present_data::build_bot_body(),
+ effects: present_data::EffectGeometry::default(),
+ });
+ 1
+}
+
+unsafe extern "C" fn shutdown(gl_context_current: i32) {
+ let Some(mut state) = STATE.take() else {
+ return;
+ };
+ if let Some(mut game) = state.game.take() {
+ if gl_context_current != 0 {
+ let _ = game.world.shutdown_gpu();
+ } else {
+ game.world.abandon_lost_context();
+ }
+ }
+ if gl_context_current != 0 {
+ let _ = state.dynamic.shutdown_gpu();
+ } else {
+ state.dynamic.abandon_lost_context();
+ }
+ strike::drain(drop);
+ strike::drain_host(drop);
+ JS_FreeValue(state.context, state.global);
+}
+
+unsafe extern "C" fn before_guest(
+ context: *mut c_void,
+ _buttons: u32,
+ _analog: u32,
+ native_keys: u32,
+) -> i32 {
+ let Some(state) = STATE.as_mut() else {
+ return 0;
+ };
+ if context.cast::() != state.context {
+ return 0;
+ }
+ for _ in 0..TICKS_PER_HOST_FRAME {
+ if !dispatch_tick(state, native_keys) {
+ return 0;
+ }
+ }
+ 1
+}
+
+unsafe extern "C" fn after_guest(context: *mut c_void) -> i32 {
+ let Some(state) = STATE.as_mut() else {
+ return 0;
+ };
+ if context.cast::() != state.context {
+ return 0;
+ }
+ drain_commands(state);
+ 1
+}
+
+unsafe extern "C" fn resize(viewport_width: i32, viewport_height: i32) {
+ let (Some(width), Some(height)) = (valid_extent(viewport_width), valid_extent(viewport_height))
+ else {
+ return;
+ };
+ if let Some(state) = STATE.as_mut() {
+ state.viewport_width = width;
+ state.viewport_height = height;
+ }
+}
+
+unsafe extern "C" fn render(
+ target_x: i32,
+ target_y: i32,
+ target_width: i32,
+ target_height: i32,
+ _window_width: i32,
+ window_height: i32,
+) -> i32 {
+ let Some(state) = STATE.as_mut() else {
+ return 0;
+ };
+ let (Some(width), Some(height)) = (valid_extent(target_width), valid_extent(target_height))
+ else {
+ return 0;
+ };
+ let gl_y = window_height - target_y - target_height;
+ if target_x < 0 || gl_y < 0 {
+ return 0;
+ }
+ let viewport = Viewport::new(target_x, gl_y, width, height);
+ state.viewport_width = width;
+ state.viewport_height = height;
+ state.dynamic.reset_counters();
+
+ if apply_pending_host(state).is_err() {
+ return 0;
+ }
+ if !state.dynamic.gpu_ready() && state.dynamic.initialize_gpu().is_err() {
+ return 0;
+ }
+
+ let camera = camera_for(state);
+ let view_projection = camera.view_proj();
+ let Some(game) = state.game.as_mut() else {
+ return state
+ .dynamic
+ .clear_frame(viewport, [0.02, 0.035, 0.055, 1.0], true)
+ .is_ok() as i32;
+ };
+
+ match game.world.upload_next_texture() {
+ Ok(TextureUpload::Uploaded { .. }) | Ok(TextureUpload::Complete { .. }) => {}
+ Err(_) => return 0,
+ }
+ let options = FrameOptions {
+ viewport,
+ clear_color: Some(sky_color(&camera)),
+ clear_depth: true,
+ };
+ if game.world.render(&camera, options).is_err() {
+ return 0;
+ }
+
+ for bot in &game.sim.bots {
+ if state
+ .dynamic
+ .draw_color_tris(
+ gles_vertices(&state.bot_body),
+ bot.transform_scaled(1.0),
+ view_projection,
+ BlendMode::Opaque,
+ )
+ .is_err()
+ {
+ return 0;
+ }
+ }
+ present_data::build_effects_into(&mut state.effects, &game.sim, camera.forward());
+ if state
+ .dynamic
+ .draw_color_tris(
+ gles_vertices(&state.effects.vertices),
+ Mat4::IDENTITY,
+ view_projection,
+ BlendMode::Additive,
+ )
+ .is_err()
+ {
+ return 0;
+ }
+ if game.sim.player.alive {
+ if state.dynamic.clear_depth_for_viewmodel().is_err()
+ || state
+ .dynamic
+ .draw_color_tris(
+ gles_vertices(&state.rifle),
+ game.sim.viewmodel_transform_at(1.0),
+ view_projection,
+ BlendMode::Opaque,
+ )
+ .is_err()
+ {
+ return 0;
+ }
+ }
+ 1
+}
+
+static EXTENSION: ExtensionV1 = ExtensionV1 {
+ abi_version: extension::ABI_V1,
+ struct_size: ExtensionV1::struct_size(),
+ flags: extension::FLAG_DEPTH_BUFFER,
+ boot: Some(boot),
+ shutdown: Some(shutdown),
+ before_guest: Some(before_guest),
+ after_guest: Some(after_guest),
+ resize: Some(resize),
+ render: Some(render),
+};
+
+#[no_mangle]
+pub extern "C" fn pocketjs_symbian_extension_v1() -> *const ExtensionV1 {
+ &EXTENSION
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn extension_requests_depth_and_keeps_the_fixed_step_ratio() {
+ assert_eq!(EXTENSION.abi_version, extension::ABI_V1);
+ assert_eq!(EXTENSION.flags, extension::FLAG_DEPTH_BUFFER);
+ assert_eq!(TICKS_PER_HOST_FRAME, 2);
+ assert!((FIXED_DT * TICKS_PER_HOST_FRAME as f32 - 1.0 / 30.0).abs() < f32::EPSILON);
+ }
+
+ #[test]
+ fn rejects_transient_or_oversized_viewports() {
+ assert_eq!(valid_extent(0), None);
+ assert_eq!(valid_extent(641), None);
+ assert_eq!(valid_extent(640), Some(640));
+ }
+
+ #[test]
+ fn quickjs_value_matches_the_symbian_host_abi() {
+ assert_eq!(core::mem::size_of::(), 8);
+ assert!(core::mem::align_of::() <= 8);
+ }
+}
diff --git a/crates/openstrike-symbian/src/quickjs.rs b/crates/openstrike-symbian/src/quickjs.rs
new file mode 100644
index 0000000..56f11b2
--- /dev/null
+++ b/crates/openstrike-symbian/src/quickjs.rs
@@ -0,0 +1,110 @@
+//! Minimal declarations for the QuickJS instance owned by the shared Qt
+//! host. The container links QuickJS's official `static-functions.c`
+//! wrappers, so Rust and C use exactly the same 32-bit NaN-boxed `JSValue`.
+
+#![allow(non_camel_case_types, non_snake_case, non_upper_case_globals)]
+
+use core::ffi::{c_char, c_int};
+
+pub type size_t = usize;
+pub type JSValue = u64;
+pub type JSCFunctionEnum = u32;
+
+#[repr(C)]
+pub struct JSContext {
+ _unused: [u8; 0],
+}
+
+pub type JSCFunction = Option<
+ unsafe extern "C" fn(
+ context: *mut JSContext,
+ this_value: JSValue,
+ argc: c_int,
+ argv: *mut JSValue,
+ ) -> JSValue,
+>;
+
+pub const JS_CFUNC_generic: JSCFunctionEnum = 0;
+pub const JS_TAG_UNDEFINED: i32 = 3;
+pub const JS_TAG_EXCEPTION: i32 = 6;
+pub const JS_UNDEFINED: JSValue = (JS_TAG_UNDEFINED as u64) << 32;
+
+const _: () = assert!(core::mem::size_of::() == 8);
+const _: () = assert!(core::mem::align_of::() <= 8);
+
+extern "C" {
+ fn JS_ValueGetTag_real(value: JSValue) -> c_int;
+ fn JS_FreeValue_real(context: *mut JSContext, value: JSValue);
+ fn JS_NewBool_real(context: *mut JSContext, value: c_int) -> JSValue;
+ fn JS_NewInt32_real(context: *mut JSContext, value: i32) -> JSValue;
+ fn JS_NewFloat64_real(context: *mut JSContext, value: f64) -> JSValue;
+ fn JS_IsUndefined_real(value: JSValue) -> c_int;
+
+ pub fn JS_GetGlobalObject(context: *mut JSContext) -> JSValue;
+ pub fn JS_NewObject(context: *mut JSContext) -> JSValue;
+ pub fn JS_GetPropertyStr(
+ context: *mut JSContext,
+ object: JSValue,
+ property: *const c_char,
+ ) -> JSValue;
+ pub fn JS_SetPropertyStr(
+ context: *mut JSContext,
+ object: JSValue,
+ property: *const c_char,
+ value: JSValue,
+ ) -> c_int;
+ pub fn JS_NewCFunction2(
+ context: *mut JSContext,
+ function: JSCFunction,
+ name: *const c_char,
+ length: c_int,
+ prototype: JSCFunctionEnum,
+ magic: c_int,
+ ) -> JSValue;
+ pub fn JS_Call(
+ context: *mut JSContext,
+ function: JSValue,
+ this_value: JSValue,
+ argc: c_int,
+ argv: *mut JSValue,
+ ) -> JSValue;
+ pub fn JS_ToInt32(context: *mut JSContext, result: *mut i32, value: JSValue) -> c_int;
+ pub fn JS_ToFloat64(context: *mut JSContext, result: *mut f64, value: JSValue) -> c_int;
+ pub fn JS_ToCStringLen2(
+ context: *mut JSContext,
+ length: *mut size_t,
+ value: JSValue,
+ cesu8: c_int,
+ ) -> *const c_char;
+ pub fn JS_FreeCString(context: *mut JSContext, value: *const c_char);
+}
+
+#[inline]
+pub unsafe fn JS_ValueGetTag(value: JSValue) -> i32 {
+ JS_ValueGetTag_real(value)
+}
+
+#[inline]
+pub unsafe fn JS_FreeValue(context: *mut JSContext, value: JSValue) {
+ JS_FreeValue_real(context, value)
+}
+
+#[inline]
+pub unsafe fn JS_NewBool(context: *mut JSContext, value: bool) -> JSValue {
+ JS_NewBool_real(context, i32::from(value))
+}
+
+#[inline]
+pub unsafe fn JS_NewInt32(context: *mut JSContext, value: i32) -> JSValue {
+ JS_NewInt32_real(context, value)
+}
+
+#[inline]
+pub unsafe fn JS_NewFloat64(context: *mut JSContext, value: f64) -> JSValue {
+ JS_NewFloat64_real(context, value)
+}
+
+#[inline]
+pub unsafe fn JS_IsUndefined(value: JSValue) -> bool {
+ JS_IsUndefined_real(value) != 0
+}
diff --git a/crates/openstrike-vita/src/lib.rs b/crates/openstrike-vita/src/lib.rs
index 53b50e3..4375224 100644
--- a/crates/openstrike-vita/src/lib.rs
+++ b/crates/openstrike-vita/src/lib.rs
@@ -4,7 +4,6 @@
#![cfg_attr(target_os = "vita", allow(static_mut_refs))]
-#[cfg(target_os = "vita")]
extern crate alloc;
// The raw strike surface is intentionally source-shared with PSP: both hosts
diff --git a/crates/openstrike-vita/src/present_data.rs b/crates/openstrike-vita/src/present_data.rs
index 0c9f847..f292e84 100644
--- a/crates/openstrike-vita/src/present_data.rs
+++ b/crates/openstrike-vita/src/present_data.rs
@@ -5,6 +5,7 @@
//! vita2d/vitaGL types makes the eventual Pocket3D Vita adapter a conversion
//! at one boundary instead of coupling simulation code to the GPU API.
+use alloc::vec::Vec;
#[cfg(target_os = "vita")]
use glam::Mat4;
use glam::Vec3;
diff --git a/game/openstrike-symbian.tsx b/game/openstrike-symbian.tsx
deleted file mode 100644
index c585750..0000000
--- a/game/openstrike-symbian.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-// @title OpenStrike — E7 2D Compatibility
-//
-// Symbian-specific product entry. It installs the OpenStrike-owned playable
-// 2D fallback before rules/sdk evaluate, then layers the ordinary menu/HUD on
-// top. Native PSP/Vita/desktop entries never import this module or its sim.
-
-import { SymbianCompatibilityLayer } from "./symbian-compat.tsx";
-import "./rules.ts";
-import { mount } from "@pocketjs/framework/solid";
-import { View } from "@pocketjs/framework/components";
-import OpenStrikeScreen from "./screen.tsx";
-
-mount(() => (
-
-
-
-
-));
diff --git a/game/screen.tsx b/game/screen.tsx
index f5f2208..0adbee5 100644
--- a/game/screen.tsx
+++ b/game/screen.tsx
@@ -1,5 +1,5 @@
-// Shared product screen. Native 3D hosts mount this directly; the explicitly
-// reduced Symbian entry places its compatibility world underneath it.
+// Shared product screen. Every native 3D host mounts this same menu/HUD over
+// its target-specific Pocket3D renderer.
import { createSignal, Show } from "solid-js";
import Hud from "./hud.tsx";
diff --git a/game/sdk.ts b/game/sdk.ts
index ef926d4..0804dcb 100644
--- a/game/sdk.ts
+++ b/game/sdk.ts
@@ -48,8 +48,6 @@ export interface BotsConfig {
export interface NativeStrike {
/** Cooked maps available to loadMap (index-aligned), host-injected. */
maps?: string[];
- /** Compatibility hosts may publish their pre-first-frame state. */
- __initialState?: StrikeState;
loadMap?(index: number): void;
toMenu?(): void;
setPhase(phase: string): void;
@@ -67,23 +65,21 @@ if (!native) {
throw new Error("openstrike: no `strike` surface — is this running under the game host?");
}
-let current: StrikeState = native.__initialState
- ? { ...native.__initialState }
- : {
- time: 0,
- phase: "starting",
- hp: 100,
- alive: true,
- ammo: 30,
- reserve: 90,
- reloading: false,
- reloadFrac: 0,
- aliveBots: 0,
- totalBots: 0,
- wins: 0,
- losses: 0,
- speed: 0,
- };
+let current: StrikeState = {
+ time: 0,
+ phase: "starting",
+ hp: 100,
+ alive: true,
+ ammo: 30,
+ reserve: 90,
+ reloading: false,
+ reloadFrac: 0,
+ aliveBots: 0,
+ totalBots: 0,
+ wins: 0,
+ losses: 0,
+ speed: 0,
+};
type Handler = (e: StrikeEvent) => void;
type TickHandler = (s: StrikeState) => void;
diff --git a/game/symbian-compat-sim.ts b/game/symbian-compat-sim.ts
deleted file mode 100644
index 9bdf85c..0000000
--- a/game/symbian-compat-sim.ts
+++ /dev/null
@@ -1,534 +0,0 @@
-// OpenStrike's explicitly reduced Nokia E7 game mode.
-//
-// This is not a replacement for openstrike-core and does not pretend to be
-// the BSP/3D game. The E7 PocketJS host currently has no Pocket3D backend or
-// native `strike` surface, so this small deterministic top-down simulation
-// preserves the real product loop while that native port is still missing:
-// map selection -> rules-driven round phases -> movement/combat -> HUD/events
-// -> win/loss -> reset.
-
-import type {
- BotsConfig,
- StrikeEvent,
- StrikeState,
- WeaponConfig,
-} from "./sdk.ts";
-
-export const SYMBIAN_COMPAT_MAPS = [
- "de_dust2",
- "de_inferno",
- "de_nuke",
- "cs_assault",
- "de_dust",
- "de_aztec",
- "de_train",
- "cs_office",
-] as const;
-
-export interface SymbianCompatInput {
- /** Forward/backward, from -1 through 1. */
- forward: number;
- /** Clockwise/counter-clockwise turn, from -1 through 1. */
- turn: number;
- /** Automatic-fire trigger held this step. */
- fire: boolean;
- /** Rising-edge reload action. */
- reload: boolean;
-}
-
-export interface SymbianCompatRect {
- readonly x: number;
- readonly y: number;
- readonly w: number;
- readonly h: number;
-}
-
-export interface SymbianCompatActor {
- readonly x: number;
- readonly y: number;
- readonly angle: number;
- readonly alive: boolean;
-}
-
-export interface SymbianCompatScene {
- readonly mapIndex: number;
- readonly mapName: string;
- readonly player: SymbianCompatActor;
- readonly bots: readonly SymbianCompatActor[];
- readonly obstacles: readonly SymbianCompatRect[];
-}
-
-interface MapLayout {
- readonly player: readonly [number, number, number];
- readonly bots: readonly (readonly [number, number])[];
- readonly obstacles: readonly SymbianCompatRect[];
-}
-
-interface MutableBot {
- x: number;
- y: number;
- hp: number;
- attackLeft: number;
- attacks: number;
-}
-
-type QueuedCommand =
- | { readonly type: "phase"; readonly phase: StrikeState["phase"] }
- | { readonly type: "reset" }
- | { readonly type: "win" }
- | { readonly type: "loss" }
- | { readonly type: "bot-count"; readonly count: number }
- | { readonly type: "weapon"; readonly config: WeaponConfig }
- | { readonly type: "bots"; readonly config: BotsConfig };
-
-const DEFAULT_WEAPON: WeaponConfig = {
- magSize: 30,
- reserve: 90,
- fireInterval: 0.105,
- reloadTime: 2.4,
- damageBody: 34,
- damageHead: 100,
-};
-
-const DEFAULT_BOTS: BotsConfig = {
- count: 3,
- speed: 190,
- attackInterval: 1.4,
- damageMin: 8,
- damageMax: 14,
-};
-
-const LAYOUTS: readonly MapLayout[] = [
- {
- player: [0.5, 0.84, -Math.PI / 2],
- // The default three spawns share the center lane: every round is
- // completable without waypoint navigation, which this reduced mode does
- // not claim. Extra modded bots may occupy the outer lanes.
- bots: [[0.5, 0.16], [0.42, 0.24], [0.58, 0.24], [0.2, 0.62], [0.8, 0.62]],
- obstacles: [
- { x: 0.12, y: 0.43, w: 0.27, h: 0.08 },
- { x: 0.61, y: 0.43, w: 0.27, h: 0.08 },
- ],
- },
- {
- player: [0.5, 0.84, -Math.PI / 2],
- bots: [[0.5, 0.16], [0.16, 0.18], [0.84, 0.18], [0.18, 0.7], [0.82, 0.7]],
- obstacles: [
- { x: 0.23, y: 0.31, w: 0.12, h: 0.34 },
- { x: 0.65, y: 0.31, w: 0.12, h: 0.34 },
- ],
- },
- {
- player: [0.5, 0.84, -Math.PI / 2],
- bots: [[0.5, 0.16], [0.2, 0.2], [0.8, 0.2], [0.16, 0.5], [0.84, 0.5]],
- obstacles: [
- { x: 0.18, y: 0.4, w: 0.22, h: 0.1 },
- { x: 0.6, y: 0.4, w: 0.22, h: 0.1 },
- { x: 0.44, y: 0.58, w: 0.12, h: 0.1 },
- ],
- },
- {
- player: [0.5, 0.84, -Math.PI / 2],
- bots: [[0.5, 0.16], [0.13, 0.26], [0.87, 0.26], [0.25, 0.58], [0.75, 0.58]],
- obstacles: [
- { x: 0.16, y: 0.38, w: 0.2, h: 0.08 },
- { x: 0.64, y: 0.38, w: 0.2, h: 0.08 },
- { x: 0.4, y: 0.26, w: 0.2, h: 0.08 },
- ],
- },
-];
-
-const clamp = (value: number, min: number, max: number): number =>
- Math.max(min, Math.min(max, value));
-
-const copyWeapon = (config: WeaponConfig): WeaponConfig => ({
- magSize: clamp(Math.floor(config.magSize), 1, 999),
- reserve: clamp(Math.floor(config.reserve), 0, 9999),
- fireInterval: clamp(config.fireInterval, 0.03, 10),
- reloadTime: clamp(config.reloadTime, 0.1, 30),
- damageBody: clamp(Math.floor(config.damageBody), 1, 9999),
- damageHead: clamp(Math.floor(config.damageHead), 1, 9999),
-});
-
-const copyBots = (config: BotsConfig): BotsConfig => ({
- count: clamp(Math.floor(config.count), 0, 8),
- speed: clamp(config.speed, 1, 1000),
- attackInterval: clamp(config.attackInterval, 0.1, 30),
- damageMin: clamp(Math.floor(config.damageMin), 0, 999),
- damageMax: clamp(Math.floor(config.damageMax), 0, 999),
-});
-
-const pointBlocked = (
- x: number,
- y: number,
- radius: number,
- layout: MapLayout,
-): boolean =>
- layout.obstacles.some(
- (rect) =>
- x + radius > rect.x &&
- x - radius < rect.x + rect.w &&
- y + radius > rect.y &&
- y - radius < rect.y + rect.h,
- );
-
-/** Liang-Barsky segment/rectangle test, expanded by the actor radius. */
-const segmentBlocked = (
- x0: number,
- y0: number,
- x1: number,
- y1: number,
- layout: MapLayout,
- radius = 0,
-): boolean => {
- const dx = x1 - x0;
- const dy = y1 - y0;
- for (const rect of layout.obstacles) {
- const minX = rect.x - radius;
- const maxX = rect.x + rect.w + radius;
- const minY = rect.y - radius;
- const maxY = rect.y + rect.h + radius;
- let near = 0;
- let far = 1;
- const clips: readonly (readonly [number, number])[] = [
- [-dx, x0 - minX],
- [dx, maxX - x0],
- [-dy, y0 - minY],
- [dy, maxY - y0],
- ];
- let hit = true;
- for (const [p, q] of clips) {
- if (Math.abs(p) < 1e-9) {
- if (q < 0) hit = false;
- continue;
- }
- const t = q / p;
- if (p < 0) near = Math.max(near, t);
- else far = Math.min(far, t);
- if (near > far) hit = false;
- }
- if (hit) return true;
- }
- return false;
-};
-
-export class SymbianCompatSimulation {
- private phase: StrikeState["phase"] = "menu";
- private time = 0;
- private hp = 100;
- private alive = true;
- private ammo = DEFAULT_WEAPON.magSize;
- private reserve = DEFAULT_WEAPON.reserve;
- private reloadLeft = 0;
- private fireLeft = 0;
- private wins = 0;
- private losses = 0;
- private speed = 0;
- private mapIndex = 0;
- private playerX = 0.5;
- private playerY = 0.84;
- private playerAngle = -Math.PI / 2;
- private bots: MutableBot[] = [];
- private weapon = copyWeapon(DEFAULT_WEAPON);
- private botConfig = copyBots(DEFAULT_BOTS);
- private readonly events: StrikeEvent[] = [];
- private readonly commands: QueuedCommand[] = [];
-
- initialState(): StrikeState {
- return this.snapshot();
- }
-
- snapshot(): StrikeState {
- const reloadFrac =
- this.reloadLeft > 0
- ? 1 - clamp(this.reloadLeft / this.weapon.reloadTime, 0, 1)
- : 0;
- return {
- time: this.time,
- phase: this.phase,
- hp: this.hp,
- alive: this.alive,
- ammo: this.ammo,
- reserve: this.reserve,
- reloading: this.reloadLeft > 0,
- reloadFrac,
- aliveBots: this.bots.filter((bot) => bot.hp > 0).length,
- totalBots: this.bots.length,
- wins: this.wins,
- losses: this.losses,
- speed: this.speed,
- };
- }
-
- scene(): SymbianCompatScene {
- const layout = this.layout();
- return {
- mapIndex: this.mapIndex,
- mapName: SYMBIAN_COMPAT_MAPS[this.mapIndex],
- player: {
- x: this.playerX,
- y: this.playerY,
- angle: this.playerAngle,
- alive: this.alive,
- },
- bots: this.bots.map((bot) => ({
- x: bot.x,
- y: bot.y,
- angle: Math.atan2(this.playerY - bot.y, this.playerX - bot.x),
- alive: bot.hp > 0,
- })),
- obstacles: layout.obstacles,
- };
- }
-
- drainEvents(): StrikeEvent[] {
- return this.events.splice(0);
- }
-
- loadMap(index: number): void {
- if (!Number.isInteger(index) || index < 0 || index >= SYMBIAN_COMPAT_MAPS.length) return;
- this.mapIndex = index;
- this.time = 0;
- this.resetRound(false);
- }
-
- toMenu(): void {
- this.phase = "menu";
- this.speed = 0;
- this.reloadLeft = 0;
- this.fireLeft = 0;
- }
-
- queueSetPhase(phase: StrikeState["phase"]): void {
- if (phase !== "menu" && phase !== "starting" && phase !== "live" && phase !== "won" && phase !== "lost") {
- return;
- }
- this.commands.push({ type: "phase", phase });
- }
-
- queueResetRound(): void {
- this.commands.push({ type: "reset" });
- }
-
- queueAddWin(): void {
- this.commands.push({ type: "win" });
- }
-
- queueAddLoss(): void {
- this.commands.push({ type: "loss" });
- }
-
- queueSetBotCount(count: number): void {
- this.commands.push({ type: "bot-count", count });
- }
-
- queueConfigureWeapon(config: WeaponConfig): void {
- this.commands.push({ type: "weapon", config: copyWeapon(config) });
- }
-
- queueConfigureBots(config: BotsConfig): void {
- this.commands.push({ type: "bots", config: copyBots(config) });
- }
-
- /** Apply guest intents after dispatch, matching the native host ordering. */
- flushCommands(): void {
- for (const command of this.commands.splice(0)) {
- switch (command.type) {
- case "phase":
- this.phase = command.phase;
- break;
- case "reset":
- this.resetRound(true);
- break;
- case "win":
- this.wins++;
- break;
- case "loss":
- this.losses++;
- break;
- case "bot-count":
- this.botConfig = {
- ...this.botConfig,
- count: clamp(Math.floor(command.count), 0, 8),
- };
- break;
- case "weapon":
- this.weapon = command.config;
- if (this.phase === "menu") {
- this.ammo = this.weapon.magSize;
- this.reserve = this.weapon.reserve;
- } else {
- this.ammo = Math.min(this.ammo, this.weapon.magSize);
- }
- break;
- case "bots":
- this.botConfig = command.config;
- break;
- }
- }
- }
-
- tick(input: SymbianCompatInput, elapsed: number): void {
- const dt = clamp(Number.isFinite(elapsed) ? elapsed : 0, 0, 0.1);
- this.time += dt;
- this.fireLeft = Math.max(0, this.fireLeft - dt);
-
- if (this.reloadLeft > 0) {
- this.reloadLeft = Math.max(0, this.reloadLeft - dt);
- if (this.reloadLeft === 0) this.finishReload();
- }
-
- if (this.phase !== "live" || !this.alive) {
- this.speed = 0;
- return;
- }
-
- const turn = clamp(input.turn, -1, 1);
- const forward = clamp(input.forward, -1, 1);
- this.playerAngle += turn * 2.45 * dt;
- const movement = forward * 0.28 * dt;
- this.movePlayer(Math.cos(this.playerAngle) * movement, Math.sin(this.playerAngle) * movement);
- this.speed = Math.abs(forward) * 250;
-
- if (input.reload) this.startReload();
- if (input.fire) this.fire();
-
- this.tickBots(dt);
- }
-
- private layout(): MapLayout {
- return LAYOUTS[this.mapIndex % LAYOUTS.length];
- }
-
- private resetRound(emitEvent: boolean): void {
- const layout = this.layout();
- this.phase = "starting";
- this.hp = 100;
- this.alive = true;
- this.ammo = this.weapon.magSize;
- this.reserve = this.weapon.reserve;
- this.reloadLeft = 0;
- this.fireLeft = 0;
- this.speed = 0;
- [this.playerX, this.playerY, this.playerAngle] = layout.player;
- this.bots = Array.from({ length: this.botConfig.count }, (_, index) => {
- const spawn = layout.bots[index % layout.bots.length];
- return {
- x: spawn[0],
- y: spawn[1],
- hp: Math.max(this.weapon.damageBody * 2, 50),
- attackLeft: this.botConfig.attackInterval * (0.45 + index * 0.12),
- attacks: 0,
- };
- });
- if (emitEvent) this.events.push({ type: "roundReset" });
- }
-
- private movePlayer(dx: number, dy: number): void {
- const layout = this.layout();
- const nextX = clamp(this.playerX + dx, 0.035, 0.965);
- if (!pointBlocked(nextX, this.playerY, 0.025, layout)) this.playerX = nextX;
- const nextY = clamp(this.playerY + dy, 0.055, 0.945);
- if (!pointBlocked(this.playerX, nextY, 0.025, layout)) this.playerY = nextY;
- }
-
- private moveBot(bot: MutableBot, dx: number, dy: number): void {
- const layout = this.layout();
- const nextX = clamp(bot.x + dx, 0.035, 0.965);
- if (!pointBlocked(nextX, bot.y, 0.022, layout)) bot.x = nextX;
- const nextY = clamp(bot.y + dy, 0.055, 0.945);
- if (!pointBlocked(bot.x, nextY, 0.022, layout)) bot.y = nextY;
- }
-
- private startReload(): void {
- if (
- this.reloadLeft === 0 &&
- this.ammo < this.weapon.magSize &&
- this.reserve > 0
- ) {
- this.reloadLeft = this.weapon.reloadTime;
- }
- }
-
- private finishReload(): void {
- const loaded = Math.min(this.weapon.magSize - this.ammo, this.reserve);
- this.ammo += loaded;
- this.reserve -= loaded;
- }
-
- private fire(): void {
- if (this.reloadLeft > 0 || this.fireLeft > 0) return;
- if (this.ammo <= 0) {
- this.startReload();
- return;
- }
- this.ammo--;
- this.fireLeft = this.weapon.fireInterval;
-
- const dx = Math.cos(this.playerAngle);
- const dy = Math.sin(this.playerAngle);
- let bestIndex = -1;
- let bestProjection = Number.POSITIVE_INFINITY;
- let bestMiss = Number.POSITIVE_INFINITY;
- const layout = this.layout();
- for (let index = 0; index < this.bots.length; index++) {
- const bot = this.bots[index];
- if (bot.hp <= 0) continue;
- const bx = bot.x - this.playerX;
- const by = bot.y - this.playerY;
- const projection = bx * dx + by * dy;
- if (projection <= 0 || projection >= bestProjection) continue;
- const miss = Math.abs(bx * dy - by * dx);
- if (miss > 0.05) continue;
- if (segmentBlocked(this.playerX, this.playerY, bot.x, bot.y, layout, 0.005)) continue;
- bestIndex = index;
- bestProjection = projection;
- bestMiss = miss;
- }
- if (bestIndex < 0) return;
-
- const bot = this.bots[bestIndex];
- const headshot = bestMiss < 0.012;
- const damage = headshot ? this.weapon.damageHead : this.weapon.damageBody;
- bot.hp = Math.max(0, bot.hp - damage);
- this.events.push({
- type: "hit",
- bot: bestIndex,
- headshot,
- damage,
- fatal: bot.hp === 0,
- });
- }
-
- private tickBots(dt: number): void {
- const layout = this.layout();
- for (const bot of this.bots) {
- if (bot.hp <= 0 || !this.alive) continue;
- const dx = this.playerX - bot.x;
- const dy = this.playerY - bot.y;
- const distance = Math.sqrt(dx * dx + dy * dy);
- const visible = !segmentBlocked(bot.x, bot.y, this.playerX, this.playerY, layout, 0.004);
- if (distance > 0.28 || !visible) {
- const step = (this.botConfig.speed / 190) * 0.075 * dt;
- const inverse = distance > 1e-6 ? 1 / distance : 0;
- this.moveBot(bot, dx * inverse * step, dy * inverse * step);
- }
-
- bot.attackLeft -= dt;
- if (visible && distance <= 0.38 && bot.attackLeft <= 0) {
- const span = Math.max(0, this.botConfig.damageMax - this.botConfig.damageMin);
- const damage =
- this.botConfig.damageMin +
- (span > 0 ? (bot.attacks * 3 + this.mapIndex + 1) % (span + 1) : 0);
- bot.attacks++;
- bot.attackLeft += this.botConfig.attackInterval;
- this.hp = Math.max(0, this.hp - damage);
- this.events.push({ type: "playerDamaged", amount: damage, hp: this.hp });
- if (this.hp === 0) {
- this.alive = false;
- this.events.push({ type: "playerDied" });
- }
- }
- }
- }
-}
diff --git a/game/symbian-compat.tsx b/game/symbian-compat.tsx
deleted file mode 100644
index e993140..0000000
--- a/game/symbian-compat.tsx
+++ /dev/null
@@ -1,300 +0,0 @@
-// Nokia E7 compatibility runtime + renderer.
-//
-// The generic PocketJS Symbian host owns a 2D UI surface, not OpenStrike's
-// native `strike` vocabulary or Pocket3D world renderer. On that one host,
-// and only while no future native `strike` surface exists, install the
-// deterministic top-down compatibility simulation. The on-screen label is
-// intentionally explicit: this is a playable 2D mode, not the BSP/3D port.
-
-import { createSignal, For, Show } from "solid-js";
-import { Text, View } from "@pocketjs/framework/components";
-import * as hot from "@pocketjs/framework/hot";
-import { BTN } from "@pocketjs/framework/input";
-import { onFrame } from "@pocketjs/framework/lifecycle";
-import type { NativeStrike, StrikeState } from "./sdk.ts";
-import {
- SYMBIAN_COMPAT_MAPS,
- SymbianCompatSimulation,
- type SymbianCompatInput,
-} from "./symbian-compat-sim.ts";
-
-const ARENA_W = 320;
-const ARENA_H = 200;
-const ACTOR_SIZE = 10;
-const FRAME_DT = 1 / 30;
-
-type Ref = NonNullable[0]>;
-type HostGlobal = typeof globalThis & {
- ui?: { __host?: string };
- strike?: NativeStrike;
-};
-
-const hostGlobal = globalThis as HostGlobal;
-const nativeStrikeExists = hostGlobal.strike !== undefined;
-
-/** True only for the reduced OpenStrike-owned mode, never a native game host. */
-export const symbianCompatibilityActive =
- hostGlobal.ui?.__host === "symbian-e7-dev" && !nativeStrikeExists;
-
-// Native PSP/Vita/desktop bundles parse the shared guest but allocate none of
-// the compatibility game state.
-const simulation = symbianCompatibilityActive
- ? new SymbianCompatSimulation()
- : undefined;
-const compatibilitySurface: NativeStrike | undefined = simulation
- ? {
- maps: [...SYMBIAN_COMPAT_MAPS],
- __initialState: simulation.initialState(),
- loadMap: (index) => simulation.loadMap(index),
- toMenu: () => simulation.toMenu(),
- setPhase: (phase) => {
- if (
- phase === "menu" ||
- phase === "starting" ||
- phase === "live" ||
- phase === "won" ||
- phase === "lost"
- ) {
- simulation.queueSetPhase(phase);
- }
- },
- resetRound: () => simulation.queueResetRound(),
- addWin: () => simulation.queueAddWin(),
- addLoss: () => simulation.queueAddLoss(),
- setBotCount: (count) => simulation.queueSetBotCount(count),
- configureWeapon: (config) => simulation.queueConfigureWeapon(config),
- configureBots: (config) => simulation.queueConfigureBots(config),
- }
- : undefined;
-
-if (compatibilitySurface) hostGlobal.strike = compatibilitySurface;
-
-export function symbianCompatInput(
- buttons: number,
- previousButtons: number,
-): SymbianCompatInput {
- return {
- forward:
- (buttons & BTN.UP ? 1 : 0) -
- (buttons & BTN.DOWN ? 1 : 0),
- turn:
- (buttons & BTN.RIGHT ? 1 : 0) -
- (buttons & BTN.LEFT ? 1 : 0),
- // E on the E7 keyboard is the Symbian host's right-trigger mapping.
- fire: (buttons & BTN.RTRIGGER) !== 0,
- // S is Square. Reload is an edge, like the native hosts.
- reload:
- (buttons & BTN.SQUARE) !== 0 &&
- (previousButtons & BTN.SQUARE) === 0,
- };
-}
-
-/**
- * Always-mounted driver plus the reduced world view. Keeping the driver
- * alive while the menu is visible lets `loadMap()` transition into gameplay
- * on the next host frame without a native lifecycle channel.
- */
-export function SymbianCompatibilityLayer() {
- if (!simulation || !compatibilitySurface) return null;
-
- const [phase, setPhase] = createSignal("menu");
- const [mapIndex, setMapIndex] = createSignal(0);
- let previousButtons = 0;
- let playerRef: Ref | undefined;
- let aimRef: Ref | undefined;
- const botRefs: Array[ = Array.from({ length: 8 });
-
- const syncScene = () => {
- const scene = simulation.scene();
- const playerX = scene.player.x * ARENA_W - ACTOR_SIZE / 2;
- const playerY = scene.player.y * ARENA_H - ACTOR_SIZE / 2;
- const angle = (scene.player.angle * 180) / Math.PI;
- hot.prop(playerRef, "translateX", playerX);
- hot.prop(playerRef, "translateY", playerY);
- hot.prop(playerRef, "rotate", angle);
- hot.prop(playerRef, "opacity", scene.player.alive ? 1 : 0.35);
- hot.prop(aimRef, "translateX", playerX + ACTOR_SIZE / 2);
- hot.prop(aimRef, "translateY", playerY + ACTOR_SIZE / 2 - 1);
- hot.prop(aimRef, "rotate", angle);
-
- for (let index = 0; index < botRefs.length; index++) {
- const bot = scene.bots[index];
- const ref = botRefs[index];
- hot.prop(ref, "opacity", bot?.alive ? 1 : bot ? 0.18 : 0);
- if (!bot) continue;
- hot.prop(ref, "translateX", bot.x * ARENA_W - ACTOR_SIZE / 2);
- hot.prop(ref, "translateY", bot.y * ARENA_H - ACTOR_SIZE / 2);
- hot.prop(ref, "rotate", (bot.angle * 180) / Math.PI);
- }
- };
-
- onFrame((buttons) => {
- simulation.tick(symbianCompatInput(buttons, previousButtons), FRAME_DT);
- previousButtons = buttons;
-
- const state = simulation.snapshot();
- const events = simulation.drainEvents();
- compatibilitySurface.__dispatch?.(state, events);
- // Gameplay rules queue intents from __dispatch callbacks. Apply them only
- // afterwards, matching the native PSP/Vita strike surface.
- simulation.flushCommands();
-
- const next = simulation.snapshot();
- setPhase(next.phase);
- setMapIndex(simulation.scene().mapIndex);
- if (next.phase !== "menu") syncScene();
- });
-
- const obstacles = () => {
- mapIndex();
- return simulation.scene().obstacles;
- };
-
- return (
-
-
-
- {/* Simple tactical floor grid. */}
- {Array.from({ length: 7 }, (_, index) => (
-
- ))}
- {Array.from({ length: 11 }, (_, index) => (
-
- ))}
-
-
- {(obstacle) => (
-
- )}
-
-
- (aimRef = element)}
- class="absolute"
- style={{
- insetL: 0,
- insetT: 0,
- width: 30,
- height: 2,
- bgColor: "#b8f34a80",
- opacity: 1,
- }}
- />
- (playerRef = element)}
- class="absolute border-[#e8f0f2]"
- style={{
- insetL: 0,
- insetT: 0,
- width: ACTOR_SIZE,
- height: ACTOR_SIZE,
- bgColor: "#b8f34a",
- }}
- >
-
-
-
- {Array.from({ length: 8 }, (_, index) => (
- (botRefs[index] = element)}
- class="absolute border-[#fecaca]"
- style={{
- insetL: 0,
- insetT: 0,
- width: ACTOR_SIZE,
- height: ACTOR_SIZE,
- bgColor: "#dc3038",
- opacity: 0,
- }}
- >
-
-
- ))}
-
-
-
- E7 2D COMPAT
-
-
- NO 3D
-
-
-
- {SYMBIAN_COMPAT_MAPS[mapIndex()].toUpperCase()}
-
-
-
- ARROWS MOVE / TURN · E FIRE · S RELOAD
-
-
-
- );
-}
diff --git a/package.json b/package.json
index a915e67..db311fd 100644
--- a/package.json
+++ b/package.json
@@ -11,7 +11,7 @@
"build:symbian": "bun scripts/symbian.ts",
"build:vita": "bun scripts/vita.ts",
"test:symbian-bundle": "bun scripts/symbian.ts --guest-only",
- "test:symbian-compat": "bun test test/symbian-compat.test.ts",
+ "test:symbian": "bun test test/symbian-tooling.test.ts",
"test:vita-package": "bun test test/openstrike-vita-artwork.test.ts",
"test:e2e:vita": "bun scripts/e2e-vita.ts",
"test:psp-toolchain": "bun test test/psp-toolchain.test.ts"
diff --git a/pocket.json b/pocket.json
index e0d74f4..4da142e 100644
--- a/pocket.json
+++ b/pocket.json
@@ -4,7 +4,7 @@
"id": "dev.pocket-stack.openstrike",
"name": "open-strike",
"title": "OpenStrike",
- "version": "0.1.0",
+ "version": "0.2.0",
"engine": {
"capabilities": {
"requires": [
@@ -12,7 +12,8 @@
"input.buttons"
],
"enhances": [
- "input.analog.left"
+ "input.analog.left",
+ "display.viewport.live"
]
}
},
@@ -27,6 +28,20 @@
272
],
"presentation": "integer-fit"
+ },
+ "dynamic": {
+ "default": [
+ 640,
+ 360
+ ],
+ "min": [
+ 360,
+ 360
+ ],
+ "max": [
+ 640,
+ 640
+ ]
}
}
}
diff --git a/scripts/symbian-compat-smoke.ts b/scripts/symbian-compat-smoke.ts
deleted file mode 100644
index eda9c49..0000000
--- a/scripts/symbian-compat-smoke.ts
+++ /dev/null
@@ -1,230 +0,0 @@
-// Execute a compiled symbian-e7-dev guest against a strict in-process model
-// of the PocketJS HostOps/frame contract, then drive a real menu selection and
-// combat input. This catches startup/module-order failures (especially a
-// missing `strike` surface) without claiming to replace hardware acceptance.
-//
-// Usage:
-// bun scripts/symbian-compat-smoke.ts \
-// --js=/path/to/openstrike.js --pak=/path/to/openstrike.pak
-
-import { resolve } from "node:path";
-
-interface StrikeState {
- phase: "menu" | "starting" | "live" | "won" | "lost";
- hp: number;
- ammo: number;
- aliveBots: number;
- totalBots: number;
- wins: number;
- losses: number;
-}
-
-interface StrikeEvent {
- type: string;
-}
-
-interface StrikeSurface {
- __dispatch?: (state: StrikeState, events: StrikeEvent[]) => void;
-}
-
-interface SmokeGlobals {
- ui?: Record;
- __pak?: ArrayBuffer;
- frame?: (buttons: number, analog?: number, touches?: readonly number[]) => void;
- strike?: StrikeSurface;
-}
-
-const flag = (name: string): string | undefined =>
- process.argv
- .slice(2)
- .find((argument) => argument.startsWith(`${name}=`))
- ?.slice(name.length + 1);
-
-const jsPath = flag("--js");
-const pakPath = flag("--pak");
-if (!jsPath || !pakPath) {
- throw new Error(
- "usage: bun scripts/symbian-compat-smoke.ts --js= --pak=",
- );
-}
-
-const absoluteJs = resolve(jsPath);
-const absolutePak = resolve(pakPath);
-if (!(await Bun.file(absoluteJs).exists()) || !(await Bun.file(absolutePak).exists())) {
- throw new Error(`missing compatibility smoke input: ${absoluteJs} or ${absolutePak}`);
-}
-
-let nextNode = 2;
-let nextTexture = 1;
-let nextAnimation = 1;
-let operationCount = 0;
-let focusedNode = 0;
-const op = () => {
- operationCount++;
-};
-
-const globals = globalThis as typeof globalThis & SmokeGlobals;
-globals.ui = {
- __host: "symbian-e7-dev",
- __hostAbi: 4,
- __viewport: { w: 640, h: 360 },
- createNode: () => {
- op();
- return nextNode++;
- },
- destroyNode: op,
- insertBefore: op,
- removeChild: op,
- setStyle: op,
- setProp: op,
- setPropBatch: op,
- setText: op,
- replaceText: op,
- uploadTexture: () => {
- op();
- return nextTexture++;
- },
- uploadImgEntry: () => {
- op();
- return nextTexture++;
- },
- setImage: op,
- setSprite: op,
- animate: () => {
- op();
- return nextAnimation++;
- },
- cancelAnim: op,
- setFocus: (node: number) => {
- focusedNode = node;
- op();
- },
- setActive: op,
- loadStyles: op,
- loadFontAtlas: op,
- measureText: (text: string) => text.length * 7,
-};
-globals.__pak = await Bun.file(absolutePak).arrayBuffer();
-
-// The output is a trusted local PocketJS IIFE generated from this checkout.
-const source = await Bun.file(absoluteJs).text();
-(0, eval)(source);
-// The native host drains pending QuickJS jobs after JS_Eval before starting
-// its frame timer. Mirror that boundary for Solid's mount effects.
-await Promise.resolve();
-await Promise.resolve();
-
-if (typeof globals.frame !== "function") {
- throw new Error("compatibility bundle did not install the PocketJS frame hook");
-}
-const surface = globals.strike;
-if (!surface?.__dispatch) {
- throw new Error("compatibility bundle did not install the strike dispatch surface");
-}
-
-let latest: StrikeState | undefined;
-const eventTypes: string[] = [];
-const dispatch = surface.__dispatch;
-surface.__dispatch = (state, events) => {
- latest = { ...state };
- eventTypes.push(...events.map((event) => event.type));
- dispatch(state, events);
-};
-
-const BTN_CIRCLE = 0x2000;
-const BTN_RTRIGGER = 0x0200;
-const BTN_LEFT = 0x0080;
-const BTN_RIGHT = 0x0020;
-const frame = (buttons = 0) => globals.frame!(buttons, 0x8080, []);
-const phase = (): StrikeState["phase"] | undefined => latest?.phase;
-const snapshot = (): StrikeState | undefined => (latest ? { ...latest } : undefined);
-
-// Let onMount establish the menu's first focused map, then activate it using
-// the exact Circle/Enter edge the E7 native host publishes.
-frame();
-if (phase() !== "menu") {
- throw new Error(`expected menu boot, received ${phase() ?? "no state"}`);
-}
-frame(BTN_CIRCLE);
-frame();
-if (phase() !== "starting") {
- throw new Error(
- `map selection did not enter a round: ${phase() ?? "no state"} (focused node ${focusedNode})`,
- );
-}
-
-for (let index = 0; index < 60 && phase() !== "live"; index++) frame();
-if (phase() !== "live") {
- throw new Error(`rules did not advance starting -> live: ${phase() ?? "no state"}`);
-}
-if (!latest) throw new Error("live phase was reached without a state snapshot");
-
-const botsBefore = latest.aliveBots;
-frame(BTN_RTRIGGER);
-frame();
-if (!eventTypes.includes("hit") || !latest || latest.aliveBots >= botsBefore) {
- throw new Error(
- `combat input did not produce a hit (bots ${botsBefore} -> ${latest?.aliveBots ?? "?"})`,
- );
-}
-
-const fireBurst = (frames: number) => {
- for (let index = 0; index < frames; index++) frame(BTN_RTRIGGER);
- frame();
-};
-
-// Clear the two flanking center-lane bots using only the E7 button protocol.
-// Integer frame turns intentionally allow body hits, exercising automatic
-// fire rather than relying on the initial exact headshot.
-for (let index = 0; index < 2; index++) frame(BTN_LEFT);
-fireBurst(12);
-const leftLaneState = snapshot();
-if (!leftLaneState || leftLaneState.aliveBots !== 1) {
- throw new Error(
- `left-lane engagement did not leave one bot: ${leftLaneState?.aliveBots ?? "?"}`,
- );
-}
-for (let index = 0; index < 4; index++) frame(BTN_RIGHT);
-fireBurst(12);
-for (let index = 0; index < 4 && phase() !== "won"; index++) frame();
-const wonState = snapshot();
-if (!wonState || phase() !== "won" || wonState.aliveBots !== 0 || wonState.wins !== 1) {
- throw new Error(
- `elimination did not reach rules-owned win state: ${phase() ?? "?"}, ` +
- `bots=${wonState?.aliveBots ?? "?"}, wins=${wonState?.wins ?? "?"}`,
- );
-}
-
-for (let index = 0; index < 130 && phase() !== "starting"; index++) frame();
-const resetState = snapshot();
-if (
- !resetState ||
- phase() !== "starting" ||
- resetState.aliveBots !== resetState.totalBots ||
- !eventTypes.includes("roundReset")
-) {
- throw new Error(
- `rules-owned next round did not reset: ${phase() ?? "?"}, ` +
- `bots=${resetState?.aliveBots ?? "?"}/${resetState?.totalBots ?? "?"}`,
- );
-}
-if (operationCount === 0) {
- throw new Error("compatibility renderer emitted no HostOps");
-}
-
-console.log(
- JSON.stringify(
- {
- result: "ok",
- mode: "E7 2D compatibility (not the Pocket3D renderer)",
- phase: latest.phase,
- bots: `${latest.aliveBots}/${latest.totalBots}`,
- hp: latest.hp,
- ammo: latest.ammo,
- events: eventTypes,
- hostOperations: operationCount,
- },
- null,
- 2,
- ),
-);
diff --git a/scripts/symbian-manifest.ts b/scripts/symbian-manifest.ts
deleted file mode 100644
index 38e5624..0000000
--- a/scripts/symbian-manifest.ts
+++ /dev/null
@@ -1,99 +0,0 @@
-// Derive the explicitly reduced E7 package from the canonical Pocket
-// contract. Keeping this mechanical avoids a second hand-maintained manifest
-// drifting from OpenStrike's version, capabilities, or viewport policy.
-
-interface PocketLikeManifest {
- $schema?: unknown;
- pocket?: unknown;
- id?: unknown;
- name?: unknown;
- title?: unknown;
- version?: unknown;
- engine?: {
- capabilities?: {
- requires?: unknown;
- enhances?: unknown;
- [key: string]: unknown;
- };
- [key: string]: unknown;
- };
- app?: {
- entry?: unknown;
- output?: unknown;
- framework?: unknown;
- viewport?: unknown;
- [key: string]: unknown;
- };
- [key: string]: unknown;
-}
-
-export const SYMBIAN_COMPAT_ENTRY = "game/openstrike-symbian.tsx";
-export const SYMBIAN_COMPAT_OUTPUT = "openstrike-e7-compat";
-export const SYMBIAN_COMPAT_LIVE_VIEWPORT = "display.viewport.live";
-export const SYMBIAN_COMPAT_DYNAMIC_VIEWPORT = {
- default: [640, 360],
- min: [360, 360],
- max: [640, 640],
-} as const;
-
-export function deriveSymbianCompatManifest(input: unknown): PocketLikeManifest {
- if (
- input === null ||
- typeof input !== "object" ||
- Array.isArray(input)
- ) {
- throw new Error("canonical pocket.json must be an object");
- }
- const canonical = input as PocketLikeManifest;
- const capabilities = canonical.engine?.capabilities;
- const enhances = capabilities?.enhances;
- const viewport = canonical.app?.viewport;
- if (
- canonical.pocket !== 2 ||
- typeof canonical.id !== "string" ||
- typeof canonical.name !== "string" ||
- typeof canonical.title !== "string" ||
- typeof canonical.version !== "string" ||
- canonical.app === undefined ||
- canonical.app.entry !== "game/openstrike.tsx" ||
- capabilities === undefined ||
- !Array.isArray(enhances) ||
- !enhances.every((capability) => typeof capability === "string") ||
- viewport === null ||
- typeof viewport !== "object" ||
- Array.isArray(viewport) ||
- !("fixed" in viewport) ||
- "dynamic" in viewport
- ) {
- throw new Error(
- "canonical pocket.json identity/version/capabilities/fixed viewport is incomplete or unexpected",
- );
- }
-
- const derived = structuredClone(canonical);
- derived.id = `${canonical.id}.e7-compat`;
- derived.name = "open-strike-e7-compat";
- derived.title = "OpenStrike E7 2D Compat";
- derived.engine = {
- ...derived.engine!,
- capabilities: {
- ...derived.engine!.capabilities!,
- enhances: [
- ...enhances,
- ...(enhances.includes(SYMBIAN_COMPAT_LIVE_VIEWPORT)
- ? []
- : [SYMBIAN_COMPAT_LIVE_VIEWPORT]),
- ],
- },
- };
- derived.app = {
- ...derived.app!,
- entry: SYMBIAN_COMPAT_ENTRY,
- output: SYMBIAN_COMPAT_OUTPUT,
- viewport: {
- ...(structuredClone(viewport) as Record),
- dynamic: structuredClone(SYMBIAN_COMPAT_DYNAMIC_VIEWPORT),
- },
- };
- return derived;
-}
diff --git a/scripts/symbian.ts b/scripts/symbian.ts
index addfe9c..c590449 100644
--- a/scripts/symbian.ts
+++ b/scripts/symbian.ts
@@ -1,154 +1,803 @@
-// Build OpenStrike's independently installable Nokia E7 compatibility SIS
-// through the canonical PocketJS Symbian toolchain.
+// Build the real OpenStrike FPS for Nokia E7:
+// de_dust2 BSP -> Pocket3D .p3d -> canonical PocketJS JS/PAK
+// -> app-specific Rust simulation/GLES2 core -> independently installable SIS.
//
-// The pinned vendor is authoritative. POCKETJS_ROOT/--pocketjs-root remains
-// available only for deliberately testing a newer PocketJS checkout.
-//
-// `--guest-only` stops before Rust/qmake/SIS packaging and runs the HostOps
-// gameplay smoke against dist/pocket/symbian-e7-dev.
+// The pinned vendor is authoritative for native builds. POCKETJS_ROOT/
+// --pocketjs-root is a guest-only escape hatch because the native Cargo graph
+// also resolves PocketJS from the pinned submodule.
-import { existsSync, mkdirSync } from "node:fs";
-import { delimiter, dirname, resolve } from "node:path";
-import { pathToFileURL } from "node:url";
+import { createHash } from "node:crypto";
import {
- deriveSymbianCompatManifest,
- SYMBIAN_COMPAT_OUTPUT,
-} from "./symbian-manifest.ts";
+ existsSync,
+ mkdirSync,
+ readdirSync,
+ readFileSync,
+ statSync,
+} from "node:fs";
+import { homedir } from "node:os";
+import { delimiter, dirname, resolve } from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { withArtifactLock } from "../vendor/pocketjs/tools/psp-toolchain.ts";
+
+export const SYMBIAN_TARGET = "symbian-e7-dev";
+export const SYMBIAN_MAP_NAME = "de_dust2";
+export const SYMBIAN_MAP_KEY = `maps/${SYMBIAN_MAP_NAME}.p3d`;
+export const SYMBIAN_APP_OUTPUT = "openstrike";
+export const SYMBIAN_GENERATED_ENTRY =
+ ".pocket/symbian-e7-dev/app/openstrike.tsx";
+const SYMBIAN_GENERATED_MAP_FILE = "../../../dist/maps/de_dust2.p3d";
interface ResolvedPlan {
- readonly app: { readonly output: string };
+ readonly app: {
+ readonly entry: string;
+ readonly output: string;
+ };
+ readonly target: {
+ readonly id: string;
+ };
}
interface SymbianProfileModule {
resolveSymbianE7BuildPlan(input: unknown): ResolvedPlan;
}
-const repo = resolve(new URL("..", import.meta.url).pathname);
-const args = process.argv.slice(2);
-
-function option(name: string): string | undefined {
- const equals = args.find((argument) => argument.startsWith(`${name}=`));
- if (equals) return equals.slice(name.length + 1);
- const index = args.indexOf(name);
- if (index >= 0) return args[index + 1];
- return undefined;
-}
-
-const vendorRoot = resolve(repo, "vendor/pocketjs");
-const pocketjsRoot = resolve(
- option("--pocketjs-root") ??
- process.env.POCKETJS_ROOT ??
- vendorRoot,
-);
-const symbianTool = resolve(pocketjsRoot, "tools/symbian.ts");
-const symbianProfile = resolve(pocketjsRoot, "tools/symbian-profile.ts");
-const pocketBuild = resolve(pocketjsRoot, "tools/build.ts");
-if (
- !existsSync(symbianTool) ||
- !existsSync(symbianProfile) ||
- !existsSync(pocketBuild)
-) {
- throw new Error(
- `PocketJS Symbian toolchain is unavailable at ${pocketjsRoot}; ` +
- "set POCKETJS_ROOT or --pocketjs-root to a current PocketJS checkout",
- );
-}
-
-const canonicalPath = resolve(repo, "pocket.json");
-const canonical = await Bun.file(canonicalPath).json();
-const manifest = deriveSymbianCompatManifest(canonical);
-const generatedRoot = resolve(repo, ".pocket/symbian-e7-dev");
-const manifestPath = resolve(generatedRoot, "pocket.generated.json");
-mkdirSync(dirname(manifestPath), { recursive: true });
-await Bun.write(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
-
-const profileUrl = pathToFileURL(symbianProfile);
-profileUrl.searchParams.set("mtime", String(Date.now()));
-const profile = await import(profileUrl.href) as SymbianProfileModule;
-const plan = profile.resolveSymbianE7BuildPlan(manifest);
-if (plan.app.output !== SYMBIAN_COMPAT_OUTPUT) {
- throw new Error(
- `derived Symbian output mismatch: ${plan.app.output}`,
- );
-}
-
-const bun = Bun.which("bun");
-if (!bun) throw new Error("Bun is required to build OpenStrike for Symbian");
-const rustup = Bun.which("rustup");
-const subprocessEnv = rustup
- ? {
- ...process.env,
- // PocketJS invokes its pinned toolchain through rustup, but Cargo then
- // resolves `rustc` through PATH. Keep the rustup proxy ahead of a
- // Homebrew stable compiler so the upstream nightly selection survives.
- PATH: [dirname(rustup), process.env.PATH].filter(Boolean).join(delimiter),
+interface SymbianToolchainFile {
+ readonly runtime?: {
+ readonly rustToolchain?: string;
+ readonly frameRate?: number;
+ };
+}
+
+interface PocketManifest {
+ readonly version?: unknown;
+ readonly engine?: unknown;
+ readonly app?: unknown;
+}
+
+interface PakManifestEntry {
+ readonly key?: unknown;
+ readonly file?: unknown;
+}
+
+interface PakBlob {
+ readonly key: string;
+ readonly data: Uint8Array;
+}
+
+interface PakCompilerModule {
+ unpack(file: Uint8Array): readonly PakBlob[];
+}
+
+export interface SymbianCliOptions {
+ readonly guestOnly: boolean;
+ readonly help: boolean;
+ readonly pocketjsRoot?: string;
+ readonly sisVersion?: string;
+ readonly uid?: string;
+}
+
+export interface SymbianBuildPaths {
+ readonly repo: string;
+ readonly pocketjs: string;
+ readonly symbianTool: string;
+ readonly symbianProfile: string;
+ readonly pocketBuild: string;
+ readonly toolchainManifest: string;
+ readonly targetSpec: string;
+ readonly pocket3dWorkspace: string;
+ readonly nativeManifest: string;
+ readonly nativeLock: string;
+ readonly generatedManifest: string;
+ readonly generatedEntry: string;
+ readonly generatedPakManifest: string;
+ readonly mapSource: string;
+ readonly mapSupport: string;
+ readonly cookedMap: string;
+ readonly plan: string;
+ readonly guestOutput: string;
+ readonly nativeTarget: string;
+ readonly nativeLibrary: string;
+ readonly packageOutput: string;
+}
+
+const repo = resolve(fileURLToPath(new URL("..", import.meta.url)));
+
+export async function withSymbianBuildLock(
+ repository: string,
+ operation: () => Promise,
+): Promise {
+ return await withArtifactLock(
+ resolve(repository, ".pocket/openstrike-symbian-build.lock"),
+ operation,
+ { timeoutMs: 20 * 60_000, staleMs: 30 * 60_000 },
+ );
+}
+
+function record(value: unknown, label: string): Record {
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
+ throw new Error(`${label} must be an object`);
+ }
+ return value as Record;
+}
+
+function optionValue(
+ args: readonly string[],
+ index: number,
+ name: string,
+): readonly [string, number] | undefined {
+ const argument = args[index]!;
+ if (argument.startsWith(`${name}=`)) {
+ const value = argument.slice(name.length + 1);
+ if (!value) throw new Error(`${name} requires a value`);
+ return [value, index];
+ }
+ if (argument !== name) return undefined;
+ const value = args[index + 1];
+ if (!value || value.startsWith("-")) {
+ throw new Error(`${name} requires a value`);
+ }
+ return [value, index + 1];
+}
+
+export function parseSymbianCli(
+ args: readonly string[],
+): SymbianCliOptions {
+ let guestOnly = false;
+ let help = false;
+ let pocketjsRoot: string | undefined;
+ let sisVersion: string | undefined;
+ let uid: string | undefined;
+ for (let index = 0; index < args.length; index++) {
+ const argument = args[index]!;
+ if (argument === "--guest-only") {
+ guestOnly = true;
+ continue;
+ }
+ if (argument === "--help" || argument === "-h") {
+ help = true;
+ continue;
+ }
+ const pocketjs = optionValue(args, index, "--pocketjs-root");
+ if (pocketjs) {
+ if (pocketjsRoot !== undefined) {
+ throw new Error("--pocketjs-root may only be provided once");
+ }
+ [pocketjsRoot, index] = pocketjs;
+ continue;
+ }
+ const version = optionValue(args, index, "--sis-version");
+ if (version) {
+ if (sisVersion !== undefined) {
+ throw new Error("--sis-version may only be provided once");
+ }
+ [sisVersion, index] = version;
+ continue;
}
- : process.env;
+ const packageUid = optionValue(args, index, "--uid");
+ if (packageUid) {
+ if (uid !== undefined) throw new Error("--uid may only be provided once");
+ [uid, index] = packageUid;
+ continue;
+ }
+ throw new Error(`unknown Symbian build argument: ${argument}`);
+ }
+ return { guestOnly, help, pocketjsRoot, sisVersion, uid };
+}
-async function run(command: readonly string[]): Promise {
- const child = Bun.spawn({
- cmd: [...command],
- cwd: repo,
- env: subprocessEnv,
- stdout: "inherit",
- stderr: "inherit",
- });
- const exitCode = await child.exited;
- if (exitCode !== 0) {
- throw new Error(`${command.slice(0, 3).join(" ")} failed with exit code ${exitCode}`);
+function expandHome(path: string, home: string): string {
+ if (path === "~") return home;
+ if (path.startsWith("~/")) return resolve(home, path.slice(2));
+ return path;
+}
+
+export function resolvePocketJsRoot(
+ repository: string,
+ options: Pick,
+ environment: Readonly> = process.env,
+): string {
+ const home = environment.HOME || homedir();
+ const selected =
+ options.pocketjsRoot ||
+ environment.POCKETJS_ROOT ||
+ resolve(repository, "vendor/pocketjs");
+ return resolve(repository, expandHome(selected, home));
+}
+
+export function validateNativePocketJsSelection(
+ repository: string,
+ pocketjs: string,
+ guestOnly: boolean,
+): void {
+ const pinned = resolve(repository, "vendor/pocketjs");
+ if (!guestOnly && resolve(pocketjs) !== pinned) {
+ throw new Error(
+ "full Symbian builds must use pinned vendor/pocketjs so the Cargo core and Qt host cannot drift",
+ );
}
}
-async function smoke(js: string, pak: string): Promise {
- await run([
- bun!,
- resolve(repo, "scripts/symbian-compat-smoke.ts"),
- `--js=${js}`,
- `--pak=${pak}`,
- ]);
+export function createSymbianBuildPaths(
+ repository: string,
+ pocketjs: string,
+ mapsRoot: string,
+): SymbianBuildPaths {
+ const nativeTarget = resolve(repository, "dist/symbian-core");
+ return {
+ repo: resolve(repository),
+ pocketjs: resolve(pocketjs),
+ symbianTool: resolve(pocketjs, "tools/symbian.ts"),
+ symbianProfile: resolve(pocketjs, "tools/symbian-profile.ts"),
+ pocketBuild: resolve(pocketjs, "tools/build.ts"),
+ toolchainManifest: resolve(
+ pocketjs,
+ "tools/cli/symbian-toolchain.json",
+ ),
+ targetSpec: resolve(
+ pocketjs,
+ "engine/symbian/targets/armv6-symbian-eabi.json",
+ ),
+ pocket3dWorkspace: resolve(pocketjs, "engine/pocket3d"),
+ nativeManifest: resolve(
+ repository,
+ "crates/openstrike-symbian/Cargo.toml",
+ ),
+ nativeLock: resolve(repository, "crates/openstrike-symbian/Cargo.lock"),
+ generatedManifest: resolve(
+ repository,
+ ".pocket/symbian-e7-dev/pocket.json",
+ ),
+ generatedEntry: resolve(repository, SYMBIAN_GENERATED_ENTRY),
+ generatedPakManifest: resolve(
+ repository,
+ ".pocket/symbian-e7-dev/app/pak.json",
+ ),
+ mapSource: resolve(mapsRoot, "maps/de_dust2.bsp"),
+ mapSupport: resolve(mapsRoot, "support"),
+ cookedMap: resolve(repository, "dist/maps/de_dust2.p3d"),
+ plan: resolve(repository, ".pocket/symbian-e7-dev/plan.json"),
+ guestOutput: resolve(repository, "dist/pocket/symbian-e7-dev"),
+ nativeTarget,
+ nativeLibrary: resolve(
+ nativeTarget,
+ "armv6-symbian-eabi/release/libopenstrike_symbian.a",
+ ),
+ packageOutput: resolve(repository, "dist/symbian"),
+ };
}
-if (args.includes("--guest-only")) {
- const planPath = resolve(generatedRoot, "plan.json");
- const outdir = resolve(repo, "dist/pocket/symbian-e7-dev");
- await Bun.write(planPath, JSON.stringify(plan, null, 2) + "\n");
- await run([
- bun,
- pocketBuild,
- `--plan=${planPath}`,
- `--project-root=${repo}`,
- `--outdir=${outdir}`,
- ]);
- await smoke(
- resolve(outdir, `${SYMBIAN_COMPAT_OUTPUT}.js`),
- resolve(outdir, `${SYMBIAN_COMPAT_OUTPUT}.pak`),
- );
- console.log(`OpenStrike E7 compatibility guest: ${outdir}`);
-} else {
- const outputRoot = resolve(repo, "dist/symbian");
+export function validateSymbianSourceContract(
+ manifestInput: unknown,
+): { readonly version: string } {
+ const manifest = record(manifestInput, "pocket.json");
+ const app = record(manifest.app, "pocket.json app");
+ const viewport = record(app.viewport, "pocket.json app.viewport");
+ const dynamic = record(
+ viewport.dynamic,
+ "pocket.json app.viewport.dynamic",
+ );
+ const engine = record(manifest.engine, "pocket.json engine");
+ const capabilities = record(
+ engine.capabilities,
+ "pocket.json engine.capabilities",
+ );
+ const enhances = capabilities.enhances;
+ if (
+ app.entry !== "game/openstrike.tsx" ||
+ app.output !== SYMBIAN_APP_OUTPUT
+ ) {
+ throw new Error(
+ "Symbian must build the canonical OpenStrike game entry and output",
+ );
+ }
+ if (
+ !Array.isArray(enhances) ||
+ !enhances.includes("display.viewport.live")
+ ) {
+ throw new Error("OpenStrike must enhance display.viewport.live");
+ }
+ for (const name of ["default", "min", "max"] as const) {
+ const actual = dynamic[name];
+ if (
+ !Array.isArray(actual) ||
+ actual.length !== 2 ||
+ actual.some((extent) =>
+ !Number.isInteger(extent) || extent <= 0
+ )
+ ) {
+ throw new Error(
+ `OpenStrike dynamic viewport ${name} must be a positive extent`,
+ );
+ }
+ }
+ if (
+ typeof manifest.version !== "string" ||
+ !/^[0-9]+\.[0-9]+\.[0-9]+$/.test(manifest.version)
+ ) {
+ throw new Error("pocket.json version must be a three-part SIS version");
+ }
+ return { version: manifest.version };
+}
+
+export function createSymbianBuildManifest(
+ manifestInput: unknown,
+): Record {
+ const manifest = structuredClone(record(manifestInput, "pocket.json"));
+ const app = record(manifest.app, "pocket.json app");
+ manifest.app = { ...app, entry: SYMBIAN_GENERATED_ENTRY };
+ return manifest;
+}
+
+export function symbianGeneratedPakManifest(): readonly PakManifestEntry[] {
+ return [{
+ key: SYMBIAN_MAP_KEY,
+ file: SYMBIAN_GENERATED_MAP_FILE,
+ }];
+}
+
+async function materializeSymbianBuildProject(
+ paths: SymbianBuildPaths,
+ manifestInput: unknown,
+): Promise> {
+ const manifest = createSymbianBuildManifest(manifestInput);
+ mkdirSync(dirname(paths.generatedEntry), { recursive: true });
+ await Bun.write(
+ paths.generatedEntry,
+ 'import "../../../game/openstrike.tsx";\n',
+ );
+ await Bun.write(
+ paths.generatedPakManifest,
+ JSON.stringify(symbianGeneratedPakManifest(), null, 2) + "\n",
+ );
+ await Bun.write(
+ paths.generatedManifest,
+ JSON.stringify(manifest, null, 2) + "\n",
+ );
+ return manifest;
+}
+
+function sha256(bytes: Uint8Array): string {
+ return createHash("sha256").update(bytes).digest("hex");
+}
+
+export function validateGuestArtifacts(
+ js: Uint8Array,
+ entries: readonly PakBlob[],
+ cookedMap: Uint8Array,
+): void {
+ if (
+ js.byteLength < 1024 ||
+ !new TextDecoder().decode(js).toLowerCase().includes("strike")
+ ) {
+ throw new Error("Symbian guest JavaScript is empty or not OpenStrike");
+ }
+ const mapEntries = entries.filter((entry) =>
+ entry.key.startsWith("maps/")
+ );
+ if (
+ mapEntries.length !== 1 ||
+ mapEntries[0]!.key !== SYMBIAN_MAP_KEY
+ ) {
+ throw new Error(
+ "Symbian guest PAK must contain only maps/de_dust2.p3d",
+ );
+ }
+ if (
+ mapEntries[0]!.data.byteLength !== cookedMap.byteLength ||
+ sha256(mapEntries[0]!.data) !== sha256(cookedMap)
+ ) {
+ throw new Error("Symbian guest PAK map differs from cooked de_dust2");
+ }
+}
+
+async function unpackGuestPak(
+ pocketjsRoot: string,
+ pak: Uint8Array,
+): Promise {
+ const compiler = resolve(
+ pocketjsRoot,
+ "framework/compiler/pak.ts",
+ );
+ if (!existsSync(compiler)) {
+ throw new Error(`PocketJS PAK authority is missing at ${compiler}`);
+ }
+ const url = pathToFileURL(compiler);
+ url.searchParams.set("mtime", String(statSync(compiler).mtimeMs));
+ const module = await import(url.href) as PakCompilerModule;
+ return module.unpack(pak);
+}
+
+export function symbianCookCommand(
+ paths: SymbianBuildPaths,
+): readonly string[] {
+ return [
+ "cargo",
+ "run",
+ "--release",
+ "-q",
+ "-p",
+ "pocket3d-cook",
+ "--",
+ paths.mapSource,
+ "--wads",
+ paths.mapSupport,
+ "--subdivide",
+ "32",
+ "-o",
+ paths.cookedMap,
+ "--verify",
+ ];
+}
+
+export function symbianVerifyCookedCommand(
+ paths: SymbianBuildPaths,
+): readonly string[] {
+ return [
+ "cargo",
+ "run",
+ "--release",
+ "-q",
+ "-p",
+ "pocket3d-cook",
+ "--",
+ "--verify-cooked",
+ paths.cookedMap,
+ ];
+}
+
+export function symbianNativeCommand(
+ paths: SymbianBuildPaths,
+ rustToolchain: string,
+ rustup = "rustup",
+): readonly string[] {
+ return [
+ rustup,
+ "run",
+ rustToolchain,
+ "cargo",
+ "build",
+ "--manifest-path",
+ paths.nativeManifest,
+ "--release",
+ "--locked",
+ "--target",
+ paths.targetSpec,
+ "-Z",
+ "json-target-spec",
+ "-Z",
+ "build-std=core,alloc,compiler_builtins",
+ "-Z",
+ "build-std-features=compiler-builtins-mem",
+ ];
+}
+
+export function symbianPackageCommand(
+ paths: SymbianBuildPaths,
+ bun: string,
+ sisVersion: string,
+ uid?: string,
+): readonly string[] {
const command = [
bun,
- symbianTool,
+ paths.symbianTool,
"build",
"app",
"--manifest",
- manifestPath,
+ paths.generatedManifest,
"--project-root",
- repo,
+ paths.repo,
"--outdir",
- outputRoot,
+ paths.packageOutput,
"--sis-version",
- option("--sis-version") ?? String(manifest.version),
+ sisVersion,
+ "--core-library",
+ paths.nativeLibrary,
];
- const uid = option("--uid");
if (uid) command.push("--uid", uid);
- await run(command);
+ return command;
+}
+
+function validatePocketJsRoot(
+ paths: SymbianBuildPaths,
+ requireNativePackaging: boolean,
+): void {
+ const required = [
+ paths.symbianTool,
+ paths.symbianProfile,
+ paths.pocketBuild,
+ paths.toolchainManifest,
+ paths.targetSpec,
+ resolve(paths.pocket3dWorkspace, "../Cargo.toml"),
+ ];
+ const missing = required.filter((path) => !existsSync(path));
+ if (missing.length > 0) {
+ throw new Error(
+ `PocketJS Symbian toolchain is incomplete at ${paths.pocketjs}: ` +
+ missing.map((path) => path.slice(paths.pocketjs.length + 1)).join(", "),
+ );
+ }
+ if (
+ requireNativePackaging &&
+ !readFileSync(paths.symbianTool, "utf8").includes("coreLibrary:")
+ ) {
+ throw new Error(
+ `PocketJS at ${paths.pocketjs} predates custom Symbian cores; ` +
+ "advance the pinned vendor/pocketjs revision",
+ );
+ }
+}
+
+function newestMapInput(paths: SymbianBuildPaths): number {
+ if (!existsSync(paths.mapSource)) {
+ throw new Error(
+ `missing ${paths.mapSource}; set OPENSTRIKE_MAPS to your GoldSrc map data`,
+ );
+ }
+ if (!existsSync(paths.mapSupport) || !statSync(paths.mapSupport).isDirectory()) {
+ throw new Error(`missing WAD support directory ${paths.mapSupport}`);
+ }
+ const wads = readdirSync(paths.mapSupport)
+ .filter((name) => name.toLowerCase().endsWith(".wad"))
+ .map((name) => resolve(paths.mapSupport, name));
+ if (wads.length === 0) {
+ throw new Error(`no WAD files found under ${paths.mapSupport}`);
+ }
+ return Math.max(
+ statSync(paths.mapSource).mtimeMs,
+ ...wads.map((path) => statSync(path).mtimeMs),
+ );
+}
+
+function mapNeedsCooking(paths: SymbianBuildPaths): boolean {
+ if (!existsSync(paths.cookedMap)) {
+ newestMapInput(paths);
+ return true;
+ }
+ // A valid ignored .p3d is already a complete build input. Requiring the
+ // copyrighted BSP again would make a copied build tree unusable; when the
+ // source is present, however, its mtime and the WAD mtimes still detect a
+ // stale cook.
+ if (!existsSync(paths.mapSource)) return false;
+ const newestInput = newestMapInput(paths);
+ return statSync(paths.cookedMap).mtimeMs < newestInput;
+}
- const payload = resolve(outputRoot, "build", SYMBIAN_COMPAT_OUTPUT);
- await smoke(resolve(payload, "app.js"), resolve(payload, "app.pak"));
- const sis = resolve(outputRoot, `${SYMBIAN_COMPAT_OUTPUT}.sis`);
- if (!existsSync(sis)) {
+interface RunOptions {
+ readonly cwd: string;
+ readonly env?: Record;
+}
+
+async function run(
+ command: readonly string[],
+ options: RunOptions,
+): Promise {
+ const child = Bun.spawn({
+ cmd: [...command],
+ cwd: options.cwd,
+ env: options.env,
+ stdout: "inherit",
+ stderr: "inherit",
+ });
+ const exitCode = await child.exited;
+ if (exitCode !== 0) {
+ throw new Error(
+ `${command.slice(0, 4).join(" ")} failed with exit code ${exitCode}`,
+ );
+ }
+}
+
+async function resolveBuildPlan(
+ paths: SymbianBuildPaths,
+ manifest: unknown,
+): Promise {
+ const profileUrl = pathToFileURL(paths.symbianProfile);
+ profileUrl.searchParams.set(
+ "mtime",
+ String(statSync(paths.symbianProfile).mtimeMs),
+ );
+ const profile = await import(profileUrl.href) as SymbianProfileModule;
+ const plan = profile.resolveSymbianE7BuildPlan(manifest);
+ if (
+ plan.target.id !== SYMBIAN_TARGET ||
+ plan.app.output !== SYMBIAN_APP_OUTPUT ||
+ plan.app.entry !== SYMBIAN_GENERATED_ENTRY
+ ) {
+ throw new Error("PocketJS resolved the wrong Symbian OpenStrike plan");
+ }
+ mkdirSync(dirname(paths.plan), { recursive: true });
+ await Bun.write(paths.plan, JSON.stringify(plan, null, 2) + "\n");
+ return plan;
+}
+
+async function ensureDust2(paths: SymbianBuildPaths): Promise {
+ if (mapNeedsCooking(paths)) {
+ mkdirSync(dirname(paths.cookedMap), { recursive: true });
+ console.log("openstrike-symbian: cooking de_dust2");
+ await run(symbianCookCommand(paths), {
+ cwd: paths.pocket3dWorkspace,
+ env: process.env,
+ });
+ }
+ // Always ask the canonical Rust reader to validate reused as well as newly
+ // cooked artifacts. Header-only checks can accept truncated section tables.
+ await run(symbianVerifyCookedCommand(paths), {
+ cwd: paths.pocket3dWorkspace,
+ env: process.env,
+ });
+}
+
+async function buildGuest(
+ paths: SymbianBuildPaths,
+ bun: string,
+): Promise {
+ mkdirSync(paths.guestOutput, { recursive: true });
+ await run([
+ bun,
+ paths.pocketBuild,
+ `--plan=${paths.plan}`,
+ `--project-root=${paths.repo}`,
+ `--outdir=${paths.guestOutput}`,
+ ], {
+ cwd: paths.repo,
+ env: process.env,
+ });
+ const js = new Uint8Array(
+ readFileSync(resolve(paths.guestOutput, `${SYMBIAN_APP_OUTPUT}.js`)),
+ );
+ const pak = new Uint8Array(
+ readFileSync(resolve(paths.guestOutput, `${SYMBIAN_APP_OUTPUT}.pak`)),
+ );
+ validateGuestArtifacts(
+ js,
+ await unpackGuestPak(paths.pocketjs, pak),
+ new Uint8Array(readFileSync(paths.cookedMap)),
+ );
+}
+
+export function validateSymbianRuntimePin(
+ input: SymbianToolchainFile,
+): string {
+ const toolchain = input.runtime?.rustToolchain;
+ if (!toolchain || !/^nightly-[0-9]{4}-[0-9]{2}-[0-9]{2}$/.test(toolchain)) {
+ throw new Error("PocketJS Symbian Rust nightly pin is missing");
+ }
+ if (input.runtime?.frameRate !== 30) {
+ throw new Error(
+ "OpenStrike requires the pinned Symbian host frameRate to remain 30 Hz",
+ );
+ }
+ return toolchain;
+}
+
+function readRustToolchain(paths: SymbianBuildPaths): string {
+ return validateSymbianRuntimePin(
+ JSON.parse(
+ readFileSync(paths.toolchainManifest, "utf8"),
+ ) as SymbianToolchainFile,
+ );
+}
+
+const HELP = `OpenStrike Nokia E7 3D build
+
+ bun scripts/symbian.ts --guest-only
+ cook only de_dust2, compile the real JS/PAK, and validate the embedded map
+
+ bun scripts/symbian.ts [--sis-version 0.2.0] [--uid 0xE.......]
+ additionally build the pinned Rust GLES2 core and package openstrike.sis
+
+ OPENSTRIKE_MAPS defaults to ~/Downloads/cs-maps-20260705-1836.
+ POCKETJS_ROOT or --pocketjs-root selects an explicit checkout only with
+ --guest-only. Native builds always use pinned vendor/pocketjs.
+`;
+
+async function runSymbianMain(
+ args: readonly string[] = Bun.argv.slice(2),
+): Promise {
+ const options = parseSymbianCli(args);
+ if (options.help) {
+ console.log(HELP);
+ return;
+ }
+ const pocketjs = resolvePocketJsRoot(repo, options);
+ validateNativePocketJsSelection(repo, pocketjs, options.guestOnly);
+ const mapsRoot = resolve(
+ expandHome(
+ process.env.OPENSTRIKE_MAPS ||
+ resolve(homedir(), "Downloads/cs-maps-20260705-1836"),
+ process.env.HOME || homedir(),
+ ),
+ );
+ const paths = createSymbianBuildPaths(repo, pocketjs, mapsRoot);
+ validatePocketJsRoot(paths, !options.guestOnly);
+
+ const manifest = JSON.parse(
+ readFileSync(resolve(repo, "pocket.json"), "utf8"),
+ ) as PocketManifest;
+ const contract = validateSymbianSourceContract(manifest);
+ await ensureDust2(paths);
+ const buildManifest = await materializeSymbianBuildProject(paths, manifest);
+ await resolveBuildPlan(paths, buildManifest);
+
+ const bun = Bun.which("bun") || process.execPath;
+ if (options.guestOnly) {
+ await buildGuest(paths, bun);
+ console.log(
+ `OpenStrike E7 real guest: ${paths.guestOutput}/${SYMBIAN_APP_OUTPUT}.{js,pak}`,
+ );
+ return;
+ }
+
+ if (!existsSync(paths.nativeManifest) || !existsSync(paths.nativeLock)) {
+ throw new Error(
+ "crates/openstrike-symbian must contain Cargo.toml and Cargo.lock",
+ );
+ }
+ const rustup = Bun.which("rustup");
+ if (!rustup) {
+ throw new Error(
+ "rustup is required; run the PocketJS Symbian setup before building",
+ );
+ }
+ const rustToolchain = readRustToolchain(paths);
+ mkdirSync(paths.nativeTarget, { recursive: true });
+ const rustEnvironment = {
+ ...process.env,
+ CARGO_TARGET_DIR: paths.nativeTarget,
+ PATH: [dirname(rustup), process.env.PATH].filter(Boolean).join(delimiter),
+ };
+ console.log(
+ `openstrike-symbian: building native core with ${rustToolchain}`,
+ );
+ await run(symbianNativeCommand(paths, rustToolchain, rustup), {
+ cwd: paths.repo,
+ env: rustEnvironment,
+ });
+ if (
+ !existsSync(paths.nativeLibrary) ||
+ statSync(paths.nativeLibrary).size === 0
+ ) {
+ throw new Error(`native build did not produce ${paths.nativeLibrary}`);
+ }
+
+ const sisVersion = options.sisVersion || contract.version;
+ await run(
+ symbianPackageCommand(
+ paths,
+ bun,
+ sisVersion,
+ options.uid,
+ ),
+ { cwd: paths.repo, env: rustEnvironment },
+ );
+
+ const payload = resolve(
+ paths.packageOutput,
+ "build",
+ SYMBIAN_APP_OUTPUT,
+ );
+ const packagedJs = new Uint8Array(
+ readFileSync(resolve(payload, "app.js")),
+ );
+ const packagedPak = new Uint8Array(
+ readFileSync(resolve(payload, "app.pak")),
+ );
+ validateGuestArtifacts(
+ packagedJs,
+ await unpackGuestPak(paths.pocketjs, packagedPak),
+ new Uint8Array(readFileSync(paths.cookedMap)),
+ );
+ const sis = resolve(paths.packageOutput, `${SYMBIAN_APP_OUTPUT}.sis`);
+ if (!existsSync(sis) || statSync(sis).size === 0) {
throw new Error(`Symbian build did not produce ${sis}`);
}
- console.log(`OpenStrike E7 compatibility SIS: ${sis}`);
+ console.log(`OpenStrike E7 full 3D SIS: ${sis}`);
+}
+
+export async function symbianMain(
+ args: readonly string[] = Bun.argv.slice(2),
+): Promise {
+ await withSymbianBuildLock(repo, () => runSymbianMain(args));
+}
+
+if (import.meta.main) {
+ await symbianMain();
}
diff --git a/test/symbian-compat.test.ts b/test/symbian-compat.test.ts
deleted file mode 100644
index 023f105..0000000
--- a/test/symbian-compat.test.ts
+++ /dev/null
@@ -1,179 +0,0 @@
-import { describe, expect, test } from "bun:test";
-import {
- SYMBIAN_COMPAT_MAPS,
- SymbianCompatSimulation,
- type SymbianCompatInput,
-} from "../game/symbian-compat-sim.ts";
-import {
- SYMBIAN_COMPAT_DYNAMIC_VIEWPORT,
- deriveSymbianCompatManifest,
- SYMBIAN_COMPAT_ENTRY,
- SYMBIAN_COMPAT_LIVE_VIEWPORT,
- SYMBIAN_COMPAT_OUTPUT,
-} from "../scripts/symbian-manifest.ts";
-
-const IDLE: SymbianCompatInput = {
- forward: 0,
- turn: 0,
- fire: false,
- reload: false,
-};
-
-const LIVE = { ...IDLE, forward: 1 };
-const FIRE = { ...IDLE, fire: true };
-
-function enterLive(simulation: SymbianCompatSimulation, bots = 3): void {
- simulation.queueConfigureBots({
- count: bots,
- speed: 190,
- attackInterval: 1.4,
- damageMin: 8,
- damageMax: 14,
- });
- simulation.flushCommands();
- simulation.loadMap(0);
- simulation.queueSetPhase("live");
- simulation.flushCommands();
-}
-
-describe("OpenStrike Nokia E7 2D compatibility simulation", () => {
- test("derives the compatibility package from canonical pocket.json", async () => {
- const canonical = await Bun.file(
- new URL("../pocket.json", import.meta.url),
- ).json() as Record;
- const derived = deriveSymbianCompatManifest(canonical) as Record;
-
- expect(derived).not.toBe(canonical);
- expect(derived.version).toBe(canonical.version);
- expect(canonical.app.viewport).not.toHaveProperty("dynamic");
- expect(derived.app.viewport.fixed).toEqual(canonical.app.viewport.fixed);
- expect(derived.app.viewport.dynamic).toEqual(SYMBIAN_COMPAT_DYNAMIC_VIEWPORT);
- expect(canonical.engine.capabilities.enhances).not.toContain(
- SYMBIAN_COMPAT_LIVE_VIEWPORT,
- );
- expect(derived.engine.capabilities.enhances).toEqual([
- ...canonical.engine.capabilities.enhances,
- SYMBIAN_COMPAT_LIVE_VIEWPORT,
- ]);
- expect(derived.engine.capabilities.enhances).not.toContain("input.touch");
- expect(derived).toMatchObject({
- id: `${canonical.id}.e7-compat`,
- name: "open-strike-e7-compat",
- title: "OpenStrike E7 2D Compat",
- app: {
- entry: SYMBIAN_COMPAT_ENTRY,
- output: SYMBIAN_COMPAT_OUTPUT,
- framework: "solid",
- },
- });
- expect(canonical.app.entry).toBe("game/openstrike.tsx");
- });
-
- test("keeps the native strike command ordering and completes a combat round", () => {
- const simulation = new SymbianCompatSimulation();
- expect(simulation.snapshot().phase).toBe("menu");
- expect(SYMBIAN_COMPAT_MAPS).toHaveLength(8);
-
- enterLive(simulation, 1);
- const before = simulation.scene().player;
- simulation.tick(LIVE, 0.1);
- expect(simulation.scene().player.y).toBeLessThan(before.y);
-
- // The first spawn is centered on the player's initial sightline, so this
- // is a deterministic precision hit rather than a random test fixture.
- simulation.tick(FIRE, 0.1);
- expect(simulation.drainEvents()).toEqual([
- {
- type: "hit",
- bot: 0,
- headshot: true,
- damage: 100,
- fatal: true,
- },
- ]);
- expect(simulation.snapshot().aliveBots).toBe(0);
-
- simulation.queueAddWin();
- simulation.queueSetPhase("won");
- // Guest intents do not mutate facts until the host-side flush.
- expect(simulation.snapshot().phase).toBe("live");
- simulation.flushCommands();
- expect(simulation.snapshot()).toMatchObject({ phase: "won", wins: 1 });
-
- simulation.queueResetRound();
- simulation.flushCommands();
- expect(simulation.snapshot()).toMatchObject({
- phase: "starting",
- hp: 100,
- aliveBots: 1,
- wins: 1,
- });
- expect(simulation.drainEvents()).toEqual([{ type: "roundReset" }]);
- });
-
- test("supports magazine depletion and an edge-triggered reload", () => {
- const simulation = new SymbianCompatSimulation();
- simulation.queueConfigureWeapon({
- magSize: 2,
- reserve: 3,
- fireInterval: 0.03,
- reloadTime: 0.2,
- damageBody: 1,
- damageHead: 1,
- });
- simulation.flushCommands();
- enterLive(simulation, 0);
-
- simulation.tick(FIRE, 0.1);
- simulation.tick(FIRE, 0.1);
- expect(simulation.snapshot()).toMatchObject({ ammo: 0, reserve: 3 });
-
- simulation.tick({ ...IDLE, reload: true }, 0.05);
- expect(simulation.snapshot().reloading).toBe(true);
- simulation.tick(IDLE, 0.1);
- simulation.tick(IDLE, 0.1);
- expect(simulation.snapshot()).toMatchObject({
- ammo: 2,
- reserve: 1,
- reloading: false,
- });
- });
-
- test("bots pursue, attack, and emit the same facts consumed by rules and HUD", () => {
- const simulation = new SymbianCompatSimulation();
- enterLive(simulation, 1);
-
- const events = [];
- for (let step = 0; step < 120 && simulation.snapshot().hp === 100; step++) {
- simulation.tick(IDLE, 0.1);
- events.push(...simulation.drainEvents());
- }
- expect(simulation.snapshot().hp).toBeLessThan(100);
- expect(events.some((event) => event.type === "playerDamaged")).toBe(true);
- });
-
- test("replays an input sequence deterministically", () => {
- const left = new SymbianCompatSimulation();
- const right = new SymbianCompatSimulation();
- enterLive(left, 3);
- enterLive(right, 3);
-
- const sequence: SymbianCompatInput[] = [
- LIVE,
- LIVE,
- { ...IDLE, turn: -1 },
- { ...IDLE, turn: -1, fire: true },
- IDLE,
- { ...IDLE, reload: true },
- ];
- for (let cycle = 0; cycle < 20; cycle++) {
- const input = sequence[cycle % sequence.length];
- left.tick(input, 1 / 30);
- right.tick(input, 1 / 30);
- }
-
- expect(right.snapshot()).toEqual(left.snapshot());
- expect(right.scene()).toEqual(left.scene());
- expect(right.drainEvents()).toEqual(left.drainEvents());
- });
-});
diff --git a/test/symbian-tooling.test.ts b/test/symbian-tooling.test.ts
new file mode 100644
index 0000000..ecce979
--- /dev/null
+++ b/test/symbian-tooling.test.ts
@@ -0,0 +1,278 @@
+import { describe, expect, test } from "bun:test";
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import {
+ SYMBIAN_GENERATED_ENTRY,
+ SYMBIAN_MAP_KEY,
+ createSymbianBuildManifest,
+ createSymbianBuildPaths,
+ parseSymbianCli,
+ resolvePocketJsRoot,
+ symbianCookCommand,
+ symbianGeneratedPakManifest,
+ symbianNativeCommand,
+ symbianPackageCommand,
+ symbianVerifyCookedCommand,
+ validateGuestArtifacts,
+ validateNativePocketJsSelection,
+ validateSymbianRuntimePin,
+ validateSymbianSourceContract,
+ withSymbianBuildLock,
+} from "../scripts/symbian.ts";
+import { PAK_DTYPE } from "../vendor/pocketjs/contracts/spec/spec.ts";
+import {
+ pack,
+ unpack,
+} from "../vendor/pocketjs/framework/compiler/pak.ts";
+
+const manifest = await Bun.file(new URL("../pocket.json", import.meta.url)).json();
+const packageJson = await Bun.file(new URL("../package.json", import.meta.url)).json();
+const script = await Bun.file(new URL("../scripts/symbian.ts", import.meta.url)).text();
+const nativeCore = await Bun.file(
+ new URL("../crates/openstrike-symbian/src/lib.rs", import.meta.url),
+).text();
+
+function cookedMap(): Uint8Array {
+ const bytes = new Uint8Array(32);
+ bytes.set(new TextEncoder().encode("P3D1"), 0);
+ const view = new DataView(bytes.buffer);
+ view.setUint32(4, 1, true);
+ view.setUint32(8, 1, true);
+ return bytes;
+}
+
+describe("OpenStrike Symbian source contract", () => {
+ test("uses the canonical full game with one embedded Dust2 map", () => {
+ expect(validateSymbianSourceContract(manifest)).toEqual({
+ version: "0.2.0",
+ });
+ expect(manifest.app.entry).toBe("game/openstrike.tsx");
+ expect(manifest.app.viewport.dynamic).toEqual({
+ default: [640, 360],
+ min: [360, 360],
+ max: [640, 640],
+ });
+ const generated = createSymbianBuildManifest(manifest);
+ expect((generated.app as { entry: string }).entry).toBe(
+ SYMBIAN_GENERATED_ENTRY,
+ );
+ expect(symbianGeneratedPakManifest()).toEqual([
+ {
+ key: SYMBIAN_MAP_KEY,
+ file: "../../../dist/maps/de_dust2.p3d",
+ },
+ ]);
+ expect(nativeCore).toContain(
+ `const MAP_KEY: &str = "${SYMBIAN_MAP_KEY}";`,
+ );
+ expect(script).not.toContain('resolve(repo, "game/pak.json")');
+ });
+
+ test("has no compatibility-game build path", () => {
+ expect(packageJson.scripts["test:symbian"]).toBe(
+ "bun test test/symbian-tooling.test.ts",
+ );
+ expect(packageJson.scripts["test:symbian-compat"]).toBeUndefined();
+ expect(script.toLowerCase()).not.toContain("compat");
+ });
+});
+
+describe("OpenStrike Symbian tooling", () => {
+ const paths = createSymbianBuildPaths(
+ "/repo",
+ "/pocketjs",
+ "/maps",
+ );
+
+ test("prefers explicit PocketJS, then POCKETJS_ROOT, then the pin", () => {
+ expect(
+ resolvePocketJsRoot(
+ "/repo",
+ { pocketjsRoot: "/explicit" },
+ { HOME: "/home", POCKETJS_ROOT: "/environment" },
+ ),
+ ).toBe("/explicit");
+ expect(
+ resolvePocketJsRoot(
+ "/repo",
+ {},
+ { HOME: "/home", POCKETJS_ROOT: "/environment" },
+ ),
+ ).toBe("/environment");
+ expect(
+ resolvePocketJsRoot("/repo", {}, { HOME: "/home" }),
+ ).toBe("/repo/vendor/pocketjs");
+ expect(() =>
+ validateNativePocketJsSelection("/repo", "/newer-pocketjs", false)
+ ).toThrow("full Symbian builds must use pinned vendor/pocketjs");
+ expect(() =>
+ validateNativePocketJsSelection("/repo", "/newer-pocketjs", true)
+ ).not.toThrow();
+ expect(() =>
+ validateNativePocketJsSelection(
+ "/repo",
+ "/repo/vendor/pocketjs",
+ false,
+ )
+ ).not.toThrow();
+ });
+
+ test("parses only the documented build switches", () => {
+ expect(
+ parseSymbianCli([
+ "--guest-only",
+ "--pocketjs-root=/pocketjs",
+ "--sis-version",
+ "0.2.1",
+ "--uid=0xE0000001",
+ ]),
+ ).toEqual({
+ guestOnly: true,
+ help: false,
+ pocketjsRoot: "/pocketjs",
+ sisVersion: "0.2.1",
+ uid: "0xE0000001",
+ });
+ expect(() => parseSymbianCli(["--compat"])).toThrow(
+ "unknown Symbian build argument",
+ );
+ });
+
+ test("serializes generated plans, guests, native cores, and packaging", async () => {
+ const root = mkdtempSync(join(tmpdir(), "openstrike-symbian-lock-"));
+ try {
+ let active = 0;
+ let maximum = 0;
+ const build = () => withSymbianBuildLock(root, async () => {
+ active += 1;
+ maximum = Math.max(maximum, active);
+ await Bun.sleep(20);
+ active -= 1;
+ });
+ await Promise.all([build(), build()]);
+ expect(maximum).toBe(1);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+ });
+
+ test("cooks only de_dust2 with the verified 32-unit recipe", () => {
+ expect(symbianCookCommand(paths)).toEqual([
+ "cargo",
+ "run",
+ "--release",
+ "-q",
+ "-p",
+ "pocket3d-cook",
+ "--",
+ "/maps/maps/de_dust2.bsp",
+ "--wads",
+ "/maps/support",
+ "--subdivide",
+ "32",
+ "-o",
+ "/repo/dist/maps/de_dust2.p3d",
+ "--verify",
+ ]);
+ expect(symbianVerifyCookedCommand(paths)).toEqual([
+ "cargo",
+ "run",
+ "--release",
+ "-q",
+ "-p",
+ "pocket3d-cook",
+ "--",
+ "--verify-cooked",
+ "/repo/dist/maps/de_dust2.p3d",
+ ]);
+ });
+
+ test("pins nightly build-std and passes the exact custom core to packaging", () => {
+ expect(validateSymbianRuntimePin({
+ runtime: {
+ rustToolchain: "nightly-2026-07-02",
+ frameRate: 30,
+ },
+ })).toBe("nightly-2026-07-02");
+ expect(() => validateSymbianRuntimePin({
+ runtime: {
+ rustToolchain: "nightly-2026-07-02",
+ frameRate: 20,
+ },
+ })).toThrow("frameRate to remain 30 Hz");
+ const native = symbianNativeCommand(
+ paths,
+ "nightly-2026-07-02",
+ "/rustup",
+ );
+ expect(native.slice(0, 4)).toEqual([
+ "/rustup",
+ "run",
+ "nightly-2026-07-02",
+ "cargo",
+ ]);
+ expect(native).toContain("--locked");
+ expect(native).toContain(
+ "build-std=core,alloc,compiler_builtins",
+ );
+ expect(native).toContain(
+ "/pocketjs/engine/symbian/targets/armv6-symbian-eabi.json",
+ );
+
+ const packaged = symbianPackageCommand(
+ paths,
+ "/bun",
+ "0.2.0",
+ "0xE0000001",
+ );
+ expect(packaged.slice(0, 4)).toEqual([
+ "/bun",
+ "/pocketjs/tools/symbian.ts",
+ "build",
+ "app",
+ ]);
+ expect(packaged[packaged.indexOf("--manifest") + 1]).toBe(
+ "/repo/.pocket/symbian-e7-dev/pocket.json",
+ );
+ expect(packaged[packaged.indexOf("--core-library") + 1]).toBe(
+ paths.nativeLibrary,
+ );
+ expect(packaged.at(-1)).toBe("0xE0000001");
+ });
+});
+
+describe("OpenStrike Symbian guest validation", () => {
+ test("accepts the real bundle only when its sole map is byte-exact Dust2", () => {
+ const map = cookedMap();
+ const pak = pack([
+ { key: "ui:styles", dtype: PAK_DTYPE.u8, data: new Uint8Array([1]) },
+ { key: SYMBIAN_MAP_KEY, dtype: PAK_DTYPE.u8, data: map },
+ ]);
+ const js = new TextEncoder().encode(
+ `const strike = "openstrike";${" ".repeat(1100)}`,
+ );
+ expect(() => validateGuestArtifacts(js, unpack(pak), map)).not.toThrow();
+ expect(unpack(pak).map((entry) => entry.key)).toEqual([
+ SYMBIAN_MAP_KEY,
+ "ui:styles",
+ ]);
+ });
+
+ test("rejects a renamed or substituted map", () => {
+ const map = cookedMap();
+ const pak = pack([
+ {
+ key: "maps/de_inferno.p3d",
+ dtype: PAK_DTYPE.u8,
+ data: map,
+ },
+ ]);
+ const js = new TextEncoder().encode(
+ `const strike = "openstrike";${" ".repeat(1100)}`,
+ );
+ expect(() => validateGuestArtifacts(js, unpack(pak), map)).toThrow(
+ "must contain only maps/de_dust2.p3d",
+ );
+ });
+});
diff --git a/vendor/pocketjs b/vendor/pocketjs
index 91a0529..59d0666 160000
--- a/vendor/pocketjs
+++ b/vendor/pocketjs
@@ -1 +1 @@
-Subproject commit 91a0529e8fe8a2da7068d0e87b796314835f3748
+Subproject commit 59d06668d9e1e9cac42f890a769268fe45abb5ba
From d927e1133c508a860363d1eb4c623b27baae51dd Mon Sep 17 00:00:00 2001
From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com>
Date: Sun, 26 Jul 2026 08:09:39 +0800
Subject: [PATCH 3/6] feat(symbian): add maps and E7 controls
---
README.md | 46 ++--
crates/openstrike-symbian/Cargo.lock | 2 +-
crates/openstrike-symbian/Cargo.toml | 8 +-
crates/openstrike-symbian/build.rs | 88 ++++++++
crates/openstrike-symbian/src/input.rs | 23 +-
crates/openstrike-symbian/src/lib.rs | 97 +++++++--
crates/openstrike-symbian/src/maps.rs | 150 +++++++++++++
game/hud.tsx | 50 ++++-
game/menu.tsx | 50 ++++-
pocket.json | 2 +-
scripts/symbian.ts | 290 +++++++++++++++++++------
test/symbian-tooling.test.ts | 145 +++++++++++--
vendor/pocketjs | 2 +-
13 files changed, 808 insertions(+), 145 deletions(-)
create mode 100644 crates/openstrike-symbian/build.rs
create mode 100644 crates/openstrike-symbian/src/maps.rs
diff --git a/README.md b/README.md
index c561970..1133cf8 100644
--- a/README.md
+++ b/README.md
@@ -102,10 +102,10 @@ is build-time consistency data, not a runtime trust mechanism.
The E7 build is the real FPS, not the former top-down substitute. Its
application-specific static core source-shares `openstrike-core`, installs the
native `strike` surface before the canonical `game/openstrike.tsx` bundle is
-evaluated, reads the cooked Dust2 BSP directly from the PocketJS PAK, and
-renders it through Pocket3D's OpenGL ES 2 backend. The depth-tested world,
-bots, weapon viewmodel, muzzle effects, collision, round logic, and PocketJS
-JSX HUD all run in the same app.
+evaluated, loads the selected cooked map on demand, and renders it through
+Pocket3D's OpenGL ES 2 backend. The depth-tested world, bots, weapon viewmodel,
+muzzle effects, collision, round logic, and PocketJS JSX HUD all run in the
+same app.
The Qt host owns QuickJS, the GL context, and presentation. The native
extension renders the 3D scene first; PocketJS composites the retained HUD
@@ -119,32 +119,38 @@ E7 keyboard controls:
| Key | Action |
| --- | --- |
| W / A / S / D | move |
-| Arrow keys | look |
-| E | fire |
-| Space | jump |
+| Arrow keys | look during play; select maps and dialog choices in menus |
+| Enter | deploy the selected map or confirm the selected dialog choice |
+| Enter or E | fire (Enter confirms the selected item while a menu is open) |
| R | reload |
+| Space | jump |
| Shift | walk |
| Backspace or Home | open/close the return-to-menu dialog |
-| Arrow keys + Enter (menus) | select and confirm |
-
-The first E7 package intentionally embeds only `de_dust2`; map and WAD data
-remain user-supplied and are never committed. Touch aiming is not implemented
-yet. The script generates a private Symbian entry and `pak.json` under
-`.pocket/symbian-e7-dev`, so PSP and Vita builds never see or duplicate this
-map. To create or refresh the ignored `dist/maps/de_dust2.p3d`,
-`OPENSTRIKE_MAPS` points to a tree with `maps/de_dust2.bsp` and
-`support/*.wad`; otherwise the local default is
-`~/Downloads/cs-maps-20260705-1836`. An already valid cooked P3D is also a
-complete build input when the copyrighted source tree is not present.
+
+The local E7 build produces one SIS containing the runtime and all eight
+locally supplied cooked maps: `cs_assault`, `cs_office`, `de_aztec`,
+`de_dust`, `de_dust2`, `de_inferno`, `de_nuke`, and `de_train`. Maps are
+installed as separate data files and loaded one at a time, so the 32 MiB
+process heap never holds the complete map set or duplicates it through
+QuickJS. Touch aiming is not implemented yet.
+
+Map and WAD data remain user-supplied. They, the generated P3D files, and the
+resulting map-bearing SIS must not be committed to this repository or
+published as a release artifact. `OPENSTRIKE_MAPS` may point to a source tree
+with `maps/*.bsp` and `support/*.wad`; otherwise the local default is
+`~/Downloads/cs-maps-20260705-1836`. The build cooks and verifies the eight
+maps into ignored `dist/maps/*.p3d`. A complete set of already cooked,
+canonically verified P3D files is also a valid local build input when the
+copyrighted BSP/WAD source tree is unavailable.
```sh
# Hermetic manifest/PAK/tooling tests: no Docker, map data, or device needed.
bun run test:symbian
-# Cook only Dust2, build the real JS/PAK, and verify the embedded P3D byte-for-byte.
+# Cook/verify all eight local maps and validate the guest plus map catalogue.
OPENSTRIKE_MAPS=~/path/to/cs-maps bun run test:symbian-bundle
-# Build the app-specific pinned-nightly core and the independently installable SIS.
+# Build the pinned native core and one independently installable, eight-map SIS.
OPENSTRIKE_MAPS=~/path/to/cs-maps bun run build:symbian
```
diff --git a/crates/openstrike-symbian/Cargo.lock b/crates/openstrike-symbian/Cargo.lock
index f1662fe..7255b49 100644
--- a/crates/openstrike-symbian/Cargo.lock
+++ b/crates/openstrike-symbian/Cargo.lock
@@ -34,7 +34,7 @@ dependencies = [
[[package]]
name = "openstrike-symbian"
-version = "0.2.0"
+version = "0.3.0"
dependencies = [
"glam",
"libm",
diff --git a/crates/openstrike-symbian/Cargo.toml b/crates/openstrike-symbian/Cargo.toml
index c5d0534..7c5b048 100644
--- a/crates/openstrike-symbian/Cargo.toml
+++ b/crates/openstrike-symbian/Cargo.toml
@@ -4,13 +4,19 @@
[package]
name = "openstrike-symbian"
-version = "0.2.0"
+version = "0.3.0"
edition = "2021"
license = "MIT"
[lib]
crate-type = ["rlib", "staticlib"]
+[features]
+default = []
+# Embed only the sorted map-name/path catalogue. Cooked map bytes remain
+# app-private files and are loaded into one reusable arena at runtime.
+embedded-map-catalog = []
+
[dependencies]
openstrike-core = { path = "../openstrike-core", default-features = false, features = ["libm"] }
pocket3d-bsp = { path = "../../vendor/pocketjs/engine/pocket3d/crates/pocket3d-bsp", default-features = false, features = ["libm"] }
diff --git a/crates/openstrike-symbian/build.rs b/crates/openstrike-symbian/build.rs
new file mode 100644
index 0000000..95b899e
--- /dev/null
+++ b/crates/openstrike-symbian/build.rs
@@ -0,0 +1,88 @@
+use std::env;
+use std::fmt::Write as _;
+use std::fs;
+use std::path::PathBuf;
+
+const MAP_COUNT: usize = 8;
+
+fn main() {
+ println!("cargo:rerun-if-changed=build.rs");
+ println!("cargo:rerun-if-env-changed=OPENSTRIKE_SYMBIAN_MAPS");
+ println!("cargo:rerun-if-env-changed=OPENSTRIKE_SYMBIAN_DATA_ROOT");
+
+ if env::var_os("CARGO_FEATURE_EMBEDDED_MAP_CATALOG").is_none() {
+ return;
+ }
+
+ let names_source = required_env("OPENSTRIKE_SYMBIAN_MAPS");
+ let names: Vec<&str> = names_source.split(',').collect();
+ if names.len() != MAP_COUNT {
+ panic!(
+ "OPENSTRIKE_SYMBIAN_MAPS must contain exactly {MAP_COUNT} comma-separated map names"
+ );
+ }
+ for name in &names {
+ if !safe_map_name(name) {
+ panic!(
+ "OPENSTRIKE_SYMBIAN_MAPS contains unsafe name {name:?}; \
+ use lowercase ASCII letters, digits, '_' or '-'"
+ );
+ }
+ }
+ if names.windows(2).any(|pair| pair[0] >= pair[1]) {
+ panic!("OPENSTRIKE_SYMBIAN_MAPS must be strictly sorted with no duplicates");
+ }
+
+ let root_source = required_env("OPENSTRIKE_SYMBIAN_DATA_ROOT");
+ let root = root_source.trim_end_matches(['/', '\\']);
+ if root.is_empty() || !root.is_ascii() || root.as_bytes().contains(&0) {
+ panic!("OPENSTRIKE_SYMBIAN_DATA_ROOT must be a non-empty NUL-free ASCII path");
+ }
+
+ let mut generated = String::from(
+ "// @generated by crates/openstrike-symbian/build.rs\n\
+ pub(crate) static MAP_CATALOG: &[MapCatalogEntry] = &[\n",
+ );
+ for name in names {
+ let path = format!("{root}/{name}.p3d\0");
+ writeln!(
+ generated,
+ " MapCatalogEntry {{ name: {name:?}, path: {} }},",
+ rust_byte_string(path.as_bytes())
+ )
+ .expect("writing generated map catalogue");
+ }
+ generated.push_str("];\n");
+
+ let output = PathBuf::from(required_env("OUT_DIR")).join("symbian_map_catalog.rs");
+ fs::write(output, generated).expect("write generated Symbian map catalogue");
+}
+
+fn required_env(name: &str) -> String {
+ env::var(name).unwrap_or_else(|_| panic!("{name} must be set for embedded-map-catalog"))
+}
+
+fn safe_map_name(name: &str) -> bool {
+ !name.is_empty()
+ && name.bytes().all(|byte| {
+ byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_' || byte == b'-'
+ })
+}
+
+fn rust_byte_string(bytes: &[u8]) -> String {
+ let mut escaped = String::from("b\"");
+ for &byte in bytes {
+ match byte {
+ b'\\' => escaped.push_str("\\\\"),
+ b'"' => escaped.push_str("\\\""),
+ b'\n' => escaped.push_str("\\n"),
+ b'\r' => escaped.push_str("\\r"),
+ b'\t' => escaped.push_str("\\t"),
+ 0 => escaped.push_str("\\0"),
+ 0x20..=0x7e => escaped.push(char::from(byte)),
+ _ => write!(escaped, "\\x{byte:02x}").expect("writing byte escape"),
+ }
+ }
+ escaped.push('"');
+ escaped
+}
diff --git a/crates/openstrike-symbian/src/input.rs b/crates/openstrike-symbian/src/input.rs
index 761a476..8aff529 100644
--- a/crates/openstrike-symbian/src/input.rs
+++ b/crates/openstrike-symbian/src/input.rs
@@ -1,6 +1,7 @@
//! E7 hardware keyboard to deterministic OpenStrike input.
use openstrike_core::sim::{SimInput, MOUSE_SENS};
+use pocketjs_core::spec::btn;
use pocketjs_symbian_core::extension::{
KEY_FIRE, KEY_JUMP, KEY_LOOK_DOWN, KEY_LOOK_LEFT, KEY_LOOK_RIGHT, KEY_LOOK_UP, KEY_MOVE_BACK,
KEY_MOVE_FORWARD, KEY_MOVE_LEFT, KEY_MOVE_RIGHT, KEY_RELOAD, KEY_WALK,
@@ -28,7 +29,7 @@ impl KeyboardInput {
}
}
- pub fn map(&mut self, keys: u32, dt: f32) -> TickInput {
+ pub fn map(&mut self, keys: u32, buttons: u32, dt: f32) -> TickInput {
let pressed = keys & !self.previous;
self.previous = keys;
@@ -54,7 +55,7 @@ impl KeyboardInput {
move_y,
walk: keys & KEY_WALK != 0,
jump: keys & KEY_JUMP != 0,
- fire: keys & KEY_FIRE != 0,
+ fire: keys & KEY_FIRE != 0 || buttons & (btn::CIRCLE | btn::RTRIGGER) != 0,
reload: pressed & KEY_RELOAD != 0,
},
look_dx: yaw * LOOK_YAW_RATE * acceleration * dt / MOUSE_SENS,
@@ -71,8 +72,8 @@ mod tests {
fn maps_levels_and_reload_edge() {
let mut input = KeyboardInput::new();
let keys = KEY_MOVE_FORWARD | KEY_MOVE_LEFT | KEY_FIRE | KEY_RELOAD;
- let first = input.map(keys, 1.0 / 60.0);
- let held = input.map(keys, 1.0 / 60.0);
+ let first = input.map(keys, 0, 1.0 / 60.0);
+ let held = input.map(keys, 0, 1.0 / 60.0);
assert_eq!(first.sim.move_x, -1.0);
assert_eq!(first.sim.move_y, 1.0);
assert!(first.sim.fire && held.sim.fire);
@@ -83,11 +84,21 @@ mod tests {
#[test]
fn look_signs_match_apply_look() {
let mut input = KeyboardInput::new();
- let right_down = input.map(KEY_LOOK_RIGHT | KEY_LOOK_DOWN, 1.0 / 60.0);
+ let right_down = input.map(KEY_LOOK_RIGHT | KEY_LOOK_DOWN, 0, 1.0 / 60.0);
assert!(right_down.look_dx > 0.0);
assert!(right_down.look_dy > 0.0);
- let left_up = input.map(KEY_LOOK_LEFT | KEY_LOOK_UP, 1.0 / 60.0);
+ let left_up = input.map(KEY_LOOK_LEFT | KEY_LOOK_UP, 0, 1.0 / 60.0);
assert!(left_up.look_dx < 0.0);
assert!(left_up.look_dy < 0.0);
}
+
+ #[test]
+ fn circle_and_right_trigger_are_sustained_fire_inputs() {
+ let mut input = KeyboardInput::new();
+ for button in [btn::CIRCLE, btn::RTRIGGER, btn::CIRCLE | btn::RTRIGGER] {
+ assert!(input.map(0, button, 1.0 / 60.0).sim.fire);
+ assert!(input.map(0, button, 1.0 / 60.0).sim.fire);
+ assert!(!input.map(0, 0, 1.0 / 60.0).sim.fire);
+ }
+ }
}
diff --git a/crates/openstrike-symbian/src/lib.rs b/crates/openstrike-symbian/src/lib.rs
index 7cab59e..0a1a1d4 100644
--- a/crates/openstrike-symbian/src/lib.rs
+++ b/crates/openstrike-symbian/src/lib.rs
@@ -6,6 +6,8 @@ extern crate self as libquickjs_sys;
extern crate self as pocketjs_psp;
mod input;
+#[cfg(feature = "embedded-map-catalog")]
+mod maps;
mod quickjs;
pub use quickjs::*;
@@ -25,6 +27,8 @@ use core::ffi::c_void;
use glam::{Mat4, Vec3};
use input::KeyboardInput;
+#[cfg(feature = "embedded-map-catalog")]
+use maps::{AlignedMapBuffer, MAP_CATALOG};
use openstrike_core::sim::Command;
use openstrike_core::StrikeSim;
use pocket3d_bsp::cooked::{self, CookedMap};
@@ -69,7 +73,9 @@ pub mod ffi {
}
}
+#[cfg(not(feature = "embedded-map-catalog"))]
const MAP_KEY: &str = "maps/de_dust2.p3d";
+#[cfg(not(feature = "embedded-map-catalog"))]
const MAP_NAME: &str = "de_dust2";
const FIXED_DT: f32 = 1.0 / 60.0;
const TICKS_PER_HOST_FRAME: usize = 2;
@@ -85,8 +91,14 @@ struct Game {
struct State {
context: *mut JSContext,
global: JSValue,
+ #[cfg(not(feature = "embedded-map-catalog"))]
map_bytes: &'static [u8],
+ // Keep Game before map_buffer: Rust drops struct fields in declaration
+ // order, so even an ordinary State drop releases every borrowed map view
+ // before freeing the backing allocation.
game: Option,
+ #[cfg(feature = "embedded-map-catalog")]
+ map_buffer: AlignedMapBuffer,
boot_config: Vec,
pending_host: Option,
input: KeyboardInput,
@@ -137,8 +149,8 @@ unsafe fn drain_commands(state: &mut State) {
strike::drain_host(|command| state.pending_host = Some(command));
}
-unsafe fn dispatch_tick(state: &mut State, native_keys: u32) -> bool {
- let tick = state.input.map(native_keys, FIXED_DT);
+unsafe fn dispatch_tick(state: &mut State, native_keys: u32, buttons: u32) -> bool {
+ let tick = state.input.map(native_keys, buttons, FIXED_DT);
let dispatched = match &mut state.game {
Some(game) => {
game.sim.apply_look(tick.look_dx, tick.look_dy);
@@ -159,15 +171,14 @@ unsafe fn dispatch_tick(state: &mut State, native_keys: u32) -> bool {
unsafe fn apply_pending_host(state: &mut State) -> Result<(), ()> {
match state.pending_host.take() {
- Some(strike::HostCmd::LoadMap(0)) if state.game.is_none() => {
- let mut game = game_from_map(state.map_bytes, &state.boot_config).map_err(|_| ())?;
+ Some(strike::HostCmd::LoadMap(index)) if valid_map_index(index) => {
+ shutdown_game(state)?;
+ let mut game = load_game(state, index)?;
game.world.initialize_gpu().map_err(|_| ())?;
state.game = Some(game);
}
Some(strike::HostCmd::ToMenu) => {
- if let Some(mut game) = state.game.take() {
- game.world.shutdown_gpu().map_err(|_| ())?;
- }
+ shutdown_game(state)?;
state.menu_time = 0.0;
}
_ => {}
@@ -175,6 +186,45 @@ unsafe fn apply_pending_host(state: &mut State) -> Result<(), ()> {
Ok(())
}
+unsafe fn shutdown_game(state: &mut State) -> Result<(), ()> {
+ if let Some(mut game) = state.game.take() {
+ let shutdown = game.world.shutdown_gpu().map_err(|_| ());
+ // Drop every CookedMap/renderer view before load_game is allowed to
+ // resize or overwrite the shared backing allocation.
+ drop(game);
+ shutdown?;
+ }
+ Ok(())
+}
+
+#[cfg(feature = "embedded-map-catalog")]
+fn valid_map_index(index: usize) -> bool {
+ index < MAP_CATALOG.len()
+}
+
+#[cfg(not(feature = "embedded-map-catalog"))]
+fn valid_map_index(index: usize) -> bool {
+ index == 0
+}
+
+#[cfg(feature = "embedded-map-catalog")]
+unsafe fn load_game(state: &mut State, index: usize) -> Result {
+ let entry = MAP_CATALOG.get(index).ok_or(())?;
+ let bytes = state.map_buffer.load(entry).map_err(|_| ())?;
+ // SAFETY: map_buffer belongs to STATE and is released only after Game.
+ // apply_pending_host always drops Game before the next buffer mutation.
+ let bytes: &'static [u8] = core::slice::from_raw_parts(bytes.as_ptr(), bytes.len());
+ game_from_map(bytes, &state.boot_config).map_err(|_| ())
+}
+
+#[cfg(not(feature = "embedded-map-catalog"))]
+unsafe fn load_game(state: &mut State, index: usize) -> Result {
+ if index != 0 {
+ return Err(());
+ }
+ game_from_map(state.map_bytes, &state.boot_config).map_err(|_| ())
+}
+
fn camera_for(state: &State) -> Camera3d {
let mut camera = match &state.game {
Some(game) => Camera3d {
@@ -207,7 +257,7 @@ fn sky_color(camera: &Camera3d) -> [f32; 4] {
unsafe extern "C" fn boot(
context: *mut c_void,
pak: *const u8,
- pak_len: usize,
+ _pak_len: usize,
viewport_width: i32,
viewport_height: i32,
) -> i32 {
@@ -218,26 +268,39 @@ unsafe extern "C" fn boot(
else {
return 0;
};
- let pak = core::slice::from_raw_parts(pak, pak_len);
- let Some(map) = pocketjs_core::pak::find(pak, MAP_KEY) else {
- return 0;
+ #[cfg(not(feature = "embedded-map-catalog"))]
+ let map = {
+ let pak = core::slice::from_raw_parts(pak, _pak_len);
+ let Some(map) = pocketjs_core::pak::find(pak, MAP_KEY) else {
+ return 0;
+ };
+ // The extension ABI guarantees that the host-owned PAK outlives boot
+ // and remains immutable until shutdown.
+ core::slice::from_raw_parts(map.as_ptr(), map.len())
};
- // The extension ABI guarantees that the host-owned PAK outlives boot and
- // remains immutable until shutdown. STATE is destroyed before that point.
- let map: &'static [u8] = core::slice::from_raw_parts(map.as_ptr(), map.len());
let context = context.cast::();
let global = JS_GetGlobalObject(context);
// A cold app boot must never inherit intent queued by an earlier guest.
strike::drain(drop);
strike::drain_host(drop);
- strike::register(context, global, &[String::from(MAP_NAME)]);
+ #[cfg(feature = "embedded-map-catalog")]
+ let map_names: Vec = MAP_CATALOG
+ .iter()
+ .map(|entry| String::from(entry.name))
+ .collect();
+ #[cfg(not(feature = "embedded-map-catalog"))]
+ let map_names = [String::from(MAP_NAME)];
+ strike::register(context, global, &map_names);
STATE = Some(State {
context,
global,
+ #[cfg(not(feature = "embedded-map-catalog"))]
map_bytes: map,
game: None,
+ #[cfg(feature = "embedded-map-catalog")]
+ map_buffer: AlignedMapBuffer::default(),
boot_config: Vec::new(),
pending_host: None,
input: KeyboardInput::new(),
@@ -275,7 +338,7 @@ unsafe extern "C" fn shutdown(gl_context_current: i32) {
unsafe extern "C" fn before_guest(
context: *mut c_void,
- _buttons: u32,
+ buttons: u32,
_analog: u32,
native_keys: u32,
) -> i32 {
@@ -286,7 +349,7 @@ unsafe extern "C" fn before_guest(
return 0;
}
for _ in 0..TICKS_PER_HOST_FRAME {
- if !dispatch_tick(state, native_keys) {
+ if !dispatch_tick(state, native_keys, buttons) {
return 0;
}
}
diff --git a/crates/openstrike-symbian/src/maps.rs b/crates/openstrike-symbian/src/maps.rs
new file mode 100644
index 0000000..921bfd5
--- /dev/null
+++ b/crates/openstrike-symbian/src/maps.rs
@@ -0,0 +1,150 @@
+//! App-private cooked-map loading through Symbian/Open C.
+//!
+//! The generated catalogue embeds names and NUL-terminated paths only. Map
+//! bytes live on mass storage and one allocation is reused after the previous
+//! renderer has released every view into it.
+
+use alloc::vec::Vec;
+use core::ffi::{c_char, c_int, c_long, c_void};
+
+pub(crate) struct MapCatalogEntry {
+ pub(crate) name: &'static str,
+ pub(crate) path: &'static [u8],
+}
+
+include!(concat!(env!("OUT_DIR"), "/symbian_map_catalog.rs"));
+
+#[repr(C)]
+struct CFile {
+ _private: [u8; 0],
+}
+
+unsafe extern "C" {
+ fn fopen(path: *const c_char, mode: *const c_char) -> *mut CFile;
+ fn fseek(file: *mut CFile, offset: c_long, origin: c_int) -> c_int;
+ fn ftell(file: *mut CFile) -> c_long;
+ fn fread(buffer: *mut c_void, size: usize, count: usize, file: *mut CFile) -> usize;
+ fn fclose(file: *mut CFile) -> c_int;
+}
+
+const SEEK_SET: c_int = 0;
+const SEEK_END: c_int = 2;
+const READ_BINARY: &[u8] = b"rb\0";
+
+struct FileHandle(*mut CFile);
+
+impl Drop for FileHandle {
+ fn drop(&mut self) {
+ // SAFETY: FileHandle is constructed only from a successful fopen and
+ // owns that handle until this single Drop call.
+ unsafe {
+ fclose(self.0);
+ }
+ }
+}
+
+/// Reusable storage whose base alignment is part of its type.
+#[derive(Default)]
+pub(crate) struct AlignedMapBuffer {
+ words: Vec,
+ offset: usize,
+ len: usize,
+}
+
+impl AlignedMapBuffer {
+ /// Read one generated catalogue path. The returned view remains valid
+ /// until the next `load` or until this buffer is dropped.
+ pub(crate) unsafe fn load(&mut self, entry: &MapCatalogEntry) -> Result<&[u8], &'static str> {
+ if entry.path.last() != Some(&0) || entry.path[..entry.path.len() - 1].contains(&0) {
+ return Err("invalid map catalogue path");
+ }
+
+ let raw = fopen(
+ entry.path.as_ptr().cast(),
+ READ_BINARY.as_ptr().cast::(),
+ );
+ if raw.is_null() {
+ return Err("map file missing");
+ }
+ let file = FileHandle(raw);
+ if fseek(file.0, 0, SEEK_END) != 0 {
+ return Err("map seek failed");
+ }
+ let length = ftell(file.0);
+ if length <= 0 {
+ return Err("map file is empty");
+ }
+ let length = usize::try_from(length).map_err(|_| "map file is too large")?;
+ // Cooked P3D sections require a 16-byte base. Symbian malloc only
+ // promises 8-byte alignment, so retain Vec ownership but reserve
+ // a full 16 bytes of headroom and align the exposed start manually.
+ let allocation_bytes = length
+ .checked_add(16)
+ .ok_or("map file is too large")?
+ .checked_add(core::mem::size_of::() - 1)
+ .ok_or("map file is too large")?;
+ let words = allocation_bytes / core::mem::size_of::();
+
+ // No world may borrow this allocation when load is called. Resize may
+ // therefore grow/reallocate it safely before the new static view is
+ // created by the caller.
+ self.offset = 0;
+ self.len = 0;
+ self.words.resize(words, 0);
+ let base = self.words.as_mut_ptr().cast::();
+ let offset = base.align_offset(16);
+ if offset == usize::MAX {
+ return Err("map alignment allocation failed");
+ }
+ let aligned = base.add(offset);
+ self.offset = offset;
+ if self.offset + length > self.words.len() * core::mem::size_of::() {
+ return Err("map alignment allocation failed");
+ }
+ if fseek(file.0, 0, SEEK_SET) != 0 {
+ return Err("map rewind failed");
+ }
+ let read = fread(aligned.cast(), 1, length, file.0);
+ if read != length {
+ return Err("map read failed");
+ }
+ self.len = length;
+ Ok(self.as_bytes())
+ }
+
+ fn as_bytes(&self) -> &[u8] {
+ // SAFETY: u8 accepts every initialized u64 bit pattern, and len is
+ // bounded by the resized words allocation after the aligned offset.
+ unsafe {
+ core::slice::from_raw_parts(self.words.as_ptr().cast::().add(self.offset), self.len)
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn generated_catalogue_is_sorted_safe_and_nul_terminated() {
+ assert_eq!(MAP_CATALOG.len(), 8);
+ assert!(MAP_CATALOG
+ .windows(2)
+ .all(|pair| pair[0].name < pair[1].name));
+ for entry in MAP_CATALOG {
+ assert!(!entry.name.is_empty());
+ assert_eq!(entry.path.last(), Some(&0));
+ assert!(!entry.path[..entry.path.len() - 1].contains(&0));
+ }
+ }
+
+ #[test]
+ fn buffer_alignment_is_explicit() {
+ let mut buffer = AlignedMapBuffer::default();
+ buffer.words.resize(3, 0);
+ let base = buffer.words.as_mut_ptr().cast::();
+ buffer.offset = base.align_offset(16);
+ buffer.len = 1;
+ assert_eq!((buffer.as_bytes().as_ptr() as usize) % 16, 0);
+ }
+}
diff --git a/game/hud.tsx b/game/hud.tsx
index 3beab61..b730b39 100644
--- a/game/hud.tsx
+++ b/game/hud.tsx
@@ -23,6 +23,7 @@ import { Text, View } from "@pocketjs/framework/components";
import * as hot from "@pocketjs/framework/hot";
import { pushFocusScope } from "@pocketjs/framework/input";
import { onButtonPress, onFrame } from "@pocketjs/framework/lifecycle";
+import { platform } from "@pocketjs/framework/platform";
import { strike, type StrikeState } from "./sdk.ts";
import { ROUND_FREEZE, ROUND_END_PAUSE, phaseAge } from "./rules.ts";
@@ -50,7 +51,8 @@ const LIME_N = abgr(LIME);
// desktop; scale the chrome so it occupies the same screen fraction.
const vp = (globalThis as { ui?: { __viewport?: { w: number; h: number } } }).ui
?.__viewport ?? { w: 480, h: 272 };
-const S = Math.max(1, Math.round(vp.h / 272));
+const S = Math.max(1, Math.floor(Math.min(vp.w / 480, vp.h / 272)));
+const IS_SYMBIAN_E7 = platform.target === "symbian-e7-dev";
const FEED_ROWS = 3;
const BAR_W = 90;
@@ -465,6 +467,32 @@ export default function Hud() {
+ {/* Static E7 keyboard legend: mounted once, never touched by the hot path. */}
+
+
+
+
+ WASD MOVE · ARROWS LOOK
+
+
+ ENTER/E FIRE · R RELOAD
+
+
+ SPACE JUMP · SHIFT WALK
+
+
+ BACKSPACE/HOME MENU
+
+
+
+
+
{/* Quit dialog (SELECT). CS-style: the world keeps running behind it. */}
void; onQuit: () => void }) {
-
- ↔ SELECT · ○ CONFIRM · SELECT CLOSE
-
+
+ ↔ SELECT · ○ CONFIRM · SELECT CLOSE
+
+ }
+ >
+
+
+ LEFT/RIGHT SELECT · ENTER CONFIRM
+
+
+ BACKSPACE/HOME CLOSE
+
+
+
);
diff --git a/game/menu.tsx b/game/menu.tsx
index 45a4366..262b23e 100644
--- a/game/menu.tsx
+++ b/game/menu.tsx
@@ -6,6 +6,7 @@
import { createSignal, For, Show, onCleanup, onMount } from "solid-js";
import { Text, View } from "@pocketjs/framework/components";
import { pushFocusGrid, pushFocusScope } from "@pocketjs/framework/input";
+import { platform } from "@pocketjs/framework/platform";
import { strike } from "./sdk.ts";
const INK = "#e8f0f2";
@@ -15,7 +16,13 @@ const AMBER = "#fbbf24";
const vp = (globalThis as { ui?: { __viewport?: { w: number; h: number } } }).ui
?.__viewport ?? { w: 480, h: 272 };
-const S = Math.max(1, Math.round(vp.h / 272));
+// Scale only when a complete 480x272 reference frame fits. Height-only
+// scaling made the E7's 360x640 portrait viewport choose 2x and clip the
+// 600px-wide map grid; using both extents keeps every supported E7
+// orientation at the compact scale while preserving exact 1x PSP and 2x
+// Vita proportions when those logical viewports are published.
+const S = Math.max(1, Math.floor(Math.min(vp.w / 480, vp.h / 272)));
+const IS_SYMBIAN_E7 = platform.target === "symbian-e7-dev";
// Selected-row look: a lime-tinted fill + a bright lime border + a small
// slide-in. The default keeps a transparent 1px border so the focused border
@@ -108,15 +115,38 @@ export default function MainMenu() {
- {/* Footer hints */}
-
-
- ↕↔ SELECT
-
-
- ○ DEPLOY
-
-
+ {/* Footer hints: retain console glyphs verbatim; E7 names its real keys. */}
+
+
+ ↕↔ SELECT
+
+
+ ○ DEPLOY
+
+
+ }
+ >
+
+
+ ARROWS SELECT · ENTER DEPLOY
+
+
+ WASD MOVE · ARROWS LOOK
+
+
+ ENTER/E FIRE · R RELOAD · SPACE JUMP
+
+
+ SHIFT WALK · BACKSPACE/HOME MENU
+
+
+
);
diff --git a/pocket.json b/pocket.json
index 4da142e..6c40abf 100644
--- a/pocket.json
+++ b/pocket.json
@@ -4,7 +4,7 @@
"id": "dev.pocket-stack.openstrike",
"name": "open-strike",
"title": "OpenStrike",
- "version": "0.2.0",
+ "version": "0.3.0",
"engine": {
"capabilities": {
"requires": [
diff --git a/scripts/symbian.ts b/scripts/symbian.ts
index c590449..4d03f25 100644
--- a/scripts/symbian.ts
+++ b/scripts/symbian.ts
@@ -1,31 +1,51 @@
// Build the real OpenStrike FPS for Nokia E7:
-// de_dust2 BSP -> Pocket3D .p3d -> canonical PocketJS JS/PAK
+// the eight tested CS BSPs -> Pocket3D .p3d -> canonical PocketJS JS/PAK
// -> app-specific Rust simulation/GLES2 core -> independently installable SIS.
//
+// Map bytes are deliberately not part of app.pak. The Qt host must copy that
+// pack once for QuickJS whenever a native extension borrows it, which would
+// make an all-map pack exceed the E7 heap before the first world is parsed.
+// Instead the same SIS installs maps as app-private mass-memory data and the
+// native core loads one selected map into a reusable buffer.
+//
// The pinned vendor is authoritative for native builds. POCKETJS_ROOT/
// --pocketjs-root is a guest-only escape hatch because the native Cargo graph
// also resolves PocketJS from the pinned submodule.
import { createHash } from "node:crypto";
import {
+ copyFileSync,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
+ rmSync,
statSync,
} from "node:fs";
import { homedir } from "node:os";
import { delimiter, dirname, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { withArtifactLock } from "../vendor/pocketjs/tools/psp-toolchain.ts";
+import {
+ symbianUidForAppId,
+ validateSymbianDevelopmentUid,
+} from "../vendor/pocketjs/tools/symbian-package.ts";
export const SYMBIAN_TARGET = "symbian-e7-dev";
-export const SYMBIAN_MAP_NAME = "de_dust2";
-export const SYMBIAN_MAP_KEY = `maps/${SYMBIAN_MAP_NAME}.p3d`;
+export const SYMBIAN_MAP_NAMES = [
+ "cs_assault",
+ "cs_office",
+ "de_aztec",
+ "de_dust",
+ "de_dust2",
+ "de_inferno",
+ "de_nuke",
+ "de_train",
+] as const;
+export type SymbianMapName = (typeof SYMBIAN_MAP_NAMES)[number];
export const SYMBIAN_APP_OUTPUT = "openstrike";
export const SYMBIAN_GENERATED_ENTRY =
".pocket/symbian-e7-dev/app/openstrike.tsx";
-const SYMBIAN_GENERATED_MAP_FILE = "../../../dist/maps/de_dust2.p3d";
interface ResolvedPlan {
readonly app: {
@@ -49,6 +69,7 @@ interface SymbianToolchainFile {
}
interface PocketManifest {
+ readonly id?: unknown;
readonly version?: unknown;
readonly engine?: unknown;
readonly app?: unknown;
@@ -90,9 +111,9 @@ export interface SymbianBuildPaths {
readonly generatedManifest: string;
readonly generatedEntry: string;
readonly generatedPakManifest: string;
- readonly mapSource: string;
- readonly mapSupport: string;
- readonly cookedMap: string;
+ readonly mapsRoot: string;
+ readonly cookedMaps: string;
+ readonly dataRoot: string;
readonly plan: string;
readonly guestOutput: string;
readonly nativeTarget: string;
@@ -251,9 +272,9 @@ export function createSymbianBuildPaths(
repository,
".pocket/symbian-e7-dev/app/pak.json",
),
- mapSource: resolve(mapsRoot, "maps/de_dust2.bsp"),
- mapSupport: resolve(mapsRoot, "support"),
- cookedMap: resolve(repository, "dist/maps/de_dust2.p3d"),
+ mapsRoot: resolve(mapsRoot),
+ cookedMaps: resolve(repository, "dist/maps"),
+ dataRoot: resolve(repository, ".pocket/symbian-e7-dev/data"),
plan: resolve(repository, ".pocket/symbian-e7-dev/plan.json"),
guestOutput: resolve(repository, "dist/pocket/symbian-e7-dev"),
nativeTarget,
@@ -265,6 +286,65 @@ export function createSymbianBuildPaths(
};
}
+export interface SymbianMapFile {
+ readonly name: SymbianMapName;
+ readonly source: string;
+ readonly support: string;
+ readonly cooked: string;
+ readonly staged: string;
+ readonly packagedPath: string;
+}
+
+function existingDirectory(path: string): boolean {
+ return existsSync(path) && statSync(path).isDirectory();
+}
+
+function hasWadFiles(path: string): boolean {
+ return existingDirectory(path) &&
+ readdirSync(path).some((name) => name.toLowerCase().endsWith(".wad"));
+}
+
+export function symbianMapFile(
+ paths: SymbianBuildPaths,
+ name: SymbianMapName,
+): SymbianMapFile {
+ const nestedSource = resolve(paths.mapsRoot, "maps", `${name}.bsp`);
+ const flatSource = resolve(paths.mapsRoot, `${name}.bsp`);
+ const nestedSupport = resolve(paths.mapsRoot, "support");
+ const source = !existsSync(nestedSource) && existsSync(flatSource)
+ ? flatSource
+ : nestedSource;
+ const support = hasWadFiles(nestedSupport) || !hasWadFiles(paths.mapsRoot)
+ ? nestedSupport
+ : paths.mapsRoot;
+ return {
+ name,
+ source,
+ support,
+ cooked: resolve(paths.cookedMaps, `${name}.p3d`),
+ staged: resolve(paths.dataRoot, "maps", `${name}.p3d`),
+ packagedPath: `maps/${name}.p3d`,
+ };
+}
+
+export function resolveSymbianPackageUid(
+ manifestInput: unknown,
+ override?: string,
+): string {
+ const manifest = record(manifestInput, "pocket.json");
+ if (typeof manifest.id !== "string" || manifest.id.length === 0) {
+ throw new Error("pocket.json id is required for Symbian private map data");
+ }
+ return validateSymbianDevelopmentUid(
+ override ?? symbianUidForAppId(manifest.id),
+ );
+}
+
+export function symbianRuntimeMapRoot(uid: string): string {
+ const normalized = validateSymbianDevelopmentUid(uid).slice(2).toLowerCase();
+ return `E:/private/${normalized}/data/maps`;
+}
+
export function validateSymbianSourceContract(
manifestInput: unknown,
): { readonly version: string } {
@@ -328,10 +408,10 @@ export function createSymbianBuildManifest(
}
export function symbianGeneratedPakManifest(): readonly PakManifestEntry[] {
- return [{
- key: SYMBIAN_MAP_KEY,
- file: SYMBIAN_GENERATED_MAP_FILE,
- }];
+ // The native map catalogue is installed beside the app as private data.
+ // Keeping this manifest empty lets the ordinary compiler still contribute
+ // styles/fonts while preventing a second copy of every P3D in QuickJS.
+ return [];
}
async function materializeSymbianBuildProject(
@@ -362,7 +442,6 @@ function sha256(bytes: Uint8Array): string {
export function validateGuestArtifacts(
js: Uint8Array,
entries: readonly PakBlob[],
- cookedMap: Uint8Array,
): void {
if (
js.byteLength < 1024 ||
@@ -373,20 +452,11 @@ export function validateGuestArtifacts(
const mapEntries = entries.filter((entry) =>
entry.key.startsWith("maps/")
);
- if (
- mapEntries.length !== 1 ||
- mapEntries[0]!.key !== SYMBIAN_MAP_KEY
- ) {
+ if (mapEntries.length !== 0) {
throw new Error(
- "Symbian guest PAK must contain only maps/de_dust2.p3d",
+ "Symbian guest PAK must not contain map bytes; maps are private SIS data",
);
}
- if (
- mapEntries[0]!.data.byteLength !== cookedMap.byteLength ||
- sha256(mapEntries[0]!.data) !== sha256(cookedMap)
- ) {
- throw new Error("Symbian guest PAK map differs from cooked de_dust2");
- }
}
async function unpackGuestPak(
@@ -408,7 +478,9 @@ async function unpackGuestPak(
export function symbianCookCommand(
paths: SymbianBuildPaths,
+ name: SymbianMapName,
): readonly string[] {
+ const map = symbianMapFile(paths, name);
return [
"cargo",
"run",
@@ -417,20 +489,22 @@ export function symbianCookCommand(
"-p",
"pocket3d-cook",
"--",
- paths.mapSource,
+ map.source,
"--wads",
- paths.mapSupport,
+ map.support,
"--subdivide",
"32",
"-o",
- paths.cookedMap,
+ map.cooked,
"--verify",
];
}
export function symbianVerifyCookedCommand(
paths: SymbianBuildPaths,
+ name: SymbianMapName,
): readonly string[] {
+ const map = symbianMapFile(paths, name);
return [
"cargo",
"run",
@@ -440,7 +514,7 @@ export function symbianVerifyCookedCommand(
"pocket3d-cook",
"--",
"--verify-cooked",
- paths.cookedMap,
+ map.cooked,
];
}
@@ -459,6 +533,7 @@ export function symbianNativeCommand(
paths.nativeManifest,
"--release",
"--locked",
+ "--features=embedded-map-catalog",
"--target",
paths.targetSpec,
"-Z",
@@ -491,6 +566,8 @@ export function symbianPackageCommand(
sisVersion,
"--core-library",
paths.nativeLibrary,
+ "--mass-storage-data-root",
+ paths.dataRoot,
];
if (uid) command.push("--uid", uid);
return command;
@@ -526,39 +603,47 @@ function validatePocketJsRoot(
}
}
-function newestMapInput(paths: SymbianBuildPaths): number {
- if (!existsSync(paths.mapSource)) {
+function newestMapInput(
+ paths: SymbianBuildPaths,
+ name: SymbianMapName,
+): number {
+ const map = symbianMapFile(paths, name);
+ if (!existsSync(map.source)) {
throw new Error(
- `missing ${paths.mapSource}; set OPENSTRIKE_MAPS to your GoldSrc map data`,
+ `missing ${map.source}; set OPENSTRIKE_MAPS to your GoldSrc map data`,
);
}
- if (!existsSync(paths.mapSupport) || !statSync(paths.mapSupport).isDirectory()) {
- throw new Error(`missing WAD support directory ${paths.mapSupport}`);
+ if (!existingDirectory(map.support)) {
+ throw new Error(`missing WAD support directory ${map.support}`);
}
- const wads = readdirSync(paths.mapSupport)
+ const wads = readdirSync(map.support)
.filter((name) => name.toLowerCase().endsWith(".wad"))
- .map((name) => resolve(paths.mapSupport, name));
+ .map((wad) => resolve(map.support, wad));
if (wads.length === 0) {
- throw new Error(`no WAD files found under ${paths.mapSupport}`);
+ throw new Error(`no WAD files found under ${map.support}`);
}
return Math.max(
- statSync(paths.mapSource).mtimeMs,
+ statSync(map.source).mtimeMs,
...wads.map((path) => statSync(path).mtimeMs),
);
}
-function mapNeedsCooking(paths: SymbianBuildPaths): boolean {
- if (!existsSync(paths.cookedMap)) {
- newestMapInput(paths);
+function mapNeedsCooking(
+ paths: SymbianBuildPaths,
+ name: SymbianMapName,
+): boolean {
+ const map = symbianMapFile(paths, name);
+ if (!existsSync(map.cooked)) {
+ newestMapInput(paths, name);
return true;
}
// A valid ignored .p3d is already a complete build input. Requiring the
// copyrighted BSP again would make a copied build tree unusable; when the
// source is present, however, its mtime and the WAD mtimes still detect a
// stale cook.
- if (!existsSync(paths.mapSource)) return false;
- const newestInput = newestMapInput(paths);
- return statSync(paths.cookedMap).mtimeMs < newestInput;
+ if (!existsSync(map.source)) return false;
+ const newestInput = newestMapInput(paths, name);
+ return statSync(map.cooked).mtimeMs < newestInput;
}
interface RunOptions {
@@ -608,21 +693,59 @@ async function resolveBuildPlan(
return plan;
}
-async function ensureDust2(paths: SymbianBuildPaths): Promise {
- if (mapNeedsCooking(paths)) {
- mkdirSync(dirname(paths.cookedMap), { recursive: true });
- console.log("openstrike-symbian: cooking de_dust2");
- await run(symbianCookCommand(paths), {
+async function ensureMaps(paths: SymbianBuildPaths): Promise {
+ for (const name of SYMBIAN_MAP_NAMES) {
+ const map = symbianMapFile(paths, name);
+ if (mapNeedsCooking(paths, name)) {
+ mkdirSync(dirname(map.cooked), { recursive: true });
+ console.log(`openstrike-symbian: cooking ${name}`);
+ await run(symbianCookCommand(paths, name), {
+ cwd: paths.pocket3dWorkspace,
+ env: process.env,
+ });
+ }
+ // Always ask the canonical Rust reader to validate reused as well as newly
+ // cooked artifacts. Header-only checks can accept truncated section tables.
+ await run(symbianVerifyCookedCommand(paths, name), {
cwd: paths.pocket3dWorkspace,
env: process.env,
});
}
- // Always ask the canonical Rust reader to validate reused as well as newly
- // cooked artifacts. Header-only checks can accept truncated section tables.
- await run(symbianVerifyCookedCommand(paths), {
- cwd: paths.pocket3dWorkspace,
- env: process.env,
- });
+}
+
+export interface SymbianMapReceiptEntry {
+ readonly path: string;
+ readonly bytes: number;
+ readonly sha256: string;
+}
+
+export function stageSymbianMaps(
+ paths: SymbianBuildPaths,
+): readonly SymbianMapReceiptEntry[] {
+ rmSync(paths.dataRoot, { recursive: true, force: true });
+ const entries: SymbianMapReceiptEntry[] = [];
+ for (const name of SYMBIAN_MAP_NAMES) {
+ const map = symbianMapFile(paths, name);
+ if (!existsSync(map.cooked) || !statSync(map.cooked).isFile()) {
+ throw new Error(`missing verified cooked map ${map.cooked}`);
+ }
+ mkdirSync(dirname(map.staged), { recursive: true });
+ copyFileSync(map.cooked, map.staged);
+ const source = new Uint8Array(readFileSync(map.cooked));
+ const staged = new Uint8Array(readFileSync(map.staged));
+ if (
+ staged.byteLength !== source.byteLength ||
+ sha256(staged) !== sha256(source)
+ ) {
+ throw new Error(`staged map differs from ${map.cooked}`);
+ }
+ entries.push({
+ path: map.packagedPath,
+ bytes: source.byteLength,
+ sha256: sha256(source),
+ });
+ }
+ return entries;
}
async function buildGuest(
@@ -649,7 +772,6 @@ async function buildGuest(
validateGuestArtifacts(
js,
await unpackGuestPak(paths.pocketjs, pak),
- new Uint8Array(readFileSync(paths.cookedMap)),
);
}
@@ -679,16 +801,47 @@ function readRustToolchain(paths: SymbianBuildPaths): string {
const HELP = `OpenStrike Nokia E7 3D build
bun scripts/symbian.ts --guest-only
- cook only de_dust2, compile the real JS/PAK, and validate the embedded map
+ verify/cook all eight maps, compile the real JS/PAK, and stage private data
- bun scripts/symbian.ts [--sis-version 0.2.0] [--uid 0xE.......]
- additionally build the pinned Rust GLES2 core and package openstrike.sis
+ bun scripts/symbian.ts [--sis-version 0.3.0] [--uid 0xE.......]
+ additionally build the pinned Rust GLES2 core and package one all-map SIS
- OPENSTRIKE_MAPS defaults to ~/Downloads/cs-maps-20260705-1836.
+ OPENSTRIKE_MAPS accepts /maps + /support or a flat BSP/WAD root,
+ and defaults to ~/Downloads/cs-maps-20260705-1836. Existing verified
+ dist/maps/*.p3d files remain valid local inputs when the originals are absent.
POCKETJS_ROOT or --pocketjs-root selects an explicit checkout only with
--guest-only. Native builds always use pinned vendor/pocketjs.
`;
+export function validateSymbianDataReceipt(
+ input: unknown,
+ expected: readonly SymbianMapReceiptEntry[],
+): void {
+ const receipt = record(input, "Symbian receipt");
+ if (!Array.isArray(receipt.data)) {
+ throw new Error("Symbian receipt is missing its external data manifest");
+ }
+ const actual = receipt.data.map((value, index) => {
+ const entry = record(value, `Symbian receipt data[${index}]`);
+ if (
+ typeof entry.path !== "string" ||
+ typeof entry.bytes !== "number" ||
+ !Number.isSafeInteger(entry.bytes) ||
+ typeof entry.sha256 !== "string"
+ ) {
+ throw new Error(`Symbian receipt data[${index}] is invalid`);
+ }
+ return {
+ path: entry.path,
+ bytes: entry.bytes,
+ sha256: entry.sha256,
+ };
+ });
+ if (JSON.stringify(actual) !== JSON.stringify(expected)) {
+ throw new Error("Symbian receipt external map data differs from staged maps");
+ }
+}
+
async function runSymbianMain(
args: readonly string[] = Bun.argv.slice(2),
): Promise {
@@ -713,7 +866,8 @@ async function runSymbianMain(
readFileSync(resolve(repo, "pocket.json"), "utf8"),
) as PocketManifest;
const contract = validateSymbianSourceContract(manifest);
- await ensureDust2(paths);
+ await ensureMaps(paths);
+ const stagedMaps = stageSymbianMaps(paths);
const buildManifest = await materializeSymbianBuildProject(paths, manifest);
await resolveBuildPlan(paths, buildManifest);
@@ -738,10 +892,13 @@ async function runSymbianMain(
);
}
const rustToolchain = readRustToolchain(paths);
+ const packageUid = resolveSymbianPackageUid(manifest, options.uid);
mkdirSync(paths.nativeTarget, { recursive: true });
const rustEnvironment = {
...process.env,
CARGO_TARGET_DIR: paths.nativeTarget,
+ OPENSTRIKE_SYMBIAN_MAPS: SYMBIAN_MAP_NAMES.join(","),
+ OPENSTRIKE_SYMBIAN_DATA_ROOT: symbianRuntimeMapRoot(packageUid),
PATH: [dirname(rustup), process.env.PATH].filter(Boolean).join(delimiter),
};
console.log(
@@ -783,7 +940,14 @@ async function runSymbianMain(
validateGuestArtifacts(
packagedJs,
await unpackGuestPak(paths.pocketjs, packagedPak),
- new Uint8Array(readFileSync(paths.cookedMap)),
+ );
+ const receipt = resolve(
+ paths.packageOutput,
+ `${SYMBIAN_APP_OUTPUT}.receipt.json`,
+ );
+ validateSymbianDataReceipt(
+ JSON.parse(readFileSync(receipt, "utf8")),
+ stagedMaps,
);
const sis = resolve(paths.packageOutput, `${SYMBIAN_APP_OUTPUT}.sis`);
if (!existsSync(sis) || statSync(sis).size === 0) {
diff --git a/test/symbian-tooling.test.ts b/test/symbian-tooling.test.ts
index ecce979..552979d 100644
--- a/test/symbian-tooling.test.ts
+++ b/test/symbian-tooling.test.ts
@@ -1,19 +1,30 @@
import { describe, expect, test } from "bun:test";
-import { mkdtempSync, rmSync } from "node:fs";
+import {
+ existsSync,
+ mkdirSync,
+ mkdtempSync,
+ rmSync,
+ writeFileSync,
+} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
SYMBIAN_GENERATED_ENTRY,
- SYMBIAN_MAP_KEY,
+ SYMBIAN_MAP_NAMES,
createSymbianBuildManifest,
createSymbianBuildPaths,
parseSymbianCli,
resolvePocketJsRoot,
+ resolveSymbianPackageUid,
+ stageSymbianMaps,
symbianCookCommand,
symbianGeneratedPakManifest,
+ symbianMapFile,
symbianNativeCommand,
symbianPackageCommand,
+ symbianRuntimeMapRoot,
symbianVerifyCookedCommand,
+ validateSymbianDataReceipt,
validateGuestArtifacts,
validateNativePocketJsSelection,
validateSymbianRuntimePin,
@@ -32,6 +43,14 @@ const script = await Bun.file(new URL("../scripts/symbian.ts", import.meta.url))
const nativeCore = await Bun.file(
new URL("../crates/openstrike-symbian/src/lib.rs", import.meta.url),
).text();
+const nativeInput = await Bun.file(
+ new URL("../crates/openstrike-symbian/src/input.rs", import.meta.url),
+).text();
+const nativeBuild = await Bun.file(
+ new URL("../crates/openstrike-symbian/build.rs", import.meta.url),
+).text();
+const menu = await Bun.file(new URL("../game/menu.tsx", import.meta.url)).text();
+const hud = await Bun.file(new URL("../game/hud.tsx", import.meta.url)).text();
function cookedMap(): Uint8Array {
const bytes = new Uint8Array(32);
@@ -43,9 +62,9 @@ function cookedMap(): Uint8Array {
}
describe("OpenStrike Symbian source contract", () => {
- test("uses the canonical full game with one embedded Dust2 map", () => {
+ test("uses the canonical full game with an external eight-map catalogue", () => {
expect(validateSymbianSourceContract(manifest)).toEqual({
- version: "0.2.0",
+ version: "0.3.0",
});
expect(manifest.app.entry).toBe("game/openstrike.tsx");
expect(manifest.app.viewport.dynamic).toEqual({
@@ -57,15 +76,26 @@ describe("OpenStrike Symbian source contract", () => {
expect((generated.app as { entry: string }).entry).toBe(
SYMBIAN_GENERATED_ENTRY,
);
- expect(symbianGeneratedPakManifest()).toEqual([
- {
- key: SYMBIAN_MAP_KEY,
- file: "../../../dist/maps/de_dust2.p3d",
- },
+ expect(SYMBIAN_MAP_NAMES).toEqual([
+ "cs_assault",
+ "cs_office",
+ "de_aztec",
+ "de_dust",
+ "de_dust2",
+ "de_inferno",
+ "de_nuke",
+ "de_train",
]);
+ expect(symbianGeneratedPakManifest()).toEqual([]);
expect(nativeCore).toContain(
- `const MAP_KEY: &str = "${SYMBIAN_MAP_KEY}";`,
+ '#[cfg(not(feature = "embedded-map-catalog"))]\nconst MAP_KEY: &str = "maps/de_dust2.p3d";',
);
+ expect(nativeCore).toContain("map_buffer: AlignedMapBuffer");
+ expect(nativeBuild).toContain("OPENSTRIKE_SYMBIAN_MAPS");
+ expect(nativeBuild).toContain("OPENSTRIKE_SYMBIAN_DATA_ROOT");
+ expect(nativeInput).toContain("btn::CIRCLE");
+ expect(menu).toContain("WASD");
+ expect(hud).toContain("FIRE");
expect(script).not.toContain('resolve(repo, "game/pak.json")');
});
@@ -157,8 +187,16 @@ describe("OpenStrike Symbian tooling", () => {
}
});
- test("cooks only de_dust2 with the verified 32-unit recipe", () => {
- expect(symbianCookCommand(paths)).toEqual([
+ test("cooks every catalogue member with the verified 32-unit recipe", () => {
+ expect(symbianMapFile(paths, "de_inferno")).toEqual({
+ name: "de_inferno",
+ source: "/maps/maps/de_inferno.bsp",
+ support: "/maps/support",
+ cooked: "/repo/dist/maps/de_inferno.p3d",
+ staged: "/repo/.pocket/symbian-e7-dev/data/maps/de_inferno.p3d",
+ packagedPath: "maps/de_inferno.p3d",
+ });
+ expect(symbianCookCommand(paths, "de_dust2")).toEqual([
"cargo",
"run",
"--release",
@@ -175,7 +213,7 @@ describe("OpenStrike Symbian tooling", () => {
"/repo/dist/maps/de_dust2.p3d",
"--verify",
]);
- expect(symbianVerifyCookedCommand(paths)).toEqual([
+ expect(symbianVerifyCookedCommand(paths, "de_dust2")).toEqual([
"cargo",
"run",
"--release",
@@ -186,6 +224,18 @@ describe("OpenStrike Symbian tooling", () => {
"--verify-cooked",
"/repo/dist/maps/de_dust2.p3d",
]);
+ expect(
+ SYMBIAN_MAP_NAMES.map((name) => symbianMapFile(paths, name).packagedPath),
+ ).toEqual([
+ "maps/cs_assault.p3d",
+ "maps/cs_office.p3d",
+ "maps/de_aztec.p3d",
+ "maps/de_dust.p3d",
+ "maps/de_dust2.p3d",
+ "maps/de_inferno.p3d",
+ "maps/de_nuke.p3d",
+ "maps/de_train.p3d",
+ ]);
});
test("pins nightly build-std and passes the exact custom core to packaging", () => {
@@ -213,6 +263,7 @@ describe("OpenStrike Symbian tooling", () => {
"cargo",
]);
expect(native).toContain("--locked");
+ expect(native).toContain("--features=embedded-map-catalog");
expect(native).toContain(
"build-std=core,alloc,compiler_builtins",
);
@@ -238,28 +289,80 @@ describe("OpenStrike Symbian tooling", () => {
expect(packaged[packaged.indexOf("--core-library") + 1]).toBe(
paths.nativeLibrary,
);
+ expect(
+ packaged[packaged.indexOf("--mass-storage-data-root") + 1],
+ ).toBe(paths.dataRoot);
expect(packaged.at(-1)).toBe("0xE0000001");
});
+
+ test("derives the private mass-memory map root from the package UID", () => {
+ expect(resolveSymbianPackageUid(manifest)).toBe("0xE86B9226");
+ expect(resolveSymbianPackageUid(manifest, "0xE0000001")).toBe(
+ "0xE0000001",
+ );
+ expect(symbianRuntimeMapRoot("0xE86B9226")).toBe(
+ "E:/private/e86b9226/data/maps",
+ );
+ });
+
+ test("stages an exact, stale-free eight-map data root and pins its receipt", () => {
+ const root = mkdtempSync(join(tmpdir(), "openstrike-symbian-maps-"));
+ try {
+ const local = createSymbianBuildPaths(
+ root,
+ join(root, "pocketjs"),
+ join(root, "source"),
+ );
+ mkdirSync(local.cookedMaps, { recursive: true });
+ mkdirSync(join(local.dataRoot, "maps"), { recursive: true });
+ writeFileSync(
+ join(local.dataRoot, "maps/stale.p3d"),
+ new Uint8Array([9, 9, 9]),
+ );
+ for (let index = 0; index < SYMBIAN_MAP_NAMES.length; index++) {
+ writeFileSync(
+ join(local.cookedMaps, `${SYMBIAN_MAP_NAMES[index]}.p3d`),
+ new Uint8Array([index, index + 1, index + 2]),
+ );
+ }
+
+ const receipt = stageSymbianMaps(local);
+ expect(receipt.map((entry) => entry.path)).toEqual(
+ SYMBIAN_MAP_NAMES.map((name) => `maps/${name}.p3d`),
+ );
+ expect(receipt.every((entry) => entry.bytes === 3)).toBe(true);
+ expect(existsSync(join(local.dataRoot, "maps/stale.p3d"))).toBe(false);
+ expect(() =>
+ validateSymbianDataReceipt({ data: receipt }, receipt)
+ ).not.toThrow();
+ expect(() =>
+ validateSymbianDataReceipt({
+ data: receipt.map((entry, index) =>
+ index === 0 ? { ...entry, bytes: 4 } : entry
+ ),
+ }, receipt)
+ ).toThrow("differs from staged maps");
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+ });
});
describe("OpenStrike Symbian guest validation", () => {
- test("accepts the real bundle only when its sole map is byte-exact Dust2", () => {
- const map = cookedMap();
+ test("accepts the real bundle only when map bytes stay out of app.pak", () => {
const pak = pack([
{ key: "ui:styles", dtype: PAK_DTYPE.u8, data: new Uint8Array([1]) },
- { key: SYMBIAN_MAP_KEY, dtype: PAK_DTYPE.u8, data: map },
]);
const js = new TextEncoder().encode(
`const strike = "openstrike";${" ".repeat(1100)}`,
);
- expect(() => validateGuestArtifacts(js, unpack(pak), map)).not.toThrow();
+ expect(() => validateGuestArtifacts(js, unpack(pak))).not.toThrow();
expect(unpack(pak).map((entry) => entry.key)).toEqual([
- SYMBIAN_MAP_KEY,
"ui:styles",
]);
});
- test("rejects a renamed or substituted map", () => {
+ test("rejects any accidentally re-embedded map", () => {
const map = cookedMap();
const pak = pack([
{
@@ -271,8 +374,8 @@ describe("OpenStrike Symbian guest validation", () => {
const js = new TextEncoder().encode(
`const strike = "openstrike";${" ".repeat(1100)}`,
);
- expect(() => validateGuestArtifacts(js, unpack(pak), map)).toThrow(
- "must contain only maps/de_dust2.p3d",
+ expect(() => validateGuestArtifacts(js, unpack(pak))).toThrow(
+ "must not contain map bytes",
);
});
});
diff --git a/vendor/pocketjs b/vendor/pocketjs
index 59d0666..f9a721f 160000
--- a/vendor/pocketjs
+++ b/vendor/pocketjs
@@ -1 +1 @@
-Subproject commit 59d06668d9e1e9cac42f890a769268fe45abb5ba
+Subproject commit f9a721f4d5e5242f4ffbfbf94953f4f4ee2fb1aa
From 32d47e0dd7bb413c75787ab18731030e2198bb23 Mon Sep 17 00:00:00 2001
From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com>
Date: Sun, 26 Jul 2026 08:38:21 +0800
Subject: [PATCH 4/6] fix(symbian): normalize E7 letter controls
---
crates/openstrike-symbian/Cargo.lock | 2 +-
crates/openstrike-symbian/Cargo.toml | 2 +-
pocket.json | 2 +-
scripts/symbian.ts | 2 +-
test/symbian-tooling.test.ts | 2 +-
vendor/pocketjs | 2 +-
6 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/crates/openstrike-symbian/Cargo.lock b/crates/openstrike-symbian/Cargo.lock
index 7255b49..c07d19e 100644
--- a/crates/openstrike-symbian/Cargo.lock
+++ b/crates/openstrike-symbian/Cargo.lock
@@ -34,7 +34,7 @@ dependencies = [
[[package]]
name = "openstrike-symbian"
-version = "0.3.0"
+version = "0.3.1"
dependencies = [
"glam",
"libm",
diff --git a/crates/openstrike-symbian/Cargo.toml b/crates/openstrike-symbian/Cargo.toml
index 7c5b048..0de89e5 100644
--- a/crates/openstrike-symbian/Cargo.toml
+++ b/crates/openstrike-symbian/Cargo.toml
@@ -4,7 +4,7 @@
[package]
name = "openstrike-symbian"
-version = "0.3.0"
+version = "0.3.1"
edition = "2021"
license = "MIT"
diff --git a/pocket.json b/pocket.json
index 6c40abf..32ec7b5 100644
--- a/pocket.json
+++ b/pocket.json
@@ -4,7 +4,7 @@
"id": "dev.pocket-stack.openstrike",
"name": "open-strike",
"title": "OpenStrike",
- "version": "0.3.0",
+ "version": "0.3.1",
"engine": {
"capabilities": {
"requires": [
diff --git a/scripts/symbian.ts b/scripts/symbian.ts
index 4d03f25..626c3e2 100644
--- a/scripts/symbian.ts
+++ b/scripts/symbian.ts
@@ -803,7 +803,7 @@ const HELP = `OpenStrike Nokia E7 3D build
bun scripts/symbian.ts --guest-only
verify/cook all eight maps, compile the real JS/PAK, and stage private data
- bun scripts/symbian.ts [--sis-version 0.3.0] [--uid 0xE.......]
+ bun scripts/symbian.ts [--sis-version 0.3.1] [--uid 0xE.......]
additionally build the pinned Rust GLES2 core and package one all-map SIS
OPENSTRIKE_MAPS accepts /maps + /support or a flat BSP/WAD root,
diff --git a/test/symbian-tooling.test.ts b/test/symbian-tooling.test.ts
index 552979d..a9dd485 100644
--- a/test/symbian-tooling.test.ts
+++ b/test/symbian-tooling.test.ts
@@ -64,7 +64,7 @@ function cookedMap(): Uint8Array {
describe("OpenStrike Symbian source contract", () => {
test("uses the canonical full game with an external eight-map catalogue", () => {
expect(validateSymbianSourceContract(manifest)).toEqual({
- version: "0.3.0",
+ version: "0.3.1",
});
expect(manifest.app.entry).toBe("game/openstrike.tsx");
expect(manifest.app.viewport.dynamic).toEqual({
diff --git a/vendor/pocketjs b/vendor/pocketjs
index f9a721f..e8a8e80 160000
--- a/vendor/pocketjs
+++ b/vendor/pocketjs
@@ -1 +1 @@
-Subproject commit f9a721f4d5e5242f4ffbfbf94953f4f4ee2fb1aa
+Subproject commit e8a8e807dde82de9b4f99cc41898338c2e3e88c1
From 48d7373f5bd97f6ce273fb9edcd0cdf5bbca8c71 Mon Sep 17 00:00:00 2001
From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com>
Date: Sun, 26 Jul 2026 09:25:56 +0800
Subject: [PATCH 5/6] fix(symbian): restore E7 movement and reload
---
crates/openstrike-core/src/sim.rs | 3 ++-
crates/openstrike-core/src/weapon.rs | 20 ++++++++++++++++++++
crates/openstrike-symbian/Cargo.lock | 2 +-
crates/openstrike-symbian/Cargo.toml | 2 +-
crates/openstrike-symbian/src/input.rs | 15 ++++++---------
pocket.json | 2 +-
scripts/symbian.ts | 2 +-
test/symbian-tooling.test.ts | 2 +-
vendor/pocketjs | 2 +-
9 files changed, 34 insertions(+), 16 deletions(-)
diff --git a/crates/openstrike-core/src/sim.rs b/crates/openstrike-core/src/sim.rs
index de35343..1a2b2a9 100644
--- a/crates/openstrike-core/src/sim.rs
+++ b/crates/openstrike-core/src/sim.rs
@@ -123,7 +123,8 @@ pub struct SimInput {
pub jump: bool,
/// Trigger held.
pub fire: bool,
- /// Reload pressed this tick.
+ /// Reload requested this tick; platforms may map either an edge or a held
+ /// level because the weapon gate makes repeated requests idempotent.
pub reload: bool,
}
diff --git a/crates/openstrike-core/src/weapon.rs b/crates/openstrike-core/src/weapon.rs
index c1cdb9b..217f498 100644
--- a/crates/openstrike-core/src/weapon.rs
+++ b/crates/openstrike-core/src/weapon.rs
@@ -277,3 +277,23 @@ impl Rng {
lo + self.f32() * (hi - lo)
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::Weapon;
+
+ #[test]
+ fn held_reload_request_refills_once_without_spending_extra_reserve() {
+ let mut weapon = Weapon::default();
+ weapon.ammo = 23;
+
+ for _ in 0..360 {
+ weapon.tick(1.0 / 60.0);
+ weapon.trigger_reload();
+ }
+
+ assert_eq!(weapon.ammo, 30);
+ assert_eq!(weapon.reserve, 83);
+ assert!(!weapon.reloading());
+ }
+}
diff --git a/crates/openstrike-symbian/Cargo.lock b/crates/openstrike-symbian/Cargo.lock
index c07d19e..bdd3b0d 100644
--- a/crates/openstrike-symbian/Cargo.lock
+++ b/crates/openstrike-symbian/Cargo.lock
@@ -34,7 +34,7 @@ dependencies = [
[[package]]
name = "openstrike-symbian"
-version = "0.3.1"
+version = "0.3.2"
dependencies = [
"glam",
"libm",
diff --git a/crates/openstrike-symbian/Cargo.toml b/crates/openstrike-symbian/Cargo.toml
index 0de89e5..5e88a8a 100644
--- a/crates/openstrike-symbian/Cargo.toml
+++ b/crates/openstrike-symbian/Cargo.toml
@@ -4,7 +4,7 @@
[package]
name = "openstrike-symbian"
-version = "0.3.1"
+version = "0.3.2"
edition = "2021"
license = "MIT"
diff --git a/crates/openstrike-symbian/src/input.rs b/crates/openstrike-symbian/src/input.rs
index 8aff529..ebfd982 100644
--- a/crates/openstrike-symbian/src/input.rs
+++ b/crates/openstrike-symbian/src/input.rs
@@ -11,7 +11,6 @@ const LOOK_YAW_RATE: f32 = 2.6;
const LOOK_PITCH_RATE: f32 = 1.7;
pub struct KeyboardInput {
- previous: u32,
look_hold: f32,
}
@@ -24,15 +23,11 @@ pub struct TickInput {
impl KeyboardInput {
pub const fn new() -> Self {
Self {
- previous: 0,
look_hold: 0.0,
}
}
pub fn map(&mut self, keys: u32, buttons: u32, dt: f32) -> TickInput {
- let pressed = keys & !self.previous;
- self.previous = keys;
-
let move_x = f32::from((keys & KEY_MOVE_RIGHT != 0) as u8)
- f32::from((keys & KEY_MOVE_LEFT != 0) as u8);
let move_y = f32::from((keys & KEY_MOVE_FORWARD != 0) as u8)
@@ -56,7 +51,10 @@ impl KeyboardInput {
walk: keys & KEY_WALK != 0,
jump: keys & KEY_JUMP != 0,
fire: keys & KEY_FIRE != 0 || buttons & (btn::CIRCLE | btn::RTRIGGER) != 0,
- reload: pressed & KEY_RELOAD != 0,
+ // Keep the request alive across both 60 Hz ticks in a 30 Hz
+ // host frame. Weapon::trigger_reload is idempotent while a
+ // reload is active and rejects full/empty-reserve magazines.
+ reload: keys & KEY_RELOAD != 0,
},
look_dx: yaw * LOOK_YAW_RATE * acceleration * dt / MOUSE_SENS,
look_dy: pitch * LOOK_PITCH_RATE * acceleration * dt / MOUSE_SENS,
@@ -69,7 +67,7 @@ mod tests {
use super::*;
#[test]
- fn maps_levels_and_reload_edge() {
+ fn maps_movement_fire_and_reload_levels() {
let mut input = KeyboardInput::new();
let keys = KEY_MOVE_FORWARD | KEY_MOVE_LEFT | KEY_FIRE | KEY_RELOAD;
let first = input.map(keys, 0, 1.0 / 60.0);
@@ -77,8 +75,7 @@ mod tests {
assert_eq!(first.sim.move_x, -1.0);
assert_eq!(first.sim.move_y, 1.0);
assert!(first.sim.fire && held.sim.fire);
- assert!(first.sim.reload);
- assert!(!held.sim.reload);
+ assert!(first.sim.reload && held.sim.reload);
}
#[test]
diff --git a/pocket.json b/pocket.json
index 32ec7b5..5f004b0 100644
--- a/pocket.json
+++ b/pocket.json
@@ -4,7 +4,7 @@
"id": "dev.pocket-stack.openstrike",
"name": "open-strike",
"title": "OpenStrike",
- "version": "0.3.1",
+ "version": "0.3.2",
"engine": {
"capabilities": {
"requires": [
diff --git a/scripts/symbian.ts b/scripts/symbian.ts
index 626c3e2..28b5db0 100644
--- a/scripts/symbian.ts
+++ b/scripts/symbian.ts
@@ -803,7 +803,7 @@ const HELP = `OpenStrike Nokia E7 3D build
bun scripts/symbian.ts --guest-only
verify/cook all eight maps, compile the real JS/PAK, and stage private data
- bun scripts/symbian.ts [--sis-version 0.3.1] [--uid 0xE.......]
+ bun scripts/symbian.ts [--sis-version 0.3.2] [--uid 0xE.......]
additionally build the pinned Rust GLES2 core and package one all-map SIS
OPENSTRIKE_MAPS accepts /maps + /support or a flat BSP/WAD root,
diff --git a/test/symbian-tooling.test.ts b/test/symbian-tooling.test.ts
index a9dd485..eb448e7 100644
--- a/test/symbian-tooling.test.ts
+++ b/test/symbian-tooling.test.ts
@@ -64,7 +64,7 @@ function cookedMap(): Uint8Array {
describe("OpenStrike Symbian source contract", () => {
test("uses the canonical full game with an external eight-map catalogue", () => {
expect(validateSymbianSourceContract(manifest)).toEqual({
- version: "0.3.1",
+ version: "0.3.2",
});
expect(manifest.app.entry).toBe("game/openstrike.tsx");
expect(manifest.app.viewport.dynamic).toEqual({
diff --git a/vendor/pocketjs b/vendor/pocketjs
index e8a8e80..f8b4b7a 160000
--- a/vendor/pocketjs
+++ b/vendor/pocketjs
@@ -1 +1 @@
-Subproject commit e8a8e807dde82de9b4f99cc41898338c2e3e88c1
+Subproject commit f8b4b7a8ef2b6d519c9b71b2c240c2ce7bfc44dd
From cfd1f01f08ab48b7732c14c7de10fb336c7beae1 Mon Sep 17 00:00:00 2001
From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com>
Date: Sun, 26 Jul 2026 15:53:12 +0800
Subject: [PATCH 6/6] fix(symbian): ship E7 top-row controls
---
crates/openstrike-symbian/Cargo.lock | 2 +-
crates/openstrike-symbian/Cargo.toml | 2 +-
pocket.json | 2 +-
scripts/symbian.ts | 2 +-
test/symbian-tooling.test.ts | 2 +-
vendor/pocketjs | 2 +-
6 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/crates/openstrike-symbian/Cargo.lock b/crates/openstrike-symbian/Cargo.lock
index bdd3b0d..ebdc1d7 100644
--- a/crates/openstrike-symbian/Cargo.lock
+++ b/crates/openstrike-symbian/Cargo.lock
@@ -34,7 +34,7 @@ dependencies = [
[[package]]
name = "openstrike-symbian"
-version = "0.3.2"
+version = "0.3.4"
dependencies = [
"glam",
"libm",
diff --git a/crates/openstrike-symbian/Cargo.toml b/crates/openstrike-symbian/Cargo.toml
index 5e88a8a..ff66a40 100644
--- a/crates/openstrike-symbian/Cargo.toml
+++ b/crates/openstrike-symbian/Cargo.toml
@@ -4,7 +4,7 @@
[package]
name = "openstrike-symbian"
-version = "0.3.2"
+version = "0.3.4"
edition = "2021"
license = "MIT"
diff --git a/pocket.json b/pocket.json
index 5f004b0..dd7205d 100644
--- a/pocket.json
+++ b/pocket.json
@@ -4,7 +4,7 @@
"id": "dev.pocket-stack.openstrike",
"name": "open-strike",
"title": "OpenStrike",
- "version": "0.3.2",
+ "version": "0.3.4",
"engine": {
"capabilities": {
"requires": [
diff --git a/scripts/symbian.ts b/scripts/symbian.ts
index 28b5db0..064b4c7 100644
--- a/scripts/symbian.ts
+++ b/scripts/symbian.ts
@@ -803,7 +803,7 @@ const HELP = `OpenStrike Nokia E7 3D build
bun scripts/symbian.ts --guest-only
verify/cook all eight maps, compile the real JS/PAK, and stage private data
- bun scripts/symbian.ts [--sis-version 0.3.2] [--uid 0xE.......]
+ bun scripts/symbian.ts [--sis-version 0.3.4] [--uid 0xE.......]
additionally build the pinned Rust GLES2 core and package one all-map SIS
OPENSTRIKE_MAPS accepts /maps + /support or a flat BSP/WAD root,
diff --git a/test/symbian-tooling.test.ts b/test/symbian-tooling.test.ts
index eb448e7..9d32a15 100644
--- a/test/symbian-tooling.test.ts
+++ b/test/symbian-tooling.test.ts
@@ -64,7 +64,7 @@ function cookedMap(): Uint8Array {
describe("OpenStrike Symbian source contract", () => {
test("uses the canonical full game with an external eight-map catalogue", () => {
expect(validateSymbianSourceContract(manifest)).toEqual({
- version: "0.3.2",
+ version: "0.3.4",
});
expect(manifest.app.entry).toBe("game/openstrike.tsx");
expect(manifest.app.viewport.dynamic).toEqual({
diff --git a/vendor/pocketjs b/vendor/pocketjs
index f8b4b7a..5bfaff7 160000
--- a/vendor/pocketjs
+++ b/vendor/pocketjs
@@ -1 +1 @@
-Subproject commit f8b4b7a8ef2b6d519c9b71b2c240c2ce7bfc44dd
+Subproject commit 5bfaff7091e63a1cd93fe46fd5a4f8b61b46b335
]