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