Skip to main content

lua_vm/
dump.rs

1//! Pre-compiled Lua chunk serializer.
2//!
3//! Translates `reference/lua-5.4.7/src/ldump.c` (230 lines, 9 functions + 1 public entry point).
4//! Writes a `LuaProto` to a byte sink in the standard Lua 5.4 bytecode format.
5
6// TODO(port): Adjust import paths once crate boundaries stabilise in Phase B.
7// The types below are expected to resolve as follows:
8//   GcRef        — lua_types (or lua-gc Phase D)
9//   LuaError     — lua_types
10//   LuaProto     — lua-vm (this crate) or lua-types
11//   LuaString    — lua-vm / lua-types
12//   LuaValue     — lua_types
13//   LuaState     — lua-vm (this crate)
14#[allow(unused_imports)]
15use crate::prelude::*;
16use std::mem::size_of;
17
18use crate::state::LuaState;
19use lua_types::proto::LuaProto;
20use lua_types::{GcRef, LuaError, LuaString, LuaValue, LuaVersion};
21
22// ── Constants from lundump.h ─────────────────────────────────────────────────
23
24// dumpLiteral expands to dumpBlock(D, s, sizeof(s) - sizeof(char)).
25// sizeof("\x1bLua") = 5; minus 1 = 4 bytes, no NUL terminator.
26// b"\x1bLua" is &[u8; 4] in Rust — no NUL — so direct use is correct.
27const LUA_SIGNATURE: &[u8] = b"\x1bLua";
28
29// With LUA_VERSION_NUM = 504 (macros.tsv):
30//   (504 / 100) * 16 + 504 % 100 = 5 * 16 + 4 = 84 = 0x54
31const LUA_VERSION_NUM_DUMP_54: i32 = 504;
32const LUAC_VERSION_54: u8 =
33    ((LUA_VERSION_NUM_DUMP_54 / 100) * 16 + LUA_VERSION_NUM_DUMP_54 % 100) as u8;
34const LUAC_VERSION_55: u8 = 0x55;
35
36const LUAC_FORMAT: u8 = 0;
37
38// sizeof("\x19\x93\r\n\x1a\n") = 7; minus 1 = 6 bytes written.
39// b"\x19\x93\r\n\x1a\n" is &[u8; 6].
40const LUAC_DATA: &[u8] = b"\x19\x93\r\n\x1a\n";
41
42const LUAC_INT: i64 = 0x5678;
43
44const LUAC_NUM: f64 = 370.5;
45
46const LUAC_INT_55: i64 = -0x5678;
47
48const LUAC_INST_55: u32 = 0x12345678;
49
50const LUAC_NUM_55: f64 = -370.5;
51
52const LUAC_VERSION_51: u8 = 0x51;
53
54const LUAC_VERSION_52: u8 = 0x52;
55
56const LUAC_VERSION_53: u8 = 0x53;
57
58/// Legacy (5.1/5.2/5.3) header byte: `sizeof(int)` = 4.
59const C_INT_SIZE: u8 = size_of::<i32>() as u8;
60
61/// Legacy (5.1/5.2/5.3) header byte: `sizeof(size_t)`, the build target's pointer width.
62const C_SIZET_SIZE: u8 = size_of::<usize>() as u8;
63
64/// Legacy (5.1/5.2) endianness flag: 1 = little-endian (the build target).
65const LUAC_ENDIAN_LITTLE: u8 = 1;
66
67/// Legacy (5.1/5.2) integral flag: 0 = `lua_Number` is floating-point.
68const LUAC_INTEGRAL_FLOAT: u8 = 0;
69
70const INSTRUCTION_SIZE: u8 = size_of::<u32>() as u8;
71
72const LUA_INTEGER_SIZE: u8 = size_of::<i64>() as u8;
73
74const LUA_NUMBER_SIZE: u8 = size_of::<f64>() as u8;
75
76// ── DumpState ────────────────────────────────────────────────────────────────
77
78/// Internal state threaded through every dump operation.
79///
80///
81/// PORT NOTE: `lua_State *L` removed — it was used only for `lua_lock`/`lua_unlock`, which are
82/// no-ops in the default Lua build and dropped here (macros.tsv). `void *data` is folded into
83/// the writer closure. `int status` is replaced by `Result<(), LuaError>` propagated with `?`.
84struct DumpState<'a> {
85    /// Byte-sink callback. C original: `lua_Writer writer` + `void *data` (combined).
86    /// lua_Writer type is TBD in types.tsv; for dump we use a bare byte-slice callback.
87    writer: &'a mut dyn FnMut(&[u8]) -> Result<(), LuaError>,
88    /// When true, strip all debug information from the output.
89    strip: bool,
90    version: LuaVersion,
91}
92
93impl<'a> DumpState<'a> {
94    // ── Low-level write primitives ────────────────────────────────────────────
95
96    /// Write raw bytes to the output stream.
97    ///
98    ///
99    /// PORT NOTE: C accumulates errors in `D->status` and skips subsequent writes once
100    /// non-zero; Rust returns `Result<(), LuaError>` and short-circuits via `?`.
101    /// `lua_lock`/`lua_unlock` are no-ops in the default build and are dropped (macros.tsv).
102    fn dump_block(&mut self, data: &[u8]) -> Result<(), LuaError> {
103        if !data.is_empty() {
104            (self.writer)(data)?;
105        }
106        Ok(())
107    }
108
109    /// Write one byte.
110    ///
111    /// C body: `lu_byte x = (lu_byte)y; dumpVar(D, x);`
112    /// (`dumpVar(D,x)` expands to `dumpVector(D,&x,1)` expands to `dumpBlock(D,&x,sizeof(x))`)
113    fn dump_byte(&mut self, y: u8) -> Result<(), LuaError> {
114        self.dump_block(&[y])
115    }
116
117    /// Write a `size_t` using Lua's variable-length encoding.
118    ///
119    ///
120    /// Encoding (big-endian 7-bit groups, **last** byte marked with MSB = 1):
121    /// - Each byte holds 7 payload bits.
122    /// - Bytes are written most-significant group first.
123    /// - The final byte (least-significant group) has its MSB set as an end marker.
124    ///
125    /// This differs from standard LEB128, which marks the *continuation* bytes rather than
126    /// the terminating byte.
127    ///
128    fn dump_size(&mut self, mut x: usize) -> Result<(), LuaError> {
129        // DIBS = (usize::BITS + 6) / 7; on 64-bit = (64+6)/7 = 10.
130        const DIBS: usize = (usize::BITS as usize + 6) / 7;
131        let mut buff = [0u8; DIBS];
132        let mut n: usize = 0;
133
134        loop {
135            n += 1;
136            buff[DIBS - n] = (x & 0x7f) as u8; // fill buffer in reverse order
137            x >>= 7;
138            if x == 0 {
139                break;
140            }
141        }
142
143        // The byte at buff[DIBS-1] is the first byte placed (least-significant group).
144        // Setting its MSB marks it as the terminal byte of the encoding.
145        buff[DIBS - 1] |= 0x80;
146
147        self.dump_block(&buff[DIBS - n..])
148    }
149
150    /// Write an `int` as a variable-length size.
151    ///
152    ///
153    /// PORT NOTE: C implicitly casts `int` → `size_t`. All call sites pass non-negative values
154    /// (line numbers, instruction counts, vector lengths); a debug assertion guards this.
155    fn dump_int(&mut self, x: i32) -> Result<(), LuaError> {
156        debug_assert!(
157            x >= 0,
158            "dump_int: negative value {} cast to usize would wrap",
159            x
160        );
161        self.dump_size(x as usize)
162    }
163
164    /// Write a `lua_Number` (f64) in the platform's native byte order.
165    ///
166    ///
167    /// `dumpVar(D,x)` expands to `dumpBlock(D, &x, sizeof(lua_Number))` — 8 bytes, native order.
168    /// `to_ne_bytes()` replicates native-endian serialisation. The bytecode header's `LUAC_NUM`
169    /// sentinel (370.5) lets `lundump` detect byte-order mismatches at load time.
170    fn dump_number(&mut self, x: f64) -> Result<(), LuaError> {
171        self.dump_block(&x.to_ne_bytes())
172    }
173
174    /// Write a `lua_Integer` (i64) in the platform's native byte order.
175    ///
176    fn dump_integer(&mut self, x: i64) -> Result<(), LuaError> {
177        self.dump_block(&x.to_ne_bytes())
178    }
179
180    fn dump_raw_i32(&mut self, x: i32) -> Result<(), LuaError> {
181        self.dump_block(&x.to_ne_bytes())
182    }
183
184    fn dump_raw_u32(&mut self, x: u32) -> Result<(), LuaError> {
185        self.dump_block(&x.to_ne_bytes())
186    }
187
188    // ── Mid-level serialisers ─────────────────────────────────────────────────
189
190    /// Write an interned or long string, or a null sentinel (encoded size = 0).
191    ///
192    ///
193    /// Encoding: `dumpSize(len + 1)` followed by `len` raw bytes; size 0 means null/absent.
194    /// `tsslen(s)` → `s.len()` and `getstr(s)` → `s.as_bytes()` (macros.tsv).
195    fn dump_string(&mut self, s: Option<&GcRef<LuaString>>) -> Result<(), LuaError> {
196        match s {
197            None => self.dump_size(0),
198
199            Some(s) => {
200                let bytes = s.as_bytes(); // tsslen → .len(); getstr → .as_bytes()
201                self.dump_size(bytes.len() + 1)?;
202                self.dump_block(bytes)
203            }
204        }
205    }
206
207    /// Write the bytecode instruction array.
208    ///
209    ///
210    /// PORT NOTE: `f->sizecode` is covered by `Vec::len()` (types.tsv).
211    fn dump_code(&mut self, proto: &LuaProto) -> Result<(), LuaError> {
212        self.dump_int(proto.code.len() as i32)?;
213
214        // dumpVector writes n * sizeof(Instruction) = n * 4 bytes in native byte order.
215        for instr in &proto.code {
216            // TODO(port): `Instruction` is a u32 newtype (types.tsv). Accessing the inner u32
217            // via `.0` assumes a tuple-struct layout. If the Instruction API differs (e.g.,
218            // exposes `.raw()` or `u32::from(*instr)`), adjust accordingly in Phase B.
219            self.dump_block(&instr.0.to_ne_bytes())?;
220        }
221        Ok(())
222    }
223
224    /// Write the constant pool.
225    ///
226    ///
227    /// Each constant is written as: one tag byte (`ttypetag`), followed by the payload
228    /// (float: 8 bytes; integer: 8 bytes; string: variable-length; nil/bool: nothing).
229    ///
230    /// PORT NOTE: `f->sizek` is covered by `Vec::len()` (types.tsv).
231    fn dump_constants(&mut self, proto: &LuaProto) -> Result<(), LuaError> {
232        let n = proto.k.len();
233        self.dump_int(n as i32)?;
234
235        for constant in &proto.k {
236            // ttypetag(o) → o.full_type_tag() (macros.tsv)
237            // Returns the C-side tag byte: bits 0-3 base type, bits 4-5 variant, bit 6 collectable.
238            let tag = constant.full_type_tag();
239            self.dump_byte(tag)?;
240
241            match constant {
242                LuaValue::Float(f) => {
243                    // fltvalue(o) → o.as_float().expect("not float") or `if let` (macros.tsv)
244                    self.dump_number(*f)?;
245                }
246                LuaValue::Int(i) => {
247                    self.dump_integer(*i)?;
248                }
249                LuaValue::Str(s) => {
250                    // tsvalue(o) → o.as_string().expect("not string") (macros.tsv)
251                    self.dump_string(Some(s))?;
252                }
253                LuaValue::Nil | LuaValue::Bool(_) => {
254                    // Only the tag byte is written; nil and booleans carry no additional payload.
255                    // lua_assert → debug_assert! (macros.tsv)
256                    debug_assert!(
257                        matches!(constant, LuaValue::Nil | LuaValue::Bool(_)),
258                        "dump_constants: default branch reached for unexpected variant"
259                    );
260                }
261                _ => {
262                    // TODO(port): LuaValue variant not valid as a constant-pool entry.
263                    // In C the default branch asserts nil/false/true only. Any other variant
264                    // here indicates a malformed proto; flag for Phase B investigation.
265                    debug_assert!(
266                        false,
267                        "dump_constants: unexpected LuaValue variant in constant pool"
268                    );
269                }
270            }
271        }
272        Ok(())
273    }
274
275    /// Write nested function prototypes (sub-functions defined inside `proto`).
276    ///
277    ///
278    /// PORT NOTE: `f->sizep` is covered by `Vec::len()` (types.tsv).
279    /// The parent's source string is passed down so that children with identical source
280    /// origins can omit the redundant source name (see `dump_function`).
281    fn dump_protos(&mut self, proto: &LuaProto) -> Result<(), LuaError> {
282        let n = proto.p.len();
283        self.dump_int(n as i32)?;
284
285        for sub in &proto.p {
286            // sub: &GcRef<LuaProto>; deref coercion (&GcRef<LuaProto> → &LuaProto) expected
287            // when GcRef<T>: Deref<Target=T> (true for Rc<T> in Phase A).
288            self.dump_function(sub, proto.source.as_ref())?;
289        }
290        Ok(())
291    }
292
293    /// Write upvalue descriptors (instack / idx / kind for each upvalue slot).
294    ///
295    ///
296    /// PORT NOTE: `f->sizeupvalues` is covered by `Vec::len()` (types.tsv).
297    /// `Upvaldesc.instack` is `bool` in Rust (types.tsv); cast to `u8` for the wire format.
298    fn dump_upvalues(&mut self, proto: &LuaProto) -> Result<(), LuaError> {
299        let n = proto.upvalues.len();
300        self.dump_int(n as i32)?;
301
302        for upval in &proto.upvalues {
303            // PORT NOTE: instack is bool in Rust (types.tsv); cast to u8: true→1, false→0.
304            self.dump_byte(upval.instack as u8)?;
305            self.dump_byte(upval.idx)?;
306            self.dump_byte(upval.kind)?;
307        }
308        Ok(())
309    }
310
311    /// Write debug information: per-instruction line deltas, absolute line records,
312    /// local-variable lifetimes, and upvalue names.
313    ///
314    /// All counts are written as zero when `self.strip` is true.
315    ///
316    ///
317    /// PORT NOTE: all `f->size*` fields are covered by `Vec::len()` (types.tsv).
318    fn dump_debug(&mut self, proto: &LuaProto) -> Result<(), LuaError> {
319        let n_lineinfo = if self.strip { 0 } else { proto.lineinfo.len() };
320        self.dump_int(n_lineinfo as i32)?;
321
322        // lineinfo is Vec<i8> (ls_byte per types.tsv). C writes them as raw bytes (sizeof(i8)=1).
323        // Cast each i8 to u8 (same bit pattern) before writing.
324        // PERF(port): iterating one byte at a time vs. bulk write — profile in Phase B.
325        // (A bulk write would require bytemuck::cast_slice or similar to avoid unsafe.)
326        let lineinfo_bytes: Vec<u8> = proto.lineinfo[..n_lineinfo]
327            .iter()
328            .map(|&b| b as u8)
329            .collect();
330        self.dump_block(&lineinfo_bytes)?;
331
332        let n_absline = if self.strip {
333            0
334        } else {
335            proto.abslineinfo.len()
336        };
337        self.dump_int(n_absline as i32)?;
338
339        for abs in proto.abslineinfo.iter().take(n_absline) {
340            // AbsLineInfo.pc and .line are i32 (types.tsv); non-negative in valid bytecode.
341            self.dump_int(abs.pc)?;
342            self.dump_int(abs.line)?;
343        }
344
345        let n_locvars = if self.strip { 0 } else { proto.locvars.len() };
346        self.dump_int(n_locvars as i32)?;
347
348        for locvar in proto.locvars.iter().take(n_locvars) {
349            // LocVar.varname is GcRef<LuaString> (types.tsv).
350            self.dump_string(Some(&locvar.varname))?;
351            self.dump_int(locvar.startpc)?;
352            self.dump_int(locvar.endpc)?;
353        }
354
355        // (Re-uses upvalues.len() for the name-writing pass — separate from dumpUpvalues
356        //  which wrote structural descriptors; here we write debug names.)
357        let n_upval_names = if self.strip { 0 } else { proto.upvalues.len() };
358        self.dump_int(n_upval_names as i32)?;
359
360        for upval in proto.upvalues.iter().take(n_upval_names) {
361            // PORT NOTE: UpvalDesc.name is GcRef<LuaString> per types.tsv (non-optional).
362            // TODO(port): In C, `TString *name` can be NULL when an upvalue is unnamed (e.g.,
363            // in bytecode compiled without debug info). Verify whether UpvalDesc.name should be
364            // `Option<GcRef<LuaString>>` in the Rust model; if so, change call to pass the Option
365            // directly instead of wrapping in Some.
366            self.dump_string(upval.name.as_ref())?;
367        }
368        Ok(())
369    }
370
371    /// Write a complete function prototype: source name, header bytes, code, constants,
372    /// upvalue descriptors, nested prototypes, and debug information.
373    ///
374    /// `psource` is the parent function's source string. When `f->source == psource` (pointer
375    /// equality — Lua interns short strings so identical source names share an object), the
376    /// source is written as null (size 0) to avoid duplication. The top-level call passes
377    /// `None` to force writing the source.
378    ///
379    ///
380    /// PORT NOTE: `f->source == psource` is a C pointer comparison exploiting string interning.
381    /// In Rust we use `GcRef::ptr_eq` (equivalent to `Rc::ptr_eq` in Phase A) for identity.
382    /// `is_vararg` is `bool` in Rust (types.tsv); cast to `u8` for the wire format.
383    fn dump_function(
384        &mut self,
385        proto: &LuaProto,
386        psource: Option<&GcRef<LuaString>>,
387    ) -> Result<(), LuaError> {
388        // Pointer-equality check: same interned string object means same source file.
389        let same_source = match (psource, proto.source.as_ref()) {
390            (Some(ps), Some(src)) => GcRef::ptr_eq(src, ps),
391            _ => false,
392        };
393
394        if self.strip || same_source {
395            self.dump_string(None)?;
396        } else {
397            self.dump_string(proto.source.as_ref())?;
398        }
399
400        self.dump_int(proto.linedefined)?;
401        self.dump_int(proto.lastlinedefined)?;
402        self.dump_byte(proto.numparams)?;
403        // PORT NOTE: is_vararg is bool in Rust (types.tsv); true → 1u8, false → 0u8.
404        self.dump_byte(proto.is_vararg as u8)?;
405        self.dump_byte(proto.maxstacksize)?;
406
407        self.dump_code(proto)?;
408        self.dump_constants(proto)?;
409        self.dump_upvalues(proto)?;
410        self.dump_protos(proto)?;
411        self.dump_debug(proto)?;
412        Ok(())
413    }
414
415    /// Write the binary chunk header.
416    ///
417    /// The header allows `lundump` (and external tools) to verify the bytecode format,
418    /// platform word sizes, and byte order before attempting to load the chunk.
419    ///
420    fn dump_header(&mut self) -> Result<(), LuaError> {
421        // dumpLiteral(D,s) = dumpBlock(D, s, sizeof(s) - sizeof(char))
422        // b"\x1bLua" is &[u8; 4] (no NUL terminator in Rust byte literals), matching the
423        // C expansion of sizeof("\x1bLua")-1 = 4 bytes.
424        self.dump_block(LUA_SIGNATURE)?;
425
426        match self.version {
427            LuaVersion::V51 => {
428                self.dump_byte(LUAC_VERSION_51)?;
429                self.dump_byte(LUAC_FORMAT)?;
430                self.dump_byte(LUAC_ENDIAN_LITTLE)?;
431                self.dump_byte(C_INT_SIZE)?;
432                self.dump_byte(C_SIZET_SIZE)?;
433                self.dump_byte(INSTRUCTION_SIZE)?;
434                self.dump_byte(LUA_NUMBER_SIZE)?;
435                self.dump_byte(LUAC_INTEGRAL_FLOAT)?;
436            }
437            LuaVersion::V52 => {
438                self.dump_byte(LUAC_VERSION_52)?;
439                self.dump_byte(LUAC_FORMAT)?;
440                self.dump_byte(LUAC_ENDIAN_LITTLE)?;
441                self.dump_byte(C_INT_SIZE)?;
442                self.dump_byte(C_SIZET_SIZE)?;
443                self.dump_byte(INSTRUCTION_SIZE)?;
444                self.dump_byte(LUA_NUMBER_SIZE)?;
445                self.dump_byte(LUAC_INTEGRAL_FLOAT)?;
446                self.dump_block(LUAC_DATA)?;
447            }
448            LuaVersion::V53 => {
449                self.dump_byte(LUAC_VERSION_53)?;
450                self.dump_byte(LUAC_FORMAT)?;
451                self.dump_block(LUAC_DATA)?;
452                self.dump_byte(C_INT_SIZE)?;
453                self.dump_byte(C_SIZET_SIZE)?;
454                self.dump_byte(INSTRUCTION_SIZE)?;
455                self.dump_byte(LUA_INTEGER_SIZE)?;
456                self.dump_byte(LUA_NUMBER_SIZE)?;
457                self.dump_integer(LUAC_INT)?;
458                self.dump_number(LUAC_NUM)?;
459            }
460            LuaVersion::V55 => {
461                self.dump_byte(LUAC_VERSION_55)?;
462                self.dump_byte(LUAC_FORMAT)?;
463                self.dump_block(LUAC_DATA)?;
464                self.dump_byte(size_of::<i32>() as u8)?;
465                self.dump_raw_i32(LUAC_INT_55 as i32)?;
466
467                self.dump_byte(INSTRUCTION_SIZE)?;
468                self.dump_raw_u32(LUAC_INST_55)?;
469
470                self.dump_byte(LUA_INTEGER_SIZE)?;
471                self.dump_integer(LUAC_INT_55)?;
472
473                self.dump_byte(LUA_NUMBER_SIZE)?;
474                self.dump_number(LUAC_NUM_55)?;
475            }
476            _ => {
477                self.dump_byte(LUAC_VERSION_54)?;
478                self.dump_byte(LUAC_FORMAT)?;
479                self.dump_block(LUAC_DATA)?;
480                self.dump_byte(INSTRUCTION_SIZE)?;
481                self.dump_byte(LUA_INTEGER_SIZE)?;
482                self.dump_byte(LUA_NUMBER_SIZE)?;
483                self.dump_integer(LUAC_INT)?;
484                self.dump_number(LUAC_NUM)?;
485            }
486        }
487
488        Ok(())
489    }
490}
491
492// ── Public entry point ───────────────────────────────────────────────────────
493
494/// Serialize a compiled Lua function prototype as a precompiled bytecode chunk.
495///
496/// The `writer` callback receives successive slices of the serialised bytes and returns
497/// `Err(LuaError)` to abort. `strip` omits debug info (line numbers, local names, etc.)
498/// from the output.
499///
500///
501/// PORT NOTE: `lua_Writer w` (fn pointer) + `void *data` (userdata) are collapsed into a
502/// single `impl FnMut(&[u8]) -> Result<(), LuaError>` closure — the Rust idiom for the
503/// callback + context pair. `_state` is retained in the signature for API parity but unused
504/// in the body: the C code needed it only for `lua_lock`/`lua_unlock`, which are no-ops per
505/// macros.tsv. Return type changes from `int` (0 = ok, non-zero = writer error) to
506/// `Result<(), LuaError>`.
507pub(crate) fn dump(
508    state: &LuaState,
509    proto: &GcRef<LuaProto>,
510    writer: &mut dyn FnMut(&[u8]) -> Result<(), LuaError>,
511    strip: bool,
512) -> Result<(), LuaError> {
513    let mut d = DumpState {
514        writer,
515        strip,
516        version: state.global().lua_version,
517    };
518
519    d.dump_header()?;
520
521    // PORT NOTE: f->sizeupvalues is covered by Vec::len(). Bounded by MAXUPVAL = 255
522    // (macros.tsv), so truncation via `as u8` is safe for well-formed prototypes.
523    d.dump_byte(proto.upvalues.len() as u8)?;
524
525    // psource = None forces the top-level function to always write its source name.
526    // Deref coercion: &GcRef<LuaProto> → &LuaProto (via Deref<Target=LuaProto> on GcRef/Rc).
527    d.dump_function(proto, None)?;
528
529    Ok(())
530}
531
532// ────────────────────────────────────────────────────────────────────────────
533// PORT STATUS
534//   source:        src/ldump.c  (230 lines, 10 functions)
535//   target_crate:  lua-vm
536//   confidence:    medium
537//   todos:         4
538//   port_notes:    12
539//   unsafe_blocks: 0
540//   notes:         Types/imports need Phase B wiring; logic should be faithful.
541//                  Key uncertainties: (1) Instruction newtype inner-field access (.0 vs
542//                  method); (2) UpvalDesc.name optionality; (3) GcRef::ptr_eq method
543//                  existence. Lineinfo bulk-write is done via collect()+dump_block to
544//                  avoid unsafe transmute of &[i8] → &[u8]; revisit with bytemuck in
545//                  Phase B for performance. Native-endian serialisation via to_ne_bytes()
546//                  matches C's raw-memory dumpVector behaviour.
547// ────────────────────────────────────────────────────────────────────────────