//! Pulse binding SIGNATURES — recovered from the accessor's own initializer, offline.
//!
//! [`valvetab`](crate::valvetab) reads the Pulse registry and gets a fully-qualified name, Valve's own
//! documentation strings, a call policy — and two code pointers that are NOT the bound function. This
//! module reads what those two pointers actually are, and they turn out to be the thing the registry was
//! missing: **the typed signature**.
//!
//! Each is an accessor for a function-local `static` holding a `{count, elements}` vector by value, so
//! the fast path is `rax = count | (capacity << 32); rdx = &elements; ret`. `+24` returns the ARGUMENT
//! list and `+32` the RETURN list — measured, not assumed: `CBaseEntityAPI::GetAbsOrigin` yields
//! `_Target: PVAL_EHANDLE` and `retval: PVAL_VEC3_WORLDSPACE`, and `CLightEntityAPI::SetLightColor`
//! yields `_Target: PVAL_EHANDLE, param: PVAL_COLOR_RGB` against an EMPTY return list. Pulse is a typed
//! graph VM, so a binding cannot be registered without this; it is the one prototype source in the
//! project that is neither 2018-era nor transferred from another game.
//!
//! **The elements are built at runtime, so they are zero in the file — but the code that builds them is
//! not.** Every field is written to a fixed RIP-relative address from a `lea` or an immediate, so a
//! constant-propagation pass over the initializer reconstructs the record without a running process.
//! That matters beyond convenience: it makes typed signatures available to an OFFLINE derive, and it
//! avoids calling 580 functions in a live process to read data the binary already states.
//!
//! Shape-driven like the table readers: an element is accepted only when its name pointer resolves to a
//! plausible identifier in non-executable memory AND its type is a value `PulseValueType_t` actually
//! declares. A layout change yields FEWER signatures, never wrong ones, and the profile floor turns
//! "fewer" into a failed release.

// Registers whose value a call destroys. The ONE list in `abi`, not a second copy of it — both loops
// below that invalidate across a call read it directly.
use crate::abi::CALLER_SAVED;
use crate::elf::CodeImage;
use iced_x86::{Decoder, DecoderOptions, FlowControl, Instruction, Mnemonic, OpKind, Register};
use std::collections::{BTreeMap, HashMap, HashSet};

/// Highest `PulseValueType_t` enumerator (`PVAL_COUNT`) plus headroom for a build that adds a few. The
/// enum is schema-registered, so the DERIVED values are what a caller should validate against — this is
/// only the gate that keeps a stale register from being read as a type.
const MAX_PVAL: i64 = 64;

/// Longest plausible parameter name. Names here are C++ parameter identifiers (`_Target`, `pEntity`).
const MAX_NAME: usize = 96;

/// Where the parameter NAME sits inside one element record.
const ELEM_NAME: u64 = 8;

/// Where the element records its type's DESTRUCTOR — a code pointer that is per concrete type rather
/// than per binding, and therefore the only thing in the binary that separates one `PVAL_EHANDLE`'s
/// entity class from another's. Measured: 990 stores across 80 distinct targets on CS2, each target
/// used by exactly one `PulseValueType_t`, and 74 of the 80 are a bare `ret` (the trivially-destructible
/// case). See [`PulseParam::type_token`].
const ELEM_DTOR: u64 = 0x60;

/// How far past the accessor's entry the decoder will follow. These are tiny functions — the largest
/// observed initializer is under 2 KB — so this only bounds a runaway walk into neighbouring code.
const MAX_SPAN: usize = 32 * 1024;

pub use crate::model::PulseParam;

/// A binding's full signature: what it takes and what it gives back.
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct PulseSignature {
    pub args: Vec<PulseParam>,
    /// Pulse models returns as named out-parameters, so this is a LIST — usually one `retval`, empty for
    /// a void binding, and occasionally several.
    pub returns: Vec<PulseParam>,
}

/// One call the initializer makes, with whatever the pass could establish about its arguments.
struct Call {
    /// The direct target, or `None` for an indirect call.
    target: Option<u64>,
    rdi: Option<u64>,
    rsi: Option<u64>,
    rdx: Option<u64>,
}

/// What a constant-propagation pass could establish about the initializer.
#[derive(Default)]
struct Trace {
    /// Absolute address -> the pointer or immediate stored there.
    writes: BTreeMap<u64, u64>,
    /// Call sites, each with the target and its argument registers. A register is present only when it
    /// was established since the PREVIOUS call, which is what makes `rsi` the type argument of this call
    /// rather than a leftover from earlier in the initializer.
    calls: Vec<Call>,
    /// `(count, elements)` as the accessor returns them on its already-initialised path.
    ret: Option<(u64, u64)>,
}

