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            part count
20//!   [Part]*           encoded parts
21//! ```
22
23use std::sync::Arc;
24
25use brink_format::{DefinitionId, LineFlags, Value};
26
27use crate::output::{OutputPart, resolve_lines};
28use crate::program::Program;
29
30// ── Format constants ──────────────────────────────────────────────────────
31
32const MAGIC: &[u8; 4] = b"BRKT";
33const VERSION: u16 = 1;
34const HEADER_SIZE: usize = 16;
35
36// Part tags
37const TAG_TEXT: u8 = 0x01;
38const TAG_LINE_REF: u8 = 0x02;
39const TAG_VALUE_REF: u8 = 0x03;
40const TAG_NEWLINE: u8 = 0x04;
41const TAG_SPRING: u8 = 0x05;
42const TAG_GLUE: u8 = 0x06;
43const TAG_TAG: u8 = 0x07;
44
45// Value tags (matching inkb encoding)
46const VAL_INT: u8 = 0x00;
47const VAL_FLOAT: u8 = 0x01;
48const VAL_BOOL: u8 = 0x02;
49const VAL_STRING: u8 = 0x03;
50const VAL_LIST: u8 = 0x04;
51const VAL_DIVERT_TARGET: u8 = 0x05;
52const VAL_NULL: u8 = 0x06;
53const VAL_FRAGMENT_REF: u8 = 0x08;
54
55// ── Error type ────────────────────────────────────────────────────────────
56
57/// Errors from transcript serialization/deserialization.
58#[derive(Debug, thiserror::Error)]
59pub enum TranscriptError {
60    #[error("invalid magic: expected BRKT")]
61    InvalidMagic,
62    #[error("unsupported version: {0}")]
63    UnsupportedVersion(u16),
64    #[error("checksum mismatch: transcript {transcript:#010x} != program {program:#010x}")]
65    ChecksumMismatch { transcript: u32, program: u32 },
66    #[error("integrity check failed: content CRC-32 mismatch")]
67    IntegrityCheckFailed,
68    #[error("unexpected end of data")]
69    UnexpectedEof,
70    #[error("invalid part tag: {0:#04x}")]
71    InvalidPartTag(u8),
72    #[error("invalid value tag: {0:#04x}")]
73    InvalidValueTag(u8),
74    #[error("invalid UTF-8")]
75    InvalidUtf8,
76    #[error("invalid definition ID")]
77    InvalidDefinitionId,
78}
79
80// ── Write ─────────────────────────────────────────────────────────────────
81
82/// Serialize a transcript to the `.brkt` binary format.
83///
84/// Checkpoint parts are filtered out (they are transient capture markers
85/// that should never appear in a persisted transcript).
86#[expect(clippy::cast_possible_truncation)]
87pub fn write_transcript(
88    parts: &[OutputPart],
89    source_checksum: u32,
90    fragments: &[crate::output::Fragment],
91) -> Vec<u8> {
92    let mut body = Vec::new();
93
94    // Count non-Checkpoint parts
95    let count = parts
96        .iter()
97        .filter(|p| !matches!(p, OutputPart::Checkpoint))
98        .count() as u32;
99    write_u32(&mut body, count);
100
101    for part in parts {
102        match part {
103            OutputPart::Text(s) => {
104                write_u8(&mut body, TAG_TEXT);
105                write_str(&mut body, s);
106            }
107            OutputPart::LineRef {
108                container_idx,
109                line_idx,
110                slots,
111                flags,
112            } => {
113                write_u8(&mut body, TAG_LINE_REF);
114                write_u32(&mut body, *container_idx);
115                write_u16(&mut body, *line_idx);
116                write_u8(&mut body, flags.bits());
117                write_u16(&mut body, slots.len() as u16);
118                for val in slots {
119                    encode_value(val, &mut body);
120                }
121            }
122            OutputPart::ValueRef(val) => {
123                write_u8(&mut body, TAG_VALUE_REF);
124                encode_value(val, &mut body);
125            }
126            OutputPart::Newline => write_u8(&mut body, TAG_NEWLINE),
127            OutputPart::Spring => write_u8(&mut body, TAG_SPRING),
128            OutputPart::Glue => write_u8(&mut body, TAG_GLUE),
129            OutputPart::Tag(s) => {
130                write_u8(&mut body, TAG_TAG);
131                write_str(&mut body, s);
132            }
133            OutputPart::Checkpoint => {} // filtered out
134        }
135    }
136
137    // Serialize fragments
138    write_u32(&mut body, fragments.len() as u32);
139    for fragment in fragments {
140        let filtered_count = fragment
141            .parts
142            .iter()
143            .filter(|p| !matches!(p, OutputPart::Checkpoint))
144            .count() as u32;
145        write_u32(&mut body, filtered_count);
146        for part in &fragment.parts {
147            match part {
148                OutputPart::Text(s) => {
149                    write_u8(&mut body, TAG_TEXT);
150                    write_str(&mut body, s);
151                }
152                OutputPart::LineRef {
153                    container_idx,
154                    line_idx,
155                    slots,
156                    flags,
157                } => {
158                    write_u8(&mut body, TAG_LINE_REF);
159                    write_u32(&mut body, *container_idx);
160                    write_u16(&mut body, *line_idx);
161                    write_u8(&mut body, flags.bits());
162                    write_u16(&mut body, slots.len() as u16);
163                    for val in slots {
164                        encode_value(val, &mut body);
165                    }
166                }
167                OutputPart::ValueRef(val) => {
168                    write_u8(&mut body, TAG_VALUE_REF);
169                    encode_value(val, &mut body);
170                }
171                OutputPart::Newline => write_u8(&mut body, TAG_NEWLINE),
172                OutputPart::Spring => write_u8(&mut body, TAG_SPRING),
173                OutputPart::Glue => write_u8(&mut body, TAG_GLUE),
174                OutputPart::Tag(s) => {
175                    write_u8(&mut body, TAG_TAG);
176                    write_str(&mut body, s);
177                }
178                OutputPart::Checkpoint => {}
179            }
180        }
181    }
182
183    // Build header
184    let content_crc = crc32(&body);
185    let mut buf = Vec::with_capacity(HEADER_SIZE + body.len());
186    buf.extend_from_slice(MAGIC);
187    write_u16(&mut buf, VERSION);
188    write_u16(&mut buf, 0); // reserved
189    write_u32(&mut buf, source_checksum);
190    write_u32(&mut buf, content_crc);
191    buf.extend(body);
192    buf
193}
194
195// ── Read ──────────────────────────────────────────────────────────────────
196
197/// A decoded transcript: the output parts, the source program's checksum
198/// (to verify compatibility before rendering), and the captured fragments
199/// (for re-rendering choice display text and computed substrings).
200///
201/// The caller should validate `source_checksum` against the program's
202/// checksum (via [`Program::source_checksum`](crate::Program::source_checksum))
203/// before passing `parts` to [`render_transcript`].
204#[derive(Debug, Clone)]
205pub struct TranscriptData {
206    pub parts: Vec<OutputPart>,
207    pub source_checksum: u32,
208    pub fragments: Vec<crate::output::Fragment>,
209}
210
211/// Deserialize a transcript from the `.brkt` binary format.
212pub fn read_transcript(bytes: &[u8]) -> Result<TranscriptData, TranscriptError> {
213    if bytes.len() < HEADER_SIZE {
214        return Err(TranscriptError::UnexpectedEof);
215    }
216
217    // Validate header
218    if &bytes[0..4] != MAGIC {
219        return Err(TranscriptError::InvalidMagic);
220    }
221    let mut off = 4;
222    let version = read_u16(bytes, &mut off)?;
223    if version != VERSION {
224        return Err(TranscriptError::UnsupportedVersion(version));
225    }
226    let _reserved = read_u16(bytes, &mut off)?;
227    let source_checksum = read_u32(bytes, &mut off)?;
228    let expected_crc = read_u32(bytes, &mut off)?;
229
230    // Validate body integrity
231    let body = &bytes[HEADER_SIZE..];
232    if crc32(body) != expected_crc {
233        return Err(TranscriptError::IntegrityCheckFailed);
234    }
235
236    // Decode parts
237    let mut off = HEADER_SIZE;
238    let count = read_u32(bytes, &mut off)? as usize;
239    let mut parts = Vec::with_capacity(count);
240
241    for _ in 0..count {
242        let tag = read_u8(bytes, &mut off)?;
243        let part = match tag {
244            TAG_TEXT => OutputPart::Text(read_str(bytes, &mut off)?),
245            TAG_LINE_REF => {
246                let container_idx = read_u32(bytes, &mut off)?;
247                let line_idx = read_u16(bytes, &mut off)?;
248                let flags_bits = read_u8(bytes, &mut off)?;
249                let flags = LineFlags::from_bits_truncate(flags_bits);
250                let slot_count = read_u16(bytes, &mut off)? as usize;
251                let mut slots = Vec::with_capacity(slot_count);
252                for _ in 0..slot_count {
253                    slots.push(decode_value(bytes, &mut off)?);
254                }
255                OutputPart::LineRef {
256                    container_idx,
257                    line_idx,
258                    slots,
259                    flags,
260                }
261            }
262            TAG_VALUE_REF => OutputPart::ValueRef(decode_value(bytes, &mut off)?),
263            TAG_NEWLINE => OutputPart::Newline,
264            TAG_SPRING => OutputPart::Spring,
265            TAG_GLUE => OutputPart::Glue,
266            TAG_TAG => OutputPart::Tag(read_str(bytes, &mut off)?),
267            _ => return Err(TranscriptError::InvalidPartTag(tag)),
268        };
269        parts.push(part);
270    }
271
272    // Deserialize fragments
273    let fragment_count = if off < bytes.len() {
274        read_u32(bytes, &mut off)? as usize
275    } else {
276        0 // backward compat: old transcripts without fragments
277    };
278    let mut fragments = Vec::with_capacity(fragment_count);
279    for _ in 0..fragment_count {
280        let frag_part_count = read_u32(bytes, &mut off)? as usize;
281        let mut frag_parts = Vec::with_capacity(frag_part_count);
282        for _ in 0..frag_part_count {
283            let tag = read_u8(bytes, &mut off)?;
284            let part = match tag {
285                TAG_TEXT => OutputPart::Text(read_str(bytes, &mut off)?),
286                TAG_LINE_REF => {
287                    let container_idx = read_u32(bytes, &mut off)?;
288                    let line_idx = read_u16(bytes, &mut off)?;
289                    let flags_bits = read_u8(bytes, &mut off)?;
290                    let flags = LineFlags::from_bits_truncate(flags_bits);
291                    let slot_count = read_u16(bytes, &mut off)? as usize;
292                    let mut slots = Vec::with_capacity(slot_count);
293                    for _ in 0..slot_count {
294                        slots.push(decode_value(bytes, &mut off)?);
295                    }
296                    OutputPart::LineRef {
297                        container_idx,
298                        line_idx,
299                        slots,
300                        flags,
301                    }
302                }
303                TAG_VALUE_REF => OutputPart::ValueRef(decode_value(bytes, &mut off)?),
304                TAG_NEWLINE => OutputPart::Newline,
305                TAG_SPRING => OutputPart::Spring,
306                TAG_GLUE => OutputPart::Glue,
307                TAG_TAG => OutputPart::Tag(read_str(bytes, &mut off)?),
308                _ => return Err(TranscriptError::InvalidPartTag(tag)),
309            };
310            frag_parts.push(part);
311        }
312        fragments.push(crate::output::Fragment {
313            parts: frag_parts,
314            tags: Vec::new(),
315        });
316    }
317
318    Ok(TranscriptData {
319        parts,
320        source_checksum,
321        fragments,
322    })
323}
324
325// ── Render ────────────────────────────────────────────────────────────────
326
327/// Re-render a transcript against the given line tables.
328///
329/// Applies glue resolution, Spring spacing, and line trimming — the same
330/// pipeline as `flush_lines` — producing `(text, tags)` tuples per line.
331pub fn render_transcript(
332    parts: &[OutputPart],
333    program: &Program,
334    line_tables: &[Vec<brink_format::LineEntry>],
335    resolver: Option<&dyn brink_format::PluralResolver>,
336    fragments: &[crate::output::Fragment],
337) -> Vec<(String, Vec<String>)> {
338    resolve_lines(parts, program, line_tables, resolver, fragments)
339}
340
341// ── Codec helpers (self-contained, no dependency on brink-format internals) ──
342
343fn write_u8(buf: &mut Vec<u8>, v: u8) {
344    buf.push(v);
345}
346
347fn write_u16(buf: &mut Vec<u8>, v: u16) {
348    buf.extend_from_slice(&v.to_le_bytes());
349}
350
351fn write_u32(buf: &mut Vec<u8>, v: u32) {
352    buf.extend_from_slice(&v.to_le_bytes());
353}
354
355fn write_u64(buf: &mut Vec<u8>, v: u64) {
356    buf.extend_from_slice(&v.to_le_bytes());
357}
358
359fn write_i32(buf: &mut Vec<u8>, v: i32) {
360    buf.extend_from_slice(&v.to_le_bytes());
361}
362
363#[expect(clippy::cast_possible_truncation)]
364fn write_str(buf: &mut Vec<u8>, s: &str) {
365    write_u32(buf, s.len() as u32);
366    buf.extend_from_slice(s.as_bytes());
367}
368
369fn write_def_id(buf: &mut Vec<u8>, id: DefinitionId) {
370    write_u64(buf, id.to_raw());
371}
372
373fn read_u8(buf: &[u8], off: &mut usize) -> Result<u8, TranscriptError> {
374    if *off >= buf.len() {
375        return Err(TranscriptError::UnexpectedEof);
376    }
377    let v = buf[*off];
378    *off += 1;
379    Ok(v)
380}
381
382fn read_u16(buf: &[u8], off: &mut usize) -> Result<u16, TranscriptError> {
383    if *off + 2 > buf.len() {
384        return Err(TranscriptError::UnexpectedEof);
385    }
386    let v = u16::from_le_bytes([buf[*off], buf[*off + 1]]);
387    *off += 2;
388    Ok(v)
389}
390
391fn read_u32(buf: &[u8], off: &mut usize) -> Result<u32, TranscriptError> {
392    if *off + 4 > buf.len() {
393        return Err(TranscriptError::UnexpectedEof);
394    }
395    let v = u32::from_le_bytes([buf[*off], buf[*off + 1], buf[*off + 2], buf[*off + 3]]);
396    *off += 4;
397    Ok(v)
398}
399
400fn read_i32(buf: &[u8], off: &mut usize) -> Result<i32, TranscriptError> {
401    if *off + 4 > buf.len() {
402        return Err(TranscriptError::UnexpectedEof);
403    }
404    let v = i32::from_le_bytes([buf[*off], buf[*off + 1], buf[*off + 2], buf[*off + 3]]);
405    *off += 4;
406    Ok(v)
407}
408
409fn read_f32(buf: &[u8], off: &mut usize) -> Result<f32, TranscriptError> {
410    if *off + 4 > buf.len() {
411        return Err(TranscriptError::UnexpectedEof);
412    }
413    let v = f32::from_le_bytes([buf[*off], buf[*off + 1], buf[*off + 2], buf[*off + 3]]);
414    *off += 4;
415    Ok(v)
416}
417
418fn read_u64(buf: &[u8], off: &mut usize) -> Result<u64, TranscriptError> {
419    if *off + 8 > buf.len() {
420        return Err(TranscriptError::UnexpectedEof);
421    }
422    let v = u64::from_le_bytes([
423        buf[*off],
424        buf[*off + 1],
425        buf[*off + 2],
426        buf[*off + 3],
427        buf[*off + 4],
428        buf[*off + 5],
429        buf[*off + 6],
430        buf[*off + 7],
431    ]);
432    *off += 8;
433    Ok(v)
434}
435
436fn read_str(buf: &[u8], off: &mut usize) -> Result<String, TranscriptError> {
437    let len = read_u32(buf, off)? as usize;
438    if *off + len > buf.len() {
439        return Err(TranscriptError::UnexpectedEof);
440    }
441    let bytes = &buf[*off..*off + len];
442    *off += len;
443    String::from_utf8(bytes.to_vec()).map_err(|_| TranscriptError::InvalidUtf8)
444}
445
446fn read_def_id(buf: &[u8], off: &mut usize) -> Result<DefinitionId, TranscriptError> {
447    let raw = read_u64(buf, off)?;
448    DefinitionId::from_raw(raw).ok_or(TranscriptError::InvalidDefinitionId)
449}
450
451// ── Value encoding ────────────────────────────────────────────────────────
452
453#[expect(clippy::cast_possible_truncation)]
454fn encode_value(v: &Value, buf: &mut Vec<u8>) {
455    match v {
456        Value::Int(n) => {
457            write_u8(buf, VAL_INT);
458            write_i32(buf, *n);
459        }
460        Value::Float(n) => {
461            write_u8(buf, VAL_FLOAT);
462            buf.extend_from_slice(&n.to_le_bytes());
463        }
464        Value::Bool(b) => {
465            write_u8(buf, VAL_BOOL);
466            write_u8(buf, u8::from(*b));
467        }
468        Value::String(s) => {
469            write_u8(buf, VAL_STRING);
470            write_str(buf, s);
471        }
472        Value::List(lv) => {
473            write_u8(buf, VAL_LIST);
474            write_u32(buf, lv.items.len() as u32);
475            for item in &lv.items {
476                write_def_id(buf, *item);
477            }
478            write_u32(buf, lv.origins.len() as u32);
479            for origin in &lv.origins {
480                write_def_id(buf, *origin);
481            }
482        }
483        Value::DivertTarget(id) => {
484            write_u8(buf, VAL_DIVERT_TARGET);
485            write_def_id(buf, *id);
486        }
487        Value::VariablePointer(id) => {
488            write_u8(buf, VAL_DIVERT_TARGET); // serialize same as divert target
489            write_def_id(buf, *id);
490        }
491        Value::FragmentRef(idx) => {
492            write_u8(buf, VAL_FRAGMENT_REF);
493            write_u32(buf, *idx);
494        }
495        Value::TempPointer { .. } | Value::Null => {
496            write_u8(buf, VAL_NULL);
497        }
498    }
499}
500
501fn decode_value(buf: &[u8], off: &mut usize) -> Result<Value, TranscriptError> {
502    let tag = read_u8(buf, off)?;
503    match tag {
504        VAL_INT => Ok(Value::Int(read_i32(buf, off)?)),
505        VAL_FLOAT => Ok(Value::Float(read_f32(buf, off)?)),
506        VAL_BOOL => {
507            let b = read_u8(buf, off)?;
508            Ok(Value::Bool(b != 0))
509        }
510        VAL_STRING => {
511            let s = read_str(buf, off)?;
512            Ok(Value::String(Arc::from(s.as_str())))
513        }
514        VAL_LIST => {
515            let item_count = read_u32(buf, off)? as usize;
516            let mut items = Vec::with_capacity(item_count);
517            for _ in 0..item_count {
518                items.push(read_def_id(buf, off)?);
519            }
520            let origin_count = read_u32(buf, off)? as usize;
521            let mut origins = Vec::with_capacity(origin_count);
522            for _ in 0..origin_count {
523                origins.push(read_def_id(buf, off)?);
524            }
525            Ok(Value::List(Arc::new(brink_format::ListValue {
526                items,
527                origins,
528            })))
529        }
530        VAL_DIVERT_TARGET => {
531            let id = read_def_id(buf, off)?;
532            Ok(Value::DivertTarget(id))
533        }
534        VAL_FRAGMENT_REF => Ok(Value::FragmentRef(read_u32(buf, off)?)),
535        VAL_NULL => Ok(Value::Null),
536        _ => Err(TranscriptError::InvalidValueTag(tag)),
537    }
538}
539
540// ── CRC-32 ────────────────────────────────────────────────────────────────
541
542fn crc32(data: &[u8]) -> u32 {
543    static TABLE: [u32; 256] = {
544        let mut table = [0u32; 256];
545        let mut i = 0u32;
546        while i < 256 {
547            let mut crc = i;
548            let mut j = 0;
549            while j < 8 {
550                if crc & 1 != 0 {
551                    crc = (crc >> 1) ^ 0xEDB8_8320;
552                } else {
553                    crc >>= 1;
554                }
555                j += 1;
556            }
557            table[i as usize] = crc;
558            i += 1;
559        }
560        table
561    };
562
563    let mut crc = 0xFFFF_FFFFu32;
564    for &byte in data {
565        let idx = ((crc ^ u32::from(byte)) & 0xFF) as usize;
566        crc = (crc >> 8) ^ TABLE[idx];
567    }
568    crc ^ 0xFFFF_FFFF
569}
570
571#[cfg(test)]
572mod tests {
573    use super::*;
574    use brink_format::LineFlags;
575
576    #[test]
577    fn round_trip_simple_parts() {
578        let parts = vec![
579            OutputPart::Text("Hello".to_string()),
580            OutputPart::Spring,
581            OutputPart::Newline,
582            OutputPart::Tag("tag1".to_string()),
583            OutputPart::Glue,
584        ];
585        let bytes = write_transcript(&parts, 0xDEAD_BEEF, &[]);
586        let data = read_transcript(&bytes).unwrap();
587        assert_eq!(data.source_checksum, 0xDEAD_BEEF);
588        assert_eq!(data.parts.len(), 5);
589        assert!(matches!(&data.parts[0], OutputPart::Text(s) if s == "Hello"));
590        assert!(matches!(&data.parts[1], OutputPart::Spring));
591        assert!(matches!(&data.parts[2], OutputPart::Newline));
592        assert!(matches!(&data.parts[3], OutputPart::Tag(s) if s == "tag1"));
593        assert!(matches!(&data.parts[4], OutputPart::Glue));
594    }
595
596    #[test]
597    fn round_trip_line_ref_with_slots() {
598        let parts = vec![OutputPart::LineRef {
599            container_idx: 42,
600            line_idx: 7,
601            slots: vec![Value::Int(123), Value::String(Arc::from("hello"))],
602            flags: LineFlags::STARTS_WITH_WS | LineFlags::ENDS_WITH_WS,
603        }];
604        let bytes = write_transcript(&parts, 1234, &[]);
605        let data = read_transcript(&bytes).unwrap();
606        assert_eq!(data.parts.len(), 1);
607        match &data.parts[0] {
608            OutputPart::LineRef {
609                container_idx,
610                line_idx,
611                slots,
612                flags,
613            } => {
614                assert_eq!(*container_idx, 42);
615                assert_eq!(*line_idx, 7);
616                assert_eq!(slots.len(), 2);
617                assert!(matches!(&slots[0], Value::Int(123)));
618                assert!(flags.contains(LineFlags::STARTS_WITH_WS));
619                assert!(flags.contains(LineFlags::ENDS_WITH_WS));
620            }
621            other => unreachable!("expected LineRef, got {other:?}"),
622        }
623    }
624
625    #[test]
626    fn checkpoint_filtered_on_write() {
627        let parts = vec![
628            OutputPart::Text("hello".to_string()),
629            OutputPart::Checkpoint,
630            OutputPart::Newline,
631        ];
632        let bytes = write_transcript(&parts, 0, &[]);
633        let data = read_transcript(&bytes).unwrap();
634        assert_eq!(data.parts.len(), 2); // Checkpoint filtered
635        assert!(matches!(&data.parts[0], OutputPart::Text(_)));
636        assert!(matches!(&data.parts[1], OutputPart::Newline));
637    }
638
639    #[test]
640    fn invalid_magic_errors() {
641        let mut bytes = write_transcript(&[], 0, &[]);
642        bytes[0] = b'X';
643        assert!(matches!(
644            read_transcript(&bytes),
645            Err(TranscriptError::InvalidMagic)
646        ));
647    }
648
649    #[test]
650    fn integrity_check_errors() {
651        let mut bytes = write_transcript(&[OutputPart::Newline], 0, &[]);
652        // Corrupt a body byte
653        if let Some(last) = bytes.last_mut() {
654            *last ^= 0xFF;
655        }
656        assert!(matches!(
657            read_transcript(&bytes),
658            Err(TranscriptError::IntegrityCheckFailed)
659        ));
660    }
661}