// picosky-comms.js // Extended bidirectional GPIO bridge (JS side) for picosky. // Mirrors the cart-side protocol in picosky.p8. See spec.md sec 3. // // Works in the browser (auto-creates `pico8_gpio` + `picoskyComms` and runs a // requestAnimationFrame loop) and under Node (module.exports) for testing. (function (root, factory) { const api = factory(); if (typeof module !== "undefined" && module.exports) { module.exports = api; // Node / test harness } if (typeof window !== "undefined") { // Browser: the cart's Emscripten module binds to this global. if (!window.pico8_gpio) window.pico8_gpio = new Array(128).fill(0); window.picoskyComms = api.createComms(window.pico8_gpio); window.picoskyProtocol = api; } })(this, function () { "use strict"; // --- byte (pin) map ------------------------------------------------------- const PINS = { TURN: 0, STATE: 1, FID: 2, // frame id (1-based) FLEN: 3, // frame count DLEN: 4, // data bytes in this frame OP: 5, // opcode ARG: 6, // small int arg DATA: 7, // first data byte }; const DATA_BYTES = 119; const TURN = { CART: 0, JS: 1 }; // --- opcodes -------------------------------------------------------------- const OP = { // cart -> js REQ_TIMELINE: 0x10, REQ_THREAD: 0x11, REQ_MORE: 0x12, REQ_DEMO: 0x13, // start the demoscene (open firehose) REQ_DEMO_STOP: 0x14, // cart left the demoscene on its own (close firehose) POST_CREATE: 0x20, POST_REPLY: 0x21, LIKE: 0x22, // arg=refid, data "1"=like / "0"=unlike REPOST: 0x23, // arg=refid, data "1"=repost / "0"=unrepost POST_QUOTE: 0x24, // arg=refid (quoted post), data=comment text // js -> cart RESP_POST: 0x30, RESP_END: 0x31, RESP_ERROR: 0x32, RESP_OK: 0x33, RESP_AUTH_LOST: 0x34, RESP_IMAGE: 0x35, // pixelated image thumbnail (32x32, packed 4bpp) RESP_IMAGE_FAIL: 0x36, // image fetch failed; arg = refId RESP_AVATAR: 0x37, // pixelated avatar (8x8, packed 4bpp, 32 bytes) RESP_AVATAR_FAIL: 0x38, // avatar fetch failed; arg = refId RESP_FEEDS: 0x39, // subscribed feed names (FSEP-joined); idx 0 = following DEMO_STOP: 0x41, // leave the demoscene (esc pressed on the page) RESP_FIRE: 0x42, // raw firehose deltas: [posts,likes,reposts,follows,deletes] }; const FSEP = 255; // field separator const RSEP = 254; // record separator (reserved) // --- encoding ------------------------------------------------------------- // Outbound text -> bytes. Newline -> 0 (matches cart's chr(0) handling). // Non-ASCII is the caller's responsibility (normalize before this). function textToBytes(s) { const out = []; for (let i = 0; i < s.length; i++) { const ch = s[i]; if (ch === "\n") out.push(0); else { const code = s.charCodeAt(i); out.push(code > 253 ? 63 /* '?' */ : code); } } return out; } // Inbound bytes -> text (0 -> newline). Used to decode cart -> js commands. function bytesToText(bytes) { let s = ""; for (let i = 0; i < bytes.length; i++) { const b = bytes[i]; s += b === 0 ? "\n" : String.fromCharCode(b); } return s; } // Serialize an ordered list of post fields into a single byte array with // FSEP between fields. See spec.md sec 4. function serializeFields(fields) { const out = []; fields.forEach((f, idx) => { if (idx > 0) out.push(FSEP); const bytes = textToBytes(String(f == null ? "" : f)); for (const b of bytes) out.push(b); }); return out; } // --- comms instance ------------------------------------------------------- function createComms(gpio) { const c = { gpio, outq: [], // queued outbound frames inbuf: "", // assembled inbound text onCommand: null, // (op, arg, text) => void (set by shim) provideIdleFrame: null, // () => frame|null (set by shim for demoscene) getState: () => 0, }; // Queue an outbound message, split into <=119 byte frames. c.enqueue = function (op, arg, bytes) { bytes = bytes || []; const n = Math.max(1, Math.ceil(bytes.length / DATA_BYTES)); for (let i = 0; i < n; i++) { const chunk = bytes.slice(i * DATA_BYTES, (i + 1) * DATA_BYTES); c.outq.push({ op, arg: arg || 0, fid: i + 1, flen: n, data: chunk }); } }; // Convenience wrappers used by the shim. c.sendPost = (fields) => c.enqueue(OP.RESP_POST, 0, serializeFields(fields)); c.sendEnd = (hasMore) => c.enqueue(OP.RESP_END, hasMore ? 1 : 0, []); c.sendOk = () => c.enqueue(OP.RESP_OK, 0, []); c.sendError = (msg) => c.enqueue(OP.RESP_ERROR, 0, textToBytes(String(msg))); c.sendAuthLost = () => c.enqueue(OP.RESP_AUTH_LOST, 0, []); c.sendDemoStop = () => c.enqueue(OP.DEMO_STOP, 0, []); // Image thumbnail: refId in arg, pixel data packed 2px/byte (hi nibble first). // 32x32 image = 512 bytes, sent as ~5 frames of 119 bytes each. c.sendImage = (refId, pixelBytes) => c.enqueue(OP.RESP_IMAGE, refId & 0xff, Array.from(pixelBytes)); c.sendImageFail = (refId) => c.enqueue(OP.RESP_IMAGE_FAIL, refId & 0xff, []); // Avatar: 16x16 = 128 bytes packed 4bpp, split into ~2 frames by enqueue(). c.sendAvatar = (refId, pixelBytes) => c.enqueue(OP.RESP_AVATAR, refId & 0xff, Array.from(pixelBytes)); c.sendAvatarFail = (refId) => c.enqueue(OP.RESP_AVATAR_FAIL, refId & 0xff, []); // Subscribed feed names, ordered (idx 0 = following). FSEP-joined like posts; // the cart rebuilds its feed switcher from this single frame-set. c.sendFeeds = (names) => c.enqueue(OP.RESP_FEEDS, 0, serializeFields(names)); // Build a single-frame firehose update. Counts are raw bytes (NOT text); // the cart reads them straight from the data pins. Clamp 0..253 so they // never collide with the FSEP/RSEP byte values. (spec.md sec 4) c.makeFireFrame = function (counts, intensity) { const data = (counts || []).map((n) => Math.max(0, Math.min(253, n | 0))); return { op: OP.RESP_FIRE, arg: Math.max(0, Math.min(255, intensity | 0)), fid: 1, flen: 1, data, }; }; c.writeFrame = function (f) { gpio[PINS.OP] = f.op; gpio[PINS.ARG] = f.arg; gpio[PINS.FID] = f.fid; gpio[PINS.FLEN] = f.flen; gpio[PINS.DLEN] = f.data.length; for (let i = 0; i < f.data.length; i++) gpio[PINS.DATA + i] = f.data[i]; }; c.clearOut = function () { gpio[PINS.OP] = 0; gpio[PINS.ARG] = 0; gpio[PINS.FID] = 0; gpio[PINS.FLEN] = 0; gpio[PINS.DLEN] = 0; }; // Read whatever the cart left in the data region this turn. c.readFrame = function () { const op = gpio[PINS.OP] | 0; if (op === 0) return; const dlen = gpio[PINS.DLEN] | 0; const bytes = []; for (let i = 0; i < dlen; i++) bytes.push(gpio[PINS.DATA + i] | 0); c.inbuf += bytesToText(bytes); if ((gpio[PINS.FID] | 0) >= (gpio[PINS.FLEN] | 0)) { const full = c.inbuf; c.inbuf = ""; if (c.onCommand) c.onCommand(op, gpio[PINS.ARG] | 0, full); } }; // One turn of the half-duplex loop. Returns true if we acted. c.step = function () { if ((gpio[PINS.TURN] | 0) !== TURN.JS) return false; c.readFrame(); if (c.outq.length > 0) { c.writeFrame(c.outq.shift()); } else { // No queued request/response: let the demoscene stream a live frame. const idle = c.provideIdleFrame && c.provideIdleFrame(); if (idle) c.writeFrame(idle); else c.clearOut(); } gpio[PINS.STATE] = c.getState() | 0; gpio[PINS.TURN] = TURN.CART; return true; }; return c; } // --- browser rAF loop ----------------------------------------------------- function startLoop(comms) { function tick() { comms.step(); requestAnimationFrame(tick); } requestAnimationFrame(tick); } if (typeof window !== "undefined" && window.picoskyComms == null) { // (set up above before factory returns to window assignment path) } const api = { PINS, DATA_BYTES, TURN, OP, FSEP, RSEP, textToBytes, bytesToText, serializeFields, createComms, startLoop, }; // Kick the browser loop once the global is wired up. if (typeof window !== "undefined") { setTimeout(() => { if (window.picoskyComms) startLoop(window.picoskyComms); }, 0); } return api; });