fn full(r: Register) -> Register {
    if r.is_gpr() { r.full_register() } else { r }
}

/// Every instruction address reachable from `entry` inside `[entry, entry+code.len())`, in ADDRESS order.
///
/// One walk, two callers: the descriptor trace and the shim's liveness read need exactly the same thing —
/// flow-reachable addresses rather than a linear sweep, so a jump table or an interleaved neighbour cannot
/// contribute instructions the function never executes. They differ only in how far they are willing to
/// walk, which is the `cap`.
///
/// `cap` bounds the SET, not the span: a crafted image can present a small span with pathological branch
/// density, and this is on the fuzz surface.
fn reachable(code: &[u8], entry: u64, cap: usize) -> Vec<u64> {
    let end = entry.saturating_add(code.len() as u64);
    let mut seen: HashSet<u64> = HashSet::new();
    let mut work = vec![entry];
    let mut insn = Instruction::default();
    while let Some(at) = work.pop() {
        if at < entry || at >= end || seen.contains(&at) || seen.len() > cap {
            continue;
        }
        let mut dec =
            Decoder::with_ip(64, &code[(at - entry) as usize..], at, DecoderOptions::NONE);
        if !dec.can_decode() {
            continue;
        }
        dec.decode_out(&mut insn);
        if insn.is_invalid() || insn.len() == 0 {
            continue;
        }
        seen.insert(at);
        match insn.flow_control() {
            FlowControl::Return
            | FlowControl::IndirectBranch
            | FlowControl::Exception
            | FlowControl::Interrupt => {}
            FlowControl::UnconditionalBranch => work.push(insn.near_branch_target()),
            FlowControl::ConditionalBranch => {
                work.push(at + insn.len() as u64);
                work.push(insn.near_branch_target());
            }
            _ => work.push(at + insn.len() as u64),
        }
    }
    let mut addrs: Vec<u64> = seen.into_iter().collect();
    addrs.sort_unstable();
    addrs
}

