//! M5.5 — `this`-flow indirect-dispatch resolution.
//!
//! M5 only resolved the canonical `lis+addi → lwz off(vt) → mtctr → bcctrl`
//! pattern (vtable address materialised statically; rare in real C++).
//! This layer closes the dominant case, where the dispatch reads through
//! the object's `vptr` field:
//!
//! ```text
//!   lwz   rVt, vptr_off(this)        ; rVt = this->vptr
//!   ...                               ; (rVt not clobbered)
//!   lwz   rFn, slot*4(rVt)            ; rFn = vtable[slot]
//!   ...                               ; (rFn / ctr not clobbered)
//!   mtctr rFn
//!   ...
//!   bcctrl
//! ```
//!
//! Resolution strategy (class-membership inference):
//!
//! 1. **Phase 1 — vptr-write scan.** Walk every function with a tiny
//!    register tracker (mirrors the lis+addi propagation in
//!    `xenia_analysis::xref`). Whenever a `stw rA, off(rB)` writes a
//!    known M3 vtable address into `off(rB)`, record
//!    `(vtable_addr, vptr_offset, writer_pc)`. These are constructor-
//!    side vptr stores.
//!
//! 2. **Phase 2 — invert by offset.** Build
//!    `vtables_by_offset[vptr_off] = set of vtables ever written at
//!    that offset`. Most classes use offset 0 (single inheritance);
//!    multiple-inheritance secondary vptrs land at non-zero offsets.
//!
//! 3. **Phase 3 — dispatch-site scan.** For each `bcctrl`, walk back
//!    up to 16 instructions looking for the canonical sequence,
//!    extracting `(vptr_off, slot)`. Bail on any clobber of the
//!    tracked register, on any branch instruction, or on a label
//!    boundary.
//!
//! 4. **Phase 4 — emit edges.** For each detected
//!    `(dispatch_pc, vptr_off, slot)`:
//!    - Look up all candidate vtables `V` where:
//!      - `vtables_by_offset[vptr_off]` contains `V`, AND
//!      - `V.length > slot`
//!    - Emit one `ind_call` edge from `dispatch_pc` to
//!      `V.methods[slot]` per candidate.
//!
//! Multi-candidate sites are an over-approximation: the analysis can't
//! distinguish without alias info which of the matching classes the
//! `this` register actually holds. Downstream queries can filter by
//! the exposed `candidate_count` column — single-candidate edges are
//! high-confidence, multi-candidate edges are reachability-only.
//!
//! ### What this layer does NOT do
//!
//! - No flow-sensitive analysis: register state is killed at every
//!   label (basic-block boundary), and we do not propagate values
//!   across calls (since the ABI's volatile/non-volatile partition is
//!   unreliable for `this`-pointer chains).
//! - No alias resolution: a multi-candidate site emits one edge per
//!   matching vtable, not the exact one used at runtime.
//! - Does not handle vptr writes via X-form indexed stores (`stwx`)
//!   or VMX/VMX128 stores — only D-form `stw rA, off(rB)`. The MSVC
//!   compiler uses D-form for all canonical vptr writes we've seen.
//! - Does not synthesise vptr writes for inlined / elided constructors.
//!   If a class never has a writer at offset `vptr_off`, dispatches
//!   through that offset will not find candidates.
//!
//! Reference: IBM PowerPC ABI, Itanium C++ ABI on vtable layout (the
//! same offset-from-`this` model applies on Win32 PPC).

use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};

use crate::func::FuncAnalysis;
use crate::vtables::Vtable;

/// One detected dispatch site after typed resolution.
#[derive(Debug, Clone)]
pub struct TypedDispatch {
    pub dispatch_pc: u32,
    pub vptr_offset: u32,
    pub slot: u32,
    /// Set of candidate vtable addresses whose `(vptr_offset, slot)` matched.
    pub candidate_vtables: Vec<u32>,
    /// Set of resolved method PCs (one per candidate vtable).
    pub method_pcs: Vec<u32>,
}

