//! Type definitions and small helpers extracted from `trace.rs` for
//! the luna-core / luna split boundary. See
//! `.dev/rfcs/v1.1-rfc-crate-split.md` §Migration Step 6.
//!
//! Everything here is cranelift-free by construction — the items
//! ultimately home to `luna-core` in Session C, while `trace.rs`
//! (the codegen pipeline) homes to `luna`. For now both files
//! live next to each other under `src/jit/` and re-export via
//! `mod.rs` + a `pub use super::trace_types::*;` in `trace.rs`
//! so `crate::jit::trace::*` paths remain compatible.

use crate::jit::send_compat::{TArc, TCellBool, TCellPtr, TCellU32, TRefLock};
use crate::runtime::Gc;
use crate::runtime::function::Proto;
use crate::vm::isa::Inst;

/// Back-edge visit count after which a PC is promoted to a trace
/// head and recording begins. Tuned for benches in the 1k–10k
/// iteration range — too low and we record short traces that don't
/// pay back compile cost; too high and we never trace at all.
pub const TRACE_HOT_THRESHOLD: u32 = 64;

/// P12-S4 — call visit count after which a Proto is promoted to a
/// trace head at `pc=0` and recording begins. Separate from
/// [`TRACE_HOT_THRESHOLD`] so we can tune them independently — a
/// self-recursive function reaches its threshold via call counter
/// while its body's back-edges (if any) reach theirs via the
/// back-edge counter. Same value for now.
pub const CALL_HOT_THRESHOLD: u32 = 64;

/// Cap on the number of bytecode instructions captured in one trace.
/// Beyond this, recording aborts (the trace is too long to compile
/// usefully). PUC LuaJIT's default is 1024; luna starts conservative.
pub const MAX_TRACE_LEN: usize = 256;

/// Max inline depth for self-recursive `Op::Call` during recording.
/// Beyond this, the trace emits a real cranelift `call` to itself.
pub const MAX_INLINE_DEPTH: u8 = 16;

/// P16-A — recunroll threshold (mirrors LuaJIT `lj_jit.h:123` default
/// `recunroll=2`). The recorder counts how many ancestor frames share
/// the trace head's proto; when the count EXCEEDS this threshold AND
/// we're about to execute the head_pc on the head_proto, close the
/// trace with `TraceEnd::SelfLink`. Default 2 = inline 2 recursion
/// levels (so the recorded body covers 3-deep fib body per loop iter
/// after the lowerer's bump-base + branch-to-self tail).
pub const RECUNROLL_THRESHOLD: usize = 2;

/// P16-A — distinguishes the two self-link close shapes. UpRec
/// corresponds to LJ's `LJ_TRLINK_UPREC` (fib's case — recursion is
/// non-tail, framedepth > 0 at close). TailRec corresponds to
/// `LJ_TRLINK_TAILREC` (factorial's tail-recursive form, depth == 0
/// at close — Lua bytecode rarely produces this without explicit TCO
/// support, but the variant is kept symmetric with LJ's enum).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SelfRecKind {
    /// Tail-recursive self-link (LuaJIT `LJ_TRLINK_TAILREC`).
    TailRec,
    /// Up-recursive self-link (LuaJIT `LJ_TRLINK_UPREC`).
    UpRec,
}

/// v2.0 Track-R R1 — a recorded return-from-inlined-frame event,
/// captured by the recorder when a depth>0 `Op::Return0` /
/// `Op::Return1` fires during active recording with
/// `p16_self_link_enabled = true`. Mirrors LuaJIT's `IR_RETF`
/// (`lj_ir.h`) — the IR-level marker that the inlined call frame at
/// `from_depth` returned to `to_depth` with `results` values.
///
/// R1 only collects these records (a side-channel parallel to
/// [`TraceRecord::ops`]) — the lowerer doesn't consume them yet.
/// R3's down-rec stitch (see `.dev/rfcs/v2.0-track-r-prep.md` §3)
/// reads them to verify that a side-trace's inlined-frame topology
/// matches the recorded shape before stitching.
///
/// `caller_pc` is the PC the inlined frame returns TO in its caller
/// (`enclosing_call.pc + 1`), captured at record-time so the R3
/// stitch can guard equality against the runtime caller PC.
///
/// v2.0 Track-R R3a — extended with `proto: Gc<Proto>`, the proto
/// the inlined frame is returning *to* (the caller's proto). Mirrors
/// LuaJIT `IR_RETF.op1 = ir_kgc(IR(ptref))` which carries the target
/// proto pointer (see `lj_record.c:897 check_downrec_unroll` chain
/// filter `op1 == ptref`). For luna's fib(28) self-recursion this
/// equals `TraceRecord.head_proto`, but R3 keeps the field explicit
/// so future mutual-recursion (`fib_even` ↔ `fib_odd`) closes through
/// the same code path without a head_proto identity assumption.
/// GC: `RetfRecord.proto` follows the same transitive-reachability
/// reasoning as `RecordedOp.proto` — not explicitly enrolled in
/// `Vm::gc_roots()` because the captured proto is, at record time,
/// reachable via a running `CallFrame`'s closure.
///
/// R3a drops the prior `PartialEq, Eq` derives because `Gc<T>`
/// intentionally doesn't impl those traits (use `Gc::ptr_eq` for
/// pointer-identity equality). No call site compared `RetfRecord`
/// values directly pre-R3a.
#[derive(Clone, Copy, Debug)]
pub struct RetfRecord {
    /// Depth this return originated from (>0; the frame about to be
    /// popped).
    pub from_depth: u8,
    /// Depth control returns to (`from_depth - 1`).
    pub to_depth: u8,
    /// Number of return values (`0` for `Op::Return0`, `1` for
    /// `Op::Return1`). Variadic `Op::Return` isn't recorded at
    /// depth>0 today.
    pub results: u8,
    /// PC the caller resumes at after the inlined frame pops
    /// (`enclosing_call.pc + 1`). Used by R3 to guard return-target
    /// equality at runtime.
    pub caller_pc: u32,
    /// v2.0 Track-R R3a — the caller's proto (target of the return).
    /// LuaJIT `IR_RETF.op1` equivalent. R3b's lowerer reads this to
    /// emit a proto-identity assertion at the stitch guard; R3c's
    /// dispatcher consults it when materialising CallFrames for
    /// stitched re-entry. For fib(28) self-recursion it equals
    /// `TraceRecord.head_proto`; kept explicit for forward-compat
    /// with mutual-recursion patterns.
    pub proto: Gc<Proto>,
}

/// v2.0 Track-R R3a — recorder-side close marker for the down-rec
/// stitched-side-trace shape. Set by the recorder when a depth>0
/// `Op::Return` fires inside an active recording AND the prior
/// `rec.retfs` chain shows the trace is bouncing in-and-out of a
/// single proto past [`RECUNROLL_THRESHOLD`] (the LuaJIT
/// `lj_record.c:912 lj_trace_err(LJ_TRERR_DOWNREC)` trigger
/// condition). The lowerer's `end_idx` picker reads this BEFORE the
/// `self_link_kind` arm and routes through the new
/// `TraceEnd::DownRec` close.
///
/// R3a only populates the close marker and adds the variant; the
/// lowerer reads it but still pins R1's safe `dispatchable=false`
/// path. R3b lifts that to a real native back-edge by reading
/// `DownRecClose.target_proto` + `return_pc` and emitting the
/// `asm_retf`-equivalent guard sequence. R3c wires the dispatcher
/// to follow the stitch.
#[derive(Clone, Copy, Debug)]
pub struct DownRecClose {
    /// PC the inlined-frame `Return` is unwinding to. Used by R3b's
    /// lowerer to bake the guard-target into the stitch IR and by
    /// R3c's dispatcher to resume interp at the correct caller PC
    /// on stitch-miss.
    pub return_pc: u32,
    /// Caller proto the down-rec is unwinding to — mirrors LuaJIT
    /// `LJ_TRLINK_DOWNREC` parent-proto association. For fib(28)
    /// self-recursion this equals `TraceRecord.head_proto`; the
    /// field is kept explicit so the close marker matches the
    /// shape R3b's guard predicate consumes.
    pub target_proto: Gc<Proto>,
    /// Depth delta the close marker observed — `from_depth - to_depth`
    /// at the moment the recorder tripped the catch. Always `1` for
    /// today's down-rec catch (depth>0 → depth-1 Return); kept as a
    /// u8 so R3d's diag rows can surface non-`1` values when future
    /// multi-level unrolls are wired up.
    pub depth_delta: u8,
}

/// A single bytecode op as captured during trace recording, with the
/// runtime context needed to emit cranelift guards (register kinds,
/// metatable null checks, etc.). Stored in `TraceRecord.ops`.
#[derive(Clone, Debug)]
pub struct RecordedOp {
    /// Original Proto + PC that produced this op. Multiple
    /// `RecordedOp`s with different `proto` come from inlined calls.
    pub proto: Gc<Proto>,
    /// Pc within `proto` at which this op was recorded.
    pub pc: u32,
    /// The bytecode instruction itself (copy — Proto.code is
    /// already immutable post-compile).
    pub inst: Inst,
    /// Depth of inlined recursion above the trace head. 0 = the
    /// outer trace; positive values come from S4 inlining.
    pub inline_depth: u8,
    /// P12-S9-A — recorder snapshot of the runtime variable count
    /// for ops whose B / C field is `0` (meaning "use stack top").
    /// - `Op::Call` with `C == 0`: snapshot of `top - A` AFTER the
    ///   call returns — i.e. the actual number of values the
    ///   callee returned this trip.
    /// - `Op::SetList` with `B == 0`: snapshot of `top - A` at the
    ///   op — i.e. the number of source slots `[A+1..top]`.
    /// - All other ops: `None`.
    /// S9-A only captures + tests; emit (S9-B/C) consumes this as
    /// a compile-time constant guarded by a runtime equality check.
    pub var_count: Option<u32>,
}