/// Constant-propagate through the accessor, recording every fixed-address store, every call's argument
/// registers, and the vector the fast path returns.
///
/// Deliberately a single ADDRESS-ORDER pass rather than a CFG walk: the guard-protected initializer is
/// straight-line, and a pass that only ever believes values it computed itself cannot invent one. Every
/// instruction it does not model invalidates what it writes.
fn trace(img: &CodeImage, entry: u64, seed_rdi: Option<u64>) -> Option<Trace> {
    let all = img.code_at(entry)?;
    let code = &all[..all.len().min(MAX_SPAN)];
    let mut insn = Instruction::default();
    let addrs = reachable(code, entry, 4000);

    let mut out = Trace::default();
    let mut regs: HashMap<Register, u64> = HashMap::new();
    // Tracing a callee with its incoming receiver known is what lets an OUTLINED constructor be read:
    // the compiler hoists `make an EHANDLE type` into its own function, so the type immediate is inside
    // the callee rather than at the call site.
    if let Some(v) = seed_rdi {
        regs.insert(Register::RDI, v);
    }
    for at in addrs {
        let mut dec =
            Decoder::with_ip(64, &code[(at - entry) as usize..], at, DecoderOptions::NONE);
        dec.decode_out(&mut insn);

        // A store to a fixed address: the only way a field of the static record is written.
        if insn.mnemonic() == Mnemonic::Mov
            && insn.op0_kind() == OpKind::Memory
            && insn.is_ip_rel_memory_operand()
        {
            let dst = insn.ip_rel_memory_address();
            let val = match insn.op1_kind() {
                OpKind::Register => regs.get(&full(insn.op1_register())).copied(),
                OpKind::Immediate32to64 | OpKind::Immediate32 | OpKind::Immediate64 => {
                    Some(insn.immediate(1))
                }
                _ => None,
            };
            if let Some(v) = val {
                out.writes.insert(dst, v);
            }
        }

        match insn.flow_control() {
            FlowControl::Call | FlowControl::IndirectCall => {
                out.calls.push(Call {
                    target: (insn.flow_control() == FlowControl::Call)
                        .then(|| insn.near_branch_target()),
                    rdi: regs.get(&Register::RDI).copied(),
                    rsi: regs.get(&Register::RSI).copied(),
                    rdx: regs.get(&Register::RDX).copied(),
                });
                for r in CALLER_SAVED {
                    regs.remove(&r);
                }
                continue;
            }
            // The already-initialised path returns the vector. The FIRST return reached in address
            // order is that path: the guard test falls through to it and jumps away to the builder.
            FlowControl::Return => {
                if out.ret.is_none() {
                    // The count must have been ESTABLISHED, never defaulted. Substituting 0 for an RAX
                    // this pass could not evaluate turns "the accessor was not readable" into an
                    // affirmative "this binding takes nothing" — a claim, not a gap, and the exact
                    // failure the project's "degrades or stops loudly, never lies" rule forbids. An
                    // accessor that genuinely returns an empty vector zeroes RAX with `xor eax, eax`,
                    // which IS modelled, so honesty here costs no real signature.
                    out.ret = regs.get(&Register::RAX).map(|&rax| {
                        (
                            rax & 0xffff_ffff,
                            regs.get(&Register::RDX).copied().unwrap_or(0),
                        )
                    });
                }
                continue;
            }
            _ => {}
        }

        // Everything below is the modelled arithmetic. An unmodelled write invalidates its destination,
        // so a value is only ever believed when this pass computed it.
        let dst = if insn.op_count() > 0 && insn.op0_kind() == OpKind::Register {
            Some(full(insn.op0_register()))
        } else {
            None
        };
        let src = (insn.op_count() > 1 && insn.op1_kind() == OpKind::Register)
            .then(|| full(insn.op1_register()));
        let imm = matches!(
            insn.op1_kind(),
            OpKind::Immediate8
                | OpKind::Immediate8to32
                | OpKind::Immediate8to64
                | OpKind::Immediate32
                | OpKind::Immediate32to64
                | OpKind::Immediate64
        )
        .then(|| insn.immediate(1));

        let Some(d) = dst else { continue };
        // Only 32- and 64-bit destinations are modelled. A byte or word write leaves the rest of the
        // register alone, so treating it as the register's whole value would invent one — and these
        // initializers do write bytes (`mov BYTE PTR [rbp-0x11], 0`). Narrow writes fall through to the
        // invalidation below, which is the safe direction.
        if insn.op0_register().size() < 4 {
            regs.remove(&d);
            continue;
        }
        // A 32-bit write zeroes the upper half, which is exactly how `mov esi, 0xd` reaches RSI.
        let mask = if insn.op0_register().size() == 4 {
            0xffff_ffff
        } else {
            u64::MAX
        };
        let value = match insn.mnemonic() {
            Mnemonic::Lea if insn.is_ip_rel_memory_operand() => Some(insn.ip_rel_memory_address()),
            // `lea reg, [base + disp]` — how the initializer walks from one element to the next.
            Mnemonic::Lea if insn.memory_index() == Register::None => regs
                .get(&full(insn.memory_base()))
                .map(|b| b.wrapping_add(insn.memory_displacement64())),
            Mnemonic::Mov => match (src, imm) {
                (Some(s), _) => regs.get(&s).copied(),
                (None, Some(i)) => Some(i),
                _ => None,
            },
            // `xor r, r` is the idiomatic zero; a xor of two different registers is not modelled.
            Mnemonic::Xor if src == Some(d) => Some(0),
            // How the initializer walks from one element to the next when the compiler advances a
            // pointer rather than emitting a fresh `lea` — without this, every element after the first
            // loses its address and the whole binding drops.
            Mnemonic::Add => match (src, imm) {
                (Some(s), _) => regs
                    .get(&d)
                    .zip(regs.get(&s))
                    .map(|(a, b)| a.wrapping_add(*b)),
                (None, Some(i)) => regs.get(&d).map(|a| a.wrapping_add(i)),
                _ => None,
            },
            Mnemonic::Sub => match (src, imm) {
                (Some(s), _) => regs
                    .get(&d)
                    .zip(regs.get(&s))
                    .map(|(a, b)| a.wrapping_sub(*b)),
                (None, Some(i)) => regs.get(&d).map(|a| a.wrapping_sub(i)),
                _ => None,
            },
            Mnemonic::And => match (src, imm) {
                (Some(s), _) => regs.get(&d).zip(regs.get(&s)).map(|(a, b)| a & b),
                (None, Some(i)) => regs.get(&d).map(|a| a & i),
                _ => None,
            },
            Mnemonic::Or => match (src, imm) {
                (Some(s), _) => regs.get(&d).zip(regs.get(&s)).map(|(a, b)| a | b),
                (None, Some(i)) => regs.get(&d).map(|a| a | i),
                _ => None,
            },
            _ => None,
        };
        match value {
            Some(v) => {
                regs.insert(d, v & mask);
            }
            None => {
                regs.remove(&d);
            }
        }
    }
    Some(out)
}

