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}