/// v2.1 Phase 1I.B — `LUNA_JIT_FIELD_IC` env gate.
///
/// Default OFF (env unset or set to anything other than `1` / `true`).
/// When ON, the trace recorder captures a [`FieldIcSnapshot`] at the
/// first eligible `Op::GetField` site and the trace lowerer replaces
/// the helper call at that site with an inline cache: 4 guards
/// (`mt is None`, `nodes.len() == cached`, `nodes[slot].key ==
/// cached_key_bits`, `val.tag == cached_tag`) + 1 load of the value's
/// raw payload. Guard miss falls through to the existing helper
/// (scaffold-safe, no new deopt edges).
///
/// Cached on the first call per process so the env-OFF path is a
/// single relaxed atomic load (~1 ns), not a syscall. Both the
/// recorder (in `luna-core`'s exec dispatch loop) and the lowerer
/// (in `luna-jit`'s trace.rs emit) call this; sharing the cache
/// guarantees they agree on a single env-read decision per process.
pub fn field_ic_enabled() -> bool {
    use std::sync::atomic::{AtomicU8, Ordering};
    // 0 = uninitialised, 1 = off, 2 = on. Sentinel encoding lets a
    // single relaxed load distinguish "decision cached" from "ask
    // env" without a Mutex.
    static CACHED: AtomicU8 = AtomicU8::new(0);
    let v = CACHED.load(Ordering::Relaxed);
    if v != 0 {
        return v == 2;
    }
    let enabled = std::env::var("LUNA_JIT_FIELD_IC")
        .map(|s| s == "1" || s.eq_ignore_ascii_case("true"))
        .unwrap_or(false);
    CACHED.store(if enabled { 2 } else { 1 }, Ordering::Relaxed);
    enabled
}

/// v2.1 Phase 1I.B — table-field IC snapshot captured by the recorder
/// at the **first** eligible `Op::GetField` site in the trace, when
/// `LUNA_JIT_FIELD_IC=1` is set.
///
/// "Eligible" means the receiver `R[B]` is `Value::Table` with no
/// metatable at recorder-fire time AND the key resolves to a
/// `Value::Str` in `head_proto.consts[C]` AND the key actually
/// occupies a hash slot at recording time. The recorder bakes the
/// cached `(nodes_len, slot_idx, key_ptr_bits, val_tag)` tuple here so
/// the lowerer can emit guards against the table's live layout.
///
/// Phase 1I.B scaffold ships SINGLE-snapshot support (the first
/// eligible site only). Phase 1I.C expands to 5+3 sites.
#[derive(Clone, Copy, Debug)]
pub struct FieldIcSnapshot {
    /// Index into `TraceRecord.ops` of the `Op::GetField` this
    /// snapshot describes. The lowerer matches `op_idx == i` at
    /// emit time to decide whether to fire the IC path or fall
    /// through to the original helper-call path.
    pub op_idx: u32,
    /// `t.nodes.len()` at recorder-fire time. The IC's shape guard
    /// (`load high fat-ptr word, icmp Equal, cached_len`) bails to
    /// the helper on mismatch so a rehash deopts predictably.
    pub nodes_len: u64,
    /// Index of the `Node` slot that holds the cached key. The IC
    /// computes `node_addr = nodes_ptr + slot_idx * SIZEOF_NODE`
    /// and reads `key`/`val` from that address.
    pub slot_idx: u64,
    /// `Gc<LuaStr>` raw pointer bits for the cached key. The IC's
    /// slot-key guard (`load i64, icmp Equal, cached_key_bits`)
    /// catches the case where a rehash or insert relocated a
    /// different key into the cached slot.
    pub key_ptr_bits: u64,
    /// Recorder-time tag byte of the slot's value. The IC's val
    /// guard (`load i8, icmp Equal, cached_val_tag`) catches the
    /// case where a `SetField` mutated the slot to a different
    /// type since recording — without this guard the lowerer's
    /// downstream `current_kinds` propagation could pack stale
    /// raw bits as the wrong tag → garbage Value on the next op.
    pub cached_val_tag: u8,
}

/// A recorded trace: a linear sequence of ops starting at a back-edge
/// target PC, terminating at either a loop close (back to head) or a
/// hard exit (return, error).
#[derive(Clone, Debug)]
pub struct TraceRecord {
    /// The PC the trace starts at (back-edge target).
    pub head_proto: Gc<Proto>,
    /// Pc within `head_proto` where the trace begins (the back-edge target).
    pub head_pc: u32,
    /// Per-register `Value` tag (from `runtime::value::raw`) at
    /// the moment recording started. Lengths matches the
    /// `head_proto.max_stack` window. Lowerer uses these to
    /// initialise per-reg kinds — a slot tagged FLOAT at entry
    /// means a subsequent `Add` op reading that reg lowers to
    /// `fadd` instead of `iadd`. Empty when the trace was built
    /// from a test harness that didn't snapshot.
    pub entry_tags: Vec<u8>,
    /// Ops in execution order.
    pub ops: Vec<RecordedOp>,
    /// `true` once the trace returns to `head_pc` (loop closes
    /// cleanly). `false` for fallthrough exits — those can still
    /// compile but never inline-loop.
    pub closed: bool,
    /// P12-S4-step2 — `true` if the recording was fired by a
    /// trace-on-call trigger (`begin_call`'s Lua callee arm), as
    /// opposed to a back-edge trigger (`Op::Jmp` neg / `Op::ForLoop`).
    /// Affects the dispatcher's close detection: call-triggered
    /// traces close on **any** re-entry of `(head_proto, head_pc)`
    /// (single-pass through the function body), while loop-triggered
    /// traces require `cur_depth == 0` so a nested call to the
    /// containing loop's function doesn't prematurely close.
    pub is_call_triggered: bool,
    /// P12-S12-B-v5 — generic-for iter fn pointer snapshot.
    /// Populated by `Op::TForLoop`'s recorder trigger when
    /// `R[A]` is `Value::Native`. Lets the lowerer specialise
    /// `Op::TForCall` emit on `ipairs_iter` (inline Table aget
    /// via `TABLE_ARRAY_PTR_OFFSET` / `TABLE_ASIZE_OFFSET` —
    /// skip the `luna_jit_op_tforcall` C call entirely). `None`
    /// for non-generic-for traces or when the recorder fires
    /// for a non-Native iter.
    pub tfor_iter_ptr: Option<usize>,
    /// P12-S12-C v3 — snapshot of `R[A+5]` (the iter's value
    /// slot) tag at recorder fire. v5 ipairs inline aget emits a
    /// runtime guard `val_tag == expected_tag` (or Nil for the
    /// loop-end branch); a mismatch deopts to interp. Without the
    /// guard, mixed-tag arrays (e.g. `{'a', 1, 'c'}`) would let
    /// v2's Str-specialised spill pack non-Str raw bits as a Str
    /// pointer → garbage. `None` for non-generic-for traces or
    /// when the snapshot slot isn't reachable.
    pub tfor_val_tag: Option<u8>,
    /// P15-A v1 — if set, this trace is a SIDE TRACE: it was
    /// triggered by a parent trace's hot side-exit, NOT by the
    /// usual back-edge / call-trigger paths. The tuple is
    /// `(parent_head_proto, parent_head_pc, parent_exit_idx)`,
    /// uniquely identifying the parent's `CompiledTrace` and the
    /// `exit_hit_counts` slot that crossed [`HOTEXIT_THRESHOLD`].
    /// `None` for primary traces. v1 only records the metadata; v2
    /// reads it to wire the parent's exit-branch indirection
    /// pointer to the side trace's entry once it compiles.
    pub side_trace_parent: Option<(Gc<Proto>, u32, usize)>,
    /// P16-A — set by the recorder cycle catch when a same-proto
    /// ancestor count exceeds [`RECUNROLL_THRESHOLD`] at head_pc on
    /// head_proto. Drives the lowerer's `TraceEnd::SelfLink` close
    /// shape (snapshot-restore + bump-base + branch-to-self), and
    /// inhibits `is_inline_abort_close` even though the recorded
    /// body has depth>0 ops. `None` for all non-self-link closes
    /// (Call truncation, ForLoop, Return, InlineAbort).
    pub self_link_kind: Option<SelfRecKind>,
    /// v2.0 Track-R R1 — side-channel of [`RetfRecord`]s captured
    /// when a depth>0 `Op::Return0` / `Op::Return1` fires during
    /// recording with `p16_self_link_enabled = true`. Empty on the
    /// ship-default path (p16 off). Lowerer doesn't consume this in
    /// R1 — the records are infrastructure for R3's down-rec stitch
    /// (see `.dev/rfcs/v2.0-track-r-prep.md` §3).
    pub retfs: Vec<RetfRecord>,
    /// v2.0 Track-R R3a — close marker set by the recorder when a
    /// depth>0 `Op::Return` re-trips the down-rec catch (i.e., the
    /// `rec.retfs` chain shows the current Return targets the same
    /// proto as a prior Retf AND the count of prior Retfs targeting
    /// that proto exceeds [`RECUNROLL_THRESHOLD`]). The lowerer's
    /// `end_idx` picker reads this BEFORE the `self_link_kind` arm
    /// and routes through `TraceEnd::DownRec`. `None` on the
    /// ship-default path (p16 off) and on all non-down-rec closes.
    pub downrec_close: Option<DownRecClose>,
    /// v2.1 Phase 1I.B — table-field IC snapshot for the first
    /// eligible `Op::GetField` site in the trace. Populated by the
    /// recorder under `LUNA_JIT_FIELD_IC=1`; `None` on the
    /// env-default path and on traces where no eligible site fires.
    pub field_ic_snapshot: Option<FieldIcSnapshot>,
}