/// Result of the M5.5 pass.
#[derive(Debug, Default)]
pub struct TypedIndirectResult {
    pub dispatches: Vec<TypedDispatch>,
    /// Phase-1 raw output, exposed for diagnostics.
    pub vptr_writes: Vec<VptrWrite>,
}

/// One detected constructor-side vptr write.
#[derive(Debug, Clone, Copy)]
pub struct VptrWrite {
    pub vtable_addr: u32,
    pub vptr_offset: u32,
    pub writer_pc: u32,
    pub writer_function: u32,
}

const OP_ADDI:  u32 = 14;
const OP_ADDIS: u32 = 15;
const OP_BCCTR: u32 = 19;
const OP_LWZ:   u32 = 32;
const OP_ORI:   u32 = 24;
const OP_STW:   u32 = 36;
const OP_X_FORM: u32 = 31;

/// Run the full M5.5 analysis.
#[tracing::instrument(skip_all, fields(image_base = format_args!("{:#010x}", image_base)))]
pub fn analyze(
    pe: &[u8],
    image_base: u32,
    func_analysis: &FuncAnalysis,
    vtables: &[Vtable],
    labels: &HashMap<u32, String>,
) -> TypedIndirectResult {
    let started = std::time::Instant::now();

    let vtable_addrs: BTreeSet<u32> = vtables.iter().map(|v| v.address).collect();
    let vtable_by_addr: BTreeMap<u32, &Vtable> =
        vtables.iter().map(|v| (v.address, v)).collect();

    let block_boundaries: HashSet<u32> = labels.keys().copied().collect();

    // Phase 1: scan for vptr writes.
    let vptr_writes = scan_vptr_writes(
        pe, image_base, func_analysis, &vtable_addrs, &block_boundaries,
    );

    // Phase 2: invert by offset.
    let mut vtables_by_offset: HashMap<u32, HashSet<u32>> = HashMap::new();
    for w in &vptr_writes {
        vtables_by_offset.entry(w.vptr_offset).or_default().insert(w.vtable_addr);
    }

    // Phase 3 + 4: scan dispatches and emit edges.
    let dispatches = scan_dispatches_and_resolve(
        pe, image_base, func_analysis, &block_boundaries,
        &vtables_by_offset, &vtable_by_addr,
    );

    let elapsed_ms = started.elapsed().as_millis() as f64;
    let single_candidate = dispatches.iter().filter(|d| d.candidate_vtables.len() == 1).count();
    let multi_candidate = dispatches.len() - single_candidate;
    let total_edges: usize = dispatches.iter().map(|d| d.method_pcs.len()).sum();
    metrics::histogram!("analysis.phase_ms", "phase" => "ind_dispatch_typed").record(elapsed_ms);
    tracing::info!(
        vptr_writes = vptr_writes.len(),
        offsets = vtables_by_offset.len(),
        dispatches = dispatches.len(),
        single = single_candidate,
        multi = multi_candidate,
        edges = total_edges,
        elapsed_ms,
        "M5.5 typed indirect-dispatch scan complete",
    );

    TypedIndirectResult { dispatches, vptr_writes }
}

fn read_instr(pe: &[u8], image_base: u32, addr: u32) -> Option<u32> {
    let off = addr.wrapping_sub(image_base) as usize;
    if off + 4 > pe.len() { return None; }
    Some(u32::from_be_bytes([pe[off], pe[off + 1], pe[off + 2], pe[off + 3]]))
}