/// Is `s` shaped like a C++ parameter name? The gate that separates the record's name slot from every
/// other pointer the initializer stores.
fn is_param_name(s: &str) -> bool {
    !s.is_empty()
        && s.len() <= MAX_NAME
        && s.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_')
        && s.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'_')
}

/// What one accessor's record region says, before the element stride is known.
struct Record {
    t: Trace,
    count: u64,
    base: u64,
    /// Every store of a plausible identifier inside the region. Some are element names; some are not —
    /// Dota's `CPulseCursorFuncs::TagCursor` stores an `Ed1` at one element's `+0x28`.
    named: BTreeMap<u64, String>,
}

fn record(img: &CodeImage, accessor: u64) -> Option<Record> {
    let t = trace(img, accessor, None)?;
    let (count, base) = t.ret?;
    if count > 32 || (count > 0 && base == 0) {
        return None;
    }
    let named = t
        .writes
        .iter()
        .filter(|&(a, _)| *a >= base)
        .filter_map(|(&a, &p)| {
            (!img.is_code(p))
                .then(|| img.read_c_string(p))
                .flatten()
                .filter(|s| is_param_name(s))
                .map(|s| (a, s))
        })
        .collect();
    Some(Record {
        t,
        count,
        base,
        named,
    })
}

/// The spacings at which this record's `count` names could sit, given that element 0's name is at
/// `base + 8` and the array is contiguous. Usually one; a record carrying a second identifier-shaped
/// string of its own offers more, which is why the stride is settled per IMAGE and not per record.
fn candidate_strides(r: &Record) -> Vec<u64> {
    let Some(first) = r.base.checked_add(8) else {
        return Vec::new();
    };
    r.named
        .keys()
        .filter(|&&a| a > first)
        .map(|&a| a - first)
        // Checked throughout: a crafted image can place a "name" anywhere, so `k * s` is a value the
        // FILE controls and must not be allowed to wrap into a plausible address.
        .filter(|&s| {
            (0..r.count).all(|k| {
                k.checked_mul(s)
                    .and_then(|o| first.checked_add(o))
                    .is_some_and(|a| r.named.contains_key(&a))
            })
        })
        .collect()
}

/// Interpret a record at a known stride.
///
/// Returns `None` when it does not hold together — an element short of a name or a type drops the WHOLE
/// list rather than shipping a partial signature, because a signature missing an argument is worse than
/// no signature at all.
fn params_at(img: &CodeImage, r: &Record, stride: u64) -> Option<Vec<PulseParam>> {
    // With no stride every element would resolve to element 0, which reads as N copies of the first
    // parameter rather than as a failure. A single-element list needs no stride and is unaffected.
    if r.count > 1 && stride == 0 {
        return None;
    }
    // `base` is whatever the accessor's RDX constant-propagates to — a value the FILE controls, gated
    // only against zero. Every step from it is checked, including this first one: an unchecked `+ 8`
    // aborts the overflow-checked build the fuzz harness uses, and `fuzz_pulse` promises fewer
    // signatures rather than a panic.
    let first = r.base.checked_add(8)?;
    (0..r.count)
        .map(|k| {
            let at = k.checked_mul(stride).and_then(|o| first.checked_add(o))?;
            let (ty, type_name) = type_at(img, &r.t, at.checked_add(8)?)?;
            Some(PulseParam {
                name: r.named.get(&at)?.clone(),
                ty,
                type_name,
                entity_class: None,
                // The element's destructor slot. `at` is the NAME, which sits at `ELEM_NAME` into the
                // element, so the element base is `at - ELEM_NAME` and the token `ELEM_DTOR` beyond it.
                // Absent rather than zero if the initializer never wrote one — a token of 0 would group
                // every unwritten parameter together, which is the opposite of what it is for.
                type_token: at
                    .checked_sub(ELEM_NAME)
                    .and_then(|e| e.checked_add(ELEM_DTOR))
                    .and_then(|a| r.t.writes.get(&a).copied())
                    .filter(|&p| img.is_code(p))
                    .unwrap_or(0),
            })
        })
        .collect()
}