impl TraceRecord {
    /// Start a fresh recording at `head_pc` of `proto`. The
    /// `entry_tags` snapshot pins the per-slot `Value` tag at the
    /// moment recording fires; pass an empty vec for test
    /// harnesses that don't have a live stack to snapshot.
    /// `is_call_triggered = true` only when fired by a trace-on-call
    /// (S4-step0); back-edge triggers pass `false`.
    pub fn start(
        proto: Gc<Proto>,
        head_pc: u32,
        entry_tags: Vec<u8>,
        is_call_triggered: bool,
    ) -> Self {
        TraceRecord {
            head_proto: proto,
            head_pc,
            entry_tags,
            ops: Vec::with_capacity(MAX_TRACE_LEN),
            closed: false,
            is_call_triggered,
            tfor_iter_ptr: None,
            tfor_val_tag: None,
            side_trace_parent: None,
            self_link_kind: None,
            retfs: Vec::new(),
            downrec_close: None,
            field_ic_snapshot: None,
        }
    }

    /// P15-A v1 — start a SIDE trace recording at a hot side-exit's
    /// `cont_pc`. The trace's head_proto is the proto interp resumed
    /// in after the side-exit fired (today: same as the parent's
    /// head_proto, since trace JIT only inlines self-recursive
    /// calls — see `docs/rfcs/20260621-side-trace-tree/design.md`).
    /// `parent_*` identifies the parent `CompiledTrace`'s
    /// `exit_hit_counts` slot so v2 can wire the back-pointer.
    ///
    /// `is_call_triggered = false` for side traces — the close
    /// detection runs like a back-edge trigger (cur_depth==0 +
    /// pc==head_pc), and per S13-H the discard heuristic for short
    /// call-triggered partials doesn't apply.
    pub fn start_side_trace(
        proto: Gc<Proto>,
        head_pc: u32,
        entry_tags: Vec<u8>,
        parent_head_proto: Gc<Proto>,
        parent_head_pc: u32,
        parent_exit_idx: usize,
    ) -> Self {
        TraceRecord {
            head_proto: proto,
            head_pc,
            entry_tags,
            ops: Vec::with_capacity(MAX_TRACE_LEN),
            closed: false,
            is_call_triggered: false,
            tfor_iter_ptr: None,
            tfor_val_tag: None,
            side_trace_parent: Some((parent_head_proto, parent_head_pc, parent_exit_idx)),
            self_link_kind: None,
            retfs: Vec::new(),
            downrec_close: None,
            field_ic_snapshot: None,
        }
    }

    /// Append an op. Returns `false` when the trace is full and
    /// recording should abort.
    pub fn push(&mut self, op: RecordedOp) -> bool {
        if self.ops.len() >= MAX_TRACE_LEN {
            return false;
        }
        self.ops.push(op);
        true
    }
}

/// Outcome of a recording attempt — what `Vm::run` should do next.
#[derive(Debug)]
pub enum RecordOutcome {
    /// Recording is still in progress; keep dispatching as normal
    /// and continue recording the next op.
    InProgress,
    /// Recording closed cleanly; the trace is ready to compile in S2.
    /// `Vm::run` should commit the record and continue interpreting.
    Closed,
    /// Recording exceeded `MAX_TRACE_LEN` or hit an un-recordable op.
    /// `Vm::run` should drop the record and resume interpretation.
    Aborted,
}

/// Native entry point for a compiled trace.
///
/// **S2.B step 2 ABI** (this commit):
///
/// ```text
/// fn(reg_state: *mut i64) -> i64
/// ```
///
/// - `reg_state` points to a caller-managed buffer of
///   `head_proto.max_stack` `i64` slots. The trace reads its live
///   inputs from this buffer at entry and writes back any modified
///   regs before returning. Each slot holds the raw 8-byte payload
///   of a Lua `Value` — type tags are out of scope until step 3 adds
///   side-exit guards (the dispatcher is expected to bias values so
///   that the trace's recorded type assumptions hold).
/// - Return value = continuation PC. A clean loop close (control
///   returns to the trace's `head_pc`) returns `head_pc as i64`.
///   Step 3 will add side-exit returns for failing guards
///   (`failing_pc as i64`).
///
/// Step 1's `() -> i64` sig has been retired — only the lowerer
/// itself has been refined; the rest of the recording pipeline
/// (`Vm.active_trace`) is untouched.
// SAFETY: `TraceFn` is the ABI of native code emitted by the Cranelift lowerer (see `jit_backend::trace`); callers guarantee the `*mut i64` points to a reg_state buffer of size `window_size` and survive the trace call.
pub type TraceFn = unsafe extern "C" fn(*mut i64) -> i64;

/// What tag a register holds at the trace's exit point (relative
/// to its entry tag). Stored per register in `CompiledTrace.exit_tags`
/// so the dispatcher knows how to re-pack the i64 payload back into
/// a `Value` after the trace runs.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[repr(u8)]
pub enum ExitTag {
    /// Slot is untouched by the trace — restore the entry tag.
    Untouched,
    /// Trace writes an `Int` value to this slot (arith result,
    /// LoadI, Len, ForLoop step / count / visible-var).
    Int,
    /// Trace writes a `Float` bit-pattern to this slot (LoadF
    /// result, Float arith on two Float operands).
    Float,
    /// Trace writes a `Table` ptr to this slot (NewTable result).
    Table,
    /// P12-S4-step2c — trace writes a `Closure` ptr to this slot.
    /// Today the only producer is `Op::GetUpval` whose result is
    /// inferred (via `infer_upval_exit`) to feed an `Op::Call` as
    /// the call target — the upval *must* be a closure for that
    /// dispatch to be sound.
    Closure,
    /// P12-S6-A1 — trace actively writes Nil to this slot (the only
    /// producer today is `Op::LoadNil`; raw payload is 0). The
    /// dispatcher restores `Value::Nil` regardless of the slot's
    /// entry tag. Split out from `Untouched` so a LoadNil writer
    /// over an Int/Float/Table entry slot doesn't get mis-packed
    /// back as the entry type.
    Nil,
    /// P12-S12-C v2 — trace writes a `Str` ptr to this slot (LoadK
    /// of a Str constant, Move from a Str slot, or Concat result).
    /// Dispatcher repacks as `Value::Str(Gc::from_ptr(raw))`.
    Str,
}

/// Derive an [`ExitTag`] vector from a per-slot `RegKind` snapshot.
/// `Unset` slots restore via the dispatcher's entry tags (trace
/// didn't touch them); writers (including `Nil`, see P12-S6-A1)
/// translate one-to-one to a tag the dispatcher packs without
/// consulting the entry tag.
/// P13-S13-E — fast-path classification of an `exit_tags`
/// vector. Lets the dispatcher's restore loop skip per-slot
/// match-arm dispatch when the entire vector resolves to a
/// single trivial pattern.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TagResKind {
    /// Every slot's tag is `Untouched`. The trace didn't override
    /// any slot's exit type; vm.stack already holds the right
    /// values from either marshal-in or trace spill helpers.
    /// Dispatcher skips the restore loop.
    AllUntouched,
    /// Every slot's tag is `Int`. Dispatcher writes
    /// `Value::Int(reg_state[i])` per slot without a match arm.
    AllInt,
    /// Anything else — fall back to the original loop with
    /// per-iter match.
    Mixed,
}

/// Walk an `exit_tags` slice and classify it for the
/// dispatcher fast path.
#[doc(hidden)]
pub fn classify_exit_tags(tags: &[ExitTag]) -> TagResKind {
    if tags.iter().all(|t| matches!(t, ExitTag::Untouched)) {
        return TagResKind::AllUntouched;
    }
    if tags.iter().all(|t| matches!(t, ExitTag::Int)) {
        return TagResKind::AllInt;
    }
    TagResKind::Mixed
}