/// Phase 1 — find every `stw rA, off(rB)` where the lis+addi-tracked
/// value of `rA` equals a known vtable address.
fn scan_vptr_writes(
    pe: &[u8],
    image_base: u32,
    func_analysis: &FuncAnalysis,
    vtable_addrs: &BTreeSet<u32>,
    block_boundaries: &HashSet<u32>,
) -> Vec<VptrWrite> {
    let mut writes: Vec<VptrWrite> = Vec::new();
    for (&fn_start, fi) in &func_analysis.functions {
        if fi.is_saverestore { continue; }
        let mut reg: [Option<u32>; 32] = [None; 32];
        let mut pc = fn_start;
        while pc < fi.end {
            if pc != fn_start && block_boundaries.contains(&pc) {
                reg = [None; 32];
            }
            let Some(instr) = read_instr(pe, image_base, pc) else { break };
            let op = instr >> 26;
            let rd = ((instr >> 21) & 0x1F) as usize;
            let ra = ((instr >> 16) & 0x1F) as usize;
            let simm = ((instr & 0xFFFF) as i16) as i32;
            let uimm = instr & 0xFFFF;
            match op {
                OP_ADDIS if ra == 0 => reg[rd] = Some(uimm << 16),
                OP_ADDIS => {
                    reg[rd] = reg[ra].map(|b| b.wrapping_add(uimm << 16));
                }
                OP_ADDI if ra != 0 => {
                    reg[rd] = reg[ra].map(|b| b.wrapping_add(simm as u32));
                }
                OP_ADDI => reg[rd] = Some(simm as u32),
                OP_ORI => {
                    let rs = rd;
                    reg[ra] = reg[rs].map(|b| b | uimm);
                }
                OP_STW => {
                    // `stw rS, off(rA)` — rS in bits 21..25, rA in 16..20.
                    if ra != 0
                        && let Some(vtable_addr) = reg[rd]
                        && vtable_addrs.contains(&vtable_addr)
                    {
                        // The vptr offset is the displacement; rB's value
                        // is irrelevant for class-membership inference.
                        writes.push(VptrWrite {
                            vtable_addr,
                            vptr_offset: simm as u32,
                            writer_pc: pc,
                            writer_function: fn_start,
                        });
                    }
                    // stw doesn't write to rD.
                }
                OP_LWZ => reg[rd] = None,
                32..=35 | 40..=43 | 48..=51 => reg[rd] = None,
                OP_X_FORM => {
                    let xo = (instr >> 1) & 0x3FF;
                    if xo != 444 && xo != 467 { reg[rd] = None; }
                }
                18 => {
                    // `bl` (LK=1) clobbers volatile r0..r12 + ctr. Plain
                    // `b` makes the next instruction unreachable; the
                    // label-based reset handles join points.
                    if (instr & 1) != 0 {
                        for r in 0..=12 { reg[r] = None; }
                    }
                }
                16 => {
                    if (instr & 1) != 0 {
                        for r in 0..=12 { reg[r] = None; }
                    }
                }
                _ => {}
            }
            pc = pc.wrapping_add(4);
        }
    }
    writes
}

/// Phase 3 + 4 — scan every `bcctrl`/`bctr` instruction; for each, walk
/// backward up to 16 instructions to find the canonical
/// `lwz vt, vptr_off(this); lwz fn, slot(vt); mtctr fn; bcctrl` sequence.
/// Emit one `TypedDispatch` per dispatch site that resolves to ≥ 1
/// candidate vtable.
fn scan_dispatches_and_resolve(
    pe: &[u8],
    image_base: u32,
    func_analysis: &FuncAnalysis,
    block_boundaries: &HashSet<u32>,
    vtables_by_offset: &HashMap<u32, HashSet<u32>>,
    vtable_by_addr: &BTreeMap<u32, &Vtable>,
) -> Vec<TypedDispatch> {
    let mut out: Vec<TypedDispatch> = Vec::new();
    for (&fn_start, fi) in &func_analysis.functions {
        if fi.is_saverestore { continue; }
        let mut pc = fn_start;
        while pc < fi.end {
            let Some(instr) = read_instr(pe, image_base, pc) else { break };
            let op = instr >> 26;
            if op == OP_BCCTR {
                let xo = (instr >> 1) & 0x3FF;
                let lk = (instr & 1) != 0;
                if xo == 528 && lk
                    && let Some(d) = try_resolve_dispatch_site(
                        pe, image_base, fn_start, fi.end, pc,
                        block_boundaries, vtables_by_offset, vtable_by_addr,
                    )
                {
                    out.push(d);
                }
            }
            pc = pc.wrapping_add(4);
        }
    }
    out
}

