Skip to main content

luna_core/vm/
exec.rs

1// CARVE-OUT: pre-existing god file, shrinking on every touch
2//! The interpreter. Dispatch is a plain match over opcodes. Lua→Lua calls
3//! share one loop and never recurse the Rust stack; only native↔Lua boundaries do (e.g. pcall).
4//!
5//! Varargs follow 5.5 semantics: a vararg call materializes a vararg table
6//! (fields 1..n plus "n") kept in the function's own stack slot; `...`
7//! expands from it and `...name` binds it. 5.1 LUAI_COMPAT_VARARG also
8//! materializes a local `arg` table (see `proto.has_compat_vararg_arg`).
9
10use crate::frontend::SyntaxError;
11use crate::jit::send_compat::TArc;
12use crate::numeric::{self, Num};
13use crate::runtime::heap::GcHeader;
14use crate::runtime::{
15    AfterClose, CallFrame, CloseCont, ContKind, Coro, CoroStatus, Frame, Gc, Heap, LuaClosure,
16    MetaAction, MetaCont, NativeClosure, NativeCont, Table, TableError, UpvalState, Upvalue, Value,
17};
18use crate::version::LuaVersion;
19use crate::vm::builtins::{nat_host_xpcall, nat_pairs, nat_pcall, nat_xpcall};
20use crate::vm::callstack::DbgKind;
21use crate::vm::error::LuaError;
22use crate::vm::isa::{Inst, Op};
23
24mod num;
25use num::*;
26pub(crate) use num::{ArithOp, arith_num, str_to_num};
27
28/// A Lua virtual machine: one OS thread's worth of Lua state.
29///
30/// # Threading model
31///
32/// `Vm` is **`!Send + !Sync`**. The GC uses `Gc<T> = NonNull<T>` over
33/// an intrusive mark-sweep heap (not `Rc<RefCell<T>>`), and the trace
34/// JIT side-table uses `Rc<CompiledTrace>` — both single-threaded by
35/// design. Embedders that want concurrency spawn one `Vm` per OS
36/// thread (or per single-thread Tokio worker) and exchange data via
37/// channels. See [`docs/threading.md`](../../docs/threading.md) for
38/// canonical embedding patterns including Tokio `current_thread`,
39/// `LocalSet` on multi-thread, and `Vm`-per-OS-thread + channels.
40///
41/// The constraint is enforced at compile time:
42///
43/// ```compile_fail
44/// fn must_be_send<T: Send>() {}
45/// must_be_send::<luna_core::Vm>(); // error[E0277]: `Vm` cannot be sent between threads safely
46/// ```
47///
48/// A future `feature = "send"` will gate an
49/// opt-in `Arc<RwLock<T>>` mode with a hard ≤8% perf regression
50/// budget.
51pub struct Vm {
52    /// The GC heap owned by this VM. Embedders normally interact via the
53    /// `Vm` methods (`load` / `call_value` / `set_global` / …) rather than
54    /// the heap directly.
55    pub heap: Heap,
56    pub(crate) stack: Vec<Value>,
57    pub(crate) frames: Vec<CallFrame>,
58    /// How many `__call` metamethods were resolved to reach each Lua frame,
59    /// by index into `frames`; each adds one argument (PUC 5.5 `CIST_CCMT`
60    /// bits, reported as `getinfo("t").extraargs`). Written whenever a Lua
61    /// frame is pushed, so the entry for a live frame is always its own;
62    /// entries past `frames.len()` are stale and never read. Kept beside
63    /// `frames` rather than in `Frame` because `Frame` is public with all
64    /// fields public, where a new field is a breaking change.
65    pub(crate) frame_ccmt: Vec<u8>,
66    /// Shadow of `self.frames.len()`. Synced on every push/pop in the
67    /// `frames_push_sync`/`frames_pop_sync` helpers (debug-asserted on
68    /// use). Not consumed by readers yet; it is scaffolding for replacing
69    /// `frames: Vec<CallFrame>` with a flat `[CallFrame; MAX_FRAMES]`
70    /// indexed by frames_top.
71    frames_top: u32,
72    /// open upvalues, sorted ascending by stack slot
73    open_upvals: Vec<(u32, Gc<Upvalue>)>,
74    /// to-be-closed slots, ascending
75    tbc: Vec<u32>,
76    /// logical stack top for multi-result sequences
77    pub(crate) top: u32,
78    globals: Gc<Table>,
79    /// shared metatable for all strings (populated by the string lib)
80    /// per-basic-type metatables (PUC luaT): indexed by `type_mt_slot`
81    /// (0 nil, 1 boolean, 2 number, 3 string, 4 function); tables carry their
82    /// own. Settable via debug.setmetatable.
83    type_mt: [Option<Gc<Table>>; 5],
84    /// pre-interned metamethod event names, indexed by `Mm`
85    mm_names: Vec<Gc<crate::runtime::LuaStr>>,
86    /// native↔Lua nesting depth (PUC C-stack guard analogue)
87    c_depth: u32,
88    /// number of live pcall/xpcall continuation frames on the running thread
89    /// (PUC counts these against nCcalls). Bounds protected-call recursion the
90    /// way `c_depth` bounds call_value recursion. Per-thread: saved/restored
91    /// with the coroutine context, since continuations survive a yield.
92    pcall_depth: u32,
93    /// number of non-yieldable C calls in flight on the running thread (PUC's
94    /// `L->nny`). A library callback that runs via synchronous Rust recursion
95    /// (sort comparator, gsub replacement) cannot be continued across a yield,
96    /// so it bumps this for its duration; `coroutine.yield` inside hits the
97    /// C-call boundary and errors. Always 0 at a suspend point (a yield can
98    /// never cross such a call), so it needs no per-thread save/restore.
99    nny: u32,
100    /// Nonzero while an xpcall message handler is on the Rust stack. Used so a
101    /// stack-overflow that surfaces *inside* the handler is reported as PUC's
102    /// "error in error handling" (LUA_ERRERR + `luaD_seterrorobj`), not the
103    /// plain "stack overflow" — errors.lua :606's `checkerr("error handling",
104    /// loop)` then matches. PUC tracks this via the soft-cap window
105    /// `nCcalls >= MAXCCALLS/10*11`; luna's c_depth is strict, so we mark the
106    /// scope explicitly.
107    pub(crate) msgh_depth: u32,
108    /// set by a coroutine closing itself (`coroutine.close()` on the running
109    /// thread): the to-be-closed handlers have already run; the thread must now
110    /// terminate. `Some(None)` is a clean close, `Some(Some(e))` a handler
111    /// raised `e`. Checked by `exec_with`/`resume_coro` to propagate (not
112    /// unwind, so a protecting pcall cannot catch it) the termination.
113    terminating: Option<Option<Value>>,
114    /// xoshiro256** state (math.random)
115    rng: [u64; 4],
116    /// VM creation time (os.clock)
117    started: std::time::Instant,
118    version: LuaVersion,
119    /// error object being threaded through a chain of __close handlers; a GC
120    /// root for the duration (a handler may trigger collection)
121    closing_err: Option<Value>,
122    /// the coroutine whose context is currently live in the fields above;
123    /// `None` while the main thread runs
124    pub(crate) current: Option<Gc<crate::runtime::Coro>>,
125    /// the main thread's saved execution context while a coroutine runs
126    main_ctx: Option<SavedCtx>,
127    /// set by `coroutine.yield` to suspend the running coroutine: the yielded
128    /// values plus the slot/result-count needed to finish the yielding call on
129    /// the next resume. Checked by `exec` to propagate (not unwind) on yield.
130    yielding: Option<(Vec<Value>, u32, i32)>,
131    /// results expected by the in-flight native call (so `yield` knows how many
132    /// values its call site wants when it suspends)
133    native_nresults: i32,
134    /// identity object for the main thread, returned by `coroutine.running`
135    /// (the main thread's context lives in the VM fields / `main_ctx`, not here)
136    main_coro: Option<Gc<Coro>>,
137    /// `collectgarbage` mode name ("incremental"/"generational"). The collector
138    /// itself is still stop-the-world mark-sweep; this tracks the mode so mode
139    /// switches report the previous one, as PUC does.
140    gc_mode: &'static str,
141    /// the live-register boundary of the running thread for GC rooting (PUC's
142    /// `L->top`): set precisely at each GC safe point so freed temporary
143    /// registers above it are not rooted. Without this the collector roots the
144    /// whole stack window, pinning weak-table values stranded in stale temps
145    /// (e.g. closure.lua's `while x[1]` GC-detection loop).
146    pub(crate) gc_top: u32,
147    /// `collectgarbage("param", name [,value])` pacing parameters. The collector
148    /// is still stop-the-world, so these are stored/returned for API fidelity
149    /// (PUC round-trips them via `setparam`/`getparam`). Defaults mirror PUC's
150    /// `LUAI_GC*` knobs: pause=200, stepmul=100, stepsize=13.
151    gc_pause: i64,
152    gc_stepmul: i64,
153    gc_stepsize: i64,
154    /// `collectgarbage`'s parameters as the dialect stores them; they set
155    /// the three knobs above through `set_gc_pacing`.
156    pub(crate) gc_params: crate::vm::lib_gc::GcParams,
157    /// true while `__gc` finalizers are being run, so a finalizer that calls
158    /// `collectgarbage` gets a no-op (PUC's non-reentrancy: lua_gc returns -1 →
159    /// `collectgarbage` yields fail).
160    gc_finalizing: bool,
161    /// C ABI scratch (`capi` module): the host-visible value stack that C
162    /// callers operate on via `lua_pushinteger` / `lua_tostring` / etc.
163    /// Kept here (instead of in a separate `LuaState` wrapper) so the
164    /// trampoline that bridges to a `LuaCFunction` can safely cast the
165    /// Vm pointer it already holds to the public `*mut LuaState` type
166    /// without any aliasing of `&mut Vm` against `&mut LuaState.vm`.
167    pub capi_stack: Vec<crate::runtime::Value>,
168    /// Pinned CString backing the pointer last returned by `lua_tostring`;
169    /// valid until the next `lua_tostring` on the same Vm.
170    pub capi_cstr_pin: Option<std::ffi::CString>,
171    /// PUC 5.4+ warning system. Lua manual §6.1 `warn`: emitted messages
172    /// concatenate across continuation calls until a non-`tocont` call
173    /// flushes; the default warnf recognises `@on`/`@off` control messages
174    /// and starts disabled. luna's `emit_warn` mirrors the default warnf
175    /// behaviour and 5.4+ `__gc` errors are routed through it (5.1–5.3
176    /// keep the older raise semantics).
177    pub(crate) warn_state: WarnState,
178    pub(crate) warn_buf: Vec<u8>,
179    /// Embedding cooperative budget: a per-Vm tick counter that the run
180    /// loop decrements once per dispatch turn. When it hits zero the loop
181    /// raises a catchable "instruction budget exceeded" error so the embedder
182    /// can yield control back to its caller (short-script eval, game
183    /// frame budgets). `None` = unbounded; reset on each call via
184    /// `set_instr_budget`.
185    pub(crate) instr_budget: Option<i64>,
186    // JIT-specific state lives in the `JitState` sidecar; see `self.jit`
187    // below and `crate::vm::jit_state` for field docs.
188    /// Bytecode-loading gate. Default `true`. Sandbox embedders should
189    /// call `set_bytecode_loading(false)` so `load`/`loadstring` reject
190    /// precompiled chunks (which bypass the parser's depth / opcode
191    /// limits). When `false`, the loader rejects any source whose first
192    /// byte is the bytecode signature `\27` ("`\27Lua`").
193    pub(crate) bytecode_loading: bool,
194    /// PUC bytecode-loading gate. Default `false` — PUC `.luac` files are
195    /// a strictly larger trust surface than luna's own dump format
196    /// (third-party toolchain bugs, malformed chunks, unknown opcode
197    /// shapes). When `true`, the loader routes `\x1bLua\x{51..55}` inputs
198    /// through the per-dialect PUC translators in `crate::vm::dump::puc`.
199    /// Embedder toggles via `set_puc_bytecode_loading`.
200    pub(crate) puc_bytecode_loading: bool,
201    /// Byte budget for source fed into `load` / `loadstring` / `Vm::load`.
202    /// Default [`Vm::DEFAULT_LOADER_INPUT_BUDGET`] (256 MiB). When the
203    /// accumulated reader output (`load(f, ...)`) or a one-shot `&[u8]`
204    /// source exceeds this, the loader returns the PUC-shaped
205    /// `not enough memory` error before the host allocator is asked to
206    /// hold the next chunk. Defends against `heavy.lua::loadrep`-style
207    /// 7 GB+ feeder loops that would otherwise SIGSEGV when `Vec::push`
208    /// crosses `isize::MAX` or the host runs out of RAM.
209    /// Embedders that genuinely need to load > 256 MiB sources widen the
210    /// cap via [`Vm::set_loader_input_budget`].
211    pub(crate) loader_input_budget: usize,
212    /// In-process log of fully-emitted warnings (each entry = one flushed
213    /// message, sans the "Lua warning: " prefix and trailing newline). Lets
214    /// tests assert what was warned without scraping stderr.
215    pub(crate) warn_log: Vec<Vec<u8>>,
216    /// PUC's `LUA_REGISTRYINDEX` table — a single Lua table the debug library
217    /// exposes via `debug.getregistry`. Used to hold `_HOOKKEY` (the weak-key
218    /// table PUC's `db_sethook` keys per-thread hooks under). luna stores hook
219    /// state directly in `Vm.hook`/`Coro.hook`, so the entry is largely a
220    /// shape stub for db.lua :328; if other registry-keyed APIs land later
221    /// they can share this table.
222    pub(crate) registry: Option<Gc<Table>>,
223    /// the shared `FILE*` metatable for io file handles (PUC's LUA_FILEHANDLE
224    /// registry entry); attached to every file userdata the io library makes
225    pub(crate) file_mt: Option<Gc<Table>>,
226    /// io library default input/output streams (PUC registry IO_INPUT/IO_OUTPUT)
227    pub(crate) io_input: Option<Gc<crate::runtime::Userdata>>,
228    pub(crate) io_output: Option<Gc<crate::runtime::Userdata>>,
229    /// `io.stdin` as the io library made it, whatever the script later does
230    /// to the `io` table: the stream a host's line reads share
231    pub(crate) io_stdin: Option<Gc<crate::runtime::Userdata>>,
232    /// lua.c's `-E`: libraries opened from now on ignore the environment
233    pub(crate) ignore_env: bool,
234    /// the running thread's debug hook state (`debug.sethook`); per-thread,
235    /// swapped with the execution context on a coroutine resume/yield
236    pub(crate) hook: HookState,
237    /// true while the hook itself runs, so its own execution fires no events
238    /// (PUC clears the mask for the duration)
239    pub(crate) in_hook: bool,
240    /// arms the next Lua frame's `tailcalls` count (PUC `ci->u.l.tailcalls`),
241    /// consumed by `push_frame`. `OP_TailCall` sets it to the caller's
242    /// own tailcalls + 1 before begin_call so deeply tail-recursive chains
243    /// accumulate the count instead of capping at 1.
244    pub(crate) pending_tailcalls: u32,
245    /// arms the next Lua frame's `ccmt` (its `__call` chain length), consumed
246    /// by `push_frame`. `OP_TailCall` sets it to the reused activation's
247    /// count; `begin_call` otherwise sets the chain it just resolved.
248    pending_ccmt: u8,
249    /// Name of the C native that just propagated an error (captured before
250    /// the native is popped from `running_natives`). Lets a dying coroutine
251    /// preserve `[C]: in function '<name>'` at the top of its traceback
252    /// snapshot — PUC walks `luaG_funcnamefrompc` over a still-live ci, but
253    /// luna's native frames are off-stack so we stash the name explicitly.
254    pub(crate) errored_natives: Vec<crate::vm::callstack::ErroredNative>,
255    /// Frames below this index are out of reach of the error handler of
256    /// an `xpcall` (PUC `L->errfunc`): a protected call made from Rust — a
257    /// finalizer, the handler itself — starts a fresh `errfunc` scope.
258    pub(crate) msgh_floor: usize,
259    /// The message handler that is running, if any. PUC's `luaG_errormsg`
260    /// calls the handler with `L->errfunc` still set, so an error inside
261    /// the handler (and not caught within it) calls the handler again, at
262    /// the point of that error. Per thread, like `L->errfunc`.
263    pub(crate) msgh_running: Option<Value>,
264    /// How many message-handler runs have started; lets a run tell whether
265    /// the error it got back was already handled by a nested run.
266    pub(crate) msgh_runs: u64,
267    /// The value the last `xpcall` handler produced for the error in
268    /// flight, so the unwind that carries it to the `xpcall` does not
269    /// run the handler again.
270    pub(crate) msgh_applied: Option<Value>,
271    /// Whether an error nothing catches should keep its traceback: not
272    /// inside a protected call made from Rust, which discards it.
273    pub(crate) keep_error_traceback: bool,
274    /// PUC `CallInfo.u2.transferinfo`: index of the first transferred value
275    /// (relative to the activation's func slot) and the number transferred.
276    /// Set just before firing a call/return hook, read by `getinfo("r")`.
277    pub(crate) hook_ftransfer: u16,
278    pub(crate) hook_ntransfer: u16,
279    /// metamethod event tag (e.g. "close") to attach to the next Lua frame
280    /// pushed by `push_frame`; `close_slots` sets this before calling a
281    /// `__close` handler so `debug.traceback` names it "metamethod 'close'"
282    /// (PUC `CallInfo.u.l.tm`). Single-shot: `push_frame` consumes it.
283    pending_tm: Option<&'static str>,
284    /// `true` when the next `push_frame` is the user hook function itself,
285    /// so `debug.getinfo(1).namewhat` resolves to `"hook"` (PUC
286    /// `CIST_HOOKED`). `run_hook` arms it before dispatching the hook.
287    pending_is_hook: bool,
288    /// traceback of an error nothing in its thread catches, one line per
289    /// stack level, taken where it was raised (see `raise_to_handler`): what
290    /// the host gets from `take_error_traceback`, and what `debug.traceback`
291    /// shows of the coroutine it kills. Cleared on a catch and at host-level
292    /// `call_value` entry (`public_call_depth == 0`).
293    pub(crate) error_traceback: Option<Vec<Vec<u8>>>,
294    /// nesting depth of public `call_value` entries (host vs. internal). The
295    /// outermost entry (depth 0) resets per-error state (`error_traceback`);
296    /// internal calls (e.g. xpcall msgh, sort callback) preserve it.
297    public_call_depth: u32,
298    /// stack of native (`Value::Native`) closures currently running on the
299    /// Rust call stack. `begin_call` pushes the closure before invoking
300    /// `nc.f` and pops on return. Used by `arg_error` to detect a *nested*
301    /// native call (PUC `ar.name == NULL` at level 0 because the level-0
302    /// caller is C, not Lua) and qualify the running function's name via
303    /// `pushglobalfuncname` (e.g. `'sort'` → `'table.sort'`).
304    pub(crate) running_natives: Vec<Gc<NativeClosure>>,
305    /// Parallel to `running_natives`: where each native sits on the value
306    /// and frame stacks, so the debug interface can place it among the Lua
307    /// activations as PUC's CallInfo chain would (see `callstack`).
308    pub(crate) running_native_acts: Vec<crate::vm::callstack::NativeAct>,
309    /// Index into `running_natives` where the running thread's own natives
310    /// begin; the ones below belong to the threads that resumed it.
311    pub(crate) natives_base: usize,
312    /// JIT sidecar. Always present (never `Option`); inert
313    /// when `chunk_compiler` / `trace_compiler` are
314    /// [`crate::jit::NullJitBackend`]. See [`crate::vm::jit_state`].
315    ///
316    /// `#[doc(hidden)] pub` so the `luna` crate's
317    /// `extern "C"` JIT helpers can write `vm.jit.pending_err`
318    /// directly. Not part of the embedder-facing API surface.
319    #[doc(hidden)]
320    pub jit: crate::vm::jit_state::JitState,
321
322    /// Host roots — a `Vec<Value>` traced as an extra GC root set.
323    /// `Lua` facade handles (`LuaFunction`, `LuaTable`, `LuaRoot`) hold
324    /// indices into this vector so the underlying `Gc<T>` stays alive
325    /// across `eval` calls / yield boundaries. Freed slots are recycled
326    /// through `host_roots_free`.
327    pub(crate) host_roots: Vec<crate::vm::host_roots::HostRootSlot>,
328    /// Recycled-slot index pool. `pin_host` pops the
329    /// back if non-empty, else extends `host_roots`. Generation
330    /// overflow at `u32::MAX` retires the slot (NOT pushed here).
331    pub(crate) host_roots_free: Vec<u32>,
332
333    /// GC-rooted scratch stack for `table.sort` (and any other
334    /// builtin that needs a Rust-side `Vec<Value>` to outlive a user
335    /// callback). Each entry is one in-flight working buffer; `gc_roots`
336    /// extends with every contained `Value` so a `collectgarbage()`
337    /// inside the comparator cannot free strings/tables snapshotted
338    /// here. Nested sorts push a new buffer on entry, pop on exit
339    /// (sort.lua's `load(..)(); collectgarbage()` compare callback
340    /// regression).
341    pub(crate) sort_scratch: Vec<Vec<Value>>,
342
343    /// MacroLua compile-time macro registry.
344    /// Pre-populated with built-in macros (`@quote` / `@unquote` /
345    /// `@if` / `@gensym`) at construction time when `version ==
346    /// LuaVersion::MacroLua`; embedders register custom macros via
347    /// [`Vm::define_macro`]. The expander runs once per `load()` call
348    /// between lexing and parsing (only when `is_macro_lua()`).
349    pub(crate) macro_registry: crate::frontend::macro_expander::MacroRegistry,
350
351    /// Per-Vm cache of `Gc<Table>` metatables keyed
352    /// by `TypeId::of::<T>()` for embedder types implementing
353    /// [`crate::vm::userdata_trait::LuaUserdata`]. Populated lazily by
354    /// [`Vm::register_userdata`]; metatables are pinned via
355    /// [`Vm::pin_host`] at registration time so the entry's
356    /// `Gc<Table>` stays live for the rest of the Vm's lifetime.
357    pub(crate) userdata_metatables:
358        std::collections::HashMap<std::any::TypeId, Gc<crate::runtime::table::Table>>,
359
360    /// Classification of the most recent error raised on this Vm.
361    /// Embedders read via [`Vm::error_kind`]; the dispatcher sets it
362    /// at well-known sites (syntax errors, instr-budget trips, native
363    /// callback errors, type errors).
364    pub(crate) last_error_kind: crate::vm::error::LuaErrorKind,
365
366    /// `(source_name, line)` of the most recent error. Set by the
367    /// dispatcher / lexer / parser; cleared when a new call_value
368    /// enters cleanly.
369    pub(crate) last_error_source: Option<(String, u32)>,
370
371    /// When `true`, `instr_budget` exhaustion in
372    /// the dispatcher hot loop yields cooperatively (sets
373    /// [`Vm::host_yield_pending`] + returns a sentinel `Err` walked up
374    /// to `EvalFuture::poll`) instead of returning a real
375    /// "instruction budget exceeded" error. Set by [`Vm::eval_async`]
376    /// for the duration of the future; restored to `false` on
377    /// `Poll::Ready`. The sync `Vm::eval` / `Vm::call_value` paths
378    /// leave it `false` so budget exhaustion stays a real error there.
379    pub(crate) async_mode: bool,
380
381    /// Host waker cloned by `EvalFuture::poll` before driving a slice.
382    /// The dispatcher itself does not call it (the future's poll loop
383    /// does `wake_by_ref` after observing `BudgetExhausted`); it is kept
384    /// so async natives can wake the host directly from a helper future.
385    pub(crate) async_waker: Option<std::task::Waker>,
386
387    /// Per-poll opcode quota loaded into
388    /// `instr_budget` at the start of each `EvalFuture::poll` slice.
389    /// Default 10_000. Tunable via
390    /// [`Vm::set_async_slice`].
391    pub(crate) async_slice_size: i64,
392
393    /// Set by the dispatcher when an async-mode
394    /// budget exhaustion fires; checked by `exec_with` (so the
395    /// sentinel propagates without `unwind` running, mirroring
396    /// `yielding.is_some()`) and by `call_value_impl` (so the call
397    /// frames survive for the next poll). Cleared by `drive_one`
398    /// after translating it to `DispatchOutcome::BudgetExhausted`.
399    pub(crate) host_yield_pending: bool,
400
401    /// Set by the dispatcher's native-call path
402    /// when an async-marked [`NativeClosure`] is invoked under
403    /// `async_mode`. The Vm pauses the dispatcher (same sentinel-Err
404    /// mechanism as `host_yield_pending` — see `exec_with` +
405    /// `call_value_impl`), stashes the in-flight future +
406    /// post-completion context here, and surfaces them to
407    /// `EvalFuture::poll` via `drive_one`. Cleared by `drive_one`
408    /// once the future is moved out into a
409    /// `DispatchOutcome::AsyncNativeAwaiting`.
410    pub(crate) pending_async_native_fut:
411        Option<std::pin::Pin<Box<dyn std::future::Future<Output = Result<u32, LuaError>>>>>,
412
413    /// Companion to `pending_async_native_fut`:
414    /// the `(func_slot, nargs, nresults, gc_top)` quad needed to
415    /// commit the future's eventual `Ok(nret)` back into the calling
416    /// frame's expected result slots. Recorded by the dispatcher;
417    /// consumed by [`Vm::commit_async_native_result`] after the
418    /// future resolves.
419    pub(crate) pending_async_native_ctx: Option<AsyncNativeCallCtx>,
420
421    /// Identifies this Vm to the JIT storages it compiles through
422    /// ([`crate::jit::JitStorage::claim`]).
423    jit_owner_id: u64,
424    /// Storages [`Vm::install_jit_storage`] replaced: code compiled into
425    /// them may still be referenced by this Vm's functions, so they live
426    /// as long as the Vm.
427    retired_jit_storage: Vec<Box<dyn crate::jit::JitStorage>>,
428}
429
430/// Call-site context an in-flight async native
431/// needs preserved across the cooperative-yield boundary.
432///
433/// The dispatcher records this when it routes a `NativeClosure` with
434/// `is_async == true` through the cooperative path; `EvalFuture::poll`
435/// hands it back to [`Vm::commit_async_native_result`] once the
436/// awaited future resolves so `finish_results` (and the post-call GC
437/// checkpoint) can run as if the native had completed synchronously.
438#[derive(Clone, Copy)]
439pub(crate) struct AsyncNativeCallCtx {
440    pub func_slot: u32,
441    /// Recorded for parity with the sync native-call path's
442    /// `native_nresults`/`gc_top` bookkeeping; reserved for hook
443    /// firing + traceback shaping. Not read yet.
444    #[allow(dead_code)]
445    pub nargs: u32,
446    pub nresults: i32,
447    /// Recorded for traceback + GC-root-window checks. The resume path
448    /// reads `Vm.gc_top` directly, so this is unread today; carried so a
449    /// check can confirm the pre-suspend root window matches the
450    /// post-resume one.
451    #[allow(dead_code)]
452    pub gc_top: u32,
453}
454
455/// Per-thread debug hook state (PUC `lua_State` hook/hookmask/basehookcount/
456/// hookcount). `func` is the Lua hook; the booleans are the PUC mask bits.
457#[derive(Clone, Copy, Default)]
458pub struct HookState {
459    /// the hook function (`None` when no hook is installed)
460    pub func: Option<Value>,
461    /// Rust-side debug hook. Fires alongside the Lua hook
462    /// (Rust first); both can be installed simultaneously, but most
463    /// embedders pick one.
464    pub rust_func: Option<RustDebugHook>,
465    /// LUA_MASKCALL — fire on function entry
466    pub call: bool,
467    /// LUA_MASKRET — fire on function return
468    pub ret: bool,
469    /// LUA_MASKLINE — fire on source-line change
470    pub line: bool,
471    /// LUA_MASKCOUNT — fire every `count_base` instructions
472    pub count: bool,
473    /// instruction count between count events (PUC basehookcount)
474    pub count_base: i64,
475    /// instructions left until the next count event (PUC hookcount)
476    pub count_left: i64,
477}
478
479/// Rust-side debug hook callback. Receives the `Vm` plus a
480/// classified event. The callback runs synchronously in the
481/// dispatcher; the hook flag (`in_hook`) is set for its duration so
482/// hook recursion is suppressed.
483pub type RustDebugHook = fn(&mut Vm, RustHookEvent);
484
485/// Classified debug event delivered to a [`RustDebugHook`].
486#[derive(Clone, Copy, Debug, PartialEq, Eq)]
487pub enum RustHookEvent {
488    /// Function entry (`hook_call` analogue).
489    Call,
490    /// Function return (`hook_return` analogue).
491    Return,
492    /// Tail call entry (PUC 5.2+ separates this from a plain Call).
493    TailCall,
494    /// Source-line change (the `u32` is the 1-based line number).
495    Line(u32),
496    /// Instruction count event (fires every `count_base` instructions).
497    Count,
498}
499
500/// Mask flags for [`Vm::set_rust_debug_hook`]. OR these to subscribe
501/// to multiple event categories with a single hook installation.
502pub const HOOK_MASK_CALL: u32 = 1;
503/// Subscribe to function-return events.
504pub const HOOK_MASK_RETURN: u32 = 2;
505/// Subscribe to line-change events.
506pub const HOOK_MASK_LINE: u32 = 4;
507/// Subscribe to instruction-count events.
508pub const HOOK_MASK_COUNT: u32 = 8;
509
510/// A thread's swapped-out execution context (PUC per-thread stack state).
511struct SavedCtx {
512    stack: Vec<Value>,
513    frames: Vec<CallFrame>,
514    frame_ccmt: Vec<u8>,
515    open_upvals: Vec<(u32, Gc<Upvalue>)>,
516    tbc: Vec<u32>,
517    top: u32,
518    pcall_depth: u32,
519    hook: HookState,
520    /// PUC `L->l_gt` — the thread's own globals table. Carried alongside
521    /// the rest of the suspended state so each thread can keep its own
522    /// `setfenv(0, env)` rewire without the swap leaking into another
523    /// thread (5.1 closure.lua :177).
524    globals: Gc<Table>,
525}
526
527/// Outcome of unwinding the call stack on an error (see `Vm::unwind`).
528enum Unwound {
529    /// caught by a pcall/xpcall continuation; resume running its caller
530    Caught,
531    /// caught by a continuation that was the entry-level activation; these are
532    /// the call's (wrapped) results
533    CaughtReturn(Vec<Value>),
534    /// no protecting continuation up to `entry_depth`; propagate the error
535    Propagated(LuaError),
536}
537
538/// Outcome of an index/newindex/comparison fast path: either a directly
539/// computed result, or a metamethod (with the receiver it resolved against) the
540/// caller must invoke — synchronously (C context) or yieldably (VM opcode).
541enum MmOut {
542    /// index → the looked-up value; newindex → done (raw set performed);
543    /// comparison → the boolean result already known
544    Done(Value),
545    /// a metamethod to call; `recv` is the chain element it was found on (the
546    /// extra args — key / value — are supplied by the caller)
547    Mm { func: Value, recv: Value },
548    /// ≤5.3 `a <= b` synthesised via `not __lt(b, a)` when neither operand
549    /// carries `__le` — `op_compare` swaps the args and negates the result.
550    /// Lives separate from `Mm` so the synth path can stay yieldable without
551    /// every other Mm caller learning a swap flag they would never set.
552    CompareSynth { func: Value },
553}
554
555/// Metamethod events; discriminants index `Vm::mm_names`.
556#[derive(Clone, Copy, PartialEq, Eq)]
557#[repr(usize)]
558pub(crate) enum Mm {
559    Index,
560    NewIndex,
561    Call,
562    ToString,
563    Metatable,
564    Name,
565    Eq,
566    Lt,
567    Le,
568    Concat,
569    Len,
570    Add,
571    Sub,
572    Mul,
573    Div,
574    Mod,
575    Pow,
576    IDiv,
577    BAnd,
578    BOr,
579    BXor,
580    Shl,
581    Shr,
582    Unm,
583    BNot,
584    Close,
585    Gc,
586    Pairs,
587}
588
589const MM_NAMES: [&str; 28] = [
590    "__index",
591    "__newindex",
592    "__call",
593    "__tostring",
594    "__metatable",
595    "__name",
596    "__eq",
597    "__lt",
598    "__le",
599    "__concat",
600    "__len",
601    "__add",
602    "__sub",
603    "__mul",
604    "__div",
605    "__mod",
606    "__pow",
607    "__idiv",
608    "__band",
609    "__bor",
610    "__bxor",
611    "__shl",
612    "__shr",
613    "__unm",
614    "__bnot",
615    "__close",
616    "__gc",
617    "__pairs",
618];
619
620/// The metamethod event an opcode dispatches, without the `__` prefix (PUC
621/// funcnamefromcode), for "(metamethod 'event')" call-error suffixes.
622fn mm_event_name(op: crate::vm::isa::Op) -> Option<&'static str> {
623    use crate::vm::isa::Op;
624    Some(match op {
625        Op::Add => "add",
626        Op::Sub => "sub",
627        Op::Mul => "mul",
628        Op::Div => "div",
629        Op::Mod => "mod",
630        Op::Pow => "pow",
631        Op::IDiv => "idiv",
632        Op::BAnd => "band",
633        Op::BOr => "bor",
634        Op::BXor => "bxor",
635        Op::Shl => "shl",
636        Op::Shr => "shr",
637        Op::Unm => "unm",
638        Op::BNot => "bnot",
639        Op::Concat => "concat",
640        Op::Len => "len",
641        Op::GetField | Op::GetTable | Op::GetI | Op::SelfOp => "index",
642        Op::SetField | Op::SetTable | Op::SetI => "newindex",
643        Op::Eq | Op::EqK => "eq",
644        Op::Lt => "lt",
645        Op::Le => "le",
646        _ => return None,
647    })
648}
649
650/// PUC MAXTAGLOOP (5.3+): bound on `__index`/`__newindex` chains.
651const MAX_TAG_LOOP: u32 = 2000;
652/// PUC `MAXCCMT`: bound on a `__call` metamethod chain (lvm.c). 200 chains
653/// is more than any reasonable program needs and matches PUC 5.4/5.5; a
654/// bound of `15` is tight enough to fire on calls.lua :194 (N=20).
655const MAX_CCMT: u32 = 200;
656/// PUC LUAI_MAXCCALLS analogue: native↔Lua nesting bound.
657pub(crate) const MAX_C_DEPTH: u32 = 200;
658/// Stack an xpcall handler may use past `MAX_LUA_STACK` while handling a
659/// stack overflow: PUC's 200 extra `ERRORSTACKSIZE` slots, plus the frame
660/// reserve (256) the overflowing call was refused, so the handler's first
661/// frame fits where that one did not.
662const ERROR_STACK_EXTRA: u32 = 200 + 256;
663/// luna's engine-level VM stack cap (used by call-site overflow checks).
664/// Slightly larger than PUC's `LUAI_MAXSTACK` so engine internals have a
665/// little headroom above any single library push.
666const MAX_LUA_STACK: u32 = 1 << 20;
667/// PUC `LUAI_MAXSTACK` (`luaconf.h`): the cap library code consults via
668/// `lua_checkstack` to refuse multi-value pushes (`table.unpack` returning
669/// N values, `string.pack` results, etc.). 5.3 coroutine.lua :530 pins
670/// this at one million — `for j in {lim-10, …}` expects every j ≥ lim-10
671/// to fail because the few slots already consumed in the coroutine push
672/// the effective cap below lim-10.
673const PUC_MAXSTACK: i64 = 1_000_000;
674
675/// PUC 5.4+ default warnf state. The base library's `warn` function flips
676/// between `Off` and `On` via the `@on` / `@off` control messages; any other
677/// `@<word>` control is silently ignored, mirroring `lauxlib.c::checkcontrol`.
678#[derive(Clone, Copy, PartialEq, Eq, Debug)]
679pub enum WarnState {
680    /// `warn` calls are silently dropped (default after `warn("@off")`).
681    Off,
682    /// `warn` calls are delivered to stderr (after `warn("@on")`).
683    On,
684}
685
686/// Best-effort extraction of a textual message from a `catch_unwind` payload.
687/// `panic!("msg")` arrives as `String`, `panic!(static)` as `&str`; anything
688/// else degrades to `"<non-string panic>"`. Used by the native-call
689/// catch_unwind to fold the panic into a Lua error.
690fn panic_payload_str(payload: &Box<dyn std::any::Any + Send>) -> String {
691    if let Some(s) = payload.downcast_ref::<String>() {
692        return s.clone();
693    }
694    if let Some(s) = payload.downcast_ref::<&'static str>() {
695        return (*s).to_string();
696    }
697    "<non-string panic>".to_string()
698}
699
700/// Combined error type returned by [`Vm::eval`] and friends — either the
701/// chunk failed to parse / compile, or it raised at runtime.
702#[derive(Debug)]
703pub enum Error {
704    /// Parse or compile failure.
705    Syntax(SyntaxError),
706    /// Runtime error raised during execution.
707    Runtime(LuaError),
708}
709
710impl From<SyntaxError> for Error {
711    fn from(e: SyntaxError) -> Error {
712        Error::Syntax(e)
713    }
714}
715
716impl From<LuaError> for Error {
717    fn from(e: LuaError) -> Error {
718        Error::Runtime(e)
719    }
720}
721
722impl Vm {
723    /// `lua_close` from inside a running script (`os.exit(code, true)`):
724    /// close the main thread's pending to-be-closed variables, then run every
725    /// finalizer. Both run protected, so their errors are dropped as PUC's
726    /// `close_state` drops them. Inside a coroutine the main thread's stack is
727    /// parked, and only the finalizers run.
728    pub(crate) fn close_state(&mut self) {
729        if self.current.is_none() {
730            let _ = self.close_slots(0, None);
731        }
732        self.heap.queue_all_finalizers();
733        self.run_finalizers();
734    }
735}
736
737impl Drop for Vm {
738    fn drop(&mut self) {
739        // state close: run `__gc` for every still-registered finalizable before
740        // the heap frees them (PUC separatetobefnz(g,1) + callallpending). A
741        // single pass — objects created by a closing finalizer are not
742        // re-finalized (they go to the heap's free list directly).
743        self.heap.queue_all_finalizers();
744        self.run_finalizers();
745        let id = self.jit_owner_id;
746        // SAFETY: the finalizers were the last Lua code this Vm runs, and
747        // its functions (the only holders of entry points compiled for it)
748        // go away with its heap.
749        unsafe {
750            self.jit.storage.release_code(id);
751            for s in &mut self.retired_jit_storage {
752                s.release_code(id);
753            }
754        }
755    }
756}
757
758// Split-borrow free fn helpers for frames push/pop with shadow counter
759// `frames_top: u32`. Free fns (not Vm methods) so callers can pass
760// `&mut self.frames` + `&mut self.frames_top` as split borrows, allowing
761// other `&mut self.field` reads inside the CallFrame construction (e.g.
762// `std::mem::take(&mut self.pending_tm)`).
763//
764// The shadow has no readers yet; it just stays in sync + asserts.
765#[inline(always)]
766fn frames_push_sync(frames: &mut Vec<CallFrame>, frames_top: &mut u32, cf: CallFrame) {
767    frames.push(cf);
768    // Shadow maintenance is debug-only: release builds skip the
769    // increment + assertion entirely. While nothing reads the shadow,
770    // its purpose is to VERIFY the assumed invariant
771    // (frames_top == frames.len()) across all push/pop sites; once readers
772    // consume it, release must run the increment unconditionally.
773    #[cfg(debug_assertions)]
774    {
775        *frames_top += 1;
776        debug_assert_eq!(
777            *frames_top as usize,
778            frames.len(),
779            "P17-D frames_top out of sync after push",
780        );
781    }
782    #[cfg(not(debug_assertions))]
783    let _ = frames_top;
784}
785
786#[inline(always)]
787fn frames_pop_sync(frames: &mut Vec<CallFrame>, frames_top: &mut u32) -> Option<CallFrame> {
788    let r = frames.pop();
789    #[cfg(debug_assertions)]
790    {
791        if r.is_some() {
792            *frames_top = frames_top.saturating_sub(1);
793        }
794        debug_assert_eq!(
795            *frames_top as usize,
796            frames.len(),
797            "P17-D frames_top out of sync after pop",
798        );
799    }
800    #[cfg(not(debug_assertions))]
801    let _ = frames_top;
802    r
803}
804
805/// One-time env-var read for
806/// `LUNA_AOT_PROBE`. Returns `true` iff the env var is set to any
807/// non-empty value. The result is cached in a `OnceLock` so the
808/// dispatcher's hot path pays a single atomic load per process. Off
809/// by default — production deploys don't bleed diagnostic prints.
810fn jit_probe_enabled() -> bool {
811    static PROBE_ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
812    *PROBE_ON.get_or_init(|| {
813        std::env::var("LUNA_AOT_PROBE")
814            .ok()
815            .filter(|v| !v.is_empty())
816            .is_some()
817    })
818}
819
820impl Vm {
821    /// Re-sync `frames_top` after a bulk `frames: Vec`
822    /// swap (take_ctx, put_ctx, load_coro_ctx). Must be called after
823    /// the Vec replacement to keep the shadow valid.
824    #[inline(always)]
825    fn frames_resync(&mut self) {
826        // Debug-only — see `frames_push_sync` comment.
827        #[cfg(debug_assertions)]
828        {
829            self.frames_top = self.frames.len() as u32;
830        }
831    }
832
833    // ====================================================================
834    // Stack-inline frame metadata accessors (unused).
835    //
836    // These methods read/write the LJ_FR2 marker slots at `stack[base-2]`
837    // (closure GCRef) and `stack[base-1]` (FrameMarker as i64). No call
838    // site uses them yet.
839    //
840    // Preconditions (debug-asserted):
841    // - base >= 2 (slots base-2 and base-1 must exist below the frame)
842    // - self.stack.len() > base + max_stack (caller has grown stack)
843    // - For Lua frames, stack[base-2] holds Value::Closure(cl)
844    // - For Lua frames, stack[base-1] holds Value::Int(marker.to_raw())
845    //
846    // No release-build cost when unused (LTO strips dead methods).
847    // ====================================================================
848
849    /// Write a Lua frame's closure pointer into `stack[base-2]`.
850    /// The caller must ensure `base >= 2` and the slot is within the
851    /// stack's allocated range.
852    #[inline]
853    #[allow(dead_code)] // no consumer yet
854    fn write_frame_closure(&mut self, base: u32, cl: crate::runtime::Gc<LuaClosure>) {
855        debug_assert!(
856            base >= 2,
857            "frame closure slot needs base >= 2; got {}",
858            base
859        );
860        let idx = (base - 2) as usize;
861        debug_assert!(idx < self.stack.len(), "stack[base-2] out of range");
862        self.stack[idx] = Value::Closure(cl);
863    }
864
865    /// Read a Lua frame's closure pointer from `stack[base-2]`.
866    /// Returns `None` if the slot doesn't hold a closure (caller is
867    /// expected to treat that as a corrupt frame).
868    ///
869    /// Uses the [`Value::tag_byte`] fast-path
870    /// to avoid the enum-match cost on the hot path. Tag check via
871    /// 1-byte load + branch + `as_closure_unchecked` payload load.
872    #[inline]
873    #[allow(dead_code)]
874    fn read_frame_closure(&self, base: u32) -> Option<crate::runtime::Gc<LuaClosure>> {
875        debug_assert!(base >= 2);
876        let v = self.stack.get((base - 2) as usize)?;
877        if v.tag_byte() == crate::runtime::value::tag::CLOSURE {
878            // SAFETY: tag byte just verified == CLOSURE.
879            Some(unsafe { v.as_closure_unchecked() })
880        } else {
881            None
882        }
883    }
884
885    /// Write a packed [`FrameMarker`] into `stack[base-1]`. The marker
886    /// encodes the frame kind (Lua / Cont) + PC-or-delta payload.
887    /// Stored as `Value::Int(marker.to_raw())` so it round-trips
888    /// cleanly through the value stack without losing bits.
889    #[inline]
890    #[allow(dead_code)]
891    fn write_frame_marker(&mut self, base: u32, marker: crate::runtime::frame_marker::FrameMarker) {
892        debug_assert!(base >= 1, "frame marker slot needs base >= 1; got {}", base);
893        let idx = (base - 1) as usize;
894        debug_assert!(idx < self.stack.len(), "stack[base-1] out of range");
895        self.stack[idx] = Value::Int(marker.to_raw());
896    }
897
898    /// Read a packed [`FrameMarker`] from `stack[base-1]`. Returns
899    /// `None` if the slot isn't a `Value::Int` (caller treats as a
900    /// corrupt frame); the kind tag itself may still be invalid, in
901    /// which case [`FrameMarker::kind`] returns `None` on the result.
902    ///
903    /// Uses the [`Value::tag_byte`] fast-path
904    /// for the tag check + `as_int_unchecked` for the payload load.
905    #[inline]
906    #[allow(dead_code)]
907    fn read_frame_marker(&self, base: u32) -> Option<crate::runtime::frame_marker::FrameMarker> {
908        debug_assert!(base >= 1);
909        let v = self.stack.get((base - 1) as usize)?;
910        if v.tag_byte() == crate::runtime::value::tag::INT {
911            // SAFETY: tag byte just verified == INT.
912            Some(crate::runtime::frame_marker::FrameMarker::from_raw(
913                unsafe { v.as_int_unchecked() },
914            ))
915        } else {
916            None
917        }
918    }
919
920    /// Build the raw `Vm` struct without main coroutine / RNG seed / library
921    /// setup. Private helper shared by `Vm::new` and `Vm::new_minimal`; the
922    /// caller is responsible for the rest of the bring-up.
923    fn new_inner(version: LuaVersion) -> Vm {
924        let mut heap = Heap::new();
925        // PUC 5.1 had no ephemeron pass — `__mode='k'` tables marked their
926        // values strongly. gc.lua's "weak tables" section relies on that.
927        heap.no_ephemeron = version <= LuaVersion::Lua51;
928        // PUC 5.3 needs two GC cycles to finalize a table caught in a
929        // coroutine reference cycle (gc.lua :502); 5.4+ rewrote the GC and
930        // finalize in a single cycle (5.4/5.5 gc.lua :544 assert exactly one).
931        heap.defer_thread_cycle_finalize = version == LuaVersion::Lua53;
932        let globals = heap.new_table();
933        let mm_names = MM_NAMES.iter().map(|n| heap.intern(n.as_bytes())).collect();
934
935        Vm {
936            heap,
937            stack: Vec::new(),
938            frames: Vec::new(),
939            frame_ccmt: Vec::new(),
940            frames_top: 0,
941            open_upvals: Vec::new(),
942            tbc: Vec::new(),
943            top: 0,
944            globals,
945            type_mt: [None; 5],
946            mm_names,
947            c_depth: 0,
948            pcall_depth: 0,
949            nny: 0,
950            msgh_depth: 0,
951            terminating: None,
952            rng: [0; 4],
953            started: std::time::Instant::now(),
954            version,
955            closing_err: None,
956            current: None,
957            main_ctx: None,
958            yielding: None,
959            native_nresults: -1,
960            main_coro: None,
961            // PUC 5.4+ boots in GENERATIONAL mode (the first
962            // `collectgarbage("generational")` reports "generational"
963            // as the previous mode on stock lua5.4;
964            // 5.5 behaves the same, probed against lua5.5). luna's
965            // collector is a single incremental engine either way;
966            // this field is the MODE REPORT the stdlib exposes.
967            gc_mode: if version >= crate::version::LuaVersion::Lua54 {
968                "generational"
969            } else {
970                "incremental"
971            },
972            gc_top: 0,
973            gc_pause: 200,
974            gc_stepmul: 100,
975            gc_stepsize: 13,
976            gc_params: crate::vm::lib_gc::GcParams::new(version),
977            gc_finalizing: false,
978            capi_stack: Vec::new(),
979            capi_cstr_pin: None,
980            warn_state: WarnState::Off,
981            warn_buf: Vec::new(),
982            warn_log: Vec::new(),
983            instr_budget: None,
984            bytecode_loading: true,
985            puc_bytecode_loading: false,
986            loader_input_budget: Vm::DEFAULT_LOADER_INPUT_BUDGET,
987            registry: None,
988            file_mt: None,
989            io_input: None,
990            io_output: None,
991            io_stdin: None,
992            ignore_env: false,
993            hook: HookState::default(),
994            in_hook: false,
995            pending_tailcalls: 0,
996            pending_ccmt: 0,
997            errored_natives: Vec::new(),
998            msgh_floor: 0,
999            msgh_running: None,
1000            msgh_runs: 0,
1001            msgh_applied: None,
1002            keep_error_traceback: true,
1003            hook_ftransfer: 0,
1004            hook_ntransfer: 0,
1005            pending_tm: None,
1006            pending_is_hook: false,
1007            error_traceback: None,
1008            public_call_depth: 0,
1009            running_natives: Vec::new(),
1010            running_native_acts: Vec::new(),
1011            natives_base: 0,
1012            // JIT-specific state lives in the `JitState`
1013            // sidecar. The `luna` crate's `Vm::new_minimal_with_jit` /
1014            // `install_jit_backend` / `luaL_newstate` swap in
1015            // `CraneliftBackend` for callers that want JIT acceleration.
1016            jit: crate::vm::jit_state::JitState::with_null_backend(),
1017            // host roots ticket pool for the `Lua` facade
1018            host_roots: Vec::new(),
1019            // MacroLua registry. Pre-populated with
1020            // built-ins (`@quote` / `@unquote` / `@if` / `@gensym`)
1021            // when this Vm is constructed under `LuaVersion::MacroLua`.
1022            macro_registry: if version == LuaVersion::MacroLua {
1023                crate::frontend::macro_expander::MacroRegistry::with_builtins()
1024            } else {
1025                crate::frontend::macro_expander::MacroRegistry::new()
1026            },
1027            host_roots_free: Vec::new(),
1028            sort_scratch: Vec::new(),
1029            // LuaUserdata trait sugar's per-Vm
1030            // metatable cache. Populated lazily by register_userdata.
1031            userdata_metatables: std::collections::HashMap::new(),
1032            // Error classification metadata. Defaults to
1033            // Runtime; set at known sites (syntax / budget trip /
1034            // native error / type error).
1035            last_error_kind: crate::vm::error::LuaErrorKind::default(),
1036            last_error_source: None,
1037            // Async embedder fields. Defaults preserve sync behavior
1038            // bit-for-bit (`async_mode = false` means the budget hot loop
1039            // errors out instead of yielding).
1040            async_mode: false,
1041            async_waker: None,
1042            async_slice_size: 10_000,
1043            host_yield_pending: false,
1044            // Pending async-native state. Empty by
1045            // default; populated only by the dispatcher when an
1046            // async-marked NativeClosure is invoked under async_mode.
1047            pending_async_native_fut: None,
1048            pending_async_native_ctx: None,
1049            jit_owner_id: {
1050                static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
1051                NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
1052            },
1053            retired_jit_storage: Vec::new(),
1054        }
1055    }
1056
1057    /// Build a fully-loaded Vm — the default for embedders that want PUC's
1058    /// standard library surface. Equivalent to `Vm::new_minimal(version)`
1059    /// followed by `vm.open_all_libs()`.
1060    pub fn new(version: LuaVersion) -> Vm {
1061        let mut vm = Vm::new_minimal(version);
1062        vm.open_all_libs();
1063        vm
1064    }
1065
1066    /// Build a Vm with no standard libraries loaded. Embedders
1067    /// that want a sandbox (Redis-style scripts, in-game scripting with
1068    /// a curated API) call this and then `open_base` / `open_math` / etc.
1069    /// selectively. The Vm is otherwise fully initialized (main coroutine,
1070    /// RNG seed, GC) so `eval` and `call_value` are immediately usable.
1071    pub fn new_minimal(version: LuaVersion) -> Vm {
1072        let mut vm = Vm::new_inner(version);
1073        let mc = vm.heap.new_coro(Value::Nil, vm.globals);
1074        // SAFETY: Gc<T> is NonNull<T> over the GC heap; the heap is single-threaded and the pointer is live as long as it is reachable from active roots (see heap.rs:5-7).
1075        unsafe { mc.as_mut() }.status = CoroStatus::Running;
1076        vm.main_coro = Some(mc);
1077        let (a, b) = vm.rng_auto_seed();
1078        vm.rng_seed(a as u64, b as u64);
1079        vm
1080    }
1081
1082    /// Install a caller-supplied JIT backend. The
1083    /// `luna` crate uses this to swap in its `CraneliftBackend`; tests
1084    /// or third-party backends pass their own [`crate::jit::IntChunkCompiler`] /
1085    /// [`crate::jit::TraceCompiler`] implementations. Re-installing on a Vm whose
1086    /// closures already populated `Proto.jit: JitProtoState::Compiled`
1087    /// does NOT evict those cached entries — call right after
1088    /// construction for a clean swap.
1089    ///
1090    /// Naming: `install_jit_backend` (not `install_default_jit`)
1091    /// because the "default" in luna-core is `NullJitBackend`; the
1092    /// "default JIT" lives in the `luna` crate.
1093    pub fn install_jit_backend<C, T>(&mut self, chunk: C, trace: T)
1094    where
1095        C: crate::jit::IntChunkCompiler + 'static,
1096        T: crate::jit::TraceCompiler + 'static,
1097    {
1098        self.jit.chunk_compiler = Box::new(chunk);
1099        self.jit.trace_compiler = Box::new(trace);
1100    }
1101
1102    /// Install a caller-supplied JIT
1103    /// storage holder. Default is [`crate::jit::NullJitStorage`];
1104    /// the `luna_jit` crate's `install_default_jit` pairs this with
1105    /// `install_jit_backend(CraneliftBackend, CraneliftBackend)` to
1106    /// also install a fresh `CraneliftJitStorage`. Storage holds
1107    /// the per-`Vm` JIT cache + handle collections.
1108    ///
1109    /// The storage it replaces is kept until the Vm drops: functions
1110    /// compiled through it may still be called.
1111    pub fn install_jit_storage<S>(&mut self, storage: S)
1112    where
1113        S: crate::jit::JitStorage + 'static,
1114    {
1115        let old = std::mem::replace(&mut self.jit.storage, Box::new(storage));
1116        self.retired_jit_storage.push(old);
1117    }
1118
1119    /// Install the no-op JIT backend. `try_compile`
1120    /// reports "skipped" so every closure stays on the interpreter
1121    /// path, and the trace recorder's compile attempt always returns
1122    /// `None`. Intended for tests that want to verify the trait
1123    /// boundary works in a JIT-free configuration.
1124    ///
1125    /// Calling this on a Vm whose closures already populated
1126    /// `Proto.jit: JitProtoState::Compiled` does NOT evict those
1127    /// cached entries — the dispatcher will still call into them. For
1128    /// a truly JIT-free run, call this immediately after construction.
1129    pub fn install_null_jit(&mut self) {
1130        self.jit.chunk_compiler = Box::new(crate::jit::NullJitBackend);
1131        self.jit.trace_compiler = Box::new(crate::jit::NullJitBackend);
1132    }
1133
1134    /// Open the entire 5.5 standard library on a `new_minimal`-built Vm.
1135    /// `Vm::new` calls this; sandboxed embedders open libraries one at a
1136    /// time instead (`open_base`, `open_math`, `open_table`, …).
1137    pub fn open_all_libs(&mut self) {
1138        self.open_base();
1139        self.open_math();
1140        self.open_table();
1141        self.open_string();
1142        self.open_utf8();
1143        self.open_os_io();
1144        self.open_debug();
1145        self.open_coroutine();
1146        // PUC 5.2 introduced `bit32`; 5.3 retired it in the manual BUT
1147        // the stock 5.3 build ships -DLUA_COMPAT_5_2, which keeps the
1148        // library loaded. The diff ground truth is the default build
1149        // (stock lua5.3), so expose it under 5.2 AND
1150        // 5.3; 5.4 dropped the compat default for real.
1151        if matches!(self.version, LuaVersion::Lua52 | LuaVersion::Lua53) {
1152            self.open_bit32();
1153        }
1154        // last, so `package.loaded` lists every library opened before it
1155        self.open_package();
1156    }
1157
1158    /// Install the base library (`print`, `type`, `pairs`, `tostring`,
1159    /// `pcall`, `error`, `assert`, `select`, `setmetatable`, `getmetatable`,
1160    /// `rawequal`, `rawget`, `rawset`, `rawlen`, `next`, `tonumber`,
1161    /// `collectgarbage`, `warn` on 5.4+, `_VERSION`, `_G`, plus 5.1's
1162    /// retired globals `unpack`, `loadstring`, `setfenv`, `getfenv`,
1163    /// `newproxy`, `gcinfo` when version == 5.1). Safe to call at most
1164    /// once per Vm.
1165    pub fn open_base(&mut self) {
1166        crate::vm::builtins::open_base(self);
1167    }
1168    /// Install the `math` standard library.
1169    pub fn open_math(&mut self) {
1170        crate::vm::lib_math::open_math(self);
1171    }
1172    /// Install the `table` standard library.
1173    pub fn open_table(&mut self) {
1174        crate::vm::lib_table::open_table(self);
1175    }
1176    /// Install the `string` standard library (and the shared string metatable).
1177    pub fn open_string(&mut self) {
1178        crate::vm::lib_string::open_string(self);
1179    }
1180    /// Install the `utf8` standard library (5.3+).
1181    pub fn open_utf8(&mut self) {
1182        crate::vm::lib_utf8::open_utf8(self);
1183    }
1184    /// `os` and `io` are merged because file userdata shares state with both
1185    /// (`io.tmpname` and `os.tmpname` are the same function, `io.popen`
1186    /// wraps `os.execute`'s shell).
1187    pub fn open_os_io(&mut self) {
1188        crate::vm::lib_os_io::open_os_io(self);
1189    }
1190    /// Install the `debug` standard library (introspection / hooks). Off by
1191    /// default for sandbox embedders.
1192    pub fn open_debug(&mut self) {
1193        crate::vm::lib_debug::open_debug(self);
1194    }
1195    /// Install the `coroutine` standard library.
1196    pub fn open_coroutine(&mut self) {
1197        crate::vm::lib_coroutine::open_coroutine(self);
1198    }
1199    /// `package` plus the 5.1-only `module` and `package.seeall` aliases.
1200    pub fn open_package(&mut self) {
1201        crate::vm::lib_package::open_package(self);
1202    }
1203    /// 5.2-only `bit32` library (5.3+ retired in favour of native bitwise
1204    /// ops on 64-bit integers).
1205    pub fn open_bit32(&mut self) {
1206        crate::vm::lib_bit32::open_bit32(self);
1207    }
1208
1209    /// xoshiro256** next.
1210    pub(crate) fn rng_next(&mut self) -> u64 {
1211        let s = &mut self.rng;
1212        let result = s[1].wrapping_mul(5).rotate_left(7).wrapping_mul(9);
1213        let t = s[1] << 17;
1214        s[2] ^= s[0];
1215        s[3] ^= s[1];
1216        s[1] ^= s[2];
1217        s[0] ^= s[3];
1218        s[2] ^= t;
1219        s[3] = s[3].rotate_left(45);
1220        result
1221    }
1222
1223    /// Seed the RNG via splitmix64 expansion (PUC randseed shape).
1224    pub(crate) fn rng_seed(&mut self, a: u64, b: u64) {
1225        // PUC setseed: state = [n1, 0xff, n2, 0] (0xff avoids an all-zero
1226        // state), then 16 discards to spread the seed. Matches PUC's exact
1227        // sequence so the low-level conformance test passes.
1228        self.rng = [a, 0xff, b, 0];
1229        for _ in 0..16 {
1230            self.rng_next();
1231        }
1232    }
1233
1234    /// Wall-clock since VM creation (os.clock approximation).
1235    pub(crate) fn uptime(&self) -> std::time::Duration {
1236        self.started.elapsed()
1237    }
1238
1239    /// Entropy for math.randomseed() with no arguments.
1240    pub(crate) fn rng_auto_seed(&mut self) -> (i64, i64) {
1241        let t = std::time::SystemTime::now()
1242            .duration_since(std::time::UNIX_EPOCH)
1243            .map(|d| d.as_nanos() as u64)
1244            .unwrap_or(0);
1245        let addr = &self.rng as *const _ as u64;
1246        (t as i64, addr as i64)
1247    }
1248
1249    /// Allocate a native function object (no upvalues): builtin registration.
1250    pub fn native(&mut self, f: crate::runtime::value::NativeFn) -> Value {
1251        Value::Native(self.heap.new_native(f, Box::new([])))
1252    }
1253
1254    /// Allocate a native function object with captured upvalues.
1255    pub fn native_with(
1256        &mut self,
1257        f: crate::runtime::value::NativeFn,
1258        upvals: Box<[Value]>,
1259    ) -> Value {
1260        Value::Native(self.heap.new_native(f, upvals))
1261    }
1262
1263    /// Install the shared string metatable (string library).
1264    pub fn set_string_metatable(&mut self, mt: Option<Gc<Table>>) {
1265        self.type_mt[3] = mt;
1266    }
1267
1268    /// The current globals table (`_G` / `_ENV` source for new chunks).
1269    pub fn globals(&self) -> Gc<Table> {
1270        self.globals
1271    }
1272
1273    /// Remaining VM stack slots (PUC `L->stack_last - L->top` analogue).
1274    /// Library code that pushes a known number of fresh slots — e.g.
1275    /// `table.unpack` returning N values — consults this to refuse when
1276    /// the push would blow past `LUAI_MAXSTACK`. 5.3 coroutine.lua :530's
1277    /// `for j in {lim-10, lim-5, …}` series pins this contract: the
1278    /// coroutine's already-built table eats a few slots, so an unpack of
1279    /// ~lim values can't fit.
1280    pub(crate) fn stack_room(&self) -> i64 {
1281        PUC_MAXSTACK - (self.stack.len() as i64)
1282    }
1283
1284    /// Repoint the thread's "global table" used by *future* `Vm::load` calls
1285    /// for the chunk's `_ENV` upvalue (PUC 5.1 `setfenv(0, env)` rewrites
1286    /// `L->l_gt`). Already-loaded chunks keep their own snapshot via the
1287    /// per-closure cell-0 clone in `Op::Closure`, so they are unaffected.
1288    pub(crate) fn set_globals(&mut self, env: Gc<Table>) {
1289        self.globals = env;
1290    }
1291
1292    /// The Lua dialect this VM was constructed for (5.1 / 5.2 / 5.3 / 5.4 /
1293    /// 5.5). Determines numeric semantics, available standard libraries, and
1294    /// metamethod behavior.
1295    pub fn version(&self) -> LuaVersion {
1296        self.version
1297    }
1298
1299    /// Set a global by name. `v` may be any `IntoValue`: a primitive
1300    /// (`i64`, `f64`, `bool`, `&str`, `String`, `Vec<u8>`), a `Value`
1301    /// directly, an `Option<T>`, or a `Gc<Table>` / `Gc<LuaClosure>` /
1302    /// `Gc<NativeClosure>` handle.
1303    ///
1304    /// Returns `Err(LuaError)` only if the globals table overflows
1305    /// (extremely unlikely in practice — `MAX_ASIZE = 1 << 27`).
1306    /// String interning + key construction cannot fail.
1307    ///
1308    /// ```
1309    /// # use luna_core::vm::Vm;
1310    /// # use luna_core::version::LuaVersion;
1311    /// let mut vm = Vm::sandbox(LuaVersion::Lua55).open_base().build();
1312    /// vm.set_global("answer", 42).unwrap();
1313    /// vm.set_global("ratio", 0.5_f64).unwrap();
1314    /// vm.set_global("hello", "world").unwrap();
1315    /// let r = vm.eval("return answer, ratio, hello").unwrap();
1316    /// assert_eq!(r.len(), 3);
1317    /// ```
1318    pub fn set_global<V: crate::vm::IntoValue>(
1319        &mut self,
1320        name: &str,
1321        v: V,
1322    ) -> Result<(), LuaError> {
1323        let v = v.into_value(self);
1324        let k = Value::Str(self.heap.intern(name.as_bytes()));
1325        // SAFETY: Gc<T> is NonNull<T> over the GC heap; the heap is single-threaded and the pointer is live as long as it is reachable from active roots (see heap.rs:5-7).
1326        unsafe { self.globals.as_mut() }.set(&mut self.heap, k, v)?;
1327        self.heap
1328            .barrier_back(self.globals.as_ptr() as *mut crate::runtime::heap::GcHeader);
1329        Ok(())
1330    }
1331
1332    /// Backward write barrier shorthand for native lib code: demote `t` from
1333    /// BLACK back to gray so the next propagate step re-traces its fields.
1334    /// No-op outside Propagate (parent is never BLACK at mutation time).
1335    pub(crate) fn barrier_back_table(&mut self, t: Gc<Table>) {
1336        self.heap
1337            .barrier_back(t.as_ptr() as *mut crate::runtime::heap::GcHeader);
1338    }
1339
1340    /// Forward write barrier shorthand: a closed upvalue is a single-slot
1341    /// container — `barrier_forward` is cheaper than `barrier_back` here.
1342    /// No-op outside Propagate.
1343    pub(crate) fn barrier_forward_upvalue(&mut self, uv: Gc<Upvalue>, child: Value) {
1344        self.heap
1345            .barrier_forward(uv.as_ptr() as *mut crate::runtime::heap::GcHeader, child);
1346    }
1347
1348    /// Register a MacroLua macro under `name`. Inert
1349    /// under non-MacroLua dialects (the macro is stored but the load
1350    /// path only consults the registry when
1351    /// `self.version == LuaVersion::MacroLua`).
1352    ///
1353    /// `name` is stored without the leading `@` — source code writes
1354    /// `@double(x)` to invoke a macro registered as `"double"`.
1355    pub fn define_macro(&mut self, name: &str, m: Box<dyn crate::frontend::macro_expander::Macro>) {
1356        self.macro_registry.register(name, m);
1357    }
1358
1359    /// Drop all MacroLua macros (built-in + custom).
1360    /// Mostly useful for tests.
1361    pub fn clear_macros(&mut self) {
1362        self.macro_registry.clear();
1363    }
1364
1365    /// PUC `luaL_loadfilex`: compile the file `name` (standard input when
1366    /// `None`, named `stdin`) into a function. A first line starting with
1367    /// `#` is skipped; `mode` (`"t"`, `"b"`, `"bt"`, `None` for both)
1368    /// limits the chunk to text and/or binary. The error is the message
1369    /// PUC's function leaves: `cannot open <name>: <reason>` when the file
1370    /// cannot be read, or the positioned syntax error.
1371    pub fn load_file(
1372        &mut self,
1373        name: Option<&[u8]>,
1374        mode: Option<&[u8]>,
1375    ) -> Result<Value, LuaError> {
1376        crate::vm::lib_os_io::load_path(self, name, mode).map_err(LuaError)
1377    }
1378
1379    /// PUC `luaL_loadbufferx`: compile `src` under `chunkname`, the chunk
1380    /// kind limited by `mode` as in [`Vm::load_file`]. A syntax error comes
1381    /// back as its positioned message (`<chunk id>:<line>: <message>`), the
1382    /// string `load` returns.
1383    pub fn load_buffer(
1384        &mut self,
1385        src: &[u8],
1386        chunkname: &[u8],
1387        mode: Option<&[u8]>,
1388    ) -> Result<Value, LuaError> {
1389        crate::vm::lib_os_io::load_chunk(self, src, chunkname, mode).map_err(LuaError)
1390    }
1391
1392    /// Parse + compile a chunk and close it over the globals table.
1393    pub fn load(&mut self, src: &[u8], chunkname: &[u8]) -> Result<Gc<LuaClosure>, SyntaxError> {
1394        // Reject oversize input *before* handing the parser/lexer a
1395        // potentially multi-GB slice. The PUC-shaped `not enough memory`
1396        // message keeps `heavy.lua::loadrep` compatibility: that test
1397        // accepts either `string length overflow` or `not enough memory`
1398        // as the failure mode for a feeder loop that outruns the host
1399        // allocator. See `set_loader_input_budget`.
1400        if src.len() > self.loader_input_budget {
1401            return Err(SyntaxError {
1402                line: 0,
1403                msg: b"not enough memory".to_vec(),
1404            });
1405        }
1406        // a precompiled (binary) chunk is undumped; source is parsed + compiled
1407        let is_bytecode = crate::vm::dump::is_binary_chunk(src);
1408        if is_bytecode && !self.bytecode_loading {
1409            return Err(SyntaxError {
1410                line: 0,
1411                msg: b"attempt to load a binary chunk (bytecode loading disabled)".to_vec(),
1412            });
1413        }
1414        let proto = if is_bytecode {
1415            let allow_puc = self.puc_bytecode_loading;
1416            crate::vm::dump::undump_named(src, &mut self.heap, self.version, allow_puc, chunkname)
1417                .map_err(SyntaxError::unpositioned)?
1418        } else if self.version.is_macro_lua() {
1419            // MacroLua dialect: drain the lexer into a
1420            // token vec, run the macro expander pre-pass against the
1421            // per-Vm registry, then hand the rewritten stream to
1422            // `parse_tokens`. The AST + compiler are dialect-agnostic
1423            // because by this point all `@`/quote tokens are gone.
1424            let mut lexer = crate::frontend::lexer::Lexer::new(src, self.version);
1425            let mut raw: Vec<crate::frontend::token::TokenInfo> = Vec::new();
1426            loop {
1427                let t = lexer.next_token()?;
1428                let eof = matches!(t.tok, crate::frontend::token::Token::Eof);
1429                raw.push(t);
1430                if eof {
1431                    break;
1432                }
1433            }
1434            // Drop the trailing Eof — expander operates on the body and
1435            // `parse_tokens` reinserts Eof when it runs out of tokens.
1436            raw.pop();
1437            let expanded = self.macro_registry.expand(raw)?;
1438            let depth = self.c_depth + self.pcall_depth;
1439            let parsed =
1440                crate::frontend::parser::parse_tokens_at_depth(expanded, src, self.version, depth)?;
1441            crate::compiler::compile_parsed(
1442                &parsed.chunk,
1443                &parsed.end_lines,
1444                self.version,
1445                chunkname,
1446                &mut self.heap,
1447            )?
1448        } else {
1449            // PUC's `nCcalls` counts protected calls as well
1450            let depth = self.c_depth + self.pcall_depth;
1451            let parsed = crate::frontend::parser::parse_at_depth(src, self.version, depth)?;
1452            crate::compiler::compile_parsed(
1453                &parsed.chunk,
1454                &parsed.end_lines,
1455                self.version,
1456                chunkname,
1457                &mut self.heap,
1458            )?
1459        };
1460        // PUC `lua_load` (lapi.c) only seeds the loaded closure's first
1461        // upvalue with the globals table when the closure has *exactly* one
1462        // upvalue — that's the main-chunk `_ENV` case. A dumped non-main
1463        // function with two-or-more upvalues keeps every cell at nil; the
1464        // host must use `debug.setupvalue` to wire them up. 5.2 calls.lua
1465        // :293's `assert(x() == nil)` pins this contract.
1466        let n = proto.upvals.len();
1467        let mut ups: Vec<Gc<Upvalue>> = Vec::with_capacity(n.max(1));
1468        if n == 0 {
1469            // synthetic main chunk has no declared upvalues, but the engine
1470            // still expects at least one cell so the host can probe via
1471            // `debug.upvalueid` etc. Match the historical luna shape.
1472            ups.push(
1473                self.heap
1474                    .new_upvalue(UpvalState::Closed(Value::Table(self.globals))),
1475            );
1476        } else if n == 1 {
1477            ups.push(
1478                self.heap
1479                    .new_upvalue(UpvalState::Closed(Value::Table(self.globals))),
1480            );
1481        } else {
1482            for _ in 0..n {
1483                ups.push(self.heap.new_upvalue(UpvalState::Closed(Value::Nil)));
1484            }
1485        }
1486        Ok(self.heap.new_closure(proto, ups.into_boxed_slice()))
1487    }
1488
1489    /// Compile and run `src` as an anonymous chunk; return its results.
1490    /// Source name in the traceback is `"=eval"`. Syntax errors are
1491    /// surfaced as `LuaError` carrying the formatted PUC-style message
1492    /// (interned through the heap so the error value composes with
1493    /// `pcall` / `error_text` like any runtime error).
1494    pub fn eval(&mut self, src: &str) -> Result<Vec<Value>, LuaError> {
1495        self.eval_chunk(src, "=eval")
1496    }
1497
1498    /// Render an error value for messages/tests. Non-string errors —
1499    /// `error({code=…})`, `error(42)`, etc. — collapse to a type tag
1500    /// (`"(error object is a table value)"`); embedders that need
1501    /// structured payloads should inspect `e.0` directly. Errors whose
1502    /// text starts with `"native panic:"` indicate a Rust panic
1503    /// crossed `catch_unwind` — the Vm may be inconsistent and should
1504    /// be dropped (do not reuse).
1505    pub fn error_text(&self, e: &LuaError) -> String {
1506        match e.0 {
1507            Value::Str(s) => String::from_utf8_lossy(s.as_bytes()).into_owned(),
1508            v => format!("(error object is a {} value)", v.type_name()),
1509        }
1510    }
1511
1512    /// Render an error value the way PUC's standalone `msghandler`
1513    /// does (lua.c): strings pass through, numbers stringify, and any
1514    /// other object is given a chance at its `__tostring` metamethod
1515    /// (the result must be a string) before collapsing to the
1516    /// `"(error object is a … value)"` tag. Needs `&mut self` because
1517    /// `__tostring` runs arbitrary Lua — `error_text` remains the
1518    /// non-executing variant.
1519    pub fn error_display(&mut self, e: &LuaError) -> String {
1520        match e.0 {
1521            Value::Str(s) => String::from_utf8_lossy(s.as_bytes()).into_owned(),
1522            v @ (Value::Int(_) | Value::Float(_)) => {
1523                String::from_utf8_lossy(&self.tostring_basic(v)).into_owned()
1524            }
1525            v => {
1526                let mm = self.get_mm(v, Mm::ToString);
1527                if !mm.is_nil()
1528                    && let Ok(r) = self.call_value(mm, &[v])
1529                    && let Some(Value::Str(s)) = r.first()
1530                {
1531                    return String::from_utf8_lossy(s.as_bytes()).into_owned();
1532                }
1533                format!("(error object is a {} value)", v.type_name())
1534            }
1535        }
1536    }
1537
1538    /// Call `f` with `args` in protected mode with the message handler
1539    /// `msgh`: PUC `lua_pcall(L, nargs, LUA_MULTRET, msgh)` made from a C
1540    /// function of the host's, as lua.c's `docall` does from `pmain`.
1541    ///
1542    /// `msgh` runs where the error was raised, before the stack unwinds, so
1543    /// it can take a traceback of the failing call ([`Vm::traceback`]); an
1544    /// error inside it calls it again with the new error, as in PUC. The
1545    /// returned error carries what the handler returned.
1546    ///
1547    /// The call counts as one C level on the stack, the host function
1548    /// making it: `debug.getinfo` finds it below `f`, and a traceback taken
1549    /// inside ends with `[C]: in ?` (`[C]: ?` in 5.1).
1550    pub fn call_value_with_handler(
1551        &mut self,
1552        f: Value,
1553        args: &[Value],
1554        msgh: Value,
1555    ) -> Result<Vec<Value>, LuaError> {
1556        let level = self.native(crate::vm::builtins::nat_host_xpcall);
1557        let mut call_args = Vec::with_capacity(args.len() + 2);
1558        call_args.push(f);
1559        call_args.push(msgh);
1560        call_args.extend_from_slice(args);
1561        let mut results = self.call_value(level, &call_args)?;
1562        // the protected call's `true, results...` or `false, handled error`
1563        if results.first().is_some_and(|ok| ok.truthy()) {
1564            results.remove(0);
1565            Ok(results)
1566        } else {
1567            Err(LuaError(results.get(1).copied().unwrap_or(Value::Nil)))
1568        }
1569    }
1570
1571    /// PUC `luaL_getmetafield`: the field `event` of `v`'s metatable, read
1572    /// raw; nil when `v` has no metatable or the field is absent.
1573    pub fn metafield(&mut self, v: Value, event: &str) -> Value {
1574        match self.metatable_of(v) {
1575            Some(mt) => {
1576                let key = Value::Str(self.heap.intern(event.as_bytes()));
1577                mt.get(key)
1578            }
1579            None => Value::Nil,
1580        }
1581    }
1582
1583    /// Call any callable value from the host (or from natives like pcall).
1584    pub fn call_value(&mut self, f: Value, args: &[Value]) -> Result<Vec<Value>, LuaError> {
1585        // host-level entry (no enclosing exec): drop any error state from a
1586        // prior call that propagated uncaught (`error_traceback` would
1587        // otherwise leak into the next debug.traceback call).
1588        if self.public_call_depth == 0 {
1589            self.error_traceback = None;
1590        }
1591        self.public_call_depth += 1;
1592        // JIT fast path. A host call with no args targeting a Lua
1593        // chunk whose body fits the int-arith whitelist short-circuits
1594        // the whole interpreter dispatch and runs straight through the
1595        // mmap'd native code. The lookup is one Cell::get + one match —
1596        // the slow path (compile attempt on first reach) is paid once per
1597        // Proto.
1598        if args.is_empty()
1599            && let Value::Closure(cl) = f
1600            && let Some(vs) = self.try_jit_call(cl)
1601        {
1602            self.public_call_depth -= 1;
1603            return Ok(vs);
1604        }
1605        let r = self.call_value_impl(f, args, true);
1606        self.public_call_depth -= 1;
1607        r
1608    }
1609
1610    /// Peek/populate the Proto's JIT cache slot, returning
1611    /// `Some(values)` when the cached native fn is callable for a
1612    /// zero-arg call. (Non-zero-arg dispatch is handled by
1613    /// `try_jit_call_op` from inside `begin_call`.)
1614    fn try_jit_call(&mut self, cl: Gc<LuaClosure>) -> Option<Vec<Value>> {
1615        use crate::runtime::function::JitProtoState;
1616        if !self.jit.enabled {
1617            return None;
1618        }
1619        let proto = cl.proto;
1620        if let JitProtoState::Untried = proto.jit.get() {
1621            self.populate_jit_cache(proto);
1622        }
1623        match proto.jit.get() {
1624            JitProtoState::Compiled {
1625                entry,
1626                num_args: 0,
1627                returns_one,
1628                arg_float_mask: _,
1629                arg_table_mask: _,
1630                ret_is_float,
1631                ret_is_table,
1632            } => {
1633                // SAFETY: the source `*const u8` is a JIT-compiled function entry pointer produced by Cranelift with the target `fn`-pointer signature (IntChunkFn / IntFnN); the JitVmGuard above keeps the JIT_VM TLS slot live across the call.
1634                let f: crate::jit::IntChunkFn = unsafe { std::mem::transmute(entry) };
1635                // Install the active Vm + closure
1636                // for any Rust helper the JIT'd code may call (e.g.
1637                // `luna_jit_new_table`, `luna_jit_upval_get`) via
1638                // cranelift `Linkage::Import`. RAII clear on return.
1639                // Chunks with no upvalue reads don't touch the closure
1640                // slot, paying nothing.
1641                // Route through chunk_compiler so
1642                // the NullJitBackend path stays inert. Raw-ptr arg
1643                // avoids the &mut self borrow conflict against the
1644                // shared self.jit.chunk_compiler read.
1645                let vm_ptr: *mut Vm = self;
1646                let _jit_vm_guard = self.jit.chunk_compiler.enter(vm_ptr, Some(cl));
1647                // SAFETY: Gc<T> is NonNull<T> over the GC heap; the heap is single-threaded and the pointer is live as long as it is reachable from active roots (see heap.rs:5-7).
1648                let r = unsafe { f() };
1649                drop(_jit_vm_guard);
1650                // A JIT helper may have detected a metatable
1651                // on a table operand and parked a deopt request here.
1652                // Discard the sentinel value and return None so the caller
1653                // re-runs the call through the interpreter, which honours
1654                // __index/__newindex.
1655                if self.jit.pending_err.take().is_some() {
1656                    return None;
1657                }
1658                Some(if returns_one {
1659                    let v = if ret_is_float {
1660                        Value::Float(f64::from_bits(r as u64))
1661                    } else if ret_is_table {
1662                        Value::Table(crate::runtime::Gc::from_ptr(
1663                            r as *mut crate::runtime::Table,
1664                        ))
1665                    } else {
1666                        Value::Int(r)
1667                    };
1668                    vec![v]
1669                } else {
1670                    Vec::new()
1671                })
1672            }
1673            // Non-zero-arg Compiled state: call_value's empty-args
1674            // fast path can't drive it. Op::Call handles those.
1675            JitProtoState::Compiled { .. } | JitProtoState::Failed | JitProtoState::Untried => None,
1676        }
1677    }
1678
1679    /// Populate the cache slot. Flips `Untried` to either
1680    /// `Compiled { … }` or `Failed`; idempotent on already-populated
1681    /// states (call sites guard with a get before invoking).
1682    ///
1683    /// Consults a thread-local cross-`Vm` cache keyed by a hash of
1684    /// `proto.code`. Compiled artefacts live in the thread-local
1685    /// `JITModule` so their mmap pages outlive the `Vm`; subsequent
1686    /// `Vm`s loading the same source skip the cranelift compile step
1687    /// entirely.
1688    fn populate_jit_cache(&mut self, proto: Gc<crate::runtime::function::Proto>) {
1689        use crate::runtime::function::JitProtoState;
1690        let version = self.version();
1691        let pre53 = version <= crate::version::LuaVersion::Lua53;
1692        // 5.1 and 5.2 have no Int subtype (all numbers
1693        // are Float). The JIT's `GetUpval` ValueRead path uses this
1694        // to default-pin upvalue reads to Float without a tag check.
1695        let float_only = version <= crate::version::LuaVersion::Lua52;
1696        // Split-borrow JitState so the
1697        // trait method can take `&mut dyn JitStorage` without
1698        // double-borrowing self.jit.
1699        let jit = &mut self.jit;
1700        jit.storage.claim(self.jit_owner_id);
1701        let storage: &mut dyn crate::jit::JitStorage = jit.storage.as_mut();
1702        match jit
1703            .chunk_compiler
1704            .try_compile(storage, proto, pre53, float_only)
1705        {
1706            crate::jit::CompileResult::Compiled {
1707                entry,
1708                num_args,
1709                returns_one,
1710                arg_float_mask,
1711                arg_table_mask,
1712                ret_is_float,
1713                ret_is_table,
1714            } => {
1715                proto.jit.set(JitProtoState::Compiled {
1716                    entry,
1717                    num_args,
1718                    returns_one,
1719                    arg_float_mask,
1720                    arg_table_mask,
1721                    ret_is_float,
1722                    ret_is_table,
1723                });
1724            }
1725            crate::jit::CompileResult::Skipped => {
1726                proto.jit.set(JitProtoState::Failed);
1727            }
1728        }
1729    }
1730
1731    /// `Op::Call` JIT fast path. Run inside `begin_call`
1732    /// before `push_frame`. Returns `true` when the call was handled
1733    /// in-place (no new Lua frame). Constraints: every arg slot must
1734    /// be `Value::Int`, the cached arity must match the call site's
1735    /// `nargs`, the host wanted-count `wanted` is honoured by
1736    /// `finish_results`. Also bails when a debug hook is armed —
1737    /// JIT'd code does not fire line / call / return hooks, so any
1738    /// active hook makes the interpreter the source of truth.
1739    fn try_jit_call_op(
1740        &mut self,
1741        cl: Gc<LuaClosure>,
1742        func_slot: u32,
1743        nargs: u32,
1744        wanted: i32,
1745    ) -> bool {
1746        use crate::runtime::function::JitProtoState;
1747        if !self.jit.enabled {
1748            return false;
1749        }
1750        // Any active debug hook means the interpreter has to run the
1751        // call so the hook gets the expected events.
1752        if self.hook.func.is_some() || self.hook.rust_func.is_some() {
1753            return false;
1754        }
1755        let proto = cl.proto;
1756        if let JitProtoState::Untried = proto.jit.get() {
1757            self.populate_jit_cache(proto);
1758        }
1759        let JitProtoState::Compiled {
1760            entry,
1761            num_args,
1762            returns_one,
1763            arg_float_mask,
1764            arg_table_mask,
1765            ret_is_float,
1766            ret_is_table,
1767        } = proto.jit.get()
1768        else {
1769            return false;
1770        };
1771        if num_args as u32 != nargs {
1772            return false;
1773        }
1774        // Pack args into i64 bit-patterns per the per-slot expected
1775        // kind. A Float-typed slot accepts Value::Float verbatim (and on
1776        // 5.1/5.2 promotes Value::Int(x) via i64 → f64); a Table-typed slot
1777        // accepts only Value::Table and passes the raw Gc ptr; an
1778        // Int-typed slot accepts only Value::Int. Any other shape
1779        // bails to the interpreter so the call's actual dynamics
1780        // (metamethod dispatch / type-coerce) take over.
1781        let mut args: [i64; crate::jit::MAX_JIT_ARITY as usize] =
1782            [0; crate::jit::MAX_JIT_ARITY as usize];
1783        // From 5.3 an integer is its own subtype: turned into a float for
1784        // a float-typed parameter, it would come back out (returned,
1785        // stored, printed) as a float. Only 5.1/5.2, where every number
1786        // is a float, may convert it.
1787        let int_as_float = self.version() <= crate::version::LuaVersion::Lua52;
1788        for i in 0..num_args as usize {
1789            let v = self.stack[(func_slot + 1) as usize + i];
1790            let want_float = (arg_float_mask >> i) & 1 == 1;
1791            let want_table = (arg_table_mask >> i) & 1 == 1;
1792            args[i] = match (want_table, want_float, v) {
1793                (true, _, Value::Table(t)) => t.as_ptr() as i64,
1794                (false, false, Value::Int(x)) => x,
1795                (false, true, Value::Float(f)) => f.to_bits() as i64,
1796                (false, true, Value::Int(x)) if int_as_float => (x as f64).to_bits() as i64,
1797                _ => return false,
1798            };
1799        }
1800        // Vm + closure pin for helpers, routed through chunk_compiler;
1801        // see the matching guard in `try_jit_call`.
1802        let vm_ptr: *mut Vm = self;
1803        let _jit_vm_guard = self.jit.chunk_compiler.enter(vm_ptr, Some(cl));
1804        // SAFETY: the source `*const u8` is a JIT-compiled function entry pointer produced by Cranelift with the target `fn`-pointer signature (IntChunkFn / IntFnN); the JitVmGuard above keeps the JIT_VM TLS slot live across the call.
1805        let r = unsafe {
1806            match num_args {
1807                0 => (std::mem::transmute::<*const u8, crate::jit::IntChunkFn>(entry))(),
1808                1 => (std::mem::transmute::<*const u8, crate::jit::IntFn1>(entry))(args[0]),
1809                2 => {
1810                    (std::mem::transmute::<*const u8, crate::jit::IntFn2>(entry))(args[0], args[1])
1811                }
1812                3 => (std::mem::transmute::<*const u8, crate::jit::IntFn3>(entry))(
1813                    args[0], args[1], args[2],
1814                ),
1815                4 => (std::mem::transmute::<*const u8, crate::jit::IntFn4>(entry))(
1816                    args[0], args[1], args[2], args[3],
1817                ),
1818                _ => unreachable!("MAX_JIT_ARITY enforces num_args <= 4"),
1819            }
1820        };
1821        drop(_jit_vm_guard);
1822        // See matching path in `try_jit_call`. A helper
1823        // flagged a metatable on a table operand; bail to the interpreter
1824        // so `push_frame` runs the call from scratch.
1825        if self.jit.pending_err.take().is_some() {
1826            return false;
1827        }
1828        // Write result at func_slot, replacing the closure value, then
1829        // hand to finish_results to pad/truncate per the call site's
1830        // `wanted` count.
1831        if returns_one {
1832            let v = if ret_is_float {
1833                Value::Float(f64::from_bits(r as u64))
1834            } else if ret_is_table {
1835                Value::Table(crate::runtime::Gc::from_ptr(
1836                    r as *mut crate::runtime::Table,
1837                ))
1838            } else {
1839                Value::Int(r)
1840            };
1841            self.stack[func_slot as usize] = v;
1842            self.finish_results(func_slot, 1, wanted);
1843        } else {
1844            self.finish_results(func_slot, 0, wanted);
1845        }
1846        true
1847    }
1848
1849    /// `call_value` with control over the `from_c` debug boundary. A `__close`
1850    /// handler runs *within* the closing Lua frame's activation (PUC luaF_close
1851    /// invokes it inside that ci), so it is called with `from_c = false`: its
1852    /// debug parent is the closing function, not a synthetic C level.
1853    fn call_value_impl(
1854        &mut self,
1855        f: Value,
1856        args: &[Value],
1857        from_c: bool,
1858    ) -> Result<Vec<Value>, LuaError> {
1859        if self.c_depth >= MAX_C_DEPTH {
1860            // PUC `luaE_checkcstack`: at the limit the call fails; an xpcall
1861            // handler running on the error gets a tenth more room before its
1862            // own failure is "error in error handling"
1863            if self.msgh_depth == 0 {
1864                return Err(self.runerror("C stack overflow"));
1865            }
1866            if self.c_depth >= MAX_C_DEPTH / 10 * 11 {
1867                return Err(self.plain_err("error in error handling"));
1868            }
1869        }
1870        self.c_depth += 1;
1871        let func_slot = self.stack.len() as u32;
1872        self.stack.push(f);
1873        self.stack.extend_from_slice(args);
1874        self.top = self.stack.len() as u32;
1875        let r = self.call_at(func_slot, args.len() as u32, from_c);
1876        self.c_depth -= 1;
1877        if r.is_err()
1878            && self.yielding.is_none()
1879            && self.terminating.is_none()
1880            && !self.host_yield_pending
1881            && self.pending_async_native_fut.is_none()
1882        {
1883            // A `coroutine.yield` in flight raises a sentinel error to unwind the
1884            // Rust stack, but the suspended coroutine's frames/registers (which
1885            // sit at/above `func_slot`) must survive for the next resume — so we
1886            // only truncate on a real error. A self-close termination is in the
1887            // same boat: the dying thread's state is discarded wholesale.
1888            // A `host_yield_pending` cooperative yield is in
1889            // the same boat as `yielding`: the next `EvalFuture::poll`
1890            // resumes the same call, so the in-flight frames must
1891            // survive.
1892            self.stack.truncate(func_slot as usize);
1893            self.top = func_slot;
1894        }
1895        r
1896    }
1897
1898    /// Invoke `f` with the running thread marked non-yieldable for the duration
1899    /// (PUC `luaD_callnoyield`): a `coroutine.yield` inside `f` hits the C-call
1900    /// boundary and errors instead of suspending. Used by library callbacks
1901    /// (sort comparator, gsub replacement) that run via synchronous Rust
1902    /// recursion and so could not be re-entered after a yield.
1903    pub(crate) fn call_noyield(
1904        &mut self,
1905        f: Value,
1906        args: &[Value],
1907    ) -> Result<Vec<Value>, LuaError> {
1908        self.nny += 1;
1909        let r = self.call_value(f, args);
1910        self.nny -= 1;
1911        r
1912    }
1913
1914    // ---- coroutines ----
1915
1916    pub(crate) fn new_coro(&mut self, body: Value) -> Gc<Coro> {
1917        // The new coroutine inherits the creating thread's current globals
1918        // (PUC `lua_newthread`: the new state copies `g->mainthread`'s
1919        // `l_gt`). `Vm.globals` always reflects the live thread, so reading
1920        // it here picks the creator regardless of which coro is running.
1921        self.heap.new_coro(body, self.globals)
1922    }
1923
1924    /// Is `t` the thread whose context is currently live in the VM?
1925    pub(crate) fn is_current_thread(&self, t: Option<Gc<Coro>>) -> bool {
1926        match (self.current, t) {
1927            (None, None) => true,
1928            (Some(a), Some(b)) => a.ptr_eq(b),
1929            _ => false,
1930        }
1931    }
1932
1933    /// Read an open-upvalue slot from its owning thread's stack (the live VM
1934    /// stack if that thread is current, else its saved context).
1935    #[doc(hidden)]
1936    pub fn read_slot(&self, slot: u32, thread: Option<Gc<Coro>>) -> Value {
1937        let s = slot as usize;
1938        if self.is_current_thread(thread) {
1939            self.stack[s]
1940        } else {
1941            match thread {
1942                Some(co) => co.stack[s],
1943                None => self.main_ctx.as_ref().expect("main context").stack[s],
1944            }
1945        }
1946    }
1947
1948    fn write_slot(&mut self, slot: u32, thread: Option<Gc<Coro>>, v: Value) {
1949        let s = slot as usize;
1950        if self.is_current_thread(thread) {
1951            self.stack[s] = v;
1952        } else {
1953            match thread {
1954                Some(co) => {
1955                    // SAFETY: Gc<T> is NonNull<T> over the GC heap; the heap is single-threaded and the pointer is live as long as it is reachable from active roots (see heap.rs:5-7).
1956                    unsafe { co.as_mut() }.stack[s] = v;
1957                    // co.stack is traced by Coro::trace; demote co back to
1958                    // gray so propagate re-traces this slot if it was
1959                    // already black.
1960                    self.heap
1961                        .barrier_back(co.as_ptr() as *mut crate::runtime::heap::GcHeader);
1962                }
1963                None => self.main_ctx.as_mut().expect("main context").stack[s] = v,
1964            }
1965        }
1966    }
1967
1968    /// Whether `co` is the main thread's identity object.
1969    pub(crate) fn is_main_coro(&self, co: Gc<Coro>) -> bool {
1970        self.main_coro.is_some_and(|m| m.ptr_eq(co))
1971    }
1972
1973    /// The status of `co` from the caller's view. The main thread's identity
1974    /// object has no stored status — it is "running" when nothing else runs,
1975    /// else "normal" (it resumed the active coroutine).
1976    pub(crate) fn effective_coro_status(&self, co: Gc<Coro>) -> CoroStatus {
1977        if self.is_main_coro(co) {
1978            if self.current.is_none() {
1979                CoroStatus::Running
1980            } else {
1981                CoroStatus::Normal
1982            }
1983        } else {
1984            co.status
1985        }
1986    }
1987
1988    /// `coroutine.close` (PUC `lua_closethread`): run the suspended coroutine's
1989    /// pending to-be-closed `__close` handlers, then mark it dead and drop its
1990    /// context. Handlers see the coroutine's death error (if it died by error)
1991    /// or nil; an error they raise propagates out. `Ok(Some(e))` means it died
1992    /// with error `e` and no handler overrode it; `Err` means a handler raised.
1993    pub(crate) fn close_coro(&mut self, co: Gc<Coro>) -> Result<Option<Value>, LuaError> {
1994        // re-entrant close: a __close handler closed its own coroutine while the
1995        // outer close is mid-flight (its context is live). Report success and let
1996        // the outer close finish — re-entering the swap would corrupt the stack.
1997        if self.current.is_some_and(|c| c.ptr_eq(co)) {
1998            return Ok(None);
1999        }
2000        // A chain of coroutines whose `__close` handlers each close the previous
2001        // one recurses on the C stack (PUC `luaD_callnoyield` in `lua_closethread`).
2002        // The calling handler's `call_value` has already pushed `c_depth` to the
2003        // cap, so here it reads as full first — report PUC's "C stack overflow"
2004        // before the next handler call would surface the plainer "stack overflow".
2005        if self.c_depth >= MAX_C_DEPTH {
2006            return Err(self.rt_err("C stack overflow"));
2007        }
2008        // SAFETY: Gc<T> is NonNull<T> over the GC heap; the heap is single-threaded and the pointer is live as long as it is reachable from active roots (see heap.rs:5-7).
2009        let death_err = unsafe { co.as_mut() }.error_value.take();
2010        // swap the caller's live context out (into a GC-rooted home) and the
2011        // coroutine's in, mirroring resume_coro, so the __close handlers run on
2012        // the coroutine's stack while everything stays rooted.
2013        let resumer = self.current;
2014        let rctx = self.take_ctx();
2015        match resumer {
2016            Some(r) => {
2017                // SAFETY: Gc<T> is NonNull<T> over the GC heap; the heap is single-threaded and the pointer is live as long as it is reachable from active roots (see heap.rs:5-7).
2018                let m = unsafe { r.as_mut() };
2019                m.stack = rctx.stack;
2020                m.frames = rctx.frames;
2021                m.frame_ccmt = rctx.frame_ccmt;
2022                m.open_upvals = rctx.open_upvals;
2023                m.tbc = rctx.tbc;
2024                m.top = rctx.top;
2025                m.pcall_depth = rctx.pcall_depth;
2026            }
2027            None => self.main_ctx = Some(rctx),
2028        }
2029        self.load_coro_ctx(co);
2030        self.current = Some(co);
2031        // PUC `luaE_resetthread` closes with no message handler, whatever
2032        // xpcall the coroutine was suspended in
2033        let natives_base = std::mem::replace(&mut self.natives_base, self.running_natives.len());
2034        let msgh_floor = std::mem::replace(&mut self.msgh_floor, self.frames.len());
2035        let result = self.close_slots(0, death_err);
2036        self.natives_base = natives_base;
2037        self.msgh_floor = msgh_floor;
2038        // discard the (now-closed) coroutine context and restore the caller
2039        let _ = self.take_ctx();
2040        match resumer {
2041            Some(r) => {
2042                self.load_coro_ctx(r);
2043                self.current = Some(r);
2044            }
2045            None => {
2046                let m = self.main_ctx.take().expect("main context saved");
2047                self.put_ctx(m);
2048                self.current = None;
2049            }
2050        }
2051        {
2052            // SAFETY: Gc<T> is NonNull<T> over the GC heap; the heap is single-threaded and the pointer is live as long as it is reachable from active roots (see heap.rs:5-7).
2053            let m = unsafe { co.as_mut() };
2054            m.status = CoroStatus::Dead;
2055            m.stack = Vec::new();
2056            m.frames = Vec::new();
2057            m.frame_ccmt = Vec::new();
2058            m.open_upvals = Vec::new();
2059            m.tbc = Vec::new();
2060            m.top = 0;
2061            m.pcall_depth = 0;
2062            m.resume_at = None;
2063            m.error_value = None;
2064            m.error_traceback = None;
2065            m.error_levels = None;
2066        }
2067        result.map(|()| death_err)
2068    }
2069
2070    /// `coroutine.running`: the running thread plus whether it is the main one.
2071    pub(crate) fn running_thread(&self) -> (Value, bool) {
2072        match self.current {
2073            Some(co) => (Value::Coro(co), false),
2074            None => (Value::Coro(self.main_coro.expect("main coro")), true),
2075        }
2076    }
2077
2078    /// `coroutine.isyieldable([co])`: whether `co` (default: the running
2079    /// thread) can yield. The main thread never can; any other coroutine can
2080    /// unless it is dead.
2081    pub(crate) fn is_yieldable(&self, co: Option<Gc<Coro>>) -> bool {
2082        match co {
2083            Some(c) => !self.main_coro.is_some_and(|m| m.ptr_eq(c)) && c.status != CoroStatus::Dead,
2084            // the running thread can yield only outside any non-yieldable C call
2085            None => self.current.is_some() && self.nny == 0,
2086        }
2087    }
2088
2089    /// Why `coroutine.yield` may not suspend the running thread right now, as a
2090    /// PUC error message — `None` if it may. Distinguishes "not in a coroutine"
2091    /// from "inside an unyieldable C call" (sort/gsub callback).
2092    pub(crate) fn yield_barrier(&self) -> Option<&'static str> {
2093        // 5.1's pcall/xpcall are plain C calls (no continuations), so a yield
2094        // below one crosses the boundary like any other; 5.1 also has a single
2095        // wording for every case, the main thread included.
2096        // 5.1 also calls every metamethod and generic-for iterator through
2097        // `luaD_call`, which counts as a C level, so a yield from inside one
2098        // is refused as well.
2099        if self.version <= LuaVersion::Lua51 {
2100            let inside_call = self.frames.iter().enumerate().any(|(i, f)| match f {
2101                CallFrame::Cont(nc) => matches!(nc.kind, ContKind::Meta(_)),
2102                CallFrame::Lua(fr) => {
2103                    fr.tm.is_some()
2104                        || (i > 0
2105                            && self.frames[i - 1].lua().is_some_and(|c| {
2106                                let pc = (c.pc as usize).wrapping_sub(1);
2107                                c.closure
2108                                    .proto
2109                                    .code
2110                                    .get(pc)
2111                                    .is_some_and(|ins| ins.op() == Op::TForCall)
2112                            }))
2113                }
2114            });
2115            if self.current.is_none() || self.nny > 0 || self.pcall_depth > 0 || inside_call {
2116                return Some("attempt to yield across metamethod/C-call boundary");
2117            }
2118            return None;
2119        }
2120        if self.current.is_none() {
2121            Some("attempt to yield from outside a coroutine")
2122        } else if self.nny > 0 {
2123            Some("attempt to yield across a C-call boundary")
2124        } else {
2125            None
2126        }
2127    }
2128
2129    /// The coroutine whose context is currently live (`None` on the main thread).
2130    pub(crate) fn current_coro(&self) -> Option<Gc<Coro>> {
2131        self.current
2132    }
2133
2134    /// `coroutine.close()` on the *running* thread (PUC 5.5 close-self): run all
2135    /// its pending `__close` handlers, then signal termination. The handlers run
2136    /// here, in place, with the thread still non-yieldable (a yield in one hits
2137    /// the C-call boundary). The returned sentinel unwinds the Rust stack the
2138    /// way a yield does — `exec_with` propagates it past any protecting pcall
2139    /// rather than letting `unwind` catch it — and `resume_coro` turns it into a
2140    /// clean death (or, if a handler raised, the coroutine's error).
2141    pub(crate) fn close_running(&mut self) -> LuaError {
2142        let death = match self.close_slots(0, None) {
2143            Ok(()) => None,
2144            Err(e) => Some(e.0),
2145        };
2146        self.terminating = Some(death);
2147        LuaError(Value::Nil)
2148    }
2149
2150    /// `coroutine.status` as seen by the caller.
2151    pub(crate) fn coro_status_str(&self, co: Gc<Coro>) -> &'static str {
2152        match self.effective_coro_status(co) {
2153            CoroStatus::Suspended => "suspended",
2154            CoroStatus::Running => "running",
2155            CoroStatus::Normal => "normal",
2156            CoroStatus::Dead => "dead",
2157        }
2158    }
2159
2160    fn take_ctx(&mut self) -> SavedCtx {
2161        let saved = SavedCtx {
2162            stack: std::mem::take(&mut self.stack),
2163            frames: std::mem::take(&mut self.frames),
2164            frame_ccmt: std::mem::take(&mut self.frame_ccmt),
2165            open_upvals: std::mem::take(&mut self.open_upvals),
2166            tbc: std::mem::take(&mut self.tbc),
2167            top: self.top,
2168            pcall_depth: self.pcall_depth,
2169            hook: self.hook,
2170            globals: self.globals,
2171        };
2172        self.frames_resync(); // frames now empty
2173        saved
2174    }
2175
2176    fn put_ctx(&mut self, c: SavedCtx) {
2177        self.stack = c.stack;
2178        self.frames = c.frames;
2179        self.frame_ccmt = c.frame_ccmt;
2180        self.open_upvals = c.open_upvals;
2181        self.tbc = c.tbc;
2182        self.top = c.top;
2183        self.pcall_depth = c.pcall_depth;
2184        self.hook = c.hook;
2185        self.globals = c.globals;
2186        self.frames_resync(); // sync shadow to new Vec
2187    }
2188
2189    /// Move a coroutine's saved context into the live VM fields.
2190    fn load_coro_ctx(&mut self, co: Gc<Coro>) {
2191        // SAFETY: Gc<T> is NonNull<T> over the GC heap; the heap is single-threaded and the pointer is live as long as it is reachable from active roots (see heap.rs:5-7).
2192        let m = unsafe { co.as_mut() };
2193        self.stack = std::mem::take(&mut m.stack);
2194        self.frames = std::mem::take(&mut m.frames);
2195        self.frame_ccmt = std::mem::take(&mut m.frame_ccmt);
2196        self.open_upvals = std::mem::take(&mut m.open_upvals);
2197        self.tbc = std::mem::take(&mut m.tbc);
2198        self.top = m.top;
2199        self.frames_resync(); // sync shadow to coro's frames
2200        self.pcall_depth = m.pcall_depth;
2201        self.hook = m.hook;
2202        self.globals = m.globals;
2203    }
2204
2205    /// Save the live VM context back into a coroutine object.
2206    fn store_coro_ctx(&mut self, co: Gc<Coro>) {
2207        let c = self.take_ctx();
2208        // SAFETY: Gc<T> is NonNull<T> over the GC heap; the heap is single-threaded and the pointer is live as long as it is reachable from active roots (see heap.rs:5-7).
2209        let m = unsafe { co.as_mut() };
2210        m.stack = c.stack;
2211        m.frames = c.frames;
2212        m.frame_ccmt = c.frame_ccmt;
2213        m.open_upvals = c.open_upvals;
2214        m.tbc = c.tbc;
2215        m.top = c.top;
2216        m.pcall_depth = c.pcall_depth;
2217        m.hook = c.hook;
2218        m.globals = c.globals;
2219        // bulk-overwrite of every collectable field traced by Coro::trace:
2220        // demote the coro back to gray so propagate re-traces its new state.
2221        self.heap
2222            .barrier_back(co.as_ptr() as *mut crate::runtime::heap::GcHeader);
2223    }
2224
2225    /// `coroutine.resume` core: drive `co` with `args` until it yields, returns
2226    /// or errors. Ok(values) carries yielded or returned values; Err carries an
2227    /// error raised inside the coroutine (the coroutine becomes dead).
2228    pub(crate) fn resume_coro(
2229        &mut self,
2230        co: Gc<Coro>,
2231        args: Vec<Value>,
2232    ) -> Result<Vec<Value>, LuaError> {
2233        match co.status {
2234            CoroStatus::Suspended => {}
2235            CoroStatus::Dead => return Err(self.plain_err("cannot resume dead coroutine")),
2236            _ => return Err(self.plain_err("cannot resume non-suspended coroutine")),
2237        }
2238        if self.c_depth >= MAX_C_DEPTH {
2239            return Err(self.plain_err("C stack overflow"));
2240        }
2241        self.c_depth += 1;
2242        let resumer = self.current;
2243        // save the resumer's live context away
2244        let rctx = self.take_ctx();
2245        match resumer {
2246            Some(r) => {
2247                // SAFETY: Gc<T> is NonNull<T> over the GC heap; the heap is single-threaded and the pointer is live as long as it is reachable from active roots (see heap.rs:5-7).
2248                let m = unsafe { r.as_mut() };
2249                m.stack = rctx.stack;
2250                m.frames = rctx.frames;
2251                m.frame_ccmt = rctx.frame_ccmt;
2252                m.open_upvals = rctx.open_upvals;
2253                m.tbc = rctx.tbc;
2254                m.top = rctx.top;
2255                m.pcall_depth = rctx.pcall_depth;
2256                m.globals = rctx.globals;
2257                m.status = CoroStatus::Normal;
2258                m.natives = self.natives_base..self.running_natives.len();
2259                // bulk overwrite of every traced field on r — mirror
2260                // store_coro_ctx's barrier_back so propagate re-traces r.
2261                self.heap
2262                    .barrier_back(r.as_ptr() as *mut crate::runtime::heap::GcHeader);
2263            }
2264            None => self.main_ctx = Some(rctx),
2265        }
2266        // swap the coroutine in
2267        self.load_coro_ctx(co);
2268        {
2269            // SAFETY: Gc<T> is NonNull<T> over the GC heap; the heap is single-threaded and the pointer is live as long as it is reachable from active roots (see heap.rs:5-7).
2270            let m = unsafe { co.as_mut() };
2271            m.status = CoroStatus::Running;
2272            m.resumer = resumer;
2273        }
2274        // co.resumer is a traced Gc field; barrier_back covers the new
2275        // resumer reference and any future field writes during this call.
2276        self.heap
2277            .barrier_back(co.as_ptr() as *mut crate::runtime::heap::GcHeader);
2278        self.current = Some(co);
2279        let resumer_natives_base = self.natives_base;
2280        self.natives_base = self.running_natives.len();
2281        // the coroutine's own frames start a fresh reach for xpcall handlers
2282        let resumer_msgh_floor = std::mem::replace(&mut self.msgh_floor, 0);
2283        let resumer_msgh_running = self.msgh_running.take();
2284        // a coroutine that dies keeps its traceback for `debug.traceback(co)`
2285        let resumer_keeps_traceback = std::mem::replace(&mut self.keep_error_traceback, true);
2286
2287        // drive it
2288        let drive = if co.started {
2289            self.coro_continue(&args)
2290        } else {
2291            // SAFETY: Gc<T> is NonNull<T> over the GC heap; the heap is single-threaded and the pointer is live as long as it is reachable from active roots (see heap.rs:5-7).
2292            unsafe { co.as_mut() }.started = true;
2293            self.coro_first(co.body, &args)
2294        };
2295
2296        // classify: a self-close termination or a pending yield each win over
2297        // the (sentinel) error they raised to unwind the Rust stack.
2298        let (outcome, status) = if let Some(death) = self.terminating.take() {
2299            // the coroutine closed itself: it dies now, cleanly or with the
2300            // error a `__close` handler raised.
2301            match death {
2302                Some(e) => {
2303                    // SAFETY: Gc<T> is NonNull<T> over the GC heap; the heap is single-threaded and the pointer is live as long as it is reachable from active roots (see heap.rs:5-7).
2304                    unsafe { co.as_mut() }.error_value = Some(e);
2305                    self.heap
2306                        .barrier_back(co.as_ptr() as *mut crate::runtime::heap::GcHeader);
2307                    (Err(LuaError(e)), CoroStatus::Dead)
2308                }
2309                None => (Ok(Vec::new()), CoroStatus::Dead),
2310            }
2311        } else {
2312            match self.yielding.take() {
2313                Some((vals, fslot, nres)) => {
2314                    // SAFETY: Gc<T> is NonNull<T> over the GC heap; the heap is single-threaded and the pointer is live as long as it is reachable from active roots (see heap.rs:5-7).
2315                    unsafe { co.as_mut() }.resume_at = Some((fslot, nres));
2316                    (Ok(vals), CoroStatus::Suspended)
2317                }
2318                None => {
2319                    // died: a return is clean, an error is remembered so a later
2320                    // `coroutine.close` can report it (PUC lua_closethread).
2321                    // Keep the error-point traceback (taken by `unwind` before
2322                    // popping the failing frames) so `debug.traceback(co)` on
2323                    // the dead coroutine still shows the error site, as PUC's
2324                    // untouched dead stack does (db.lua :848 family).
2325                    if drive.is_err() {
2326                        let levels = self.error_traceback.take().unwrap_or_default();
2327                        let tb =
2328                            crate::vm::callstack::traceback_from_lines(self.version, &levels, 0);
2329                        // SAFETY: Gc<T> is NonNull<T> over the GC heap; the heap is single-threaded and the pointer is live as long as it is reachable from active roots (see heap.rs:5-7).
2330                        let m = unsafe { co.as_mut() };
2331                        m.error_traceback = Some(tb);
2332                        m.error_levels = Some(levels);
2333                    }
2334                    if let Err(e) = drive {
2335                        // SAFETY: Gc<T> is NonNull<T> over the GC heap; the heap is single-threaded and the pointer is live as long as it is reachable from active roots (see heap.rs:5-7).
2336                        unsafe { co.as_mut() }.error_value = Some(e.0);
2337                        self.heap
2338                            .barrier_back(co.as_ptr() as *mut crate::runtime::heap::GcHeader);
2339                    }
2340                    (drive, CoroStatus::Dead)
2341                }
2342            }
2343        };
2344
2345        // save the coroutine's context back and restore the resumer
2346        self.natives_base = resumer_natives_base;
2347        self.msgh_floor = resumer_msgh_floor;
2348        self.msgh_running = resumer_msgh_running;
2349        self.keep_error_traceback = resumer_keeps_traceback;
2350        self.store_coro_ctx(co);
2351        // SAFETY: Gc<T> is NonNull<T> over the GC heap; the heap is single-threaded and the pointer is live as long as it is reachable from active roots (see heap.rs:5-7).
2352        unsafe { co.as_mut() }.status = status;
2353        match resumer {
2354            Some(r) => {
2355                self.load_coro_ctx(r);
2356                // SAFETY: Gc<T> is NonNull<T> over the GC heap; the heap is single-threaded and the pointer is live as long as it is reachable from active roots (see heap.rs:5-7).
2357                unsafe { r.as_mut() }.status = CoroStatus::Running;
2358                self.current = Some(r);
2359            }
2360            None => {
2361                let m = self.main_ctx.take().expect("main context saved");
2362                self.put_ctx(m);
2363                self.current = None;
2364            }
2365        }
2366        self.c_depth -= 1;
2367        outcome
2368    }
2369
2370    /// First resume: install the body function at slot 0 and run.
2371    fn coro_first(&mut self, body: Value, args: &[Value]) -> Result<Vec<Value>, LuaError> {
2372        self.stack.clear();
2373        self.stack.push(body);
2374        self.stack.extend_from_slice(args);
2375        self.top = self.stack.len() as u32;
2376        match self.begin_call(0, Some(args.len() as u32), -1, true) {
2377            Ok(true) => self.exec_with(1),
2378            Ok(false) => Ok(self.take_results(0)),
2379            Err(e) => Err(e),
2380        }
2381    }
2382
2383    /// Resume after a yield: deliver `args` as the results of the call that
2384    /// yielded, then continue the suspended thread.
2385    fn coro_continue(&mut self, args: &[Value]) -> Result<Vec<Value>, LuaError> {
2386        let (fslot, nres) = self.current.unwrap().resume_at.expect("resume point");
2387        let n = args.len() as u32;
2388        // Restore the full register window of the suspended top frame: a yield
2389        // that unwound through a native (call_value) may have left the stack
2390        // shorter than the frame needs. `base + max_stack` is what push_frame
2391        // allocates; `fslot + n` covers the delivered yield results.
2392        let frame_need = self
2393            .frames
2394            .last()
2395            .and_then(CallFrame::lua)
2396            .map(|f| (f.base + f.closure.proto.max_stack as u32) as usize)
2397            .unwrap_or(0);
2398        let need = frame_need.max((fslot + n) as usize);
2399        if self.stack.len() < need {
2400            self.stack.resize(need, Value::Nil);
2401        }
2402        for (i, &v) in args.iter().enumerate() {
2403            self.stack[fslot as usize + i] = v;
2404        }
2405        self.finish_results(fslot, n, nres);
2406        // the suspended `coroutine.yield` (a C call) now returns its resume
2407        // values: fire the matching "return" hook PUC defers until the resume.
2408        self.hook_return(true, 1, n)?;
2409        self.exec_with(1)
2410    }
2411
2412    /// `coroutine.yield`: suspend the running coroutine, recording where to
2413    /// resume. Errors if called outside a coroutine. Returns a sentinel error
2414    /// that `exec`/`resume_coro` recognise as a yield (never surfaced to Lua).
2415    pub(crate) fn do_yield(&mut self, func_slot: u32, vals: Vec<Value>) -> LuaError {
2416        let nres = self.native_nresults;
2417        self.yielding = Some((vals, func_slot, nres));
2418        // value is irrelevant: resume_coro consults `self.yielding`, not this
2419        LuaError(Value::Nil)
2420    }
2421
2422    /// Install or clear the debug hook on the running thread (`debug.sethook`
2423    /// without a thread argument). Arms the calling frame's `oldpc` to the
2424    /// sethook CALL's own pc (one less than the next-to-execute pc), mirroring
2425    /// PUC `rethook`'s `L->oldpc = pcRel(savedpc, p)` (= savedpc - code - 1) on
2426    /// native return: the very next traceexec compares against the sethook
2427    /// CALL's line. When the install statement and the following statement are
2428    /// on different source lines (db.lua :322), `changedline` fires for that
2429    /// first statement; when they share a line (db.lua :25 wrapper), they do
2430    /// not, so the wrapper line is not re-fired.
2431    pub(crate) fn install_hook(&mut self, hook: HookState) {
2432        self.hook = hook;
2433        if self.hook.line
2434            && let Some(f) = self.frames.last_mut().and_then(CallFrame::lua_mut)
2435        {
2436            f.hook_oldpc = f.pc.saturating_sub(1);
2437        }
2438    }
2439
2440    /// Install a hook on `target` (`None`/current thread → the live VM fields;
2441    /// another, suspended thread → its saved `Coro` state). PUC `debug.sethook`
2442    /// with an optional thread argument.
2443    ///
2444    /// `target == None` means "no explicit thread argument" — PUC binds that
2445    /// to `L` (the running thread). luna's live VM fields (`self.hook`,
2446    /// `self.frames`, `self.stack`) ARE the running thread's state, regardless
2447    /// of whether that's the main thread or a currently-resumed coroutine
2448    /// (save/restore happens at resume/yield boundaries via `load_coro_ctx`/
2449    /// `store_coro_ctx`). So a `None` target should always route to
2450    /// `install_hook` on the live fields. The pre-fix predicate gate
2451    /// `is_current_thread(target)` returned `false` when running inside a
2452    /// coroutine (`self.current = Some(co)`, `target = None` don't match)
2453    /// and silently dropped the hook on the floor — the install happened on
2454    /// no thread at all.
2455    pub(crate) fn set_hook(&mut self, target: Option<Gc<Coro>>, state: HookState) {
2456        if target.is_none() || self.is_current_thread(target) {
2457            self.install_hook(state);
2458        } else if let Some(co) = target {
2459            // SAFETY: Gc<T> is NonNull<T> over the GC heap; the heap is single-threaded and the pointer is live as long as it is reachable from active roots (see heap.rs:5-7).
2460            let m = unsafe { co.as_mut() };
2461            m.hook = state;
2462            if state.line
2463                && let Some(f) = m.frames.last_mut().and_then(CallFrame::lua_mut)
2464            {
2465                f.hook_oldpc = u32::MAX;
2466            }
2467            // co.hook.func is a traced Value (Coro::trace covers it); demote
2468            // co back to gray so propagate sees the new hook function.
2469            self.heap
2470                .barrier_back(co.as_ptr() as *mut crate::runtime::heap::GcHeader);
2471        }
2472    }
2473
2474    /// The hook state of `target` (`None`/current → the live VM state).
2475    pub(crate) fn get_hook(&self, target: Option<Gc<Coro>>) -> HookState {
2476        match target {
2477            t if self.is_current_thread(t) => self.hook,
2478            Some(co) => co.hook,
2479            None => self.hook,
2480        }
2481    }
2482
2483    /// Invoke the debug hook for `event` (PUC `luaD_hook`). The hook runs with
2484    /// hooks disabled (PUC clears the mask) and its results/stack growth are
2485    /// discarded so the interrupted frame's register window is untouched.
2486    /// `line` is the source line for a "line" event, `None` (→ nil) otherwise.
2487    fn run_hook(
2488        &mut self,
2489        event: &[u8],
2490        line: Option<i64>,
2491        from_native: bool,
2492    ) -> Result<(), LuaError> {
2493        // line and count events transfer no values (PUC `luaD_hook(L,
2494        // event, line, 0, 0)`); call and return hooks set theirs first
2495        if matches!(event, b"line" | b"count") {
2496            self.hook_ftransfer = 0;
2497            self.hook_ntransfer = 0;
2498        }
2499        // Rust hook fires first (no Vm reentrancy via call_value;
2500        // synchronous fn pointer call). Both Rust and Lua hooks may be
2501        // installed; both observe each event.
2502        if let Some(rh) = self.hook.rust_func {
2503            let evt = match event {
2504                b"call" => Some(RustHookEvent::Call),
2505                b"return" => Some(RustHookEvent::Return),
2506                b"tail call" | b"tail return" => Some(RustHookEvent::TailCall),
2507                b"line" => Some(RustHookEvent::Line(line.unwrap_or(0).max(0) as u32)),
2508                b"count" => Some(RustHookEvent::Count),
2509                _ => None,
2510            };
2511            if let Some(evt) = evt {
2512                let was_in_hook = self.in_hook;
2513                self.in_hook = true;
2514                rh(self, evt);
2515                self.in_hook = was_in_hook;
2516            }
2517        }
2518        let Some(hook) = self.hook.func else {
2519            return Ok(());
2520        };
2521        let saved_top = self.top;
2522        let saved_len = self.stack.len();
2523        let name = Value::Str(self.heap.intern(event));
2524        let lv = line.map_or(Value::Nil, Value::Int);
2525        self.in_hook = true;
2526        // PUC `db_sethook`'s C trampoline `hookf` sits between the engine and
2527        // the Lua hook — so `getinfo(2)` inside the hook resolves to whatever
2528        // ci sat below `hookf` (the function being hooked). When that hooked
2529        // function is native, no Lua frame for it exists in luna's `frames`;
2530        // model it as a synthetic C level by pushing the hook with
2531        // `from_c = true` (then `c_frame_name` reads the caller's call
2532        // instruction → e.g. `name = "sethook"`). When the hooked function is
2533        // Lua (its frame is still on the stack), push with `from_c = false`
2534        // so the level descent lands on it directly. The hook's own frame
2535        // carries `is_hook = true` so `getinfo(1).namewhat` reports "hook"
2536        // (PUC `CIST_HOOKED`).
2537        self.pending_is_hook = true;
2538        let r = self.call_value_impl(hook, &[name, lv], from_native);
2539        self.pending_is_hook = false;
2540        self.in_hook = false;
2541        self.stack.truncate(saved_len);
2542        self.top = saved_top;
2543        r.map(|_| ())
2544    }
2545
2546    /// Fire the "call" hook on entry to a function, if armed and not already in
2547    /// a hook (PUC clears the mask while a hook runs). PUC's transferinfo for
2548    /// a call hook is the param window: ftransfer = 1, ntransfer = nargs.
2549    /// `is_tail` selects the "tail call" event (PUC `LUA_HOOKTAILCALL`); a
2550    /// tail-call hook has no matching return hook (PUC luaD_pretailcall).
2551    fn hook_call_with(
2552        &mut self,
2553        from_native: bool,
2554        nargs: u32,
2555        is_tail: bool,
2556    ) -> Result<(), LuaError> {
2557        if self.hook.call
2558            && !self.in_hook
2559            && (self.hook.func.is_some() || self.hook.rust_func.is_some())
2560        {
2561            self.hook_ftransfer = 1;
2562            self.hook_ntransfer = nargs.min(u16::MAX as u32) as u16;
2563            // PUC 5.1 didn't distinguish tail-call events — every call,
2564            // including tail-calls, fired plain `"call"`. 5.2 introduced
2565            // the separate `"tail call"` event (mask `"c"` covers both).
2566            // 5.1 db.lua :366 pins this with `{"call","call","call","call",
2567            // "return","tail return","return","tail return"}`.
2568            let event: &[u8] = if is_tail && self.version >= LuaVersion::Lua52 {
2569                b"tail call"
2570            } else {
2571                b"call"
2572            };
2573            self.run_hook(event, None, from_native)?;
2574        }
2575        Ok(())
2576    }
2577
2578    pub(crate) fn hook_call(&mut self, from_native: bool, nargs: u32) -> Result<(), LuaError> {
2579        self.hook_call_with(from_native, nargs, false)
2580    }
2581
2582    /// Fire the "return" hook on exit from a function, if armed. ftransfer is
2583    /// the first result slot relative to the activation's func slot, ntransfer
2584    /// the number of results.
2585    pub(crate) fn hook_return(
2586        &mut self,
2587        from_native: bool,
2588        ftransfer: u32,
2589        nresults: u32,
2590    ) -> Result<(), LuaError> {
2591        if self.hook.ret
2592            && !self.in_hook
2593            && (self.hook.func.is_some() || self.hook.rust_func.is_some())
2594        {
2595            self.hook_ftransfer = ftransfer.min(u16::MAX as u32) as u16;
2596            self.hook_ntransfer = nresults.min(u16::MAX as u32) as u16;
2597            self.run_hook(b"return", None, from_native)?;
2598        }
2599        Ok(())
2600    }
2601
2602    /// PUC "tail return" event — fires once per tail call that collapsed
2603    /// into the activation now returning, *after* its own "return" event.
2604    /// 5.1 hook mask `"r"` covers both `return` and `tail return`.
2605    fn hook_tail_return(&mut self) -> Result<(), LuaError> {
2606        if self.hook.ret
2607            && !self.in_hook
2608            && (self.hook.func.is_some() || self.hook.rust_func.is_some())
2609        {
2610            self.run_hook(b"tail return", None, false)?;
2611        }
2612        Ok(())
2613    }
2614
2615    /// Call a metamethod with a single expected result.
2616    fn call_mm1(&mut self, f: Value, args: &[Value]) -> Result<Value, LuaError> {
2617        let mut r = self.call_value(f, args)?;
2618        Ok(if r.is_empty() {
2619            Value::Nil
2620        } else {
2621            r.swap_remove(0)
2622        })
2623    }
2624
2625    /// Begin a *yieldable* metamethod call from a VM instruction: `func(args…)`
2626    /// driven through the interpreter loop with a `Meta` continuation, so a
2627    /// `coroutine.yield` inside the metamethod suspends and resumes cleanly.
2628    /// On the metamethod's return the loop head runs `finish_meta(action, …)`.
2629    /// Returns to the caller with the call set up — the opcode arm must do no
2630    /// further work on the running frame and let the loop iterate. `tm` is
2631    /// the metamethod event name (e.g. "index", "add"); a Lua handler frame
2632    /// born from this call inherits it via `pending_tm`, so
2633    /// `debug.getinfo(1).namewhat == "metamethod"` and `.name == tm`
2634    /// (db.lua :878).
2635    fn begin_meta_call(
2636        &mut self,
2637        func: Value,
2638        args: &[Value],
2639        action: MetaAction,
2640        tm: &'static str,
2641    ) -> Result<(), LuaError> {
2642        let saved_top = self.top;
2643        let cont_slot = self.stack.len() as u32;
2644        self.stack.push(func);
2645        self.stack.extend_from_slice(args);
2646        self.top = self.stack.len() as u32;
2647        frames_push_sync(
2648            &mut self.frames,
2649            &mut self.frames_top,
2650            CallFrame::Cont(NativeCont {
2651                kind: ContKind::Meta(MetaCont { action, saved_top }),
2652                func_slot: cont_slot,
2653                nresults: 1,
2654            }),
2655        );
2656        let saved_tm = self.pending_tm.replace(tm);
2657        // begin_call drives a Lua metamethod through the loop (returns true) or
2658        // runs a native one inline (returns false, leaving results at cont_slot
2659        // for the loop head to pick up); either way the Meta cont resolves there.
2660        let r = self.begin_call(cont_slot, Some(args.len() as u32), 1, true);
2661        // Native callees never consumed pending_tm (push_frame is only hit on
2662        // a Lua callee); restore so it doesn't leak to a later push_frame.
2663        self.pending_tm = saved_tm;
2664        r?;
2665        Ok(())
2666    }
2667
2668    /// `R[dst] := t[key]` for a VM read opcode, resolving `__index` yieldably.
2669    fn op_index(&mut self, t: Value, key: Value, dst: u32) -> Result<(), LuaError> {
2670        // Read-time probe: a collectable key must be live at
2671        // the moment it is used. O(1) membership test against the
2672        // freed-pointer log — gc-verify diagnostic builds only; exact
2673        // under quarantining allocators (ASAN).
2674        #[cfg(feature = "gc-verify")]
2675        if matches!(key, Value::Str(_)) {
2676            let h = match key {
2677                Value::Str(s) => s.as_ptr() as usize,
2678                _ => unreachable!(),
2679            };
2680            if self.heap.recently_freed.contains(&h) {
2681                let (pc, reg_info) = match self.frames.last() {
2682                    Some(CallFrame::Lua(f)) => {
2683                        let pc = f.pc as usize;
2684                        let inst = f.closure.proto.code.get(pc.wrapping_sub(1));
2685                        (
2686                            pc,
2687                            inst.map(|i| {
2688                                format!(
2689                                    "op[pc-1]={:?} a={} b={} c={} base={}",
2690                                    i.op(),
2691                                    i.a(),
2692                                    i.b(),
2693                                    i.c(),
2694                                    f.base
2695                                )
2696                            })
2697                            .unwrap_or_default(),
2698                        )
2699                    }
2700                    _ => (0, String::new()),
2701                };
2702                panic!(
2703                    "[gc-verify] op_index READ of dead string key {h:#x} \
2704                     (gc_top {}, top {}, pc {pc}, {reg_info})",
2705                    self.gc_top, self.top,
2706                );
2707            }
2708        }
2709        match self.index_step(t, key)? {
2710            MmOut::Done(v) => self.stack[dst as usize] = v,
2711            MmOut::Mm { func, recv } => {
2712                self.begin_meta_call(func, &[recv, key], MetaAction::Store { dst }, "index")?;
2713            }
2714            MmOut::CompareSynth { .. } => unreachable!("CompareSynth from index_step"),
2715        }
2716        Ok(())
2717    }
2718
2719    /// `t[key] := v` for a VM write opcode, resolving `__newindex` yieldably.
2720    fn op_newindex(&mut self, t: Value, key: Value, v: Value) -> Result<(), LuaError> {
2721        match self.newindex_step(t, key, v)? {
2722            MmOut::Done(_) => {}
2723            MmOut::Mm { func, recv } => {
2724                self.begin_meta_call(func, &[recv, key, v], MetaAction::Discard, "newindex")?;
2725            }
2726            MmOut::CompareSynth { .. } => unreachable!("CompareSynth from newindex_step"),
2727        }
2728        Ok(())
2729    }
2730
2731    /// Apply a comparison opcode's outcome: a known boolean drives the
2732    /// conditional skip directly; a metamethod is called yieldably, its
2733    /// truthiness driving the skip on return.
2734    fn op_compare(
2735        &mut self,
2736        step: MmOut,
2737        l: Value,
2738        r: Value,
2739        k: bool,
2740        tm: &'static str,
2741    ) -> Result<(), LuaError> {
2742        match step {
2743            MmOut::Done(v) => self.cond_skip(v.truthy(), k),
2744            MmOut::Mm { func, .. } => {
2745                self.begin_meta_call(func, &[l, r], MetaAction::Compare { k, negate: false }, tm)?;
2746            }
2747            MmOut::CompareSynth { func } => {
2748                // ≤5.3 `__le` falls back to `not __lt(r, l)`; the swap and
2749                // negation are driven through `MetaAction::Compare` so the
2750                // metamethod call can yield like any other compare.
2751                self.begin_meta_call(func, &[r, l], MetaAction::Compare { k, negate: true }, "lt")?;
2752            }
2753        }
2754        Ok(())
2755    }
2756
2757    /// Complete a VM instruction whose metamethod just returned `result` (PUC
2758    /// `luaV_finishOp`). The running frame is already back on top.
2759    fn finish_meta(&mut self, action: MetaAction, result: Value) -> Result<(), LuaError> {
2760        match action {
2761            MetaAction::Store { dst } => self.stack[dst as usize] = result,
2762            MetaAction::Discard => {}
2763            MetaAction::Compare { k, negate } => {
2764                let t = if negate {
2765                    !result.truthy()
2766                } else {
2767                    result.truthy()
2768                };
2769                self.cond_skip(t, k);
2770            }
2771            MetaAction::Concat { dst, base_a } => {
2772                self.stack[dst as usize] = result;
2773                self.top = dst + 1;
2774                self.concat_run(base_a)?;
2775            }
2776        }
2777        Ok(())
2778    }
2779
2780    // ---- metatables ----
2781
2782    pub(crate) fn metatable_of(&self, v: Value) -> Option<Gc<Table>> {
2783        match v {
2784            Value::Table(t) => t.metatable(),
2785            Value::Userdata(u) => u.metatable(),
2786            v => type_mt_slot(v).and_then(|i| self.type_mt[i]),
2787        }
2788    }
2789
2790    /// Set the shared metatable for `v`'s basic type (debug.setmetatable on a
2791    /// non-table). No-op for tables (they carry their own).
2792    pub(crate) fn set_type_metatable(&mut self, v: Value, mt: Option<Gc<Table>>) {
2793        if let Some(i) = type_mt_slot(v) {
2794            self.type_mt[i] = mt;
2795        }
2796    }
2797
2798    /// The metamethod of `v` for `mm`, or nil.
2799    pub(crate) fn get_mm(&self, v: Value, mm: Mm) -> Value {
2800        match self.metatable_of(v) {
2801            Some(mt) => mt.get(Value::Str(self.mm_names[mm as usize])),
2802            None => Value::Nil,
2803        }
2804    }
2805
2806    /// PUC 5.1 `get_compTM`: a comparison metamethod (`__eq` / `__lt` / `__le`)
2807    /// only fires when both operands carry a metatable that exposes the same
2808    /// implementation. Returns the metamethod to call, or `Nil` when no
2809    /// compatible match exists. Used to honour events.lua 5.1 :262's rule
2810    /// that `c == d` (where `d` has no metatable) falls back to raw equality.
2811    pub(crate) fn get_comp_mm(&self, l: Value, r: Value, mm: Mm) -> Value {
2812        let mt1 = self.metatable_of(l);
2813        let Some(mt1) = mt1 else { return Value::Nil };
2814        let key = Value::Str(self.mm_names[mm as usize]);
2815        let tm1 = mt1.get(key);
2816        if tm1.is_nil() {
2817            return Value::Nil;
2818        }
2819        let mt2 = self.metatable_of(r);
2820        let Some(mt2) = mt2 else { return Value::Nil };
2821        if mt1.as_ptr() == mt2.as_ptr() {
2822            return tm1;
2823        }
2824        let tm2 = mt2.get(key);
2825        if tm2.is_nil() {
2826            return Value::Nil;
2827        }
2828        if tm1.raw_eq(tm2) {
2829            return tm1;
2830        }
2831        Value::Nil
2832    }
2833
2834    /// PUC `luaT_objtypename`: the type name shown in error messages. A table
2835    /// or full userdata whose metatable carries a string `__name` reports that
2836    /// (e.g. "FILE*", "My Type") instead of the bare "table"/"userdata".
2837    pub(crate) fn obj_typename(&self, v: Value) -> String {
2838        // `__name` (luaT_objtypename) arrived in 5.3
2839        if self.version >= LuaVersion::Lua53
2840            && matches!(v, Value::Table(_) | Value::Userdata(_))
2841            && let Value::Str(s) = self.get_mm(v, Mm::Name)
2842        {
2843            return String::from_utf8_lossy(s.as_bytes()).into_owned();
2844        }
2845        v.type_name().to_string()
2846    }
2847
2848    fn call_at(
2849        &mut self,
2850        func_slot: u32,
2851        nargs: u32,
2852        from_c: bool,
2853    ) -> Result<Vec<Value>, LuaError> {
2854        let depth = self.frames.len();
2855        match self.begin_call(func_slot, Some(nargs), -1, from_c) {
2856            // run until every frame the call pushed has returned: a pcall /
2857            // xpcall / __pairs continuation sits below the frame of the
2858            // function it called, and it is the continuation that produces
2859            // the call's results (`true, ...`, or `false, msg` on an error)
2860            Ok(true) => self.exec_with(depth + 1),
2861            // native completed inline; results at func_slot..top
2862            Ok(false) => Ok(self.take_results(func_slot)),
2863            // pcall / xpcall pushed their continuation and then failed to
2864            // call their function (`pcall("x")`): the continuation catches
2865            // that error, as it does in the dispatch loop
2866            Err(e)
2867                if self.frames.len() > depth
2868                    && self.yielding.is_none()
2869                    && self.terminating.is_none()
2870                    && !self.host_yield_pending
2871                    && self.pending_async_native_fut.is_none() =>
2872            {
2873                match self.unwind(e.0, depth + 1) {
2874                    Unwound::Caught => self.exec_with(depth + 1),
2875                    Unwound::CaughtReturn(vals) => Ok(vals),
2876                    Unwound::Propagated(err) => Err(err),
2877                }
2878            }
2879            Err(e) => Err(e),
2880        }
2881    }
2882
2883    /// Switch the `collectgarbage` mode, returning the previous mode name.
2884    pub(crate) fn gc_switch_mode(&mut self, new: &'static str) -> &'static str {
2885        std::mem::replace(&mut self.gc_mode, new)
2886    }
2887
2888    /// Whether the current `collectgarbage` mode is "generational" (where a
2889    /// "step" is a minor collection — a full atomic pass — rather than a paced
2890    /// incremental sweep).
2891    pub(crate) fn gc_mode_is_generational(&self) -> bool {
2892        self.gc_mode == "generational"
2893    }
2894
2895    /// Current `stepsize` pacing parameter (PUC: 0 means an unbounded step that
2896    /// completes a whole cycle at once).
2897    pub(crate) fn gc_stepsize(&self) -> i64 {
2898        self.gc_stepsize
2899    }
2900
2901    /// Set luna's collector knobs: heap growth before a new cycle (%), sweep
2902    /// work per safe point, and the default step size (0 = a step completes
2903    /// the cycle).
2904    pub(crate) fn set_gc_pacing(&mut self, pause: i64, stepmul: i64, stepsize: i64) {
2905        self.gc_pause = pause;
2906        self.gc_stepmul = stepmul;
2907        self.gc_stepsize = stepsize;
2908    }
2909
2910    /// Interpreter safe-point auto-GC: FULL incremental Propagate + adaptive
2911    /// paced sweep via `Vm::gc_step`.
2912    ///
2913    /// Running Propagate from a safe-point relies on objects being **born
2914    /// black during Propagate**: a newly allocated object never becomes
2915    /// dead-white at the atomic flip.
2916    ///
2917    /// Adaptive budget scales with heap size: 100M-object heap (heavy.lua's
2918    /// `loadrep` stress) gets a 25M-object budget so a cycle completes in
2919    /// O(SWEEP_DIVISOR) safe-points regardless of size.
2920    #[inline(always)]
2921    pub(crate) fn maybe_collect_garbage(&mut self, live_top: u32) {
2922        if self.gc_finalizing {
2923            return;
2924        }
2925        if !self.heap.gc_due() {
2926            return;
2927        }
2928        // Bare `live_top`, no `max(self.top)` widening: every frame-pop
2929        // site (`finish_results`, the Op::TailCall collapse, pcall
2930        // unwind) clears the slots it vacates, mirroring PUC's L->top
2931        // discipline.
2932        self.gc_top = live_top;
2933        // PUC stepmul: % of allocation rate. Higher = more GC work per
2934        // safe-point (lower memory, more CPU). Default 100 = `live / 4` per
2935        // step (~4 safe-points per cycle). stepmul=200 → `live / 2`, etc.
2936        const SWEEP_BASE: usize = 400; // 400 / stepmul=100 = divisor 4
2937        const MIN_BUDGET: usize = 64_000;
2938        let stepmul = self.gc_stepmul.max(1) as usize;
2939        let divisor = (SWEEP_BASE / stepmul).max(1);
2940        let budget = (self.heap.live_objects() / divisor).max(MIN_BUDGET);
2941        if self.gc_step(budget) {
2942            self.heap.rearm_gc_pause(self.gc_pause);
2943        }
2944    }
2945
2946    /// Enumerate the GC roots: first-class `Value` roots plus bare-object
2947    /// roots (open upvalues, which are not first-class Values). Shared by the
2948    /// full collector and the incremental-sweep driver so both snapshot the
2949    /// exact same live set.
2950    fn gc_roots(&self) -> (Vec<Value>, Vec<*mut GcHeader>) {
2951        let mut roots: Vec<Value> = Vec::with_capacity(self.stack.len() + 32);
2952        roots.push(Value::Table(self.globals));
2953        for mt in self.type_mt.into_iter().flatten() {
2954            roots.push(Value::Table(mt));
2955        }
2956        for &n in &self.mm_names {
2957            roots.push(Value::Str(n));
2958        }
2959        // Root the running thread's live registers (PUC marks [stack, top)).
2960        // `gc_top` is the instruction-level cursor of the last GC
2961        // safe-point: allocation safe-points set it via
2962        // `maybe_collect_garbage(live_top)`, and `begin_call` raises it
2963        // to the callee's argument top when entering a native — PUC's
2964        // `L->top = func + 1 + nargs` C-call discipline. Without that
2965        // raise, an explicit `collectgarbage()` collected with a STALE
2966        // cursor from some earlier (lower) safe-point and freed its own
2967        // caller's register-held strings
2968        // (STATUS_ACCESS_VIOLATION on Windows / ASAN heap-use-after-free
2969        // on Linux). Values stranded above the cursor stay
2970        // excluded so weak-table entries are not spuriously pinned
2971        // (gc.lua:544 suspended-coroutine collection).
2972        let live = (self.gc_top as usize).min(self.stack.len());
2973        roots.extend_from_slice(&self.stack[..live]);
2974        for cf in &self.frames {
2975            match cf {
2976                CallFrame::Lua(f) => roots.push(Value::Closure(f.closure)),
2977                CallFrame::Cont(NativeCont {
2978                    kind: ContKind::Xpcall { handler },
2979                    ..
2980                }) => roots.push(*handler),
2981                CallFrame::Cont(NativeCont {
2982                    kind: ContKind::Close(cc),
2983                    ..
2984                }) => {
2985                    // Root the error threaded through this close chain so a
2986                    // `collectgarbage()` inside a sibling `__close` handler
2987                    // does not free it before the next handler is invoked
2988                    // (PUC L->ci->u.l.errfunc / the closing_err shadow).
2989                    if let Some(e) = cc.pending {
2990                        roots.push(e);
2991                    }
2992                    if let AfterClose::ResumeUnwind { err, .. } = cc.after {
2993                        roots.push(err);
2994                    }
2995                }
2996                CallFrame::Cont(_) => {}
2997            }
2998        }
2999        if let Some(e) = self.closing_err {
3000            roots.push(e);
3001        }
3002        // Host roots — Lua-facade handles keep their referenced
3003        // values alive across calls/yields. Trace the whole vector;
3004        // unused slots (post-`unpin_all`) carry Value::Nil which the
3005        // GC ignores.
3006        for slot in &self.host_roots {
3007            // free-list slots carry Value::Nil (GC no-op)
3008            roots.push(slot.value);
3009        }
3010        // `table.sort` and similar builtins stash their working
3011        // `Vec<Value>` here so a `collectgarbage()` invoked inside the
3012        // comparator callback doesn't free strings/tables snapshotted
3013        // off the live table (sort.lua's `load(..)(); collectgarbage()`
3014        // compare regression).
3015        for buf in &self.sort_scratch {
3016            roots.extend_from_slice(buf);
3017        }
3018        // The running-natives chain holds Gc<NativeClosure>s
3019        // mid-execution. Without rooting them here, a `collectgarbage()`
3020        // invoked inside the running native (sort.lua's `load(..)();
3021        // collectgarbage()` compare callback regression) sweeps the
3022        // closure that's actively executing, leaving `nc.upvals`
3023        // dangling and the Rust local `nc` pointing at recycled memory
3024        // — the SIGSEGV pops on the very next field access or pop.
3025        for &nc in &self.running_natives {
3026            roots.push(Value::Native(nc));
3027        }
3028        // the running thread's debug hook (suspended threads root theirs via
3029        // Coro::trace / the main_ctx sweep below)
3030        if let Some(h) = self.hook.func {
3031            roots.push(h);
3032        }
3033        // the running coroutine (its saved-context fields live in the VM, but
3034        // the object itself + its resumer chain must stay reachable)
3035        if let Some(co) = self.current {
3036            roots.push(Value::Coro(co));
3037        }
3038        if let Some(mc) = self.main_coro {
3039            roots.push(Value::Coro(mc));
3040        }
3041        // debug.getregistry() and io library state
3042        if let Some(r) = self.registry {
3043            roots.push(Value::Table(r));
3044        }
3045        if let Some(mt) = self.file_mt {
3046            roots.push(Value::Table(mt));
3047        }
3048        if let Some(f) = self.io_input {
3049            roots.push(Value::Userdata(f));
3050        }
3051        if let Some(f) = self.io_output {
3052            roots.push(Value::Userdata(f));
3053        }
3054        if let Some(f) = self.io_stdin {
3055            roots.push(Value::Userdata(f));
3056        }
3057        // the main thread's saved context while a coroutine runs
3058        if let Some(m) = &self.main_ctx {
3059            roots.extend_from_slice(&m.stack);
3060            if let Some(h) = m.hook.func {
3061                roots.push(h);
3062            }
3063            for cf in &m.frames {
3064                match cf {
3065                    CallFrame::Lua(f) => roots.push(Value::Closure(f.closure)),
3066                    CallFrame::Cont(NativeCont {
3067                        kind: ContKind::Xpcall { handler },
3068                        ..
3069                    }) => roots.push(*handler),
3070                    CallFrame::Cont(_) => {}
3071                }
3072            }
3073        }
3074        let mut extra: Vec<*mut GcHeader> = self
3075            .open_upvals
3076            .iter()
3077            .map(|&(_, uv)| uv.as_ptr() as *mut GcHeader)
3078            .collect();
3079        if let Some(m) = &self.main_ctx {
3080            extra.extend(
3081                m.open_upvals
3082                    .iter()
3083                    .map(|&(_, uv)| uv.as_ptr() as *mut GcHeader),
3084            );
3085        }
3086        (roots, extra)
3087    }
3088
3089    /// Run a full collection with the VM's roots, then run any `__gc`
3090    /// finalizers the collection scheduled. A no-op (returns 0) when already
3091    /// inside a finalizer — the collector is not reentrant (PUC).
3092    pub fn collect_garbage(&mut self) -> usize {
3093        if self.gc_finalizing {
3094            return 0;
3095        }
3096        let (roots, extra) = self.gc_roots();
3097        let freed = self.heap.collect_ex(&roots, &extra);
3098        #[cfg(feature = "gc-verify")]
3099        self.verify_frame_regs_live("collect_garbage");
3100        self.run_finalizers();
3101        freed
3102    }
3103
3104    /// `gc-verify`: after a collect, every register slot the
3105    /// collector just rooted (`[0, max(gc_top, top))` — the same bound
3106    /// `gc_roots` uses) must hold a live value. A dead value inside the
3107    /// rooted range means the root snapshot and the sweep disagreed —
3108    /// a use-after-free waiting to happen. (Slots ABOVE the bound may hold
3109    /// stale dead values legitimately; the interpreter's contract is
3110    /// that it writes them before reading.)
3111    #[cfg(feature = "gc-verify")]
3112    pub(crate) fn verify_frame_regs_live(&self, ctx: &str) {
3113        let live = self.heap.debug_live_set();
3114        let header = |v: Value| -> Option<usize> {
3115            match v {
3116                Value::Str(s) => Some(s.as_ptr() as usize),
3117                Value::Table(t) => Some(t.as_ptr() as usize),
3118                Value::Closure(c) => Some(c.as_ptr() as usize),
3119                Value::Native(n) => Some(n.as_ptr() as usize),
3120                Value::Coro(c) => Some(c.as_ptr() as usize),
3121                Value::Userdata(u) => Some(u.as_ptr() as usize),
3122                _ => None,
3123            }
3124        };
3125        let bound = (self.gc_top as usize).min(self.stack.len());
3126        for i in 0..bound {
3127            if let Some(h) = header(self.stack[i])
3128                && !live.contains(&h)
3129            {
3130                panic!(
3131                    "[gc-verify] {ctx}: rooted stack slot {i} (gc_top {}, top {}) \
3132                         holds a dead value {h:#x} after collect",
3133                    self.gc_top, self.top,
3134                );
3135            }
3136        }
3137        // Diagnostic tier: a dead value ABOVE the cursor is only a bug if
3138        // that register is a named local still in scope (the interpreter
3139        // WILL read it). Cross-check against the proto's LocVar table.
3140        for (fi, cf) in self.frames.iter().enumerate() {
3141            if let CallFrame::Lua(f) = cf {
3142                let base = f.base as usize;
3143                let maxs = f.closure.proto.max_stack as usize;
3144                let hi = (base + maxs).min(self.stack.len());
3145                let pc = f.pc;
3146                for i in bound.max(base)..hi {
3147                    if let Some(h) = header(self.stack[i])
3148                        && !live.contains(&h)
3149                    {
3150                        let reg = (i - base) as u32;
3151                        if let Some(lv) = f
3152                            .closure
3153                            .proto
3154                            .locvars
3155                            .iter()
3156                            .find(|lv| lv.reg == reg && lv.start_pc <= pc && pc < lv.end_pc)
3157                        {
3158                            panic!(
3159                                "[gc-verify] {ctx}: frame {fi} IN-SCOPE LOCAL '{}' \
3160                                     (reg {reg}, abs {i}, pc {pc}, gc_top {}) holds a \
3161                                     dead value {h:#x} — live_top cursor excluded a \
3162                                     live named local",
3163                                lv.name, self.gc_top,
3164                            );
3165                        }
3166                    }
3167                }
3168            }
3169        }
3170    }
3171
3172    /// PUC 5.1 `collectgarbage` re-raised the first error a `__gc` finalizer
3173    /// threw; gc.lua's "errors during collection" probe relies on it. This
3174    /// variant runs the same cycle but propagates the captured finalizer
3175    /// error to the explicit caller.
3176    pub(crate) fn collect_garbage_propagating(&mut self) -> Result<usize, LuaError> {
3177        if self.gc_finalizing {
3178            return Ok(0);
3179        }
3180        let (roots, extra) = self.gc_roots();
3181        let freed = self.heap.collect_ex(&roots, &extra);
3182        #[cfg(feature = "gc-verify")]
3183        self.verify_frame_regs_live("collect_garbage_propagating");
3184        self.run_finalizers_or_err()?;
3185        Ok(freed)
3186    }
3187
3188    /// Whether a `__gc` finalizer is currently running (so `collectgarbage`
3189    /// should report fail rather than collect).
3190    pub(crate) fn gc_is_finalizing(&self) -> bool {
3191        self.gc_finalizing
3192    }
3193
3194    /// PUC 5.4+ default warnf: emit one piece of a warning message. `to_cont`
3195    /// = true indicates more pieces follow (concatenated until the first
3196    /// `to_cont = false` call flushes the whole line). Mirrors
3197    /// `lauxlib.c::warnfon` + `warnfcont` + `checkcontrol`:
3198    ///   * If the buffer is fresh, `to_cont` is false, and the message is
3199    ///     `@<word>`, treat as a control message — only `@on` / `@off` are
3200    ///     recognised; any other `@…` is silently ignored.
3201    ///   * Otherwise, while the state is `Off`, drop the piece; while `On`,
3202    ///     accumulate, and flush to stderr + `warn_log` on the
3203    ///     non-continuation call.
3204    pub(crate) fn emit_warn(&mut self, msg: &[u8], to_cont: bool) {
3205        if self.warn_buf.is_empty()
3206            && !to_cont
3207            && let Some(b'@') = msg.first().copied()
3208        {
3209            match &msg[1..] {
3210                b"on" => self.warn_state = WarnState::On,
3211                b"off" => self.warn_state = WarnState::Off,
3212                _ => {} // unknown control — silently ignored (PUC checkcontrol)
3213            }
3214            return;
3215        }
3216        if self.warn_state == WarnState::Off {
3217            // drop continuation pieces too — PUC `warnfoff` is the trampoline
3218            return;
3219        }
3220        self.warn_buf.extend_from_slice(msg);
3221        if !to_cont {
3222            let line = std::mem::take(&mut self.warn_buf);
3223            eprintln!("Lua warning: {}", String::from_utf8_lossy(&line));
3224            self.warn_log.push(line);
3225        }
3226    }
3227
3228    /// Drain the in-process warning log (one entry per emitted message, sans
3229    /// `"Lua warning: "` prefix and newline). For test harnesses that want to
3230    /// assert on warn output without scraping stderr.
3231    pub fn warn_log_take(&mut self) -> Vec<Vec<u8>> {
3232        std::mem::take(&mut self.warn_log)
3233    }
3234
3235    /// Arm the cooperative instruction budget. The run loop
3236    /// decrements this once per dispatch turn; on zero it raises a catchable
3237    /// `"instruction budget exceeded"` error and disarms itself so the host
3238    /// can resume with a fresh budget on the next call. `None` removes the
3239    /// cap. Pass `Some(n)` before `eval`/`call_value` for the embedder's
3240    /// short-script semantics.
3241    pub fn set_instr_budget(&mut self, budget: Option<i64>) {
3242        self.instr_budget = budget;
3243    }
3244
3245    /// Remaining instruction budget (None when unbounded).
3246    pub fn instr_budget_remaining(&self) -> Option<i64> {
3247        self.instr_budget
3248    }
3249
3250    /// Toggle the cranelift JIT. Default `true`. Sandbox embedders
3251    /// **must** disable JIT when relying on `instr_budget` — see the
3252    /// `jit_enabled` field doc for the rationale.
3253    pub fn set_jit_enabled(&mut self, enabled: bool) {
3254        self.jit.enabled = enabled;
3255    }
3256
3257    /// Current JIT enable state.
3258    pub fn jit_enabled(&self) -> bool {
3259        self.jit.enabled
3260    }
3261
3262    /// Toggle the trace JIT. Off by default. When enabled, hot
3263    /// back-edges are counted on `Proto.trace_hot_count`; once the
3264    /// counter passes `TRACE_HOT_THRESHOLD`, the dispatch loop enters
3265    /// recording mode at the back-edge target.
3266    pub fn set_trace_jit_enabled(&mut self, enabled: bool) {
3267        self.jit.trace_enabled = enabled;
3268    }
3269
3270    /// Opt-in flag for the self-link cycle catch. See field
3271    /// docs for the correctness blocker. Default `false`.
3272    pub fn set_self_link_enabled(&mut self, enabled: bool) {
3273        self.jit.self_link_enabled = enabled;
3274    }
3275
3276    /// Current state of the self-link cycle catch.
3277    pub fn self_link_enabled(&self) -> bool {
3278        self.jit.self_link_enabled
3279    }
3280
3281    #[doc(hidden)]
3282    #[deprecated(since = "3.2.0", note = "renamed to `set_self_link_enabled`")]
3283    pub fn set_p16_self_link_enabled(&mut self, enabled: bool) {
3284        self.set_self_link_enabled(enabled);
3285    }
3286
3287    #[doc(hidden)]
3288    #[deprecated(since = "3.2.0", note = "renamed to `self_link_enabled`")]
3289    pub fn p16_self_link_enabled(&self) -> bool {
3290        self.self_link_enabled()
3291    }
3292
3293    /// Current trace-JIT enable state.
3294    pub fn trace_jit_enabled(&self) -> bool {
3295        self.jit.trace_enabled
3296    }
3297
3298    /// Number of traces that have closed cleanly (looped back to the
3299    /// head PC) since this Vm was constructed. Cumulative; used by
3300    /// tests + tuning.
3301    pub fn trace_closed_count(&self) -> u64 {
3302        self.jit.counters.closed
3303    }
3304
3305    /// Number of traces that have aborted (exceeded MAX_TRACE_LEN or
3306    /// hit an un-recordable op).
3307    pub fn trace_aborted_count(&self) -> u64 {
3308        self.jit.counters.aborted
3309    }
3310
3311    /// Number of compiled traces whose close shape
3312    /// is `TraceEnd::InlineAbort` (depth>0 boundary). Such traces
3313    /// pin `dispatchable=false` because the dispatcher can't
3314    /// resume at a depth>0 PC without the matching CallFrames.
3315    /// The frame-materialisation helper could synthesise those, but
3316    /// the InlineAbort emit path isn't wired up to it.
3317    pub fn trace_inline_abort_count(&self) -> u64 {
3318        self.jit.counters.inline_abort
3319    }
3320
3321    /// See `JitCounters::dispatch_off_reasons`.
3322    pub fn trace_dispatch_off_reasons(&self) -> &[&'static str] {
3323        &self.jit.counters.dispatch_off_reasons
3324    }
3325
3326    /// See `JitCounters::compile_failed_reasons`.
3327    pub fn trace_compile_failed_reasons(&self) -> &[&'static str] {
3328        &self.jit.counters.compile_failed_reasons
3329    }
3330
3331    /// See `JitCounters::closed_lens`. Returns
3332    /// `(is_call_triggered, ops_len)` for every trace that closed.
3333    pub fn trace_closed_lens(&self) -> &[(bool, usize)] {
3334        &self.jit.counters.closed_lens
3335    }
3336
3337    /// See [`crate::vm::jit_state::JitCounters::close_cause_counts`].
3338    /// Per-reason close-cause counts (recorder-side abort/discard +
3339    /// lowerer-side dispatch_off labels) keyed by `&'static str`.
3340    pub fn trace_close_cause_counts(&self) -> &std::collections::HashMap<&'static str, u64> {
3341        &self.jit.counters.close_cause_counts
3342    }
3343
3344    /// Number of compiled traces whose
3345    /// `CompiledTrace.downrec_link` is `Some(_)` (lowerer's
3346    /// `downrec_idx_opt` arm emitted the stitch sentinel + caller-pc
3347    /// guard scaffold).
3348    pub fn trace_downrec_link_compiled_count(&self) -> u64 {
3349        self.jit.counters.downrec_link_compiled
3350    }
3351
3352    /// See
3353    /// [`crate::vm::jit_state::JitCounters::downrec_dispatched`]. Number
3354    /// of times the dispatcher's `is_downrec_sentinel` arm fired and
3355    /// classified the return as a caller-pc-guard HIT.
3356    pub fn trace_downrec_dispatched_count(&self) -> u64 {
3357        self.jit.counters.downrec_dispatched
3358    }
3359
3360    /// See
3361    /// [`crate::vm::jit_state::JitCounters::downrec_deopt`]. Number of
3362    /// times the dispatcher entered a `downrec_link`-bearing trace and
3363    /// the trace returned via the lowerer's deopt block (caller-pc
3364    /// guard MISS), or the dispatcher itself force-deopted via the
3365    /// stitch-cycle checkpoint.
3366    pub fn trace_downrec_deopt_count(&self) -> u64 {
3367        self.jit.counters.downrec_deopt
3368    }
3369
3370    /// See
3371    /// [`crate::vm::jit_state::JitCounters::multi_way_guard_emitted`].
3372    /// Number of compiled traces whose lowerer emitted a multi-way
3373    /// caller-pc guard chain (>= 2 distinct `caller_pc` candidates)
3374    /// at the `TraceEnd::DownRec` close + lifted `dispatchable = true`.
3375    pub fn trace_multi_way_guard_emitted_count(&self) -> u64 {
3376        self.jit.counters.multi_way_guard_emitted
3377    }
3378
3379    /// Number of closed traces the lowerer compiled and
3380    /// parked on `Proto.traces`. Re-records of the same head_pc are
3381    /// deduped (the second close finds the head_pc already cached
3382    /// and skips compile), so this never exceeds `trace_closed_count`.
3383    pub fn trace_compiled_count(&self) -> u64 {
3384        self.jit.counters.compiled
3385    }
3386
3387    /// Number of times the recorder captured a
3388    /// [`crate::jit::trace_types::FieldIcSnapshot`] under
3389    /// `LUNA_JIT_FIELD_IC=1`. Stays 0 on the env-default path. Used
3390    /// by the opt-in fire test to verify the env gate
3391    /// wiring round-trips end-to-end (env -> recorder -> snapshot
3392    /// -> counter -> getter -> assertion).
3393    pub fn trace_field_ic_snapshot_count(&self) -> u64 {
3394        self.jit.counters.field_ic_snapshot_captured
3395    }
3396
3397    /// Number of closed traces the lowerer rejected
3398    /// (any of the bail conditions in
3399    /// `crate::jit::trace::try_compile_trace`).
3400    pub fn trace_compile_failed_count(&self) -> u64 {
3401        self.jit.counters.compile_failed
3402    }
3403
3404    /// Number of times the dispatcher jumped into a
3405    /// compiled trace. Bumps on every entry; `trace_deopt_count`
3406    /// counts the subset where the trace returned with a parked
3407    /// `jit_pending_err`.
3408    pub fn trace_dispatched_count(&self) -> u64 {
3409        self.jit.counters.dispatched
3410    }
3411
3412    /// Number of trace entries that came back with
3413    /// `jit_pending_err` set (typically a metatable shadowed an
3414    /// index inside a helper, forcing the dispatcher to fall back
3415    /// to the interpreter without committing the trace's result).
3416    pub fn trace_deopt_count(&self) -> u64 {
3417        self.jit.counters.deopt
3418    }
3419
3420    /// Number of times the dispatcher started a side
3421    /// trace recording (an `exit_hit_counts` slot crossed
3422    /// [`crate::jit::trace::HOTEXIT_THRESHOLD`] while `active_trace`
3423    /// was None and trace JIT was enabled). Each unit is exactly one
3424    /// `start_side_trace` call; the actual compile success counts
3425    /// under [`Self::trace_compiled_count`] like any other trace.
3426    /// Probe use: distinguishes the "side-trace pipeline fired"
3427    /// signal from the "primary back-edge / call-trigger fired"
3428    /// signal without reading per-counter histograms.
3429    pub fn trace_side_trace_started_count(&self) -> u64 {
3430        self.jit.counters.side_trace_started
3431    }
3432
3433    /// Number of side-trace recordings that closed,
3434    /// compiled successfully, AND patched their parent's
3435    /// `exit_side_trace_ptrs[exit_idx]`.
3436    pub fn trace_side_trace_compiled_count(&self) -> u64 {
3437        self.jit.counters.side_trace_compiled
3438    }
3439
3440    /// Number of side traces that compiled
3441    /// successfully but were SHEDDED by the close-handler shape-
3442    /// match gate (`exit_tags_match_entry_tags`). High ratios
3443    /// vs. `trace_side_trace_compiled_count` indicate the
3444    /// architecture is shedding lots of would-be side traces;
3445    /// useful as a tuning probe for future relaxation of the
3446    /// gate or for child-IR re-specialisation against parent's
3447    /// exit shape.
3448    pub fn trace_side_trace_shape_mismatch_count(&self) -> u64 {
3449        self.jit.counters.side_trace_shape_mismatch
3450    }
3451
3452    /// Sum of NewTable sites the pre-emit escape sweep
3453    /// classified as `crate::jit::trace::EscapeState::Sinkable`
3454    /// across every successfully compiled trace on this Vm. The
3455    /// count is post-demotion: sites pre-emit drops back to Escaped
3456    /// for not meeting the sunk-emit criteria are NOT counted.
3457    /// `trace_sunk_alloc_count` matches one-for-one today (every
3458    /// surviving Sinkable site goes through sunk emit).
3459    pub fn trace_sinkable_seen_count(&self) -> u64 {
3460        self.jit.counters.sinkable_seen
3461    }
3462
3463    /// See `JitCounters::accum_bufferable_seen`.
3464    pub fn trace_accum_bufferable_seen_count(&self) -> u64 {
3465        self.jit.counters.accum_bufferable_seen
3466    }
3467
3468    /// Total dispatch hits across all known traces,
3469    /// broken into hot-exit telemetry (max single-exit count,
3470    /// total dispatches, exit count). Used by probes to identify
3471    /// hot side-exits as side-trace candidates.
3472    ///
3473    /// Walks `cl.proto` AND all nested protos in `cl.proto.protos`
3474    /// recursively, so inner functions' traces are reported.
3475    pub fn trace_exit_hit_summary(
3476        &self,
3477        cl: crate::runtime::heap::Gc<crate::runtime::function::LuaClosure>,
3478    ) -> Vec<(u32, Vec<u32>)> {
3479        fn walk(
3480            proto: crate::runtime::heap::Gc<crate::runtime::function::Proto>,
3481            out: &mut Vec<(u32, Vec<u32>)>,
3482        ) {
3483            for ct in proto.traces.borrow().iter() {
3484                let counts: Vec<u32> = ct.exit_hit_counts.iter().map(|c| c.get()).collect();
3485                out.push((ct.head_pc, counts));
3486            }
3487            for inner in proto.protos.iter() {
3488                walk(*inner, out);
3489            }
3490        }
3491        let mut out: Vec<(u32, Vec<u32>)> = Vec::new();
3492        walk(cl.proto, &mut out);
3493        out
3494    }
3495
3496    /// Surface every side-exit slot whose hit count is
3497    /// `>= HOTEXIT_THRESHOLD` across every trace reachable from
3498    /// `cl.proto` (recursively walking `proto.protos`). Returned
3499    /// entries are side-trace candidates: each carries the parent
3500    /// trace's `(head_proto, head_pc)`, the exit's index in the
3501    /// parent's `exit_hit_counts`, and the side trace's natural
3502    /// entry shape (`cont_pc` + `exit_tags`).
3503    ///
3504    /// Layout of `exit_hit_counts` (mirrored by the iter):
3505    /// - `[0..per_exit_inline.len())` → `InlineSideExit` (cont_pc +
3506    ///   window-sized exit_tags).
3507    /// - `[per_exit_inline.len()..inline.len() + per_exit_tags.len())`
3508    ///   → `per_exit_tags[i]` (per-cont_pc caller-window tags).
3509    /// - Last slot → global clean-tail (cont_pc = `head_pc`,
3510    ///   exit_tags = `ct.exit_tags`).
3511    pub fn hot_exit_iter(
3512        &self,
3513        cl: crate::runtime::heap::Gc<crate::runtime::function::LuaClosure>,
3514    ) -> Vec<crate::jit::trace::HotExitInfo> {
3515        use crate::jit::trace::{HOTEXIT_THRESHOLD, HotExitInfo};
3516        fn walk(
3517            proto: crate::runtime::heap::Gc<crate::runtime::function::Proto>,
3518            out: &mut Vec<HotExitInfo>,
3519        ) {
3520            for ct in proto.traces.borrow().iter() {
3521                let inline_n = ct.per_exit_inline.len();
3522                let tags_n = ct.per_exit_tags.len();
3523                debug_assert_eq!(
3524                    ct.exit_hit_counts.len(),
3525                    inline_n + tags_n + 1,
3526                    "exit_hit_counts layout invariant violated"
3527                );
3528                for (idx, cell) in ct.exit_hit_counts.iter().enumerate() {
3529                    let hits = cell.get();
3530                    if hits < HOTEXIT_THRESHOLD {
3531                        continue;
3532                    }
3533                    let (cont_pc, exit_tags) = if idx < inline_n {
3534                        let ent = &ct.per_exit_inline[idx];
3535                        (ent.cont_pc, ent.exit_tags.clone())
3536                    } else if idx < inline_n + tags_n {
3537                        let (pc, tags) = &ct.per_exit_tags[idx - inline_n];
3538                        (*pc, tags.clone())
3539                    } else {
3540                        (ct.head_pc, ct.exit_tags.clone())
3541                    };
3542                    out.push(HotExitInfo {
3543                        head_proto: proto,
3544                        head_pc: ct.head_pc,
3545                        exit_idx: idx,
3546                        hits,
3547                        cont_pc,
3548                        exit_tags,
3549                    });
3550                }
3551            }
3552            for inner in proto.protos.iter() {
3553                walk(*inner, out);
3554            }
3555        }
3556        let mut out: Vec<HotExitInfo> = Vec::new();
3557        walk(cl.proto, &mut out);
3558        out
3559    }
3560
3561    /// Sum of NewTable sites that actually took the
3562    /// sunk-emit path across every successfully compiled trace on
3563    /// this Vm. Each counted site skips its heap `Gc<Table>`
3564    /// allocation per dispatch; the array part lives as Cranelift
3565    /// `Variable`s for the duration of the trace.
3566    pub fn trace_sunk_alloc_count(&self) -> u64 {
3567        self.jit.counters.sunk_alloc
3568    }
3569
3570    /// Sum of materialise-helper emit sites across every
3571    /// successfully compiled trace on this Vm. Each unit is a
3572    /// (site × cmp side-exit) pair whose IR reconstructs a heap
3573    /// `Gc<Table>` from the virt slots on deopt.
3574    pub fn trace_materialize_emit_count(&self) -> u64 {
3575        self.jit.counters.materialize_emit
3576    }
3577
3578    /// Diagnostic: total `Op::Closure` ops the trace JIT
3579    /// lowered to the `luna_jit_op_closure` helper. Each emitted op
3580    /// replaces a `Heap::new_closure_inline` call on the dispatch
3581    /// path; the count is static (one per matching op per compiled
3582    /// trace), summed at compile success.
3583    pub fn trace_closure_emit_count(&self) -> u64 {
3584        self.jit.counters.closure_emit
3585    }
3586
3587    /// See
3588    /// [`crate::vm::jit_state::JitCounters::per_exit_inline_compiled`].
3589    /// Number of compiled traces whose `per_exit_inline.len() > 0`
3590    /// (depth>0 inlined cmp side-exits emitted).
3591    pub fn trace_per_exit_inline_compiled_count(&self) -> u64 {
3592        self.jit.counters.per_exit_inline_compiled
3593    }
3594
3595    /// See
3596    /// [`crate::vm::jit_state::JitCounters::per_exit_inline_dispatchable`].
3597    /// Number of compiled traces with `per_exit_inline.len() > 0` AND
3598    /// `dispatchable == true` — i.e. the count of compiled traces
3599    /// that would actually exercise the AOT chain-reloc +
3600    /// deploy-resolver path.
3601    pub fn trace_per_exit_inline_dispatchable_count(&self) -> u64 {
3602        self.jit.counters.per_exit_inline_dispatchable
3603    }
3604
3605    /// Diagnostic: max `inline_depth` ever seen on any
3606    /// `RecordedOp` pushed by the recorder. Tells tests + tuning
3607    /// whether a self-recursive function actually walked the depth
3608    /// tracker past 0. Saturates at `MAX_INLINE_DEPTH`. Persists
3609    /// across traces and Vm activations; reset only on `Vm::new`.
3610    pub fn trace_max_depth_seen(&self) -> u8 {
3611        self.jit.max_depth_seen
3612    }
3613
3614    /// Last live Lua frame (the trace head's frame at
3615    /// dispatch time). The frame-materialization helper reads `.base`
3616    /// to compute offsets for each inlined frame's window.
3617    #[doc(hidden)]
3618    pub fn jit_last_lua_frame(&self) -> Option<Frame> {
3619        match self.frames.last() {
3620            Some(CallFrame::Lua(f)) => Some(*f),
3621            _ => None,
3622        }
3623    }
3624
3625    /// Read-only borrow of the current call
3626    /// stack, for the [`crate::vm::inspect`] pure-read accessors used
3627    /// by `luna-tools` (`luna-profile`'s sampler walks this from
3628    /// inside a `Count` hook). Sibling-module scope: not part of the
3629    /// public embedder surface, but `inspect::frames_for_profile` is.
3630    #[doc(hidden)]
3631    pub(super) fn inspect_frames(&self) -> &[CallFrame] {
3632        &self.frames
3633    }
3634
3635    /// Ensure the value stack covers indices
3636    /// `[0..need)`. Extends with Nil if shorter. Called by the
3637    /// frame-materialization helper before pushing an inlined frame
3638    /// whose register window may exceed the current stack length.
3639    #[doc(hidden)]
3640    pub fn jit_ensure_stack(&mut self, need: usize) {
3641        if self.stack.len() < need {
3642            self.stack.resize(need, Value::Nil);
3643        }
3644    }
3645
3646    /// Trace JIT path for `Op::Close A`. Predicts whether
3647    /// `__close` handlers would run (any active tbc slot ≥ from
3648    /// holding a non-nil/false Value); if so, returns 1 without doing
3649    /// anything and the trace side-exits at the op, so the interpreter
3650    /// runs the handlers. Otherwise performs the safe part of close —
3651    /// `close_from(from)` to close open upvals + drop any drained tbc
3652    /// entries ≥ from — and returns 0.
3653    ///
3654    /// Returns are i64-shaped so the cranelift import sig stays
3655    /// trivial (i64 → i64 mapping).
3656    #[doc(hidden)]
3657    pub fn jit_op_close(&mut self, start_offset: u32) -> i64 {
3658        let Some(f) = self.jit_last_lua_frame() else {
3659            return 1;
3660        };
3661        let from = f.base + start_offset;
3662        let has_handler = self.tbc.iter().any(|&s| {
3663            s >= from && {
3664                let v = self.stack[s as usize];
3665                !matches!(v, Value::Nil | Value::Bool(false))
3666            }
3667        });
3668        if has_handler {
3669            self.jit.counters.deopt += 1;
3670            return 1;
3671        }
3672        self.close_from(from);
3673        // Drain any tbc entries ≥ from (they're nil/false stubs the
3674        // interpreter's drive_close would have skipped silently).
3675        while let Some(&s) = self.tbc.last() {
3676            if s < from {
3677                break;
3678            }
3679            self.tbc.pop();
3680        }
3681        0
3682    }
3683
3684    /// Spill the trace's current value for a register to
3685    /// the underlying `vm.stack[base + slot_offset]`. Required before
3686    /// an `Op::Closure` whose inner proto has an `in_stack: true`
3687    /// upval at `slot_offset` — the helper's `find_or_create_upval`
3688    /// captures a live pointer to `vm.stack[base + slot_offset]`,
3689    /// which must hold the right value at call time (trace IR's
3690    /// Variable hasn't yet been written back).
3691    ///
3692    /// Parameters arrive as i64 from the IR: `slot_offset` is the
3693    /// caller-frame register index (`u32` in practice, depth=0
3694    /// only — depth>0 Closure is not supported); `tag` is the
3695    /// `crate::runtime::value::raw` byte for the slot's RegKind;
3696    /// `raw_bits` is the trace Variable's `use_var` payload
3697    /// (i64-shaped — Float is its bit-pattern, Table/Closure is the
3698    /// raw `Gc::as_ptr` cast).
3699    #[doc(hidden)]
3700    pub fn jit_spill_stack(&mut self, slot_offset: u32, tag: u8, raw_bits: u64) {
3701        let Some(f) = self.jit_last_lua_frame() else {
3702            self.jit.pending_err =
3703                Some(self.rt_err("JIT spill: no Lua frame on jit_last_lua_frame()"));
3704            return;
3705        };
3706        let idx = (f.base as usize) + (slot_offset as usize);
3707        if self.stack.len() <= idx {
3708            self.stack.resize(idx + 1, Value::Nil);
3709        }
3710        // SAFETY: caller (trace JIT IR emit) provides matching
3711        // `(tag, raw_bits)` — same shape produced by Value::unpack.
3712        let v = unsafe {
3713            crate::runtime::Value::pack(tag, crate::runtime::value::RawVal { zero: raw_bits })
3714        };
3715        self.stack[idx] = v;
3716    }
3717
3718    /// Refresh only the raw payload of
3719    /// `vm.stack[base + slot_offset]`, preserving its existing
3720    /// `Value` tag. The caller (trace JIT Op::Concat body emit)
3721    /// uses this when the slot's `RegKind` is `Unset` (no compile-
3722    /// time tag info; commonly `Str` slots which the trace doesn't
3723    /// model). The interp's previous execution of the same op
3724    /// already populated the slot with the right tag — the trace
3725    /// only needs to swap in its current raw value.
3726    #[doc(hidden)]
3727    pub fn jit_stack_update_raw(&mut self, slot_offset: u32, raw_bits: u64) {
3728        let Some(f) = self.jit_last_lua_frame() else {
3729            return;
3730        };
3731        let idx = (f.base as usize) + (slot_offset as usize);
3732        if idx >= self.stack.len() {
3733            return;
3734        }
3735        let (tag, _) = self.stack[idx].unpack();
3736        // SAFETY: Gc<T> is NonNull<T> over the GC heap; the heap is single-threaded and the pointer is live as long as it is reachable from active roots (see heap.rs:5-7).
3737        self.stack[idx] = unsafe {
3738            crate::runtime::Value::pack(tag, crate::runtime::value::RawVal { zero: raw_bits })
3739        };
3740    }
3741
3742    /// Trace JIT path for `Op::Concat A B`.
3743    ///
3744    /// Mirrors the interp arm (this file ~L5112): `self.top =
3745    /// base + a + n; concat_run(base + a)`. Result lands at
3746    /// `vm.stack[base + a]`. Returns `0` on success, `-1` when the
3747    /// interpreter must do it (any error from `concat_run` OR
3748    /// detection that the metamethod path was taken — `concat_run`
3749    /// returns `Ok(())` after `begin_meta_call` which has pushed a Lua
3750    /// frame the trace can't safely continue past); the trace then
3751    /// side-exits at the op and the interpreter redoes it, raising the
3752    /// error or calling `__concat` itself.
3753    ///
3754    /// The frame-push detection uses `pre/post frames.len()` and
3755    /// unwinds any pushed frames first, so the exit sees a clean stack.
3756    #[doc(hidden)]
3757    pub fn jit_op_concat(&mut self, slot_offset: u32, n: i32) -> i64 {
3758        let Some(f) = self.jit_last_lua_frame() else {
3759            return -1;
3760        };
3761        let abs_a = f.base + slot_offset;
3762        self.top = abs_a + n as u32;
3763        let pre_frames = self.frames.len();
3764        let result = self.concat_run(abs_a);
3765        let post_frames = self.frames.len();
3766        // Frame-push = metamethod path taken (begin_meta_call pushed
3767        // a Lua frame). The trace can't continue past it; unwind +
3768        // deopt so interp redoes Op::Concat in the slow path.
3769        while self.frames.len() > pre_frames {
3770            frames_pop_sync(&mut self.frames, &mut self.frames_top);
3771        }
3772        if result.is_err() || post_frames > pre_frames {
3773            self.jit.counters.deopt += 1;
3774            return -1;
3775        }
3776        0
3777    }
3778
3779    /// Pop a reusable `Vec<u8>` from the JIT
3780    /// accumulator buffer pool, returning a raw pointer. The trace
3781    /// fn's IR holds this pointer in a stack slot through the loop
3782    /// and calls `jit_str_buf_extend` per iter. If the pool is
3783    /// empty, allocate fresh.
3784    ///
3785    /// Safety: the returned pointer is valid until
3786    /// `jit_str_buf_release` is called or the Vm is dropped. The
3787    /// caller MUST not retain it across `enter_jit` boundaries.
3788    #[doc(hidden)]
3789    pub fn jit_str_buf_acquire(&mut self) -> *mut Vec<u8> {
3790        let buf = self.jit.str_buf_pool.pop().unwrap_or_default();
3791        // Move into a Box so the pointer is stable until release.
3792        Box::into_raw(Box::new(buf))
3793    }
3794
3795    /// Return a previously-acquired buffer to the
3796    /// pool, dropping any excess past `jit_str_buf_pool_cap`. The
3797    /// buffer is `clear`ed (capacity retained) so the next acquire
3798    /// gets a ready-to-extend Vec.
3799    ///
3800    /// Safety: `buf` must have been returned by a prior
3801    /// `jit_str_buf_acquire` on the same Vm.
3802    #[doc(hidden)]
3803    #[allow(clippy::not_unsafe_ptr_arg_deref)] // JIT helper: `buf` round-trips through `Box::into_raw`; SAFETY documented below.
3804    pub fn jit_str_buf_release(&mut self, buf: *mut Vec<u8>) {
3805        if buf.is_null() {
3806            return;
3807        }
3808        // SAFETY: `ptr` round-trips through `Box::into_raw` set up earlier in this dispatch (or owned by a long-lived VM handle); ownership re-acquired here.
3809        let mut owned = unsafe { Box::from_raw(buf) };
3810        owned.clear();
3811        if self.jit.str_buf_pool.len() < self.jit.str_buf_pool_cap {
3812            self.jit.str_buf_pool.push(*owned);
3813        }
3814        // Else: drop the buffer.
3815    }
3816
3817    /// Append a LuaStr's bytes to the accumulator
3818    /// buffer. The trace IR computes the `str_ptr` (= raw bits of
3819    /// the piece slot) and passes it through; we treat it as a
3820    /// `*mut LuaStr` and append its bytes.
3821    ///
3822    /// Returns 0 on success, -1 if the piece isn't a Str (would
3823    /// trip __concat metamethod path → deopt to interp).
3824    ///
3825    /// Safety: `buf` from prior `acquire`; `str_ptr` from the
3826    /// trace's piece slot raw bits.
3827    #[doc(hidden)]
3828    #[allow(clippy::not_unsafe_ptr_arg_deref)] // JIT helper: `buf` from prior `acquire`; `str_ptr` from trace piece slot; SAFETY documented below.
3829    pub fn jit_str_buf_extend(&mut self, buf: *mut Vec<u8>, str_ptr: i64) -> i64 {
3830        if buf.is_null() || str_ptr == 0 {
3831            return -1;
3832        }
3833        // SAFETY: Gc<T> is NonNull<T> over the GC heap; the heap is single-threaded and the pointer is live as long as it is reachable from active roots (see heap.rs:5-7).
3834        let buf = unsafe { &mut *buf };
3835        let lua_str_ptr = str_ptr as *const crate::runtime::string::LuaStr;
3836        // SAFETY: Gc<T> is NonNull<T> over the GC heap; the heap is single-threaded and the pointer is live as long as it is reachable from active roots (see heap.rs:5-7).
3837        let bytes = unsafe { crate::runtime::string::bytes_of(lua_str_ptr) };
3838        buf.extend_from_slice(bytes);
3839        0
3840    }
3841
3842    /// Drain the accumulator buffer into a fresh
3843    /// `LuaStr` via `heap.intern`, returning the raw ptr bits for
3844    /// the trace to write into the accumulator slot.
3845    ///
3846    /// Returns the LuaStr ptr as i64 on success, 0 on overflow
3847    /// (the hard cap; the trace deopts).
3848    ///
3849    /// Safety: `buf` from prior `acquire`. The buffer is left
3850    /// CLEAR (drained) ready for `release`.
3851    #[doc(hidden)]
3852    #[allow(clippy::not_unsafe_ptr_arg_deref)] // JIT helper: `buf` from prior `acquire`; SAFETY documented below.
3853    pub fn jit_str_buf_intern(&mut self, buf: *mut Vec<u8>) -> i64 {
3854        if buf.is_null() {
3855            return 0;
3856        }
3857        // SAFETY: Gc<T> is NonNull<T> over the GC heap; the heap is single-threaded and the pointer is live as long as it is reachable from active roots (see heap.rs:5-7).
3858        let buf = unsafe { &mut *buf };
3859        let bytes = std::mem::take(buf);
3860        // hard cap at 256KB
3861        if bytes.len() > 256 * 1024 {
3862            return 0;
3863        }
3864        let gc = self.heap.intern(&bytes);
3865        gc.as_ptr() as i64
3866    }
3867
3868    /// Trace JIT helper for `Op::TForCall A 0 C`.
3869    ///
3870    /// Base path: copy R[A..=A+2] → R[A+4..=A+6] + `begin_call`.
3871    /// ipairs `inext` fast path at the top — skip begin_call
3872    ///     when R[A]=Native(ipairs_iter), R[A+1]=Table no-mt,
3873    ///     R[A+2]=Int.
3874    /// Batched out-ptr writeback — fill ctrl/key/val raws into
3875    ///     caller-provided buffers + return R[A+4]'s tag byte. Lets
3876    ///     emit skip 3 separate `luna_jit_stack_load` calls and 1
3877    ///     `luna_jit_stack_tag` call by reading the buffer via
3878    ///     cranelift `stack_load` IR instead. Returns -1 on deopt,
3879    ///     else R[A+4]'s tag byte | R[A+5]'s tag byte << 8 (the value's
3880    ///     tag only when `nvars >= 2`, 0 otherwise).
3881    #[doc(hidden)]
3882    #[allow(clippy::not_unsafe_ptr_arg_deref)] // JIT helper: `ctrl_out`/`key_out`/`val_out` are caller-stack buffers from Cranelift-emitted prologue; SAFETY documented below.
3883    pub fn jit_op_tforcall(
3884        &mut self,
3885        slot_offset: u32,
3886        nvars: i32,
3887        ctrl_out: *mut i64,
3888        key_out: *mut i64,
3889        val_out: *mut i64,
3890    ) -> i64 {
3891        let Some(f) = self.jit_last_lua_frame() else {
3892            return -1;
3893        };
3894        let abs = f.base + slot_offset;
3895        let need = (abs + 7) as usize;
3896        if self.stack.len() < need {
3897            self.stack.resize(need, Value::Nil);
3898        }
3899        // ipairs fast path
3900        let took_fast_path = if let Value::Native(n) = self.stack[abs as usize]
3901            && std::ptr::fn_addr_eq(
3902                n.f,
3903                crate::vm::builtins::ipairs_iter as crate::runtime::value::NativeFn,
3904            )
3905            && let Value::Table(t) = self.stack[(abs + 1) as usize]
3906            && t.metatable().is_none()
3907            && let Value::Int(i) = self.stack[(abs + 2) as usize]
3908        {
3909            let next_i = i.wrapping_add(1);
3910            let v = t.get_int(next_i);
3911            if v.is_nil() {
3912                self.stack[(abs + 4) as usize] = Value::Nil;
3913            } else {
3914                self.stack[(abs + 4) as usize] = Value::Int(next_i);
3915                if (nvars as usize) >= 2 {
3916                    self.stack[(abs + 5) as usize] = v;
3917                }
3918                for j in 2..nvars as usize {
3919                    let slot = abs + 4 + j as u32;
3920                    if (slot as usize) < self.stack.len() {
3921                        self.stack[slot as usize] = Value::Nil;
3922                    }
3923                }
3924            }
3925            true
3926        } else {
3927            false
3928        };
3929        if !took_fast_path {
3930            // slow path: copy R[A..=A+2] → R[A+4..=A+6], then
3931            // route through begin_call. Lua-closure iters would push
3932            // a Lua frame mid-trace → deopt.
3933            self.stack[(abs + 4) as usize] = self.stack[abs as usize];
3934            self.stack[(abs + 5) as usize] = self.stack[(abs + 1) as usize];
3935            self.stack[(abs + 6) as usize] = self.stack[(abs + 2) as usize];
3936            // the interpreter raises the call's error itself; and a native
3937            // that `begin_call` hands to the interpreter loop (pcall, xpcall,
3938            // pairs, an async native) pushes frames or parks a future
3939            // instead of returning its results here
3940            let runs_to_completion = match self.stack[abs as usize] {
3941                Value::Native(nc) => {
3942                    use crate::runtime::value::NativeFn;
3943                    !nc.is_async
3944                        && ![
3945                            nat_pcall as NativeFn,
3946                            nat_xpcall as NativeFn,
3947                            nat_host_xpcall as NativeFn,
3948                            nat_pairs as NativeFn,
3949                        ]
3950                        .iter()
3951                        .any(|&g| std::ptr::fn_addr_eq(nc.f, g))
3952                }
3953                _ => false,
3954            };
3955            if !runs_to_completion || self.begin_call(abs + 4, Some(2), nvars, false).is_err() {
3956                self.jit.counters.deopt += 1;
3957                return -1;
3958            }
3959        }
3960        // Batched writeback — fill the caller's buffers with the
3961        // raw bits of R[A+2] / R[A+4] / R[A+5] so the trace IR can
3962        // reload via cranelift `stack_load` instead of separate
3963        // `luna_jit_stack_load` helper calls.
3964        // SAFETY: Gc<T> is NonNull<T> over the GC heap; the heap is single-threaded and the pointer is live as long as it is reachable from active roots (see heap.rs:5-7).
3965        let ctrl_raw = unsafe { self.stack[(abs + 2) as usize].unpack().1.zero };
3966        let (key_tag, key_rv) = self.stack[(abs + 4) as usize].unpack();
3967        // SAFETY: Gc<T> is NonNull<T> over the GC heap; the heap is single-threaded and the pointer is live as long as it is reachable from active roots (see heap.rs:5-7).
3968        let key_raw = unsafe { key_rv.zero };
3969        let (val_tag, val_raw) = if (nvars as usize) >= 2 {
3970            let (tag, rv) = self.stack[(abs + 5) as usize].unpack();
3971            // SAFETY: Gc<T> is NonNull<T> over the GC heap; the heap is single-threaded and the pointer is live as long as it is reachable from active roots (see heap.rs:5-7).
3972            (tag, unsafe { rv.zero })
3973        } else {
3974            (0, 0u64)
3975        };
3976        // SAFETY: Gc<T> is NonNull<T> over the GC heap; the heap is single-threaded and the pointer is live as long as it is reachable from active roots (see heap.rs:5-7).
3977        unsafe {
3978            ctrl_out.write(ctrl_raw as i64);
3979            key_out.write(key_raw as i64);
3980            val_out.write(val_raw as i64);
3981        }
3982        i64::from(key_tag) | i64::from(val_tag) << 8
3983    }
3984
3985    /// Load the raw `i64` payload of
3986    /// `vm.stack[base + slot_offset]` for the active trace's head
3987    /// Lua frame. Used to reload trace IR `Variable`s after a
3988    /// helper has written to `vm.stack` directly (e.g. TForCall's
3989    /// iter results land at `R[A+4..A+4+nvars]`).
3990    #[doc(hidden)]
3991    pub fn jit_stack_load(&mut self, slot_offset: u32) -> i64 {
3992        let Some(f) = self.jit_last_lua_frame() else {
3993            return 0;
3994        };
3995        let idx = (f.base as usize) + (slot_offset as usize);
3996        if idx >= self.stack.len() {
3997            return 0;
3998        }
3999        let v = self.stack[idx];
4000        let (_, raw) = v.unpack();
4001        // SAFETY: Gc<T> is NonNull<T> over the GC heap; the heap is single-threaded and the pointer is live as long as it is reachable from active roots (see heap.rs:5-7).
4002        unsafe { raw.zero as i64 }
4003    }
4004
4005    /// Read the tag byte of
4006    /// `vm.stack[base + slot_offset]`. Used by `Op::TForLoop` emit
4007    /// to dispatch on the iterator's return-key tag at runtime
4008    /// (`raw::NIL` → loop end exit, `raw::INT` → continue, other →
4009    /// deopt).
4010    #[doc(hidden)]
4011    pub fn jit_stack_tag(&mut self, slot_offset: u32) -> u8 {
4012        let Some(f) = self.jit_last_lua_frame() else {
4013            return crate::runtime::value::raw::NIL;
4014        };
4015        let idx = (f.base as usize) + (slot_offset as usize);
4016        if idx >= self.stack.len() {
4017            return crate::runtime::value::raw::NIL;
4018        }
4019        self.stack[idx].unpack().0
4020    }
4021
4022    /// Push a Lua frame onto the call stack with
4023    /// JIT-known metadata. Used by `luna_jit_trace_materialize_frames`
4024    /// at trace side-exits to recreate the inlined call activations
4025    /// the lowerer compiled past. The contract (enforced by the
4026    /// lowerer's pre-emit pass): `cl.proto` is non-vararg,
4027    /// `nresults` is the caller's expected count (today always 1
4028    /// because the lowerer bails Op::Call C != 2), and the caller
4029    /// has already called `jit_ensure_stack` to cover
4030    /// `[0..base + cl.proto.max_stack)`.
4031    #[doc(hidden)]
4032    pub fn jit_push_inlined_frame(
4033        &mut self,
4034        cl: Gc<LuaClosure>,
4035        base: u32,
4036        pc: u32,
4037        nresults: i32,
4038    ) {
4039        self.set_frame_ccmt(0);
4040        frames_push_sync(
4041            &mut self.frames,
4042            &mut self.frames_top,
4043            CallFrame::Lua(Frame {
4044                closure: cl,
4045                base,
4046                pc,
4047                // Lua call ABI: callee R[0] sits at caller R[A+1], so
4048                // callee.base = caller.base + A + 1; func_slot is
4049                // caller.base + A = callee.base - 1.
4050                func_slot: base - 1,
4051                n_varargs: 0,
4052                nresults,
4053                hook_oldpc: u32::MAX,
4054                from_c: false,
4055                tm: None,
4056                is_hook: false,
4057                tailcalls: 0,
4058            }),
4059        );
4060    }
4061
4062    /// Toggle precompiled-chunk loading. Default `true`. Sandbox embedders
4063    /// should set to `false` so `load`/`loadstring` reject bytecode input
4064    /// (which bypasses parser limits and could exploit verifier gaps).
4065    pub fn set_bytecode_loading(&mut self, enabled: bool) {
4066        self.bytecode_loading = enabled;
4067    }
4068
4069    /// Current bytecode-loading gate state.
4070    pub fn bytecode_loading(&self) -> bool {
4071        self.bytecode_loading
4072    }
4073
4074    /// Toggle PUC `.luac` bytecode loading. Default `false` — PUC
4075    /// bytecode is a strictly larger trust surface than luna's own dump
4076    /// format (third-party toolchain bugs, malformed chunks, unknown
4077    /// opcode shapes). Enable only for trusted PUC chunks. Per-dialect
4078    /// translators live in `crate::vm::dump::puc`.
4079    pub fn set_puc_bytecode_loading(&mut self, enabled: bool) {
4080        self.puc_bytecode_loading = enabled;
4081    }
4082
4083    /// Current PUC bytecode-loading gate state.
4084    pub fn puc_bytecode_loading(&self) -> bool {
4085        self.puc_bytecode_loading
4086    }
4087
4088    /// Default loader input budget — 256 MiB.
4089    ///
4090    /// `Vm::load` and the Lua-level `load(reader, ...)` both refuse
4091    /// sources whose byte length crosses this cap, returning the
4092    /// PUC-shaped `not enough memory` error rather than letting the
4093    /// host allocator try (and crash) to hold the next chunk.
4094    pub const DEFAULT_LOADER_INPUT_BUDGET: usize = 256 * 1024 * 1024;
4095
4096    /// Set the loader input byte budget (see
4097    /// [`Vm::DEFAULT_LOADER_INPUT_BUDGET`]). Pass `usize::MAX` to
4098    /// effectively disable. Smaller caps are honored verbatim — a 0
4099    /// cap rejects every non-empty source.
4100    pub fn set_loader_input_budget(&mut self, bytes: usize) {
4101        self.loader_input_budget = bytes;
4102    }
4103
4104    /// Current loader input byte budget.
4105    pub fn loader_input_budget(&self) -> usize {
4106        self.loader_input_budget
4107    }
4108
4109    /// Take the error traceback captured at the latest error point and
4110    /// reset it. Embedders should call this immediately after a failed
4111    /// `call_value`/`eval`/`call`/etc. — the next public `call_value`
4112    /// entry clears it. Returns `None` if no error was in flight.
4113    pub fn take_error_traceback(&mut self) -> Option<String> {
4114        let levels = self.error_traceback.take()?;
4115        let tb = crate::vm::callstack::traceback_from_lines(self.version, &levels, 0);
4116        Some(String::from_utf8_lossy(&tb).into_owned())
4117    }
4118
4119    /// PUC `luaL_traceback(L, L, msg, level)` on the running thread: `msg`
4120    /// (when given) and a newline, then `stack traceback:` and one line per
4121    /// stack level from `level` on, level 0 being the running function (the
4122    /// native calling this, when a native does).
4123    pub fn traceback(&mut self, msg: Option<&[u8]>, level: i64) -> Vec<u8> {
4124        let mut out = match msg {
4125            Some(m) => {
4126                let mut out = m.to_vec();
4127                out.push(b'\n');
4128                out
4129            }
4130            None => Vec::new(),
4131        };
4132        out.extend_from_slice(b"stack traceback:");
4133        out.extend(self.traceback_lines(None, level));
4134        out
4135    }
4136
4137    /// Arm the soft memory cap. The run loop checks the
4138    /// heap's tracked byte usage between dispatch turns; on overshoot it
4139    /// first runs a full collect, and if `bytes` still exceeds the cap it
4140    /// raises a catchable `"memory cap exceeded"` Lua error and disarms
4141    /// itself (fire-once: re-arm before the next `call_value` if reusing
4142    /// the Vm across requests). `None` removes the cap. The accounting is
4143    /// approximate — internal Vec/Box capacity overhead is not tracked,
4144    /// so embedders should size the cap with ~2× margin over the desired
4145    /// hard limit and additionally bound the Vm's lifetime (drop after
4146    /// each request).
4147    pub fn set_memory_cap(&mut self, cap: Option<usize>) {
4148        self.heap.mem_cap = cap;
4149    }
4150
4151    /// Approximate bytes the heap is currently holding. Object shells plus
4152    /// every table's internal array/hash boxes (tracked via
4153    /// `Heap::apply_bytes_delta` in `set`/`rehash`/`ensure_*`). Proto
4154    /// bytecode and closure upvalue slices still go uncounted — this is a
4155    /// lower bound, not a precise `malloc_stats`-style total.
4156    pub fn memory_used(&self) -> usize {
4157        self.heap.bytes()
4158    }
4159
4160    /// Read upvalue slot `i` of the native function currently on top of the
4161    /// dispatch chain (the one whose body is executing). Returns `Value::Nil`
4162    /// when no native is running. Public so the C ABI trampoline can fetch
4163    /// the host C function pointer it stashed there at registration time.
4164    pub fn running_native_upvalue(&self, i: usize) -> Value {
4165        match self.running_natives.last() {
4166            // SAFETY: Gc<T> is NonNull<T> over the GC heap; the heap is single-threaded and the pointer is live as long as it is reachable from active roots (see heap.rs:5-7).
4167            Some(nc) => unsafe {
4168                let upvals = &(*nc.as_ptr()).upvals;
4169                upvals.get(i).copied().unwrap_or(Value::Nil)
4170            },
4171            None => Value::Nil,
4172        }
4173    }
4174
4175    /// Register a table for finalization if its (just-set) metatable carries a
4176    /// `__gc` metamethod (PUC luaC_checkfinalizer at setmetatable time — adding
4177    /// `__gc` to the metatable afterwards does not retroactively register).
4178    pub(crate) fn check_finalizer(&mut self, t: Gc<Table>) {
4179        // Tables gained finalizers in 5.2; PUC 5.1 runs `__gc` for userdata only.
4180        if self.version == crate::version::LuaVersion::Lua51 {
4181            return;
4182        }
4183        if !self.get_mm(Value::Table(t), Mm::Gc).is_nil() {
4184            self.heap.register_finalizable(t);
4185        }
4186    }
4187
4188    /// Same as [`Self::check_finalizer`] for a userdata. PUC 5.1 attaches the
4189    /// finalizer to the proxy produced by `newproxy(true)` once its metatable
4190    /// gains `__gc`. gc.lua's "testing userdata" section sets `__gc` on the
4191    /// metatable that `newproxy` returned, which then needs to flow through.
4192    /// Kept available for the future 5.2+ `lua_setmetatable` path (which
4193    /// would re-check at metatable-set time); luna's only userdata
4194    /// finalizables today come via `newproxy`, which registers itself.
4195    #[allow(dead_code)]
4196    pub(crate) fn check_finalizer_userdata(&mut self, u: Gc<crate::runtime::Userdata>) {
4197        if !self.get_mm(Value::Userdata(u), Mm::Gc).is_nil() {
4198            self.heap.register_finalizable_userdata(u);
4199        }
4200    }
4201
4202    /// Run pending `__gc` finalizers (objects the collector resurrected for
4203    /// finalization). Finalizer errors are swallowed — PUC turns them into a
4204    /// warning; they must never propagate to the mutator. Reentrancy-guarded.
4205    fn run_finalizers(&mut self) {
4206        let _ = self.run_finalizers_or_err();
4207    }
4208
4209    fn run_finalizers_or_err(&mut self) -> Result<(), LuaError> {
4210        if self.gc_finalizing {
4211            return Ok(());
4212        }
4213        let pending = self.heap.take_tobefnz();
4214        if pending.is_empty() {
4215            return Ok(());
4216        }
4217        self.gc_finalizing = true;
4218        let mut first_err: Option<LuaError> = None;
4219        for obj in pending {
4220            let gc = self.get_mm(obj, Mm::Gc);
4221            // PUC 5.2+ accepts any non-nil `__gc` at setmetatable time to
4222            // schedule the object for finalization (`__gc = true` is the
4223            // canonical placeholder); only call it at finalize time when it
4224            // is actually a function. gc.lua 5.2 :412 wires up exactly this
4225            // sentinel and then expects no call.
4226            let callable = matches!(gc, Value::Closure(_) | Value::Native(_));
4227            if callable {
4228                // PUC `GCTM` sets `CIST_FIN` on the new ci so
4229                // `funcnamefromfinalizer` reports `namewhat = "metamethod"`,
4230                // `name = "__gc"`. luna threads the same outcome through the
4231                // generic `pending_tm` slot: the Lua frame born from this
4232                // call consumes it in `push_frame`. Saved/restored around the
4233                // call in case the handler is a native (which never pops it).
4234                // Bare event name; `frame_name` / `c_frame_name` add the
4235                // `"__"` debug prefix for 5.2/5.3, drop it for 5.4+. Matches
4236                // the convention used by `__close`, `__index`, …
4237                let saved_tm = self.pending_tm.replace("gc");
4238                // PUC `GCTM` runs the finalizer with `luaD_pcall` and no
4239                // message handler
4240                if let Err(e) = self.call_protected(gc, &[obj]) {
4241                    // PUC 5.1 GCTM raised the finalizer's error to the
4242                    // explicit `collectgarbage()` caller (`gc.lua 5.1 :255`
4243                    // baselines on `not pcall(collectgarbage)`). 5.2/5.3
4244                    // wrapped it in `error in __gc metamethod (msg)` first
4245                    // (`callGCTM` → `luaG_runerror`) but still raised. 5.4
4246                    // introduced the warning system and switched to "warn
4247                    // then continue" — never re-raise, just route the
4248                    // wrapped message through `warn`. gc.lua 5.5 :378 wires
4249                    // up `_WARN` capture under the `if T then …` block to
4250                    // baseline on the same wrapped string.
4251                    if self.version >= LuaVersion::Lua54 {
4252                        let inner = self.error_text(&e);
4253                        let msg = format!("error in __gc metamethod ({inner})");
4254                        self.emit_warn(msg.as_bytes(), false);
4255                    } else if first_err.is_none() {
4256                        let wrapped = if self.version >= LuaVersion::Lua52 {
4257                            let inner = self.error_text(&e);
4258                            let msg = format!("error in __gc metamethod ({inner})");
4259                            let s = Value::Str(self.heap.intern(msg.as_bytes()));
4260                            LuaError(s)
4261                        } else {
4262                            e
4263                        };
4264                        first_err = Some(wrapped);
4265                    }
4266                }
4267                self.pending_tm = saved_tm;
4268            }
4269        }
4270        self.gc_finalizing = false;
4271        match first_err {
4272            Some(e) => Err(e),
4273            None => Ok(()),
4274        }
4275    }
4276
4277    /// Drive one incremental GC step (PUC `collectgarbage("step", n)`).
4278    /// Crosses up to three phases per call:
4279    ///   1. Pause      → seed Propagate (`gc_start_propagate`)
4280    ///   2. Propagate  → drain gray up to `budget`; on exhaustion run atomic
4281    ///                   (`gc_finish_atomic` → tobefnz populated; finalizers
4282    ///                   run via `run_finalizers`) and enter Sweep
4283    ///   3. Sweep      → `gc_sweep_step` up to (residual) `budget`
4284    /// Returns true when this call completed the cycle's sweep (back to
4285    /// Pause). The budget is spent generously across phases — a large `n`
4286    /// can finish a whole cycle in one call (PUC stop-the-world step).
4287    pub(crate) fn gc_step(&mut self, budget: usize) -> bool {
4288        // Re-entry guard: never recurse — `run_finalizers` calls Lua code
4289        // that may hit a safe point and try to step again. Re-entry was OK
4290        // under STW (collect_garbage had its own guard) but here the
4291        // intermediate phase state would corrupt.
4292        if self.gc_finalizing {
4293            return false;
4294        }
4295        if self.heap.gc_phase_is_pause() {
4296            let (roots, extra) = self.gc_roots();
4297            self.heap.gc_start_propagate(&roots, &extra);
4298        }
4299        if self.heap.gc_phase_is_propagate() {
4300            if !self.heap.gc_step_propagate(budget) {
4301                return false;
4302            }
4303            self.heap.gc_finish_atomic();
4304            // any __gc scheduled by atomic — run before sweep so a finalizer
4305            // re-registering `self` re-enters the next cycle, not this sweep
4306            self.run_finalizers();
4307        }
4308        // either we just transitioned, or we entered already in Sweep, or
4309        // a finalizer started a new cycle (gc_sweep_step is a no-op then)
4310        self.heap.gc_sweep_step(budget)
4311    }
4312
4313    // ---- frames & calls ----
4314
4315    /// Begin calling stack[func_slot] with `nargs` (None: up to self.top).
4316    /// Returns true if a Lua frame was pushed (the dispatch loop continues
4317    /// there), false if a native completed inline.
4318    fn begin_call(
4319        &mut self,
4320        func_slot: u32,
4321        nargs: Option<u32>,
4322        nresults: i32,
4323        from_c: bool,
4324    ) -> Result<bool, LuaError> {
4325        let mut nargs = match nargs {
4326            Some(n) => n,
4327            None => self.top - (func_slot + 1),
4328        };
4329        // Consume `pending_is_tail` at the boundary: a tail-call op sets it
4330        // only for the immediately-following Lua activation. Native dispatch
4331        // (or `__call` resolution) below must not let it leak to the next
4332        // begin_call's frame; restore it just before push_frame for the Lua
4333        // arm so its meaning is preserved across __call chaining.
4334        let tailcalls = std::mem::take(&mut self.pending_tailcalls);
4335        let tail_ccmt = std::mem::take(&mut self.pending_ccmt);
4336        // resolve __call handlers iteratively (PUC tryfuncTM loop): each handler
4337        // is inserted before the value so it becomes the first argument, and a
4338        // chain of `__call` tables resolves down to a real function.
4339        let mut chain = 0u32;
4340        loop {
4341            match self.stack[func_slot as usize] {
4342                Value::Closure(cl) => {
4343                    // JIT fast path: if the Proto's body fits
4344                    // the int-arith whitelist, every arg is `Value::Int`,
4345                    // and the cached arity matches, skip frame setup and
4346                    // run the cached native fn in-place.
4347                    if self.try_jit_call_op(cl, func_slot, nargs, nresults) {
4348                        self.pending_tailcalls = tailcalls;
4349                        return Ok(false);
4350                    }
4351                    self.pending_tailcalls = tailcalls;
4352                    self.pending_ccmt = if tailcalls > 0 {
4353                        tail_ccmt
4354                    } else {
4355                        chain as u8
4356                    };
4357                    self.push_frame(cl, func_slot, nargs, nresults, from_c)?;
4358                    // Trace-on-call trigger. The frame
4359                    // we just pushed is the callee whose body the
4360                    // recorder will trace. Bump the per-Proto call
4361                    // counter; once it crosses `CALL_HOT_THRESHOLD`
4362                    // and no other trace is in flight, snapshot the
4363                    // callee's register window (R[0..max_stack]) and
4364                    // begin recording at `pc=0`. This is what unlocks
4365                    // tracing for functions whose body has no negative
4366                    // `Op::Jmp` back-edge (`fib`, recursive helpers).
4367                    //
4368                    // Gated on `trace_jit_enabled`, so the default
4369                    // dispatch pays a single not-taken branch.
4370                    if self.jit.trace_enabled {
4371                        let proto = cl.proto;
4372                        let c = proto.call_hot_count.get();
4373                        if c < u32::MAX / 2 {
4374                            proto.call_hot_count.set(c + 1);
4375                        }
4376                        // Relaxed call-trigger:
4377                        // `c >= THRESHOLD` (not `c == THRESHOLD`) +
4378                        // `!already_cached` short-circuit. Lets a
4379                        // discarded short call-trigger close retry
4380                        // on the next call (fib(10/15/20/25)
4381                        // pathology — first capture is base-case
4382                        // [Lt,Jmp,Return1]; coverage-heuristic
4383                        // discards; next call gets to record at a
4384                        // potentially deeper recursion point).
4385                        // Without `already_cached`, the relaxed
4386                        // condition would re-record over a cached
4387                        // trace every call.
4388                        //
4389                        // Additionally short-circuit on
4390                        // `proto.trace_gave_up`. The per-Proto discard
4391                        // cap force-compiles a partial trace and
4392                        // flips this flag; subsequent calls into
4393                        // this Proto skip the RefCell borrow + Vec
4394                        // scan entirely.
4395                        if proto.trace_gave_up.get() {
4396                            return Ok(true);
4397                        }
4398                        let call_already_cached =
4399                            proto.traces.borrow().iter().any(|t| t.head_pc == 0)
4400                                || trace_head_abandoned(proto, 0);
4401                        if c >= self.jit.call_hot_threshold
4402                            && self.jit.active_trace.is_none()
4403                            && !call_already_cached
4404                        {
4405                            // The new frame is on top: index in
4406                            // `self.frames` is `len() - 1`.
4407                            let frame_idx = self.frames.len() - 1;
4408                            // Snapshot R[0..max_stack] at the callee's
4409                            // base. `push_frame` resized `self.stack`
4410                            // to `base + max_stack`, so this window is
4411                            // guaranteed in-bounds.
4412                            let f = match &self.frames[frame_idx] {
4413                                CallFrame::Lua(f) => f,
4414                                _ => unreachable!("push_frame just pushed a Lua frame"),
4415                            };
4416                            let max_stack = cl.proto.max_stack as usize;
4417                            let base_us = f.base as usize;
4418                            let mut entry_tags = Vec::with_capacity(max_stack);
4419                            for i in 0..max_stack {
4420                                let (tag, _) = self.stack[base_us + i].unpack();
4421                                entry_tags.push(tag);
4422                            }
4423                            self.jit.active_trace =
4424                                Some(Box::new(crate::jit::trace::TraceRecord::start(
4425                                    cl.proto, 0, entry_tags, true,
4426                                )));
4427                            self.jit.recording_frame_base = frame_idx;
4428                        }
4429                    }
4430                    return Ok(true);
4431                }
4432                Value::Native(nc) => {
4433                    // Async-marked NativeClosure.
4434                    // Route through the cooperative-yield mechanism
4435                    // when async_mode is on; reject when called from
4436                    // a sync `eval`/`call_value` path (would have no
4437                    // executor to drive the returned future).
4438                    if nc.is_async {
4439                        if !self.async_mode {
4440                            let s = Value::Str(
4441                                self.heap.intern(b"async native called in sync context"),
4442                            );
4443                            self.last_error_kind = crate::vm::error::LuaErrorKind::Runtime;
4444                            return Err(LuaError(s));
4445                        }
4446                        // Same root-up bookkeeping as the sync path:
4447                        // pin args + result-count expectation so a
4448                        // collection across the suspend boundary
4449                        // keeps the arg window live.
4450                        self.native_nresults = nresults;
4451                        self.gc_top = func_slot + nargs + 1;
4452                        // Fire the "call" hook BEFORE
4453                        // building the future. Mirrors the sync native
4454                        // path's `hook_call(true, nargs)` site
4455                        // (`exec.rs` further down) so embedders with a
4456                        // Rust debug hook installed see a Call event
4457                        // for async natives identical to the sync
4458                        // path. The matching "return" hook fires from
4459                        // `commit_async_native_result` in
4460                        // `async_drive.rs` after the future resolves.
4461                        // Placement: after the `native_nresults` / `gc_top`
4462                        // pin, before the future is constructed, so a
4463                        // hook body that triggers GC observes the
4464                        // correct pinned window. On hook error the
4465                        // sentinel never returns and
4466                        // `pending_async_native_*` remain `None` —
4467                        // the executor sees `DispatchOutcome::Error`.
4468                        self.hook_call(true, nargs)?;
4469                        // Transmute the stored NativeFn back to its
4470                        // real AsyncNativeFn shape. Sound because
4471                        // `set_async_native` / `create_async_native`
4472                        // installed an AsyncNativeFn through the
4473                        // identically-sized fn-pointer slot, and the
4474                        // `is_async` marker bit is what records that
4475                        // fact.
4476                        let async_fn: crate::vm::async_drive::AsyncNativeFn =
4477                            // SAFETY: same-size fn pointers; provenance
4478                            // preserved through `mem::transmute`. The
4479                            // `is_async` marker is the only safe-to-call
4480                            // gate, set exclusively by
4481                            // `Vm::create_async_native`.
4482                            unsafe { std::mem::transmute(nc.f) };
4483                        let vm_ptr: *mut Vm = self;
4484                        let fut = async_fn(vm_ptr, func_slot, nargs);
4485                        // Stash the future + post-call context for
4486                        // `drive_one` to surface to `EvalFuture::poll`.
4487                        self.pending_async_native_fut = Some(fut);
4488                        self.pending_async_native_ctx = Some(AsyncNativeCallCtx {
4489                            func_slot,
4490                            nargs,
4491                            nresults,
4492                            gc_top: self.gc_top,
4493                        });
4494                        // Sentinel Err walked up to `drive_one` (same
4495                        // shape as `host_yield_pending`'s budget yield).
4496                        // Value::Nil — never seen by user code.
4497                        return Err(LuaError(Value::Nil));
4498                    }
4499                    // pcall/xpcall are yieldable: rather than calling the
4500                    // protected function through the Rust stack (which cannot be
4501                    // suspended), push a continuation frame and drive the call
4502                    // through the interpreter loop (PUC lua_pcallk). A yield
4503                    // inside it is preserved with the thread's saved frames.
4504                    use crate::runtime::value::NativeFn;
4505                    if std::ptr::fn_addr_eq(nc.f, nat_pcall as NativeFn) {
4506                        return self.begin_pcall(func_slot, nargs, nresults);
4507                    }
4508                    if std::ptr::fn_addr_eq(nc.f, nat_xpcall as NativeFn) {
4509                        // 5.1 `xpcall(f, err)` calls `f` with no arguments
4510                        let forward = self.version > LuaVersion::Lua51;
4511                        return self.begin_xpcall(func_slot, nargs, nresults, forward);
4512                    }
4513                    if std::ptr::fn_addr_eq(nc.f, nat_host_xpcall as NativeFn) {
4514                        return self.begin_xpcall(func_slot, nargs, nresults, true);
4515                    }
4516                    // From 5.4 on, pairs(t) calls a __pairs metamethod yieldably
4517                    // (PUC luaB_pairs uses lua_callk). 5.2/5.3 use a plain
4518                    // lua_call, and 5.1 has no `__pairs`: the native handles those.
4519                    if std::ptr::fn_addr_eq(nc.f, nat_pairs as NativeFn)
4520                        && nargs >= 1
4521                        && self.version >= LuaVersion::Lua54
4522                    {
4523                        let arg = self.stack[(func_slot + 1) as usize];
4524                        if !self.get_mm(arg, Mm::Pairs).is_nil() {
4525                            return self.begin_pairs(func_slot, nresults);
4526                        }
4527                    }
4528                    // a native that collects (e.g. `collectgarbage`) roots up to
4529                    // its own arguments — the caller's live registers all sit
4530                    // below `func_slot` and stay rooted.
4531                    self.native_nresults = nresults;
4532                    self.gc_top = func_slot + nargs + 1;
4533                    // Push the native onto the running-natives chain BEFORE
4534                    // firing the call hook so that `debug.getinfo(level)` and
4535                    // `arg_error` from inside the hook see this native as the
4536                    // currently-running C function (db.lua :344 reads
4537                    // `getinfo(2, "f").func` for the just-entered callee).
4538                    // Popped after the matching return hook fires — even on
4539                    // error, the pop must happen, so the body is bracketed
4540                    // through a scope guard.
4541                    self.running_natives.push(nc);
4542                    self.running_native_acts
4543                        .push(crate::vm::callstack::NativeAct {
4544                            func_slot,
4545                            nargs,
4546                            depth: self.frames.len() as u32,
4547                            // a tail call resolved its `__call` chain before
4548                            // calling here and passed the count in tail_ccmt
4549                            ccmt: tail_ccmt + chain as u8,
4550                        });
4551                    // PUC C-call discipline: entering a C function sets
4552                    // L->top to func + 1 + nargs, so a collect triggered
4553                    // INSIDE the native (explicit `collectgarbage()`, or
4554                    // an allocation crossing the GC threshold) roots the
4555                    // whole caller window up to and including the
4556                    // arguments. Without this raise the cursor is stale —
4557                    // parked at some earlier, possibly much lower
4558                    // safe-point — and the collect frees register-held
4559                    // values of the native's own caller (use-after-free).
4560                    // Never lower it: a re-entrant chain
4561                    // (native → Lua → native) must keep the outermost
4562                    // window rooted.
4563                    self.gc_top = self.gc_top.max(func_slot + 1 + nargs);
4564                    // PUC luaD_precall fires the "call" hook for C functions too.
4565                    // A yield inside the native (coroutine.yield) propagates an
4566                    // Err and the matching "return" hook fires on resume instead.
4567                    if let Err(e) = self.hook_call(true, nargs) {
4568                        self.running_natives.pop();
4569                        self.running_native_acts.pop();
4570                        return Err(e);
4571                    }
4572                    // Trap a Rust panic in the native and surface it as
4573                    // a Lua error rather than letting it unwind through the
4574                    // VM into the embedder. The VM's internal state may still
4575                    // be inconsistent after a panic (half-pushed args,
4576                    // dangling GC references), so embedders that catch this
4577                    // class of error should drop and re-create the Vm — but
4578                    // it's still better than tearing the host process down.
4579                    // `AssertUnwindSafe` is sound because the caller is the
4580                    // dispatch loop and any half-done state is fenced behind
4581                    // the immediate Err return below.
4582                    use std::panic::{AssertUnwindSafe, catch_unwind};
4583                    let result =
4584                        match catch_unwind(AssertUnwindSafe(|| (nc.f)(self, func_slot, nargs))) {
4585                            Ok(r) => r,
4586                            Err(payload) => {
4587                                let msg = panic_payload_str(&payload);
4588                                let s = Value::Str(
4589                                    self.heap.intern(format!("native panic: {msg}").as_bytes()),
4590                                );
4591                                Err(LuaError(s))
4592                            }
4593                        };
4594                    let nret = match result {
4595                        Ok(n) => n,
4596                        Err(e) => {
4597                            // PUC raises with the native still on the stack;
4598                            // remember it for the handler and traceback of the
4599                            // error (see `raise_to_handler`)
4600                            let act = self.running_native_acts.pop().expect("pushed above");
4601                            self.running_natives.pop();
4602                            self.note_errored_native(nc, act, e.0);
4603                            return Err(e);
4604                        }
4605                    };
4606                    // PUC `luaD_poscall` fires the return hook BEFORE moving
4607                    // results into the function's slot — at that point args
4608                    // sit at `[func_slot + 1, func_slot + 1 + nargs)` and
4609                    // results above them at `[func_slot + 1 + nargs, …)`.
4610                    // luna's `nat_return` has already written the results
4611                    // into `[func_slot, func_slot + nret)`, so we replay PUC's
4612                    // layout by copying the results up past the preserved
4613                    // args, firing the hook (with ftransfer = nargs + 1, so
4614                    // `getlocal(2, ftransfer..)` reads results), and then
4615                    // copying back for `finish_results`. db.lua :541 reads
4616                    // `getinfo("r").ftransfer` + `getlocal` to inspect a
4617                    // returning native's results this way.
4618                    if self.hook.ret
4619                        && !self.in_hook
4620                        && (self.hook.func.is_some() || self.hook.rust_func.is_some())
4621                    {
4622                        let res_dst = func_slot + nargs + 1;
4623                        let need = (res_dst + nret) as usize;
4624                        if self.stack.len() < need {
4625                            self.stack.resize(need, Value::Nil);
4626                        }
4627                        for i in (0..nret).rev() {
4628                            self.stack[(res_dst + i) as usize] =
4629                                self.stack[(func_slot + i) as usize];
4630                        }
4631                        // widen the C-frame's argument window for getlocal
4632                        if let Some(act) = self.running_native_acts.last_mut() {
4633                            act.nargs = nargs + nret;
4634                        }
4635                        let hr = self.hook_return(true, nargs + 1, nret);
4636                        if let Some(act) = self.running_native_acts.last_mut() {
4637                            act.nargs = nargs;
4638                        }
4639                        // restore results into the slot finish_results expects
4640                        for i in 0..nret {
4641                            self.stack[(func_slot + i) as usize] =
4642                                self.stack[(res_dst + i) as usize];
4643                        }
4644                        self.running_natives.pop();
4645                        self.running_native_acts.pop();
4646                        hr?;
4647                    } else {
4648                        self.running_natives.pop();
4649                        self.running_native_acts.pop();
4650                    }
4651                    self.finish_results(func_slot, nret, nresults);
4652                    // the native may have allocated; collect with the results as
4653                    // the live boundary (PUC checks GC after a call returns).
4654                    self.maybe_collect_garbage(self.top);
4655                    return Ok(false);
4656                }
4657                v => {
4658                    let mm = self.get_mm(v, Mm::Call);
4659                    if mm.is_nil() || self.call_mm_unusable(mm) {
4660                        return Err(self.call_err(v));
4661                    }
4662                    chain += 1;
4663                    // PUC 5.5 dropped the chain cap from `MAXTAGRECUR = 200`
4664                    // (the value 5.4's `lvm.c` uses) down to `MAXCCMT = 16`,
4665                    // and the 5.5 test exercises the new tight bound directly
4666                    // (calls.lua :225 builds a 16-deep chain and expects the
4667                    // 16th to error). 5.4 calls.lua :194 instead builds a 20-
4668                    // deep chain and expects it to succeed.
4669                    let cap = if self.version >= crate::version::LuaVersion::Lua55 {
4670                        15
4671                    } else {
4672                        MAX_CCMT
4673                    };
4674                    if chain > cap {
4675                        return Err(self.rt_err("'__call' chain too long"));
4676                    }
4677                    // slots above shift by one; at a call site those are dead
4678                    // temps of the current frame
4679                    self.stack.insert(func_slot as usize, mm);
4680                    if self.top > func_slot {
4681                        self.top += 1;
4682                    }
4683                    nargs += 1;
4684                }
4685            }
4686        }
4687    }
4688
4689    /// Up to 5.3 `tryfuncTM` takes one `__call` hop and needs a function
4690    /// there; anything else is a call error on the original object. 5.4
4691    /// retries the call with whatever `__call` holds, so chains resolve.
4692    fn call_mm_unusable(&self, mm: Value) -> bool {
4693        self.version <= LuaVersion::Lua53 && !matches!(mm, Value::Closure(_) | Value::Native(_))
4694    }
4695
4696    fn push_frame(
4697        &mut self,
4698        cl: Gc<LuaClosure>,
4699        func_slot: u32,
4700        nargs: u32,
4701        nresults: i32,
4702        from_c: bool,
4703    ) -> Result<(), LuaError> {
4704        if func_slot + 256 > MAX_LUA_STACK {
4705            // PUC `luaD_growstack`: the overflow raises "stack overflow" and
4706            // leaves ERRORSTACKSIZE's extra slots for the xpcall handler that
4707            // runs on it; overflowing those is LUA_ERRERR, "error in error
4708            // handling" (errors.lua :606, cstack.lua :29).
4709            if self.msgh_depth == 0 {
4710                return Err(self.rt_err("stack overflow"));
4711            }
4712            if func_slot + 256 > MAX_LUA_STACK + ERROR_STACK_EXTRA {
4713                return Err(self.plain_err("error in error handling"));
4714            }
4715        }
4716        let proto = cl.proto;
4717        let nparams = proto.num_params as u32;
4718        // 5.5 vararg layout (PUC luaT_adjustvarargs): the extra args stay on the
4719        // stack just below the new `base`, so a named vararg can be indexed
4720        // virtually without allocating a table. Rotate `[p1..pn][e1..em]` to
4721        // `[e1..em][p1..pn]` so the fixed params land at the new base.
4722        let n_varargs = if proto.is_vararg {
4723            nargs.saturating_sub(nparams)
4724        } else {
4725            0
4726        };
4727        if n_varargs > 0 {
4728            let s = (func_slot + 1) as usize;
4729            self.stack[s..s + nargs as usize].rotate_left(nparams as usize);
4730        }
4731        let base = func_slot + 1 + n_varargs;
4732        let need = (base + proto.max_stack as u32) as usize;
4733        if self.stack.len() < need {
4734            self.stack.resize(need, Value::Nil);
4735        }
4736        // wipe the register window beyond the kept parameters (stale values —
4737        // required for GC-safety and codegen). The varargs below `base` survive.
4738        let kept = nargs.saturating_sub(n_varargs).min(nparams);
4739        // SAFETY: just resized above so `need <= stack.len()`; `base + kept <=
4740        // need` since `base + nparams <= base + max_stack = need` and `kept <=
4741        // nparams`. `slice::fill` lowers to a single memset on Copy types.
4742        unsafe {
4743            self.stack
4744                .get_unchecked_mut((base + kept) as usize..need)
4745                .fill(Value::Nil);
4746        }
4747        let ccmt = std::mem::take(&mut self.pending_ccmt);
4748        self.set_frame_ccmt(ccmt);
4749        frames_push_sync(
4750            &mut self.frames,
4751            &mut self.frames_top,
4752            CallFrame::Lua(Frame {
4753                closure: cl,
4754                base,
4755                pc: 0,
4756                func_slot,
4757                nresults,
4758                hook_oldpc: u32::MAX,
4759                from_c,
4760                n_varargs,
4761                // single-shot consume: `close_slots` sets pending_tm before each
4762                // handler call; the next Lua frame born is that handler's.
4763                tm: self.pending_tm.take(),
4764                // `run_hook` sets `pending_is_hook` before dispatching the user
4765                // hook so its frame reports `namewhat = "hook"` via getinfo.
4766                is_hook: std::mem::take(&mut self.pending_is_hook),
4767                tailcalls: std::mem::take(&mut self.pending_tailcalls),
4768            }),
4769        );
4770        // PUC 5.1 `LUAI_COMPAT_VARARG`: populate the hidden `arg` local with
4771        // `{ n = n_varargs, [1] = e1, [2] = e2, … }`. The compiler reserved
4772        // the slot at `base + nparams`; the extras sit just below `base` from
4773        // the vararg rotate above. 5.1 db.lua :279 reads `arg.n` from a line
4774        // hook; vararg.lua's contradictory expectations were already going to
4775        // fail either way (some asserts want `arg == nil`).
4776        if proto.has_compat_vararg_arg {
4777            let arg_slot = (base + nparams) as usize;
4778            let t = self.heap.new_table();
4779            {
4780                // SAFETY: Gc<T> is NonNull<T> over the GC heap; the heap is single-threaded and the pointer is live as long as it is reachable from active roots (see heap.rs:5-7).
4781                let tm = unsafe { t.as_mut() };
4782                for i in 0..n_varargs {
4783                    let v = self.stack[(base - n_varargs + i) as usize];
4784                    // bounded by `n_varargs` (≤ MAXUPVAL territory), well
4785                    // below `MAX_ASIZE`
4786                    let _ = tm.set_int(&mut self.heap, (i + 1) as i64, v);
4787                }
4788                let nk = Value::Str(self.heap.intern(b"n"));
4789                tm.set(&mut self.heap, nk, Value::Int(n_varargs as i64))
4790                    .expect("'n' key");
4791            }
4792            // once-per-table barrier mirrors SETLIST: t is born BLACK during
4793            // Propagate and the bulk `set_int`/`set` calls above don't barrier
4794            self.heap
4795                .barrier_back(t.as_ptr() as *mut crate::runtime::heap::GcHeader);
4796            self.stack[arg_slot] = Value::Table(t);
4797        }
4798        // PUC luaD_precall fires the "call" hook with the new frame current, so
4799        // a hook calling debug.getinfo(2) sees the entered function. For a Lua
4800        // callee, PUC `luaD_hookcall` passes `p->numparams` as ntransfer (only
4801        // fixed params count — extras already live below `base`).
4802        // A frame born via OP_TailCall fires "tail call" instead (PUC
4803        // luaD_pretailcall) and skips the matching "return" hook on exit.
4804        let is_tail = self
4805            .frames
4806            .last()
4807            .and_then(|f| f.lua())
4808            .is_some_and(|f| f.tailcalls > 0);
4809        self.hook_call_with(false, nparams, is_tail)?;
4810        Ok(())
4811    }
4812
4813    /// `pcall(f, ...)` (PUC luaB_pcall): push a continuation frame, then drive
4814    /// the protected call `f` through the interpreter loop. The protected
4815    /// function and its arguments already sit at `func_slot+1..`, so calling `f`
4816    /// at `func_slot+1` lets its results land one slot above the continuation —
4817    /// the loop head then writes `true` at `func_slot` to form `true, results…`.
4818    /// Always returns `Ok(true)`: a continuation is now on the stack to be
4819    /// resolved by the loop (even when `f` is a native that already ran inline).
4820    fn begin_pcall(&mut self, func_slot: u32, nargs: u32, nresults: i32) -> Result<bool, LuaError> {
4821        if nargs == 0 {
4822            // `luaL_checkany` fails here: there is no function to call.
4823            self.with_native_running(func_slot, nargs, |vm| {
4824                let a = crate::vm::argcheck::Args::new(func_slot, nargs);
4825                crate::vm::argcheck::check_any(vm, a, 0).map(drop)
4826            })?;
4827        }
4828        if self.pcall_depth >= MAX_C_DEPTH {
4829            // raised inside pcall, a C function: no position
4830            return Err(self.plain_err("C stack overflow"));
4831        }
4832        self.pcall_depth += 1;
4833        frames_push_sync(
4834            &mut self.frames,
4835            &mut self.frames_top,
4836            CallFrame::Cont(NativeCont {
4837                kind: ContKind::Pcall,
4838                func_slot,
4839                nresults,
4840            }),
4841        );
4842        // call f (slot func_slot+1) with the remaining args, asking for all
4843        // results; a yield or error inside propagates with the continuation kept
4844        // on the stack (caught by `unwind` / preserved across a yield).
4845        self.begin_call(func_slot + 1, Some(nargs - 1), -1, true)?;
4846        Ok(true)
4847    }
4848
4849    /// `xpcall(f, msgh, ...)` (PUC luaB_xpcall): like `begin_pcall`, but the
4850    /// message handler is stashed in the continuation and the arguments are
4851    /// shifted down over the handler's slot so `f`'s args are contiguous.
4852    /// `forward` is false for 5.1's `xpcall`, which passes `f` none of them.
4853    fn begin_xpcall(
4854        &mut self,
4855        func_slot: u32,
4856        nargs: u32,
4857        nresults: i32,
4858        forward: bool,
4859    ) -> Result<bool, LuaError> {
4860        self.with_native_running(func_slot, nargs, |vm| {
4861            let a = crate::vm::argcheck::Args::new(func_slot, nargs);
4862            crate::vm::builtins::xpcall_handler(vm, a).map(drop)
4863        })?;
4864        if self.pcall_depth >= MAX_C_DEPTH {
4865            // raised inside pcall, a C function: no position
4866            return Err(self.plain_err("C stack overflow"));
4867        }
4868        self.pcall_depth += 1;
4869        // layout: [xpcall@func_slot, f@+1, msgh@+2, a1@+3, ...]. Stash msgh and
4870        // close its gap so f's args become [f@+1, a1@+2, ...].
4871        let handler = self.stack[(func_slot + 2) as usize];
4872        // 5.1: `xpcall (f, err)` takes exactly two parameters — extra
4873        // arguments are NOT forwarded to `f` (5.2 added forwarding;
4874        // 5.1 calls f with zero args).
4875        let nfargs = if forward { nargs - 2 } else { 0 };
4876        for i in 0..nfargs {
4877            self.stack[(func_slot + 2 + i) as usize] = self.stack[(func_slot + 3 + i) as usize];
4878        }
4879        self.top = func_slot + 2 + nfargs;
4880        frames_push_sync(
4881            &mut self.frames,
4882            &mut self.frames_top,
4883            CallFrame::Cont(NativeCont {
4884                kind: ContKind::Xpcall { handler },
4885                func_slot,
4886                nresults,
4887            }),
4888        );
4889        self.begin_call(func_slot + 1, Some(nfargs), -1, true)?;
4890        Ok(true)
4891    }
4892
4893    /// `pairs(t)` where `t` has a `__pairs` metamethod (PUC luaB_pairs's
4894    /// lua_callk path): drive `__pairs(t)` through the loop with a `Pairs`
4895    /// continuation so a `coroutine.yield` inside it suspends cleanly. The
4896    /// metamethod is called in `pairs`'s own slot, so its (≤4, nil-padded)
4897    /// results land exactly where `pairs`'s results belong.
4898    /// Run a check of the native at `func_slot` while it counts as the running
4899    /// C function, so an argument error names it the way PUC does. pcall and
4900    /// xpcall check their arguments in the dispatcher, before the native
4901    /// would otherwise be entered.
4902    fn with_native_running(
4903        &mut self,
4904        func_slot: u32,
4905        nargs: u32,
4906        check: impl FnOnce(&mut Vm) -> Result<(), LuaError>,
4907    ) -> Result<(), LuaError> {
4908        let Value::Native(nc) = self.stack[func_slot as usize] else {
4909            unreachable!("pcall/xpcall dispatch sits on a native")
4910        };
4911        self.running_natives.push(nc);
4912        self.running_native_acts
4913            .push(crate::vm::callstack::NativeAct {
4914                func_slot,
4915                nargs,
4916                depth: self.frames.len() as u32,
4917                ccmt: 0,
4918            });
4919        let r = check(self);
4920        self.running_natives.pop();
4921        self.running_native_acts.pop();
4922        r
4923    }
4924
4925    fn begin_pairs(&mut self, func_slot: u32, nresults: i32) -> Result<bool, LuaError> {
4926        let arg = self.stack[(func_slot + 1) as usize];
4927        let mm = self.get_mm(arg, Mm::Pairs);
4928        // layout becomes [pairs@func_slot, mm@func_slot+1, t@func_slot+2]:
4929        // `pairs` keeps its slot so the debug interface can report it as the
4930        // C function running below the metamethod. Call mm(t) wanting 4.
4931        let need = (func_slot + 3) as usize;
4932        if self.stack.len() < need {
4933            self.stack.resize(need, Value::Nil);
4934        }
4935        self.stack[(func_slot + 2) as usize] = arg;
4936        self.stack[(func_slot + 1) as usize] = mm;
4937        self.top = func_slot + 3;
4938        frames_push_sync(
4939            &mut self.frames,
4940            &mut self.frames_top,
4941            CallFrame::Cont(NativeCont {
4942                kind: ContKind::Pairs,
4943                func_slot,
4944                nresults,
4945            }),
4946        );
4947        let want = crate::vm::builtins::pairs_mm_results(self) as i32;
4948        self.begin_call(func_slot + 1, Some(1), want, true)?;
4949        Ok(true)
4950    }
4951
4952    /// The running (top) Lua frame. The interpreter only reads this while a Lua
4953    /// frame is on top — a continuation frame is never the running frame (it is
4954    /// consumed the instant the call it protects unwinds onto it).
4955    #[inline]
4956    /// Records the `__call` count of the Lua frame about to be pushed.
4957    fn set_frame_ccmt(&mut self, ccmt: u8) {
4958        let i = self.frames.len();
4959        if self.frame_ccmt.len() <= i {
4960            self.frame_ccmt.resize(i + 1, 0);
4961        }
4962        self.frame_ccmt[i] = ccmt;
4963    }
4964
4965    fn top_frame(&self) -> &Frame {
4966        self.frames
4967            .last()
4968            .and_then(CallFrame::lua)
4969            .expect("running Lua frame")
4970    }
4971
4972    #[inline]
4973    fn top_frame_mut(&mut self) -> &mut Frame {
4974        self.frames
4975            .last_mut()
4976            .and_then(CallFrame::lua_mut)
4977            .expect("running Lua frame")
4978    }
4979
4980    /// Pad/announce results sitting at func_slot.
4981    pub(crate) fn finish_results(&mut self, func_slot: u32, nret: u32, wanted: i32) {
4982        // Capture the call's high-water-mark before
4983        // setting the new top so we can Nil-clear slots that the
4984        // call temporarily wrote but no longer holds — matching
4985        // PUC's `L->top` discipline (slots past L->top are "free"
4986        // and the next push overwrites them). Without this clear,
4987        // a stale `Value::Closure` (e.g. the called function
4988        // itself, when wanted = 0) sits at `func_slot` and a
4989        // later GC with wider `gc_top` traces it after the
4990        // closure has been freed by a previous narrow safe-point
4991        // GC → heap-buffer-overflow in `Marker::header` (sort.lua
4992        // comparator case).
4993        let prev_top = self.top as usize;
4994        if wanted < 0 {
4995            self.top = func_slot + nret;
4996        } else {
4997            let wanted = wanted as u32;
4998            let need = (func_slot + wanted) as usize;
4999            if self.stack.len() < need {
5000                self.stack.resize(need, Value::Nil);
5001            }
5002            for i in nret..wanted {
5003                self.stack[(func_slot + i) as usize] = Value::Nil;
5004            }
5005            self.top = func_slot + wanted;
5006        }
5007        let new_top = self.top as usize;
5008        let clear_end = prev_top.min(self.stack.len());
5009        if new_top < clear_end {
5010            for slot in &mut self.stack[new_top..clear_end] {
5011                *slot = Value::Nil;
5012            }
5013        }
5014    }
5015
5016    /// Current Lua call-frame depth (read-only).
5017    /// Used by `EvalFuture` on the bootstrap poll to compute the
5018    /// `entry_depth` it will pass to subsequent resume slices.
5019    pub(crate) fn frame_count(&self) -> usize {
5020        self.frames.len()
5021    }
5022
5023    fn take_results(&mut self, func_slot: u32) -> Vec<Value> {
5024        let nret = self.top - func_slot;
5025        let out = self.stack[func_slot as usize..(func_slot + nret) as usize].to_vec();
5026        self.stack.truncate(func_slot as usize);
5027        self.top = func_slot;
5028        out
5029    }
5030
5031    // ---- open upvalues ----
5032
5033    #[doc(hidden)]
5034    pub fn find_or_create_upval(&mut self, slot: u32) -> Gc<Upvalue> {
5035        match self.open_upvals.binary_search_by_key(&slot, |&(s, _)| s) {
5036            Ok(i) => self.open_upvals[i].1,
5037            Err(i) => {
5038                let uv = self.heap.new_upvalue(UpvalState::Open {
5039                    slot,
5040                    thread: self.current,
5041                });
5042                self.open_upvals.insert(i, (slot, uv));
5043                uv
5044            }
5045        }
5046    }
5047
5048    pub(crate) fn close_from(&mut self, slot: u32) {
5049        while let Some(&(s, uv)) = self.open_upvals.last() {
5050            if s < slot {
5051                break;
5052            }
5053            let v = self.stack[s as usize];
5054            // SAFETY: Gc<T> is NonNull<T> over the GC heap; the heap is single-threaded and the pointer is live as long as it is reachable from active roots (see heap.rs:5-7).
5055            unsafe { uv.as_mut() }.set_closed(v);
5056            self.heap
5057                .barrier_forward(uv.as_ptr() as *mut crate::runtime::heap::GcHeader, v);
5058            self.open_upvals.pop();
5059        }
5060    }
5061
5062    /// Register a to-be-closed slot (TBC op / generic-for closing value).
5063    fn register_tbc(&mut self, slot: u32) -> Result<(), LuaError> {
5064        let v = self.stack[slot as usize];
5065        if matches!(v, Value::Nil | Value::Bool(false)) {
5066            return Ok(()); // nil and false are silently ignored
5067        }
5068        if self.get_mm(v, Mm::Close).is_nil() {
5069            // PUC `checkclosemth`: "variable '<name>' got a non-closable
5070            // value", the name as `luaG_findlocal` gives it — the frame's
5071            // locvars at this pc, else "(temporary)".
5072            let f = self.top_frame();
5073            let reg = slot - f.base;
5074            let pc = (f.pc as usize).saturating_sub(1);
5075            let name = crate::vm::objname::getlocalname(&f.closure.proto, reg, pc)
5076                .unwrap_or("(temporary)");
5077            return Err(self.rt_err(&format!("variable '{name}' got a non-closable value")));
5078        }
5079        // compiled code registers in register order and closes before it
5080        // registers a slot again; only a crafted chunk (`TBC R0; TBC R0`)
5081        // breaks that, which PUC's list cannot represent either
5082        if self.tbc.last().is_some_and(|&s| s >= slot) {
5083            return Err(self.rt_err("'<close>' state corrupted"));
5084        }
5085        self.tbc.push(slot);
5086        Ok(())
5087    }
5088
5089    /// Close upvalues and run `__close` handlers for slots ≥ `from`
5090    /// (handlers in reverse registration order; PUC luaF_close).
5091    fn close_slots(&mut self, from: u32, err: Option<Value>) -> Result<(), LuaError> {
5092        self.close_from(from);
5093        // PUC: handlers run in reverse declaration order; an error raised by a
5094        // handler becomes the error object passed to the remaining ones, and
5095        // the rest are still closed. The last raised error propagates.
5096        let mut pending = err;
5097        let mut result = Ok(());
5098        let saved_err = self.closing_err;
5099        // On a normal close the handler runs within the closing function's
5100        // activation (debug parent = that function); during error unwinding the
5101        // function's frame is already gone, so the handler sits at the C
5102        // boundary instead (PUC: luaF_close runs after the ci is restored).
5103        let error_close = err.is_some();
5104        while let Some(&s) = self.tbc.last() {
5105            if s < from {
5106                break;
5107            }
5108            self.tbc.pop();
5109            let v = self.stack[s as usize];
5110            if matches!(v, Value::Nil | Value::Bool(false)) {
5111                continue;
5112            }
5113            let mm = self.get_mm(v, Mm::Close);
5114            if mm.is_nil() {
5115                // PUC `prepclosingmethod`: the __close metamethod was present
5116                // at OP_TBC (else we would have errored there) but has since
5117                // been removed/replaced. Treat as a non-callable target.
5118                let tn = self.obj_typename(v);
5119                let e = self.rt_err(&format!(
5120                    "attempt to call a {tn} value (metamethod 'close')"
5121                ));
5122                pending = Some(e.0);
5123                result = Err(e);
5124                continue;
5125            }
5126            // root the pending error: a handler may trigger a collection
5127            self.closing_err = pending;
5128            // PUC `luaF_close` sets `ci->u.l.tm = TM_CLOSE` so traceback /
5129            // getinfo report the handler as "in metamethod 'close'". Saved/
5130            // restored around the call to cover the path where `mm` is a
5131            // native (`push_frame` never consumes it) or it raises before
5132            // reaching push_frame.
5133            let saved_tm = self.pending_tm.replace("close");
5134            // PUC 5.4 `prepclosingmethod` always pushed (obj, errobj) — errobj
5135            // is nil on a normal close (5.4 locals.lua :875's
5136            // `func2close(coroutine.yield)` wrap pins `(self, nil)` back
5137            // through the yield). PUC 5.5 dropped the trailing nil: a clean
5138            // close passes only `obj`, the error case still passes both
5139            // (5.5 locals.lua :314 `select("#", ...) == n` with n=1 for the
5140            // normal-close arms, n=2 for the error arm).
5141            let call = match pending {
5142                Some(e) => self.call_value_impl(mm, &[v, e], error_close),
5143                None => {
5144                    if self.version >= LuaVersion::Lua55 {
5145                        self.call_value_impl(mm, &[v], error_close)
5146                    } else {
5147                        self.call_value_impl(mm, &[v, Value::Nil], error_close)
5148                    }
5149                }
5150            };
5151            self.pending_tm = saved_tm;
5152            if let Err(e) = call {
5153                pending = Some(e.0);
5154                result = Err(e);
5155            }
5156        }
5157        self.closing_err = saved_err;
5158        result
5159    }
5160
5161    /// Yieldable variant of `close_slots`: drive the chain of `__close`
5162    /// handlers for slots ≥ `from` through the interpreter loop with a
5163    /// `Cont::Close` continuation, so a `coroutine.yield()` inside any handler
5164    /// suspends cleanly (the close iteration's state rides on the thread's
5165    /// frame/stack like any other suspended call) — PUC's `lua_callk` pattern
5166    /// applied to `luaF_close`. `after` runs when every slot is closed; if
5167    /// `after` is `Return` and we've returned past `entry_depth`,
5168    /// `Ok(Some(vals))` carries the result up to the host caller.
5169    fn begin_close(
5170        &mut self,
5171        from: u32,
5172        err: Option<Value>,
5173        after: AfterClose,
5174        entry_depth: usize,
5175    ) -> Result<Option<Vec<Value>>, LuaError> {
5176        self.close_from(from);
5177        self.drive_close(from, err, after, entry_depth)
5178    }
5179
5180    /// Pop tbc slots ≥ `from`, skipping nil/false and synthesising a
5181    /// non-callable-mm error for an `__close` that was reset to a bad value
5182    /// between OP_TBC and now (PUC `prepclosingmethod`). The first real
5183    /// handler pushes a `Cont::Close` + `begin_call` and returns `Ok(None)`;
5184    /// the interpreter then drives the handler and re-enters this driver via
5185    /// the `Cont::Close` consumer in `run()`. When the chain is exhausted,
5186    /// the threaded error (if any) propagates or `after` fires.
5187    fn drive_close(
5188        &mut self,
5189        from: u32,
5190        mut pending: Option<Value>,
5191        after: AfterClose,
5192        entry_depth: usize,
5193    ) -> Result<Option<Vec<Value>>, LuaError> {
5194        loop {
5195            let drained = match self.tbc.last() {
5196                None => true,
5197                Some(&s) => s < from,
5198            };
5199            if drained {
5200                return self.finish_close_after(after, pending, entry_depth);
5201            }
5202            let s = self.tbc.pop().expect("tbc non-empty");
5203            let v = self.stack[s as usize];
5204            if matches!(v, Value::Nil | Value::Bool(false)) {
5205                continue;
5206            }
5207            let mm = self.get_mm(v, Mm::Close);
5208            if mm.is_nil() {
5209                let tn = self.obj_typename(v);
5210                let e = self.rt_err(&format!(
5211                    "attempt to call a {tn} value (metamethod 'close')"
5212                ));
5213                pending = Some(e.0);
5214                continue;
5215            }
5216            // A real handler: stage [mm, v, (err?)] above the current top,
5217            // record the close iteration state in a Cont::Close, and let the
5218            // interpreter dispatch the handler. On return the run() head
5219            // re-enters this driver via the Cont::Close consumer.
5220            let func_slot = self.top;
5221            let error_close = pending.is_some();
5222            let need = (func_slot + 3) as usize;
5223            if self.stack.len() < need {
5224                self.stack.resize(need, Value::Nil);
5225            }
5226            self.stack[func_slot as usize] = mm;
5227            self.stack[func_slot as usize + 1] = v;
5228            // PUC 5.4 always passes (obj, errobj=nil) on a normal close;
5229            // 5.5 drops the trailing nil. 5.4 locals.lua :875 vs 5.5 :314.
5230            let nargs = match pending {
5231                Some(e) => {
5232                    self.stack[func_slot as usize + 2] = e;
5233                    2u32
5234                }
5235                None => {
5236                    if self.version >= LuaVersion::Lua55 {
5237                        1u32
5238                    } else {
5239                        self.stack[func_slot as usize + 2] = Value::Nil;
5240                        2u32
5241                    }
5242                }
5243            };
5244            self.top = func_slot + 1 + nargs;
5245            // Root the pending error during the call (a handler may collect).
5246            let saved_err = self.closing_err;
5247            self.closing_err = pending;
5248            // PUC `luaF_close` flags the handler frame as "metamethod 'close'"
5249            // for traceback / getinfo.
5250            let saved_tm = self.pending_tm.replace("close");
5251            frames_push_sync(
5252                &mut self.frames,
5253                &mut self.frames_top,
5254                CallFrame::Cont(NativeCont {
5255                    kind: ContKind::Close(CloseCont {
5256                        from,
5257                        pending,
5258                        after,
5259                    }),
5260                    func_slot,
5261                    nresults: 0,
5262                }),
5263            );
5264            // PUC luaF_close runs a normal close *within* the closing
5265            // function's activation (debug parent = that function); during an
5266            // error unwind the function's frame is already gone and the
5267            // handler sits at the C boundary instead.
5268            let r = self.begin_call(func_slot, Some(nargs), 0, error_close);
5269            self.pending_tm = saved_tm;
5270            self.closing_err = saved_err;
5271            r?;
5272            return Ok(None);
5273        }
5274    }
5275
5276    /// Fire `after` once every `__close` handler has run. `Block` propagates
5277    /// any remaining error or simply continues; `Return` performs OP_Return's
5278    /// tail (hook + frame pop + result delivery) and may surface results to
5279    /// the host when the function whose return triggered the close was the
5280    /// entry activation, but only on a clean drain — a pending error skips
5281    /// the return tail and propagates instead. `ResumeUnwind` pops the
5282    /// deferred Lua frame and re-raises, letting a handler's own error win
5283    /// over the original propagating one (PUC luaF_close).
5284    fn finish_close_after(
5285        &mut self,
5286        after: AfterClose,
5287        pending: Option<Value>,
5288        entry_depth: usize,
5289    ) -> Result<Option<Vec<Value>>, LuaError> {
5290        match after {
5291            AfterClose::Block => match pending {
5292                Some(e) => Err(LuaError(e)),
5293                None => Ok(None),
5294            },
5295            AfterClose::Return {
5296                abs_a,
5297                nret,
5298                from_native,
5299            } => match pending {
5300                Some(e) => Err(LuaError(e)),
5301                None => self.complete_return(abs_a, nret, from_native, entry_depth),
5302            },
5303            AfterClose::ResumeUnwind { func_slot, err } => {
5304                // The aborting Lua frame was popped before `begin_close`;
5305                // restore the catcher's stack window down to `func_slot` and
5306                // re-raise — preferring a handler-raised error over the
5307                // original (PUC luaF_close).
5308                self.stack.truncate(func_slot as usize);
5309                self.top = func_slot;
5310                self.tbc.retain(|&s| s < func_slot);
5311                Err(LuaError(pending.unwrap_or(err)))
5312            }
5313        }
5314    }
5315
5316    /// OP_Return's post-close tail: fire the "return" hook (frame still
5317    /// current), pop the Lua frame, slide results into `func_slot`, then
5318    /// either hand them to the host (`Ok(Some(vals))` when we've returned
5319    /// past `entry_depth`), leave them contiguous for an exposed
5320    /// pcall/xpcall continuation, or finish into the caller's expected
5321    /// result slot. Mirrors the synchronous OP_Return tail so both paths
5322    /// share semantics — the `from_native` flag selects the right "return"
5323    /// hook context for `hook_return`.
5324    fn complete_return(
5325        &mut self,
5326        abs_a: u32,
5327        nret: u32,
5328        from_native: bool,
5329        entry_depth: usize,
5330    ) -> Result<Option<Vec<Value>>, LuaError> {
5331        // ftransfer is the local index (1-based) of the first result, as
5332        // `getinfo("r").ftransfer + getlocal(level, k)` consumes it. luna
5333        // exposes locals starting at `frame.base` (= func_slot + 1 +
5334        // n_varargs for a vararg call), so the conversion is the absolute
5335        // result slot minus base, plus one to make it 1-based. db.lua 5.4
5336        // :542 (`foo1(); on=false; eqseq(out, {10, 0})`) pins the vararg
5337        // shape end-to-end.
5338        let ftransfer = self
5339            .frames
5340            .last()
5341            .and_then(CallFrame::lua)
5342            .map(|fr| {
5343                let raw = abs_a.saturating_sub(fr.base) + 1;
5344                // 5.5 anonymous-vararg functions get a `(vararg table)` pseudo
5345                // local injected at index `numparams + 1`, so getlocal
5346                // numbering shifts results past it (5.5 db.lua :539
5347                // `eqseq(out, {10, 0})`). 5.4 and earlier have no such pseudo.
5348                if fr.closure.proto.has_vararg_table_pseudo {
5349                    raw + 1
5350                } else {
5351                    raw
5352                }
5353            })
5354            .unwrap_or(1);
5355        // PUC 5.1 `luaD_poscall`: fire one extra "tail return" hook event
5356        // per tail call that collapsed into this activation, *after* its
5357        // own "return". `tailcalls` tracks that count exactly (PUC
5358        // `ci->u.l.tailcalls`). 5.2+ retired LUA_HOOKTAILRET, so the
5359        // "return" hook fires once even when the activation absorbed
5360        // multiple tail calls — only `istailcall` on getinfo surfaces the
5361        // collapse. 5.1 db.lua :366 pins the event ordering.
5362        let tailcalls = if self.version <= LuaVersion::Lua51 {
5363            self.frames
5364                .last()
5365                .and_then(|f| f.lua())
5366                .map(|f| f.tailcalls)
5367                .unwrap_or(0)
5368        } else {
5369            0
5370        };
5371        self.hook_return(from_native, ftransfer, nret)?;
5372        for _ in 0..tailcalls {
5373            self.hook_tail_return()?;
5374        }
5375        let CallFrame::Lua(fr) =
5376            frames_pop_sync(&mut self.frames, &mut self.frames_top).expect("no frame")
5377        else {
5378            unreachable!("returning from a non-Lua frame")
5379        };
5380        for i in 0..nret {
5381            self.stack[(fr.func_slot + i) as usize] = self.stack[(abs_a + i) as usize];
5382        }
5383        if self.frames.len() < entry_depth {
5384            self.top = fr.func_slot + nret;
5385            return Ok(Some(self.take_results(fr.func_slot)));
5386        } else if matches!(self.frames.last(), Some(CallFrame::Cont(_))) {
5387            self.top = fr.func_slot + nret;
5388        } else {
5389            self.finish_results(fr.func_slot, nret, fr.nresults);
5390        }
5391        Ok(None)
5392    }
5393
5394    #[doc(hidden)]
5395    pub fn upval_get(&self, cl: Gc<LuaClosure>, idx: u32) -> Value {
5396        match cl.upvals()[idx as usize].state() {
5397            UpvalState::Open { slot, thread } => self.read_slot(slot, thread),
5398            UpvalState::Closed(v) => v,
5399        }
5400    }
5401
5402    fn upval_set(&mut self, cl: Gc<LuaClosure>, idx: u32, v: Value) {
5403        let uv = cl.upvals()[idx as usize];
5404        match uv.state() {
5405            UpvalState::Open { slot, thread } => self.write_slot(slot, thread, v),
5406            UpvalState::Closed(_) => {
5407                // SAFETY: Gc<T> is NonNull<T> over the GC heap; the heap is single-threaded and the pointer is live as long as it is reachable from active roots (see heap.rs:5-7).
5408                unsafe { uv.as_mut() }.set_closed(v);
5409                // forward barrier: a closed upvalue is single-slot, so the
5410                // forward variant is cheaper than barrier_back (PUC uses
5411                // `luaC_barrier_` for upvalues; `luaC_barrierback_` for
5412                // tables / threads).
5413                self.heap
5414                    .barrier_forward(uv.as_ptr() as *mut crate::runtime::heap::GcHeader, v);
5415            }
5416        }
5417    }
5418
5419    // ---- register / error helpers ----
5420
5421    #[inline(always)]
5422    fn r(&self, base: u32, i: u32) -> Value {
5423        // SAFETY: the compiler reserves `proto.max_stack` slots above `base`
5424        // at frame entry (`push_frame` sizes the stack up to base + max_stack),
5425        // and every bytecode-generated reference falls within `[0, max_stack)`.
5426        // PUC's vmfetch uses raw `R(A)` (`s2v(L->base + A)`) for the same
5427        // reason. The bounds check would re-validate this invariant on every
5428        // op — the dispatch hot path can't afford it.
5429        // SAFETY: Gc<T> is NonNull<T> over the GC heap; the heap is single-threaded and the pointer is live as long as it is reachable from active roots (see heap.rs:5-7).
5430        unsafe { *self.stack.get_unchecked((base + i) as usize) }
5431    }
5432
5433    #[inline(always)]
5434    fn set_r(&mut self, base: u32, i: u32, v: Value) {
5435        // SAFETY: see `r` — `base + i < base + max_stack <= stack.len()` by
5436        // frame-entry contract.
5437        unsafe {
5438            *self.stack.get_unchecked_mut((base + i) as usize) = v;
5439        }
5440    }
5441
5442    #[doc(hidden)]
5443    pub fn rt_err(&mut self, msg: &str) -> LuaError {
5444        let text = match self.position_prefix() {
5445            Some(p) => format!("{p}{msg}"),
5446            None => msg.to_string(),
5447        };
5448        LuaError(Value::Str(self.heap.intern(text.as_bytes())))
5449    }
5450
5451    /// Error without the `chunk:line:` position prefix. PUC's
5452    /// `resume_error` (ldo.c) pushes its message as a bare literal,
5453    /// so `cannot resume dead coroutine` etc. must not be prefixed.
5454    pub(crate) fn plain_err(&mut self, msg: &str) -> LuaError {
5455        LuaError(Value::Str(self.heap.intern(msg.as_bytes())))
5456    }
5457
5458    /// A string a library built from pieces of any size: one longer than a
5459    /// string can hold raises, as the concatenation operator does.
5460    pub(crate) fn built_str(&mut self, bytes: &[u8]) -> Result<Value, LuaError> {
5461        if bytes.len() > crate::runtime::string::MAX_LEN {
5462            return Err(self.rt_err("string length overflow"));
5463        }
5464        Ok(Value::Str(self.heap.intern(bytes)))
5465    }
5466
5467    pub(crate) fn type_err(&mut self, what: &str, v: Value) -> LuaError {
5468        let extra = self.subject_varinfo(v);
5469        let tn = self.obj_typename(v);
5470        let msg = self.compose_type_err(what, &tn, &extra);
5471        self.runerror(&msg)
5472    }
5473
5474    /// Assemble a `luaG_typeerror` / `luaG_callerror` message in the dialect's
5475    /// word order.
5476    ///
5477    /// PUC ≤5.2 names the operand first — `attempt to call field 'f' (a nil
5478    /// value)`. 5.3 flipped it to type-first — `attempt to call a nil value
5479    /// (field 'f')`. luna emitted the 5.3+ form on every dialect, so every
5480    /// such error was worded wrong under 5.1/5.2.
5481    ///
5482    /// Two shapes carry no operand name on ≤5.2 and must collapse to the bare
5483    /// message: an absent varinfo (identical across dialects), and a
5484    /// metamethod target — ≤5.2's `luaG_typeerror` only names locals, globals,
5485    /// fields, upvalues and methods, so `(metamethod 'add')` has no ≤5.2
5486    /// counterpart and is dropped rather than reworded. All four shapes were
5487    /// measured against stock 5.1.5 / 5.2.4 / 5.5.1 before this was written.
5488    fn compose_type_err(&self, what: &str, tn: &str, extra: &str) -> String {
5489        if self.version() > crate::version::LuaVersion::Lua52 {
5490            return format!("attempt to {what} a {tn} value{extra}");
5491        }
5492        // `extra` is "" or " (kind 'name')" — unwrap to "kind 'name'".
5493        let inner = extra
5494            .trim_start()
5495            .trim_start_matches('(')
5496            .trim_end_matches(')');
5497        if inner.is_empty() || inner.starts_with("metamethod") {
5498            format!("attempt to {what} a {tn} value")
5499        } else {
5500            format!("attempt to {what} {inner} (a {tn} value)")
5501        }
5502    }
5503
5504    /// Name the offending operand of the current instruction (PUC varinfo) for
5505    /// a type error, e.g. " (global 'x')". The faulting value `bad` is matched
5506    /// to the instruction's subject register(s); a native-raised error whose
5507    /// current instruction doesn't hold `bad` simply yields "".
5508    fn subject_varinfo(&self, bad: Value) -> String {
5509        use crate::vm::isa::Op;
5510        // PUC `varinfo` names a variable only for a Lua activation
5511        if self.native_on_top() {
5512            return String::new();
5513        }
5514        let Some(f) = self.frames.last().and_then(CallFrame::lua) else {
5515            return String::new();
5516        };
5517        let proto = f.closure.proto;
5518        let p: &crate::runtime::Proto = &proto;
5519        let pc = f.pc as usize;
5520        if pc == 0 || pc > p.code.len() {
5521            return String::new();
5522        }
5523        let instr = p.code[pc - 1];
5524        let mut cands: Vec<u32> = Vec::new();
5525        match instr.op() {
5526            // indexed reads / length / method: the table/object is in B
5527            Op::GetField | Op::GetI | Op::GetTable | Op::SelfOp | Op::Len => {
5528                cands.push(instr.b());
5529            }
5530            // indexed writes / calls: the table/function is in A
5531            Op::SetField | Op::SetI | Op::SetTable | Op::Call | Op::TailCall => {
5532                cands.push(instr.a());
5533            }
5534            // arithmetic/bitwise: a register operand (B, and C unless constant)
5535            Op::Add
5536            | Op::Sub
5537            | Op::Mul
5538            | Op::Div
5539            | Op::Mod
5540            | Op::Pow
5541            | Op::IDiv
5542            | Op::BAnd
5543            | Op::BOr
5544            | Op::BXor
5545            | Op::Shl
5546            | Op::Shr => {
5547                cands.push(instr.b());
5548                if !instr.k() {
5549                    cands.push(instr.c());
5550                }
5551            }
5552            Op::Unm | Op::BNot => cands.push(instr.b()),
5553            // indexing an upvalue table (`_ENV` for a global): PUC
5554            // `getupvalname` finds the value among the closure's upvalues
5555            Op::GetTabUp | Op::SetTabUp => {
5556                let u = if instr.op() == Op::GetTabUp {
5557                    instr.b()
5558                } else {
5559                    instr.a()
5560                };
5561                if self.upval_get(f.closure, u).raw_eq(bad)
5562                    && let Some(d) = p.upvals.get(u as usize)
5563                {
5564                    return format!(" (upvalue '{}')", d.name);
5565                }
5566            }
5567            Op::Concat => {
5568                let a = instr.a();
5569                for r in a..a + instr.b() {
5570                    cands.push(r);
5571                }
5572            }
5573            _ => {}
5574        }
5575        // Up to 5.3 a binary operator takes a constant operand straight
5576        // from the constant table (RK), where `varinfo` cannot see it, so a
5577        // string constant is not named there; unary operators load it into
5578        // a register first and do name it.
5579        let rk_operands = self.version <= LuaVersion::Lua53
5580            && matches!(
5581                instr.op(),
5582                Op::Add
5583                    | Op::Sub
5584                    | Op::Mul
5585                    | Op::Div
5586                    | Op::Mod
5587                    | Op::Pow
5588                    | Op::IDiv
5589                    | Op::BAnd
5590                    | Op::BOr
5591                    | Op::BXor
5592                    | Op::Shl
5593                    | Op::Shr
5594            );
5595        for reg in cands {
5596            if self.r(f.base, reg).raw_eq(bad) {
5597                return match crate::vm::objname::getobjname_in(p, pc - 1, reg, self.version) {
5598                    Some(("constant", _)) if rk_operands => String::new(),
5599                    Some((kind, name)) => format!(" ({kind} '{name}')"),
5600                    None => String::new(),
5601                };
5602            }
5603        }
5604        String::new()
5605    }
5606
5607    /// "attempt to call a X value", enriched (PUC luaG_callerror) with a name
5608    /// for the call target: "(global 'f')" for a direct call, or "(metamethod
5609    /// 'add')" when the call is a metamethod dispatched by the current opcode.
5610    fn call_err(&mut self, v: Value) -> LuaError {
5611        let extra = self.call_target_varinfo(v);
5612        let tn = self.obj_typename(v);
5613        let msg = self.compose_type_err("call", &tn, &extra);
5614        self.runerror(&msg)
5615    }
5616
5617    /// Name the offending call target. A metamethod dispatch pushes a `Cont`
5618    /// frame before the call, so the opcode that triggered it lives in the
5619    /// nearest *Lua* frame — read that instruction: OP_CALL names the function
5620    /// register, any metamethod-bearing opcode yields "(metamethod 'event')".
5621    fn call_target_varinfo(&self, bad: Value) -> String {
5622        use crate::vm::isa::Op;
5623        if self.native_on_top() {
5624            return String::new();
5625        }
5626        let Some(f) = self.frames.iter().rev().find_map(CallFrame::lua) else {
5627            return String::new();
5628        };
5629        let proto = f.closure.proto;
5630        let p: &crate::runtime::Proto = &proto;
5631        let pc = f.pc as usize;
5632        if pc == 0 || pc > p.code.len() {
5633            return String::new();
5634        }
5635        let instr = p.code[pc - 1];
5636        match instr.source_op() {
5637            Op::Call | Op::TailCall => {
5638                let reg = instr.a();
5639                if self.r(f.base, reg).raw_eq(bad) {
5640                    match crate::vm::objname::getobjname_in(p, pc - 1, reg, self.version) {
5641                        Some((kind, name)) => format!(" ({kind} '{name}')"),
5642                        None => String::new(),
5643                    }
5644                } else {
5645                    String::new()
5646                }
5647            }
5648            // 5.4 `funcnamefromcode` names the generic-for iterator call
5649            // (5.3 had the entry but raised through plain `luaG_typeerror`)
5650            Op::TForCall if self.version >= LuaVersion::Lua54 => {
5651                " (for iterator 'for iterator')".to_string()
5652            }
5653            // 5.4 `funcnamefromcall` names the metamethod; up to 5.3 the
5654            // call raised through `luaG_typeerror`, whose `varinfo` does not
5655            op if self.version >= LuaVersion::Lua54 => match mm_event_name(op) {
5656                Some(ev) => format!(" (metamethod '{ev}')"),
5657                None => String::new(),
5658            },
5659            _ => String::new(),
5660        }
5661    }
5662
5663    /// "number has no integer representation", enriched (PUC luaG_tointerror)
5664    /// with a "(field 'x')"-style suffix naming the offending operand of the
5665    /// current arithmetic instruction when it can be recovered from bytecode.
5666    fn no_int_rep_err(&mut self) -> LuaError {
5667        let extra = self.bad_operand_varinfo();
5668        self.runerror(&format!("number{extra} has no integer representation"))
5669    }
5670
5671    /// Inspect the current frame's faulting instruction: find the register
5672    /// operand holding a float with no integer representation and name it.
5673    fn bad_operand_varinfo(&self) -> String {
5674        if self.native_on_top() {
5675            return String::new();
5676        }
5677        let Some(f) = self.frames.last().and_then(CallFrame::lua) else {
5678            return String::new();
5679        };
5680        let proto = f.closure.proto;
5681        let p: &crate::runtime::Proto = &proto;
5682        let pc = f.pc as usize;
5683        if pc == 0 || pc > p.code.len() {
5684            return String::new();
5685        }
5686        let instr = p.code[pc - 1];
5687        let mut regs = vec![instr.b()];
5688        if !instr.k() {
5689            regs.push(instr.c());
5690        }
5691        let no_int = |n: Option<Num>| matches!(n, Some(Num::Float(x)) if crate::runtime::value::f2i_exact(x).is_none());
5692        for reg in regs {
5693            let v = self.r(f.base, reg);
5694            // before 5.4 a numeric string is converted first, so "2.5" is
5695            // the operand without an integer value
5696            let n = self.arith_operand()(v);
5697            if no_int(n) {
5698                return match crate::vm::objname::getobjname_in(p, pc - 1, reg, self.version) {
5699                    Some((kind, name)) => format!(" ({kind} '{name}')"),
5700                    None => String::new(),
5701                };
5702            }
5703        }
5704        String::new()
5705    }
5706
5707    /// Position prefix of the currently executing Lua frame. PUC `luaL_error`
5708    /// calls `luaL_where(L, 1)` which reads `L->ci->previous`. When the prior
5709    /// frame is a C function (e.g. a pcall Cont parked above `require`'s
5710    /// native call), PUC pushes no prefix — match that by looking only at the
5711    /// topmost frame directly and bailing if it is anything but a Lua frame.
5712    pub(crate) fn position_prefix(&self) -> Option<String> {
5713        let f = match self.frames.last()? {
5714            CallFrame::Lua(f) => f,
5715            // a native metamethod runs above the Meta continuation of the
5716            // instruction that triggered it: that Lua function is its caller
5717            CallFrame::Cont(NativeCont {
5718                kind: ContKind::Meta(_),
5719                ..
5720            }) => self.frames.iter().rev().nth(1)?.lua()?,
5721            CallFrame::Cont(_) => return None,
5722        };
5723        let proto = f.closure.proto;
5724        // a stripped chunk: no source in luna's own format, no line info in
5725        // PUC's (whose loader names the missing source "=?")
5726        if proto.source.as_bytes().is_empty() || proto.lines.is_empty() {
5727            return Some(self.stripped_prefix());
5728        }
5729        let line = proto.lines[(f.pc as usize).saturating_sub(1).min(proto.lines.len() - 1)];
5730        // SAFETY: Gc<T> is NonNull<T> over the GC heap; the heap is single-threaded and the pointer is live as long as it is reachable from active roots (see heap.rs:5-7).
5731        let raw = unsafe { crate::runtime::string::bytes_of(proto.source.as_ptr()) };
5732        let display = crate::vm::lib_debug::chunk_id(self.version, raw);
5733        let src = String::from_utf8_lossy(&display).into_owned();
5734        Some(format!("{src}:{line}: "))
5735    }
5736
5737    /// PUC `luaG_addinfo` prefix for a stripped chunk. 5.5 substitutes "=?"
5738    /// for the source and renders the line as "?" (so the prefix reads
5739    /// `?:?: `). 5.4 and below leave the source NULL ("?") and use the raw
5740    /// `getfuncline = -1`, so the prefix reads `?:-1: ` (5.4 errors.lua :282
5741    /// matches `^%?:%-1:`).
5742    fn stripped_prefix(&self) -> String {
5743        if self.version >= crate::version::LuaVersion::Lua55 {
5744            "?:?: ".to_string()
5745        } else {
5746            "?:-1: ".to_string()
5747        }
5748    }
5749
5750    /// PUC `luaL_where(L, level)`: `"short_src:line: "` for the function at
5751    /// `level` (0 = the running native), or `None` when that level does not
5752    /// exist or has no line information (a C function, a stripped chunk).
5753    pub(crate) fn position_prefix_at_level(&self, level: i64) -> Option<String> {
5754        let ts = self.thread_stack(None);
5755        let i = usize::try_from(level).ok()?;
5756        if i >= ts.levels.len() {
5757            return None;
5758        }
5759        let line = ts.currentline(i);
5760        if line <= 0 {
5761            return None;
5762        }
5763        let DbgKind::Lua(fi) = ts.levels[i] else {
5764            return None;
5765        };
5766        let ar = self.closure_ar(ts.lua(fi).closure);
5767        Some(format!(
5768            "{}:{line}: ",
5769            String::from_utf8_lossy(&ar.short_src)
5770        ))
5771    }
5772
5773    // ---- the interpreter ----
5774
5775    /// Run from the current top frame down to (but not past) `entry_depth`
5776    /// frames. Coroutine driving passes `entry_depth = 1` so the whole thread
5777    /// runs to completion or a yield.
5778    /// Resume the dispatcher from the saved
5779    /// `entry_depth` (captured pre-yield by `drive_one`). Called by
5780    /// `EvalFuture::poll` on every poll after the first to walk the
5781    /// existing call frames until the next `BudgetExhausted` or
5782    /// terminal `Ok`/`Err`. Not a public-API surface; the
5783    /// embedder reaches it through `Vm::eval_async`.
5784    pub(crate) fn exec_with_async(&mut self, entry_depth: usize) -> Result<Vec<Value>, LuaError> {
5785        self.exec_with(entry_depth)
5786    }
5787
5788    fn exec_with(&mut self, entry_depth: usize) -> Result<Vec<Value>, LuaError> {
5789        loop {
5790            let r = self.run(entry_depth);
5791            if r.is_err()
5792                && (self.yielding.is_some()
5793                    || self.terminating.is_some()
5794                    || self.host_yield_pending
5795                    || self.pending_async_native_fut.is_some())
5796            {
5797                // a `coroutine.yield` is in flight: keep the frames intact (they
5798                // are the suspended coroutine's saved state) and propagate to
5799                // resume. A self-close termination propagates the same way, so a
5800                // protecting pcall on the way out cannot catch (unwind) it.
5801                // `host_yield_pending` is the async-mode
5802                // analogue: the sentinel must reach `drive_one` without
5803                // a protecting `pcall` swallowing it.
5804                return r;
5805            }
5806            match r {
5807                Ok(vals) => return Ok(vals),
5808                // unwind toward `entry_depth`. A protecting pcall/xpcall
5809                // continuation caught along the way turns the error into
5810                // `false, msg` and the loop resumes running its caller; an
5811                // uncaught error propagates out.
5812                Err(e) => match self.unwind(e.0, entry_depth) {
5813                    Unwound::Caught => continue,
5814                    Unwound::CaughtReturn(vals) => return Ok(vals),
5815                    Unwound::Propagated(err) => return Err(err),
5816                },
5817            }
5818        }
5819    }
5820
5821    /// Unwind the call stack from the error point toward `entry_depth`, running
5822    /// `__close` handlers on each Lua frame. Stops at the first pcall/xpcall
5823    /// continuation frame at/above `entry_depth` (the error is *caught*: its
5824    /// slot receives `false, msg`); if none is reached, the error propagates.
5825    fn unwind(&mut self, mut err: Value, entry_depth: usize) -> Unwound {
5826        // The protected call runs in-place among the caller frames' registers,
5827        // so truncating the failed frames here cuts into caller windows below
5828        // the catcher. Snapshot the live length: at the error point the stack
5829        // already spans every surviving frame's window, so restoring it after a
5830        // catch reinstates them all (the reclaimed slots above are dead temps).
5831        // PUC handles overflow recovery via a separate EXTRA_STACK reserve;
5832        // we instead clamp the restore to the catcher's caller window when the
5833        // error point was at the stack limit (cause: the next `call_value_impl`
5834        // picks `func_slot = stack.len()` which would otherwise re-overflow).
5835        let saved_len = self.stack.len();
5836        err = self.raise_to_handler(err);
5837        // An error that no protected call inside the running coroutine will
5838        // catch kills it without unwinding: PUC's `lua_resume` leaves the
5839        // dead thread's stack as it was, so its pending to-be-closed
5840        // variables run only when it is closed (`coroutine.close`, or
5841        // `coroutine.wrap` closing it before re-raising). Scoped to the
5842        // coroutine's own run (`entry_depth == 1`); a run nested under a
5843        // native unwinds as before.
5844        if entry_depth == 1
5845            && self.version >= LuaVersion::Lua54
5846            && self
5847                .current
5848                .is_some_and(|c| c.status == crate::runtime::CoroStatus::Running)
5849            && !self.frames.iter().any(|f| {
5850                matches!(
5851                    f,
5852                    CallFrame::Cont(NativeCont {
5853                        kind: ContKind::Pcall | ContKind::Xpcall { .. } | ContKind::Close(_),
5854                        ..
5855                    })
5856                )
5857            })
5858        {
5859            while self.frames.len() >= entry_depth {
5860                frames_pop_sync(&mut self.frames, &mut self.frames_top);
5861            }
5862            return Unwound::Propagated(LuaError(err));
5863        }
5864        while self.frames.len() >= entry_depth {
5865            match *self.frames.last().expect("frame") {
5866                // a yieldable-metamethod continuation does not catch: discard the
5867                // abandoned instruction and keep unwinding (PUC drops the partial
5868                // op on error).
5869                CallFrame::Cont(NativeCont {
5870                    kind: ContKind::Meta(mc),
5871                    func_slot,
5872                    ..
5873                }) => {
5874                    frames_pop_sync(&mut self.frames, &mut self.frames_top);
5875                    self.stack.truncate(func_slot as usize);
5876                    self.top = mc.saved_top.min(func_slot);
5877                    self.tbc.retain(|&s| s < func_slot);
5878                }
5879                // a __pairs continuation does not catch either: an error inside
5880                // the metamethod propagates past `pairs`.
5881                CallFrame::Cont(NativeCont {
5882                    kind: ContKind::Pairs,
5883                    func_slot,
5884                    ..
5885                }) => {
5886                    frames_pop_sync(&mut self.frames, &mut self.frames_top);
5887                    self.stack.truncate(func_slot as usize);
5888                    self.top = func_slot;
5889                    self.tbc.retain(|&s| s < func_slot);
5890                }
5891                // a __close continuation does not catch: drop the half-run
5892                // handler's window, then continue the close yieldably with
5893                // the new error threaded as `pending`. Preserve `cc.after`
5894                // verbatim — `Return`/`Block` originating from an aborting
5895                // OP_Return/OP_Close will be short-circuited by
5896                // `finish_close_after` (pending propagates as Err); a
5897                // `ResumeUnwind` originated by our own Lua-frame handler
5898                // must keep its deferred frame-pop semantics so that frame
5899                // is not orphaned. If a fresh handler yields, `drive_close`
5900                // pushes another `Cont::Close` and we return `Caught` so
5901                // `exec_with` re-enters the run loop.
5902                CallFrame::Cont(NativeCont {
5903                    kind: ContKind::Close(cc),
5904                    func_slot,
5905                    ..
5906                }) => {
5907                    frames_pop_sync(&mut self.frames, &mut self.frames_top);
5908                    self.stack.truncate(func_slot as usize);
5909                    self.top = func_slot;
5910                    self.tbc.retain(|&s| s < func_slot);
5911                    match self.drive_close(cc.from, Some(err), cc.after, entry_depth) {
5912                        Ok(Some(_)) => {
5913                            unreachable!(
5914                                "Block / Return / ResumeUnwind never return host values mid-unwind"
5915                            )
5916                        }
5917                        Ok(None) => return Unwound::Caught,
5918                        Err(e) => {
5919                            // the drained close re-raises `err`; only an
5920                            // error a handler raised is new
5921                            if !e.0.raw_eq(err) {
5922                                err = self.raise_to_handler(e.0);
5923                            }
5924                            continue;
5925                        }
5926                    }
5927                }
5928                CallFrame::Cont(nc) => {
5929                    frames_pop_sync(&mut self.frames, &mut self.frames_top);
5930                    self.pcall_depth -= 1;
5931                    let result = match nc.kind {
5932                        ContKind::Pcall => {
5933                            self.msgh_applied = None;
5934                            err
5935                        }
5936                        // the handler ran where the error was raised (see
5937                        // `raise_to_handler`); one raised past the handler's
5938                        // reach (by the unwind itself) meets it here
5939                        ContKind::Xpcall { handler } => {
5940                            if self.msgh_applied.take().is_some_and(|v| v.raw_eq(err)) {
5941                                err
5942                            } else {
5943                                self.call_msgh(handler, err)
5944                            }
5945                        }
5946                        ContKind::Meta(_) | ContKind::Pairs | ContKind::Close(_) => {
5947                            unreachable!("Meta/Pairs/Close cont handled above")
5948                        }
5949                    };
5950                    // PUC 5.5 `luaG_errormsg` substitutes "<no error object>"
5951                    // for nil AFTER the message handler ran (ldebug.c:849) —
5952                    // so it applies to the pcall-caught object and to an
5953                    // xpcall HANDLER'S return value, while the handler itself
5954                    // (and a top-level propagation into the host, whose
5955                    // `error_display` plays msghandler) still sees the raw
5956                    // nil. 5.4- keep nil everywhere (errors.lua :49 asserts
5957                    // `doit("error()") == nil`).
5958                    let result = if matches!(result, Value::Nil)
5959                        && self.version >= crate::version::LuaVersion::Lua55
5960                    {
5961                        Value::Str(self.heap.intern(b"<no error object>"))
5962                    } else {
5963                        result
5964                    };
5965                    // the error has been caught (pcall/xpcall): the captured
5966                    // traceback was for that error and is no longer in flight.
5967                    self.error_traceback = None;
5968                    let fs = nc.func_slot as usize;
5969                    if self.stack.len() < fs + 2 {
5970                        self.stack.resize(fs + 2, Value::Nil);
5971                    }
5972                    self.stack[fs] = Value::Bool(false);
5973                    self.stack[fs + 1] = result;
5974                    self.top = nc.func_slot + 2;
5975                    self.tbc.retain(|&s| s < nc.func_slot);
5976                    if self.frames.len() < entry_depth {
5977                        return Unwound::CaughtReturn(self.take_results(nc.func_slot));
5978                    }
5979                    self.finish_results(nc.func_slot, 2, nc.nresults);
5980                    // reinstate the caller windows the unwind truncated into,
5981                    // clamped to the catcher's caller window + a `MIN_STACK`
5982                    // reserve. The clamp is a no-op for normal pcall catches
5983                    // (saved_len lies within the caller's max_stack window),
5984                    // and prevents the stack from staying near `MAX_LUA_STACK`
5985                    // after an overflow-recovery catch — which would make the
5986                    // next `call_value_impl` (e.g. a `__close` in the catcher's
5987                    // errorh, locals.lua:659) pick `func_slot = stack.len()`
5988                    // above the limit and re-overflow.
5989                    // Restore the caller's full register window: opcodes
5990                    // index it directly. The cap covers caller's base +
5991                    // `max_stack` + a small reserve. We always resize to
5992                    // exactly this window — previously this clamped
5993                    // `saved_len` from above to prevent staying near
5994                    // `MAX_LUA_STACK` after an overflow-recovery catch, and
5995                    // a yieldable-unwind re-entry adds the dual case where
5996                    // `saved_len` is *below* the window (a prior
5997                    // `ResumeUnwind` truncated). Using the window directly
5998                    // covers both.
5999                    let restore = self
6000                        .frames
6001                        .iter()
6002                        .rev()
6003                        .find_map(CallFrame::lua)
6004                        .map(|c| (c.base + c.closure.proto.max_stack as u32) as usize + 256)
6005                        .unwrap_or(saved_len);
6006                    if self.stack.len() < restore {
6007                        self.stack.resize(restore, Value::Nil);
6008                    } else if self.stack.len() > restore {
6009                        self.stack.truncate(restore);
6010                    }
6011                    // Clear slots vacated by the popped
6012                    // frames the unwind walked over. finish_results
6013                    // above clears `[nc.func_slot + nresults ..
6014                    // nc.func_slot + 2)`, which only covers the
6015                    // pcall's own result region — the unwind-popped
6016                    // frames' locals in `[nc.func_slot + 2 .. restore)`
6017                    // are still in place with whatever Gc-bearing
6018                    // Values they last held. Without this clear, a
6019                    // later GC marks the stale pointers (same hazard as
6020                    // the Op::Return finish_results path). PUC's `luaD_pcall` similarly truncates
6021                    // L->top to the catcher's level — luna's
6022                    // truncate above resizes the Vec but doesn't
6023                    // touch slots [func_slot+2..restore) that were
6024                    // already present.
6025                    let clear_lo = (nc.func_slot as usize + 2).min(self.stack.len());
6026                    let clear_hi = restore.min(self.stack.len());
6027                    if clear_lo < clear_hi {
6028                        for slot in &mut self.stack[clear_lo..clear_hi] {
6029                            *slot = Value::Nil;
6030                        }
6031                    }
6032                    return Unwound::Caught;
6033                }
6034                CallFrame::Lua(f) => {
6035                    // Yieldable error-unwind close, PUC luaG_errormsg shape:
6036                    // (1) pop the Lua frame immediately so each `__close`
6037                    // handler runs at the C boundary above — `debug.getinfo`
6038                    // sees the next outer Lua frame's call site (typically
6039                    // `pcall`), not this aborting function (locals.lua:480).
6040                    // (2) drive the close yieldably with
6041                    // `AfterClose::ResumeUnwind { func_slot, err }`; on drain
6042                    // it truncates to `func_slot` and re-raises (letting a
6043                    // handler-raised error win over `err`). If a handler
6044                    // yields, `drive_close` pushes `Cont::Close` and we
6045                    // return `Caught` so `exec_with` re-enters the run loop;
6046                    // a synchronous drain returns Err exactly as the old
6047                    // path did.
6048                    frames_pop_sync(&mut self.frames, &mut self.frames_top);
6049                    let after = AfterClose::ResumeUnwind {
6050                        func_slot: f.func_slot,
6051                        err,
6052                    };
6053                    match self.begin_close(f.base, Some(err), after, entry_depth) {
6054                        Ok(Some(_)) => {
6055                            unreachable!("ResumeUnwind never returns host values")
6056                        }
6057                        Ok(None) => return Unwound::Caught,
6058                        Err(e) => {
6059                            // the drained close re-raises `err`; only an
6060                            // error a handler raised is new
6061                            if !e.0.raw_eq(err) {
6062                                err = self.raise_to_handler(e.0);
6063                            }
6064                            continue;
6065                        }
6066                    }
6067                }
6068            }
6069        }
6070        Unwound::Propagated(LuaError(err))
6071    }
6072
6073    fn run(&mut self, entry_depth: usize) -> Result<Vec<Value>, LuaError> {
6074        loop {
6075            // Fast-path slow-check gate: most embedders run with both
6076            // `instr_budget` and `mem_cap` as None, so a single combined
6077            // is_some test lets the hot loop skip both branches with one
6078            // load + branch instead of two.
6079            if self.instr_budget.is_some() || self.heap.mem_cap.is_some() {
6080                if let Some(b) = self.instr_budget.as_mut() {
6081                    *b -= 1;
6082                    if *b <= 0 {
6083                        self.instr_budget = None;
6084                        // Async-mode cooperative
6085                        // yield. Set a sentinel flag so `exec_with`
6086                        // propagates the Err without `unwind` running
6087                        // (mirroring the `yielding.is_some()` path),
6088                        // and `call_value_impl` preserves the call
6089                        // frames for the next `poll`. Translation back
6090                        // to `DispatchOutcome::BudgetExhausted` happens
6091                        // in `drive_one`. The Err value itself is
6092                        // `Value::Nil` — a pure sentinel, never seen by
6093                        // user code.
6094                        if self.async_mode {
6095                            self.host_yield_pending = true;
6096                            return Err(LuaError(Value::Nil));
6097                        }
6098                        // Classify the trip so embedders can
6099                        // distinguish budget exhaustion from a
6100                        // generic Runtime error and retry / give up
6101                        // accordingly.
6102                        self.last_error_kind = crate::vm::error::LuaErrorKind::InstrBudget;
6103                        let s = Value::Str(self.heap.intern(b"instruction budget exceeded"));
6104                        return Err(LuaError(s));
6105                    }
6106                }
6107                if let Some(cap) = self.heap.mem_cap
6108                    && self.heap.bytes() > cap
6109                {
6110                    // First try a full collect — embedders set tight caps
6111                    // and the overshoot may be reclaimable (closures kept
6112                    // by short-lived frames, intermediate strings). Only
6113                    // disarm + raise if the cap is still breached after
6114                    // collection. PUC's `LUA_GCEMERGENCY` path matches.
6115                    //
6116                    // Root up to the deepest Lua frame's
6117                    // `base + max_stack` window rather than the entire
6118                    // `self.stack.len()`
6119                    // (covers register operands the current opcode
6120                    // might reference). The cap fires during table
6121                    // mutation in a tight `a[i] = i` loop where `a`
6122                    // lives at a frame-register slot past `self.top`
6123                    // (OP_NEWINDEX doesn't advance top); the deepest
6124                    // frame's max_stack window provably covers it
6125                    // since `a` is a register of the executing proto.
6126                    //
6127                    // Still over-roots caller frames' dead regs
6128                    // (slots between caller.base and the callee
6129                    // func_slot are live; slots past callee
6130                    // func_slot in caller's frame are dead until
6131                    // caller resumes). For fire-once cap path this
6132                    // residual over-root is acceptable; there is no
6133                    // full per-frame walk because a strong/weak pass
6134                    // split is semantically impossible — the weak pass
6135                    // depends on strong-pass marks.
6136                    let cap_root_top = self
6137                        .frames
6138                        .iter()
6139                        .rev()
6140                        .find_map(CallFrame::lua)
6141                        .map(|f| f.base + f.closure.proto.max_stack as u32)
6142                        .unwrap_or(self.top);
6143                    self.gc_top = cap_root_top.max(self.top);
6144                    self.collect_garbage();
6145                    if self.heap.bytes() > cap {
6146                        self.heap.mem_cap = None;
6147                        let s = Value::Str(self.heap.intern(b"memory cap exceeded"));
6148                        return Err(LuaError(s));
6149                    }
6150                }
6151            }
6152            // Single combined frame fetch: continuation arm OR Lua arm. Saves
6153            // a second `self.frames.last()` slice access vs the prior split
6154            // form (LLVM doesn't always CSE these across the cont branch).
6155            // A continuation frame on top means the call it protected just
6156            // delivered its results — wrap as `true, results…` and hand to
6157            // the pcall/xpcall caller. The error path is handled by `unwind`;
6158            // this branch is only reached on success/resume completion.
6159            // SAFETY: Gc<T> is NonNull<T> over the GC heap; the heap is single-threaded and the pointer is live as long as it is reachable from active roots (see heap.rs:5-7).
6160            let frame_peek = unsafe { self.frames.last().unwrap_unchecked() };
6161            if let &CallFrame::Cont(nc) = frame_peek {
6162                // a yieldable metamethod returned: complete the interrupted
6163                // instruction (PUC luaV_finishOp) and resume the running frame.
6164                if let ContKind::Meta(mc) = nc.kind {
6165                    frames_pop_sync(&mut self.frames, &mut self.frames_top);
6166                    let result = if self.top > nc.func_slot {
6167                        self.stack[nc.func_slot as usize]
6168                    } else {
6169                        Value::Nil
6170                    };
6171                    self.stack.truncate(nc.func_slot as usize);
6172                    self.top = mc.saved_top;
6173                    self.finish_meta(mc.action, result)?;
6174                    continue;
6175                }
6176                // a __close handler returned successfully: discard its
6177                // results, restore `top` to the slot the handler was called
6178                // at (the surrounding frame's register window above this slot
6179                // must stay alloc'd — never truncate the underlying stack),
6180                // then continue the close chain (next slot, or fire
6181                // AfterClose). When the close ends an entry activation,
6182                // drive_close hands the results up to exec_with directly.
6183                if let ContKind::Close(cc) = nc.kind {
6184                    frames_pop_sync(&mut self.frames, &mut self.frames_top);
6185                    self.top = nc.func_slot;
6186                    if let Some(vals) =
6187                        self.drive_close(cc.from, cc.pending, cc.after, entry_depth)?
6188                    {
6189                        return Ok(vals);
6190                    }
6191                    continue;
6192                }
6193                // __pairs returned: normalize its results to exactly the
6194                // dialect's count (iterator, state, control, and on 5.5 the
6195                // closing value) at pairs's slot, where the metamethod was
6196                // called, and hand them to pairs's caller.
6197                if let ContKind::Pairs = nc.kind {
6198                    frames_pop_sync(&mut self.frames, &mut self.frames_top);
6199                    let total = crate::vm::builtins::pairs_mm_results(self) as u32;
6200                    let need = (nc.func_slot + total) as usize;
6201                    if self.stack.len() < need {
6202                        self.stack.resize(need, Value::Nil);
6203                    }
6204                    // the metamethod ran one slot above pairs's own
6205                    let first = nc.func_slot + 1;
6206                    let n = (self.top - first).min(total);
6207                    for i in 0..n {
6208                        self.stack[(nc.func_slot + i) as usize] = self.stack[(first + i) as usize];
6209                    }
6210                    for s in (nc.func_slot + n)..(nc.func_slot + total) {
6211                        self.stack[s as usize] = Value::Nil;
6212                    }
6213                    self.top = nc.func_slot + total;
6214                    if self.frames.len() < entry_depth {
6215                        return Ok(self.take_results(nc.func_slot));
6216                    }
6217                    self.finish_results(nc.func_slot, total, nc.nresults);
6218                    continue;
6219                }
6220                frames_pop_sync(&mut self.frames, &mut self.frames_top);
6221                self.pcall_depth -= 1;
6222                // f's results sit at nc.func_slot+1.. (f was called one slot
6223                // above the continuation), so writing `true` at the slot makes
6224                // `true, results…` already contiguous.
6225                let nret = self.top - (nc.func_slot + 1);
6226                self.stack[nc.func_slot as usize] = Value::Bool(true);
6227                let total = 1 + nret;
6228                self.top = nc.func_slot + total;
6229                if self.frames.len() < entry_depth {
6230                    return Ok(self.take_results(nc.func_slot));
6231                }
6232                self.finish_results(nc.func_slot, total, nc.nresults);
6233                continue;
6234            }
6235            // GC runs only at the allocation safe points below (PUC's
6236            // `luaC_checkGC` sites), each with a precise `gc_top`; the loop head
6237            // no longer collects, so a stale full-window `gc_top` cannot leak in.
6238            //
6239            // Hot-path frame fetch: the Cont arm above continues the loop,
6240            // so reaching here means `frame_peek` is the Lua frame. Reuse it
6241            // rather than re-fetching `self.frames.last()`.
6242            let f = match frame_peek {
6243                CallFrame::Lua(f) => f,
6244                _ => unreachable!("Cont frame survived the dispatch loop head"),
6245            };
6246            let cl = f.closure;
6247            let base = f.base;
6248            let func_slot = f.func_slot;
6249            let n_varargs = f.n_varargs;
6250            let pc = f.pc;
6251            let oldpc = f.hook_oldpc;
6252
6253            // SAFETY: `pc` is bounded by the compiler against `proto.code.len()`
6254            // — every branch / call op only sets `pc` to a valid index, and
6255            // function entry initialises pc=0 with a non-empty body. PUC's
6256            // `vmfetch` uses the equivalent unchecked load.
6257            let inst = unsafe { *cl.proto.code.get_unchecked(pc as usize) };
6258
6259            // Trace recording append + close detection.
6260            // Gated on `trace_jit_enabled` + `active_trace.is_some()`
6261            // so default dispatch keeps a single not-taken branch.
6262            //
6263            // - At the head PC with a non-empty record, the trace has
6264            //   looped back to its start: mark `closed = true` and
6265            //   take the record for compile + cache.
6266            // - Otherwise, capture the op. If the record overflows
6267            //   MAX_TRACE_LEN, abort by dropping it.
6268            if self.jit.trace_enabled
6269                && let Some(_rec) = self.jit.active_trace.as_mut()
6270            {
6271                // Depth tracking. The trace head's frame is
6272                // at index `recording_frame_base`; every Op::Call that
6273                // pushes a new frame bumps the live depth, every
6274                // Op::Return that pops one decrements it.
6275                //
6276                // **Three clean-close conditions**:
6277                // - `at_head`: cur_depth == 0 AND about-to-execute the
6278                //   trace's head_pc on its head_proto (loop closed back
6279                //   to start). Same for loop-triggered and call-triggered
6280                //   traces, so a call-triggered trace does not close on
6281                //   the first re-entry (that would leave fib's body at 7
6282                //   depth=0 ops); it inlines up to MAX_INLINE_DEPTH
6283                //   levels before any close.
6284                // - `returned_past_head`: trace head's frame is gone
6285                //   (callee returned past it, or the call-trigger
6286                //   started a recording inside a callee that has now
6287                //   returned). Whatever ops were recorded form the
6288                //   trace body; the lowerer treats the partial trace
6289                //   the same as InlineAbort.
6290                // - `depth_cap_hit`: cur_depth > MAX_INLINE_DEPTH.
6291                //   Recording any deeper would just bloat the IR; close
6292                //   with the body we have. Lowerer's existing length
6293                //   gate + InlineAbort path handles short bodies.
6294                let returned_past_head = self.frames.len() <= self.jit.recording_frame_base;
6295                let cur_depth = if returned_past_head {
6296                    0
6297                } else {
6298                    self.frames.len() - 1 - self.jit.recording_frame_base
6299                };
6300                let depth_cap_hit = cur_depth > crate::jit::trace::MAX_INLINE_DEPTH as usize;
6301                let rec = self.jit.active_trace.as_mut().expect("just checked Some");
6302                let at_head_loop = cur_depth == 0
6303                    && !rec.ops.is_empty()
6304                    && !returned_past_head
6305                    && std::ptr::eq(cl.proto.as_ptr(), rec.head_proto.as_ptr())
6306                    && pc == rec.head_pc;
6307                // Self-link cycle catch (mirrors LuaJIT's
6308                // `check_call_unroll` at `lj_record.c:1869`). Trips when:
6309                //   1. We're about to execute the head_pc on head_proto
6310                //      at depth > 0 (we're re-entering the trace head
6311                //      from inside an inlined recursion level — UpRec).
6312                //   2. The count of ancestor frames in the recording
6313                //      window that share `head_proto` exceeds
6314                //      [`RECUNROLL_THRESHOLD`] (default 2).
6315                // For fib(N): head_pc=0, head_proto=fib. After 2 inline
6316                // recursion levels are captured, the recorder enters
6317                // the 3rd nested fib frame, sees cur_depth=3 > 2, and
6318                // trips this catch — closing with `SelfRecKind::UpRec`.
6319                // The lowerer's `TraceEnd::SelfLink` tail emits the
6320                // bump-base + branch-to-self loop body.
6321                //
6322                // TailRec vs UpRec: LJ distinguishes via
6323                // `framedepth + retdepth == 0`. luna doesn't track
6324                // retdepth separately; cur_depth == 0 with a non-empty
6325                // call chain in tail position is rare (would require
6326                // explicit Lua TCO). We use cur_depth > 0 as the UpRec
6327                // condition (fib's case); cur_depth == 0 with positive
6328                // ancestor count would route to TailRec, but luna's
6329                // recorder doesn't currently produce that shape because
6330                // tail-call elision pops the caller frame and we'd
6331                // hit `at_head_loop` instead.
6332                let self_link_trip: Option<crate::jit::trace::SelfRecKind> = {
6333                    if self.jit.self_link_enabled
6334                        && !returned_past_head
6335                        && std::ptr::eq(cl.proto.as_ptr(), rec.head_proto.as_ptr())
6336                        && pc == rec.head_pc
6337                        && cur_depth > 0
6338                    {
6339                        // Count ancestor frames sharing head_proto.
6340                        // self.frames[recording_frame_base..] currently
6341                        // includes the just-pushed frame at the top
6342                        // (the one about to execute head_pc). Ancestors
6343                        // = the slice excluding the top frame.
6344                        let head_proto_ptr = rec.head_proto.as_ptr();
6345                        let last_idx = self.frames.len() - 1;
6346                        let mut count = 0usize;
6347                        for i in self.jit.recording_frame_base..last_idx {
6348                            if let CallFrame::Lua(f) = &self.frames[i]
6349                                && std::ptr::eq(f.closure.proto.as_ptr(), head_proto_ptr)
6350                            {
6351                                count += 1;
6352                            }
6353                        }
6354                        if count > crate::jit::trace::RECUNROLL_THRESHOLD {
6355                            // cur_depth > 0 → UpRec (fib pattern).
6356                            // cur_depth == 0 wouldn't reach this arm.
6357                            Some(crate::jit::trace::SelfRecKind::UpRec)
6358                        } else {
6359                            None
6360                        }
6361                    } else {
6362                        None
6363                    }
6364                };
6365                if let Some(kind) = self_link_trip {
6366                    // SelfLink relax for self-recursive patterns at frame
6367                    // depth >= 2.
6368                    //
6369                    // Stamping `self_link_kind` unconditionally at the
6370                    // head_pc re-entry would never dispatch: the
6371                    // `downrec_close` marker can only fire from the
6372                    // depth>0 Op::Return path (`rec.retfs` chain),
6373                    // which never reaches the recorder for fib(28)-like
6374                    // shapes that hit the SelfLink cycle catch BEFORE
6375                    // any base-case Return — leaving `downrec_close`
6376                    // None and routing the trace through the safe
6377                    // `dispatchable=false` `"self-link-retf-r1"` path.
6378                    //
6379                    // So when the SelfLink trip fires AND
6380                    // `cur_depth >= 2` (the count > RECUNROLL_THRESHOLD
6381                    // gate already requires this — kept explicit as a
6382                    // safety floor), route the close through `downrec_
6383                    // close` INSTEAD of `self_link_kind`. The recorder
6384                    // synthesises the close marker from the most
6385                    // recent Op::Call at depth `cur_depth - 1`:
6386                    //   - `return_pc` = `call.pc + 1` (caller's resume
6387                    //     PC after the recursive call returns; mirror
6388                    //     of the `caller_pc` derivation at the
6389                    //     depth>0 Op::Return capture path below).
6390                    //   - `target_proto` = `call.proto` (caller's
6391                    //     proto; equals `rec.head_proto` for self-
6392                    //     recursion).
6393                    //   - `depth_delta` = `1` (the recorder always
6394                    //     unrolls one level; the Op::Return path uses
6395                    //     the same constant).
6396                    //
6397                    // The lowerer's `end_idx` picker routes through
6398                    // `TraceEnd::DownRec` ahead of the `self_link_kind`
6399                    // arm and emits the stitch-sentinel +
6400                    // caller-pc-guard scaffold. A single-candidate guard
6401                    // chain (this path produces 1 caller_pc candidate
6402                    // because `rec.retfs` is empty) keeps
6403                    // `dispatchable=false` + `"downrec-stitch-pending"`
6404                    // label (the lowerer requires
6405                    // `multi_way_candidate_count >= 2`). Net behaviour:
6406                    // trace compiles under DownRec routing; interp runs
6407                    // the recursion naturally.
6408                    //
6409                    // The `cur_depth >= 2` gate is automatically
6410                    // satisfied by the count > RECUNROLL_THRESHOLD=2
6411                    // trip condition (3 ancestor frames sharing
6412                    // head_proto implies cur_depth >= 3), kept
6413                    // explicit so a future RECUNROLL_THRESHOLD tweak
6414                    // doesn't silently flip shallow-recursion
6415                    // shapes (cur_depth == 1) onto the DownRec arm.
6416                    //
6417                    // The recorded body still uses depth-baked
6418                    // op_offsets[] addressing, so this is routing
6419                    // scaffolding and gives no speedup by itself.
6420                    let _ = kind;
6421                    let relaxed_to_downrec = cur_depth >= 2 && rec.downrec_close.is_none() && {
6422                        let caller_depth_u8 = (cur_depth - 1) as u8;
6423                        if let Some(call_op) = rec.ops.iter().rev().find(|r| {
6424                            r.inline_depth == caller_depth_u8
6425                                && matches!(r.inst.op(), crate::vm::isa::Op::Call)
6426                        }) {
6427                            rec.downrec_close = Some(crate::jit::trace::DownRecClose {
6428                                return_pc: call_op.pc + 1,
6429                                target_proto: call_op.proto,
6430                                depth_delta: 1,
6431                            });
6432                            true
6433                        } else {
6434                            false
6435                        }
6436                    };
6437                    if relaxed_to_downrec {
6438                        // Close-cause taxonomy: tag the lift so
6439                        // probes can tally the fire rate. Mirrors
6440                        // the `"downrec-restart"` bump for the
6441                        // depth>0 Op::Return path (different trip
6442                        // origin, same downstream routing). The
6443                        // existing `"self-link-retf-r1"` label still
6444                        // fires for trips that DON'T relax (no
6445                        // candidate Op::Call ancestor in rec.ops, or
6446                        // cur_depth < 2) via the lowerer's
6447                        // dispatch_off_reason mirror at the close
6448                        // handler — kept as a regression safety net.
6449                        self.jit
6450                            .counters
6451                            .bump_close_cause("selflink-yields-to-downrec");
6452                    } else {
6453                        rec.self_link_kind = Some(kind);
6454                    }
6455                }
6456                let should_close =
6457                    at_head_loop || returned_past_head || depth_cap_hit || self_link_trip.is_some();
6458                if should_close {
6459                    // Long-trace bias: a call-triggered
6460                    // recording that closed with a very short body
6461                    // (fib base case: `Lt`/`Jmp`/`Return1` = 3 ops,
6462                    // binary_trees `make(0)`: 4 ops) is pathological.
6463                    // Compiling + caching it pins `Proto.traces` to a
6464                    // trace that the length gate will refuse to
6465                    // dispatch (per `MIN_DISPATCHABLE_TRUNC_BODY_FLOOR
6466                    // = 40`), AND blocks the back-edge / longer-call
6467                    // path from re-recording the same head_pc (the
6468                    // dedup `already_cached` check below short-
6469                    // circuits). The fix: discard the short call-
6470                    // triggered recording WITHOUT caching, and bias
6471                    // the proto's `call_hot_count` back to
6472                    // `THRESHOLD - HOT_RETRY_WINDOW` so the next
6473                    // sequence of calls retries the trigger at a
6474                    // different (hopefully deeper) recursion point.
6475                    //
6476                    // Back-edge triggered traces are exempt — a
6477                    // tight numeric-for loop's body is legitimately
6478                    // 3 ops (`Add`, ForLoop) and DOES dispatch
6479                    // usefully when re-entered many times.
6480                    // Coverage heuristic to detect
6481                    // pathologically partial call-triggered traces:
6482                    // for self-recursive / branchy protos like
6483                    // `fib` (~17 bytecode ops) or
6484                    // `binary_trees.make` (~26 ops), the recorder
6485                    // can fire at a BASE-case entry (`fib(0)` or
6486                    // `make(0)`) producing a 3–4 op trace that
6487                    // covers a tiny fraction of the proto's code.
6488                    // That trace is doomed by the length gate
6489                    // post-compile AND blocks any longer follow-up
6490                    // (the dedup `already_cached` check below). The
6491                    // fix: discard call-triggered closes where
6492                    // `rec.ops.len() * 2 < head_proto.code.len()`
6493                    // (less than half the proto's bytecode), so the
6494                    // back-edge / longer call path can take over.
6495                    //
6496                    // Why coverage > raw length:protos with
6497                    // intrinsically short bodies (closure
6498                    // factories: `Closure + Return1` = 2 ops,
6499                    // simple wrappers: `LoadI + Return1` = 2 ops)
6500                    // record 100% coverage even at length 2 — those
6501                    // ARE legitimately short and the closure /
6502                    // sunk-emit lowering paths make
6503                    // them worth compiling. The heuristic admits
6504                    // them. fib's `[Lt, Jmp, Return1]` (3 of ~17)
6505                    // and make's `[Lt, Jmp, LoadI, Return1]` (4 of
6506                    // ~26) get discarded.
6507                    //
6508                    // Back-edge triggered traces are unaffected —
6509                    // a tight numeric-for body legitimately covers
6510                    // 3 of ~3 proto ops it can dispatch from
6511                    // (`Add + ForLoop`) and the recorder fires on
6512                    // the back-edge, not call entry.
6513                    //
6514                    // `call_hot_count` is intentionally NOT reset
6515                    // (an earlier draft tried `THRESHOLD - 32` but
6516                    // caused active_trace contention with the
6517                    // outer back-edge trigger — see
6518                    // setlist_b_zero_with_call_c_zero_sunk_emits).
6519                    // We give up on dispatching the pathological
6520                    // shape on the same proto; the back-edge or a
6521                    // longer call path on a deeper recursion point
6522                    // can still record + cache a real trace.
6523                    let proto_code_len = rec.head_proto.code.len();
6524                    let is_partial_coverage = rec.ops.len() * 2 < proto_code_len;
6525                    // Per-Proto discard cap. The relaxed
6526                    // trigger condition (`c >= THRESHOLD &&
6527                    // !already_cached`) means a Proto whose every
6528                    // recording is partial-coverage will re-fire the
6529                    // trigger every call indefinitely (1500+ in
6530                    // `binary_trees`-pattern test). The cap stops
6531                    // discarding after `MAX_DISCARDS_PER_PROTO` —
6532                    // the next close falls through to compile (even
6533                    // if partial), caches the trace, and the
6534                    // `already_cached` short-circuit kills the
6535                    // storm. Dispatch may still be refused
6536                    // post-compile (length gate), but the recorder
6537                    // stops churning.
6538                    const MAX_DISCARDS_PER_PROTO: u32 = 5;
6539                    let prior_discards = rec.head_proto.trace_discard_count.get();
6540                    let cap_reached = prior_discards >= MAX_DISCARDS_PER_PROTO;
6541                    // Flip the `gave_up` flag the
6542                    // moment cap is reached (BEFORE the close-
6543                    // dispatching branch below). The trigger gates
6544                    // short-circuit on this flag, skipping the
6545                    // RefCell + linear `already_cached` scan on
6546                    // every subsequent call to this Proto. Useful
6547                    // for `binary_trees_pattern`-class loads where
6548                    // a single Proto sees ~20k calls post-cap.
6549                    if cap_reached
6550                        && rec.is_call_triggered
6551                        && is_partial_coverage
6552                        && !rec.head_proto.trace_gave_up.get()
6553                    {
6554                        rec.head_proto.trace_gave_up.set(true);
6555                    }
6556                    if rec.is_call_triggered && is_partial_coverage && !cap_reached {
6557                        // Tally as closed (for visibility) but DROP
6558                        // without compile/cache. Use the existing
6559                        // closed-lens accumulator so probes can
6560                        // observe the discarded shape.
6561                        // Bump discard count BEFORE
6562                        // dropping the recording so the next
6563                        // close sees the updated counter.
6564                        rec.head_proto.trace_discard_count.set(prior_discards + 1);
6565                        self.jit.counters.closed += 1;
6566                        self.jit
6567                            .counters
6568                            .closed_lens
6569                            .push((rec.is_call_triggered, rec.ops.len()));
6570                        // Partial-coverage discard close path.
6571                        // `closed` + `closed_lens` alone can't separate
6572                        // a real successful close from a discard tally,
6573                        // so tag explicitly to keep the recorder-side
6574                        // close-cause taxonomy single-source.
6575                        self.jit
6576                            .counters
6577                            .bump_close_cause("partial-coverage-discard");
6578                        self.jit.active_trace = None;
6579                        // Continue with interp loop — don't
6580                        // fall through to compile path.
6581                        // The op at `pc` hasn't dispatched yet;
6582                        // the outer loop iteration handles it.
6583                    } else {
6584                        rec.closed = true;
6585                        // Detach the closed record, then try
6586                        // to compile it. Dedup by `head_pc`: a Proto
6587                        // already carrying a CompiledTrace for this PC
6588                        // skips recompile (the hot counter caps
6589                        // re-recording at `u32::MAX / 2` anyway, but
6590                        // explicit dedup keeps `Proto.traces` short
6591                        // for the dispatcher's linear scan).
6592                        //
6593                        // On failure we just bump the failed counter
6594                        // and drop the record.
6595                        let head_pc_val = rec.head_pc;
6596                        let closed_record = self
6597                            .jit
6598                            .active_trace
6599                            .take()
6600                            .expect("active_trace was Some this branch");
6601                        self.jit.counters.closed += 1;
6602                        self.jit
6603                            .counters
6604                            .closed_lens
6605                            .push((closed_record.is_call_triggered, closed_record.ops.len()));
6606                        // Cache the trace on the
6607                        // recorder's *head proto*, not the current
6608                        // closure's proto. For non-recursive
6609                        // call-triggered traces, close fires after
6610                        // `Return1` pops the callee frame — `cl` at
6611                        // that point is the CALLER's closure, while
6612                        // `closed_record.head_proto` is the CALLEE's
6613                        // proto (the one we actually want the trace
6614                        // to be discoverable from on the next call).
6615                        // Self-recursive fib closed via depth-cap
6616                        // mid-recursion, so `cl.proto == head_proto`
6617                        // there, but only by coincidence.
6618                        let head_proto = closed_record.head_proto;
6619                        let already_cached = head_proto
6620                            .traces
6621                            .borrow()
6622                            .iter()
6623                            .any(|t| t.head_pc == head_pc_val);
6624                        if !already_cached {
6625                            // Internal-loop = true: the trace runs in
6626                            // a native loop until a cmp side-exits, so
6627                            // the dispatcher's per-entry marshal cost
6628                            // amortizes across the whole run of
6629                            // iterations the loop's recorded direction
6630                            // stays valid. The lowerer auto-downgrades
6631                            // to one-shot for cmp-less or Call-truncating
6632                            // traces.
6633                            // Side traces MUST NOT
6634                            // internal-loop. The parent's recorded prefix
6635                            // (ops at PCs < side trace's head_pc) defines
6636                            // values for registers the child's body reads
6637                            // without re-writing each iter — e.g. for
6638                            // s12_step_b, parent's `pc=19 Add R[12] = R[1]
6639                            // + R[11]` sets R[12], and the child trace
6640                            // (head_pc=24) re-runs `pc=20 Move R[1] =
6641                            // R[12]` each iter via its outer ForLoop
6642                            // internal-loop, ALWAYS reading the stale
6643                            // entry-time R[12]. The parent's Add never
6644                            // re-runs during child's loop, so R[1] gets
6645                            // pinned to one stale value. Force one-shot
6646                            // for side traces: each parent-exit round-
6647                            // trips through dispatcher → parent's Add
6648                            // runs → side trace runs ONE iter → return.
6649                            let opts = crate::jit::trace::CompileOptions {
6650                                internal_loop: closed_record.side_trace_parent.is_none(),
6651                                pre53: self.version() <= LuaVersion::Lua53,
6652                                aot: false,
6653                            };
6654                            // Route through trace_compiler; split-borrow JitState
6655                            // so the trait method can take `&mut dyn JitStorage`.
6656                            let result = {
6657                                let version = self.version();
6658                                let jit = &mut self.jit;
6659                                jit.storage.claim(self.jit_owner_id);
6660                                let storage: &mut dyn crate::jit::JitStorage = jit.storage.as_mut();
6661                                jit.trace_compiler.try_compile_trace_for(
6662                                    storage,
6663                                    &closed_record,
6664                                    opts,
6665                                    version,
6666                                )
6667                            };
6668                            match result {
6669                                Some(mut ct) => {
6670                                    // Tally Sinkable sites
6671                                    // + actually-sunk-emit sites + materialise
6672                                    // emit sites before moving `ct` into
6673                                    // Proto.traces.
6674                                    self.jit.counters.sinkable_seen +=
6675                                        ct.sinkable_sites_seen as u64;
6676                                    self.jit.counters.accum_bufferable_seen +=
6677                                        ct.accum_bufferable_seen as u64;
6678                                    self.jit.counters.sunk_alloc += ct.sunk_alloc_seen as u64;
6679                                    self.jit.counters.materialize_emit +=
6680                                        ct.materialize_emit_count as u64;
6681                                    self.jit.counters.closure_emit += ct.closure_seen as u64;
6682                                    if ct.is_inline_abort_close {
6683                                        self.jit.counters.inline_abort += 1;
6684                                    }
6685                                    // Split tally so a
6686                                    // probe can answer the AOT
6687                                    // `accepted_with_per_exit_inline`
6688                                    // gate's question at the JIT
6689                                    // surface too: how many compiled
6690                                    // traces emitted depth>0 cmp
6691                                    // side-exits, and how many of
6692                                    // those survived all the
6693                                    // `dispatchable = false` pins
6694                                    // (`InlineAbort-gate`,
6695                                    // `self-link-retf-r1`,
6696                                    // `downrec-stitch-pending`, etc.).
6697                                    if !ct.per_exit_inline.is_empty() {
6698                                        self.jit.counters.per_exit_inline_compiled += 1;
6699                                        if ct.dispatchable {
6700                                            self.jit.counters.per_exit_inline_dispatchable += 1;
6701                                        }
6702                                    }
6703                                    if let Some(reason) = ct.dispatch_off_reason {
6704                                        self.jit.counters.dispatch_off_reasons.push(reason);
6705                                        // Mirror
6706                                        // the ordered Vec push into
6707                                        // the per-reason HashMap so
6708                                        // probes can answer "how many
6709                                        // of each dispatch_off label
6710                                        // fired" in O(1) without
6711                                        // walking the Vec. Same
6712                                        // bucket as the recorder-side
6713                                        // abort/discard tags above.
6714                                        self.jit.counters.bump_close_cause(reason);
6715                                    }
6716                                    // Count compiled traces that
6717                                    // carry a down-recursion stitch
6718                                    // link. Bumped here (not at the
6719                                    // lowerer emit site) because the
6720                                    // Vm's JitCounters live on the Vm,
6721                                    // and the lowerer doesn't have a
6722                                    // Vm handle. Read via
6723                                    // `Vm::trace_downrec_link_compiled_count`.
6724                                    if ct.downrec_link.is_some() {
6725                                        self.jit.counters.downrec_link_compiled += 1;
6726                                    }
6727                                    // Multi-way guard emit counter.
6728                                    // Bumped when the lowerer collected
6729                                    // >= 2 distinct caller_pc candidates
6730                                    // and lifted `dispatchable=true`.
6731                                    // The single-CMP shape stores
6732                                    // `1` here without bumping; non-
6733                                    // DownRec closes store `0`.
6734                                    if ct.downrec_multi_way_count >= 2 {
6735                                        self.jit.counters.multi_way_guard_emitted += 1;
6736                                    }
6737                                    // Side-trace finalisation.
6738                                    // Pin `dispatchable=false` so the
6739                                    // primary lookup `traces.find(|t|
6740                                    // t.head_pc == pc && t.dispatchable)`
6741                                    // never matches this entry — the
6742                                    // side trace is meant to be entered
6743                                    // ONLY through the parent's exit
6744                                    // indirection, not the
6745                                    // back-edge / call-trigger paths.
6746                                    // Then write the entry fn ptr into
6747                                    // the parent's `exit_side_trace_ptrs`
6748                                    // slot so the parent's IR can read it.
6749                                    if let Some((parent_proto, parent_head_pc, parent_exit_idx)) =
6750                                        closed_record.side_trace_parent
6751                                    {
6752                                        // The lowerer's own verdict: a trace it
6753                                        // compiled but would not dispatch (an
6754                                        // untyped table read, an inline abort)
6755                                        // is not safe to enter from the parent's
6756                                        // exit either — unless the only reason
6757                                        // was the length gate, which weighs
6758                                        // dispatch overhead, not soundness (the
6759                                        // lowerer records the other reasons
6760                                        // first).
6761                                        let runnable = ct.dispatchable
6762                                            || ct.dispatch_off_reason == Some("length-gate");
6763                                        ct.dispatchable = false;
6764                                        let entry_ptr = ct.entry as *const () as *const u8;
6765                                        let _side_trace_head_pc = closed_record.head_pc;
6766                                        let parent_traces = parent_proto.traces.borrow();
6767                                        if let Some(parent_ct) = parent_traces
6768                                            .iter()
6769                                            .find(|t| t.head_pc == parent_head_pc)
6770                                        {
6771                                            // Shape-match
6772                                            // gate. Find the parent's per-exit
6773                                            // tag snapshot at the wired exit
6774                                            // (inline / tag / global) and
6775                                            // check the child's entry_tags
6776                                            // match. If not, leave the cell
6777                                            // null + skip cache populate so
6778                                            // the parent IR's
6779                                            // `call_indirect` stays inert at
6780                                            // this exit (the child's
6781                                            // shape-specialised IR would
6782                                            // mis-interpret raw bits the
6783                                            // parent writes to reg_state).
6784                                            let inline_n = parent_ct.per_exit_inline.len();
6785                                            let tags_n = parent_ct.per_exit_tags.len();
6786                                            let parent_exit_tags_slice: &[
6787                                            crate::jit::trace::ExitTag
6788                                        ] = if parent_exit_idx < inline_n {
6789                                            &parent_ct.per_exit_inline
6790                                                [parent_exit_idx]
6791                                                .exit_tags
6792                                        } else if parent_exit_idx
6793                                            < inline_n + tags_n
6794                                        {
6795                                            &parent_ct.per_exit_tags
6796                                                [parent_exit_idx - inline_n]
6797                                                .1
6798                                        } else {
6799                                            &parent_ct.exit_tags
6800                                        };
6801                                            let shape_matches =
6802                                                crate::jit::trace::exit_tags_match_entry_tags(
6803                                                    &ct.entry_tags,
6804                                                    parent_exit_tags_slice,
6805                                                    &parent_ct.entry_tags,
6806                                                );
6807                                            if !shape_matches {
6808                                                self.jit.counters.side_trace_shape_mismatch += 1;
6809                                            }
6810                                            let shape_ok = runnable && shape_matches;
6811                                            // Write the child's
6812                                            // entry fn ptr to BOTH the legacy
6813                                            // `exit_side_trace_ptrs[idx]`
6814                                            // cell (read by the
6815                                            // walk_any_side_ptr_non_null tests)
6816                                            // AND the per-kind cell
6817                                            // whose heap address the parent's
6818                                            // IR baked. The IR-baked
6819                                            // cell is what the call_indirect
6820                                            // gate actually reads. Only write
6821                                            // when the shape gate passes.
6822                                            if shape_ok {
6823                                                if let Some(cell) = parent_ct
6824                                                    .exit_side_trace_ptrs
6825                                                    .get(parent_exit_idx)
6826                                                {
6827                                                    cell.set(entry_ptr);
6828                                                }
6829                                                // Compute (kind, local) for the
6830                                                // IR-baked cell. Layout follows
6831                                                // exit_hit_counts: inline first,
6832                                                // then per_exit_tags, then the
6833                                                // global tail slot.
6834                                                let (sent_kind, sent_local) = if parent_exit_idx
6835                                                    < inline_n
6836                                                {
6837                                                    parent_ct.per_exit_inline[parent_exit_idx]
6838                                                        .side_trace_ptr
6839                                                        .set(entry_ptr);
6840                                                    (
6841                                                        crate::jit::trace::SIDE_SENT_KIND_INLINE,
6842                                                        parent_exit_idx as u32,
6843                                                    )
6844                                                } else if parent_exit_idx < inline_n + tags_n {
6845                                                    let local = parent_exit_idx - inline_n;
6846                                                    if let Some(b) =
6847                                                        parent_ct.tags_side_trace_ptrs.get(local)
6848                                                    {
6849                                                        b.set(entry_ptr);
6850                                                    }
6851                                                    (
6852                                                        crate::jit::trace::SIDE_SENT_KIND_TAG,
6853                                                        local as u32,
6854                                                    )
6855                                                } else {
6856                                                    parent_ct.global_side_trace_ptr.set(entry_ptr);
6857                                                    (crate::jit::trace::SIDE_SENT_KIND_GLOBAL, 0)
6858                                                };
6859                                                self.jit.counters.side_trace_compiled += 1;
6860                                                // Flip the
6861                                                // parent's fast-path hint so
6862                                                // the dispatcher knows to do
6863                                                // the tentative decode + cell
6864                                                // check on subsequent
6865                                                // dispatches. Set once and
6866                                                // stays true (we never unwire
6867                                                // a side trace today).
6868                                                parent_ct.has_any_side_wired.set(true);
6869
6870                                                // Populate
6871                                                // the O(1) lookup cache the
6872                                                // dispatcher consults on
6873                                                // sentinel-bit-set returns.
6874                                                // Key is the encoded sentinel
6875                                                // (same encoding the IR ORs
6876                                                // into bits 56..=62 of the
6877                                                // child's i64 return).
6878                                                let sentinel =
6879                                                    crate::jit::trace::encode_side_sentinel(
6880                                                        sent_kind, sent_local,
6881                                                    );
6882                                                let predicted_idx = if std::ptr::eq(
6883                                                    parent_proto.as_ptr(),
6884                                                    head_proto.as_ptr(),
6885                                                ) {
6886                                                    parent_traces.len() as u32
6887                                                } else {
6888                                                    head_proto.traces.borrow().len() as u32
6889                                                };
6890                                                parent_ct
6891                                                    .side_trace_cache
6892                                                    .borrow_mut()
6893                                                    .insert(sentinel, predicted_idx);
6894                                            }
6895                                        }
6896                                        drop(parent_traces);
6897                                    }
6898                                    head_proto.traces.borrow_mut().push(TArc::new(ct));
6899                                    self.jit.counters.compiled += 1;
6900                                }
6901                                None => {
6902                                    self.jit.counters.compile_failed += 1;
6903                                    note_trace_compile_failure(head_proto, closed_record.head_pc);
6904                                    self.jit
6905                                        .counters
6906                                        .compile_failed_reasons
6907                                        .push(self.jit.trace_compiler.last_compile_checkpoint());
6908                                }
6909                            }
6910                        }
6911                    } // close the long-trace-bias else branch
6912                } else {
6913                    // Depth-aware push at the
6914                    // current `cur_depth`. The `depth_cap_hit` /
6915                    // `returned_past_head` early-exit is handled by
6916                    // the `should_close` branch above; reaching here
6917                    // means `cur_depth <= MAX_INLINE_DEPTH` and the
6918                    // trace head's frame is still live.
6919                    let depth_u8 = cur_depth as u8;
6920                    if depth_u8 > self.jit.max_depth_seen {
6921                        self.jit.max_depth_seen = depth_u8;
6922                    }
6923                    // Fix up a prior `Op::Call C=0` (multi-
6924                    // return / variable return count). Recorder pushed
6925                    // it with var_count=None before the call dispatched;
6926                    // now that the call has returned and we're about to
6927                    // push the next op, top reflects the actual return
6928                    // count. Snapshot top - (caller.base + call.a).
6929                    if let Some(last) = rec.ops.last_mut()
6930                        && matches!(last.inst.op(), crate::vm::isa::Op::Call)
6931                        && last.inst.c() == 0
6932                        && last.var_count.is_none()
6933                        && let Some(f) = self.frames.last().and_then(CallFrame::lua)
6934                    {
6935                        let from = f.base + last.inst.a();
6936                        if self.top >= from {
6937                            last.var_count = Some(self.top - from);
6938                        }
6939                    }
6940                    // For SetList B=0, snapshot the source
6941                    // count = top - A - 1 (mirrors Lua's `n = top - ra
6942                    // - 1` from lvm.c OP_SETLIST). Sources are
6943                    // R[A+1..top), exclusive top. For Call C=0's
6944                    // var_count (the return count = top - A inclusive),
6945                    // see the prior-op fix-up above; here we
6946                    // initialise the current Call op to None and let
6947                    // the fix-up on the next op's push populate it.
6948                    let var_count = if matches!(inst.op(), crate::vm::isa::Op::SetList)
6949                        && inst.b() == 0
6950                        && let Some(f) = self.frames.last().and_then(CallFrame::lua)
6951                    {
6952                        let from = f.base + inst.a();
6953                        if self.top > from {
6954                            Some(self.top - from - 1)
6955                        } else {
6956                            None
6957                        }
6958                    } else {
6959                        None
6960                    };
6961                    let op = crate::jit::trace::RecordedOp {
6962                        proto: cl.proto,
6963                        pc,
6964                        inst,
6965                        inline_depth: depth_u8,
6966                        var_count,
6967                    };
6968                    // Depth>0 Return0/Return1 mirrors
6969                    // LuaJIT's `IR_RETF` (lj_record.c:922+ lj_record_ret).
6970                    // Captured as a side-channel `RetfRecord` parallel to
6971                    // `ops` when `self_link_enabled` is on. The
6972                    // down-rec stitch consumes these to guard side-trace
6973                    // inlined-frame topology against the recorded shape.
6974                    // Gated on the same flag as the cycle catch so the
6975                    // ship-default path (p16 off) sees zero behavior
6976                    // change. `caller_pc` is the recorded enclosing Call's
6977                    // pc + 1 — interp's resume point after the inlined
6978                    // frame pops.
6979                    if self.jit.self_link_enabled
6980                        && depth_u8 > 0
6981                        && matches!(
6982                            inst.op(),
6983                            crate::vm::isa::Op::Return0 | crate::vm::isa::Op::Return1
6984                        )
6985                    {
6986                        let results: u8 = match inst.op() {
6987                            crate::vm::isa::Op::Return0 => 0,
6988                            crate::vm::isa::Op::Return1 => 1,
6989                            _ => 0,
6990                        };
6991                        // Most recent Op::Call recorded at the caller's
6992                        // depth (`depth_u8 - 1`) is the frame this Return
6993                        // is unwinding from. Reverse scan stops at the
6994                        // first match.
6995                        let caller_depth = depth_u8 - 1;
6996                        let caller_call = rec.ops.iter().rev().find(|r| {
6997                            r.inline_depth == caller_depth
6998                                && matches!(r.inst.op(), crate::vm::isa::Op::Call)
6999                        });
7000                        let caller_pc = caller_call.map(|r| r.pc + 1).unwrap_or(pc);
7001                        // Capture the caller's proto
7002                        // for the RetfRecord. LuaJIT `IR_RETF.op1`
7003                        // equivalent. For fib(28) the caller's proto
7004                        // equals the trace head; for future mutual
7005                        // recursion the recorded Op::Call's proto is the
7006                        // right target. Fallback to head_proto when no
7007                        // enclosing Call op was captured (mirrors
7008                        // `caller_pc`'s fallback to the Return's own pc).
7009                        let caller_proto = caller_call.map(|r| r.proto).unwrap_or(rec.head_proto);
7010                        rec.retfs.push(crate::jit::trace::RetfRecord {
7011                            from_depth: depth_u8,
7012                            to_depth: caller_depth,
7013                            results,
7014                            caller_pc,
7015                            proto: caller_proto,
7016                        });
7017                        // DownRec close trigger:
7018                        // count RetfRecords on this recording whose
7019                        // `proto` matches `caller_proto` (LuaJIT
7020                        // `check_downrec_unroll` chain filter
7021                        // `op1 == ptref`). Threshold mirrors
7022                        // RECUNROLL_THRESHOLD; first trip stamps the
7023                        // `downrec_close` marker, subsequent retfs
7024                        // keep the marker without overwrite. The
7025                        // lowerer's end_idx picker routes through
7026                        // TraceEnd::DownRec when the marker is set.
7027                        if rec.downrec_close.is_none() {
7028                            let caller_proto_ptr = caller_proto.as_ptr();
7029                            let prior_match_count = rec
7030                                .retfs
7031                                .iter()
7032                                .filter(|r| r.proto.as_ptr() == caller_proto_ptr)
7033                                .count();
7034                            // Strictly-greater-than threshold matches
7035                            // LuaJIT `count + J->tailcalled > recunroll`.
7036                            // The newly-pushed retf is already counted.
7037                            if prior_match_count > crate::jit::trace::RECUNROLL_THRESHOLD {
7038                                rec.downrec_close = Some(crate::jit::trace::DownRecClose {
7039                                    return_pc: caller_pc,
7040                                    target_proto: caller_proto,
7041                                    depth_delta: 1,
7042                                });
7043                                // Close-cause taxonomy: tag the
7044                                // restart with `"downrec-restart"`. The
7045                                // lowerer adds `"downrec-stitch-failed"` when
7046                                // the lifted back-edge falls back to
7047                                // deopt.
7048                                self.jit.counters.bump_close_cause("downrec-restart");
7049                            }
7050                        }
7051                    }
7052                    // Capture FieldIcSnapshot for the
7053                    // FIRST eligible Op::GetField site under env-gate
7054                    // LUNA_JIT_FIELD_IC=1. "Eligible" means:
7055                    //   - R[B] is Value::Table with metatable.is_none()
7056                    //   - K[C] is Value::Str
7057                    //   - The string key actually occupies a hash slot
7058                    //     (so the IC's slot_idx is a real index, not
7059                    //     a probe sentinel).
7060                    // Once captured, subsequent GetFields skip this
7061                    // logic (rec.field_ic_snapshot.is_some() short-
7062                    // circuits). Env-OFF short-circuits on the cached
7063                    // atomic check inside field_ic_enabled().
7064                    if rec.field_ic_snapshot.is_none()
7065                        && matches!(inst.op(), crate::vm::isa::Op::GetField)
7066                        && crate::jit::trace_types::field_ic_enabled()
7067                    {
7068                        let b = inst.b();
7069                        let c_idx = inst.c() as usize;
7070                        let r_b = self.stack[(base + b) as usize];
7071                        if let Value::Table(g) = r_b
7072                            && g.metatable().is_none()
7073                            && c_idx < cl.proto.consts.len()
7074                            && let Value::Str(s) = cl.proto.consts[c_idx]
7075                        {
7076                            let key = Value::Str(s);
7077                            let tbl_ref = &*g;
7078                            if let Some(slot_idx) = tbl_ref.find_node_idx(key)
7079                                && let Some(val) = tbl_ref.node_val_at(slot_idx)
7080                            {
7081                                let op_idx = rec.ops.len() as u32;
7082                                rec.field_ic_snapshot =
7083                                    Some(crate::jit::trace_types::FieldIcSnapshot {
7084                                        op_idx,
7085                                        nodes_len: tbl_ref.nodes_capacity() as u64,
7086                                        slot_idx: slot_idx as u64,
7087                                        key_ptr_bits: s.as_ptr() as u64,
7088                                        cached_val_tag: val.tag_byte(),
7089                                    });
7090                                self.jit.counters.field_ic_snapshot_captured += 1;
7091                            }
7092                        }
7093                    }
7094                    if !rec.push(op) {
7095                        // Recorder overflow (MAX_TRACE_LEN). Tag it
7096                        // explicitly under the close-cause bucket so
7097                        // probes can tally overflow vs other abort
7098                        // causes in O(1).
7099                        self.jit.active_trace = None;
7100                        self.jit.counters.aborted += 1;
7101                        self.jit.counters.bump_close_cause("trace-overflow");
7102                    }
7103                }
7104            }
7105
7106            // Trace JIT dispatcher.
7107            //
7108            // When the dispatch loop is about to execute the op at
7109            // `pc` and there's a `numeric_only` CompiledTrace cached
7110            // for that `head_pc`, marshal the live regs into an
7111            // i64 buffer, jump into the trace, and resume the
7112            // interpreter at the returned continuation PC.
7113            //
7114            // Skipped (zero overhead) when `trace_jit_enabled` is
7115            // false; the lookup is a borrow + scan over
7116            // `cl.proto.traces`, which is a `Vec` whose size is at
7117            // most one entry per back-edge per Proto in practice.
7118            //
7119            // Marshalling contract — only Int slots survive the
7120            // round-trip cleanly (the reg_state ABI is `*mut i64`
7121            // with no tag info). Any non-Int slot in the affected
7122            // window forces a skip; interp takes over for one op
7123            // and the back-edge brings us back to try again next
7124            // pass (slots that were Nil/Float at one moment can
7125            // settle to Int by the time the next back-edge fires).
7126            //
7127            // A trace that comes back with `vm.jit.pending_err`
7128            // parked is treated as a deopt: clear the err, leave
7129            // the stack as the trace wrote it, and let the
7130            // interpreter run from the same `pc`. The trace itself
7131            // is left cached — a future entry might find no
7132            // metatable in the way and succeed.
7133            // Single Rc<CompiledTrace> clone instead of per-field Rc
7134            // clones: proto.traces is Vec<Rc<CompiledTrace>>; the
7135            // dispatcher clones ONE Rc and reads fields via auto-deref.
7136            // One-shot consume of the
7137            // `suppress_downrec_admit_once` flag. Set by the
7138            // downrec post-invoke arm below when it force-deopts the
7139            // trace (caller-pc guard miss OR cycle-budget exhausted)
7140            // so the NEXT interpreter loop iteration skips the
7141            // downrec admit, lets interp run the op at `head_pc`,
7142            // advances `pc` past `head_pc`, and breaks the otherwise-
7143            // infinite admit loop. Reading + clearing here means a
7144            // single dispatch tick consumes the suppression — the
7145            // following tick re-admits naturally (with the budget
7146            // also reset by the deopt site).
7147            let downrec_admit_blocked = self.jit.suppress_downrec_admit_once;
7148            self.jit.suppress_downrec_admit_once = false;
7149            if self.jit.trace_enabled
7150                && let Some(ct) = {
7151                    let traces = cl.proto.traces.borrow();
7152                    traces
7153                        .iter()
7154                        .find(|t| {
7155                            if t.head_pc != pc {
7156                                return false;
7157                            }
7158                            let is_downrec = t.downrec_link.is_some();
7159                            // The one-shot suppress
7160                            // flag blocks any admit (primary or fallback)
7161                            // for `downrec_link`-bearing traces so the
7162                            // next interp iter can run the natural op
7163                            // at `head_pc` and advance past it. The multi-way
7164                            // `dispatchable=true` lift means the suppress
7165                            // must also cover the primary `t.dispatchable`
7166                            // arm — otherwise the lifted lookup would
7167                            // immediately re-admit after a force-deopt
7168                            // and the infinite loop returns.
7169                            if downrec_admit_blocked {
7170                                return false;
7171                            }
7172                            // Primary arm: `dispatchable=true` traces
7173                            // (lifted multi-way DownRec or normal traces).
7174                            // Fallback arm: single-CMP `dispatchable=false`
7175                            // DownRec traces (single-CMP guard kept
7176                            // pinned because the 90% miss-rate would
7177                            // make blind admit perf-negative).
7178                            t.dispatchable || is_downrec
7179                        })
7180                        .cloned()
7181                }
7182            {
7183                // Borrow Rc<[T]> fields as &Rc<[T]> instead
7184                // of cloning. The outer `ct: Rc<CompiledTrace>` is held
7185                // across the entire dispatch block so the fields outlive
7186                // all consumers.
7187                let entry_fn = ct.entry;
7188                let head_pc_val = ct.head_pc;
7189                let window_size = ct.window_size;
7190                let exit_tags = &ct.exit_tags;
7191                let per_exit_tags = &ct.per_exit_tags;
7192                let per_exit_inline = &ct.per_exit_inline;
7193                let compile_entry_tags = &ct.entry_tags;
7194                let global_tag_res_kind = ct.global_tag_res_kind;
7195                let exit_hit_counts = &ct.exit_hit_counts;
7196                let max_stack = cl.proto.max_stack as usize;
7197                let window_size_us = window_size as usize;
7198                let base_us = base as usize;
7199                // `reg_state` sized to the trace's `window_size`, which
7200                // may exceed max_stack.
7201                // Marshal-in still only writes [0..max_stack); slots
7202                // [max_stack..window_size) are zero-initialised and
7203                // filled by the trace's own GetUpval / arith.
7204                // Reuse the Vm's amortised buffers
7205                // instead of allocating fresh Vecs each dispatch.
7206                // mem::take leaves an empty placeholder we restore
7207                // at the end of the dispatch block (success +
7208                // deopt paths both fall through to the restore).
7209                let mut entry_tags: Vec<u8> = std::mem::take(&mut self.jit.entry_tags_buf);
7210                entry_tags.clear();
7211                entry_tags.reserve(max_stack);
7212                // This trace was admitted via the
7213                // `downrec_link.is_some()` arm rather than the normal
7214                // `dispatchable=true` arm. The pre-invoke path
7215                // populates a reserved saved-PC slot just past the
7216                // normal register window so the lowerer's guard load
7217                // (`reg_state[window_size]`) compares the runtime
7218                // saved caller PC against the recorded `dr_return_pc`.
7219                //
7220                // No `!ct.dispatchable` gate: when the lowerer lifts
7221                // `dispatchable = true` for multi-way guards, the
7222                // trace's body still emits the downrec sentinel shape
7223                // on return — the saved-PC slot
7224                // and post-invoke classifier must keep firing.
7225                // `downrec_link.is_some()` is the unique structural
7226                // signal that the trace closes via DownRec.
7227                let is_downrec_entry = ct.downrec_link.is_some();
7228                let mut reg_state: Vec<i64> = std::mem::take(&mut self.jit.reg_state_buf);
7229                reg_state.clear();
7230                // When admitting a downrec trace,
7231                // size the buffer to `window_size + 1` so the lowerer
7232                // can `load(I64, ..., reg_state, window_size * 8)`
7233                // for the saved caller PC guard input. The extra slot
7234                // is the LAST element so cranelift's existing
7235                // `0..window_size` accesses are unaffected.
7236                let reg_state_len = if is_downrec_entry {
7237                    window_size_us + 1
7238                } else {
7239                    window_size_us
7240                };
7241                reg_state.resize(reg_state_len, 0i64);
7242                let mut dispatch_ok = true;
7243                for i in 0..max_stack {
7244                    let v = self.stack[base_us + i];
7245                    let (tag, raw) = v.unpack();
7246                    entry_tags.push(tag);
7247                    // Entry tag guard. The trace's IR
7248                    // is specialised to the compile-time entry tags
7249                    // (via current_kinds propagation from
7250                    // from_entry_tag). A runtime tag mismatch means
7251                    // body ops would mis-interpret raw bits (e.g.
7252                    // treat a Str pointer as Int payload → garbage).
7253                    // Skip dispatch on mismatch so interp handles
7254                    // this entry shape; the trace stays cached for
7255                    // future entries that match.
7256                    if i < compile_entry_tags.len() && tag != compile_entry_tags[i] {
7257                        dispatch_ok = false;
7258                        break;
7259                    }
7260                    match tag {
7261                        // Int / Float / Table / Nil all marshal
7262                        // to raw payload cleanly; the trace's IR
7263                        // treats the 8-byte slot as an i64 (with
7264                        // f64 ops bitcasting around the boundary).
7265                        crate::runtime::value::raw::INT
7266                        | crate::runtime::value::raw::FLOAT
7267                        | crate::runtime::value::raw::TABLE
7268                        | crate::runtime::value::raw::CLOSURE
7269                        // Native iter slots (e.g.
7270                        // R[A] = ipairs_iter) are present in
7271                        // generic-for traces; the raw bits are a
7272                        // valid `*mut NativeClosure` and round-trip
7273                        // cleanly.
7274                        | crate::runtime::value::raw::NATIVE
7275                        // Str slots show up in
7276                        // string-concat traces; raw bits = `*mut
7277                        // LuaStr` (interned, GC-managed). Round-
7278                        // trips cleanly as a heap pointer.
7279                        | crate::runtime::value::raw::STR
7280                        | crate::runtime::value::raw::NIL => {
7281                            // SAFETY: Gc<T> is NonNull<T> over the GC heap; the heap is single-threaded and the pointer is live as long as it is reachable from active roots (see heap.rs:5-7).
7282                            reg_state[i] = unsafe { raw.zero as i64 };
7283                        }
7284                        _ => {
7285                            dispatch_ok = false;
7286                            break;
7287                        }
7288                    }
7289                }
7290
7291                if dispatch_ok {
7292                    debug_assert_eq!(head_pc_val, pc, "trace cache hit's head_pc != pc");
7293                    // A recording in progress cannot see what the trace runs
7294                    // natively: it would resume after the trace with ops
7295                    // missing, and could close as a loop that never ran (a
7296                    // side trace of two ops returning its own head, which
7297                    // the dispatcher then entered forever). Drop it.
7298                    if self.jit.active_trace.take().is_some() {
7299                        self.jit.counters.aborted += 1;
7300                        self.jit.counters.bump_close_cause("reached-compiled-trace");
7301                    }
7302                    self.jit.pending_err = None;
7303                    // Snapshot the pre-entry frame
7304                    // count. A cmp@d>0 side-exit calls the materialize
7305                    // helper which pushes inlined frames onto
7306                    // `vm.frames`; on deopt those frames must be popped
7307                    // before falling through to the interpreter, else
7308                    // the stack grows unboundedly per deopted dispatch.
7309                    let pre_frames = self.frames.len();
7310                    // Saved-PC slot population. The
7311                    // recorded `dr_return_pc` on the closing trace is
7312                    // the caller's resume PC captured at a depth>0
7313                    // Return push (recorder push site). The natural runtime analogue for self-
7314                    // stitch is the dispatching frame's PARENT frame's
7315                    // PC: the trace's head_pc sits inside a Lua frame,
7316                    // and the parent (caller) frame's `pc` is what
7317                    // luna would observe as `[base-8]` in the LJ
7318                    // `asm_retf` shape (`lj_asm_arm64.h:565`). When
7319                    // the parent isn't a Lua frame (top-level dispatch
7320                    // — first invocation through `call_value`), no
7321                    // saved PC exists; we write 0, which always
7322                    // mismatches the recorded `dr_return_pc != 0`
7323                    // invariant (debug-asserted in the luna-jit trace
7324                    // lowerer).
7325                    if is_downrec_entry {
7326                        let saved_pc: i64 = if pre_frames >= 2 {
7327                            match &self.frames[pre_frames - 2] {
7328                                CallFrame::Lua(parent) => parent.pc as i64,
7329                                CallFrame::Cont(_) => 0,
7330                            }
7331                        } else {
7332                            0
7333                        };
7334                        reg_state[window_size_us] = saved_pc;
7335                    }
7336                    // `LUNA_AOT_PROBE`
7337                    // diagnostic hook. The probe fires once per trace dispatch
7338                    // (regardless of JIT vs AOT origin — both go through this
7339                    // arm), letting the AOT smoke test verify mcode actually
7340                    // executed. Guarded behind `OnceLock` so the env read is
7341                    // a one-time cost per process; not gated on a particular
7342                    // counter so the smoke test gets a deterministic single-
7343                    // line `aot_trace_fired pc=N` per first dispatch.
7344                    if jit_probe_enabled() && self.jit.counters.dispatched == 0 {
7345                        eprintln!("luna-runtime-helpers: aot_trace_fired pc={head_pc_val}");
7346                    }
7347                    let continuation_pc = {
7348                        // chunk_compiler.enter
7349                        // (CraneliftBackend delegates to enter_jit;
7350                        // NullJitBackend returns an inert guard).
7351                        let vm_ptr: *mut Vm = self;
7352                        let _guard = self.jit.chunk_compiler.enter(vm_ptr, Some(cl));
7353                        // SAFETY: Gc<T> is NonNull<T> over the GC heap; the heap is single-threaded and the pointer is live as long as it is reachable from active roots (see heap.rs:5-7).
7354                        unsafe { entry_fn(reg_state.as_mut_ptr()) }
7355                    };
7356                    self.jit.counters.dispatched += 1;
7357
7358                    if self.jit.pending_err.is_some() {
7359                        self.jit.pending_err = None;
7360                        self.jit.counters.deopt += 1;
7361                        // Unwind any helper-pushed
7362                        // inlined frames before the interpreter resumes.
7363                        // Don't restore reg_state — the trace's partial
7364                        // writes are discarded; interp re-executes from
7365                        // the original `pc`.
7366                        while self.frames.len() > pre_frames {
7367                            frames_pop_sync(&mut self.frames, &mut self.frames_top);
7368                        }
7369                        if is_downrec_entry {
7370                            // pending_err observed
7371                            // mid-trace inside a downrec admit. Treat
7372                            // it as a guard miss: bump `downrec_deopt`
7373                            // and suppress the next downrec admit so
7374                            // interp can advance past `head_pc` and
7375                            // the same trace doesn't immediately re-
7376                            // fire on the next loop iteration.
7377                            self.jit.counters.downrec_deopt += 1;
7378                            self.jit.suppress_downrec_admit_once = true;
7379                        }
7380                    } else if is_downrec_entry && {
7381                        // Only enter the
7382                        // downrec classifier for returns whose shape
7383                        // matches the lowerer's `downrec_idx_opt` tail
7384                        // emit: either the stitch_blk DOWNREC sentinel
7385                        // (HIT) or the deopt_blk GLOBAL-sentinel-with-
7386                        // body==head_pc (MISS via guard fail). Any
7387                        // other return from a downrec trace (intermediate
7388                        // body cmp side-exit, GetField inference fail,
7389                        // etc.) carries a different sentinel/body shape
7390                        // and means the body exited BEFORE reaching the
7391                        // downrec close — classify those through the
7392                        // normal decode path (else branch below) so
7393                        // reg_state restores + pc advances correctly.
7394                        // Classifying them all as MISS would skip the
7395                        // normal restore, inflating `downrec_deopt` with
7396                        // non-downrec events and losing the trace's
7397                        // mid-flight writes.
7398                        let raw_ret = continuation_pc as u64;
7399                        let from_side_trace = (raw_ret >> 63) & 1 == 1;
7400                        let sentinel_code = if from_side_trace {
7401                            ((raw_ret >> 56) & 0x7F) as u32
7402                        } else {
7403                            0
7404                        };
7405                        let raw_body = raw_ret & 0x00FF_FFFF_FFFF_FFFFu64;
7406                        let global_deopt_code = crate::jit::trace_types::encode_side_sentinel(
7407                            crate::jit::trace_types::SIDE_SENT_KIND_GLOBAL,
7408                            0,
7409                        );
7410                        from_side_trace
7411                            && (crate::jit::trace_types::is_downrec_sentinel(sentinel_code)
7412                                || (sentinel_code == global_deopt_code
7413                                    && raw_body == head_pc_val as u64))
7414                    } {
7415                        // Downrec event classifier.
7416                        let raw_ret = continuation_pc as u64;
7417                        let sentinel_code = ((raw_ret >> 56) & 0x7F) as u32;
7418                        if crate::jit::trace_types::is_downrec_sentinel(sentinel_code) {
7419                            // Guard HIT — saved_pc matched one of the
7420                            // baked candidates and the trace's
7421                            // `stitch_blk` arm returned the DOWNREC
7422                            // sentinel. Cycle-safety checkpoint:
7423                            // decrement budget; on underflow,
7424                            // reclassify as deopt + reset budget.
7425                            // `STITCH_DEPTH_DEFAULT = 32` lets
7426                            // ~all natural HITs in a hot loop fire
7427                            // before reset pressure.
7428                            if self.jit.stitch_depth_remaining > 0 {
7429                                self.jit.stitch_depth_remaining -= 1;
7430                                self.jit.counters.downrec_dispatched += 1;
7431                            } else {
7432                                self.jit.counters.downrec_deopt += 1;
7433                                self.jit.stitch_depth_remaining =
7434                                    crate::vm::jit_state::JitState::STITCH_DEPTH_DEFAULT;
7435                            }
7436                        } else {
7437                            // Guard MISS via the lowerer's deopt_blk
7438                            // arm (GLOBAL sentinel + body == head_pc).
7439                            // The deopt_blk emit performs the
7440                            // store-back via `emit_store_back_and_return_pc`,
7441                            // so the live stack already reflects the
7442                            // body's writes; no extra restore needed
7443                            // from the dispatcher side.
7444                            self.jit.counters.downrec_deopt += 1;
7445                        }
7446                        self.jit.suppress_downrec_admit_once = true;
7447                        // Pop helper-pushed inlined frames (defensive —
7448                        // the downrec emit shape doesn't push frames in the
7449                        // tail, but a body side-exit before reaching
7450                        // the tail may have via the materialize helper).
7451                        while self.frames.len() > pre_frames {
7452                            frames_pop_sync(&mut self.frames, &mut self.frames_top);
7453                        }
7454                        self.jit.reg_state_buf = reg_state;
7455                        self.jit.entry_tags_buf = entry_tags;
7456                        continue;
7457                    } else {
7458                        // Restore each slot using the trace's
7459                        // exit-tag analysis (see ExitTag docs).
7460                        // Decode the IR's
7461                        // side-exit shape. Upper 32 bits = (site_idx
7462                        // + 1) for inline cmp side-exits, 0 for
7463                        // legacy clean-tail / non-inline exits.
7464                        // The decode lives in
7465                        // `crate::jit::trace::decode_exit_shape` so
7466                        // side-trace returns can reuse it with the SIDE
7467                        // TRACE's shape inputs when the sentinel bit
7468                        // is set on `raw_ret`.
7469                        let raw_ret = continuation_pc as u64;
7470                        // Side-trace return decode.
7471                        // Bit 63 of `raw_ret` is the side-trace
7472                        // marker the parent's IR OR'd in when it
7473                        // tail-called into a wired child trace.
7474                        // Bits 56..=62 carry the sentinel code (the
7475                        // cache key into the parent's
7476                        // `side_trace_cache`); bits 0..=55 are the
7477                        // child's own return value (encoded site or
7478                        // plain cont_pc) which we MUST decode using
7479                        // the CHILD's per_exit_inline / per_exit_tags
7480                        // / exit_tags / exit_hit_counts — not the
7481                        // parent's. The dispatcher snapshot read
7482                        // above holds the parent's shapes; when bit
7483                        // 63 is set we re-fetch the child's via the
7484                        // sentinel-keyed cache.
7485                        let from_side_trace = (raw_ret >> 63) & 1 == 1;
7486                        let (
7487                            decode_inline,
7488                            decode_tags,
7489                            decode_exit_tags,
7490                            decode_hit_counts,
7491                            decode_body,
7492                            child_ran,
7493                        ) = if from_side_trace {
7494                            let sentinel_code = ((raw_ret >> 56) & 0x7F) as u32;
7495                            let body = raw_ret & 0x00FF_FFFF_FFFF_FFFFu64;
7496                            let traces = cl.proto.traces.borrow();
7497                            let child_idx = traces
7498                                .iter()
7499                                .find(|t| t.head_pc == head_pc_val)
7500                                .and_then(|pct| {
7501                                    pct.side_trace_cache.borrow().get(&sentinel_code).copied()
7502                                });
7503                            if let Some(idx) = child_idx
7504                                && let Some(child) = traces.get(idx as usize)
7505                            {
7506                                if crate::jit::trace::v2c_probe_enabled() {
7507                                    eprintln!(
7508                                        "[v2c-A3-decode] sentinel={:#04x} body={:#018x} child_idx={} child.n_ops={} child.head_pc={} child.window_size={} parent.pc={} parent.window_size={} child.dispatchable={} child.inline_abort={}",
7509                                        sentinel_code,
7510                                        body,
7511                                        idx,
7512                                        child.n_ops,
7513                                        child.head_pc,
7514                                        child.window_size,
7515                                        pc,
7516                                        window_size,
7517                                        child.dispatchable,
7518                                        child.is_inline_abort_close,
7519                                    );
7520                                }
7521                                (
7522                                    child.per_exit_inline.clone(),
7523                                    child.per_exit_tags.clone(),
7524                                    child.exit_tags.clone(),
7525                                    child.exit_hit_counts.clone(),
7526                                    body,
7527                                    true,
7528                                )
7529                            } else {
7530                                if crate::jit::trace::v2c_probe_enabled() {
7531                                    eprintln!(
7532                                        "[v2c-A3-decode] sentinel={:#04x} body={:#018x} child MISS (fallback parent shapes)",
7533                                        sentinel_code, body,
7534                                    );
7535                                }
7536                                // Cache miss — fall back to parent
7537                                // shapes with the body bits. Best-
7538                                // effort; the trace_side_trace_
7539                                // shape_mismatch_count records this
7540                                // path indirectly (close-handler
7541                                // skips wiring on mismatch so we
7542                                // shouldn't reach here when shape
7543                                // gate held).
7544                                (
7545                                    per_exit_inline.clone(),
7546                                    per_exit_tags.clone(),
7547                                    exit_tags.clone(),
7548                                    exit_hit_counts.clone(),
7549                                    body,
7550                                    true,
7551                                )
7552                            }
7553                        } else {
7554                            // Dispatcher-level side-trace invocation,
7555                            // rather than an IR gate (`load + icmp +
7556                            // brif`) at every emit_store_back callsite,
7557                            // which measured as a net slowdown. The
7558                            // tentative decode + cell load always runs:
7559                            // short-circuiting it on a
7560                            // `parent_has_side` hint measured slower on
7561                            // btrees_d8 and no faster on fib_10.
7562                            {
7563                                let tentative = crate::jit::trace::decode_exit_shape(
7564                                    raw_ret,
7565                                    per_exit_inline,
7566                                    per_exit_tags,
7567                                    exit_tags,
7568                                );
7569                                let tentative_exit_idx = tentative.exit_hit_idx;
7570                                let child_invoke = {
7571                                    let traces = cl.proto.traces.borrow();
7572                                    traces.iter().find(|t| t.head_pc == head_pc_val).and_then(
7573                                        |pct| {
7574                                            let cell =
7575                                                pct.exit_side_trace_ptrs.get(tentative_exit_idx)?;
7576                                            let fn_ptr = cell.get();
7577                                            if fn_ptr.is_null() {
7578                                                return None;
7579                                            }
7580                                            traces
7581                                                .iter()
7582                                                .find(|t| {
7583                                                    t.entry as *const () as *const u8 == fn_ptr
7584                                                })
7585                                                .map(|child| {
7586                                                    (
7587                                                        child.entry,
7588                                                        child.per_exit_inline.clone(),
7589                                                        child.per_exit_tags.clone(),
7590                                                        child.exit_tags.clone(),
7591                                                        child.exit_hit_counts.clone(),
7592                                                    )
7593                                                })
7594                                        },
7595                                    )
7596                                };
7597                                if let Some((cent, cpi, cpt, cet, chc)) = child_invoke {
7598                                    let child_raw_ret = {
7599                                        // chunk_compiler.enter
7600                                        // (side-trace entry).
7601                                        let vm_ptr: *mut Vm = self;
7602                                        let _guard =
7603                                            self.jit.chunk_compiler.enter(vm_ptr, Some(cl));
7604                                        // SAFETY: Gc<T> is NonNull<T> over the GC heap; the heap is single-threaded and the pointer is live as long as it is reachable from active roots (see heap.rs:5-7).
7605                                        unsafe { cent(reg_state.as_mut_ptr()) }
7606                                    };
7607                                    (cpi, cpt, cet, chc, child_raw_ret as u64, true)
7608                                } else {
7609                                    (
7610                                        per_exit_inline.clone(),
7611                                        per_exit_tags.clone(),
7612                                        exit_tags.clone(),
7613                                        exit_hit_counts.clone(),
7614                                        raw_ret,
7615                                        false,
7616                                    )
7617                                }
7618                            }
7619                        };
7620                        let decoded = crate::jit::trace::decode_exit_shape(
7621                            decode_body,
7622                            &decode_inline,
7623                            &decode_tags,
7624                            &decode_exit_tags,
7625                        );
7626                        let site_id = decoded.site_id;
7627                        let cont_pc = decoded.cont_pc;
7628                        let exit_hit_idx = decoded.exit_hit_idx;
7629                        let exit_tags_for_pc = decoded.exit_tags_for_pc;
7630                        // When a side trace ran (tail-called by the
7631                        // parent's code or invoked here), force
7632                        // using_global_exit_tags=false so the restore
7633                        // loop takes the per-tag slow path:
7634                        // `global_tag_res_kind` classifies the parent's
7635                        // exit tags, not the child's.
7636                        let using_global_exit_tags = if child_ran {
7637                            false
7638                        } else {
7639                            decoded.using_global_exit_tags
7640                        };
7641                        // Increment the counter (saturate
7642                        // at u32::MAX to avoid wrap on long runs).
7643                        // Track whether this increment is
7644                        // the one that crossed `HOTEXIT_THRESHOLD`
7645                        // (transition: previous v < threshold, new v
7646                        // == threshold). The side-trace start is
7647                        // deferred to just before `continue;` so
7648                        // vm.stack and frame.pc are fully restored
7649                        // (the snapshot reads post-restore values).
7650                        let mut side_trace_should_start = false;
7651                        // For side-trace returns the
7652                        // counter to bump is the CHILD's (decoded
7653                        // shape lookup) — `exit_hit_idx` is into the
7654                        // decoded layout, so use the matching
7655                        // `decode_hit_counts`. For parent decode
7656                        // they're aliased (clone of the parent's
7657                        // own Rc).
7658                        if let Some(c) = decode_hit_counts.get(exit_hit_idx) {
7659                            let v = c.get();
7660                            if v < u32::MAX {
7661                                c.set(v + 1);
7662                            }
7663                            // After a side trace ran, `exit_hit_idx` is an
7664                            // exit of that child, but a side trace is
7665                            // recorded and wired as one of `head_pc_val`'s
7666                            // exits: it would replace the side trace on the
7667                            // parent's exit of that number, which resumes
7668                            // elsewhere.
7669                            if v + 1 == crate::jit::trace::HOTEXIT_THRESHOLD
7670                                && !child_ran
7671                                && self.jit.active_trace.is_none()
7672                                && self.jit.trace_enabled
7673                            {
7674                                side_trace_should_start = true;
7675                            }
7676                        }
7677                        // At an inline cmp@d>0
7678                        // side-exit, the helper has pushed N frames on
7679                        // top of the trace head's frame and
7680                        // `exit_tags_for_pc.len()` covers the full
7681                        // window (caller + each inlined frame's
7682                        // window). Slots beyond `max_stack` belong to
7683                        // an inlined frame: their `Untouched` entries
7684                        // default to Nil (no entry-tag fallback —
7685                        // marshal-in only captured caller slots) and
7686                        // we write to interp stack at `base + i` which
7687                        // mirrors `op_offsets`-derived layout.
7688                        let slot_count = exit_tags_for_pc.len();
7689                        // The helper only extends
7690                        // vm.stack up to the deepest pushed frame's
7691                        // window, but the exit_tags snapshot covers
7692                        // the trace's full `window_size` (which
7693                        // includes depth-N+1 scratch slots that the
7694                        // trace's IR may have written without a
7695                        // matching pushed frame). Extend with Nil so
7696                        // the write at the tail doesn't panic; these
7697                        // slots get overwritten by the writeback loop
7698                        // and won't leak meaningful data past the
7699                        // pushed frames' R[0..max_stack) windows.
7700                        if self.stack.len() < base_us + slot_count {
7701                            self.stack
7702                                .resize(base_us + slot_count, crate::runtime::Value::Nil);
7703                        }
7704                        // Fast-path restore loop. When
7705                        // we landed on the global `exit_tags`,
7706                        // dispatch on the compile-time
7707                        // classification: skip the loop entirely
7708                        // for `AllUntouched`, do a tag-free
7709                        // `Value::Int(...)` write per slot for
7710                        // `AllInt`, otherwise fall through to the
7711                        // general match-arm loop. site_id > 0
7712                        // (inline frame mat) and per_exit_tags
7713                        // hits always take the general path —
7714                        // their per-side-exit shapes aren't
7715                        // pre-classified yet.
7716                        // A generic-for exit whose TForCall wrote the loop
7717                        // variables to the stack with tags the trace did not
7718                        // compile for: leave those slots as they are.
7719                        let keep_tfor =
7720                            if decode_body & crate::jit::trace_types::EXIT_KEEP_TFOR_VARS != 0 {
7721                                let call = cl.proto.code[cont_pc as usize - 1];
7722                                debug_assert!(matches!(call.op(), crate::vm::isa::Op::TForCall));
7723                                let first = call.a() as usize + 4;
7724                                first..first + call.c() as usize
7725                            } else {
7726                                0..0
7727                            };
7728                        let fast_path_taken = if using_global_exit_tags && keep_tfor.is_empty() {
7729                            match global_tag_res_kind {
7730                                crate::jit::trace::TagResKind::AllUntouched => {
7731                                    // No-op: vm.stack already
7732                                    // matches the trace's post-
7733                                    // entry state for these
7734                                    // slots (entry values not
7735                                    // overridden, or already
7736                                    // spilled by helpers).
7737                                    true
7738                                }
7739                                crate::jit::trace::TagResKind::AllInt => {
7740                                    for i in 0..slot_count {
7741                                        self.stack[base_us + i] =
7742                                            crate::runtime::Value::Int(reg_state[i]);
7743                                    }
7744                                    true
7745                                }
7746                                crate::jit::trace::TagResKind::Mixed => false,
7747                            }
7748                        } else {
7749                            false
7750                        };
7751                        if !fast_path_taken {
7752                            for i in 0..slot_count {
7753                                if keep_tfor.contains(&i) {
7754                                    continue;
7755                                }
7756                                let tag = match exit_tags_for_pc[i] {
7757                                    crate::jit::trace::ExitTag::Untouched => {
7758                                        if i < max_stack {
7759                                            entry_tags[i]
7760                                        } else {
7761                                            crate::runtime::value::raw::NIL
7762                                        }
7763                                    }
7764                                    crate::jit::trace::ExitTag::Int => {
7765                                        crate::runtime::value::raw::INT
7766                                    }
7767                                    crate::jit::trace::ExitTag::Float => {
7768                                        crate::runtime::value::raw::FLOAT
7769                                    }
7770                                    crate::jit::trace::ExitTag::Table => {
7771                                        crate::runtime::value::raw::TABLE
7772                                    }
7773                                    crate::jit::trace::ExitTag::Closure => {
7774                                        crate::runtime::value::raw::CLOSURE
7775                                    }
7776                                    // Trace actively wrote Nil
7777                                    // to this slot (e.g. via Op::LoadNil).
7778                                    // Restore as Nil regardless of the entry
7779                                    // tag, since the i64 payload is 0 and
7780                                    // packing as the entry tag (e.g. INT)
7781                                    // would mis-type the slot.
7782                                    crate::jit::trace::ExitTag::Nil => {
7783                                        crate::runtime::value::raw::NIL
7784                                    }
7785                                    // Trace wrote a Str ptr
7786                                    // to this slot (LoadK Str / Move from
7787                                    // Str / Concat result). Restore as
7788                                    // Value::Str with raw bits round-
7789                                    // tripped.
7790                                    crate::jit::trace::ExitTag::Str => {
7791                                        crate::runtime::value::raw::STR
7792                                    }
7793                                };
7794                                // SAFETY: tag is from a verified slot
7795                                // (entry validated above) or pinned by
7796                                // the exit-tag analysis to INT/TABLE.
7797                                // The raw payload sits in reg_state[i].
7798                                // Stack was extended by the materialize
7799                                // helper for inline frames.
7800                                // SAFETY: Gc<T> is NonNull<T> over the GC heap; the heap is single-threaded and the pointer is live as long as it is reachable from active roots (see heap.rs:5-7).
7801                                self.stack[base_us + i] = unsafe {
7802                                    Value::pack(
7803                                        tag,
7804                                        crate::runtime::value::RawVal {
7805                                            zero: reg_state[i] as u64,
7806                                        },
7807                                    )
7808                                };
7809                            }
7810                        }
7811                        // For non-inline exits the
7812                        // helper was never called (no metas chain for
7813                        // this cont_pc), so `frames.last()` is the
7814                        // trace head's frame and we set its pc to
7815                        // cont_pc as before. For inline exits the
7816                        // helper baked the side-exit PC into the
7817                        // innermost frame's `pc` at push time
7818                        // (chain.last().pc was overridden at emit),
7819                        // so this assignment to `frames.last_mut().pc
7820                        // = cont_pc` is a redundant-but-correct
7821                        // confirmation.
7822                        let _ = &per_exit_inline; // hold the Rc alive across dispatch
7823                        // For inline side-exits the
7824                        // helper has pushed N frames on top. The trace
7825                        // head frame is at `pre_frames - 1`; set its
7826                        // pc to `head_resume_pc` so when the chain
7827                        // eventually pops back to it, interp resumes
7828                        // PAST the trace's depth-0 Op::Call instead of
7829                        // restarting from `head_pc` and re-triggering
7830                        // dispatch (infinite loop). The innermost
7831                        // (helper-pushed) frame already has its pc
7832                        // baked in at compile time, but we still
7833                        // assign `cont_pc` below for parity with the
7834                        // non-inline path (no-op).
7835                        if site_id > 0 {
7836                            let idx = (site_id - 1) as usize;
7837                            let head_resume_pc = decode_inline[idx].head_resume_pc;
7838                            if pre_frames > 0
7839                                && let CallFrame::Lua(f) = &mut self.frames[pre_frames - 1]
7840                            {
7841                                f.pc = head_resume_pc;
7842                            }
7843                        }
7844                        let frames_len_now = self.frames.len();
7845                        // SAFETY: Gc<T> is NonNull<T> over the GC heap; the heap is single-threaded and the pointer is live as long as it is reachable from active roots (see heap.rs:5-7).
7846                        match unsafe { self.frames.last_mut().unwrap_unchecked() } {
7847                            CallFrame::Lua(fmut) => {
7848                                if crate::jit::trace::v2c_probe_enabled() {
7849                                    eprintln!(
7850                                        "[v2c-set-pc] from_side={} sentinel_or_raw={:#018x} prev_pc={} new_cont_pc={} site_id={} frames.len={} pre_frames={} max_stack={}",
7851                                        from_side_trace,
7852                                        raw_ret,
7853                                        fmut.pc,
7854                                        cont_pc,
7855                                        site_id,
7856                                        frames_len_now,
7857                                        pre_frames,
7858                                        max_stack,
7859                                    );
7860                                }
7861                                fmut.pc = cont_pc;
7862                            }
7863                            _ => unreachable!("Cont frame at trace dispatch"),
7864                        }
7865                        // Deferred side-trace start. The
7866                        // increment block above flagged this exit's
7867                        // hit count crossing HOTEXIT_THRESHOLD; now
7868                        // that vm.stack is restored and frame.pc is
7869                        // settled, snapshot entry_tags from the
7870                        // resume frame's window and create the
7871                        // recorder. The recorder's first push fires
7872                        // on the next interp iteration at cont_pc.
7873                        //
7874                        // `head_proto` for the side trace = cl.proto
7875                        // (trace JIT only inlines self-recursive
7876                        // calls today, so cont_pc always lands in
7877                        // the same proto as the parent). Frame base
7878                        // is the resume frame (top of `self.frames`
7879                        // — inline-pushed frames moved this).
7880                        if side_trace_should_start {
7881                            let (resume_base, resume_proto) = match self.frames.last() {
7882                                Some(CallFrame::Lua(f)) => (f.base as usize, f.closure.proto),
7883                                _ => (base_us, cl.proto),
7884                            };
7885                            let resume_max_stack = resume_proto.max_stack as usize;
7886                            let mut side_entry_tags: Vec<u8> = Vec::with_capacity(resume_max_stack);
7887                            // Extend stack if cont_pc's frame window
7888                            // overhangs the current stack len (rare,
7889                            // but inline-pushed frame stack writes
7890                            // only covered the trace's writeback).
7891                            if self.stack.len() < resume_base + resume_max_stack {
7892                                self.stack.resize(
7893                                    resume_base + resume_max_stack,
7894                                    crate::runtime::Value::Nil,
7895                                );
7896                            }
7897                            for i in 0..resume_max_stack {
7898                                let (tag, _) = self.stack[resume_base + i].unpack();
7899                                side_entry_tags.push(tag);
7900                            }
7901                            self.jit.active_trace =
7902                                Some(Box::new(crate::jit::trace::TraceRecord::start_side_trace(
7903                                    resume_proto,
7904                                    cont_pc,
7905                                    side_entry_tags,
7906                                    cl.proto,
7907                                    head_pc_val,
7908                                    exit_hit_idx,
7909                                )));
7910                            self.jit.recording_frame_base = self.frames.len() - 1;
7911                            self.jit.counters.side_trace_started += 1;
7912                        }
7913                        // Put the dispatch buffers back
7914                        // before the `continue;` so the next
7915                        // dispatch picks up the same allocation.
7916                        self.jit.reg_state_buf = reg_state;
7917                        self.jit.entry_tags_buf = entry_tags;
7918                        continue;
7919                    }
7920                }
7921                // !dispatch_ok / deopt path / non-cont
7922                // exit also restore the buffers before falling
7923                // through to the interp.
7924                self.jit.reg_state_buf = reg_state;
7925                self.jit.entry_tags_buf = entry_tags;
7926            }
7927
7928            // PUC `vmfetch` increments savedpc BEFORE firing traceexec, so
7929            // hook code that consults `currentpc = savedpc - 1` lands on the
7930            // instruction now executing. luna mirrors that by advancing
7931            // `f.pc` to `pc + 1` before the hook block — local_at /
7932            // getinfo / line attribution all read f.pc, and the existing
7933            // `pc - 1` convention in those helpers then yields the current
7934            // instruction's pc (db.lua :696: local `A` visible at the
7935            // chunk's return line once OP_CLOSURE has advanced pc).
7936            //
7937            // Inline `top_frame_mut` for the hot path: top is guaranteed Lua
7938            // (cont frames drained above) so the and_then/Option layers are
7939            // dead weight.
7940            // SAFETY: Gc<T> is NonNull<T> over the GC heap; the heap is single-threaded and the pointer is live as long as it is reachable from active roots (see heap.rs:5-7).
7941            match unsafe { self.frames.last_mut().unwrap_unchecked() } {
7942                CallFrame::Lua(fmut) => fmut.pc = pc + 1,
7943                _ => unreachable!("Cont frame at pc bump"),
7944            }
7945
7946            // count + line hooks (PUC traceexec): before executing the
7947            // instruction. Skipped while the hook itself runs.
7948            // (Parens here are load-bearing — without them `&&` binds tighter
7949            // than `||` and the `!in_hook` guard only gates the rust-hook arm,
7950            // letting a Lua line hook recurse into itself → stack overflow
7951            // on db.lua line-hook assertions. Matches the `hook_call_with` /
7952            // `hook_return` predicate shape at lines 2245 / 2279 / 2294 / 4023.)
7953            if !self.in_hook && (self.hook.func.is_some() || self.hook.rust_func.is_some()) {
7954                let lines = &cl.proto.lines;
7955                let cur_line = if lines.is_empty() {
7956                    None
7957                } else {
7958                    Some(lines[(pc as usize).min(lines.len() - 1)] as i64)
7959                };
7960                // count hook: fire every `count_base` instructions
7961                if self.hook.count {
7962                    self.hook.count_left -= 1;
7963                    if self.hook.count_left <= 0 {
7964                        self.hook.count_left = self.hook.count_base;
7965                        // hooked function is the running Lua frame: its frame
7966                        // is on the stack, so no synthetic C level is needed.
7967                        self.run_hook(b"count", cur_line, false)?;
7968                    }
7969                }
7970                // line hook: fire on a fresh frame, a backward jump (loop), or a
7971                // change of source line.
7972                if self.hook.line {
7973                    if lines.is_empty() {
7974                        // PUC: a stripped chunk has no line info, so
7975                        // `getfuncline` returns -1. The line hook still fires
7976                        // on the first instruction of the new frame (where
7977                        // `npci <= oldpc` holds at oldpc=0), with the line
7978                        // pushed as `nil` instead of an integer (db.lua :1030
7979                        // "hook called without debug info for 1st instruction").
7980                        if oldpc == u32::MAX {
7981                            self.run_hook(b"line", None, false)?;
7982                            self.top_frame_mut().hook_oldpc = pc;
7983                        }
7984                    } else {
7985                        let newline = lines[(pc as usize).min(lines.len() - 1)];
7986                        // PUC `traceexec`: fire on frame entry (`oldpc == MAX`),
7987                        // on a backward jump (`pc < oldpc` — strict; an equal pc
7988                        // would re-fire the install-site after `oldpc = pc`),
7989                        // or when the source line changes.
7990                        let fire = oldpc == u32::MAX
7991                            || pc < oldpc
7992                            || newline != lines[(oldpc as usize).min(lines.len() - 1)];
7993                        if fire {
7994                            self.run_hook(b"line", Some(newline as i64), false)?;
7995                        }
7996                        self.top_frame_mut().hook_oldpc = pc;
7997                    }
7998                }
7999            }
8000
8001            match inst.op() {
8002                Op::Move => {
8003                    let v = self.r(base, inst.b());
8004                    self.set_r(base, inst.a(), v);
8005                }
8006                Op::LoadI => self.set_r(base, inst.a(), Value::Int(inst.sbx() as i64)),
8007                Op::LoadF => self.set_r(base, inst.a(), Value::Float(inst.sbx() as f64)),
8008                Op::LoadK => {
8009                    let v = cl.proto.consts[inst.bx() as usize];
8010                    self.set_r(base, inst.a(), v);
8011                }
8012                Op::LoadKx => {
8013                    let extra = cl.proto.code[self.pc_of_top() as usize];
8014                    self.bump_pc();
8015                    let v = cl.proto.consts[extra.ax() as usize];
8016                    self.set_r(base, inst.a(), v);
8017                }
8018                Op::LoadFalse => self.set_r(base, inst.a(), Value::Bool(false)),
8019                Op::LFalseSkip => {
8020                    self.set_r(base, inst.a(), Value::Bool(false));
8021                    self.bump_pc();
8022                }
8023                Op::LoadTrue => self.set_r(base, inst.a(), Value::Bool(true)),
8024                Op::LoadNil => {
8025                    let a = inst.a();
8026                    for i in 0..=inst.b() {
8027                        self.set_r(base, a + i, Value::Nil);
8028                    }
8029                }
8030                Op::GetUpval => {
8031                    let v = self.upval_get(cl, inst.b());
8032                    self.set_r(base, inst.a(), v);
8033                }
8034                Op::SetUpval => {
8035                    let v = self.r(base, inst.a());
8036                    self.upval_set(cl, inst.b(), v);
8037                }
8038                Op::GetTabUp => {
8039                    let t = self.upval_get(cl, inst.b());
8040                    let key = cl.proto.consts[inst.c() as usize];
8041                    self.op_index(t, key, base + inst.a())?;
8042                }
8043                Op::GetTable => {
8044                    let t = self.r(base, inst.b());
8045                    let key = self.r(base, inst.c());
8046                    self.op_index(t, key, base + inst.a())?;
8047                }
8048                Op::GetI => {
8049                    let t = self.r(base, inst.b());
8050                    self.op_index(t, Value::Int(inst.c() as i64), base + inst.a())?;
8051                }
8052                Op::GetField => {
8053                    let t = self.r(base, inst.b());
8054                    let key = cl.proto.consts[inst.c() as usize];
8055                    // Fast path: known-Str const key + no
8056                    // metatable on the table → skip `op_index` /
8057                    // `index_step`'s MAX_TAG_LOOP setup and the outer
8058                    // `Value` match. Falls through to the slow path
8059                    // when either invariant breaks (`__index`
8060                    // metamethods, non-Table receivers, non-Str keys).
8061                    if let Value::Table(tb) = t
8062                        && tb.metatable().is_none()
8063                        && let Value::Str(s) = key
8064                    {
8065                        let v = tb.get_str(s);
8066                        self.stack[(base + inst.a()) as usize] = v;
8067                    } else {
8068                        self.op_index(t, key, base + inst.a())?;
8069                    }
8070                }
8071                Op::SetTabUp => {
8072                    let t = self.upval_get(cl, inst.a());
8073                    let key = cl.proto.consts[inst.b() as usize];
8074                    let v = self.r(base, inst.c());
8075                    self.op_newindex(t, key, v)?;
8076                }
8077                Op::SetTable => {
8078                    let t = self.r(base, inst.a());
8079                    let key = self.r(base, inst.b());
8080                    let v = self.r(base, inst.c());
8081                    self.op_newindex(t, key, v)?;
8082                }
8083                Op::SetI => {
8084                    let t = self.r(base, inst.a());
8085                    let v = self.r(base, inst.c());
8086                    self.op_newindex(t, Value::Int(inst.b() as i64), v)?;
8087                }
8088                Op::SetField => {
8089                    let t = self.r(base, inst.a());
8090                    let key = cl.proto.consts[inst.b() as usize];
8091                    let v = self.r(base, inst.c());
8092                    self.op_newindex(t, key, v)?;
8093                }
8094                Op::NewTable => {
8095                    let t = self.heap.new_table();
8096                    self.set_r(base, inst.a(), Value::Table(t));
8097                    self.maybe_collect_garbage(base + inst.a() + 1);
8098                }
8099                Op::SetList => {
8100                    let a = inst.a();
8101                    let abs_a = base + a;
8102                    // only `debug.setlocal` or crafted bytecode can put a
8103                    // non-table here; PUC crashes, luna raises
8104                    let t = match self.r(base, a) {
8105                        Value::Table(t) => t,
8106                        v => return Err(self.type_err("index", v)),
8107                    };
8108                    let n = if inst.b() == 0 {
8109                        self.top - (abs_a + 1)
8110                    } else {
8111                        inst.b()
8112                    };
8113                    let offset = if inst.k() {
8114                        let extra = cl.proto.code[self.pc_of_top() as usize];
8115                        self.bump_pc();
8116                        extra.ax() as i64
8117                    } else {
8118                        inst.c() as i64
8119                    };
8120                    for i in 1..=n {
8121                        let v = self.r(base, a + i);
8122                        // SAFETY: Gc<T> is NonNull<T> over the GC heap; the heap is single-threaded and the pointer is live as long as it is reachable from active roots (see heap.rs:5-7).
8123                        if let Err(TableError::Overflow) =
8124                            unsafe { t.as_mut() }.set_int(&mut self.heap, offset + i as i64, v)
8125                        {
8126                            return Err(self.rt_err("table overflow"));
8127                        }
8128                    }
8129                    // one barrier_back covers every store this op did — PUC's
8130                    // `luaC_barrierback_` once-per-table optimisation
8131                    self.heap
8132                        .barrier_back(t.as_ptr() as *mut crate::runtime::heap::GcHeader);
8133                    // the element temps above the table are now consumed
8134                    self.maybe_collect_garbage(base + a + 1);
8135                }
8136                Op::SelfOp => {
8137                    let o = self.r(base, inst.b());
8138                    self.set_r(base, inst.a() + 1, o);
8139                    // PUC OP_SELF's C is a constant index when the k-flag is
8140                    // set; otherwise it points to a register that holds the
8141                    // (constant-loaded) key. luna's compiler falls back to the
8142                    // register form when the constant index exceeds OP_SELF's
8143                    // 8-bit C field (5.1 big.lua's `a:findfield(...)` against
8144                    // a table with 250+ string keys, where "findfield" lands
8145                    // past const #255). The exec must honour the same split.
8146                    let key = if inst.k() {
8147                        cl.proto.consts[inst.c() as usize]
8148                    } else {
8149                        self.r(base, inst.c())
8150                    };
8151                    self.op_index(o, key, base + inst.a())?;
8152                }
8153                Op::Add => self.arith_rr(inst, base, ArithOp::Add)?,
8154                Op::Sub => self.arith_rr(inst, base, ArithOp::Sub)?,
8155                Op::Mul => self.arith_rr(inst, base, ArithOp::Mul)?,
8156                Op::Mod => self.arith_rr(inst, base, ArithOp::Mod)?,
8157                Op::Pow => self.arith_rr(inst, base, ArithOp::Pow)?,
8158                Op::Div => self.arith_rr(inst, base, ArithOp::Div)?,
8159                Op::IDiv => self.arith_rr(inst, base, ArithOp::IDiv)?,
8160                Op::BAnd => self.arith_rr(inst, base, ArithOp::BAnd)?,
8161                Op::BOr => self.arith_rr(inst, base, ArithOp::BOr)?,
8162                Op::BXor => self.arith_rr(inst, base, ArithOp::BXor)?,
8163                Op::Shl => self.arith_rr(inst, base, ArithOp::Shl)?,
8164                Op::Shr => self.arith_rr(inst, base, ArithOp::Shr)?,
8165                Op::Unm => {
8166                    let v = self.r(base, inst.b());
8167                    match self.unary_operand(v) {
8168                        Some(Num::Int(i)) => {
8169                            self.set_r(base, inst.a(), Value::Int(i.wrapping_neg()))
8170                        }
8171                        Some(Num::Float(f)) => self.set_r(base, inst.a(), Value::Float(-f)),
8172                        None => {
8173                            let mm = self.get_mm(v, Mm::Unm);
8174                            if mm.is_nil() {
8175                                return Err(self.type_err("perform arithmetic on", v));
8176                            }
8177                            let dst = base + inst.a();
8178                            self.begin_meta_call(mm, &[v, v], MetaAction::Store { dst }, "unm")?;
8179                        }
8180                    }
8181                }
8182                Op::BNot => {
8183                    let v = self.r(base, inst.b());
8184                    match self.arith_operand()(v) {
8185                        Some(n) => {
8186                            let Some(i) = int_of(n) else {
8187                                return Err(self.no_int_rep_err());
8188                            };
8189                            self.set_r(base, inst.a(), Value::Int(!i));
8190                        }
8191                        None => {
8192                            let mm = self.get_mm(v, Mm::BNot);
8193                            if mm.is_nil() {
8194                                return Err(self.type_err("perform bitwise operation on", v));
8195                            }
8196                            let dst = base + inst.a();
8197                            self.begin_meta_call(mm, &[v, v], MetaAction::Store { dst }, "bnot")?;
8198                        }
8199                    }
8200                }
8201                Op::Not => {
8202                    let v = self.r(base, inst.b());
8203                    self.set_r(base, inst.a(), Value::Bool(!v.truthy()));
8204                }
8205                Op::Len => {
8206                    let v = self.r(base, inst.b());
8207                    match self.len_step(v)? {
8208                        MmOut::Done(r) => self.set_r(base, inst.a(), r),
8209                        MmOut::Mm { func, recv } => {
8210                            let dst = base + inst.a();
8211                            self.begin_meta_call(
8212                                func,
8213                                &[recv, recv],
8214                                MetaAction::Store { dst },
8215                                "len",
8216                            )?;
8217                        }
8218                        MmOut::CompareSynth { .. } => unreachable!("CompareSynth from len_step"),
8219                    }
8220                }
8221                Op::Concat => {
8222                    // right-associative fold over operands at base+a .. base+a+n,
8223                    // in place on the stack so a yielding __concat can suspend.
8224                    let a = inst.a();
8225                    let n = inst.b();
8226                    self.top = base + a + n;
8227                    self.concat_run(base + a)?;
8228                }
8229                Op::Close => {
8230                    // Yieldable: drive __close handlers through the
8231                    // interpreter loop so a coroutine.yield() inside a
8232                    // handler suspends cleanly (locals.lua block-end yield).
8233                    // `drive_close` parks the handler call at `self.top`, so
8234                    // raise `top` past this frame's full register window
8235                    // first — a goto out of a nested for-loop can fire
8236                    // OP_Close while `self.top` still sits at the inner
8237                    // body's working top, which would let `push_frame`'s
8238                    // wipe clobber the outer tbc slot before it could be
8239                    // closed (locals.lua:1219 nested-for goto regression).
8240                    self.top = self.top.max(base + cl.proto.max_stack as u32);
8241                    let _ =
8242                        self.begin_close(base + inst.a(), None, AfterClose::Block, entry_depth)?;
8243                }
8244                Op::Tbc => {
8245                    self.register_tbc(base + inst.a())?;
8246                }
8247                Op::Jmp => {
8248                    let off = inst.sj();
8249                    // Trace JIT back-edge counter. A negative
8250                    // jump offset is a loop back-edge (the only canonical
8251                    // backward jumps the compiler emits — `while`, `for`,
8252                    // `repeat`). Tick the per-Proto counter and, once it
8253                    // exceeds the threshold, start a trace recording. The
8254                    // whole block is gated on `trace_jit_enabled` so
8255                    // existing benches see one branch-not-taken and no
8256                    // counter writes.
8257                    if self.jit.trace_enabled && off < 0 {
8258                        let proto = cl.proto;
8259                        let c = proto.trace_hot_count.get();
8260                        if c < u32::MAX / 2 {
8261                            proto.trace_hot_count.set(c + 1);
8262                        }
8263                        // Relaxed back-edge trigger:
8264                        // `c >= THRESHOLD` (not `c == THRESHOLD`) so
8265                        // a missed crossing (active_trace busy with
8266                        // a call-trigger, or the recorder slot
8267                        // happened to be in use) doesn't permanently
8268                        // lock this back-edge target out. The
8269                        // `already_cached` short-circuit prevents
8270                        // duplicate recordings: once a trace is
8271                        // cached for this target, subsequent
8272                        // crossings skip the start. This pairs with
8273                        // the discard-on-partial-coverage close
8274                        // handling — when a short call-trigger is
8275                        // discarded, the back-edge can still find an
8276                        // open slot at the next iteration.
8277                        let target_pc = (pc as i32 + 1 + off as i32).max(0) as u32;
8278                        // Gave-up short-circuit. Skip
8279                        // the RefCell borrow + scan when the
8280                        // discard cap force-compiled a partial
8281                        // trace on this Proto.
8282                        let back_edge_already_cached = if proto.trace_gave_up.get() {
8283                            true
8284                        } else {
8285                            proto.traces.borrow().iter().any(|t| t.head_pc == target_pc)
8286                                || trace_head_abandoned(proto, target_pc)
8287                        };
8288                        if c >= self.jit.trace_hot_threshold
8289                            && self.jit.active_trace.is_none()
8290                            && !back_edge_already_cached
8291                        {
8292                            // Back-edge target = pc after `add_pc(off)`,
8293                            // i.e. current `pc + 1 + off` (the dispatch
8294                            // loop has already advanced f.pc to pc+1).
8295                            let target = (pc as i32 + 1 + off as i32).max(0) as u32;
8296                            // Snapshot per-slot Value tag at trace
8297                            // entry so the lowerer's kind tracker
8298                            // knows which arith path to lower
8299                            // (iadd vs fadd, etc.).
8300                            let max_stack = cl.proto.max_stack as usize;
8301                            let base_us = base as usize;
8302                            let mut entry_tags = Vec::with_capacity(max_stack);
8303                            for i in 0..max_stack {
8304                                let (tag, _) = self.stack[base_us + i].unpack();
8305                                entry_tags.push(tag);
8306                            }
8307                            self.jit.active_trace =
8308                                Some(Box::new(crate::jit::trace::TraceRecord::start(
8309                                    cl.proto, target, entry_tags, false,
8310                                )));
8311                            // Record the frame the trace
8312                            // started in. `self.frames.len() - 1`
8313                            // since we're inside the currently-running
8314                            // Lua frame's dispatch.
8315                            self.jit.recording_frame_base = self.frames.len() - 1;
8316                        }
8317                    }
8318                    self.add_pc(off);
8319                }
8320                Op::Eq => {
8321                    let l = self.r(base, inst.a());
8322                    let r = self.r(base, inst.b());
8323                    if let (Value::Int(a), Value::Int(b)) = (l, r) {
8324                        if (a == b) != inst.k() {
8325                            self.bump_pc();
8326                        }
8327                    } else {
8328                        let step = self.eq_step(l, r);
8329                        self.op_compare(step, l, r, inst.k(), "eq")?;
8330                    }
8331                }
8332                Op::EqK => {
8333                    let l = self.r(base, inst.a());
8334                    let r = cl.proto.consts[inst.b() as usize];
8335                    if let (Value::Int(a), Value::Int(b)) = (l, r) {
8336                        if (a == b) != inst.k() {
8337                            self.bump_pc();
8338                        }
8339                    } else {
8340                        let step = self.eq_step(l, r);
8341                        self.op_compare(step, l, r, inst.k(), "eq")?;
8342                    }
8343                }
8344                Op::Lt => {
8345                    let l = self.r(base, inst.a());
8346                    let r = self.r(base, inst.b());
8347                    // hot path: Int < Int — drops the MmOut + op_compare match
8348                    if let (Value::Int(a), Value::Int(b)) = (l, r) {
8349                        if (a < b) != inst.k() {
8350                            self.bump_pc();
8351                        }
8352                    } else {
8353                        let step = self.less_step(l, r, false)?;
8354                        self.op_compare(step, l, r, inst.k(), "lt")?;
8355                    }
8356                }
8357                Op::Le => {
8358                    let l = self.r(base, inst.a());
8359                    let r = self.r(base, inst.b());
8360                    if let (Value::Int(a), Value::Int(b)) = (l, r) {
8361                        if (a <= b) != inst.k() {
8362                            self.bump_pc();
8363                        }
8364                    } else {
8365                        let step = self.less_step(l, r, true)?;
8366                        self.op_compare(step, l, r, inst.k(), "le")?;
8367                    }
8368                }
8369                Op::Test => {
8370                    let cond = self.r(base, inst.a()).truthy();
8371                    self.cond_skip(cond, inst.k());
8372                }
8373                Op::TestSet => {
8374                    let v = self.r(base, inst.b());
8375                    if v.truthy() == inst.k() {
8376                        self.set_r(base, inst.a(), v);
8377                    } else {
8378                        self.bump_pc();
8379                    }
8380                }
8381                Op::Call => {
8382                    let abs = base + inst.a();
8383                    let nargs = if inst.b() == 0 {
8384                        None
8385                    } else {
8386                        Some(inst.b() - 1)
8387                    };
8388                    let wanted = inst.c() as i32 - 1;
8389                    self.begin_call(abs, nargs, wanted, false)?;
8390                }
8391                Op::TailCall => {
8392                    let fr = *self.top_frame();
8393                    let abs = base + inst.a();
8394                    let mut nargs = if inst.b() == 0 {
8395                        self.top - (abs + 1)
8396                    } else {
8397                        inst.b() - 1
8398                    };
8399                    // A tail call pops this frame before begin_call, so a
8400                    // non-callable target would lose its name/position. Report
8401                    // it now (PUC reads funcname from the still-current ci),
8402                    // while the frame is intact, for "(field 'x')"-style info.
8403                    let mut func = self.stack[abs as usize];
8404                    if !matches!(func, Value::Closure(_) | Value::Native(_))
8405                        && self.get_mm(func, Mm::Call).is_nil()
8406                    {
8407                        return Err(self.call_err(func));
8408                    }
8409                    // PUC `luaD_pretailcall` resolves a chain of `__call`
8410                    // metamethods *in place* before deciding whether to
8411                    // collapse this frame. Without that, each __call hop
8412                    // would push a fresh Lua frame and a 10000-deep
8413                    // tail-recursion through a 100-deep __call chain
8414                    // (5.4 calls.lua :172) blows up. Mirror the PUC loop:
8415                    // shift args right, install the handler at `abs`, retry.
8416                    // Chain depth limit matches the call-site `begin_call`
8417                    // version cap (5.5 calls.lua :223 — 15 max, then "too
8418                    // long"; 16th wrap fails the call). An infinite
8419                    // self-referential `__call` would otherwise spin.
8420                    let chain_cap = if self.version >= LuaVersion::Lua55 {
8421                        15
8422                    } else {
8423                        MAX_CCMT
8424                    };
8425                    let mut chain = 0u32;
8426                    while !matches!(func, Value::Closure(_) | Value::Native(_)) {
8427                        let mm = self.get_mm(func, Mm::Call);
8428                        if mm.is_nil() || self.call_mm_unusable(mm) {
8429                            return Err(self.call_err(func));
8430                        }
8431                        chain += 1;
8432                        if chain > chain_cap {
8433                            return Err(self.rt_err("'__call' chain too long"));
8434                        }
8435                        let end = (abs + 1 + nargs) as usize;
8436                        if self.stack.len() < end + 1 {
8437                            self.stack.resize(end + 1, Value::Nil);
8438                        }
8439                        for i in (0..=nargs).rev() {
8440                            self.stack[(abs + 1 + i) as usize] = self.stack[(abs + i) as usize];
8441                        }
8442                        self.stack[abs as usize] = mm;
8443                        nargs += 1;
8444                        self.top = abs + 1 + nargs;
8445                        func = mm;
8446                    }
8447                    // PUC's tail-call collapse is Lua→Lua only. A tail call to
8448                    // a C function runs the C function under the *current* Lua
8449                    // activation (no frame fold — a C frame has nothing to
8450                    // collapse into); after the C function returns, the
8451                    // calling Lua function returns those results normally.
8452                    // Mirror that: keep our Lua frame on the stack, call the
8453                    // target through `begin_call(abs, …)` as a regular call,
8454                    // and let the fallback `Op::Return` that the compiler
8455                    // emits right after `Op::TailCall` forward the results.
8456                    // 5.1 closure.lua :177's `return getfenv()` from inside
8457                    // foo needs level 1 to resolve to foo, not to the
8458                    // thread's globals fallback that happens when no Lua
8459                    // frame is on the stack.
8460                    let lua_target = matches!(func, Value::Closure(_));
8461                    if lua_target {
8462                        self.close_slots(fr.base, None)?;
8463                        for i in 0..=nargs {
8464                            self.stack[(fr.func_slot + i) as usize] =
8465                                self.stack[(abs + i) as usize];
8466                        }
8467                        // Clear the slot range that's now
8468                        // stranded by the tail-call collapse. The args
8469                        // were copied to `[fr.func_slot..fr.func_slot+
8470                        // nargs+1)`; the source slots `[abs..abs+
8471                        // nargs+1)` still hold the same `Value::Closure
8472                        // / Value::Str / ...` entries, but they're past
8473                        // the new call's window. Without this clear, a
8474                        // later GC with wider gc_top would mark stale
8475                        // pointers there (same hazard the
8476                        // finish_results slot-clear closes for the
8477                        // Op::Return path).
8478                        let new_top_lower_bound = fr.func_slot + nargs + 1;
8479                        let prev_top = (self.top as usize).min(self.stack.len());
8480                        if (new_top_lower_bound as usize) < prev_top {
8481                            for slot in &mut self.stack[new_top_lower_bound as usize..prev_top] {
8482                                *slot = Value::Nil;
8483                            }
8484                        }
8485                        // PUC `CIST_TAIL`: the new Lua activation inherits
8486                        // the popped frame's tailcalls count plus one for
8487                        // this collapse. 5.1 db.lua :372 hammers 30000
8488                        // recursive tail calls and expects to see the
8489                        // synthetic tail level for every one of them.
8490                        self.pending_tailcalls = fr.tailcalls.saturating_add(1);
8491                        self.pending_ccmt = self.frame_ccmt[self.frames.len() - 1];
8492                        frames_pop_sync(&mut self.frames, &mut self.frames_top);
8493                        if !self.begin_call(fr.func_slot, Some(nargs), fr.nresults, false)?
8494                            && self.frames.len() < entry_depth
8495                        {
8496                            // a native completed what was this function's result
8497                            return Ok(self.take_results(fr.func_slot));
8498                        }
8499                    } else {
8500                        // Native (or __call-bearing) target: regular call. The
8501                        // results land at `abs..self.top` and the next op (the
8502                        // fallback `Op::Return`) forwards them. `wanted = -1`
8503                        // because the caller will multret them through Return.
8504                        // PUC's precallC gives the C call this tail call's own
8505                        // `__call` count (5.5 extraargs); the chain was already
8506                        // resolved above, so hand it over.
8507                        self.pending_ccmt = chain as u8;
8508                        self.begin_call(abs, Some(nargs), -1, false)?;
8509                    }
8510                }
8511                Op::Return | Op::Return0 | Op::Return1 => {
8512                    let (abs_a, nret) = match inst.op() {
8513                        Op::Return0 => (base, 0),
8514                        Op::Return1 => (base + inst.a(), 1),
8515                        _ => {
8516                            let abs_a = base + inst.a();
8517                            let nret = if inst.b() == 0 {
8518                                self.top - abs_a
8519                            } else {
8520                                inst.b() - 1
8521                            };
8522                            (abs_a, nret)
8523                        }
8524                    };
8525                    // close before moving results: __close handlers run above
8526                    // the stack top, so the result region [abs_a..abs_a+nret)
8527                    // stays intact across any yields the close performs.
8528                    // Fixed-count returns may leave `self.top` below the last
8529                    // result slot (the compiler does not always re-bump it);
8530                    // raise it past the result region so `drive_close` parks
8531                    // the handler call *above* — landing at `self.top` would
8532                    // otherwise clobber a result with the handler closure.
8533                    self.top = self.top.max(abs_a + nret);
8534                    if let Some(vals) = self.begin_close(
8535                        base,
8536                        None,
8537                        AfterClose::Return {
8538                            abs_a,
8539                            nret,
8540                            from_native: false,
8541                        },
8542                        entry_depth,
8543                    )? {
8544                        return Ok(vals);
8545                    }
8546                }
8547                Op::ForPrep => self.for_prep(inst, base)?,
8548                Op::ForLoop => {
8549                    // Trace JIT back-edge counter on the
8550                    // numeric-for back-edge. ForLoop is always at
8551                    // a back-edge position (when it continues);
8552                    // for the trace recorder we treat it as the
8553                    // close-detection equivalent of `Op::Jmp` with
8554                    // negative offset. Counter only ticks when the
8555                    // back-edge will actually fire (count > 0 in
8556                    // the 5.4+ Int form, comparable predicates in
8557                    // pre-5.3 / Float). The cheap check up front
8558                    // matches the for_loop helper's branch.
8559                    if self.jit.trace_enabled {
8560                        let a = inst.a();
8561                        let pre53 = self.version() <= LuaVersion::Lua53;
8562                        let take_back_edge =
8563                            match (self.r(base, a), self.r(base, a + 1), self.r(base, a + 2)) {
8564                                (Value::Int(_), Value::Int(count), Value::Int(_)) if !pre53 => {
8565                                    count != 0
8566                                }
8567                                (Value::Int(cur), Value::Int(lim), Value::Int(st)) if pre53 => {
8568                                    let next = cur.wrapping_add(st);
8569                                    if st > 0 { next <= lim } else { next >= lim }
8570                                }
8571                                (Value::Float(cur), Value::Float(lim), Value::Float(st)) => {
8572                                    let next = cur + st;
8573                                    if st > 0.0 { next <= lim } else { next >= lim }
8574                                }
8575                                _ => false,
8576                            };
8577                        if take_back_edge {
8578                            let proto = cl.proto;
8579                            let c = proto.trace_hot_count.get();
8580                            if c < u32::MAX / 2 {
8581                                proto.trace_hot_count.set(c + 1);
8582                            }
8583                            if c == self.jit.trace_hot_threshold && self.jit.active_trace.is_none()
8584                            {
8585                                // ForLoop's back-edge target = pc
8586                                // after `add_pc(-bx)` runs from the
8587                                // already-bumped f.pc (= pc + 1).
8588                                // So target = (pc + 1) - bx.
8589                                let target = (pc as i32 + 1 - inst.bx() as i32).max(0) as u32;
8590                                let max_stack = cl.proto.max_stack as usize;
8591                                let base_us = base as usize;
8592                                let mut entry_tags = Vec::with_capacity(max_stack);
8593                                for i in 0..max_stack {
8594                                    let (tag, _) = self.stack[base_us + i].unpack();
8595                                    entry_tags.push(tag);
8596                                }
8597                                self.jit.active_trace =
8598                                    Some(Box::new(crate::jit::trace::TraceRecord::start(
8599                                        cl.proto, target, entry_tags, false,
8600                                    )));
8601                                // Record the frame the trace
8602                                // started in. The currently-running
8603                                // Lua frame is at len() - 1.
8604                                self.jit.recording_frame_base = self.frames.len() - 1;
8605                            }
8606                        }
8607                    }
8608                    self.for_loop(inst, base)?;
8609                }
8610                Op::TForPrep => {
8611                    // the 4th control slot is the iterator's closing value
8612                    self.register_tbc(base + inst.a() + 3)?;
8613                    self.add_pc(inst.bx() as i32);
8614                }
8615                Op::TForCall => {
8616                    let abs = base + inst.a();
8617                    let need = (abs + 7) as usize;
8618                    if self.stack.len() < need {
8619                        self.stack.resize(need, Value::Nil);
8620                    }
8621                    self.stack[(abs + 4) as usize] = self.stack[abs as usize];
8622                    self.stack[(abs + 5) as usize] = self.stack[(abs + 1) as usize];
8623                    self.stack[(abs + 6) as usize] = self.stack[(abs + 2) as usize];
8624                    let nvars = inst.c() as i32;
8625                    self.begin_call(abs + 4, Some(2), nvars, false)?;
8626                }
8627                Op::TForLoop => {
8628                    let a = inst.a();
8629                    let ctrl = self.r(base, a + 4);
8630                    if !ctrl.is_nil() {
8631                        // Trace JIT back-edge counter on
8632                        // generic-for back-edge. TForLoop sits at the
8633                        // tail of `for k,v in expr do ... end`; recorder
8634                        // treats it as the close-detection equivalent of
8635                        // a negative Op::Jmp. Gate on `take_back_edge`
8636                        // (= `ctrl != nil`) so empty-iter loops don't
8637                        // pollute hot_count.
8638                        if self.jit.trace_enabled {
8639                            let proto = cl.proto;
8640                            let c = proto.trace_hot_count.get();
8641                            if c < u32::MAX / 2 {
8642                                proto.trace_hot_count.set(c + 1);
8643                            }
8644                            if c == self.jit.trace_hot_threshold && self.jit.active_trace.is_none()
8645                            {
8646                                // TForLoop back-edge target = pc after
8647                                // `add_pc(-bx)` runs from the already-
8648                                // bumped f.pc (= pc + 1). So target =
8649                                // (pc + 1) - bx, normally landing on
8650                                // body_top (the op right after TForPrep).
8651                                let target = (pc as i32 + 1 - inst.bx() as i32).max(0) as u32;
8652                                let max_stack = cl.proto.max_stack as usize;
8653                                let base_us = base as usize;
8654                                let mut entry_tags = Vec::with_capacity(max_stack);
8655                                for i in 0..max_stack {
8656                                    let (tag, _) = self.stack[base_us + i].unpack();
8657                                    entry_tags.push(tag);
8658                                }
8659                                // Snapshot the iter
8660                                // fn's address if Native, so the
8661                                // lowerer can specialise ipairs into
8662                                // inline Table aget IR.
8663                                let iter_ptr =
8664                                    if let Value::Native(n) = self.stack[base_us + a as usize] {
8665                                        Some(n.f as usize)
8666                                    } else {
8667                                        None
8668                                    };
8669                                // Snapshot R[A+5]'s
8670                                // tag (= current iter's val from
8671                                // the just-fired TForCall). The
8672                                // inline aget fast_blk emits a
8673                                // runtime guard against this tag;
8674                                // mixed-tag arrays deopt rather
8675                                // than producing garbage pointers
8676                                // through the spill path.
8677                                let val_slot = base_us + (a as usize) + 5;
8678                                let val_tag = if val_slot < self.stack.len() {
8679                                    Some(self.stack[val_slot].unpack().0)
8680                                } else {
8681                                    None
8682                                };
8683                                let mut rec = crate::jit::trace::TraceRecord::start(
8684                                    cl.proto, target, entry_tags, false,
8685                                );
8686                                rec.tfor_iter_ptr = iter_ptr;
8687                                rec.tfor_val_tag = val_tag;
8688                                self.jit.active_trace = Some(Box::new(rec));
8689                                self.jit.recording_frame_base = self.frames.len() - 1;
8690                            }
8691                        }
8692                        self.set_r(base, a + 2, ctrl);
8693                        self.add_pc(-(inst.bx() as i32));
8694                    }
8695                }
8696                Op::Closure => {
8697                    let proto = cl.proto.protos[inst.bx() as usize];
8698                    let n_ups = proto.upvals.len();
8699                    // Build upvals on the stack for small
8700                    // closures, skipping the per-call Vec/Box alloc
8701                    // that closure_alloc's 10k iters pay. INLINE_UPVALS_N
8702                    // = 2 covers most Lua source (1 captured local, or
8703                    // _ENV + a single capture). Beyond that, fall back
8704                    // to a heap Vec.
8705                    use crate::runtime::function::INLINE_UPVALS_N;
8706                    let mut stack_buf: [std::mem::MaybeUninit<
8707                        Gc<crate::runtime::function::Upvalue>,
8708                    >; INLINE_UPVALS_N] = [std::mem::MaybeUninit::uninit(); INLINE_UPVALS_N];
8709                    let mut heap_buf: Vec<Gc<crate::runtime::function::Upvalue>> = Vec::new();
8710                    let use_inline = n_ups <= INLINE_UPVALS_N;
8711                    if !use_inline {
8712                        heap_buf.reserve_exact(n_ups);
8713                    }
8714                    for (i, d) in proto.upvals.iter().enumerate() {
8715                        let uv = if d.in_stack {
8716                            self.find_or_create_upval(base + d.index as u32)
8717                        } else {
8718                            cl.upvals()[d.index as usize]
8719                        };
8720                        if use_inline {
8721                            stack_buf[i] = std::mem::MaybeUninit::new(uv);
8722                        } else {
8723                            heap_buf.push(uv);
8724                        }
8725                    }
8726                    // Tiny shim around the two paths so the 5.1 _ENV
8727                    // clone + cache check below see one uniform
8728                    // `&mut [Gc<Upvalue>]`. The stack_buf slice points
8729                    // into the local frame (still valid through the
8730                    // rest of this Op::Closure handler).
8731                    let ups: &mut [Gc<crate::runtime::function::Upvalue>] = if use_inline {
8732                        // SAFETY: the first n_ups slots of stack_buf
8733                        // were initialised above; we hand out a slice
8734                        // covering exactly them.
8735                        unsafe {
8736                            std::slice::from_raw_parts_mut(
8737                                stack_buf.as_mut_ptr()
8738                                    as *mut Gc<crate::runtime::function::Upvalue>,
8739                                n_ups,
8740                            )
8741                        }
8742                    } else {
8743                        &mut heap_buf[..]
8744                    };
8745                    // PUC 5.1 had per-function environments: every Lua
8746                    // function carried its own `env` slot, snapshotted from
8747                    // the creating function's env at closure time, so a
8748                    // `setfenv` on one closure never bled into a sibling.
8749                    // luna models that by giving the 5.1 closure a *fresh*
8750                    // closed upvalue for whichever cell holds `_ENV`, seeded
8751                    // from the parent's current env value. Only that cell is
8752                    // cloned — every other upvalue keeps its open/shared
8753                    // identity (so e.g. `local function range(...) ...
8754                    // range(...) ... end` still sees its self-reference). 5.2+
8755                    // keeps the shared-upval model (and the proto cache that
8756                    // depends on it).
8757                    let v51 = self.version() <= LuaVersion::Lua51;
8758                    if v51 && proto.env_upval_idx != u8::MAX {
8759                        let i = proto.env_upval_idx as usize;
8760                        let cur = match ups[i].state() {
8761                            UpvalState::Open { slot, thread } => self.read_slot(slot, thread),
8762                            UpvalState::Closed(v) => v,
8763                        };
8764                        ups[i] = self.heap.new_upvalue(UpvalState::Closed(cur));
8765                    }
8766                    let ups_slice: &[Gc<crate::runtime::function::Upvalue>] = ups;
8767                    // PUC 5.2+ `getcached`: a Proto remembers its last LClosure
8768                    // and reuses it when every fresh-upvalue binding still
8769                    // points to the same Upvalue object as the cached one.
8770                    // That keeps `function() return outer end` repeated in a
8771                    // loop comparing equal across iterations (the captured
8772                    // outer is a shared open upvalue), while `function()
8773                    // return loop_var end` gets a fresh closure each round
8774                    // because the loop var is re-created per iteration. PUC
8775                    // 5.1 predated the cache, and the per-closure `_ENV`
8776                    // clone above would defeat it anyway, so skip it.
8777                    let nc = if v51 {
8778                        self.heap.new_closure_inline(proto, ups_slice)
8779                    } else {
8780                        let cached = proto.cache.get().filter(|c| {
8781                            c.upvals().len() == ups_slice.len()
8782                                && c.upvals()
8783                                    .iter()
8784                                    .zip(ups_slice.iter())
8785                                    .all(|(a, b)| std::ptr::eq(a.as_ptr(), b.as_ptr()))
8786                        });
8787                        match cached {
8788                            Some(c) => c,
8789                            None => {
8790                                let n = self.heap.new_closure_inline(proto, ups_slice);
8791                                proto.cache.set(Some(n));
8792                                n
8793                            }
8794                        }
8795                    };
8796                    self.set_r(base, inst.a(), Value::Closure(nc));
8797                    self.maybe_collect_garbage(base + inst.a() + 1);
8798                }
8799                Op::Vararg => {
8800                    let abs_a = base + inst.a();
8801                    let wanted = inst.c() as i32 - 1;
8802                    // A materialized named vararg lives in func_slot (its writes
8803                    // must be visible to `...`); otherwise spread the extra args
8804                    // straight off the stack at func_slot+1 .. +n_varargs.
8805                    let vt = match self.stack[func_slot as usize] {
8806                        Value::Table(t) => Some(t),
8807                        _ => None,
8808                    };
8809                    let n = match vt {
8810                        Some(t) => {
8811                            let n_key = Value::Str(self.heap.intern(b"n"));
8812                            // PUC getnumargs: a named vararg `t.n` set out of the
8813                            // integer range [0, INT_MAX/2] is rejected here
8814                            match t.get(n_key) {
8815                                Value::Int(n) if (n as u64) <= (i32::MAX as u64 / 2) => n as u32,
8816                                _ => return Err(self.rt_err("vararg table has no proper 'n'")),
8817                            }
8818                        }
8819                        None => n_varargs,
8820                    };
8821                    let count = if wanted < 0 { n } else { wanted as u32 };
8822                    // a named vararg's `n` can be set to anything up to
8823                    // INT_MAX/2; PUC's `luaD_checkstack` refuses what the
8824                    // stack cannot hold
8825                    if abs_a + count > MAX_LUA_STACK {
8826                        return Err(self.rt_err("stack overflow"));
8827                    }
8828                    let need = (abs_a + count) as usize;
8829                    if self.stack.len() < need {
8830                        self.stack.resize(need, Value::Nil);
8831                    }
8832                    for i in 0..count {
8833                        let v = if i >= n {
8834                            Value::Nil
8835                        } else if let Some(t) = vt {
8836                            t.get_int(i as i64 + 1)
8837                        } else {
8838                            self.stack[(func_slot + 1 + i) as usize]
8839                        };
8840                        self.stack[(abs_a + i) as usize] = v;
8841                    }
8842                    if wanted < 0 {
8843                        self.top = abs_a + count;
8844                    }
8845                }
8846                Op::GetVarg => {
8847                    // materialize the vararg table (PUC table.pack shape) from the
8848                    // stack varargs — used when the named vararg is written /
8849                    // escapes / is `_ENV`. It is kept BOTH in func_slot (so `...`
8850                    // sees later writes) and in the local register R[A].
8851                    let n = n_varargs;
8852                    let t = self.heap.new_table();
8853                    {
8854                        // SAFETY: Gc<T> is NonNull<T> over the GC heap; the heap is single-threaded and the pointer is live as long as it is reachable from active roots (see heap.rs:5-7).
8855                        let tm = unsafe { t.as_mut() };
8856                        for i in 0..n {
8857                            let _ = tm.set_int(
8858                                &mut self.heap,
8859                                i as i64 + 1,
8860                                self.stack[(func_slot + 1 + i) as usize],
8861                            );
8862                        }
8863                    }
8864                    let n_key = Value::Str(self.heap.intern(b"n"));
8865                    // SAFETY: Gc<T> is NonNull<T> over the GC heap; the heap is single-threaded and the pointer is live as long as it is reachable from active roots (see heap.rs:5-7).
8866                    unsafe { t.as_mut() }
8867                        .set(&mut self.heap, n_key, Value::Int(n as i64))
8868                        .expect("'n' is a valid key");
8869                    // once-per-table barrier (mirror SETLIST): t is born BLACK
8870                    // during Propagate; the bulk inserts above don't barrier.
8871                    self.heap
8872                        .barrier_back(t.as_ptr() as *mut crate::runtime::heap::GcHeader);
8873                    self.stack[func_slot as usize] = Value::Table(t);
8874                    self.set_r(base, inst.a(), Value::Table(t));
8875                }
8876                Op::VargIdx => {
8877                    // R[A] := vararg[R[C]] without allocating: integer key in
8878                    // [1,n] → that vararg, "n" → the count, else nil.
8879                    let key = self.r(base, inst.c());
8880                    let n = n_varargs;
8881                    let v = match key {
8882                        Value::Int(k) if k >= 1 && (k as u64) <= n as u64 => {
8883                            self.stack[(func_slot + k as u32) as usize]
8884                        }
8885                        Value::Float(f) if f.fract() == 0.0 && f >= 1.0 && f <= n as f64 => {
8886                            self.stack[(func_slot + f as u32) as usize]
8887                        }
8888                        Value::Str(s) if s.as_bytes() == b"n" => Value::Int(n as i64),
8889                        _ => Value::Nil,
8890                    };
8891                    self.set_r(base, inst.a(), v);
8892                }
8893                Op::ErrNNil => {
8894                    let v = self.r(base, inst.a());
8895                    if !matches!(v, Value::Nil) {
8896                        let bx = inst.bx();
8897                        let name = if bx == 0 {
8898                            "?".to_string()
8899                        } else {
8900                            match cl.proto.consts[(bx - 1) as usize] {
8901                                Value::Str(s) => String::from_utf8_lossy(s.as_bytes()).into_owned(),
8902                                _ => "?".to_string(),
8903                            }
8904                        };
8905                        return Err(self.rt_err(&format!("global '{name}' already defined")));
8906                    }
8907                }
8908                Op::ExtraArg => unreachable!("EXTRAARG executed directly"),
8909            }
8910        }
8911    }
8912
8913    #[inline(always)]
8914    fn pc_of_top(&self) -> u32 {
8915        self.top_frame().pc
8916    }
8917
8918    #[inline(always)]
8919    fn bump_pc(&mut self) {
8920        // Inline `top_frame_mut`: top is guaranteed Lua (continuation frames
8921        // drained at dispatch loop head). Avoids the and_then/lua_mut Option
8922        // layers — bump_pc fires per Jmp / cond_skip miss, so the savings add
8923        // up over `fib_28`'s ~500k jumps.
8924        // SAFETY: Gc<T> is NonNull<T> over the GC heap; the heap is single-threaded and the pointer is live as long as it is reachable from active roots (see heap.rs:5-7).
8925        match unsafe { self.frames.last_mut().unwrap_unchecked() } {
8926            CallFrame::Lua(f) => f.pc += 1,
8927            _ => unreachable!("Cont frame at bump_pc"),
8928        }
8929    }
8930
8931    #[inline(always)]
8932    fn add_pc(&mut self, d: i32) {
8933        // SAFETY: Gc<T> is NonNull<T> over the GC heap; the heap is single-threaded and the pointer is live as long as it is reachable from active roots (see heap.rs:5-7).
8934        match unsafe { self.frames.last_mut().unwrap_unchecked() } {
8935            CallFrame::Lua(f) => f.pc = (f.pc as i64 + d as i64) as u32,
8936            _ => unreachable!("Cont frame at add_pc"),
8937        }
8938    }
8939
8940    /// PUC conditional-skip convention: the JMP that follows is executed when
8941    /// `cond == k`; otherwise it is skipped.
8942    #[inline(always)]
8943    fn cond_skip(&mut self, cond: bool, k: bool) {
8944        if cond != k {
8945            self.bump_pc();
8946        }
8947    }
8948
8949    // ---- indexing (with __index/__newindex chains) ----
8950
8951    /// The `#` length operation: string byte length, `__len` if present, else
8952    /// the raw table border. Returns the raw length value (may be non-integer
8953    /// when `__len` is exotic).
8954    pub(crate) fn len_value(&mut self, v: Value) -> Result<Value, LuaError> {
8955        match self.len_step(v)? {
8956            MmOut::Done(n) => Ok(n),
8957            // PUC calls unary metamethods with the operand twice
8958            MmOut::Mm { func, recv } => self.call_mm1(func, &[recv, recv]),
8959            MmOut::CompareSynth { .. } => unreachable!("CompareSynth from len_step"),
8960        }
8961    }
8962
8963    /// Length fast path: a string's byte count or a table's raw border when no
8964    /// `__len` is present (`Done`); otherwise the `__len` metamethod (`Mm`),
8965    /// called with the operand twice. Errors for a non-table with no `__len`.
8966    fn len_step(&mut self, v: Value) -> Result<MmOut, LuaError> {
8967        match v {
8968            Value::Str(s) => Ok(MmOut::Done(Value::Int(s.len() as i64))),
8969            Value::Table(t) => {
8970                // PUC 5.1's `__len` applies to userdata only — `luaV_objlen`
8971                // there takes the raw border for a table without consulting
8972                // the metatable, so `#setmetatable({}, {__len = f})` is 0 on
8973                // 5.1 and 7 on 5.2+. Verified against stock 5.1.5 / 5.2.4.
8974                if self.version() == crate::version::LuaVersion::Lua51 {
8975                    return Ok(MmOut::Done(Value::Int(t.len())));
8976                }
8977                let mm = self.get_mm(v, Mm::Len);
8978                if mm.is_nil() {
8979                    Ok(MmOut::Done(Value::Int(t.len())))
8980                } else {
8981                    Ok(MmOut::Mm { func: mm, recv: v })
8982                }
8983            }
8984            _ => {
8985                let mm = self.get_mm(v, Mm::Len);
8986                if mm.is_nil() {
8987                    Err(self.type_err("get length of", v))
8988                } else {
8989                    Ok(MmOut::Mm { func: mm, recv: v })
8990                }
8991            }
8992        }
8993    }
8994
8995    pub(crate) fn index_value(&mut self, t: Value, key: Value) -> Result<Value, LuaError> {
8996        match self.index_step(t, key)? {
8997            MmOut::Done(v) => Ok(v),
8998            MmOut::Mm { func, recv } => self.call_mm1(func, &[recv, key]),
8999            MmOut::CompareSynth { .. } => unreachable!("CompareSynth from index_step"),
9000        }
9001    }
9002
9003    /// PUC `MAXTAGLOOP`: 100 links of `__index`/`__newindex` in 5.1/5.2,
9004    /// 2000 from 5.3.
9005    fn tag_loop_limit(&self) -> u32 {
9006        if self.version <= LuaVersion::Lua52 {
9007            100
9008        } else {
9009            MAX_TAG_LOOP
9010        }
9011    }
9012
9013    /// Resolve `t[key]` through the `__index` chain, stopping at the first raw
9014    /// hit (`Done`) or function metamethod (`Mm`). Table-valued `__index` links
9015    /// are followed inline (no yield possible); only a function link can yield.
9016    fn index_step(&mut self, t: Value, key: Value) -> Result<MmOut, LuaError> {
9017        let mut cur = t;
9018        for _ in 0..self.tag_loop_limit() {
9019            let mm = match cur {
9020                Value::Table(tb) => {
9021                    let v = tb.get(key);
9022                    if !v.is_nil() {
9023                        return Ok(MmOut::Done(v));
9024                    }
9025                    let mm = self.get_mm(cur, Mm::Index);
9026                    if mm.is_nil() {
9027                        return Ok(MmOut::Done(Value::Nil));
9028                    }
9029                    mm
9030                }
9031                v => {
9032                    let mm = self.get_mm(v, Mm::Index);
9033                    if mm.is_nil() {
9034                        return Err(self.type_err("index", v));
9035                    }
9036                    mm
9037                }
9038            };
9039            match mm {
9040                Value::Closure(_) | Value::Native(_) => {
9041                    return Ok(MmOut::Mm {
9042                        func: mm,
9043                        recv: cur,
9044                    });
9045                }
9046                next => cur = next,
9047            }
9048        }
9049        Err(self.runerror(if self.version <= LuaVersion::Lua52 {
9050            "loop in gettable"
9051        } else {
9052            "'__index' chain too long; possible loop"
9053        }))
9054    }
9055
9056    pub(crate) fn newindex_value(
9057        &mut self,
9058        t: Value,
9059        key: Value,
9060        v: Value,
9061    ) -> Result<(), LuaError> {
9062        match self.newindex_step(t, key, v)? {
9063            MmOut::Done(_) => Ok(()),
9064            MmOut::Mm { func, recv } => {
9065                self.call_value(func, &[recv, key, v])?;
9066                Ok(())
9067            }
9068            MmOut::CompareSynth { .. } => unreachable!("CompareSynth from newindex_step"),
9069        }
9070    }
9071
9072    /// Resolve `t[key] = v` through the `__newindex` chain. A raw assignment is
9073    /// performed inline (returning `Done`); only a function metamethod (`Mm`)
9074    /// needs an actual call — which the caller may run yieldably.
9075    fn newindex_step(&mut self, t: Value, key: Value, v: Value) -> Result<MmOut, LuaError> {
9076        // Read-time probe (gc-verify): a dead query key at a
9077        // WRITE site, attributed to the instruction that produced it.
9078        #[cfg(feature = "gc-verify")]
9079        if let Some(p) = match key {
9080            Value::Str(s) => Some(s.as_ptr() as usize),
9081            Value::Table(t2) => Some(t2.as_ptr() as usize),
9082            _ => None,
9083        } && crate::runtime::gc_verify_probe::is_freed(p)
9084        {
9085            let detail = match self.frames.last() {
9086                Some(CallFrame::Lua(f)) => {
9087                    let pc = f.pc as usize;
9088                    let mut w = String::new();
9089                    for q in pc.saturating_sub(6)..(pc + 2) {
9090                        if let Some(inst) = f.closure.proto.code.get(q) {
9091                            w.push_str(&format!(
9092                                "\n  [{q}] {:?} a={} b={} c={} k={}",
9093                                inst.op(),
9094                                inst.a(),
9095                                inst.b(),
9096                                inst.c(),
9097                                inst.k()
9098                            ));
9099                        }
9100                    }
9101                    format!("pc={pc} base={} gc_top={} window:{w}", f.base, self.gc_top)
9102                }
9103                _ => "non-Lua frame".into(),
9104            };
9105            panic!("[gc-verify] newindex_step QUERY key {p:#x} freed. {detail}");
9106        }
9107        let mut cur = t;
9108        for _ in 0..self.tag_loop_limit() {
9109            let mm = match cur {
9110                Value::Table(tb) => {
9111                    // Single-walk collapse — Table::try_set_existing
9112                    // fuses the prior `tb.get(key).is_nil()` gate and
9113                    // `raw_set` walk into one chain traversal when the
9114                    // key is already present with a non-nil value. The
9115                    // __newindex chain semantics are preserved by the
9116                    // identity (slot_nil ⇔ fire_newindex).
9117                    //
9118                    // SAFETY: Gc<T> is NonNull<T> over the GC heap; the
9119                    // heap is single-threaded and the pointer is live as
9120                    // long as it is reachable from active roots (see
9121                    // heap.rs:5-7). Mirrors the raw_set wrapper below.
9122                    if unsafe { tb.as_mut() }.try_set_existing(key, v) {
9123                        self.heap
9124                            .barrier_back(tb.as_ptr() as *mut crate::runtime::heap::GcHeader);
9125                        return Ok(MmOut::Done(Value::Nil));
9126                    }
9127                    let mm = self.get_mm(cur, Mm::NewIndex);
9128                    if mm.is_nil() {
9129                        self.raw_set(tb, key, v)?;
9130                        return Ok(MmOut::Done(Value::Nil));
9131                    }
9132                    mm
9133                }
9134                bad => {
9135                    let mm = self.get_mm(bad, Mm::NewIndex);
9136                    if mm.is_nil() {
9137                        return Err(self.type_err("index", bad));
9138                    }
9139                    mm
9140                }
9141            };
9142            match mm {
9143                Value::Closure(_) | Value::Native(_) => {
9144                    return Ok(MmOut::Mm {
9145                        func: mm,
9146                        recv: cur,
9147                    });
9148                }
9149                next => cur = next,
9150            }
9151        }
9152        Err(self.runerror(if self.version <= LuaVersion::Lua52 {
9153            "loop in settable"
9154        } else {
9155            "'__newindex' chain too long; possible loop"
9156        }))
9157    }
9158
9159    pub(crate) fn raw_set(&mut self, t: Gc<Table>, key: Value, v: Value) -> Result<(), LuaError> {
9160        // SAFETY: Gc<T> is NonNull<T> over the GC heap; the heap is single-threaded and the pointer is live as long as it is reachable from active roots (see heap.rs:5-7).
9161        match unsafe { t.as_mut() }.set(&mut self.heap, key, v) {
9162            Ok(()) => {
9163                self.heap
9164                    .barrier_back(t.as_ptr() as *mut crate::runtime::heap::GcHeader);
9165                Ok(())
9166            }
9167            Err(TableError::NilIndex) => Err(self.runerror("table index is nil")),
9168            Err(TableError::NanIndex) => Err(self.runerror("table index is NaN")),
9169            Err(TableError::Overflow) => Err(self.runerror("table overflow")),
9170            Err(TableError::InvalidNext) => unreachable!(),
9171        }
9172    }
9173
9174    /// Decide equality, or surface the `__eq` metamethod to call. `Done` carries
9175    /// the boolean result; `Mm` (when raw equality fails and both are tables
9176    /// with an `__eq`) carries the metamethod — called with `(l, r)`.
9177    fn eq_step(&mut self, l: Value, r: Value) -> MmOut {
9178        if l.raw_eq(r) {
9179            return MmOut::Done(Value::Bool(true));
9180        }
9181        if let (Value::Table(_), Value::Table(_)) | (Value::Userdata(_), Value::Userdata(_)) =
9182            (l, r)
9183        {
9184            // PUC 5.3+ accepts any `__eq` reachable from either operand; 5.1
9185            // and 5.2 require the two operands' metatables to expose the same
9186            // `__eq` (`get_compTM` / `get_equalTM`) — `c == d` where `d` has
9187            // no metatable falls straight back to raw inequality. events.lua
9188            // 5.1 :262 bakes this in.
9189            let mm = if self.version() <= LuaVersion::Lua52 {
9190                self.get_comp_mm(l, r, Mm::Eq)
9191            } else {
9192                let mut m = self.get_mm(l, Mm::Eq);
9193                if m.is_nil() {
9194                    m = self.get_mm(r, Mm::Eq);
9195                }
9196                m
9197            };
9198            if !mm.is_nil() {
9199                return MmOut::Mm { func: mm, recv: l };
9200            }
9201        }
9202        MmOut::Done(Value::Bool(false))
9203    }
9204
9205    // ---- arithmetic ----
9206
9207    #[inline(always)]
9208    fn arith_rr(&mut self, inst: Inst, base: u32, op: ArithOp) -> Result<(), LuaError> {
9209        let l = self.r(base, inst.b());
9210        let r = self.r(base, inst.c());
9211        // hot path: Int + Int for Add / Sub / Mul — fib_28, loop_int_1m,
9212        // binary_trees all hammer these. Skipping coerce_num + the big
9213        // arith_fast match shaves several conditional moves per op.
9214        if let (Value::Int(a), Value::Int(b)) = (l, r) {
9215            let fast = match op {
9216                ArithOp::Add => Some(Value::Int(a.wrapping_add(b))),
9217                ArithOp::Sub => Some(Value::Int(a.wrapping_sub(b))),
9218                ArithOp::Mul => Some(Value::Int(a.wrapping_mul(b))),
9219                _ => None,
9220            };
9221            if let Some(v) = fast {
9222                self.set_r(base, inst.a(), v);
9223                return Ok(());
9224            }
9225        }
9226        // hot path: Float + Float for Add / Sub / Mul / Div — math_loop_100k
9227        // and any numeric workload with non-integer accumulators benefits.
9228        if let (Value::Float(a), Value::Float(b)) = (l, r) {
9229            let fast = match op {
9230                ArithOp::Add => Some(Value::Float(a + b)),
9231                ArithOp::Sub => Some(Value::Float(a - b)),
9232                ArithOp::Mul => Some(Value::Float(a * b)),
9233                ArithOp::Div => Some(Value::Float(a / b)),
9234                _ => None,
9235            };
9236            if let Some(v) = fast {
9237                self.set_r(base, inst.a(), v);
9238                return Ok(());
9239            }
9240        }
9241        // An `Add` with k set is a 5.4+ `x - 0` (see `Op::Add`): the right
9242        // operand is the integer 0, so it adds only when the left is a number.
9243        let op = if inst.k() && op == ArithOp::Add && !matches!(l, Value::Int(_) | Value::Float(_))
9244        {
9245            ArithOp::Sub
9246        } else {
9247            op
9248        };
9249        match self.arith_fast(op, l, r)? {
9250            Some(v) => self.set_r(base, inst.a(), v),
9251            None => {
9252                let mm = self.arith_mm_func(op, l, r)?;
9253                let dst = base + inst.a();
9254                self.begin_meta_call(mm, &[l, r], MetaAction::Store { dst }, op.mm_name())?;
9255            }
9256        }
9257        Ok(())
9258    }
9259
9260    /// The number a unary `-` operand stands for: 5.4+ leaves strings to
9261    /// the string metatable, 5.3 converts them to floats (PUC `tonumber`).
9262    fn unary_operand(&self, v: Value) -> Option<Num> {
9263        let n = self.arith_operand()(v);
9264        if self.version == LuaVersion::Lua53 && matches!(v, Value::Str(_)) {
9265            n.map(|n| Num::Float(n.as_f64()))
9266        } else {
9267            n
9268        }
9269    }
9270
9271    /// How an arithmetic operand becomes a number: 5.4+ takes numbers only
9272    /// (strings go to their metatable), 5.3 converts numeric strings, and
9273    /// 5.1/5.2, which have only floats, convert them to floats (5.1 with C
9274    /// `strtod`, so `"inf"` and `"0x1p4"` count).
9275    fn arith_operand(&self) -> fn(Value) -> Option<Num> {
9276        if self.version >= LuaVersion::Lua54 {
9277            as_number
9278        } else if self.version == LuaVersion::Lua53 {
9279            coerce_num
9280        } else if self.version == LuaVersion::Lua52 {
9281            coerce_num_float
9282        } else {
9283            coerce_num_51
9284        }
9285    }
9286
9287    /// Fast path for an arithmetic/bitwise op: `Ok(Some(v))` when computed
9288    /// directly, `Ok(None)` when a metamethod is required (the caller decides
9289    /// whether to call it synchronously or yieldably).
9290    fn arith_fast(&mut self, op: ArithOp, l: Value, r: Value) -> Result<Option<Value>, LuaError> {
9291        use ArithOp::*;
9292        // 5.4 moved string->number coercion out of the VM: a string operand
9293        // goes to the string metatable's `__add` etc., and bitwise operators
9294        // have no string metamethods at all.
9295        let num = self.arith_operand();
9296        if let BAnd | BOr | BXor | Shl | Shr = op {
9297            let (Some(a), Some(b)) = (num(l), num(r)) else {
9298                return Ok(None);
9299            };
9300            let (Some(a), Some(b)) = (int_of(a), int_of(b)) else {
9301                // PUC luaG_tointerror: name the offending operand
9302                return Err(self.no_int_rep_err());
9303            };
9304            let v = match op {
9305                BAnd => a & b,
9306                BOr => a | b,
9307                BXor => a ^ b,
9308                Shl => shift_left(a, b),
9309                Shr => shift_left(a, b.wrapping_neg()),
9310                _ => unreachable!(),
9311            };
9312            return Ok(Some(Value::Int(v)));
9313        }
9314        let (Some(mut ln), Some(mut rn)) = (num(l), num(r)) else {
9315            return Ok(None);
9316        };
9317        // PUC 5.3 takes the integer path only when both operands are
9318        // integers (`ttisinteger`); a converted string goes through
9319        // `tonumber`, which yields a float.
9320        if self.version == LuaVersion::Lua53
9321            && (matches!(l, Value::Str(_)) || matches!(r, Value::Str(_)))
9322        {
9323            ln = Num::Float(ln.as_f64());
9324            rn = Num::Float(rn.as_f64());
9325        }
9326        match arith_num(self.version, op, ln, rn) {
9327            Ok(v) => Ok(Some(v)),
9328            Err(msg) => Err(self.runerror(msg)),
9329        }
9330    }
9331
9332    /// Find the arithmetic/bitwise metamethod (left operand first), or raise the
9333    /// PUC type error when neither operand provides one.
9334    fn arith_mm_func(&mut self, op: ArithOp, l: Value, r: Value) -> Result<Value, LuaError> {
9335        use ArithOp::*;
9336        let event = match op {
9337            Add => Mm::Add,
9338            Sub => Mm::Sub,
9339            Mul => Mm::Mul,
9340            Div => Mm::Div,
9341            Mod => Mm::Mod,
9342            Pow => Mm::Pow,
9343            IDiv => Mm::IDiv,
9344            BAnd => Mm::BAnd,
9345            BOr => Mm::BOr,
9346            BXor => Mm::BXor,
9347            Shl => Mm::Shl,
9348            Shr => Mm::Shr,
9349        };
9350        let mut mm = self.get_mm(l, event);
9351        if mm.is_nil() {
9352            mm = self.get_mm(r, event);
9353        }
9354        if mm.is_nil() {
9355            let what = if matches!(op, BAnd | BOr | BXor | Shl | Shr) {
9356                "perform bitwise operation on"
9357            } else {
9358                "perform arithmetic on"
9359            };
9360            // luaG_opinterror blames the first operand that is not a number;
9361            // before 5.4 a numeric string counts as one.
9362            let bad = if self.arith_operand()(l).is_none() {
9363                l
9364            } else {
9365                r
9366            };
9367            return Err(self.type_err(what, bad));
9368        }
9369        Ok(mm)
9370    }
9371
9372    // ---- comparison ----
9373
9374    /// `lua_compare(L, a, b, LUA_OPEQ)`: equality including `__eq`.
9375    pub(crate) fn equal(&mut self, l: Value, r: Value) -> Result<bool, LuaError> {
9376        match self.eq_step(l, r) {
9377            MmOut::Done(v) => Ok(v.truthy()),
9378            MmOut::Mm { func, .. } => Ok(self.call_mm1(func, &[l, r])?.truthy()),
9379            MmOut::CompareSynth { .. } => unreachable!("CompareSynth from eq_step"),
9380        }
9381    }
9382
9383    pub(crate) fn less_than(&mut self, l: Value, r: Value, or_eq: bool) -> Result<bool, LuaError> {
9384        match self.less_step(l, r, or_eq)? {
9385            MmOut::Done(v) => Ok(v.truthy()),
9386            MmOut::Mm { func, .. } => Ok(self.call_mm1(func, &[l, r])?.truthy()),
9387            MmOut::CompareSynth { func } => {
9388                // ≤5.3 `__le` via `not __lt(r, l)`. Synchronous helper used
9389                // by library code (sort comparator etc.) — no yield expected
9390                // here (a yield would have hit `call_noyield`'s C boundary).
9391                Ok(!self.call_mm1(func, &[r, l])?.truthy())
9392            }
9393        }
9394    }
9395
9396    /// Decide `l < r` / `l <= r`, or surface the `__lt`/`__le` metamethod. `Done`
9397    /// carries the boolean result; `Mm` (for non-number/string operands) carries
9398    /// the metamethod — called with `(l, r)`; raises the PUC compare error when
9399    /// neither operand provides one.
9400    fn less_step(&mut self, l: Value, r: Value, or_eq: bool) -> Result<MmOut, LuaError> {
9401        let b = match (l, r) {
9402            (Value::Int(a), Value::Int(b)) => {
9403                if or_eq {
9404                    a <= b
9405                } else {
9406                    a < b
9407                }
9408            }
9409            (Value::Float(a), Value::Float(b)) => {
9410                if or_eq {
9411                    a <= b
9412                } else {
9413                    a < b
9414                }
9415            }
9416            (Value::Int(a), Value::Float(b)) => {
9417                if or_eq {
9418                    int_le_float(a, b)
9419                } else {
9420                    int_lt_float(a, b)
9421                }
9422            }
9423            (Value::Float(a), Value::Int(b)) => {
9424                if a.is_nan() {
9425                    false
9426                } else if or_eq {
9427                    !int_lt_float(b, a)
9428                } else {
9429                    !int_le_float(b, a)
9430                }
9431            }
9432            (Value::Str(a), Value::Str(b)) => {
9433                let (a, b) = (a.as_bytes(), b.as_bytes());
9434                if or_eq { a <= b } else { a < b }
9435            }
9436            (l, r) => {
9437                let event = if or_eq { Mm::Le } else { Mm::Lt };
9438                // PUC 5.1's `get_compTM` rule applies to ordered comparisons
9439                // too: both operands' metatables must expose the same
9440                // implementation for `__lt` / `__le` to fire. events.lua 5.1
9441                // :262 expects `c < d` (where `d` has no metatable) to error
9442                // with the default "attempt to compare two table values"
9443                // rather than running c's `__lt` blindly.
9444                let mm = if self.version() <= LuaVersion::Lua51 {
9445                    self.get_comp_mm(l, r, event)
9446                } else {
9447                    let mut m = self.get_mm(l, event);
9448                    if m.is_nil() {
9449                        m = self.get_mm(r, event);
9450                    }
9451                    m
9452                };
9453                // PUC ≤5.4: `a <= b` falls back to `not (b < a)` when neither
9454                // operand carries `__le` (5.4 through its default build's
9455                // LUA_COMPAT_LT_LE); 5.5 requires an explicit `__le`.
9456                // events.lua 5.2/5.3 :172 relies on the synthesis — its
9457                // metatable defines only `__lt`. The `__lt` is looked up as
9458                // for `b < a`: on `b` first (5.1: the same one on both). The
9459                // fallback calls `__lt(r, l)` synchronously (the suite's
9460                // `__lt` doesn't yield) and negates the result; the yieldable
9461                // `__lt` path stays reserved for the explicit `<` operator.
9462                if mm.is_nil() && or_eq && self.version < LuaVersion::Lua55 {
9463                    let mm_lt = if self.version <= LuaVersion::Lua51 {
9464                        self.get_comp_mm(r, l, Mm::Lt)
9465                    } else {
9466                        let m = self.get_mm(r, Mm::Lt);
9467                        if m.is_nil() {
9468                            self.get_mm(l, Mm::Lt)
9469                        } else {
9470                            m
9471                        }
9472                    };
9473                    if !mm_lt.is_nil() {
9474                        return Ok(MmOut::CompareSynth { func: mm_lt });
9475                    }
9476                }
9477                if mm.is_nil() {
9478                    // PUC luaG_ordererror: "two X values" when the operand
9479                    // types match, "X with Y" otherwise (objtypename-aware).
9480                    let (t1, t2) = (self.obj_typename(l), self.obj_typename(r));
9481                    return Err(self.runerror(&if t1 == t2 {
9482                        format!("attempt to compare two {t1} values")
9483                    } else {
9484                        format!("attempt to compare {t1} with {t2}")
9485                    }));
9486                }
9487                return Ok(MmOut::Mm { func: mm, recv: l });
9488            }
9489        };
9490        Ok(MmOut::Done(Value::Bool(b)))
9491    }
9492
9493    // ---- numeric for ----
9494
9495    /// Check and convert a numeric for's control values the way the
9496    /// dialect's `OP_FORPREP` does. The integer loop is chosen by the
9497    /// values' tags (a numeric string makes it a float loop, 5.3+); the
9498    /// check order, wording and the zero-step error differ per version:
9499    /// 5.1/5.2 test initial value, limit, step; 5.3+ limit, step, initial
9500    /// value; only 5.4+ reject a zero step, and an integer loop does that
9501    /// before looking at the limit.
9502    fn for_operands(&mut self, base: u32, a: u32) -> Result<(Num, Num, Num), LuaError> {
9503        let (init, limit, step) = (self.r(base, a), self.r(base, a + 1), self.r(base, a + 2));
9504        let v = self.version();
9505        let order = if v <= LuaVersion::Lua52 {
9506            [("initial value", init), ("limit", limit), ("step", step)]
9507        } else {
9508            [("limit", limit), ("step", step), ("initial value", init)]
9509        };
9510        if v >= LuaVersion::Lua54 && matches!((init, step), (Value::Int(_), Value::Int(0))) {
9511            return Err(self.rt_err("'for' step is zero"));
9512        }
9513        for (what, val) in order {
9514            if as_num(val, v).is_none() {
9515                return Err(self.rt_err(&if v >= LuaVersion::Lua54 {
9516                    format!(
9517                        "bad 'for' {what} (number expected, got {})",
9518                        self.obj_typename(val)
9519                    )
9520                } else {
9521                    format!("'for' {what} must be a number")
9522                }));
9523            }
9524        }
9525        let n = |val| as_num(val, v).expect("checked above");
9526        let int_loop =
9527            v <= LuaVersion::Lua52 || matches!((init, step), (Value::Int(_), Value::Int(_)));
9528        if int_loop {
9529            return Ok((n(init), n(limit), n(step)));
9530        }
9531        let (i, l, st) = (n(init).as_f64(), n(limit).as_f64(), n(step).as_f64());
9532        if v >= LuaVersion::Lua54 && st == 0.0 {
9533            return Err(self.rt_err("'for' step is zero"));
9534        }
9535        Ok((Num::Float(i), Num::Float(l), Num::Float(st)))
9536    }
9537
9538    fn for_prep(&mut self, inst: Inst, base: u32) -> Result<(), LuaError> {
9539        let a = inst.a();
9540        let (init_n, limit_n, step_n) = self.for_operands(base, a)?;
9541        // PUC 5.1–5.3 `OP_FORPREP` stores `i = init - step` and *unconditionally*
9542        // jumps to the matching `OP_FORLOOP` — the body never runs ahead of the
9543        // first test, so each successful iteration emits a backward `OP_FORLOOP`
9544        // jump (db.lua's `for i=1,4 do a=1 end` ↦ 5 line-hook events instead of
9545        // 5.4's 4). 5.4+ collapsed that to a count-based fall-through. The skip
9546        // distance in luna's encoding is `loop_pc - prep_pc`; firing
9547        // `add_pc(bx - 1)` lands the running pc on OP_FORLOOP itself.
9548        let pre53 = self.version() <= LuaVersion::Lua53;
9549        match (init_n, step_n) {
9550            (Num::Int(i0), Num::Int(st)) => {
9551                if pre53 {
9552                    // PUC 5.3 `forlimit`: int limit passes through; float limit
9553                    // gets clamped to MIN/MAX with a `stopnow` flag set only
9554                    // when the clamp is unreachable (positive float with a
9555                    // negative step → limit=MAX, stopnow; negative float with
9556                    // step>=0 → limit=MIN, stopnow). On `stopnow` PUC rewrites
9557                    // `init = 0` so OP_FORLOOP's first test against the
9558                    // unreachable clamp fails cleanly. An ordinary in-range
9559                    // empty loop (e.g. `for i = 1, 0`) is *not* `stopnow` — it
9560                    // lets OP_FORLOOP's natural test reject the first step.
9561                    let (lim, stopnow) = match limit_n {
9562                        Num::Int(l) => (l, false),
9563                        Num::Float(f) => {
9564                            // `luaV_tointeger` floors (ceils for a negative
9565                            // step); a float it cannot fit is clamped on
9566                            // the side of its sign, NaN counting as
9567                            // negative (`0 < n` is false).
9568                            let conv = if st < 0 { f.ceil() } else { f.floor() };
9569                            if (-9_223_372_036_854_775_808.0..9_223_372_036_854_775_808.0)
9570                                .contains(&conv)
9571                            {
9572                                (conv as i64, false)
9573                            } else if f > 0.0 {
9574                                (i64::MAX, st < 0)
9575                            } else {
9576                                (i64::MIN, st > 0)
9577                            }
9578                        }
9579                    };
9580                    let initv = if stopnow { 0 } else { i0 };
9581                    let pre = initv.wrapping_sub(st);
9582                    self.set_r(base, a, Value::Int(pre));
9583                    self.set_r(base, a + 1, Value::Int(lim));
9584                    self.set_r(base, a + 2, Value::Int(st));
9585                    self.add_pc(inst.bx() as i32 - 1);
9586                    return Ok(());
9587                }
9588                let (lim, empty) = int_for_limit(limit_n, i0, st);
9589                if empty {
9590                    self.add_pc(inst.bx() as i32);
9591                    return Ok(());
9592                }
9593                let count = if st > 0 {
9594                    (lim as u64).wrapping_sub(i0 as u64) / (st as u64)
9595                } else {
9596                    (i0 as u64).wrapping_sub(lim as u64) / (st as i128).unsigned_abs() as u64
9597                };
9598                self.set_r(base, a, Value::Int(i0));
9599                self.set_r(base, a + 1, Value::Int(count as i64));
9600                self.set_r(base, a + 2, Value::Int(st));
9601                self.set_r(base, a + 3, Value::Int(i0));
9602            }
9603            _ => {
9604                let (x0, lim, st) = (init_n.as_f64(), limit_n.as_f64(), step_n.as_f64());
9605                if pre53 {
9606                    let pre = x0 - st;
9607                    self.set_r(base, a, Value::Float(pre));
9608                    self.set_r(base, a + 1, Value::Float(lim));
9609                    self.set_r(base, a + 2, Value::Float(st));
9610                    self.add_pc(inst.bx() as i32 - 1);
9611                    return Ok(());
9612                }
9613                // lvm.c `forprep`: skip only when `0 < step ? limit < init :
9614                // init < limit`; a NaN makes both false, so the body runs
9615                // once (with a NaN step, on the second test's side)
9616                let skip = if 0.0 < st { lim < x0 } else { x0 < lim };
9617                let runs = !skip;
9618                if !runs {
9619                    self.add_pc(inst.bx() as i32);
9620                    return Ok(());
9621                }
9622                self.set_r(base, a, Value::Float(x0));
9623                self.set_r(base, a + 1, Value::Float(lim));
9624                self.set_r(base, a + 2, Value::Float(st));
9625                self.set_r(base, a + 3, Value::Float(x0));
9626            }
9627        }
9628        Ok(())
9629    }
9630
9631    #[inline(always)]
9632    fn for_loop(&mut self, inst: Inst, base: u32) -> Result<(), LuaError> {
9633        let a = inst.a();
9634        // PUC 5.1–5.3 `OP_FORLOOP` compares the post-step `i` to `limit`
9635        // directly (R[a+1] holds the limit, *not* a remaining-count) so the
9636        // first iteration's test fires through the same backward-jump path as
9637        // every later iteration. 5.4+ switched to the count-based form luna
9638        // already uses for `Int`; the float branch was already PUC-3.x-style.
9639        let v = self.version();
9640        let pre53 = v <= LuaVersion::Lua53;
9641        // `for_prep` leaves the three slots all Int or all Float; anything
9642        // else was written by `debug.setlocal` or by crafted bytecode. PUC
9643        // reads such slots unchecked (garbage or a crash); luna raises.
9644        match (self.r(base, a), self.r(base, a + 1), self.r(base, a + 2)) {
9645            (Value::Int(cur), Value::Int(lim), Value::Int(st)) if pre53 => {
9646                let next = cur.wrapping_add(st);
9647                let cont = if st > 0 { next <= lim } else { next >= lim };
9648                if cont {
9649                    self.set_r(base, a, Value::Int(next));
9650                    self.set_r(base, a + 3, Value::Int(next));
9651                    self.add_pc(-(inst.bx() as i32));
9652                }
9653            }
9654            // the count is unsigned (PUC `lua_Unsigned`): a loop over
9655            // more than 2^63 values stores a "negative" one
9656            (Value::Int(cur), Value::Int(count), Value::Int(st)) => {
9657                if count != 0 {
9658                    let next = cur.wrapping_add(st);
9659                    self.set_r(base, a, Value::Int(next));
9660                    self.set_r(base, a + 1, Value::Int(count.wrapping_sub(1)));
9661                    self.set_r(base, a + 3, Value::Int(next));
9662                    self.add_pc(-(inst.bx() as i32));
9663                }
9664            }
9665            (Value::Float(cur), Value::Float(lim), Value::Float(st)) => {
9666                self.float_for_step(inst, base, cur, lim, st);
9667            }
9668            // 5.1/5.2 have one number type, so a number of the other
9669            // representation stored into a slot is still a valid state
9670            (x, l, s) if v <= LuaVersion::Lua52 => {
9671                match (as_number(x), as_number(l), as_number(s)) {
9672                    (Some(cur), Some(lim), Some(st)) => {
9673                        self.float_for_step(inst, base, cur.as_f64(), lim.as_f64(), st.as_f64())
9674                    }
9675                    _ => return Err(self.rt_err("'for' state corrupted")),
9676                }
9677            }
9678            _ => return Err(self.rt_err("'for' state corrupted")),
9679        }
9680        Ok(())
9681    }
9682
9683    #[inline(always)]
9684    fn float_for_step(&mut self, inst: Inst, base: u32, cur: f64, lim: f64, st: f64) {
9685        let a = inst.a();
9686        let next = cur + st;
9687        let cont = if st > 0.0 { next <= lim } else { next >= lim };
9688        if cont {
9689            self.set_r(base, a, Value::Float(next));
9690            self.set_r(base, a + 3, Value::Float(next));
9691            self.add_pc(-(inst.bx() as i32));
9692        }
9693    }
9694
9695    // ---- native helpers (used by builtins) ----
9696
9697    /// A native function's own captured upvalue (self lives at func_slot).
9698    ///
9699    /// Public so `native_typed` trampolines and embedders authoring
9700    /// stateful natives via `native_with(...)` can read their upvals.
9701    pub fn nat_upval(&self, func_slot: u32, i: usize) -> Value {
9702        let Value::Native(nc) = self.stack[func_slot as usize] else {
9703            unreachable!("native frame without native closure");
9704        };
9705        nc.upvals[i]
9706    }
9707
9708    /// Number of upvalues captured by the native at `func_slot` (variadic
9709    /// captures such as the `io.lines` format list).
9710    pub(crate) fn nat_upcount(&self, func_slot: u32) -> usize {
9711        let Value::Native(nc) = self.stack[func_slot as usize] else {
9712            unreachable!("native frame without native closure");
9713        };
9714        nc.upvals.len()
9715    }
9716
9717    /// Write a native function's own upvalue (stateful iterators).
9718    pub(crate) fn nat_set_upval(&mut self, func_slot: u32, i: usize, v: Value) {
9719        let Value::Native(nc) = self.stack[func_slot as usize] else {
9720            unreachable!("native frame without native closure");
9721        };
9722        // SAFETY: Gc<T> is NonNull<T> over the GC heap; the heap is single-threaded and the pointer is live as long as it is reachable from active roots (see heap.rs:5-7).
9723        unsafe { nc.as_mut() }.upvals[i] = v;
9724        // NativeClosure.upvals is traced as part of its Trace; a long-lived
9725        // stateful iterator closure (e.g. string.gmatch) sees many writes —
9726        // barrier_back once-and-done is cheaper than per-child forward.
9727        self.heap
9728            .barrier_back(nc.as_ptr() as *mut crate::runtime::heap::GcHeader);
9729    }
9730
9731    /// Read the i-th positional argument inside a `NativeFn` body
9732    /// (analogous to `lua_tovalue(L, i + 1)`). `i >= nargs` yields `Nil`,
9733    /// matching PUC's "missing arg is nil" contract. Public so embedders
9734    /// can author their own natives.
9735    pub fn nat_arg(&self, func_slot: u32, nargs: u32, i: u32) -> Value {
9736        if i < nargs {
9737            self.stack[(func_slot + 1 + i) as usize]
9738        } else {
9739            Value::Nil
9740        }
9741    }
9742
9743    /// Overwrite the i-th argument slot of the running native (the in-place
9744    /// conversion `lua_tolstring` performs on a number argument).
9745    pub(crate) fn nat_set_arg(&mut self, func_slot: u32, i: u32, v: Value) {
9746        self.stack[(func_slot + 1 + i) as usize] = v;
9747    }
9748
9749    /// Push the return values of a `NativeFn` and return their count
9750    /// (analogous to pushing N values then `return N` from a C function).
9751    /// Public so embedders can author their own natives.
9752    pub fn nat_return(&mut self, func_slot: u32, vals: &[Value]) -> u32 {
9753        let need = func_slot as usize + vals.len();
9754        if self.stack.len() < need {
9755            self.stack.resize(need, Value::Nil);
9756        }
9757        for (i, &v) in vals.iter().enumerate() {
9758            self.stack[func_slot as usize + i] = v;
9759        }
9760        vals.len() as u32
9761    }
9762
9763    /// Fast string concatenation of an adjacent pair, or `None` when a
9764    /// `__concat` metamethod is required.
9765    fn concat_pair(&mut self, l: Value, r: Value) -> Result<Option<Value>, LuaError> {
9766        let legacy = self.float_fmt();
9767        // Length-check fast paths for both string operands BEFORE the
9768        // (expensive) copy in `concat_piece`, so a runaway `a..a..a..…`
9769        // chain (5.1 big.lua / 5.5 heavy.lua's `teststring`) raises the
9770        // overflow on the first pair that would exceed `INT_MAX` instead
9771        // of allocating multi-GB intermediates first.
9772        let max_str = i32::MAX as usize;
9773        if let (Value::Str(ls), Value::Str(rs)) = (l, r) {
9774            let a_len = ls.as_bytes().len();
9775            let b_len = rs.as_bytes().len();
9776            let new_len = a_len.checked_add(b_len);
9777            if new_len.is_none() || new_len.unwrap() > max_str {
9778                return Err(self.rt_err("string length overflow"));
9779            }
9780        }
9781        match (concat_piece(l, legacy), concat_piece(r, legacy)) {
9782            (Some(a), Some(b)) => {
9783                // PUC `MAX_SIZE` for Lua strings is `INT_MAX`; an attempt to
9784                // concat past it raises "string length overflow"
9785                // (5.5 heavy.lua `teststring` doubles `a..a..…` until it hits
9786                // exactly this wall).
9787                let new_len = a.len().checked_add(b.len());
9788                if new_len.is_none() || new_len.unwrap() > max_str {
9789                    return Err(self.rt_err("string length overflow"));
9790                }
9791                let mut combined = a;
9792                combined.extend_from_slice(&b);
9793                Ok(Some(Value::Str(self.heap.intern(&combined))))
9794            }
9795            _ => Ok(None),
9796        }
9797    }
9798
9799    /// Fold the concat operands occupying `[base_a .. self.top)` right-to-left
9800    /// into a single result at `base_a` (PUC `luaV_concat`). Returns after
9801    /// either finishing (result at `base_a`) or arming a yieldable `__concat`
9802    /// call — its `Meta` continuation re-enters here on the metamethod's return.
9803    fn concat_run(&mut self, base_a: u32) -> Result<(), LuaError> {
9804        // Sum the lengths of all all-Str operands BEFORE starting the
9805        // right-associative fold so a 129-operand `a..a..…` chain
9806        // (5.1 big.lua's `rep129(longs)`) raises overflow immediately,
9807        // not after dozens of multi-GB intermediate intern+hash rounds.
9808        // A non-Str operand falls through to the per-pair check.
9809        let max_str = i32::MAX as usize;
9810        let mut total: usize = 0;
9811        let mut all_str = true;
9812        for slot in base_a..self.top {
9813            match self.stack[slot as usize] {
9814                Value::Str(s) => match total.checked_add(s.as_bytes().len()) {
9815                    Some(t) if t <= max_str => total = t,
9816                    _ => return Err(self.rt_err("string length overflow")),
9817                },
9818                _ => {
9819                    all_str = false;
9820                    break;
9821                }
9822            }
9823        }
9824        let _ = all_str; // discrimination already captured by early returns above
9825        while self.top.saturating_sub(base_a) >= 2 {
9826            let i = self.top - 1; // rightmost operand
9827            let x = self.stack[(i - 1) as usize];
9828            let y = self.stack[i as usize];
9829            match self.concat_pair(x, y)? {
9830                Some(s) => {
9831                    self.stack[(i - 1) as usize] = s;
9832                    self.top = i; // consumed y
9833                }
9834                None => {
9835                    let mut mm = self.get_mm(x, Mm::Concat);
9836                    if mm.is_nil() {
9837                        mm = self.get_mm(y, Mm::Concat);
9838                    }
9839                    if mm.is_nil() {
9840                        let legacy = self.float_fmt();
9841                        let bad = if concat_piece(x, legacy).is_none() {
9842                            x
9843                        } else {
9844                            y
9845                        };
9846                        return Err(self.type_err("concatenate", bad));
9847                    }
9848                    // result lands at i-1, dropping y (top→i); resume continues.
9849                    let dst = i - 1;
9850                    self.begin_meta_call(
9851                        mm,
9852                        &[x, y],
9853                        MetaAction::Concat { dst, base_a },
9854                        "concat",
9855                    )?;
9856                    return Ok(());
9857                }
9858            }
9859        }
9860        self.maybe_collect_garbage(base_a + 1);
9861        Ok(())
9862    }
9863
9864    /// `luaL_tolstring`: `__tostring` (whose result must be a string or a
9865    /// number, rendered), else the basic rendering, where 5.3+ names a value
9866    /// by a string `__name` metafield.
9867    pub fn tostring_value(&mut self, v: Value) -> Result<Vec<u8>, LuaError> {
9868        let mm = self.get_mm(v, Mm::ToString);
9869        if !mm.is_nil() {
9870            // `luaL_callmeta` is a plain `lua_call`: `__tostring` cannot yield.
9871            let r = self.call_noyield(mm, &[v])?;
9872            return match r.first().copied().unwrap_or(Value::Nil) {
9873                Value::Str(s) => Ok(s.as_bytes().to_vec()),
9874                r @ (Value::Int(_) | Value::Float(_)) => Ok(self.tostring_basic(r)),
9875                // luaL_error: positioned at whatever called the library function
9876                _ => Err(crate::vm::builtins::raise_str(
9877                    self,
9878                    "'__tostring' must return a string",
9879                )),
9880            };
9881        }
9882        if self.version >= LuaVersion::Lua53
9883            && !matches!(
9884                v,
9885                Value::Nil | Value::Bool(_) | Value::Int(_) | Value::Float(_) | Value::Str(_)
9886            )
9887            && let Value::Str(name) = self.get_mm(v, Mm::Name)
9888        {
9889            let basic = self.tostring_basic(v);
9890            let at = basic
9891                .iter()
9892                .position(|&c| c == b':')
9893                .expect("an object renders as `kind: address`");
9894            let mut out = name.as_bytes().to_vec();
9895            out.extend_from_slice(&basic[at..]);
9896            return Ok(out);
9897        }
9898        Ok(self.tostring_basic(v))
9899    }
9900
9901    /// The dialect's float-rendering flavor: ≤5.2 %.14g
9902    /// bare, 5.3/5.4 %.14g + ".0", 5.5 two-stage %.15g/%.17g + ".0".
9903    pub(crate) fn float_fmt(&self) -> numeric::FloatFmt {
9904        use crate::version::LuaVersion::*;
9905        match self.version {
9906            Lua51 | Lua52 => numeric::FloatFmt::Legacy14,
9907            Lua53 | Lua54 => numeric::FloatFmt::G14,
9908            _ => numeric::FloatFmt::TwoStage55,
9909        }
9910    }
9911
9912    /// Basic tostring (no metamethods).
9913    pub(crate) fn tostring_basic(&mut self, v: Value) -> Vec<u8> {
9914        match v {
9915            Value::Nil => b"nil".to_vec(),
9916            Value::Bool(true) => b"true".to_vec(),
9917            Value::Bool(false) => b"false".to_vec(),
9918            Value::Int(i) => numeric::num_to_string(Num::Int(i)).into_bytes(),
9919            // PUC ≤5.2 has no integer subtype — `tostring(2.0)` is `"2"`, not
9920            // `"2.0"`. The 5.3+ split needs the suffix so `print(2.0)` is
9921            // distinguishable from `print(2)`. pm.lua :13 builds patterns by
9922            // concatenating these renderings.
9923            Value::Float(f) => {
9924                numeric::num_to_string_for(Num::Float(f), self.float_fmt()).into_bytes()
9925            }
9926            Value::Str(s) => s.as_bytes().to_vec(),
9927            Value::Table(t) => format!("table: {:p}", t.as_ptr()).into_bytes(),
9928            Value::Closure(c) => format!("function: {:p}", c.as_ptr()).into_bytes(),
9929            Value::Native(n) => format!("function: {:p}", n.as_ptr()).into_bytes(),
9930            Value::Coro(co) => format!("thread: {:p}", co.as_ptr()).into_bytes(),
9931            // PUC names file handles `file (0x…)`; a bare userdata is
9932            // `userdata: 0x…`. The io library overrides this via __tostring.
9933            Value::Userdata(u) => format!("userdata: {:p}", u.as_ptr()).into_bytes(),
9934            // PUC `lua_topointer`/tostring on light udata: "userdata: 0x…"
9935            // (the "light" qualifier only appears in `luaL_typeerror`).
9936            Value::LightUserdata(p) => format!("userdata: {p:p}").into_bytes(),
9937        }
9938    }
9939}
9940
9941impl Vm {
9942    /// PUC's debug-API placeholder for an unnamed vararg slot returned by
9943    /// `debug.getlocal(_, -n)`. 5.2/5.3 spelled it `"(*vararg)"`; 5.4
9944    /// dropped the asterisk in favour of `"(vararg)"`. db.lua 5.2 :189 /
9945    /// 5.3 :195 / 5.4 :286 baseline on their respective form.
9946    pub(crate) fn vararg_locvar_name(&self) -> &'static str {
9947        if matches!(self.version, LuaVersion::Lua52 | LuaVersion::Lua53) {
9948            "(*vararg)"
9949        } else {
9950            "(vararg)"
9951        }
9952    }
9953
9954    /// PUC's debug-API placeholder for an unnamed temporary on a C
9955    /// activation. 5.2/5.3 reported `"(*temporary)"`; 5.4 switched to
9956    /// `"(C temporary)"`. db.lua 5.2 :288, 5.3 :312, 5.4 :404 each pin
9957    /// their spelling.
9958    pub(crate) fn temporary_locvar_name(&self) -> &'static str {
9959        if matches!(
9960            self.version,
9961            LuaVersion::Lua51 | LuaVersion::Lua52 | LuaVersion::Lua53
9962        ) {
9963            // PUC 5.1's `findlocal` C-frame branch reported `(*temporary)`
9964            // (db.lua :228 pins it). 5.2/5.3 kept the spelling, 5.4 changed
9965            // to `(C temporary)`.
9966            "(*temporary)"
9967        } else {
9968            "(C temporary)"
9969        }
9970    }
9971
9972    /// PUC's debug-API placeholder for an unnamed Lua-frame temporary
9973    /// (an arithmetic intermediate sitting past the last named local on a
9974    /// live register slot). 5.2/5.3 reported `"(*temporary)"`; 5.4 dropped
9975    /// the asterisk to `"(temporary)"`. db.lua 5.3 :786, 5.4 :966 pin the
9976    /// spelling.
9977    pub(crate) fn lua_temporary_locvar_name(&self) -> &'static str {
9978        if matches!(
9979            self.version,
9980            LuaVersion::Lua51 | LuaVersion::Lua52 | LuaVersion::Lua53
9981        ) {
9982            "(*temporary)"
9983        } else {
9984            "(temporary)"
9985        }
9986    }
9987
9988    /// PUC `pushglobalfuncname`: walk `package.loaded` to depth 2 looking for a
9989    /// native whose function pointer matches `target`, and return its qualified
9990    /// name (e.g. `"table.sort"`). A `_G.X` match is stripped to `"X"`. Returns
9991    /// `None` if no match is found. Used by `arg_error` when the running native
9992    /// was invoked from another native (PUC `ar.name == NULL` at level 0).
9993    pub(crate) fn pushglobalfuncname(
9994        &mut self,
9995        target: crate::runtime::value::NativeFn,
9996    ) -> Option<String> {
9997        let pkg_k = Value::Str(self.heap.intern(b"package"));
9998        let pkg = match self.globals().get(pkg_k) {
9999            Value::Table(t) => t,
10000            _ => return None,
10001        };
10002        let loaded_k = Value::Str(self.heap.intern(b"loaded"));
10003        let loaded = match pkg.get(loaded_k) {
10004            Value::Table(t) => t,
10005            _ => return None,
10006        };
10007        let matches = |v: Value| -> bool {
10008            matches!(v, Value::Native(nc) if std::ptr::fn_addr_eq(nc.f, target))
10009        };
10010        let mut k = Value::Nil;
10011        while let Ok(Some((nk, nv))) = loaded.next(k) {
10012            k = nk;
10013            let Value::Str(outer) = nk else { continue };
10014            let outer = String::from_utf8_lossy(outer.as_bytes()).into_owned();
10015            if matches(nv) {
10016                return Some(if outer == "_G" { String::new() } else { outer });
10017            }
10018            if let Value::Table(inner_t) = nv {
10019                let mut k2 = Value::Nil;
10020                while let Ok(Some((nk2, nv2))) = inner_t.next(k2) {
10021                    k2 = nk2;
10022                    if matches(nv2)
10023                        && let Value::Str(inner) = nk2
10024                    {
10025                        let inner = String::from_utf8_lossy(inner.as_bytes()).into_owned();
10026                        return Some(if outer == "_G" {
10027                            inner
10028                        } else {
10029                            format!("{outer}.{inner}")
10030                        });
10031                    }
10032                }
10033            }
10034        }
10035        None
10036    }
10037
10038    /// How the caller named the running native (PUC `lua_getinfo("n")` at
10039    /// level 0): `None` when it gives no name, as when the caller is C.
10040    pub(crate) fn running_call_name(&self) -> Option<(&'static str, String)> {
10041        let ts = self.thread_stack(None);
10042        if ts.levels.is_empty() {
10043            return None;
10044        }
10045        self.level_name(&ts, 0)
10046    }
10047
10048    /// Read an upvalue cell of a closure (debug.getupvalue).
10049    pub(crate) fn upvalue_value(&self, cl: Gc<LuaClosure>, idx: usize) -> Value {
10050        match cl.upvals()[idx].state() {
10051            UpvalState::Open { slot, thread } => self.read_slot(slot, thread),
10052            UpvalState::Closed(v) => v,
10053        }
10054    }
10055
10056    /// Write an upvalue cell of a closure (debug.setupvalue).
10057    pub(crate) fn upvalue_set_value(&mut self, cl: Gc<LuaClosure>, idx: usize, v: Value) {
10058        let uv = cl.upvals()[idx];
10059        match uv.state() {
10060            UpvalState::Open { slot, thread } => self.write_slot(slot, thread, v),
10061            UpvalState::Closed(_) => {
10062                // SAFETY: Gc<T> is NonNull<T> over the GC heap; the heap is single-threaded and the pointer is live as long as it is reachable from active roots (see heap.rs:5-7).
10063                unsafe { uv.as_mut() }.set_closed(v);
10064                self.heap
10065                    .barrier_forward(uv.as_ptr() as *mut crate::runtime::heap::GcHeader, v);
10066            }
10067        }
10068    }
10069}
10070
10071// ────────────────────────────────────────────────────────────────────
10072// AOT trace dispatch install.
10073//
10074// The deploy-side resolver in `luna-runtime-helpers` walks the binary's
10075// trace-meta section after `vm.load`, resolves each entry's
10076// `(proto_hash, head_pc, fn_ptr)` triple against the loaded chunk's
10077// proto tree, and pushes a `CompiledTrace` onto the matching Proto's
10078// `traces` Vec via [`Vm::install_aot_trace`] below. The existing
10079// trace-dispatch loop (this file's `cl.proto.traces.borrow().iter()
10080// .find(|t| t.head_pc == pc && t.dispatchable)`) then fires the AOT
10081// mcode without further plumbing — same code path the runtime JIT
10082// uses.
10083//
10084// Why a separate impl block: keeps the AOT API surface (one fn) easy
10085// to locate when grep'ing for `install_aot_trace`, without dragging
10086// the 8500-line `impl Vm` block above.
10087// ────────────────────────────────────────────────────────────────────
10088
10089impl Vm {
10090    /// Install a precompiled
10091    /// `CompiledTrace` onto `proto.traces` so the interp dispatcher
10092    /// fires it at the trace's `head_pc`. This is the runtime install
10093    /// API the deploy-side `luna-runtime-helpers` resolver calls once
10094    /// per AOT-emitted trace meta entry, after looking up `proto` by
10095    /// stable hash (see `crate::runtime::function::Proto::stable_hash`).
10096    ///
10097    /// # What this does
10098    ///
10099    /// Pushes `trace` onto `proto.traces` via the existing `RefCell`.
10100    /// The trace's `entry` fn ptr must already point at runnable
10101    /// machine code (the AOT linker resolved the symbol at link time;
10102    /// the deploy resolver passes the address verbatim).
10103    ///
10104    /// # What this does NOT do
10105    ///
10106    /// - **No deduplication.** Calling twice with the same `head_pc`
10107    ///   pushes two entries; the dispatcher's `find` will pick the
10108    ///   first match. The deploy resolver is responsible for not
10109    ///   double-installing.
10110    /// - **No invalidation of the runtime JIT cache.** If the runtime
10111    ///   JIT later records + compiles a trace for the same
10112    ///   `(proto, head_pc)`, both coexist on `proto.traces` and the
10113    ///   dispatcher's `find` picks whichever appears first. AOT
10114    ///   traces install before any runtime recording is possible
10115    ///   (resolver runs before `vm.load` returns its first closure),
10116    ///   so AOT traces win the race for the same site.
10117    /// - **No coverage gating.** AOT traces are trusted by
10118    ///   construction — they were validated at compile time. Setting
10119    ///   `dispatchable: false` on the input would silently disable
10120    ///   dispatch; the caller controls that flag.
10121    ///
10122    /// # Safety / soundness
10123    ///
10124    /// `trace.entry` is an `unsafe extern "C" fn` (mmap'd or linked
10125    /// machine code). Soundness contract:
10126    ///
10127    /// - The fn pointer must remain valid for the `Vm`'s lifetime.
10128    ///   In the AOT-binary deploy shape this is trivially satisfied —
10129    ///   the fn lives in the binary's `.text`.
10130    /// - `trace.entry_tags` / `exit_tags` / `window_size` must match
10131    ///   what the trace's IR actually compiled against; the dispatcher
10132    ///   uses them to marshal `reg_state` in and out without further
10133    ///   validation. A mismatch corrupts vm.stack.
10134    ///
10135    /// The AOT pipeline (`luna-aot`) is responsible for ensuring these
10136    /// invariants hold; this fn is a plain push — no validation that
10137    /// would slow the dispatcher's hot path either.
10138    pub fn install_aot_trace(
10139        &mut self,
10140        proto: crate::runtime::Gc<crate::runtime::function::Proto>,
10141        trace: crate::jit::trace::CompiledTrace,
10142    ) {
10143        let _ = self; // resolver passes &mut Vm for symmetry with future
10144        // pending-install + hash-walk variants; nothing on `self` to
10145        // mutate today because the install target lives on the Proto.
10146        proto.traces.borrow_mut().push(TArc::new(trace));
10147    }
10148
10149    /// Walk the proto tree
10150    /// reachable from `root` and return `(proto, stable_hash)` pairs
10151    /// for every Proto found. Used by the deploy-side resolver to
10152    /// match AOT-emitted `proto_hash` keys against the freshly
10153    /// `undump`'d chunk's protos.
10154    ///
10155    /// The walk is BFS over `Proto.protos`. Same-Proto deduplication
10156    /// is done via `Gc::as_ptr` identity — a Proto re-referenced from
10157    /// multiple nested closures (rare; the cache field would catch
10158    /// the closure-side dedup, not the Proto side) is reported once.
10159    ///
10160    /// # Why on `&Vm` and not a free fn
10161    ///
10162    /// Keeps the AOT install API discoverable on the Vm surface —
10163    /// `vm.collect_proto_hashes(root)` reads naturally next to
10164    /// `vm.install_aot_trace(proto, trace)`. Doesn't actually touch
10165    /// any Vm field, so `&self` (read-only) is enough.
10166    pub fn collect_proto_hashes(
10167        &self,
10168        root: crate::runtime::Gc<crate::runtime::function::Proto>,
10169    ) -> Vec<(
10170        crate::runtime::Gc<crate::runtime::function::Proto>,
10171        [u8; 16],
10172    )> {
10173        let _ = self;
10174        let mut out = Vec::new();
10175        let mut seen: std::collections::HashSet<*const crate::runtime::function::Proto> =
10176            std::collections::HashSet::new();
10177        let mut queue: std::collections::VecDeque<
10178            crate::runtime::Gc<crate::runtime::function::Proto>,
10179        > = std::collections::VecDeque::new();
10180        queue.push_back(root);
10181        while let Some(p) = queue.pop_front() {
10182            let key = p.as_ptr() as *const _;
10183            if !seen.insert(key) {
10184                continue;
10185            }
10186            out.push((p, p.stable_hash()));
10187            for &child in p.protos.iter() {
10188                queue.push_back(child);
10189            }
10190        }
10191        out
10192    }
10193}
10194
10195/// Recordings of one trace head that may fail to compile before the head
10196/// is no longer recorded (LuaJIT likewise blacklists a trace start after
10197/// repeated failures). A few tries, since a later recording can see
10198/// different register kinds.
10199const MAX_TRACE_COMPILE_FAILURES: u8 = 3;
10200
10201fn note_trace_compile_failure(proto: Gc<crate::runtime::function::Proto>, head_pc: u32) {
10202    let mut failures = proto.trace_compile_failures.borrow_mut();
10203    match failures.iter_mut().find(|(pc, _)| *pc == head_pc) {
10204        Some((_, n)) => *n = n.saturating_add(1),
10205        None => failures.push((head_pc, 1)),
10206    }
10207}
10208
10209fn trace_head_abandoned(proto: Gc<crate::runtime::function::Proto>, head_pc: u32) -> bool {
10210    proto
10211        .trace_compile_failures
10212        .borrow()
10213        .iter()
10214        .any(|&(pc, n)| pc == head_pc && n >= MAX_TRACE_COMPILE_FAILURES)
10215}