/// A trace compiled by S2's lowerer and ready to be dispatched into
/// at its head PC. Owned by `Proto.traces`; the underlying mmap is
/// kept alive by the `Vm.jit_handles` Vec for the Vm's lifetime,
/// just like the method JIT's compiled functions.
pub struct CompiledTrace {
    /// Pc the trace dispatches at (matches the recorder's `head_pc`).
    pub head_pc: u32,
    /// Native entry function (mmap'd machine code, valid for the Vm's
    /// lifetime as long as the backing handle is kept).
    pub entry: TraceFn,
    /// Number of ops in the source `TraceRecord`. Diagnostic only;
    /// tuning will gate re-record vs. recompile based on this.
    pub n_ops: u32,
    /// `true` iff the dispatcher can safely invoke this trace.
    /// False when the trace has ops the lowerer can't predict the
    /// exit type for (today: `Op::GetI` — the helper returns a
    /// raw payload that may be Int or Table or Float; without
    /// runtime tag info the dispatcher can't repack the slot).
    /// Non-dispatchable traces still compile and stay cached so
    /// a future dispatcher with richer marshalling can pick them
    /// up — they just don't run today.
    pub dispatchable: bool,
    /// P12-S4-step3a — size of the reg_state buffer the dispatcher
    /// must allocate when calling `entry`. Today always equals
    /// `head_proto.max_stack` (the trace covers only the head
    /// frame). S4-step3b's inline emit pushes this past `max_stack`
    /// to fit additional inlined frames whose register windows sit
    /// at `offsets[i]..offsets[i] + max_stack` within the buffer.
    /// The dispatcher's marshal-in still only writes [0..max_stack)
    /// — depth>0 slots start initialized to zero, and the trace's
    /// own GetUpval / arith fills them as it runs.
    pub window_size: u32,
    /// Per-register exit tag of length `window_size`. Indexed by
    /// position within the trace's reg_state_buf. The dispatcher
    /// consults this to pack `reg_state[i]` back into a `Value`
    /// after the trace returns at the **clean tail** (head_pc or
    /// call-truncation pc). See [`ExitTag`] for the semantics.
    /// `Rc<[]>` so the dispatcher's per-dispatch lookup is a cheap
    /// refcount bump, not a Vec heap clone (fib_28 dispatches 1M×
    /// — clone cost dominates without this).
    pub exit_tags: TArc<[ExitTag]>,
    /// P13-S13-E — classification of the global `exit_tags` for
    /// the dispatcher's restore-loop fast path. The dispatcher
    /// dispatches on this when `site_id == 0` AND
    /// `per_exit_tags.find(cont_pc)` misses (the common
    /// back-edge / clean-tail exit shape):
    /// - `AllUntouched` → skip the restore loop entirely (trace
    ///   touched no slots; vm.stack already holds the right
    ///   values from entry, possibly modified by spill helpers)
    /// - `AllInt`       → `vm.stack[base+i] = Value::Int(reg_state[i])`
    ///   per slot, no per-iter match
    /// - `Mixed`        → original match-arm loop
    pub global_tag_res_kind: TagResKind,
    /// P12-S12-C v3 — compile-time snapshot of `entry_tags` from the
    /// `TraceRecord`. The trace's IR + `current_kinds` propagation
    /// are specialised to these tags; if the runtime entry tags
    /// differ, the dispatcher must skip dispatch (fall back to
    /// interp) — otherwise the trace would treat e.g. a Str ptr
    /// slot as Int and produce garbage. `Rc<[]>` to match the
    /// other tag arrays' cheap-clone idiom.
    pub entry_tags: TArc<[u8]>,
    /// P12-S4-step2c — per side-exit `exit_tags`. Each entry is
    /// `(continuation_pc, exit_tags)`; when the trace returns a PC
    /// matching an entry, the dispatcher uses that vector instead of
    /// the clean-tail `exit_tags`. This makes side-exits that fire
    /// **before** later writers (`GetUpval` is the today motivator)
    /// restore the affected slot as `Untouched` (carry entry tag)
    /// rather than pack with a tag the slot hasn't actually become.
    /// Empty when no side-exit needs a different vector than the
    /// clean tail (e.g. plain numeric loops with no GetUpval).
    pub per_exit_tags: TArc<[(u32, TArc<[ExitTag]>)]>,
    /// P12-S4-step4b-C-2 — per inline side-exit metadata, indexed by
    /// `site_idx`. Each entry carries the side-exit's `cont_pc`,
    /// the per-slot `exit_tags` snapshot (sized to `window_size` so
    /// every materialised frame's window is restored), and the
    /// frame-materialise `chain` to push.
    ///
    /// fib has SIBLING self-recursive Calls (pc7, pc11) and EVERY
    /// depth's cmp lands at the same `cont_pc` — keying the lookup
    /// by `cont_pc` alone (the v2 attempt) collapsed all those
    /// distinct chains onto one entry. The trace IR encodes the
    /// firing site's `(site_idx + 1)` in the upper 32 bits of the
    /// returned i64 so the dispatcher disambiguates O(1).
    ///
    /// Empty when no cmp@d>0 fires in the trace. The IR pre-bakes
    /// each `chain`'s raw pointer (`Rc::as_ptr`) at compile time;
    /// the `Rc` clones in this field keep the slice alive for the
    /// trace's mmap lifetime (Proto.traces owns the CompiledTrace).
    pub per_exit_inline: TArc<[InlineSideExit]>,
    /// P15-prep — per-exit hit counter (LuaJIT-study foundation for
    /// future side trace work). Length and layout:
    /// - `[0..per_exit_inline.len())`: parallel to per_exit_inline
    ///   (indexed by `site_id - 1` in the dispatcher).
    /// - `[per_exit_inline.len()..per_exit_inline.len()+per_exit_tags.len())`:
    ///   parallel to per_exit_tags (indexed by find-by-cont_pc order).
    /// - Last slot: global / clean-tail exit (when site_id == 0 AND
    ///   per_exit_tags.find misses).
    ///
    /// `Rc<[Cell<u32>]>` so the dispatcher can increment without a
    /// mutable borrow on the CompiledTrace. Vm's
    /// `trace_exit_hit_distribution()` aggregates this for probe use.
    pub exit_hit_counts: TArc<[TCellU32]>,
    /// P15-A v2-A — per-exit raw side-trace function pointer. Same
    /// length / layout as [`Self::exit_hit_counts`]. `null` means
    /// "no side trace compiled for this exit yet"; non-null means a
    /// child side trace's entry fn lives at this pointer and v2-B/C
    /// will wire the parent's IR at each exit site to read this Cell
    /// and indirect-call when non-null.
    ///
    /// `Cell<*const u8>` (not Atomic) since the Vm is single-
    /// threaded — see RFC Q2. The pointer's stability is owned by
    /// the child side trace's `TraceHandle` in `TRACE_JIT_HANDLES`
    /// (thread-local Vec), which persists for the thread lifetime.
    ///
    /// **Send/Sync invariant**: `Cell<*const u8>` is not Sync, but
    /// `CompiledTrace` was never required to be Sync (it lives in
    /// `Proto.traces: RefCell<Vec<CompiledTrace>>` on the runtime
    /// path). Adding this field doesn't tighten that.
    pub exit_side_trace_ptrs: TArc<[TCellPtr]>,
    /// P15-A v2-C-A2 — per-`per_exit_tags`-entry side-trace cell.
    /// Same length as `per_exit_tags`; the IR at the corresponding
    /// `emit_store_back_and_return_pc` callsite (immediately after
    /// `per_exit_kinds.push`) bakes this cell's heap address. Same
    /// semantics as [`InlineSideExit::side_trace_ptr`] but with
    /// `kind = SIDE_SENT_KIND_TAG` and `local = tag_idx`.
    pub tags_side_trace_ptrs: TArc<[Box<TCellPtr>]>,
    /// P15-A v2-C-A2 — singleton cell shared by every GLOBAL-kind
    /// callsite (clean-tail return, Call truncation, ForLoop /
    /// TForLoop exits, generic err deopts, etc.). All such sites'
    /// IR bakes the same heap address; the close handler writes
    /// the child entry ptr here for `parent_exit_idx ==
    /// per_exit_inline.len() + per_exit_tags.len()` (the
    /// `exit_hit_counts` layout's last slot).
    pub global_side_trace_ptr: Box<TCellPtr>,
    /// P15-A v2-C-A1 — when a child side trace compiles for any
    /// of this trace's hot exits, the close handler inserts
    /// `(child.head_pc, child_traces_idx)` here. v2-C-A3's
    /// dispatcher uses this for an O(1) lookup of the side trace's
    /// own [`CompiledTrace`] when the sentinel bit on `raw_ret`
    /// (introduced by v2-C-A2) flags a side-trace return — so
    /// [`decode_exit_shape`] can be called with the SIDE TRACE's
    /// `per_exit_inline` / `per_exit_tags` / `exit_tags` instead
    /// of the parent's.
    ///
    /// Value is an **index** into `head_proto.traces` (the same
    /// proto this `CompiledTrace` lives in — trace JIT only fires
    /// side traces from self-recursive parents today, so child +
    /// parent share `head_proto`). Storing an index instead of a
    /// raw pointer dodges the `Vec<CompiledTrace>` realloc-
    /// invalidation pitfall: `proto.traces.push` doesn't reorder,
    /// only appends, so an index assigned at compile time stays
    /// valid for the trace's lifetime.
    ///
    /// `RefCell<HashMap<u32, u32>>` because the close handler
    /// holds only `&CompiledTrace` (the parent's traces borrow is
    /// immutable while we're walking it to find the parent_ct).
    pub side_trace_cache: TRefLock<std::collections::HashMap<u32, u32>>,
    /// P15-A v2-D-A8 — fast-path short-circuit hint for the
    /// dispatcher's tentative-decode + cell-load + check path. Set
    /// to `true` by the close handler when ANY of this trace's
    /// `exit_side_trace_ptrs` cells gets wired (i.e., the first
    /// time a child side trace compiles + the A5-C shape gate
    /// passes). Stays `true` for the trace's lifetime — once any
    /// side trace exists, the dispatcher must perform the per-
    /// exit check on every dispatch.
    ///
    /// When `false`, the dispatcher skips the tentative decode +
    /// cell load + child lookup entirely, falling straight through
    /// to the cheap parent decode + writeback. Trims fib_10_x10k-
    /// class tight-trace workloads' per-dispatch overhead from the
    /// double-decode pattern to a single `Cell::get()`.
    ///
    /// `Cell<bool>` so the close handler can flip the flag through
    /// only an `&CompiledTrace` borrow (the parent's `traces`
    /// borrow is immutable while the close handler walks).
    pub has_any_side_wired: TCellBool,
    /// P13-S13-G v2 — `true` iff this trace closes at a
    /// `TraceEnd::InlineAbort` (depth>0 op the lowerer can't
    /// continue past: depth past `MAX_INLINE_DEPTH`, non-self
    /// Call@d>0, ForLoop@d>0, TForLoop@d>0, or proto mismatch).
    /// Such traces compile but pin `dispatchable=false` —
    /// dispatching them would resume interp at a depth>0 PC
    /// without the matching CallFrames the trace inlined past
    /// (S4-step4b's frame mat helper can synthesise these but
    /// isn't wired up for InlineAbort exits yet — that's the
    /// S13-G v2 follow-up). Vm's `trace_inline_abort_count`
    /// tallies these so future-tuning sees what bench cells
    /// would benefit from the frame-mat unlock.
    pub is_inline_abort_close: bool,
    /// P13-S13-G v2.5 — if `dispatchable == false`, the static
    /// label of the emit-pass site that flipped it. Lets a probe
    /// distinguish among the six places trace.rs pins dispatch
    /// off (GetI / GetTable / GetUpval inference fail, TForCall
    /// slow-path, length gate, InlineAbort gate). `None` if the
    /// trace IS dispatchable, the first label otherwise.
    pub dispatch_off_reason: Option<&'static str>,
    /// P12-S5-A — number of NewTable sites in this trace whose
    /// final `EscapeState` is `EscapeState::Sinkable` after
    /// S5-B's pre-emit demotion pass. Vm's
    /// `trace_sinkable_seen_count` tallies these for telemetry.
    pub sinkable_sites_seen: u32,
    /// P14-S14-B v1 — number of `AccumSite`s with `BufferState::Bufferable`
    /// detected by `detect_accumulators`. v1 only counts; v2+ will use
    /// the sites for buffered emit. Vm's `trace_accum_bufferable_seen_count`
    /// tallies these for probe visibility.
    pub accum_bufferable_seen: u32,
    /// P12-S5-B — number of Sinkable sites this trace's emit
    /// actually allocated virt slot Variables for (i.e., took the
    /// no-heap-alloc path). Always `<= sinkable_sites_seen`. Bumps
    /// `Vm::trace_sunk_alloc_count` on compile success.
    pub sunk_alloc_seen: u32,
    /// P12-S5-C — number of (site × cmp side-exit) pairs in this
    /// trace's IR that emit the materialise helper. Each pair is
    /// "this cmp's side-exit reconstructs site X's heap Table".
    /// Static count; the runtime number of helper calls depends
    /// on dispatch shape (which side-exits actually fire).
    pub materialize_emit_count: u32,
    /// P12-S7-A — number of `Op::Closure` ops this trace's emit
    /// lowered to a `luna_jit_op_closure` helper call. Each
    /// closure-creating op replaces a `Heap::new_closure_inline`
    /// allocation, which dwarfs the dispatcher's marshal overhead;
    /// the length-gate skip below treats `closure_seen > 0` the
    /// same as `sunk_alloc_seen > 0` (don't gate short traces).
    pub closure_seen: u32,
    /// P15-A v2-E — sorted unique list of slot indices that ANY
    /// op in this trace's body WRITES (post `inline_depth` offset).
    /// Computed at compile via `compute_body_writes`; consumed
    /// by the v2-E smart side-trace gate at child compile to
    /// detect read-before-write live-in registers that would
    /// re-read the parent's stale exit value across the child's
    /// internal-loop iters (see s12_step_b bug analysis).
    pub body_writes: Box<[u32]>,
    /// v2.0 Track-R R3b — down-recursion stitch link populated by
    /// the lowerer's `downrec_idx_opt` arm
    /// (`crates/luna-jit/src/jit_backend/trace.rs:7129+`) when a
    /// trace closes via `TraceEnd::DownRec`. Layout:
    /// `Some((trace_id_placeholder, target_head_pc))`. R3b emits a
    /// caller-pc guard at the close site that, on guard hit, returns
    /// the [`SIDE_SENT_DOWNREC_CODE`] sentinel — and on guard miss,
    /// falls back to R1's safe deopt-tail (store back caller window +
    /// return `head_pc` via GLOBAL sentinel, [`Self::dispatchable`]
    /// pinned to `false`). R3b deliberately keeps
    /// `dispatchable = false` even when this field is `Some(_)`;
    /// R3d will lift `dispatchable = true` after R3c wires the
    /// dispatcher consumer.
    ///
    /// Field semantics agreed for R3b -> R3c handoff:
    /// - `.0` = placeholder trace id. At compile time the trace
    ///   doesn't know its own index in `head_proto.traces` yet
    ///   (the index is assigned at the close handler's `traces.push`
    ///   site after this function returns). R3b writes `0` here as
    ///   a "this trace, self-stitch" sentinel; R3c interprets a
    ///   non-`None` value with `.0 == 0` as "stitch target = the
    ///   trace currently dispatching" and uses [`Self::head_pc`]
    ///   for resolution. A future R3.2 may carry an explicit
    ///   `head_proto.traces`-index when mutual-recursion stitch
    ///   lands.
    /// - `.1` = `target_head_pc`, copied from `record.head_pc` at
    ///   compile time. R3c's stitch dispatcher tail-calls into the
    ///   target trace at this PC (which today = self, the trace
    ///   currently dispatching).
    ///
    /// `None` for every trace that doesn't close via `TraceEnd::
    /// DownRec`. Tested via R3b's `r3b_lowerer_stitch_sentinel.rs`
    /// regression: at least 1 trace recorded on a fib(3) hot-loop
    /// has `downrec_link == Some(_)` after the R3a recorder pushes
    /// the threshold-tripping retfs.
    pub downrec_link: Option<(u32, u32)>,
    // v2.0 Track-R R3d — GC trace mcode lifetime invariant for the
    // multi-way stitch path. The lowerer's R3d arm bakes
    // `dr_return_pc` + each retf's `caller_pc` into the IR as plain
    // `iconst(I64, _)` constants — none of these reach the runtime as
    // a pointer dereference. The stitch HIT path returns the DOWNREC
    // sentinel (a constant `u64`) and the deopt path stores back the
    // caller window + returns via the GLOBAL sentinel; neither path
    // dereferences any external trace's mcode. R3d's `downrec_link =
    // Some((0, head_pc))` is a `(u32, u32)` pair, `Copy`. No
    // `Box<Cell<*const u8>>` (the InlineSideExit / TAG / GLOBAL slot
    // shape that R3.2+'s tail-call-into-target work will introduce)
    // is added here.
    //
    // Consequence: this trace's mcode lifetime is governed solely by
    // its own `Rc<CompiledTrace>` strong-count (held by `proto.traces`
    // for as long as the proto lives). R3d introduces no cross-trace
    // mcode dependency, so R3 prep §7.3 ("Child trace fn-ptr stale
    // after parent recompile") doesn't apply to the R3d shape — the
    // hazard surfaces only when the R3.2+ tail-call-into-target work
    // wires `Rc<CompiledTrace>` / `Weak<CompiledTrace>` into
    // `parent_ct.side_trace_cache` for the stitch target.
    /// v2.0 Track-R R3d — number of distinct caller_pc candidates the
    /// lowerer baked into the multi-way guard at the
    /// `TraceEnd::DownRec` close. `0` for every trace that doesn't
    /// close via DownRec; `1` for R3c-shape single-CMP guards (the
    /// `dr_return_pc` alone, no additional retfs matched the close
    /// marker's `target_proto`); `>= 2` for R3d-shape multi-way
    /// guards that triggered the `dispatchable = true` lift.
    /// Capped at [`DOWNREC_MULTI_WAY_GUARD_MAX`].
    ///
    /// Read by the close handler in `crates/luna-core/src/vm/exec.rs`
    /// to bump `JitCounters.multi_way_guard_emitted` (the probe
    /// surface for R3d's regression test
    /// `r3d_multi_way_guard_dispatch.rs`).
    pub downrec_multi_way_count: u8,
}

