Skip to main content

justerm_core/
serialize.rs

1//! Issue #6 — binary, reference-based wire format for a damage frame.
2//!
3//! `encode` a [`Frame`] to bytes, `decode` them back; the round-trip is the
4//! contract. Reference-based (colour refs, Unicode scalars — never resolved RGB
5//! or atlas ids) so the engine stays theme- and font-agnostic; the consumer's
6//! adapter resolves references before handing cells to the renderer. Format spec
7//! and rationale: `docs/architecture.md` §Serialization + ADR-0005.
8
9use crate::cell::{Cell, CellFlags};
10use crate::color::Color;
11use crate::cursor::CursorShape;
12use crate::damage::ScrollOp;
13use core::num::NonZeroU32;
14use std::collections::BTreeMap;
15
16/// Wire magic ("juSTerm") + format version. A new feature bumps `VERSION`.
17const MAGIC: [u8; 2] = *b"JT";
18const VERSION: u8 = 4; // v4 adds the cursor shape + blink (#81); v3 added cursor row/col/visibility (#38)
19
20/// The wire-format version (the gating `VERSION` byte), exposed so a binding can
21/// assert at load that its decoder matches the backend encoder (#34/ADR-0008).
22pub const WIRE_VERSION: u8 = VERSION;
23
24/// Whether a frame redraws everything or just its spans.
25#[derive(Clone, Copy, PartialEq, Eq, Debug)]
26pub enum FrameKind {
27    /// Every row is present (resize / alt-screen clear).
28    Full,
29    /// Only the listed spans changed since the consumer's ack.
30    Partial,
31}
32
33/// A damaged column run on one line, with its cells.
34///
35/// `combining` and `links` map a span-relative column to its frame-local index
36/// (1-based) — `combining` into [`Frame::side_table`], `links` into
37/// [`Frame::link_table`]. These are the per-cell `extra`/`link` references lifted
38/// out of the cell now that combining clusters (#45) and hyperlinks (#46) live in
39/// per-row maps. A column is present iff its cell carries the matching bit; on the
40/// wire they are the cell record's `extra`/`link` fields, so the bytes are
41/// unchanged.
42#[derive(Clone, PartialEq, Eq, Debug)]
43pub struct Span {
44    pub line: u16,
45    pub left: u16,
46    pub right: u16,
47    pub cells: Vec<Cell>,
48    pub combining: BTreeMap<usize, NonZeroU32>,
49    pub links: BTreeMap<usize, NonZeroU32>,
50}
51
52/// One serialized damage cycle: the decoded logical form that `encode`/`decode`
53/// round-trip. `side_table` holds this frame's grapheme clusters (referenced by
54/// each cell's frame-local `extra`); `link_table` holds its OSC 8 hyperlink URIs
55/// (referenced by each cell's frame-local `link`).
56#[derive(Clone, PartialEq, Eq, Debug)]
57pub struct Frame {
58    pub cols: u16,
59    pub rows: u16,
60    pub kind: FrameKind,
61    /// Cursor row/col in screen coordinates (0-based), and whether the engine
62    /// shows it (DECTCEM). Rides in the header because the cursor moves with
63    /// almost every frame (#38). *Drawing* the cursor — cell-invert / overlay —
64    /// stays the consumer's renderer adapter; the engine only reports state.
65    pub cursor_row: u16,
66    pub cursor_col: u16,
67    pub cursor_visible: bool,
68    /// The caret shape (DECSCUSR #89) and whether it blinks (att610 ?12, #81).
69    /// Reported for the renderer; drawing/animation stays the consumer's.
70    pub cursor_shape: CursorShape,
71    pub cursor_blink: bool,
72    pub scroll: Option<ScrollOp>,
73    pub spans: Vec<Span>,
74    pub side_table: Vec<Vec<char>>,
75    pub link_table: Vec<String>,
76}
77
78/// Why a byte buffer could not be decoded into a [`Frame`].
79#[derive(Clone, Copy, PartialEq, Eq, Debug)]
80pub enum DecodeError {
81    /// Ran out of bytes mid-field.
82    Truncated,
83    /// First two bytes are not the wire magic.
84    BadMagic,
85    /// Unsupported format version.
86    BadVersion(u8),
87    /// A tag/kind byte held a value outside its defined set.
88    BadTag,
89    /// A span's `left` was past its `right` (would underflow the cell count).
90    BadSpan,
91}
92
93/// Serialize a frame to the binary wire format.
94pub fn encode(frame: &Frame) -> Vec<u8> {
95    let mut out = Vec::new();
96    out.extend_from_slice(&MAGIC);
97    out.push(VERSION);
98    out.push(frame.scroll.is_some() as u8);
99    out.push(match frame.kind {
100        FrameKind::Full => 0,
101        FrameKind::Partial => 1,
102    });
103    out.extend_from_slice(&frame.cols.to_le_bytes());
104    out.extend_from_slice(&frame.rows.to_le_bytes());
105    out.extend_from_slice(&frame.cursor_row.to_le_bytes());
106    out.extend_from_slice(&frame.cursor_col.to_le_bytes());
107    out.push(frame.cursor_visible as u8);
108    out.push(match frame.cursor_shape {
109        CursorShape::Block => 0,
110        CursorShape::Underline => 1,
111        CursorShape::Bar => 2,
112    });
113    out.push(frame.cursor_blink as u8);
114    if let Some(s) = frame.scroll {
115        out.extend_from_slice(&(s.top as u16).to_le_bytes());
116        out.extend_from_slice(&(s.bottom as u16).to_le_bytes());
117        out.extend_from_slice(&(s.count as i16).to_le_bytes());
118    }
119    out.extend_from_slice(&(frame.spans.len() as u16).to_le_bytes());
120    for span in &frame.spans {
121        out.extend_from_slice(&span.line.to_le_bytes());
122        out.extend_from_slice(&span.left.to_le_bytes());
123        out.extend_from_slice(&span.right.to_le_bytes());
124        for (col, cell) in span.cells.iter().enumerate() {
125            // The grapheme and hyperlink indices now ride on the span (per
126            // column), not the cell.
127            let extra = span.combining.get(&col).map_or(0, |n| n.get() as u16);
128            let link = span.links.get(&col).map_or(0, |n| n.get() as u16);
129            out.extend_from_slice(&encode_cell_record(cell, extra, link));
130        }
131    }
132    out.extend_from_slice(&(frame.side_table.len() as u16).to_le_bytes());
133    for cluster in &frame.side_table {
134        out.extend_from_slice(&(cluster.len() as u16).to_le_bytes());
135        for &ch in cluster {
136            out.extend_from_slice(&(ch as u32).to_le_bytes());
137        }
138    }
139    // Hyperlink side-table: each URI as a length-prefixed UTF-8 byte run (#26).
140    out.extend_from_slice(&(frame.link_table.len() as u16).to_le_bytes());
141    for uri in &frame.link_table {
142        out.extend_from_slice(&(uri.len() as u16).to_le_bytes());
143        out.extend_from_slice(uri.as_bytes());
144    }
145    out
146}
147
148/// Length in bytes of one fixed-width wire cell record (see
149/// [`encode_cell_record`]).
150pub const CELL_RECORD_LEN: usize = 18;
151
152/// Encode one [`Cell`] to its fixed 18-byte little-endian record:
153/// `c` u32 (Unicode scalar) · `fg` u32 · `bg` u32 · `flags` u16 · `extra` u16
154/// (frame-local grapheme index, 0 = none) · `link` u16 (frame-local hyperlink
155/// index, 0 = none). Width derives from `flags`.
156///
157/// `extra` and `link` are passed in rather than read from the cell: combining
158/// clusters (#45) and hyperlinks (#46) now live in per-row maps, so both indices
159/// ride on the [`Span`], not the cell. The wire bytes are unchanged.
160///
161/// This is the single definition of the cell record layout — [`encode`] writes
162/// it per span cell, and an alternate consumer (the WASM decoder, #34/ADR-0008)
163/// reuses it to lay decoded cells out flat without re-implementing the layout,
164/// so the two cannot drift.
165pub fn encode_cell_record(cell: &Cell, extra: u16, link: u16) -> [u8; CELL_RECORD_LEN] {
166    let mut r = [0u8; CELL_RECORD_LEN];
167    r[0..4].copy_from_slice(&(cell.c() as u32).to_le_bytes());
168    r[4..8].copy_from_slice(&encode_color(cell.fg()).to_le_bytes());
169    r[8..12].copy_from_slice(&encode_color(cell.bg()).to_le_bytes());
170    r[12..14].copy_from_slice(&cell.flags().bits().to_le_bytes());
171    r[14..16].copy_from_slice(&extra.to_le_bytes());
172    r[16..18].copy_from_slice(&link.to_le_bytes());
173    r
174}
175
176/// A colour reference as a tagged u32: high byte = tag
177/// (0 = Default, 1 = Indexed, 2 = Rgb), low 24 bits = payload. The tag is
178/// mandatory so `Default`, `Indexed(0)`, and `Rgb(0,0,0)` stay distinct.
179///
180/// Public so an alternate consumer (the WASM decoder's structure-of-arrays
181/// `fg`/`bg` columns, #35) reuses this single definition of the colour-ref
182/// encoding instead of re-implementing the tag packing — no drift.
183pub fn encode_color(c: Color) -> u32 {
184    match c {
185        Color::Default => 0,
186        Color::Indexed(i) => (1 << 24) | i as u32,
187        Color::Rgb(r, g, b) => (2 << 24) | (r as u32) << 16 | (g as u32) << 8 | b as u32,
188    }
189}
190
191/// Deserialize the binary wire format back into a [`Frame`].
192pub fn decode(bytes: &[u8]) -> Result<Frame, DecodeError> {
193    let mut r = Reader::new(bytes);
194    if r.take(2)? != MAGIC {
195        return Err(DecodeError::BadMagic);
196    }
197    let version = r.u8()?;
198    if version != VERSION {
199        return Err(DecodeError::BadVersion(version));
200    }
201    let has_scroll = r.u8()? != 0;
202    let kind = match r.u8()? {
203        0 => FrameKind::Full,
204        1 => FrameKind::Partial,
205        _ => return Err(DecodeError::BadTag),
206    };
207    let cols = r.u16()?;
208    let rows = r.u16()?;
209    let cursor_row = r.u16()?;
210    let cursor_col = r.u16()?;
211    let cursor_visible = r.u8()? != 0;
212    let cursor_shape = match r.u8()? {
213        0 => CursorShape::Block,
214        1 => CursorShape::Underline,
215        2 => CursorShape::Bar,
216        _ => return Err(DecodeError::BadTag),
217    };
218    let cursor_blink = r.u8()? != 0;
219    let scroll = if has_scroll {
220        let top = r.u16()? as usize;
221        let bottom = r.u16()? as usize;
222        let count = (r.u16()? as i16) as isize;
223        Some(ScrollOp { top, bottom, count })
224    } else {
225        None
226    };
227    let span_count = r.u16()?;
228    let mut spans = Vec::with_capacity(span_count as usize);
229    for _ in 0..span_count {
230        let line = r.u16()?;
231        let left = r.u16()?;
232        let right = r.u16()?;
233        if right < left {
234            return Err(DecodeError::BadSpan);
235        }
236        // Widen before the arithmetic: `right - left + 1` in `u16` overflows
237        // when `right == u16::MAX` (e.g. left=0, right=65535), panicking under
238        // overflow checks. `right >= left` is enforced just above, so the
239        // subtraction in `usize` cannot underflow.
240        let n = right as usize - left as usize + 1;
241        let mut cells = Vec::with_capacity(n);
242        let mut combining = BTreeMap::new();
243        let mut links = BTreeMap::new();
244        for col in 0..n {
245            let (cell, extra, link) = decode_cell(&mut r)?;
246            if let Some(idx) = NonZeroU32::new(extra as u32) {
247                combining.insert(col, idx);
248            }
249            if let Some(idx) = NonZeroU32::new(link as u32) {
250                links.insert(col, idx);
251            }
252            cells.push(cell);
253        }
254        spans.push(Span {
255            line,
256            left,
257            right,
258            cells,
259            combining,
260            links,
261        });
262    }
263    let side_table_count = r.u16()?;
264    let mut side_table = Vec::with_capacity(side_table_count as usize);
265    for _ in 0..side_table_count {
266        let len = r.u16()?;
267        let mut cluster = Vec::with_capacity(len as usize);
268        for _ in 0..len {
269            cluster.push(char::from_u32(r.u32()?).ok_or(DecodeError::BadTag)?);
270        }
271        side_table.push(cluster);
272    }
273    let link_count = r.u16()?;
274    let mut link_table = Vec::with_capacity(link_count as usize);
275    for _ in 0..link_count {
276        let len = r.u16()? as usize;
277        let bytes = r.take(len)?;
278        link_table.push(String::from_utf8_lossy(bytes).into_owned());
279    }
280    Ok(Frame {
281        cols,
282        rows,
283        kind,
284        cursor_row,
285        cursor_col,
286        cursor_visible,
287        cursor_shape,
288        cursor_blink,
289        scroll,
290        spans,
291        side_table,
292        link_table,
293    })
294}
295
296/// Decode one 18-byte cell record (inverse of [`encode_cell_record`]), returning
297/// the cell and its raw `extra` grapheme index and `link` index (0 = none). A
298/// non-zero index sets the corresponding presence bit; the caller records the
299/// indices on the span.
300fn decode_cell(r: &mut Reader) -> Result<(Cell, u16, u16), DecodeError> {
301    let c = char::from_u32(r.u32()?).ok_or(DecodeError::BadTag)?;
302    let fg = decode_color(r.u32()?)?;
303    let bg = decode_color(r.u32()?)?;
304    let flags = CellFlags::from_bits_retain(r.u16()?);
305    let extra = r.u16()?;
306    let link = r.u16()?;
307    let mut cell = Cell::from_parts(c, fg, bg, flags);
308    cell.set_combined(extra != 0);
309    cell.set_linked(link != 0);
310    Ok((cell, extra, link))
311}
312
313/// Decode a tagged-u32 colour reference (inverse of [`encode_color`]).
314fn decode_color(v: u32) -> Result<Color, DecodeError> {
315    let payload = v & 0x00FF_FFFF;
316    match v >> 24 {
317        0 => Ok(Color::Default),
318        1 => Ok(Color::Indexed(payload as u8)),
319        2 => Ok(Color::Rgb(
320            (payload >> 16) as u8,
321            (payload >> 8) as u8,
322            payload as u8,
323        )),
324        _ => Err(DecodeError::BadTag),
325    }
326}
327
328/// A little-endian cursor over the wire bytes.
329struct Reader<'a> {
330    bytes: &'a [u8],
331    pos: usize,
332}
333
334impl<'a> Reader<'a> {
335    fn new(bytes: &'a [u8]) -> Self {
336        Reader { bytes, pos: 0 }
337    }
338
339    fn take(&mut self, n: usize) -> Result<&'a [u8], DecodeError> {
340        let end = self.pos.checked_add(n).ok_or(DecodeError::Truncated)?;
341        let slice = self
342            .bytes
343            .get(self.pos..end)
344            .ok_or(DecodeError::Truncated)?;
345        self.pos = end;
346        Ok(slice)
347    }
348
349    fn u8(&mut self) -> Result<u8, DecodeError> {
350        Ok(self.take(1)?[0])
351    }
352
353    fn u16(&mut self) -> Result<u16, DecodeError> {
354        let b = self.take(2)?;
355        Ok(u16::from_le_bytes([b[0], b[1]]))
356    }
357
358    fn u32(&mut self) -> Result<u32, DecodeError> {
359        let b = self.take(4)?;
360        Ok(u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
361    }
362}