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