/// P12-S4-step4b-C-2 — per inline cmp@d>0 side-exit record. See
/// [`CompiledTrace::per_exit_inline`] for the shape rationale.
#[derive(Clone, Debug)]
pub struct InlineSideExit {
    /// PC the interpreter resumes at after the side-exit fires.
    /// Mirrors the innermost frame's `pc` in `chain`.
    pub cont_pc: u32,
    /// PC to write on the trace head frame when the side-exit
    /// fires — the depth-0 frame's resume point after ITS own Call
    /// that entered depth 1. Without this update, the trace head
    /// frame's pc stays at `head_pc` (where the dispatcher entered);
    /// once the inlined chain pops, interp resumes the trace head
    /// at pc=0 and immediately self-Calls again → infinite dispatch
    /// loop. Captured at emit time as the outermost `Op::Call`'s
    /// `pc + 1` from the live `call_chain`.
    pub head_resume_pc: u32,
    /// Slot-by-slot `ExitTag` snapshot at the side-exit moment.
    /// Length = `window_size` — covers caller + every inlined
    /// frame's register window.
    pub exit_tags: TArc<[ExitTag]>,
    /// Frames to push onto `vm.frames` (outermost = depth 1 first,
    /// innermost = depth `len()` last). The innermost frame's `pc`
    /// is overwritten to the side-exit PC at compile time so the
    /// helper stays PC-agnostic.
    pub chain: TArc<[FrameMaterializeInfo]>,
    /// P15-A v2-C-A2 — raw `*const u8` (entry fn pointer of a child
    /// side trace) for THIS inline cmp@d>0 side-exit. The IR at the
    /// `emit_store_back_and_return_site` call site loads this cell
    /// BEFORE the encoded-return path: non-null → store-back +
    /// `call_indirect` into the child + OR sentinel(INLINE, site_idx)
    /// into bits 56..=63 of the child's return + return; null → run
    /// the existing encoded-return path.
    ///
    /// `Box<Cell<*const u8>>` (not embedded Cell) so the cell's HEAP
    /// address is stable for the IR's `iconst`-baked load. Moving
    /// the Box (e.g. into `Rc<[]>` via `.collect`) doesn't move the
    /// cell. Single-threaded Vm so `Cell` is sound.
    pub side_trace_ptr: Box<TCellPtr>,
}

