Skip to content
Draft
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
33 changes: 33 additions & 0 deletions contracts/spec/gen-rust.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,19 @@ import {
STREAM_VRING_OFF,
SVC_IMG_MAX_BYTES,
SVC_POLL_BUF,
WIRE_BEACON_MAGIC,
WIRE_BEACON_PORT,
WIRE_CHUNK_HEADER_SIZE,
WIRE_HEADER_SIZE,
WIRE_MAGIC,
WIRE_MARK_FLAG_ENDED,
WIRE_MARK_SIZE,
WIRE_MAX_PAYLOAD,
WIRE_MSG,
WIRE_PORT,
WIRE_SLOT_FLAG_RLE,
WIRE_SLOT_HEADER_SIZE,
WIRE_VERSION,
TEX_MAX_DIM,
TEX_SLOT_BITS,
TEX_SLOT_MASK,
Expand Down Expand Up @@ -318,6 +331,26 @@ export function generateRust(): string {
put(` pub const FLAG_ENDED: u16 = ${STREAM_FLAG_ENDED};`);
put("}");
put("");
put("/// SVC WIRE protocol (PKNT) — the svc mailbox over a socket.");
put("/// Full byte layout in spec.ts; parsed by engine/core/src/wire.rs.");
put("pub mod wire {");
put(` pub const MAGIC: u32 = ${hex(WIRE_MAGIC)}; // 'PKNT' LE`);
put(` pub const BEACON_MAGIC: u32 = ${hex(WIRE_BEACON_MAGIC)}; // 'PKDB' LE`);
put(` pub const VERSION: u8 = ${WIRE_VERSION};`);
put(` pub const HEADER_SIZE: usize = ${WIRE_HEADER_SIZE};`);
put(` pub const MAX_PAYLOAD: usize = ${WIRE_MAX_PAYLOAD};`);
put(` pub const BEACON_PORT: u16 = ${WIRE_BEACON_PORT};`);
put(` pub const PORT: u16 = ${WIRE_PORT};`);
for (const [name, v] of Object.entries(WIRE_MSG)) {
put(` pub const MSG_${screaming(name)}: u8 = ${hex(v, 2)};`);
}
put(` pub const SLOT_HEADER_SIZE: usize = ${WIRE_SLOT_HEADER_SIZE};`);
put(` pub const CHUNK_HEADER_SIZE: usize = ${WIRE_CHUNK_HEADER_SIZE};`);
put(` pub const MARK_SIZE: usize = ${WIRE_MARK_SIZE};`);
put(` pub const SLOT_FLAG_RLE: u16 = ${WIRE_SLOT_FLAG_RLE};`);
put(` pub const MARK_FLAG_ENDED: u16 = ${WIRE_MARK_FLAG_ENDED};`);
put("}");
put("");

// --- style table --------------------------------------------------------------
put("/// STYLE TABLE (styles.bin) format constants — full layout in spec.ts.");
Expand Down
89 changes: 89 additions & 0 deletions contracts/spec/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -737,6 +737,95 @@ export const STREAM_SLOT_HEADER_SIZE = 32;
export const STREAM_CHUNK_HEADER_SIZE = 16;
export const STREAM_FLAG_ENDED = 1 << 0;

// ---------------------------------------------------------------------------
// SVC WIRE protocol (PKNT) — the svc mailbox over a socket
// ---------------------------------------------------------------------------
// The PSP reaches its companion host through PSPLINK's usbhostfs file share
// (SVC above); hosts without a shared filesystem (the Vita, over WiFi) speak
// the SAME mailbox + side-file + .pkst semantics over one TCP connection.
// One connection on purpose: TCP ordering is load-bearing — STREAM_OPEN
// precedes the JSON line announcing it, FILE pushes precede the results line
// that references them — and a second channel would reintroduce exactly the
// cross-channel races the file transport never had.
//
// Discovery: the host broadcasts a UDP beacon once a second on
// WIRE_BEACON_PORT; the datagram's SOURCE ADDRESS is the connect target.
//
// Beacon datagram:
// off 0 u32 magic = 0x42444b50 bytes 'P','K','D','B'
// off 4 u8 version = WIRE_VERSION
// off 5 u8 reserved (0)
// off 6 u16 tcpPort the WIRE listener's port
// off 8 u8 appLen, then app id bytes (the pocket-svc app segment)
// ... u8 nameLen, then a display name (<= 32 bytes, for pickers)
//
// Handshake (before any frame):
// device -> host: u32 magic 'PKNT' · u8 version · u8 reserved ·
// u8 appLen · app id bytes
// host -> device: u32 magic 'PKNT' · u8 acceptedVersion · u8 flags (0) ·
// u16 reserved
// A magic/version/app mismatch closes the socket.
//
// Every subsequent message, both directions, is one frame:
//
// Frame header (WIRE_HEADER_SIZE = 8 bytes):
// off 0 u8 type (WIRE_MSG)
// off 1 u8 flags type-specific (see below)
// off 2 u16 reserved (0)
// off 4 u32 payloadLen (<= WIRE_MAX_PAYLOAD; oversize closes the socket)
//
// Types (unknown types are skipped by length — forward compatible):
// ping (h->d) u32 token, every ~2 s; either side drops the
// pong (d->h) connection after ~10 s of silence. Pong echoes.
// ctrl (both) one UTF-8 JSON line, no trailing \n — the in.jsonl /
// out.jsonl mailbox semantics (<= SVC_POLL_BUF bytes).
// file (h->d) u16 pathLen · path (svc-relative, side_path rules) ·
// whole IMG-entry bytes (<= SVC_IMG_MAX_BYTES). Pushed
// PROACTIVELY before the ctrl line that references it,
// so a synchronous loadImgFile hits a warm device cache.
// streamOpen (h->d) u16 pathLen · path (what the announce line will name)
// · the verbatim 96-byte .pkst header block. The device
// allocates a RAM ring image (stream_rx.rs) — every
// streamOpen is a full ring reset.
// streamClose (h->d) empty payload.
// videoSlot (h->d) u32 seq · u32 frameIndex · u16 w · u16 h ·
// u16 flags (bit 0 = indices PackBits-RLE) · u16 rsv ·
// u8[1024] palette · indices (raw w*h, or RLE decoding
// to exactly w*h). Applied payload-first, seq-published-
// after into the RAM ring, preserving the .pkst torn-
// frame contract verbatim.
// audioChunk (h->d) u32 seq · u32 startFrame · s16 PCM, exactly
// chunkFrames*channels samples.
// streamMark (h->d) u32 epoch · u16 flags (bit 0 = ended) · u16 reserved —
// carries bumpEpoch()/markEnded() into the ring header.

export const WIRE_MAGIC = 0x544e4b50; // 'PKNT' LE
export const WIRE_BEACON_MAGIC = 0x42444b50; // 'PKDB' LE
export const WIRE_VERSION = 1;
export const WIRE_HEADER_SIZE = 8;
export const WIRE_MAX_PAYLOAD = 256 * 1024;
/** UDP discovery beacon (host broadcasts; device listens). */
export const WIRE_BEACON_PORT = 8621;
/** The host's TCP listener for the framed protocol. */
export const WIRE_PORT = 8622;
export const WIRE_MSG = {
ping: 0x01,
pong: 0x02,
ctrl: 0x10,
file: 0x20,
streamOpen: 0x30,
streamClose: 0x31,
videoSlot: 0x32,
audioChunk: 0x33,
streamMark: 0x34,
} as const;
/** videoSlot leading fields (seq..reserved) — the palette follows. */
export const WIRE_SLOT_HEADER_SIZE = 16;
export const WIRE_CHUNK_HEADER_SIZE = 8;
export const WIRE_MARK_SIZE = 8;
export const WIRE_SLOT_FLAG_RLE = 1 << 0;
export const WIRE_MARK_FLAG_ENDED = 1 << 0;

// ---------------------------------------------------------------------------
// Font slots
// ---------------------------------------------------------------------------
Expand Down
2 changes: 2 additions & 0 deletions engine/core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,11 @@ pub mod package;
pub mod raster;
pub mod spec;
pub mod stream;
pub mod stream_rx;
pub mod style;
pub mod text;
pub mod tree;
pub mod wire;

pub use draw::DrawList;

Expand Down
26 changes: 26 additions & 0 deletions engine/core/src/spec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,32 @@ pub mod stream {
pub const FLAG_ENDED: u16 = 1;
}

/// SVC WIRE protocol (PKNT) — the svc mailbox over a socket.
/// Full byte layout in spec.ts; parsed by engine/core/src/wire.rs.
pub mod wire {
pub const MAGIC: u32 = 0x544e4b50; // 'PKNT' LE
pub const BEACON_MAGIC: u32 = 0x42444b50; // 'PKDB' LE
pub const VERSION: u8 = 1;
pub const HEADER_SIZE: usize = 8;
pub const MAX_PAYLOAD: usize = 262144;
pub const BEACON_PORT: u16 = 8621;
pub const PORT: u16 = 8622;
pub const MSG_PING: u8 = 0x01;
pub const MSG_PONG: u8 = 0x02;
pub const MSG_CTRL: u8 = 0x10;
pub const MSG_FILE: u8 = 0x20;
pub const MSG_STREAM_OPEN: u8 = 0x30;
pub const MSG_STREAM_CLOSE: u8 = 0x31;
pub const MSG_VIDEO_SLOT: u8 = 0x32;
pub const MSG_AUDIO_CHUNK: u8 = 0x33;
pub const MSG_STREAM_MARK: u8 = 0x34;
pub const SLOT_HEADER_SIZE: usize = 16;
pub const CHUNK_HEADER_SIZE: usize = 8;
pub const MARK_SIZE: usize = 8;
pub const SLOT_FLAG_RLE: u16 = 1;
pub const MARK_FLAG_ENDED: u16 = 1;
}

/// STYLE TABLE (styles.bin) format constants — full layout in spec.ts.
pub mod style_table {
pub const MAGIC: u32 = 0x54534344; // 'DCST' LE
Expand Down
165 changes: 165 additions & 0 deletions engine/core/src/stream_rx.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
//! RAM `.pkst` assembler — the receive side of the WIRE stream messages.
//!
//! `RamStream` maintains a byte-exact `.pkst` FILE IMAGE in memory: the same
//! 96-byte header block, the same video/audio rings at the same offsets. A
//! host that receives WIRE videoSlot/audioChunk/streamMark messages applies
//! them here, then points the exact same reader logic (stream.rs
//! parse_header_block / slot_offset / parse_slot_header / chunk_offset) at
//! `buf()` — the file-backed and socket-backed transports share one reader,
//! one torn-frame contract, one golden format.
//!
//! The publish order is preserved verbatim: payload bytes land first, the
//! ring's latestSeq is written after. Over TCP nothing can tear mid-slot,
//! but the reader's seq re-check stays meaningful across epoch resets and
//! writer laps, and keeping the invariant means the two transports are
//! byte-equivalent, not just morally similar.

use alloc::vec;
use alloc::vec::Vec;

use crate::codec;
use crate::spec::stream as st;
use crate::spec::wire;
use crate::stream::{self, StreamHeaders};

pub struct RamStream {
buf: Vec<u8>,
/// Ring GEOMETRY parsed at open (fixed for the stream's lifetime). Live
/// cursors (latestSeq, epoch, ended) are read from `buf` like any reader.
geo: StreamHeaders,
}

impl RamStream {
/// Allocate the ring image from a verbatim 96-byte header block (a WIRE
/// streamOpen payload). The block is validated by the same
/// parse_header_block every reader runs; a malformed one is refused here,
/// before any allocation is trusted.
pub fn open(header_block: &[u8]) -> Option<Self> {
let geo = stream::parse_header_block(header_block)?;
let chunk_size = stream::chunk_size(geo.audio.chunk_frames, geo.audio.channels)?;
let video_end = geo.video_off.checked_add(geo.video.slot_count.checked_mul(geo.video.slot_size)?)?;
let audio_end = geo.audio_off.checked_add(geo.audio.chunk_count.checked_mul(chunk_size)?)?;
let total = video_end.max(audio_end).max(st::HEADER_BLOCK_SIZE as u32) as usize;
let mut buf = vec![0u8; total];
buf[..st::HEADER_BLOCK_SIZE].copy_from_slice(&header_block[..st::HEADER_BLOCK_SIZE]);
Some(Self { buf, geo })
}

/// The `.pkst` file image — hand it to the stream.rs readers.
pub fn buf(&self) -> &[u8] {
&self.buf
}

pub fn geometry(&self) -> &StreamHeaders {
&self.geo
}

fn wr_u32(&mut self, off: usize, v: u32) {
self.buf[off..off + 4].copy_from_slice(&v.to_le_bytes());
}

fn wr_u16(&mut self, off: usize, v: u16) {
self.buf[off..off + 2].copy_from_slice(&v.to_le_bytes());
}

/// Apply a WIRE videoSlot: write the slot payload, THEN publish the
/// ring's latestSeq. Rejects geometry that disagrees with the open
/// header, palettes that are not 1024 bytes, and indices that are not
/// (or do not RLE-decode to) exactly w*h.
pub fn apply_slot(&mut self, msg: &crate::wire::VideoSlotMsg) -> bool {
let v = self.geo.video;
if msg.w != v.w || msg.h != v.h || msg.palette.len() != 1024 {
return false;
}
let pixels = (v.w * v.h) as usize;
let Some(off) = stream::slot_offset(&self.geo, msg.seq) else {
return false;
};
let off = off as usize;
let indices_at = off + st::SLOT_HEADER_SIZE + 1024;
if indices_at + pixels > self.buf.len() {
return false;
}
if msg.rle {
if !codec::packbits_decode(msg.indices, &mut self.buf[indices_at..indices_at + pixels]) {
return false;
}
} else {
if msg.indices.len() != pixels {
return false;
}
self.buf[indices_at..indices_at + pixels].copy_from_slice(msg.indices);
}
self.buf[off + st::SLOT_HEADER_SIZE..indices_at].copy_from_slice(msg.palette);
// Slot header: seq · frameIndex · w · h · reserved(20B zeros).
self.wr_u32(off, msg.seq);
self.wr_u32(off + 4, msg.frame_index);
self.wr_u16(off + 8, msg.w as u16);
self.wr_u16(off + 10, msg.h as u16);
for b in &mut self.buf[off + 12..off + st::SLOT_HEADER_SIZE] {
*b = 0;
}
// Payload is in place — NOW publish the cursor.
self.wr_u32(st::VRING_OFF + 20, msg.seq.max(self.latest_video_seq()));
true
}

/// Apply a WIRE audioChunk: payload first, latestSeq after.
pub fn apply_chunk(&mut self, msg: &crate::wire::AudioChunkMsg) -> bool {
let a = self.geo.audio;
let bytes = (a.chunk_frames * a.channels * 2) as usize;
if msg.pcm.len() != bytes {
return false;
}
let Some(off) = stream::chunk_offset(&self.geo, msg.seq) else {
return false;
};
let off = off as usize;
let pcm_at = off + st::CHUNK_HEADER_SIZE;
if pcm_at + bytes > self.buf.len() {
return false;
}
self.buf[pcm_at..pcm_at + bytes].copy_from_slice(msg.pcm);
self.wr_u32(off, msg.seq);
self.wr_u32(off + 4, msg.start_frame);
for b in &mut self.buf[off + 8..off + st::CHUNK_HEADER_SIZE] {
*b = 0;
}
self.wr_u32(st::ARING_OFF + 20, msg.seq.max(self.latest_audio_seq()));
true
}

/// Apply a WIRE streamMark: epoch bump and/or the ended flag — the same
/// header fields the file writer's bumpEpoch()/markEnded() mutate.
pub fn apply_mark(&mut self, msg: &crate::wire::StreamMarkMsg) {
self.wr_u32(8, msg.epoch);
let mut flags = u16::from_le_bytes([self.buf[6], self.buf[7]]);
if msg.ended {
flags |= st::FLAG_ENDED;
}
self.wr_u16(6, flags);
}

fn latest_video_seq(&self) -> u32 {
u32::from_le_bytes([
self.buf[st::VRING_OFF + 20],
self.buf[st::VRING_OFF + 21],
self.buf[st::VRING_OFF + 22],
self.buf[st::VRING_OFF + 23],
])
}

fn latest_audio_seq(&self) -> u32 {
u32::from_le_bytes([
self.buf[st::ARING_OFF + 20],
self.buf[st::ARING_OFF + 21],
self.buf[st::ARING_OFF + 22],
self.buf[st::ARING_OFF + 23],
])
}

const _ASSERT_MSG_SIZES: () = {
assert!(wire::SLOT_HEADER_SIZE == 16);
assert!(wire::CHUNK_HEADER_SIZE == 8);
};
}
Loading