Skip to main content

brink_format/inkb/
mod.rs

1//! Binary (.inkb) writer and reader for [`StoryData`].
2//!
3//! The `.inkb` format is a compact, little-endian binary encoding designed for
4//! fast loading by the runtime.
5//!
6//! ## Header layout
7//!
8//! ```text
9//! Offset  Size   Field
10//! ------  -----  ------
11//! 0       4      Magic: b"INKB"
12//! 4       2      Version: u16 LE (= 1)
13//! 6       1      Section count: u8 (N entries in offset table)
14//! 7       1      Reserved: 0x00
15//! 8       4      File size: u32 LE (total bytes)
16//! 12      4      Content checksum: u32 LE (CRC-32 of all bytes after header)
17//! 16      N*8    Offset table entries
18//! ```
19//!
20//! Each offset table entry (8 bytes):
21//! ```text
22//! 0       1      SectionKind: u8 tag
23//! 1       3      Reserved: 3 bytes of 0x00
24//! 4       4      Offset: u32 LE (byte offset from start of file)
25//! ```
26
27pub(crate) mod read;
28pub(crate) mod write;
29
30pub use read::{
31    read_inkb, read_inkb_index, read_section_address_paths, read_section_addresses,
32    read_section_alias_table, read_section_containers, read_section_effect_rows,
33    read_section_externals, read_section_frame_shapes, read_section_line_tables,
34    read_section_list_defs, read_section_list_items, read_section_list_literals,
35    read_section_literal_pool, read_section_name_table, read_section_struct_shapes,
36    read_section_variables, read_section_visibility,
37};
38pub use write::{
39    assemble_inkb, write_inkb, write_section_address_paths, write_section_addresses,
40    write_section_alias_table, write_section_containers, write_section_effect_rows,
41    write_section_externals, write_section_frame_shapes, write_section_line_tables,
42    write_section_list_defs, write_section_list_items, write_section_list_literals,
43    write_section_literal_pool, write_section_name_table, write_section_struct_shapes,
44    write_section_variables, write_section_visibility,
45};
46
47use core::ops::Range;
48
49use alloc::vec::Vec;
50
51use crate::opcode::DecodeError;
52
53// ── Constants ───────────────────────────────────────────────────────────────
54
55pub(crate) const MAGIC: &[u8; 4] = b"INKB";
56/// On-the-wire format version. Bumped on any byte-layout change; the reader
57/// hard-rejects an unrecognized version (see `docs/format-spec.md` § Versioning).
58/// v2 added `ContainerDef::param_count` to the Containers section.
59/// v3 added the `local` scope bit to `GlobalVarDef` (Variables section) and
60/// `ContainerDef` (Containers section) — see `docs/directive-annotations-spec.md`.
61/// v4 added the collection value tags `VAL_ARRAY`/`VAL_MAP` (tree encoding) and
62/// froze the reserved Tier-1 value-tag/section/opcode surface (the §9 one-bump
63/// rule of `docs/value-model-spec.md`) — see `docs/format-v4-rfc.md`.
64/// (Also on the v4 line: the optional `Visibility` section, M-2b,
65/// `docs/modules-spec.md` §4, tag `0x0E` — omitted when empty, so it didn't
66/// need a bump of its own.)
67/// v5 added the `AliasTable` section (`docs/modules-spec.md` §5, M-3): this
68/// section was not part of the v4 RFC's frozen inventory (unlike
69/// `StructShapes`/`EffectRows`, which were pre-reserved and only needed
70/// their *encoding* materialized without a bump), so a brand-new *mandatory*
71/// section is its own one-bump event. The section itself carries a
72/// one-byte section-local version so its *row encoding* can still evolve
73/// without a further format bump, matching the `EffectRows` precedent.
74/// `AliasTable` takes tag `0x0F` (the next free tag after `Visibility`).
75/// v6 added the `PART_SPAN` `LinePart` tag (#1716, `docs/prose-dialect-spec.md`
76/// §4.4/§4.5): like `AliasTable`, `PART_SPAN` was not part of the v4 RFC's
77/// pre-reserved inventory (that inventory covers `VAL_ARRAY`/`VAL_MAP`-style
78/// *value* tags and the `StructShapes`/`EffectRows` sections — it never
79/// reserved a `LinePart` tag), so introducing it is its own one-bump event,
80/// not a free ride on the `VAL_VEC2`/`VAL_WEIGHTED` no-bump precedent (see
81/// `PART_SPAN`'s doc comment below). Ruled directly by issue #1716's own
82/// ⚠ ("`LinePart::Span` is a v6 payload") and coordinated with #1683 (the
83/// v6 bump manifest); this PR lands only the `Span` payload of that
84/// manifest, so `VERSION` 6 stays open to absorb #1683's remaining payloads
85/// (element kind/data, universal block id, choice captured environment)
86/// without a further bump, the same way v4 absorbed its later Tier-1
87/// milestones — a single bump event, not a bump per payload.
88pub(crate) const VERSION: u16 = 6;
89/// Fixed-size preamble: magic + version + section count + reserved + file size + checksum.
90pub(crate) const HEADER_PREAMBLE: usize = 16;
91/// Each offset table entry: kind(1) + reserved(3) + offset(4)
92pub(crate) const SECTION_ENTRY_SIZE: usize = 8;
93/// Number of *mandatory* sections in the current format (always present,
94/// including the possibly-empty `AliasTable` and `EffectRows`). The optional
95/// `Visibility` section (M-2b) adds one more entry to the offset table when
96/// non-empty.
97pub(crate) const SECTION_COUNT: u8 = 14;
98
99// Value type tags
100pub(crate) const VAL_INT: u8 = 0x00;
101pub(crate) const VAL_FLOAT: u8 = 0x01;
102pub(crate) const VAL_BOOL: u8 = 0x02;
103pub(crate) const VAL_STRING: u8 = 0x03;
104pub(crate) const VAL_LIST: u8 = 0x04;
105pub(crate) const VAL_DIVERT_TARGET: u8 = 0x05;
106pub(crate) const VAL_NULL: u8 = 0x06;
107pub(crate) const VAL_VAR_POINTER: u8 = 0x07;
108pub(crate) const VAL_FRAGMENT_REF: u8 = 0x08;
109// v4 collection tags (`docs/format-v4-rfc.md` §1). Tree encoding: sharing is
110// not preserved on the wire — a snapshot serializes as a plain nested tree.
111pub(crate) const VAL_ARRAY: u8 = 0x09;
112pub(crate) const VAL_MAP: u8 = 0x0A;
113// TM-4 (`docs/typed-mode-spec.md` §6 / `docs/value-model-spec.md` §11c):
114// closed-shape records. `docs/format-v4-rfc.md` §1: `ShapeId (u32 into
115// StructShapes), then field values in shape order`.
116pub(crate) const VAL_RECORD: u8 = 0x0F;
117// T1c (`docs/t1c-spec.md` §6, `docs/format-v4-rfc.md` §1): function values.
118// `VAL_FN_REF` = the zero-bound case (a `DefinitionId`); `VAL_CLOSURE` =
119// `DefinitionId`, u16 env count, then env entries `{NameId, kind u8 (0=val,
120// 1=ref), value}`. Numeric assignments were frozen by the one-bump rule; this
121// PR (T1c-2) materializes them.
122pub(crate) const VAL_FN_REF: u8 = 0x0B;
123pub(crate) const VAL_CLOSURE: u8 = 0x0C;
124// T1d (`docs/t1d-spec.md` §2, `docs/format-v4-rfc.md` §1): opaque
125// host-resource tokens. `kind NameId, u64 id` — no live pointer, no
126// dedicated opcode; handles enter the script world only via bindings.
127pub(crate) const VAL_HANDLE: u8 = 0x0D;
128// T1e (`docs/t1e-spec.md` §3, `docs/format-v4-rfc.md` §1): symbolic path
129// projections. `cell reference (= VAL_VAR_POINTER payload shape), u8 segment
130// count, then segments (u8 kind: 0=index i32 / 1=key value)`. Segment kind
131// `2=range` is RESERVED — never emitted (icebox #829). First emission of
132// this reserved tag.
133pub(crate) const VAL_PROJECTION: u8 = 0x0E;
134// NS-A1 (`docs/stdlib-spec.md` §1.1/§1.4, ruled 2026-07-18): the compiler-
135// owned `Option[T]` enum. Wire form: one flag byte (0 = `none`, 1 =
136// `some`), then the inner value when `some` — the enum's two variants,
137// nothing more. Next free tag after `VAL_RECORD` (0x0F); this PR's own
138// reservation, same "assigned here" precedent as the record/handle/
139// projection tags above. Recursion counts toward `MAX_DECODE_DEPTH`
140// exactly like the collection tags (a crafted chain of nested `some`s is
141// the same stack-overflow shape as nested single-element arrays).
142pub(crate) const VAL_OPTION: u8 = 0x10;
143// NS-A5 (`docs/stdlib-spec.md` §7, F7 ruled 2026-07-19): the integer range
144// value kind. Wire form: start i32, end i32, one flag byte (0 = `..`
145// exclusive, 1 = `..=` inclusive) — flat, no recursion, so it does NOT
146// count toward `MAX_DECODE_DEPTH` (a range holds two ints, never another
147// value). Next free tag after `VAL_OPTION` (0x10); same "assigned here"
148// reservation precedent as its neighbors. Distinct from the RESERVED
149// projection-*segment* kind 0x02 below — that is a different namespace.
150pub(crate) const VAL_RANGE: u8 = 0x11;
151// NS-A8 (`docs/tower-mini-spec.md` T5, issue #1114): the numeric tower.
152// Wire form is hand-serialized **explicit little-endian f32 lanes** — never
153// glam's memory layout (it varies with SIMD features and versions) and never
154// serde-through-glam. Lane order: vectors and the quat `x, y(, z, w)`;
155// matrices column-major, column-by-column. Fixed payload sizes (no counts,
156// no recursion — tower values are leaves, so like `VAL_RANGE` they do NOT
157// count toward `MAX_DECODE_DEPTH`): vec2 = 8 bytes, vec3 = 12,
158// vec4/quat/mat2 = 16, mat3 = 36, mat4 = 64. Next free tags after
159// `VAL_RANGE` (0x11); this PR's own reservation, the same "assigned here"
160// precedent as the record/handle/projection/option/range tags above. No
161// format `VERSION` bump — additive value tags follow the NS-A1 `VAL_OPTION`
162// precedent (an old reader rejects the unknown tag; an old file simply
163// never contains one).
164pub(crate) const VAL_VEC2: u8 = 0x12;
165pub(crate) const VAL_VEC3: u8 = 0x13;
166pub(crate) const VAL_VEC4: u8 = 0x14;
167pub(crate) const VAL_QUAT: u8 = 0x15;
168pub(crate) const VAL_MAT2: u8 = 0x16;
169pub(crate) const VAL_MAT3: u8 = 0x17;
170pub(crate) const VAL_MAT4: u8 = 0x18;
171// NS-A7 (`docs/stdlib-spec.md` §8, issue #1113): the weighted table.
172// Wire form: u32 entry count, then per entry an i32 weight followed by a
173// recursively-encoded value. Values recurse, so decoding counts toward
174// `MAX_DECODE_DEPTH` exactly like the collection tags. The reader enforces
175// the §8 evidence-by-construction invariant (non-empty, weights ≥ 1) — a
176// violating payload is a decode error, so a `Weighted` never enters the
177// runtime invalid, even from a crafted file. Next free tag after
178// `VAL_MAT4` (0x18); this PR's own reservation, additive per the NS-A1
179// `VAL_OPTION` precedent (no `VERSION` bump).
180pub(crate) const VAL_WEIGHTED: u8 = 0x19;
181/// Wire kind for a [`crate::ProjSegment::Index`] segment.
182pub(crate) const PROJ_SEG_INDEX: u8 = 0x00;
183/// Wire kind for a [`crate::ProjSegment::Key`] segment.
184pub(crate) const PROJ_SEG_KEY: u8 = 0x01;
185// Segment kind 0x02 (range: start i32, end i32) is RESERVED — sequence
186// slices/ranges (icebox #829). Never emitted; the reader rejects it
187// (`InvalidProjSegmentKind`) since no `ProjSegment` variant exists to decode
188// into, the same discipline the value-tag reservations above follow.
189
190// EffectRows call-atom slots (T2-3, `docs/effects-spec.md` §11). The
191// capability-parameter slot is populated `(any)` in v1; the handle-parameter
192// slot is reserved (`docs/t1d-spec.md` §7) and always `None`.
193/// Capability-parameter slot value: the whole capability, unrefined. The only
194/// value v1 emits; path-granular tags (#826) are reserved (reader rejects).
195pub(crate) const CAP_PARAM_ANY: u8 = 0x00;
196/// Handle-parameter slot value: no bound handle. The only value v1 emits;
197/// a non-zero slot is the reserved handle-parameterized form (reader rejects).
198pub(crate) const HANDLE_PARAM_NONE: u8 = 0x00;
199
200/// NS-A2 (issue #1108): bit assignments for the `DirectEffects` extension
201/// flags byte (`EffectRows` section version 3). Bits 3–7 are RESERVED —
202/// the strict reader rejects a nonzero reserved bit until a section version
203/// graduates it (the same discipline as the capability/handle slots).
204pub(crate) const EFFECT_DIM_EMITS: u8 = 0b0000_0001;
205pub(crate) const EFFECT_DIM_TAGS: u8 = 0b0000_0010;
206pub(crate) const EFFECT_DIM_FAULTS: u8 = 0b0000_0100;
207pub(crate) const EFFECT_DIM_KNOWN_MASK: u8 = EFFECT_DIM_EMITS | EFFECT_DIM_TAGS | EFFECT_DIM_FAULTS;
208
209// LineContent tags
210pub(crate) const LINE_PLAIN: u8 = 0x00;
211pub(crate) const LINE_TEMPLATE: u8 = 0x01;
212
213// LinePart tags
214pub(crate) const PART_LITERAL: u8 = 0x00;
215pub(crate) const PART_SLOT: u8 = 0x01;
216pub(crate) const PART_SELECT: u8 = 0x02;
217// #1716 (`docs/prose-dialect-spec.md` §4.4/§4.5): the inline markup span.
218// Structurally, adding this tag is one match arm on the existing `u8`
219// part-tag dispatch — an old reader hard-rejects the unknown tag
220// (`decode_line_part`'s `_ => Err(InvalidLinePart)`) and an old file simply
221// never contains one. That is *not* the same as the `VAL_VEC2`/
222// `VAL_WEIGHTED` no-`VERSION`-bump precedent, though: those value tags sit
223// in the v4 RFC's pre-reserved, frozen tag inventory (`docs/format-v4-rfc.md`,
224// the §9 one-bump rule), so materializing their encoding was already paid
225// for by v4's bump. `PART_SPAN` was never part of that reservation — issue
226// #1716 rules it explicitly as **"a v6 payload"**, coordinated with #1683
227// (the v6 bump manifest). It IS its own one-bump event: `VERSION` bumped to
228// 6 (see `VERSION`'s doc comment). `.inkl` shares this encoder/decoder
229// (`inkl::{read,write}` call straight through to
230// `encode_line_content`/`decode_line_content`), so both formats gain the
231// tag from this one bump.
232pub(crate) const PART_SPAN: u8 = 0x03;
233
234// SelectKey tags
235pub(crate) const KEY_CARDINAL: u8 = 0x00;
236pub(crate) const KEY_ORDINAL: u8 = 0x01;
237pub(crate) const KEY_EXACT: u8 = 0x02;
238pub(crate) const KEY_KEYWORD: u8 = 0x03;
239
240// PluralCategory tags
241pub(crate) const CAT_ZERO: u8 = 0x00;
242pub(crate) const CAT_ONE: u8 = 0x01;
243pub(crate) const CAT_TWO: u8 = 0x02;
244pub(crate) const CAT_FEW: u8 = 0x03;
245pub(crate) const CAT_MANY: u8 = 0x04;
246pub(crate) const CAT_OTHER: u8 = 0x05;
247
248// ── Section types ───────────────────────────────────────────────────────────
249
250/// Identifies a section within an `.inkb` file.
251#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
252#[repr(u8)]
253pub enum SectionKind {
254    NameTable = 0x01,
255    Variables = 0x02,
256    ListDefs = 0x03,
257    ListItems = 0x04,
258    Externals = 0x05,
259    Containers = 0x06,
260    LineTables = 0x07,
261    Labels = 0x08,
262    ListLiterals = 0x09,
263    AddressPaths = 0x0A,
264    /// T1b `LiteralPool` (`docs/format-v4-rfc.md` §2): content-hash
265    /// deduplicated constant values referenced by `PushLiteral(idx)`.
266    /// Additive alongside `ListLiterals` — see the T1b-2 PR description for
267    /// why the RFC's `ListLiterals` absorption is deferred, not done here.
268    LiteralPool = 0x0B,
269    /// TM-4 `StructShapes` (`docs/typed-mode-spec.md` §6): one entry per
270    /// declared `STRUCT` — shape id, name, ordered field names. Reserved
271    /// (count always 0) through 4.0; this PR lands the section's real
272    /// encoding at the format layer only — nothing in the compiler emits a
273    /// non-empty table yet (see the PR description's scope note).
274    StructShapes = 0x0C,
275    /// T2-3 `EffectRows` (`docs/effects-spec.md` §11, `docs/format-v4-rfc.md`
276    /// §2): the `DefinitionId → row` table of factored effect rows — one per
277    /// knot/stitch (the resume-scheduling estimate, §12.1). Section-locally
278    /// versioned (one prefix byte) so the row encoding can grow without a
279    /// format-wide bump — the reservation this graduates was made for exactly
280    /// this, so no `VERSION` bump accompanies it. Always present (possibly
281    /// empty). Was reserved (count-0) through v5; this slice lands the real
282    /// encoding **and** first emission (rows are inert metadata the runtime
283    /// does not yet read).
284    EffectRows = 0x0D,
285    /// M-2b `Visibility` (`docs/modules-spec.md` §4, `docs/format-spec.md`):
286    /// the `DefinitionId`s of every `#@private` definition, sorted ascending.
287    /// **Omitted entirely when empty** (the common all-public case), so
288    /// public-only stories stay byte-identical for that section — the
289    /// section is purely additive and self-framed in the offset table.
290    /// `0x0D` is claimed by `EffectRows`, so this takes the next free tag.
291    Visibility = 0x0E,
292    /// M-3 `AliasTable` (`docs/modules-spec.md` §5): old→new `DefinitionId`
293    /// rename records from `#@was(old_name)` directives. Section-locally
294    /// versioned (one prefix byte) so the row encoding can grow without a
295    /// format-wide bump. Always present (possibly empty) from v5 onward.
296    /// `0x0E` was claimed by `Visibility` (M-2b), so this takes the next
297    /// free tag.
298    AliasTable = 0x0F,
299    /// FS-3 `FrameShapes` (`docs/flow-suspension-spec.md` §4/§11): one
300    /// name-keyed frame shape per `await` site — the static crossing-locals
301    /// description the runtime spills/restores around a park.
302    /// Section-locally versioned (one prefix byte) so the shape encoding can
303    /// grow without a format-wide bump. **Omitted entirely when empty** (the
304    /// common case — and, behind the E052 fence, the *only* case today, since
305    /// no `await` compiles yet), so all existing stories stay byte-identical
306    /// and no `VERSION` bump is needed. `0x0F` was claimed by `AliasTable`, so
307    /// this takes the next free tag.
308    FrameShapes = 0x10,
309}
310
311// All v4-reserved section kinds have now graduated: `LiteralPool` (0x0B),
312// `StructShapes` (0x0C), and `EffectRows` (0x0D, T2-3). `Visibility` (0x0E)
313// and `AliasTable` (0x0F) were later one-bump additions past the reserved gap.
314
315impl SectionKind {
316    pub(crate) fn from_u8(tag: u8) -> Result<Self, DecodeError> {
317        match tag {
318            0x01 => Ok(Self::NameTable),
319            0x02 => Ok(Self::Variables),
320            0x03 => Ok(Self::ListDefs),
321            0x04 => Ok(Self::ListItems),
322            0x05 => Ok(Self::Externals),
323            0x06 => Ok(Self::Containers),
324            0x07 => Ok(Self::LineTables),
325            0x08 => Ok(Self::Labels),
326            0x09 => Ok(Self::ListLiterals),
327            0x0A => Ok(Self::AddressPaths),
328            0x0B => Ok(Self::LiteralPool),
329            0x0C => Ok(Self::StructShapes),
330            0x0D => Ok(Self::EffectRows),
331            0x0E => Ok(Self::Visibility),
332            0x0F => Ok(Self::AliasTable),
333            0x10 => Ok(Self::FrameShapes),
334            _ => Err(DecodeError::InvalidSectionKind(tag)),
335        }
336    }
337}
338
339/// An entry in the `.inkb` offset table.
340#[derive(Debug, Clone, Copy, PartialEq, Eq)]
341pub struct SectionEntry {
342    pub kind: SectionKind,
343    pub offset: u32,
344}
345
346/// Parsed header + offset table from an `.inkb` file.
347///
348/// Allows selective reads without parsing section data.
349#[derive(Debug, Clone, PartialEq, Eq)]
350pub struct InkbIndex {
351    pub version: u16,
352    pub file_size: u32,
353    pub checksum: u32,
354    pub sections: Vec<SectionEntry>,
355}
356
357impl InkbIndex {
358    /// Total header size in bytes (preamble + offset table).
359    pub fn header_size(&self) -> usize {
360        HEADER_PREAMBLE + self.sections.len() * SECTION_ENTRY_SIZE
361    }
362
363    /// Returns `(offset, length)` for a section, computing length from the
364    /// next section's offset (or `file_size` for the last section).
365    ///
366    /// Subtraction is safe because `read_inkb_index` validates that offsets
367    /// are monotonically increasing and within `[header_size, file_size]`.
368    pub fn section_range(&self, kind: SectionKind) -> Option<Range<usize>> {
369        let idx = self.sections.iter().position(|e| e.kind == kind)?;
370        let start = self.sections[idx].offset as usize;
371        let end = self
372            .sections
373            .get(idx + 1)
374            .map_or(self.file_size, |e| e.offset) as usize;
375        Some(start..end)
376    }
377}
378
379/// Cap `Vec::with_capacity` allocations against remaining buffer bytes to avoid
380/// OOM on crafted inputs with huge count fields. Each element occupies at least
381/// `min_element_size` bytes, so the count can't exceed `remaining / min`.
382pub(crate) fn safe_capacity(
383    count: usize,
384    buf_len: usize,
385    offset: usize,
386    min_element_size: usize,
387) -> usize {
388    let remaining = buf_len.saturating_sub(offset);
389    let max_possible = remaining.checked_div(min_element_size).unwrap_or(remaining);
390    count.min(max_possible)
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396
397    /// Every v4-reserved section tag has now graduated to a real
398    /// `SectionKind` variant — `LiteralPool` (0x0B, T1b-2 #570),
399    /// `StructShapes` (0x0C, TM-4 #620), and `EffectRows` (0x0D, T2-3 #862).
400    /// `FrameShapes` (0x10, FS-3c) is the newest tag; the next unclaimed tag
401    /// (0x11) is still rejected.
402    #[test]
403    fn from_u8_rejects_unclaimed_section_tag() {
404        let tag = 0x11u8;
405        let err = SectionKind::from_u8(tag).unwrap_err();
406        assert_eq!(err, DecodeError::InvalidSectionKind(tag));
407    }
408
409    #[test]
410    fn from_u8_accepts_all_current_sections() {
411        // 0x01..=0x0D are contiguous real sections (EffectRows graduated 0x0D).
412        for tag in 0x01u8..=0x0D {
413            assert!(SectionKind::from_u8(tag).is_ok());
414        }
415        assert!(SectionKind::from_u8(0x0E).is_ok(), "Visibility (M-2b)");
416        assert!(SectionKind::from_u8(0x0F).is_ok(), "AliasTable (M-3)");
417        assert!(SectionKind::from_u8(0x10).is_ok(), "FrameShapes (FS-3c)");
418    }
419}