Skip to main content

brink_format/inkb/
write.rs

1//! Encoding (write) half of the `.inkb` binary format.
2
3use alloc::string::String;
4use alloc::vec::Vec;
5
6use crate::codec::{
7    crc32, write_def_id, write_i32, write_str, write_u8, write_u16, write_u32, write_u64,
8    write_varint,
9};
10use crate::definition::{
11    AddressDef, AddressPath, AliasEntry, CallAtom, CapabilityParam, ContainerDef, DebugInfoSection,
12    DirectEffects, EffectRowEntry, ExternalFnDef, FrameShapeDef, GlobalVarDef, LineEntry,
13    LineVariantGroup, ListDef, ListItemDef, ScopeLineTable, StructShapeDef,
14};
15use crate::id::DefinitionId;
16use crate::line::{LineContent, LinePart, PluralCategory, SelectKey};
17use crate::story::StoryData;
18use crate::value::{ListValue, MapKey, ProjSegment, Value, ValueType};
19
20use super::{
21    CAP_PARAM_ANY, CAT_FEW, CAT_MANY, CAT_ONE, CAT_OTHER, CAT_TWO, CAT_ZERO, HANDLE_PARAM_NONE,
22    HEADER_PREAMBLE, KEY_CARDINAL, KEY_EXACT, KEY_KEYWORD, KEY_ORDINAL, LINE_PLAIN, LINE_TEMPLATE,
23    MAGIC, PART_LITERAL, PART_SELECT, PART_SLOT, PART_SPAN, PROJ_SEG_INDEX, PROJ_SEG_KEY,
24    SECTION_COUNT, SECTION_ENTRY_SIZE, SectionKind, VAL_ARRAY, VAL_BOOL, VAL_CLOSURE,
25    VAL_DIVERT_TARGET, VAL_FLOAT, VAL_FN_REF, VAL_FRAGMENT_REF, VAL_HANDLE, VAL_INT, VAL_LIST,
26    VAL_MAP, VAL_MAT2, VAL_MAT3, VAL_MAT4, VAL_NULL, VAL_OPTION, VAL_PROJECTION, VAL_QUAT,
27    VAL_RANGE, VAL_RECORD, VAL_STRING, VAL_VAR_POINTER, VAL_VEC2, VAL_VEC3, VAL_VEC4, VAL_WEIGHTED,
28    VERSION,
29};
30
31// ── Tier 1: Full story write ────────────────────────────────────────────────
32
33/// Encode a [`StoryData`] into the `.inkb` binary format with sectioned header.
34#[expect(clippy::cast_possible_truncation, clippy::too_many_lines)]
35pub fn write_inkb(story: &StoryData, buf: &mut Vec<u8>) {
36    let base = buf.len();
37
38    // The `Visibility` section (M-2b, tag `0x0E`) is **optional**: emitted
39    // only when the story has `#@private` definitions. All-public stories —
40    // the entire pre-modules world — omit it, so their offset table stays
41    // at `SECTION_COUNT` entries (which now includes the mandatory M-3
42    // `AliasTable` section, always present — possibly empty — from v5
43    // onward; see `SectionKind::AliasTable`).
44    let has_visibility = !story.private_defs.is_empty();
45    // The `FrameShapes` section (FS-3, tag `0x10`) is likewise **optional**:
46    // emitted only when the story carries `await` frame shapes. Behind the
47    // E052 fence no `await` compiles, so this is always empty today and every
48    // existing story omits it (byte-identical, no `VERSION` bump).
49    let has_frame_shapes = !story.frame_shapes.is_empty();
50    // The `DebugInfo` section (D6, tag `0x11`) is likewise **optional**:
51    // emitted only when the story was compiled with the debug flag on
52    // (`docs/debugger-spec.md` §1.2 ship policy — dev/studio compiles and
53    // an explicit CLI flag opt in; release export never does). Presence
54    // tracks *whether debug info was requested*, not whether any entry
55    // exists, so this checks `is_some()` rather than emptiness like the
56    // sections above.
57    let has_debug_info = story.debug_info.is_some();
58    // The `LineVariantGroups` section (#3273, tag `0x12`) is likewise
59    // **optional**: emitted only when the compiler enumerated at least one
60    // variant group. Nothing does until the stage-2 flip (#3274), so every
61    // existing story omits it (byte-identical, no `VERSION` bump).
62    let has_line_variant_groups = !story.line_variant_groups.is_empty();
63    let section_count = SECTION_COUNT as usize
64        + usize::from(has_visibility)
65        + usize::from(has_frame_shapes)
66        + usize::from(has_debug_info)
67        + usize::from(has_line_variant_groups);
68    let header_size = HEADER_PREAMBLE + section_count * SECTION_ENTRY_SIZE;
69
70    // Write placeholder header (zeros) — we'll patch it after writing sections.
71    buf.resize(base + header_size, 0);
72
73    // Track (kind, offset) pairs as we write each section, in canonical
74    // tag order. The offset table is self-describing (count + per-entry tag),
75    // so a conditionally-omitted section is fully readable.
76    let mut sections: Vec<(SectionKind, u32)> = Vec::with_capacity(section_count);
77
78    macro_rules! section {
79        ($kind:expr, $write:expr) => {{
80            let offset = (buf.len() - base) as u32;
81            $write;
82            sections.push(($kind, offset));
83        }};
84    }
85
86    section!(
87        SectionKind::NameTable,
88        write_section_name_table(&story.name_table, buf)
89    );
90    section!(
91        SectionKind::Variables,
92        write_section_variables(&story.variables, buf)
93    );
94    section!(
95        SectionKind::ListDefs,
96        write_section_list_defs(&story.list_defs, buf)
97    );
98    section!(
99        SectionKind::ListItems,
100        write_section_list_items(&story.list_items, buf)
101    );
102    section!(
103        SectionKind::Externals,
104        write_section_externals(&story.externals, buf)
105    );
106    section!(
107        SectionKind::Containers,
108        write_section_containers(&story.containers, buf)
109    );
110    section!(
111        SectionKind::LineTables,
112        write_section_line_tables(&story.line_tables, buf)
113    );
114    section!(
115        SectionKind::Labels,
116        write_section_addresses(&story.addresses, buf)
117    );
118    section!(
119        SectionKind::ListLiterals,
120        write_section_list_literals(&story.list_literals, buf)
121    );
122    section!(
123        SectionKind::AddressPaths,
124        write_section_address_paths(&story.address_paths, buf)
125    );
126    section!(
127        SectionKind::LiteralPool,
128        write_section_literal_pool(&story.literal_pool, buf)
129    );
130    section!(
131        SectionKind::StructShapes,
132        write_section_struct_shapes(&story.struct_shapes, buf)
133    );
134    // EffectRows (T2-3, tag 0x0D) is mandatory — always present (possibly
135    // empty), section-locally versioned. Emitted between StructShapes and the
136    // optional Visibility section so tags stay in canonical ascending order.
137    section!(
138        SectionKind::EffectRows,
139        write_section_effect_rows(&story.effect_rows, buf)
140    );
141    if has_visibility {
142        section!(
143            SectionKind::Visibility,
144            write_section_visibility(&story.private_defs, buf)
145        );
146    }
147    // AliasTable (M-3) is mandatory — always present (possibly empty) from
148    // v5 onward, unlike the optional `Visibility` section above.
149    section!(
150        SectionKind::AliasTable,
151        write_section_alias_table(&story.alias_table, buf)
152    );
153    // FrameShapes (FS-3, tag 0x10) is optional — emitted last (highest tag) so
154    // the offset table stays in canonical ascending tag order, and omitted
155    // entirely when empty so existing stories stay byte-identical.
156    if has_frame_shapes {
157        section!(
158            SectionKind::FrameShapes,
159            write_section_frame_shapes(&story.frame_shapes, buf)
160        );
161    }
162    // DebugInfo (D6, tag 0x11) is optional and emitted last (highest tag) —
163    // omitted entirely when not requested so every story compiled without
164    // the debug flag stays byte-identical to a pre-D6 compile (the
165    // oracle-safety guarantee, `docs/debugger-spec.md` §1.2/§6).
166    if let Some(debug_info) = &story.debug_info {
167        section!(
168            SectionKind::DebugInfo,
169            write_section_debug_info(debug_info, buf)
170        );
171    }
172    // LineVariantGroups (#3273, tag 0x12) is optional and emitted last
173    // (highest tag) — omitted entirely when empty so every story without
174    // variant groups stays byte-identical.
175    if has_line_variant_groups {
176        section!(
177            SectionKind::LineVariantGroups,
178            write_section_line_variant_groups(&story.line_variant_groups, buf)
179        );
180    }
181
182    let file_size = (buf.len() - base) as u32;
183    let checksum = crc32(&buf[base + header_size..]);
184
185    // Patch header in-place.
186    let h = &mut buf[base..];
187    h[0..4].copy_from_slice(MAGIC);
188    h[4..6].copy_from_slice(&VERSION.to_le_bytes());
189    h[6] = section_count as u8;
190    h[7] = 0; // reserved
191    h[8..12].copy_from_slice(&file_size.to_le_bytes());
192    h[12..16].copy_from_slice(&checksum.to_le_bytes());
193
194    for (i, (kind, offset)) in sections.iter().enumerate() {
195        let entry_base = HEADER_PREAMBLE + i * SECTION_ENTRY_SIZE;
196        h[entry_base] = *kind as u8;
197        h[entry_base + 1] = 0; // reserved
198        h[entry_base + 2] = 0;
199        h[entry_base + 3] = 0;
200        h[entry_base + 4..entry_base + 8].copy_from_slice(&offset.to_le_bytes());
201    }
202}
203
204// ── Assembly ────────────────────────────────────────────────────────────────
205
206/// Assemble a complete `.inkb` file from pre-encoded section buffers.
207///
208/// Sections should be provided in the canonical order matching [`SectionKind`]
209/// tags. The header (with offsets and checksum) is computed automatically.
210#[expect(clippy::cast_possible_truncation)]
211pub fn assemble_inkb(sections: &[(SectionKind, &[u8])], out: &mut Vec<u8>) {
212    let base = out.len();
213    let section_count = sections.len() as u8;
214    let header_size = HEADER_PREAMBLE + sections.len() * SECTION_ENTRY_SIZE;
215
216    // Placeholder header.
217    out.resize(base + header_size, 0);
218
219    // Append section data and record offsets.
220    let mut entries: Vec<(SectionKind, u32)> = Vec::with_capacity(sections.len());
221    for (kind, data) in sections {
222        let offset = (out.len() - base) as u32;
223        entries.push((*kind, offset));
224        out.extend_from_slice(data);
225    }
226
227    let file_size = (out.len() - base) as u32;
228    let checksum = crc32(&out[base + header_size..]);
229
230    // Patch header.
231    let h = &mut out[base..];
232    h[0..4].copy_from_slice(MAGIC);
233    h[4..6].copy_from_slice(&VERSION.to_le_bytes());
234    h[6] = section_count;
235    h[7] = 0;
236    h[8..12].copy_from_slice(&file_size.to_le_bytes());
237    h[12..16].copy_from_slice(&checksum.to_le_bytes());
238
239    for (i, (kind, offset)) in entries.iter().enumerate() {
240        let entry_base = HEADER_PREAMBLE + i * SECTION_ENTRY_SIZE;
241        h[entry_base] = *kind as u8;
242        h[entry_base + 1] = 0;
243        h[entry_base + 2] = 0;
244        h[entry_base + 3] = 0;
245        h[entry_base + 4..entry_base + 8].copy_from_slice(&offset.to_le_bytes());
246    }
247}
248
249// ── Section writers ─────────────────────────────────────────────────────────
250
251/// Write the name table section (no header framing).
252#[expect(clippy::cast_possible_truncation)]
253pub fn write_section_name_table(names: &[String], buf: &mut Vec<u8>) {
254    write_u32(buf, names.len() as u32);
255    for name in names {
256        write_str(buf, name);
257    }
258}
259
260/// Write the variables section (no header framing).
261#[expect(clippy::cast_possible_truncation)]
262pub fn write_section_variables(variables: &[GlobalVarDef], buf: &mut Vec<u8>) {
263    write_u32(buf, variables.len() as u32);
264    for var in variables {
265        encode_global_var(var, buf);
266    }
267}
268
269/// Write the list definitions section (no header framing).
270#[expect(clippy::cast_possible_truncation)]
271pub fn write_section_list_defs(list_defs: &[ListDef], buf: &mut Vec<u8>) {
272    write_u32(buf, list_defs.len() as u32);
273    for ld in list_defs {
274        encode_list_def(ld, buf);
275    }
276}
277
278/// Write the list items section (no header framing).
279#[expect(clippy::cast_possible_truncation)]
280pub fn write_section_list_items(list_items: &[ListItemDef], buf: &mut Vec<u8>) {
281    write_u32(buf, list_items.len() as u32);
282    for li in list_items {
283        encode_list_item(li, buf);
284    }
285}
286
287/// Write the externals section (no header framing).
288#[expect(clippy::cast_possible_truncation)]
289pub fn write_section_externals(externals: &[ExternalFnDef], buf: &mut Vec<u8>) {
290    write_u32(buf, externals.len() as u32);
291    for ext in externals {
292        encode_external(ext, buf);
293    }
294}
295
296/// Write the containers section (no header framing).
297#[expect(clippy::cast_possible_truncation)]
298pub fn write_section_containers(containers: &[ContainerDef], buf: &mut Vec<u8>) {
299    write_u32(buf, containers.len() as u32);
300    for c in containers {
301        encode_container(c, buf);
302    }
303}
304
305/// Write the addresses section (no header framing).
306#[expect(clippy::cast_possible_truncation)]
307pub fn write_section_addresses(addresses: &[AddressDef], buf: &mut Vec<u8>) {
308    write_u32(buf, addresses.len() as u32);
309    for addr in addresses {
310        write_def_id(buf, addr.id);
311        write_def_id(buf, addr.container_id);
312        write_u32(buf, addr.byte_offset);
313    }
314}
315
316/// Write the address-paths section (no header framing).
317#[expect(clippy::cast_possible_truncation)]
318pub fn write_section_address_paths(address_paths: &[AddressPath], buf: &mut Vec<u8>) {
319    write_u32(buf, address_paths.len() as u32);
320    for ap in address_paths {
321        write_u16(buf, ap.path.0);
322        write_def_id(buf, ap.target);
323    }
324}
325
326/// Write the visibility section (no header framing): a count followed by the
327/// `DefinitionId` of every `#@private` definition (M-2b). Callers only emit
328/// this section when `private_defs` is non-empty.
329#[expect(clippy::cast_possible_truncation)]
330pub fn write_section_visibility(private_defs: &[DefinitionId], buf: &mut Vec<u8>) {
331    write_u32(buf, private_defs.len() as u32);
332    for id in private_defs {
333        write_def_id(buf, *id);
334    }
335}
336
337// ── Encode helpers (private) ────────────────────────────────────────────────
338
339fn encode_global_var(v: &GlobalVarDef, buf: &mut Vec<u8>) {
340    write_def_id(buf, v.id);
341    write_u16(buf, v.name.0);
342    encode_value_type(v.value_type, buf);
343    encode_value(&v.default_value, buf);
344    write_u8(buf, u8::from(v.mutable));
345    write_u8(buf, u8::from(v.local));
346}
347
348fn encode_value_type(vt: ValueType, buf: &mut Vec<u8>) {
349    let tag = match vt {
350        ValueType::Int => VAL_INT,
351        ValueType::Float => VAL_FLOAT,
352        ValueType::Bool => VAL_BOOL,
353        ValueType::String => VAL_STRING,
354        ValueType::List => VAL_LIST,
355        ValueType::DivertTarget => VAL_DIVERT_TARGET,
356        ValueType::VariablePointer => VAL_VAR_POINTER,
357        // TempPointer is runtime-only and should never appear in .inkb files.
358        ValueType::FragmentRef => VAL_FRAGMENT_REF,
359        ValueType::TempPointer | ValueType::Null => VAL_NULL,
360        // Collection value types (v4, `docs/format-v4-rfc.md` §1).
361        ValueType::Array => VAL_ARRAY,
362        ValueType::Map => VAL_MAP,
363        // TM-4 record value type (v4, reserved tag graduated this PR).
364        ValueType::Record => VAL_RECORD,
365        // T1c function value types (v4, materialized in #700).
366        ValueType::FnRef => VAL_FN_REF,
367        ValueType::Closure => VAL_CLOSURE,
368        // T1d handle value type (v4, reserved tag graduated this PR).
369        ValueType::Handle => VAL_HANDLE,
370        // T1e projection value type (v4, reserved tag graduated this PR).
371        ValueType::Projection => VAL_PROJECTION,
372        // NS-A1 Option value type.
373        ValueType::Option => VAL_OPTION,
374        // NS-A5 range value type (F7).
375        ValueType::Range => VAL_RANGE,
376        // NS-A8 numeric tower value types.
377        ValueType::Vec2 => VAL_VEC2,
378        ValueType::Vec3 => VAL_VEC3,
379        ValueType::Vec4 => VAL_VEC4,
380        ValueType::Quat => VAL_QUAT,
381        ValueType::Mat2 => VAL_MAT2,
382        ValueType::Mat3 => VAL_MAT3,
383        ValueType::Mat4 => VAL_MAT4,
384        // NS-A7 weighted table value type.
385        ValueType::Weighted => VAL_WEIGHTED,
386    };
387    write_u8(buf, tag);
388}
389
390/// NS-A8 (`docs/tower-mini-spec.md` T5): write tower lanes as explicit
391/// little-endian f32s, one by one — the hand-serialized wire form. The
392/// caller supplies the lanes in the pinned order (vec/quat `x, y(, z, w)`;
393/// matrices column-major via `to_cols_array`), always from glam's explicit
394/// array conversions — never from glam's memory representation.
395fn write_f32_lanes(buf: &mut Vec<u8>, lanes: &[f32]) {
396    for lane in lanes {
397        buf.extend_from_slice(&lane.to_le_bytes());
398    }
399}
400
401#[expect(clippy::cast_possible_truncation)]
402#[expect(
403    clippy::too_many_lines,
404    reason = "one match arm per value variant — the NS-A1 Option arm pushed this past 100"
405)]
406fn encode_value(v: &Value, buf: &mut Vec<u8>) {
407    match v {
408        Value::Int(n) => {
409            write_u8(buf, VAL_INT);
410            write_i32(buf, *n);
411        }
412        Value::Float(n) => {
413            write_u8(buf, VAL_FLOAT);
414            buf.extend_from_slice(&n.to_le_bytes());
415        }
416        Value::Bool(b) => {
417            write_u8(buf, VAL_BOOL);
418            write_u8(buf, u8::from(*b));
419        }
420        Value::String(s) => {
421            write_u8(buf, VAL_STRING);
422            write_str(buf, s);
423        }
424        Value::List(lv) => {
425            write_u8(buf, VAL_LIST);
426            write_u32(buf, lv.items.len() as u32);
427            for item in &lv.items {
428                write_def_id(buf, *item);
429            }
430            write_u32(buf, lv.origins.len() as u32);
431            for origin in &lv.origins {
432                write_def_id(buf, *origin);
433            }
434        }
435        Value::DivertTarget(id) => {
436            write_u8(buf, VAL_DIVERT_TARGET);
437            write_def_id(buf, *id);
438        }
439        Value::VariablePointer(id) => {
440            write_u8(buf, VAL_VAR_POINTER);
441            write_def_id(buf, *id);
442        }
443        Value::FragmentRef(idx) => {
444            write_u8(buf, VAL_FRAGMENT_REF);
445            write_u32(buf, *idx);
446        }
447        // TempPointer is runtime-only and should never appear in .inkb files.
448        Value::TempPointer { .. } | Value::Null => {
449            write_u8(buf, VAL_NULL);
450        }
451        // Collections encode as trees (v4, `docs/format-v4-rfc.md` §1): a length
452        // prefix then the recursively-encoded elements / key-value pairs. Arc
453        // sharing is deliberately not preserved on the wire (value-model-spec §5).
454        Value::Array(items) => {
455            write_u8(buf, VAL_ARRAY);
456            write_u32(buf, items.len() as u32);
457            for item in items.iter() {
458                encode_value(item, buf);
459            }
460        }
461        Value::Map(map) => {
462            write_u8(buf, VAL_MAP);
463            write_u32(buf, map.len() as u32);
464            // Insertion order is semantic (keys restricted to int/string/bool).
465            for (key, val) in map.iter() {
466                encode_map_key(key, buf);
467                encode_value(val, buf);
468            }
469        }
470        // TM-4 (`docs/format-v4-rfc.md` §1): `ShapeId` then field values in
471        // shape order — no field names on the wire (they live once, in the
472        // `StructShapes` section entry the shape id references).
473        Value::Record { shape, fields } => {
474            write_u8(buf, VAL_RECORD);
475            write_u32(buf, shape.0);
476            write_u32(buf, fields.len() as u32);
477            for field in fields.iter() {
478                encode_value(field, buf);
479            }
480        }
481        // Function values (T1c, `docs/format-v4-rfc.md` §1). `FnRef` is just
482        // the fn token; `Closure` adds a u16-counted env of `{NameId, kind u8,
483        // value}` entries — the named/moded env is the redundancy rehydration
484        // validation reads (spec §6).
485        Value::FnRef(target) => {
486            write_u8(buf, VAL_FN_REF);
487            write_def_id(buf, *target);
488        }
489        Value::Closure(c) => {
490            write_u8(buf, VAL_CLOSURE);
491            write_def_id(buf, c.target);
492            write_u16(buf, c.env.len() as u16);
493            for entry in &c.env {
494                write_u16(buf, entry.name.0);
495                write_u8(buf, u8::from(entry.is_ref));
496                encode_value(&entry.payload, buf);
497            }
498        }
499        // Handle values (T1d, `docs/format-v4-rfc.md` §1: `kind NameId, u64
500        // id`). First emission of this reserved tag — the wire form is frozen
501        // by the RFC, materialized here. No opcode ever pushes one; a handle
502        // reaches this encoder only as a binding-produced global default or a
503        // literal-pool entry supplied by a future manifest-aware pipeline.
504        Value::Handle { kind, id } => {
505            write_u8(buf, VAL_HANDLE);
506            write_u16(buf, kind.0);
507            write_u64(buf, *id);
508        }
509        // Projection values (T1e, `docs/format-v4-rfc.md` §1: "cell
510        // reference, u8 segment count, then segments"). First emission of
511        // this reserved tag. Segment kind `2=range` is RESERVED and never
512        // written — `ProjSegment` has no variant to produce it.
513        Value::Projection(p) => {
514            write_u8(buf, VAL_PROJECTION);
515            write_def_id(buf, p.cell);
516            write_u8(buf, p.segments.len() as u8);
517            for seg in &p.segments {
518                encode_proj_segment(seg, buf);
519            }
520        }
521        // Option values (NS-A1, `docs/stdlib-spec.md` §1.4): flag byte
522        // (0 = none, 1 = some) then the inner value when some. No opcode
523        // literal ever produces one at compile time today (`none`/`some(x)`
524        // lower to `PushNone`/`MakeSome`, and a bare-`none` declaration
525        // default is the E107 compile error), so like `VAL_HANDLE` above
526        // this encoder leg exists for wire completeness — saves/transcripts
527        // are the live consumers.
528        Value::OptionVal(inner) => {
529            write_u8(buf, VAL_OPTION);
530            match inner {
531                None => write_u8(buf, 0),
532                Some(v) => {
533                    write_u8(buf, 1);
534                    encode_value(v, buf);
535                }
536            }
537        }
538        // Range values (NS-A5, F7): start i32, end i32, inclusive flag —
539        // the *written* form is preserved on the wire (1..=6 and 1..7 are
540        // content-equal but round-trip their own spelling). Flat: no
541        // recursion, no depth accounting.
542        Value::Range {
543            start,
544            end,
545            inclusive,
546        } => {
547            write_u8(buf, VAL_RANGE);
548            write_i32(buf, *start);
549            write_i32(buf, *end);
550            write_u8(buf, u8::from(*inclusive));
551        }
552        // Tower values (NS-A8, `docs/tower-mini-spec.md` T5): explicit
553        // little-endian f32 lanes in the pinned order — vectors and the
554        // quat `x, y(, z, w)`, matrices column-major column-by-column via
555        // glam's `to_cols_array` (an explicit conversion, never a memory
556        // cast). Fixed sizes, no counts, no recursion. Like `VAL_HANDLE`
557        // above, no opcode literal produces one at compile time today
558        // (construction is the runtime `Tower` opcode) — saves, transcripts
559        // and future const-folding are the wire consumers.
560        Value::Vec2(v) => {
561            write_u8(buf, VAL_VEC2);
562            write_f32_lanes(buf, &v.to_array());
563        }
564        Value::Vec3(v) => {
565            write_u8(buf, VAL_VEC3);
566            write_f32_lanes(buf, &v.to_array());
567        }
568        Value::Vec4(v) => {
569            write_u8(buf, VAL_VEC4);
570            write_f32_lanes(buf, &v.to_array());
571        }
572        Value::Quat(q) => {
573            write_u8(buf, VAL_QUAT);
574            write_f32_lanes(buf, &q.to_array());
575        }
576        Value::Mat2(m) => {
577            write_u8(buf, VAL_MAT2);
578            write_f32_lanes(buf, &m.to_cols_array());
579        }
580        Value::Mat3(m) => {
581            write_u8(buf, VAL_MAT3);
582            write_f32_lanes(buf, &m.to_cols_array());
583        }
584        Value::Mat4(m) => {
585            write_u8(buf, VAL_MAT4);
586            write_f32_lanes(buf, &m.to_cols_array());
587        }
588        // Weighted tables (NS-A7, `docs/stdlib-spec.md` §8): u32 entry
589        // count, then per entry an i32 weight and the recursively-encoded
590        // value, in construction order (order is semantic for display and
591        // the roll walk). Like `VAL_HANDLE` above, no opcode literal
592        // produces one at compile time today (construction is the runtime
593        // `Collect(WeightedNew)` op) — saves and transcripts are the wire
594        // consumers.
595        Value::Weighted(w) => {
596            write_u8(buf, VAL_WEIGHTED);
597            write_u32(buf, w.entries.len() as u32);
598            for (weight, value) in &w.entries {
599                write_i32(buf, *weight);
600                encode_value(value, buf);
601            }
602        }
603    }
604}
605
606/// Encode a single [`ProjSegment`] (`docs/format-v4-rfc.md` §1: `u8 kind (0
607/// = index i32, 1 = key value)`).
608fn encode_proj_segment(seg: &ProjSegment, buf: &mut Vec<u8>) {
609    match seg {
610        ProjSegment::Index(n) => {
611            write_u8(buf, PROJ_SEG_INDEX);
612            write_i32(buf, *n);
613        }
614        ProjSegment::Key(v) => {
615            write_u8(buf, PROJ_SEG_KEY);
616            encode_value(v, buf);
617        }
618    }
619}
620
621/// Encode a [`MapKey`] using the scalar `VAL_*` tag surface it maps onto
622/// (`int`/`string`/`bool` — the v1 key domain, `docs/value-model-spec.md` §4).
623/// Self-describing so the reader can reject a non-scalar key tag.
624fn encode_map_key(key: &MapKey, buf: &mut Vec<u8>) {
625    match key {
626        MapKey::Int(n) => {
627            write_u8(buf, VAL_INT);
628            write_i32(buf, *n);
629        }
630        MapKey::Str(s) => {
631            write_u8(buf, VAL_STRING);
632            write_str(buf, s);
633        }
634        MapKey::Bool(b) => {
635            write_u8(buf, VAL_BOOL);
636            write_u8(buf, u8::from(*b));
637        }
638    }
639}
640
641#[expect(clippy::cast_possible_truncation)]
642fn encode_list_def(ld: &ListDef, buf: &mut Vec<u8>) {
643    write_def_id(buf, ld.id);
644    write_u16(buf, ld.name.0);
645    write_u32(buf, ld.items.len() as u32);
646    for (name_id, ordinal) in &ld.items {
647        write_u16(buf, name_id.0);
648        write_i32(buf, *ordinal);
649    }
650}
651
652fn encode_list_item(li: &ListItemDef, buf: &mut Vec<u8>) {
653    write_def_id(buf, li.id);
654    write_def_id(buf, li.origin);
655    write_i32(buf, li.ordinal);
656    write_u16(buf, li.name.0);
657}
658
659/// Write the list literals section (no header framing).
660#[expect(clippy::cast_possible_truncation)]
661pub fn write_section_list_literals(list_literals: &[ListValue], buf: &mut Vec<u8>) {
662    write_u32(buf, list_literals.len() as u32);
663    for lv in list_literals {
664        write_u32(buf, lv.items.len() as u32);
665        for item in &lv.items {
666            write_def_id(buf, *item);
667        }
668        write_u32(buf, lv.origins.len() as u32);
669        for origin in &lv.origins {
670            write_def_id(buf, *origin);
671        }
672    }
673}
674
675/// Write the T1b literal pool section (no header framing) — a flat list of
676/// content-hash-deduplicated constant [`Value`]s referenced by
677/// `PushLiteral(idx)` (`docs/format-v4-rfc.md` §2). Each entry uses the
678/// existing generic `encode_value` (the same recursive `VAL_ARRAY`/`VAL_MAP`
679/// tree encoding as a `GlobalVarDef` default).
680#[expect(clippy::cast_possible_truncation)]
681pub fn write_section_literal_pool(literal_pool: &[Value], buf: &mut Vec<u8>) {
682    write_u32(buf, literal_pool.len() as u32);
683    for v in literal_pool {
684        encode_value(v, buf);
685    }
686}
687
688/// Write the TM-4 `StructShapes` section (no header framing): one entry per
689/// declared `STRUCT` — shape id, name, then its ordered field `NameId`s
690/// (`docs/format-v4-rfc.md` §2). Empty (count 0) until a compiler milestone
691/// emits struct declarations — see the PR description's scope note.
692#[expect(clippy::cast_possible_truncation)]
693pub fn write_section_struct_shapes(struct_shapes: &[StructShapeDef], buf: &mut Vec<u8>) {
694    write_u32(buf, struct_shapes.len() as u32);
695    for shape in struct_shapes {
696        write_u32(buf, shape.id.0);
697        write_u16(buf, shape.name.0);
698        write_u16(buf, shape.fields.len() as u16);
699        for field in &shape.fields {
700            write_u16(buf, field.0);
701        }
702    }
703}
704
705/// Section-local encoding version for `AliasTable` (`docs/modules-spec.md`
706/// §5) — independent of the `.inkb` format `VERSION`, so the row encoding
707/// can change without another whole-format bump.
708pub(crate) const ALIAS_TABLE_SECTION_VERSION: u8 = 1;
709
710/// Write the M-3 `AliasTable` section (no header framing): a one-byte
711/// section-local version, then a flat list of old→new `DefinitionId` pairs
712/// (`docs/modules-spec.md` §5). Entries are written in the order given —
713/// callers sort by `old` for the runtime's binary-search lookup.
714#[expect(clippy::cast_possible_truncation)]
715pub fn write_section_alias_table(entries: &[AliasEntry], buf: &mut Vec<u8>) {
716    write_u8(buf, ALIAS_TABLE_SECTION_VERSION);
717    write_u32(buf, entries.len() as u32);
718    for entry in entries {
719        write_def_id(buf, entry.old);
720        write_def_id(buf, entry.new);
721    }
722}
723
724/// Section-local encoding version for `FrameShapes` (FS-3,
725/// `docs/flow-suspension-spec.md` §4/§11) — independent of the `.inkb` format
726/// `VERSION`, so the shape encoding can grow (e.g. per-slot type metadata)
727/// without another whole-format bump.
728pub(crate) const FRAME_SHAPES_SECTION_VERSION: u8 = 1;
729
730/// Write the FS-3 `FrameShapes` section (no header framing): a one-byte
731/// section-local version, then one entry per `await` site
732/// (`docs/flow-suspension-spec.md` §4/§11) — the site's stable `DefinitionId`
733/// (the synthesized continuation container id) followed by its name-keyed
734/// crossing-local slots. Entries are written in the order given; callers sort
735/// by `site` for determinism. Callers emit this section only when non-empty.
736#[expect(clippy::cast_possible_truncation)]
737pub fn write_section_frame_shapes(shapes: &[FrameShapeDef], buf: &mut Vec<u8>) {
738    write_u8(buf, FRAME_SHAPES_SECTION_VERSION);
739    write_u32(buf, shapes.len() as u32);
740    for shape in shapes {
741        write_def_id(buf, shape.site);
742        write_u32(buf, shape.slots.len() as u32);
743        for slot in &shape.slots {
744            write_u16(buf, slot.0);
745        }
746    }
747}
748
749/// Section-local encoding version for `DebugInfo` (D6,
750/// `docs/debugger-spec.md` §2.2) — independent of the `.inkb` format
751/// `VERSION`, so the entry encoding can grow (e.g. the reserved `NodeId`
752/// column, §1.3) without another whole-format bump.
753pub(crate) const DEBUG_INFO_SECTION_VERSION: u8 = 2;
754
755/// `DebugLocalEntry` row flags (section version 2). Version 1 wrote a bare
756/// `has_range` 0/1 byte in this position; version 2 keeps that as bit 0 and
757/// adds bit 1 (#3395). Any other bit set is a decode error — a strict
758/// reader, so a future bit graduates through a section version, never by
759/// being silently tolerated.
760pub(crate) const LOCAL_FLAG_HAS_RANGE: u8 = 0b01;
761pub(crate) const LOCAL_FLAG_SYNTHETIC: u8 = 0b10;
762pub(crate) const LOCAL_FLAGS_KNOWN: u8 = LOCAL_FLAG_HAS_RANGE | LOCAL_FLAG_SYNTHETIC;
763
764/// Section-local version of the `LineVariantGroups` encoding (#3273) —
765/// bump to grow the record without a format-wide `VERSION` bump.
766pub(crate) const LINE_VARIANT_GROUPS_SECTION_VERSION: u8 = 1;
767
768/// Write the D6 `DebugInfo` section (no header framing): a one-byte
769/// section-local version, the section-local file table (§2.3), then one
770/// entry table per container in `Containers` order (§2.2). Callers emit
771/// this section only when debug info was requested (`story.debug_info` is
772/// `Some`).
773///
774/// Per-container entries use the varint delta/absolute encoding §2.2
775/// decides (a deliberate, section-scoped departure from this format's
776/// fixed-width house style — see [`write_varint`]'s doc): `bytecode_offset`
777/// is delta-from-previous-entry (always ≥ 0, entries are sorted ascending
778/// by offset), `range_len` is delta-from-`range_start` (the range's
779/// length), everything else in the entry is an absolute varint except the
780/// fixed-width `kind_token: u32` and `flags: u8` (§2.2's table explains why
781/// those two stay fixed-width).
782/// Encode the `LineVariantGroups` section (#3273): version byte, group
783/// count, then per group `{scope_id, base, dim-count, dims}`. Dims are u16
784/// on the wire exactly as in [`LineVariantGroup`] — a group's variant count
785/// is `dims.product()`, capped at recognition time, so no field here needs
786/// to carry more range.
787#[expect(
788    clippy::cast_possible_truncation,
789    reason = "counts capped at recognition"
790)]
791pub fn write_section_line_variant_groups(groups: &[LineVariantGroup], buf: &mut Vec<u8>) {
792    write_u8(buf, LINE_VARIANT_GROUPS_SECTION_VERSION);
793    write_u32(buf, groups.len() as u32);
794    for group in groups {
795        write_def_id(buf, group.scope_id);
796        write_u32(buf, group.base);
797        write_u8(buf, group.dims.len() as u8);
798        for dim in &group.dims {
799            write_u16(buf, *dim);
800        }
801    }
802}
803
804#[expect(clippy::cast_possible_truncation)]
805pub fn write_section_debug_info(section: &DebugInfoSection, buf: &mut Vec<u8>) {
806    write_u8(buf, DEBUG_INFO_SECTION_VERSION);
807
808    write_u32(buf, section.files.len() as u32);
809    for file in &section.files {
810        write_u8(buf, file.surface as u8);
811        write_str(buf, &file.path);
812        // #3261: the staleness detector, and the line index that lets a
813        // reader answer `file:line` without being handed source text.
814        write_u64(buf, file.source_hash);
815        write_varint(buf, file.line_starts.len() as u64);
816        // Delta-encoded: line lengths are small, so each start costs ~1
817        // varint byte rather than 4. `line_starts` is contracted ascending
818        // with a leading 0, so these are exact deltas; `saturating_sub`
819        // keeps a contract-violating caller from wrapping to a huge varint,
820        // matching the entry table's own tolerance above.
821        let mut prev: u32 = 0;
822        for start in &file.line_starts {
823            write_varint(buf, u64::from(start.saturating_sub(prev)));
824            prev = *start;
825        }
826    }
827
828    write_u32(buf, section.containers.len() as u32);
829    for table in &section.containers {
830        write_varint(buf, table.entries.len() as u64);
831        let mut prev_offset: u32 = 0;
832        for entry in &table.entries {
833            // Saturating, not `-`: entries are contracted to arrive sorted
834            // ascending (§2.2), so this is normally an exact delta, but a
835            // caller that violates that contract must not panic (debug) or
836            // silently wrap to a huge varint (release) — clamp to 0 instead,
837            // matching the reader's own `wrapping_add` tolerance on the
838            // decode side (`read_inkb_index`'s counterpart in `read.rs`).
839            write_varint(
840                buf,
841                u64::from(entry.bytecode_offset.saturating_sub(prev_offset)),
842            );
843            prev_offset = entry.bytecode_offset;
844            write_varint(buf, u64::from(entry.file_idx));
845            write_varint(buf, u64::from(entry.range_start));
846            write_varint(buf, u64::from(entry.range_len));
847            write_u32(buf, entry.kind_token);
848            write_u8(buf, entry.flags);
849        }
850
851        write_varint(buf, table.locals.len() as u64);
852        for local in &table.locals {
853            write_u16(buf, local.slot);
854            write_str(buf, &local.name);
855            // Flags byte (section version 2): bit 0 = a declaring range
856            // follows, bit 1 = `synthetic` (#3395). Version 1 wrote a bare
857            // `has_range` 0/1 here — the same bit, so the layout is
858            // unchanged and only the meaning of bit 1 is new.
859            let mut flags = 0u8;
860            if local.declaring_range.is_some() {
861                flags |= LOCAL_FLAG_HAS_RANGE;
862            }
863            if local.synthetic {
864                flags |= LOCAL_FLAG_SYNTHETIC;
865            }
866            write_u8(buf, flags);
867            if let Some((file_idx, range_start, range_len)) = local.declaring_range {
868                write_varint(buf, u64::from(file_idx));
869                write_varint(buf, u64::from(range_start));
870                write_varint(buf, u64::from(range_len));
871            }
872        }
873    }
874}
875
876/// Section-local encoding version for `EffectRows` (T2-3,
877/// `docs/effects-spec.md` §11) — independent of the `.inkb` format `VERSION`,
878/// so the factored-row encoding can change without another whole-format bump
879/// (the reservation this section graduates was made for exactly this).
880///
881/// Bumped 1 → 2 for #882: each row gains a leading `is_entry` byte (the
882/// freeze bit — see [`EffectRowEntry::is_entry`]).
883///
884/// Bumped 2 → 3 for NS-A2 (issue #1108): each `DirectEffects` block gains a
885/// trailing extension-flags byte carrying the emits/tags/faults dimensions
886/// (bits 0–2; bits 3–7 reserved, strict-rejected — per-fault-kind
887/// granularity is the named future occupant, graduating via the next bump).
888pub(crate) const EFFECT_ROWS_SECTION_VERSION: u8 = 3;
889
890/// Write the T2-3 `EffectRows` section (no header framing): a one-byte
891/// section-local version, then the `DefinitionId → row` table of factored
892/// effect rows (`docs/effects-spec.md` §11). One entry per knot/stitch — the
893/// host's resume-scheduling estimate (§12.1). Entries are written in the order
894/// given; callers sort by `def` for determinism.
895#[expect(clippy::cast_possible_truncation)]
896pub fn write_section_effect_rows(rows: &[EffectRowEntry], buf: &mut Vec<u8>) {
897    write_u8(buf, EFFECT_ROWS_SECTION_VERSION);
898    write_u32(buf, rows.len() as u32);
899    for row in rows {
900        write_def_id(buf, row.def);
901        // #882 freeze bit: whether this row is a legitimate host entry point
902        // (see `EffectRowEntry::is_entry`'s doc — `false` only for
903        // `#@private` defs, and the row still ships either way).
904        write_u8(buf, u8::from(row.is_entry));
905        encode_direct_effects(&row.direct, buf);
906        // Per-dispatch entries (v1 emits none, but the encoding ships the
907        // structure — a flat row forecloses §7 narrowing).
908        write_u32(buf, row.dispatches.len() as u32);
909        for d in &row.dispatches {
910            write_def_id(buf, d.cell);
911            write_u8(buf, u8::from(d.narrowable));
912            encode_direct_effects(&d.fallback, buf);
913        }
914    }
915}
916
917/// Encode a [`DirectEffects`] block: reads, writes, call atoms, opaque flag.
918#[expect(clippy::cast_possible_truncation)]
919fn encode_direct_effects(direct: &DirectEffects, buf: &mut Vec<u8>) {
920    write_u32(buf, direct.reads.len() as u32);
921    for id in &direct.reads {
922        write_def_id(buf, *id);
923    }
924    write_u32(buf, direct.writes.len() as u32);
925    for id in &direct.writes {
926        write_def_id(buf, *id);
927    }
928    write_u32(buf, direct.calls.len() as u32);
929    for atom in &direct.calls {
930        encode_call_atom(atom, buf);
931    }
932    write_u8(buf, u8::from(direct.opaque));
933    // NS-A2 extension-flags byte (section version 3): emits/tags/faults.
934    let mut dims = 0u8;
935    if direct.emits {
936        dims |= super::EFFECT_DIM_EMITS;
937    }
938    if direct.tags {
939        dims |= super::EFFECT_DIM_TAGS;
940    }
941    if direct.faults {
942        dims |= super::EFFECT_DIM_FAULTS;
943    }
944    write_u8(buf, dims);
945}
946
947/// Encode a single [`CallAtom`]: interned name, the capability-parameter slot
948/// (`(any)` in v1), then the reserved handle-parameter slot (`None` in v1 —
949/// `docs/t1d-spec.md` §7). A bound handle is never emitted in this section
950/// version.
951fn encode_call_atom(atom: &CallAtom, buf: &mut Vec<u8>) {
952    write_u16(buf, atom.name.0);
953    let cap_tag = match atom.capability {
954        CapabilityParam::Any => CAP_PARAM_ANY,
955    };
956    write_u8(buf, cap_tag);
957    // Reserved handle-parameter slot: v1 is always `None`. A `Some` is
958    // structurally representable but never encoded in this section version.
959    write_u8(buf, atom.handle_param.unwrap_or(HANDLE_PARAM_NONE));
960}
961
962fn encode_external(ext: &ExternalFnDef, buf: &mut Vec<u8>) {
963    write_def_id(buf, ext.id);
964    write_u16(buf, ext.name.0);
965    write_u8(buf, ext.arg_count);
966    match ext.fallback {
967        Some(fb) => {
968            write_u8(buf, 1);
969            write_def_id(buf, fb);
970        }
971        None => {
972            write_u8(buf, 0);
973        }
974    }
975}
976
977#[expect(clippy::cast_possible_truncation)]
978fn encode_container(c: &ContainerDef, buf: &mut Vec<u8>) {
979    write_def_id(buf, c.id);
980    write_def_id(buf, c.scope_id);
981    match c.name {
982        Some(name_id) => {
983            write_u8(buf, 1);
984            write_u16(buf, name_id.0);
985        }
986        None => {
987            write_u8(buf, 0);
988        }
989    }
990    write_u8(buf, c.counting_flags.bits());
991    write_i32(buf, c.path_hash);
992    write_u8(buf, c.param_count);
993    write_u8(buf, u8::from(c.local));
994    // Per-param name/mode metadata (T1c, `docs/t1c-spec.md` §6). Additive
995    // trailing field: a `0` count for the common no-param container.
996    write_u16(buf, c.params.len() as u16);
997    for p in &c.params {
998        write_u16(buf, p.name.0);
999        write_u8(buf, u8::from(p.is_ref));
1000        write_u16(buf, p.slot);
1001    }
1002    write_u32(buf, c.bytecode.len() as u32);
1003    buf.extend_from_slice(&c.bytecode);
1004}
1005
1006/// Write the line tables section (no header framing).
1007#[expect(clippy::cast_possible_truncation)]
1008pub fn write_section_line_tables(line_tables: &[ScopeLineTable], buf: &mut Vec<u8>) {
1009    write_u32(buf, line_tables.len() as u32);
1010    for lt in line_tables {
1011        encode_scope_line_table(lt, buf);
1012    }
1013}
1014
1015#[expect(clippy::cast_possible_truncation)]
1016fn encode_scope_line_table(lt: &ScopeLineTable, buf: &mut Vec<u8>) {
1017    write_def_id(buf, lt.scope_id);
1018    write_u32(buf, lt.lines.len() as u32);
1019    for entry in &lt.lines {
1020        encode_line_entry(entry, buf);
1021    }
1022}
1023
1024fn encode_line_entry(entry: &LineEntry, buf: &mut Vec<u8>) {
1025    encode_line_content(&entry.content, buf);
1026    write_u64(buf, entry.source_hash);
1027    match &entry.audio_ref {
1028        Some(audio) => {
1029            write_u8(buf, 1);
1030            write_str(buf, audio);
1031        }
1032        None => {
1033            write_u8(buf, 0);
1034        }
1035    }
1036
1037    // Slot info
1038    #[expect(clippy::cast_possible_truncation)]
1039    write_u8(buf, entry.slot_info.len() as u8);
1040    for slot in &entry.slot_info {
1041        write_u8(buf, slot.index);
1042        write_str(buf, &slot.name);
1043    }
1044
1045    // Source location
1046    match &entry.source_location {
1047        Some(loc) => {
1048            write_u8(buf, 1);
1049            write_str(buf, &loc.file);
1050            write_u32(buf, loc.range_start);
1051            write_u32(buf, loc.range_end);
1052        }
1053        None => {
1054            write_u8(buf, 0);
1055        }
1056    }
1057}
1058
1059#[expect(clippy::cast_possible_truncation)]
1060pub(crate) fn encode_line_content(content: &LineContent, buf: &mut Vec<u8>) {
1061    match content {
1062        LineContent::Plain(s) => {
1063            write_u8(buf, LINE_PLAIN);
1064            write_str(buf, s);
1065        }
1066        LineContent::Template(parts) => {
1067            write_u8(buf, LINE_TEMPLATE);
1068            write_u32(buf, parts.len() as u32);
1069            for part in parts {
1070                encode_line_part(part, buf);
1071            }
1072        }
1073    }
1074}
1075
1076#[expect(clippy::cast_possible_truncation)]
1077fn encode_line_part(part: &LinePart, buf: &mut Vec<u8>) {
1078    match part {
1079        LinePart::Literal(s) => {
1080            write_u8(buf, PART_LITERAL);
1081            write_str(buf, s);
1082        }
1083        LinePart::Slot(idx) => {
1084            write_u8(buf, PART_SLOT);
1085            write_u8(buf, *idx);
1086        }
1087        LinePart::Select {
1088            slot,
1089            variants,
1090            default,
1091        } => {
1092            write_u8(buf, PART_SELECT);
1093            write_u8(buf, *slot);
1094            write_u32(buf, variants.len() as u32);
1095            for (key, text) in variants {
1096                encode_select_key(key, buf);
1097                write_str(buf, text);
1098            }
1099            write_str(buf, default);
1100        }
1101        LinePart::Span {
1102            name,
1103            attrs,
1104            children,
1105        } => {
1106            write_u8(buf, PART_SPAN);
1107            write_str(buf, name);
1108            write_u32(buf, attrs.len() as u32);
1109            for (k, v) in attrs {
1110                write_str(buf, k);
1111                write_str(buf, v);
1112            }
1113            write_u32(buf, children.len() as u32);
1114            for child in children {
1115                encode_line_part(child, buf);
1116            }
1117        }
1118    }
1119}
1120
1121fn encode_select_key(key: &SelectKey, buf: &mut Vec<u8>) {
1122    match key {
1123        SelectKey::Cardinal(cat) => {
1124            write_u8(buf, KEY_CARDINAL);
1125            encode_plural_category(*cat, buf);
1126        }
1127        SelectKey::Ordinal(cat) => {
1128            write_u8(buf, KEY_ORDINAL);
1129            encode_plural_category(*cat, buf);
1130        }
1131        SelectKey::Exact(n) => {
1132            write_u8(buf, KEY_EXACT);
1133            write_i32(buf, *n);
1134        }
1135        SelectKey::Keyword(k) => {
1136            write_u8(buf, KEY_KEYWORD);
1137            write_str(buf, k);
1138        }
1139    }
1140}
1141
1142fn encode_plural_category(cat: PluralCategory, buf: &mut Vec<u8>) {
1143    let tag = match cat {
1144        PluralCategory::Zero => CAT_ZERO,
1145        PluralCategory::One => CAT_ONE,
1146        PluralCategory::Two => CAT_TWO,
1147        PluralCategory::Few => CAT_FEW,
1148        PluralCategory::Many => CAT_MANY,
1149        PluralCategory::Other => CAT_OTHER,
1150    };
1151    write_u8(buf, tag);
1152}