/// P15-A v0 — hot side-exit detection threshold. Exits whose hit
/// count crosses this value are reported by `Vm::hot_exit_iter` as
/// side-trace candidates. LuaJIT 2.1's default is 10, but short
/// workloads (binary_trees_d4_x200 = 200 outer iters, each calling
/// make/itemcheck a small handful of times) don't reach 10 hot
/// hits before the run ends. v2-G drops to 3 so short workloads
/// also get a chance to wire side traces.
pub const HOTEXIT_THRESHOLD: u32 = 2;

/// P15-A v2-C-A2 — sentinel kind tags for side-trace returns.
/// When a parent trace's IR detects a wired child side-trace cell
/// non-null at a side-exit and tail-calls into the child, it OR's
/// a 7-bit sentinel into the upper bits of the child's return value
/// (bit 63 = side-trace marker, bits 56..=62 = `encode_side_sentinel
/// (kind, local)`). The dispatcher reads the marker to know it must
/// re-decode the body using the SIDE TRACE's shape inputs, not the
/// parent's. The kind is informational (debug + close-handler routes
/// the right cell write); `local` disambiguates among multiple wired
/// cells of the same kind (e.g. several inline cmp@d>0 sites).
pub const SIDE_SENT_KIND_INLINE: u8 = 1;
/// Sentinel kind for tag-cell side-traces (typed-register exits).
pub const SIDE_SENT_KIND_TAG: u8 = 2;
/// Sentinel kind for global-cell side-traces (env-table exits).
pub const SIDE_SENT_KIND_GLOBAL: u8 = 3;
/// v2.0 Track-R R3b — sentinel kind for down-recursion stitch
/// returns. Emitted at the `TraceEnd::DownRec` close arm in
/// `crates/luna-jit/src/jit_backend/trace.rs` `downrec_idx_opt`
/// branch when the caller-pc guard hits (saved `[base-8]` matches
/// the recorded `target_proto`'s expected return PC) so the
/// dispatcher (R3c) knows to walk the parent trace's RetfRecord
/// chain to materialise the inlined frames and tail-call into the
/// stitched child trace rather than falling back to interp at
/// `head_pc`. R3b only emits the sentinel + populates
/// `CompiledTrace.downrec_link`; the dispatcher consumer arrives
/// in R3c. Until R3c lands, the sentinel falls into the existing
/// dispatcher's "cache miss" fallback path (interp resumes at
/// `head_pc`) so the trace stays correct.
pub const SIDE_SENT_KIND_DOWNREC: u8 = 4;

/// v2.0 Track-R R3b — encoded sentinel value reserved for
/// [`SIDE_SENT_KIND_DOWNREC`]. Picked as `0x10` (= 16) which sits
/// in the (kind=0, local=0..=31) slice unused by existing kinds
/// 1..=3 (those occupy encoded ranges 32..=127). DOWNREC has no
/// `local` (only 1 stitch slot per trace today), so the value is
/// a single constant rather than a function of `local`. Out-of-band
/// vs the regular `((kind & 0x3) << 5) | (local & 0x1F)` layout —
/// keeps existing kinds' encoding (and TAG local cap of 32) intact
/// without widening the kind bits.
pub const SIDE_SENT_DOWNREC_CODE: u32 = 0x10;

/// v2.0 Track-R R3d — upper bound on the multi-way caller-pc guard
/// chain emitted at the `TraceEnd::DownRec` close in the lowerer
/// (`crates/luna-jit/src/jit_backend/trace.rs` `downrec_idx_opt` arm).
/// The lowerer dedupes `record.retfs` by `caller_pc` (filtered to
/// retfs whose `proto` matches the close marker's `target_proto`) and
/// emits up to this many `icmp(Equal, saved_pc, iconst(candidate_pc))
/// + brif(stitch_blk, next_blk)` chain entries before falling through
/// to `deopt_blk`. R3c shipped with 1 (single-CMP) and measured a 90%
/// miss-rate on fib(3) hot-loop; the typical fib body shape captures
/// 2 distinct caller_pcs (one per call site `pc+1`), so a cap of 4
/// covers the fib pattern with headroom for slightly deeper closes
/// without growing IR proportional to retfs.len(). When the candidate
/// set reaches >= 2 entries, the lowerer also lifts `dispatchable =
/// true` (was R3c's `false`-pin) so the primary dispatcher arm hits
/// the trace without going through R3c's `downrec_link.is_some()`
/// fallback admit clause.
pub const DOWNREC_MULTI_WAY_GUARD_MAX: usize = 4;

/// P15-A v2-C-A2 — encode a `(kind, local)` pair into a 7-bit
/// sentinel code that fits in `raw_ret`'s bits 56..=62. Layout for
/// kinds 1..=3: upper 2 bits = kind, lower 5 bits = local index.
/// A local index `>= 32` is truncated; the close handler caps
/// tag-cell allocation at 32 to avoid sentinel collisions. The
/// dispatcher uses the full 7-bit value as the key into the
/// parent's `side_trace_cache`.
///
/// v2.0 Track-R R3b adds kind 4 ([`SIDE_SENT_KIND_DOWNREC`]) which
/// is encoded out-of-band as [`SIDE_SENT_DOWNREC_CODE`] (= 0x10).
/// DOWNREC has only one slot per trace (the stitch target lives on
/// `CompiledTrace.downrec_link`, not in the side_trace_cache), so
/// its `local` is asserted to be 0.
pub fn encode_side_sentinel(kind: u8, local: u32) -> u32 {
    debug_assert!(
        (1..=4).contains(&kind),
        "kind must be SIDE_SENT_KIND_* (1..=4)"
    );
    if kind == SIDE_SENT_KIND_DOWNREC {
        debug_assert!(
            local == 0,
            "SIDE_SENT_KIND_DOWNREC requires local == 0 (single stitch slot per trace)"
        );
        return SIDE_SENT_DOWNREC_CODE;
    }
    ((kind as u32 & 0x3) << 5) | (local & 0x1F)
}

/// v2.0 Track-R R3b — true iff the dispatcher's decoded
/// `sentinel_code` (`(raw_ret >> 56) & 0x7F` at
/// `crates/luna-core/src/vm/exec.rs:6355`) marks a down-recursion
/// stitch return. The dispatcher arm that consumes this is R3c's
/// job; R3b adds the predicate so the lowerer and any diagnostic
/// probe share one definition.
#[inline]
pub fn is_downrec_sentinel(sentinel_code: u32) -> bool {
    sentinel_code == SIDE_SENT_DOWNREC_CODE
}

/// P15-A v2-C-A6 — env-gated probe switch. `LUNA_V2C_PROBE=1` (any
/// non-empty value) turns on the side-trace dispatch probes (IR
/// side-entry, dispatcher A3 decode, frame.pc set). Off by default
/// so production runs pay no overhead — even the IR-emitted probe
/// call is conditional on the probe helper itself short-circuiting
/// when the OnceLock resolves to `false`.
static V2C_PROBE_ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
/// True iff the side-trace dispatch probes are enabled via
/// `LUNA_V2C_PROBE=1`. Diagnostic-only; production builds keep this
/// off so the IR-emitted probe call short-circuits cheaply.
pub fn v2c_probe_enabled() -> bool {
    *V2C_PROBE_ON.get_or_init(|| {
        std::env::var("LUNA_V2C_PROBE")
            .ok()
            .filter(|v| !v.is_empty())
            .is_some()
    })
}