/// Every binding's typed signature, read from the accessor pairs `(args, returns)`.
///
/// The element stride is DERIVED once per image and then applied strictly, rather than re-guessed per
/// record. It has to be: the record size is a property of the type, so one image has one answer, while an
/// individual record can be ambiguous about it — a two-element list plus one stray identifier admits two
/// spacings, and picking the smaller by fiat would read a stray as a parameter name. Taking the value the
/// unambiguous records agree on settles those.
///
/// The winner is the PLURALITY, not a unanimity: requiring every record to agree would let one malformed
/// accessor veto an entire library. That is safe in the direction that matters — a record whose own names
/// do not sit at the winning spacing fails its gate and drops — but it does mean the vote spread is worth
/// looking at, so it is returned and reported rather than reduced to a single number.
///
/// Returns `(signatures, stride, votes, rivals)`, where `rivals` is the number of records that voted for
/// some OTHER spacing. Anything but zero there means the image is not speaking with one voice.
pub fn read_all(
    img: &CodeImage,
    accessors: &[(u64, u64)],
    threads: usize,
) -> (Vec<Option<PulseSignature>>, u64, usize, usize) {
    let flat: Vec<u64> = accessors.iter().flat_map(|&(a, b)| [a, b]).collect();
    let recs = crate::par::parallel_map(&flat, threads, |&a| record(img, a));

    let mut votes: HashMap<u64, usize> = HashMap::new();
    for r in recs.iter().flatten().filter(|r| r.count > 1) {
        if let [s] = candidate_strides(r).as_slice() {
            *votes.entry(*s).or_default() += 1;
        }
    }
    // No record answered unambiguously — a library of single-parameter bindings, or a layout that moved.
    // Stride 0 is honest about that: the single-element lists still read, and every longer one fails.
    //
    // A tie is broken by the SMALLEST stride, and the tie-break is not cosmetic: `max_by_key` over a
    // HashMap would otherwise pick by hash order, which makes the emitted artifact depend on iteration
    // order rather than on the binary. This project's artifacts are byte-reproducible; a coin flip here
    // would quietly end that.
    let (stride, won) = votes
        .iter()
        .max_by_key(|&(s, n)| (*n, std::cmp::Reverse(*s)))
        .map_or((0, 0), |(&s, &n)| (s, n));
    let rivals: usize = votes
        .iter()
        .filter(|&(&s, _)| s != stride)
        .map(|(_, n)| n)
        .sum();

    let sigs = accessors
        .iter()
        .enumerate()
        .map(|(i, _)| {
            let args = recs[2 * i].as_ref()?;
            let returns = recs[2 * i + 1].as_ref()?;
            Some(PulseSignature {
                args: params_at(img, args, stride)?,
                returns: params_at(img, returns, stride)?,
            })
        })
        .collect();
    (sigs, stride, won, rivals)
}

/// The `PulseValueType_t` a `CPulseValueFullType` at `obj` is set to, with the schema type it names.
///
/// The type is passed in ESI to a setter called on the object. `rsi` is only believed when it was
/// established since the previous call, which is what distinguishes the setter from the default
/// constructor invoked on the same object first.
///
/// Where the setter is not at this level the compiler has OUTLINED it — `make an EHANDLE type` becomes
/// its own function taking only `this`, so nothing at the call site carries the immediate. Following one
/// level with the receiver seeded recovers it, and 92 of 485 CS2 server bindings need exactly that. Two
/// callees disagreeing drops the parameter rather than picking, since nothing here can adjudicate.
fn type_at(img: &CodeImage, t: &Trace, obj: u64) -> Option<(i32, Option<String>)> {
    let read = |c: &Call| {
        (
            c.rsi.unwrap() as i32,
            c.rdx
                .filter(|&p| p != 0 && !img.is_code(p))
                .and_then(|p| img.read_c_string(p))
                .filter(|s| !s.is_empty() && s.len() <= MAX_NAME),
        )
    };
    let direct = t
        .calls
        .iter()
        .filter(|c| c.rdi == Some(obj) && c.rsi.is_some_and(valid_pval))
        .map(read)
        .next_back();
    if direct.is_some() {
        return direct;
    }

    let mut found: Option<(i32, Option<String>)> = None;
    for c in t
        .calls
        .iter()
        .filter(|c| c.rdi == Some(obj) && c.rsi.is_none())
    {
        let Some(inner) = c.target.and_then(|f| trace(img, f, Some(obj))) else {
            continue;
        };
        let Some(hit) = inner
            .calls
            .iter()
            .filter(|c| c.rdi == Some(obj) && c.rsi.is_some_and(valid_pval))
            .map(read)
            .next_back()
        else {
            continue;
        };
        match &found {
            Some(prev) if prev.0 != hit.0 => return None,
            _ => found = Some(hit),
        }
    }
    found
}

