Skip to main content

brink_codegen_inkb/
lib.rs

1//! Bytecode backend: LIR → `StoryData`.
2
3mod chunk;
4mod container;
5mod content;
6mod debug_info;
7mod expr;
8
9pub use chunk::{ContainerChunk, NameRef, Relocation, UNRESOLVED_NAME_ID};
10pub use debug_info::EmitOptions;
11
12use std::collections::HashMap;
13
14use brink_format::{
15    AddressDef, AddressPath, ContainerDef, DefinitionId, ExternalFnDef, GlobalVarDef, LineContent,
16    LineEntry, ListDef, ListItemDef, ListValue, MapKey, NameId, Opcode, OrderedMap, ScopeLineTable,
17    ShapeId, StoryData, StructShapeDef, Value,
18};
19use brink_ir::lir;
20
21/// A defect in the LIR fed to codegen — an invariant that a well-formed
22/// `Program` is guaranteed to satisfy by earlier, non-suppressible compiler
23/// stages, which codegen has no independent way to verify structurally
24/// beyond this checkpoint. See #586: with #577's `Nop` degradation removed,
25/// `container.rs`'s `LogicBreak`/`LogicContinue` handling had zero
26/// codegen-level guard against a `loop_stack` that's empty — a future or
27/// refactored LIR producer that ever emitted one outside a loop would
28/// silently corrupt bytecode via an unpatched `Jump(0)` that looks
29/// well-formed, rather than fail. This is the hard error that replaces
30/// that silent corruption; today it can only fire on hand-assembled LIR
31/// that bypasses `brink-ir::lir::lower` (which rejects this case at E057,
32/// non-suppressibly, before a `Program` is ever produced).
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct CodegenError {
35    message: String,
36}
37
38impl CodegenError {
39    fn new(message: impl Into<String>) -> Self {
40        Self {
41            message: message.into(),
42        }
43    }
44}
45
46impl std::fmt::Display for CodegenError {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        f.write_str(&self.message)
49    }
50}
51
52impl std::error::Error for CodegenError {}
53
54/// Apply [`collapse_whitespace`] to a template part's literal text, recursing
55/// into `Span { children }` so nested literals get the same treatment as
56/// top-level ones (`<b>a  b</b>` must collapse the same as `a  b`).
57/// `Slot`/`Select` carry no literal text of their own and pass through
58/// unchanged.
59fn collapse_whitespace_in_part(part: brink_format::LinePart) -> brink_format::LinePart {
60    match part {
61        brink_format::LinePart::Literal(s) => {
62            brink_format::LinePart::Literal(collapse_whitespace(&s))
63        }
64        brink_format::LinePart::Span {
65            name,
66            attrs,
67            children,
68        } => brink_format::LinePart::Span {
69            name,
70            attrs,
71            children: children
72                .into_iter()
73                .map(collapse_whitespace_in_part)
74                .collect(),
75        },
76        other @ (brink_format::LinePart::Slot(_) | brink_format::LinePart::Select { .. }) => other,
77    }
78}
79
80/// Collapse runs of consecutive spaces/tabs within `s` to a single space.
81fn collapse_whitespace(s: &str) -> String {
82    let mut out = String::with_capacity(s.len());
83    let mut prev_ws = false;
84    for c in s.chars() {
85        if c == ' ' || c == '\t' {
86            if !prev_ws {
87                out.push(' ');
88            }
89            prev_ws = true;
90        } else {
91            prev_ws = false;
92            out.push(c);
93        }
94    }
95    out
96}
97
98/// Compile a resolved LIR `Program` into `StoryData` for the runtime.
99///
100/// Returns `Err(CodegenError)` only for a defect in the LIR itself — see
101/// [`CodegenError`]. A well-formed `Program` (the only kind
102/// `brink-ir::lir::lower` ever hands back) always succeeds.
103///
104/// Equivalent to [`emit_with_options`] with [`EmitOptions::default()`] —
105/// `emit_debug_info: false`, reproducing every byte this function has ever
106/// emitted (the D6 byte-identical guarantee, `docs/debugger-spec.md` §1.2).
107pub fn emit(program: &lir::Program) -> Result<StoryData, CodegenError> {
108    emit_with_options(program, EmitOptions::default())
109}
110
111/// [`emit`] with explicit [`EmitOptions`] — the D6 (`docs/debugger-spec.md`
112/// §2) entry point. `options.emit_debug_info` gates the `DebugInfo` section
113/// (`SectionKind::DebugInfo`, tag `0x11`): `false` takes the exact same code
114/// path `emit` always has, byte-for-byte; `true` additionally records
115/// `(bytecode_offset, source_range)` pairs during the same container walk
116/// and attaches them as `StoryData::debug_info`.
117pub fn emit_with_options(
118    program: &lir::Program,
119    options: EmitOptions<'_>,
120) -> Result<StoryData, CodegenError> {
121    let mut state = EmitState {
122        chunks: Vec::new(),
123        addresses: Vec::new(),
124        definition_id_first_seen: HashMap::new(),
125        address_paths: Vec::new(),
126        scope_line_tables: HashMap::new(),
127        scope_line_index: HashMap::new(),
128        line_variant_groups: Vec::new(),
129        list_literals: Vec::new(),
130        literal_pool: Vec::new(),
131        name_table: program.name_table.clone(),
132        name_index: HashMap::new(),
133        errors: Vec::new(),
134        debug: options
135            .emit_debug_info
136            .then(debug_info::DebugCollector::new),
137    };
138
139    // Build the name index from the existing name table for dedup.
140    for (i, name) in state.name_table.iter().enumerate() {
141        #[expect(clippy::cast_possible_truncation)]
142        state.name_index.insert(name.clone(), NameId(i as u16));
143    }
144
145    // Walk the container tree depth-first.
146    // Root is always a scope — its scope_id is its own id; its author scope
147    // path is empty.
148    walk_container(&program.root, "", "", program.root.id, &mut state);
149
150    // D6 (`docs/debugger-spec.md` §2, issue #3184): finished here, before
151    // the error check below, not folded into the final `Ok(StoryData {..})`
152    // the way it originally was — `DebugCollector::finish` can itself push
153    // a `CodegenError` (an interned `FileId` unresolvable in
154    // `program.file_paths`, #3219 review), and `state.errors` is only ever
155    // inspected at the single early-return point right after this. Folding
156    // it in later would let that error go unchecked, since nothing after
157    // this point looks at `state.errors` again.
158    let debug_info = state
159        .debug
160        .take()
161        .map(|d| d.finish(program, options.debug_sources, &mut state.errors));
162
163    if let Some(first) = core::mem::take(&mut state.errors).into_iter().next() {
164        return Err(first);
165    }
166
167    // Build globals, lists, externals.
168    let variables = build_globals(&program.globals, &mut state);
169    let list_defs = build_list_defs(&program.lists);
170    let list_items = build_list_items(&program.list_items);
171    let externals = build_externals(&program.externals);
172    let struct_shapes = build_struct_shapes(&program.struct_shapes);
173
174    // Convert scope line tables to a sorted Vec<ScopeLineTable>.
175    // #3273: deterministic group order, mirroring the line-table sort.
176    let mut line_variant_groups = state.line_variant_groups;
177    line_variant_groups.sort_by_key(|g| (g.scope_id.to_raw(), g.base));
178
179    let mut line_tables: Vec<ScopeLineTable> = state
180        .scope_line_tables
181        .into_iter()
182        .map(|(scope_id, lines)| ScopeLineTable { scope_id, lines })
183        .collect();
184    line_tables.sort_by_key(|lt| lt.scope_id.to_raw());
185
186    // Link phase (FG-4b): resolve each chunk's symbolic name-reference
187    // relocations against the now-fully-assembled name table and patch the
188    // placeholder operands in place. The name index is complete — every
189    // `PushString` symbol was interned as its relocation was recorded (see
190    // `ContainerEmitter::emit_push_string`) — so a miss is a real defect,
191    // surfaced by `ContainerChunk::link` rather than silently dropped.
192    let name_index = &state.name_index;
193    let containers = state
194        .chunks
195        .into_iter()
196        .map(|c| c.link(|s| name_index.get(s).copied()))
197        .collect::<Result<Vec<_>, _>>()?;
198
199    Ok(StoryData {
200        containers,
201        line_tables,
202        variables,
203        list_defs,
204        list_items,
205        externals,
206        addresses: state.addresses,
207        address_paths: state.address_paths,
208        name_table: state.name_table,
209        list_literals: state.list_literals,
210        literal_pool: state.literal_pool,
211        struct_shapes,
212        // M-2b: the compiler-computed private-definition set rides straight
213        // through — the LIR already sorted it deterministically.
214        private_defs: program.private_defs.clone(),
215        alias_table: program.aliases.clone(),
216        // T2-3 `EffectRows`: codegen has no analyzer access, so it emits an
217        // empty table here. The `story_data` db query populates the real rows
218        // from `effects_query` after this `emit` (the one canonical codegen
219        // site) — see `docs/effects-spec.md` §11.
220        effect_rows: Vec::new(),
221        // FS-3 `FrameShapes` (`docs/flow-suspension-spec.md` §4/§11): the E052
222        // `await` lowering fence stands, so no `await` reaches codegen and no
223        // frame shapes are synthesized. Emitted empty; first population rides
224        // the continuation-splitting codegen when the fence drops (FS-3r).
225        frame_shapes: Vec::new(),
226        // D6 `DebugInfo` (`docs/debugger-spec.md` §2, tag 0x11): `None`
227        // unless `options.emit_debug_info` was set — the section is
228        // omitted entirely from `.inkb` in that case, so a release compile
229        // stays byte-identical (§1.2 ship policy). Computed above, before
230        // the error check — see that comment for why.
231        debug_info,
232        line_variant_groups,
233        source_checksum: 0,
234    })
235}
236
237/// TM-4c: `lir::StructShapeDef` → `brink_format::StructShapeDef`, id order
238/// preserved (`lir::lower::structs::struct_shape_defs` already hands back a
239/// `Vec` ordered by `ShapeId`, so this is a 1:1 field mapping, not a sort).
240fn build_struct_shapes(shapes: &[lir::StructShapeDef]) -> Vec<StructShapeDef> {
241    shapes
242        .iter()
243        .map(|s| StructShapeDef {
244            id: ShapeId(s.id),
245            name: s.name,
246            fields: s.fields.clone(),
247        })
248        .collect()
249}
250
251// ─── Emission state ─────────────────────────────────────────────────
252
253struct EmitState {
254    /// Per-container codegen chunks (FG-4b): each holds a `ContainerDef`
255    /// whose bytecode carries [`UNRESOLVED_NAME_ID`] placeholders at every
256    /// name-reference site, plus the symbolic relocation table the link
257    /// phase in [`emit`] resolves. Pushed in container-walk order.
258    chunks: Vec<ContainerChunk>,
259    addresses: Vec<AddressDef>,
260    /// #1673 codegen-boundary uniqueness guard: every emitted container's
261    /// `DefinitionId` mapped to the (inklecate-style) path it was first
262    /// seen at. A well-formed `Program` assigns every container a distinct
263    /// id; nothing upstream of codegen independently re-verified that
264    /// before this guard existed, and the #1504 collision reached the
265    /// runtime silently — the linker's address map is last-write-wins, so
266    /// a duplicate id made a player-picked choice run the *other*
267    /// container's body. See [`walk_container`]'s check against this map.
268    definition_id_first_seen: HashMap<DefinitionId, String>,
269    /// Qualified-path → target table (scope containers + author labels).
270    address_paths: Vec<AddressPath>,
271    /// Scope-shared line tables: `scope_id` → accumulated line entries.
272    scope_line_tables: HashMap<DefinitionId, Vec<LineEntry>>,
273    /// Per-scope dedup index over [`scope_line_tables`](Self::scope_line_tables):
274    /// the entry already holding a given `(content, slot_info)`, so a line
275    /// authored twice in one scope is one translation unit
276    /// (`docs/intl-spec.md` §"Line-table deduplication"). Lookup only —
277    /// never iterated, so the hash order cannot leak into the artifact.
278    scope_line_index: HashMap<DefinitionId, HashMap<LineKey, u16>>,
279    /// #3273: variant-group records accumulated as `EmitLineVariants`
280    /// statements register their line-table runs. Sorted before assembly
281    /// for deterministic output.
282    line_variant_groups: Vec<brink_format::LineVariantGroup>,
283    list_literals: Vec<ListValue>,
284    /// The T1b `LiteralPool` (`docs/format-v4-rfc.md` §2), built up as
285    /// `PushLiteral` sites are emitted. Content-hash-dedup isn't needed for
286    /// correctness (structural equality dedup below is exact); a linear
287    /// scan is fine at game-corpus literal-pool sizes.
288    literal_pool: Vec<Value>,
289    name_table: Vec<String>,
290    name_index: HashMap<String, NameId>,
291    /// Codegen-level defects found during the tree walk (see
292    /// [`CodegenError`]) — accumulated the same way `brink-ir`'s LIR
293    /// lowering accumulates diagnostics (`ctx.diagnostics.push`), checked
294    /// once after the whole walk finishes rather than threading a
295    /// `Result` through every recursive emitter call. Bounded by the size
296    /// of the `Program` being walked, same as every other `Vec` here.
297    errors: Vec<CodegenError>,
298    /// D6 (`docs/debugger-spec.md` §2, issue #3184) debug-info recording —
299    /// `None` unless `EmitOptions::emit_debug_info` was set. Kept as an
300    /// `Option` rather than an always-present, conditionally-populated
301    /// collector so the container walk pays nothing (no branch, no
302    /// allocation) on the default `emit()` path — the byte-identical
303    /// guarantee this section's whole design depends on.
304    debug: Option<debug_info::DebugCollector>,
305}
306
307/// Intern a string into a story name table, deduping against entries already
308/// present. Shared by both codegen phases: the container walk
309/// ([`ContainerEmitter::intern_string`]) and the post-walk build phase
310/// ([`const_to_value`]), which hold the name table/index by different paths
311/// but need identical dedup semantics.
312fn intern_into(
313    name_table: &mut Vec<String>,
314    name_index: &mut HashMap<String, NameId>,
315    s: &str,
316) -> NameId {
317    if let Some(&id) = name_index.get(s) {
318        return id;
319    }
320    #[expect(clippy::cast_possible_truncation)]
321    let id = NameId(name_table.len() as u16);
322    name_table.push(s.to_string());
323    name_index.insert(s.to_string(), id);
324    id
325}
326
327// ─── Container emitter ──────────────────────────────────────────────
328
329/// What makes two line-table entries the same translation unit: the
330/// (whitespace-collapsed) content and the slot names a translator sees.
331/// `source_hash` is deliberately not part of it — a merged entry keeps its
332/// first occurrence's hash and location — and neither is anything the
333/// runtime reads, since the runtime treats equal content identically.
334#[derive(Debug, Clone, PartialEq, Eq, Hash)]
335struct LineKey {
336    content: LineContent,
337    slot_info: Vec<brink_format::SlotInfo>,
338}
339
340struct ContainerEmitter<'a> {
341    bytecode: Vec<u8>,
342    scope_line_table: &'a mut Vec<LineEntry>,
343    /// See [`EmitState::scope_line_index`].
344    scope_line_index: &'a mut HashMap<LineKey, u16>,
345    /// While a variant run (#3273) is being laid out, entries must stay
346    /// consecutive and one-per-leaf — `base + combo` is how the runtime
347    /// finds them — so dedup is off, and the run's entries are never
348    /// registered as dedup targets either.
349    dedup_suspended: bool,
350    /// The scope whose line table this emitter appends to — the
351    /// `scope_id` a variant-group record (#3273) is keyed by.
352    scope_id: DefinitionId,
353    /// #3273: shared with [`EmitState::line_variant_groups`].
354    line_variant_groups: &'a mut Vec<brink_format::LineVariantGroup>,
355    list_literals: &'a mut Vec<ListValue>,
356    literal_pool: &'a mut Vec<Value>,
357    state_name_table: &'a mut Vec<String>,
358    state_name_index: &'a mut HashMap<String, NameId>,
359    in_conditional_branch: bool,
360    /// Issue #3508: set while a choice's DISPLAY text is being emitted. Line
361    /// entries added then keep their whitespace runs verbatim — ink presents
362    /// a choice's text as the evaluated string trimmed at the ends only
363    /// (`a  0` stays `a  0`), while an output line is collapsed on render
364    /// (`CleanOutputWhitespace`). brink collapses at compile time instead
365    /// ([`collapse_whitespace`] in [`Self::add_line_with_hash`]), which is
366    /// observably the same for output lines and was wrong for choice text.
367    in_choice_display: bool,
368    /// Stack of open T1b `LogicWhile` loops (innermost last) — targets for
369    /// `break`/`continue` jump patching. Empty outside any loop.
370    loop_stack: Vec<LoopCtx>,
371    /// Shared with every other `ContainerEmitter` created during the same
372    /// `emit()` call (see `EmitState::errors`).
373    errors: &'a mut Vec<CodegenError>,
374    /// FG-4b symbolic name-reference patch sites into this container's
375    /// `bytecode`. Populated by [`Self::emit_push_string`]; drained into the
376    /// container's [`ContainerChunk`] by `walk_container`.
377    relocations: Vec<Relocation>,
378    /// D6 (`docs/debugger-spec.md` §2.2, issue #3184 review): `Some` for the
379    /// whole life of this emitter exactly when `state.debug` is `Some` —
380    /// `walk_container` seeds it (with the params-prologue entry, if any)
381    /// right after construction and takes it back out when this container's
382    /// bytecode is done. Recording happens at a single point,
383    /// [`Self::record_debug_entry`], called from every body-statement walk —
384    /// top-level *and* nested (`Conditional`/`Sequence`/`LogicWhile` branch
385    /// bodies) — so a statement inside a branch gets an entry the same way a
386    /// top-level one does (#3219 review: nested statements previously got
387    /// none). `None` on the default `emit()` path costs nothing beyond the
388    /// tag check itself.
389    debug_entries: Option<Vec<debug_info::RawDebugEntry>>,
390}
391
392/// Jump-patch bookkeeping for one open `LogicWhile` (innermost = top of
393/// `ContainerEmitter::loop_stack`).
394struct LoopCtx {
395    /// `break` sites — patched to land just after the whole loop.
396    break_patches: Vec<usize>,
397    /// `continue` sites — patched to land at the start of `post` (the
398    /// backward jump to `condition` for a plain `while`, since `post` is
399    /// empty then).
400    continue_patches: Vec<usize>,
401}
402
403impl<'a> ContainerEmitter<'a> {
404    fn new(state: &'a mut EmitState, scope_id: DefinitionId) -> Self {
405        let scope_line_table = state.scope_line_tables.entry(scope_id).or_default();
406        let scope_line_index = state.scope_line_index.entry(scope_id).or_default();
407        Self {
408            bytecode: Vec::new(),
409            scope_line_table,
410            scope_line_index,
411            dedup_suspended: false,
412            scope_id,
413            line_variant_groups: &mut state.line_variant_groups,
414            list_literals: &mut state.list_literals,
415            literal_pool: &mut state.literal_pool,
416            state_name_table: &mut state.name_table,
417            state_name_index: &mut state.name_index,
418            in_conditional_branch: false,
419            in_choice_display: false,
420            loop_stack: Vec::new(),
421            errors: &mut state.errors,
422            relocations: Vec::new(),
423            debug_entries: None,
424        }
425    }
426
427    /// Emit a `PushString` whose `NameId` operand is left *symbolic*
428    /// (FG-4b): the string is interned into the story name table now — so
429    /// the table's contents and ordering are byte-for-byte identical to
430    /// eager resolution — but the bytecode carries an [`UNRESOLVED_NAME_ID`]
431    /// placeholder and a [`Relocation`] recording the symbolic reference.
432    /// The link phase in [`emit`] resolves the symbol against the assembled
433    /// name table and patches the operand (see [`chunk`]).
434    fn emit_push_string(&mut self, text: &str) {
435        // Intern eagerly to fix this string's position in the name table:
436        // the link phase looks the same string up, so the patched operand
437        // equals what pre-chunk codegen wrote inline. The assigned id is
438        // deliberately not baked into the chunk — the chunk stays symbolic.
439        self.intern_string(text);
440        self.emit(Opcode::PushString(UNRESOLVED_NAME_ID));
441        #[expect(clippy::cast_possible_truncation)]
442        let offset = (self.bytecode.len() - 2) as u32;
443        self.relocations.push(Relocation {
444            offset,
445            name: NameRef::Symbol(text.to_string()),
446        });
447    }
448
449    #[expect(clippy::needless_pass_by_value)]
450    fn emit(&mut self, op: Opcode) {
451        op.encode(&mut self.bytecode);
452    }
453
454    /// `source_location` is an explicit, required parameter — not a
455    /// convenience default of `None` — precisely because that default was
456    /// the issue #3181 bug: every caller must say what it knows (a real
457    /// location threaded from `hir::Content::ptr`, or `None` with a reason
458    /// at the call site) rather than one caller's ignorance silently
459    /// becoming every caller's answer.
460    fn add_line(
461        &mut self,
462        text: &str,
463        source_location: Option<brink_format::SourceLocation>,
464    ) -> u16 {
465        self.add_line_with_hash(
466            text,
467            brink_format::content_hash(text),
468            Vec::new(),
469            source_location,
470        )
471    }
472
473    fn add_line_with_hash(
474        &mut self,
475        text: &str,
476        source_hash: u64,
477        slot_info: Vec<brink_format::SlotInfo>,
478        source_location: Option<brink_format::SourceLocation>,
479    ) -> u16 {
480        let text = if self.in_choice_display {
481            text.to_owned()
482        } else {
483            collapse_whitespace(text)
484        };
485        self.push_line(
486            LineContent::Plain(text),
487            source_hash,
488            slot_info,
489            source_location,
490        )
491    }
492
493    fn add_template_line(
494        &mut self,
495        parts: brink_format::LineTemplate,
496        source_hash: u64,
497        slot_info: Vec<brink_format::SlotInfo>,
498        source_location: Option<brink_format::SourceLocation>,
499    ) -> u16 {
500        let parts = if self.in_choice_display {
501            parts
502        } else {
503            parts.into_iter().map(collapse_whitespace_in_part).collect()
504        };
505        self.push_line(
506            LineContent::Template(parts),
507            source_hash,
508            slot_info,
509            source_location,
510        )
511    }
512
513    /// Append a line-table entry — or, when this scope already holds an
514    /// entry with the same [`LineKey`], return that entry's index instead
515    /// (`docs/intl-spec.md` §"Line-table deduplication"). The first
516    /// occurrence supplies the entry's `source_hash` and `source_location`.
517    #[expect(clippy::cast_possible_truncation)]
518    fn push_line(
519        &mut self,
520        content: LineContent,
521        source_hash: u64,
522        slot_info: Vec<brink_format::SlotInfo>,
523        source_location: Option<brink_format::SourceLocation>,
524    ) -> u16 {
525        let key = LineKey { content, slot_info };
526        if !self.dedup_suspended
527            && let Some(&idx) = self.scope_line_index.get(&key)
528        {
529            return idx;
530        }
531        let idx = self.scope_line_table.len() as u16;
532        let flags = brink_format::LineFlags::from_content(&key.content);
533        self.scope_line_table.push(LineEntry {
534            content: key.content.clone(),
535            flags,
536            source_hash,
537            audio_ref: None,
538            slot_info: key.slot_info.clone(),
539            source_location,
540        });
541        if !self.dedup_suspended {
542            self.scope_line_index.insert(key, idx);
543        }
544        idx
545    }
546
547    fn intern_string(&mut self, s: &str) -> NameId {
548        if let Some(&id) = self.state_name_index.get(s) {
549            return id;
550        }
551        #[expect(clippy::cast_possible_truncation)]
552        let id = NameId(self.state_name_table.len() as u16);
553        self.state_name_table.push(s.to_string());
554        self.state_name_index.insert(s.to_string(), id);
555        id
556    }
557
558    /// Emit a jump-like instruction with a placeholder offset.
559    /// Returns the byte position of the i32 offset field for later patching.
560    #[expect(clippy::needless_pass_by_value)]
561    fn emit_jump_placeholder(&mut self, op: Opcode) -> usize {
562        op.encode(&mut self.bytecode);
563        // The i32 offset occupies the last 4 bytes of the encoded instruction.
564        self.bytecode.len() - 4
565    }
566
567    /// Patch a previously emitted jump offset to point to the current position.
568    /// The offset is relative: bytes from end of the jump instruction to current pos.
569    fn patch_jump(&mut self, offset_pos: usize) {
570        let target = self.bytecode.len();
571        // The jump instruction ends right after the i32 field (offset_pos + 4).
572        let instruction_end = offset_pos + 4;
573        #[expect(clippy::cast_possible_wrap)]
574        #[expect(clippy::cast_possible_truncation)]
575        let relative = (target - instruction_end) as i32;
576        let bytes = relative.to_le_bytes();
577        self.bytecode[offset_pos..offset_pos + 4].copy_from_slice(&bytes);
578    }
579
580    /// D6 (`docs/debugger-spec.md` §2.2, issue #3184 review — nested
581    /// statements previously got no debug entries): record one
582    /// [`debug_info::RawDebugEntry`] for `stmt` at this container's current
583    /// bytecode length, a no-op when `self.debug_entries` is `None` (the
584    /// default `emit()` path). Called from [`Self::emit_body`] for *every*
585    /// statement it walks — top-level and nested (`Conditional`/`Sequence`/
586    /// `LogicWhile` branch bodies all route back through `emit_body`) — so
587    /// entries come out already sorted ascending by construction: they are
588    /// pushed in emission order, and bytecode length only grows.
589    /// `prologue_end` is always `false` from this call site; only
590    /// `walk_container`'s dedicated top-level pass (`emit_body_top_level`)
591    /// ever sets it `true`, since the prologue-end marker (§2.4) is a
592    /// per-container concept, not a per-branch one.
593    fn record_debug_entry(&mut self, stmt: &lir::Stmt, prologue_end: bool) {
594        if self.debug_entries.is_none() {
595            return;
596        }
597        #[expect(clippy::cast_possible_truncation)]
598        let offset = self.bytecode.len() as u32;
599        if let Some(entries) = self.debug_entries.as_mut() {
600            entries.push(debug_info::RawDebugEntry {
601                offset,
602                provenance: stmt.provenance,
603                prologue_end,
604            });
605        }
606    }
607}
608
609// ─── Container tree walk ────────────────────────────────────────────
610
611/// Returns `true` if the container kind is a lexical scope (root, knot, stitch).
612fn is_scope_kind(kind: lir::ContainerKind) -> bool {
613    matches!(
614        kind,
615        lir::ContainerKind::Root | lir::ContainerKind::Knot | lir::ContainerKind::Stitch
616    )
617}
618
619#[expect(
620    clippy::too_many_lines,
621    reason = "single linear emit sequence; splitting would obscure the order"
622)]
623fn walk_container(
624    container: &lir::Container,
625    path: &str,
626    scope_author_path: &str,
627    scope_id: DefinitionId,
628    state: &mut EmitState,
629) {
630    // #1673 codegen-boundary uniqueness guard: two containers must never
631    // share a `DefinitionId`. This should be structurally impossible — every
632    // id is a content-pure hash minted once per container during LIR
633    // lowering — but the #1504 collision proved it *can* happen (unqualified
634    // anonymous scope paths across files) and, when it does, the failure is
635    // silent all the way to the player: the linker's address map (and this
636    // walk's own `state.chunks`/`state.addresses`) is last-write-wins, so
637    // the second container to reach this point quietly overwrites the
638    // first's entry instead of erroring. Checked once per container walked,
639    // O(1) amortized against the `HashMap` insert every other per-container
640    // table here already pays — cheap by design (see #1673).
641    if let Some(prior_path) = state
642        .definition_id_first_seen
643        .insert(container.id, path.to_string())
644    {
645        state.errors.push(CodegenError::new(format!(
646            "duplicate DefinitionId {} assigned to two different containers, at paths {prior_path:?} and {path:?} — every container must have a unique DefinitionId (#1673); this collision would otherwise reach the runtime silently and produce wrong player-visible output, as it did in #1504",
647            container.id
648        )));
649    }
650
651    // The author-facing path of the nearest enclosing scope. For a scope
652    // container this is its own `path` (scope paths never get inklecate's
653    // implicit `0.` stitch prefix); non-scope containers inherit it. Used to
654    // qualify author labels (matching the analyzer's `qualify_label`),
655    // independent of the inklecate `path` used for `path_hash`.
656    let this_scope_path = if is_scope_kind(container.kind) {
657        path
658    } else {
659        scope_author_path
660    };
661
662    // D6 (`docs/debugger-spec.md` §2.2/§2.4): read before `state` is
663    // (re)borrowed by `ContainerEmitter::new` below — `emitter` holds a
664    // mutable borrow of `*state` for its whole lifetime, so `state.debug`
665    // must be read here, not after. `raw_entries` stays empty (no
666    // allocation) on the default `emit()` path.
667    // Since `.inkb` v10 there is no parameter-binding bytecode to describe:
668    // the VM binds parameters at entry, so offset 0 is the container's first
669    // real statement and that statement's own entry covers it. (Before v10
670    // the leading `DeclareTemp` run owned offset 0 and needed an entry of
671    // its own, carrying the container's provenance because no `lir::Stmt`
672    // stood behind it.)
673    let debug_enabled = state.debug.is_some();
674    let raw_entries: Vec<debug_info::RawDebugEntry> = Vec::new();
675
676    // §2.4: which statement in `container.body` (by position) is the
677    // landing point past this container's prologue bytecode — the leading
678    // param `DeclareTemp`s above, plus, for a choice-target body, its
679    // leading `ChoiceOutput` statement, which is *also* prologue bytecode:
680    // a breakpoint on a choice target must land past the choice's own
681    // output being emitted, not on it (#3219 review — the naive `i == 0`
682    // this replaced flagged the `ChoiceOutput` statement itself whenever it
683    // was `body[0]`, which it always is when present). `None` when there is
684    // no statement to flag: an empty body, or a choice-target body
685    // containing only the `ChoiceOutput` — `walk_container` pushes its own
686    // synthetic coverage entry for that case below, once the real offset
687    // past all prologue bytecode is known.
688    let leading_choice_output = matches!(
689        container.body.first().map(|stmt| &stmt.kind),
690        Some(lir::StmtKind::ChoiceOutput { .. })
691    );
692    let prologue_end_index = if leading_choice_output {
693        (container.body.len() > 1).then_some(1)
694    } else {
695        (!container.body.is_empty()).then_some(0)
696    };
697
698    // D7 (`docs/debugger-spec.md` §3, issue #3185): this container's own
699    // `LocalsTable` rows — one per declared parameter (bound by the VM at
700    // entry since v10, so with no `lir::Stmt`/source range of their own) plus one per top-level `~ temp` declaration in this
701    // container's own body (a nested child container's `DeclareTemp`s are
702    // recorded when *it* is walked, into *its own* table — §2.2's
703    // container-lockstep framing, not this container's). `raw_locals` stays
704    // empty (no allocation) on the default `emit()` path.
705    let mut raw_locals: Vec<debug_info::RawLocal> = Vec::new();
706    if debug_enabled {
707        for param in &container.params {
708            raw_locals.push(debug_info::RawLocal {
709                slot: param.slot,
710                name: param.name,
711                declaring_range: None,
712                synthetic: false,
713            });
714        }
715        for stmt in &container.body {
716            if let lir::StmtKind::DeclareTemp {
717                slot,
718                name,
719                synthetic,
720                ..
721            } = &stmt.kind
722            {
723                raw_locals.push(debug_info::RawLocal {
724                    slot: *slot,
725                    name: *name,
726                    declaring_range: Some(stmt.provenance),
727                    synthetic: *synthetic,
728                });
729            }
730        }
731    }
732
733    // Emit this container's bytecode.
734    let mut emitter = ContainerEmitter::new(state, scope_id);
735    if debug_enabled {
736        emitter.debug_entries = Some(raw_entries);
737    }
738
739    // Branch containers (conditional or sequence) suppress `Done` after
740    // ChoiceSets. Choices inside branches form part of a larger logical
741    // ChoiceSet in the parent — the runtime auto-presents pending choices
742    // on frame/container exhaustion (no explicit Done needed).
743    if container.kind == lir::ContainerKind::ConditionalBranch
744        || container.kind == lir::ContainerKind::SequenceBranch
745    {
746        emitter.in_conditional_branch = true;
747    }
748
749    // `.inkb` v10: no parameter prologue. The VM binds a container's
750    // arguments into its frame at every entry (`brink_runtime::vm`'s
751    // `bind_entry_params`), which is what the leading `DeclareTemp` run used
752    // to do by sitting at offset 0. It reads each parameter's slot out of
753    // `ContainerDef::params` — that metadata is load-bearing now, not
754    // informational, and `param_count` below is derived from the same list
755    // so the two cannot disagree.
756
757    // Unconditional: `emit_body_top_level` (and everything it recurses
758    // into) only *records* when `emitter.debug_entries` is `Some` — see
759    // `ContainerEmitter::record_debug_entry` — so this is exactly
760    // `emit_body`'s old plain behavior byte-for-byte on the default
761    // (`debug_enabled == false`) path, with no separate branch needed here.
762    emitter.emit_body_top_level(&container.body, prologue_end_index);
763
764    let raw_entries = if debug_enabled {
765        let mut entries = emitter.debug_entries.take().unwrap_or_default();
766        if prologue_end_index.is_none() {
767            // Coverage guarantee (§2.4): even when no statement was flagged
768            // above — an empty body, or a choice-target body containing
769            // only the `ChoiceOutput` — this container still needs an entry
770            // covering its post-prologue offset, so the floor-lookup binary
771            // search never runs off the end of the table.
772            // `emitter.bytecode.len()` here is exactly that offset:
773            // `emit_body_top_level` has already finished, so it is past the
774            // `ChoiceOutput`'s own bytecode when one is present. Since v10
775            // that is the only prologue bytecode a container can have —
776            // parameters are bound by the VM at entry, not by an opcode
777            // run.
778            #[expect(clippy::cast_possible_truncation)]
779            entries.push(debug_info::RawDebugEntry {
780                offset: emitter.bytecode.len() as u32,
781                provenance: container.provenance,
782                prologue_end: true,
783            });
784        }
785        entries
786    } else {
787        Vec::new()
788    };
789
790    let path_hash: i32 = path.chars().map(|c| c as i32).sum();
791
792    // Scope-owning containers get a human-readable name for the intl pipeline.
793    let name = if is_scope_kind(container.kind) {
794        Some(emitter.intern_string(path))
795    } else {
796        None
797    };
798
799    // Qualified author path → this container, for find_address. Scope
800    // containers are addressable by their scope path; author-labeled
801    // gathers/choices by `{enclosing_scope}.{label}`. Interned now while the
802    // emitter is alive; the entry is pushed after the emitter is consumed.
803    let address_path_id: Option<NameId> = if is_scope_kind(container.kind) {
804        name
805    } else if container.labeled {
806        let label = container.name.as_deref().unwrap_or("_anon");
807        let qualified = if this_scope_path.is_empty() {
808            label.to_string()
809        } else {
810            format!("{this_scope_path}.{label}")
811        };
812        Some(emitter.intern_string(&qualified))
813    } else {
814        None
815    };
816
817    // Take the symbolic relocation table before the emitter's bytecode is
818    // moved into the def; the chunk carries both (FG-4b).
819    let relocations = core::mem::take(&mut emitter.relocations);
820    let def = ContainerDef {
821        id: container.id,
822        scope_id,
823        name,
824        bytecode: emitter.bytecode,
825        counting_flags: container.counting_flags,
826        path_hash,
827        // Declared-parameter count for arity-checking a host-directed entry
828        // (`choose_path_string_with_args`) / `call_function`. Saturates — a
829        // knot with >255 params is absurd and never legitimately occurs.
830        param_count: u8::try_from(container.params.len()).unwrap_or(u8::MAX),
831        // Per-param name/mode metadata (T1c, #700): carried so the runtime can
832        // validate a rehydrated function value against the current signature.
833        // The LIR param `NameId`s are valid in the story name table (it is
834        // cloned from `program.name_table` — see `emit`), so they are used
835        // as-is.
836        params: container
837            .params
838            .iter()
839            .map(|p| brink_format::ParamMeta {
840                slot: p.slot,
841                name: p.name,
842                is_ref: p.is_ref,
843            })
844            .collect(),
845        local: container.local,
846    };
847    state.chunks.push(ContainerChunk { def, relocations });
848
849    // D6: pushed in the same order as `state.chunks` above — lockstep with
850    // the eventual `StoryData::containers`, matching §2.2's `container_idx`
851    // contract. A no-op (`state.debug` is `None`) on the default path.
852    if let Some(debug) = state.debug.as_mut() {
853        debug.push_container(raw_entries, raw_locals);
854    }
855
856    // Primary address: every container is addressable by its own id.
857    state.addresses.push(AddressDef {
858        id: container.id,
859        container_id: container.id,
860        byte_offset: 0,
861    });
862
863    // Record the qualified-path → container mapping for find_address.
864    if let Some(path_id) = address_path_id {
865        state.address_paths.push(AddressPath {
866            path: path_id,
867            target: container.id,
868        });
869    }
870
871    // Recurse into children.
872    for child in &container.children {
873        let child_name = child.name.as_deref().unwrap_or("_anon");
874
875        // Compute the path segment for this child, applying inklecate-compatible
876        // naming rules so that path_hash values match for shuffle RNG seeding.
877        // Inklecate-compatible path naming rules so path_hash values match
878        // for shuffle RNG seeding. Only non-function knots get the implicit
879        // stitch ".0" prefix (functions store children directly).
880        let needs_stitch_prefix = container.kind == lir::ContainerKind::Knot
881            && !container.is_function
882            && child.kind != lir::ContainerKind::Stitch;
883
884        let segment = if needs_stitch_prefix && child.kind == lir::ContainerKind::Sequence {
885            // Rule 1+2: stitch prefix + rename "s-N" → "N"
886            let n = child_name.strip_prefix("s-").unwrap_or(child_name);
887            format!("0.{n}")
888        } else if needs_stitch_prefix {
889            // Rule 1: just add stitch prefix
890            format!("0.{child_name}")
891        } else if child.kind == lir::ContainerKind::Sequence {
892            // Rule 2: Sequence wrappers elsewhere: rename "s-N" → "N"
893            child_name
894                .strip_prefix("s-")
895                .unwrap_or(child_name)
896                .to_string()
897        } else if container.kind == lir::ContainerKind::Sequence
898            && child.kind == lir::ContainerKind::SequenceBranch
899        {
900            // Rule 3: Sequence branches: rename "N" → "sN"
901            format!("s{child_name}")
902        } else {
903            child_name.to_string()
904        };
905
906        let child_path = if path.is_empty() {
907            segment
908        } else {
909            format!("{path}.{segment}")
910        };
911        // If this child is a scope (knot, stitch, root), it starts a new scope.
912        // Otherwise it inherits the parent's scope.
913        let child_scope_id = if is_scope_kind(child.kind) {
914            child.id
915        } else {
916            scope_id
917        };
918        // A scope child's author path is its own (author-form) path; other
919        // children inherit the nearest enclosing scope's author path.
920        let child_scope_author_path: &str = if is_scope_kind(child.kind) {
921            &child_path
922        } else {
923            this_scope_path
924        };
925        walk_container(
926            child,
927            &child_path,
928            child_scope_author_path,
929            child_scope_id,
930            state,
931        );
932    }
933}
934
935// ─── Top-level definition builders ─────────────────────────────────
936
937fn build_globals(globals: &[lir::GlobalDef], state: &mut EmitState) -> Vec<GlobalVarDef> {
938    globals
939        .iter()
940        .map(|g| GlobalVarDef {
941            id: g.id,
942            name: g.name,
943            value_type: const_value_type(&g.default),
944            default_value: const_to_value(&g.default, &mut state.name_table, &mut state.name_index),
945            mutable: g.mutable,
946            local: g.local,
947        })
948        .collect()
949}
950
951fn build_list_defs(lists: &[lir::ListDef]) -> Vec<ListDef> {
952    lists
953        .iter()
954        .map(|l| ListDef {
955            id: l.id,
956            name: l.name,
957            items: l.items.clone(),
958        })
959        .collect()
960}
961
962fn build_list_items(items: &[lir::ListItemDef]) -> Vec<ListItemDef> {
963    items
964        .iter()
965        .map(|i| ListItemDef {
966            id: i.id,
967            origin: i.origin,
968            ordinal: i.ordinal,
969            name: i.name,
970        })
971        .collect()
972}
973
974fn build_externals(externals: &[lir::ExternalDef]) -> Vec<ExternalFnDef> {
975    externals
976        .iter()
977        .map(|e| ExternalFnDef {
978            id: e.id,
979            name: e.name,
980            arg_count: e.arg_count,
981            fallback: e.fallback,
982        })
983        .collect()
984}
985
986fn const_value_type(v: &lir::ConstValue) -> brink_format::ValueType {
987    match v {
988        lir::ConstValue::Int(_) => brink_format::ValueType::Int,
989        lir::ConstValue::Float(_) => brink_format::ValueType::Float,
990        lir::ConstValue::Bool(_) => brink_format::ValueType::Bool,
991        lir::ConstValue::String(_) => brink_format::ValueType::String,
992        lir::ConstValue::List { .. } => brink_format::ValueType::List,
993        lir::ConstValue::DivertTarget(_) => brink_format::ValueType::DivertTarget,
994        lir::ConstValue::Null => brink_format::ValueType::Null,
995        lir::ConstValue::Array(_) => brink_format::ValueType::Array,
996        lir::ConstValue::Map(_) => brink_format::ValueType::Map,
997        lir::ConstValue::Record { .. } => brink_format::ValueType::Record,
998        lir::ConstValue::FnRef(_) => brink_format::ValueType::FnRef,
999        lir::ConstValue::Closure { .. } => brink_format::ValueType::Closure,
1000    }
1001}
1002
1003fn const_to_value(
1004    v: &lir::ConstValue,
1005    name_table: &mut Vec<String>,
1006    name_index: &mut HashMap<String, NameId>,
1007) -> Value {
1008    match v {
1009        lir::ConstValue::Int(n) => Value::Int(*n),
1010        lir::ConstValue::Float(f) => Value::Float(*f),
1011        lir::ConstValue::Bool(b) => Value::Bool(*b),
1012        lir::ConstValue::String(s) => Value::String(s.clone().into()),
1013        lir::ConstValue::Null => Value::Null,
1014        lir::ConstValue::DivertTarget(id) => Value::DivertTarget(*id),
1015        lir::ConstValue::List { items, origins } => Value::List(
1016            ListValue {
1017                items: items.clone(),
1018                origins: origins.clone(),
1019            }
1020            .into(),
1021        ),
1022        lir::ConstValue::Array(items) => Value::array(
1023            items
1024                .iter()
1025                .map(|i| const_to_value(i, name_table, name_index))
1026                .collect(),
1027        ),
1028        lir::ConstValue::Map(entries) => {
1029            let mut map = OrderedMap::with_capacity(entries.len());
1030            for (k, v) in entries {
1031                let val = const_to_value(v, name_table, name_index);
1032                map.insert(const_map_key_to_value(k), val);
1033            }
1034            Value::map(map)
1035        }
1036        // A record baked into a declaration default (#1530). `fields` is
1037        // already in the shape's declaration order — the same order
1038        // `RecordNew` pushes and `Value::Record` stores — so there is
1039        // nothing left to reorder here.
1040        lir::ConstValue::Record { shape_id, fields } => Value::record(
1041            brink_format::ShapeId(*shape_id),
1042            fields
1043                .iter()
1044                .map(|f| const_to_value(f, name_table, name_index))
1045                .collect(),
1046        ),
1047        // Function values baked into a declaration default (T1c, #700). The
1048        // param name is interned (deduped) into the story name table so it
1049        // resolves to the same string the target container's `params` table
1050        // carries — the runtime rehydration check compares the two by name.
1051        lir::ConstValue::FnRef(target) => Value::FnRef(*target),
1052        lir::ConstValue::Closure { target, env } => {
1053            let env = env
1054                .iter()
1055                .map(|e| match e {
1056                    lir::ConstClosureEntry::Val { name, value } => {
1057                        let payload = const_to_value(value, name_table, name_index);
1058                        brink_format::ClosureEnvEntry {
1059                            name: intern_into(name_table, name_index, name),
1060                            is_ref: false,
1061                            payload,
1062                        }
1063                    }
1064                    lir::ConstClosureEntry::Ref { name, cell } => brink_format::ClosureEnvEntry {
1065                        name: intern_into(name_table, name_index, name),
1066                        is_ref: true,
1067                        payload: Value::VariablePointer(*cell),
1068                    },
1069                })
1070                .collect();
1071            Value::closure(*target, env)
1072        }
1073    }
1074}
1075
1076fn const_map_key_to_value(k: &lir::ConstMapKey) -> MapKey {
1077    match k {
1078        lir::ConstMapKey::Int(n) => MapKey::Int(*n),
1079        lir::ConstMapKey::Str(s) => MapKey::Str(s.clone().into()),
1080        lir::ConstMapKey::Bool(b) => MapKey::Bool(*b),
1081    }
1082}