feat(wasm): add ds-stream-wasm crate — browser codec via WebAssembly

- 18KB WASM binary with wasm-bindgen exports
- Header encode/decode, RLE compression, XOR delta
- Message builders for signal diff/sync and input events
- 7 native tests, all passing
- Total workspace: 89 tests, 0 failures
This commit is contained in:
enzotar 2026-02-25 14:45:51 -08:00
parent 7f795eac6a
commit 2b2b4ffaec
3 changed files with 292 additions and 0 deletions

View file

@ -9,6 +9,7 @@ members = [
"compiler/ds-cli",
"engine/ds-physics",
"engine/ds-stream",
"engine/ds-stream-wasm",
]
[workspace.package]
@ -24,3 +25,4 @@ ds-layout = { path = "compiler/ds-layout" }
ds-types = { path = "compiler/ds-types" }
ds-physics = { path = "engine/ds-physics" }
ds-stream = { path = "engine/ds-stream" }
ds-stream-wasm = { path = "engine/ds-stream-wasm" }

View file

@ -0,0 +1,17 @@
[package]
name = "ds-stream-wasm"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "WebAssembly codec for DreamStack bitstream protocol"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
wasm-bindgen = "0.2"
js-sys = "0.3"
[profile.release]
opt-level = "s"
lto = true

View file