/// How far past a shim's entry the read-measurement will follow. `.eh_frame_hdr` covers only a fraction of
/// these images' functions and none of the shims, so there is no exact extent available; flow-following ends
/// at every `ret` regardless, so this only bounds a runaway path.
const SHIM_SPAN: u64 = 0x1000;

/// The seven integer arguments a Pulse invocation shim takes, in SysV order. The seventh is the first
/// STACK slot — measured, and the reason the shim's arity cannot be read off `abi_shape`, whose backward
/// liveness stops at the registers.
const SHIM_SLOTS: [Register; 6] = [
    Register::RDI,
    Register::RSI,
    Register::RDX,
    Register::RCX,
    Register::R8,
    Register::R9,
];

/// What an invocation shim was measured to read, and therefore what a caller has to supply.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ShimReads {
    /// The argument slots actually read, named — `rcx`, `r8`, `stack0`. The argument array (`r8`) is
    /// stated here and nowhere else: reading it is the ordinary case and constrains a caller in no way,
    /// so it needs no flag of its own beside the three that do.
    pub reads: Vec<&'static str>,
    /// Does it read the output sink (the first stack slot)? True for exactly the bindings that declare a
    /// return, measured across both games with no exceptions.
    pub sink: bool,
    /// Does it read the Pulse host-service context (`rcx`)? That object is VM-owned, so a host cannot
    /// supply one.
    pub context: bool,
    /// Does it read any OTHER slot — `rdi`, `rsi`, `rdx`, `r9`? These are the slots a caller would
    /// otherwise pass as null, so any read here means it cannot.
    pub other: bool,
}

impl ShimReads {
    /// What a host must supply, as the artifact states it.
    ///
    /// `args-only` is the one that matters: everything such a shim reads is either the argument array a
    /// caller builds or the game's own entity list, so the remaining slots may be null. That is not a
    /// deduction — it was validated by calling every eligible binding in both games with a sentinel handle
    /// (CS2 186 of 193 clean, Dota 211 of 211), and the exceptions are exactly the shims this reports as
    /// reading another slot.
    pub fn needs(&self) -> &'static str {
        if self.context {
            "pulse-context"
        } else if self.other {
            "other-slots"
        } else if self.sink {
            "output-sink"
        } else {
            "args-only"
        }
    }
}

/// Where an accessor's descriptor region LIVES, and how many elements it holds: `(base, count)`.
///
/// The signature reader reconstructs the region's CONTENTS by constant-propagating the initialiser,
/// because on disk the elements are zeroes — they are written at runtime. That reconstruction is the
/// only offline route, and it is also unverified: the multi-library duplicate check reports that CS2
/// disagrees with itself on 331 of 419 repeat registrations, and nothing offline can say which account
/// is right.
///
/// A running server can. The region is a plain static, so at `slide + base` a live process holds the
/// POPULATED elements, and reading them settles the question against the same build rather than against
/// a dump of a different one. This accessor exists for that oracle; the derivation itself never needs it.
pub fn record_region(img: &CodeImage, accessor: u64) -> Option<(u64, u64)> {
    let r = record(img, accessor)?;
    (r.base != 0).then_some((r.base, r.count))
}

/// What a read of one argument slot demands of a HOST caller.
///
/// The argument array (`r8`) demands nothing — the caller builds it, so reading it is the ordinary case
/// and `reads` already states it. The Pulse context (`rcx`) is VM-owned and cannot be supplied at all.
/// Everything else is a slot the caller would otherwise pass null.
///
/// A named arm rather than a fall-through for `r8` specifically: dropping it into the `_` catch-all would
/// mark every ordinary binding as needing a slot no host can fill, retiring the entire `args-only`
/// callable tier — a collapse that reads as "this build has no callable bindings", which is a legitimate
/// answer for a game and therefore invisible.
fn slot_need(r: Register, out: &mut ShimReads) {
    match r {
        Register::RCX => out.context = true,
        Register::R8 => {}
        _ => out.other = true,
    }
}

