Skip to main content

luna_core/jit/
aot_meta.rs

1//! Wire format for AOT trace metadata.
2//!
3//! # Why a luna-core module
4//!
5//! The format is shared by two distinct crates:
6//!
7//! - `luna-aot` (compile-time): serializes a runtime `CompiledTrace`
8//!   into bytes embedded in the AOT object's `luna_trace_blob`
9//!   section.
10//! - `luna-runtime-helpers` (deploy-time): walks the
11//!   `luna_trace_meta` index section, deserializes each entry's blob
12//!   into the **minimal fields** needed to construct a fresh
13//!   `CompiledTrace` for the deploy `Vm`'s dispatcher.
14//!
15//! Putting the wire format under `luna-core` keeps both crates pinned
16//! to the same constants without giving either a dep on the other.
17//!
18//! # 0-dep contract
19//!
20//! Hand-rolled `u8` packing — no `bincode`, no `serde`. The header
21//! carries [`AOT_META_MAGIC`] +
22//! [`AOT_META_VERSION`]; a mismatch on the deploy side is a hard
23//! reject, not silent fallback.
24//!
25//! # Wire format versions
26//!
27//! - **v1** — minimal format. Fields: `head_pc`, `n_ops`,
28//!   `window_size`, `dispatchable`, `entry_tags`, `exit_tags`,
29//!   `global_tag_res_kind`. Only "simple" traces (no inline side-
30//!   exits, no per-cont_pc tag exits) installable.
31//! - **v2** — Appends a trailing
32//!   `per_exit_tags` array (`(cont_pc, [ExitTag])` per entry) so
33//!   traces with typed-register side-exit guards (GetUpval-heavy
34//!   closures, type-specialized GetField loops) are AOT-installable.
35//!   v2 readers MUST accept v1 blobs as if `per_exit_tags`
36//!   were empty (the trailing block becomes optional via the
37//!   `total_payload < bytes.len()` predicate at decode time).
38//! - **v3** — Inline cmp@d>0 side-exit scaffolding. Appends a second
39//!   trailing block after `per_exit_tags`: a list of
40//!   [`PerExitInlineEntry`] records carrying the `cont_pc`,
41//!   `head_resume_pc`, the per-slot exit-tag snapshot covering the
42//!   trace's full `window_size`, and the `FrameMaterializeInfo`
43//!   chain bytes. v3 readers accept v1 and v2 blobs as if the
44//!   inline block were empty.
45//!
46//!   **Important — install-time invariant**: emitting the v3 inline
47//!   block into a meta blob is necessary but **not sufficient** to
48//!   safely AOT-install a trace whose `per_exit_inline` is non-
49//!   empty. The trace mcode itself today bakes a raw
50//!   `Rc::as_ptr(&chain_rc)` value as an `iconst` immediate at lower
51//!   time (see `luna-jit/src/jit_backend/trace.rs` near the cmp@d>0
52//!   side-exit emit sites). Under JIT the immediate is a live heap
53//!   pointer; under AOT it would be the warmup VM's heap address,
54//!   invalid in the deploy binary. To actually unlock the install
55//!   path the lowerer must:
56//!     1. Emit a writable per-site slot (`__luna_aot_inline_chain_
57//!        slot_<key>`) and load through it instead of the raw
58//!        `iconst`.
59//!     2. Register a new `luna_inline_chain_idx` bracketed section
60//!        analogous to `luna_strkey_idx` so the deploy resolver can
61//!        match installed entries to slot addresses.
62//!     3. The deploy walker (after rebuilding `Rc<[FrameMaterializeInfo
63//!        ]>` from v3 blob bytes) writes the live address of the
64//!        rebuilt chain into each slot before the first dispatch.
65//!
66//!   Until those three pieces land, the AOT harvester
67//!   (`luna-aot::embed`) MUST keep its
68//!   `per_exit_inline.is_empty()` filter so non-empty-inline
69//!   traces never produce a v3 blob with inline data — they stay
70//!   JIT-only. The wire format below is forward-ready so the
71//!   lowerer / resolver work doesn't need a fresh version bump.
72//!
73//! # Field summary
74//!
75//! `CompiledTrace` carries 30+ fields including `RefCell<HashMap>`,
76//! `Box<Cell<*const u8>>`, `Rc<[InlineSideExit]>` — most are side-
77//! trace bookkeeping irrelevant for AOT (the deploy `Vm` never side-
78//! traces an AOT-installed trace, so all those start empty). The
79//! AOT meta format serializes only the **dispatch-load-bearing**
80//! fields:
81//!
82//! - `head_pc`, `n_ops`, `window_size`, `dispatchable`
83//! - `entry_tags: Rc<[u8]>` — per-slot entry-tag specialization
84//! - `exit_tags: Rc<[ExitTag]>` — per-slot exit-tag restore (clean tail)
85//! - `global_tag_res_kind` — fast-path classification
86//! - `per_exit_tags` *(v2+)* — per-cont_pc slot-shape entries the
87//!   dispatcher uses to restore vm.stack on a typed-register
88//!   side-exit
89//! - `per_exit_inline` *(v3+)* — per-site inline cmp@d>0 side-exit
90//!   records: cont_pc + head_resume_pc + per-slot exit_tags + the
91//!   `FrameMaterializeInfo` chain bytes. Forward-compat scaffold;
92//!   the harvester does not yet emit non-empty entries because the
93//!   trace mcode side needs a relocatable chain-slot scheme first
94//!   (see the v3 paragraph above).
95//!
96//! `body_writes`, side-trace ptrs etc. default to empty / null.
97
98use crate::jit::trace_types::{ExitTag, FrameMaterializeInfo, TagResKind};
99
100/// Magic bytes at the start of every AOT meta blob. The deploy walker
101/// checks this against `read::<u32>` before parsing the rest;
102/// mismatches are reported (and the entry skipped) rather than
103/// causing arbitrary deserialization.
104pub const AOT_META_MAGIC: u32 = 0xAA77_0001;
105
106/// Wire-format version. v1 = minimal format. v2 = appends
107/// trailing `per_exit_tags` block so typed-register side-exits
108/// (GetUpval-heavy traces) install at deploy time. v3 = appends a
109/// second trailing block carrying `per_exit_inline` data so
110/// depth>0 inlined cmp side-exits can be rebuilt at install time
111/// (the trace mcode side still needs a relocatable-slot scheme
112/// before non-empty inline entries can actually be emitted —
113/// see the module docs).
114///
115/// **Forward compatibility contract**: a v3 writer emits the same
116/// fixed-prefix header layout as v1/v2 plus the v2 tail (always
117/// present, count=0 if empty) plus the v3 tail (count=0 if empty).
118/// A v3 reader MUST accept v1 blobs (= header + tags only, no v2
119/// tail) and v2 blobs (= header + tags + v2 tail only, no v3 tail)
120/// as if the missing tails were empty — implementation lives in
121/// [`decode_meta_blob`]'s `bytes.len() > cur` predicate at each
122/// tail boundary. v2 readers on a v3 blob would mis-parse the v3
123/// tail as garbage; we bump `AOT_META_VERSION` so older readers
124/// hard-reject instead of silently mis-installing.
125pub const AOT_META_VERSION: u32 = 3;
126
127/// Fixed-size header at the top of every meta blob. All ints are
128/// little-endian.
129///
130/// Total = 28 bytes. The variable-length tag arrays follow this
131/// header back-to-back (`entry_tags_len` u8s then `exit_tags_len`
132/// u8s).
133#[repr(C)]
134#[derive(Clone, Copy, Debug)]
135pub struct AotTraceMetaHeader {
136    /// [`AOT_META_MAGIC`]. Deploy-side hard-rejects mismatch.
137    pub magic: u32,
138    /// [`AOT_META_VERSION`]. Deploy-side hard-rejects mismatch.
139    pub version: u32,
140    /// Trace's `head_pc` — the PC the dispatcher matches on.
141    pub head_pc: u32,
142    /// Trace's `n_ops` — diagnostic only on the deploy side.
143    pub n_ops: u32,
144    /// Trace's `window_size` — sizes the dispatcher's `reg_state` buffer.
145    pub window_size: u32,
146    /// Trace's `dispatchable` flag as `u8` (0 / 1).
147    pub dispatchable: u8,
148    /// Trace's `global_tag_res_kind` packed:
149    /// `0 = AllUntouched`, `1 = AllInt`, `2 = Mixed`.
150    pub tag_res_kind: u8,
151    /// Length of the `entry_tags` array that follows the header.
152    /// `u16` is enough: trace's `max_stack` is bounded by Lua's
153    /// `MAXREGS` (255) and even worst-case inlining caps under 4K.
154    pub entry_tags_len: u16,
155    /// Length of the `exit_tags` array that follows after `entry_tags`.
156    pub exit_tags_len: u32,
157}
158
159impl AotTraceMetaHeader {
160    /// Byte size of the fixed prefix. Used to compute payload offset.
161    pub const SIZE: usize = 28;
162}
163
164/// Pack an `ExitTag` into its on-disk `u8` representation. Mirrors the
165/// `#[repr(u8)]` discriminant so the wire format is the same byte the
166/// compiler would lay out — but we go through the explicit match so a
167/// future reorder of [`ExitTag`]'s variants doesn't silently change
168/// the format.
169pub fn pack_exit_tag(t: ExitTag) -> u8 {
170    match t {
171        ExitTag::Untouched => 0,
172        ExitTag::Int => 1,
173        ExitTag::Float => 2,
174        ExitTag::Table => 3,
175        ExitTag::Closure => 4,
176        ExitTag::Nil => 5,
177        ExitTag::Str => 6,
178    }
179}
180
181/// Inverse of [`pack_exit_tag`]. Returns `None` on an unknown byte
182/// (treated as a corruption signal by the deploy walker).
183pub fn unpack_exit_tag(b: u8) -> Option<ExitTag> {
184    match b {
185        0 => Some(ExitTag::Untouched),
186        1 => Some(ExitTag::Int),
187        2 => Some(ExitTag::Float),
188        3 => Some(ExitTag::Table),
189        4 => Some(ExitTag::Closure),
190        5 => Some(ExitTag::Nil),
191        6 => Some(ExitTag::Str),
192        _ => None,
193    }
194}
195
196/// Pack a [`TagResKind`] into its wire byte.
197pub fn pack_tag_res_kind(k: TagResKind) -> u8 {
198    match k {
199        TagResKind::AllUntouched => 0,
200        TagResKind::AllInt => 1,
201        TagResKind::Mixed => 2,
202    }
203}
204
205/// Inverse of [`pack_tag_res_kind`]. Returns `None` on an unknown byte.
206pub fn unpack_tag_res_kind(b: u8) -> Option<TagResKind> {
207    match b {
208        0 => Some(TagResKind::AllUntouched),
209        1 => Some(TagResKind::AllInt),
210        2 => Some(TagResKind::Mixed),
211        _ => None,
212    }
213}
214
215/// One per-cont_pc side-exit entry serialized into the v2 tail of a
216/// meta blob. Mirrors `CompiledTrace::per_exit_tags`'s `(u32,
217/// Rc<[ExitTag]>)` shape, with the `ExitTag` slice already packed
218/// through [`pack_exit_tag`].
219#[derive(Clone, Debug)]
220pub struct PerExitTagsEntry {
221    /// Pc the interp resumes at after the side-exit fires. Matches
222    /// the IR's `iconst` baked into the side-exit return.
223    pub cont_pc: u32,
224    /// Per-slot `ExitTag` snapshot at the side-exit moment, packed
225    /// via [`pack_exit_tag`]. Length is the trace's caller-window
226    /// `max_stack` (always ≤ `window_size`).
227    pub tags_packed: Vec<u8>,
228}
229
230/// One per-site inline cmp@d>0 side-exit entry serialized into the v3
231/// tail of a meta blob. Mirrors `CompiledTrace::per_exit_inline`'s
232/// [`crate::jit::trace_types::InlineSideExit`] shape minus the
233/// runtime-only `side_trace_ptr` cell (always defaults null on AOT
234/// install). The `chain` field carries [`FrameMaterializeInfo`]
235/// records as raw bytes — `FrameMaterializeInfo` is `repr(C)` with
236/// three 32-bit fields = exactly 12 bytes per entry on every
237/// supported target, so the wire layout is stable.
238#[derive(Clone, Debug)]
239pub struct PerExitInlineEntry {
240    /// Pc the interpreter resumes at after the inline side-exit
241    /// fires. Mirrors `InlineSideExit::cont_pc`.
242    pub cont_pc: u32,
243    /// Pc to write on the trace head frame when the side-exit fires
244    /// (the outermost self-rec Call's `pc + 1`). Mirrors
245    /// `InlineSideExit::head_resume_pc`.
246    pub head_resume_pc: u32,
247    /// Per-slot `ExitTag` snapshot at the side-exit moment, packed
248    /// via [`pack_exit_tag`]. Length equals the trace's full
249    /// `window_size` (caller + every inlined frame's register
250    /// window) — `per_exit_tags`'s arrays cover only `max_stack`,
251    /// inline arrays cover the full window.
252    pub tags_packed: Vec<u8>,
253    /// `FrameMaterializeInfo` records as raw bytes (count * 12).
254    /// Outermost = depth 1 first, innermost = depth N last; the
255    /// innermost frame's `pc` is already overwritten to the side-
256    /// exit PC at AOT-compile time (matches the JIT-side
257    /// snapshot.last_mut().pc = side_exit_pc step). Length divisible
258    /// by 12 is a wire-format invariant; the decoder rejects otherwise.
259    pub chain_bytes: Vec<u8>,
260}
261
262impl PerExitInlineEntry {
263    /// Byte size of one `FrameMaterializeInfo` on the wire. Asserted
264    /// at compile time via [`FRAME_MATERIALIZE_INFO_WIRE_SIZE_CHECK`]
265    /// against the live struct so layout drift fails the build.
266    pub const FRAME_MATERIALIZE_INFO_SIZE: usize = 12;
267
268    /// Construct a `PerExitInlineEntry` from a live
269    /// [`crate::jit::trace_types::InlineSideExit`]. The chain is
270    /// serialized via a raw-byte copy — `FrameMaterializeInfo` is
271    /// `repr(C)` + all-`Copy` fields with no padding, so the byte
272    /// pattern matches the on-disk wire layout verbatim.
273    ///
274    /// # Safety
275    ///
276    /// `FrameMaterializeInfo` is `#[repr(C)]` with three 32-bit
277    /// fields and no padding; the byte-level transmute below is
278    /// sound per the wire-size assertion at module top.
279    pub fn from_inline_side_exit(src: &crate::jit::trace_types::InlineSideExit) -> Self {
280        let tags_packed: Vec<u8> = src.exit_tags.iter().copied().map(pack_exit_tag).collect();
281        let n = src.chain.len();
282        let mut chain_bytes = Vec::with_capacity(n * Self::FRAME_MATERIALIZE_INFO_SIZE);
283        for fm in src.chain.iter() {
284            chain_bytes.extend_from_slice(&fm.base_offset.to_le_bytes());
285            chain_bytes.extend_from_slice(&fm.pc.to_le_bytes());
286            chain_bytes.extend_from_slice(&fm.nresults.to_le_bytes());
287        }
288        PerExitInlineEntry {
289            cont_pc: src.cont_pc,
290            head_resume_pc: src.head_resume_pc,
291            tags_packed,
292            chain_bytes,
293        }
294    }
295
296    /// Reconstruct a `Vec<FrameMaterializeInfo>` from this entry's
297    /// `chain_bytes`. Returns `None` if `chain_bytes.len()` is not a
298    /// multiple of [`Self::FRAME_MATERIALIZE_INFO_SIZE`] (corruption
299    /// signal — the deploy walker should skip the entry).
300    pub fn rebuild_chain(&self) -> Option<Vec<FrameMaterializeInfo>> {
301        let unit = Self::FRAME_MATERIALIZE_INFO_SIZE;
302        if !self.chain_bytes.len().is_multiple_of(unit) {
303            return None;
304        }
305        let n = self.chain_bytes.len() / unit;
306        let mut out = Vec::with_capacity(n);
307        for i in 0..n {
308            let off = i * unit;
309            let base_offset =
310                u32::from_le_bytes(self.chain_bytes[off..off + 4].try_into().unwrap());
311            let pc = u32::from_le_bytes(self.chain_bytes[off + 4..off + 8].try_into().unwrap());
312            let nresults =
313                i32::from_le_bytes(self.chain_bytes[off + 8..off + 12].try_into().unwrap());
314            out.push(FrameMaterializeInfo {
315                base_offset,
316                pc,
317                nresults,
318            });
319        }
320        Some(out)
321    }
322}
323
324/// Static assertion that [`FrameMaterializeInfo`]'s in-memory size
325/// matches the wire-format constant. A regression here (a fourth
326/// field, a different layout attribute) silently misaligns the
327/// `chain_bytes` (re)serialization; the build break makes the drift
328/// loud at the source instead of mysterious at deploy time.
329pub const FRAME_MATERIALIZE_INFO_WIRE_SIZE_CHECK: () = assert!(
330    core::mem::size_of::<FrameMaterializeInfo>() == PerExitInlineEntry::FRAME_MATERIALIZE_INFO_SIZE,
331    "FrameMaterializeInfo wire size drifted — update PerExitInlineEntry::FRAME_MATERIALIZE_INFO_SIZE \
332     and any deploy-side rebuilders together"
333);
334
335/// Serialize a header + the two tag arrays + the v2 `per_exit_tags`
336/// tail + the v3 `per_exit_inline` tail into a fresh `Vec<u8>`. Pass
337/// empty slices to emit a "simple" trace (each tail then carries a
338/// single `count = 0` u32 — still v3 layout, just empty).
339///
340/// The produced bytes are what `luna-aot` embeds into the
341/// `luna_trace_blob` section per-trace; the deploy walker reads from
342/// the same wire shape via [`decode_meta_blob`].
343pub fn encode_meta_blob(
344    header: &AotTraceMetaHeader,
345    entry_tags: &[u8],
346    exit_tags_packed: &[u8],
347    per_exit_tags: &[PerExitTagsEntry],
348    per_exit_inline: &[PerExitInlineEntry],
349) -> Vec<u8> {
350    assert_eq!(entry_tags.len(), header.entry_tags_len as usize);
351    assert_eq!(exit_tags_packed.len(), header.exit_tags_len as usize);
352    assert_eq!(header.version, AOT_META_VERSION);
353    let v2_tail_bytes: usize = 4 + per_exit_tags
354        .iter()
355        .map(|e| 4 + 4 + e.tags_packed.len())
356        .sum::<usize>();
357    let v3_tail_bytes: usize = 4 + per_exit_inline
358        .iter()
359        .map(|e| 4 + 4 + 4 + e.tags_packed.len() + 4 + e.chain_bytes.len())
360        .sum::<usize>();
361    let mut out = Vec::with_capacity(
362        AotTraceMetaHeader::SIZE
363            + entry_tags.len()
364            + exit_tags_packed.len()
365            + v2_tail_bytes
366            + v3_tail_bytes,
367    );
368    out.extend_from_slice(&header.magic.to_le_bytes());
369    out.extend_from_slice(&header.version.to_le_bytes());
370    out.extend_from_slice(&header.head_pc.to_le_bytes());
371    out.extend_from_slice(&header.n_ops.to_le_bytes());
372    out.extend_from_slice(&header.window_size.to_le_bytes());
373    out.push(header.dispatchable);
374    out.push(header.tag_res_kind);
375    out.extend_from_slice(&header.entry_tags_len.to_le_bytes());
376    out.extend_from_slice(&header.exit_tags_len.to_le_bytes());
377    out.extend_from_slice(entry_tags);
378    out.extend_from_slice(exit_tags_packed);
379    // v2 tail: u32 count, then per entry [cont_pc:u32, tags_len:u32, tags:[u8; tags_len]].
380    out.extend_from_slice(&(per_exit_tags.len() as u32).to_le_bytes());
381    for ent in per_exit_tags {
382        out.extend_from_slice(&ent.cont_pc.to_le_bytes());
383        out.extend_from_slice(&(ent.tags_packed.len() as u32).to_le_bytes());
384        out.extend_from_slice(&ent.tags_packed);
385    }
386    // v3 tail: u32 count, then per entry
387    //   [cont_pc:u32, head_resume_pc:u32, tags_len:u32, tags:[u8; tags_len],
388    //    chain_bytes_len:u32, chain_bytes:[u8; chain_bytes_len]].
389    // chain_bytes_len is always a multiple of 12 (FrameMaterializeInfo
390    // wire size); the decoder rejects otherwise as a corruption signal.
391    out.extend_from_slice(&(per_exit_inline.len() as u32).to_le_bytes());
392    for ent in per_exit_inline {
393        out.extend_from_slice(&ent.cont_pc.to_le_bytes());
394        out.extend_from_slice(&ent.head_resume_pc.to_le_bytes());
395        out.extend_from_slice(&(ent.tags_packed.len() as u32).to_le_bytes());
396        out.extend_from_slice(&ent.tags_packed);
397        out.extend_from_slice(&(ent.chain_bytes.len() as u32).to_le_bytes());
398        out.extend_from_slice(&ent.chain_bytes);
399    }
400    out
401}
402
403/// Decoded shape returned by [`decode_meta_blob`].
404#[derive(Debug)]
405pub struct DecodedMeta {
406    /// The fixed-prefix header.
407    pub header: AotTraceMetaHeader,
408    /// `entry_tags` payload (length = `header.entry_tags_len`).
409    pub entry_tags: Vec<u8>,
410    /// `exit_tags` payload (length = `header.exit_tags_len`), still in
411    /// packed `u8` form. Caller maps each through [`unpack_exit_tag`].
412    pub exit_tags: Vec<u8>,
413    /// v2 tail — per-cont_pc tag arrays. Empty for v1 blobs and for
414    /// v2+ traces with no typed-register side-exits.
415    pub per_exit_tags: Vec<PerExitTagsEntry>,
416    /// v3 tail — per-site inline cmp@d>0 side-exit metadata.
417    /// Empty for v1 / v2 blobs and for v3 traces with no inlined
418    /// side-exits. Today the AOT harvester filters out traces with
419    /// non-empty `per_exit_inline` regardless (see module docs);
420    /// the field exists so the wire format is forward-ready for the
421    /// relocatable-chain-slot lowerer work that flips the filter.
422    pub per_exit_inline: Vec<PerExitInlineEntry>,
423}
424
425/// Deserialize a blob produced by [`encode_meta_blob`]. Returns
426/// `Err(reason)` on magic / version / length mismatch — the deploy
427/// walker should skip the entry and log the reason rather than
428/// installing a broken trace.
429pub fn decode_meta_blob(bytes: &[u8]) -> Result<DecodedMeta, &'static str> {
430    if bytes.len() < AotTraceMetaHeader::SIZE {
431        return Err("blob shorter than header");
432    }
433    let magic = u32::from_le_bytes(bytes[0..4].try_into().unwrap());
434    if magic != AOT_META_MAGIC {
435        return Err("AOT_META_MAGIC mismatch");
436    }
437    let version = u32::from_le_bytes(bytes[4..8].try_into().unwrap());
438    if version != AOT_META_VERSION {
439        return Err("AOT_META_VERSION mismatch");
440    }
441    let head_pc = u32::from_le_bytes(bytes[8..12].try_into().unwrap());
442    let n_ops = u32::from_le_bytes(bytes[12..16].try_into().unwrap());
443    let window_size = u32::from_le_bytes(bytes[16..20].try_into().unwrap());
444    let dispatchable = bytes[20];
445    let tag_res_kind = bytes[21];
446    let entry_tags_len = u16::from_le_bytes(bytes[22..24].try_into().unwrap());
447    let exit_tags_len = u32::from_le_bytes(bytes[24..28].try_into().unwrap());
448    let header = AotTraceMetaHeader {
449        magic,
450        version,
451        head_pc,
452        n_ops,
453        window_size,
454        dispatchable,
455        tag_res_kind,
456        entry_tags_len,
457        exit_tags_len,
458    };
459    let total_payload = entry_tags_len as usize + exit_tags_len as usize;
460    if bytes.len() < AotTraceMetaHeader::SIZE + total_payload {
461        return Err("blob shorter than declared payload");
462    }
463    let entry_start = AotTraceMetaHeader::SIZE;
464    let entry_end = entry_start + entry_tags_len as usize;
465    let exit_end = entry_end + exit_tags_len as usize;
466    let entry_tags = bytes[entry_start..entry_end].to_vec();
467    let exit_tags = bytes[entry_end..exit_end].to_vec();
468    // v2 tail: optional per_exit_tags block. Absent (= empty) when
469    // the blob ends exactly at `exit_end` — covers v1-shaped
470    // producers (which never wrote a tail) and v2+ producers
471    // serializing a trace with zero typed-register side-exits.
472    let mut per_exit_tags: Vec<PerExitTagsEntry> = Vec::new();
473    let mut cur = exit_end;
474    if bytes.len() > cur {
475        if bytes.len() < cur + 4 {
476            return Err("v2 tail truncated at count");
477        }
478        let count = u32::from_le_bytes(bytes[cur..cur + 4].try_into().unwrap()) as usize;
479        cur += 4;
480        // the count comes from the blob: reserve only what the rest of it
481        // can hold (an entry takes at least its 8-byte header), so a
482        // corrupt count fails in the loop below instead of asking for
483        // gigabytes
484        per_exit_tags.reserve(count.min((bytes.len() - cur) / 8));
485        for _ in 0..count {
486            if bytes.len() < cur + 8 {
487                return Err("v2 tail truncated at entry header");
488            }
489            let cont_pc = u32::from_le_bytes(bytes[cur..cur + 4].try_into().unwrap());
490            cur += 4;
491            let tags_len = u32::from_le_bytes(bytes[cur..cur + 4].try_into().unwrap()) as usize;
492            cur += 4;
493            if bytes.len() < cur + tags_len {
494                return Err("v2 tail truncated at entry tags");
495            }
496            let tags_packed = bytes[cur..cur + tags_len].to_vec();
497            cur += tags_len;
498            per_exit_tags.push(PerExitTagsEntry {
499                cont_pc,
500                tags_packed,
501            });
502        }
503    }
504    // v3 tail: optional per_exit_inline block. Absent when the blob
505    // ends at the v2 tail boundary — covers v1 / v2 producers and
506    // v3 producers serializing a trace with zero inline cmp@d>0
507    // side-exits. Tail layout per entry:
508    //   cont_pc:u32, head_resume_pc:u32,
509    //   tags_len:u32, tags:[u8; tags_len],
510    //   chain_bytes_len:u32, chain_bytes:[u8; chain_bytes_len]
511    // chain_bytes_len validated as a multiple of 12
512    // (FrameMaterializeInfo wire size) — non-multiple = corruption,
513    // we Err so the deploy walker skips the entry cleanly.
514    let mut per_exit_inline: Vec<PerExitInlineEntry> = Vec::new();
515    if bytes.len() > cur {
516        if bytes.len() < cur + 4 {
517            return Err("v3 tail truncated at count");
518        }
519        let count = u32::from_le_bytes(bytes[cur..cur + 4].try_into().unwrap()) as usize;
520        cur += 4;
521        // as above: an entry is at least 16 bytes (cont_pc, resume pc and
522        // the two length fields)
523        per_exit_inline.reserve(count.min((bytes.len() - cur) / 16));
524        for _ in 0..count {
525            if bytes.len() < cur + 12 {
526                return Err("v3 tail truncated at entry header");
527            }
528            let cont_pc = u32::from_le_bytes(bytes[cur..cur + 4].try_into().unwrap());
529            cur += 4;
530            let head_resume_pc = u32::from_le_bytes(bytes[cur..cur + 4].try_into().unwrap());
531            cur += 4;
532            let tags_len = u32::from_le_bytes(bytes[cur..cur + 4].try_into().unwrap()) as usize;
533            cur += 4;
534            if bytes.len() < cur + tags_len {
535                return Err("v3 tail truncated at entry tags");
536            }
537            let tags_packed = bytes[cur..cur + tags_len].to_vec();
538            cur += tags_len;
539            if bytes.len() < cur + 4 {
540                return Err("v3 tail truncated at chain header");
541            }
542            let chain_bytes_len =
543                u32::from_le_bytes(bytes[cur..cur + 4].try_into().unwrap()) as usize;
544            cur += 4;
545            if bytes.len() < cur + chain_bytes_len {
546                return Err("v3 tail truncated at chain bytes");
547            }
548            if !chain_bytes_len.is_multiple_of(PerExitInlineEntry::FRAME_MATERIALIZE_INFO_SIZE) {
549                return Err("v3 tail chain_bytes_len not a multiple of FrameMaterializeInfo size");
550            }
551            let chain_bytes = bytes[cur..cur + chain_bytes_len].to_vec();
552            cur += chain_bytes_len;
553            per_exit_inline.push(PerExitInlineEntry {
554                cont_pc,
555                head_resume_pc,
556                tags_packed,
557                chain_bytes,
558            });
559        }
560    }
561    Ok(DecodedMeta {
562        header,
563        entry_tags,
564        exit_tags,
565        per_exit_tags,
566        per_exit_inline,
567    })
568}
569
570/// Index entry layout in the deploy-side `luna_trace_meta` section.
571///
572/// 48 bytes per entry; the static linker fills `fn_ptr` and `meta_ptr`
573/// with relocations resolving to the trace's `.text` body and the
574/// matching `luna_trace_blob` payload respectively.
575///
576/// The deploy walker brackets the section via linker-synthetic
577/// `__start_luna_trace_meta` / `__stop_luna_trace_meta` (ELF) or
578/// `section$start$__DATA$luna_trace_meta` (Mach-O), mirroring sub-
579/// piece 3's `luna_strkey_idx` plumbing.
580#[repr(C)]
581#[derive(Clone, Copy, Debug)]
582pub struct AotTraceIndexEntry {
583    /// `Proto::stable_hash()` — matches the AOT-time proto identity
584    /// against the deploy-loaded proto tree.
585    pub proto_hash: [u8; 16],
586    /// Trace's `head_pc`. Used together with `proto_hash` to detect
587    /// duplicate installs and to log which trace fired.
588    pub head_pc: u32,
589    /// Padding so the following 64-bit address fields align at 8 bytes.
590    pub _pad: u32,
591    /// Address of the AOT-emitted trace fn
592    /// (`extern "C" fn(*mut i64) -> i64`). Stored as `u64` so the
593    /// wire layout is identical across 32/64-bit targets — wasm32 +
594    /// other 32-bit targets cast through this field. AOT-binary
595    /// deploy is always 64-bit (cross-compile to 32-bit targets
596    /// disabled at the linker step), so the upper 32 bits are zero
597    /// in practice. Linker-resolved relocation against the
598    /// `luna_aot_trace_<idx>` symbol the lowerer exports.
599    pub fn_ptr: u64,
600    /// Address of the matching meta blob in `luna_trace_blob`. Same
601    /// width-stable rationale as `fn_ptr`.
602    pub meta_ptr: u64,
603    /// Length of the meta blob (the deploy walker hard-rejects entries
604    /// whose declared payload exceeds this).
605    pub meta_len: u32,
606    /// Padding so the entry is a multiple of 8 bytes (48 total).
607    pub _pad2: u32,
608}
609
610impl AotTraceIndexEntry {
611    /// Byte size of one index entry. Compile-time assertion lives
612    /// next to the type via [`AOT_TRACE_INDEX_ENTRY_SIZE_CHECK`].
613    pub const SIZE: usize = 48;
614}
615
616/// Static assertion that `AotTraceIndexEntry` is exactly 48 bytes on
617/// the host build. Both crates that consume this format (`luna-aot`,
618/// `luna-runtime-helpers`) inherit the assertion via the type, so a
619/// padding regression fails compilation before the wire format
620/// silently misaligns.
621pub const AOT_TRACE_INDEX_ENTRY_SIZE_CHECK: () = assert!(
622    core::mem::size_of::<AotTraceIndexEntry>() == AotTraceIndexEntry::SIZE,
623    "AotTraceIndexEntry must be 48 bytes — alignment / padding regressed"
624);
625
626#[cfg(test)]
627mod tests {
628    use super::*;
629
630    /// A tail count the rest of the blob cannot hold is a truncation. It
631    /// sized a `reserve` before, so a corrupt count asked for tens of
632    /// gigabytes and aborted the process (found by `fuzz_aot_meta`).
633    #[test]
634    fn a_tail_count_larger_than_the_blob_is_refused_before_allocating() {
635        let header = AotTraceMetaHeader {
636            magic: AOT_META_MAGIC,
637            version: AOT_META_VERSION,
638            head_pc: 0,
639            n_ops: 0,
640            window_size: 0,
641            dispatchable: 1,
642            tag_res_kind: pack_tag_res_kind(TagResKind::AllInt),
643            entry_tags_len: 0,
644            exit_tags_len: 0,
645        };
646        let blob = encode_meta_blob(&header, &[], &[], &[], &[]);
647        let v2 = AotTraceMetaHeader::SIZE;
648        let mut bad = blob.clone();
649        bad[v2..v2 + 4].copy_from_slice(&u32::MAX.to_le_bytes());
650        assert_eq!(
651            decode_meta_blob(&bad).err(),
652            Some("v2 tail truncated at entry header")
653        );
654        let mut bad = blob;
655        bad[v2 + 4..v2 + 8].copy_from_slice(&u32::MAX.to_le_bytes());
656        assert_eq!(
657            decode_meta_blob(&bad).err(),
658            Some("v3 tail truncated at entry header")
659        );
660    }
661
662    #[test]
663    fn header_round_trip() {
664        let header = AotTraceMetaHeader {
665            magic: AOT_META_MAGIC,
666            version: AOT_META_VERSION,
667            head_pc: 42,
668            n_ops: 7,
669            window_size: 4,
670            dispatchable: 1,
671            tag_res_kind: pack_tag_res_kind(TagResKind::AllInt),
672            entry_tags_len: 2,
673            exit_tags_len: 3,
674        };
675        let entry_tags = vec![1u8, 2u8];
676        let exit_tags = vec![
677            pack_exit_tag(ExitTag::Int),
678            pack_exit_tag(ExitTag::Untouched),
679            pack_exit_tag(ExitTag::Float),
680        ];
681        let blob = encode_meta_blob(&header, &entry_tags, &exit_tags, &[], &[]);
682        // SIZE + entry_tags + exit_tags + v2-tail-count(4) + v3-tail-count(4)
683        assert_eq!(blob.len(), AotTraceMetaHeader::SIZE + 2 + 3 + 4 + 4);
684        let decoded = decode_meta_blob(&blob).expect("decode");
685        assert!(decoded.per_exit_tags.is_empty());
686        assert!(decoded.per_exit_inline.is_empty());
687        assert_eq!(decoded.header.head_pc, 42);
688        assert_eq!(decoded.header.window_size, 4);
689        assert_eq!(decoded.header.dispatchable, 1);
690        assert_eq!(decoded.entry_tags, entry_tags);
691        assert_eq!(decoded.exit_tags, exit_tags);
692        assert_eq!(
693            unpack_tag_res_kind(decoded.header.tag_res_kind),
694            Some(TagResKind::AllInt)
695        );
696        for (raw, expected) in
697            decoded
698                .exit_tags
699                .iter()
700                .zip([ExitTag::Int, ExitTag::Untouched, ExitTag::Float])
701        {
702            assert_eq!(unpack_exit_tag(*raw), Some(expected));
703        }
704    }
705
706    #[test]
707    fn decode_rejects_magic_mismatch() {
708        let mut blob = vec![0u8; AotTraceMetaHeader::SIZE];
709        // Magic stays zero.
710        let err = decode_meta_blob(&blob).unwrap_err();
711        assert!(err.contains("MAGIC"));
712        // Now valid magic + wrong version.
713        blob[..4].copy_from_slice(&AOT_META_MAGIC.to_le_bytes());
714        let err = decode_meta_blob(&blob).unwrap_err();
715        assert!(err.contains("VERSION"));
716    }
717
718    #[test]
719    fn v2_per_exit_tags_round_trip() {
720        // Two entries — one shorter than the other so the tail walker
721        // exercises variable-length parsing per entry.
722        let header = AotTraceMetaHeader {
723            magic: AOT_META_MAGIC,
724            version: AOT_META_VERSION,
725            head_pc: 7,
726            n_ops: 12,
727            window_size: 5,
728            dispatchable: 1,
729            tag_res_kind: pack_tag_res_kind(TagResKind::Mixed),
730            entry_tags_len: 0,
731            exit_tags_len: 0,
732        };
733        let entries = vec![
734            PerExitTagsEntry {
735                cont_pc: 3,
736                tags_packed: vec![
737                    pack_exit_tag(ExitTag::Int),
738                    pack_exit_tag(ExitTag::Untouched),
739                ],
740            },
741            PerExitTagsEntry {
742                cont_pc: 11,
743                tags_packed: vec![
744                    pack_exit_tag(ExitTag::Closure),
745                    pack_exit_tag(ExitTag::Table),
746                    pack_exit_tag(ExitTag::Float),
747                ],
748            },
749        ];
750        let blob = encode_meta_blob(&header, &[], &[], &entries, &[]);
751        let decoded = decode_meta_blob(&blob).expect("decode v2");
752        assert_eq!(decoded.per_exit_tags.len(), 2);
753        assert_eq!(decoded.per_exit_tags[0].cont_pc, 3);
754        assert_eq!(decoded.per_exit_tags[0].tags_packed.len(), 2);
755        assert_eq!(decoded.per_exit_tags[1].cont_pc, 11);
756        assert_eq!(decoded.per_exit_tags[1].tags_packed.len(), 3);
757        assert!(decoded.per_exit_inline.is_empty());
758    }
759
760    #[test]
761    fn v3_per_exit_inline_round_trip() {
762        // Two inline-side-exit entries with different chain depths so
763        // the tail walker exercises variable-length per-entry parsing.
764        let header = AotTraceMetaHeader {
765            magic: AOT_META_MAGIC,
766            version: AOT_META_VERSION,
767            head_pc: 0,
768            n_ops: 0,
769            window_size: 8,
770            dispatchable: 1,
771            tag_res_kind: pack_tag_res_kind(TagResKind::Mixed),
772            entry_tags_len: 0,
773            exit_tags_len: 0,
774        };
775        // Hand-roll the inline entries (the live-CompiledTrace
776        // converter is exercised by the round-trip-from-live test
777        // below).
778        let inline = vec![
779            PerExitInlineEntry {
780                cont_pc: 5,
781                head_resume_pc: 9,
782                tags_packed: vec![pack_exit_tag(ExitTag::Int), pack_exit_tag(ExitTag::Int)],
783                // 1 FrameMaterializeInfo = 12 bytes:
784                //   base_offset = 3, pc = 4, nresults = 1
785                chain_bytes: {
786                    let mut v = Vec::new();
787                    v.extend_from_slice(&3u32.to_le_bytes());
788                    v.extend_from_slice(&4u32.to_le_bytes());
789                    v.extend_from_slice(&1i32.to_le_bytes());
790                    v
791                },
792            },
793            PerExitInlineEntry {
794                cont_pc: 17,
795                head_resume_pc: 21,
796                tags_packed: vec![
797                    pack_exit_tag(ExitTag::Closure),
798                    pack_exit_tag(ExitTag::Untouched),
799                    pack_exit_tag(ExitTag::Float),
800                ],
801                // 2 frames = 24 bytes.
802                chain_bytes: {
803                    let mut v = Vec::new();
804                    for (off, pc, nr) in [(2u32, 7u32, 1i32), (5u32, 11u32, 2i32)] {
805                        v.extend_from_slice(&off.to_le_bytes());
806                        v.extend_from_slice(&pc.to_le_bytes());
807                        v.extend_from_slice(&nr.to_le_bytes());
808                    }
809                    v
810                },
811            },
812        ];
813        let blob = encode_meta_blob(&header, &[], &[], &[], &inline);
814        let decoded = decode_meta_blob(&blob).expect("decode v3");
815        assert_eq!(decoded.per_exit_inline.len(), 2);
816        assert_eq!(decoded.per_exit_inline[0].cont_pc, 5);
817        assert_eq!(decoded.per_exit_inline[0].head_resume_pc, 9);
818        assert_eq!(decoded.per_exit_inline[0].tags_packed.len(), 2);
819        let chain0 = decoded.per_exit_inline[0]
820            .rebuild_chain()
821            .expect("rebuild chain[0]");
822        assert_eq!(chain0.len(), 1);
823        assert_eq!(chain0[0].base_offset, 3);
824        assert_eq!(chain0[0].pc, 4);
825        assert_eq!(chain0[0].nresults, 1);
826        let chain1 = decoded.per_exit_inline[1]
827            .rebuild_chain()
828            .expect("rebuild chain[1]");
829        assert_eq!(chain1.len(), 2);
830        assert_eq!(chain1[0].base_offset, 2);
831        assert_eq!(chain1[1].pc, 11);
832        assert_eq!(chain1[1].nresults, 2);
833    }
834
835    #[test]
836    fn v3_per_exit_inline_round_trip_from_live() {
837        // Exercise PerExitInlineEntry::from_inline_side_exit against
838        // a hand-built InlineSideExit, then encode + decode +
839        // rebuild and check field equality. Catches drift if either
840        // the live-struct shape or the wire layout changes without
841        // updating the other.
842        use crate::jit::trace_types::{ExitTag, FrameMaterializeInfo, InlineSideExit};
843        let chain = vec![FrameMaterializeInfo {
844            base_offset: 1,
845            pc: 2,
846            nresults: 3,
847        }];
848        let live = InlineSideExit {
849            cont_pc: 42,
850            head_resume_pc: 50,
851            exit_tags: crate::jit::send_compat::TArc::from(
852                vec![ExitTag::Int, ExitTag::Float, ExitTag::Untouched].into_boxed_slice(),
853            ),
854            chain: crate::jit::send_compat::TArc::from(chain.into_boxed_slice()),
855            side_trace_ptr: Box::new(crate::jit::send_compat::TCellPtr::null()),
856        };
857        let entry = PerExitInlineEntry::from_inline_side_exit(&live);
858        assert_eq!(entry.cont_pc, 42);
859        assert_eq!(entry.head_resume_pc, 50);
860        assert_eq!(entry.tags_packed.len(), 3);
861        assert_eq!(
862            entry.chain_bytes.len(),
863            PerExitInlineEntry::FRAME_MATERIALIZE_INFO_SIZE
864        );
865        let header = AotTraceMetaHeader {
866            magic: AOT_META_MAGIC,
867            version: AOT_META_VERSION,
868            head_pc: 0,
869            n_ops: 0,
870            window_size: 4,
871            dispatchable: 1,
872            tag_res_kind: pack_tag_res_kind(TagResKind::Mixed),
873            entry_tags_len: 0,
874            exit_tags_len: 0,
875        };
876        let blob = encode_meta_blob(&header, &[], &[], &[], &[entry]);
877        let decoded = decode_meta_blob(&blob).expect("decode v3 from live");
878        assert_eq!(decoded.per_exit_inline.len(), 1);
879        let rebuilt = decoded.per_exit_inline[0].rebuild_chain().expect("rebuild");
880        assert_eq!(rebuilt.len(), 1);
881        assert_eq!(rebuilt[0].base_offset, 1);
882        assert_eq!(rebuilt[0].pc, 2);
883        assert_eq!(rebuilt[0].nresults, 3);
884    }
885
886    #[test]
887    fn decode_rejects_v3_chain_bytes_misaligned() {
888        // Hand-emit a v3 blob whose chain_bytes_len is not a multiple
889        // of 12 — the decoder MUST refuse (returning Err) instead of
890        // silently truncating, so the deploy walker has a clean skip
891        // signal.
892        let header = AotTraceMetaHeader {
893            magic: AOT_META_MAGIC,
894            version: AOT_META_VERSION,
895            head_pc: 0,
896            n_ops: 0,
897            window_size: 0,
898            dispatchable: 0,
899            tag_res_kind: 0,
900            entry_tags_len: 0,
901            exit_tags_len: 0,
902        };
903        let mut blob = Vec::new();
904        blob.extend_from_slice(&header.magic.to_le_bytes());
905        blob.extend_from_slice(&header.version.to_le_bytes());
906        blob.extend_from_slice(&header.head_pc.to_le_bytes());
907        blob.extend_from_slice(&header.n_ops.to_le_bytes());
908        blob.extend_from_slice(&header.window_size.to_le_bytes());
909        blob.push(header.dispatchable);
910        blob.push(header.tag_res_kind);
911        blob.extend_from_slice(&header.entry_tags_len.to_le_bytes());
912        blob.extend_from_slice(&header.exit_tags_len.to_le_bytes());
913        // v2 tail: count=0.
914        blob.extend_from_slice(&0u32.to_le_bytes());
915        // v3 tail: count=1, cont_pc=0, head_resume_pc=0, tags_len=0,
916        // chain_bytes_len=7 (not a multiple of 12), chain_bytes=7 zeros.
917        blob.extend_from_slice(&1u32.to_le_bytes());
918        blob.extend_from_slice(&0u32.to_le_bytes());
919        blob.extend_from_slice(&0u32.to_le_bytes());
920        blob.extend_from_slice(&0u32.to_le_bytes());
921        blob.extend_from_slice(&7u32.to_le_bytes());
922        blob.extend(std::iter::repeat_n(0u8, 7));
923        let err = decode_meta_blob(&blob).unwrap_err();
924        assert!(
925            err.contains("FrameMaterializeInfo"),
926            "expected misalignment err, got {err:?}"
927        );
928    }
929
930    #[test]
931    fn decode_tolerates_v1_blob_shape() {
932        // Emulate a v1-shaped blob: header + tags, NO trailing v2/v3
933        // tails. The v3 decoder should accept as empty everywhere.
934        let header = AotTraceMetaHeader {
935            magic: AOT_META_MAGIC,
936            version: AOT_META_VERSION,
937            head_pc: 0,
938            n_ops: 0,
939            window_size: 0,
940            dispatchable: 0,
941            tag_res_kind: 0,
942            entry_tags_len: 1,
943            exit_tags_len: 0,
944        };
945        let mut blob = Vec::new();
946        blob.extend_from_slice(&header.magic.to_le_bytes());
947        blob.extend_from_slice(&header.version.to_le_bytes());
948        blob.extend_from_slice(&header.head_pc.to_le_bytes());
949        blob.extend_from_slice(&header.n_ops.to_le_bytes());
950        blob.extend_from_slice(&header.window_size.to_le_bytes());
951        blob.push(header.dispatchable);
952        blob.push(header.tag_res_kind);
953        blob.extend_from_slice(&header.entry_tags_len.to_le_bytes());
954        blob.extend_from_slice(&header.exit_tags_len.to_le_bytes());
955        blob.push(0); // entry_tags[0]
956        // No v2/v3 tails.
957        let decoded = decode_meta_blob(&blob).expect("decode v1-shaped");
958        assert!(decoded.per_exit_tags.is_empty());
959        assert!(decoded.per_exit_inline.is_empty());
960    }
961
962    #[test]
963    fn decode_tolerates_v2_blob_shape() {
964        // Emulate a v2-shaped blob: header + tags + v2 tail only,
965        // NO trailing v3 count u32. The v3 decoder should accept it
966        // as an empty per_exit_inline.
967        let header = AotTraceMetaHeader {
968            magic: AOT_META_MAGIC,
969            version: AOT_META_VERSION,
970            head_pc: 0,
971            n_ops: 0,
972            window_size: 0,
973            dispatchable: 0,
974            tag_res_kind: 0,
975            entry_tags_len: 0,
976            exit_tags_len: 0,
977        };
978        let mut blob = Vec::new();
979        blob.extend_from_slice(&header.magic.to_le_bytes());
980        blob.extend_from_slice(&header.version.to_le_bytes());
981        blob.extend_from_slice(&header.head_pc.to_le_bytes());
982        blob.extend_from_slice(&header.n_ops.to_le_bytes());
983        blob.extend_from_slice(&header.window_size.to_le_bytes());
984        blob.push(header.dispatchable);
985        blob.push(header.tag_res_kind);
986        blob.extend_from_slice(&header.entry_tags_len.to_le_bytes());
987        blob.extend_from_slice(&header.exit_tags_len.to_le_bytes());
988        // v2 tail: count=1, one entry (cont_pc=3, tags_len=1, tags=[Int])
989        blob.extend_from_slice(&1u32.to_le_bytes());
990        blob.extend_from_slice(&3u32.to_le_bytes());
991        blob.extend_from_slice(&1u32.to_le_bytes());
992        blob.push(pack_exit_tag(ExitTag::Int));
993        // No v3 tail.
994        let decoded = decode_meta_blob(&blob).expect("decode v2-shaped");
995        assert_eq!(decoded.per_exit_tags.len(), 1);
996        assert!(decoded.per_exit_inline.is_empty());
997    }
998
999    #[test]
1000    fn decode_rejects_truncated() {
1001        // Header is fine, but exit_tags_len declares 10 bytes that
1002        // aren't there.
1003        let header = AotTraceMetaHeader {
1004            magic: AOT_META_MAGIC,
1005            version: AOT_META_VERSION,
1006            head_pc: 0,
1007            n_ops: 0,
1008            window_size: 0,
1009            dispatchable: 0,
1010            tag_res_kind: 0,
1011            entry_tags_len: 0,
1012            exit_tags_len: 10,
1013        };
1014        let blob = {
1015            let mut b = Vec::new();
1016            b.extend_from_slice(&header.magic.to_le_bytes());
1017            b.extend_from_slice(&header.version.to_le_bytes());
1018            b.extend_from_slice(&header.head_pc.to_le_bytes());
1019            b.extend_from_slice(&header.n_ops.to_le_bytes());
1020            b.extend_from_slice(&header.window_size.to_le_bytes());
1021            b.push(header.dispatchable);
1022            b.push(header.tag_res_kind);
1023            b.extend_from_slice(&header.entry_tags_len.to_le_bytes());
1024            b.extend_from_slice(&header.exit_tags_len.to_le_bytes());
1025            b
1026        };
1027        // Only header, no payload — should fail truncation check.
1028        let err = decode_meta_blob(&blob).unwrap_err();
1029        assert!(err.contains("payload"));
1030    }
1031}