Skip to main content

luna_core/vm/
exec.rs

1//! The interpreter. Dispatch is a plain match over opcodes. Lua→Lua calls
2//! share one loop and never recurse the Rust stack; only native↔Lua boundaries do (e.g. pcall).
3//!
4//! Varargs follow 5.5 semantics: a vararg call materializes a vararg table
5//! (fields 1..n plus "n") kept in the function's own stack slot; `...`
6//! expands from it and `...name` binds it. 5.1 LUAI_COMPAT_VARARG also
7//! materializes a local `arg` table (see `proto.has_compat_vararg_arg`).
8
9use crate::frontend::SyntaxError;
10use crate::jit::send_compat::TArc;
11use crate::numeric::{self, Num};
12use crate::runtime::heap::GcHeader;
13use crate::runtime::{
14    AfterClose, CallFrame, CloseCont, ContKind, Coro, CoroStatus, Frame, Gc, Heap, LuaClosure,
15    MetaAction, MetaCont, NativeClosure, NativeCont, Table, TableError, UpvalState, Upvalue, Value,
16};
17use crate::version::LuaVersion;
18use crate::vm::builtins::{nat_host_xpcall, nat_pairs, nat_pcall, nat_xpcall};
19use crate::vm::callstack::DbgKind;
20use crate::vm::error::LuaError;
21use crate::vm::isa::{Inst, Op};
22
23/// A Lua virtual machine: one OS thread's worth of Lua state.
24///
25/// # Threading model
26///
27/// `Vm` is **`!Send + !Sync`**. The GC uses `Gc<T> = NonNull<T>` over
28/// an intrusive mark-sweep heap (not `Rc<RefCell<T>>`), and the trace
29/// JIT side-table uses `Rc<CompiledTrace>` — both single-threaded by
30/// design. Embedders that want concurrency spawn one `Vm` per OS
31/// thread (or per single-thread Tokio worker) and exchange data via
32/// channels. See [`docs/threading.md`](../../docs/threading.md) for
33/// canonical embedding patterns including Tokio `current_thread`,
34/// `LocalSet` on multi-thread, and `Vm`-per-OS-thread + channels.
35///
36/// The constraint is enforced at compile time:
37///
38/// ```compile_fail
39/// fn must_be_send<T: Send>() {}
40/// must_be_send::<luna_core::Vm>(); // error[E0277]: `Vm` cannot be sent between threads safely
41/// ```
42///
43/// A future `feature = "send"` will gate an
44/// opt-in `Arc<RwLock<T>>` mode with a hard ≤8% perf regression
45/// budget.
46pub struct Vm {
47    /// The GC heap owned by this VM. Embedders normally interact via the
48    /// `Vm` methods (`load` / `call_value` / `set_global` / …) rather than
49    /// the heap directly.
50    pub heap: Heap,
51    pub(crate) stack: Vec<Value>,
52    pub(crate) frames: Vec<CallFrame>,
53    /// How many `__call` metamethods were resolved to reach each Lua frame,
54    /// by index into `frames`; each adds one argument (PUC 5.5 `CIST_CCMT`
55    /// bits, reported as `getinfo("t").extraargs`). Written whenever a Lua
56    /// frame is pushed, so the entry for a live frame is always its own;
57    /// entries past `frames.len()` are stale and never read. Kept beside
58    /// `frames` rather than in `Frame` because `Frame` is public with all
59    /// fields public, where a new field is a breaking change.
60    pub(crate) frame_ccmt: Vec<u8>,
61    /// Shadow of `self.frames.len()`. Synced on every push/pop in the
62    /// `frames_push_sync`/`frames_pop_sync` helpers (debug-asserted on
63    /// use). Not consumed by readers yet; it is scaffolding for replacing
64    /// `frames: Vec<CallFrame>` with a flat `[CallFrame; MAX_FRAMES]`
65    /// indexed by frames_top.
66    frames_top: u32,
67    /// open upvalues, sorted ascending by stack slot
68    open_upvals: Vec<(u32, Gc<Upvalue>)>,
69    /// to-be-closed slots, ascending
70    tbc: Vec<u32>,
71    /// logical stack top for multi-result sequences
72    pub(crate) top: u32,
73    globals: Gc<Table>,
74    /// shared metatable for all strings (populated by the string lib)
75    /// per-basic-type metatables (PUC luaT): indexed by `type_mt_slot`
76    /// (0 nil, 1 boolean, 2 number, 3 string, 4 function); tables carry their
77    /// own. Settable via debug.setmetatable.
78    type_mt: [Option<Gc<Table>>; 5],
79    /// pre-interned metamethod event names, indexed by `Mm`
80    mm_names: Vec<Gc<crate::runtime::LuaStr>>,
81    /// native↔Lua nesting depth (PUC C-stack guard analogue)
82    c_depth: u32,
83    /// number of live pcall/xpcall continuation frames on the running thread
84    /// (PUC counts these against nCcalls). Bounds protected-call recursion the
85    /// way `c_depth` bounds call_value recursion. Per-thread: saved/restored
86    /// with the coroutine context, since continuations survive a yield.
87    pcall_depth: u32,
88    /// number of non-yieldable C calls in flight on the running thread (PUC's
89    /// `L->nny`). A library callback that runs via synchronous Rust recursion
90    /// (sort comparator, gsub replacement) cannot be continued across a yield,
91    /// so it bumps this for its duration; `coroutine.yield` inside hits the
92    /// C-call boundary and errors. Always 0 at a suspend point (a yield can
93    /// never cross such a call), so it needs no per-thread save/restore.
94    nny: u32,
95    /// Nonzero while an xpcall message handler is on the Rust stack. Used so a
96    /// stack-overflow that surfaces *inside* the handler is reported as PUC's
97    /// "error in error handling" (LUA_ERRERR + `luaD_seterrorobj`), not the
98    /// plain "stack overflow" — errors.lua :606's `checkerr("error handling",
99    /// loop)` then matches. PUC tracks this via the soft-cap window
100    /// `nCcalls >= MAXCCALLS/10*11`; luna's c_depth is strict, so we mark the
101    /// scope explicitly.
102    pub(crate) msgh_depth: u32,
103    /// set by a coroutine closing itself (`coroutine.close()` on the running
104    /// thread): the to-be-closed handlers have already run; the thread must now
105    /// terminate. `Some(None)` is a clean close, `Some(Some(e))` a handler
106    /// raised `e`. Checked by `exec_with`/`resume_coro` to propagate (not
107    /// unwind, so a protecting pcall cannot catch it) the termination.
108    terminating: Option<Option<Value>>,
109    /// xoshiro256** state (math.random)
110    rng: [u64; 4],
111    /// VM creation time (os.clock)
112    started: std::time::Instant,
113    version: LuaVersion,
114    /// error object being threaded through a chain of __close handlers; a GC
115    /// root for the duration (a handler may trigger collection)
116    closing_err: Option<Value>,
117    /// the coroutine whose context is currently live in the fields above;
118    /// `None` while the main thread runs
119    pub(crate) current: Option<Gc<crate::runtime::Coro>>,
120    /// the main thread's saved execution context while a coroutine runs
121    main_ctx: Option<SavedCtx>,
122    /// set by `coroutine.yield` to suspend the running coroutine: the yielded
123    /// values plus the slot/result-count needed to finish the yielding call on
124    /// the next resume. Checked by `exec` to propagate (not unwind) on yield.
125    yielding: Option<(Vec<Value>, u32, i32)>,
126    /// results expected by the in-flight native call (so `yield` knows how many
127    /// values its call site wants when it suspends)
128    native_nresults: i32,
129    /// identity object for the main thread, returned by `coroutine.running`
130    /// (the main thread's context lives in the VM fields / `main_ctx`, not here)
131    main_coro: Option<Gc<Coro>>,
132    /// `collectgarbage` mode name ("incremental"/"generational"). The collector
133    /// itself is still stop-the-world mark-sweep; this tracks the mode so mode
134    /// switches report the previous one, as PUC does.
135    gc_mode: &'static str,
136    /// the live-register boundary of the running thread for GC rooting (PUC's
137    /// `L->top`): set precisely at each GC safe point so freed temporary
138    /// registers above it are not rooted. Without this the collector roots the
139    /// whole stack window, pinning weak-table values stranded in stale temps
140    /// (e.g. closure.lua's `while x[1]` GC-detection loop).
141    pub(crate) gc_top: u32,
142    /// `collectgarbage("param", name [,value])` pacing parameters. The collector
143    /// is still stop-the-world, so these are stored/returned for API fidelity
144    /// (PUC round-trips them via `setparam`/`getparam`). Defaults mirror PUC's
145    /// `LUAI_GC*` knobs: pause=200, stepmul=100, stepsize=13.
146    gc_pause: i64,
147    gc_stepmul: i64,
148    gc_stepsize: i64,
149    /// `collectgarbage`'s parameters as the dialect stores them; they set
150    /// the three knobs above through `set_gc_pacing`.
151    pub(crate) gc_params: crate::vm::lib_gc::GcParams,
152    /// true while `__gc` finalizers are being run, so a finalizer that calls
153    /// `collectgarbage` gets a no-op (PUC's non-reentrancy: lua_gc returns -1 →
154    /// `collectgarbage` yields fail).
155    gc_finalizing: bool,
156    /// C ABI scratch (`capi` module): the host-visible value stack that C
157    /// callers operate on via `lua_pushinteger` / `lua_tostring` / etc.
158    /// Kept here (instead of in a separate `LuaState` wrapper) so the
159    /// trampoline that bridges to a `LuaCFunction` can safely cast the
160    /// Vm pointer it already holds to the public `*mut LuaState` type
161    /// without any aliasing of `&mut Vm` against `&mut LuaState.vm`.
162    pub capi_stack: Vec<crate::runtime::Value>,
163    /// Pinned CString backing the pointer last returned by `lua_tostring`;
164    /// valid until the next `lua_tostring` on the same Vm.
165    pub capi_cstr_pin: Option<std::ffi::CString>,
166    /// PUC 5.4+ warning system. Lua manual §6.1 `warn`: emitted messages
167    /// concatenate across continuation calls until a non-`tocont` call
168    /// flushes; the default warnf recognises `@on`/`@off` control messages
169    /// and starts disabled. luna's `emit_warn` mirrors the default warnf
170    /// behaviour and 5.4+ `__gc` errors are routed through it (5.1–5.3
171    /// keep the older raise semantics).
172    pub(crate) warn_state: WarnState,
173    pub(crate) warn_buf: Vec<u8>,
174    /// Embedding cooperative budget: a per-Vm tick counter that the run
175    /// loop decrements once per dispatch turn. When it hits zero the loop
176    /// raises a catchable "instruction budget exceeded" error so the embedder
177    /// can yield control back to its caller (short-script eval, game
178    /// frame budgets). `None` = unbounded; reset on each call via
179    /// `set_instr_budget`.
180    pub(crate) instr_budget: Option<i64>,
181    // JIT-specific state lives in the `JitState` sidecar; see `self.jit`
182    // below and `crate::vm::jit_state` for field docs.
183    /// Bytecode-loading gate. Default `true`. Sandbox embedders should
184    /// call `set_bytecode_loading(false)` so `load`/`loadstring` reject
185    /// precompiled chunks (which bypass the parser's depth / opcode
186    /// limits). When `false`, the loader rejects any source whose first
187    /// byte is the bytecode signature `\27` ("`\27Lua`").
188    pub(crate) bytecode_loading: bool,
189    /// PUC bytecode-loading gate. Default `false` — PUC `.luac` files are
190    /// a strictly larger trust surface than luna's own dump format
191    /// (third-party toolchain bugs, malformed chunks, unknown opcode
192    /// shapes). When `true`, the loader routes `\x1bLua\x{51..55}` inputs
193    /// through the per-dialect PUC translators in `crate::vm::dump::puc`.
194    /// Embedder toggles via `set_puc_bytecode_loading`.
195    pub(crate) puc_bytecode_loading: bool,
196    /// Byte budget for source fed into `load` / `loadstring` / `Vm::load`.
197    /// Default [`Vm::DEFAULT_LOADER_INPUT_BUDGET`] (256 MiB). When the
198    /// accumulated reader output (`load(f, ...)`) or a one-shot `&[u8]`
199    /// source exceeds this, the loader returns the PUC-shaped
200    /// `not enough memory` error before the host allocator is asked to
201    /// hold the next chunk. Defends against `heavy.lua::loadrep`-style
202    /// 7 GB+ feeder loops that would otherwise SIGSEGV when `Vec::push`
203    /// crosses `isize::MAX` or the host runs out of RAM.
204    /// Embedders that genuinely need to load > 256 MiB sources widen the
205    /// cap via [`Vm::set_loader_input_budget`].
206    pub(crate) loader_input_budget: usize,
207    /// In-process log of fully-emitted warnings (each entry = one flushed
208    /// message, sans the "Lua warning: " prefix and trailing newline). Lets
209    /// tests assert what was warned without scraping stderr.
210    pub(crate) warn_log: Vec<Vec<u8>>,
211    /// PUC's `LUA_REGISTRYINDEX` table — a single Lua table the debug library
212    /// exposes via `debug.getregistry`. Used to hold `_HOOKKEY` (the weak-key
213    /// table PUC's `db_sethook` keys per-thread hooks under). luna stores hook
214    /// state directly in `Vm.hook`/`Coro.hook`, so the entry is largely a
215    /// shape stub for db.lua :328; if other registry-keyed APIs land later
216    /// they can share this table.
217    pub(crate) registry: Option<Gc<Table>>,
218    /// the shared `FILE*` metatable for io file handles (PUC's LUA_FILEHANDLE
219    /// registry entry); attached to every file userdata the io library makes
220    pub(crate) file_mt: Option<Gc<Table>>,
221    /// io library default input/output streams (PUC registry IO_INPUT/IO_OUTPUT)
222    pub(crate) io_input: Option<Gc<crate::runtime::Userdata>>,
223    pub(crate) io_output: Option<Gc<crate::runtime::Userdata>>,
224    /// `io.stdin` as the io library made it, whatever the script later does
225    /// to the `io` table: the stream a host's line reads share
226    pub(crate) io_stdin: Option<Gc<crate::runtime::Userdata>>,
227    /// lua.c's `-E`: libraries opened from now on ignore the environment
228    pub(crate) ignore_env: bool,
229    /// the running thread's debug hook state (`debug.sethook`); per-thread,
230    /// swapped with the execution context on a coroutine resume/yield
231    pub(crate) hook: HookState,
232    /// true while the hook itself runs, so its own execution fires no events
233    /// (PUC clears the mask for the duration)
234    pub(crate) in_hook: bool,
235    /// arms the next Lua frame's `tailcalls` count (PUC `ci->u.l.tailcalls`),
236    /// consumed by `push_frame`. `OP_TailCall` sets it to the caller's
237    /// own tailcalls + 1 before begin_call so deeply tail-recursive chains
238    /// accumulate the count instead of capping at 1.
239    pub(crate) pending_tailcalls: u32,
240    /// arms the next Lua frame's `ccmt` (its `__call` chain length), consumed
241    /// by `push_frame`. `OP_TailCall` sets it to the reused activation's
242    /// count; `begin_call` otherwise sets the chain it just resolved.
243    pending_ccmt: u8,
244    /// Name of the C native that just propagated an error (captured before
245    /// the native is popped from `running_natives`). Lets a dying coroutine
246    /// preserve `[C]: in function '<name>'` at the top of its traceback
247    /// snapshot — PUC walks `luaG_funcnamefrompc` over a still-live ci, but
248    /// luna's native frames are off-stack so we stash the name explicitly.
249    pub(crate) errored_natives: Vec<crate::vm::callstack::ErroredNative>,
250    /// Frames below this index are out of reach of the error handler of
251    /// an `xpcall` (PUC `L->errfunc`): a protected call made from Rust — a
252    /// finalizer, the handler itself — starts a fresh `errfunc` scope.
253    pub(crate) msgh_floor: usize,
254    /// The message handler that is running, if any. PUC's `luaG_errormsg`
255    /// calls the handler with `L->errfunc` still set, so an error inside
256    /// the handler (and not caught within it) calls the handler again, at
257    /// the point of that error. Per thread, like `L->errfunc`.
258    pub(crate) msgh_running: Option<Value>,
259    /// How many message-handler runs have started; lets a run tell whether
260    /// the error it got back was already handled by a nested run.
261    pub(crate) msgh_runs: u64,
262    /// The value the last `xpcall` handler produced for the error in
263    /// flight, so the unwind that carries it to the `xpcall` does not
264    /// run the handler again.
265    pub(crate) msgh_applied: Option<Value>,
266    /// Whether an error nothing catches should keep its traceback: not
267    /// inside a protected call made from Rust, which discards it.
268    pub(crate) keep_error_traceback: bool,
269    /// PUC `CallInfo.u2.transferinfo`: index of the first transferred value
270    /// (relative to the activation's func slot) and the number transferred.
271    /// Set just before firing a call/return hook, read by `getinfo("r")`.
272    pub(crate) hook_ftransfer: u16,
273    pub(crate) hook_ntransfer: u16,
274    /// metamethod event tag (e.g. "close") to attach to the next Lua frame
275    /// pushed by `push_frame`; `close_slots` sets this before calling a
276    /// `__close` handler so `debug.traceback` names it "metamethod 'close'"
277    /// (PUC `CallInfo.u.l.tm`). Single-shot: `push_frame` consumes it.
278    pending_tm: Option<&'static str>,
279    /// `true` when the next `push_frame` is the user hook function itself,
280    /// so `debug.getinfo(1).namewhat` resolves to `"hook"` (PUC
281    /// `CIST_HOOKED`). `run_hook` arms it before dispatching the hook.
282    pending_is_hook: bool,
283    /// traceback of an error nothing in its thread catches, one line per
284    /// stack level, taken where it was raised (see `raise_to_handler`): what
285    /// the host gets from `take_error_traceback`, and what `debug.traceback`
286    /// shows of the coroutine it kills. Cleared on a catch and at host-level
287    /// `call_value` entry (`public_call_depth == 0`).
288    pub(crate) error_traceback: Option<Vec<Vec<u8>>>,
289    /// nesting depth of public `call_value` entries (host vs. internal). The
290    /// outermost entry (depth 0) resets per-error state (`error_traceback`);
291    /// internal calls (e.g. xpcall msgh, sort callback) preserve it.
292    public_call_depth: u32,
293    /// stack of native (`Value::Native`) closures currently running on the
294    /// Rust call stack. `begin_call` pushes the closure before invoking
295    /// `nc.f` and pops on return. Used by `arg_error` to detect a *nested*
296    /// native call (PUC `ar.name == NULL` at level 0 because the level-0
297    /// caller is C, not Lua) and qualify the running function's name via
298    /// `pushglobalfuncname` (e.g. `'sort'` → `'table.sort'`).
299    pub(crate) running_natives: Vec<Gc<NativeClosure>>,
300    /// Parallel to `running_natives`: where each native sits on the value
301    /// and frame stacks, so the debug interface can place it among the Lua
302    /// activations as PUC's CallInfo chain would (see `callstack`).
303    pub(crate) running_native_acts: Vec<crate::vm::callstack::NativeAct>,
304    /// Index into `running_natives` where the running thread's own natives
305    /// begin; the ones below belong to the threads that resumed it.
306    pub(crate) natives_base: usize,
307    /// JIT sidecar. Always present (never `Option`); inert
308    /// when `chunk_compiler` / `trace_compiler` are
309    /// [`crate::jit::NullJitBackend`]. See [`crate::vm::jit_state`].
310    ///
311    /// `#[doc(hidden)] pub` so the `luna` crate's
312    /// `extern "C"` JIT helpers can write `vm.jit.pending_err`
313    /// directly. Not part of the embedder-facing API surface.
314    #[doc(hidden)]
315    pub jit: crate::vm::jit_state::JitState,
316
317    /// Host roots — a `Vec<Value>` traced as an extra GC root set.
318    /// `Lua` facade handles (`LuaFunction`, `LuaTable`, `LuaRoot`) hold
319    /// indices into this vector so the underlying `Gc<T>` stays alive
320    /// across `eval` calls / yield boundaries. Freed slots are recycled
321    /// through `host_roots_free`.
322    pub(crate) host_roots: Vec<crate::vm::host_roots::HostRootSlot>,
323    /// Recycled-slot index pool. `pin_host` pops the
324    /// back if non-empty, else extends `host_roots`. Generation
325    /// overflow at `u32::MAX` retires the slot (NOT pushed here).
326    pub(crate) host_roots_free: Vec<u32>,
327
328    /// GC-rooted scratch stack for `table.sort` (and any other
329    /// builtin that needs a Rust-side `Vec<Value>` to outlive a user
330    /// callback). Each entry is one in-flight working buffer; `gc_roots`
331    /// extends with every contained `Value` so a `collectgarbage()`
332    /// inside the comparator cannot free strings/tables snapshotted
333    /// here. Nested sorts push a new buffer on entry, pop on exit
334    /// (sort.lua's `load(..)(); collectgarbage()` compare callback
335    /// regression).
336    pub(crate) sort_scratch: Vec<Vec<Value>>,
337
338    /// MacroLua compile-time macro registry.
339    /// Pre-populated with built-in macros (`@quote` / `@unquote` /
340    /// `@if` / `@gensym`) at construction time when `version ==
341    /// LuaVersion::MacroLua`; embedders register custom macros via
342    /// [`Vm::define_macro`]. The expander runs once per `load()` call
343    /// between lexing and parsing (only when `is_macro_lua()`).
344    pub(crate) macro_registry: crate::frontend::macro_expander::MacroRegistry,
345
346    /// Per-Vm cache of `Gc<Table>` metatables keyed
347    /// by `TypeId::of::<T>()` for embedder types implementing
348    /// [`crate::vm::userdata_trait::LuaUserdata`]. Populated lazily by
349    /// [`Vm::register_userdata`]; metatables are pinned via
350    /// [`Vm::pin_host`] at registration time so the entry's
351    /// `Gc<Table>` stays live for the rest of the Vm's lifetime.
352    pub(crate) userdata_metatables:
353        std::collections::HashMap<std::any::TypeId, Gc<crate::runtime::table::Table>>,
354
355    /// Classification of the most recent error raised on this Vm.
356    /// Embedders read via [`Vm::error_kind`]; the dispatcher sets it
357    /// at well-known sites (syntax errors, instr-budget trips, native
358    /// callback errors, type errors).
359    pub(crate) last_error_kind: crate::vm::error::LuaErrorKind,
360
361    /// `(source_name, line)` of the most recent error. Set by the
362    /// dispatcher / lexer / parser; cleared when a new call_value
363    /// enters cleanly.
364    pub(crate) last_error_source: Option<(String, u32)>,
365
366    /// When `true`, `instr_budget` exhaustion in
367    /// the dispatcher hot loop yields cooperatively (sets
368    /// [`Vm::host_yield_pending`] + returns a sentinel `Err` walked up
369    /// to `EvalFuture::poll`) instead of returning a real
370    /// "instruction budget exceeded" error. Set by [`Vm::eval_async`]
371    /// for the duration of the future; restored to `false` on
372    /// `Poll::Ready`. The sync `Vm::eval` / `Vm::call_value` paths
373    /// leave it `false` so budget exhaustion stays a real error there.
374    pub(crate) async_mode: bool,
375
376    /// Host waker cloned by `EvalFuture::poll` before driving a slice.
377    /// The dispatcher itself does not call it (the future's poll loop
378    /// does `wake_by_ref` after observing `BudgetExhausted`); it is kept
379    /// so async natives can wake the host directly from a helper future.
380    pub(crate) async_waker: Option<std::task::Waker>,
381
382    /// Per-poll opcode quota loaded into
383    /// `instr_budget` at the start of each `EvalFuture::poll` slice.
384    /// Default 10_000. Tunable via
385    /// [`Vm::set_async_slice`].
386    pub(crate) async_slice_size: i64,
387
388    /// Set by the dispatcher when an async-mode
389    /// budget exhaustion fires; checked by `exec_with` (so the
390    /// sentinel propagates without `unwind` running, mirroring
391    /// `yielding.is_some()`) and by `call_value_impl` (so the call
392    /// frames survive for the next poll). Cleared by `drive_one`
393    /// after translating it to `DispatchOutcome::BudgetExhausted`.
394    pub(crate) host_yield_pending: bool,
395
396    /// Set by the dispatcher's native-call path
397    /// when an async-marked [`NativeClosure`] is invoked under
398    /// `async_mode`. The Vm pauses the dispatcher (same sentinel-Err
399    /// mechanism as `host_yield_pending` — see `exec_with` +
400    /// `call_value_impl`), stashes the in-flight future +
401    /// post-completion context here, and surfaces them to
402    /// `EvalFuture::poll` via `drive_one`. Cleared by `drive_one`
403    /// once the future is moved out into a
404    /// `DispatchOutcome::AsyncNativeAwaiting`.
405    pub(crate) pending_async_native_fut:
406        Option<std::pin::Pin<Box<dyn std::future::Future<Output = Result<u32, LuaError>>>>>,
407
408    /// Companion to `pending_async_native_fut`:
409    /// the `(func_slot, nargs, nresults, gc_top)` quad needed to
410    /// commit the future's eventual `Ok(nret)` back into the calling
411    /// frame's expected result slots. Recorded by the dispatcher;
412    /// consumed by [`Vm::commit_async_native_result`] after the
413    /// future resolves.
414    pub(crate) pending_async_native_ctx: Option<AsyncNativeCallCtx>,
415
416    /// Identifies this Vm to the JIT storages it compiles through
417    /// ([`crate::jit::JitStorage::claim`]).
418    jit_owner_id: u64,
419    /// Storages [`Vm::install_jit_storage`] replaced: code compiled into
420    /// them may still be referenced by this Vm's functions, so they live
421    /// as long as the Vm.
422    retired_jit_storage: Vec<Box<dyn crate::jit::JitStorage>>,
423}
424
425/// Call-site context an in-flight async native
426/// needs preserved across the cooperative-yield boundary.
427///
428/// The dispatcher records this when it routes a `NativeClosure` with
429/// `is_async == true` through the cooperative path; `EvalFuture::poll`
430/// hands it back to [`Vm::commit_async_native_result`] once the
431/// awaited future resolves so `finish_results` (and the post-call GC
432/// checkpoint) can run as if the native had completed synchronously.
433#[derive(Clone, Copy)]
434pub(crate) struct AsyncNativeCallCtx {
435    pub func_slot: u32,
436    /// Recorded for parity with the sync native-call path's
437    /// `native_nresults`/`gc_top` bookkeeping; reserved for hook
438    /// firing + traceback shaping. Not read yet.
439    #[allow(dead_code)]
440    pub nargs: u32,
441    pub nresults: i32,
442    /// Recorded for traceback + GC-root-window checks. The resume path
443    /// reads `Vm.gc_top` directly, so this is unread today; carried so a
444    /// check can confirm the pre-suspend root window matches the
445    /// post-resume one.
446    #[allow(dead_code)]
447    pub gc_top: u32,
448}
449
450/// Per-thread debug hook state (PUC `lua_State` hook/hookmask/basehookcount/
451/// hookcount). `func` is the Lua hook; the booleans are the PUC mask bits.
452#[derive(Clone, Copy, Default)]
453pub struct HookState {
454    /// the hook function (`None` when no hook is installed)
455    pub func: Option<Value>,
456    /// Rust-side debug hook. Fires alongside the Lua hook
457    /// (Rust first); both can be installed simultaneously, but most
458    /// embedders pick one.
459    pub rust_func: Option<RustDebugHook>,
460    /// LUA_MASKCALL — fire on function entry
461    pub call: bool,
462    /// LUA_MASKRET — fire on function return
463    pub ret: bool,
464    /// LUA_MASKLINE — fire on source-line change
465    pub line: bool,
466    /// LUA_MASKCOUNT — fire every `count_base` instructions
467    pub count: bool,
468    /// instruction count between count events (PUC basehookcount)
469    pub count_base: i64,
470    /// instructions left until the next count event (PUC hookcount)
471    pub count_left: i64,
472}
473
474/// Rust-side debug hook callback. Receives the `Vm` plus a
475/// classified event. The callback runs synchronously in the
476/// dispatcher; the hook flag (`in_hook`) is set for its duration so
477/// hook recursion is suppressed.
478pub type RustDebugHook = fn(&mut Vm, RustHookEvent);
479
480/// Classified debug event delivered to a [`RustDebugHook`].
481#[derive(Clone, Copy, Debug, PartialEq, Eq)]
482pub enum RustHookEvent {
483    /// Function entry (`hook_call` analogue).
484    Call,
485    /// Function return (`hook_return` analogue).
486    Return,
487    /// Tail call entry (PUC 5.2+ separates this from a plain Call).
488    TailCall,
489    /// Source-line change (the `u32` is the 1-based line number).
490    Line(u32),
491    /// Instruction count event (fires every `count_base` instructions).
492    Count,
493}
494
495/// Mask flags for [`Vm::set_rust_debug_hook`]. OR these to subscribe
496/// to multiple event categories with a single hook installation.
497pub const HOOK_MASK_CALL: u32 = 1;
498/// Subscribe to function-return events.
499pub const HOOK_MASK_RETURN: u32 = 2;
500/// Subscribe to line-change events.
501pub const HOOK_MASK_LINE: u32 = 4;
502/// Subscribe to instruction-count events.
503pub const HOOK_MASK_COUNT: u32 = 8;
504
505/// A thread's swapped-out execution context (PUC per-thread stack state).
506struct SavedCtx {
507    stack: Vec<Value>,
508    frames: Vec<CallFrame>,
509    frame_ccmt: Vec<u8>,
510    open_upvals: Vec<(u32, Gc<Upvalue>)>,
511    tbc: Vec<u32>,
512    top: u32,
513    pcall_depth: u32,
514    hook: HookState,
515    /// PUC `L->l_gt` — the thread's own globals table. Carried alongside
516    /// the rest of the suspended state so each thread can keep its own
517    /// `setfenv(0, env)` rewire without the swap leaking into another
518    /// thread (5.1 closure.lua :177).
519    globals: Gc<Table>,
520}
521
522/// Outcome of unwinding the call stack on an error (see `Vm::unwind`).
523enum Unwound {
524    /// caught by a pcall/xpcall continuation; resume running its caller
525    Caught,
526    /// caught by a continuation that was the entry-level activation; these are
527    /// the call's (wrapped) results
528    CaughtReturn(Vec<Value>),
529    /// no protecting continuation up to `entry_depth`; propagate the error
530    Propagated(LuaError),
531}
532
533/// Outcome of an index/newindex/comparison fast path: either a directly
534/// computed result, or a metamethod (with the receiver it resolved against) the
535/// caller must invoke — synchronously (C context) or yieldably (VM opcode).
536enum MmOut {
537    /// index → the looked-up value; newindex → done (raw set performed);
538    /// comparison → the boolean result already known
539    Done(Value),
540    /// a metamethod to call; `recv` is the chain element it was found on (the
541    /// extra args — key / value — are supplied by the caller)
542    Mm { func: Value, recv: Value },
543    /// ≤5.3 `a <= b` synthesised via `not __lt(b, a)` when neither operand
544    /// carries `__le` — `op_compare` swaps the args and negates the result.
545    /// Lives separate from `Mm` so the synth path can stay yieldable without
546    /// every other Mm caller learning a swap flag they would never set.
547    CompareSynth { func: Value },
548}
549
550/// Metamethod events; discriminants index `Vm::mm_names`.
551#[derive(Clone, Copy, PartialEq, Eq)]
552#[repr(usize)]
553pub(crate) enum Mm {
554    Index,
555    NewIndex,
556    Call,
557    ToString,
558    Metatable,
559    Name,
560    Eq,
561    Lt,
562    Le,
563    Concat,
564    Len,
565    Add,
566    Sub,
567    Mul,
568    Div,
569    Mod,
570    Pow,
571    IDiv,
572    BAnd,
573    BOr,
574    BXor,
575    Shl,
576    Shr,
577    Unm,
578    BNot,
579    Close,
580    Gc,
581    Pairs,
582}
583
584const MM_NAMES: [&str; 28] = [
585    "__index",
586    "__newindex",
587    "__call",
588    "__tostring",
589    "__metatable",
590    "__name",
591    "__eq",
592    "__lt",
593    "__le",
594    "__concat",
595    "__len",
596    "__add",
597    "__sub",
598    "__mul",
599    "__div",
600    "__mod",
601    "__pow",
602    "__idiv",
603    "__band",
604    "__bor",
605    "__bxor",
606    "__shl",
607    "__shr",
608    "__unm",
609    "__bnot",
610    "__close",
611    "__gc",
612    "__pairs",
613];
614
615/// The metamethod event an opcode dispatches, without the `__` prefix (PUC
616/// funcnamefromcode), for "(metamethod 'event')" call-error suffixes.
617fn mm_event_name(op: crate::vm::isa::Op) -> Option<&'static str> {
618    use crate::vm::isa::Op;
619    Some(match op {
620        Op::Add => "add",
621        Op::Sub => "sub",
622        Op::Mul => "mul",
623        Op::Div => "div",
624        Op::Mod => "mod",
625        Op::Pow => "pow",
626        Op::IDiv => "idiv",
627        Op::BAnd => "band",
628        Op::BOr => "bor",
629        Op::BXor => "bxor",
630        Op::Shl => "shl",
631        Op::Shr => "shr",
632        Op::Unm => "unm",
633        Op::BNot => "bnot",
634        Op::Concat => "concat",
635        Op::Len => "len",
636        Op::GetField | Op::GetTable | Op::GetI | Op::SelfOp => "index",
637        Op::SetField | Op::SetTable | Op::SetI => "newindex",
638        Op::Eq | Op::EqK => "eq",
639        Op::Lt => "lt",
640        Op::Le => "le",
641        _ => return None,
642    })
643}
644
645/// PUC MAXTAGLOOP (5.3+): bound on `__index`/`__newindex` chains.
646const MAX_TAG_LOOP: u32 = 2000;
647/// PUC `MAXCCMT`: bound on a `__call` metamethod chain (lvm.c). 200 chains
648/// is more than any reasonable program needs and matches PUC 5.4/5.5; a
649/// bound of `15` is tight enough to fire on calls.lua :194 (N=20).
650const MAX_CCMT: u32 = 200;
651/// PUC LUAI_MAXCCALLS analogue: native↔Lua nesting bound.
652pub(crate) const MAX_C_DEPTH: u32 = 200;
653/// Stack an xpcall handler may use past `MAX_LUA_STACK` while handling a
654/// stack overflow: PUC's 200 extra `ERRORSTACKSIZE` slots, plus the frame
655/// reserve (256) the overflowing call was refused, so the handler's first
656/// frame fits where that one did not.
657const ERROR_STACK_EXTRA: u32 = 200 + 256;
658/// luna's engine-level VM stack cap (used by call-site overflow checks).
659/// Slightly larger than PUC's `LUAI_MAXSTACK` so engine internals have a
660/// little headroom above any single library push.
661const MAX_LUA_STACK: u32 = 1 << 20;
662/// PUC `LUAI_MAXSTACK` (`luaconf.h`): the cap library code consults via
663/// `lua_checkstack` to refuse multi-value pushes (`table.unpack` returning
664/// N values, `string.pack` results, etc.). 5.3 coroutine.lua :530 pins
665/// this at one million — `for j in {lim-10, …}` expects every j ≥ lim-10
666/// to fail because the few slots already consumed in the coroutine push
667/// the effective cap below lim-10.
668const PUC_MAXSTACK: i64 = 1_000_000;
669
670/// PUC 5.4+ default warnf state. The base library's `warn` function flips
671/// between `Off` and `On` via the `@on` / `@off` control messages; any other
672/// `@<word>` control is silently ignored, mirroring `lauxlib.c::checkcontrol`.
673#[derive(Clone, Copy, PartialEq, Eq, Debug)]
674pub enum WarnState {
675    /// `warn` calls are silently dropped (default after `warn("@off")`).
676    Off,
677    /// `warn` calls are delivered to stderr (after `warn("@on")`).
678    On,
679}
680
681/// Best-effort extraction of a textual message from a `catch_unwind` payload.
682/// `panic!("msg")` arrives as `String`, `panic!(static)` as `&str`; anything
683/// else degrades to `"<non-string panic>"`. Used by the native-call
684/// catch_unwind to fold the panic into a Lua error.
685fn panic_payload_str(payload: &Box<dyn std::any::Any + Send>) -> String {
686    if let Some(s) = payload.downcast_ref::<String>() {
687        return s.clone();
688    }
689    if let Some(s) = payload.downcast_ref::<&'static str>() {
690        return (*s).to_string();
691    }
692    "<non-string panic>".to_string()
693}
694
695/// Combined error type returned by [`Vm::eval`] and friends — either the
696/// chunk failed to parse / compile, or it raised at runtime.
697#[derive(Debug)]
698pub enum Error {
699    /// Parse or compile failure.
700    Syntax(SyntaxError),
701    /// Runtime error raised during execution.
702    Runtime(LuaError),
703}
704
705impl From<SyntaxError> for Error {
706    fn from(e: SyntaxError) -> Error {
707        Error::Syntax(e)
708    }
709}
710
711impl From<LuaError> for Error {
712    fn from(e: LuaError) -> Error {
713        Error::Runtime(e)
714    }
715}
716
717impl Vm {
718    /// `lua_close` from inside a running script (`os.exit(code, true)`):
719    /// close the main thread's pending to-be-closed variables, then run every
720    /// finalizer. Both run protected, so their errors are dropped as PUC's
721    /// `close_state` drops them. Inside a coroutine the main thread's stack is
722    /// parked, and only the finalizers run.
723    pub(crate) fn close_state(&mut self) {
724        if self.current.is_none() {
725            let _ = self.close_slots(0, None);
726        }
727        self.heap.queue_all_finalizers();
728        self.run_finalizers();
729    }
730}
731
732impl Drop for Vm {
733    fn drop(&mut self) {
734        // state close: run `__gc` for every still-registered finalizable before
735        // the heap frees them (PUC separatetobefnz(g,1) + callallpending). A
736        // single pass — objects created by a closing finalizer are not
737        // re-finalized (they go to the heap's free list directly).
738        self.heap.queue_all_finalizers();
739        self.run_finalizers();
740        let id = self.jit_owner_id;
741        // SAFETY: the finalizers were the last Lua code this Vm runs, and
742        // its functions (the only holders of entry points compiled for it)
743        // go away with its heap.
744        unsafe {
745            self.jit.storage.release_code(id);
746            for s in &mut self.retired_jit_storage {
747                s.release_code(id);
748            }
749        }
750    }
751}
752
753// Split-borrow free fn helpers for frames push/pop with shadow counter
754// `frames_top: u32`. Free fns (not Vm methods) so callers can pass
755// `&mut self.frames` + `&mut self.frames_top` as split borrows, allowing
756// other `&mut self.field` reads inside the CallFrame construction (e.g.
757// `std::mem::take(&mut self.pending_tm)`).
758//
759// The shadow has no readers yet; it just stays in sync + asserts.
760#[inline(always)]
761fn frames_push_sync(frames: &mut Vec<CallFrame>, frames_top: &mut u32, cf: CallFrame) {
762    frames.push(cf);
763    // Shadow maintenance is debug-only: release builds skip the
764    // increment + assertion entirely. While nothing reads the shadow,
765    // its purpose is to VERIFY the assumed invariant
766    // (frames_top == frames.len()) across all push/pop sites; once readers
767    // consume it, release must run the increment unconditionally.
768    #[cfg(debug_assertions)]
769    {
770        *frames_top += 1;
771        debug_assert_eq!(
772            *frames_top as usize,
773            frames.len(),
774            "P17-D frames_top out of sync after push",
775        );
776    }
777    #[cfg(not(debug_assertions))]
778    let _ = frames_top;
779}
780
781#[inline(always)]
782fn frames_pop_sync(frames: &mut Vec<CallFrame>, frames_top: &mut u32) -> Option<CallFrame> {
783    let r = frames.pop();
784    #[cfg(debug_assertions)]
785    {
786        if r.is_some() {
787            *frames_top = frames_top.saturating_sub(1);
788        }
789        debug_assert_eq!(
790            *frames_top as usize,
791            frames.len(),
792            "P17-D frames_top out of sync after pop",
793        );
794    }
795    #[cfg(not(debug_assertions))]
796    let _ = frames_top;
797    r
798}
799
800/// One-time env-var read for
801/// `LUNA_AOT_PROBE`. Returns `true` iff the env var is set to any
802/// non-empty value. The result is cached in a `OnceLock` so the
803/// dispatcher's hot path pays a single atomic load per process. Off
804/// by default — production deploys don't bleed diagnostic prints.
805fn jit_probe_enabled() -> bool {
806    static PROBE_ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
807    *PROBE_ON.get_or_init(|| {
808        std::env::var("LUNA_AOT_PROBE")
809            .ok()
810            .filter(|v| !v.is_empty())
811            .is_some()
812    })
813}
814
815impl Vm {
816    /// Re-sync `frames_top` after a bulk `frames: Vec`
817    /// swap (take_ctx, put_ctx, load_coro_ctx). Must be called after
818    /// the Vec replacement to keep the shadow valid.
819    #[inline(always)]
820    fn frames_resync(&mut self) {
821        // Debug-only — see `frames_push_sync` comment.
822        #[cfg(debug_assertions)]
823        {
824            self.frames_top = self.frames.len() as u32;
825        }
826    }
827
828    // ====================================================================
829    // Stack-inline frame metadata accessors (unused).
830    //
831    // These methods read/write the LJ_FR2 marker slots at `stack[base-2]`
832    // (closure GCRef) and `stack[base-1]` (FrameMarker as i64). No call
833    // site uses them yet.
834    //
835    // Preconditions (debug-asserted):
836    // - base >= 2 (slots base-2 and base-1 must exist below the frame)
837    // - self.stack.len() > base + max_stack (caller has grown stack)
838    // - For Lua frames, stack[base-2] holds Value::Closure(cl)
839    // - For Lua frames, stack[base-1] holds Value::Int(marker.to_raw())
840    //
841    // No release-build cost when unused (LTO strips dead methods).
842    // ====================================================================
843
844    /// Write a Lua frame's closure pointer into `stack[base-2]`.
845    /// The caller must ensure `base >= 2` and the slot is within the
846    /// stack's allocated range.
847    #[inline]
848    #[allow(dead_code)] // no consumer yet
849    fn write_frame_closure(&mut self, base: u32, cl: crate::runtime::Gc<LuaClosure>) {
850        debug_assert!(
851            base >= 2,
852            "frame closure slot needs base >= 2; got {}",
853            base
854        );
855        let idx = (base - 2) as usize;
856        debug_assert!(idx < self.stack.len(), "stack[base-2] out of range");
857        self.stack[idx] = Value::Closure(cl);
858    }
859
860    /// Read a Lua frame's closure pointer from `stack[base-2]`.
861    /// Returns `None` if the slot doesn't hold a closure (caller is
862    /// expected to treat that as a corrupt frame).
863    ///
864    /// Uses the [`Value::tag_byte`] fast-path
865    /// to avoid the enum-match cost on the hot path. Tag check via
866    /// 1-byte load + branch + `as_closure_unchecked` payload load.
867    #[inline]
868    #[allow(dead_code)]
869    fn read_frame_closure(&self, base: u32) -> Option<crate::runtime::Gc<LuaClosure>> {
870        debug_assert!(base >= 2);
871        let v = self.stack.get((base - 2) as usize)?;
872        if v.tag_byte() == crate::runtime::value::tag::CLOSURE {
873            // SAFETY: tag byte just verified == CLOSURE.
874            Some(unsafe { v.as_closure_unchecked() })
875        } else {
876            None
877        }
878    }
879
880    /// Write a packed [`FrameMarker`] into `stack[base-1]`. The marker
881    /// encodes the frame kind (Lua / Cont) + PC-or-delta payload.
882    /// Stored as `Value::Int(marker.to_raw())` so it round-trips
883    /// cleanly through the value stack without losing bits.
884    #[inline]
885    #[allow(dead_code)]
886    fn write_frame_marker(&mut self, base: u32, marker: crate::runtime::frame_marker::FrameMarker) {
887        debug_assert!(base >= 1, "frame marker slot needs base >= 1; got {}", base);
888        let idx = (base - 1) as usize;
889        debug_assert!(idx < self.stack.len(), "stack[base-1] out of range");
890        self.stack[idx] = Value::Int(marker.to_raw());
891    }
892
893    /// Read a packed [`FrameMarker`] from `stack[base-1]`. Returns
894    /// `None` if the slot isn't a `Value::Int` (caller treats as a
895    /// corrupt frame); the kind tag itself may still be invalid, in
896    /// which case [`FrameMarker::kind`] returns `None` on the result.
897    ///
898    /// Uses the [`Value::tag_byte`] fast-path
899    /// for the tag check + `as_int_unchecked` for the payload load.
900    #[inline]
901    #[allow(dead_code)]
902    fn read_frame_marker(&self, base: u32) -> Option<crate::runtime::frame_marker::FrameMarker> {
903        debug_assert!(base >= 1);
904        let v = self.stack.get((base - 1) as usize)?;
905        if v.tag_byte() == crate::runtime::value::tag::INT {
906            // SAFETY: tag byte just verified == INT.
907            Some(crate::runtime::frame_marker::FrameMarker::from_raw(
908                unsafe { v.as_int_unchecked() },
909            ))
910        } else {
911            None
912        }
913    }
914
915    /// Build the raw `Vm` struct without main coroutine / RNG seed / library
916    /// setup. Private helper shared by `Vm::new` and `Vm::new_minimal`; the
917    /// caller is responsible for the rest of the bring-up.
918    fn new_inner(version: LuaVersion) -> Vm {
919        let mut heap = Heap::new();
920        // PUC 5.1 had no ephemeron pass — `__mode='k'` tables marked their
921        // values strongly. gc.lua's "weak tables" section relies on that.
922        heap.no_ephemeron = version <= LuaVersion::Lua51;
923        // PUC 5.3 needs two GC cycles to finalize a table caught in a
924        // coroutine reference cycle (gc.lua :502); 5.4+ rewrote the GC and
925        // finalize in a single cycle (5.4/5.5 gc.lua :544 assert exactly one).
926        heap.defer_thread_cycle_finalize = version == LuaVersion::Lua53;
927        let globals = heap.new_table();
928        let mm_names = MM_NAMES.iter().map(|n| heap.intern(n.as_bytes())).collect();
929
930        Vm {
931            heap,
932            stack: Vec::new(),
933            frames: Vec::new(),
934            frame_ccmt: Vec::new(),
935            frames_top: 0,
936            open_upvals: Vec::new(),
937            tbc: Vec::new(),
938            top: 0,
939            globals,
940            type_mt: [None; 5],
941            mm_names,
942            c_depth: 0,
943            pcall_depth: 0,
944            nny: 0,
945            msgh_depth: 0,
946            terminating: None,
947            rng: [0; 4],
948            started: std::time::Instant::now(),
949            version,
950            closing_err: None,
951            current: None,
952            main_ctx: None,
953            yielding: None,
954            native_nresults: -1,
955            main_coro: None,
956            // PUC 5.4+ boots in GENERATIONAL mode (the first
957            // `collectgarbage("generational")` reports "generational"
958            // as the previous mode on stock lua5.4;
959            // 5.5 behaves the same, probed against lua5.5). luna's
960            // collector is a single incremental engine either way;
961            // this field is the MODE REPORT the stdlib exposes.
962            gc_mode: if version >= crate::version::LuaVersion::Lua54 {
963                "generational"
964            } else {
965                "incremental"
966            },
967            gc_top: 0,
968            gc_pause: 200,
969            gc_stepmul: 100,
970            gc_stepsize: 13,
971            gc_params: crate::vm::lib_gc::GcParams::new(version),
972            gc_finalizing: false,
973            capi_stack: Vec::new(),
974            capi_cstr_pin: None,
975            warn_state: WarnState::Off,
976            warn_buf: Vec::new(),
977            warn_log: Vec::new(),
978            instr_budget: None,
979            bytecode_loading: true,
980            puc_bytecode_loading: false,
981            loader_input_budget: Vm::DEFAULT_LOADER_INPUT_BUDGET,
982            registry: None,
983            file_mt: None,
984            io_input: None,
985            io_output: None,
986            io_stdin: None,
987            ignore_env: false,
988            hook: HookState::default(),
989            in_hook: false,
990            pending_tailcalls: 0,
991            pending_ccmt: 0,
992            errored_natives: Vec::new(),
993            msgh_floor: 0,
994            msgh_running: None,
995            msgh_runs: 0,
996            msgh_applied: None,
997            keep_error_traceback: true,
998            hook_ftransfer: 0,
999            hook_ntransfer: 0,
1000            pending_tm: None,
1001            pending_is_hook: false,
1002            error_traceback: None,
1003            public_call_depth: 0,
1004            running_natives: Vec::new(),
1005            running_native_acts: Vec::new(),
1006            natives_base: 0,
1007            // JIT-specific state lives in the `JitState`
1008            // sidecar. The `luna` crate's `Vm::new_minimal_with_jit` /
1009            // `install_jit_backend` / `luaL_newstate` swap in
1010            // `CraneliftBackend` for callers that want JIT acceleration.
1011            jit: crate::vm::jit_state::JitState::with_null_backend(),
1012            // host roots ticket pool for the `Lua` facade
1013            host_roots: Vec::new(),
1014            // MacroLua registry. Pre-populated with
1015            // built-ins (`@quote` / `@unquote` / `@if` / `@gensym`)
1016            // when this Vm is constructed under `LuaVersion::MacroLua`.
1017            macro_registry: if version == LuaVersion::MacroLua {
1018                crate::frontend::macro_expander::MacroRegistry::with_builtins()
1019            } else {
1020                crate::frontend::macro_expander::MacroRegistry::new()
1021            },
1022            host_roots_free: Vec::new(),
1023            sort_scratch: Vec::new(),
1024            // LuaUserdata trait sugar's per-Vm
1025            // metatable cache. Populated lazily by register_userdata.
1026            userdata_metatables: std::collections::HashMap::new(),
1027            // Error classification metadata. Defaults to
1028            // Runtime; set at known sites (syntax / budget trip /
1029            // native error / type error).
1030            last_error_kind: crate::vm::error::LuaErrorKind::default(),
1031            last_error_source: None,
1032            // Async embedder fields. Defaults preserve sync behavior
1033            // bit-for-bit (`async_mode = false` means the budget hot loop
1034            // errors out instead of yielding).
1035            async_mode: false,
1036            async_waker: None,
1037            async_slice_size: 10_000,
1038            host_yield_pending: false,
1039            // Pending async-native state. Empty by
1040            // default; populated only by the dispatcher when an
1041            // async-marked NativeClosure is invoked under async_mode.
1042            pending_async_native_fut: None,
1043            pending_async_native_ctx: None,
1044            jit_owner_id: {
1045                static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
1046                NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
1047            },
1048            retired_jit_storage: Vec::new(),
1049        }
1050    }
1051
1052    /// Build a fully-loaded Vm — the default for embedders that want PUC's
1053    /// standard library surface. Equivalent to `Vm::new_minimal(version)`
1054    /// followed by `vm.open_all_libs()`.
1055    pub fn new(version: LuaVersion) -> Vm {
1056        let mut vm = Vm::new_minimal(version);
1057        vm.open_all_libs();
1058        vm
1059    }
1060
1061    /// Build a Vm with no standard libraries loaded. Embedders
1062    /// that want a sandbox (Redis-style scripts, in-game scripting with
1063    /// a curated API) call this and then `open_base` / `open_math` / etc.
1064    /// selectively. The Vm is otherwise fully initialized (main coroutine,
1065    /// RNG seed, GC) so `eval` and `call_value` are immediately usable.
1066    pub fn new_minimal(version: LuaVersion) -> Vm {
1067        let mut vm = Vm::new_inner(version);
1068        let mc = vm.heap.new_coro(Value::Nil, vm.globals);
1069        // 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).
1070        unsafe { mc.as_mut() }.status = CoroStatus::Running;
1071        vm.main_coro = Some(mc);
1072        let (a, b) = vm.rng_auto_seed();
1073        vm.rng_seed(a as u64, b as u64);
1074        vm
1075    }
1076
1077    /// Install a caller-supplied JIT backend. The
1078    /// `luna` crate uses this to swap in its `CraneliftBackend`; tests
1079    /// or third-party backends pass their own [`crate::jit::IntChunkCompiler`] /
1080    /// [`crate::jit::TraceCompiler`] implementations. Re-installing on a Vm whose
1081    /// closures already populated `Proto.jit: JitProtoState::Compiled`
1082    /// does NOT evict those cached entries — call right after
1083    /// construction for a clean swap.
1084    ///
1085    /// Naming: `install_jit_backend` (not `install_default_jit`)
1086    /// because the "default" in luna-core is `NullJitBackend`; the
1087    /// "default JIT" lives in the `luna` crate.
1088    pub fn install_jit_backend<C, T>(&mut self, chunk: C, trace: T)
1089    where
1090        C: crate::jit::IntChunkCompiler + 'static,
1091        T: crate::jit::TraceCompiler + 'static,
1092    {
1093        self.jit.chunk_compiler = Box::new(chunk);
1094        self.jit.trace_compiler = Box::new(trace);
1095    }
1096
1097    /// Install a caller-supplied JIT
1098    /// storage holder. Default is [`crate::jit::NullJitStorage`];
1099    /// the `luna_jit` crate's `install_default_jit` pairs this with
1100    /// `install_jit_backend(CraneliftBackend, CraneliftBackend)` to
1101    /// also install a fresh `CraneliftJitStorage`. Storage holds
1102    /// the per-`Vm` JIT cache + handle collections.
1103    ///
1104    /// The storage it replaces is kept until the Vm drops: functions
1105    /// compiled through it may still be called.
1106    pub fn install_jit_storage<S>(&mut self, storage: S)
1107    where
1108        S: crate::jit::JitStorage + 'static,
1109    {
1110        let old = std::mem::replace(&mut self.jit.storage, Box::new(storage));
1111        self.retired_jit_storage.push(old);
1112    }
1113
1114    /// Install the no-op JIT backend. `try_compile`
1115    /// reports "skipped" so every closure stays on the interpreter
1116    /// path, and the trace recorder's compile attempt always returns
1117    /// `None`. Intended for tests that want to verify the trait
1118    /// boundary works in a JIT-free configuration.
1119    ///
1120    /// Calling this on a Vm whose closures already populated
1121    /// `Proto.jit: JitProtoState::Compiled` does NOT evict those
1122    /// cached entries — the dispatcher will still call into them. For
1123    /// a truly JIT-free run, call this immediately after construction.
1124    pub fn install_null_jit(&mut self) {
1125        self.jit.chunk_compiler = Box::new(crate::jit::NullJitBackend);
1126        self.jit.trace_compiler = Box::new(crate::jit::NullJitBackend);
1127    }
1128
1129    /// Open the entire 5.5 standard library on a `new_minimal`-built Vm.
1130    /// `Vm::new` calls this; sandboxed embedders open libraries one at a
1131    /// time instead (`open_base`, `open_math`, `open_table`, …).
1132    pub fn open_all_libs(&mut self) {
1133        self.open_base();
1134        self.open_math();
1135        self.open_table();
1136        self.open_string();
1137        self.open_utf8();
1138        self.open_os_io();
1139        self.open_debug();
1140        self.open_coroutine();
1141        // PUC 5.2 introduced `bit32`; 5.3 retired it in the manual BUT
1142        // the stock 5.3 build ships -DLUA_COMPAT_5_2, which keeps the
1143        // library loaded. The diff ground truth is the default build
1144        // (stock lua5.3), so expose it under 5.2 AND
1145        // 5.3; 5.4 dropped the compat default for real.
1146        if matches!(self.version, LuaVersion::Lua52 | LuaVersion::Lua53) {
1147            self.open_bit32();
1148        }
1149        // last, so `package.loaded` lists every library opened before it
1150        self.open_package();
1151    }
1152
1153    /// Install the base library (`print`, `type`, `pairs`, `tostring`,
1154    /// `pcall`, `error`, `assert`, `select`, `setmetatable`, `getmetatable`,
1155    /// `rawequal`, `rawget`, `rawset`, `rawlen`, `next`, `tonumber`,
1156    /// `collectgarbage`, `warn` on 5.4+, `_VERSION`, `_G`, plus 5.1's
1157    /// retired globals `unpack`, `loadstring`, `setfenv`, `getfenv`,
1158    /// `newproxy`, `gcinfo` when version == 5.1). Safe to call at most
1159    /// once per Vm.
1160    pub fn open_base(&mut self) {
1161        crate::vm::builtins::open_base(self);
1162    }
1163    /// Install the `math` standard library.
1164    pub fn open_math(&mut self) {
1165        crate::vm::lib_math::open_math(self);
1166    }
1167    /// Install the `table` standard library.
1168    pub fn open_table(&mut self) {
1169        crate::vm::lib_table::open_table(self);
1170    }
1171    /// Install the `string` standard library (and the shared string metatable).
1172    pub fn open_string(&mut self) {
1173        crate::vm::lib_string::open_string(self);
1174    }
1175    /// Install the `utf8` standard library (5.3+).
1176    pub fn open_utf8(&mut self) {
1177        crate::vm::lib_utf8::open_utf8(self);
1178    }
1179    /// `os` and `io` are merged because file userdata shares state with both
1180    /// (`io.tmpname` and `os.tmpname` are the same function, `io.popen`
1181    /// wraps `os.execute`'s shell).
1182    pub fn open_os_io(&mut self) {
1183        crate::vm::lib_os_io::open_os_io(self);
1184    }
1185    /// Install the `debug` standard library (introspection / hooks). Off by
1186    /// default for sandbox embedders.
1187    pub fn open_debug(&mut self) {
1188        crate::vm::lib_debug::open_debug(self);
1189    }
1190    /// Install the `coroutine` standard library.
1191    pub fn open_coroutine(&mut self) {
1192        crate::vm::lib_coroutine::open_coroutine(self);
1193    }
1194    /// `package` plus the 5.1-only `module` and `package.seeall` aliases.
1195    pub fn open_package(&mut self) {
1196        crate::vm::lib_package::open_package(self);
1197    }
1198    /// 5.2-only `bit32` library (5.3+ retired in favour of native bitwise
1199    /// ops on 64-bit integers).
1200    pub fn open_bit32(&mut self) {
1201        crate::vm::lib_bit32::open_bit32(self);
1202    }
1203
1204    /// xoshiro256** next.
1205    pub(crate) fn rng_next(&mut self) -> u64 {
1206        let s = &mut self.rng;
1207        let result = s[1].wrapping_mul(5).rotate_left(7).wrapping_mul(9);
1208        let t = s[1] << 17;
1209        s[2] ^= s[0];
1210        s[3] ^= s[1];
1211        s[1] ^= s[2];
1212        s[0] ^= s[3];
1213        s[2] ^= t;
1214        s[3] = s[3].rotate_left(45);
1215        result
1216    }
1217
1218    /// Seed the RNG via splitmix64 expansion (PUC randseed shape).
1219    pub(crate) fn rng_seed(&mut self, a: u64, b: u64) {
1220        // PUC setseed: state = [n1, 0xff, n2, 0] (0xff avoids an all-zero
1221        // state), then 16 discards to spread the seed. Matches PUC's exact
1222        // sequence so the low-level conformance test passes.
1223        self.rng = [a, 0xff, b, 0];
1224        for _ in 0..16 {
1225            self.rng_next();
1226        }
1227    }
1228
1229    /// Wall-clock since VM creation (os.clock approximation).
1230    pub(crate) fn uptime(&self) -> std::time::Duration {
1231        self.started.elapsed()
1232    }
1233
1234    /// Entropy for math.randomseed() with no arguments.
1235    pub(crate) fn rng_auto_seed(&mut self) -> (i64, i64) {
1236        let t = std::time::SystemTime::now()
1237            .duration_since(std::time::UNIX_EPOCH)
1238            .map(|d| d.as_nanos() as u64)
1239            .unwrap_or(0);
1240        let addr = &self.rng as *const _ as u64;
1241        (t as i64, addr as i64)
1242    }
1243
1244    /// Allocate a native function object (no upvalues): builtin registration.
1245    pub fn native(&mut self, f: crate::runtime::value::NativeFn) -> Value {
1246        Value::Native(self.heap.new_native(f, Box::new([])))
1247    }
1248
1249    /// Allocate a native function object with captured upvalues.
1250    pub fn native_with(
1251        &mut self,
1252        f: crate::runtime::value::NativeFn,
1253        upvals: Box<[Value]>,
1254    ) -> Value {
1255        Value::Native(self.heap.new_native(f, upvals))
1256    }
1257
1258    /// Install the shared string metatable (string library).
1259    pub fn set_string_metatable(&mut self, mt: Option<Gc<Table>>) {
1260        self.type_mt[3] = mt;
1261    }
1262
1263    /// The current globals table (`_G` / `_ENV` source for new chunks).
1264    pub fn globals(&self) -> Gc<Table> {
1265        self.globals
1266    }
1267
1268    /// Remaining VM stack slots (PUC `L->stack_last - L->top` analogue).
1269    /// Library code that pushes a known number of fresh slots — e.g.
1270    /// `table.unpack` returning N values — consults this to refuse when
1271    /// the push would blow past `LUAI_MAXSTACK`. 5.3 coroutine.lua :530's
1272    /// `for j in {lim-10, lim-5, …}` series pins this contract: the
1273    /// coroutine's already-built table eats a few slots, so an unpack of
1274    /// ~lim values can't fit.
1275    pub(crate) fn stack_room(&self) -> i64 {
1276        PUC_MAXSTACK - (self.stack.len() as i64)
1277    }
1278
1279    /// Repoint the thread's "global table" used by *future* `Vm::load` calls
1280    /// for the chunk's `_ENV` upvalue (PUC 5.1 `setfenv(0, env)` rewrites
1281    /// `L->l_gt`). Already-loaded chunks keep their own snapshot via the
1282    /// per-closure cell-0 clone in `Op::Closure`, so they are unaffected.
1283    pub(crate) fn set_globals(&mut self, env: Gc<Table>) {
1284        self.globals = env;
1285    }
1286
1287    /// The Lua dialect this VM was constructed for (5.1 / 5.2 / 5.3 / 5.4 /
1288    /// 5.5). Determines numeric semantics, available standard libraries, and
1289    /// metamethod behavior.
1290    pub fn version(&self) -> LuaVersion {
1291        self.version
1292    }
1293
1294    /// Set a global by name. `v` may be any `IntoValue`: a primitive
1295    /// (`i64`, `f64`, `bool`, `&str`, `String`, `Vec<u8>`), a `Value`
1296    /// directly, an `Option<T>`, or a `Gc<Table>` / `Gc<LuaClosure>` /
1297    /// `Gc<NativeClosure>` handle.
1298    ///
1299    /// Returns `Err(LuaError)` only if the globals table overflows
1300    /// (extremely unlikely in practice — `MAX_ASIZE = 1 << 27`).
1301    /// String interning + key construction cannot fail.
1302    ///
1303    /// ```
1304    /// # use luna_core::vm::Vm;
1305    /// # use luna_core::version::LuaVersion;
1306    /// let mut vm = Vm::sandbox(LuaVersion::Lua55).open_base().build();
1307    /// vm.set_global("answer", 42).unwrap();
1308    /// vm.set_global("ratio", 0.5_f64).unwrap();
1309    /// vm.set_global("hello", "world").unwrap();
1310    /// let r = vm.eval("return answer, ratio, hello").unwrap();
1311    /// assert_eq!(r.len(), 3);
1312    /// ```
1313    pub fn set_global<V: crate::vm::IntoValue>(
1314        &mut self,
1315        name: &str,
1316        v: V,
1317    ) -> Result<(), LuaError> {
1318        let v = v.into_value(self);
1319        let k = Value::Str(self.heap.intern(name.as_bytes()));
1320        // 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).
1321        unsafe { self.globals.as_mut() }.set(&mut self.heap, k, v)?;
1322        self.heap
1323            .barrier_back(self.globals.as_ptr() as *mut crate::runtime::heap::GcHeader);
1324        Ok(())
1325    }
1326
1327    /// Backward write barrier shorthand for native lib code: demote `t` from
1328    /// BLACK back to gray so the next propagate step re-traces its fields.
1329    /// No-op outside Propagate (parent is never BLACK at mutation time).
1330    pub(crate) fn barrier_back_table(&mut self, t: Gc<Table>) {
1331        self.heap
1332            .barrier_back(t.as_ptr() as *mut crate::runtime::heap::GcHeader);
1333    }
1334
1335    /// Forward write barrier shorthand: a closed upvalue is a single-slot
1336    /// container — `barrier_forward` is cheaper than `barrier_back` here.
1337    /// No-op outside Propagate.
1338    pub(crate) fn barrier_forward_upvalue(&mut self, uv: Gc<Upvalue>, child: Value) {
1339        self.heap
1340            .barrier_forward(uv.as_ptr() as *mut crate::runtime::heap::GcHeader, child);
1341    }
1342
1343    /// Register a MacroLua macro under `name`. Inert
1344    /// under non-MacroLua dialects (the macro is stored but the load
1345    /// path only consults the registry when
1346    /// `self.version == LuaVersion::MacroLua`).
1347    ///
1348    /// `name` is stored without the leading `@` — source code writes
1349    /// `@double(x)` to invoke a macro registered as `"double"`.
1350    pub fn define_macro(&mut self, name: &str, m: Box<dyn crate::frontend::macro_expander::Macro>) {
1351        self.macro_registry.register(name, m);
1352    }
1353
1354    /// Drop all MacroLua macros (built-in + custom).
1355    /// Mostly useful for tests.
1356    pub fn clear_macros(&mut self) {
1357        self.macro_registry.clear();
1358    }
1359
1360    /// PUC `luaL_loadfilex`: compile the file `name` (standard input when
1361    /// `None`, named `stdin`) into a function. A first line starting with
1362    /// `#` is skipped; `mode` (`"t"`, `"b"`, `"bt"`, `None` for both)
1363    /// limits the chunk to text and/or binary. The error is the message
1364    /// PUC's function leaves: `cannot open <name>: <reason>` when the file
1365    /// cannot be read, or the positioned syntax error.
1366    pub fn load_file(
1367        &mut self,
1368        name: Option<&[u8]>,
1369        mode: Option<&[u8]>,
1370    ) -> Result<Value, LuaError> {
1371        crate::vm::lib_os_io::load_path(self, name, mode).map_err(LuaError)
1372    }
1373
1374    /// PUC `luaL_loadbufferx`: compile `src` under `chunkname`, the chunk
1375    /// kind limited by `mode` as in [`Vm::load_file`]. A syntax error comes
1376    /// back as its positioned message (`<chunk id>:<line>: <message>`), the
1377    /// string `load` returns.
1378    pub fn load_buffer(
1379        &mut self,
1380        src: &[u8],
1381        chunkname: &[u8],
1382        mode: Option<&[u8]>,
1383    ) -> Result<Value, LuaError> {
1384        crate::vm::lib_os_io::load_chunk(self, src, chunkname, mode).map_err(LuaError)
1385    }
1386
1387    /// Parse + compile a chunk and close it over the globals table.
1388    pub fn load(&mut self, src: &[u8], chunkname: &[u8]) -> Result<Gc<LuaClosure>, SyntaxError> {
1389        // Reject oversize input *before* handing the parser/lexer a
1390        // potentially multi-GB slice. The PUC-shaped `not enough memory`
1391        // message keeps `heavy.lua::loadrep` compatibility: that test
1392        // accepts either `string length overflow` or `not enough memory`
1393        // as the failure mode for a feeder loop that outruns the host
1394        // allocator. See `set_loader_input_budget`.
1395        if src.len() > self.loader_input_budget {
1396            return Err(SyntaxError {
1397                line: 0,
1398                msg: b"not enough memory".to_vec(),
1399            });
1400        }
1401        // a precompiled (binary) chunk is undumped; source is parsed + compiled
1402        let is_bytecode = crate::vm::dump::is_binary_chunk(src);
1403        if is_bytecode && !self.bytecode_loading {
1404            return Err(SyntaxError {
1405                line: 0,
1406                msg: b"attempt to load a binary chunk (bytecode loading disabled)".to_vec(),
1407            });
1408        }
1409        let proto = if is_bytecode {
1410            let allow_puc = self.puc_bytecode_loading;
1411            crate::vm::dump::undump_named(src, &mut self.heap, self.version, allow_puc, chunkname)
1412                .map_err(SyntaxError::unpositioned)?
1413        } else if self.version.is_macro_lua() {
1414            // MacroLua dialect: drain the lexer into a
1415            // token vec, run the macro expander pre-pass against the
1416            // per-Vm registry, then hand the rewritten stream to
1417            // `parse_tokens`. The AST + compiler are dialect-agnostic
1418            // because by this point all `@`/quote tokens are gone.
1419            let mut lexer = crate::frontend::lexer::Lexer::new(src, self.version);
1420            let mut raw: Vec<crate::frontend::token::TokenInfo> = Vec::new();
1421            loop {
1422                let t = lexer.next_token()?;
1423                let eof = matches!(t.tok, crate::frontend::token::Token::Eof);
1424                raw.push(t);
1425                if eof {
1426                    break;
1427                }
1428            }
1429            // Drop the trailing Eof — expander operates on the body and
1430            // `parse_tokens` reinserts Eof when it runs out of tokens.
1431            raw.pop();
1432            let expanded = self.macro_registry.expand(raw)?;
1433            let depth = self.c_depth + self.pcall_depth;
1434            let parsed =
1435                crate::frontend::parser::parse_tokens_at_depth(expanded, src, self.version, depth)?;
1436            crate::compiler::compile_parsed(
1437                &parsed.chunk,
1438                &parsed.end_lines,
1439                self.version,
1440                chunkname,
1441                &mut self.heap,
1442            )?
1443        } else {
1444            // PUC's `nCcalls` counts protected calls as well
1445            let depth = self.c_depth + self.pcall_depth;
1446            let parsed = crate::frontend::parser::parse_at_depth(src, self.version, depth)?;
1447            crate::compiler::compile_parsed(
1448                &parsed.chunk,
1449                &parsed.end_lines,
1450                self.version,
1451                chunkname,
1452                &mut self.heap,
1453            )?
1454        };
1455        // PUC `lua_load` (lapi.c) only seeds the loaded closure's first
1456        // upvalue with the globals table when the closure has *exactly* one
1457        // upvalue — that's the main-chunk `_ENV` case. A dumped non-main
1458        // function with two-or-more upvalues keeps every cell at nil; the
1459        // host must use `debug.setupvalue` to wire them up. 5.2 calls.lua
1460        // :293's `assert(x() == nil)` pins this contract.
1461        let n = proto.upvals.len();
1462        let mut ups: Vec<Gc<Upvalue>> = Vec::with_capacity(n.max(1));
1463        if n == 0 {
1464            // synthetic main chunk has no declared upvalues, but the engine
1465            // still expects at least one cell so the host can probe via
1466            // `debug.upvalueid` etc. Match the historical luna shape.
1467            ups.push(
1468                self.heap
1469                    .new_upvalue(UpvalState::Closed(Value::Table(self.globals))),
1470            );
1471        } else if n == 1 {
1472            ups.push(
1473                self.heap
1474                    .new_upvalue(UpvalState::Closed(Value::Table(self.globals))),
1475            );
1476        } else {
1477            for _ in 0..n {
1478                ups.push(self.heap.new_upvalue(UpvalState::Closed(Value::Nil)));
1479            }
1480        }
1481        Ok(self.heap.new_closure(proto, ups.into_boxed_slice()))
1482    }
1483
1484    /// Compile and run `src` as an anonymous chunk; return its results.
1485    /// Source name in the traceback is `"=eval"`. Syntax errors are
1486    /// surfaced as `LuaError` carrying the formatted PUC-style message
1487    /// (interned through the heap so the error value composes with
1488    /// `pcall` / `error_text` like any runtime error).
1489    pub fn eval(&mut self, src: &str) -> Result<Vec<Value>, LuaError> {
1490        self.eval_chunk(src, "=eval")
1491    }
1492
1493    /// Render an error value for messages/tests. Non-string errors —
1494    /// `error({code=…})`, `error(42)`, etc. — collapse to a type tag
1495    /// (`"(error object is a table value)"`); embedders that need
1496    /// structured payloads should inspect `e.0` directly. Errors whose
1497    /// text starts with `"native panic:"` indicate a Rust panic
1498    /// crossed `catch_unwind` — the Vm may be inconsistent and should
1499    /// be dropped (do not reuse).
1500    pub fn error_text(&self, e: &LuaError) -> String {
1501        match e.0 {
1502            Value::Str(s) => String::from_utf8_lossy(s.as_bytes()).into_owned(),
1503            v => format!("(error object is a {} value)", v.type_name()),
1504        }
1505    }
1506
1507    /// Render an error value the way PUC's standalone `msghandler`
1508    /// does (lua.c): strings pass through, numbers stringify, and any
1509    /// other object is given a chance at its `__tostring` metamethod
1510    /// (the result must be a string) before collapsing to the
1511    /// `"(error object is a … value)"` tag. Needs `&mut self` because
1512    /// `__tostring` runs arbitrary Lua — `error_text` remains the
1513    /// non-executing variant.
1514    pub fn error_display(&mut self, e: &LuaError) -> String {
1515        match e.0 {
1516            Value::Str(s) => String::from_utf8_lossy(s.as_bytes()).into_owned(),
1517            v @ (Value::Int(_) | Value::Float(_)) => {
1518                String::from_utf8_lossy(&self.tostring_basic(v)).into_owned()
1519            }
1520            v => {
1521                let mm = self.get_mm(v, Mm::ToString);
1522                if !mm.is_nil()
1523                    && let Ok(r) = self.call_value(mm, &[v])
1524                    && let Some(Value::Str(s)) = r.first()
1525                {
1526                    return String::from_utf8_lossy(s.as_bytes()).into_owned();
1527                }
1528                format!("(error object is a {} value)", v.type_name())
1529            }
1530        }
1531    }
1532
1533    /// Call `f` with `args` in protected mode with the message handler
1534    /// `msgh`: PUC `lua_pcall(L, nargs, LUA_MULTRET, msgh)` made from a C
1535    /// function of the host's, as lua.c's `docall` does from `pmain`.
1536    ///
1537    /// `msgh` runs where the error was raised, before the stack unwinds, so
1538    /// it can take a traceback of the failing call ([`Vm::traceback`]); an
1539    /// error inside it calls it again with the new error, as in PUC. The
1540    /// returned error carries what the handler returned.
1541    ///
1542    /// The call counts as one C level on the stack, the host function
1543    /// making it: `debug.getinfo` finds it below `f`, and a traceback taken
1544    /// inside ends with `[C]: in ?` (`[C]: ?` in 5.1).
1545    pub fn call_value_with_handler(
1546        &mut self,
1547        f: Value,
1548        args: &[Value],
1549        msgh: Value,
1550    ) -> Result<Vec<Value>, LuaError> {
1551        let level = self.native(crate::vm::builtins::nat_host_xpcall);
1552        let mut call_args = Vec::with_capacity(args.len() + 2);
1553        call_args.push(f);
1554        call_args.push(msgh);
1555        call_args.extend_from_slice(args);
1556        let mut results = self.call_value(level, &call_args)?;
1557        // the protected call's `true, results...` or `false, handled error`
1558        if results.first().is_some_and(|ok| ok.truthy()) {
1559            results.remove(0);
1560            Ok(results)
1561        } else {
1562            Err(LuaError(results.get(1).copied().unwrap_or(Value::Nil)))
1563        }
1564    }
1565
1566    /// PUC `luaL_getmetafield`: the field `event` of `v`'s metatable, read
1567    /// raw; nil when `v` has no metatable or the field is absent.
1568    pub fn metafield(&mut self, v: Value, event: &str) -> Value {
1569        match self.metatable_of(v) {
1570            Some(mt) => {
1571                let key = Value::Str(self.heap.intern(event.as_bytes()));
1572                mt.get(key)
1573            }
1574            None => Value::Nil,
1575        }
1576    }
1577
1578    /// Call any callable value from the host (or from natives like pcall).
1579    pub fn call_value(&mut self, f: Value, args: &[Value]) -> Result<Vec<Value>, LuaError> {
1580        // host-level entry (no enclosing exec): drop any error state from a
1581        // prior call that propagated uncaught (`error_traceback` would
1582        // otherwise leak into the next debug.traceback call).
1583        if self.public_call_depth == 0 {
1584            self.error_traceback = None;
1585        }
1586        self.public_call_depth += 1;
1587        // JIT fast path. A host call with no args targeting a Lua
1588        // chunk whose body fits the int-arith whitelist short-circuits
1589        // the whole interpreter dispatch and runs straight through the
1590        // mmap'd native code. The lookup is one Cell::get + one match —
1591        // the slow path (compile attempt on first reach) is paid once per
1592        // Proto.
1593        if args.is_empty()
1594            && let Value::Closure(cl) = f
1595            && let Some(vs) = self.try_jit_call(cl)
1596        {
1597            self.public_call_depth -= 1;
1598            return Ok(vs);
1599        }
1600        let r = self.call_value_impl(f, args, true);
1601        self.public_call_depth -= 1;
1602        r
1603    }
1604
1605    /// Peek/populate the Proto's JIT cache slot, returning
1606    /// `Some(values)` when the cached native fn is callable for a
1607    /// zero-arg call. (Non-zero-arg dispatch is handled by
1608    /// `try_jit_call_op` from inside `begin_call`.)
1609    fn try_jit_call(&mut self, cl: Gc<LuaClosure>) -> Option<Vec<Value>> {
1610        use crate::runtime::function::JitProtoState;
1611        if !self.jit.enabled {
1612            return None;
1613        }
1614        let proto = cl.proto;
1615        if let JitProtoState::Untried = proto.jit.get() {
1616            self.populate_jit_cache(proto);
1617        }
1618        match proto.jit.get() {
1619            JitProtoState::Compiled {
1620                entry,
1621                num_args: 0,
1622                returns_one,
1623                arg_float_mask: _,
1624                arg_table_mask: _,
1625                ret_is_float,
1626                ret_is_table,
1627            } => {
1628                // 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.
1629                let f: crate::jit::IntChunkFn = unsafe { std::mem::transmute(entry) };
1630                // Install the active Vm + closure
1631                // for any Rust helper the JIT'd code may call (e.g.
1632                // `luna_jit_new_table`, `luna_jit_upval_get`) via
1633                // cranelift `Linkage::Import`. RAII clear on return.
1634                // Chunks with no upvalue reads don't touch the closure
1635                // slot, paying nothing.
1636                // Route through chunk_compiler so
1637                // the NullJitBackend path stays inert. Raw-ptr arg
1638                // avoids the &mut self borrow conflict against the
1639                // shared self.jit.chunk_compiler read.
1640                let vm_ptr: *mut Vm = self;
1641                let _jit_vm_guard = self.jit.chunk_compiler.enter(vm_ptr, Some(cl));
1642                // 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).
1643                let r = unsafe { f() };
1644                drop(_jit_vm_guard);
1645                // A JIT helper may have detected a metatable
1646                // on a table operand and parked a deopt request here.
1647                // Discard the sentinel value and return None so the caller
1648                // re-runs the call through the interpreter, which honours
1649                // __index/__newindex.
1650                if self.jit.pending_err.take().is_some() {
1651                    return None;
1652                }
1653                Some(if returns_one {
1654                    let v = if ret_is_float {
1655                        Value::Float(f64::from_bits(r as u64))
1656                    } else if ret_is_table {
1657                        Value::Table(crate::runtime::Gc::from_ptr(
1658                            r as *mut crate::runtime::Table,
1659                        ))
1660                    } else {
1661                        Value::Int(r)
1662                    };
1663                    vec![v]
1664                } else {
1665                    Vec::new()
1666                })
1667            }
1668            // Non-zero-arg Compiled state: call_value's empty-args
1669            // fast path can't drive it. Op::Call handles those.
1670            JitProtoState::Compiled { .. } | JitProtoState::Failed | JitProtoState::Untried => None,
1671        }
1672    }
1673
1674    /// Populate the cache slot. Flips `Untried` to either
1675    /// `Compiled { … }` or `Failed`; idempotent on already-populated
1676    /// states (call sites guard with a get before invoking).
1677    ///
1678    /// Consults a thread-local cross-`Vm` cache keyed by a hash of
1679    /// `proto.code`. Compiled artefacts live in the thread-local
1680    /// `JITModule` so their mmap pages outlive the `Vm`; subsequent
1681    /// `Vm`s loading the same source skip the cranelift compile step
1682    /// entirely.
1683    fn populate_jit_cache(&mut self, proto: Gc<crate::runtime::function::Proto>) {
1684        use crate::runtime::function::JitProtoState;
1685        let version = self.version();
1686        let pre53 = version <= crate::version::LuaVersion::Lua53;
1687        // 5.1 and 5.2 have no Int subtype (all numbers
1688        // are Float). The JIT's `GetUpval` ValueRead path uses this
1689        // to default-pin upvalue reads to Float without a tag check.
1690        let float_only = version <= crate::version::LuaVersion::Lua52;
1691        // Split-borrow JitState so the
1692        // trait method can take `&mut dyn JitStorage` without
1693        // double-borrowing self.jit.
1694        let jit = &mut self.jit;
1695        jit.storage.claim(self.jit_owner_id);
1696        let storage: &mut dyn crate::jit::JitStorage = jit.storage.as_mut();
1697        match jit
1698            .chunk_compiler
1699            .try_compile(storage, proto, pre53, float_only)
1700        {
1701            crate::jit::CompileResult::Compiled {
1702                entry,
1703                num_args,
1704                returns_one,
1705                arg_float_mask,
1706                arg_table_mask,
1707                ret_is_float,
1708                ret_is_table,
1709            } => {
1710                proto.jit.set(JitProtoState::Compiled {
1711                    entry,
1712                    num_args,
1713                    returns_one,
1714                    arg_float_mask,
1715                    arg_table_mask,
1716                    ret_is_float,
1717                    ret_is_table,
1718                });
1719            }
1720            crate::jit::CompileResult::Skipped => {
1721                proto.jit.set(JitProtoState::Failed);
1722            }
1723        }
1724    }
1725
1726    /// `Op::Call` JIT fast path. Run inside `begin_call`
1727    /// before `push_frame`. Returns `true` when the call was handled
1728    /// in-place (no new Lua frame). Constraints: every arg slot must
1729    /// be `Value::Int`, the cached arity must match the call site's
1730    /// `nargs`, the host wanted-count `wanted` is honoured by
1731    /// `finish_results`. Also bails when a debug hook is armed —
1732    /// JIT'd code does not fire line / call / return hooks, so any
1733    /// active hook makes the interpreter the source of truth.
1734    fn try_jit_call_op(
1735        &mut self,
1736        cl: Gc<LuaClosure>,
1737        func_slot: u32,
1738        nargs: u32,
1739        wanted: i32,
1740    ) -> bool {
1741        use crate::runtime::function::JitProtoState;
1742        if !self.jit.enabled {
1743            return false;
1744        }
1745        // Any active debug hook means the interpreter has to run the
1746        // call so the hook gets the expected events.
1747        if self.hook.func.is_some() || self.hook.rust_func.is_some() {
1748            return false;
1749        }
1750        let proto = cl.proto;
1751        if let JitProtoState::Untried = proto.jit.get() {
1752            self.populate_jit_cache(proto);
1753        }
1754        let JitProtoState::Compiled {
1755            entry,
1756            num_args,
1757            returns_one,
1758            arg_float_mask,
1759            arg_table_mask,
1760            ret_is_float,
1761            ret_is_table,
1762        } = proto.jit.get()
1763        else {
1764            return false;
1765        };
1766        if num_args as u32 != nargs {
1767            return false;
1768        }
1769        // Pack args into i64 bit-patterns per the per-slot expected
1770        // kind. A Float-typed slot accepts Value::Float verbatim (and on
1771        // 5.1/5.2 promotes Value::Int(x) via i64 → f64); a Table-typed slot
1772        // accepts only Value::Table and passes the raw Gc ptr; an
1773        // Int-typed slot accepts only Value::Int. Any other shape
1774        // bails to the interpreter so the call's actual dynamics
1775        // (metamethod dispatch / type-coerce) take over.
1776        let mut args: [i64; crate::jit::MAX_JIT_ARITY as usize] =
1777            [0; crate::jit::MAX_JIT_ARITY as usize];
1778        // From 5.3 an integer is its own subtype: turned into a float for
1779        // a float-typed parameter, it would come back out (returned,
1780        // stored, printed) as a float. Only 5.1/5.2, where every number
1781        // is a float, may convert it.
1782        let int_as_float = self.version() <= crate::version::LuaVersion::Lua52;
1783        for i in 0..num_args as usize {
1784            let v = self.stack[(func_slot + 1) as usize + i];
1785            let want_float = (arg_float_mask >> i) & 1 == 1;
1786            let want_table = (arg_table_mask >> i) & 1 == 1;
1787            args[i] = match (want_table, want_float, v) {
1788                (true, _, Value::Table(t)) => t.as_ptr() as i64,
1789                (false, false, Value::Int(x)) => x,
1790                (false, true, Value::Float(f)) => f.to_bits() as i64,
1791                (false, true, Value::Int(x)) if int_as_float => (x as f64).to_bits() as i64,
1792                _ => return false,
1793            };
1794        }
1795        // Vm + closure pin for helpers, routed through chunk_compiler;
1796        // see the matching guard in `try_jit_call`.
1797        let vm_ptr: *mut Vm = self;
1798        let _jit_vm_guard = self.jit.chunk_compiler.enter(vm_ptr, Some(cl));
1799        // 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.
1800        let r = unsafe {
1801            match num_args {
1802                0 => (std::mem::transmute::<*const u8, crate::jit::IntChunkFn>(entry))(),
1803                1 => (std::mem::transmute::<*const u8, crate::jit::IntFn1>(entry))(args[0]),
1804                2 => {
1805                    (std::mem::transmute::<*const u8, crate::jit::IntFn2>(entry))(args[0], args[1])
1806                }
1807                3 => (std::mem::transmute::<*const u8, crate::jit::IntFn3>(entry))(
1808                    args[0], args[1], args[2],
1809                ),
1810                4 => (std::mem::transmute::<*const u8, crate::jit::IntFn4>(entry))(
1811                    args[0], args[1], args[2], args[3],
1812                ),
1813                _ => unreachable!("MAX_JIT_ARITY enforces num_args <= 4"),
1814            }
1815        };
1816        drop(_jit_vm_guard);
1817        // See matching path in `try_jit_call`. A helper
1818        // flagged a metatable on a table operand; bail to the interpreter
1819        // so `push_frame` runs the call from scratch.
1820        if self.jit.pending_err.take().is_some() {
1821            return false;
1822        }
1823        // Write result at func_slot, replacing the closure value, then
1824        // hand to finish_results to pad/truncate per the call site's
1825        // `wanted` count.
1826        if returns_one {
1827            let v = if ret_is_float {
1828                Value::Float(f64::from_bits(r as u64))
1829            } else if ret_is_table {
1830                Value::Table(crate::runtime::Gc::from_ptr(
1831                    r as *mut crate::runtime::Table,
1832                ))
1833            } else {
1834                Value::Int(r)
1835            };
1836            self.stack[func_slot as usize] = v;
1837            self.finish_results(func_slot, 1, wanted);
1838        } else {
1839            self.finish_results(func_slot, 0, wanted);
1840        }
1841        true
1842    }
1843
1844    /// `call_value` with control over the `from_c` debug boundary. A `__close`
1845    /// handler runs *within* the closing Lua frame's activation (PUC luaF_close
1846    /// invokes it inside that ci), so it is called with `from_c = false`: its
1847    /// debug parent is the closing function, not a synthetic C level.
1848    fn call_value_impl(
1849        &mut self,
1850        f: Value,
1851        args: &[Value],
1852        from_c: bool,
1853    ) -> Result<Vec<Value>, LuaError> {
1854        if self.c_depth >= MAX_C_DEPTH {
1855            // PUC `luaE_checkcstack`: at the limit the call fails; an xpcall
1856            // handler running on the error gets a tenth more room before its
1857            // own failure is "error in error handling"
1858            if self.msgh_depth == 0 {
1859                return Err(self.runerror("C stack overflow"));
1860            }
1861            if self.c_depth >= MAX_C_DEPTH / 10 * 11 {
1862                return Err(self.plain_err("error in error handling"));
1863            }
1864        }
1865        self.c_depth += 1;
1866        let func_slot = self.stack.len() as u32;
1867        self.stack.push(f);
1868        self.stack.extend_from_slice(args);
1869        self.top = self.stack.len() as u32;
1870        let r = self.call_at(func_slot, args.len() as u32, from_c);
1871        self.c_depth -= 1;
1872        if r.is_err()
1873            && self.yielding.is_none()
1874            && self.terminating.is_none()
1875            && !self.host_yield_pending
1876            && self.pending_async_native_fut.is_none()
1877        {
1878            // A `coroutine.yield` in flight raises a sentinel error to unwind the
1879            // Rust stack, but the suspended coroutine's frames/registers (which
1880            // sit at/above `func_slot`) must survive for the next resume — so we
1881            // only truncate on a real error. A self-close termination is in the
1882            // same boat: the dying thread's state is discarded wholesale.
1883            // A `host_yield_pending` cooperative yield is in
1884            // the same boat as `yielding`: the next `EvalFuture::poll`
1885            // resumes the same call, so the in-flight frames must
1886            // survive.
1887            self.stack.truncate(func_slot as usize);
1888            self.top = func_slot;
1889        }
1890        r
1891    }
1892
1893    /// Invoke `f` with the running thread marked non-yieldable for the duration
1894    /// (PUC `luaD_callnoyield`): a `coroutine.yield` inside `f` hits the C-call
1895    /// boundary and errors instead of suspending. Used by library callbacks
1896    /// (sort comparator, gsub replacement) that run via synchronous Rust
1897    /// recursion and so could not be re-entered after a yield.
1898    pub(crate) fn call_noyield(
1899        &mut self,
1900        f: Value,
1901        args: &[Value],
1902    ) -> Result<Vec<Value>, LuaError> {
1903        self.nny += 1;
1904        let r = self.call_value(f, args);
1905        self.nny -= 1;
1906        r
1907    }
1908
1909    // ---- coroutines ----
1910
1911    pub(crate) fn new_coro(&mut self, body: Value) -> Gc<Coro> {
1912        // The new coroutine inherits the creating thread's current globals
1913        // (PUC `lua_newthread`: the new state copies `g->mainthread`'s
1914        // `l_gt`). `Vm.globals` always reflects the live thread, so reading
1915        // it here picks the creator regardless of which coro is running.
1916        self.heap.new_coro(body, self.globals)
1917    }
1918
1919    /// Is `t` the thread whose context is currently live in the VM?
1920    pub(crate) fn is_current_thread(&self, t: Option<Gc<Coro>>) -> bool {
1921        match (self.current, t) {
1922            (None, None) => true,
1923            (Some(a), Some(b)) => a.ptr_eq(b),
1924            _ => false,
1925        }
1926    }
1927
1928    /// Read an open-upvalue slot from its owning thread's stack (the live VM
1929    /// stack if that thread is current, else its saved context).
1930    #[doc(hidden)]
1931    pub fn read_slot(&self, slot: u32, thread: Option<Gc<Coro>>) -> Value {
1932        let s = slot as usize;
1933        if self.is_current_thread(thread) {
1934            self.stack[s]
1935        } else {
1936            match thread {
1937                Some(co) => co.stack[s],
1938                None => self.main_ctx.as_ref().expect("main context").stack[s],
1939            }
1940        }
1941    }
1942
1943    fn write_slot(&mut self, slot: u32, thread: Option<Gc<Coro>>, v: Value) {
1944        let s = slot as usize;
1945        if self.is_current_thread(thread) {
1946            self.stack[s] = v;
1947        } else {
1948            match thread {
1949                Some(co) => {
1950                    // 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).
1951                    unsafe { co.as_mut() }.stack[s] = v;
1952                    // co.stack is traced by Coro::trace; demote co back to
1953                    // gray so propagate re-traces this slot if it was
1954                    // already black.
1955                    self.heap
1956                        .barrier_back(co.as_ptr() as *mut crate::runtime::heap::GcHeader);
1957                }
1958                None => self.main_ctx.as_mut().expect("main context").stack[s] = v,
1959            }
1960        }
1961    }
1962
1963    /// Whether `co` is the main thread's identity object.
1964    pub(crate) fn is_main_coro(&self, co: Gc<Coro>) -> bool {
1965        self.main_coro.is_some_and(|m| m.ptr_eq(co))
1966    }
1967
1968    /// The status of `co` from the caller's view. The main thread's identity
1969    /// object has no stored status — it is "running" when nothing else runs,
1970    /// else "normal" (it resumed the active coroutine).
1971    pub(crate) fn effective_coro_status(&self, co: Gc<Coro>) -> CoroStatus {
1972        if self.is_main_coro(co) {
1973            if self.current.is_none() {
1974                CoroStatus::Running
1975            } else {
1976                CoroStatus::Normal
1977            }
1978        } else {
1979            co.status
1980        }
1981    }
1982
1983    /// `coroutine.close` (PUC `lua_closethread`): run the suspended coroutine's
1984    /// pending to-be-closed `__close` handlers, then mark it dead and drop its
1985    /// context. Handlers see the coroutine's death error (if it died by error)
1986    /// or nil; an error they raise propagates out. `Ok(Some(e))` means it died
1987    /// with error `e` and no handler overrode it; `Err` means a handler raised.
1988    pub(crate) fn close_coro(&mut self, co: Gc<Coro>) -> Result<Option<Value>, LuaError> {
1989        // re-entrant close: a __close handler closed its own coroutine while the
1990        // outer close is mid-flight (its context is live). Report success and let
1991        // the outer close finish — re-entering the swap would corrupt the stack.
1992        if self.current.is_some_and(|c| c.ptr_eq(co)) {
1993            return Ok(None);
1994        }
1995        // A chain of coroutines whose `__close` handlers each close the previous
1996        // one recurses on the C stack (PUC `luaD_callnoyield` in `lua_closethread`).
1997        // The calling handler's `call_value` has already pushed `c_depth` to the
1998        // cap, so here it reads as full first — report PUC's "C stack overflow"
1999        // before the next handler call would surface the plainer "stack overflow".
2000        if self.c_depth >= MAX_C_DEPTH {
2001            return Err(self.rt_err("C stack overflow"));
2002        }
2003        // 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).
2004        let death_err = unsafe { co.as_mut() }.error_value.take();
2005        // swap the caller's live context out (into a GC-rooted home) and the
2006        // coroutine's in, mirroring resume_coro, so the __close handlers run on
2007        // the coroutine's stack while everything stays rooted.
2008        let resumer = self.current;
2009        let rctx = self.take_ctx();
2010        match resumer {
2011            Some(r) => {
2012                // 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).
2013                let m = unsafe { r.as_mut() };
2014                m.stack = rctx.stack;
2015                m.frames = rctx.frames;
2016                m.frame_ccmt = rctx.frame_ccmt;
2017                m.open_upvals = rctx.open_upvals;
2018                m.tbc = rctx.tbc;
2019                m.top = rctx.top;
2020                m.pcall_depth = rctx.pcall_depth;
2021            }
2022            None => self.main_ctx = Some(rctx),
2023        }
2024        self.load_coro_ctx(co);
2025        self.current = Some(co);
2026        // PUC `luaE_resetthread` closes with no message handler, whatever
2027        // xpcall the coroutine was suspended in
2028        let natives_base = std::mem::replace(&mut self.natives_base, self.running_natives.len());
2029        let msgh_floor = std::mem::replace(&mut self.msgh_floor, self.frames.len());
2030        let result = self.close_slots(0, death_err);
2031        self.natives_base = natives_base;
2032        self.msgh_floor = msgh_floor;
2033        // discard the (now-closed) coroutine context and restore the caller
2034        let _ = self.take_ctx();
2035        match resumer {
2036            Some(r) => {
2037                self.load_coro_ctx(r);
2038                self.current = Some(r);
2039            }
2040            None => {
2041                let m = self.main_ctx.take().expect("main context saved");
2042                self.put_ctx(m);
2043                self.current = None;
2044            }
2045        }
2046        {
2047            // 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).
2048            let m = unsafe { co.as_mut() };
2049            m.status = CoroStatus::Dead;
2050            m.stack = Vec::new();
2051            m.frames = Vec::new();
2052            m.frame_ccmt = Vec::new();
2053            m.open_upvals = Vec::new();
2054            m.tbc = Vec::new();
2055            m.top = 0;
2056            m.pcall_depth = 0;
2057            m.resume_at = None;
2058            m.error_value = None;
2059            m.error_traceback = None;
2060            m.error_levels = None;
2061        }
2062        result.map(|()| death_err)
2063    }
2064
2065    /// `coroutine.running`: the running thread plus whether it is the main one.
2066    pub(crate) fn running_thread(&self) -> (Value, bool) {
2067        match self.current {
2068            Some(co) => (Value::Coro(co), false),
2069            None => (Value::Coro(self.main_coro.expect("main coro")), true),
2070        }
2071    }
2072
2073    /// `coroutine.isyieldable([co])`: whether `co` (default: the running
2074    /// thread) can yield. The main thread never can; any other coroutine can
2075    /// unless it is dead.
2076    pub(crate) fn is_yieldable(&self, co: Option<Gc<Coro>>) -> bool {
2077        match co {
2078            Some(c) => !self.main_coro.is_some_and(|m| m.ptr_eq(c)) && c.status != CoroStatus::Dead,
2079            // the running thread can yield only outside any non-yieldable C call
2080            None => self.current.is_some() && self.nny == 0,
2081        }
2082    }
2083
2084    /// Why `coroutine.yield` may not suspend the running thread right now, as a
2085    /// PUC error message — `None` if it may. Distinguishes "not in a coroutine"
2086    /// from "inside an unyieldable C call" (sort/gsub callback).
2087    pub(crate) fn yield_barrier(&self) -> Option<&'static str> {
2088        // 5.1's pcall/xpcall are plain C calls (no continuations), so a yield
2089        // below one crosses the boundary like any other; 5.1 also has a single
2090        // wording for every case, the main thread included.
2091        // 5.1 also calls every metamethod and generic-for iterator through
2092        // `luaD_call`, which counts as a C level, so a yield from inside one
2093        // is refused as well.
2094        if self.version <= LuaVersion::Lua51 {
2095            let inside_call = self.frames.iter().enumerate().any(|(i, f)| match f {
2096                CallFrame::Cont(nc) => matches!(nc.kind, ContKind::Meta(_)),
2097                CallFrame::Lua(fr) => {
2098                    fr.tm.is_some()
2099                        || (i > 0
2100                            && self.frames[i - 1].lua().is_some_and(|c| {
2101                                let pc = (c.pc as usize).wrapping_sub(1);
2102                                c.closure
2103                                    .proto
2104                                    .code
2105                                    .get(pc)
2106                                    .is_some_and(|ins| ins.op() == Op::TForCall)
2107                            }))
2108                }
2109            });
2110            if self.current.is_none() || self.nny > 0 || self.pcall_depth > 0 || inside_call {
2111                return Some("attempt to yield across metamethod/C-call boundary");
2112            }
2113            return None;
2114        }
2115        if self.current.is_none() {
2116            Some("attempt to yield from outside a coroutine")
2117        } else if self.nny > 0 {
2118            Some("attempt to yield across a C-call boundary")
2119        } else {
2120            None
2121        }
2122    }
2123
2124    /// The coroutine whose context is currently live (`None` on the main thread).
2125    pub(crate) fn current_coro(&self) -> Option<Gc<Coro>> {
2126        self.current
2127    }
2128
2129    /// `coroutine.close()` on the *running* thread (PUC 5.5 close-self): run all
2130    /// its pending `__close` handlers, then signal termination. The handlers run
2131    /// here, in place, with the thread still non-yieldable (a yield in one hits
2132    /// the C-call boundary). The returned sentinel unwinds the Rust stack the
2133    /// way a yield does — `exec_with` propagates it past any protecting pcall
2134    /// rather than letting `unwind` catch it — and `resume_coro` turns it into a
2135    /// clean death (or, if a handler raised, the coroutine's error).
2136    pub(crate) fn close_running(&mut self) -> LuaError {
2137        let death = match self.close_slots(0, None) {
2138            Ok(()) => None,
2139            Err(e) => Some(e.0),
2140        };
2141        self.terminating = Some(death);
2142        LuaError(Value::Nil)
2143    }
2144
2145    /// `coroutine.status` as seen by the caller.
2146    pub(crate) fn coro_status_str(&self, co: Gc<Coro>) -> &'static str {
2147        match self.effective_coro_status(co) {
2148            CoroStatus::Suspended => "suspended",
2149            CoroStatus::Running => "running",
2150            CoroStatus::Normal => "normal",
2151            CoroStatus::Dead => "dead",
2152        }
2153    }
2154
2155    fn take_ctx(&mut self) -> SavedCtx {
2156        let saved = SavedCtx {
2157            stack: std::mem::take(&mut self.stack),
2158            frames: std::mem::take(&mut self.frames),
2159            frame_ccmt: std::mem::take(&mut self.frame_ccmt),
2160            open_upvals: std::mem::take(&mut self.open_upvals),
2161            tbc: std::mem::take(&mut self.tbc),
2162            top: self.top,
2163            pcall_depth: self.pcall_depth,
2164            hook: self.hook,
2165            globals: self.globals,
2166        };
2167        self.frames_resync(); // frames now empty
2168        saved
2169    }
2170
2171    fn put_ctx(&mut self, c: SavedCtx) {
2172        self.stack = c.stack;
2173        self.frames = c.frames;
2174        self.frame_ccmt = c.frame_ccmt;
2175        self.open_upvals = c.open_upvals;
2176        self.tbc = c.tbc;
2177        self.top = c.top;
2178        self.pcall_depth = c.pcall_depth;
2179        self.hook = c.hook;
2180        self.globals = c.globals;
2181        self.frames_resync(); // sync shadow to new Vec
2182    }
2183
2184    /// Move a coroutine's saved context into the live VM fields.
2185    fn load_coro_ctx(&mut self, co: Gc<Coro>) {
2186        // 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).
2187        let m = unsafe { co.as_mut() };
2188        self.stack = std::mem::take(&mut m.stack);
2189        self.frames = std::mem::take(&mut m.frames);
2190        self.frame_ccmt = std::mem::take(&mut m.frame_ccmt);
2191        self.open_upvals = std::mem::take(&mut m.open_upvals);
2192        self.tbc = std::mem::take(&mut m.tbc);
2193        self.top = m.top;
2194        self.frames_resync(); // sync shadow to coro's frames
2195        self.pcall_depth = m.pcall_depth;
2196        self.hook = m.hook;
2197        self.globals = m.globals;
2198    }
2199
2200    /// Save the live VM context back into a coroutine object.
2201    fn store_coro_ctx(&mut self, co: Gc<Coro>) {
2202        let c = self.take_ctx();
2203        // 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).
2204        let m = unsafe { co.as_mut() };
2205        m.stack = c.stack;
2206        m.frames = c.frames;
2207        m.frame_ccmt = c.frame_ccmt;
2208        m.open_upvals = c.open_upvals;
2209        m.tbc = c.tbc;
2210        m.top = c.top;
2211        m.pcall_depth = c.pcall_depth;
2212        m.hook = c.hook;
2213        m.globals = c.globals;
2214        // bulk-overwrite of every collectable field traced by Coro::trace:
2215        // demote the coro back to gray so propagate re-traces its new state.
2216        self.heap
2217            .barrier_back(co.as_ptr() as *mut crate::runtime::heap::GcHeader);
2218    }
2219
2220    /// `coroutine.resume` core: drive `co` with `args` until it yields, returns
2221    /// or errors. Ok(values) carries yielded or returned values; Err carries an
2222    /// error raised inside the coroutine (the coroutine becomes dead).
2223    pub(crate) fn resume_coro(
2224        &mut self,
2225        co: Gc<Coro>,
2226        args: Vec<Value>,
2227    ) -> Result<Vec<Value>, LuaError> {
2228        match co.status {
2229            CoroStatus::Suspended => {}
2230            CoroStatus::Dead => return Err(self.plain_err("cannot resume dead coroutine")),
2231            _ => return Err(self.plain_err("cannot resume non-suspended coroutine")),
2232        }
2233        if self.c_depth >= MAX_C_DEPTH {
2234            return Err(self.plain_err("C stack overflow"));
2235        }
2236        self.c_depth += 1;
2237        let resumer = self.current;
2238        // save the resumer's live context away
2239        let rctx = self.take_ctx();
2240        match resumer {
2241            Some(r) => {
2242                // 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).
2243                let m = unsafe { r.as_mut() };
2244                m.stack = rctx.stack;
2245                m.frames = rctx.frames;
2246                m.frame_ccmt = rctx.frame_ccmt;
2247                m.open_upvals = rctx.open_upvals;
2248                m.tbc = rctx.tbc;
2249                m.top = rctx.top;
2250                m.pcall_depth = rctx.pcall_depth;
2251                m.globals = rctx.globals;
2252                m.status = CoroStatus::Normal;
2253                m.natives = self.natives_base..self.running_natives.len();
2254                // bulk overwrite of every traced field on r — mirror
2255                // store_coro_ctx's barrier_back so propagate re-traces r.
2256                self.heap
2257                    .barrier_back(r.as_ptr() as *mut crate::runtime::heap::GcHeader);
2258            }
2259            None => self.main_ctx = Some(rctx),
2260        }
2261        // swap the coroutine in
2262        self.load_coro_ctx(co);
2263        {
2264            // 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).
2265            let m = unsafe { co.as_mut() };
2266            m.status = CoroStatus::Running;
2267            m.resumer = resumer;
2268        }
2269        // co.resumer is a traced Gc field; barrier_back covers the new
2270        // resumer reference and any future field writes during this call.
2271        self.heap
2272            .barrier_back(co.as_ptr() as *mut crate::runtime::heap::GcHeader);
2273        self.current = Some(co);
2274        let resumer_natives_base = self.natives_base;
2275        self.natives_base = self.running_natives.len();
2276        // the coroutine's own frames start a fresh reach for xpcall handlers
2277        let resumer_msgh_floor = std::mem::replace(&mut self.msgh_floor, 0);
2278        let resumer_msgh_running = self.msgh_running.take();
2279        // a coroutine that dies keeps its traceback for `debug.traceback(co)`
2280        let resumer_keeps_traceback = std::mem::replace(&mut self.keep_error_traceback, true);
2281
2282        // drive it
2283        let drive = if co.started {
2284            self.coro_continue(&args)
2285        } else {
2286            // 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).
2287            unsafe { co.as_mut() }.started = true;
2288            self.coro_first(co.body, &args)
2289        };
2290
2291        // classify: a self-close termination or a pending yield each win over
2292        // the (sentinel) error they raised to unwind the Rust stack.
2293        let (outcome, status) = if let Some(death) = self.terminating.take() {
2294            // the coroutine closed itself: it dies now, cleanly or with the
2295            // error a `__close` handler raised.
2296            match death {
2297                Some(e) => {
2298                    // 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).
2299                    unsafe { co.as_mut() }.error_value = Some(e);
2300                    self.heap
2301                        .barrier_back(co.as_ptr() as *mut crate::runtime::heap::GcHeader);
2302                    (Err(LuaError(e)), CoroStatus::Dead)
2303                }
2304                None => (Ok(Vec::new()), CoroStatus::Dead),
2305            }
2306        } else {
2307            match self.yielding.take() {
2308                Some((vals, fslot, nres)) => {
2309                    // 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).
2310                    unsafe { co.as_mut() }.resume_at = Some((fslot, nres));
2311                    (Ok(vals), CoroStatus::Suspended)
2312                }
2313                None => {
2314                    // died: a return is clean, an error is remembered so a later
2315                    // `coroutine.close` can report it (PUC lua_closethread).
2316                    // Keep the error-point traceback (taken by `unwind` before
2317                    // popping the failing frames) so `debug.traceback(co)` on
2318                    // the dead coroutine still shows the error site, as PUC's
2319                    // untouched dead stack does (db.lua :848 family).
2320                    if drive.is_err() {
2321                        let levels = self.error_traceback.take().unwrap_or_default();
2322                        let tb =
2323                            crate::vm::callstack::traceback_from_lines(self.version, &levels, 0);
2324                        // 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).
2325                        let m = unsafe { co.as_mut() };
2326                        m.error_traceback = Some(tb);
2327                        m.error_levels = Some(levels);
2328                    }
2329                    if let Err(e) = drive {
2330                        // 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).
2331                        unsafe { co.as_mut() }.error_value = Some(e.0);
2332                        self.heap
2333                            .barrier_back(co.as_ptr() as *mut crate::runtime::heap::GcHeader);
2334                    }
2335                    (drive, CoroStatus::Dead)
2336                }
2337            }
2338        };
2339
2340        // save the coroutine's context back and restore the resumer
2341        self.natives_base = resumer_natives_base;
2342        self.msgh_floor = resumer_msgh_floor;
2343        self.msgh_running = resumer_msgh_running;
2344        self.keep_error_traceback = resumer_keeps_traceback;
2345        self.store_coro_ctx(co);
2346        // 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).
2347        unsafe { co.as_mut() }.status = status;
2348        match resumer {
2349            Some(r) => {
2350                self.load_coro_ctx(r);
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 { r.as_mut() }.status = CoroStatus::Running;
2353                self.current = Some(r);
2354            }
2355            None => {
2356                let m = self.main_ctx.take().expect("main context saved");
2357                self.put_ctx(m);
2358                self.current = None;
2359            }
2360        }
2361        self.c_depth -= 1;
2362        outcome
2363    }
2364
2365    /// First resume: install the body function at slot 0 and run.
2366    fn coro_first(&mut self, body: Value, args: &[Value]) -> Result<Vec<Value>, LuaError> {
2367        self.stack.clear();
2368        self.stack.push(body);
2369        self.stack.extend_from_slice(args);
2370        self.top = self.stack.len() as u32;
2371        match self.begin_call(0, Some(args.len() as u32), -1, true) {
2372            Ok(true) => self.exec_with(1),
2373            Ok(false) => Ok(self.take_results(0)),
2374            Err(e) => Err(e),
2375        }
2376    }
2377
2378    /// Resume after a yield: deliver `args` as the results of the call that
2379    /// yielded, then continue the suspended thread.
2380    fn coro_continue(&mut self, args: &[Value]) -> Result<Vec<Value>, LuaError> {
2381        let (fslot, nres) = self.current.unwrap().resume_at.expect("resume point");
2382        let n = args.len() as u32;
2383        // Restore the full register window of the suspended top frame: a yield
2384        // that unwound through a native (call_value) may have left the stack
2385        // shorter than the frame needs. `base + max_stack` is what push_frame
2386        // allocates; `fslot + n` covers the delivered yield results.
2387        let frame_need = self
2388            .frames
2389            .last()
2390            .and_then(CallFrame::lua)
2391            .map(|f| (f.base + f.closure.proto.max_stack as u32) as usize)
2392            .unwrap_or(0);
2393        let need = frame_need.max((fslot + n) as usize);
2394        if self.stack.len() < need {
2395            self.stack.resize(need, Value::Nil);
2396        }
2397        for (i, &v) in args.iter().enumerate() {
2398            self.stack[fslot as usize + i] = v;
2399        }
2400        self.finish_results(fslot, n, nres);
2401        // the suspended `coroutine.yield` (a C call) now returns its resume
2402        // values: fire the matching "return" hook PUC defers until the resume.
2403        self.hook_return(true, 1, n)?;
2404        self.exec_with(1)
2405    }
2406
2407    /// `coroutine.yield`: suspend the running coroutine, recording where to
2408    /// resume. Errors if called outside a coroutine. Returns a sentinel error
2409    /// that `exec`/`resume_coro` recognise as a yield (never surfaced to Lua).
2410    pub(crate) fn do_yield(&mut self, func_slot: u32, vals: Vec<Value>) -> LuaError {
2411        let nres = self.native_nresults;
2412        self.yielding = Some((vals, func_slot, nres));
2413        // value is irrelevant: resume_coro consults `self.yielding`, not this
2414        LuaError(Value::Nil)
2415    }
2416
2417    /// Install or clear the debug hook on the running thread (`debug.sethook`
2418    /// without a thread argument). Arms the calling frame's `oldpc` to the
2419    /// sethook CALL's own pc (one less than the next-to-execute pc), mirroring
2420    /// PUC `rethook`'s `L->oldpc = pcRel(savedpc, p)` (= savedpc - code - 1) on
2421    /// native return: the very next traceexec compares against the sethook
2422    /// CALL's line. When the install statement and the following statement are
2423    /// on different source lines (db.lua :322), `changedline` fires for that
2424    /// first statement; when they share a line (db.lua :25 wrapper), they do
2425    /// not, so the wrapper line is not re-fired.
2426    pub(crate) fn install_hook(&mut self, hook: HookState) {
2427        self.hook = hook;
2428        if self.hook.line
2429            && let Some(f) = self.frames.last_mut().and_then(CallFrame::lua_mut)
2430        {
2431            f.hook_oldpc = f.pc.saturating_sub(1);
2432        }
2433    }
2434
2435    /// Install a hook on `target` (`None`/current thread → the live VM fields;
2436    /// another, suspended thread → its saved `Coro` state). PUC `debug.sethook`
2437    /// with an optional thread argument.
2438    ///
2439    /// `target == None` means "no explicit thread argument" — PUC binds that
2440    /// to `L` (the running thread). luna's live VM fields (`self.hook`,
2441    /// `self.frames`, `self.stack`) ARE the running thread's state, regardless
2442    /// of whether that's the main thread or a currently-resumed coroutine
2443    /// (save/restore happens at resume/yield boundaries via `load_coro_ctx`/
2444    /// `store_coro_ctx`). So a `None` target should always route to
2445    /// `install_hook` on the live fields. The pre-fix predicate gate
2446    /// `is_current_thread(target)` returned `false` when running inside a
2447    /// coroutine (`self.current = Some(co)`, `target = None` don't match)
2448    /// and silently dropped the hook on the floor — the install happened on
2449    /// no thread at all.
2450    pub(crate) fn set_hook(&mut self, target: Option<Gc<Coro>>, state: HookState) {
2451        if target.is_none() || self.is_current_thread(target) {
2452            self.install_hook(state);
2453        } else if let Some(co) = target {
2454            // 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).
2455            let m = unsafe { co.as_mut() };
2456            m.hook = state;
2457            if state.line
2458                && let Some(f) = m.frames.last_mut().and_then(CallFrame::lua_mut)
2459            {
2460                f.hook_oldpc = u32::MAX;
2461            }
2462            // co.hook.func is a traced Value (Coro::trace covers it); demote
2463            // co back to gray so propagate sees the new hook function.
2464            self.heap
2465                .barrier_back(co.as_ptr() as *mut crate::runtime::heap::GcHeader);
2466        }
2467    }
2468
2469    /// The hook state of `target` (`None`/current → the live VM state).
2470    pub(crate) fn get_hook(&self, target: Option<Gc<Coro>>) -> HookState {
2471        match target {
2472            t if self.is_current_thread(t) => self.hook,
2473            Some(co) => co.hook,
2474            None => self.hook,
2475        }
2476    }
2477
2478    /// Invoke the debug hook for `event` (PUC `luaD_hook`). The hook runs with
2479    /// hooks disabled (PUC clears the mask) and its results/stack growth are
2480    /// discarded so the interrupted frame's register window is untouched.
2481    /// `line` is the source line for a "line" event, `None` (→ nil) otherwise.
2482    fn run_hook(
2483        &mut self,
2484        event: &[u8],
2485        line: Option<i64>,
2486        from_native: bool,
2487    ) -> Result<(), LuaError> {
2488        // line and count events transfer no values (PUC `luaD_hook(L,
2489        // event, line, 0, 0)`); call and return hooks set theirs first
2490        if matches!(event, b"line" | b"count") {
2491            self.hook_ftransfer = 0;
2492            self.hook_ntransfer = 0;
2493        }
2494        // Rust hook fires first (no Vm reentrancy via call_value;
2495        // synchronous fn pointer call). Both Rust and Lua hooks may be
2496        // installed; both observe each event.
2497        if let Some(rh) = self.hook.rust_func {
2498            let evt = match event {
2499                b"call" => Some(RustHookEvent::Call),
2500                b"return" => Some(RustHookEvent::Return),
2501                b"tail call" | b"tail return" => Some(RustHookEvent::TailCall),
2502                b"line" => Some(RustHookEvent::Line(line.unwrap_or(0).max(0) as u32)),
2503                b"count" => Some(RustHookEvent::Count),
2504                _ => None,
2505            };
2506            if let Some(evt) = evt {
2507                let was_in_hook = self.in_hook;
2508                self.in_hook = true;
2509                rh(self, evt);
2510                self.in_hook = was_in_hook;
2511            }
2512        }
2513        let Some(hook) = self.hook.func else {
2514            return Ok(());
2515        };
2516        let saved_top = self.top;
2517        let saved_len = self.stack.len();
2518        let name = Value::Str(self.heap.intern(event));
2519        let lv = line.map_or(Value::Nil, Value::Int);
2520        self.in_hook = true;
2521        // PUC `db_sethook`'s C trampoline `hookf` sits between the engine and
2522        // the Lua hook — so `getinfo(2)` inside the hook resolves to whatever
2523        // ci sat below `hookf` (the function being hooked). When that hooked
2524        // function is native, no Lua frame for it exists in luna's `frames`;
2525        // model it as a synthetic C level by pushing the hook with
2526        // `from_c = true` (then `c_frame_name` reads the caller's call
2527        // instruction → e.g. `name = "sethook"`). When the hooked function is
2528        // Lua (its frame is still on the stack), push with `from_c = false`
2529        // so the level descent lands on it directly. The hook's own frame
2530        // carries `is_hook = true` so `getinfo(1).namewhat` reports "hook"
2531        // (PUC `CIST_HOOKED`).
2532        self.pending_is_hook = true;
2533        let r = self.call_value_impl(hook, &[name, lv], from_native);
2534        self.pending_is_hook = false;
2535        self.in_hook = false;
2536        self.stack.truncate(saved_len);
2537        self.top = saved_top;
2538        r.map(|_| ())
2539    }
2540
2541    /// Fire the "call" hook on entry to a function, if armed and not already in
2542    /// a hook (PUC clears the mask while a hook runs). PUC's transferinfo for
2543    /// a call hook is the param window: ftransfer = 1, ntransfer = nargs.
2544    /// `is_tail` selects the "tail call" event (PUC `LUA_HOOKTAILCALL`); a
2545    /// tail-call hook has no matching return hook (PUC luaD_pretailcall).
2546    fn hook_call_with(
2547        &mut self,
2548        from_native: bool,
2549        nargs: u32,
2550        is_tail: bool,
2551    ) -> Result<(), LuaError> {
2552        if self.hook.call
2553            && !self.in_hook
2554            && (self.hook.func.is_some() || self.hook.rust_func.is_some())
2555        {
2556            self.hook_ftransfer = 1;
2557            self.hook_ntransfer = nargs.min(u16::MAX as u32) as u16;
2558            // PUC 5.1 didn't distinguish tail-call events — every call,
2559            // including tail-calls, fired plain `"call"`. 5.2 introduced
2560            // the separate `"tail call"` event (mask `"c"` covers both).
2561            // 5.1 db.lua :366 pins this with `{"call","call","call","call",
2562            // "return","tail return","return","tail return"}`.
2563            let event: &[u8] = if is_tail && self.version >= LuaVersion::Lua52 {
2564                b"tail call"
2565            } else {
2566                b"call"
2567            };
2568            self.run_hook(event, None, from_native)?;
2569        }
2570        Ok(())
2571    }
2572
2573    pub(crate) fn hook_call(&mut self, from_native: bool, nargs: u32) -> Result<(), LuaError> {
2574        self.hook_call_with(from_native, nargs, false)
2575    }
2576
2577    /// Fire the "return" hook on exit from a function, if armed. ftransfer is
2578    /// the first result slot relative to the activation's func slot, ntransfer
2579    /// the number of results.
2580    pub(crate) fn hook_return(
2581        &mut self,
2582        from_native: bool,
2583        ftransfer: u32,
2584        nresults: u32,
2585    ) -> Result<(), LuaError> {
2586        if self.hook.ret
2587            && !self.in_hook
2588            && (self.hook.func.is_some() || self.hook.rust_func.is_some())
2589        {
2590            self.hook_ftransfer = ftransfer.min(u16::MAX as u32) as u16;
2591            self.hook_ntransfer = nresults.min(u16::MAX as u32) as u16;
2592            self.run_hook(b"return", None, from_native)?;
2593        }
2594        Ok(())
2595    }
2596
2597    /// PUC "tail return" event — fires once per tail call that collapsed
2598    /// into the activation now returning, *after* its own "return" event.
2599    /// 5.1 hook mask `"r"` covers both `return` and `tail return`.
2600    fn hook_tail_return(&mut self) -> Result<(), LuaError> {
2601        if self.hook.ret
2602            && !self.in_hook
2603            && (self.hook.func.is_some() || self.hook.rust_func.is_some())
2604        {
2605            self.run_hook(b"tail return", None, false)?;
2606        }
2607        Ok(())
2608    }
2609
2610    /// Call a metamethod with a single expected result.
2611    fn call_mm1(&mut self, f: Value, args: &[Value]) -> Result<Value, LuaError> {
2612        let mut r = self.call_value(f, args)?;
2613        Ok(if r.is_empty() {
2614            Value::Nil
2615        } else {
2616            r.swap_remove(0)
2617        })
2618    }
2619
2620    /// Begin a *yieldable* metamethod call from a VM instruction: `func(args…)`
2621    /// driven through the interpreter loop with a `Meta` continuation, so a
2622    /// `coroutine.yield` inside the metamethod suspends and resumes cleanly.
2623    /// On the metamethod's return the loop head runs `finish_meta(action, …)`.
2624    /// Returns to the caller with the call set up — the opcode arm must do no
2625    /// further work on the running frame and let the loop iterate. `tm` is
2626    /// the metamethod event name (e.g. "index", "add"); a Lua handler frame
2627    /// born from this call inherits it via `pending_tm`, so
2628    /// `debug.getinfo(1).namewhat == "metamethod"` and `.name == tm`
2629    /// (db.lua :878).
2630    fn begin_meta_call(
2631        &mut self,
2632        func: Value,
2633        args: &[Value],
2634        action: MetaAction,
2635        tm: &'static str,
2636    ) -> Result<(), LuaError> {
2637        let saved_top = self.top;
2638        let cont_slot = self.stack.len() as u32;
2639        self.stack.push(func);
2640        self.stack.extend_from_slice(args);
2641        self.top = self.stack.len() as u32;
2642        frames_push_sync(
2643            &mut self.frames,
2644            &mut self.frames_top,
2645            CallFrame::Cont(NativeCont {
2646                kind: ContKind::Meta(MetaCont { action, saved_top }),
2647                func_slot: cont_slot,
2648                nresults: 1,
2649            }),
2650        );
2651        let saved_tm = self.pending_tm.replace(tm);
2652        // begin_call drives a Lua metamethod through the loop (returns true) or
2653        // runs a native one inline (returns false, leaving results at cont_slot
2654        // for the loop head to pick up); either way the Meta cont resolves there.
2655        let r = self.begin_call(cont_slot, Some(args.len() as u32), 1, true);
2656        // Native callees never consumed pending_tm (push_frame is only hit on
2657        // a Lua callee); restore so it doesn't leak to a later push_frame.
2658        self.pending_tm = saved_tm;
2659        r?;
2660        Ok(())
2661    }
2662
2663    /// `R[dst] := t[key]` for a VM read opcode, resolving `__index` yieldably.
2664    fn op_index(&mut self, t: Value, key: Value, dst: u32) -> Result<(), LuaError> {
2665        // Read-time probe: a collectable key must be live at
2666        // the moment it is used. O(1) membership test against the
2667        // freed-pointer log — gc-verify diagnostic builds only; exact
2668        // under quarantining allocators (ASAN).
2669        #[cfg(feature = "gc-verify")]
2670        if matches!(key, Value::Str(_)) {
2671            let h = match key {
2672                Value::Str(s) => s.as_ptr() as usize,
2673                _ => unreachable!(),
2674            };
2675            if self.heap.recently_freed.contains(&h) {
2676                let (pc, reg_info) = match self.frames.last() {
2677                    Some(CallFrame::Lua(f)) => {
2678                        let pc = f.pc as usize;
2679                        let inst = f.closure.proto.code.get(pc.wrapping_sub(1));
2680                        (
2681                            pc,
2682                            inst.map(|i| {
2683                                format!(
2684                                    "op[pc-1]={:?} a={} b={} c={} base={}",
2685                                    i.op(),
2686                                    i.a(),
2687                                    i.b(),
2688                                    i.c(),
2689                                    f.base
2690                                )
2691                            })
2692                            .unwrap_or_default(),
2693                        )
2694                    }
2695                    _ => (0, String::new()),
2696                };
2697                panic!(
2698                    "[gc-verify] op_index READ of dead string key {h:#x} \
2699                     (gc_top {}, top {}, pc {pc}, {reg_info})",
2700                    self.gc_top, self.top,
2701                );
2702            }
2703        }
2704        match self.index_step(t, key)? {
2705            MmOut::Done(v) => self.stack[dst as usize] = v,
2706            MmOut::Mm { func, recv } => {
2707                self.begin_meta_call(func, &[recv, key], MetaAction::Store { dst }, "index")?;
2708            }
2709            MmOut::CompareSynth { .. } => unreachable!("CompareSynth from index_step"),
2710        }
2711        Ok(())
2712    }
2713
2714    /// `t[key] := v` for a VM write opcode, resolving `__newindex` yieldably.
2715    fn op_newindex(&mut self, t: Value, key: Value, v: Value) -> Result<(), LuaError> {
2716        match self.newindex_step(t, key, v)? {
2717            MmOut::Done(_) => {}
2718            MmOut::Mm { func, recv } => {
2719                self.begin_meta_call(func, &[recv, key, v], MetaAction::Discard, "newindex")?;
2720            }
2721            MmOut::CompareSynth { .. } => unreachable!("CompareSynth from newindex_step"),
2722        }
2723        Ok(())
2724    }
2725
2726    /// Apply a comparison opcode's outcome: a known boolean drives the
2727    /// conditional skip directly; a metamethod is called yieldably, its
2728    /// truthiness driving the skip on return.
2729    fn op_compare(
2730        &mut self,
2731        step: MmOut,
2732        l: Value,
2733        r: Value,
2734        k: bool,
2735        tm: &'static str,
2736    ) -> Result<(), LuaError> {
2737        match step {
2738            MmOut::Done(v) => self.cond_skip(v.truthy(), k),
2739            MmOut::Mm { func, .. } => {
2740                self.begin_meta_call(func, &[l, r], MetaAction::Compare { k, negate: false }, tm)?;
2741            }
2742            MmOut::CompareSynth { func } => {
2743                // ≤5.3 `__le` falls back to `not __lt(r, l)`; the swap and
2744                // negation are driven through `MetaAction::Compare` so the
2745                // metamethod call can yield like any other compare.
2746                self.begin_meta_call(func, &[r, l], MetaAction::Compare { k, negate: true }, "lt")?;
2747            }
2748        }
2749        Ok(())
2750    }
2751
2752    /// Complete a VM instruction whose metamethod just returned `result` (PUC
2753    /// `luaV_finishOp`). The running frame is already back on top.
2754    fn finish_meta(&mut self, action: MetaAction, result: Value) -> Result<(), LuaError> {
2755        match action {
2756            MetaAction::Store { dst } => self.stack[dst as usize] = result,
2757            MetaAction::Discard => {}
2758            MetaAction::Compare { k, negate } => {
2759                let t = if negate {
2760                    !result.truthy()
2761                } else {
2762                    result.truthy()
2763                };
2764                self.cond_skip(t, k);
2765            }
2766            MetaAction::Concat { dst, base_a } => {
2767                self.stack[dst as usize] = result;
2768                self.top = dst + 1;
2769                self.concat_run(base_a)?;
2770            }
2771        }
2772        Ok(())
2773    }
2774
2775    // ---- metatables ----
2776
2777    pub(crate) fn metatable_of(&self, v: Value) -> Option<Gc<Table>> {
2778        match v {
2779            Value::Table(t) => t.metatable(),
2780            Value::Userdata(u) => u.metatable(),
2781            v => type_mt_slot(v).and_then(|i| self.type_mt[i]),
2782        }
2783    }
2784
2785    /// Set the shared metatable for `v`'s basic type (debug.setmetatable on a
2786    /// non-table). No-op for tables (they carry their own).
2787    pub(crate) fn set_type_metatable(&mut self, v: Value, mt: Option<Gc<Table>>) {
2788        if let Some(i) = type_mt_slot(v) {
2789            self.type_mt[i] = mt;
2790        }
2791    }
2792
2793    /// The metamethod of `v` for `mm`, or nil.
2794    pub(crate) fn get_mm(&self, v: Value, mm: Mm) -> Value {
2795        match self.metatable_of(v) {
2796            Some(mt) => mt.get(Value::Str(self.mm_names[mm as usize])),
2797            None => Value::Nil,
2798        }
2799    }
2800
2801    /// PUC 5.1 `get_compTM`: a comparison metamethod (`__eq` / `__lt` / `__le`)
2802    /// only fires when both operands carry a metatable that exposes the same
2803    /// implementation. Returns the metamethod to call, or `Nil` when no
2804    /// compatible match exists. Used to honour events.lua 5.1 :262's rule
2805    /// that `c == d` (where `d` has no metatable) falls back to raw equality.
2806    pub(crate) fn get_comp_mm(&self, l: Value, r: Value, mm: Mm) -> Value {
2807        let mt1 = self.metatable_of(l);
2808        let Some(mt1) = mt1 else { return Value::Nil };
2809        let key = Value::Str(self.mm_names[mm as usize]);
2810        let tm1 = mt1.get(key);
2811        if tm1.is_nil() {
2812            return Value::Nil;
2813        }
2814        let mt2 = self.metatable_of(r);
2815        let Some(mt2) = mt2 else { return Value::Nil };
2816        if mt1.as_ptr() == mt2.as_ptr() {
2817            return tm1;
2818        }
2819        let tm2 = mt2.get(key);
2820        if tm2.is_nil() {
2821            return Value::Nil;
2822        }
2823        if tm1.raw_eq(tm2) {
2824            return tm1;
2825        }
2826        Value::Nil
2827    }
2828
2829    /// PUC `luaT_objtypename`: the type name shown in error messages. A table
2830    /// or full userdata whose metatable carries a string `__name` reports that
2831    /// (e.g. "FILE*", "My Type") instead of the bare "table"/"userdata".
2832    pub(crate) fn obj_typename(&self, v: Value) -> String {
2833        // `__name` (luaT_objtypename) arrived in 5.3
2834        if self.version >= LuaVersion::Lua53
2835            && matches!(v, Value::Table(_) | Value::Userdata(_))
2836            && let Value::Str(s) = self.get_mm(v, Mm::Name)
2837        {
2838            return String::from_utf8_lossy(s.as_bytes()).into_owned();
2839        }
2840        v.type_name().to_string()
2841    }
2842
2843    fn call_at(
2844        &mut self,
2845        func_slot: u32,
2846        nargs: u32,
2847        from_c: bool,
2848    ) -> Result<Vec<Value>, LuaError> {
2849        let depth = self.frames.len();
2850        match self.begin_call(func_slot, Some(nargs), -1, from_c) {
2851            // run until every frame the call pushed has returned: a pcall /
2852            // xpcall / __pairs continuation sits below the frame of the
2853            // function it called, and it is the continuation that produces
2854            // the call's results (`true, ...`, or `false, msg` on an error)
2855            Ok(true) => self.exec_with(depth + 1),
2856            // native completed inline; results at func_slot..top
2857            Ok(false) => Ok(self.take_results(func_slot)),
2858            // pcall / xpcall pushed their continuation and then failed to
2859            // call their function (`pcall("x")`): the continuation catches
2860            // that error, as it does in the dispatch loop
2861            Err(e)
2862                if self.frames.len() > depth
2863                    && self.yielding.is_none()
2864                    && self.terminating.is_none()
2865                    && !self.host_yield_pending
2866                    && self.pending_async_native_fut.is_none() =>
2867            {
2868                match self.unwind(e.0, depth + 1) {
2869                    Unwound::Caught => self.exec_with(depth + 1),
2870                    Unwound::CaughtReturn(vals) => Ok(vals),
2871                    Unwound::Propagated(err) => Err(err),
2872                }
2873            }
2874            Err(e) => Err(e),
2875        }
2876    }
2877
2878    /// Switch the `collectgarbage` mode, returning the previous mode name.
2879    pub(crate) fn gc_switch_mode(&mut self, new: &'static str) -> &'static str {
2880        std::mem::replace(&mut self.gc_mode, new)
2881    }
2882
2883    /// Whether the current `collectgarbage` mode is "generational" (where a
2884    /// "step" is a minor collection — a full atomic pass — rather than a paced
2885    /// incremental sweep).
2886    pub(crate) fn gc_mode_is_generational(&self) -> bool {
2887        self.gc_mode == "generational"
2888    }
2889
2890    /// Current `stepsize` pacing parameter (PUC: 0 means an unbounded step that
2891    /// completes a whole cycle at once).
2892    pub(crate) fn gc_stepsize(&self) -> i64 {
2893        self.gc_stepsize
2894    }
2895
2896    /// Set luna's collector knobs: heap growth before a new cycle (%), sweep
2897    /// work per safe point, and the default step size (0 = a step completes
2898    /// the cycle).
2899    pub(crate) fn set_gc_pacing(&mut self, pause: i64, stepmul: i64, stepsize: i64) {
2900        self.gc_pause = pause;
2901        self.gc_stepmul = stepmul;
2902        self.gc_stepsize = stepsize;
2903    }
2904
2905    /// Interpreter safe-point auto-GC: FULL incremental Propagate + adaptive
2906    /// paced sweep via `Vm::gc_step`.
2907    ///
2908    /// Running Propagate from a safe-point relies on objects being **born
2909    /// black during Propagate**: a newly allocated object never becomes
2910    /// dead-white at the atomic flip.
2911    ///
2912    /// Adaptive budget scales with heap size: 100M-object heap (heavy.lua's
2913    /// `loadrep` stress) gets a 25M-object budget so a cycle completes in
2914    /// O(SWEEP_DIVISOR) safe-points regardless of size.
2915    #[inline(always)]
2916    pub(crate) fn maybe_collect_garbage(&mut self, live_top: u32) {
2917        if self.gc_finalizing {
2918            return;
2919        }
2920        if !self.heap.gc_due() {
2921            return;
2922        }
2923        // Bare `live_top`, no `max(self.top)` widening: every frame-pop
2924        // site (`finish_results`, the Op::TailCall collapse, pcall
2925        // unwind) clears the slots it vacates, mirroring PUC's L->top
2926        // discipline.
2927        self.gc_top = live_top;
2928        // PUC stepmul: % of allocation rate. Higher = more GC work per
2929        // safe-point (lower memory, more CPU). Default 100 = `live / 4` per
2930        // step (~4 safe-points per cycle). stepmul=200 → `live / 2`, etc.
2931        const SWEEP_BASE: usize = 400; // 400 / stepmul=100 = divisor 4
2932        const MIN_BUDGET: usize = 64_000;
2933        let stepmul = self.gc_stepmul.max(1) as usize;
2934        let divisor = (SWEEP_BASE / stepmul).max(1);
2935        let budget = (self.heap.live_objects() / divisor).max(MIN_BUDGET);
2936        if self.gc_step(budget) {
2937            self.heap.rearm_gc_pause(self.gc_pause);
2938        }
2939    }
2940
2941    /// Enumerate the GC roots: first-class `Value` roots plus bare-object
2942    /// roots (open upvalues, which are not first-class Values). Shared by the
2943    /// full collector and the incremental-sweep driver so both snapshot the
2944    /// exact same live set.
2945    fn gc_roots(&self) -> (Vec<Value>, Vec<*mut GcHeader>) {
2946        let mut roots: Vec<Value> = Vec::with_capacity(self.stack.len() + 32);
2947        roots.push(Value::Table(self.globals));
2948        for mt in self.type_mt.into_iter().flatten() {
2949            roots.push(Value::Table(mt));
2950        }
2951        for &n in &self.mm_names {
2952            roots.push(Value::Str(n));
2953        }
2954        // Root the running thread's live registers (PUC marks [stack, top)).
2955        // `gc_top` is the instruction-level cursor of the last GC
2956        // safe-point: allocation safe-points set it via
2957        // `maybe_collect_garbage(live_top)`, and `begin_call` raises it
2958        // to the callee's argument top when entering a native — PUC's
2959        // `L->top = func + 1 + nargs` C-call discipline. Without that
2960        // raise, an explicit `collectgarbage()` collected with a STALE
2961        // cursor from some earlier (lower) safe-point and freed its own
2962        // caller's register-held strings
2963        // (STATUS_ACCESS_VIOLATION on Windows / ASAN heap-use-after-free
2964        // on Linux). Values stranded above the cursor stay
2965        // excluded so weak-table entries are not spuriously pinned
2966        // (gc.lua:544 suspended-coroutine collection).
2967        let live = (self.gc_top as usize).min(self.stack.len());
2968        roots.extend_from_slice(&self.stack[..live]);
2969        for cf in &self.frames {
2970            match cf {
2971                CallFrame::Lua(f) => roots.push(Value::Closure(f.closure)),
2972                CallFrame::Cont(NativeCont {
2973                    kind: ContKind::Xpcall { handler },
2974                    ..
2975                }) => roots.push(*handler),
2976                CallFrame::Cont(NativeCont {
2977                    kind: ContKind::Close(cc),
2978                    ..
2979                }) => {
2980                    // Root the error threaded through this close chain so a
2981                    // `collectgarbage()` inside a sibling `__close` handler
2982                    // does not free it before the next handler is invoked
2983                    // (PUC L->ci->u.l.errfunc / the closing_err shadow).
2984                    if let Some(e) = cc.pending {
2985                        roots.push(e);
2986                    }
2987                    if let AfterClose::ResumeUnwind { err, .. } = cc.after {
2988                        roots.push(err);
2989                    }
2990                }
2991                CallFrame::Cont(_) => {}
2992            }
2993        }
2994        if let Some(e) = self.closing_err {
2995            roots.push(e);
2996        }
2997        // Host roots — Lua-facade handles keep their referenced
2998        // values alive across calls/yields. Trace the whole vector;
2999        // unused slots (post-`unpin_all`) carry Value::Nil which the
3000        // GC ignores.
3001        for slot in &self.host_roots {
3002            // free-list slots carry Value::Nil (GC no-op)
3003            roots.push(slot.value);
3004        }
3005        // `table.sort` and similar builtins stash their working
3006        // `Vec<Value>` here so a `collectgarbage()` invoked inside the
3007        // comparator callback doesn't free strings/tables snapshotted
3008        // off the live table (sort.lua's `load(..)(); collectgarbage()`
3009        // compare regression).
3010        for buf in &self.sort_scratch {
3011            roots.extend_from_slice(buf);
3012        }
3013        // The running-natives chain holds Gc<NativeClosure>s
3014        // mid-execution. Without rooting them here, a `collectgarbage()`
3015        // invoked inside the running native (sort.lua's `load(..)();
3016        // collectgarbage()` compare callback regression) sweeps the
3017        // closure that's actively executing, leaving `nc.upvals`
3018        // dangling and the Rust local `nc` pointing at recycled memory
3019        // — the SIGSEGV pops on the very next field access or pop.
3020        for &nc in &self.running_natives {
3021            roots.push(Value::Native(nc));
3022        }
3023        // the running thread's debug hook (suspended threads root theirs via
3024        // Coro::trace / the main_ctx sweep below)
3025        if let Some(h) = self.hook.func {
3026            roots.push(h);
3027        }
3028        // the running coroutine (its saved-context fields live in the VM, but
3029        // the object itself + its resumer chain must stay reachable)
3030        if let Some(co) = self.current {
3031            roots.push(Value::Coro(co));
3032        }
3033        if let Some(mc) = self.main_coro {
3034            roots.push(Value::Coro(mc));
3035        }
3036        // debug.getregistry() and io library state
3037        if let Some(r) = self.registry {
3038            roots.push(Value::Table(r));
3039        }
3040        if let Some(mt) = self.file_mt {
3041            roots.push(Value::Table(mt));
3042        }
3043        if let Some(f) = self.io_input {
3044            roots.push(Value::Userdata(f));
3045        }
3046        if let Some(f) = self.io_output {
3047            roots.push(Value::Userdata(f));
3048        }
3049        if let Some(f) = self.io_stdin {
3050            roots.push(Value::Userdata(f));
3051        }
3052        // the main thread's saved context while a coroutine runs
3053        if let Some(m) = &self.main_ctx {
3054            roots.extend_from_slice(&m.stack);
3055            if let Some(h) = m.hook.func {
3056                roots.push(h);
3057            }
3058            for cf in &m.frames {
3059                match cf {
3060                    CallFrame::Lua(f) => roots.push(Value::Closure(f.closure)),
3061                    CallFrame::Cont(NativeCont {
3062                        kind: ContKind::Xpcall { handler },
3063                        ..
3064                    }) => roots.push(*handler),
3065                    CallFrame::Cont(_) => {}
3066                }
3067            }
3068        }
3069        let mut extra: Vec<*mut GcHeader> = self
3070            .open_upvals
3071            .iter()
3072            .map(|&(_, uv)| uv.as_ptr() as *mut GcHeader)
3073            .collect();
3074        if let Some(m) = &self.main_ctx {
3075            extra.extend(
3076                m.open_upvals
3077                    .iter()
3078                    .map(|&(_, uv)| uv.as_ptr() as *mut GcHeader),
3079            );
3080        }
3081        (roots, extra)
3082    }
3083
3084    /// Run a full collection with the VM's roots, then run any `__gc`
3085    /// finalizers the collection scheduled. A no-op (returns 0) when already
3086    /// inside a finalizer — the collector is not reentrant (PUC).
3087    pub fn collect_garbage(&mut self) -> usize {
3088        if self.gc_finalizing {
3089            return 0;
3090        }
3091        let (roots, extra) = self.gc_roots();
3092        let freed = self.heap.collect_ex(&roots, &extra);
3093        #[cfg(feature = "gc-verify")]
3094        self.verify_frame_regs_live("collect_garbage");
3095        self.run_finalizers();
3096        freed
3097    }
3098
3099    /// `gc-verify`: after a collect, every register slot the
3100    /// collector just rooted (`[0, max(gc_top, top))` — the same bound
3101    /// `gc_roots` uses) must hold a live value. A dead value inside the
3102    /// rooted range means the root snapshot and the sweep disagreed —
3103    /// a use-after-free waiting to happen. (Slots ABOVE the bound may hold
3104    /// stale dead values legitimately; the interpreter's contract is
3105    /// that it writes them before reading.)
3106    #[cfg(feature = "gc-verify")]
3107    pub(crate) fn verify_frame_regs_live(&self, ctx: &str) {
3108        let live = self.heap.debug_live_set();
3109        let header = |v: Value| -> Option<usize> {
3110            match v {
3111                Value::Str(s) => Some(s.as_ptr() as usize),
3112                Value::Table(t) => Some(t.as_ptr() as usize),
3113                Value::Closure(c) => Some(c.as_ptr() as usize),
3114                Value::Native(n) => Some(n.as_ptr() as usize),
3115                Value::Coro(c) => Some(c.as_ptr() as usize),
3116                Value::Userdata(u) => Some(u.as_ptr() as usize),
3117                _ => None,
3118            }
3119        };
3120        let bound = (self.gc_top as usize).min(self.stack.len());
3121        for i in 0..bound {
3122            if let Some(h) = header(self.stack[i])
3123                && !live.contains(&h)
3124            {
3125                panic!(
3126                    "[gc-verify] {ctx}: rooted stack slot {i} (gc_top {}, top {}) \
3127                         holds a dead value {h:#x} after collect",
3128                    self.gc_top, self.top,
3129                );
3130            }
3131        }
3132        // Diagnostic tier: a dead value ABOVE the cursor is only a bug if
3133        // that register is a named local still in scope (the interpreter
3134        // WILL read it). Cross-check against the proto's LocVar table.
3135        for (fi, cf) in self.frames.iter().enumerate() {
3136            if let CallFrame::Lua(f) = cf {
3137                let base = f.base as usize;
3138                let maxs = f.closure.proto.max_stack as usize;
3139                let hi = (base + maxs).min(self.stack.len());
3140                let pc = f.pc;
3141                for i in bound.max(base)..hi {
3142                    if let Some(h) = header(self.stack[i])
3143                        && !live.contains(&h)
3144                    {
3145                        let reg = (i - base) as u32;
3146                        if let Some(lv) = f
3147                            .closure
3148                            .proto
3149                            .locvars
3150                            .iter()
3151                            .find(|lv| lv.reg == reg && lv.start_pc <= pc && pc < lv.end_pc)
3152                        {
3153                            panic!(
3154                                "[gc-verify] {ctx}: frame {fi} IN-SCOPE LOCAL '{}' \
3155                                     (reg {reg}, abs {i}, pc {pc}, gc_top {}) holds a \
3156                                     dead value {h:#x} — live_top cursor excluded a \
3157                                     live named local",
3158                                lv.name, self.gc_top,
3159                            );
3160                        }
3161                    }
3162                }
3163            }
3164        }
3165    }
3166
3167    /// PUC 5.1 `collectgarbage` re-raised the first error a `__gc` finalizer
3168    /// threw; gc.lua's "errors during collection" probe relies on it. This
3169    /// variant runs the same cycle but propagates the captured finalizer
3170    /// error to the explicit caller.
3171    pub(crate) fn collect_garbage_propagating(&mut self) -> Result<usize, LuaError> {
3172        if self.gc_finalizing {
3173            return Ok(0);
3174        }
3175        let (roots, extra) = self.gc_roots();
3176        let freed = self.heap.collect_ex(&roots, &extra);
3177        #[cfg(feature = "gc-verify")]
3178        self.verify_frame_regs_live("collect_garbage_propagating");
3179        self.run_finalizers_or_err()?;
3180        Ok(freed)
3181    }
3182
3183    /// Whether a `__gc` finalizer is currently running (so `collectgarbage`
3184    /// should report fail rather than collect).
3185    pub(crate) fn gc_is_finalizing(&self) -> bool {
3186        self.gc_finalizing
3187    }
3188
3189    /// PUC 5.4+ default warnf: emit one piece of a warning message. `to_cont`
3190    /// = true indicates more pieces follow (concatenated until the first
3191    /// `to_cont = false` call flushes the whole line). Mirrors
3192    /// `lauxlib.c::warnfon` + `warnfcont` + `checkcontrol`:
3193    ///   * If the buffer is fresh, `to_cont` is false, and the message is
3194    ///     `@<word>`, treat as a control message — only `@on` / `@off` are
3195    ///     recognised; any other `@…` is silently ignored.
3196    ///   * Otherwise, while the state is `Off`, drop the piece; while `On`,
3197    ///     accumulate, and flush to stderr + `warn_log` on the
3198    ///     non-continuation call.
3199    pub(crate) fn emit_warn(&mut self, msg: &[u8], to_cont: bool) {
3200        if self.warn_buf.is_empty()
3201            && !to_cont
3202            && let Some(b'@') = msg.first().copied()
3203        {
3204            match &msg[1..] {
3205                b"on" => self.warn_state = WarnState::On,
3206                b"off" => self.warn_state = WarnState::Off,
3207                _ => {} // unknown control — silently ignored (PUC checkcontrol)
3208            }
3209            return;
3210        }
3211        if self.warn_state == WarnState::Off {
3212            // drop continuation pieces too — PUC `warnfoff` is the trampoline
3213            return;
3214        }
3215        self.warn_buf.extend_from_slice(msg);
3216        if !to_cont {
3217            let line = std::mem::take(&mut self.warn_buf);
3218            eprintln!("Lua warning: {}", String::from_utf8_lossy(&line));
3219            self.warn_log.push(line);
3220        }
3221    }
3222
3223    /// Drain the in-process warning log (one entry per emitted message, sans
3224    /// `"Lua warning: "` prefix and newline). For test harnesses that want to
3225    /// assert on warn output without scraping stderr.
3226    pub fn warn_log_take(&mut self) -> Vec<Vec<u8>> {
3227        std::mem::take(&mut self.warn_log)
3228    }
3229
3230    /// Arm the cooperative instruction budget. The run loop
3231    /// decrements this once per dispatch turn; on zero it raises a catchable
3232    /// `"instruction budget exceeded"` error and disarms itself so the host
3233    /// can resume with a fresh budget on the next call. `None` removes the
3234    /// cap. Pass `Some(n)` before `eval`/`call_value` for the embedder's
3235    /// short-script semantics.
3236    pub fn set_instr_budget(&mut self, budget: Option<i64>) {
3237        self.instr_budget = budget;
3238    }
3239
3240    /// Remaining instruction budget (None when unbounded).
3241    pub fn instr_budget_remaining(&self) -> Option<i64> {
3242        self.instr_budget
3243    }
3244
3245    /// Toggle the cranelift JIT. Default `true`. Sandbox embedders
3246    /// **must** disable JIT when relying on `instr_budget` — see the
3247    /// `jit_enabled` field doc for the rationale.
3248    pub fn set_jit_enabled(&mut self, enabled: bool) {
3249        self.jit.enabled = enabled;
3250    }
3251
3252    /// Current JIT enable state.
3253    pub fn jit_enabled(&self) -> bool {
3254        self.jit.enabled
3255    }
3256
3257    /// Toggle the trace JIT. Off by default. When enabled, hot
3258    /// back-edges are counted on `Proto.trace_hot_count`; once the
3259    /// counter passes `TRACE_HOT_THRESHOLD`, the dispatch loop enters
3260    /// recording mode at the back-edge target.
3261    pub fn set_trace_jit_enabled(&mut self, enabled: bool) {
3262        self.jit.trace_enabled = enabled;
3263    }
3264
3265    /// Opt-in flag for the self-link cycle catch. See field
3266    /// docs for the correctness blocker. Default `false`.
3267    pub fn set_self_link_enabled(&mut self, enabled: bool) {
3268        self.jit.self_link_enabled = enabled;
3269    }
3270
3271    /// Current state of the self-link cycle catch.
3272    pub fn self_link_enabled(&self) -> bool {
3273        self.jit.self_link_enabled
3274    }
3275
3276    #[doc(hidden)]
3277    #[deprecated(since = "3.2.0", note = "renamed to `set_self_link_enabled`")]
3278    pub fn set_p16_self_link_enabled(&mut self, enabled: bool) {
3279        self.set_self_link_enabled(enabled);
3280    }
3281
3282    #[doc(hidden)]
3283    #[deprecated(since = "3.2.0", note = "renamed to `self_link_enabled`")]
3284    pub fn p16_self_link_enabled(&self) -> bool {
3285        self.self_link_enabled()
3286    }
3287
3288    /// Current trace-JIT enable state.
3289    pub fn trace_jit_enabled(&self) -> bool {
3290        self.jit.trace_enabled
3291    }
3292
3293    /// Number of traces that have closed cleanly (looped back to the
3294    /// head PC) since this Vm was constructed. Cumulative; used by
3295    /// tests + tuning.
3296    pub fn trace_closed_count(&self) -> u64 {
3297        self.jit.counters.closed
3298    }
3299
3300    /// Number of traces that have aborted (exceeded MAX_TRACE_LEN or
3301    /// hit an un-recordable op).
3302    pub fn trace_aborted_count(&self) -> u64 {
3303        self.jit.counters.aborted
3304    }
3305
3306    /// Number of compiled traces whose close shape
3307    /// is `TraceEnd::InlineAbort` (depth>0 boundary). Such traces
3308    /// pin `dispatchable=false` because the dispatcher can't
3309    /// resume at a depth>0 PC without the matching CallFrames.
3310    /// The frame-materialisation helper could synthesise those, but
3311    /// the InlineAbort emit path isn't wired up to it.
3312    pub fn trace_inline_abort_count(&self) -> u64 {
3313        self.jit.counters.inline_abort
3314    }
3315
3316    /// See `JitCounters::dispatch_off_reasons`.
3317    pub fn trace_dispatch_off_reasons(&self) -> &[&'static str] {
3318        &self.jit.counters.dispatch_off_reasons
3319    }
3320
3321    /// See `JitCounters::compile_failed_reasons`.
3322    pub fn trace_compile_failed_reasons(&self) -> &[&'static str] {
3323        &self.jit.counters.compile_failed_reasons
3324    }
3325
3326    /// See `JitCounters::closed_lens`. Returns
3327    /// `(is_call_triggered, ops_len)` for every trace that closed.
3328    pub fn trace_closed_lens(&self) -> &[(bool, usize)] {
3329        &self.jit.counters.closed_lens
3330    }
3331
3332    /// See [`crate::vm::jit_state::JitCounters::close_cause_counts`].
3333    /// Per-reason close-cause counts (recorder-side abort/discard +
3334    /// lowerer-side dispatch_off labels) keyed by `&'static str`.
3335    pub fn trace_close_cause_counts(&self) -> &std::collections::HashMap<&'static str, u64> {
3336        &self.jit.counters.close_cause_counts
3337    }
3338
3339    /// Number of compiled traces whose
3340    /// `CompiledTrace.downrec_link` is `Some(_)` (lowerer's
3341    /// `downrec_idx_opt` arm emitted the stitch sentinel + caller-pc
3342    /// guard scaffold).
3343    pub fn trace_downrec_link_compiled_count(&self) -> u64 {
3344        self.jit.counters.downrec_link_compiled
3345    }
3346
3347    /// See
3348    /// [`crate::vm::jit_state::JitCounters::downrec_dispatched`]. Number
3349    /// of times the dispatcher's `is_downrec_sentinel` arm fired and
3350    /// classified the return as a caller-pc-guard HIT.
3351    pub fn trace_downrec_dispatched_count(&self) -> u64 {
3352        self.jit.counters.downrec_dispatched
3353    }
3354
3355    /// See
3356    /// [`crate::vm::jit_state::JitCounters::downrec_deopt`]. Number of
3357    /// times the dispatcher entered a `downrec_link`-bearing trace and
3358    /// the trace returned via the lowerer's deopt block (caller-pc
3359    /// guard MISS), or the dispatcher itself force-deopted via the
3360    /// stitch-cycle checkpoint.
3361    pub fn trace_downrec_deopt_count(&self) -> u64 {
3362        self.jit.counters.downrec_deopt
3363    }
3364
3365    /// See
3366    /// [`crate::vm::jit_state::JitCounters::multi_way_guard_emitted`].
3367    /// Number of compiled traces whose lowerer emitted a multi-way
3368    /// caller-pc guard chain (>= 2 distinct `caller_pc` candidates)
3369    /// at the `TraceEnd::DownRec` close + lifted `dispatchable = true`.
3370    pub fn trace_multi_way_guard_emitted_count(&self) -> u64 {
3371        self.jit.counters.multi_way_guard_emitted
3372    }
3373
3374    /// Number of closed traces the lowerer compiled and
3375    /// parked on `Proto.traces`. Re-records of the same head_pc are
3376    /// deduped (the second close finds the head_pc already cached
3377    /// and skips compile), so this never exceeds `trace_closed_count`.
3378    pub fn trace_compiled_count(&self) -> u64 {
3379        self.jit.counters.compiled
3380    }
3381
3382    /// Number of times the recorder captured a
3383    /// [`crate::jit::trace_types::FieldIcSnapshot`] under
3384    /// `LUNA_JIT_FIELD_IC=1`. Stays 0 on the env-default path. Used
3385    /// by the opt-in fire test to verify the env gate
3386    /// wiring round-trips end-to-end (env -> recorder -> snapshot
3387    /// -> counter -> getter -> assertion).
3388    pub fn trace_field_ic_snapshot_count(&self) -> u64 {
3389        self.jit.counters.field_ic_snapshot_captured
3390    }
3391
3392    /// Number of closed traces the lowerer rejected
3393    /// (any of the bail conditions in
3394    /// `crate::jit::trace::try_compile_trace`).
3395    pub fn trace_compile_failed_count(&self) -> u64 {
3396        self.jit.counters.compile_failed
3397    }
3398
3399    /// Number of times the dispatcher jumped into a
3400    /// compiled trace. Bumps on every entry; `trace_deopt_count`
3401    /// counts the subset where the trace returned with a parked
3402    /// `jit_pending_err`.
3403    pub fn trace_dispatched_count(&self) -> u64 {
3404        self.jit.counters.dispatched
3405    }
3406
3407    /// Number of trace entries that came back with
3408    /// `jit_pending_err` set (typically a metatable shadowed an
3409    /// index inside a helper, forcing the dispatcher to fall back
3410    /// to the interpreter without committing the trace's result).
3411    pub fn trace_deopt_count(&self) -> u64 {
3412        self.jit.counters.deopt
3413    }
3414
3415    /// Number of times the dispatcher started a side
3416    /// trace recording (an `exit_hit_counts` slot crossed
3417    /// [`crate::jit::trace::HOTEXIT_THRESHOLD`] while `active_trace`
3418    /// was None and trace JIT was enabled). Each unit is exactly one
3419    /// `start_side_trace` call; the actual compile success counts
3420    /// under [`Self::trace_compiled_count`] like any other trace.
3421    /// Probe use: distinguishes the "side-trace pipeline fired"
3422    /// signal from the "primary back-edge / call-trigger fired"
3423    /// signal without reading per-counter histograms.
3424    pub fn trace_side_trace_started_count(&self) -> u64 {
3425        self.jit.counters.side_trace_started
3426    }
3427
3428    /// Number of side-trace recordings that closed,
3429    /// compiled successfully, AND patched their parent's
3430    /// `exit_side_trace_ptrs[exit_idx]`.
3431    pub fn trace_side_trace_compiled_count(&self) -> u64 {
3432        self.jit.counters.side_trace_compiled
3433    }
3434
3435    /// Number of side traces that compiled
3436    /// successfully but were SHEDDED by the close-handler shape-
3437    /// match gate (`exit_tags_match_entry_tags`). High ratios
3438    /// vs. `trace_side_trace_compiled_count` indicate the
3439    /// architecture is shedding lots of would-be side traces;
3440    /// useful as a tuning probe for future relaxation of the
3441    /// gate or for child-IR re-specialisation against parent's
3442    /// exit shape.
3443    pub fn trace_side_trace_shape_mismatch_count(&self) -> u64 {
3444        self.jit.counters.side_trace_shape_mismatch
3445    }
3446
3447    /// Sum of NewTable sites the pre-emit escape sweep
3448    /// classified as `crate::jit::trace::EscapeState::Sinkable`
3449    /// across every successfully compiled trace on this Vm. The
3450    /// count is post-demotion: sites pre-emit drops back to Escaped
3451    /// for not meeting the sunk-emit criteria are NOT counted.
3452    /// `trace_sunk_alloc_count` matches one-for-one today (every
3453    /// surviving Sinkable site goes through sunk emit).
3454    pub fn trace_sinkable_seen_count(&self) -> u64 {
3455        self.jit.counters.sinkable_seen
3456    }
3457
3458    /// See `JitCounters::accum_bufferable_seen`.
3459    pub fn trace_accum_bufferable_seen_count(&self) -> u64 {
3460        self.jit.counters.accum_bufferable_seen
3461    }
3462
3463    /// Total dispatch hits across all known traces,
3464    /// broken into hot-exit telemetry (max single-exit count,
3465    /// total dispatches, exit count). Used by probes to identify
3466    /// hot side-exits as side-trace candidates.
3467    ///
3468    /// Walks `cl.proto` AND all nested protos in `cl.proto.protos`
3469    /// recursively, so inner functions' traces are reported.
3470    pub fn trace_exit_hit_summary(
3471        &self,
3472        cl: crate::runtime::heap::Gc<crate::runtime::function::LuaClosure>,
3473    ) -> Vec<(u32, Vec<u32>)> {
3474        fn walk(
3475            proto: crate::runtime::heap::Gc<crate::runtime::function::Proto>,
3476            out: &mut Vec<(u32, Vec<u32>)>,
3477        ) {
3478            for ct in proto.traces.borrow().iter() {
3479                let counts: Vec<u32> = ct.exit_hit_counts.iter().map(|c| c.get()).collect();
3480                out.push((ct.head_pc, counts));
3481            }
3482            for inner in proto.protos.iter() {
3483                walk(*inner, out);
3484            }
3485        }
3486        let mut out: Vec<(u32, Vec<u32>)> = Vec::new();
3487        walk(cl.proto, &mut out);
3488        out
3489    }
3490
3491    /// Surface every side-exit slot whose hit count is
3492    /// `>= HOTEXIT_THRESHOLD` across every trace reachable from
3493    /// `cl.proto` (recursively walking `proto.protos`). Returned
3494    /// entries are side-trace candidates: each carries the parent
3495    /// trace's `(head_proto, head_pc)`, the exit's index in the
3496    /// parent's `exit_hit_counts`, and the side trace's natural
3497    /// entry shape (`cont_pc` + `exit_tags`).
3498    ///
3499    /// Layout of `exit_hit_counts` (mirrored by the iter):
3500    /// - `[0..per_exit_inline.len())` → `InlineSideExit` (cont_pc +
3501    ///   window-sized exit_tags).
3502    /// - `[per_exit_inline.len()..inline.len() + per_exit_tags.len())`
3503    ///   → `per_exit_tags[i]` (per-cont_pc caller-window tags).
3504    /// - Last slot → global clean-tail (cont_pc = `head_pc`,
3505    ///   exit_tags = `ct.exit_tags`).
3506    pub fn hot_exit_iter(
3507        &self,
3508        cl: crate::runtime::heap::Gc<crate::runtime::function::LuaClosure>,
3509    ) -> Vec<crate::jit::trace::HotExitInfo> {
3510        use crate::jit::trace::{HOTEXIT_THRESHOLD, HotExitInfo};
3511        fn walk(
3512            proto: crate::runtime::heap::Gc<crate::runtime::function::Proto>,
3513            out: &mut Vec<HotExitInfo>,
3514        ) {
3515            for ct in proto.traces.borrow().iter() {
3516                let inline_n = ct.per_exit_inline.len();
3517                let tags_n = ct.per_exit_tags.len();
3518                debug_assert_eq!(
3519                    ct.exit_hit_counts.len(),
3520                    inline_n + tags_n + 1,
3521                    "exit_hit_counts layout invariant violated"
3522                );
3523                for (idx, cell) in ct.exit_hit_counts.iter().enumerate() {
3524                    let hits = cell.get();
3525                    if hits < HOTEXIT_THRESHOLD {
3526                        continue;
3527                    }
3528                    let (cont_pc, exit_tags) = if idx < inline_n {
3529                        let ent = &ct.per_exit_inline[idx];
3530                        (ent.cont_pc, ent.exit_tags.clone())
3531                    } else if idx < inline_n + tags_n {
3532                        let (pc, tags) = &ct.per_exit_tags[idx - inline_n];
3533                        (*pc, tags.clone())
3534                    } else {
3535                        (ct.head_pc, ct.exit_tags.clone())
3536                    };
3537                    out.push(HotExitInfo {
3538                        head_proto: proto,
3539                        head_pc: ct.head_pc,
3540                        exit_idx: idx,
3541                        hits,
3542                        cont_pc,
3543                        exit_tags,
3544                    });
3545                }
3546            }
3547            for inner in proto.protos.iter() {
3548                walk(*inner, out);
3549            }
3550        }
3551        let mut out: Vec<HotExitInfo> = Vec::new();
3552        walk(cl.proto, &mut out);
3553        out
3554    }
3555
3556    /// Sum of NewTable sites that actually took the
3557    /// sunk-emit path across every successfully compiled trace on
3558    /// this Vm. Each counted site skips its heap `Gc<Table>`
3559    /// allocation per dispatch; the array part lives as Cranelift
3560    /// `Variable`s for the duration of the trace.
3561    pub fn trace_sunk_alloc_count(&self) -> u64 {
3562        self.jit.counters.sunk_alloc
3563    }
3564
3565    /// Sum of materialise-helper emit sites across every
3566    /// successfully compiled trace on this Vm. Each unit is a
3567    /// (site × cmp side-exit) pair whose IR reconstructs a heap
3568    /// `Gc<Table>` from the virt slots on deopt.
3569    pub fn trace_materialize_emit_count(&self) -> u64 {
3570        self.jit.counters.materialize_emit
3571    }
3572
3573    /// Diagnostic: total `Op::Closure` ops the trace JIT
3574    /// lowered to the `luna_jit_op_closure` helper. Each emitted op
3575    /// replaces a `Heap::new_closure_inline` call on the dispatch
3576    /// path; the count is static (one per matching op per compiled
3577    /// trace), summed at compile success.
3578    pub fn trace_closure_emit_count(&self) -> u64 {
3579        self.jit.counters.closure_emit
3580    }
3581
3582    /// See
3583    /// [`crate::vm::jit_state::JitCounters::per_exit_inline_compiled`].
3584    /// Number of compiled traces whose `per_exit_inline.len() > 0`
3585    /// (depth>0 inlined cmp side-exits emitted).
3586    pub fn trace_per_exit_inline_compiled_count(&self) -> u64 {
3587        self.jit.counters.per_exit_inline_compiled
3588    }
3589
3590    /// See
3591    /// [`crate::vm::jit_state::JitCounters::per_exit_inline_dispatchable`].
3592    /// Number of compiled traces with `per_exit_inline.len() > 0` AND
3593    /// `dispatchable == true` — i.e. the count of compiled traces
3594    /// that would actually exercise the AOT chain-reloc +
3595    /// deploy-resolver path.
3596    pub fn trace_per_exit_inline_dispatchable_count(&self) -> u64 {
3597        self.jit.counters.per_exit_inline_dispatchable
3598    }
3599
3600    /// Diagnostic: max `inline_depth` ever seen on any
3601    /// `RecordedOp` pushed by the recorder. Tells tests + tuning
3602    /// whether a self-recursive function actually walked the depth
3603    /// tracker past 0. Saturates at `MAX_INLINE_DEPTH`. Persists
3604    /// across traces and Vm activations; reset only on `Vm::new`.
3605    pub fn trace_max_depth_seen(&self) -> u8 {
3606        self.jit.max_depth_seen
3607    }
3608
3609    /// Last live Lua frame (the trace head's frame at
3610    /// dispatch time). The frame-materialization helper reads `.base`
3611    /// to compute offsets for each inlined frame's window.
3612    #[doc(hidden)]
3613    pub fn jit_last_lua_frame(&self) -> Option<Frame> {
3614        match self.frames.last() {
3615            Some(CallFrame::Lua(f)) => Some(*f),
3616            _ => None,
3617        }
3618    }
3619
3620    /// Read-only borrow of the current call
3621    /// stack, for the [`crate::vm::inspect`] pure-read accessors used
3622    /// by `luna-tools` (`luna-profile`'s sampler walks this from
3623    /// inside a `Count` hook). Sibling-module scope: not part of the
3624    /// public embedder surface, but `inspect::frames_for_profile` is.
3625    #[doc(hidden)]
3626    pub(super) fn inspect_frames(&self) -> &[CallFrame] {
3627        &self.frames
3628    }
3629
3630    /// Ensure the value stack covers indices
3631    /// `[0..need)`. Extends with Nil if shorter. Called by the
3632    /// frame-materialization helper before pushing an inlined frame
3633    /// whose register window may exceed the current stack length.
3634    #[doc(hidden)]
3635    pub fn jit_ensure_stack(&mut self, need: usize) {
3636        if self.stack.len() < need {
3637            self.stack.resize(need, Value::Nil);
3638        }
3639    }
3640
3641    /// Trace JIT path for `Op::Close A`. Predicts whether
3642    /// `__close` handlers would run (any active tbc slot ≥ from
3643    /// holding a non-nil/false Value); if so, returns 1 without doing
3644    /// anything and the trace side-exits at the op, so the interpreter
3645    /// runs the handlers. Otherwise performs the safe part of close —
3646    /// `close_from(from)` to close open upvals + drop any drained tbc
3647    /// entries ≥ from — and returns 0.
3648    ///
3649    /// Returns are i64-shaped so the cranelift import sig stays
3650    /// trivial (i64 → i64 mapping).
3651    #[doc(hidden)]
3652    pub fn jit_op_close(&mut self, start_offset: u32) -> i64 {
3653        let Some(f) = self.jit_last_lua_frame() else {
3654            return 1;
3655        };
3656        let from = f.base + start_offset;
3657        let has_handler = self.tbc.iter().any(|&s| {
3658            s >= from && {
3659                let v = self.stack[s as usize];
3660                !matches!(v, Value::Nil | Value::Bool(false))
3661            }
3662        });
3663        if has_handler {
3664            self.jit.counters.deopt += 1;
3665            return 1;
3666        }
3667        self.close_from(from);
3668        // Drain any tbc entries ≥ from (they're nil/false stubs the
3669        // interpreter's drive_close would have skipped silently).
3670        while let Some(&s) = self.tbc.last() {
3671            if s < from {
3672                break;
3673            }
3674            self.tbc.pop();
3675        }
3676        0
3677    }
3678
3679    /// Spill the trace's current value for a register to
3680    /// the underlying `vm.stack[base + slot_offset]`. Required before
3681    /// an `Op::Closure` whose inner proto has an `in_stack: true`
3682    /// upval at `slot_offset` — the helper's `find_or_create_upval`
3683    /// captures a live pointer to `vm.stack[base + slot_offset]`,
3684    /// which must hold the right value at call time (trace IR's
3685    /// Variable hasn't yet been written back).
3686    ///
3687    /// Parameters arrive as i64 from the IR: `slot_offset` is the
3688    /// caller-frame register index (`u32` in practice, depth=0
3689    /// only — depth>0 Closure is not supported); `tag` is the
3690    /// `crate::runtime::value::raw` byte for the slot's RegKind;
3691    /// `raw_bits` is the trace Variable's `use_var` payload
3692    /// (i64-shaped — Float is its bit-pattern, Table/Closure is the
3693    /// raw `Gc::as_ptr` cast).
3694    #[doc(hidden)]
3695    pub fn jit_spill_stack(&mut self, slot_offset: u32, tag: u8, raw_bits: u64) {
3696        let Some(f) = self.jit_last_lua_frame() else {
3697            self.jit.pending_err =
3698                Some(self.rt_err("JIT spill: no Lua frame on jit_last_lua_frame()"));
3699            return;
3700        };
3701        let idx = (f.base as usize) + (slot_offset as usize);
3702        if self.stack.len() <= idx {
3703            self.stack.resize(idx + 1, Value::Nil);
3704        }
3705        // SAFETY: caller (trace JIT IR emit) provides matching
3706        // `(tag, raw_bits)` — same shape produced by Value::unpack.
3707        let v = unsafe {
3708            crate::runtime::Value::pack(tag, crate::runtime::value::RawVal { zero: raw_bits })
3709        };
3710        self.stack[idx] = v;
3711    }
3712
3713    /// Refresh only the raw payload of
3714    /// `vm.stack[base + slot_offset]`, preserving its existing
3715    /// `Value` tag. The caller (trace JIT Op::Concat body emit)
3716    /// uses this when the slot's `RegKind` is `Unset` (no compile-
3717    /// time tag info; commonly `Str` slots which the trace doesn't
3718    /// model). The interp's previous execution of the same op
3719    /// already populated the slot with the right tag — the trace
3720    /// only needs to swap in its current raw value.
3721    #[doc(hidden)]
3722    pub fn jit_stack_update_raw(&mut self, slot_offset: u32, raw_bits: u64) {
3723        let Some(f) = self.jit_last_lua_frame() else {
3724            return;
3725        };
3726        let idx = (f.base as usize) + (slot_offset as usize);
3727        if idx >= self.stack.len() {
3728            return;
3729        }
3730        let (tag, _) = self.stack[idx].unpack();
3731        // 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).
3732        self.stack[idx] = unsafe {
3733            crate::runtime::Value::pack(tag, crate::runtime::value::RawVal { zero: raw_bits })
3734        };
3735    }
3736
3737    /// Trace JIT path for `Op::Concat A B`.
3738    ///
3739    /// Mirrors the interp arm (this file ~L5112): `self.top =
3740    /// base + a + n; concat_run(base + a)`. Result lands at
3741    /// `vm.stack[base + a]`. Returns `0` on success, `-1` when the
3742    /// interpreter must do it (any error from `concat_run` OR
3743    /// detection that the metamethod path was taken — `concat_run`
3744    /// returns `Ok(())` after `begin_meta_call` which has pushed a Lua
3745    /// frame the trace can't safely continue past); the trace then
3746    /// side-exits at the op and the interpreter redoes it, raising the
3747    /// error or calling `__concat` itself.
3748    ///
3749    /// The frame-push detection uses `pre/post frames.len()` and
3750    /// unwinds any pushed frames first, so the exit sees a clean stack.
3751    #[doc(hidden)]
3752    pub fn jit_op_concat(&mut self, slot_offset: u32, n: i32) -> i64 {
3753        let Some(f) = self.jit_last_lua_frame() else {
3754            return -1;
3755        };
3756        let abs_a = f.base + slot_offset;
3757        self.top = abs_a + n as u32;
3758        let pre_frames = self.frames.len();
3759        let result = self.concat_run(abs_a);
3760        let post_frames = self.frames.len();
3761        // Frame-push = metamethod path taken (begin_meta_call pushed
3762        // a Lua frame). The trace can't continue past it; unwind +
3763        // deopt so interp redoes Op::Concat in the slow path.
3764        while self.frames.len() > pre_frames {
3765            frames_pop_sync(&mut self.frames, &mut self.frames_top);
3766        }
3767        if result.is_err() || post_frames > pre_frames {
3768            self.jit.counters.deopt += 1;
3769            return -1;
3770        }
3771        0
3772    }
3773
3774    /// Pop a reusable `Vec<u8>` from the JIT
3775    /// accumulator buffer pool, returning a raw pointer. The trace
3776    /// fn's IR holds this pointer in a stack slot through the loop
3777    /// and calls `jit_str_buf_extend` per iter. If the pool is
3778    /// empty, allocate fresh.
3779    ///
3780    /// Safety: the returned pointer is valid until
3781    /// `jit_str_buf_release` is called or the Vm is dropped. The
3782    /// caller MUST not retain it across `enter_jit` boundaries.
3783    #[doc(hidden)]
3784    pub fn jit_str_buf_acquire(&mut self) -> *mut Vec<u8> {
3785        let buf = self.jit.str_buf_pool.pop().unwrap_or_default();
3786        // Move into a Box so the pointer is stable until release.
3787        Box::into_raw(Box::new(buf))
3788    }
3789
3790    /// Return a previously-acquired buffer to the
3791    /// pool, dropping any excess past `jit_str_buf_pool_cap`. The
3792    /// buffer is `clear`ed (capacity retained) so the next acquire
3793    /// gets a ready-to-extend Vec.
3794    ///
3795    /// Safety: `buf` must have been returned by a prior
3796    /// `jit_str_buf_acquire` on the same Vm.
3797    #[doc(hidden)]
3798    #[allow(clippy::not_unsafe_ptr_arg_deref)] // JIT helper: `buf` round-trips through `Box::into_raw`; SAFETY documented below.
3799    pub fn jit_str_buf_release(&mut self, buf: *mut Vec<u8>) {
3800        if buf.is_null() {
3801            return;
3802        }
3803        // 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.
3804        let mut owned = unsafe { Box::from_raw(buf) };
3805        owned.clear();
3806        if self.jit.str_buf_pool.len() < self.jit.str_buf_pool_cap {
3807            self.jit.str_buf_pool.push(*owned);
3808        }
3809        // Else: drop the buffer.
3810    }
3811
3812    /// Append a LuaStr's bytes to the accumulator
3813    /// buffer. The trace IR computes the `str_ptr` (= raw bits of
3814    /// the piece slot) and passes it through; we treat it as a
3815    /// `*mut LuaStr` and append its bytes.
3816    ///
3817    /// Returns 0 on success, -1 if the piece isn't a Str (would
3818    /// trip __concat metamethod path → deopt to interp).
3819    ///
3820    /// Safety: `buf` from prior `acquire`; `str_ptr` from the
3821    /// trace's piece slot raw bits.
3822    #[doc(hidden)]
3823    #[allow(clippy::not_unsafe_ptr_arg_deref)] // JIT helper: `buf` from prior `acquire`; `str_ptr` from trace piece slot; SAFETY documented below.
3824    pub fn jit_str_buf_extend(&mut self, buf: *mut Vec<u8>, str_ptr: i64) -> i64 {
3825        if buf.is_null() || str_ptr == 0 {
3826            return -1;
3827        }
3828        // 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).
3829        let buf = unsafe { &mut *buf };
3830        let lua_str_ptr = str_ptr as *const crate::runtime::string::LuaStr;
3831        // 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).
3832        let bytes = unsafe { crate::runtime::string::bytes_of(lua_str_ptr) };
3833        buf.extend_from_slice(bytes);
3834        0
3835    }
3836
3837    /// Drain the accumulator buffer into a fresh
3838    /// `LuaStr` via `heap.intern`, returning the raw ptr bits for
3839    /// the trace to write into the accumulator slot.
3840    ///
3841    /// Returns the LuaStr ptr as i64 on success, 0 on overflow
3842    /// (the hard cap; the trace deopts).
3843    ///
3844    /// Safety: `buf` from prior `acquire`. The buffer is left
3845    /// CLEAR (drained) ready for `release`.
3846    #[doc(hidden)]
3847    #[allow(clippy::not_unsafe_ptr_arg_deref)] // JIT helper: `buf` from prior `acquire`; SAFETY documented below.
3848    pub fn jit_str_buf_intern(&mut self, buf: *mut Vec<u8>) -> i64 {
3849        if buf.is_null() {
3850            return 0;
3851        }
3852        // 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).
3853        let buf = unsafe { &mut *buf };
3854        let bytes = std::mem::take(buf);
3855        // hard cap at 256KB
3856        if bytes.len() > 256 * 1024 {
3857            return 0;
3858        }
3859        let gc = self.heap.intern(&bytes);
3860        gc.as_ptr() as i64
3861    }
3862
3863    /// Trace JIT helper for `Op::TForCall A 0 C`.
3864    ///
3865    /// Base path: copy R[A..=A+2] → R[A+4..=A+6] + `begin_call`.
3866    /// ipairs `inext` fast path at the top — skip begin_call
3867    ///     when R[A]=Native(ipairs_iter), R[A+1]=Table no-mt,
3868    ///     R[A+2]=Int.
3869    /// Batched out-ptr writeback — fill ctrl/key/val raws into
3870    ///     caller-provided buffers + return R[A+4]'s tag byte. Lets
3871    ///     emit skip 3 separate `luna_jit_stack_load` calls and 1
3872    ///     `luna_jit_stack_tag` call by reading the buffer via
3873    ///     cranelift `stack_load` IR instead. Returns -1 on deopt,
3874    ///     else R[A+4]'s tag byte | R[A+5]'s tag byte << 8 (the value's
3875    ///     tag only when `nvars >= 2`, 0 otherwise).
3876    #[doc(hidden)]
3877    #[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.
3878    pub fn jit_op_tforcall(
3879        &mut self,
3880        slot_offset: u32,
3881        nvars: i32,
3882        ctrl_out: *mut i64,
3883        key_out: *mut i64,
3884        val_out: *mut i64,
3885    ) -> i64 {
3886        let Some(f) = self.jit_last_lua_frame() else {
3887            return -1;
3888        };
3889        let abs = f.base + slot_offset;
3890        let need = (abs + 7) as usize;
3891        if self.stack.len() < need {
3892            self.stack.resize(need, Value::Nil);
3893        }
3894        // ipairs fast path
3895        let took_fast_path = if let Value::Native(n) = self.stack[abs as usize]
3896            && std::ptr::fn_addr_eq(
3897                n.f,
3898                crate::vm::builtins::ipairs_iter as crate::runtime::value::NativeFn,
3899            )
3900            && let Value::Table(t) = self.stack[(abs + 1) as usize]
3901            && t.metatable().is_none()
3902            && let Value::Int(i) = self.stack[(abs + 2) as usize]
3903        {
3904            let next_i = i.wrapping_add(1);
3905            let v = t.get_int(next_i);
3906            if v.is_nil() {
3907                self.stack[(abs + 4) as usize] = Value::Nil;
3908            } else {
3909                self.stack[(abs + 4) as usize] = Value::Int(next_i);
3910                if (nvars as usize) >= 2 {
3911                    self.stack[(abs + 5) as usize] = v;
3912                }
3913                for j in 2..nvars as usize {
3914                    let slot = abs + 4 + j as u32;
3915                    if (slot as usize) < self.stack.len() {
3916                        self.stack[slot as usize] = Value::Nil;
3917                    }
3918                }
3919            }
3920            true
3921        } else {
3922            false
3923        };
3924        if !took_fast_path {
3925            // slow path: copy R[A..=A+2] → R[A+4..=A+6], then
3926            // route through begin_call. Lua-closure iters would push
3927            // a Lua frame mid-trace → deopt.
3928            self.stack[(abs + 4) as usize] = self.stack[abs as usize];
3929            self.stack[(abs + 5) as usize] = self.stack[(abs + 1) as usize];
3930            self.stack[(abs + 6) as usize] = self.stack[(abs + 2) as usize];
3931            // the interpreter raises the call's error itself; and a native
3932            // that `begin_call` hands to the interpreter loop (pcall, xpcall,
3933            // pairs, an async native) pushes frames or parks a future
3934            // instead of returning its results here
3935            let runs_to_completion = match self.stack[abs as usize] {
3936                Value::Native(nc) => {
3937                    use crate::runtime::value::NativeFn;
3938                    !nc.is_async
3939                        && ![
3940                            nat_pcall as NativeFn,
3941                            nat_xpcall as NativeFn,
3942                            nat_host_xpcall as NativeFn,
3943                            nat_pairs as NativeFn,
3944                        ]
3945                        .iter()
3946                        .any(|&g| std::ptr::fn_addr_eq(nc.f, g))
3947                }
3948                _ => false,
3949            };
3950            if !runs_to_completion || self.begin_call(abs + 4, Some(2), nvars, false).is_err() {
3951                self.jit.counters.deopt += 1;
3952                return -1;
3953            }
3954        }
3955        // Batched writeback — fill the caller's buffers with the
3956        // raw bits of R[A+2] / R[A+4] / R[A+5] so the trace IR can
3957        // reload via cranelift `stack_load` instead of separate
3958        // `luna_jit_stack_load` helper calls.
3959        // 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).
3960        let ctrl_raw = unsafe { self.stack[(abs + 2) as usize].unpack().1.zero };
3961        let (key_tag, key_rv) = self.stack[(abs + 4) as usize].unpack();
3962        // 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).
3963        let key_raw = unsafe { key_rv.zero };
3964        let (val_tag, val_raw) = if (nvars as usize) >= 2 {
3965            let (tag, rv) = self.stack[(abs + 5) as usize].unpack();
3966            // 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).
3967            (tag, unsafe { rv.zero })
3968        } else {
3969            (0, 0u64)
3970        };
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        unsafe {
3973            ctrl_out.write(ctrl_raw as i64);
3974            key_out.write(key_raw as i64);
3975            val_out.write(val_raw as i64);
3976        }
3977        i64::from(key_tag) | i64::from(val_tag) << 8
3978    }
3979
3980    /// Load the raw `i64` payload of
3981    /// `vm.stack[base + slot_offset]` for the active trace's head
3982    /// Lua frame. Used to reload trace IR `Variable`s after a
3983    /// helper has written to `vm.stack` directly (e.g. TForCall's
3984    /// iter results land at `R[A+4..A+4+nvars]`).
3985    #[doc(hidden)]
3986    pub fn jit_stack_load(&mut self, slot_offset: u32) -> i64 {
3987        let Some(f) = self.jit_last_lua_frame() else {
3988            return 0;
3989        };
3990        let idx = (f.base as usize) + (slot_offset as usize);
3991        if idx >= self.stack.len() {
3992            return 0;
3993        }
3994        let v = self.stack[idx];
3995        let (_, raw) = v.unpack();
3996        // 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).
3997        unsafe { raw.zero as i64 }
3998    }
3999
4000    /// Read the tag byte of
4001    /// `vm.stack[base + slot_offset]`. Used by `Op::TForLoop` emit
4002    /// to dispatch on the iterator's return-key tag at runtime
4003    /// (`raw::NIL` → loop end exit, `raw::INT` → continue, other →
4004    /// deopt).
4005    #[doc(hidden)]
4006    pub fn jit_stack_tag(&mut self, slot_offset: u32) -> u8 {
4007        let Some(f) = self.jit_last_lua_frame() else {
4008            return crate::runtime::value::raw::NIL;
4009        };
4010        let idx = (f.base as usize) + (slot_offset as usize);
4011        if idx >= self.stack.len() {
4012            return crate::runtime::value::raw::NIL;
4013        }
4014        self.stack[idx].unpack().0
4015    }
4016
4017    /// Push a Lua frame onto the call stack with
4018    /// JIT-known metadata. Used by `luna_jit_trace_materialize_frames`
4019    /// at trace side-exits to recreate the inlined call activations
4020    /// the lowerer compiled past. The contract (enforced by the
4021    /// lowerer's pre-emit pass): `cl.proto` is non-vararg,
4022    /// `nresults` is the caller's expected count (today always 1
4023    /// because the lowerer bails Op::Call C != 2), and the caller
4024    /// has already called `jit_ensure_stack` to cover
4025    /// `[0..base + cl.proto.max_stack)`.
4026    #[doc(hidden)]
4027    pub fn jit_push_inlined_frame(
4028        &mut self,
4029        cl: Gc<LuaClosure>,
4030        base: u32,
4031        pc: u32,
4032        nresults: i32,
4033    ) {
4034        self.set_frame_ccmt(0);
4035        frames_push_sync(
4036            &mut self.frames,
4037            &mut self.frames_top,
4038            CallFrame::Lua(Frame {
4039                closure: cl,
4040                base,
4041                pc,
4042                // Lua call ABI: callee R[0] sits at caller R[A+1], so
4043                // callee.base = caller.base + A + 1; func_slot is
4044                // caller.base + A = callee.base - 1.
4045                func_slot: base - 1,
4046                n_varargs: 0,
4047                nresults,
4048                hook_oldpc: u32::MAX,
4049                from_c: false,
4050                tm: None,
4051                is_hook: false,
4052                tailcalls: 0,
4053            }),
4054        );
4055    }
4056
4057    /// Toggle precompiled-chunk loading. Default `true`. Sandbox embedders
4058    /// should set to `false` so `load`/`loadstring` reject bytecode input
4059    /// (which bypasses parser limits and could exploit verifier gaps).
4060    pub fn set_bytecode_loading(&mut self, enabled: bool) {
4061        self.bytecode_loading = enabled;
4062    }
4063
4064    /// Current bytecode-loading gate state.
4065    pub fn bytecode_loading(&self) -> bool {
4066        self.bytecode_loading
4067    }
4068
4069    /// Toggle PUC `.luac` bytecode loading. Default `false` — PUC
4070    /// bytecode is a strictly larger trust surface than luna's own dump
4071    /// format (third-party toolchain bugs, malformed chunks, unknown
4072    /// opcode shapes). Enable only for trusted PUC chunks. Per-dialect
4073    /// translators live in `crate::vm::dump::puc`.
4074    pub fn set_puc_bytecode_loading(&mut self, enabled: bool) {
4075        self.puc_bytecode_loading = enabled;
4076    }
4077
4078    /// Current PUC bytecode-loading gate state.
4079    pub fn puc_bytecode_loading(&self) -> bool {
4080        self.puc_bytecode_loading
4081    }
4082
4083    /// Default loader input budget — 256 MiB.
4084    ///
4085    /// `Vm::load` and the Lua-level `load(reader, ...)` both refuse
4086    /// sources whose byte length crosses this cap, returning the
4087    /// PUC-shaped `not enough memory` error rather than letting the
4088    /// host allocator try (and crash) to hold the next chunk.
4089    pub const DEFAULT_LOADER_INPUT_BUDGET: usize = 256 * 1024 * 1024;
4090
4091    /// Set the loader input byte budget (see
4092    /// [`Vm::DEFAULT_LOADER_INPUT_BUDGET`]). Pass `usize::MAX` to
4093    /// effectively disable. Smaller caps are honored verbatim — a 0
4094    /// cap rejects every non-empty source.
4095    pub fn set_loader_input_budget(&mut self, bytes: usize) {
4096        self.loader_input_budget = bytes;
4097    }
4098
4099    /// Current loader input byte budget.
4100    pub fn loader_input_budget(&self) -> usize {
4101        self.loader_input_budget
4102    }
4103
4104    /// Take the error traceback captured at the latest error point and
4105    /// reset it. Embedders should call this immediately after a failed
4106    /// `call_value`/`eval`/`call`/etc. — the next public `call_value`
4107    /// entry clears it. Returns `None` if no error was in flight.
4108    pub fn take_error_traceback(&mut self) -> Option<String> {
4109        let levels = self.error_traceback.take()?;
4110        let tb = crate::vm::callstack::traceback_from_lines(self.version, &levels, 0);
4111        Some(String::from_utf8_lossy(&tb).into_owned())
4112    }
4113
4114    /// PUC `luaL_traceback(L, L, msg, level)` on the running thread: `msg`
4115    /// (when given) and a newline, then `stack traceback:` and one line per
4116    /// stack level from `level` on, level 0 being the running function (the
4117    /// native calling this, when a native does).
4118    pub fn traceback(&mut self, msg: Option<&[u8]>, level: i64) -> Vec<u8> {
4119        let mut out = match msg {
4120            Some(m) => {
4121                let mut out = m.to_vec();
4122                out.push(b'\n');
4123                out
4124            }
4125            None => Vec::new(),
4126        };
4127        out.extend_from_slice(b"stack traceback:");
4128        out.extend(self.traceback_lines(None, level));
4129        out
4130    }
4131
4132    /// Arm the soft memory cap. The run loop checks the
4133    /// heap's tracked byte usage between dispatch turns; on overshoot it
4134    /// first runs a full collect, and if `bytes` still exceeds the cap it
4135    /// raises a catchable `"memory cap exceeded"` Lua error and disarms
4136    /// itself (fire-once: re-arm before the next `call_value` if reusing
4137    /// the Vm across requests). `None` removes the cap. The accounting is
4138    /// approximate — internal Vec/Box capacity overhead is not tracked,
4139    /// so embedders should size the cap with ~2× margin over the desired
4140    /// hard limit and additionally bound the Vm's lifetime (drop after
4141    /// each request).
4142    pub fn set_memory_cap(&mut self, cap: Option<usize>) {
4143        self.heap.mem_cap = cap;
4144    }
4145
4146    /// Approximate bytes the heap is currently holding. Object shells plus
4147    /// every table's internal array/hash boxes (tracked via
4148    /// `Heap::apply_bytes_delta` in `set`/`rehash`/`ensure_*`). Proto
4149    /// bytecode and closure upvalue slices still go uncounted — this is a
4150    /// lower bound, not a precise `malloc_stats`-style total.
4151    pub fn memory_used(&self) -> usize {
4152        self.heap.bytes()
4153    }
4154
4155    /// Read upvalue slot `i` of the native function currently on top of the
4156    /// dispatch chain (the one whose body is executing). Returns `Value::Nil`
4157    /// when no native is running. Public so the C ABI trampoline can fetch
4158    /// the host C function pointer it stashed there at registration time.
4159    pub fn running_native_upvalue(&self, i: usize) -> Value {
4160        match self.running_natives.last() {
4161            // 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).
4162            Some(nc) => unsafe {
4163                let upvals = &(*nc.as_ptr()).upvals;
4164                upvals.get(i).copied().unwrap_or(Value::Nil)
4165            },
4166            None => Value::Nil,
4167        }
4168    }
4169
4170    /// Register a table for finalization if its (just-set) metatable carries a
4171    /// `__gc` metamethod (PUC luaC_checkfinalizer at setmetatable time — adding
4172    /// `__gc` to the metatable afterwards does not retroactively register).
4173    pub(crate) fn check_finalizer(&mut self, t: Gc<Table>) {
4174        // Tables gained finalizers in 5.2; PUC 5.1 runs `__gc` for userdata only.
4175        if self.version == crate::version::LuaVersion::Lua51 {
4176            return;
4177        }
4178        if !self.get_mm(Value::Table(t), Mm::Gc).is_nil() {
4179            self.heap.register_finalizable(t);
4180        }
4181    }
4182
4183    /// Same as [`Self::check_finalizer`] for a userdata. PUC 5.1 attaches the
4184    /// finalizer to the proxy produced by `newproxy(true)` once its metatable
4185    /// gains `__gc`. gc.lua's "testing userdata" section sets `__gc` on the
4186    /// metatable that `newproxy` returned, which then needs to flow through.
4187    /// Kept available for the future 5.2+ `lua_setmetatable` path (which
4188    /// would re-check at metatable-set time); luna's only userdata
4189    /// finalizables today come via `newproxy`, which registers itself.
4190    #[allow(dead_code)]
4191    pub(crate) fn check_finalizer_userdata(&mut self, u: Gc<crate::runtime::Userdata>) {
4192        if !self.get_mm(Value::Userdata(u), Mm::Gc).is_nil() {
4193            self.heap.register_finalizable_userdata(u);
4194        }
4195    }
4196
4197    /// Run pending `__gc` finalizers (objects the collector resurrected for
4198    /// finalization). Finalizer errors are swallowed — PUC turns them into a
4199    /// warning; they must never propagate to the mutator. Reentrancy-guarded.
4200    fn run_finalizers(&mut self) {
4201        let _ = self.run_finalizers_or_err();
4202    }
4203
4204    fn run_finalizers_or_err(&mut self) -> Result<(), LuaError> {
4205        if self.gc_finalizing {
4206            return Ok(());
4207        }
4208        let pending = self.heap.take_tobefnz();
4209        if pending.is_empty() {
4210            return Ok(());
4211        }
4212        self.gc_finalizing = true;
4213        let mut first_err: Option<LuaError> = None;
4214        for obj in pending {
4215            let gc = self.get_mm(obj, Mm::Gc);
4216            // PUC 5.2+ accepts any non-nil `__gc` at setmetatable time to
4217            // schedule the object for finalization (`__gc = true` is the
4218            // canonical placeholder); only call it at finalize time when it
4219            // is actually a function. gc.lua 5.2 :412 wires up exactly this
4220            // sentinel and then expects no call.
4221            let callable = matches!(gc, Value::Closure(_) | Value::Native(_));
4222            if callable {
4223                // PUC `GCTM` sets `CIST_FIN` on the new ci so
4224                // `funcnamefromfinalizer` reports `namewhat = "metamethod"`,
4225                // `name = "__gc"`. luna threads the same outcome through the
4226                // generic `pending_tm` slot: the Lua frame born from this
4227                // call consumes it in `push_frame`. Saved/restored around the
4228                // call in case the handler is a native (which never pops it).
4229                // Bare event name; `frame_name` / `c_frame_name` add the
4230                // `"__"` debug prefix for 5.2/5.3, drop it for 5.4+. Matches
4231                // the convention used by `__close`, `__index`, …
4232                let saved_tm = self.pending_tm.replace("gc");
4233                // PUC `GCTM` runs the finalizer with `luaD_pcall` and no
4234                // message handler
4235                if let Err(e) = self.call_protected(gc, &[obj]) {
4236                    // PUC 5.1 GCTM raised the finalizer's error to the
4237                    // explicit `collectgarbage()` caller (`gc.lua 5.1 :255`
4238                    // baselines on `not pcall(collectgarbage)`). 5.2/5.3
4239                    // wrapped it in `error in __gc metamethod (msg)` first
4240                    // (`callGCTM` → `luaG_runerror`) but still raised. 5.4
4241                    // introduced the warning system and switched to "warn
4242                    // then continue" — never re-raise, just route the
4243                    // wrapped message through `warn`. gc.lua 5.5 :378 wires
4244                    // up `_WARN` capture under the `if T then …` block to
4245                    // baseline on the same wrapped string.
4246                    if self.version >= LuaVersion::Lua54 {
4247                        let inner = self.error_text(&e);
4248                        let msg = format!("error in __gc metamethod ({inner})");
4249                        self.emit_warn(msg.as_bytes(), false);
4250                    } else if first_err.is_none() {
4251                        let wrapped = if self.version >= LuaVersion::Lua52 {
4252                            let inner = self.error_text(&e);
4253                            let msg = format!("error in __gc metamethod ({inner})");
4254                            let s = Value::Str(self.heap.intern(msg.as_bytes()));
4255                            LuaError(s)
4256                        } else {
4257                            e
4258                        };
4259                        first_err = Some(wrapped);
4260                    }
4261                }
4262                self.pending_tm = saved_tm;
4263            }
4264        }
4265        self.gc_finalizing = false;
4266        match first_err {
4267            Some(e) => Err(e),
4268            None => Ok(()),
4269        }
4270    }
4271
4272    /// Drive one incremental GC step (PUC `collectgarbage("step", n)`).
4273    /// Crosses up to three phases per call:
4274    ///   1. Pause      → seed Propagate (`gc_start_propagate`)
4275    ///   2. Propagate  → drain gray up to `budget`; on exhaustion run atomic
4276    ///                   (`gc_finish_atomic` → tobefnz populated; finalizers
4277    ///                   run via `run_finalizers`) and enter Sweep
4278    ///   3. Sweep      → `gc_sweep_step` up to (residual) `budget`
4279    /// Returns true when this call completed the cycle's sweep (back to
4280    /// Pause). The budget is spent generously across phases — a large `n`
4281    /// can finish a whole cycle in one call (PUC stop-the-world step).
4282    pub(crate) fn gc_step(&mut self, budget: usize) -> bool {
4283        // Re-entry guard: never recurse — `run_finalizers` calls Lua code
4284        // that may hit a safe point and try to step again. Re-entry was OK
4285        // under STW (collect_garbage had its own guard) but here the
4286        // intermediate phase state would corrupt.
4287        if self.gc_finalizing {
4288            return false;
4289        }
4290        if self.heap.gc_phase_is_pause() {
4291            let (roots, extra) = self.gc_roots();
4292            self.heap.gc_start_propagate(&roots, &extra);
4293        }
4294        if self.heap.gc_phase_is_propagate() {
4295            if !self.heap.gc_step_propagate(budget) {
4296                return false;
4297            }
4298            self.heap.gc_finish_atomic();
4299            // any __gc scheduled by atomic — run before sweep so a finalizer
4300            // re-registering `self` re-enters the next cycle, not this sweep
4301            self.run_finalizers();
4302        }
4303        // either we just transitioned, or we entered already in Sweep, or
4304        // a finalizer started a new cycle (gc_sweep_step is a no-op then)
4305        self.heap.gc_sweep_step(budget)
4306    }
4307
4308    // ---- frames & calls ----
4309
4310    /// Begin calling stack[func_slot] with `nargs` (None: up to self.top).
4311    /// Returns true if a Lua frame was pushed (the dispatch loop continues
4312    /// there), false if a native completed inline.
4313    fn begin_call(
4314        &mut self,
4315        func_slot: u32,
4316        nargs: Option<u32>,
4317        nresults: i32,
4318        from_c: bool,
4319    ) -> Result<bool, LuaError> {
4320        let mut nargs = match nargs {
4321            Some(n) => n,
4322            None => self.top - (func_slot + 1),
4323        };
4324        // Consume `pending_is_tail` at the boundary: a tail-call op sets it
4325        // only for the immediately-following Lua activation. Native dispatch
4326        // (or `__call` resolution) below must not let it leak to the next
4327        // begin_call's frame; restore it just before push_frame for the Lua
4328        // arm so its meaning is preserved across __call chaining.
4329        let tailcalls = std::mem::take(&mut self.pending_tailcalls);
4330        let tail_ccmt = std::mem::take(&mut self.pending_ccmt);
4331        // resolve __call handlers iteratively (PUC tryfuncTM loop): each handler
4332        // is inserted before the value so it becomes the first argument, and a
4333        // chain of `__call` tables resolves down to a real function.
4334        let mut chain = 0u32;
4335        loop {
4336            match self.stack[func_slot as usize] {
4337                Value::Closure(cl) => {
4338                    // JIT fast path: if the Proto's body fits
4339                    // the int-arith whitelist, every arg is `Value::Int`,
4340                    // and the cached arity matches, skip frame setup and
4341                    // run the cached native fn in-place.
4342                    if self.try_jit_call_op(cl, func_slot, nargs, nresults) {
4343                        self.pending_tailcalls = tailcalls;
4344                        return Ok(false);
4345                    }
4346                    self.pending_tailcalls = tailcalls;
4347                    self.pending_ccmt = if tailcalls > 0 {
4348                        tail_ccmt
4349                    } else {
4350                        chain as u8
4351                    };
4352                    self.push_frame(cl, func_slot, nargs, nresults, from_c)?;
4353                    // Trace-on-call trigger. The frame
4354                    // we just pushed is the callee whose body the
4355                    // recorder will trace. Bump the per-Proto call
4356                    // counter; once it crosses `CALL_HOT_THRESHOLD`
4357                    // and no other trace is in flight, snapshot the
4358                    // callee's register window (R[0..max_stack]) and
4359                    // begin recording at `pc=0`. This is what unlocks
4360                    // tracing for functions whose body has no negative
4361                    // `Op::Jmp` back-edge (`fib`, recursive helpers).
4362                    //
4363                    // Gated on `trace_jit_enabled`, so the default
4364                    // dispatch pays a single not-taken branch.
4365                    if self.jit.trace_enabled {
4366                        let proto = cl.proto;
4367                        let c = proto.call_hot_count.get();
4368                        if c < u32::MAX / 2 {
4369                            proto.call_hot_count.set(c + 1);
4370                        }
4371                        // Relaxed call-trigger:
4372                        // `c >= THRESHOLD` (not `c == THRESHOLD`) +
4373                        // `!already_cached` short-circuit. Lets a
4374                        // discarded short call-trigger close retry
4375                        // on the next call (fib(10/15/20/25)
4376                        // pathology — first capture is base-case
4377                        // [Lt,Jmp,Return1]; coverage-heuristic
4378                        // discards; next call gets to record at a
4379                        // potentially deeper recursion point).
4380                        // Without `already_cached`, the relaxed
4381                        // condition would re-record over a cached
4382                        // trace every call.
4383                        //
4384                        // Additionally short-circuit on
4385                        // `proto.trace_gave_up`. The per-Proto discard
4386                        // cap force-compiles a partial trace and
4387                        // flips this flag; subsequent calls into
4388                        // this Proto skip the RefCell borrow + Vec
4389                        // scan entirely.
4390                        if proto.trace_gave_up.get() {
4391                            return Ok(true);
4392                        }
4393                        let call_already_cached =
4394                            proto.traces.borrow().iter().any(|t| t.head_pc == 0)
4395                                || trace_head_abandoned(proto, 0);
4396                        if c >= self.jit.call_hot_threshold
4397                            && self.jit.active_trace.is_none()
4398                            && !call_already_cached
4399                        {
4400                            // The new frame is on top: index in
4401                            // `self.frames` is `len() - 1`.
4402                            let frame_idx = self.frames.len() - 1;
4403                            // Snapshot R[0..max_stack] at the callee's
4404                            // base. `push_frame` resized `self.stack`
4405                            // to `base + max_stack`, so this window is
4406                            // guaranteed in-bounds.
4407                            let f = match &self.frames[frame_idx] {
4408                                CallFrame::Lua(f) => f,
4409                                _ => unreachable!("push_frame just pushed a Lua frame"),
4410                            };
4411                            let max_stack = cl.proto.max_stack as usize;
4412                            let base_us = f.base as usize;
4413                            let mut entry_tags = Vec::with_capacity(max_stack);
4414                            for i in 0..max_stack {
4415                                let (tag, _) = self.stack[base_us + i].unpack();
4416                                entry_tags.push(tag);
4417                            }
4418                            self.jit.active_trace =
4419                                Some(Box::new(crate::jit::trace::TraceRecord::start(
4420                                    cl.proto, 0, entry_tags, true,
4421                                )));
4422                            self.jit.recording_frame_base = frame_idx;
4423                        }
4424                    }
4425                    return Ok(true);
4426                }
4427                Value::Native(nc) => {
4428                    // Async-marked NativeClosure.
4429                    // Route through the cooperative-yield mechanism
4430                    // when async_mode is on; reject when called from
4431                    // a sync `eval`/`call_value` path (would have no
4432                    // executor to drive the returned future).
4433                    if nc.is_async {
4434                        if !self.async_mode {
4435                            let s = Value::Str(
4436                                self.heap.intern(b"async native called in sync context"),
4437                            );
4438                            self.last_error_kind = crate::vm::error::LuaErrorKind::Runtime;
4439                            return Err(LuaError(s));
4440                        }
4441                        // Same root-up bookkeeping as the sync path:
4442                        // pin args + result-count expectation so a
4443                        // collection across the suspend boundary
4444                        // keeps the arg window live.
4445                        self.native_nresults = nresults;
4446                        self.gc_top = func_slot + nargs + 1;
4447                        // Fire the "call" hook BEFORE
4448                        // building the future. Mirrors the sync native
4449                        // path's `hook_call(true, nargs)` site
4450                        // (`exec.rs` further down) so embedders with a
4451                        // Rust debug hook installed see a Call event
4452                        // for async natives identical to the sync
4453                        // path. The matching "return" hook fires from
4454                        // `commit_async_native_result` in
4455                        // `async_drive.rs` after the future resolves.
4456                        // Placement: after the `native_nresults` / `gc_top`
4457                        // pin, before the future is constructed, so a
4458                        // hook body that triggers GC observes the
4459                        // correct pinned window. On hook error the
4460                        // sentinel never returns and
4461                        // `pending_async_native_*` remain `None` —
4462                        // the executor sees `DispatchOutcome::Error`.
4463                        self.hook_call(true, nargs)?;
4464                        // Transmute the stored NativeFn back to its
4465                        // real AsyncNativeFn shape. Sound because
4466                        // `set_async_native` / `create_async_native`
4467                        // installed an AsyncNativeFn through the
4468                        // identically-sized fn-pointer slot, and the
4469                        // `is_async` marker bit is what records that
4470                        // fact.
4471                        let async_fn: crate::vm::async_drive::AsyncNativeFn =
4472                            // SAFETY: same-size fn pointers; provenance
4473                            // preserved through `mem::transmute`. The
4474                            // `is_async` marker is the only safe-to-call
4475                            // gate, set exclusively by
4476                            // `Vm::create_async_native`.
4477                            unsafe { std::mem::transmute(nc.f) };
4478                        let vm_ptr: *mut Vm = self;
4479                        let fut = async_fn(vm_ptr, func_slot, nargs);
4480                        // Stash the future + post-call context for
4481                        // `drive_one` to surface to `EvalFuture::poll`.
4482                        self.pending_async_native_fut = Some(fut);
4483                        self.pending_async_native_ctx = Some(AsyncNativeCallCtx {
4484                            func_slot,
4485                            nargs,
4486                            nresults,
4487                            gc_top: self.gc_top,
4488                        });
4489                        // Sentinel Err walked up to `drive_one` (same
4490                        // shape as `host_yield_pending`'s budget yield).
4491                        // Value::Nil — never seen by user code.
4492                        return Err(LuaError(Value::Nil));
4493                    }
4494                    // pcall/xpcall are yieldable: rather than calling the
4495                    // protected function through the Rust stack (which cannot be
4496                    // suspended), push a continuation frame and drive the call
4497                    // through the interpreter loop (PUC lua_pcallk). A yield
4498                    // inside it is preserved with the thread's saved frames.
4499                    use crate::runtime::value::NativeFn;
4500                    if std::ptr::fn_addr_eq(nc.f, nat_pcall as NativeFn) {
4501                        return self.begin_pcall(func_slot, nargs, nresults);
4502                    }
4503                    if std::ptr::fn_addr_eq(nc.f, nat_xpcall as NativeFn) {
4504                        // 5.1 `xpcall(f, err)` calls `f` with no arguments
4505                        let forward = self.version > LuaVersion::Lua51;
4506                        return self.begin_xpcall(func_slot, nargs, nresults, forward);
4507                    }
4508                    if std::ptr::fn_addr_eq(nc.f, nat_host_xpcall as NativeFn) {
4509                        return self.begin_xpcall(func_slot, nargs, nresults, true);
4510                    }
4511                    // From 5.4 on, pairs(t) calls a __pairs metamethod yieldably
4512                    // (PUC luaB_pairs uses lua_callk). 5.2/5.3 use a plain
4513                    // lua_call, and 5.1 has no `__pairs`: the native handles those.
4514                    if std::ptr::fn_addr_eq(nc.f, nat_pairs as NativeFn)
4515                        && nargs >= 1
4516                        && self.version >= LuaVersion::Lua54
4517                    {
4518                        let arg = self.stack[(func_slot + 1) as usize];
4519                        if !self.get_mm(arg, Mm::Pairs).is_nil() {
4520                            return self.begin_pairs(func_slot, nresults);
4521                        }
4522                    }
4523                    // a native that collects (e.g. `collectgarbage`) roots up to
4524                    // its own arguments — the caller's live registers all sit
4525                    // below `func_slot` and stay rooted.
4526                    self.native_nresults = nresults;
4527                    self.gc_top = func_slot + nargs + 1;
4528                    // Push the native onto the running-natives chain BEFORE
4529                    // firing the call hook so that `debug.getinfo(level)` and
4530                    // `arg_error` from inside the hook see this native as the
4531                    // currently-running C function (db.lua :344 reads
4532                    // `getinfo(2, "f").func` for the just-entered callee).
4533                    // Popped after the matching return hook fires — even on
4534                    // error, the pop must happen, so the body is bracketed
4535                    // through a scope guard.
4536                    self.running_natives.push(nc);
4537                    self.running_native_acts
4538                        .push(crate::vm::callstack::NativeAct {
4539                            func_slot,
4540                            nargs,
4541                            depth: self.frames.len() as u32,
4542                            // a tail call resolved its `__call` chain before
4543                            // calling here and passed the count in tail_ccmt
4544                            ccmt: tail_ccmt + chain as u8,
4545                        });
4546                    // PUC C-call discipline: entering a C function sets
4547                    // L->top to func + 1 + nargs, so a collect triggered
4548                    // INSIDE the native (explicit `collectgarbage()`, or
4549                    // an allocation crossing the GC threshold) roots the
4550                    // whole caller window up to and including the
4551                    // arguments. Without this raise the cursor is stale —
4552                    // parked at some earlier, possibly much lower
4553                    // safe-point — and the collect frees register-held
4554                    // values of the native's own caller (use-after-free).
4555                    // Never lower it: a re-entrant chain
4556                    // (native → Lua → native) must keep the outermost
4557                    // window rooted.
4558                    self.gc_top = self.gc_top.max(func_slot + 1 + nargs);
4559                    // PUC luaD_precall fires the "call" hook for C functions too.
4560                    // A yield inside the native (coroutine.yield) propagates an
4561                    // Err and the matching "return" hook fires on resume instead.
4562                    if let Err(e) = self.hook_call(true, nargs) {
4563                        self.running_natives.pop();
4564                        self.running_native_acts.pop();
4565                        return Err(e);
4566                    }
4567                    // Trap a Rust panic in the native and surface it as
4568                    // a Lua error rather than letting it unwind through the
4569                    // VM into the embedder. The VM's internal state may still
4570                    // be inconsistent after a panic (half-pushed args,
4571                    // dangling GC references), so embedders that catch this
4572                    // class of error should drop and re-create the Vm — but
4573                    // it's still better than tearing the host process down.
4574                    // `AssertUnwindSafe` is sound because the caller is the
4575                    // dispatch loop and any half-done state is fenced behind
4576                    // the immediate Err return below.
4577                    use std::panic::{AssertUnwindSafe, catch_unwind};
4578                    let result =
4579                        match catch_unwind(AssertUnwindSafe(|| (nc.f)(self, func_slot, nargs))) {
4580                            Ok(r) => r,
4581                            Err(payload) => {
4582                                let msg = panic_payload_str(&payload);
4583                                let s = Value::Str(
4584                                    self.heap.intern(format!("native panic: {msg}").as_bytes()),
4585                                );
4586                                Err(LuaError(s))
4587                            }
4588                        };
4589                    let nret = match result {
4590                        Ok(n) => n,
4591                        Err(e) => {
4592                            // PUC raises with the native still on the stack;
4593                            // remember it for the handler and traceback of the
4594                            // error (see `raise_to_handler`)
4595                            let act = self.running_native_acts.pop().expect("pushed above");
4596                            self.running_natives.pop();
4597                            self.note_errored_native(nc, act, e.0);
4598                            return Err(e);
4599                        }
4600                    };
4601                    // PUC `luaD_poscall` fires the return hook BEFORE moving
4602                    // results into the function's slot — at that point args
4603                    // sit at `[func_slot + 1, func_slot + 1 + nargs)` and
4604                    // results above them at `[func_slot + 1 + nargs, …)`.
4605                    // luna's `nat_return` has already written the results
4606                    // into `[func_slot, func_slot + nret)`, so we replay PUC's
4607                    // layout by copying the results up past the preserved
4608                    // args, firing the hook (with ftransfer = nargs + 1, so
4609                    // `getlocal(2, ftransfer..)` reads results), and then
4610                    // copying back for `finish_results`. db.lua :541 reads
4611                    // `getinfo("r").ftransfer` + `getlocal` to inspect a
4612                    // returning native's results this way.
4613                    if self.hook.ret
4614                        && !self.in_hook
4615                        && (self.hook.func.is_some() || self.hook.rust_func.is_some())
4616                    {
4617                        let res_dst = func_slot + nargs + 1;
4618                        let need = (res_dst + nret) as usize;
4619                        if self.stack.len() < need {
4620                            self.stack.resize(need, Value::Nil);
4621                        }
4622                        for i in (0..nret).rev() {
4623                            self.stack[(res_dst + i) as usize] =
4624                                self.stack[(func_slot + i) as usize];
4625                        }
4626                        // widen the C-frame's argument window for getlocal
4627                        if let Some(act) = self.running_native_acts.last_mut() {
4628                            act.nargs = nargs + nret;
4629                        }
4630                        let hr = self.hook_return(true, nargs + 1, nret);
4631                        if let Some(act) = self.running_native_acts.last_mut() {
4632                            act.nargs = nargs;
4633                        }
4634                        // restore results into the slot finish_results expects
4635                        for i in 0..nret {
4636                            self.stack[(func_slot + i) as usize] =
4637                                self.stack[(res_dst + i) as usize];
4638                        }
4639                        self.running_natives.pop();
4640                        self.running_native_acts.pop();
4641                        hr?;
4642                    } else {
4643                        self.running_natives.pop();
4644                        self.running_native_acts.pop();
4645                    }
4646                    self.finish_results(func_slot, nret, nresults);
4647                    // the native may have allocated; collect with the results as
4648                    // the live boundary (PUC checks GC after a call returns).
4649                    self.maybe_collect_garbage(self.top);
4650                    return Ok(false);
4651                }
4652                v => {
4653                    let mm = self.get_mm(v, Mm::Call);
4654                    if mm.is_nil() || self.call_mm_unusable(mm) {
4655                        return Err(self.call_err(v));
4656                    }
4657                    chain += 1;
4658                    // PUC 5.5 dropped the chain cap from `MAXTAGRECUR = 200`
4659                    // (the value 5.4's `lvm.c` uses) down to `MAXCCMT = 16`,
4660                    // and the 5.5 test exercises the new tight bound directly
4661                    // (calls.lua :225 builds a 16-deep chain and expects the
4662                    // 16th to error). 5.4 calls.lua :194 instead builds a 20-
4663                    // deep chain and expects it to succeed.
4664                    let cap = if self.version >= crate::version::LuaVersion::Lua55 {
4665                        15
4666                    } else {
4667                        MAX_CCMT
4668                    };
4669                    if chain > cap {
4670                        return Err(self.rt_err("'__call' chain too long"));
4671                    }
4672                    // slots above shift by one; at a call site those are dead
4673                    // temps of the current frame
4674                    self.stack.insert(func_slot as usize, mm);
4675                    if self.top > func_slot {
4676                        self.top += 1;
4677                    }
4678                    nargs += 1;
4679                }
4680            }
4681        }
4682    }
4683
4684    /// Up to 5.3 `tryfuncTM` takes one `__call` hop and needs a function
4685    /// there; anything else is a call error on the original object. 5.4
4686    /// retries the call with whatever `__call` holds, so chains resolve.
4687    fn call_mm_unusable(&self, mm: Value) -> bool {
4688        self.version <= LuaVersion::Lua53 && !matches!(mm, Value::Closure(_) | Value::Native(_))
4689    }
4690
4691    fn push_frame(
4692        &mut self,
4693        cl: Gc<LuaClosure>,
4694        func_slot: u32,
4695        nargs: u32,
4696        nresults: i32,
4697        from_c: bool,
4698    ) -> Result<(), LuaError> {
4699        if func_slot + 256 > MAX_LUA_STACK {
4700            // PUC `luaD_growstack`: the overflow raises "stack overflow" and
4701            // leaves ERRORSTACKSIZE's extra slots for the xpcall handler that
4702            // runs on it; overflowing those is LUA_ERRERR, "error in error
4703            // handling" (errors.lua :606, cstack.lua :29).
4704            if self.msgh_depth == 0 {
4705                return Err(self.rt_err("stack overflow"));
4706            }
4707            if func_slot + 256 > MAX_LUA_STACK + ERROR_STACK_EXTRA {
4708                return Err(self.plain_err("error in error handling"));
4709            }
4710        }
4711        let proto = cl.proto;
4712        let nparams = proto.num_params as u32;
4713        // 5.5 vararg layout (PUC luaT_adjustvarargs): the extra args stay on the
4714        // stack just below the new `base`, so a named vararg can be indexed
4715        // virtually without allocating a table. Rotate `[p1..pn][e1..em]` to
4716        // `[e1..em][p1..pn]` so the fixed params land at the new base.
4717        let n_varargs = if proto.is_vararg {
4718            nargs.saturating_sub(nparams)
4719        } else {
4720            0
4721        };
4722        if n_varargs > 0 {
4723            let s = (func_slot + 1) as usize;
4724            self.stack[s..s + nargs as usize].rotate_left(nparams as usize);
4725        }
4726        let base = func_slot + 1 + n_varargs;
4727        let need = (base + proto.max_stack as u32) as usize;
4728        if self.stack.len() < need {
4729            self.stack.resize(need, Value::Nil);
4730        }
4731        // wipe the register window beyond the kept parameters (stale values —
4732        // required for GC-safety and codegen). The varargs below `base` survive.
4733        let kept = nargs.saturating_sub(n_varargs).min(nparams);
4734        // SAFETY: just resized above so `need <= stack.len()`; `base + kept <=
4735        // need` since `base + nparams <= base + max_stack = need` and `kept <=
4736        // nparams`. `slice::fill` lowers to a single memset on Copy types.
4737        unsafe {
4738            self.stack
4739                .get_unchecked_mut((base + kept) as usize..need)
4740                .fill(Value::Nil);
4741        }
4742        let ccmt = std::mem::take(&mut self.pending_ccmt);
4743        self.set_frame_ccmt(ccmt);
4744        frames_push_sync(
4745            &mut self.frames,
4746            &mut self.frames_top,
4747            CallFrame::Lua(Frame {
4748                closure: cl,
4749                base,
4750                pc: 0,
4751                func_slot,
4752                nresults,
4753                hook_oldpc: u32::MAX,
4754                from_c,
4755                n_varargs,
4756                // single-shot consume: `close_slots` sets pending_tm before each
4757                // handler call; the next Lua frame born is that handler's.
4758                tm: self.pending_tm.take(),
4759                // `run_hook` sets `pending_is_hook` before dispatching the user
4760                // hook so its frame reports `namewhat = "hook"` via getinfo.
4761                is_hook: std::mem::take(&mut self.pending_is_hook),
4762                tailcalls: std::mem::take(&mut self.pending_tailcalls),
4763            }),
4764        );
4765        // PUC 5.1 `LUAI_COMPAT_VARARG`: populate the hidden `arg` local with
4766        // `{ n = n_varargs, [1] = e1, [2] = e2, … }`. The compiler reserved
4767        // the slot at `base + nparams`; the extras sit just below `base` from
4768        // the vararg rotate above. 5.1 db.lua :279 reads `arg.n` from a line
4769        // hook; vararg.lua's contradictory expectations were already going to
4770        // fail either way (some asserts want `arg == nil`).
4771        if proto.has_compat_vararg_arg {
4772            let arg_slot = (base + nparams) as usize;
4773            let t = self.heap.new_table();
4774            {
4775                // 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).
4776                let tm = unsafe { t.as_mut() };
4777                for i in 0..n_varargs {
4778                    let v = self.stack[(base - n_varargs + i) as usize];
4779                    // bounded by `n_varargs` (≤ MAXUPVAL territory), well
4780                    // below `MAX_ASIZE`
4781                    let _ = tm.set_int(&mut self.heap, (i + 1) as i64, v);
4782                }
4783                let nk = Value::Str(self.heap.intern(b"n"));
4784                tm.set(&mut self.heap, nk, Value::Int(n_varargs as i64))
4785                    .expect("'n' key");
4786            }
4787            // once-per-table barrier mirrors SETLIST: t is born BLACK during
4788            // Propagate and the bulk `set_int`/`set` calls above don't barrier
4789            self.heap
4790                .barrier_back(t.as_ptr() as *mut crate::runtime::heap::GcHeader);
4791            self.stack[arg_slot] = Value::Table(t);
4792        }
4793        // PUC luaD_precall fires the "call" hook with the new frame current, so
4794        // a hook calling debug.getinfo(2) sees the entered function. For a Lua
4795        // callee, PUC `luaD_hookcall` passes `p->numparams` as ntransfer (only
4796        // fixed params count — extras already live below `base`).
4797        // A frame born via OP_TailCall fires "tail call" instead (PUC
4798        // luaD_pretailcall) and skips the matching "return" hook on exit.
4799        let is_tail = self
4800            .frames
4801            .last()
4802            .and_then(|f| f.lua())
4803            .is_some_and(|f| f.tailcalls > 0);
4804        self.hook_call_with(false, nparams, is_tail)?;
4805        Ok(())
4806    }
4807
4808    /// `pcall(f, ...)` (PUC luaB_pcall): push a continuation frame, then drive
4809    /// the protected call `f` through the interpreter loop. The protected
4810    /// function and its arguments already sit at `func_slot+1..`, so calling `f`
4811    /// at `func_slot+1` lets its results land one slot above the continuation —
4812    /// the loop head then writes `true` at `func_slot` to form `true, results…`.
4813    /// Always returns `Ok(true)`: a continuation is now on the stack to be
4814    /// resolved by the loop (even when `f` is a native that already ran inline).
4815    fn begin_pcall(&mut self, func_slot: u32, nargs: u32, nresults: i32) -> Result<bool, LuaError> {
4816        if nargs == 0 {
4817            // `luaL_checkany` fails here: there is no function to call.
4818            self.with_native_running(func_slot, nargs, |vm| {
4819                let a = crate::vm::argcheck::Args::new(func_slot, nargs);
4820                crate::vm::argcheck::check_any(vm, a, 0).map(drop)
4821            })?;
4822        }
4823        if self.pcall_depth >= MAX_C_DEPTH {
4824            // raised inside pcall, a C function: no position
4825            return Err(self.plain_err("C stack overflow"));
4826        }
4827        self.pcall_depth += 1;
4828        frames_push_sync(
4829            &mut self.frames,
4830            &mut self.frames_top,
4831            CallFrame::Cont(NativeCont {
4832                kind: ContKind::Pcall,
4833                func_slot,
4834                nresults,
4835            }),
4836        );
4837        // call f (slot func_slot+1) with the remaining args, asking for all
4838        // results; a yield or error inside propagates with the continuation kept
4839        // on the stack (caught by `unwind` / preserved across a yield).
4840        self.begin_call(func_slot + 1, Some(nargs - 1), -1, true)?;
4841        Ok(true)
4842    }
4843
4844    /// `xpcall(f, msgh, ...)` (PUC luaB_xpcall): like `begin_pcall`, but the
4845    /// message handler is stashed in the continuation and the arguments are
4846    /// shifted down over the handler's slot so `f`'s args are contiguous.
4847    /// `forward` is false for 5.1's `xpcall`, which passes `f` none of them.
4848    fn begin_xpcall(
4849        &mut self,
4850        func_slot: u32,
4851        nargs: u32,
4852        nresults: i32,
4853        forward: bool,
4854    ) -> Result<bool, LuaError> {
4855        self.with_native_running(func_slot, nargs, |vm| {
4856            let a = crate::vm::argcheck::Args::new(func_slot, nargs);
4857            crate::vm::builtins::xpcall_handler(vm, a).map(drop)
4858        })?;
4859        if self.pcall_depth >= MAX_C_DEPTH {
4860            // raised inside pcall, a C function: no position
4861            return Err(self.plain_err("C stack overflow"));
4862        }
4863        self.pcall_depth += 1;
4864        // layout: [xpcall@func_slot, f@+1, msgh@+2, a1@+3, ...]. Stash msgh and
4865        // close its gap so f's args become [f@+1, a1@+2, ...].
4866        let handler = self.stack[(func_slot + 2) as usize];
4867        // 5.1: `xpcall (f, err)` takes exactly two parameters — extra
4868        // arguments are NOT forwarded to `f` (5.2 added forwarding;
4869        // 5.1 calls f with zero args).
4870        let nfargs = if forward { nargs - 2 } else { 0 };
4871        for i in 0..nfargs {
4872            self.stack[(func_slot + 2 + i) as usize] = self.stack[(func_slot + 3 + i) as usize];
4873        }
4874        self.top = func_slot + 2 + nfargs;
4875        frames_push_sync(
4876            &mut self.frames,
4877            &mut self.frames_top,
4878            CallFrame::Cont(NativeCont {
4879                kind: ContKind::Xpcall { handler },
4880                func_slot,
4881                nresults,
4882            }),
4883        );
4884        self.begin_call(func_slot + 1, Some(nfargs), -1, true)?;
4885        Ok(true)
4886    }
4887
4888    /// `pairs(t)` where `t` has a `__pairs` metamethod (PUC luaB_pairs's
4889    /// lua_callk path): drive `__pairs(t)` through the loop with a `Pairs`
4890    /// continuation so a `coroutine.yield` inside it suspends cleanly. The
4891    /// metamethod is called in `pairs`'s own slot, so its (≤4, nil-padded)
4892    /// results land exactly where `pairs`'s results belong.
4893    /// Run a check of the native at `func_slot` while it counts as the running
4894    /// C function, so an argument error names it the way PUC does. pcall and
4895    /// xpcall check their arguments in the dispatcher, before the native
4896    /// would otherwise be entered.
4897    fn with_native_running(
4898        &mut self,
4899        func_slot: u32,
4900        nargs: u32,
4901        check: impl FnOnce(&mut Vm) -> Result<(), LuaError>,
4902    ) -> Result<(), LuaError> {
4903        let Value::Native(nc) = self.stack[func_slot as usize] else {
4904            unreachable!("pcall/xpcall dispatch sits on a native")
4905        };
4906        self.running_natives.push(nc);
4907        self.running_native_acts
4908            .push(crate::vm::callstack::NativeAct {
4909                func_slot,
4910                nargs,
4911                depth: self.frames.len() as u32,
4912                ccmt: 0,
4913            });
4914        let r = check(self);
4915        self.running_natives.pop();
4916        self.running_native_acts.pop();
4917        r
4918    }
4919
4920    fn begin_pairs(&mut self, func_slot: u32, nresults: i32) -> Result<bool, LuaError> {
4921        let arg = self.stack[(func_slot + 1) as usize];
4922        let mm = self.get_mm(arg, Mm::Pairs);
4923        // layout becomes [pairs@func_slot, mm@func_slot+1, t@func_slot+2]:
4924        // `pairs` keeps its slot so the debug interface can report it as the
4925        // C function running below the metamethod. Call mm(t) wanting 4.
4926        let need = (func_slot + 3) as usize;
4927        if self.stack.len() < need {
4928            self.stack.resize(need, Value::Nil);
4929        }
4930        self.stack[(func_slot + 2) as usize] = arg;
4931        self.stack[(func_slot + 1) as usize] = mm;
4932        self.top = func_slot + 3;
4933        frames_push_sync(
4934            &mut self.frames,
4935            &mut self.frames_top,
4936            CallFrame::Cont(NativeCont {
4937                kind: ContKind::Pairs,
4938                func_slot,
4939                nresults,
4940            }),
4941        );
4942        let want = crate::vm::builtins::pairs_mm_results(self) as i32;
4943        self.begin_call(func_slot + 1, Some(1), want, true)?;
4944        Ok(true)
4945    }
4946
4947    /// The running (top) Lua frame. The interpreter only reads this while a Lua
4948    /// frame is on top — a continuation frame is never the running frame (it is
4949    /// consumed the instant the call it protects unwinds onto it).
4950    #[inline]
4951    /// Records the `__call` count of the Lua frame about to be pushed.
4952    fn set_frame_ccmt(&mut self, ccmt: u8) {
4953        let i = self.frames.len();
4954        if self.frame_ccmt.len() <= i {
4955            self.frame_ccmt.resize(i + 1, 0);
4956        }
4957        self.frame_ccmt[i] = ccmt;
4958    }
4959
4960    fn top_frame(&self) -> &Frame {
4961        self.frames
4962            .last()
4963            .and_then(CallFrame::lua)
4964            .expect("running Lua frame")
4965    }
4966
4967    #[inline]
4968    fn top_frame_mut(&mut self) -> &mut Frame {
4969        self.frames
4970            .last_mut()
4971            .and_then(CallFrame::lua_mut)
4972            .expect("running Lua frame")
4973    }
4974
4975    /// Pad/announce results sitting at func_slot.
4976    pub(crate) fn finish_results(&mut self, func_slot: u32, nret: u32, wanted: i32) {
4977        // Capture the call's high-water-mark before
4978        // setting the new top so we can Nil-clear slots that the
4979        // call temporarily wrote but no longer holds — matching
4980        // PUC's `L->top` discipline (slots past L->top are "free"
4981        // and the next push overwrites them). Without this clear,
4982        // a stale `Value::Closure` (e.g. the called function
4983        // itself, when wanted = 0) sits at `func_slot` and a
4984        // later GC with wider `gc_top` traces it after the
4985        // closure has been freed by a previous narrow safe-point
4986        // GC → heap-buffer-overflow in `Marker::header` (sort.lua
4987        // comparator case).
4988        let prev_top = self.top as usize;
4989        if wanted < 0 {
4990            self.top = func_slot + nret;
4991        } else {
4992            let wanted = wanted as u32;
4993            let need = (func_slot + wanted) as usize;
4994            if self.stack.len() < need {
4995                self.stack.resize(need, Value::Nil);
4996            }
4997            for i in nret..wanted {
4998                self.stack[(func_slot + i) as usize] = Value::Nil;
4999            }
5000            self.top = func_slot + wanted;
5001        }
5002        let new_top = self.top as usize;
5003        let clear_end = prev_top.min(self.stack.len());
5004        if new_top < clear_end {
5005            for slot in &mut self.stack[new_top..clear_end] {
5006                *slot = Value::Nil;
5007            }
5008        }
5009    }
5010
5011    /// Current Lua call-frame depth (read-only).
5012    /// Used by `EvalFuture` on the bootstrap poll to compute the
5013    /// `entry_depth` it will pass to subsequent resume slices.
5014    pub(crate) fn frame_count(&self) -> usize {
5015        self.frames.len()
5016    }
5017
5018    fn take_results(&mut self, func_slot: u32) -> Vec<Value> {
5019        let nret = self.top - func_slot;
5020        let out = self.stack[func_slot as usize..(func_slot + nret) as usize].to_vec();
5021        self.stack.truncate(func_slot as usize);
5022        self.top = func_slot;
5023        out
5024    }
5025
5026    // ---- open upvalues ----
5027
5028    #[doc(hidden)]
5029    pub fn find_or_create_upval(&mut self, slot: u32) -> Gc<Upvalue> {
5030        match self.open_upvals.binary_search_by_key(&slot, |&(s, _)| s) {
5031            Ok(i) => self.open_upvals[i].1,
5032            Err(i) => {
5033                let uv = self.heap.new_upvalue(UpvalState::Open {
5034                    slot,
5035                    thread: self.current,
5036                });
5037                self.open_upvals.insert(i, (slot, uv));
5038                uv
5039            }
5040        }
5041    }
5042
5043    pub(crate) fn close_from(&mut self, slot: u32) {
5044        while let Some(&(s, uv)) = self.open_upvals.last() {
5045            if s < slot {
5046                break;
5047            }
5048            let v = self.stack[s as usize];
5049            // 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).
5050            unsafe { uv.as_mut() }.set_closed(v);
5051            self.heap
5052                .barrier_forward(uv.as_ptr() as *mut crate::runtime::heap::GcHeader, v);
5053            self.open_upvals.pop();
5054        }
5055    }
5056
5057    /// Register a to-be-closed slot (TBC op / generic-for closing value).
5058    fn register_tbc(&mut self, slot: u32) -> Result<(), LuaError> {
5059        let v = self.stack[slot as usize];
5060        if matches!(v, Value::Nil | Value::Bool(false)) {
5061            return Ok(()); // nil and false are silently ignored
5062        }
5063        if self.get_mm(v, Mm::Close).is_nil() {
5064            // PUC `checkclosemth`: "variable '<name>' got a non-closable
5065            // value", the name as `luaG_findlocal` gives it — the frame's
5066            // locvars at this pc, else "(temporary)".
5067            let f = self.top_frame();
5068            let reg = slot - f.base;
5069            let pc = (f.pc as usize).saturating_sub(1);
5070            let name = crate::vm::objname::getlocalname(&f.closure.proto, reg, pc)
5071                .unwrap_or("(temporary)");
5072            return Err(self.rt_err(&format!("variable '{name}' got a non-closable value")));
5073        }
5074        // compiled code registers in register order and closes before it
5075        // registers a slot again; only a crafted chunk (`TBC R0; TBC R0`)
5076        // breaks that, which PUC's list cannot represent either
5077        if self.tbc.last().is_some_and(|&s| s >= slot) {
5078            return Err(self.rt_err("'<close>' state corrupted"));
5079        }
5080        self.tbc.push(slot);
5081        Ok(())
5082    }
5083
5084    /// Close upvalues and run `__close` handlers for slots ≥ `from`
5085    /// (handlers in reverse registration order; PUC luaF_close).
5086    fn close_slots(&mut self, from: u32, err: Option<Value>) -> Result<(), LuaError> {
5087        self.close_from(from);
5088        // PUC: handlers run in reverse declaration order; an error raised by a
5089        // handler becomes the error object passed to the remaining ones, and
5090        // the rest are still closed. The last raised error propagates.
5091        let mut pending = err;
5092        let mut result = Ok(());
5093        let saved_err = self.closing_err;
5094        // On a normal close the handler runs within the closing function's
5095        // activation (debug parent = that function); during error unwinding the
5096        // function's frame is already gone, so the handler sits at the C
5097        // boundary instead (PUC: luaF_close runs after the ci is restored).
5098        let error_close = err.is_some();
5099        while let Some(&s) = self.tbc.last() {
5100            if s < from {
5101                break;
5102            }
5103            self.tbc.pop();
5104            let v = self.stack[s as usize];
5105            if matches!(v, Value::Nil | Value::Bool(false)) {
5106                continue;
5107            }
5108            let mm = self.get_mm(v, Mm::Close);
5109            if mm.is_nil() {
5110                // PUC `prepclosingmethod`: the __close metamethod was present
5111                // at OP_TBC (else we would have errored there) but has since
5112                // been removed/replaced. Treat as a non-callable target.
5113                let tn = self.obj_typename(v);
5114                let e = self.rt_err(&format!(
5115                    "attempt to call a {tn} value (metamethod 'close')"
5116                ));
5117                pending = Some(e.0);
5118                result = Err(e);
5119                continue;
5120            }
5121            // root the pending error: a handler may trigger a collection
5122            self.closing_err = pending;
5123            // PUC `luaF_close` sets `ci->u.l.tm = TM_CLOSE` so traceback /
5124            // getinfo report the handler as "in metamethod 'close'". Saved/
5125            // restored around the call to cover the path where `mm` is a
5126            // native (`push_frame` never consumes it) or it raises before
5127            // reaching push_frame.
5128            let saved_tm = self.pending_tm.replace("close");
5129            // PUC 5.4 `prepclosingmethod` always pushed (obj, errobj) — errobj
5130            // is nil on a normal close (5.4 locals.lua :875's
5131            // `func2close(coroutine.yield)` wrap pins `(self, nil)` back
5132            // through the yield). PUC 5.5 dropped the trailing nil: a clean
5133            // close passes only `obj`, the error case still passes both
5134            // (5.5 locals.lua :314 `select("#", ...) == n` with n=1 for the
5135            // normal-close arms, n=2 for the error arm).
5136            let call = match pending {
5137                Some(e) => self.call_value_impl(mm, &[v, e], error_close),
5138                None => {
5139                    if self.version >= LuaVersion::Lua55 {
5140                        self.call_value_impl(mm, &[v], error_close)
5141                    } else {
5142                        self.call_value_impl(mm, &[v, Value::Nil], error_close)
5143                    }
5144                }
5145            };
5146            self.pending_tm = saved_tm;
5147            if let Err(e) = call {
5148                pending = Some(e.0);
5149                result = Err(e);
5150            }
5151        }
5152        self.closing_err = saved_err;
5153        result
5154    }
5155
5156    /// Yieldable variant of `close_slots`: drive the chain of `__close`
5157    /// handlers for slots ≥ `from` through the interpreter loop with a
5158    /// `Cont::Close` continuation, so a `coroutine.yield()` inside any handler
5159    /// suspends cleanly (the close iteration's state rides on the thread's
5160    /// frame/stack like any other suspended call) — PUC's `lua_callk` pattern
5161    /// applied to `luaF_close`. `after` runs when every slot is closed; if
5162    /// `after` is `Return` and we've returned past `entry_depth`,
5163    /// `Ok(Some(vals))` carries the result up to the host caller.
5164    fn begin_close(
5165        &mut self,
5166        from: u32,
5167        err: Option<Value>,
5168        after: AfterClose,
5169        entry_depth: usize,
5170    ) -> Result<Option<Vec<Value>>, LuaError> {
5171        self.close_from(from);
5172        self.drive_close(from, err, after, entry_depth)
5173    }
5174
5175    /// Pop tbc slots ≥ `from`, skipping nil/false and synthesising a
5176    /// non-callable-mm error for an `__close` that was reset to a bad value
5177    /// between OP_TBC and now (PUC `prepclosingmethod`). The first real
5178    /// handler pushes a `Cont::Close` + `begin_call` and returns `Ok(None)`;
5179    /// the interpreter then drives the handler and re-enters this driver via
5180    /// the `Cont::Close` consumer in `run()`. When the chain is exhausted,
5181    /// the threaded error (if any) propagates or `after` fires.
5182    fn drive_close(
5183        &mut self,
5184        from: u32,
5185        mut pending: Option<Value>,
5186        after: AfterClose,
5187        entry_depth: usize,
5188    ) -> Result<Option<Vec<Value>>, LuaError> {
5189        loop {
5190            let drained = match self.tbc.last() {
5191                None => true,
5192                Some(&s) => s < from,
5193            };
5194            if drained {
5195                return self.finish_close_after(after, pending, entry_depth);
5196            }
5197            let s = self.tbc.pop().expect("tbc non-empty");
5198            let v = self.stack[s as usize];
5199            if matches!(v, Value::Nil | Value::Bool(false)) {
5200                continue;
5201            }
5202            let mm = self.get_mm(v, Mm::Close);
5203            if mm.is_nil() {
5204                let tn = self.obj_typename(v);
5205                let e = self.rt_err(&format!(
5206                    "attempt to call a {tn} value (metamethod 'close')"
5207                ));
5208                pending = Some(e.0);
5209                continue;
5210            }
5211            // A real handler: stage [mm, v, (err?)] above the current top,
5212            // record the close iteration state in a Cont::Close, and let the
5213            // interpreter dispatch the handler. On return the run() head
5214            // re-enters this driver via the Cont::Close consumer.
5215            let func_slot = self.top;
5216            let error_close = pending.is_some();
5217            let need = (func_slot + 3) as usize;
5218            if self.stack.len() < need {
5219                self.stack.resize(need, Value::Nil);
5220            }
5221            self.stack[func_slot as usize] = mm;
5222            self.stack[func_slot as usize + 1] = v;
5223            // PUC 5.4 always passes (obj, errobj=nil) on a normal close;
5224            // 5.5 drops the trailing nil. 5.4 locals.lua :875 vs 5.5 :314.
5225            let nargs = match pending {
5226                Some(e) => {
5227                    self.stack[func_slot as usize + 2] = e;
5228                    2u32
5229                }
5230                None => {
5231                    if self.version >= LuaVersion::Lua55 {
5232                        1u32
5233                    } else {
5234                        self.stack[func_slot as usize + 2] = Value::Nil;
5235                        2u32
5236                    }
5237                }
5238            };
5239            self.top = func_slot + 1 + nargs;
5240            // Root the pending error during the call (a handler may collect).
5241            let saved_err = self.closing_err;
5242            self.closing_err = pending;
5243            // PUC `luaF_close` flags the handler frame as "metamethod 'close'"
5244            // for traceback / getinfo.
5245            let saved_tm = self.pending_tm.replace("close");
5246            frames_push_sync(
5247                &mut self.frames,
5248                &mut self.frames_top,
5249                CallFrame::Cont(NativeCont {
5250                    kind: ContKind::Close(CloseCont {
5251                        from,
5252                        pending,
5253                        after,
5254                    }),
5255                    func_slot,
5256                    nresults: 0,
5257                }),
5258            );
5259            // PUC luaF_close runs a normal close *within* the closing
5260            // function's activation (debug parent = that function); during an
5261            // error unwind the function's frame is already gone and the
5262            // handler sits at the C boundary instead.
5263            let r = self.begin_call(func_slot, Some(nargs), 0, error_close);
5264            self.pending_tm = saved_tm;
5265            self.closing_err = saved_err;
5266            r?;
5267            return Ok(None);
5268        }
5269    }
5270
5271    /// Fire `after` once every `__close` handler has run. `Block` propagates
5272    /// any remaining error or simply continues; `Return` performs OP_Return's
5273    /// tail (hook + frame pop + result delivery) and may surface results to
5274    /// the host when the function whose return triggered the close was the
5275    /// entry activation, but only on a clean drain — a pending error skips
5276    /// the return tail and propagates instead. `ResumeUnwind` pops the
5277    /// deferred Lua frame and re-raises, letting a handler's own error win
5278    /// over the original propagating one (PUC luaF_close).
5279    fn finish_close_after(
5280        &mut self,
5281        after: AfterClose,
5282        pending: Option<Value>,
5283        entry_depth: usize,
5284    ) -> Result<Option<Vec<Value>>, LuaError> {
5285        match after {
5286            AfterClose::Block => match pending {
5287                Some(e) => Err(LuaError(e)),
5288                None => Ok(None),
5289            },
5290            AfterClose::Return {
5291                abs_a,
5292                nret,
5293                from_native,
5294            } => match pending {
5295                Some(e) => Err(LuaError(e)),
5296                None => self.complete_return(abs_a, nret, from_native, entry_depth),
5297            },
5298            AfterClose::ResumeUnwind { func_slot, err } => {
5299                // The aborting Lua frame was popped before `begin_close`;
5300                // restore the catcher's stack window down to `func_slot` and
5301                // re-raise — preferring a handler-raised error over the
5302                // original (PUC luaF_close).
5303                self.stack.truncate(func_slot as usize);
5304                self.top = func_slot;
5305                self.tbc.retain(|&s| s < func_slot);
5306                Err(LuaError(pending.unwrap_or(err)))
5307            }
5308        }
5309    }
5310
5311    /// OP_Return's post-close tail: fire the "return" hook (frame still
5312    /// current), pop the Lua frame, slide results into `func_slot`, then
5313    /// either hand them to the host (`Ok(Some(vals))` when we've returned
5314    /// past `entry_depth`), leave them contiguous for an exposed
5315    /// pcall/xpcall continuation, or finish into the caller's expected
5316    /// result slot. Mirrors the synchronous OP_Return tail so both paths
5317    /// share semantics — the `from_native` flag selects the right "return"
5318    /// hook context for `hook_return`.
5319    fn complete_return(
5320        &mut self,
5321        abs_a: u32,
5322        nret: u32,
5323        from_native: bool,
5324        entry_depth: usize,
5325    ) -> Result<Option<Vec<Value>>, LuaError> {
5326        // ftransfer is the local index (1-based) of the first result, as
5327        // `getinfo("r").ftransfer + getlocal(level, k)` consumes it. luna
5328        // exposes locals starting at `frame.base` (= func_slot + 1 +
5329        // n_varargs for a vararg call), so the conversion is the absolute
5330        // result slot minus base, plus one to make it 1-based. db.lua 5.4
5331        // :542 (`foo1(); on=false; eqseq(out, {10, 0})`) pins the vararg
5332        // shape end-to-end.
5333        let ftransfer = self
5334            .frames
5335            .last()
5336            .and_then(CallFrame::lua)
5337            .map(|fr| {
5338                let raw = abs_a.saturating_sub(fr.base) + 1;
5339                // 5.5 anonymous-vararg functions get a `(vararg table)` pseudo
5340                // local injected at index `numparams + 1`, so getlocal
5341                // numbering shifts results past it (5.5 db.lua :539
5342                // `eqseq(out, {10, 0})`). 5.4 and earlier have no such pseudo.
5343                if fr.closure.proto.has_vararg_table_pseudo {
5344                    raw + 1
5345                } else {
5346                    raw
5347                }
5348            })
5349            .unwrap_or(1);
5350        // PUC 5.1 `luaD_poscall`: fire one extra "tail return" hook event
5351        // per tail call that collapsed into this activation, *after* its
5352        // own "return". `tailcalls` tracks that count exactly (PUC
5353        // `ci->u.l.tailcalls`). 5.2+ retired LUA_HOOKTAILRET, so the
5354        // "return" hook fires once even when the activation absorbed
5355        // multiple tail calls — only `istailcall` on getinfo surfaces the
5356        // collapse. 5.1 db.lua :366 pins the event ordering.
5357        let tailcalls = if self.version <= LuaVersion::Lua51 {
5358            self.frames
5359                .last()
5360                .and_then(|f| f.lua())
5361                .map(|f| f.tailcalls)
5362                .unwrap_or(0)
5363        } else {
5364            0
5365        };
5366        self.hook_return(from_native, ftransfer, nret)?;
5367        for _ in 0..tailcalls {
5368            self.hook_tail_return()?;
5369        }
5370        let CallFrame::Lua(fr) =
5371            frames_pop_sync(&mut self.frames, &mut self.frames_top).expect("no frame")
5372        else {
5373            unreachable!("returning from a non-Lua frame")
5374        };
5375        for i in 0..nret {
5376            self.stack[(fr.func_slot + i) as usize] = self.stack[(abs_a + i) as usize];
5377        }
5378        if self.frames.len() < entry_depth {
5379            self.top = fr.func_slot + nret;
5380            return Ok(Some(self.take_results(fr.func_slot)));
5381        } else if matches!(self.frames.last(), Some(CallFrame::Cont(_))) {
5382            self.top = fr.func_slot + nret;
5383        } else {
5384            self.finish_results(fr.func_slot, nret, fr.nresults);
5385        }
5386        Ok(None)
5387    }
5388
5389    #[doc(hidden)]
5390    pub fn upval_get(&self, cl: Gc<LuaClosure>, idx: u32) -> Value {
5391        match cl.upvals()[idx as usize].state() {
5392            UpvalState::Open { slot, thread } => self.read_slot(slot, thread),
5393            UpvalState::Closed(v) => v,
5394        }
5395    }
5396
5397    fn upval_set(&mut self, cl: Gc<LuaClosure>, idx: u32, v: Value) {
5398        let uv = cl.upvals()[idx as usize];
5399        match uv.state() {
5400            UpvalState::Open { slot, thread } => self.write_slot(slot, thread, v),
5401            UpvalState::Closed(_) => {
5402                // 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).
5403                unsafe { uv.as_mut() }.set_closed(v);
5404                // forward barrier: a closed upvalue is single-slot, so the
5405                // forward variant is cheaper than barrier_back (PUC uses
5406                // `luaC_barrier_` for upvalues; `luaC_barrierback_` for
5407                // tables / threads).
5408                self.heap
5409                    .barrier_forward(uv.as_ptr() as *mut crate::runtime::heap::GcHeader, v);
5410            }
5411        }
5412    }
5413
5414    // ---- register / error helpers ----
5415
5416    #[inline(always)]
5417    fn r(&self, base: u32, i: u32) -> Value {
5418        // SAFETY: the compiler reserves `proto.max_stack` slots above `base`
5419        // at frame entry (`push_frame` sizes the stack up to base + max_stack),
5420        // and every bytecode-generated reference falls within `[0, max_stack)`.
5421        // PUC's vmfetch uses raw `R(A)` (`s2v(L->base + A)`) for the same
5422        // reason. The bounds check would re-validate this invariant on every
5423        // op — the dispatch hot path can't afford it.
5424        // 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).
5425        unsafe { *self.stack.get_unchecked((base + i) as usize) }
5426    }
5427
5428    #[inline(always)]
5429    fn set_r(&mut self, base: u32, i: u32, v: Value) {
5430        // SAFETY: see `r` — `base + i < base + max_stack <= stack.len()` by
5431        // frame-entry contract.
5432        unsafe {
5433            *self.stack.get_unchecked_mut((base + i) as usize) = v;
5434        }
5435    }
5436
5437    #[doc(hidden)]
5438    pub fn rt_err(&mut self, msg: &str) -> LuaError {
5439        let text = match self.position_prefix() {
5440            Some(p) => format!("{p}{msg}"),
5441            None => msg.to_string(),
5442        };
5443        LuaError(Value::Str(self.heap.intern(text.as_bytes())))
5444    }
5445
5446    /// Error without the `chunk:line:` position prefix. PUC's
5447    /// `resume_error` (ldo.c) pushes its message as a bare literal,
5448    /// so `cannot resume dead coroutine` etc. must not be prefixed.
5449    pub(crate) fn plain_err(&mut self, msg: &str) -> LuaError {
5450        LuaError(Value::Str(self.heap.intern(msg.as_bytes())))
5451    }
5452
5453    /// A string a library built from pieces of any size: one longer than a
5454    /// string can hold raises, as the concatenation operator does.
5455    pub(crate) fn built_str(&mut self, bytes: &[u8]) -> Result<Value, LuaError> {
5456        if bytes.len() > crate::runtime::string::MAX_LEN {
5457            return Err(self.rt_err("string length overflow"));
5458        }
5459        Ok(Value::Str(self.heap.intern(bytes)))
5460    }
5461
5462    pub(crate) fn type_err(&mut self, what: &str, v: Value) -> LuaError {
5463        let extra = self.subject_varinfo(v);
5464        let tn = self.obj_typename(v);
5465        let msg = self.compose_type_err(what, &tn, &extra);
5466        self.runerror(&msg)
5467    }
5468
5469    /// Assemble a `luaG_typeerror` / `luaG_callerror` message in the dialect's
5470    /// word order.
5471    ///
5472    /// PUC ≤5.2 names the operand first — `attempt to call field 'f' (a nil
5473    /// value)`. 5.3 flipped it to type-first — `attempt to call a nil value
5474    /// (field 'f')`. luna emitted the 5.3+ form on every dialect, so every
5475    /// such error was worded wrong under 5.1/5.2.
5476    ///
5477    /// Two shapes carry no operand name on ≤5.2 and must collapse to the bare
5478    /// message: an absent varinfo (identical across dialects), and a
5479    /// metamethod target — ≤5.2's `luaG_typeerror` only names locals, globals,
5480    /// fields, upvalues and methods, so `(metamethod 'add')` has no ≤5.2
5481    /// counterpart and is dropped rather than reworded. All four shapes were
5482    /// measured against stock 5.1.5 / 5.2.4 / 5.5.1 before this was written.
5483    fn compose_type_err(&self, what: &str, tn: &str, extra: &str) -> String {
5484        if self.version() > crate::version::LuaVersion::Lua52 {
5485            return format!("attempt to {what} a {tn} value{extra}");
5486        }
5487        // `extra` is "" or " (kind 'name')" — unwrap to "kind 'name'".
5488        let inner = extra
5489            .trim_start()
5490            .trim_start_matches('(')
5491            .trim_end_matches(')');
5492        if inner.is_empty() || inner.starts_with("metamethod") {
5493            format!("attempt to {what} a {tn} value")
5494        } else {
5495            format!("attempt to {what} {inner} (a {tn} value)")
5496        }
5497    }
5498
5499    /// Name the offending operand of the current instruction (PUC varinfo) for
5500    /// a type error, e.g. " (global 'x')". The faulting value `bad` is matched
5501    /// to the instruction's subject register(s); a native-raised error whose
5502    /// current instruction doesn't hold `bad` simply yields "".
5503    fn subject_varinfo(&self, bad: Value) -> String {
5504        use crate::vm::isa::Op;
5505        // PUC `varinfo` names a variable only for a Lua activation
5506        if self.native_on_top() {
5507            return String::new();
5508        }
5509        let Some(f) = self.frames.last().and_then(CallFrame::lua) else {
5510            return String::new();
5511        };
5512        let proto = f.closure.proto;
5513        let p: &crate::runtime::Proto = &proto;
5514        let pc = f.pc as usize;
5515        if pc == 0 || pc > p.code.len() {
5516            return String::new();
5517        }
5518        let instr = p.code[pc - 1];
5519        let mut cands: Vec<u32> = Vec::new();
5520        match instr.op() {
5521            // indexed reads / length / method: the table/object is in B
5522            Op::GetField | Op::GetI | Op::GetTable | Op::SelfOp | Op::Len => {
5523                cands.push(instr.b());
5524            }
5525            // indexed writes / calls: the table/function is in A
5526            Op::SetField | Op::SetI | Op::SetTable | Op::Call | Op::TailCall => {
5527                cands.push(instr.a());
5528            }
5529            // arithmetic/bitwise: a register operand (B, and C unless constant)
5530            Op::Add
5531            | Op::Sub
5532            | Op::Mul
5533            | Op::Div
5534            | Op::Mod
5535            | Op::Pow
5536            | Op::IDiv
5537            | Op::BAnd
5538            | Op::BOr
5539            | Op::BXor
5540            | Op::Shl
5541            | Op::Shr => {
5542                cands.push(instr.b());
5543                if !instr.k() {
5544                    cands.push(instr.c());
5545                }
5546            }
5547            Op::Unm | Op::BNot => cands.push(instr.b()),
5548            // indexing an upvalue table (`_ENV` for a global): PUC
5549            // `getupvalname` finds the value among the closure's upvalues
5550            Op::GetTabUp | Op::SetTabUp => {
5551                let u = if instr.op() == Op::GetTabUp {
5552                    instr.b()
5553                } else {
5554                    instr.a()
5555                };
5556                if self.upval_get(f.closure, u).raw_eq(bad)
5557                    && let Some(d) = p.upvals.get(u as usize)
5558                {
5559                    return format!(" (upvalue '{}')", d.name);
5560                }
5561            }
5562            Op::Concat => {
5563                let a = instr.a();
5564                for r in a..a + instr.b() {
5565                    cands.push(r);
5566                }
5567            }
5568            _ => {}
5569        }
5570        // Up to 5.3 a binary operator takes a constant operand straight
5571        // from the constant table (RK), where `varinfo` cannot see it, so a
5572        // string constant is not named there; unary operators load it into
5573        // a register first and do name it.
5574        let rk_operands = self.version <= LuaVersion::Lua53
5575            && matches!(
5576                instr.op(),
5577                Op::Add
5578                    | Op::Sub
5579                    | Op::Mul
5580                    | Op::Div
5581                    | Op::Mod
5582                    | Op::Pow
5583                    | Op::IDiv
5584                    | Op::BAnd
5585                    | Op::BOr
5586                    | Op::BXor
5587                    | Op::Shl
5588                    | Op::Shr
5589            );
5590        for reg in cands {
5591            if self.r(f.base, reg).raw_eq(bad) {
5592                return match crate::vm::objname::getobjname_in(p, pc - 1, reg, self.version) {
5593                    Some(("constant", _)) if rk_operands => String::new(),
5594                    Some((kind, name)) => format!(" ({kind} '{name}')"),
5595                    None => String::new(),
5596                };
5597            }
5598        }
5599        String::new()
5600    }
5601
5602    /// "attempt to call a X value", enriched (PUC luaG_callerror) with a name
5603    /// for the call target: "(global 'f')" for a direct call, or "(metamethod
5604    /// 'add')" when the call is a metamethod dispatched by the current opcode.
5605    fn call_err(&mut self, v: Value) -> LuaError {
5606        let extra = self.call_target_varinfo(v);
5607        let tn = self.obj_typename(v);
5608        let msg = self.compose_type_err("call", &tn, &extra);
5609        self.runerror(&msg)
5610    }
5611
5612    /// Name the offending call target. A metamethod dispatch pushes a `Cont`
5613    /// frame before the call, so the opcode that triggered it lives in the
5614    /// nearest *Lua* frame — read that instruction: OP_CALL names the function
5615    /// register, any metamethod-bearing opcode yields "(metamethod 'event')".
5616    fn call_target_varinfo(&self, bad: Value) -> String {
5617        use crate::vm::isa::Op;
5618        if self.native_on_top() {
5619            return String::new();
5620        }
5621        let Some(f) = self.frames.iter().rev().find_map(CallFrame::lua) else {
5622            return String::new();
5623        };
5624        let proto = f.closure.proto;
5625        let p: &crate::runtime::Proto = &proto;
5626        let pc = f.pc as usize;
5627        if pc == 0 || pc > p.code.len() {
5628            return String::new();
5629        }
5630        let instr = p.code[pc - 1];
5631        match instr.source_op() {
5632            Op::Call | Op::TailCall => {
5633                let reg = instr.a();
5634                if self.r(f.base, reg).raw_eq(bad) {
5635                    match crate::vm::objname::getobjname_in(p, pc - 1, reg, self.version) {
5636                        Some((kind, name)) => format!(" ({kind} '{name}')"),
5637                        None => String::new(),
5638                    }
5639                } else {
5640                    String::new()
5641                }
5642            }
5643            // 5.4 `funcnamefromcode` names the generic-for iterator call
5644            // (5.3 had the entry but raised through plain `luaG_typeerror`)
5645            Op::TForCall if self.version >= LuaVersion::Lua54 => {
5646                " (for iterator 'for iterator')".to_string()
5647            }
5648            // 5.4 `funcnamefromcall` names the metamethod; up to 5.3 the
5649            // call raised through `luaG_typeerror`, whose `varinfo` does not
5650            op if self.version >= LuaVersion::Lua54 => match mm_event_name(op) {
5651                Some(ev) => format!(" (metamethod '{ev}')"),
5652                None => String::new(),
5653            },
5654            _ => String::new(),
5655        }
5656    }
5657
5658    /// "number has no integer representation", enriched (PUC luaG_tointerror)
5659    /// with a "(field 'x')"-style suffix naming the offending operand of the
5660    /// current arithmetic instruction when it can be recovered from bytecode.
5661    fn no_int_rep_err(&mut self) -> LuaError {
5662        let extra = self.bad_operand_varinfo();
5663        self.runerror(&format!("number{extra} has no integer representation"))
5664    }
5665
5666    /// Inspect the current frame's faulting instruction: find the register
5667    /// operand holding a float with no integer representation and name it.
5668    fn bad_operand_varinfo(&self) -> String {
5669        if self.native_on_top() {
5670            return String::new();
5671        }
5672        let Some(f) = self.frames.last().and_then(CallFrame::lua) else {
5673            return String::new();
5674        };
5675        let proto = f.closure.proto;
5676        let p: &crate::runtime::Proto = &proto;
5677        let pc = f.pc as usize;
5678        if pc == 0 || pc > p.code.len() {
5679            return String::new();
5680        }
5681        let instr = p.code[pc - 1];
5682        let mut regs = vec![instr.b()];
5683        if !instr.k() {
5684            regs.push(instr.c());
5685        }
5686        let no_int = |n: Option<Num>| matches!(n, Some(Num::Float(x)) if crate::runtime::value::f2i_exact(x).is_none());
5687        for reg in regs {
5688            let v = self.r(f.base, reg);
5689            // before 5.4 a numeric string is converted first, so "2.5" is
5690            // the operand without an integer value
5691            let n = self.arith_operand()(v);
5692            if no_int(n) {
5693                return match crate::vm::objname::getobjname_in(p, pc - 1, reg, self.version) {
5694                    Some((kind, name)) => format!(" ({kind} '{name}')"),
5695                    None => String::new(),
5696                };
5697            }
5698        }
5699        String::new()
5700    }
5701
5702    /// Position prefix of the currently executing Lua frame. PUC `luaL_error`
5703    /// calls `luaL_where(L, 1)` which reads `L->ci->previous`. When the prior
5704    /// frame is a C function (e.g. a pcall Cont parked above `require`'s
5705    /// native call), PUC pushes no prefix — match that by looking only at the
5706    /// topmost frame directly and bailing if it is anything but a Lua frame.
5707    pub(crate) fn position_prefix(&self) -> Option<String> {
5708        let f = match self.frames.last()? {
5709            CallFrame::Lua(f) => f,
5710            // a native metamethod runs above the Meta continuation of the
5711            // instruction that triggered it: that Lua function is its caller
5712            CallFrame::Cont(NativeCont {
5713                kind: ContKind::Meta(_),
5714                ..
5715            }) => self.frames.iter().rev().nth(1)?.lua()?,
5716            CallFrame::Cont(_) => return None,
5717        };
5718        let proto = f.closure.proto;
5719        // a stripped chunk: no source in luna's own format, no line info in
5720        // PUC's (whose loader names the missing source "=?")
5721        if proto.source.as_bytes().is_empty() || proto.lines.is_empty() {
5722            return Some(self.stripped_prefix());
5723        }
5724        let line = proto.lines[(f.pc as usize).saturating_sub(1).min(proto.lines.len() - 1)];
5725        // 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).
5726        let raw = unsafe { crate::runtime::string::bytes_of(proto.source.as_ptr()) };
5727        let display = crate::vm::lib_debug::chunk_id(self.version, raw);
5728        let src = String::from_utf8_lossy(&display).into_owned();
5729        Some(format!("{src}:{line}: "))
5730    }
5731
5732    /// PUC `luaG_addinfo` prefix for a stripped chunk. 5.5 substitutes "=?"
5733    /// for the source and renders the line as "?" (so the prefix reads
5734    /// `?:?: `). 5.4 and below leave the source NULL ("?") and use the raw
5735    /// `getfuncline = -1`, so the prefix reads `?:-1: ` (5.4 errors.lua :282
5736    /// matches `^%?:%-1:`).
5737    fn stripped_prefix(&self) -> String {
5738        if self.version >= crate::version::LuaVersion::Lua55 {
5739            "?:?: ".to_string()
5740        } else {
5741            "?:-1: ".to_string()
5742        }
5743    }
5744
5745    /// PUC `luaL_where(L, level)`: `"short_src:line: "` for the function at
5746    /// `level` (0 = the running native), or `None` when that level does not
5747    /// exist or has no line information (a C function, a stripped chunk).
5748    pub(crate) fn position_prefix_at_level(&self, level: i64) -> Option<String> {
5749        let ts = self.thread_stack(None);
5750        let i = usize::try_from(level).ok()?;
5751        if i >= ts.levels.len() {
5752            return None;
5753        }
5754        let line = ts.currentline(i);
5755        if line <= 0 {
5756            return None;
5757        }
5758        let DbgKind::Lua(fi) = ts.levels[i] else {
5759            return None;
5760        };
5761        let ar = self.closure_ar(ts.lua(fi).closure);
5762        Some(format!(
5763            "{}:{line}: ",
5764            String::from_utf8_lossy(&ar.short_src)
5765        ))
5766    }
5767
5768    // ---- the interpreter ----
5769
5770    /// Run from the current top frame down to (but not past) `entry_depth`
5771    /// frames. Coroutine driving passes `entry_depth = 1` so the whole thread
5772    /// runs to completion or a yield.
5773    /// Resume the dispatcher from the saved
5774    /// `entry_depth` (captured pre-yield by `drive_one`). Called by
5775    /// `EvalFuture::poll` on every poll after the first to walk the
5776    /// existing call frames until the next `BudgetExhausted` or
5777    /// terminal `Ok`/`Err`. Not a public-API surface; the
5778    /// embedder reaches it through `Vm::eval_async`.
5779    pub(crate) fn exec_with_async(&mut self, entry_depth: usize) -> Result<Vec<Value>, LuaError> {
5780        self.exec_with(entry_depth)
5781    }
5782
5783    fn exec_with(&mut self, entry_depth: usize) -> Result<Vec<Value>, LuaError> {
5784        loop {
5785            let r = self.run(entry_depth);
5786            if r.is_err()
5787                && (self.yielding.is_some()
5788                    || self.terminating.is_some()
5789                    || self.host_yield_pending
5790                    || self.pending_async_native_fut.is_some())
5791            {
5792                // a `coroutine.yield` is in flight: keep the frames intact (they
5793                // are the suspended coroutine's saved state) and propagate to
5794                // resume. A self-close termination propagates the same way, so a
5795                // protecting pcall on the way out cannot catch (unwind) it.
5796                // `host_yield_pending` is the async-mode
5797                // analogue: the sentinel must reach `drive_one` without
5798                // a protecting `pcall` swallowing it.
5799                return r;
5800            }
5801            match r {
5802                Ok(vals) => return Ok(vals),
5803                // unwind toward `entry_depth`. A protecting pcall/xpcall
5804                // continuation caught along the way turns the error into
5805                // `false, msg` and the loop resumes running its caller; an
5806                // uncaught error propagates out.
5807                Err(e) => match self.unwind(e.0, entry_depth) {
5808                    Unwound::Caught => continue,
5809                    Unwound::CaughtReturn(vals) => return Ok(vals),
5810                    Unwound::Propagated(err) => return Err(err),
5811                },
5812            }
5813        }
5814    }
5815
5816    /// Unwind the call stack from the error point toward `entry_depth`, running
5817    /// `__close` handlers on each Lua frame. Stops at the first pcall/xpcall
5818    /// continuation frame at/above `entry_depth` (the error is *caught*: its
5819    /// slot receives `false, msg`); if none is reached, the error propagates.
5820    fn unwind(&mut self, mut err: Value, entry_depth: usize) -> Unwound {
5821        // The protected call runs in-place among the caller frames' registers,
5822        // so truncating the failed frames here cuts into caller windows below
5823        // the catcher. Snapshot the live length: at the error point the stack
5824        // already spans every surviving frame's window, so restoring it after a
5825        // catch reinstates them all (the reclaimed slots above are dead temps).
5826        // PUC handles overflow recovery via a separate EXTRA_STACK reserve;
5827        // we instead clamp the restore to the catcher's caller window when the
5828        // error point was at the stack limit (cause: the next `call_value_impl`
5829        // picks `func_slot = stack.len()` which would otherwise re-overflow).
5830        let saved_len = self.stack.len();
5831        err = self.raise_to_handler(err);
5832        // An error that no protected call inside the running coroutine will
5833        // catch kills it without unwinding: PUC's `lua_resume` leaves the
5834        // dead thread's stack as it was, so its pending to-be-closed
5835        // variables run only when it is closed (`coroutine.close`, or
5836        // `coroutine.wrap` closing it before re-raising). Scoped to the
5837        // coroutine's own run (`entry_depth == 1`); a run nested under a
5838        // native unwinds as before.
5839        if entry_depth == 1
5840            && self.version >= LuaVersion::Lua54
5841            && self
5842                .current
5843                .is_some_and(|c| c.status == crate::runtime::CoroStatus::Running)
5844            && !self.frames.iter().any(|f| {
5845                matches!(
5846                    f,
5847                    CallFrame::Cont(NativeCont {
5848                        kind: ContKind::Pcall | ContKind::Xpcall { .. } | ContKind::Close(_),
5849                        ..
5850                    })
5851                )
5852            })
5853        {
5854            while self.frames.len() >= entry_depth {
5855                frames_pop_sync(&mut self.frames, &mut self.frames_top);
5856            }
5857            return Unwound::Propagated(LuaError(err));
5858        }
5859        while self.frames.len() >= entry_depth {
5860            match *self.frames.last().expect("frame") {
5861                // a yieldable-metamethod continuation does not catch: discard the
5862                // abandoned instruction and keep unwinding (PUC drops the partial
5863                // op on error).
5864                CallFrame::Cont(NativeCont {
5865                    kind: ContKind::Meta(mc),
5866                    func_slot,
5867                    ..
5868                }) => {
5869                    frames_pop_sync(&mut self.frames, &mut self.frames_top);
5870                    self.stack.truncate(func_slot as usize);
5871                    self.top = mc.saved_top.min(func_slot);
5872                    self.tbc.retain(|&s| s < func_slot);
5873                }
5874                // a __pairs continuation does not catch either: an error inside
5875                // the metamethod propagates past `pairs`.
5876                CallFrame::Cont(NativeCont {
5877                    kind: ContKind::Pairs,
5878                    func_slot,
5879                    ..
5880                }) => {
5881                    frames_pop_sync(&mut self.frames, &mut self.frames_top);
5882                    self.stack.truncate(func_slot as usize);
5883                    self.top = func_slot;
5884                    self.tbc.retain(|&s| s < func_slot);
5885                }
5886                // a __close continuation does not catch: drop the half-run
5887                // handler's window, then continue the close yieldably with
5888                // the new error threaded as `pending`. Preserve `cc.after`
5889                // verbatim — `Return`/`Block` originating from an aborting
5890                // OP_Return/OP_Close will be short-circuited by
5891                // `finish_close_after` (pending propagates as Err); a
5892                // `ResumeUnwind` originated by our own Lua-frame handler
5893                // must keep its deferred frame-pop semantics so that frame
5894                // is not orphaned. If a fresh handler yields, `drive_close`
5895                // pushes another `Cont::Close` and we return `Caught` so
5896                // `exec_with` re-enters the run loop.
5897                CallFrame::Cont(NativeCont {
5898                    kind: ContKind::Close(cc),
5899                    func_slot,
5900                    ..
5901                }) => {
5902                    frames_pop_sync(&mut self.frames, &mut self.frames_top);
5903                    self.stack.truncate(func_slot as usize);
5904                    self.top = func_slot;
5905                    self.tbc.retain(|&s| s < func_slot);
5906                    match self.drive_close(cc.from, Some(err), cc.after, entry_depth) {
5907                        Ok(Some(_)) => {
5908                            unreachable!(
5909                                "Block / Return / ResumeUnwind never return host values mid-unwind"
5910                            )
5911                        }
5912                        Ok(None) => return Unwound::Caught,
5913                        Err(e) => {
5914                            // the drained close re-raises `err`; only an
5915                            // error a handler raised is new
5916                            if !e.0.raw_eq(err) {
5917                                err = self.raise_to_handler(e.0);
5918                            }
5919                            continue;
5920                        }
5921                    }
5922                }
5923                CallFrame::Cont(nc) => {
5924                    frames_pop_sync(&mut self.frames, &mut self.frames_top);
5925                    self.pcall_depth -= 1;
5926                    let result = match nc.kind {
5927                        ContKind::Pcall => {
5928                            self.msgh_applied = None;
5929                            err
5930                        }
5931                        // the handler ran where the error was raised (see
5932                        // `raise_to_handler`); one raised past the handler's
5933                        // reach (by the unwind itself) meets it here
5934                        ContKind::Xpcall { handler } => {
5935                            if self.msgh_applied.take().is_some_and(|v| v.raw_eq(err)) {
5936                                err
5937                            } else {
5938                                self.call_msgh(handler, err)
5939                            }
5940                        }
5941                        ContKind::Meta(_) | ContKind::Pairs | ContKind::Close(_) => {
5942                            unreachable!("Meta/Pairs/Close cont handled above")
5943                        }
5944                    };
5945                    // PUC 5.5 `luaG_errormsg` substitutes "<no error object>"
5946                    // for nil AFTER the message handler ran (ldebug.c:849) —
5947                    // so it applies to the pcall-caught object and to an
5948                    // xpcall HANDLER'S return value, while the handler itself
5949                    // (and a top-level propagation into the host, whose
5950                    // `error_display` plays msghandler) still sees the raw
5951                    // nil. 5.4- keep nil everywhere (errors.lua :49 asserts
5952                    // `doit("error()") == nil`).
5953                    let result = if matches!(result, Value::Nil)
5954                        && self.version >= crate::version::LuaVersion::Lua55
5955                    {
5956                        Value::Str(self.heap.intern(b"<no error object>"))
5957                    } else {
5958                        result
5959                    };
5960                    // the error has been caught (pcall/xpcall): the captured
5961                    // traceback was for that error and is no longer in flight.
5962                    self.error_traceback = None;
5963                    let fs = nc.func_slot as usize;
5964                    if self.stack.len() < fs + 2 {
5965                        self.stack.resize(fs + 2, Value::Nil);
5966                    }
5967                    self.stack[fs] = Value::Bool(false);
5968                    self.stack[fs + 1] = result;
5969                    self.top = nc.func_slot + 2;
5970                    self.tbc.retain(|&s| s < nc.func_slot);
5971                    if self.frames.len() < entry_depth {
5972                        return Unwound::CaughtReturn(self.take_results(nc.func_slot));
5973                    }
5974                    self.finish_results(nc.func_slot, 2, nc.nresults);
5975                    // reinstate the caller windows the unwind truncated into,
5976                    // clamped to the catcher's caller window + a `MIN_STACK`
5977                    // reserve. The clamp is a no-op for normal pcall catches
5978                    // (saved_len lies within the caller's max_stack window),
5979                    // and prevents the stack from staying near `MAX_LUA_STACK`
5980                    // after an overflow-recovery catch — which would make the
5981                    // next `call_value_impl` (e.g. a `__close` in the catcher's
5982                    // errorh, locals.lua:659) pick `func_slot = stack.len()`
5983                    // above the limit and re-overflow.
5984                    // Restore the caller's full register window: opcodes
5985                    // index it directly. The cap covers caller's base +
5986                    // `max_stack` + a small reserve. We always resize to
5987                    // exactly this window — previously this clamped
5988                    // `saved_len` from above to prevent staying near
5989                    // `MAX_LUA_STACK` after an overflow-recovery catch, and
5990                    // a yieldable-unwind re-entry adds the dual case where
5991                    // `saved_len` is *below* the window (a prior
5992                    // `ResumeUnwind` truncated). Using the window directly
5993                    // covers both.
5994                    let restore = self
5995                        .frames
5996                        .iter()
5997                        .rev()
5998                        .find_map(CallFrame::lua)
5999                        .map(|c| (c.base + c.closure.proto.max_stack as u32) as usize + 256)
6000                        .unwrap_or(saved_len);
6001                    if self.stack.len() < restore {
6002                        self.stack.resize(restore, Value::Nil);
6003                    } else if self.stack.len() > restore {
6004                        self.stack.truncate(restore);
6005                    }
6006                    // Clear slots vacated by the popped
6007                    // frames the unwind walked over. finish_results
6008                    // above clears `[nc.func_slot + nresults ..
6009                    // nc.func_slot + 2)`, which only covers the
6010                    // pcall's own result region — the unwind-popped
6011                    // frames' locals in `[nc.func_slot + 2 .. restore)`
6012                    // are still in place with whatever Gc-bearing
6013                    // Values they last held. Without this clear, a
6014                    // later GC marks the stale pointers (same hazard as
6015                    // the Op::Return finish_results path). PUC's `luaD_pcall` similarly truncates
6016                    // L->top to the catcher's level — luna's
6017                    // truncate above resizes the Vec but doesn't
6018                    // touch slots [func_slot+2..restore) that were
6019                    // already present.
6020                    let clear_lo = (nc.func_slot as usize + 2).min(self.stack.len());
6021                    let clear_hi = restore.min(self.stack.len());
6022                    if clear_lo < clear_hi {
6023                        for slot in &mut self.stack[clear_lo..clear_hi] {
6024                            *slot = Value::Nil;
6025                        }
6026                    }
6027                    return Unwound::Caught;
6028                }
6029                CallFrame::Lua(f) => {
6030                    // Yieldable error-unwind close, PUC luaG_errormsg shape:
6031                    // (1) pop the Lua frame immediately so each `__close`
6032                    // handler runs at the C boundary above — `debug.getinfo`
6033                    // sees the next outer Lua frame's call site (typically
6034                    // `pcall`), not this aborting function (locals.lua:480).
6035                    // (2) drive the close yieldably with
6036                    // `AfterClose::ResumeUnwind { func_slot, err }`; on drain
6037                    // it truncates to `func_slot` and re-raises (letting a
6038                    // handler-raised error win over `err`). If a handler
6039                    // yields, `drive_close` pushes `Cont::Close` and we
6040                    // return `Caught` so `exec_with` re-enters the run loop;
6041                    // a synchronous drain returns Err exactly as the old
6042                    // path did.
6043                    frames_pop_sync(&mut self.frames, &mut self.frames_top);
6044                    let after = AfterClose::ResumeUnwind {
6045                        func_slot: f.func_slot,
6046                        err,
6047                    };
6048                    match self.begin_close(f.base, Some(err), after, entry_depth) {
6049                        Ok(Some(_)) => {
6050                            unreachable!("ResumeUnwind never returns host values")
6051                        }
6052                        Ok(None) => return Unwound::Caught,
6053                        Err(e) => {
6054                            // the drained close re-raises `err`; only an
6055                            // error a handler raised is new
6056                            if !e.0.raw_eq(err) {
6057                                err = self.raise_to_handler(e.0);
6058                            }
6059                            continue;
6060                        }
6061                    }
6062                }
6063            }
6064        }
6065        Unwound::Propagated(LuaError(err))
6066    }
6067
6068    fn run(&mut self, entry_depth: usize) -> Result<Vec<Value>, LuaError> {
6069        loop {
6070            // Fast-path slow-check gate: most embedders run with both
6071            // `instr_budget` and `mem_cap` as None, so a single combined
6072            // is_some test lets the hot loop skip both branches with one
6073            // load + branch instead of two.
6074            if self.instr_budget.is_some() || self.heap.mem_cap.is_some() {
6075                if let Some(b) = self.instr_budget.as_mut() {
6076                    *b -= 1;
6077                    if *b <= 0 {
6078                        self.instr_budget = None;
6079                        // Async-mode cooperative
6080                        // yield. Set a sentinel flag so `exec_with`
6081                        // propagates the Err without `unwind` running
6082                        // (mirroring the `yielding.is_some()` path),
6083                        // and `call_value_impl` preserves the call
6084                        // frames for the next `poll`. Translation back
6085                        // to `DispatchOutcome::BudgetExhausted` happens
6086                        // in `drive_one`. The Err value itself is
6087                        // `Value::Nil` — a pure sentinel, never seen by
6088                        // user code.
6089                        if self.async_mode {
6090                            self.host_yield_pending = true;
6091                            return Err(LuaError(Value::Nil));
6092                        }
6093                        // Classify the trip so embedders can
6094                        // distinguish budget exhaustion from a
6095                        // generic Runtime error and retry / give up
6096                        // accordingly.
6097                        self.last_error_kind = crate::vm::error::LuaErrorKind::InstrBudget;
6098                        let s = Value::Str(self.heap.intern(b"instruction budget exceeded"));
6099                        return Err(LuaError(s));
6100                    }
6101                }
6102                if let Some(cap) = self.heap.mem_cap
6103                    && self.heap.bytes() > cap
6104                {
6105                    // First try a full collect — embedders set tight caps
6106                    // and the overshoot may be reclaimable (closures kept
6107                    // by short-lived frames, intermediate strings). Only
6108                    // disarm + raise if the cap is still breached after
6109                    // collection. PUC's `LUA_GCEMERGENCY` path matches.
6110                    //
6111                    // Root up to the deepest Lua frame's
6112                    // `base + max_stack` window rather than the entire
6113                    // `self.stack.len()`
6114                    // (covers register operands the current opcode
6115                    // might reference). The cap fires during table
6116                    // mutation in a tight `a[i] = i` loop where `a`
6117                    // lives at a frame-register slot past `self.top`
6118                    // (OP_NEWINDEX doesn't advance top); the deepest
6119                    // frame's max_stack window provably covers it
6120                    // since `a` is a register of the executing proto.
6121                    //
6122                    // Still over-roots caller frames' dead regs
6123                    // (slots between caller.base and the callee
6124                    // func_slot are live; slots past callee
6125                    // func_slot in caller's frame are dead until
6126                    // caller resumes). For fire-once cap path this
6127                    // residual over-root is acceptable; there is no
6128                    // full per-frame walk because a strong/weak pass
6129                    // split is semantically impossible — the weak pass
6130                    // depends on strong-pass marks.
6131                    let cap_root_top = self
6132                        .frames
6133                        .iter()
6134                        .rev()
6135                        .find_map(CallFrame::lua)
6136                        .map(|f| f.base + f.closure.proto.max_stack as u32)
6137                        .unwrap_or(self.top);
6138                    self.gc_top = cap_root_top.max(self.top);
6139                    self.collect_garbage();
6140                    if self.heap.bytes() > cap {
6141                        self.heap.mem_cap = None;
6142                        let s = Value::Str(self.heap.intern(b"memory cap exceeded"));
6143                        return Err(LuaError(s));
6144                    }
6145                }
6146            }
6147            // Single combined frame fetch: continuation arm OR Lua arm. Saves
6148            // a second `self.frames.last()` slice access vs the prior split
6149            // form (LLVM doesn't always CSE these across the cont branch).
6150            // A continuation frame on top means the call it protected just
6151            // delivered its results — wrap as `true, results…` and hand to
6152            // the pcall/xpcall caller. The error path is handled by `unwind`;
6153            // this branch is only reached on success/resume completion.
6154            // 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).
6155            let frame_peek = unsafe { self.frames.last().unwrap_unchecked() };
6156            if let &CallFrame::Cont(nc) = frame_peek {
6157                // a yieldable metamethod returned: complete the interrupted
6158                // instruction (PUC luaV_finishOp) and resume the running frame.
6159                if let ContKind::Meta(mc) = nc.kind {
6160                    frames_pop_sync(&mut self.frames, &mut self.frames_top);
6161                    let result = if self.top > nc.func_slot {
6162                        self.stack[nc.func_slot as usize]
6163                    } else {
6164                        Value::Nil
6165                    };
6166                    self.stack.truncate(nc.func_slot as usize);
6167                    self.top = mc.saved_top;
6168                    self.finish_meta(mc.action, result)?;
6169                    continue;
6170                }
6171                // a __close handler returned successfully: discard its
6172                // results, restore `top` to the slot the handler was called
6173                // at (the surrounding frame's register window above this slot
6174                // must stay alloc'd — never truncate the underlying stack),
6175                // then continue the close chain (next slot, or fire
6176                // AfterClose). When the close ends an entry activation,
6177                // drive_close hands the results up to exec_with directly.
6178                if let ContKind::Close(cc) = nc.kind {
6179                    frames_pop_sync(&mut self.frames, &mut self.frames_top);
6180                    self.top = nc.func_slot;
6181                    if let Some(vals) =
6182                        self.drive_close(cc.from, cc.pending, cc.after, entry_depth)?
6183                    {
6184                        return Ok(vals);
6185                    }
6186                    continue;
6187                }
6188                // __pairs returned: normalize its results to exactly the
6189                // dialect's count (iterator, state, control, and on 5.5 the
6190                // closing value) at pairs's slot, where the metamethod was
6191                // called, and hand them to pairs's caller.
6192                if let ContKind::Pairs = nc.kind {
6193                    frames_pop_sync(&mut self.frames, &mut self.frames_top);
6194                    let total = crate::vm::builtins::pairs_mm_results(self) as u32;
6195                    let need = (nc.func_slot + total) as usize;
6196                    if self.stack.len() < need {
6197                        self.stack.resize(need, Value::Nil);
6198                    }
6199                    // the metamethod ran one slot above pairs's own
6200                    let first = nc.func_slot + 1;
6201                    let n = (self.top - first).min(total);
6202                    for i in 0..n {
6203                        self.stack[(nc.func_slot + i) as usize] = self.stack[(first + i) as usize];
6204                    }
6205                    for s in (nc.func_slot + n)..(nc.func_slot + total) {
6206                        self.stack[s as usize] = Value::Nil;
6207                    }
6208                    self.top = nc.func_slot + total;
6209                    if self.frames.len() < entry_depth {
6210                        return Ok(self.take_results(nc.func_slot));
6211                    }
6212                    self.finish_results(nc.func_slot, total, nc.nresults);
6213                    continue;
6214                }
6215                frames_pop_sync(&mut self.frames, &mut self.frames_top);
6216                self.pcall_depth -= 1;
6217                // f's results sit at nc.func_slot+1.. (f was called one slot
6218                // above the continuation), so writing `true` at the slot makes
6219                // `true, results…` already contiguous.
6220                let nret = self.top - (nc.func_slot + 1);
6221                self.stack[nc.func_slot as usize] = Value::Bool(true);
6222                let total = 1 + nret;
6223                self.top = nc.func_slot + total;
6224                if self.frames.len() < entry_depth {
6225                    return Ok(self.take_results(nc.func_slot));
6226                }
6227                self.finish_results(nc.func_slot, total, nc.nresults);
6228                continue;
6229            }
6230            // GC runs only at the allocation safe points below (PUC's
6231            // `luaC_checkGC` sites), each with a precise `gc_top`; the loop head
6232            // no longer collects, so a stale full-window `gc_top` cannot leak in.
6233            //
6234            // Hot-path frame fetch: the Cont arm above continues the loop,
6235            // so reaching here means `frame_peek` is the Lua frame. Reuse it
6236            // rather than re-fetching `self.frames.last()`.
6237            let f = match frame_peek {
6238                CallFrame::Lua(f) => f,
6239                _ => unreachable!("Cont frame survived the dispatch loop head"),
6240            };
6241            let cl = f.closure;
6242            let base = f.base;
6243            let func_slot = f.func_slot;
6244            let n_varargs = f.n_varargs;
6245            let pc = f.pc;
6246            let oldpc = f.hook_oldpc;
6247
6248            // SAFETY: `pc` is bounded by the compiler against `proto.code.len()`
6249            // — every branch / call op only sets `pc` to a valid index, and
6250            // function entry initialises pc=0 with a non-empty body. PUC's
6251            // `vmfetch` uses the equivalent unchecked load.
6252            let inst = unsafe { *cl.proto.code.get_unchecked(pc as usize) };
6253
6254            // Trace recording append + close detection.
6255            // Gated on `trace_jit_enabled` + `active_trace.is_some()`
6256            // so default dispatch keeps a single not-taken branch.
6257            //
6258            // - At the head PC with a non-empty record, the trace has
6259            //   looped back to its start: mark `closed = true` and
6260            //   take the record for compile + cache.
6261            // - Otherwise, capture the op. If the record overflows
6262            //   MAX_TRACE_LEN, abort by dropping it.
6263            if self.jit.trace_enabled
6264                && let Some(_rec) = self.jit.active_trace.as_mut()
6265            {
6266                // Depth tracking. The trace head's frame is
6267                // at index `recording_frame_base`; every Op::Call that
6268                // pushes a new frame bumps the live depth, every
6269                // Op::Return that pops one decrements it.
6270                //
6271                // **Three clean-close conditions**:
6272                // - `at_head`: cur_depth == 0 AND about-to-execute the
6273                //   trace's head_pc on its head_proto (loop closed back
6274                //   to start). Same for loop-triggered and call-triggered
6275                //   traces, so a call-triggered trace does not close on
6276                //   the first re-entry (that would leave fib's body at 7
6277                //   depth=0 ops); it inlines up to MAX_INLINE_DEPTH
6278                //   levels before any close.
6279                // - `returned_past_head`: trace head's frame is gone
6280                //   (callee returned past it, or the call-trigger
6281                //   started a recording inside a callee that has now
6282                //   returned). Whatever ops were recorded form the
6283                //   trace body; the lowerer treats the partial trace
6284                //   the same as InlineAbort.
6285                // - `depth_cap_hit`: cur_depth > MAX_INLINE_DEPTH.
6286                //   Recording any deeper would just bloat the IR; close
6287                //   with the body we have. Lowerer's existing length
6288                //   gate + InlineAbort path handles short bodies.
6289                let returned_past_head = self.frames.len() <= self.jit.recording_frame_base;
6290                let cur_depth = if returned_past_head {
6291                    0
6292                } else {
6293                    self.frames.len() - 1 - self.jit.recording_frame_base
6294                };
6295                let depth_cap_hit = cur_depth > crate::jit::trace::MAX_INLINE_DEPTH as usize;
6296                let rec = self.jit.active_trace.as_mut().expect("just checked Some");
6297                let at_head_loop = cur_depth == 0
6298                    && !rec.ops.is_empty()
6299                    && !returned_past_head
6300                    && std::ptr::eq(cl.proto.as_ptr(), rec.head_proto.as_ptr())
6301                    && pc == rec.head_pc;
6302                // Self-link cycle catch (mirrors LuaJIT's
6303                // `check_call_unroll` at `lj_record.c:1869`). Trips when:
6304                //   1. We're about to execute the head_pc on head_proto
6305                //      at depth > 0 (we're re-entering the trace head
6306                //      from inside an inlined recursion level — UpRec).
6307                //   2. The count of ancestor frames in the recording
6308                //      window that share `head_proto` exceeds
6309                //      [`RECUNROLL_THRESHOLD`] (default 2).
6310                // For fib(N): head_pc=0, head_proto=fib. After 2 inline
6311                // recursion levels are captured, the recorder enters
6312                // the 3rd nested fib frame, sees cur_depth=3 > 2, and
6313                // trips this catch — closing with `SelfRecKind::UpRec`.
6314                // The lowerer's `TraceEnd::SelfLink` tail emits the
6315                // bump-base + branch-to-self loop body.
6316                //
6317                // TailRec vs UpRec: LJ distinguishes via
6318                // `framedepth + retdepth == 0`. luna doesn't track
6319                // retdepth separately; cur_depth == 0 with a non-empty
6320                // call chain in tail position is rare (would require
6321                // explicit Lua TCO). We use cur_depth > 0 as the UpRec
6322                // condition (fib's case); cur_depth == 0 with positive
6323                // ancestor count would route to TailRec, but luna's
6324                // recorder doesn't currently produce that shape because
6325                // tail-call elision pops the caller frame and we'd
6326                // hit `at_head_loop` instead.
6327                let self_link_trip: Option<crate::jit::trace::SelfRecKind> = {
6328                    if self.jit.self_link_enabled
6329                        && !returned_past_head
6330                        && std::ptr::eq(cl.proto.as_ptr(), rec.head_proto.as_ptr())
6331                        && pc == rec.head_pc
6332                        && cur_depth > 0
6333                    {
6334                        // Count ancestor frames sharing head_proto.
6335                        // self.frames[recording_frame_base..] currently
6336                        // includes the just-pushed frame at the top
6337                        // (the one about to execute head_pc). Ancestors
6338                        // = the slice excluding the top frame.
6339                        let head_proto_ptr = rec.head_proto.as_ptr();
6340                        let last_idx = self.frames.len() - 1;
6341                        let mut count = 0usize;
6342                        for i in self.jit.recording_frame_base..last_idx {
6343                            if let CallFrame::Lua(f) = &self.frames[i]
6344                                && std::ptr::eq(f.closure.proto.as_ptr(), head_proto_ptr)
6345                            {
6346                                count += 1;
6347                            }
6348                        }
6349                        if count > crate::jit::trace::RECUNROLL_THRESHOLD {
6350                            // cur_depth > 0 → UpRec (fib pattern).
6351                            // cur_depth == 0 wouldn't reach this arm.
6352                            Some(crate::jit::trace::SelfRecKind::UpRec)
6353                        } else {
6354                            None
6355                        }
6356                    } else {
6357                        None
6358                    }
6359                };
6360                if let Some(kind) = self_link_trip {
6361                    // SelfLink relax for self-recursive patterns at frame
6362                    // depth >= 2.
6363                    //
6364                    // Stamping `self_link_kind` unconditionally at the
6365                    // head_pc re-entry would never dispatch: the
6366                    // `downrec_close` marker can only fire from the
6367                    // depth>0 Op::Return path (`rec.retfs` chain),
6368                    // which never reaches the recorder for fib(28)-like
6369                    // shapes that hit the SelfLink cycle catch BEFORE
6370                    // any base-case Return — leaving `downrec_close`
6371                    // None and routing the trace through the safe
6372                    // `dispatchable=false` `"self-link-retf-r1"` path.
6373                    //
6374                    // So when the SelfLink trip fires AND
6375                    // `cur_depth >= 2` (the count > RECUNROLL_THRESHOLD
6376                    // gate already requires this — kept explicit as a
6377                    // safety floor), route the close through `downrec_
6378                    // close` INSTEAD of `self_link_kind`. The recorder
6379                    // synthesises the close marker from the most
6380                    // recent Op::Call at depth `cur_depth - 1`:
6381                    //   - `return_pc` = `call.pc + 1` (caller's resume
6382                    //     PC after the recursive call returns; mirror
6383                    //     of the `caller_pc` derivation at the
6384                    //     depth>0 Op::Return capture path below).
6385                    //   - `target_proto` = `call.proto` (caller's
6386                    //     proto; equals `rec.head_proto` for self-
6387                    //     recursion).
6388                    //   - `depth_delta` = `1` (the recorder always
6389                    //     unrolls one level; the Op::Return path uses
6390                    //     the same constant).
6391                    //
6392                    // The lowerer's `end_idx` picker routes through
6393                    // `TraceEnd::DownRec` ahead of the `self_link_kind`
6394                    // arm and emits the stitch-sentinel +
6395                    // caller-pc-guard scaffold. A single-candidate guard
6396                    // chain (this path produces 1 caller_pc candidate
6397                    // because `rec.retfs` is empty) keeps
6398                    // `dispatchable=false` + `"downrec-stitch-pending"`
6399                    // label (the lowerer requires
6400                    // `multi_way_candidate_count >= 2`). Net behaviour:
6401                    // trace compiles under DownRec routing; interp runs
6402                    // the recursion naturally.
6403                    //
6404                    // The `cur_depth >= 2` gate is automatically
6405                    // satisfied by the count > RECUNROLL_THRESHOLD=2
6406                    // trip condition (3 ancestor frames sharing
6407                    // head_proto implies cur_depth >= 3), kept
6408                    // explicit so a future RECUNROLL_THRESHOLD tweak
6409                    // doesn't silently flip shallow-recursion
6410                    // shapes (cur_depth == 1) onto the DownRec arm.
6411                    //
6412                    // The recorded body still uses depth-baked
6413                    // op_offsets[] addressing, so this is routing
6414                    // scaffolding and gives no speedup by itself.
6415                    let _ = kind;
6416                    let relaxed_to_downrec = cur_depth >= 2 && rec.downrec_close.is_none() && {
6417                        let caller_depth_u8 = (cur_depth - 1) as u8;
6418                        if let Some(call_op) = rec.ops.iter().rev().find(|r| {
6419                            r.inline_depth == caller_depth_u8
6420                                && matches!(r.inst.op(), crate::vm::isa::Op::Call)
6421                        }) {
6422                            rec.downrec_close = Some(crate::jit::trace::DownRecClose {
6423                                return_pc: call_op.pc + 1,
6424                                target_proto: call_op.proto,
6425                                depth_delta: 1,
6426                            });
6427                            true
6428                        } else {
6429                            false
6430                        }
6431                    };
6432                    if relaxed_to_downrec {
6433                        // Close-cause taxonomy: tag the lift so
6434                        // probes can tally the fire rate. Mirrors
6435                        // the `"downrec-restart"` bump for the
6436                        // depth>0 Op::Return path (different trip
6437                        // origin, same downstream routing). The
6438                        // existing `"self-link-retf-r1"` label still
6439                        // fires for trips that DON'T relax (no
6440                        // candidate Op::Call ancestor in rec.ops, or
6441                        // cur_depth < 2) via the lowerer's
6442                        // dispatch_off_reason mirror at the close
6443                        // handler — kept as a regression safety net.
6444                        self.jit
6445                            .counters
6446                            .bump_close_cause("selflink-yields-to-downrec");
6447                    } else {
6448                        rec.self_link_kind = Some(kind);
6449                    }
6450                }
6451                let should_close =
6452                    at_head_loop || returned_past_head || depth_cap_hit || self_link_trip.is_some();
6453                if should_close {
6454                    // Long-trace bias: a call-triggered
6455                    // recording that closed with a very short body
6456                    // (fib base case: `Lt`/`Jmp`/`Return1` = 3 ops,
6457                    // binary_trees `make(0)`: 4 ops) is pathological.
6458                    // Compiling + caching it pins `Proto.traces` to a
6459                    // trace that the length gate will refuse to
6460                    // dispatch (per `MIN_DISPATCHABLE_TRUNC_BODY_FLOOR
6461                    // = 40`), AND blocks the back-edge / longer-call
6462                    // path from re-recording the same head_pc (the
6463                    // dedup `already_cached` check below short-
6464                    // circuits). The fix: discard the short call-
6465                    // triggered recording WITHOUT caching, and bias
6466                    // the proto's `call_hot_count` back to
6467                    // `THRESHOLD - HOT_RETRY_WINDOW` so the next
6468                    // sequence of calls retries the trigger at a
6469                    // different (hopefully deeper) recursion point.
6470                    //
6471                    // Back-edge triggered traces are exempt — a
6472                    // tight numeric-for loop's body is legitimately
6473                    // 3 ops (`Add`, ForLoop) and DOES dispatch
6474                    // usefully when re-entered many times.
6475                    // Coverage heuristic to detect
6476                    // pathologically partial call-triggered traces:
6477                    // for self-recursive / branchy protos like
6478                    // `fib` (~17 bytecode ops) or
6479                    // `binary_trees.make` (~26 ops), the recorder
6480                    // can fire at a BASE-case entry (`fib(0)` or
6481                    // `make(0)`) producing a 3–4 op trace that
6482                    // covers a tiny fraction of the proto's code.
6483                    // That trace is doomed by the length gate
6484                    // post-compile AND blocks any longer follow-up
6485                    // (the dedup `already_cached` check below). The
6486                    // fix: discard call-triggered closes where
6487                    // `rec.ops.len() * 2 < head_proto.code.len()`
6488                    // (less than half the proto's bytecode), so the
6489                    // back-edge / longer call path can take over.
6490                    //
6491                    // Why coverage > raw length:protos with
6492                    // intrinsically short bodies (closure
6493                    // factories: `Closure + Return1` = 2 ops,
6494                    // simple wrappers: `LoadI + Return1` = 2 ops)
6495                    // record 100% coverage even at length 2 — those
6496                    // ARE legitimately short and the closure /
6497                    // sunk-emit lowering paths make
6498                    // them worth compiling. The heuristic admits
6499                    // them. fib's `[Lt, Jmp, Return1]` (3 of ~17)
6500                    // and make's `[Lt, Jmp, LoadI, Return1]` (4 of
6501                    // ~26) get discarded.
6502                    //
6503                    // Back-edge triggered traces are unaffected —
6504                    // a tight numeric-for body legitimately covers
6505                    // 3 of ~3 proto ops it can dispatch from
6506                    // (`Add + ForLoop`) and the recorder fires on
6507                    // the back-edge, not call entry.
6508                    //
6509                    // `call_hot_count` is intentionally NOT reset
6510                    // (an earlier draft tried `THRESHOLD - 32` but
6511                    // caused active_trace contention with the
6512                    // outer back-edge trigger — see
6513                    // setlist_b_zero_with_call_c_zero_sunk_emits).
6514                    // We give up on dispatching the pathological
6515                    // shape on the same proto; the back-edge or a
6516                    // longer call path on a deeper recursion point
6517                    // can still record + cache a real trace.
6518                    let proto_code_len = rec.head_proto.code.len();
6519                    let is_partial_coverage = rec.ops.len() * 2 < proto_code_len;
6520                    // Per-Proto discard cap. The relaxed
6521                    // trigger condition (`c >= THRESHOLD &&
6522                    // !already_cached`) means a Proto whose every
6523                    // recording is partial-coverage will re-fire the
6524                    // trigger every call indefinitely (1500+ in
6525                    // `binary_trees`-pattern test). The cap stops
6526                    // discarding after `MAX_DISCARDS_PER_PROTO` —
6527                    // the next close falls through to compile (even
6528                    // if partial), caches the trace, and the
6529                    // `already_cached` short-circuit kills the
6530                    // storm. Dispatch may still be refused
6531                    // post-compile (length gate), but the recorder
6532                    // stops churning.
6533                    const MAX_DISCARDS_PER_PROTO: u32 = 5;
6534                    let prior_discards = rec.head_proto.trace_discard_count.get();
6535                    let cap_reached = prior_discards >= MAX_DISCARDS_PER_PROTO;
6536                    // Flip the `gave_up` flag the
6537                    // moment cap is reached (BEFORE the close-
6538                    // dispatching branch below). The trigger gates
6539                    // short-circuit on this flag, skipping the
6540                    // RefCell + linear `already_cached` scan on
6541                    // every subsequent call to this Proto. Useful
6542                    // for `binary_trees_pattern`-class loads where
6543                    // a single Proto sees ~20k calls post-cap.
6544                    if cap_reached
6545                        && rec.is_call_triggered
6546                        && is_partial_coverage
6547                        && !rec.head_proto.trace_gave_up.get()
6548                    {
6549                        rec.head_proto.trace_gave_up.set(true);
6550                    }
6551                    if rec.is_call_triggered && is_partial_coverage && !cap_reached {
6552                        // Tally as closed (for visibility) but DROP
6553                        // without compile/cache. Use the existing
6554                        // closed-lens accumulator so probes can
6555                        // observe the discarded shape.
6556                        // Bump discard count BEFORE
6557                        // dropping the recording so the next
6558                        // close sees the updated counter.
6559                        rec.head_proto.trace_discard_count.set(prior_discards + 1);
6560                        self.jit.counters.closed += 1;
6561                        self.jit
6562                            .counters
6563                            .closed_lens
6564                            .push((rec.is_call_triggered, rec.ops.len()));
6565                        // Partial-coverage discard close path.
6566                        // `closed` + `closed_lens` alone can't separate
6567                        // a real successful close from a discard tally,
6568                        // so tag explicitly to keep the recorder-side
6569                        // close-cause taxonomy single-source.
6570                        self.jit
6571                            .counters
6572                            .bump_close_cause("partial-coverage-discard");
6573                        self.jit.active_trace = None;
6574                        // Continue with interp loop — don't
6575                        // fall through to compile path.
6576                        // The op at `pc` hasn't dispatched yet;
6577                        // the outer loop iteration handles it.
6578                    } else {
6579                        rec.closed = true;
6580                        // Detach the closed record, then try
6581                        // to compile it. Dedup by `head_pc`: a Proto
6582                        // already carrying a CompiledTrace for this PC
6583                        // skips recompile (the hot counter caps
6584                        // re-recording at `u32::MAX / 2` anyway, but
6585                        // explicit dedup keeps `Proto.traces` short
6586                        // for the dispatcher's linear scan).
6587                        //
6588                        // On failure we just bump the failed counter
6589                        // and drop the record.
6590                        let head_pc_val = rec.head_pc;
6591                        let closed_record = self
6592                            .jit
6593                            .active_trace
6594                            .take()
6595                            .expect("active_trace was Some this branch");
6596                        self.jit.counters.closed += 1;
6597                        self.jit
6598                            .counters
6599                            .closed_lens
6600                            .push((closed_record.is_call_triggered, closed_record.ops.len()));
6601                        // Cache the trace on the
6602                        // recorder's *head proto*, not the current
6603                        // closure's proto. For non-recursive
6604                        // call-triggered traces, close fires after
6605                        // `Return1` pops the callee frame — `cl` at
6606                        // that point is the CALLER's closure, while
6607                        // `closed_record.head_proto` is the CALLEE's
6608                        // proto (the one we actually want the trace
6609                        // to be discoverable from on the next call).
6610                        // Self-recursive fib closed via depth-cap
6611                        // mid-recursion, so `cl.proto == head_proto`
6612                        // there, but only by coincidence.
6613                        let head_proto = closed_record.head_proto;
6614                        let already_cached = head_proto
6615                            .traces
6616                            .borrow()
6617                            .iter()
6618                            .any(|t| t.head_pc == head_pc_val);
6619                        if !already_cached {
6620                            // Internal-loop = true: the trace runs in
6621                            // a native loop until a cmp side-exits, so
6622                            // the dispatcher's per-entry marshal cost
6623                            // amortizes across the whole run of
6624                            // iterations the loop's recorded direction
6625                            // stays valid. The lowerer auto-downgrades
6626                            // to one-shot for cmp-less or Call-truncating
6627                            // traces.
6628                            // Side traces MUST NOT
6629                            // internal-loop. The parent's recorded prefix
6630                            // (ops at PCs < side trace's head_pc) defines
6631                            // values for registers the child's body reads
6632                            // without re-writing each iter — e.g. for
6633                            // s12_step_b, parent's `pc=19 Add R[12] = R[1]
6634                            // + R[11]` sets R[12], and the child trace
6635                            // (head_pc=24) re-runs `pc=20 Move R[1] =
6636                            // R[12]` each iter via its outer ForLoop
6637                            // internal-loop, ALWAYS reading the stale
6638                            // entry-time R[12]. The parent's Add never
6639                            // re-runs during child's loop, so R[1] gets
6640                            // pinned to one stale value. Force one-shot
6641                            // for side traces: each parent-exit round-
6642                            // trips through dispatcher → parent's Add
6643                            // runs → side trace runs ONE iter → return.
6644                            let opts = crate::jit::trace::CompileOptions {
6645                                internal_loop: closed_record.side_trace_parent.is_none(),
6646                                pre53: self.version() <= LuaVersion::Lua53,
6647                                aot: false,
6648                            };
6649                            // Route through trace_compiler; split-borrow JitState
6650                            // so the trait method can take `&mut dyn JitStorage`.
6651                            let result = {
6652                                let jit = &mut self.jit;
6653                                jit.storage.claim(self.jit_owner_id);
6654                                let storage: &mut dyn crate::jit::JitStorage = jit.storage.as_mut();
6655                                jit.trace_compiler
6656                                    .try_compile_trace(storage, &closed_record, opts)
6657                            };
6658                            match result {
6659                                Some(mut ct) => {
6660                                    // Tally Sinkable sites
6661                                    // + actually-sunk-emit sites + materialise
6662                                    // emit sites before moving `ct` into
6663                                    // Proto.traces.
6664                                    self.jit.counters.sinkable_seen +=
6665                                        ct.sinkable_sites_seen as u64;
6666                                    self.jit.counters.accum_bufferable_seen +=
6667                                        ct.accum_bufferable_seen as u64;
6668                                    self.jit.counters.sunk_alloc += ct.sunk_alloc_seen as u64;
6669                                    self.jit.counters.materialize_emit +=
6670                                        ct.materialize_emit_count as u64;
6671                                    self.jit.counters.closure_emit += ct.closure_seen as u64;
6672                                    if ct.is_inline_abort_close {
6673                                        self.jit.counters.inline_abort += 1;
6674                                    }
6675                                    // Split tally so a
6676                                    // probe can answer the AOT
6677                                    // `accepted_with_per_exit_inline`
6678                                    // gate's question at the JIT
6679                                    // surface too: how many compiled
6680                                    // traces emitted depth>0 cmp
6681                                    // side-exits, and how many of
6682                                    // those survived all the
6683                                    // `dispatchable = false` pins
6684                                    // (`InlineAbort-gate`,
6685                                    // `self-link-retf-r1`,
6686                                    // `downrec-stitch-pending`, etc.).
6687                                    if !ct.per_exit_inline.is_empty() {
6688                                        self.jit.counters.per_exit_inline_compiled += 1;
6689                                        if ct.dispatchable {
6690                                            self.jit.counters.per_exit_inline_dispatchable += 1;
6691                                        }
6692                                    }
6693                                    if let Some(reason) = ct.dispatch_off_reason {
6694                                        self.jit.counters.dispatch_off_reasons.push(reason);
6695                                        // Mirror
6696                                        // the ordered Vec push into
6697                                        // the per-reason HashMap so
6698                                        // probes can answer "how many
6699                                        // of each dispatch_off label
6700                                        // fired" in O(1) without
6701                                        // walking the Vec. Same
6702                                        // bucket as the recorder-side
6703                                        // abort/discard tags above.
6704                                        self.jit.counters.bump_close_cause(reason);
6705                                    }
6706                                    // Count compiled traces that
6707                                    // carry a down-recursion stitch
6708                                    // link. Bumped here (not at the
6709                                    // lowerer emit site) because the
6710                                    // Vm's JitCounters live on the Vm,
6711                                    // and the lowerer doesn't have a
6712                                    // Vm handle. Read via
6713                                    // `Vm::trace_downrec_link_compiled_count`.
6714                                    if ct.downrec_link.is_some() {
6715                                        self.jit.counters.downrec_link_compiled += 1;
6716                                    }
6717                                    // Multi-way guard emit counter.
6718                                    // Bumped when the lowerer collected
6719                                    // >= 2 distinct caller_pc candidates
6720                                    // and lifted `dispatchable=true`.
6721                                    // The single-CMP shape stores
6722                                    // `1` here without bumping; non-
6723                                    // DownRec closes store `0`.
6724                                    if ct.downrec_multi_way_count >= 2 {
6725                                        self.jit.counters.multi_way_guard_emitted += 1;
6726                                    }
6727                                    // Side-trace finalisation.
6728                                    // Pin `dispatchable=false` so the
6729                                    // primary lookup `traces.find(|t|
6730                                    // t.head_pc == pc && t.dispatchable)`
6731                                    // never matches this entry — the
6732                                    // side trace is meant to be entered
6733                                    // ONLY through the parent's exit
6734                                    // indirection, not the
6735                                    // back-edge / call-trigger paths.
6736                                    // Then write the entry fn ptr into
6737                                    // the parent's `exit_side_trace_ptrs`
6738                                    // slot so the parent's IR can read it.
6739                                    if let Some((parent_proto, parent_head_pc, parent_exit_idx)) =
6740                                        closed_record.side_trace_parent
6741                                    {
6742                                        // The lowerer's own verdict: a trace it
6743                                        // compiled but would not dispatch (an
6744                                        // untyped table read, an inline abort)
6745                                        // is not safe to enter from the parent's
6746                                        // exit either — unless the only reason
6747                                        // was the length gate, which weighs
6748                                        // dispatch overhead, not soundness (the
6749                                        // lowerer records the other reasons
6750                                        // first).
6751                                        let runnable = ct.dispatchable
6752                                            || ct.dispatch_off_reason == Some("length-gate");
6753                                        ct.dispatchable = false;
6754                                        let entry_ptr = ct.entry as *const () as *const u8;
6755                                        let _side_trace_head_pc = closed_record.head_pc;
6756                                        let parent_traces = parent_proto.traces.borrow();
6757                                        if let Some(parent_ct) = parent_traces
6758                                            .iter()
6759                                            .find(|t| t.head_pc == parent_head_pc)
6760                                        {
6761                                            // Shape-match
6762                                            // gate. Find the parent's per-exit
6763                                            // tag snapshot at the wired exit
6764                                            // (inline / tag / global) and
6765                                            // check the child's entry_tags
6766                                            // match. If not, leave the cell
6767                                            // null + skip cache populate so
6768                                            // the parent IR's
6769                                            // `call_indirect` stays inert at
6770                                            // this exit (the child's
6771                                            // shape-specialised IR would
6772                                            // mis-interpret raw bits the
6773                                            // parent writes to reg_state).
6774                                            let inline_n = parent_ct.per_exit_inline.len();
6775                                            let tags_n = parent_ct.per_exit_tags.len();
6776                                            let parent_exit_tags_slice: &[
6777                                            crate::jit::trace::ExitTag
6778                                        ] = if parent_exit_idx < inline_n {
6779                                            &parent_ct.per_exit_inline
6780                                                [parent_exit_idx]
6781                                                .exit_tags
6782                                        } else if parent_exit_idx
6783                                            < inline_n + tags_n
6784                                        {
6785                                            &parent_ct.per_exit_tags
6786                                                [parent_exit_idx - inline_n]
6787                                                .1
6788                                        } else {
6789                                            &parent_ct.exit_tags
6790                                        };
6791                                            let shape_matches =
6792                                                crate::jit::trace::exit_tags_match_entry_tags(
6793                                                    &ct.entry_tags,
6794                                                    parent_exit_tags_slice,
6795                                                    &parent_ct.entry_tags,
6796                                                );
6797                                            if !shape_matches {
6798                                                self.jit.counters.side_trace_shape_mismatch += 1;
6799                                            }
6800                                            let shape_ok = runnable && shape_matches;
6801                                            // Write the child's
6802                                            // entry fn ptr to BOTH the legacy
6803                                            // `exit_side_trace_ptrs[idx]`
6804                                            // cell (read by the
6805                                            // walk_any_side_ptr_non_null tests)
6806                                            // AND the per-kind cell
6807                                            // whose heap address the parent's
6808                                            // IR baked. The IR-baked
6809                                            // cell is what the call_indirect
6810                                            // gate actually reads. Only write
6811                                            // when the shape gate passes.
6812                                            if shape_ok {
6813                                                if let Some(cell) = parent_ct
6814                                                    .exit_side_trace_ptrs
6815                                                    .get(parent_exit_idx)
6816                                                {
6817                                                    cell.set(entry_ptr);
6818                                                }
6819                                                // Compute (kind, local) for the
6820                                                // IR-baked cell. Layout follows
6821                                                // exit_hit_counts: inline first,
6822                                                // then per_exit_tags, then the
6823                                                // global tail slot.
6824                                                let (sent_kind, sent_local) = if parent_exit_idx
6825                                                    < inline_n
6826                                                {
6827                                                    parent_ct.per_exit_inline[parent_exit_idx]
6828                                                        .side_trace_ptr
6829                                                        .set(entry_ptr);
6830                                                    (
6831                                                        crate::jit::trace::SIDE_SENT_KIND_INLINE,
6832                                                        parent_exit_idx as u32,
6833                                                    )
6834                                                } else if parent_exit_idx < inline_n + tags_n {
6835                                                    let local = parent_exit_idx - inline_n;
6836                                                    if let Some(b) =
6837                                                        parent_ct.tags_side_trace_ptrs.get(local)
6838                                                    {
6839                                                        b.set(entry_ptr);
6840                                                    }
6841                                                    (
6842                                                        crate::jit::trace::SIDE_SENT_KIND_TAG,
6843                                                        local as u32,
6844                                                    )
6845                                                } else {
6846                                                    parent_ct.global_side_trace_ptr.set(entry_ptr);
6847                                                    (crate::jit::trace::SIDE_SENT_KIND_GLOBAL, 0)
6848                                                };
6849                                                self.jit.counters.side_trace_compiled += 1;
6850                                                // Flip the
6851                                                // parent's fast-path hint so
6852                                                // the dispatcher knows to do
6853                                                // the tentative decode + cell
6854                                                // check on subsequent
6855                                                // dispatches. Set once and
6856                                                // stays true (we never unwire
6857                                                // a side trace today).
6858                                                parent_ct.has_any_side_wired.set(true);
6859
6860                                                // Populate
6861                                                // the O(1) lookup cache the
6862                                                // dispatcher consults on
6863                                                // sentinel-bit-set returns.
6864                                                // Key is the encoded sentinel
6865                                                // (same encoding the IR ORs
6866                                                // into bits 56..=62 of the
6867                                                // child's i64 return).
6868                                                let sentinel =
6869                                                    crate::jit::trace::encode_side_sentinel(
6870                                                        sent_kind, sent_local,
6871                                                    );
6872                                                let predicted_idx = if std::ptr::eq(
6873                                                    parent_proto.as_ptr(),
6874                                                    head_proto.as_ptr(),
6875                                                ) {
6876                                                    parent_traces.len() as u32
6877                                                } else {
6878                                                    head_proto.traces.borrow().len() as u32
6879                                                };
6880                                                parent_ct
6881                                                    .side_trace_cache
6882                                                    .borrow_mut()
6883                                                    .insert(sentinel, predicted_idx);
6884                                            }
6885                                        }
6886                                        drop(parent_traces);
6887                                    }
6888                                    head_proto.traces.borrow_mut().push(TArc::new(ct));
6889                                    self.jit.counters.compiled += 1;
6890                                }
6891                                None => {
6892                                    self.jit.counters.compile_failed += 1;
6893                                    note_trace_compile_failure(head_proto, closed_record.head_pc);
6894                                    self.jit
6895                                        .counters
6896                                        .compile_failed_reasons
6897                                        .push(self.jit.trace_compiler.last_compile_checkpoint());
6898                                }
6899                            }
6900                        }
6901                    } // close the long-trace-bias else branch
6902                } else {
6903                    // Depth-aware push at the
6904                    // current `cur_depth`. The `depth_cap_hit` /
6905                    // `returned_past_head` early-exit is handled by
6906                    // the `should_close` branch above; reaching here
6907                    // means `cur_depth <= MAX_INLINE_DEPTH` and the
6908                    // trace head's frame is still live.
6909                    let depth_u8 = cur_depth as u8;
6910                    if depth_u8 > self.jit.max_depth_seen {
6911                        self.jit.max_depth_seen = depth_u8;
6912                    }
6913                    // Fix up a prior `Op::Call C=0` (multi-
6914                    // return / variable return count). Recorder pushed
6915                    // it with var_count=None before the call dispatched;
6916                    // now that the call has returned and we're about to
6917                    // push the next op, top reflects the actual return
6918                    // count. Snapshot top - (caller.base + call.a).
6919                    if let Some(last) = rec.ops.last_mut()
6920                        && matches!(last.inst.op(), crate::vm::isa::Op::Call)
6921                        && last.inst.c() == 0
6922                        && last.var_count.is_none()
6923                        && let Some(f) = self.frames.last().and_then(CallFrame::lua)
6924                    {
6925                        let from = f.base + last.inst.a();
6926                        if self.top >= from {
6927                            last.var_count = Some(self.top - from);
6928                        }
6929                    }
6930                    // For SetList B=0, snapshot the source
6931                    // count = top - A - 1 (mirrors Lua's `n = top - ra
6932                    // - 1` from lvm.c OP_SETLIST). Sources are
6933                    // R[A+1..top), exclusive top. For Call C=0's
6934                    // var_count (the return count = top - A inclusive),
6935                    // see the prior-op fix-up above; here we
6936                    // initialise the current Call op to None and let
6937                    // the fix-up on the next op's push populate it.
6938                    let var_count = if matches!(inst.op(), crate::vm::isa::Op::SetList)
6939                        && inst.b() == 0
6940                        && let Some(f) = self.frames.last().and_then(CallFrame::lua)
6941                    {
6942                        let from = f.base + inst.a();
6943                        if self.top > from {
6944                            Some(self.top - from - 1)
6945                        } else {
6946                            None
6947                        }
6948                    } else {
6949                        None
6950                    };
6951                    let op = crate::jit::trace::RecordedOp {
6952                        proto: cl.proto,
6953                        pc,
6954                        inst,
6955                        inline_depth: depth_u8,
6956                        var_count,
6957                    };
6958                    // Depth>0 Return0/Return1 mirrors
6959                    // LuaJIT's `IR_RETF` (lj_record.c:922+ lj_record_ret).
6960                    // Captured as a side-channel `RetfRecord` parallel to
6961                    // `ops` when `self_link_enabled` is on. The
6962                    // down-rec stitch consumes these to guard side-trace
6963                    // inlined-frame topology against the recorded shape.
6964                    // Gated on the same flag as the cycle catch so the
6965                    // ship-default path (p16 off) sees zero behavior
6966                    // change. `caller_pc` is the recorded enclosing Call's
6967                    // pc + 1 — interp's resume point after the inlined
6968                    // frame pops.
6969                    if self.jit.self_link_enabled
6970                        && depth_u8 > 0
6971                        && matches!(
6972                            inst.op(),
6973                            crate::vm::isa::Op::Return0 | crate::vm::isa::Op::Return1
6974                        )
6975                    {
6976                        let results: u8 = match inst.op() {
6977                            crate::vm::isa::Op::Return0 => 0,
6978                            crate::vm::isa::Op::Return1 => 1,
6979                            _ => 0,
6980                        };
6981                        // Most recent Op::Call recorded at the caller's
6982                        // depth (`depth_u8 - 1`) is the frame this Return
6983                        // is unwinding from. Reverse scan stops at the
6984                        // first match.
6985                        let caller_depth = depth_u8 - 1;
6986                        let caller_call = rec.ops.iter().rev().find(|r| {
6987                            r.inline_depth == caller_depth
6988                                && matches!(r.inst.op(), crate::vm::isa::Op::Call)
6989                        });
6990                        let caller_pc = caller_call.map(|r| r.pc + 1).unwrap_or(pc);
6991                        // Capture the caller's proto
6992                        // for the RetfRecord. LuaJIT `IR_RETF.op1`
6993                        // equivalent. For fib(28) the caller's proto
6994                        // equals the trace head; for future mutual
6995                        // recursion the recorded Op::Call's proto is the
6996                        // right target. Fallback to head_proto when no
6997                        // enclosing Call op was captured (mirrors
6998                        // `caller_pc`'s fallback to the Return's own pc).
6999                        let caller_proto = caller_call.map(|r| r.proto).unwrap_or(rec.head_proto);
7000                        rec.retfs.push(crate::jit::trace::RetfRecord {
7001                            from_depth: depth_u8,
7002                            to_depth: caller_depth,
7003                            results,
7004                            caller_pc,
7005                            proto: caller_proto,
7006                        });
7007                        // DownRec close trigger:
7008                        // count RetfRecords on this recording whose
7009                        // `proto` matches `caller_proto` (LuaJIT
7010                        // `check_downrec_unroll` chain filter
7011                        // `op1 == ptref`). Threshold mirrors
7012                        // RECUNROLL_THRESHOLD; first trip stamps the
7013                        // `downrec_close` marker, subsequent retfs
7014                        // keep the marker without overwrite. The
7015                        // lowerer's end_idx picker routes through
7016                        // TraceEnd::DownRec when the marker is set.
7017                        if rec.downrec_close.is_none() {
7018                            let caller_proto_ptr = caller_proto.as_ptr();
7019                            let prior_match_count = rec
7020                                .retfs
7021                                .iter()
7022                                .filter(|r| r.proto.as_ptr() == caller_proto_ptr)
7023                                .count();
7024                            // Strictly-greater-than threshold matches
7025                            // LuaJIT `count + J->tailcalled > recunroll`.
7026                            // The newly-pushed retf is already counted.
7027                            if prior_match_count > crate::jit::trace::RECUNROLL_THRESHOLD {
7028                                rec.downrec_close = Some(crate::jit::trace::DownRecClose {
7029                                    return_pc: caller_pc,
7030                                    target_proto: caller_proto,
7031                                    depth_delta: 1,
7032                                });
7033                                // Close-cause taxonomy: tag the
7034                                // restart with `"downrec-restart"`. The
7035                                // lowerer adds `"downrec-stitch-failed"` when
7036                                // the lifted back-edge falls back to
7037                                // deopt.
7038                                self.jit.counters.bump_close_cause("downrec-restart");
7039                            }
7040                        }
7041                    }
7042                    // Capture FieldIcSnapshot for the
7043                    // FIRST eligible Op::GetField site under env-gate
7044                    // LUNA_JIT_FIELD_IC=1. "Eligible" means:
7045                    //   - R[B] is Value::Table with metatable.is_none()
7046                    //   - K[C] is Value::Str
7047                    //   - The string key actually occupies a hash slot
7048                    //     (so the IC's slot_idx is a real index, not
7049                    //     a probe sentinel).
7050                    // Once captured, subsequent GetFields skip this
7051                    // logic (rec.field_ic_snapshot.is_some() short-
7052                    // circuits). Env-OFF short-circuits on the cached
7053                    // atomic check inside field_ic_enabled().
7054                    if rec.field_ic_snapshot.is_none()
7055                        && matches!(inst.op(), crate::vm::isa::Op::GetField)
7056                        && crate::jit::trace_types::field_ic_enabled()
7057                    {
7058                        let b = inst.b();
7059                        let c_idx = inst.c() as usize;
7060                        let r_b = self.stack[(base + b) as usize];
7061                        if let Value::Table(g) = r_b
7062                            && g.metatable().is_none()
7063                            && c_idx < cl.proto.consts.len()
7064                            && let Value::Str(s) = cl.proto.consts[c_idx]
7065                        {
7066                            let key = Value::Str(s);
7067                            let tbl_ref = &*g;
7068                            if let Some(slot_idx) = tbl_ref.find_node_idx(key)
7069                                && let Some(val) = tbl_ref.node_val_at(slot_idx)
7070                            {
7071                                let op_idx = rec.ops.len() as u32;
7072                                rec.field_ic_snapshot =
7073                                    Some(crate::jit::trace_types::FieldIcSnapshot {
7074                                        op_idx,
7075                                        nodes_len: tbl_ref.nodes_capacity() as u64,
7076                                        slot_idx: slot_idx as u64,
7077                                        key_ptr_bits: s.as_ptr() as u64,
7078                                        cached_val_tag: val.tag_byte(),
7079                                    });
7080                                self.jit.counters.field_ic_snapshot_captured += 1;
7081                            }
7082                        }
7083                    }
7084                    if !rec.push(op) {
7085                        // Recorder overflow (MAX_TRACE_LEN). Tag it
7086                        // explicitly under the close-cause bucket so
7087                        // probes can tally overflow vs other abort
7088                        // causes in O(1).
7089                        self.jit.active_trace = None;
7090                        self.jit.counters.aborted += 1;
7091                        self.jit.counters.bump_close_cause("trace-overflow");
7092                    }
7093                }
7094            }
7095
7096            // Trace JIT dispatcher.
7097            //
7098            // When the dispatch loop is about to execute the op at
7099            // `pc` and there's a `numeric_only` CompiledTrace cached
7100            // for that `head_pc`, marshal the live regs into an
7101            // i64 buffer, jump into the trace, and resume the
7102            // interpreter at the returned continuation PC.
7103            //
7104            // Skipped (zero overhead) when `trace_jit_enabled` is
7105            // false; the lookup is a borrow + scan over
7106            // `cl.proto.traces`, which is a `Vec` whose size is at
7107            // most one entry per back-edge per Proto in practice.
7108            //
7109            // Marshalling contract — only Int slots survive the
7110            // round-trip cleanly (the reg_state ABI is `*mut i64`
7111            // with no tag info). Any non-Int slot in the affected
7112            // window forces a skip; interp takes over for one op
7113            // and the back-edge brings us back to try again next
7114            // pass (slots that were Nil/Float at one moment can
7115            // settle to Int by the time the next back-edge fires).
7116            //
7117            // A trace that comes back with `vm.jit.pending_err`
7118            // parked is treated as a deopt: clear the err, leave
7119            // the stack as the trace wrote it, and let the
7120            // interpreter run from the same `pc`. The trace itself
7121            // is left cached — a future entry might find no
7122            // metatable in the way and succeed.
7123            // Single Rc<CompiledTrace> clone instead of per-field Rc
7124            // clones: proto.traces is Vec<Rc<CompiledTrace>>; the
7125            // dispatcher clones ONE Rc and reads fields via auto-deref.
7126            // One-shot consume of the
7127            // `suppress_downrec_admit_once` flag. Set by the
7128            // downrec post-invoke arm below when it force-deopts the
7129            // trace (caller-pc guard miss OR cycle-budget exhausted)
7130            // so the NEXT interpreter loop iteration skips the
7131            // downrec admit, lets interp run the op at `head_pc`,
7132            // advances `pc` past `head_pc`, and breaks the otherwise-
7133            // infinite admit loop. Reading + clearing here means a
7134            // single dispatch tick consumes the suppression — the
7135            // following tick re-admits naturally (with the budget
7136            // also reset by the deopt site).
7137            let downrec_admit_blocked = self.jit.suppress_downrec_admit_once;
7138            self.jit.suppress_downrec_admit_once = false;
7139            if self.jit.trace_enabled
7140                && let Some(ct) = {
7141                    let traces = cl.proto.traces.borrow();
7142                    traces
7143                        .iter()
7144                        .find(|t| {
7145                            if t.head_pc != pc {
7146                                return false;
7147                            }
7148                            let is_downrec = t.downrec_link.is_some();
7149                            // The one-shot suppress
7150                            // flag blocks any admit (primary or fallback)
7151                            // for `downrec_link`-bearing traces so the
7152                            // next interp iter can run the natural op
7153                            // at `head_pc` and advance past it. The multi-way
7154                            // `dispatchable=true` lift means the suppress
7155                            // must also cover the primary `t.dispatchable`
7156                            // arm — otherwise the lifted lookup would
7157                            // immediately re-admit after a force-deopt
7158                            // and the infinite loop returns.
7159                            if downrec_admit_blocked {
7160                                return false;
7161                            }
7162                            // Primary arm: `dispatchable=true` traces
7163                            // (lifted multi-way DownRec or normal traces).
7164                            // Fallback arm: single-CMP `dispatchable=false`
7165                            // DownRec traces (single-CMP guard kept
7166                            // pinned because the 90% miss-rate would
7167                            // make blind admit perf-negative).
7168                            t.dispatchable || is_downrec
7169                        })
7170                        .cloned()
7171                }
7172            {
7173                // Borrow Rc<[T]> fields as &Rc<[T]> instead
7174                // of cloning. The outer `ct: Rc<CompiledTrace>` is held
7175                // across the entire dispatch block so the fields outlive
7176                // all consumers.
7177                let entry_fn = ct.entry;
7178                let head_pc_val = ct.head_pc;
7179                let window_size = ct.window_size;
7180                let exit_tags = &ct.exit_tags;
7181                let per_exit_tags = &ct.per_exit_tags;
7182                let per_exit_inline = &ct.per_exit_inline;
7183                let compile_entry_tags = &ct.entry_tags;
7184                let global_tag_res_kind = ct.global_tag_res_kind;
7185                let exit_hit_counts = &ct.exit_hit_counts;
7186                let max_stack = cl.proto.max_stack as usize;
7187                let window_size_us = window_size as usize;
7188                let base_us = base as usize;
7189                // `reg_state` sized to the trace's `window_size`, which
7190                // may exceed max_stack.
7191                // Marshal-in still only writes [0..max_stack); slots
7192                // [max_stack..window_size) are zero-initialised and
7193                // filled by the trace's own GetUpval / arith.
7194                // Reuse the Vm's amortised buffers
7195                // instead of allocating fresh Vecs each dispatch.
7196                // mem::take leaves an empty placeholder we restore
7197                // at the end of the dispatch block (success +
7198                // deopt paths both fall through to the restore).
7199                let mut entry_tags: Vec<u8> = std::mem::take(&mut self.jit.entry_tags_buf);
7200                entry_tags.clear();
7201                entry_tags.reserve(max_stack);
7202                // This trace was admitted via the
7203                // `downrec_link.is_some()` arm rather than the normal
7204                // `dispatchable=true` arm. The pre-invoke path
7205                // populates a reserved saved-PC slot just past the
7206                // normal register window so the lowerer's guard load
7207                // (`reg_state[window_size]`) compares the runtime
7208                // saved caller PC against the recorded `dr_return_pc`.
7209                //
7210                // No `!ct.dispatchable` gate: when the lowerer lifts
7211                // `dispatchable = true` for multi-way guards, the
7212                // trace's body still emits the downrec sentinel shape
7213                // on return — the saved-PC slot
7214                // and post-invoke classifier must keep firing.
7215                // `downrec_link.is_some()` is the unique structural
7216                // signal that the trace closes via DownRec.
7217                let is_downrec_entry = ct.downrec_link.is_some();
7218                let mut reg_state: Vec<i64> = std::mem::take(&mut self.jit.reg_state_buf);
7219                reg_state.clear();
7220                // When admitting a downrec trace,
7221                // size the buffer to `window_size + 1` so the lowerer
7222                // can `load(I64, ..., reg_state, window_size * 8)`
7223                // for the saved caller PC guard input. The extra slot
7224                // is the LAST element so cranelift's existing
7225                // `0..window_size` accesses are unaffected.
7226                let reg_state_len = if is_downrec_entry {
7227                    window_size_us + 1
7228                } else {
7229                    window_size_us
7230                };
7231                reg_state.resize(reg_state_len, 0i64);
7232                let mut dispatch_ok = true;
7233                for i in 0..max_stack {
7234                    let v = self.stack[base_us + i];
7235                    let (tag, raw) = v.unpack();
7236                    entry_tags.push(tag);
7237                    // Entry tag guard. The trace's IR
7238                    // is specialised to the compile-time entry tags
7239                    // (via current_kinds propagation from
7240                    // from_entry_tag). A runtime tag mismatch means
7241                    // body ops would mis-interpret raw bits (e.g.
7242                    // treat a Str pointer as Int payload → garbage).
7243                    // Skip dispatch on mismatch so interp handles
7244                    // this entry shape; the trace stays cached for
7245                    // future entries that match.
7246                    if i < compile_entry_tags.len() && tag != compile_entry_tags[i] {
7247                        dispatch_ok = false;
7248                        break;
7249                    }
7250                    match tag {
7251                        // Int / Float / Table / Nil all marshal
7252                        // to raw payload cleanly; the trace's IR
7253                        // treats the 8-byte slot as an i64 (with
7254                        // f64 ops bitcasting around the boundary).
7255                        crate::runtime::value::raw::INT
7256                        | crate::runtime::value::raw::FLOAT
7257                        | crate::runtime::value::raw::TABLE
7258                        | crate::runtime::value::raw::CLOSURE
7259                        // Native iter slots (e.g.
7260                        // R[A] = ipairs_iter) are present in
7261                        // generic-for traces; the raw bits are a
7262                        // valid `*mut NativeClosure` and round-trip
7263                        // cleanly.
7264                        | crate::runtime::value::raw::NATIVE
7265                        // Str slots show up in
7266                        // string-concat traces; raw bits = `*mut
7267                        // LuaStr` (interned, GC-managed). Round-
7268                        // trips cleanly as a heap pointer.
7269                        | crate::runtime::value::raw::STR
7270                        | crate::runtime::value::raw::NIL => {
7271                            // 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).
7272                            reg_state[i] = unsafe { raw.zero as i64 };
7273                        }
7274                        _ => {
7275                            dispatch_ok = false;
7276                            break;
7277                        }
7278                    }
7279                }
7280
7281                if dispatch_ok {
7282                    debug_assert_eq!(head_pc_val, pc, "trace cache hit's head_pc != pc");
7283                    // A recording in progress cannot see what the trace runs
7284                    // natively: it would resume after the trace with ops
7285                    // missing, and could close as a loop that never ran (a
7286                    // side trace of two ops returning its own head, which
7287                    // the dispatcher then entered forever). Drop it.
7288                    if self.jit.active_trace.take().is_some() {
7289                        self.jit.counters.aborted += 1;
7290                        self.jit.counters.bump_close_cause("reached-compiled-trace");
7291                    }
7292                    self.jit.pending_err = None;
7293                    // Snapshot the pre-entry frame
7294                    // count. A cmp@d>0 side-exit calls the materialize
7295                    // helper which pushes inlined frames onto
7296                    // `vm.frames`; on deopt those frames must be popped
7297                    // before falling through to the interpreter, else
7298                    // the stack grows unboundedly per deopted dispatch.
7299                    let pre_frames = self.frames.len();
7300                    // Saved-PC slot population. The
7301                    // recorded `dr_return_pc` on the closing trace is
7302                    // the caller's resume PC captured at a depth>0
7303                    // Return push (recorder push site). The natural runtime analogue for self-
7304                    // stitch is the dispatching frame's PARENT frame's
7305                    // PC: the trace's head_pc sits inside a Lua frame,
7306                    // and the parent (caller) frame's `pc` is what
7307                    // luna would observe as `[base-8]` in the LJ
7308                    // `asm_retf` shape (`lj_asm_arm64.h:565`). When
7309                    // the parent isn't a Lua frame (top-level dispatch
7310                    // — first invocation through `call_value`), no
7311                    // saved PC exists; we write 0, which always
7312                    // mismatches the recorded `dr_return_pc != 0`
7313                    // invariant (debug-asserted in the luna-jit trace
7314                    // lowerer).
7315                    if is_downrec_entry {
7316                        let saved_pc: i64 = if pre_frames >= 2 {
7317                            match &self.frames[pre_frames - 2] {
7318                                CallFrame::Lua(parent) => parent.pc as i64,
7319                                CallFrame::Cont(_) => 0,
7320                            }
7321                        } else {
7322                            0
7323                        };
7324                        reg_state[window_size_us] = saved_pc;
7325                    }
7326                    // `LUNA_AOT_PROBE`
7327                    // diagnostic hook. The probe fires once per trace dispatch
7328                    // (regardless of JIT vs AOT origin — both go through this
7329                    // arm), letting the AOT smoke test verify mcode actually
7330                    // executed. Guarded behind `OnceLock` so the env read is
7331                    // a one-time cost per process; not gated on a particular
7332                    // counter so the smoke test gets a deterministic single-
7333                    // line `aot_trace_fired pc=N` per first dispatch.
7334                    if jit_probe_enabled() && self.jit.counters.dispatched == 0 {
7335                        eprintln!("luna-runtime-helpers: aot_trace_fired pc={head_pc_val}");
7336                    }
7337                    let continuation_pc = {
7338                        // chunk_compiler.enter
7339                        // (CraneliftBackend delegates to enter_jit;
7340                        // NullJitBackend returns an inert guard).
7341                        let vm_ptr: *mut Vm = self;
7342                        let _guard = self.jit.chunk_compiler.enter(vm_ptr, Some(cl));
7343                        // 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).
7344                        unsafe { entry_fn(reg_state.as_mut_ptr()) }
7345                    };
7346                    self.jit.counters.dispatched += 1;
7347
7348                    if self.jit.pending_err.is_some() {
7349                        self.jit.pending_err = None;
7350                        self.jit.counters.deopt += 1;
7351                        // Unwind any helper-pushed
7352                        // inlined frames before the interpreter resumes.
7353                        // Don't restore reg_state — the trace's partial
7354                        // writes are discarded; interp re-executes from
7355                        // the original `pc`.
7356                        while self.frames.len() > pre_frames {
7357                            frames_pop_sync(&mut self.frames, &mut self.frames_top);
7358                        }
7359                        if is_downrec_entry {
7360                            // pending_err observed
7361                            // mid-trace inside a downrec admit. Treat
7362                            // it as a guard miss: bump `downrec_deopt`
7363                            // and suppress the next downrec admit so
7364                            // interp can advance past `head_pc` and
7365                            // the same trace doesn't immediately re-
7366                            // fire on the next loop iteration.
7367                            self.jit.counters.downrec_deopt += 1;
7368                            self.jit.suppress_downrec_admit_once = true;
7369                        }
7370                    } else if is_downrec_entry && {
7371                        // Only enter the
7372                        // downrec classifier for returns whose shape
7373                        // matches the lowerer's `downrec_idx_opt` tail
7374                        // emit: either the stitch_blk DOWNREC sentinel
7375                        // (HIT) or the deopt_blk GLOBAL-sentinel-with-
7376                        // body==head_pc (MISS via guard fail). Any
7377                        // other return from a downrec trace (intermediate
7378                        // body cmp side-exit, GetField inference fail,
7379                        // etc.) carries a different sentinel/body shape
7380                        // and means the body exited BEFORE reaching the
7381                        // downrec close — classify those through the
7382                        // normal decode path (else branch below) so
7383                        // reg_state restores + pc advances correctly.
7384                        // Classifying them all as MISS would skip the
7385                        // normal restore, inflating `downrec_deopt` with
7386                        // non-downrec events and losing the trace's
7387                        // mid-flight writes.
7388                        let raw_ret = continuation_pc as u64;
7389                        let from_side_trace = (raw_ret >> 63) & 1 == 1;
7390                        let sentinel_code = if from_side_trace {
7391                            ((raw_ret >> 56) & 0x7F) as u32
7392                        } else {
7393                            0
7394                        };
7395                        let raw_body = raw_ret & 0x00FF_FFFF_FFFF_FFFFu64;
7396                        let global_deopt_code = crate::jit::trace_types::encode_side_sentinel(
7397                            crate::jit::trace_types::SIDE_SENT_KIND_GLOBAL,
7398                            0,
7399                        );
7400                        from_side_trace
7401                            && (crate::jit::trace_types::is_downrec_sentinel(sentinel_code)
7402                                || (sentinel_code == global_deopt_code
7403                                    && raw_body == head_pc_val as u64))
7404                    } {
7405                        // Downrec event classifier.
7406                        let raw_ret = continuation_pc as u64;
7407                        let sentinel_code = ((raw_ret >> 56) & 0x7F) as u32;
7408                        if crate::jit::trace_types::is_downrec_sentinel(sentinel_code) {
7409                            // Guard HIT — saved_pc matched one of the
7410                            // baked candidates and the trace's
7411                            // `stitch_blk` arm returned the DOWNREC
7412                            // sentinel. Cycle-safety checkpoint:
7413                            // decrement budget; on underflow,
7414                            // reclassify as deopt + reset budget.
7415                            // `STITCH_DEPTH_DEFAULT = 32` lets
7416                            // ~all natural HITs in a hot loop fire
7417                            // before reset pressure.
7418                            if self.jit.stitch_depth_remaining > 0 {
7419                                self.jit.stitch_depth_remaining -= 1;
7420                                self.jit.counters.downrec_dispatched += 1;
7421                            } else {
7422                                self.jit.counters.downrec_deopt += 1;
7423                                self.jit.stitch_depth_remaining =
7424                                    crate::vm::jit_state::JitState::STITCH_DEPTH_DEFAULT;
7425                            }
7426                        } else {
7427                            // Guard MISS via the lowerer's deopt_blk
7428                            // arm (GLOBAL sentinel + body == head_pc).
7429                            // The deopt_blk emit performs the
7430                            // store-back via `emit_store_back_and_return_pc`,
7431                            // so the live stack already reflects the
7432                            // body's writes; no extra restore needed
7433                            // from the dispatcher side.
7434                            self.jit.counters.downrec_deopt += 1;
7435                        }
7436                        self.jit.suppress_downrec_admit_once = true;
7437                        // Pop helper-pushed inlined frames (defensive —
7438                        // the downrec emit shape doesn't push frames in the
7439                        // tail, but a body side-exit before reaching
7440                        // the tail may have via the materialize helper).
7441                        while self.frames.len() > pre_frames {
7442                            frames_pop_sync(&mut self.frames, &mut self.frames_top);
7443                        }
7444                        self.jit.reg_state_buf = reg_state;
7445                        self.jit.entry_tags_buf = entry_tags;
7446                        continue;
7447                    } else {
7448                        // Restore each slot using the trace's
7449                        // exit-tag analysis (see ExitTag docs).
7450                        // Decode the IR's
7451                        // side-exit shape. Upper 32 bits = (site_idx
7452                        // + 1) for inline cmp side-exits, 0 for
7453                        // legacy clean-tail / non-inline exits.
7454                        // The decode lives in
7455                        // `crate::jit::trace::decode_exit_shape` so
7456                        // side-trace returns can reuse it with the SIDE
7457                        // TRACE's shape inputs when the sentinel bit
7458                        // is set on `raw_ret`.
7459                        let raw_ret = continuation_pc as u64;
7460                        // Side-trace return decode.
7461                        // Bit 63 of `raw_ret` is the side-trace
7462                        // marker the parent's IR OR'd in when it
7463                        // tail-called into a wired child trace.
7464                        // Bits 56..=62 carry the sentinel code (the
7465                        // cache key into the parent's
7466                        // `side_trace_cache`); bits 0..=55 are the
7467                        // child's own return value (encoded site or
7468                        // plain cont_pc) which we MUST decode using
7469                        // the CHILD's per_exit_inline / per_exit_tags
7470                        // / exit_tags / exit_hit_counts — not the
7471                        // parent's. The dispatcher snapshot read
7472                        // above holds the parent's shapes; when bit
7473                        // 63 is set we re-fetch the child's via the
7474                        // sentinel-keyed cache.
7475                        let from_side_trace = (raw_ret >> 63) & 1 == 1;
7476                        let (
7477                            decode_inline,
7478                            decode_tags,
7479                            decode_exit_tags,
7480                            decode_hit_counts,
7481                            decode_body,
7482                            child_ran,
7483                        ) = if from_side_trace {
7484                            let sentinel_code = ((raw_ret >> 56) & 0x7F) as u32;
7485                            let body = raw_ret & 0x00FF_FFFF_FFFF_FFFFu64;
7486                            let traces = cl.proto.traces.borrow();
7487                            let child_idx = traces
7488                                .iter()
7489                                .find(|t| t.head_pc == head_pc_val)
7490                                .and_then(|pct| {
7491                                    pct.side_trace_cache.borrow().get(&sentinel_code).copied()
7492                                });
7493                            if let Some(idx) = child_idx
7494                                && let Some(child) = traces.get(idx as usize)
7495                            {
7496                                if crate::jit::trace::v2c_probe_enabled() {
7497                                    eprintln!(
7498                                        "[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={}",
7499                                        sentinel_code,
7500                                        body,
7501                                        idx,
7502                                        child.n_ops,
7503                                        child.head_pc,
7504                                        child.window_size,
7505                                        pc,
7506                                        window_size,
7507                                        child.dispatchable,
7508                                        child.is_inline_abort_close,
7509                                    );
7510                                }
7511                                (
7512                                    child.per_exit_inline.clone(),
7513                                    child.per_exit_tags.clone(),
7514                                    child.exit_tags.clone(),
7515                                    child.exit_hit_counts.clone(),
7516                                    body,
7517                                    true,
7518                                )
7519                            } else {
7520                                if crate::jit::trace::v2c_probe_enabled() {
7521                                    eprintln!(
7522                                        "[v2c-A3-decode] sentinel={:#04x} body={:#018x} child MISS (fallback parent shapes)",
7523                                        sentinel_code, body,
7524                                    );
7525                                }
7526                                // Cache miss — fall back to parent
7527                                // shapes with the body bits. Best-
7528                                // effort; the trace_side_trace_
7529                                // shape_mismatch_count records this
7530                                // path indirectly (close-handler
7531                                // skips wiring on mismatch so we
7532                                // shouldn't reach here when shape
7533                                // gate held).
7534                                (
7535                                    per_exit_inline.clone(),
7536                                    per_exit_tags.clone(),
7537                                    exit_tags.clone(),
7538                                    exit_hit_counts.clone(),
7539                                    body,
7540                                    true,
7541                                )
7542                            }
7543                        } else {
7544                            // Dispatcher-level side-trace invocation,
7545                            // rather than an IR gate (`load + icmp +
7546                            // brif`) at every emit_store_back callsite,
7547                            // which measured as a net slowdown. The
7548                            // tentative decode + cell load always runs:
7549                            // short-circuiting it on a
7550                            // `parent_has_side` hint measured slower on
7551                            // btrees_d8 and no faster on fib_10.
7552                            {
7553                                let tentative = crate::jit::trace::decode_exit_shape(
7554                                    raw_ret,
7555                                    per_exit_inline,
7556                                    per_exit_tags,
7557                                    exit_tags,
7558                                );
7559                                let tentative_exit_idx = tentative.exit_hit_idx;
7560                                let child_invoke = {
7561                                    let traces = cl.proto.traces.borrow();
7562                                    traces.iter().find(|t| t.head_pc == head_pc_val).and_then(
7563                                        |pct| {
7564                                            let cell =
7565                                                pct.exit_side_trace_ptrs.get(tentative_exit_idx)?;
7566                                            let fn_ptr = cell.get();
7567                                            if fn_ptr.is_null() {
7568                                                return None;
7569                                            }
7570                                            traces
7571                                                .iter()
7572                                                .find(|t| {
7573                                                    t.entry as *const () as *const u8 == fn_ptr
7574                                                })
7575                                                .map(|child| {
7576                                                    (
7577                                                        child.entry,
7578                                                        child.per_exit_inline.clone(),
7579                                                        child.per_exit_tags.clone(),
7580                                                        child.exit_tags.clone(),
7581                                                        child.exit_hit_counts.clone(),
7582                                                    )
7583                                                })
7584                                        },
7585                                    )
7586                                };
7587                                if let Some((cent, cpi, cpt, cet, chc)) = child_invoke {
7588                                    let child_raw_ret = {
7589                                        // chunk_compiler.enter
7590                                        // (side-trace entry).
7591                                        let vm_ptr: *mut Vm = self;
7592                                        let _guard =
7593                                            self.jit.chunk_compiler.enter(vm_ptr, Some(cl));
7594                                        // 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).
7595                                        unsafe { cent(reg_state.as_mut_ptr()) }
7596                                    };
7597                                    (cpi, cpt, cet, chc, child_raw_ret as u64, true)
7598                                } else {
7599                                    (
7600                                        per_exit_inline.clone(),
7601                                        per_exit_tags.clone(),
7602                                        exit_tags.clone(),
7603                                        exit_hit_counts.clone(),
7604                                        raw_ret,
7605                                        false,
7606                                    )
7607                                }
7608                            }
7609                        };
7610                        let decoded = crate::jit::trace::decode_exit_shape(
7611                            decode_body,
7612                            &decode_inline,
7613                            &decode_tags,
7614                            &decode_exit_tags,
7615                        );
7616                        let site_id = decoded.site_id;
7617                        let cont_pc = decoded.cont_pc;
7618                        let exit_hit_idx = decoded.exit_hit_idx;
7619                        let exit_tags_for_pc = decoded.exit_tags_for_pc;
7620                        // When a side trace ran (tail-called by the
7621                        // parent's code or invoked here), force
7622                        // using_global_exit_tags=false so the restore
7623                        // loop takes the per-tag slow path:
7624                        // `global_tag_res_kind` classifies the parent's
7625                        // exit tags, not the child's.
7626                        let using_global_exit_tags = if child_ran {
7627                            false
7628                        } else {
7629                            decoded.using_global_exit_tags
7630                        };
7631                        // Increment the counter (saturate
7632                        // at u32::MAX to avoid wrap on long runs).
7633                        // Track whether this increment is
7634                        // the one that crossed `HOTEXIT_THRESHOLD`
7635                        // (transition: previous v < threshold, new v
7636                        // == threshold). The side-trace start is
7637                        // deferred to just before `continue;` so
7638                        // vm.stack and frame.pc are fully restored
7639                        // (the snapshot reads post-restore values).
7640                        let mut side_trace_should_start = false;
7641                        // For side-trace returns the
7642                        // counter to bump is the CHILD's (decoded
7643                        // shape lookup) — `exit_hit_idx` is into the
7644                        // decoded layout, so use the matching
7645                        // `decode_hit_counts`. For parent decode
7646                        // they're aliased (clone of the parent's
7647                        // own Rc).
7648                        if let Some(c) = decode_hit_counts.get(exit_hit_idx) {
7649                            let v = c.get();
7650                            if v < u32::MAX {
7651                                c.set(v + 1);
7652                            }
7653                            // After a side trace ran, `exit_hit_idx` is an
7654                            // exit of that child, but a side trace is
7655                            // recorded and wired as one of `head_pc_val`'s
7656                            // exits: it would replace the side trace on the
7657                            // parent's exit of that number, which resumes
7658                            // elsewhere.
7659                            if v + 1 == crate::jit::trace::HOTEXIT_THRESHOLD
7660                                && !child_ran
7661                                && self.jit.active_trace.is_none()
7662                                && self.jit.trace_enabled
7663                            {
7664                                side_trace_should_start = true;
7665                            }
7666                        }
7667                        // At an inline cmp@d>0
7668                        // side-exit, the helper has pushed N frames on
7669                        // top of the trace head's frame and
7670                        // `exit_tags_for_pc.len()` covers the full
7671                        // window (caller + each inlined frame's
7672                        // window). Slots beyond `max_stack` belong to
7673                        // an inlined frame: their `Untouched` entries
7674                        // default to Nil (no entry-tag fallback —
7675                        // marshal-in only captured caller slots) and
7676                        // we write to interp stack at `base + i` which
7677                        // mirrors `op_offsets`-derived layout.
7678                        let slot_count = exit_tags_for_pc.len();
7679                        // The helper only extends
7680                        // vm.stack up to the deepest pushed frame's
7681                        // window, but the exit_tags snapshot covers
7682                        // the trace's full `window_size` (which
7683                        // includes depth-N+1 scratch slots that the
7684                        // trace's IR may have written without a
7685                        // matching pushed frame). Extend with Nil so
7686                        // the write at the tail doesn't panic; these
7687                        // slots get overwritten by the writeback loop
7688                        // and won't leak meaningful data past the
7689                        // pushed frames' R[0..max_stack) windows.
7690                        if self.stack.len() < base_us + slot_count {
7691                            self.stack
7692                                .resize(base_us + slot_count, crate::runtime::Value::Nil);
7693                        }
7694                        // Fast-path restore loop. When
7695                        // we landed on the global `exit_tags`,
7696                        // dispatch on the compile-time
7697                        // classification: skip the loop entirely
7698                        // for `AllUntouched`, do a tag-free
7699                        // `Value::Int(...)` write per slot for
7700                        // `AllInt`, otherwise fall through to the
7701                        // general match-arm loop. site_id > 0
7702                        // (inline frame mat) and per_exit_tags
7703                        // hits always take the general path —
7704                        // their per-side-exit shapes aren't
7705                        // pre-classified yet.
7706                        // A generic-for exit whose TForCall wrote the loop
7707                        // variables to the stack with tags the trace did not
7708                        // compile for: leave those slots as they are.
7709                        let keep_tfor =
7710                            if decode_body & crate::jit::trace_types::EXIT_KEEP_TFOR_VARS != 0 {
7711                                let call = cl.proto.code[cont_pc as usize - 1];
7712                                debug_assert!(matches!(call.op(), crate::vm::isa::Op::TForCall));
7713                                let first = call.a() as usize + 4;
7714                                first..first + call.c() as usize
7715                            } else {
7716                                0..0
7717                            };
7718                        let fast_path_taken = if using_global_exit_tags && keep_tfor.is_empty() {
7719                            match global_tag_res_kind {
7720                                crate::jit::trace::TagResKind::AllUntouched => {
7721                                    // No-op: vm.stack already
7722                                    // matches the trace's post-
7723                                    // entry state for these
7724                                    // slots (entry values not
7725                                    // overridden, or already
7726                                    // spilled by helpers).
7727                                    true
7728                                }
7729                                crate::jit::trace::TagResKind::AllInt => {
7730                                    for i in 0..slot_count {
7731                                        self.stack[base_us + i] =
7732                                            crate::runtime::Value::Int(reg_state[i]);
7733                                    }
7734                                    true
7735                                }
7736                                crate::jit::trace::TagResKind::Mixed => false,
7737                            }
7738                        } else {
7739                            false
7740                        };
7741                        if !fast_path_taken {
7742                            for i in 0..slot_count {
7743                                if keep_tfor.contains(&i) {
7744                                    continue;
7745                                }
7746                                let tag = match exit_tags_for_pc[i] {
7747                                    crate::jit::trace::ExitTag::Untouched => {
7748                                        if i < max_stack {
7749                                            entry_tags[i]
7750                                        } else {
7751                                            crate::runtime::value::raw::NIL
7752                                        }
7753                                    }
7754                                    crate::jit::trace::ExitTag::Int => {
7755                                        crate::runtime::value::raw::INT
7756                                    }
7757                                    crate::jit::trace::ExitTag::Float => {
7758                                        crate::runtime::value::raw::FLOAT
7759                                    }
7760                                    crate::jit::trace::ExitTag::Table => {
7761                                        crate::runtime::value::raw::TABLE
7762                                    }
7763                                    crate::jit::trace::ExitTag::Closure => {
7764                                        crate::runtime::value::raw::CLOSURE
7765                                    }
7766                                    // Trace actively wrote Nil
7767                                    // to this slot (e.g. via Op::LoadNil).
7768                                    // Restore as Nil regardless of the entry
7769                                    // tag, since the i64 payload is 0 and
7770                                    // packing as the entry tag (e.g. INT)
7771                                    // would mis-type the slot.
7772                                    crate::jit::trace::ExitTag::Nil => {
7773                                        crate::runtime::value::raw::NIL
7774                                    }
7775                                    // Trace wrote a Str ptr
7776                                    // to this slot (LoadK Str / Move from
7777                                    // Str / Concat result). Restore as
7778                                    // Value::Str with raw bits round-
7779                                    // tripped.
7780                                    crate::jit::trace::ExitTag::Str => {
7781                                        crate::runtime::value::raw::STR
7782                                    }
7783                                };
7784                                // SAFETY: tag is from a verified slot
7785                                // (entry validated above) or pinned by
7786                                // the exit-tag analysis to INT/TABLE.
7787                                // The raw payload sits in reg_state[i].
7788                                // Stack was extended by the materialize
7789                                // helper for inline frames.
7790                                // 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).
7791                                self.stack[base_us + i] = unsafe {
7792                                    Value::pack(
7793                                        tag,
7794                                        crate::runtime::value::RawVal {
7795                                            zero: reg_state[i] as u64,
7796                                        },
7797                                    )
7798                                };
7799                            }
7800                        }
7801                        // For non-inline exits the
7802                        // helper was never called (no metas chain for
7803                        // this cont_pc), so `frames.last()` is the
7804                        // trace head's frame and we set its pc to
7805                        // cont_pc as before. For inline exits the
7806                        // helper baked the side-exit PC into the
7807                        // innermost frame's `pc` at push time
7808                        // (chain.last().pc was overridden at emit),
7809                        // so this assignment to `frames.last_mut().pc
7810                        // = cont_pc` is a redundant-but-correct
7811                        // confirmation.
7812                        let _ = &per_exit_inline; // hold the Rc alive across dispatch
7813                        // For inline side-exits the
7814                        // helper has pushed N frames on top. The trace
7815                        // head frame is at `pre_frames - 1`; set its
7816                        // pc to `head_resume_pc` so when the chain
7817                        // eventually pops back to it, interp resumes
7818                        // PAST the trace's depth-0 Op::Call instead of
7819                        // restarting from `head_pc` and re-triggering
7820                        // dispatch (infinite loop). The innermost
7821                        // (helper-pushed) frame already has its pc
7822                        // baked in at compile time, but we still
7823                        // assign `cont_pc` below for parity with the
7824                        // non-inline path (no-op).
7825                        if site_id > 0 {
7826                            let idx = (site_id - 1) as usize;
7827                            let head_resume_pc = decode_inline[idx].head_resume_pc;
7828                            if pre_frames > 0
7829                                && let CallFrame::Lua(f) = &mut self.frames[pre_frames - 1]
7830                            {
7831                                f.pc = head_resume_pc;
7832                            }
7833                        }
7834                        let frames_len_now = self.frames.len();
7835                        // 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).
7836                        match unsafe { self.frames.last_mut().unwrap_unchecked() } {
7837                            CallFrame::Lua(fmut) => {
7838                                if crate::jit::trace::v2c_probe_enabled() {
7839                                    eprintln!(
7840                                        "[v2c-set-pc] from_side={} sentinel_or_raw={:#018x} prev_pc={} new_cont_pc={} site_id={} frames.len={} pre_frames={} max_stack={}",
7841                                        from_side_trace,
7842                                        raw_ret,
7843                                        fmut.pc,
7844                                        cont_pc,
7845                                        site_id,
7846                                        frames_len_now,
7847                                        pre_frames,
7848                                        max_stack,
7849                                    );
7850                                }
7851                                fmut.pc = cont_pc;
7852                            }
7853                            _ => unreachable!("Cont frame at trace dispatch"),
7854                        }
7855                        // Deferred side-trace start. The
7856                        // increment block above flagged this exit's
7857                        // hit count crossing HOTEXIT_THRESHOLD; now
7858                        // that vm.stack is restored and frame.pc is
7859                        // settled, snapshot entry_tags from the
7860                        // resume frame's window and create the
7861                        // recorder. The recorder's first push fires
7862                        // on the next interp iteration at cont_pc.
7863                        //
7864                        // `head_proto` for the side trace = cl.proto
7865                        // (trace JIT only inlines self-recursive
7866                        // calls today, so cont_pc always lands in
7867                        // the same proto as the parent). Frame base
7868                        // is the resume frame (top of `self.frames`
7869                        // — inline-pushed frames moved this).
7870                        if side_trace_should_start {
7871                            let (resume_base, resume_proto) = match self.frames.last() {
7872                                Some(CallFrame::Lua(f)) => (f.base as usize, f.closure.proto),
7873                                _ => (base_us, cl.proto),
7874                            };
7875                            let resume_max_stack = resume_proto.max_stack as usize;
7876                            let mut side_entry_tags: Vec<u8> = Vec::with_capacity(resume_max_stack);
7877                            // Extend stack if cont_pc's frame window
7878                            // overhangs the current stack len (rare,
7879                            // but inline-pushed frame stack writes
7880                            // only covered the trace's writeback).
7881                            if self.stack.len() < resume_base + resume_max_stack {
7882                                self.stack.resize(
7883                                    resume_base + resume_max_stack,
7884                                    crate::runtime::Value::Nil,
7885                                );
7886                            }
7887                            for i in 0..resume_max_stack {
7888                                let (tag, _) = self.stack[resume_base + i].unpack();
7889                                side_entry_tags.push(tag);
7890                            }
7891                            self.jit.active_trace =
7892                                Some(Box::new(crate::jit::trace::TraceRecord::start_side_trace(
7893                                    resume_proto,
7894                                    cont_pc,
7895                                    side_entry_tags,
7896                                    cl.proto,
7897                                    head_pc_val,
7898                                    exit_hit_idx,
7899                                )));
7900                            self.jit.recording_frame_base = self.frames.len() - 1;
7901                            self.jit.counters.side_trace_started += 1;
7902                        }
7903                        // Put the dispatch buffers back
7904                        // before the `continue;` so the next
7905                        // dispatch picks up the same allocation.
7906                        self.jit.reg_state_buf = reg_state;
7907                        self.jit.entry_tags_buf = entry_tags;
7908                        continue;
7909                    }
7910                }
7911                // !dispatch_ok / deopt path / non-cont
7912                // exit also restore the buffers before falling
7913                // through to the interp.
7914                self.jit.reg_state_buf = reg_state;
7915                self.jit.entry_tags_buf = entry_tags;
7916            }
7917
7918            // PUC `vmfetch` increments savedpc BEFORE firing traceexec, so
7919            // hook code that consults `currentpc = savedpc - 1` lands on the
7920            // instruction now executing. luna mirrors that by advancing
7921            // `f.pc` to `pc + 1` before the hook block — local_at /
7922            // getinfo / line attribution all read f.pc, and the existing
7923            // `pc - 1` convention in those helpers then yields the current
7924            // instruction's pc (db.lua :696: local `A` visible at the
7925            // chunk's return line once OP_CLOSURE has advanced pc).
7926            //
7927            // Inline `top_frame_mut` for the hot path: top is guaranteed Lua
7928            // (cont frames drained above) so the and_then/Option layers are
7929            // dead weight.
7930            // 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).
7931            match unsafe { self.frames.last_mut().unwrap_unchecked() } {
7932                CallFrame::Lua(fmut) => fmut.pc = pc + 1,
7933                _ => unreachable!("Cont frame at pc bump"),
7934            }
7935
7936            // count + line hooks (PUC traceexec): before executing the
7937            // instruction. Skipped while the hook itself runs.
7938            // (Parens here are load-bearing — without them `&&` binds tighter
7939            // than `||` and the `!in_hook` guard only gates the rust-hook arm,
7940            // letting a Lua line hook recurse into itself → stack overflow
7941            // on db.lua line-hook assertions. Matches the `hook_call_with` /
7942            // `hook_return` predicate shape at lines 2245 / 2279 / 2294 / 4023.)
7943            if !self.in_hook && (self.hook.func.is_some() || self.hook.rust_func.is_some()) {
7944                let lines = &cl.proto.lines;
7945                let cur_line = if lines.is_empty() {
7946                    None
7947                } else {
7948                    Some(lines[(pc as usize).min(lines.len() - 1)] as i64)
7949                };
7950                // count hook: fire every `count_base` instructions
7951                if self.hook.count {
7952                    self.hook.count_left -= 1;
7953                    if self.hook.count_left <= 0 {
7954                        self.hook.count_left = self.hook.count_base;
7955                        // hooked function is the running Lua frame: its frame
7956                        // is on the stack, so no synthetic C level is needed.
7957                        self.run_hook(b"count", cur_line, false)?;
7958                    }
7959                }
7960                // line hook: fire on a fresh frame, a backward jump (loop), or a
7961                // change of source line.
7962                if self.hook.line {
7963                    if lines.is_empty() {
7964                        // PUC: a stripped chunk has no line info, so
7965                        // `getfuncline` returns -1. The line hook still fires
7966                        // on the first instruction of the new frame (where
7967                        // `npci <= oldpc` holds at oldpc=0), with the line
7968                        // pushed as `nil` instead of an integer (db.lua :1030
7969                        // "hook called without debug info for 1st instruction").
7970                        if oldpc == u32::MAX {
7971                            self.run_hook(b"line", None, false)?;
7972                            self.top_frame_mut().hook_oldpc = pc;
7973                        }
7974                    } else {
7975                        let newline = lines[(pc as usize).min(lines.len() - 1)];
7976                        // PUC `traceexec`: fire on frame entry (`oldpc == MAX`),
7977                        // on a backward jump (`pc < oldpc` — strict; an equal pc
7978                        // would re-fire the install-site after `oldpc = pc`),
7979                        // or when the source line changes.
7980                        let fire = oldpc == u32::MAX
7981                            || pc < oldpc
7982                            || newline != lines[(oldpc as usize).min(lines.len() - 1)];
7983                        if fire {
7984                            self.run_hook(b"line", Some(newline as i64), false)?;
7985                        }
7986                        self.top_frame_mut().hook_oldpc = pc;
7987                    }
7988                }
7989            }
7990
7991            match inst.op() {
7992                Op::Move => {
7993                    let v = self.r(base, inst.b());
7994                    self.set_r(base, inst.a(), v);
7995                }
7996                Op::LoadI => self.set_r(base, inst.a(), Value::Int(inst.sbx() as i64)),
7997                Op::LoadF => self.set_r(base, inst.a(), Value::Float(inst.sbx() as f64)),
7998                Op::LoadK => {
7999                    let v = cl.proto.consts[inst.bx() as usize];
8000                    self.set_r(base, inst.a(), v);
8001                }
8002                Op::LoadKx => {
8003                    let extra = cl.proto.code[self.pc_of_top() as usize];
8004                    self.bump_pc();
8005                    let v = cl.proto.consts[extra.ax() as usize];
8006                    self.set_r(base, inst.a(), v);
8007                }
8008                Op::LoadFalse => self.set_r(base, inst.a(), Value::Bool(false)),
8009                Op::LFalseSkip => {
8010                    self.set_r(base, inst.a(), Value::Bool(false));
8011                    self.bump_pc();
8012                }
8013                Op::LoadTrue => self.set_r(base, inst.a(), Value::Bool(true)),
8014                Op::LoadNil => {
8015                    let a = inst.a();
8016                    for i in 0..=inst.b() {
8017                        self.set_r(base, a + i, Value::Nil);
8018                    }
8019                }
8020                Op::GetUpval => {
8021                    let v = self.upval_get(cl, inst.b());
8022                    self.set_r(base, inst.a(), v);
8023                }
8024                Op::SetUpval => {
8025                    let v = self.r(base, inst.a());
8026                    self.upval_set(cl, inst.b(), v);
8027                }
8028                Op::GetTabUp => {
8029                    let t = self.upval_get(cl, inst.b());
8030                    let key = cl.proto.consts[inst.c() as usize];
8031                    self.op_index(t, key, base + inst.a())?;
8032                }
8033                Op::GetTable => {
8034                    let t = self.r(base, inst.b());
8035                    let key = self.r(base, inst.c());
8036                    self.op_index(t, key, base + inst.a())?;
8037                }
8038                Op::GetI => {
8039                    let t = self.r(base, inst.b());
8040                    self.op_index(t, Value::Int(inst.c() as i64), base + inst.a())?;
8041                }
8042                Op::GetField => {
8043                    let t = self.r(base, inst.b());
8044                    let key = cl.proto.consts[inst.c() as usize];
8045                    // Fast path: known-Str const key + no
8046                    // metatable on the table → skip `op_index` /
8047                    // `index_step`'s MAX_TAG_LOOP setup and the outer
8048                    // `Value` match. Falls through to the slow path
8049                    // when either invariant breaks (`__index`
8050                    // metamethods, non-Table receivers, non-Str keys).
8051                    if let Value::Table(tb) = t
8052                        && tb.metatable().is_none()
8053                        && let Value::Str(s) = key
8054                    {
8055                        let v = tb.get_str(s);
8056                        self.stack[(base + inst.a()) as usize] = v;
8057                    } else {
8058                        self.op_index(t, key, base + inst.a())?;
8059                    }
8060                }
8061                Op::SetTabUp => {
8062                    let t = self.upval_get(cl, inst.a());
8063                    let key = cl.proto.consts[inst.b() as usize];
8064                    let v = self.r(base, inst.c());
8065                    self.op_newindex(t, key, v)?;
8066                }
8067                Op::SetTable => {
8068                    let t = self.r(base, inst.a());
8069                    let key = self.r(base, inst.b());
8070                    let v = self.r(base, inst.c());
8071                    self.op_newindex(t, key, v)?;
8072                }
8073                Op::SetI => {
8074                    let t = self.r(base, inst.a());
8075                    let v = self.r(base, inst.c());
8076                    self.op_newindex(t, Value::Int(inst.b() as i64), v)?;
8077                }
8078                Op::SetField => {
8079                    let t = self.r(base, inst.a());
8080                    let key = cl.proto.consts[inst.b() as usize];
8081                    let v = self.r(base, inst.c());
8082                    self.op_newindex(t, key, v)?;
8083                }
8084                Op::NewTable => {
8085                    let t = self.heap.new_table();
8086                    self.set_r(base, inst.a(), Value::Table(t));
8087                    self.maybe_collect_garbage(base + inst.a() + 1);
8088                }
8089                Op::SetList => {
8090                    let a = inst.a();
8091                    let abs_a = base + a;
8092                    // only `debug.setlocal` or crafted bytecode can put a
8093                    // non-table here; PUC crashes, luna raises
8094                    let t = match self.r(base, a) {
8095                        Value::Table(t) => t,
8096                        v => return Err(self.type_err("index", v)),
8097                    };
8098                    let n = if inst.b() == 0 {
8099                        self.top - (abs_a + 1)
8100                    } else {
8101                        inst.b()
8102                    };
8103                    let offset = if inst.k() {
8104                        let extra = cl.proto.code[self.pc_of_top() as usize];
8105                        self.bump_pc();
8106                        extra.ax() as i64
8107                    } else {
8108                        inst.c() as i64
8109                    };
8110                    for i in 1..=n {
8111                        let v = self.r(base, a + i);
8112                        // 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).
8113                        if let Err(TableError::Overflow) =
8114                            unsafe { t.as_mut() }.set_int(&mut self.heap, offset + i as i64, v)
8115                        {
8116                            return Err(self.rt_err("table overflow"));
8117                        }
8118                    }
8119                    // one barrier_back covers every store this op did — PUC's
8120                    // `luaC_barrierback_` once-per-table optimisation
8121                    self.heap
8122                        .barrier_back(t.as_ptr() as *mut crate::runtime::heap::GcHeader);
8123                    // the element temps above the table are now consumed
8124                    self.maybe_collect_garbage(base + a + 1);
8125                }
8126                Op::SelfOp => {
8127                    let o = self.r(base, inst.b());
8128                    self.set_r(base, inst.a() + 1, o);
8129                    // PUC OP_SELF's C is a constant index when the k-flag is
8130                    // set; otherwise it points to a register that holds the
8131                    // (constant-loaded) key. luna's compiler falls back to the
8132                    // register form when the constant index exceeds OP_SELF's
8133                    // 8-bit C field (5.1 big.lua's `a:findfield(...)` against
8134                    // a table with 250+ string keys, where "findfield" lands
8135                    // past const #255). The exec must honour the same split.
8136                    let key = if inst.k() {
8137                        cl.proto.consts[inst.c() as usize]
8138                    } else {
8139                        self.r(base, inst.c())
8140                    };
8141                    self.op_index(o, key, base + inst.a())?;
8142                }
8143                Op::Add => self.arith_rr(inst, base, ArithOp::Add)?,
8144                Op::Sub => self.arith_rr(inst, base, ArithOp::Sub)?,
8145                Op::Mul => self.arith_rr(inst, base, ArithOp::Mul)?,
8146                Op::Mod => self.arith_rr(inst, base, ArithOp::Mod)?,
8147                Op::Pow => self.arith_rr(inst, base, ArithOp::Pow)?,
8148                Op::Div => self.arith_rr(inst, base, ArithOp::Div)?,
8149                Op::IDiv => self.arith_rr(inst, base, ArithOp::IDiv)?,
8150                Op::BAnd => self.arith_rr(inst, base, ArithOp::BAnd)?,
8151                Op::BOr => self.arith_rr(inst, base, ArithOp::BOr)?,
8152                Op::BXor => self.arith_rr(inst, base, ArithOp::BXor)?,
8153                Op::Shl => self.arith_rr(inst, base, ArithOp::Shl)?,
8154                Op::Shr => self.arith_rr(inst, base, ArithOp::Shr)?,
8155                Op::Unm => {
8156                    let v = self.r(base, inst.b());
8157                    match self.unary_operand(v) {
8158                        Some(Num::Int(i)) => {
8159                            self.set_r(base, inst.a(), Value::Int(i.wrapping_neg()))
8160                        }
8161                        Some(Num::Float(f)) => self.set_r(base, inst.a(), Value::Float(-f)),
8162                        None => {
8163                            let mm = self.get_mm(v, Mm::Unm);
8164                            if mm.is_nil() {
8165                                return Err(self.type_err("perform arithmetic on", v));
8166                            }
8167                            let dst = base + inst.a();
8168                            self.begin_meta_call(mm, &[v, v], MetaAction::Store { dst }, "unm")?;
8169                        }
8170                    }
8171                }
8172                Op::BNot => {
8173                    let v = self.r(base, inst.b());
8174                    match self.arith_operand()(v) {
8175                        Some(n) => {
8176                            let Some(i) = int_of(n) else {
8177                                return Err(self.no_int_rep_err());
8178                            };
8179                            self.set_r(base, inst.a(), Value::Int(!i));
8180                        }
8181                        None => {
8182                            let mm = self.get_mm(v, Mm::BNot);
8183                            if mm.is_nil() {
8184                                return Err(self.type_err("perform bitwise operation on", v));
8185                            }
8186                            let dst = base + inst.a();
8187                            self.begin_meta_call(mm, &[v, v], MetaAction::Store { dst }, "bnot")?;
8188                        }
8189                    }
8190                }
8191                Op::Not => {
8192                    let v = self.r(base, inst.b());
8193                    self.set_r(base, inst.a(), Value::Bool(!v.truthy()));
8194                }
8195                Op::Len => {
8196                    let v = self.r(base, inst.b());
8197                    match self.len_step(v)? {
8198                        MmOut::Done(r) => self.set_r(base, inst.a(), r),
8199                        MmOut::Mm { func, recv } => {
8200                            let dst = base + inst.a();
8201                            self.begin_meta_call(
8202                                func,
8203                                &[recv, recv],
8204                                MetaAction::Store { dst },
8205                                "len",
8206                            )?;
8207                        }
8208                        MmOut::CompareSynth { .. } => unreachable!("CompareSynth from len_step"),
8209                    }
8210                }
8211                Op::Concat => {
8212                    // right-associative fold over operands at base+a .. base+a+n,
8213                    // in place on the stack so a yielding __concat can suspend.
8214                    let a = inst.a();
8215                    let n = inst.b();
8216                    self.top = base + a + n;
8217                    self.concat_run(base + a)?;
8218                }
8219                Op::Close => {
8220                    // Yieldable: drive __close handlers through the
8221                    // interpreter loop so a coroutine.yield() inside a
8222                    // handler suspends cleanly (locals.lua block-end yield).
8223                    // `drive_close` parks the handler call at `self.top`, so
8224                    // raise `top` past this frame's full register window
8225                    // first — a goto out of a nested for-loop can fire
8226                    // OP_Close while `self.top` still sits at the inner
8227                    // body's working top, which would let `push_frame`'s
8228                    // wipe clobber the outer tbc slot before it could be
8229                    // closed (locals.lua:1219 nested-for goto regression).
8230                    self.top = self.top.max(base + cl.proto.max_stack as u32);
8231                    let _ =
8232                        self.begin_close(base + inst.a(), None, AfterClose::Block, entry_depth)?;
8233                }
8234                Op::Tbc => {
8235                    self.register_tbc(base + inst.a())?;
8236                }
8237                Op::Jmp => {
8238                    let off = inst.sj();
8239                    // Trace JIT back-edge counter. A negative
8240                    // jump offset is a loop back-edge (the only canonical
8241                    // backward jumps the compiler emits — `while`, `for`,
8242                    // `repeat`). Tick the per-Proto counter and, once it
8243                    // exceeds the threshold, start a trace recording. The
8244                    // whole block is gated on `trace_jit_enabled` so
8245                    // existing benches see one branch-not-taken and no
8246                    // counter writes.
8247                    if self.jit.trace_enabled && off < 0 {
8248                        let proto = cl.proto;
8249                        let c = proto.trace_hot_count.get();
8250                        if c < u32::MAX / 2 {
8251                            proto.trace_hot_count.set(c + 1);
8252                        }
8253                        // Relaxed back-edge trigger:
8254                        // `c >= THRESHOLD` (not `c == THRESHOLD`) so
8255                        // a missed crossing (active_trace busy with
8256                        // a call-trigger, or the recorder slot
8257                        // happened to be in use) doesn't permanently
8258                        // lock this back-edge target out. The
8259                        // `already_cached` short-circuit prevents
8260                        // duplicate recordings: once a trace is
8261                        // cached for this target, subsequent
8262                        // crossings skip the start. This pairs with
8263                        // the discard-on-partial-coverage close
8264                        // handling — when a short call-trigger is
8265                        // discarded, the back-edge can still find an
8266                        // open slot at the next iteration.
8267                        let target_pc = (pc as i32 + 1 + off as i32).max(0) as u32;
8268                        // Gave-up short-circuit. Skip
8269                        // the RefCell borrow + scan when the
8270                        // discard cap force-compiled a partial
8271                        // trace on this Proto.
8272                        let back_edge_already_cached = if proto.trace_gave_up.get() {
8273                            true
8274                        } else {
8275                            proto.traces.borrow().iter().any(|t| t.head_pc == target_pc)
8276                                || trace_head_abandoned(proto, target_pc)
8277                        };
8278                        if c >= self.jit.trace_hot_threshold
8279                            && self.jit.active_trace.is_none()
8280                            && !back_edge_already_cached
8281                        {
8282                            // Back-edge target = pc after `add_pc(off)`,
8283                            // i.e. current `pc + 1 + off` (the dispatch
8284                            // loop has already advanced f.pc to pc+1).
8285                            let target = (pc as i32 + 1 + off as i32).max(0) as u32;
8286                            // Snapshot per-slot Value tag at trace
8287                            // entry so the lowerer's kind tracker
8288                            // knows which arith path to lower
8289                            // (iadd vs fadd, etc.).
8290                            let max_stack = cl.proto.max_stack as usize;
8291                            let base_us = base as usize;
8292                            let mut entry_tags = Vec::with_capacity(max_stack);
8293                            for i in 0..max_stack {
8294                                let (tag, _) = self.stack[base_us + i].unpack();
8295                                entry_tags.push(tag);
8296                            }
8297                            self.jit.active_trace =
8298                                Some(Box::new(crate::jit::trace::TraceRecord::start(
8299                                    cl.proto, target, entry_tags, false,
8300                                )));
8301                            // Record the frame the trace
8302                            // started in. `self.frames.len() - 1`
8303                            // since we're inside the currently-running
8304                            // Lua frame's dispatch.
8305                            self.jit.recording_frame_base = self.frames.len() - 1;
8306                        }
8307                    }
8308                    self.add_pc(off);
8309                }
8310                Op::Eq => {
8311                    let l = self.r(base, inst.a());
8312                    let r = self.r(base, inst.b());
8313                    if let (Value::Int(a), Value::Int(b)) = (l, r) {
8314                        if (a == b) != inst.k() {
8315                            self.bump_pc();
8316                        }
8317                    } else {
8318                        let step = self.eq_step(l, r);
8319                        self.op_compare(step, l, r, inst.k(), "eq")?;
8320                    }
8321                }
8322                Op::EqK => {
8323                    let l = self.r(base, inst.a());
8324                    let r = cl.proto.consts[inst.b() as usize];
8325                    if let (Value::Int(a), Value::Int(b)) = (l, r) {
8326                        if (a == b) != inst.k() {
8327                            self.bump_pc();
8328                        }
8329                    } else {
8330                        let step = self.eq_step(l, r);
8331                        self.op_compare(step, l, r, inst.k(), "eq")?;
8332                    }
8333                }
8334                Op::Lt => {
8335                    let l = self.r(base, inst.a());
8336                    let r = self.r(base, inst.b());
8337                    // hot path: Int < Int — drops the MmOut + op_compare match
8338                    if let (Value::Int(a), Value::Int(b)) = (l, r) {
8339                        if (a < b) != inst.k() {
8340                            self.bump_pc();
8341                        }
8342                    } else {
8343                        let step = self.less_step(l, r, false)?;
8344                        self.op_compare(step, l, r, inst.k(), "lt")?;
8345                    }
8346                }
8347                Op::Le => {
8348                    let l = self.r(base, inst.a());
8349                    let r = self.r(base, inst.b());
8350                    if let (Value::Int(a), Value::Int(b)) = (l, r) {
8351                        if (a <= b) != inst.k() {
8352                            self.bump_pc();
8353                        }
8354                    } else {
8355                        let step = self.less_step(l, r, true)?;
8356                        self.op_compare(step, l, r, inst.k(), "le")?;
8357                    }
8358                }
8359                Op::Test => {
8360                    let cond = self.r(base, inst.a()).truthy();
8361                    self.cond_skip(cond, inst.k());
8362                }
8363                Op::TestSet => {
8364                    let v = self.r(base, inst.b());
8365                    if v.truthy() == inst.k() {
8366                        self.set_r(base, inst.a(), v);
8367                    } else {
8368                        self.bump_pc();
8369                    }
8370                }
8371                Op::Call => {
8372                    let abs = base + inst.a();
8373                    let nargs = if inst.b() == 0 {
8374                        None
8375                    } else {
8376                        Some(inst.b() - 1)
8377                    };
8378                    let wanted = inst.c() as i32 - 1;
8379                    self.begin_call(abs, nargs, wanted, false)?;
8380                }
8381                Op::TailCall => {
8382                    let fr = *self.top_frame();
8383                    let abs = base + inst.a();
8384                    let mut nargs = if inst.b() == 0 {
8385                        self.top - (abs + 1)
8386                    } else {
8387                        inst.b() - 1
8388                    };
8389                    // A tail call pops this frame before begin_call, so a
8390                    // non-callable target would lose its name/position. Report
8391                    // it now (PUC reads funcname from the still-current ci),
8392                    // while the frame is intact, for "(field 'x')"-style info.
8393                    let mut func = self.stack[abs as usize];
8394                    if !matches!(func, Value::Closure(_) | Value::Native(_))
8395                        && self.get_mm(func, Mm::Call).is_nil()
8396                    {
8397                        return Err(self.call_err(func));
8398                    }
8399                    // PUC `luaD_pretailcall` resolves a chain of `__call`
8400                    // metamethods *in place* before deciding whether to
8401                    // collapse this frame. Without that, each __call hop
8402                    // would push a fresh Lua frame and a 10000-deep
8403                    // tail-recursion through a 100-deep __call chain
8404                    // (5.4 calls.lua :172) blows up. Mirror the PUC loop:
8405                    // shift args right, install the handler at `abs`, retry.
8406                    // Chain depth limit matches the call-site `begin_call`
8407                    // version cap (5.5 calls.lua :223 — 15 max, then "too
8408                    // long"; 16th wrap fails the call). An infinite
8409                    // self-referential `__call` would otherwise spin.
8410                    let chain_cap = if self.version >= LuaVersion::Lua55 {
8411                        15
8412                    } else {
8413                        MAX_CCMT
8414                    };
8415                    let mut chain = 0u32;
8416                    while !matches!(func, Value::Closure(_) | Value::Native(_)) {
8417                        let mm = self.get_mm(func, Mm::Call);
8418                        if mm.is_nil() || self.call_mm_unusable(mm) {
8419                            return Err(self.call_err(func));
8420                        }
8421                        chain += 1;
8422                        if chain > chain_cap {
8423                            return Err(self.rt_err("'__call' chain too long"));
8424                        }
8425                        let end = (abs + 1 + nargs) as usize;
8426                        if self.stack.len() < end + 1 {
8427                            self.stack.resize(end + 1, Value::Nil);
8428                        }
8429                        for i in (0..=nargs).rev() {
8430                            self.stack[(abs + 1 + i) as usize] = self.stack[(abs + i) as usize];
8431                        }
8432                        self.stack[abs as usize] = mm;
8433                        nargs += 1;
8434                        self.top = abs + 1 + nargs;
8435                        func = mm;
8436                    }
8437                    // PUC's tail-call collapse is Lua→Lua only. A tail call to
8438                    // a C function runs the C function under the *current* Lua
8439                    // activation (no frame fold — a C frame has nothing to
8440                    // collapse into); after the C function returns, the
8441                    // calling Lua function returns those results normally.
8442                    // Mirror that: keep our Lua frame on the stack, call the
8443                    // target through `begin_call(abs, …)` as a regular call,
8444                    // and let the fallback `Op::Return` that the compiler
8445                    // emits right after `Op::TailCall` forward the results.
8446                    // 5.1 closure.lua :177's `return getfenv()` from inside
8447                    // foo needs level 1 to resolve to foo, not to the
8448                    // thread's globals fallback that happens when no Lua
8449                    // frame is on the stack.
8450                    let lua_target = matches!(func, Value::Closure(_));
8451                    if lua_target {
8452                        self.close_slots(fr.base, None)?;
8453                        for i in 0..=nargs {
8454                            self.stack[(fr.func_slot + i) as usize] =
8455                                self.stack[(abs + i) as usize];
8456                        }
8457                        // Clear the slot range that's now
8458                        // stranded by the tail-call collapse. The args
8459                        // were copied to `[fr.func_slot..fr.func_slot+
8460                        // nargs+1)`; the source slots `[abs..abs+
8461                        // nargs+1)` still hold the same `Value::Closure
8462                        // / Value::Str / ...` entries, but they're past
8463                        // the new call's window. Without this clear, a
8464                        // later GC with wider gc_top would mark stale
8465                        // pointers there (same hazard the
8466                        // finish_results slot-clear closes for the
8467                        // Op::Return path).
8468                        let new_top_lower_bound = fr.func_slot + nargs + 1;
8469                        let prev_top = (self.top as usize).min(self.stack.len());
8470                        if (new_top_lower_bound as usize) < prev_top {
8471                            for slot in &mut self.stack[new_top_lower_bound as usize..prev_top] {
8472                                *slot = Value::Nil;
8473                            }
8474                        }
8475                        // PUC `CIST_TAIL`: the new Lua activation inherits
8476                        // the popped frame's tailcalls count plus one for
8477                        // this collapse. 5.1 db.lua :372 hammers 30000
8478                        // recursive tail calls and expects to see the
8479                        // synthetic tail level for every one of them.
8480                        self.pending_tailcalls = fr.tailcalls.saturating_add(1);
8481                        self.pending_ccmt = self.frame_ccmt[self.frames.len() - 1];
8482                        frames_pop_sync(&mut self.frames, &mut self.frames_top);
8483                        if !self.begin_call(fr.func_slot, Some(nargs), fr.nresults, false)?
8484                            && self.frames.len() < entry_depth
8485                        {
8486                            // a native completed what was this function's result
8487                            return Ok(self.take_results(fr.func_slot));
8488                        }
8489                    } else {
8490                        // Native (or __call-bearing) target: regular call. The
8491                        // results land at `abs..self.top` and the next op (the
8492                        // fallback `Op::Return`) forwards them. `wanted = -1`
8493                        // because the caller will multret them through Return.
8494                        // PUC's precallC gives the C call this tail call's own
8495                        // `__call` count (5.5 extraargs); the chain was already
8496                        // resolved above, so hand it over.
8497                        self.pending_ccmt = chain as u8;
8498                        self.begin_call(abs, Some(nargs), -1, false)?;
8499                    }
8500                }
8501                Op::Return | Op::Return0 | Op::Return1 => {
8502                    let (abs_a, nret) = match inst.op() {
8503                        Op::Return0 => (base, 0),
8504                        Op::Return1 => (base + inst.a(), 1),
8505                        _ => {
8506                            let abs_a = base + inst.a();
8507                            let nret = if inst.b() == 0 {
8508                                self.top - abs_a
8509                            } else {
8510                                inst.b() - 1
8511                            };
8512                            (abs_a, nret)
8513                        }
8514                    };
8515                    // close before moving results: __close handlers run above
8516                    // the stack top, so the result region [abs_a..abs_a+nret)
8517                    // stays intact across any yields the close performs.
8518                    // Fixed-count returns may leave `self.top` below the last
8519                    // result slot (the compiler does not always re-bump it);
8520                    // raise it past the result region so `drive_close` parks
8521                    // the handler call *above* — landing at `self.top` would
8522                    // otherwise clobber a result with the handler closure.
8523                    self.top = self.top.max(abs_a + nret);
8524                    if let Some(vals) = self.begin_close(
8525                        base,
8526                        None,
8527                        AfterClose::Return {
8528                            abs_a,
8529                            nret,
8530                            from_native: false,
8531                        },
8532                        entry_depth,
8533                    )? {
8534                        return Ok(vals);
8535                    }
8536                }
8537                Op::ForPrep => self.for_prep(inst, base)?,
8538                Op::ForLoop => {
8539                    // Trace JIT back-edge counter on the
8540                    // numeric-for back-edge. ForLoop is always at
8541                    // a back-edge position (when it continues);
8542                    // for the trace recorder we treat it as the
8543                    // close-detection equivalent of `Op::Jmp` with
8544                    // negative offset. Counter only ticks when the
8545                    // back-edge will actually fire (count > 0 in
8546                    // the 5.4+ Int form, comparable predicates in
8547                    // pre-5.3 / Float). The cheap check up front
8548                    // matches the for_loop helper's branch.
8549                    if self.jit.trace_enabled {
8550                        let a = inst.a();
8551                        let pre53 = self.version() <= LuaVersion::Lua53;
8552                        let take_back_edge =
8553                            match (self.r(base, a), self.r(base, a + 1), self.r(base, a + 2)) {
8554                                (Value::Int(_), Value::Int(count), Value::Int(_)) if !pre53 => {
8555                                    count != 0
8556                                }
8557                                (Value::Int(cur), Value::Int(lim), Value::Int(st)) if pre53 => {
8558                                    let next = cur.wrapping_add(st);
8559                                    if st > 0 { next <= lim } else { next >= lim }
8560                                }
8561                                (Value::Float(cur), Value::Float(lim), Value::Float(st)) => {
8562                                    let next = cur + st;
8563                                    if st > 0.0 { next <= lim } else { next >= lim }
8564                                }
8565                                _ => false,
8566                            };
8567                        if take_back_edge {
8568                            let proto = cl.proto;
8569                            let c = proto.trace_hot_count.get();
8570                            if c < u32::MAX / 2 {
8571                                proto.trace_hot_count.set(c + 1);
8572                            }
8573                            if c == self.jit.trace_hot_threshold && self.jit.active_trace.is_none()
8574                            {
8575                                // ForLoop's back-edge target = pc
8576                                // after `add_pc(-bx)` runs from the
8577                                // already-bumped f.pc (= pc + 1).
8578                                // So target = (pc + 1) - bx.
8579                                let target = (pc as i32 + 1 - inst.bx() as i32).max(0) as u32;
8580                                let max_stack = cl.proto.max_stack as usize;
8581                                let base_us = base as usize;
8582                                let mut entry_tags = Vec::with_capacity(max_stack);
8583                                for i in 0..max_stack {
8584                                    let (tag, _) = self.stack[base_us + i].unpack();
8585                                    entry_tags.push(tag);
8586                                }
8587                                self.jit.active_trace =
8588                                    Some(Box::new(crate::jit::trace::TraceRecord::start(
8589                                        cl.proto, target, entry_tags, false,
8590                                    )));
8591                                // Record the frame the trace
8592                                // started in. The currently-running
8593                                // Lua frame is at len() - 1.
8594                                self.jit.recording_frame_base = self.frames.len() - 1;
8595                            }
8596                        }
8597                    }
8598                    self.for_loop(inst, base)?;
8599                }
8600                Op::TForPrep => {
8601                    // the 4th control slot is the iterator's closing value
8602                    self.register_tbc(base + inst.a() + 3)?;
8603                    self.add_pc(inst.bx() as i32);
8604                }
8605                Op::TForCall => {
8606                    let abs = base + inst.a();
8607                    let need = (abs + 7) as usize;
8608                    if self.stack.len() < need {
8609                        self.stack.resize(need, Value::Nil);
8610                    }
8611                    self.stack[(abs + 4) as usize] = self.stack[abs as usize];
8612                    self.stack[(abs + 5) as usize] = self.stack[(abs + 1) as usize];
8613                    self.stack[(abs + 6) as usize] = self.stack[(abs + 2) as usize];
8614                    let nvars = inst.c() as i32;
8615                    self.begin_call(abs + 4, Some(2), nvars, false)?;
8616                }
8617                Op::TForLoop => {
8618                    let a = inst.a();
8619                    let ctrl = self.r(base, a + 4);
8620                    if !ctrl.is_nil() {
8621                        // Trace JIT back-edge counter on
8622                        // generic-for back-edge. TForLoop sits at the
8623                        // tail of `for k,v in expr do ... end`; recorder
8624                        // treats it as the close-detection equivalent of
8625                        // a negative Op::Jmp. Gate on `take_back_edge`
8626                        // (= `ctrl != nil`) so empty-iter loops don't
8627                        // pollute hot_count.
8628                        if self.jit.trace_enabled {
8629                            let proto = cl.proto;
8630                            let c = proto.trace_hot_count.get();
8631                            if c < u32::MAX / 2 {
8632                                proto.trace_hot_count.set(c + 1);
8633                            }
8634                            if c == self.jit.trace_hot_threshold && self.jit.active_trace.is_none()
8635                            {
8636                                // TForLoop back-edge target = pc after
8637                                // `add_pc(-bx)` runs from the already-
8638                                // bumped f.pc (= pc + 1). So target =
8639                                // (pc + 1) - bx, normally landing on
8640                                // body_top (the op right after TForPrep).
8641                                let target = (pc as i32 + 1 - inst.bx() as i32).max(0) as u32;
8642                                let max_stack = cl.proto.max_stack as usize;
8643                                let base_us = base as usize;
8644                                let mut entry_tags = Vec::with_capacity(max_stack);
8645                                for i in 0..max_stack {
8646                                    let (tag, _) = self.stack[base_us + i].unpack();
8647                                    entry_tags.push(tag);
8648                                }
8649                                // Snapshot the iter
8650                                // fn's address if Native, so the
8651                                // lowerer can specialise ipairs into
8652                                // inline Table aget IR.
8653                                let iter_ptr =
8654                                    if let Value::Native(n) = self.stack[base_us + a as usize] {
8655                                        Some(n.f as usize)
8656                                    } else {
8657                                        None
8658                                    };
8659                                // Snapshot R[A+5]'s
8660                                // tag (= current iter's val from
8661                                // the just-fired TForCall). The
8662                                // inline aget fast_blk emits a
8663                                // runtime guard against this tag;
8664                                // mixed-tag arrays deopt rather
8665                                // than producing garbage pointers
8666                                // through the spill path.
8667                                let val_slot = base_us + (a as usize) + 5;
8668                                let val_tag = if val_slot < self.stack.len() {
8669                                    Some(self.stack[val_slot].unpack().0)
8670                                } else {
8671                                    None
8672                                };
8673                                let mut rec = crate::jit::trace::TraceRecord::start(
8674                                    cl.proto, target, entry_tags, false,
8675                                );
8676                                rec.tfor_iter_ptr = iter_ptr;
8677                                rec.tfor_val_tag = val_tag;
8678                                self.jit.active_trace = Some(Box::new(rec));
8679                                self.jit.recording_frame_base = self.frames.len() - 1;
8680                            }
8681                        }
8682                        self.set_r(base, a + 2, ctrl);
8683                        self.add_pc(-(inst.bx() as i32));
8684                    }
8685                }
8686                Op::Closure => {
8687                    let proto = cl.proto.protos[inst.bx() as usize];
8688                    let n_ups = proto.upvals.len();
8689                    // Build upvals on the stack for small
8690                    // closures, skipping the per-call Vec/Box alloc
8691                    // that closure_alloc's 10k iters pay. INLINE_UPVALS_N
8692                    // = 2 covers most Lua source (1 captured local, or
8693                    // _ENV + a single capture). Beyond that, fall back
8694                    // to a heap Vec.
8695                    use crate::runtime::function::INLINE_UPVALS_N;
8696                    let mut stack_buf: [std::mem::MaybeUninit<
8697                        Gc<crate::runtime::function::Upvalue>,
8698                    >; INLINE_UPVALS_N] = [std::mem::MaybeUninit::uninit(); INLINE_UPVALS_N];
8699                    let mut heap_buf: Vec<Gc<crate::runtime::function::Upvalue>> = Vec::new();
8700                    let use_inline = n_ups <= INLINE_UPVALS_N;
8701                    if !use_inline {
8702                        heap_buf.reserve_exact(n_ups);
8703                    }
8704                    for (i, d) in proto.upvals.iter().enumerate() {
8705                        let uv = if d.in_stack {
8706                            self.find_or_create_upval(base + d.index as u32)
8707                        } else {
8708                            cl.upvals()[d.index as usize]
8709                        };
8710                        if use_inline {
8711                            stack_buf[i] = std::mem::MaybeUninit::new(uv);
8712                        } else {
8713                            heap_buf.push(uv);
8714                        }
8715                    }
8716                    // Tiny shim around the two paths so the 5.1 _ENV
8717                    // clone + cache check below see one uniform
8718                    // `&mut [Gc<Upvalue>]`. The stack_buf slice points
8719                    // into the local frame (still valid through the
8720                    // rest of this Op::Closure handler).
8721                    let ups: &mut [Gc<crate::runtime::function::Upvalue>] = if use_inline {
8722                        // SAFETY: the first n_ups slots of stack_buf
8723                        // were initialised above; we hand out a slice
8724                        // covering exactly them.
8725                        unsafe {
8726                            std::slice::from_raw_parts_mut(
8727                                stack_buf.as_mut_ptr()
8728                                    as *mut Gc<crate::runtime::function::Upvalue>,
8729                                n_ups,
8730                            )
8731                        }
8732                    } else {
8733                        &mut heap_buf[..]
8734                    };
8735                    // PUC 5.1 had per-function environments: every Lua
8736                    // function carried its own `env` slot, snapshotted from
8737                    // the creating function's env at closure time, so a
8738                    // `setfenv` on one closure never bled into a sibling.
8739                    // luna models that by giving the 5.1 closure a *fresh*
8740                    // closed upvalue for whichever cell holds `_ENV`, seeded
8741                    // from the parent's current env value. Only that cell is
8742                    // cloned — every other upvalue keeps its open/shared
8743                    // identity (so e.g. `local function range(...) ...
8744                    // range(...) ... end` still sees its self-reference). 5.2+
8745                    // keeps the shared-upval model (and the proto cache that
8746                    // depends on it).
8747                    let v51 = self.version() <= LuaVersion::Lua51;
8748                    if v51 && proto.env_upval_idx != u8::MAX {
8749                        let i = proto.env_upval_idx as usize;
8750                        let cur = match ups[i].state() {
8751                            UpvalState::Open { slot, thread } => self.read_slot(slot, thread),
8752                            UpvalState::Closed(v) => v,
8753                        };
8754                        ups[i] = self.heap.new_upvalue(UpvalState::Closed(cur));
8755                    }
8756                    let ups_slice: &[Gc<crate::runtime::function::Upvalue>] = ups;
8757                    // PUC 5.2+ `getcached`: a Proto remembers its last LClosure
8758                    // and reuses it when every fresh-upvalue binding still
8759                    // points to the same Upvalue object as the cached one.
8760                    // That keeps `function() return outer end` repeated in a
8761                    // loop comparing equal across iterations (the captured
8762                    // outer is a shared open upvalue), while `function()
8763                    // return loop_var end` gets a fresh closure each round
8764                    // because the loop var is re-created per iteration. PUC
8765                    // 5.1 predated the cache, and the per-closure `_ENV`
8766                    // clone above would defeat it anyway, so skip it.
8767                    let nc = if v51 {
8768                        self.heap.new_closure_inline(proto, ups_slice)
8769                    } else {
8770                        let cached = proto.cache.get().filter(|c| {
8771                            c.upvals().len() == ups_slice.len()
8772                                && c.upvals()
8773                                    .iter()
8774                                    .zip(ups_slice.iter())
8775                                    .all(|(a, b)| std::ptr::eq(a.as_ptr(), b.as_ptr()))
8776                        });
8777                        match cached {
8778                            Some(c) => c,
8779                            None => {
8780                                let n = self.heap.new_closure_inline(proto, ups_slice);
8781                                proto.cache.set(Some(n));
8782                                n
8783                            }
8784                        }
8785                    };
8786                    self.set_r(base, inst.a(), Value::Closure(nc));
8787                    self.maybe_collect_garbage(base + inst.a() + 1);
8788                }
8789                Op::Vararg => {
8790                    let abs_a = base + inst.a();
8791                    let wanted = inst.c() as i32 - 1;
8792                    // A materialized named vararg lives in func_slot (its writes
8793                    // must be visible to `...`); otherwise spread the extra args
8794                    // straight off the stack at func_slot+1 .. +n_varargs.
8795                    let vt = match self.stack[func_slot as usize] {
8796                        Value::Table(t) => Some(t),
8797                        _ => None,
8798                    };
8799                    let n = match vt {
8800                        Some(t) => {
8801                            let n_key = Value::Str(self.heap.intern(b"n"));
8802                            // PUC getnumargs: a named vararg `t.n` set out of the
8803                            // integer range [0, INT_MAX/2] is rejected here
8804                            match t.get(n_key) {
8805                                Value::Int(n) if (n as u64) <= (i32::MAX as u64 / 2) => n as u32,
8806                                _ => return Err(self.rt_err("vararg table has no proper 'n'")),
8807                            }
8808                        }
8809                        None => n_varargs,
8810                    };
8811                    let count = if wanted < 0 { n } else { wanted as u32 };
8812                    // a named vararg's `n` can be set to anything up to
8813                    // INT_MAX/2; PUC's `luaD_checkstack` refuses what the
8814                    // stack cannot hold
8815                    if abs_a + count > MAX_LUA_STACK {
8816                        return Err(self.rt_err("stack overflow"));
8817                    }
8818                    let need = (abs_a + count) as usize;
8819                    if self.stack.len() < need {
8820                        self.stack.resize(need, Value::Nil);
8821                    }
8822                    for i in 0..count {
8823                        let v = if i >= n {
8824                            Value::Nil
8825                        } else if let Some(t) = vt {
8826                            t.get_int(i as i64 + 1)
8827                        } else {
8828                            self.stack[(func_slot + 1 + i) as usize]
8829                        };
8830                        self.stack[(abs_a + i) as usize] = v;
8831                    }
8832                    if wanted < 0 {
8833                        self.top = abs_a + count;
8834                    }
8835                }
8836                Op::GetVarg => {
8837                    // materialize the vararg table (PUC table.pack shape) from the
8838                    // stack varargs — used when the named vararg is written /
8839                    // escapes / is `_ENV`. It is kept BOTH in func_slot (so `...`
8840                    // sees later writes) and in the local register R[A].
8841                    let n = n_varargs;
8842                    let t = self.heap.new_table();
8843                    {
8844                        // 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).
8845                        let tm = unsafe { t.as_mut() };
8846                        for i in 0..n {
8847                            let _ = tm.set_int(
8848                                &mut self.heap,
8849                                i as i64 + 1,
8850                                self.stack[(func_slot + 1 + i) as usize],
8851                            );
8852                        }
8853                    }
8854                    let n_key = Value::Str(self.heap.intern(b"n"));
8855                    // 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).
8856                    unsafe { t.as_mut() }
8857                        .set(&mut self.heap, n_key, Value::Int(n as i64))
8858                        .expect("'n' is a valid key");
8859                    // once-per-table barrier (mirror SETLIST): t is born BLACK
8860                    // during Propagate; the bulk inserts above don't barrier.
8861                    self.heap
8862                        .barrier_back(t.as_ptr() as *mut crate::runtime::heap::GcHeader);
8863                    self.stack[func_slot as usize] = Value::Table(t);
8864                    self.set_r(base, inst.a(), Value::Table(t));
8865                }
8866                Op::VargIdx => {
8867                    // R[A] := vararg[R[C]] without allocating: integer key in
8868                    // [1,n] → that vararg, "n" → the count, else nil.
8869                    let key = self.r(base, inst.c());
8870                    let n = n_varargs;
8871                    let v = match key {
8872                        Value::Int(k) if k >= 1 && (k as u64) <= n as u64 => {
8873                            self.stack[(func_slot + k as u32) as usize]
8874                        }
8875                        Value::Float(f) if f.fract() == 0.0 && f >= 1.0 && f <= n as f64 => {
8876                            self.stack[(func_slot + f as u32) as usize]
8877                        }
8878                        Value::Str(s) if s.as_bytes() == b"n" => Value::Int(n as i64),
8879                        _ => Value::Nil,
8880                    };
8881                    self.set_r(base, inst.a(), v);
8882                }
8883                Op::ErrNNil => {
8884                    let v = self.r(base, inst.a());
8885                    if !matches!(v, Value::Nil) {
8886                        let bx = inst.bx();
8887                        let name = if bx == 0 {
8888                            "?".to_string()
8889                        } else {
8890                            match cl.proto.consts[(bx - 1) as usize] {
8891                                Value::Str(s) => String::from_utf8_lossy(s.as_bytes()).into_owned(),
8892                                _ => "?".to_string(),
8893                            }
8894                        };
8895                        return Err(self.rt_err(&format!("global '{name}' already defined")));
8896                    }
8897                }
8898                Op::ExtraArg => unreachable!("EXTRAARG executed directly"),
8899            }
8900        }
8901    }
8902
8903    #[inline(always)]
8904    fn pc_of_top(&self) -> u32 {
8905        self.top_frame().pc
8906    }
8907
8908    #[inline(always)]
8909    fn bump_pc(&mut self) {
8910        // Inline `top_frame_mut`: top is guaranteed Lua (continuation frames
8911        // drained at dispatch loop head). Avoids the and_then/lua_mut Option
8912        // layers — bump_pc fires per Jmp / cond_skip miss, so the savings add
8913        // up over `fib_28`'s ~500k jumps.
8914        // 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).
8915        match unsafe { self.frames.last_mut().unwrap_unchecked() } {
8916            CallFrame::Lua(f) => f.pc += 1,
8917            _ => unreachable!("Cont frame at bump_pc"),
8918        }
8919    }
8920
8921    #[inline(always)]
8922    fn add_pc(&mut self, d: i32) {
8923        // 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).
8924        match unsafe { self.frames.last_mut().unwrap_unchecked() } {
8925            CallFrame::Lua(f) => f.pc = (f.pc as i64 + d as i64) as u32,
8926            _ => unreachable!("Cont frame at add_pc"),
8927        }
8928    }
8929
8930    /// PUC conditional-skip convention: the JMP that follows is executed when
8931    /// `cond == k`; otherwise it is skipped.
8932    #[inline(always)]
8933    fn cond_skip(&mut self, cond: bool, k: bool) {
8934        if cond != k {
8935            self.bump_pc();
8936        }
8937    }
8938
8939    // ---- indexing (with __index/__newindex chains) ----
8940
8941    /// The `#` length operation: string byte length, `__len` if present, else
8942    /// the raw table border. Returns the raw length value (may be non-integer
8943    /// when `__len` is exotic).
8944    pub(crate) fn len_value(&mut self, v: Value) -> Result<Value, LuaError> {
8945        match self.len_step(v)? {
8946            MmOut::Done(n) => Ok(n),
8947            // PUC calls unary metamethods with the operand twice
8948            MmOut::Mm { func, recv } => self.call_mm1(func, &[recv, recv]),
8949            MmOut::CompareSynth { .. } => unreachable!("CompareSynth from len_step"),
8950        }
8951    }
8952
8953    /// Length fast path: a string's byte count or a table's raw border when no
8954    /// `__len` is present (`Done`); otherwise the `__len` metamethod (`Mm`),
8955    /// called with the operand twice. Errors for a non-table with no `__len`.
8956    fn len_step(&mut self, v: Value) -> Result<MmOut, LuaError> {
8957        match v {
8958            Value::Str(s) => Ok(MmOut::Done(Value::Int(s.len() as i64))),
8959            Value::Table(t) => {
8960                // PUC 5.1's `__len` applies to userdata only — `luaV_objlen`
8961                // there takes the raw border for a table without consulting
8962                // the metatable, so `#setmetatable({}, {__len = f})` is 0 on
8963                // 5.1 and 7 on 5.2+. Verified against stock 5.1.5 / 5.2.4.
8964                if self.version() == crate::version::LuaVersion::Lua51 {
8965                    return Ok(MmOut::Done(Value::Int(t.len())));
8966                }
8967                let mm = self.get_mm(v, Mm::Len);
8968                if mm.is_nil() {
8969                    Ok(MmOut::Done(Value::Int(t.len())))
8970                } else {
8971                    Ok(MmOut::Mm { func: mm, recv: v })
8972                }
8973            }
8974            _ => {
8975                let mm = self.get_mm(v, Mm::Len);
8976                if mm.is_nil() {
8977                    Err(self.type_err("get length of", v))
8978                } else {
8979                    Ok(MmOut::Mm { func: mm, recv: v })
8980                }
8981            }
8982        }
8983    }
8984
8985    pub(crate) fn index_value(&mut self, t: Value, key: Value) -> Result<Value, LuaError> {
8986        match self.index_step(t, key)? {
8987            MmOut::Done(v) => Ok(v),
8988            MmOut::Mm { func, recv } => self.call_mm1(func, &[recv, key]),
8989            MmOut::CompareSynth { .. } => unreachable!("CompareSynth from index_step"),
8990        }
8991    }
8992
8993    /// PUC `MAXTAGLOOP`: 100 links of `__index`/`__newindex` in 5.1/5.2,
8994    /// 2000 from 5.3.
8995    fn tag_loop_limit(&self) -> u32 {
8996        if self.version <= LuaVersion::Lua52 {
8997            100
8998        } else {
8999            MAX_TAG_LOOP
9000        }
9001    }
9002
9003    /// Resolve `t[key]` through the `__index` chain, stopping at the first raw
9004    /// hit (`Done`) or function metamethod (`Mm`). Table-valued `__index` links
9005    /// are followed inline (no yield possible); only a function link can yield.
9006    fn index_step(&mut self, t: Value, key: Value) -> Result<MmOut, LuaError> {
9007        let mut cur = t;
9008        for _ in 0..self.tag_loop_limit() {
9009            let mm = match cur {
9010                Value::Table(tb) => {
9011                    let v = tb.get(key);
9012                    if !v.is_nil() {
9013                        return Ok(MmOut::Done(v));
9014                    }
9015                    let mm = self.get_mm(cur, Mm::Index);
9016                    if mm.is_nil() {
9017                        return Ok(MmOut::Done(Value::Nil));
9018                    }
9019                    mm
9020                }
9021                v => {
9022                    let mm = self.get_mm(v, Mm::Index);
9023                    if mm.is_nil() {
9024                        return Err(self.type_err("index", v));
9025                    }
9026                    mm
9027                }
9028            };
9029            match mm {
9030                Value::Closure(_) | Value::Native(_) => {
9031                    return Ok(MmOut::Mm {
9032                        func: mm,
9033                        recv: cur,
9034                    });
9035                }
9036                next => cur = next,
9037            }
9038        }
9039        Err(self.runerror(if self.version <= LuaVersion::Lua52 {
9040            "loop in gettable"
9041        } else {
9042            "'__index' chain too long; possible loop"
9043        }))
9044    }
9045
9046    pub(crate) fn newindex_value(
9047        &mut self,
9048        t: Value,
9049        key: Value,
9050        v: Value,
9051    ) -> Result<(), LuaError> {
9052        match self.newindex_step(t, key, v)? {
9053            MmOut::Done(_) => Ok(()),
9054            MmOut::Mm { func, recv } => {
9055                self.call_value(func, &[recv, key, v])?;
9056                Ok(())
9057            }
9058            MmOut::CompareSynth { .. } => unreachable!("CompareSynth from newindex_step"),
9059        }
9060    }
9061
9062    /// Resolve `t[key] = v` through the `__newindex` chain. A raw assignment is
9063    /// performed inline (returning `Done`); only a function metamethod (`Mm`)
9064    /// needs an actual call — which the caller may run yieldably.
9065    fn newindex_step(&mut self, t: Value, key: Value, v: Value) -> Result<MmOut, LuaError> {
9066        // Read-time probe (gc-verify): a dead query key at a
9067        // WRITE site, attributed to the instruction that produced it.
9068        #[cfg(feature = "gc-verify")]
9069        if let Some(p) = match key {
9070            Value::Str(s) => Some(s.as_ptr() as usize),
9071            Value::Table(t2) => Some(t2.as_ptr() as usize),
9072            _ => None,
9073        } && crate::runtime::gc_verify_probe::is_freed(p)
9074        {
9075            let detail = match self.frames.last() {
9076                Some(CallFrame::Lua(f)) => {
9077                    let pc = f.pc as usize;
9078                    let mut w = String::new();
9079                    for q in pc.saturating_sub(6)..(pc + 2) {
9080                        if let Some(inst) = f.closure.proto.code.get(q) {
9081                            w.push_str(&format!(
9082                                "\n  [{q}] {:?} a={} b={} c={} k={}",
9083                                inst.op(),
9084                                inst.a(),
9085                                inst.b(),
9086                                inst.c(),
9087                                inst.k()
9088                            ));
9089                        }
9090                    }
9091                    format!("pc={pc} base={} gc_top={} window:{w}", f.base, self.gc_top)
9092                }
9093                _ => "non-Lua frame".into(),
9094            };
9095            panic!("[gc-verify] newindex_step QUERY key {p:#x} freed. {detail}");
9096        }
9097        let mut cur = t;
9098        for _ in 0..self.tag_loop_limit() {
9099            let mm = match cur {
9100                Value::Table(tb) => {
9101                    // Single-walk collapse — Table::try_set_existing
9102                    // fuses the prior `tb.get(key).is_nil()` gate and
9103                    // `raw_set` walk into one chain traversal when the
9104                    // key is already present with a non-nil value. The
9105                    // __newindex chain semantics are preserved by the
9106                    // identity (slot_nil ⇔ fire_newindex).
9107                    //
9108                    // SAFETY: Gc<T> is NonNull<T> over the GC heap; the
9109                    // heap is single-threaded and the pointer is live as
9110                    // long as it is reachable from active roots (see
9111                    // heap.rs:5-7). Mirrors the raw_set wrapper below.
9112                    if unsafe { tb.as_mut() }.try_set_existing(key, v) {
9113                        self.heap
9114                            .barrier_back(tb.as_ptr() as *mut crate::runtime::heap::GcHeader);
9115                        return Ok(MmOut::Done(Value::Nil));
9116                    }
9117                    let mm = self.get_mm(cur, Mm::NewIndex);
9118                    if mm.is_nil() {
9119                        self.raw_set(tb, key, v)?;
9120                        return Ok(MmOut::Done(Value::Nil));
9121                    }
9122                    mm
9123                }
9124                bad => {
9125                    let mm = self.get_mm(bad, Mm::NewIndex);
9126                    if mm.is_nil() {
9127                        return Err(self.type_err("index", bad));
9128                    }
9129                    mm
9130                }
9131            };
9132            match mm {
9133                Value::Closure(_) | Value::Native(_) => {
9134                    return Ok(MmOut::Mm {
9135                        func: mm,
9136                        recv: cur,
9137                    });
9138                }
9139                next => cur = next,
9140            }
9141        }
9142        Err(self.runerror(if self.version <= LuaVersion::Lua52 {
9143            "loop in settable"
9144        } else {
9145            "'__newindex' chain too long; possible loop"
9146        }))
9147    }
9148
9149    pub(crate) fn raw_set(&mut self, t: Gc<Table>, key: Value, v: Value) -> Result<(), LuaError> {
9150        // 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).
9151        match unsafe { t.as_mut() }.set(&mut self.heap, key, v) {
9152            Ok(()) => {
9153                self.heap
9154                    .barrier_back(t.as_ptr() as *mut crate::runtime::heap::GcHeader);
9155                Ok(())
9156            }
9157            Err(TableError::NilIndex) => Err(self.runerror("table index is nil")),
9158            Err(TableError::NanIndex) => Err(self.runerror("table index is NaN")),
9159            Err(TableError::Overflow) => Err(self.runerror("table overflow")),
9160            Err(TableError::InvalidNext) => unreachable!(),
9161        }
9162    }
9163
9164    /// Decide equality, or surface the `__eq` metamethod to call. `Done` carries
9165    /// the boolean result; `Mm` (when raw equality fails and both are tables
9166    /// with an `__eq`) carries the metamethod — called with `(l, r)`.
9167    fn eq_step(&mut self, l: Value, r: Value) -> MmOut {
9168        if l.raw_eq(r) {
9169            return MmOut::Done(Value::Bool(true));
9170        }
9171        if let (Value::Table(_), Value::Table(_)) | (Value::Userdata(_), Value::Userdata(_)) =
9172            (l, r)
9173        {
9174            // PUC 5.3+ accepts any `__eq` reachable from either operand; 5.1
9175            // and 5.2 require the two operands' metatables to expose the same
9176            // `__eq` (`get_compTM` / `get_equalTM`) — `c == d` where `d` has
9177            // no metatable falls straight back to raw inequality. events.lua
9178            // 5.1 :262 bakes this in.
9179            let mm = if self.version() <= LuaVersion::Lua52 {
9180                self.get_comp_mm(l, r, Mm::Eq)
9181            } else {
9182                let mut m = self.get_mm(l, Mm::Eq);
9183                if m.is_nil() {
9184                    m = self.get_mm(r, Mm::Eq);
9185                }
9186                m
9187            };
9188            if !mm.is_nil() {
9189                return MmOut::Mm { func: mm, recv: l };
9190            }
9191        }
9192        MmOut::Done(Value::Bool(false))
9193    }
9194
9195    // ---- arithmetic ----
9196
9197    #[inline(always)]
9198    fn arith_rr(&mut self, inst: Inst, base: u32, op: ArithOp) -> Result<(), LuaError> {
9199        let l = self.r(base, inst.b());
9200        let r = self.r(base, inst.c());
9201        // hot path: Int + Int for Add / Sub / Mul — fib_28, loop_int_1m,
9202        // binary_trees all hammer these. Skipping coerce_num + the big
9203        // arith_fast match shaves several conditional moves per op.
9204        if let (Value::Int(a), Value::Int(b)) = (l, r) {
9205            let fast = match op {
9206                ArithOp::Add => Some(Value::Int(a.wrapping_add(b))),
9207                ArithOp::Sub => Some(Value::Int(a.wrapping_sub(b))),
9208                ArithOp::Mul => Some(Value::Int(a.wrapping_mul(b))),
9209                _ => None,
9210            };
9211            if let Some(v) = fast {
9212                self.set_r(base, inst.a(), v);
9213                return Ok(());
9214            }
9215        }
9216        // hot path: Float + Float for Add / Sub / Mul / Div — math_loop_100k
9217        // and any numeric workload with non-integer accumulators benefits.
9218        if let (Value::Float(a), Value::Float(b)) = (l, r) {
9219            let fast = match op {
9220                ArithOp::Add => Some(Value::Float(a + b)),
9221                ArithOp::Sub => Some(Value::Float(a - b)),
9222                ArithOp::Mul => Some(Value::Float(a * b)),
9223                ArithOp::Div => Some(Value::Float(a / b)),
9224                _ => None,
9225            };
9226            if let Some(v) = fast {
9227                self.set_r(base, inst.a(), v);
9228                return Ok(());
9229            }
9230        }
9231        // An `Add` with k set is a 5.4+ `x - 0` (see `Op::Add`): the right
9232        // operand is the integer 0, so it adds only when the left is a number.
9233        let op = if inst.k() && op == ArithOp::Add && !matches!(l, Value::Int(_) | Value::Float(_))
9234        {
9235            ArithOp::Sub
9236        } else {
9237            op
9238        };
9239        match self.arith_fast(op, l, r)? {
9240            Some(v) => self.set_r(base, inst.a(), v),
9241            None => {
9242                let mm = self.arith_mm_func(op, l, r)?;
9243                let dst = base + inst.a();
9244                self.begin_meta_call(mm, &[l, r], MetaAction::Store { dst }, op.mm_name())?;
9245            }
9246        }
9247        Ok(())
9248    }
9249
9250    /// The number a unary `-` operand stands for: 5.4+ leaves strings to
9251    /// the string metatable, 5.3 converts them to floats (PUC `tonumber`).
9252    fn unary_operand(&self, v: Value) -> Option<Num> {
9253        let n = self.arith_operand()(v);
9254        if self.version == LuaVersion::Lua53 && matches!(v, Value::Str(_)) {
9255            n.map(|n| Num::Float(n.as_f64()))
9256        } else {
9257            n
9258        }
9259    }
9260
9261    /// How an arithmetic operand becomes a number: 5.4+ takes numbers only
9262    /// (strings go to their metatable), 5.3 converts numeric strings, and
9263    /// 5.1/5.2, which have only floats, convert them to floats (5.1 with C
9264    /// `strtod`, so `"inf"` and `"0x1p4"` count).
9265    fn arith_operand(&self) -> fn(Value) -> Option<Num> {
9266        if self.version >= LuaVersion::Lua54 {
9267            as_number
9268        } else if self.version == LuaVersion::Lua53 {
9269            coerce_num
9270        } else if self.version == LuaVersion::Lua52 {
9271            coerce_num_float
9272        } else {
9273            coerce_num_51
9274        }
9275    }
9276
9277    /// Fast path for an arithmetic/bitwise op: `Ok(Some(v))` when computed
9278    /// directly, `Ok(None)` when a metamethod is required (the caller decides
9279    /// whether to call it synchronously or yieldably).
9280    fn arith_fast(&mut self, op: ArithOp, l: Value, r: Value) -> Result<Option<Value>, LuaError> {
9281        use ArithOp::*;
9282        // 5.4 moved string->number coercion out of the VM: a string operand
9283        // goes to the string metatable's `__add` etc., and bitwise operators
9284        // have no string metamethods at all.
9285        let num = self.arith_operand();
9286        if let BAnd | BOr | BXor | Shl | Shr = op {
9287            let (Some(a), Some(b)) = (num(l), num(r)) else {
9288                return Ok(None);
9289            };
9290            let (Some(a), Some(b)) = (int_of(a), int_of(b)) else {
9291                // PUC luaG_tointerror: name the offending operand
9292                return Err(self.no_int_rep_err());
9293            };
9294            let v = match op {
9295                BAnd => a & b,
9296                BOr => a | b,
9297                BXor => a ^ b,
9298                Shl => shift_left(a, b),
9299                Shr => shift_left(a, b.wrapping_neg()),
9300                _ => unreachable!(),
9301            };
9302            return Ok(Some(Value::Int(v)));
9303        }
9304        let (Some(mut ln), Some(mut rn)) = (num(l), num(r)) else {
9305            return Ok(None);
9306        };
9307        // PUC 5.3 takes the integer path only when both operands are
9308        // integers (`ttisinteger`); a converted string goes through
9309        // `tonumber`, which yields a float.
9310        if self.version == LuaVersion::Lua53
9311            && (matches!(l, Value::Str(_)) || matches!(r, Value::Str(_)))
9312        {
9313            ln = Num::Float(ln.as_f64());
9314            rn = Num::Float(rn.as_f64());
9315        }
9316        match arith_num(self.version, op, ln, rn) {
9317            Ok(v) => Ok(Some(v)),
9318            Err(msg) => Err(self.runerror(msg)),
9319        }
9320    }
9321
9322    /// Find the arithmetic/bitwise metamethod (left operand first), or raise the
9323    /// PUC type error when neither operand provides one.
9324    fn arith_mm_func(&mut self, op: ArithOp, l: Value, r: Value) -> Result<Value, LuaError> {
9325        use ArithOp::*;
9326        let event = match op {
9327            Add => Mm::Add,
9328            Sub => Mm::Sub,
9329            Mul => Mm::Mul,
9330            Div => Mm::Div,
9331            Mod => Mm::Mod,
9332            Pow => Mm::Pow,
9333            IDiv => Mm::IDiv,
9334            BAnd => Mm::BAnd,
9335            BOr => Mm::BOr,
9336            BXor => Mm::BXor,
9337            Shl => Mm::Shl,
9338            Shr => Mm::Shr,
9339        };
9340        let mut mm = self.get_mm(l, event);
9341        if mm.is_nil() {
9342            mm = self.get_mm(r, event);
9343        }
9344        if mm.is_nil() {
9345            let what = if matches!(op, BAnd | BOr | BXor | Shl | Shr) {
9346                "perform bitwise operation on"
9347            } else {
9348                "perform arithmetic on"
9349            };
9350            // luaG_opinterror blames the first operand that is not a number;
9351            // before 5.4 a numeric string counts as one.
9352            let bad = if self.arith_operand()(l).is_none() {
9353                l
9354            } else {
9355                r
9356            };
9357            return Err(self.type_err(what, bad));
9358        }
9359        Ok(mm)
9360    }
9361
9362    // ---- comparison ----
9363
9364    /// `lua_compare(L, a, b, LUA_OPEQ)`: equality including `__eq`.
9365    pub(crate) fn equal(&mut self, l: Value, r: Value) -> Result<bool, LuaError> {
9366        match self.eq_step(l, r) {
9367            MmOut::Done(v) => Ok(v.truthy()),
9368            MmOut::Mm { func, .. } => Ok(self.call_mm1(func, &[l, r])?.truthy()),
9369            MmOut::CompareSynth { .. } => unreachable!("CompareSynth from eq_step"),
9370        }
9371    }
9372
9373    pub(crate) fn less_than(&mut self, l: Value, r: Value, or_eq: bool) -> Result<bool, LuaError> {
9374        match self.less_step(l, r, or_eq)? {
9375            MmOut::Done(v) => Ok(v.truthy()),
9376            MmOut::Mm { func, .. } => Ok(self.call_mm1(func, &[l, r])?.truthy()),
9377            MmOut::CompareSynth { func } => {
9378                // ≤5.3 `__le` via `not __lt(r, l)`. Synchronous helper used
9379                // by library code (sort comparator etc.) — no yield expected
9380                // here (a yield would have hit `call_noyield`'s C boundary).
9381                Ok(!self.call_mm1(func, &[r, l])?.truthy())
9382            }
9383        }
9384    }
9385
9386    /// Decide `l < r` / `l <= r`, or surface the `__lt`/`__le` metamethod. `Done`
9387    /// carries the boolean result; `Mm` (for non-number/string operands) carries
9388    /// the metamethod — called with `(l, r)`; raises the PUC compare error when
9389    /// neither operand provides one.
9390    fn less_step(&mut self, l: Value, r: Value, or_eq: bool) -> Result<MmOut, LuaError> {
9391        let b = match (l, r) {
9392            (Value::Int(a), Value::Int(b)) => {
9393                if or_eq {
9394                    a <= b
9395                } else {
9396                    a < b
9397                }
9398            }
9399            (Value::Float(a), Value::Float(b)) => {
9400                if or_eq {
9401                    a <= b
9402                } else {
9403                    a < b
9404                }
9405            }
9406            (Value::Int(a), Value::Float(b)) => {
9407                if or_eq {
9408                    int_le_float(a, b)
9409                } else {
9410                    int_lt_float(a, b)
9411                }
9412            }
9413            (Value::Float(a), Value::Int(b)) => {
9414                if a.is_nan() {
9415                    false
9416                } else if or_eq {
9417                    !int_lt_float(b, a)
9418                } else {
9419                    !int_le_float(b, a)
9420                }
9421            }
9422            (Value::Str(a), Value::Str(b)) => {
9423                let (a, b) = (a.as_bytes(), b.as_bytes());
9424                if or_eq { a <= b } else { a < b }
9425            }
9426            (l, r) => {
9427                let event = if or_eq { Mm::Le } else { Mm::Lt };
9428                // PUC 5.1's `get_compTM` rule applies to ordered comparisons
9429                // too: both operands' metatables must expose the same
9430                // implementation for `__lt` / `__le` to fire. events.lua 5.1
9431                // :262 expects `c < d` (where `d` has no metatable) to error
9432                // with the default "attempt to compare two table values"
9433                // rather than running c's `__lt` blindly.
9434                let mm = if self.version() <= LuaVersion::Lua51 {
9435                    self.get_comp_mm(l, r, event)
9436                } else {
9437                    let mut m = self.get_mm(l, event);
9438                    if m.is_nil() {
9439                        m = self.get_mm(r, event);
9440                    }
9441                    m
9442                };
9443                // PUC ≤5.4: `a <= b` falls back to `not (b < a)` when neither
9444                // operand carries `__le` (5.4 through its default build's
9445                // LUA_COMPAT_LT_LE); 5.5 requires an explicit `__le`.
9446                // events.lua 5.2/5.3 :172 relies on the synthesis — its
9447                // metatable defines only `__lt`. The `__lt` is looked up as
9448                // for `b < a`: on `b` first (5.1: the same one on both). The
9449                // fallback calls `__lt(r, l)` synchronously (the suite's
9450                // `__lt` doesn't yield) and negates the result; the yieldable
9451                // `__lt` path stays reserved for the explicit `<` operator.
9452                if mm.is_nil() && or_eq && self.version < LuaVersion::Lua55 {
9453                    let mm_lt = if self.version <= LuaVersion::Lua51 {
9454                        self.get_comp_mm(r, l, Mm::Lt)
9455                    } else {
9456                        let m = self.get_mm(r, Mm::Lt);
9457                        if m.is_nil() {
9458                            self.get_mm(l, Mm::Lt)
9459                        } else {
9460                            m
9461                        }
9462                    };
9463                    if !mm_lt.is_nil() {
9464                        return Ok(MmOut::CompareSynth { func: mm_lt });
9465                    }
9466                }
9467                if mm.is_nil() {
9468                    // PUC luaG_ordererror: "two X values" when the operand
9469                    // types match, "X with Y" otherwise (objtypename-aware).
9470                    let (t1, t2) = (self.obj_typename(l), self.obj_typename(r));
9471                    return Err(self.runerror(&if t1 == t2 {
9472                        format!("attempt to compare two {t1} values")
9473                    } else {
9474                        format!("attempt to compare {t1} with {t2}")
9475                    }));
9476                }
9477                return Ok(MmOut::Mm { func: mm, recv: l });
9478            }
9479        };
9480        Ok(MmOut::Done(Value::Bool(b)))
9481    }
9482
9483    // ---- numeric for ----
9484
9485    /// Check and convert a numeric for's control values the way the
9486    /// dialect's `OP_FORPREP` does. The integer loop is chosen by the
9487    /// values' tags (a numeric string makes it a float loop, 5.3+); the
9488    /// check order, wording and the zero-step error differ per version:
9489    /// 5.1/5.2 test initial value, limit, step; 5.3+ limit, step, initial
9490    /// value; only 5.4+ reject a zero step, and an integer loop does that
9491    /// before looking at the limit.
9492    fn for_operands(&mut self, base: u32, a: u32) -> Result<(Num, Num, Num), LuaError> {
9493        let (init, limit, step) = (self.r(base, a), self.r(base, a + 1), self.r(base, a + 2));
9494        let v = self.version();
9495        let order = if v <= LuaVersion::Lua52 {
9496            [("initial value", init), ("limit", limit), ("step", step)]
9497        } else {
9498            [("limit", limit), ("step", step), ("initial value", init)]
9499        };
9500        if v >= LuaVersion::Lua54 && matches!((init, step), (Value::Int(_), Value::Int(0))) {
9501            return Err(self.rt_err("'for' step is zero"));
9502        }
9503        for (what, val) in order {
9504            if as_num(val, v).is_none() {
9505                return Err(self.rt_err(&if v >= LuaVersion::Lua54 {
9506                    format!(
9507                        "bad 'for' {what} (number expected, got {})",
9508                        self.obj_typename(val)
9509                    )
9510                } else {
9511                    format!("'for' {what} must be a number")
9512                }));
9513            }
9514        }
9515        let n = |val| as_num(val, v).expect("checked above");
9516        let int_loop =
9517            v <= LuaVersion::Lua52 || matches!((init, step), (Value::Int(_), Value::Int(_)));
9518        if int_loop {
9519            return Ok((n(init), n(limit), n(step)));
9520        }
9521        let (i, l, st) = (n(init).as_f64(), n(limit).as_f64(), n(step).as_f64());
9522        if v >= LuaVersion::Lua54 && st == 0.0 {
9523            return Err(self.rt_err("'for' step is zero"));
9524        }
9525        Ok((Num::Float(i), Num::Float(l), Num::Float(st)))
9526    }
9527
9528    fn for_prep(&mut self, inst: Inst, base: u32) -> Result<(), LuaError> {
9529        let a = inst.a();
9530        let (init_n, limit_n, step_n) = self.for_operands(base, a)?;
9531        // PUC 5.1–5.3 `OP_FORPREP` stores `i = init - step` and *unconditionally*
9532        // jumps to the matching `OP_FORLOOP` — the body never runs ahead of the
9533        // first test, so each successful iteration emits a backward `OP_FORLOOP`
9534        // jump (db.lua's `for i=1,4 do a=1 end` ↦ 5 line-hook events instead of
9535        // 5.4's 4). 5.4+ collapsed that to a count-based fall-through. The skip
9536        // distance in luna's encoding is `loop_pc - prep_pc`; firing
9537        // `add_pc(bx - 1)` lands the running pc on OP_FORLOOP itself.
9538        let pre53 = self.version() <= LuaVersion::Lua53;
9539        match (init_n, step_n) {
9540            (Num::Int(i0), Num::Int(st)) => {
9541                if pre53 {
9542                    // PUC 5.3 `forlimit`: int limit passes through; float limit
9543                    // gets clamped to MIN/MAX with a `stopnow` flag set only
9544                    // when the clamp is unreachable (positive float with a
9545                    // negative step → limit=MAX, stopnow; negative float with
9546                    // step>=0 → limit=MIN, stopnow). On `stopnow` PUC rewrites
9547                    // `init = 0` so OP_FORLOOP's first test against the
9548                    // unreachable clamp fails cleanly. An ordinary in-range
9549                    // empty loop (e.g. `for i = 1, 0`) is *not* `stopnow` — it
9550                    // lets OP_FORLOOP's natural test reject the first step.
9551                    let (lim, stopnow) = match limit_n {
9552                        Num::Int(l) => (l, false),
9553                        Num::Float(f) => {
9554                            // `luaV_tointeger` floors (ceils for a negative
9555                            // step); a float it cannot fit is clamped on
9556                            // the side of its sign, NaN counting as
9557                            // negative (`0 < n` is false).
9558                            let conv = if st < 0 { f.ceil() } else { f.floor() };
9559                            if (-9_223_372_036_854_775_808.0..9_223_372_036_854_775_808.0)
9560                                .contains(&conv)
9561                            {
9562                                (conv as i64, false)
9563                            } else if f > 0.0 {
9564                                (i64::MAX, st < 0)
9565                            } else {
9566                                (i64::MIN, st > 0)
9567                            }
9568                        }
9569                    };
9570                    let initv = if stopnow { 0 } else { i0 };
9571                    let pre = initv.wrapping_sub(st);
9572                    self.set_r(base, a, Value::Int(pre));
9573                    self.set_r(base, a + 1, Value::Int(lim));
9574                    self.set_r(base, a + 2, Value::Int(st));
9575                    self.add_pc(inst.bx() as i32 - 1);
9576                    return Ok(());
9577                }
9578                let (lim, empty) = int_for_limit(limit_n, i0, st);
9579                if empty {
9580                    self.add_pc(inst.bx() as i32);
9581                    return Ok(());
9582                }
9583                let count = if st > 0 {
9584                    (lim as u64).wrapping_sub(i0 as u64) / (st as u64)
9585                } else {
9586                    (i0 as u64).wrapping_sub(lim as u64) / (st as i128).unsigned_abs() as u64
9587                };
9588                self.set_r(base, a, Value::Int(i0));
9589                self.set_r(base, a + 1, Value::Int(count as i64));
9590                self.set_r(base, a + 2, Value::Int(st));
9591                self.set_r(base, a + 3, Value::Int(i0));
9592            }
9593            _ => {
9594                let (x0, lim, st) = (init_n.as_f64(), limit_n.as_f64(), step_n.as_f64());
9595                if pre53 {
9596                    let pre = x0 - st;
9597                    self.set_r(base, a, Value::Float(pre));
9598                    self.set_r(base, a + 1, Value::Float(lim));
9599                    self.set_r(base, a + 2, Value::Float(st));
9600                    self.add_pc(inst.bx() as i32 - 1);
9601                    return Ok(());
9602                }
9603                // lvm.c `forprep`: skip only when `0 < step ? limit < init :
9604                // init < limit`; a NaN makes both false, so the body runs
9605                // once (with a NaN step, on the second test's side)
9606                let skip = if 0.0 < st { lim < x0 } else { x0 < lim };
9607                let runs = !skip;
9608                if !runs {
9609                    self.add_pc(inst.bx() as i32);
9610                    return Ok(());
9611                }
9612                self.set_r(base, a, Value::Float(x0));
9613                self.set_r(base, a + 1, Value::Float(lim));
9614                self.set_r(base, a + 2, Value::Float(st));
9615                self.set_r(base, a + 3, Value::Float(x0));
9616            }
9617        }
9618        Ok(())
9619    }
9620
9621    #[inline(always)]
9622    fn for_loop(&mut self, inst: Inst, base: u32) -> Result<(), LuaError> {
9623        let a = inst.a();
9624        // PUC 5.1–5.3 `OP_FORLOOP` compares the post-step `i` to `limit`
9625        // directly (R[a+1] holds the limit, *not* a remaining-count) so the
9626        // first iteration's test fires through the same backward-jump path as
9627        // every later iteration. 5.4+ switched to the count-based form luna
9628        // already uses for `Int`; the float branch was already PUC-3.x-style.
9629        let v = self.version();
9630        let pre53 = v <= LuaVersion::Lua53;
9631        // `for_prep` leaves the three slots all Int or all Float; anything
9632        // else was written by `debug.setlocal` or by crafted bytecode. PUC
9633        // reads such slots unchecked (garbage or a crash); luna raises.
9634        match (self.r(base, a), self.r(base, a + 1), self.r(base, a + 2)) {
9635            (Value::Int(cur), Value::Int(lim), Value::Int(st)) if pre53 => {
9636                let next = cur.wrapping_add(st);
9637                let cont = if st > 0 { next <= lim } else { next >= lim };
9638                if cont {
9639                    self.set_r(base, a, Value::Int(next));
9640                    self.set_r(base, a + 3, Value::Int(next));
9641                    self.add_pc(-(inst.bx() as i32));
9642                }
9643            }
9644            // the count is unsigned (PUC `lua_Unsigned`): a loop over
9645            // more than 2^63 values stores a "negative" one
9646            (Value::Int(cur), Value::Int(count), Value::Int(st)) => {
9647                if count != 0 {
9648                    let next = cur.wrapping_add(st);
9649                    self.set_r(base, a, Value::Int(next));
9650                    self.set_r(base, a + 1, Value::Int(count.wrapping_sub(1)));
9651                    self.set_r(base, a + 3, Value::Int(next));
9652                    self.add_pc(-(inst.bx() as i32));
9653                }
9654            }
9655            (Value::Float(cur), Value::Float(lim), Value::Float(st)) => {
9656                self.float_for_step(inst, base, cur, lim, st);
9657            }
9658            // 5.1/5.2 have one number type, so a number of the other
9659            // representation stored into a slot is still a valid state
9660            (x, l, s) if v <= LuaVersion::Lua52 => {
9661                match (as_number(x), as_number(l), as_number(s)) {
9662                    (Some(cur), Some(lim), Some(st)) => {
9663                        self.float_for_step(inst, base, cur.as_f64(), lim.as_f64(), st.as_f64())
9664                    }
9665                    _ => return Err(self.rt_err("'for' state corrupted")),
9666                }
9667            }
9668            _ => return Err(self.rt_err("'for' state corrupted")),
9669        }
9670        Ok(())
9671    }
9672
9673    #[inline(always)]
9674    fn float_for_step(&mut self, inst: Inst, base: u32, cur: f64, lim: f64, st: f64) {
9675        let a = inst.a();
9676        let next = cur + st;
9677        let cont = if st > 0.0 { next <= lim } else { next >= lim };
9678        if cont {
9679            self.set_r(base, a, Value::Float(next));
9680            self.set_r(base, a + 3, Value::Float(next));
9681            self.add_pc(-(inst.bx() as i32));
9682        }
9683    }
9684
9685    // ---- native helpers (used by builtins) ----
9686
9687    /// A native function's own captured upvalue (self lives at func_slot).
9688    ///
9689    /// Public so `native_typed` trampolines and embedders authoring
9690    /// stateful natives via `native_with(...)` can read their upvals.
9691    pub fn nat_upval(&self, func_slot: u32, i: usize) -> Value {
9692        let Value::Native(nc) = self.stack[func_slot as usize] else {
9693            unreachable!("native frame without native closure");
9694        };
9695        nc.upvals[i]
9696    }
9697
9698    /// Number of upvalues captured by the native at `func_slot` (variadic
9699    /// captures such as the `io.lines` format list).
9700    pub(crate) fn nat_upcount(&self, func_slot: u32) -> usize {
9701        let Value::Native(nc) = self.stack[func_slot as usize] else {
9702            unreachable!("native frame without native closure");
9703        };
9704        nc.upvals.len()
9705    }
9706
9707    /// Write a native function's own upvalue (stateful iterators).
9708    pub(crate) fn nat_set_upval(&mut self, func_slot: u32, i: usize, v: Value) {
9709        let Value::Native(nc) = self.stack[func_slot as usize] else {
9710            unreachable!("native frame without native closure");
9711        };
9712        // 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).
9713        unsafe { nc.as_mut() }.upvals[i] = v;
9714        // NativeClosure.upvals is traced as part of its Trace; a long-lived
9715        // stateful iterator closure (e.g. string.gmatch) sees many writes —
9716        // barrier_back once-and-done is cheaper than per-child forward.
9717        self.heap
9718            .barrier_back(nc.as_ptr() as *mut crate::runtime::heap::GcHeader);
9719    }
9720
9721    /// Read the i-th positional argument inside a `NativeFn` body
9722    /// (analogous to `lua_tovalue(L, i + 1)`). `i >= nargs` yields `Nil`,
9723    /// matching PUC's "missing arg is nil" contract. Public so embedders
9724    /// can author their own natives.
9725    pub fn nat_arg(&self, func_slot: u32, nargs: u32, i: u32) -> Value {
9726        if i < nargs {
9727            self.stack[(func_slot + 1 + i) as usize]
9728        } else {
9729            Value::Nil
9730        }
9731    }
9732
9733    /// Overwrite the i-th argument slot of the running native (the in-place
9734    /// conversion `lua_tolstring` performs on a number argument).
9735    pub(crate) fn nat_set_arg(&mut self, func_slot: u32, i: u32, v: Value) {
9736        self.stack[(func_slot + 1 + i) as usize] = v;
9737    }
9738
9739    /// Push the return values of a `NativeFn` and return their count
9740    /// (analogous to pushing N values then `return N` from a C function).
9741    /// Public so embedders can author their own natives.
9742    pub fn nat_return(&mut self, func_slot: u32, vals: &[Value]) -> u32 {
9743        let need = func_slot as usize + vals.len();
9744        if self.stack.len() < need {
9745            self.stack.resize(need, Value::Nil);
9746        }
9747        for (i, &v) in vals.iter().enumerate() {
9748            self.stack[func_slot as usize + i] = v;
9749        }
9750        vals.len() as u32
9751    }
9752
9753    /// Fast string concatenation of an adjacent pair, or `None` when a
9754    /// `__concat` metamethod is required.
9755    fn concat_pair(&mut self, l: Value, r: Value) -> Result<Option<Value>, LuaError> {
9756        let legacy = self.float_fmt();
9757        // Length-check fast paths for both string operands BEFORE the
9758        // (expensive) copy in `concat_piece`, so a runaway `a..a..a..…`
9759        // chain (5.1 big.lua / 5.5 heavy.lua's `teststring`) raises the
9760        // overflow on the first pair that would exceed `INT_MAX` instead
9761        // of allocating multi-GB intermediates first.
9762        let max_str = i32::MAX as usize;
9763        if let (Value::Str(ls), Value::Str(rs)) = (l, r) {
9764            let a_len = ls.as_bytes().len();
9765            let b_len = rs.as_bytes().len();
9766            let new_len = a_len.checked_add(b_len);
9767            if new_len.is_none() || new_len.unwrap() > max_str {
9768                return Err(self.rt_err("string length overflow"));
9769            }
9770        }
9771        match (concat_piece(l, legacy), concat_piece(r, legacy)) {
9772            (Some(a), Some(b)) => {
9773                // PUC `MAX_SIZE` for Lua strings is `INT_MAX`; an attempt to
9774                // concat past it raises "string length overflow"
9775                // (5.5 heavy.lua `teststring` doubles `a..a..…` until it hits
9776                // exactly this wall).
9777                let new_len = a.len().checked_add(b.len());
9778                if new_len.is_none() || new_len.unwrap() > max_str {
9779                    return Err(self.rt_err("string length overflow"));
9780                }
9781                let mut combined = a;
9782                combined.extend_from_slice(&b);
9783                Ok(Some(Value::Str(self.heap.intern(&combined))))
9784            }
9785            _ => Ok(None),
9786        }
9787    }
9788
9789    /// Fold the concat operands occupying `[base_a .. self.top)` right-to-left
9790    /// into a single result at `base_a` (PUC `luaV_concat`). Returns after
9791    /// either finishing (result at `base_a`) or arming a yieldable `__concat`
9792    /// call — its `Meta` continuation re-enters here on the metamethod's return.
9793    fn concat_run(&mut self, base_a: u32) -> Result<(), LuaError> {
9794        // Sum the lengths of all all-Str operands BEFORE starting the
9795        // right-associative fold so a 129-operand `a..a..…` chain
9796        // (5.1 big.lua's `rep129(longs)`) raises overflow immediately,
9797        // not after dozens of multi-GB intermediate intern+hash rounds.
9798        // A non-Str operand falls through to the per-pair check.
9799        let max_str = i32::MAX as usize;
9800        let mut total: usize = 0;
9801        let mut all_str = true;
9802        for slot in base_a..self.top {
9803            match self.stack[slot as usize] {
9804                Value::Str(s) => match total.checked_add(s.as_bytes().len()) {
9805                    Some(t) if t <= max_str => total = t,
9806                    _ => return Err(self.rt_err("string length overflow")),
9807                },
9808                _ => {
9809                    all_str = false;
9810                    break;
9811                }
9812            }
9813        }
9814        let _ = all_str; // discrimination already captured by early returns above
9815        while self.top.saturating_sub(base_a) >= 2 {
9816            let i = self.top - 1; // rightmost operand
9817            let x = self.stack[(i - 1) as usize];
9818            let y = self.stack[i as usize];
9819            match self.concat_pair(x, y)? {
9820                Some(s) => {
9821                    self.stack[(i - 1) as usize] = s;
9822                    self.top = i; // consumed y
9823                }
9824                None => {
9825                    let mut mm = self.get_mm(x, Mm::Concat);
9826                    if mm.is_nil() {
9827                        mm = self.get_mm(y, Mm::Concat);
9828                    }
9829                    if mm.is_nil() {
9830                        let legacy = self.float_fmt();
9831                        let bad = if concat_piece(x, legacy).is_none() {
9832                            x
9833                        } else {
9834                            y
9835                        };
9836                        return Err(self.type_err("concatenate", bad));
9837                    }
9838                    // result lands at i-1, dropping y (top→i); resume continues.
9839                    let dst = i - 1;
9840                    self.begin_meta_call(
9841                        mm,
9842                        &[x, y],
9843                        MetaAction::Concat { dst, base_a },
9844                        "concat",
9845                    )?;
9846                    return Ok(());
9847                }
9848            }
9849        }
9850        self.maybe_collect_garbage(base_a + 1);
9851        Ok(())
9852    }
9853
9854    /// `luaL_tolstring`: `__tostring` (whose result must be a string or a
9855    /// number, rendered), else the basic rendering, where 5.3+ names a value
9856    /// by a string `__name` metafield.
9857    pub fn tostring_value(&mut self, v: Value) -> Result<Vec<u8>, LuaError> {
9858        let mm = self.get_mm(v, Mm::ToString);
9859        if !mm.is_nil() {
9860            // `luaL_callmeta` is a plain `lua_call`: `__tostring` cannot yield.
9861            let r = self.call_noyield(mm, &[v])?;
9862            return match r.first().copied().unwrap_or(Value::Nil) {
9863                Value::Str(s) => Ok(s.as_bytes().to_vec()),
9864                r @ (Value::Int(_) | Value::Float(_)) => Ok(self.tostring_basic(r)),
9865                // luaL_error: positioned at whatever called the library function
9866                _ => Err(crate::vm::builtins::raise_str(
9867                    self,
9868                    "'__tostring' must return a string",
9869                )),
9870            };
9871        }
9872        if self.version >= LuaVersion::Lua53
9873            && !matches!(
9874                v,
9875                Value::Nil | Value::Bool(_) | Value::Int(_) | Value::Float(_) | Value::Str(_)
9876            )
9877            && let Value::Str(name) = self.get_mm(v, Mm::Name)
9878        {
9879            let basic = self.tostring_basic(v);
9880            let at = basic
9881                .iter()
9882                .position(|&c| c == b':')
9883                .expect("an object renders as `kind: address`");
9884            let mut out = name.as_bytes().to_vec();
9885            out.extend_from_slice(&basic[at..]);
9886            return Ok(out);
9887        }
9888        Ok(self.tostring_basic(v))
9889    }
9890
9891    /// The dialect's float-rendering flavor: ≤5.2 %.14g
9892    /// bare, 5.3/5.4 %.14g + ".0", 5.5 two-stage %.15g/%.17g + ".0".
9893    pub(crate) fn float_fmt(&self) -> numeric::FloatFmt {
9894        use crate::version::LuaVersion::*;
9895        match self.version {
9896            Lua51 | Lua52 => numeric::FloatFmt::Legacy14,
9897            Lua53 | Lua54 => numeric::FloatFmt::G14,
9898            _ => numeric::FloatFmt::TwoStage55,
9899        }
9900    }
9901
9902    /// Basic tostring (no metamethods).
9903    pub(crate) fn tostring_basic(&mut self, v: Value) -> Vec<u8> {
9904        match v {
9905            Value::Nil => b"nil".to_vec(),
9906            Value::Bool(true) => b"true".to_vec(),
9907            Value::Bool(false) => b"false".to_vec(),
9908            Value::Int(i) => numeric::num_to_string(Num::Int(i)).into_bytes(),
9909            // PUC ≤5.2 has no integer subtype — `tostring(2.0)` is `"2"`, not
9910            // `"2.0"`. The 5.3+ split needs the suffix so `print(2.0)` is
9911            // distinguishable from `print(2)`. pm.lua :13 builds patterns by
9912            // concatenating these renderings.
9913            Value::Float(f) => {
9914                numeric::num_to_string_for(Num::Float(f), self.float_fmt()).into_bytes()
9915            }
9916            Value::Str(s) => s.as_bytes().to_vec(),
9917            Value::Table(t) => format!("table: {:p}", t.as_ptr()).into_bytes(),
9918            Value::Closure(c) => format!("function: {:p}", c.as_ptr()).into_bytes(),
9919            Value::Native(n) => format!("function: {:p}", n.as_ptr()).into_bytes(),
9920            Value::Coro(co) => format!("thread: {:p}", co.as_ptr()).into_bytes(),
9921            // PUC names file handles `file (0x…)`; a bare userdata is
9922            // `userdata: 0x…`. The io library overrides this via __tostring.
9923            Value::Userdata(u) => format!("userdata: {:p}", u.as_ptr()).into_bytes(),
9924            // PUC `lua_topointer`/tostring on light udata: "userdata: 0x…"
9925            // (the "light" qualifier only appears in `luaL_typeerror`).
9926            Value::LightUserdata(p) => format!("userdata: {p:p}").into_bytes(),
9927        }
9928    }
9929}
9930
9931#[derive(Clone, Copy, PartialEq, Eq)]
9932pub(crate) enum ArithOp {
9933    Add,
9934    Sub,
9935    Mul,
9936    Mod,
9937    Pow,
9938    Div,
9939    IDiv,
9940    BAnd,
9941    BOr,
9942    BXor,
9943    Shl,
9944    Shr,
9945}
9946
9947impl ArithOp {
9948    /// PUC metamethod event name (`__add` → "add" etc.) used by
9949    /// `debug.getinfo(level, "n")` inside a metamethod handler.
9950    fn mm_name(self) -> &'static str {
9951        match self {
9952            ArithOp::Add => "add",
9953            ArithOp::Sub => "sub",
9954            ArithOp::Mul => "mul",
9955            ArithOp::Mod => "mod",
9956            ArithOp::Pow => "pow",
9957            ArithOp::Div => "div",
9958            ArithOp::IDiv => "idiv",
9959            ArithOp::BAnd => "band",
9960            ArithOp::BOr => "bor",
9961            ArithOp::BXor => "bxor",
9962            ArithOp::Shl => "shl",
9963            ArithOp::Shr => "shr",
9964        }
9965    }
9966}
9967
9968fn as_num(v: Value, version: LuaVersion) -> Option<Num> {
9969    match v {
9970        Value::Int(i) => Some(Num::Int(i)),
9971        Value::Float(f) => Some(Num::Float(f)),
9972        // PUC forprep coerces numeric strings (`for i = "10", "1", "-2"`).
9973        Value::Str(s) => str_to_num(s.as_bytes(), version),
9974        _ => None,
9975    }
9976}
9977
9978/// Float `%` as each dialect's `luai_nummod` defines it. 5.1/5.2 compute
9979/// `a - floor(a/b)*b` (so `5 % math.huge` is nan); 5.3 fixes `fmod`'s sign
9980/// when `m*b < 0`; 5.4 compares signs instead, because that product
9981/// underflows to zero for tiny operands.
9982fn float_mod(version: LuaVersion, a: f64, b: f64) -> f64 {
9983    if version <= LuaVersion::Lua52 {
9984        return a - (a / b).floor() * b;
9985    }
9986    let m = a % b;
9987    let fix = if version == LuaVersion::Lua53 {
9988        m * b < 0.0
9989    } else {
9990        (m > 0.0 && b < 0.0) || (m < 0.0 && b > 0.0)
9991    };
9992    if fix { m + b } else { m }
9993}
9994
9995/// A concatenable operand's byte form (string, or a number coerced to its
9996/// string), or `None` when only a `__concat` metamethod can handle it.
9997/// `legacy_float = true` follows PUC ≤5.2's `%.14g` rendering (no `.0`
9998/// suffix on integer-valued floats) — see `num_to_string_for`.
9999fn concat_piece(v: Value, float_fmt: numeric::FloatFmt) -> Option<Vec<u8>> {
10000    match v {
10001        Value::Str(s) => Some(s.as_bytes().to_vec()),
10002        Value::Int(x) => Some(numeric::num_to_string(Num::Int(x)).into_bytes()),
10003        Value::Float(x) => Some(numeric::num_to_string_for(Num::Float(x), float_fmt).into_bytes()),
10004        _ => None,
10005    }
10006}
10007
10008/// Index into the per-basic-type metatable table for a non-table value
10009/// (None for tables, which carry their own metatable).
10010fn type_mt_slot(v: Value) -> Option<usize> {
10011    match v {
10012        Value::Nil => Some(0),
10013        Value::Bool(_) => Some(1),
10014        Value::Int(_) | Value::Float(_) => Some(2),
10015        Value::Str(_) => Some(3),
10016        Value::Closure(_) | Value::Native(_) => Some(4),
10017        // tables and full userdata carry their own metatable; threads and
10018        // light userdata have none (PUC keeps a shared per-type mt slot for
10019        // light, but luna doesn't expose it — no test gates on it yet).
10020        Value::Table(_) | Value::Coro(_) | Value::Userdata(_) | Value::LightUserdata(_) => None,
10021    }
10022}
10023
10024/// A number operand as-is; strings stay non-numbers (5.4+ `tonumberns`).
10025fn as_number(v: Value) -> Option<Num> {
10026    match v {
10027        Value::Int(i) => Some(Num::Int(i)),
10028        Value::Float(f) => Some(Num::Float(f)),
10029        _ => None,
10030    }
10031}
10032
10033/// Arithmetic (not bitwise) on two numbers, PUC `luaO_rawarith`: integer
10034/// results for integer operands except `/` and `^`. The error is the
10035/// message of a zero integer divisor.
10036pub(crate) fn arith_num(
10037    version: LuaVersion,
10038    op: ArithOp,
10039    ln: Num,
10040    rn: Num,
10041) -> Result<Value, &'static str> {
10042    use ArithOp::*;
10043    Ok(match (op, ln, rn) {
10044        (Add, Num::Int(a), Num::Int(b)) => Value::Int(a.wrapping_add(b)),
10045        (Sub, Num::Int(a), Num::Int(b)) => Value::Int(a.wrapping_sub(b)),
10046        (Mul, Num::Int(a), Num::Int(b)) => Value::Int(a.wrapping_mul(b)),
10047        (IDiv, Num::Int(a), Num::Int(b)) => {
10048            if b == 0 {
10049                return Err("attempt to divide by zero");
10050            }
10051            let mut q = a.wrapping_div(b);
10052            if (a ^ b) < 0 && q.wrapping_mul(b) != a {
10053                q -= 1;
10054            }
10055            Value::Int(q)
10056        }
10057        (Mod, Num::Int(a), Num::Int(b)) => {
10058            if b == 0 {
10059                return Err("attempt to perform 'n%0'");
10060            }
10061            let mut m = a.wrapping_rem(b);
10062            if m != 0 && (m ^ b) < 0 {
10063                m += b;
10064            }
10065            Value::Int(m)
10066        }
10067        (Add, a, b) => Value::Float(a.as_f64() + b.as_f64()),
10068        (Sub, a, b) => Value::Float(a.as_f64() - b.as_f64()),
10069        (Mul, a, b) => Value::Float(a.as_f64() * b.as_f64()),
10070        (Div, a, b) => Value::Float(a.as_f64() / b.as_f64()),
10071        // 5.4+ `luai_numpow` squares by multiplying, which can differ from
10072        // the C library's `pow` in the last bit
10073        (Pow, a, b) => {
10074            let (a, b) = (a.as_f64(), b.as_f64());
10075            Value::Float(if b == 2.0 && version >= LuaVersion::Lua54 {
10076                a * a
10077            } else {
10078                a.powf(b)
10079            })
10080        }
10081        (IDiv, a, b) => Value::Float((a.as_f64() / b.as_f64()).floor()),
10082        (Mod, a, b) => Value::Float(float_mod(version, a.as_f64(), b.as_f64())),
10083        (BAnd | BOr | BXor | Shl | Shr, ..) => unreachable!("bitwise op in arith_num"),
10084    })
10085}
10086
10087/// A number's integer value, if it has one.
10088fn int_of(n: Num) -> Option<i64> {
10089    match n {
10090        Num::Int(i) => Some(i),
10091        Num::Float(f) => crate::runtime::value::f2i_exact(f),
10092    }
10093}
10094
10095/// A number, or a numeric string read as a float (5.2).
10096fn coerce_num_float(v: Value) -> Option<Num> {
10097    match v {
10098        Value::Str(s) => numeric::str2num(s.as_bytes(), false, true),
10099        v => as_number(v),
10100    }
10101}
10102
10103/// Number, or string coerced to number (5.3 string-arith coercion).
10104fn coerce_num(v: Value) -> Option<Num> {
10105    match v {
10106        Value::Str(s) => numeric::str2num(s.as_bytes(), true, true),
10107        v => as_number(v),
10108    }
10109}
10110
10111/// A number, or a numeric string read by C `strtod` (5.1 `luaO_str2d`).
10112fn coerce_num_51(v: Value) -> Option<Num> {
10113    match v {
10114        Value::Str(s) => numeric::strtod_str(s.as_bytes()).map(Num::Float),
10115        v => as_number(v),
10116    }
10117}
10118
10119/// A string's numeric value as the dialect converts it: 5.1 runs C
10120/// `strtod` over it (so `inf`/`nan` count and a NUL ends it), 5.2 has its
10121/// own reader but still only floats, 5.3+ `luaO_str2num` with integers.
10122pub(crate) fn str_to_num(s: &[u8], version: LuaVersion) -> Option<Num> {
10123    match version {
10124        LuaVersion::Lua51 => numeric::strtod_str(s).map(Num::Float),
10125        LuaVersion::Lua52 => numeric::str2num(s, false, true),
10126        _ => numeric::str2num(s, true, true),
10127    }
10128}
10129
10130/// Lua shifts: logical on 64 bits; |shift| ≥ 64 yields 0; negative shifts
10131/// reverse direction.
10132fn shift_left(a: i64, b: i64) -> i64 {
10133    if b < 0 {
10134        if b <= -64 {
10135            0
10136        } else {
10137            ((a as u64) >> (-b as u32)) as i64
10138        }
10139    } else if b >= 64 {
10140        0
10141    } else {
10142        ((a as u64) << (b as u32)) as i64
10143    }
10144}
10145
10146/// i < f, exactly (PUC LTintfloat shape).
10147fn int_lt_float(i: i64, f: f64) -> bool {
10148    if f.is_nan() {
10149        return false;
10150    }
10151    if f >= 9_223_372_036_854_775_808.0 {
10152        return true;
10153    }
10154    if f < -9_223_372_036_854_775_808.0 {
10155        return false;
10156    }
10157    let ff = f.floor();
10158    let fi = ff as i64;
10159    if f == ff { i < fi } else { i <= fi }
10160}
10161
10162/// i <= f, exactly.
10163fn int_le_float(i: i64, f: f64) -> bool {
10164    if f.is_nan() {
10165        return false;
10166    }
10167    if f >= 9_223_372_036_854_775_808.0 {
10168        return true;
10169    }
10170    if f < -9_223_372_036_854_775_808.0 {
10171        return false;
10172    }
10173    i <= f.floor() as i64
10174}
10175
10176/// Clip a numeric `for` limit to the integer range (PUC forlimit). Returns
10177/// (clipped limit, loop-is-empty).
10178fn int_for_limit(limit: Num, init: i64, step: i64) -> (i64, bool) {
10179    match limit {
10180        Num::Int(l) => {
10181            let empty = if step > 0 { init > l } else { init < l };
10182            (l, empty)
10183        }
10184        Num::Float(f) => {
10185            // PUC `forlimit` treats NaN like a limit below the integer
10186            // range (`0 < flim` is false): no run upward, down to minint
10187            // otherwise.
10188            if f.is_nan() {
10189                return if step > 0 {
10190                    (0, true)
10191                } else {
10192                    (i64::MIN, false)
10193                };
10194            }
10195            if step > 0 {
10196                if f >= 9_223_372_036_854_775_808.0 {
10197                    (i64::MAX, false)
10198                } else {
10199                    let l = f.floor();
10200                    if l < -9_223_372_036_854_775_808.0 {
10201                        (i64::MIN, true)
10202                    } else {
10203                        let li = l as i64;
10204                        (li, init > li)
10205                    }
10206                }
10207            } else if f <= -9_223_372_036_854_775_808.0 {
10208                (i64::MIN, false)
10209            } else {
10210                let l = f.ceil();
10211                if l >= 9_223_372_036_854_775_808.0 {
10212                    // PUC forlimit: a positive limit beyond the integer range
10213                    // is unreachable for a decreasing loop — empty.
10214                    (i64::MAX, true)
10215                } else {
10216                    let li = l as i64;
10217                    (li, init < li)
10218                }
10219            }
10220        }
10221    }
10222}
10223
10224impl Vm {
10225    /// PUC's debug-API placeholder for an unnamed vararg slot returned by
10226    /// `debug.getlocal(_, -n)`. 5.2/5.3 spelled it `"(*vararg)"`; 5.4
10227    /// dropped the asterisk in favour of `"(vararg)"`. db.lua 5.2 :189 /
10228    /// 5.3 :195 / 5.4 :286 baseline on their respective form.
10229    pub(crate) fn vararg_locvar_name(&self) -> &'static str {
10230        if matches!(self.version, LuaVersion::Lua52 | LuaVersion::Lua53) {
10231            "(*vararg)"
10232        } else {
10233            "(vararg)"
10234        }
10235    }
10236
10237    /// PUC's debug-API placeholder for an unnamed temporary on a C
10238    /// activation. 5.2/5.3 reported `"(*temporary)"`; 5.4 switched to
10239    /// `"(C temporary)"`. db.lua 5.2 :288, 5.3 :312, 5.4 :404 each pin
10240    /// their spelling.
10241    pub(crate) fn temporary_locvar_name(&self) -> &'static str {
10242        if matches!(
10243            self.version,
10244            LuaVersion::Lua51 | LuaVersion::Lua52 | LuaVersion::Lua53
10245        ) {
10246            // PUC 5.1's `findlocal` C-frame branch reported `(*temporary)`
10247            // (db.lua :228 pins it). 5.2/5.3 kept the spelling, 5.4 changed
10248            // to `(C temporary)`.
10249            "(*temporary)"
10250        } else {
10251            "(C temporary)"
10252        }
10253    }
10254
10255    /// PUC's debug-API placeholder for an unnamed Lua-frame temporary
10256    /// (an arithmetic intermediate sitting past the last named local on a
10257    /// live register slot). 5.2/5.3 reported `"(*temporary)"`; 5.4 dropped
10258    /// the asterisk to `"(temporary)"`. db.lua 5.3 :786, 5.4 :966 pin the
10259    /// spelling.
10260    pub(crate) fn lua_temporary_locvar_name(&self) -> &'static str {
10261        if matches!(
10262            self.version,
10263            LuaVersion::Lua51 | LuaVersion::Lua52 | LuaVersion::Lua53
10264        ) {
10265            "(*temporary)"
10266        } else {
10267            "(temporary)"
10268        }
10269    }
10270
10271    /// PUC `pushglobalfuncname`: walk `package.loaded` to depth 2 looking for a
10272    /// native whose function pointer matches `target`, and return its qualified
10273    /// name (e.g. `"table.sort"`). A `_G.X` match is stripped to `"X"`. Returns
10274    /// `None` if no match is found. Used by `arg_error` when the running native
10275    /// was invoked from another native (PUC `ar.name == NULL` at level 0).
10276    pub(crate) fn pushglobalfuncname(
10277        &mut self,
10278        target: crate::runtime::value::NativeFn,
10279    ) -> Option<String> {
10280        let pkg_k = Value::Str(self.heap.intern(b"package"));
10281        let pkg = match self.globals().get(pkg_k) {
10282            Value::Table(t) => t,
10283            _ => return None,
10284        };
10285        let loaded_k = Value::Str(self.heap.intern(b"loaded"));
10286        let loaded = match pkg.get(loaded_k) {
10287            Value::Table(t) => t,
10288            _ => return None,
10289        };
10290        let matches = |v: Value| -> bool {
10291            matches!(v, Value::Native(nc) if std::ptr::fn_addr_eq(nc.f, target))
10292        };
10293        let mut k = Value::Nil;
10294        while let Ok(Some((nk, nv))) = loaded.next(k) {
10295            k = nk;
10296            let Value::Str(outer) = nk else { continue };
10297            let outer = String::from_utf8_lossy(outer.as_bytes()).into_owned();
10298            if matches(nv) {
10299                return Some(if outer == "_G" { String::new() } else { outer });
10300            }
10301            if let Value::Table(inner_t) = nv {
10302                let mut k2 = Value::Nil;
10303                while let Ok(Some((nk2, nv2))) = inner_t.next(k2) {
10304                    k2 = nk2;
10305                    if matches(nv2)
10306                        && let Value::Str(inner) = nk2
10307                    {
10308                        let inner = String::from_utf8_lossy(inner.as_bytes()).into_owned();
10309                        return Some(if outer == "_G" {
10310                            inner
10311                        } else {
10312                            format!("{outer}.{inner}")
10313                        });
10314                    }
10315                }
10316            }
10317        }
10318        None
10319    }
10320
10321    /// How the caller named the running native (PUC `lua_getinfo("n")` at
10322    /// level 0): `None` when it gives no name, as when the caller is C.
10323    pub(crate) fn running_call_name(&self) -> Option<(&'static str, String)> {
10324        let ts = self.thread_stack(None);
10325        if ts.levels.is_empty() {
10326            return None;
10327        }
10328        self.level_name(&ts, 0)
10329    }
10330
10331    /// Read an upvalue cell of a closure (debug.getupvalue).
10332    pub(crate) fn upvalue_value(&self, cl: Gc<LuaClosure>, idx: usize) -> Value {
10333        match cl.upvals()[idx].state() {
10334            UpvalState::Open { slot, thread } => self.read_slot(slot, thread),
10335            UpvalState::Closed(v) => v,
10336        }
10337    }
10338
10339    /// Write an upvalue cell of a closure (debug.setupvalue).
10340    pub(crate) fn upvalue_set_value(&mut self, cl: Gc<LuaClosure>, idx: usize, v: Value) {
10341        let uv = cl.upvals()[idx];
10342        match uv.state() {
10343            UpvalState::Open { slot, thread } => self.write_slot(slot, thread, v),
10344            UpvalState::Closed(_) => {
10345                // 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).
10346                unsafe { uv.as_mut() }.set_closed(v);
10347                self.heap
10348                    .barrier_forward(uv.as_ptr() as *mut crate::runtime::heap::GcHeader, v);
10349            }
10350        }
10351    }
10352}
10353
10354// ────────────────────────────────────────────────────────────────────
10355// AOT trace dispatch install.
10356//
10357// The deploy-side resolver in `luna-runtime-helpers` walks the binary's
10358// trace-meta section after `vm.load`, resolves each entry's
10359// `(proto_hash, head_pc, fn_ptr)` triple against the loaded chunk's
10360// proto tree, and pushes a `CompiledTrace` onto the matching Proto's
10361// `traces` Vec via [`Vm::install_aot_trace`] below. The existing
10362// trace-dispatch loop (this file's `cl.proto.traces.borrow().iter()
10363// .find(|t| t.head_pc == pc && t.dispatchable)`) then fires the AOT
10364// mcode without further plumbing — same code path the runtime JIT
10365// uses.
10366//
10367// Why a separate impl block: keeps the AOT API surface (one fn) easy
10368// to locate when grep'ing for `install_aot_trace`, without dragging
10369// the 8500-line `impl Vm` block above.
10370// ────────────────────────────────────────────────────────────────────
10371
10372impl Vm {
10373    /// Install a precompiled
10374    /// `CompiledTrace` onto `proto.traces` so the interp dispatcher
10375    /// fires it at the trace's `head_pc`. This is the runtime install
10376    /// API the deploy-side `luna-runtime-helpers` resolver calls once
10377    /// per AOT-emitted trace meta entry, after looking up `proto` by
10378    /// stable hash (see `crate::runtime::function::Proto::stable_hash`).
10379    ///
10380    /// # What this does
10381    ///
10382    /// Pushes `trace` onto `proto.traces` via the existing `RefCell`.
10383    /// The trace's `entry` fn ptr must already point at runnable
10384    /// machine code (the AOT linker resolved the symbol at link time;
10385    /// the deploy resolver passes the address verbatim).
10386    ///
10387    /// # What this does NOT do
10388    ///
10389    /// - **No deduplication.** Calling twice with the same `head_pc`
10390    ///   pushes two entries; the dispatcher's `find` will pick the
10391    ///   first match. The deploy resolver is responsible for not
10392    ///   double-installing.
10393    /// - **No invalidation of the runtime JIT cache.** If the runtime
10394    ///   JIT later records + compiles a trace for the same
10395    ///   `(proto, head_pc)`, both coexist on `proto.traces` and the
10396    ///   dispatcher's `find` picks whichever appears first. AOT
10397    ///   traces install before any runtime recording is possible
10398    ///   (resolver runs before `vm.load` returns its first closure),
10399    ///   so AOT traces win the race for the same site.
10400    /// - **No coverage gating.** AOT traces are trusted by
10401    ///   construction — they were validated at compile time. Setting
10402    ///   `dispatchable: false` on the input would silently disable
10403    ///   dispatch; the caller controls that flag.
10404    ///
10405    /// # Safety / soundness
10406    ///
10407    /// `trace.entry` is an `unsafe extern "C" fn` (mmap'd or linked
10408    /// machine code). Soundness contract:
10409    ///
10410    /// - The fn pointer must remain valid for the `Vm`'s lifetime.
10411    ///   In the AOT-binary deploy shape this is trivially satisfied —
10412    ///   the fn lives in the binary's `.text`.
10413    /// - `trace.entry_tags` / `exit_tags` / `window_size` must match
10414    ///   what the trace's IR actually compiled against; the dispatcher
10415    ///   uses them to marshal `reg_state` in and out without further
10416    ///   validation. A mismatch corrupts vm.stack.
10417    ///
10418    /// The AOT pipeline (`luna-aot`) is responsible for ensuring these
10419    /// invariants hold; this fn is a plain push — no validation that
10420    /// would slow the dispatcher's hot path either.
10421    pub fn install_aot_trace(
10422        &mut self,
10423        proto: crate::runtime::Gc<crate::runtime::function::Proto>,
10424        trace: crate::jit::trace::CompiledTrace,
10425    ) {
10426        let _ = self; // resolver passes &mut Vm for symmetry with future
10427        // pending-install + hash-walk variants; nothing on `self` to
10428        // mutate today because the install target lives on the Proto.
10429        proto.traces.borrow_mut().push(TArc::new(trace));
10430    }
10431
10432    /// Walk the proto tree
10433    /// reachable from `root` and return `(proto, stable_hash)` pairs
10434    /// for every Proto found. Used by the deploy-side resolver to
10435    /// match AOT-emitted `proto_hash` keys against the freshly
10436    /// `undump`'d chunk's protos.
10437    ///
10438    /// The walk is BFS over `Proto.protos`. Same-Proto deduplication
10439    /// is done via `Gc::as_ptr` identity — a Proto re-referenced from
10440    /// multiple nested closures (rare; the cache field would catch
10441    /// the closure-side dedup, not the Proto side) is reported once.
10442    ///
10443    /// # Why on `&Vm` and not a free fn
10444    ///
10445    /// Keeps the AOT install API discoverable on the Vm surface —
10446    /// `vm.collect_proto_hashes(root)` reads naturally next to
10447    /// `vm.install_aot_trace(proto, trace)`. Doesn't actually touch
10448    /// any Vm field, so `&self` (read-only) is enough.
10449    pub fn collect_proto_hashes(
10450        &self,
10451        root: crate::runtime::Gc<crate::runtime::function::Proto>,
10452    ) -> Vec<(
10453        crate::runtime::Gc<crate::runtime::function::Proto>,
10454        [u8; 16],
10455    )> {
10456        let _ = self;
10457        let mut out = Vec::new();
10458        let mut seen: std::collections::HashSet<*const crate::runtime::function::Proto> =
10459            std::collections::HashSet::new();
10460        let mut queue: std::collections::VecDeque<
10461            crate::runtime::Gc<crate::runtime::function::Proto>,
10462        > = std::collections::VecDeque::new();
10463        queue.push_back(root);
10464        while let Some(p) = queue.pop_front() {
10465            let key = p.as_ptr() as *const _;
10466            if !seen.insert(key) {
10467                continue;
10468            }
10469            out.push((p, p.stable_hash()));
10470            for &child in p.protos.iter() {
10471                queue.push_back(child);
10472            }
10473        }
10474        out
10475    }
10476}
10477
10478/// Recordings of one trace head that may fail to compile before the head
10479/// is no longer recorded (LuaJIT likewise blacklists a trace start after
10480/// repeated failures). A few tries, since a later recording can see
10481/// different register kinds.
10482const MAX_TRACE_COMPILE_FAILURES: u8 = 3;
10483
10484fn note_trace_compile_failure(proto: Gc<crate::runtime::function::Proto>, head_pc: u32) {
10485    let mut failures = proto.trace_compile_failures.borrow_mut();
10486    match failures.iter_mut().find(|(pc, _)| *pc == head_pc) {
10487        Some((_, n)) => *n = n.saturating_add(1),
10488        None => failures.push((head_pc, 1)),
10489    }
10490}
10491
10492fn trace_head_abandoned(proto: Gc<crate::runtime::function::Proto>, head_pc: u32) -> bool {
10493    proto
10494        .trace_compile_failures
10495        .borrow()
10496        .iter()
10497        .any(|&(pc, n)| pc == head_pc && n >= MAX_TRACE_COMPILE_FAILURES)
10498}