/// Backwards scan from `bcctrl` at `pc` (looking back at most 16 instrs
/// within the same basic block). Returns `Some(_)` only when the full
/// `lwz vt, off(rA); lwz fn, slot(vt); mtctr fn` chain is present and the
/// `(vptr_off, slot)` pair has at least one candidate vtable.
fn try_resolve_dispatch_site(
    pe: &[u8],
    image_base: u32,
    fn_start: u32,
    _fn_end: u32,
    bcctrl_pc: u32,
    block_boundaries: &HashSet<u32>,
    vtables_by_offset: &HashMap<u32, HashSet<u32>>,
    vtable_by_addr: &BTreeMap<u32, &Vtable>,
) -> Option<TypedDispatch> {
    const LOOKBACK: u32 = 16;

    // Walk back 1..LOOKBACK instrs to find `mtctr rFn`.
    let mut mtctr_rs: Option<usize> = None;
    let mut mtctr_pc: Option<u32> = None;
    for i in 1..=LOOKBACK {
        let p = bcctrl_pc.wrapping_sub(i * 4);
        if p < fn_start { break; }
        if block_boundaries.contains(&p) { break; }
        let Some(instr) = read_instr(pe, image_base, p) else { break };
        let op = instr >> 26;
        if op == OP_X_FORM {
            let xo = (instr >> 1) & 0x3FF;
            if xo == 467 {
                let spr = (((instr >> 11) & 0x1F) << 5) | ((instr >> 16) & 0x1F);
                if spr == 9 {
                    mtctr_rs = Some(((instr >> 21) & 0x1F) as usize);
                    mtctr_pc = Some(p);
                    break;
                }
            }
        }
    }
    let mtctr_rs = mtctr_rs?;
    let mtctr_pc = mtctr_pc?;

    // Walk back from mtctr to find `lwz rFn, slot(rVt)` defining mtctr_rs.
    let mut slot: Option<u32> = None;
    let mut vt_reg: Option<usize> = None;
    let mut fn_lwz_pc: Option<u32> = None;
    for i in 1..=LOOKBACK {
        let p = mtctr_pc.wrapping_sub(i * 4);
        if p < fn_start { break; }
        if block_boundaries.contains(&p) { break; }
        let Some(instr) = read_instr(pe, image_base, p) else { break };
        let op = instr >> 26;
        let rd = ((instr >> 21) & 0x1F) as usize;
        if op == OP_LWZ {
            if rd == mtctr_rs {
                let ra = ((instr >> 16) & 0x1F) as usize;
                if ra == 0 { return None; }
                let off = ((instr & 0xFFFF) as i16) as i32;
                if off < 0 || (off % 4) != 0 { return None; }
                slot = Some((off as u32) / 4);
                vt_reg = Some(ra);
                fn_lwz_pc = Some(p);
                break;
            }
            // Other lwz; if it writes our target reg, it's a clobber, but
            // the loop already keys on the lwz that produces the value, so
            // no clobber check needed beyond seeing rd == mtctr_rs.
        } else if writes_reg(instr, mtctr_rs as u32) {
            return None;
        }
    }
    let slot = slot?;
    let vt_reg = vt_reg?;
    let fn_lwz_pc = fn_lwz_pc?;

    // Walk back from the fn-lwz to find `lwz rVt, vptr_off(rThis)` defining vt_reg.
    let mut vptr_off: Option<u32> = None;
    for i in 1..=LOOKBACK {
        let p = fn_lwz_pc.wrapping_sub(i * 4);
        if p < fn_start { break; }
        if block_boundaries.contains(&p) { break; }
        let Some(instr) = read_instr(pe, image_base, p) else { break };
        let op = instr >> 26;
        let rd = ((instr >> 21) & 0x1F) as usize;
        if op == OP_LWZ && rd == vt_reg {
            let ra = ((instr >> 16) & 0x1F) as usize;
            if ra == 0 { return None; }
            let off = ((instr & 0xFFFF) as i16) as i32;
            // Negative offsets are valid in C++ (multiple inheritance casts
            // can produce them in some ABIs); reinterpret as u32 wrap.
            vptr_off = Some(off as u32);
            break;
        }
        if writes_reg(instr, vt_reg as u32) {
            return None;
        }
    }
    let vptr_off = vptr_off?;

    // Phase 4 — resolve to candidate vtables.
    let candidates = vtables_by_offset.get(&vptr_off)?;
    let mut candidate_vtables: Vec<u32> = Vec::new();
    let mut method_pcs: Vec<u32> = Vec::new();
    for &vt_addr in candidates {
        if let Some(vt) = vtable_by_addr.get(&vt_addr)
            && vt.length > slot
            && let Some(&method_pc) = vt.methods.get(slot as usize)
        {
            candidate_vtables.push(vt_addr);
            method_pcs.push(method_pc);
        }
    }
    if method_pcs.is_empty() { return None; }

    Some(TypedDispatch {
        dispatch_pc: bcctrl_pc,
        vptr_offset: vptr_off,
        slot,
        candidate_vtables,
        method_pcs,
    })
}

