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 crate::input::MouseEvents;
14use crate::selection::SelectionSpan;
15use core::num::NonZeroU32;
16use std::collections::BTreeMap;
17
18/// Wire magic ("juSTerm") + format version. A new feature bumps `VERSION`.
19const MAGIC: [u8; 2] = *b"JT";
20const VERSION: u8 = 14; // v14 moves combining clusters and hyperlink refs off the fixed cell record (18 B -> 14 B) into per-span sparse groups, inlining the cluster (no side-table) but keeping the URI table interned, and widens every count/length prefix they use to u32 — the engine could hold a cluster, a URI, or a viewport its own decoder then rejected or, worse, mis-read as Ok (#621); v13 adds a per-span underline-colour group: sparse (col, Color) pairs for cells drawing a coloured underline (SGR 58, #520); v12 adds a fifth overlay group: the consumer-designated active search match's spans (#428); v11 adds a fourth overlay group: every live marker's absolute buffer line for the overview ruler (#120 S3); v10 adds a marker kind discriminant + optional i32 exit to the overlay marker group (#159); v9 adds the alt-screen flag in the header (#149); v8 adds the mouse wanted-events mask in the header (#129/ADR-0016); v7 overlay marker group (#118/ADR-0015); v6 overlay selection + search-match spans (#108/ADR-0014); v5 scroll position (#112/ADR-0013); v4 cursor shape+blink (#81); v3 cursor row/col/visibility (#38)
21
22/// The wire-format version (the gating `VERSION` byte), exposed so a binding can
23/// assert at load that its decoder matches the backend encoder (#34/ADR-0008).
24pub const WIRE_VERSION: u8 = VERSION;
25
26/// Whether a frame redraws everything or just its spans.
27#[derive(Clone, Copy, PartialEq, Eq, Debug)]
28pub enum FrameKind {
29    /// Every row is present (resize / alt-screen clear).
30    Full,
31    /// Only the listed spans changed since the consumer's ack.
32    Partial,
33}
34
35/// A damaged column run on one line, with its cells.
36///
37/// `combining` and `links` map a span-relative column to what that cell carries —
38/// combining clusters (#45) and hyperlinks (#46) live in per-row maps, so neither
39/// rides the cell. Since v14 (#621) both are **sparse wire groups of their own**,
40/// not indices in the cell record, which is what removed the `u16` ceilings the
41/// engine could legitimately exceed.
42///
43/// The two are deliberately **not** symmetric, and the asymmetry is measured rather
44/// than stylistic:
45///
46/// - `combining` holds the cluster **inline**. `Term::frame` pushes one entry per
47///   combining cell with no interning, so an index bought nothing but a level of
48///   indirection and a table to count. Inlining is size-neutral (measured: −0.5% on
49///   a combining-heavy frame) and buys the deletion of both.
50/// - `links` holds a **1-based index into [`Frame::link_table`]**, because that
51///   table *is* interned (`Term::frame`'s `link_remap` ships each referenced URI
52///   once). Inlining a URI at every linked cell was measured at +171…403% on
53///   link-dense frames, and all three references share one copy across cells —
54///   ghostty ref-counts its hyperlink set explicitly *"so that a set of cells can
55///   share the same hyperlink without duplicating the data"*, xterm.js keys cells to
56///   an `OscLinkService` id, alacritty holds `Arc<HyperlinkInner>`.
57///
58/// A column is present in either map iff its cell carries the matching bit — and,
59/// as with `ucolors` below, that bit does **not** travel on the wire (the record
60/// encodes `cell.c()` and `encode_color(bg)`, which drop `C_COMBINED` and
61/// `LINK_PRESENT` respectively). `decode` re-arms both from these maps' own entries.
62/// A `Span` built by hand for a test owes the same pairing.
63#[derive(Clone, PartialEq, Eq, Debug)]
64pub struct Span {
65    pub line: u16,
66    pub left: u16,
67    pub right: u16,
68    pub cells: Vec<Cell>,
69    pub combining: BTreeMap<usize, Vec<char>>,
70    pub links: BTreeMap<usize, NonZeroU32>,
71    /// Underline colours (SGR 58, #520): span-relative column → the `Color`
72    /// reference the cell's coloured underline draws in. Sparse — only cells that
73    /// carry a non-default underline colour appear (gated on the `UNDERLINE`
74    /// attribute at parse time). Unlike `combining`/`links` this is a colour
75    /// reference, not a side-table index, so it ships inline (no `_table` on the
76    /// [`Frame`]). Kept off the per-cell record so a plain-text frame pays nothing
77    /// (ADR-0020: no inert per-cell payload).
78    ///
79    /// Like `combining` and `links`, a column here is present iff its cell carries
80    /// the matching bit ([`Cell::is_ucolored`]) — but that bit does **not** travel on
81    /// the wire (`encode_color` keeps only mode+value, and `CellFlags` holds no
82    /// presence bits), so `decode` re-arms it from this map's own entries. A `Span`
83    /// built by hand for a test owes the same pairing: an entry here without
84    /// [`Cell::set_ucolored`] on the cell is a column the gated readers cannot see.
85    /// (#531)
86    pub ucolors: BTreeMap<usize, Color>,
87}
88
89/// A stable handle to a buffer line, handed out by `Engine::add_marker` (#118).
90/// Monotonic per engine. The consumer attaches a decoration to the id; the frame
91/// reports where the marker currently sits, and `TermEvent::MarkerDisposed`
92/// signals when its line has left the buffer.
93#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
94pub struct MarkerId(pub u32);
95
96/// What a marker means (#158). A plain `add_marker` decoration carries no
97/// semantics ([`MarkerKind::Plain`]); OSC 133 shell-integration marks carry the
98/// command-boundary role (prompt/command/output start, or command finished with
99/// its optional exit code). The engine only *parses and anchors* these — the
100/// success/failure colour, earcon and prompt-to-prompt navigation are consumer
101/// policy (ADR-0017), driven off the kind + exit the wire (#159) carries.
102#[derive(Clone, Copy, PartialEq, Eq, Debug)]
103pub enum MarkerKind {
104    /// A `add_marker` decoration anchor (#118) — no OSC-133 semantics.
105    Plain,
106    /// OSC `133;A` — the shell prompt begins here.
107    PromptStart,
108    /// OSC `133;B` — the typed command begins here (the prompt ended).
109    CommandStart,
110    /// OSC `133;C` — the command was submitted; its output begins here.
111    OutputStart,
112    /// OSC `133;D[;exit]` — the command finished, with its exit code if reported
113    /// (absent, empty or non-numeric → `None`).
114    CommandFinished(Option<i32>),
115}
116
117/// A marker projected onto the viewport (#118): its id, the row it sits on, and
118/// its kind (#159). Only markers visible in the current viewport are reported; an
119/// off-screen marker is omitted but still alive (death comes via `MarkerDisposed`,
120/// not absence — so the consumer can tell "scrolled away" from "gone"). The kind
121/// carries the OSC 133 command-boundary role + exit code so the consumer can drive
122/// prompt-to-prompt navigation and success/fail signals (#160).
123#[derive(Clone, Copy, PartialEq, Eq, Debug)]
124pub struct MarkerPosition {
125    pub id: MarkerId,
126    pub row: usize,
127    pub kind: MarkerKind,
128}
129
130/// A marker's absolute buffer line (#120 S3, v11). Unlike [`MarkerPosition`],
131/// this is reported for EVERY live marker — on-screen or not — so a frame-mode
132/// consumer can place overview-ruler marks buffer-relatively (dividing by
133/// `scrollback + rows`), the whole point of a ruler being to show off-viewport
134/// anchors. The consumer joins `id` with its decoration registry; the ruler mark's
135/// colour is the consumer's (theme-agnostic), so no kind/exit rides here.
136#[derive(Clone, Copy, PartialEq, Eq, Debug)]
137pub struct MarkerLine {
138    pub id: MarkerId,
139    /// Absolute buffer line, in the same `[0, scrollback_len + rows)` frame the
140    /// header's `scrollback_len`/`display_offset` use.
141    pub line: u32,
142}
143
144/// Interaction overlays projected onto the viewport (#108): highlight spans the
145/// engine carries on the frame so a frame-mode consumer can paint them without
146/// an in-process model query. Positions only — highlight colour is the
147/// consumer's (theme-agnostic). Coordinates are viewport rows/cols, re-projected
148/// by `frame()` against the scroll offset so the engine stays the single
149/// anchoring authority.
150#[derive(Clone, PartialEq, Eq, Debug, Default)]
151pub struct Overlay {
152    /// The live selection projected onto visible rows (`selection_range`).
153    pub selection: Vec<SelectionSpan>,
154    /// The search highlights projected onto visible rows. Search matches
155    /// are consumer-owned (next/prev navigation holds the `Vec<Match>`), so the
156    /// consumer hands the highlight set back via `set_search_highlights` and the
157    /// engine projects it here — mirroring how the engine-owned selection rides.
158    pub matches: Vec<SelectionSpan>,
159    /// Engine-owned markers visible in this viewport (#118): persistent line
160    /// anchors for decorations. Unlike the selection (cleared on a screen swap)
161    /// and search highlights (invalidated on output), markers re-anchor through
162    /// buffer mutation and survive an alt-screen excursion; only their viewport
163    /// position rides here.
164    pub markers: Vec<MarkerPosition>,
165    /// Every live marker's absolute buffer line (#120 S3, v11), on-screen or not —
166    /// the overview ruler needs off-viewport anchors, which `markers` (viewport-
167    /// only) can't supply. A superset of `markers` by id; different frame of
168    /// reference (absolute line, not viewport row).
169    pub marker_lines: Vec<MarkerLine>,
170    /// The *active* (current) search match's spans (#428, v12): the member of
171    /// `matches` the consumer designated via `set_active_search_highlight`
172    /// (which match is active is consumer policy — next/prev navigation).
173    /// Projected by the same mechanism as `matches`, and *also* present there —
174    /// the renderer's highlight ranking resolves the overlap (#424), not
175    /// exclusion here. Empty when nothing is designated.
176    pub active_match: Vec<SelectionSpan>,
177}
178
179/// One serialized damage cycle: the decoded logical form that `encode`/`decode`
180/// round-trip. `link_table` holds this frame's OSC 8 hyperlink URIs, each shipped
181/// once and referenced by [`Span::links`]. Grapheme clusters have **no** table —
182/// since v14 (#621) they are inlined at their column in [`Span::combining`],
183/// because nothing interned them and the table only bought an index to overflow.
184#[derive(Clone, PartialEq, Eq, Debug)]
185pub struct Frame {
186    pub cols: u16,
187    pub rows: u16,
188    pub kind: FrameKind,
189    /// Cursor row/col in screen coordinates (0-based), and whether the engine
190    /// shows it (DECTCEM). Rides in the header because the cursor moves with
191    /// almost every frame (#38). *Drawing* the cursor — cell-invert / overlay —
192    /// stays the consumer's renderer adapter; the engine only reports state.
193    pub cursor_row: u16,
194    pub cursor_col: u16,
195    pub cursor_visible: bool,
196    /// The caret shape (DECSCUSR #89) and whether it blinks (att610 ?12, #81).
197    /// Reported for the renderer; drawing/animation stays the consumer's.
198    pub cursor_shape: CursorShape,
199    pub cursor_blink: bool,
200    /// Viewport scroll position (#112 / ADR-0013), for the consumer's scrollbar.
201    /// `display_offset` = lines scrolled up from the bottom (0 = following the
202    /// live screen); `scrollback_len` = history lines (total = `+ rows`). Ride in
203    /// the header like the cursor — per-frame viewport state, not cell content.
204    pub display_offset: u32,
205    pub scrollback_len: u32,
206    /// The mouse tracking mode as a *wanted-events* mask (#129): which mouse
207    /// event categories the app asked to receive, so the consumer routes an event
208    /// to the app (bit set) or keeps it local. `empty()` = no reporting. Rides the
209    /// header like the cursor — per-frame mode state the consumer reads, not cell
210    /// content. Positions/encoding never cross; the backend encodes via
211    /// `encode_mouse`.
212    pub mouse_events: MouseEvents,
213    /// Whether the alternate screen (`?1049`/`?47`) is active (#149). Buffer-global
214    /// state a frame-mode consumer can't derive from viewport damage — the
215    /// accessibility announce policy (#119) gates on it (suppress TUI repaints).
216    /// Rides the header like the cursor scalars (ADR-0014).
217    pub alt_screen: bool,
218    pub scroll: Option<ScrollOp>,
219    pub spans: Vec<Span>,
220    pub link_table: Vec<String>,
221    /// Interaction overlays for this viewport (#108): selection, search
222    /// highlights, the active match, and markers — see [`Overlay`].
223    pub overlay: Overlay,
224}
225
226/// Why a byte buffer could not be decoded into a [`Frame`].
227#[derive(Clone, Copy, PartialEq, Eq, Debug)]
228pub enum DecodeError {
229    /// Ran out of bytes mid-field.
230    Truncated,
231    /// First two bytes are not the wire magic.
232    BadMagic,
233    /// Unsupported format version.
234    BadVersion(u8),
235    /// A tag/kind byte held a value outside its defined set.
236    BadTag,
237    /// A span's `left` was past its `right` (would underflow the cell count).
238    BadSpan,
239}
240
241/// Serialize a frame to the binary wire format.
242pub fn encode(frame: &Frame) -> Vec<u8> {
243    let mut out = Vec::new();
244    out.extend_from_slice(&MAGIC);
245    out.push(VERSION);
246    out.push(frame.scroll.is_some() as u8);
247    out.push(match frame.kind {
248        FrameKind::Full => 0,
249        FrameKind::Partial => 1,
250    });
251    out.extend_from_slice(&frame.cols.to_le_bytes());
252    out.extend_from_slice(&frame.rows.to_le_bytes());
253    out.extend_from_slice(&frame.cursor_row.to_le_bytes());
254    out.extend_from_slice(&frame.cursor_col.to_le_bytes());
255    out.push(frame.cursor_visible as u8);
256    out.push(match frame.cursor_shape {
257        CursorShape::Block => 0,
258        CursorShape::Underline => 1,
259        CursorShape::Bar => 2,
260    });
261    out.push(frame.cursor_blink as u8);
262    out.extend_from_slice(&frame.display_offset.to_le_bytes());
263    out.extend_from_slice(&frame.scrollback_len.to_le_bytes());
264    // Mouse wanted-events mask (#129): one byte in the header, like the cursor
265    // scalars. Off = 0.
266    out.push(frame.mouse_events.bits());
267    // Alt-screen flag (#149): one byte in the header, like the cursor scalars.
268    out.push(frame.alt_screen as u8);
269    if let Some(s) = frame.scroll {
270        out.extend_from_slice(&(s.top as u16).to_le_bytes());
271        out.extend_from_slice(&(s.bottom as u16).to_le_bytes());
272        out.extend_from_slice(&(s.count as i16).to_le_bytes());
273    }
274    out.extend_from_slice(&(frame.spans.len() as u16).to_le_bytes());
275    for span in &frame.spans {
276        out.extend_from_slice(&span.line.to_le_bytes());
277        out.extend_from_slice(&span.left.to_le_bytes());
278        out.extend_from_slice(&span.right.to_le_bytes());
279        for cell in &span.cells {
280            out.extend_from_slice(&encode_cell_record(cell));
281        }
282    }
283    // Hyperlink table (#26), interned: each referenced URI ships once as a
284    // length-prefixed UTF-8 run, and `Span::links` points at it. Both the count and
285    // the length are u32 since v14 — the old u16 length rejected a URI the engine
286    // stores happily, and the old u16 count could not describe one entry per cell of
287    // a viewport the header's own `cols`/`rows` (u16 *each*) permit (#621).
288    out.extend_from_slice(&(frame.link_table.len() as u32).to_le_bytes());
289    for uri in &frame.link_table {
290        out.extend_from_slice(&(uri.len() as u32).to_le_bytes());
291        out.extend_from_slice(uri.as_bytes());
292    }
293    // Combining-cluster group (#45, v14): one sparse map per span, in span order, so
294    // the column keys need no span index — the same positional convention `ucolors`
295    // uses below. Each entry is `(col u16, len u32, len * char u32)`, the cluster
296    // inline. There is no side-table and no index: nothing interned them, so the
297    // indirection only bought a second count to overflow (#621).
298    for span in &frame.spans {
299        out.extend_from_slice(&(span.combining.len() as u32).to_le_bytes());
300        for (&col, cluster) in &span.combining {
301            out.extend_from_slice(&(col as u16).to_le_bytes());
302            out.extend_from_slice(&(cluster.len() as u32).to_le_bytes());
303            for &ch in cluster {
304                out.extend_from_slice(&(ch as u32).to_le_bytes());
305            }
306        }
307    }
308    // Hyperlink reference group (#46, v14): same positional shape, but the value is a
309    // 1-based index into `link_table` rather than the URI — see `Span`'s doc for why
310    // this half stays interned where the one above does not.
311    for span in &frame.spans {
312        out.extend_from_slice(&(span.links.len() as u32).to_le_bytes());
313        for (&col, &idx) in &span.links {
314            out.extend_from_slice(&(col as u16).to_le_bytes());
315            out.extend_from_slice(&idx.get().to_le_bytes());
316        }
317    }
318    // Underline-colour group (SGR 58, #520, v13): one sparse map per span, in span
319    // order, so the column keys need no span index — the decoder reads exactly
320    // `span_count` maps and attaches each to its span. Each entry is `(col u16,
321    // colour u32)`, the colour packed by the same `encode_color` as fg/bg. A frame
322    // with no coloured underlines pays 2 bytes per span (the zero count).
323    for span in &frame.spans {
324        out.extend_from_slice(&(span.ucolors.len() as u16).to_le_bytes());
325        for (&col, &color) in &span.ucolors {
326            out.extend_from_slice(&(col as u16).to_le_bytes());
327            out.extend_from_slice(&encode_color(color).to_le_bytes());
328        }
329    }
330    // Overlay section (#108): each group is a u16 count then that many
331    // `(row, left, right)` u16 viewport triples. Selection first, then search
332    // matches. Append-only, version-gated — a future group (markers, #118) adds
333    // a third count here at the next version bump.
334    encode_overlay_spans(&mut out, &frame.overlay.selection);
335    encode_overlay_spans(&mut out, &frame.overlay.matches);
336    // Third overlay group (#118): markers as `(id u32, row u16)` pairs — a
337    // different record shape from the span groups (a marker is a line anchor,
338    // not a column run). v10 (#159) appends a kind discriminant (u8, like
339    // `cursor_shape`), and — only for `CommandFinished` — a presence byte + i32
340    // exit code (the presence pattern mirrors the header's `scroll` option).
341    out.extend_from_slice(&(frame.overlay.markers.len() as u16).to_le_bytes());
342    for m in &frame.overlay.markers {
343        out.extend_from_slice(&m.id.0.to_le_bytes());
344        out.extend_from_slice(&(m.row as u16).to_le_bytes());
345        out.push(match m.kind {
346            MarkerKind::Plain => 0,
347            MarkerKind::PromptStart => 1,
348            MarkerKind::CommandStart => 2,
349            MarkerKind::OutputStart => 3,
350            MarkerKind::CommandFinished(_) => 4,
351        });
352        if let MarkerKind::CommandFinished(exit) = m.kind {
353            out.push(exit.is_some() as u8);
354            out.extend_from_slice(&exit.unwrap_or(0).to_le_bytes());
355        }
356    }
357    // Fourth overlay group (#120 S3, v11): every live marker's absolute buffer
358    // line as `(id u32, line u32)` pairs — a superset of the viewport marker group
359    // above, for placing overview-ruler marks off-viewport. Count-prefixed like the
360    // others.
361    out.extend_from_slice(&(frame.overlay.marker_lines.len() as u16).to_le_bytes());
362    for m in &frame.overlay.marker_lines {
363        out.extend_from_slice(&m.id.0.to_le_bytes());
364        out.extend_from_slice(&m.line.to_le_bytes());
365    }
366    // Fifth overlay group (#428, v12): the active search match's spans, same
367    // count + `(row, left, right)` shape as the selection/match groups. Appended
368    // at the tail so the section stays append-only.
369    encode_overlay_spans(&mut out, &frame.overlay.active_match);
370    out
371}
372
373/// Encode one overlay group: a u32 span count, then each span as three u16s
374/// (`row`, `left`, `right`) in viewport coordinates.
375///
376/// The count is u32 since v14 (#621), and this is the *same* defect as the cluster
377/// and URI fields — found by that issue's own acceptance item ("do not assume those
378/// two are the only `as u16` narrowings"), which the first sweep answered for the
379/// `(row, left, right)` triples and not for the count above them. A one-character
380/// search over a large viewport reaches it: measured at 1000×133, 66 000 highlight
381/// spans wrapped the count to 464, and `decode` returned **`Ok`** having also
382/// fabricated 928 marker-lines and 3 active-match spans the engine never had — the
383/// wrapped count leaves the reader mid-group, and every group after it is read from
384/// the wrong offset.
385///
386/// **This widens the three viewport-projected groups and deliberately not the two
387/// marker groups**, which keep their `u16` counts a few lines below. That is not an
388/// oversight and not inconsistency: `frame()` clips selection / matches /
389/// active-match to the viewport, so their counts are `O(viewport)` — ADR-0020 R3
390/// satisfied, and widening entrenches nothing. The marker groups report **every
391/// live marker**, on-screen or not; they are unbounded by the viewport and are the
392/// one R3 violation ADR-0020 records against itself. Widening those would make that
393/// violation cheaper to keep, which is precisely what #490 exists to remove — so
394/// they stay narrow and stay #490's.
395fn encode_overlay_spans(out: &mut Vec<u8>, spans: &[SelectionSpan]) {
396    out.extend_from_slice(&(spans.len() as u32).to_le_bytes());
397    for s in spans {
398        out.extend_from_slice(&(s.row as u16).to_le_bytes());
399        out.extend_from_slice(&(s.left as u16).to_le_bytes());
400        out.extend_from_slice(&(s.right as u16).to_le_bytes());
401    }
402}
403
404/// Length in bytes of one fixed-width wire cell record (see
405/// [`encode_cell_record`]).
406pub const CELL_RECORD_LEN: usize = 14;
407
408/// Encode one [`Cell`] to its fixed 14-byte little-endian record:
409/// `c` u32 (Unicode scalar) · `fg` u32 · `bg` u32 · `flags` u16. Width derives
410/// from `flags`.
411///
412/// **The record carries no grapheme or hyperlink reference (v14, #621).** Both were
413/// `u16` fields on every cell, and widening them to hold what the engine can
414/// legitimately store would have inflated a record every cell pays — the trade
415/// ADR-0008's Axis 4 already rejected in the other direction. They moved to sparse
416/// per-[`Span`] groups instead, which is why this record *shrank* by 4 bytes:
417/// measured, −20.9% on an ordinary frame that carries neither.
418///
419/// This is the single definition of the cell record layout — [`encode`] writes
420/// it per span cell, and an alternate consumer (the WASM decoder, #34/ADR-0008)
421/// reuses it to lay decoded cells out flat without re-implementing the layout,
422/// so the two cannot drift.
423pub fn encode_cell_record(cell: &Cell) -> [u8; CELL_RECORD_LEN] {
424    let mut r = [0u8; CELL_RECORD_LEN];
425    r[0..4].copy_from_slice(&(cell.c() as u32).to_le_bytes());
426    r[4..8].copy_from_slice(&encode_color(cell.fg()).to_le_bytes());
427    r[8..12].copy_from_slice(&encode_color(cell.bg()).to_le_bytes());
428    r[12..14].copy_from_slice(&cell.flags().bits().to_le_bytes());
429    r
430}
431
432/// A colour reference as a tagged u32: high byte = tag
433/// (0 = Default, 1 = Indexed, 2 = Rgb), low 24 bits = payload. The tag is
434/// mandatory so `Default`, `Indexed(0)`, and `Rgb(0,0,0)` stay distinct.
435///
436/// Public so an alternate consumer (the WASM decoder's structure-of-arrays
437/// `fg`/`bg` columns, #35) reuses this single definition of the colour-ref
438/// encoding instead of re-implementing the tag packing — no drift.
439pub fn encode_color(c: Color) -> u32 {
440    match c {
441        Color::Default => 0,
442        Color::Indexed(i) => (1 << 24) | i as u32,
443        Color::Rgb(r, g, b) => (2 << 24) | (r as u32) << 16 | (g as u32) << 8 | b as u32,
444    }
445}
446
447/// Deserialize the binary wire format back into a [`Frame`].
448pub fn decode(bytes: &[u8]) -> Result<Frame, DecodeError> {
449    let mut r = Reader::new(bytes);
450    if r.take(2)? != MAGIC {
451        return Err(DecodeError::BadMagic);
452    }
453    let version = r.u8()?;
454    if version != VERSION {
455        return Err(DecodeError::BadVersion(version));
456    }
457    let has_scroll = r.u8()? != 0;
458    let kind = match r.u8()? {
459        0 => FrameKind::Full,
460        1 => FrameKind::Partial,
461        _ => return Err(DecodeError::BadTag),
462    };
463    let cols = r.u16()?;
464    let rows = r.u16()?;
465    let cursor_row = r.u16()?;
466    let cursor_col = r.u16()?;
467    let cursor_visible = r.u8()? != 0;
468    let cursor_shape = match r.u8()? {
469        0 => CursorShape::Block,
470        1 => CursorShape::Underline,
471        2 => CursorShape::Bar,
472        _ => return Err(DecodeError::BadTag),
473    };
474    let cursor_blink = r.u8()? != 0;
475    let display_offset = r.u32()?;
476    let scrollback_len = r.u32()?;
477    let mouse_events = MouseEvents::from_bits_retain(r.u8()?);
478    let alt_screen = r.u8()? != 0;
479    let scroll = if has_scroll {
480        let top = r.u16()? as usize;
481        let bottom = r.u16()? as usize;
482        let count = (r.u16()? as i16) as isize;
483        Some(ScrollOp { top, bottom, count })
484    } else {
485        None
486    };
487    let span_count = r.u16()?;
488    let mut spans = Vec::with_capacity(span_count as usize);
489    for _ in 0..span_count {
490        let line = r.u16()?;
491        let left = r.u16()?;
492        let right = r.u16()?;
493        if right < left {
494            return Err(DecodeError::BadSpan);
495        }
496        // Widen before the arithmetic: `right - left + 1` in `u16` overflows
497        // when `right == u16::MAX` (e.g. left=0, right=65535), panicking under
498        // overflow checks. `right >= left` is enforced just above, so the
499        // subtraction in `usize` cannot underflow.
500        let n = right as usize - left as usize + 1;
501        let mut cells = Vec::with_capacity(n);
502        for _ in 0..n {
503            cells.push(decode_cell(&mut r)?);
504        }
505        spans.push(Span {
506            line,
507            left,
508            right,
509            cells,
510            // Filled from their own groups below (v14): the record no longer carries
511            // either reference, so neither the maps nor the cells' presence bits can
512            // be built here.
513            combining: BTreeMap::new(),
514            links: BTreeMap::new(),
515            ucolors: BTreeMap::new(),
516        });
517    }
518    // Counts and lengths are u32 since v14 — see `encode`. Note the deliberate loss of
519    // `Vec::with_capacity`: these counts are attacker-influenced (`tests/robustness.rs`
520    // drives `decode` from arbitrary bytes) and a u32 one can now declare 4 billion
521    // entries, so reserving up front would turn a 12-byte buffer into an OOM. Growing
522    // as the entries actually arrive is bounded by the input's own length.
523    let link_count = r.u32()?;
524    let mut link_table = Vec::new();
525    for _ in 0..link_count {
526        let len = r.u32()? as usize;
527        let bytes = r.take(len)?;
528        link_table.push(String::from_utf8_lossy(bytes).into_owned());
529    }
530    // Combining group (v14, #621): inverse of the encode above — one sparse map per
531    // span, in span order, attached to `spans[i]` positionally.
532    //
533    // Re-arming `C_COMBINED` here is not a nicety, it is the only place left that can.
534    // The bit never travels *as a bit*: the record encodes `cell.c()`, the char, so
535    // the content word's marker is dropped. Until v14 `decode_cell` could reconstruct
536    // it inline from `extra != 0` because the index rode the record; now that it does
537    // not, this loop inherits that duty — the same reconstruction `ucolors` has always
538    // done, and the defect #531 was filed for when it was missing.
539    //
540    // Bounds-gated on the same terms as `ucolors`: `col` is attacker-influenced and
541    // nothing on the wire bounds it against the span's width, so an out-of-range key
542    // arms no cell and is left in the map. Whether it should be *rejected* is #582's
543    // question, and answering half of it here would pre-empt that decision.
544    for span in &mut spans {
545        let count = r.u32()?;
546        for _ in 0..count {
547            let col = r.u16()? as usize;
548            let len = r.u32()?;
549            let mut cluster = Vec::new();
550            for _ in 0..len {
551                cluster.push(char::from_u32(r.u32()?).ok_or(DecodeError::BadTag)?);
552            }
553            if let Some(cell) = span.cells.get_mut(col) {
554                cell.set_combined(true);
555            }
556            span.combining.insert(col, cluster);
557        }
558    }
559    // Hyperlink reference group (v14, #621): same shape, and `LINK_PRESENT` is re-armed
560    // for the same reason — it lives in the bg word, which `encode_color` drops.
561    for span in &mut spans {
562        let count = r.u32()?;
563        for _ in 0..count {
564            let col = r.u16()? as usize;
565            let idx = NonZeroU32::new(r.u32()?).ok_or(DecodeError::BadTag)?;
566            if let Some(cell) = span.cells.get_mut(col) {
567                cell.set_linked(true);
568            }
569            span.links.insert(col, idx);
570        }
571    }
572    // Underline-colour group (v13, #520): inverse of the encode above — one sparse
573    // map per span, in span order, so each attaches to `spans[i]` positionally.
574    for span in &mut spans {
575        let count = r.u16()?;
576        for _ in 0..count {
577            let col = r.u16()? as usize;
578            let color = decode_color(r.u32()?)?;
579            // Re-arm `UCOLOR_PRESENT` from the group (#531). On this wire a presence
580            // bit never travels *as a bit*: it lives in the bg word (`BG_UCOLOR`),
581            // which `encode_color` drops when it keeps only mode+value, and
582            // `CellFlags` carries no presence bits. So every one of them is
583            // *reconstructed* from whether its group carries an entry — the same
584            // derivation `decode_cell` performs for `combined`/`linked`, which can do
585            // it inline only because `extra`/`link` ride the cell record while a
586            // colour reference rides a separate group. `Row::ucolor_at` gates the map
587            // read on this bit, so without the re-arm `Cell::is_ucolored()` returns
588            // false on a decoded cell whose column *does* carry a colour.
589            //
590            // Bounds-gated on purpose: `col` is attacker-influenced (see
591            // `tests/robustness.rs`) and nothing on the wire bounds it against the
592            // span's width, so an unchecked `span.cells[col]` would panic where
593            // ADR-0008 owes a typed error. An out-of-range key arms no cell and is
594            // left in the map — whether it should instead be *rejected* is #582's
595            // question (a group riding a frame it does not fit), and answering half
596            // of it here would pre-empt that decision.
597            if let Some(cell) = span.cells.get_mut(col) {
598                cell.set_ucolored(true);
599            }
600            span.ucolors.insert(col, color);
601        }
602    }
603    // Overlay section (#108): selection group then match group, each a count +
604    // `(row, left, right)` triples (inverse of `encode_overlay_spans`).
605    let selection = decode_overlay_spans(&mut r)?;
606    let matches = decode_overlay_spans(&mut r)?;
607    // Third group (#118): marker `(id u32, row u16)` records, each followed by a
608    // kind discriminant (v10, #159) and — for `CommandFinished` — a presence byte
609    // + i32 exit (inverse of the marker encode loop).
610    let marker_count = r.u16()?;
611    let mut markers = Vec::with_capacity(marker_count as usize);
612    for _ in 0..marker_count {
613        let id = MarkerId(r.u32()?);
614        let row = r.u16()? as usize;
615        let kind = match r.u8()? {
616            0 => MarkerKind::Plain,
617            1 => MarkerKind::PromptStart,
618            2 => MarkerKind::CommandStart,
619            3 => MarkerKind::OutputStart,
620            4 => {
621                // Always read presence + i32 (the encoder writes both); a 0
622                // presence means the exit bytes are padding to discard.
623                let present = r.u8()? != 0;
624                let exit = r.u32()? as i32;
625                MarkerKind::CommandFinished(present.then_some(exit))
626            }
627            _ => return Err(DecodeError::BadTag),
628        };
629        markers.push(MarkerPosition { id, row, kind });
630    }
631    // Fourth group (#120 S3, v11): every live marker's `(id u32, line u32)` — the
632    // absolute-line superset for the overview ruler (inverse of the encode loop).
633    let marker_line_count = r.u16()?;
634    let mut marker_lines = Vec::with_capacity(marker_line_count as usize);
635    for _ in 0..marker_line_count {
636        let id = MarkerId(r.u32()?);
637        let line = r.u32()?;
638        marker_lines.push(MarkerLine { id, line });
639    }
640    // Fifth group (#428, v12): the active search match's spans (inverse of the
641    // tail `encode_overlay_spans` call).
642    let active_match = decode_overlay_spans(&mut r)?;
643    let overlay = Overlay {
644        selection,
645        matches,
646        markers,
647        marker_lines,
648        active_match,
649    };
650    Ok(Frame {
651        cols,
652        rows,
653        kind,
654        cursor_row,
655        cursor_col,
656        cursor_visible,
657        cursor_shape,
658        cursor_blink,
659        display_offset,
660        scrollback_len,
661        mouse_events,
662        alt_screen,
663        scroll,
664        spans,
665        link_table,
666        overlay,
667    })
668}
669
670/// Decode one overlay group: a u16 span count, then that many `(row, left,
671/// right)` u16 triples back into viewport [`SelectionSpan`]s (inverse of
672/// [`encode_overlay_spans`]).
673fn decode_overlay_spans(r: &mut Reader) -> Result<Vec<SelectionSpan>, DecodeError> {
674    let count = r.u32()?;
675    // No `with_capacity`: the count is attacker-influenced and now u32 — see `decode`.
676    let mut spans = Vec::new();
677    for _ in 0..count {
678        let row = r.u16()? as usize;
679        let left = r.u16()? as usize;
680        let right = r.u16()? as usize;
681        spans.push(SelectionSpan { row, left, right });
682    }
683    Ok(spans)
684}
685
686/// Decode one 14-byte cell record (inverse of [`encode_cell_record`]).
687///
688/// **No presence bit is re-armed here — since v14 (#621), none of the three can be.**
689/// They are reconstructed rather than transmitted (they live in the packed content
690/// and colour words, which [`encode_color`] and `Cell::c` strip), and every one of
691/// them now has its evidence in a sparse group read further down the buffer:
692/// `C_COMBINED` and `LINK_PRESENT` from the combining and link groups, and
693/// `UCOLOR_PRESENT` from the underline-colour group as it always was (#531).
694///
695/// This doc used to say *two of the three are re-armed here*, and that exception —
696/// "mine rides the record, so it needs nothing" — is the reading #531 was filed for.
697/// There is no longer a member of the set it could apply to.
698fn decode_cell(r: &mut Reader) -> Result<Cell, DecodeError> {
699    let c = char::from_u32(r.u32()?).ok_or(DecodeError::BadTag)?;
700    let fg = decode_color(r.u32()?)?;
701    let bg = decode_color(r.u32()?)?;
702    let flags = CellFlags::from_bits_retain(r.u16()?);
703    // `C_COMBINED` and `LINK_PRESENT` are deliberately left off here and re-armed by
704    // `decode` from the per-span groups (v14, #621). This function used to set them
705    // from the record's `extra`/`link`; with those gone it has nothing to read, and
706    // guessing `false` is correct precisely because the groups are the authority.
707    Ok(Cell::from_parts(c, fg, bg, flags))
708}
709
710/// Decode a tagged-u32 colour reference (inverse of [`encode_color`]).
711fn decode_color(v: u32) -> Result<Color, DecodeError> {
712    let payload = v & 0x00FF_FFFF;
713    match v >> 24 {
714        0 => Ok(Color::Default),
715        1 => Ok(Color::Indexed(payload as u8)),
716        2 => Ok(Color::Rgb(
717            (payload >> 16) as u8,
718            (payload >> 8) as u8,
719            payload as u8,
720        )),
721        _ => Err(DecodeError::BadTag),
722    }
723}
724
725/// A little-endian cursor over the wire bytes.
726struct Reader<'a> {
727    bytes: &'a [u8],
728    pos: usize,
729}
730
731impl<'a> Reader<'a> {
732    fn new(bytes: &'a [u8]) -> Self {
733        Reader { bytes, pos: 0 }
734    }
735
736    fn take(&mut self, n: usize) -> Result<&'a [u8], DecodeError> {
737        let end = self.pos.checked_add(n).ok_or(DecodeError::Truncated)?;
738        let slice = self
739            .bytes
740            .get(self.pos..end)
741            .ok_or(DecodeError::Truncated)?;
742        self.pos = end;
743        Ok(slice)
744    }
745
746    fn u8(&mut self) -> Result<u8, DecodeError> {
747        Ok(self.take(1)?[0])
748    }
749
750    fn u16(&mut self) -> Result<u16, DecodeError> {
751        let b = self.take(2)?;
752        Ok(u16::from_le_bytes([b[0], b[1]]))
753    }
754
755    fn u32(&mut self) -> Result<u32, DecodeError> {
756        let b = self.take(4)?;
757        Ok(u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
758    }
759}