Skip to main content

lua_vm/
undump.rs

1//! Load precompiled Lua chunks.
2//!
3//! The binary chunk format matches the reference C implementation
4//! (`lundump.c`/`lundump.h`) byte-for-byte, so `string.dump` output and
5//! precompiled chunks stay interchangeable with stock Lua.
6//!
7//! The public entry point is [`undump`], which reads a binary Lua chunk from
8//! a [`ZIO`] stream and returns a Lua closure ready to call.
9
10#[allow(unused_imports)]
11use crate::prelude::*;
12use crate::state::LuaState;
13use crate::zio::ZIO;
14use lua_types::error::LuaError;
15use lua_types::value::LuaValue;
16
17use lua_types::closure::LuaLClosure;
18use lua_types::gc::GcRef;
19use lua_types::opcode::Instruction;
20use lua_types::proto::{AbsLineInfo, LocalVar, LuaProto, UpvalDesc};
21use lua_types::string::LuaString;
22use lua_types::LuaVersion;
23
24// ── Constants (from lundump.h) ─────────────────────────────────────────────
25
26/// Six-byte data marker in the chunk header used to catch conversion errors.
27const LUAC_DATA: &[u8] = b"\x19\x93\r\n\x1a\n";
28
29/// Reference integer written in the header to detect integer endianness/size
30/// mismatches.
31const LUAC_INT: i64 = 0x5678;
32
33/// Reference float written in the header to detect float format mismatches.
34const LUAC_NUM: f64 = 370.5;
35
36const LUAC_INT_55: i64 = -0x5678;
37
38const LUAC_INST_55: u32 = 0x12345678;
39
40const LUAC_NUM_55: f64 = -370.5;
41
42// LUA_VERSION_NUM = 504 → ((5 * 16) + 4) = 0x54 = 84
43/// One-byte version tag: upper nibble = major, lower nibble = minor.
44const LUAC_VERSION_51: u8 = 0x51;
45const LUAC_VERSION_52: u8 = 0x52;
46const LUAC_VERSION_53: u8 = 0x53;
47const LUAC_VERSION_54: u8 = 0x54;
48const LUAC_VERSION_55: u8 = 0x55;
49
50const LUAC_FORMAT: u8 = 0;
51
52const LUA_SIGNATURE: &[u8] = b"\x1bLua";
53
54const MAX_SHORT_LEN: usize = 40;
55
56// ── Constant-pool type tags (from lobject.h makevariant) ───────────────────
57//
58// These are the byte values written by ldump.c into the constants array.
59// makevariant(t, v) = t | (v << 4).
60//
61// The byte values used in the binary format are the raw tag integers from
62// lobject.h, distinct from LuaValue's variant tags. Defined here as u8
63// constants so the match in load_constants is self-documenting.
64
65const TAG_NIL: u8 = 0x00;
66const TAG_FALSE: u8 = 0x01;
67const TAG_TRUE: u8 = 0x11;
68const TAG_INT: u8 = 0x03;
69const TAG_FLOAT: u8 = 0x13;
70const TAG_SHORT_STR: u8 = 0x04;
71const TAG_LONG_STR: u8 = 0x14;
72
73// ── LoadState ──────────────────────────────────────────────────────────────
74
75/// Loader state bundled for convenience: Lua state, input stream, and the
76/// chunk name used in error messages.
77///
78/// Always stack-allocated inside [`undump`] and never escapes the call.
79struct LoadState<'a> {
80    state: &'a mut LuaState,
81    z: &'a mut ZIO,
82}
83
84// ── Error helper ───────────────────────────────────────────────────────────
85
86/// Build a syntax error for a malformed binary chunk.
87///
88/// Returns a `LuaError` for the caller to propagate with `?`, rather than
89/// throwing via `longjmp` as the C reference does.
90fn load_error(_s: &LoadState<'_>, why: &'static str) -> LuaError {
91    LuaError::syntax(format_args!("bad binary format ({})", why))
92}
93
94// ── Low-level I/O ──────────────────────────────────────────────────────────
95
96/// Read exactly `buf.len()` bytes from the stream into `buf`.
97///
98/// `ZIO::read` returns the number of bytes NOT read (0 = success).
99fn load_block(s: &mut LoadState<'_>, buf: &mut [u8]) -> Result<(), LuaError> {
100    if s.z.read(s.state, buf)? != 0 {
101        return Err(load_error(s, "truncated chunk"));
102    }
103    Ok(())
104}
105
106/// Read a single byte from the stream.
107fn load_byte(s: &mut LoadState<'_>) -> Result<u8, LuaError> {
108    let b = s.z.getc(s.state)?;
109    if b == crate::zio::EOZ {
110        return Err(load_error(s, "truncated chunk"));
111    }
112    Ok(b as u8)
113}
114
115/// Read a variable-length unsigned integer (7 bits per byte, big-endian,
116/// MSB-first continuation flag).
117///
118/// The encoding terminates when a byte with the high bit set is seen (the
119/// *last* byte has bit 7 = 1) — the opposite of the more common LEB128, where
120/// the continuation bit means "more follows".
121fn load_unsigned(s: &mut LoadState<'_>, limit: usize) -> Result<usize, LuaError> {
122    let mut x: usize = 0;
123    let limit = limit >> 7;
124    loop {
125        let b = load_byte(s)? as usize;
126        if x >= limit {
127            return Err(load_error(s, "integer overflow"));
128        }
129        x = (x << 7) | (b & 0x7f);
130        if (b & 0x80) != 0 {
131            break;
132        }
133    }
134    Ok(x)
135}
136
137/// Read a `size_t`-sized unsigned value.
138fn load_size(s: &mut LoadState<'_>) -> Result<usize, LuaError> {
139    load_unsigned(s, usize::MAX)
140}
141
142/// Read a signed `int`-sized value.
143fn load_int(s: &mut LoadState<'_>) -> Result<i32, LuaError> {
144    let v = load_unsigned(s, i32::MAX as usize)?;
145    Ok(v as i32)
146}
147
148/// Read a `lua_Number` (f64) as eight raw native-endian bytes.
149///
150/// The binary format is host-endian for these fields; the header check
151/// verifies endianness compatibility via the `LUAC_INT` and `LUAC_NUM`
152/// sentinels.
153fn load_number(s: &mut LoadState<'_>) -> Result<f64, LuaError> {
154    let mut buf = [0u8; 8];
155    load_block(s, &mut buf)?;
156    Ok(f64::from_ne_bytes(buf))
157}
158
159/// Read a `lua_Integer` (i64) as eight raw native-endian bytes. Same
160/// endianness reasoning as [`load_number`].
161fn load_integer(s: &mut LoadState<'_>) -> Result<i64, LuaError> {
162    let mut buf = [0u8; 8];
163    load_block(s, &mut buf)?;
164    Ok(i64::from_ne_bytes(buf))
165}
166
167fn load_raw_i32(s: &mut LoadState<'_>) -> Result<i32, LuaError> {
168    let mut buf = [0u8; 4];
169    load_block(s, &mut buf)?;
170    Ok(i32::from_ne_bytes(buf))
171}
172
173fn load_raw_u32(s: &mut LoadState<'_>) -> Result<u32, LuaError> {
174    let mut buf = [0u8; 4];
175    load_block(s, &mut buf)?;
176    Ok(u32::from_ne_bytes(buf))
177}
178
179// ── String loading ─────────────────────────────────────────────────────────
180
181/// Load a nullable string.  Returns `None` if the stored size is zero.
182///
183/// The Lua binary format stores `actual_length + 1` so that size=0 is the
184/// null-string sentinel. After reading `raw_size`, the actual byte count is
185/// `raw_size - 1`.
186///
187/// Long strings are interned through the same `intern_str` path as short
188/// strings; C creates long strings directly via `luaS_createlngstrobj`
189/// without interning them.
190///
191/// The `_proto` parameter corresponds to C's `Proto *p`, used there only for
192/// the `luaC_objbarrier(L, p, ts)` write barrier. That barrier is not invoked
193/// here.
194fn load_string_n(
195    s: &mut LoadState<'_>,
196    _proto: &LuaProto,
197) -> Result<Option<GcRef<LuaString>>, LuaError> {
198    let raw_size = load_size(s)?;
199    if raw_size == 0 {
200        return Ok(None);
201    }
202    let size = raw_size - 1;
203
204    // Read the raw bytes regardless of short/long distinction.
205    let mut buf = vec![0u8; size];
206
207    if size <= MAX_SHORT_LEN {
208        load_block(s, &mut buf)?;
209    } else {
210        load_block(s, &mut buf)?;
211    }
212
213    let ts = s.state.intern_str(&buf)?;
214
215    Ok(Some(ts))
216}
217
218/// Load a non-nullable string; error if the stream encodes a null string.
219fn load_string(s: &mut LoadState<'_>, proto: &LuaProto) -> Result<GcRef<LuaString>, LuaError> {
220    match load_string_n(s, proto)? {
221        Some(ts) => Ok(ts),
222        None => Err(load_error(s, "bad format for constant string")),
223    }
224}
225
226// ── Proto-field loaders ────────────────────────────────────────────────────
227
228/// Load the bytecode instruction array into a prototype.
229///
230/// Reads `n` raw 4-byte words in native-endian order, consistent with how
231/// [`load_number`] and [`load_integer`] work.
232fn load_code(s: &mut LoadState<'_>, f: &mut LuaProto) -> Result<(), LuaError> {
233    let n = load_int(s)? as usize;
234    let mut code = Vec::with_capacity(n);
235    for _ in 0..n {
236        let mut buf = [0u8; 4];
237        load_block(s, &mut buf)?;
238        code.push(Instruction(u32::from_ne_bytes(buf)));
239    }
240    f.code = code;
241    Ok(())
242}
243
244/// Load the constant pool into a prototype.
245///
246/// Reads the tag byte for each constant, then its payload if any.
247fn load_constants(s: &mut LoadState<'_>, f: &mut LuaProto) -> Result<(), LuaError> {
248    let n = load_int(s)? as usize;
249    let mut k = Vec::with_capacity(n);
250
251    for _ in 0..n {
252        let t = load_byte(s)?;
253        let val = match t {
254            TAG_NIL => LuaValue::Nil,
255            TAG_FALSE => LuaValue::Bool(false),
256            TAG_TRUE => LuaValue::Bool(true),
257            TAG_FLOAT => LuaValue::Float(load_number(s)?),
258            TAG_INT => LuaValue::Int(load_integer(s)?),
259
260            TAG_SHORT_STR | TAG_LONG_STR => {
261                let ts = load_string(s, f)?;
262                LuaValue::Str(ts)
263            }
264
265            _ => {
266                debug_assert!(false, "unknown constant type tag {:#04x}", t);
267                LuaValue::Nil
268            }
269        };
270        k.push(val);
271    }
272
273    f.k = k;
274    Ok(())
275}
276
277/// Load nested function prototypes into a prototype.
278///
279/// C creates the proto first, as a GC anchor, then fills it. Here a default
280/// `LuaProto` is built, filled, then wrapped in a `GcRef`.
281fn load_protos(s: &mut LoadState<'_>, f: &mut LuaProto) -> Result<(), LuaError> {
282    let n = load_int(s)? as usize;
283    let mut protos = Vec::with_capacity(n);
284
285    for _ in 0..n {
286        let mut sub = LuaProto::placeholder();
287
288        // Pass parent source as fallback.
289        let parent_source = f.source.clone();
290        load_function(s, &mut sub, parent_source)?;
291
292        // A `LuaProto` is populated field-by-field through a `&mut` local and
293        // then wrapped, because `GcRef` exposes only `Deref` (no `DerefMut`):
294        // the canonical `state.new_proto()` would hand back a placeholder that
295        // cannot be filled in place. The direct `GcRef::new` path is kept for
296        // that reason, but `mark_gc_check_needed` is invoked here so a
297        // precompiled-chunk load registers its allocations with the collector
298        // exactly as `state.new_proto()` would (issue #276).
299        s.state.mark_gc_check_needed();
300        let sub_ref = GcRef::new(sub);
301        sub_ref.account_buffer(sub_ref.buffer_bytes() as isize);
302        protos.push(sub_ref);
303    }
304
305    f.p = protos;
306    Ok(())
307}
308
309/// Load upvalue descriptors into a prototype.
310///
311/// C fills upvalue names first (`NULL`) for GC safety, then names are
312/// attached separately. Here `UpvalDesc` values are built with `name: None`
313/// and filled in later by [`load_debug`], which is why `UpvalDesc.name` is
314/// `Option<GcRef<LuaString>>` rather than a bare `GcRef<LuaString>`.
315fn load_upvalues(s: &mut LoadState<'_>, f: &mut LuaProto) -> Result<(), LuaError> {
316    let n = load_int(s)? as usize;
317
318    let mut upvalues = Vec::with_capacity(n);
319    for _ in 0..n {
320        let instack_raw = load_byte(s)?;
321        let idx = load_byte(s)?;
322        let kind = load_byte(s)?;
323
324        upvalues.push(UpvalDesc {
325            name: None, // filled by load_debug
326            instack: instack_raw != 0,
327            idx,
328            kind,
329        });
330    }
331
332    f.upvalues = upvalues;
333    Ok(())
334}
335
336/// Load debug information into a prototype.
337///
338/// `lineinfo` is `ls_byte` (a signed byte) in C; each byte is read as `u8`
339/// then cast to `i8`, which is safe since the two share the same in-memory
340/// representation. `LocalVar.varname` and `UpvalDesc.name` are both
341/// `Option<GcRef<LuaString>>` here because `loadStringN` can return `None`;
342/// see also the note on [`load_upvalues`].
343fn load_debug(s: &mut LoadState<'_>, f: &mut LuaProto) -> Result<(), LuaError> {
344    let n = load_int(s)? as usize;
345    let mut lineinfo = vec![0i8; n];
346    for item in lineinfo.iter_mut() {
347        *item = load_byte(s)? as i8;
348    }
349    f.lineinfo = lineinfo;
350
351    let n = load_int(s)? as usize;
352    let mut abslineinfo = Vec::with_capacity(n);
353    for _ in 0..n {
354        abslineinfo.push(AbsLineInfo {
355            pc: load_int(s)?,
356            line: load_int(s)?,
357        });
358    }
359    f.abslineinfo = abslineinfo;
360
361    let n = load_int(s)? as usize;
362
363    let mut locvars = Vec::with_capacity(n);
364    for _ in 0..n {
365        let varname = load_string_n(s, f)?;
366        let startpc = load_int(s)?;
367        let endpc = load_int(s)?;
368        let varname = match varname {
369            Some(v) => v,
370            None => s.state.new_string(b"")?,
371        };
372        locvars.push(LocalVar {
373            varname,
374            startpc,
375            endpc,
376        });
377    }
378    f.locvars = locvars;
379
380    // If n == 0 there is no upvalue name info (stripped).
381    let has_names = load_int(s)?;
382    if has_names != 0 {
383        let n_upvals = f.upvalues.len();
384        for i in 0..n_upvals {
385            let name = load_string_n(s, f)?;
386            f.upvalues[i].name = name;
387        }
388    }
389
390    Ok(())
391}
392
393// ── Function loader ────────────────────────────────────────────────────────
394
395/// Load a complete function prototype from the stream.
396///
397/// `psource` is `None` at the top level; a nested prototype with no source of
398/// its own inherits the parent's, expressed here by falling back to
399/// `psource` when `loadStringN` returns `None`.
400fn load_function(
401    s: &mut LoadState<'_>,
402    f: &mut LuaProto,
403    psource: Option<GcRef<LuaString>>,
404) -> Result<(), LuaError> {
405    let source = load_string_n(s, f)?;
406    f.source = source.or(psource);
407
408    f.linedefined = load_int(s)?;
409    f.lastlinedefined = load_int(s)?;
410    f.numparams = load_byte(s)?;
411    f.is_vararg = load_byte(s)? != 0;
412    f.maxstacksize = load_byte(s)?;
413    load_code(s, f)?;
414    reconstruct_vararg_table_reg(f);
415    load_constants(s, f)?;
416    load_upvalues(s, f)?;
417    load_protos(s, f)?;
418    load_debug(s, f)?;
419
420    Ok(())
421}
422
423/// Recover `LuaProto.vararg_table_reg` from the loaded bytecode instead of from
424/// the wire format, so a precompiled chunk keeps Lua 5.5 named-vararg aliasing
425/// (`function f(...t)`) without lua-rs's `string.dump` output diverging from
426/// C's bytecode layout (which the structural oracle compares).
427///
428/// A named-vararg function emits exactly one `OP_VARARGPACK` (opcode 84) at
429/// entry; its A operand is the register holding the shared vararg table. Its
430/// k bit records whether the table must be materialized.
431fn reconstruct_vararg_table_reg(f: &mut LuaProto) {
432    const OP_VARARGPACK: u32 = 84;
433    const OPCODE_MASK: u32 = 0x7F;
434    const POS_K: u32 = 15;
435    if let Some((reg, needed)) = f.code.iter().find_map(|inst| {
436        let raw = inst.raw();
437        (raw & OPCODE_MASK == OP_VARARGPACK).then(|| {
438            let reg = ((raw >> 7) & 0xFF) as u8;
439            let needed = ((raw >> POS_K) & 1) != 0;
440            (reg, needed)
441        })
442    }) {
443        f.vararg_table_reg = Some(reg);
444        f.vararg_table_needed = needed;
445    }
446}
447
448// ── Header validation ──────────────────────────────────────────────────────
449
450/// Verify that the next `expected.len()` bytes in the stream match `expected`.
451fn check_literal(
452    s: &mut LoadState<'_>,
453    expected: &[u8],
454    msg: &'static str,
455) -> Result<(), LuaError> {
456    let mut buf = vec![0u8; expected.len()];
457    load_block(s, &mut buf)?;
458    if buf != expected {
459        return Err(load_error(s, msg));
460    }
461    Ok(())
462}
463
464/// Verify that the next byte in the stream equals `expected_size`. `tname` is
465/// always a Rust type-name string literal (ASCII) from the call sites.
466fn fcheck_size(
467    s: &mut LoadState<'_>,
468    expected_size: usize,
469    tname: &'static str,
470) -> Result<(), LuaError> {
471    let b = load_byte(s)? as usize;
472    if b != expected_size {
473        return Err(LuaError::syntax(format_args!("{} size mismatch", tname)));
474    }
475    Ok(())
476}
477
478/// Validate the binary chunk header.
479///
480/// The three fixed-size checks below cover `Instruction` (4 bytes, u32),
481/// `lua_Integer` (8 bytes, i64), and `lua_Number` (8 bytes, f64).
482///
483/// The first byte of `LUA_SIGNATURE` (`\x1b`) is already consumed by the
484/// caller before `check_header` is invoked, so only bytes 1.. of the
485/// signature (`"Lua"`) are checked here.
486fn check_header(s: &mut LoadState<'_>) -> Result<(), LuaError> {
487    // Skip LUA_SIGNATURE[0] (\x1b) — already consumed by the caller.
488    check_literal(s, &LUA_SIGNATURE[1..], "not a binary chunk")?;
489
490    let version = s.state.global().lua_version;
491    let expected_version = match version {
492        LuaVersion::V51 => LUAC_VERSION_51,
493        LuaVersion::V52 => LUAC_VERSION_52,
494        LuaVersion::V53 => LUAC_VERSION_53,
495        LuaVersion::V55 => LUAC_VERSION_55,
496        _ => LUAC_VERSION_54,
497    };
498    let ver = load_byte(s)?;
499    if ver != expected_version {
500        return Err(load_error(s, "version mismatch"));
501    }
502
503    let fmt = load_byte(s)?;
504    if fmt != LUAC_FORMAT {
505        return Err(load_error(s, "format mismatch"));
506    }
507
508    match version {
509        LuaVersion::V51 => {
510            check_legacy_sizes(s)?;
511        }
512        LuaVersion::V52 => {
513            check_legacy_sizes(s)?;
514            check_literal(s, LUAC_DATA, "corrupted chunk")?;
515        }
516        LuaVersion::V53 => {
517            check_literal(s, LUAC_DATA, "corrupted chunk")?;
518            fcheck_size(s, size_of::<i32>(), "int")?;
519            fcheck_size(s, size_of::<usize>(), "size_t")?;
520            fcheck_size(s, 4, "Instruction")?;
521            fcheck_size(s, 8, "lua_Integer")?;
522            fcheck_size(s, 8, "lua_Number")?;
523            if load_integer(s)? != LUAC_INT {
524                return Err(load_error(s, "integer format mismatch"));
525            }
526            if load_number(s)? != LUAC_NUM {
527                return Err(load_error(s, "float format mismatch"));
528            }
529        }
530        LuaVersion::V55 => {
531            check_literal(s, LUAC_DATA, "corrupted chunk")?;
532            fcheck_size(s, 4, "int")?;
533            if load_raw_i32(s)? != LUAC_INT_55 as i32 {
534                return Err(load_error(s, "int format mismatch"));
535            }
536
537            fcheck_size(s, 4, "instruction")?;
538            if load_raw_u32(s)? != LUAC_INST_55 {
539                return Err(load_error(s, "instruction format mismatch"));
540            }
541
542            fcheck_size(s, 8, "Lua integer")?;
543            if load_integer(s)? != LUAC_INT_55 {
544                return Err(load_error(s, "Lua integer format mismatch"));
545            }
546
547            fcheck_size(s, 8, "Lua number")?;
548            if load_number(s)? != LUAC_NUM_55 {
549                return Err(load_error(s, "Lua number format mismatch"));
550            }
551        }
552        _ => {
553            check_literal(s, LUAC_DATA, "corrupted chunk")?;
554            fcheck_size(s, 4, "Instruction")?;
555
556            fcheck_size(s, 8, "lua_Integer")?;
557
558            fcheck_size(s, 8, "lua_Number")?;
559
560            let int_check = load_integer(s)?;
561            if int_check != LUAC_INT {
562                return Err(load_error(s, "integer format mismatch"));
563            }
564
565            let num_check = load_number(s)?;
566            if num_check != LUAC_NUM {
567                return Err(load_error(s, "float format mismatch"));
568            }
569        }
570    }
571
572    Ok(())
573}
574
575/// Validate the 5.1/5.2 endianness + size + integral-flag block: endian = 1
576/// (little), `sizeof(int)` = 4, `sizeof(size_t)`, `sizeof(Instruction)` = 4,
577/// `sizeof(lua_Number)` = 8, integral = 0. These versions have no integer
578/// subtype, so there is no `lua_Integer` size byte and no `LUAC_INT`/`LUAC_NUM`
579/// sentinel.
580fn check_legacy_sizes(s: &mut LoadState<'_>) -> Result<(), LuaError> {
581    if load_byte(s)? != 1 {
582        return Err(load_error(s, "endianness mismatch"));
583    }
584    fcheck_size(s, size_of::<i32>(), "int")?;
585    fcheck_size(s, size_of::<usize>(), "size_t")?;
586    fcheck_size(s, 4, "Instruction")?;
587    fcheck_size(s, 8, "lua_Number")?;
588    if load_byte(s)? != 0 {
589        return Err(load_error(s, "number format mismatch"));
590    }
591    Ok(())
592}
593
594// ── Public entry point ─────────────────────────────────────────────────────
595
596/// Load a precompiled Lua chunk and return the top-level Lua closure.
597///
598/// This is the Rust equivalent of `luaU_undump` — the single public function
599/// exported by `lundump.c`.
600///
601/// # Parameters
602/// - `state` — the Lua thread state.
603/// - `z` — input stream positioned at the start of the binary chunk
604///   (the first byte `\x1b` of `LUA_SIGNATURE` must still be present).
605/// - `name` — chunk name for error messages.  Stripped per Lua convention:
606///   - `@…` → filename (strip `@`)
607///   - `=…` → literal name (strip `=`)
608///   - starts with `\x1b` → `"binary string"`
609///   - otherwise used as-is.
610///
611/// Unlike the C reference — which anchors the half-built closure on the stack
612/// (`setclLvalue2s` + `luaD_inctop`) so a mid-load emergency collection cannot
613/// sweep it — no stack anchoring is needed here: `protected_parser` stops the
614/// collector for the entire load window, and the closure is returned by value
615/// (`do_.rs::f_parser` pushes it onto the stack after undump returns, which is
616/// the real anchor point). `luai_verifycode`, a no-op in the default C build,
617/// has no equivalent call here.
618pub(crate) fn undump(
619    state: &mut LuaState,
620    z: &mut ZIO,
621    _name: &[u8],
622) -> Result<GcRef<LuaLClosure>, LuaError> {
623    let mut s = LoadState { state, z };
624
625    check_header(&mut s)?;
626
627    // Reads the number of upvalues for the top-level closure.
628    let nupvalues = load_byte(&mut s)? as usize;
629
630    let mut proto = LuaProto::placeholder();
631
632    load_function(&mut s, &mut proto, None)?;
633
634    // Build-then-wrap the top proto (see the note in `load_protos` on why the
635    // direct `GcRef::new` path is kept and `mark_gc_check_needed` fired here).
636    s.state.mark_gc_check_needed();
637    let proto_ref = GcRef::new(proto);
638    proto_ref.account_buffer(proto_ref.buffer_bytes() as isize);
639
640    debug_assert_eq!(
641        nupvalues,
642        proto_ref.upvalues.len(),
643        "upvalue count mismatch between closure header and prototype"
644    );
645
646    // Route the closure through the canonical constructor: it fires
647    // `mark_gc_check_needed`, fills the `nupvalues` upvalue slots with fresh
648    // closed nil upvalues (Rust's `Cell<GcRef<UpVal>>` slots are non-nullable,
649    // so C's `luaF_newLclosure`-with-NULL-slots followed by a later
650    // `luaF_initupvals` fill collapses into this one construction step), and
651    // accounts the closure's owned buffer (issue #276).
652    let cl_ref = s.state.new_lclosure(proto_ref, nupvalues);
653
654    Ok(cl_ref)
655}