brink_codegen_inkb/chunk.rs
1//! Symbolic name-reference + relocation representation for codegen chunks
2//! (FG-4b, #808 — `docs/fine-grained-salsa-proposal.md` §5 + the
3//! three-resolution-moments appendix).
4//!
5//! A per-container **chunk** ([`ContainerChunk`]) is the unit
6//! [`crate::emit`] produces for one LIR container: its emitted bytecode plus
7//! a table of *relocations*. Every name reference codegen must bake into
8//! bytecode (today only `PushString`'s `NameId` operand) is written as an
9//! unresolved placeholder ([`UNRESOLVED_NAME_ID`]) and recorded as a
10//! [`Relocation`] carrying the *symbolic* name ([`NameRef`]) rather than a
11//! resolved `NameId`. The assembly/link phase ([`ContainerChunk::link`])
12//! resolves each symbol against the assembled story name table and patches
13//! the two operand bytes in place.
14//!
15//! This is compile-link — the first of the three resolution moments in the
16//! proposal's appendix — mirroring the runtime's existing symbolic-ref /
17//! linker model (decision-log 2026-03-01) one layer earlier. Note the
18//! division of labour: the chunk owns only *bytecode* name references
19//! (patch sites). A container's own identity strings (its name, its
20//! author-path) are assembly-table fields, resolved by the assembler as it
21//! builds the story's name table — not chunk relocations.
22//!
23//! The types are deliberately **serializable-in-principle** — a [`NameRef`]
24//! owns its string and a [`Relocation`] is self-contained (a byte offset +
25//! a symbol, no transient pointers) — so a future dynamic-linking slice
26//! (#717) can ship relocatable chunks without redesigning them. No
27//! serialization is implemented here.
28//!
29//! History-independence (the FG-4d gate this representation exists to
30//! satisfy): a chunk's relocation offsets are byte positions in its own
31//! bytecode and its `NameRef`s are the source strings — both derived from
32//! the container's content, never from allocation history. Resolving them
33//! against a name table built in the deterministic container-walk order
34//! yields byte-identical output whether the chunk was freshly emitted or
35//! (in a future incremental world) re-linked from cache.
36
37use brink_format::{ContainerDef, NameId};
38
39use crate::CodegenError;
40
41/// The placeholder written into a chunk's bytecode at every name-reference
42/// operand before linking. `u16::MAX` is never a legitimate resolved
43/// `NameId` in the stories this backend emits (name tables stay far below
44/// 65 535 entries at game-corpus sizes), so an un-patched site is
45/// detectable rather than silently valid.
46pub const UNRESOLVED_NAME_ID: u16 = u16::MAX;
47
48/// A symbolic reference to a name, resolved to a final [`NameId`] by the
49/// link phase. Owns its string so the record is self-contained (the FG-4
50/// appendix's "no transient pointers, serializable in principle").
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub enum NameRef {
53 /// The referenced name, identified by its interned string. The content
54 /// *is* the address, so this doubles as the content-addressed id the
55 /// appendix calls for.
56 Symbol(String),
57}
58
59impl NameRef {
60 /// The referenced symbol's string.
61 #[must_use]
62 pub fn as_str(&self) -> &str {
63 match self {
64 NameRef::Symbol(s) => s,
65 }
66 }
67}
68
69/// A patch site in a chunk's bytecode: the little-endian `u16` `NameId`
70/// operand at byte `offset` (currently always a `PushString` operand) must
71/// be overwritten with the link-resolved id of [`name`](Self::name).
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct Relocation {
74 /// Byte offset of the 2-byte operand within the chunk's own bytecode.
75 pub offset: u32,
76 /// The symbolic name to resolve and patch in.
77 pub name: NameRef,
78}
79
80/// One container's codegen output: a [`ContainerDef`] whose bytecode still
81/// carries [`UNRESOLVED_NAME_ID`] placeholders at every [`Relocation`] site,
82/// plus the relocation table itself. [`link`](Self::link) resolves the
83/// symbols and yields a fully-patched `ContainerDef`.
84#[derive(Debug, Clone)]
85pub struct ContainerChunk {
86 /// The container definition. `def.bytecode` holds placeholders at every
87 /// relocation offset until [`link`](Self::link) runs.
88 pub def: ContainerDef,
89 /// Symbolic name-reference patch sites into `def.bytecode`, in emission
90 /// order.
91 pub relocations: Vec<Relocation>,
92}
93
94impl ContainerChunk {
95 /// Resolve every relocation against `resolve` and patch the operand
96 /// bytes in place, consuming the chunk and returning the linked
97 /// [`ContainerDef`].
98 ///
99 /// `resolve` maps a symbol to its assembled [`NameId`] (a lookup into
100 /// the story name table). A miss is an internal invariant violation —
101 /// the writer interns every referenced symbol into the name table as it
102 /// records the relocation — so it surfaces as a distinct
103 /// [`CodegenError`] rather than being silently skipped.
104 pub fn link(
105 mut self,
106 resolve: impl Fn(&str) -> Option<NameId>,
107 ) -> Result<ContainerDef, CodegenError> {
108 for reloc in &self.relocations {
109 let id = resolve(reloc.name.as_str()).ok_or_else(|| {
110 CodegenError::new(format!(
111 "codegen link: unresolved name reference {:?} at byte offset {}",
112 reloc.name.as_str(),
113 reloc.offset
114 ))
115 })?;
116 let start = reloc.offset as usize;
117 let len = self.def.bytecode.len();
118 let slot = self.def.bytecode.get_mut(start..start + 2).ok_or_else(|| {
119 CodegenError::new(format!(
120 "codegen link: relocation offset {} out of bounds for chunk bytecode (len {len})",
121 reloc.offset,
122 ))
123 })?;
124 slot.copy_from_slice(&id.0.to_le_bytes());
125 }
126 Ok(self.def)
127 }
128}
129
130#[cfg(test)]
131mod tests {
132 use brink_format::{CountingFlags, DefinitionId, DefinitionTag, Opcode};
133
134 use super::{ContainerChunk, NameRef, Relocation, UNRESOLVED_NAME_ID};
135
136 fn def_id() -> DefinitionId {
137 DefinitionId::new(DefinitionTag::Address, 1)
138 }
139
140 /// A minimal `ContainerDef` whose bytecode is exactly `bytecode`.
141 fn chunk_with(bytecode: Vec<u8>, relocations: Vec<Relocation>) -> ContainerChunk {
142 ContainerChunk {
143 def: brink_format::ContainerDef {
144 id: def_id(),
145 scope_id: def_id(),
146 name: None,
147 bytecode,
148 counting_flags: CountingFlags::empty(),
149 path_hash: 0,
150 param_count: 0,
151 params: Vec::new(),
152 local: false,
153 },
154 relocations,
155 }
156 }
157
158 /// The writer's placeholder → the reader's resolved id: a single
159 /// `PushString` site round-trips through `link` to the byte-exact
160 /// operand a resolved emit would have produced.
161 #[test]
162 fn push_string_relocation_round_trips() {
163 // Writer: emit `PushString(UNRESOLVED)`; record a relocation at the
164 // 2-byte operand.
165 let mut bytecode = Vec::new();
166 Opcode::PushString(UNRESOLVED_NAME_ID).encode(&mut bytecode);
167 let offset = u32::try_from(bytecode.len() - 2).unwrap();
168 let chunk = chunk_with(
169 bytecode,
170 vec![Relocation {
171 offset,
172 name: NameRef::Symbol("greeting".to_string()),
173 }],
174 );
175
176 // The chunk is genuinely symbolic before linking: the operand is the
177 // placeholder, not a resolved id.
178 assert_eq!(
179 &chunk.def.bytecode[offset as usize..offset as usize + 2],
180 &[0xFF, 0xFF]
181 );
182
183 // Reader: resolve "greeting" -> NameId(7) and patch.
184 let linked = chunk
185 .link(|s| (s == "greeting").then_some(brink_format::NameId(7)))
186 .expect("resolvable");
187
188 // Byte-identical to a directly-resolved emit.
189 let mut expected = Vec::new();
190 Opcode::PushString(7).encode(&mut expected);
191 assert_eq!(linked.bytecode, expected);
192 }
193
194 /// Multiple relocations in one chunk each patch their own site; an empty
195 /// relocation table leaves bytecode untouched.
196 #[test]
197 fn multiple_and_empty_relocations() {
198 let mut bytecode = Vec::new();
199 Opcode::PushString(UNRESOLVED_NAME_ID).encode(&mut bytecode);
200 let first = u32::try_from(bytecode.len() - 2).unwrap();
201 Opcode::PushString(UNRESOLVED_NAME_ID).encode(&mut bytecode);
202 let second = u32::try_from(bytecode.len() - 2).unwrap();
203
204 let chunk = chunk_with(
205 bytecode,
206 vec![
207 Relocation {
208 offset: first,
209 name: NameRef::Symbol("a".to_string()),
210 },
211 Relocation {
212 offset: second,
213 name: NameRef::Symbol("b".to_string()),
214 },
215 ],
216 );
217 let linked = chunk
218 .link(|s| match s {
219 "a" => Some(brink_format::NameId(3)),
220 "b" => Some(brink_format::NameId(9)),
221 _ => None,
222 })
223 .expect("resolvable");
224
225 let mut expected = Vec::new();
226 Opcode::PushString(3).encode(&mut expected);
227 Opcode::PushString(9).encode(&mut expected);
228 assert_eq!(linked.bytecode, expected);
229
230 // No relocations => untouched.
231 let untouched = chunk_with(vec![0xAB, 0xCD], Vec::new());
232 let out = untouched.link(|_| None).expect("no work");
233 assert_eq!(out.bytecode, vec![0xAB, 0xCD]);
234 }
235
236 /// A symbol the name table cannot resolve is a distinct error, never a
237 /// silently-skipped patch.
238 #[test]
239 fn unresolved_symbol_is_an_error() {
240 let mut bytecode = Vec::new();
241 Opcode::PushString(UNRESOLVED_NAME_ID).encode(&mut bytecode);
242 let offset = u32::try_from(bytecode.len() - 2).unwrap();
243 let chunk = chunk_with(
244 bytecode,
245 vec![Relocation {
246 offset,
247 name: NameRef::Symbol("missing".to_string()),
248 }],
249 );
250 let err = chunk.link(|_| None).expect_err("should fail");
251 assert!(err.to_string().contains("missing"));
252 }
253}