Skip to main content

callgraph_verify/
disasm.rs

1//! Recursive-descent function disassembly over real PE bytes (iced-x86).
2//!
3//! Given a function entry VA, we decode instructions and follow the function's
4//! *intra-function* control flow (conditional branches + their fallthrough,
5//! unconditional `JMP rel`) until every reachable path terminates at a `RET` /
6//! interrupt / undecodable byte. Along the way we harvest, purely from the
7//! decoded operands, every fact that lets us catch a rebuild hallucination:
8//!
9//!   * `direct_calls`  — targets of `CALL rel16/rel32` (the call graph proper)
10//!   * `tail_calls`    — `JMP rel` leaving the body (a tail call into another fn)
11//!   * `indirect_calls`/`indirect_jumps` — counts of register/memory dispatch
12//!   * `vcall_slots`   — displacements of `CALL/JMP [reg+0xNN]` (vtable slots)
13//!   * `iat_calls`     — absolute targets of `CALL [0x00a28xxx]` (IAT imports)
14//!   * `field_offsets` — displacements of `[reg+0xNN]` accesses (struct fields),
15//!     excluding ESP/EBP (those are stack locals/args)
16//!   * `globals`       — absolute data addresses referenced as `[0x00bXXXXX]`
17//!   * `imm_addrs`     — in-image immediates (`MOV [esi],0x00a7e38c` vtable VAs,
18//!     function pointers, string-literal addresses)
19//!
20//! This is *structural* ground truth: no decompiler in the loop.
21
22use crate::pe::Pe;
23use iced_x86::{Decoder, DecoderOptions, FlowControl, Instruction, Mnemonic, OpKind, Register};
24use std::collections::{BTreeMap, BTreeSet, HashMap};
25use std::sync::{Arc, Mutex, OnceLock};
26
27type AnalyzeCacheKey = (u64, u64, bool);
28type AnalyzeCache = Mutex<HashMap<AnalyzeCacheKey, Arc<Result<CallGraph, String>>>>;
29
30/// Upper bound on how far past the entry we follow control flow. Real-world
31/// largest functions are a few KB; 64 KB stops a mis-decoded `JMP` from walking
32/// the whole section.
33const MAX_FN_SPAN: u64 = 0x10000;
34/// `[reg+disp]` with disp below this is treated as a struct-field access; larger
35/// displacements are almost certainly absolute addresses or noise.
36const MAX_FIELD_OFFSET: u64 = 0x8000;
37
38/// The recovered "kind" of a single call argument, harvested from the instruction that set it
39/// up (a register load or a `PUSH`). Coarse on purpose — only the distinctions a faithful port
40/// must preserve, and only those a short local scan can recover without false positives.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum ArgKind {
43    /// An immediate constant (`PUSH 0x10`, `MOV ECX,0`, `XOR ECX,ECX`).
44    Imm(u64),
45    /// A struct-field load `[reg+disp]` off a non-stack base (`PUSH [ESI+0xc]`).
46    Field(u64),
47    /// An absolute in-image data reference `[0x00bXXXXX]` (a global).
48    Global(u64),
49    /// Address of a stack local (`LEA ECX,[EBP-8]` / `LEA EAX,[ESP+4]`) — an out-param / temp.
50    LocalAddr,
51    /// Anything not confidently classified (a register value, computed expression, prior result).
52    Other,
53}
54
55/// One direct call site with the argument set-up recovered by a short backward scan of its basic
56/// block. `ecx`/`edx` are the fastcall/thiscall register args (the receiver lands in `ecx`); `stack`
57/// is the pushed args in source (left-to-right) order. `None`/empty means "not recovered in-block".
58#[derive(Debug, Clone, Default)]
59pub struct CallSite {
60    pub target: u64,
61    pub ecx: Option<ArgKind>,
62    pub edx: Option<ArgKind>,
63    pub stack: Vec<ArgKind>,
64}
65
66#[derive(Debug, Default, Clone)]
67pub struct CallGraph {
68    pub entry: u64,
69    /// Direct `CALL` targets → number of call sites hitting that target.
70    pub direct_calls: BTreeMap<u64, u32>,
71    /// Per-call-site recovered argument shapes (one entry per direct `CALL` instruction).
72    pub call_sites: Vec<CallSite>,
73    /// Direct `JMP rel` targets that leave the walked body (tail calls).
74    pub tail_calls: BTreeSet<u64>,
75    pub indirect_calls: u32,
76    pub indirect_jumps: u32,
77    /// vtable slot displacements seen in `CALL/JMP [reg+0xNN]`.
78    pub vcall_slots: BTreeSet<u64>,
79    /// IAT import slot addresses seen in `CALL [0x00a28xxx]`.
80    pub iat_calls: BTreeSet<u64>,
81    /// Struct-field displacements off a non-stack base register.
82    pub field_offsets: BTreeSet<u64>,
83    /// Absolute data addresses referenced as memory operands (globals).
84    pub globals: BTreeSet<u64>,
85    /// In-image immediate values (vtable VAs, fn pointers, string addresses).
86    pub imm_addrs: BTreeSet<u64>,
87    /// Conditional-branch polarity (by condition class): signed (`JL/JLE/JG/JGE`),
88    /// unsigned (`JB/JBE/JA/JAE`), equality (`JE/JNE`). Other conditions
89    /// (`JP/JS/JO/...`) are not counted. Ground truth for the polarity gap:
90    /// the gate checks CFG *structure* but never `==` vs `!=` or signed vs
91    /// unsigned, so an inverted mirror passes. These counts let ports and
92    /// tests pin which class the binary actually uses.
93    pub signed_branches: u32,
94    pub unsigned_branches: u32,
95    pub equality_branches: u32,
96    /// Highest instruction-end VA reached — the function's apparent extent.
97    pub max_end: u64,
98    pub decode_errors: u32,
99    /// First few decode failures as `(ip, raw_bytes)`. Decoders hitting
100    /// invalid opcodes is normal at padding/alignment/data boundaries, but a
101    /// long run inside what is supposed to be a function usually means the
102    /// entry VA is wrong, or the function crosses into embedded data. The
103    /// hex bytes here are enough to tell which.
104    pub decode_error_samples: Vec<(u64, Vec<u8>)>,
105}
106
107impl CallGraph {
108    pub fn call_targets(&self) -> BTreeSet<u64> {
109        self.direct_calls.keys().copied().collect()
110    }
111
112    /// Every in-image address this function references — call targets, tail-call
113    /// JMPs, IAT slots, global data refs and baked immediates (vtable VAs / fn
114    /// pointers / string addresses). Used to detect inlining: if callee C's whole
115    /// footprint also appears in caller F's footprint, the binary pasted C's body
116    /// into F, so a Rust call F→C is faithful (un-inlined), not hallucinated.
117    pub fn footprint(&self) -> BTreeSet<u64> {
118        let mut s: BTreeSet<u64> = self.direct_calls.keys().copied().collect();
119        s.extend(&self.tail_calls);
120        s.extend(&self.iat_calls);
121        s.extend(&self.globals);
122        s.extend(&self.imm_addrs);
123        s
124    }
125}
126
127/// Harvest every memory/immediate operand fact from one decoded instruction.
128fn scan_operands(pe: &Pe, instr: &Instruction, cg: &mut CallGraph) {
129    for i in 0..instr.op_count() {
130        match instr.op_kind(i) {
131            OpKind::Memory => {
132                let base = instr.memory_base();
133                let index = instr.memory_index();
134                let disp = instr.memory_displacement64();
135                if base == Register::None && index == Register::None {
136                    // absolute `[0x...]` — a global/static data reference.
137                    // Skip LEA: it computes an address without dereferencing, so
138                    // the operand isn't a memory reference. (BUG #14)
139                    if pe.section_of(disp).is_some() && instr.mnemonic() != Mnemonic::Lea {
140                        cg.globals.insert(disp);
141                    }
142                } else if pe.section_of(disp).is_some() && instr.mnemonic() != Mnemonic::Lea {
143                    // Indexed `[base + index*scale + disp]` (or `[reg+disp]`)
144                    // off a large in-image displacement — a table base, not a
145                    // struct field (e.g. `[eax*4+0x18F01]` in a real LE binary). Record it
146                    // as a global so bodies may bake the true address without
147                    // tripping HALLUCINATED-ADDR. Small offsets miss
148                    // `section_of` and fall through to the field branch below;
149                    // LEA never dereferences (BUG #14), so it stays excluded.
150                    cg.globals.insert(disp);
151                } else if base != Register::None
152                    && base != Register::ESP
153                    && base != Register::EBP
154                    && index == Register::None
155                    && disp > 0
156                    && disp < MAX_FIELD_OFFSET
157                    // LEA computes an address (pointer math / &field), it doesn't
158                    // dereference a struct field — skip it to avoid e.g. the
159                    // `LEA EDX,[EAX+1]` of a strlen loop looking like field 0x1.
160                    && instr.mnemonic() != Mnemonic::Lea
161                {
162                    // `[reg+0xNN]` off a non-stack base — a struct field access.
163                    cg.field_offsets.insert(disp);
164                }
165            }
166            OpKind::Immediate8
167            | OpKind::Immediate8to16
168            | OpKind::Immediate8to32
169            | OpKind::Immediate16
170            | OpKind::Immediate32 => {
171                let v = instr.immediate(i);
172                // In-image immediate = a baked address (vtable VA, fn ptr, string).
173                if pe.section_of(v).is_some() {
174                    cg.imm_addrs.insert(v);
175                }
176            }
177            _ => {}
178        }
179    }
180}
181
182/// Classify operand `i` of `instr` as an argument kind (immediate / field / global / other).
183fn classify_operand(pe: &Pe, instr: &Instruction, i: u32) -> ArgKind {
184    match instr.op_kind(i) {
185        OpKind::Immediate8
186        | OpKind::Immediate8to16
187        | OpKind::Immediate8to32
188        | OpKind::Immediate16
189        | OpKind::Immediate32 => ArgKind::Imm(instr.immediate(i)),
190        OpKind::Memory => {
191            let base = instr.memory_base();
192            let index = instr.memory_index();
193            let disp = instr.memory_displacement64();
194            if base == Register::None && index == Register::None {
195                if pe.section_of(disp).is_some() {
196                    ArgKind::Global(disp)
197                } else {
198                    ArgKind::Other
199                }
200            } else if base != Register::ESP
201                && base != Register::EBP
202                && base != Register::None
203                && index == Register::None
204                && disp > 0
205                && disp < MAX_FIELD_OFFSET
206            {
207                ArgKind::Field(disp)
208            } else {
209                ArgKind::Other
210            }
211        }
212        _ => ArgKind::Other,
213    }
214}
215
216/// Classify the *source* of a register-setup instruction (`MOV reg,src` / `LEA reg,src` /
217/// `XOR reg,reg`). Returns the arg kind the register holds going into the following `CALL`.
218fn classify_reg_src(pe: &Pe, instr: &Instruction) -> ArgKind {
219    match instr.mnemonic() {
220        Mnemonic::Lea => {
221            let base = instr.memory_base();
222            if base == Register::EBP || base == Register::ESP {
223                ArgKind::LocalAddr
224            } else {
225                ArgKind::Other // lea of a field address — not distinguished in v1
226            }
227        }
228        Mnemonic::Xor => ArgKind::Imm(0), // only called when op0==op1 (zero idiom)
229        Mnemonic::Mov | Mnemonic::Movzx | Mnemonic::Movsx => classify_operand(pe, instr, 1),
230        _ => ArgKind::Other,
231    }
232}
233
234/// True if `instr` writes register `reg` as its destination (op0) via a recognized setup form.
235fn writes_reg(instr: &Instruction, reg: Register) -> bool {
236    if instr.op0_kind() != OpKind::Register || instr.op0_register() != reg {
237        return false;
238    }
239    match instr.mnemonic() {
240        Mnemonic::Mov | Mnemonic::Lea | Mnemonic::Movzx | Mnemonic::Movsx => true,
241        // XOR reg,reg is the zero idiom only when both operands are the same register.
242        Mnemonic::Xor => instr.op1_kind() == OpKind::Register && instr.op1_register() == reg,
243        _ => false,
244    }
245}
246
247/// Recover the argument shape of a `CALL` from the instructions preceding it in its basic block
248/// (`buf` excludes the CALL itself). Scans backward: the first `PUSH`es encountered are the
249/// left-to-right stack args (cdecl/stdcall push right-to-left, so backward order = source order),
250/// and the first writes to ECX/EDX are the fastcall/thiscall register args.
251pub(crate) fn recover_call_site(pe: &Pe, buf: &[Instruction], target: u64) -> CallSite {
252    let mut cs = CallSite {
253        target,
254        ..Default::default()
255    };
256    for instr in buf.iter().rev().take(64) {
257        // Stop at a prologue / frame boundary: an explicit ESP adjust (`sub esp,N`) or any
258        // FS-relative access (SEH frame set-up). Argument pushes never precede these, so this
259        // keeps callee-saved / SEH-cookie pushes out of the recovered argument list.
260        if is_frame_boundary(instr) {
261            break;
262        }
263        if instr.mnemonic() == Mnemonic::Push {
264            cs.stack.push(classify_operand(pe, instr, 0));
265        } else if cs.ecx.is_none() && writes_reg(instr, Register::ECX) {
266            cs.ecx = Some(classify_reg_src(pe, instr));
267        } else if cs.edx.is_none() && writes_reg(instr, Register::EDX) {
268            cs.edx = Some(classify_reg_src(pe, instr));
269        }
270    }
271    cs
272}
273
274/// A prologue / stack-frame boundary that argument set-up never precedes: an `ADD/SUB ESP,imm`
275/// or any instruction touching FS-relative memory (the SEH frame).
276fn is_frame_boundary(instr: &Instruction) -> bool {
277    if matches!(instr.mnemonic(), Mnemonic::Add | Mnemonic::Sub)
278        && instr.op0_kind() == OpKind::Register
279        && instr.op0_register() == Register::ESP
280    {
281        return true;
282    }
283    (0..instr.op_count())
284        .any(|i| instr.op_kind(i) == OpKind::Memory && instr.memory_segment() == Register::FS)
285}
286
287/// If `addr` is a one-instruction thunk (`JMP target`), return `target`. Callers often invoke a
288/// function through such a thunk (a tail-jump stub, or `/OPT:ICF`-folded forwarder), so the byte
289/// call edge points at the thunk, not the real callee — following it one level lets the call-graph
290/// check see the true target.
291pub fn resolve_thunk(pe: &Pe, addr: u64) -> Option<u64> {
292    let bytes = pe.read(addr, 16)?;
293    let mut dec = Decoder::with_ip(32, bytes, addr, DecoderOptions::NONE);
294    if !dec.can_decode() {
295        return None;
296    }
297    let instr = dec.decode();
298    if instr.flow_control() == FlowControl::UnconditionalBranch {
299        let t = instr.near_branch_target();
300        if t != 0 {
301            return Some(t);
302        }
303    }
304    None
305}
306
307/// Does `instr` *read* `reg` (as a source register or a memory base/index)? Excludes the
308/// destination register operand (op0) so a pure write like `MOV ECX,EAX` doesn't count as a read.
309fn reads_reg(instr: &Instruction, reg: Register) -> bool {
310    for i in 0..instr.op_count() {
311        match instr.op_kind(i) {
312            OpKind::Memory => {
313                if instr.memory_base() == reg || instr.memory_index() == reg {
314                    return true;
315                }
316            }
317            OpKind::Register if i > 0 && instr.op_register(i) == reg => {
318                return true;
319            }
320            _ => {}
321        }
322    }
323    false
324}
325
326/// Heuristically determine whether the function at `addr` takes register parameters in ECX / EDX
327/// (i.e. is thiscall/fastcall). Scans the entry block for a READ of the register before any write:
328/// thiscall reads ECX (the `this`), fastcall also reads EDX, cdecl/stdcall read neither. Used to
329/// align a caller's recovered register args to the callee's actual parameters (so a stray EDX in a
330/// caller is not mistaken for an argument to a thiscall callee). Conservative: under-detection just
331/// means a position is skipped, never a false mismatch.
332pub fn reg_params(pe: &Pe, addr: u64) -> (bool, bool) {
333    let Some(bytes) = pe.read(addr, 32 * 32) else {
334        return (false, false);
335    };
336    let mut dec = Decoder::with_ip(32, bytes, addr, DecoderOptions::NONE);
337    let mut instr = Instruction::default();
338    let (mut ecx_written, mut edx_written) = (false, false);
339    let (mut ecx_param, mut edx_param) = (false, false);
340    let mut n = 0;
341    while dec.can_decode() && n < 24 {
342        dec.decode_out(&mut instr);
343        if instr.is_invalid() {
344            break;
345        }
346        if !ecx_written && reads_reg(&instr, Register::ECX) {
347            ecx_param = true;
348        }
349        if !edx_written && reads_reg(&instr, Register::EDX) {
350            edx_param = true;
351        }
352        if instr.op0_kind() == OpKind::Register {
353            match instr.op0_register() {
354                Register::ECX => ecx_written = true,
355                Register::EDX => edx_written = true,
356                _ => {}
357            }
358        }
359        // A leaf that immediately forwards its incoming registers to another call is still
360        // thiscall/fastcall even though iced-x86 cannot represent the implicit register use on
361        // the CALL itself. Preserve untouched ECX/EDX on the first instruction so constructors
362        // such as BSScissorTriShape (which forwards both registers to its base ctor) align stack
363        // arguments correctly. Later calls retain the caller-saved rule below.
364        if n == 0
365            && matches!(instr.flow_control(), FlowControl::Call)
366            && instr.near_branch_target() != instr.next_ip()
367        {
368            if !ecx_written {
369                ecx_param = true;
370            }
371            if !edx_written {
372                edx_param = true;
373            }
374        }
375        // Per the x86 cdecl/fastcall/thiscall conventions, ECX and EDX are caller-saved:
376        // any CALL in the entry block clobbers them, so a later READ of ECX/EDX is not
377        // evidence that the callee takes that register as a parameter. Mark them written
378        // across the CALL — without this, a stray `MOV EAX, [ECX]` after a prior CALL
379        // would falsely set `ecx_param = true` and pollute ARG-SHAPE diffs.
380        if matches!(instr.flow_control(), FlowControl::Call) {
381            ecx_written = true;
382            edx_written = true;
383        }
384        if !matches!(instr.flow_control(), FlowControl::Next | FlowControl::Call) {
385            break;
386        }
387        n += 1;
388    }
389    (ecx_param, edx_param)
390}
391
392/// Walk the function at `entry` and return its facts, sourced purely from bytes.
393pub fn analyze(pe: &Pe, entry: u64) -> Result<CallGraph, String> {
394    analyze_opt(pe, entry, true)
395}
396
397/// [`analyze`] with control over LE jump-table translation. With
398/// `translate_le = false` a table's pre-fixup base/entries are read raw
399/// (the pre-0.1.8 behaviour: LE arms are invisible). The gate uses both:
400/// the `false` set drives `MISSING` (no new demands), the `true` set drives
401/// acceptance (spelling an arm is faithful).
402pub fn analyze_opt(pe: &Pe, entry: u64, translate_le: bool) -> Result<CallGraph, String> {
403    // The macro asks for the same entry through several checks (call diff,
404    // operand facts, tail-call expansion, and inlining detection). Keep the
405    // decoded result per loaded image and mode so those consumers share the
406    // expensive recursive-descent walk. Pe IDs are assigned at parse time,
407    // so same-layout binaries with different bytes cannot alias.
408    static CACHE: OnceLock<AnalyzeCache> = OnceLock::new();
409    let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
410    let key = (pe.id(), entry, translate_le);
411    if let Some(cached) = cache
412        .lock()
413        .unwrap_or_else(|e| e.into_inner())
414        .get(&key)
415        .cloned()
416    {
417        return (*cached).clone();
418    }
419
420    let result = analyze_uncached(pe, entry, translate_le);
421    let mut entries = cache.lock().unwrap_or_else(|e| e.into_inner());
422    // This is a process-local build cache, not a general purpose disassembly
423    // database. Bound it for long-lived library users that analyze many
424    // unrelated images/functions.
425    if entries.len() >= 8192 {
426        entries.clear();
427    }
428    entries.insert(key, Arc::new(result.clone()));
429    result
430}
431
432fn analyze_uncached(pe: &Pe, entry: u64, translate_le: bool) -> Result<CallGraph, String> {
433    if !pe.is_executable_va(entry) {
434        return Err(format!(
435            "{entry:#010x} is not inside any PE section of this image\n{}",
436            pe.describe_sections(entry)
437        ));
438    }
439    let mut cg = CallGraph {
440        entry,
441        ..Default::default()
442    };
443    let lo = entry;
444    let hi = entry.saturating_add(MAX_FN_SPAN);
445
446    let mut work = vec![entry];
447    let mut seen: BTreeSet<u64> = BTreeSet::new();
448    // Per-branch-target seed: when we encounter `Jcc target` / `JMP target` /
449    // indirect-jump case, the args set up before the branch (PUSHes, MOV-to-ECX,
450    // etc.) also apply to the target block's first CALL — the predecessor and
451    // target share the same register/stack state at the branch instruction. The
452    // outer loop resets `block_buf` when it pops a new `start`, so without this
453    // seed map the target's calls would be processed with an empty `block_buf`
454    // and `recover_call_site` would undercount arguments. (BUG #8 fix.)
455    //
456    // Multiple predecessors may push the same target — `entry().or_insert_with`
457    // keeps the FIRST push, since the args are equivalent along either path up
458    // to the branch instruction itself (the differing branches only affect the
459    // value of flags, which `recover_call_site` doesn't read).
460    let mut branch_seed: std::collections::HashMap<u64, Vec<Instruction>> =
461        std::collections::HashMap::new();
462
463    while let Some(start) = work.pop() {
464        if !seen.insert(start) {
465            continue;
466        }
467        let Some(bytes) = pe.read(start, 32 * 64) else {
468            continue;
469        };
470        let mut dec = Decoder::with_ip(32, bytes, start, DecoderOptions::NONE);
471        let mut instr = Instruction::default();
472        // Straight-line instruction buffer for the current block, used to recover the argument
473        // set-up preceding each CALL. Reset at block boundaries (branches) and after each call.
474        // For non-entry `start`s, seed from the predecessor branch (see `branch_seed`).
475        let mut block_buf: Vec<Instruction> = branch_seed.remove(&start).unwrap_or_default();
476
477        loop {
478            if !dec.can_decode() {
479                break;
480            }
481            let ip = dec.ip();
482            if ip != start && seen.contains(&ip) {
483                break; // block already covered by another path
484            }
485            if ip > start {
486                seen.insert(ip);
487            }
488            dec.decode_out(&mut instr);
489            if instr.is_invalid() {
490                cg.decode_errors += 1;
491                // Keep up to 5 samples so the user can tell at a glance
492                // whether the failures are padding (single 0x00/0xCC/0x90),
493                // an aligned jump table (4 distinct bytes), or something
494                // structural (random bytes that decode to nothing — entry
495                // VA is probably wrong).
496                if cg.decode_error_samples.len() < 5 {
497                    let bytes = pe.read(ip, 8).unwrap_or(&[]).to_vec();
498                    cg.decode_error_samples.push((ip, bytes));
499                }
500                break;
501            }
502            cg.max_end = cg.max_end.max(instr.next_ip());
503            scan_operands(pe, &instr, &mut cg);
504            block_buf.push(instr);
505
506            match instr.flow_control() {
507                FlowControl::Call => {
508                    let t = instr.near_branch_target();
509                    // `call $+5` (rel32 == 0, target == next_ip) is either a
510                    // genuine get-PC/fall-through or a load-time relocation
511                    // site (LE 08 self-relative fixup) whose on-disk field is
512                    // a zero placeholder. Prefer the fixup's runtime target
513                    // (operand sits at ip+1 for `E8 rel32`).
514                    let fixed = if t == instr.next_ip() {
515                        pe.fixup_rel32_target(instr.ip() + 1)
516                    } else {
517                        None
518                    };
519                    let dest = match fixed {
520                        Some(target) => Some(target),
521                        None if t != 0 && t != instr.next_ip() => Some(t),
522                        None => None,
523                    };
524                    if let Some(target) = dest {
525                        if target != 0 {
526                            *cg.direct_calls.entry(target).or_insert(0) += 1;
527                            let prior = &block_buf[..block_buf.len() - 1]; // exclude the CALL itself
528                            let cs = recover_call_site(pe, prior, target);
529                            cg.call_sites.push(cs);
530                        }
531                    }
532                    block_buf.clear(); // args after a call start fresh
533                }
534                FlowControl::IndirectCall => {
535                    cg.indirect_calls += 1;
536                    record_indirect_target(&instr, pe, &mut cg);
537                    block_buf.clear(); // an indirect call's args must not leak into the next call
538                }
539                FlowControl::ConditionalBranch => {
540                    // Polarity ground truth (one count per branch instruction;
541                    // each ip is visited once via `seen`, so no double-count).
542                    match instr.mnemonic() {
543                        Mnemonic::Jl | Mnemonic::Jle | Mnemonic::Jg | Mnemonic::Jge => {
544                            cg.signed_branches += 1;
545                        }
546                        Mnemonic::Jb | Mnemonic::Jbe | Mnemonic::Ja | Mnemonic::Jae => {
547                            cg.unsigned_branches += 1;
548                        }
549                        Mnemonic::Je | Mnemonic::Jne => {
550                            cg.equality_branches += 1;
551                        }
552                        _ => {}
553                    }
554                    let t = instr.near_branch_target();
555                    if (lo..hi).contains(&t) {
556                        branch_seed.entry(t).or_insert_with(|| block_buf.clone());
557                        work.push(t);
558                    } else {
559                        cg.tail_calls.insert(t);
560                    }
561                    // Don't clear `block_buf` here: the args set up before the
562                    // conditional apply to BOTH the taken and the fallthrough
563                    // paths (they share register/stack state at the branch).
564                    // Clearing would make the fallthrough's first CALL recover
565                    // an empty argument set. The buffer is reset by the next
566                    // CALL we encounter (Call/IndirectCall arms above clear
567                    // post-call) or by an explicit boundary.
568                }
569                FlowControl::UnconditionalBranch => {
570                    let t = instr.near_branch_target();
571                    if (lo..hi).contains(&t) {
572                        // Single-JMP entry stub: a function whose entire
573                        // entry block is one unconditional JMP (sibling-call
574                        // `JMP tail_target`, ICF forwarder, `jmp` over no
575                        // bytes) transfers control elsewhere instead of
576                        // containing its own body. Following it would merge
577                        // the callee's body (and globals) into the caller,
578                        // hiding the tail edge the gate checks. Scoped to
579                        // exactly one instruction so a `JMP` that skips an
580                        // inline jump table after a prologue (multi-instr
581                        // entry block) keeps the follow behavior below.
582                        if start == lo && block_buf.len() == 1 {
583                            cg.tail_calls.insert(t);
584                        } else {
585                            branch_seed.entry(t).or_insert_with(|| block_buf.clone());
586                            work.push(t);
587                        }
588                    } else {
589                        cg.tail_calls.insert(t);
590                    }
591                    break;
592                }
593                FlowControl::IndirectBranch => {
594                    cg.indirect_jumps += 1;
595                    record_indirect_target(&instr, pe, &mut cg);
596                    let mut cases: Vec<u64> = Vec::new();
597                    // Indirect-jump target recovery, by operand shape:
598                    // * SIB-indexed `[base+idx*s+TABLE]` (MSVC-style): the
599                    //   existing follow_jump_table read below. Unchanged.
600                    // * Unindexed `[reg+TABLE]` with NO base register, i.e.
601                    //   an absolute `[disp]` (IAT thunks, import jumps):
602                    //   NEVER followed. The disp names data, not code, and
603                    //   the fallthrough past a non-returning jump is dead
604                    //   code -- decoding it attributes neighboring
605                    //   functions' calls here (verified: 9KB/20-call blowup
606                    //   on a real PE at 0x006ff480, whose IAT tail-jmps sit
607                    //   after cmp/jcc).
608                    // * Unindexed `[reg+TABLE]` WITH a base register: Watcom
609                    //   switch lowering (`shl reg,2` then `jmp [reg+TABLE]`,
610                    //   e.g. an LE binary at 0x602c0). The table entries are
611                    //   pre-fixup bytes on LE, so the read below yields
612                    //   nothing; instead continue decoding at the fallthrough
613                    //   address -- the case bodies tile the bytes right after
614                    //   the dispatch (verified: 602c0's five hidden 7d6ae
615                    //   calls, previously mis-reported as HALLUCINATED).
616                    //   Gated on switch evidence (a compare/test plus a
617                    //   conditional branch in this block) so vtable tail-jumps
618                    //   like `jmp [ecx+0x44]` keep today's behavior.
619                    let has_index = instr.memory_index() != Register::None;
620                    // The base must be produced locally (e.g. the scaled
621                    // index): an incoming register like a vtable `this`
622                    // is never a switch table.
623                    let mut base_written = false;
624                    if instr.memory_base() != Register::None {
625                        // CMP/TEST only write flags, not their operands --
626                        // without this carve-out the evidence check itself
627                        // (`cmp reg,...`) would trivially satisfy "written".
628                        for b in block_buf.iter() {
629                            match b.mnemonic() {
630                                Mnemonic::Cmp | Mnemonic::Test => continue,
631                                _ => {}
632                            }
633                            if b.op0_kind() == OpKind::Register
634                                && b.op0_register() == instr.memory_base()
635                            {
636                                base_written = true;
637                                break;
638                            }
639                        }
640                    }
641                    let unindexed_switch =
642                        !has_index && base_written && is_switch_dispatch(&block_buf);
643                    follow_jump_table(
644                        &instr,
645                        pe,
646                        lo,
647                        hi,
648                        unindexed_switch,
649                        translate_le,
650                        &mut cases,
651                    );
652                    // GCC -O0 switch lowering keeps the scale in a separate
653                    // `shl`, adds the table base as an immediate, loads the
654                    // entry, then jumps through a register:
655                    //   shl $2,%eax; add $TABLE,%eax; mov (%eax),%eax; jmp *%eax
656                    // The JMP itself has no memory operand, so the readers
657                    // above see nothing (MinGW `c_callgraph_od` classify:
658                    // 1 indirect jump, 0 cases, 0 calls). Recover the table
659                    // base from the `add` and read it like any other table,
660                    // gated on the same switch evidence so a vtable
661                    // `mov eax,[ecx+slot]; jmp eax` (no bound check, `this`
662                    // never `add`ed with an in-image base) stays untouched.
663                    if cases.is_empty()
664                        && instr.op_count() > 0
665                        && instr.op_kind(0) == OpKind::Register
666                        && is_switch_dispatch(&block_buf)
667                    {
668                        if let Some(mut table) =
669                            register_table_base(&block_buf, pe, instr.op_register(0))
670                        {
671                            // LE bases are pre-fixup too; translate only when
672                            // the raw base is not already a valid section VA.
673                            if translate_le && table != 0 && pe.section_of(table).is_none() {
674                                if let Some(post) = pe.fixup_post_image(table) {
675                                    if pe.section_of(post).is_some() {
676                                        table = post;
677                                    }
678                                }
679                            }
680                            read_table_entries(pe, table, lo, hi, translate_le, &mut cases);
681                        }
682                    }
683                    for c in &cases {
684                        branch_seed.entry(*c).or_insert_with(|| block_buf.clone());
685                        work.push(*c);
686                    }
687                    if unindexed_switch {
688                        let ft = instr.next_ip();
689                        if (lo..hi).contains(&ft) {
690                            branch_seed.entry(ft).or_insert_with(|| block_buf.clone());
691                            work.push(ft);
692                        }
693                    }
694                    break;
695                }
696                FlowControl::Return | FlowControl::Exception => break,
697                // Most `int n` instructions are returning DOS/DPMI API calls,
698                // so keep decoding at next_ip(). DOS INT 21h/AH=4Ch terminates
699                // the process, though; following that fallthrough would merge
700                // the next adjacent function into this one.
701                FlowControl::Interrupt => {
702                    let dos_terminate = instr.op_count() == 1
703                        && instr.op0_kind() == OpKind::Immediate8
704                        && instr.immediate8() == 0x21
705                        && block_buf
706                            .get(block_buf.len().saturating_sub(2))
707                            .is_some_and(|prior| {
708                                prior.mnemonic() == Mnemonic::Mov
709                                    && prior.op0_kind() == OpKind::Register
710                                    && prior.op0_register() == Register::AX
711                                    && prior.op1_kind() == OpKind::Immediate16
712                                    && prior.immediate16() & 0xff00 == 0x4c00
713                            });
714                    if dos_terminate {
715                        break;
716                    }
717                }
718                FlowControl::Next | FlowControl::XbeginXabortXend => {}
719            }
720        }
721    }
722    Ok(cg)
723}
724
725/// Addresses a tail-call target references (its globals, baked immediates,
726/// and IAT slots), unioned over every `JMP`-out edge of `cg`.
727///
728/// A tail-call `JMP G` transfers control to G, so G's data references execute
729/// as part of THIS function. The call-graph diff (`diff_body_calls`) already
730/// counts a tail target's *callees* as acceptable spellings; this is the
731/// address-side mirror for the HALLUCINATED-ADDR check: a port that
732/// duplicates a shared tail body (e.g. a predicate tail-jumped into from two
733/// origins) bakes the tail's addresses faithfully. Only ever ADDS acceptance
734/// — a target that fails to analyze is skipped, never an error.
735pub fn tail_referenced(pe: &Pe, cg: &CallGraph) -> BTreeSet<u64> {
736    let mut out = BTreeSet::new();
737    for t in &cg.tail_calls {
738        if let Ok(tcg) = analyze(pe, *t) {
739            out.extend(&tcg.globals);
740            out.extend(&tcg.imm_addrs);
741            out.extend(&tcg.iat_calls);
742        }
743    }
744    out
745}
746
747/// Follow an MSVC-style `switch` jump table: `JMP dword ptr [idx*4 + TABLE_VA]`.
748/// `TABLE_VA` (the operand displacement, an in-image .rdata address) holds an
749/// array of u32 case addresses. We read entries until one falls outside the
750/// function `[lo, hi)` span -- switch cases always point back into the function,
751/// so that's a reliable terminator. Recovers the case bodies (and their `CALL`s)
752/// that a plain recursive descent, unable to follow an indirect jump, would miss.
753///
754/// `allow_unindexed` covers Watcom-style lowering where the scale lives in a
755/// separate `shl` and the dispatch is a plain `[reg+TABLE]` with no SIB index
756/// (e.g. an LE binary at `0x602c0`: `jmp dword ptr cs:[eax+0x502D8]`). Only set it
757/// when the block already looks like a switch (see `is_switch_dispatch`):
758/// without that evidence a vtable tail-jump would read arbitrary words as
759/// "cases". On LE binaries the table entries are pre-fixup bytes, so the
760/// read yields no in-range targets and the fallthrough arm below carries
761/// the recovery instead.
762pub(crate) fn follow_jump_table(
763    instr: &Instruction,
764    pe: &Pe,
765    lo: u64,
766    hi: u64,
767    allow_unindexed: bool,
768    translate_le: bool,
769    work: &mut Vec<u64>,
770) {
771    for i in 0..instr.op_count() {
772        if instr.op_kind(i) != OpKind::Memory {
773            continue;
774        }
775        let mut disp = instr.memory_displacement64();
776        // LE stores the table base pre-fixup (e.g. `jmp [ecx*4+0x549A9]`);
777        // translate it to the runtime VA before the section check and the
778        // page-map read. A LE absolute operand is always a link-time value, so
779        // a fixup-site displacement is translated whenever its post image is a
780        // valid section VA (a `0` terminator/offset is excluded). PE has no
781        // fixup tables, so `fixup_post_image` is None and this is a no-op.
782        if translate_le && disp != 0 {
783            if let Some(post) = pe.fixup_post_image(disp) {
784                if pe.section_of(post).is_some() {
785                    disp = post;
786                }
787            }
788        }
789        if instr.memory_index() == Register::None && !allow_unindexed {
790            return;
791        }
792        if pe.section_of(disp).is_none() {
793            return;
794        }
795        read_table_entries(pe, disp, lo, hi, translate_le, work);
796        return;
797    }
798}
799
800/// Read a u32 VA jump table at `table_va`, pushing entries while they point
801/// back into the function `[lo, hi)` (the terminator shared by every table
802/// shape: switch cases always target the function's own bodies).
803fn read_table_entries(
804    pe: &Pe,
805    table_va: u64,
806    lo: u64,
807    hi: u64,
808    translate_le: bool,
809    work: &mut Vec<u64>,
810) {
811    let mut k = 0u64;
812    while k < 1024 {
813        let Some(b) = pe.read(table_va + k * 4, 4) else {
814            break;
815        };
816        if b.len() < 4 {
817            break;
818        }
819        let raw = u32::from_le_bytes([b[0], b[1], b[2], b[3]]) as u64;
820        if raw == 0 {
821            break; // zero terminator (and pre=0 fixup sites are not cases)
822        }
823        // LE table entries are pre-fixup case addresses; translate each to
824        // its runtime VA *only* when the raw value is out of the function
825        // span and the post image is inside it. In-span entries (PE, or
826        // already-post values) pass through unchanged.
827        let mut target = raw;
828        if translate_le && !(lo..hi).contains(&target) {
829            if let Some(post) = pe.fixup_post_image(raw) {
830                if (lo..hi).contains(&post) {
831                    target = post;
832                }
833            }
834        }
835        if !(lo..hi).contains(&target) {
836            break; // first entry outside the function ends the table
837        }
838        work.push(target);
839        k += 1;
840    }
841}
842
843/// Table base for a register-indirect switch dispatch (`jmp *%reg`).
844/// Finds the entry load `mov %reg,[%base]` nearest the jump, then the
845/// `add %base,$TABLE` / `lea %base,[...]` / `mov %base,$TABLE` that
846/// established it with an in-image table address. Returns `None` when the
847/// jump register comes straight from memory with no locally-built table
848/// (e.g. a vtable `mov %eax,[%ecx+slot]; jmp *%eax`, whose `%ecx` is an
849/// incoming `this` never `add`ed with an in-image base).
850fn register_table_base(block: &[Instruction], pe: &Pe, jump_reg: Register) -> Option<u64> {
851    // Nearest load into the jump register: `mov %reg,[%base]`.
852    let mut load_base: Option<Register> = None;
853    for b in block.iter().rev() {
854        if matches!(
855            b.mnemonic(),
856            Mnemonic::Mov | Mnemonic::Movzx | Mnemonic::Movsx
857        ) && b.op0_kind() == OpKind::Register
858            && b.op0_register() == jump_reg
859            && b.op1_kind() == OpKind::Memory
860        {
861            load_base = Some(b.memory_base());
862            break;
863        }
864    }
865    let base = load_base?;
866    // The table address built into the base register further up.
867    for b in block.iter().rev() {
868        match b.mnemonic() {
869            Mnemonic::Add => {
870                if b.op0_kind() == OpKind::Register && b.op0_register() == base {
871                    for i in 0..b.op_count() {
872                        if matches!(
873                            b.op_kind(i),
874                            OpKind::Immediate8
875                                | OpKind::Immediate8to16
876                                | OpKind::Immediate8to32
877                                | OpKind::Immediate16
878                                | OpKind::Immediate32
879                        ) && pe.section_of(b.immediate(i)).is_some()
880                        {
881                            return Some(b.immediate(i));
882                        }
883                    }
884                }
885            }
886            Mnemonic::Lea => {
887                if b.op0_kind() == OpKind::Register
888                    && b.op0_register() == base
889                    && b.op1_kind() == OpKind::Memory
890                    && pe.section_of(b.memory_displacement64()).is_some()
891                {
892                    return Some(b.memory_displacement64());
893                }
894            }
895            Mnemonic::Mov | Mnemonic::Movzx | Mnemonic::Movsx
896                if b.op0_kind() == OpKind::Register && b.op0_register() == base =>
897            {
898                for i in 0..b.op_count() {
899                    if matches!(
900                        b.op_kind(i),
901                        OpKind::Immediate8
902                            | OpKind::Immediate8to16
903                            | OpKind::Immediate8to32
904                            | OpKind::Immediate16
905                            | OpKind::Immediate32
906                    ) && pe.section_of(b.immediate(i)).is_some()
907                    {
908                        return Some(b.immediate(i));
909                    }
910                }
911            }
912            _ => {}
913        }
914    }
915    None
916}
917
918/// Switch-dispatch evidence: a compare/test plus a conditional branch in
919/// the current straight-line block (e.g. `cmp edx,6; ja default` ahead of
920/// `jmp [reg+TABLE]`). Used to license fallthrough decoding past an
921/// indirect jump only where a switch table is plausible, so vtable
922/// tail-jumps (no bound check) never gain fallthrough coverage.
923fn is_switch_dispatch(block: &[Instruction]) -> bool {
924    let mut has_cmp = false;
925    let mut has_jcc = false;
926    for i in block {
927        match i.mnemonic() {
928            Mnemonic::Cmp | Mnemonic::Test => has_cmp = true,
929            _ => {}
930        }
931        if matches!(i.flow_control(), FlowControl::ConditionalBranch) {
932            has_jcc = true;
933        }
934    }
935    has_cmp && has_jcc
936}
937
938/// Classify an indirect `CALL`/`JMP [...]` target: `[reg+disp]` is a vtable slot
939/// (record the displacement); `[abs]` in the IAT range is an import (record addr).
940fn record_indirect_target(instr: &Instruction, pe: &Pe, cg: &mut CallGraph) {
941    // The branch target operand is the (single) memory operand, if any.
942    for i in 0..instr.op_count() {
943        if instr.op_kind(i) == OpKind::Memory {
944            let base = instr.memory_base();
945            let disp = instr.memory_displacement64();
946            if base == Register::None && instr.memory_index() == Register::None {
947                if pe.section_of(disp).is_some() {
948                    cg.iat_calls.insert(disp); // CALL [0x00a28xxx]
949                }
950            } else if base != Register::None && disp < MAX_FIELD_OFFSET {
951                cg.vcall_slots.insert(disp); // CALL [reg+slot]
952            }
953            return;
954        }
955    }
956}