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
417/// Call-site context an in-flight async native
418/// needs preserved across the cooperative-yield boundary.
419///
420/// The dispatcher records this when it routes a `NativeClosure` with
421/// `is_async == true` through the cooperative path; `EvalFuture::poll`
422/// hands it back to [`Vm::commit_async_native_result`] once the
423/// awaited future resolves so `finish_results` (and the post-call GC
424/// checkpoint) can run as if the native had completed synchronously.
425#[derive(Clone, Copy)]
426pub(crate) struct AsyncNativeCallCtx {
427    pub func_slot: u32,
428    /// Recorded for parity with the sync native-call path's
429    /// `native_nresults`/`gc_top` bookkeeping; reserved for hook
430    /// firing + traceback shaping. Not read yet.
431    #[allow(dead_code)]
432    pub nargs: u32,
433    pub nresults: i32,
434    /// Recorded for traceback + GC-root-window checks. The resume path
435    /// reads `Vm.gc_top` directly, so this is unread today; carried so a
436    /// check can confirm the pre-suspend root window matches the
437    /// post-resume one.
438    #[allow(dead_code)]
439    pub gc_top: u32,
440}
441
442/// Per-thread debug hook state (PUC `lua_State` hook/hookmask/basehookcount/
443/// hookcount). `func` is the Lua hook; the booleans are the PUC mask bits.
444#[derive(Clone, Copy, Default)]
445pub struct HookState {
446    /// the hook function (`None` when no hook is installed)
447    pub func: Option<Value>,
448    /// Rust-side debug hook. Fires alongside the Lua hook
449    /// (Rust first); both can be installed simultaneously, but most
450    /// embedders pick one.
451    pub rust_func: Option<RustDebugHook>,
452    /// LUA_MASKCALL — fire on function entry
453    pub call: bool,
454    /// LUA_MASKRET — fire on function return
455    pub ret: bool,
456    /// LUA_MASKLINE — fire on source-line change
457    pub line: bool,
458    /// LUA_MASKCOUNT — fire every `count_base` instructions
459    pub count: bool,
460    /// instruction count between count events (PUC basehookcount)
461    pub count_base: i64,
462    /// instructions left until the next count event (PUC hookcount)
463    pub count_left: i64,
464}
465
466/// Rust-side debug hook callback. Receives the `Vm` plus a
467/// classified event. The callback runs synchronously in the
468/// dispatcher; the hook flag (`in_hook`) is set for its duration so
469/// hook recursion is suppressed.
470pub type RustDebugHook = fn(&mut Vm, RustHookEvent);
471
472/// Classified debug event delivered to a [`RustDebugHook`].
473#[derive(Clone, Copy, Debug, PartialEq, Eq)]
474pub enum RustHookEvent {
475    /// Function entry (`hook_call` analogue).
476    Call,
477    /// Function return (`hook_return` analogue).
478    Return,
479    /// Tail call entry (PUC 5.2+ separates this from a plain Call).
480    TailCall,
481    /// Source-line change (the `u32` is the 1-based line number).
482    Line(u32),
483    /// Instruction count event (fires every `count_base` instructions).
484    Count,
485}
486
487/// Mask flags for [`Vm::set_rust_debug_hook`]. OR these to subscribe
488/// to multiple event categories with a single hook installation.
489pub const HOOK_MASK_CALL: u32 = 1;
490/// Subscribe to function-return events.
491pub const HOOK_MASK_RETURN: u32 = 2;
492/// Subscribe to line-change events.
493pub const HOOK_MASK_LINE: u32 = 4;
494/// Subscribe to instruction-count events.
495pub const HOOK_MASK_COUNT: u32 = 8;
496
497/// A thread's swapped-out execution context (PUC per-thread stack state).
498struct SavedCtx {
499    stack: Vec<Value>,
500    frames: Vec<CallFrame>,
501    frame_ccmt: Vec<u8>,
502    open_upvals: Vec<(u32, Gc<Upvalue>)>,
503    tbc: Vec<u32>,
504    top: u32,
505    pcall_depth: u32,
506    hook: HookState,
507    /// PUC `L->l_gt` — the thread's own globals table. Carried alongside
508    /// the rest of the suspended state so each thread can keep its own
509    /// `setfenv(0, env)` rewire without the swap leaking into another
510    /// thread (5.1 closure.lua :177).
511    globals: Gc<Table>,
512}
513
514/// Outcome of unwinding the call stack on an error (see `Vm::unwind`).
515enum Unwound {
516    /// caught by a pcall/xpcall continuation; resume running its caller
517    Caught,
518    /// caught by a continuation that was the entry-level activation; these are
519    /// the call's (wrapped) results
520    CaughtReturn(Vec<Value>),
521    /// no protecting continuation up to `entry_depth`; propagate the error
522    Propagated(LuaError),
523}
524
525/// Outcome of an index/newindex/comparison fast path: either a directly
526/// computed result, or a metamethod (with the receiver it resolved against) the
527/// caller must invoke — synchronously (C context) or yieldably (VM opcode).
528enum MmOut {
529    /// index → the looked-up value; newindex → done (raw set performed);
530    /// comparison → the boolean result already known
531    Done(Value),
532    /// a metamethod to call; `recv` is the chain element it was found on (the
533    /// extra args — key / value — are supplied by the caller)
534    Mm { func: Value, recv: Value },
535    /// ≤5.3 `a <= b` synthesised via `not __lt(b, a)` when neither operand
536    /// carries `__le` — `op_compare` swaps the args and negates the result.
537    /// Lives separate from `Mm` so the synth path can stay yieldable without
538    /// every other Mm caller learning a swap flag they would never set.
539    CompareSynth { func: Value },
540}
541
542/// Metamethod events; discriminants index `Vm::mm_names`.
543#[derive(Clone, Copy, PartialEq, Eq)]
544#[repr(usize)]
545pub(crate) enum Mm {
546    Index,
547    NewIndex,
548    Call,
549    ToString,
550    Metatable,
551    Name,
552    Eq,
553    Lt,
554    Le,
555    Concat,
556    Len,
557    Add,
558    Sub,
559    Mul,
560    Div,
561    Mod,
562    Pow,
563    IDiv,
564    BAnd,
565    BOr,
566    BXor,
567    Shl,
568    Shr,
569    Unm,
570    BNot,
571    Close,
572    Gc,
573    Pairs,
574}
575
576const MM_NAMES: [&str; 28] = [
577    "__index",
578    "__newindex",
579    "__call",
580    "__tostring",
581    "__metatable",
582    "__name",
583    "__eq",
584    "__lt",
585    "__le",
586    "__concat",
587    "__len",
588    "__add",
589    "__sub",
590    "__mul",
591    "__div",
592    "__mod",
593    "__pow",
594    "__idiv",
595    "__band",
596    "__bor",
597    "__bxor",
598    "__shl",
599    "__shr",
600    "__unm",
601    "__bnot",
602    "__close",
603    "__gc",
604    "__pairs",
605];
606
607/// The metamethod event an opcode dispatches, without the `__` prefix (PUC
608/// funcnamefromcode), for "(metamethod 'event')" call-error suffixes.
609fn mm_event_name(op: crate::vm::isa::Op) -> Option<&'static str> {
610    use crate::vm::isa::Op;
611    Some(match op {
612        Op::Add => "add",
613        Op::Sub => "sub",
614        Op::Mul => "mul",
615        Op::Div => "div",
616        Op::Mod => "mod",
617        Op::Pow => "pow",
618        Op::IDiv => "idiv",
619        Op::BAnd => "band",
620        Op::BOr => "bor",
621        Op::BXor => "bxor",
622        Op::Shl => "shl",
623        Op::Shr => "shr",
624        Op::Unm => "unm",
625        Op::BNot => "bnot",
626        Op::Concat => "concat",
627        Op::Len => "len",
628        Op::GetField | Op::GetTable | Op::GetI | Op::SelfOp => "index",
629        Op::SetField | Op::SetTable | Op::SetI => "newindex",
630        Op::Eq | Op::EqK => "eq",
631        Op::Lt => "lt",
632        Op::Le => "le",
633        _ => return None,
634    })
635}
636
637/// PUC MAXTAGLOOP (5.3+): bound on `__index`/`__newindex` chains.
638const MAX_TAG_LOOP: u32 = 2000;
639/// PUC `MAXCCMT`: bound on a `__call` metamethod chain (lvm.c). 200 chains
640/// is more than any reasonable program needs and matches PUC 5.4/5.5; a
641/// bound of `15` is tight enough to fire on calls.lua :194 (N=20).
642const MAX_CCMT: u32 = 200;
643/// PUC LUAI_MAXCCALLS analogue: native↔Lua nesting bound.
644pub(crate) const MAX_C_DEPTH: u32 = 200;
645/// Stack an xpcall handler may use past `MAX_LUA_STACK` while handling a
646/// stack overflow: PUC's 200 extra `ERRORSTACKSIZE` slots, plus the frame
647/// reserve (256) the overflowing call was refused, so the handler's first
648/// frame fits where that one did not.
649const ERROR_STACK_EXTRA: u32 = 200 + 256;
650/// luna's engine-level VM stack cap (used by call-site overflow checks).
651/// Slightly larger than PUC's `LUAI_MAXSTACK` so engine internals have a
652/// little headroom above any single library push.
653const MAX_LUA_STACK: u32 = 1 << 20;
654/// PUC `LUAI_MAXSTACK` (`luaconf.h`): the cap library code consults via
655/// `lua_checkstack` to refuse multi-value pushes (`table.unpack` returning
656/// N values, `string.pack` results, etc.). 5.3 coroutine.lua :530 pins
657/// this at one million — `for j in {lim-10, …}` expects every j ≥ lim-10
658/// to fail because the few slots already consumed in the coroutine push
659/// the effective cap below lim-10.
660const PUC_MAXSTACK: i64 = 1_000_000;
661
662/// PUC 5.4+ default warnf state. The base library's `warn` function flips
663/// between `Off` and `On` via the `@on` / `@off` control messages; any other
664/// `@<word>` control is silently ignored, mirroring `lauxlib.c::checkcontrol`.
665#[derive(Clone, Copy, PartialEq, Eq, Debug)]
666pub enum WarnState {
667    /// `warn` calls are silently dropped (default after `warn("@off")`).
668    Off,
669    /// `warn` calls are delivered to stderr (after `warn("@on")`).
670    On,
671}
672
673/// Best-effort extraction of a textual message from a `catch_unwind` payload.
674/// `panic!("msg")` arrives as `String`, `panic!(static)` as `&str`; anything
675/// else degrades to `"<non-string panic>"`. Used by the native-call
676/// catch_unwind to fold the panic into a Lua error.
677fn panic_payload_str(payload: &Box<dyn std::any::Any + Send>) -> String {
678    if let Some(s) = payload.downcast_ref::<String>() {
679        return s.clone();
680    }
681    if let Some(s) = payload.downcast_ref::<&'static str>() {
682        return (*s).to_string();
683    }
684    "<non-string panic>".to_string()
685}
686
687/// Combined error type returned by [`Vm::eval`] and friends — either the
688/// chunk failed to parse / compile, or it raised at runtime.
689#[derive(Debug)]
690pub enum Error {
691    /// Parse or compile failure.
692    Syntax(SyntaxError),
693    /// Runtime error raised during execution.
694    Runtime(LuaError),
695}
696
697impl From<SyntaxError> for Error {
698    fn from(e: SyntaxError) -> Error {
699        Error::Syntax(e)
700    }
701}
702
703impl From<LuaError> for Error {
704    fn from(e: LuaError) -> Error {
705        Error::Runtime(e)
706    }
707}
708
709impl Vm {
710    /// `lua_close` from inside a running script (`os.exit(code, true)`):
711    /// close the main thread's pending to-be-closed variables, then run every
712    /// finalizer. Both run protected, so their errors are dropped as PUC's
713    /// `close_state` drops them. Inside a coroutine the main thread's stack is
714    /// parked, and only the finalizers run.
715    pub(crate) fn close_state(&mut self) {
716        if self.current.is_none() {
717            let _ = self.close_slots(0, None);
718        }
719        self.heap.queue_all_finalizers();
720        self.run_finalizers();
721    }
722}
723
724impl Drop for Vm {
725    fn drop(&mut self) {
726        // state close: run `__gc` for every still-registered finalizable before
727        // the heap frees them (PUC separatetobefnz(g,1) + callallpending). A
728        // single pass — objects created by a closing finalizer are not
729        // re-finalized (they go to the heap's free list directly).
730        self.heap.queue_all_finalizers();
731        self.run_finalizers();
732    }
733}
734
735// Split-borrow free fn helpers for frames push/pop with shadow counter
736// `frames_top: u32`. Free fns (not Vm methods) so callers can pass
737// `&mut self.frames` + `&mut self.frames_top` as split borrows, allowing
738// other `&mut self.field` reads inside the CallFrame construction (e.g.
739// `std::mem::take(&mut self.pending_tm)`).
740//
741// The shadow has no readers yet; it just stays in sync + asserts.
742#[inline(always)]
743fn frames_push_sync(frames: &mut Vec<CallFrame>, frames_top: &mut u32, cf: CallFrame) {
744    frames.push(cf);
745    // Shadow maintenance is debug-only: release builds skip the
746    // increment + assertion entirely. While nothing reads the shadow,
747    // its purpose is to VERIFY the assumed invariant
748    // (frames_top == frames.len()) across all push/pop sites; once readers
749    // consume it, release must run the increment unconditionally.
750    #[cfg(debug_assertions)]
751    {
752        *frames_top += 1;
753        debug_assert_eq!(
754            *frames_top as usize,
755            frames.len(),
756            "P17-D frames_top out of sync after push",
757        );
758    }
759    #[cfg(not(debug_assertions))]
760    let _ = frames_top;
761}
762
763#[inline(always)]
764fn frames_pop_sync(frames: &mut Vec<CallFrame>, frames_top: &mut u32) -> Option<CallFrame> {
765    let r = frames.pop();
766    #[cfg(debug_assertions)]
767    {
768        if r.is_some() {
769            *frames_top = frames_top.saturating_sub(1);
770        }
771        debug_assert_eq!(
772            *frames_top as usize,
773            frames.len(),
774            "P17-D frames_top out of sync after pop",
775        );
776    }
777    #[cfg(not(debug_assertions))]
778    let _ = frames_top;
779    r
780}
781
782/// One-time env-var read for
783/// `LUNA_AOT_PROBE`. Returns `true` iff the env var is set to any
784/// non-empty value. The result is cached in a `OnceLock` so the
785/// dispatcher's hot path pays a single atomic load per process. Off
786/// by default — production deploys don't bleed diagnostic prints.
787fn jit_probe_enabled() -> bool {
788    static PROBE_ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
789    *PROBE_ON.get_or_init(|| {
790        std::env::var("LUNA_AOT_PROBE")
791            .ok()
792            .filter(|v| !v.is_empty())
793            .is_some()
794    })
795}
796
797impl Vm {
798    /// Re-sync `frames_top` after a bulk `frames: Vec`
799    /// swap (take_ctx, put_ctx, load_coro_ctx). Must be called after
800    /// the Vec replacement to keep the shadow valid.
801    #[inline(always)]
802    fn frames_resync(&mut self) {
803        // Debug-only — see `frames_push_sync` comment.
804        #[cfg(debug_assertions)]
805        {
806            self.frames_top = self.frames.len() as u32;
807        }
808    }
809
810    // ====================================================================
811    // Stack-inline frame metadata accessors (unused).
812    //
813    // These methods read/write the LJ_FR2 marker slots at `stack[base-2]`
814    // (closure GCRef) and `stack[base-1]` (FrameMarker as i64). No call
815    // site uses them yet.
816    //
817    // Preconditions (debug-asserted):
818    // - base >= 2 (slots base-2 and base-1 must exist below the frame)
819    // - self.stack.len() > base + max_stack (caller has grown stack)
820    // - For Lua frames, stack[base-2] holds Value::Closure(cl)
821    // - For Lua frames, stack[base-1] holds Value::Int(marker.to_raw())
822    //
823    // No release-build cost when unused (LTO strips dead methods).
824    // ====================================================================
825
826    /// Write a Lua frame's closure pointer into `stack[base-2]`.
827    /// The caller must ensure `base >= 2` and the slot is within the
828    /// stack's allocated range.
829    #[inline]
830    #[allow(dead_code)] // no consumer yet
831    fn write_frame_closure(&mut self, base: u32, cl: crate::runtime::Gc<LuaClosure>) {
832        debug_assert!(
833            base >= 2,
834            "frame closure slot needs base >= 2; got {}",
835            base
836        );
837        let idx = (base - 2) as usize;
838        debug_assert!(idx < self.stack.len(), "stack[base-2] out of range");
839        self.stack[idx] = Value::Closure(cl);
840    }
841
842    /// Read a Lua frame's closure pointer from `stack[base-2]`.
843    /// Returns `None` if the slot doesn't hold a closure (caller is
844    /// expected to treat that as a corrupt frame).
845    ///
846    /// Uses the [`Value::tag_byte`] fast-path
847    /// to avoid the enum-match cost on the hot path. Tag check via
848    /// 1-byte load + branch + `as_closure_unchecked` payload load.
849    #[inline]
850    #[allow(dead_code)]
851    fn read_frame_closure(&self, base: u32) -> Option<crate::runtime::Gc<LuaClosure>> {
852        debug_assert!(base >= 2);
853        let v = self.stack.get((base - 2) as usize)?;
854        if v.tag_byte() == crate::runtime::value::tag::CLOSURE {
855            // SAFETY: tag byte just verified == CLOSURE.
856            Some(unsafe { v.as_closure_unchecked() })
857        } else {
858            None
859        }
860    }
861
862    /// Write a packed [`FrameMarker`] into `stack[base-1]`. The marker
863    /// encodes the frame kind (Lua / Cont) + PC-or-delta payload.
864    /// Stored as `Value::Int(marker.to_raw())` so it round-trips
865    /// cleanly through the value stack without losing bits.
866    #[inline]
867    #[allow(dead_code)]
868    fn write_frame_marker(&mut self, base: u32, marker: crate::runtime::frame_marker::FrameMarker) {
869        debug_assert!(base >= 1, "frame marker slot needs base >= 1; got {}", base);
870        let idx = (base - 1) as usize;
871        debug_assert!(idx < self.stack.len(), "stack[base-1] out of range");
872        self.stack[idx] = Value::Int(marker.to_raw());
873    }
874
875    /// Read a packed [`FrameMarker`] from `stack[base-1]`. Returns
876    /// `None` if the slot isn't a `Value::Int` (caller treats as a
877    /// corrupt frame); the kind tag itself may still be invalid, in
878    /// which case [`FrameMarker::kind`] returns `None` on the result.
879    ///
880    /// Uses the [`Value::tag_byte`] fast-path
881    /// for the tag check + `as_int_unchecked` for the payload load.
882    #[inline]
883    #[allow(dead_code)]
884    fn read_frame_marker(&self, base: u32) -> Option<crate::runtime::frame_marker::FrameMarker> {
885        debug_assert!(base >= 1);
886        let v = self.stack.get((base - 1) as usize)?;
887        if v.tag_byte() == crate::runtime::value::tag::INT {
888            // SAFETY: tag byte just verified == INT.
889            Some(crate::runtime::frame_marker::FrameMarker::from_raw(
890                unsafe { v.as_int_unchecked() },
891            ))
892        } else {
893            None
894        }
895    }
896
897    /// Build the raw `Vm` struct without main coroutine / RNG seed / library
898    /// setup. Private helper shared by `Vm::new` and `Vm::new_minimal`; the
899    /// caller is responsible for the rest of the bring-up.
900    fn new_inner(version: LuaVersion) -> Vm {
901        let mut heap = Heap::new();
902        // PUC 5.1 had no ephemeron pass — `__mode='k'` tables marked their
903        // values strongly. gc.lua's "weak tables" section relies on that.
904        heap.no_ephemeron = version <= LuaVersion::Lua51;
905        // PUC 5.3 needs two GC cycles to finalize a table caught in a
906        // coroutine reference cycle (gc.lua :502); 5.4+ rewrote the GC and
907        // finalize in a single cycle (5.4/5.5 gc.lua :544 assert exactly one).
908        heap.defer_thread_cycle_finalize = version == LuaVersion::Lua53;
909        let globals = heap.new_table();
910        let mm_names = MM_NAMES.iter().map(|n| heap.intern(n.as_bytes())).collect();
911
912        Vm {
913            heap,
914            stack: Vec::new(),
915            frames: Vec::new(),
916            frame_ccmt: Vec::new(),
917            frames_top: 0,
918            open_upvals: Vec::new(),
919            tbc: Vec::new(),
920            top: 0,
921            globals,
922            type_mt: [None; 5],
923            mm_names,
924            c_depth: 0,
925            pcall_depth: 0,
926            nny: 0,
927            msgh_depth: 0,
928            terminating: None,
929            rng: [0; 4],
930            started: std::time::Instant::now(),
931            version,
932            closing_err: None,
933            current: None,
934            main_ctx: None,
935            yielding: None,
936            native_nresults: -1,
937            main_coro: None,
938            // PUC 5.4+ boots in GENERATIONAL mode (the first
939            // `collectgarbage("generational")` reports "generational"
940            // as the previous mode on stock lua5.4;
941            // 5.5 behaves the same, probed against lua5.5). luna's
942            // collector is a single incremental engine either way;
943            // this field is the MODE REPORT the stdlib exposes.
944            gc_mode: if version >= crate::version::LuaVersion::Lua54 {
945                "generational"
946            } else {
947                "incremental"
948            },
949            gc_top: 0,
950            gc_pause: 200,
951            gc_stepmul: 100,
952            gc_stepsize: 13,
953            gc_params: crate::vm::lib_gc::GcParams::new(version),
954            gc_finalizing: false,
955            capi_stack: Vec::new(),
956            capi_cstr_pin: None,
957            warn_state: WarnState::Off,
958            warn_buf: Vec::new(),
959            warn_log: Vec::new(),
960            instr_budget: None,
961            bytecode_loading: true,
962            puc_bytecode_loading: false,
963            loader_input_budget: Vm::DEFAULT_LOADER_INPUT_BUDGET,
964            registry: None,
965            file_mt: None,
966            io_input: None,
967            io_output: None,
968            io_stdin: None,
969            ignore_env: false,
970            hook: HookState::default(),
971            in_hook: false,
972            pending_tailcalls: 0,
973            pending_ccmt: 0,
974            errored_natives: Vec::new(),
975            msgh_floor: 0,
976            msgh_running: None,
977            msgh_runs: 0,
978            msgh_applied: None,
979            keep_error_traceback: true,
980            hook_ftransfer: 0,
981            hook_ntransfer: 0,
982            pending_tm: None,
983            pending_is_hook: false,
984            error_traceback: None,
985            public_call_depth: 0,
986            running_natives: Vec::new(),
987            running_native_acts: Vec::new(),
988            natives_base: 0,
989            // JIT-specific state lives in the `JitState`
990            // sidecar. The `luna` crate's `Vm::new_minimal_with_jit` /
991            // `install_jit_backend` / `luaL_newstate` swap in
992            // `CraneliftBackend` for callers that want JIT acceleration.
993            jit: crate::vm::jit_state::JitState::with_null_backend(),
994            // host roots ticket pool for the `Lua` facade
995            host_roots: Vec::new(),
996            // MacroLua registry. Pre-populated with
997            // built-ins (`@quote` / `@unquote` / `@if` / `@gensym`)
998            // when this Vm is constructed under `LuaVersion::MacroLua`.
999            macro_registry: if version == LuaVersion::MacroLua {
1000                crate::frontend::macro_expander::MacroRegistry::with_builtins()
1001            } else {
1002                crate::frontend::macro_expander::MacroRegistry::new()
1003            },
1004            host_roots_free: Vec::new(),
1005            sort_scratch: Vec::new(),
1006            // LuaUserdata trait sugar's per-Vm
1007            // metatable cache. Populated lazily by register_userdata.
1008            userdata_metatables: std::collections::HashMap::new(),
1009            // Error classification metadata. Defaults to
1010            // Runtime; set at known sites (syntax / budget trip /
1011            // native error / type error).
1012            last_error_kind: crate::vm::error::LuaErrorKind::default(),
1013            last_error_source: None,
1014            // Async embedder fields. Defaults preserve sync behavior
1015            // bit-for-bit (`async_mode = false` means the budget hot loop
1016            // errors out instead of yielding).
1017            async_mode: false,
1018            async_waker: None,
1019            async_slice_size: 10_000,
1020            host_yield_pending: false,
1021            // Pending async-native state. Empty by
1022            // default; populated only by the dispatcher when an
1023            // async-marked NativeClosure is invoked under async_mode.
1024            pending_async_native_fut: None,
1025            pending_async_native_ctx: None,
1026        }
1027    }
1028
1029    /// Build a fully-loaded Vm — the default for embedders that want PUC's
1030    /// standard library surface. Equivalent to `Vm::new_minimal(version)`
1031    /// followed by `vm.open_all_libs()`.
1032    pub fn new(version: LuaVersion) -> Vm {
1033        let mut vm = Vm::new_minimal(version);
1034        vm.open_all_libs();
1035        vm
1036    }
1037
1038    /// Build a Vm with no standard libraries loaded. Embedders
1039    /// that want a sandbox (Redis-style scripts, in-game scripting with
1040    /// a curated API) call this and then `open_base` / `open_math` / etc.
1041    /// selectively. The Vm is otherwise fully initialized (main coroutine,
1042    /// RNG seed, GC) so `eval` and `call_value` are immediately usable.
1043    pub fn new_minimal(version: LuaVersion) -> Vm {
1044        let mut vm = Vm::new_inner(version);
1045        let mc = vm.heap.new_coro(Value::Nil, vm.globals);
1046        // 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).
1047        unsafe { mc.as_mut() }.status = CoroStatus::Running;
1048        vm.main_coro = Some(mc);
1049        let (a, b) = vm.rng_auto_seed();
1050        vm.rng_seed(a as u64, b as u64);
1051        vm
1052    }
1053
1054    /// Install a caller-supplied JIT backend. The
1055    /// `luna` crate uses this to swap in its `CraneliftBackend`; tests
1056    /// or third-party backends pass their own [`crate::jit::IntChunkCompiler`] /
1057    /// [`crate::jit::TraceCompiler`] implementations. Re-installing on a Vm whose
1058    /// closures already populated `Proto.jit: JitProtoState::Compiled`
1059    /// does NOT evict those cached entries — call right after
1060    /// construction for a clean swap.
1061    ///
1062    /// Naming: `install_jit_backend` (not `install_default_jit`)
1063    /// because the "default" in luna-core is `NullJitBackend`; the
1064    /// "default JIT" lives in the `luna` crate.
1065    pub fn install_jit_backend<C, T>(&mut self, chunk: C, trace: T)
1066    where
1067        C: crate::jit::IntChunkCompiler + 'static,
1068        T: crate::jit::TraceCompiler + 'static,
1069    {
1070        self.jit.chunk_compiler = Box::new(chunk);
1071        self.jit.trace_compiler = Box::new(trace);
1072    }
1073
1074    /// Install a caller-supplied JIT
1075    /// storage holder. Default is [`crate::jit::NullJitStorage`];
1076    /// the `luna_jit` crate's `install_default_jit` pairs this with
1077    /// `install_jit_backend(CraneliftBackend, CraneliftBackend)` to
1078    /// also install a fresh `CraneliftJitStorage`. Storage holds
1079    /// the per-`Vm` JIT cache + handle collections.
1080    ///
1081    /// Idempotency: re-installing storage on a Vm that already
1082    /// holds compiled-trace pointers WILL evict their owners (the
1083    /// old `CraneliftJitStorage`'s `JITModule`s drop their mmap
1084    /// pages). Call right after construction for a clean swap.
1085    pub fn install_jit_storage<S>(&mut self, storage: S)
1086    where
1087        S: crate::jit::JitStorage + 'static,
1088    {
1089        self.jit.storage = Box::new(storage);
1090    }
1091
1092    /// Install the no-op JIT backend. `try_compile`
1093    /// reports "skipped" so every closure stays on the interpreter
1094    /// path, and the trace recorder's compile attempt always returns
1095    /// `None`. Intended for tests that want to verify the trait
1096    /// boundary works in a JIT-free configuration.
1097    ///
1098    /// Calling this on a Vm whose closures already populated
1099    /// `Proto.jit: JitProtoState::Compiled` does NOT evict those
1100    /// cached entries — the dispatcher will still call into them. For
1101    /// a truly JIT-free run, call this immediately after construction.
1102    pub fn install_null_jit(&mut self) {
1103        self.jit.chunk_compiler = Box::new(crate::jit::NullJitBackend);
1104        self.jit.trace_compiler = Box::new(crate::jit::NullJitBackend);
1105    }
1106
1107    /// Open the entire 5.5 standard library on a `new_minimal`-built Vm.
1108    /// `Vm::new` calls this; sandboxed embedders open libraries one at a
1109    /// time instead (`open_base`, `open_math`, `open_table`, …).
1110    pub fn open_all_libs(&mut self) {
1111        self.open_base();
1112        self.open_math();
1113        self.open_table();
1114        self.open_string();
1115        self.open_utf8();
1116        self.open_os_io();
1117        self.open_debug();
1118        self.open_coroutine();
1119        // PUC 5.2 introduced `bit32`; 5.3 retired it in the manual BUT
1120        // the stock 5.3 build ships -DLUA_COMPAT_5_2, which keeps the
1121        // library loaded. The diff ground truth is the default build
1122        // (stock lua5.3), so expose it under 5.2 AND
1123        // 5.3; 5.4 dropped the compat default for real.
1124        if matches!(self.version, LuaVersion::Lua52 | LuaVersion::Lua53) {
1125            self.open_bit32();
1126        }
1127        // last, so `package.loaded` lists every library opened before it
1128        self.open_package();
1129    }
1130
1131    /// Install the base library (`print`, `type`, `pairs`, `tostring`,
1132    /// `pcall`, `error`, `assert`, `select`, `setmetatable`, `getmetatable`,
1133    /// `rawequal`, `rawget`, `rawset`, `rawlen`, `next`, `tonumber`,
1134    /// `collectgarbage`, `warn` on 5.4+, `_VERSION`, `_G`, plus 5.1's
1135    /// retired globals `unpack`, `loadstring`, `setfenv`, `getfenv`,
1136    /// `newproxy`, `gcinfo` when version == 5.1). Safe to call at most
1137    /// once per Vm.
1138    pub fn open_base(&mut self) {
1139        crate::vm::builtins::open_base(self);
1140    }
1141    /// Install the `math` standard library.
1142    pub fn open_math(&mut self) {
1143        crate::vm::lib_math::open_math(self);
1144    }
1145    /// Install the `table` standard library.
1146    pub fn open_table(&mut self) {
1147        crate::vm::lib_table::open_table(self);
1148    }
1149    /// Install the `string` standard library (and the shared string metatable).
1150    pub fn open_string(&mut self) {
1151        crate::vm::lib_string::open_string(self);
1152    }
1153    /// Install the `utf8` standard library (5.3+).
1154    pub fn open_utf8(&mut self) {
1155        crate::vm::lib_utf8::open_utf8(self);
1156    }
1157    /// `os` and `io` are merged because file userdata shares state with both
1158    /// (`io.tmpname` and `os.tmpname` are the same function, `io.popen`
1159    /// wraps `os.execute`'s shell).
1160    pub fn open_os_io(&mut self) {
1161        crate::vm::lib_os_io::open_os_io(self);
1162    }
1163    /// Install the `debug` standard library (introspection / hooks). Off by
1164    /// default for sandbox embedders.
1165    pub fn open_debug(&mut self) {
1166        crate::vm::lib_debug::open_debug(self);
1167    }
1168    /// Install the `coroutine` standard library.
1169    pub fn open_coroutine(&mut self) {
1170        crate::vm::lib_coroutine::open_coroutine(self);
1171    }
1172    /// `package` plus the 5.1-only `module` and `package.seeall` aliases.
1173    pub fn open_package(&mut self) {
1174        crate::vm::lib_package::open_package(self);
1175    }
1176    /// 5.2-only `bit32` library (5.3+ retired in favour of native bitwise
1177    /// ops on 64-bit integers).
1178    pub fn open_bit32(&mut self) {
1179        crate::vm::lib_bit32::open_bit32(self);
1180    }
1181
1182    /// xoshiro256** next.
1183    pub(crate) fn rng_next(&mut self) -> u64 {
1184        let s = &mut self.rng;
1185        let result = s[1].wrapping_mul(5).rotate_left(7).wrapping_mul(9);
1186        let t = s[1] << 17;
1187        s[2] ^= s[0];
1188        s[3] ^= s[1];
1189        s[1] ^= s[2];
1190        s[0] ^= s[3];
1191        s[2] ^= t;
1192        s[3] = s[3].rotate_left(45);
1193        result
1194    }
1195
1196    /// Seed the RNG via splitmix64 expansion (PUC randseed shape).
1197    pub(crate) fn rng_seed(&mut self, a: u64, b: u64) {
1198        // PUC setseed: state = [n1, 0xff, n2, 0] (0xff avoids an all-zero
1199        // state), then 16 discards to spread the seed. Matches PUC's exact
1200        // sequence so the low-level conformance test passes.
1201        self.rng = [a, 0xff, b, 0];
1202        for _ in 0..16 {
1203            self.rng_next();
1204        }
1205    }
1206
1207    /// Wall-clock since VM creation (os.clock approximation).
1208    pub(crate) fn uptime(&self) -> std::time::Duration {
1209        self.started.elapsed()
1210    }
1211
1212    /// Entropy for math.randomseed() with no arguments.
1213    pub(crate) fn rng_auto_seed(&mut self) -> (i64, i64) {
1214        let t = std::time::SystemTime::now()
1215            .duration_since(std::time::UNIX_EPOCH)
1216            .map(|d| d.as_nanos() as u64)
1217            .unwrap_or(0);
1218        let addr = &self.rng as *const _ as u64;
1219        (t as i64, addr as i64)
1220    }
1221
1222    /// Allocate a native function object (no upvalues): builtin registration.
1223    pub fn native(&mut self, f: crate::runtime::value::NativeFn) -> Value {
1224        Value::Native(self.heap.new_native(f, Box::new([])))
1225    }
1226
1227    /// Allocate a native function object with captured upvalues.
1228    pub fn native_with(
1229        &mut self,
1230        f: crate::runtime::value::NativeFn,
1231        upvals: Box<[Value]>,
1232    ) -> Value {
1233        Value::Native(self.heap.new_native(f, upvals))
1234    }
1235
1236    /// Install the shared string metatable (string library).
1237    pub fn set_string_metatable(&mut self, mt: Option<Gc<Table>>) {
1238        self.type_mt[3] = mt;
1239    }
1240
1241    /// The current globals table (`_G` / `_ENV` source for new chunks).
1242    pub fn globals(&self) -> Gc<Table> {
1243        self.globals
1244    }
1245
1246    /// Remaining VM stack slots (PUC `L->stack_last - L->top` analogue).
1247    /// Library code that pushes a known number of fresh slots — e.g.
1248    /// `table.unpack` returning N values — consults this to refuse when
1249    /// the push would blow past `LUAI_MAXSTACK`. 5.3 coroutine.lua :530's
1250    /// `for j in {lim-10, lim-5, …}` series pins this contract: the
1251    /// coroutine's already-built table eats a few slots, so an unpack of
1252    /// ~lim values can't fit.
1253    pub(crate) fn stack_room(&self) -> i64 {
1254        PUC_MAXSTACK - (self.stack.len() as i64)
1255    }
1256
1257    /// Repoint the thread's "global table" used by *future* `Vm::load` calls
1258    /// for the chunk's `_ENV` upvalue (PUC 5.1 `setfenv(0, env)` rewrites
1259    /// `L->l_gt`). Already-loaded chunks keep their own snapshot via the
1260    /// per-closure cell-0 clone in `Op::Closure`, so they are unaffected.
1261    pub(crate) fn set_globals(&mut self, env: Gc<Table>) {
1262        self.globals = env;
1263    }
1264
1265    /// The Lua dialect this VM was constructed for (5.1 / 5.2 / 5.3 / 5.4 /
1266    /// 5.5). Determines numeric semantics, available standard libraries, and
1267    /// metamethod behavior.
1268    pub fn version(&self) -> LuaVersion {
1269        self.version
1270    }
1271
1272    /// Set a global by name. `v` may be any `IntoValue`: a primitive
1273    /// (`i64`, `f64`, `bool`, `&str`, `String`, `Vec<u8>`), a `Value`
1274    /// directly, an `Option<T>`, or a `Gc<Table>` / `Gc<LuaClosure>` /
1275    /// `Gc<NativeClosure>` handle.
1276    ///
1277    /// Returns `Err(LuaError)` only if the globals table overflows
1278    /// (extremely unlikely in practice — `MAX_ASIZE = 1 << 27`).
1279    /// String interning + key construction cannot fail.
1280    ///
1281    /// ```
1282    /// # use luna_core::vm::Vm;
1283    /// # use luna_core::version::LuaVersion;
1284    /// let mut vm = Vm::sandbox(LuaVersion::Lua55).open_base().build();
1285    /// vm.set_global("answer", 42).unwrap();
1286    /// vm.set_global("ratio", 0.5_f64).unwrap();
1287    /// vm.set_global("hello", "world").unwrap();
1288    /// let r = vm.eval("return answer, ratio, hello").unwrap();
1289    /// assert_eq!(r.len(), 3);
1290    /// ```
1291    pub fn set_global<V: crate::vm::IntoValue>(
1292        &mut self,
1293        name: &str,
1294        v: V,
1295    ) -> Result<(), LuaError> {
1296        let v = v.into_value(self);
1297        let k = Value::Str(self.heap.intern(name.as_bytes()));
1298        // 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).
1299        unsafe { self.globals.as_mut() }.set(&mut self.heap, k, v)?;
1300        self.heap
1301            .barrier_back(self.globals.as_ptr() as *mut crate::runtime::heap::GcHeader);
1302        Ok(())
1303    }
1304
1305    /// Backward write barrier shorthand for native lib code: demote `t` from
1306    /// BLACK back to gray so the next propagate step re-traces its fields.
1307    /// No-op outside Propagate (parent is never BLACK at mutation time).
1308    pub(crate) fn barrier_back_table(&mut self, t: Gc<Table>) {
1309        self.heap
1310            .barrier_back(t.as_ptr() as *mut crate::runtime::heap::GcHeader);
1311    }
1312
1313    /// Forward write barrier shorthand: a closed upvalue is a single-slot
1314    /// container — `barrier_forward` is cheaper than `barrier_back` here.
1315    /// No-op outside Propagate.
1316    pub(crate) fn barrier_forward_upvalue(&mut self, uv: Gc<Upvalue>, child: Value) {
1317        self.heap
1318            .barrier_forward(uv.as_ptr() as *mut crate::runtime::heap::GcHeader, child);
1319    }
1320
1321    /// Register a MacroLua macro under `name`. Inert
1322    /// under non-MacroLua dialects (the macro is stored but the load
1323    /// path only consults the registry when
1324    /// `self.version == LuaVersion::MacroLua`).
1325    ///
1326    /// `name` is stored without the leading `@` — source code writes
1327    /// `@double(x)` to invoke a macro registered as `"double"`.
1328    pub fn define_macro(&mut self, name: &str, m: Box<dyn crate::frontend::macro_expander::Macro>) {
1329        self.macro_registry.register(name, m);
1330    }
1331
1332    /// Drop all MacroLua macros (built-in + custom).
1333    /// Mostly useful for tests.
1334    pub fn clear_macros(&mut self) {
1335        self.macro_registry.clear();
1336    }
1337
1338    /// PUC `luaL_loadfilex`: compile the file `name` (standard input when
1339    /// `None`, named `stdin`) into a function. A first line starting with
1340    /// `#` is skipped; `mode` (`"t"`, `"b"`, `"bt"`, `None` for both)
1341    /// limits the chunk to text and/or binary. The error is the message
1342    /// PUC's function leaves: `cannot open <name>: <reason>` when the file
1343    /// cannot be read, or the positioned syntax error.
1344    pub fn load_file(
1345        &mut self,
1346        name: Option<&[u8]>,
1347        mode: Option<&[u8]>,
1348    ) -> Result<Value, LuaError> {
1349        crate::vm::lib_os_io::load_path(self, name, mode).map_err(LuaError)
1350    }
1351
1352    /// PUC `luaL_loadbufferx`: compile `src` under `chunkname`, the chunk
1353    /// kind limited by `mode` as in [`Vm::load_file`]. A syntax error comes
1354    /// back as its positioned message (`<chunk id>:<line>: <message>`), the
1355    /// string `load` returns.
1356    pub fn load_buffer(
1357        &mut self,
1358        src: &[u8],
1359        chunkname: &[u8],
1360        mode: Option<&[u8]>,
1361    ) -> Result<Value, LuaError> {
1362        crate::vm::lib_os_io::load_chunk(self, src, chunkname, mode).map_err(LuaError)
1363    }
1364
1365    /// Parse + compile a chunk and close it over the globals table.
1366    pub fn load(&mut self, src: &[u8], chunkname: &[u8]) -> Result<Gc<LuaClosure>, SyntaxError> {
1367        // Reject oversize input *before* handing the parser/lexer a
1368        // potentially multi-GB slice. The PUC-shaped `not enough memory`
1369        // message keeps `heavy.lua::loadrep` compatibility: that test
1370        // accepts either `string length overflow` or `not enough memory`
1371        // as the failure mode for a feeder loop that outruns the host
1372        // allocator. See `set_loader_input_budget`.
1373        if src.len() > self.loader_input_budget {
1374            return Err(SyntaxError {
1375                line: 0,
1376                msg: b"not enough memory".to_vec(),
1377            });
1378        }
1379        // a precompiled (binary) chunk is undumped; source is parsed + compiled
1380        let is_bytecode = crate::vm::dump::is_binary_chunk(src);
1381        if is_bytecode && !self.bytecode_loading {
1382            return Err(SyntaxError {
1383                line: 0,
1384                msg: b"attempt to load a binary chunk (bytecode loading disabled)".to_vec(),
1385            });
1386        }
1387        let proto = if is_bytecode {
1388            let allow_puc = self.puc_bytecode_loading;
1389            crate::vm::dump::undump_named(src, &mut self.heap, self.version, allow_puc, chunkname)
1390                .map_err(SyntaxError::unpositioned)?
1391        } else if self.version.is_macro_lua() {
1392            // MacroLua dialect: drain the lexer into a
1393            // token vec, run the macro expander pre-pass against the
1394            // per-Vm registry, then hand the rewritten stream to
1395            // `parse_tokens`. The AST + compiler are dialect-agnostic
1396            // because by this point all `@`/quote tokens are gone.
1397            let mut lexer = crate::frontend::lexer::Lexer::new(src, self.version);
1398            let mut raw: Vec<crate::frontend::token::TokenInfo> = Vec::new();
1399            loop {
1400                let t = lexer.next_token()?;
1401                let eof = matches!(t.tok, crate::frontend::token::Token::Eof);
1402                raw.push(t);
1403                if eof {
1404                    break;
1405                }
1406            }
1407            // Drop the trailing Eof — expander operates on the body and
1408            // `parse_tokens` reinserts Eof when it runs out of tokens.
1409            raw.pop();
1410            let expanded = self.macro_registry.expand(raw)?;
1411            let depth = self.c_depth + self.pcall_depth;
1412            let parsed =
1413                crate::frontend::parser::parse_tokens_at_depth(expanded, src, self.version, depth)?;
1414            crate::compiler::compile_parsed(
1415                &parsed.chunk,
1416                &parsed.end_lines,
1417                self.version,
1418                chunkname,
1419                &mut self.heap,
1420            )?
1421        } else {
1422            // PUC's `nCcalls` counts protected calls as well
1423            let depth = self.c_depth + self.pcall_depth;
1424            let parsed = crate::frontend::parser::parse_at_depth(src, self.version, depth)?;
1425            crate::compiler::compile_parsed(
1426                &parsed.chunk,
1427                &parsed.end_lines,
1428                self.version,
1429                chunkname,
1430                &mut self.heap,
1431            )?
1432        };
1433        // PUC `lua_load` (lapi.c) only seeds the loaded closure's first
1434        // upvalue with the globals table when the closure has *exactly* one
1435        // upvalue — that's the main-chunk `_ENV` case. A dumped non-main
1436        // function with two-or-more upvalues keeps every cell at nil; the
1437        // host must use `debug.setupvalue` to wire them up. 5.2 calls.lua
1438        // :293's `assert(x() == nil)` pins this contract.
1439        let n = proto.upvals.len();
1440        let mut ups: Vec<Gc<Upvalue>> = Vec::with_capacity(n.max(1));
1441        if n == 0 {
1442            // synthetic main chunk has no declared upvalues, but the engine
1443            // still expects at least one cell so the host can probe via
1444            // `debug.upvalueid` etc. Match the historical luna shape.
1445            ups.push(
1446                self.heap
1447                    .new_upvalue(UpvalState::Closed(Value::Table(self.globals))),
1448            );
1449        } else if n == 1 {
1450            ups.push(
1451                self.heap
1452                    .new_upvalue(UpvalState::Closed(Value::Table(self.globals))),
1453            );
1454        } else {
1455            for _ in 0..n {
1456                ups.push(self.heap.new_upvalue(UpvalState::Closed(Value::Nil)));
1457            }
1458        }
1459        Ok(self.heap.new_closure(proto, ups.into_boxed_slice()))
1460    }
1461
1462    /// Compile and run `src` as an anonymous chunk; return its results.
1463    /// Source name in the traceback is `"=eval"`. Syntax errors are
1464    /// surfaced as `LuaError` carrying the formatted PUC-style message
1465    /// (interned through the heap so the error value composes with
1466    /// `pcall` / `error_text` like any runtime error).
1467    pub fn eval(&mut self, src: &str) -> Result<Vec<Value>, LuaError> {
1468        self.eval_chunk(src, "=eval")
1469    }
1470
1471    /// Render an error value for messages/tests. Non-string errors —
1472    /// `error({code=…})`, `error(42)`, etc. — collapse to a type tag
1473    /// (`"(error object is a table value)"`); embedders that need
1474    /// structured payloads should inspect `e.0` directly. Errors whose
1475    /// text starts with `"native panic:"` indicate a Rust panic
1476    /// crossed `catch_unwind` — the Vm may be inconsistent and should
1477    /// be dropped (do not reuse).
1478    pub fn error_text(&self, e: &LuaError) -> String {
1479        match e.0 {
1480            Value::Str(s) => String::from_utf8_lossy(s.as_bytes()).into_owned(),
1481            v => format!("(error object is a {} value)", v.type_name()),
1482        }
1483    }
1484
1485    /// Render an error value the way PUC's standalone `msghandler`
1486    /// does (lua.c): strings pass through, numbers stringify, and any
1487    /// other object is given a chance at its `__tostring` metamethod
1488    /// (the result must be a string) before collapsing to the
1489    /// `"(error object is a … value)"` tag. Needs `&mut self` because
1490    /// `__tostring` runs arbitrary Lua — `error_text` remains the
1491    /// non-executing variant.
1492    pub fn error_display(&mut self, e: &LuaError) -> String {
1493        match e.0 {
1494            Value::Str(s) => String::from_utf8_lossy(s.as_bytes()).into_owned(),
1495            v @ (Value::Int(_) | Value::Float(_)) => {
1496                String::from_utf8_lossy(&self.tostring_basic(v)).into_owned()
1497            }
1498            v => {
1499                let mm = self.get_mm(v, Mm::ToString);
1500                if !mm.is_nil()
1501                    && let Ok(r) = self.call_value(mm, &[v])
1502                    && let Some(Value::Str(s)) = r.first()
1503                {
1504                    return String::from_utf8_lossy(s.as_bytes()).into_owned();
1505                }
1506                format!("(error object is a {} value)", v.type_name())
1507            }
1508        }
1509    }
1510
1511    /// Call `f` with `args` in protected mode with the message handler
1512    /// `msgh`: PUC `lua_pcall(L, nargs, LUA_MULTRET, msgh)` made from a C
1513    /// function of the host's, as lua.c's `docall` does from `pmain`.
1514    ///
1515    /// `msgh` runs where the error was raised, before the stack unwinds, so
1516    /// it can take a traceback of the failing call ([`Vm::traceback`]); an
1517    /// error inside it calls it again with the new error, as in PUC. The
1518    /// returned error carries what the handler returned.
1519    ///
1520    /// The call counts as one C level on the stack, the host function
1521    /// making it: `debug.getinfo` finds it below `f`, and a traceback taken
1522    /// inside ends with `[C]: in ?` (`[C]: ?` in 5.1).
1523    pub fn call_value_with_handler(
1524        &mut self,
1525        f: Value,
1526        args: &[Value],
1527        msgh: Value,
1528    ) -> Result<Vec<Value>, LuaError> {
1529        let level = self.native(crate::vm::builtins::nat_host_xpcall);
1530        let mut call_args = Vec::with_capacity(args.len() + 2);
1531        call_args.push(f);
1532        call_args.push(msgh);
1533        call_args.extend_from_slice(args);
1534        let mut results = self.call_value(level, &call_args)?;
1535        // the protected call's `true, results...` or `false, handled error`
1536        if results.first().is_some_and(|ok| ok.truthy()) {
1537            results.remove(0);
1538            Ok(results)
1539        } else {
1540            Err(LuaError(results.get(1).copied().unwrap_or(Value::Nil)))
1541        }
1542    }
1543
1544    /// PUC `luaL_getmetafield`: the field `event` of `v`'s metatable, read
1545    /// raw; nil when `v` has no metatable or the field is absent.
1546    pub fn metafield(&mut self, v: Value, event: &str) -> Value {
1547        match self.metatable_of(v) {
1548            Some(mt) => {
1549                let key = Value::Str(self.heap.intern(event.as_bytes()));
1550                mt.get(key)
1551            }
1552            None => Value::Nil,
1553        }
1554    }
1555
1556    /// Call any callable value from the host (or from natives like pcall).
1557    pub fn call_value(&mut self, f: Value, args: &[Value]) -> Result<Vec<Value>, LuaError> {
1558        // host-level entry (no enclosing exec): drop any error state from a
1559        // prior call that propagated uncaught (`error_traceback` would
1560        // otherwise leak into the next debug.traceback call).
1561        if self.public_call_depth == 0 {
1562            self.error_traceback = None;
1563        }
1564        self.public_call_depth += 1;
1565        // JIT fast path. A host call with no args targeting a Lua
1566        // chunk whose body fits the int-arith whitelist short-circuits
1567        // the whole interpreter dispatch and runs straight through the
1568        // mmap'd native code. The lookup is one Cell::get + one match —
1569        // the slow path (compile attempt on first reach) is paid once per
1570        // Proto.
1571        if args.is_empty()
1572            && let Value::Closure(cl) = f
1573            && let Some(vs) = self.try_jit_call(cl)
1574        {
1575            self.public_call_depth -= 1;
1576            return Ok(vs);
1577        }
1578        let r = self.call_value_impl(f, args, true);
1579        self.public_call_depth -= 1;
1580        r
1581    }
1582
1583    /// Peek/populate the Proto's JIT cache slot, returning
1584    /// `Some(values)` when the cached native fn is callable for a
1585    /// zero-arg call. (Non-zero-arg dispatch is handled by
1586    /// `try_jit_call_op` from inside `begin_call`.)
1587    fn try_jit_call(&mut self, cl: Gc<LuaClosure>) -> Option<Vec<Value>> {
1588        use crate::runtime::function::JitProtoState;
1589        if !self.jit.enabled {
1590            return None;
1591        }
1592        let proto = cl.proto;
1593        if let JitProtoState::Untried = proto.jit.get() {
1594            self.populate_jit_cache(proto);
1595        }
1596        match proto.jit.get() {
1597            JitProtoState::Compiled {
1598                entry,
1599                num_args: 0,
1600                returns_one,
1601                arg_float_mask: _,
1602                arg_table_mask: _,
1603                ret_is_float,
1604                ret_is_table,
1605            } => {
1606                // 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.
1607                let f: crate::jit::IntChunkFn = unsafe { std::mem::transmute(entry) };
1608                // Install the active Vm + closure
1609                // for any Rust helper the JIT'd code may call (e.g.
1610                // `luna_jit_new_table`, `luna_jit_upval_get`) via
1611                // cranelift `Linkage::Import`. RAII clear on return.
1612                // Chunks with no upvalue reads don't touch the closure
1613                // slot, paying nothing.
1614                // Route through chunk_compiler so
1615                // the NullJitBackend path stays inert. Raw-ptr arg
1616                // avoids the &mut self borrow conflict against the
1617                // shared self.jit.chunk_compiler read.
1618                let vm_ptr: *mut Vm = self;
1619                let _jit_vm_guard = self.jit.chunk_compiler.enter(vm_ptr, Some(cl));
1620                // 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).
1621                let r = unsafe { f() };
1622                drop(_jit_vm_guard);
1623                // A JIT helper may have detected a metatable
1624                // on a table operand and parked a deopt request here.
1625                // Discard the sentinel value and return None so the caller
1626                // re-runs the call through the interpreter, which honours
1627                // __index/__newindex.
1628                if self.jit.pending_err.take().is_some() {
1629                    return None;
1630                }
1631                Some(if returns_one {
1632                    let v = if ret_is_float {
1633                        Value::Float(f64::from_bits(r as u64))
1634                    } else if ret_is_table {
1635                        Value::Table(crate::runtime::Gc::from_ptr(
1636                            r as *mut crate::runtime::Table,
1637                        ))
1638                    } else {
1639                        Value::Int(r)
1640                    };
1641                    vec![v]
1642                } else {
1643                    Vec::new()
1644                })
1645            }
1646            // Non-zero-arg Compiled state: call_value's empty-args
1647            // fast path can't drive it. Op::Call handles those.
1648            JitProtoState::Compiled { .. } | JitProtoState::Failed | JitProtoState::Untried => None,
1649        }
1650    }
1651
1652    /// Populate the cache slot. Flips `Untried` to either
1653    /// `Compiled { … }` or `Failed`; idempotent on already-populated
1654    /// states (call sites guard with a get before invoking).
1655    ///
1656    /// Consults a thread-local cross-`Vm` cache keyed by a hash of
1657    /// `proto.code`. Compiled artefacts live in the thread-local
1658    /// `JITModule` so their mmap pages outlive the `Vm`; subsequent
1659    /// `Vm`s loading the same source skip the cranelift compile step
1660    /// entirely.
1661    fn populate_jit_cache(&mut self, proto: Gc<crate::runtime::function::Proto>) {
1662        use crate::runtime::function::JitProtoState;
1663        let version = self.version();
1664        let pre53 = version <= crate::version::LuaVersion::Lua53;
1665        // 5.1 and 5.2 have no Int subtype (all numbers
1666        // are Float). The JIT's `GetUpval` ValueRead path uses this
1667        // to default-pin upvalue reads to Float without a tag check.
1668        let float_only = version <= crate::version::LuaVersion::Lua52;
1669        // Split-borrow JitState so the
1670        // trait method can take `&mut dyn JitStorage` without
1671        // double-borrowing self.jit.
1672        let jit = &mut self.jit;
1673        let storage: &mut dyn crate::jit::JitStorage = jit.storage.as_mut();
1674        match jit
1675            .chunk_compiler
1676            .try_compile(storage, proto, pre53, float_only)
1677        {
1678            crate::jit::CompileResult::Compiled {
1679                entry,
1680                num_args,
1681                returns_one,
1682                arg_float_mask,
1683                arg_table_mask,
1684                ret_is_float,
1685                ret_is_table,
1686            } => {
1687                proto.jit.set(JitProtoState::Compiled {
1688                    entry,
1689                    num_args,
1690                    returns_one,
1691                    arg_float_mask,
1692                    arg_table_mask,
1693                    ret_is_float,
1694                    ret_is_table,
1695                });
1696            }
1697            crate::jit::CompileResult::Skipped => {
1698                proto.jit.set(JitProtoState::Failed);
1699            }
1700        }
1701    }
1702
1703    /// `Op::Call` JIT fast path. Run inside `begin_call`
1704    /// before `push_frame`. Returns `true` when the call was handled
1705    /// in-place (no new Lua frame). Constraints: every arg slot must
1706    /// be `Value::Int`, the cached arity must match the call site's
1707    /// `nargs`, the host wanted-count `wanted` is honoured by
1708    /// `finish_results`. Also bails when a debug hook is armed —
1709    /// JIT'd code does not fire line / call / return hooks, so any
1710    /// active hook makes the interpreter the source of truth.
1711    fn try_jit_call_op(
1712        &mut self,
1713        cl: Gc<LuaClosure>,
1714        func_slot: u32,
1715        nargs: u32,
1716        wanted: i32,
1717    ) -> bool {
1718        use crate::runtime::function::JitProtoState;
1719        if !self.jit.enabled {
1720            return false;
1721        }
1722        // Any active debug hook means the interpreter has to run the
1723        // call so the hook gets the expected events.
1724        if self.hook.func.is_some() || self.hook.rust_func.is_some() {
1725            return false;
1726        }
1727        let proto = cl.proto;
1728        if let JitProtoState::Untried = proto.jit.get() {
1729            self.populate_jit_cache(proto);
1730        }
1731        let JitProtoState::Compiled {
1732            entry,
1733            num_args,
1734            returns_one,
1735            arg_float_mask,
1736            arg_table_mask,
1737            ret_is_float,
1738            ret_is_table,
1739        } = proto.jit.get()
1740        else {
1741            return false;
1742        };
1743        if num_args as u32 != nargs {
1744            return false;
1745        }
1746        // Pack args into i64 bit-patterns per the per-slot expected
1747        // kind. A Float-typed slot accepts Value::Float verbatim (and on
1748        // 5.1/5.2 promotes Value::Int(x) via i64 → f64); a Table-typed slot
1749        // accepts only Value::Table and passes the raw Gc ptr; an
1750        // Int-typed slot accepts only Value::Int. Any other shape
1751        // bails to the interpreter so the call's actual dynamics
1752        // (metamethod dispatch / type-coerce) take over.
1753        let mut args: [i64; crate::jit::MAX_JIT_ARITY as usize] =
1754            [0; crate::jit::MAX_JIT_ARITY as usize];
1755        // From 5.3 an integer is its own subtype: turned into a float for
1756        // a float-typed parameter, it would come back out (returned,
1757        // stored, printed) as a float. Only 5.1/5.2, where every number
1758        // is a float, may convert it.
1759        let int_as_float = self.version() <= crate::version::LuaVersion::Lua52;
1760        for i in 0..num_args as usize {
1761            let v = self.stack[(func_slot + 1) as usize + i];
1762            let want_float = (arg_float_mask >> i) & 1 == 1;
1763            let want_table = (arg_table_mask >> i) & 1 == 1;
1764            args[i] = match (want_table, want_float, v) {
1765                (true, _, Value::Table(t)) => t.as_ptr() as i64,
1766                (false, false, Value::Int(x)) => x,
1767                (false, true, Value::Float(f)) => f.to_bits() as i64,
1768                (false, true, Value::Int(x)) if int_as_float => (x as f64).to_bits() as i64,
1769                _ => return false,
1770            };
1771        }
1772        // Vm + closure pin for helpers, routed through chunk_compiler;
1773        // see the matching guard in `try_jit_call`.
1774        let vm_ptr: *mut Vm = self;
1775        let _jit_vm_guard = self.jit.chunk_compiler.enter(vm_ptr, Some(cl));
1776        // 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.
1777        let r = unsafe {
1778            match num_args {
1779                0 => (std::mem::transmute::<*const u8, crate::jit::IntChunkFn>(entry))(),
1780                1 => (std::mem::transmute::<*const u8, crate::jit::IntFn1>(entry))(args[0]),
1781                2 => {
1782                    (std::mem::transmute::<*const u8, crate::jit::IntFn2>(entry))(args[0], args[1])
1783                }
1784                3 => (std::mem::transmute::<*const u8, crate::jit::IntFn3>(entry))(
1785                    args[0], args[1], args[2],
1786                ),
1787                4 => (std::mem::transmute::<*const u8, crate::jit::IntFn4>(entry))(
1788                    args[0], args[1], args[2], args[3],
1789                ),
1790                _ => unreachable!("MAX_JIT_ARITY enforces num_args <= 4"),
1791            }
1792        };
1793        drop(_jit_vm_guard);
1794        // See matching path in `try_jit_call`. A helper
1795        // flagged a metatable on a table operand; bail to the interpreter
1796        // so `push_frame` runs the call from scratch.
1797        if self.jit.pending_err.take().is_some() {
1798            return false;
1799        }
1800        // Write result at func_slot, replacing the closure value, then
1801        // hand to finish_results to pad/truncate per the call site's
1802        // `wanted` count.
1803        if returns_one {
1804            let v = if ret_is_float {
1805                Value::Float(f64::from_bits(r as u64))
1806            } else if ret_is_table {
1807                Value::Table(crate::runtime::Gc::from_ptr(
1808                    r as *mut crate::runtime::Table,
1809                ))
1810            } else {
1811                Value::Int(r)
1812            };
1813            self.stack[func_slot as usize] = v;
1814            self.finish_results(func_slot, 1, wanted);
1815        } else {
1816            self.finish_results(func_slot, 0, wanted);
1817        }
1818        true
1819    }
1820
1821    /// `call_value` with control over the `from_c` debug boundary. A `__close`
1822    /// handler runs *within* the closing Lua frame's activation (PUC luaF_close
1823    /// invokes it inside that ci), so it is called with `from_c = false`: its
1824    /// debug parent is the closing function, not a synthetic C level.
1825    fn call_value_impl(
1826        &mut self,
1827        f: Value,
1828        args: &[Value],
1829        from_c: bool,
1830    ) -> Result<Vec<Value>, LuaError> {
1831        if self.c_depth >= MAX_C_DEPTH {
1832            // PUC `luaE_checkcstack`: at the limit the call fails; an xpcall
1833            // handler running on the error gets a tenth more room before its
1834            // own failure is "error in error handling"
1835            if self.msgh_depth == 0 {
1836                return Err(self.runerror("C stack overflow"));
1837            }
1838            if self.c_depth >= MAX_C_DEPTH / 10 * 11 {
1839                return Err(self.plain_err("error in error handling"));
1840            }
1841        }
1842        self.c_depth += 1;
1843        let func_slot = self.stack.len() as u32;
1844        self.stack.push(f);
1845        self.stack.extend_from_slice(args);
1846        self.top = self.stack.len() as u32;
1847        let r = self.call_at(func_slot, args.len() as u32, from_c);
1848        self.c_depth -= 1;
1849        if r.is_err()
1850            && self.yielding.is_none()
1851            && self.terminating.is_none()
1852            && !self.host_yield_pending
1853            && self.pending_async_native_fut.is_none()
1854        {
1855            // A `coroutine.yield` in flight raises a sentinel error to unwind the
1856            // Rust stack, but the suspended coroutine's frames/registers (which
1857            // sit at/above `func_slot`) must survive for the next resume — so we
1858            // only truncate on a real error. A self-close termination is in the
1859            // same boat: the dying thread's state is discarded wholesale.
1860            // A `host_yield_pending` cooperative yield is in
1861            // the same boat as `yielding`: the next `EvalFuture::poll`
1862            // resumes the same call, so the in-flight frames must
1863            // survive.
1864            self.stack.truncate(func_slot as usize);
1865            self.top = func_slot;
1866        }
1867        r
1868    }
1869
1870    /// Invoke `f` with the running thread marked non-yieldable for the duration
1871    /// (PUC `luaD_callnoyield`): a `coroutine.yield` inside `f` hits the C-call
1872    /// boundary and errors instead of suspending. Used by library callbacks
1873    /// (sort comparator, gsub replacement) that run via synchronous Rust
1874    /// recursion and so could not be re-entered after a yield.
1875    pub(crate) fn call_noyield(
1876        &mut self,
1877        f: Value,
1878        args: &[Value],
1879    ) -> Result<Vec<Value>, LuaError> {
1880        self.nny += 1;
1881        let r = self.call_value(f, args);
1882        self.nny -= 1;
1883        r
1884    }
1885
1886    // ---- coroutines ----
1887
1888    pub(crate) fn new_coro(&mut self, body: Value) -> Gc<Coro> {
1889        // The new coroutine inherits the creating thread's current globals
1890        // (PUC `lua_newthread`: the new state copies `g->mainthread`'s
1891        // `l_gt`). `Vm.globals` always reflects the live thread, so reading
1892        // it here picks the creator regardless of which coro is running.
1893        self.heap.new_coro(body, self.globals)
1894    }
1895
1896    /// Is `t` the thread whose context is currently live in the VM?
1897    pub(crate) fn is_current_thread(&self, t: Option<Gc<Coro>>) -> bool {
1898        match (self.current, t) {
1899            (None, None) => true,
1900            (Some(a), Some(b)) => a.ptr_eq(b),
1901            _ => false,
1902        }
1903    }
1904
1905    /// Read an open-upvalue slot from its owning thread's stack (the live VM
1906    /// stack if that thread is current, else its saved context).
1907    #[doc(hidden)]
1908    pub fn read_slot(&self, slot: u32, thread: Option<Gc<Coro>>) -> Value {
1909        let s = slot as usize;
1910        if self.is_current_thread(thread) {
1911            self.stack[s]
1912        } else {
1913            match thread {
1914                Some(co) => co.stack[s],
1915                None => self.main_ctx.as_ref().expect("main context").stack[s],
1916            }
1917        }
1918    }
1919
1920    fn write_slot(&mut self, slot: u32, thread: Option<Gc<Coro>>, v: Value) {
1921        let s = slot as usize;
1922        if self.is_current_thread(thread) {
1923            self.stack[s] = v;
1924        } else {
1925            match thread {
1926                Some(co) => {
1927                    // 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).
1928                    unsafe { co.as_mut() }.stack[s] = v;
1929                    // co.stack is traced by Coro::trace; demote co back to
1930                    // gray so propagate re-traces this slot if it was
1931                    // already black.
1932                    self.heap
1933                        .barrier_back(co.as_ptr() as *mut crate::runtime::heap::GcHeader);
1934                }
1935                None => self.main_ctx.as_mut().expect("main context").stack[s] = v,
1936            }
1937        }
1938    }
1939
1940    /// Whether `co` is the main thread's identity object.
1941    pub(crate) fn is_main_coro(&self, co: Gc<Coro>) -> bool {
1942        self.main_coro.is_some_and(|m| m.ptr_eq(co))
1943    }
1944
1945    /// The status of `co` from the caller's view. The main thread's identity
1946    /// object has no stored status — it is "running" when nothing else runs,
1947    /// else "normal" (it resumed the active coroutine).
1948    pub(crate) fn effective_coro_status(&self, co: Gc<Coro>) -> CoroStatus {
1949        if self.is_main_coro(co) {
1950            if self.current.is_none() {
1951                CoroStatus::Running
1952            } else {
1953                CoroStatus::Normal
1954            }
1955        } else {
1956            co.status
1957        }
1958    }
1959
1960    /// `coroutine.close` (PUC `lua_closethread`): run the suspended coroutine's
1961    /// pending to-be-closed `__close` handlers, then mark it dead and drop its
1962    /// context. Handlers see the coroutine's death error (if it died by error)
1963    /// or nil; an error they raise propagates out. `Ok(Some(e))` means it died
1964    /// with error `e` and no handler overrode it; `Err` means a handler raised.
1965    pub(crate) fn close_coro(&mut self, co: Gc<Coro>) -> Result<Option<Value>, LuaError> {
1966        // re-entrant close: a __close handler closed its own coroutine while the
1967        // outer close is mid-flight (its context is live). Report success and let
1968        // the outer close finish — re-entering the swap would corrupt the stack.
1969        if self.current.is_some_and(|c| c.ptr_eq(co)) {
1970            return Ok(None);
1971        }
1972        // A chain of coroutines whose `__close` handlers each close the previous
1973        // one recurses on the C stack (PUC `luaD_callnoyield` in `lua_closethread`).
1974        // The calling handler's `call_value` has already pushed `c_depth` to the
1975        // cap, so here it reads as full first — report PUC's "C stack overflow"
1976        // before the next handler call would surface the plainer "stack overflow".
1977        if self.c_depth >= MAX_C_DEPTH {
1978            return Err(self.rt_err("C stack overflow"));
1979        }
1980        // 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).
1981        let death_err = unsafe { co.as_mut() }.error_value.take();
1982        // swap the caller's live context out (into a GC-rooted home) and the
1983        // coroutine's in, mirroring resume_coro, so the __close handlers run on
1984        // the coroutine's stack while everything stays rooted.
1985        let resumer = self.current;
1986        let rctx = self.take_ctx();
1987        match resumer {
1988            Some(r) => {
1989                // 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).
1990                let m = unsafe { r.as_mut() };
1991                m.stack = rctx.stack;
1992                m.frames = rctx.frames;
1993                m.frame_ccmt = rctx.frame_ccmt;
1994                m.open_upvals = rctx.open_upvals;
1995                m.tbc = rctx.tbc;
1996                m.top = rctx.top;
1997                m.pcall_depth = rctx.pcall_depth;
1998            }
1999            None => self.main_ctx = Some(rctx),
2000        }
2001        self.load_coro_ctx(co);
2002        self.current = Some(co);
2003        // PUC `luaE_resetthread` closes with no message handler, whatever
2004        // xpcall the coroutine was suspended in
2005        let natives_base = std::mem::replace(&mut self.natives_base, self.running_natives.len());
2006        let msgh_floor = std::mem::replace(&mut self.msgh_floor, self.frames.len());
2007        let result = self.close_slots(0, death_err);
2008        self.natives_base = natives_base;
2009        self.msgh_floor = msgh_floor;
2010        // discard the (now-closed) coroutine context and restore the caller
2011        let _ = self.take_ctx();
2012        match resumer {
2013            Some(r) => {
2014                self.load_coro_ctx(r);
2015                self.current = Some(r);
2016            }
2017            None => {
2018                let m = self.main_ctx.take().expect("main context saved");
2019                self.put_ctx(m);
2020                self.current = None;
2021            }
2022        }
2023        {
2024            // 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).
2025            let m = unsafe { co.as_mut() };
2026            m.status = CoroStatus::Dead;
2027            m.stack = Vec::new();
2028            m.frames = Vec::new();
2029            m.frame_ccmt = Vec::new();
2030            m.open_upvals = Vec::new();
2031            m.tbc = Vec::new();
2032            m.top = 0;
2033            m.pcall_depth = 0;
2034            m.resume_at = None;
2035            m.error_value = None;
2036            m.error_traceback = None;
2037            m.error_levels = None;
2038        }
2039        result.map(|()| death_err)
2040    }
2041
2042    /// `coroutine.running`: the running thread plus whether it is the main one.
2043    pub(crate) fn running_thread(&self) -> (Value, bool) {
2044        match self.current {
2045            Some(co) => (Value::Coro(co), false),
2046            None => (Value::Coro(self.main_coro.expect("main coro")), true),
2047        }
2048    }
2049
2050    /// `coroutine.isyieldable([co])`: whether `co` (default: the running
2051    /// thread) can yield. The main thread never can; any other coroutine can
2052    /// unless it is dead.
2053    pub(crate) fn is_yieldable(&self, co: Option<Gc<Coro>>) -> bool {
2054        match co {
2055            Some(c) => !self.main_coro.is_some_and(|m| m.ptr_eq(c)) && c.status != CoroStatus::Dead,
2056            // the running thread can yield only outside any non-yieldable C call
2057            None => self.current.is_some() && self.nny == 0,
2058        }
2059    }
2060
2061    /// Why `coroutine.yield` may not suspend the running thread right now, as a
2062    /// PUC error message — `None` if it may. Distinguishes "not in a coroutine"
2063    /// from "inside an unyieldable C call" (sort/gsub callback).
2064    pub(crate) fn yield_barrier(&self) -> Option<&'static str> {
2065        // 5.1's pcall/xpcall are plain C calls (no continuations), so a yield
2066        // below one crosses the boundary like any other; 5.1 also has a single
2067        // wording for every case, the main thread included.
2068        // 5.1 also calls every metamethod and generic-for iterator through
2069        // `luaD_call`, which counts as a C level, so a yield from inside one
2070        // is refused as well.
2071        if self.version <= LuaVersion::Lua51 {
2072            let inside_call = self.frames.iter().enumerate().any(|(i, f)| match f {
2073                CallFrame::Cont(nc) => matches!(nc.kind, ContKind::Meta(_)),
2074                CallFrame::Lua(fr) => {
2075                    fr.tm.is_some()
2076                        || (i > 0
2077                            && self.frames[i - 1].lua().is_some_and(|c| {
2078                                let pc = (c.pc as usize).wrapping_sub(1);
2079                                c.closure
2080                                    .proto
2081                                    .code
2082                                    .get(pc)
2083                                    .is_some_and(|ins| ins.op() == Op::TForCall)
2084                            }))
2085                }
2086            });
2087            if self.current.is_none() || self.nny > 0 || self.pcall_depth > 0 || inside_call {
2088                return Some("attempt to yield across metamethod/C-call boundary");
2089            }
2090            return None;
2091        }
2092        if self.current.is_none() {
2093            Some("attempt to yield from outside a coroutine")
2094        } else if self.nny > 0 {
2095            Some("attempt to yield across a C-call boundary")
2096        } else {
2097            None
2098        }
2099    }
2100
2101    /// The coroutine whose context is currently live (`None` on the main thread).
2102    pub(crate) fn current_coro(&self) -> Option<Gc<Coro>> {
2103        self.current
2104    }
2105
2106    /// `coroutine.close()` on the *running* thread (PUC 5.5 close-self): run all
2107    /// its pending `__close` handlers, then signal termination. The handlers run
2108    /// here, in place, with the thread still non-yieldable (a yield in one hits
2109    /// the C-call boundary). The returned sentinel unwinds the Rust stack the
2110    /// way a yield does — `exec_with` propagates it past any protecting pcall
2111    /// rather than letting `unwind` catch it — and `resume_coro` turns it into a
2112    /// clean death (or, if a handler raised, the coroutine's error).
2113    pub(crate) fn close_running(&mut self) -> LuaError {
2114        let death = match self.close_slots(0, None) {
2115            Ok(()) => None,
2116            Err(e) => Some(e.0),
2117        };
2118        self.terminating = Some(death);
2119        LuaError(Value::Nil)
2120    }
2121
2122    /// `coroutine.status` as seen by the caller.
2123    pub(crate) fn coro_status_str(&self, co: Gc<Coro>) -> &'static str {
2124        match self.effective_coro_status(co) {
2125            CoroStatus::Suspended => "suspended",
2126            CoroStatus::Running => "running",
2127            CoroStatus::Normal => "normal",
2128            CoroStatus::Dead => "dead",
2129        }
2130    }
2131
2132    fn take_ctx(&mut self) -> SavedCtx {
2133        let saved = SavedCtx {
2134            stack: std::mem::take(&mut self.stack),
2135            frames: std::mem::take(&mut self.frames),
2136            frame_ccmt: std::mem::take(&mut self.frame_ccmt),
2137            open_upvals: std::mem::take(&mut self.open_upvals),
2138            tbc: std::mem::take(&mut self.tbc),
2139            top: self.top,
2140            pcall_depth: self.pcall_depth,
2141            hook: self.hook,
2142            globals: self.globals,
2143        };
2144        self.frames_resync(); // frames now empty
2145        saved
2146    }
2147
2148    fn put_ctx(&mut self, c: SavedCtx) {
2149        self.stack = c.stack;
2150        self.frames = c.frames;
2151        self.frame_ccmt = c.frame_ccmt;
2152        self.open_upvals = c.open_upvals;
2153        self.tbc = c.tbc;
2154        self.top = c.top;
2155        self.pcall_depth = c.pcall_depth;
2156        self.hook = c.hook;
2157        self.globals = c.globals;
2158        self.frames_resync(); // sync shadow to new Vec
2159    }
2160
2161    /// Move a coroutine's saved context into the live VM fields.
2162    fn load_coro_ctx(&mut self, co: Gc<Coro>) {
2163        // 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).
2164        let m = unsafe { co.as_mut() };
2165        self.stack = std::mem::take(&mut m.stack);
2166        self.frames = std::mem::take(&mut m.frames);
2167        self.frame_ccmt = std::mem::take(&mut m.frame_ccmt);
2168        self.open_upvals = std::mem::take(&mut m.open_upvals);
2169        self.tbc = std::mem::take(&mut m.tbc);
2170        self.top = m.top;
2171        self.frames_resync(); // sync shadow to coro's frames
2172        self.pcall_depth = m.pcall_depth;
2173        self.hook = m.hook;
2174        self.globals = m.globals;
2175    }
2176
2177    /// Save the live VM context back into a coroutine object.
2178    fn store_coro_ctx(&mut self, co: Gc<Coro>) {
2179        let c = self.take_ctx();
2180        // 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).
2181        let m = unsafe { co.as_mut() };
2182        m.stack = c.stack;
2183        m.frames = c.frames;
2184        m.frame_ccmt = c.frame_ccmt;
2185        m.open_upvals = c.open_upvals;
2186        m.tbc = c.tbc;
2187        m.top = c.top;
2188        m.pcall_depth = c.pcall_depth;
2189        m.hook = c.hook;
2190        m.globals = c.globals;
2191        // bulk-overwrite of every collectable field traced by Coro::trace:
2192        // demote the coro back to gray so propagate re-traces its new state.
2193        self.heap
2194            .barrier_back(co.as_ptr() as *mut crate::runtime::heap::GcHeader);
2195    }
2196
2197    /// `coroutine.resume` core: drive `co` with `args` until it yields, returns
2198    /// or errors. Ok(values) carries yielded or returned values; Err carries an
2199    /// error raised inside the coroutine (the coroutine becomes dead).
2200    pub(crate) fn resume_coro(
2201        &mut self,
2202        co: Gc<Coro>,
2203        args: Vec<Value>,
2204    ) -> Result<Vec<Value>, LuaError> {
2205        match co.status {
2206            CoroStatus::Suspended => {}
2207            CoroStatus::Dead => return Err(self.plain_err("cannot resume dead coroutine")),
2208            _ => return Err(self.plain_err("cannot resume non-suspended coroutine")),
2209        }
2210        if self.c_depth >= MAX_C_DEPTH {
2211            return Err(self.plain_err("C stack overflow"));
2212        }
2213        self.c_depth += 1;
2214        let resumer = self.current;
2215        // save the resumer's live context away
2216        let rctx = self.take_ctx();
2217        match resumer {
2218            Some(r) => {
2219                // 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).
2220                let m = unsafe { r.as_mut() };
2221                m.stack = rctx.stack;
2222                m.frames = rctx.frames;
2223                m.frame_ccmt = rctx.frame_ccmt;
2224                m.open_upvals = rctx.open_upvals;
2225                m.tbc = rctx.tbc;
2226                m.top = rctx.top;
2227                m.pcall_depth = rctx.pcall_depth;
2228                m.globals = rctx.globals;
2229                m.status = CoroStatus::Normal;
2230                m.natives = self.natives_base..self.running_natives.len();
2231                // bulk overwrite of every traced field on r — mirror
2232                // store_coro_ctx's barrier_back so propagate re-traces r.
2233                self.heap
2234                    .barrier_back(r.as_ptr() as *mut crate::runtime::heap::GcHeader);
2235            }
2236            None => self.main_ctx = Some(rctx),
2237        }
2238        // swap the coroutine in
2239        self.load_coro_ctx(co);
2240        {
2241            // 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).
2242            let m = unsafe { co.as_mut() };
2243            m.status = CoroStatus::Running;
2244            m.resumer = resumer;
2245        }
2246        // co.resumer is a traced Gc field; barrier_back covers the new
2247        // resumer reference and any future field writes during this call.
2248        self.heap
2249            .barrier_back(co.as_ptr() as *mut crate::runtime::heap::GcHeader);
2250        self.current = Some(co);
2251        let resumer_natives_base = self.natives_base;
2252        self.natives_base = self.running_natives.len();
2253        // the coroutine's own frames start a fresh reach for xpcall handlers
2254        let resumer_msgh_floor = std::mem::replace(&mut self.msgh_floor, 0);
2255        let resumer_msgh_running = self.msgh_running.take();
2256        // a coroutine that dies keeps its traceback for `debug.traceback(co)`
2257        let resumer_keeps_traceback = std::mem::replace(&mut self.keep_error_traceback, true);
2258
2259        // drive it
2260        let drive = if co.started {
2261            self.coro_continue(&args)
2262        } else {
2263            // 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).
2264            unsafe { co.as_mut() }.started = true;
2265            self.coro_first(co.body, &args)
2266        };
2267
2268        // classify: a self-close termination or a pending yield each win over
2269        // the (sentinel) error they raised to unwind the Rust stack.
2270        let (outcome, status) = if let Some(death) = self.terminating.take() {
2271            // the coroutine closed itself: it dies now, cleanly or with the
2272            // error a `__close` handler raised.
2273            match death {
2274                Some(e) => {
2275                    // 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).
2276                    unsafe { co.as_mut() }.error_value = Some(e);
2277                    self.heap
2278                        .barrier_back(co.as_ptr() as *mut crate::runtime::heap::GcHeader);
2279                    (Err(LuaError(e)), CoroStatus::Dead)
2280                }
2281                None => (Ok(Vec::new()), CoroStatus::Dead),
2282            }
2283        } else {
2284            match self.yielding.take() {
2285                Some((vals, fslot, nres)) => {
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() }.resume_at = Some((fslot, nres));
2288                    (Ok(vals), CoroStatus::Suspended)
2289                }
2290                None => {
2291                    // died: a return is clean, an error is remembered so a later
2292                    // `coroutine.close` can report it (PUC lua_closethread).
2293                    // Keep the error-point traceback (taken by `unwind` before
2294                    // popping the failing frames) so `debug.traceback(co)` on
2295                    // the dead coroutine still shows the error site, as PUC's
2296                    // untouched dead stack does (db.lua :848 family).
2297                    if drive.is_err() {
2298                        let levels = self.error_traceback.take().unwrap_or_default();
2299                        let tb =
2300                            crate::vm::callstack::traceback_from_lines(self.version, &levels, 0);
2301                        // 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).
2302                        let m = unsafe { co.as_mut() };
2303                        m.error_traceback = Some(tb);
2304                        m.error_levels = Some(levels);
2305                    }
2306                    if let Err(e) = drive {
2307                        // 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).
2308                        unsafe { co.as_mut() }.error_value = Some(e.0);
2309                        self.heap
2310                            .barrier_back(co.as_ptr() as *mut crate::runtime::heap::GcHeader);
2311                    }
2312                    (drive, CoroStatus::Dead)
2313                }
2314            }
2315        };
2316
2317        // save the coroutine's context back and restore the resumer
2318        self.natives_base = resumer_natives_base;
2319        self.msgh_floor = resumer_msgh_floor;
2320        self.msgh_running = resumer_msgh_running;
2321        self.keep_error_traceback = resumer_keeps_traceback;
2322        self.store_coro_ctx(co);
2323        // 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).
2324        unsafe { co.as_mut() }.status = status;
2325        match resumer {
2326            Some(r) => {
2327                self.load_coro_ctx(r);
2328                // 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).
2329                unsafe { r.as_mut() }.status = CoroStatus::Running;
2330                self.current = Some(r);
2331            }
2332            None => {
2333                let m = self.main_ctx.take().expect("main context saved");
2334                self.put_ctx(m);
2335                self.current = None;
2336            }
2337        }
2338        self.c_depth -= 1;
2339        outcome
2340    }
2341
2342    /// First resume: install the body function at slot 0 and run.
2343    fn coro_first(&mut self, body: Value, args: &[Value]) -> Result<Vec<Value>, LuaError> {
2344        self.stack.clear();
2345        self.stack.push(body);
2346        self.stack.extend_from_slice(args);
2347        self.top = self.stack.len() as u32;
2348        match self.begin_call(0, Some(args.len() as u32), -1, true) {
2349            Ok(true) => self.exec_with(1),
2350            Ok(false) => Ok(self.take_results(0)),
2351            Err(e) => Err(e),
2352        }
2353    }
2354
2355    /// Resume after a yield: deliver `args` as the results of the call that
2356    /// yielded, then continue the suspended thread.
2357    fn coro_continue(&mut self, args: &[Value]) -> Result<Vec<Value>, LuaError> {
2358        let (fslot, nres) = self.current.unwrap().resume_at.expect("resume point");
2359        let n = args.len() as u32;
2360        // Restore the full register window of the suspended top frame: a yield
2361        // that unwound through a native (call_value) may have left the stack
2362        // shorter than the frame needs. `base + max_stack` is what push_frame
2363        // allocates; `fslot + n` covers the delivered yield results.
2364        let frame_need = self
2365            .frames
2366            .last()
2367            .and_then(CallFrame::lua)
2368            .map(|f| (f.base + f.closure.proto.max_stack as u32) as usize)
2369            .unwrap_or(0);
2370        let need = frame_need.max((fslot + n) as usize);
2371        if self.stack.len() < need {
2372            self.stack.resize(need, Value::Nil);
2373        }
2374        for (i, &v) in args.iter().enumerate() {
2375            self.stack[fslot as usize + i] = v;
2376        }
2377        self.finish_results(fslot, n, nres);
2378        // the suspended `coroutine.yield` (a C call) now returns its resume
2379        // values: fire the matching "return" hook PUC defers until the resume.
2380        self.hook_return(true, 1, n)?;
2381        self.exec_with(1)
2382    }
2383
2384    /// `coroutine.yield`: suspend the running coroutine, recording where to
2385    /// resume. Errors if called outside a coroutine. Returns a sentinel error
2386    /// that `exec`/`resume_coro` recognise as a yield (never surfaced to Lua).
2387    pub(crate) fn do_yield(&mut self, func_slot: u32, vals: Vec<Value>) -> LuaError {
2388        let nres = self.native_nresults;
2389        self.yielding = Some((vals, func_slot, nres));
2390        // value is irrelevant: resume_coro consults `self.yielding`, not this
2391        LuaError(Value::Nil)
2392    }
2393
2394    /// Install or clear the debug hook on the running thread (`debug.sethook`
2395    /// without a thread argument). Arms the calling frame's `oldpc` to the
2396    /// sethook CALL's own pc (one less than the next-to-execute pc), mirroring
2397    /// PUC `rethook`'s `L->oldpc = pcRel(savedpc, p)` (= savedpc - code - 1) on
2398    /// native return: the very next traceexec compares against the sethook
2399    /// CALL's line. When the install statement and the following statement are
2400    /// on different source lines (db.lua :322), `changedline` fires for that
2401    /// first statement; when they share a line (db.lua :25 wrapper), they do
2402    /// not, so the wrapper line is not re-fired.
2403    pub(crate) fn install_hook(&mut self, hook: HookState) {
2404        self.hook = hook;
2405        if self.hook.line
2406            && let Some(f) = self.frames.last_mut().and_then(CallFrame::lua_mut)
2407        {
2408            f.hook_oldpc = f.pc.saturating_sub(1);
2409        }
2410    }
2411
2412    /// Install a hook on `target` (`None`/current thread → the live VM fields;
2413    /// another, suspended thread → its saved `Coro` state). PUC `debug.sethook`
2414    /// with an optional thread argument.
2415    ///
2416    /// `target == None` means "no explicit thread argument" — PUC binds that
2417    /// to `L` (the running thread). luna's live VM fields (`self.hook`,
2418    /// `self.frames`, `self.stack`) ARE the running thread's state, regardless
2419    /// of whether that's the main thread or a currently-resumed coroutine
2420    /// (save/restore happens at resume/yield boundaries via `load_coro_ctx`/
2421    /// `store_coro_ctx`). So a `None` target should always route to
2422    /// `install_hook` on the live fields. The pre-fix predicate gate
2423    /// `is_current_thread(target)` returned `false` when running inside a
2424    /// coroutine (`self.current = Some(co)`, `target = None` don't match)
2425    /// and silently dropped the hook on the floor — the install happened on
2426    /// no thread at all.
2427    pub(crate) fn set_hook(&mut self, target: Option<Gc<Coro>>, state: HookState) {
2428        if target.is_none() || self.is_current_thread(target) {
2429            self.install_hook(state);
2430        } else if let Some(co) = target {
2431            // 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).
2432            let m = unsafe { co.as_mut() };
2433            m.hook = state;
2434            if state.line
2435                && let Some(f) = m.frames.last_mut().and_then(CallFrame::lua_mut)
2436            {
2437                f.hook_oldpc = u32::MAX;
2438            }
2439            // co.hook.func is a traced Value (Coro::trace covers it); demote
2440            // co back to gray so propagate sees the new hook function.
2441            self.heap
2442                .barrier_back(co.as_ptr() as *mut crate::runtime::heap::GcHeader);
2443        }
2444    }
2445
2446    /// The hook state of `target` (`None`/current → the live VM state).
2447    pub(crate) fn get_hook(&self, target: Option<Gc<Coro>>) -> HookState {
2448        match target {
2449            t if self.is_current_thread(t) => self.hook,
2450            Some(co) => co.hook,
2451            None => self.hook,
2452        }
2453    }
2454
2455    /// Invoke the debug hook for `event` (PUC `luaD_hook`). The hook runs with
2456    /// hooks disabled (PUC clears the mask) and its results/stack growth are
2457    /// discarded so the interrupted frame's register window is untouched.
2458    /// `line` is the source line for a "line" event, `None` (→ nil) otherwise.
2459    fn run_hook(
2460        &mut self,
2461        event: &[u8],
2462        line: Option<i64>,
2463        from_native: bool,
2464    ) -> Result<(), LuaError> {
2465        // line and count events transfer no values (PUC `luaD_hook(L,
2466        // event, line, 0, 0)`); call and return hooks set theirs first
2467        if matches!(event, b"line" | b"count") {
2468            self.hook_ftransfer = 0;
2469            self.hook_ntransfer = 0;
2470        }
2471        // Rust hook fires first (no Vm reentrancy via call_value;
2472        // synchronous fn pointer call). Both Rust and Lua hooks may be
2473        // installed; both observe each event.
2474        if let Some(rh) = self.hook.rust_func {
2475            let evt = match event {
2476                b"call" => Some(RustHookEvent::Call),
2477                b"return" => Some(RustHookEvent::Return),
2478                b"tail call" | b"tail return" => Some(RustHookEvent::TailCall),
2479                b"line" => Some(RustHookEvent::Line(line.unwrap_or(0).max(0) as u32)),
2480                b"count" => Some(RustHookEvent::Count),
2481                _ => None,
2482            };
2483            if let Some(evt) = evt {
2484                let was_in_hook = self.in_hook;
2485                self.in_hook = true;
2486                rh(self, evt);
2487                self.in_hook = was_in_hook;
2488            }
2489        }
2490        let Some(hook) = self.hook.func else {
2491            return Ok(());
2492        };
2493        let saved_top = self.top;
2494        let saved_len = self.stack.len();
2495        let name = Value::Str(self.heap.intern(event));
2496        let lv = line.map_or(Value::Nil, Value::Int);
2497        self.in_hook = true;
2498        // PUC `db_sethook`'s C trampoline `hookf` sits between the engine and
2499        // the Lua hook — so `getinfo(2)` inside the hook resolves to whatever
2500        // ci sat below `hookf` (the function being hooked). When that hooked
2501        // function is native, no Lua frame for it exists in luna's `frames`;
2502        // model it as a synthetic C level by pushing the hook with
2503        // `from_c = true` (then `c_frame_name` reads the caller's call
2504        // instruction → e.g. `name = "sethook"`). When the hooked function is
2505        // Lua (its frame is still on the stack), push with `from_c = false`
2506        // so the level descent lands on it directly. The hook's own frame
2507        // carries `is_hook = true` so `getinfo(1).namewhat` reports "hook"
2508        // (PUC `CIST_HOOKED`).
2509        self.pending_is_hook = true;
2510        let r = self.call_value_impl(hook, &[name, lv], from_native);
2511        self.pending_is_hook = false;
2512        self.in_hook = false;
2513        self.stack.truncate(saved_len);
2514        self.top = saved_top;
2515        r.map(|_| ())
2516    }
2517
2518    /// Fire the "call" hook on entry to a function, if armed and not already in
2519    /// a hook (PUC clears the mask while a hook runs). PUC's transferinfo for
2520    /// a call hook is the param window: ftransfer = 1, ntransfer = nargs.
2521    /// `is_tail` selects the "tail call" event (PUC `LUA_HOOKTAILCALL`); a
2522    /// tail-call hook has no matching return hook (PUC luaD_pretailcall).
2523    fn hook_call_with(
2524        &mut self,
2525        from_native: bool,
2526        nargs: u32,
2527        is_tail: bool,
2528    ) -> Result<(), LuaError> {
2529        if self.hook.call
2530            && !self.in_hook
2531            && (self.hook.func.is_some() || self.hook.rust_func.is_some())
2532        {
2533            self.hook_ftransfer = 1;
2534            self.hook_ntransfer = nargs.min(u16::MAX as u32) as u16;
2535            // PUC 5.1 didn't distinguish tail-call events — every call,
2536            // including tail-calls, fired plain `"call"`. 5.2 introduced
2537            // the separate `"tail call"` event (mask `"c"` covers both).
2538            // 5.1 db.lua :366 pins this with `{"call","call","call","call",
2539            // "return","tail return","return","tail return"}`.
2540            let event: &[u8] = if is_tail && self.version >= LuaVersion::Lua52 {
2541                b"tail call"
2542            } else {
2543                b"call"
2544            };
2545            self.run_hook(event, None, from_native)?;
2546        }
2547        Ok(())
2548    }
2549
2550    pub(crate) fn hook_call(&mut self, from_native: bool, nargs: u32) -> Result<(), LuaError> {
2551        self.hook_call_with(from_native, nargs, false)
2552    }
2553
2554    /// Fire the "return" hook on exit from a function, if armed. ftransfer is
2555    /// the first result slot relative to the activation's func slot, ntransfer
2556    /// the number of results.
2557    pub(crate) fn hook_return(
2558        &mut self,
2559        from_native: bool,
2560        ftransfer: u32,
2561        nresults: u32,
2562    ) -> Result<(), LuaError> {
2563        if self.hook.ret
2564            && !self.in_hook
2565            && (self.hook.func.is_some() || self.hook.rust_func.is_some())
2566        {
2567            self.hook_ftransfer = ftransfer.min(u16::MAX as u32) as u16;
2568            self.hook_ntransfer = nresults.min(u16::MAX as u32) as u16;
2569            self.run_hook(b"return", None, from_native)?;
2570        }
2571        Ok(())
2572    }
2573
2574    /// PUC "tail return" event — fires once per tail call that collapsed
2575    /// into the activation now returning, *after* its own "return" event.
2576    /// 5.1 hook mask `"r"` covers both `return` and `tail return`.
2577    fn hook_tail_return(&mut self) -> Result<(), LuaError> {
2578        if self.hook.ret
2579            && !self.in_hook
2580            && (self.hook.func.is_some() || self.hook.rust_func.is_some())
2581        {
2582            self.run_hook(b"tail return", None, false)?;
2583        }
2584        Ok(())
2585    }
2586
2587    /// Call a metamethod with a single expected result.
2588    fn call_mm1(&mut self, f: Value, args: &[Value]) -> Result<Value, LuaError> {
2589        let mut r = self.call_value(f, args)?;
2590        Ok(if r.is_empty() {
2591            Value::Nil
2592        } else {
2593            r.swap_remove(0)
2594        })
2595    }
2596
2597    /// Begin a *yieldable* metamethod call from a VM instruction: `func(args…)`
2598    /// driven through the interpreter loop with a `Meta` continuation, so a
2599    /// `coroutine.yield` inside the metamethod suspends and resumes cleanly.
2600    /// On the metamethod's return the loop head runs `finish_meta(action, …)`.
2601    /// Returns to the caller with the call set up — the opcode arm must do no
2602    /// further work on the running frame and let the loop iterate. `tm` is
2603    /// the metamethod event name (e.g. "index", "add"); a Lua handler frame
2604    /// born from this call inherits it via `pending_tm`, so
2605    /// `debug.getinfo(1).namewhat == "metamethod"` and `.name == tm`
2606    /// (db.lua :878).
2607    fn begin_meta_call(
2608        &mut self,
2609        func: Value,
2610        args: &[Value],
2611        action: MetaAction,
2612        tm: &'static str,
2613    ) -> Result<(), LuaError> {
2614        let saved_top = self.top;
2615        let cont_slot = self.stack.len() as u32;
2616        self.stack.push(func);
2617        self.stack.extend_from_slice(args);
2618        self.top = self.stack.len() as u32;
2619        frames_push_sync(
2620            &mut self.frames,
2621            &mut self.frames_top,
2622            CallFrame::Cont(NativeCont {
2623                kind: ContKind::Meta(MetaCont { action, saved_top }),
2624                func_slot: cont_slot,
2625                nresults: 1,
2626            }),
2627        );
2628        let saved_tm = self.pending_tm.replace(tm);
2629        // begin_call drives a Lua metamethod through the loop (returns true) or
2630        // runs a native one inline (returns false, leaving results at cont_slot
2631        // for the loop head to pick up); either way the Meta cont resolves there.
2632        let r = self.begin_call(cont_slot, Some(args.len() as u32), 1, true);
2633        // Native callees never consumed pending_tm (push_frame is only hit on
2634        // a Lua callee); restore so it doesn't leak to a later push_frame.
2635        self.pending_tm = saved_tm;
2636        r?;
2637        Ok(())
2638    }
2639
2640    /// `R[dst] := t[key]` for a VM read opcode, resolving `__index` yieldably.
2641    fn op_index(&mut self, t: Value, key: Value, dst: u32) -> Result<(), LuaError> {
2642        // Read-time probe: a collectable key must be live at
2643        // the moment it is used. O(1) membership test against the
2644        // freed-pointer log — gc-verify diagnostic builds only; exact
2645        // under quarantining allocators (ASAN).
2646        #[cfg(feature = "gc-verify")]
2647        if matches!(key, Value::Str(_)) {
2648            let h = match key {
2649                Value::Str(s) => s.as_ptr() as usize,
2650                _ => unreachable!(),
2651            };
2652            if self.heap.recently_freed.contains(&h) {
2653                let (pc, reg_info) = match self.frames.last() {
2654                    Some(CallFrame::Lua(f)) => {
2655                        let pc = f.pc as usize;
2656                        let inst = f.closure.proto.code.get(pc.wrapping_sub(1));
2657                        (
2658                            pc,
2659                            inst.map(|i| {
2660                                format!(
2661                                    "op[pc-1]={:?} a={} b={} c={} base={}",
2662                                    i.op(),
2663                                    i.a(),
2664                                    i.b(),
2665                                    i.c(),
2666                                    f.base
2667                                )
2668                            })
2669                            .unwrap_or_default(),
2670                        )
2671                    }
2672                    _ => (0, String::new()),
2673                };
2674                panic!(
2675                    "[gc-verify] op_index READ of dead string key {h:#x} \
2676                     (gc_top {}, top {}, pc {pc}, {reg_info})",
2677                    self.gc_top, self.top,
2678                );
2679            }
2680        }
2681        match self.index_step(t, key)? {
2682            MmOut::Done(v) => self.stack[dst as usize] = v,
2683            MmOut::Mm { func, recv } => {
2684                self.begin_meta_call(func, &[recv, key], MetaAction::Store { dst }, "index")?;
2685            }
2686            MmOut::CompareSynth { .. } => unreachable!("CompareSynth from index_step"),
2687        }
2688        Ok(())
2689    }
2690
2691    /// `t[key] := v` for a VM write opcode, resolving `__newindex` yieldably.
2692    fn op_newindex(&mut self, t: Value, key: Value, v: Value) -> Result<(), LuaError> {
2693        match self.newindex_step(t, key, v)? {
2694            MmOut::Done(_) => {}
2695            MmOut::Mm { func, recv } => {
2696                self.begin_meta_call(func, &[recv, key, v], MetaAction::Discard, "newindex")?;
2697            }
2698            MmOut::CompareSynth { .. } => unreachable!("CompareSynth from newindex_step"),
2699        }
2700        Ok(())
2701    }
2702
2703    /// Apply a comparison opcode's outcome: a known boolean drives the
2704    /// conditional skip directly; a metamethod is called yieldably, its
2705    /// truthiness driving the skip on return.
2706    fn op_compare(
2707        &mut self,
2708        step: MmOut,
2709        l: Value,
2710        r: Value,
2711        k: bool,
2712        tm: &'static str,
2713    ) -> Result<(), LuaError> {
2714        match step {
2715            MmOut::Done(v) => self.cond_skip(v.truthy(), k),
2716            MmOut::Mm { func, .. } => {
2717                self.begin_meta_call(func, &[l, r], MetaAction::Compare { k, negate: false }, tm)?;
2718            }
2719            MmOut::CompareSynth { func } => {
2720                // ≤5.3 `__le` falls back to `not __lt(r, l)`; the swap and
2721                // negation are driven through `MetaAction::Compare` so the
2722                // metamethod call can yield like any other compare.
2723                self.begin_meta_call(func, &[r, l], MetaAction::Compare { k, negate: true }, "lt")?;
2724            }
2725        }
2726        Ok(())
2727    }
2728
2729    /// Complete a VM instruction whose metamethod just returned `result` (PUC
2730    /// `luaV_finishOp`). The running frame is already back on top.
2731    fn finish_meta(&mut self, action: MetaAction, result: Value) -> Result<(), LuaError> {
2732        match action {
2733            MetaAction::Store { dst } => self.stack[dst as usize] = result,
2734            MetaAction::Discard => {}
2735            MetaAction::Compare { k, negate } => {
2736                let t = if negate {
2737                    !result.truthy()
2738                } else {
2739                    result.truthy()
2740                };
2741                self.cond_skip(t, k);
2742            }
2743            MetaAction::Concat { dst, base_a } => {
2744                self.stack[dst as usize] = result;
2745                self.top = dst + 1;
2746                self.concat_run(base_a)?;
2747            }
2748        }
2749        Ok(())
2750    }
2751
2752    // ---- metatables ----
2753
2754    pub(crate) fn metatable_of(&self, v: Value) -> Option<Gc<Table>> {
2755        match v {
2756            Value::Table(t) => t.metatable(),
2757            Value::Userdata(u) => u.metatable(),
2758            v => type_mt_slot(v).and_then(|i| self.type_mt[i]),
2759        }
2760    }
2761
2762    /// Set the shared metatable for `v`'s basic type (debug.setmetatable on a
2763    /// non-table). No-op for tables (they carry their own).
2764    pub(crate) fn set_type_metatable(&mut self, v: Value, mt: Option<Gc<Table>>) {
2765        if let Some(i) = type_mt_slot(v) {
2766            self.type_mt[i] = mt;
2767        }
2768    }
2769
2770    /// The metamethod of `v` for `mm`, or nil.
2771    pub(crate) fn get_mm(&self, v: Value, mm: Mm) -> Value {
2772        match self.metatable_of(v) {
2773            Some(mt) => mt.get(Value::Str(self.mm_names[mm as usize])),
2774            None => Value::Nil,
2775        }
2776    }
2777
2778    /// PUC 5.1 `get_compTM`: a comparison metamethod (`__eq` / `__lt` / `__le`)
2779    /// only fires when both operands carry a metatable that exposes the same
2780    /// implementation. Returns the metamethod to call, or `Nil` when no
2781    /// compatible match exists. Used to honour events.lua 5.1 :262's rule
2782    /// that `c == d` (where `d` has no metatable) falls back to raw equality.
2783    pub(crate) fn get_comp_mm(&self, l: Value, r: Value, mm: Mm) -> Value {
2784        let mt1 = self.metatable_of(l);
2785        let Some(mt1) = mt1 else { return Value::Nil };
2786        let key = Value::Str(self.mm_names[mm as usize]);
2787        let tm1 = mt1.get(key);
2788        if tm1.is_nil() {
2789            return Value::Nil;
2790        }
2791        let mt2 = self.metatable_of(r);
2792        let Some(mt2) = mt2 else { return Value::Nil };
2793        if mt1.as_ptr() == mt2.as_ptr() {
2794            return tm1;
2795        }
2796        let tm2 = mt2.get(key);
2797        if tm2.is_nil() {
2798            return Value::Nil;
2799        }
2800        if tm1.raw_eq(tm2) {
2801            return tm1;
2802        }
2803        Value::Nil
2804    }
2805
2806    /// PUC `luaT_objtypename`: the type name shown in error messages. A table
2807    /// or full userdata whose metatable carries a string `__name` reports that
2808    /// (e.g. "FILE*", "My Type") instead of the bare "table"/"userdata".
2809    pub(crate) fn obj_typename(&self, v: Value) -> String {
2810        // `__name` (luaT_objtypename) arrived in 5.3
2811        if self.version >= LuaVersion::Lua53
2812            && matches!(v, Value::Table(_) | Value::Userdata(_))
2813            && let Value::Str(s) = self.get_mm(v, Mm::Name)
2814        {
2815            return String::from_utf8_lossy(s.as_bytes()).into_owned();
2816        }
2817        v.type_name().to_string()
2818    }
2819
2820    fn call_at(
2821        &mut self,
2822        func_slot: u32,
2823        nargs: u32,
2824        from_c: bool,
2825    ) -> Result<Vec<Value>, LuaError> {
2826        let depth = self.frames.len();
2827        match self.begin_call(func_slot, Some(nargs), -1, from_c) {
2828            // run until every frame the call pushed has returned: a pcall /
2829            // xpcall / __pairs continuation sits below the frame of the
2830            // function it called, and it is the continuation that produces
2831            // the call's results (`true, ...`, or `false, msg` on an error)
2832            Ok(true) => self.exec_with(depth + 1),
2833            // native completed inline; results at func_slot..top
2834            Ok(false) => Ok(self.take_results(func_slot)),
2835            // pcall / xpcall pushed their continuation and then failed to
2836            // call their function (`pcall("x")`): the continuation catches
2837            // that error, as it does in the dispatch loop
2838            Err(e)
2839                if self.frames.len() > depth
2840                    && self.yielding.is_none()
2841                    && self.terminating.is_none()
2842                    && !self.host_yield_pending
2843                    && self.pending_async_native_fut.is_none() =>
2844            {
2845                match self.unwind(e.0, depth + 1) {
2846                    Unwound::Caught => self.exec_with(depth + 1),
2847                    Unwound::CaughtReturn(vals) => Ok(vals),
2848                    Unwound::Propagated(err) => Err(err),
2849                }
2850            }
2851            Err(e) => Err(e),
2852        }
2853    }
2854
2855    /// Switch the `collectgarbage` mode, returning the previous mode name.
2856    pub(crate) fn gc_switch_mode(&mut self, new: &'static str) -> &'static str {
2857        std::mem::replace(&mut self.gc_mode, new)
2858    }
2859
2860    /// Whether the current `collectgarbage` mode is "generational" (where a
2861    /// "step" is a minor collection — a full atomic pass — rather than a paced
2862    /// incremental sweep).
2863    pub(crate) fn gc_mode_is_generational(&self) -> bool {
2864        self.gc_mode == "generational"
2865    }
2866
2867    /// Current `stepsize` pacing parameter (PUC: 0 means an unbounded step that
2868    /// completes a whole cycle at once).
2869    pub(crate) fn gc_stepsize(&self) -> i64 {
2870        self.gc_stepsize
2871    }
2872
2873    /// Set luna's collector knobs: heap growth before a new cycle (%), sweep
2874    /// work per safe point, and the default step size (0 = a step completes
2875    /// the cycle).
2876    pub(crate) fn set_gc_pacing(&mut self, pause: i64, stepmul: i64, stepsize: i64) {
2877        self.gc_pause = pause;
2878        self.gc_stepmul = stepmul;
2879        self.gc_stepsize = stepsize;
2880    }
2881
2882    /// Interpreter safe-point auto-GC: FULL incremental Propagate + adaptive
2883    /// paced sweep via `Vm::gc_step`.
2884    ///
2885    /// Running Propagate from a safe-point relies on objects being **born
2886    /// black during Propagate**: a newly allocated object never becomes
2887    /// dead-white at the atomic flip.
2888    ///
2889    /// Adaptive budget scales with heap size: 100M-object heap (heavy.lua's
2890    /// `loadrep` stress) gets a 25M-object budget so a cycle completes in
2891    /// O(SWEEP_DIVISOR) safe-points regardless of size.
2892    #[inline(always)]
2893    pub(crate) fn maybe_collect_garbage(&mut self, live_top: u32) {
2894        if self.gc_finalizing {
2895            return;
2896        }
2897        if !self.heap.gc_due() {
2898            return;
2899        }
2900        // Bare `live_top`, no `max(self.top)` widening: every frame-pop
2901        // site (`finish_results`, the Op::TailCall collapse, pcall
2902        // unwind) clears the slots it vacates, mirroring PUC's L->top
2903        // discipline.
2904        self.gc_top = live_top;
2905        // PUC stepmul: % of allocation rate. Higher = more GC work per
2906        // safe-point (lower memory, more CPU). Default 100 = `live / 4` per
2907        // step (~4 safe-points per cycle). stepmul=200 → `live / 2`, etc.
2908        const SWEEP_BASE: usize = 400; // 400 / stepmul=100 = divisor 4
2909        const MIN_BUDGET: usize = 64_000;
2910        let stepmul = self.gc_stepmul.max(1) as usize;
2911        let divisor = (SWEEP_BASE / stepmul).max(1);
2912        let budget = (self.heap.live_objects() / divisor).max(MIN_BUDGET);
2913        if self.gc_step(budget) {
2914            self.heap.rearm_gc_pause(self.gc_pause);
2915        }
2916    }
2917
2918    /// Enumerate the GC roots: first-class `Value` roots plus bare-object
2919    /// roots (open upvalues, which are not first-class Values). Shared by the
2920    /// full collector and the incremental-sweep driver so both snapshot the
2921    /// exact same live set.
2922    fn gc_roots(&self) -> (Vec<Value>, Vec<*mut GcHeader>) {
2923        let mut roots: Vec<Value> = Vec::with_capacity(self.stack.len() + 32);
2924        roots.push(Value::Table(self.globals));
2925        for mt in self.type_mt.into_iter().flatten() {
2926            roots.push(Value::Table(mt));
2927        }
2928        for &n in &self.mm_names {
2929            roots.push(Value::Str(n));
2930        }
2931        // Root the running thread's live registers (PUC marks [stack, top)).
2932        // `gc_top` is the instruction-level cursor of the last GC
2933        // safe-point: allocation safe-points set it via
2934        // `maybe_collect_garbage(live_top)`, and `begin_call` raises it
2935        // to the callee's argument top when entering a native — PUC's
2936        // `L->top = func + 1 + nargs` C-call discipline. Without that
2937        // raise, an explicit `collectgarbage()` collected with a STALE
2938        // cursor from some earlier (lower) safe-point and freed its own
2939        // caller's register-held strings
2940        // (STATUS_ACCESS_VIOLATION on Windows / ASAN heap-use-after-free
2941        // on Linux). Values stranded above the cursor stay
2942        // excluded so weak-table entries are not spuriously pinned
2943        // (gc.lua:544 suspended-coroutine collection).
2944        let live = (self.gc_top as usize).min(self.stack.len());
2945        roots.extend_from_slice(&self.stack[..live]);
2946        for cf in &self.frames {
2947            match cf {
2948                CallFrame::Lua(f) => roots.push(Value::Closure(f.closure)),
2949                CallFrame::Cont(NativeCont {
2950                    kind: ContKind::Xpcall { handler },
2951                    ..
2952                }) => roots.push(*handler),
2953                CallFrame::Cont(NativeCont {
2954                    kind: ContKind::Close(cc),
2955                    ..
2956                }) => {
2957                    // Root the error threaded through this close chain so a
2958                    // `collectgarbage()` inside a sibling `__close` handler
2959                    // does not free it before the next handler is invoked
2960                    // (PUC L->ci->u.l.errfunc / the closing_err shadow).
2961                    if let Some(e) = cc.pending {
2962                        roots.push(e);
2963                    }
2964                    if let AfterClose::ResumeUnwind { err, .. } = cc.after {
2965                        roots.push(err);
2966                    }
2967                }
2968                CallFrame::Cont(_) => {}
2969            }
2970        }
2971        if let Some(e) = self.closing_err {
2972            roots.push(e);
2973        }
2974        // Host roots — Lua-facade handles keep their referenced
2975        // values alive across calls/yields. Trace the whole vector;
2976        // unused slots (post-`unpin_all`) carry Value::Nil which the
2977        // GC ignores.
2978        for slot in &self.host_roots {
2979            // free-list slots carry Value::Nil (GC no-op)
2980            roots.push(slot.value);
2981        }
2982        // `table.sort` and similar builtins stash their working
2983        // `Vec<Value>` here so a `collectgarbage()` invoked inside the
2984        // comparator callback doesn't free strings/tables snapshotted
2985        // off the live table (sort.lua's `load(..)(); collectgarbage()`
2986        // compare regression).
2987        for buf in &self.sort_scratch {
2988            roots.extend_from_slice(buf);
2989        }
2990        // The running-natives chain holds Gc<NativeClosure>s
2991        // mid-execution. Without rooting them here, a `collectgarbage()`
2992        // invoked inside the running native (sort.lua's `load(..)();
2993        // collectgarbage()` compare callback regression) sweeps the
2994        // closure that's actively executing, leaving `nc.upvals`
2995        // dangling and the Rust local `nc` pointing at recycled memory
2996        // — the SIGSEGV pops on the very next field access or pop.
2997        for &nc in &self.running_natives {
2998            roots.push(Value::Native(nc));
2999        }
3000        // the running thread's debug hook (suspended threads root theirs via
3001        // Coro::trace / the main_ctx sweep below)
3002        if let Some(h) = self.hook.func {
3003            roots.push(h);
3004        }
3005        // the running coroutine (its saved-context fields live in the VM, but
3006        // the object itself + its resumer chain must stay reachable)
3007        if let Some(co) = self.current {
3008            roots.push(Value::Coro(co));
3009        }
3010        if let Some(mc) = self.main_coro {
3011            roots.push(Value::Coro(mc));
3012        }
3013        // debug.getregistry() and io library state
3014        if let Some(r) = self.registry {
3015            roots.push(Value::Table(r));
3016        }
3017        if let Some(mt) = self.file_mt {
3018            roots.push(Value::Table(mt));
3019        }
3020        if let Some(f) = self.io_input {
3021            roots.push(Value::Userdata(f));
3022        }
3023        if let Some(f) = self.io_output {
3024            roots.push(Value::Userdata(f));
3025        }
3026        if let Some(f) = self.io_stdin {
3027            roots.push(Value::Userdata(f));
3028        }
3029        // the main thread's saved context while a coroutine runs
3030        if let Some(m) = &self.main_ctx {
3031            roots.extend_from_slice(&m.stack);
3032            if let Some(h) = m.hook.func {
3033                roots.push(h);
3034            }
3035            for cf in &m.frames {
3036                match cf {
3037                    CallFrame::Lua(f) => roots.push(Value::Closure(f.closure)),
3038                    CallFrame::Cont(NativeCont {
3039                        kind: ContKind::Xpcall { handler },
3040                        ..
3041                    }) => roots.push(*handler),
3042                    CallFrame::Cont(_) => {}
3043                }
3044            }
3045        }
3046        let mut extra: Vec<*mut GcHeader> = self
3047            .open_upvals
3048            .iter()
3049            .map(|&(_, uv)| uv.as_ptr() as *mut GcHeader)
3050            .collect();
3051        if let Some(m) = &self.main_ctx {
3052            extra.extend(
3053                m.open_upvals
3054                    .iter()
3055                    .map(|&(_, uv)| uv.as_ptr() as *mut GcHeader),
3056            );
3057        }
3058        (roots, extra)
3059    }
3060
3061    /// Run a full collection with the VM's roots, then run any `__gc`
3062    /// finalizers the collection scheduled. A no-op (returns 0) when already
3063    /// inside a finalizer — the collector is not reentrant (PUC).
3064    pub fn collect_garbage(&mut self) -> usize {
3065        if self.gc_finalizing {
3066            return 0;
3067        }
3068        let (roots, extra) = self.gc_roots();
3069        let freed = self.heap.collect_ex(&roots, &extra);
3070        #[cfg(feature = "gc-verify")]
3071        self.verify_frame_regs_live("collect_garbage");
3072        self.run_finalizers();
3073        freed
3074    }
3075
3076    /// `gc-verify`: after a collect, every register slot the
3077    /// collector just rooted (`[0, max(gc_top, top))` — the same bound
3078    /// `gc_roots` uses) must hold a live value. A dead value inside the
3079    /// rooted range means the root snapshot and the sweep disagreed —
3080    /// a use-after-free waiting to happen. (Slots ABOVE the bound may hold
3081    /// stale dead values legitimately; the interpreter's contract is
3082    /// that it writes them before reading.)
3083    #[cfg(feature = "gc-verify")]
3084    pub(crate) fn verify_frame_regs_live(&self, ctx: &str) {
3085        let live = self.heap.debug_live_set();
3086        let header = |v: Value| -> Option<usize> {
3087            match v {
3088                Value::Str(s) => Some(s.as_ptr() as usize),
3089                Value::Table(t) => Some(t.as_ptr() as usize),
3090                Value::Closure(c) => Some(c.as_ptr() as usize),
3091                Value::Native(n) => Some(n.as_ptr() as usize),
3092                Value::Coro(c) => Some(c.as_ptr() as usize),
3093                Value::Userdata(u) => Some(u.as_ptr() as usize),
3094                _ => None,
3095            }
3096        };
3097        let bound = (self.gc_top as usize).min(self.stack.len());
3098        for i in 0..bound {
3099            if let Some(h) = header(self.stack[i])
3100                && !live.contains(&h)
3101            {
3102                panic!(
3103                    "[gc-verify] {ctx}: rooted stack slot {i} (gc_top {}, top {}) \
3104                         holds a dead value {h:#x} after collect",
3105                    self.gc_top, self.top,
3106                );
3107            }
3108        }
3109        // Diagnostic tier: a dead value ABOVE the cursor is only a bug if
3110        // that register is a named local still in scope (the interpreter
3111        // WILL read it). Cross-check against the proto's LocVar table.
3112        for (fi, cf) in self.frames.iter().enumerate() {
3113            if let CallFrame::Lua(f) = cf {
3114                let base = f.base as usize;
3115                let maxs = f.closure.proto.max_stack as usize;
3116                let hi = (base + maxs).min(self.stack.len());
3117                let pc = f.pc;
3118                for i in bound.max(base)..hi {
3119                    if let Some(h) = header(self.stack[i])
3120                        && !live.contains(&h)
3121                    {
3122                        let reg = (i - base) as u32;
3123                        if let Some(lv) = f
3124                            .closure
3125                            .proto
3126                            .locvars
3127                            .iter()
3128                            .find(|lv| lv.reg == reg && lv.start_pc <= pc && pc < lv.end_pc)
3129                        {
3130                            panic!(
3131                                "[gc-verify] {ctx}: frame {fi} IN-SCOPE LOCAL '{}' \
3132                                     (reg {reg}, abs {i}, pc {pc}, gc_top {}) holds a \
3133                                     dead value {h:#x} — live_top cursor excluded a \
3134                                     live named local",
3135                                lv.name, self.gc_top,
3136                            );
3137                        }
3138                    }
3139                }
3140            }
3141        }
3142    }
3143
3144    /// PUC 5.1 `collectgarbage` re-raised the first error a `__gc` finalizer
3145    /// threw; gc.lua's "errors during collection" probe relies on it. This
3146    /// variant runs the same cycle but propagates the captured finalizer
3147    /// error to the explicit caller.
3148    pub(crate) fn collect_garbage_propagating(&mut self) -> Result<usize, LuaError> {
3149        if self.gc_finalizing {
3150            return Ok(0);
3151        }
3152        let (roots, extra) = self.gc_roots();
3153        let freed = self.heap.collect_ex(&roots, &extra);
3154        #[cfg(feature = "gc-verify")]
3155        self.verify_frame_regs_live("collect_garbage_propagating");
3156        self.run_finalizers_or_err()?;
3157        Ok(freed)
3158    }
3159
3160    /// Whether a `__gc` finalizer is currently running (so `collectgarbage`
3161    /// should report fail rather than collect).
3162    pub(crate) fn gc_is_finalizing(&self) -> bool {
3163        self.gc_finalizing
3164    }
3165
3166    /// PUC 5.4+ default warnf: emit one piece of a warning message. `to_cont`
3167    /// = true indicates more pieces follow (concatenated until the first
3168    /// `to_cont = false` call flushes the whole line). Mirrors
3169    /// `lauxlib.c::warnfon` + `warnfcont` + `checkcontrol`:
3170    ///   * If the buffer is fresh, `to_cont` is false, and the message is
3171    ///     `@<word>`, treat as a control message — only `@on` / `@off` are
3172    ///     recognised; any other `@…` is silently ignored.
3173    ///   * Otherwise, while the state is `Off`, drop the piece; while `On`,
3174    ///     accumulate, and flush to stderr + `warn_log` on the
3175    ///     non-continuation call.
3176    pub(crate) fn emit_warn(&mut self, msg: &[u8], to_cont: bool) {
3177        if self.warn_buf.is_empty()
3178            && !to_cont
3179            && let Some(b'@') = msg.first().copied()
3180        {
3181            match &msg[1..] {
3182                b"on" => self.warn_state = WarnState::On,
3183                b"off" => self.warn_state = WarnState::Off,
3184                _ => {} // unknown control — silently ignored (PUC checkcontrol)
3185            }
3186            return;
3187        }
3188        if self.warn_state == WarnState::Off {
3189            // drop continuation pieces too — PUC `warnfoff` is the trampoline
3190            return;
3191        }
3192        self.warn_buf.extend_from_slice(msg);
3193        if !to_cont {
3194            let line = std::mem::take(&mut self.warn_buf);
3195            eprintln!("Lua warning: {}", String::from_utf8_lossy(&line));
3196            self.warn_log.push(line);
3197        }
3198    }
3199
3200    /// Drain the in-process warning log (one entry per emitted message, sans
3201    /// `"Lua warning: "` prefix and newline). For test harnesses that want to
3202    /// assert on warn output without scraping stderr.
3203    pub fn warn_log_take(&mut self) -> Vec<Vec<u8>> {
3204        std::mem::take(&mut self.warn_log)
3205    }
3206
3207    /// Arm the cooperative instruction budget. The run loop
3208    /// decrements this once per dispatch turn; on zero it raises a catchable
3209    /// `"instruction budget exceeded"` error and disarms itself so the host
3210    /// can resume with a fresh budget on the next call. `None` removes the
3211    /// cap. Pass `Some(n)` before `eval`/`call_value` for the embedder's
3212    /// short-script semantics.
3213    pub fn set_instr_budget(&mut self, budget: Option<i64>) {
3214        self.instr_budget = budget;
3215    }
3216
3217    /// Remaining instruction budget (None when unbounded).
3218    pub fn instr_budget_remaining(&self) -> Option<i64> {
3219        self.instr_budget
3220    }
3221
3222    /// Toggle the cranelift JIT. Default `true`. Sandbox embedders
3223    /// **must** disable JIT when relying on `instr_budget` — see the
3224    /// `jit_enabled` field doc for the rationale.
3225    pub fn set_jit_enabled(&mut self, enabled: bool) {
3226        self.jit.enabled = enabled;
3227    }
3228
3229    /// Current JIT enable state.
3230    pub fn jit_enabled(&self) -> bool {
3231        self.jit.enabled
3232    }
3233
3234    /// Toggle the trace JIT. Off by default. When enabled, hot
3235    /// back-edges are counted on `Proto.trace_hot_count`; once the
3236    /// counter passes `TRACE_HOT_THRESHOLD`, the dispatch loop enters
3237    /// recording mode at the back-edge target.
3238    pub fn set_trace_jit_enabled(&mut self, enabled: bool) {
3239        self.jit.trace_enabled = enabled;
3240    }
3241
3242    /// Opt-in flag for the self-link cycle catch. See field
3243    /// docs for the correctness blocker. Default `false`.
3244    pub fn set_self_link_enabled(&mut self, enabled: bool) {
3245        self.jit.self_link_enabled = enabled;
3246    }
3247
3248    /// Current state of the self-link cycle catch.
3249    pub fn self_link_enabled(&self) -> bool {
3250        self.jit.self_link_enabled
3251    }
3252
3253    #[doc(hidden)]
3254    #[deprecated(since = "3.2.0", note = "renamed to `set_self_link_enabled`")]
3255    pub fn set_p16_self_link_enabled(&mut self, enabled: bool) {
3256        self.set_self_link_enabled(enabled);
3257    }
3258
3259    #[doc(hidden)]
3260    #[deprecated(since = "3.2.0", note = "renamed to `self_link_enabled`")]
3261    pub fn p16_self_link_enabled(&self) -> bool {
3262        self.self_link_enabled()
3263    }
3264
3265    /// Current trace-JIT enable state.
3266    pub fn trace_jit_enabled(&self) -> bool {
3267        self.jit.trace_enabled
3268    }
3269
3270    /// Number of traces that have closed cleanly (looped back to the
3271    /// head PC) since this Vm was constructed. Cumulative; used by
3272    /// tests + tuning.
3273    pub fn trace_closed_count(&self) -> u64 {
3274        self.jit.counters.closed
3275    }
3276
3277    /// Number of traces that have aborted (exceeded MAX_TRACE_LEN or
3278    /// hit an un-recordable op).
3279    pub fn trace_aborted_count(&self) -> u64 {
3280        self.jit.counters.aborted
3281    }
3282
3283    /// Number of compiled traces whose close shape
3284    /// is `TraceEnd::InlineAbort` (depth>0 boundary). Such traces
3285    /// pin `dispatchable=false` because the dispatcher can't
3286    /// resume at a depth>0 PC without the matching CallFrames.
3287    /// The frame-materialisation helper could synthesise those, but
3288    /// the InlineAbort emit path isn't wired up to it.
3289    pub fn trace_inline_abort_count(&self) -> u64 {
3290        self.jit.counters.inline_abort
3291    }
3292
3293    /// See `JitCounters::dispatch_off_reasons`.
3294    pub fn trace_dispatch_off_reasons(&self) -> &[&'static str] {
3295        &self.jit.counters.dispatch_off_reasons
3296    }
3297
3298    /// See `JitCounters::compile_failed_reasons`.
3299    pub fn trace_compile_failed_reasons(&self) -> &[&'static str] {
3300        &self.jit.counters.compile_failed_reasons
3301    }
3302
3303    /// See `JitCounters::closed_lens`. Returns
3304    /// `(is_call_triggered, ops_len)` for every trace that closed.
3305    pub fn trace_closed_lens(&self) -> &[(bool, usize)] {
3306        &self.jit.counters.closed_lens
3307    }
3308
3309    /// See [`crate::vm::jit_state::JitCounters::close_cause_counts`].
3310    /// Per-reason close-cause counts (recorder-side abort/discard +
3311    /// lowerer-side dispatch_off labels) keyed by `&'static str`.
3312    pub fn trace_close_cause_counts(&self) -> &std::collections::HashMap<&'static str, u64> {
3313        &self.jit.counters.close_cause_counts
3314    }
3315
3316    /// Number of compiled traces whose
3317    /// `CompiledTrace.downrec_link` is `Some(_)` (lowerer's
3318    /// `downrec_idx_opt` arm emitted the stitch sentinel + caller-pc
3319    /// guard scaffold).
3320    pub fn trace_downrec_link_compiled_count(&self) -> u64 {
3321        self.jit.counters.downrec_link_compiled
3322    }
3323
3324    /// See
3325    /// [`crate::vm::jit_state::JitCounters::downrec_dispatched`]. Number
3326    /// of times the dispatcher's `is_downrec_sentinel` arm fired and
3327    /// classified the return as a caller-pc-guard HIT.
3328    pub fn trace_downrec_dispatched_count(&self) -> u64 {
3329        self.jit.counters.downrec_dispatched
3330    }
3331
3332    /// See
3333    /// [`crate::vm::jit_state::JitCounters::downrec_deopt`]. Number of
3334    /// times the dispatcher entered a `downrec_link`-bearing trace and
3335    /// the trace returned via the lowerer's deopt block (caller-pc
3336    /// guard MISS), or the dispatcher itself force-deopted via the
3337    /// stitch-cycle checkpoint.
3338    pub fn trace_downrec_deopt_count(&self) -> u64 {
3339        self.jit.counters.downrec_deopt
3340    }
3341
3342    /// See
3343    /// [`crate::vm::jit_state::JitCounters::multi_way_guard_emitted`].
3344    /// Number of compiled traces whose lowerer emitted a multi-way
3345    /// caller-pc guard chain (>= 2 distinct `caller_pc` candidates)
3346    /// at the `TraceEnd::DownRec` close + lifted `dispatchable = true`.
3347    pub fn trace_multi_way_guard_emitted_count(&self) -> u64 {
3348        self.jit.counters.multi_way_guard_emitted
3349    }
3350
3351    /// Number of closed traces the lowerer compiled and
3352    /// parked on `Proto.traces`. Re-records of the same head_pc are
3353    /// deduped (the second close finds the head_pc already cached
3354    /// and skips compile), so this never exceeds `trace_closed_count`.
3355    pub fn trace_compiled_count(&self) -> u64 {
3356        self.jit.counters.compiled
3357    }
3358
3359    /// Number of times the recorder captured a
3360    /// [`crate::jit::trace_types::FieldIcSnapshot`] under
3361    /// `LUNA_JIT_FIELD_IC=1`. Stays 0 on the env-default path. Used
3362    /// by the opt-in fire test to verify the env gate
3363    /// wiring round-trips end-to-end (env -> recorder -> snapshot
3364    /// -> counter -> getter -> assertion).
3365    pub fn trace_field_ic_snapshot_count(&self) -> u64 {
3366        self.jit.counters.field_ic_snapshot_captured
3367    }
3368
3369    /// Number of closed traces the lowerer rejected
3370    /// (any of the bail conditions in
3371    /// `crate::jit::trace::try_compile_trace`).
3372    pub fn trace_compile_failed_count(&self) -> u64 {
3373        self.jit.counters.compile_failed
3374    }
3375
3376    /// Number of times the dispatcher jumped into a
3377    /// compiled trace. Bumps on every entry; `trace_deopt_count`
3378    /// counts the subset where the trace returned with a parked
3379    /// `jit_pending_err`.
3380    pub fn trace_dispatched_count(&self) -> u64 {
3381        self.jit.counters.dispatched
3382    }
3383
3384    /// Number of trace entries that came back with
3385    /// `jit_pending_err` set (typically a metatable shadowed an
3386    /// index inside a helper, forcing the dispatcher to fall back
3387    /// to the interpreter without committing the trace's result).
3388    pub fn trace_deopt_count(&self) -> u64 {
3389        self.jit.counters.deopt
3390    }
3391
3392    /// Number of times the dispatcher started a side
3393    /// trace recording (an `exit_hit_counts` slot crossed
3394    /// [`crate::jit::trace::HOTEXIT_THRESHOLD`] while `active_trace`
3395    /// was None and trace JIT was enabled). Each unit is exactly one
3396    /// `start_side_trace` call; the actual compile success counts
3397    /// under [`Self::trace_compiled_count`] like any other trace.
3398    /// Probe use: distinguishes the "side-trace pipeline fired"
3399    /// signal from the "primary back-edge / call-trigger fired"
3400    /// signal without reading per-counter histograms.
3401    pub fn trace_side_trace_started_count(&self) -> u64 {
3402        self.jit.counters.side_trace_started
3403    }
3404
3405    /// Number of side-trace recordings that closed,
3406    /// compiled successfully, AND patched their parent's
3407    /// `exit_side_trace_ptrs[exit_idx]`.
3408    pub fn trace_side_trace_compiled_count(&self) -> u64 {
3409        self.jit.counters.side_trace_compiled
3410    }
3411
3412    /// Number of side traces that compiled
3413    /// successfully but were SHEDDED by the close-handler shape-
3414    /// match gate (`exit_tags_match_entry_tags`). High ratios
3415    /// vs. `trace_side_trace_compiled_count` indicate the
3416    /// architecture is shedding lots of would-be side traces;
3417    /// useful as a tuning probe for future relaxation of the
3418    /// gate or for child-IR re-specialisation against parent's
3419    /// exit shape.
3420    pub fn trace_side_trace_shape_mismatch_count(&self) -> u64 {
3421        self.jit.counters.side_trace_shape_mismatch
3422    }
3423
3424    /// Sum of NewTable sites the pre-emit escape sweep
3425    /// classified as `crate::jit::trace::EscapeState::Sinkable`
3426    /// across every successfully compiled trace on this Vm. The
3427    /// count is post-demotion: sites pre-emit drops back to Escaped
3428    /// for not meeting the sunk-emit criteria are NOT counted.
3429    /// `trace_sunk_alloc_count` matches one-for-one today (every
3430    /// surviving Sinkable site goes through sunk emit).
3431    pub fn trace_sinkable_seen_count(&self) -> u64 {
3432        self.jit.counters.sinkable_seen
3433    }
3434
3435    /// See `JitCounters::accum_bufferable_seen`.
3436    pub fn trace_accum_bufferable_seen_count(&self) -> u64 {
3437        self.jit.counters.accum_bufferable_seen
3438    }
3439
3440    /// Total dispatch hits across all known traces,
3441    /// broken into hot-exit telemetry (max single-exit count,
3442    /// total dispatches, exit count). Used by probes to identify
3443    /// hot side-exits as side-trace candidates.
3444    ///
3445    /// Walks `cl.proto` AND all nested protos in `cl.proto.protos`
3446    /// recursively, so inner functions' traces are reported.
3447    pub fn trace_exit_hit_summary(
3448        &self,
3449        cl: crate::runtime::heap::Gc<crate::runtime::function::LuaClosure>,
3450    ) -> Vec<(u32, Vec<u32>)> {
3451        fn walk(
3452            proto: crate::runtime::heap::Gc<crate::runtime::function::Proto>,
3453            out: &mut Vec<(u32, Vec<u32>)>,
3454        ) {
3455            for ct in proto.traces.borrow().iter() {
3456                let counts: Vec<u32> = ct.exit_hit_counts.iter().map(|c| c.get()).collect();
3457                out.push((ct.head_pc, counts));
3458            }
3459            for inner in proto.protos.iter() {
3460                walk(*inner, out);
3461            }
3462        }
3463        let mut out: Vec<(u32, Vec<u32>)> = Vec::new();
3464        walk(cl.proto, &mut out);
3465        out
3466    }
3467
3468    /// Surface every side-exit slot whose hit count is
3469    /// `>= HOTEXIT_THRESHOLD` across every trace reachable from
3470    /// `cl.proto` (recursively walking `proto.protos`). Returned
3471    /// entries are side-trace candidates: each carries the parent
3472    /// trace's `(head_proto, head_pc)`, the exit's index in the
3473    /// parent's `exit_hit_counts`, and the side trace's natural
3474    /// entry shape (`cont_pc` + `exit_tags`).
3475    ///
3476    /// Layout of `exit_hit_counts` (mirrored by the iter):
3477    /// - `[0..per_exit_inline.len())` → `InlineSideExit` (cont_pc +
3478    ///   window-sized exit_tags).
3479    /// - `[per_exit_inline.len()..inline.len() + per_exit_tags.len())`
3480    ///   → `per_exit_tags[i]` (per-cont_pc caller-window tags).
3481    /// - Last slot → global clean-tail (cont_pc = `head_pc`,
3482    ///   exit_tags = `ct.exit_tags`).
3483    pub fn hot_exit_iter(
3484        &self,
3485        cl: crate::runtime::heap::Gc<crate::runtime::function::LuaClosure>,
3486    ) -> Vec<crate::jit::trace::HotExitInfo> {
3487        use crate::jit::trace::{HOTEXIT_THRESHOLD, HotExitInfo};
3488        fn walk(
3489            proto: crate::runtime::heap::Gc<crate::runtime::function::Proto>,
3490            out: &mut Vec<HotExitInfo>,
3491        ) {
3492            for ct in proto.traces.borrow().iter() {
3493                let inline_n = ct.per_exit_inline.len();
3494                let tags_n = ct.per_exit_tags.len();
3495                debug_assert_eq!(
3496                    ct.exit_hit_counts.len(),
3497                    inline_n + tags_n + 1,
3498                    "exit_hit_counts layout invariant violated"
3499                );
3500                for (idx, cell) in ct.exit_hit_counts.iter().enumerate() {
3501                    let hits = cell.get();
3502                    if hits < HOTEXIT_THRESHOLD {
3503                        continue;
3504                    }
3505                    let (cont_pc, exit_tags) = if idx < inline_n {
3506                        let ent = &ct.per_exit_inline[idx];
3507                        (ent.cont_pc, ent.exit_tags.clone())
3508                    } else if idx < inline_n + tags_n {
3509                        let (pc, tags) = &ct.per_exit_tags[idx - inline_n];
3510                        (*pc, tags.clone())
3511                    } else {
3512                        (ct.head_pc, ct.exit_tags.clone())
3513                    };
3514                    out.push(HotExitInfo {
3515                        head_proto: proto,
3516                        head_pc: ct.head_pc,
3517                        exit_idx: idx,
3518                        hits,
3519                        cont_pc,
3520                        exit_tags,
3521                    });
3522                }
3523            }
3524            for inner in proto.protos.iter() {
3525                walk(*inner, out);
3526            }
3527        }
3528        let mut out: Vec<HotExitInfo> = Vec::new();
3529        walk(cl.proto, &mut out);
3530        out
3531    }
3532
3533    /// Sum of NewTable sites that actually took the
3534    /// sunk-emit path across every successfully compiled trace on
3535    /// this Vm. Each counted site skips its heap `Gc<Table>`
3536    /// allocation per dispatch; the array part lives as Cranelift
3537    /// `Variable`s for the duration of the trace.
3538    pub fn trace_sunk_alloc_count(&self) -> u64 {
3539        self.jit.counters.sunk_alloc
3540    }
3541
3542    /// Sum of materialise-helper emit sites across every
3543    /// successfully compiled trace on this Vm. Each unit is a
3544    /// (site × cmp side-exit) pair whose IR reconstructs a heap
3545    /// `Gc<Table>` from the virt slots on deopt.
3546    pub fn trace_materialize_emit_count(&self) -> u64 {
3547        self.jit.counters.materialize_emit
3548    }
3549
3550    /// Diagnostic: total `Op::Closure` ops the trace JIT
3551    /// lowered to the `luna_jit_op_closure` helper. Each emitted op
3552    /// replaces a `Heap::new_closure_inline` call on the dispatch
3553    /// path; the count is static (one per matching op per compiled
3554    /// trace), summed at compile success.
3555    pub fn trace_closure_emit_count(&self) -> u64 {
3556        self.jit.counters.closure_emit
3557    }
3558
3559    /// See
3560    /// [`crate::vm::jit_state::JitCounters::per_exit_inline_compiled`].
3561    /// Number of compiled traces whose `per_exit_inline.len() > 0`
3562    /// (depth>0 inlined cmp side-exits emitted).
3563    pub fn trace_per_exit_inline_compiled_count(&self) -> u64 {
3564        self.jit.counters.per_exit_inline_compiled
3565    }
3566
3567    /// See
3568    /// [`crate::vm::jit_state::JitCounters::per_exit_inline_dispatchable`].
3569    /// Number of compiled traces with `per_exit_inline.len() > 0` AND
3570    /// `dispatchable == true` — i.e. the count of compiled traces
3571    /// that would actually exercise the AOT chain-reloc +
3572    /// deploy-resolver path.
3573    pub fn trace_per_exit_inline_dispatchable_count(&self) -> u64 {
3574        self.jit.counters.per_exit_inline_dispatchable
3575    }
3576
3577    /// Diagnostic: max `inline_depth` ever seen on any
3578    /// `RecordedOp` pushed by the recorder. Tells tests + tuning
3579    /// whether a self-recursive function actually walked the depth
3580    /// tracker past 0. Saturates at `MAX_INLINE_DEPTH`. Persists
3581    /// across traces and Vm activations; reset only on `Vm::new`.
3582    pub fn trace_max_depth_seen(&self) -> u8 {
3583        self.jit.max_depth_seen
3584    }
3585
3586    /// Last live Lua frame (the trace head's frame at
3587    /// dispatch time). The frame-materialization helper reads `.base`
3588    /// to compute offsets for each inlined frame's window.
3589    #[doc(hidden)]
3590    pub fn jit_last_lua_frame(&self) -> Option<Frame> {
3591        match self.frames.last() {
3592            Some(CallFrame::Lua(f)) => Some(*f),
3593            _ => None,
3594        }
3595    }
3596
3597    /// Read-only borrow of the current call
3598    /// stack, for the [`crate::vm::inspect`] pure-read accessors used
3599    /// by `luna-tools` (`luna-profile`'s sampler walks this from
3600    /// inside a `Count` hook). Sibling-module scope: not part of the
3601    /// public embedder surface, but `inspect::frames_for_profile` is.
3602    #[doc(hidden)]
3603    pub(super) fn inspect_frames(&self) -> &[CallFrame] {
3604        &self.frames
3605    }
3606
3607    /// Ensure the value stack covers indices
3608    /// `[0..need)`. Extends with Nil if shorter. Called by the
3609    /// frame-materialization helper before pushing an inlined frame
3610    /// whose register window may exceed the current stack length.
3611    #[doc(hidden)]
3612    pub fn jit_ensure_stack(&mut self, need: usize) {
3613        if self.stack.len() < need {
3614            self.stack.resize(need, Value::Nil);
3615        }
3616    }
3617
3618    /// Trace JIT path for `Op::Close A`. Predicts whether
3619    /// `__close` handlers would run (any active tbc slot ≥ from
3620    /// holding a non-nil/false Value); if so, returns 1 without doing
3621    /// anything and the trace side-exits at the op, so the interpreter
3622    /// runs the handlers. Otherwise performs the safe part of close —
3623    /// `close_from(from)` to close open upvals + drop any drained tbc
3624    /// entries ≥ from — and returns 0.
3625    ///
3626    /// Returns are i64-shaped so the cranelift import sig stays
3627    /// trivial (i64 → i64 mapping).
3628    #[doc(hidden)]
3629    pub fn jit_op_close(&mut self, start_offset: u32) -> i64 {
3630        let Some(f) = self.jit_last_lua_frame() else {
3631            return 1;
3632        };
3633        let from = f.base + start_offset;
3634        let has_handler = self.tbc.iter().any(|&s| {
3635            s >= from && {
3636                let v = self.stack[s as usize];
3637                !matches!(v, Value::Nil | Value::Bool(false))
3638            }
3639        });
3640        if has_handler {
3641            self.jit.counters.deopt += 1;
3642            return 1;
3643        }
3644        self.close_from(from);
3645        // Drain any tbc entries ≥ from (they're nil/false stubs the
3646        // interpreter's drive_close would have skipped silently).
3647        while let Some(&s) = self.tbc.last() {
3648            if s < from {
3649                break;
3650            }
3651            self.tbc.pop();
3652        }
3653        0
3654    }
3655
3656    /// Spill the trace's current value for a register to
3657    /// the underlying `vm.stack[base + slot_offset]`. Required before
3658    /// an `Op::Closure` whose inner proto has an `in_stack: true`
3659    /// upval at `slot_offset` — the helper's `find_or_create_upval`
3660    /// captures a live pointer to `vm.stack[base + slot_offset]`,
3661    /// which must hold the right value at call time (trace IR's
3662    /// Variable hasn't yet been written back).
3663    ///
3664    /// Parameters arrive as i64 from the IR: `slot_offset` is the
3665    /// caller-frame register index (`u32` in practice, depth=0
3666    /// only — depth>0 Closure is not supported); `tag` is the
3667    /// `crate::runtime::value::raw` byte for the slot's RegKind;
3668    /// `raw_bits` is the trace Variable's `use_var` payload
3669    /// (i64-shaped — Float is its bit-pattern, Table/Closure is the
3670    /// raw `Gc::as_ptr` cast).
3671    #[doc(hidden)]
3672    pub fn jit_spill_stack(&mut self, slot_offset: u32, tag: u8, raw_bits: u64) {
3673        let Some(f) = self.jit_last_lua_frame() else {
3674            self.jit.pending_err =
3675                Some(self.rt_err("JIT spill: no Lua frame on jit_last_lua_frame()"));
3676            return;
3677        };
3678        let idx = (f.base as usize) + (slot_offset as usize);
3679        if self.stack.len() <= idx {
3680            self.stack.resize(idx + 1, Value::Nil);
3681        }
3682        // SAFETY: caller (trace JIT IR emit) provides matching
3683        // `(tag, raw_bits)` — same shape produced by Value::unpack.
3684        let v = unsafe {
3685            crate::runtime::Value::pack(tag, crate::runtime::value::RawVal { zero: raw_bits })
3686        };
3687        self.stack[idx] = v;
3688    }
3689
3690    /// Refresh only the raw payload of
3691    /// `vm.stack[base + slot_offset]`, preserving its existing
3692    /// `Value` tag. The caller (trace JIT Op::Concat body emit)
3693    /// uses this when the slot's `RegKind` is `Unset` (no compile-
3694    /// time tag info; commonly `Str` slots which the trace doesn't
3695    /// model). The interp's previous execution of the same op
3696    /// already populated the slot with the right tag — the trace
3697    /// only needs to swap in its current raw value.
3698    #[doc(hidden)]
3699    pub fn jit_stack_update_raw(&mut self, slot_offset: u32, raw_bits: u64) {
3700        let Some(f) = self.jit_last_lua_frame() else {
3701            return;
3702        };
3703        let idx = (f.base as usize) + (slot_offset as usize);
3704        if idx >= self.stack.len() {
3705            return;
3706        }
3707        let (tag, _) = self.stack[idx].unpack();
3708        // 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).
3709        self.stack[idx] = unsafe {
3710            crate::runtime::Value::pack(tag, crate::runtime::value::RawVal { zero: raw_bits })
3711        };
3712    }
3713
3714    /// Trace JIT path for `Op::Concat A B`.
3715    ///
3716    /// Mirrors the interp arm (this file ~L5112): `self.top =
3717    /// base + a + n; concat_run(base + a)`. Result lands at
3718    /// `vm.stack[base + a]`. Returns `0` on success, `-1` when the
3719    /// interpreter must do it (any error from `concat_run` OR
3720    /// detection that the metamethod path was taken — `concat_run`
3721    /// returns `Ok(())` after `begin_meta_call` which has pushed a Lua
3722    /// frame the trace can't safely continue past); the trace then
3723    /// side-exits at the op and the interpreter redoes it, raising the
3724    /// error or calling `__concat` itself.
3725    ///
3726    /// The frame-push detection uses `pre/post frames.len()` and
3727    /// unwinds any pushed frames first, so the exit sees a clean stack.
3728    #[doc(hidden)]
3729    pub fn jit_op_concat(&mut self, slot_offset: u32, n: i32) -> i64 {
3730        let Some(f) = self.jit_last_lua_frame() else {
3731            return -1;
3732        };
3733        let abs_a = f.base + slot_offset;
3734        self.top = abs_a + n as u32;
3735        let pre_frames = self.frames.len();
3736        let result = self.concat_run(abs_a);
3737        let post_frames = self.frames.len();
3738        // Frame-push = metamethod path taken (begin_meta_call pushed
3739        // a Lua frame). The trace can't continue past it; unwind +
3740        // deopt so interp redoes Op::Concat in the slow path.
3741        while self.frames.len() > pre_frames {
3742            frames_pop_sync(&mut self.frames, &mut self.frames_top);
3743        }
3744        if result.is_err() || post_frames > pre_frames {
3745            self.jit.counters.deopt += 1;
3746            return -1;
3747        }
3748        0
3749    }
3750
3751    /// Pop a reusable `Vec<u8>` from the JIT
3752    /// accumulator buffer pool, returning a raw pointer. The trace
3753    /// fn's IR holds this pointer in a stack slot through the loop
3754    /// and calls `jit_str_buf_extend` per iter. If the pool is
3755    /// empty, allocate fresh.
3756    ///
3757    /// Safety: the returned pointer is valid until
3758    /// `jit_str_buf_release` is called or the Vm is dropped. The
3759    /// caller MUST not retain it across `enter_jit` boundaries.
3760    #[doc(hidden)]
3761    pub fn jit_str_buf_acquire(&mut self) -> *mut Vec<u8> {
3762        let buf = self.jit.str_buf_pool.pop().unwrap_or_default();
3763        // Move into a Box so the pointer is stable until release.
3764        Box::into_raw(Box::new(buf))
3765    }
3766
3767    /// Return a previously-acquired buffer to the
3768    /// pool, dropping any excess past `jit_str_buf_pool_cap`. The
3769    /// buffer is `clear`ed (capacity retained) so the next acquire
3770    /// gets a ready-to-extend Vec.
3771    ///
3772    /// Safety: `buf` must have been returned by a prior
3773    /// `jit_str_buf_acquire` on the same Vm.
3774    #[doc(hidden)]
3775    #[allow(clippy::not_unsafe_ptr_arg_deref)] // JIT helper: `buf` round-trips through `Box::into_raw`; SAFETY documented below.
3776    pub fn jit_str_buf_release(&mut self, buf: *mut Vec<u8>) {
3777        if buf.is_null() {
3778            return;
3779        }
3780        // 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.
3781        let mut owned = unsafe { Box::from_raw(buf) };
3782        owned.clear();
3783        if self.jit.str_buf_pool.len() < self.jit.str_buf_pool_cap {
3784            self.jit.str_buf_pool.push(*owned);
3785        }
3786        // Else: drop the buffer.
3787    }
3788
3789    /// Append a LuaStr's bytes to the accumulator
3790    /// buffer. The trace IR computes the `str_ptr` (= raw bits of
3791    /// the piece slot) and passes it through; we treat it as a
3792    /// `*mut LuaStr` and append its bytes.
3793    ///
3794    /// Returns 0 on success, -1 if the piece isn't a Str (would
3795    /// trip __concat metamethod path → deopt to interp).
3796    ///
3797    /// Safety: `buf` from prior `acquire`; `str_ptr` from the
3798    /// trace's piece slot raw bits.
3799    #[doc(hidden)]
3800    #[allow(clippy::not_unsafe_ptr_arg_deref)] // JIT helper: `buf` from prior `acquire`; `str_ptr` from trace piece slot; SAFETY documented below.
3801    pub fn jit_str_buf_extend(&mut self, buf: *mut Vec<u8>, str_ptr: i64) -> i64 {
3802        if buf.is_null() || str_ptr == 0 {
3803            return -1;
3804        }
3805        // 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).
3806        let buf = unsafe { &mut *buf };
3807        let lua_str_ptr = str_ptr as *const crate::runtime::string::LuaStr;
3808        // 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).
3809        let bytes = unsafe { crate::runtime::string::bytes_of(lua_str_ptr) };
3810        buf.extend_from_slice(bytes);
3811        0
3812    }
3813
3814    /// Drain the accumulator buffer into a fresh
3815    /// `LuaStr` via `heap.intern`, returning the raw ptr bits for
3816    /// the trace to write into the accumulator slot.
3817    ///
3818    /// Returns the LuaStr ptr as i64 on success, 0 on overflow
3819    /// (the hard cap; the trace deopts).
3820    ///
3821    /// Safety: `buf` from prior `acquire`. The buffer is left
3822    /// CLEAR (drained) ready for `release`.
3823    #[doc(hidden)]
3824    #[allow(clippy::not_unsafe_ptr_arg_deref)] // JIT helper: `buf` from prior `acquire`; SAFETY documented below.
3825    pub fn jit_str_buf_intern(&mut self, buf: *mut Vec<u8>) -> i64 {
3826        if buf.is_null() {
3827            return 0;
3828        }
3829        // 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).
3830        let buf = unsafe { &mut *buf };
3831        let bytes = std::mem::take(buf);
3832        // hard cap at 256KB
3833        if bytes.len() > 256 * 1024 {
3834            return 0;
3835        }
3836        let gc = self.heap.intern(&bytes);
3837        gc.as_ptr() as i64
3838    }
3839
3840    /// Trace JIT helper for `Op::TForCall A 0 C`.
3841    ///
3842    /// Base path: copy R[A..=A+2] → R[A+4..=A+6] + `begin_call`.
3843    /// ipairs `inext` fast path at the top — skip begin_call
3844    ///     when R[A]=Native(ipairs_iter), R[A+1]=Table no-mt,
3845    ///     R[A+2]=Int.
3846    /// Batched out-ptr writeback — fill ctrl/key/val raws into
3847    ///     caller-provided buffers + return R[A+4]'s tag byte. Lets
3848    ///     emit skip 3 separate `luna_jit_stack_load` calls and 1
3849    ///     `luna_jit_stack_tag` call by reading the buffer via
3850    ///     cranelift `stack_load` IR instead. Returns -1 on deopt,
3851    ///     else R[A+4]'s tag byte | R[A+5]'s tag byte << 8 (the value's
3852    ///     tag only when `nvars >= 2`, 0 otherwise).
3853    #[doc(hidden)]
3854    #[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.
3855    pub fn jit_op_tforcall(
3856        &mut self,
3857        slot_offset: u32,
3858        nvars: i32,
3859        ctrl_out: *mut i64,
3860        key_out: *mut i64,
3861        val_out: *mut i64,
3862    ) -> i64 {
3863        let Some(f) = self.jit_last_lua_frame() else {
3864            return -1;
3865        };
3866        let abs = f.base + slot_offset;
3867        let need = (abs + 7) as usize;
3868        if self.stack.len() < need {
3869            self.stack.resize(need, Value::Nil);
3870        }
3871        // ipairs fast path
3872        let took_fast_path = if let Value::Native(n) = self.stack[abs as usize]
3873            && std::ptr::fn_addr_eq(
3874                n.f,
3875                crate::vm::builtins::ipairs_iter as crate::runtime::value::NativeFn,
3876            )
3877            && let Value::Table(t) = self.stack[(abs + 1) as usize]
3878            && t.metatable().is_none()
3879            && let Value::Int(i) = self.stack[(abs + 2) as usize]
3880        {
3881            let next_i = i.wrapping_add(1);
3882            let v = t.get_int(next_i);
3883            if v.is_nil() {
3884                self.stack[(abs + 4) as usize] = Value::Nil;
3885            } else {
3886                self.stack[(abs + 4) as usize] = Value::Int(next_i);
3887                if (nvars as usize) >= 2 {
3888                    self.stack[(abs + 5) as usize] = v;
3889                }
3890                for j in 2..nvars as usize {
3891                    let slot = abs + 4 + j as u32;
3892                    if (slot as usize) < self.stack.len() {
3893                        self.stack[slot as usize] = Value::Nil;
3894                    }
3895                }
3896            }
3897            true
3898        } else {
3899            false
3900        };
3901        if !took_fast_path {
3902            // slow path: copy R[A..=A+2] → R[A+4..=A+6], then
3903            // route through begin_call. Lua-closure iters would push
3904            // a Lua frame mid-trace → deopt.
3905            self.stack[(abs + 4) as usize] = self.stack[abs as usize];
3906            self.stack[(abs + 5) as usize] = self.stack[(abs + 1) as usize];
3907            self.stack[(abs + 6) as usize] = self.stack[(abs + 2) as usize];
3908            // the interpreter raises the call's error itself; and a native
3909            // that `begin_call` hands to the interpreter loop (pcall, xpcall,
3910            // pairs, an async native) pushes frames or parks a future
3911            // instead of returning its results here
3912            let runs_to_completion = match self.stack[abs as usize] {
3913                Value::Native(nc) => {
3914                    use crate::runtime::value::NativeFn;
3915                    !nc.is_async
3916                        && ![
3917                            nat_pcall as NativeFn,
3918                            nat_xpcall as NativeFn,
3919                            nat_host_xpcall as NativeFn,
3920                            nat_pairs as NativeFn,
3921                        ]
3922                        .iter()
3923                        .any(|&g| std::ptr::fn_addr_eq(nc.f, g))
3924                }
3925                _ => false,
3926            };
3927            if !runs_to_completion || self.begin_call(abs + 4, Some(2), nvars, false).is_err() {
3928                self.jit.counters.deopt += 1;
3929                return -1;
3930            }
3931        }
3932        // Batched writeback — fill the caller's buffers with the
3933        // raw bits of R[A+2] / R[A+4] / R[A+5] so the trace IR can
3934        // reload via cranelift `stack_load` instead of separate
3935        // `luna_jit_stack_load` helper calls.
3936        // 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).
3937        let ctrl_raw = unsafe { self.stack[(abs + 2) as usize].unpack().1.zero };
3938        let (key_tag, key_rv) = self.stack[(abs + 4) as usize].unpack();
3939        // 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).
3940        let key_raw = unsafe { key_rv.zero };
3941        let (val_tag, val_raw) = if (nvars as usize) >= 2 {
3942            let (tag, rv) = self.stack[(abs + 5) as usize].unpack();
3943            // 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).
3944            (tag, unsafe { rv.zero })
3945        } else {
3946            (0, 0u64)
3947        };
3948        // 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).
3949        unsafe {
3950            ctrl_out.write(ctrl_raw as i64);
3951            key_out.write(key_raw as i64);
3952            val_out.write(val_raw as i64);
3953        }
3954        i64::from(key_tag) | i64::from(val_tag) << 8
3955    }
3956
3957    /// Load the raw `i64` payload of
3958    /// `vm.stack[base + slot_offset]` for the active trace's head
3959    /// Lua frame. Used to reload trace IR `Variable`s after a
3960    /// helper has written to `vm.stack` directly (e.g. TForCall's
3961    /// iter results land at `R[A+4..A+4+nvars]`).
3962    #[doc(hidden)]
3963    pub fn jit_stack_load(&mut self, slot_offset: u32) -> i64 {
3964        let Some(f) = self.jit_last_lua_frame() else {
3965            return 0;
3966        };
3967        let idx = (f.base as usize) + (slot_offset as usize);
3968        if idx >= self.stack.len() {
3969            return 0;
3970        }
3971        let v = self.stack[idx];
3972        let (_, raw) = v.unpack();
3973        // 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).
3974        unsafe { raw.zero as i64 }
3975    }
3976
3977    /// Read the tag byte of
3978    /// `vm.stack[base + slot_offset]`. Used by `Op::TForLoop` emit
3979    /// to dispatch on the iterator's return-key tag at runtime
3980    /// (`raw::NIL` → loop end exit, `raw::INT` → continue, other →
3981    /// deopt).
3982    #[doc(hidden)]
3983    pub fn jit_stack_tag(&mut self, slot_offset: u32) -> u8 {
3984        let Some(f) = self.jit_last_lua_frame() else {
3985            return crate::runtime::value::raw::NIL;
3986        };
3987        let idx = (f.base as usize) + (slot_offset as usize);
3988        if idx >= self.stack.len() {
3989            return crate::runtime::value::raw::NIL;
3990        }
3991        self.stack[idx].unpack().0
3992    }
3993
3994    /// Push a Lua frame onto the call stack with
3995    /// JIT-known metadata. Used by `luna_jit_trace_materialize_frames`
3996    /// at trace side-exits to recreate the inlined call activations
3997    /// the lowerer compiled past. The contract (enforced by the
3998    /// lowerer's pre-emit pass): `cl.proto` is non-vararg,
3999    /// `nresults` is the caller's expected count (today always 1
4000    /// because the lowerer bails Op::Call C != 2), and the caller
4001    /// has already called `jit_ensure_stack` to cover
4002    /// `[0..base + cl.proto.max_stack)`.
4003    #[doc(hidden)]
4004    pub fn jit_push_inlined_frame(
4005        &mut self,
4006        cl: Gc<LuaClosure>,
4007        base: u32,
4008        pc: u32,
4009        nresults: i32,
4010    ) {
4011        self.set_frame_ccmt(0);
4012        frames_push_sync(
4013            &mut self.frames,
4014            &mut self.frames_top,
4015            CallFrame::Lua(Frame {
4016                closure: cl,
4017                base,
4018                pc,
4019                // Lua call ABI: callee R[0] sits at caller R[A+1], so
4020                // callee.base = caller.base + A + 1; func_slot is
4021                // caller.base + A = callee.base - 1.
4022                func_slot: base - 1,
4023                n_varargs: 0,
4024                nresults,
4025                hook_oldpc: u32::MAX,
4026                from_c: false,
4027                tm: None,
4028                is_hook: false,
4029                tailcalls: 0,
4030            }),
4031        );
4032    }
4033
4034    /// Toggle precompiled-chunk loading. Default `true`. Sandbox embedders
4035    /// should set to `false` so `load`/`loadstring` reject bytecode input
4036    /// (which bypasses parser limits and could exploit verifier gaps).
4037    pub fn set_bytecode_loading(&mut self, enabled: bool) {
4038        self.bytecode_loading = enabled;
4039    }
4040
4041    /// Current bytecode-loading gate state.
4042    pub fn bytecode_loading(&self) -> bool {
4043        self.bytecode_loading
4044    }
4045
4046    /// Toggle PUC `.luac` bytecode loading. Default `false` — PUC
4047    /// bytecode is a strictly larger trust surface than luna's own dump
4048    /// format (third-party toolchain bugs, malformed chunks, unknown
4049    /// opcode shapes). Enable only for trusted PUC chunks. Per-dialect
4050    /// translators live in `crate::vm::dump::puc`.
4051    pub fn set_puc_bytecode_loading(&mut self, enabled: bool) {
4052        self.puc_bytecode_loading = enabled;
4053    }
4054
4055    /// Current PUC bytecode-loading gate state.
4056    pub fn puc_bytecode_loading(&self) -> bool {
4057        self.puc_bytecode_loading
4058    }
4059
4060    /// Default loader input budget — 256 MiB.
4061    ///
4062    /// `Vm::load` and the Lua-level `load(reader, ...)` both refuse
4063    /// sources whose byte length crosses this cap, returning the
4064    /// PUC-shaped `not enough memory` error rather than letting the
4065    /// host allocator try (and crash) to hold the next chunk.
4066    pub const DEFAULT_LOADER_INPUT_BUDGET: usize = 256 * 1024 * 1024;
4067
4068    /// Set the loader input byte budget (see
4069    /// [`Vm::DEFAULT_LOADER_INPUT_BUDGET`]). Pass `usize::MAX` to
4070    /// effectively disable. Smaller caps are honored verbatim — a 0
4071    /// cap rejects every non-empty source.
4072    pub fn set_loader_input_budget(&mut self, bytes: usize) {
4073        self.loader_input_budget = bytes;
4074    }
4075
4076    /// Current loader input byte budget.
4077    pub fn loader_input_budget(&self) -> usize {
4078        self.loader_input_budget
4079    }
4080
4081    /// Take the error traceback captured at the latest error point and
4082    /// reset it. Embedders should call this immediately after a failed
4083    /// `call_value`/`eval`/`call`/etc. — the next public `call_value`
4084    /// entry clears it. Returns `None` if no error was in flight.
4085    pub fn take_error_traceback(&mut self) -> Option<String> {
4086        let levels = self.error_traceback.take()?;
4087        let tb = crate::vm::callstack::traceback_from_lines(self.version, &levels, 0);
4088        Some(String::from_utf8_lossy(&tb).into_owned())
4089    }
4090
4091    /// PUC `luaL_traceback(L, L, msg, level)` on the running thread: `msg`
4092    /// (when given) and a newline, then `stack traceback:` and one line per
4093    /// stack level from `level` on, level 0 being the running function (the
4094    /// native calling this, when a native does).
4095    pub fn traceback(&mut self, msg: Option<&[u8]>, level: i64) -> Vec<u8> {
4096        let mut out = match msg {
4097            Some(m) => {
4098                let mut out = m.to_vec();
4099                out.push(b'\n');
4100                out
4101            }
4102            None => Vec::new(),
4103        };
4104        out.extend_from_slice(b"stack traceback:");
4105        out.extend(self.traceback_lines(None, level));
4106        out
4107    }
4108
4109    /// Arm the soft memory cap. The run loop checks the
4110    /// heap's tracked byte usage between dispatch turns; on overshoot it
4111    /// first runs a full collect, and if `bytes` still exceeds the cap it
4112    /// raises a catchable `"memory cap exceeded"` Lua error and disarms
4113    /// itself (fire-once: re-arm before the next `call_value` if reusing
4114    /// the Vm across requests). `None` removes the cap. The accounting is
4115    /// approximate — internal Vec/Box capacity overhead is not tracked,
4116    /// so embedders should size the cap with ~2× margin over the desired
4117    /// hard limit and additionally bound the Vm's lifetime (drop after
4118    /// each request).
4119    pub fn set_memory_cap(&mut self, cap: Option<usize>) {
4120        self.heap.mem_cap = cap;
4121    }
4122
4123    /// Approximate bytes the heap is currently holding. Object shells plus
4124    /// every table's internal array/hash boxes (tracked via
4125    /// `Heap::apply_bytes_delta` in `set`/`rehash`/`ensure_*`). Proto
4126    /// bytecode and closure upvalue slices still go uncounted — this is a
4127    /// lower bound, not a precise `malloc_stats`-style total.
4128    pub fn memory_used(&self) -> usize {
4129        self.heap.bytes()
4130    }
4131
4132    /// Read upvalue slot `i` of the native function currently on top of the
4133    /// dispatch chain (the one whose body is executing). Returns `Value::Nil`
4134    /// when no native is running. Public so the C ABI trampoline can fetch
4135    /// the host C function pointer it stashed there at registration time.
4136    pub fn running_native_upvalue(&self, i: usize) -> Value {
4137        match self.running_natives.last() {
4138            // 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).
4139            Some(nc) => unsafe {
4140                let upvals = &(*nc.as_ptr()).upvals;
4141                upvals.get(i).copied().unwrap_or(Value::Nil)
4142            },
4143            None => Value::Nil,
4144        }
4145    }
4146
4147    /// Register a table for finalization if its (just-set) metatable carries a
4148    /// `__gc` metamethod (PUC luaC_checkfinalizer at setmetatable time — adding
4149    /// `__gc` to the metatable afterwards does not retroactively register).
4150    pub(crate) fn check_finalizer(&mut self, t: Gc<Table>) {
4151        // Tables gained finalizers in 5.2; PUC 5.1 runs `__gc` for userdata only.
4152        if self.version == crate::version::LuaVersion::Lua51 {
4153            return;
4154        }
4155        if !self.get_mm(Value::Table(t), Mm::Gc).is_nil() {
4156            self.heap.register_finalizable(t);
4157        }
4158    }
4159
4160    /// Same as [`Self::check_finalizer`] for a userdata. PUC 5.1 attaches the
4161    /// finalizer to the proxy produced by `newproxy(true)` once its metatable
4162    /// gains `__gc`. gc.lua's "testing userdata" section sets `__gc` on the
4163    /// metatable that `newproxy` returned, which then needs to flow through.
4164    /// Kept available for the future 5.2+ `lua_setmetatable` path (which
4165    /// would re-check at metatable-set time); luna's only userdata
4166    /// finalizables today come via `newproxy`, which registers itself.
4167    #[allow(dead_code)]
4168    pub(crate) fn check_finalizer_userdata(&mut self, u: Gc<crate::runtime::Userdata>) {
4169        if !self.get_mm(Value::Userdata(u), Mm::Gc).is_nil() {
4170            self.heap.register_finalizable_userdata(u);
4171        }
4172    }
4173
4174    /// Run pending `__gc` finalizers (objects the collector resurrected for
4175    /// finalization). Finalizer errors are swallowed — PUC turns them into a
4176    /// warning; they must never propagate to the mutator. Reentrancy-guarded.
4177    fn run_finalizers(&mut self) {
4178        let _ = self.run_finalizers_or_err();
4179    }
4180
4181    fn run_finalizers_or_err(&mut self) -> Result<(), LuaError> {
4182        if self.gc_finalizing {
4183            return Ok(());
4184        }
4185        let pending = self.heap.take_tobefnz();
4186        if pending.is_empty() {
4187            return Ok(());
4188        }
4189        self.gc_finalizing = true;
4190        let mut first_err: Option<LuaError> = None;
4191        for obj in pending {
4192            let gc = self.get_mm(obj, Mm::Gc);
4193            // PUC 5.2+ accepts any non-nil `__gc` at setmetatable time to
4194            // schedule the object for finalization (`__gc = true` is the
4195            // canonical placeholder); only call it at finalize time when it
4196            // is actually a function. gc.lua 5.2 :412 wires up exactly this
4197            // sentinel and then expects no call.
4198            let callable = matches!(gc, Value::Closure(_) | Value::Native(_));
4199            if callable {
4200                // PUC `GCTM` sets `CIST_FIN` on the new ci so
4201                // `funcnamefromfinalizer` reports `namewhat = "metamethod"`,
4202                // `name = "__gc"`. luna threads the same outcome through the
4203                // generic `pending_tm` slot: the Lua frame born from this
4204                // call consumes it in `push_frame`. Saved/restored around the
4205                // call in case the handler is a native (which never pops it).
4206                // Bare event name; `frame_name` / `c_frame_name` add the
4207                // `"__"` debug prefix for 5.2/5.3, drop it for 5.4+. Matches
4208                // the convention used by `__close`, `__index`, …
4209                let saved_tm = self.pending_tm.replace("gc");
4210                // PUC `GCTM` runs the finalizer with `luaD_pcall` and no
4211                // message handler
4212                if let Err(e) = self.call_protected(gc, &[obj]) {
4213                    // PUC 5.1 GCTM raised the finalizer's error to the
4214                    // explicit `collectgarbage()` caller (`gc.lua 5.1 :255`
4215                    // baselines on `not pcall(collectgarbage)`). 5.2/5.3
4216                    // wrapped it in `error in __gc metamethod (msg)` first
4217                    // (`callGCTM` → `luaG_runerror`) but still raised. 5.4
4218                    // introduced the warning system and switched to "warn
4219                    // then continue" — never re-raise, just route the
4220                    // wrapped message through `warn`. gc.lua 5.5 :378 wires
4221                    // up `_WARN` capture under the `if T then …` block to
4222                    // baseline on the same wrapped string.
4223                    if self.version >= LuaVersion::Lua54 {
4224                        let inner = self.error_text(&e);
4225                        let msg = format!("error in __gc metamethod ({inner})");
4226                        self.emit_warn(msg.as_bytes(), false);
4227                    } else if first_err.is_none() {
4228                        let wrapped = if self.version >= LuaVersion::Lua52 {
4229                            let inner = self.error_text(&e);
4230                            let msg = format!("error in __gc metamethod ({inner})");
4231                            let s = Value::Str(self.heap.intern(msg.as_bytes()));
4232                            LuaError(s)
4233                        } else {
4234                            e
4235                        };
4236                        first_err = Some(wrapped);
4237                    }
4238                }
4239                self.pending_tm = saved_tm;
4240            }
4241        }
4242        self.gc_finalizing = false;
4243        match first_err {
4244            Some(e) => Err(e),
4245            None => Ok(()),
4246        }
4247    }
4248
4249    /// Drive one incremental GC step (PUC `collectgarbage("step", n)`).
4250    /// Crosses up to three phases per call:
4251    ///   1. Pause      → seed Propagate (`gc_start_propagate`)
4252    ///   2. Propagate  → drain gray up to `budget`; on exhaustion run atomic
4253    ///                   (`gc_finish_atomic` → tobefnz populated; finalizers
4254    ///                   run via `run_finalizers`) and enter Sweep
4255    ///   3. Sweep      → `gc_sweep_step` up to (residual) `budget`
4256    /// Returns true when this call completed the cycle's sweep (back to
4257    /// Pause). The budget is spent generously across phases — a large `n`
4258    /// can finish a whole cycle in one call (PUC stop-the-world step).
4259    pub(crate) fn gc_step(&mut self, budget: usize) -> bool {
4260        // Re-entry guard: never recurse — `run_finalizers` calls Lua code
4261        // that may hit a safe point and try to step again. Re-entry was OK
4262        // under STW (collect_garbage had its own guard) but here the
4263        // intermediate phase state would corrupt.
4264        if self.gc_finalizing {
4265            return false;
4266        }
4267        if self.heap.gc_phase_is_pause() {
4268            let (roots, extra) = self.gc_roots();
4269            self.heap.gc_start_propagate(&roots, &extra);
4270        }
4271        if self.heap.gc_phase_is_propagate() {
4272            if !self.heap.gc_step_propagate(budget) {
4273                return false;
4274            }
4275            self.heap.gc_finish_atomic();
4276            // any __gc scheduled by atomic — run before sweep so a finalizer
4277            // re-registering `self` re-enters the next cycle, not this sweep
4278            self.run_finalizers();
4279        }
4280        // either we just transitioned, or we entered already in Sweep, or
4281        // a finalizer started a new cycle (gc_sweep_step is a no-op then)
4282        self.heap.gc_sweep_step(budget)
4283    }
4284
4285    // ---- frames & calls ----
4286
4287    /// Begin calling stack[func_slot] with `nargs` (None: up to self.top).
4288    /// Returns true if a Lua frame was pushed (the dispatch loop continues
4289    /// there), false if a native completed inline.
4290    fn begin_call(
4291        &mut self,
4292        func_slot: u32,
4293        nargs: Option<u32>,
4294        nresults: i32,
4295        from_c: bool,
4296    ) -> Result<bool, LuaError> {
4297        let mut nargs = match nargs {
4298            Some(n) => n,
4299            None => self.top - (func_slot + 1),
4300        };
4301        // Consume `pending_is_tail` at the boundary: a tail-call op sets it
4302        // only for the immediately-following Lua activation. Native dispatch
4303        // (or `__call` resolution) below must not let it leak to the next
4304        // begin_call's frame; restore it just before push_frame for the Lua
4305        // arm so its meaning is preserved across __call chaining.
4306        let tailcalls = std::mem::take(&mut self.pending_tailcalls);
4307        let tail_ccmt = std::mem::take(&mut self.pending_ccmt);
4308        // resolve __call handlers iteratively (PUC tryfuncTM loop): each handler
4309        // is inserted before the value so it becomes the first argument, and a
4310        // chain of `__call` tables resolves down to a real function.
4311        let mut chain = 0u32;
4312        loop {
4313            match self.stack[func_slot as usize] {
4314                Value::Closure(cl) => {
4315                    // JIT fast path: if the Proto's body fits
4316                    // the int-arith whitelist, every arg is `Value::Int`,
4317                    // and the cached arity matches, skip frame setup and
4318                    // run the cached native fn in-place.
4319                    if self.try_jit_call_op(cl, func_slot, nargs, nresults) {
4320                        self.pending_tailcalls = tailcalls;
4321                        return Ok(false);
4322                    }
4323                    self.pending_tailcalls = tailcalls;
4324                    self.pending_ccmt = if tailcalls > 0 {
4325                        tail_ccmt
4326                    } else {
4327                        chain as u8
4328                    };
4329                    self.push_frame(cl, func_slot, nargs, nresults, from_c)?;
4330                    // Trace-on-call trigger. The frame
4331                    // we just pushed is the callee whose body the
4332                    // recorder will trace. Bump the per-Proto call
4333                    // counter; once it crosses `CALL_HOT_THRESHOLD`
4334                    // and no other trace is in flight, snapshot the
4335                    // callee's register window (R[0..max_stack]) and
4336                    // begin recording at `pc=0`. This is what unlocks
4337                    // tracing for functions whose body has no negative
4338                    // `Op::Jmp` back-edge (`fib`, recursive helpers).
4339                    //
4340                    // Gated on `trace_jit_enabled`, so the default
4341                    // dispatch pays a single not-taken branch.
4342                    if self.jit.trace_enabled {
4343                        let proto = cl.proto;
4344                        let c = proto.call_hot_count.get();
4345                        if c < u32::MAX / 2 {
4346                            proto.call_hot_count.set(c + 1);
4347                        }
4348                        // Relaxed call-trigger:
4349                        // `c >= THRESHOLD` (not `c == THRESHOLD`) +
4350                        // `!already_cached` short-circuit. Lets a
4351                        // discarded short call-trigger close retry
4352                        // on the next call (fib(10/15/20/25)
4353                        // pathology — first capture is base-case
4354                        // [Lt,Jmp,Return1]; coverage-heuristic
4355                        // discards; next call gets to record at a
4356                        // potentially deeper recursion point).
4357                        // Without `already_cached`, the relaxed
4358                        // condition would re-record over a cached
4359                        // trace every call.
4360                        //
4361                        // Additionally short-circuit on
4362                        // `proto.trace_gave_up`. The per-Proto discard
4363                        // cap force-compiles a partial trace and
4364                        // flips this flag; subsequent calls into
4365                        // this Proto skip the RefCell borrow + Vec
4366                        // scan entirely.
4367                        if proto.trace_gave_up.get() {
4368                            return Ok(true);
4369                        }
4370                        let call_already_cached =
4371                            proto.traces.borrow().iter().any(|t| t.head_pc == 0)
4372                                || trace_head_abandoned(proto, 0);
4373                        if c >= self.jit.call_hot_threshold
4374                            && self.jit.active_trace.is_none()
4375                            && !call_already_cached
4376                        {
4377                            // The new frame is on top: index in
4378                            // `self.frames` is `len() - 1`.
4379                            let frame_idx = self.frames.len() - 1;
4380                            // Snapshot R[0..max_stack] at the callee's
4381                            // base. `push_frame` resized `self.stack`
4382                            // to `base + max_stack`, so this window is
4383                            // guaranteed in-bounds.
4384                            let f = match &self.frames[frame_idx] {
4385                                CallFrame::Lua(f) => f,
4386                                _ => unreachable!("push_frame just pushed a Lua frame"),
4387                            };
4388                            let max_stack = cl.proto.max_stack as usize;
4389                            let base_us = f.base as usize;
4390                            let mut entry_tags = Vec::with_capacity(max_stack);
4391                            for i in 0..max_stack {
4392                                let (tag, _) = self.stack[base_us + i].unpack();
4393                                entry_tags.push(tag);
4394                            }
4395                            self.jit.active_trace =
4396                                Some(Box::new(crate::jit::trace::TraceRecord::start(
4397                                    cl.proto, 0, entry_tags, true,
4398                                )));
4399                            self.jit.recording_frame_base = frame_idx;
4400                        }
4401                    }
4402                    return Ok(true);
4403                }
4404                Value::Native(nc) => {
4405                    // Async-marked NativeClosure.
4406                    // Route through the cooperative-yield mechanism
4407                    // when async_mode is on; reject when called from
4408                    // a sync `eval`/`call_value` path (would have no
4409                    // executor to drive the returned future).
4410                    if nc.is_async {
4411                        if !self.async_mode {
4412                            let s = Value::Str(
4413                                self.heap.intern(b"async native called in sync context"),
4414                            );
4415                            self.last_error_kind = crate::vm::error::LuaErrorKind::Runtime;
4416                            return Err(LuaError(s));
4417                        }
4418                        // Same root-up bookkeeping as the sync path:
4419                        // pin args + result-count expectation so a
4420                        // collection across the suspend boundary
4421                        // keeps the arg window live.
4422                        self.native_nresults = nresults;
4423                        self.gc_top = func_slot + nargs + 1;
4424                        // Fire the "call" hook BEFORE
4425                        // building the future. Mirrors the sync native
4426                        // path's `hook_call(true, nargs)` site
4427                        // (`exec.rs` further down) so embedders with a
4428                        // Rust debug hook installed see a Call event
4429                        // for async natives identical to the sync
4430                        // path. The matching "return" hook fires from
4431                        // `commit_async_native_result` in
4432                        // `async_drive.rs` after the future resolves.
4433                        // Placement: after the `native_nresults` / `gc_top`
4434                        // pin, before the future is constructed, so a
4435                        // hook body that triggers GC observes the
4436                        // correct pinned window. On hook error the
4437                        // sentinel never returns and
4438                        // `pending_async_native_*` remain `None` —
4439                        // the executor sees `DispatchOutcome::Error`.
4440                        self.hook_call(true, nargs)?;
4441                        // Transmute the stored NativeFn back to its
4442                        // real AsyncNativeFn shape. Sound because
4443                        // `set_async_native` / `create_async_native`
4444                        // installed an AsyncNativeFn through the
4445                        // identically-sized fn-pointer slot, and the
4446                        // `is_async` marker bit is what records that
4447                        // fact.
4448                        let async_fn: crate::vm::async_drive::AsyncNativeFn =
4449                            // SAFETY: same-size fn pointers; provenance
4450                            // preserved through `mem::transmute`. The
4451                            // `is_async` marker is the only safe-to-call
4452                            // gate, set exclusively by
4453                            // `Vm::create_async_native`.
4454                            unsafe { std::mem::transmute(nc.f) };
4455                        let vm_ptr: *mut Vm = self;
4456                        let fut = async_fn(vm_ptr, func_slot, nargs);
4457                        // Stash the future + post-call context for
4458                        // `drive_one` to surface to `EvalFuture::poll`.
4459                        self.pending_async_native_fut = Some(fut);
4460                        self.pending_async_native_ctx = Some(AsyncNativeCallCtx {
4461                            func_slot,
4462                            nargs,
4463                            nresults,
4464                            gc_top: self.gc_top,
4465                        });
4466                        // Sentinel Err walked up to `drive_one` (same
4467                        // shape as `host_yield_pending`'s budget yield).
4468                        // Value::Nil — never seen by user code.
4469                        return Err(LuaError(Value::Nil));
4470                    }
4471                    // pcall/xpcall are yieldable: rather than calling the
4472                    // protected function through the Rust stack (which cannot be
4473                    // suspended), push a continuation frame and drive the call
4474                    // through the interpreter loop (PUC lua_pcallk). A yield
4475                    // inside it is preserved with the thread's saved frames.
4476                    use crate::runtime::value::NativeFn;
4477                    if std::ptr::fn_addr_eq(nc.f, nat_pcall as NativeFn) {
4478                        return self.begin_pcall(func_slot, nargs, nresults);
4479                    }
4480                    if std::ptr::fn_addr_eq(nc.f, nat_xpcall as NativeFn) {
4481                        // 5.1 `xpcall(f, err)` calls `f` with no arguments
4482                        let forward = self.version > LuaVersion::Lua51;
4483                        return self.begin_xpcall(func_slot, nargs, nresults, forward);
4484                    }
4485                    if std::ptr::fn_addr_eq(nc.f, nat_host_xpcall as NativeFn) {
4486                        return self.begin_xpcall(func_slot, nargs, nresults, true);
4487                    }
4488                    // From 5.4 on, pairs(t) calls a __pairs metamethod yieldably
4489                    // (PUC luaB_pairs uses lua_callk). 5.2/5.3 use a plain
4490                    // lua_call, and 5.1 has no `__pairs`: the native handles those.
4491                    if std::ptr::fn_addr_eq(nc.f, nat_pairs as NativeFn)
4492                        && nargs >= 1
4493                        && self.version >= LuaVersion::Lua54
4494                    {
4495                        let arg = self.stack[(func_slot + 1) as usize];
4496                        if !self.get_mm(arg, Mm::Pairs).is_nil() {
4497                            return self.begin_pairs(func_slot, nresults);
4498                        }
4499                    }
4500                    // a native that collects (e.g. `collectgarbage`) roots up to
4501                    // its own arguments — the caller's live registers all sit
4502                    // below `func_slot` and stay rooted.
4503                    self.native_nresults = nresults;
4504                    self.gc_top = func_slot + nargs + 1;
4505                    // Push the native onto the running-natives chain BEFORE
4506                    // firing the call hook so that `debug.getinfo(level)` and
4507                    // `arg_error` from inside the hook see this native as the
4508                    // currently-running C function (db.lua :344 reads
4509                    // `getinfo(2, "f").func` for the just-entered callee).
4510                    // Popped after the matching return hook fires — even on
4511                    // error, the pop must happen, so the body is bracketed
4512                    // through a scope guard.
4513                    self.running_natives.push(nc);
4514                    self.running_native_acts
4515                        .push(crate::vm::callstack::NativeAct {
4516                            func_slot,
4517                            nargs,
4518                            depth: self.frames.len() as u32,
4519                            // a tail call resolved its `__call` chain before
4520                            // calling here and passed the count in tail_ccmt
4521                            ccmt: tail_ccmt + chain as u8,
4522                        });
4523                    // PUC C-call discipline: entering a C function sets
4524                    // L->top to func + 1 + nargs, so a collect triggered
4525                    // INSIDE the native (explicit `collectgarbage()`, or
4526                    // an allocation crossing the GC threshold) roots the
4527                    // whole caller window up to and including the
4528                    // arguments. Without this raise the cursor is stale —
4529                    // parked at some earlier, possibly much lower
4530                    // safe-point — and the collect frees register-held
4531                    // values of the native's own caller (use-after-free).
4532                    // Never lower it: a re-entrant chain
4533                    // (native → Lua → native) must keep the outermost
4534                    // window rooted.
4535                    self.gc_top = self.gc_top.max(func_slot + 1 + nargs);
4536                    // PUC luaD_precall fires the "call" hook for C functions too.
4537                    // A yield inside the native (coroutine.yield) propagates an
4538                    // Err and the matching "return" hook fires on resume instead.
4539                    if let Err(e) = self.hook_call(true, nargs) {
4540                        self.running_natives.pop();
4541                        self.running_native_acts.pop();
4542                        return Err(e);
4543                    }
4544                    // Trap a Rust panic in the native and surface it as
4545                    // a Lua error rather than letting it unwind through the
4546                    // VM into the embedder. The VM's internal state may still
4547                    // be inconsistent after a panic (half-pushed args,
4548                    // dangling GC references), so embedders that catch this
4549                    // class of error should drop and re-create the Vm — but
4550                    // it's still better than tearing the host process down.
4551                    // `AssertUnwindSafe` is sound because the caller is the
4552                    // dispatch loop and any half-done state is fenced behind
4553                    // the immediate Err return below.
4554                    use std::panic::{AssertUnwindSafe, catch_unwind};
4555                    let result =
4556                        match catch_unwind(AssertUnwindSafe(|| (nc.f)(self, func_slot, nargs))) {
4557                            Ok(r) => r,
4558                            Err(payload) => {
4559                                let msg = panic_payload_str(&payload);
4560                                let s = Value::Str(
4561                                    self.heap.intern(format!("native panic: {msg}").as_bytes()),
4562                                );
4563                                Err(LuaError(s))
4564                            }
4565                        };
4566                    let nret = match result {
4567                        Ok(n) => n,
4568                        Err(e) => {
4569                            // PUC raises with the native still on the stack;
4570                            // remember it for the handler and traceback of the
4571                            // error (see `raise_to_handler`)
4572                            let act = self.running_native_acts.pop().expect("pushed above");
4573                            self.running_natives.pop();
4574                            self.note_errored_native(nc, act, e.0);
4575                            return Err(e);
4576                        }
4577                    };
4578                    // PUC `luaD_poscall` fires the return hook BEFORE moving
4579                    // results into the function's slot — at that point args
4580                    // sit at `[func_slot + 1, func_slot + 1 + nargs)` and
4581                    // results above them at `[func_slot + 1 + nargs, …)`.
4582                    // luna's `nat_return` has already written the results
4583                    // into `[func_slot, func_slot + nret)`, so we replay PUC's
4584                    // layout by copying the results up past the preserved
4585                    // args, firing the hook (with ftransfer = nargs + 1, so
4586                    // `getlocal(2, ftransfer..)` reads results), and then
4587                    // copying back for `finish_results`. db.lua :541 reads
4588                    // `getinfo("r").ftransfer` + `getlocal` to inspect a
4589                    // returning native's results this way.
4590                    if self.hook.ret
4591                        && !self.in_hook
4592                        && (self.hook.func.is_some() || self.hook.rust_func.is_some())
4593                    {
4594                        let res_dst = func_slot + nargs + 1;
4595                        let need = (res_dst + nret) as usize;
4596                        if self.stack.len() < need {
4597                            self.stack.resize(need, Value::Nil);
4598                        }
4599                        for i in (0..nret).rev() {
4600                            self.stack[(res_dst + i) as usize] =
4601                                self.stack[(func_slot + i) as usize];
4602                        }
4603                        // widen the C-frame's argument window for getlocal
4604                        if let Some(act) = self.running_native_acts.last_mut() {
4605                            act.nargs = nargs + nret;
4606                        }
4607                        let hr = self.hook_return(true, nargs + 1, nret);
4608                        if let Some(act) = self.running_native_acts.last_mut() {
4609                            act.nargs = nargs;
4610                        }
4611                        // restore results into the slot finish_results expects
4612                        for i in 0..nret {
4613                            self.stack[(func_slot + i) as usize] =
4614                                self.stack[(res_dst + i) as usize];
4615                        }
4616                        self.running_natives.pop();
4617                        self.running_native_acts.pop();
4618                        hr?;
4619                    } else {
4620                        self.running_natives.pop();
4621                        self.running_native_acts.pop();
4622                    }
4623                    self.finish_results(func_slot, nret, nresults);
4624                    // the native may have allocated; collect with the results as
4625                    // the live boundary (PUC checks GC after a call returns).
4626                    self.maybe_collect_garbage(self.top);
4627                    return Ok(false);
4628                }
4629                v => {
4630                    let mm = self.get_mm(v, Mm::Call);
4631                    if mm.is_nil() || self.call_mm_unusable(mm) {
4632                        return Err(self.call_err(v));
4633                    }
4634                    chain += 1;
4635                    // PUC 5.5 dropped the chain cap from `MAXTAGRECUR = 200`
4636                    // (the value 5.4's `lvm.c` uses) down to `MAXCCMT = 16`,
4637                    // and the 5.5 test exercises the new tight bound directly
4638                    // (calls.lua :225 builds a 16-deep chain and expects the
4639                    // 16th to error). 5.4 calls.lua :194 instead builds a 20-
4640                    // deep chain and expects it to succeed.
4641                    let cap = if self.version >= crate::version::LuaVersion::Lua55 {
4642                        15
4643                    } else {
4644                        MAX_CCMT
4645                    };
4646                    if chain > cap {
4647                        return Err(self.rt_err("'__call' chain too long"));
4648                    }
4649                    // slots above shift by one; at a call site those are dead
4650                    // temps of the current frame
4651                    self.stack.insert(func_slot as usize, mm);
4652                    if self.top > func_slot {
4653                        self.top += 1;
4654                    }
4655                    nargs += 1;
4656                }
4657            }
4658        }
4659    }
4660
4661    /// Up to 5.3 `tryfuncTM` takes one `__call` hop and needs a function
4662    /// there; anything else is a call error on the original object. 5.4
4663    /// retries the call with whatever `__call` holds, so chains resolve.
4664    fn call_mm_unusable(&self, mm: Value) -> bool {
4665        self.version <= LuaVersion::Lua53 && !matches!(mm, Value::Closure(_) | Value::Native(_))
4666    }
4667
4668    fn push_frame(
4669        &mut self,
4670        cl: Gc<LuaClosure>,
4671        func_slot: u32,
4672        nargs: u32,
4673        nresults: i32,
4674        from_c: bool,
4675    ) -> Result<(), LuaError> {
4676        if func_slot + 256 > MAX_LUA_STACK {
4677            // PUC `luaD_growstack`: the overflow raises "stack overflow" and
4678            // leaves ERRORSTACKSIZE's extra slots for the xpcall handler that
4679            // runs on it; overflowing those is LUA_ERRERR, "error in error
4680            // handling" (errors.lua :606, cstack.lua :29).
4681            if self.msgh_depth == 0 {
4682                return Err(self.rt_err("stack overflow"));
4683            }
4684            if func_slot + 256 > MAX_LUA_STACK + ERROR_STACK_EXTRA {
4685                return Err(self.plain_err("error in error handling"));
4686            }
4687        }
4688        let proto = cl.proto;
4689        let nparams = proto.num_params as u32;
4690        // 5.5 vararg layout (PUC luaT_adjustvarargs): the extra args stay on the
4691        // stack just below the new `base`, so a named vararg can be indexed
4692        // virtually without allocating a table. Rotate `[p1..pn][e1..em]` to
4693        // `[e1..em][p1..pn]` so the fixed params land at the new base.
4694        let n_varargs = if proto.is_vararg {
4695            nargs.saturating_sub(nparams)
4696        } else {
4697            0
4698        };
4699        if n_varargs > 0 {
4700            let s = (func_slot + 1) as usize;
4701            self.stack[s..s + nargs as usize].rotate_left(nparams as usize);
4702        }
4703        let base = func_slot + 1 + n_varargs;
4704        let need = (base + proto.max_stack as u32) as usize;
4705        if self.stack.len() < need {
4706            self.stack.resize(need, Value::Nil);
4707        }
4708        // wipe the register window beyond the kept parameters (stale values —
4709        // required for GC-safety and codegen). The varargs below `base` survive.
4710        let kept = nargs.saturating_sub(n_varargs).min(nparams);
4711        // SAFETY: just resized above so `need <= stack.len()`; `base + kept <=
4712        // need` since `base + nparams <= base + max_stack = need` and `kept <=
4713        // nparams`. `slice::fill` lowers to a single memset on Copy types.
4714        unsafe {
4715            self.stack
4716                .get_unchecked_mut((base + kept) as usize..need)
4717                .fill(Value::Nil);
4718        }
4719        let ccmt = std::mem::take(&mut self.pending_ccmt);
4720        self.set_frame_ccmt(ccmt);
4721        frames_push_sync(
4722            &mut self.frames,
4723            &mut self.frames_top,
4724            CallFrame::Lua(Frame {
4725                closure: cl,
4726                base,
4727                pc: 0,
4728                func_slot,
4729                nresults,
4730                hook_oldpc: u32::MAX,
4731                from_c,
4732                n_varargs,
4733                // single-shot consume: `close_slots` sets pending_tm before each
4734                // handler call; the next Lua frame born is that handler's.
4735                tm: self.pending_tm.take(),
4736                // `run_hook` sets `pending_is_hook` before dispatching the user
4737                // hook so its frame reports `namewhat = "hook"` via getinfo.
4738                is_hook: std::mem::take(&mut self.pending_is_hook),
4739                tailcalls: std::mem::take(&mut self.pending_tailcalls),
4740            }),
4741        );
4742        // PUC 5.1 `LUAI_COMPAT_VARARG`: populate the hidden `arg` local with
4743        // `{ n = n_varargs, [1] = e1, [2] = e2, … }`. The compiler reserved
4744        // the slot at `base + nparams`; the extras sit just below `base` from
4745        // the vararg rotate above. 5.1 db.lua :279 reads `arg.n` from a line
4746        // hook; vararg.lua's contradictory expectations were already going to
4747        // fail either way (some asserts want `arg == nil`).
4748        if proto.has_compat_vararg_arg {
4749            let arg_slot = (base + nparams) as usize;
4750            let t = self.heap.new_table();
4751            {
4752                // 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).
4753                let tm = unsafe { t.as_mut() };
4754                for i in 0..n_varargs {
4755                    let v = self.stack[(base - n_varargs + i) as usize];
4756                    // bounded by `n_varargs` (≤ MAXUPVAL territory), well
4757                    // below `MAX_ASIZE`
4758                    let _ = tm.set_int(&mut self.heap, (i + 1) as i64, v);
4759                }
4760                let nk = Value::Str(self.heap.intern(b"n"));
4761                tm.set(&mut self.heap, nk, Value::Int(n_varargs as i64))
4762                    .expect("'n' key");
4763            }
4764            // once-per-table barrier mirrors SETLIST: t is born BLACK during
4765            // Propagate and the bulk `set_int`/`set` calls above don't barrier
4766            self.heap
4767                .barrier_back(t.as_ptr() as *mut crate::runtime::heap::GcHeader);
4768            self.stack[arg_slot] = Value::Table(t);
4769        }
4770        // PUC luaD_precall fires the "call" hook with the new frame current, so
4771        // a hook calling debug.getinfo(2) sees the entered function. For a Lua
4772        // callee, PUC `luaD_hookcall` passes `p->numparams` as ntransfer (only
4773        // fixed params count — extras already live below `base`).
4774        // A frame born via OP_TailCall fires "tail call" instead (PUC
4775        // luaD_pretailcall) and skips the matching "return" hook on exit.
4776        let is_tail = self
4777            .frames
4778            .last()
4779            .and_then(|f| f.lua())
4780            .is_some_and(|f| f.tailcalls > 0);
4781        self.hook_call_with(false, nparams, is_tail)?;
4782        Ok(())
4783    }
4784
4785    /// `pcall(f, ...)` (PUC luaB_pcall): push a continuation frame, then drive
4786    /// the protected call `f` through the interpreter loop. The protected
4787    /// function and its arguments already sit at `func_slot+1..`, so calling `f`
4788    /// at `func_slot+1` lets its results land one slot above the continuation —
4789    /// the loop head then writes `true` at `func_slot` to form `true, results…`.
4790    /// Always returns `Ok(true)`: a continuation is now on the stack to be
4791    /// resolved by the loop (even when `f` is a native that already ran inline).
4792    fn begin_pcall(&mut self, func_slot: u32, nargs: u32, nresults: i32) -> Result<bool, LuaError> {
4793        if nargs == 0 {
4794            // `luaL_checkany` fails here: there is no function to call.
4795            self.with_native_running(func_slot, nargs, |vm| {
4796                let a = crate::vm::argcheck::Args::new(func_slot, nargs);
4797                crate::vm::argcheck::check_any(vm, a, 0).map(drop)
4798            })?;
4799        }
4800        if self.pcall_depth >= MAX_C_DEPTH {
4801            // raised inside pcall, a C function: no position
4802            return Err(self.plain_err("C stack overflow"));
4803        }
4804        self.pcall_depth += 1;
4805        frames_push_sync(
4806            &mut self.frames,
4807            &mut self.frames_top,
4808            CallFrame::Cont(NativeCont {
4809                kind: ContKind::Pcall,
4810                func_slot,
4811                nresults,
4812            }),
4813        );
4814        // call f (slot func_slot+1) with the remaining args, asking for all
4815        // results; a yield or error inside propagates with the continuation kept
4816        // on the stack (caught by `unwind` / preserved across a yield).
4817        self.begin_call(func_slot + 1, Some(nargs - 1), -1, true)?;
4818        Ok(true)
4819    }
4820
4821    /// `xpcall(f, msgh, ...)` (PUC luaB_xpcall): like `begin_pcall`, but the
4822    /// message handler is stashed in the continuation and the arguments are
4823    /// shifted down over the handler's slot so `f`'s args are contiguous.
4824    /// `forward` is false for 5.1's `xpcall`, which passes `f` none of them.
4825    fn begin_xpcall(
4826        &mut self,
4827        func_slot: u32,
4828        nargs: u32,
4829        nresults: i32,
4830        forward: bool,
4831    ) -> Result<bool, LuaError> {
4832        self.with_native_running(func_slot, nargs, |vm| {
4833            let a = crate::vm::argcheck::Args::new(func_slot, nargs);
4834            crate::vm::builtins::xpcall_handler(vm, a).map(drop)
4835        })?;
4836        if self.pcall_depth >= MAX_C_DEPTH {
4837            // raised inside pcall, a C function: no position
4838            return Err(self.plain_err("C stack overflow"));
4839        }
4840        self.pcall_depth += 1;
4841        // layout: [xpcall@func_slot, f@+1, msgh@+2, a1@+3, ...]. Stash msgh and
4842        // close its gap so f's args become [f@+1, a1@+2, ...].
4843        let handler = self.stack[(func_slot + 2) as usize];
4844        // 5.1: `xpcall (f, err)` takes exactly two parameters — extra
4845        // arguments are NOT forwarded to `f` (5.2 added forwarding;
4846        // 5.1 calls f with zero args).
4847        let nfargs = if forward { nargs - 2 } else { 0 };
4848        for i in 0..nfargs {
4849            self.stack[(func_slot + 2 + i) as usize] = self.stack[(func_slot + 3 + i) as usize];
4850        }
4851        self.top = func_slot + 2 + nfargs;
4852        frames_push_sync(
4853            &mut self.frames,
4854            &mut self.frames_top,
4855            CallFrame::Cont(NativeCont {
4856                kind: ContKind::Xpcall { handler },
4857                func_slot,
4858                nresults,
4859            }),
4860        );
4861        self.begin_call(func_slot + 1, Some(nfargs), -1, true)?;
4862        Ok(true)
4863    }
4864
4865    /// `pairs(t)` where `t` has a `__pairs` metamethod (PUC luaB_pairs's
4866    /// lua_callk path): drive `__pairs(t)` through the loop with a `Pairs`
4867    /// continuation so a `coroutine.yield` inside it suspends cleanly. The
4868    /// metamethod is called in `pairs`'s own slot, so its (≤4, nil-padded)
4869    /// results land exactly where `pairs`'s results belong.
4870    /// Run a check of the native at `func_slot` while it counts as the running
4871    /// C function, so an argument error names it the way PUC does. pcall and
4872    /// xpcall check their arguments in the dispatcher, before the native
4873    /// would otherwise be entered.
4874    fn with_native_running(
4875        &mut self,
4876        func_slot: u32,
4877        nargs: u32,
4878        check: impl FnOnce(&mut Vm) -> Result<(), LuaError>,
4879    ) -> Result<(), LuaError> {
4880        let Value::Native(nc) = self.stack[func_slot as usize] else {
4881            unreachable!("pcall/xpcall dispatch sits on a native")
4882        };
4883        self.running_natives.push(nc);
4884        self.running_native_acts
4885            .push(crate::vm::callstack::NativeAct {
4886                func_slot,
4887                nargs,
4888                depth: self.frames.len() as u32,
4889                ccmt: 0,
4890            });
4891        let r = check(self);
4892        self.running_natives.pop();
4893        self.running_native_acts.pop();
4894        r
4895    }
4896
4897    fn begin_pairs(&mut self, func_slot: u32, nresults: i32) -> Result<bool, LuaError> {
4898        let arg = self.stack[(func_slot + 1) as usize];
4899        let mm = self.get_mm(arg, Mm::Pairs);
4900        // layout becomes [pairs@func_slot, mm@func_slot+1, t@func_slot+2]:
4901        // `pairs` keeps its slot so the debug interface can report it as the
4902        // C function running below the metamethod. Call mm(t) wanting 4.
4903        let need = (func_slot + 3) as usize;
4904        if self.stack.len() < need {
4905            self.stack.resize(need, Value::Nil);
4906        }
4907        self.stack[(func_slot + 2) as usize] = arg;
4908        self.stack[(func_slot + 1) as usize] = mm;
4909        self.top = func_slot + 3;
4910        frames_push_sync(
4911            &mut self.frames,
4912            &mut self.frames_top,
4913            CallFrame::Cont(NativeCont {
4914                kind: ContKind::Pairs,
4915                func_slot,
4916                nresults,
4917            }),
4918        );
4919        let want = crate::vm::builtins::pairs_mm_results(self) as i32;
4920        self.begin_call(func_slot + 1, Some(1), want, true)?;
4921        Ok(true)
4922    }
4923
4924    /// The running (top) Lua frame. The interpreter only reads this while a Lua
4925    /// frame is on top — a continuation frame is never the running frame (it is
4926    /// consumed the instant the call it protects unwinds onto it).
4927    #[inline]
4928    /// Records the `__call` count of the Lua frame about to be pushed.
4929    fn set_frame_ccmt(&mut self, ccmt: u8) {
4930        let i = self.frames.len();
4931        if self.frame_ccmt.len() <= i {
4932            self.frame_ccmt.resize(i + 1, 0);
4933        }
4934        self.frame_ccmt[i] = ccmt;
4935    }
4936
4937    fn top_frame(&self) -> &Frame {
4938        self.frames
4939            .last()
4940            .and_then(CallFrame::lua)
4941            .expect("running Lua frame")
4942    }
4943
4944    #[inline]
4945    fn top_frame_mut(&mut self) -> &mut Frame {
4946        self.frames
4947            .last_mut()
4948            .and_then(CallFrame::lua_mut)
4949            .expect("running Lua frame")
4950    }
4951
4952    /// Pad/announce results sitting at func_slot.
4953    pub(crate) fn finish_results(&mut self, func_slot: u32, nret: u32, wanted: i32) {
4954        // Capture the call's high-water-mark before
4955        // setting the new top so we can Nil-clear slots that the
4956        // call temporarily wrote but no longer holds — matching
4957        // PUC's `L->top` discipline (slots past L->top are "free"
4958        // and the next push overwrites them). Without this clear,
4959        // a stale `Value::Closure` (e.g. the called function
4960        // itself, when wanted = 0) sits at `func_slot` and a
4961        // later GC with wider `gc_top` traces it after the
4962        // closure has been freed by a previous narrow safe-point
4963        // GC → heap-buffer-overflow in `Marker::header` (sort.lua
4964        // comparator case).
4965        let prev_top = self.top as usize;
4966        if wanted < 0 {
4967            self.top = func_slot + nret;
4968        } else {
4969            let wanted = wanted as u32;
4970            let need = (func_slot + wanted) as usize;
4971            if self.stack.len() < need {
4972                self.stack.resize(need, Value::Nil);
4973            }
4974            for i in nret..wanted {
4975                self.stack[(func_slot + i) as usize] = Value::Nil;
4976            }
4977            self.top = func_slot + wanted;
4978        }
4979        let new_top = self.top as usize;
4980        let clear_end = prev_top.min(self.stack.len());
4981        if new_top < clear_end {
4982            for slot in &mut self.stack[new_top..clear_end] {
4983                *slot = Value::Nil;
4984            }
4985        }
4986    }
4987
4988    /// Current Lua call-frame depth (read-only).
4989    /// Used by `EvalFuture` on the bootstrap poll to compute the
4990    /// `entry_depth` it will pass to subsequent resume slices.
4991    pub(crate) fn frame_count(&self) -> usize {
4992        self.frames.len()
4993    }
4994
4995    fn take_results(&mut self, func_slot: u32) -> Vec<Value> {
4996        let nret = self.top - func_slot;
4997        let out = self.stack[func_slot as usize..(func_slot + nret) as usize].to_vec();
4998        self.stack.truncate(func_slot as usize);
4999        self.top = func_slot;
5000        out
5001    }
5002
5003    // ---- open upvalues ----
5004
5005    #[doc(hidden)]
5006    pub fn find_or_create_upval(&mut self, slot: u32) -> Gc<Upvalue> {
5007        match self.open_upvals.binary_search_by_key(&slot, |&(s, _)| s) {
5008            Ok(i) => self.open_upvals[i].1,
5009            Err(i) => {
5010                let uv = self.heap.new_upvalue(UpvalState::Open {
5011                    slot,
5012                    thread: self.current,
5013                });
5014                self.open_upvals.insert(i, (slot, uv));
5015                uv
5016            }
5017        }
5018    }
5019
5020    pub(crate) fn close_from(&mut self, slot: u32) {
5021        while let Some(&(s, uv)) = self.open_upvals.last() {
5022            if s < slot {
5023                break;
5024            }
5025            let v = self.stack[s as usize];
5026            // 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).
5027            unsafe { uv.as_mut() }.set_closed(v);
5028            self.heap
5029                .barrier_forward(uv.as_ptr() as *mut crate::runtime::heap::GcHeader, v);
5030            self.open_upvals.pop();
5031        }
5032    }
5033
5034    /// Register a to-be-closed slot (TBC op / generic-for closing value).
5035    fn register_tbc(&mut self, slot: u32) -> Result<(), LuaError> {
5036        let v = self.stack[slot as usize];
5037        if matches!(v, Value::Nil | Value::Bool(false)) {
5038            return Ok(()); // nil and false are silently ignored
5039        }
5040        if self.get_mm(v, Mm::Close).is_nil() {
5041            // PUC `checkclosemth`: "variable '<name>' got a non-closable
5042            // value", the name as `luaG_findlocal` gives it — the frame's
5043            // locvars at this pc, else "(temporary)".
5044            let f = self.top_frame();
5045            let reg = slot - f.base;
5046            let pc = (f.pc as usize).saturating_sub(1);
5047            let name = crate::vm::objname::getlocalname(&f.closure.proto, reg, pc)
5048                .unwrap_or("(temporary)");
5049            return Err(self.rt_err(&format!("variable '{name}' got a non-closable value")));
5050        }
5051        // compiled code registers in register order and closes before it
5052        // registers a slot again; only a crafted chunk (`TBC R0; TBC R0`)
5053        // breaks that, which PUC's list cannot represent either
5054        if self.tbc.last().is_some_and(|&s| s >= slot) {
5055            return Err(self.rt_err("'<close>' state corrupted"));
5056        }
5057        self.tbc.push(slot);
5058        Ok(())
5059    }
5060
5061    /// Close upvalues and run `__close` handlers for slots ≥ `from`
5062    /// (handlers in reverse registration order; PUC luaF_close).
5063    fn close_slots(&mut self, from: u32, err: Option<Value>) -> Result<(), LuaError> {
5064        self.close_from(from);
5065        // PUC: handlers run in reverse declaration order; an error raised by a
5066        // handler becomes the error object passed to the remaining ones, and
5067        // the rest are still closed. The last raised error propagates.
5068        let mut pending = err;
5069        let mut result = Ok(());
5070        let saved_err = self.closing_err;
5071        // On a normal close the handler runs within the closing function's
5072        // activation (debug parent = that function); during error unwinding the
5073        // function's frame is already gone, so the handler sits at the C
5074        // boundary instead (PUC: luaF_close runs after the ci is restored).
5075        let error_close = err.is_some();
5076        while let Some(&s) = self.tbc.last() {
5077            if s < from {
5078                break;
5079            }
5080            self.tbc.pop();
5081            let v = self.stack[s as usize];
5082            if matches!(v, Value::Nil | Value::Bool(false)) {
5083                continue;
5084            }
5085            let mm = self.get_mm(v, Mm::Close);
5086            if mm.is_nil() {
5087                // PUC `prepclosingmethod`: the __close metamethod was present
5088                // at OP_TBC (else we would have errored there) but has since
5089                // been removed/replaced. Treat as a non-callable target.
5090                let tn = self.obj_typename(v);
5091                let e = self.rt_err(&format!(
5092                    "attempt to call a {tn} value (metamethod 'close')"
5093                ));
5094                pending = Some(e.0);
5095                result = Err(e);
5096                continue;
5097            }
5098            // root the pending error: a handler may trigger a collection
5099            self.closing_err = pending;
5100            // PUC `luaF_close` sets `ci->u.l.tm = TM_CLOSE` so traceback /
5101            // getinfo report the handler as "in metamethod 'close'". Saved/
5102            // restored around the call to cover the path where `mm` is a
5103            // native (`push_frame` never consumes it) or it raises before
5104            // reaching push_frame.
5105            let saved_tm = self.pending_tm.replace("close");
5106            // PUC 5.4 `prepclosingmethod` always pushed (obj, errobj) — errobj
5107            // is nil on a normal close (5.4 locals.lua :875's
5108            // `func2close(coroutine.yield)` wrap pins `(self, nil)` back
5109            // through the yield). PUC 5.5 dropped the trailing nil: a clean
5110            // close passes only `obj`, the error case still passes both
5111            // (5.5 locals.lua :314 `select("#", ...) == n` with n=1 for the
5112            // normal-close arms, n=2 for the error arm).
5113            let call = match pending {
5114                Some(e) => self.call_value_impl(mm, &[v, e], error_close),
5115                None => {
5116                    if self.version >= LuaVersion::Lua55 {
5117                        self.call_value_impl(mm, &[v], error_close)
5118                    } else {
5119                        self.call_value_impl(mm, &[v, Value::Nil], error_close)
5120                    }
5121                }
5122            };
5123            self.pending_tm = saved_tm;
5124            if let Err(e) = call {
5125                pending = Some(e.0);
5126                result = Err(e);
5127            }
5128        }
5129        self.closing_err = saved_err;
5130        result
5131    }
5132
5133    /// Yieldable variant of `close_slots`: drive the chain of `__close`
5134    /// handlers for slots ≥ `from` through the interpreter loop with a
5135    /// `Cont::Close` continuation, so a `coroutine.yield()` inside any handler
5136    /// suspends cleanly (the close iteration's state rides on the thread's
5137    /// frame/stack like any other suspended call) — PUC's `lua_callk` pattern
5138    /// applied to `luaF_close`. `after` runs when every slot is closed; if
5139    /// `after` is `Return` and we've returned past `entry_depth`,
5140    /// `Ok(Some(vals))` carries the result up to the host caller.
5141    fn begin_close(
5142        &mut self,
5143        from: u32,
5144        err: Option<Value>,
5145        after: AfterClose,
5146        entry_depth: usize,
5147    ) -> Result<Option<Vec<Value>>, LuaError> {
5148        self.close_from(from);
5149        self.drive_close(from, err, after, entry_depth)
5150    }
5151
5152    /// Pop tbc slots ≥ `from`, skipping nil/false and synthesising a
5153    /// non-callable-mm error for an `__close` that was reset to a bad value
5154    /// between OP_TBC and now (PUC `prepclosingmethod`). The first real
5155    /// handler pushes a `Cont::Close` + `begin_call` and returns `Ok(None)`;
5156    /// the interpreter then drives the handler and re-enters this driver via
5157    /// the `Cont::Close` consumer in `run()`. When the chain is exhausted,
5158    /// the threaded error (if any) propagates or `after` fires.
5159    fn drive_close(
5160        &mut self,
5161        from: u32,
5162        mut pending: Option<Value>,
5163        after: AfterClose,
5164        entry_depth: usize,
5165    ) -> Result<Option<Vec<Value>>, LuaError> {
5166        loop {
5167            let drained = match self.tbc.last() {
5168                None => true,
5169                Some(&s) => s < from,
5170            };
5171            if drained {
5172                return self.finish_close_after(after, pending, entry_depth);
5173            }
5174            let s = self.tbc.pop().expect("tbc non-empty");
5175            let v = self.stack[s as usize];
5176            if matches!(v, Value::Nil | Value::Bool(false)) {
5177                continue;
5178            }
5179            let mm = self.get_mm(v, Mm::Close);
5180            if mm.is_nil() {
5181                let tn = self.obj_typename(v);
5182                let e = self.rt_err(&format!(
5183                    "attempt to call a {tn} value (metamethod 'close')"
5184                ));
5185                pending = Some(e.0);
5186                continue;
5187            }
5188            // A real handler: stage [mm, v, (err?)] above the current top,
5189            // record the close iteration state in a Cont::Close, and let the
5190            // interpreter dispatch the handler. On return the run() head
5191            // re-enters this driver via the Cont::Close consumer.
5192            let func_slot = self.top;
5193            let error_close = pending.is_some();
5194            let need = (func_slot + 3) as usize;
5195            if self.stack.len() < need {
5196                self.stack.resize(need, Value::Nil);
5197            }
5198            self.stack[func_slot as usize] = mm;
5199            self.stack[func_slot as usize + 1] = v;
5200            // PUC 5.4 always passes (obj, errobj=nil) on a normal close;
5201            // 5.5 drops the trailing nil. 5.4 locals.lua :875 vs 5.5 :314.
5202            let nargs = match pending {
5203                Some(e) => {
5204                    self.stack[func_slot as usize + 2] = e;
5205                    2u32
5206                }
5207                None => {
5208                    if self.version >= LuaVersion::Lua55 {
5209                        1u32
5210                    } else {
5211                        self.stack[func_slot as usize + 2] = Value::Nil;
5212                        2u32
5213                    }
5214                }
5215            };
5216            self.top = func_slot + 1 + nargs;
5217            // Root the pending error during the call (a handler may collect).
5218            let saved_err = self.closing_err;
5219            self.closing_err = pending;
5220            // PUC `luaF_close` flags the handler frame as "metamethod 'close'"
5221            // for traceback / getinfo.
5222            let saved_tm = self.pending_tm.replace("close");
5223            frames_push_sync(
5224                &mut self.frames,
5225                &mut self.frames_top,
5226                CallFrame::Cont(NativeCont {
5227                    kind: ContKind::Close(CloseCont {
5228                        from,
5229                        pending,
5230                        after,
5231                    }),
5232                    func_slot,
5233                    nresults: 0,
5234                }),
5235            );
5236            // PUC luaF_close runs a normal close *within* the closing
5237            // function's activation (debug parent = that function); during an
5238            // error unwind the function's frame is already gone and the
5239            // handler sits at the C boundary instead.
5240            let r = self.begin_call(func_slot, Some(nargs), 0, error_close);
5241            self.pending_tm = saved_tm;
5242            self.closing_err = saved_err;
5243            r?;
5244            return Ok(None);
5245        }
5246    }
5247
5248    /// Fire `after` once every `__close` handler has run. `Block` propagates
5249    /// any remaining error or simply continues; `Return` performs OP_Return's
5250    /// tail (hook + frame pop + result delivery) and may surface results to
5251    /// the host when the function whose return triggered the close was the
5252    /// entry activation, but only on a clean drain — a pending error skips
5253    /// the return tail and propagates instead. `ResumeUnwind` pops the
5254    /// deferred Lua frame and re-raises, letting a handler's own error win
5255    /// over the original propagating one (PUC luaF_close).
5256    fn finish_close_after(
5257        &mut self,
5258        after: AfterClose,
5259        pending: Option<Value>,
5260        entry_depth: usize,
5261    ) -> Result<Option<Vec<Value>>, LuaError> {
5262        match after {
5263            AfterClose::Block => match pending {
5264                Some(e) => Err(LuaError(e)),
5265                None => Ok(None),
5266            },
5267            AfterClose::Return {
5268                abs_a,
5269                nret,
5270                from_native,
5271            } => match pending {
5272                Some(e) => Err(LuaError(e)),
5273                None => self.complete_return(abs_a, nret, from_native, entry_depth),
5274            },
5275            AfterClose::ResumeUnwind { func_slot, err } => {
5276                // The aborting Lua frame was popped before `begin_close`;
5277                // restore the catcher's stack window down to `func_slot` and
5278                // re-raise — preferring a handler-raised error over the
5279                // original (PUC luaF_close).
5280                self.stack.truncate(func_slot as usize);
5281                self.top = func_slot;
5282                self.tbc.retain(|&s| s < func_slot);
5283                Err(LuaError(pending.unwrap_or(err)))
5284            }
5285        }
5286    }
5287
5288    /// OP_Return's post-close tail: fire the "return" hook (frame still
5289    /// current), pop the Lua frame, slide results into `func_slot`, then
5290    /// either hand them to the host (`Ok(Some(vals))` when we've returned
5291    /// past `entry_depth`), leave them contiguous for an exposed
5292    /// pcall/xpcall continuation, or finish into the caller's expected
5293    /// result slot. Mirrors the synchronous OP_Return tail so both paths
5294    /// share semantics — the `from_native` flag selects the right "return"
5295    /// hook context for `hook_return`.
5296    fn complete_return(
5297        &mut self,
5298        abs_a: u32,
5299        nret: u32,
5300        from_native: bool,
5301        entry_depth: usize,
5302    ) -> Result<Option<Vec<Value>>, LuaError> {
5303        // ftransfer is the local index (1-based) of the first result, as
5304        // `getinfo("r").ftransfer + getlocal(level, k)` consumes it. luna
5305        // exposes locals starting at `frame.base` (= func_slot + 1 +
5306        // n_varargs for a vararg call), so the conversion is the absolute
5307        // result slot minus base, plus one to make it 1-based. db.lua 5.4
5308        // :542 (`foo1(); on=false; eqseq(out, {10, 0})`) pins the vararg
5309        // shape end-to-end.
5310        let ftransfer = self
5311            .frames
5312            .last()
5313            .and_then(CallFrame::lua)
5314            .map(|fr| {
5315                let raw = abs_a.saturating_sub(fr.base) + 1;
5316                // 5.5 anonymous-vararg functions get a `(vararg table)` pseudo
5317                // local injected at index `numparams + 1`, so getlocal
5318                // numbering shifts results past it (5.5 db.lua :539
5319                // `eqseq(out, {10, 0})`). 5.4 and earlier have no such pseudo.
5320                if fr.closure.proto.has_vararg_table_pseudo {
5321                    raw + 1
5322                } else {
5323                    raw
5324                }
5325            })
5326            .unwrap_or(1);
5327        // PUC 5.1 `luaD_poscall`: fire one extra "tail return" hook event
5328        // per tail call that collapsed into this activation, *after* its
5329        // own "return". `tailcalls` tracks that count exactly (PUC
5330        // `ci->u.l.tailcalls`). 5.2+ retired LUA_HOOKTAILRET, so the
5331        // "return" hook fires once even when the activation absorbed
5332        // multiple tail calls — only `istailcall` on getinfo surfaces the
5333        // collapse. 5.1 db.lua :366 pins the event ordering.
5334        let tailcalls = if self.version <= LuaVersion::Lua51 {
5335            self.frames
5336                .last()
5337                .and_then(|f| f.lua())
5338                .map(|f| f.tailcalls)
5339                .unwrap_or(0)
5340        } else {
5341            0
5342        };
5343        self.hook_return(from_native, ftransfer, nret)?;
5344        for _ in 0..tailcalls {
5345            self.hook_tail_return()?;
5346        }
5347        let CallFrame::Lua(fr) =
5348            frames_pop_sync(&mut self.frames, &mut self.frames_top).expect("no frame")
5349        else {
5350            unreachable!("returning from a non-Lua frame")
5351        };
5352        for i in 0..nret {
5353            self.stack[(fr.func_slot + i) as usize] = self.stack[(abs_a + i) as usize];
5354        }
5355        if self.frames.len() < entry_depth {
5356            self.top = fr.func_slot + nret;
5357            return Ok(Some(self.take_results(fr.func_slot)));
5358        } else if matches!(self.frames.last(), Some(CallFrame::Cont(_))) {
5359            self.top = fr.func_slot + nret;
5360        } else {
5361            self.finish_results(fr.func_slot, nret, fr.nresults);
5362        }
5363        Ok(None)
5364    }
5365
5366    #[doc(hidden)]
5367    pub fn upval_get(&self, cl: Gc<LuaClosure>, idx: u32) -> Value {
5368        match cl.upvals()[idx as usize].state() {
5369            UpvalState::Open { slot, thread } => self.read_slot(slot, thread),
5370            UpvalState::Closed(v) => v,
5371        }
5372    }
5373
5374    fn upval_set(&mut self, cl: Gc<LuaClosure>, idx: u32, v: Value) {
5375        let uv = cl.upvals()[idx as usize];
5376        match uv.state() {
5377            UpvalState::Open { slot, thread } => self.write_slot(slot, thread, v),
5378            UpvalState::Closed(_) => {
5379                // 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).
5380                unsafe { uv.as_mut() }.set_closed(v);
5381                // forward barrier: a closed upvalue is single-slot, so the
5382                // forward variant is cheaper than barrier_back (PUC uses
5383                // `luaC_barrier_` for upvalues; `luaC_barrierback_` for
5384                // tables / threads).
5385                self.heap
5386                    .barrier_forward(uv.as_ptr() as *mut crate::runtime::heap::GcHeader, v);
5387            }
5388        }
5389    }
5390
5391    // ---- register / error helpers ----
5392
5393    #[inline(always)]
5394    fn r(&self, base: u32, i: u32) -> Value {
5395        // SAFETY: the compiler reserves `proto.max_stack` slots above `base`
5396        // at frame entry (`push_frame` sizes the stack up to base + max_stack),
5397        // and every bytecode-generated reference falls within `[0, max_stack)`.
5398        // PUC's vmfetch uses raw `R(A)` (`s2v(L->base + A)`) for the same
5399        // reason. The bounds check would re-validate this invariant on every
5400        // op — the dispatch hot path can't afford it.
5401        // 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).
5402        unsafe { *self.stack.get_unchecked((base + i) as usize) }
5403    }
5404
5405    #[inline(always)]
5406    fn set_r(&mut self, base: u32, i: u32, v: Value) {
5407        // SAFETY: see `r` — `base + i < base + max_stack <= stack.len()` by
5408        // frame-entry contract.
5409        unsafe {
5410            *self.stack.get_unchecked_mut((base + i) as usize) = v;
5411        }
5412    }
5413
5414    #[doc(hidden)]
5415    pub fn rt_err(&mut self, msg: &str) -> LuaError {
5416        let text = match self.position_prefix() {
5417            Some(p) => format!("{p}{msg}"),
5418            None => msg.to_string(),
5419        };
5420        LuaError(Value::Str(self.heap.intern(text.as_bytes())))
5421    }
5422
5423    /// Error without the `chunk:line:` position prefix. PUC's
5424    /// `resume_error` (ldo.c) pushes its message as a bare literal,
5425    /// so `cannot resume dead coroutine` etc. must not be prefixed.
5426    pub(crate) fn plain_err(&mut self, msg: &str) -> LuaError {
5427        LuaError(Value::Str(self.heap.intern(msg.as_bytes())))
5428    }
5429
5430    /// A string a library built from pieces of any size: one longer than a
5431    /// string can hold raises, as the concatenation operator does.
5432    pub(crate) fn built_str(&mut self, bytes: &[u8]) -> Result<Value, LuaError> {
5433        if bytes.len() > crate::runtime::string::MAX_LEN {
5434            return Err(self.rt_err("string length overflow"));
5435        }
5436        Ok(Value::Str(self.heap.intern(bytes)))
5437    }
5438
5439    pub(crate) fn type_err(&mut self, what: &str, v: Value) -> LuaError {
5440        let extra = self.subject_varinfo(v);
5441        let tn = self.obj_typename(v);
5442        let msg = self.compose_type_err(what, &tn, &extra);
5443        self.runerror(&msg)
5444    }
5445
5446    /// Assemble a `luaG_typeerror` / `luaG_callerror` message in the dialect's
5447    /// word order.
5448    ///
5449    /// PUC ≤5.2 names the operand first — `attempt to call field 'f' (a nil
5450    /// value)`. 5.3 flipped it to type-first — `attempt to call a nil value
5451    /// (field 'f')`. luna emitted the 5.3+ form on every dialect, so every
5452    /// such error was worded wrong under 5.1/5.2.
5453    ///
5454    /// Two shapes carry no operand name on ≤5.2 and must collapse to the bare
5455    /// message: an absent varinfo (identical across dialects), and a
5456    /// metamethod target — ≤5.2's `luaG_typeerror` only names locals, globals,
5457    /// fields, upvalues and methods, so `(metamethod 'add')` has no ≤5.2
5458    /// counterpart and is dropped rather than reworded. All four shapes were
5459    /// measured against stock 5.1.5 / 5.2.4 / 5.5.1 before this was written.
5460    fn compose_type_err(&self, what: &str, tn: &str, extra: &str) -> String {
5461        if self.version() > crate::version::LuaVersion::Lua52 {
5462            return format!("attempt to {what} a {tn} value{extra}");
5463        }
5464        // `extra` is "" or " (kind 'name')" — unwrap to "kind 'name'".
5465        let inner = extra
5466            .trim_start()
5467            .trim_start_matches('(')
5468            .trim_end_matches(')');
5469        if inner.is_empty() || inner.starts_with("metamethod") {
5470            format!("attempt to {what} a {tn} value")
5471        } else {
5472            format!("attempt to {what} {inner} (a {tn} value)")
5473        }
5474    }
5475
5476    /// Name the offending operand of the current instruction (PUC varinfo) for
5477    /// a type error, e.g. " (global 'x')". The faulting value `bad` is matched
5478    /// to the instruction's subject register(s); a native-raised error whose
5479    /// current instruction doesn't hold `bad` simply yields "".
5480    fn subject_varinfo(&self, bad: Value) -> String {
5481        use crate::vm::isa::Op;
5482        // PUC `varinfo` names a variable only for a Lua activation
5483        if self.native_on_top() {
5484            return String::new();
5485        }
5486        let Some(f) = self.frames.last().and_then(CallFrame::lua) else {
5487            return String::new();
5488        };
5489        let proto = f.closure.proto;
5490        let p: &crate::runtime::Proto = &proto;
5491        let pc = f.pc as usize;
5492        if pc == 0 || pc > p.code.len() {
5493            return String::new();
5494        }
5495        let instr = p.code[pc - 1];
5496        let mut cands: Vec<u32> = Vec::new();
5497        match instr.op() {
5498            // indexed reads / length / method: the table/object is in B
5499            Op::GetField | Op::GetI | Op::GetTable | Op::SelfOp | Op::Len => {
5500                cands.push(instr.b());
5501            }
5502            // indexed writes / calls: the table/function is in A
5503            Op::SetField | Op::SetI | Op::SetTable | Op::Call | Op::TailCall => {
5504                cands.push(instr.a());
5505            }
5506            // arithmetic/bitwise: a register operand (B, and C unless constant)
5507            Op::Add
5508            | Op::Sub
5509            | Op::Mul
5510            | Op::Div
5511            | Op::Mod
5512            | Op::Pow
5513            | Op::IDiv
5514            | Op::BAnd
5515            | Op::BOr
5516            | Op::BXor
5517            | Op::Shl
5518            | Op::Shr => {
5519                cands.push(instr.b());
5520                if !instr.k() {
5521                    cands.push(instr.c());
5522                }
5523            }
5524            Op::Unm | Op::BNot => cands.push(instr.b()),
5525            // indexing an upvalue table (`_ENV` for a global): PUC
5526            // `getupvalname` finds the value among the closure's upvalues
5527            Op::GetTabUp | Op::SetTabUp => {
5528                let u = if instr.op() == Op::GetTabUp {
5529                    instr.b()
5530                } else {
5531                    instr.a()
5532                };
5533                if self.upval_get(f.closure, u).raw_eq(bad)
5534                    && let Some(d) = p.upvals.get(u as usize)
5535                {
5536                    return format!(" (upvalue '{}')", d.name);
5537                }
5538            }
5539            Op::Concat => {
5540                let a = instr.a();
5541                for r in a..a + instr.b() {
5542                    cands.push(r);
5543                }
5544            }
5545            _ => {}
5546        }
5547        // Up to 5.3 a binary operator takes a constant operand straight
5548        // from the constant table (RK), where `varinfo` cannot see it, so a
5549        // string constant is not named there; unary operators load it into
5550        // a register first and do name it.
5551        let rk_operands = self.version <= LuaVersion::Lua53
5552            && matches!(
5553                instr.op(),
5554                Op::Add
5555                    | Op::Sub
5556                    | Op::Mul
5557                    | Op::Div
5558                    | Op::Mod
5559                    | Op::Pow
5560                    | Op::IDiv
5561                    | Op::BAnd
5562                    | Op::BOr
5563                    | Op::BXor
5564                    | Op::Shl
5565                    | Op::Shr
5566            );
5567        for reg in cands {
5568            if self.r(f.base, reg).raw_eq(bad) {
5569                return match crate::vm::objname::getobjname_in(p, pc - 1, reg, self.version) {
5570                    Some(("constant", _)) if rk_operands => String::new(),
5571                    Some((kind, name)) => format!(" ({kind} '{name}')"),
5572                    None => String::new(),
5573                };
5574            }
5575        }
5576        String::new()
5577    }
5578
5579    /// "attempt to call a X value", enriched (PUC luaG_callerror) with a name
5580    /// for the call target: "(global 'f')" for a direct call, or "(metamethod
5581    /// 'add')" when the call is a metamethod dispatched by the current opcode.
5582    fn call_err(&mut self, v: Value) -> LuaError {
5583        let extra = self.call_target_varinfo(v);
5584        let tn = self.obj_typename(v);
5585        let msg = self.compose_type_err("call", &tn, &extra);
5586        self.runerror(&msg)
5587    }
5588
5589    /// Name the offending call target. A metamethod dispatch pushes a `Cont`
5590    /// frame before the call, so the opcode that triggered it lives in the
5591    /// nearest *Lua* frame — read that instruction: OP_CALL names the function
5592    /// register, any metamethod-bearing opcode yields "(metamethod 'event')".
5593    fn call_target_varinfo(&self, bad: Value) -> String {
5594        use crate::vm::isa::Op;
5595        if self.native_on_top() {
5596            return String::new();
5597        }
5598        let Some(f) = self.frames.iter().rev().find_map(CallFrame::lua) else {
5599            return String::new();
5600        };
5601        let proto = f.closure.proto;
5602        let p: &crate::runtime::Proto = &proto;
5603        let pc = f.pc as usize;
5604        if pc == 0 || pc > p.code.len() {
5605            return String::new();
5606        }
5607        let instr = p.code[pc - 1];
5608        match instr.source_op() {
5609            Op::Call | Op::TailCall => {
5610                let reg = instr.a();
5611                if self.r(f.base, reg).raw_eq(bad) {
5612                    match crate::vm::objname::getobjname_in(p, pc - 1, reg, self.version) {
5613                        Some((kind, name)) => format!(" ({kind} '{name}')"),
5614                        None => String::new(),
5615                    }
5616                } else {
5617                    String::new()
5618                }
5619            }
5620            // 5.4 `funcnamefromcode` names the generic-for iterator call
5621            // (5.3 had the entry but raised through plain `luaG_typeerror`)
5622            Op::TForCall if self.version >= LuaVersion::Lua54 => {
5623                " (for iterator 'for iterator')".to_string()
5624            }
5625            // 5.4 `funcnamefromcall` names the metamethod; up to 5.3 the
5626            // call raised through `luaG_typeerror`, whose `varinfo` does not
5627            op if self.version >= LuaVersion::Lua54 => match mm_event_name(op) {
5628                Some(ev) => format!(" (metamethod '{ev}')"),
5629                None => String::new(),
5630            },
5631            _ => String::new(),
5632        }
5633    }
5634
5635    /// "number has no integer representation", enriched (PUC luaG_tointerror)
5636    /// with a "(field 'x')"-style suffix naming the offending operand of the
5637    /// current arithmetic instruction when it can be recovered from bytecode.
5638    fn no_int_rep_err(&mut self) -> LuaError {
5639        let extra = self.bad_operand_varinfo();
5640        self.runerror(&format!("number{extra} has no integer representation"))
5641    }
5642
5643    /// Inspect the current frame's faulting instruction: find the register
5644    /// operand holding a float with no integer representation and name it.
5645    fn bad_operand_varinfo(&self) -> String {
5646        if self.native_on_top() {
5647            return String::new();
5648        }
5649        let Some(f) = self.frames.last().and_then(CallFrame::lua) else {
5650            return String::new();
5651        };
5652        let proto = f.closure.proto;
5653        let p: &crate::runtime::Proto = &proto;
5654        let pc = f.pc as usize;
5655        if pc == 0 || pc > p.code.len() {
5656            return String::new();
5657        }
5658        let instr = p.code[pc - 1];
5659        let mut regs = vec![instr.b()];
5660        if !instr.k() {
5661            regs.push(instr.c());
5662        }
5663        let no_int = |n: Option<Num>| matches!(n, Some(Num::Float(x)) if crate::runtime::value::f2i_exact(x).is_none());
5664        for reg in regs {
5665            let v = self.r(f.base, reg);
5666            // before 5.4 a numeric string is converted first, so "2.5" is
5667            // the operand without an integer value
5668            let n = self.arith_operand()(v);
5669            if no_int(n) {
5670                return match crate::vm::objname::getobjname_in(p, pc - 1, reg, self.version) {
5671                    Some((kind, name)) => format!(" ({kind} '{name}')"),
5672                    None => String::new(),
5673                };
5674            }
5675        }
5676        String::new()
5677    }
5678
5679    /// Position prefix of the currently executing Lua frame. PUC `luaL_error`
5680    /// calls `luaL_where(L, 1)` which reads `L->ci->previous`. When the prior
5681    /// frame is a C function (e.g. a pcall Cont parked above `require`'s
5682    /// native call), PUC pushes no prefix — match that by looking only at the
5683    /// topmost frame directly and bailing if it is anything but a Lua frame.
5684    pub(crate) fn position_prefix(&self) -> Option<String> {
5685        let f = match self.frames.last()? {
5686            CallFrame::Lua(f) => f,
5687            // a native metamethod runs above the Meta continuation of the
5688            // instruction that triggered it: that Lua function is its caller
5689            CallFrame::Cont(NativeCont {
5690                kind: ContKind::Meta(_),
5691                ..
5692            }) => self.frames.iter().rev().nth(1)?.lua()?,
5693            CallFrame::Cont(_) => return None,
5694        };
5695        let proto = f.closure.proto;
5696        // a stripped chunk: no source in luna's own format, no line info in
5697        // PUC's (whose loader names the missing source "=?")
5698        if proto.source.as_bytes().is_empty() || proto.lines.is_empty() {
5699            return Some(self.stripped_prefix());
5700        }
5701        let line = proto.lines[(f.pc as usize).saturating_sub(1).min(proto.lines.len() - 1)];
5702        // 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).
5703        let raw = unsafe { crate::runtime::string::bytes_of(proto.source.as_ptr()) };
5704        let display = crate::vm::lib_debug::chunk_id(self.version, raw);
5705        let src = String::from_utf8_lossy(&display).into_owned();
5706        Some(format!("{src}:{line}: "))
5707    }
5708
5709    /// PUC `luaG_addinfo` prefix for a stripped chunk. 5.5 substitutes "=?"
5710    /// for the source and renders the line as "?" (so the prefix reads
5711    /// `?:?: `). 5.4 and below leave the source NULL ("?") and use the raw
5712    /// `getfuncline = -1`, so the prefix reads `?:-1: ` (5.4 errors.lua :282
5713    /// matches `^%?:%-1:`).
5714    fn stripped_prefix(&self) -> String {
5715        if self.version >= crate::version::LuaVersion::Lua55 {
5716            "?:?: ".to_string()
5717        } else {
5718            "?:-1: ".to_string()
5719        }
5720    }
5721
5722    /// PUC `luaL_where(L, level)`: `"short_src:line: "` for the function at
5723    /// `level` (0 = the running native), or `None` when that level does not
5724    /// exist or has no line information (a C function, a stripped chunk).
5725    pub(crate) fn position_prefix_at_level(&self, level: i64) -> Option<String> {
5726        let ts = self.thread_stack(None);
5727        let i = usize::try_from(level).ok()?;
5728        if i >= ts.levels.len() {
5729            return None;
5730        }
5731        let line = ts.currentline(i);
5732        if line <= 0 {
5733            return None;
5734        }
5735        let DbgKind::Lua(fi) = ts.levels[i] else {
5736            return None;
5737        };
5738        let ar = self.closure_ar(ts.lua(fi).closure);
5739        Some(format!(
5740            "{}:{line}: ",
5741            String::from_utf8_lossy(&ar.short_src)
5742        ))
5743    }
5744
5745    // ---- the interpreter ----
5746
5747    /// Run from the current top frame down to (but not past) `entry_depth`
5748    /// frames. Coroutine driving passes `entry_depth = 1` so the whole thread
5749    /// runs to completion or a yield.
5750    /// Resume the dispatcher from the saved
5751    /// `entry_depth` (captured pre-yield by `drive_one`). Called by
5752    /// `EvalFuture::poll` on every poll after the first to walk the
5753    /// existing call frames until the next `BudgetExhausted` or
5754    /// terminal `Ok`/`Err`. Not a public-API surface; the
5755    /// embedder reaches it through `Vm::eval_async`.
5756    pub(crate) fn exec_with_async(&mut self, entry_depth: usize) -> Result<Vec<Value>, LuaError> {
5757        self.exec_with(entry_depth)
5758    }
5759
5760    fn exec_with(&mut self, entry_depth: usize) -> Result<Vec<Value>, LuaError> {
5761        loop {
5762            let r = self.run(entry_depth);
5763            if r.is_err()
5764                && (self.yielding.is_some()
5765                    || self.terminating.is_some()
5766                    || self.host_yield_pending
5767                    || self.pending_async_native_fut.is_some())
5768            {
5769                // a `coroutine.yield` is in flight: keep the frames intact (they
5770                // are the suspended coroutine's saved state) and propagate to
5771                // resume. A self-close termination propagates the same way, so a
5772                // protecting pcall on the way out cannot catch (unwind) it.
5773                // `host_yield_pending` is the async-mode
5774                // analogue: the sentinel must reach `drive_one` without
5775                // a protecting `pcall` swallowing it.
5776                return r;
5777            }
5778            match r {
5779                Ok(vals) => return Ok(vals),
5780                // unwind toward `entry_depth`. A protecting pcall/xpcall
5781                // continuation caught along the way turns the error into
5782                // `false, msg` and the loop resumes running its caller; an
5783                // uncaught error propagates out.
5784                Err(e) => match self.unwind(e.0, entry_depth) {
5785                    Unwound::Caught => continue,
5786                    Unwound::CaughtReturn(vals) => return Ok(vals),
5787                    Unwound::Propagated(err) => return Err(err),
5788                },
5789            }
5790        }
5791    }
5792
5793    /// Unwind the call stack from the error point toward `entry_depth`, running
5794    /// `__close` handlers on each Lua frame. Stops at the first pcall/xpcall
5795    /// continuation frame at/above `entry_depth` (the error is *caught*: its
5796    /// slot receives `false, msg`); if none is reached, the error propagates.
5797    fn unwind(&mut self, mut err: Value, entry_depth: usize) -> Unwound {
5798        // The protected call runs in-place among the caller frames' registers,
5799        // so truncating the failed frames here cuts into caller windows below
5800        // the catcher. Snapshot the live length: at the error point the stack
5801        // already spans every surviving frame's window, so restoring it after a
5802        // catch reinstates them all (the reclaimed slots above are dead temps).
5803        // PUC handles overflow recovery via a separate EXTRA_STACK reserve;
5804        // we instead clamp the restore to the catcher's caller window when the
5805        // error point was at the stack limit (cause: the next `call_value_impl`
5806        // picks `func_slot = stack.len()` which would otherwise re-overflow).
5807        let saved_len = self.stack.len();
5808        err = self.raise_to_handler(err);
5809        // An error that no protected call inside the running coroutine will
5810        // catch kills it without unwinding: PUC's `lua_resume` leaves the
5811        // dead thread's stack as it was, so its pending to-be-closed
5812        // variables run only when it is closed (`coroutine.close`, or
5813        // `coroutine.wrap` closing it before re-raising). Scoped to the
5814        // coroutine's own run (`entry_depth == 1`); a run nested under a
5815        // native unwinds as before.
5816        if entry_depth == 1
5817            && self.version >= LuaVersion::Lua54
5818            && self
5819                .current
5820                .is_some_and(|c| c.status == crate::runtime::CoroStatus::Running)
5821            && !self.frames.iter().any(|f| {
5822                matches!(
5823                    f,
5824                    CallFrame::Cont(NativeCont {
5825                        kind: ContKind::Pcall | ContKind::Xpcall { .. } | ContKind::Close(_),
5826                        ..
5827                    })
5828                )
5829            })
5830        {
5831            while self.frames.len() >= entry_depth {
5832                frames_pop_sync(&mut self.frames, &mut self.frames_top);
5833            }
5834            return Unwound::Propagated(LuaError(err));
5835        }
5836        while self.frames.len() >= entry_depth {
5837            match *self.frames.last().expect("frame") {
5838                // a yieldable-metamethod continuation does not catch: discard the
5839                // abandoned instruction and keep unwinding (PUC drops the partial
5840                // op on error).
5841                CallFrame::Cont(NativeCont {
5842                    kind: ContKind::Meta(mc),
5843                    func_slot,
5844                    ..
5845                }) => {
5846                    frames_pop_sync(&mut self.frames, &mut self.frames_top);
5847                    self.stack.truncate(func_slot as usize);
5848                    self.top = mc.saved_top.min(func_slot);
5849                    self.tbc.retain(|&s| s < func_slot);
5850                }
5851                // a __pairs continuation does not catch either: an error inside
5852                // the metamethod propagates past `pairs`.
5853                CallFrame::Cont(NativeCont {
5854                    kind: ContKind::Pairs,
5855                    func_slot,
5856                    ..
5857                }) => {
5858                    frames_pop_sync(&mut self.frames, &mut self.frames_top);
5859                    self.stack.truncate(func_slot as usize);
5860                    self.top = func_slot;
5861                    self.tbc.retain(|&s| s < func_slot);
5862                }
5863                // a __close continuation does not catch: drop the half-run
5864                // handler's window, then continue the close yieldably with
5865                // the new error threaded as `pending`. Preserve `cc.after`
5866                // verbatim — `Return`/`Block` originating from an aborting
5867                // OP_Return/OP_Close will be short-circuited by
5868                // `finish_close_after` (pending propagates as Err); a
5869                // `ResumeUnwind` originated by our own Lua-frame handler
5870                // must keep its deferred frame-pop semantics so that frame
5871                // is not orphaned. If a fresh handler yields, `drive_close`
5872                // pushes another `Cont::Close` and we return `Caught` so
5873                // `exec_with` re-enters the run loop.
5874                CallFrame::Cont(NativeCont {
5875                    kind: ContKind::Close(cc),
5876                    func_slot,
5877                    ..
5878                }) => {
5879                    frames_pop_sync(&mut self.frames, &mut self.frames_top);
5880                    self.stack.truncate(func_slot as usize);
5881                    self.top = func_slot;
5882                    self.tbc.retain(|&s| s < func_slot);
5883                    match self.drive_close(cc.from, Some(err), cc.after, entry_depth) {
5884                        Ok(Some(_)) => {
5885                            unreachable!(
5886                                "Block / Return / ResumeUnwind never return host values mid-unwind"
5887                            )
5888                        }
5889                        Ok(None) => return Unwound::Caught,
5890                        Err(e) => {
5891                            // the drained close re-raises `err`; only an
5892                            // error a handler raised is new
5893                            if !e.0.raw_eq(err) {
5894                                err = self.raise_to_handler(e.0);
5895                            }
5896                            continue;
5897                        }
5898                    }
5899                }
5900                CallFrame::Cont(nc) => {
5901                    frames_pop_sync(&mut self.frames, &mut self.frames_top);
5902                    self.pcall_depth -= 1;
5903                    let result = match nc.kind {
5904                        ContKind::Pcall => {
5905                            self.msgh_applied = None;
5906                            err
5907                        }
5908                        // the handler ran where the error was raised (see
5909                        // `raise_to_handler`); one raised past the handler's
5910                        // reach (by the unwind itself) meets it here
5911                        ContKind::Xpcall { handler } => {
5912                            if self.msgh_applied.take().is_some_and(|v| v.raw_eq(err)) {
5913                                err
5914                            } else {
5915                                self.call_msgh(handler, err)
5916                            }
5917                        }
5918                        ContKind::Meta(_) | ContKind::Pairs | ContKind::Close(_) => {
5919                            unreachable!("Meta/Pairs/Close cont handled above")
5920                        }
5921                    };
5922                    // PUC 5.5 `luaG_errormsg` substitutes "<no error object>"
5923                    // for nil AFTER the message handler ran (ldebug.c:849) —
5924                    // so it applies to the pcall-caught object and to an
5925                    // xpcall HANDLER'S return value, while the handler itself
5926                    // (and a top-level propagation into the host, whose
5927                    // `error_display` plays msghandler) still sees the raw
5928                    // nil. 5.4- keep nil everywhere (errors.lua :49 asserts
5929                    // `doit("error()") == nil`).
5930                    let result = if matches!(result, Value::Nil)
5931                        && self.version >= crate::version::LuaVersion::Lua55
5932                    {
5933                        Value::Str(self.heap.intern(b"<no error object>"))
5934                    } else {
5935                        result
5936                    };
5937                    // the error has been caught (pcall/xpcall): the captured
5938                    // traceback was for that error and is no longer in flight.
5939                    self.error_traceback = None;
5940                    let fs = nc.func_slot as usize;
5941                    if self.stack.len() < fs + 2 {
5942                        self.stack.resize(fs + 2, Value::Nil);
5943                    }
5944                    self.stack[fs] = Value::Bool(false);
5945                    self.stack[fs + 1] = result;
5946                    self.top = nc.func_slot + 2;
5947                    self.tbc.retain(|&s| s < nc.func_slot);
5948                    if self.frames.len() < entry_depth {
5949                        return Unwound::CaughtReturn(self.take_results(nc.func_slot));
5950                    }
5951                    self.finish_results(nc.func_slot, 2, nc.nresults);
5952                    // reinstate the caller windows the unwind truncated into,
5953                    // clamped to the catcher's caller window + a `MIN_STACK`
5954                    // reserve. The clamp is a no-op for normal pcall catches
5955                    // (saved_len lies within the caller's max_stack window),
5956                    // and prevents the stack from staying near `MAX_LUA_STACK`
5957                    // after an overflow-recovery catch — which would make the
5958                    // next `call_value_impl` (e.g. a `__close` in the catcher's
5959                    // errorh, locals.lua:659) pick `func_slot = stack.len()`
5960                    // above the limit and re-overflow.
5961                    // Restore the caller's full register window: opcodes
5962                    // index it directly. The cap covers caller's base +
5963                    // `max_stack` + a small reserve. We always resize to
5964                    // exactly this window — previously this clamped
5965                    // `saved_len` from above to prevent staying near
5966                    // `MAX_LUA_STACK` after an overflow-recovery catch, and
5967                    // a yieldable-unwind re-entry adds the dual case where
5968                    // `saved_len` is *below* the window (a prior
5969                    // `ResumeUnwind` truncated). Using the window directly
5970                    // covers both.
5971                    let restore = self
5972                        .frames
5973                        .iter()
5974                        .rev()
5975                        .find_map(CallFrame::lua)
5976                        .map(|c| (c.base + c.closure.proto.max_stack as u32) as usize + 256)
5977                        .unwrap_or(saved_len);
5978                    if self.stack.len() < restore {
5979                        self.stack.resize(restore, Value::Nil);
5980                    } else if self.stack.len() > restore {
5981                        self.stack.truncate(restore);
5982                    }
5983                    // Clear slots vacated by the popped
5984                    // frames the unwind walked over. finish_results
5985                    // above clears `[nc.func_slot + nresults ..
5986                    // nc.func_slot + 2)`, which only covers the
5987                    // pcall's own result region — the unwind-popped
5988                    // frames' locals in `[nc.func_slot + 2 .. restore)`
5989                    // are still in place with whatever Gc-bearing
5990                    // Values they last held. Without this clear, a
5991                    // later GC marks the stale pointers (same hazard as
5992                    // the Op::Return finish_results path). PUC's `luaD_pcall` similarly truncates
5993                    // L->top to the catcher's level — luna's
5994                    // truncate above resizes the Vec but doesn't
5995                    // touch slots [func_slot+2..restore) that were
5996                    // already present.
5997                    let clear_lo = (nc.func_slot as usize + 2).min(self.stack.len());
5998                    let clear_hi = restore.min(self.stack.len());
5999                    if clear_lo < clear_hi {
6000                        for slot in &mut self.stack[clear_lo..clear_hi] {
6001                            *slot = Value::Nil;
6002                        }
6003                    }
6004                    return Unwound::Caught;
6005                }
6006                CallFrame::Lua(f) => {
6007                    // Yieldable error-unwind close, PUC luaG_errormsg shape:
6008                    // (1) pop the Lua frame immediately so each `__close`
6009                    // handler runs at the C boundary above — `debug.getinfo`
6010                    // sees the next outer Lua frame's call site (typically
6011                    // `pcall`), not this aborting function (locals.lua:480).
6012                    // (2) drive the close yieldably with
6013                    // `AfterClose::ResumeUnwind { func_slot, err }`; on drain
6014                    // it truncates to `func_slot` and re-raises (letting a
6015                    // handler-raised error win over `err`). If a handler
6016                    // yields, `drive_close` pushes `Cont::Close` and we
6017                    // return `Caught` so `exec_with` re-enters the run loop;
6018                    // a synchronous drain returns Err exactly as the old
6019                    // path did.
6020                    frames_pop_sync(&mut self.frames, &mut self.frames_top);
6021                    let after = AfterClose::ResumeUnwind {
6022                        func_slot: f.func_slot,
6023                        err,
6024                    };
6025                    match self.begin_close(f.base, Some(err), after, entry_depth) {
6026                        Ok(Some(_)) => {
6027                            unreachable!("ResumeUnwind never returns host values")
6028                        }
6029                        Ok(None) => return Unwound::Caught,
6030                        Err(e) => {
6031                            // the drained close re-raises `err`; only an
6032                            // error a handler raised is new
6033                            if !e.0.raw_eq(err) {
6034                                err = self.raise_to_handler(e.0);
6035                            }
6036                            continue;
6037                        }
6038                    }
6039                }
6040            }
6041        }
6042        Unwound::Propagated(LuaError(err))
6043    }
6044
6045    fn run(&mut self, entry_depth: usize) -> Result<Vec<Value>, LuaError> {
6046        loop {
6047            // Fast-path slow-check gate: most embedders run with both
6048            // `instr_budget` and `mem_cap` as None, so a single combined
6049            // is_some test lets the hot loop skip both branches with one
6050            // load + branch instead of two.
6051            if self.instr_budget.is_some() || self.heap.mem_cap.is_some() {
6052                if let Some(b) = self.instr_budget.as_mut() {
6053                    *b -= 1;
6054                    if *b <= 0 {
6055                        self.instr_budget = None;
6056                        // Async-mode cooperative
6057                        // yield. Set a sentinel flag so `exec_with`
6058                        // propagates the Err without `unwind` running
6059                        // (mirroring the `yielding.is_some()` path),
6060                        // and `call_value_impl` preserves the call
6061                        // frames for the next `poll`. Translation back
6062                        // to `DispatchOutcome::BudgetExhausted` happens
6063                        // in `drive_one`. The Err value itself is
6064                        // `Value::Nil` — a pure sentinel, never seen by
6065                        // user code.
6066                        if self.async_mode {
6067                            self.host_yield_pending = true;
6068                            return Err(LuaError(Value::Nil));
6069                        }
6070                        // Classify the trip so embedders can
6071                        // distinguish budget exhaustion from a
6072                        // generic Runtime error and retry / give up
6073                        // accordingly.
6074                        self.last_error_kind = crate::vm::error::LuaErrorKind::InstrBudget;
6075                        let s = Value::Str(self.heap.intern(b"instruction budget exceeded"));
6076                        return Err(LuaError(s));
6077                    }
6078                }
6079                if let Some(cap) = self.heap.mem_cap
6080                    && self.heap.bytes() > cap
6081                {
6082                    // First try a full collect — embedders set tight caps
6083                    // and the overshoot may be reclaimable (closures kept
6084                    // by short-lived frames, intermediate strings). Only
6085                    // disarm + raise if the cap is still breached after
6086                    // collection. PUC's `LUA_GCEMERGENCY` path matches.
6087                    //
6088                    // Root up to the deepest Lua frame's
6089                    // `base + max_stack` window rather than the entire
6090                    // `self.stack.len()`
6091                    // (covers register operands the current opcode
6092                    // might reference). The cap fires during table
6093                    // mutation in a tight `a[i] = i` loop where `a`
6094                    // lives at a frame-register slot past `self.top`
6095                    // (OP_NEWINDEX doesn't advance top); the deepest
6096                    // frame's max_stack window provably covers it
6097                    // since `a` is a register of the executing proto.
6098                    //
6099                    // Still over-roots caller frames' dead regs
6100                    // (slots between caller.base and the callee
6101                    // func_slot are live; slots past callee
6102                    // func_slot in caller's frame are dead until
6103                    // caller resumes). For fire-once cap path this
6104                    // residual over-root is acceptable; there is no
6105                    // full per-frame walk because a strong/weak pass
6106                    // split is semantically impossible — the weak pass
6107                    // depends on strong-pass marks.
6108                    let cap_root_top = self
6109                        .frames
6110                        .iter()
6111                        .rev()
6112                        .find_map(CallFrame::lua)
6113                        .map(|f| f.base + f.closure.proto.max_stack as u32)
6114                        .unwrap_or(self.top);
6115                    self.gc_top = cap_root_top.max(self.top);
6116                    self.collect_garbage();
6117                    if self.heap.bytes() > cap {
6118                        self.heap.mem_cap = None;
6119                        let s = Value::Str(self.heap.intern(b"memory cap exceeded"));
6120                        return Err(LuaError(s));
6121                    }
6122                }
6123            }
6124            // Single combined frame fetch: continuation arm OR Lua arm. Saves
6125            // a second `self.frames.last()` slice access vs the prior split
6126            // form (LLVM doesn't always CSE these across the cont branch).
6127            // A continuation frame on top means the call it protected just
6128            // delivered its results — wrap as `true, results…` and hand to
6129            // the pcall/xpcall caller. The error path is handled by `unwind`;
6130            // this branch is only reached on success/resume completion.
6131            // 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).
6132            let frame_peek = unsafe { self.frames.last().unwrap_unchecked() };
6133            if let &CallFrame::Cont(nc) = frame_peek {
6134                // a yieldable metamethod returned: complete the interrupted
6135                // instruction (PUC luaV_finishOp) and resume the running frame.
6136                if let ContKind::Meta(mc) = nc.kind {
6137                    frames_pop_sync(&mut self.frames, &mut self.frames_top);
6138                    let result = if self.top > nc.func_slot {
6139                        self.stack[nc.func_slot as usize]
6140                    } else {
6141                        Value::Nil
6142                    };
6143                    self.stack.truncate(nc.func_slot as usize);
6144                    self.top = mc.saved_top;
6145                    self.finish_meta(mc.action, result)?;
6146                    continue;
6147                }
6148                // a __close handler returned successfully: discard its
6149                // results, restore `top` to the slot the handler was called
6150                // at (the surrounding frame's register window above this slot
6151                // must stay alloc'd — never truncate the underlying stack),
6152                // then continue the close chain (next slot, or fire
6153                // AfterClose). When the close ends an entry activation,
6154                // drive_close hands the results up to exec_with directly.
6155                if let ContKind::Close(cc) = nc.kind {
6156                    frames_pop_sync(&mut self.frames, &mut self.frames_top);
6157                    self.top = nc.func_slot;
6158                    if let Some(vals) =
6159                        self.drive_close(cc.from, cc.pending, cc.after, entry_depth)?
6160                    {
6161                        return Ok(vals);
6162                    }
6163                    continue;
6164                }
6165                // __pairs returned: normalize its results to exactly the
6166                // dialect's count (iterator, state, control, and on 5.5 the
6167                // closing value) at pairs's slot, where the metamethod was
6168                // called, and hand them to pairs's caller.
6169                if let ContKind::Pairs = nc.kind {
6170                    frames_pop_sync(&mut self.frames, &mut self.frames_top);
6171                    let total = crate::vm::builtins::pairs_mm_results(self) as u32;
6172                    let need = (nc.func_slot + total) as usize;
6173                    if self.stack.len() < need {
6174                        self.stack.resize(need, Value::Nil);
6175                    }
6176                    // the metamethod ran one slot above pairs's own
6177                    let first = nc.func_slot + 1;
6178                    let n = (self.top - first).min(total);
6179                    for i in 0..n {
6180                        self.stack[(nc.func_slot + i) as usize] = self.stack[(first + i) as usize];
6181                    }
6182                    for s in (nc.func_slot + n)..(nc.func_slot + total) {
6183                        self.stack[s as usize] = Value::Nil;
6184                    }
6185                    self.top = nc.func_slot + total;
6186                    if self.frames.len() < entry_depth {
6187                        return Ok(self.take_results(nc.func_slot));
6188                    }
6189                    self.finish_results(nc.func_slot, total, nc.nresults);
6190                    continue;
6191                }
6192                frames_pop_sync(&mut self.frames, &mut self.frames_top);
6193                self.pcall_depth -= 1;
6194                // f's results sit at nc.func_slot+1.. (f was called one slot
6195                // above the continuation), so writing `true` at the slot makes
6196                // `true, results…` already contiguous.
6197                let nret = self.top - (nc.func_slot + 1);
6198                self.stack[nc.func_slot as usize] = Value::Bool(true);
6199                let total = 1 + nret;
6200                self.top = nc.func_slot + total;
6201                if self.frames.len() < entry_depth {
6202                    return Ok(self.take_results(nc.func_slot));
6203                }
6204                self.finish_results(nc.func_slot, total, nc.nresults);
6205                continue;
6206            }
6207            // GC runs only at the allocation safe points below (PUC's
6208            // `luaC_checkGC` sites), each with a precise `gc_top`; the loop head
6209            // no longer collects, so a stale full-window `gc_top` cannot leak in.
6210            //
6211            // Hot-path frame fetch: the Cont arm above continues the loop,
6212            // so reaching here means `frame_peek` is the Lua frame. Reuse it
6213            // rather than re-fetching `self.frames.last()`.
6214            let f = match frame_peek {
6215                CallFrame::Lua(f) => f,
6216                _ => unreachable!("Cont frame survived the dispatch loop head"),
6217            };
6218            let cl = f.closure;
6219            let base = f.base;
6220            let func_slot = f.func_slot;
6221            let n_varargs = f.n_varargs;
6222            let pc = f.pc;
6223            let oldpc = f.hook_oldpc;
6224
6225            // SAFETY: `pc` is bounded by the compiler against `proto.code.len()`
6226            // — every branch / call op only sets `pc` to a valid index, and
6227            // function entry initialises pc=0 with a non-empty body. PUC's
6228            // `vmfetch` uses the equivalent unchecked load.
6229            let inst = unsafe { *cl.proto.code.get_unchecked(pc as usize) };
6230
6231            // Trace recording append + close detection.
6232            // Gated on `trace_jit_enabled` + `active_trace.is_some()`
6233            // so default dispatch keeps a single not-taken branch.
6234            //
6235            // - At the head PC with a non-empty record, the trace has
6236            //   looped back to its start: mark `closed = true` and
6237            //   take the record for compile + cache.
6238            // - Otherwise, capture the op. If the record overflows
6239            //   MAX_TRACE_LEN, abort by dropping it.
6240            if self.jit.trace_enabled
6241                && let Some(_rec) = self.jit.active_trace.as_mut()
6242            {
6243                // Depth tracking. The trace head's frame is
6244                // at index `recording_frame_base`; every Op::Call that
6245                // pushes a new frame bumps the live depth, every
6246                // Op::Return that pops one decrements it.
6247                //
6248                // **Three clean-close conditions**:
6249                // - `at_head`: cur_depth == 0 AND about-to-execute the
6250                //   trace's head_pc on its head_proto (loop closed back
6251                //   to start). Same for loop-triggered and call-triggered
6252                //   traces, so a call-triggered trace does not close on
6253                //   the first re-entry (that would leave fib's body at 7
6254                //   depth=0 ops); it inlines up to MAX_INLINE_DEPTH
6255                //   levels before any close.
6256                // - `returned_past_head`: trace head's frame is gone
6257                //   (callee returned past it, or the call-trigger
6258                //   started a recording inside a callee that has now
6259                //   returned). Whatever ops were recorded form the
6260                //   trace body; the lowerer treats the partial trace
6261                //   the same as InlineAbort.
6262                // - `depth_cap_hit`: cur_depth > MAX_INLINE_DEPTH.
6263                //   Recording any deeper would just bloat the IR; close
6264                //   with the body we have. Lowerer's existing length
6265                //   gate + InlineAbort path handles short bodies.
6266                let returned_past_head = self.frames.len() <= self.jit.recording_frame_base;
6267                let cur_depth = if returned_past_head {
6268                    0
6269                } else {
6270                    self.frames.len() - 1 - self.jit.recording_frame_base
6271                };
6272                let depth_cap_hit = cur_depth > crate::jit::trace::MAX_INLINE_DEPTH as usize;
6273                let rec = self.jit.active_trace.as_mut().expect("just checked Some");
6274                let at_head_loop = cur_depth == 0
6275                    && !rec.ops.is_empty()
6276                    && !returned_past_head
6277                    && std::ptr::eq(cl.proto.as_ptr(), rec.head_proto.as_ptr())
6278                    && pc == rec.head_pc;
6279                // Self-link cycle catch (mirrors LuaJIT's
6280                // `check_call_unroll` at `lj_record.c:1869`). Trips when:
6281                //   1. We're about to execute the head_pc on head_proto
6282                //      at depth > 0 (we're re-entering the trace head
6283                //      from inside an inlined recursion level — UpRec).
6284                //   2. The count of ancestor frames in the recording
6285                //      window that share `head_proto` exceeds
6286                //      [`RECUNROLL_THRESHOLD`] (default 2).
6287                // For fib(N): head_pc=0, head_proto=fib. After 2 inline
6288                // recursion levels are captured, the recorder enters
6289                // the 3rd nested fib frame, sees cur_depth=3 > 2, and
6290                // trips this catch — closing with `SelfRecKind::UpRec`.
6291                // The lowerer's `TraceEnd::SelfLink` tail emits the
6292                // bump-base + branch-to-self loop body.
6293                //
6294                // TailRec vs UpRec: LJ distinguishes via
6295                // `framedepth + retdepth == 0`. luna doesn't track
6296                // retdepth separately; cur_depth == 0 with a non-empty
6297                // call chain in tail position is rare (would require
6298                // explicit Lua TCO). We use cur_depth > 0 as the UpRec
6299                // condition (fib's case); cur_depth == 0 with positive
6300                // ancestor count would route to TailRec, but luna's
6301                // recorder doesn't currently produce that shape because
6302                // tail-call elision pops the caller frame and we'd
6303                // hit `at_head_loop` instead.
6304                let self_link_trip: Option<crate::jit::trace::SelfRecKind> = {
6305                    if self.jit.self_link_enabled
6306                        && !returned_past_head
6307                        && std::ptr::eq(cl.proto.as_ptr(), rec.head_proto.as_ptr())
6308                        && pc == rec.head_pc
6309                        && cur_depth > 0
6310                    {
6311                        // Count ancestor frames sharing head_proto.
6312                        // self.frames[recording_frame_base..] currently
6313                        // includes the just-pushed frame at the top
6314                        // (the one about to execute head_pc). Ancestors
6315                        // = the slice excluding the top frame.
6316                        let head_proto_ptr = rec.head_proto.as_ptr();
6317                        let last_idx = self.frames.len() - 1;
6318                        let mut count = 0usize;
6319                        for i in self.jit.recording_frame_base..last_idx {
6320                            if let CallFrame::Lua(f) = &self.frames[i]
6321                                && std::ptr::eq(f.closure.proto.as_ptr(), head_proto_ptr)
6322                            {
6323                                count += 1;
6324                            }
6325                        }
6326                        if count > crate::jit::trace::RECUNROLL_THRESHOLD {
6327                            // cur_depth > 0 → UpRec (fib pattern).
6328                            // cur_depth == 0 wouldn't reach this arm.
6329                            Some(crate::jit::trace::SelfRecKind::UpRec)
6330                        } else {
6331                            None
6332                        }
6333                    } else {
6334                        None
6335                    }
6336                };
6337                if let Some(kind) = self_link_trip {
6338                    // SelfLink relax for self-recursive patterns at frame
6339                    // depth >= 2.
6340                    //
6341                    // Stamping `self_link_kind` unconditionally at the
6342                    // head_pc re-entry would never dispatch: the
6343                    // `downrec_close` marker can only fire from the
6344                    // depth>0 Op::Return path (`rec.retfs` chain),
6345                    // which never reaches the recorder for fib(28)-like
6346                    // shapes that hit the SelfLink cycle catch BEFORE
6347                    // any base-case Return — leaving `downrec_close`
6348                    // None and routing the trace through the safe
6349                    // `dispatchable=false` `"self-link-retf-r1"` path.
6350                    //
6351                    // So when the SelfLink trip fires AND
6352                    // `cur_depth >= 2` (the count > RECUNROLL_THRESHOLD
6353                    // gate already requires this — kept explicit as a
6354                    // safety floor), route the close through `downrec_
6355                    // close` INSTEAD of `self_link_kind`. The recorder
6356                    // synthesises the close marker from the most
6357                    // recent Op::Call at depth `cur_depth - 1`:
6358                    //   - `return_pc` = `call.pc + 1` (caller's resume
6359                    //     PC after the recursive call returns; mirror
6360                    //     of the `caller_pc` derivation at the
6361                    //     depth>0 Op::Return capture path below).
6362                    //   - `target_proto` = `call.proto` (caller's
6363                    //     proto; equals `rec.head_proto` for self-
6364                    //     recursion).
6365                    //   - `depth_delta` = `1` (the recorder always
6366                    //     unrolls one level; the Op::Return path uses
6367                    //     the same constant).
6368                    //
6369                    // The lowerer's `end_idx` picker routes through
6370                    // `TraceEnd::DownRec` ahead of the `self_link_kind`
6371                    // arm and emits the stitch-sentinel +
6372                    // caller-pc-guard scaffold. A single-candidate guard
6373                    // chain (this path produces 1 caller_pc candidate
6374                    // because `rec.retfs` is empty) keeps
6375                    // `dispatchable=false` + `"downrec-stitch-pending"`
6376                    // label (the lowerer requires
6377                    // `multi_way_candidate_count >= 2`). Net behaviour:
6378                    // trace compiles under DownRec routing; interp runs
6379                    // the recursion naturally.
6380                    //
6381                    // The `cur_depth >= 2` gate is automatically
6382                    // satisfied by the count > RECUNROLL_THRESHOLD=2
6383                    // trip condition (3 ancestor frames sharing
6384                    // head_proto implies cur_depth >= 3), kept
6385                    // explicit so a future RECUNROLL_THRESHOLD tweak
6386                    // doesn't silently flip shallow-recursion
6387                    // shapes (cur_depth == 1) onto the DownRec arm.
6388                    //
6389                    // The recorded body still uses depth-baked
6390                    // op_offsets[] addressing, so this is routing
6391                    // scaffolding and gives no speedup by itself.
6392                    let _ = kind;
6393                    let relaxed_to_downrec = cur_depth >= 2 && rec.downrec_close.is_none() && {
6394                        let caller_depth_u8 = (cur_depth - 1) as u8;
6395                        if let Some(call_op) = rec.ops.iter().rev().find(|r| {
6396                            r.inline_depth == caller_depth_u8
6397                                && matches!(r.inst.op(), crate::vm::isa::Op::Call)
6398                        }) {
6399                            rec.downrec_close = Some(crate::jit::trace::DownRecClose {
6400                                return_pc: call_op.pc + 1,
6401                                target_proto: call_op.proto,
6402                                depth_delta: 1,
6403                            });
6404                            true
6405                        } else {
6406                            false
6407                        }
6408                    };
6409                    if relaxed_to_downrec {
6410                        // Close-cause taxonomy: tag the lift so
6411                        // probes can tally the fire rate. Mirrors
6412                        // the `"downrec-restart"` bump for the
6413                        // depth>0 Op::Return path (different trip
6414                        // origin, same downstream routing). The
6415                        // existing `"self-link-retf-r1"` label still
6416                        // fires for trips that DON'T relax (no
6417                        // candidate Op::Call ancestor in rec.ops, or
6418                        // cur_depth < 2) via the lowerer's
6419                        // dispatch_off_reason mirror at the close
6420                        // handler — kept as a regression safety net.
6421                        self.jit
6422                            .counters
6423                            .bump_close_cause("selflink-yields-to-downrec");
6424                    } else {
6425                        rec.self_link_kind = Some(kind);
6426                    }
6427                }
6428                let should_close =
6429                    at_head_loop || returned_past_head || depth_cap_hit || self_link_trip.is_some();
6430                if should_close {
6431                    // Long-trace bias: a call-triggered
6432                    // recording that closed with a very short body
6433                    // (fib base case: `Lt`/`Jmp`/`Return1` = 3 ops,
6434                    // binary_trees `make(0)`: 4 ops) is pathological.
6435                    // Compiling + caching it pins `Proto.traces` to a
6436                    // trace that the length gate will refuse to
6437                    // dispatch (per `MIN_DISPATCHABLE_TRUNC_BODY_FLOOR
6438                    // = 40`), AND blocks the back-edge / longer-call
6439                    // path from re-recording the same head_pc (the
6440                    // dedup `already_cached` check below short-
6441                    // circuits). The fix: discard the short call-
6442                    // triggered recording WITHOUT caching, and bias
6443                    // the proto's `call_hot_count` back to
6444                    // `THRESHOLD - HOT_RETRY_WINDOW` so the next
6445                    // sequence of calls retries the trigger at a
6446                    // different (hopefully deeper) recursion point.
6447                    //
6448                    // Back-edge triggered traces are exempt — a
6449                    // tight numeric-for loop's body is legitimately
6450                    // 3 ops (`Add`, ForLoop) and DOES dispatch
6451                    // usefully when re-entered many times.
6452                    // Coverage heuristic to detect
6453                    // pathologically partial call-triggered traces:
6454                    // for self-recursive / branchy protos like
6455                    // `fib` (~17 bytecode ops) or
6456                    // `binary_trees.make` (~26 ops), the recorder
6457                    // can fire at a BASE-case entry (`fib(0)` or
6458                    // `make(0)`) producing a 3–4 op trace that
6459                    // covers a tiny fraction of the proto's code.
6460                    // That trace is doomed by the length gate
6461                    // post-compile AND blocks any longer follow-up
6462                    // (the dedup `already_cached` check below). The
6463                    // fix: discard call-triggered closes where
6464                    // `rec.ops.len() * 2 < head_proto.code.len()`
6465                    // (less than half the proto's bytecode), so the
6466                    // back-edge / longer call path can take over.
6467                    //
6468                    // Why coverage > raw length:protos with
6469                    // intrinsically short bodies (closure
6470                    // factories: `Closure + Return1` = 2 ops,
6471                    // simple wrappers: `LoadI + Return1` = 2 ops)
6472                    // record 100% coverage even at length 2 — those
6473                    // ARE legitimately short and the closure /
6474                    // sunk-emit lowering paths make
6475                    // them worth compiling. The heuristic admits
6476                    // them. fib's `[Lt, Jmp, Return1]` (3 of ~17)
6477                    // and make's `[Lt, Jmp, LoadI, Return1]` (4 of
6478                    // ~26) get discarded.
6479                    //
6480                    // Back-edge triggered traces are unaffected —
6481                    // a tight numeric-for body legitimately covers
6482                    // 3 of ~3 proto ops it can dispatch from
6483                    // (`Add + ForLoop`) and the recorder fires on
6484                    // the back-edge, not call entry.
6485                    //
6486                    // `call_hot_count` is intentionally NOT reset
6487                    // (an earlier draft tried `THRESHOLD - 32` but
6488                    // caused active_trace contention with the
6489                    // outer back-edge trigger — see
6490                    // setlist_b_zero_with_call_c_zero_sunk_emits).
6491                    // We give up on dispatching the pathological
6492                    // shape on the same proto; the back-edge or a
6493                    // longer call path on a deeper recursion point
6494                    // can still record + cache a real trace.
6495                    let proto_code_len = rec.head_proto.code.len();
6496                    let is_partial_coverage = rec.ops.len() * 2 < proto_code_len;
6497                    // Per-Proto discard cap. The relaxed
6498                    // trigger condition (`c >= THRESHOLD &&
6499                    // !already_cached`) means a Proto whose every
6500                    // recording is partial-coverage will re-fire the
6501                    // trigger every call indefinitely (1500+ in
6502                    // `binary_trees`-pattern test). The cap stops
6503                    // discarding after `MAX_DISCARDS_PER_PROTO` —
6504                    // the next close falls through to compile (even
6505                    // if partial), caches the trace, and the
6506                    // `already_cached` short-circuit kills the
6507                    // storm. Dispatch may still be refused
6508                    // post-compile (length gate), but the recorder
6509                    // stops churning.
6510                    const MAX_DISCARDS_PER_PROTO: u32 = 5;
6511                    let prior_discards = rec.head_proto.trace_discard_count.get();
6512                    let cap_reached = prior_discards >= MAX_DISCARDS_PER_PROTO;
6513                    // Flip the `gave_up` flag the
6514                    // moment cap is reached (BEFORE the close-
6515                    // dispatching branch below). The trigger gates
6516                    // short-circuit on this flag, skipping the
6517                    // RefCell + linear `already_cached` scan on
6518                    // every subsequent call to this Proto. Useful
6519                    // for `binary_trees_pattern`-class loads where
6520                    // a single Proto sees ~20k calls post-cap.
6521                    if cap_reached
6522                        && rec.is_call_triggered
6523                        && is_partial_coverage
6524                        && !rec.head_proto.trace_gave_up.get()
6525                    {
6526                        rec.head_proto.trace_gave_up.set(true);
6527                    }
6528                    if rec.is_call_triggered && is_partial_coverage && !cap_reached {
6529                        // Tally as closed (for visibility) but DROP
6530                        // without compile/cache. Use the existing
6531                        // closed-lens accumulator so probes can
6532                        // observe the discarded shape.
6533                        // Bump discard count BEFORE
6534                        // dropping the recording so the next
6535                        // close sees the updated counter.
6536                        rec.head_proto.trace_discard_count.set(prior_discards + 1);
6537                        self.jit.counters.closed += 1;
6538                        self.jit
6539                            .counters
6540                            .closed_lens
6541                            .push((rec.is_call_triggered, rec.ops.len()));
6542                        // Partial-coverage discard close path.
6543                        // `closed` + `closed_lens` alone can't separate
6544                        // a real successful close from a discard tally,
6545                        // so tag explicitly to keep the recorder-side
6546                        // close-cause taxonomy single-source.
6547                        self.jit
6548                            .counters
6549                            .bump_close_cause("partial-coverage-discard");
6550                        self.jit.active_trace = None;
6551                        // Continue with interp loop — don't
6552                        // fall through to compile path.
6553                        // The op at `pc` hasn't dispatched yet;
6554                        // the outer loop iteration handles it.
6555                    } else {
6556                        rec.closed = true;
6557                        // Detach the closed record, then try
6558                        // to compile it. Dedup by `head_pc`: a Proto
6559                        // already carrying a CompiledTrace for this PC
6560                        // skips recompile (the hot counter caps
6561                        // re-recording at `u32::MAX / 2` anyway, but
6562                        // explicit dedup keeps `Proto.traces` short
6563                        // for the dispatcher's linear scan).
6564                        //
6565                        // On failure we just bump the failed counter
6566                        // and drop the record.
6567                        let head_pc_val = rec.head_pc;
6568                        let closed_record = self
6569                            .jit
6570                            .active_trace
6571                            .take()
6572                            .expect("active_trace was Some this branch");
6573                        self.jit.counters.closed += 1;
6574                        self.jit
6575                            .counters
6576                            .closed_lens
6577                            .push((closed_record.is_call_triggered, closed_record.ops.len()));
6578                        // Cache the trace on the
6579                        // recorder's *head proto*, not the current
6580                        // closure's proto. For non-recursive
6581                        // call-triggered traces, close fires after
6582                        // `Return1` pops the callee frame — `cl` at
6583                        // that point is the CALLER's closure, while
6584                        // `closed_record.head_proto` is the CALLEE's
6585                        // proto (the one we actually want the trace
6586                        // to be discoverable from on the next call).
6587                        // Self-recursive fib closed via depth-cap
6588                        // mid-recursion, so `cl.proto == head_proto`
6589                        // there, but only by coincidence.
6590                        let head_proto = closed_record.head_proto;
6591                        let already_cached = head_proto
6592                            .traces
6593                            .borrow()
6594                            .iter()
6595                            .any(|t| t.head_pc == head_pc_val);
6596                        if !already_cached {
6597                            // Internal-loop = true: the trace runs in
6598                            // a native loop until a cmp side-exits, so
6599                            // the dispatcher's per-entry marshal cost
6600                            // amortizes across the whole run of
6601                            // iterations the loop's recorded direction
6602                            // stays valid. The lowerer auto-downgrades
6603                            // to one-shot for cmp-less or Call-truncating
6604                            // traces.
6605                            // Side traces MUST NOT
6606                            // internal-loop. The parent's recorded prefix
6607                            // (ops at PCs < side trace's head_pc) defines
6608                            // values for registers the child's body reads
6609                            // without re-writing each iter — e.g. for
6610                            // s12_step_b, parent's `pc=19 Add R[12] = R[1]
6611                            // + R[11]` sets R[12], and the child trace
6612                            // (head_pc=24) re-runs `pc=20 Move R[1] =
6613                            // R[12]` each iter via its outer ForLoop
6614                            // internal-loop, ALWAYS reading the stale
6615                            // entry-time R[12]. The parent's Add never
6616                            // re-runs during child's loop, so R[1] gets
6617                            // pinned to one stale value. Force one-shot
6618                            // for side traces: each parent-exit round-
6619                            // trips through dispatcher → parent's Add
6620                            // runs → side trace runs ONE iter → return.
6621                            let opts = crate::jit::trace::CompileOptions {
6622                                internal_loop: closed_record.side_trace_parent.is_none(),
6623                                pre53: self.version() <= LuaVersion::Lua53,
6624                                aot: false,
6625                            };
6626                            // Route through trace_compiler; split-borrow JitState
6627                            // so the trait method can take `&mut dyn JitStorage`.
6628                            let result = {
6629                                let jit = &mut self.jit;
6630                                let storage: &mut dyn crate::jit::JitStorage = jit.storage.as_mut();
6631                                jit.trace_compiler
6632                                    .try_compile_trace(storage, &closed_record, opts)
6633                            };
6634                            match result {
6635                                Some(mut ct) => {
6636                                    // Tally Sinkable sites
6637                                    // + actually-sunk-emit sites + materialise
6638                                    // emit sites before moving `ct` into
6639                                    // Proto.traces.
6640                                    self.jit.counters.sinkable_seen +=
6641                                        ct.sinkable_sites_seen as u64;
6642                                    self.jit.counters.accum_bufferable_seen +=
6643                                        ct.accum_bufferable_seen as u64;
6644                                    self.jit.counters.sunk_alloc += ct.sunk_alloc_seen as u64;
6645                                    self.jit.counters.materialize_emit +=
6646                                        ct.materialize_emit_count as u64;
6647                                    self.jit.counters.closure_emit += ct.closure_seen as u64;
6648                                    if ct.is_inline_abort_close {
6649                                        self.jit.counters.inline_abort += 1;
6650                                    }
6651                                    // Split tally so a
6652                                    // probe can answer the AOT
6653                                    // `accepted_with_per_exit_inline`
6654                                    // gate's question at the JIT
6655                                    // surface too: how many compiled
6656                                    // traces emitted depth>0 cmp
6657                                    // side-exits, and how many of
6658                                    // those survived all the
6659                                    // `dispatchable = false` pins
6660                                    // (`InlineAbort-gate`,
6661                                    // `self-link-retf-r1`,
6662                                    // `downrec-stitch-pending`, etc.).
6663                                    if !ct.per_exit_inline.is_empty() {
6664                                        self.jit.counters.per_exit_inline_compiled += 1;
6665                                        if ct.dispatchable {
6666                                            self.jit.counters.per_exit_inline_dispatchable += 1;
6667                                        }
6668                                    }
6669                                    if let Some(reason) = ct.dispatch_off_reason {
6670                                        self.jit.counters.dispatch_off_reasons.push(reason);
6671                                        // Mirror
6672                                        // the ordered Vec push into
6673                                        // the per-reason HashMap so
6674                                        // probes can answer "how many
6675                                        // of each dispatch_off label
6676                                        // fired" in O(1) without
6677                                        // walking the Vec. Same
6678                                        // bucket as the recorder-side
6679                                        // abort/discard tags above.
6680                                        self.jit.counters.bump_close_cause(reason);
6681                                    }
6682                                    // Count compiled traces that
6683                                    // carry a down-recursion stitch
6684                                    // link. Bumped here (not at the
6685                                    // lowerer emit site) because the
6686                                    // Vm's JitCounters live on the Vm,
6687                                    // and the lowerer doesn't have a
6688                                    // Vm handle. Read via
6689                                    // `Vm::trace_downrec_link_compiled_count`.
6690                                    if ct.downrec_link.is_some() {
6691                                        self.jit.counters.downrec_link_compiled += 1;
6692                                    }
6693                                    // Multi-way guard emit counter.
6694                                    // Bumped when the lowerer collected
6695                                    // >= 2 distinct caller_pc candidates
6696                                    // and lifted `dispatchable=true`.
6697                                    // The single-CMP shape stores
6698                                    // `1` here without bumping; non-
6699                                    // DownRec closes store `0`.
6700                                    if ct.downrec_multi_way_count >= 2 {
6701                                        self.jit.counters.multi_way_guard_emitted += 1;
6702                                    }
6703                                    // Side-trace finalisation.
6704                                    // Pin `dispatchable=false` so the
6705                                    // primary lookup `traces.find(|t|
6706                                    // t.head_pc == pc && t.dispatchable)`
6707                                    // never matches this entry — the
6708                                    // side trace is meant to be entered
6709                                    // ONLY through the parent's exit
6710                                    // indirection, not the
6711                                    // back-edge / call-trigger paths.
6712                                    // Then write the entry fn ptr into
6713                                    // the parent's `exit_side_trace_ptrs`
6714                                    // slot so the parent's IR can read it.
6715                                    if let Some((parent_proto, parent_head_pc, parent_exit_idx)) =
6716                                        closed_record.side_trace_parent
6717                                    {
6718                                        // The lowerer's own verdict: a trace it
6719                                        // compiled but would not dispatch (an
6720                                        // untyped table read, an inline abort)
6721                                        // is not safe to enter from the parent's
6722                                        // exit either — unless the only reason
6723                                        // was the length gate, which weighs
6724                                        // dispatch overhead, not soundness (the
6725                                        // lowerer records the other reasons
6726                                        // first).
6727                                        let runnable = ct.dispatchable
6728                                            || ct.dispatch_off_reason == Some("length-gate");
6729                                        ct.dispatchable = false;
6730                                        let entry_ptr = ct.entry as *const () as *const u8;
6731                                        let _side_trace_head_pc = closed_record.head_pc;
6732                                        let parent_traces = parent_proto.traces.borrow();
6733                                        if let Some(parent_ct) = parent_traces
6734                                            .iter()
6735                                            .find(|t| t.head_pc == parent_head_pc)
6736                                        {
6737                                            // Shape-match
6738                                            // gate. Find the parent's per-exit
6739                                            // tag snapshot at the wired exit
6740                                            // (inline / tag / global) and
6741                                            // check the child's entry_tags
6742                                            // match. If not, leave the cell
6743                                            // null + skip cache populate so
6744                                            // the parent IR's
6745                                            // `call_indirect` stays inert at
6746                                            // this exit (the child's
6747                                            // shape-specialised IR would
6748                                            // mis-interpret raw bits the
6749                                            // parent writes to reg_state).
6750                                            let inline_n = parent_ct.per_exit_inline.len();
6751                                            let tags_n = parent_ct.per_exit_tags.len();
6752                                            let parent_exit_tags_slice: &[
6753                                            crate::jit::trace::ExitTag
6754                                        ] = if parent_exit_idx < inline_n {
6755                                            &parent_ct.per_exit_inline
6756                                                [parent_exit_idx]
6757                                                .exit_tags
6758                                        } else if parent_exit_idx
6759                                            < inline_n + tags_n
6760                                        {
6761                                            &parent_ct.per_exit_tags
6762                                                [parent_exit_idx - inline_n]
6763                                                .1
6764                                        } else {
6765                                            &parent_ct.exit_tags
6766                                        };
6767                                            let shape_matches =
6768                                                crate::jit::trace::exit_tags_match_entry_tags(
6769                                                    &ct.entry_tags,
6770                                                    parent_exit_tags_slice,
6771                                                    &parent_ct.entry_tags,
6772                                                );
6773                                            if !shape_matches {
6774                                                self.jit.counters.side_trace_shape_mismatch += 1;
6775                                            }
6776                                            let shape_ok = runnable && shape_matches;
6777                                            // Write the child's
6778                                            // entry fn ptr to BOTH the legacy
6779                                            // `exit_side_trace_ptrs[idx]`
6780                                            // cell (read by the
6781                                            // walk_any_side_ptr_non_null tests)
6782                                            // AND the per-kind cell
6783                                            // whose heap address the parent's
6784                                            // IR baked. The IR-baked
6785                                            // cell is what the call_indirect
6786                                            // gate actually reads. Only write
6787                                            // when the shape gate passes.
6788                                            if shape_ok {
6789                                                if let Some(cell) = parent_ct
6790                                                    .exit_side_trace_ptrs
6791                                                    .get(parent_exit_idx)
6792                                                {
6793                                                    cell.set(entry_ptr);
6794                                                }
6795                                                // Compute (kind, local) for the
6796                                                // IR-baked cell. Layout follows
6797                                                // exit_hit_counts: inline first,
6798                                                // then per_exit_tags, then the
6799                                                // global tail slot.
6800                                                let (sent_kind, sent_local) = if parent_exit_idx
6801                                                    < inline_n
6802                                                {
6803                                                    parent_ct.per_exit_inline[parent_exit_idx]
6804                                                        .side_trace_ptr
6805                                                        .set(entry_ptr);
6806                                                    (
6807                                                        crate::jit::trace::SIDE_SENT_KIND_INLINE,
6808                                                        parent_exit_idx as u32,
6809                                                    )
6810                                                } else if parent_exit_idx < inline_n + tags_n {
6811                                                    let local = parent_exit_idx - inline_n;
6812                                                    if let Some(b) =
6813                                                        parent_ct.tags_side_trace_ptrs.get(local)
6814                                                    {
6815                                                        b.set(entry_ptr);
6816                                                    }
6817                                                    (
6818                                                        crate::jit::trace::SIDE_SENT_KIND_TAG,
6819                                                        local as u32,
6820                                                    )
6821                                                } else {
6822                                                    parent_ct.global_side_trace_ptr.set(entry_ptr);
6823                                                    (crate::jit::trace::SIDE_SENT_KIND_GLOBAL, 0)
6824                                                };
6825                                                self.jit.counters.side_trace_compiled += 1;
6826                                                // Flip the
6827                                                // parent's fast-path hint so
6828                                                // the dispatcher knows to do
6829                                                // the tentative decode + cell
6830                                                // check on subsequent
6831                                                // dispatches. Set once and
6832                                                // stays true (we never unwire
6833                                                // a side trace today).
6834                                                parent_ct.has_any_side_wired.set(true);
6835
6836                                                // Populate
6837                                                // the O(1) lookup cache the
6838                                                // dispatcher consults on
6839                                                // sentinel-bit-set returns.
6840                                                // Key is the encoded sentinel
6841                                                // (same encoding the IR ORs
6842                                                // into bits 56..=62 of the
6843                                                // child's i64 return).
6844                                                let sentinel =
6845                                                    crate::jit::trace::encode_side_sentinel(
6846                                                        sent_kind, sent_local,
6847                                                    );
6848                                                let predicted_idx = if std::ptr::eq(
6849                                                    parent_proto.as_ptr(),
6850                                                    head_proto.as_ptr(),
6851                                                ) {
6852                                                    parent_traces.len() as u32
6853                                                } else {
6854                                                    head_proto.traces.borrow().len() as u32
6855                                                };
6856                                                parent_ct
6857                                                    .side_trace_cache
6858                                                    .borrow_mut()
6859                                                    .insert(sentinel, predicted_idx);
6860                                            }
6861                                        }
6862                                        drop(parent_traces);
6863                                    }
6864                                    head_proto.traces.borrow_mut().push(TArc::new(ct));
6865                                    self.jit.counters.compiled += 1;
6866                                }
6867                                None => {
6868                                    self.jit.counters.compile_failed += 1;
6869                                    note_trace_compile_failure(head_proto, closed_record.head_pc);
6870                                    self.jit
6871                                        .counters
6872                                        .compile_failed_reasons
6873                                        .push(self.jit.trace_compiler.last_compile_checkpoint());
6874                                }
6875                            }
6876                        }
6877                    } // close the long-trace-bias else branch
6878                } else {
6879                    // Depth-aware push at the
6880                    // current `cur_depth`. The `depth_cap_hit` /
6881                    // `returned_past_head` early-exit is handled by
6882                    // the `should_close` branch above; reaching here
6883                    // means `cur_depth <= MAX_INLINE_DEPTH` and the
6884                    // trace head's frame is still live.
6885                    let depth_u8 = cur_depth as u8;
6886                    if depth_u8 > self.jit.max_depth_seen {
6887                        self.jit.max_depth_seen = depth_u8;
6888                    }
6889                    // Fix up a prior `Op::Call C=0` (multi-
6890                    // return / variable return count). Recorder pushed
6891                    // it with var_count=None before the call dispatched;
6892                    // now that the call has returned and we're about to
6893                    // push the next op, top reflects the actual return
6894                    // count. Snapshot top - (caller.base + call.a).
6895                    if let Some(last) = rec.ops.last_mut()
6896                        && matches!(last.inst.op(), crate::vm::isa::Op::Call)
6897                        && last.inst.c() == 0
6898                        && last.var_count.is_none()
6899                        && let Some(f) = self.frames.last().and_then(CallFrame::lua)
6900                    {
6901                        let from = f.base + last.inst.a();
6902                        if self.top >= from {
6903                            last.var_count = Some(self.top - from);
6904                        }
6905                    }
6906                    // For SetList B=0, snapshot the source
6907                    // count = top - A - 1 (mirrors Lua's `n = top - ra
6908                    // - 1` from lvm.c OP_SETLIST). Sources are
6909                    // R[A+1..top), exclusive top. For Call C=0's
6910                    // var_count (the return count = top - A inclusive),
6911                    // see the prior-op fix-up above; here we
6912                    // initialise the current Call op to None and let
6913                    // the fix-up on the next op's push populate it.
6914                    let var_count = if matches!(inst.op(), crate::vm::isa::Op::SetList)
6915                        && inst.b() == 0
6916                        && let Some(f) = self.frames.last().and_then(CallFrame::lua)
6917                    {
6918                        let from = f.base + inst.a();
6919                        if self.top > from {
6920                            Some(self.top - from - 1)
6921                        } else {
6922                            None
6923                        }
6924                    } else {
6925                        None
6926                    };
6927                    let op = crate::jit::trace::RecordedOp {
6928                        proto: cl.proto,
6929                        pc,
6930                        inst,
6931                        inline_depth: depth_u8,
6932                        var_count,
6933                    };
6934                    // Depth>0 Return0/Return1 mirrors
6935                    // LuaJIT's `IR_RETF` (lj_record.c:922+ lj_record_ret).
6936                    // Captured as a side-channel `RetfRecord` parallel to
6937                    // `ops` when `self_link_enabled` is on. The
6938                    // down-rec stitch consumes these to guard side-trace
6939                    // inlined-frame topology against the recorded shape.
6940                    // Gated on the same flag as the cycle catch so the
6941                    // ship-default path (p16 off) sees zero behavior
6942                    // change. `caller_pc` is the recorded enclosing Call's
6943                    // pc + 1 — interp's resume point after the inlined
6944                    // frame pops.
6945                    if self.jit.self_link_enabled
6946                        && depth_u8 > 0
6947                        && matches!(
6948                            inst.op(),
6949                            crate::vm::isa::Op::Return0 | crate::vm::isa::Op::Return1
6950                        )
6951                    {
6952                        let results: u8 = match inst.op() {
6953                            crate::vm::isa::Op::Return0 => 0,
6954                            crate::vm::isa::Op::Return1 => 1,
6955                            _ => 0,
6956                        };
6957                        // Most recent Op::Call recorded at the caller's
6958                        // depth (`depth_u8 - 1`) is the frame this Return
6959                        // is unwinding from. Reverse scan stops at the
6960                        // first match.
6961                        let caller_depth = depth_u8 - 1;
6962                        let caller_call = rec.ops.iter().rev().find(|r| {
6963                            r.inline_depth == caller_depth
6964                                && matches!(r.inst.op(), crate::vm::isa::Op::Call)
6965                        });
6966                        let caller_pc = caller_call.map(|r| r.pc + 1).unwrap_or(pc);
6967                        // Capture the caller's proto
6968                        // for the RetfRecord. LuaJIT `IR_RETF.op1`
6969                        // equivalent. For fib(28) the caller's proto
6970                        // equals the trace head; for future mutual
6971                        // recursion the recorded Op::Call's proto is the
6972                        // right target. Fallback to head_proto when no
6973                        // enclosing Call op was captured (mirrors
6974                        // `caller_pc`'s fallback to the Return's own pc).
6975                        let caller_proto = caller_call.map(|r| r.proto).unwrap_or(rec.head_proto);
6976                        rec.retfs.push(crate::jit::trace::RetfRecord {
6977                            from_depth: depth_u8,
6978                            to_depth: caller_depth,
6979                            results,
6980                            caller_pc,
6981                            proto: caller_proto,
6982                        });
6983                        // DownRec close trigger:
6984                        // count RetfRecords on this recording whose
6985                        // `proto` matches `caller_proto` (LuaJIT
6986                        // `check_downrec_unroll` chain filter
6987                        // `op1 == ptref`). Threshold mirrors
6988                        // RECUNROLL_THRESHOLD; first trip stamps the
6989                        // `downrec_close` marker, subsequent retfs
6990                        // keep the marker without overwrite. The
6991                        // lowerer's end_idx picker routes through
6992                        // TraceEnd::DownRec when the marker is set.
6993                        if rec.downrec_close.is_none() {
6994                            let caller_proto_ptr = caller_proto.as_ptr();
6995                            let prior_match_count = rec
6996                                .retfs
6997                                .iter()
6998                                .filter(|r| r.proto.as_ptr() == caller_proto_ptr)
6999                                .count();
7000                            // Strictly-greater-than threshold matches
7001                            // LuaJIT `count + J->tailcalled > recunroll`.
7002                            // The newly-pushed retf is already counted.
7003                            if prior_match_count > crate::jit::trace::RECUNROLL_THRESHOLD {
7004                                rec.downrec_close = Some(crate::jit::trace::DownRecClose {
7005                                    return_pc: caller_pc,
7006                                    target_proto: caller_proto,
7007                                    depth_delta: 1,
7008                                });
7009                                // Close-cause taxonomy: tag the
7010                                // restart with `"downrec-restart"`. The
7011                                // lowerer adds `"downrec-stitch-failed"` when
7012                                // the lifted back-edge falls back to
7013                                // deopt.
7014                                self.jit.counters.bump_close_cause("downrec-restart");
7015                            }
7016                        }
7017                    }
7018                    // Capture FieldIcSnapshot for the
7019                    // FIRST eligible Op::GetField site under env-gate
7020                    // LUNA_JIT_FIELD_IC=1. "Eligible" means:
7021                    //   - R[B] is Value::Table with metatable.is_none()
7022                    //   - K[C] is Value::Str
7023                    //   - The string key actually occupies a hash slot
7024                    //     (so the IC's slot_idx is a real index, not
7025                    //     a probe sentinel).
7026                    // Once captured, subsequent GetFields skip this
7027                    // logic (rec.field_ic_snapshot.is_some() short-
7028                    // circuits). Env-OFF short-circuits on the cached
7029                    // atomic check inside field_ic_enabled().
7030                    if rec.field_ic_snapshot.is_none()
7031                        && matches!(inst.op(), crate::vm::isa::Op::GetField)
7032                        && crate::jit::trace_types::field_ic_enabled()
7033                    {
7034                        let b = inst.b();
7035                        let c_idx = inst.c() as usize;
7036                        let r_b = self.stack[(base + b) as usize];
7037                        if let Value::Table(g) = r_b
7038                            && g.metatable().is_none()
7039                            && c_idx < cl.proto.consts.len()
7040                            && let Value::Str(s) = cl.proto.consts[c_idx]
7041                        {
7042                            let key = Value::Str(s);
7043                            let tbl_ref = &*g;
7044                            if let Some(slot_idx) = tbl_ref.find_node_idx(key)
7045                                && let Some(val) = tbl_ref.node_val_at(slot_idx)
7046                            {
7047                                let op_idx = rec.ops.len() as u32;
7048                                rec.field_ic_snapshot =
7049                                    Some(crate::jit::trace_types::FieldIcSnapshot {
7050                                        op_idx,
7051                                        nodes_len: tbl_ref.nodes_capacity() as u64,
7052                                        slot_idx: slot_idx as u64,
7053                                        key_ptr_bits: s.as_ptr() as u64,
7054                                        cached_val_tag: val.tag_byte(),
7055                                    });
7056                                self.jit.counters.field_ic_snapshot_captured += 1;
7057                            }
7058                        }
7059                    }
7060                    if !rec.push(op) {
7061                        // Recorder overflow (MAX_TRACE_LEN). Tag it
7062                        // explicitly under the close-cause bucket so
7063                        // probes can tally overflow vs other abort
7064                        // causes in O(1).
7065                        self.jit.active_trace = None;
7066                        self.jit.counters.aborted += 1;
7067                        self.jit.counters.bump_close_cause("trace-overflow");
7068                    }
7069                }
7070            }
7071
7072            // Trace JIT dispatcher.
7073            //
7074            // When the dispatch loop is about to execute the op at
7075            // `pc` and there's a `numeric_only` CompiledTrace cached
7076            // for that `head_pc`, marshal the live regs into an
7077            // i64 buffer, jump into the trace, and resume the
7078            // interpreter at the returned continuation PC.
7079            //
7080            // Skipped (zero overhead) when `trace_jit_enabled` is
7081            // false; the lookup is a borrow + scan over
7082            // `cl.proto.traces`, which is a `Vec` whose size is at
7083            // most one entry per back-edge per Proto in practice.
7084            //
7085            // Marshalling contract — only Int slots survive the
7086            // round-trip cleanly (the reg_state ABI is `*mut i64`
7087            // with no tag info). Any non-Int slot in the affected
7088            // window forces a skip; interp takes over for one op
7089            // and the back-edge brings us back to try again next
7090            // pass (slots that were Nil/Float at one moment can
7091            // settle to Int by the time the next back-edge fires).
7092            //
7093            // A trace that comes back with `vm.jit.pending_err`
7094            // parked is treated as a deopt: clear the err, leave
7095            // the stack as the trace wrote it, and let the
7096            // interpreter run from the same `pc`. The trace itself
7097            // is left cached — a future entry might find no
7098            // metatable in the way and succeed.
7099            // Single Rc<CompiledTrace> clone instead of per-field Rc
7100            // clones: proto.traces is Vec<Rc<CompiledTrace>>; the
7101            // dispatcher clones ONE Rc and reads fields via auto-deref.
7102            // One-shot consume of the
7103            // `suppress_downrec_admit_once` flag. Set by the
7104            // downrec post-invoke arm below when it force-deopts the
7105            // trace (caller-pc guard miss OR cycle-budget exhausted)
7106            // so the NEXT interpreter loop iteration skips the
7107            // downrec admit, lets interp run the op at `head_pc`,
7108            // advances `pc` past `head_pc`, and breaks the otherwise-
7109            // infinite admit loop. Reading + clearing here means a
7110            // single dispatch tick consumes the suppression — the
7111            // following tick re-admits naturally (with the budget
7112            // also reset by the deopt site).
7113            let downrec_admit_blocked = self.jit.suppress_downrec_admit_once;
7114            self.jit.suppress_downrec_admit_once = false;
7115            if self.jit.trace_enabled
7116                && let Some(ct) = {
7117                    let traces = cl.proto.traces.borrow();
7118                    traces
7119                        .iter()
7120                        .find(|t| {
7121                            if t.head_pc != pc {
7122                                return false;
7123                            }
7124                            let is_downrec = t.downrec_link.is_some();
7125                            // The one-shot suppress
7126                            // flag blocks any admit (primary or fallback)
7127                            // for `downrec_link`-bearing traces so the
7128                            // next interp iter can run the natural op
7129                            // at `head_pc` and advance past it. The multi-way
7130                            // `dispatchable=true` lift means the suppress
7131                            // must also cover the primary `t.dispatchable`
7132                            // arm — otherwise the lifted lookup would
7133                            // immediately re-admit after a force-deopt
7134                            // and the infinite loop returns.
7135                            if downrec_admit_blocked {
7136                                return false;
7137                            }
7138                            // Primary arm: `dispatchable=true` traces
7139                            // (lifted multi-way DownRec or normal traces).
7140                            // Fallback arm: single-CMP `dispatchable=false`
7141                            // DownRec traces (single-CMP guard kept
7142                            // pinned because the 90% miss-rate would
7143                            // make blind admit perf-negative).
7144                            t.dispatchable || is_downrec
7145                        })
7146                        .cloned()
7147                }
7148            {
7149                // Borrow Rc<[T]> fields as &Rc<[T]> instead
7150                // of cloning. The outer `ct: Rc<CompiledTrace>` is held
7151                // across the entire dispatch block so the fields outlive
7152                // all consumers.
7153                let entry_fn = ct.entry;
7154                let head_pc_val = ct.head_pc;
7155                let window_size = ct.window_size;
7156                let exit_tags = &ct.exit_tags;
7157                let per_exit_tags = &ct.per_exit_tags;
7158                let per_exit_inline = &ct.per_exit_inline;
7159                let compile_entry_tags = &ct.entry_tags;
7160                let global_tag_res_kind = ct.global_tag_res_kind;
7161                let exit_hit_counts = &ct.exit_hit_counts;
7162                let max_stack = cl.proto.max_stack as usize;
7163                let window_size_us = window_size as usize;
7164                let base_us = base as usize;
7165                // `reg_state` sized to the trace's `window_size`, which
7166                // may exceed max_stack.
7167                // Marshal-in still only writes [0..max_stack); slots
7168                // [max_stack..window_size) are zero-initialised and
7169                // filled by the trace's own GetUpval / arith.
7170                // Reuse the Vm's amortised buffers
7171                // instead of allocating fresh Vecs each dispatch.
7172                // mem::take leaves an empty placeholder we restore
7173                // at the end of the dispatch block (success +
7174                // deopt paths both fall through to the restore).
7175                let mut entry_tags: Vec<u8> = std::mem::take(&mut self.jit.entry_tags_buf);
7176                entry_tags.clear();
7177                entry_tags.reserve(max_stack);
7178                // This trace was admitted via the
7179                // `downrec_link.is_some()` arm rather than the normal
7180                // `dispatchable=true` arm. The pre-invoke path
7181                // populates a reserved saved-PC slot just past the
7182                // normal register window so the lowerer's guard load
7183                // (`reg_state[window_size]`) compares the runtime
7184                // saved caller PC against the recorded `dr_return_pc`.
7185                //
7186                // No `!ct.dispatchable` gate: when the lowerer lifts
7187                // `dispatchable = true` for multi-way guards, the
7188                // trace's body still emits the downrec sentinel shape
7189                // on return — the saved-PC slot
7190                // and post-invoke classifier must keep firing.
7191                // `downrec_link.is_some()` is the unique structural
7192                // signal that the trace closes via DownRec.
7193                let is_downrec_entry = ct.downrec_link.is_some();
7194                let mut reg_state: Vec<i64> = std::mem::take(&mut self.jit.reg_state_buf);
7195                reg_state.clear();
7196                // When admitting a downrec trace,
7197                // size the buffer to `window_size + 1` so the lowerer
7198                // can `load(I64, ..., reg_state, window_size * 8)`
7199                // for the saved caller PC guard input. The extra slot
7200                // is the LAST element so cranelift's existing
7201                // `0..window_size` accesses are unaffected.
7202                let reg_state_len = if is_downrec_entry {
7203                    window_size_us + 1
7204                } else {
7205                    window_size_us
7206                };
7207                reg_state.resize(reg_state_len, 0i64);
7208                let mut dispatch_ok = true;
7209                for i in 0..max_stack {
7210                    let v = self.stack[base_us + i];
7211                    let (tag, raw) = v.unpack();
7212                    entry_tags.push(tag);
7213                    // Entry tag guard. The trace's IR
7214                    // is specialised to the compile-time entry tags
7215                    // (via current_kinds propagation from
7216                    // from_entry_tag). A runtime tag mismatch means
7217                    // body ops would mis-interpret raw bits (e.g.
7218                    // treat a Str pointer as Int payload → garbage).
7219                    // Skip dispatch on mismatch so interp handles
7220                    // this entry shape; the trace stays cached for
7221                    // future entries that match.
7222                    if i < compile_entry_tags.len() && tag != compile_entry_tags[i] {
7223                        dispatch_ok = false;
7224                        break;
7225                    }
7226                    match tag {
7227                        // Int / Float / Table / Nil all marshal
7228                        // to raw payload cleanly; the trace's IR
7229                        // treats the 8-byte slot as an i64 (with
7230                        // f64 ops bitcasting around the boundary).
7231                        crate::runtime::value::raw::INT
7232                        | crate::runtime::value::raw::FLOAT
7233                        | crate::runtime::value::raw::TABLE
7234                        | crate::runtime::value::raw::CLOSURE
7235                        // Native iter slots (e.g.
7236                        // R[A] = ipairs_iter) are present in
7237                        // generic-for traces; the raw bits are a
7238                        // valid `*mut NativeClosure` and round-trip
7239                        // cleanly.
7240                        | crate::runtime::value::raw::NATIVE
7241                        // Str slots show up in
7242                        // string-concat traces; raw bits = `*mut
7243                        // LuaStr` (interned, GC-managed). Round-
7244                        // trips cleanly as a heap pointer.
7245                        | crate::runtime::value::raw::STR
7246                        | crate::runtime::value::raw::NIL => {
7247                            // 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).
7248                            reg_state[i] = unsafe { raw.zero as i64 };
7249                        }
7250                        _ => {
7251                            dispatch_ok = false;
7252                            break;
7253                        }
7254                    }
7255                }
7256
7257                if dispatch_ok {
7258                    debug_assert_eq!(head_pc_val, pc, "trace cache hit's head_pc != pc");
7259                    // A recording in progress cannot see what the trace runs
7260                    // natively: it would resume after the trace with ops
7261                    // missing, and could close as a loop that never ran (a
7262                    // side trace of two ops returning its own head, which
7263                    // the dispatcher then entered forever). Drop it.
7264                    if self.jit.active_trace.take().is_some() {
7265                        self.jit.counters.aborted += 1;
7266                        self.jit.counters.bump_close_cause("reached-compiled-trace");
7267                    }
7268                    self.jit.pending_err = None;
7269                    // Snapshot the pre-entry frame
7270                    // count. A cmp@d>0 side-exit calls the materialize
7271                    // helper which pushes inlined frames onto
7272                    // `vm.frames`; on deopt those frames must be popped
7273                    // before falling through to the interpreter, else
7274                    // the stack grows unboundedly per deopted dispatch.
7275                    let pre_frames = self.frames.len();
7276                    // Saved-PC slot population. The
7277                    // recorded `dr_return_pc` on the closing trace is
7278                    // the caller's resume PC captured at a depth>0
7279                    // Return push (recorder push site). The natural runtime analogue for self-
7280                    // stitch is the dispatching frame's PARENT frame's
7281                    // PC: the trace's head_pc sits inside a Lua frame,
7282                    // and the parent (caller) frame's `pc` is what
7283                    // luna would observe as `[base-8]` in the LJ
7284                    // `asm_retf` shape (`lj_asm_arm64.h:565`). When
7285                    // the parent isn't a Lua frame (top-level dispatch
7286                    // — first invocation through `call_value`), no
7287                    // saved PC exists; we write 0, which always
7288                    // mismatches the recorded `dr_return_pc != 0`
7289                    // invariant (debug-asserted in the luna-jit trace
7290                    // lowerer).
7291                    if is_downrec_entry {
7292                        let saved_pc: i64 = if pre_frames >= 2 {
7293                            match &self.frames[pre_frames - 2] {
7294                                CallFrame::Lua(parent) => parent.pc as i64,
7295                                CallFrame::Cont(_) => 0,
7296                            }
7297                        } else {
7298                            0
7299                        };
7300                        reg_state[window_size_us] = saved_pc;
7301                    }
7302                    // `LUNA_AOT_PROBE`
7303                    // diagnostic hook. The probe fires once per trace dispatch
7304                    // (regardless of JIT vs AOT origin — both go through this
7305                    // arm), letting the AOT smoke test verify mcode actually
7306                    // executed. Guarded behind `OnceLock` so the env read is
7307                    // a one-time cost per process; not gated on a particular
7308                    // counter so the smoke test gets a deterministic single-
7309                    // line `aot_trace_fired pc=N` per first dispatch.
7310                    if jit_probe_enabled() && self.jit.counters.dispatched == 0 {
7311                        eprintln!("luna-runtime-helpers: aot_trace_fired pc={head_pc_val}");
7312                    }
7313                    let continuation_pc = {
7314                        // chunk_compiler.enter
7315                        // (CraneliftBackend delegates to enter_jit;
7316                        // NullJitBackend returns an inert guard).
7317                        let vm_ptr: *mut Vm = self;
7318                        let _guard = self.jit.chunk_compiler.enter(vm_ptr, Some(cl));
7319                        // 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).
7320                        unsafe { entry_fn(reg_state.as_mut_ptr()) }
7321                    };
7322                    self.jit.counters.dispatched += 1;
7323
7324                    if self.jit.pending_err.is_some() {
7325                        self.jit.pending_err = None;
7326                        self.jit.counters.deopt += 1;
7327                        // Unwind any helper-pushed
7328                        // inlined frames before the interpreter resumes.
7329                        // Don't restore reg_state — the trace's partial
7330                        // writes are discarded; interp re-executes from
7331                        // the original `pc`.
7332                        while self.frames.len() > pre_frames {
7333                            frames_pop_sync(&mut self.frames, &mut self.frames_top);
7334                        }
7335                        if is_downrec_entry {
7336                            // pending_err observed
7337                            // mid-trace inside a downrec admit. Treat
7338                            // it as a guard miss: bump `downrec_deopt`
7339                            // and suppress the next downrec admit so
7340                            // interp can advance past `head_pc` and
7341                            // the same trace doesn't immediately re-
7342                            // fire on the next loop iteration.
7343                            self.jit.counters.downrec_deopt += 1;
7344                            self.jit.suppress_downrec_admit_once = true;
7345                        }
7346                    } else if is_downrec_entry && {
7347                        // Only enter the
7348                        // downrec classifier for returns whose shape
7349                        // matches the lowerer's `downrec_idx_opt` tail
7350                        // emit: either the stitch_blk DOWNREC sentinel
7351                        // (HIT) or the deopt_blk GLOBAL-sentinel-with-
7352                        // body==head_pc (MISS via guard fail). Any
7353                        // other return from a downrec trace (intermediate
7354                        // body cmp side-exit, GetField inference fail,
7355                        // etc.) carries a different sentinel/body shape
7356                        // and means the body exited BEFORE reaching the
7357                        // downrec close — classify those through the
7358                        // normal decode path (else branch below) so
7359                        // reg_state restores + pc advances correctly.
7360                        // Classifying them all as MISS would skip the
7361                        // normal restore, inflating `downrec_deopt` with
7362                        // non-downrec events and losing the trace's
7363                        // mid-flight writes.
7364                        let raw_ret = continuation_pc as u64;
7365                        let from_side_trace = (raw_ret >> 63) & 1 == 1;
7366                        let sentinel_code = if from_side_trace {
7367                            ((raw_ret >> 56) & 0x7F) as u32
7368                        } else {
7369                            0
7370                        };
7371                        let raw_body = raw_ret & 0x00FF_FFFF_FFFF_FFFFu64;
7372                        let global_deopt_code = crate::jit::trace_types::encode_side_sentinel(
7373                            crate::jit::trace_types::SIDE_SENT_KIND_GLOBAL,
7374                            0,
7375                        );
7376                        from_side_trace
7377                            && (crate::jit::trace_types::is_downrec_sentinel(sentinel_code)
7378                                || (sentinel_code == global_deopt_code
7379                                    && raw_body == head_pc_val as u64))
7380                    } {
7381                        // Downrec event classifier.
7382                        let raw_ret = continuation_pc as u64;
7383                        let sentinel_code = ((raw_ret >> 56) & 0x7F) as u32;
7384                        if crate::jit::trace_types::is_downrec_sentinel(sentinel_code) {
7385                            // Guard HIT — saved_pc matched one of the
7386                            // baked candidates and the trace's
7387                            // `stitch_blk` arm returned the DOWNREC
7388                            // sentinel. Cycle-safety checkpoint:
7389                            // decrement budget; on underflow,
7390                            // reclassify as deopt + reset budget.
7391                            // `STITCH_DEPTH_DEFAULT = 32` lets
7392                            // ~all natural HITs in a hot loop fire
7393                            // before reset pressure.
7394                            if self.jit.stitch_depth_remaining > 0 {
7395                                self.jit.stitch_depth_remaining -= 1;
7396                                self.jit.counters.downrec_dispatched += 1;
7397                            } else {
7398                                self.jit.counters.downrec_deopt += 1;
7399                                self.jit.stitch_depth_remaining =
7400                                    crate::vm::jit_state::JitState::STITCH_DEPTH_DEFAULT;
7401                            }
7402                        } else {
7403                            // Guard MISS via the lowerer's deopt_blk
7404                            // arm (GLOBAL sentinel + body == head_pc).
7405                            // The deopt_blk emit performs the
7406                            // store-back via `emit_store_back_and_return_pc`,
7407                            // so the live stack already reflects the
7408                            // body's writes; no extra restore needed
7409                            // from the dispatcher side.
7410                            self.jit.counters.downrec_deopt += 1;
7411                        }
7412                        self.jit.suppress_downrec_admit_once = true;
7413                        // Pop helper-pushed inlined frames (defensive —
7414                        // the downrec emit shape doesn't push frames in the
7415                        // tail, but a body side-exit before reaching
7416                        // the tail may have via the materialize helper).
7417                        while self.frames.len() > pre_frames {
7418                            frames_pop_sync(&mut self.frames, &mut self.frames_top);
7419                        }
7420                        self.jit.reg_state_buf = reg_state;
7421                        self.jit.entry_tags_buf = entry_tags;
7422                        continue;
7423                    } else {
7424                        // Restore each slot using the trace's
7425                        // exit-tag analysis (see ExitTag docs).
7426                        // Decode the IR's
7427                        // side-exit shape. Upper 32 bits = (site_idx
7428                        // + 1) for inline cmp side-exits, 0 for
7429                        // legacy clean-tail / non-inline exits.
7430                        // The decode lives in
7431                        // `crate::jit::trace::decode_exit_shape` so
7432                        // side-trace returns can reuse it with the SIDE
7433                        // TRACE's shape inputs when the sentinel bit
7434                        // is set on `raw_ret`.
7435                        let raw_ret = continuation_pc as u64;
7436                        // Side-trace return decode.
7437                        // Bit 63 of `raw_ret` is the side-trace
7438                        // marker the parent's IR OR'd in when it
7439                        // tail-called into a wired child trace.
7440                        // Bits 56..=62 carry the sentinel code (the
7441                        // cache key into the parent's
7442                        // `side_trace_cache`); bits 0..=55 are the
7443                        // child's own return value (encoded site or
7444                        // plain cont_pc) which we MUST decode using
7445                        // the CHILD's per_exit_inline / per_exit_tags
7446                        // / exit_tags / exit_hit_counts — not the
7447                        // parent's. The dispatcher snapshot read
7448                        // above holds the parent's shapes; when bit
7449                        // 63 is set we re-fetch the child's via the
7450                        // sentinel-keyed cache.
7451                        let from_side_trace = (raw_ret >> 63) & 1 == 1;
7452                        let (
7453                            decode_inline,
7454                            decode_tags,
7455                            decode_exit_tags,
7456                            decode_hit_counts,
7457                            decode_body,
7458                            child_ran,
7459                        ) = if from_side_trace {
7460                            let sentinel_code = ((raw_ret >> 56) & 0x7F) as u32;
7461                            let body = raw_ret & 0x00FF_FFFF_FFFF_FFFFu64;
7462                            let traces = cl.proto.traces.borrow();
7463                            let child_idx = traces
7464                                .iter()
7465                                .find(|t| t.head_pc == head_pc_val)
7466                                .and_then(|pct| {
7467                                    pct.side_trace_cache.borrow().get(&sentinel_code).copied()
7468                                });
7469                            if let Some(idx) = child_idx
7470                                && let Some(child) = traces.get(idx as usize)
7471                            {
7472                                if crate::jit::trace::v2c_probe_enabled() {
7473                                    eprintln!(
7474                                        "[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={}",
7475                                        sentinel_code,
7476                                        body,
7477                                        idx,
7478                                        child.n_ops,
7479                                        child.head_pc,
7480                                        child.window_size,
7481                                        pc,
7482                                        window_size,
7483                                        child.dispatchable,
7484                                        child.is_inline_abort_close,
7485                                    );
7486                                }
7487                                (
7488                                    child.per_exit_inline.clone(),
7489                                    child.per_exit_tags.clone(),
7490                                    child.exit_tags.clone(),
7491                                    child.exit_hit_counts.clone(),
7492                                    body,
7493                                    true,
7494                                )
7495                            } else {
7496                                if crate::jit::trace::v2c_probe_enabled() {
7497                                    eprintln!(
7498                                        "[v2c-A3-decode] sentinel={:#04x} body={:#018x} child MISS (fallback parent shapes)",
7499                                        sentinel_code, body,
7500                                    );
7501                                }
7502                                // Cache miss — fall back to parent
7503                                // shapes with the body bits. Best-
7504                                // effort; the trace_side_trace_
7505                                // shape_mismatch_count records this
7506                                // path indirectly (close-handler
7507                                // skips wiring on mismatch so we
7508                                // shouldn't reach here when shape
7509                                // gate held).
7510                                (
7511                                    per_exit_inline.clone(),
7512                                    per_exit_tags.clone(),
7513                                    exit_tags.clone(),
7514                                    exit_hit_counts.clone(),
7515                                    body,
7516                                    true,
7517                                )
7518                            }
7519                        } else {
7520                            // Dispatcher-level side-trace invocation,
7521                            // rather than an IR gate (`load + icmp +
7522                            // brif`) at every emit_store_back callsite,
7523                            // which measured as a net slowdown. The
7524                            // tentative decode + cell load always runs:
7525                            // short-circuiting it on a
7526                            // `parent_has_side` hint measured slower on
7527                            // btrees_d8 and no faster on fib_10.
7528                            {
7529                                let tentative = crate::jit::trace::decode_exit_shape(
7530                                    raw_ret,
7531                                    per_exit_inline,
7532                                    per_exit_tags,
7533                                    exit_tags,
7534                                );
7535                                let tentative_exit_idx = tentative.exit_hit_idx;
7536                                let child_invoke = {
7537                                    let traces = cl.proto.traces.borrow();
7538                                    traces.iter().find(|t| t.head_pc == head_pc_val).and_then(
7539                                        |pct| {
7540                                            let cell =
7541                                                pct.exit_side_trace_ptrs.get(tentative_exit_idx)?;
7542                                            let fn_ptr = cell.get();
7543                                            if fn_ptr.is_null() {
7544                                                return None;
7545                                            }
7546                                            traces
7547                                                .iter()
7548                                                .find(|t| {
7549                                                    t.entry as *const () as *const u8 == fn_ptr
7550                                                })
7551                                                .map(|child| {
7552                                                    (
7553                                                        child.entry,
7554                                                        child.per_exit_inline.clone(),
7555                                                        child.per_exit_tags.clone(),
7556                                                        child.exit_tags.clone(),
7557                                                        child.exit_hit_counts.clone(),
7558                                                    )
7559                                                })
7560                                        },
7561                                    )
7562                                };
7563                                if let Some((cent, cpi, cpt, cet, chc)) = child_invoke {
7564                                    let child_raw_ret = {
7565                                        // chunk_compiler.enter
7566                                        // (side-trace entry).
7567                                        let vm_ptr: *mut Vm = self;
7568                                        let _guard =
7569                                            self.jit.chunk_compiler.enter(vm_ptr, Some(cl));
7570                                        // 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).
7571                                        unsafe { cent(reg_state.as_mut_ptr()) }
7572                                    };
7573                                    (cpi, cpt, cet, chc, child_raw_ret as u64, true)
7574                                } else {
7575                                    (
7576                                        per_exit_inline.clone(),
7577                                        per_exit_tags.clone(),
7578                                        exit_tags.clone(),
7579                                        exit_hit_counts.clone(),
7580                                        raw_ret,
7581                                        false,
7582                                    )
7583                                }
7584                            }
7585                        };
7586                        let decoded = crate::jit::trace::decode_exit_shape(
7587                            decode_body,
7588                            &decode_inline,
7589                            &decode_tags,
7590                            &decode_exit_tags,
7591                        );
7592                        let site_id = decoded.site_id;
7593                        let cont_pc = decoded.cont_pc;
7594                        let exit_hit_idx = decoded.exit_hit_idx;
7595                        let exit_tags_for_pc = decoded.exit_tags_for_pc;
7596                        // When a side trace ran (tail-called by the
7597                        // parent's code or invoked here), force
7598                        // using_global_exit_tags=false so the restore
7599                        // loop takes the per-tag slow path:
7600                        // `global_tag_res_kind` classifies the parent's
7601                        // exit tags, not the child's.
7602                        let using_global_exit_tags = if child_ran {
7603                            false
7604                        } else {
7605                            decoded.using_global_exit_tags
7606                        };
7607                        // Increment the counter (saturate
7608                        // at u32::MAX to avoid wrap on long runs).
7609                        // Track whether this increment is
7610                        // the one that crossed `HOTEXIT_THRESHOLD`
7611                        // (transition: previous v < threshold, new v
7612                        // == threshold). The side-trace start is
7613                        // deferred to just before `continue;` so
7614                        // vm.stack and frame.pc are fully restored
7615                        // (the snapshot reads post-restore values).
7616                        let mut side_trace_should_start = false;
7617                        // For side-trace returns the
7618                        // counter to bump is the CHILD's (decoded
7619                        // shape lookup) — `exit_hit_idx` is into the
7620                        // decoded layout, so use the matching
7621                        // `decode_hit_counts`. For parent decode
7622                        // they're aliased (clone of the parent's
7623                        // own Rc).
7624                        if let Some(c) = decode_hit_counts.get(exit_hit_idx) {
7625                            let v = c.get();
7626                            if v < u32::MAX {
7627                                c.set(v + 1);
7628                            }
7629                            if v + 1 == crate::jit::trace::HOTEXIT_THRESHOLD
7630                                && self.jit.active_trace.is_none()
7631                                && self.jit.trace_enabled
7632                            {
7633                                side_trace_should_start = true;
7634                            }
7635                        }
7636                        // At an inline cmp@d>0
7637                        // side-exit, the helper has pushed N frames on
7638                        // top of the trace head's frame and
7639                        // `exit_tags_for_pc.len()` covers the full
7640                        // window (caller + each inlined frame's
7641                        // window). Slots beyond `max_stack` belong to
7642                        // an inlined frame: their `Untouched` entries
7643                        // default to Nil (no entry-tag fallback —
7644                        // marshal-in only captured caller slots) and
7645                        // we write to interp stack at `base + i` which
7646                        // mirrors `op_offsets`-derived layout.
7647                        let slot_count = exit_tags_for_pc.len();
7648                        // The helper only extends
7649                        // vm.stack up to the deepest pushed frame's
7650                        // window, but the exit_tags snapshot covers
7651                        // the trace's full `window_size` (which
7652                        // includes depth-N+1 scratch slots that the
7653                        // trace's IR may have written without a
7654                        // matching pushed frame). Extend with Nil so
7655                        // the write at the tail doesn't panic; these
7656                        // slots get overwritten by the writeback loop
7657                        // and won't leak meaningful data past the
7658                        // pushed frames' R[0..max_stack) windows.
7659                        if self.stack.len() < base_us + slot_count {
7660                            self.stack
7661                                .resize(base_us + slot_count, crate::runtime::Value::Nil);
7662                        }
7663                        // Fast-path restore loop. When
7664                        // we landed on the global `exit_tags`,
7665                        // dispatch on the compile-time
7666                        // classification: skip the loop entirely
7667                        // for `AllUntouched`, do a tag-free
7668                        // `Value::Int(...)` write per slot for
7669                        // `AllInt`, otherwise fall through to the
7670                        // general match-arm loop. site_id > 0
7671                        // (inline frame mat) and per_exit_tags
7672                        // hits always take the general path —
7673                        // their per-side-exit shapes aren't
7674                        // pre-classified yet.
7675                        // A generic-for exit whose TForCall wrote the loop
7676                        // variables to the stack with tags the trace did not
7677                        // compile for: leave those slots as they are.
7678                        let keep_tfor =
7679                            if decode_body & crate::jit::trace_types::EXIT_KEEP_TFOR_VARS != 0 {
7680                                let call = cl.proto.code[cont_pc as usize - 1];
7681                                debug_assert!(matches!(call.op(), crate::vm::isa::Op::TForCall));
7682                                let first = call.a() as usize + 4;
7683                                first..first + call.c() as usize
7684                            } else {
7685                                0..0
7686                            };
7687                        let fast_path_taken = if using_global_exit_tags && keep_tfor.is_empty() {
7688                            match global_tag_res_kind {
7689                                crate::jit::trace::TagResKind::AllUntouched => {
7690                                    // No-op: vm.stack already
7691                                    // matches the trace's post-
7692                                    // entry state for these
7693                                    // slots (entry values not
7694                                    // overridden, or already
7695                                    // spilled by helpers).
7696                                    true
7697                                }
7698                                crate::jit::trace::TagResKind::AllInt => {
7699                                    for i in 0..slot_count {
7700                                        self.stack[base_us + i] =
7701                                            crate::runtime::Value::Int(reg_state[i]);
7702                                    }
7703                                    true
7704                                }
7705                                crate::jit::trace::TagResKind::Mixed => false,
7706                            }
7707                        } else {
7708                            false
7709                        };
7710                        if !fast_path_taken {
7711                            for i in 0..slot_count {
7712                                if keep_tfor.contains(&i) {
7713                                    continue;
7714                                }
7715                                let tag = match exit_tags_for_pc[i] {
7716                                    crate::jit::trace::ExitTag::Untouched => {
7717                                        if i < max_stack {
7718                                            entry_tags[i]
7719                                        } else {
7720                                            crate::runtime::value::raw::NIL
7721                                        }
7722                                    }
7723                                    crate::jit::trace::ExitTag::Int => {
7724                                        crate::runtime::value::raw::INT
7725                                    }
7726                                    crate::jit::trace::ExitTag::Float => {
7727                                        crate::runtime::value::raw::FLOAT
7728                                    }
7729                                    crate::jit::trace::ExitTag::Table => {
7730                                        crate::runtime::value::raw::TABLE
7731                                    }
7732                                    crate::jit::trace::ExitTag::Closure => {
7733                                        crate::runtime::value::raw::CLOSURE
7734                                    }
7735                                    // Trace actively wrote Nil
7736                                    // to this slot (e.g. via Op::LoadNil).
7737                                    // Restore as Nil regardless of the entry
7738                                    // tag, since the i64 payload is 0 and
7739                                    // packing as the entry tag (e.g. INT)
7740                                    // would mis-type the slot.
7741                                    crate::jit::trace::ExitTag::Nil => {
7742                                        crate::runtime::value::raw::NIL
7743                                    }
7744                                    // Trace wrote a Str ptr
7745                                    // to this slot (LoadK Str / Move from
7746                                    // Str / Concat result). Restore as
7747                                    // Value::Str with raw bits round-
7748                                    // tripped.
7749                                    crate::jit::trace::ExitTag::Str => {
7750                                        crate::runtime::value::raw::STR
7751                                    }
7752                                };
7753                                // SAFETY: tag is from a verified slot
7754                                // (entry validated above) or pinned by
7755                                // the exit-tag analysis to INT/TABLE.
7756                                // The raw payload sits in reg_state[i].
7757                                // Stack was extended by the materialize
7758                                // helper for inline frames.
7759                                // 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).
7760                                self.stack[base_us + i] = unsafe {
7761                                    Value::pack(
7762                                        tag,
7763                                        crate::runtime::value::RawVal {
7764                                            zero: reg_state[i] as u64,
7765                                        },
7766                                    )
7767                                };
7768                            }
7769                        }
7770                        // For non-inline exits the
7771                        // helper was never called (no metas chain for
7772                        // this cont_pc), so `frames.last()` is the
7773                        // trace head's frame and we set its pc to
7774                        // cont_pc as before. For inline exits the
7775                        // helper baked the side-exit PC into the
7776                        // innermost frame's `pc` at push time
7777                        // (chain.last().pc was overridden at emit),
7778                        // so this assignment to `frames.last_mut().pc
7779                        // = cont_pc` is a redundant-but-correct
7780                        // confirmation.
7781                        let _ = &per_exit_inline; // hold the Rc alive across dispatch
7782                        // For inline side-exits the
7783                        // helper has pushed N frames on top. The trace
7784                        // head frame is at `pre_frames - 1`; set its
7785                        // pc to `head_resume_pc` so when the chain
7786                        // eventually pops back to it, interp resumes
7787                        // PAST the trace's depth-0 Op::Call instead of
7788                        // restarting from `head_pc` and re-triggering
7789                        // dispatch (infinite loop). The innermost
7790                        // (helper-pushed) frame already has its pc
7791                        // baked in at compile time, but we still
7792                        // assign `cont_pc` below for parity with the
7793                        // non-inline path (no-op).
7794                        if site_id > 0 {
7795                            let idx = (site_id - 1) as usize;
7796                            let head_resume_pc = decode_inline[idx].head_resume_pc;
7797                            if pre_frames > 0
7798                                && let CallFrame::Lua(f) = &mut self.frames[pre_frames - 1]
7799                            {
7800                                f.pc = head_resume_pc;
7801                            }
7802                        }
7803                        let frames_len_now = self.frames.len();
7804                        // 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).
7805                        match unsafe { self.frames.last_mut().unwrap_unchecked() } {
7806                            CallFrame::Lua(fmut) => {
7807                                if crate::jit::trace::v2c_probe_enabled() {
7808                                    eprintln!(
7809                                        "[v2c-set-pc] from_side={} sentinel_or_raw={:#018x} prev_pc={} new_cont_pc={} site_id={} frames.len={} pre_frames={} max_stack={}",
7810                                        from_side_trace,
7811                                        raw_ret,
7812                                        fmut.pc,
7813                                        cont_pc,
7814                                        site_id,
7815                                        frames_len_now,
7816                                        pre_frames,
7817                                        max_stack,
7818                                    );
7819                                }
7820                                fmut.pc = cont_pc;
7821                            }
7822                            _ => unreachable!("Cont frame at trace dispatch"),
7823                        }
7824                        // Deferred side-trace start. The
7825                        // increment block above flagged this exit's
7826                        // hit count crossing HOTEXIT_THRESHOLD; now
7827                        // that vm.stack is restored and frame.pc is
7828                        // settled, snapshot entry_tags from the
7829                        // resume frame's window and create the
7830                        // recorder. The recorder's first push fires
7831                        // on the next interp iteration at cont_pc.
7832                        //
7833                        // `head_proto` for the side trace = cl.proto
7834                        // (trace JIT only inlines self-recursive
7835                        // calls today, so cont_pc always lands in
7836                        // the same proto as the parent). Frame base
7837                        // is the resume frame (top of `self.frames`
7838                        // — inline-pushed frames moved this).
7839                        if side_trace_should_start {
7840                            let (resume_base, resume_proto) = match self.frames.last() {
7841                                Some(CallFrame::Lua(f)) => (f.base as usize, f.closure.proto),
7842                                _ => (base_us, cl.proto),
7843                            };
7844                            let resume_max_stack = resume_proto.max_stack as usize;
7845                            let mut side_entry_tags: Vec<u8> = Vec::with_capacity(resume_max_stack);
7846                            // Extend stack if cont_pc's frame window
7847                            // overhangs the current stack len (rare,
7848                            // but inline-pushed frame stack writes
7849                            // only covered the trace's writeback).
7850                            if self.stack.len() < resume_base + resume_max_stack {
7851                                self.stack.resize(
7852                                    resume_base + resume_max_stack,
7853                                    crate::runtime::Value::Nil,
7854                                );
7855                            }
7856                            for i in 0..resume_max_stack {
7857                                let (tag, _) = self.stack[resume_base + i].unpack();
7858                                side_entry_tags.push(tag);
7859                            }
7860                            self.jit.active_trace =
7861                                Some(Box::new(crate::jit::trace::TraceRecord::start_side_trace(
7862                                    resume_proto,
7863                                    cont_pc,
7864                                    side_entry_tags,
7865                                    cl.proto,
7866                                    head_pc_val,
7867                                    exit_hit_idx,
7868                                )));
7869                            self.jit.recording_frame_base = self.frames.len() - 1;
7870                            self.jit.counters.side_trace_started += 1;
7871                        }
7872                        // Put the dispatch buffers back
7873                        // before the `continue;` so the next
7874                        // dispatch picks up the same allocation.
7875                        self.jit.reg_state_buf = reg_state;
7876                        self.jit.entry_tags_buf = entry_tags;
7877                        continue;
7878                    }
7879                }
7880                // !dispatch_ok / deopt path / non-cont
7881                // exit also restore the buffers before falling
7882                // through to the interp.
7883                self.jit.reg_state_buf = reg_state;
7884                self.jit.entry_tags_buf = entry_tags;
7885            }
7886
7887            // PUC `vmfetch` increments savedpc BEFORE firing traceexec, so
7888            // hook code that consults `currentpc = savedpc - 1` lands on the
7889            // instruction now executing. luna mirrors that by advancing
7890            // `f.pc` to `pc + 1` before the hook block — local_at /
7891            // getinfo / line attribution all read f.pc, and the existing
7892            // `pc - 1` convention in those helpers then yields the current
7893            // instruction's pc (db.lua :696: local `A` visible at the
7894            // chunk's return line once OP_CLOSURE has advanced pc).
7895            //
7896            // Inline `top_frame_mut` for the hot path: top is guaranteed Lua
7897            // (cont frames drained above) so the and_then/Option layers are
7898            // dead weight.
7899            // 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).
7900            match unsafe { self.frames.last_mut().unwrap_unchecked() } {
7901                CallFrame::Lua(fmut) => fmut.pc = pc + 1,
7902                _ => unreachable!("Cont frame at pc bump"),
7903            }
7904
7905            // count + line hooks (PUC traceexec): before executing the
7906            // instruction. Skipped while the hook itself runs.
7907            // (Parens here are load-bearing — without them `&&` binds tighter
7908            // than `||` and the `!in_hook` guard only gates the rust-hook arm,
7909            // letting a Lua line hook recurse into itself → stack overflow
7910            // on db.lua line-hook assertions. Matches the `hook_call_with` /
7911            // `hook_return` predicate shape at lines 2245 / 2279 / 2294 / 4023.)
7912            if !self.in_hook && (self.hook.func.is_some() || self.hook.rust_func.is_some()) {
7913                let lines = &cl.proto.lines;
7914                let cur_line = if lines.is_empty() {
7915                    None
7916                } else {
7917                    Some(lines[(pc as usize).min(lines.len() - 1)] as i64)
7918                };
7919                // count hook: fire every `count_base` instructions
7920                if self.hook.count {
7921                    self.hook.count_left -= 1;
7922                    if self.hook.count_left <= 0 {
7923                        self.hook.count_left = self.hook.count_base;
7924                        // hooked function is the running Lua frame: its frame
7925                        // is on the stack, so no synthetic C level is needed.
7926                        self.run_hook(b"count", cur_line, false)?;
7927                    }
7928                }
7929                // line hook: fire on a fresh frame, a backward jump (loop), or a
7930                // change of source line.
7931                if self.hook.line {
7932                    if lines.is_empty() {
7933                        // PUC: a stripped chunk has no line info, so
7934                        // `getfuncline` returns -1. The line hook still fires
7935                        // on the first instruction of the new frame (where
7936                        // `npci <= oldpc` holds at oldpc=0), with the line
7937                        // pushed as `nil` instead of an integer (db.lua :1030
7938                        // "hook called without debug info for 1st instruction").
7939                        if oldpc == u32::MAX {
7940                            self.run_hook(b"line", None, false)?;
7941                            self.top_frame_mut().hook_oldpc = pc;
7942                        }
7943                    } else {
7944                        let newline = lines[(pc as usize).min(lines.len() - 1)];
7945                        // PUC `traceexec`: fire on frame entry (`oldpc == MAX`),
7946                        // on a backward jump (`pc < oldpc` — strict; an equal pc
7947                        // would re-fire the install-site after `oldpc = pc`),
7948                        // or when the source line changes.
7949                        let fire = oldpc == u32::MAX
7950                            || pc < oldpc
7951                            || newline != lines[(oldpc as usize).min(lines.len() - 1)];
7952                        if fire {
7953                            self.run_hook(b"line", Some(newline as i64), false)?;
7954                        }
7955                        self.top_frame_mut().hook_oldpc = pc;
7956                    }
7957                }
7958            }
7959
7960            match inst.op() {
7961                Op::Move => {
7962                    let v = self.r(base, inst.b());
7963                    self.set_r(base, inst.a(), v);
7964                }
7965                Op::LoadI => self.set_r(base, inst.a(), Value::Int(inst.sbx() as i64)),
7966                Op::LoadF => self.set_r(base, inst.a(), Value::Float(inst.sbx() as f64)),
7967                Op::LoadK => {
7968                    let v = cl.proto.consts[inst.bx() as usize];
7969                    self.set_r(base, inst.a(), v);
7970                }
7971                Op::LoadKx => {
7972                    let extra = cl.proto.code[self.pc_of_top() as usize];
7973                    self.bump_pc();
7974                    let v = cl.proto.consts[extra.ax() as usize];
7975                    self.set_r(base, inst.a(), v);
7976                }
7977                Op::LoadFalse => self.set_r(base, inst.a(), Value::Bool(false)),
7978                Op::LFalseSkip => {
7979                    self.set_r(base, inst.a(), Value::Bool(false));
7980                    self.bump_pc();
7981                }
7982                Op::LoadTrue => self.set_r(base, inst.a(), Value::Bool(true)),
7983                Op::LoadNil => {
7984                    let a = inst.a();
7985                    for i in 0..=inst.b() {
7986                        self.set_r(base, a + i, Value::Nil);
7987                    }
7988                }
7989                Op::GetUpval => {
7990                    let v = self.upval_get(cl, inst.b());
7991                    self.set_r(base, inst.a(), v);
7992                }
7993                Op::SetUpval => {
7994                    let v = self.r(base, inst.a());
7995                    self.upval_set(cl, inst.b(), v);
7996                }
7997                Op::GetTabUp => {
7998                    let t = self.upval_get(cl, inst.b());
7999                    let key = cl.proto.consts[inst.c() as usize];
8000                    self.op_index(t, key, base + inst.a())?;
8001                }
8002                Op::GetTable => {
8003                    let t = self.r(base, inst.b());
8004                    let key = self.r(base, inst.c());
8005                    self.op_index(t, key, base + inst.a())?;
8006                }
8007                Op::GetI => {
8008                    let t = self.r(base, inst.b());
8009                    self.op_index(t, Value::Int(inst.c() as i64), base + inst.a())?;
8010                }
8011                Op::GetField => {
8012                    let t = self.r(base, inst.b());
8013                    let key = cl.proto.consts[inst.c() as usize];
8014                    // Fast path: known-Str const key + no
8015                    // metatable on the table → skip `op_index` /
8016                    // `index_step`'s MAX_TAG_LOOP setup and the outer
8017                    // `Value` match. Falls through to the slow path
8018                    // when either invariant breaks (`__index`
8019                    // metamethods, non-Table receivers, non-Str keys).
8020                    if let Value::Table(tb) = t
8021                        && tb.metatable().is_none()
8022                        && let Value::Str(s) = key
8023                    {
8024                        let v = tb.get_str(s);
8025                        self.stack[(base + inst.a()) as usize] = v;
8026                    } else {
8027                        self.op_index(t, key, base + inst.a())?;
8028                    }
8029                }
8030                Op::SetTabUp => {
8031                    let t = self.upval_get(cl, inst.a());
8032                    let key = cl.proto.consts[inst.b() as usize];
8033                    let v = self.r(base, inst.c());
8034                    self.op_newindex(t, key, v)?;
8035                }
8036                Op::SetTable => {
8037                    let t = self.r(base, inst.a());
8038                    let key = self.r(base, inst.b());
8039                    let v = self.r(base, inst.c());
8040                    self.op_newindex(t, key, v)?;
8041                }
8042                Op::SetI => {
8043                    let t = self.r(base, inst.a());
8044                    let v = self.r(base, inst.c());
8045                    self.op_newindex(t, Value::Int(inst.b() as i64), v)?;
8046                }
8047                Op::SetField => {
8048                    let t = self.r(base, inst.a());
8049                    let key = cl.proto.consts[inst.b() as usize];
8050                    let v = self.r(base, inst.c());
8051                    self.op_newindex(t, key, v)?;
8052                }
8053                Op::NewTable => {
8054                    let t = self.heap.new_table();
8055                    self.set_r(base, inst.a(), Value::Table(t));
8056                    self.maybe_collect_garbage(base + inst.a() + 1);
8057                }
8058                Op::SetList => {
8059                    let a = inst.a();
8060                    let abs_a = base + a;
8061                    // only `debug.setlocal` or crafted bytecode can put a
8062                    // non-table here; PUC crashes, luna raises
8063                    let t = match self.r(base, a) {
8064                        Value::Table(t) => t,
8065                        v => return Err(self.type_err("index", v)),
8066                    };
8067                    let n = if inst.b() == 0 {
8068                        self.top - (abs_a + 1)
8069                    } else {
8070                        inst.b()
8071                    };
8072                    let offset = if inst.k() {
8073                        let extra = cl.proto.code[self.pc_of_top() as usize];
8074                        self.bump_pc();
8075                        extra.ax() as i64
8076                    } else {
8077                        inst.c() as i64
8078                    };
8079                    for i in 1..=n {
8080                        let v = self.r(base, a + i);
8081                        // 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).
8082                        if let Err(TableError::Overflow) =
8083                            unsafe { t.as_mut() }.set_int(&mut self.heap, offset + i as i64, v)
8084                        {
8085                            return Err(self.rt_err("table overflow"));
8086                        }
8087                    }
8088                    // one barrier_back covers every store this op did — PUC's
8089                    // `luaC_barrierback_` once-per-table optimisation
8090                    self.heap
8091                        .barrier_back(t.as_ptr() as *mut crate::runtime::heap::GcHeader);
8092                    // the element temps above the table are now consumed
8093                    self.maybe_collect_garbage(base + a + 1);
8094                }
8095                Op::SelfOp => {
8096                    let o = self.r(base, inst.b());
8097                    self.set_r(base, inst.a() + 1, o);
8098                    // PUC OP_SELF's C is a constant index when the k-flag is
8099                    // set; otherwise it points to a register that holds the
8100                    // (constant-loaded) key. luna's compiler falls back to the
8101                    // register form when the constant index exceeds OP_SELF's
8102                    // 8-bit C field (5.1 big.lua's `a:findfield(...)` against
8103                    // a table with 250+ string keys, where "findfield" lands
8104                    // past const #255). The exec must honour the same split.
8105                    let key = if inst.k() {
8106                        cl.proto.consts[inst.c() as usize]
8107                    } else {
8108                        self.r(base, inst.c())
8109                    };
8110                    self.op_index(o, key, base + inst.a())?;
8111                }
8112                Op::Add => self.arith_rr(inst, base, ArithOp::Add)?,
8113                Op::Sub => self.arith_rr(inst, base, ArithOp::Sub)?,
8114                Op::Mul => self.arith_rr(inst, base, ArithOp::Mul)?,
8115                Op::Mod => self.arith_rr(inst, base, ArithOp::Mod)?,
8116                Op::Pow => self.arith_rr(inst, base, ArithOp::Pow)?,
8117                Op::Div => self.arith_rr(inst, base, ArithOp::Div)?,
8118                Op::IDiv => self.arith_rr(inst, base, ArithOp::IDiv)?,
8119                Op::BAnd => self.arith_rr(inst, base, ArithOp::BAnd)?,
8120                Op::BOr => self.arith_rr(inst, base, ArithOp::BOr)?,
8121                Op::BXor => self.arith_rr(inst, base, ArithOp::BXor)?,
8122                Op::Shl => self.arith_rr(inst, base, ArithOp::Shl)?,
8123                Op::Shr => self.arith_rr(inst, base, ArithOp::Shr)?,
8124                Op::Unm => {
8125                    let v = self.r(base, inst.b());
8126                    match self.unary_operand(v) {
8127                        Some(Num::Int(i)) => {
8128                            self.set_r(base, inst.a(), Value::Int(i.wrapping_neg()))
8129                        }
8130                        Some(Num::Float(f)) => self.set_r(base, inst.a(), Value::Float(-f)),
8131                        None => {
8132                            let mm = self.get_mm(v, Mm::Unm);
8133                            if mm.is_nil() {
8134                                return Err(self.type_err("perform arithmetic on", v));
8135                            }
8136                            let dst = base + inst.a();
8137                            self.begin_meta_call(mm, &[v, v], MetaAction::Store { dst }, "unm")?;
8138                        }
8139                    }
8140                }
8141                Op::BNot => {
8142                    let v = self.r(base, inst.b());
8143                    match self.arith_operand()(v) {
8144                        Some(n) => {
8145                            let Some(i) = int_of(n) else {
8146                                return Err(self.no_int_rep_err());
8147                            };
8148                            self.set_r(base, inst.a(), Value::Int(!i));
8149                        }
8150                        None => {
8151                            let mm = self.get_mm(v, Mm::BNot);
8152                            if mm.is_nil() {
8153                                return Err(self.type_err("perform bitwise operation on", v));
8154                            }
8155                            let dst = base + inst.a();
8156                            self.begin_meta_call(mm, &[v, v], MetaAction::Store { dst }, "bnot")?;
8157                        }
8158                    }
8159                }
8160                Op::Not => {
8161                    let v = self.r(base, inst.b());
8162                    self.set_r(base, inst.a(), Value::Bool(!v.truthy()));
8163                }
8164                Op::Len => {
8165                    let v = self.r(base, inst.b());
8166                    match self.len_step(v)? {
8167                        MmOut::Done(r) => self.set_r(base, inst.a(), r),
8168                        MmOut::Mm { func, recv } => {
8169                            let dst = base + inst.a();
8170                            self.begin_meta_call(
8171                                func,
8172                                &[recv, recv],
8173                                MetaAction::Store { dst },
8174                                "len",
8175                            )?;
8176                        }
8177                        MmOut::CompareSynth { .. } => unreachable!("CompareSynth from len_step"),
8178                    }
8179                }
8180                Op::Concat => {
8181                    // right-associative fold over operands at base+a .. base+a+n,
8182                    // in place on the stack so a yielding __concat can suspend.
8183                    let a = inst.a();
8184                    let n = inst.b();
8185                    self.top = base + a + n;
8186                    self.concat_run(base + a)?;
8187                }
8188                Op::Close => {
8189                    // Yieldable: drive __close handlers through the
8190                    // interpreter loop so a coroutine.yield() inside a
8191                    // handler suspends cleanly (locals.lua block-end yield).
8192                    // `drive_close` parks the handler call at `self.top`, so
8193                    // raise `top` past this frame's full register window
8194                    // first — a goto out of a nested for-loop can fire
8195                    // OP_Close while `self.top` still sits at the inner
8196                    // body's working top, which would let `push_frame`'s
8197                    // wipe clobber the outer tbc slot before it could be
8198                    // closed (locals.lua:1219 nested-for goto regression).
8199                    self.top = self.top.max(base + cl.proto.max_stack as u32);
8200                    let _ =
8201                        self.begin_close(base + inst.a(), None, AfterClose::Block, entry_depth)?;
8202                }
8203                Op::Tbc => {
8204                    self.register_tbc(base + inst.a())?;
8205                }
8206                Op::Jmp => {
8207                    let off = inst.sj();
8208                    // Trace JIT back-edge counter. A negative
8209                    // jump offset is a loop back-edge (the only canonical
8210                    // backward jumps the compiler emits — `while`, `for`,
8211                    // `repeat`). Tick the per-Proto counter and, once it
8212                    // exceeds the threshold, start a trace recording. The
8213                    // whole block is gated on `trace_jit_enabled` so
8214                    // existing benches see one branch-not-taken and no
8215                    // counter writes.
8216                    if self.jit.trace_enabled && off < 0 {
8217                        let proto = cl.proto;
8218                        let c = proto.trace_hot_count.get();
8219                        if c < u32::MAX / 2 {
8220                            proto.trace_hot_count.set(c + 1);
8221                        }
8222                        // Relaxed back-edge trigger:
8223                        // `c >= THRESHOLD` (not `c == THRESHOLD`) so
8224                        // a missed crossing (active_trace busy with
8225                        // a call-trigger, or the recorder slot
8226                        // happened to be in use) doesn't permanently
8227                        // lock this back-edge target out. The
8228                        // `already_cached` short-circuit prevents
8229                        // duplicate recordings: once a trace is
8230                        // cached for this target, subsequent
8231                        // crossings skip the start. This pairs with
8232                        // the discard-on-partial-coverage close
8233                        // handling — when a short call-trigger is
8234                        // discarded, the back-edge can still find an
8235                        // open slot at the next iteration.
8236                        let target_pc = (pc as i32 + 1 + off as i32).max(0) as u32;
8237                        // Gave-up short-circuit. Skip
8238                        // the RefCell borrow + scan when the
8239                        // discard cap force-compiled a partial
8240                        // trace on this Proto.
8241                        let back_edge_already_cached = if proto.trace_gave_up.get() {
8242                            true
8243                        } else {
8244                            proto.traces.borrow().iter().any(|t| t.head_pc == target_pc)
8245                                || trace_head_abandoned(proto, target_pc)
8246                        };
8247                        if c >= self.jit.trace_hot_threshold
8248                            && self.jit.active_trace.is_none()
8249                            && !back_edge_already_cached
8250                        {
8251                            // Back-edge target = pc after `add_pc(off)`,
8252                            // i.e. current `pc + 1 + off` (the dispatch
8253                            // loop has already advanced f.pc to pc+1).
8254                            let target = (pc as i32 + 1 + off as i32).max(0) as u32;
8255                            // Snapshot per-slot Value tag at trace
8256                            // entry so the lowerer's kind tracker
8257                            // knows which arith path to lower
8258                            // (iadd vs fadd, etc.).
8259                            let max_stack = cl.proto.max_stack as usize;
8260                            let base_us = base as usize;
8261                            let mut entry_tags = Vec::with_capacity(max_stack);
8262                            for i in 0..max_stack {
8263                                let (tag, _) = self.stack[base_us + i].unpack();
8264                                entry_tags.push(tag);
8265                            }
8266                            self.jit.active_trace =
8267                                Some(Box::new(crate::jit::trace::TraceRecord::start(
8268                                    cl.proto, target, entry_tags, false,
8269                                )));
8270                            // Record the frame the trace
8271                            // started in. `self.frames.len() - 1`
8272                            // since we're inside the currently-running
8273                            // Lua frame's dispatch.
8274                            self.jit.recording_frame_base = self.frames.len() - 1;
8275                        }
8276                    }
8277                    self.add_pc(off);
8278                }
8279                Op::Eq => {
8280                    let l = self.r(base, inst.a());
8281                    let r = self.r(base, inst.b());
8282                    if let (Value::Int(a), Value::Int(b)) = (l, r) {
8283                        if (a == b) != inst.k() {
8284                            self.bump_pc();
8285                        }
8286                    } else {
8287                        let step = self.eq_step(l, r);
8288                        self.op_compare(step, l, r, inst.k(), "eq")?;
8289                    }
8290                }
8291                Op::EqK => {
8292                    let l = self.r(base, inst.a());
8293                    let r = cl.proto.consts[inst.b() as usize];
8294                    if let (Value::Int(a), Value::Int(b)) = (l, r) {
8295                        if (a == b) != inst.k() {
8296                            self.bump_pc();
8297                        }
8298                    } else {
8299                        let step = self.eq_step(l, r);
8300                        self.op_compare(step, l, r, inst.k(), "eq")?;
8301                    }
8302                }
8303                Op::Lt => {
8304                    let l = self.r(base, inst.a());
8305                    let r = self.r(base, inst.b());
8306                    // hot path: Int < Int — drops the MmOut + op_compare match
8307                    if let (Value::Int(a), Value::Int(b)) = (l, r) {
8308                        if (a < b) != inst.k() {
8309                            self.bump_pc();
8310                        }
8311                    } else {
8312                        let step = self.less_step(l, r, false)?;
8313                        self.op_compare(step, l, r, inst.k(), "lt")?;
8314                    }
8315                }
8316                Op::Le => {
8317                    let l = self.r(base, inst.a());
8318                    let r = self.r(base, inst.b());
8319                    if let (Value::Int(a), Value::Int(b)) = (l, r) {
8320                        if (a <= b) != inst.k() {
8321                            self.bump_pc();
8322                        }
8323                    } else {
8324                        let step = self.less_step(l, r, true)?;
8325                        self.op_compare(step, l, r, inst.k(), "le")?;
8326                    }
8327                }
8328                Op::Test => {
8329                    let cond = self.r(base, inst.a()).truthy();
8330                    self.cond_skip(cond, inst.k());
8331                }
8332                Op::TestSet => {
8333                    let v = self.r(base, inst.b());
8334                    if v.truthy() == inst.k() {
8335                        self.set_r(base, inst.a(), v);
8336                    } else {
8337                        self.bump_pc();
8338                    }
8339                }
8340                Op::Call => {
8341                    let abs = base + inst.a();
8342                    let nargs = if inst.b() == 0 {
8343                        None
8344                    } else {
8345                        Some(inst.b() - 1)
8346                    };
8347                    let wanted = inst.c() as i32 - 1;
8348                    self.begin_call(abs, nargs, wanted, false)?;
8349                }
8350                Op::TailCall => {
8351                    let fr = *self.top_frame();
8352                    let abs = base + inst.a();
8353                    let mut nargs = if inst.b() == 0 {
8354                        self.top - (abs + 1)
8355                    } else {
8356                        inst.b() - 1
8357                    };
8358                    // A tail call pops this frame before begin_call, so a
8359                    // non-callable target would lose its name/position. Report
8360                    // it now (PUC reads funcname from the still-current ci),
8361                    // while the frame is intact, for "(field 'x')"-style info.
8362                    let mut func = self.stack[abs as usize];
8363                    if !matches!(func, Value::Closure(_) | Value::Native(_))
8364                        && self.get_mm(func, Mm::Call).is_nil()
8365                    {
8366                        return Err(self.call_err(func));
8367                    }
8368                    // PUC `luaD_pretailcall` resolves a chain of `__call`
8369                    // metamethods *in place* before deciding whether to
8370                    // collapse this frame. Without that, each __call hop
8371                    // would push a fresh Lua frame and a 10000-deep
8372                    // tail-recursion through a 100-deep __call chain
8373                    // (5.4 calls.lua :172) blows up. Mirror the PUC loop:
8374                    // shift args right, install the handler at `abs`, retry.
8375                    // Chain depth limit matches the call-site `begin_call`
8376                    // version cap (5.5 calls.lua :223 — 15 max, then "too
8377                    // long"; 16th wrap fails the call). An infinite
8378                    // self-referential `__call` would otherwise spin.
8379                    let chain_cap = if self.version >= LuaVersion::Lua55 {
8380                        15
8381                    } else {
8382                        MAX_CCMT
8383                    };
8384                    let mut chain = 0u32;
8385                    while !matches!(func, Value::Closure(_) | Value::Native(_)) {
8386                        let mm = self.get_mm(func, Mm::Call);
8387                        if mm.is_nil() || self.call_mm_unusable(mm) {
8388                            return Err(self.call_err(func));
8389                        }
8390                        chain += 1;
8391                        if chain > chain_cap {
8392                            return Err(self.rt_err("'__call' chain too long"));
8393                        }
8394                        let end = (abs + 1 + nargs) as usize;
8395                        if self.stack.len() < end + 1 {
8396                            self.stack.resize(end + 1, Value::Nil);
8397                        }
8398                        for i in (0..=nargs).rev() {
8399                            self.stack[(abs + 1 + i) as usize] = self.stack[(abs + i) as usize];
8400                        }
8401                        self.stack[abs as usize] = mm;
8402                        nargs += 1;
8403                        self.top = abs + 1 + nargs;
8404                        func = mm;
8405                    }
8406                    // PUC's tail-call collapse is Lua→Lua only. A tail call to
8407                    // a C function runs the C function under the *current* Lua
8408                    // activation (no frame fold — a C frame has nothing to
8409                    // collapse into); after the C function returns, the
8410                    // calling Lua function returns those results normally.
8411                    // Mirror that: keep our Lua frame on the stack, call the
8412                    // target through `begin_call(abs, …)` as a regular call,
8413                    // and let the fallback `Op::Return` that the compiler
8414                    // emits right after `Op::TailCall` forward the results.
8415                    // 5.1 closure.lua :177's `return getfenv()` from inside
8416                    // foo needs level 1 to resolve to foo, not to the
8417                    // thread's globals fallback that happens when no Lua
8418                    // frame is on the stack.
8419                    let lua_target = matches!(func, Value::Closure(_));
8420                    if lua_target {
8421                        self.close_slots(fr.base, None)?;
8422                        for i in 0..=nargs {
8423                            self.stack[(fr.func_slot + i) as usize] =
8424                                self.stack[(abs + i) as usize];
8425                        }
8426                        // Clear the slot range that's now
8427                        // stranded by the tail-call collapse. The args
8428                        // were copied to `[fr.func_slot..fr.func_slot+
8429                        // nargs+1)`; the source slots `[abs..abs+
8430                        // nargs+1)` still hold the same `Value::Closure
8431                        // / Value::Str / ...` entries, but they're past
8432                        // the new call's window. Without this clear, a
8433                        // later GC with wider gc_top would mark stale
8434                        // pointers there (same hazard the
8435                        // finish_results slot-clear closes for the
8436                        // Op::Return path).
8437                        let new_top_lower_bound = fr.func_slot + nargs + 1;
8438                        let prev_top = (self.top as usize).min(self.stack.len());
8439                        if (new_top_lower_bound as usize) < prev_top {
8440                            for slot in &mut self.stack[new_top_lower_bound as usize..prev_top] {
8441                                *slot = Value::Nil;
8442                            }
8443                        }
8444                        // PUC `CIST_TAIL`: the new Lua activation inherits
8445                        // the popped frame's tailcalls count plus one for
8446                        // this collapse. 5.1 db.lua :372 hammers 30000
8447                        // recursive tail calls and expects to see the
8448                        // synthetic tail level for every one of them.
8449                        self.pending_tailcalls = fr.tailcalls.saturating_add(1);
8450                        self.pending_ccmt = self.frame_ccmt[self.frames.len() - 1];
8451                        frames_pop_sync(&mut self.frames, &mut self.frames_top);
8452                        if !self.begin_call(fr.func_slot, Some(nargs), fr.nresults, false)?
8453                            && self.frames.len() < entry_depth
8454                        {
8455                            // a native completed what was this function's result
8456                            return Ok(self.take_results(fr.func_slot));
8457                        }
8458                    } else {
8459                        // Native (or __call-bearing) target: regular call. The
8460                        // results land at `abs..self.top` and the next op (the
8461                        // fallback `Op::Return`) forwards them. `wanted = -1`
8462                        // because the caller will multret them through Return.
8463                        // PUC's precallC gives the C call this tail call's own
8464                        // `__call` count (5.5 extraargs); the chain was already
8465                        // resolved above, so hand it over.
8466                        self.pending_ccmt = chain as u8;
8467                        self.begin_call(abs, Some(nargs), -1, false)?;
8468                    }
8469                }
8470                Op::Return | Op::Return0 | Op::Return1 => {
8471                    let (abs_a, nret) = match inst.op() {
8472                        Op::Return0 => (base, 0),
8473                        Op::Return1 => (base + inst.a(), 1),
8474                        _ => {
8475                            let abs_a = base + inst.a();
8476                            let nret = if inst.b() == 0 {
8477                                self.top - abs_a
8478                            } else {
8479                                inst.b() - 1
8480                            };
8481                            (abs_a, nret)
8482                        }
8483                    };
8484                    // close before moving results: __close handlers run above
8485                    // the stack top, so the result region [abs_a..abs_a+nret)
8486                    // stays intact across any yields the close performs.
8487                    // Fixed-count returns may leave `self.top` below the last
8488                    // result slot (the compiler does not always re-bump it);
8489                    // raise it past the result region so `drive_close` parks
8490                    // the handler call *above* — landing at `self.top` would
8491                    // otherwise clobber a result with the handler closure.
8492                    self.top = self.top.max(abs_a + nret);
8493                    if let Some(vals) = self.begin_close(
8494                        base,
8495                        None,
8496                        AfterClose::Return {
8497                            abs_a,
8498                            nret,
8499                            from_native: false,
8500                        },
8501                        entry_depth,
8502                    )? {
8503                        return Ok(vals);
8504                    }
8505                }
8506                Op::ForPrep => self.for_prep(inst, base)?,
8507                Op::ForLoop => {
8508                    // Trace JIT back-edge counter on the
8509                    // numeric-for back-edge. ForLoop is always at
8510                    // a back-edge position (when it continues);
8511                    // for the trace recorder we treat it as the
8512                    // close-detection equivalent of `Op::Jmp` with
8513                    // negative offset. Counter only ticks when the
8514                    // back-edge will actually fire (count > 0 in
8515                    // the 5.4+ Int form, comparable predicates in
8516                    // pre-5.3 / Float). The cheap check up front
8517                    // matches the for_loop helper's branch.
8518                    if self.jit.trace_enabled {
8519                        let a = inst.a();
8520                        let pre53 = self.version() <= LuaVersion::Lua53;
8521                        let take_back_edge =
8522                            match (self.r(base, a), self.r(base, a + 1), self.r(base, a + 2)) {
8523                                (Value::Int(_), Value::Int(count), Value::Int(_)) if !pre53 => {
8524                                    count != 0
8525                                }
8526                                (Value::Int(cur), Value::Int(lim), Value::Int(st)) if pre53 => {
8527                                    let next = cur.wrapping_add(st);
8528                                    if st > 0 { next <= lim } else { next >= lim }
8529                                }
8530                                (Value::Float(cur), Value::Float(lim), Value::Float(st)) => {
8531                                    let next = cur + st;
8532                                    if st > 0.0 { next <= lim } else { next >= lim }
8533                                }
8534                                _ => false,
8535                            };
8536                        if take_back_edge {
8537                            let proto = cl.proto;
8538                            let c = proto.trace_hot_count.get();
8539                            if c < u32::MAX / 2 {
8540                                proto.trace_hot_count.set(c + 1);
8541                            }
8542                            if c == self.jit.trace_hot_threshold && self.jit.active_trace.is_none()
8543                            {
8544                                // ForLoop's back-edge target = pc
8545                                // after `add_pc(-bx)` runs from the
8546                                // already-bumped f.pc (= pc + 1).
8547                                // So target = (pc + 1) - bx.
8548                                let target = (pc as i32 + 1 - inst.bx() as i32).max(0) as u32;
8549                                let max_stack = cl.proto.max_stack as usize;
8550                                let base_us = base as usize;
8551                                let mut entry_tags = Vec::with_capacity(max_stack);
8552                                for i in 0..max_stack {
8553                                    let (tag, _) = self.stack[base_us + i].unpack();
8554                                    entry_tags.push(tag);
8555                                }
8556                                self.jit.active_trace =
8557                                    Some(Box::new(crate::jit::trace::TraceRecord::start(
8558                                        cl.proto, target, entry_tags, false,
8559                                    )));
8560                                // Record the frame the trace
8561                                // started in. The currently-running
8562                                // Lua frame is at len() - 1.
8563                                self.jit.recording_frame_base = self.frames.len() - 1;
8564                            }
8565                        }
8566                    }
8567                    self.for_loop(inst, base)?;
8568                }
8569                Op::TForPrep => {
8570                    // the 4th control slot is the iterator's closing value
8571                    self.register_tbc(base + inst.a() + 3)?;
8572                    self.add_pc(inst.bx() as i32);
8573                }
8574                Op::TForCall => {
8575                    let abs = base + inst.a();
8576                    let need = (abs + 7) as usize;
8577                    if self.stack.len() < need {
8578                        self.stack.resize(need, Value::Nil);
8579                    }
8580                    self.stack[(abs + 4) as usize] = self.stack[abs as usize];
8581                    self.stack[(abs + 5) as usize] = self.stack[(abs + 1) as usize];
8582                    self.stack[(abs + 6) as usize] = self.stack[(abs + 2) as usize];
8583                    let nvars = inst.c() as i32;
8584                    self.begin_call(abs + 4, Some(2), nvars, false)?;
8585                }
8586                Op::TForLoop => {
8587                    let a = inst.a();
8588                    let ctrl = self.r(base, a + 4);
8589                    if !ctrl.is_nil() {
8590                        // Trace JIT back-edge counter on
8591                        // generic-for back-edge. TForLoop sits at the
8592                        // tail of `for k,v in expr do ... end`; recorder
8593                        // treats it as the close-detection equivalent of
8594                        // a negative Op::Jmp. Gate on `take_back_edge`
8595                        // (= `ctrl != nil`) so empty-iter loops don't
8596                        // pollute hot_count.
8597                        if self.jit.trace_enabled {
8598                            let proto = cl.proto;
8599                            let c = proto.trace_hot_count.get();
8600                            if c < u32::MAX / 2 {
8601                                proto.trace_hot_count.set(c + 1);
8602                            }
8603                            if c == self.jit.trace_hot_threshold && self.jit.active_trace.is_none()
8604                            {
8605                                // TForLoop back-edge target = pc after
8606                                // `add_pc(-bx)` runs from the already-
8607                                // bumped f.pc (= pc + 1). So target =
8608                                // (pc + 1) - bx, normally landing on
8609                                // body_top (the op right after TForPrep).
8610                                let target = (pc as i32 + 1 - inst.bx() as i32).max(0) as u32;
8611                                let max_stack = cl.proto.max_stack as usize;
8612                                let base_us = base as usize;
8613                                let mut entry_tags = Vec::with_capacity(max_stack);
8614                                for i in 0..max_stack {
8615                                    let (tag, _) = self.stack[base_us + i].unpack();
8616                                    entry_tags.push(tag);
8617                                }
8618                                // Snapshot the iter
8619                                // fn's address if Native, so the
8620                                // lowerer can specialise ipairs into
8621                                // inline Table aget IR.
8622                                let iter_ptr =
8623                                    if let Value::Native(n) = self.stack[base_us + a as usize] {
8624                                        Some(n.f as usize)
8625                                    } else {
8626                                        None
8627                                    };
8628                                // Snapshot R[A+5]'s
8629                                // tag (= current iter's val from
8630                                // the just-fired TForCall). The
8631                                // inline aget fast_blk emits a
8632                                // runtime guard against this tag;
8633                                // mixed-tag arrays deopt rather
8634                                // than producing garbage pointers
8635                                // through the spill path.
8636                                let val_slot = base_us + (a as usize) + 5;
8637                                let val_tag = if val_slot < self.stack.len() {
8638                                    Some(self.stack[val_slot].unpack().0)
8639                                } else {
8640                                    None
8641                                };
8642                                let mut rec = crate::jit::trace::TraceRecord::start(
8643                                    cl.proto, target, entry_tags, false,
8644                                );
8645                                rec.tfor_iter_ptr = iter_ptr;
8646                                rec.tfor_val_tag = val_tag;
8647                                self.jit.active_trace = Some(Box::new(rec));
8648                                self.jit.recording_frame_base = self.frames.len() - 1;
8649                            }
8650                        }
8651                        self.set_r(base, a + 2, ctrl);
8652                        self.add_pc(-(inst.bx() as i32));
8653                    }
8654                }
8655                Op::Closure => {
8656                    let proto = cl.proto.protos[inst.bx() as usize];
8657                    let n_ups = proto.upvals.len();
8658                    // Build upvals on the stack for small
8659                    // closures, skipping the per-call Vec/Box alloc
8660                    // that closure_alloc's 10k iters pay. INLINE_UPVALS_N
8661                    // = 2 covers most Lua source (1 captured local, or
8662                    // _ENV + a single capture). Beyond that, fall back
8663                    // to a heap Vec.
8664                    use crate::runtime::function::INLINE_UPVALS_N;
8665                    let mut stack_buf: [std::mem::MaybeUninit<
8666                        Gc<crate::runtime::function::Upvalue>,
8667                    >; INLINE_UPVALS_N] = [std::mem::MaybeUninit::uninit(); INLINE_UPVALS_N];
8668                    let mut heap_buf: Vec<Gc<crate::runtime::function::Upvalue>> = Vec::new();
8669                    let use_inline = n_ups <= INLINE_UPVALS_N;
8670                    if !use_inline {
8671                        heap_buf.reserve_exact(n_ups);
8672                    }
8673                    for (i, d) in proto.upvals.iter().enumerate() {
8674                        let uv = if d.in_stack {
8675                            self.find_or_create_upval(base + d.index as u32)
8676                        } else {
8677                            cl.upvals()[d.index as usize]
8678                        };
8679                        if use_inline {
8680                            stack_buf[i] = std::mem::MaybeUninit::new(uv);
8681                        } else {
8682                            heap_buf.push(uv);
8683                        }
8684                    }
8685                    // Tiny shim around the two paths so the 5.1 _ENV
8686                    // clone + cache check below see one uniform
8687                    // `&mut [Gc<Upvalue>]`. The stack_buf slice points
8688                    // into the local frame (still valid through the
8689                    // rest of this Op::Closure handler).
8690                    let ups: &mut [Gc<crate::runtime::function::Upvalue>] = if use_inline {
8691                        // SAFETY: the first n_ups slots of stack_buf
8692                        // were initialised above; we hand out a slice
8693                        // covering exactly them.
8694                        unsafe {
8695                            std::slice::from_raw_parts_mut(
8696                                stack_buf.as_mut_ptr()
8697                                    as *mut Gc<crate::runtime::function::Upvalue>,
8698                                n_ups,
8699                            )
8700                        }
8701                    } else {
8702                        &mut heap_buf[..]
8703                    };
8704                    // PUC 5.1 had per-function environments: every Lua
8705                    // function carried its own `env` slot, snapshotted from
8706                    // the creating function's env at closure time, so a
8707                    // `setfenv` on one closure never bled into a sibling.
8708                    // luna models that by giving the 5.1 closure a *fresh*
8709                    // closed upvalue for whichever cell holds `_ENV`, seeded
8710                    // from the parent's current env value. Only that cell is
8711                    // cloned — every other upvalue keeps its open/shared
8712                    // identity (so e.g. `local function range(...) ...
8713                    // range(...) ... end` still sees its self-reference). 5.2+
8714                    // keeps the shared-upval model (and the proto cache that
8715                    // depends on it).
8716                    let v51 = self.version() <= LuaVersion::Lua51;
8717                    if v51 && proto.env_upval_idx != u8::MAX {
8718                        let i = proto.env_upval_idx as usize;
8719                        let cur = match ups[i].state() {
8720                            UpvalState::Open { slot, thread } => self.read_slot(slot, thread),
8721                            UpvalState::Closed(v) => v,
8722                        };
8723                        ups[i] = self.heap.new_upvalue(UpvalState::Closed(cur));
8724                    }
8725                    let ups_slice: &[Gc<crate::runtime::function::Upvalue>] = ups;
8726                    // PUC 5.2+ `getcached`: a Proto remembers its last LClosure
8727                    // and reuses it when every fresh-upvalue binding still
8728                    // points to the same Upvalue object as the cached one.
8729                    // That keeps `function() return outer end` repeated in a
8730                    // loop comparing equal across iterations (the captured
8731                    // outer is a shared open upvalue), while `function()
8732                    // return loop_var end` gets a fresh closure each round
8733                    // because the loop var is re-created per iteration. PUC
8734                    // 5.1 predated the cache, and the per-closure `_ENV`
8735                    // clone above would defeat it anyway, so skip it.
8736                    let nc = if v51 {
8737                        self.heap.new_closure_inline(proto, ups_slice)
8738                    } else {
8739                        let cached = proto.cache.get().filter(|c| {
8740                            c.upvals().len() == ups_slice.len()
8741                                && c.upvals()
8742                                    .iter()
8743                                    .zip(ups_slice.iter())
8744                                    .all(|(a, b)| std::ptr::eq(a.as_ptr(), b.as_ptr()))
8745                        });
8746                        match cached {
8747                            Some(c) => c,
8748                            None => {
8749                                let n = self.heap.new_closure_inline(proto, ups_slice);
8750                                proto.cache.set(Some(n));
8751                                n
8752                            }
8753                        }
8754                    };
8755                    self.set_r(base, inst.a(), Value::Closure(nc));
8756                    self.maybe_collect_garbage(base + inst.a() + 1);
8757                }
8758                Op::Vararg => {
8759                    let abs_a = base + inst.a();
8760                    let wanted = inst.c() as i32 - 1;
8761                    // A materialized named vararg lives in func_slot (its writes
8762                    // must be visible to `...`); otherwise spread the extra args
8763                    // straight off the stack at func_slot+1 .. +n_varargs.
8764                    let vt = match self.stack[func_slot as usize] {
8765                        Value::Table(t) => Some(t),
8766                        _ => None,
8767                    };
8768                    let n = match vt {
8769                        Some(t) => {
8770                            let n_key = Value::Str(self.heap.intern(b"n"));
8771                            // PUC getnumargs: a named vararg `t.n` set out of the
8772                            // integer range [0, INT_MAX/2] is rejected here
8773                            match t.get(n_key) {
8774                                Value::Int(n) if (n as u64) <= (i32::MAX as u64 / 2) => n as u32,
8775                                _ => return Err(self.rt_err("vararg table has no proper 'n'")),
8776                            }
8777                        }
8778                        None => n_varargs,
8779                    };
8780                    let count = if wanted < 0 { n } else { wanted as u32 };
8781                    // a named vararg's `n` can be set to anything up to
8782                    // INT_MAX/2; PUC's `luaD_checkstack` refuses what the
8783                    // stack cannot hold
8784                    if abs_a + count > MAX_LUA_STACK {
8785                        return Err(self.rt_err("stack overflow"));
8786                    }
8787                    let need = (abs_a + count) as usize;
8788                    if self.stack.len() < need {
8789                        self.stack.resize(need, Value::Nil);
8790                    }
8791                    for i in 0..count {
8792                        let v = if i >= n {
8793                            Value::Nil
8794                        } else if let Some(t) = vt {
8795                            t.get_int(i as i64 + 1)
8796                        } else {
8797                            self.stack[(func_slot + 1 + i) as usize]
8798                        };
8799                        self.stack[(abs_a + i) as usize] = v;
8800                    }
8801                    if wanted < 0 {
8802                        self.top = abs_a + count;
8803                    }
8804                }
8805                Op::GetVarg => {
8806                    // materialize the vararg table (PUC table.pack shape) from the
8807                    // stack varargs — used when the named vararg is written /
8808                    // escapes / is `_ENV`. It is kept BOTH in func_slot (so `...`
8809                    // sees later writes) and in the local register R[A].
8810                    let n = n_varargs;
8811                    let t = self.heap.new_table();
8812                    {
8813                        // 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).
8814                        let tm = unsafe { t.as_mut() };
8815                        for i in 0..n {
8816                            let _ = tm.set_int(
8817                                &mut self.heap,
8818                                i as i64 + 1,
8819                                self.stack[(func_slot + 1 + i) as usize],
8820                            );
8821                        }
8822                    }
8823                    let n_key = Value::Str(self.heap.intern(b"n"));
8824                    // 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).
8825                    unsafe { t.as_mut() }
8826                        .set(&mut self.heap, n_key, Value::Int(n as i64))
8827                        .expect("'n' is a valid key");
8828                    // once-per-table barrier (mirror SETLIST): t is born BLACK
8829                    // during Propagate; the bulk inserts above don't barrier.
8830                    self.heap
8831                        .barrier_back(t.as_ptr() as *mut crate::runtime::heap::GcHeader);
8832                    self.stack[func_slot as usize] = Value::Table(t);
8833                    self.set_r(base, inst.a(), Value::Table(t));
8834                }
8835                Op::VargIdx => {
8836                    // R[A] := vararg[R[C]] without allocating: integer key in
8837                    // [1,n] → that vararg, "n" → the count, else nil.
8838                    let key = self.r(base, inst.c());
8839                    let n = n_varargs;
8840                    let v = match key {
8841                        Value::Int(k) if k >= 1 && (k as u64) <= n as u64 => {
8842                            self.stack[(func_slot + k as u32) as usize]
8843                        }
8844                        Value::Float(f) if f.fract() == 0.0 && f >= 1.0 && f <= n as f64 => {
8845                            self.stack[(func_slot + f as u32) as usize]
8846                        }
8847                        Value::Str(s) if s.as_bytes() == b"n" => Value::Int(n as i64),
8848                        _ => Value::Nil,
8849                    };
8850                    self.set_r(base, inst.a(), v);
8851                }
8852                Op::ErrNNil => {
8853                    let v = self.r(base, inst.a());
8854                    if !matches!(v, Value::Nil) {
8855                        let bx = inst.bx();
8856                        let name = if bx == 0 {
8857                            "?".to_string()
8858                        } else {
8859                            match cl.proto.consts[(bx - 1) as usize] {
8860                                Value::Str(s) => String::from_utf8_lossy(s.as_bytes()).into_owned(),
8861                                _ => "?".to_string(),
8862                            }
8863                        };
8864                        return Err(self.rt_err(&format!("global '{name}' already defined")));
8865                    }
8866                }
8867                Op::ExtraArg => unreachable!("EXTRAARG executed directly"),
8868            }
8869        }
8870    }
8871
8872    #[inline(always)]
8873    fn pc_of_top(&self) -> u32 {
8874        self.top_frame().pc
8875    }
8876
8877    #[inline(always)]
8878    fn bump_pc(&mut self) {
8879        // Inline `top_frame_mut`: top is guaranteed Lua (continuation frames
8880        // drained at dispatch loop head). Avoids the and_then/lua_mut Option
8881        // layers — bump_pc fires per Jmp / cond_skip miss, so the savings add
8882        // up over `fib_28`'s ~500k jumps.
8883        // 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).
8884        match unsafe { self.frames.last_mut().unwrap_unchecked() } {
8885            CallFrame::Lua(f) => f.pc += 1,
8886            _ => unreachable!("Cont frame at bump_pc"),
8887        }
8888    }
8889
8890    #[inline(always)]
8891    fn add_pc(&mut self, d: i32) {
8892        // 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).
8893        match unsafe { self.frames.last_mut().unwrap_unchecked() } {
8894            CallFrame::Lua(f) => f.pc = (f.pc as i64 + d as i64) as u32,
8895            _ => unreachable!("Cont frame at add_pc"),
8896        }
8897    }
8898
8899    /// PUC conditional-skip convention: the JMP that follows is executed when
8900    /// `cond == k`; otherwise it is skipped.
8901    #[inline(always)]
8902    fn cond_skip(&mut self, cond: bool, k: bool) {
8903        if cond != k {
8904            self.bump_pc();
8905        }
8906    }
8907
8908    // ---- indexing (with __index/__newindex chains) ----
8909
8910    /// The `#` length operation: string byte length, `__len` if present, else
8911    /// the raw table border. Returns the raw length value (may be non-integer
8912    /// when `__len` is exotic).
8913    pub(crate) fn len_value(&mut self, v: Value) -> Result<Value, LuaError> {
8914        match self.len_step(v)? {
8915            MmOut::Done(n) => Ok(n),
8916            // PUC calls unary metamethods with the operand twice
8917            MmOut::Mm { func, recv } => self.call_mm1(func, &[recv, recv]),
8918            MmOut::CompareSynth { .. } => unreachable!("CompareSynth from len_step"),
8919        }
8920    }
8921
8922    /// Length fast path: a string's byte count or a table's raw border when no
8923    /// `__len` is present (`Done`); otherwise the `__len` metamethod (`Mm`),
8924    /// called with the operand twice. Errors for a non-table with no `__len`.
8925    fn len_step(&mut self, v: Value) -> Result<MmOut, LuaError> {
8926        match v {
8927            Value::Str(s) => Ok(MmOut::Done(Value::Int(s.len() as i64))),
8928            Value::Table(t) => {
8929                // PUC 5.1's `__len` applies to userdata only — `luaV_objlen`
8930                // there takes the raw border for a table without consulting
8931                // the metatable, so `#setmetatable({}, {__len = f})` is 0 on
8932                // 5.1 and 7 on 5.2+. Verified against stock 5.1.5 / 5.2.4.
8933                if self.version() == crate::version::LuaVersion::Lua51 {
8934                    return Ok(MmOut::Done(Value::Int(t.len())));
8935                }
8936                let mm = self.get_mm(v, Mm::Len);
8937                if mm.is_nil() {
8938                    Ok(MmOut::Done(Value::Int(t.len())))
8939                } else {
8940                    Ok(MmOut::Mm { func: mm, recv: v })
8941                }
8942            }
8943            _ => {
8944                let mm = self.get_mm(v, Mm::Len);
8945                if mm.is_nil() {
8946                    Err(self.type_err("get length of", v))
8947                } else {
8948                    Ok(MmOut::Mm { func: mm, recv: v })
8949                }
8950            }
8951        }
8952    }
8953
8954    pub(crate) fn index_value(&mut self, t: Value, key: Value) -> Result<Value, LuaError> {
8955        match self.index_step(t, key)? {
8956            MmOut::Done(v) => Ok(v),
8957            MmOut::Mm { func, recv } => self.call_mm1(func, &[recv, key]),
8958            MmOut::CompareSynth { .. } => unreachable!("CompareSynth from index_step"),
8959        }
8960    }
8961
8962    /// PUC `MAXTAGLOOP`: 100 links of `__index`/`__newindex` in 5.1/5.2,
8963    /// 2000 from 5.3.
8964    fn tag_loop_limit(&self) -> u32 {
8965        if self.version <= LuaVersion::Lua52 {
8966            100
8967        } else {
8968            MAX_TAG_LOOP
8969        }
8970    }
8971
8972    /// Resolve `t[key]` through the `__index` chain, stopping at the first raw
8973    /// hit (`Done`) or function metamethod (`Mm`). Table-valued `__index` links
8974    /// are followed inline (no yield possible); only a function link can yield.
8975    fn index_step(&mut self, t: Value, key: Value) -> Result<MmOut, LuaError> {
8976        let mut cur = t;
8977        for _ in 0..self.tag_loop_limit() {
8978            let mm = match cur {
8979                Value::Table(tb) => {
8980                    let v = tb.get(key);
8981                    if !v.is_nil() {
8982                        return Ok(MmOut::Done(v));
8983                    }
8984                    let mm = self.get_mm(cur, Mm::Index);
8985                    if mm.is_nil() {
8986                        return Ok(MmOut::Done(Value::Nil));
8987                    }
8988                    mm
8989                }
8990                v => {
8991                    let mm = self.get_mm(v, Mm::Index);
8992                    if mm.is_nil() {
8993                        return Err(self.type_err("index", v));
8994                    }
8995                    mm
8996                }
8997            };
8998            match mm {
8999                Value::Closure(_) | Value::Native(_) => {
9000                    return Ok(MmOut::Mm {
9001                        func: mm,
9002                        recv: cur,
9003                    });
9004                }
9005                next => cur = next,
9006            }
9007        }
9008        Err(self.runerror(if self.version <= LuaVersion::Lua52 {
9009            "loop in gettable"
9010        } else {
9011            "'__index' chain too long; possible loop"
9012        }))
9013    }
9014
9015    pub(crate) fn newindex_value(
9016        &mut self,
9017        t: Value,
9018        key: Value,
9019        v: Value,
9020    ) -> Result<(), LuaError> {
9021        match self.newindex_step(t, key, v)? {
9022            MmOut::Done(_) => Ok(()),
9023            MmOut::Mm { func, recv } => {
9024                self.call_value(func, &[recv, key, v])?;
9025                Ok(())
9026            }
9027            MmOut::CompareSynth { .. } => unreachable!("CompareSynth from newindex_step"),
9028        }
9029    }
9030
9031    /// Resolve `t[key] = v` through the `__newindex` chain. A raw assignment is
9032    /// performed inline (returning `Done`); only a function metamethod (`Mm`)
9033    /// needs an actual call — which the caller may run yieldably.
9034    fn newindex_step(&mut self, t: Value, key: Value, v: Value) -> Result<MmOut, LuaError> {
9035        // Read-time probe (gc-verify): a dead query key at a
9036        // WRITE site, attributed to the instruction that produced it.
9037        #[cfg(feature = "gc-verify")]
9038        if let Some(p) = match key {
9039            Value::Str(s) => Some(s.as_ptr() as usize),
9040            Value::Table(t2) => Some(t2.as_ptr() as usize),
9041            _ => None,
9042        } && crate::runtime::gc_verify_probe::is_freed(p)
9043        {
9044            let detail = match self.frames.last() {
9045                Some(CallFrame::Lua(f)) => {
9046                    let pc = f.pc as usize;
9047                    let mut w = String::new();
9048                    for q in pc.saturating_sub(6)..(pc + 2) {
9049                        if let Some(inst) = f.closure.proto.code.get(q) {
9050                            w.push_str(&format!(
9051                                "\n  [{q}] {:?} a={} b={} c={} k={}",
9052                                inst.op(),
9053                                inst.a(),
9054                                inst.b(),
9055                                inst.c(),
9056                                inst.k()
9057                            ));
9058                        }
9059                    }
9060                    format!("pc={pc} base={} gc_top={} window:{w}", f.base, self.gc_top)
9061                }
9062                _ => "non-Lua frame".into(),
9063            };
9064            panic!("[gc-verify] newindex_step QUERY key {p:#x} freed. {detail}");
9065        }
9066        let mut cur = t;
9067        for _ in 0..self.tag_loop_limit() {
9068            let mm = match cur {
9069                Value::Table(tb) => {
9070                    // Single-walk collapse — Table::try_set_existing
9071                    // fuses the prior `tb.get(key).is_nil()` gate and
9072                    // `raw_set` walk into one chain traversal when the
9073                    // key is already present with a non-nil value. The
9074                    // __newindex chain semantics are preserved by the
9075                    // identity (slot_nil ⇔ fire_newindex).
9076                    //
9077                    // SAFETY: Gc<T> is NonNull<T> over the GC heap; the
9078                    // heap is single-threaded and the pointer is live as
9079                    // long as it is reachable from active roots (see
9080                    // heap.rs:5-7). Mirrors the raw_set wrapper below.
9081                    if unsafe { tb.as_mut() }.try_set_existing(key, v) {
9082                        self.heap
9083                            .barrier_back(tb.as_ptr() as *mut crate::runtime::heap::GcHeader);
9084                        return Ok(MmOut::Done(Value::Nil));
9085                    }
9086                    let mm = self.get_mm(cur, Mm::NewIndex);
9087                    if mm.is_nil() {
9088                        self.raw_set(tb, key, v)?;
9089                        return Ok(MmOut::Done(Value::Nil));
9090                    }
9091                    mm
9092                }
9093                bad => {
9094                    let mm = self.get_mm(bad, Mm::NewIndex);
9095                    if mm.is_nil() {
9096                        return Err(self.type_err("index", bad));
9097                    }
9098                    mm
9099                }
9100            };
9101            match mm {
9102                Value::Closure(_) | Value::Native(_) => {
9103                    return Ok(MmOut::Mm {
9104                        func: mm,
9105                        recv: cur,
9106                    });
9107                }
9108                next => cur = next,
9109            }
9110        }
9111        Err(self.runerror(if self.version <= LuaVersion::Lua52 {
9112            "loop in settable"
9113        } else {
9114            "'__newindex' chain too long; possible loop"
9115        }))
9116    }
9117
9118    pub(crate) fn raw_set(&mut self, t: Gc<Table>, key: Value, v: Value) -> Result<(), LuaError> {
9119        // 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).
9120        match unsafe { t.as_mut() }.set(&mut self.heap, key, v) {
9121            Ok(()) => {
9122                self.heap
9123                    .barrier_back(t.as_ptr() as *mut crate::runtime::heap::GcHeader);
9124                Ok(())
9125            }
9126            Err(TableError::NilIndex) => Err(self.runerror("table index is nil")),
9127            Err(TableError::NanIndex) => Err(self.runerror("table index is NaN")),
9128            Err(TableError::Overflow) => Err(self.runerror("table overflow")),
9129            Err(TableError::InvalidNext) => unreachable!(),
9130        }
9131    }
9132
9133    /// Decide equality, or surface the `__eq` metamethod to call. `Done` carries
9134    /// the boolean result; `Mm` (when raw equality fails and both are tables
9135    /// with an `__eq`) carries the metamethod — called with `(l, r)`.
9136    fn eq_step(&mut self, l: Value, r: Value) -> MmOut {
9137        if l.raw_eq(r) {
9138            return MmOut::Done(Value::Bool(true));
9139        }
9140        if let (Value::Table(_), Value::Table(_)) | (Value::Userdata(_), Value::Userdata(_)) =
9141            (l, r)
9142        {
9143            // PUC 5.3+ accepts any `__eq` reachable from either operand; 5.1
9144            // and 5.2 require the two operands' metatables to expose the same
9145            // `__eq` (`get_compTM` / `get_equalTM`) — `c == d` where `d` has
9146            // no metatable falls straight back to raw inequality. events.lua
9147            // 5.1 :262 bakes this in.
9148            let mm = if self.version() <= LuaVersion::Lua52 {
9149                self.get_comp_mm(l, r, Mm::Eq)
9150            } else {
9151                let mut m = self.get_mm(l, Mm::Eq);
9152                if m.is_nil() {
9153                    m = self.get_mm(r, Mm::Eq);
9154                }
9155                m
9156            };
9157            if !mm.is_nil() {
9158                return MmOut::Mm { func: mm, recv: l };
9159            }
9160        }
9161        MmOut::Done(Value::Bool(false))
9162    }
9163
9164    // ---- arithmetic ----
9165
9166    #[inline(always)]
9167    fn arith_rr(&mut self, inst: Inst, base: u32, op: ArithOp) -> Result<(), LuaError> {
9168        let l = self.r(base, inst.b());
9169        let r = self.r(base, inst.c());
9170        // hot path: Int + Int for Add / Sub / Mul — fib_28, loop_int_1m,
9171        // binary_trees all hammer these. Skipping coerce_num + the big
9172        // arith_fast match shaves several conditional moves per op.
9173        if let (Value::Int(a), Value::Int(b)) = (l, r) {
9174            let fast = match op {
9175                ArithOp::Add => Some(Value::Int(a.wrapping_add(b))),
9176                ArithOp::Sub => Some(Value::Int(a.wrapping_sub(b))),
9177                ArithOp::Mul => Some(Value::Int(a.wrapping_mul(b))),
9178                _ => None,
9179            };
9180            if let Some(v) = fast {
9181                self.set_r(base, inst.a(), v);
9182                return Ok(());
9183            }
9184        }
9185        // hot path: Float + Float for Add / Sub / Mul / Div — math_loop_100k
9186        // and any numeric workload with non-integer accumulators benefits.
9187        if let (Value::Float(a), Value::Float(b)) = (l, r) {
9188            let fast = match op {
9189                ArithOp::Add => Some(Value::Float(a + b)),
9190                ArithOp::Sub => Some(Value::Float(a - b)),
9191                ArithOp::Mul => Some(Value::Float(a * b)),
9192                ArithOp::Div => Some(Value::Float(a / b)),
9193                _ => None,
9194            };
9195            if let Some(v) = fast {
9196                self.set_r(base, inst.a(), v);
9197                return Ok(());
9198            }
9199        }
9200        // An `Add` with k set is a 5.4+ `x - 0` (see `Op::Add`): the right
9201        // operand is the integer 0, so it adds only when the left is a number.
9202        let op = if inst.k() && op == ArithOp::Add && !matches!(l, Value::Int(_) | Value::Float(_))
9203        {
9204            ArithOp::Sub
9205        } else {
9206            op
9207        };
9208        match self.arith_fast(op, l, r)? {
9209            Some(v) => self.set_r(base, inst.a(), v),
9210            None => {
9211                let mm = self.arith_mm_func(op, l, r)?;
9212                let dst = base + inst.a();
9213                self.begin_meta_call(mm, &[l, r], MetaAction::Store { dst }, op.mm_name())?;
9214            }
9215        }
9216        Ok(())
9217    }
9218
9219    /// The number a unary `-` operand stands for: 5.4+ leaves strings to
9220    /// the string metatable, 5.3 converts them to floats (PUC `tonumber`).
9221    fn unary_operand(&self, v: Value) -> Option<Num> {
9222        let n = self.arith_operand()(v);
9223        if self.version == LuaVersion::Lua53 && matches!(v, Value::Str(_)) {
9224            n.map(|n| Num::Float(n.as_f64()))
9225        } else {
9226            n
9227        }
9228    }
9229
9230    /// How an arithmetic operand becomes a number: 5.4+ takes numbers only
9231    /// (strings go to their metatable), 5.3 converts numeric strings, and
9232    /// 5.1/5.2, which have only floats, convert them to floats (5.1 with C
9233    /// `strtod`, so `"inf"` and `"0x1p4"` count).
9234    fn arith_operand(&self) -> fn(Value) -> Option<Num> {
9235        if self.version >= LuaVersion::Lua54 {
9236            as_number
9237        } else if self.version == LuaVersion::Lua53 {
9238            coerce_num
9239        } else if self.version == LuaVersion::Lua52 {
9240            coerce_num_float
9241        } else {
9242            coerce_num_51
9243        }
9244    }
9245
9246    /// Fast path for an arithmetic/bitwise op: `Ok(Some(v))` when computed
9247    /// directly, `Ok(None)` when a metamethod is required (the caller decides
9248    /// whether to call it synchronously or yieldably).
9249    fn arith_fast(&mut self, op: ArithOp, l: Value, r: Value) -> Result<Option<Value>, LuaError> {
9250        use ArithOp::*;
9251        // 5.4 moved string->number coercion out of the VM: a string operand
9252        // goes to the string metatable's `__add` etc., and bitwise operators
9253        // have no string metamethods at all.
9254        let num = self.arith_operand();
9255        if let BAnd | BOr | BXor | Shl | Shr = op {
9256            let (Some(a), Some(b)) = (num(l), num(r)) else {
9257                return Ok(None);
9258            };
9259            let (Some(a), Some(b)) = (int_of(a), int_of(b)) else {
9260                // PUC luaG_tointerror: name the offending operand
9261                return Err(self.no_int_rep_err());
9262            };
9263            let v = match op {
9264                BAnd => a & b,
9265                BOr => a | b,
9266                BXor => a ^ b,
9267                Shl => shift_left(a, b),
9268                Shr => shift_left(a, b.wrapping_neg()),
9269                _ => unreachable!(),
9270            };
9271            return Ok(Some(Value::Int(v)));
9272        }
9273        let (Some(mut ln), Some(mut rn)) = (num(l), num(r)) else {
9274            return Ok(None);
9275        };
9276        // PUC 5.3 takes the integer path only when both operands are
9277        // integers (`ttisinteger`); a converted string goes through
9278        // `tonumber`, which yields a float.
9279        if self.version == LuaVersion::Lua53
9280            && (matches!(l, Value::Str(_)) || matches!(r, Value::Str(_)))
9281        {
9282            ln = Num::Float(ln.as_f64());
9283            rn = Num::Float(rn.as_f64());
9284        }
9285        match arith_num(self.version, op, ln, rn) {
9286            Ok(v) => Ok(Some(v)),
9287            Err(msg) => Err(self.runerror(msg)),
9288        }
9289    }
9290
9291    /// Find the arithmetic/bitwise metamethod (left operand first), or raise the
9292    /// PUC type error when neither operand provides one.
9293    fn arith_mm_func(&mut self, op: ArithOp, l: Value, r: Value) -> Result<Value, LuaError> {
9294        use ArithOp::*;
9295        let event = match op {
9296            Add => Mm::Add,
9297            Sub => Mm::Sub,
9298            Mul => Mm::Mul,
9299            Div => Mm::Div,
9300            Mod => Mm::Mod,
9301            Pow => Mm::Pow,
9302            IDiv => Mm::IDiv,
9303            BAnd => Mm::BAnd,
9304            BOr => Mm::BOr,
9305            BXor => Mm::BXor,
9306            Shl => Mm::Shl,
9307            Shr => Mm::Shr,
9308        };
9309        let mut mm = self.get_mm(l, event);
9310        if mm.is_nil() {
9311            mm = self.get_mm(r, event);
9312        }
9313        if mm.is_nil() {
9314            let what = if matches!(op, BAnd | BOr | BXor | Shl | Shr) {
9315                "perform bitwise operation on"
9316            } else {
9317                "perform arithmetic on"
9318            };
9319            // luaG_opinterror blames the first operand that is not a number;
9320            // before 5.4 a numeric string counts as one.
9321            let bad = if self.arith_operand()(l).is_none() {
9322                l
9323            } else {
9324                r
9325            };
9326            return Err(self.type_err(what, bad));
9327        }
9328        Ok(mm)
9329    }
9330
9331    // ---- comparison ----
9332
9333    /// `lua_compare(L, a, b, LUA_OPEQ)`: equality including `__eq`.
9334    pub(crate) fn equal(&mut self, l: Value, r: Value) -> Result<bool, LuaError> {
9335        match self.eq_step(l, r) {
9336            MmOut::Done(v) => Ok(v.truthy()),
9337            MmOut::Mm { func, .. } => Ok(self.call_mm1(func, &[l, r])?.truthy()),
9338            MmOut::CompareSynth { .. } => unreachable!("CompareSynth from eq_step"),
9339        }
9340    }
9341
9342    pub(crate) fn less_than(&mut self, l: Value, r: Value, or_eq: bool) -> Result<bool, LuaError> {
9343        match self.less_step(l, r, or_eq)? {
9344            MmOut::Done(v) => Ok(v.truthy()),
9345            MmOut::Mm { func, .. } => Ok(self.call_mm1(func, &[l, r])?.truthy()),
9346            MmOut::CompareSynth { func } => {
9347                // ≤5.3 `__le` via `not __lt(r, l)`. Synchronous helper used
9348                // by library code (sort comparator etc.) — no yield expected
9349                // here (a yield would have hit `call_noyield`'s C boundary).
9350                Ok(!self.call_mm1(func, &[r, l])?.truthy())
9351            }
9352        }
9353    }
9354
9355    /// Decide `l < r` / `l <= r`, or surface the `__lt`/`__le` metamethod. `Done`
9356    /// carries the boolean result; `Mm` (for non-number/string operands) carries
9357    /// the metamethod — called with `(l, r)`; raises the PUC compare error when
9358    /// neither operand provides one.
9359    fn less_step(&mut self, l: Value, r: Value, or_eq: bool) -> Result<MmOut, LuaError> {
9360        let b = match (l, r) {
9361            (Value::Int(a), Value::Int(b)) => {
9362                if or_eq {
9363                    a <= b
9364                } else {
9365                    a < b
9366                }
9367            }
9368            (Value::Float(a), Value::Float(b)) => {
9369                if or_eq {
9370                    a <= b
9371                } else {
9372                    a < b
9373                }
9374            }
9375            (Value::Int(a), Value::Float(b)) => {
9376                if or_eq {
9377                    int_le_float(a, b)
9378                } else {
9379                    int_lt_float(a, b)
9380                }
9381            }
9382            (Value::Float(a), Value::Int(b)) => {
9383                if a.is_nan() {
9384                    false
9385                } else if or_eq {
9386                    !int_lt_float(b, a)
9387                } else {
9388                    !int_le_float(b, a)
9389                }
9390            }
9391            (Value::Str(a), Value::Str(b)) => {
9392                let (a, b) = (a.as_bytes(), b.as_bytes());
9393                if or_eq { a <= b } else { a < b }
9394            }
9395            (l, r) => {
9396                let event = if or_eq { Mm::Le } else { Mm::Lt };
9397                // PUC 5.1's `get_compTM` rule applies to ordered comparisons
9398                // too: both operands' metatables must expose the same
9399                // implementation for `__lt` / `__le` to fire. events.lua 5.1
9400                // :262 expects `c < d` (where `d` has no metatable) to error
9401                // with the default "attempt to compare two table values"
9402                // rather than running c's `__lt` blindly.
9403                let mm = if self.version() <= LuaVersion::Lua51 {
9404                    self.get_comp_mm(l, r, event)
9405                } else {
9406                    let mut m = self.get_mm(l, event);
9407                    if m.is_nil() {
9408                        m = self.get_mm(r, event);
9409                    }
9410                    m
9411                };
9412                // PUC ≤5.4: `a <= b` falls back to `not (b < a)` when neither
9413                // operand carries `__le` (5.4 through its default build's
9414                // LUA_COMPAT_LT_LE); 5.5 requires an explicit `__le`.
9415                // events.lua 5.2/5.3 :172 relies on the synthesis — its
9416                // metatable defines only `__lt`. The `__lt` is looked up as
9417                // for `b < a`: on `b` first (5.1: the same one on both). The
9418                // fallback calls `__lt(r, l)` synchronously (the suite's
9419                // `__lt` doesn't yield) and negates the result; the yieldable
9420                // `__lt` path stays reserved for the explicit `<` operator.
9421                if mm.is_nil() && or_eq && self.version < LuaVersion::Lua55 {
9422                    let mm_lt = if self.version <= LuaVersion::Lua51 {
9423                        self.get_comp_mm(r, l, Mm::Lt)
9424                    } else {
9425                        let m = self.get_mm(r, Mm::Lt);
9426                        if m.is_nil() {
9427                            self.get_mm(l, Mm::Lt)
9428                        } else {
9429                            m
9430                        }
9431                    };
9432                    if !mm_lt.is_nil() {
9433                        return Ok(MmOut::CompareSynth { func: mm_lt });
9434                    }
9435                }
9436                if mm.is_nil() {
9437                    // PUC luaG_ordererror: "two X values" when the operand
9438                    // types match, "X with Y" otherwise (objtypename-aware).
9439                    let (t1, t2) = (self.obj_typename(l), self.obj_typename(r));
9440                    return Err(self.runerror(&if t1 == t2 {
9441                        format!("attempt to compare two {t1} values")
9442                    } else {
9443                        format!("attempt to compare {t1} with {t2}")
9444                    }));
9445                }
9446                return Ok(MmOut::Mm { func: mm, recv: l });
9447            }
9448        };
9449        Ok(MmOut::Done(Value::Bool(b)))
9450    }
9451
9452    // ---- numeric for ----
9453
9454    /// Check and convert a numeric for's control values the way the
9455    /// dialect's `OP_FORPREP` does. The integer loop is chosen by the
9456    /// values' tags (a numeric string makes it a float loop, 5.3+); the
9457    /// check order, wording and the zero-step error differ per version:
9458    /// 5.1/5.2 test initial value, limit, step; 5.3+ limit, step, initial
9459    /// value; only 5.4+ reject a zero step, and an integer loop does that
9460    /// before looking at the limit.
9461    fn for_operands(&mut self, base: u32, a: u32) -> Result<(Num, Num, Num), LuaError> {
9462        let (init, limit, step) = (self.r(base, a), self.r(base, a + 1), self.r(base, a + 2));
9463        let v = self.version();
9464        let order = if v <= LuaVersion::Lua52 {
9465            [("initial value", init), ("limit", limit), ("step", step)]
9466        } else {
9467            [("limit", limit), ("step", step), ("initial value", init)]
9468        };
9469        if v >= LuaVersion::Lua54 && matches!((init, step), (Value::Int(_), Value::Int(0))) {
9470            return Err(self.rt_err("'for' step is zero"));
9471        }
9472        for (what, val) in order {
9473            if as_num(val, v).is_none() {
9474                return Err(self.rt_err(&if v >= LuaVersion::Lua54 {
9475                    format!(
9476                        "bad 'for' {what} (number expected, got {})",
9477                        self.obj_typename(val)
9478                    )
9479                } else {
9480                    format!("'for' {what} must be a number")
9481                }));
9482            }
9483        }
9484        let n = |val| as_num(val, v).expect("checked above");
9485        let int_loop =
9486            v <= LuaVersion::Lua52 || matches!((init, step), (Value::Int(_), Value::Int(_)));
9487        if int_loop {
9488            return Ok((n(init), n(limit), n(step)));
9489        }
9490        let (i, l, st) = (n(init).as_f64(), n(limit).as_f64(), n(step).as_f64());
9491        if v >= LuaVersion::Lua54 && st == 0.0 {
9492            return Err(self.rt_err("'for' step is zero"));
9493        }
9494        Ok((Num::Float(i), Num::Float(l), Num::Float(st)))
9495    }
9496
9497    fn for_prep(&mut self, inst: Inst, base: u32) -> Result<(), LuaError> {
9498        let a = inst.a();
9499        let (init_n, limit_n, step_n) = self.for_operands(base, a)?;
9500        // PUC 5.1–5.3 `OP_FORPREP` stores `i = init - step` and *unconditionally*
9501        // jumps to the matching `OP_FORLOOP` — the body never runs ahead of the
9502        // first test, so each successful iteration emits a backward `OP_FORLOOP`
9503        // jump (db.lua's `for i=1,4 do a=1 end` ↦ 5 line-hook events instead of
9504        // 5.4's 4). 5.4+ collapsed that to a count-based fall-through. The skip
9505        // distance in luna's encoding is `loop_pc - prep_pc`; firing
9506        // `add_pc(bx - 1)` lands the running pc on OP_FORLOOP itself.
9507        let pre53 = self.version() <= LuaVersion::Lua53;
9508        match (init_n, step_n) {
9509            (Num::Int(i0), Num::Int(st)) => {
9510                if pre53 {
9511                    // PUC 5.3 `forlimit`: int limit passes through; float limit
9512                    // gets clamped to MIN/MAX with a `stopnow` flag set only
9513                    // when the clamp is unreachable (positive float with a
9514                    // negative step → limit=MAX, stopnow; negative float with
9515                    // step>=0 → limit=MIN, stopnow). On `stopnow` PUC rewrites
9516                    // `init = 0` so OP_FORLOOP's first test against the
9517                    // unreachable clamp fails cleanly. An ordinary in-range
9518                    // empty loop (e.g. `for i = 1, 0`) is *not* `stopnow` — it
9519                    // lets OP_FORLOOP's natural test reject the first step.
9520                    let (lim, stopnow) = match limit_n {
9521                        Num::Int(l) => (l, false),
9522                        Num::Float(f) => {
9523                            // `luaV_tointeger` floors (ceils for a negative
9524                            // step); a float it cannot fit is clamped on
9525                            // the side of its sign, NaN counting as
9526                            // negative (`0 < n` is false).
9527                            let conv = if st < 0 { f.ceil() } else { f.floor() };
9528                            if (-9_223_372_036_854_775_808.0..9_223_372_036_854_775_808.0)
9529                                .contains(&conv)
9530                            {
9531                                (conv as i64, false)
9532                            } else if f > 0.0 {
9533                                (i64::MAX, st < 0)
9534                            } else {
9535                                (i64::MIN, st > 0)
9536                            }
9537                        }
9538                    };
9539                    let initv = if stopnow { 0 } else { i0 };
9540                    let pre = initv.wrapping_sub(st);
9541                    self.set_r(base, a, Value::Int(pre));
9542                    self.set_r(base, a + 1, Value::Int(lim));
9543                    self.set_r(base, a + 2, Value::Int(st));
9544                    self.add_pc(inst.bx() as i32 - 1);
9545                    return Ok(());
9546                }
9547                let (lim, empty) = int_for_limit(limit_n, i0, st);
9548                if empty {
9549                    self.add_pc(inst.bx() as i32);
9550                    return Ok(());
9551                }
9552                let count = if st > 0 {
9553                    (lim as u64).wrapping_sub(i0 as u64) / (st as u64)
9554                } else {
9555                    (i0 as u64).wrapping_sub(lim as u64) / (st as i128).unsigned_abs() as u64
9556                };
9557                self.set_r(base, a, Value::Int(i0));
9558                self.set_r(base, a + 1, Value::Int(count as i64));
9559                self.set_r(base, a + 2, Value::Int(st));
9560                self.set_r(base, a + 3, Value::Int(i0));
9561            }
9562            _ => {
9563                let (x0, lim, st) = (init_n.as_f64(), limit_n.as_f64(), step_n.as_f64());
9564                if pre53 {
9565                    let pre = x0 - st;
9566                    self.set_r(base, a, Value::Float(pre));
9567                    self.set_r(base, a + 1, Value::Float(lim));
9568                    self.set_r(base, a + 2, Value::Float(st));
9569                    self.add_pc(inst.bx() as i32 - 1);
9570                    return Ok(());
9571                }
9572                // lvm.c `forprep`: skip only when `0 < step ? limit < init :
9573                // init < limit`; a NaN makes both false, so the body runs
9574                // once (with a NaN step, on the second test's side)
9575                let skip = if 0.0 < st { lim < x0 } else { x0 < lim };
9576                let runs = !skip;
9577                if !runs {
9578                    self.add_pc(inst.bx() as i32);
9579                    return Ok(());
9580                }
9581                self.set_r(base, a, Value::Float(x0));
9582                self.set_r(base, a + 1, Value::Float(lim));
9583                self.set_r(base, a + 2, Value::Float(st));
9584                self.set_r(base, a + 3, Value::Float(x0));
9585            }
9586        }
9587        Ok(())
9588    }
9589
9590    #[inline(always)]
9591    fn for_loop(&mut self, inst: Inst, base: u32) -> Result<(), LuaError> {
9592        let a = inst.a();
9593        // PUC 5.1–5.3 `OP_FORLOOP` compares the post-step `i` to `limit`
9594        // directly (R[a+1] holds the limit, *not* a remaining-count) so the
9595        // first iteration's test fires through the same backward-jump path as
9596        // every later iteration. 5.4+ switched to the count-based form luna
9597        // already uses for `Int`; the float branch was already PUC-3.x-style.
9598        let v = self.version();
9599        let pre53 = v <= LuaVersion::Lua53;
9600        // `for_prep` leaves the three slots all Int or all Float; anything
9601        // else was written by `debug.setlocal` or by crafted bytecode. PUC
9602        // reads such slots unchecked (garbage or a crash); luna raises.
9603        match (self.r(base, a), self.r(base, a + 1), self.r(base, a + 2)) {
9604            (Value::Int(cur), Value::Int(lim), Value::Int(st)) if pre53 => {
9605                let next = cur.wrapping_add(st);
9606                let cont = if st > 0 { next <= lim } else { next >= lim };
9607                if cont {
9608                    self.set_r(base, a, Value::Int(next));
9609                    self.set_r(base, a + 3, Value::Int(next));
9610                    self.add_pc(-(inst.bx() as i32));
9611                }
9612            }
9613            // the count is unsigned (PUC `lua_Unsigned`): a loop over
9614            // more than 2^63 values stores a "negative" one
9615            (Value::Int(cur), Value::Int(count), Value::Int(st)) => {
9616                if count != 0 {
9617                    let next = cur.wrapping_add(st);
9618                    self.set_r(base, a, Value::Int(next));
9619                    self.set_r(base, a + 1, Value::Int(count.wrapping_sub(1)));
9620                    self.set_r(base, a + 3, Value::Int(next));
9621                    self.add_pc(-(inst.bx() as i32));
9622                }
9623            }
9624            (Value::Float(cur), Value::Float(lim), Value::Float(st)) => {
9625                self.float_for_step(inst, base, cur, lim, st);
9626            }
9627            // 5.1/5.2 have one number type, so a number of the other
9628            // representation stored into a slot is still a valid state
9629            (x, l, s) if v <= LuaVersion::Lua52 => {
9630                match (as_number(x), as_number(l), as_number(s)) {
9631                    (Some(cur), Some(lim), Some(st)) => {
9632                        self.float_for_step(inst, base, cur.as_f64(), lim.as_f64(), st.as_f64())
9633                    }
9634                    _ => return Err(self.rt_err("'for' state corrupted")),
9635                }
9636            }
9637            _ => return Err(self.rt_err("'for' state corrupted")),
9638        }
9639        Ok(())
9640    }
9641
9642    #[inline(always)]
9643    fn float_for_step(&mut self, inst: Inst, base: u32, cur: f64, lim: f64, st: f64) {
9644        let a = inst.a();
9645        let next = cur + st;
9646        let cont = if st > 0.0 { next <= lim } else { next >= lim };
9647        if cont {
9648            self.set_r(base, a, Value::Float(next));
9649            self.set_r(base, a + 3, Value::Float(next));
9650            self.add_pc(-(inst.bx() as i32));
9651        }
9652    }
9653
9654    // ---- native helpers (used by builtins) ----
9655
9656    /// A native function's own captured upvalue (self lives at func_slot).
9657    ///
9658    /// Public so `native_typed` trampolines and embedders authoring
9659    /// stateful natives via `native_with(...)` can read their upvals.
9660    pub fn nat_upval(&self, func_slot: u32, i: usize) -> Value {
9661        let Value::Native(nc) = self.stack[func_slot as usize] else {
9662            unreachable!("native frame without native closure");
9663        };
9664        nc.upvals[i]
9665    }
9666
9667    /// Number of upvalues captured by the native at `func_slot` (variadic
9668    /// captures such as the `io.lines` format list).
9669    pub(crate) fn nat_upcount(&self, func_slot: u32) -> usize {
9670        let Value::Native(nc) = self.stack[func_slot as usize] else {
9671            unreachable!("native frame without native closure");
9672        };
9673        nc.upvals.len()
9674    }
9675
9676    /// Write a native function's own upvalue (stateful iterators).
9677    pub(crate) fn nat_set_upval(&mut self, func_slot: u32, i: usize, v: Value) {
9678        let Value::Native(nc) = self.stack[func_slot as usize] else {
9679            unreachable!("native frame without native closure");
9680        };
9681        // 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).
9682        unsafe { nc.as_mut() }.upvals[i] = v;
9683        // NativeClosure.upvals is traced as part of its Trace; a long-lived
9684        // stateful iterator closure (e.g. string.gmatch) sees many writes —
9685        // barrier_back once-and-done is cheaper than per-child forward.
9686        self.heap
9687            .barrier_back(nc.as_ptr() as *mut crate::runtime::heap::GcHeader);
9688    }
9689
9690    /// Read the i-th positional argument inside a `NativeFn` body
9691    /// (analogous to `lua_tovalue(L, i + 1)`). `i >= nargs` yields `Nil`,
9692    /// matching PUC's "missing arg is nil" contract. Public so embedders
9693    /// can author their own natives.
9694    pub fn nat_arg(&self, func_slot: u32, nargs: u32, i: u32) -> Value {
9695        if i < nargs {
9696            self.stack[(func_slot + 1 + i) as usize]
9697        } else {
9698            Value::Nil
9699        }
9700    }
9701
9702    /// Overwrite the i-th argument slot of the running native (the in-place
9703    /// conversion `lua_tolstring` performs on a number argument).
9704    pub(crate) fn nat_set_arg(&mut self, func_slot: u32, i: u32, v: Value) {
9705        self.stack[(func_slot + 1 + i) as usize] = v;
9706    }
9707
9708    /// Push the return values of a `NativeFn` and return their count
9709    /// (analogous to pushing N values then `return N` from a C function).
9710    /// Public so embedders can author their own natives.
9711    pub fn nat_return(&mut self, func_slot: u32, vals: &[Value]) -> u32 {
9712        let need = func_slot as usize + vals.len();
9713        if self.stack.len() < need {
9714            self.stack.resize(need, Value::Nil);
9715        }
9716        for (i, &v) in vals.iter().enumerate() {
9717            self.stack[func_slot as usize + i] = v;
9718        }
9719        vals.len() as u32
9720    }
9721
9722    /// Fast string concatenation of an adjacent pair, or `None` when a
9723    /// `__concat` metamethod is required.
9724    fn concat_pair(&mut self, l: Value, r: Value) -> Result<Option<Value>, LuaError> {
9725        let legacy = self.float_fmt();
9726        // Length-check fast paths for both string operands BEFORE the
9727        // (expensive) copy in `concat_piece`, so a runaway `a..a..a..…`
9728        // chain (5.1 big.lua / 5.5 heavy.lua's `teststring`) raises the
9729        // overflow on the first pair that would exceed `INT_MAX` instead
9730        // of allocating multi-GB intermediates first.
9731        let max_str = i32::MAX as usize;
9732        if let (Value::Str(ls), Value::Str(rs)) = (l, r) {
9733            let a_len = ls.as_bytes().len();
9734            let b_len = rs.as_bytes().len();
9735            let new_len = a_len.checked_add(b_len);
9736            if new_len.is_none() || new_len.unwrap() > max_str {
9737                return Err(self.rt_err("string length overflow"));
9738            }
9739        }
9740        match (concat_piece(l, legacy), concat_piece(r, legacy)) {
9741            (Some(a), Some(b)) => {
9742                // PUC `MAX_SIZE` for Lua strings is `INT_MAX`; an attempt to
9743                // concat past it raises "string length overflow"
9744                // (5.5 heavy.lua `teststring` doubles `a..a..…` until it hits
9745                // exactly this wall).
9746                let new_len = a.len().checked_add(b.len());
9747                if new_len.is_none() || new_len.unwrap() > max_str {
9748                    return Err(self.rt_err("string length overflow"));
9749                }
9750                let mut combined = a;
9751                combined.extend_from_slice(&b);
9752                Ok(Some(Value::Str(self.heap.intern(&combined))))
9753            }
9754            _ => Ok(None),
9755        }
9756    }
9757
9758    /// Fold the concat operands occupying `[base_a .. self.top)` right-to-left
9759    /// into a single result at `base_a` (PUC `luaV_concat`). Returns after
9760    /// either finishing (result at `base_a`) or arming a yieldable `__concat`
9761    /// call — its `Meta` continuation re-enters here on the metamethod's return.
9762    fn concat_run(&mut self, base_a: u32) -> Result<(), LuaError> {
9763        // Sum the lengths of all all-Str operands BEFORE starting the
9764        // right-associative fold so a 129-operand `a..a..…` chain
9765        // (5.1 big.lua's `rep129(longs)`) raises overflow immediately,
9766        // not after dozens of multi-GB intermediate intern+hash rounds.
9767        // A non-Str operand falls through to the per-pair check.
9768        let max_str = i32::MAX as usize;
9769        let mut total: usize = 0;
9770        let mut all_str = true;
9771        for slot in base_a..self.top {
9772            match self.stack[slot as usize] {
9773                Value::Str(s) => match total.checked_add(s.as_bytes().len()) {
9774                    Some(t) if t <= max_str => total = t,
9775                    _ => return Err(self.rt_err("string length overflow")),
9776                },
9777                _ => {
9778                    all_str = false;
9779                    break;
9780                }
9781            }
9782        }
9783        let _ = all_str; // discrimination already captured by early returns above
9784        while self.top.saturating_sub(base_a) >= 2 {
9785            let i = self.top - 1; // rightmost operand
9786            let x = self.stack[(i - 1) as usize];
9787            let y = self.stack[i as usize];
9788            match self.concat_pair(x, y)? {
9789                Some(s) => {
9790                    self.stack[(i - 1) as usize] = s;
9791                    self.top = i; // consumed y
9792                }
9793                None => {
9794                    let mut mm = self.get_mm(x, Mm::Concat);
9795                    if mm.is_nil() {
9796                        mm = self.get_mm(y, Mm::Concat);
9797                    }
9798                    if mm.is_nil() {
9799                        let legacy = self.float_fmt();
9800                        let bad = if concat_piece(x, legacy).is_none() {
9801                            x
9802                        } else {
9803                            y
9804                        };
9805                        return Err(self.type_err("concatenate", bad));
9806                    }
9807                    // result lands at i-1, dropping y (top→i); resume continues.
9808                    let dst = i - 1;
9809                    self.begin_meta_call(
9810                        mm,
9811                        &[x, y],
9812                        MetaAction::Concat { dst, base_a },
9813                        "concat",
9814                    )?;
9815                    return Ok(());
9816                }
9817            }
9818        }
9819        self.maybe_collect_garbage(base_a + 1);
9820        Ok(())
9821    }
9822
9823    /// `luaL_tolstring`: `__tostring` (whose result must be a string or a
9824    /// number, rendered), else the basic rendering, where 5.3+ names a value
9825    /// by a string `__name` metafield.
9826    pub fn tostring_value(&mut self, v: Value) -> Result<Vec<u8>, LuaError> {
9827        let mm = self.get_mm(v, Mm::ToString);
9828        if !mm.is_nil() {
9829            // `luaL_callmeta` is a plain `lua_call`: `__tostring` cannot yield.
9830            let r = self.call_noyield(mm, &[v])?;
9831            return match r.first().copied().unwrap_or(Value::Nil) {
9832                Value::Str(s) => Ok(s.as_bytes().to_vec()),
9833                r @ (Value::Int(_) | Value::Float(_)) => Ok(self.tostring_basic(r)),
9834                // luaL_error: positioned at whatever called the library function
9835                _ => Err(crate::vm::builtins::raise_str(
9836                    self,
9837                    "'__tostring' must return a string",
9838                )),
9839            };
9840        }
9841        if self.version >= LuaVersion::Lua53
9842            && !matches!(
9843                v,
9844                Value::Nil | Value::Bool(_) | Value::Int(_) | Value::Float(_) | Value::Str(_)
9845            )
9846            && let Value::Str(name) = self.get_mm(v, Mm::Name)
9847        {
9848            let basic = self.tostring_basic(v);
9849            let at = basic
9850                .iter()
9851                .position(|&c| c == b':')
9852                .expect("an object renders as `kind: address`");
9853            let mut out = name.as_bytes().to_vec();
9854            out.extend_from_slice(&basic[at..]);
9855            return Ok(out);
9856        }
9857        Ok(self.tostring_basic(v))
9858    }
9859
9860    /// The dialect's float-rendering flavor: ≤5.2 %.14g
9861    /// bare, 5.3/5.4 %.14g + ".0", 5.5 two-stage %.15g/%.17g + ".0".
9862    pub(crate) fn float_fmt(&self) -> numeric::FloatFmt {
9863        use crate::version::LuaVersion::*;
9864        match self.version {
9865            Lua51 | Lua52 => numeric::FloatFmt::Legacy14,
9866            Lua53 | Lua54 => numeric::FloatFmt::G14,
9867            _ => numeric::FloatFmt::TwoStage55,
9868        }
9869    }
9870
9871    /// Basic tostring (no metamethods).
9872    pub(crate) fn tostring_basic(&mut self, v: Value) -> Vec<u8> {
9873        match v {
9874            Value::Nil => b"nil".to_vec(),
9875            Value::Bool(true) => b"true".to_vec(),
9876            Value::Bool(false) => b"false".to_vec(),
9877            Value::Int(i) => numeric::num_to_string(Num::Int(i)).into_bytes(),
9878            // PUC ≤5.2 has no integer subtype — `tostring(2.0)` is `"2"`, not
9879            // `"2.0"`. The 5.3+ split needs the suffix so `print(2.0)` is
9880            // distinguishable from `print(2)`. pm.lua :13 builds patterns by
9881            // concatenating these renderings.
9882            Value::Float(f) => {
9883                numeric::num_to_string_for(Num::Float(f), self.float_fmt()).into_bytes()
9884            }
9885            Value::Str(s) => s.as_bytes().to_vec(),
9886            Value::Table(t) => format!("table: {:p}", t.as_ptr()).into_bytes(),
9887            Value::Closure(c) => format!("function: {:p}", c.as_ptr()).into_bytes(),
9888            Value::Native(n) => format!("function: {:p}", n.as_ptr()).into_bytes(),
9889            Value::Coro(co) => format!("thread: {:p}", co.as_ptr()).into_bytes(),
9890            // PUC names file handles `file (0x…)`; a bare userdata is
9891            // `userdata: 0x…`. The io library overrides this via __tostring.
9892            Value::Userdata(u) => format!("userdata: {:p}", u.as_ptr()).into_bytes(),
9893            // PUC `lua_topointer`/tostring on light udata: "userdata: 0x…"
9894            // (the "light" qualifier only appears in `luaL_typeerror`).
9895            Value::LightUserdata(p) => format!("userdata: {p:p}").into_bytes(),
9896        }
9897    }
9898}
9899
9900#[derive(Clone, Copy, PartialEq, Eq)]
9901pub(crate) enum ArithOp {
9902    Add,
9903    Sub,
9904    Mul,
9905    Mod,
9906    Pow,
9907    Div,
9908    IDiv,
9909    BAnd,
9910    BOr,
9911    BXor,
9912    Shl,
9913    Shr,
9914}
9915
9916impl ArithOp {
9917    /// PUC metamethod event name (`__add` → "add" etc.) used by
9918    /// `debug.getinfo(level, "n")` inside a metamethod handler.
9919    fn mm_name(self) -> &'static str {
9920        match self {
9921            ArithOp::Add => "add",
9922            ArithOp::Sub => "sub",
9923            ArithOp::Mul => "mul",
9924            ArithOp::Mod => "mod",
9925            ArithOp::Pow => "pow",
9926            ArithOp::Div => "div",
9927            ArithOp::IDiv => "idiv",
9928            ArithOp::BAnd => "band",
9929            ArithOp::BOr => "bor",
9930            ArithOp::BXor => "bxor",
9931            ArithOp::Shl => "shl",
9932            ArithOp::Shr => "shr",
9933        }
9934    }
9935}
9936
9937fn as_num(v: Value, version: LuaVersion) -> Option<Num> {
9938    match v {
9939        Value::Int(i) => Some(Num::Int(i)),
9940        Value::Float(f) => Some(Num::Float(f)),
9941        // PUC forprep coerces numeric strings (`for i = "10", "1", "-2"`).
9942        Value::Str(s) => str_to_num(s.as_bytes(), version),
9943        _ => None,
9944    }
9945}
9946
9947/// Float `%` as each dialect's `luai_nummod` defines it. 5.1/5.2 compute
9948/// `a - floor(a/b)*b` (so `5 % math.huge` is nan); 5.3 fixes `fmod`'s sign
9949/// when `m*b < 0`; 5.4 compares signs instead, because that product
9950/// underflows to zero for tiny operands.
9951fn float_mod(version: LuaVersion, a: f64, b: f64) -> f64 {
9952    if version <= LuaVersion::Lua52 {
9953        return a - (a / b).floor() * b;
9954    }
9955    let m = a % b;
9956    let fix = if version == LuaVersion::Lua53 {
9957        m * b < 0.0
9958    } else {
9959        (m > 0.0 && b < 0.0) || (m < 0.0 && b > 0.0)
9960    };
9961    if fix { m + b } else { m }
9962}
9963
9964/// A concatenable operand's byte form (string, or a number coerced to its
9965/// string), or `None` when only a `__concat` metamethod can handle it.
9966/// `legacy_float = true` follows PUC ≤5.2's `%.14g` rendering (no `.0`
9967/// suffix on integer-valued floats) — see `num_to_string_for`.
9968fn concat_piece(v: Value, float_fmt: numeric::FloatFmt) -> Option<Vec<u8>> {
9969    match v {
9970        Value::Str(s) => Some(s.as_bytes().to_vec()),
9971        Value::Int(x) => Some(numeric::num_to_string(Num::Int(x)).into_bytes()),
9972        Value::Float(x) => Some(numeric::num_to_string_for(Num::Float(x), float_fmt).into_bytes()),
9973        _ => None,
9974    }
9975}
9976
9977/// Index into the per-basic-type metatable table for a non-table value
9978/// (None for tables, which carry their own metatable).
9979fn type_mt_slot(v: Value) -> Option<usize> {
9980    match v {
9981        Value::Nil => Some(0),
9982        Value::Bool(_) => Some(1),
9983        Value::Int(_) | Value::Float(_) => Some(2),
9984        Value::Str(_) => Some(3),
9985        Value::Closure(_) | Value::Native(_) => Some(4),
9986        // tables and full userdata carry their own metatable; threads and
9987        // light userdata have none (PUC keeps a shared per-type mt slot for
9988        // light, but luna doesn't expose it — no test gates on it yet).
9989        Value::Table(_) | Value::Coro(_) | Value::Userdata(_) | Value::LightUserdata(_) => None,
9990    }
9991}
9992
9993/// A number operand as-is; strings stay non-numbers (5.4+ `tonumberns`).
9994fn as_number(v: Value) -> Option<Num> {
9995    match v {
9996        Value::Int(i) => Some(Num::Int(i)),
9997        Value::Float(f) => Some(Num::Float(f)),
9998        _ => None,
9999    }
10000}
10001
10002/// Arithmetic (not bitwise) on two numbers, PUC `luaO_rawarith`: integer
10003/// results for integer operands except `/` and `^`. The error is the
10004/// message of a zero integer divisor.
10005pub(crate) fn arith_num(
10006    version: LuaVersion,
10007    op: ArithOp,
10008    ln: Num,
10009    rn: Num,
10010) -> Result<Value, &'static str> {
10011    use ArithOp::*;
10012    Ok(match (op, ln, rn) {
10013        (Add, Num::Int(a), Num::Int(b)) => Value::Int(a.wrapping_add(b)),
10014        (Sub, Num::Int(a), Num::Int(b)) => Value::Int(a.wrapping_sub(b)),
10015        (Mul, Num::Int(a), Num::Int(b)) => Value::Int(a.wrapping_mul(b)),
10016        (IDiv, Num::Int(a), Num::Int(b)) => {
10017            if b == 0 {
10018                return Err("attempt to divide by zero");
10019            }
10020            let mut q = a.wrapping_div(b);
10021            if (a ^ b) < 0 && q.wrapping_mul(b) != a {
10022                q -= 1;
10023            }
10024            Value::Int(q)
10025        }
10026        (Mod, Num::Int(a), Num::Int(b)) => {
10027            if b == 0 {
10028                return Err("attempt to perform 'n%0'");
10029            }
10030            let mut m = a.wrapping_rem(b);
10031            if m != 0 && (m ^ b) < 0 {
10032                m += b;
10033            }
10034            Value::Int(m)
10035        }
10036        (Add, a, b) => Value::Float(a.as_f64() + b.as_f64()),
10037        (Sub, a, b) => Value::Float(a.as_f64() - b.as_f64()),
10038        (Mul, a, b) => Value::Float(a.as_f64() * b.as_f64()),
10039        (Div, a, b) => Value::Float(a.as_f64() / b.as_f64()),
10040        // 5.4+ `luai_numpow` squares by multiplying, which can differ from
10041        // the C library's `pow` in the last bit
10042        (Pow, a, b) => {
10043            let (a, b) = (a.as_f64(), b.as_f64());
10044            Value::Float(if b == 2.0 && version >= LuaVersion::Lua54 {
10045                a * a
10046            } else {
10047                a.powf(b)
10048            })
10049        }
10050        (IDiv, a, b) => Value::Float((a.as_f64() / b.as_f64()).floor()),
10051        (Mod, a, b) => Value::Float(float_mod(version, a.as_f64(), b.as_f64())),
10052        (BAnd | BOr | BXor | Shl | Shr, ..) => unreachable!("bitwise op in arith_num"),
10053    })
10054}
10055
10056/// A number's integer value, if it has one.
10057fn int_of(n: Num) -> Option<i64> {
10058    match n {
10059        Num::Int(i) => Some(i),
10060        Num::Float(f) => crate::runtime::value::f2i_exact(f),
10061    }
10062}
10063
10064/// A number, or a numeric string read as a float (5.2).
10065fn coerce_num_float(v: Value) -> Option<Num> {
10066    match v {
10067        Value::Str(s) => numeric::str2num(s.as_bytes(), false, true),
10068        v => as_number(v),
10069    }
10070}
10071
10072/// Number, or string coerced to number (5.3 string-arith coercion).
10073fn coerce_num(v: Value) -> Option<Num> {
10074    match v {
10075        Value::Str(s) => numeric::str2num(s.as_bytes(), true, true),
10076        v => as_number(v),
10077    }
10078}
10079
10080/// A number, or a numeric string read by C `strtod` (5.1 `luaO_str2d`).
10081fn coerce_num_51(v: Value) -> Option<Num> {
10082    match v {
10083        Value::Str(s) => numeric::strtod_str(s.as_bytes()).map(Num::Float),
10084        v => as_number(v),
10085    }
10086}
10087
10088/// A string's numeric value as the dialect converts it: 5.1 runs C
10089/// `strtod` over it (so `inf`/`nan` count and a NUL ends it), 5.2 has its
10090/// own reader but still only floats, 5.3+ `luaO_str2num` with integers.
10091pub(crate) fn str_to_num(s: &[u8], version: LuaVersion) -> Option<Num> {
10092    match version {
10093        LuaVersion::Lua51 => numeric::strtod_str(s).map(Num::Float),
10094        LuaVersion::Lua52 => numeric::str2num(s, false, true),
10095        _ => numeric::str2num(s, true, true),
10096    }
10097}
10098
10099/// Lua shifts: logical on 64 bits; |shift| ≥ 64 yields 0; negative shifts
10100/// reverse direction.
10101fn shift_left(a: i64, b: i64) -> i64 {
10102    if b < 0 {
10103        if b <= -64 {
10104            0
10105        } else {
10106            ((a as u64) >> (-b as u32)) as i64
10107        }
10108    } else if b >= 64 {
10109        0
10110    } else {
10111        ((a as u64) << (b as u32)) as i64
10112    }
10113}
10114
10115/// i < f, exactly (PUC LTintfloat shape).
10116fn int_lt_float(i: i64, f: f64) -> bool {
10117    if f.is_nan() {
10118        return false;
10119    }
10120    if f >= 9_223_372_036_854_775_808.0 {
10121        return true;
10122    }
10123    if f < -9_223_372_036_854_775_808.0 {
10124        return false;
10125    }
10126    let ff = f.floor();
10127    let fi = ff as i64;
10128    if f == ff { i < fi } else { i <= fi }
10129}
10130
10131/// i <= f, exactly.
10132fn int_le_float(i: i64, f: f64) -> bool {
10133    if f.is_nan() {
10134        return false;
10135    }
10136    if f >= 9_223_372_036_854_775_808.0 {
10137        return true;
10138    }
10139    if f < -9_223_372_036_854_775_808.0 {
10140        return false;
10141    }
10142    i <= f.floor() as i64
10143}
10144
10145/// Clip a numeric `for` limit to the integer range (PUC forlimit). Returns
10146/// (clipped limit, loop-is-empty).
10147fn int_for_limit(limit: Num, init: i64, step: i64) -> (i64, bool) {
10148    match limit {
10149        Num::Int(l) => {
10150            let empty = if step > 0 { init > l } else { init < l };
10151            (l, empty)
10152        }
10153        Num::Float(f) => {
10154            // PUC `forlimit` treats NaN like a limit below the integer
10155            // range (`0 < flim` is false): no run upward, down to minint
10156            // otherwise.
10157            if f.is_nan() {
10158                return if step > 0 {
10159                    (0, true)
10160                } else {
10161                    (i64::MIN, false)
10162                };
10163            }
10164            if step > 0 {
10165                if f >= 9_223_372_036_854_775_808.0 {
10166                    (i64::MAX, false)
10167                } else {
10168                    let l = f.floor();
10169                    if l < -9_223_372_036_854_775_808.0 {
10170                        (i64::MIN, true)
10171                    } else {
10172                        let li = l as i64;
10173                        (li, init > li)
10174                    }
10175                }
10176            } else if f <= -9_223_372_036_854_775_808.0 {
10177                (i64::MIN, false)
10178            } else {
10179                let l = f.ceil();
10180                if l >= 9_223_372_036_854_775_808.0 {
10181                    // PUC forlimit: a positive limit beyond the integer range
10182                    // is unreachable for a decreasing loop — empty.
10183                    (i64::MAX, true)
10184                } else {
10185                    let li = l as i64;
10186                    (li, init < li)
10187                }
10188            }
10189        }
10190    }
10191}
10192
10193impl Vm {
10194    /// PUC's debug-API placeholder for an unnamed vararg slot returned by
10195    /// `debug.getlocal(_, -n)`. 5.2/5.3 spelled it `"(*vararg)"`; 5.4
10196    /// dropped the asterisk in favour of `"(vararg)"`. db.lua 5.2 :189 /
10197    /// 5.3 :195 / 5.4 :286 baseline on their respective form.
10198    pub(crate) fn vararg_locvar_name(&self) -> &'static str {
10199        if matches!(self.version, LuaVersion::Lua52 | LuaVersion::Lua53) {
10200            "(*vararg)"
10201        } else {
10202            "(vararg)"
10203        }
10204    }
10205
10206    /// PUC's debug-API placeholder for an unnamed temporary on a C
10207    /// activation. 5.2/5.3 reported `"(*temporary)"`; 5.4 switched to
10208    /// `"(C temporary)"`. db.lua 5.2 :288, 5.3 :312, 5.4 :404 each pin
10209    /// their spelling.
10210    pub(crate) fn temporary_locvar_name(&self) -> &'static str {
10211        if matches!(
10212            self.version,
10213            LuaVersion::Lua51 | LuaVersion::Lua52 | LuaVersion::Lua53
10214        ) {
10215            // PUC 5.1's `findlocal` C-frame branch reported `(*temporary)`
10216            // (db.lua :228 pins it). 5.2/5.3 kept the spelling, 5.4 changed
10217            // to `(C temporary)`.
10218            "(*temporary)"
10219        } else {
10220            "(C temporary)"
10221        }
10222    }
10223
10224    /// PUC's debug-API placeholder for an unnamed Lua-frame temporary
10225    /// (an arithmetic intermediate sitting past the last named local on a
10226    /// live register slot). 5.2/5.3 reported `"(*temporary)"`; 5.4 dropped
10227    /// the asterisk to `"(temporary)"`. db.lua 5.3 :786, 5.4 :966 pin the
10228    /// spelling.
10229    pub(crate) fn lua_temporary_locvar_name(&self) -> &'static str {
10230        if matches!(
10231            self.version,
10232            LuaVersion::Lua51 | LuaVersion::Lua52 | LuaVersion::Lua53
10233        ) {
10234            "(*temporary)"
10235        } else {
10236            "(temporary)"
10237        }
10238    }
10239
10240    /// PUC `pushglobalfuncname`: walk `package.loaded` to depth 2 looking for a
10241    /// native whose function pointer matches `target`, and return its qualified
10242    /// name (e.g. `"table.sort"`). A `_G.X` match is stripped to `"X"`. Returns
10243    /// `None` if no match is found. Used by `arg_error` when the running native
10244    /// was invoked from another native (PUC `ar.name == NULL` at level 0).
10245    pub(crate) fn pushglobalfuncname(
10246        &mut self,
10247        target: crate::runtime::value::NativeFn,
10248    ) -> Option<String> {
10249        let pkg_k = Value::Str(self.heap.intern(b"package"));
10250        let pkg = match self.globals().get(pkg_k) {
10251            Value::Table(t) => t,
10252            _ => return None,
10253        };
10254        let loaded_k = Value::Str(self.heap.intern(b"loaded"));
10255        let loaded = match pkg.get(loaded_k) {
10256            Value::Table(t) => t,
10257            _ => return None,
10258        };
10259        let matches = |v: Value| -> bool {
10260            matches!(v, Value::Native(nc) if std::ptr::fn_addr_eq(nc.f, target))
10261        };
10262        let mut k = Value::Nil;
10263        while let Ok(Some((nk, nv))) = loaded.next(k) {
10264            k = nk;
10265            let Value::Str(outer) = nk else { continue };
10266            let outer = String::from_utf8_lossy(outer.as_bytes()).into_owned();
10267            if matches(nv) {
10268                return Some(if outer == "_G" { String::new() } else { outer });
10269            }
10270            if let Value::Table(inner_t) = nv {
10271                let mut k2 = Value::Nil;
10272                while let Ok(Some((nk2, nv2))) = inner_t.next(k2) {
10273                    k2 = nk2;
10274                    if matches(nv2)
10275                        && let Value::Str(inner) = nk2
10276                    {
10277                        let inner = String::from_utf8_lossy(inner.as_bytes()).into_owned();
10278                        return Some(if outer == "_G" {
10279                            inner
10280                        } else {
10281                            format!("{outer}.{inner}")
10282                        });
10283                    }
10284                }
10285            }
10286        }
10287        None
10288    }
10289
10290    /// How the caller named the running native (PUC `lua_getinfo("n")` at
10291    /// level 0): `None` when it gives no name, as when the caller is C.
10292    pub(crate) fn running_call_name(&self) -> Option<(&'static str, String)> {
10293        let ts = self.thread_stack(None);
10294        if ts.levels.is_empty() {
10295            return None;
10296        }
10297        self.level_name(&ts, 0)
10298    }
10299
10300    /// Read an upvalue cell of a closure (debug.getupvalue).
10301    pub(crate) fn upvalue_value(&self, cl: Gc<LuaClosure>, idx: usize) -> Value {
10302        match cl.upvals()[idx].state() {
10303            UpvalState::Open { slot, thread } => self.read_slot(slot, thread),
10304            UpvalState::Closed(v) => v,
10305        }
10306    }
10307
10308    /// Write an upvalue cell of a closure (debug.setupvalue).
10309    pub(crate) fn upvalue_set_value(&mut self, cl: Gc<LuaClosure>, idx: usize, v: Value) {
10310        let uv = cl.upvals()[idx];
10311        match uv.state() {
10312            UpvalState::Open { slot, thread } => self.write_slot(slot, thread, v),
10313            UpvalState::Closed(_) => {
10314                // 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).
10315                unsafe { uv.as_mut() }.set_closed(v);
10316                self.heap
10317                    .barrier_forward(uv.as_ptr() as *mut crate::runtime::heap::GcHeader, v);
10318            }
10319        }
10320    }
10321}
10322
10323// ────────────────────────────────────────────────────────────────────
10324// AOT trace dispatch install.
10325//
10326// The deploy-side resolver in `luna-runtime-helpers` walks the binary's
10327// trace-meta section after `vm.load`, resolves each entry's
10328// `(proto_hash, head_pc, fn_ptr)` triple against the loaded chunk's
10329// proto tree, and pushes a `CompiledTrace` onto the matching Proto's
10330// `traces` Vec via [`Vm::install_aot_trace`] below. The existing
10331// trace-dispatch loop (this file's `cl.proto.traces.borrow().iter()
10332// .find(|t| t.head_pc == pc && t.dispatchable)`) then fires the AOT
10333// mcode without further plumbing — same code path the runtime JIT
10334// uses.
10335//
10336// Why a separate impl block: keeps the AOT API surface (one fn) easy
10337// to locate when grep'ing for `install_aot_trace`, without dragging
10338// the 8500-line `impl Vm` block above.
10339// ────────────────────────────────────────────────────────────────────
10340
10341impl Vm {
10342    /// Install a precompiled
10343    /// `CompiledTrace` onto `proto.traces` so the interp dispatcher
10344    /// fires it at the trace's `head_pc`. This is the runtime install
10345    /// API the deploy-side `luna-runtime-helpers` resolver calls once
10346    /// per AOT-emitted trace meta entry, after looking up `proto` by
10347    /// stable hash (see `crate::runtime::function::Proto::stable_hash`).
10348    ///
10349    /// # What this does
10350    ///
10351    /// Pushes `trace` onto `proto.traces` via the existing `RefCell`.
10352    /// The trace's `entry` fn ptr must already point at runnable
10353    /// machine code (the AOT linker resolved the symbol at link time;
10354    /// the deploy resolver passes the address verbatim).
10355    ///
10356    /// # What this does NOT do
10357    ///
10358    /// - **No deduplication.** Calling twice with the same `head_pc`
10359    ///   pushes two entries; the dispatcher's `find` will pick the
10360    ///   first match. The deploy resolver is responsible for not
10361    ///   double-installing.
10362    /// - **No invalidation of the runtime JIT cache.** If the runtime
10363    ///   JIT later records + compiles a trace for the same
10364    ///   `(proto, head_pc)`, both coexist on `proto.traces` and the
10365    ///   dispatcher's `find` picks whichever appears first. AOT
10366    ///   traces install before any runtime recording is possible
10367    ///   (resolver runs before `vm.load` returns its first closure),
10368    ///   so AOT traces win the race for the same site.
10369    /// - **No coverage gating.** AOT traces are trusted by
10370    ///   construction — they were validated at compile time. Setting
10371    ///   `dispatchable: false` on the input would silently disable
10372    ///   dispatch; the caller controls that flag.
10373    ///
10374    /// # Safety / soundness
10375    ///
10376    /// `trace.entry` is an `unsafe extern "C" fn` (mmap'd or linked
10377    /// machine code). Soundness contract:
10378    ///
10379    /// - The fn pointer must remain valid for the `Vm`'s lifetime.
10380    ///   In the AOT-binary deploy shape this is trivially satisfied —
10381    ///   the fn lives in the binary's `.text`.
10382    /// - `trace.entry_tags` / `exit_tags` / `window_size` must match
10383    ///   what the trace's IR actually compiled against; the dispatcher
10384    ///   uses them to marshal `reg_state` in and out without further
10385    ///   validation. A mismatch corrupts vm.stack.
10386    ///
10387    /// The AOT pipeline (`luna-aot`) is responsible for ensuring these
10388    /// invariants hold; this fn is a plain push — no validation that
10389    /// would slow the dispatcher's hot path either.
10390    pub fn install_aot_trace(
10391        &mut self,
10392        proto: crate::runtime::Gc<crate::runtime::function::Proto>,
10393        trace: crate::jit::trace::CompiledTrace,
10394    ) {
10395        let _ = self; // resolver passes &mut Vm for symmetry with future
10396        // pending-install + hash-walk variants; nothing on `self` to
10397        // mutate today because the install target lives on the Proto.
10398        proto.traces.borrow_mut().push(TArc::new(trace));
10399    }
10400
10401    /// Walk the proto tree
10402    /// reachable from `root` and return `(proto, stable_hash)` pairs
10403    /// for every Proto found. Used by the deploy-side resolver to
10404    /// match AOT-emitted `proto_hash` keys against the freshly
10405    /// `undump`'d chunk's protos.
10406    ///
10407    /// The walk is BFS over `Proto.protos`. Same-Proto deduplication
10408    /// is done via `Gc::as_ptr` identity — a Proto re-referenced from
10409    /// multiple nested closures (rare; the cache field would catch
10410    /// the closure-side dedup, not the Proto side) is reported once.
10411    ///
10412    /// # Why on `&Vm` and not a free fn
10413    ///
10414    /// Keeps the AOT install API discoverable on the Vm surface —
10415    /// `vm.collect_proto_hashes(root)` reads naturally next to
10416    /// `vm.install_aot_trace(proto, trace)`. Doesn't actually touch
10417    /// any Vm field, so `&self` (read-only) is enough.
10418    pub fn collect_proto_hashes(
10419        &self,
10420        root: crate::runtime::Gc<crate::runtime::function::Proto>,
10421    ) -> Vec<(
10422        crate::runtime::Gc<crate::runtime::function::Proto>,
10423        [u8; 16],
10424    )> {
10425        let _ = self;
10426        let mut out = Vec::new();
10427        let mut seen: std::collections::HashSet<*const crate::runtime::function::Proto> =
10428            std::collections::HashSet::new();
10429        let mut queue: std::collections::VecDeque<
10430            crate::runtime::Gc<crate::runtime::function::Proto>,
10431        > = std::collections::VecDeque::new();
10432        queue.push_back(root);
10433        while let Some(p) = queue.pop_front() {
10434            let key = p.as_ptr() as *const _;
10435            if !seen.insert(key) {
10436                continue;
10437            }
10438            out.push((p, p.stable_hash()));
10439            for &child in p.protos.iter() {
10440                queue.push_back(child);
10441            }
10442        }
10443        out
10444    }
10445}
10446
10447/// Recordings of one trace head that may fail to compile before the head
10448/// is no longer recorded (LuaJIT likewise blacklists a trace start after
10449/// repeated failures). A few tries, since a later recording can see
10450/// different register kinds.
10451const MAX_TRACE_COMPILE_FAILURES: u8 = 3;
10452
10453fn note_trace_compile_failure(proto: Gc<crate::runtime::function::Proto>, head_pc: u32) {
10454    let mut failures = proto.trace_compile_failures.borrow_mut();
10455    match failures.iter_mut().find(|(pc, _)| *pc == head_pc) {
10456        Some((_, n)) => *n = n.saturating_add(1),
10457        None => failures.push((head_pc, 1)),
10458    }
10459}
10460
10461fn trace_head_abandoned(proto: Gc<crate::runtime::function::Proto>, head_pc: u32) -> bool {
10462    proto
10463        .trace_compile_failures
10464        .borrow()
10465        .iter()
10466        .any(|&(pc, n)| pc == head_pc && n >= MAX_TRACE_COMPILE_FAILURES)
10467}