/// P15-A v0 — one hot side-exit candidate surfaced by
/// `Vm::hot_exit_iter`. The walker fills this from one
/// [`CompiledTrace`]'s `exit_hit_counts` slot whose value passed
/// [`HOTEXIT_THRESHOLD`].
///
/// `head_proto` + `head_pc` identify the *parent* trace; `exit_idx`
/// indexes into the parent's `exit_hit_counts` (same layout the
/// dispatcher uses to bump). `cont_pc` is where the interpreter
/// resumed after the side-exit; this is the side trace's natural
/// entry PC. `exit_tags` is the compile-time slot-shape snapshot the
/// side trace would inherit as its entry tags.
#[derive(Clone, Debug)]
pub struct HotExitInfo {
    /// The trace head's Proto. `head_proto.traces` owns the parent
    /// [`CompiledTrace`]; combined with `head_pc` it uniquely
    /// identifies which trace this exit belongs to.
    pub head_proto: Gc<Proto>,
    /// PC of the parent trace's head (== the entry the dispatcher
    /// looks up under `cl.proto.traces`).
    pub head_pc: u32,
    /// Index into the parent's `exit_hit_counts`. Layout:
    /// - `[0..per_exit_inline.len())`: inline cmp@d>0 side-exits
    /// - `[per_exit_inline.len()..per_exit_inline.len() + per_exit_tags.len())`:
    ///   per-cont_pc side-exits (GetUpval-style)
    /// - last slot: global clean-tail / back-edge fallback
    pub exit_idx: usize,
    /// Saturating count from `exit_hit_counts[exit_idx]` at the
    /// moment of the walk. Always `>= HOTEXIT_THRESHOLD`.
    pub hits: u32,
    /// PC the interpreter resumed at after this side-exit fired.
    /// Inline side-exits read from `InlineSideExit.cont_pc`;
    /// per_exit_tags entries from their `(cont_pc, _)` pair; the
    /// global slot reports `head_pc` (the clean-tail back-edge
    /// returns to the trace's head, where dispatch can re-enter).
    pub cont_pc: u32,
    /// Slot-shape snapshot at the exit moment, reused as the side
    /// trace's entry_tags. Inline side-exits cover the full
    /// `window_size` (caller + inlined frames); per_exit_tags
    /// entries cover only the caller's `max_stack`; the global
    /// slot exposes the clean-tail `exit_tags` (caller window only).
    pub exit_tags: TArc<[ExitTag]>,
}

/// P12-S4-step4b — one Lua frame to push when a depth>0 side-exit
/// fires. Constructed at trace compile time from the recorded
/// `Op::Call` chain's `A` field (caller's `R[A]` = function slot) and
/// the inlined callee's `c` field (`nresults`). `pc` is the address
/// the helper writes onto the freshly-pushed frame so the interp
/// resumes at the right offset inside the callee body.
///
/// `repr(C)` because the trace's IR loads the array via raw pointer
/// arithmetic; Rust's default `repr` doesn't guarantee field order.
/// All-`Copy` fields with no padding inside each field — 12 bytes
/// per entry on amd64.
#[repr(C)]
#[derive(Clone, Copy, Debug)]
pub struct FrameMaterializeInfo {
    /// Stack offset (relative to the trace head's `frame.base`) of
    /// the callee's first register slot. The new frame's `base` is
    /// `head_frame.base + base_offset`; its `func_slot` is one below.
    pub base_offset: u32,
    /// PC to write on the freshly-pushed frame. For inner frames
    /// (not the innermost) this is the caller's Call.pc + 1 so the
    /// interp resumes after the Call instruction. For the innermost
    /// frame (the one the side-exit fires inside) the dispatcher
    /// overrides this with the actual side-exit PC — keeps the
    /// helper PC-agnostic per the RFC's "helper doesn't know which
    /// frame is innermost" rule.
    pub pc: u32,
    /// PUC `nresults`: how many return values the caller expects
    /// from this call (encoded as `Op::Call`'s C - 1). step4b-C's
    /// pre-emit pass bails if any inlined Call has nresults != 1
    /// (Op::Return1 copy-back assumes one value).
    pub nresults: i32,
}

impl std::fmt::Debug for CompiledTrace {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("CompiledTrace")
            .field("head_pc", &self.head_pc)
            .field("n_ops", &self.n_ops)
            .field("dispatchable", &self.dispatchable)
            .field("exit_tags", &self.exit_tags)
            .field("entry", &"<fn>")
            .finish()
    }
}

impl CompiledTrace {
    /// v1.3 Phase AOT Stage 7 sub-piece 4 — minimal AOT install
    /// constructor; Stage 7 follow-up — extended to accept
    /// `per_exit_tags` (typed-register side-exits) so GetUpval-heavy
    /// traces install correctly.
    ///
    /// Builds a [`CompiledTrace`] from the fields the AOT meta blob
    /// carries plus a deploy-resolved trace fn pointer.
    /// `per_exit_inline`, `tags_side_trace_ptrs`,
    /// `side_trace_cache`, `body_writes` default to empty / null;
    /// `exit_hit_counts` / `exit_side_trace_ptrs` are sized to
    /// `per_exit_tags.len() + 1` (each typed-exit slot + the global
    /// clean-tail). The deploy `Vm` never records side traces against
    /// an AOT-installed parent (the recorder is invoked from the
    /// dispatch path; AOT traces install before any record can fire),
    /// so the side-trace bookkeeping defaults are sound.
    ///
    /// Traces whose runtime shape requires non-empty
    /// `per_exit_inline` (depth>0 inlined cmp side-exits with frame
    /// materialization chains) **must not** be emitted by the AOT
    /// pipeline — this constructor produces a CompiledTrace that the
    /// dispatcher would mis-restore for those traces. The AOT
    /// recorder driver (luna-aot side) filters to traces whose
    /// `per_exit_inline.is_empty()` before serializing.
    ///
    /// # Safety
    ///
    /// `entry` must be a valid `unsafe extern "C" fn(*mut i64) -> i64`
    /// living in the binary's text segment (linker-resolved from the
    /// AOT-emitted trace `.o`). The constructor doesn't validate this
    /// — `install_aot_trace` is the next gate.
    pub fn from_aot_meta(
        entry: TraceFn,
        head_pc: u32,
        n_ops: u32,
        dispatchable: bool,
        window_size: u32,
        entry_tags: TArc<[u8]>,
        exit_tags: TArc<[ExitTag]>,
        global_tag_res_kind: TagResKind,
        per_exit_tags: Vec<(u32, TArc<[ExitTag]>)>,
        per_exit_inline: Vec<crate::jit::trace_types::InlineSideExit>,
    ) -> Self {
        // v1.3 Phase AOT Stage 7 polish 6 — `inline_n` non-zero when
        // the AOT trace ships depth>0 inlined cmp side-exits. The
        // chain pointers baked into the trace mcode are populated by
        // the deploy-side `aot_inline_chain_resolver`; the
        // `per_exit_inline` entries here own a separate Rc<[...]>
        // rebuilt from the v3 wire format's `chain_bytes`, used by
        // the dispatcher for side-exit shape decode + side-trace
        // routing. Neither side compares pointers — both consume the
        // metadata they own.
        //
        // `exit_hit_counts` / `exit_side_trace_ptrs` sized to
        // `inline_n + tags_n + 1` (matches the dispatcher's
        // `hot_exit_iter` invariant).
        let inline_n = per_exit_inline.len();
        let total_slots = inline_n + per_exit_tags.len() + 1;
        let exit_hit_counts: TArc<[TCellU32]> = {
            let v: Vec<TCellU32> = (0..total_slots).map(|_| TCellU32::new(0)).collect();
            v.into()
        };
        let exit_side_trace_ptrs: TArc<[TCellPtr]> = {
            let v: Vec<TCellPtr> = (0..total_slots).map(|_| TCellPtr::null()).collect();
            v.into()
        };
        // Parallel `tags_side_trace_ptrs` slice — one Box per
        // per_exit_tags entry, all null. The AOT install never wires
        // a side trace (no recorder fires on this parent), so the
        // cells stay null for the binary's lifetime; sizing matches
        // the dispatcher's `per_exit_kinds.len() == tags_side_trace
        // _ptrs.len()` invariant the close handler asserts.
        let tags_side_trace_ptrs: TArc<[Box<TCellPtr>]> = {
            let v: Vec<Box<TCellPtr>> = (0..per_exit_tags.len())
                .map(|_| Box::new(TCellPtr::null()))
                .collect();
            v.into()
        };
        CompiledTrace {
            head_pc,
            entry,
            n_ops,
            dispatchable,
            window_size,
            exit_tags,
            global_tag_res_kind,
            entry_tags,
            per_exit_tags: TArc::from(per_exit_tags),
            per_exit_inline: TArc::from(per_exit_inline),
            exit_hit_counts,
            exit_side_trace_ptrs,
            tags_side_trace_ptrs,
            global_side_trace_ptr: Box::new(TCellPtr::null()),
            side_trace_cache: TRefLock::new(std::collections::HashMap::new()),
            has_any_side_wired: TCellBool::new(false),
            is_inline_abort_close: false,
            dispatch_off_reason: None,
            sinkable_sites_seen: 0,
            accum_bufferable_seen: 0,
            sunk_alloc_seen: 0,
            materialize_emit_count: 0,
            closure_seen: 0,
            body_writes: Box::new([]),
            // v2.0 Track-R R3b — AOT-install path never triggers a
            // down-recursion stitch (no R3a recorder fires on the
            // deploy-side install). Always `None`.
            downrec_link: None,
            // v2.0 Track-R R3d — AOT-install path doesn't emit a
            // DownRec close (no R3a recorder fires on the deploy-side
            // install), so the candidate count is always `0`.
            downrec_multi_way_count: 0,
        }
    }
}

