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 crate::term::MIN_COLUMNS;
16use core::num::NonZeroU32;
17use std::collections::BTreeMap;
18
19/// Wire magic ("juSTerm") + format version. A new feature bumps `VERSION`.
20const MAGIC: [u8; 2] = *b"JT";
21const VERSION: u8 = 15; // v15 adds the marker-index basis to the header — `evicted_total` (u64) and `marker_epoch` (u32) — so a consumer can pull the marker set once and keep it valid instead of being handed every live marker in every frame; the two marker groups stay for now so the still-present `marker_lines` is an oracle the consumer's index can be checked against, and they leave in v16 (#490); 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)
22
23/// The wire-format version (the gating `VERSION` byte), exposed so a binding can
24/// assert at load that its decoder matches the backend encoder (#34/ADR-0008).
25pub const WIRE_VERSION: u8 = VERSION;
26
27/// The largest `ScrollOp::count` magnitude this format can carry — the field rides
28/// as `i16` (#661).
29///
30/// [`crate::Term::scroll_delta`] caps against **both** this and the scroll region's
31/// own height. The height alone is not enough: [`crate::MAX_ROWS`] is `u16::MAX`, so
32/// a region can be taller than `i16::MAX` and a count legitimately below its height
33/// can still be unrepresentable. That corner truncates the magnitude; what it must
34/// never do is wrap, because a wrapped count arrives with the **opposite sign** and
35/// the consumer shifts the region the wrong way.
36pub(crate) const MAX_SCROLL_COUNT: isize = i16::MAX as isize;
37
38/// Whether a frame redraws everything or just its spans.
39#[derive(Clone, Copy, PartialEq, Eq, Debug)]
40pub enum FrameKind {
41 /// Every row is present (resize / alt-screen clear).
42 Full,
43 /// Only the listed spans changed since the consumer's ack.
44 Partial,
45}
46
47/// A damaged column run on one line, with its cells.
48///
49/// `combining` and `links` map a span-relative column to what that cell carries —
50/// combining clusters (#45) and hyperlinks (#46) live in per-row maps, so neither
51/// rides the cell. Since v14 (#621) both are **sparse wire groups of their own**,
52/// not indices in the cell record, which is what removed the `u16` ceilings the
53/// engine could legitimately exceed.
54///
55/// The two are deliberately **not** symmetric, and the asymmetry is measured rather
56/// than stylistic:
57///
58/// - `combining` holds the cluster **inline**. `Term::frame` pushes one entry per
59/// combining cell with no interning, so an index bought nothing but a level of
60/// indirection and a table to count. Inlining is size-neutral (measured: −0.5% on
61/// a combining-heavy frame) and buys the deletion of both.
62/// - `links` holds a **1-based index into [`Frame::link_table`]**, because that
63/// table *is* interned (`Term::frame`'s `link_remap` ships each referenced URI
64/// once). Inlining a URI at every linked cell was measured at +171…403% on
65/// link-dense frames, and all three references share one copy across cells —
66/// ghostty ref-counts its hyperlink set explicitly *"so that a set of cells can
67/// share the same hyperlink without duplicating the data"*, xterm.js keys cells to
68/// an `OscLinkService` id, alacritty holds `Arc<HyperlinkInner>`.
69///
70/// A column is present in either map iff its cell carries the matching bit — and,
71/// as with `ucolors` below, that bit does **not** travel on the wire (the record
72/// encodes `cell.c()` and `encode_color(bg)`, which drop `C_COMBINED` and
73/// `LINK_PRESENT` respectively). `decode` re-arms both from these maps' own entries.
74/// A `Span` built by hand for a test owes the same pairing.
75#[derive(Clone, PartialEq, Eq, Debug)]
76pub struct Span {
77 pub line: u16,
78 pub left: u16,
79 pub right: u16,
80 pub cells: Vec<Cell>,
81 pub combining: BTreeMap<usize, Vec<char>>,
82 pub links: BTreeMap<usize, NonZeroU32>,
83 /// Underline colours (SGR 58, #520): span-relative column → the `Color`
84 /// reference the cell's coloured underline draws in. Sparse — only cells that
85 /// carry a non-default underline colour appear (gated on the `UNDERLINE`
86 /// attribute at parse time). Unlike `combining`/`links` this is a colour
87 /// reference, not a side-table index, so it ships inline (no `_table` on the
88 /// [`Frame`]). Kept off the per-cell record so a plain-text frame pays nothing
89 /// (ADR-0020: no inert per-cell payload).
90 ///
91 /// Like `combining` and `links`, a column here is present iff its cell carries
92 /// the matching bit ([`Cell::is_ucolored`]) — but that bit does **not** travel on
93 /// the wire (`encode_color` keeps only mode+value, and `CellFlags` holds no
94 /// presence bits), so `decode` re-arms it from this map's own entries. A `Span`
95 /// built by hand for a test owes the same pairing: an entry here without
96 /// [`Cell::set_ucolored`] on the cell is a column the gated readers cannot see.
97 /// (#531)
98 pub ucolors: BTreeMap<usize, Color>,
99}
100
101/// A stable handle to a buffer line, handed out by `Engine::add_marker` (#118).
102/// Monotonic per engine. The consumer attaches a decoration to the id; the frame
103/// reports where the marker currently sits, and `TermEvent::MarkerDisposed`
104/// signals when its line has left the buffer.
105#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
106pub struct MarkerId(pub u32);
107
108/// What a marker means (#158). A plain `add_marker` decoration carries no
109/// semantics ([`MarkerKind::Plain`]); OSC 133 shell-integration marks carry the
110/// command-boundary role (prompt/command/output start, or command finished with
111/// its optional exit code). The engine only *parses and anchors* these — the
112/// success/failure colour, earcon and prompt-to-prompt navigation are consumer
113/// policy (ADR-0017), driven off the kind + exit the wire (#159) carries.
114#[derive(Clone, Copy, PartialEq, Eq, Debug)]
115pub enum MarkerKind {
116 /// A `add_marker` decoration anchor (#118) — no OSC-133 semantics.
117 Plain,
118 /// OSC `133;A` — the shell prompt begins here.
119 PromptStart,
120 /// OSC `133;B` — the typed command begins here (the prompt ended).
121 CommandStart,
122 /// OSC `133;C` — the command was submitted; its output begins here.
123 OutputStart,
124 /// OSC `133;D[;exit]` — the command finished, with its exit code if reported
125 /// (absent, empty or non-numeric → `None`).
126 CommandFinished(Option<i32>),
127}
128
129/// A marker projected onto the viewport (#118): its id, the row it sits on, and
130/// its kind (#159). Only markers visible in the current viewport are reported; an
131/// off-screen marker is omitted but still alive (death comes via `MarkerDisposed`,
132/// not absence — so the consumer can tell "scrolled away" from "gone"). The kind
133/// carries the OSC 133 command-boundary role + exit code so the consumer can drive
134/// prompt-to-prompt navigation and success/fail signals (#160).
135#[derive(Clone, Copy, PartialEq, Eq, Debug)]
136pub struct MarkerPosition {
137 pub id: MarkerId,
138 pub row: usize,
139 pub kind: MarkerKind,
140}
141
142/// A marker's absolute buffer line (#120 S3, v11). Unlike [`MarkerPosition`],
143/// this is reported for EVERY live marker — on-screen or not — so a frame-mode
144/// consumer can place overview-ruler marks buffer-relatively (dividing by
145/// `scrollback + rows`), the whole point of a ruler being to show off-viewport
146/// anchors. The consumer joins `id` with its decoration registry; the ruler mark's
147/// colour is the consumer's (theme-agnostic), so no kind/exit rides here.
148#[derive(Clone, Copy, PartialEq, Eq, Debug)]
149pub struct MarkerLine {
150 pub id: MarkerId,
151 /// Absolute buffer line, in the same `[0, scrollback_len + rows)` frame the
152 /// header's `scrollback_len`/`display_offset` use.
153 pub line: u32,
154}
155
156/// Interaction overlays projected onto the viewport (#108): highlight spans the
157/// engine carries on the frame so a frame-mode consumer can paint them without
158/// an in-process model query. Positions only — highlight colour is the
159/// consumer's (theme-agnostic). Coordinates are viewport rows/cols, re-projected
160/// by `frame()` against the scroll offset so the engine stays the single
161/// anchoring authority.
162#[derive(Clone, PartialEq, Eq, Debug, Default)]
163pub struct Overlay {
164 /// The live selection projected onto visible rows (`selection_range`).
165 pub selection: Vec<SelectionSpan>,
166 /// The search highlights projected onto visible rows. Search matches
167 /// are consumer-owned (next/prev navigation holds the `Vec<Match>`), so the
168 /// consumer hands the highlight set back via `set_search_highlights` and the
169 /// engine projects it here — mirroring how the engine-owned selection rides.
170 pub matches: Vec<SelectionSpan>,
171 /// Engine-owned markers visible in this viewport (#118): persistent line
172 /// anchors for decorations. Unlike the selection (cleared on a screen swap)
173 /// and search highlights (invalidated on output), markers re-anchor through
174 /// buffer mutation and survive an alt-screen excursion; only their viewport
175 /// position rides here.
176 pub markers: Vec<MarkerPosition>,
177 /// Every live marker's absolute buffer line (#120 S3, v11), on-screen or not —
178 /// the overview ruler needs off-viewport anchors, which `markers` (viewport-
179 /// only) can't supply. A superset of `markers` by id; different frame of
180 /// reference (absolute line, not viewport row).
181 pub marker_lines: Vec<MarkerLine>,
182 /// The *active* (current) search match's spans (#428, v12): the member of
183 /// `matches` the consumer designated via `set_active_search_highlight`
184 /// (which match is active is consumer policy — next/prev navigation).
185 /// Projected by the same mechanism as `matches`, and *also* present there —
186 /// the renderer's highlight ranking resolves the overlap (#424), not
187 /// exclusion here. Empty when nothing is designated.
188 pub active_match: Vec<SelectionSpan>,
189}
190
191/// One serialized damage cycle: the decoded logical form that `encode`/`decode`
192/// round-trip. `link_table` holds this frame's OSC 8 hyperlink URIs, each shipped
193/// once and referenced by [`Span::links`]. Grapheme clusters have **no** table —
194/// since v14 (#621) they are inlined at their column in [`Span::combining`],
195/// because nothing interned them and the table only bought an index to overflow.
196#[derive(Clone, PartialEq, Eq, Debug)]
197pub struct Frame {
198 pub cols: u16,
199 pub rows: u16,
200 pub kind: FrameKind,
201 /// Cursor row/col in screen coordinates (0-based), and whether the engine
202 /// shows it (DECTCEM). Rides in the header because the cursor moves with
203 /// almost every frame (#38). *Drawing* the cursor — cell-invert / overlay —
204 /// stays the consumer's renderer adapter; the engine only reports state.
205 pub cursor_row: u16,
206 pub cursor_col: u16,
207 pub cursor_visible: bool,
208 /// The caret shape (DECSCUSR #89) and whether it blinks (att610 ?12, #81).
209 /// Reported for the renderer; drawing/animation stays the consumer's.
210 pub cursor_shape: CursorShape,
211 pub cursor_blink: bool,
212 /// Viewport scroll position (#112 / ADR-0013), for the consumer's scrollbar.
213 /// `display_offset` = lines scrolled up from the bottom (0 = following the
214 /// live screen); `scrollback_len` = history lines (total = `+ rows`). Ride in
215 /// the header like the cursor — per-frame viewport state, not cell content.
216 pub display_offset: u32,
217 pub scrollback_len: u32,
218 /// Lines popped off the front of scrollback since startup or RIS (#490). The
219 /// basis a consumer rebases a *pulled* marker index by: eviction shifts every
220 /// absolute line by the same amount, so the whole class is one number.
221 ///
222 /// `u64` on purpose. A `u32` wraps after 2^32 evicted lines, which is reachable
223 /// in exactly the long high-throughput session this field exists to serve — and
224 /// a wrapped basis is silent, producing a plausible line that names other
225 /// content. The narrowing invariant asks the width question per field, and four
226 /// bytes is the cheapest possible answer here.
227 pub evicted_total: u64,
228 /// Bumped whenever a held marker line went stale for a reason `evicted_total`
229 /// cannot express — a reflow, a region rotate that moved a surviving marker, an
230 /// alt-screen switch (#490). A consumer compares it against the epoch its index
231 /// was pulled at and re-pulls on a difference.
232 ///
233 /// Deliberately *not* bumped by a disposal: that arrives as
234 /// `TermEvent::MarkerDisposed`, which the consumer already handles, so it costs
235 /// no re-pull.
236 pub marker_epoch: u32,
237 /// The mouse tracking mode as a *wanted-events* mask (#129): which mouse
238 /// event categories the app asked to receive, so the consumer routes an event
239 /// to the app (bit set) or keeps it local. `empty()` = no reporting. Rides the
240 /// header like the cursor — per-frame mode state the consumer reads, not cell
241 /// content. Positions/encoding never cross; the backend encodes via
242 /// `encode_mouse`.
243 pub mouse_events: MouseEvents,
244 /// Whether the alternate screen (`?1049`/`?47`) is active (#149). Buffer-global
245 /// state a frame-mode consumer can't derive from viewport damage — the
246 /// accessibility announce policy (#119) gates on it (suppress TUI repaints).
247 /// Rides the header like the cursor scalars (ADR-0014).
248 pub alt_screen: bool,
249 pub scroll: Option<ScrollOp>,
250 pub spans: Vec<Span>,
251 pub link_table: Vec<String>,
252 /// Interaction overlays for this viewport (#108): selection, search
253 /// highlights, the active match, and markers — see [`Overlay`].
254 pub overlay: Overlay,
255}
256
257/// Why a byte buffer could not be decoded into a [`Frame`].
258#[derive(Clone, Copy, PartialEq, Eq, Debug)]
259pub enum DecodeError {
260 /// Ran out of bytes mid-field.
261 Truncated,
262 /// First two bytes are not the wire magic.
263 BadMagic,
264 /// Unsupported format version.
265 BadVersion(u8),
266 /// A tag/kind byte held a value outside its defined set.
267 BadTag,
268 /// The frame's **own** declared geometry is one no terminal can have: fewer than
269 /// [`MIN_COLUMNS`](crate::MIN_COLUMNS) columns, or no rows at all (#663).
270 ///
271 /// Distinct from [`BadSpan`](Self::BadSpan), and the distinction is the *direction of
272 /// the comparison* rather than a shade of severity. `BadSpan` means a part of the
273 /// frame does not fit the geometry the header declares; this means the header itself
274 /// declares a geometry the engine defines as impossible and clamps away at every entry
275 /// point (`Term::with_scrollback` and [`Term::resize`](crate::Term::resize) widen
276 /// `cols` to `MIN_COLUMNS` and `rows` to 1). Nothing this crate encodes can carry it,
277 /// so it is malformed input by the same rule `BadSpan` applies one level in.
278 ///
279 /// The floor imports no policy: xterm.js clamps to the same pair for the same reason
280 /// (`MINIMUM_COLS = 2`, *"Less than 2 can mess with wide chars"*, and
281 /// `MINIMUM_ROWS = 1`). There is deliberately **no ceiling** error — `cols`/`rows` are
282 /// `u16` on the wire and [`MAX_COLUMNS`](crate::MAX_COLUMNS) is `u16::MAX` for exactly
283 /// that reason, so the upper end is bounded by the field and needs no check.
284 ///
285 /// This variant is what `BadSpan`'s doc-comment deferred to *"the next release that is
286 /// breaking anyway"* — #663 changes what `decode` accepts, so the version that carries
287 /// it is that release, and the marginal cost of the enum growing is paid there rather
288 /// than on its own. Measured at the time: no exhaustive match on `DecodeError` exists
289 /// in this workspace, `justerm-wasm-decode` formats the variant with `{:?}` (so the
290 /// name reaches JS unaided, #662), and penterm holds no reference to the type.
291 BadGeometry,
292 /// A part of the frame does not fit the geometry the frame itself declares: a span
293 /// whose `left` is past its `right` (which would underflow the cell count), a span
294 /// reaching past `cols` or sitting past `rows`, a sparse group entry keyed outside
295 /// its own span, or a scroll region whose `bottom` is past the last row (#582).
296 ///
297 /// One rule, one error: a coordinate describing a cell the frame says does not exist
298 /// is malformed input, and the consumer must not be handed it.
299 ///
300 /// A dedicated `BadScroll` was considered and **not** taken, and the trade is worth
301 /// stating honestly rather than as a slogan. Against it: this enum is `pub` and not
302 /// `#[non_exhaustive]`, so a new variant is a breaking change for any downstream
303 /// exhaustive match — of which there are, measured, **none in this workspace**; the
304 /// cost is borne only by an external matcher nobody has seen. For it: this variant is
305 /// now the whole diagnostic for six distinct malformations, and the JS side has no
306 /// more to work with (`justerm-wasm-decode` formats the variant name into the thrown
307 /// `Error`'s `message`, #662). The distinction is real but belongs to the next
308 /// release that is breaking anyway — a version bump spent on a diagnostic label, on
309 /// a crate published in lockstep with an npm package, is the more expensive half of
310 /// this trade today.
311 ///
312 /// **That condition arrived, and only for the case it names (#663).**
313 /// [`BadGeometry`](Self::BadGeometry) split off because #663 changes what `decode`
314 /// *accepts*, so its release is the breaking one this paragraph was waiting for. It is
315 /// not a precedent for splitting the six below: the new variant answers a comparison
316 /// pointing the other way (the header against the engine, not a part against the
317 /// header), whereas `BadScroll` would still be one of these six re-labelled. The trade
318 /// above is unchanged for them and they stay merged.
319 BadSpan,
320}
321
322/// Serialize a frame to the binary wire format.
323pub fn encode(frame: &Frame) -> Vec<u8> {
324 let mut out = Vec::new();
325 out.extend_from_slice(&MAGIC);
326 out.push(VERSION);
327 out.push(frame.scroll.is_some() as u8);
328 out.push(match frame.kind {
329 FrameKind::Full => 0,
330 FrameKind::Partial => 1,
331 });
332 out.extend_from_slice(&frame.cols.to_le_bytes());
333 out.extend_from_slice(&frame.rows.to_le_bytes());
334 out.extend_from_slice(&frame.cursor_row.to_le_bytes());
335 out.extend_from_slice(&frame.cursor_col.to_le_bytes());
336 out.push(frame.cursor_visible as u8);
337 out.push(match frame.cursor_shape {
338 CursorShape::Block => 0,
339 CursorShape::Underline => 1,
340 CursorShape::Bar => 2,
341 });
342 out.push(frame.cursor_blink as u8);
343 out.extend_from_slice(&frame.display_offset.to_le_bytes());
344 out.extend_from_slice(&frame.scrollback_len.to_le_bytes());
345 // Marker-index basis (#490): the two scalars a consumer needs to keep a pulled
346 // index valid without being handed every marker in every frame.
347 out.extend_from_slice(&frame.evicted_total.to_le_bytes());
348 out.extend_from_slice(&frame.marker_epoch.to_le_bytes());
349 // Mouse wanted-events mask (#129): one byte in the header, like the cursor
350 // scalars. Off = 0.
351 out.push(frame.mouse_events.bits());
352 // Alt-screen flag (#149): one byte in the header, like the cursor scalars.
353 out.push(frame.alt_screen as u8);
354 if let Some(s) = frame.scroll {
355 out.extend_from_slice(&(s.top as u16).to_le_bytes());
356 out.extend_from_slice(&(s.bottom as u16).to_le_bytes());
357 out.extend_from_slice(&(s.count as i16).to_le_bytes());
358 }
359 out.extend_from_slice(&(frame.spans.len() as u16).to_le_bytes());
360 for span in &frame.spans {
361 out.extend_from_slice(&span.line.to_le_bytes());
362 out.extend_from_slice(&span.left.to_le_bytes());
363 out.extend_from_slice(&span.right.to_le_bytes());
364 for cell in &span.cells {
365 out.extend_from_slice(&encode_cell_record(cell));
366 }
367 }
368 // Hyperlink table (#26), interned: each referenced URI ships once as a
369 // length-prefixed UTF-8 run, and `Span::links` points at it. Both the count and
370 // the length are u32 since v14 — the old u16 length rejected a URI the engine
371 // stores happily, and the old u16 count could not describe one entry per cell of
372 // a viewport the header's own `cols`/`rows` (u16 *each*) permit (#621).
373 out.extend_from_slice(&(frame.link_table.len() as u32).to_le_bytes());
374 for uri in &frame.link_table {
375 out.extend_from_slice(&(uri.len() as u32).to_le_bytes());
376 out.extend_from_slice(uri.as_bytes());
377 }
378 // Combining-cluster group (#45, v14): one sparse map per span, in span order, so
379 // the column keys need no span index — the same positional convention `ucolors`
380 // uses below. Each entry is `(col u16, len u32, len * char u32)`, the cluster
381 // inline. There is no side-table and no index: nothing interned them, so the
382 // indirection only bought a second count to overflow (#621).
383 //
384 // **An entry keyed outside its span is dropped, not narrowed (#582).** `Span`'s maps
385 // are `pub` and keyed by `usize` while the wire key is a `u16`, so `col as u16` did
386 // not lose an out-of-range entry — it *moved* it onto a different, live column
387 // (measured: 65539 encoded as 3 and armed the cell 'D'; 65540 in this group armed
388 // 'E'). Dropping is the only answer that fails in the harmless direction, the same
389 // asymmetry [`Term::damage_span`] records from ghostty — "may have false positives
390 // but should never have false negatives".
391 //
392 // The `debug_assert` is the detector and the drop is the release backstop, again as
393 // `damage_span`: `Term::frame` cannot build such a key (it inserts `col - left` for
394 // `col` in `left..=right`), so one arriving here is a justerm bug and should name its
395 // producer at the site — but justerm is a library, and a panic crosses into the
396 // consumer's process.
397 //
398 // The keys are sorted, so the writable entries are a prefix and the *last* key decides
399 // whether any were dropped: when none were — every frame the engine produces — the
400 // count is still `len()`, O(1), and only the write loop walks the map. Counting the
401 // range unconditionally would have replaced an O(1) read with a walk per span per
402 // group on the encode hot path, to describe a case that cannot occur.
403 for span in &frame.spans {
404 debug_assert!(
405 span.combining.keys().all(|&c| c < span.cells.len()),
406 "combining key past the end of its {}-cell span",
407 span.cells.len()
408 );
409 let n = if span
410 .combining
411 .last_key_value()
412 .is_none_or(|(&k, _)| k < span.cells.len())
413 {
414 span.combining.len()
415 } else {
416 span.combining.range(..span.cells.len()).count()
417 };
418 out.extend_from_slice(&(n as u32).to_le_bytes());
419 for (&col, cluster) in span.combining.range(..span.cells.len()) {
420 out.extend_from_slice(&(col as u16).to_le_bytes());
421 out.extend_from_slice(&(cluster.len() as u32).to_le_bytes());
422 for &ch in cluster {
423 out.extend_from_slice(&(ch as u32).to_le_bytes());
424 }
425 }
426 }
427 // Hyperlink reference group (#46, v14): same positional shape, but the value is a
428 // 1-based index into `link_table` rather than the URI — see `Span`'s doc for why
429 // this half stays interned where the one above does not.
430 // Out-of-span keys are dropped here for the reason written out at the combining group
431 // above; every group answers the question the same way or the rule is not a rule.
432 for span in &frame.spans {
433 debug_assert!(
434 span.links.keys().all(|&c| c < span.cells.len()),
435 "link key past the end of its {}-cell span",
436 span.cells.len()
437 );
438 let n = if span
439 .links
440 .last_key_value()
441 .is_none_or(|(&k, _)| k < span.cells.len())
442 {
443 span.links.len()
444 } else {
445 span.links.range(..span.cells.len()).count()
446 };
447 out.extend_from_slice(&(n as u32).to_le_bytes());
448 for (&col, &idx) in span.links.range(..span.cells.len()) {
449 out.extend_from_slice(&(col as u16).to_le_bytes());
450 out.extend_from_slice(&idx.get().to_le_bytes());
451 }
452 }
453 // Underline-colour group (SGR 58, #520, v13): one sparse map per span, in span
454 // order, so the column keys need no span index — the decoder reads exactly
455 // `span_count` maps and attaches each to its span. Each entry is `(col u16,
456 // colour u32)`, the colour packed by the same `encode_color` as fg/bg. A frame
457 // with no coloured underlines pays 2 bytes per span (the zero count).
458 // Out-of-span keys are dropped here too — see the combining group above for why.
459 for span in &frame.spans {
460 debug_assert!(
461 span.ucolors.keys().all(|&c| c < span.cells.len()),
462 "underline-colour key past the end of its {}-cell span",
463 span.cells.len()
464 );
465 let n = if span
466 .ucolors
467 .last_key_value()
468 .is_none_or(|(&k, _)| k < span.cells.len())
469 {
470 span.ucolors.len()
471 } else {
472 span.ucolors.range(..span.cells.len()).count()
473 };
474 out.extend_from_slice(&(n as u16).to_le_bytes());
475 for (&col, &color) in span.ucolors.range(..span.cells.len()) {
476 out.extend_from_slice(&(col as u16).to_le_bytes());
477 out.extend_from_slice(&encode_color(color).to_le_bytes());
478 }
479 }
480 // Overlay section (#108): each group is a u16 count then that many
481 // `(row, left, right)` u16 viewport triples. Selection first, then search
482 // matches. Append-only, version-gated — a future group (markers, #118) adds
483 // a third count here at the next version bump.
484 encode_overlay_spans(&mut out, &frame.overlay.selection);
485 encode_overlay_spans(&mut out, &frame.overlay.matches);
486 // Third overlay group (#118): markers as `(id u32, row u16)` pairs — a
487 // different record shape from the span groups (a marker is a line anchor,
488 // not a column run). v10 (#159) appends a kind discriminant (u8, like
489 // `cursor_shape`), and — only for `CommandFinished` — a presence byte + i32
490 // exit code (the presence pattern mirrors the header's `scroll` option).
491 out.extend_from_slice(&(frame.overlay.markers.len() as u16).to_le_bytes());
492 for m in &frame.overlay.markers {
493 out.extend_from_slice(&m.id.0.to_le_bytes());
494 out.extend_from_slice(&(m.row as u16).to_le_bytes());
495 out.push(match m.kind {
496 MarkerKind::Plain => 0,
497 MarkerKind::PromptStart => 1,
498 MarkerKind::CommandStart => 2,
499 MarkerKind::OutputStart => 3,
500 MarkerKind::CommandFinished(_) => 4,
501 });
502 if let MarkerKind::CommandFinished(exit) = m.kind {
503 out.push(exit.is_some() as u8);
504 out.extend_from_slice(&exit.unwrap_or(0).to_le_bytes());
505 }
506 }
507 // Fourth overlay group (#120 S3, v11): every live marker's absolute buffer
508 // line as `(id u32, line u32)` pairs — a superset of the viewport marker group
509 // above, for placing overview-ruler marks off-viewport. Count-prefixed like the
510 // others.
511 out.extend_from_slice(&(frame.overlay.marker_lines.len() as u16).to_le_bytes());
512 for m in &frame.overlay.marker_lines {
513 out.extend_from_slice(&m.id.0.to_le_bytes());
514 out.extend_from_slice(&m.line.to_le_bytes());
515 }
516 // Fifth overlay group (#428, v12): the active search match's spans, same
517 // count + `(row, left, right)` shape as the selection/match groups. Appended
518 // at the tail so the section stays append-only.
519 encode_overlay_spans(&mut out, &frame.overlay.active_match);
520 out
521}
522
523/// Encode one overlay group: a u32 span count, then each span as three u16s
524/// (`row`, `left`, `right`) in viewport coordinates.
525///
526/// The count is u32 since v14 (#621), and this is the *same* defect as the cluster
527/// and URI fields — found by that issue's own acceptance item ("do not assume those
528/// two are the only `as u16` narrowings"), which the first sweep answered for the
529/// `(row, left, right)` triples and not for the count above them. A one-character
530/// search over a large viewport reaches it: measured at 1000×133, 66 000 highlight
531/// spans wrapped the count to 464, and `decode` returned **`Ok`** having also
532/// fabricated 928 marker-lines and 3 active-match spans the engine never had — the
533/// wrapped count leaves the reader mid-group, and every group after it is read from
534/// the wrong offset.
535///
536/// **This widens the three viewport-projected groups and deliberately not the two
537/// marker groups**, which keep their `u16` counts a few lines below. That is not an
538/// oversight and not inconsistency: `frame()` clips selection / matches /
539/// active-match to the viewport, so their counts are `O(viewport)` — ADR-0020 R3
540/// satisfied, and widening entrenches nothing. Neither marker group is bounded by
541/// the viewport, so widening those *would* entrench what ADR-0020 records as its one
542/// R3 violation — which is #490's to remove, not this function's.
543///
544/// **Why the two marker counts are nonetheless safe at `u16` (#721).** Not because
545/// the groups are small: the *producer* is bounded. `MAX_MARKERS` caps a buffer's
546/// live population at `u16::MAX`, so neither count can reach a value it cannot
547/// declare. That bound had to exist for its own reason — the marks are allocated by
548/// an untrusted stream — and it closes this hazard as a consequence rather than as
549/// its purpose.
550///
551/// **And the reason they are unbounded is not the one this comment used to give.**
552/// It said *"the marker groups report every live marker, on-screen or not"*. True of
553/// `marker_lines`; **false of `markers`**, which `marker_positions` filters to visible
554/// rows. `markers` is unbounded because several marks legitimately share one line and
555/// nothing dedups them — measured at 70 000 records in *both* groups on an 80x24 grid
556/// (#721). The wrong reason mattered: it made ADR-0020's "one stated violation"
557/// framing read as if only `marker_lines` were at issue.
558fn encode_overlay_spans(out: &mut Vec<u8>, spans: &[SelectionSpan]) {
559 out.extend_from_slice(&(spans.len() as u32).to_le_bytes());
560 for s in spans {
561 out.extend_from_slice(&(s.row as u16).to_le_bytes());
562 out.extend_from_slice(&(s.left as u16).to_le_bytes());
563 out.extend_from_slice(&(s.right as u16).to_le_bytes());
564 }
565}
566
567/// Length in bytes of one fixed-width wire cell record (see
568/// [`encode_cell_record`]).
569pub const CELL_RECORD_LEN: usize = 14;
570
571/// Encode one [`Cell`] to its fixed 14-byte little-endian record:
572/// `c` u32 (Unicode scalar) · `fg` u32 · `bg` u32 · `flags` u16. Width derives
573/// from `flags`.
574///
575/// **The record carries no grapheme or hyperlink reference (v14, #621).** Both were
576/// `u16` fields on every cell, and widening them to hold what the engine can
577/// legitimately store would have inflated a record every cell pays — the trade
578/// ADR-0008's Axis 4 already rejected in the other direction. They moved to sparse
579/// per-[`Span`] groups instead, which is why this record *shrank* by 4 bytes:
580/// measured, −20.9% on an ordinary frame that carries neither.
581///
582/// This is the single definition of the cell record layout — [`encode`] writes
583/// it per span cell, and an alternate consumer (the WASM decoder, #34/ADR-0008)
584/// reuses it to lay decoded cells out flat without re-implementing the layout,
585/// so the two cannot drift.
586pub fn encode_cell_record(cell: &Cell) -> [u8; CELL_RECORD_LEN] {
587 let mut r = [0u8; CELL_RECORD_LEN];
588 r[0..4].copy_from_slice(&(cell.c() as u32).to_le_bytes());
589 r[4..8].copy_from_slice(&encode_color(cell.fg()).to_le_bytes());
590 r[8..12].copy_from_slice(&encode_color(cell.bg()).to_le_bytes());
591 r[12..14].copy_from_slice(&cell.flags().bits().to_le_bytes());
592 r
593}
594
595/// A colour reference as a tagged u32: high byte = tag
596/// (0 = Default, 1 = Indexed, 2 = Rgb), low 24 bits = payload. The tag is
597/// mandatory so `Default`, `Indexed(0)`, and `Rgb(0,0,0)` stay distinct.
598///
599/// Public so an alternate consumer (the WASM decoder's structure-of-arrays
600/// `fg`/`bg` columns, #35) reuses this single definition of the colour-ref
601/// encoding instead of re-implementing the tag packing — no drift.
602pub fn encode_color(c: Color) -> u32 {
603 match c {
604 Color::Default => 0,
605 Color::Indexed(i) => (1 << 24) | i as u32,
606 Color::Rgb(r, g, b) => (2 << 24) | (r as u32) << 16 | (g as u32) << 8 | b as u32,
607 }
608}
609
610/// Deserialize the binary wire format back into a [`Frame`].
611pub fn decode(bytes: &[u8]) -> Result<Frame, DecodeError> {
612 let mut r = Reader::new(bytes);
613 if r.take(2)? != MAGIC {
614 return Err(DecodeError::BadMagic);
615 }
616 let version = r.u8()?;
617 if version != VERSION {
618 return Err(DecodeError::BadVersion(version));
619 }
620 let has_scroll = r.u8()? != 0;
621 let kind = match r.u8()? {
622 0 => FrameKind::Full,
623 1 => FrameKind::Partial,
624 _ => return Err(DecodeError::BadTag),
625 };
626 let cols = r.u16()?;
627 let rows = r.u16()?;
628 // The header is read against the engine's own floor before anything is read against
629 // the header (#663). #582 made every *part* of a frame answer to the declared
630 // geometry; this asks whether that geometry is one a terminal can have at all.
631 //
632 // `MIN_COLUMNS = 2` is not a number picked here: a width-2 glyph needs a `WIDE_CHAR`
633 // lead and its `WIDE_CHAR_SPACER`, so one column cannot hold one (ADR-0025 D4's stated
634 // precondition, #547), and `Term::with_scrollback` / `Term::resize` clamp `cols` up to
635 // it and `rows` up to 1 on every path. So nothing this crate encodes can declare
636 // either — measured, not assumed (`tests/header_geometry_floor.rs` drives the engine
637 // at every geometry from 0×0 up through the floor). xterm.js reaches the same pair
638 // from the same cause: `MINIMUM_COLS = 2` ("Less than 2 can mess with wide chars") and
639 // `MINIMUM_ROWS = 1`, applied on every resize.
640 //
641 // Rejected rather than clamped — and **the references do not decide that**, which is
642 // worth stating because the tempting derivation is wrong. They split on the form, all
643 // three at a site they own: alacritty and xterm.js clamp, ghostty *rejects*
644 // (`Terminal.zig:3721` @ `e6e26e16`, `if (opts.cols == 0 or opts.rows == 0) return
645 // error.InvalidValue`, guarding its own `resize`). So "who owns the number" separates
646 // nothing — ghostty owns it and refuses anyway.
647 //
648 // What decides it here is that this boundary cannot *repair*. `decode` reads bytes a
649 // consumer hands back over its own transport (ADR-0008; `tests/robustness.rs` names
650 // them attacker-influenced), and the payload behind this header was laid out for the
651 // width it declares — so widening `cols` does not fix a frame, it re-indexes one, and
652 // the caller gets cells in the wrong places with no error. Reject and "hand back wrong
653 // content" are the only two total answers, which is not a choice. No reference
654 // arbitrates the site because none of them decodes a serialized grid at all
655 // (`docs/map/territory/wire-format.md`); what ghostty does supply is that refusing an
656 // impossible geometry outright is ordinary terminal behaviour, not an invention here.
657 //
658 // **Deliberately no ceiling**, and this comment is where a later reader is stopped
659 // from adding the "obvious symmetric half": `cols`/`rows` are `u16` and `MAX_COLUMNS`
660 // is `u16::MAX` for exactly that representational reason (#621), so the upper end is
661 // bounded by the field's own width. xterm.js has no maximum at all, and the one layer
662 // that could know a real one — `justerm-renderer` — asks the GL implementation rather
663 // than predicting it. A number chosen here would be the only arbitrary constant in the
664 // stack.
665 //
666 // Before the span loop rather than inside it, and that ordering is asserted: a frame
667 // whose declared width is shrunk below the floor usually has an out-of-frame span too,
668 // and `BadSpan` would name the consequence instead of the cause.
669 if (cols as usize) < MIN_COLUMNS || rows == 0 {
670 return Err(DecodeError::BadGeometry);
671 }
672 let cursor_row = r.u16()?;
673 let cursor_col = r.u16()?;
674 let cursor_visible = r.u8()? != 0;
675 let cursor_shape = match r.u8()? {
676 0 => CursorShape::Block,
677 1 => CursorShape::Underline,
678 2 => CursorShape::Bar,
679 _ => return Err(DecodeError::BadTag),
680 };
681 let cursor_blink = r.u8()? != 0;
682 let display_offset = r.u32()?;
683 let scrollback_len = r.u32()?;
684 let evicted_total = r.u64()?;
685 let marker_epoch = r.u32()?;
686 let mouse_events = MouseEvents::from_bits_retain(r.u8()?);
687 let alt_screen = r.u8()? != 0;
688 let scroll = if has_scroll {
689 let top = r.u16()? as usize;
690 let bottom = r.u16()? as usize;
691 let count = (r.u16()? as i16) as isize;
692 // A scroll region is a write index in a consumer, not an annotation (#582):
693 // `justerm-web`'s `cell-mirror.ts` assigns `cells[y * cols + x]` for every `y` in
694 // `top..=bottom` with nothing bounding it against `rows`. The renderer already
695 // rejects the same value (`FrameGrid::validate` → `ScrollOutsideGrid`) after a
696 // `line == rows` off-by-one trapped the wasm module and left it poisoned (#355).
697 //
698 // **This is deliberately one notch stricter than the renderer, and the difference
699 // is not an oversight.** `FrameGrid::validate` gates its check on `kind != Full`,
700 // because a Full frame repaints everything and its own `shift_region` is skipped.
701 // The web mirror does not have that exemption: it blanks the grid on a Full frame
702 // and *then* still runs `shiftRegion` if the frame carries a scroll op. So the two
703 // consumers disagree about whether a Full frame's scroll op is live, and the wire
704 // is the wrong place to encode either answer — it rejects a region that cannot be
705 // applied to the frame it rides on, whatever the consumer then does with it.
706 //
707 // `top > bottom` is an empty region, not an error — no consumer iterates it, and
708 // the renderer says so explicitly. Rejecting it would be new strictness with no
709 // failure behind it.
710 //
711 // Still not checked here: `count` — but the reason changed with #661, and the
712 // difference matters to anyone extending this guard. It *was* that the engine
713 // legitimately produced counts this field cannot hold (`Term::record_scroll`
714 // accumulated without a cap, and a single 32 770-byte `feed()` of newlines
715 // encoded 32 768 as **−32 768**), so rejecting them here would have rejected
716 // frames the encoder emits — exactly what #582 promised not to do. That is
717 // fixed at the producer: `Term::scroll_delta` caps the count at the region's
718 // height and at [`MAX_SCROLL_COUNT`], so nothing this crate encodes overflows.
719 //
720 // A *foreign* frame carrying an over-height count is still accepted, and that is
721 // a decision rather than the leftover of one. Bounding it became possible once
722 // the engine stopped producing them — but there is no failure behind it, which
723 // is the same reason `top > bottom` rides through above. Measured: every count
724 // across the full `i16` range, both signs, blanks the region in the renderer
725 // and returns `Ok`; `shift_region` / `shiftRegion` / `shiftPrev` all bound the
726 // *source* row against `[top, bottom]` before indexing, so an over-height count
727 // cannot address a cell outside the region it already declared. It costs the
728 // consumer a wasted region-sized shift, and the spans repaint over it.
729 //
730 // Unlike a span's `right`, this is not a write index a consumer walks off — the
731 // distinction `docs/map/territory/wire-format.md` draws between a payload's
732 // placement and its annotations. Rejecting it would be new strictness with no
733 // defect behind it.
734 if top <= bottom && bottom >= rows as usize {
735 return Err(DecodeError::BadSpan);
736 }
737 Some(ScrollOp { top, bottom, count })
738 } else {
739 None
740 };
741 let span_count = r.u16()?;
742 let mut spans = Vec::with_capacity(span_count as usize);
743 for _ in 0..span_count {
744 let line = r.u16()?;
745 let left = r.u16()?;
746 let right = r.u16()?;
747 // A span is read against the frame's own header, not just against itself (#582).
748 // Before this, a frame could declare a 4×2 grid and carry a span claiming column 8
749 // of line 99 and still decode `Ok` — and the consumer that writes those cells does
750 // not fail on it either: `cell-mirror.ts` keeps the viewport as one flat array, so
751 // a column past `cols` lands in the *next row's* slot and silently overwrites it.
752 // A screen reader then announces, and a copy produces, characters that are not on
753 // that line. `decode`'s own input is attacker-influenced (`tests/robustness.rs`),
754 // and rejecting malformed input rather than repairing it is what ADR-0008 makes
755 // this boundary for.
756 if right < left || right >= cols || line >= rows {
757 return Err(DecodeError::BadSpan);
758 }
759 // Widen before the arithmetic: `right - left + 1` in `u16` overflows when
760 // `right == u16::MAX` (e.g. left=0, right=65535), panicking under overflow checks
761 // (#33, found by `cargo fuzz`). `right >= left` is enforced just above, so the
762 // subtraction in `usize` cannot underflow.
763 //
764 // Since #582 this can no longer be *reached*: `right < cols` and `cols` is a u16,
765 // so `right <= 65534` and the sum fits. Kept anyway, and deliberately — it is the
766 // cheaper of the two guarantees and it does not depend on the check above keeping
767 // its position. Deleting it would make a reordering of this function silently
768 // reintroduce a panic that a fuzz run had to find once already.
769 let n = right as usize - left as usize + 1;
770 let mut cells = Vec::with_capacity(n);
771 for _ in 0..n {
772 cells.push(decode_cell(&mut r)?);
773 }
774 spans.push(Span {
775 line,
776 left,
777 right,
778 cells,
779 // Filled from their own groups below (v14): the record no longer carries
780 // either reference, so neither the maps nor the cells' presence bits can
781 // be built here.
782 combining: BTreeMap::new(),
783 links: BTreeMap::new(),
784 ucolors: BTreeMap::new(),
785 });
786 }
787 // Counts and lengths are u32 since v14 — see `encode`. Note the deliberate loss of
788 // `Vec::with_capacity`: these counts are attacker-influenced (`tests/robustness.rs`
789 // drives `decode` from arbitrary bytes) and a u32 one can now declare 4 billion
790 // entries, so reserving up front would turn a 12-byte buffer into an OOM. Growing
791 // as the entries actually arrive is bounded by the input's own length.
792 let link_count = r.u32()?;
793 let mut link_table = Vec::new();
794 for _ in 0..link_count {
795 let len = r.u32()? as usize;
796 let bytes = r.take(len)?;
797 link_table.push(String::from_utf8_lossy(bytes).into_owned());
798 }
799 // Combining group (v14, #621): inverse of the encode above — one sparse map per
800 // span, in span order, attached to `spans[i]` positionally.
801 //
802 // Re-arming `C_COMBINED` here is not a nicety, it is the only place left that can.
803 // The bit never travels *as a bit*: the record encodes `cell.c()`, the char, so
804 // the content word's marker is dropped. Until v14 `decode_cell` could reconstruct
805 // it inline from `extra != 0` because the index rode the record; now that it does
806 // not, this loop inherits that duty — the same reconstruction `ucolors` has always
807 // done, and the defect #531 was filed for when it was missing.
808 //
809 // A key outside its span is rejected, not tolerated (#582, answering the question this
810 // comment used to defer): `col` is attacker-influenced and nothing on the wire bounds
811 // it against the span's width, and an entry addressing a cell the span does not have
812 // describes nothing the frame contains. It used to arm no cell and stay in the map,
813 // which handed the consumer a coordinate it is free to index with.
814 for span in &mut spans {
815 let count = r.u32()?;
816 for _ in 0..count {
817 let col = r.u16()? as usize;
818 let len = r.u32()?;
819 let mut cluster = Vec::new();
820 for _ in 0..len {
821 cluster.push(char::from_u32(r.u32()?).ok_or(DecodeError::BadTag)?);
822 }
823 let Some(cell) = span.cells.get_mut(col) else {
824 return Err(DecodeError::BadSpan);
825 };
826 cell.set_combined(true);
827 span.combining.insert(col, cluster);
828 }
829 }
830 // Hyperlink reference group (v14, #621): same shape, and `LINK_PRESENT` is re-armed
831 // for the same reason — it lives in the bg word, which `encode_color` drops.
832 for span in &mut spans {
833 let count = r.u32()?;
834 for _ in 0..count {
835 let col = r.u16()? as usize;
836 let idx = NonZeroU32::new(r.u32()?).ok_or(DecodeError::BadTag)?;
837 let Some(cell) = span.cells.get_mut(col) else {
838 return Err(DecodeError::BadSpan);
839 };
840 cell.set_linked(true);
841 span.links.insert(col, idx);
842 }
843 }
844 // Underline-colour group (v13, #520): inverse of the encode above — one sparse
845 // map per span, in span order, so each attaches to `spans[i]` positionally.
846 for span in &mut spans {
847 let count = r.u16()?;
848 for _ in 0..count {
849 let col = r.u16()? as usize;
850 let color = decode_color(r.u32()?)?;
851 // Re-arm `UCOLOR_PRESENT` from the group (#531). On this wire a presence
852 // bit never travels *as a bit*: it lives in the bg word (`BG_UCOLOR`),
853 // which `encode_color` drops when it keeps only mode+value, and
854 // `CellFlags` carries no presence bits. So every one of them is
855 // *reconstructed* from whether its group carries an entry — the same
856 // derivation `decode_cell` performs for `combined`/`linked`, which can do
857 // it inline only because `extra`/`link` ride the cell record while a
858 // colour reference rides a separate group. `Row::ucolor_at` gates the map
859 // read on this bit, so without the re-arm `Cell::is_ucolored()` returns
860 // false on a decoded cell whose column *does* carry a colour.
861 //
862 // `col` is attacker-influenced (see `tests/robustness.rs`) and nothing on
863 // the wire bounds it against the span's width, so an unchecked
864 // `span.cells[col]` would panic where ADR-0008 owes a typed error. #531
865 // bought the safety with a gate that kept the entry; #582 answers the
866 // question that gate deferred — the entry is rejected, because a colour for
867 // a cell this span does not have is not a colour the frame carries.
868 let Some(cell) = span.cells.get_mut(col) else {
869 return Err(DecodeError::BadSpan);
870 };
871 cell.set_ucolored(true);
872 span.ucolors.insert(col, color);
873 }
874 }
875 // Overlay section (#108): selection group then match group, each a count +
876 // `(row, left, right)` triples (inverse of `encode_overlay_spans`).
877 let selection = decode_overlay_spans(&mut r)?;
878 let matches = decode_overlay_spans(&mut r)?;
879 // Third group (#118): marker `(id u32, row u16)` records, each followed by a
880 // kind discriminant (v10, #159) and — for `CommandFinished` — a presence byte
881 // + i32 exit (inverse of the marker encode loop).
882 let marker_count = r.u16()?;
883 let mut markers = Vec::with_capacity(marker_count as usize);
884 for _ in 0..marker_count {
885 let id = MarkerId(r.u32()?);
886 let row = r.u16()? as usize;
887 let kind = match r.u8()? {
888 0 => MarkerKind::Plain,
889 1 => MarkerKind::PromptStart,
890 2 => MarkerKind::CommandStart,
891 3 => MarkerKind::OutputStart,
892 4 => {
893 // Always read presence + i32 (the encoder writes both); a 0
894 // presence means the exit bytes are padding to discard.
895 let present = r.u8()? != 0;
896 let exit = r.u32()? as i32;
897 MarkerKind::CommandFinished(present.then_some(exit))
898 }
899 _ => return Err(DecodeError::BadTag),
900 };
901 markers.push(MarkerPosition { id, row, kind });
902 }
903 // Fourth group (#120 S3, v11): every live marker's `(id u32, line u32)` — the
904 // absolute-line superset for the overview ruler (inverse of the encode loop).
905 let marker_line_count = r.u16()?;
906 let mut marker_lines = Vec::with_capacity(marker_line_count as usize);
907 for _ in 0..marker_line_count {
908 let id = MarkerId(r.u32()?);
909 let line = r.u32()?;
910 marker_lines.push(MarkerLine { id, line });
911 }
912 // Fifth group (#428, v12): the active search match's spans (inverse of the
913 // tail `encode_overlay_spans` call).
914 let active_match = decode_overlay_spans(&mut r)?;
915 let overlay = Overlay {
916 selection,
917 matches,
918 markers,
919 marker_lines,
920 active_match,
921 };
922 Ok(Frame {
923 cols,
924 rows,
925 kind,
926 cursor_row,
927 cursor_col,
928 cursor_visible,
929 cursor_shape,
930 cursor_blink,
931 display_offset,
932 scrollback_len,
933 evicted_total,
934 marker_epoch,
935 mouse_events,
936 alt_screen,
937 scroll,
938 spans,
939 link_table,
940 overlay,
941 })
942}
943
944/// Decode one overlay group: a u16 span count, then that many `(row, left,
945/// right)` u16 triples back into viewport [`SelectionSpan`]s (inverse of
946/// [`encode_overlay_spans`]).
947fn decode_overlay_spans(r: &mut Reader) -> Result<Vec<SelectionSpan>, DecodeError> {
948 let count = r.u32()?;
949 // No `with_capacity`: the count is attacker-influenced and now u32 — see `decode`.
950 let mut spans = Vec::new();
951 for _ in 0..count {
952 let row = r.u16()? as usize;
953 let left = r.u16()? as usize;
954 let right = r.u16()? as usize;
955 spans.push(SelectionSpan { row, left, right });
956 }
957 Ok(spans)
958}
959
960/// Decode one 14-byte cell record (inverse of [`encode_cell_record`]).
961///
962/// **No presence bit is re-armed here — since v14 (#621), none of the three can be.**
963/// They are reconstructed rather than transmitted (they live in the packed content
964/// and colour words, which [`encode_color`] and `Cell::c` strip), and every one of
965/// them now has its evidence in a sparse group read further down the buffer:
966/// `C_COMBINED` and `LINK_PRESENT` from the combining and link groups, and
967/// `UCOLOR_PRESENT` from the underline-colour group as it always was (#531).
968///
969/// This doc used to say *two of the three are re-armed here*, and that exception —
970/// "mine rides the record, so it needs nothing" — is the reading #531 was filed for.
971/// There is no longer a member of the set it could apply to.
972fn decode_cell(r: &mut Reader) -> Result<Cell, DecodeError> {
973 let c = char::from_u32(r.u32()?).ok_or(DecodeError::BadTag)?;
974 let fg = decode_color(r.u32()?)?;
975 let bg = decode_color(r.u32()?)?;
976 let flags = CellFlags::from_bits_retain(r.u16()?);
977 // `C_COMBINED` and `LINK_PRESENT` are deliberately left off here and re-armed by
978 // `decode` from the per-span groups (v14, #621). This function used to set them
979 // from the record's `extra`/`link`; with those gone it has nothing to read, and
980 // guessing `false` is correct precisely because the groups are the authority.
981 Ok(Cell::from_parts(c, fg, bg, flags))
982}
983
984/// Decode a tagged-u32 colour reference (inverse of [`encode_color`]).
985fn decode_color(v: u32) -> Result<Color, DecodeError> {
986 let payload = v & 0x00FF_FFFF;
987 match v >> 24 {
988 0 => Ok(Color::Default),
989 1 => Ok(Color::Indexed(payload as u8)),
990 2 => Ok(Color::Rgb(
991 (payload >> 16) as u8,
992 (payload >> 8) as u8,
993 payload as u8,
994 )),
995 _ => Err(DecodeError::BadTag),
996 }
997}
998
999/// A little-endian cursor over the wire bytes.
1000struct Reader<'a> {
1001 bytes: &'a [u8],
1002 pos: usize,
1003}
1004
1005impl<'a> Reader<'a> {
1006 fn new(bytes: &'a [u8]) -> Self {
1007 Reader { bytes, pos: 0 }
1008 }
1009
1010 fn take(&mut self, n: usize) -> Result<&'a [u8], DecodeError> {
1011 let end = self.pos.checked_add(n).ok_or(DecodeError::Truncated)?;
1012 let slice = self
1013 .bytes
1014 .get(self.pos..end)
1015 .ok_or(DecodeError::Truncated)?;
1016 self.pos = end;
1017 Ok(slice)
1018 }
1019
1020 fn u8(&mut self) -> Result<u8, DecodeError> {
1021 Ok(self.take(1)?[0])
1022 }
1023
1024 fn u16(&mut self) -> Result<u16, DecodeError> {
1025 let b = self.take(2)?;
1026 Ok(u16::from_le_bytes([b[0], b[1]]))
1027 }
1028
1029 fn u32(&mut self) -> Result<u32, DecodeError> {
1030 let b = self.take(4)?;
1031 Ok(u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
1032 }
1033
1034 /// Only the marker-index basis needs eight bytes (#490) — see `Frame::evicted_total`
1035 /// for why that one is not a `u32` like every other header scalar.
1036 fn u64(&mut self) -> Result<u64, DecodeError> {
1037 let b = self.take(8)?;
1038 Ok(u64::from_le_bytes([
1039 b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7],
1040 ]))
1041 }
1042}