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.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..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" @@ -14,11 +19,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..1133cf8 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,9 @@

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), 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 @@ -37,6 +39,9 @@ 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 @@ -77,11 +82,13 @@ 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 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 @@ -90,6 +97,75 @@ 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: 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, 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 +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 | +| --- | --- | +| W / A / S / D | move | +| 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 | + +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/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 pinned native core and one independently installable, eight-map SIS. +OPENSTRIKE_MAPS=~/path/to/cs-maps bun run build:symbian +``` + +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 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-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-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-symbian/Cargo.lock b/crates/openstrike-symbian/Cargo.lock new file mode 100644 index 0000000..ebdc1d7 --- /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.3.4" +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..ff66a40 --- /dev/null +++ b/crates/openstrike-symbian/Cargo.toml @@ -0,0 +1,36 @@ +# 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.3.4" +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"] } +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/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 new file mode 100644 index 0000000..ebfd982 --- /dev/null +++ b/crates/openstrike-symbian/src/input.rs @@ -0,0 +1,101 @@ +//! 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, +}; + +const LOOK_YAW_RATE: f32 = 2.6; +const LOOK_PITCH_RATE: f32 = 1.7; + +pub struct KeyboardInput { + 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 { + look_hold: 0.0, + } + } + + pub fn map(&mut self, keys: u32, buttons: u32, dt: f32) -> TickInput { + 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 || buttons & (btn::CIRCLE | btn::RTRIGGER) != 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, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + 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); + 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); + assert!(first.sim.reload && 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, 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, 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 new file mode 100644 index 0000000..0a1a1d4 --- /dev/null +++ b/crates/openstrike-symbian/src/lib.rs @@ -0,0 +1,520 @@ +#![no_std] +#![allow(static_mut_refs)] + +extern crate alloc; +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::*; + +#[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; +#[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}; +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); + } +} + +#[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; + +#[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, + #[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, + 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, 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); + 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(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) => { + shutdown_game(state)?; + state.menu_time = 0.0; + } + _ => {} + } + 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 { + 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; + }; + #[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()) + }; + 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); + #[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(), + 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, buttons) { + 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/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/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/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/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/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/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..0adbee5 --- /dev/null +++ b/game/screen.tsx @@ -0,0 +1,17 @@ +// 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"; +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..0804dcb 100644 --- a/game/sdk.ts +++ b/game/sdk.ts @@ -45,7 +45,7 @@ export interface BotsConfig { damageMax: number; } -interface NativeStrike { +export interface NativeStrike { /** Cooked maps available to loadMap (index-aligned), host-injected. */ maps?: string[]; loadMap?(index: number): void; diff --git a/package.json b/package.json index 7dadcae..db311fd 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": "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 4229f42..dd7205d 100644 --- a/pocket.json +++ b/pocket.json @@ -4,13 +4,16 @@ "id": "dev.pocket-stack.openstrike", "name": "open-strike", "title": "OpenStrike", - "version": "0.1.0", + "version": "0.3.4", "engine": { "capabilities": { "requires": [ "text.glyphs.baked", - "input.buttons", - "input.analog.left" + "input.buttons" + ], + "enhances": [ + "input.analog.left", + "display.viewport.live" ] } }, @@ -19,11 +22,27 @@ "output": "openstrike", "framework": "solid", "viewport": { - "logical": [ - 480, - 272 - ], - "presentation": "integer-fit" + "fixed": { + "logical": [ + 480, + 272 + ], + "presentation": "integer-fit" + }, + "dynamic": { + "default": [ + 640, + 360 + ], + "min": [ + 360, + 360 + ], + "max": [ + 640, + 640 + ] + } } } } 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.ts b/scripts/symbian.ts new file mode 100644 index 0000000..064b4c7 --- /dev/null +++ b/scripts/symbian.ts @@ -0,0 +1,967 @@ +// Build the real OpenStrike FPS for Nokia E7: +// 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_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"; + +interface ResolvedPlan { + readonly app: { + readonly entry: string; + readonly output: string; + }; + readonly target: { + readonly id: string; + }; +} + +interface SymbianProfileModule { + resolveSymbianE7BuildPlan(input: unknown): ResolvedPlan; +} + +interface SymbianToolchainFile { + readonly runtime?: { + readonly rustToolchain?: string; + readonly frameRate?: number; + }; +} + +interface PocketManifest { + readonly id?: unknown; + 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 mapsRoot: string; + readonly cookedMaps: string; + readonly dataRoot: 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; + } + 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 }; +} + +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", + ); + } +} + +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", + ), + 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, + nativeLibrary: resolve( + nativeTarget, + "armv6-symbian-eabi/release/libopenstrike_symbian.a", + ), + packageOutput: resolve(repository, "dist/symbian"), + }; +} + +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 } { + 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[] { + // 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( + 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[], +): 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 !== 0) { + throw new Error( + "Symbian guest PAK must not contain map bytes; maps are private SIS data", + ); + } +} + +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, + name: SymbianMapName, +): readonly string[] { + const map = symbianMapFile(paths, name); + return [ + "cargo", + "run", + "--release", + "-q", + "-p", + "pocket3d-cook", + "--", + map.source, + "--wads", + map.support, + "--subdivide", + "32", + "-o", + map.cooked, + "--verify", + ]; +} + +export function symbianVerifyCookedCommand( + paths: SymbianBuildPaths, + name: SymbianMapName, +): readonly string[] { + const map = symbianMapFile(paths, name); + return [ + "cargo", + "run", + "--release", + "-q", + "-p", + "pocket3d-cook", + "--", + "--verify-cooked", + map.cooked, + ]; +} + +export function symbianNativeCommand( + paths: SymbianBuildPaths, + rustToolchain: string, + rustup = "rustup", +): readonly string[] { + return [ + rustup, + "run", + rustToolchain, + "cargo", + "build", + "--manifest-path", + paths.nativeManifest, + "--release", + "--locked", + "--features=embedded-map-catalog", + "--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, + paths.symbianTool, + "build", + "app", + "--manifest", + paths.generatedManifest, + "--project-root", + paths.repo, + "--outdir", + paths.packageOutput, + "--sis-version", + sisVersion, + "--core-library", + paths.nativeLibrary, + "--mass-storage-data-root", + paths.dataRoot, + ]; + if (uid) command.push("--uid", uid); + 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, + name: SymbianMapName, +): number { + const map = symbianMapFile(paths, name); + if (!existsSync(map.source)) { + throw new Error( + `missing ${map.source}; set OPENSTRIKE_MAPS to your GoldSrc map data`, + ); + } + if (!existingDirectory(map.support)) { + throw new Error(`missing WAD support directory ${map.support}`); + } + const wads = readdirSync(map.support) + .filter((name) => name.toLowerCase().endsWith(".wad")) + .map((wad) => resolve(map.support, wad)); + if (wads.length === 0) { + throw new Error(`no WAD files found under ${map.support}`); + } + return Math.max( + statSync(map.source).mtimeMs, + ...wads.map((path) => statSync(path).mtimeMs), + ); +} + +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(map.source)) return false; + const newestInput = newestMapInput(paths, name); + return statSync(map.cooked).mtimeMs < newestInput; +} + +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 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, + }); + } +} + +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( + 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), + ); +} + +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 + verify/cook all eight maps, compile the real JS/PAK, and stage private data + + 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, + 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 { + 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 ensureMaps(paths); + const stagedMaps = stageSymbianMaps(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); + 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( + `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), + ); + 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) { + throw new Error(`Symbian build did not produce ${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/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-tooling.test.ts b/test/symbian-tooling.test.ts new file mode 100644 index 0000000..9d32a15 --- /dev/null +++ b/test/symbian-tooling.test.ts @@ -0,0 +1,381 @@ +import { describe, expect, test } from "bun:test"; +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_NAMES, + createSymbianBuildManifest, + createSymbianBuildPaths, + parseSymbianCli, + resolvePocketJsRoot, + resolveSymbianPackageUid, + stageSymbianMaps, + symbianCookCommand, + symbianGeneratedPakManifest, + symbianMapFile, + symbianNativeCommand, + symbianPackageCommand, + symbianRuntimeMapRoot, + symbianVerifyCookedCommand, + validateSymbianDataReceipt, + 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(); +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); + 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 an external eight-map catalogue", () => { + expect(validateSymbianSourceContract(manifest)).toEqual({ + version: "0.3.4", + }); + 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(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( + '#[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")'); + }); + + 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 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", + "-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, "de_dust2")).toEqual([ + "cargo", + "run", + "--release", + "-q", + "-p", + "pocket3d-cook", + "--", + "--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", () => { + 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("--features=embedded-map-catalog"); + 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[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 map bytes stay out of app.pak", () => { + const pak = pack([ + { key: "ui:styles", dtype: PAK_DTYPE.u8, data: new Uint8Array([1]) }, + ]); + const js = new TextEncoder().encode( + `const strike = "openstrike";${" ".repeat(1100)}`, + ); + expect(() => validateGuestArtifacts(js, unpack(pak))).not.toThrow(); + expect(unpack(pak).map((entry) => entry.key)).toEqual([ + "ui:styles", + ]); + }); + + test("rejects any accidentally re-embedded 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))).toThrow( + "must not contain map bytes", + ); + }); +}); 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..5bfaff7 160000 --- a/vendor/pocketjs +++ b/vendor/pocketjs @@ -1 +1 @@ -Subproject commit 42d255d6de02906c6371112adc1a14d856c0e161 +Subproject commit 5bfaff7091e63a1cd93fe46fd5a4f8b61b46b335