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