/// Measure which of a shim's seven arguments it reads.
///
/// Reachable instructions in ADDRESS order, which needs two guards that cost real time to find:
///
/// * `push`/`pop` must NOT update the alias map. The compiler lays the epilogue out BEFORE the
///   found-path block, so `pop r13` sits at a lower address than the `mov rax,[r13+0x10]` that reads the
///   second argument through a stashed `mov r13, r8` — and letting the pop clear the alias loses the read.
///   The same shape cost the ConCommand reader an epoch counter.
/// * `xor r, r` / `sub r, r` name the register in BOTH operands and read neither. Counted, they mark an
///   argument live that the shim never consumes; `xor edi, edi` alone accounted for 143 false positives.
pub fn shim_reads(img: &CodeImage, entry: u64) -> Option<ShimReads> {
    let all = img.code_at(entry)?;
    let code = &all[..(all.len() as u64).min(SHIM_SPAN) as usize];
    let mut insn = Instruction::default();
    let addrs = reachable(code, entry, 20000);

    let mut live: BTreeMap<Register, bool> = BTreeMap::new();
    let mut sink = false;
    let mut fresh: Vec<Register> = SHIM_SLOTS.to_vec();

    for at in addrs {
        let mut dec =
            Decoder::with_ip(64, &code[(at - entry) as usize..], at, DecoderOptions::NONE);
        dec.decode_out(&mut insn);

        // The first stack slot is the output sink. Only `[rbp+0x10]` is ever read — no shim in either
        // game touches a second — which is what pins the arity at seven.
        //
        // The displacement MUST be read as signed. `memory_displacement64` is unsigned, so a local at
        // `[rbp-0x10]` comes back as `0xffff_ffff_ffff_fff0`, which passes an unsigned `>= 0x10` — and
        // every shim with a stack local then looks as though it reads the output sink. That mistake
        // reported 246 sink-readers against a true 201 and hid two bindings whose callability had already
        // been demonstrated by a live call.
        if (insn.op0_kind() == OpKind::Memory || insn.op1_kind() == OpKind::Memory)
            && insn.memory_base() == Register::RBP
            && insn.memory_index() == Register::None
            && insn.memory_displacement64() as i64 >= 0x10
        {
            sink = true;
        }
        let zeroing = matches!(insn.mnemonic(), Mnemonic::Xor | Mnemonic::Sub)
            && insn.op0_kind() == OpKind::Register
            && insn.op1_kind() == OpKind::Register
            && insn.op0_register().full_register() == insn.op1_register().full_register();
        // A register named inside a MEMORY operand is read even though it is not a register operand.
        if !zeroing {
            for r in [insn.memory_base(), insn.memory_index()] {
                if r != Register::None && r != Register::RIP && fresh.contains(&r.full_register()) {
                    live.insert(r.full_register(), true);
                }
            }
            for i in 0..insn.op_count() {
                if insn.op_kind(i) != OpKind::Register {
                    continue;
                }
                let pure_dst = i == 0
                    && matches!(
                        insn.mnemonic(),
                        Mnemonic::Mov | Mnemonic::Lea | Mnemonic::Movzx | Mnemonic::Movsxd
                    );
                let r = insn.op_register(i).full_register();
                if !pure_dst && fresh.contains(&r) {
                    live.insert(r, true);
                }
            }
        }

        if insn.op_count() > 0
            && insn.op0_kind() == OpKind::Register
            && !matches!(insn.mnemonic(), Mnemonic::Push | Mnemonic::Pop)
        {
            let d = insn.op0_register().full_register();
            fresh.retain(|&r| r != d);
        }
        if insn.flow_control() == FlowControl::Call {
            for r in CALLER_SAVED {
                fresh.retain(|&x| x != r);
            }
        }
    }

    let mut out = ShimReads {
        sink,
        ..Default::default()
    };
    for (r, name) in SHIM_SLOTS
        .iter()
        .zip(["rdi", "rsi", "rdx", "rcx", "r8", "r9"])
    {
        if live.contains_key(r) {
            out.reads.push(name);
            slot_need(*r, &mut out);
        }
    }
    if sink {
        out.reads.push("stack0");
    }
    Some(out)
}