/// Conservative "does this instruction write to register `r`" predicate.
/// Used to detect register clobbers between the value-producing lwz and
/// its consumer.
fn writes_reg(instr: u32, r: u32) -> bool {
    let op = instr >> 26;
    let rd = (instr >> 21) & 0x1F;
    let _ra = (instr >> 16) & 0x1F;
    match op {
        // Most arithmetic / load opcodes use bits 21..25 = rD/rT.
        14 | 15 | 32..=43 | 46 | 48..=51 => rd == r,
        // ori/oris/xor/etc. opcodes 24..29 — rA in bits 16..20 is the dest.
        24 | 25 | 26 | 27 | 28 | 29 => ((instr >> 16) & 0x1F) == r,
        // X-form: most write rD; some write rA. Check both, conservatively.
        OP_X_FORM => {
            let xo = (instr >> 1) & 0x3FF;
            // Logical X-form (and/or/xor/etc.): rA is the dest.
            // Logical X-form ops (and/or/xor/etc.) write rA, not rD.
            if matches!(xo, 26 | 28 | 60 | 124 | 284 | 316 | 444 | 476 | 536 | 539 | 922 | 954) {
                ((instr >> 16) & 0x1F) == r
            } else {
                rd == r
            }
        }
        _ => false,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::func::FuncInfo;
    use std::collections::BTreeMap;

    fn mk_vtable(addr: u32, methods: Vec<u32>) -> Vtable {
        Vtable {
            address: addr,
            length: methods.len() as u32,
            col_address: None,
            class_name: format!("ANON_{addr:08X}"),
            rtti_present: false,
            base_classes_json: None,
            methods,
        }
    }

    fn mk_func_analysis(start: u32, len: u32) -> FuncAnalysis {
        let mut functions: BTreeMap<u32, FuncInfo> = BTreeMap::new();
        functions.insert(start, FuncInfo {
            start,
            end: start + len,
            frame_size: 0,
            saved_gprs: 0,
            is_leaf: false,
            is_saverestore: false,
            pdata_validated: false,
            pdata_length: None,
            has_eh: false,
        });
        FuncAnalysis { functions, save_gpr_base: None, restore_gpr_base: None, pdata_entries: Vec::new() }
    }

    fn write_be(pe: &mut [u8], at: usize, v: u32) {
        pe[at..at + 4].copy_from_slice(&v.to_be_bytes());
    }

    /// Encode a vptr-write site: `lis rN, hi(vt); addi rN, rN, lo(vt); stw rN, off(rOther)`.
    fn enc_vptr_write(pe: &mut [u8], at: usize, vt: u32, write_off: i16, dest_reg: u32) {
        let hi = (vt >> 16) as u16;
        let lo = (vt & 0xFFFF) as i16;
        let lis = (15u32 << 26) | (3 << 21) | 0 << 16 | (hi as u32);
        let addi = (14u32 << 26) | (3 << 21) | (3 << 16) | ((lo as u16) as u32);
        let stw = (36u32 << 26) | (3 << 21) | (dest_reg << 16) | ((write_off as u16) as u32);
        write_be(pe, at, lis);
        write_be(pe, at + 4, addi);
        write_be(pe, at + 8, stw);
    }

    /// Encode a dispatch site:
    ///   lwz r4, vptr_off(r3)       ; r4 = this->vptr
    ///   lwz r5, slot*4(r4)         ; r5 = vptr[slot]
    ///   mtctr r5
    ///   bcctrl
    fn enc_dispatch(pe: &mut [u8], at: usize, vptr_off: i16, slot: u32) {
        let lwz_vt = (32u32 << 26) | (4 << 21) | (3 << 16) | ((vptr_off as u16) as u32);
        let lwz_fn = (32u32 << 26) | (5 << 21) | (4 << 16) | ((slot * 4) & 0xFFFF);
        // mtctr r5 = mtspr CTR(=9), r5: SPR_low (=9) → bits 16..20.
        let mtctr = (31u32 << 26) | (5 << 21) | (9 << 16) | (467 << 1);
        let bcctrl = (19u32 << 26) | (20 << 21) | (528 << 1) | 1;
        write_be(pe, at, lwz_vt);
        write_be(pe, at + 4, lwz_fn);
        write_be(pe, at + 8, mtctr);
        write_be(pe, at + 12, bcctrl);
    }

    #[test]
    fn single_candidate_vtable_resolves_to_one_method() {
        let image_base = 0x82000000u32;
        let mut pe = vec![0u8; 0x4000];

        // Function A — constructor — at 0x82001000. Writes vt=0x82010000 at off=0.
        let ctor_pc = 0x82001000u32;
        enc_vptr_write(&mut pe, (ctor_pc - image_base) as usize, 0x82010000, 0, 31);

        // Function B — dispatcher — at 0x82002000. Calls slot 2 of vptr at off 0.
        let disp_pc = 0x82002000u32;
        enc_dispatch(&mut pe, (disp_pc - image_base) as usize, 0, 2);
        let bcctrl_pc = disp_pc + 12;

        // Both functions in func_analysis (synthesise).
        let mut fa = mk_func_analysis(ctor_pc, 0x40);
        fa.functions.insert(disp_pc, FuncInfo {
            start: disp_pc, end: disp_pc + 0x40, frame_size: 0, saved_gprs: 0,
            is_leaf: false, is_saverestore: false,
            pdata_validated: false, pdata_length: None, has_eh: false,
        });

        let vt = mk_vtable(0x82010000, vec![0xAA, 0xBB, 0xCC, 0xDD]);
        let labels: HashMap<u32, String> = HashMap::new();
        let r = analyze(&pe, image_base, &fa, &[vt], &labels);

        assert_eq!(r.vptr_writes.len(), 1);
        assert_eq!(r.vptr_writes[0].vtable_addr, 0x82010000);
        assert_eq!(r.vptr_writes[0].vptr_offset, 0);

        assert_eq!(r.dispatches.len(), 1);
        let d = &r.dispatches[0];
        assert_eq!(d.dispatch_pc, bcctrl_pc);
        assert_eq!(d.vptr_offset, 0);
        assert_eq!(d.slot, 2);
        assert_eq!(d.method_pcs, vec![0xCC]);
        assert_eq!(d.candidate_vtables, vec![0x82010000]);
    }

    #[test]
    fn multi_candidate_emits_one_edge_per_match() {
        let image_base = 0x82000000u32;
        let mut pe = vec![0u8; 0x4000];

        // Two ctors, each writing a different vtable at offset 0.
        let ctor_a = 0x82001000u32;
        enc_vptr_write(&mut pe, (ctor_a - image_base) as usize, 0x82010000, 0, 31);
        let ctor_b = 0x82001100u32;
        enc_vptr_write(&mut pe, (ctor_b - image_base) as usize, 0x82010040, 0, 31);

        // One dispatch at slot 1.
        let disp = 0x82002000u32;
        enc_dispatch(&mut pe, (disp - image_base) as usize, 0, 1);

        let mut fa = mk_func_analysis(ctor_a, 0x40);
        fa.functions.insert(ctor_b, FuncInfo {
            start: ctor_b, end: ctor_b + 0x40, frame_size: 0, saved_gprs: 0,
            is_leaf: false, is_saverestore: false,
            pdata_validated: false, pdata_length: None, has_eh: false,
        });
        fa.functions.insert(disp, FuncInfo {
            start: disp, end: disp + 0x40, frame_size: 0, saved_gprs: 0,
            is_leaf: false, is_saverestore: false,
            pdata_validated: false, pdata_length: None, has_eh: false,
        });

        let vts = vec![
            mk_vtable(0x82010000, vec![0x11, 0x22, 0x33, 0x44]),
            mk_vtable(0x82010040, vec![0x55, 0x66, 0x77, 0x88]),
        ];
        let labels: HashMap<u32, String> = HashMap::new();
        let r = analyze(&pe, image_base, &fa, &vts, &labels);

        assert_eq!(r.vptr_writes.len(), 2);
        assert_eq!(r.dispatches.len(), 1);
        let d = &r.dispatches[0];
        assert_eq!(d.candidate_vtables.len(), 2);
        assert!(d.method_pcs.contains(&0x22));
        assert!(d.method_pcs.contains(&0x66));
    }

    #[test]
    fn out_of_bounds_slot_yields_no_dispatch() {
        let image_base = 0x82000000u32;
        let mut pe = vec![0u8; 0x4000];

        let ctor = 0x82001000u32;
        enc_vptr_write(&mut pe, (ctor - image_base) as usize, 0x82010000, 0, 31);

        let disp = 0x82002000u32;
        // slot 10 — vtable only has 4 methods.
        enc_dispatch(&mut pe, (disp - image_base) as usize, 0, 10);

        let mut fa = mk_func_analysis(ctor, 0x40);
        fa.functions.insert(disp, FuncInfo {
            start: disp, end: disp + 0x40, frame_size: 0, saved_gprs: 0,
            is_leaf: false, is_saverestore: false,
            pdata_validated: false, pdata_length: None, has_eh: false,
        });

        let vt = mk_vtable(0x82010000, vec![0x11, 0x22, 0x33, 0x44]);
        let labels: HashMap<u32, String> = HashMap::new();
        let r = analyze(&pe, image_base, &fa, &[vt], &labels);
        assert_eq!(r.dispatches.len(), 0);
    }

    #[test]
    fn no_writer_at_offset_yields_no_dispatch() {
        let image_base = 0x82000000u32;
        let mut pe = vec![0u8; 0x4000];

        // ctor writes at offset 0
        let ctor = 0x82001000u32;
        enc_vptr_write(&mut pe, (ctor - image_base) as usize, 0x82010000, 0, 31);

        // dispatch reads from offset 8 — no class writes vptr there.
        let disp = 0x82002000u32;
        enc_dispatch(&mut pe, (disp - image_base) as usize, 8, 1);

        let mut fa = mk_func_analysis(ctor, 0x40);
        fa.functions.insert(disp, FuncInfo {
            start: disp, end: disp + 0x40, frame_size: 0, saved_gprs: 0,
            is_leaf: false, is_saverestore: false,
            pdata_validated: false, pdata_length: None, has_eh: false,
        });

        let vt = mk_vtable(0x82010000, vec![0x11, 0x22, 0x33, 0x44]);
        let labels: HashMap<u32, String> = HashMap::new();
        let r = analyze(&pe, image_base, &fa, &[vt], &labels);
        assert_eq!(r.dispatches.len(), 0);
    }
}
