Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/STRUCTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ pocketjs/
│ ├─ backends/ platform render backends (ESP32-P4 PPA is a standalone no_std crate)
│ ├─ wasm/ core compiled to wasm32 for web/sim hosts (standalone crate)
│ ├─ symbian/ no_std Symbian UI static library: C ABI, capture raster + GLES2 DrawList backend (standalone crate)
│ ├─ pocket3d/ the 3D core family (bsp, cook, gu, vita) + desktop examples
│ ├─ pocket3d/ the 3D core family (bsp, cook, gu, vita, GLES2) + desktop examples
│ ├─ crates/ non-3D engine crates: pocket-mod, pocket-ui-surface, pocket-ui-wgpu, pocket-vrm, pocket-widget
│ └─ Cargo.toml the desktop workspace root (core/, wasm/, symbian/, and
│ console-toolchain crates are deliberately excluded and
Expand Down
43 changes: 43 additions & 0 deletions docs/SYMBIAN_E7.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,45 @@ bun tools/symbian.ts build app \
--outdir /path/to/project/dist/symbian
```

Applications that need an app-specific native surface can supply a prebuilt
archive through `--core-library`. The archive must implement the ordinary
PocketJS `ui_*` ABI; it may also export the versioned
`pocketjs_symbian_extension_v1` table from
`pocketjs_symbian_extension.h`. The table adds synchronous boot, fixed-step
input, post-guest command draining, resize, render, and shutdown callbacks
without copying or forking the Qt/QuickJS host:

```sh
bun tools/symbian.ts build app \
--manifest /path/to/project/pocket.json \
--project-root /path/to/project \
--core-library /path/to/libapplication_symbian_core.a
```

The stock core's provider returns null, so it retains the exact 2D runtime and
requests no depth buffer. A custom core disables that default Cargo feature
and exports the same symbol with a callback table. This explicit null-provider
shape is intentional: Symbian's historical E32 conversion tools cannot safely
consume ELF weak relocations. An extension can request a depth attachment,
render its scene first, and let `ui_gl_render_over` composite the retained
PocketJS HUD without clearing the color buffer. The PAK bytes stay owned by the
host and may be borrowed only until the matching shutdown call; the JS guest
receives a separate writable ArrayBuffer, so script code cannot mutate native
borrowed storage. Shutdown reports whether the owning GL context is current,
allowing the extension to delete live resources normally or abandon stale
handles after context loss. Application-specific cores are intentionally
single-app only and cannot be combined with a launcher catalog because their
process-wide provider has no per-guest opt-out. The selected
archive is copied inside the serialized build transaction and its exact bytes
remain covered by the receipt's `sha256.core`. The pinned QuickJS archive also
includes its official `static-functions.c` wrappers, so Rust extensions can
call the inline value helpers through stable C symbols without carrying a
second QuickJS build.

Rust application cores should import the matching table, flags, and native-key
bits from `pocketjs_symbian_core::extension`; that module is the Rust ABI
authority paired with the public C header.

The experimental host has these runtime semantics:

- PocketJS uses the full native Qt window as its logical viewport: `640x360`
Expand All @@ -196,6 +235,10 @@ The experimental host has these runtime semantics:
- Arrow keys map to the four directions, the navigation center/Select key and
keyboard Enter to `CIRCLE`, Escape to `CROSS`, Space to `START`, Q/E to the
left/right triggers, and T/S to `TRIANGLE`/`SQUARE`.
- Native extensions additionally receive an independent held-key bitset:
W/A/S/D move, arrows look, E fires, Space jumps, R reloads, and Shift walks.
This channel does not change the portable PocketJS button mask seen by the
guest.
- Touch frame v2 uses tagged 10-bit coordinates for the E7's full 640-pixel
axis while retaining the original untagged 9-bit wire for PSP/Vita-era
hosts. `symbian-e7-dev` advertises `input.touch`; the framework exposes the
Expand Down
2 changes: 2 additions & 0 deletions engine/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
# lone standalone crates (see their Cargo.toml)
# pocket3d/crates/pocket3d-gu, gu-demo — PSP-only (cargo-psp toolchain)
# pocket3d/crates/pocket3d-vita — Vita-only (vitasdk toolchain)
# pocket3d/crates/pocket3d-gles2 — no_std Symbian GLES2 backend
[workspace]
resolver = "2"
members = [
Expand All @@ -29,6 +30,7 @@ exclude = [
"pocket3d/crates/pocket3d-gu",
"pocket3d/crates/gu-demo",
"pocket3d/crates/pocket3d-vita",
"pocket3d/crates/pocket3d-gles2",
]

[workspace.package]
Expand Down
1 change: 1 addition & 0 deletions engine/core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ pub mod codec;
pub mod damage;
pub mod draw;
pub mod layout;
pub mod pak;
pub mod package;
pub mod raster;
pub mod spec;
Expand Down
144 changes: 144 additions & 0 deletions engine/core/src/pak.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
//! Allocation-free reader for the immutable PocketJS asset pack.

use crate::spec;

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct PakEntry<'a> {
pub key: &'a str,
pub blob: &'a [u8],
}

#[derive(Clone)]
pub struct PakEntries<'a> {
pak: &'a [u8],
directory_offset: usize,
names_offset: usize,
count: usize,
index: usize,
}

#[inline]
fn read_u16(bytes: &[u8], offset: usize) -> Option<u16> {
Some(u16::from_le_bytes([
*bytes.get(offset)?,
*bytes.get(offset + 1)?,
]))
}

#[inline]
fn read_u32(bytes: &[u8], offset: usize) -> Option<u32> {
Some(u32::from_le_bytes([
*bytes.get(offset)?,
*bytes.get(offset + 1)?,
*bytes.get(offset + 2)?,
*bytes.get(offset + 3)?,
]))
}

pub fn entries(pak: &[u8]) -> PakEntries<'_> {
let valid =
read_u32(pak, 0) == Some(spec::pak::MAGIC) && read_u16(pak, 4) == Some(spec::pak::VERSION);
let (count, directory_offset, names_offset) = if valid {
match (read_u32(pak, 8), read_u32(pak, 12), read_u32(pak, 16)) {
(Some(count), Some(directory), Some(names)) => {
let directory = directory as usize;
let count = (count as usize)
.min(pak.len().saturating_sub(directory) / spec::pak::ENTRY_SIZE);
(count, directory, names as usize)
}
_ => (0, 0, 0),
}
} else {
(0, 0, 0)
};
PakEntries {
pak,
directory_offset,
names_offset,
count,
index: 0,
}
}

pub fn find<'a>(pak: &'a [u8], key: &str) -> Option<&'a [u8]> {
entries(pak)
.find(|entry| entry.key == key)
.map(|entry| entry.blob)
}

impl<'a> Iterator for PakEntries<'a> {
type Item = PakEntry<'a>;

fn next(&mut self) -> Option<Self::Item> {
while self.index < self.count {
let entry = self.directory_offset + self.index * spec::pak::ENTRY_SIZE;
self.index += 1;
let (Some(blob_offset), Some(blob_length), Some(name_offset), Some(name_length)) = (
read_u32(self.pak, entry + 4),
read_u32(self.pak, entry + 8),
read_u32(self.pak, entry + 12),
read_u16(self.pak, entry + 16),
) else {
continue;
};
let Some(name_start) = self.names_offset.checked_add(name_offset as usize) else {
continue;
};
let Some(name_end) = name_start.checked_add(name_length as usize) else {
continue;
};
let blob_start = blob_offset as usize;
let Some(blob_end) = blob_start.checked_add(blob_length as usize) else {
continue;
};
let (Some(name), Some(blob)) = (
self.pak.get(name_start..name_end),
self.pak.get(blob_start..blob_end),
) else {
continue;
};
let Ok(key) = core::str::from_utf8(name) else {
continue;
};
return Some(PakEntry { key, blob });
}
None
}
}

#[cfg(test)]
mod tests {
use super::*;
use alloc::vec;

fn fixture() -> alloc::vec::Vec<u8> {
let mut pak = vec![0u8; 96];
pak[0..4].copy_from_slice(&spec::pak::MAGIC.to_le_bytes());
pak[4..6].copy_from_slice(&spec::pak::VERSION.to_le_bytes());
pak[8..12].copy_from_slice(&1u32.to_le_bytes());
pak[12..16].copy_from_slice(&32u32.to_le_bytes());
pak[16..20].copy_from_slice(&64u32.to_le_bytes());
pak[32..36].copy_from_slice(&7u32.to_le_bytes());
pak[36..40].copy_from_slice(&80u32.to_le_bytes());
pak[40..44].copy_from_slice(&4u32.to_le_bytes());
pak[44..48].copy_from_slice(&0u32.to_le_bytes());
pak[48..50].copy_from_slice(&3u16.to_le_bytes());
pak[64..67].copy_from_slice(b"map");
pak[80..84].copy_from_slice(b"P3D!");
pak
}

#[test]
fn finds_a_borrowed_blob_without_allocating() {
let pak = fixture();
assert_eq!(find(&pak, "map"), Some(&b"P3D!"[..]));
assert_eq!(find(&pak, "missing"), None);
}

#[test]
fn malformed_ranges_are_skipped() {
let mut pak = fixture();
pak[40..44].copy_from_slice(&u32::MAX.to_le_bytes());
assert_eq!(entries(&pak).count(), 0);
}
}
63 changes: 3 additions & 60 deletions engine/crates/pocket-ui-surface/src/pak.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,72 +3,15 @@
//! `pocketjs_core::spec::pak`). Malformed packs/entries are skipped, never
//! fatal, matching the PSP walker's contract.

use pocketjs_core::spec;

/// One named blob from a pak.
pub struct PakEntry<'a> {
pub key: &'a str,
pub blob: &'a [u8],
}

#[inline]
fn rd_u16(b: &[u8], off: usize) -> Option<u16> {
Some(u16::from_le_bytes([*b.get(off)?, *b.get(off + 1)?]))
}

#[inline]
fn rd_u32(b: &[u8], off: usize) -> Option<u32> {
Some(u32::from_le_bytes([
*b.get(off)?,
*b.get(off + 1)?,
*b.get(off + 2)?,
*b.get(off + 3)?,
]))
}
pub use pocketjs_core::pak::PakEntry;

/// The blob for `key`, or None for absent keys (same tolerance for
/// malformed packs as the walk).
pub fn find_pak<'a>(pak: &'a [u8], key: &str) -> Option<&'a [u8]> {
walk_pak(pak).into_iter().find(|e| e.key == key).map(|e| e.blob)
pocketjs_core::pak::find(pak, key)
}

/// Iterate every well-formed entry in `pak`.
pub fn walk_pak(pak: &[u8]) -> Vec<PakEntry<'_>> {
let mut out = Vec::new();
let ok = rd_u32(pak, 0) == Some(spec::pak::MAGIC) && rd_u16(pak, 4) == Some(spec::pak::VERSION);
if !ok {
return out;
}
let (Some(count), Some(dir_off), Some(names_off)) =
(rd_u32(pak, 8), rd_u32(pak, 12), rd_u32(pak, 16))
else {
return out;
};
// Clamp the walk to what the pack can actually hold (corrupt count words
// must not stall the host).
let count =
(count as usize).min(pak.len().saturating_sub(dir_off as usize) / spec::pak::ENTRY_SIZE);
for i in 0..count {
let e = dir_off as usize + i * spec::pak::ENTRY_SIZE;
let (Some(blob_off), Some(blob_len), Some(name_off), Some(name_len)) = (
rd_u32(pak, e + 4),
rd_u32(pak, e + 8),
rd_u32(pak, e + 12),
rd_u16(pak, e + 16),
) else {
continue;
};
let ns = names_off as usize + name_off as usize;
let (Some(name_bytes), Some(blob)) = (
pak.get(ns..ns + name_len as usize),
pak.get(blob_off as usize..blob_off as usize + blob_len as usize),
) else {
continue;
};
let Ok(key) = core::str::from_utf8(name_bytes) else {
continue;
};
out.push(PakEntry { key, blob });
}
out
pocketjs_core::pak::entries(pak).collect()
}
69 changes: 69 additions & 0 deletions engine/pocket3d/crates/pocket3d-bsp/src/cooked.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
//! - `WENT` — spawns, sun light, map bounds.

use alloc::string::String;
use alloc::vec;
use alloc::vec::Vec;

use glam::Vec3;
Expand Down Expand Up @@ -75,6 +76,74 @@ pub fn mip_rows(height: u32, level: u32) -> usize {
((height >> level).max(1) as usize).div_ceil(8) * 8
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TextureDecodeError {
MissingLevelZero,
TruncatedSwizzle,
TruncatedPalette,
SizeOverflow,
}

/// Unswizzle and palette-expand a cooked texture's first mip into RGBA8.
///
/// The `.p3d` texture layout is shared by every constrained backend, so this
/// format operation lives next to the reader rather than in a specific GPU
/// implementation. Transparent palette entries have RGB cleared to prevent
/// bilinear filtering from bleeding arbitrary cutout colors into their edge.
pub fn expand_level0_rgba(texture: &CookedTexture<'_>) -> Result<Vec<u8>, TextureDecodeError> {
let source = texture
.mips
.first()
.copied()
.ok_or(TextureDecodeError::MissingLevelZero)?;
if texture.palette.len() < 256 * 4 {
return Err(TextureDecodeError::TruncatedPalette);
}

let width = texture.width.max(1) as usize;
let height = texture.height.max(1) as usize;
let stride = mip_stride(texture.width.max(1), 0);
let rows = mip_rows(texture.height.max(1), 0);
let expected = stride
.checked_mul(rows)
.ok_or(TextureDecodeError::SizeOverflow)?;
if source.len() < expected {
return Err(TextureDecodeError::TruncatedSwizzle);
}
let pixel_count = width
.checked_mul(height)
.ok_or(TextureDecodeError::SizeOverflow)?;
let mut rgba = vec![
0u8;
pixel_count
.checked_mul(4)
.ok_or(TextureDecodeError::SizeOverflow)?
];

let mut source_offset = 0usize;
for block_y in 0..rows / 8 {
for block_x in 0..stride / 16 {
for row in 0..8 {
let y = block_y * 8 + row;
for column in 0..16 {
let x = block_x * 16 + column;
let palette_index = source[source_offset + column] as usize;
if x < width && y < height {
let palette = &texture.palette[palette_index * 4..palette_index * 4 + 4];
let destination = (y * width + x) * 4;
rgba[destination..destination + 4].copy_from_slice(palette);
if palette[3] == 0 {
rgba[destination..destination + 3].fill(0);
}
}
}
source_offset += 16;
}
}
}
Ok(rgba)
}

#[derive(Clone, Copy, Debug)]
pub struct BatchDesc {
pub texture: u16,
Expand Down
Loading
Loading