Skip to main content

luna_core/vm/
exec.rs

1//! The interpreter. Dispatch is a plain match over opcodes (the P10 ceiling
2//! pass owns dispatch optimization). Lua→Lua calls share one loop and never
3//! recurse the Rust stack; only native↔Lua boundaries do (e.g. pcall).
4//!
5//! Varargs follow 5.5 semantics: a vararg call materializes a vararg table
6//! (fields 1..n plus "n") kept in the function's own stack slot; `...`
7//! expands from it and `...name` binds it. 5.1 LUAI_COMPAT_VARARG also
8//! materializes a local `arg` table (see `proto.has_compat_vararg_arg`).
9
10use crate::compiler::compile_chunk;
11use crate::frontend::{SyntaxError, parse};
12use crate::jit::send_compat::TArc;
13use crate::numeric::{self, Num};
14use crate::runtime::heap::GcHeader;
15use crate::runtime::{
16    AfterClose, CallFrame, CloseCont, ContKind, Coro, CoroStatus, Frame, Gc, Heap, LuaClosure,
17    MetaAction, MetaCont, NativeClosure, NativeCont, Table, TableError, UpvalState, Upvalue, Value,
18};
19use crate::version::LuaVersion;
20use crate::vm::builtins::{nat_pairs, nat_pcall, nat_xpcall};
21use crate::vm::error::LuaError;
22use crate::vm::isa::{Inst, Op};
23
24/// A Lua virtual machine: one OS thread's worth of Lua state.
25///
26/// # Threading model
27///
28/// `Vm` is **`!Send + !Sync`**. The GC uses `Gc<T> = NonNull<T>` over
29/// an intrusive mark-sweep heap (not `Rc<RefCell<T>>`), and the trace
30/// JIT side-table uses `Rc<CompiledTrace>` — both single-threaded by
31/// design. Embedders that want concurrency spawn one `Vm` per OS
32/// thread (or per single-thread Tokio worker) and exchange data via
33/// channels. See [`docs/threading.md`](../../docs/threading.md) for
34/// canonical embedding patterns including Tokio `current_thread`,
35/// `LocalSet` on multi-thread, and `Vm`-per-OS-thread + channels.
36///
37/// The constraint is enforced at compile time:
38///
39/// ```compile_fail
40/// fn must_be_send<T: Send>() {}
41/// must_be_send::<luna_core::Vm>(); // error[E0277]: `Vm` cannot be sent between threads safely
42/// ```
43///
44/// A future `feature = "send"` (post-v1.1 sprint) will gate an
45/// opt-in `Arc<RwLock<T>>` mode with a hard ≤8% perf regression
46/// budget. See `.dev/rfcs/v1.1-rfc-vm-send-sync.md` for the design.
47pub struct Vm {
48    /// The GC heap owned by this VM. Embedders normally interact via the
49    /// `Vm` methods (`load` / `call_value` / `set_global` / …) rather than
50    /// the heap directly.
51    pub heap: Heap,
52    stack: Vec<Value>,
53    frames: Vec<CallFrame>,
54    /// P17-D Week 1 shadow — frames_top mirrors `self.frames.len()`.
55    /// Synced on every push/pop in `frames_push_sync`/`frames_pop_sync`
56    /// helpers (debug-asserted on use). NOT consumed by readers yet;
57    /// week 1 is pure scaffold. Week 2-N migrations replace readers
58    /// one slice at a time, then remove `frames: Vec<CallFrame>` in
59    /// favour of a flat `[CallFrame; MAX_FRAMES]` indexed by frames_top.
60    frames_top: u32,
61    /// open upvalues, sorted ascending by stack slot
62    open_upvals: Vec<(u32, Gc<Upvalue>)>,
63    /// to-be-closed slots, ascending
64    tbc: Vec<u32>,
65    /// logical stack top for multi-result sequences
66    pub(crate) top: u32,
67    globals: Gc<Table>,
68    /// shared metatable for all strings (populated by the string lib, P04)
69    /// per-basic-type metatables (PUC luaT): indexed by `type_mt_slot`
70    /// (0 nil, 1 boolean, 2 number, 3 string, 4 function); tables carry their
71    /// own. Settable via debug.setmetatable.
72    type_mt: [Option<Gc<Table>>; 5],
73    /// pre-interned metamethod event names, indexed by `Mm`
74    mm_names: Vec<Gc<crate::runtime::LuaStr>>,
75    /// native↔Lua nesting depth (PUC C-stack guard analogue)
76    c_depth: u32,
77    /// number of live pcall/xpcall continuation frames on the running thread
78    /// (PUC counts these against nCcalls). Bounds protected-call recursion the
79    /// way `c_depth` bounds call_value recursion. Per-thread: saved/restored
80    /// with the coroutine context, since continuations survive a yield.
81    pcall_depth: u32,
82    /// number of non-yieldable C calls in flight on the running thread (PUC's
83    /// `L->nny`). A library callback that runs via synchronous Rust recursion
84    /// (sort comparator, gsub replacement) cannot be continued across a yield,
85    /// so it bumps this for its duration; `coroutine.yield` inside hits the
86    /// C-call boundary and errors. Always 0 at a suspend point (a yield can
87    /// never cross such a call), so it needs no per-thread save/restore.
88    nny: u32,
89    /// Nonzero while an xpcall message handler is on the Rust stack. Used so a
90    /// stack-overflow that surfaces *inside* the handler is reported as PUC's
91    /// "error in error handling" (LUA_ERRERR + `luaD_seterrorobj`), not the
92    /// plain "stack overflow" — errors.lua :606's `checkerr("error handling",
93    /// loop)` then matches. PUC tracks this via the soft-cap window
94    /// `nCcalls >= MAXCCALLS/10*11`; luna's c_depth is strict, so we mark the
95    /// scope explicitly.
96    msgh_depth: u32,
97    /// set by a coroutine closing itself (`coroutine.close()` on the running
98    /// thread): the to-be-closed handlers have already run; the thread must now
99    /// terminate. `Some(None)` is a clean close, `Some(Some(e))` a handler
100    /// raised `e`. Checked by `exec_with`/`resume_coro` to propagate (not
101    /// unwind, so a protecting pcall cannot catch it) the termination.
102    terminating: Option<Option<Value>>,
103    /// xoshiro256** state (math.random)
104    rng: [u64; 4],
105    /// VM creation time (os.clock)
106    started: std::time::Instant,
107    version: LuaVersion,
108    /// error object being threaded through a chain of __close handlers; a GC
109    /// root for the duration (a handler may trigger collection)
110    closing_err: Option<Value>,
111    /// the coroutine whose context is currently live in the fields above;
112    /// `None` while the main thread runs (P05)
113    current: Option<Gc<crate::runtime::Coro>>,
114    /// the main thread's saved execution context while a coroutine runs
115    main_ctx: Option<SavedCtx>,
116    /// set by `coroutine.yield` to suspend the running coroutine: the yielded
117    /// values plus the slot/result-count needed to finish the yielding call on
118    /// the next resume. Checked by `exec` to propagate (not unwind) on yield.
119    yielding: Option<(Vec<Value>, u32, i32)>,
120    /// results expected by the in-flight native call (so `yield` knows how many
121    /// values its call site wants when it suspends)
122    native_nresults: i32,
123    /// identity object for the main thread, returned by `coroutine.running`
124    /// (the main thread's context lives in the VM fields / `main_ctx`, not here)
125    main_coro: Option<Gc<Coro>>,
126    /// `collectgarbage` mode name ("incremental"/"generational"). The collector
127    /// itself is still stop-the-world mark-sweep; this tracks the mode so mode
128    /// switches report the previous one, as PUC does.
129    gc_mode: &'static str,
130    /// the live-register boundary of the running thread for GC rooting (PUC's
131    /// `L->top`): set precisely at each GC safe point so freed temporary
132    /// registers above it are not rooted. Without this the collector roots the
133    /// whole stack window, pinning weak-table values stranded in stale temps
134    /// (e.g. closure.lua's `while x[1]` GC-detection loop).
135    pub(crate) gc_top: u32,
136    /// `collectgarbage("param", name [,value])` pacing parameters. The collector
137    /// is still stop-the-world, so these are stored/returned for API fidelity
138    /// (PUC round-trips them via `setparam`/`getparam`). Defaults mirror PUC's
139    /// `LUAI_GC*` knobs: pause=200, stepmul=100, stepsize=13.
140    gc_pause: i64,
141    gc_stepmul: i64,
142    gc_stepsize: i64,
143    /// true while `__gc` finalizers are being run, so a finalizer that calls
144    /// `collectgarbage` gets a no-op (PUC's non-reentrancy: lua_gc returns -1 →
145    /// `collectgarbage` yields fail).
146    gc_finalizing: bool,
147    /// C ABI scratch (`capi` module): the host-visible value stack that C
148    /// callers operate on via `lua_pushinteger` / `lua_tostring` / etc.
149    /// Kept here (instead of in a separate `LuaState` wrapper) so the
150    /// trampoline that bridges to a `LuaCFunction` can safely cast the
151    /// Vm pointer it already holds to the public `*mut LuaState` type
152    /// without any aliasing of `&mut Vm` against `&mut LuaState.vm`.
153    pub capi_stack: Vec<crate::runtime::Value>,
154    /// Pinned CString backing the pointer last returned by `lua_tostring`;
155    /// valid until the next `lua_tostring` on the same Vm.
156    pub capi_cstr_pin: Option<std::ffi::CString>,
157    /// PUC 5.4+ warning system. Lua manual §6.1 `warn`: emitted messages
158    /// concatenate across continuation calls until a non-`tocont` call
159    /// flushes; the default warnf recognises `@on`/`@off` control messages
160    /// and starts disabled. luna's `emit_warn` mirrors the default warnf
161    /// behaviour and 5.4+ `__gc` errors are routed through it (5.1–5.3
162    /// keep the older raise semantics).
163    pub(crate) warn_state: WarnState,
164    pub(crate) warn_buf: Vec<u8>,
165    /// P09 embedding cooperative budget: a per-Vm tick counter that the run
166    /// loop decrements once per dispatch turn. When it hits zero the loop
167    /// raises a catchable "instruction budget exceeded" error so the embedder
168    /// can yield control back to its caller (short-script eval, game
169    /// frame budgets). `None` = unbounded; reset on each call via
170    /// `set_instr_budget`.
171    pub(crate) instr_budget: Option<i64>,
172    // v1.1 A2 — JIT-specific fields moved to `JitState` sidecar; see
173    // `self.jit` below + `crate::vm::jit_state` for field docs.
174    // (Was: jit_enabled here.)
175    // v1.1 A2 — was: trace_jit_enabled (moved to JitState).
176    // v1.1 A2 — was: p16_self_link_enabled (moved to JitState).
177    // v1.1 A2 — was: active_trace, recording_frame_base, trace_max_depth_seen,
178    // trace_closed_count, trace_aborted_count, trace_inline_abort_count,
179    // trace_dispatch_off_reasons, trace_compile_failed_reasons, trace_closed_lens,
180    // trace_compiled_count, trace_compile_failed_count, trace_dispatched_count,
181    // trace_deopt_count, trace_side_trace_{started,compiled,shape_mismatch}_count,
182    // trace_{sinkable,accum_bufferable}_seen_count, trace_{sunk_alloc,
183    // materialize_emit,closure_emit}_count — all moved to JitState.
184    /// Bytecode-loading gate. Default `true`. Sandbox embedders should
185    /// call `set_bytecode_loading(false)` so `load`/`loadstring` reject
186    /// precompiled chunks (which bypass the parser's depth / opcode
187    /// limits). When `false`, the loader rejects any source whose first
188    /// byte is the bytecode signature `\27` ("`\27Lua`").
189    pub(crate) bytecode_loading: bool,
190    /// PUC bytecode-loading gate. Default `false` — PUC `.luac` files are
191    /// a strictly larger trust surface than luna's own dump format
192    /// (third-party toolchain bugs, malformed chunks, unknown opcode
193    /// shapes). When `true`, the loader routes `\x1bLua\x{51..55}` inputs
194    /// through the per-dialect PUC translators in `crate::vm::dump::puc`
195    /// (Phase LB Wave 2 — currently returns "not yet implemented" stubs).
196    /// Embedder toggles via `set_puc_bytecode_loading`.
197    pub(crate) puc_bytecode_loading: bool,
198    /// Byte budget for source fed into `load` / `loadstring` / `Vm::load`.
199    /// Default [`Vm::DEFAULT_LOADER_INPUT_BUDGET`] (256 MiB). When the
200    /// accumulated reader output (`load(f, ...)`) or a one-shot `&[u8]`
201    /// source exceeds this, the loader returns the PUC-shaped
202    /// `not enough memory` error before the host allocator is asked to
203    /// hold the next chunk. Defends against `heavy.lua::loadrep`-style
204    /// 7 GB+ feeder loops that would otherwise SIGSEGV when `Vec::push`
205    /// crosses `isize::MAX` or the host runs out of RAM. Tracked at
206    /// `.dev/known-bugs/fixed/heavy-lua-sigsegv-under-128mb-loadrep.md`.
207    /// Embedders that genuinely need to load > 256 MiB sources widen the
208    /// cap via [`Vm::set_loader_input_budget`].
209    pub(crate) loader_input_budget: usize,
210    /// In-process log of fully-emitted warnings (each entry = one flushed
211    /// message, sans the "Lua warning: " prefix and trailing newline). Lets
212    /// tests assert what was warned without scraping stderr.
213    pub(crate) warn_log: Vec<Vec<u8>>,
214    /// PUC's `LUA_REGISTRYINDEX` table — a single Lua table the debug library
215    /// exposes via `debug.getregistry`. Used to hold `_HOOKKEY` (the weak-key
216    /// table PUC's `db_sethook` keys per-thread hooks under). luna stores hook
217    /// state directly in `Vm.hook`/`Coro.hook`, so the entry is largely a
218    /// shape stub for db.lua :328; if other registry-keyed APIs land later
219    /// they can share this table.
220    pub(crate) registry: Option<Gc<Table>>,
221    /// the shared `FILE*` metatable for io file handles (PUC's LUA_FILEHANDLE
222    /// registry entry); attached to every file userdata the io library makes
223    pub(crate) file_mt: Option<Gc<Table>>,
224    /// io library default input/output streams (PUC registry IO_INPUT/IO_OUTPUT)
225    pub(crate) io_input: Option<Gc<crate::runtime::Userdata>>,
226    pub(crate) io_output: Option<Gc<crate::runtime::Userdata>>,
227    /// the running thread's debug hook state (`debug.sethook`); per-thread,
228    /// swapped with the execution context on a coroutine resume/yield
229    pub(crate) hook: HookState,
230    /// true while the hook itself runs, so its own execution fires no events
231    /// (PUC clears the mask for the duration)
232    pub(crate) in_hook: bool,
233    /// arms the next Lua frame's `tailcalls` count (PUC `ci->u.l.tailcalls`),
234    /// consumed by `push_frame`. `OP_TailCall` sets it to the caller's
235    /// own tailcalls + 1 before begin_call so deeply tail-recursive chains
236    /// accumulate the count instead of capping at 1.
237    pub(crate) pending_tailcalls: u32,
238    /// Name of the C native that just propagated an error (captured before
239    /// the native is popped from `running_natives`). Lets a dying coroutine
240    /// preserve `[C]: in function '<name>'` at the top of its traceback
241    /// snapshot — PUC walks `luaG_funcnamefrompc` over a still-live ci, but
242    /// luna's native frames are off-stack so we stash the name explicitly.
243    pub(crate) errored_native: Option<String>,
244    /// PUC `CallInfo.u2.transferinfo`: index of the first transferred value
245    /// (relative to the activation's func slot) and the number transferred.
246    /// Set just before firing a call/return hook, read by `getinfo("r")`.
247    pub(crate) hook_ftransfer: u16,
248    pub(crate) hook_ntransfer: u16,
249    /// metamethod event tag (e.g. "close") to attach to the next Lua frame
250    /// pushed by `push_frame`; `close_slots` sets this before calling a
251    /// `__close` handler so `debug.traceback` names it "metamethod 'close'"
252    /// (PUC `CallInfo.u.l.tm`). Single-shot: `push_frame` consumes it.
253    pending_tm: Option<&'static str>,
254    /// `true` when the next `push_frame` is the user hook function itself,
255    /// so `debug.getinfo(1).namewhat` resolves to `"hook"` (PUC
256    /// `CIST_HOOKED`). `run_hook` arms it before dispatching the hook.
257    pending_is_hook: bool,
258    /// traceback snapshot taken at the error point (the first `unwind` entry
259    /// for the in-flight error), so that an `xpcall` msgh — which runs *after*
260    /// the failed frames are popped — can still see the error point's stack
261    /// via `debug.traceback`. PUC `luaG_errormsg` instead runs msgh with the
262    /// stack intact; we approximate by snapshotting the string and letting
263    /// `d_traceback` consume it. Cleared on Cont catch and at host-level
264    /// `call_value` entry (`public_call_depth == 0`).
265    pub(crate) error_traceback: Option<Vec<u8>>,
266    /// nesting depth of public `call_value` entries (host vs. internal). The
267    /// outermost entry (depth 0) resets per-error state (`error_traceback`);
268    /// internal calls (e.g. xpcall msgh, sort callback) preserve it.
269    public_call_depth: u32,
270    /// stack of native (`Value::Native`) closures currently running on the
271    /// Rust call stack. `begin_call` pushes the closure before invoking
272    /// `nc.f` and pops on return. Used by `arg_error` to detect a *nested*
273    /// native call (PUC `ar.name == NULL` at level 0 because the level-0
274    /// caller is C, not Lua) and qualify the running function's name via
275    /// `pushglobalfuncname` (e.g. `'sort'` → `'table.sort'`).
276    pub(crate) running_natives: Vec<Gc<NativeClosure>>,
277    /// Parallel to `running_natives`: each entry's `(func_slot, nargs)` is
278    /// the native's argument-window head and width, so `debug.getlocal`
279    /// can index it like PUC's `luaG_findlocal` `(C temporary)` path.
280    pub(crate) running_native_slots: Vec<(u32, u32)>,
281    // v1.1 A2 — was: jit_pending_err, jit_reg_state_buf, jit_str_buf_pool,
282    // jit_str_buf_pool_cap, jit_entry_tags_buf, chunk_compiler,
283    // trace_compiler — all moved to JitState. See `jit` below.
284    /// v1.1 A2 — JIT sidecar. Always present (never `Option`); inert
285    /// when `chunk_compiler` / `trace_compiler` are
286    /// [`crate::jit::NullJitBackend`]. See [`crate::vm::jit_state`].
287    ///
288    /// `#[doc(hidden)] pub` so the `luna` crate's
289    /// `extern "C"` JIT helpers can write `vm.jit.pending_err`
290    /// directly (same pattern as the pre-A2 `pub Vm::jit_pending_err`
291    /// field). Not part of the embedder-facing API surface.
292    #[doc(hidden)]
293    pub jit: crate::vm::jit_state::JitState,
294
295    /// B12 host roots — append-only `Vec<Value>` traced as an extra
296    /// GC root set. `Lua` facade handles (`LuaFunction`, `LuaTable`,
297    /// `LuaRoot`) hold indices into this vector so the underlying
298    /// `Gc<T>` stays alive across `eval` calls / yield boundaries.
299    ///
300    /// v1.1 strategy: append-only with explicit `unpin_all` / new Vm.
301    /// Slot recycling lands in Phase 3 alongside B8 LuaUserdata, when
302    /// the trade-offs between `Drop` plumbing and append-only memory
303    /// growth have a richer ergonomics envelope to live in.
304    pub(crate) host_roots: Vec<crate::vm::host_roots::HostRootSlot>,
305    /// v1.3 Phase SR — recycled-slot index pool. `pin_host` pops the
306    /// back if non-empty, else extends `host_roots`. Generation
307    /// overflow at `u32::MAX` retires the slot (NOT pushed here).
308    pub(crate) host_roots_free: Vec<u32>,
309
310    /// v2.1 — GC-rooted scratch stack for `table.sort` (and any other
311    /// builtin that needs a Rust-side `Vec<Value>` to outlive a user
312    /// callback). Each entry is one in-flight working buffer; `gc_roots`
313    /// extends with every contained `Value` so a `collectgarbage()`
314    /// inside the comparator cannot free strings/tables snapshotted
315    /// here. Nested sorts push a new buffer on entry, pop on exit
316    /// (sort.lua's `load(..)(); collectgarbage()` compare callback
317    /// regression).
318    pub(crate) sort_scratch: Vec<Vec<Value>>,
319
320    /// v1.3 Phase ML — MacroLua compile-time macro registry.
321    /// Pre-populated with built-in macros (`@quote` / `@unquote` /
322    /// `@if` / `@gensym`) at construction time when `version ==
323    /// LuaVersion::MacroLua`; embedders register custom macros via
324    /// [`Vm::define_macro`]. The expander runs once per `load()` call
325    /// between lexing and parsing (only when `is_macro_lua()`).
326    pub(crate) macro_registry: crate::frontend::macro_expander::MacroRegistry,
327
328    /// v1.2 Track B — per-Vm cache of `Gc<Table>` metatables keyed
329    /// by `TypeId::of::<T>()` for embedder types implementing
330    /// [`crate::vm::userdata_trait::LuaUserdata`]. Populated lazily by
331    /// [`Vm::register_userdata`]; metatables are pinned via
332    /// [`Vm::pin_host`] at registration time so the entry's
333    /// `Gc<Table>` stays live for the rest of the Vm's lifetime.
334    pub(crate) userdata_metatables:
335        std::collections::HashMap<std::any::TypeId, Gc<crate::runtime::table::Table>>,
336
337    /// B6 — classification of the most recent error raised on this Vm.
338    /// Embedders read via [`Vm::error_kind`]; the dispatcher sets it
339    /// at well-known sites (syntax errors, instr-budget trips, native
340    /// callback errors, type errors).
341    pub(crate) last_error_kind: crate::vm::error::LuaErrorKind,
342
343    /// B6 — `(source_name, line)` of the most recent error. Set by the
344    /// dispatcher / lexer / parser; cleared when a new call_value
345    /// enters cleanly.
346    pub(crate) last_error_source: Option<(String, u32)>,
347
348    /// v1.1 B10 Stage 1 — when `true`, `instr_budget` exhaustion in
349    /// the dispatcher hot loop yields cooperatively (sets
350    /// [`Vm::host_yield_pending`] + returns a sentinel `Err` walked up
351    /// to `EvalFuture::poll`) instead of returning a real
352    /// "instruction budget exceeded" error. Set by [`Vm::eval_async`]
353    /// for the duration of the future; restored to `false` on
354    /// `Poll::Ready`. The sync `Vm::eval` / `Vm::call_value` paths
355    /// leave it `false` so v1.0 behavior is preserved exactly.
356    pub(crate) async_mode: bool,
357
358    /// v1.1 B10 Stage 1 — host waker cloned by `EvalFuture::poll`
359    /// before driving a slice. The dispatcher itself does not call it
360    /// (the future's poll loop does `wake_by_ref` after observing
361    /// `BudgetExhausted`), but storing the waker keeps the door open
362    /// for Stage 2 async natives to wake the host directly from a
363    /// helper future.
364    pub(crate) async_waker: Option<std::task::Waker>,
365
366    /// v1.1 B10 Stage 1 — per-poll opcode quota loaded into
367    /// `instr_budget` at the start of each `EvalFuture::poll` slice.
368    /// Default 10_000 (RFC §D5). Tunable via
369    /// [`Vm::set_async_slice`].
370    pub(crate) async_slice_size: i64,
371
372    /// v1.1 B10 Stage 1 — set by the dispatcher when an async-mode
373    /// budget exhaustion fires; checked by `exec_with` (so the
374    /// sentinel propagates without `unwind` running, mirroring
375    /// `yielding.is_some()`) and by `call_value_impl` (so the call
376    /// frames survive for the next poll). Cleared by `drive_one`
377    /// after translating it to `DispatchOutcome::BudgetExhausted`.
378    pub(crate) host_yield_pending: bool,
379
380    /// v1.1 B10 Stage 2 — set by the dispatcher's native-call path
381    /// when an async-marked [`NativeClosure`] is invoked under
382    /// `async_mode`. The Vm pauses the dispatcher (same sentinel-Err
383    /// mechanism as `host_yield_pending` — see `exec_with` +
384    /// `call_value_impl`), stashes the in-flight future +
385    /// post-completion context here, and surfaces them to
386    /// `EvalFuture::poll` via `drive_one`. Cleared by `drive_one`
387    /// once the future is moved out into a
388    /// `DispatchOutcome::AsyncNativeAwaiting`.
389    pub(crate) pending_async_native_fut:
390        Option<std::pin::Pin<Box<dyn std::future::Future<Output = Result<u32, LuaError>>>>>,
391
392    /// v1.1 B10 Stage 2 — companion to `pending_async_native_fut`:
393    /// the `(func_slot, nargs, nresults, gc_top)` quad needed to
394    /// commit the future's eventual `Ok(nret)` back into the calling
395    /// frame's expected result slots. Recorded by the dispatcher;
396    /// consumed by [`Vm::commit_async_native_result`] after the
397    /// future resolves.
398    pub(crate) pending_async_native_ctx: Option<AsyncNativeCallCtx>,
399}
400
401/// v1.1 B10 Stage 2 — call-site context an in-flight async native
402/// needs preserved across the cooperative-yield boundary.
403///
404/// The dispatcher records this when it routes a `NativeClosure` with
405/// `is_async == true` through the cooperative path; `EvalFuture::poll`
406/// hands it back to [`Vm::commit_async_native_result`] once the
407/// awaited future resolves so `finish_results` (and the post-call GC
408/// checkpoint) can run as if the native had completed synchronously.
409#[derive(Clone, Copy)]
410pub(crate) struct AsyncNativeCallCtx {
411    pub func_slot: u32,
412    /// Recorded for parity with the sync native-call path's
413    /// `native_nresults`/`gc_top` bookkeeping; reserved for Stage 3+
414    /// hook firing + traceback shaping. Not yet read in Stage 2.
415    #[allow(dead_code)]
416    pub nargs: u32,
417    pub nresults: i32,
418    /// Recorded for Stage 3+ traceback + GC-root-window auditing.
419    /// Stage 2 reads `Vm.gc_top` directly post-resume, so this is
420    /// unread today; carried so an Stage 3 audit can confirm the
421    /// pre-suspend root window matches the post-resume one.
422    #[allow(dead_code)]
423    pub gc_top: u32,
424}
425
426/// Per-thread debug hook state (PUC `lua_State` hook/hookmask/basehookcount/
427/// hookcount). `func` is the Lua hook; the booleans are the PUC mask bits.
428#[derive(Clone, Copy, Default)]
429pub struct HookState {
430    /// the hook function (`None` when no hook is installed)
431    pub func: Option<Value>,
432    /// v1.1 B11 — Rust-side debug hook. Fires alongside the Lua hook
433    /// (Rust first); both can be installed simultaneously, but most
434    /// embedders pick one.
435    pub rust_func: Option<RustDebugHook>,
436    /// LUA_MASKCALL — fire on function entry
437    pub call: bool,
438    /// LUA_MASKRET — fire on function return
439    pub ret: bool,
440    /// LUA_MASKLINE — fire on source-line change
441    pub line: bool,
442    /// LUA_MASKCOUNT — fire every `count_base` instructions
443    pub count: bool,
444    /// instruction count between count events (PUC basehookcount)
445    pub count_base: i64,
446    /// instructions left until the next count event (PUC hookcount)
447    pub count_left: i64,
448}
449
450/// Rust-side debug hook callback (B11). Receives the `Vm` plus a
451/// classified event. The callback runs synchronously in the
452/// dispatcher; the hook flag (`in_hook`) is set for its duration so
453/// hook recursion is suppressed.
454pub type RustDebugHook = fn(&mut Vm, RustHookEvent);
455
456/// Classified debug event delivered to a [`RustDebugHook`].
457#[derive(Clone, Copy, Debug, PartialEq, Eq)]
458pub enum RustHookEvent {
459    /// Function entry (`hook_call` analogue).
460    Call,
461    /// Function return (`hook_return` analogue).
462    Return,
463    /// Tail call entry (PUC 5.2+ separates this from a plain Call).
464    TailCall,
465    /// Source-line change (the `u32` is the 1-based line number).
466    Line(u32),
467    /// Instruction count event (fires every `count_base` instructions).
468    Count,
469}
470
471/// Mask flags for [`Vm::set_rust_debug_hook`]. OR these to subscribe
472/// to multiple event categories with a single hook installation.
473pub const HOOK_MASK_CALL: u32 = 1;
474/// Subscribe to function-return events.
475pub const HOOK_MASK_RETURN: u32 = 2;
476/// Subscribe to line-change events.
477pub const HOOK_MASK_LINE: u32 = 4;
478/// Subscribe to instruction-count events.
479pub const HOOK_MASK_COUNT: u32 = 8;
480
481/// A thread's swapped-out execution context (PUC per-thread stack state).
482struct SavedCtx {
483    stack: Vec<Value>,
484    frames: Vec<CallFrame>,
485    open_upvals: Vec<(u32, Gc<Upvalue>)>,
486    tbc: Vec<u32>,
487    top: u32,
488    pcall_depth: u32,
489    hook: HookState,
490    /// PUC `L->l_gt` — the thread's own globals table. Carried alongside
491    /// the rest of the suspended state so each thread can keep its own
492    /// `setfenv(0, env)` rewire without the swap leaking into another
493    /// thread (5.1 closure.lua :177).
494    globals: Gc<Table>,
495}
496
497/// Outcome of unwinding the call stack on an error (see `Vm::unwind`).
498enum Unwound {
499    /// caught by a pcall/xpcall continuation; resume running its caller
500    Caught,
501    /// caught by a continuation that was the entry-level activation; these are
502    /// the call's (wrapped) results
503    CaughtReturn(Vec<Value>),
504    /// no protecting continuation up to `entry_depth`; propagate the error
505    Propagated(LuaError),
506}
507
508/// A resolved debug stack level: a real Lua frame (by index into `frames`) or a
509/// synthetic C frame for a call_value boundary.
510pub(crate) enum DbgKind {
511    Lua(usize),
512    /// a synthetic C level; the index is the `from_c` Lua frame it sits below,
513    /// used to name the native via its invoking call instruction.
514    C(usize),
515    /// PUC `CIST_TAIL` placeholder — a Lua-to-Lua tail call collapsed the
516    /// caller's activation, so `debug.getinfo(level)` at this slot returns
517    /// `what = "tail"` / `short_src = "(tail call)"` / `linedefined = -1` /
518    /// `func = nil` and `getfenv(level)` errors (5.1 db.lua :336/:341 pin
519    /// both shapes). The index points at the *tail-called* frame whose
520    /// `is_tail` flag induced this synthetic level.
521    Tail(#[allow(dead_code)] usize),
522}
523
524/// Outcome of an index/newindex/comparison fast path: either a directly
525/// computed result, or a metamethod (with the receiver it resolved against) the
526/// caller must invoke — synchronously (C context) or yieldably (VM opcode).
527enum MmOut {
528    /// index → the looked-up value; newindex → done (raw set performed);
529    /// comparison → the boolean result already known
530    Done(Value),
531    /// a metamethod to call; `recv` is the chain element it was found on (the
532    /// extra args — key / value — are supplied by the caller)
533    Mm { func: Value, recv: Value },
534    /// ≤5.3 `a <= b` synthesised via `not __lt(b, a)` when neither operand
535    /// carries `__le` — `op_compare` swaps the args and negates the result.
536    /// Lives separate from `Mm` so the synth path can stay yieldable without
537    /// every other Mm caller learning a swap flag they would never set.
538    CompareSynth { func: Value },
539}
540
541/// Metamethod events; discriminants index `Vm::mm_names`.
542#[derive(Clone, Copy, PartialEq, Eq)]
543#[repr(usize)]
544pub(crate) enum Mm {
545    Index,
546    NewIndex,
547    Call,
548    ToString,
549    Metatable,
550    Name,
551    Eq,
552    Lt,
553    Le,
554    Concat,
555    Len,
556    Add,
557    Sub,
558    Mul,
559    Div,
560    Mod,
561    Pow,
562    IDiv,
563    BAnd,
564    BOr,
565    BXor,
566    Shl,
567    Shr,
568    Unm,
569    BNot,
570    Close,
571    Gc,
572    Pairs,
573}
574
575const MM_NAMES: [&str; 28] = [
576    "__index",
577    "__newindex",
578    "__call",
579    "__tostring",
580    "__metatable",
581    "__name",
582    "__eq",
583    "__lt",
584    "__le",
585    "__concat",
586    "__len",
587    "__add",
588    "__sub",
589    "__mul",
590    "__div",
591    "__mod",
592    "__pow",
593    "__idiv",
594    "__band",
595    "__bor",
596    "__bxor",
597    "__shl",
598    "__shr",
599    "__unm",
600    "__bnot",
601    "__close",
602    "__gc",
603    "__pairs",
604];
605
606/// Debug-name spelling for a metamethod event tag (the bare `"index"` /
607/// `"gc"` / … stored in `Frame.tm`), as `getinfo("n").name` reports it.
608///
609/// PUC 5.2/5.3 keep the leading `"__"` for every event; 5.4+ strips it for
610/// every event *except* `__gc` (`funcnamefromcall` returns the literal
611/// `"__gc"` string for `CIST_FIN`, whereas `funcnamefromcode` does
612/// `getstr(tmname[tm]) + 2` to skip the `__`).
613fn tm_debug_name(version: LuaVersion, tm: &str) -> String {
614    if version <= LuaVersion::Lua53 {
615        format!("__{tm}")
616    } else if tm == "gc" {
617        "__gc".to_string()
618    } else {
619        tm.to_string()
620    }
621}
622
623/// The metamethod event an opcode dispatches, without the `__` prefix (PUC
624/// funcnamefromcode), for "(metamethod 'event')" call-error suffixes.
625fn mm_event_name(op: crate::vm::isa::Op) -> Option<&'static str> {
626    use crate::vm::isa::Op;
627    Some(match op {
628        Op::Add => "add",
629        Op::Sub => "sub",
630        Op::Mul => "mul",
631        Op::Div => "div",
632        Op::Mod => "mod",
633        Op::Pow => "pow",
634        Op::IDiv => "idiv",
635        Op::BAnd => "band",
636        Op::BOr => "bor",
637        Op::BXor => "bxor",
638        Op::Shl => "shl",
639        Op::Shr => "shr",
640        Op::Unm => "unm",
641        Op::BNot => "bnot",
642        Op::Concat => "concat",
643        Op::Len => "len",
644        Op::GetField | Op::GetTable | Op::GetI | Op::SelfOp => "index",
645        Op::SetField | Op::SetTable | Op::SetI => "newindex",
646        Op::Eq | Op::EqK => "eq",
647        Op::Lt => "lt",
648        Op::Le => "le",
649        _ => return None,
650    })
651}
652
653/// PUC MAXTAGLOOP: bound on `__index`/`__newindex` chains.
654const MAX_TAG_LOOP: u32 = 2000;
655/// PUC `MAXCCMT`: bound on a `__call` metamethod chain (lvm.c). 200 chains
656/// is more than any reasonable program needs and matches PUC 5.4/5.5; the
657/// earlier `15` here was tight enough to fire on calls.lua :194 (N=20).
658const MAX_CCMT: u32 = 200;
659/// PUC LUAI_MAXCCALLS analogue: native↔Lua nesting bound.
660const MAX_C_DEPTH: u32 = 200;
661/// luna's engine-level VM stack cap (used by call-site overflow checks).
662/// Slightly larger than PUC's `LUAI_MAXSTACK` so engine internals have a
663/// little headroom above any single library push.
664const MAX_LUA_STACK: u32 = 1 << 20;
665/// PUC `LUAI_MAXSTACK` (`luaconf.h`): the cap library code consults via
666/// `lua_checkstack` to refuse multi-value pushes (`table.unpack` returning
667/// N values, `string.pack` results, etc.). 5.3 coroutine.lua :530 pins
668/// this at one million — `for j in {lim-10, …}` expects every j ≥ lim-10
669/// to fail because the few slots already consumed in the coroutine push
670/// the effective cap below lim-10.
671const PUC_MAXSTACK: i64 = 1_000_000;
672
673/// PUC 5.4+ default warnf state. The base library's `warn` function flips
674/// between `Off` and `On` via the `@on` / `@off` control messages; any other
675/// `@<word>` control is silently ignored, mirroring `lauxlib.c::checkcontrol`.
676#[derive(Clone, Copy, PartialEq, Eq, Debug)]
677pub enum WarnState {
678    /// `warn` calls are silently dropped (default after `warn("@off")`).
679    Off,
680    /// `warn` calls are delivered to stderr (after `warn("@on")`).
681    On,
682}
683
684/// Best-effort extraction of a textual message from a `catch_unwind` payload.
685/// `panic!("msg")` arrives as `String`, `panic!(static)` as `&str`; anything
686/// else degrades to `"<non-string panic>"`. Used by the native-call
687/// catch_unwind to fold the panic into a Lua error.
688fn panic_payload_str(payload: &Box<dyn std::any::Any + Send>) -> String {
689    if let Some(s) = payload.downcast_ref::<String>() {
690        return s.clone();
691    }
692    if let Some(s) = payload.downcast_ref::<&'static str>() {
693        return (*s).to_string();
694    }
695    "<non-string panic>".to_string()
696}
697
698/// Combined error type returned by [`Vm::eval`] and friends — either the
699/// chunk failed to parse / compile, or it raised at runtime.
700#[derive(Debug)]
701pub enum Error {
702    /// Parse or compile failure.
703    Syntax(SyntaxError),
704    /// Runtime error raised during execution.
705    Runtime(LuaError),
706}
707
708impl From<SyntaxError> for Error {
709    fn from(e: SyntaxError) -> Error {
710        Error::Syntax(e)
711    }
712}
713
714impl From<LuaError> for Error {
715    fn from(e: LuaError) -> Error {
716        Error::Runtime(e)
717    }
718}
719
720impl Drop for Vm {
721    fn drop(&mut self) {
722        // state close: run `__gc` for every still-registered finalizable before
723        // the heap frees them (PUC separatetobefnz(g,1) + callallpending). A
724        // single pass — objects created by a closing finalizer are not
725        // re-finalized (they go to the heap's free list directly).
726        self.heap.queue_all_finalizers();
727        self.run_finalizers();
728    }
729}
730
731// P17-D Week 1 scaffold — split-borrow free fn helpers for frames
732// push/pop with shadow counter `frames_top: u32`. Free fns (not Vm
733// methods) so callers can pass `&mut self.frames` + `&mut self.frames_top`
734// as split borrows, allowing other `&mut self.field` reads inside the
735// CallFrame construction (e.g. `std::mem::take(&mut self.pending_tm)`).
736//
737// Week 1 has NO readers yet; the shadow just stays in sync + asserts.
738// Week 2 begins migrating hot-path readers (materialize_frames helper)
739// to consume `frames_top` and a flat array in place of the Vec.
740#[inline(always)]
741fn frames_push_sync(frames: &mut Vec<CallFrame>, frames_top: &mut u32, cf: CallFrame) {
742    frames.push(cf);
743    // Shadow maintenance is debug-only: release builds skip the
744    // increment + assertion entirely. The shadow's purpose in Week 1
745    // is to VERIFY the assumed invariant (frames_top == frames.len())
746    // across all push/pop sites; once Week 2+ migrates readers to
747    // consume the shadow, release will run the increment unconditionally.
748    #[cfg(debug_assertions)]
749    {
750        *frames_top += 1;
751        debug_assert_eq!(
752            *frames_top as usize,
753            frames.len(),
754            "P17-D frames_top out of sync after push",
755        );
756    }
757    #[cfg(not(debug_assertions))]
758    let _ = frames_top;
759}
760
761#[inline(always)]
762fn frames_pop_sync(frames: &mut Vec<CallFrame>, frames_top: &mut u32) -> Option<CallFrame> {
763    let r = frames.pop();
764    #[cfg(debug_assertions)]
765    {
766        if r.is_some() {
767            *frames_top = frames_top.saturating_sub(1);
768        }
769        debug_assert_eq!(
770            *frames_top as usize,
771            frames.len(),
772            "P17-D frames_top out of sync after pop",
773        );
774    }
775    #[cfg(not(debug_assertions))]
776    let _ = frames_top;
777    r
778}
779
780/// v1.3 Phase AOT Stage 7 sub-piece 4 — one-time env-var read for
781/// `LUNA_AOT_PROBE`. Returns `true` iff the env var is set to any
782/// non-empty value. The result is cached in a `OnceLock` so the
783/// dispatcher's hot path pays a single atomic load per process. Off
784/// by default — production deploys don't bleed diagnostic prints.
785fn jit_probe_enabled() -> bool {
786    static PROBE_ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
787    *PROBE_ON.get_or_init(|| {
788        std::env::var("LUNA_AOT_PROBE")
789            .ok()
790            .filter(|v| !v.is_empty())
791            .is_some()
792    })
793}
794
795impl Vm {
796    /// P17-D Week 1 — re-sync `frames_top` after a bulk `frames: Vec`
797    /// swap (take_ctx, put_ctx, load_coro_ctx). Must be called after
798    /// the Vec replacement to keep the shadow valid.
799    #[inline(always)]
800    fn frames_resync(&mut self) {
801        // Debug-only Week 1 — see `frames_push_sync` comment.
802        #[cfg(debug_assertions)]
803        {
804            self.frames_top = self.frames.len() as u32;
805        }
806    }
807
808    // ====================================================================
809    // P17-D v2 Phase 2 — stack-inline frame metadata accessors (unused).
810    //
811    // These methods read/write the LJ_FR2 marker slots at `stack[base-2]`
812    // (closure GCRef) and `stack[base-1]` (FrameMarker as i64). Phase 2
813    // ships them WITHOUT call-site usage; Phase 3 migrates push/pop
814    // sites to consume them. Phase 4 removes Vec<CallFrame>.
815    //
816    // Preconditions (debug-asserted):
817    // - base >= 2 (slots base-2 and base-1 must exist below the frame)
818    // - self.stack.len() > base + max_stack (caller has grown stack)
819    // - For Lua frames, stack[base-2] holds Value::Closure(cl)
820    // - For Lua frames, stack[base-1] holds Value::Int(marker.to_raw())
821    //
822    // No release-build cost when unused (LTO strips dead methods).
823    // ====================================================================
824
825    /// Write a Lua frame's closure pointer into `stack[base-2]`.
826    /// The caller must ensure `base >= 2` and the slot is within the
827    /// stack's allocated range.
828    #[inline]
829    #[allow(dead_code)] // Phase 2 — consumer is Phase 3.
830    fn write_frame_closure(&mut self, base: u32, cl: crate::runtime::Gc<LuaClosure>) {
831        debug_assert!(
832            base >= 2,
833            "frame closure slot needs base >= 2; got {}",
834            base
835        );
836        let idx = (base - 2) as usize;
837        debug_assert!(idx < self.stack.len(), "stack[base-2] out of range");
838        self.stack[idx] = Value::Closure(cl);
839    }
840
841    /// Read a Lua frame's closure pointer from `stack[base-2]`.
842    /// Returns `None` if the slot doesn't hold a closure (caller is
843    /// expected to treat that as a corrupt frame).
844    ///
845    /// P17-D v2 Direction E2 — uses E1's [`Value::tag_byte`] fast-path
846    /// to avoid the enum-match cost on the hot path. Tag check via
847    /// 1-byte load + branch + `as_closure_unchecked` payload load.
848    #[inline]
849    #[allow(dead_code)]
850    fn read_frame_closure(&self, base: u32) -> Option<crate::runtime::Gc<LuaClosure>> {
851        debug_assert!(base >= 2);
852        let v = self.stack.get((base - 2) as usize)?;
853        if v.tag_byte() == crate::runtime::value::tag::CLOSURE {
854            // SAFETY: tag byte just verified == CLOSURE.
855            Some(unsafe { v.as_closure_unchecked() })
856        } else {
857            None
858        }
859    }
860
861    /// Write a packed [`FrameMarker`] into `stack[base-1]`. The marker
862    /// encodes the frame kind (Lua / Cont) + PC-or-delta payload.
863    /// Stored as `Value::Int(marker.to_raw())` so it round-trips
864    /// cleanly through the value stack without losing bits.
865    #[inline]
866    #[allow(dead_code)]
867    fn write_frame_marker(&mut self, base: u32, marker: crate::runtime::frame_marker::FrameMarker) {
868        debug_assert!(base >= 1, "frame marker slot needs base >= 1; got {}", base);
869        let idx = (base - 1) as usize;
870        debug_assert!(idx < self.stack.len(), "stack[base-1] out of range");
871        self.stack[idx] = Value::Int(marker.to_raw());
872    }
873
874    /// Read a packed [`FrameMarker`] from `stack[base-1]`. Returns
875    /// `None` if the slot isn't a `Value::Int` (caller treats as a
876    /// corrupt frame); the kind tag itself may still be invalid, in
877    /// which case [`FrameMarker::kind`] returns `None` on the result.
878    ///
879    /// P17-D v2 Direction E2 — uses E1's [`Value::tag_byte`] fast-path
880    /// for the tag check + `as_int_unchecked` for the payload load.
881    #[inline]
882    #[allow(dead_code)]
883    fn read_frame_marker(&self, base: u32) -> Option<crate::runtime::frame_marker::FrameMarker> {
884        debug_assert!(base >= 1);
885        let v = self.stack.get((base - 1) as usize)?;
886        if v.tag_byte() == crate::runtime::value::tag::INT {
887            // SAFETY: tag byte just verified == INT.
888            Some(crate::runtime::frame_marker::FrameMarker::from_raw(
889                unsafe { v.as_int_unchecked() },
890            ))
891        } else {
892            None
893        }
894    }
895
896    /// Build the raw `Vm` struct without main coroutine / RNG seed / library
897    /// setup. Private helper shared by `Vm::new` and `Vm::new_minimal`; the
898    /// caller is responsible for the rest of the bring-up.
899    fn new_inner(version: LuaVersion) -> Vm {
900        let mut heap = Heap::new();
901        // PUC 5.1 had no ephemeron pass — `__mode='k'` tables marked their
902        // values strongly. gc.lua's "weak tables" section relies on that.
903        heap.no_ephemeron = version <= LuaVersion::Lua51;
904        // PUC 5.3 needs two GC cycles to finalize a table caught in a
905        // coroutine reference cycle (gc.lua :502); 5.4+ rewrote the GC and
906        // finalize in a single cycle (5.4/5.5 gc.lua :544 assert exactly one).
907        heap.defer_thread_cycle_finalize = version == LuaVersion::Lua53;
908        let globals = heap.new_table();
909        let mm_names = MM_NAMES.iter().map(|n| heap.intern(n.as_bytes())).collect();
910
911        Vm {
912            heap,
913            stack: Vec::new(),
914            frames: Vec::new(),
915            frames_top: 0,
916            open_upvals: Vec::new(),
917            tbc: Vec::new(),
918            top: 0,
919            globals,
920            type_mt: [None; 5],
921            mm_names,
922            c_depth: 0,
923            pcall_depth: 0,
924            nny: 0,
925            msgh_depth: 0,
926            terminating: None,
927            rng: [0; 4],
928            started: std::time::Instant::now(),
929            version,
930            closing_err: None,
931            current: None,
932            main_ctx: None,
933            yielding: None,
934            native_nresults: -1,
935            main_coro: None,
936            // PUC 5.4+ boots in GENERATIONAL mode (the first
937            // `collectgarbage("generational")` reports "generational"
938            // as the previous mode — v2.14 dialect fixture 5.4/549;
939            // 5.5 behaves the same, probed against lua5.5). luna's
940            // collector is a single incremental engine either way;
941            // this field is the MODE REPORT the stdlib exposes.
942            gc_mode: if version >= crate::version::LuaVersion::Lua54 {
943                "generational"
944            } else {
945                "incremental"
946            },
947            gc_top: 0,
948            gc_pause: 200,
949            gc_stepmul: 100,
950            gc_stepsize: 13,
951            gc_finalizing: false,
952            capi_stack: Vec::new(),
953            capi_cstr_pin: None,
954            warn_state: WarnState::Off,
955            warn_buf: Vec::new(),
956            warn_log: Vec::new(),
957            instr_budget: None,
958            bytecode_loading: true,
959            puc_bytecode_loading: false,
960            loader_input_budget: Vm::DEFAULT_LOADER_INPUT_BUDGET,
961            registry: None,
962            file_mt: None,
963            io_input: None,
964            io_output: None,
965            hook: HookState::default(),
966            in_hook: false,
967            pending_tailcalls: 0,
968            errored_native: None,
969            hook_ftransfer: 0,
970            hook_ntransfer: 0,
971            pending_tm: None,
972            pending_is_hook: false,
973            error_traceback: None,
974            public_call_depth: 0,
975            running_natives: Vec::new(),
976            running_native_slots: Vec::new(),
977            // v1.1 A2 — JIT-specific state factored into `JitState`
978            // sidecar. The `luna` crate's `Vm::new_minimal_with_jit` /
979            // `install_jit_backend` / `luaL_newstate` swap in
980            // `CraneliftBackend` for callers that want JIT acceleration.
981            jit: crate::vm::jit_state::JitState::with_null_backend(),
982            // v1.1 B12 — host roots ticket pool for the `Lua` facade.
983            host_roots: Vec::new(),
984            // v1.3 Phase ML — MacroLua registry. Pre-populated with
985            // built-ins (`@quote` / `@unquote` / `@if` / `@gensym`)
986            // when this Vm is constructed under `LuaVersion::MacroLua`.
987            macro_registry: if version == LuaVersion::MacroLua {
988                crate::frontend::macro_expander::MacroRegistry::with_builtins()
989            } else {
990                crate::frontend::macro_expander::MacroRegistry::new()
991            },
992            host_roots_free: Vec::new(),
993            sort_scratch: Vec::new(),
994            // v1.2 Track B — LuaUserdata trait sugar's per-Vm
995            // metatable cache. Populated lazily by register_userdata.
996            userdata_metatables: std::collections::HashMap::new(),
997            // v1.1 B6 — error classification metadata. Defaults to
998            // Runtime; set at known sites (syntax / budget trip /
999            // native error / type error).
1000            last_error_kind: crate::vm::error::LuaErrorKind::default(),
1001            last_error_source: None,
1002            // v1.1 B10 Stage 1 — async embedder fields. Defaults
1003            // preserve sync behavior bit-for-bit (`async_mode = false`
1004            // means the budget hot loop errors out exactly as v1.0).
1005            async_mode: false,
1006            async_waker: None,
1007            async_slice_size: 10_000,
1008            host_yield_pending: false,
1009            // v1.1 B10 Stage 2 — pending async-native state. Empty by
1010            // default; populated only by the dispatcher when an
1011            // async-marked NativeClosure is invoked under async_mode.
1012            pending_async_native_fut: None,
1013            pending_async_native_ctx: None,
1014        }
1015    }
1016
1017    /// Build a fully-loaded Vm — the default for embedders that want PUC's
1018    /// standard library surface. Equivalent to `Vm::new_minimal(version)`
1019    /// followed by `vm.open_all_libs()`.
1020    pub fn new(version: LuaVersion) -> Vm {
1021        let mut vm = Vm::new_minimal(version);
1022        vm.open_all_libs();
1023        vm
1024    }
1025
1026    /// P09 embedding: build a Vm with no standard libraries loaded. Embedders
1027    /// that want a sandbox (Redis-style scripts, in-game scripting with
1028    /// a curated API) call this and then `open_base` / `open_math` / etc.
1029    /// selectively. The Vm is otherwise fully initialized (main coroutine,
1030    /// RNG seed, GC) so `eval` and `call_value` are immediately usable.
1031    pub fn new_minimal(version: LuaVersion) -> Vm {
1032        let mut vm = Vm::new_inner(version);
1033        let mc = vm.heap.new_coro(Value::Nil, vm.globals);
1034        // 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).
1035        unsafe { mc.as_mut() }.status = CoroStatus::Running;
1036        vm.main_coro = Some(mc);
1037        let (a, b) = vm.rng_auto_seed();
1038        vm.rng_seed(a as u64, b as u64);
1039        vm
1040    }
1041
1042    /// v1.1 A1 Session C — install a caller-supplied JIT backend. The
1043    /// `luna` crate uses this to swap in its `CraneliftBackend`; tests
1044    /// or third-party backends pass their own [`crate::jit::IntChunkCompiler`] /
1045    /// [`crate::jit::TraceCompiler`] implementations. Re-installing on a Vm whose
1046    /// closures already populated `Proto.jit: JitProtoState::Compiled`
1047    /// does NOT evict those cached entries — call right after
1048    /// construction for a clean swap.
1049    ///
1050    /// Naming: `install_jit_backend` (not `install_default_jit`)
1051    /// because the "default" in luna-core is `NullJitBackend`; the
1052    /// "default JIT" lives in the `luna` crate.
1053    pub fn install_jit_backend<C, T>(&mut self, chunk: C, trace: T)
1054    where
1055        C: crate::jit::IntChunkCompiler + 'static,
1056        T: crate::jit::TraceCompiler + 'static,
1057    {
1058        self.jit.chunk_compiler = Box::new(chunk);
1059        self.jit.trace_compiler = Box::new(trace);
1060    }
1061
1062    /// v2.0 Track J sub-step J-B — install a caller-supplied JIT
1063    /// storage holder. Default is [`crate::jit::NullJitStorage`];
1064    /// the `luna_jit` crate's `install_default_jit` pairs this with
1065    /// `install_jit_backend(CraneliftBackend, CraneliftBackend)` to
1066    /// also install a fresh `CraneliftJitStorage`. Storage holds
1067    /// the per-`Vm` JIT cache + handle collections that used to be
1068    /// `thread_local!`s in `luna_jit::jit_backend`.
1069    ///
1070    /// Idempotency: re-installing storage on a Vm that already
1071    /// holds compiled-trace pointers WILL evict their owners (the
1072    /// old `CraneliftJitStorage`'s `JITModule`s drop their mmap
1073    /// pages). Call right after construction for a clean swap.
1074    pub fn install_jit_storage<S>(&mut self, storage: S)
1075    where
1076        S: crate::jit::JitStorage + 'static,
1077    {
1078        self.jit.storage = Box::new(storage);
1079    }
1080
1081    /// v1.1 A1 Session A — install the no-op JIT backend. `try_compile`
1082    /// reports "skipped" so every closure stays on the interpreter
1083    /// path, and the trace recorder's compile attempt always returns
1084    /// `None`. Intended for tests that want to verify the trait
1085    /// boundary works in a JIT-free configuration, and for the future
1086    /// `luna-core` build path that ships without Cranelift.
1087    ///
1088    /// Calling this on a Vm whose closures already populated
1089    /// `Proto.jit: JitProtoState::Compiled` does NOT evict those
1090    /// cached entries — the dispatcher will still call into them. For
1091    /// a truly JIT-free run, call this immediately after construction.
1092    pub fn install_null_jit(&mut self) {
1093        self.jit.chunk_compiler = Box::new(crate::jit::NullJitBackend);
1094        self.jit.trace_compiler = Box::new(crate::jit::NullJitBackend);
1095    }
1096
1097    /// Open the entire 5.5 standard library on a `new_minimal`-built Vm.
1098    /// `Vm::new` calls this; sandboxed embedders open libraries one at a
1099    /// time instead (`open_base`, `open_math`, `open_table`, …).
1100    pub fn open_all_libs(&mut self) {
1101        self.open_base();
1102        self.open_math();
1103        self.open_table();
1104        self.open_string();
1105        self.open_utf8();
1106        self.open_os_io();
1107        self.open_debug();
1108        self.open_coroutine();
1109        self.open_package();
1110        // PUC 5.2 introduced `bit32`; 5.3 retired it in the manual BUT
1111        // the stock 5.3 build ships -DLUA_COMPAT_5_2, which keeps the
1112        // library loaded. The diff ground truth is the default build
1113        // (v2.14 dialect fixture 5.3/535), so expose it under 5.2 AND
1114        // 5.3; 5.4 dropped the compat default for real.
1115        if matches!(self.version, LuaVersion::Lua52 | LuaVersion::Lua53) {
1116            self.open_bit32();
1117        }
1118    }
1119
1120    /// Install the base library (`print`, `type`, `pairs`, `tostring`,
1121    /// `pcall`, `error`, `assert`, `select`, `setmetatable`, `getmetatable`,
1122    /// `rawequal`, `rawget`, `rawset`, `rawlen`, `next`, `tonumber`,
1123    /// `collectgarbage`, `warn` on 5.4+, `_VERSION`, `_G`, plus 5.1's
1124    /// retired globals `unpack`, `loadstring`, `setfenv`, `getfenv`,
1125    /// `newproxy`, `gcinfo` when version == 5.1). Safe to call at most
1126    /// once per Vm.
1127    pub fn open_base(&mut self) {
1128        crate::vm::builtins::open_base(self);
1129    }
1130    /// Install the `math` standard library.
1131    pub fn open_math(&mut self) {
1132        crate::vm::lib_math::open_math(self);
1133    }
1134    /// Install the `table` standard library.
1135    pub fn open_table(&mut self) {
1136        crate::vm::lib_table::open_table(self);
1137    }
1138    /// Install the `string` standard library (and the shared string metatable).
1139    pub fn open_string(&mut self) {
1140        crate::vm::lib_string::open_string(self);
1141    }
1142    /// Install the `utf8` standard library (5.3+).
1143    pub fn open_utf8(&mut self) {
1144        crate::vm::lib_utf8::open_utf8(self);
1145    }
1146    /// `os` and `io` are merged because file userdata shares state with both
1147    /// (`io.tmpname` and `os.tmpname` are the same function, `io.popen`
1148    /// wraps `os.execute`'s shell).
1149    pub fn open_os_io(&mut self) {
1150        crate::vm::lib_os_io::open_os_io(self);
1151    }
1152    /// Install the `debug` standard library (introspection / hooks). Off by
1153    /// default for sandbox embedders.
1154    pub fn open_debug(&mut self) {
1155        crate::vm::lib_debug::open_debug(self);
1156    }
1157    /// Install the `coroutine` standard library.
1158    pub fn open_coroutine(&mut self) {
1159        crate::vm::lib_coroutine::open_coroutine(self);
1160    }
1161    /// `package` plus the 5.1-only `module` and `package.seeall` aliases.
1162    pub fn open_package(&mut self) {
1163        crate::vm::lib_os_io::open_package(self);
1164    }
1165    /// 5.2-only `bit32` library (5.3+ retired in favour of native bitwise
1166    /// ops on 64-bit integers).
1167    pub fn open_bit32(&mut self) {
1168        crate::vm::lib_bit32::open_bit32(self);
1169    }
1170
1171    /// xoshiro256** next.
1172    pub(crate) fn rng_next(&mut self) -> u64 {
1173        let s = &mut self.rng;
1174        let result = s[1].wrapping_mul(5).rotate_left(7).wrapping_mul(9);
1175        let t = s[1] << 17;
1176        s[2] ^= s[0];
1177        s[3] ^= s[1];
1178        s[1] ^= s[2];
1179        s[0] ^= s[3];
1180        s[2] ^= t;
1181        s[3] = s[3].rotate_left(45);
1182        result
1183    }
1184
1185    /// Seed the RNG via splitmix64 expansion (PUC randseed shape).
1186    pub(crate) fn rng_seed(&mut self, a: u64, b: u64) {
1187        // PUC setseed: state = [n1, 0xff, n2, 0] (0xff avoids an all-zero
1188        // state), then 16 discards to spread the seed. Matches PUC's exact
1189        // sequence so the low-level conformance test passes.
1190        self.rng = [a, 0xff, b, 0];
1191        for _ in 0..16 {
1192            self.rng_next();
1193        }
1194    }
1195
1196    /// Wall-clock since VM creation (os.clock approximation).
1197    pub(crate) fn uptime(&self) -> std::time::Duration {
1198        self.started.elapsed()
1199    }
1200
1201    /// Entropy for math.randomseed() with no arguments.
1202    pub(crate) fn rng_auto_seed(&mut self) -> (i64, i64) {
1203        let t = std::time::SystemTime::now()
1204            .duration_since(std::time::UNIX_EPOCH)
1205            .map(|d| d.as_nanos() as u64)
1206            .unwrap_or(0);
1207        let addr = &self.rng as *const _ as u64;
1208        (t as i64, addr as i64)
1209    }
1210
1211    /// Allocate a native function object (no upvalues): builtin registration.
1212    pub fn native(&mut self, f: crate::runtime::value::NativeFn) -> Value {
1213        Value::Native(self.heap.new_native(f, Box::new([])))
1214    }
1215
1216    /// Allocate a native function object with captured upvalues.
1217    pub fn native_with(
1218        &mut self,
1219        f: crate::runtime::value::NativeFn,
1220        upvals: Box<[Value]>,
1221    ) -> Value {
1222        Value::Native(self.heap.new_native(f, upvals))
1223    }
1224
1225    /// Install the shared string metatable (string library, P04).
1226    pub fn set_string_metatable(&mut self, mt: Option<Gc<Table>>) {
1227        self.type_mt[3] = mt;
1228    }
1229
1230    /// The current globals table (`_G` / `_ENV` source for new chunks).
1231    pub fn globals(&self) -> Gc<Table> {
1232        self.globals
1233    }
1234
1235    /// Remaining VM stack slots (PUC `L->stack_last - L->top` analogue).
1236    /// Library code that pushes a known number of fresh slots — e.g.
1237    /// `table.unpack` returning N values — consults this to refuse when
1238    /// the push would blow past `LUAI_MAXSTACK`. 5.3 coroutine.lua :530's
1239    /// `for j in {lim-10, lim-5, …}` series pins this contract: the
1240    /// coroutine's already-built table eats a few slots, so an unpack of
1241    /// ~lim values can't fit.
1242    pub(crate) fn stack_room(&self) -> i64 {
1243        PUC_MAXSTACK - (self.stack.len() as i64)
1244    }
1245
1246    /// Repoint the thread's "global table" used by *future* `Vm::load` calls
1247    /// for the chunk's `_ENV` upvalue (PUC 5.1 `setfenv(0, env)` rewrites
1248    /// `L->l_gt`). Already-loaded chunks keep their own snapshot via the
1249    /// per-closure cell-0 clone in `Op::Closure`, so they are unaffected.
1250    pub(crate) fn set_globals(&mut self, env: Gc<Table>) {
1251        self.globals = env;
1252    }
1253
1254    /// The Lua dialect this VM was constructed for (5.1 / 5.2 / 5.3 / 5.4 /
1255    /// 5.5). Determines numeric semantics, available standard libraries, and
1256    /// metamethod behavior.
1257    pub fn version(&self) -> LuaVersion {
1258        self.version
1259    }
1260
1261    /// Set a global by name. `v` may be any `IntoValue`: a primitive
1262    /// (`i64`, `f64`, `bool`, `&str`, `String`, `Vec<u8>`), a `Value`
1263    /// directly, an `Option<T>`, or a `Gc<Table>` / `Gc<LuaClosure>` /
1264    /// `Gc<NativeClosure>` handle.
1265    ///
1266    /// Returns `Err(LuaError)` only if the globals table overflows
1267    /// (extremely unlikely in practice — `MAX_ASIZE = 1 << 27`).
1268    /// String interning + key construction cannot fail.
1269    ///
1270    /// ```
1271    /// # use luna_core::vm::Vm;
1272    /// # use luna_core::version::LuaVersion;
1273    /// let mut vm = Vm::sandbox(LuaVersion::Lua55).open_base().build();
1274    /// vm.set_global("answer", 42).unwrap();
1275    /// vm.set_global("ratio", 0.5_f64).unwrap();
1276    /// vm.set_global("hello", "world").unwrap();
1277    /// let r = vm.eval("return answer, ratio, hello").unwrap();
1278    /// assert_eq!(r.len(), 3);
1279    /// ```
1280    pub fn set_global<V: crate::vm::IntoValue>(
1281        &mut self,
1282        name: &str,
1283        v: V,
1284    ) -> Result<(), LuaError> {
1285        let v = v.into_value(self);
1286        let k = Value::Str(self.heap.intern(name.as_bytes()));
1287        // 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).
1288        unsafe { self.globals.as_mut() }.set(&mut self.heap, k, v)?;
1289        self.heap
1290            .barrier_back(self.globals.as_ptr() as *mut crate::runtime::heap::GcHeader);
1291        Ok(())
1292    }
1293
1294    /// Backward write barrier shorthand for native lib code: demote `t` from
1295    /// BLACK back to gray so the next propagate step re-traces its fields.
1296    /// No-op outside Propagate (parent is never BLACK at mutation time).
1297    pub(crate) fn barrier_back_table(&mut self, t: Gc<Table>) {
1298        self.heap
1299            .barrier_back(t.as_ptr() as *mut crate::runtime::heap::GcHeader);
1300    }
1301
1302    /// Forward write barrier shorthand: a closed upvalue is a single-slot
1303    /// container — `barrier_forward` is cheaper than `barrier_back` here.
1304    /// No-op outside Propagate.
1305    pub(crate) fn barrier_forward_upvalue(&mut self, uv: Gc<Upvalue>, child: Value) {
1306        self.heap
1307            .barrier_forward(uv.as_ptr() as *mut crate::runtime::heap::GcHeader, child);
1308    }
1309
1310    /// v1.3 Phase ML — register a MacroLua macro under `name`. Inert
1311    /// under non-MacroLua dialects (the macro is stored but the load
1312    /// path only consults the registry when
1313    /// `self.version == LuaVersion::MacroLua`).
1314    ///
1315    /// `name` is stored without the leading `@` — source code writes
1316    /// `@double(x)` to invoke a macro registered as `"double"`.
1317    pub fn define_macro(&mut self, name: &str, m: Box<dyn crate::frontend::macro_expander::Macro>) {
1318        self.macro_registry.register(name, m);
1319    }
1320
1321    /// v1.3 Phase ML — drop all MacroLua macros (built-in + custom).
1322    /// Mostly useful for tests / dogfood resets.
1323    pub fn clear_macros(&mut self) {
1324        self.macro_registry.clear();
1325    }
1326
1327    /// Parse + compile a chunk and close it over the globals table.
1328    pub fn load(&mut self, src: &[u8], chunkname: &[u8]) -> Result<Gc<LuaClosure>, SyntaxError> {
1329        // Reject oversize input *before* handing the parser/lexer a
1330        // potentially multi-GB slice. The PUC-shaped `not enough memory`
1331        // message keeps `heavy.lua::loadrep` compatibility: that test
1332        // accepts either `string length overflow` or `not enough memory`
1333        // as the failure mode for a feeder loop that outruns the host
1334        // allocator. See `set_loader_input_budget`.
1335        if src.len() > self.loader_input_budget {
1336            return Err(SyntaxError {
1337                line: 0,
1338                msg: b"not enough memory".to_vec(),
1339            });
1340        }
1341        // a precompiled (binary) chunk is undumped; source is parsed + compiled
1342        let is_bytecode = crate::vm::dump::is_binary_chunk(src);
1343        if is_bytecode && !self.bytecode_loading {
1344            return Err(SyntaxError {
1345                line: 0,
1346                msg: b"attempt to load a binary chunk (bytecode loading disabled)".to_vec(),
1347            });
1348        }
1349        let proto = if is_bytecode {
1350            let allow_puc = self.puc_bytecode_loading;
1351            crate::vm::dump::undump(src, &mut self.heap, self.version, allow_puc).map_err(
1352                |msg| SyntaxError {
1353                    line: 0,
1354                    msg: msg.into_bytes(),
1355                },
1356            )?
1357        } else if self.version.is_macro_lua() {
1358            // v1.3 Phase ML — MacroLua dialect: drain the lexer into a
1359            // token vec, run the macro expander pre-pass against the
1360            // per-Vm registry, then hand the rewritten stream to
1361            // `parse_tokens`. The AST + compiler are dialect-agnostic
1362            // because by this point all `@`/quote tokens are gone.
1363            let mut lexer = crate::frontend::lexer::Lexer::new(src, self.version);
1364            let mut raw: Vec<crate::frontend::token::TokenInfo> = Vec::new();
1365            loop {
1366                let t = lexer.next_token()?;
1367                let eof = matches!(t.tok, crate::frontend::token::Token::Eof);
1368                raw.push(t);
1369                if eof {
1370                    break;
1371                }
1372            }
1373            // Drop the trailing Eof — expander operates on the body and
1374            // `parse_tokens` reinserts Eof when it runs out of tokens.
1375            raw.pop();
1376            let expanded = self.macro_registry.expand(raw)?;
1377            let ast = crate::frontend::parse_tokens(expanded, src, self.version)?;
1378            compile_chunk(&ast, self.version, chunkname, &mut self.heap)?
1379        } else {
1380            let ast = parse(src, self.version)?;
1381            compile_chunk(&ast, self.version, chunkname, &mut self.heap)?
1382        };
1383        // PUC `lua_load` (lapi.c) only seeds the loaded closure's first
1384        // upvalue with the globals table when the closure has *exactly* one
1385        // upvalue — that's the main-chunk `_ENV` case. A dumped non-main
1386        // function with two-or-more upvalues keeps every cell at nil; the
1387        // host must use `debug.setupvalue` to wire them up. 5.2 calls.lua
1388        // :293's `assert(x() == nil)` pins this contract.
1389        let n = proto.upvals.len();
1390        let mut ups: Vec<Gc<Upvalue>> = Vec::with_capacity(n.max(1));
1391        if n == 0 {
1392            // synthetic main chunk has no declared upvalues, but the engine
1393            // still expects at least one cell so the host can probe via
1394            // `debug.upvalueid` etc. Match the historical luna shape.
1395            ups.push(
1396                self.heap
1397                    .new_upvalue(UpvalState::Closed(Value::Table(self.globals))),
1398            );
1399        } else if n == 1 {
1400            ups.push(
1401                self.heap
1402                    .new_upvalue(UpvalState::Closed(Value::Table(self.globals))),
1403            );
1404        } else {
1405            for _ in 0..n {
1406                ups.push(self.heap.new_upvalue(UpvalState::Closed(Value::Nil)));
1407            }
1408        }
1409        Ok(self.heap.new_closure(proto, ups.into_boxed_slice()))
1410    }
1411
1412    /// Compile and run `src` as an anonymous chunk; return its results.
1413    /// Source name in the traceback is `"=eval"`. Syntax errors are
1414    /// surfaced as `LuaError` carrying the formatted PUC-style message
1415    /// (interned through the heap so the error value composes with
1416    /// `pcall` / `error_text` like any runtime error).
1417    pub fn eval(&mut self, src: &str) -> Result<Vec<Value>, LuaError> {
1418        self.eval_chunk(src, "=eval")
1419    }
1420
1421    /// Render an error value for messages/tests. Non-string errors —
1422    /// `error({code=…})`, `error(42)`, etc. — collapse to a type tag
1423    /// (`"(error object is a table value)"`); embedders that need
1424    /// structured payloads should inspect `e.0` directly. Errors whose
1425    /// text starts with `"native panic:"` indicate a Rust panic
1426    /// crossed `catch_unwind` — the Vm may be inconsistent and should
1427    /// be dropped (do not reuse).
1428    pub fn error_text(&self, e: &LuaError) -> String {
1429        match e.0 {
1430            Value::Str(s) => String::from_utf8_lossy(s.as_bytes()).into_owned(),
1431            v => format!("(error object is a {} value)", v.type_name()),
1432        }
1433    }
1434
1435    /// Render an error value the way PUC's standalone `msghandler`
1436    /// does (lua.c): strings pass through, numbers stringify, and any
1437    /// other object is given a chance at its `__tostring` metamethod
1438    /// (the result must be a string) before collapsing to the
1439    /// `"(error object is a … value)"` tag. Needs `&mut self` because
1440    /// `__tostring` runs arbitrary Lua — `error_text` remains the
1441    /// non-executing variant (v2.14 CV.2, fixture 5.5/321).
1442    pub fn error_display(&mut self, e: &LuaError) -> String {
1443        match e.0 {
1444            Value::Str(s) => String::from_utf8_lossy(s.as_bytes()).into_owned(),
1445            v @ (Value::Int(_) | Value::Float(_)) => {
1446                String::from_utf8_lossy(&self.tostring_basic(v)).into_owned()
1447            }
1448            v => {
1449                let mm = self.get_mm(v, Mm::ToString);
1450                if !mm.is_nil()
1451                    && let Ok(r) = self.call_value(mm, &[v])
1452                    && let Some(Value::Str(s)) = r.first()
1453                {
1454                    return String::from_utf8_lossy(s.as_bytes()).into_owned();
1455                }
1456                format!("(error object is a {} value)", v.type_name())
1457            }
1458        }
1459    }
1460
1461    /// Call any callable value from the host (or from natives like pcall).
1462    pub fn call_value(&mut self, f: Value, args: &[Value]) -> Result<Vec<Value>, LuaError> {
1463        // host-level entry (no enclosing exec): drop any error state from a
1464        // prior call that propagated uncaught (`error_traceback` would
1465        // otherwise leak into the next debug.traceback call).
1466        if self.public_call_depth == 0 {
1467            self.error_traceback = None;
1468        }
1469        self.public_call_depth += 1;
1470        // P11-S2 — JIT fast path. A host call with no args targeting a Lua
1471        // chunk whose body fits the S1 int-arith whitelist short-circuits
1472        // the whole interpreter dispatch and runs straight through the
1473        // mmap'd native code. The lookup is one Cell::get + one match —
1474        // the slow path (compile attempt on first reach) is paid once per
1475        // Proto.
1476        if args.is_empty()
1477            && let Value::Closure(cl) = f
1478            && let Some(vs) = self.try_jit_call(cl)
1479        {
1480            self.public_call_depth -= 1;
1481            return Ok(vs);
1482        }
1483        let r = self.call_value_impl(f, args, true);
1484        self.public_call_depth -= 1;
1485        r
1486    }
1487
1488    /// P11-S2 — peek/populate the Proto's JIT cache slot, returning
1489    /// `Some(values)` when the cached native fn is callable for a
1490    /// zero-arg call. (Non-zero-arg dispatch is handled by
1491    /// `try_jit_call_op` from inside `begin_call`.)
1492    fn try_jit_call(&mut self, cl: Gc<LuaClosure>) -> Option<Vec<Value>> {
1493        use crate::runtime::function::JitProtoState;
1494        if !self.jit.enabled {
1495            return None;
1496        }
1497        let proto = cl.proto;
1498        if let JitProtoState::Untried = proto.jit.get() {
1499            self.populate_jit_cache(proto);
1500        }
1501        match proto.jit.get() {
1502            JitProtoState::Compiled {
1503                entry,
1504                num_args: 0,
1505                returns_one,
1506                arg_float_mask: _,
1507                arg_table_mask: _,
1508                ret_is_float,
1509                ret_is_table,
1510            } => {
1511                // 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.
1512                let f: crate::jit::IntChunkFn = unsafe { std::mem::transmute(entry) };
1513                // P11-S5c / S5d.J — install the active Vm + closure
1514                // for any Rust helper the JIT'd code may call (e.g.
1515                // `luna_jit_new_table`, `luna_jit_upval_get`) via
1516                // cranelift `Linkage::Import`. RAII clear on return.
1517                // Chunks with no upvalue reads don't touch the closure
1518                // slot, paying nothing.
1519                // v1.1 A1 Session A — route through chunk_compiler so
1520                // the NullJitBackend path stays inert. Raw-ptr arg
1521                // avoids the &mut self borrow conflict against the
1522                // shared self.jit.chunk_compiler read.
1523                let vm_ptr: *mut Vm = self;
1524                let _jit_vm_guard = self.jit.chunk_compiler.enter(vm_ptr, Some(cl));
1525                // 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).
1526                let r = unsafe { f() };
1527                drop(_jit_vm_guard);
1528                // P11-S5d.E' — a JIT helper may have detected a metatable
1529                // on a table operand and parked a deopt request here.
1530                // Discard the sentinel value and return None so the caller
1531                // re-runs the call through the interpreter, which honours
1532                // __index/__newindex.
1533                if self.jit.pending_err.take().is_some() {
1534                    return None;
1535                }
1536                Some(if returns_one {
1537                    let v = if ret_is_float {
1538                        Value::Float(f64::from_bits(r as u64))
1539                    } else if ret_is_table {
1540                        Value::Table(crate::runtime::Gc::from_ptr(
1541                            r as *mut crate::runtime::Table,
1542                        ))
1543                    } else {
1544                        Value::Int(r)
1545                    };
1546                    vec![v]
1547                } else {
1548                    Vec::new()
1549                })
1550            }
1551            // Non-zero-arg Compiled state: call_value's empty-args
1552            // fast path can't drive it. Op::Call handles those.
1553            JitProtoState::Compiled { .. } | JitProtoState::Failed | JitProtoState::Untried => None,
1554        }
1555    }
1556
1557    /// P11-S2 / S2c — populate the cache slot. Flips `Untried` to either
1558    /// `Compiled { … }` or `Failed`; idempotent on already-populated
1559    /// states (call sites guard with a get before invoking).
1560    ///
1561    /// S4: consults a thread-local cross-`Vm` cache keyed by a hash of
1562    /// `proto.code`. Compiled artefacts live in the thread-local
1563    /// `JITModule` so their mmap pages outlive the `Vm`; subsequent
1564    /// `Vm`s loading the same source skip the cranelift compile step
1565    /// entirely.
1566    fn populate_jit_cache(&mut self, proto: Gc<crate::runtime::function::Proto>) {
1567        use crate::runtime::function::JitProtoState;
1568        let version = self.version();
1569        let pre53 = version <= crate::version::LuaVersion::Lua53;
1570        // P11-S5d.J — 5.1 and 5.2 have no Int subtype (all numbers
1571        // are Float). The JIT's `GetUpval` ValueRead path uses this
1572        // to default-pin upvalue reads to Float without a tag check.
1573        let float_only = version <= crate::version::LuaVersion::Lua52;
1574        // v2.0 Track J sub-step J-B — split-borrow JitState so the
1575        // trait method can take `&mut dyn JitStorage` without
1576        // double-borrowing self.jit.
1577        let jit = &mut self.jit;
1578        let storage: &mut dyn crate::jit::JitStorage = jit.storage.as_mut();
1579        match jit
1580            .chunk_compiler
1581            .try_compile(storage, proto, pre53, float_only)
1582        {
1583            crate::jit::CompileResult::Compiled {
1584                entry,
1585                num_args,
1586                returns_one,
1587                arg_float_mask,
1588                arg_table_mask,
1589                ret_is_float,
1590                ret_is_table,
1591            } => {
1592                proto.jit.set(JitProtoState::Compiled {
1593                    entry,
1594                    num_args,
1595                    returns_one,
1596                    arg_float_mask,
1597                    arg_table_mask,
1598                    ret_is_float,
1599                    ret_is_table,
1600                });
1601            }
1602            crate::jit::CompileResult::Skipped => {
1603                proto.jit.set(JitProtoState::Failed);
1604            }
1605        }
1606    }
1607
1608    /// P11-S2c.B — `Op::Call` JIT fast path. Run inside `begin_call`
1609    /// before `push_frame`. Returns `true` when the call was handled
1610    /// in-place (no new Lua frame). Constraints: every arg slot must
1611    /// be `Value::Int`, the cached arity must match the call site's
1612    /// `nargs`, the host wanted-count `wanted` is honoured by
1613    /// `finish_results`. Also bails when a debug hook is armed —
1614    /// JIT'd code does not fire line / call / return hooks, so any
1615    /// active hook makes the interpreter the source of truth.
1616    fn try_jit_call_op(
1617        &mut self,
1618        cl: Gc<LuaClosure>,
1619        func_slot: u32,
1620        nargs: u32,
1621        wanted: i32,
1622    ) -> bool {
1623        use crate::runtime::function::JitProtoState;
1624        if !self.jit.enabled {
1625            return false;
1626        }
1627        // Any active debug hook means the interpreter has to run the
1628        // call so the hook gets the expected events.
1629        if self.hook.func.is_some() || self.hook.rust_func.is_some() {
1630            return false;
1631        }
1632        let proto = cl.proto;
1633        if let JitProtoState::Untried = proto.jit.get() {
1634            self.populate_jit_cache(proto);
1635        }
1636        let JitProtoState::Compiled {
1637            entry,
1638            num_args,
1639            returns_one,
1640            arg_float_mask,
1641            arg_table_mask,
1642            ret_is_float,
1643            ret_is_table,
1644        } = proto.jit.get()
1645        else {
1646            return false;
1647        };
1648        if num_args as u32 != nargs {
1649            return false;
1650        }
1651        // Pack args into i64 bit-patterns per the per-slot expected
1652        // kind. A Float-typed slot accepts Value::Float verbatim and
1653        // promotes Value::Int(x) via i64 → f64; a Table-typed slot
1654        // accepts only Value::Table and passes the raw Gc ptr; an
1655        // Int-typed slot accepts only Value::Int. Any other shape
1656        // bails to the interpreter so the call's actual dynamics
1657        // (metamethod dispatch / type-coerce) take over.
1658        let mut args: [i64; crate::jit::MAX_JIT_ARITY as usize] =
1659            [0; crate::jit::MAX_JIT_ARITY as usize];
1660        for i in 0..num_args as usize {
1661            let v = self.stack[(func_slot + 1) as usize + i];
1662            let want_float = (arg_float_mask >> i) & 1 == 1;
1663            let want_table = (arg_table_mask >> i) & 1 == 1;
1664            args[i] = match (want_table, want_float, v) {
1665                (true, _, Value::Table(t)) => t.as_ptr() as i64,
1666                (false, false, Value::Int(x)) => x,
1667                (false, true, Value::Float(f)) => f.to_bits() as i64,
1668                (false, true, Value::Int(x)) => (x as f64).to_bits() as i64,
1669                _ => return false,
1670            };
1671        }
1672        // P11-S5c / S5d.J — Vm + closure pin for helpers; see the
1673        // matching guard in `try_jit_call`.
1674        // v1.1 A1 Session A — route through chunk_compiler.
1675        let vm_ptr: *mut Vm = self;
1676        let _jit_vm_guard = self.jit.chunk_compiler.enter(vm_ptr, Some(cl));
1677        // 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.
1678        let r = unsafe {
1679            match num_args {
1680                0 => (std::mem::transmute::<*const u8, crate::jit::IntChunkFn>(entry))(),
1681                1 => (std::mem::transmute::<*const u8, crate::jit::IntFn1>(entry))(args[0]),
1682                2 => {
1683                    (std::mem::transmute::<*const u8, crate::jit::IntFn2>(entry))(args[0], args[1])
1684                }
1685                3 => (std::mem::transmute::<*const u8, crate::jit::IntFn3>(entry))(
1686                    args[0], args[1], args[2],
1687                ),
1688                4 => (std::mem::transmute::<*const u8, crate::jit::IntFn4>(entry))(
1689                    args[0], args[1], args[2], args[3],
1690                ),
1691                _ => unreachable!("MAX_JIT_ARITY enforces num_args <= 4"),
1692            }
1693        };
1694        drop(_jit_vm_guard);
1695        // P11-S5d.E' — see matching path in `try_jit_call`. A helper
1696        // flagged a metatable on a table operand; bail to the interpreter
1697        // so `push_frame` runs the call from scratch.
1698        if self.jit.pending_err.take().is_some() {
1699            return false;
1700        }
1701        // Write result at func_slot, replacing the closure value, then
1702        // hand to finish_results to pad/truncate per the call site's
1703        // `wanted` count.
1704        if returns_one {
1705            let v = if ret_is_float {
1706                Value::Float(f64::from_bits(r as u64))
1707            } else if ret_is_table {
1708                Value::Table(crate::runtime::Gc::from_ptr(
1709                    r as *mut crate::runtime::Table,
1710                ))
1711            } else {
1712                Value::Int(r)
1713            };
1714            self.stack[func_slot as usize] = v;
1715            self.finish_results(func_slot, 1, wanted);
1716        } else {
1717            self.finish_results(func_slot, 0, wanted);
1718        }
1719        true
1720    }
1721
1722    /// `call_value` with control over the `from_c` debug boundary. A `__close`
1723    /// handler runs *within* the closing Lua frame's activation (PUC luaF_close
1724    /// invokes it inside that ci), so it is called with `from_c = false`: its
1725    /// debug parent is the closing function, not a synthetic C level.
1726    fn call_value_impl(
1727        &mut self,
1728        f: Value,
1729        args: &[Value],
1730        from_c: bool,
1731    ) -> Result<Vec<Value>, LuaError> {
1732        if self.c_depth >= MAX_C_DEPTH {
1733            return Err(self.rt_err("stack overflow"));
1734        }
1735        self.c_depth += 1;
1736        let func_slot = self.stack.len() as u32;
1737        self.stack.push(f);
1738        self.stack.extend_from_slice(args);
1739        self.top = self.stack.len() as u32;
1740        let r = self.call_at(func_slot, args.len() as u32, from_c);
1741        self.c_depth -= 1;
1742        if r.is_err()
1743            && self.yielding.is_none()
1744            && self.terminating.is_none()
1745            && !self.host_yield_pending
1746            && self.pending_async_native_fut.is_none()
1747        {
1748            // A `coroutine.yield` in flight raises a sentinel error to unwind the
1749            // Rust stack, but the suspended coroutine's frames/registers (which
1750            // sit at/above `func_slot`) must survive for the next resume — so we
1751            // only truncate on a real error. A self-close termination is in the
1752            // same boat: the dying thread's state is discarded wholesale.
1753            // v1.1 B10 — a `host_yield_pending` cooperative yield is in
1754            // the same boat as `yielding`: the next `EvalFuture::poll`
1755            // resumes the same call, so the in-flight frames must
1756            // survive.
1757            self.stack.truncate(func_slot as usize);
1758            self.top = func_slot;
1759        }
1760        r
1761    }
1762
1763    /// Invoke `f` with the running thread marked non-yieldable for the duration
1764    /// (PUC `luaD_callnoyield`): a `coroutine.yield` inside `f` hits the C-call
1765    /// boundary and errors instead of suspending. Used by library callbacks
1766    /// (sort comparator, gsub replacement) that run via synchronous Rust
1767    /// recursion and so could not be re-entered after a yield.
1768    pub(crate) fn call_noyield(
1769        &mut self,
1770        f: Value,
1771        args: &[Value],
1772    ) -> Result<Vec<Value>, LuaError> {
1773        self.nny += 1;
1774        let r = self.call_value(f, args);
1775        self.nny -= 1;
1776        r
1777    }
1778
1779    // ---- coroutines (P05) ----
1780
1781    pub(crate) fn new_coro(&mut self, body: Value) -> Gc<Coro> {
1782        // The new coroutine inherits the creating thread's current globals
1783        // (PUC `lua_newthread`: the new state copies `g->mainthread`'s
1784        // `l_gt`). `Vm.globals` always reflects the live thread, so reading
1785        // it here picks the creator regardless of which coro is running.
1786        self.heap.new_coro(body, self.globals)
1787    }
1788
1789    /// Is `t` the thread whose context is currently live in the VM?
1790    pub(crate) fn is_current_thread(&self, t: Option<Gc<Coro>>) -> bool {
1791        match (self.current, t) {
1792            (None, None) => true,
1793            (Some(a), Some(b)) => a.ptr_eq(b),
1794            _ => false,
1795        }
1796    }
1797
1798    /// Read an open-upvalue slot from its owning thread's stack (the live VM
1799    /// stack if that thread is current, else its saved context).
1800    #[doc(hidden)]
1801    pub fn read_slot(&self, slot: u32, thread: Option<Gc<Coro>>) -> Value {
1802        let s = slot as usize;
1803        if self.is_current_thread(thread) {
1804            self.stack[s]
1805        } else {
1806            match thread {
1807                Some(co) => co.stack[s],
1808                None => self.main_ctx.as_ref().expect("main context").stack[s],
1809            }
1810        }
1811    }
1812
1813    fn write_slot(&mut self, slot: u32, thread: Option<Gc<Coro>>, v: Value) {
1814        let s = slot as usize;
1815        if self.is_current_thread(thread) {
1816            self.stack[s] = v;
1817        } else {
1818            match thread {
1819                Some(co) => {
1820                    // 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).
1821                    unsafe { co.as_mut() }.stack[s] = v;
1822                    // co.stack is traced by Coro::trace; demote co back to
1823                    // gray so propagate re-traces this slot if it was
1824                    // already black.
1825                    self.heap
1826                        .barrier_back(co.as_ptr() as *mut crate::runtime::heap::GcHeader);
1827                }
1828                None => self.main_ctx.as_mut().expect("main context").stack[s] = v,
1829            }
1830        }
1831    }
1832
1833    /// Whether `co` is the main thread's identity object.
1834    pub(crate) fn is_main_coro(&self, co: Gc<Coro>) -> bool {
1835        self.main_coro.is_some_and(|m| m.ptr_eq(co))
1836    }
1837
1838    /// The status of `co` from the caller's view. The main thread's identity
1839    /// object has no stored status — it is "running" when nothing else runs,
1840    /// else "normal" (it resumed the active coroutine).
1841    pub(crate) fn effective_coro_status(&self, co: Gc<Coro>) -> CoroStatus {
1842        if self.is_main_coro(co) {
1843            if self.current.is_none() {
1844                CoroStatus::Running
1845            } else {
1846                CoroStatus::Normal
1847            }
1848        } else {
1849            co.status
1850        }
1851    }
1852
1853    /// `coroutine.close` (PUC `lua_closethread`): run the suspended coroutine's
1854    /// pending to-be-closed `__close` handlers, then mark it dead and drop its
1855    /// context. Handlers see the coroutine's death error (if it died by error)
1856    /// or nil; an error they raise propagates out. `Ok(Some(e))` means it died
1857    /// with error `e` and no handler overrode it; `Err` means a handler raised.
1858    pub(crate) fn close_coro(&mut self, co: Gc<Coro>) -> Result<Option<Value>, LuaError> {
1859        // re-entrant close: a __close handler closed its own coroutine while the
1860        // outer close is mid-flight (its context is live). Report success and let
1861        // the outer close finish — re-entering the swap would corrupt the stack.
1862        if self.current.is_some_and(|c| c.ptr_eq(co)) {
1863            return Ok(None);
1864        }
1865        // A chain of coroutines whose `__close` handlers each close the previous
1866        // one recurses on the C stack (PUC `luaD_callnoyield` in `lua_closethread`).
1867        // The calling handler's `call_value` has already pushed `c_depth` to the
1868        // cap, so here it reads as full first — report PUC's "C stack overflow"
1869        // before the next handler call would surface the plainer "stack overflow".
1870        if self.c_depth >= MAX_C_DEPTH {
1871            return Err(self.rt_err("C stack overflow"));
1872        }
1873        // 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).
1874        let death_err = unsafe { co.as_mut() }.error_value.take();
1875        // swap the caller's live context out (into a GC-rooted home) and the
1876        // coroutine's in, mirroring resume_coro, so the __close handlers run on
1877        // the coroutine's stack while everything stays rooted.
1878        let resumer = self.current;
1879        let rctx = self.take_ctx();
1880        match resumer {
1881            Some(r) => {
1882                // 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).
1883                let m = unsafe { r.as_mut() };
1884                m.stack = rctx.stack;
1885                m.frames = rctx.frames;
1886                m.open_upvals = rctx.open_upvals;
1887                m.tbc = rctx.tbc;
1888                m.top = rctx.top;
1889                m.pcall_depth = rctx.pcall_depth;
1890            }
1891            None => self.main_ctx = Some(rctx),
1892        }
1893        self.load_coro_ctx(co);
1894        self.current = Some(co);
1895        let result = self.close_slots(0, death_err);
1896        // discard the (now-closed) coroutine context and restore the caller
1897        let _ = self.take_ctx();
1898        match resumer {
1899            Some(r) => {
1900                self.load_coro_ctx(r);
1901                self.current = Some(r);
1902            }
1903            None => {
1904                let m = self.main_ctx.take().expect("main context saved");
1905                self.put_ctx(m);
1906                self.current = None;
1907            }
1908        }
1909        {
1910            // 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).
1911            let m = unsafe { co.as_mut() };
1912            m.status = CoroStatus::Dead;
1913            m.stack = Vec::new();
1914            m.frames = Vec::new();
1915            m.open_upvals = Vec::new();
1916            m.tbc = Vec::new();
1917            m.top = 0;
1918            m.pcall_depth = 0;
1919            m.resume_at = None;
1920            m.error_value = None;
1921        }
1922        result.map(|()| death_err)
1923    }
1924
1925    /// `coroutine.running`: the running thread plus whether it is the main one.
1926    pub(crate) fn running_thread(&self) -> (Value, bool) {
1927        match self.current {
1928            Some(co) => (Value::Coro(co), false),
1929            None => (Value::Coro(self.main_coro.expect("main coro")), true),
1930        }
1931    }
1932
1933    /// `coroutine.isyieldable([co])`: whether `co` (default: the running
1934    /// thread) can yield. The main thread never can; any other coroutine can
1935    /// unless it is dead.
1936    pub(crate) fn is_yieldable(&self, co: Option<Gc<Coro>>) -> bool {
1937        match co {
1938            Some(c) => !self.main_coro.is_some_and(|m| m.ptr_eq(c)) && c.status != CoroStatus::Dead,
1939            // the running thread can yield only outside any non-yieldable C call
1940            None => self.current.is_some() && self.nny == 0,
1941        }
1942    }
1943
1944    /// Why `coroutine.yield` may not suspend the running thread right now, as a
1945    /// PUC error message — `None` if it may. Distinguishes "not in a coroutine"
1946    /// from "inside an unyieldable C call" (sort/gsub callback).
1947    pub(crate) fn yield_barrier(&self) -> Option<&'static str> {
1948        if self.current.is_none() {
1949            Some("attempt to yield from outside a coroutine")
1950        } else if self.nny > 0 {
1951            Some("attempt to yield across a C-call boundary")
1952        } else {
1953            None
1954        }
1955    }
1956
1957    /// The coroutine whose context is currently live (`None` on the main thread).
1958    pub(crate) fn current_coro(&self) -> Option<Gc<Coro>> {
1959        self.current
1960    }
1961
1962    /// `coroutine.close()` on the *running* thread (PUC 5.5 close-self): run all
1963    /// its pending `__close` handlers, then signal termination. The handlers run
1964    /// here, in place, with the thread still non-yieldable (a yield in one hits
1965    /// the C-call boundary). The returned sentinel unwinds the Rust stack the
1966    /// way a yield does — `exec_with` propagates it past any protecting pcall
1967    /// rather than letting `unwind` catch it — and `resume_coro` turns it into a
1968    /// clean death (or, if a handler raised, the coroutine's error).
1969    pub(crate) fn close_running(&mut self) -> LuaError {
1970        let death = match self.close_slots(0, None) {
1971            Ok(()) => None,
1972            Err(e) => Some(e.0),
1973        };
1974        self.terminating = Some(death);
1975        LuaError(Value::Nil)
1976    }
1977
1978    /// `coroutine.status` as seen by the caller.
1979    pub(crate) fn coro_status_str(&self, co: Gc<Coro>) -> &'static str {
1980        match self.effective_coro_status(co) {
1981            CoroStatus::Suspended => "suspended",
1982            CoroStatus::Running => "running",
1983            CoroStatus::Normal => "normal",
1984            CoroStatus::Dead => "dead",
1985        }
1986    }
1987
1988    fn take_ctx(&mut self) -> SavedCtx {
1989        let saved = SavedCtx {
1990            stack: std::mem::take(&mut self.stack),
1991            frames: std::mem::take(&mut self.frames),
1992            open_upvals: std::mem::take(&mut self.open_upvals),
1993            tbc: std::mem::take(&mut self.tbc),
1994            top: self.top,
1995            pcall_depth: self.pcall_depth,
1996            hook: self.hook,
1997            globals: self.globals,
1998        };
1999        self.frames_resync(); // P17-D Week 1 — frames now empty.
2000        saved
2001    }
2002
2003    fn put_ctx(&mut self, c: SavedCtx) {
2004        self.stack = c.stack;
2005        self.frames = c.frames;
2006        self.open_upvals = c.open_upvals;
2007        self.tbc = c.tbc;
2008        self.top = c.top;
2009        self.pcall_depth = c.pcall_depth;
2010        self.hook = c.hook;
2011        self.globals = c.globals;
2012        self.frames_resync(); // P17-D Week 1 — sync shadow to new Vec.
2013    }
2014
2015    /// Move a coroutine's saved context into the live VM fields.
2016    fn load_coro_ctx(&mut self, co: Gc<Coro>) {
2017        // SAFETY: Gc<T> is NonNull<T> over the GC heap; the heap is single-threaded and the pointer is live as long as it is reachable from active roots (see heap.rs:5-7).
2018        let m = unsafe { co.as_mut() };
2019        self.stack = std::mem::take(&mut m.stack);
2020        self.frames = std::mem::take(&mut m.frames);
2021        self.open_upvals = std::mem::take(&mut m.open_upvals);
2022        self.tbc = std::mem::take(&mut m.tbc);
2023        self.top = m.top;
2024        self.frames_resync(); // P17-D Week 1 — sync shadow to coro's frames.
2025        self.pcall_depth = m.pcall_depth;
2026        self.hook = m.hook;
2027        self.globals = m.globals;
2028    }
2029
2030    /// Save the live VM context back into a coroutine object.
2031    fn store_coro_ctx(&mut self, co: Gc<Coro>) {
2032        let c = self.take_ctx();
2033        // 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).
2034        let m = unsafe { co.as_mut() };
2035        m.stack = c.stack;
2036        m.frames = c.frames;
2037        m.open_upvals = c.open_upvals;
2038        m.tbc = c.tbc;
2039        m.top = c.top;
2040        m.pcall_depth = c.pcall_depth;
2041        m.hook = c.hook;
2042        m.globals = c.globals;
2043        // bulk-overwrite of every collectable field traced by Coro::trace:
2044        // demote the coro back to gray so propagate re-traces its new state.
2045        self.heap
2046            .barrier_back(co.as_ptr() as *mut crate::runtime::heap::GcHeader);
2047    }
2048
2049    /// `coroutine.resume` core: drive `co` with `args` until it yields, returns
2050    /// or errors. Ok(values) carries yielded or returned values; Err carries an
2051    /// error raised inside the coroutine (the coroutine becomes dead).
2052    pub(crate) fn resume_coro(
2053        &mut self,
2054        co: Gc<Coro>,
2055        args: Vec<Value>,
2056    ) -> Result<Vec<Value>, LuaError> {
2057        match co.status {
2058            CoroStatus::Suspended => {}
2059            CoroStatus::Dead => return Err(self.plain_err("cannot resume dead coroutine")),
2060            _ => return Err(self.plain_err("cannot resume non-suspended coroutine")),
2061        }
2062        if self.c_depth >= MAX_C_DEPTH {
2063            return Err(self.plain_err("C stack overflow"));
2064        }
2065        self.c_depth += 1;
2066        let resumer = self.current;
2067        // save the resumer's live context away
2068        let rctx = self.take_ctx();
2069        match resumer {
2070            Some(r) => {
2071                // 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).
2072                let m = unsafe { r.as_mut() };
2073                m.stack = rctx.stack;
2074                m.frames = rctx.frames;
2075                m.open_upvals = rctx.open_upvals;
2076                m.tbc = rctx.tbc;
2077                m.top = rctx.top;
2078                m.pcall_depth = rctx.pcall_depth;
2079                m.globals = rctx.globals;
2080                m.status = CoroStatus::Normal;
2081                // bulk overwrite of every traced field on r — mirror
2082                // store_coro_ctx's barrier_back so propagate re-traces r.
2083                self.heap
2084                    .barrier_back(r.as_ptr() as *mut crate::runtime::heap::GcHeader);
2085            }
2086            None => self.main_ctx = Some(rctx),
2087        }
2088        // swap the coroutine in
2089        self.load_coro_ctx(co);
2090        {
2091            // 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).
2092            let m = unsafe { co.as_mut() };
2093            m.status = CoroStatus::Running;
2094            m.resumer = resumer;
2095        }
2096        // co.resumer is a traced Gc field; barrier_back covers the new
2097        // resumer reference and any future field writes during this call.
2098        self.heap
2099            .barrier_back(co.as_ptr() as *mut crate::runtime::heap::GcHeader);
2100        self.current = Some(co);
2101
2102        // drive it
2103        let drive = if co.started {
2104            self.coro_continue(&args)
2105        } else {
2106            // 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).
2107            unsafe { co.as_mut() }.started = true;
2108            self.coro_first(co.body, &args)
2109        };
2110
2111        // classify: a self-close termination or a pending yield each win over
2112        // the (sentinel) error they raised to unwind the Rust stack.
2113        let (outcome, status) = if let Some(death) = self.terminating.take() {
2114            // the coroutine closed itself: it dies now, cleanly or with the
2115            // error a `__close` handler raised.
2116            match death {
2117                Some(e) => {
2118                    // 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).
2119                    unsafe { co.as_mut() }.error_value = Some(e);
2120                    self.heap
2121                        .barrier_back(co.as_ptr() as *mut crate::runtime::heap::GcHeader);
2122                    (Err(LuaError(e)), CoroStatus::Dead)
2123                }
2124                None => (Ok(Vec::new()), CoroStatus::Dead),
2125            }
2126        } else {
2127            match self.yielding.take() {
2128                Some((vals, fslot, nres)) => {
2129                    // 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).
2130                    unsafe { co.as_mut() }.resume_at = Some((fslot, nres));
2131                    (Ok(vals), CoroStatus::Suspended)
2132                }
2133                None => {
2134                    // died: a return is clean, an error is remembered so a later
2135                    // `coroutine.close` can report it (PUC lua_closethread).
2136                    // Capture the error-point traceback (set by `unwind` before
2137                    // popping the failing frames) and prepend a synthetic
2138                    // top entry for the C native that initiated the error
2139                    // (PUC `[C]: in function '<name>'`) so `debug.traceback(co)`
2140                    // on the dead coroutine still shows the error site
2141                    // (db.lua :848 family).
2142                    if drive.is_err() {
2143                        let mut tb = self.error_traceback.take().unwrap_or_default();
2144                        if let Some(nm) = self.errored_native.take() {
2145                            let mut prefixed: Vec<u8> = Vec::new();
2146                            prefixed.extend_from_slice(
2147                                format!("\n\t[C]: in function '{nm}'").as_bytes(),
2148                            );
2149                            prefixed.extend(tb);
2150                            tb = prefixed;
2151                        }
2152                        // 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).
2153                        unsafe { co.as_mut() }.error_traceback = Some(tb);
2154                    }
2155                    if let Err(e) = drive {
2156                        // 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).
2157                        unsafe { co.as_mut() }.error_value = Some(e.0);
2158                        self.heap
2159                            .barrier_back(co.as_ptr() as *mut crate::runtime::heap::GcHeader);
2160                    }
2161                    (drive, CoroStatus::Dead)
2162                }
2163            }
2164        };
2165
2166        // save the coroutine's context back and restore the resumer
2167        self.store_coro_ctx(co);
2168        // 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).
2169        unsafe { co.as_mut() }.status = status;
2170        match resumer {
2171            Some(r) => {
2172                self.load_coro_ctx(r);
2173                // 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).
2174                unsafe { r.as_mut() }.status = CoroStatus::Running;
2175                self.current = Some(r);
2176            }
2177            None => {
2178                let m = self.main_ctx.take().expect("main context saved");
2179                self.put_ctx(m);
2180                self.current = None;
2181            }
2182        }
2183        self.c_depth -= 1;
2184        outcome
2185    }
2186
2187    /// First resume: install the body function at slot 0 and run.
2188    fn coro_first(&mut self, body: Value, args: &[Value]) -> Result<Vec<Value>, LuaError> {
2189        self.stack.clear();
2190        self.stack.push(body);
2191        self.stack.extend_from_slice(args);
2192        self.top = self.stack.len() as u32;
2193        match self.begin_call(0, Some(args.len() as u32), -1, true) {
2194            Ok(true) => self.exec_with(1),
2195            Ok(false) => Ok(self.take_results(0)),
2196            Err(e) => Err(e),
2197        }
2198    }
2199
2200    /// Resume after a yield: deliver `args` as the results of the call that
2201    /// yielded, then continue the suspended thread.
2202    fn coro_continue(&mut self, args: &[Value]) -> Result<Vec<Value>, LuaError> {
2203        let (fslot, nres) = self.current.unwrap().resume_at.expect("resume point");
2204        let n = args.len() as u32;
2205        // Restore the full register window of the suspended top frame: a yield
2206        // that unwound through a native (call_value) may have left the stack
2207        // shorter than the frame needs. `base + max_stack` is what push_frame
2208        // allocates; `fslot + n` covers the delivered yield results.
2209        let frame_need = self
2210            .frames
2211            .last()
2212            .and_then(CallFrame::lua)
2213            .map(|f| (f.base + f.closure.proto.max_stack as u32) as usize)
2214            .unwrap_or(0);
2215        let need = frame_need.max((fslot + n) as usize);
2216        if self.stack.len() < need {
2217            self.stack.resize(need, Value::Nil);
2218        }
2219        for (i, &v) in args.iter().enumerate() {
2220            self.stack[fslot as usize + i] = v;
2221        }
2222        self.finish_results(fslot, n, nres);
2223        // the suspended `coroutine.yield` (a C call) now returns its resume
2224        // values: fire the matching "return" hook PUC defers until the resume.
2225        self.hook_return(true, 1, n)?;
2226        self.exec_with(1)
2227    }
2228
2229    /// `coroutine.yield`: suspend the running coroutine, recording where to
2230    /// resume. Errors if called outside a coroutine. Returns a sentinel error
2231    /// that `exec`/`resume_coro` recognise as a yield (never surfaced to Lua).
2232    pub(crate) fn do_yield(&mut self, func_slot: u32, vals: Vec<Value>) -> LuaError {
2233        let nres = self.native_nresults;
2234        self.yielding = Some((vals, func_slot, nres));
2235        // value is irrelevant: resume_coro consults `self.yielding`, not this
2236        LuaError(Value::Nil)
2237    }
2238
2239    /// Install or clear the debug hook on the running thread (`debug.sethook`
2240    /// without a thread argument). Arms the calling frame's `oldpc` to the
2241    /// sethook CALL's own pc (one less than the next-to-execute pc), mirroring
2242    /// PUC `rethook`'s `L->oldpc = pcRel(savedpc, p)` (= savedpc - code - 1) on
2243    /// native return: the very next traceexec compares against the sethook
2244    /// CALL's line. When the install statement and the following statement are
2245    /// on different source lines (db.lua :322), `changedline` fires for that
2246    /// first statement; when they share a line (db.lua :25 wrapper), they do
2247    /// not, so the wrapper line is not re-fired.
2248    pub(crate) fn install_hook(&mut self, hook: HookState) {
2249        self.hook = hook;
2250        if self.hook.line
2251            && let Some(f) = self.frames.last_mut().and_then(CallFrame::lua_mut)
2252        {
2253            f.hook_oldpc = f.pc.saturating_sub(1);
2254        }
2255    }
2256
2257    /// Install a hook on `target` (`None`/current thread → the live VM fields;
2258    /// another, suspended thread → its saved `Coro` state). PUC `debug.sethook`
2259    /// with an optional thread argument.
2260    ///
2261    /// `target == None` means "no explicit thread argument" — PUC binds that
2262    /// to `L` (the running thread). luna's live VM fields (`self.hook`,
2263    /// `self.frames`, `self.stack`) ARE the running thread's state, regardless
2264    /// of whether that's the main thread or a currently-resumed coroutine
2265    /// (save/restore happens at resume/yield boundaries via `load_coro_ctx`/
2266    /// `store_coro_ctx`). So a `None` target should always route to
2267    /// `install_hook` on the live fields. The pre-fix predicate gate
2268    /// `is_current_thread(target)` returned `false` when running inside a
2269    /// coroutine (`self.current = Some(co)`, `target = None` don't match)
2270    /// and silently dropped the hook on the floor — the install happened on
2271    /// no thread at all.
2272    pub(crate) fn set_hook(&mut self, target: Option<Gc<Coro>>, state: HookState) {
2273        if target.is_none() || self.is_current_thread(target) {
2274            self.install_hook(state);
2275        } else if let Some(co) = target {
2276            // 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).
2277            let m = unsafe { co.as_mut() };
2278            m.hook = state;
2279            if state.line
2280                && let Some(f) = m.frames.last_mut().and_then(CallFrame::lua_mut)
2281            {
2282                f.hook_oldpc = u32::MAX;
2283            }
2284            // co.hook.func is a traced Value (Coro::trace covers it); demote
2285            // co back to gray so propagate sees the new hook function.
2286            self.heap
2287                .barrier_back(co.as_ptr() as *mut crate::runtime::heap::GcHeader);
2288        }
2289    }
2290
2291    /// The hook state of `target` (`None`/current → the live VM state).
2292    pub(crate) fn get_hook(&self, target: Option<Gc<Coro>>) -> HookState {
2293        match target {
2294            t if self.is_current_thread(t) => self.hook,
2295            Some(co) => co.hook,
2296            None => self.hook,
2297        }
2298    }
2299
2300    /// Invoke the debug hook for `event` (PUC `luaD_hook`). The hook runs with
2301    /// hooks disabled (PUC clears the mask) and its results/stack growth are
2302    /// discarded so the interrupted frame's register window is untouched.
2303    /// `line` is the source line for a "line" event, `None` (→ nil) otherwise.
2304    fn run_hook(
2305        &mut self,
2306        event: &[u8],
2307        line: Option<i64>,
2308        from_native: bool,
2309    ) -> Result<(), LuaError> {
2310        // v1.1 B11 — Rust hook fires first (no Vm reentrancy via call_value;
2311        // synchronous fn pointer call). Both Rust and Lua hooks may be
2312        // installed; both observe each event.
2313        if let Some(rh) = self.hook.rust_func {
2314            let evt = match event {
2315                b"call" => Some(RustHookEvent::Call),
2316                b"return" => Some(RustHookEvent::Return),
2317                b"tail call" | b"tail return" => Some(RustHookEvent::TailCall),
2318                b"line" => Some(RustHookEvent::Line(line.unwrap_or(0).max(0) as u32)),
2319                b"count" => Some(RustHookEvent::Count),
2320                _ => None,
2321            };
2322            if let Some(evt) = evt {
2323                let was_in_hook = self.in_hook;
2324                self.in_hook = true;
2325                rh(self, evt);
2326                self.in_hook = was_in_hook;
2327            }
2328        }
2329        let Some(hook) = self.hook.func else {
2330            return Ok(());
2331        };
2332        let saved_top = self.top;
2333        let saved_len = self.stack.len();
2334        let name = Value::Str(self.heap.intern(event));
2335        let lv = line.map_or(Value::Nil, Value::Int);
2336        self.in_hook = true;
2337        // PUC `db_sethook`'s C trampoline `hookf` sits between the engine and
2338        // the Lua hook — so `getinfo(2)` inside the hook resolves to whatever
2339        // ci sat below `hookf` (the function being hooked). When that hooked
2340        // function is native, no Lua frame for it exists in luna's `frames`;
2341        // model it as a synthetic C level by pushing the hook with
2342        // `from_c = true` (then `c_frame_name` reads the caller's call
2343        // instruction → e.g. `name = "sethook"`). When the hooked function is
2344        // Lua (its frame is still on the stack), push with `from_c = false`
2345        // so the level descent lands on it directly. The hook's own frame
2346        // carries `is_hook = true` so `getinfo(1).namewhat` reports "hook"
2347        // (PUC `CIST_HOOKED`).
2348        self.pending_is_hook = true;
2349        let r = self.call_value_impl(hook, &[name, lv], from_native);
2350        self.pending_is_hook = false;
2351        self.in_hook = false;
2352        self.stack.truncate(saved_len);
2353        self.top = saved_top;
2354        r.map(|_| ())
2355    }
2356
2357    /// Fire the "call" hook on entry to a function, if armed and not already in
2358    /// a hook (PUC clears the mask while a hook runs). PUC's transferinfo for
2359    /// a call hook is the param window: ftransfer = 1, ntransfer = nargs.
2360    /// `is_tail` selects the "tail call" event (PUC `LUA_HOOKTAILCALL`); a
2361    /// tail-call hook has no matching return hook (PUC luaD_pretailcall).
2362    fn hook_call_with(
2363        &mut self,
2364        from_native: bool,
2365        nargs: u32,
2366        is_tail: bool,
2367    ) -> Result<(), LuaError> {
2368        if self.hook.call
2369            && !self.in_hook
2370            && (self.hook.func.is_some() || self.hook.rust_func.is_some())
2371        {
2372            self.hook_ftransfer = 1;
2373            self.hook_ntransfer = nargs.min(u16::MAX as u32) as u16;
2374            // PUC 5.1 didn't distinguish tail-call events — every call,
2375            // including tail-calls, fired plain `"call"`. 5.2 introduced
2376            // the separate `"tail call"` event (mask `"c"` covers both).
2377            // 5.1 db.lua :366 pins this with `{"call","call","call","call",
2378            // "return","tail return","return","tail return"}`.
2379            let event: &[u8] = if is_tail && self.version >= LuaVersion::Lua52 {
2380                b"tail call"
2381            } else {
2382                b"call"
2383            };
2384            self.run_hook(event, None, from_native)?;
2385        }
2386        Ok(())
2387    }
2388
2389    pub(crate) fn hook_call(&mut self, from_native: bool, nargs: u32) -> Result<(), LuaError> {
2390        self.hook_call_with(from_native, nargs, false)
2391    }
2392
2393    /// Fire the "return" hook on exit from a function, if armed. ftransfer is
2394    /// the first result slot relative to the activation's func slot, ntransfer
2395    /// the number of results.
2396    pub(crate) fn hook_return(
2397        &mut self,
2398        from_native: bool,
2399        ftransfer: u32,
2400        nresults: u32,
2401    ) -> Result<(), LuaError> {
2402        if self.hook.ret
2403            && !self.in_hook
2404            && (self.hook.func.is_some() || self.hook.rust_func.is_some())
2405        {
2406            self.hook_ftransfer = ftransfer.min(u16::MAX as u32) as u16;
2407            self.hook_ntransfer = nresults.min(u16::MAX as u32) as u16;
2408            self.run_hook(b"return", None, from_native)?;
2409        }
2410        Ok(())
2411    }
2412
2413    /// PUC "tail return" event — fires once per tail call that collapsed
2414    /// into the activation now returning, *after* its own "return" event.
2415    /// 5.1 hook mask `"r"` covers both `return` and `tail return`.
2416    fn hook_tail_return(&mut self) -> Result<(), LuaError> {
2417        if self.hook.ret
2418            && !self.in_hook
2419            && (self.hook.func.is_some() || self.hook.rust_func.is_some())
2420        {
2421            self.run_hook(b"tail return", None, false)?;
2422        }
2423        Ok(())
2424    }
2425
2426    /// Call a metamethod with a single expected result.
2427    fn call_mm1(&mut self, f: Value, args: &[Value]) -> Result<Value, LuaError> {
2428        let mut r = self.call_value(f, args)?;
2429        Ok(if r.is_empty() {
2430            Value::Nil
2431        } else {
2432            r.swap_remove(0)
2433        })
2434    }
2435
2436    /// Begin a *yieldable* metamethod call from a VM instruction: `func(args…)`
2437    /// driven through the interpreter loop with a `Meta` continuation, so a
2438    /// `coroutine.yield` inside the metamethod suspends and resumes cleanly.
2439    /// On the metamethod's return the loop head runs `finish_meta(action, …)`.
2440    /// Returns to the caller with the call set up — the opcode arm must do no
2441    /// further work on the running frame and let the loop iterate. `tm` is
2442    /// the metamethod event name (e.g. "index", "add"); a Lua handler frame
2443    /// born from this call inherits it via `pending_tm`, so
2444    /// `debug.getinfo(1).namewhat == "metamethod"` and `.name == tm`
2445    /// (db.lua :878).
2446    fn begin_meta_call(
2447        &mut self,
2448        func: Value,
2449        args: &[Value],
2450        action: MetaAction,
2451        tm: &'static str,
2452    ) -> Result<(), LuaError> {
2453        let saved_top = self.top;
2454        let cont_slot = self.stack.len() as u32;
2455        self.stack.push(func);
2456        self.stack.extend_from_slice(args);
2457        self.top = self.stack.len() as u32;
2458        frames_push_sync(
2459            &mut self.frames,
2460            &mut self.frames_top,
2461            CallFrame::Cont(NativeCont {
2462                kind: ContKind::Meta(MetaCont { action, saved_top }),
2463                func_slot: cont_slot,
2464                nresults: 1,
2465            }),
2466        );
2467        let saved_tm = self.pending_tm.replace(tm);
2468        // begin_call drives a Lua metamethod through the loop (returns true) or
2469        // runs a native one inline (returns false, leaving results at cont_slot
2470        // for the loop head to pick up); either way the Meta cont resolves there.
2471        let r = self.begin_call(cont_slot, Some(args.len() as u32), 1, true);
2472        // Native callees never consumed pending_tm (push_frame is only hit on
2473        // a Lua callee); restore so it doesn't leak to a later push_frame.
2474        self.pending_tm = saved_tm;
2475        r?;
2476        Ok(())
2477    }
2478
2479    /// `R[dst] := t[key]` for a VM read opcode, resolving `__index` yieldably.
2480    fn op_index(&mut self, t: Value, key: Value, dst: u32) -> Result<(), LuaError> {
2481        // v2.13 WUC read-time probe: a collectable key must be live at
2482        // the moment it is used. O(1) membership test against the
2483        // freed-pointer log — gc-verify diagnostic builds only; exact
2484        // under quarantining allocators (ASAN).
2485        #[cfg(feature = "gc-verify")]
2486        if matches!(key, Value::Str(_)) {
2487            let h = match key {
2488                Value::Str(s) => s.as_ptr() as usize,
2489                _ => unreachable!(),
2490            };
2491            if self.heap.recently_freed.contains(&h) {
2492                let (pc, reg_info) = match self.frames.last() {
2493                    Some(CallFrame::Lua(f)) => {
2494                        let pc = f.pc as usize;
2495                        let inst = f.closure.proto.code.get(pc.wrapping_sub(1));
2496                        (
2497                            pc,
2498                            inst.map(|i| {
2499                                format!(
2500                                    "op[pc-1]={:?} a={} b={} c={} base={}",
2501                                    i.op(),
2502                                    i.a(),
2503                                    i.b(),
2504                                    i.c(),
2505                                    f.base
2506                                )
2507                            })
2508                            .unwrap_or_default(),
2509                        )
2510                    }
2511                    _ => (0, String::new()),
2512                };
2513                panic!(
2514                    "[gc-verify] op_index READ of dead string key {h:#x} \
2515                     (gc_top {}, top {}, pc {pc}, {reg_info})",
2516                    self.gc_top, self.top,
2517                );
2518            }
2519        }
2520        match self.index_step(t, key)? {
2521            MmOut::Done(v) => self.stack[dst as usize] = v,
2522            MmOut::Mm { func, recv } => {
2523                self.begin_meta_call(func, &[recv, key], MetaAction::Store { dst }, "index")?;
2524            }
2525            MmOut::CompareSynth { .. } => unreachable!("CompareSynth from index_step"),
2526        }
2527        Ok(())
2528    }
2529
2530    /// `t[key] := v` for a VM write opcode, resolving `__newindex` yieldably.
2531    fn op_newindex(&mut self, t: Value, key: Value, v: Value) -> Result<(), LuaError> {
2532        match self.newindex_step(t, key, v)? {
2533            MmOut::Done(_) => {}
2534            MmOut::Mm { func, recv } => {
2535                self.begin_meta_call(func, &[recv, key, v], MetaAction::Discard, "newindex")?;
2536            }
2537            MmOut::CompareSynth { .. } => unreachable!("CompareSynth from newindex_step"),
2538        }
2539        Ok(())
2540    }
2541
2542    /// Apply a comparison opcode's outcome: a known boolean drives the
2543    /// conditional skip directly; a metamethod is called yieldably, its
2544    /// truthiness driving the skip on return.
2545    fn op_compare(
2546        &mut self,
2547        step: MmOut,
2548        l: Value,
2549        r: Value,
2550        k: bool,
2551        tm: &'static str,
2552    ) -> Result<(), LuaError> {
2553        match step {
2554            MmOut::Done(v) => self.cond_skip(v.truthy(), k),
2555            MmOut::Mm { func, .. } => {
2556                self.begin_meta_call(func, &[l, r], MetaAction::Compare { k, negate: false }, tm)?;
2557            }
2558            MmOut::CompareSynth { func } => {
2559                // ≤5.3 `__le` falls back to `not __lt(r, l)`; the swap and
2560                // negation are driven through `MetaAction::Compare` so the
2561                // metamethod call can yield like any other compare.
2562                self.begin_meta_call(func, &[r, l], MetaAction::Compare { k, negate: true }, "lt")?;
2563            }
2564        }
2565        Ok(())
2566    }
2567
2568    /// Complete a VM instruction whose metamethod just returned `result` (PUC
2569    /// `luaV_finishOp`). The running frame is already back on top.
2570    fn finish_meta(&mut self, action: MetaAction, result: Value) -> Result<(), LuaError> {
2571        match action {
2572            MetaAction::Store { dst } => self.stack[dst as usize] = result,
2573            MetaAction::Discard => {}
2574            MetaAction::Compare { k, negate } => {
2575                let t = if negate {
2576                    !result.truthy()
2577                } else {
2578                    result.truthy()
2579                };
2580                self.cond_skip(t, k);
2581            }
2582            MetaAction::Concat { dst, base_a } => {
2583                self.stack[dst as usize] = result;
2584                self.top = dst + 1;
2585                self.concat_run(base_a)?;
2586            }
2587        }
2588        Ok(())
2589    }
2590
2591    // ---- metatables ----
2592
2593    pub(crate) fn metatable_of(&self, v: Value) -> Option<Gc<Table>> {
2594        match v {
2595            Value::Table(t) => t.metatable(),
2596            Value::Userdata(u) => u.metatable(),
2597            v => type_mt_slot(v).and_then(|i| self.type_mt[i]),
2598        }
2599    }
2600
2601    /// Set the shared metatable for `v`'s basic type (debug.setmetatable on a
2602    /// non-table). No-op for tables (they carry their own).
2603    pub(crate) fn set_type_metatable(&mut self, v: Value, mt: Option<Gc<Table>>) {
2604        if let Some(i) = type_mt_slot(v) {
2605            self.type_mt[i] = mt;
2606        }
2607    }
2608
2609    /// The metamethod of `v` for `mm`, or nil.
2610    pub(crate) fn get_mm(&self, v: Value, mm: Mm) -> Value {
2611        match self.metatable_of(v) {
2612            Some(mt) => mt.get(Value::Str(self.mm_names[mm as usize])),
2613            None => Value::Nil,
2614        }
2615    }
2616
2617    /// PUC 5.1 `get_compTM`: a comparison metamethod (`__eq` / `__lt` / `__le`)
2618    /// only fires when both operands carry a metatable that exposes the same
2619    /// implementation. Returns the metamethod to call, or `Nil` when no
2620    /// compatible match exists. Used to honour events.lua 5.1 :262's rule
2621    /// that `c == d` (where `d` has no metatable) falls back to raw equality.
2622    pub(crate) fn get_comp_mm(&self, l: Value, r: Value, mm: Mm) -> Value {
2623        let mt1 = self.metatable_of(l);
2624        let Some(mt1) = mt1 else { return Value::Nil };
2625        let key = Value::Str(self.mm_names[mm as usize]);
2626        let tm1 = mt1.get(key);
2627        if tm1.is_nil() {
2628            return Value::Nil;
2629        }
2630        let mt2 = self.metatable_of(r);
2631        let Some(mt2) = mt2 else { return Value::Nil };
2632        if mt1.as_ptr() == mt2.as_ptr() {
2633            return tm1;
2634        }
2635        let tm2 = mt2.get(key);
2636        if tm2.is_nil() {
2637            return Value::Nil;
2638        }
2639        if tm1.raw_eq(tm2) {
2640            return tm1;
2641        }
2642        Value::Nil
2643    }
2644
2645    /// PUC `luaT_objtypename`: the type name shown in error messages. A table
2646    /// or full userdata whose metatable carries a string `__name` reports that
2647    /// (e.g. "FILE*", "My Type") instead of the bare "table"/"userdata".
2648    pub(crate) fn obj_typename(&self, v: Value) -> String {
2649        if matches!(v, Value::Table(_) | Value::Userdata(_))
2650            && let Value::Str(s) = self.get_mm(v, Mm::Name)
2651        {
2652            return String::from_utf8_lossy(s.as_bytes()).into_owned();
2653        }
2654        v.type_name().to_string()
2655    }
2656
2657    fn call_at(
2658        &mut self,
2659        func_slot: u32,
2660        nargs: u32,
2661        from_c: bool,
2662    ) -> Result<Vec<Value>, LuaError> {
2663        if self.begin_call(func_slot, Some(nargs), -1, from_c)? {
2664            self.exec()
2665        } else {
2666            // native completed inline; results at func_slot..top
2667            Ok(self.take_results(func_slot))
2668        }
2669    }
2670
2671    /// Switch the `collectgarbage` mode, returning the previous mode name.
2672    pub(crate) fn gc_switch_mode(&mut self, new: &'static str) -> &'static str {
2673        std::mem::replace(&mut self.gc_mode, new)
2674    }
2675
2676    /// Whether the current `collectgarbage` mode is "generational" (where a
2677    /// "step" is a minor collection — a full atomic pass — rather than a paced
2678    /// incremental sweep).
2679    pub(crate) fn gc_mode_is_generational(&self) -> bool {
2680        self.gc_mode == "generational"
2681    }
2682
2683    /// Current `stepsize` pacing parameter (PUC: 0 means an unbounded step that
2684    /// completes a whole cycle at once).
2685    pub(crate) fn gc_stepsize(&self) -> i64 {
2686        self.gc_stepsize
2687    }
2688
2689    /// `collectgarbage("param", name [,value])`: read (or set, returning the
2690    /// previous value of) a pacing parameter. Returns `None` for an unknown
2691    /// name so the caller can raise PUC's `invalid parameter` error. The
2692    /// collector is stop-the-world, so these only round-trip for API fidelity.
2693    pub(crate) fn gc_param(&mut self, name: &[u8], set: Option<i64>) -> Option<i64> {
2694        let slot = match name {
2695            b"pause" => &mut self.gc_pause,
2696            b"stepmul" => &mut self.gc_stepmul,
2697            b"stepsize" => &mut self.gc_stepsize,
2698            _ => return None,
2699        };
2700        let prev = *slot;
2701        if let Some(v) = set {
2702            *slot = v;
2703        }
2704        Some(prev)
2705    }
2706
2707    /// Interpreter safe-point auto-GC: FULL incremental Propagate + adaptive
2708    /// paced sweep via `Vm::gc_step`.
2709    ///
2710    /// Round 1/2 of this attempt SIGABRT'd under coroutine + finalizer stress
2711    /// (suspected missed barrier). Round 3 (STW-mark + paced sweep) hung
2712    /// heavy.lua. With **born-black during Propagate** landed (@92b22b3) the
2713    /// suspected UAF is structurally closed — born objects no longer become
2714    /// dead-white at atomic flip — so Propagate is safe to re-enable here.
2715    ///
2716    /// Adaptive budget scales with heap size: 100M-object heap (heavy.lua's
2717    /// `loadrep` stress) gets a 25M-object budget so a cycle completes in
2718    /// O(SWEEP_DIVISOR) safe-points regardless of size.
2719    #[inline(always)]
2720    pub(crate) fn maybe_collect_garbage(&mut self, live_top: u32) {
2721        if self.gc_finalizing {
2722            return;
2723        }
2724        if !self.heap.gc_due() {
2725            return;
2726        }
2727        // v2.5 P1B-2E: tighten to bare `live_top`. The v2.2.0
2728        // `live_top.max(self.top)` workaround is now obsoleted by
2729        // v2.3's `finish_results` slot-clear + v2.5 P1B-2A
2730        // (Op::TailCall collapse slot-clear) + v2.5 P1B-2B
2731        // (pcall unwind slot-clear). PUC L->top discipline is now
2732        // mirrored at every frame-pop site.
2733        self.gc_top = live_top;
2734        // PUC stepmul: % of allocation rate. Higher = more GC work per
2735        // safe-point (lower memory, more CPU). Default 100 = `live / 4` per
2736        // step (~4 safe-points per cycle). stepmul=200 → `live / 2`, etc.
2737        const SWEEP_BASE: usize = 400; // 400 / stepmul=100 = divisor 4
2738        const MIN_BUDGET: usize = 64_000;
2739        let stepmul = self.gc_stepmul.max(1) as usize;
2740        let divisor = (SWEEP_BASE / stepmul).max(1);
2741        let budget = (self.heap.live_objects() / divisor).max(MIN_BUDGET);
2742        if self.gc_step(budget) {
2743            self.heap.rearm_gc_pause(self.gc_pause);
2744        }
2745    }
2746
2747    /// Enumerate the GC roots: first-class `Value` roots plus bare-object
2748    /// roots (open upvalues, which are not first-class Values). Shared by the
2749    /// full collector and the incremental-sweep driver so both snapshot the
2750    /// exact same live set.
2751    fn gc_roots(&self) -> (Vec<Value>, Vec<*mut GcHeader>) {
2752        let mut roots: Vec<Value> = Vec::with_capacity(self.stack.len() + 32);
2753        roots.push(Value::Table(self.globals));
2754        for mt in self.type_mt.into_iter().flatten() {
2755            roots.push(Value::Table(mt));
2756        }
2757        for &n in &self.mm_names {
2758            roots.push(Value::Str(n));
2759        }
2760        // Root the running thread's live registers (PUC marks [stack, top)).
2761        // `gc_top` is the instruction-level cursor of the last GC
2762        // safe-point: allocation safe-points set it via
2763        // `maybe_collect_garbage(live_top)`, and `begin_call` raises it
2764        // to the callee's argument top when entering a native — PUC's
2765        // `L->top = func + 1 + nargs` C-call discipline. Without that
2766        // raise, an explicit `collectgarbage()` collected with a STALE
2767        // cursor from some earlier (lower) safe-point and freed its own
2768        // caller's register-held strings — UAF-C
2769        // (STATUS_ACCESS_VIOLATION on Windows / ASAN heap-use-after-free
2770        // on Linux; the v2.13 WUC gc-verify frame audit pinpointed the
2771        // under-rooted slots). Values stranded above the cursor stay
2772        // excluded so weak-table entries are not spuriously pinned
2773        // (gc.lua:544 suspended-coroutine collection).
2774        let live = (self.gc_top as usize).min(self.stack.len());
2775        roots.extend_from_slice(&self.stack[..live]);
2776        for cf in &self.frames {
2777            match cf {
2778                CallFrame::Lua(f) => roots.push(Value::Closure(f.closure)),
2779                CallFrame::Cont(NativeCont {
2780                    kind: ContKind::Xpcall { handler },
2781                    ..
2782                }) => roots.push(*handler),
2783                CallFrame::Cont(NativeCont {
2784                    kind: ContKind::Close(cc),
2785                    ..
2786                }) => {
2787                    // Root the error threaded through this close chain so a
2788                    // `collectgarbage()` inside a sibling `__close` handler
2789                    // does not free it before the next handler is invoked
2790                    // (PUC L->ci->u.l.errfunc / the closing_err shadow).
2791                    if let Some(e) = cc.pending {
2792                        roots.push(e);
2793                    }
2794                    if let AfterClose::ResumeUnwind { err, .. } = cc.after {
2795                        roots.push(err);
2796                    }
2797                }
2798                CallFrame::Cont(_) => {}
2799            }
2800        }
2801        if let Some(e) = self.closing_err {
2802            roots.push(e);
2803        }
2804        // B12 host roots — Lua-facade handles keep their referenced
2805        // values alive across calls/yields. Trace the whole vector;
2806        // unused slots (post-`unpin_all`) carry Value::Nil which the
2807        // GC ignores.
2808        for slot in &self.host_roots {
2809            // v1.3 SR — free-list slots carry Value::Nil (GC no-op).
2810            roots.push(slot.value);
2811        }
2812        // v2.1 — `table.sort` and similar builtins stash their working
2813        // `Vec<Value>` here so a `collectgarbage()` invoked inside the
2814        // comparator callback doesn't free strings/tables snapshotted
2815        // off the live table (sort.lua's `load(..)(); collectgarbage()`
2816        // compare regression).
2817        for buf in &self.sort_scratch {
2818            roots.extend_from_slice(buf);
2819        }
2820        // v2.1 — the running-natives chain holds Gc<NativeClosure>s
2821        // mid-execution. Without rooting them here, a `collectgarbage()`
2822        // invoked inside the running native (sort.lua AA `load(..)();
2823        // collectgarbage()` compare callback regression) sweeps the
2824        // closure that's actively executing, leaving `nc.upvals`
2825        // dangling and the Rust local `nc` pointing at recycled memory
2826        // — the SIGSEGV pops on the very next field access or pop.
2827        for &nc in &self.running_natives {
2828            roots.push(Value::Native(nc));
2829        }
2830        // the running thread's debug hook (suspended threads root theirs via
2831        // Coro::trace / the main_ctx sweep below)
2832        if let Some(h) = self.hook.func {
2833            roots.push(h);
2834        }
2835        // the running coroutine (its saved-context fields live in the VM, but
2836        // the object itself + its resumer chain must stay reachable)
2837        if let Some(co) = self.current {
2838            roots.push(Value::Coro(co));
2839        }
2840        if let Some(mc) = self.main_coro {
2841            roots.push(Value::Coro(mc));
2842        }
2843        // debug.getregistry() and io library state
2844        if let Some(r) = self.registry {
2845            roots.push(Value::Table(r));
2846        }
2847        if let Some(mt) = self.file_mt {
2848            roots.push(Value::Table(mt));
2849        }
2850        if let Some(f) = self.io_input {
2851            roots.push(Value::Userdata(f));
2852        }
2853        if let Some(f) = self.io_output {
2854            roots.push(Value::Userdata(f));
2855        }
2856        // the main thread's saved context while a coroutine runs
2857        if let Some(m) = &self.main_ctx {
2858            roots.extend_from_slice(&m.stack);
2859            if let Some(h) = m.hook.func {
2860                roots.push(h);
2861            }
2862            for cf in &m.frames {
2863                match cf {
2864                    CallFrame::Lua(f) => roots.push(Value::Closure(f.closure)),
2865                    CallFrame::Cont(NativeCont {
2866                        kind: ContKind::Xpcall { handler },
2867                        ..
2868                    }) => roots.push(*handler),
2869                    CallFrame::Cont(_) => {}
2870                }
2871            }
2872        }
2873        let mut extra: Vec<*mut GcHeader> = self
2874            .open_upvals
2875            .iter()
2876            .map(|&(_, uv)| uv.as_ptr() as *mut GcHeader)
2877            .collect();
2878        if let Some(m) = &self.main_ctx {
2879            extra.extend(
2880                m.open_upvals
2881                    .iter()
2882                    .map(|&(_, uv)| uv.as_ptr() as *mut GcHeader),
2883            );
2884        }
2885        (roots, extra)
2886    }
2887
2888    /// Run a full collection with the VM's roots, then run any `__gc`
2889    /// finalizers the collection scheduled. A no-op (returns 0) when already
2890    /// inside a finalizer — the collector is not reentrant (PUC).
2891    pub fn collect_garbage(&mut self) -> usize {
2892        if self.gc_finalizing {
2893            return 0;
2894        }
2895        let (roots, extra) = self.gc_roots();
2896        let freed = self.heap.collect_ex(&roots, &extra);
2897        #[cfg(feature = "gc-verify")]
2898        self.verify_frame_regs_live("collect_garbage");
2899        self.run_finalizers();
2900        freed
2901    }
2902
2903    /// v2.13 WUC `gc-verify` — after a collect, every register slot the
2904    /// collector just rooted (`[0, max(gc_top, top))` — the same bound
2905    /// `gc_roots` uses) must hold a live value. A dead value inside the
2906    /// rooted range means the root snapshot and the sweep disagreed —
2907    /// the bug class behind UAF-C. (Slots ABOVE the bound may hold
2908    /// stale dead values legitimately; the interpreter's contract is
2909    /// that it writes them before reading.)
2910    #[cfg(feature = "gc-verify")]
2911    pub(crate) fn verify_frame_regs_live(&self, ctx: &str) {
2912        let live = self.heap.debug_live_set();
2913        let header = |v: Value| -> Option<usize> {
2914            match v {
2915                Value::Str(s) => Some(s.as_ptr() as usize),
2916                Value::Table(t) => Some(t.as_ptr() as usize),
2917                Value::Closure(c) => Some(c.as_ptr() as usize),
2918                Value::Native(n) => Some(n.as_ptr() as usize),
2919                Value::Coro(c) => Some(c.as_ptr() as usize),
2920                Value::Userdata(u) => Some(u.as_ptr() as usize),
2921                _ => None,
2922            }
2923        };
2924        let bound = (self.gc_top as usize).min(self.stack.len());
2925        for i in 0..bound {
2926            if let Some(h) = header(self.stack[i])
2927                && !live.contains(&h)
2928            {
2929                panic!(
2930                    "[gc-verify] {ctx}: rooted stack slot {i} (gc_top {}, top {}) \
2931                         holds a dead value {h:#x} after collect",
2932                    self.gc_top, self.top,
2933                );
2934            }
2935        }
2936        // Diagnostic tier: a dead value ABOVE the cursor is only a bug if
2937        // that register is a named local still in scope (the interpreter
2938        // WILL read it). Cross-check against the proto's LocVar table.
2939        for (fi, cf) in self.frames.iter().enumerate() {
2940            if let CallFrame::Lua(f) = cf {
2941                let base = f.base as usize;
2942                let maxs = f.closure.proto.max_stack as usize;
2943                let hi = (base + maxs).min(self.stack.len());
2944                let pc = f.pc;
2945                for i in bound.max(base)..hi {
2946                    if let Some(h) = header(self.stack[i])
2947                        && !live.contains(&h)
2948                    {
2949                        let reg = (i - base) as u32;
2950                        if let Some(lv) = f
2951                            .closure
2952                            .proto
2953                            .locvars
2954                            .iter()
2955                            .find(|lv| lv.reg == reg && lv.start_pc <= pc && pc < lv.end_pc)
2956                        {
2957                            panic!(
2958                                "[gc-verify] {ctx}: frame {fi} IN-SCOPE LOCAL '{}' \
2959                                     (reg {reg}, abs {i}, pc {pc}, gc_top {}) holds a \
2960                                     dead value {h:#x} — live_top cursor excluded a \
2961                                     live named local",
2962                                lv.name, self.gc_top,
2963                            );
2964                        }
2965                    }
2966                }
2967            }
2968        }
2969    }
2970
2971    /// PUC 5.1 `collectgarbage` re-raised the first error a `__gc` finalizer
2972    /// threw; gc.lua's "errors during collection" probe relies on it. This
2973    /// variant runs the same cycle but propagates the captured finalizer
2974    /// error to the explicit caller.
2975    pub(crate) fn collect_garbage_propagating(&mut self) -> Result<usize, LuaError> {
2976        if self.gc_finalizing {
2977            return Ok(0);
2978        }
2979        let (roots, extra) = self.gc_roots();
2980        let freed = self.heap.collect_ex(&roots, &extra);
2981        #[cfg(feature = "gc-verify")]
2982        self.verify_frame_regs_live("collect_garbage_propagating");
2983        self.run_finalizers_or_err()?;
2984        Ok(freed)
2985    }
2986
2987    /// Whether a `__gc` finalizer is currently running (so `collectgarbage`
2988    /// should report fail rather than collect).
2989    pub(crate) fn gc_is_finalizing(&self) -> bool {
2990        self.gc_finalizing
2991    }
2992
2993    /// PUC 5.4+ default warnf: emit one piece of a warning message. `to_cont`
2994    /// = true indicates more pieces follow (concatenated until the first
2995    /// `to_cont = false` call flushes the whole line). Mirrors
2996    /// `lauxlib.c::warnfon` + `warnfcont` + `checkcontrol`:
2997    ///   * If the buffer is fresh, `to_cont` is false, and the message is
2998    ///     `@<word>`, treat as a control message — only `@on` / `@off` are
2999    ///     recognised; any other `@…` is silently ignored.
3000    ///   * Otherwise, while the state is `Off`, drop the piece; while `On`,
3001    ///     accumulate, and flush to stderr + `warn_log` on the
3002    ///     non-continuation call.
3003    pub(crate) fn emit_warn(&mut self, msg: &[u8], to_cont: bool) {
3004        if self.warn_buf.is_empty()
3005            && !to_cont
3006            && let Some(b'@') = msg.first().copied()
3007        {
3008            match &msg[1..] {
3009                b"on" => self.warn_state = WarnState::On,
3010                b"off" => self.warn_state = WarnState::Off,
3011                _ => {} // unknown control — silently ignored (PUC checkcontrol)
3012            }
3013            return;
3014        }
3015        if self.warn_state == WarnState::Off {
3016            // drop continuation pieces too — PUC `warnfoff` is the trampoline
3017            return;
3018        }
3019        self.warn_buf.extend_from_slice(msg);
3020        if !to_cont {
3021            let line = std::mem::take(&mut self.warn_buf);
3022            eprintln!("Lua warning: {}", String::from_utf8_lossy(&line));
3023            self.warn_log.push(line);
3024        }
3025    }
3026
3027    /// Drain the in-process warning log (one entry per emitted message, sans
3028    /// `"Lua warning: "` prefix and newline). For test harnesses that want to
3029    /// assert on warn output without scraping stderr.
3030    pub fn warn_log_take(&mut self) -> Vec<Vec<u8>> {
3031        std::mem::take(&mut self.warn_log)
3032    }
3033
3034    /// Arm the cooperative instruction budget (P09 embedding). The run loop
3035    /// decrements this once per dispatch turn; on zero it raises a catchable
3036    /// `"instruction budget exceeded"` error and disarms itself so the host
3037    /// can resume with a fresh budget on the next call. `None` removes the
3038    /// cap. Pass `Some(n)` before `eval`/`call_value` for the embedder's
3039    /// short-script semantics.
3040    pub fn set_instr_budget(&mut self, budget: Option<i64>) {
3041        self.instr_budget = budget;
3042    }
3043
3044    /// Remaining instruction budget (None when unbounded).
3045    pub fn instr_budget_remaining(&self) -> Option<i64> {
3046        self.instr_budget
3047    }
3048
3049    /// Toggle the cranelift JIT (P11). Default `true`. Sandbox embedders
3050    /// **must** disable JIT when relying on `instr_budget` — see the
3051    /// `jit_enabled` field doc for the rationale.
3052    pub fn set_jit_enabled(&mut self, enabled: bool) {
3053        self.jit.enabled = enabled;
3054    }
3055
3056    /// Current JIT enable state.
3057    pub fn jit_enabled(&self) -> bool {
3058        self.jit.enabled
3059    }
3060
3061    /// Toggle the trace JIT (P12). Off by default while the sprint
3062    /// develops. When enabled, hot back-edges are counted on
3063    /// `Proto.trace_hot_count`; once the counter passes
3064    /// `TRACE_HOT_THRESHOLD`, the dispatch loop enters recording
3065    /// mode at the back-edge target. Stays a no-op until S2's
3066    /// trace lowerer and S3's dispatcher land.
3067    pub fn set_trace_jit_enabled(&mut self, enabled: bool) {
3068        self.jit.trace_enabled = enabled;
3069    }
3070
3071    /// P16-A — opt-in flag for the self-link cycle catch. See field
3072    /// docs for the correctness blocker. Default `false`.
3073    pub fn set_p16_self_link_enabled(&mut self, enabled: bool) {
3074        self.jit.p16_self_link_enabled = enabled;
3075    }
3076
3077    /// Current state of the P16-A self-link cycle catch.
3078    pub fn p16_self_link_enabled(&self) -> bool {
3079        self.jit.p16_self_link_enabled
3080    }
3081
3082    /// Current trace-JIT enable state.
3083    pub fn trace_jit_enabled(&self) -> bool {
3084        self.jit.trace_enabled
3085    }
3086
3087    /// Number of traces that have closed cleanly (looped back to the
3088    /// head PC) since this Vm was constructed. Cumulative; used by
3089    /// tests + tuning. Will become the dominant signal once S2's
3090    /// compile + cache lands.
3091    pub fn trace_closed_count(&self) -> u64 {
3092        self.jit.counters.closed
3093    }
3094
3095    /// Number of traces that have aborted (exceeded MAX_TRACE_LEN or
3096    /// hit an un-recordable op — the latter lands at S2).
3097    pub fn trace_aborted_count(&self) -> u64 {
3098        self.jit.counters.aborted
3099    }
3100
3101    /// P13-S13-G v2 — number of compiled traces whose close shape
3102    /// is `TraceEnd::InlineAbort` (depth>0 boundary). Such traces
3103    /// pin `dispatchable=false` because the dispatcher can't
3104    /// resume at a depth>0 PC without the matching CallFrames.
3105    /// S4-step4b's frame-mat helper could synthesise those, but
3106    /// the InlineAbort emit path isn't wired up yet — fresh
3107    /// pickup work for S13-G v2-full.
3108    pub fn trace_inline_abort_count(&self) -> u64 {
3109        self.jit.counters.inline_abort
3110    }
3111
3112    /// P13-S13-G v2.5 — see `JitCounters::dispatch_off_reasons`.
3113    pub fn trace_dispatch_off_reasons(&self) -> &[&'static str] {
3114        &self.jit.counters.dispatch_off_reasons
3115    }
3116
3117    /// P13-S13-G v2.6 — see `JitCounters::compile_failed_reasons`.
3118    pub fn trace_compile_failed_reasons(&self) -> &[&'static str] {
3119        &self.jit.counters.compile_failed_reasons
3120    }
3121
3122    /// P13-S13-H — see `JitCounters::closed_lens`. Returns
3123    /// `(is_call_triggered, ops_len)` for every trace that closed.
3124    pub fn trace_closed_lens(&self) -> &[(bool, usize)] {
3125        &self.jit.counters.closed_lens
3126    }
3127
3128    /// v2.0 Track-R R2 — see [`crate::vm::jit_state::JitCounters::close_cause_counts`].
3129    /// Per-reason close-cause counts (recorder-side abort/discard +
3130    /// lowerer-side dispatch_off labels) keyed by `&'static str`.
3131    pub fn trace_close_cause_counts(&self) -> &std::collections::HashMap<&'static str, u64> {
3132        &self.jit.counters.close_cause_counts
3133    }
3134
3135    /// v2.0 Track-R R3b — number of compiled traces whose
3136    /// `CompiledTrace.downrec_link` is `Some(_)` (lowerer's
3137    /// `downrec_idx_opt` arm emitted the stitch sentinel + caller-pc
3138    /// guard scaffold). R3b regression pin checks `>= 1` on a fib(3)
3139    /// hot loop with p16-on. R3b keeps `dispatchable = false` even
3140    /// when this count bumps; R3d will lift it.
3141    pub fn trace_downrec_link_compiled_count(&self) -> u64 {
3142        self.jit.counters.downrec_link_compiled
3143    }
3144
3145    /// v2.0 Track-R R3c — see
3146    /// [`crate::vm::jit_state::JitCounters::downrec_dispatched`]. Number
3147    /// of times the dispatcher's `is_downrec_sentinel` arm fired and
3148    /// classified the return as a caller-pc-guard HIT.
3149    pub fn trace_downrec_dispatched_count(&self) -> u64 {
3150        self.jit.counters.downrec_dispatched
3151    }
3152
3153    /// v2.0 Track-R R3c — see
3154    /// [`crate::vm::jit_state::JitCounters::downrec_deopt`]. Number of
3155    /// times the dispatcher entered a `downrec_link`-bearing trace and
3156    /// the trace returned via the lowerer's deopt block (caller-pc
3157    /// guard MISS), or the dispatcher itself force-deopted via the
3158    /// stitch-cycle checkpoint.
3159    pub fn trace_downrec_deopt_count(&self) -> u64 {
3160        self.jit.counters.downrec_deopt
3161    }
3162
3163    /// v2.0 Track-R R3d — see
3164    /// [`crate::vm::jit_state::JitCounters::multi_way_guard_emitted`].
3165    /// Number of compiled traces whose lowerer emitted a multi-way
3166    /// caller-pc guard chain (>= 2 distinct `caller_pc` candidates)
3167    /// at the `TraceEnd::DownRec` close + lifted `dispatchable = true`.
3168    pub fn trace_multi_way_guard_emitted_count(&self) -> u64 {
3169        self.jit.counters.multi_way_guard_emitted
3170    }
3171
3172    /// P12-S2.C — number of closed traces the lowerer compiled and
3173    /// parked on `Proto.traces`. Re-records of the same head_pc are
3174    /// deduped (the second close finds the head_pc already cached
3175    /// and skips compile), so this never exceeds `trace_closed_count`.
3176    pub fn trace_compiled_count(&self) -> u64 {
3177        self.jit.counters.compiled
3178    }
3179
3180    /// v2.1 Phase 1I.B — number of times the recorder captured a
3181    /// [`crate::jit::trace_types::FieldIcSnapshot`] under
3182    /// `LUNA_JIT_FIELD_IC=1`. Stays 0 on the env-default path. Used
3183    /// by the Phase 1I.B opt-in fire test to verify the env gate
3184    /// wiring round-trips end-to-end (env -> recorder -> snapshot
3185    /// -> counter -> getter -> assertion).
3186    pub fn trace_field_ic_snapshot_count(&self) -> u64 {
3187        self.jit.counters.field_ic_snapshot_captured
3188    }
3189
3190    /// P12-S2.C — number of closed traces the lowerer rejected
3191    /// (any of the bail conditions in
3192    /// `crate::jit::trace::try_compile_trace`).
3193    pub fn trace_compile_failed_count(&self) -> u64 {
3194        self.jit.counters.compile_failed
3195    }
3196
3197    /// P12-S3 — number of times the dispatcher jumped into a
3198    /// compiled trace. Bumps on every entry; `trace_deopt_count`
3199    /// counts the subset where the trace returned with a parked
3200    /// `jit_pending_err`.
3201    pub fn trace_dispatched_count(&self) -> u64 {
3202        self.jit.counters.dispatched
3203    }
3204
3205    /// P12-S3 — number of trace entries that came back with
3206    /// `jit_pending_err` set (typically a metatable shadowed an
3207    /// index inside a helper, forcing the dispatcher to fall back
3208    /// to the interpreter without committing the trace's result).
3209    pub fn trace_deopt_count(&self) -> u64 {
3210        self.jit.counters.deopt
3211    }
3212
3213    /// P15-A v1 — number of times the dispatcher started a side
3214    /// trace recording (an `exit_hit_counts` slot crossed
3215    /// [`crate::jit::trace::HOTEXIT_THRESHOLD`] while `active_trace`
3216    /// was None and trace JIT was enabled). Each unit is exactly one
3217    /// `start_side_trace` call; the actual compile success counts
3218    /// under [`Self::trace_compiled_count`] like any other trace.
3219    /// Probe use: distinguishes the "side-trace pipeline fired"
3220    /// signal from the "primary back-edge / call-trigger fired"
3221    /// signal so v0-v3 architectural progress is visible without
3222    /// reading per-counter histograms.
3223    pub fn trace_side_trace_started_count(&self) -> u64 {
3224        self.jit.counters.side_trace_started
3225    }
3226
3227    /// P15-A v2-A — number of side-trace recordings that closed,
3228    /// compiled successfully, AND patched their parent's
3229    /// `exit_side_trace_ptrs[exit_idx]`. The parent's IR doesn't
3230    /// dispatch through these ptrs yet (v2-B/C job), but the
3231    /// counter + ptr write proves the compile + link pipeline is
3232    /// complete end-to-end.
3233    pub fn trace_side_trace_compiled_count(&self) -> u64 {
3234        self.jit.counters.side_trace_compiled
3235    }
3236
3237    /// P15-A v2-C-A5-C — number of side traces that compiled
3238    /// successfully but were SHEDDED by the close-handler shape-
3239    /// match gate (`exit_tags_match_entry_tags`). High ratios
3240    /// vs. `trace_side_trace_compiled_count` indicate the
3241    /// architecture is shedding lots of would-be side traces;
3242    /// useful as a tuning probe for future relaxation of the
3243    /// gate or for child-IR re-specialisation against parent's
3244    /// exit shape.
3245    pub fn trace_side_trace_shape_mismatch_count(&self) -> u64 {
3246        self.jit.counters.side_trace_shape_mismatch
3247    }
3248
3249    /// P12-S5-A — sum of NewTable sites the pre-emit escape sweep
3250    /// classified as `crate::jit::trace::EscapeState::Sinkable`
3251    /// across every successfully compiled trace on this Vm. The
3252    /// count is post-demotion: sites pre-emit drops back to Escaped
3253    /// for not meeting v1 sunk-emit criteria are NOT counted.
3254    /// `trace_sunk_alloc_count` matches one-for-one today (every
3255    /// surviving Sinkable site goes through sunk emit).
3256    pub fn trace_sinkable_seen_count(&self) -> u64 {
3257        self.jit.counters.sinkable_seen
3258    }
3259
3260    /// P14-S14-B v1 — see `JitCounters::accum_bufferable_seen`.
3261    pub fn trace_accum_bufferable_seen_count(&self) -> u64 {
3262        self.jit.counters.accum_bufferable_seen
3263    }
3264
3265    /// P15-prep — total dispatch hits across all known traces,
3266    /// broken into hot-exit telemetry (max single-exit count,
3267    /// total dispatches, exit count). Used by probes to identify
3268    /// hot side-exits as side-trace candidates.
3269    ///
3270    /// Walks `cl.proto` AND all nested protos in `cl.proto.protos`
3271    /// recursively, so inner functions' traces are reported.
3272    pub fn trace_exit_hit_summary(
3273        &self,
3274        cl: crate::runtime::heap::Gc<crate::runtime::function::LuaClosure>,
3275    ) -> Vec<(u32, Vec<u32>)> {
3276        fn walk(
3277            proto: crate::runtime::heap::Gc<crate::runtime::function::Proto>,
3278            out: &mut Vec<(u32, Vec<u32>)>,
3279        ) {
3280            for ct in proto.traces.borrow().iter() {
3281                let counts: Vec<u32> = ct.exit_hit_counts.iter().map(|c| c.get()).collect();
3282                out.push((ct.head_pc, counts));
3283            }
3284            for inner in proto.protos.iter() {
3285                walk(*inner, out);
3286            }
3287        }
3288        let mut out: Vec<(u32, Vec<u32>)> = Vec::new();
3289        walk(cl.proto, &mut out);
3290        out
3291    }
3292
3293    /// P15-A v0 — surface every side-exit slot whose hit count is
3294    /// `>= HOTEXIT_THRESHOLD` across every trace reachable from
3295    /// `cl.proto` (recursively walking `proto.protos`). Returned
3296    /// entries are side-trace candidates: each carries the parent
3297    /// trace's `(head_proto, head_pc)`, the exit's index in the
3298    /// parent's `exit_hit_counts`, and the side trace's natural
3299    /// entry shape (`cont_pc` + `exit_tags`).
3300    ///
3301    /// Layout of `exit_hit_counts` (mirrored by the iter):
3302    /// - `[0..per_exit_inline.len())` → `InlineSideExit` (cont_pc +
3303    ///   window-sized exit_tags).
3304    /// - `[per_exit_inline.len()..inline.len() + per_exit_tags.len())`
3305    ///   → `per_exit_tags[i]` (per-cont_pc caller-window tags).
3306    /// - Last slot → global clean-tail (cont_pc = `head_pc`,
3307    ///   exit_tags = `ct.exit_tags`).
3308    pub fn hot_exit_iter(
3309        &self,
3310        cl: crate::runtime::heap::Gc<crate::runtime::function::LuaClosure>,
3311    ) -> Vec<crate::jit::trace::HotExitInfo> {
3312        use crate::jit::trace::{HOTEXIT_THRESHOLD, HotExitInfo};
3313        fn walk(
3314            proto: crate::runtime::heap::Gc<crate::runtime::function::Proto>,
3315            out: &mut Vec<HotExitInfo>,
3316        ) {
3317            for ct in proto.traces.borrow().iter() {
3318                let inline_n = ct.per_exit_inline.len();
3319                let tags_n = ct.per_exit_tags.len();
3320                debug_assert_eq!(
3321                    ct.exit_hit_counts.len(),
3322                    inline_n + tags_n + 1,
3323                    "exit_hit_counts layout invariant violated"
3324                );
3325                for (idx, cell) in ct.exit_hit_counts.iter().enumerate() {
3326                    let hits = cell.get();
3327                    if hits < HOTEXIT_THRESHOLD {
3328                        continue;
3329                    }
3330                    let (cont_pc, exit_tags) = if idx < inline_n {
3331                        let ent = &ct.per_exit_inline[idx];
3332                        (ent.cont_pc, ent.exit_tags.clone())
3333                    } else if idx < inline_n + tags_n {
3334                        let (pc, tags) = &ct.per_exit_tags[idx - inline_n];
3335                        (*pc, tags.clone())
3336                    } else {
3337                        (ct.head_pc, ct.exit_tags.clone())
3338                    };
3339                    out.push(HotExitInfo {
3340                        head_proto: proto,
3341                        head_pc: ct.head_pc,
3342                        exit_idx: idx,
3343                        hits,
3344                        cont_pc,
3345                        exit_tags,
3346                    });
3347                }
3348            }
3349            for inner in proto.protos.iter() {
3350                walk(*inner, out);
3351            }
3352        }
3353        let mut out: Vec<HotExitInfo> = Vec::new();
3354        walk(cl.proto, &mut out);
3355        out
3356    }
3357
3358    /// P12-S5-B — sum of NewTable sites that actually took the
3359    /// sunk-emit path across every successfully compiled trace on
3360    /// this Vm. Each counted site skips its heap `Gc<Table>`
3361    /// allocation per dispatch; the array part lives as Cranelift
3362    /// `Variable`s for the duration of the trace.
3363    pub fn trace_sunk_alloc_count(&self) -> u64 {
3364        self.jit.counters.sunk_alloc
3365    }
3366
3367    /// P12-S5-C — sum of materialise-helper emit sites across every
3368    /// successfully compiled trace on this Vm. Each unit is a
3369    /// (site × cmp side-exit) pair whose IR reconstructs a heap
3370    /// `Gc<Table>` from the virt slots on deopt — proves S5-C
3371    /// emit is wiring materialise into the right side-exits.
3372    pub fn trace_materialize_emit_count(&self) -> u64 {
3373        self.jit.counters.materialize_emit
3374    }
3375
3376    /// P12-S7-A diagnostic — total `Op::Closure` ops the trace JIT
3377    /// lowered to the `luna_jit_op_closure` helper. Each emitted op
3378    /// replaces a `Heap::new_closure_inline` call on the dispatch
3379    /// path; the count is static (one per matching op per compiled
3380    /// trace), summed at compile success.
3381    pub fn trace_closure_emit_count(&self) -> u64 {
3382        self.jit.counters.closure_emit
3383    }
3384
3385    /// v2.0 Stage 7 polish 6 fire experiment — see
3386    /// [`crate::vm::jit_state::JitCounters::per_exit_inline_compiled`].
3387    /// Number of compiled traces whose `per_exit_inline.len() > 0`
3388    /// (depth>0 inlined cmp side-exits emitted).
3389    pub fn trace_per_exit_inline_compiled_count(&self) -> u64 {
3390        self.jit.counters.per_exit_inline_compiled
3391    }
3392
3393    /// v2.0 Stage 7 polish 6 fire experiment — see
3394    /// [`crate::vm::jit_state::JitCounters::per_exit_inline_dispatchable`].
3395    /// Number of compiled traces with `per_exit_inline.len() > 0` AND
3396    /// `dispatchable == true` — i.e. the count of compiled traces
3397    /// that would actually exercise the AOT polish 6 chain-reloc +
3398    /// deploy-resolver path.
3399    pub fn trace_per_exit_inline_dispatchable_count(&self) -> u64 {
3400        self.jit.counters.per_exit_inline_dispatchable
3401    }
3402
3403    /// P12-S4-step1 diagnostic — max `inline_depth` ever seen on any
3404    /// `RecordedOp` pushed by the recorder. Tells tests + tuning
3405    /// whether a self-recursive function actually walked the depth
3406    /// tracker past 0. Saturates at `MAX_INLINE_DEPTH`. Persists
3407    /// across traces and Vm activations; reset only on `Vm::new`.
3408    pub fn trace_max_depth_seen(&self) -> u8 {
3409        self.jit.max_depth_seen
3410    }
3411
3412    /// P12-S4-step4b — last live Lua frame (the trace head's frame at
3413    /// dispatch time). The frame-materialization helper reads `.base`
3414    /// to compute offsets for each inlined frame's window.
3415    #[doc(hidden)]
3416    pub fn jit_last_lua_frame(&self) -> Option<Frame> {
3417        match self.frames.last() {
3418            Some(CallFrame::Lua(f)) => Some(*f),
3419            _ => None,
3420        }
3421    }
3422
3423    /// v2.0 Track TL Phase 2 — read-only borrow of the current call
3424    /// stack, for the [`crate::vm::inspect`] pure-read accessors used
3425    /// by `luna-tools` (`luna-profile`'s sampler walks this from
3426    /// inside a `Count` hook). Sibling-module scope: not part of the
3427    /// public embedder surface, but `inspect::frames_for_profile` is.
3428    #[doc(hidden)]
3429    pub(super) fn inspect_frames(&self) -> &[CallFrame] {
3430        &self.frames
3431    }
3432
3433    /// P12-S4-step4b — ensure the value stack covers indices
3434    /// `[0..need)`. Extends with Nil if shorter. Called by the
3435    /// frame-materialization helper before pushing an inlined frame
3436    /// whose register window may exceed the current stack length.
3437    #[doc(hidden)]
3438    pub fn jit_ensure_stack(&mut self, need: usize) {
3439        if self.stack.len() < need {
3440            self.stack.resize(need, Value::Nil);
3441        }
3442    }
3443
3444    /// P12-S7-C — trace JIT path for `Op::Close A`. Predicts whether
3445    /// `__close` handlers would run (any active tbc slot ≥ from
3446    /// holding a non-nil/false Value); if so, parks a deopt sentinel
3447    /// in `jit_pending_err` and returns 1 (helper-side bool) so the
3448    /// IR branches to the deopt block. Otherwise performs the safe
3449    /// part of close — `close_from(from)` to close open upvals +
3450    /// drop any drained tbc entries ≥ from — and returns 0.
3451    ///
3452    /// Returns are i64-shaped so the cranelift import sig stays
3453    /// trivial (i64 → i64 mapping).
3454    #[doc(hidden)]
3455    pub fn jit_op_close(&mut self, start_offset: u32) -> i64 {
3456        if self.jit.pending_err.is_some() {
3457            return 1;
3458        }
3459        let Some(f) = self.jit_last_lua_frame() else {
3460            self.jit.pending_err = Some(self.rt_err("JIT op_close: no Lua frame"));
3461            return 1;
3462        };
3463        let from = f.base + start_offset;
3464        let has_handler = self.tbc.iter().any(|&s| {
3465            s >= from && {
3466                let v = self.stack[s as usize];
3467                !matches!(v, Value::Nil | Value::Bool(false))
3468            }
3469        });
3470        if has_handler {
3471            self.jit.pending_err =
3472                Some(self.rt_err("JIT deopt: Op::Close with active tbc handler"));
3473            return 1;
3474        }
3475        self.close_from(from);
3476        // Drain any tbc entries ≥ from (they're nil/false stubs the
3477        // interpreter's drive_close would have skipped silently).
3478        while let Some(&s) = self.tbc.last() {
3479            if s < from {
3480                break;
3481            }
3482            self.tbc.pop();
3483        }
3484        0
3485    }
3486
3487    /// P12-S7-B — spill the trace's current value for a register to
3488    /// the underlying `vm.stack[base + slot_offset]`. Required before
3489    /// an `Op::Closure` whose inner proto has an `in_stack: true`
3490    /// upval at `slot_offset` — the helper's `find_or_create_upval`
3491    /// captures a live pointer to `vm.stack[base + slot_offset]`,
3492    /// which must hold the right value at call time (trace IR's
3493    /// Variable hasn't yet been written back).
3494    ///
3495    /// Parameters arrive as i64 from the IR: `slot_offset` is the
3496    /// caller-frame register index (`u32` in practice, depth=0
3497    /// only — S7-B doesn't support depth>0 Closure); `tag` is the
3498    /// `crate::runtime::value::raw` byte for the slot's RegKind;
3499    /// `raw_bits` is the trace Variable's `use_var` payload
3500    /// (i64-shaped — Float is its bit-pattern, Table/Closure is the
3501    /// raw `Gc::as_ptr` cast).
3502    #[doc(hidden)]
3503    pub fn jit_spill_stack(&mut self, slot_offset: u32, tag: u8, raw_bits: u64) {
3504        let Some(f) = self.jit_last_lua_frame() else {
3505            self.jit.pending_err =
3506                Some(self.rt_err("JIT spill: no Lua frame on jit_last_lua_frame()"));
3507            return;
3508        };
3509        let idx = (f.base as usize) + (slot_offset as usize);
3510        if self.stack.len() <= idx {
3511            self.stack.resize(idx + 1, Value::Nil);
3512        }
3513        // SAFETY: caller (trace JIT IR emit) provides matching
3514        // `(tag, raw_bits)` — same shape produced by Value::unpack.
3515        let v = unsafe {
3516            crate::runtime::Value::pack(tag, crate::runtime::value::RawVal { zero: raw_bits })
3517        };
3518        self.stack[idx] = v;
3519    }
3520
3521    /// P12-S12-B-v2 — trace JIT path for `Op::TForCall A 0 C`.
3522    /// Mirrors the interp arm (this file ~L5316): copies the
3523    /// generator/state/control triple from `R[A..=A+2]` to
3524    /// `R[A+4..=A+6]` (resizing the stack if needed), then enters
3525    /// the iterator function via `begin_call`. v2 only handles
3526    /// `Value::Native` iterators (the canonical `ipairs_iter` /
3527    /// `next` builtins) — a Lua-closure iterator would push a Lua
3528    /// frame mid-trace, breaking `recording_frame_base`, so we
3529    /// deopt by parking a `pending_err` and returning `-1`.
3530    ///
3531    /// `slot_offset` is the caller-frame register index (=
3532    /// `inst.a()` decoded from a u32-wide field). `nvars` is
3533    /// `inst.c() as i32` — the caller's expected return count.
3534    /// P12-S12-C v1 — refresh only the raw payload of
3535    /// `vm.stack[base + slot_offset]`, preserving its existing
3536    /// `Value` tag. The caller (trace JIT Op::Concat body emit)
3537    /// uses this when the slot's `RegKind` is `Unset` (no compile-
3538    /// time tag info; commonly `Str` slots which the trace doesn't
3539    /// model). The interp's previous execution of the same op
3540    /// already populated the slot with the right tag — the trace
3541    /// only needs to swap in its current raw value.
3542    #[doc(hidden)]
3543    pub fn jit_stack_update_raw(&mut self, slot_offset: u32, raw_bits: u64) {
3544        let Some(f) = self.jit_last_lua_frame() else {
3545            return;
3546        };
3547        let idx = (f.base as usize) + (slot_offset as usize);
3548        if idx >= self.stack.len() {
3549            return;
3550        }
3551        let (tag, _) = self.stack[idx].unpack();
3552        // 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).
3553        self.stack[idx] = unsafe {
3554            crate::runtime::Value::pack(tag, crate::runtime::value::RawVal { zero: raw_bits })
3555        };
3556    }
3557
3558    /// P12-S12-C v1 — trace JIT path for `Op::Concat A B`.
3559    ///
3560    /// Mirrors the interp arm (this file ~L5112): `self.top =
3561    /// base + a + n; concat_run(base + a)`. Result lands at
3562    /// `vm.stack[base + a]`. Returns `0` on success, `-1` on
3563    /// deopt (any error from `concat_run` OR detection that the
3564    /// metamethod path was taken — `concat_run` returns `Ok(())`
3565    /// after `begin_meta_call` which has pushed a Lua frame the
3566    /// trace can't safely continue past).
3567    ///
3568    /// The frame-push detection uses `pre/post frames.len()` and
3569    /// unwinds any pushed frames before deopting, so the
3570    /// dispatcher's existing deopt path sees a clean stack.
3571    #[doc(hidden)]
3572    pub fn jit_op_concat(&mut self, slot_offset: u32, n: i32) -> i64 {
3573        if self.jit.pending_err.is_some() {
3574            return -1;
3575        }
3576        let Some(f) = self.jit_last_lua_frame() else {
3577            self.jit.pending_err = Some(self.rt_err("JIT Concat: no Lua frame"));
3578            return -1;
3579        };
3580        let abs_a = f.base + slot_offset;
3581        self.top = abs_a + n as u32;
3582        let pre_frames = self.frames.len();
3583        let result = self.concat_run(abs_a);
3584        let post_frames = self.frames.len();
3585        // Frame-push = metamethod path taken (begin_meta_call pushed
3586        // a Lua frame). The trace can't continue past it; unwind +
3587        // deopt so interp redoes Op::Concat in the slow path.
3588        while self.frames.len() > pre_frames {
3589            frames_pop_sync(&mut self.frames, &mut self.frames_top);
3590        }
3591        if let Err(e) = result {
3592            self.jit.pending_err = Some(e);
3593            return -1;
3594        }
3595        if post_frames > pre_frames {
3596            self.jit.pending_err = Some(self.rt_err("JIT Concat: __concat metamethod path"));
3597            return -1;
3598        }
3599        0
3600    }
3601
3602    /// P14-S14-B v2 — pop a reusable `Vec<u8>` from the JIT
3603    /// accumulator buffer pool, returning a raw pointer. The trace
3604    /// fn's IR holds this pointer in a stack slot through the loop
3605    /// and calls `jit_str_buf_extend` per iter. If the pool is
3606    /// empty, allocate fresh.
3607    ///
3608    /// Safety: the returned pointer is valid until
3609    /// `jit_str_buf_release` is called or the Vm is dropped. The
3610    /// caller MUST not retain it across `enter_jit` boundaries.
3611    #[doc(hidden)]
3612    pub fn jit_str_buf_acquire(&mut self) -> *mut Vec<u8> {
3613        let buf = self.jit.str_buf_pool.pop().unwrap_or_default();
3614        // Move into a Box so the pointer is stable until release.
3615        Box::into_raw(Box::new(buf))
3616    }
3617
3618    /// P14-S14-B v2 — return a previously-acquired buffer to the
3619    /// pool, dropping any excess past `jit_str_buf_pool_cap`. The
3620    /// buffer is `clear`ed (capacity retained) so the next acquire
3621    /// gets a ready-to-extend Vec.
3622    ///
3623    /// Safety: `buf` must have been returned by a prior
3624    /// `jit_str_buf_acquire` on the same Vm.
3625    #[doc(hidden)]
3626    #[allow(clippy::not_unsafe_ptr_arg_deref)] // JIT helper: `buf` round-trips through `Box::into_raw`; SAFETY documented below.
3627    pub fn jit_str_buf_release(&mut self, buf: *mut Vec<u8>) {
3628        if buf.is_null() {
3629            return;
3630        }
3631        // 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.
3632        let mut owned = unsafe { Box::from_raw(buf) };
3633        owned.clear();
3634        if self.jit.str_buf_pool.len() < self.jit.str_buf_pool_cap {
3635            self.jit.str_buf_pool.push(*owned);
3636        }
3637        // Else: drop the buffer.
3638    }
3639
3640    /// P14-S14-B v2 — append a LuaStr's bytes to the accumulator
3641    /// buffer. The trace IR computes the `str_ptr` (= raw bits of
3642    /// the piece slot) and passes it through; we treat it as a
3643    /// `*mut LuaStr` and append its bytes.
3644    ///
3645    /// Returns 0 on success, -1 if the piece isn't a Str (would
3646    /// trip __concat metamethod path → deopt to interp).
3647    ///
3648    /// Safety: `buf` from prior `acquire`; `str_ptr` from the
3649    /// trace's piece slot raw bits.
3650    #[doc(hidden)]
3651    #[allow(clippy::not_unsafe_ptr_arg_deref)] // JIT helper: `buf` from prior `acquire`; `str_ptr` from trace piece slot; SAFETY documented below.
3652    pub fn jit_str_buf_extend(&mut self, buf: *mut Vec<u8>, str_ptr: i64) -> i64 {
3653        if buf.is_null() || str_ptr == 0 {
3654            return -1;
3655        }
3656        // 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).
3657        let buf = unsafe { &mut *buf };
3658        let lua_str_ptr = str_ptr as *const crate::runtime::string::LuaStr;
3659        // 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).
3660        let bytes = unsafe { crate::runtime::string::bytes_of(lua_str_ptr) };
3661        buf.extend_from_slice(bytes);
3662        0
3663    }
3664
3665    /// P14-S14-B v2 — drain the accumulator buffer into a fresh
3666    /// `LuaStr` via `heap.intern`, returning the raw ptr bits for
3667    /// the trace to write into the accumulator slot.
3668    ///
3669    /// Returns the LuaStr ptr as i64 on success, 0 on overflow
3670    /// (the v2 hard cap; the trace deopts).
3671    ///
3672    /// Safety: `buf` from prior `acquire`. The buffer is left
3673    /// CLEAR (drained) ready for `release`.
3674    #[doc(hidden)]
3675    #[allow(clippy::not_unsafe_ptr_arg_deref)] // JIT helper: `buf` from prior `acquire`; SAFETY documented below.
3676    pub fn jit_str_buf_intern(&mut self, buf: *mut Vec<u8>) -> i64 {
3677        if buf.is_null() {
3678            return 0;
3679        }
3680        // 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).
3681        let buf = unsafe { &mut *buf };
3682        let bytes = std::mem::take(buf);
3683        // v2 hard cap at 256KB per RFC Q3.
3684        if bytes.len() > 256 * 1024 {
3685            return 0;
3686        }
3687        let gc = self.heap.intern(&bytes);
3688        gc.as_ptr() as i64
3689    }
3690
3691    /// P12-S12-B v2/v3/v4 — trace JIT helper for `Op::TForCall A 0 C`.
3692    ///
3693    /// v2 base: copy R[A..=A+2] → R[A+4..=A+6] + `begin_call`.
3694    /// v3: ipairs `inext` fast path at the top — skip begin_call
3695    ///     when R[A]=Native(ipairs_iter), R[A+1]=Table no-mt,
3696    ///     R[A+2]=Int.
3697    /// v4: batched out-ptr writeback — fill ctrl/key/val raws into
3698    ///     caller-provided buffers + return R[A+4]'s tag byte. Lets
3699    ///     emit skip 3 separate `luna_jit_stack_load` calls and 1
3700    ///     `luna_jit_stack_tag` call by reading the buffer via
3701    ///     cranelift `stack_load` IR instead. Returns -1 on deopt.
3702    #[doc(hidden)]
3703    #[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.
3704    pub fn jit_op_tforcall(
3705        &mut self,
3706        slot_offset: u32,
3707        nvars: i32,
3708        ctrl_out: *mut i64,
3709        key_out: *mut i64,
3710        val_out: *mut i64,
3711    ) -> i64 {
3712        if self.jit.pending_err.is_some() {
3713            return -1;
3714        }
3715        let Some(f) = self.jit_last_lua_frame() else {
3716            self.jit.pending_err = Some(self.rt_err("JIT TForCall: no Lua frame"));
3717            return -1;
3718        };
3719        let abs = f.base + slot_offset;
3720        let need = (abs + 7) as usize;
3721        if self.stack.len() < need {
3722            self.stack.resize(need, Value::Nil);
3723        }
3724        // v3 fast path.
3725        let took_fast_path = if let Value::Native(n) = self.stack[abs as usize]
3726            && std::ptr::fn_addr_eq(
3727                n.f,
3728                crate::vm::builtins::ipairs_iter as crate::runtime::value::NativeFn,
3729            )
3730            && let Value::Table(t) = self.stack[(abs + 1) as usize]
3731            && t.metatable().is_none()
3732            && let Value::Int(i) = self.stack[(abs + 2) as usize]
3733        {
3734            let next_i = i.wrapping_add(1);
3735            let v = t.get_int(next_i);
3736            if v.is_nil() {
3737                self.stack[(abs + 4) as usize] = Value::Nil;
3738            } else {
3739                self.stack[(abs + 4) as usize] = Value::Int(next_i);
3740                if (nvars as usize) >= 2 {
3741                    self.stack[(abs + 5) as usize] = v;
3742                }
3743                for j in 2..nvars as usize {
3744                    let slot = abs + 4 + j as u32;
3745                    if (slot as usize) < self.stack.len() {
3746                        self.stack[slot as usize] = Value::Nil;
3747                    }
3748                }
3749            }
3750            true
3751        } else {
3752            false
3753        };
3754        if !took_fast_path {
3755            // v2 slow path: copy R[A..=A+2] → R[A+4..=A+6], then
3756            // route through begin_call. Lua-closure iters would push
3757            // a Lua frame mid-trace → deopt.
3758            self.stack[(abs + 4) as usize] = self.stack[abs as usize];
3759            self.stack[(abs + 5) as usize] = self.stack[(abs + 1) as usize];
3760            self.stack[(abs + 6) as usize] = self.stack[(abs + 2) as usize];
3761            if !matches!(self.stack[abs as usize], Value::Native(_)) {
3762                self.jit.pending_err = Some(self.rt_err("JIT TForCall: non-Native iter (v2 only)"));
3763                return -1;
3764            }
3765            if let Err(e) = self.begin_call(abs + 4, Some(2), nvars, false) {
3766                self.jit.pending_err = Some(e);
3767                return -1;
3768            }
3769        }
3770        // v4 batched writeback — fill the caller's buffers with the
3771        // raw bits of R[A+2] / R[A+4] / R[A+5] so the trace IR can
3772        // reload via cranelift `stack_load` instead of separate
3773        // `luna_jit_stack_load` helper calls.
3774        // 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).
3775        let ctrl_raw = unsafe { self.stack[(abs + 2) as usize].unpack().1.zero };
3776        let (key_tag, key_rv) = self.stack[(abs + 4) as usize].unpack();
3777        // 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).
3778        let key_raw = unsafe { key_rv.zero };
3779        let val_raw = if (nvars as usize) >= 2 {
3780            // 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).
3781            unsafe { self.stack[(abs + 5) as usize].unpack().1.zero }
3782        } else {
3783            0u64
3784        };
3785        // 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).
3786        unsafe {
3787            ctrl_out.write(ctrl_raw as i64);
3788            key_out.write(key_raw as i64);
3789            val_out.write(val_raw as i64);
3790        }
3791        key_tag as i64
3792    }
3793
3794    /// P12-S12-B-v2 — load the raw `i64` payload of
3795    /// `vm.stack[base + slot_offset]` for the active trace's head
3796    /// Lua frame. Used to reload trace IR `Variable`s after a
3797    /// helper has written to `vm.stack` directly (e.g. TForCall's
3798    /// iter results land at `R[A+4..A+4+nvars]`).
3799    #[doc(hidden)]
3800    pub fn jit_stack_load(&mut self, slot_offset: u32) -> i64 {
3801        let Some(f) = self.jit_last_lua_frame() else {
3802            return 0;
3803        };
3804        let idx = (f.base as usize) + (slot_offset as usize);
3805        if idx >= self.stack.len() {
3806            return 0;
3807        }
3808        let v = self.stack[idx];
3809        let (_, raw) = v.unpack();
3810        // 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).
3811        unsafe { raw.zero as i64 }
3812    }
3813
3814    /// P12-S12-B-v2 — read the tag byte of
3815    /// `vm.stack[base + slot_offset]`. Used by `Op::TForLoop` emit
3816    /// to dispatch on the iterator's return-key tag at runtime
3817    /// (`raw::NIL` → loop end exit, `raw::INT` → continue, other →
3818    /// deopt for v2).
3819    #[doc(hidden)]
3820    pub fn jit_stack_tag(&mut self, slot_offset: u32) -> u8 {
3821        let Some(f) = self.jit_last_lua_frame() else {
3822            return crate::runtime::value::raw::NIL;
3823        };
3824        let idx = (f.base as usize) + (slot_offset as usize);
3825        if idx >= self.stack.len() {
3826            return crate::runtime::value::raw::NIL;
3827        }
3828        self.stack[idx].unpack().0
3829    }
3830
3831    /// P12-S4-step4b — push a Lua frame onto the call stack with
3832    /// JIT-known metadata. Used by `luna_jit_trace_materialize_frames`
3833    /// at trace side-exits to recreate the inlined call activations
3834    /// the lowerer compiled past. The contract (enforced by the
3835    /// lowerer's pre-emit pass): `cl.proto` is non-vararg,
3836    /// `nresults` is the caller's expected count (today always 1
3837    /// because the lowerer bails Op::Call C != 2), and the caller
3838    /// has already called `jit_ensure_stack` to cover
3839    /// `[0..base + cl.proto.max_stack)`.
3840    #[doc(hidden)]
3841    pub fn jit_push_inlined_frame(
3842        &mut self,
3843        cl: Gc<LuaClosure>,
3844        base: u32,
3845        pc: u32,
3846        nresults: i32,
3847    ) {
3848        frames_push_sync(
3849            &mut self.frames,
3850            &mut self.frames_top,
3851            CallFrame::Lua(Frame {
3852                closure: cl,
3853                base,
3854                pc,
3855                // Lua call ABI: callee R[0] sits at caller R[A+1], so
3856                // callee.base = caller.base + A + 1; func_slot is
3857                // caller.base + A = callee.base - 1.
3858                func_slot: base - 1,
3859                n_varargs: 0,
3860                nresults,
3861                hook_oldpc: u32::MAX,
3862                from_c: false,
3863                tm: None,
3864                is_hook: false,
3865                tailcalls: 0,
3866            }),
3867        );
3868    }
3869
3870    /// Toggle precompiled-chunk loading. Default `true`. Sandbox embedders
3871    /// should set to `false` so `load`/`loadstring` reject bytecode input
3872    /// (which bypasses parser limits and could exploit verifier gaps).
3873    pub fn set_bytecode_loading(&mut self, enabled: bool) {
3874        self.bytecode_loading = enabled;
3875    }
3876
3877    /// Current bytecode-loading gate state.
3878    pub fn bytecode_loading(&self) -> bool {
3879        self.bytecode_loading
3880    }
3881
3882    /// Toggle PUC `.luac` bytecode loading. Default `false` — PUC
3883    /// bytecode is a strictly larger trust surface than luna's own dump
3884    /// format (third-party toolchain bugs, malformed chunks, unknown
3885    /// opcode shapes). Enable only for trusted PUC chunks. Per-dialect
3886    /// translators (Phase LB Wave 2) live in `crate::vm::dump::puc`.
3887    pub fn set_puc_bytecode_loading(&mut self, enabled: bool) {
3888        self.puc_bytecode_loading = enabled;
3889    }
3890
3891    /// Current PUC bytecode-loading gate state.
3892    pub fn puc_bytecode_loading(&self) -> bool {
3893        self.puc_bytecode_loading
3894    }
3895
3896    /// Default loader input budget — 256 MiB.
3897    ///
3898    /// `Vm::load` and the Lua-level `load(reader, ...)` both refuse
3899    /// sources whose byte length crosses this cap, returning the
3900    /// PUC-shaped `not enough memory` error rather than letting the
3901    /// host allocator try (and crash) to hold the next chunk.
3902    pub const DEFAULT_LOADER_INPUT_BUDGET: usize = 256 * 1024 * 1024;
3903
3904    /// Set the loader input byte budget (see
3905    /// [`Vm::DEFAULT_LOADER_INPUT_BUDGET`]). Pass `usize::MAX` to
3906    /// effectively disable. Smaller caps are honored verbatim — a 0
3907    /// cap rejects every non-empty source.
3908    pub fn set_loader_input_budget(&mut self, bytes: usize) {
3909        self.loader_input_budget = bytes;
3910    }
3911
3912    /// Current loader input byte budget.
3913    pub fn loader_input_budget(&self) -> usize {
3914        self.loader_input_budget
3915    }
3916
3917    /// Take the error traceback captured at the latest error point and
3918    /// reset it. Embedders should call this immediately after a failed
3919    /// `call_value`/`eval`/`call`/etc. — the next public `call_value`
3920    /// entry clears it. Returns `None` if no error was in flight.
3921    pub fn take_error_traceback(&mut self) -> Option<String> {
3922        self.error_traceback
3923            .take()
3924            .map(|b| String::from_utf8_lossy(&b).into_owned())
3925    }
3926
3927    /// Arm the soft memory cap (P09 embedding). The run loop checks the
3928    /// heap's tracked byte usage between dispatch turns; on overshoot it
3929    /// first runs a full collect, and if `bytes` still exceeds the cap it
3930    /// raises a catchable `"memory cap exceeded"` Lua error and disarms
3931    /// itself (fire-once: re-arm before the next `call_value` if reusing
3932    /// the Vm across requests). `None` removes the cap. The accounting is
3933    /// approximate — internal Vec/Box capacity overhead is not tracked,
3934    /// so embedders should size the cap with ~2× margin over the desired
3935    /// hard limit and additionally bound the Vm's lifetime (drop after
3936    /// each request).
3937    pub fn set_memory_cap(&mut self, cap: Option<usize>) {
3938        self.heap.mem_cap = cap;
3939    }
3940
3941    /// Approximate bytes the heap is currently holding. Object shells plus
3942    /// every table's internal array/hash boxes (tracked via
3943    /// `Heap::apply_bytes_delta` in `set`/`rehash`/`ensure_*`). Proto
3944    /// bytecode and closure upvalue slices still go uncounted — this is a
3945    /// lower bound, not a precise `malloc_stats`-style total.
3946    pub fn memory_used(&self) -> usize {
3947        self.heap.bytes()
3948    }
3949
3950    /// Read upvalue slot `i` of the native function currently on top of the
3951    /// dispatch chain (the one whose body is executing). Returns `Value::Nil`
3952    /// when no native is running. Public so the C ABI trampoline can fetch
3953    /// the host C function pointer it stashed there at registration time.
3954    pub fn running_native_upvalue(&self, i: usize) -> Value {
3955        match self.running_natives.last() {
3956            // 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).
3957            Some(nc) => unsafe {
3958                let upvals = &(*nc.as_ptr()).upvals;
3959                upvals.get(i).copied().unwrap_or(Value::Nil)
3960            },
3961            None => Value::Nil,
3962        }
3963    }
3964
3965    /// Register a table for finalization if its (just-set) metatable carries a
3966    /// `__gc` metamethod (PUC luaC_checkfinalizer at setmetatable time — adding
3967    /// `__gc` to the metatable afterwards does not retroactively register).
3968    pub(crate) fn check_finalizer(&mut self, t: Gc<Table>) {
3969        if !self.get_mm(Value::Table(t), Mm::Gc).is_nil() {
3970            self.heap.register_finalizable(t);
3971        }
3972    }
3973
3974    /// Same as [`Self::check_finalizer`] for a userdata. PUC 5.1 attaches the
3975    /// finalizer to the proxy produced by `newproxy(true)` once its metatable
3976    /// gains `__gc`. gc.lua's "testing userdata" section sets `__gc` on the
3977    /// metatable that `newproxy` returned, which then needs to flow through.
3978    /// Kept available for the future 5.2+ `lua_setmetatable` path (which
3979    /// would re-check at metatable-set time); luna's only userdata
3980    /// finalizables today come via `newproxy`, which registers itself.
3981    #[allow(dead_code)]
3982    pub(crate) fn check_finalizer_userdata(&mut self, u: Gc<crate::runtime::Userdata>) {
3983        if !self.get_mm(Value::Userdata(u), Mm::Gc).is_nil() {
3984            self.heap.register_finalizable_userdata(u);
3985        }
3986    }
3987
3988    /// Run pending `__gc` finalizers (objects the collector resurrected for
3989    /// finalization). Finalizer errors are swallowed — PUC turns them into a
3990    /// warning; they must never propagate to the mutator. Reentrancy-guarded.
3991    fn run_finalizers(&mut self) {
3992        let _ = self.run_finalizers_or_err();
3993    }
3994
3995    fn run_finalizers_or_err(&mut self) -> Result<(), LuaError> {
3996        if self.gc_finalizing {
3997            return Ok(());
3998        }
3999        let pending = self.heap.take_tobefnz();
4000        if pending.is_empty() {
4001            return Ok(());
4002        }
4003        self.gc_finalizing = true;
4004        let mut first_err: Option<LuaError> = None;
4005        for obj in pending {
4006            let gc = self.get_mm(obj, Mm::Gc);
4007            // PUC 5.2+ accepts any non-nil `__gc` at setmetatable time to
4008            // schedule the object for finalization (`__gc = true` is the
4009            // canonical placeholder); only call it at finalize time when it
4010            // is actually a function. gc.lua 5.2 :412 wires up exactly this
4011            // sentinel and then expects no call.
4012            let callable = matches!(gc, Value::Closure(_) | Value::Native(_));
4013            if callable {
4014                // PUC `GCTM` sets `CIST_FIN` on the new ci so
4015                // `funcnamefromfinalizer` reports `namewhat = "metamethod"`,
4016                // `name = "__gc"`. luna threads the same outcome through the
4017                // generic `pending_tm` slot: the Lua frame born from this
4018                // call consumes it in `push_frame`. Saved/restored around the
4019                // call in case the handler is a native (which never pops it).
4020                // Bare event name; `frame_name` / `c_frame_name` add the
4021                // `"__"` debug prefix for 5.2/5.3, drop it for 5.4+. Matches
4022                // the convention used by `__close`, `__index`, …
4023                let saved_tm = self.pending_tm.replace("gc");
4024                // PUC `GCTM` also sets `CIST_FIN` on the CALLER's ci before
4025                // pcall, so `getinfo(2).namewhat` inside the finalizer reads
4026                // "metamethod" (5.3 db.lua :720 wires up exactly this probe).
4027                // luna mirrors by temporarily tagging the current top Lua
4028                // frame's `tm` to "__gc" for the duration of the call.
4029                let caller_tm_idx = self
4030                    .frames
4031                    .iter()
4032                    .rposition(|cf| matches!(cf, CallFrame::Lua(_)));
4033                let saved_caller_tm = caller_tm_idx.and_then(|i| {
4034                    if let CallFrame::Lua(fr) = &mut self.frames[i] {
4035                        let prev = fr.tm;
4036                        fr.tm = Some("gc");
4037                        Some(prev)
4038                    } else {
4039                        None
4040                    }
4041                });
4042                if let Err(e) = self.call_value(gc, &[obj]) {
4043                    // PUC 5.1 GCTM raised the finalizer's error to the
4044                    // explicit `collectgarbage()` caller (`gc.lua 5.1 :255`
4045                    // baselines on `not pcall(collectgarbage)`). 5.2/5.3
4046                    // wrapped it in `error in __gc metamethod (msg)` first
4047                    // (`callGCTM` → `luaG_runerror`) but still raised. 5.4
4048                    // introduced the warning system and switched to "warn
4049                    // then continue" — never re-raise, just route the
4050                    // wrapped message through `warn`. gc.lua 5.5 :378 wires
4051                    // up `_WARN` capture under the `if T then …` block to
4052                    // baseline on the same wrapped string.
4053                    if self.version >= LuaVersion::Lua54 {
4054                        let inner = self.error_text(&e);
4055                        let msg = format!("error in __gc metamethod ({inner})");
4056                        self.emit_warn(msg.as_bytes(), false);
4057                    } else if first_err.is_none() {
4058                        let wrapped = if self.version >= LuaVersion::Lua52 {
4059                            let inner = self.error_text(&e);
4060                            let msg = format!("error in __gc metamethod ({inner})");
4061                            let s = Value::Str(self.heap.intern(msg.as_bytes()));
4062                            LuaError(s)
4063                        } else {
4064                            e
4065                        };
4066                        first_err = Some(wrapped);
4067                    }
4068                }
4069                self.pending_tm = saved_tm;
4070                if let (Some(i), Some(prev)) = (caller_tm_idx, saved_caller_tm)
4071                    && let Some(CallFrame::Lua(fr)) = self.frames.get_mut(i)
4072                {
4073                    fr.tm = prev; // prev is Option<&'static str>; restore exactly
4074                }
4075            }
4076        }
4077        self.gc_finalizing = false;
4078        match first_err {
4079            Some(e) => Err(e),
4080            None => Ok(()),
4081        }
4082    }
4083
4084    /// Drive one incremental GC step (PUC `collectgarbage("step", n)`).
4085    /// Crosses up to three phases per call:
4086    ///   1. Pause      → seed Propagate (`gc_start_propagate`)
4087    ///   2. Propagate  → drain gray up to `budget`; on exhaustion run atomic
4088    ///                   (`gc_finish_atomic` → tobefnz populated; finalizers
4089    ///                   run via `run_finalizers`) and enter Sweep
4090    ///   3. Sweep      → `gc_sweep_step` up to (residual) `budget`
4091    /// Returns true when this call completed the cycle's sweep (back to
4092    /// Pause). The budget is spent generously across phases — a large `n`
4093    /// can finish a whole cycle in one call (PUC stop-the-world step).
4094    pub(crate) fn gc_step(&mut self, budget: usize) -> bool {
4095        // Re-entry guard: never recurse — `run_finalizers` calls Lua code
4096        // that may hit a safe point and try to step again. Re-entry was OK
4097        // under STW (collect_garbage had its own guard) but here the
4098        // intermediate phase state would corrupt.
4099        if self.gc_finalizing {
4100            return false;
4101        }
4102        if self.heap.gc_phase_is_pause() {
4103            let (roots, extra) = self.gc_roots();
4104            self.heap.gc_start_propagate(&roots, &extra);
4105        }
4106        if self.heap.gc_phase_is_propagate() {
4107            if !self.heap.gc_step_propagate(budget) {
4108                return false;
4109            }
4110            self.heap.gc_finish_atomic();
4111            // any __gc scheduled by atomic — run before sweep so a finalizer
4112            // re-registering `self` re-enters the next cycle, not this sweep
4113            self.run_finalizers();
4114        }
4115        // either we just transitioned, or we entered already in Sweep, or
4116        // a finalizer started a new cycle (gc_sweep_step is a no-op then)
4117        self.heap.gc_sweep_step(budget)
4118    }
4119
4120    // ---- frames & calls ----
4121
4122    /// Begin calling stack[func_slot] with `nargs` (None: up to self.top).
4123    /// Returns true if a Lua frame was pushed (the dispatch loop continues
4124    /// there), false if a native completed inline.
4125    fn begin_call(
4126        &mut self,
4127        func_slot: u32,
4128        nargs: Option<u32>,
4129        nresults: i32,
4130        from_c: bool,
4131    ) -> Result<bool, LuaError> {
4132        let mut nargs = match nargs {
4133            Some(n) => n,
4134            None => self.top - (func_slot + 1),
4135        };
4136        // Consume `pending_is_tail` at the boundary: a tail-call op sets it
4137        // only for the immediately-following Lua activation. Native dispatch
4138        // (or `__call` resolution) below must not let it leak to the next
4139        // begin_call's frame; restore it just before push_frame for the Lua
4140        // arm so its meaning is preserved across __call chaining.
4141        let tailcalls = std::mem::take(&mut self.pending_tailcalls);
4142        // resolve __call handlers iteratively (PUC tryfuncTM loop): each handler
4143        // is inserted before the value so it becomes the first argument, and a
4144        // chain of `__call` tables resolves down to a real function.
4145        let mut chain = 0u32;
4146        loop {
4147            match self.stack[func_slot as usize] {
4148                Value::Closure(cl) => {
4149                    // P11-S2c.B JIT fast path: if the Proto's body fits
4150                    // the int-arith whitelist, every arg is `Value::Int`,
4151                    // and the cached arity matches, skip frame setup and
4152                    // run the cached native fn in-place.
4153                    if self.try_jit_call_op(cl, func_slot, nargs, nresults) {
4154                        self.pending_tailcalls = tailcalls;
4155                        return Ok(false);
4156                    }
4157                    self.pending_tailcalls = tailcalls;
4158                    self.push_frame(cl, func_slot, nargs, nresults, from_c)?;
4159                    // P12-S4-step0 — trace-on-call trigger. The frame
4160                    // we just pushed is the callee whose body the
4161                    // recorder will trace. Bump the per-Proto call
4162                    // counter; once it crosses `CALL_HOT_THRESHOLD`
4163                    // and no other trace is in flight, snapshot the
4164                    // callee's register window (R[0..max_stack]) and
4165                    // begin recording at `pc=0`. This is what unlocks
4166                    // tracing for functions whose body has no negative
4167                    // `Op::Jmp` back-edge (`fib`, recursive helpers).
4168                    //
4169                    // Gated on `trace_jit_enabled`, so the default
4170                    // dispatch pays a single not-taken branch.
4171                    if self.jit.trace_enabled {
4172                        let proto = cl.proto;
4173                        let c = proto.call_hot_count.get();
4174                        if c < u32::MAX / 2 {
4175                            proto.call_hot_count.set(c + 1);
4176                        }
4177                        // P13-S13-H — relaxed call-trigger:
4178                        // `c >= THRESHOLD` (was `c == THRESHOLD`) +
4179                        // `!already_cached` short-circuit. Lets a
4180                        // discarded short call-trigger close retry
4181                        // on the next call (fib(10/15/20/25)
4182                        // pathology — first capture is base-case
4183                        // [Lt,Jmp,Return1]; coverage-heuristic
4184                        // discards; next call gets to record at a
4185                        // potentially deeper recursion point).
4186                        // Without `already_cached`, the relaxed
4187                        // condition would re-record over a cached
4188                        // trace every call.
4189                        //
4190                        // P13-S13-K — additionally short-circuit on
4191                        // `proto.trace_gave_up`. The S13-I discard
4192                        // cap force-compiles a partial trace and
4193                        // flips this flag; subsequent calls into
4194                        // this Proto skip the RefCell borrow + Vec
4195                        // scan entirely.
4196                        if proto.trace_gave_up.get() {
4197                            return Ok(true);
4198                        }
4199                        let call_already_cached =
4200                            proto.traces.borrow().iter().any(|t| t.head_pc == 0);
4201                        if c >= crate::jit::trace::CALL_HOT_THRESHOLD
4202                            && self.jit.active_trace.is_none()
4203                            && !call_already_cached
4204                        {
4205                            // The new frame is on top: index in
4206                            // `self.frames` is `len() - 1`.
4207                            let frame_idx = self.frames.len() - 1;
4208                            // Snapshot R[0..max_stack] at the callee's
4209                            // base. `push_frame` resized `self.stack`
4210                            // to `base + max_stack`, so this window is
4211                            // guaranteed in-bounds.
4212                            let f = match &self.frames[frame_idx] {
4213                                CallFrame::Lua(f) => f,
4214                                _ => unreachable!("push_frame just pushed a Lua frame"),
4215                            };
4216                            let max_stack = cl.proto.max_stack as usize;
4217                            let base_us = f.base as usize;
4218                            let mut entry_tags = Vec::with_capacity(max_stack);
4219                            for i in 0..max_stack {
4220                                let (tag, _) = self.stack[base_us + i].unpack();
4221                                entry_tags.push(tag);
4222                            }
4223                            self.jit.active_trace =
4224                                Some(Box::new(crate::jit::trace::TraceRecord::start(
4225                                    cl.proto, 0, entry_tags, true,
4226                                )));
4227                            self.jit.recording_frame_base = frame_idx;
4228                        }
4229                    }
4230                    return Ok(true);
4231                }
4232                Value::Native(nc) => {
4233                    // v1.1 B10 Stage 2 — async-marked NativeClosure.
4234                    // Route through the cooperative-yield mechanism
4235                    // when async_mode is on; reject when called from
4236                    // a sync `eval`/`call_value` path (would have no
4237                    // executor to drive the returned future).
4238                    if nc.is_async {
4239                        if !self.async_mode {
4240                            let s = Value::Str(
4241                                self.heap.intern(b"async native called in sync context"),
4242                            );
4243                            self.last_error_kind = crate::vm::error::LuaErrorKind::Runtime;
4244                            return Err(LuaError(s));
4245                        }
4246                        // Same root-up bookkeeping as the sync path:
4247                        // pin args + result-count expectation so a
4248                        // collection across the suspend boundary
4249                        // keeps the arg window live.
4250                        self.native_nresults = nresults;
4251                        self.gc_top = func_slot + nargs + 1;
4252                        // v1.3 Phase AS — fire the "call" hook BEFORE
4253                        // building the future. Mirrors the sync native
4254                        // path's `hook_call(true, nargs)` site
4255                        // (`exec.rs` further down) so embedders with a
4256                        // Rust debug hook installed see a Call event
4257                        // for async natives identical to the sync
4258                        // path. The matching "return" hook fires from
4259                        // `commit_async_native_result` in
4260                        // `async_drive.rs` after the future resolves.
4261                        // Placement follows audit §"Open questions"
4262                        // Q6: after the `native_nresults` / `gc_top`
4263                        // pin, before the future is constructed, so a
4264                        // hook body that triggers GC observes the
4265                        // correct pinned window. On hook error the
4266                        // sentinel never returns and
4267                        // `pending_async_native_*` remain `None` —
4268                        // the executor sees `DispatchOutcome::Error`
4269                        // (audit §A.1 edge cases).
4270                        self.hook_call(true, nargs)?;
4271                        // Transmute the stored NativeFn back to its
4272                        // real AsyncNativeFn shape. Sound because
4273                        // `set_async_native` / `create_async_native`
4274                        // installed an AsyncNativeFn through the
4275                        // identically-sized fn-pointer slot, and the
4276                        // `is_async` marker bit is what records that
4277                        // fact.
4278                        let async_fn: crate::vm::async_drive::AsyncNativeFn =
4279                            // SAFETY: same-size fn pointers; provenance
4280                            // preserved through `mem::transmute`. The
4281                            // `is_async` marker is the only safe-to-call
4282                            // gate, set exclusively by
4283                            // `Vm::create_async_native`.
4284                            unsafe { std::mem::transmute(nc.f) };
4285                        let vm_ptr: *mut Vm = self;
4286                        let fut = async_fn(vm_ptr, func_slot, nargs);
4287                        // Stash the future + post-call context for
4288                        // `drive_one` to surface to `EvalFuture::poll`.
4289                        self.pending_async_native_fut = Some(fut);
4290                        self.pending_async_native_ctx = Some(AsyncNativeCallCtx {
4291                            func_slot,
4292                            nargs,
4293                            nresults,
4294                            gc_top: self.gc_top,
4295                        });
4296                        // Sentinel Err walked up to `drive_one` (same
4297                        // shape as `host_yield_pending`'s budget yield).
4298                        // Value::Nil — never seen by user code.
4299                        return Err(LuaError(Value::Nil));
4300                    }
4301                    // pcall/xpcall are yieldable: rather than calling the
4302                    // protected function through the Rust stack (which cannot be
4303                    // suspended), push a continuation frame and drive the call
4304                    // through the interpreter loop (PUC lua_pcallk). A yield
4305                    // inside it is preserved with the thread's saved frames.
4306                    use crate::runtime::value::NativeFn;
4307                    if std::ptr::fn_addr_eq(nc.f, nat_pcall as NativeFn) {
4308                        return self.begin_pcall(func_slot, nargs, nresults);
4309                    }
4310                    if std::ptr::fn_addr_eq(nc.f, nat_xpcall as NativeFn) {
4311                        return self.begin_xpcall(func_slot, nargs, nresults);
4312                    }
4313                    // pairs(t) with a __pairs metamethod calls it yieldably (PUC
4314                    // luaB_pairs); without one, fall through to the plain native.
4315                    if std::ptr::fn_addr_eq(nc.f, nat_pairs as NativeFn) && nargs >= 1 {
4316                        let arg = self.stack[(func_slot + 1) as usize];
4317                        if !self.get_mm(arg, Mm::Pairs).is_nil() {
4318                            return self.begin_pairs(func_slot, nresults);
4319                        }
4320                    }
4321                    // a native that collects (e.g. `collectgarbage`) roots up to
4322                    // its own arguments — the caller's live registers all sit
4323                    // below `func_slot` and stay rooted.
4324                    self.native_nresults = nresults;
4325                    self.gc_top = func_slot + nargs + 1;
4326                    // Push the native onto the running-natives chain BEFORE
4327                    // firing the call hook so that `debug.getinfo(level)` and
4328                    // `arg_error` from inside the hook see this native as the
4329                    // currently-running C function (db.lua :344 reads
4330                    // `getinfo(2, "f").func` for the just-entered callee).
4331                    // Popped after the matching return hook fires — even on
4332                    // error, the pop must happen, so the body is bracketed
4333                    // through a scope guard.
4334                    self.running_natives.push(nc);
4335                    self.running_native_slots.push((func_slot, nargs));
4336                    // PUC C-call discipline: entering a C function sets
4337                    // L->top to func + 1 + nargs, so a collect triggered
4338                    // INSIDE the native (explicit `collectgarbage()`, or
4339                    // an allocation crossing the GC threshold) roots the
4340                    // whole caller window up to and including the
4341                    // arguments. Without this raise the cursor is stale —
4342                    // parked at some earlier, possibly much lower
4343                    // safe-point — and the collect frees register-held
4344                    // values of the native's own caller (UAF-C, v2.13
4345                    // Track WUC). Never lower it: a re-entrant chain
4346                    // (native → Lua → native) must keep the outermost
4347                    // window rooted.
4348                    self.gc_top = self.gc_top.max(func_slot + 1 + nargs);
4349                    // PUC luaD_precall fires the "call" hook for C functions too.
4350                    // A yield inside the native (coroutine.yield) propagates an
4351                    // Err and the matching "return" hook fires on resume instead.
4352                    if let Err(e) = self.hook_call(true, nargs) {
4353                        self.running_natives.pop();
4354                        self.running_native_slots.pop();
4355                        return Err(e);
4356                    }
4357                    // P09: trap a Rust panic in the native and surface it as
4358                    // a Lua error rather than letting it unwind through the
4359                    // VM into the embedder. The VM's internal state may still
4360                    // be inconsistent after a panic (half-pushed args,
4361                    // dangling GC references), so embedders that catch this
4362                    // class of error should drop and re-create the Vm — but
4363                    // it's still better than tearing the host process down.
4364                    // `AssertUnwindSafe` is sound because the caller is the
4365                    // dispatch loop and any half-done state is fenced behind
4366                    // the immediate Err return below.
4367                    use std::panic::{AssertUnwindSafe, catch_unwind};
4368                    let result =
4369                        match catch_unwind(AssertUnwindSafe(|| (nc.f)(self, func_slot, nargs))) {
4370                            Ok(r) => r,
4371                            Err(payload) => {
4372                                let msg = panic_payload_str(&payload);
4373                                let s = Value::Str(
4374                                    self.heap.intern(format!("native panic: {msg}").as_bytes()),
4375                                );
4376                                Err(LuaError(s))
4377                            }
4378                        };
4379                    let nret = match result {
4380                        Ok(n) => n,
4381                        Err(e) => {
4382                            // Stash the offending native's name BEFORE the
4383                            // pop so a dying coroutine's traceback snapshot
4384                            // can prepend `[C]: in function '<name>'`. Use
4385                            // pushglobalfuncname (PUC walks package.loaded
4386                            // to qualify); fall back to "?".
4387                            self.errored_native =
4388                                Some(self.pushglobalfuncname(nc.f).unwrap_or_else(|| "?".into()));
4389                            self.running_natives.pop();
4390                            self.running_native_slots.pop();
4391                            return Err(e);
4392                        }
4393                    };
4394                    // PUC `luaD_poscall` fires the return hook BEFORE moving
4395                    // results into the function's slot — at that point args
4396                    // sit at `[func_slot + 1, func_slot + 1 + nargs)` and
4397                    // results above them at `[func_slot + 1 + nargs, …)`.
4398                    // luna's `nat_return` has already written the results
4399                    // into `[func_slot, func_slot + nret)`, so we replay PUC's
4400                    // layout by copying the results up past the preserved
4401                    // args, firing the hook (with ftransfer = nargs + 1, so
4402                    // `getlocal(2, ftransfer..)` reads results), and then
4403                    // copying back for `finish_results`. db.lua :541 reads
4404                    // `getinfo("r").ftransfer` + `getlocal` to inspect a
4405                    // returning native's results this way.
4406                    if self.hook.ret
4407                        && !self.in_hook
4408                        && (self.hook.func.is_some() || self.hook.rust_func.is_some())
4409                    {
4410                        let res_dst = func_slot + nargs + 1;
4411                        let need = (res_dst + nret) as usize;
4412                        if self.stack.len() < need {
4413                            self.stack.resize(need, Value::Nil);
4414                        }
4415                        for i in (0..nret).rev() {
4416                            self.stack[(res_dst + i) as usize] =
4417                                self.stack[(func_slot + i) as usize];
4418                        }
4419                        // widen the C-frame's argument window for getlocal
4420                        if let Some(slot) = self.running_native_slots.last_mut() {
4421                            slot.1 = nargs + nret;
4422                        }
4423                        let hr = self.hook_return(true, nargs + 1, nret);
4424                        if let Some(slot) = self.running_native_slots.last_mut() {
4425                            slot.1 = nargs;
4426                        }
4427                        // restore results into the slot finish_results expects
4428                        for i in 0..nret {
4429                            self.stack[(func_slot + i) as usize] =
4430                                self.stack[(res_dst + i) as usize];
4431                        }
4432                        self.running_natives.pop();
4433                        self.running_native_slots.pop();
4434                        hr?;
4435                    } else {
4436                        self.running_natives.pop();
4437                        self.running_native_slots.pop();
4438                    }
4439                    self.finish_results(func_slot, nret, nresults);
4440                    // the native may have allocated; collect with the results as
4441                    // the live boundary (PUC checks GC after a call returns).
4442                    self.maybe_collect_garbage(self.top);
4443                    return Ok(false);
4444                }
4445                v => {
4446                    let mm = self.get_mm(v, Mm::Call);
4447                    if mm.is_nil() {
4448                        return Err(self.call_err(v));
4449                    }
4450                    chain += 1;
4451                    // PUC 5.5 dropped the chain cap from `MAXTAGRECUR = 200`
4452                    // (the value 5.4's `lvm.c` uses) down to `MAXCCMT = 16`,
4453                    // and the 5.5 test exercises the new tight bound directly
4454                    // (calls.lua :225 builds a 16-deep chain and expects the
4455                    // 16th to error). 5.4 calls.lua :194 instead builds a 20-
4456                    // deep chain and expects it to succeed.
4457                    let cap = if self.version >= crate::version::LuaVersion::Lua55 {
4458                        15
4459                    } else {
4460                        MAX_CCMT
4461                    };
4462                    if chain > cap {
4463                        return Err(self.rt_err("'__call' chain too long"));
4464                    }
4465                    // slots above shift by one; at a call site those are dead
4466                    // temps of the current frame
4467                    self.stack.insert(func_slot as usize, mm);
4468                    if self.top > func_slot {
4469                        self.top += 1;
4470                    }
4471                    nargs += 1;
4472                }
4473            }
4474        }
4475    }
4476
4477    fn push_frame(
4478        &mut self,
4479        cl: Gc<LuaClosure>,
4480        func_slot: u32,
4481        nargs: u32,
4482        nresults: i32,
4483        from_c: bool,
4484    ) -> Result<(), LuaError> {
4485        if func_slot + 256 > MAX_LUA_STACK {
4486            // PUC `stackerror`: a stack overflow that surfaces while the
4487            // current activation is inside an xpcall message handler is
4488            // translated by `luaD_seterrorobj` (LUA_ERRERR) to "error in
4489            // error handling". errors.lua :606 expects the inner pcall(loop)
4490            // it runs from within `xpcall(loop, msgh)`'s msgh to fail with a
4491            // message matching "error handling".
4492            let msg = if self.msgh_depth > 0 {
4493                "error in error handling"
4494            } else {
4495                "stack overflow"
4496            };
4497            return Err(self.rt_err(msg));
4498        }
4499        let proto = cl.proto;
4500        let nparams = proto.num_params as u32;
4501        // 5.5 vararg layout (PUC luaT_adjustvarargs): the extra args stay on the
4502        // stack just below the new `base`, so a named vararg can be indexed
4503        // virtually without allocating a table. Rotate `[p1..pn][e1..em]` to
4504        // `[e1..em][p1..pn]` so the fixed params land at the new base.
4505        let n_varargs = if proto.is_vararg {
4506            nargs.saturating_sub(nparams)
4507        } else {
4508            0
4509        };
4510        if n_varargs > 0 {
4511            let s = (func_slot + 1) as usize;
4512            self.stack[s..s + nargs as usize].rotate_left(nparams as usize);
4513        }
4514        let base = func_slot + 1 + n_varargs;
4515        let need = (base + proto.max_stack as u32) as usize;
4516        if self.stack.len() < need {
4517            self.stack.resize(need, Value::Nil);
4518        }
4519        // wipe the register window beyond the kept parameters (stale values —
4520        // required for GC-safety and codegen). The varargs below `base` survive.
4521        let kept = nargs.saturating_sub(n_varargs).min(nparams);
4522        // SAFETY: just resized above so `need <= stack.len()`; `base + kept <=
4523        // need` since `base + nparams <= base + max_stack = need` and `kept <=
4524        // nparams`. `slice::fill` lowers to a single memset on Copy types.
4525        unsafe {
4526            self.stack
4527                .get_unchecked_mut((base + kept) as usize..need)
4528                .fill(Value::Nil);
4529        }
4530        frames_push_sync(
4531            &mut self.frames,
4532            &mut self.frames_top,
4533            CallFrame::Lua(Frame {
4534                closure: cl,
4535                base,
4536                pc: 0,
4537                func_slot,
4538                nresults,
4539                hook_oldpc: u32::MAX,
4540                from_c,
4541                n_varargs,
4542                // single-shot consume: `close_slots` sets pending_tm before each
4543                // handler call; the next Lua frame born is that handler's.
4544                tm: self.pending_tm.take(),
4545                // `run_hook` sets `pending_is_hook` before dispatching the user
4546                // hook so its frame reports `namewhat = "hook"` via getinfo.
4547                is_hook: std::mem::take(&mut self.pending_is_hook),
4548                tailcalls: std::mem::take(&mut self.pending_tailcalls),
4549            }),
4550        );
4551        // PUC 5.1 `LUAI_COMPAT_VARARG`: populate the hidden `arg` local with
4552        // `{ n = n_varargs, [1] = e1, [2] = e2, … }`. The compiler reserved
4553        // the slot at `base + nparams`; the extras sit just below `base` from
4554        // the vararg rotate above. 5.1 db.lua :279 reads `arg.n` from a line
4555        // hook; vararg.lua's contradictory expectations were already going to
4556        // fail either way (some asserts want `arg == nil`).
4557        if proto.has_compat_vararg_arg {
4558            let arg_slot = (base + nparams) as usize;
4559            let t = self.heap.new_table();
4560            {
4561                // 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).
4562                let tm = unsafe { t.as_mut() };
4563                for i in 0..n_varargs {
4564                    let v = self.stack[(base - n_varargs + i) as usize];
4565                    // bounded by `n_varargs` (≤ MAXUPVAL territory), well
4566                    // below `MAX_ASIZE`
4567                    let _ = tm.set_int(&mut self.heap, (i + 1) as i64, v);
4568                }
4569                let nk = Value::Str(self.heap.intern(b"n"));
4570                tm.set(&mut self.heap, nk, Value::Int(n_varargs as i64))
4571                    .expect("'n' key");
4572            }
4573            // once-per-table barrier mirrors SETLIST: t is born BLACK during
4574            // Propagate and the bulk `set_int`/`set` calls above don't barrier
4575            self.heap
4576                .barrier_back(t.as_ptr() as *mut crate::runtime::heap::GcHeader);
4577            self.stack[arg_slot] = Value::Table(t);
4578        }
4579        // PUC luaD_precall fires the "call" hook with the new frame current, so
4580        // a hook calling debug.getinfo(2) sees the entered function. For a Lua
4581        // callee, PUC `luaD_hookcall` passes `p->numparams` as ntransfer (only
4582        // fixed params count — extras already live below `base`).
4583        // A frame born via OP_TailCall fires "tail call" instead (PUC
4584        // luaD_pretailcall) and skips the matching "return" hook on exit.
4585        let is_tail = self
4586            .frames
4587            .last()
4588            .and_then(|f| f.lua())
4589            .is_some_and(|f| f.tailcalls > 0);
4590        self.hook_call_with(false, nparams, is_tail)?;
4591        Ok(())
4592    }
4593
4594    /// `pcall(f, ...)` (PUC luaB_pcall): push a continuation frame, then drive
4595    /// the protected call `f` through the interpreter loop. The protected
4596    /// function and its arguments already sit at `func_slot+1..`, so calling `f`
4597    /// at `func_slot+1` lets its results land one slot above the continuation —
4598    /// the loop head then writes `true` at `func_slot` to form `true, results…`.
4599    /// Always returns `Ok(true)`: a continuation is now on the stack to be
4600    /// resolved by the loop (even when `f` is a native that already ran inline).
4601    fn begin_pcall(&mut self, func_slot: u32, nargs: u32, nresults: i32) -> Result<bool, LuaError> {
4602        if nargs == 0 {
4603            return Err(crate::vm::builtins::raise_str(
4604                self,
4605                "bad argument #1 to 'pcall' (value expected)",
4606            ));
4607        }
4608        if self.pcall_depth >= MAX_C_DEPTH {
4609            return Err(self.rt_err("C stack overflow"));
4610        }
4611        self.pcall_depth += 1;
4612        frames_push_sync(
4613            &mut self.frames,
4614            &mut self.frames_top,
4615            CallFrame::Cont(NativeCont {
4616                kind: ContKind::Pcall,
4617                func_slot,
4618                nresults,
4619            }),
4620        );
4621        // call f (slot func_slot+1) with the remaining args, asking for all
4622        // results; a yield or error inside propagates with the continuation kept
4623        // on the stack (caught by `unwind` / preserved across a yield).
4624        self.begin_call(func_slot + 1, Some(nargs - 1), -1, true)?;
4625        Ok(true)
4626    }
4627
4628    /// `xpcall(f, msgh, ...)` (PUC luaB_xpcall): like `begin_pcall`, but the
4629    /// message handler is stashed in the continuation and the arguments are
4630    /// shifted down over the handler's slot so `f`'s args are contiguous.
4631    fn begin_xpcall(
4632        &mut self,
4633        func_slot: u32,
4634        nargs: u32,
4635        nresults: i32,
4636    ) -> Result<bool, LuaError> {
4637        if nargs < 2 {
4638            return Err(crate::vm::builtins::raise_str(
4639                self,
4640                "bad argument #2 to 'xpcall' (value expected)",
4641            ));
4642        }
4643        if self.pcall_depth >= MAX_C_DEPTH {
4644            return Err(self.rt_err("C stack overflow"));
4645        }
4646        self.pcall_depth += 1;
4647        // layout: [xpcall@func_slot, f@+1, msgh@+2, a1@+3, ...]. Stash msgh and
4648        // close its gap so f's args become [f@+1, a1@+2, ...].
4649        let handler = self.stack[(func_slot + 2) as usize];
4650        // 5.1: `xpcall (f, err)` takes exactly two parameters — extra
4651        // arguments are NOT forwarded to `f` (5.2 added forwarding;
4652        // 5.1 calls f with zero args). v2.14 dialect fixture 5.1/519.
4653        let nfargs = if self.version <= crate::version::LuaVersion::Lua51 {
4654            0
4655        } else {
4656            nargs - 2
4657        };
4658        for i in 0..nfargs {
4659            self.stack[(func_slot + 2 + i) as usize] = self.stack[(func_slot + 3 + i) as usize];
4660        }
4661        self.top = func_slot + 2 + nfargs;
4662        frames_push_sync(
4663            &mut self.frames,
4664            &mut self.frames_top,
4665            CallFrame::Cont(NativeCont {
4666                kind: ContKind::Xpcall { handler },
4667                func_slot,
4668                nresults,
4669            }),
4670        );
4671        self.begin_call(func_slot + 1, Some(nfargs), -1, true)?;
4672        Ok(true)
4673    }
4674
4675    /// `pairs(t)` where `t` has a `__pairs` metamethod (PUC luaB_pairs's
4676    /// lua_callk path): drive `__pairs(t)` through the loop with a `Pairs`
4677    /// continuation so a `coroutine.yield` inside it suspends cleanly. The
4678    /// metamethod is called in `pairs`'s own slot, so its (≤4, nil-padded)
4679    /// results land exactly where `pairs`'s results belong.
4680    fn begin_pairs(&mut self, func_slot: u32, nresults: i32) -> Result<bool, LuaError> {
4681        let arg = self.stack[(func_slot + 1) as usize];
4682        let mm = self.get_mm(arg, Mm::Pairs);
4683        // layout becomes [mm@func_slot, t@func_slot+1]; call mm(t) wanting 4.
4684        self.stack[func_slot as usize] = mm;
4685        self.top = func_slot + 2;
4686        frames_push_sync(
4687            &mut self.frames,
4688            &mut self.frames_top,
4689            CallFrame::Cont(NativeCont {
4690                kind: ContKind::Pairs,
4691                func_slot,
4692                nresults,
4693            }),
4694        );
4695        self.begin_call(func_slot, Some(1), 4, true)?;
4696        Ok(true)
4697    }
4698
4699    /// The running (top) Lua frame. The interpreter only reads this while a Lua
4700    /// frame is on top — a continuation frame is never the running frame (it is
4701    /// consumed the instant the call it protects unwinds onto it).
4702    #[inline]
4703    fn top_frame(&self) -> &Frame {
4704        self.frames
4705            .last()
4706            .and_then(CallFrame::lua)
4707            .expect("running Lua frame")
4708    }
4709
4710    #[inline]
4711    fn top_frame_mut(&mut self) -> &mut Frame {
4712        self.frames
4713            .last_mut()
4714            .and_then(CallFrame::lua_mut)
4715            .expect("running Lua frame")
4716    }
4717
4718    /// Pad/announce results sitting at func_slot.
4719    pub(crate) fn finish_results(&mut self, func_slot: u32, nret: u32, wanted: i32) {
4720        // v2.3 P1B-A: capture the call's high-water-mark before
4721        // setting the new top so we can Nil-clear slots that the
4722        // call temporarily wrote but no longer holds — matching
4723        // PUC's `L->top` discipline (slots past L->top are "free"
4724        // and the next push overwrites them). Without this clear,
4725        // a stale `Value::Closure` (e.g. the called function
4726        // itself, when wanted = 0) sits at `func_slot` and a
4727        // later GC with wider `gc_top` traces it after the
4728        // closure has been freed by a previous narrow safe-point
4729        // GC → heap-buffer-overflow in `Marker::header` (UAF-A
4730        // sort.lua AA case).
4731        let prev_top = self.top as usize;
4732        if wanted < 0 {
4733            self.top = func_slot + nret;
4734        } else {
4735            let wanted = wanted as u32;
4736            let need = (func_slot + wanted) as usize;
4737            if self.stack.len() < need {
4738                self.stack.resize(need, Value::Nil);
4739            }
4740            for i in nret..wanted {
4741                self.stack[(func_slot + i) as usize] = Value::Nil;
4742            }
4743            self.top = func_slot + wanted;
4744        }
4745        let new_top = self.top as usize;
4746        let clear_end = prev_top.min(self.stack.len());
4747        if new_top < clear_end {
4748            for slot in &mut self.stack[new_top..clear_end] {
4749                *slot = Value::Nil;
4750            }
4751        }
4752    }
4753
4754    /// v1.1 B10 Stage 1 — current Lua call-frame depth (read-only).
4755    /// Used by `EvalFuture` on the bootstrap poll to compute the
4756    /// `entry_depth` it will pass to subsequent resume slices.
4757    pub(crate) fn frame_count(&self) -> usize {
4758        self.frames.len()
4759    }
4760
4761    fn take_results(&mut self, func_slot: u32) -> Vec<Value> {
4762        let nret = self.top - func_slot;
4763        let out = self.stack[func_slot as usize..(func_slot + nret) as usize].to_vec();
4764        self.stack.truncate(func_slot as usize);
4765        self.top = func_slot;
4766        out
4767    }
4768
4769    // ---- open upvalues ----
4770
4771    #[doc(hidden)]
4772    pub fn find_or_create_upval(&mut self, slot: u32) -> Gc<Upvalue> {
4773        match self.open_upvals.binary_search_by_key(&slot, |&(s, _)| s) {
4774            Ok(i) => self.open_upvals[i].1,
4775            Err(i) => {
4776                let uv = self.heap.new_upvalue(UpvalState::Open {
4777                    slot,
4778                    thread: self.current,
4779                });
4780                self.open_upvals.insert(i, (slot, uv));
4781                uv
4782            }
4783        }
4784    }
4785
4786    pub(crate) fn close_from(&mut self, slot: u32) {
4787        while let Some(&(s, uv)) = self.open_upvals.last() {
4788            if s < slot {
4789                break;
4790            }
4791            let v = self.stack[s as usize];
4792            // 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).
4793            unsafe { uv.as_mut() }.set_closed(v);
4794            self.heap
4795                .barrier_forward(uv.as_ptr() as *mut crate::runtime::heap::GcHeader, v);
4796            self.open_upvals.pop();
4797        }
4798    }
4799
4800    /// Register a to-be-closed slot (TBC op / generic-for closing value).
4801    fn register_tbc(&mut self, slot: u32) -> Result<(), LuaError> {
4802        let v = self.stack[slot as usize];
4803        if matches!(v, Value::Nil | Value::Bool(false)) {
4804            return Ok(()); // nil and false are silently ignored
4805        }
4806        if self.get_mm(v, Mm::Close).is_nil() {
4807            // PUC `checkclosemth`: "variable '<name>' got a non-closable value
4808            // (a <type> value)"; the local's name comes from the running
4809            // frame's locvars at this pc.
4810            let tn = v.type_name();
4811            let f = self.top_frame();
4812            let reg = slot - f.base;
4813            let pc = (f.pc as usize).saturating_sub(1);
4814            let where_ = match crate::vm::objname::getlocalname(&f.closure.proto, reg, pc) {
4815                Some(n) => format!("variable '{n}'"),
4816                None => "to-be-closed slot".to_string(),
4817            };
4818            return Err(self.rt_err(&format!("{where_} got a non-closable value (a {tn} value)")));
4819        }
4820        debug_assert!(self.tbc.last().is_none_or(|&s| s < slot));
4821        self.tbc.push(slot);
4822        Ok(())
4823    }
4824
4825    /// Close upvalues and run `__close` handlers for slots ≥ `from`
4826    /// (handlers in reverse registration order; PUC luaF_close).
4827    fn close_slots(&mut self, from: u32, err: Option<Value>) -> Result<(), LuaError> {
4828        self.close_from(from);
4829        // PUC: handlers run in reverse declaration order; an error raised by a
4830        // handler becomes the error object passed to the remaining ones, and
4831        // the rest are still closed. The last raised error propagates.
4832        let mut pending = err;
4833        let mut result = Ok(());
4834        let saved_err = self.closing_err;
4835        // On a normal close the handler runs within the closing function's
4836        // activation (debug parent = that function); during error unwinding the
4837        // function's frame is already gone, so the handler sits at the C
4838        // boundary instead (PUC: luaF_close runs after the ci is restored).
4839        let error_close = err.is_some();
4840        while let Some(&s) = self.tbc.last() {
4841            if s < from {
4842                break;
4843            }
4844            self.tbc.pop();
4845            let v = self.stack[s as usize];
4846            if matches!(v, Value::Nil | Value::Bool(false)) {
4847                continue;
4848            }
4849            let mm = self.get_mm(v, Mm::Close);
4850            if mm.is_nil() {
4851                // PUC `prepclosingmethod`: the __close metamethod was present
4852                // at OP_TBC (else we would have errored there) but has since
4853                // been removed/replaced. Treat as a non-callable target.
4854                let tn = self.obj_typename(v);
4855                let e = self.rt_err(&format!(
4856                    "attempt to call a {tn} value (metamethod 'close')"
4857                ));
4858                pending = Some(e.0);
4859                result = Err(e);
4860                continue;
4861            }
4862            // root the pending error: a handler may trigger a collection
4863            self.closing_err = pending;
4864            // PUC `luaF_close` sets `ci->u.l.tm = TM_CLOSE` so traceback /
4865            // getinfo report the handler as "in metamethod 'close'". Saved/
4866            // restored around the call to cover the path where `mm` is a
4867            // native (`push_frame` never consumes it) or it raises before
4868            // reaching push_frame.
4869            let saved_tm = self.pending_tm.replace("close");
4870            // PUC 5.4 `prepclosingmethod` always pushed (obj, errobj) — errobj
4871            // is nil on a normal close (5.4 locals.lua :875's
4872            // `func2close(coroutine.yield)` wrap pins `(self, nil)` back
4873            // through the yield). PUC 5.5 dropped the trailing nil: a clean
4874            // close passes only `obj`, the error case still passes both
4875            // (5.5 locals.lua :314 `select("#", ...) == n` with n=1 for the
4876            // normal-close arms, n=2 for the error arm).
4877            let call = match pending {
4878                Some(e) => self.call_value_impl(mm, &[v, e], error_close),
4879                None => {
4880                    if self.version >= LuaVersion::Lua55 {
4881                        self.call_value_impl(mm, &[v], error_close)
4882                    } else {
4883                        self.call_value_impl(mm, &[v, Value::Nil], error_close)
4884                    }
4885                }
4886            };
4887            self.pending_tm = saved_tm;
4888            if let Err(e) = call {
4889                pending = Some(e.0);
4890                result = Err(e);
4891            }
4892        }
4893        self.closing_err = saved_err;
4894        result
4895    }
4896
4897    /// Yieldable variant of `close_slots`: drive the chain of `__close`
4898    /// handlers for slots ≥ `from` through the interpreter loop with a
4899    /// `Cont::Close` continuation, so a `coroutine.yield()` inside any handler
4900    /// suspends cleanly (the close iteration's state rides on the thread's
4901    /// frame/stack like any other suspended call) — PUC's `lua_callk` pattern
4902    /// applied to `luaF_close`. `after` runs when every slot is closed; if
4903    /// `after` is `Return` and we've returned past `entry_depth`,
4904    /// `Ok(Some(vals))` carries the result up to the host caller.
4905    fn begin_close(
4906        &mut self,
4907        from: u32,
4908        err: Option<Value>,
4909        after: AfterClose,
4910        entry_depth: usize,
4911    ) -> Result<Option<Vec<Value>>, LuaError> {
4912        self.close_from(from);
4913        self.drive_close(from, err, after, entry_depth)
4914    }
4915
4916    /// Pop tbc slots ≥ `from`, skipping nil/false and synthesising a
4917    /// non-callable-mm error for an `__close` that was reset to a bad value
4918    /// between OP_TBC and now (PUC `prepclosingmethod`). The first real
4919    /// handler pushes a `Cont::Close` + `begin_call` and returns `Ok(None)`;
4920    /// the interpreter then drives the handler and re-enters this driver via
4921    /// the `Cont::Close` consumer in `run()`. When the chain is exhausted,
4922    /// the threaded error (if any) propagates or `after` fires.
4923    fn drive_close(
4924        &mut self,
4925        from: u32,
4926        mut pending: Option<Value>,
4927        after: AfterClose,
4928        entry_depth: usize,
4929    ) -> Result<Option<Vec<Value>>, LuaError> {
4930        loop {
4931            let drained = match self.tbc.last() {
4932                None => true,
4933                Some(&s) => s < from,
4934            };
4935            if drained {
4936                return self.finish_close_after(after, pending, entry_depth);
4937            }
4938            let s = self.tbc.pop().expect("tbc non-empty");
4939            let v = self.stack[s as usize];
4940            if matches!(v, Value::Nil | Value::Bool(false)) {
4941                continue;
4942            }
4943            let mm = self.get_mm(v, Mm::Close);
4944            if mm.is_nil() {
4945                let tn = self.obj_typename(v);
4946                let e = self.rt_err(&format!(
4947                    "attempt to call a {tn} value (metamethod 'close')"
4948                ));
4949                pending = Some(e.0);
4950                continue;
4951            }
4952            // A real handler: stage [mm, v, (err?)] above the current top,
4953            // record the close iteration state in a Cont::Close, and let the
4954            // interpreter dispatch the handler. On return the run() head
4955            // re-enters this driver via the Cont::Close consumer.
4956            let func_slot = self.top;
4957            let error_close = pending.is_some();
4958            let need = (func_slot + 3) as usize;
4959            if self.stack.len() < need {
4960                self.stack.resize(need, Value::Nil);
4961            }
4962            self.stack[func_slot as usize] = mm;
4963            self.stack[func_slot as usize + 1] = v;
4964            // PUC 5.4 always passes (obj, errobj=nil) on a normal close;
4965            // 5.5 drops the trailing nil. 5.4 locals.lua :875 vs 5.5 :314.
4966            let nargs = match pending {
4967                Some(e) => {
4968                    self.stack[func_slot as usize + 2] = e;
4969                    2u32
4970                }
4971                None => {
4972                    if self.version >= LuaVersion::Lua55 {
4973                        1u32
4974                    } else {
4975                        self.stack[func_slot as usize + 2] = Value::Nil;
4976                        2u32
4977                    }
4978                }
4979            };
4980            self.top = func_slot + 1 + nargs;
4981            // Root the pending error during the call (a handler may collect).
4982            let saved_err = self.closing_err;
4983            self.closing_err = pending;
4984            // PUC `luaF_close` flags the handler frame as "metamethod 'close'"
4985            // for traceback / getinfo.
4986            let saved_tm = self.pending_tm.replace("close");
4987            frames_push_sync(
4988                &mut self.frames,
4989                &mut self.frames_top,
4990                CallFrame::Cont(NativeCont {
4991                    kind: ContKind::Close(CloseCont {
4992                        from,
4993                        pending,
4994                        after,
4995                    }),
4996                    func_slot,
4997                    nresults: 0,
4998                }),
4999            );
5000            // PUC luaF_close runs a normal close *within* the closing
5001            // function's activation (debug parent = that function); during an
5002            // error unwind the function's frame is already gone and the
5003            // handler sits at the C boundary instead.
5004            let r = self.begin_call(func_slot, Some(nargs), 0, error_close);
5005            self.pending_tm = saved_tm;
5006            self.closing_err = saved_err;
5007            r?;
5008            return Ok(None);
5009        }
5010    }
5011
5012    /// Fire `after` once every `__close` handler has run. `Block` propagates
5013    /// any remaining error or simply continues; `Return` performs OP_Return's
5014    /// tail (hook + frame pop + result delivery) and may surface results to
5015    /// the host when the function whose return triggered the close was the
5016    /// entry activation, but only on a clean drain — a pending error skips
5017    /// the return tail and propagates instead. `ResumeUnwind` pops the
5018    /// deferred Lua frame and re-raises, letting a handler's own error win
5019    /// over the original propagating one (PUC luaF_close).
5020    fn finish_close_after(
5021        &mut self,
5022        after: AfterClose,
5023        pending: Option<Value>,
5024        entry_depth: usize,
5025    ) -> Result<Option<Vec<Value>>, LuaError> {
5026        match after {
5027            AfterClose::Block => match pending {
5028                Some(e) => Err(LuaError(e)),
5029                None => Ok(None),
5030            },
5031            AfterClose::Return {
5032                abs_a,
5033                nret,
5034                from_native,
5035            } => match pending {
5036                Some(e) => Err(LuaError(e)),
5037                None => self.complete_return(abs_a, nret, from_native, entry_depth),
5038            },
5039            AfterClose::ResumeUnwind { func_slot, err } => {
5040                // The aborting Lua frame was popped before `begin_close`;
5041                // restore the catcher's stack window down to `func_slot` and
5042                // re-raise — preferring a handler-raised error over the
5043                // original (PUC luaF_close).
5044                self.stack.truncate(func_slot as usize);
5045                self.top = func_slot;
5046                self.tbc.retain(|&s| s < func_slot);
5047                Err(LuaError(pending.unwrap_or(err)))
5048            }
5049        }
5050    }
5051
5052    /// OP_Return's post-close tail: fire the "return" hook (frame still
5053    /// current), pop the Lua frame, slide results into `func_slot`, then
5054    /// either hand them to the host (`Ok(Some(vals))` when we've returned
5055    /// past `entry_depth`), leave them contiguous for an exposed
5056    /// pcall/xpcall continuation, or finish into the caller's expected
5057    /// result slot. Mirrors the synchronous OP_Return tail so both paths
5058    /// share semantics — the `from_native` flag selects the right "return"
5059    /// hook context for `hook_return`.
5060    fn complete_return(
5061        &mut self,
5062        abs_a: u32,
5063        nret: u32,
5064        from_native: bool,
5065        entry_depth: usize,
5066    ) -> Result<Option<Vec<Value>>, LuaError> {
5067        // ftransfer is the local index (1-based) of the first result, as
5068        // `getinfo("r").ftransfer + getlocal(level, k)` consumes it. luna
5069        // exposes locals starting at `frame.base` (= func_slot + 1 +
5070        // n_varargs for a vararg call), so the conversion is the absolute
5071        // result slot minus base, plus one to make it 1-based. db.lua 5.4
5072        // :542 (`foo1(); on=false; eqseq(out, {10, 0})`) pins the vararg
5073        // shape end-to-end.
5074        let ftransfer = self
5075            .frames
5076            .last()
5077            .and_then(CallFrame::lua)
5078            .map(|fr| {
5079                let raw = abs_a.saturating_sub(fr.base) + 1;
5080                // 5.5 anonymous-vararg functions get a `(vararg table)` pseudo
5081                // local injected at index `numparams + 1`, so getlocal
5082                // numbering shifts results past it (5.5 db.lua :539
5083                // `eqseq(out, {10, 0})`). 5.4 and earlier have no such pseudo.
5084                if fr.closure.proto.has_vararg_table_pseudo {
5085                    raw + 1
5086                } else {
5087                    raw
5088                }
5089            })
5090            .unwrap_or(1);
5091        // PUC 5.1 `luaD_poscall`: fire one extra "tail return" hook event
5092        // per tail call that collapsed into this activation, *after* its
5093        // own "return". `tailcalls` tracks that count exactly (PUC
5094        // `ci->u.l.tailcalls`). 5.2+ retired LUA_HOOKTAILRET, so the
5095        // "return" hook fires once even when the activation absorbed
5096        // multiple tail calls — only `istailcall` on getinfo surfaces the
5097        // collapse. 5.1 db.lua :366 pins the event ordering.
5098        let tailcalls = if self.version <= LuaVersion::Lua51 {
5099            self.frames
5100                .last()
5101                .and_then(|f| f.lua())
5102                .map(|f| f.tailcalls)
5103                .unwrap_or(0)
5104        } else {
5105            0
5106        };
5107        self.hook_return(from_native, ftransfer, nret)?;
5108        for _ in 0..tailcalls {
5109            self.hook_tail_return()?;
5110        }
5111        let CallFrame::Lua(fr) =
5112            frames_pop_sync(&mut self.frames, &mut self.frames_top).expect("no frame")
5113        else {
5114            unreachable!("returning from a non-Lua frame")
5115        };
5116        for i in 0..nret {
5117            self.stack[(fr.func_slot + i) as usize] = self.stack[(abs_a + i) as usize];
5118        }
5119        if self.frames.len() < entry_depth {
5120            self.top = fr.func_slot + nret;
5121            return Ok(Some(self.take_results(fr.func_slot)));
5122        } else if matches!(self.frames.last(), Some(CallFrame::Cont(_))) {
5123            self.top = fr.func_slot + nret;
5124        } else {
5125            self.finish_results(fr.func_slot, nret, fr.nresults);
5126        }
5127        Ok(None)
5128    }
5129
5130    #[doc(hidden)]
5131    pub fn upval_get(&self, cl: Gc<LuaClosure>, idx: u32) -> Value {
5132        match cl.upvals()[idx as usize].state() {
5133            UpvalState::Open { slot, thread } => self.read_slot(slot, thread),
5134            UpvalState::Closed(v) => v,
5135        }
5136    }
5137
5138    fn upval_set(&mut self, cl: Gc<LuaClosure>, idx: u32, v: Value) {
5139        let uv = cl.upvals()[idx as usize];
5140        match uv.state() {
5141            UpvalState::Open { slot, thread } => self.write_slot(slot, thread, v),
5142            UpvalState::Closed(_) => {
5143                // 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).
5144                unsafe { uv.as_mut() }.set_closed(v);
5145                // forward barrier: a closed upvalue is single-slot, so the
5146                // forward variant is cheaper than barrier_back (PUC uses
5147                // `luaC_barrier_` for upvalues; `luaC_barrierback_` for
5148                // tables / threads).
5149                self.heap
5150                    .barrier_forward(uv.as_ptr() as *mut crate::runtime::heap::GcHeader, v);
5151            }
5152        }
5153    }
5154
5155    // ---- register / error helpers ----
5156
5157    #[inline(always)]
5158    fn r(&self, base: u32, i: u32) -> Value {
5159        // SAFETY: the compiler reserves `proto.max_stack` slots above `base`
5160        // at frame entry (`push_frame` sizes the stack up to base + max_stack),
5161        // and every bytecode-generated reference falls within `[0, max_stack)`.
5162        // PUC's vmfetch uses raw `R(A)` (`s2v(L->base + A)`) for the same
5163        // reason. The bounds check would re-validate this invariant on every
5164        // op — the dispatch hot path can't afford it.
5165        // 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).
5166        unsafe { *self.stack.get_unchecked((base + i) as usize) }
5167    }
5168
5169    #[inline(always)]
5170    fn set_r(&mut self, base: u32, i: u32, v: Value) {
5171        // SAFETY: see `r` — `base + i < base + max_stack <= stack.len()` by
5172        // frame-entry contract.
5173        unsafe {
5174            *self.stack.get_unchecked_mut((base + i) as usize) = v;
5175        }
5176    }
5177
5178    #[doc(hidden)]
5179    pub fn rt_err(&mut self, msg: &str) -> LuaError {
5180        let text = match self.position_prefix() {
5181            Some(p) => format!("{p}{msg}"),
5182            None => msg.to_string(),
5183        };
5184        LuaError(Value::Str(self.heap.intern(text.as_bytes())))
5185    }
5186
5187    /// Error without the `chunk:line:` position prefix. PUC's
5188    /// `resume_error` (ldo.c) pushes its message as a bare literal,
5189    /// so `cannot resume dead coroutine` etc. must not be prefixed.
5190    pub(crate) fn plain_err(&mut self, msg: &str) -> LuaError {
5191        LuaError(Value::Str(self.heap.intern(msg.as_bytes())))
5192    }
5193
5194    pub(crate) fn type_err(&mut self, what: &str, v: Value) -> LuaError {
5195        let extra = self.subject_varinfo(v);
5196        let tn = self.obj_typename(v);
5197        let msg = self.compose_type_err(what, &tn, &extra);
5198        self.rt_err(&msg)
5199    }
5200
5201    /// Assemble a `luaG_typeerror` / `luaG_callerror` message in the dialect's
5202    /// word order.
5203    ///
5204    /// PUC ≤5.2 names the operand first — `attempt to call field 'f' (a nil
5205    /// value)`. 5.3 flipped it to type-first — `attempt to call a nil value
5206    /// (field 'f')`. luna emitted the 5.3+ form on every dialect, so every
5207    /// such error was worded wrong under 5.1/5.2.
5208    ///
5209    /// Two shapes carry no operand name on ≤5.2 and must collapse to the bare
5210    /// message: an absent varinfo (identical across dialects), and a
5211    /// metamethod target — ≤5.2's `luaG_typeerror` only names locals, globals,
5212    /// fields, upvalues and methods, so `(metamethod 'add')` has no ≤5.2
5213    /// counterpart and is dropped rather than reworded. All four shapes were
5214    /// measured against stock 5.1.5 / 5.2.4 / 5.5.1 before this was written.
5215    fn compose_type_err(&self, what: &str, tn: &str, extra: &str) -> String {
5216        if self.version() > crate::version::LuaVersion::Lua52 {
5217            return format!("attempt to {what} a {tn} value{extra}");
5218        }
5219        // `extra` is "" or " (kind 'name')" — unwrap to "kind 'name'".
5220        let inner = extra
5221            .trim_start()
5222            .trim_start_matches('(')
5223            .trim_end_matches(')');
5224        if inner.is_empty() || inner.starts_with("metamethod") {
5225            format!("attempt to {what} a {tn} value")
5226        } else {
5227            format!("attempt to {what} {inner} (a {tn} value)")
5228        }
5229    }
5230
5231    /// Name the offending operand of the current instruction (PUC varinfo) for
5232    /// a type error, e.g. " (global 'x')". The faulting value `bad` is matched
5233    /// to the instruction's subject register(s); a native-raised error whose
5234    /// current instruction doesn't hold `bad` simply yields "".
5235    fn subject_varinfo(&self, bad: Value) -> String {
5236        use crate::vm::isa::Op;
5237        let Some(f) = self.frames.last().and_then(CallFrame::lua) else {
5238            return String::new();
5239        };
5240        let proto = f.closure.proto;
5241        let p: &crate::runtime::Proto = &proto;
5242        let pc = f.pc as usize;
5243        if pc == 0 || pc > p.code.len() {
5244            return String::new();
5245        }
5246        let instr = p.code[pc - 1];
5247        let mut cands: Vec<u32> = Vec::new();
5248        match instr.op() {
5249            // indexed reads / length / method: the table/object is in B
5250            Op::GetField | Op::GetI | Op::GetTable | Op::SelfOp | Op::Len => {
5251                cands.push(instr.b());
5252            }
5253            // indexed writes / calls: the table/function is in A
5254            Op::SetField | Op::SetI | Op::SetTable | Op::Call | Op::TailCall => {
5255                cands.push(instr.a());
5256            }
5257            // arithmetic/bitwise: a register operand (B, and C unless constant)
5258            Op::Add
5259            | Op::Sub
5260            | Op::Mul
5261            | Op::Div
5262            | Op::Mod
5263            | Op::Pow
5264            | Op::IDiv
5265            | Op::BAnd
5266            | Op::BOr
5267            | Op::BXor
5268            | Op::Shl
5269            | Op::Shr => {
5270                cands.push(instr.b());
5271                if !instr.k() {
5272                    cands.push(instr.c());
5273                }
5274            }
5275            Op::Unm | Op::BNot => cands.push(instr.b()),
5276            Op::Concat => {
5277                let a = instr.a();
5278                for r in a..a + instr.b() {
5279                    cands.push(r);
5280                }
5281            }
5282            _ => {}
5283        }
5284        for reg in cands {
5285            if self.r(f.base, reg).raw_eq(bad) {
5286                return match crate::vm::objname::getobjname(p, pc - 1, reg) {
5287                    Some((kind, name)) => format!(" ({kind} '{name}')"),
5288                    None => String::new(),
5289                };
5290            }
5291        }
5292        String::new()
5293    }
5294
5295    /// "attempt to call a X value", enriched (PUC luaG_callerror) with a name
5296    /// for the call target: "(global 'f')" for a direct call, or "(metamethod
5297    /// 'add')" when the call is a metamethod dispatched by the current opcode.
5298    fn call_err(&mut self, v: Value) -> LuaError {
5299        let extra = self.call_target_varinfo(v);
5300        let tn = self.obj_typename(v);
5301        let msg = self.compose_type_err("call", &tn, &extra);
5302        self.rt_err(&msg)
5303    }
5304
5305    /// Name the offending call target. A metamethod dispatch pushes a `Cont`
5306    /// frame before the call, so the opcode that triggered it lives in the
5307    /// nearest *Lua* frame — read that instruction: OP_CALL names the function
5308    /// register, any metamethod-bearing opcode yields "(metamethod 'event')".
5309    fn call_target_varinfo(&self, bad: Value) -> String {
5310        use crate::vm::isa::Op;
5311        let Some(f) = self.frames.iter().rev().find_map(CallFrame::lua) else {
5312            return String::new();
5313        };
5314        let proto = f.closure.proto;
5315        let p: &crate::runtime::Proto = &proto;
5316        let pc = f.pc as usize;
5317        if pc == 0 || pc > p.code.len() {
5318            return String::new();
5319        }
5320        let instr = p.code[pc - 1];
5321        match instr.op() {
5322            Op::Call | Op::TailCall => {
5323                let reg = instr.a();
5324                if self.r(f.base, reg).raw_eq(bad) {
5325                    match crate::vm::objname::getobjname(p, pc - 1, reg) {
5326                        Some((kind, name)) => format!(" ({kind} '{name}')"),
5327                        None => String::new(),
5328                    }
5329                } else {
5330                    String::new()
5331                }
5332            }
5333            op => match mm_event_name(op) {
5334                Some(ev) => format!(" (metamethod '{ev}')"),
5335                None => String::new(),
5336            },
5337        }
5338    }
5339
5340    /// "number has no integer representation", enriched (PUC luaG_tointerror)
5341    /// with a "(field 'x')"-style suffix naming the offending operand of the
5342    /// current arithmetic instruction when it can be recovered from bytecode.
5343    fn no_int_rep_err(&mut self) -> LuaError {
5344        let extra = self.bad_operand_varinfo();
5345        self.rt_err(&format!("number{extra} has no integer representation"))
5346    }
5347
5348    /// Inspect the current frame's faulting instruction: find the register
5349    /// operand holding a float with no integer representation and name it.
5350    fn bad_operand_varinfo(&self) -> String {
5351        let Some(f) = self.frames.last().and_then(CallFrame::lua) else {
5352            return String::new();
5353        };
5354        let proto = f.closure.proto;
5355        let p: &crate::runtime::Proto = &proto;
5356        let pc = f.pc as usize;
5357        if pc == 0 || pc > p.code.len() {
5358            return String::new();
5359        }
5360        let instr = p.code[pc - 1];
5361        let mut regs = vec![instr.b()];
5362        if !instr.k() {
5363            regs.push(instr.c());
5364        }
5365        for reg in regs {
5366            let v = self.r(f.base, reg);
5367            if matches!(v, Value::Float(x) if crate::runtime::value::f2i_exact(x).is_none()) {
5368                return match crate::vm::objname::getobjname(p, pc - 1, reg) {
5369                    Some((kind, name)) => format!(" ({kind} '{name}')"),
5370                    None => String::new(),
5371                };
5372            }
5373        }
5374        String::new()
5375    }
5376
5377    /// Position prefix of the currently executing Lua frame. PUC `luaL_error`
5378    /// calls `luaL_where(L, 1)` which reads `L->ci->previous`. When the prior
5379    /// frame is a C function (e.g. a pcall Cont parked above `require`'s
5380    /// native call), PUC pushes no prefix — match that by looking only at the
5381    /// topmost frame directly and bailing if it is anything but a Lua frame.
5382    pub(crate) fn position_prefix(&self) -> Option<String> {
5383        let f = self.frames.last().and_then(CallFrame::lua)?;
5384        let proto = f.closure.proto;
5385        if proto.source.as_bytes().is_empty() {
5386            return Some(self.stripped_prefix());
5387        }
5388        if proto.lines.is_empty() {
5389            return None;
5390        }
5391        let line = proto.lines[(f.pc as usize).saturating_sub(1).min(proto.lines.len() - 1)];
5392        // 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).
5393        let raw = unsafe { crate::runtime::string::bytes_of(proto.source.as_ptr()) };
5394        let display = crate::vm::lib_debug::chunk_id(raw);
5395        let src = String::from_utf8_lossy(&display).into_owned();
5396        Some(format!("{src}:{line}: "))
5397    }
5398
5399    /// PUC `luaG_addinfo` prefix for a stripped chunk. 5.5 substitutes "=?"
5400    /// for the source and renders the line as "?" (so the prefix reads
5401    /// `?:?: `). 5.4 and below leave the source NULL ("?") and use the raw
5402    /// `getfuncline = -1`, so the prefix reads `?:-1: ` (5.4 errors.lua :282
5403    /// matches `^%?:%-1:`).
5404    fn stripped_prefix(&self) -> String {
5405        if self.version >= crate::version::LuaVersion::Lua55 {
5406            "?:?: ".to_string()
5407        } else {
5408            "?:-1: ".to_string()
5409        }
5410    }
5411
5412    /// Position prefix of the Lua frame `level` steps up from the running C
5413    /// function (PUC `luaL_where(L, level)`): `level == 1` is the immediate
5414    /// Lua caller (skipping Cont/C-boundary frames the way `dbg_frame` does),
5415    /// `level == 2` its caller, and so on. Used by `error(msg, level)` so the
5416    /// caller's frame is reported even across pcall/xpcall continuations.
5417    /// `luaL_where(level)` for `error()`: unlike `dbg_frame` (whose 5.2+
5418    /// level numbering skips Cont activations to match db.lua's getinfo
5419    /// shape), PUC counts EVERY CallInfo — a C caller occupies a level of
5420    /// its own. `pcall(pcall, error, "msg")` must therefore resolve
5421    /// level 1 to the inner pcall (a C activation, no line info → no
5422    /// prefix), not tunnel through to the Lua frame below (v2.13
5423    /// CORPUS-IV fixture 239).
5424    pub(crate) fn position_prefix_at_level(&self, level: i64) -> Option<String> {
5425        if level < 1 {
5426            return None;
5427        }
5428        let v51 = self.version <= LuaVersion::Lua51;
5429        let mut lvl = level;
5430        let mut found: Option<usize> = None;
5431        'walk: for fi in (0..self.frames.len()).rev() {
5432            match &self.frames[fi] {
5433                CallFrame::Lua(f) => {
5434                    lvl -= 1;
5435                    if lvl == 0 {
5436                        found = Some(fi);
5437                        break 'walk;
5438                    }
5439                    if v51 {
5440                        for _ in 0..f.tailcalls {
5441                            lvl -= 1;
5442                            if lvl == 0 {
5443                                return None; // synthetic tail level: no line info
5444                            }
5445                        }
5446                    }
5447                    if f.from_c {
5448                        lvl -= 1;
5449                        if lvl == 0 {
5450                            return None; // C activation: no line info
5451                        }
5452                    }
5453                }
5454                CallFrame::Cont(_) => {
5455                    // A continuation-driven native (pcall/xpcall/close)
5456                    // is a C activation — it takes a level and has no
5457                    // line info.
5458                    lvl -= 1;
5459                    if lvl == 0 {
5460                        return None;
5461                    }
5462                }
5463            }
5464        }
5465        let fi = found?;
5466        let f = self.frames[fi].lua()?;
5467        let proto = f.closure.proto;
5468        // PUC luaG_addinfo: a stripped chunk has no source — see
5469        // `stripped_prefix` for the per-version wording (5.5 vs ≤5.4).
5470        if proto.source.as_bytes().is_empty() {
5471            return Some(self.stripped_prefix());
5472        }
5473        // a stripped chunk carries no per-instruction line info
5474        if proto.lines.is_empty() {
5475            return None;
5476        }
5477        let line = proto.lines[(f.pc as usize).saturating_sub(1).min(proto.lines.len() - 1)];
5478        // PUC `luaG_addinfo` renders source via `luaO_chunkid` (LUA_IDSIZE=60),
5479        // not the raw chunk name — handles `@file`/`=name` sigils + truncation.
5480        // 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).
5481        let raw = unsafe { crate::runtime::string::bytes_of(proto.source.as_ptr()) };
5482        let display = crate::vm::lib_debug::chunk_id(raw);
5483        let src = String::from_utf8_lossy(&display).into_owned();
5484        Some(format!("{src}:{line}: "))
5485    }
5486
5487    // ---- the interpreter ----
5488
5489    fn exec(&mut self) -> Result<Vec<Value>, LuaError> {
5490        let entry_depth = self.frames.len();
5491        self.exec_with(entry_depth)
5492    }
5493
5494    /// Run from the current top frame down to (but not past) `entry_depth`
5495    /// frames. Coroutine driving passes `entry_depth = 1` so the whole thread
5496    /// runs to completion or a yield.
5497    /// v1.1 B10 Stage 1 — resume the dispatcher from the saved
5498    /// `entry_depth` (captured pre-yield by `drive_one`). Called by
5499    /// `EvalFuture::poll` on every poll after the first to walk the
5500    /// existing call frames until the next `BudgetExhausted` or
5501    /// terminal `Ok`/`Err`. Not a public-API surface in Stage 1; the
5502    /// embedder reaches it through `Vm::eval_async`.
5503    pub(crate) fn exec_with_async(&mut self, entry_depth: usize) -> Result<Vec<Value>, LuaError> {
5504        self.exec_with(entry_depth)
5505    }
5506
5507    fn exec_with(&mut self, entry_depth: usize) -> Result<Vec<Value>, LuaError> {
5508        loop {
5509            let r = self.run(entry_depth);
5510            if r.is_err()
5511                && (self.yielding.is_some()
5512                    || self.terminating.is_some()
5513                    || self.host_yield_pending
5514                    || self.pending_async_native_fut.is_some())
5515            {
5516                // a `coroutine.yield` is in flight: keep the frames intact (they
5517                // are the suspended coroutine's saved state) and propagate to
5518                // resume. A self-close termination propagates the same way, so a
5519                // protecting pcall on the way out cannot catch (unwind) it.
5520                // v1.1 B10 — `host_yield_pending` is the async-mode
5521                // analogue: the sentinel must reach `drive_one` without
5522                // a protecting `pcall` swallowing it.
5523                return r;
5524            }
5525            match r {
5526                Ok(vals) => return Ok(vals),
5527                // unwind toward `entry_depth`. A protecting pcall/xpcall
5528                // continuation caught along the way turns the error into
5529                // `false, msg` and the loop resumes running its caller; an
5530                // uncaught error propagates out.
5531                Err(e) => match self.unwind(e.0, entry_depth) {
5532                    Unwound::Caught => continue,
5533                    Unwound::CaughtReturn(vals) => return Ok(vals),
5534                    Unwound::Propagated(err) => return Err(err),
5535                },
5536            }
5537        }
5538    }
5539
5540    /// Unwind the call stack from the error point toward `entry_depth`, running
5541    /// `__close` handlers on each Lua frame. Stops at the first pcall/xpcall
5542    /// continuation frame at/above `entry_depth` (the error is *caught*: its
5543    /// slot receives `false, msg`); if none is reached, the error propagates.
5544    fn unwind(&mut self, mut err: Value, entry_depth: usize) -> Unwound {
5545        // The protected call runs in-place among the caller frames' registers,
5546        // so truncating the failed frames here cuts into caller windows below
5547        // the catcher. Snapshot the live length: at the error point the stack
5548        // already spans every surviving frame's window, so restoring it after a
5549        // catch reinstates them all (the reclaimed slots above are dead temps).
5550        // PUC handles overflow recovery via a separate EXTRA_STACK reserve;
5551        // we instead clamp the restore to the catcher's caller window when the
5552        // error point was at the stack limit (cause: the next `call_value_impl`
5553        // picks `func_slot = stack.len()` which would otherwise re-overflow).
5554        let saved_len = self.stack.len();
5555        // Snapshot the traceback at the error point — before any frame is
5556        // popped — so an `xpcall` msgh (which runs after the failed frames are
5557        // gone) can still describe the error site. The handler frame about to
5558        // be popped (e.g. a `__close` handler with `tm = Some("close")`) is
5559        // visible here; once popped, `debug.traceback` would miss it.
5560        // PUC instead runs msgh with the failed stack intact (luaG_errormsg);
5561        // but doing so when the stack is near `MAX_LUA_STACK` (true overflow
5562        // recovery — locals.lua:659) re-overflows. Capture-once propagates
5563        // through nested unwinds (inner→outer) without re-running msgh.
5564        if self.error_traceback.is_none() {
5565            self.error_traceback = Some(self.traceback_bytes(1));
5566        }
5567        while self.frames.len() >= entry_depth {
5568            match *self.frames.last().expect("frame") {
5569                // a yieldable-metamethod continuation does not catch: discard the
5570                // abandoned instruction and keep unwinding (PUC drops the partial
5571                // op on error).
5572                CallFrame::Cont(NativeCont {
5573                    kind: ContKind::Meta(mc),
5574                    func_slot,
5575                    ..
5576                }) => {
5577                    frames_pop_sync(&mut self.frames, &mut self.frames_top);
5578                    self.stack.truncate(func_slot as usize);
5579                    self.top = mc.saved_top.min(func_slot);
5580                    self.tbc.retain(|&s| s < func_slot);
5581                }
5582                // a __pairs continuation does not catch either: an error inside
5583                // the metamethod propagates past `pairs`.
5584                CallFrame::Cont(NativeCont {
5585                    kind: ContKind::Pairs,
5586                    func_slot,
5587                    ..
5588                }) => {
5589                    frames_pop_sync(&mut self.frames, &mut self.frames_top);
5590                    self.stack.truncate(func_slot as usize);
5591                    self.top = func_slot;
5592                    self.tbc.retain(|&s| s < func_slot);
5593                }
5594                // a __close continuation does not catch: drop the half-run
5595                // handler's window, then continue the close yieldably with
5596                // the new error threaded as `pending`. Preserve `cc.after`
5597                // verbatim — `Return`/`Block` originating from an aborting
5598                // OP_Return/OP_Close will be short-circuited by
5599                // `finish_close_after` (pending propagates as Err); a
5600                // `ResumeUnwind` originated by our own Lua-frame handler
5601                // must keep its deferred frame-pop semantics so that frame
5602                // is not orphaned. If a fresh handler yields, `drive_close`
5603                // pushes another `Cont::Close` and we return `Caught` so
5604                // `exec_with` re-enters the run loop.
5605                CallFrame::Cont(NativeCont {
5606                    kind: ContKind::Close(cc),
5607                    func_slot,
5608                    ..
5609                }) => {
5610                    frames_pop_sync(&mut self.frames, &mut self.frames_top);
5611                    self.stack.truncate(func_slot as usize);
5612                    self.top = func_slot;
5613                    self.tbc.retain(|&s| s < func_slot);
5614                    match self.drive_close(cc.from, Some(err), cc.after, entry_depth) {
5615                        Ok(Some(_)) => {
5616                            unreachable!(
5617                                "Block / Return / ResumeUnwind never return host values mid-unwind"
5618                            )
5619                        }
5620                        Ok(None) => return Unwound::Caught,
5621                        Err(e) => {
5622                            err = e.0;
5623                            continue;
5624                        }
5625                    }
5626                }
5627                CallFrame::Cont(nc) => {
5628                    frames_pop_sync(&mut self.frames, &mut self.frames_top);
5629                    self.pcall_depth -= 1;
5630                    let result = match nc.kind {
5631                        ContKind::Pcall => err,
5632                        ContKind::Xpcall { handler } => {
5633                            // PUC keeps `L->errfunc` set across the handler's
5634                            // call: `luaG_errormsg` re-fires the handler when
5635                            // it raises (so `xpcall(error, err, 170)` lets the
5636                            // chain bottom out at err(0) → "END"). luna mirrors
5637                            // that by looping until the handler returns or
5638                            // luna's `iters` cap forces termination.
5639                            //
5640                            // The cap models PUC's nCcalls soft window
5641                            // (MAXCCALLS/10*11): once tripped, `stackerror`
5642                            // raises "C stack overflow" via `luaG_runerror`
5643                            // which itself re-enters `luaG_errormsg`, so the
5644                            // handler runs once more with that string and
5645                            // naturally returns it (errors.lua :637 at N=300).
5646                            // We count iterations per Cont::Xpcall rather than
5647                            // a global counter — nested xpcalls each get their
5648                            // own budget, matching the way PUC's stack frames
5649                            // accumulate per dispatch path.
5650                            const MSGH_CAP: u32 = MAX_C_DEPTH;
5651                            let mut cur_err = err;
5652                            let mut iters: u32 = 0;
5653                            let mut capped = false;
5654                            loop {
5655                                if iters >= MSGH_CAP && !capped {
5656                                    cur_err = Value::Str(self.heap.intern(b"C stack overflow"));
5657                                    capped = true;
5658                                }
5659                                iters += 1;
5660                                self.msgh_depth += 1;
5661                                let r = self.call_value(handler, &[cur_err]);
5662                                self.msgh_depth -= 1;
5663                                match r {
5664                                    Ok(hr) => {
5665                                        break hr.first().copied().unwrap_or(Value::Nil);
5666                                    }
5667                                    Err(_) if capped => {
5668                                        // the handler still errored on the
5669                                        // synthesized "C stack overflow"; fall
5670                                        // back to PUC's LUA_ERRERR string.
5671                                        break Value::Str(
5672                                            self.heap.intern(b"error in error handling"),
5673                                        );
5674                                    }
5675                                    Err(e) => {
5676                                        cur_err = e.0;
5677                                    }
5678                                }
5679                            }
5680                        }
5681                        ContKind::Meta(_) | ContKind::Pairs | ContKind::Close(_) => {
5682                            unreachable!("Meta/Pairs/Close cont handled above")
5683                        }
5684                    };
5685                    // PUC 5.5 `luaG_errormsg` substitutes "<no error object>"
5686                    // for nil AFTER the message handler ran (ldebug.c:849) —
5687                    // so it applies to the pcall-caught object and to an
5688                    // xpcall HANDLER'S return value, while the handler itself
5689                    // (and a top-level propagation into the host, whose
5690                    // `error_display` plays msghandler) still sees the raw
5691                    // nil. 5.4- keep nil everywhere (errors.lua :49 asserts
5692                    // `doit("error()") == nil`). v2.14 fixture 5.5/334.
5693                    let result = if matches!(result, Value::Nil)
5694                        && self.version >= crate::version::LuaVersion::Lua55
5695                    {
5696                        Value::Str(self.heap.intern(b"<no error object>"))
5697                    } else {
5698                        result
5699                    };
5700                    // the error has been caught (pcall/xpcall): the captured
5701                    // traceback was for that error and is no longer in flight.
5702                    self.error_traceback = None;
5703                    let fs = nc.func_slot as usize;
5704                    if self.stack.len() < fs + 2 {
5705                        self.stack.resize(fs + 2, Value::Nil);
5706                    }
5707                    self.stack[fs] = Value::Bool(false);
5708                    self.stack[fs + 1] = result;
5709                    self.top = nc.func_slot + 2;
5710                    self.tbc.retain(|&s| s < nc.func_slot);
5711                    if self.frames.len() < entry_depth {
5712                        return Unwound::CaughtReturn(self.take_results(nc.func_slot));
5713                    }
5714                    self.finish_results(nc.func_slot, 2, nc.nresults);
5715                    // reinstate the caller windows the unwind truncated into,
5716                    // clamped to the catcher's caller window + a `MIN_STACK`
5717                    // reserve. The clamp is a no-op for normal pcall catches
5718                    // (saved_len lies within the caller's max_stack window),
5719                    // and prevents the stack from staying near `MAX_LUA_STACK`
5720                    // after an overflow-recovery catch — which would make the
5721                    // next `call_value_impl` (e.g. a `__close` in the catcher's
5722                    // errorh, locals.lua:659) pick `func_slot = stack.len()`
5723                    // above the limit and re-overflow.
5724                    // Restore the caller's full register window: opcodes
5725                    // index it directly. The cap covers caller's base +
5726                    // `max_stack` + a small reserve. We always resize to
5727                    // exactly this window — previously this clamped
5728                    // `saved_len` from above to prevent staying near
5729                    // `MAX_LUA_STACK` after an overflow-recovery catch, and
5730                    // a yieldable-unwind re-entry adds the dual case where
5731                    // `saved_len` is *below* the window (a prior
5732                    // `ResumeUnwind` truncated). Using the window directly
5733                    // covers both.
5734                    let restore = self
5735                        .frames
5736                        .iter()
5737                        .rev()
5738                        .find_map(CallFrame::lua)
5739                        .map(|c| (c.base + c.closure.proto.max_stack as u32) as usize + 256)
5740                        .unwrap_or(saved_len);
5741                    if self.stack.len() < restore {
5742                        self.stack.resize(restore, Value::Nil);
5743                    } else if self.stack.len() > restore {
5744                        self.stack.truncate(restore);
5745                    }
5746                    // v2.5 P1B-2B: clear slots vacated by the popped
5747                    // frames the unwind walked over. finish_results
5748                    // above clears `[nc.func_slot + nresults ..
5749                    // nc.func_slot + 2)`, which only covers the
5750                    // pcall's own result region — the unwind-popped
5751                    // frames' locals in `[nc.func_slot + 2 .. restore)`
5752                    // are still in place with whatever Gc-bearing
5753                    // Values they last held. Without this clear, a
5754                    // later GC marks the stale pointers (UAF-A family
5755                    // analog of the v2.3 Op::Return finish_results
5756                    // path). PUC's `luaD_pcall` similarly truncates
5757                    // L->top to the catcher's level — luna's
5758                    // truncate above resizes the Vec but doesn't
5759                    // touch slots [func_slot+2..restore) that were
5760                    // already present.
5761                    let clear_lo = (nc.func_slot as usize + 2).min(self.stack.len());
5762                    let clear_hi = restore.min(self.stack.len());
5763                    if clear_lo < clear_hi {
5764                        for slot in &mut self.stack[clear_lo..clear_hi] {
5765                            *slot = Value::Nil;
5766                        }
5767                    }
5768                    return Unwound::Caught;
5769                }
5770                CallFrame::Lua(f) => {
5771                    // Yieldable error-unwind close, PUC luaG_errormsg shape:
5772                    // (1) pop the Lua frame immediately so each `__close`
5773                    // handler runs at the C boundary above — `debug.getinfo`
5774                    // sees the next outer Lua frame's call site (typically
5775                    // `pcall`), not this aborting function (locals.lua:480).
5776                    // (2) drive the close yieldably with
5777                    // `AfterClose::ResumeUnwind { func_slot, err }`; on drain
5778                    // it truncates to `func_slot` and re-raises (letting a
5779                    // handler-raised error win over `err`). If a handler
5780                    // yields, `drive_close` pushes `Cont::Close` and we
5781                    // return `Caught` so `exec_with` re-enters the run loop;
5782                    // a synchronous drain returns Err exactly as the old
5783                    // path did.
5784                    frames_pop_sync(&mut self.frames, &mut self.frames_top);
5785                    let after = AfterClose::ResumeUnwind {
5786                        func_slot: f.func_slot,
5787                        err,
5788                    };
5789                    match self.begin_close(f.base, Some(err), after, entry_depth) {
5790                        Ok(Some(_)) => {
5791                            unreachable!("ResumeUnwind never returns host values")
5792                        }
5793                        Ok(None) => return Unwound::Caught,
5794                        Err(e) => {
5795                            err = e.0;
5796                            continue;
5797                        }
5798                    }
5799                }
5800            }
5801        }
5802        Unwound::Propagated(LuaError(err))
5803    }
5804
5805    fn run(&mut self, entry_depth: usize) -> Result<Vec<Value>, LuaError> {
5806        loop {
5807            // Fast-path slow-check gate: most embedders run with both
5808            // `instr_budget` and `mem_cap` as None, so a single combined
5809            // is_some test lets the hot loop skip both branches with one
5810            // load + branch instead of two.
5811            if self.instr_budget.is_some() || self.heap.mem_cap.is_some() {
5812                if let Some(b) = self.instr_budget.as_mut() {
5813                    *b -= 1;
5814                    if *b <= 0 {
5815                        self.instr_budget = None;
5816                        // v1.1 B10 Stage 1 — async-mode cooperative
5817                        // yield. Set a sentinel flag so `exec_with`
5818                        // propagates the Err without `unwind` running
5819                        // (mirroring the `yielding.is_some()` path),
5820                        // and `call_value_impl` preserves the call
5821                        // frames for the next `poll`. Translation back
5822                        // to `DispatchOutcome::BudgetExhausted` happens
5823                        // in `drive_one`. The Err value itself is
5824                        // `Value::Nil` — a pure sentinel, never seen by
5825                        // user code.
5826                        if self.async_mode {
5827                            self.host_yield_pending = true;
5828                            return Err(LuaError(Value::Nil));
5829                        }
5830                        // B6: classify the trip so embedders can
5831                        // distinguish budget exhaustion from a
5832                        // generic Runtime error and retry / give up
5833                        // accordingly.
5834                        self.last_error_kind = crate::vm::error::LuaErrorKind::InstrBudget;
5835                        let s = Value::Str(self.heap.intern(b"instruction budget exceeded"));
5836                        return Err(LuaError(s));
5837                    }
5838                }
5839                if let Some(cap) = self.heap.mem_cap
5840                    && self.heap.bytes() > cap
5841                {
5842                    // First try a full collect — embedders set tight caps
5843                    // and the overshoot may be reclaimable (closures kept
5844                    // by short-lived frames, intermediate strings). Only
5845                    // disarm + raise if the cap is still breached after
5846                    // collection. PUC's `LUA_GCEMERGENCY` path matches.
5847                    //
5848                    // v2.6 A.2: tighten mem-cap-fire over-root from
5849                    // entire `self.stack.len()` (whole heap) to the
5850                    // deepest Lua frame's `base + max_stack` window
5851                    // (covers register operands the current opcode
5852                    // might reference). The cap fires during table
5853                    // mutation in a tight `a[i] = i` loop where `a`
5854                    // lives at a frame-register slot past `self.top`
5855                    // (OP_NEWINDEX doesn't advance top); the deepest
5856                    // frame's max_stack window provably covers it
5857                    // since `a` is a register of the executing proto.
5858                    //
5859                    // Still over-roots caller frames' dead regs
5860                    // (slots between caller.base and the callee
5861                    // func_slot are live; slots past callee
5862                    // func_slot in caller's frame are dead until
5863                    // caller resumes). For fire-once cap path this
5864                    // residual over-root is acceptable; full
5865                    // per-frame walk was canceled per
5866                    // `.dev/rfcs/v2.6-plan-state.md` amendments log
5867                    // (charter §2.1's strong/weak pass split is
5868                    // semantically impossible — weak pass depends on
5869                    // strong-pass marks).
5870                    let cap_root_top = self
5871                        .frames
5872                        .iter()
5873                        .rev()
5874                        .find_map(CallFrame::lua)
5875                        .map(|f| f.base + f.closure.proto.max_stack as u32)
5876                        .unwrap_or(self.top);
5877                    self.gc_top = cap_root_top.max(self.top);
5878                    self.collect_garbage();
5879                    if self.heap.bytes() > cap {
5880                        self.heap.mem_cap = None;
5881                        let s = Value::Str(self.heap.intern(b"memory cap exceeded"));
5882                        return Err(LuaError(s));
5883                    }
5884                }
5885            }
5886            // Single combined frame fetch: continuation arm OR Lua arm. Saves
5887            // a second `self.frames.last()` slice access vs the prior split
5888            // form (LLVM doesn't always CSE these across the cont branch).
5889            // A continuation frame on top means the call it protected just
5890            // delivered its results — wrap as `true, results…` and hand to
5891            // the pcall/xpcall caller. The error path is handled by `unwind`;
5892            // this branch is only reached on success/resume completion.
5893            // 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).
5894            let frame_peek = unsafe { self.frames.last().unwrap_unchecked() };
5895            if let &CallFrame::Cont(nc) = frame_peek {
5896                // a yieldable metamethod returned: complete the interrupted
5897                // instruction (PUC luaV_finishOp) and resume the running frame.
5898                if let ContKind::Meta(mc) = nc.kind {
5899                    frames_pop_sync(&mut self.frames, &mut self.frames_top);
5900                    let result = if self.top > nc.func_slot {
5901                        self.stack[nc.func_slot as usize]
5902                    } else {
5903                        Value::Nil
5904                    };
5905                    self.stack.truncate(nc.func_slot as usize);
5906                    self.top = mc.saved_top;
5907                    self.finish_meta(mc.action, result)?;
5908                    continue;
5909                }
5910                // a __close handler returned successfully: discard its
5911                // results, restore `top` to the slot the handler was called
5912                // at (the surrounding frame's register window above this slot
5913                // must stay alloc'd — never truncate the underlying stack),
5914                // then continue the close chain (next slot, or fire
5915                // AfterClose). When the close ends an entry activation,
5916                // drive_close hands the results up to exec_with directly.
5917                if let ContKind::Close(cc) = nc.kind {
5918                    frames_pop_sync(&mut self.frames, &mut self.frames_top);
5919                    self.top = nc.func_slot;
5920                    if let Some(vals) =
5921                        self.drive_close(cc.from, cc.pending, cc.after, entry_depth)?
5922                    {
5923                        return Ok(vals);
5924                    }
5925                    continue;
5926                }
5927                // __pairs returned: normalize its results to exactly four
5928                // (iterator, state, control, closing) at pairs's slot, where
5929                // the metamethod was called, and hand them to pairs's caller.
5930                if let ContKind::Pairs = nc.kind {
5931                    frames_pop_sync(&mut self.frames, &mut self.frames_top);
5932                    let total = 4u32;
5933                    let need = (nc.func_slot + total) as usize;
5934                    if self.stack.len() < need {
5935                        self.stack.resize(need, Value::Nil);
5936                    }
5937                    for s in self.top..(nc.func_slot + total) {
5938                        self.stack[s as usize] = Value::Nil;
5939                    }
5940                    self.top = nc.func_slot + total;
5941                    if self.frames.len() < entry_depth {
5942                        return Ok(self.take_results(nc.func_slot));
5943                    }
5944                    self.finish_results(nc.func_slot, total, nc.nresults);
5945                    continue;
5946                }
5947                frames_pop_sync(&mut self.frames, &mut self.frames_top);
5948                self.pcall_depth -= 1;
5949                // f's results sit at nc.func_slot+1.. (f was called one slot
5950                // above the continuation), so writing `true` at the slot makes
5951                // `true, results…` already contiguous.
5952                let nret = self.top - (nc.func_slot + 1);
5953                self.stack[nc.func_slot as usize] = Value::Bool(true);
5954                let total = 1 + nret;
5955                self.top = nc.func_slot + total;
5956                if self.frames.len() < entry_depth {
5957                    return Ok(self.take_results(nc.func_slot));
5958                }
5959                self.finish_results(nc.func_slot, total, nc.nresults);
5960                continue;
5961            }
5962            // GC runs only at the allocation safe points below (PUC's
5963            // `luaC_checkGC` sites), each with a precise `gc_top`; the loop head
5964            // no longer collects, so a stale full-window `gc_top` cannot leak in.
5965            //
5966            // Hot-path frame fetch: the Cont arm above continues the loop,
5967            // so reaching here means `frame_peek` is the Lua frame. Reuse it
5968            // rather than re-fetching `self.frames.last()`.
5969            let f = match frame_peek {
5970                CallFrame::Lua(f) => f,
5971                _ => unreachable!("Cont frame survived the dispatch loop head"),
5972            };
5973            let cl = f.closure;
5974            let base = f.base;
5975            let func_slot = f.func_slot;
5976            let n_varargs = f.n_varargs;
5977            let pc = f.pc;
5978            let oldpc = f.hook_oldpc;
5979
5980            // SAFETY: `pc` is bounded by the compiler against `proto.code.len()`
5981            // — every branch / call op only sets `pc` to a valid index, and
5982            // function entry initialises pc=0 with a non-empty body. PUC's
5983            // `vmfetch` uses the equivalent unchecked load.
5984            let inst = unsafe { *cl.proto.code.get_unchecked(pc as usize) };
5985
5986            // P12-S1.C/D — trace recording append + close detection.
5987            // Gated on `trace_jit_enabled` + `active_trace.is_some()`
5988            // so default dispatch keeps a single not-taken branch.
5989            //
5990            // - At the head PC with a non-empty record, the trace has
5991            //   looped back to its start: mark `closed = true` and
5992            //   take the record (S2 will compile + cache).
5993            // - Otherwise, capture the op. If the record overflows
5994            //   MAX_TRACE_LEN, abort by dropping it.
5995            if self.jit.trace_enabled
5996                && let Some(_rec) = self.jit.active_trace.as_mut()
5997            {
5998                // P12-S4 — depth tracking. The trace head's frame is
5999                // at index `recording_frame_base`; every Op::Call that
6000                // pushes a new frame bumps the live depth, every
6001                // Op::Return that pops one decrements it.
6002                //
6003                // **Three clean-close conditions** (P12-S4-step4a):
6004                // - `at_head`: cur_depth == 0 AND about-to-execute the
6005                //   trace's head_pc on its head_proto (loop closed back
6006                //   to start). Same for loop-triggered and call-triggered
6007                //   traces — step4a unified the gating so call-triggered
6008                //   no longer closes on the first re-entry (that left
6009                //   fib's body at 7 depth=0 ops; step4a lets it inline
6010                //   up to MAX_INLINE_DEPTH levels before any close).
6011                // - `returned_past_head`: trace head's frame is gone
6012                //   (callee returned past it, or the call-trigger
6013                //   started a recording inside a callee that has now
6014                //   returned). Whatever ops were recorded form the
6015                //   trace body; the lowerer treats the partial trace
6016                //   the same as InlineAbort (dispatchable=false until
6017                //   step4b's frame materialization lands).
6018                // - `depth_cap_hit`: cur_depth > MAX_INLINE_DEPTH.
6019                //   Recording any deeper would just bloat the IR; close
6020                //   with the body we have. Lowerer's existing length
6021                //   gate + InlineAbort path handles short bodies.
6022                let returned_past_head = self.frames.len() <= self.jit.recording_frame_base;
6023                let cur_depth = if returned_past_head {
6024                    0
6025                } else {
6026                    self.frames.len() - 1 - self.jit.recording_frame_base
6027                };
6028                let depth_cap_hit = cur_depth > crate::jit::trace::MAX_INLINE_DEPTH as usize;
6029                let rec = self.jit.active_trace.as_mut().expect("just checked Some");
6030                let at_head_loop = cur_depth == 0
6031                    && !rec.ops.is_empty()
6032                    && !returned_past_head
6033                    && std::ptr::eq(cl.proto.as_ptr(), rec.head_proto.as_ptr())
6034                    && pc == rec.head_pc;
6035                // P16-A — self-link cycle catch (mirrors LuaJIT's
6036                // `check_call_unroll` at `lj_record.c:1869`). Trips when:
6037                //   1. We're about to execute the head_pc on head_proto
6038                //      at depth > 0 (we're re-entering the trace head
6039                //      from inside an inlined recursion level — UpRec).
6040                //   2. The count of ancestor frames in the recording
6041                //      window that share `head_proto` exceeds
6042                //      [`RECUNROLL_THRESHOLD`] (default 2).
6043                // For fib(N): head_pc=0, head_proto=fib. After 2 inline
6044                // recursion levels are captured, the recorder enters
6045                // the 3rd nested fib frame, sees cur_depth=3 > 2, and
6046                // trips this catch — closing with `SelfRecKind::UpRec`.
6047                // The lowerer's `TraceEnd::SelfLink` tail emits the
6048                // bump-base + branch-to-self loop body.
6049                //
6050                // TailRec vs UpRec: LJ distinguishes via
6051                // `framedepth + retdepth == 0`. luna doesn't track
6052                // retdepth separately; cur_depth == 0 with a non-empty
6053                // call chain in tail position is rare (would require
6054                // explicit Lua TCO). We use cur_depth > 0 as the UpRec
6055                // condition (fib's case); cur_depth == 0 with positive
6056                // ancestor count would route to TailRec, but luna's
6057                // recorder doesn't currently produce that shape because
6058                // tail-call elision pops the caller frame and we'd
6059                // hit `at_head_loop` instead.
6060                let self_link_trip: Option<crate::jit::trace::SelfRecKind> = {
6061                    if self.jit.p16_self_link_enabled
6062                        && !returned_past_head
6063                        && std::ptr::eq(cl.proto.as_ptr(), rec.head_proto.as_ptr())
6064                        && pc == rec.head_pc
6065                        && cur_depth > 0
6066                    {
6067                        // Count ancestor frames sharing head_proto.
6068                        // self.frames[recording_frame_base..] currently
6069                        // includes the just-pushed frame at the top
6070                        // (the one about to execute head_pc). Ancestors
6071                        // = the slice excluding the top frame.
6072                        let head_proto_ptr = rec.head_proto.as_ptr();
6073                        let last_idx = self.frames.len() - 1;
6074                        let mut count = 0usize;
6075                        for i in self.jit.recording_frame_base..last_idx {
6076                            if let CallFrame::Lua(f) = &self.frames[i]
6077                                && std::ptr::eq(f.closure.proto.as_ptr(), head_proto_ptr)
6078                            {
6079                                count += 1;
6080                            }
6081                        }
6082                        if count > crate::jit::trace::RECUNROLL_THRESHOLD {
6083                            // cur_depth > 0 → UpRec (fib pattern).
6084                            // cur_depth == 0 wouldn't reach this arm.
6085                            Some(crate::jit::trace::SelfRecKind::UpRec)
6086                        } else {
6087                            None
6088                        }
6089                    } else {
6090                        None
6091                    }
6092                };
6093                if let Some(kind) = self_link_trip {
6094                    // v2.0 Track-R R3.3+ sub-0 — SelfLink relax for
6095                    // self-recursive patterns at frame depth >= 2.
6096                    //
6097                    // Pre sub-0: a SelfLink trip at the head_pc re-entry
6098                    // unconditionally stamped `self_link_kind`. The
6099                    // R3a `downrec_close` marker can only fire from the
6100                    // depth>0 Op::Return path (`rec.retfs` chain),
6101                    // which never reaches the recorder for fib(28)-like
6102                    // shapes that hit the SelfLink cycle catch BEFORE
6103                    // any base-case Return — leaving `downrec_close`
6104                    // None and routing the trace through R1's safe
6105                    // `dispatchable=false` `"self-link-retf-r1"` path
6106                    // (audit measured `trace_dispatched = 0`).
6107                    //
6108                    // Sub-0 lift: when the SelfLink trip fires AND
6109                    // `cur_depth >= 2` (the count > RECUNROLL_THRESHOLD
6110                    // gate already requires this — kept explicit as a
6111                    // safety floor), route the close through `downrec_
6112                    // close` INSTEAD of `self_link_kind`. The recorder
6113                    // synthesises the close marker from the most
6114                    // recent Op::Call at depth `cur_depth - 1`:
6115                    //   - `return_pc` = `call.pc + 1` (caller's resume
6116                    //     PC after the recursive call returns; mirror
6117                    //     of R3a's `caller_pc` derivation at the
6118                    //     depth>0 Op::Return capture path below).
6119                    //   - `target_proto` = `call.proto` (caller's
6120                    //     proto; equals `rec.head_proto` for self-
6121                    //     recursion).
6122                    //   - `depth_delta` = `1` (today's recorder always
6123                    //     unrolls one level; R3a uses the same
6124                    //     constant).
6125                    //
6126                    // The lowerer's `end_idx` picker (`trace.rs:3729`)
6127                    // routes through `TraceEnd::DownRec` ahead of the
6128                    // `self_link_kind` arm; the R3b/R3d lowerer arm
6129                    // emits the stitch-sentinel + caller-pc-guard
6130                    // scaffold. Single-candidate guard chain (sub-0's
6131                    // recorder produces 1 caller_pc candidate because
6132                    // `rec.retfs` is empty) keeps `dispatchable=false`
6133                    // + `"downrec-stitch-pending"` label (per R3d's
6134                    // `multi_way_candidate_count >= 2` gate at
6135                    // `trace.rs:7385`). Net behaviour: trace compiles
6136                    // under DownRec routing; interp runs the
6137                    // recursion naturally → result 317811.
6138                    //
6139                    // The `cur_depth >= 2` gate is automatically
6140                    // satisfied by the count > RECUNROLL_THRESHOLD=2
6141                    // trip condition (3 ancestor frames sharing
6142                    // head_proto implies cur_depth >= 3), kept
6143                    // explicit so a future RECUNROLL_THRESHOLD tweak
6144                    // doesn't silently flip shallow-recursion
6145                    // shapes (cur_depth == 1) onto the DownRec arm.
6146                    //
6147                    // R3.3+ sub-1/2/3/4 will replace the depth-baked
6148                    // op_offsets[] addressing with runtime base_var
6149                    // threading so the trace's recorded body is
6150                    // depth-relative and the DownRec dispatch
6151                    // becomes wall-clock-positive. Sub-0 is the
6152                    // routing scaffold; it does not aim for gain.
6153                    let _ = kind;
6154                    let relaxed_to_downrec = cur_depth >= 2 && rec.downrec_close.is_none() && {
6155                        let caller_depth_u8 = (cur_depth - 1) as u8;
6156                        if let Some(call_op) = rec.ops.iter().rev().find(|r| {
6157                            r.inline_depth == caller_depth_u8
6158                                && matches!(r.inst.op(), crate::vm::isa::Op::Call)
6159                        }) {
6160                            rec.downrec_close = Some(crate::jit::trace::DownRecClose {
6161                                return_pc: call_op.pc + 1,
6162                                target_proto: call_op.proto,
6163                                depth_delta: 1,
6164                            });
6165                            true
6166                        } else {
6167                            false
6168                        }
6169                    };
6170                    if relaxed_to_downrec {
6171                        // R2 close-cause taxonomy: tag the lift so
6172                        // probes can tally the fire rate. Mirrors
6173                        // R3a's `"downrec-restart"` bump for the
6174                        // depth>0 Op::Return path (different trip
6175                        // origin, same downstream routing). The
6176                        // existing `"self-link-retf-r1"` label still
6177                        // fires for trips that DON'T relax (no
6178                        // candidate Op::Call ancestor in rec.ops, or
6179                        // cur_depth < 2) via the lowerer's
6180                        // dispatch_off_reason mirror at the close
6181                        // handler — kept as a regression safety net.
6182                        self.jit
6183                            .counters
6184                            .bump_close_cause("selflink-yields-to-downrec");
6185                    } else {
6186                        rec.self_link_kind = Some(kind);
6187                    }
6188                }
6189                let should_close =
6190                    at_head_loop || returned_past_head || depth_cap_hit || self_link_trip.is_some();
6191                if should_close {
6192                    // P13-S13-H — long-trace bias: a call-triggered
6193                    // recording that closed with a very short body
6194                    // (fib base case: `Lt`/`Jmp`/`Return1` = 3 ops,
6195                    // binary_trees `make(0)`: 4 ops) is pathological.
6196                    // Compiling + caching it pins `Proto.traces` to a
6197                    // trace that the length gate will refuse to
6198                    // dispatch (per `MIN_DISPATCHABLE_TRUNC_BODY_FLOOR
6199                    // = 40`), AND blocks the back-edge / longer-call
6200                    // path from re-recording the same head_pc (the
6201                    // dedup `already_cached` check below short-
6202                    // circuits). The fix: discard the short call-
6203                    // triggered recording WITHOUT caching, and bias
6204                    // the proto's `call_hot_count` back to
6205                    // `THRESHOLD - HOT_RETRY_WINDOW` so the next
6206                    // sequence of calls retries the trigger at a
6207                    // different (hopefully deeper) recursion point.
6208                    //
6209                    // Back-edge triggered traces are exempt — a
6210                    // tight numeric-for loop's body is legitimately
6211                    // 3 ops (`Add`, ForLoop) and DOES dispatch
6212                    // usefully when re-entered many times.
6213                    // P13-S13-H — coverage heuristic to detect
6214                    // pathologically partial call-triggered traces:
6215                    // for self-recursive / branchy protos like
6216                    // `fib` (~17 bytecode ops) or
6217                    // `binary_trees.make` (~26 ops), the recorder
6218                    // can fire at a BASE-case entry (`fib(0)` or
6219                    // `make(0)`) producing a 3–4 op trace that
6220                    // covers a tiny fraction of the proto's code.
6221                    // That trace is doomed by the length gate
6222                    // post-compile AND blocks any longer follow-up
6223                    // (the dedup `already_cached` check below). The
6224                    // fix: discard call-triggered closes where
6225                    // `rec.ops.len() * 2 < head_proto.code.len()`
6226                    // (less than half the proto's bytecode), so the
6227                    // back-edge / longer call path can take over.
6228                    //
6229                    // Why coverage > raw length:protos with
6230                    // intrinsically short bodies (closure
6231                    // factories: `Closure + Return1` = 2 ops,
6232                    // simple wrappers: `LoadI + Return1` = 2 ops)
6233                    // record 100% coverage even at length 2 — those
6234                    // ARE legitimately short and the closure /
6235                    // sunk-emit lowering paths (S7-A / S9-C) make
6236                    // them worth compiling. The heuristic admits
6237                    // them. fib's `[Lt, Jmp, Return1]` (3 of ~17)
6238                    // and make's `[Lt, Jmp, LoadI, Return1]` (4 of
6239                    // ~26) get discarded.
6240                    //
6241                    // Back-edge triggered traces are unaffected —
6242                    // a tight numeric-for body legitimately covers
6243                    // 3 of ~3 proto ops it can dispatch from
6244                    // (`Add + ForLoop`) and the recorder fires on
6245                    // the back-edge, not call entry.
6246                    //
6247                    // `call_hot_count` is intentionally NOT reset
6248                    // (an earlier draft tried `THRESHOLD - 32` but
6249                    // caused active_trace contention with the
6250                    // outer back-edge trigger — see
6251                    // setlist_b_zero_with_call_c_zero_sunk_emits).
6252                    // We give up on dispatching the pathological
6253                    // shape on the same proto; the back-edge or a
6254                    // longer call path on a deeper recursion point
6255                    // can still record + cache a real trace.
6256                    let proto_code_len = rec.head_proto.code.len();
6257                    let is_partial_coverage = rec.ops.len() * 2 < proto_code_len;
6258                    // P13-S13-I — per-Proto discard cap. The S13-H
6259                    // relaxed trigger condition (`c >= THRESHOLD &&
6260                    // !already_cached`) means a Proto whose every
6261                    // recording is partial-coverage will re-fire the
6262                    // trigger every call indefinitely (1500+ in
6263                    // `binary_trees`-pattern test). The cap stops
6264                    // discarding after `MAX_DISCARDS_PER_PROTO` —
6265                    // the next close falls through to compile (even
6266                    // if partial), caches the trace, and the
6267                    // `already_cached` short-circuit kills the
6268                    // storm. Dispatch may still be refused
6269                    // post-compile (length gate), but the recorder
6270                    // stops churning.
6271                    const MAX_DISCARDS_PER_PROTO: u32 = 5;
6272                    let prior_discards = rec.head_proto.trace_discard_count.get();
6273                    let cap_reached = prior_discards >= MAX_DISCARDS_PER_PROTO;
6274                    // P13-S13-K — flip the `gave_up` flag the
6275                    // moment cap is reached (BEFORE the close-
6276                    // dispatching branch below). The trigger gates
6277                    // short-circuit on this flag, skipping the
6278                    // RefCell + linear `already_cached` scan on
6279                    // every subsequent call to this Proto. Useful
6280                    // for `binary_trees_pattern`-class loads where
6281                    // a single Proto sees ~20k calls post-cap.
6282                    if cap_reached
6283                        && rec.is_call_triggered
6284                        && is_partial_coverage
6285                        && !rec.head_proto.trace_gave_up.get()
6286                    {
6287                        rec.head_proto.trace_gave_up.set(true);
6288                    }
6289                    if rec.is_call_triggered && is_partial_coverage && !cap_reached {
6290                        // Tally as closed (for visibility) but DROP
6291                        // without compile/cache. Use the existing
6292                        // closed-lens accumulator so probes can
6293                        // observe the discarded shape.
6294                        // P13-S13-I — bump discard count BEFORE
6295                        // dropping the recording so the next
6296                        // close sees the updated counter.
6297                        rec.head_proto.trace_discard_count.set(prior_discards + 1);
6298                        self.jit.counters.closed += 1;
6299                        self.jit
6300                            .counters
6301                            .closed_lens
6302                            .push((rec.is_call_triggered, rec.ops.len()));
6303                        // v2.0 Track-R R2 — partial-coverage discard
6304                        // close path. Pre-R2 this site bumped `closed`
6305                        // + `closed_lens` (visibility) but no per-
6306                        // reason label, so probes couldn't separate a
6307                        // real successful close from a discard tally.
6308                        // Tag explicitly to make the recorder-side
6309                        // close-cause taxonomy single-source.
6310                        self.jit
6311                            .counters
6312                            .bump_close_cause("partial-coverage-discard");
6313                        self.jit.active_trace = None;
6314                        // Continue with interp loop — don't
6315                        // fall through to compile path.
6316                        // The op at `pc` hasn't dispatched yet;
6317                        // the outer loop iteration handles it.
6318                    } else {
6319                        rec.closed = true;
6320                        // P12-S2.C — detach the closed record, then try
6321                        // to compile it. Dedup by `head_pc`: a Proto
6322                        // already carrying a CompiledTrace for this PC
6323                        // skips recompile (the hot counter caps
6324                        // re-recording at `u32::MAX / 2` anyway, but
6325                        // explicit dedup keeps `Proto.traces` short
6326                        // for the S3 dispatcher's linear scan).
6327                        //
6328                        // No `Vm::run` change for failure: we just bump
6329                        // the failed counter and drop the record. S3
6330                        // will read `Proto.traces` to decide whether to
6331                        // dispatch — until then, this is bookkeeping.
6332                        let head_pc_val = rec.head_pc;
6333                        let closed_record = self
6334                            .jit
6335                            .active_trace
6336                            .take()
6337                            .expect("active_trace was Some this branch");
6338                        self.jit.counters.closed += 1;
6339                        self.jit
6340                            .counters
6341                            .closed_lens
6342                            .push((closed_record.is_call_triggered, closed_record.ops.len()));
6343                        // P12-S5-B fix: cache the trace on the
6344                        // recorder's *head proto*, not the current
6345                        // closure's proto. For non-recursive
6346                        // call-triggered traces, close fires after
6347                        // `Return1` pops the callee frame — `cl` at
6348                        // that point is the CALLER's closure, while
6349                        // `closed_record.head_proto` is the CALLEE's
6350                        // proto (the one we actually want the trace
6351                        // to be discoverable from on the next call).
6352                        // Self-recursive fib closed via depth-cap
6353                        // mid-recursion so `cl.proto == head_proto`
6354                        // happened to coincide — this fix makes that
6355                        // accidental coincidence intentional.
6356                        let head_proto = closed_record.head_proto;
6357                        let already_cached = head_proto
6358                            .traces
6359                            .borrow()
6360                            .iter()
6361                            .any(|t| t.head_pc == head_pc_val);
6362                        if !already_cached {
6363                            // Internal-loop = true: the trace runs in
6364                            // a native loop until a cmp side-exits, so
6365                            // the dispatcher's per-entry marshal cost
6366                            // amortizes across the whole run of
6367                            // iterations the loop's recorded direction
6368                            // stays valid. The lowerer auto-downgrades
6369                            // to one-shot for cmp-less or Call-truncating
6370                            // traces.
6371                            // P15-A v2-C-A6-5 — side traces MUST NOT
6372                            // internal-loop. The parent's recorded prefix
6373                            // (ops at PCs < side trace's head_pc) defines
6374                            // values for registers the child's body reads
6375                            // without re-writing each iter — e.g. for
6376                            // s12_step_b, parent's `pc=19 Add R[12] = R[1]
6377                            // + R[11]` sets R[12], and the child trace
6378                            // (head_pc=24) re-runs `pc=20 Move R[1] =
6379                            // R[12]` each iter via its outer ForLoop
6380                            // internal-loop, ALWAYS reading the stale
6381                            // entry-time R[12]. The parent's Add never
6382                            // re-runs during child's loop, so R[1] gets
6383                            // pinned to one stale value. Force one-shot
6384                            // for side traces: each parent-exit round-
6385                            // trips through dispatcher → parent's Add
6386                            // runs → side trace runs ONE iter → return.
6387                            let opts = crate::jit::trace::CompileOptions {
6388                                internal_loop: closed_record.side_trace_parent.is_none(),
6389                                pre53: self.version() <= LuaVersion::Lua53,
6390                                aot: false,
6391                            };
6392                            // v1.1 A1 Session A — route through trace_compiler.
6393                            // v2.0 Track J sub-step J-B — split-borrow JitState
6394                            // so the trait method can take `&mut dyn JitStorage`.
6395                            let result = {
6396                                let jit = &mut self.jit;
6397                                let storage: &mut dyn crate::jit::JitStorage = jit.storage.as_mut();
6398                                jit.trace_compiler
6399                                    .try_compile_trace(storage, &closed_record, opts)
6400                            };
6401                            match result {
6402                                Some(mut ct) => {
6403                                    // P12-S5-A/B/C — tally Sinkable sites
6404                                    // + actually-sunk-emit sites + materialise
6405                                    // emit sites before moving `ct` into
6406                                    // Proto.traces.
6407                                    self.jit.counters.sinkable_seen +=
6408                                        ct.sinkable_sites_seen as u64;
6409                                    self.jit.counters.accum_bufferable_seen +=
6410                                        ct.accum_bufferable_seen as u64;
6411                                    self.jit.counters.sunk_alloc += ct.sunk_alloc_seen as u64;
6412                                    self.jit.counters.materialize_emit +=
6413                                        ct.materialize_emit_count as u64;
6414                                    self.jit.counters.closure_emit += ct.closure_seen as u64;
6415                                    if ct.is_inline_abort_close {
6416                                        self.jit.counters.inline_abort += 1;
6417                                    }
6418                                    // v2.0 Stage 7 polish 6 fire
6419                                    // experiment — split tally so a
6420                                    // probe can answer the AOT
6421                                    // `accepted_with_per_exit_inline`
6422                                    // gate's question at the JIT
6423                                    // surface too: how many compiled
6424                                    // traces emitted depth>0 cmp
6425                                    // side-exits, and how many of
6426                                    // those survived all the
6427                                    // `dispatchable = false` pins
6428                                    // (`InlineAbort-gate`,
6429                                    // `self-link-retf-r1`,
6430                                    // `downrec-stitch-pending`, etc.).
6431                                    if !ct.per_exit_inline.is_empty() {
6432                                        self.jit.counters.per_exit_inline_compiled += 1;
6433                                        if ct.dispatchable {
6434                                            self.jit.counters.per_exit_inline_dispatchable += 1;
6435                                        }
6436                                    }
6437                                    if let Some(reason) = ct.dispatch_off_reason {
6438                                        self.jit.counters.dispatch_off_reasons.push(reason);
6439                                        // v2.0 Track-R R2 — mirror
6440                                        // the ordered Vec push into
6441                                        // the per-reason HashMap so
6442                                        // probes can answer "how many
6443                                        // of each dispatch_off label
6444                                        // fired" in O(1) without
6445                                        // walking the Vec. Same
6446                                        // bucket as the recorder-side
6447                                        // abort/discard tags above.
6448                                        self.jit.counters.bump_close_cause(reason);
6449                                    }
6450                                    // v2.0 Track-R R3b — count
6451                                    // compiled traces that carry a
6452                                    // down-recursion stitch link.
6453                                    // Bumped here (not at the lowerer
6454                                    // emit site) because the Vm's
6455                                    // JitCounters live on the Vm,
6456                                    // and the lowerer doesn't have a
6457                                    // Vm handle. R3b's regression
6458                                    // pin reads this via
6459                                    // `Vm::trace_downrec_link_compiled_count`.
6460                                    if ct.downrec_link.is_some() {
6461                                        self.jit.counters.downrec_link_compiled += 1;
6462                                    }
6463                                    // v2.0 Track-R R3d — multi-way
6464                                    // guard emit counter. Bumped when
6465                                    // the lowerer's R3d arm collected
6466                                    // >= 2 distinct caller_pc candidates
6467                                    // and lifted `dispatchable=true`.
6468                                    // R3c's single-CMP shape stores
6469                                    // `1` here without bumping; non-
6470                                    // DownRec closes store `0`.
6471                                    if ct.downrec_multi_way_count >= 2 {
6472                                        self.jit.counters.multi_way_guard_emitted += 1;
6473                                    }
6474                                    // P15-A v2-A — side-trace finalisation.
6475                                    // Pin `dispatchable=false` so the
6476                                    // primary lookup `traces.find(|t|
6477                                    // t.head_pc == pc && t.dispatchable)`
6478                                    // never matches this entry — the
6479                                    // side trace is meant to be entered
6480                                    // ONLY through the parent's exit
6481                                    // indirection (v2-B/C IR), not the
6482                                    // back-edge / call-trigger paths.
6483                                    // Then write the entry fn ptr into
6484                                    // the parent's `exit_side_trace_ptrs`
6485                                    // slot so v2-B/C IR can read it.
6486                                    if let Some((parent_proto, parent_head_pc, parent_exit_idx)) =
6487                                        closed_record.side_trace_parent
6488                                    {
6489                                        ct.dispatchable = false;
6490                                        let entry_ptr = ct.entry as *const () as *const u8;
6491                                        let _side_trace_head_pc = closed_record.head_pc;
6492                                        let parent_traces = parent_proto.traces.borrow();
6493                                        if let Some(parent_ct) = parent_traces
6494                                            .iter()
6495                                            .find(|t| t.head_pc == parent_head_pc)
6496                                        {
6497                                            // P15-A v2-C-A5-C — shape-match
6498                                            // gate. Find the parent's per-exit
6499                                            // tag snapshot at the wired exit
6500                                            // (inline / tag / global) and
6501                                            // check the child's entry_tags
6502                                            // match. If not, leave the cell
6503                                            // null + skip cache populate so
6504                                            // the future v2-C-A2 IR's
6505                                            // `call_indirect` stays inert at
6506                                            // this exit (the child's
6507                                            // shape-specialised IR would
6508                                            // mis-interpret raw bits the
6509                                            // parent writes to reg_state).
6510                                            let inline_n = parent_ct.per_exit_inline.len();
6511                                            let tags_n = parent_ct.per_exit_tags.len();
6512                                            let parent_exit_tags_slice: &[
6513                                            crate::jit::trace::ExitTag
6514                                        ] = if parent_exit_idx < inline_n {
6515                                            &parent_ct.per_exit_inline
6516                                                [parent_exit_idx]
6517                                                .exit_tags
6518                                        } else if parent_exit_idx
6519                                            < inline_n + tags_n
6520                                        {
6521                                            &parent_ct.per_exit_tags
6522                                                [parent_exit_idx - inline_n]
6523                                                .1
6524                                        } else {
6525                                            &parent_ct.exit_tags
6526                                        };
6527                                            let shape_ok =
6528                                                crate::jit::trace::exit_tags_match_entry_tags(
6529                                                    &ct.entry_tags,
6530                                                    parent_exit_tags_slice,
6531                                                    &parent_ct.entry_tags,
6532                                                );
6533                                            if !shape_ok {
6534                                                self.jit.counters.side_trace_shape_mismatch += 1;
6535                                            }
6536                                            // P15-A v2-C-A4 — write the child's
6537                                            // entry fn ptr to BOTH the legacy
6538                                            // v2-A `exit_side_trace_ptrs[idx]`
6539                                            // cell (kept so v2-A's
6540                                            // walk_any_side_ptr_non_null tests
6541                                            // stay green) AND the per-kind cell
6542                                            // whose heap address the parent's
6543                                            // IR baked (v2-C-A2). The IR-baked
6544                                            // cell is what the call_indirect
6545                                            // gate actually reads. Only write
6546                                            // when A5-C shape gate passes.
6547                                            if shape_ok {
6548                                                if let Some(cell) = parent_ct
6549                                                    .exit_side_trace_ptrs
6550                                                    .get(parent_exit_idx)
6551                                                {
6552                                                    cell.set(entry_ptr);
6553                                                }
6554                                                // Compute (kind, local) for the
6555                                                // IR-baked cell. Layout follows
6556                                                // exit_hit_counts: inline first,
6557                                                // then per_exit_tags, then the
6558                                                // global tail slot.
6559                                                let (sent_kind, sent_local) = if parent_exit_idx
6560                                                    < inline_n
6561                                                {
6562                                                    parent_ct.per_exit_inline[parent_exit_idx]
6563                                                        .side_trace_ptr
6564                                                        .set(entry_ptr);
6565                                                    (
6566                                                        crate::jit::trace::SIDE_SENT_KIND_INLINE,
6567                                                        parent_exit_idx as u32,
6568                                                    )
6569                                                } else if parent_exit_idx < inline_n + tags_n {
6570                                                    let local = parent_exit_idx - inline_n;
6571                                                    if let Some(b) =
6572                                                        parent_ct.tags_side_trace_ptrs.get(local)
6573                                                    {
6574                                                        b.set(entry_ptr);
6575                                                    }
6576                                                    (
6577                                                        crate::jit::trace::SIDE_SENT_KIND_TAG,
6578                                                        local as u32,
6579                                                    )
6580                                                } else {
6581                                                    parent_ct.global_side_trace_ptr.set(entry_ptr);
6582                                                    (crate::jit::trace::SIDE_SENT_KIND_GLOBAL, 0)
6583                                                };
6584                                                self.jit.counters.side_trace_compiled += 1;
6585                                                // P15-A v2-D-A8 — flip the
6586                                                // parent's fast-path hint so
6587                                                // the dispatcher knows to do
6588                                                // the tentative decode + cell
6589                                                // check on subsequent
6590                                                // dispatches. Set once and
6591                                                // stays true (we never unwire
6592                                                // a side trace today).
6593                                                parent_ct.has_any_side_wired.set(true);
6594
6595                                                // P15-A v2-C-A1/A4 — populate
6596                                                // the O(1) lookup cache the
6597                                                // dispatcher consults on
6598                                                // sentinel-bit-set returns.
6599                                                // Key is the encoded sentinel
6600                                                // (same encoding the IR ORs
6601                                                // into bits 56..=62 of the
6602                                                // child's i64 return).
6603                                                let sentinel =
6604                                                    crate::jit::trace::encode_side_sentinel(
6605                                                        sent_kind, sent_local,
6606                                                    );
6607                                                let predicted_idx = if std::ptr::eq(
6608                                                    parent_proto.as_ptr(),
6609                                                    head_proto.as_ptr(),
6610                                                ) {
6611                                                    parent_traces.len() as u32
6612                                                } else {
6613                                                    head_proto.traces.borrow().len() as u32
6614                                                };
6615                                                parent_ct
6616                                                    .side_trace_cache
6617                                                    .borrow_mut()
6618                                                    .insert(sentinel, predicted_idx);
6619                                            }
6620                                        }
6621                                        drop(parent_traces);
6622                                    }
6623                                    head_proto.traces.borrow_mut().push(TArc::new(ct));
6624                                    self.jit.counters.compiled += 1;
6625                                }
6626                                None => {
6627                                    self.jit.counters.compile_failed += 1;
6628                                    self.jit
6629                                        .counters
6630                                        .compile_failed_reasons
6631                                        .push(self.jit.trace_compiler.last_compile_checkpoint());
6632                                }
6633                            }
6634                        }
6635                    } // P13-S13-H — close the long-trace-bias else branch
6636                } else {
6637                    // P12-S4-step1 + step4a — depth-aware push at the
6638                    // current `cur_depth`. The `depth_cap_hit` /
6639                    // `returned_past_head` early-exit is handled by
6640                    // the `should_close` branch above; reaching here
6641                    // means `cur_depth <= MAX_INLINE_DEPTH` and the
6642                    // trace head's frame is still live.
6643                    let depth_u8 = cur_depth as u8;
6644                    if depth_u8 > self.jit.max_depth_seen {
6645                        self.jit.max_depth_seen = depth_u8;
6646                    }
6647                    // P12-S9-A — fix up a prior `Op::Call C=0` (multi-
6648                    // return / variable return count). Recorder pushed
6649                    // it with var_count=None before the call dispatched;
6650                    // now that the call has returned and we're about to
6651                    // push the next op, top reflects the actual return
6652                    // count. Snapshot top - (caller.base + call.a).
6653                    if let Some(last) = rec.ops.last_mut()
6654                        && matches!(last.inst.op(), crate::vm::isa::Op::Call)
6655                        && last.inst.c() == 0
6656                        && last.var_count.is_none()
6657                        && let Some(f) = self.frames.last().and_then(CallFrame::lua)
6658                    {
6659                        let from = f.base + last.inst.a();
6660                        if self.top >= from {
6661                            last.var_count = Some(self.top - from);
6662                        }
6663                    }
6664                    // P12-S9-A/C — for SetList B=0, snapshot the source
6665                    // count = top - A - 1 (mirrors Lua's `n = top - ra
6666                    // - 1` from lvm.c OP_SETLIST). Sources are
6667                    // R[A+1..top), exclusive top. For Call C=0's
6668                    // var_count (the return count = top - A inclusive),
6669                    // see the prior-op fix-up above; here we
6670                    // initialise the current Call op to None and let
6671                    // the fix-up on the next op's push populate it.
6672                    let var_count = if matches!(inst.op(), crate::vm::isa::Op::SetList)
6673                        && inst.b() == 0
6674                        && let Some(f) = self.frames.last().and_then(CallFrame::lua)
6675                    {
6676                        let from = f.base + inst.a();
6677                        if self.top > from {
6678                            Some(self.top - from - 1)
6679                        } else {
6680                            None
6681                        }
6682                    } else {
6683                        None
6684                    };
6685                    let op = crate::jit::trace::RecordedOp {
6686                        proto: cl.proto,
6687                        pc,
6688                        inst,
6689                        inline_depth: depth_u8,
6690                        var_count,
6691                    };
6692                    // v2.0 Track-R R1 — depth>0 Return0/Return1 mirrors
6693                    // LuaJIT's `IR_RETF` (lj_record.c:922+ lj_record_ret).
6694                    // Captured as a side-channel `RetfRecord` parallel to
6695                    // `ops` when `p16_self_link_enabled` is on. R3's
6696                    // down-rec stitch consumes these to guard side-trace
6697                    // inlined-frame topology against the recorded shape.
6698                    // Gated on the same flag as the cycle catch so the
6699                    // ship-default path (p16 off) sees zero behavior
6700                    // change. `caller_pc` is the recorded enclosing Call's
6701                    // pc + 1 — interp's resume point after the inlined
6702                    // frame pops.
6703                    if self.jit.p16_self_link_enabled
6704                        && depth_u8 > 0
6705                        && matches!(
6706                            inst.op(),
6707                            crate::vm::isa::Op::Return0 | crate::vm::isa::Op::Return1
6708                        )
6709                    {
6710                        let results: u8 = match inst.op() {
6711                            crate::vm::isa::Op::Return0 => 0,
6712                            crate::vm::isa::Op::Return1 => 1,
6713                            _ => 0,
6714                        };
6715                        // Most recent Op::Call recorded at the caller's
6716                        // depth (`depth_u8 - 1`) is the frame this Return
6717                        // is unwinding from. Reverse scan stops at the
6718                        // first match.
6719                        let caller_depth = depth_u8 - 1;
6720                        let caller_call = rec.ops.iter().rev().find(|r| {
6721                            r.inline_depth == caller_depth
6722                                && matches!(r.inst.op(), crate::vm::isa::Op::Call)
6723                        });
6724                        let caller_pc = caller_call.map(|r| r.pc + 1).unwrap_or(pc);
6725                        // v2.0 Track-R R3a — capture the caller's proto
6726                        // for the RetfRecord. LuaJIT `IR_RETF.op1`
6727                        // equivalent. For fib(28) the caller's proto
6728                        // equals the trace head; for future mutual
6729                        // recursion the recorded Op::Call's proto is the
6730                        // right target. Fallback to head_proto when no
6731                        // enclosing Call op was captured (mirrors
6732                        // `caller_pc`'s fallback to the Return's own pc).
6733                        let caller_proto = caller_call.map(|r| r.proto).unwrap_or(rec.head_proto);
6734                        rec.retfs.push(crate::jit::trace::RetfRecord {
6735                            from_depth: depth_u8,
6736                            to_depth: caller_depth,
6737                            results,
6738                            caller_pc,
6739                            proto: caller_proto,
6740                        });
6741                        // v2.0 Track-R R3a — DownRec close trigger:
6742                        // count RetfRecords on this recording whose
6743                        // `proto` matches `caller_proto` (LuaJIT
6744                        // `check_downrec_unroll` chain filter
6745                        // `op1 == ptref`). Threshold mirrors
6746                        // RECUNROLL_THRESHOLD; first trip stamps the
6747                        // `downrec_close` marker, subsequent retfs
6748                        // keep the marker without overwrite. The
6749                        // lowerer's end_idx picker routes through
6750                        // TraceEnd::DownRec when the marker is set;
6751                        // R3a's tail emit still falls through to R1's
6752                        // safe deopt path so fib(28) result stays
6753                        // 317_811. R3b lifts.
6754                        if rec.downrec_close.is_none() {
6755                            let caller_proto_ptr = caller_proto.as_ptr();
6756                            let prior_match_count = rec
6757                                .retfs
6758                                .iter()
6759                                .filter(|r| r.proto.as_ptr() == caller_proto_ptr)
6760                                .count();
6761                            // Strictly-greater-than threshold matches
6762                            // LuaJIT `count + J->tailcalled > recunroll`.
6763                            // The newly-pushed retf is already counted.
6764                            if prior_match_count > crate::jit::trace::RECUNROLL_THRESHOLD {
6765                                rec.downrec_close = Some(crate::jit::trace::DownRecClose {
6766                                    return_pc: caller_pc,
6767                                    target_proto: caller_proto,
6768                                    depth_delta: 1,
6769                                });
6770                                // R2 close-cause taxonomy: tag the
6771                                // restart with `"downrec-restart"`. R3b
6772                                // adds `"downrec-stitch-failed"` when
6773                                // the lifted back-edge falls back to
6774                                // deopt.
6775                                self.jit.counters.bump_close_cause("downrec-restart");
6776                            }
6777                        }
6778                    }
6779                    // v2.1 Phase 1I.B — capture FieldIcSnapshot for the
6780                    // FIRST eligible Op::GetField site under env-gate
6781                    // LUNA_JIT_FIELD_IC=1. "Eligible" means:
6782                    //   - R[B] is Value::Table with metatable.is_none()
6783                    //   - K[C] is Value::Str
6784                    //   - The string key actually occupies a hash slot
6785                    //     (so the IC's slot_idx is a real index, not
6786                    //     a probe sentinel).
6787                    // Once captured, subsequent GetFields skip this
6788                    // logic (rec.field_ic_snapshot.is_some() short-
6789                    // circuits). Env-OFF short-circuits on the cached
6790                    // atomic check inside field_ic_enabled().
6791                    if rec.field_ic_snapshot.is_none()
6792                        && matches!(inst.op(), crate::vm::isa::Op::GetField)
6793                        && crate::jit::trace_types::field_ic_enabled()
6794                    {
6795                        let b = inst.b();
6796                        let c_idx = inst.c() as usize;
6797                        let r_b = self.stack[(base + b) as usize];
6798                        if let Value::Table(g) = r_b
6799                            && g.metatable().is_none()
6800                            && c_idx < cl.proto.consts.len()
6801                            && let Value::Str(s) = cl.proto.consts[c_idx]
6802                        {
6803                            let key = Value::Str(s);
6804                            let tbl_ref = &*g;
6805                            if let Some(slot_idx) = tbl_ref.find_node_idx(key)
6806                                && let Some(val) = tbl_ref.node_val_at(slot_idx)
6807                            {
6808                                let op_idx = rec.ops.len() as u32;
6809                                rec.field_ic_snapshot =
6810                                    Some(crate::jit::trace_types::FieldIcSnapshot {
6811                                        op_idx,
6812                                        nodes_len: tbl_ref.nodes_capacity() as u64,
6813                                        slot_idx: slot_idx as u64,
6814                                        key_ptr_bits: s.as_ptr() as u64,
6815                                        cached_val_tag: val.tag_byte(),
6816                                    });
6817                                self.jit.counters.field_ic_snapshot_captured += 1;
6818                            }
6819                        }
6820                    }
6821                    if !rec.push(op) {
6822                        // v2.0 Track-R R2 — recorder overflow
6823                        // (MAX_TRACE_LEN). Pre-R2 this site bumped
6824                        // `aborted` with no reason label, leaving the
6825                        // overflow indistinguishable from any other
6826                        // abort cause that might be added later.
6827                        // Tag it explicitly under the close-cause
6828                        // bucket so probes can tally overflow vs
6829                        // other abort causes in O(1).
6830                        self.jit.active_trace = None;
6831                        self.jit.counters.aborted += 1;
6832                        self.jit.counters.bump_close_cause("trace-overflow");
6833                    }
6834                }
6835            }
6836
6837            // P12-S3 — trace JIT dispatcher.
6838            //
6839            // When the dispatch loop is about to execute the op at
6840            // `pc` and there's a `numeric_only` CompiledTrace cached
6841            // for that `head_pc`, marshal the live regs into an
6842            // i64 buffer, jump into the trace, and resume the
6843            // interpreter at the returned continuation PC.
6844            //
6845            // Skipped (zero overhead) when `trace_jit_enabled` is
6846            // false; the lookup is a borrow + scan over
6847            // `cl.proto.traces`, which is a `Vec` whose size is at
6848            // most one entry per back-edge per Proto in practice.
6849            //
6850            // Marshalling contract — only Int slots survive the
6851            // round-trip cleanly (the reg_state ABI is `*mut i64`
6852            // with no tag info). Any non-Int slot in the affected
6853            // window forces a skip; interp takes over for one op
6854            // and the back-edge brings us back to try again next
6855            // pass (slots that were Nil/Float at one moment can
6856            // settle to Int by the time the next back-edge fires).
6857            //
6858            // A trace that comes back with `vm.jit.pending_err`
6859            // parked is treated as a deopt: clear the err, leave
6860            // the stack as the trace wrote it, and let the
6861            // interpreter run from the same `pc`. The trace itself
6862            // is left cached — a future entry might find no
6863            // metatable in the way and succeed.
6864            // P17-A1 (Path C #3) — single Rc<CompiledTrace> clone instead
6865            // of 6 per-field Rc clones. proto.traces is now
6866            // Vec<Rc<CompiledTrace>>; the dispatcher clones ONE Rc and
6867            // reads fields via auto-deref. fib_28 saves ~5 Rc::clone
6868            // operations per dispatch × 434k = ~2.2M Rc atomic ops
6869            // (~1-2% gain measured separately).
6870            // v2.0 Track-R R3c — one-shot consume of the
6871            // `suppress_downrec_admit_once` flag. Set by the R3c
6872            // downrec post-invoke arm below when it force-deopts the
6873            // trace (caller-pc guard miss OR cycle-budget exhausted)
6874            // so the NEXT interpreter loop iteration skips the
6875            // downrec admit, lets interp run the op at `head_pc`,
6876            // advances `pc` past `head_pc`, and breaks the otherwise-
6877            // infinite admit loop. Reading + clearing here means a
6878            // single dispatch tick consumes the suppression — the
6879            // following tick re-admits naturally (with the budget
6880            // also reset by the deopt site).
6881            let downrec_admit_blocked = self.jit.suppress_downrec_admit_once;
6882            self.jit.suppress_downrec_admit_once = false;
6883            if self.jit.trace_enabled
6884                && let Some(ct) = {
6885                    let traces = cl.proto.traces.borrow();
6886                    traces
6887                        .iter()
6888                        .find(|t| {
6889                            if t.head_pc != pc {
6890                                return false;
6891                            }
6892                            let is_downrec = t.downrec_link.is_some();
6893                            // v2.0 Track-R R3c — the one-shot suppress
6894                            // flag blocks any admit (primary or fallback)
6895                            // for `downrec_link`-bearing traces so the
6896                            // next interp iter can run the natural op
6897                            // at `head_pc` and advance past it. R3d's
6898                            // `dispatchable=true` lift means the suppress
6899                            // must also cover the primary `t.dispatchable`
6900                            // arm — otherwise the lifted lookup would
6901                            // immediately re-admit after a force-deopt
6902                            // and the infinite loop returns.
6903                            if is_downrec && downrec_admit_blocked {
6904                                return false;
6905                            }
6906                            // Primary arm: `dispatchable=true` traces
6907                            // (R3d-lifted DownRec or normal traces).
6908                            // Fallback arm: R3c-shape `dispatchable=false`
6909                            // DownRec traces (single-CMP guard kept
6910                            // pinned because the 90% miss-rate would
6911                            // make blind admit perf-negative).
6912                            t.dispatchable || is_downrec
6913                        })
6914                        .cloned()
6915                }
6916            {
6917                // Path C #6 — borrow Rc<[T]> fields as &Rc<[T]> instead
6918                // of cloning. The outer `ct: Rc<CompiledTrace>` is held
6919                // across the entire dispatch block so the fields outlive
6920                // all consumers. Saves 5 Rc::clone per dispatch.
6921                let entry_fn = ct.entry;
6922                let head_pc_val = ct.head_pc;
6923                let window_size = ct.window_size;
6924                let exit_tags = &ct.exit_tags;
6925                let per_exit_tags = &ct.per_exit_tags;
6926                let per_exit_inline = &ct.per_exit_inline;
6927                let compile_entry_tags = &ct.entry_tags;
6928                let global_tag_res_kind = ct.global_tag_res_kind;
6929                let exit_hit_counts = &ct.exit_hit_counts;
6930                let max_stack = cl.proto.max_stack as usize;
6931                let window_size_us = window_size as usize;
6932                let base_us = base as usize;
6933                // P12-S4-step3a — `reg_state` sized to the trace's
6934                // `window_size`, which today equals max_stack but
6935                // S4-step3b will expand for inlined frames.
6936                // Marshal-in still only writes [0..max_stack); slots
6937                // [max_stack..window_size) are zero-initialised and
6938                // filled by the trace's own GetUpval / arith.
6939                // P13-S13-D — reuse the Vm's amortised buffers
6940                // instead of allocating fresh Vecs each dispatch.
6941                // mem::take leaves an empty placeholder we restore
6942                // at the end of the dispatch block (success +
6943                // deopt paths both fall through to the restore).
6944                let mut entry_tags: Vec<u8> = std::mem::take(&mut self.jit.entry_tags_buf);
6945                entry_tags.clear();
6946                entry_tags.reserve(max_stack);
6947                // v2.0 Track-R R3c — this trace was admitted via the
6948                // `downrec_link.is_some()` arm rather than the normal
6949                // `dispatchable=true` arm. The pre-invoke path
6950                // populates a reserved saved-PC slot just past the
6951                // normal register window so R3b's lowerer guard load
6952                // (`reg_state[window_size]`) compares the runtime
6953                // saved caller PC against the recorded `dr_return_pc`.
6954                //
6955                // v2.0 Track-R R3d — drop the `!ct.dispatchable`
6956                // gate. After R3d lifts `dispatchable = true` for
6957                // multi-way guards, the trace's body still emits the
6958                // R3b/R3d sentinel shape on return — the saved-PC slot
6959                // and post-invoke classifier must keep firing.
6960                // `downrec_link.is_some()` is the unique structural
6961                // signal that the trace closes via DownRec.
6962                let is_downrec_entry = ct.downrec_link.is_some();
6963                let mut reg_state: Vec<i64> = std::mem::take(&mut self.jit.reg_state_buf);
6964                reg_state.clear();
6965                // v2.0 Track-R R3c — when admitting a downrec trace,
6966                // size the buffer to `window_size + 1` so the lowerer
6967                // can `load(I64, ..., reg_state, window_size * 8)`
6968                // for the saved caller PC guard input. The extra slot
6969                // is the LAST element so cranelift's existing
6970                // `0..window_size` accesses are unaffected.
6971                let reg_state_len = if is_downrec_entry {
6972                    window_size_us + 1
6973                } else {
6974                    window_size_us
6975                };
6976                reg_state.resize(reg_state_len, 0i64);
6977                let mut dispatch_ok = true;
6978                for i in 0..max_stack {
6979                    let v = self.stack[base_us + i];
6980                    let (tag, raw) = v.unpack();
6981                    entry_tags.push(tag);
6982                    // P12-S12-C v3 — entry tag guard. The trace's IR
6983                    // is specialised to the compile-time entry tags
6984                    // (via current_kinds propagation from
6985                    // from_entry_tag). A runtime tag mismatch means
6986                    // body ops would mis-interpret raw bits (e.g.
6987                    // treat a Str pointer as Int payload → garbage).
6988                    // Skip dispatch on mismatch so interp handles
6989                    // this entry shape; the trace stays cached for
6990                    // future entries that match.
6991                    if i < compile_entry_tags.len() && tag != compile_entry_tags[i] {
6992                        dispatch_ok = false;
6993                        break;
6994                    }
6995                    match tag {
6996                        // Int / Float / Table / Nil all marshal
6997                        // to raw payload cleanly; the trace's IR
6998                        // treats the 8-byte slot as an i64 (with
6999                        // f64 ops bitcasting around the boundary).
7000                        crate::runtime::value::raw::INT
7001                        | crate::runtime::value::raw::FLOAT
7002                        | crate::runtime::value::raw::TABLE
7003                        | crate::runtime::value::raw::CLOSURE
7004                        // P12-S12-B-v2 — Native iter slots (e.g.
7005                        // R[A] = ipairs_iter) are present in
7006                        // generic-for traces; the raw bits are a
7007                        // valid `*mut NativeClosure` and round-trip
7008                        // cleanly.
7009                        | crate::runtime::value::raw::NATIVE
7010                        // P12-S12-C v1 — Str slots show up in
7011                        // string-concat traces; raw bits = `*mut
7012                        // LuaStr` (interned, GC-managed). Round-
7013                        // trips cleanly as a heap pointer.
7014                        | crate::runtime::value::raw::STR
7015                        | crate::runtime::value::raw::NIL => {
7016                            // 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).
7017                            reg_state[i] = unsafe { raw.zero as i64 };
7018                        }
7019                        _ => {
7020                            dispatch_ok = false;
7021                            break;
7022                        }
7023                    }
7024                }
7025
7026                if dispatch_ok {
7027                    debug_assert_eq!(head_pc_val, pc, "trace cache hit's head_pc != pc");
7028                    self.jit.pending_err = None;
7029                    // P12-S4-step4b-C-2 — snapshot the pre-entry frame
7030                    // count. A cmp@d>0 side-exit calls the materialize
7031                    // helper which pushes inlined frames onto
7032                    // `vm.frames`; on deopt those frames must be popped
7033                    // before falling through to the interpreter, else
7034                    // the stack grows unboundedly per deopted dispatch.
7035                    let pre_frames = self.frames.len();
7036                    // v2.0 Track-R R3c — saved-PC slot population. The
7037                    // recorded `dr_return_pc` on the closing trace is
7038                    // the caller's resume PC captured at a depth>0
7039                    // Return push (recorder push site, see R3a verdict
7040                    // §3). The natural runtime analogue for self-
7041                    // stitch is the dispatching frame's PARENT frame's
7042                    // PC: the trace's head_pc sits inside a Lua frame,
7043                    // and the parent (caller) frame's `pc` is what
7044                    // luna would observe as `[base-8]` in the LJ
7045                    // `asm_retf` shape (`lj_asm_arm64.h:565`). When
7046                    // the parent isn't a Lua frame (top-level dispatch
7047                    // — first invocation through `call_value`), no
7048                    // saved PC exists; we write 0, which always
7049                    // mismatches the recorded `dr_return_pc != 0`
7050                    // invariant pinned by R3b
7051                    // (`crates/luna-jit/src/jit_backend/trace.rs:7206
7052                    // debug_assert!(dr_return_pc != 0, ...)`).
7053                    if is_downrec_entry {
7054                        let saved_pc: i64 = if pre_frames >= 2 {
7055                            match &self.frames[pre_frames - 2] {
7056                                CallFrame::Lua(parent) => parent.pc as i64,
7057                                CallFrame::Cont(_) => 0,
7058                            }
7059                        } else {
7060                            0
7061                        };
7062                        reg_state[window_size_us] = saved_pc;
7063                    }
7064                    // v1.3 Phase AOT Stage 7 sub-piece 4 — `LUNA_AOT_PROBE`
7065                    // diagnostic hook. The probe fires once per trace dispatch
7066                    // (regardless of JIT vs AOT origin — both go through this
7067                    // arm), letting the AOT smoke test verify mcode actually
7068                    // executed. Guarded behind `OnceLock` so the env read is
7069                    // a one-time cost per process; not gated on a particular
7070                    // counter so the smoke test gets a deterministic single-
7071                    // line `aot_trace_fired pc=N` per first dispatch.
7072                    if jit_probe_enabled() && self.jit.counters.dispatched == 0 {
7073                        eprintln!("luna-runtime-helpers: aot_trace_fired pc={head_pc_val}");
7074                    }
7075                    let continuation_pc = {
7076                        // v1.1 A1 Session A — chunk_compiler.enter
7077                        // (CraneliftBackend delegates to enter_jit;
7078                        // NullJitBackend returns an inert guard).
7079                        let vm_ptr: *mut Vm = self;
7080                        let _guard = self.jit.chunk_compiler.enter(vm_ptr, Some(cl));
7081                        // 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).
7082                        unsafe { entry_fn(reg_state.as_mut_ptr()) }
7083                    };
7084                    self.jit.counters.dispatched += 1;
7085
7086                    if self.jit.pending_err.is_some() {
7087                        self.jit.pending_err = None;
7088                        self.jit.counters.deopt += 1;
7089                        // P12-S4-step4b-C-2 — unwind any helper-pushed
7090                        // inlined frames before the interpreter resumes.
7091                        // Don't restore reg_state — the trace's partial
7092                        // writes are discarded; interp re-executes from
7093                        // the original `pc`.
7094                        while self.frames.len() > pre_frames {
7095                            frames_pop_sync(&mut self.frames, &mut self.frames_top);
7096                        }
7097                        if is_downrec_entry {
7098                            // v2.0 Track-R R3c — pending_err observed
7099                            // mid-trace inside a downrec admit. Treat
7100                            // it as a guard miss: bump `downrec_deopt`
7101                            // and suppress the next downrec admit so
7102                            // interp can advance past `head_pc` and
7103                            // the same trace doesn't immediately re-
7104                            // fire on the next loop iteration.
7105                            self.jit.counters.downrec_deopt += 1;
7106                            self.jit.suppress_downrec_admit_once = true;
7107                        }
7108                    } else if is_downrec_entry && {
7109                        // v2.0 Track-R R3d — only enter the R3c/R3d
7110                        // downrec classifier for returns whose shape
7111                        // matches the lowerer's `downrec_idx_opt` tail
7112                        // emit: either the stitch_blk DOWNREC sentinel
7113                        // (HIT) or the deopt_blk GLOBAL-sentinel-with-
7114                        // body==head_pc (MISS via guard fail). Any
7115                        // other return from a downrec trace (intermediate
7116                        // body cmp side-exit, GetField inference fail,
7117                        // etc.) carries a different sentinel/body shape
7118                        // and means the body exited BEFORE reaching the
7119                        // downrec close — classify those through the
7120                        // normal decode path (else branch below) so
7121                        // reg_state restores + pc advances correctly.
7122                        // The pre-R3d behavior (R3c) classified them all
7123                        // as MISS and skipped the normal restore, which
7124                        // inflated `downrec_deopt` with non-downrec
7125                        // events and lost the trace's mid-flight writes.
7126                        let raw_ret = continuation_pc as u64;
7127                        let from_side_trace = (raw_ret >> 63) & 1 == 1;
7128                        let sentinel_code = if from_side_trace {
7129                            ((raw_ret >> 56) & 0x7F) as u32
7130                        } else {
7131                            0
7132                        };
7133                        let raw_body = raw_ret & 0x00FF_FFFF_FFFF_FFFFu64;
7134                        let global_deopt_code = crate::jit::trace_types::encode_side_sentinel(
7135                            crate::jit::trace_types::SIDE_SENT_KIND_GLOBAL,
7136                            0,
7137                        );
7138                        from_side_trace
7139                            && (crate::jit::trace_types::is_downrec_sentinel(sentinel_code)
7140                                || (sentinel_code == global_deopt_code
7141                                    && raw_body == head_pc_val as u64))
7142                    } {
7143                        // R3d downrec event classifier.
7144                        let raw_ret = continuation_pc as u64;
7145                        let sentinel_code = ((raw_ret >> 56) & 0x7F) as u32;
7146                        if crate::jit::trace_types::is_downrec_sentinel(sentinel_code) {
7147                            // Guard HIT — saved_pc matched one of the
7148                            // baked candidates and the trace's
7149                            // `stitch_blk` arm returned the DOWNREC
7150                            // sentinel. Cycle-safety checkpoint:
7151                            // decrement budget; on underflow,
7152                            // reclassify as deopt + reset budget.
7153                            // R3d's `STITCH_DEPTH_DEFAULT = 32` lets
7154                            // ~all natural HITs in a hot loop fire
7155                            // before reset pressure.
7156                            if self.jit.stitch_depth_remaining > 0 {
7157                                self.jit.stitch_depth_remaining -= 1;
7158                                self.jit.counters.downrec_dispatched += 1;
7159                            } else {
7160                                self.jit.counters.downrec_deopt += 1;
7161                                self.jit.stitch_depth_remaining =
7162                                    crate::vm::jit_state::JitState::STITCH_DEPTH_DEFAULT;
7163                            }
7164                        } else {
7165                            // Guard MISS via the lowerer's deopt_blk
7166                            // arm (GLOBAL sentinel + body == head_pc).
7167                            // The deopt_blk emit performs the
7168                            // store-back via `emit_store_back_and_return_pc`,
7169                            // so the live stack already reflects the
7170                            // body's writes; no extra restore needed
7171                            // from the dispatcher side.
7172                            self.jit.counters.downrec_deopt += 1;
7173                        }
7174                        self.jit.suppress_downrec_admit_once = true;
7175                        // Pop helper-pushed inlined frames (defensive —
7176                        // R3d's emit shape doesn't push frames in the
7177                        // tail, but a body side-exit before reaching
7178                        // the tail may have via the materialize helper).
7179                        while self.frames.len() > pre_frames {
7180                            frames_pop_sync(&mut self.frames, &mut self.frames_top);
7181                        }
7182                        self.jit.reg_state_buf = reg_state;
7183                        self.jit.entry_tags_buf = entry_tags;
7184                        continue;
7185                    } else {
7186                        // Restore each slot using the trace's
7187                        // exit-tag analysis (see ExitTag docs).
7188                        // P12-S4-step4b-C-2 — decode the IR's
7189                        // side-exit shape. Upper 32 bits = (site_idx
7190                        // + 1) for inline cmp side-exits, 0 for
7191                        // legacy clean-tail / non-inline exits.
7192                        // P15-A v2-C-A0 — decode lives in
7193                        // `crate::jit::trace::decode_exit_shape` so
7194                        // v2-C-A3 can reuse it with the SIDE TRACE's
7195                        // shape inputs when the sentinel bit
7196                        // (v2-C-A2) is set on `raw_ret`.
7197                        let raw_ret = continuation_pc as u64;
7198                        // P15-A v2-C-A3 — side-trace return decode.
7199                        // Bit 63 of `raw_ret` is the side-trace
7200                        // marker the parent's IR OR'd in when it
7201                        // tail-called into a wired child trace.
7202                        // Bits 56..=62 carry the sentinel code (the
7203                        // cache key into the parent's
7204                        // `side_trace_cache`); bits 0..=55 are the
7205                        // child's own return value (encoded site or
7206                        // plain cont_pc) which we MUST decode using
7207                        // the CHILD's per_exit_inline / per_exit_tags
7208                        // / exit_tags / exit_hit_counts — not the
7209                        // parent's. The dispatcher snapshot read
7210                        // above holds the parent's shapes; when bit
7211                        // 63 is set we re-fetch the child's via the
7212                        // sentinel-keyed cache.
7213                        let from_side_trace = (raw_ret >> 63) & 1 == 1;
7214                        let (
7215                            decode_inline,
7216                            decode_tags,
7217                            decode_exit_tags,
7218                            decode_hit_counts,
7219                            decode_body,
7220                        ) = if from_side_trace {
7221                            let sentinel_code = ((raw_ret >> 56) & 0x7F) as u32;
7222                            let body = raw_ret & 0x00FF_FFFF_FFFF_FFFFu64;
7223                            let traces = cl.proto.traces.borrow();
7224                            let child_idx = traces
7225                                .iter()
7226                                .find(|t| t.head_pc == head_pc_val)
7227                                .and_then(|pct| {
7228                                    pct.side_trace_cache.borrow().get(&sentinel_code).copied()
7229                                });
7230                            if let Some(idx) = child_idx
7231                                && let Some(child) = traces.get(idx as usize)
7232                            {
7233                                if crate::jit::trace::v2c_probe_enabled() {
7234                                    eprintln!(
7235                                        "[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={}",
7236                                        sentinel_code,
7237                                        body,
7238                                        idx,
7239                                        child.n_ops,
7240                                        child.head_pc,
7241                                        child.window_size,
7242                                        pc,
7243                                        window_size,
7244                                        child.dispatchable,
7245                                        child.is_inline_abort_close,
7246                                    );
7247                                }
7248                                (
7249                                    child.per_exit_inline.clone(),
7250                                    child.per_exit_tags.clone(),
7251                                    child.exit_tags.clone(),
7252                                    child.exit_hit_counts.clone(),
7253                                    body,
7254                                )
7255                            } else {
7256                                if crate::jit::trace::v2c_probe_enabled() {
7257                                    eprintln!(
7258                                        "[v2c-A3-decode] sentinel={:#04x} body={:#018x} child MISS (fallback parent shapes)",
7259                                        sentinel_code, body,
7260                                    );
7261                                }
7262                                // Cache miss — fall back to parent
7263                                // shapes with the body bits. Best-
7264                                // effort; the trace_side_trace_
7265                                // shape_mismatch_count records this
7266                                // path indirectly (close-handler
7267                                // skips wiring on mismatch so we
7268                                // shouldn't reach here when shape
7269                                // gate held).
7270                                (
7271                                    per_exit_inline.clone(),
7272                                    per_exit_tags.clone(),
7273                                    exit_tags.clone(),
7274                                    exit_hit_counts.clone(),
7275                                    body,
7276                                )
7277                            }
7278                        } else {
7279                            // P15-A v2-D — dispatcher-level side-trace
7280                            // invocation. Replaces v2-C's universal IR
7281                            // gate (`load + icmp + brif` at every
7282                            // emit_store_back callsite, which A6/A7
7283                            // measured as a net perf regression).
7284                            // A8 fast-path: skip the tentative decode +
7285                            // child lookup entirely when `has_any_side
7286                            // _wired == false` (the common case until
7287                            // the first side trace compiles for this
7288                            // parent). For fib_10_x10k and other tight
7289                            // short-trace workloads where most parent
7290                            // traces never get a wired child, this
7291                            // collapses the v2-D overhead to a single
7292                            // `Cell::get()` on the cold path.
7293                            // A8-revert: A8 had `parent_has_side` short-
7294                            // circuit + snapshot hoist; mini N=3 showed
7295                            // A8 lost the btrees_d8 1.02× win (dropped
7296                            // to 0.95×) WITHOUT helping fib_10 (same
7297                            // 0.86×). Drop A8 — accept the always-run
7298                            // v2-D path; the tentative decode + cell
7299                            // load is cheaper than the cost A8 added.
7300                            {
7301                                let tentative = crate::jit::trace::decode_exit_shape(
7302                                    raw_ret,
7303                                    per_exit_inline,
7304                                    per_exit_tags,
7305                                    exit_tags,
7306                                );
7307                                let tentative_exit_idx = tentative.exit_hit_idx;
7308                                let child_invoke = {
7309                                    let traces = cl.proto.traces.borrow();
7310                                    traces.iter().find(|t| t.head_pc == head_pc_val).and_then(
7311                                        |pct| {
7312                                            let cell =
7313                                                pct.exit_side_trace_ptrs.get(tentative_exit_idx)?;
7314                                            let fn_ptr = cell.get();
7315                                            if fn_ptr.is_null() {
7316                                                return None;
7317                                            }
7318                                            traces
7319                                                .iter()
7320                                                .find(|t| {
7321                                                    t.entry as *const () as *const u8 == fn_ptr
7322                                                })
7323                                                .map(|child| {
7324                                                    (
7325                                                        child.entry,
7326                                                        child.per_exit_inline.clone(),
7327                                                        child.per_exit_tags.clone(),
7328                                                        child.exit_tags.clone(),
7329                                                        child.exit_hit_counts.clone(),
7330                                                    )
7331                                                })
7332                                        },
7333                                    )
7334                                };
7335                                if let Some((cent, cpi, cpt, cet, chc)) = child_invoke {
7336                                    let child_raw_ret = {
7337                                        // v1.1 A1 Session A — chunk_compiler.enter
7338                                        // (side-trace entry).
7339                                        let vm_ptr: *mut Vm = self;
7340                                        let _guard =
7341                                            self.jit.chunk_compiler.enter(vm_ptr, Some(cl));
7342                                        // 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).
7343                                        unsafe { cent(reg_state.as_mut_ptr()) }
7344                                    };
7345                                    (cpi, cpt, cet, chc, child_raw_ret as u64)
7346                                } else {
7347                                    (
7348                                        per_exit_inline.clone(),
7349                                        per_exit_tags.clone(),
7350                                        exit_tags.clone(),
7351                                        exit_hit_counts.clone(),
7352                                        raw_ret,
7353                                    )
7354                                }
7355                            }
7356                        };
7357                        let decoded = crate::jit::trace::decode_exit_shape(
7358                            decode_body,
7359                            &decode_inline,
7360                            &decode_tags,
7361                            &decode_exit_tags,
7362                        );
7363                        let site_id = decoded.site_id;
7364                        let cont_pc = decoded.cont_pc;
7365                        let exit_hit_idx = decoded.exit_hit_idx;
7366                        let exit_tags_for_pc = decoded.exit_tags_for_pc;
7367                        // P15-A v2-C-A3 — for side-trace returns
7368                        // force using_global_exit_tags=false so the
7369                        // restore loop always takes the per-tag slow
7370                        // path (the child's global_tag_res_kind
7371                        // classification isn't plumbed through yet
7372                        // — TODO for a future polish step).
7373                        let using_global_exit_tags = if from_side_trace {
7374                            false
7375                        } else {
7376                            decoded.using_global_exit_tags
7377                        };
7378                        // P15-prep — increment the counter (saturate
7379                        // at u32::MAX to avoid wrap on long runs).
7380                        // P15-A v1 — track whether this increment is
7381                        // the one that crossed `HOTEXIT_THRESHOLD`
7382                        // (transition: previous v < threshold, new v
7383                        // == threshold). The side-trace start is
7384                        // deferred to just before `continue;` so
7385                        // vm.stack and frame.pc are fully restored
7386                        // (the snapshot reads post-restore values).
7387                        let mut side_trace_should_start = false;
7388                        // P15-A v2-C-A3 — for side-trace returns the
7389                        // counter to bump is the CHILD's (decoded
7390                        // shape lookup) — `exit_hit_idx` is into the
7391                        // decoded layout, so use the matching
7392                        // `decode_hit_counts`. For parent decode
7393                        // they're aliased (clone of the parent's
7394                        // own Rc).
7395                        if let Some(c) = decode_hit_counts.get(exit_hit_idx) {
7396                            let v = c.get();
7397                            if v < u32::MAX {
7398                                c.set(v + 1);
7399                            }
7400                            if v + 1 == crate::jit::trace::HOTEXIT_THRESHOLD
7401                                && self.jit.active_trace.is_none()
7402                                && self.jit.trace_enabled
7403                            {
7404                                side_trace_should_start = true;
7405                            }
7406                        }
7407                        // P12-S4-step4b-C-2 — at an inline cmp@d>0
7408                        // side-exit, the helper has pushed N frames on
7409                        // top of the trace head's frame and
7410                        // `exit_tags_for_pc.len()` covers the full
7411                        // window (caller + each inlined frame's
7412                        // window). Slots beyond `max_stack` belong to
7413                        // an inlined frame: their `Untouched` entries
7414                        // default to Nil (no entry-tag fallback —
7415                        // marshal-in only captured caller slots) and
7416                        // we write to interp stack at `base + i` which
7417                        // mirrors `op_offsets`-derived layout.
7418                        let slot_count = exit_tags_for_pc.len();
7419                        // P12-S4-step4b-C-2 — the helper only extends
7420                        // vm.stack up to the deepest pushed frame's
7421                        // window, but the exit_tags snapshot covers
7422                        // the trace's full `window_size` (which
7423                        // includes depth-N+1 scratch slots that the
7424                        // trace's IR may have written without a
7425                        // matching pushed frame). Extend with Nil so
7426                        // the write at the tail doesn't panic; these
7427                        // slots get overwritten by the writeback loop
7428                        // and won't leak meaningful data past the
7429                        // pushed frames' R[0..max_stack) windows.
7430                        if self.stack.len() < base_us + slot_count {
7431                            self.stack
7432                                .resize(base_us + slot_count, crate::runtime::Value::Nil);
7433                        }
7434                        // P13-S13-E — fast-path restore loop. When
7435                        // we landed on the global `exit_tags`,
7436                        // dispatch on the compile-time
7437                        // classification: skip the loop entirely
7438                        // for `AllUntouched`, do a tag-free
7439                        // `Value::Int(...)` write per slot for
7440                        // `AllInt`, otherwise fall through to the
7441                        // general match-arm loop. site_id > 0
7442                        // (inline frame mat) and per_exit_tags
7443                        // hits always take the general path —
7444                        // their per-side-exit shapes aren't
7445                        // pre-classified yet.
7446                        let fast_path_taken = if using_global_exit_tags {
7447                            match global_tag_res_kind {
7448                                crate::jit::trace::TagResKind::AllUntouched => {
7449                                    // No-op: vm.stack already
7450                                    // matches the trace's post-
7451                                    // entry state for these
7452                                    // slots (entry values not
7453                                    // overridden, or already
7454                                    // spilled by helpers).
7455                                    true
7456                                }
7457                                crate::jit::trace::TagResKind::AllInt => {
7458                                    for i in 0..slot_count {
7459                                        self.stack[base_us + i] =
7460                                            crate::runtime::Value::Int(reg_state[i]);
7461                                    }
7462                                    true
7463                                }
7464                                crate::jit::trace::TagResKind::Mixed => false,
7465                            }
7466                        } else {
7467                            false
7468                        };
7469                        if !fast_path_taken {
7470                            for i in 0..slot_count {
7471                                let tag = match exit_tags_for_pc[i] {
7472                                    crate::jit::trace::ExitTag::Untouched => {
7473                                        if i < max_stack {
7474                                            entry_tags[i]
7475                                        } else {
7476                                            crate::runtime::value::raw::NIL
7477                                        }
7478                                    }
7479                                    crate::jit::trace::ExitTag::Int => {
7480                                        crate::runtime::value::raw::INT
7481                                    }
7482                                    crate::jit::trace::ExitTag::Float => {
7483                                        crate::runtime::value::raw::FLOAT
7484                                    }
7485                                    crate::jit::trace::ExitTag::Table => {
7486                                        crate::runtime::value::raw::TABLE
7487                                    }
7488                                    crate::jit::trace::ExitTag::Closure => {
7489                                        crate::runtime::value::raw::CLOSURE
7490                                    }
7491                                    // P12-S6-A1 — trace actively wrote Nil
7492                                    // to this slot (e.g. via Op::LoadNil).
7493                                    // Restore as Nil regardless of the entry
7494                                    // tag, since the i64 payload is 0 and
7495                                    // packing as the entry tag (e.g. INT)
7496                                    // would mis-type the slot.
7497                                    crate::jit::trace::ExitTag::Nil => {
7498                                        crate::runtime::value::raw::NIL
7499                                    }
7500                                    // P12-S12-C v2 — trace wrote a Str ptr
7501                                    // to this slot (LoadK Str / Move from
7502                                    // Str / Concat result). Restore as
7503                                    // Value::Str with raw bits round-
7504                                    // tripped.
7505                                    crate::jit::trace::ExitTag::Str => {
7506                                        crate::runtime::value::raw::STR
7507                                    }
7508                                };
7509                                // SAFETY: tag is from a verified slot
7510                                // (entry validated above) or pinned by
7511                                // the exit-tag analysis to INT/TABLE.
7512                                // The raw payload sits in reg_state[i].
7513                                // Stack was extended by the materialize
7514                                // helper for inline frames.
7515                                // 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).
7516                                self.stack[base_us + i] = unsafe {
7517                                    Value::pack(
7518                                        tag,
7519                                        crate::runtime::value::RawVal {
7520                                            zero: reg_state[i] as u64,
7521                                        },
7522                                    )
7523                                };
7524                            }
7525                        }
7526                        // P12-S4-step4b-C-2 — for non-inline exits the
7527                        // helper was never called (no metas chain for
7528                        // this cont_pc), so `frames.last()` is the
7529                        // trace head's frame and we set its pc to
7530                        // cont_pc as before. For inline exits the
7531                        // helper baked the side-exit PC into the
7532                        // innermost frame's `pc` at push time
7533                        // (chain.last().pc was overridden at emit),
7534                        // so this assignment to `frames.last_mut().pc
7535                        // = cont_pc` is a redundant-but-correct
7536                        // confirmation.
7537                        let _ = &per_exit_inline; // hold the Rc alive across dispatch
7538                        // P12-S4-step4b-C-2 — for inline side-exits the
7539                        // helper has pushed N frames on top. The trace
7540                        // head frame is at `pre_frames - 1`; set its
7541                        // pc to `head_resume_pc` so when the chain
7542                        // eventually pops back to it, interp resumes
7543                        // PAST the trace's depth-0 Op::Call instead of
7544                        // restarting from `head_pc` and re-triggering
7545                        // dispatch (infinite loop). The innermost
7546                        // (helper-pushed) frame already has its pc
7547                        // baked in at compile time, but we still
7548                        // assign `cont_pc` below for parity with the
7549                        // non-inline path (no-op).
7550                        if site_id > 0 {
7551                            let idx = (site_id - 1) as usize;
7552                            let head_resume_pc = decode_inline[idx].head_resume_pc;
7553                            if pre_frames > 0
7554                                && let CallFrame::Lua(f) = &mut self.frames[pre_frames - 1]
7555                            {
7556                                f.pc = head_resume_pc;
7557                            }
7558                        }
7559                        let frames_len_now = self.frames.len();
7560                        // 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).
7561                        match unsafe { self.frames.last_mut().unwrap_unchecked() } {
7562                            CallFrame::Lua(fmut) => {
7563                                if crate::jit::trace::v2c_probe_enabled() {
7564                                    eprintln!(
7565                                        "[v2c-set-pc] from_side={} sentinel_or_raw={:#018x} prev_pc={} new_cont_pc={} site_id={} frames.len={} pre_frames={} max_stack={}",
7566                                        from_side_trace,
7567                                        raw_ret,
7568                                        fmut.pc,
7569                                        cont_pc,
7570                                        site_id,
7571                                        frames_len_now,
7572                                        pre_frames,
7573                                        max_stack,
7574                                    );
7575                                }
7576                                fmut.pc = cont_pc;
7577                            }
7578                            _ => unreachable!("Cont frame at trace dispatch"),
7579                        }
7580                        // P15-A v1 — deferred side-trace start. The
7581                        // increment block above flagged this exit's
7582                        // hit count crossing HOTEXIT_THRESHOLD; now
7583                        // that vm.stack is restored and frame.pc is
7584                        // settled, snapshot entry_tags from the
7585                        // resume frame's window and create the
7586                        // recorder. The recorder's first push fires
7587                        // on the next interp iteration at cont_pc.
7588                        //
7589                        // `head_proto` for the side trace = cl.proto
7590                        // (trace JIT only inlines self-recursive
7591                        // calls today, so cont_pc always lands in
7592                        // the same proto as the parent). Frame base
7593                        // is the resume frame (top of `self.frames`
7594                        // — inline-pushed frames moved this).
7595                        if side_trace_should_start {
7596                            let (resume_base, resume_proto) = match self.frames.last() {
7597                                Some(CallFrame::Lua(f)) => (f.base as usize, f.closure.proto),
7598                                _ => (base_us, cl.proto),
7599                            };
7600                            let resume_max_stack = resume_proto.max_stack as usize;
7601                            let mut side_entry_tags: Vec<u8> = Vec::with_capacity(resume_max_stack);
7602                            // Extend stack if cont_pc's frame window
7603                            // overhangs the current stack len (rare,
7604                            // but inline-pushed frame stack writes
7605                            // only covered the trace's writeback).
7606                            if self.stack.len() < resume_base + resume_max_stack {
7607                                self.stack.resize(
7608                                    resume_base + resume_max_stack,
7609                                    crate::runtime::Value::Nil,
7610                                );
7611                            }
7612                            for i in 0..resume_max_stack {
7613                                let (tag, _) = self.stack[resume_base + i].unpack();
7614                                side_entry_tags.push(tag);
7615                            }
7616                            self.jit.active_trace =
7617                                Some(Box::new(crate::jit::trace::TraceRecord::start_side_trace(
7618                                    resume_proto,
7619                                    cont_pc,
7620                                    side_entry_tags,
7621                                    cl.proto,
7622                                    head_pc_val,
7623                                    exit_hit_idx,
7624                                )));
7625                            self.jit.recording_frame_base = self.frames.len() - 1;
7626                            self.jit.counters.side_trace_started += 1;
7627                        }
7628                        // P13-S13-D — put the dispatch buffers back
7629                        // before the `continue;` so the next
7630                        // dispatch picks up the same allocation.
7631                        self.jit.reg_state_buf = reg_state;
7632                        self.jit.entry_tags_buf = entry_tags;
7633                        continue;
7634                    }
7635                }
7636                // P13-S13-D — !dispatch_ok / deopt path / non-cont
7637                // exit also restore the buffers before falling
7638                // through to the interp.
7639                self.jit.reg_state_buf = reg_state;
7640                self.jit.entry_tags_buf = entry_tags;
7641            }
7642
7643            // PUC `vmfetch` increments savedpc BEFORE firing traceexec, so
7644            // hook code that consults `currentpc = savedpc - 1` lands on the
7645            // instruction now executing. luna mirrors that by advancing
7646            // `f.pc` to `pc + 1` before the hook block — local_at /
7647            // getinfo / line attribution all read f.pc, and the existing
7648            // `pc - 1` convention in those helpers then yields the current
7649            // instruction's pc (db.lua :696: local `A` visible at the
7650            // chunk's return line once OP_CLOSURE has advanced pc).
7651            //
7652            // Inline `top_frame_mut` for the hot path: top is guaranteed Lua
7653            // (cont frames drained above) so the and_then/Option layers are
7654            // dead weight.
7655            // 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).
7656            match unsafe { self.frames.last_mut().unwrap_unchecked() } {
7657                CallFrame::Lua(fmut) => fmut.pc = pc + 1,
7658                _ => unreachable!("Cont frame at pc bump"),
7659            }
7660
7661            // count + line hooks (PUC traceexec): before executing the
7662            // instruction. Skipped while the hook itself runs.
7663            // (Parens here are load-bearing — without them `&&` binds tighter
7664            // than `||` and the `!in_hook` guard only gates the rust-hook arm,
7665            // letting a Lua line hook recurse into itself → stack overflow
7666            // on db.lua line-hook assertions. Matches the `hook_call_with` /
7667            // `hook_return` predicate shape at lines 2245 / 2279 / 2294 / 4023.)
7668            if !self.in_hook && (self.hook.func.is_some() || self.hook.rust_func.is_some()) {
7669                let lines = &cl.proto.lines;
7670                let cur_line = if lines.is_empty() {
7671                    None
7672                } else {
7673                    Some(lines[(pc as usize).min(lines.len() - 1)] as i64)
7674                };
7675                // count hook: fire every `count_base` instructions
7676                if self.hook.count {
7677                    self.hook.count_left -= 1;
7678                    if self.hook.count_left <= 0 {
7679                        self.hook.count_left = self.hook.count_base;
7680                        // hooked function is the running Lua frame: its frame
7681                        // is on the stack, so no synthetic C level is needed.
7682                        self.run_hook(b"count", cur_line, false)?;
7683                    }
7684                }
7685                // line hook: fire on a fresh frame, a backward jump (loop), or a
7686                // change of source line.
7687                if self.hook.line {
7688                    if lines.is_empty() {
7689                        // PUC: a stripped chunk has no line info, so
7690                        // `getfuncline` returns -1. The line hook still fires
7691                        // on the first instruction of the new frame (where
7692                        // `npci <= oldpc` holds at oldpc=0), with the line
7693                        // pushed as `nil` instead of an integer (db.lua :1030
7694                        // "hook called without debug info for 1st instruction").
7695                        if oldpc == u32::MAX {
7696                            self.run_hook(b"line", None, false)?;
7697                            self.top_frame_mut().hook_oldpc = pc;
7698                        }
7699                    } else {
7700                        let newline = lines[(pc as usize).min(lines.len() - 1)];
7701                        // PUC `traceexec`: fire on frame entry (`oldpc == MAX`),
7702                        // on a backward jump (`pc < oldpc` — strict; an equal pc
7703                        // would re-fire the install-site after `oldpc = pc`),
7704                        // or when the source line changes.
7705                        let fire = oldpc == u32::MAX
7706                            || pc < oldpc
7707                            || newline != lines[(oldpc as usize).min(lines.len() - 1)];
7708                        if fire {
7709                            self.run_hook(b"line", Some(newline as i64), false)?;
7710                        }
7711                        self.top_frame_mut().hook_oldpc = pc;
7712                    }
7713                }
7714            }
7715
7716            match inst.op() {
7717                Op::Move => {
7718                    let v = self.r(base, inst.b());
7719                    self.set_r(base, inst.a(), v);
7720                }
7721                Op::LoadI => self.set_r(base, inst.a(), Value::Int(inst.sbx() as i64)),
7722                Op::LoadF => self.set_r(base, inst.a(), Value::Float(inst.sbx() as f64)),
7723                Op::LoadK => {
7724                    let v = cl.proto.consts[inst.bx() as usize];
7725                    self.set_r(base, inst.a(), v);
7726                }
7727                Op::LoadKx => {
7728                    let extra = cl.proto.code[self.pc_of_top() as usize];
7729                    self.bump_pc();
7730                    let v = cl.proto.consts[extra.ax() as usize];
7731                    self.set_r(base, inst.a(), v);
7732                }
7733                Op::LoadFalse => self.set_r(base, inst.a(), Value::Bool(false)),
7734                Op::LFalseSkip => {
7735                    self.set_r(base, inst.a(), Value::Bool(false));
7736                    self.bump_pc();
7737                }
7738                Op::LoadTrue => self.set_r(base, inst.a(), Value::Bool(true)),
7739                Op::LoadNil => {
7740                    let a = inst.a();
7741                    for i in 0..=inst.b() {
7742                        self.set_r(base, a + i, Value::Nil);
7743                    }
7744                }
7745                Op::GetUpval => {
7746                    let v = self.upval_get(cl, inst.b());
7747                    self.set_r(base, inst.a(), v);
7748                }
7749                Op::SetUpval => {
7750                    let v = self.r(base, inst.a());
7751                    self.upval_set(cl, inst.b(), v);
7752                }
7753                Op::GetTabUp => {
7754                    let t = self.upval_get(cl, inst.b());
7755                    let key = cl.proto.consts[inst.c() as usize];
7756                    self.op_index(t, key, base + inst.a())?;
7757                }
7758                Op::GetTable => {
7759                    let t = self.r(base, inst.b());
7760                    let key = self.r(base, inst.c());
7761                    self.op_index(t, key, base + inst.a())?;
7762                }
7763                Op::GetI => {
7764                    let t = self.r(base, inst.b());
7765                    self.op_index(t, Value::Int(inst.c() as i64), base + inst.a())?;
7766                }
7767                Op::GetField => {
7768                    let t = self.r(base, inst.b());
7769                    let key = cl.proto.consts[inst.c() as usize];
7770                    // v1.2 D4 A1 — fast path: known-Str const key + no
7771                    // metatable on the table → skip `op_index` /
7772                    // `index_step`'s MAX_TAG_LOOP setup and the outer
7773                    // `Value` match. Falls through to the slow path
7774                    // unchanged when either invariant breaks (so
7775                    // `__index` metamethods, non-Table receivers, and
7776                    // non-Str keys behave exactly as before).
7777                    if let Value::Table(tb) = t
7778                        && tb.metatable().is_none()
7779                        && let Value::Str(s) = key
7780                    {
7781                        let v = tb.get_str(s);
7782                        self.stack[(base + inst.a()) as usize] = v;
7783                    } else {
7784                        self.op_index(t, key, base + inst.a())?;
7785                    }
7786                }
7787                Op::SetTabUp => {
7788                    let t = self.upval_get(cl, inst.a());
7789                    let key = cl.proto.consts[inst.b() as usize];
7790                    let v = self.r(base, inst.c());
7791                    self.op_newindex(t, key, v)?;
7792                }
7793                Op::SetTable => {
7794                    let t = self.r(base, inst.a());
7795                    let key = self.r(base, inst.b());
7796                    let v = self.r(base, inst.c());
7797                    self.op_newindex(t, key, v)?;
7798                }
7799                Op::SetI => {
7800                    let t = self.r(base, inst.a());
7801                    let v = self.r(base, inst.c());
7802                    self.op_newindex(t, Value::Int(inst.b() as i64), v)?;
7803                }
7804                Op::SetField => {
7805                    let t = self.r(base, inst.a());
7806                    let key = cl.proto.consts[inst.b() as usize];
7807                    let v = self.r(base, inst.c());
7808                    self.op_newindex(t, key, v)?;
7809                }
7810                Op::NewTable => {
7811                    let t = self.heap.new_table();
7812                    self.set_r(base, inst.a(), Value::Table(t));
7813                    self.maybe_collect_garbage(base + inst.a() + 1);
7814                }
7815                Op::SetList => {
7816                    let a = inst.a();
7817                    let abs_a = base + a;
7818                    let n = if inst.b() == 0 {
7819                        self.top - (abs_a + 1)
7820                    } else {
7821                        inst.b()
7822                    };
7823                    let offset = if inst.k() {
7824                        let extra = cl.proto.code[self.pc_of_top() as usize];
7825                        self.bump_pc();
7826                        extra.ax() as i64
7827                    } else {
7828                        inst.c() as i64
7829                    };
7830                    let Value::Table(t) = self.r(base, a) else {
7831                        unreachable!("SETLIST on non-table");
7832                    };
7833                    for i in 1..=n {
7834                        let v = self.r(base, a + i);
7835                        // SAFETY: Gc<T> is NonNull<T> over the GC heap; the heap is single-threaded and the pointer is live as long as it is reachable from active roots (see heap.rs:5-7).
7836                        if let Err(TableError::Overflow) =
7837                            unsafe { t.as_mut() }.set_int(&mut self.heap, offset + i as i64, v)
7838                        {
7839                            return Err(self.rt_err("table overflow"));
7840                        }
7841                    }
7842                    // one barrier_back covers every store this op did — PUC's
7843                    // `luaC_barrierback_` once-per-table optimisation
7844                    self.heap
7845                        .barrier_back(t.as_ptr() as *mut crate::runtime::heap::GcHeader);
7846                    // the element temps above the table are now consumed
7847                    self.maybe_collect_garbage(base + a + 1);
7848                }
7849                Op::SelfOp => {
7850                    let o = self.r(base, inst.b());
7851                    self.set_r(base, inst.a() + 1, o);
7852                    // PUC OP_SELF's C is a constant index when the k-flag is
7853                    // set; otherwise it points to a register that holds the
7854                    // (constant-loaded) key. luna's compiler falls back to the
7855                    // register form when the constant index exceeds OP_SELF's
7856                    // 8-bit C field (5.1 big.lua's `a:findfield(...)` against
7857                    // a table with 250+ string keys, where "findfield" lands
7858                    // past const #255). The exec must honour the same split.
7859                    let key = if inst.k() {
7860                        cl.proto.consts[inst.c() as usize]
7861                    } else {
7862                        self.r(base, inst.c())
7863                    };
7864                    self.op_index(o, key, base + inst.a())?;
7865                }
7866                Op::Add => self.arith_rr(inst, base, ArithOp::Add)?,
7867                Op::Sub => self.arith_rr(inst, base, ArithOp::Sub)?,
7868                Op::Mul => self.arith_rr(inst, base, ArithOp::Mul)?,
7869                Op::Mod => self.arith_rr(inst, base, ArithOp::Mod)?,
7870                Op::Pow => self.arith_rr(inst, base, ArithOp::Pow)?,
7871                Op::Div => self.arith_rr(inst, base, ArithOp::Div)?,
7872                Op::IDiv => self.arith_rr(inst, base, ArithOp::IDiv)?,
7873                Op::BAnd => self.arith_rr(inst, base, ArithOp::BAnd)?,
7874                Op::BOr => self.arith_rr(inst, base, ArithOp::BOr)?,
7875                Op::BXor => self.arith_rr(inst, base, ArithOp::BXor)?,
7876                Op::Shl => self.arith_rr(inst, base, ArithOp::Shl)?,
7877                Op::Shr => self.arith_rr(inst, base, ArithOp::Shr)?,
7878                Op::Unm => {
7879                    let v = self.r(base, inst.b());
7880                    match coerce_num(v) {
7881                        Some(Num::Int(i)) => {
7882                            self.set_r(base, inst.a(), Value::Int(i.wrapping_neg()))
7883                        }
7884                        Some(Num::Float(f)) => self.set_r(base, inst.a(), Value::Float(-f)),
7885                        None => {
7886                            let mm = self.get_mm(v, Mm::Unm);
7887                            if mm.is_nil() {
7888                                return Err(self.type_err("perform arithmetic on", v));
7889                            }
7890                            let dst = base + inst.a();
7891                            self.begin_meta_call(mm, &[v, v], MetaAction::Store { dst }, "unm")?;
7892                        }
7893                    }
7894                }
7895                Op::BNot => {
7896                    let v = self.r(base, inst.b());
7897                    match coerce_num(v) {
7898                        Some(n) => {
7899                            let i = self.int_from_num(n)?;
7900                            self.set_r(base, inst.a(), Value::Int(!i));
7901                        }
7902                        None => {
7903                            let mm = self.get_mm(v, Mm::BNot);
7904                            if mm.is_nil() {
7905                                return Err(self.type_err("perform bitwise operation on", v));
7906                            }
7907                            let dst = base + inst.a();
7908                            self.begin_meta_call(mm, &[v, v], MetaAction::Store { dst }, "bnot")?;
7909                        }
7910                    }
7911                }
7912                Op::Not => {
7913                    let v = self.r(base, inst.b());
7914                    self.set_r(base, inst.a(), Value::Bool(!v.truthy()));
7915                }
7916                Op::Len => {
7917                    let v = self.r(base, inst.b());
7918                    match self.len_step(v)? {
7919                        MmOut::Done(r) => self.set_r(base, inst.a(), r),
7920                        MmOut::Mm { func, recv } => {
7921                            let dst = base + inst.a();
7922                            self.begin_meta_call(
7923                                func,
7924                                &[recv, recv],
7925                                MetaAction::Store { dst },
7926                                "len",
7927                            )?;
7928                        }
7929                        MmOut::CompareSynth { .. } => unreachable!("CompareSynth from len_step"),
7930                    }
7931                }
7932                Op::Concat => {
7933                    // right-associative fold over operands at base+a .. base+a+n,
7934                    // in place on the stack so a yielding __concat can suspend.
7935                    let a = inst.a();
7936                    let n = inst.b();
7937                    self.top = base + a + n;
7938                    self.concat_run(base + a)?;
7939                }
7940                Op::Close => {
7941                    // Yieldable: drive __close handlers through the
7942                    // interpreter loop so a coroutine.yield() inside a
7943                    // handler suspends cleanly (locals.lua block-end yield).
7944                    // `drive_close` parks the handler call at `self.top`, so
7945                    // raise `top` past this frame's full register window
7946                    // first — a goto out of a nested for-loop can fire
7947                    // OP_Close while `self.top` still sits at the inner
7948                    // body's working top, which would let `push_frame`'s
7949                    // wipe clobber the outer tbc slot before it could be
7950                    // closed (locals.lua:1219 nested-for goto regression).
7951                    self.top = self.top.max(base + cl.proto.max_stack as u32);
7952                    let _ =
7953                        self.begin_close(base + inst.a(), None, AfterClose::Block, entry_depth)?;
7954                }
7955                Op::Tbc => {
7956                    self.register_tbc(base + inst.a())?;
7957                }
7958                Op::Jmp => {
7959                    let off = inst.sj();
7960                    // P12-S1.B — trace JIT back-edge counter. A negative
7961                    // jump offset is a loop back-edge (the only canonical
7962                    // backward jumps the compiler emits — `while`, `for`,
7963                    // `repeat`). Tick the per-Proto counter and, once it
7964                    // exceeds the threshold, log a stub promotion that
7965                    // S1.C will turn into actual trace recording. The
7966                    // whole block is gated on `trace_jit_enabled` so
7967                    // existing benches see one branch-not-taken and no
7968                    // counter writes.
7969                    if self.jit.trace_enabled && off < 0 {
7970                        let proto = cl.proto;
7971                        let c = proto.trace_hot_count.get();
7972                        if c < u32::MAX / 2 {
7973                            proto.trace_hot_count.set(c + 1);
7974                        }
7975                        // P13-S13-H — relaxed back-edge trigger:
7976                        // `c >= THRESHOLD` (was `c == THRESHOLD`) so
7977                        // a missed crossing (active_trace busy with
7978                        // a call-trigger, or the recorder slot
7979                        // happened to be in use) doesn't permanently
7980                        // lock this back-edge target out. The
7981                        // `already_cached` short-circuit prevents
7982                        // duplicate recordings: once a trace is
7983                        // cached for this target, subsequent
7984                        // crossings skip the start. This pairs with
7985                        // S13-H's discard-on-partial-coverage close
7986                        // handling — when a short call-trigger is
7987                        // discarded, the back-edge can still find an
7988                        // open slot at the next iteration.
7989                        let target_pc = (pc as i32 + 1 + off as i32).max(0) as u32;
7990                        // P13-S13-K — gave-up short-circuit. Skip
7991                        // the RefCell borrow + scan when the
7992                        // S13-I cap force-compiled a partial
7993                        // trace on this Proto.
7994                        let back_edge_already_cached = if proto.trace_gave_up.get() {
7995                            true
7996                        } else {
7997                            proto.traces.borrow().iter().any(|t| t.head_pc == target_pc)
7998                        };
7999                        if c >= crate::jit::trace::TRACE_HOT_THRESHOLD
8000                            && self.jit.active_trace.is_none()
8001                            && !back_edge_already_cached
8002                        {
8003                            // Back-edge target = pc after `add_pc(off)`,
8004                            // i.e. current `pc + 1 + off` (the dispatch
8005                            // loop has already advanced f.pc to pc+1).
8006                            let target = (pc as i32 + 1 + off as i32).max(0) as u32;
8007                            // Snapshot per-slot Value tag at trace
8008                            // entry so the lowerer's kind tracker
8009                            // knows which arith path to lower
8010                            // (iadd vs fadd, etc.).
8011                            let max_stack = cl.proto.max_stack as usize;
8012                            let base_us = base as usize;
8013                            let mut entry_tags = Vec::with_capacity(max_stack);
8014                            for i in 0..max_stack {
8015                                let (tag, _) = self.stack[base_us + i].unpack();
8016                                entry_tags.push(tag);
8017                            }
8018                            self.jit.active_trace =
8019                                Some(Box::new(crate::jit::trace::TraceRecord::start(
8020                                    cl.proto, target, entry_tags, false,
8021                                )));
8022                            // P12-S4 — record the frame the trace
8023                            // started in. `self.frames.len() - 1`
8024                            // since we're inside the currently-running
8025                            // Lua frame's dispatch.
8026                            self.jit.recording_frame_base = self.frames.len() - 1;
8027                        }
8028                    }
8029                    self.add_pc(off);
8030                }
8031                Op::Eq => {
8032                    let l = self.r(base, inst.a());
8033                    let r = self.r(base, inst.b());
8034                    if let (Value::Int(a), Value::Int(b)) = (l, r) {
8035                        if (a == b) != inst.k() {
8036                            self.bump_pc();
8037                        }
8038                    } else {
8039                        let step = self.eq_step(l, r);
8040                        self.op_compare(step, l, r, inst.k(), "eq")?;
8041                    }
8042                }
8043                Op::EqK => {
8044                    let l = self.r(base, inst.a());
8045                    let r = cl.proto.consts[inst.b() as usize];
8046                    if let (Value::Int(a), Value::Int(b)) = (l, r) {
8047                        if (a == b) != inst.k() {
8048                            self.bump_pc();
8049                        }
8050                    } else {
8051                        let step = self.eq_step(l, r);
8052                        self.op_compare(step, l, r, inst.k(), "eq")?;
8053                    }
8054                }
8055                Op::Lt => {
8056                    let l = self.r(base, inst.a());
8057                    let r = self.r(base, inst.b());
8058                    // hot path: Int < Int — drops the MmOut + op_compare match
8059                    if let (Value::Int(a), Value::Int(b)) = (l, r) {
8060                        if (a < b) != inst.k() {
8061                            self.bump_pc();
8062                        }
8063                    } else {
8064                        let step = self.less_step(l, r, false)?;
8065                        self.op_compare(step, l, r, inst.k(), "lt")?;
8066                    }
8067                }
8068                Op::Le => {
8069                    let l = self.r(base, inst.a());
8070                    let r = self.r(base, inst.b());
8071                    if let (Value::Int(a), Value::Int(b)) = (l, r) {
8072                        if (a <= b) != inst.k() {
8073                            self.bump_pc();
8074                        }
8075                    } else {
8076                        let step = self.less_step(l, r, true)?;
8077                        self.op_compare(step, l, r, inst.k(), "le")?;
8078                    }
8079                }
8080                Op::Test => {
8081                    let cond = self.r(base, inst.a()).truthy();
8082                    self.cond_skip(cond, inst.k());
8083                }
8084                Op::TestSet => {
8085                    let v = self.r(base, inst.b());
8086                    if v.truthy() == inst.k() {
8087                        self.set_r(base, inst.a(), v);
8088                    } else {
8089                        self.bump_pc();
8090                    }
8091                }
8092                Op::Call => {
8093                    let abs = base + inst.a();
8094                    let nargs = if inst.b() == 0 {
8095                        None
8096                    } else {
8097                        Some(inst.b() - 1)
8098                    };
8099                    let wanted = inst.c() as i32 - 1;
8100                    self.begin_call(abs, nargs, wanted, false)?;
8101                }
8102                Op::TailCall => {
8103                    let fr = *self.top_frame();
8104                    let abs = base + inst.a();
8105                    let mut nargs = if inst.b() == 0 {
8106                        self.top - (abs + 1)
8107                    } else {
8108                        inst.b() - 1
8109                    };
8110                    // A tail call pops this frame before begin_call, so a
8111                    // non-callable target would lose its name/position. Report
8112                    // it now (PUC reads funcname from the still-current ci),
8113                    // while the frame is intact, for "(field 'x')"-style info.
8114                    let mut func = self.stack[abs as usize];
8115                    if !matches!(func, Value::Closure(_) | Value::Native(_))
8116                        && self.get_mm(func, Mm::Call).is_nil()
8117                    {
8118                        return Err(self.call_err(func));
8119                    }
8120                    // PUC `luaD_pretailcall` resolves a chain of `__call`
8121                    // metamethods *in place* before deciding whether to
8122                    // collapse this frame. Without that, each __call hop
8123                    // would push a fresh Lua frame and a 10000-deep
8124                    // tail-recursion through a 100-deep __call chain
8125                    // (5.4 calls.lua :172) blows up. Mirror the PUC loop:
8126                    // shift args right, install the handler at `abs`, retry.
8127                    // Chain depth limit matches the call-site `begin_call`
8128                    // version cap (5.5 calls.lua :223 — 15 max, then "too
8129                    // long"; 16th wrap fails the call). An infinite
8130                    // self-referential `__call` would otherwise spin.
8131                    let chain_cap = if self.version >= LuaVersion::Lua55 {
8132                        15
8133                    } else {
8134                        MAX_CCMT
8135                    };
8136                    let mut chain = 0u32;
8137                    while !matches!(func, Value::Closure(_) | Value::Native(_)) {
8138                        let mm = self.get_mm(func, Mm::Call);
8139                        if mm.is_nil() {
8140                            return Err(self.call_err(func));
8141                        }
8142                        chain += 1;
8143                        if chain > chain_cap {
8144                            return Err(self.rt_err("'__call' chain too long"));
8145                        }
8146                        let end = (abs + 1 + nargs) as usize;
8147                        if self.stack.len() < end + 1 {
8148                            self.stack.resize(end + 1, Value::Nil);
8149                        }
8150                        for i in (0..=nargs).rev() {
8151                            self.stack[(abs + 1 + i) as usize] = self.stack[(abs + i) as usize];
8152                        }
8153                        self.stack[abs as usize] = mm;
8154                        nargs += 1;
8155                        self.top = abs + 1 + nargs;
8156                        func = mm;
8157                    }
8158                    // PUC's tail-call collapse is Lua→Lua only. A tail call to
8159                    // a C function runs the C function under the *current* Lua
8160                    // activation (no frame fold — a C frame has nothing to
8161                    // collapse into); after the C function returns, the
8162                    // calling Lua function returns those results normally.
8163                    // Mirror that: keep our Lua frame on the stack, call the
8164                    // target through `begin_call(abs, …)` as a regular call,
8165                    // and let the fallback `Op::Return` that the compiler
8166                    // emits right after `Op::TailCall` forward the results.
8167                    // 5.1 closure.lua :177's `return getfenv()` from inside
8168                    // foo needs level 1 to resolve to foo, not to the
8169                    // thread's globals fallback that happens when no Lua
8170                    // frame is on the stack.
8171                    let lua_target = matches!(func, Value::Closure(_));
8172                    if lua_target {
8173                        self.close_slots(fr.base, None)?;
8174                        for i in 0..=nargs {
8175                            self.stack[(fr.func_slot + i) as usize] =
8176                                self.stack[(abs + i) as usize];
8177                        }
8178                        // v2.5 P1B-2A: clear the slot range that's now
8179                        // stranded by the tail-call collapse. The args
8180                        // were copied to `[fr.func_slot..fr.func_slot+
8181                        // nargs+1)`; the source slots `[abs..abs+
8182                        // nargs+1)` still hold the same `Value::Closure
8183                        // / Value::Str / ...` entries, but they're past
8184                        // the new call's window. Without this clear, a
8185                        // later GC with wider gc_top would mark stale
8186                        // pointers there (same UAF-A family the v2.3
8187                        // finish_results slot-clear closed for the
8188                        // Op::Return path).
8189                        let new_top_lower_bound = fr.func_slot + nargs + 1;
8190                        let prev_top = (self.top as usize).min(self.stack.len());
8191                        if (new_top_lower_bound as usize) < prev_top {
8192                            for slot in &mut self.stack[new_top_lower_bound as usize..prev_top] {
8193                                *slot = Value::Nil;
8194                            }
8195                        }
8196                        // PUC `CIST_TAIL`: the new Lua activation inherits
8197                        // the popped frame's tailcalls count plus one for
8198                        // this collapse. 5.1 db.lua :372 hammers 30000
8199                        // recursive tail calls and expects to see the
8200                        // synthetic tail level for every one of them.
8201                        self.pending_tailcalls = fr.tailcalls.saturating_add(1);
8202                        frames_pop_sync(&mut self.frames, &mut self.frames_top);
8203                        if !self.begin_call(fr.func_slot, Some(nargs), fr.nresults, false)?
8204                            && self.frames.len() < entry_depth
8205                        {
8206                            // a native completed what was this function's result
8207                            return Ok(self.take_results(fr.func_slot));
8208                        }
8209                    } else {
8210                        // Native (or __call-bearing) target: regular call. The
8211                        // results land at `abs..self.top` and the next op (the
8212                        // fallback `Op::Return`) forwards them. `wanted = -1`
8213                        // because the caller will multret them through Return.
8214                        self.begin_call(abs, Some(nargs), -1, false)?;
8215                    }
8216                }
8217                Op::Return | Op::Return0 | Op::Return1 => {
8218                    let (abs_a, nret) = match inst.op() {
8219                        Op::Return0 => (base, 0),
8220                        Op::Return1 => (base + inst.a(), 1),
8221                        _ => {
8222                            let abs_a = base + inst.a();
8223                            let nret = if inst.b() == 0 {
8224                                self.top - abs_a
8225                            } else {
8226                                inst.b() - 1
8227                            };
8228                            (abs_a, nret)
8229                        }
8230                    };
8231                    // close before moving results: __close handlers run above
8232                    // the stack top, so the result region [abs_a..abs_a+nret)
8233                    // stays intact across any yields the close performs.
8234                    // Fixed-count returns may leave `self.top` below the last
8235                    // result slot (the compiler does not always re-bump it);
8236                    // raise it past the result region so `drive_close` parks
8237                    // the handler call *above* — landing at `self.top` would
8238                    // otherwise clobber a result with the handler closure.
8239                    self.top = self.top.max(abs_a + nret);
8240                    if let Some(vals) = self.begin_close(
8241                        base,
8242                        None,
8243                        AfterClose::Return {
8244                            abs_a,
8245                            nret,
8246                            from_native: false,
8247                        },
8248                        entry_depth,
8249                    )? {
8250                        return Ok(vals);
8251                    }
8252                }
8253                Op::ForPrep => self.for_prep(inst, base)?,
8254                Op::ForLoop => {
8255                    // P12 — trace JIT back-edge counter on the
8256                    // numeric-for back-edge. ForLoop is always at
8257                    // a back-edge position (when it continues);
8258                    // for the trace recorder we treat it as the
8259                    // close-detection equivalent of `Op::Jmp` with
8260                    // negative offset. Counter only ticks when the
8261                    // back-edge will actually fire (count > 0 in
8262                    // the 5.4+ Int form, comparable predicates in
8263                    // pre-5.3 / Float). The cheap check up front
8264                    // matches the for_loop helper's branch.
8265                    if self.jit.trace_enabled {
8266                        let a = inst.a();
8267                        let pre53 = self.version() <= LuaVersion::Lua53;
8268                        let take_back_edge =
8269                            match (self.r(base, a), self.r(base, a + 1), self.r(base, a + 2)) {
8270                                (Value::Int(_), Value::Int(count), Value::Int(_)) if !pre53 => {
8271                                    count > 0
8272                                }
8273                                (Value::Int(cur), Value::Int(lim), Value::Int(st)) if pre53 => {
8274                                    let next = cur.wrapping_add(st);
8275                                    if st > 0 { next <= lim } else { next >= lim }
8276                                }
8277                                (Value::Float(cur), Value::Float(lim), Value::Float(st)) => {
8278                                    let next = cur + st;
8279                                    if st > 0.0 { next <= lim } else { next >= lim }
8280                                }
8281                                _ => false,
8282                            };
8283                        if take_back_edge {
8284                            let proto = cl.proto;
8285                            let c = proto.trace_hot_count.get();
8286                            if c < u32::MAX / 2 {
8287                                proto.trace_hot_count.set(c + 1);
8288                            }
8289                            if c == crate::jit::trace::TRACE_HOT_THRESHOLD
8290                                && self.jit.active_trace.is_none()
8291                            {
8292                                // ForLoop's back-edge target = pc
8293                                // after `add_pc(-bx)` runs from the
8294                                // already-bumped f.pc (= pc + 1).
8295                                // So target = (pc + 1) - bx.
8296                                let target = (pc as i32 + 1 - inst.bx() as i32).max(0) as u32;
8297                                let max_stack = cl.proto.max_stack as usize;
8298                                let base_us = base as usize;
8299                                let mut entry_tags = Vec::with_capacity(max_stack);
8300                                for i in 0..max_stack {
8301                                    let (tag, _) = self.stack[base_us + i].unpack();
8302                                    entry_tags.push(tag);
8303                                }
8304                                self.jit.active_trace =
8305                                    Some(Box::new(crate::jit::trace::TraceRecord::start(
8306                                        cl.proto, target, entry_tags, false,
8307                                    )));
8308                                // P12-S4 — record the frame the trace
8309                                // started in. The currently-running
8310                                // Lua frame is at len() - 1.
8311                                self.jit.recording_frame_base = self.frames.len() - 1;
8312                            }
8313                        }
8314                    }
8315                    self.for_loop(inst, base);
8316                }
8317                Op::TForPrep => {
8318                    // the 4th control slot is the iterator's closing value
8319                    self.register_tbc(base + inst.a() + 3)?;
8320                    self.add_pc(inst.bx() as i32);
8321                }
8322                Op::TForCall => {
8323                    let abs = base + inst.a();
8324                    let need = (abs + 7) as usize;
8325                    if self.stack.len() < need {
8326                        self.stack.resize(need, Value::Nil);
8327                    }
8328                    self.stack[(abs + 4) as usize] = self.stack[abs as usize];
8329                    self.stack[(abs + 5) as usize] = self.stack[(abs + 1) as usize];
8330                    self.stack[(abs + 6) as usize] = self.stack[(abs + 2) as usize];
8331                    let nvars = inst.c() as i32;
8332                    self.begin_call(abs + 4, Some(2), nvars, false)?;
8333                }
8334                Op::TForLoop => {
8335                    let a = inst.a();
8336                    let ctrl = self.r(base, a + 4);
8337                    if !ctrl.is_nil() {
8338                        // P12-S12-B v1 — trace JIT back-edge counter on
8339                        // generic-for back-edge. TForLoop sits at the
8340                        // tail of `for k,v in expr do ... end`; recorder
8341                        // treats it as the close-detection equivalent of
8342                        // a negative Op::Jmp. Gate on `take_back_edge`
8343                        // (= `ctrl != nil`) so empty-iter loops don't
8344                        // pollute hot_count. v1 only adds the trigger;
8345                        // whitelist + helper + emit live in v2.
8346                        if self.jit.trace_enabled {
8347                            let proto = cl.proto;
8348                            let c = proto.trace_hot_count.get();
8349                            if c < u32::MAX / 2 {
8350                                proto.trace_hot_count.set(c + 1);
8351                            }
8352                            if c == crate::jit::trace::TRACE_HOT_THRESHOLD
8353                                && self.jit.active_trace.is_none()
8354                            {
8355                                // TForLoop back-edge target = pc after
8356                                // `add_pc(-bx)` runs from the already-
8357                                // bumped f.pc (= pc + 1). So target =
8358                                // (pc + 1) - bx, normally landing on
8359                                // body_top (the op right after TForPrep).
8360                                let target = (pc as i32 + 1 - inst.bx() as i32).max(0) as u32;
8361                                let max_stack = cl.proto.max_stack as usize;
8362                                let base_us = base as usize;
8363                                let mut entry_tags = Vec::with_capacity(max_stack);
8364                                for i in 0..max_stack {
8365                                    let (tag, _) = self.stack[base_us + i].unpack();
8366                                    entry_tags.push(tag);
8367                                }
8368                                // P12-S12-B-v5 — snapshot the iter
8369                                // fn's address if Native, so the
8370                                // lowerer can specialise ipairs into
8371                                // inline Table aget IR.
8372                                let iter_ptr =
8373                                    if let Value::Native(n) = self.stack[base_us + a as usize] {
8374                                        Some(n.f as usize)
8375                                    } else {
8376                                        None
8377                                    };
8378                                // P12-S12-C v3 — snapshot R[A+5]'s
8379                                // tag (= current iter's val from
8380                                // the just-fired TForCall). The v5
8381                                // inline aget fast_blk emits a
8382                                // runtime guard against this tag;
8383                                // mixed-tag arrays deopt rather
8384                                // than producing garbage pointers
8385                                // through the v2 spill path.
8386                                let val_slot = base_us + (a as usize) + 5;
8387                                let val_tag = if val_slot < self.stack.len() {
8388                                    Some(self.stack[val_slot].unpack().0)
8389                                } else {
8390                                    None
8391                                };
8392                                let mut rec = crate::jit::trace::TraceRecord::start(
8393                                    cl.proto, target, entry_tags, false,
8394                                );
8395                                rec.tfor_iter_ptr = iter_ptr;
8396                                rec.tfor_val_tag = val_tag;
8397                                self.jit.active_trace = Some(Box::new(rec));
8398                                self.jit.recording_frame_base = self.frames.len() - 1;
8399                            }
8400                        }
8401                        self.set_r(base, a + 2, ctrl);
8402                        self.add_pc(-(inst.bx() as i32));
8403                    }
8404                }
8405                Op::Closure => {
8406                    let proto = cl.proto.protos[inst.bx() as usize];
8407                    let n_ups = proto.upvals.len();
8408                    // P11-S5d.M — build upvals on the stack for small
8409                    // closures, skipping the per-call Vec/Box alloc
8410                    // that closure_alloc's 10k iters pay. INLINE_UPVALS_N
8411                    // = 2 covers most Lua source (1 captured local, or
8412                    // _ENV + a single capture). Beyond that, fall back
8413                    // to a heap Vec.
8414                    use crate::runtime::function::INLINE_UPVALS_N;
8415                    let mut stack_buf: [std::mem::MaybeUninit<
8416                        Gc<crate::runtime::function::Upvalue>,
8417                    >; INLINE_UPVALS_N] = [std::mem::MaybeUninit::uninit(); INLINE_UPVALS_N];
8418                    let mut heap_buf: Vec<Gc<crate::runtime::function::Upvalue>> = Vec::new();
8419                    let use_inline = n_ups <= INLINE_UPVALS_N;
8420                    if !use_inline {
8421                        heap_buf.reserve_exact(n_ups);
8422                    }
8423                    for (i, d) in proto.upvals.iter().enumerate() {
8424                        let uv = if d.in_stack {
8425                            self.find_or_create_upval(base + d.index as u32)
8426                        } else {
8427                            cl.upvals()[d.index as usize]
8428                        };
8429                        if use_inline {
8430                            stack_buf[i] = std::mem::MaybeUninit::new(uv);
8431                        } else {
8432                            heap_buf.push(uv);
8433                        }
8434                    }
8435                    // Tiny shim around the two paths so the 5.1 _ENV
8436                    // clone + cache check below see one uniform
8437                    // `&mut [Gc<Upvalue>]`. The stack_buf slice points
8438                    // into the local frame (still valid through the
8439                    // rest of this Op::Closure handler).
8440                    let ups: &mut [Gc<crate::runtime::function::Upvalue>] = if use_inline {
8441                        // SAFETY: the first n_ups slots of stack_buf
8442                        // were initialised above; we hand out a slice
8443                        // covering exactly them.
8444                        unsafe {
8445                            std::slice::from_raw_parts_mut(
8446                                stack_buf.as_mut_ptr()
8447                                    as *mut Gc<crate::runtime::function::Upvalue>,
8448                                n_ups,
8449                            )
8450                        }
8451                    } else {
8452                        &mut heap_buf[..]
8453                    };
8454                    // PUC 5.1 had per-function environments: every Lua
8455                    // function carried its own `env` slot, snapshotted from
8456                    // the creating function's env at closure time, so a
8457                    // `setfenv` on one closure never bled into a sibling.
8458                    // luna models that by giving the 5.1 closure a *fresh*
8459                    // closed upvalue for whichever cell holds `_ENV`, seeded
8460                    // from the parent's current env value. Only that cell is
8461                    // cloned — every other upvalue keeps its open/shared
8462                    // identity (so e.g. `local function range(...) ...
8463                    // range(...) ... end` still sees its self-reference). 5.2+
8464                    // keeps the shared-upval model (and the proto cache that
8465                    // depends on it).
8466                    let v51 = self.version() <= LuaVersion::Lua51;
8467                    if v51 && proto.env_upval_idx != u8::MAX {
8468                        let i = proto.env_upval_idx as usize;
8469                        let cur = match ups[i].state() {
8470                            UpvalState::Open { slot, thread } => self.read_slot(slot, thread),
8471                            UpvalState::Closed(v) => v,
8472                        };
8473                        ups[i] = self.heap.new_upvalue(UpvalState::Closed(cur));
8474                    }
8475                    let ups_slice: &[Gc<crate::runtime::function::Upvalue>] = ups;
8476                    // PUC 5.2+ `getcached`: a Proto remembers its last LClosure
8477                    // and reuses it when every fresh-upvalue binding still
8478                    // points to the same Upvalue object as the cached one.
8479                    // That keeps `function() return outer end` repeated in a
8480                    // loop comparing equal across iterations (the captured
8481                    // outer is a shared open upvalue), while `function()
8482                    // return loop_var end` gets a fresh closure each round
8483                    // because the loop var is re-created per iteration. PUC
8484                    // 5.1 predated the cache, and the per-closure `_ENV`
8485                    // clone above would defeat it anyway, so skip it.
8486                    let nc = if v51 {
8487                        self.heap.new_closure_inline(proto, ups_slice)
8488                    } else {
8489                        let cached = proto.cache.get().filter(|c| {
8490                            c.upvals().len() == ups_slice.len()
8491                                && c.upvals()
8492                                    .iter()
8493                                    .zip(ups_slice.iter())
8494                                    .all(|(a, b)| std::ptr::eq(a.as_ptr(), b.as_ptr()))
8495                        });
8496                        match cached {
8497                            Some(c) => c,
8498                            None => {
8499                                let n = self.heap.new_closure_inline(proto, ups_slice);
8500                                proto.cache.set(Some(n));
8501                                n
8502                            }
8503                        }
8504                    };
8505                    self.set_r(base, inst.a(), Value::Closure(nc));
8506                    self.maybe_collect_garbage(base + inst.a() + 1);
8507                }
8508                Op::Vararg => {
8509                    let abs_a = base + inst.a();
8510                    let wanted = inst.c() as i32 - 1;
8511                    // A materialized named vararg lives in func_slot (its writes
8512                    // must be visible to `...`); otherwise spread the extra args
8513                    // straight off the stack at func_slot+1 .. +n_varargs.
8514                    let vt = match self.stack[func_slot as usize] {
8515                        Value::Table(t) => Some(t),
8516                        _ => None,
8517                    };
8518                    let n = match vt {
8519                        Some(t) => {
8520                            let n_key = Value::Str(self.heap.intern(b"n"));
8521                            // PUC getnumargs: a named vararg `t.n` set out of the
8522                            // integer range [0, INT_MAX/2] is rejected here
8523                            match t.get(n_key) {
8524                                Value::Int(n) if (n as u64) <= (i32::MAX as u64 / 2) => n as u32,
8525                                _ => return Err(self.rt_err("vararg table has no proper 'n'")),
8526                            }
8527                        }
8528                        None => n_varargs,
8529                    };
8530                    let count = if wanted < 0 { n } else { wanted as u32 };
8531                    let need = (abs_a + count) as usize;
8532                    if self.stack.len() < need {
8533                        self.stack.resize(need, Value::Nil);
8534                    }
8535                    for i in 0..count {
8536                        let v = if i >= n {
8537                            Value::Nil
8538                        } else if let Some(t) = vt {
8539                            t.get_int(i as i64 + 1)
8540                        } else {
8541                            self.stack[(func_slot + 1 + i) as usize]
8542                        };
8543                        self.stack[(abs_a + i) as usize] = v;
8544                    }
8545                    if wanted < 0 {
8546                        self.top = abs_a + count;
8547                    }
8548                }
8549                Op::GetVarg => {
8550                    // materialize the vararg table (PUC table.pack shape) from the
8551                    // stack varargs — used when the named vararg is written /
8552                    // escapes / is `_ENV`. It is kept BOTH in func_slot (so `...`
8553                    // sees later writes) and in the local register R[A].
8554                    let n = n_varargs;
8555                    let t = self.heap.new_table();
8556                    {
8557                        // 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).
8558                        let tm = unsafe { t.as_mut() };
8559                        for i in 0..n {
8560                            let _ = tm.set_int(
8561                                &mut self.heap,
8562                                i as i64 + 1,
8563                                self.stack[(func_slot + 1 + i) as usize],
8564                            );
8565                        }
8566                    }
8567                    let n_key = Value::Str(self.heap.intern(b"n"));
8568                    // 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).
8569                    unsafe { t.as_mut() }
8570                        .set(&mut self.heap, n_key, Value::Int(n as i64))
8571                        .expect("'n' is a valid key");
8572                    // once-per-table barrier (mirror SETLIST): t is born BLACK
8573                    // during Propagate; the bulk inserts above don't barrier.
8574                    self.heap
8575                        .barrier_back(t.as_ptr() as *mut crate::runtime::heap::GcHeader);
8576                    self.stack[func_slot as usize] = Value::Table(t);
8577                    self.set_r(base, inst.a(), Value::Table(t));
8578                }
8579                Op::VargIdx => {
8580                    // R[A] := vararg[R[C]] without allocating: integer key in
8581                    // [1,n] → that vararg, "n" → the count, else nil.
8582                    let key = self.r(base, inst.c());
8583                    let n = n_varargs;
8584                    let v = match key {
8585                        Value::Int(k) if k >= 1 && (k as u64) <= n as u64 => {
8586                            self.stack[(func_slot + k as u32) as usize]
8587                        }
8588                        Value::Float(f) if f.fract() == 0.0 && f >= 1.0 && f <= n as f64 => {
8589                            self.stack[(func_slot + f as u32) as usize]
8590                        }
8591                        Value::Str(s) if s.as_bytes() == b"n" => Value::Int(n as i64),
8592                        _ => Value::Nil,
8593                    };
8594                    self.set_r(base, inst.a(), v);
8595                }
8596                Op::ErrNNil => {
8597                    let v = self.r(base, inst.a());
8598                    if !matches!(v, Value::Nil) {
8599                        let bx = inst.bx();
8600                        let name = if bx == 0 {
8601                            "?".to_string()
8602                        } else {
8603                            match cl.proto.consts[(bx - 1) as usize] {
8604                                Value::Str(s) => String::from_utf8_lossy(s.as_bytes()).into_owned(),
8605                                _ => "?".to_string(),
8606                            }
8607                        };
8608                        return Err(self.rt_err(&format!("global '{name}' already defined")));
8609                    }
8610                }
8611                Op::ExtraArg => unreachable!("EXTRAARG executed directly"),
8612            }
8613        }
8614    }
8615
8616    #[inline(always)]
8617    fn pc_of_top(&self) -> u32 {
8618        self.top_frame().pc
8619    }
8620
8621    #[inline(always)]
8622    fn bump_pc(&mut self) {
8623        // Inline `top_frame_mut`: top is guaranteed Lua (continuation frames
8624        // drained at dispatch loop head). Avoids the and_then/lua_mut Option
8625        // layers — bump_pc fires per Jmp / cond_skip miss, so the savings add
8626        // up over `fib_28`'s ~500k jumps.
8627        // 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).
8628        match unsafe { self.frames.last_mut().unwrap_unchecked() } {
8629            CallFrame::Lua(f) => f.pc += 1,
8630            _ => unreachable!("Cont frame at bump_pc"),
8631        }
8632    }
8633
8634    #[inline(always)]
8635    fn add_pc(&mut self, d: i32) {
8636        // 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).
8637        match unsafe { self.frames.last_mut().unwrap_unchecked() } {
8638            CallFrame::Lua(f) => f.pc = (f.pc as i64 + d as i64) as u32,
8639            _ => unreachable!("Cont frame at add_pc"),
8640        }
8641    }
8642
8643    /// PUC conditional-skip convention: the JMP that follows is executed when
8644    /// `cond == k`; otherwise it is skipped.
8645    #[inline(always)]
8646    fn cond_skip(&mut self, cond: bool, k: bool) {
8647        if cond != k {
8648            self.bump_pc();
8649        }
8650    }
8651
8652    // ---- indexing (with __index/__newindex chains) ----
8653
8654    /// The `#` length operation: string byte length, `__len` if present, else
8655    /// the raw table border. Returns the raw length value (may be non-integer
8656    /// when `__len` is exotic).
8657    pub(crate) fn len_value(&mut self, v: Value) -> Result<Value, LuaError> {
8658        match self.len_step(v)? {
8659            MmOut::Done(n) => Ok(n),
8660            // PUC calls unary metamethods with the operand twice
8661            MmOut::Mm { func, recv } => self.call_mm1(func, &[recv, recv]),
8662            MmOut::CompareSynth { .. } => unreachable!("CompareSynth from len_step"),
8663        }
8664    }
8665
8666    /// Length fast path: a string's byte count or a table's raw border when no
8667    /// `__len` is present (`Done`); otherwise the `__len` metamethod (`Mm`),
8668    /// called with the operand twice. Errors for a non-table with no `__len`.
8669    fn len_step(&mut self, v: Value) -> Result<MmOut, LuaError> {
8670        match v {
8671            Value::Str(s) => Ok(MmOut::Done(Value::Int(s.len() as i64))),
8672            Value::Table(t) => {
8673                // PUC 5.1's `__len` applies to userdata only — `luaV_objlen`
8674                // there takes the raw border for a table without consulting
8675                // the metatable, so `#setmetatable({}, {__len = f})` is 0 on
8676                // 5.1 and 7 on 5.2+. Verified against stock 5.1.5 / 5.2.4.
8677                if self.version() == crate::version::LuaVersion::Lua51 {
8678                    return Ok(MmOut::Done(Value::Int(t.len())));
8679                }
8680                let mm = self.get_mm(v, Mm::Len);
8681                if mm.is_nil() {
8682                    Ok(MmOut::Done(Value::Int(t.len())))
8683                } else {
8684                    Ok(MmOut::Mm { func: mm, recv: v })
8685                }
8686            }
8687            _ => {
8688                let mm = self.get_mm(v, Mm::Len);
8689                if mm.is_nil() {
8690                    Err(self.type_err("get length of", v))
8691                } else {
8692                    Ok(MmOut::Mm { func: mm, recv: v })
8693                }
8694            }
8695        }
8696    }
8697
8698    /// PUC luaL_len: the length as an integer, erroring if `__len` returned a
8699    /// value with no integer representation.
8700    pub(crate) fn checked_len(&mut self, v: Value) -> Result<i64, LuaError> {
8701        match self.len_value(v)? {
8702            Value::Int(i) => Ok(i),
8703            Value::Float(f) => crate::runtime::value::f2i_exact(f)
8704                .ok_or_else(|| self.rt_err("object length is not an integer")),
8705            _ => Err(self.rt_err("object length is not an integer")),
8706        }
8707    }
8708
8709    pub(crate) fn index_value(&mut self, t: Value, key: Value) -> Result<Value, LuaError> {
8710        match self.index_step(t, key)? {
8711            MmOut::Done(v) => Ok(v),
8712            MmOut::Mm { func, recv } => self.call_mm1(func, &[recv, key]),
8713            MmOut::CompareSynth { .. } => unreachable!("CompareSynth from index_step"),
8714        }
8715    }
8716
8717    /// Resolve `t[key]` through the `__index` chain, stopping at the first raw
8718    /// hit (`Done`) or function metamethod (`Mm`). Table-valued `__index` links
8719    /// are followed inline (no yield possible); only a function link can yield.
8720    fn index_step(&mut self, t: Value, key: Value) -> Result<MmOut, LuaError> {
8721        let mut cur = t;
8722        for _ in 0..MAX_TAG_LOOP {
8723            let mm = match cur {
8724                Value::Table(tb) => {
8725                    let v = tb.get(key);
8726                    if !v.is_nil() {
8727                        return Ok(MmOut::Done(v));
8728                    }
8729                    let mm = self.get_mm(cur, Mm::Index);
8730                    if mm.is_nil() {
8731                        return Ok(MmOut::Done(Value::Nil));
8732                    }
8733                    mm
8734                }
8735                v => {
8736                    let mm = self.get_mm(v, Mm::Index);
8737                    if mm.is_nil() {
8738                        return Err(self.type_err("index", v));
8739                    }
8740                    mm
8741                }
8742            };
8743            match mm {
8744                Value::Closure(_) | Value::Native(_) => {
8745                    return Ok(MmOut::Mm {
8746                        func: mm,
8747                        recv: cur,
8748                    });
8749                }
8750                next => cur = next,
8751            }
8752        }
8753        Err(self.rt_err("'__index' chain too long; possible loop"))
8754    }
8755
8756    pub(crate) fn newindex_value(
8757        &mut self,
8758        t: Value,
8759        key: Value,
8760        v: Value,
8761    ) -> Result<(), LuaError> {
8762        match self.newindex_step(t, key, v)? {
8763            MmOut::Done(_) => Ok(()),
8764            MmOut::Mm { func, recv } => {
8765                self.call_value(func, &[recv, key, v])?;
8766                Ok(())
8767            }
8768            MmOut::CompareSynth { .. } => unreachable!("CompareSynth from newindex_step"),
8769        }
8770    }
8771
8772    /// Resolve `t[key] = v` through the `__newindex` chain. A raw assignment is
8773    /// performed inline (returning `Done`); only a function metamethod (`Mm`)
8774    /// needs an actual call — which the caller may run yieldably.
8775    fn newindex_step(&mut self, t: Value, key: Value, v: Value) -> Result<MmOut, LuaError> {
8776        // v2.13 WUC read-time probe (gc-verify): a dead query key at a
8777        // WRITE site, attributed to the instruction that produced it.
8778        #[cfg(feature = "gc-verify")]
8779        if let Some(p) = match key {
8780            Value::Str(s) => Some(s.as_ptr() as usize),
8781            Value::Table(t2) => Some(t2.as_ptr() as usize),
8782            _ => None,
8783        } && crate::runtime::gc_verify_probe::is_freed(p)
8784        {
8785            let detail = match self.frames.last() {
8786                Some(CallFrame::Lua(f)) => {
8787                    let pc = f.pc as usize;
8788                    let mut w = String::new();
8789                    for q in pc.saturating_sub(6)..(pc + 2) {
8790                        if let Some(inst) = f.closure.proto.code.get(q) {
8791                            w.push_str(&format!(
8792                                "\n  [{q}] {:?} a={} b={} c={} k={}",
8793                                inst.op(),
8794                                inst.a(),
8795                                inst.b(),
8796                                inst.c(),
8797                                inst.k()
8798                            ));
8799                        }
8800                    }
8801                    format!("pc={pc} base={} gc_top={} window:{w}", f.base, self.gc_top)
8802                }
8803                _ => "non-Lua frame".into(),
8804            };
8805            panic!("[gc-verify] newindex_step QUERY key {p:#x} freed. {detail}");
8806        }
8807        let mut cur = t;
8808        for _ in 0..MAX_TAG_LOOP {
8809            let mm = match cur {
8810                Value::Table(tb) => {
8811                    // PI-A3 single-walk collapse — Table::try_set_existing
8812                    // fuses the prior `tb.get(key).is_nil()` gate and
8813                    // `raw_set` walk into one chain traversal when the
8814                    // key is already present with a non-nil value. The
8815                    // __newindex chain semantics are preserved by the
8816                    // identity (slot_nil ⇔ fire_newindex); see
8817                    // .dev/rfcs/v2.0-pi-phase2-a3-audit.md §4.
8818                    //
8819                    // SAFETY: Gc<T> is NonNull<T> over the GC heap; the
8820                    // heap is single-threaded and the pointer is live as
8821                    // long as it is reachable from active roots (see
8822                    // heap.rs:5-7). Mirrors the raw_set wrapper below.
8823                    if unsafe { tb.as_mut() }.try_set_existing(key, v) {
8824                        self.heap
8825                            .barrier_back(tb.as_ptr() as *mut crate::runtime::heap::GcHeader);
8826                        return Ok(MmOut::Done(Value::Nil));
8827                    }
8828                    let mm = self.get_mm(cur, Mm::NewIndex);
8829                    if mm.is_nil() {
8830                        self.raw_set(tb, key, v)?;
8831                        return Ok(MmOut::Done(Value::Nil));
8832                    }
8833                    mm
8834                }
8835                bad => {
8836                    let mm = self.get_mm(bad, Mm::NewIndex);
8837                    if mm.is_nil() {
8838                        return Err(self.type_err("index", bad));
8839                    }
8840                    mm
8841                }
8842            };
8843            match mm {
8844                Value::Closure(_) | Value::Native(_) => {
8845                    return Ok(MmOut::Mm {
8846                        func: mm,
8847                        recv: cur,
8848                    });
8849                }
8850                next => cur = next,
8851            }
8852        }
8853        Err(self.rt_err("'__newindex' chain too long; possible loop"))
8854    }
8855
8856    fn raw_set(&mut self, t: Gc<Table>, key: Value, v: Value) -> Result<(), LuaError> {
8857        // 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).
8858        match unsafe { t.as_mut() }.set(&mut self.heap, key, v) {
8859            Ok(()) => {
8860                self.heap
8861                    .barrier_back(t.as_ptr() as *mut crate::runtime::heap::GcHeader);
8862                Ok(())
8863            }
8864            Err(TableError::NilIndex) => Err(self.rt_err("table index is nil")),
8865            Err(TableError::NanIndex) => Err(self.rt_err("table index is NaN")),
8866            Err(TableError::Overflow) => Err(self.rt_err("table overflow")),
8867            Err(TableError::InvalidNext) => unreachable!(),
8868        }
8869    }
8870
8871    /// Decide equality, or surface the `__eq` metamethod to call. `Done` carries
8872    /// the boolean result; `Mm` (when raw equality fails and both are tables
8873    /// with an `__eq`) carries the metamethod — called with `(l, r)`.
8874    fn eq_step(&mut self, l: Value, r: Value) -> MmOut {
8875        if l.raw_eq(r) {
8876            return MmOut::Done(Value::Bool(true));
8877        }
8878        if let (Value::Table(_), Value::Table(_)) | (Value::Userdata(_), Value::Userdata(_)) =
8879            (l, r)
8880        {
8881            // PUC 5.2+ accepts any `__eq` reachable from either operand; 5.1
8882            // (and earlier) required the two operands' metatables to expose a
8883            // matching `__eq` (`get_compTM`) — `c == d` where `d` has no
8884            // metatable falls straight back to raw inequality. events.lua 5.1
8885            // :262 bakes this in.
8886            let mm = if self.version() <= LuaVersion::Lua51 {
8887                self.get_comp_mm(l, r, Mm::Eq)
8888            } else {
8889                let mut m = self.get_mm(l, Mm::Eq);
8890                if m.is_nil() {
8891                    m = self.get_mm(r, Mm::Eq);
8892                }
8893                m
8894            };
8895            if !mm.is_nil() {
8896                return MmOut::Mm { func: mm, recv: l };
8897            }
8898        }
8899        MmOut::Done(Value::Bool(false))
8900    }
8901
8902    // ---- arithmetic ----
8903
8904    #[inline(always)]
8905    fn arith_rr(&mut self, inst: Inst, base: u32, op: ArithOp) -> Result<(), LuaError> {
8906        let l = self.r(base, inst.b());
8907        let r = self.r(base, inst.c());
8908        // hot path: Int + Int for Add / Sub / Mul — fib_28, loop_int_1m,
8909        // binary_trees all hammer these. Skipping coerce_num + the big
8910        // arith_fast match shaves several conditional moves per op.
8911        if let (Value::Int(a), Value::Int(b)) = (l, r) {
8912            let fast = match op {
8913                ArithOp::Add => Some(Value::Int(a.wrapping_add(b))),
8914                ArithOp::Sub => Some(Value::Int(a.wrapping_sub(b))),
8915                ArithOp::Mul => Some(Value::Int(a.wrapping_mul(b))),
8916                _ => None,
8917            };
8918            if let Some(v) = fast {
8919                self.set_r(base, inst.a(), v);
8920                return Ok(());
8921            }
8922        }
8923        // hot path: Float + Float for Add / Sub / Mul / Div — math_loop_100k
8924        // and any numeric workload with non-integer accumulators benefits.
8925        if let (Value::Float(a), Value::Float(b)) = (l, r) {
8926            let fast = match op {
8927                ArithOp::Add => Some(Value::Float(a + b)),
8928                ArithOp::Sub => Some(Value::Float(a - b)),
8929                ArithOp::Mul => Some(Value::Float(a * b)),
8930                ArithOp::Div => Some(Value::Float(a / b)),
8931                _ => None,
8932            };
8933            if let Some(v) = fast {
8934                self.set_r(base, inst.a(), v);
8935                return Ok(());
8936            }
8937        }
8938        match self.arith_fast(op, l, r)? {
8939            Some(v) => self.set_r(base, inst.a(), v),
8940            None => {
8941                let mm = self.arith_mm_func(op, l, r)?;
8942                let dst = base + inst.a();
8943                self.begin_meta_call(mm, &[l, r], MetaAction::Store { dst }, op.mm_name())?;
8944            }
8945        }
8946        Ok(())
8947    }
8948
8949    /// Fast path for an arithmetic/bitwise op: `Ok(Some(v))` when computed
8950    /// directly, `Ok(None)` when a metamethod is required (the caller decides
8951    /// whether to call it synchronously or yieldably).
8952    fn arith_fast(&mut self, op: ArithOp, l: Value, r: Value) -> Result<Option<Value>, LuaError> {
8953        use ArithOp::*;
8954        match op {
8955            BAnd | BOr | BXor | Shl | Shr => {
8956                // strings coerce for bitwise too (PUC tointegerns via cvt2num)
8957                match (coerce_num(l), coerce_num(r)) {
8958                    (Some(a), Some(b)) => {
8959                        let to_int = |n: Num| match n {
8960                            Num::Int(i) => Some(i),
8961                            Num::Float(f) => crate::runtime::value::f2i_exact(f),
8962                        };
8963                        let (Some(a), Some(b)) = (to_int(a), to_int(b)) else {
8964                            // PUC luaG_tointerror: name the offending operand
8965                            return Err(self.no_int_rep_err());
8966                        };
8967                        let v = match op {
8968                            BAnd => a & b,
8969                            BOr => a | b,
8970                            BXor => a ^ b,
8971                            Shl => shift_left(a, b),
8972                            Shr => shift_left(a, b.wrapping_neg()),
8973                            _ => unreachable!(),
8974                        };
8975                        return Ok(Some(Value::Int(v)));
8976                    }
8977                    _ => return Ok(None),
8978                }
8979            }
8980            _ => {}
8981        }
8982        let (ln, rn) = match (coerce_num(l), coerce_num(r)) {
8983            (Some(a), Some(b)) => (a, b),
8984            _ => return Ok(None),
8985        };
8986        let v = match (op, ln, rn) {
8987            (Add, Num::Int(a), Num::Int(b)) => Value::Int(a.wrapping_add(b)),
8988            (Sub, Num::Int(a), Num::Int(b)) => Value::Int(a.wrapping_sub(b)),
8989            (Mul, Num::Int(a), Num::Int(b)) => Value::Int(a.wrapping_mul(b)),
8990            (IDiv, Num::Int(a), Num::Int(b)) => {
8991                if b == 0 {
8992                    return Err(self.rt_err("attempt to divide by zero"));
8993                }
8994                let mut q = a.wrapping_div(b);
8995                if (a ^ b) < 0 && q.wrapping_mul(b) != a {
8996                    q -= 1;
8997                }
8998                Value::Int(q)
8999            }
9000            (Mod, Num::Int(a), Num::Int(b)) => {
9001                if b == 0 {
9002                    return Err(self.rt_err("attempt to perform 'n%0'"));
9003                }
9004                let mut m = a.wrapping_rem(b);
9005                if m != 0 && (m ^ b) < 0 {
9006                    m += b;
9007                }
9008                Value::Int(m)
9009            }
9010            (Add, a, b) => Value::Float(a.as_f64() + b.as_f64()),
9011            (Sub, a, b) => Value::Float(a.as_f64() - b.as_f64()),
9012            (Mul, a, b) => Value::Float(a.as_f64() * b.as_f64()),
9013            (Div, a, b) => Value::Float(a.as_f64() / b.as_f64()),
9014            (Pow, a, b) => Value::Float(a.as_f64().powf(b.as_f64())),
9015            (IDiv, a, b) => Value::Float((a.as_f64() / b.as_f64()).floor()),
9016            (Mod, a, b) => {
9017                let (x, y) = (a.as_f64(), b.as_f64());
9018                // PUC luai_nummod: correct fmod's sign without the `m*y`
9019                // product, which underflows to 0 for tiny denormals
9020                let mut m = x % y;
9021                if (m > 0.0 && y < 0.0) || (m < 0.0 && y > 0.0) {
9022                    m += y;
9023                }
9024                Value::Float(m)
9025            }
9026            _ => unreachable!(),
9027        };
9028        Ok(Some(v))
9029    }
9030
9031    pub(crate) fn int_from(&mut self, v: Value, what: &str) -> Result<i64, LuaError> {
9032        match v {
9033            Value::Int(i) => Ok(i),
9034            Value::Float(f) => match crate::runtime::value::f2i_exact(f) {
9035                Some(i) => Ok(i),
9036                None => Err(self.rt_err("number has no integer representation")),
9037            },
9038            v => Err(self.type_err(what, v)),
9039        }
9040    }
9041
9042    fn int_from_num(&mut self, n: Num) -> Result<i64, LuaError> {
9043        match n {
9044            Num::Int(i) => Ok(i),
9045            Num::Float(f) => match crate::runtime::value::f2i_exact(f) {
9046                Some(i) => Ok(i),
9047                None => Err(self.rt_err("number has no integer representation")),
9048            },
9049        }
9050    }
9051
9052    /// Find the arithmetic/bitwise metamethod (left operand first), or raise the
9053    /// PUC type error when neither operand provides one.
9054    fn arith_mm_func(&mut self, op: ArithOp, l: Value, r: Value) -> Result<Value, LuaError> {
9055        use ArithOp::*;
9056        let event = match op {
9057            Add => Mm::Add,
9058            Sub => Mm::Sub,
9059            Mul => Mm::Mul,
9060            Div => Mm::Div,
9061            Mod => Mm::Mod,
9062            Pow => Mm::Pow,
9063            IDiv => Mm::IDiv,
9064            BAnd => Mm::BAnd,
9065            BOr => Mm::BOr,
9066            BXor => Mm::BXor,
9067            Shl => Mm::Shl,
9068            Shr => Mm::Shr,
9069        };
9070        let mut mm = self.get_mm(l, event);
9071        if mm.is_nil() {
9072            mm = self.get_mm(r, event);
9073        }
9074        if mm.is_nil() {
9075            let what = if matches!(op, BAnd | BOr | BXor | Shl | Shr) {
9076                "perform bitwise operation on"
9077            } else {
9078                // 5.4+ report string-involved arithmetic faults through
9079                // lstrlib's string-metatable arithmetic handlers, which
9080                // emit the per-op wording `attempt to add a 'string'
9081                // with a 'number'` (operands in syntactic order, quoted
9082                // type names, no varinfo). Non-string faults (nil+1,
9083                // {}+{}) keep the classic VM wording on every dialect —
9084                // v2.14 HC.4, probed against stock 5.1.5-5.5.0.
9085                if self.version >= crate::version::LuaVersion::Lua54
9086                    && (matches!(l, Value::Str(_)) || matches!(r, Value::Str(_)))
9087                {
9088                    let verb = match op {
9089                        Add => "add",
9090                        Sub => "sub",
9091                        Mul => "mul",
9092                        Div => "div",
9093                        Mod => "mod",
9094                        Pow => "pow",
9095                        IDiv => "idiv",
9096                        BAnd | BOr | BXor | Shl | Shr => unreachable!(),
9097                    };
9098                    let t1 = self.obj_typename(l);
9099                    let t2 = self.obj_typename(r);
9100                    return Err(self.rt_err(&format!("attempt to {verb} a '{t1}' with a '{t2}'")));
9101                }
9102                "perform arithmetic on"
9103            };
9104            let bad = if coerce_num(l).is_none() { l } else { r };
9105            return Err(self.type_err(what, bad));
9106        }
9107        Ok(mm)
9108    }
9109
9110    // ---- comparison ----
9111
9112    pub(crate) fn less_than(&mut self, l: Value, r: Value, or_eq: bool) -> Result<bool, LuaError> {
9113        match self.less_step(l, r, or_eq)? {
9114            MmOut::Done(v) => Ok(v.truthy()),
9115            MmOut::Mm { func, .. } => Ok(self.call_mm1(func, &[l, r])?.truthy()),
9116            MmOut::CompareSynth { func } => {
9117                // ≤5.3 `__le` via `not __lt(r, l)`. Synchronous helper used
9118                // by library code (sort comparator etc.) — no yield expected
9119                // here (a yield would have hit `call_noyield`'s C boundary).
9120                Ok(!self.call_mm1(func, &[r, l])?.truthy())
9121            }
9122        }
9123    }
9124
9125    /// Decide `l < r` / `l <= r`, or surface the `__lt`/`__le` metamethod. `Done`
9126    /// carries the boolean result; `Mm` (for non-number/string operands) carries
9127    /// the metamethod — called with `(l, r)`; raises the PUC compare error when
9128    /// neither operand provides one.
9129    fn less_step(&mut self, l: Value, r: Value, or_eq: bool) -> Result<MmOut, LuaError> {
9130        let b = match (l, r) {
9131            (Value::Int(a), Value::Int(b)) => {
9132                if or_eq {
9133                    a <= b
9134                } else {
9135                    a < b
9136                }
9137            }
9138            (Value::Float(a), Value::Float(b)) => {
9139                if or_eq {
9140                    a <= b
9141                } else {
9142                    a < b
9143                }
9144            }
9145            (Value::Int(a), Value::Float(b)) => {
9146                if or_eq {
9147                    int_le_float(a, b)
9148                } else {
9149                    int_lt_float(a, b)
9150                }
9151            }
9152            (Value::Float(a), Value::Int(b)) => {
9153                if a.is_nan() {
9154                    false
9155                } else if or_eq {
9156                    !int_lt_float(b, a)
9157                } else {
9158                    !int_le_float(b, a)
9159                }
9160            }
9161            (Value::Str(a), Value::Str(b)) => {
9162                let (a, b) = (a.as_bytes(), b.as_bytes());
9163                if or_eq { a <= b } else { a < b }
9164            }
9165            (l, r) => {
9166                let event = if or_eq { Mm::Le } else { Mm::Lt };
9167                // PUC 5.1's `get_compTM` rule applies to ordered comparisons
9168                // too: both operands' metatables must expose the same
9169                // implementation for `__lt` / `__le` to fire. events.lua 5.1
9170                // :262 expects `c < d` (where `d` has no metatable) to error
9171                // with the default "attempt to compare two table values"
9172                // rather than running c's `__lt` blindly.
9173                let mm = if self.version() <= LuaVersion::Lua51 {
9174                    self.get_comp_mm(l, r, event)
9175                } else {
9176                    let mut m = self.get_mm(l, event);
9177                    if m.is_nil() {
9178                        m = self.get_mm(r, event);
9179                    }
9180                    m
9181                };
9182                // PUC ≤5.3: `a <= b` falls back to `not (b < a)` when neither
9183                // operand carries `__le`. 5.4 dropped the synthesis (now
9184                // requires an explicit `__le`). events.lua 5.2/5.3 :172 relies
9185                // on the synthesis — its metatable defines only `__lt`.
9186                // The fallback calls `__lt(r, l)` synchronously (the suite's
9187                // `__lt` doesn't yield) and negates the result; the yieldable
9188                // `__lt` path stays reserved for the explicit `<` operator.
9189                if mm.is_nil() && or_eq && self.version <= crate::version::LuaVersion::Lua53 {
9190                    let lt = Mm::Lt;
9191                    let mut mm_lt = self.get_mm(l, lt);
9192                    if mm_lt.is_nil() {
9193                        mm_lt = self.get_mm(r, lt);
9194                    }
9195                    if !mm_lt.is_nil() {
9196                        return Ok(MmOut::CompareSynth { func: mm_lt });
9197                    }
9198                }
9199                if mm.is_nil() {
9200                    // PUC luaG_ordererror: "two X values" when the operand
9201                    // types match, "X with Y" otherwise (objtypename-aware).
9202                    let (t1, t2) = (self.obj_typename(l), self.obj_typename(r));
9203                    return Err(self.rt_err(&if t1 == t2 {
9204                        format!("attempt to compare two {t1} values")
9205                    } else {
9206                        format!("attempt to compare {t1} with {t2}")
9207                    }));
9208                }
9209                return Ok(MmOut::Mm { func: mm, recv: l });
9210            }
9211        };
9212        Ok(MmOut::Done(Value::Bool(b)))
9213    }
9214
9215    // ---- numeric for ----
9216
9217    fn for_prep(&mut self, inst: Inst, base: u32) -> Result<(), LuaError> {
9218        let a = inst.a();
9219        let init = self.r(base, a);
9220        let limit = self.r(base, a + 1);
9221        let step = self.r(base, a + 2);
9222        let (Some(init_n), Some(limit_n), Some(step_n)) =
9223            (as_num(init), as_num(limit), as_num(step))
9224        else {
9225            // PUC luaG_forerror: "bad 'for' <what> (number expected, got <type>)".
9226            // PUC checks limit, then step, then initial value.
9227            let (what, bad) = if as_num(limit).is_none() {
9228                ("limit", limit)
9229            } else if as_num(step).is_none() {
9230                ("step", step)
9231            } else {
9232                ("initial value", init)
9233            };
9234            let tn = self.obj_typename(bad);
9235            return Err(self.rt_err(&format!("bad 'for' {what} (number expected, got {tn})")));
9236        };
9237        // PUC 5.1–5.3 `OP_FORPREP` stores `i = init - step` and *unconditionally*
9238        // jumps to the matching `OP_FORLOOP` — the body never runs ahead of the
9239        // first test, so each successful iteration emits a backward `OP_FORLOOP`
9240        // jump (db.lua's `for i=1,4 do a=1 end` ↦ 5 line-hook events instead of
9241        // 5.4's 4). 5.4+ collapsed that to a count-based fall-through. The skip
9242        // distance in luna's encoding is `loop_pc - prep_pc`; firing
9243        // `add_pc(bx - 1)` lands the running pc on OP_FORLOOP itself.
9244        let pre53 = self.version() <= LuaVersion::Lua53;
9245        match (init_n, step_n) {
9246            (Num::Int(i0), Num::Int(st)) => {
9247                if st == 0 {
9248                    return Err(self.rt_err("'for' step is zero"));
9249                }
9250                if pre53 {
9251                    // PUC 5.3 `forlimit`: int limit passes through; float limit
9252                    // gets clamped to MIN/MAX with a `stopnow` flag set only
9253                    // when the clamp is unreachable (positive float with a
9254                    // negative step → limit=MAX, stopnow; negative float with
9255                    // step>=0 → limit=MIN, stopnow). On `stopnow` PUC rewrites
9256                    // `init = 0` so OP_FORLOOP's first test against the
9257                    // unreachable clamp fails cleanly. An ordinary in-range
9258                    // empty loop (e.g. `for i = 1, 0`) is *not* `stopnow` — it
9259                    // lets OP_FORLOOP's natural test reject the first step.
9260                    let (lim, stopnow) = match limit_n {
9261                        Num::Int(l) => (l, false),
9262                        Num::Float(f) => {
9263                            if f.is_nan() {
9264                                (0, true)
9265                            } else if f >= i64::MAX as f64 + 1.0 {
9266                                // beyond +MAX: unreachable for a decreasing loop
9267                                (i64::MAX, st < 0)
9268                            } else if f <= i64::MIN as f64 {
9269                                // beyond -MIN: unreachable for an increasing loop
9270                                (i64::MIN, st >= 0)
9271                            } else if st > 0 {
9272                                (f.floor() as i64, false)
9273                            } else {
9274                                (f.ceil() as i64, false)
9275                            }
9276                        }
9277                    };
9278                    let initv = if stopnow { 0 } else { i0 };
9279                    let pre = initv.wrapping_sub(st);
9280                    self.set_r(base, a, Value::Int(pre));
9281                    self.set_r(base, a + 1, Value::Int(lim));
9282                    self.set_r(base, a + 2, Value::Int(st));
9283                    self.add_pc(inst.bx() as i32 - 1);
9284                    return Ok(());
9285                }
9286                let (lim, empty) = int_for_limit(limit_n, i0, st);
9287                if empty {
9288                    self.add_pc(inst.bx() as i32);
9289                    return Ok(());
9290                }
9291                let count = if st > 0 {
9292                    (lim as u64).wrapping_sub(i0 as u64) / (st as u64)
9293                } else {
9294                    (i0 as u64).wrapping_sub(lim as u64) / (st as i128).unsigned_abs() as u64
9295                };
9296                self.set_r(base, a, Value::Int(i0));
9297                self.set_r(base, a + 1, Value::Int(count as i64));
9298                self.set_r(base, a + 2, Value::Int(st));
9299                self.set_r(base, a + 3, Value::Int(i0));
9300            }
9301            _ => {
9302                let (x0, lim, st) = (init_n.as_f64(), limit_n.as_f64(), step_n.as_f64());
9303                if st == 0.0 {
9304                    return Err(self.rt_err("'for' step is zero"));
9305                }
9306                if pre53 {
9307                    let pre = x0 - st;
9308                    self.set_r(base, a, Value::Float(pre));
9309                    self.set_r(base, a + 1, Value::Float(lim));
9310                    self.set_r(base, a + 2, Value::Float(st));
9311                    self.add_pc(inst.bx() as i32 - 1);
9312                    return Ok(());
9313                }
9314                let runs = if st > 0.0 { x0 <= lim } else { x0 >= lim };
9315                if !runs {
9316                    self.add_pc(inst.bx() as i32);
9317                    return Ok(());
9318                }
9319                self.set_r(base, a, Value::Float(x0));
9320                self.set_r(base, a + 1, Value::Float(lim));
9321                self.set_r(base, a + 2, Value::Float(st));
9322                self.set_r(base, a + 3, Value::Float(x0));
9323            }
9324        }
9325        Ok(())
9326    }
9327
9328    #[inline(always)]
9329    fn for_loop(&mut self, inst: Inst, base: u32) {
9330        let a = inst.a();
9331        // PUC 5.1–5.3 `OP_FORLOOP` compares the post-step `i` to `limit`
9332        // directly (R[a+1] holds the limit, *not* a remaining-count) so the
9333        // first iteration's test fires through the same backward-jump path as
9334        // every later iteration. 5.4+ switched to the count-based form luna
9335        // already uses for `Int`; the float branch was already PUC-3.x-style.
9336        let pre53 = self.version() <= LuaVersion::Lua53;
9337        match self.r(base, a) {
9338            Value::Int(cur) if pre53 => {
9339                let Value::Int(lim) = self.r(base, a + 1) else {
9340                    unreachable!()
9341                };
9342                let Value::Int(st) = self.r(base, a + 2) else {
9343                    unreachable!()
9344                };
9345                let next = cur.wrapping_add(st);
9346                let cont = if st > 0 { next <= lim } else { next >= lim };
9347                if cont {
9348                    self.set_r(base, a, Value::Int(next));
9349                    self.set_r(base, a + 3, Value::Int(next));
9350                    self.add_pc(-(inst.bx() as i32));
9351                }
9352            }
9353            Value::Int(cur) => {
9354                let Value::Int(count) = self.r(base, a + 1) else {
9355                    unreachable!()
9356                };
9357                if count > 0 {
9358                    let Value::Int(st) = self.r(base, a + 2) else {
9359                        unreachable!()
9360                    };
9361                    let next = cur.wrapping_add(st);
9362                    self.set_r(base, a, Value::Int(next));
9363                    self.set_r(base, a + 1, Value::Int(count - 1));
9364                    self.set_r(base, a + 3, Value::Int(next));
9365                    self.add_pc(-(inst.bx() as i32));
9366                }
9367            }
9368            Value::Float(cur) => {
9369                let Value::Float(lim) = self.r(base, a + 1) else {
9370                    unreachable!()
9371                };
9372                let Value::Float(st) = self.r(base, a + 2) else {
9373                    unreachable!()
9374                };
9375                let next = cur + st;
9376                let cont = if st > 0.0 { next <= lim } else { next >= lim };
9377                if cont {
9378                    self.set_r(base, a, Value::Float(next));
9379                    self.set_r(base, a + 3, Value::Float(next));
9380                    self.add_pc(-(inst.bx() as i32));
9381                }
9382            }
9383            _ => unreachable!("corrupt for-loop state"),
9384        }
9385    }
9386
9387    // ---- native helpers (used by builtins) ----
9388
9389    /// A native function's own captured upvalue (self lives at func_slot).
9390    ///
9391    /// Public so `native_typed` trampolines and embedders authoring
9392    /// stateful natives via `native_with(...)` can read their upvals.
9393    pub fn nat_upval(&self, func_slot: u32, i: usize) -> Value {
9394        let Value::Native(nc) = self.stack[func_slot as usize] else {
9395            unreachable!("native frame without native closure");
9396        };
9397        nc.upvals[i]
9398    }
9399
9400    /// Number of upvalues captured by the native at `func_slot` (variadic
9401    /// captures such as the `io.lines` format list).
9402    pub(crate) fn nat_upcount(&self, func_slot: u32) -> usize {
9403        let Value::Native(nc) = self.stack[func_slot as usize] else {
9404            unreachable!("native frame without native closure");
9405        };
9406        nc.upvals.len()
9407    }
9408
9409    /// Write a native function's own upvalue (stateful iterators).
9410    pub(crate) fn nat_set_upval(&mut self, func_slot: u32, i: usize, v: Value) {
9411        let Value::Native(nc) = self.stack[func_slot as usize] else {
9412            unreachable!("native frame without native closure");
9413        };
9414        // 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).
9415        unsafe { nc.as_mut() }.upvals[i] = v;
9416        // NativeClosure.upvals is traced as part of its Trace; a long-lived
9417        // stateful iterator closure (e.g. string.gmatch) sees many writes —
9418        // barrier_back once-and-done is cheaper than per-child forward.
9419        self.heap
9420            .barrier_back(nc.as_ptr() as *mut crate::runtime::heap::GcHeader);
9421    }
9422
9423    /// Read the i-th positional argument inside a `NativeFn` body
9424    /// (analogous to `lua_tovalue(L, i + 1)`). `i >= nargs` yields `Nil`,
9425    /// matching PUC's "missing arg is nil" contract. Public so embedders
9426    /// can author their own natives.
9427    pub fn nat_arg(&self, func_slot: u32, nargs: u32, i: u32) -> Value {
9428        if i < nargs {
9429            self.stack[(func_slot + 1 + i) as usize]
9430        } else {
9431            Value::Nil
9432        }
9433    }
9434
9435    /// Push the return values of a `NativeFn` and return their count
9436    /// (analogous to pushing N values then `return N` from a C function).
9437    /// Public so embedders can author their own natives.
9438    pub fn nat_return(&mut self, func_slot: u32, vals: &[Value]) -> u32 {
9439        let need = func_slot as usize + vals.len();
9440        if self.stack.len() < need {
9441            self.stack.resize(need, Value::Nil);
9442        }
9443        for (i, &v) in vals.iter().enumerate() {
9444            self.stack[func_slot as usize + i] = v;
9445        }
9446        vals.len() as u32
9447    }
9448
9449    /// Fast string concatenation of an adjacent pair, or `None` when a
9450    /// `__concat` metamethod is required.
9451    fn concat_pair(&mut self, l: Value, r: Value) -> Result<Option<Value>, LuaError> {
9452        let legacy = self.float_fmt();
9453        // Length-check fast paths for both string operands BEFORE the
9454        // (expensive) copy in `concat_piece`, so a runaway `a..a..a..…`
9455        // chain (5.1 big.lua / 5.5 heavy.lua's `teststring`) raises the
9456        // overflow on the first pair that would exceed `INT_MAX` instead
9457        // of allocating multi-GB intermediates first.
9458        let max_str = i32::MAX as usize;
9459        if let (Value::Str(ls), Value::Str(rs)) = (l, r) {
9460            let a_len = ls.as_bytes().len();
9461            let b_len = rs.as_bytes().len();
9462            let new_len = a_len.checked_add(b_len);
9463            if new_len.is_none() || new_len.unwrap() > max_str {
9464                return Err(self.rt_err("string length overflow"));
9465            }
9466        }
9467        match (concat_piece(l, legacy), concat_piece(r, legacy)) {
9468            (Some(a), Some(b)) => {
9469                // PUC `MAX_SIZE` for Lua strings is `INT_MAX`; an attempt to
9470                // concat past it raises "string length overflow"
9471                // (5.5 heavy.lua `teststring` doubles `a..a..…` until it hits
9472                // exactly this wall).
9473                let new_len = a.len().checked_add(b.len());
9474                if new_len.is_none() || new_len.unwrap() > max_str {
9475                    return Err(self.rt_err("string length overflow"));
9476                }
9477                let mut combined = a;
9478                combined.extend_from_slice(&b);
9479                Ok(Some(Value::Str(self.heap.intern(&combined))))
9480            }
9481            _ => Ok(None),
9482        }
9483    }
9484
9485    /// Fold the concat operands occupying `[base_a .. self.top)` right-to-left
9486    /// into a single result at `base_a` (PUC `luaV_concat`). Returns after
9487    /// either finishing (result at `base_a`) or arming a yieldable `__concat`
9488    /// call — its `Meta` continuation re-enters here on the metamethod's return.
9489    fn concat_run(&mut self, base_a: u32) -> Result<(), LuaError> {
9490        // Sum the lengths of all all-Str operands BEFORE starting the
9491        // right-associative fold so a 129-operand `a..a..…` chain
9492        // (5.1 big.lua's `rep129(longs)`) raises overflow immediately,
9493        // not after dozens of multi-GB intermediate intern+hash rounds.
9494        // A non-Str operand falls through to the per-pair check.
9495        let max_str = i32::MAX as usize;
9496        let mut total: usize = 0;
9497        let mut all_str = true;
9498        for slot in base_a..self.top {
9499            match self.stack[slot as usize] {
9500                Value::Str(s) => match total.checked_add(s.as_bytes().len()) {
9501                    Some(t) if t <= max_str => total = t,
9502                    _ => return Err(self.rt_err("string length overflow")),
9503                },
9504                _ => {
9505                    all_str = false;
9506                    break;
9507                }
9508            }
9509        }
9510        let _ = all_str; // discrimination already captured by early returns above
9511        while self.top.saturating_sub(base_a) >= 2 {
9512            let i = self.top - 1; // rightmost operand
9513            let x = self.stack[(i - 1) as usize];
9514            let y = self.stack[i as usize];
9515            match self.concat_pair(x, y)? {
9516                Some(s) => {
9517                    self.stack[(i - 1) as usize] = s;
9518                    self.top = i; // consumed y
9519                }
9520                None => {
9521                    let mut mm = self.get_mm(x, Mm::Concat);
9522                    if mm.is_nil() {
9523                        mm = self.get_mm(y, Mm::Concat);
9524                    }
9525                    if mm.is_nil() {
9526                        let legacy = self.float_fmt();
9527                        let bad = if concat_piece(x, legacy).is_none() {
9528                            x
9529                        } else {
9530                            y
9531                        };
9532                        return Err(self.type_err("concatenate", bad));
9533                    }
9534                    // result lands at i-1, dropping y (top→i); resume continues.
9535                    let dst = i - 1;
9536                    self.begin_meta_call(
9537                        mm,
9538                        &[x, y],
9539                        MetaAction::Concat { dst, base_a },
9540                        "concat",
9541                    )?;
9542                    return Ok(());
9543                }
9544            }
9545        }
9546        self.maybe_collect_garbage(base_a + 1);
9547        Ok(())
9548    }
9549
9550    /// tostring with __tostring / __name support.
9551    pub(crate) fn tostring_value(&mut self, v: Value) -> Result<Vec<u8>, LuaError> {
9552        let mm = self.get_mm(v, Mm::ToString);
9553        if !mm.is_nil() {
9554            return match self.call_mm1(mm, &[v])? {
9555                Value::Str(s) => Ok(s.as_bytes().to_vec()),
9556                _ => Err(self.rt_err("'__tostring' must return a string")),
9557            };
9558        }
9559        if let Value::Table(t) = v
9560            && let Value::Str(name) = self.get_mm(v, Mm::Name)
9561        {
9562            let mut out = name.as_bytes().to_vec();
9563            out.extend_from_slice(format!(": {:p}", t.as_ptr()).as_bytes());
9564            return Ok(out);
9565        }
9566        Ok(self.tostring_basic(v))
9567    }
9568
9569    /// The dialect's float-rendering flavor (v2.14 HD): ≤5.2 %.14g
9570    /// bare, 5.3/5.4 %.14g + ".0", 5.5 two-stage %.15g/%.17g + ".0".
9571    pub(crate) fn float_fmt(&self) -> numeric::FloatFmt {
9572        use crate::version::LuaVersion::*;
9573        match self.version {
9574            Lua51 | Lua52 => numeric::FloatFmt::Legacy14,
9575            Lua53 | Lua54 => numeric::FloatFmt::G14,
9576            _ => numeric::FloatFmt::TwoStage55,
9577        }
9578    }
9579
9580    /// Basic tostring (no metamethods).
9581    pub(crate) fn tostring_basic(&mut self, v: Value) -> Vec<u8> {
9582        match v {
9583            Value::Nil => b"nil".to_vec(),
9584            Value::Bool(true) => b"true".to_vec(),
9585            Value::Bool(false) => b"false".to_vec(),
9586            Value::Int(i) => numeric::num_to_string(Num::Int(i)).into_bytes(),
9587            // PUC ≤5.2 has no integer subtype — `tostring(2.0)` is `"2"`, not
9588            // `"2.0"`. The 5.3+ split needs the suffix so `print(2.0)` is
9589            // distinguishable from `print(2)`. pm.lua :13 builds patterns by
9590            // concatenating these renderings.
9591            Value::Float(f) => {
9592                numeric::num_to_string_for(Num::Float(f), self.float_fmt()).into_bytes()
9593            }
9594            Value::Str(s) => s.as_bytes().to_vec(),
9595            Value::Table(t) => format!("table: {:p}", t.as_ptr()).into_bytes(),
9596            Value::Closure(c) => format!("function: {:p}", c.as_ptr()).into_bytes(),
9597            Value::Native(n) => format!("function: builtin: {:p}", n.as_ptr()).into_bytes(),
9598            Value::Coro(co) => format!("thread: {:p}", co.as_ptr()).into_bytes(),
9599            // PUC names file handles `file (0x…)`; a bare userdata is
9600            // `userdata: 0x…`. The io library overrides this via __tostring.
9601            Value::Userdata(u) => format!("userdata: {:p}", u.as_ptr()).into_bytes(),
9602            // PUC `lua_topointer`/tostring on light udata: "userdata: 0x…"
9603            // (the "light" qualifier only appears in `luaL_typeerror`).
9604            Value::LightUserdata(p) => format!("userdata: {p:p}").into_bytes(),
9605        }
9606    }
9607}
9608
9609#[derive(Clone, Copy, PartialEq, Eq)]
9610enum ArithOp {
9611    Add,
9612    Sub,
9613    Mul,
9614    Mod,
9615    Pow,
9616    Div,
9617    IDiv,
9618    BAnd,
9619    BOr,
9620    BXor,
9621    Shl,
9622    Shr,
9623}
9624
9625impl ArithOp {
9626    /// PUC metamethod event name (`__add` → "add" etc.) used by
9627    /// `debug.getinfo(level, "n")` inside a metamethod handler.
9628    fn mm_name(self) -> &'static str {
9629        match self {
9630            ArithOp::Add => "add",
9631            ArithOp::Sub => "sub",
9632            ArithOp::Mul => "mul",
9633            ArithOp::Mod => "mod",
9634            ArithOp::Pow => "pow",
9635            ArithOp::Div => "div",
9636            ArithOp::IDiv => "idiv",
9637            ArithOp::BAnd => "band",
9638            ArithOp::BOr => "bor",
9639            ArithOp::BXor => "bxor",
9640            ArithOp::Shl => "shl",
9641            ArithOp::Shr => "shr",
9642        }
9643    }
9644}
9645
9646fn as_num(v: Value) -> Option<Num> {
9647    match v {
9648        Value::Int(i) => Some(Num::Int(i)),
9649        Value::Float(f) => Some(Num::Float(f)),
9650        // PUC forprep coerces numeric strings (`for i = "10", "1", "-2"`).
9651        Value::Str(s) => crate::numeric::str2num(s.as_bytes(), true, true),
9652        _ => None,
9653    }
9654}
9655
9656/// A concatenable operand's byte form (string, or a number coerced to its
9657/// string), or `None` when only a `__concat` metamethod can handle it.
9658/// `legacy_float = true` follows PUC ≤5.2's `%.14g` rendering (no `.0`
9659/// suffix on integer-valued floats) — see `num_to_string_for`.
9660fn concat_piece(v: Value, float_fmt: numeric::FloatFmt) -> Option<Vec<u8>> {
9661    match v {
9662        Value::Str(s) => Some(s.as_bytes().to_vec()),
9663        Value::Int(x) => Some(numeric::num_to_string(Num::Int(x)).into_bytes()),
9664        Value::Float(x) => Some(numeric::num_to_string_for(Num::Float(x), float_fmt).into_bytes()),
9665        _ => None,
9666    }
9667}
9668
9669/// Index into the per-basic-type metatable table for a non-table value
9670/// (None for tables, which carry their own metatable).
9671fn type_mt_slot(v: Value) -> Option<usize> {
9672    match v {
9673        Value::Nil => Some(0),
9674        Value::Bool(_) => Some(1),
9675        Value::Int(_) | Value::Float(_) => Some(2),
9676        Value::Str(_) => Some(3),
9677        Value::Closure(_) | Value::Native(_) => Some(4),
9678        // tables and full userdata carry their own metatable; threads and
9679        // light userdata have none (PUC keeps a shared per-type mt slot for
9680        // light, but luna doesn't expose it — no test gates on it yet).
9681        Value::Table(_) | Value::Coro(_) | Value::Userdata(_) | Value::LightUserdata(_) => None,
9682    }
9683}
9684
9685/// Number, or string coerced to number (5.5 default string-arith coercion).
9686fn coerce_num(v: Value) -> Option<Num> {
9687    match v {
9688        Value::Int(i) => Some(Num::Int(i)),
9689        Value::Float(f) => Some(Num::Float(f)),
9690        Value::Str(s) => numeric::str2num(s.as_bytes(), true, true),
9691        _ => None,
9692    }
9693}
9694
9695/// Lua shifts: logical on 64 bits; |shift| ≥ 64 yields 0; negative shifts
9696/// reverse direction.
9697fn shift_left(a: i64, b: i64) -> i64 {
9698    if b < 0 {
9699        if b <= -64 {
9700            0
9701        } else {
9702            ((a as u64) >> (-b as u32)) as i64
9703        }
9704    } else if b >= 64 {
9705        0
9706    } else {
9707        ((a as u64) << (b as u32)) as i64
9708    }
9709}
9710
9711/// i < f, exactly (PUC LTintfloat shape).
9712fn int_lt_float(i: i64, f: f64) -> bool {
9713    if f.is_nan() {
9714        return false;
9715    }
9716    if f >= 9_223_372_036_854_775_808.0 {
9717        return true;
9718    }
9719    if f < -9_223_372_036_854_775_808.0 {
9720        return false;
9721    }
9722    let ff = f.floor();
9723    let fi = ff as i64;
9724    if f == ff { i < fi } else { i <= fi }
9725}
9726
9727/// i <= f, exactly.
9728fn int_le_float(i: i64, f: f64) -> bool {
9729    if f.is_nan() {
9730        return false;
9731    }
9732    if f >= 9_223_372_036_854_775_808.0 {
9733        return true;
9734    }
9735    if f < -9_223_372_036_854_775_808.0 {
9736        return false;
9737    }
9738    i <= f.floor() as i64
9739}
9740
9741/// Clip a numeric `for` limit to the integer range (PUC forlimit). Returns
9742/// (clipped limit, loop-is-empty).
9743fn int_for_limit(limit: Num, init: i64, step: i64) -> (i64, bool) {
9744    match limit {
9745        Num::Int(l) => {
9746            let empty = if step > 0 { init > l } else { init < l };
9747            (l, empty)
9748        }
9749        Num::Float(f) => {
9750            if f.is_nan() {
9751                return (0, true);
9752            }
9753            if step > 0 {
9754                if f >= 9_223_372_036_854_775_808.0 {
9755                    (i64::MAX, false)
9756                } else {
9757                    let l = f.floor();
9758                    if l < -9_223_372_036_854_775_808.0 {
9759                        (i64::MIN, true)
9760                    } else {
9761                        let li = l as i64;
9762                        (li, init > li)
9763                    }
9764                }
9765            } else if f <= -9_223_372_036_854_775_808.0 {
9766                (i64::MIN, false)
9767            } else {
9768                let l = f.ceil();
9769                if l >= 9_223_372_036_854_775_808.0 {
9770                    // PUC forlimit: a positive limit beyond the integer range
9771                    // is unreachable for a decreasing loop — empty.
9772                    (i64::MAX, true)
9773                } else {
9774                    let li = l as i64;
9775                    (li, init < li)
9776                }
9777            }
9778        }
9779    }
9780}
9781
9782/// Strip the load-prefix sigil from a chunk name for messages (PUC keeps
9783/// `@file` / `=name` markers in `source`).
9784fn chunk_display_name(p: *const crate::runtime::LuaStr) -> &'static [u8] {
9785    // 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).
9786    let b = unsafe { crate::runtime::string::bytes_of(p) };
9787    match b.first() {
9788        Some(b'@') | Some(b'=') => &b[1..],
9789        _ => b,
9790    }
9791}
9792
9793impl Vm {
9794    /// Frame introspection for debug.getinfo: `level` 1 = the Lua function
9795    /// that called the current native. Returns (closure, current line,
9796    /// extra vararg count).
9797    /// Name (and kind: local/global/field/upvalue/method/for iterator) of the
9798    /// function running at `level`, recovered from the caller's call
9799    /// instruction (PUC funcnamefromcode). None for the main chunk or a
9800    /// tail/anonymous call with no recoverable name.
9801    /// A debug-level position: either a real Lua frame (by index) or a synthetic
9802    /// C frame standing for a call_value boundary (metamethod / pcall / __close /
9803    /// coroutine body), which `debug.getinfo` and traceback report as "C".
9804    /// PUC lua_getlocal: the `n`-th (1-based) local variable active at the Lua
9805    /// frame at `level`'s current pc, as (name, value). Locals are visited in
9806    /// registration order (start pc, then register) to match luaF_getlocalname.
9807    pub(crate) fn local_at(&self, level: i64, n: i64) -> Option<(String, Value)> {
9808        if n == 0 {
9809            return None;
9810        }
9811        let fi = match self.dbg_frame(level)? {
9812            DbgKind::Lua(fi) => fi,
9813            // Tail-call placeholder has no real frame backing it — no locals
9814            // exist to read or write here. PUC `findlocal` returns NULL on
9815            // a CIST_TAIL activation.
9816            DbgKind::Tail(_) => return None,
9817            // PUC's `luaG_findlocal` on a C activation returns `(C temporary)`
9818            // for slot `n` inside the argument window (db.lua :408-:413, and
9819            // the call/return hook reads of math.sin / select args via
9820            // `getinfo("r")` + `getlocal`). Negative `n` (vararg) is not
9821            // meaningful for a C frame here.
9822            DbgKind::C(fi) => {
9823                if n < 1 {
9824                    return None;
9825                }
9826                let (func_slot, nargs) = self.c_frame_native_slots(fi)?;
9827                if (n as u32) > nargs {
9828                    return None;
9829                }
9830                let slot = (func_slot + n as u32) as usize;
9831                let val = self.stack.get(slot).copied().unwrap_or(Value::Nil);
9832                return Some((self.temporary_locvar_name().to_string(), val));
9833            }
9834        };
9835        let f = self.frames[fi].lua()?;
9836        // PUC `lua_getlocal` with a negative `n` indexes the varargs: `-1`
9837        // is the first extra arg passed to the function (`...[1]`), `-2` the
9838        // second, etc. The 5.5 stack layout parks varargs in
9839        // [func_slot + 1, base), so the i-th is at `func_slot + i`.
9840        if n < 0 {
9841            let i = (-n) as u32;
9842            if i == 0 || i > f.n_varargs {
9843                return None;
9844            }
9845            let val = self
9846                .stack
9847                .get((f.func_slot + i) as usize)
9848                .copied()
9849                .unwrap_or(Value::Nil);
9850            return Some((self.vararg_locvar_name().to_string(), val));
9851        }
9852        let proto = f.closure.proto;
9853        // PUC's parser injects a hidden `(vararg table)` locvar for an
9854        // anonymous-vararg function (lparser.c new_localvarliteral), sitting
9855        // right after the fixed parameters (`numparams + 1`). Main chunks
9856        // and `(...t)` named-vararg funcs do NOT get one — gate on the
9857        // compiler-set flag, not on `is_vararg`. luna keeps user locals in
9858        // their declared registers (no shadow slot allocated), so we expose
9859        // that hidden index purely in this debug view.
9860        let num_params = proto.num_params as i64;
9861        let vararg_slot = if proto.has_vararg_table_pseudo {
9862            Some(num_params + 1)
9863        } else {
9864            None
9865        };
9866        if vararg_slot == Some(n) {
9867            return Some(("(vararg table)".to_string(), Value::Nil));
9868        }
9869        let pc = (f.pc as usize).saturating_sub(1);
9870        let mut active: Vec<&crate::runtime::LocVar> = proto
9871            .locvars
9872            .iter()
9873            .filter(|lv| (lv.start_pc as usize) <= pc && pc < lv.end_pc as usize)
9874            .collect();
9875        active.sort_by_key(|lv| (lv.start_pc, lv.reg));
9876        let mut idx: i64 = n - 1;
9877        if let Some(vs) = vararg_slot
9878            && n > vs
9879        {
9880            idx -= 1;
9881        }
9882        let idx = idx as usize;
9883        if let Some(lv) = active.get(idx) {
9884            let val = self
9885                .stack
9886                .get((f.base + lv.reg) as usize)
9887                .copied()
9888                .unwrap_or(Value::Nil);
9889            return Some((lv.name.to_string(), val));
9890        }
9891        // PUC `luaG_findlocal` fallback: `n` is past the named locals but
9892        // still inside the frame's live register window — report a
9893        // "(temporary)" (e.g. an arithmetic intermediate). The limit is
9894        // the next frame's func slot (`ci->next->func.p`) so the
9895        // temporary window stops where the callee's frame begins
9896        // (db.lua :416/:417 distinguish a live temporary `(a+1)` from
9897        // an out-of-range slot).
9898        let limit = self
9899            .frames
9900            .get(fi + 1)
9901            .and_then(|cf| cf.lua())
9902            .map(|nf| nf.func_slot)
9903            .unwrap_or_else(|| self.top.max(f.base));
9904        let temp_reg = idx as u32;
9905        if f.base + temp_reg < limit {
9906            let val = self
9907                .stack
9908                .get((f.base + temp_reg) as usize)
9909                .copied()
9910                .unwrap_or(Value::Nil);
9911            return Some((self.lua_temporary_locvar_name().to_string(), val));
9912        }
9913        None
9914    }
9915
9916    /// `debug.setlocal`'s underlying write (PUC `lua_setlocal`). Returns
9917    /// the local / vararg name on success, `None` when the slot does not
9918    /// resolve. Mirrors `local_at`'s indexing exactly.
9919    pub(crate) fn local_set(&mut self, level: i64, n: i64, v: Value) -> Option<String> {
9920        if n == 0 {
9921            return None;
9922        }
9923        let DbgKind::Lua(fi) = self.dbg_frame(level)? else {
9924            return None;
9925        };
9926        let f = self.frames[fi].lua()?;
9927        if n < 0 {
9928            let i = (-n) as u32;
9929            if i == 0 || i > f.n_varargs {
9930                return None;
9931            }
9932            let slot = (f.func_slot + i) as usize;
9933            if let Some(s) = self.stack.get_mut(slot) {
9934                *s = v;
9935            }
9936            return Some(self.vararg_locvar_name().to_string());
9937        }
9938        let proto = f.closure.proto;
9939        let num_params = proto.num_params as i64;
9940        let vararg_slot = if proto.has_vararg_table_pseudo {
9941            Some(num_params + 1)
9942        } else {
9943            None
9944        };
9945        if vararg_slot == Some(n) {
9946            // hidden (vararg table) slot has no real storage — accept the
9947            // write as a no-op for PUC parity (db.lua doesn't write to it).
9948            return Some("(vararg table)".to_string());
9949        }
9950        let pc = (f.pc as usize).saturating_sub(1);
9951        let mut active: Vec<&crate::runtime::LocVar> = proto
9952            .locvars
9953            .iter()
9954            .filter(|lv| (lv.start_pc as usize) <= pc && pc < lv.end_pc as usize)
9955            .collect();
9956        active.sort_by_key(|lv| (lv.start_pc, lv.reg));
9957        let mut idx: i64 = n - 1;
9958        if let Some(vs) = vararg_slot
9959            && n > vs
9960        {
9961            idx -= 1;
9962        }
9963        let idx = idx as usize;
9964        let (name, reg) = if let Some(lv) = active.get(idx) {
9965            (lv.name.to_string(), lv.reg)
9966        } else {
9967            // PUC `luaG_findlocal` fallback into the temporary window —
9968            // bounded by the next frame's func slot (see local_at).
9969            let limit = self
9970                .frames
9971                .get(fi + 1)
9972                .and_then(|cf| cf.lua())
9973                .map(|nf| nf.func_slot)
9974                .unwrap_or_else(|| self.top.max(f.base));
9975            let temp_reg = idx as u32;
9976            if f.base + temp_reg >= limit {
9977                return None;
9978            }
9979            (self.lua_temporary_locvar_name().to_string(), temp_reg)
9980        };
9981        let slot = (f.base + reg) as usize;
9982        if let Some(s) = self.stack.get_mut(slot) {
9983            *s = v;
9984        }
9985        Some(name)
9986    }
9987
9988    /// `debug.getlocal(thread, level, n)`: read frame `level` of the suspended
9989    /// coroutine `co`. Walks `co.frames` (the saved Lua activation stack) and
9990    /// reads from `co.stack`. Returns `None` for out-of-range, for negative
9991    /// vararg indexing past `n_varargs`, or for a register past the live
9992    /// window. Naming follows the same priority as `local_at`: named locals,
9993    /// then `(vararg)` for negative `n`, then `(vararg table)` for the
9994    /// explicit-`(...)` pseudo, else `(temporary)` in the live register
9995    /// window.
9996    pub(crate) fn local_at_coro(
9997        &self,
9998        co: Gc<crate::runtime::Coro>,
9999        level: i64,
10000        n: i64,
10001    ) -> Option<(String, Value)> {
10002        if level < 1 || n == 0 {
10003            return None;
10004        }
10005        let frames = &co.frames;
10006        // Logical level: iterate Lua frames from the top.
10007        let lua_indices: Vec<usize> = (0..frames.len())
10008            .rev()
10009            .filter(|&i| frames[i].lua().is_some())
10010            .collect();
10011        let fi = *lua_indices.get((level - 1) as usize)?;
10012        let f = frames[fi].lua()?;
10013        if n < 0 {
10014            let i = (-n) as u32;
10015            if i == 0 || i > f.n_varargs {
10016                return None;
10017            }
10018            let val = co
10019                .stack
10020                .get((f.func_slot + i) as usize)
10021                .copied()
10022                .unwrap_or(Value::Nil);
10023            return Some((self.vararg_locvar_name().to_string(), val));
10024        }
10025        let proto = f.closure.proto;
10026        let num_params = proto.num_params as i64;
10027        let vararg_slot = if proto.has_vararg_table_pseudo {
10028            Some(num_params + 1)
10029        } else {
10030            None
10031        };
10032        if vararg_slot == Some(n) {
10033            return Some(("(vararg table)".to_string(), Value::Nil));
10034        }
10035        let pc = (f.pc as usize).saturating_sub(1);
10036        let mut active: Vec<&crate::runtime::LocVar> = proto
10037            .locvars
10038            .iter()
10039            .filter(|lv| (lv.start_pc as usize) <= pc && pc < lv.end_pc as usize)
10040            .collect();
10041        active.sort_by_key(|lv| (lv.start_pc, lv.reg));
10042        let mut idx: i64 = n - 1;
10043        if let Some(vs) = vararg_slot
10044            && n > vs
10045        {
10046            idx -= 1;
10047        }
10048        let idx = idx as usize;
10049        if let Some(lv) = active.get(idx) {
10050            let val = co
10051                .stack
10052                .get((f.base + lv.reg) as usize)
10053                .copied()
10054                .unwrap_or(Value::Nil);
10055            return Some((lv.name.to_string(), val));
10056        }
10057        let limit = frames
10058            .get(fi + 1)
10059            .and_then(|cf| cf.lua())
10060            .map(|nf| nf.func_slot)
10061            .unwrap_or(co.top.max(f.base));
10062        let temp_reg = idx as u32;
10063        if f.base + temp_reg < limit {
10064            let val = co
10065                .stack
10066                .get((f.base + temp_reg) as usize)
10067                .copied()
10068                .unwrap_or(Value::Nil);
10069            return Some((self.lua_temporary_locvar_name().to_string(), val));
10070        }
10071        None
10072    }
10073
10074    /// `debug.setlocal(thread, level, n, value)`: write into frame `level` of
10075    /// suspended `co`. Mirrors `local_at_coro`'s indexing exactly.
10076    pub(crate) fn local_set_coro(
10077        &mut self,
10078        co: Gc<crate::runtime::Coro>,
10079        level: i64,
10080        n: i64,
10081        v: Value,
10082    ) -> Option<String> {
10083        if level < 1 || n == 0 {
10084            return None;
10085        }
10086        let lua_indices: Vec<usize> = (0..co.frames.len())
10087            .rev()
10088            .filter(|&i| co.frames[i].lua().is_some())
10089            .collect();
10090        let fi = *lua_indices.get((level - 1) as usize)?;
10091        let (func_slot, n_varargs, base, proto, top_for_temp, next_func_slot) = {
10092            let f = co.frames[fi].lua()?;
10093            (
10094                f.func_slot,
10095                f.n_varargs,
10096                f.base,
10097                f.closure.proto,
10098                co.top.max(f.base),
10099                co.frames
10100                    .get(fi + 1)
10101                    .and_then(|cf| cf.lua())
10102                    .map(|nf| nf.func_slot),
10103            )
10104        };
10105        if n < 0 {
10106            let i = (-n) as u32;
10107            if i == 0 || i > n_varargs {
10108                return None;
10109            }
10110            let slot = (func_slot + i) as usize;
10111            // 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).
10112            let stack = unsafe { &mut co.as_mut().stack };
10113            if let Some(s) = stack.get_mut(slot) {
10114                *s = v;
10115            }
10116            // co.stack values are traced — once-per-call barrier so propagate
10117            // sees the new value if co was already BLACK this cycle.
10118            self.heap
10119                .barrier_back(co.as_ptr() as *mut crate::runtime::heap::GcHeader);
10120            return Some(self.vararg_locvar_name().to_string());
10121        }
10122        let num_params = proto.num_params as i64;
10123        let vararg_slot = if proto.has_vararg_table_pseudo {
10124            Some(num_params + 1)
10125        } else {
10126            None
10127        };
10128        if vararg_slot == Some(n) {
10129            return Some("(vararg table)".to_string());
10130        }
10131        let pc = (co.frames[fi].lua().unwrap().pc as usize).saturating_sub(1);
10132        let mut active: Vec<&crate::runtime::LocVar> = proto
10133            .locvars
10134            .iter()
10135            .filter(|lv| (lv.start_pc as usize) <= pc && pc < lv.end_pc as usize)
10136            .collect();
10137        active.sort_by_key(|lv| (lv.start_pc, lv.reg));
10138        let mut idx: i64 = n - 1;
10139        if let Some(vs) = vararg_slot
10140            && n > vs
10141        {
10142            idx -= 1;
10143        }
10144        let idx = idx as usize;
10145        let (name, reg) = if let Some(lv) = active.get(idx) {
10146            (lv.name.to_string(), lv.reg)
10147        } else {
10148            let limit = next_func_slot.unwrap_or(top_for_temp);
10149            let temp_reg = idx as u32;
10150            if base + temp_reg >= limit {
10151                return None;
10152            }
10153            (self.lua_temporary_locvar_name().to_string(), temp_reg)
10154        };
10155        let slot = (base + reg) as usize;
10156        // 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).
10157        let stack = unsafe { &mut co.as_mut().stack };
10158        if let Some(s) = stack.get_mut(slot) {
10159            *s = v;
10160        }
10161        // co.stack values are traced — once-per-call barrier so propagate
10162        // sees the new value if co was already BLACK this cycle.
10163        self.heap
10164            .barrier_back(co.as_ptr() as *mut crate::runtime::heap::GcHeader);
10165        Some(name)
10166    }
10167
10168    /// Frame info for a level on a suspended coroutine (PUC
10169    /// `lua_getinfo(L1, "Sl...", &ar)` after `lua_getstack(L1, level, &ar)`).
10170    /// Returns the closure + currentline + extraargs + istailcall for the
10171    /// level-th Lua activation in `co.frames`. None if level overshoots.
10172    pub(crate) fn coro_frame_info(
10173        &self,
10174        co: Gc<crate::runtime::Coro>,
10175        level: i64,
10176    ) -> Option<(Gc<LuaClosure>, u32, i64, bool)> {
10177        if level < 1 {
10178            return None;
10179        }
10180        let lua_indices: Vec<usize> = (0..co.frames.len())
10181            .rev()
10182            .filter(|&i| co.frames[i].lua().is_some())
10183            .collect();
10184        let fi = *lua_indices.get((level - 1) as usize)?;
10185        let f = co.frames[fi].lua()?;
10186        let proto = f.closure.proto;
10187        let pc = (f.pc as usize)
10188            .saturating_sub(1)
10189            .min(proto.lines.len().saturating_sub(1));
10190        let line = proto.lines.get(pc).copied().unwrap_or(0);
10191        Some((f.closure, line, f.n_varargs as i64, f.tailcalls > 0))
10192    }
10193
10194    /// Whether `level` resolves to any live activation (PUC lua_getstack).
10195    pub(crate) fn level_in_range(&self, level: i64) -> bool {
10196        self.dbg_frame(level).is_some()
10197    }
10198
10199    /// PUC's debug-API placeholder for an unnamed vararg slot returned by
10200    /// `debug.getlocal(_, -n)`. 5.2/5.3 spelled it `"(*vararg)"`; 5.4
10201    /// dropped the asterisk in favour of `"(vararg)"`. db.lua 5.2 :189 /
10202    /// 5.3 :195 / 5.4 :286 baseline on their respective form.
10203    pub(crate) fn vararg_locvar_name(&self) -> &'static str {
10204        if matches!(self.version, LuaVersion::Lua52 | LuaVersion::Lua53) {
10205            "(*vararg)"
10206        } else {
10207            "(vararg)"
10208        }
10209    }
10210
10211    /// PUC's debug-API placeholder for an unnamed temporary on a C
10212    /// activation. 5.2/5.3 reported `"(*temporary)"`; 5.4 switched to
10213    /// `"(C temporary)"`. db.lua 5.2 :288, 5.3 :312, 5.4 :404 each pin
10214    /// their spelling.
10215    pub(crate) fn temporary_locvar_name(&self) -> &'static str {
10216        if matches!(
10217            self.version,
10218            LuaVersion::Lua51 | LuaVersion::Lua52 | LuaVersion::Lua53
10219        ) {
10220            // PUC 5.1's `findlocal` C-frame branch reported `(*temporary)`
10221            // (db.lua :228 pins it). 5.2/5.3 kept the spelling, 5.4 changed
10222            // to `(C temporary)`.
10223            "(*temporary)"
10224        } else {
10225            "(C temporary)"
10226        }
10227    }
10228
10229    /// PUC's debug-API placeholder for an unnamed Lua-frame temporary
10230    /// (an arithmetic intermediate sitting past the last named local on a
10231    /// live register slot). 5.2/5.3 reported `"(*temporary)"`; 5.4 dropped
10232    /// the asterisk to `"(temporary)"`. db.lua 5.3 :786, 5.4 :966 pin the
10233    /// spelling.
10234    pub(crate) fn lua_temporary_locvar_name(&self) -> &'static str {
10235        if matches!(
10236            self.version,
10237            LuaVersion::Lua51 | LuaVersion::Lua52 | LuaVersion::Lua53
10238        ) {
10239            "(*temporary)"
10240        } else {
10241            "(temporary)"
10242        }
10243    }
10244
10245    /// The Lua closure running at `level` on the current thread, or `None`
10246    /// when the frame is a synthetic C boundary. PUC 5.1 `getfenv`/`setfenv`
10247    /// need this to reach the function whose env they read or rewrite.
10248    pub(crate) fn lua_closure_at_level(&self, level: i64) -> Option<Gc<LuaClosure>> {
10249        // `DbgKind::Tail` also falls into the else branch — a tail-call
10250        // placeholder has no closure of its own, so PUC's `lua_getstack` +
10251        // `getfunc` for that level returns no function, and `getfenv(level)`
10252        // / `setfenv(level)` raise an error (5.1 db.lua :336/:341).
10253        let DbgKind::Lua(fi) = self.dbg_frame(level)? else {
10254            return None;
10255        };
10256        Some(self.frames[fi].lua()?.closure)
10257    }
10258
10259    pub(crate) fn coro_level_in_range(&self, co: Gc<crate::runtime::Coro>, level: i64) -> bool {
10260        if level < 1 {
10261            return false;
10262        }
10263        let count = co.frames.iter().filter(|cf| cf.lua().is_some()).count();
10264        (level as usize) <= count
10265    }
10266
10267    pub(crate) fn dbg_frame(&self, level: i64) -> Option<DbgKind> {
10268        if level < 1 {
10269            return None;
10270        }
10271        // PUC 5.1's `lua_getstack` walks the full `ci` chain — each C
10272        // activation counts as a level, and each Lua activation's
10273        // `tailcalls` adds an extra synthetic level (CIST_TAIL). 5.2+
10274        // dropped the synthetic shape: `istailcall` becomes a flag on the
10275        // real frame and Cont activations no longer count separately.
10276        // 5.1 db.lua :336-:343 pin the 5.1 shape; 5.2/5.3/5.5 db.lua's
10277        // `getinfo(2).func == g1` pins the 5.2+ shape.
10278        let v51 = self.version <= LuaVersion::Lua51;
10279        let mut lvl = level;
10280        for fi in (0..self.frames.len()).rev() {
10281            match &self.frames[fi] {
10282                CallFrame::Lua(f) => {
10283                    lvl -= 1;
10284                    if lvl == 0 {
10285                        return Some(DbgKind::Lua(fi));
10286                    }
10287                    if v51 {
10288                        // 5.1 reports one synthetic CIST_TAIL level per
10289                        // collapsed tail call (PUC `lua_getstack` subtracts
10290                        // `ci->u.l.tailcalls` from the remaining level).
10291                        for _ in 0..f.tailcalls {
10292                            lvl -= 1;
10293                            if lvl == 0 {
10294                                return Some(DbgKind::Tail(fi));
10295                            }
10296                        }
10297                    }
10298                    if f.from_c {
10299                        lvl -= 1;
10300                        if lvl == 0 {
10301                            return Some(DbgKind::C(fi));
10302                        }
10303                    }
10304                }
10305                CallFrame::Cont(_) => {
10306                    if !v51 {
10307                        continue;
10308                    }
10309                    lvl -= 1;
10310                    if lvl == 0 {
10311                        let parent = (0..fi)
10312                            .rev()
10313                            .find(|&j| matches!(self.frames[j], CallFrame::Lua(_)));
10314                        return Some(DbgKind::C(parent.unwrap_or(fi.saturating_sub(1))));
10315                    }
10316                }
10317            }
10318        }
10319        None
10320    }
10321
10322    pub(crate) fn frame_name(&self, fi: usize) -> Option<(&'static str, String)> {
10323        let f = self.frames[fi].lua()?;
10324        // metamethod handler frames carry the event tag (e.g. "close" for
10325        // `__close`); PUC `funcnamefromcall` reads `ci->u.l.tm`.
10326        if f.is_hook {
10327            return Some(("hook", "?".to_string()));
10328        }
10329        if let Some(tm) = f.tm {
10330            return Some(("metamethod", tm_debug_name(self.version, tm)));
10331        }
10332        // a frame entered across a C boundary has no naming call instruction
10333        if fi == 0 || f.from_c {
10334            return None;
10335        }
10336        // the caller's call instruction names this frame; a continuation frame
10337        // just below (pcall/xpcall) is itself a C boundary, so f.from_c above
10338        // already short-circuits those.
10339        let caller = self.frames[fi - 1].lua()?;
10340        let caller_proto = caller.closure.proto;
10341        let p: &crate::runtime::Proto = &caller_proto;
10342        let call_pc = (caller.pc as usize).checked_sub(1)?;
10343        let instr = *p.code.get(call_pc)?;
10344        match instr.op() {
10345            Op::Call | Op::TailCall => crate::vm::objname::getobjname(p, call_pc, instr.a()),
10346            Op::TForCall => Some(("for iterator", "for iterator".to_string())),
10347            _ => None,
10348        }
10349    }
10350
10351    /// Name the synthetic C level sitting below the `from_c` Lua frame at `fi`
10352    /// (PUC names a C function from the call instruction that invoked it). The
10353    /// native was called by the nearest Lua frame below `fi` (skipping pcall/
10354    /// xpcall continuations); that frame's call instruction names it.
10355    pub(crate) fn c_frame_name(&self, fi: usize) -> Option<(&'static str, String)> {
10356        // PUC `GCTM` sets `CIST_FIN` on the calling ci, so when getinfo names
10357        // the synthetic C edge between the __gc finalizer (top Lua frame, has
10358        // `tm = "gc"`) and its triggering Lua frame it reports "metamethod"
10359        // "__gc" — 5.3 db.lua :720's `getinfo(2).namewhat == "metamethod"`
10360        // pin. Restricted to the `__gc` event: `__close` (`tm = "close"`)
10361        // sets the tag on the handler frame only, so level 2 there still
10362        // names the calling Lua frame's call instruction (5.5 locals.lua
10363        // :514 pins `getinfo(2).name == "pcall"` from a __close handler).
10364        if let Some(fr) = self.frames.get(fi).and_then(|cf| cf.lua())
10365            && fr.tm == Some("gc")
10366        {
10367            let name = tm_debug_name(self.version, "gc");
10368            return Some(("metamethod", name));
10369        }
10370        let caller_fi = (0..fi).rev().find(|&i| self.frames[i].lua().is_some())?;
10371        let caller = self.frames[caller_fi].lua()?;
10372        let p = &caller.closure.proto;
10373        let call_pc = (caller.pc as usize).checked_sub(1)?;
10374        let instr = *p.code.get(call_pc)?;
10375        match instr.op() {
10376            Op::Call | Op::TailCall => crate::vm::objname::getobjname(p, call_pc, instr.a()),
10377            _ => None,
10378        }
10379    }
10380
10381    /// Native value currently sitting on the synthetic C edge identified by
10382    /// `DbgKind::C(fi)`. The walk counts how many `from_c` Lua frames live
10383    /// above `fi` (each one corresponds to one native pushing the hook) and
10384    /// indexes into `running_natives` from the top, also skipping the caller
10385    /// of `getinfo` itself (the native that is currently asking).
10386    /// db.lua :344 reads `debug.getinfo(2, "f").func` from a call hook and
10387    /// expects the just-entered C function.
10388    pub(crate) fn c_frame_func(&self, fi: usize) -> Option<Value> {
10389        let idx = self.c_frame_native_idx(fi)?;
10390        Some(Value::Native(self.running_natives[idx]))
10391    }
10392
10393    /// `(func_slot, nargs)` for the synthetic C edge identified by `C(fi)`,
10394    /// so `local_at` can index the native's argument window like PUC's
10395    /// `(C temporary)` path. Returns `None` when no matching native exists
10396    /// (e.g. the C edge corresponds to a non-native boundary).
10397    pub(crate) fn c_frame_native_slots(&self, fi: usize) -> Option<(u32, u32)> {
10398        let idx = self.c_frame_native_idx(fi)?;
10399        self.running_native_slots.get(idx).copied()
10400    }
10401
10402    fn c_frame_native_idx(&self, fi: usize) -> Option<usize> {
10403        let n_above = self.frames[fi..]
10404            .iter()
10405            .filter_map(CallFrame::lua)
10406            .filter(|f| f.from_c)
10407            .count();
10408        if n_above == 0 {
10409            return None;
10410        }
10411        // running_natives.last() is the native currently executing (the one
10412        // that called getinfo). Pop it conceptually, then take the n_above-th
10413        // entry from the top of what remains.
10414        let nr = self.running_natives.len().checked_sub(1)?;
10415        nr.checked_sub(n_above)
10416    }
10417
10418    /// PUC `pushglobalfuncname`: walk `package.loaded` to depth 2 looking for a
10419    /// native whose function pointer matches `target`, and return its qualified
10420    /// name (e.g. `"table.sort"`). A `_G.X` match is stripped to `"X"`. Returns
10421    /// `None` if no match is found. Used by `arg_error` when the running native
10422    /// was invoked from another native (PUC `ar.name == NULL` at level 0).
10423    /// True when the innermost call frame is a pcall/xpcall
10424    /// continuation — i.e. the currently-running native was invoked
10425    /// DIRECTLY by pcall/xpcall rather than by Lua code. PUC's
10426    /// luaL_argerror sees ar.name == NULL there (the caller is C)
10427    /// and qualifies the name via pushglobalfuncname — so
10428    /// `pcall(coroutine.resume, 42)` blames 'coroutine.resume'
10429    /// (v2.14 fixture 5.5/365).
10430    pub(crate) fn caller_is_protected_cont(&self) -> bool {
10431        matches!(
10432            self.frames.last(),
10433            Some(CallFrame::Cont(nc))
10434                if matches!(nc.kind, ContKind::Pcall | ContKind::Xpcall { .. })
10435        )
10436    }
10437
10438    pub(crate) fn pushglobalfuncname(
10439        &mut self,
10440        target: crate::runtime::value::NativeFn,
10441    ) -> Option<String> {
10442        let pkg_k = Value::Str(self.heap.intern(b"package"));
10443        let pkg = match self.globals().get(pkg_k) {
10444            Value::Table(t) => t,
10445            _ => return None,
10446        };
10447        let loaded_k = Value::Str(self.heap.intern(b"loaded"));
10448        let loaded = match pkg.get(loaded_k) {
10449            Value::Table(t) => t,
10450            _ => return None,
10451        };
10452        let matches = |v: Value| -> bool {
10453            matches!(v, Value::Native(nc) if std::ptr::fn_addr_eq(nc.f, target))
10454        };
10455        let mut k = Value::Nil;
10456        while let Ok(Some((nk, nv))) = loaded.next(k) {
10457            k = nk;
10458            let Value::Str(outer) = nk else { continue };
10459            let outer = String::from_utf8_lossy(outer.as_bytes()).into_owned();
10460            if matches(nv) {
10461                return Some(if outer == "_G" { String::new() } else { outer });
10462            }
10463            if let Value::Table(inner_t) = nv {
10464                let mut k2 = Value::Nil;
10465                while let Ok(Some((nk2, nv2))) = inner_t.next(k2) {
10466                    k2 = nk2;
10467                    if matches(nv2)
10468                        && let Value::Str(inner) = nk2
10469                    {
10470                        let inner = String::from_utf8_lossy(inner.as_bytes()).into_owned();
10471                        return Some(if outer == "_G" {
10472                            inner
10473                        } else {
10474                            format!("{outer}.{inner}")
10475                        });
10476                    }
10477                }
10478            }
10479        }
10480        None
10481    }
10482
10483    /// Name and namewhat of the native currently running on behalf of the top
10484    /// Lua frame's call instruction (PUC `lua_getinfo("n")` at level 0). Lets
10485    /// `luaL_argerror` rewrite a method call's self-argument error.
10486    pub(crate) fn running_call_name(&self) -> Option<(&'static str, String)> {
10487        let caller = self.frames.iter().rev().find_map(CallFrame::lua)?;
10488        let p = &caller.closure.proto;
10489        let call_pc = (caller.pc as usize).checked_sub(1)?;
10490        let instr = *p.code.get(call_pc)?;
10491        match instr.op() {
10492            Op::Call | Op::TailCall => crate::vm::objname::getobjname(p, call_pc, instr.a()),
10493            _ => None,
10494        }
10495    }
10496
10497    pub(crate) fn frame_info(&mut self, fi: usize) -> (Gc<LuaClosure>, u32, i64, bool) {
10498        let f = self.frames[fi].lua().expect("Lua frame");
10499        let proto = f.closure.proto;
10500        let pc = (f.pc as usize)
10501            .saturating_sub(1)
10502            .min(proto.lines.len().saturating_sub(1));
10503        let line = proto.lines.get(pc).copied().unwrap_or(0);
10504        // PUC CallInfo.nextraargs: the original extra-arg count, fixed at call
10505        // (independent of any later write to a materialized vararg table's `n`).
10506        // `istailcall` mirrors PUC `CIST_TAIL` for `debug.getinfo(_, "t")` —
10507        // any nonzero `tailcalls` count flips it true.
10508        (f.closure, line, f.n_varargs as i64, f.tailcalls > 0)
10509    }
10510
10511    /// Read an upvalue cell of a closure (debug.getupvalue).
10512    pub(crate) fn upvalue_value(&self, cl: Gc<LuaClosure>, idx: usize) -> Value {
10513        match cl.upvals()[idx].state() {
10514            UpvalState::Open { slot, thread } => self.read_slot(slot, thread),
10515            UpvalState::Closed(v) => v,
10516        }
10517    }
10518
10519    /// Write an upvalue cell of a closure (debug.setupvalue).
10520    pub(crate) fn upvalue_set_value(&mut self, cl: Gc<LuaClosure>, idx: usize, v: Value) {
10521        let uv = cl.upvals()[idx];
10522        match uv.state() {
10523            UpvalState::Open { slot, thread } => self.write_slot(slot, thread, v),
10524            UpvalState::Closed(_) => {
10525                // 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).
10526                unsafe { uv.as_mut() }.set_closed(v);
10527                self.heap
10528                    .barrier_forward(uv.as_ptr() as *mut crate::runtime::heap::GcHeader, v);
10529            }
10530        }
10531    }
10532
10533    /// Lines for debug.traceback (PUC `luaL_traceback` / `pushfuncname`).
10534    /// Per Lua frame, emits `"\n\t<src>:<line>: in <funcname>"` where
10535    /// `<funcname>` is, in priority order: `"metamethod 'event'"` if the frame
10536    /// is a metamethod handler (e.g. `__close`); else `"<namewhat> '<name>'"`
10537    /// from the caller's call instruction (`getobjname`); else `"main chunk"`;
10538    /// else `"function <src:line_defined>"` for an anonymous Lua function.
10539    /// Traceback of a suspended coroutine (PUC `debug.traceback(L1, msg, lvl)`).
10540    /// Walks the coroutine's saved frames and prepends a synthetic C-level
10541    /// `'yield'` entry when the coroutine paused at a `coroutine.yield` call
10542    /// (its `resume_at` marker is set). `level` skips entries from the top
10543    /// (level 0 includes the yield frame; level 1 starts at the deepest Lua
10544    /// frame; etc.). db.lua :764-:768 sample several levels.
10545    pub(crate) fn coro_traceback(&self, co: Gc<crate::runtime::Coro>, mut level: i64) -> Vec<u8> {
10546        use crate::runtime::CoroStatus;
10547        const LEVELS1: usize = 10;
10548        const LEVELS2: usize = 11;
10549        #[derive(Clone, Copy)]
10550        enum VFrame<'a> {
10551            Lua(&'a crate::runtime::function::Frame),
10552            CPcall,
10553            CXpcall,
10554            CYield,
10555            /// Synthetic CIST_TAIL placeholder under 5.1 — one per tail
10556            /// call collapsed into the next Lua frame down the chain.
10557            Tail,
10558        }
10559        let v51 = self.version <= LuaVersion::Lua51;
10560        let mut visible: Vec<VFrame<'_>> = Vec::new();
10561        // PUC's level 0 entry on a suspended coroutine is the C call where it
10562        // paused — `coroutine.yield` for a yielded thread.
10563        if matches!(co.status, CoroStatus::Suspended) && co.resume_at.is_some() {
10564            visible.push(VFrame::CYield);
10565        }
10566        for cf in co.frames.iter().rev() {
10567            match cf {
10568                CallFrame::Lua(f) => {
10569                    visible.push(VFrame::Lua(f));
10570                    if v51 {
10571                        for _ in 0..f.tailcalls {
10572                            visible.push(VFrame::Tail);
10573                        }
10574                    }
10575                }
10576                CallFrame::Cont(nc) => match nc.kind {
10577                    ContKind::Pcall => visible.push(VFrame::CPcall),
10578                    ContKind::Xpcall { .. } => visible.push(VFrame::CXpcall),
10579                    _ => {}
10580                },
10581            }
10582        }
10583        if level < 0 {
10584            level = 0;
10585        }
10586        if (level as usize) >= visible.len() {
10587            return Vec::new();
10588        }
10589        let visible = &visible[level as usize..];
10590        let total = visible.len();
10591        let mut out = Vec::new();
10592        // To name a Lua frame, PUC consults the caller's OP_CALL via
10593        // getobjname: find the index `fi` of the current frame in co.frames,
10594        // then look at frames[fi-1] (the caller) and read its `code[pc-1]`.
10595        let coro_frame_name = |frames: &[CallFrame],
10596                               target: &crate::runtime::function::Frame|
10597         -> Option<(&'static str, String)> {
10598            let fi = frames
10599                .iter()
10600                .position(|cf| matches!(cf, CallFrame::Lua(f) if std::ptr::eq(f, target)))?;
10601            if fi == 0 || target.from_c {
10602                return None;
10603            }
10604            let caller = frames[fi - 1].lua()?;
10605            let p = &caller.closure.proto;
10606            let call_pc = (caller.pc as usize).checked_sub(1)?;
10607            let instr = *p.code.get(call_pc)?;
10608            match instr.op() {
10609                Op::Call | Op::TailCall => crate::vm::objname::getobjname(p, call_pc, instr.a()),
10610                Op::TForCall => Some(("for iterator", "for iterator".to_string())),
10611                _ => None,
10612            }
10613        };
10614        let frames = &co.frames;
10615        let emit = |out: &mut Vec<u8>, v: VFrame<'_>| match v {
10616            VFrame::Lua(f) => {
10617                let proto = f.closure.proto;
10618                let src = chunk_display_name(proto.source.as_ptr());
10619                let pc = (f.pc as usize)
10620                    .saturating_sub(1)
10621                    .min(proto.lines.len().saturating_sub(1));
10622                let line = proto.lines.get(pc).copied().unwrap_or(0);
10623                out.extend_from_slice(b"\n\t");
10624                out.extend_from_slice(src);
10625                out.extend_from_slice(format!(":{line}: in ").as_bytes());
10626                if let Some((namewhat, name)) = coro_frame_name(frames, f) {
10627                    out.extend_from_slice(format!("{namewhat} '{name}'").as_bytes());
10628                } else if proto.line_defined == 0 {
10629                    out.extend_from_slice(b"main chunk");
10630                } else {
10631                    out.extend_from_slice(
10632                        format!(
10633                            "function <{}:{}>",
10634                            String::from_utf8_lossy(src),
10635                            proto.line_defined
10636                        )
10637                        .as_bytes(),
10638                    );
10639                }
10640            }
10641            VFrame::CPcall => out.extend_from_slice(b"\n\t[C]: in function 'pcall'"),
10642            VFrame::CXpcall => out.extend_from_slice(b"\n\t[C]: in function 'xpcall'"),
10643            VFrame::CYield => {
10644                // PUC `pushglobalfuncname` reports `yield` as
10645                // `'coroutine.yield'` under 5.3 and 5.4 (5.3 :566 / 5.4 :830
10646                // `checktraceback` baselines). 5.1/5.2/5.5 emit the bare
10647                // `'yield'` (5.5 :841).
10648                let qualified = matches!(self.version, LuaVersion::Lua53 | LuaVersion::Lua54);
10649                if qualified {
10650                    out.extend_from_slice(b"\n\t[C]: in function 'coroutine.yield'");
10651                } else {
10652                    out.extend_from_slice(b"\n\t[C]: in function 'yield'");
10653                }
10654            }
10655            VFrame::Tail => {
10656                // 5.1 traceback synthetic CIST_TAIL entry — luaG_addinfo
10657                // / luaO_chunkid format: `(...tail calls...)`. 5.1 db.lua
10658                // :403 asserts these appear once per collapsed tail call.
10659                out.extend_from_slice(b"\n\t(...tail calls...)");
10660            }
10661        };
10662        if total <= LEVELS1 + LEVELS2 {
10663            for &v in visible {
10664                emit(&mut out, v);
10665            }
10666        } else {
10667            for &v in &visible[..LEVELS1] {
10668                emit(&mut out, v);
10669            }
10670            let skip = total - LEVELS1 - LEVELS2;
10671            out.extend_from_slice(format!("\n\t...\t(skipping {skip} levels)").as_bytes());
10672            for &v in &visible[total - LEVELS2..] {
10673                emit(&mut out, v);
10674            }
10675        }
10676        out
10677    }
10678
10679    pub(crate) fn traceback_bytes(&self, level: i64) -> Vec<u8> {
10680        // PUC `luaL_traceback` shows up to LEVELS1 (10) top frames + LEVELS2
10681        // (11) bottom frames; if there are more, the middle is collapsed into
10682        // a `"...\t(skipping N levels)"` marker. Without this, a stack-
10683        // overflow traceback would balloon to tens of megabytes (errors.lua's
10684        // stack-overflow test ran string.gmatch over the resulting buffer).
10685        const LEVELS1: usize = 10;
10686        const LEVELS2: usize = 11;
10687        // Collect visible frames in top-down order (deepest first). Both Lua
10688        // activations and pcall/xpcall continuations (which stand in for a
10689        // C-level pcall on the stack) are visible; PUC's traceback enumerates
10690        // both via lua_getstack. db.lua :715 expects "pcall" to appear.
10691        #[derive(Clone, Copy)]
10692        enum VFrame {
10693            Lua(usize),
10694            CPcall,
10695            CXpcall,
10696        }
10697        let mut visible: Vec<VFrame> = Vec::new();
10698        for (fi, cf) in self.frames.iter().enumerate().rev() {
10699            match cf {
10700                CallFrame::Lua(_) => visible.push(VFrame::Lua(fi)),
10701                CallFrame::Cont(nc) => match nc.kind {
10702                    ContKind::Pcall => visible.push(VFrame::CPcall),
10703                    ContKind::Xpcall { .. } => visible.push(VFrame::CXpcall),
10704                    _ => {}
10705                },
10706            }
10707        }
10708        // PUC `luaL_traceback` starts enumerating at the given `level` (in
10709        // terms of L1's CallInfo chain). For the running-thread case the C
10710        // frame for debug.traceback itself is level 0 and luna's `visible`
10711        // doesn't include it — so level=1 (PUC default) means "emit from the
10712        // innermost Lua frame" (visible[0..]); level=k skips k-1 frames from
10713        // the top. level<=0 emits nothing extra here (d_traceback handles the
10714        // "[C]: in function 'traceback'" prefix for level==0 separately).
10715        let skip = (level - 1).max(0) as usize;
10716        if skip >= visible.len() {
10717            return Vec::new();
10718        }
10719        let visible = &visible[skip..];
10720        let total = visible.len();
10721        let mut out = Vec::new();
10722        let emit_frame = |out: &mut Vec<u8>, v: VFrame, this: &Vm| match v {
10723            VFrame::Lua(fi) => {
10724                let f = this.frames[fi].lua().expect("Lua frame");
10725                let proto = f.closure.proto;
10726                let src = chunk_display_name(proto.source.as_ptr());
10727                let pc = (f.pc as usize)
10728                    .saturating_sub(1)
10729                    .min(proto.lines.len().saturating_sub(1));
10730                let line = proto.lines.get(pc).copied().unwrap_or(0);
10731                out.extend_from_slice(b"\n\t");
10732                out.extend_from_slice(src);
10733                out.extend_from_slice(format!(":{line}: in ").as_bytes());
10734                if let Some((namewhat, name)) = this.frame_name(fi) {
10735                    out.extend_from_slice(format!("{namewhat} '{name}'").as_bytes());
10736                } else if proto.line_defined == 0 {
10737                    out.extend_from_slice(b"main chunk");
10738                } else {
10739                    out.extend_from_slice(
10740                        format!(
10741                            "function <{}:{}>",
10742                            String::from_utf8_lossy(src),
10743                            proto.line_defined
10744                        )
10745                        .as_bytes(),
10746                    );
10747                }
10748            }
10749            VFrame::CPcall => out.extend_from_slice(b"\n\t[C]: in function 'pcall'"),
10750            VFrame::CXpcall => out.extend_from_slice(b"\n\t[C]: in function 'xpcall'"),
10751        };
10752        if total <= LEVELS1 + LEVELS2 {
10753            for &v in visible {
10754                emit_frame(&mut out, v, self);
10755            }
10756        } else {
10757            for &v in &visible[..LEVELS1] {
10758                emit_frame(&mut out, v, self);
10759            }
10760            let dropped = total - LEVELS1 - LEVELS2;
10761            out.extend_from_slice(format!("\n\t...\t(skipping {dropped} levels)").as_bytes());
10762            for &v in &visible[total - LEVELS2..] {
10763                emit_frame(&mut out, v, self);
10764            }
10765        }
10766        out
10767    }
10768}
10769
10770// ────────────────────────────────────────────────────────────────────
10771// v1.3 Phase AOT Stage 7 sub-piece 4 — AOT trace dispatch install.
10772//
10773// The deploy-side resolver in `luna-runtime-helpers` walks the binary's
10774// trace-meta section after `vm.load`, resolves each entry's
10775// `(proto_hash, head_pc, fn_ptr)` triple against the loaded chunk's
10776// proto tree, and pushes a `CompiledTrace` onto the matching Proto's
10777// `traces` Vec via [`Vm::install_aot_trace`] below. The existing
10778// trace-dispatch loop (this file's `cl.proto.traces.borrow().iter()
10779// .find(|t| t.head_pc == pc && t.dispatchable)`) then fires the AOT
10780// mcode without further plumbing — same code path the runtime JIT
10781// uses.
10782//
10783// Why a separate impl block: keeps the AOT API surface (one fn) easy
10784// to locate when grep'ing for `install_aot_trace`, without dragging
10785// the 8500-line `impl Vm` block above.
10786// ────────────────────────────────────────────────────────────────────
10787
10788impl Vm {
10789    /// v1.3 Phase AOT Stage 7 sub-piece 4 — install a precompiled
10790    /// `CompiledTrace` onto `proto.traces` so the interp dispatcher
10791    /// fires it at the trace's `head_pc`. This is the runtime install
10792    /// API the deploy-side `luna-runtime-helpers` resolver calls once
10793    /// per AOT-emitted trace meta entry, after looking up `proto` by
10794    /// stable hash (see `crate::runtime::function::Proto::stable_hash`).
10795    ///
10796    /// # What this does
10797    ///
10798    /// Pushes `trace` onto `proto.traces` via the existing `RefCell`.
10799    /// The trace's `entry` fn ptr must already point at runnable
10800    /// machine code (the AOT linker resolved the symbol at link time;
10801    /// the deploy resolver passes the address verbatim).
10802    ///
10803    /// # What this does NOT do
10804    ///
10805    /// - **No deduplication.** Calling twice with the same `head_pc`
10806    ///   pushes two entries; the dispatcher's `find` will pick the
10807    ///   first match. The deploy resolver is responsible for not
10808    ///   double-installing.
10809    /// - **No invalidation of the runtime JIT cache.** If the runtime
10810    ///   JIT later records + compiles a trace for the same
10811    ///   `(proto, head_pc)`, both coexist on `proto.traces` and the
10812    ///   dispatcher's `find` picks whichever appears first. AOT
10813    ///   traces install before any runtime recording is possible
10814    ///   (resolver runs before `vm.load` returns its first closure),
10815    ///   so AOT traces win the race for the same site.
10816    /// - **No coverage gating.** AOT traces are trusted by
10817    ///   construction — they were validated at compile time. Setting
10818    ///   `dispatchable: false` on the input would silently disable
10819    ///   dispatch; the caller controls that flag.
10820    ///
10821    /// # Safety / soundness
10822    ///
10823    /// `trace.entry` is an `unsafe extern "C" fn` (mmap'd or linked
10824    /// machine code). Soundness contract:
10825    ///
10826    /// - The fn pointer must remain valid for the `Vm`'s lifetime.
10827    ///   In the AOT-binary deploy shape this is trivially satisfied —
10828    ///   the fn lives in the binary's `.text`.
10829    /// - `trace.entry_tags` / `exit_tags` / `window_size` must match
10830    ///   what the trace's IR actually compiled against; the dispatcher
10831    ///   uses them to marshal `reg_state` in and out without further
10832    ///   validation. A mismatch corrupts vm.stack.
10833    ///
10834    /// The AOT pipeline (`luna-aot`) is responsible for ensuring these
10835    /// invariants hold; this fn is a plain push — no validation that
10836    /// would slow the dispatcher's hot path either.
10837    pub fn install_aot_trace(
10838        &mut self,
10839        proto: crate::runtime::Gc<crate::runtime::function::Proto>,
10840        trace: crate::jit::trace::CompiledTrace,
10841    ) {
10842        let _ = self; // resolver passes &mut Vm for symmetry with future
10843        // pending-install + hash-walk variants; nothing on `self` to
10844        // mutate today because the install target lives on the Proto.
10845        proto.traces.borrow_mut().push(TArc::new(trace));
10846    }
10847
10848    /// v1.3 Phase AOT Stage 7 sub-piece 4 — walk the proto tree
10849    /// reachable from `root` and return `(proto, stable_hash)` pairs
10850    /// for every Proto found. Used by the deploy-side resolver to
10851    /// match AOT-emitted `proto_hash` keys against the freshly
10852    /// `undump`'d chunk's protos.
10853    ///
10854    /// The walk is BFS over `Proto.protos`. Same-Proto deduplication
10855    /// is done via `Gc::as_ptr` identity — a Proto re-referenced from
10856    /// multiple nested closures (rare; the cache field would catch
10857    /// the closure-side dedup, not the Proto side) is reported once.
10858    ///
10859    /// # Why on `&Vm` and not a free fn
10860    ///
10861    /// Keeps the AOT install API discoverable on the Vm surface —
10862    /// `vm.collect_proto_hashes(root)` reads naturally next to
10863    /// `vm.install_aot_trace(proto, trace)`. Doesn't actually touch
10864    /// any Vm field, so `&self` (read-only) is enough.
10865    pub fn collect_proto_hashes(
10866        &self,
10867        root: crate::runtime::Gc<crate::runtime::function::Proto>,
10868    ) -> Vec<(
10869        crate::runtime::Gc<crate::runtime::function::Proto>,
10870        [u8; 16],
10871    )> {
10872        let _ = self;
10873        let mut out = Vec::new();
10874        let mut seen: std::collections::HashSet<*const crate::runtime::function::Proto> =
10875            std::collections::HashSet::new();
10876        let mut queue: std::collections::VecDeque<
10877            crate::runtime::Gc<crate::runtime::function::Proto>,
10878        > = std::collections::VecDeque::new();
10879        queue.push_back(root);
10880        while let Some(p) = queue.pop_front() {
10881            let key = p.as_ptr() as *const _;
10882            if !seen.insert(key) {
10883                continue;
10884            }
10885            out.push((p, p.stable_hash()));
10886            for &child in p.protos.iter() {
10887                queue.push_back(child);
10888            }
10889        }
10890        out
10891    }
10892}