/// A `PulseValueType_t` value, `PVAL_VOID` (-1) included.
fn valid_pval(v: u64) -> bool {
    let s = v as i64;
    (-1..=MAX_PVAL).contains(&s) || (v as i32) == -1
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_parameter_name_is_an_identifier_and_nothing_else() {
        assert!(is_param_name("_Target"));
        assert!(is_param_name("retval"));
        assert!(is_param_name("pEntity2"));
        // The record's other string slots: a description is prose, and a display name has spaces.
        assert!(!is_param_name("The entity origin (absolute)."));
        assert!(!is_param_name("Get Abs Origin"));
        assert!(!is_param_name(""));
        assert!(!is_param_name("9lives"));
    }

    fn rec(count: u64, base: u64, at: &[(u64, &str)]) -> Record {
        Record {
            t: Trace::default(),
            count,
            base,
            named: at.iter().map(|&(a, n)| (base + a, n.to_string())).collect(),
        }
    }

    #[test]
    fn a_stray_string_inside_a_record_offers_a_second_spacing() {
        // Dota's `CPulseCursorFuncs::TagCursor`: two parameters at the real stride, plus an identifier
        // the record stores for its own reasons. Both 0x70 and 0xC0 place a name at every element, so
        // the record ALONE cannot say which is the stride — which is why the answer is taken from the
        // image, where the unambiguous records agree.
        let r = rec(
            2,
            0x1000,
            &[(8, "pTagName"), (0x78, "tagValue"), (0xc8, "Ed1")],
        );
        assert_eq!(candidate_strides(&r), vec![0x70, 0xc0]);
        // A record with no stray answers on its own, and that is the vote the image counts.
        let clean = rec(2, 0x2000, &[(8, "_Target"), (0x78, "param")]);
        assert_eq!(candidate_strides(&clean), vec![0x70]);
    }

    #[test]
    fn a_spacing_only_counts_when_every_element_lands_on_it() {
        // Three elements, and the run is broken: nothing places a name at all three positions, so the
        // record offers no spacing at all rather than a partial one.
        let r = rec(3, 0x1000, &[(8, "a"), (0x78, "b"), (0x200, "c")]);
        assert!(candidate_strides(&r).is_empty());
    }

    #[test]
    fn needs_reports_the_most_restrictive_requirement_a_shim_has() {
        // Precedence matters: a shim reading both the context and the sink is not "output-sink", because
        // the context is the one a host cannot supply at all. Ordering it the other way would advertise
        // a binding as merely needing a sink when it actually needs a live cursor.
        let ctx = ShimReads {
            context: true,
            sink: true,
            reads: vec!["rcx", "r8", "stack0"],
            ..Default::default()
        };
        assert_eq!(ctx.needs(), "pulse-context");
        let other = ShimReads {
            other: true,
            sink: true,
            reads: vec!["rdi", "r8", "stack0"],
            ..Default::default()
        };
        assert_eq!(other.needs(), "other-slots");
        let sink = ShimReads {
            sink: true,
            reads: vec!["r8", "stack0"],
            ..Default::default()
        };
        assert_eq!(sink.needs(), "output-sink");
        // The callable tier: the argument array and nothing else.
        let only = ShimReads {
            reads: vec!["r8"],
            ..Default::default()
        };
        assert_eq!(only.needs(), "args-only");
        // A shim reading NOTHING is still args-only — a zero-argument binding reads no array either.
        assert_eq!(ShimReads::default().needs(), "args-only");
    }

    #[test]
    fn reading_the_argument_array_leaves_a_shim_host_callable() {
        // Asserted against the shipped rule rather than a copy of it. `r8` is the argument array the
        // CALLER builds, so a read of it must impose nothing; the arm exists only to keep it out of the
        // catch-all, where it would mark every ordinary binding uncallable at once.
        let mut r8 = ShimReads::default();
        slot_need(Register::R8, &mut r8);
        assert_eq!(r8.needs(), "args-only");
        let mut rcx = ShimReads::default();
        slot_need(Register::RCX, &mut rcx);
        assert_eq!(rcx.needs(), "pulse-context");
        for r in [Register::RDI, Register::RSI, Register::RDX, Register::R9] {
            let mut o = ShimReads::default();
            slot_need(r, &mut o);
            assert_eq!(o.needs(), "other-slots", "{r:?} is a slot a host must fill");
        }
    }

    #[test]
    fn pval_void_is_negative_one_and_still_a_type() {
        assert!(valid_pval(0)); // PVAL_BOOL
        assert!(valid_pval(13)); // PVAL_EHANDLE
        assert!(valid_pval(0xffff_ffff)); // PVAL_VOID, as a 32-bit -1
        assert!(!valid_pval(0x1000));
    }
}
