brink_format/conventions.rs
1//! The conventions-projection wire shape (issue #2111, continuation
2//! finding 2): a flat, span-free mirror of `brink_ir::ConventionsProjection`
3//! that can round-trip through bytes, in the same hand-rolled
4//! `.inkb`-section-codec idiom `StructShapeDef`/`FrameShapeDef` already use
5//! — never `serde`. Nothing in this crate's `.inkb` format is serde-based
6//! (`definition.rs`'s types derive no `Serialize`/`Deserialize` at all);
7//! `serde` only appears elsewhere in this crate for the unrelated
8//! JSON-based save-game format (`save.rs`) and `Value`'s `serde_json` law
9//! tests. Following the established `.inkb` idiom here — rather than
10//! reaching for `serde` because the review finding's prose said "no
11//! serde" — is the "do not fork a second format" instruction read
12//! literally: a serde-derived type would BE a second, inconsistent
13//! encoding convention living next to every other section in this format.
14//!
15//! # What this module does NOT yet do
16//!
17//! This defines the wire SHAPE and its codec only — it is not yet wired
18//! into [`crate::StoryData`]/[`crate::inkb::SectionKind`]. Doing that
19//! requires threading the project-layer conventions projection —
20//! `brink_ir::ConventionsProjection::from_decls` (built from
21//! `ClaimHandlerDecl`s), surfaced editor-side via `brink_db::queries::
22//! analysis::conventions_projection_query` (#2111/#2212) — through LIR
23//! lowering, which `brink-compiler`'s production pipeline does not do today
24//! (it never runs through `brink-db`'s salsa layer — only the editor does).
25//! The join this used to name, `brink_analyzer::conventions_registry`, was
26//! deleted with the rest of the dissolved `fn conventions()`/`register`
27//! machinery (issue #2165); it is not what this section still needs to
28//! wire up. Allocating a `.inkb` section tag and `StoryData` field ahead of
29//! that consumer (the #2108 host binding join) settling its exact needs
30//! risks locking in the wrong shape; wiring is left to a tracked follow-up.
31//! What exists here —
32//! the types, [`write_conventions_projection`]/[`read_conventions_projection`],
33//! and `brink_ir::ConventionsProjection::to_wire`'s conversion (every field
34//! this wire shape carries survives the round trip — see
35//! [`ConventionEntryDef`]'s own doc for the one field it deliberately does
36//! not carry, and why that is not a loss today) — is the reusable, tested
37//! groundwork that follow-up builds on, not a stand-in for it.
38//!
39//! # `transitions`/`templates` do not live here (2026-08-05 ruling)
40//!
41//! Issue #2115 briefly gave this shape `transitions`/`templates` fields —
42//! `brink_ir::dialect::DialogueDialect`'s editor-overlay succession rows
43//! (Tab/Enter/Shift-Tab behavior, picker labels), carried verbatim. The
44//! 2026-08-05 ruling *"Succession is EDITOR-OWNED and externally defined"*
45//! (`docs/decision-log.md`, PR #2276) reversed that transport: `dialect.rs`
46//! already says these rows "never travel beyond tooling", and `.inkb` **is**
47//! beyond tooling — it is the compiled artifact a game host loads, and a
48//! runtime has no Tab key. Issue #2277 stripped them back out of this wire
49//! shape and its codec. The editor now supplies succession rows externally;
50//! `brink_ir::ConventionsProjection::with_succession` and
51//! `brink_ir::dialect::validate_succession` still re-key those
52//! externally-supplied rows against this projection's real convention kinds
53//! in-process (so a rule naming a nonexistent kind fails loudly) — that
54//! validator half of #2115 survives the ruling intact. Only the wire
55//! transport half was undone.
56
57use alloc::boxed::Box;
58use alloc::string::String;
59use alloc::vec::Vec;
60
61use crate::codec::{
62 read_str, read_u8, read_u32, read_u64, write_str, write_u8, write_u32, write_u64,
63};
64use crate::inkb::safe_capacity;
65use crate::opcode::DecodeError;
66use crate::value::MAX_DECODE_DEPTH;
67
68/// Section-local encoding version — independent of the whole-`.inkb`
69/// `VERSION`, matching every other section-local-versioned table
70/// (`EffectRows`, `AliasTable`, `FrameShapes`) so this section's own
71/// encoding can grow without a format-wide bump once it is wired in.
72///
73/// Bumped `1` → `2` by issue #2115 (`ConventionsProjectionDef` gained
74/// `transitions`/`templates`), then `2` → `3` by issue #2277, which removed
75/// them again per the 2026-08-05 ruling — see this module's own doc.
76/// Nothing has emitted any of these versions into a real `.inkb`/`StoryData`
77/// yet (see this module's own doc — the wire shape is not wired into
78/// `crate::StoryData` at all), so there is no on-disk payload either bump
79/// could orphan; the version byte exists so a future reader can tell the
80/// shapes apart the moment this section *is* wired in, rather than needing
81/// an out-of-band note.
82pub const CONVENTIONS_PROJECTION_WIRE_VERSION: u8 = 3;
83
84/// The conventions projection, as it would ride the wire: every
85/// `@[convention]` handler declared in the project's one configured
86/// conventions module, ascending by `order` — the same shape
87/// `brink_ir::ConventionsProjection` carries in-process, with source spans
88/// stripped (a `.inkb`-loaded host has no source text to point a range at)
89/// and `disposition` intentionally not carried — see
90/// [`ConventionEntryDef`]'s own doc for why.
91///
92/// Does **not** carry `transitions`/`templates` — see this module's own doc
93/// for why (2026-08-05 ruling, issue #2277).
94#[derive(Debug, Clone, PartialEq, Eq, Default)]
95pub struct ConventionsProjectionDef {
96 pub entries: Vec<ConventionEntryDef>,
97}
98
99/// One `@[convention]` handler's projected shape on the wire. Mirrors
100/// `brink_ir::ConventionProjectionEntry`, with one deliberate exception:
101/// there is no `disposition` field here, because every wire entry is,
102/// today, the single existing `ElementDisposition::Call` case — this format
103/// has no other disposition to distinguish yet, unlike the in-process type,
104/// which carries the field explicitly (`ConventionProjectionEntry::disposition`'s
105/// own "read what happened, don't infer it from absence" reasoning) so a
106/// future second disposition doesn't need a wire bump to become visible
107/// in-process. Adding a second disposition to this wire shape is exactly the
108/// kind of section-local-version bump
109/// [`CONVENTIONS_PROJECTION_WIRE_VERSION`] exists for — until then, this
110/// one field's omission is a considered simplification of a
111/// currently-single-variant enum, not evidence of a lossy conversion
112/// elsewhere.
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct ConventionEntryDef {
115 pub name: String,
116 pub pattern: String,
117 pub order: i64,
118 pub mode: ConventionModeDef,
119 pub attach: Option<ConventionAttachDef>,
120}
121
122/// Wire mirror of `brink_ir::ConventionMode`.
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub enum ConventionModeDef {
125 Attach,
126 Wrap,
127}
128
129/// Wire mirror of `brink_ir::ConventionAttachSchema` — issue #2111 finding
130/// 1's resolved field list, carried all the way to the wire shape rather
131/// than collapsing back to a bare name.
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub enum ConventionAttachDef {
134 Resolved {
135 name: String,
136 fields: Vec<ConventionAttachFieldDef>,
137 },
138 Unresolved(String),
139}
140
141/// Wire mirror of `brink_ir::ConventionAttachField`.
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub struct ConventionAttachFieldDef {
144 pub name: String,
145 pub ty: SchemaTypeDef,
146}
147
148/// Wire mirror of `brink_ir::SchemaTypeShape`.
149#[derive(Debug, Clone, PartialEq, Eq)]
150pub enum SchemaTypeDef {
151 Named(String),
152 Generic {
153 name: String,
154 args: Vec<SchemaTypeDef>,
155 },
156 Fn {
157 params: Vec<SchemaTypeDef>,
158 ret: Box<SchemaTypeDef>,
159 },
160}
161
162const TAG_ATTACH: u8 = 0;
163const TAG_WRAP: u8 = 1;
164
165const TAG_RESOLVED: u8 = 0;
166const TAG_UNRESOLVED: u8 = 1;
167
168const TAG_TYPE_NAMED: u8 = 0;
169const TAG_TYPE_GENERIC: u8 = 1;
170const TAG_TYPE_FN: u8 = 2;
171
172/// Write the conventions-projection section (no header framing beyond its
173/// own section-local version byte, matching [`CONVENTIONS_PROJECTION_WIRE_VERSION`]):
174/// entry count, then each entry in the order given. Callers sort/dedupe
175/// before calling — this function trusts its input's order, the same
176/// posture every writer in this crate takes toward its own table.
177#[expect(clippy::cast_possible_truncation)]
178pub fn write_conventions_projection(projection: &ConventionsProjectionDef, buf: &mut Vec<u8>) {
179 write_u8(buf, CONVENTIONS_PROJECTION_WIRE_VERSION);
180 write_u32(buf, projection.entries.len() as u32);
181 for entry in &projection.entries {
182 write_str(buf, &entry.name);
183 write_str(buf, &entry.pattern);
184 write_u64(buf, entry.order.cast_unsigned());
185 write_u8(
186 buf,
187 match entry.mode {
188 ConventionModeDef::Attach => TAG_ATTACH,
189 ConventionModeDef::Wrap => TAG_WRAP,
190 },
191 );
192 match &entry.attach {
193 None => write_u8(buf, 0),
194 Some(attach) => {
195 write_u8(buf, 1);
196 write_attach(attach, buf);
197 }
198 }
199 }
200}
201
202#[expect(clippy::cast_possible_truncation)]
203fn write_attach(attach: &ConventionAttachDef, buf: &mut Vec<u8>) {
204 match attach {
205 ConventionAttachDef::Resolved { name, fields } => {
206 write_u8(buf, TAG_RESOLVED);
207 write_str(buf, name);
208 write_u32(buf, fields.len() as u32);
209 for field in fields {
210 write_str(buf, &field.name);
211 write_schema_type(&field.ty, buf);
212 }
213 }
214 ConventionAttachDef::Unresolved(name) => {
215 write_u8(buf, TAG_UNRESOLVED);
216 write_str(buf, name);
217 }
218 }
219}
220
221#[expect(clippy::cast_possible_truncation)]
222fn write_schema_type(ty: &SchemaTypeDef, buf: &mut Vec<u8>) {
223 match ty {
224 SchemaTypeDef::Named(name) => {
225 write_u8(buf, TAG_TYPE_NAMED);
226 write_str(buf, name);
227 }
228 SchemaTypeDef::Generic { name, args } => {
229 write_u8(buf, TAG_TYPE_GENERIC);
230 write_str(buf, name);
231 write_u32(buf, args.len() as u32);
232 for arg in args {
233 write_schema_type(arg, buf);
234 }
235 }
236 SchemaTypeDef::Fn { params, ret } => {
237 write_u8(buf, TAG_TYPE_FN);
238 write_u32(buf, params.len() as u32);
239 for param in params {
240 write_schema_type(param, buf);
241 }
242 write_schema_type(ret, buf);
243 }
244 }
245}
246
247/// Read a conventions-projection section written by
248/// [`write_conventions_projection`]. `buf`/`offset` are a raw cursor, not an
249/// `.inkb`-indexed section range — this codec is not wired into
250/// [`crate::inkb::InkbIndex`] yet (see this module's own doc).
251pub fn read_conventions_projection(
252 buf: &[u8],
253 offset: &mut usize,
254) -> Result<ConventionsProjectionDef, DecodeError> {
255 let section_version = read_u8(buf, offset)?;
256 if section_version != CONVENTIONS_PROJECTION_WIRE_VERSION {
257 return Err(DecodeError::UnsupportedSectionVersion {
258 // No `SectionKind` tag exists for this section yet (see this
259 // module's own doc) — `0` stands in as "not yet a real section".
260 section: 0,
261 version: section_version,
262 });
263 }
264 let count = read_u32(buf, offset)? as usize;
265 // Minimum per-entry footprint: two empty strings (4+4) + order (8) +
266 // mode (1) + no-attach (1) = 18 bytes.
267 let mut entries = Vec::with_capacity(safe_capacity(count, buf.len(), *offset, 18));
268 for _ in 0..count {
269 let name = read_str(buf, offset)?;
270 let pattern = read_str(buf, offset)?;
271 let order = read_u64(buf, offset)?.cast_signed();
272 let mode = match read_u8(buf, offset)? {
273 TAG_ATTACH => ConventionModeDef::Attach,
274 TAG_WRAP => ConventionModeDef::Wrap,
275 other => return Err(DecodeError::InvalidConventionsProjectionTag(other)),
276 };
277 let attach = match read_u8(buf, offset)? {
278 0 => None,
279 1 => Some(read_attach(buf, offset, 0)?),
280 other => return Err(DecodeError::InvalidConventionsProjectionTag(other)),
281 };
282 entries.push(ConventionEntryDef {
283 name,
284 pattern,
285 order,
286 mode,
287 attach,
288 });
289 }
290 Ok(ConventionsProjectionDef { entries })
291}
292
293fn read_attach(
294 buf: &[u8],
295 offset: &mut usize,
296 depth: usize,
297) -> Result<ConventionAttachDef, DecodeError> {
298 match read_u8(buf, offset)? {
299 TAG_RESOLVED => {
300 let name = read_str(buf, offset)?;
301 let count = read_u32(buf, offset)? as usize;
302 // Minimum per-field footprint: empty name (4) + type tag (1) = 5.
303 let mut fields = Vec::with_capacity(safe_capacity(count, buf.len(), *offset, 5));
304 for _ in 0..count {
305 let field_name = read_str(buf, offset)?;
306 let ty = read_schema_type(buf, offset, depth)?;
307 fields.push(ConventionAttachFieldDef {
308 name: field_name,
309 ty,
310 });
311 }
312 Ok(ConventionAttachDef::Resolved { name, fields })
313 }
314 TAG_UNRESOLVED => Ok(ConventionAttachDef::Unresolved(read_str(buf, offset)?)),
315 other => Err(DecodeError::InvalidConventionsProjectionTag(other)),
316 }
317}
318
319fn read_schema_type(
320 buf: &[u8],
321 offset: &mut usize,
322 depth: usize,
323) -> Result<SchemaTypeDef, DecodeError> {
324 if depth >= MAX_DECODE_DEPTH {
325 return Err(DecodeError::MaxDepthExceeded(MAX_DECODE_DEPTH));
326 }
327 match read_u8(buf, offset)? {
328 TAG_TYPE_NAMED => Ok(SchemaTypeDef::Named(read_str(buf, offset)?)),
329 TAG_TYPE_GENERIC => {
330 let name = read_str(buf, offset)?;
331 let count = read_u32(buf, offset)? as usize;
332 let mut args = Vec::with_capacity(safe_capacity(count, buf.len(), *offset, 1));
333 for _ in 0..count {
334 args.push(read_schema_type(buf, offset, depth + 1)?);
335 }
336 Ok(SchemaTypeDef::Generic { name, args })
337 }
338 TAG_TYPE_FN => {
339 let count = read_u32(buf, offset)? as usize;
340 let mut params = Vec::with_capacity(safe_capacity(count, buf.len(), *offset, 1));
341 for _ in 0..count {
342 params.push(read_schema_type(buf, offset, depth + 1)?);
343 }
344 let ret = Box::new(read_schema_type(buf, offset, depth + 1)?);
345 Ok(SchemaTypeDef::Fn { params, ret })
346 }
347 other => Err(DecodeError::InvalidConventionsProjectionTag(other)),
348 }
349}
350
351#[cfg(test)]
352mod tests {
353 use super::*;
354 use alloc::string::ToString;
355 use alloc::vec;
356
357 fn sample() -> ConventionsProjectionDef {
358 ConventionsProjectionDef {
359 entries: vec![
360 ConventionEntryDef {
361 name: "cue".to_string(),
362 pattern: "^(?<name>[A-Z]+)$".to_string(),
363 order: 5,
364 mode: ConventionModeDef::Wrap,
365 attach: Some(ConventionAttachDef::Resolved {
366 name: "Cue".to_string(),
367 fields: vec![
368 ConventionAttachFieldDef {
369 name: "speaker".to_string(),
370 ty: SchemaTypeDef::Named("string".to_string()),
371 },
372 ConventionAttachFieldDef {
373 name: "voiceover".to_string(),
374 ty: SchemaTypeDef::Named("bool".to_string()),
375 },
376 ],
377 }),
378 },
379 ConventionEntryDef {
380 name: "interior".to_string(),
381 pattern: "^INT\\. (?<place>.+)$".to_string(),
382 order: 10,
383 mode: ConventionModeDef::Attach,
384 attach: None,
385 },
386 ConventionEntryDef {
387 name: "broken".to_string(),
388 pattern: "^X$".to_string(),
389 order: 99,
390 mode: ConventionModeDef::Attach,
391 attach: Some(ConventionAttachDef::Unresolved("Ghost".to_string())),
392 },
393 ],
394 }
395 }
396
397 #[test]
398 fn round_trips_a_mixed_projection() {
399 let projection = sample();
400 let mut buf = Vec::new();
401 write_conventions_projection(&projection, &mut buf);
402 let mut offset = 0;
403 let decoded = read_conventions_projection(&buf, &mut offset).expect("decode");
404 assert_eq!(decoded, projection);
405 assert_eq!(
406 offset,
407 buf.len(),
408 "reader must consume exactly what the writer wrote"
409 );
410 }
411
412 #[test]
413 fn round_trips_an_empty_projection() {
414 let projection = ConventionsProjectionDef::default();
415 let mut buf = Vec::new();
416 write_conventions_projection(&projection, &mut buf);
417 let mut offset = 0;
418 let decoded = read_conventions_projection(&buf, &mut offset).expect("decode");
419 assert_eq!(decoded, projection);
420 }
421
422 #[test]
423 fn round_trips_a_generic_and_fn_typed_field() {
424 let projection = ConventionsProjectionDef {
425 entries: vec![ConventionEntryDef {
426 name: "handler".to_string(),
427 pattern: "^Z$".to_string(),
428 order: 1,
429 mode: ConventionModeDef::Attach,
430 attach: Some(ConventionAttachDef::Resolved {
431 name: "Fancy".to_string(),
432 fields: vec![
433 ConventionAttachFieldDef {
434 name: "items".to_string(),
435 ty: SchemaTypeDef::Generic {
436 name: "List".to_string(),
437 args: vec![SchemaTypeDef::Named("L".to_string())],
438 },
439 },
440 ConventionAttachFieldDef {
441 name: "callback".to_string(),
442 ty: SchemaTypeDef::Fn {
443 params: vec![SchemaTypeDef::Named("int".to_string())],
444 ret: Box::new(SchemaTypeDef::Named("bool".to_string())),
445 },
446 },
447 ],
448 }),
449 }],
450 };
451 let mut buf = Vec::new();
452 write_conventions_projection(&projection, &mut buf);
453 let mut offset = 0;
454 let decoded = read_conventions_projection(&buf, &mut offset).expect("decode");
455 assert_eq!(decoded, projection);
456 }
457
458 /// Rule 20a/mutation-style: an unknown mode tag must be a decode error,
459 /// not silently coerced to a valid variant.
460 #[test]
461 fn unknown_mode_tag_is_rejected() {
462 let projection = sample();
463 let mut buf = Vec::new();
464 write_conventions_projection(&projection, &mut buf);
465 // The mode byte for the first entry sits right after: version(1) +
466 // count(4) + name(4+len) + pattern(4+len) + order(8). Computed from
467 // the actual fixture strings' lengths (not hardcoded) so this stays
468 // correct if `sample()` ever changes.
469 let first = &projection.entries[0];
470 let mode_byte_offset = 1 + 4 + (4 + first.name.len()) + (4 + first.pattern.len()) + 8;
471 assert_eq!(buf[mode_byte_offset], TAG_WRAP, "test fixture assumption");
472 buf[mode_byte_offset] = 0xFF;
473 let mut offset = 0;
474 let err = read_conventions_projection(&buf, &mut offset).unwrap_err();
475 assert_eq!(err, DecodeError::InvalidConventionsProjectionTag(0xFF));
476 }
477
478 /// Companion to `unknown_mode_tag_is_rejected`: corrupts the byte right
479 /// after the mode tag — the attach-presence flag — so the attach-tag
480 /// match's `other` arm is exercised independently. Before this pair, a
481 /// wrong offset made the "mode" test actually corrupt this byte instead,
482 /// leaving the mode match's `other` arm with zero coverage.
483 #[test]
484 fn unknown_attach_presence_tag_is_rejected() {
485 let projection = sample();
486 let mut buf = Vec::new();
487 write_conventions_projection(&projection, &mut buf);
488 let first = &projection.entries[0];
489 let mode_byte_offset = 1 + 4 + (4 + first.name.len()) + (4 + first.pattern.len()) + 8;
490 let attach_presence_offset = mode_byte_offset + 1;
491 assert_eq!(
492 buf[attach_presence_offset], 1,
493 "test fixture assumption: attach present"
494 );
495 buf[attach_presence_offset] = 0xFF;
496 let mut offset = 0;
497 let err = read_conventions_projection(&buf, &mut offset).unwrap_err();
498 assert_eq!(err, DecodeError::InvalidConventionsProjectionTag(0xFF));
499 }
500
501 /// The section-local version byte is checked independently of decoding
502 /// the rest of the buffer — a future version bump must not be silently
503 /// misread as today's shape.
504 #[test]
505 fn unsupported_section_version_is_rejected() {
506 let mut buf = Vec::new();
507 write_conventions_projection(&ConventionsProjectionDef::default(), &mut buf);
508 buf[0] = CONVENTIONS_PROJECTION_WIRE_VERSION + 1;
509 let mut offset = 0;
510 let err = read_conventions_projection(&buf, &mut offset).unwrap_err();
511 assert_eq!(
512 err,
513 DecodeError::UnsupportedSectionVersion {
514 section: 0,
515 version: CONVENTIONS_PROJECTION_WIRE_VERSION + 1,
516 }
517 );
518 }
519}