Skip to main content

brink_runtime/
transcript.rs

1//! Transcript binary serialization (`.brkt` format).
2//!
3//! A transcript is a serialized `Vec<OutputPart>` — the append-only log of
4//! all output parts produced during story execution. Combined with an `.inkb`
5//! program and optional `.inkl` locale data, a transcript can be re-rendered
6//! in any language without re-executing the story.
7//!
8//! ## Binary format
9//!
10//! ```text
11//! Header (16 bytes):
12//!   b"BRKT"           magic (4)
13//!   u16 LE            version = 1 (2)
14//!   u16 LE            reserved (2)
15//!   u32 LE            source_checksum (4)
16//!   u32 LE            content CRC-32 (4)
17//!
18//! Body:
19//!   u32 LE            top-level part count
20//!   [Part]*           encoded top-level parts
21//!
22//!   u32 LE            fragment count
23//!   ( u32 LE          this fragment's part count
24//!     [Part]*          encoded fragment parts
25//!   )*
26//!
27//!   ( u32 LE          this fragment's tag count      -- #953, trailing section
28//!     [str]*           tags, in fragment order        (see below)
29//!   )*
30//! ```
31//!
32//! Both "part count" fields above count only **persisted** parts —
33//! `OutputPart::Checkpoint` is a transient capture marker that is filtered
34//! out before encoding (see [`is_persisted`]) and contributes zero bytes to
35//! `[Part]*`, so the count must exclude it too or a reader following this
36//! doc to extend the format would write `parts.len()` and produce a byte
37//! stream whose declared count disagrees with what it actually encoded.
38//! `OutputPart::ElementAttach`/`ElementAttachEnd` (issue #2108) are the same
39//! kind of transient, zero-byte marker for the identical reason —
40//! deliberately in-memory-only; see that variant's own doc.
41//!
42//! The fragment section and the trailing fragment-tags section are both
43//! **backward-compat optional**: `read_transcript` treats "no bytes left"
44//! at either boundary as "this section is absent", not as truncated input,
45//! and falls back to an empty `Vec` (zero fragments, or every fragment's
46//! `tags: Vec::new()`) rather than erroring. This lets a `.brkt` written
47//! before a section existed keep decoding under a newer reader.
48//!
49//! The fragment-tags section is written as a distinct trailing section
50//! *after* every fragment's parts — one `(tag count, [str]*)` block per
51//! fragment, in the same order the fragments themselves were written —
52//! rather than inlined into each fragment's own record. An inline layout
53//! could not tell "this fragment has a tags section" apart from "the next
54//! fragment's part bytes happen to start here" once a `.brkt` written
55//! before tags existed was read by tags-aware code; the trailing-section
56//! layout sidesteps that ambiguity by using the same "any bytes left?"
57//! probe already used for the fragment section itself. Fixes #953:
58//! `Fragment::tags` was silently dropped by this codec. See
59//! `write_transcript`/`read_transcript` below for the code-level version of
60//! this note.
61//!
62//! A third trailing section (as the `.inkb` v6 bump is expected to add) is
63//! **not yet safe** to bolt on the same way: see
64//! `docs/brkt-trailing-section-findings.md` for a traced report of exactly
65//! what breaks and why, written as input to #1519's design pass.
66
67use alloc::string::String;
68use alloc::sync::Arc;
69use alloc::vec::Vec;
70
71use brink_format::{DefinitionId, LineFlags, MAX_DECODE_DEPTH, MapKey, NameId, OrderedMap, Value};
72
73use crate::output::{OutputPart, resolve_lines};
74use crate::program::Program;
75
76// ── Format constants ──────────────────────────────────────────────────────
77
78const MAGIC: &[u8; 4] = b"BRKT";
79const VERSION: u16 = 1;
80const HEADER_SIZE: usize = 16;
81
82// Part tags
83const TAG_TEXT: u8 = 0x01;
84const TAG_LINE_REF: u8 = 0x02;
85const TAG_VALUE_REF: u8 = 0x03;
86const TAG_NEWLINE: u8 = 0x04;
87const TAG_SPRING: u8 = 0x05;
88const TAG_GLUE: u8 = 0x06;
89const TAG_TAG: u8 = 0x07;
90
91// Value tags (matching inkb encoding)
92const VAL_INT: u8 = 0x00;
93const VAL_FLOAT: u8 = 0x01;
94const VAL_BOOL: u8 = 0x02;
95const VAL_STRING: u8 = 0x03;
96const VAL_LIST: u8 = 0x04;
97const VAL_DIVERT_TARGET: u8 = 0x05;
98const VAL_NULL: u8 = 0x06;
99// Shared numeric surface with the `.inkb` format (`inkb::VAL_VAR_POINTER`). A
100// `VariablePointer` is preserved distinctly (T1c, #700) so a `ref`-bound
101// closure env entry round-trips losslessly — the old code collapsed it to
102// `VAL_DIVERT_TARGET`, which is fine for a bare pointer (display-identical) but
103// would corrupt a captured `ref` cell inside a persisted function value.
104const VAL_VAR_POINTER: u8 = 0x07;
105const VAL_FRAGMENT_REF: u8 = 0x08;
106// v4 collection tags — shared numeric surface with the `.inkb` format
107// (`docs/format-v4-rfc.md` §1). Reachable today via `Opcode::EmitValue`, which
108// pushes any popped stack value (including a binding/external return that since
109// #525 can be a collection) into `OutputPart::ValueRef`.
110const VAL_ARRAY: u8 = 0x09;
111const VAL_MAP: u8 = 0x0A;
112// T1c function-value tags — shared numeric surface with the `.inkb` format
113// (`docs/format-v4-rfc.md` §1). A function value can ride the append-only
114// transcript / journal / speculation snapshots as an ordinary value (spec §6:
115// "function values save like every other value"), e.g. via `Opcode::EmitValue`
116// or a saved binding.
117const VAL_FN_REF: u8 = 0x0B;
118const VAL_CLOSURE: u8 = 0x0C;
119// T1d handle tag — shared numeric surface with the `.inkb` format
120// (`docs/format-v4-rfc.md` §1: `kind NameId, u64 id`). A handle received
121// from a binding rides the append-only transcript / journal / speculation
122// snapshots as an ordinary value (`docs/t1d-spec.md` §2: "handles appear in
123// saves, journals, and speculation snapshots as ordinary values"), e.g. via
124// `Opcode::EmitValue` or a saved binding.
125const VAL_HANDLE: u8 = 0x0D;
126// TM-4 record tag — shared numeric surface with the `.inkb` format
127// (`docs/format-v4-rfc.md` §1: `ShapeId`, then field values in shape order).
128const VAL_RECORD: u8 = 0x0F;
129// T1e projection tag — shared numeric surface with the `.inkb` format
130// (`docs/format-v4-rfc.md` §1: cell reference, u8 segment count, segments).
131// A projection rides the append-only transcript / journal / speculation
132// snapshots as an ordinary value (`docs/t1e-spec.md` §3: "Saves/journal/
133// speculation: ordinary values"), e.g. via `Opcode::EmitValue`.
134const VAL_PROJECTION: u8 = 0x0E;
135/// NS-A1 Option value tag (`docs/stdlib-spec.md` §1.4): flag byte (0 =
136/// `none`, 1 = `some`), then the inner value when `some` — mirrors the
137/// `.inkb` `VAL_OPTION` wire form exactly, like every tag above.
138const VAL_OPTION: u8 = 0x10;
139/// NS-A5 range value tag (`docs/stdlib-spec.md` §7, F7): start i32, end
140/// i32, one flag byte (0 = `..` exclusive, 1 = `..=` inclusive) — mirrors
141/// the `.inkb` `VAL_RANGE` wire form exactly. Flat: never recurses, no
142/// depth accounting (a range holds two ints, never another value).
143const VAL_RANGE: u8 = 0x11;
144// NS-A8 tower value tags (`docs/tower-mini-spec.md` T5): explicit
145// little-endian f32 lanes — vec/quat `x, y(, z, w)`, matrices column-major
146// column-by-column — mirroring the `.inkb` wire form exactly, like every
147// tag above. NEVER glam's memory layout: lanes go through glam's explicit
148// `to_array`/`from_array`/`to_cols_array`/`from_cols_array` conversions.
149const VAL_VEC2: u8 = 0x12;
150const VAL_VEC3: u8 = 0x13;
151const VAL_VEC4: u8 = 0x14;
152const VAL_QUAT: u8 = 0x15;
153const VAL_MAT2: u8 = 0x16;
154const VAL_MAT3: u8 = 0x17;
155const VAL_MAT4: u8 = 0x18;
156/// NS-A7 weighted tables (`docs/stdlib-spec.md` §8): mirrors the `.inkb`
157/// `VAL_WEIGHTED` wire form exactly — u32 entry count, then per entry an
158/// i32 weight and a recursively-encoded value. Depth-counted like the
159/// collection tags; the reader enforces the evidence-by-construction
160/// invariant (non-empty, weights ≥ 1).
161const VAL_WEIGHTED: u8 = 0x19;
162const PROJ_SEG_INDEX: u8 = 0x00;
163const PROJ_SEG_KEY: u8 = 0x01;
164
165// ── Error type ────────────────────────────────────────────────────────────
166
167/// Errors from transcript serialization/deserialization.
168#[derive(Debug, thiserror::Error)]
169pub enum TranscriptError {
170    #[error("invalid magic: expected BRKT")]
171    InvalidMagic,
172    #[error("unsupported version: {0}")]
173    UnsupportedVersion(u16),
174    #[error("checksum mismatch: transcript {transcript:#010x} != program {program:#010x}")]
175    ChecksumMismatch { transcript: u32, program: u32 },
176    #[error("integrity check failed: content CRC-32 mismatch")]
177    IntegrityCheckFailed,
178    #[error("unexpected end of data")]
179    UnexpectedEof,
180    #[error("invalid part tag: {0:#04x}")]
181    InvalidPartTag(u8),
182    #[error("invalid value tag: {0:#04x}")]
183    InvalidValueTag(u8),
184    #[error("invalid UTF-8")]
185    InvalidUtf8,
186    #[error("invalid definition ID")]
187    InvalidDefinitionId,
188    #[error("value nesting exceeded max decode depth ({0})")]
189    MaxDepthExceeded(usize),
190    /// A `VAL_MAP` entry list carried the same key twice. A legitimate
191    /// writer never emits this — `OrderedMap::insert` de-duplicates on the
192    /// write side — so a repeated key is a corrupt or crafted `.brkt`; the
193    /// content-based `OrderedMap` `Eq` (issue #909) assumes each key appears
194    /// once, so this is rejected rather than silently keeping the last
195    /// occurrence (issue #985).
196    #[error("duplicate key in map value")]
197    DuplicateMapKey,
198}
199
200// ── Write ─────────────────────────────────────────────────────────────────
201
202/// Serialize a transcript to the `.brkt` binary format.
203///
204/// Checkpoint parts are filtered out (they are transient capture markers
205/// that should never appear in a persisted transcript).
206#[expect(clippy::cast_possible_truncation)]
207pub fn write_transcript(
208    parts: &[OutputPart],
209    source_checksum: u32,
210    fragments: &[crate::output::Fragment],
211) -> Vec<u8> {
212    let mut body = Vec::new();
213
214    // Count non-Checkpoint parts
215    let count = parts.iter().filter(|p| is_persisted(p)).count() as u32;
216    write_u32(&mut body, count);
217
218    for part in parts {
219        encode_part(part, &mut body);
220    }
221
222    // Serialize fragments
223    write_u32(&mut body, fragments.len() as u32);
224    for fragment in fragments {
225        let filtered_count = fragment.parts.iter().filter(|p| is_persisted(p)).count() as u32;
226        write_u32(&mut body, filtered_count);
227        for part in &fragment.parts {
228            encode_part(part, &mut body);
229        }
230    }
231
232    // Serialize fragment tags, appended as a trailing section *after* every
233    // fragment's parts (rather than inline per-fragment) so that a `.brkt`
234    // file written before this section existed remains readable: the reader
235    // detects the section's absence via a plain "any bytes left?" check (the
236    // same backward-compat idiom already used for the fragment section
237    // itself, above) and falls back to empty tags, instead of misreading a
238    // later fragment's part bytes as an earlier fragment's tag bytes (which
239    // an inline per-fragment layout could not distinguish after the fact).
240    // Fixes #953: `Fragment::tags` was silently dropped by this codec.
241    for fragment in fragments {
242        write_u32(&mut body, fragment.tags.len() as u32);
243        for tag in &fragment.tags {
244            write_str(&mut body, tag);
245        }
246    }
247
248    // Build header
249    let content_crc = crc32(&body);
250    let mut buf = Vec::with_capacity(HEADER_SIZE + body.len());
251    buf.extend_from_slice(MAGIC);
252    write_u16(&mut buf, VERSION);
253    write_u16(&mut buf, 0); // reserved
254    write_u32(&mut buf, source_checksum);
255    write_u32(&mut buf, content_crc);
256    buf.extend(body);
257    buf
258}
259
260// ── Read ──────────────────────────────────────────────────────────────────
261
262/// A decoded transcript: the output parts, the source program's checksum
263/// (to verify compatibility before rendering), and the captured fragments
264/// (for re-rendering choice display text and computed substrings).
265///
266/// The caller should validate `source_checksum` against the program's
267/// checksum (via [`Program::source_checksum`](crate::Program::source_checksum))
268/// before passing `parts` to [`render_transcript`].
269#[derive(Debug, Clone)]
270pub struct TranscriptData {
271    pub parts: Vec<OutputPart>,
272    pub source_checksum: u32,
273    pub fragments: Vec<crate::output::Fragment>,
274}
275
276/// Deserialize a transcript from the `.brkt` binary format.
277pub fn read_transcript(bytes: &[u8]) -> Result<TranscriptData, TranscriptError> {
278    if bytes.len() < HEADER_SIZE {
279        return Err(TranscriptError::UnexpectedEof);
280    }
281
282    // Validate header
283    if &bytes[0..4] != MAGIC {
284        return Err(TranscriptError::InvalidMagic);
285    }
286    let mut off = 4;
287    let version = read_u16(bytes, &mut off)?;
288    if version != VERSION {
289        return Err(TranscriptError::UnsupportedVersion(version));
290    }
291    let _reserved = read_u16(bytes, &mut off)?;
292    let source_checksum = read_u32(bytes, &mut off)?;
293    let expected_crc = read_u32(bytes, &mut off)?;
294
295    // Validate body integrity
296    let body = &bytes[HEADER_SIZE..];
297    if crc32(body) != expected_crc {
298        return Err(TranscriptError::IntegrityCheckFailed);
299    }
300
301    // Decode parts
302    let mut off = HEADER_SIZE;
303    let count = read_u32(bytes, &mut off)? as usize;
304    let mut parts = Vec::with_capacity(count);
305
306    for _ in 0..count {
307        parts.push(decode_part(bytes, &mut off)?);
308    }
309
310    // Deserialize fragments
311    let fragment_count = if off < bytes.len() {
312        read_u32(bytes, &mut off)? as usize
313    } else {
314        0 // backward compat: old transcripts without fragments
315    };
316    let mut fragments = Vec::with_capacity(fragment_count);
317    for _ in 0..fragment_count {
318        let frag_part_count = read_u32(bytes, &mut off)? as usize;
319        let mut frag_parts = Vec::with_capacity(frag_part_count);
320        for _ in 0..frag_part_count {
321            frag_parts.push(decode_part(bytes, &mut off)?);
322        }
323        fragments.push(crate::output::Fragment {
324            parts: frag_parts,
325            tags: Vec::new(),
326        });
327    }
328
329    // Fragment tags (fixes #953): a trailing section written after every
330    // fragment's parts (see `write_transcript`'s matching comment). Older
331    // transcripts written before this section existed end exactly at the
332    // fragment section, so `off == bytes.len()` there and every fragment
333    // keeps the empty `tags` it was constructed with above — the same
334    // observable (if buggy) behavior those files always had, preserved for
335    // backward compatibility rather than erroring on legacy saves.
336    if off < bytes.len() {
337        for fragment in &mut fragments {
338            let tag_count = read_u32(bytes, &mut off)? as usize;
339            let mut tags = Vec::with_capacity(tag_count.min(bytes.len().saturating_sub(off)));
340            for _ in 0..tag_count {
341                tags.push(read_str(bytes, &mut off)?);
342            }
343            fragment.tags = tags;
344        }
345    }
346
347    Ok(TranscriptData {
348        parts,
349        source_checksum,
350        fragments,
351    })
352}
353
354// ── Render ────────────────────────────────────────────────────────────────
355
356/// Re-render a transcript against the given line tables.
357///
358/// Applies glue resolution, Spring spacing, and line trimming — the same
359/// pipeline as `flush_lines` — producing `(text, tags)` tuples per line.
360pub fn render_transcript(
361    parts: &[OutputPart],
362    program: &Program,
363    line_tables: &[Vec<brink_format::LineEntry>],
364    resolver: Option<&dyn brink_format::PluralResolver>,
365    fragments: &[crate::output::Fragment],
366) -> Vec<(String, Vec<String>)> {
367    // Element-attachment data (issue #2108) is dropped here — moot in
368    // practice, since `OutputPart::ElementAttach`/`ElementAttachEnd` are not
369    // persisted (`is_persisted`, below), so a `.brkt`-sourced `parts` slice
370    // never contains any to begin with. This function's public contract
371    // (`(text, tags)`) stays unchanged either way.
372    resolve_lines(parts, program, line_tables, resolver, fragments)
373        .into_iter()
374        .map(|(text, tags, _element)| (text, tags))
375        .collect()
376}
377
378// ── Part codec ────────────────────────────────────────────────────────────
379//
380// One shared encode/decode pair for `OutputPart`, used by both the
381// top-level part list and each fragment's part list in `write_transcript`/
382// `read_transcript`. Before this, the two call sites hand-duplicated the
383// same match arms; #953 was exactly that duplication silently dropping
384// `Fragment::tags` because the two loops drifted. Any new `OutputPart`
385// variant that always writes bytes needs exactly one new arm here, reached
386// from both loops. A new *transient* (zero-byte) variant — like
387// `OutputPart::Checkpoint` — additionally needs its own arm added to
388// `is_persisted` below, which both part-count filters share; see that
389// function's doc comment.
390
391/// Returns whether `part` is written to the persisted `.brkt` format.
392///
393/// `OutputPart::Checkpoint` is the one transient capture marker that is
394/// filtered out (it writes zero bytes in [`encode_part`]). This predicate is
395/// shared by both of `write_transcript`'s part-count computations (the
396/// top-level count and each fragment's `filtered_count`) so they cannot
397/// drift from each other or from `encode_part`'s zero-byte arm. Any future
398/// transient variant must be added here *and* to `encode_part`'s zero-byte
399/// arm in lockstep — otherwise the written count disagrees with the emitted
400/// bytes and `read_transcript` misreads the part list.
401fn is_persisted(part: &OutputPart) -> bool {
402    !matches!(
403        part,
404        OutputPart::Checkpoint | OutputPart::ElementAttach(..) | OutputPart::ElementAttachEnd
405    )
406}
407
408/// Encode a single [`OutputPart`] (its tag byte plus payload) onto `buf`.
409/// `OutputPart::Checkpoint` writes nothing — it is a transient capture
410/// marker filtered out of the persisted `.brkt` format by the caller's part
411/// count (see `write_transcript` and [`is_persisted`]).
412#[expect(clippy::cast_possible_truncation)]
413fn encode_part(part: &OutputPart, buf: &mut Vec<u8>) {
414    match part {
415        OutputPart::Text(s) => {
416            write_u8(buf, TAG_TEXT);
417            write_str(buf, s);
418        }
419        OutputPart::LineRef {
420            container_idx,
421            line_idx,
422            slots,
423            flags,
424        } => {
425            write_u8(buf, TAG_LINE_REF);
426            write_u32(buf, *container_idx);
427            write_u16(buf, *line_idx);
428            write_u8(buf, flags.bits());
429            write_u16(buf, slots.len() as u16);
430            for val in slots {
431                encode_value(val, buf);
432            }
433        }
434        OutputPart::ValueRef(val) => {
435            write_u8(buf, TAG_VALUE_REF);
436            encode_value(val, buf);
437        }
438        OutputPart::Newline => write_u8(buf, TAG_NEWLINE),
439        OutputPart::Spring => write_u8(buf, TAG_SPRING),
440        OutputPart::Glue => write_u8(buf, TAG_GLUE),
441        OutputPart::Tag(s) => {
442            write_u8(buf, TAG_TAG);
443            write_str(buf, s);
444        }
445        // `Checkpoint` is filtered out (transient capture marker).
446        // `ElementAttach`/`ElementAttachEnd` (issue #2108) are the same kind
447        // of transient, in-memory-only marker — see `is_persisted`'s doc
448        // and `OutputPart::ElementAttach`'s own doc for why they never
449        // reach the `.brkt` wire format either.
450        OutputPart::Checkpoint | OutputPart::ElementAttach(..) | OutputPart::ElementAttachEnd => {}
451    }
452}
453
454/// Decode a single [`OutputPart`] (its tag byte plus payload) from `bytes`
455/// at `*off`, advancing `*off` past it. The counterpart of [`encode_part`].
456fn decode_part(bytes: &[u8], off: &mut usize) -> Result<OutputPart, TranscriptError> {
457    let tag = read_u8(bytes, off)?;
458    let part = match tag {
459        TAG_TEXT => OutputPart::Text(read_str(bytes, off)?),
460        TAG_LINE_REF => {
461            let container_idx = read_u32(bytes, off)?;
462            let line_idx = read_u16(bytes, off)?;
463            let flags_bits = read_u8(bytes, off)?;
464            let flags = LineFlags::from_bits_truncate(flags_bits);
465            let slot_count = read_u16(bytes, off)? as usize;
466            let mut slots = Vec::with_capacity(slot_count);
467            for _ in 0..slot_count {
468                slots.push(decode_value(bytes, off, 0)?);
469            }
470            OutputPart::LineRef {
471                container_idx,
472                line_idx,
473                slots,
474                flags,
475            }
476        }
477        TAG_VALUE_REF => OutputPart::ValueRef(decode_value(bytes, off, 0)?),
478        TAG_NEWLINE => OutputPart::Newline,
479        TAG_SPRING => OutputPart::Spring,
480        TAG_GLUE => OutputPart::Glue,
481        TAG_TAG => OutputPart::Tag(read_str(bytes, off)?),
482        _ => return Err(TranscriptError::InvalidPartTag(tag)),
483    };
484    Ok(part)
485}
486
487// ── Codec helpers (self-contained, no dependency on brink-format internals) ──
488
489fn write_u8(buf: &mut Vec<u8>, v: u8) {
490    buf.push(v);
491}
492
493fn write_u16(buf: &mut Vec<u8>, v: u16) {
494    buf.extend_from_slice(&v.to_le_bytes());
495}
496
497fn write_u32(buf: &mut Vec<u8>, v: u32) {
498    buf.extend_from_slice(&v.to_le_bytes());
499}
500
501fn write_u64(buf: &mut Vec<u8>, v: u64) {
502    buf.extend_from_slice(&v.to_le_bytes());
503}
504
505fn write_i32(buf: &mut Vec<u8>, v: i32) {
506    buf.extend_from_slice(&v.to_le_bytes());
507}
508
509#[expect(clippy::cast_possible_truncation)]
510fn write_str(buf: &mut Vec<u8>, s: &str) {
511    write_u32(buf, s.len() as u32);
512    buf.extend_from_slice(s.as_bytes());
513}
514
515fn write_def_id(buf: &mut Vec<u8>, id: DefinitionId) {
516    write_u64(buf, id.to_raw());
517}
518
519fn read_u8(buf: &[u8], off: &mut usize) -> Result<u8, TranscriptError> {
520    if *off >= buf.len() {
521        return Err(TranscriptError::UnexpectedEof);
522    }
523    let v = buf[*off];
524    *off += 1;
525    Ok(v)
526}
527
528fn read_u16(buf: &[u8], off: &mut usize) -> Result<u16, TranscriptError> {
529    if *off + 2 > buf.len() {
530        return Err(TranscriptError::UnexpectedEof);
531    }
532    let v = u16::from_le_bytes([buf[*off], buf[*off + 1]]);
533    *off += 2;
534    Ok(v)
535}
536
537fn read_u32(buf: &[u8], off: &mut usize) -> Result<u32, TranscriptError> {
538    if *off + 4 > buf.len() {
539        return Err(TranscriptError::UnexpectedEof);
540    }
541    let v = u32::from_le_bytes([buf[*off], buf[*off + 1], buf[*off + 2], buf[*off + 3]]);
542    *off += 4;
543    Ok(v)
544}
545
546fn read_i32(buf: &[u8], off: &mut usize) -> Result<i32, TranscriptError> {
547    if *off + 4 > buf.len() {
548        return Err(TranscriptError::UnexpectedEof);
549    }
550    let v = i32::from_le_bytes([buf[*off], buf[*off + 1], buf[*off + 2], buf[*off + 3]]);
551    *off += 4;
552    Ok(v)
553}
554
555fn read_f32(buf: &[u8], off: &mut usize) -> Result<f32, TranscriptError> {
556    if *off + 4 > buf.len() {
557        return Err(TranscriptError::UnexpectedEof);
558    }
559    let v = f32::from_le_bytes([buf[*off], buf[*off + 1], buf[*off + 2], buf[*off + 3]]);
560    *off += 4;
561    Ok(v)
562}
563
564fn read_u64(buf: &[u8], off: &mut usize) -> Result<u64, TranscriptError> {
565    if *off + 8 > buf.len() {
566        return Err(TranscriptError::UnexpectedEof);
567    }
568    let v = u64::from_le_bytes([
569        buf[*off],
570        buf[*off + 1],
571        buf[*off + 2],
572        buf[*off + 3],
573        buf[*off + 4],
574        buf[*off + 5],
575        buf[*off + 6],
576        buf[*off + 7],
577    ]);
578    *off += 8;
579    Ok(v)
580}
581
582fn read_str(buf: &[u8], off: &mut usize) -> Result<String, TranscriptError> {
583    let len = read_u32(buf, off)? as usize;
584    if *off + len > buf.len() {
585        return Err(TranscriptError::UnexpectedEof);
586    }
587    let bytes = &buf[*off..*off + len];
588    *off += len;
589    String::from_utf8(bytes.to_vec()).map_err(|_| TranscriptError::InvalidUtf8)
590}
591
592fn read_def_id(buf: &[u8], off: &mut usize) -> Result<DefinitionId, TranscriptError> {
593    let raw = read_u64(buf, off)?;
594    DefinitionId::from_raw(raw).ok_or(TranscriptError::InvalidDefinitionId)
595}
596
597// ── Value encoding ────────────────────────────────────────────────────────
598
599#[expect(clippy::cast_possible_truncation)]
600#[expect(
601    clippy::too_many_lines,
602    reason = "one match arm per value tag — T1e's VAL_PROJECTION arm pushed this past 100"
603)]
604fn encode_value(v: &Value, buf: &mut Vec<u8>) {
605    match v {
606        Value::Int(n) => {
607            write_u8(buf, VAL_INT);
608            write_i32(buf, *n);
609        }
610        Value::Float(n) => {
611            write_u8(buf, VAL_FLOAT);
612            buf.extend_from_slice(&n.to_le_bytes());
613        }
614        Value::Bool(b) => {
615            write_u8(buf, VAL_BOOL);
616            write_u8(buf, u8::from(*b));
617        }
618        Value::String(s) => {
619            write_u8(buf, VAL_STRING);
620            write_str(buf, s);
621        }
622        Value::List(lv) => {
623            write_u8(buf, VAL_LIST);
624            write_u32(buf, lv.items.len() as u32);
625            for item in &lv.items {
626                write_def_id(buf, *item);
627            }
628            write_u32(buf, lv.origins.len() as u32);
629            for origin in &lv.origins {
630                write_def_id(buf, *origin);
631            }
632        }
633        Value::DivertTarget(id) => {
634            write_u8(buf, VAL_DIVERT_TARGET);
635            write_def_id(buf, *id);
636        }
637        Value::VariablePointer(id) => {
638            write_u8(buf, VAL_VAR_POINTER);
639            write_def_id(buf, *id);
640        }
641        Value::FragmentRef(idx) => {
642            write_u8(buf, VAL_FRAGMENT_REF);
643            write_u32(buf, *idx);
644        }
645        // TempPointer is runtime-only.
646        Value::TempPointer { .. } | Value::Null => {
647            write_u8(buf, VAL_NULL);
648        }
649        // Collections encode as trees (v4, `docs/format-v4-rfc.md` §1): a length
650        // prefix then the recursively-encoded elements / key-value pairs. Arc
651        // sharing is not preserved on the wire (value-model-spec §5).
652        Value::Array(items) => {
653            write_u8(buf, VAL_ARRAY);
654            write_u32(buf, items.len() as u32);
655            for item in items.iter() {
656                encode_value(item, buf);
657            }
658        }
659        Value::Map(map) => {
660            write_u8(buf, VAL_MAP);
661            write_u32(buf, map.len() as u32);
662            for (key, val) in map.iter() {
663                encode_map_key(key, buf);
664                encode_value(val, buf);
665            }
666        }
667        Value::Record { shape, fields } => {
668            write_u8(buf, VAL_RECORD);
669            write_u32(buf, shape.0);
670            write_u32(buf, fields.len() as u32);
671            for field in fields.iter() {
672                encode_value(field, buf);
673            }
674        }
675        // Function values (T1c, spec §6): save like every other value. `FnRef`
676        // is the fn token; `Closure` adds a u32-counted env of `{NameId, kind
677        // u8, value}` entries — the named/moded env the rehydration check reads.
678        Value::FnRef(target) => {
679            write_u8(buf, VAL_FN_REF);
680            write_def_id(buf, *target);
681        }
682        Value::Closure(c) => {
683            write_u8(buf, VAL_CLOSURE);
684            write_def_id(buf, c.target);
685            write_u32(buf, c.env.len() as u32);
686            for entry in &c.env {
687                write_u16(buf, entry.name.0);
688                write_u8(buf, u8::from(entry.is_ref));
689                encode_value(&entry.payload, buf);
690            }
691        }
692        // Handle values (T1d, spec §5: "the journal records returned tokens";
693        // §2: "handles appear in saves, journals, and speculation snapshots
694        // as ordinary values"). Token equality holds at this level; rebinding
695        // to a live resource happens at the host boundary, not here.
696        Value::Handle { kind, id } => {
697            write_u8(buf, VAL_HANDLE);
698            write_u16(buf, kind.0);
699            write_u64(buf, *id);
700        }
701        // Projection values (T1e, spec §3: "Saves/journal/speculation:
702        // ordinary values"). Segment kind `2=range` is RESERVED and never
703        // written — `ProjSegment` has no variant to produce it.
704        Value::Projection(p) => {
705            write_u8(buf, VAL_PROJECTION);
706            write_def_id(buf, p.cell);
707            write_u8(buf, p.segments.len() as u8);
708            for seg in &p.segments {
709                match seg {
710                    brink_format::ProjSegment::Index(n) => {
711                        write_u8(buf, PROJ_SEG_INDEX);
712                        write_i32(buf, *n);
713                    }
714                    brink_format::ProjSegment::Key(v) => {
715                        write_u8(buf, PROJ_SEG_KEY);
716                        encode_value(v, buf);
717                    }
718                }
719            }
720        }
721        // Option values (NS-A1, `docs/stdlib-spec.md` §1.4): an Option in a
722        // global/frame slot journals as an ordinary value, same as every
723        // variant above.
724        Value::OptionVal(inner) => {
725            write_u8(buf, VAL_OPTION);
726            match inner {
727                None => write_u8(buf, 0),
728                Some(v) => {
729                    write_u8(buf, 1);
730                    encode_value(v, buf);
731                }
732            }
733        }
734        // Range values (NS-A5, F7): a range in a global/frame slot journals
735        // as an ordinary value — this is exactly the FlowFrame iterator-
736        // spill durability the F7 ruling demanded (`for i in 0..n` across
737        // an `await` parks its snapshot range in the frame record). The
738        // written form is preserved.
739        Value::Range {
740            start,
741            end,
742            inclusive,
743        } => {
744            write_u8(buf, VAL_RANGE);
745            write_i32(buf, *start);
746            write_i32(buf, *end);
747            write_u8(buf, u8::from(*inclusive));
748        }
749        // Tower values (NS-A8, `docs/tower-mini-spec.md` T5): explicit
750        // little-endian f32 lanes in the pinned order, via glam's explicit
751        // array conversions — mirrors the `.inkb` wire form exactly.
752        Value::Vec2(v) => {
753            write_u8(buf, VAL_VEC2);
754            write_f32_lanes(buf, &v.to_array());
755        }
756        Value::Vec3(v) => {
757            write_u8(buf, VAL_VEC3);
758            write_f32_lanes(buf, &v.to_array());
759        }
760        Value::Vec4(v) => {
761            write_u8(buf, VAL_VEC4);
762            write_f32_lanes(buf, &v.to_array());
763        }
764        Value::Quat(q) => {
765            write_u8(buf, VAL_QUAT);
766            write_f32_lanes(buf, &q.to_array());
767        }
768        Value::Mat2(m) => {
769            write_u8(buf, VAL_MAT2);
770            write_f32_lanes(buf, &m.to_cols_array());
771        }
772        Value::Mat3(m) => {
773            write_u8(buf, VAL_MAT3);
774            write_f32_lanes(buf, &m.to_cols_array());
775        }
776        Value::Mat4(m) => {
777            write_u8(buf, VAL_MAT4);
778            write_f32_lanes(buf, &m.to_cols_array());
779        }
780        Value::Weighted(w) => {
781            write_u8(buf, VAL_WEIGHTED);
782            write_u32(buf, w.entries.len() as u32);
783            for (weight, value) in &w.entries {
784                write_i32(buf, *weight);
785                encode_value(value, buf);
786            }
787        }
788    }
789}
790
791/// NS-A8 (`docs/tower-mini-spec.md` T5): write tower lanes as explicit
792/// little-endian f32s, one by one — the hand-serialized tower wire form
793/// (same helper shape as the `.inkb` writer's).
794fn write_f32_lanes(buf: &mut Vec<u8>, lanes: &[f32]) {
795    for lane in lanes {
796        buf.extend_from_slice(&lane.to_le_bytes());
797    }
798}
799
800/// NS-A8 (`docs/tower-mini-spec.md` T5): read `N` explicit little-endian
801/// f32 lanes; the caller rebuilds the glam value through its explicit
802/// `from_array`/`from_cols_array` constructor.
803fn read_f32_lanes<const N: usize>(
804    buf: &[u8],
805    off: &mut usize,
806) -> Result<[f32; N], TranscriptError> {
807    let mut lanes = [0.0f32; N];
808    for lane in &mut lanes {
809        *lane = read_f32(buf, off)?;
810    }
811    Ok(lanes)
812}
813
814/// Encode a [`MapKey`] using the scalar `VAL_*` tag surface (`int`/`string`/
815/// `bool` — the v1 key domain). Self-describing so the reader rejects a
816/// non-scalar key tag.
817fn encode_map_key(key: &MapKey, buf: &mut Vec<u8>) {
818    match key {
819        MapKey::Int(n) => {
820            write_u8(buf, VAL_INT);
821            write_i32(buf, *n);
822        }
823        MapKey::Str(s) => {
824            write_u8(buf, VAL_STRING);
825            write_str(buf, s);
826        }
827        MapKey::Bool(b) => {
828            write_u8(buf, VAL_BOOL);
829            write_u8(buf, u8::from(*b));
830        }
831    }
832}
833
834#[expect(
835    clippy::too_many_lines,
836    reason = "one match arm per value tag — T1e's VAL_PROJECTION arm pushed this past 100"
837)]
838fn decode_value(buf: &[u8], off: &mut usize, depth: usize) -> Result<Value, TranscriptError> {
839    if depth > MAX_DECODE_DEPTH {
840        return Err(TranscriptError::MaxDepthExceeded(MAX_DECODE_DEPTH));
841    }
842    let tag = read_u8(buf, off)?;
843    match tag {
844        VAL_INT => Ok(Value::Int(read_i32(buf, off)?)),
845        VAL_FLOAT => Ok(Value::Float(read_f32(buf, off)?)),
846        VAL_BOOL => {
847            let b = read_u8(buf, off)?;
848            Ok(Value::Bool(b != 0))
849        }
850        VAL_STRING => {
851            let s = read_str(buf, off)?;
852            Ok(Value::String(Arc::from(s.as_str())))
853        }
854        VAL_LIST => {
855            let item_count = read_u32(buf, off)? as usize;
856            let mut items = Vec::with_capacity(item_count);
857            for _ in 0..item_count {
858                items.push(read_def_id(buf, off)?);
859            }
860            let origin_count = read_u32(buf, off)? as usize;
861            let mut origins = Vec::with_capacity(origin_count);
862            for _ in 0..origin_count {
863                origins.push(read_def_id(buf, off)?);
864            }
865            Ok(Value::List(Arc::new(brink_format::ListValue {
866                items,
867                origins,
868            })))
869        }
870        VAL_DIVERT_TARGET => {
871            let id = read_def_id(buf, off)?;
872            Ok(Value::DivertTarget(id))
873        }
874        VAL_VAR_POINTER => {
875            let id = read_def_id(buf, off)?;
876            Ok(Value::VariablePointer(id))
877        }
878        VAL_FRAGMENT_REF => Ok(Value::FragmentRef(read_u32(buf, off)?)),
879        VAL_NULL => Ok(Value::Null),
880        VAL_ARRAY => {
881            let len = read_u32(buf, off)? as usize;
882            let mut items = Vec::with_capacity(len.min(buf.len().saturating_sub(*off)));
883            for _ in 0..len {
884                items.push(decode_value(buf, off, depth + 1)?);
885            }
886            Ok(Value::array(items))
887        }
888        VAL_MAP => {
889            let len = read_u32(buf, off)? as usize;
890            let mut map = OrderedMap::with_capacity(len.min(buf.len().saturating_sub(*off)));
891            for _ in 0..len {
892                let key = decode_map_key(buf, off)?;
893                let val = decode_value(buf, off, depth + 1)?;
894                // A repeated key would violate the content-based `OrderedMap`
895                // `Eq` (#909); reject rather than silently keeping the last
896                // occurrence (#985).
897                if map.contains_key(&key) {
898                    return Err(TranscriptError::DuplicateMapKey);
899                }
900                map.insert(key, val);
901            }
902            Ok(Value::map(map))
903        }
904        VAL_RECORD => {
905            let shape = brink_format::ShapeId(read_u32(buf, off)?);
906            let len = read_u32(buf, off)? as usize;
907            let mut fields = Vec::with_capacity(len.min(buf.len().saturating_sub(*off)));
908            for _ in 0..len {
909                fields.push(decode_value(buf, off, depth + 1)?);
910            }
911            Ok(Value::record(shape, fields))
912        }
913        VAL_FN_REF => Ok(Value::FnRef(read_def_id(buf, off)?)),
914        VAL_CLOSURE => {
915            let target = read_def_id(buf, off)?;
916            let count = read_u32(buf, off)? as usize;
917            let mut env = Vec::with_capacity(count.min(buf.len().saturating_sub(*off)));
918            for _ in 0..count {
919                let name = brink_format::NameId(read_u16(buf, off)?);
920                let is_ref = read_u8(buf, off)? != 0;
921                let payload = decode_value(buf, off, depth + 1)?;
922                env.push(brink_format::ClosureEnvEntry {
923                    name,
924                    is_ref,
925                    payload,
926                });
927            }
928            Ok(Value::closure(target, env))
929        }
930        // Handle values (T1d, `docs/format-v4-rfc.md` §1).
931        VAL_HANDLE => {
932            let kind = NameId(read_u16(buf, off)?);
933            let id = read_u64(buf, off)?;
934            Ok(Value::handle(kind, id))
935        }
936        // Projection values (T1e, `docs/format-v4-rfc.md` §1).
937        VAL_PROJECTION => {
938            let cell = read_def_id(buf, off)?;
939            let count = read_u8(buf, off)? as usize;
940            let mut segments = Vec::with_capacity(count.min(buf.len().saturating_sub(*off)));
941            for _ in 0..count {
942                let kind = read_u8(buf, off)?;
943                let seg = match kind {
944                    PROJ_SEG_INDEX => brink_format::ProjSegment::Index(read_i32(buf, off)?),
945                    PROJ_SEG_KEY => {
946                        brink_format::ProjSegment::Key(decode_value(buf, off, depth + 1)?)
947                    }
948                    other => return Err(TranscriptError::InvalidValueTag(other)),
949                };
950                segments.push(seg);
951            }
952            Ok(Value::projection(cell, segments))
953        }
954        // Option values (NS-A1): flag byte then inner-when-some; any other
955        // flag byte is corrupt input. Depth-counted like the collections.
956        VAL_OPTION => match read_u8(buf, off)? {
957            0 => Ok(Value::none()),
958            1 => Ok(Value::some(decode_value(buf, off, depth + 1)?)),
959            other => Err(TranscriptError::InvalidValueTag(other)),
960        },
961        // Range values (NS-A5, F7): flat — start, end, incl/excl flag; any
962        // other flag byte is corrupt input. No recursion, no depth.
963        VAL_RANGE => {
964            let start = read_i32(buf, off)?;
965            let end = read_i32(buf, off)?;
966            let inclusive = match read_u8(buf, off)? {
967                0 => false,
968                1 => true,
969                other => return Err(TranscriptError::InvalidValueTag(other)),
970            };
971            Ok(Value::range(start, end, inclusive))
972        }
973        // Tower values (NS-A8): fixed-size little-endian f32 lanes in the
974        // pinned order, rebuilt through glam's explicit array constructors.
975        // Leaves — no counts, no recursion, no depth concerns.
976        VAL_VEC2 => Ok(Value::Vec2(glam::Vec2::from_array(read_f32_lanes::<2>(
977            buf, off,
978        )?))),
979        VAL_VEC3 => Ok(Value::Vec3(glam::Vec3::from_array(read_f32_lanes::<3>(
980            buf, off,
981        )?))),
982        VAL_VEC4 => Ok(Value::Vec4(glam::Vec4::from_array(read_f32_lanes::<4>(
983            buf, off,
984        )?))),
985        VAL_QUAT => Ok(Value::Quat(glam::Quat::from_array(read_f32_lanes::<4>(
986            buf, off,
987        )?))),
988        VAL_MAT2 => Ok(Value::Mat2(glam::Mat2::from_cols_array(&read_f32_lanes::<
989            4,
990        >(
991            buf, off
992        )?))),
993        VAL_MAT3 => Ok(Value::Mat3(glam::Mat3::from_cols_array(&read_f32_lanes::<
994            9,
995        >(
996            buf, off
997        )?))),
998        VAL_MAT4 => Ok(Value::Mat4(glam::Mat4::from_cols_array(&read_f32_lanes::<
999            16,
1000        >(
1001            buf, off
1002        )?))),
1003        // Weighted tables (NS-A7): mirror of the `.inkb` reader, invariant
1004        // checks included — a violating payload is corrupt input.
1005        VAL_WEIGHTED => {
1006            let count = read_u32(buf, off)? as usize;
1007            if count == 0 {
1008                return Err(TranscriptError::InvalidValueTag(VAL_WEIGHTED));
1009            }
1010            let mut entries = Vec::with_capacity(count.min(1024));
1011            for _ in 0..count {
1012                let weight = read_i32(buf, off)?;
1013                if weight < 1 {
1014                    return Err(TranscriptError::InvalidValueTag(VAL_WEIGHTED));
1015                }
1016                let value = decode_value(buf, off, depth + 1)?;
1017                entries.push((weight, value));
1018            }
1019            Ok(Value::weighted(entries))
1020        }
1021        _ => Err(TranscriptError::InvalidValueTag(tag)),
1022    }
1023}
1024
1025/// Decode a [`MapKey`] written by `encode_map_key`: a scalar `VAL_*` tag then
1026/// its payload. Any other tag is rejected — only `int`/`string`/`bool` keys are
1027/// permitted (`docs/value-model-spec.md` §4).
1028fn decode_map_key(buf: &[u8], off: &mut usize) -> Result<MapKey, TranscriptError> {
1029    let tag = read_u8(buf, off)?;
1030    match tag {
1031        VAL_INT => Ok(MapKey::Int(read_i32(buf, off)?)),
1032        VAL_STRING => Ok(MapKey::Str(Arc::from(read_str(buf, off)?.as_str()))),
1033        VAL_BOOL => Ok(MapKey::Bool(read_u8(buf, off)? != 0)),
1034        _ => Err(TranscriptError::InvalidValueTag(tag)),
1035    }
1036}
1037
1038// ── CRC-32 ────────────────────────────────────────────────────────────────
1039
1040fn crc32(data: &[u8]) -> u32 {
1041    static TABLE: [u32; 256] = {
1042        let mut table = [0u32; 256];
1043        let mut i = 0u32;
1044        while i < 256 {
1045            let mut crc = i;
1046            let mut j = 0;
1047            while j < 8 {
1048                if crc & 1 != 0 {
1049                    crc = (crc >> 1) ^ 0xEDB8_8320;
1050                } else {
1051                    crc >>= 1;
1052                }
1053                j += 1;
1054            }
1055            table[i as usize] = crc;
1056            i += 1;
1057        }
1058        table
1059    };
1060
1061    let mut crc = 0xFFFF_FFFFu32;
1062    for &byte in data {
1063        let idx = ((crc ^ u32::from(byte)) & 0xFF) as usize;
1064        crc = (crc >> 8) ^ TABLE[idx];
1065    }
1066    crc ^ 0xFFFF_FFFF
1067}
1068
1069#[cfg(test)]
1070mod tests {
1071    use super::*;
1072    use brink_format::LineFlags;
1073
1074    #[test]
1075    fn round_trip_simple_parts() {
1076        let parts = vec![
1077            OutputPart::Text("Hello".to_string()),
1078            OutputPart::Spring,
1079            OutputPart::Newline,
1080            OutputPart::Tag("tag1".to_string()),
1081            OutputPart::Glue,
1082        ];
1083        let bytes = write_transcript(&parts, 0xDEAD_BEEF, &[]);
1084        let data = read_transcript(&bytes).unwrap();
1085        assert_eq!(data.source_checksum, 0xDEAD_BEEF);
1086        assert_eq!(data.parts.len(), 5);
1087        assert!(matches!(&data.parts[0], OutputPart::Text(s) if s == "Hello"));
1088        assert!(matches!(&data.parts[1], OutputPart::Spring));
1089        assert!(matches!(&data.parts[2], OutputPart::Newline));
1090        assert!(matches!(&data.parts[3], OutputPart::Tag(s) if s == "tag1"));
1091        assert!(matches!(&data.parts[4], OutputPart::Glue));
1092    }
1093
1094    // #1443 review finding: `write_transcript`'s top-level `count` and each
1095    // fragment's `filtered_count` used to hand-duplicate the same
1096    // `!matches!(p, OutputPart::Checkpoint)` predicate. They now both call
1097    // the single shared `is_persisted` helper, which must stay in lockstep
1098    // with `encode_part`'s zero-byte arms: `Checkpoint` and — since issue
1099    // #2108 — `ElementAttach`/`ElementAttachEnd` are transient (zero
1100    // bytes); every other variant persists.
1101    #[test]
1102    fn is_persisted_filters_transient_markers_only() {
1103        assert!(!is_persisted(&OutputPart::Checkpoint));
1104        assert!(!is_persisted(&OutputPart::ElementAttach(
1105            "speaker".to_string(),
1106            "VENDOR".to_string()
1107        )));
1108        assert!(!is_persisted(&OutputPart::ElementAttachEnd));
1109        assert!(is_persisted(&OutputPart::Text("hi".to_string())));
1110        assert!(is_persisted(&OutputPart::LineRef {
1111            container_idx: 0,
1112            line_idx: 0,
1113            slots: Vec::new(),
1114            flags: LineFlags::empty(),
1115        }));
1116        assert!(is_persisted(&OutputPart::ValueRef(Value::Bool(true))));
1117        assert!(is_persisted(&OutputPart::Newline));
1118        assert!(is_persisted(&OutputPart::Spring));
1119        assert!(is_persisted(&OutputPart::Glue));
1120        assert!(is_persisted(&OutputPart::Tag("t".to_string())));
1121    }
1122
1123    // #1443: `write_transcript`/`read_transcript` used to hand-duplicate one
1124    // match arm per `OutputPart` tag for the top-level part list and again
1125    // for each fragment's part list (plus the #953 fix for `Fragment::tags`
1126    // landing in only one of the two copies, which is exactly how that tags
1127    // regression happened). Both loops now call the single shared
1128    // `encode_part`/`decode_part` pair. This pins that: encoding the same
1129    // `OutputPart` sequence through the top-level path and through a
1130    // fragment's path produces byte-identical part payloads, and both paths
1131    // decode back to equal parts — proving one shared codec, not two copies
1132    // that happen to still agree.
1133    #[test]
1134    fn top_level_and_fragment_part_codec_are_byte_identical() {
1135        let parts = vec![
1136            OutputPart::Text("Hello".to_string()),
1137            OutputPart::LineRef {
1138                container_idx: 3,
1139                line_idx: 9,
1140                slots: vec![Value::Int(1), Value::String(Arc::from("hi"))],
1141                flags: LineFlags::ALL_WS,
1142            },
1143            OutputPart::ValueRef(Value::Bool(true)),
1144            OutputPart::Spring,
1145            OutputPart::Newline,
1146            OutputPart::Glue,
1147            OutputPart::Tag("tag1".to_string()),
1148            OutputPart::Checkpoint, // filtered identically by both paths
1149        ];
1150
1151        // Independently reconstruct the expected encoded bytes by calling
1152        // the shared `encode_part` codec directly, one call per non-Checkpoint
1153        // part — this is what both `write_transcript` loops should be
1154        // producing under the hood.
1155        let mut expected = Vec::new();
1156        for part in &parts {
1157            if !matches!(part, OutputPart::Checkpoint) {
1158                encode_part(part, &mut expected);
1159            }
1160        }
1161
1162        // Top-level loop: header, then a u32 part count, then the parts.
1163        let top_level_bytes = write_transcript(&parts, 0, &[]);
1164        let top_level_part_bytes =
1165            &top_level_bytes[HEADER_SIZE + 4..HEADER_SIZE + 4 + expected.len()];
1166        assert_eq!(
1167            top_level_part_bytes,
1168            expected.as_slice(),
1169            "top-level part encoding must match the shared codec exactly"
1170        );
1171
1172        // Fragment loop: header, u32 top-level count (0), u32 fragment count
1173        // (1), u32 this-fragment's part count, then the same parts.
1174        let fragment = crate::output::Fragment {
1175            parts: parts.clone(),
1176            tags: Vec::new(),
1177        };
1178        let fragment_bytes = write_transcript(&[], 0, &[fragment]);
1179        let frag_start = HEADER_SIZE + 4 + 4 + 4;
1180        let fragment_part_bytes = &fragment_bytes[frag_start..frag_start + expected.len()];
1181        assert_eq!(
1182            fragment_part_bytes,
1183            expected.as_slice(),
1184            "fragment part encoding must match the shared codec exactly"
1185        );
1186
1187        // And decoding both paths yields the same, correct parts.
1188        let top_level_data = read_transcript(&top_level_bytes).unwrap();
1189        let fragment_data = read_transcript(&fragment_bytes).unwrap();
1190        assert_eq!(top_level_data.parts.len(), 7); // Checkpoint filtered
1191        assert_eq!(fragment_data.fragments.len(), 1);
1192        assert_eq!(fragment_data.fragments[0].parts.len(), 7);
1193        assert_eq!(top_level_data.parts, fragment_data.fragments[0].parts);
1194    }
1195
1196    /// NS-A8 (`docs/tower-mini-spec.md` T5): a tower value in an
1197    /// `OutputPart::ValueRef` crosses the `.brkt` round-trip as explicit
1198    /// little-endian lanes — including a NaN lane, compared here by lane
1199    /// bits (a NaN-bearing vector correctly never compares equal, T4).
1200    #[test]
1201    fn round_trip_value_ref_tower() {
1202        let parts = vec![
1203            OutputPart::ValueRef(Value::Vec3(glam::Vec3::new(1.5, -0.0, 3.0))),
1204            OutputPart::ValueRef(Value::Quat(glam::Quat::from_xyzw(0.5, -0.5, 0.5, 0.5))),
1205            OutputPart::ValueRef(Value::Mat2(glam::Mat2::from_cols_array(&[
1206                1.0, 2.0, 3.0, 4.0,
1207            ]))),
1208            OutputPart::ValueRef(Value::Vec2(glam::Vec2::new(f32::NAN, 7.0))),
1209        ];
1210        let bytes = write_transcript(&parts, 0, &[]);
1211        let data = read_transcript(&bytes).unwrap();
1212        assert_eq!(data.parts.len(), 4);
1213        assert!(
1214            matches!(&data.parts[0], OutputPart::ValueRef(v) if *v == Value::Vec3(glam::Vec3::new(1.5, -0.0, 3.0)))
1215        );
1216        assert!(
1217            matches!(&data.parts[1], OutputPart::ValueRef(v) if *v == Value::Quat(glam::Quat::from_xyzw(0.5, -0.5, 0.5, 0.5)))
1218        );
1219        assert!(
1220            matches!(&data.parts[2], OutputPart::ValueRef(v) if *v == Value::Mat2(glam::Mat2::from_cols_array(&[1.0, 2.0, 3.0, 4.0])))
1221        );
1222        let OutputPart::ValueRef(Value::Vec2(v)) = &data.parts[3] else {
1223            unreachable!("expected vec2 part, got {:?}", data.parts[3]);
1224        };
1225        assert_eq!(v.x.to_bits(), f32::NAN.to_bits(), "NaN lane bits drifted");
1226        assert_eq!(v.y.to_bits(), 7.0f32.to_bits());
1227    }
1228
1229    // A collection reaches the transcript through `Opcode::EmitValue`, which
1230    // pops any stack value — including a binding/external return that since #525
1231    // can be an `Array`/`Map` — into `OutputPart::ValueRef`. This locks the v4
1232    // tree encoding of that part: structural equality, insertion order, scalar
1233    // key types, and nesting all survive the `.brkt` round-trip (#526).
1234    #[test]
1235    fn round_trip_value_ref_collections() {
1236        use brink_format::{MapKey, OrderedMap};
1237
1238        let map: OrderedMap = [
1239            (MapKey::from("name"), Value::String(Arc::from("goblin"))),
1240            (
1241                MapKey::from(1),
1242                Value::array(vec![Value::Int(10), Value::Int(20)]),
1243            ),
1244            (MapKey::from(true), Value::Bool(false)),
1245        ]
1246        .into_iter()
1247        .collect();
1248        let array = Value::array(vec![
1249            Value::Int(1),
1250            Value::String(Arc::from("two")),
1251            Value::map(map.clone()),
1252            Value::Null,
1253        ]);
1254
1255        let parts = vec![
1256            OutputPart::ValueRef(array.clone()),
1257            OutputPart::ValueRef(Value::map(map.clone())),
1258        ];
1259        let bytes = write_transcript(&parts, 42, &[]);
1260        let data = read_transcript(&bytes).unwrap();
1261
1262        assert_eq!(data.parts.len(), 2);
1263        match &data.parts[0] {
1264            OutputPart::ValueRef(v) => assert_eq!(*v, array),
1265            other => unreachable!("expected ValueRef(array), got {other:?}"),
1266        }
1267        match &data.parts[1] {
1268            OutputPart::ValueRef(v) => assert_eq!(*v, Value::map(map)),
1269            other => unreachable!("expected ValueRef(map), got {other:?}"),
1270        }
1271    }
1272
1273    // Function values (T1c, #700) persist through the transcript/journal as
1274    // ordinary values (spec §6). This locks the VAL_FN_REF / VAL_CLOSURE
1275    // encoding — the fn token, the bound-env names/modes, and both payload
1276    // shapes (`val` snapshot, `ref` VariablePointer) — across the round-trip.
1277    #[test]
1278    fn round_trip_value_ref_function_values() {
1279        use brink_format::{ClosureEnvEntry, DefinitionId, DefinitionTag, NameId};
1280
1281        let target = DefinitionId::new(DefinitionTag::Address, 7);
1282        let cell = DefinitionId::new(DefinitionTag::Address, 3);
1283        let fn_ref = Value::FnRef(target);
1284        let closure = Value::closure(
1285            target,
1286            vec![
1287                ClosureEnvEntry {
1288                    name: NameId(2),
1289                    is_ref: true,
1290                    payload: Value::VariablePointer(cell),
1291                },
1292                ClosureEnvEntry {
1293                    name: NameId(5),
1294                    is_ref: false,
1295                    payload: Value::Int(41),
1296                },
1297            ],
1298        );
1299
1300        let parts = vec![
1301            OutputPart::ValueRef(fn_ref.clone()),
1302            OutputPart::ValueRef(closure.clone()),
1303        ];
1304        let bytes = write_transcript(&parts, 7, &[]);
1305        let data = read_transcript(&bytes).unwrap();
1306
1307        assert_eq!(data.parts.len(), 2);
1308        match &data.parts[0] {
1309            OutputPart::ValueRef(v) => assert_eq!(*v, fn_ref),
1310            other => unreachable!("expected ValueRef(fn_ref), got {other:?}"),
1311        }
1312        match &data.parts[1] {
1313            OutputPart::ValueRef(v) => assert_eq!(*v, closure),
1314            other => unreachable!("expected ValueRef(closure), got {other:?}"),
1315        }
1316    }
1317
1318    // Handle values (T1d, `docs/t1d-spec.md` §2/§5) persist through the
1319    // transcript/journal codec as ordinary values — "handles appear in
1320    // saves, journals, and speculation snapshots" per the spec. This locks
1321    // the VAL_HANDLE (0x0D) encode/decode arms: a bare handle, one nested
1322    // inside a collection, and the `u64::MAX` id to exercise the full
1323    // write_u64/read_u64 leg (not just small ids that might coincidentally
1324    // round-trip through a truncated path).
1325    #[test]
1326    fn round_trip_value_ref_handle() {
1327        let handle = Value::handle(NameId(9), u64::MAX);
1328        let nested = Value::array(vec![
1329            Value::handle(NameId(3), 0),
1330            Value::String(Arc::from("goblin")),
1331        ]);
1332
1333        let parts = vec![
1334            OutputPart::ValueRef(handle.clone()),
1335            OutputPart::ValueRef(nested.clone()),
1336        ];
1337        let bytes = write_transcript(&parts, 13, &[]);
1338        let data = read_transcript(&bytes).unwrap();
1339
1340        assert_eq!(data.parts.len(), 2);
1341        match &data.parts[0] {
1342            OutputPart::ValueRef(v) => assert_eq!(*v, handle),
1343            other => unreachable!("expected ValueRef(handle), got {other:?}"),
1344        }
1345        match &data.parts[1] {
1346            OutputPart::ValueRef(v) => assert_eq!(*v, nested),
1347            other => unreachable!("expected ValueRef(nested handle), got {other:?}"),
1348        }
1349    }
1350
1351    /// T1e (`docs/t1e-spec.md` §3: "Saves/journal/speculation: ordinary
1352    /// values") — the transcript leg of the per-codec round-trip discipline
1353    /// (inkb/inkt/transcript, the wave-11 lesson): the `VAL_PROJECTION`
1354    /// (0x0E) encode/decode arms, a bare projection and one nested inside a
1355    /// collection, with a mixed index+key segment chain.
1356    #[test]
1357    fn round_trip_value_ref_projection() {
1358        use brink_format::ProjSegment;
1359
1360        let cell = DefinitionId::new(brink_format::DefinitionTag::GlobalVar, 42);
1361        let proj = Value::projection(
1362            cell,
1363            vec![
1364                ProjSegment::Key(Value::String("hp".into())),
1365                ProjSegment::Index(3),
1366            ],
1367        );
1368        let nested = Value::array(vec![Value::projection(cell, vec![]), Value::Bool(true)]);
1369
1370        let parts = vec![
1371            OutputPart::ValueRef(proj.clone()),
1372            OutputPart::ValueRef(nested.clone()),
1373        ];
1374        let bytes = write_transcript(&parts, 13, &[]);
1375        let data = read_transcript(&bytes).unwrap();
1376
1377        assert_eq!(data.parts.len(), 2);
1378        match &data.parts[0] {
1379            OutputPart::ValueRef(v) => assert_eq!(*v, proj),
1380            other => unreachable!("expected ValueRef(projection), got {other:?}"),
1381        }
1382        match &data.parts[1] {
1383            OutputPart::ValueRef(v) => assert_eq!(*v, nested),
1384            other => unreachable!("expected ValueRef(nested projection), got {other:?}"),
1385        }
1386    }
1387
1388    #[test]
1389    fn round_trip_line_ref_with_slots() {
1390        let parts = vec![OutputPart::LineRef {
1391            container_idx: 42,
1392            line_idx: 7,
1393            slots: vec![Value::Int(123), Value::String(Arc::from("hello"))],
1394            flags: LineFlags::ALL_WS | LineFlags::EMPTY,
1395        }];
1396        let bytes = write_transcript(&parts, 1234, &[]);
1397        let data = read_transcript(&bytes).unwrap();
1398        assert_eq!(data.parts.len(), 1);
1399        match &data.parts[0] {
1400            OutputPart::LineRef {
1401                container_idx,
1402                line_idx,
1403                slots,
1404                flags,
1405            } => {
1406                assert_eq!(*container_idx, 42);
1407                assert_eq!(*line_idx, 7);
1408                assert_eq!(slots.len(), 2);
1409                assert!(matches!(&slots[0], Value::Int(123)));
1410                assert!(flags.contains(LineFlags::ALL_WS));
1411                assert!(flags.contains(LineFlags::EMPTY));
1412            }
1413            other => unreachable!("expected LineRef, got {other:?}"),
1414        }
1415    }
1416
1417    #[test]
1418    fn checkpoint_filtered_on_write() {
1419        let parts = vec![
1420            OutputPart::Text("hello".to_string()),
1421            OutputPart::Checkpoint,
1422            OutputPart::Newline,
1423        ];
1424        let bytes = write_transcript(&parts, 0, &[]);
1425        let data = read_transcript(&bytes).unwrap();
1426        assert_eq!(data.parts.len(), 2); // Checkpoint filtered
1427        assert!(matches!(&data.parts[0], OutputPart::Text(_)));
1428        assert!(matches!(&data.parts[1], OutputPart::Newline));
1429    }
1430
1431    // ── #953: Fragment::tags round-trip ─────────────────────────────────────
1432    //
1433    // `write_transcript` never serialized `Fragment::tags` and
1434    // `read_transcript` always reconstructed an empty `Vec` — a transcript
1435    // with tagged fragments (live, populated data — see
1436    // `OutputBuffer::push_fragment_tag`) round-tripped to untagged. This
1437    // pins the fix: tags now travel through the `.brkt` codec.
1438    #[test]
1439    fn round_trip_fragment_tags() {
1440        let fragments = vec![
1441            crate::output::Fragment {
1442                parts: vec![OutputPart::Text("hp: 10".to_string())],
1443                tags: vec!["a_tag".to_string(), "b_tag".to_string()],
1444            },
1445            crate::output::Fragment {
1446                parts: vec![OutputPart::Newline],
1447                tags: Vec::new(),
1448            },
1449        ];
1450        let bytes = write_transcript(&[], 0, &fragments);
1451        let data = read_transcript(&bytes).unwrap();
1452
1453        assert_eq!(data.fragments.len(), 2);
1454        assert_eq!(
1455            data.fragments[0].tags,
1456            vec!["a_tag".to_string(), "b_tag".to_string()]
1457        );
1458        assert_eq!(data.fragments[0].parts, fragments[0].parts);
1459        assert!(data.fragments[1].tags.is_empty());
1460    }
1461
1462    // Every `.brkt` file written before this fix has the fragment section
1463    // (fragment_count + per-fragment parts) with NO trailing tag section —
1464    // the reader must keep decoding those files (not error), falling back
1465    // to empty tags per fragment, exactly as it did before this fix. This
1466    // hand-builds that exact pre-fix byte shape rather than relying on the
1467    // current writer (which now always appends the tag section) so the
1468    // legacy shape is pinned even after the writer changes further.
1469    #[test]
1470    fn legacy_transcript_without_tag_section_reads_as_empty_tags() {
1471        let mut body = Vec::new();
1472        write_u32(&mut body, 0); // part count
1473        write_u32(&mut body, 1); // fragment count
1474        write_u32(&mut body, 1); // fragment 0's part count
1475        write_u8(&mut body, TAG_TEXT);
1476        write_str(&mut body, "legacy");
1477        // (no tag section appended — matches the pre-#953 writer)
1478
1479        let content_crc = crc32(&body);
1480        let mut bytes = Vec::with_capacity(HEADER_SIZE + body.len());
1481        bytes.extend_from_slice(MAGIC);
1482        write_u16(&mut bytes, VERSION);
1483        write_u16(&mut bytes, 0);
1484        write_u32(&mut bytes, 0xCAFE_BABE);
1485        write_u32(&mut bytes, content_crc);
1486        bytes.extend(body);
1487
1488        let data = read_transcript(&bytes).expect("legacy transcript must still decode");
1489        assert_eq!(data.fragments.len(), 1);
1490        assert!(matches!(&data.fragments[0].parts[0], OutputPart::Text(s) if s == "legacy"));
1491        assert!(data.fragments[0].tags.is_empty());
1492    }
1493
1494    // The *other* backward-compat boundary this module's doc claims but only
1495    // `legacy_transcript_without_tag_section_reads_as_empty_tags` above pins:
1496    // a `.brkt` written before the fragment section existed at all (pre-
1497    // fragments feature), where the body ends right after the top-level part
1498    // list — no `fragment_count` `u32`, not even a zero one. `write_transcript`
1499    // has *always* written `fragments.len()` unconditionally (even `0` for an
1500    // empty slice — see the call site right after the part loop), so no call
1501    // through the real writer can ever produce this exact shape; it has to be
1502    // hand-built, same rationale as the tag-section test above. The read-side
1503    // `if off < bytes.len()` probe at the fragment-count read (this module's
1504    // `read_transcript`) is what is actually under test here: with zero bytes
1505    // left after the parts, it must fall back to "no fragments" rather than
1506    // erroring as truncated input.
1507    #[test]
1508    fn legacy_transcript_without_fragment_section_reads_as_no_fragments() {
1509        let mut body = Vec::new();
1510        write_u32(&mut body, 1); // part count
1511        write_u8(&mut body, TAG_TEXT);
1512        write_str(&mut body, "legacy");
1513        // (body ends here — no fragment section, no tag section, matches a
1514        // `.brkt` written before fragments existed at all)
1515
1516        let content_crc = crc32(&body);
1517        let mut bytes = Vec::with_capacity(HEADER_SIZE + body.len());
1518        bytes.extend_from_slice(MAGIC);
1519        write_u16(&mut bytes, VERSION);
1520        write_u16(&mut bytes, 0);
1521        write_u32(&mut bytes, 0xCAFE_BABE);
1522        write_u32(&mut bytes, content_crc);
1523        bytes.extend(body);
1524
1525        let data = read_transcript(&bytes).expect("legacy transcript must still decode");
1526        assert_eq!(data.parts.len(), 1);
1527        assert!(matches!(&data.parts[0], OutputPart::Text(s) if s == "legacy"));
1528        assert!(
1529            data.fragments.is_empty(),
1530            "a pre-fragments `.brkt` must decode with zero fragments, not error: {:?}",
1531            data.fragments
1532        );
1533    }
1534
1535    #[test]
1536    fn invalid_magic_errors() {
1537        let mut bytes = write_transcript(&[], 0, &[]);
1538        bytes[0] = b'X';
1539        assert!(matches!(
1540            read_transcript(&bytes),
1541            Err(TranscriptError::InvalidMagic)
1542        ));
1543    }
1544
1545    #[test]
1546    fn integrity_check_errors() {
1547        let mut bytes = write_transcript(&[OutputPart::Newline], 0, &[]);
1548        // Corrupt a body byte
1549        if let Some(last) = bytes.last_mut() {
1550            *last ^= 0xFF;
1551        }
1552        assert!(matches!(
1553            read_transcript(&bytes),
1554            Err(TranscriptError::IntegrityCheckFailed)
1555        ));
1556    }
1557
1558    // ── Recursion-depth cap on VAL_ARRAY/VAL_MAP decode (#553, #561, #562) ──
1559    //
1560    // `decode_value` recurses into itself for VAL_ARRAY/VAL_MAP children with
1561    // no depth limit. A crafted transcript of nested single-element arrays
1562    // (~5 bytes/level) can stack-overflow the reader. These tests hand-build
1563    // a `Value` nested exactly at, and one past,
1564    // `brink_format::MAX_DECODE_DEPTH` (the single canonical definition
1565    // shared by every `decode_value` implementation, #561) and prove the
1566    // reader accepts the former and rejects the latter with a proper decode
1567    // error instead of overflowing the stack. Both the `VAL_ARRAY` recursion
1568    // branch and the parallel `VAL_MAP` branch are exercised at the boundary
1569    // (#562).
1570
1571    /// A `Value` wrapped in `depth` single-element arrays around a scalar
1572    /// leaf, matching the issue's "nested single-element arrays" shape.
1573    fn nested_array(depth: usize) -> Value {
1574        let mut v = Value::Int(42);
1575        for _ in 0..depth {
1576            v = Value::array(vec![v]);
1577        }
1578        v
1579    }
1580
1581    /// A `Value` wrapped in `depth` single-entry maps around a scalar leaf —
1582    /// the `VAL_MAP` analogue of [`nested_array`], exercising the parallel
1583    /// map recursion branch in `decode_value` (#562).
1584    fn nested_map(depth: usize) -> Value {
1585        use brink_format::{MapKey, OrderedMap};
1586
1587        let mut v = Value::Int(42);
1588        for _ in 0..depth {
1589            let mut map = OrderedMap::with_capacity(1);
1590            map.insert(MapKey::Int(0), v);
1591            v = Value::map(map);
1592        }
1593        v
1594    }
1595
1596    #[test]
1597    fn decode_value_accepts_max_depth_nesting() {
1598        // Exactly MAX_DECODE_DEPTH levels of nesting must still decode
1599        // cleanly — the cap must not clip legitimate (if unusual) data.
1600        let value = nested_array(MAX_DECODE_DEPTH);
1601        let parts = vec![OutputPart::ValueRef(value.clone())];
1602        let bytes = write_transcript(&parts, 0, &[]);
1603
1604        let data = read_transcript(&bytes).expect("depth exactly at cap must decode");
1605        match &data.parts[0] {
1606            OutputPart::ValueRef(v) => assert_eq!(*v, value),
1607            other => unreachable!("expected ValueRef, got {other:?}"),
1608        }
1609    }
1610
1611    #[test]
1612    fn decode_value_rejects_beyond_max_depth() {
1613        // One level past the cap must be rejected with a proper decode
1614        // error, not a stack overflow.
1615        let value = nested_array(MAX_DECODE_DEPTH + 1);
1616        let parts = vec![OutputPart::ValueRef(value)];
1617        let bytes = write_transcript(&parts, 0, &[]);
1618
1619        assert!(matches!(
1620            read_transcript(&bytes),
1621            Err(TranscriptError::MaxDepthExceeded(MAX_DECODE_DEPTH))
1622        ));
1623    }
1624
1625    #[test]
1626    fn decode_value_rejects_deeply_crafted_nesting() {
1627        // The actual attack scenario the issue describes: a much deeper
1628        // chain than any legitimate story would produce (well beyond the
1629        // cap, but shallow enough that constructing/encoding the fixture
1630        // itself — which has no depth cap by design; only the
1631        // untrusted-input decode path is guarded — doesn't hit unrelated
1632        // recursion limits). The reader must reject it promptly rather than
1633        // recursing hundreds of frames deep.
1634        let value = nested_array(8 * MAX_DECODE_DEPTH);
1635        let parts = vec![OutputPart::ValueRef(value)];
1636        let bytes = write_transcript(&parts, 0, &[]);
1637
1638        assert!(matches!(
1639            read_transcript(&bytes),
1640            Err(TranscriptError::MaxDepthExceeded(MAX_DECODE_DEPTH))
1641        ));
1642    }
1643
1644    // ── #562: parallel VAL_MAP recursion branch at the boundary ────────────
1645
1646    #[test]
1647    fn decode_value_accepts_max_depth_map_nesting() {
1648        // Exactly MAX_DECODE_DEPTH levels of map nesting must still decode
1649        // cleanly — the cap must not clip legitimate (if unusual) data.
1650        let value = nested_map(MAX_DECODE_DEPTH);
1651        let parts = vec![OutputPart::ValueRef(value.clone())];
1652        let bytes = write_transcript(&parts, 0, &[]);
1653
1654        let data = read_transcript(&bytes).expect("map depth exactly at cap must decode");
1655        match &data.parts[0] {
1656            OutputPart::ValueRef(v) => assert_eq!(*v, value),
1657            other => unreachable!("expected ValueRef, got {other:?}"),
1658        }
1659    }
1660
1661    #[test]
1662    fn decode_value_rejects_beyond_max_depth_map_nesting() {
1663        // One level past the cap must be rejected with a proper decode
1664        // error, not a stack overflow.
1665        let value = nested_map(MAX_DECODE_DEPTH + 1);
1666        let parts = vec![OutputPart::ValueRef(value)];
1667        let bytes = write_transcript(&parts, 0, &[]);
1668
1669        assert!(matches!(
1670            read_transcript(&bytes),
1671            Err(TranscriptError::MaxDepthExceeded(MAX_DECODE_DEPTH))
1672        ));
1673    }
1674
1675    // Issue #985 (follow-up to #909): `OrderedMap`'s `Eq` is content-based
1676    // and assumes each key appears at most once. A legitimate `write_transcript`
1677    // never emits a duplicate `VAL_MAP` key — `OrderedMap::insert`
1678    // de-duplicates on the write side, so `encode_value` can't be driven
1679    // into producing one from an in-memory `Value`. This hand-builds the raw
1680    // `VAL_MAP` bytes (the crafted/corrupt-payload scenario the issue
1681    // describes) with the same `int` key twice, proving the reader rejects
1682    // it with a decode error rather than silently keeping the last
1683    // occurrence and handing back an invariant-violating `OrderedMap`.
1684    fn duplicate_int_key_map_body() -> Vec<u8> {
1685        let mut body = Vec::new();
1686        write_u32(&mut body, 1); // part count
1687        write_u8(&mut body, TAG_VALUE_REF);
1688        write_u8(&mut body, VAL_MAP);
1689        write_u32(&mut body, 2); // entry count
1690        write_u8(&mut body, VAL_INT);
1691        write_i32(&mut body, 0);
1692        write_u8(&mut body, VAL_INT);
1693        write_i32(&mut body, 1);
1694        write_u8(&mut body, VAL_INT);
1695        write_i32(&mut body, 0);
1696        write_u8(&mut body, VAL_INT);
1697        write_i32(&mut body, 2);
1698        write_u32(&mut body, 0); // fragment count
1699        body
1700    }
1701
1702    fn wrap_body_as_transcript(body: &[u8]) -> Vec<u8> {
1703        let content_crc = crc32(body);
1704        let mut bytes = Vec::with_capacity(HEADER_SIZE + body.len());
1705        bytes.extend_from_slice(MAGIC);
1706        write_u16(&mut bytes, VERSION);
1707        write_u16(&mut bytes, 0);
1708        write_u32(&mut bytes, 0);
1709        write_u32(&mut bytes, content_crc);
1710        bytes.extend_from_slice(body);
1711        bytes
1712    }
1713
1714    #[test]
1715    fn decode_value_rejects_duplicate_map_key() {
1716        let bytes = wrap_body_as_transcript(&duplicate_int_key_map_body());
1717        assert!(matches!(
1718            read_transcript(&bytes),
1719            Err(TranscriptError::DuplicateMapKey)
1720        ));
1721    }
1722
1723    #[test]
1724    fn decode_value_accepts_distinct_map_keys() {
1725        let mut body = Vec::new();
1726        write_u32(&mut body, 1); // part count
1727        write_u8(&mut body, TAG_VALUE_REF);
1728        write_u8(&mut body, VAL_MAP);
1729        write_u32(&mut body, 2); // entry count
1730        write_u8(&mut body, VAL_INT);
1731        write_i32(&mut body, 0);
1732        write_u8(&mut body, VAL_INT);
1733        write_i32(&mut body, 1);
1734        write_u8(&mut body, VAL_INT);
1735        write_i32(&mut body, 5);
1736        write_u8(&mut body, VAL_INT);
1737        write_i32(&mut body, 2);
1738        write_u32(&mut body, 0); // fragment count
1739
1740        let bytes = wrap_body_as_transcript(&body);
1741        let data = read_transcript(&bytes).expect("distinct keys must decode cleanly");
1742        match &data.parts[0] {
1743            OutputPart::ValueRef(Value::Map(map)) => assert_eq!(map.len(), 2),
1744            other => unreachable!("expected ValueRef(map), got {other:?}"),
1745        }
1746    }
1747}