/// Result of attempting to lower a closed [`TraceRecord`] to native
/// code. Most failure cases are recoverable — the recorder bumps the
/// head PC's failure count and refuses to re-record until the
/// threshold rolls over again.
#[derive(Debug)]
pub enum CompileOutcome {
    /// Trace compiled; the cached entry is ready for dispatch in S3.
    Compiled,
    /// Some op in the trace falls outside S2's whitelist (e.g. a
    /// metamethod-bearing operand, or a yet-unsupported opcode).
    /// The record is dropped; the head PC remembers the rejection.
    UnsupportedOp,
    /// Cranelift signaled an error during code emission. Should be
    /// rare in practice — usually a programmer error in the lowerer.
    BackendError,
}

/// P15-A v2-C-A5-C — return `true` iff `child_entry_tags` is
/// compatible with `parent_exit_tags` (the parent's per-exit tag
/// snapshot at the slot the side trace was wired to). Used by
/// the close handler to gate the side-trace ptr write: only write
/// when shapes match so the future `call_indirect` (v2-C-A2 redo)
/// is guaranteed to feed the child reg_state values whose tags
/// agree with the child's `compile_entry_tags`.
///
/// `Untouched` slots in `parent_exit_tags` mean the parent didn't
/// override that slot during execution — its tag at parent's
/// exit equals its tag at parent's entry. The child's recorder
/// snapshotted from the same vm.stack at parent's exit, so for
/// those slots `child_entry_tags[i] == parent_compile_entry_tags
/// [i]` should hold.
pub fn exit_tags_match_entry_tags(
    child_entry_tags: &[u8],
    parent_exit_tags: &[ExitTag],
    parent_compile_entry_tags: &[u8],
) -> bool {
    let n = parent_exit_tags.len();
    if child_entry_tags.len() < n {
        return false;
    }
    for i in 0..n {
        let expected = match parent_exit_tags[i] {
            ExitTag::Untouched => {
                if i < parent_compile_entry_tags.len() {
                    parent_compile_entry_tags[i]
                } else {
                    // Parent didn't capture an entry tag here
                    // (inlined-frame scratch slot). Child can't
                    // safely consume — bail.
                    return false;
                }
            }
            ExitTag::Int => crate::runtime::value::raw::INT,
            ExitTag::Float => crate::runtime::value::raw::FLOAT,
            ExitTag::Table => crate::runtime::value::raw::TABLE,
            ExitTag::Closure => crate::runtime::value::raw::CLOSURE,
            ExitTag::Nil => crate::runtime::value::raw::NIL,
            ExitTag::Str => crate::runtime::value::raw::STR,
        };
        if child_entry_tags[i] != expected {
            return false;
        }
    }
    true
}

/// P15-A v2-C-A0 — decoded exit shape. Returned by
/// [`decode_exit_shape`]. Carries the per-exit metadata the
/// dispatcher's restore loop needs: the resume PC, the
/// `exit_hit_counts` slot index for the side-trace trigger
/// counter, the per-slot exit-tag array to interpret reg_state
/// through, and a flag for the global classified-restore fast
/// path.
///
/// The lifetime ties `exit_tags_for_pc` to whichever input slice
/// the decode picked from (one of the `CompiledTrace` fields).
/// The dispatcher's per_exit_inline / per_exit_tags / exit_tags
/// Rc clones from the per-dispatch lookup keep them alive for
/// the dispatch.
pub struct DecodedExit<'a> {
    /// Pc the interpreter should resume at after the trace exit.
    pub cont_pc: u32,
    /// Stable id of the exit site (used to key per-site counters / caches).
    pub site_id: u32,
    /// Index into `exit_hit_counts` for the side-trace trigger counter.
    pub exit_hit_idx: usize,
    /// Per-slot exit-tag array describing how to interpret saved register
    /// state for this exit.
    pub exit_tags_for_pc: &'a [ExitTag],
    /// True when the global classified-restore fast path applies.
    pub using_global_exit_tags: bool,
}

/// P15-A v2-C-A0 — decode a trace's i64 return value into the
/// per-exit shape the dispatcher needs to restore vm.stack +
/// bump the hit counter.
///
/// Pure function over the input slices — the dispatcher passes
/// the parent's `per_exit_inline` / `per_exit_tags` / `exit_tags`;
/// v2-C-A3 will call it again with the side trace's same fields
/// when bit 63 of `raw_ret` is set (the sentinel introduced by
/// v2-C-A2). Factored out of the inlined dispatcher block in
/// `Vm::run` for that future reuse — no behavior change vs the
/// inlined form.
///
/// Layout reminder (from `CompiledTrace::exit_hit_counts`):
/// - `[0..inline.len())` — inline cmp@d>0 sites, indexed by
///   `site_id - 1` (1-based encoding lets `site_id == 0` mean
///   "non-inline").
/// - `[inline.len()..inline.len() + tags.len())` — per_exit_tags
///   in find-by-cont_pc order.
/// - Last slot — global / clean-tail fallback.
pub fn decode_exit_shape<'a>(
    raw_ret: u64,
    per_exit_inline: &'a [InlineSideExit],
    per_exit_tags: &'a [(u32, TArc<[ExitTag]>)],
    exit_tags: &'a [ExitTag],
) -> DecodedExit<'a> {
    let site_id = (raw_ret >> 32) as u32;
    let cont_pc = (raw_ret & 0xFFFF_FFFF) as u32;
    let inline_n = per_exit_inline.len();
    if site_id > 0 {
        let idx = (site_id - 1) as usize;
        debug_assert!(
            idx < inline_n,
            "site_idx out of range (idx={} inline_n={})",
            idx,
            inline_n
        );
        debug_assert_eq!(
            per_exit_inline[idx].cont_pc, cont_pc,
            "per_exit_inline entry's cont_pc mismatch with IR"
        );
        DecodedExit {
            cont_pc,
            site_id,
            exit_hit_idx: idx,
            exit_tags_for_pc: &per_exit_inline[idx].exit_tags,
            using_global_exit_tags: false,
        }
    } else {
        match per_exit_tags
            .iter()
            .enumerate()
            .find(|(_, (pc, _))| *pc == cont_pc)
        {
            Some((i, (_, tags))) => DecodedExit {
                cont_pc,
                site_id: 0,
                exit_hit_idx: inline_n + i,
                exit_tags_for_pc: tags,
                using_global_exit_tags: false,
            },
            None => DecodedExit {
                cont_pc,
                site_id: 0,
                exit_hit_idx: inline_n + per_exit_tags.len(),
                exit_tags_for_pc: exit_tags,
                using_global_exit_tags: true,
            },
        }
    }
}

/// Compile-time options for the trace lowerer.
#[derive(Clone, Copy, Debug, Default)]
pub struct CompileOptions {
    /// When `true`, the trace's clean-close path emits a back-edge
    /// jump to its own body-loop block instead of returning
    /// `head_pc` to the caller — so the JIT'd code runs in a tight
    /// native loop until a cmp side-exit fires. The dispatcher's
    /// per-entry marshal cost amortizes across however many
    /// iterations the trace runs before diverging.
    ///
    /// Internal-loop traces require at least one exit edge
    /// (`Lt / Le / Eq` cmp or `Op::ForLoop`) AND no `Op::Call`
    /// truncation — otherwise the trace would run forever. The
    /// lowerer auto-downgrades to one-shot when neither condition
    /// holds, so callers can safely set this `true` for any
    /// record.
    ///
    /// Defaults to `false` in `try_compile_trace` (one-shot, the
    /// shape unit tests assume) and `true` in
    /// `try_compile_trace_with_options` when callers explicitly
    /// want the dispatcher fast path.
    pub internal_loop: bool,
    /// Lua dialect — `true` for 5.1 / 5.2 / 5.3, `false` for
    /// 5.4 / 5.5. The numeric `for` op (`Op::ForLoop`) has a
    /// different layout pre-5.3 (the slot at `R[A+1]` is the raw
    /// `limit` Value, not a remaining-iteration count). Step-6
    /// only lowers the 5.4+ Int count form; pre-5.3 traces bail
    /// and stay on the interp side.
    pub pre53: bool,
    /// v1.3 Phase AOT Stage 7 sub-piece 2 — emit AOT-relocatable IR.
    ///
    /// When `false` (the JIT default), interned-string-key arguments
    /// to `luna_jit_*_field` helpers are baked as
    /// `iconst(I64, key_str.as_ptr() as i64)` — the live runtime
    /// pointer, valid because the lowered mcode runs in the same
    /// process / `Vm` / `StringTable` as the recorder.
    ///
    /// When `true` (the AOT path), the lowerer instead routes each
    /// key through a writable data slot named
    /// `__luna_aot_strkey_slot_<hex>` (8 bytes, zero-initialised at
    /// link time) and emits a load through that symbol. A sibling
    /// read-only object `__luna_aot_strkey_bytes_<hex>` carries the
    /// UTF-8 bytes; the deploy-side startup hook walks every
    /// `_bytes_<hex>` symbol, interns the bytes into the deploy
    /// `Vm`'s `StringTable`, and writes the resulting
    /// `Gc<LuaStr>::as_ptr()` into the matching `_slot_<hex>` before
    /// any AOT trace dispatches. JIT path is unaffected — same
    /// `iconst` it always emitted.
    ///
    /// See `.dev/rfcs/v1.3-rfc-trace-aot-relocation.md` for the full
    /// scheme; sub-piece 2 (this flag) lands the codegen half — the
    /// deploy-side resolver is sub-piece 3.
    pub aot: bool,
}