@ -0,0 +1,273 @@
//! DreamStack Bitstream WASM Codec
//!
//! WebAssembly bindings for the bitstream binary protocol.
//! Provides encode/decode for headers, RLE compression, and delta frames.
//!
//! This eliminates protocol drift between JS and Rust implementations
//! by sharing the exact same codec logic in the browser.
use wasm_bindgen::prelude::*;
// ─── Protocol Constants ───
pub const HEADER_SIZE: usize = 16;
// Frame types
pub const FRAME_PIXELS: u8 = 0x01;
pub const FRAME_COMPRESSED: u8 = 0x02;
pub const FRAME_DELTA: u8 = 0x03;
pub const FRAME_AUDIO_PCM: u8 = 0x10;
pub const FRAME_SIGNAL_SYNC: u8 = 0x30;
pub const FRAME_SIGNAL_DIFF: u8 = 0x31;
pub const FRAME_PING: u8 = 0xFE;
pub const FRAME_END: u8 = 0xFF;
// Flags
pub const FLAG_INPUT: u8 = 0x01;
pub const FLAG_KEYFRAME: u8 = 0x02;
pub const FLAG_COMPRESSED: u8 = 0x04;
// Input types
pub const INPUT_POINTER: u8 = 0x01;
pub const INPUT_PTR_DOWN: u8 = 0x02;
pub const INPUT_PTR_UP: u8 = 0x03;
pub const INPUT_KEY_DOWN: u8 = 0x10;
pub const INPUT_KEY_UP: u8 = 0x11;
pub const INPUT_SCROLL: u8 = 0x50;
// ─── Header Encode/Decode ───
/// Encode a 16-byte frame header.
#[wasm_bindgen]
pub fn encode_header(
frame_type: u8,
flags: u8,
seq: u16,
timestamp: u32,
width: u16,
height: u16,
payload_len: u32,
) -> Vec<u8> {
let mut buf = vec![0u8; HEADER_SIZE];
buf[0] = frame_type;
buf[1] = flags;
buf[2..4].copy_from_slice(&seq.to_le_bytes());
buf[4..8].copy_from_slice(&timestamp.to_le_bytes());
buf[8..10].copy_from_slice(&width.to_le_bytes());
buf[10..12].copy_from_slice(&height.to_le_bytes());
buf[12..16].copy_from_slice(&payload_len.to_le_bytes());
buf
}
/// Decode header fields from a buffer. Returns [type, flags, seq, timestamp, width, height, length].
#[wasm_bindgen]
pub fn decode_header(buf: &[u8]) -> Vec<u32> {
if buf.len() < HEADER_SIZE {
return vec![];
}
vec![
buf[0] as u32, // type
buf[1] as u32, // flags
u16::from_le_bytes([buf[2], buf[3]]) as u32, // seq
u32::from_le_bytes([buf[4], buf[5], buf[6], buf[7]]), // timestamp
u16::from_le_bytes([buf[8], buf[9]]) as u32, // width
u16::from_le_bytes([buf[10], buf[11]]) as u32, // height
u32::from_le_bytes([buf[12], buf[13], buf[14], buf[15]]), // length
]
}
// ─── RLE Compression ───
/// RLE-encode a delta buffer for transmission.
///
/// Encoding: non-zero bytes pass through; runs of zero bytes become
/// `0x00 count_lo count_hi` (2-byte LE count, max 65535 per run).
///
/// Achieves 5-20x compression on typical XOR delta frames (70-95% zeros).
#[wasm_bindgen]
pub fn rle_encode(data: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(data.len() / 2);
let mut i = 0;
while i < data.len() {
if data[i] == 0 {
let start = i;
while i < data.len() && data[i] == 0 {
i += 1;
}
let count = i - start;
let mut remaining = count;
while remaining > 0 {
let chunk = remaining.min(65535);
out.push(0x00);
out.push((chunk & 0xFF) as u8);
out.push(((chunk >> 8) & 0xFF) as u8);
remaining -= chunk;
}
} else {
out.push(data[i]);
i += 1;
}
}
out
}
/// RLE-decode a compressed delta buffer.
///
/// Inverse of `rle_encode`: expands `0x00 count_lo count_hi` into zero runs.
#[wasm_bindgen]
pub fn rle_decode(data: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(data.len() * 2);
let mut i = 0;
while i < data.len() {
if data[i] == 0 {
if i + 2 >= data.len() {
break;
}
let count = data[i + 1] as usize | ((data[i + 2] as usize) << 8);
out.resize(out.len() + count, 0);
i += 3;
} else {
out.push(data[i]);
i += 1;
}
}
out
}
// ─── Delta Compression ───
/// Compute XOR delta between two equal-length frames.
#[wasm_bindgen]
pub fn compute_delta(current: &[u8], previous: &[u8]) -> Vec<u8> {
current.iter().zip(previous.iter()).map(|(c, p)| c ^ p).collect()
}
/// Apply XOR delta to reconstruct the current frame.
#[wasm_bindgen]
pub fn apply_delta(previous: &[u8], delta: &[u8]) -> Vec<u8> {
previous.iter().zip(delta.iter()).map(|(p, d)| p ^ d).collect()
}
/// Encode a delta frame: XOR + RLE. Returns compressed bytes.
#[wasm_bindgen]
pub fn encode_delta_rle(current: &[u8], previous: &[u8]) -> Vec<u8> {
let delta = compute_delta(current, previous);
rle_encode(&delta)
}
/// Decode a delta frame: RLE decompress + XOR apply.
#[wasm_bindgen]
pub fn decode_delta_rle(compressed: &[u8], previous: &[u8]) -> Vec<u8> {
let delta = rle_decode(compressed);
apply_delta(previous, &delta)
}
// ─── Message Builders ───
/// Build a complete message: header + payload.
#[wasm_bindgen]
pub fn build_message(
frame_type: u8,
flags: u8,
seq: u16,
timestamp: u32,
width: u16,
height: u16,
payload: &[u8],
) -> Vec<u8> {
let header = encode_header(frame_type, flags, seq, timestamp, width, height, payload.len() as u32);
let mut msg = Vec::with_capacity(HEADER_SIZE + payload.len());
msg.extend_from_slice(&header);
msg.extend_from_slice(payload);
msg
}
/// Build a signal diff message from JSON bytes.
#[wasm_bindgen]
pub fn signal_diff_message(seq: u16, timestamp: u32, json: &[u8]) -> Vec<u8> {
build_message(FRAME_SIGNAL_DIFF, 0, seq, timestamp, 0, 0, json)
}
/// Build a signal sync (keyframe) message from JSON bytes.
#[wasm_bindgen]
pub fn signal_sync_message(seq: u16, timestamp: u32, json: &[u8]) -> Vec<u8> {
build_message(FRAME_SIGNAL_SYNC, FLAG_KEYFRAME, seq, timestamp, 0, 0, json)
}
/// Build an input message with FLAG_INPUT set.
#[wasm_bindgen]
pub fn input_message(input_type: u8, seq: u16, timestamp: u32, payload: &[u8]) -> Vec<u8> {
build_message(input_type, FLAG_INPUT, seq, timestamp, 0, 0, payload)
}
// ─── Tests ───
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_header_roundtrip() {
let encoded = encode_header(0x31, 0x02, 42, 1000, 800, 600, 256);
let decoded = decode_header(&encoded);
assert_eq!(decoded, vec![0x31, 0x02, 42, 1000, 800, 600, 256]);
}
#[test]
fn test_rle_roundtrip() {
let data = vec![0, 0, 0, 0, 0, 0x42, 0x43, 0, 0, 0];
let encoded = rle_encode(&data);
let decoded = rle_decode(&encoded);
assert_eq!(decoded, data);
}
#[test]
fn test_rle_compression_ratio() {
// 1000 zeros should compress to 3 bytes
let data = vec![0u8; 1000];
let encoded = rle_encode(&data);
assert_eq!(encoded.len(), 3);
let decoded = rle_decode(&encoded);
assert_eq!(decoded.len(), 1000);
}
#[test]
fn test_delta_roundtrip() {
let prev = vec![10, 20, 30, 40];
let curr = vec![10, 25, 30, 45];
let delta = compute_delta(&curr, &prev);
let reconstructed = apply_delta(&prev, &delta);
assert_eq!(reconstructed, curr);
}
#[test]
fn test_delta_rle_roundtrip() {
let prev = vec![0u8; 100];
let mut curr = vec![0u8; 100];
curr[50] = 0xFF;
curr[51] = 0xAB;
let compressed = encode_delta_rle(&curr, &prev);
assert!(compressed.len() < 100); // Should compress well
let reconstructed = decode_delta_rle(&compressed, &prev);
assert_eq!(reconstructed, curr);
}
#[test]
fn test_message_builder() {
let msg = signal_diff_message(1, 500, b"{\"count\":42}");
assert!(msg.len() == HEADER_SIZE + 12);
let header = decode_header(&msg);
assert_eq!(header[0], FRAME_SIGNAL_DIFF as u32);
assert_eq!(header[6], 12); // payload length
}
#[test]
fn test_input_message() {
let payload = vec![100u8, 0, 200, 0, 1]; // x=100, y=200, buttons=1
let msg = input_message(INPUT_POINTER, 0, 0, &payload);
let header = decode_header(&msg);
assert_eq!(header[0], INPUT_POINTER as u32);
assert_eq!(header[1], FLAG_INPUT as u32);
}
}