From 968d62d0bb77cfcb241a3072f62342566da63547 Mon Sep 17 00:00:00 2001 From: enzotar Date: Wed, 25 Feb 2026 11:06:42 -0800 Subject: [PATCH] feat(demos): sync protocol with Rust codec, add touch/gamepad support - RLE format: 2-byte LE count (matches Rust rle_encode/rle_decode) - Delta frames: FLAG_COMPRESSED flag set correctly - Signal sync: FLAG_KEYFRAME flag + periodic sync every ~5s for late-join - Source: handle touch, gamepad axis/button, resize inputs from receiver - Receiver: touch event capture for mobile, ping frame handling - Protocol constants: added FRAME_PING, INPUT_TOUCH, INPUT_GAMEPAD_* --- examples/stream-receiver.html | 49 +++++++++++++++++++++++++++++---- examples/stream-source.html | 52 +++++++++++++++++++++++++++++------ 2 files changed, 87 insertions(+), 14 deletions(-) diff --git a/examples/stream-receiver.html b/examples/stream-receiver.html index 83d398f..12aaa19 100644 --- a/examples/stream-receiver.html +++ b/examples/stream-receiver.html @@ -361,9 +361,12 @@ const HEADER_SIZE = 16; const FRAME_PIXELS = 0x01, FRAME_DELTA = 0x03, FRAME_AUDIO = 0x10; const FRAME_SIGNAL_SYNC = 0x30, FRAME_SIGNAL_DIFF = 0x31, FRAME_NEURAL = 0x40; + const FRAME_PING = 0xFE; const INPUT_POINTER = 0x01, INPUT_PTR_DOWN = 0x02, INPUT_PTR_UP = 0x03; - const INPUT_KEY_DOWN = 0x10, INPUT_KEY_UP = 0x11, INPUT_SCROLL = 0x50; - const FLAG_INPUT = 0x01; + const INPUT_KEY_DOWN = 0x10, INPUT_KEY_UP = 0x11; + const INPUT_TOUCH = 0x20, INPUT_TOUCH_END = 0x21; + const INPUT_SCROLL = 0x50; + const FLAG_INPUT = 0x01, FLAG_KEYFRAME = 0x02, FLAG_COMPRESSED = 0x04; const canvas = document.getElementById('display'); const ctx = canvas.getContext('2d'); @@ -434,14 +437,15 @@ } // ── Feature 1: Delta decompression ── + // RLE decompression matching Rust codec: 0x00 + 2-byte LE count function rleDecompress(compressed, expectedLen) { const out = new Uint8Array(expectedLen); let ci = 0, oi = 0; while (ci < compressed.length && oi < expectedLen) { - if (compressed[ci] === 0 && ci + 1 < compressed.length) { - const run = compressed[ci + 1]; + if (compressed[ci] === 0 && ci + 2 < compressed.length) { + const run = compressed[ci + 1] | (compressed[ci + 2] << 8); for (let j = 0; j < run && oi < expectedLen; j++) out[oi++] = 0; - ci += 2; + ci += 3; } else { out[oi++] = compressed[ci++]; } @@ -561,6 +565,10 @@ busBytes.audio += e.data.byteLength; break; } + + case FRAME_PING: + // Keepalive — ignore + return; } framesRecv++; @@ -628,6 +636,37 @@ busBytes.scroll += HEADER_SIZE + 4; }, { passive: false }); + // Touch — for mobile receivers + function touchPayload(id, x, y, phase) { + const b = new ArrayBuffer(6), v = new DataView(b); + v.setUint8(0, id); v.setUint16(1, x, true); v.setUint16(3, y, true); v.setUint8(5, phase); + return b; + } + canvas.addEventListener('touchstart', e => { + e.preventDefault(); + const r = canvas.getBoundingClientRect(); + for (const t of e.changedTouches) { + const x = Math.round(t.clientX - r.left), y = Math.round(t.clientY - r.top); + sendInput(encodeInput(INPUT_TOUCH, touchPayload(t.identifier & 0xFF, x, y, 0))); + busBytes.pointer += HEADER_SIZE + 6; + } + }, { passive: false }); + canvas.addEventListener('touchmove', e => { + e.preventDefault(); + const r = canvas.getBoundingClientRect(); + for (const t of e.changedTouches) { + const x = Math.round(t.clientX - r.left), y = Math.round(t.clientY - r.top); + sendInput(encodeInput(INPUT_TOUCH, touchPayload(t.identifier & 0xFF, x, y, 0))); + busBytes.pointer += HEADER_SIZE + 6; + } + }, { passive: false }); + canvas.addEventListener('touchend', e => { + for (const t of e.changedTouches) { + sendInput(encodeInput(INPUT_TOUCH_END, touchPayload(t.identifier & 0xFF, 0, 0, 1))); + busBytes.pointer += HEADER_SIZE + 6; + } + }); + // ── Stats ── setInterval(() => { const now = performance.now(), elapsed = (now - lastSecTime) / 1000; diff --git a/examples/stream-source.html b/examples/stream-source.html index f592c87..24c8e47 100644 --- a/examples/stream-source.html +++ b/examples/stream-source.html @@ -371,9 +371,13 @@ const HEADER_SIZE = 16; const FRAME_PIXELS = 0x01, FRAME_DELTA = 0x03, FRAME_AUDIO = 0x10; const FRAME_SIGNAL_SYNC = 0x30, FRAME_SIGNAL_DIFF = 0x31, FRAME_NEURAL = 0x40; + const FRAME_PING = 0xFE, FRAME_END = 0xFF; const INPUT_POINTER = 0x01, INPUT_PTR_DOWN = 0x02, INPUT_PTR_UP = 0x03; - const INPUT_KEY_DOWN = 0x10, INPUT_KEY_UP = 0x11, INPUT_SCROLL = 0x50; - const FLAG_INPUT = 0x01, FLAG_KEYFRAME = 0x02; + const INPUT_KEY_DOWN = 0x10, INPUT_KEY_UP = 0x11; + const INPUT_TOUCH = 0x20, INPUT_TOUCH_END = 0x21; + const INPUT_GAMEPAD_AXIS = 0x30, INPUT_GAMEPAD_BUTTON = 0x31; + const INPUT_SCROLL = 0x50, INPUT_RESIZE = 0x60; + const FLAG_INPUT = 0x01, FLAG_KEYFRAME = 0x02, FLAG_COMPRESSED = 0x04; function encodeHeader(type, flags, seq, ts, w, h, len) { const b = new ArrayBuffer(HEADER_SIZE), v = new DataView(b); @@ -624,6 +628,29 @@ ballR.value = Math.max(10, Math.min(80, ballR._signal._value + dy * 0.1)); } break; + case INPUT_TOUCH: + if (payload.length >= 6) { + const tx = view.getUint16(1, true), ty = view.getUint16(3, true); + const phase = payload[5]; + if (phase === 0) handlePointer(tx, ty, 'down'); + else handlePointer(tx, ty, 'move'); + } + break; + case INPUT_TOUCH_END: + handlePointer(0, 0, 'up'); + break; + case INPUT_GAMEPAD_AXIS: + if (payload.length >= 3) { + const axis = payload[0], val = view.getInt16(1, true) / 32768; + if (axis === 0) { targetX.value = 300 + val * 250; ballX.value = targetX._value; } + if (axis === 1) { targetY.value = 200 + val * 150; ballY.value = targetY._value; } + } + break; + case INPUT_GAMEPAD_BUTTON: + if (payload.length >= 3 && payload[1] === 1) { + ballR.value = 70; setTimeout(() => ballR.value = 25, 250); + } + break; } } @@ -690,7 +717,7 @@ // Delta is worthwhile — compress by RLE-encoding the zero runs const compressed = rleCompress(delta); prevFrame = new Uint8Array(pixels); - const header = encodeHeader(FRAME_DELTA, 0, seq & 0xFFFF, ts, W, H, compressed.length); + const header = encodeHeader(FRAME_DELTA, FLAG_COMPRESSED, seq & 0xFFFF, ts, W, H, compressed.length); const msg = new Uint8Array(HEADER_SIZE + compressed.length); msg.set(header, 0); msg.set(compressed, HEADER_SIZE); return msg; @@ -705,15 +732,20 @@ return msg; } - // Simple RLE for delta frames: encode runs of zeros compactly + // RLE compression matching Rust codec: 0x00 + 2-byte LE count for zero runs function rleCompress(data) { const out = []; let i = 0; while (i < data.length) { if (data[i] === 0) { let run = 0; - while (i < data.length && data[i] === 0 && run < 255) { run++; i++; } - out.push(0, run); // 0x00 followed by run length + while (i < data.length && data[i] === 0) { run++; i++; } + // Emit run in chunks of max 65535 + while (run > 0) { + const chunk = Math.min(run, 65535); + out.push(0, chunk & 0xFF, (chunk >> 8) & 0xFF); + run -= chunk; + } } else { out.push(data[i]); i++; } @@ -737,11 +769,13 @@ const json = JSON.stringify(state); const payload = new TextEncoder().encode(json); - // Send full sync or diff - const frameType = prevSignalState ? FRAME_SIGNAL_DIFF : FRAME_SIGNAL_SYNC; + // Send full sync (with FLAG_KEYFRAME) or diff + const isSync = !prevSignalState || (framesSent % 150 === 0); // sync every ~5s at 30fps + const frameType = isSync ? FRAME_SIGNAL_SYNC : FRAME_SIGNAL_DIFF; + const flags = isSync ? FLAG_KEYFRAME : 0; prevSignalState = state; - const header = encodeHeader(frameType, 0, seq & 0xFFFF, ts, W, H, payload.length); + const header = encodeHeader(frameType, flags, seq & 0xFFFF, ts, W, H, payload.length); const msg = new Uint8Array(HEADER_SIZE + payload.length); msg.set(header, 0); msg.set(payload, HEADER_SIZE); return msg;